From 0dd5b56dfe6bfbf2c8bcb32a7b54013ffd6459f6 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 19 Oct 2026 12:17:59 +0000 Subject: [PATCH 001/199] Task panes: draw them with craft_ui::panes; Styles docks left or right or floats (#1756) The nine task panes (SmartArt Text Pane, Navigation, Clipboard, Styles, Style Inspector, Accessibility, Alt Text, Restrict Editing, Comments) are drawn by craft_ui::panes with the Columns policy: a title bar with a menu and close button, a resize edge that also takes arrow keys, and F6 to move between panes and the document. Panes open from the same view flags, in the same order and at the same default widths; close runs each pane's existing command. The Styles pane can dock left or right or float through the pane menu or the new command view.stylesPaneDock. ui.json keeps its position and float rect; unknown values read as the defaults. --- Cargo.lock | 2 +- crates/engine/src/cmd/view.rs | 14 ++ crates/engine/src/lib.rs | 3 +- crates/engine/src/session.rs | 37 ++++ crates/engine/src/tests.rs | 25 +++ crates/ui-egui/Cargo.toml | 2 +- crates/ui-egui/src/a11y_pane.rs | 8 - crates/ui-egui/src/i18n/bg.tsv | 8 + crates/ui-egui/src/i18n/cs.tsv | 8 + crates/ui-egui/src/i18n/de.tsv | 8 + crates/ui-egui/src/i18n/es.tsv | 8 + crates/ui-egui/src/i18n/et.tsv | 8 + crates/ui-egui/src/i18n/fr.tsv | 8 + crates/ui-egui/src/i18n/hr.tsv | 8 + crates/ui-egui/src/i18n/hu.tsv | 8 + crates/ui-egui/src/i18n/it.tsv | 8 + crates/ui-egui/src/i18n/ja.tsv | 8 + crates/ui-egui/src/i18n/ko.tsv | 8 + crates/ui-egui/src/i18n/pl.tsv | 8 + crates/ui-egui/src/i18n/pt-br.tsv | 8 + crates/ui-egui/src/i18n/ru.tsv | 8 + crates/ui-egui/src/i18n/sr-latn.tsv | 8 + crates/ui-egui/src/i18n/sr.tsv | 8 + crates/ui-egui/src/i18n/sv.tsv | 8 + crates/ui-egui/src/i18n/tr.tsv | 8 + crates/ui-egui/src/i18n/uk.tsv | 8 + crates/ui-egui/src/i18n/vi.tsv | 8 + crates/ui-egui/src/i18n/zh-hans.tsv | 8 + crates/ui-egui/src/i18n/zh-hant.tsv | 8 + crates/ui-egui/src/lib.rs | 90 ++++++++- crates/ui-egui/src/panes.rs | 289 +++++++++++++++++----------- 31 files changed, 522 insertions(+), 124 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8154b2041a..ded460c881 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -880,7 +880,7 @@ dependencies = [ [[package]] name = "craft-ui" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=d27646a5f7c8b4fc85d9dd3213b8c88b58e0088d#d27646a5f7c8b4fc85d9dd3213b8c88b58e0088d" +source = "git+https://github.com/aiongg/craft-libs?rev=c94ab4d313832ea9a84da721a6d3e7a7838dacef#c94ab4d313832ea9a84da721a6d3e7a7838dacef" dependencies = [ "egui", ] diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index ff29899133..eea11db594 100644 --- a/crates/engine/src/cmd/view.rs +++ b/crates/engine/src/cmd/view.rs @@ -91,6 +91,10 @@ pub fn specs() -> Vec { .params(r#"{"value"?: "light" | "dark" | "system"; without it, toggles light and dark}"#) .pure(), CommandSpec::new("view.stylesPane", "Styles Pane", "Home › Styles", |s, v| toggle(s, v, |x| &mut x.styles_pane)).pure(), + // Where the Styles pane sits: docked right or left, or floating (#1756). No `dock` reads it. + CommandSpec::new("view.stylesPaneDock", "Styles Pane Position", "Home › Styles", styles_pane_dock) + .params(r#"{"dock"?: "left" | "right" | "float"}"#) + .pure(), CommandSpec::new("view.commentsPane", "Comments Pane", "Review › Comments", |s, v| toggle(s, v, |x| &mut x.comments_pane)).pure(), CommandSpec::new("view.accessibilityPane", "Accessibility Pane", "Review › Accessibility", |s, v| { toggle(s, v, |x| &mut x.accessibility_pane) @@ -158,6 +162,16 @@ fn mode(s: &mut Session, m: ViewMode, read: bool) -> CmdResult { Ok(json!({"mode": m, "readMode": read})) } +fn styles_pane_dock(s: &mut Session, v: &Value) -> CmdResult { + if let Some(d) = v.get("dock") { + let Some(dock) = d.as_str().and_then(crate::StylesPaneDock::parse) else { + return Err(CmdError::Params("dock must be left, right or float".into())); + }; + s.view.styles_pane_dock = dock; + } + Ok(json!({"dock": s.view.styles_pane_dock.name()})) +} + fn read_column_width(s: &mut Session, v: &Value) -> CmdResult { let width = match v.get("value").and_then(Value::as_str) { Some("auto") => None, diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 0158a6054f..32e43e3f9f 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -28,7 +28,8 @@ pub use io::Password; pub use keymap::KeyMap; pub use ribbon::RibbonLayout; pub use session::{ - CellRange, ColumnBlock, EditSnapshot, FindState, MathEdit, Prefs, Selection, Session, UNDO_LIST_MAX, ViewState, blank_document, set_system_locale, + CellRange, ColumnBlock, EditSnapshot, FindState, MathEdit, Prefs, Selection, Session, StylesPaneDock, UNDO_LIST_MAX, ViewState, blank_document, + set_system_locale, }; pub use wordcraft_doc as doc; pub use wordcraft_geom::Region; diff --git a/crates/engine/src/session.rs b/crates/engine/src/session.rs index 671a53cb2b..93fe4fcffc 100644 --- a/crates/engine/src/session.rs +++ b/crates/engine/src/session.rs @@ -131,6 +131,10 @@ pub struct ViewState { pub table_gridlines: bool, pub nav_pane: bool, pub styles_pane: bool, + /// Where the Styles pane sits: docked right (the default) or left, or floating (#1756). + /// Kept in the user's preferences, not the document. + #[serde(default)] + pub styles_pane_dock: StylesPaneDock, /// Style Inspector pane (Home › Styles). #[serde(default)] pub style_inspector: bool, @@ -204,6 +208,38 @@ pub struct ViewState { pub full_screen: bool, } +/// Where the Styles pane sits (`view.stylesPaneDock`, #1756). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum StylesPaneDock { + #[default] + Right, + Left, + /// A window of its own over the document, which can be moved and resized. + #[serde(rename = "float")] + Floating, +} + +impl StylesPaneDock { + /// `left`, `right` or `float`; anything else is `None`. + pub fn parse(name: &str) -> Option { + match name { + "right" => Some(Self::Right), + "left" => Some(Self::Left), + "float" => Some(Self::Floating), + _ => None, + } + } + + pub fn name(self) -> &'static str { + match self { + Self::Right => "right", + Self::Left => "left", + Self::Floating => "float", + } + } +} + fn on() -> bool { true } @@ -240,6 +276,7 @@ impl Default for ViewState { table_gridlines: true, nav_pane: false, styles_pane: false, + styles_pane_dock: StylesPaneDock::Right, style_inspector: false, comments_pane: false, clipboard_pane: false, diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 4031e230a1..f45b1656cd 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -3882,6 +3882,31 @@ fn styles_apply_pane_is_not_an_edit() { assert!(s.view.styles_pane); } +/// #1756: the Styles pane docks on the right (the default), on the left, or floats. +#[test] +fn styles_pane_docks_left_right_or_floats() { + let mut s = s(); + run(&mut s, "text.insert", json!({"text": "Hello"})); + s.dirty = false; + let undo = s.undo_labels(); + assert_eq!(run(&mut s, "view.state", json!({}))["stylesPaneDock"], "right"); + assert_eq!(run(&mut s, "view.stylesPaneDock", json!({})), json!({"dock": "right"}), "no dock reads it"); + for dock in ["left", "float", "right"] { + assert_eq!(run(&mut s, "view.stylesPaneDock", json!({"dock": dock})), json!({"dock": dock})); + assert_eq!(run(&mut s, "view.stylesPaneDock", json!({})), json!({"dock": dock})); + assert_eq!(run(&mut s, "view.state", json!({}))["stylesPaneDock"], dock); + } + run(&mut s, "view.stylesPaneDock", json!({"dock": "left"})); + for junk in [json!({"dock": "top"}), json!({"dock": 1}), json!({"dock": null}), json!({"dock": ["left"]}), json!({"dock": "LEFT "})] { + assert!(s.run("view.stylesPaneDock", &junk).is_err(), "{junk} is an error"); + assert_eq!(run(&mut s, "view.stylesPaneDock", json!({})), json!({"dock": "left"}), "{junk} changes nothing"); + } + // A view setting: no undo step, the document isn't changed, and the pane's visibility stays. + assert_eq!(s.undo_labels(), undo); + assert!(!s.dirty); + assert!(!s.view.styles_pane); +} + #[test] fn styles_apply_rejects_bad_style_params() { let mut s = s(); diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 48ed412196..d7762b82c0 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -9,7 +9,7 @@ description = "WordCraft egui front end (Word-style ribbon UI); swappable: nothi workspace = true [dependencies] -craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "d27646a5f7c8b4fc85d9dd3213b8c88b58e0088d" } +craft-ui = { git = "https://github.com/aiongg/craft-libs", rev = "c94ab4d313832ea9a84da721a6d3e7a7838dacef" } wordcraft-geom = { workspace = true } wordcraft-doc = { workspace = true } unicode-bidi = { workspace = true } diff --git a/crates/ui-egui/src/a11y_pane.rs b/crates/ui-egui/src/a11y_pane.rs index 25d2c82c62..425af8c6ca 100644 --- a/crates/ui-egui/src/a11y_pane.rs +++ b/crates/ui-egui/src/a11y_pane.rs @@ -10,10 +10,6 @@ use crate::theme::{regular, semibold}; /// Review › Check Accessibility: the findings for the document as it is now. pub fn accessibility(app: &mut WordApp, ui: &mut Ui) { - if crate::panes::header(ui, "Accessibility") { - let _ = app.run("view.accessibilityPane", json!({"value": false})); - return; - } let issues = issues(app, ui); if issues.is_empty() { ui.label(egui::RichText::new(tl!("No accessibility issues found.")).weak()); @@ -90,10 +86,6 @@ fn go_to(app: &mut WordApp, issue: &Value) { /// Alt Text: the selected picture's, shape's, chart's or group's description and decorative mark, /// or the title and description of the table the caret is in. pub fn alt_text(app: &mut WordApp, ui: &mut Ui) { - if crate::panes::header(ui, "Alt Text") { - let _ = app.run("view.altTextPane", json!({"value": false})); - return; - } if let Some((pos, obj)) = wordcraft_engine::cmd::objects::selected(&app.session) { let decorative = obj.is_decorative(); ui.label(egui::RichText::new(tl!("Describe what this object shows, for people who can't see it.")).weak()); diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index af0a3a3144..f62fcc6591 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2687,3 +2687,11 @@ Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. Въведете размер в произволна единица, например 5 cm или 300 px. Другата страна се променя съответно, така че картината запазва пропорциите си. Import… Импортиране… +# Task panes and the Styles pane position (#1756) + Styles Pane Position Положение на панела със стилове + Dock Left Закачане вляво + Dock Right Закачане вдясно + Float Откачане + Pane Options Опции на панела + Move Pane Преместване на панела + Resize Pane Преоразмеряване на панела diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 041f03be1d..280765cc72 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2690,3 +2690,11 @@ preview Title Ukázka Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. Zadejte velikost v libovolné jednotce, například 5 cm nebo 300 px. Druhá strana se přizpůsobí, takže obrázek zachová své proporce. Import… Importovat… +# Task panes and the Styles pane position (#1756) + Styles Pane Position Umístění podokna stylů + Dock Left Ukotvit vlevo + Dock Right Ukotvit vpravo + Float Plovoucí + Pane Options Možnosti podokna + Move Pane Přesunout podokno + Resize Pane Změnit velikost podokna diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index eeb70c65e8..b75c438cfb 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2287,3 +2287,11 @@ Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. Geben Sie eine Größe in einer beliebigen Einheit ein, z. B. 5 cm oder 300 px. Die andere Seite passt sich an, sodass das Bild seine Proportionen behält. Import… Importieren… +# Task panes and the Styles pane position (#1756) + Styles Pane Position Position des Formatvorlagenbereichs + Dock Left Links andocken + Dock Right Rechts andocken + Float Unverankert + Pane Options Bereichsoptionen + Move Pane Bereich verschieben + Resize Pane Bereichsgröße ändern diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 19ef8fcf53..6e0e94e026 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2654,3 +2654,11 @@ preview Title Título Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. Escribe un tamaño en cualquier unidad, como 5 cm o 300 px. El otro lado se ajusta, así que la imagen conserva sus proporciones. Import… Importar… +# Task panes and the Styles pane position (#1756) + Styles Pane Position Posición del panel de estilos + Dock Left Acoplar a la izquierda + Dock Right Acoplar a la derecha + Float Flotante + Pane Options Opciones del panel + Move Pane Mover panel + Resize Pane Cambiar tamaño del panel diff --git a/crates/ui-egui/src/i18n/et.tsv b/crates/ui-egui/src/i18n/et.tsv index 0aa9e52c17..aeec5279f9 100644 --- a/crates/ui-egui/src/i18n/et.tsv +++ b/crates/ui-egui/src/i18n/et.tsv @@ -2322,3 +2322,11 @@ Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. Sisesta suurus mis tahes ühikus, näiteks 5 cm või 300 px. Teine külg muutub kaasa, nii et pilt säilitab oma proportsioonid. Import… Impordi… +# Task panes and the Styles pane position (#1756) + Styles Pane Position Stiilipaani asukoht + Dock Left Dokki vasakule + Dock Right Dokki paremale + Float Ujuv + Pane Options Paani suvandid + Move Pane Teisalda paan + Resize Pane Muuda paani suurust diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 44c9cb9377..f4d289be76 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2700,3 +2700,11 @@ template Letter Lettre Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. Tapez une taille dans n'importe quelle unité, par exemple 5 cm ou 300 px. L'autre côté suit, et l'image garde ses proportions. Import… Importer… +# Task panes and the Styles pane position (#1756) + Styles Pane Position Position du volet Styles + Dock Left Ancrer à gauche + Dock Right Ancrer à droite + Float Flottant + Pane Options Options du volet + Move Pane Déplacer le volet + Resize Pane Redimensionner le volet diff --git a/crates/ui-egui/src/i18n/hr.tsv b/crates/ui-egui/src/i18n/hr.tsv index a80b38d0b2..29ebe34792 100644 --- a/crates/ui-egui/src/i18n/hr.tsv +++ b/crates/ui-egui/src/i18n/hr.tsv @@ -2698,3 +2698,11 @@ preview Title Naslov Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. Upišite veličinu u bilo kojoj jedinici, npr. 5 cm ili 300 px. Druga se strana prilagođava pa slika zadržava svoje proporcije. Import… Uvezi… +# Task panes and the Styles pane position (#1756) + Styles Pane Position Položaj okna stilova + Dock Left Usidri lijevo + Dock Right Usidri desno + Float Plutajuće + Pane Options Mogućnosti okna + Move Pane Premjesti okno + Resize Pane Promijeni veličinu okna diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 0998d7d86a..c15b031f7b 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2694,3 +2694,11 @@ Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. Írj be egy méretet bármilyen mértékegységben, például 5 cm vagy 300 px. A másik oldal követi, így a kép megtartja az arányait. Import… Importálás… +# Task panes and the Styles pane position (#1756) + Styles Pane Position Stílusok panel helye + Dock Left Rögzítés balra + Dock Right Rögzítés jobbra + Float Lebegtetés + Pane Options Panel beállításai + Move Pane Panel áthelyezése + Resize Pane Panel átméretezése diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 314f7cada3..a85cf1d4ed 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2707,3 +2707,11 @@ preview Title Esempio Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. Scrivi una dimensione in qualsiasi unità, ad esempio 5 cm o 300 px. L'altro lato si adegua, così l'immagine mantiene le proporzioni. Import… Importa… +# Task panes and the Styles pane position (#1756) + Styles Pane Position Posizione del riquadro Stili + Dock Left Ancora a sinistra + Dock Right Ancora a destra + Float Mobile + Pane Options Opzioni riquadro + Move Pane Sposta riquadro + Resize Pane Ridimensiona riquadro diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index d633eac511..00cd791af4 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2288,3 +2288,11 @@ Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. 5 cm や 300 px など、任意の単位でサイズを入力します。もう一方の辺も合わせて変わるため、図の縦横比は保たれます。 Import… インポート… +# Task panes and the Styles pane position (#1756) + Styles Pane Position スタイル ウィンドウの位置 + Dock Left 左にドッキング + Dock Right 右にドッキング + Float フローティング + Pane Options ウィンドウ オプション + Move Pane ウィンドウの移動 + Resize Pane ウィンドウのサイズ変更 diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 7e50812e09..49b47bd027 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -2542,3 +2542,11 @@ preview Title 제목 Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. 5 cm 또는 300 px처럼 원하는 단위로 크기를 입력하세요. 다른 쪽도 함께 바뀌어 그림의 비율이 유지됩니다. Import… 가져오기… +# Task panes and the Styles pane position (#1756) + Styles Pane Position 스타일 창 위치 + Dock Left 왼쪽에 고정 + Dock Right 오른쪽에 고정 + Float 떠 있음 + Pane Options 창 옵션 + Move Pane 창 이동 + Resize Pane 창 크기 조정 diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 784182eb7d..f2be90935a 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2703,3 +2703,11 @@ preview Title Tytuł Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. Wpisz rozmiar w dowolnej jednostce, np. 5 cm lub 300 px. Druga strona dopasuje się, więc obraz zachowa proporcje. Import… Importuj… +# Task panes and the Styles pane position (#1756) + Styles Pane Position Położenie okienka stylów + Dock Left Zadokuj po lewej + Dock Right Zadokuj po prawej + Float Pływające + Pane Options Opcje okienka + Move Pane Przenieś okienko + Resize Pane Zmień rozmiar okienka diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 1a1038e1a7..dd6f449c4f 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2300,3 +2300,11 @@ Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. Digite um tamanho em qualquer unidade, como 5 cm ou 300 px. O outro lado acompanha, e a imagem mantém as proporções. Import… Importar… +# Task panes and the Styles pane position (#1756) + Styles Pane Position Posição do painel de estilos + Dock Left Encaixar à esquerda + Dock Right Encaixar à direita + Float Flutuante + Pane Options Opções do painel + Move Pane Mover painel + Resize Pane Redimensionar painel diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 7880999f98..3e00416600 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2696,3 +2696,11 @@ preview Title Пример Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. Введите размер в любых единицах, например 5 cm или 300 px. Другая сторона изменится вместе с ней, и рисунок сохранит пропорции. Import… Импорт… +# Task panes and the Styles pane position (#1756) + Styles Pane Position Положение области стилей + Dock Left Закрепить слева + Dock Right Закрепить справа + Float Открепить + Pane Options Параметры области + Move Pane Переместить область + Resize Pane Изменить размер области diff --git a/crates/ui-egui/src/i18n/sr-latn.tsv b/crates/ui-egui/src/i18n/sr-latn.tsv index 8e0a9136c2..3a7fe5f91f 100644 --- a/crates/ui-egui/src/i18n/sr-latn.tsv +++ b/crates/ui-egui/src/i18n/sr-latn.tsv @@ -2298,3 +2298,11 @@ Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. Unesite veličinu u bilo kojoj jedinici, na primer 5 cm ili 300 px. Druga strana se prilagođava, pa slika zadržava svoje proporcije. Import… Uvezi… +# Task panes and the Styles pane position (#1756) + Styles Pane Position Položaj okna stilova + Dock Left Usidri levo + Dock Right Usidri desno + Float Plutajuće + Pane Options Opcije okna + Move Pane Premesti okno + Resize Pane Promeni veličinu okna diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index c55fa2c7e4..7e99069f32 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -2302,3 +2302,11 @@ Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. Унесите величину у било којој јединици, на пример 5 cm или 300 px. Друга страна се прилагођава, па слика задржава своје пропорције. Import… Увези… +# Task panes and the Styles pane position (#1756) + Styles Pane Position Положај окна стилова + Dock Left Усидри лево + Dock Right Усидри десно + Float Плутајуће + Pane Options Опције окна + Move Pane Премести окно + Resize Pane Промени величину окна diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 50cab37aac..88d593509d 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -2713,3 +2713,11 @@ preview Title Titel Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. Skriv en storlek i valfri enhet, till exempel 5 cm eller 300 px. Den andra sidan följer med, så att bilden behåller sina proportioner. Import… Importera… +# Task panes and the Styles pane position (#1756) + Styles Pane Position Stilpanelens placering + Dock Left Docka till vänster + Dock Right Docka till höger + Float Flytande + Pane Options Panelalternativ + Move Pane Flytta panel + Resize Pane Ändra storlek på panel diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index e7771f20a5..971f4914de 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2695,3 +2695,11 @@ preview Title Örnek Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. Herhangi bir birimde bir boyut yazın, örneğin 5 cm veya 300 px. Diğer kenar da buna uyar, böylece resim oranlarını korur. Import… İçe aktar… +# Task panes and the Styles pane position (#1756) + Styles Pane Position Stiller Bölmesi Konumu + Dock Left Sola Yerleştir + Dock Right Sağa Yerleştir + Float Serbest Bırak + Pane Options Bölme Seçenekleri + Move Pane Bölmeyi Taşı + Resize Pane Bölmeyi Yeniden Boyutlandır diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index f327585957..01895fb26e 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2300,3 +2300,11 @@ Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. Введіть розмір у будь-яких одиницях, наприклад 5 cm або 300 px. Інша сторона зміниться разом із нею, тож зображення збереже пропорції. Import… Імпортувати… +# Task panes and the Styles pane position (#1756) + Styles Pane Position Положення панелі стилів + Dock Left Закріпити ліворуч + Dock Right Закріпити праворуч + Float Відкріпити + Pane Options Параметри панелі + Move Pane Перемістити панель + Resize Pane Змінити розмір панелі diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 6616368ace..2b5ba02cec 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -2738,3 +2738,11 @@ template Letter Thư Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. Nhập kích thước theo đơn vị bất kỳ, ví dụ 5 cm hoặc 300 px. Cạnh còn lại thay đổi theo, nên ảnh giữ nguyên tỷ lệ. Import… Nhập… +# Task panes and the Styles pane position (#1756) + Styles Pane Position Vị trí khung kiểu + Dock Left Neo bên trái + Dock Right Neo bên phải + Float Nổi + Pane Options Tùy chọn khung + Move Pane Di chuyển khung + Resize Pane Đổi kích thước khung diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index d733ca8f00..ae23a8e6ab 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2300,3 +2300,11 @@ Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. 可用任意单位输入大小,例如 5 cm 或 300 px。另一边随之调整,图片保持原有比例。 Import… 导入… +# Task panes and the Styles pane position (#1756) + Styles Pane Position 样式窗格位置 + Dock Left 停靠在左侧 + Dock Right 停靠在右侧 + Float 浮动 + Pane Options 窗格选项 + Move Pane 移动窗格 + Resize Pane 调整窗格大小 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index a8bc708ea1..de6f923688 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2289,3 +2289,11 @@ Type a size in any unit, such as 5 cm or 300 px. The other side follows, so the picture keeps its proportions. 可用任何單位輸入大小,例如 5 cm 或 300 px。另一邊隨之調整,圖片保持原有比例。 Import… 匯入… +# Task panes and the Styles pane position (#1756) + Styles Pane Position 樣式窗格位置 + Dock Left 停駐在左側 + Dock Right 停駐在右側 + Float 浮動 + Pane Options 窗格選項 + Move Pane 移動窗格 + Resize Pane 調整窗格大小 diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 220131df86..4e8f1463f0 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -271,6 +271,15 @@ pub struct UiState { /// View › Navigation Pane: open or closed as the person left it, kept between runs (#2526). /// The session owns it while running (see [`WordApp::prefs`]). pub nav_pane: bool, + /// Where the Styles pane sits (#1756). The session owns it while running (see [`WordApp::prefs`]). + #[serde(deserialize_with = "lenient_styles_pane_dock")] + pub styles_pane_dock: wordcraft_engine::StylesPaneDock, + /// The floating Styles pane's `[x, y, width, height]`, also kept while it is docked. + #[serde(deserialize_with = "lenient_float_rect")] + pub styles_pane_float: Option<[f32; 4]>, + /// The docked Styles pane's width this run; like the other panes' widths, not kept between runs. + #[serde(skip)] + pub styles_pane_width: Option, pub show_discord: bool, /// User name (File › Options) for comments and tracked changes; empty keeps the default. pub author: String, @@ -373,6 +382,9 @@ impl Default for UiState { legacy_dark: None, nav_tab: "headings".into(), nav_pane: false, + styles_pane_dock: wordcraft_engine::StylesPaneDock::Right, + styles_pane_float: None, + styles_pane_width: None, show_discord: true, author: String::new(), window: None, @@ -459,6 +471,21 @@ pub fn recent_fonts_from<'a>(names: impl IntoIterator) -> Vec>(d: D) -> Result { + let v = Value::deserialize(d)?; + Ok(v.as_str().and_then(wordcraft_engine::StylesPaneDock::parse).unwrap_or_default()) +} + +/// Anything but four finite numbers reads as no saved rect; `craft_ui::panes` brings a saved one +/// back inside the window. +fn lenient_float_rect<'de, D: serde::Deserializer<'de>>(d: D) -> Result, D::Error> { + let v = Value::deserialize(d)?; + let Some([x, y, w, h]) = v.as_array().map(Vec::as_slice) else { return Ok(None) }; + let n = |v: &Value| v.as_f64().map(|n| n as f32).filter(|n| n.is_finite()); + Ok(n(x).zip(n(y)).zip(n(w)).zip(n(h)).map(|(((x, y), w), h)| [x, y, w, h])) +} + /// `recentFonts` as saved, through [`recent_fonts_from`]: an entry that isn't a name is dropped, /// not the whole list. fn lenient_recent_fonts<'de, D: serde::Deserializer<'de>>(d: D) -> Result, D::Error> { @@ -762,6 +789,7 @@ impl WordApp { ui.read_aloud_skip_citations = self.session.read_aloud.skip_citations; ui.dark_page = self.session.view.dark_mode; ui.nav_pane = self.session.view.nav_pane; + ui.styles_pane_dock = self.session.view.styles_pane_dock; ui.keyboard = self.session.keymap.clone(); ui.ribbon = self.session.ribbon.clone(); ui.ink_pens = self.session.view.draw.pens.clone(); @@ -807,6 +835,7 @@ impl WordApp { self.session.read_aloud.skip_citations = self.ui.read_aloud_skip_citations; self.session.view.dark_mode = self.ui.dark_page; self.session.view.nav_pane = self.ui.nav_pane; + self.session.view.styles_pane_dock = self.ui.styles_pane_dock; // Keys for commands this version doesn't have are dropped. self.session.keymap = std::mem::take(&mut self.ui.keyboard); let reg = self.session.registry.clone(); @@ -6613,6 +6642,62 @@ mod tests { assert!(!old.session.view.nav_pane, "no saved value: closed"); } + /// Task panes: two open panes on one edge sit side by side, the first outermost; a pane's × + /// runs its command; the Styles pane's place and float rect survive a restart (#1756). + #[test] + fn task_panes_share_an_edge_close_and_keep_the_styles_place() { + use egui_kittest::kittest::Queryable; + let bars = |h: &egui_kittest::Harness<'_, WordApp>| { + let mut bars: Vec = h.get_all_by_label("Move Pane").map(|n| n.rect()).collect(); + bars.sort_by(|a, b| a.left().total_cmp(&b.left())); + bars + }; + // A title bar's ▾ and × sit right of the bar, on its row. + fn click_button(h: &egui_kittest::Harness<'_, WordApp>, bar: egui::Rect, label: &str) { + let on_row = |r: egui::Rect| r.center().y > bar.top() && r.center().y < bar.bottom() && r.left() >= bar.right() - 1.0; + let node = h.get_all_by_label(label).filter(|n| on_row(n.rect())).min_by(|a, b| a.rect().left().total_cmp(&b.rect().left())); + node.unwrap_or_else(|| panic!("no {label} beside the title bar")).click(); + } + let mut a = app(); + a.run("view.stylesPane", json!({"value": true})).unwrap(); + a.run("view.commentsPane", json!({"value": true})).unwrap(); + let mut h = harness(a); + h.run_steps(4); + let [comments, styles] = bars(&h)[..] else { panic!("two pane title bars") }; + assert!(comments.right() <= styles.left() + 1.0, "side by side"); + assert!(comments.left() > 720.0, "both on the right"); + // Default widths 290 and 250: Styles, opened first, is the outer one. + assert!((comments.width() - styles.width() - 40.0).abs() < 2.0, "Styles is outermost: {comments:?} {styles:?}"); + + click_button(&h, styles, "Close"); + h.run_steps(3); + assert!(!h.state().session.view.styles_pane, "× ran view.stylesPane"); + assert!(h.state().session.view.comments_pane); + assert_eq!(bars(&h).len(), 1); + + h.state_mut().run("view.stylesPane", json!({"value": true})).unwrap(); + h.run_steps(3); + let styles = *bars(&h).last().unwrap(); + click_button(&h, styles, "Pane Options"); + h.run_steps(2); + h.get_by_label("Float").click(); + h.run_steps(3); + assert_eq!(h.state().session.view.styles_pane_dock, wordcraft_engine::StylesPaneDock::Floating); + let rect = h.state().ui.styles_pane_float.expect("the float rect is kept"); + + let saved = serde_json::to_vec(&h.state().prefs()).unwrap(); + let mut next = app(); + next.apply_prefs(serde_json::from_slice(&saved).unwrap()); + assert_eq!(next.session.view.styles_pane_dock, wordcraft_engine::StylesPaneDock::Floating); + assert_eq!(next.ui.styles_pane_float, Some(rect)); + + let mut junk = app(); + junk.apply_prefs(serde_json::from_str(r#"{"stylesPaneDock": "top", "stylesPaneFloat": [1, "x", 3, 4], "navPane": true}"#).unwrap()); + assert_eq!(junk.session.view.styles_pane_dock, wordcraft_engine::StylesPaneDock::Right, "junk reads as the default"); + assert_eq!(junk.ui.styles_pane_float, None); + assert!(junk.session.view.nav_pane, "the rest of the file still loads"); + } + #[test] fn prefs_without_user_name_keep_default() { let mut a = app(); @@ -7890,13 +7975,14 @@ mod tests { h.step(); assert!(h.state().session.view.focus_mode); assert!(!shown(&h, "Ruler") && !shown(&h, "Focus"), "the ribbon is hidden"); - assert!(!shown(&h, "Navigation"), "and so is the Navigation pane"); + // The pane's title bar: the only pane open is Navigation. + assert!(!shown(&h, "Move Pane"), "and so is the Navigation pane"); h.event(egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }); h.step(); h.step(); assert!(!h.state().session.view.focus_mode, "Escape leaves Focus"); assert!(shown(&h, "Ruler"), "and the ribbon is back"); - assert!(shown(&h, "Navigation"), "with the pane"); + assert!(shown(&h, "Move Pane"), "with the pane"); // Immersive Reader is Read Mode, not Focus: its status bar stays. h.state_mut().run("view.immersive", json!({})).unwrap(); h.step(); diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index c600daaea5..673a472e12 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -1,116 +1,198 @@ -//! Side panes: Navigation (headings, pages, search results), Clipboard, Styles, Style Inspector, +//! Task panes: Navigation (headings, pages, search results), Clipboard, Styles, Style Inspector, //! Accessibility and Alt Text (see `a11y_pane`), Restrict Editing ([`crate::dialogs_review`]), Comments, -//! and the SmartArt Text Pane. +//! and the SmartArt Text Pane. `craft_ui::panes` draws them around the document, with their title +//! bars, menus and resize edges; the session's view flags say which are open. use std::collections::BTreeMap; +use craft_ui::panes::{Edge, PaneContent, PaneHost, PaneLabels, PaneRequest, PaneState, Place, SidePolicy}; use egui::{Stroke, Ui, vec2}; use serde_json::{Value, json}; use wordcraft_doc::{Pos, StoryRef}; +use wordcraft_engine::StylesPaneDock; use crate::WordApp; use crate::theme::{Tokens, regular, semibold}; -pub fn show(app: &mut WordApp, ui: &mut Ui) { - let t = Tokens::get(ui.ctx()); - // SmartArt Design › Text Pane, while a SmartArt graphic WordCraft can edit is selected. - if app.session.view.smart_art_pane && wordcraft_engine::cmd::smart_art::selected_smart_art(&app.session).is_some() { - egui::Panel::left("smart_art_pane") - .default_size(250.0) - .resizable(true) - .frame(egui::Frame::NONE.fill(t.panel).inner_margin(10).stroke(Stroke::new(1.0, t.border))) - .show(ui, |ui| { - if header(ui, "Text Pane") { - let _ = app.run("smartArt.textPane", json!({"value": false})); - return; - } - crate::dialogs_smart_art::text_pane(app, ui); - }); +/// The task panes, in the order they open: each edge's first pane is outermost. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Pane { + /// SmartArt Design › Text Pane, while a SmartArt graphic WordCraft can edit is selected. + SmartArtText, + Navigation, + Clipboard, + Styles, + StyleInspector, + Accessibility, + AltText, + RestrictEditing, + Comments, +} + +impl Pane { + fn title(self) -> &'static str { + match self { + Self::SmartArtText => "Text Pane", + Self::Navigation => "Navigation", + Self::Clipboard => "Clipboard", + Self::Styles => "Styles", + Self::StyleInspector => "Style Inspector", + Self::Accessibility => "Accessibility", + Self::AltText => "Alt Text", + Self::RestrictEditing => "Restrict Editing", + Self::Comments => "Comments", + } } - if app.session.view.nav_pane { - egui::Panel::left("nav_pane") - .default_size(260.0) - .resizable(true) - .frame(egui::Frame::NONE.fill(t.panel).inner_margin(10).stroke(Stroke::new(1.0, t.border))) - .show(ui, |ui| nav(app, ui)); + + fn default_state(self) -> PaneState { + let (edge, width) = match self { + Self::SmartArtText => (Edge::Start, 250.0), + Self::Navigation => (Edge::Start, 260.0), + Self::Clipboard => (Edge::Start, 250.0), + Self::Styles => (Edge::End, 250.0), + Self::StyleInspector => (Edge::End, 240.0), + Self::Accessibility => (Edge::End, 280.0), + Self::AltText => (Edge::End, 260.0), + Self::RestrictEditing => (Edge::End, 270.0), + Self::Comments => (Edge::End, 290.0), + }; + PaneState::docked(edge, width) } - if app.session.view.clipboard_pane { - egui::Panel::left("clipboard_pane") - .default_size(250.0) - .resizable(true) - .frame(egui::Frame::NONE.fill(t.panel).inner_margin(10).stroke(Stroke::new(1.0, t.border))) - .show(ui, |ui| clipboard(app, ui)); - } - if app.session.view.styles_pane { - egui::Panel::right("styles_pane") - .default_size(250.0) - .resizable(true) - .frame(egui::Frame::NONE.fill(t.panel).inner_margin(10).stroke(Stroke::new(1.0, t.border))) - .show(ui, |ui| styles(app, ui)); - } - if app.session.view.style_inspector { - egui::Panel::right("style_inspector") - .default_size(240.0) - .resizable(true) - .frame(egui::Frame::NONE.fill(t.panel).inner_margin(10).stroke(Stroke::new(1.0, t.border))) - .show(ui, |ui| inspector(app, ui)); - } - if app.session.view.accessibility_pane { - egui::Panel::right("accessibility_pane") - .default_size(280.0) - .resizable(true) - .frame(egui::Frame::NONE.fill(t.panel).inner_margin(10).stroke(Stroke::new(1.0, t.border))) - .show(ui, |ui| crate::a11y_pane::accessibility(app, ui)); - } - if app.session.view.alt_text_pane { - egui::Panel::right("alt_text_pane") - .default_size(260.0) - .resizable(true) - .frame(egui::Frame::NONE.fill(t.panel).inner_margin(10).stroke(Stroke::new(1.0, t.border))) - .show(ui, |ui| crate::a11y_pane::alt_text(app, ui)); - } - if app.restrict.open { - egui::Panel::right("restrict_pane") - .default_size(270.0) - .resizable(true) - .frame(egui::Frame::NONE.fill(t.panel).inner_margin(10).stroke(Stroke::new(1.0, t.border))) - .show(ui, |ui| { - if header(ui, "Restrict Editing") { - app.restrict.open = false; - return; - } - crate::dialogs_review::restrict_pane(app, ui); - }); - } - if app.session.view.comments_pane { - egui::Panel::right("comments_pane") - .default_size(290.0) - .resizable(true) - .frame(egui::Frame::NONE.fill(t.panel).inner_margin(10).stroke(Stroke::new(1.0, t.border))) - .show(ui, |ui| comments(app, ui)); + + /// The command that closes the pane; Restrict Editing has none and closes in [`close`]. + fn close_command(self) -> Option<&'static str> { + match self { + Self::SmartArtText => Some("smartArt.textPane"), + Self::Navigation => Some("view.navigationPane"), + Self::Clipboard => Some("edit.clipboardPane"), + Self::Styles => Some("view.stylesPane"), + Self::StyleInspector => Some("styles.inspector"), + Self::Accessibility => Some("view.accessibilityPane"), + Self::AltText => Some("view.altTextPane"), + Self::Comments => Some("view.commentsPane"), + Self::RestrictEditing => None, + } } } -pub(crate) fn header(ui: &mut Ui, title: &str) -> bool { - let mut close = false; - ui.horizontal(|ui| { - ui.label(egui::RichText::new(tl!(title)).font(semibold(15.0))); - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - // Drawn, not the "✕" character: the interface fonts have no glyph for it. - if crate::widgets::icon_button(ui, "close", tl!("Close")).clicked() { - close = true; - } - }); - }); - ui.add_space(4.0); - close +/// The panes open now, from the session's view flags. +pub fn open(app: &WordApp) -> Vec { + let v = &app.session.view; + let smart_art = v.smart_art_pane && wordcraft_engine::cmd::smart_art::selected_smart_art(&app.session).is_some(); + [ + (Pane::SmartArtText, smart_art), + (Pane::Navigation, v.nav_pane), + (Pane::Clipboard, v.clipboard_pane), + (Pane::Styles, v.styles_pane), + (Pane::StyleInspector, v.style_inspector), + (Pane::Accessibility, v.accessibility_pane), + (Pane::AltText, v.alt_text_pane), + (Pane::RestrictEditing, app.restrict.open), + (Pane::Comments, v.comments_pane), + ] + .into_iter() + .filter_map(|(pane, shown)| shown.then_some(pane)) + .collect() +} + +/// The Styles pane's place and size, from `view.stylesPaneDock` and the UI preferences (#1756). +/// Other panes keep theirs in the host for the session. +fn styles_state(app: &WordApp) -> PaneState { + let place = match app.session.view.styles_pane_dock { + StylesPaneDock::Right => Place::Docked(Edge::End), + StylesPaneDock::Left => Place::Docked(Edge::Start), + StylesPaneDock::Floating => Place::Floating, + }; + PaneState { place, size: app.ui.styles_pane_width.or(Pane::Styles.default_state().size), float_rect: app.ui.styles_pane_float } +} + +fn labels() -> PaneLabels { + PaneLabels { + menu: tl!("Pane Options").into(), + move_start: tl!("Dock Left").into(), + move_end: tl!("Dock Right").into(), + float: tl!("Float").into(), + close: tl!("Close").into(), + move_pane: tl!("Move Pane").into(), + resize: tl!("Resize Pane").into(), + ..PaneLabels::default() + } +} + +struct Content<'a> { + app: &'a mut WordApp, +} + +impl PaneContent for Content<'_> { + fn title(&self, pane: &Pane) -> String { + tl!(pane.title()).into() + } + + fn body(&mut self, ui: &mut Ui, pane: &Pane) { + let app = &mut *self.app; + match pane { + Pane::SmartArtText => crate::dialogs_smart_art::text_pane(app, ui), + Pane::Navigation => nav(app, ui), + Pane::Clipboard => clipboard(app, ui), + Pane::Styles => styles(app, ui), + Pane::StyleInspector => inspector(app, ui), + Pane::Accessibility => crate::a11y_pane::accessibility(app, ui), + Pane::AltText => crate::a11y_pane::alt_text(app, ui), + Pane::RestrictEditing => crate::dialogs_review::restrict_pane(app, ui), + Pane::Comments => comments(app, ui), + } + } + + fn default_state(&self, pane: &Pane) -> PaneState { + pane.default_state() + } +} + +/// Draw the open panes around the document. Call before the canvas's `CentralPanel`. +pub fn show(app: &mut WordApp, ui: &mut Ui) { + let t = Tokens::get(ui.ctx()); + let open = open(app); + let styles = styles_state(app); + let out = PaneHost::new("task_panes") + .policy(SidePolicy::Columns) + .labels(labels()) + .frame(egui::Frame::NONE.fill(t.panel).stroke(Stroke::new(1.0, t.border))) + .body_margin(egui::Margin::same(10)) + .show(ui, &open, |pane| (*pane == Pane::Styles).then_some(styles), &mut Content { app }); + for request in out.requests { + apply(app, request); + } +} + +/// Run a request from the pane host through the pane's commands and the UI preferences. +pub(crate) fn apply(app: &mut WordApp, request: PaneRequest) { + match request { + PaneRequest::Close(pane) => close(app, pane), + PaneRequest::Place(Pane::Styles, place) => { + let dock = match place { + Place::Docked(Edge::Start) => "left", + Place::Docked(Edge::End) => "right", + Place::Floating => "float", + Place::Docked(Edge::Bottom) => return, + }; + let _ = app.run("view.stylesPaneDock", json!({"dock": dock})); + } + PaneRequest::Resize(Pane::Styles, width) => app.ui.styles_pane_width = Some(width), + PaneRequest::FloatRect(Pane::Styles, rect) => app.ui.styles_pane_float = Some(rect), + PaneRequest::Place(..) | PaneRequest::Resize(..) | PaneRequest::FloatRect(..) | PaneRequest::Focus(_) => {} + } +} + +fn close(app: &mut WordApp, pane: Pane) { + match pane.close_command() { + Some(id) => { + let _ = app.run(id, json!({"value": false})); + } + None => app.restrict.open = false, + } } fn nav(app: &mut WordApp, ui: &mut Ui) { - if header(ui, "Navigation") { - let _ = app.run("view.navigationPane", json!({"value": false})); - return; - } let qid = egui::Id::new("nav_query"); let mut q = ui.data(|d| d.get_temp::(qid)).unwrap_or_default(); // Ctrl+F with text selected searches for it, as Word does (a selection within one line). @@ -234,10 +316,6 @@ fn nav_heading(ui: &mut Ui, level: u8, text: &str, selected: bool) -> egui::Resp /// Items collected by Copy and Cut, newest first; clicking one pastes it at the caret. fn clipboard(app: &mut WordApp, ui: &mut Ui) { - if header(ui, "Clipboard") { - let _ = app.run("edit.clipboardPane", json!({"value": false})); - return; - } let items: Vec = app.session.clip_history.items().iter().map(|i| i.preview()).collect(); ui.horizontal(|ui| { if ui.add_enabled(!items.is_empty(), egui::Button::new(tl!("Paste All"))).clicked() { @@ -280,10 +358,6 @@ fn clipboard(app: &mut WordApp, ui: &mut Ui) { } fn styles(app: &mut WordApp, ui: &mut Ui) { - if header(ui, "Styles") { - let _ = app.run("view.stylesPane", json!({"value": false})); - return; - } let st = app.session.run("format.state", &json!({})).unwrap_or_default(); let cur = st.get("style").and_then(Value::as_str).unwrap_or("Normal").to_string(); let current_name = app @@ -294,7 +368,8 @@ fn styles(app: &mut WordApp, ui: &mut Ui) { .map(crate::i18n::style_name) .unwrap_or_else(|| st.get("styleName").and_then(Value::as_str).unwrap_or("").to_string()); ui.label(egui::RichText::new(crate::i18n::fmt(tl!("Current style: {style}"), &[("style", ¤t_name)])).small()); - ui.horizontal(|ui| { + // Wrapped: the host gives the pane a fixed width, and the row is wider than its default. + ui.horizontal_wrapped(|ui| { if ui.button(tl!("New Style…")).clicked() { app.dialog = crate::dialogs::Dialog::open("newStyle", app); } @@ -355,10 +430,6 @@ fn styles(app: &mut WordApp, ui: &mut Ui) { } fn comments(app: &mut WordApp, ui: &mut Ui) { - if header(ui, "Comments") { - let _ = app.run("view.commentsPane", json!({"value": false})); - return; - } if crate::widgets::icon_text_button(ui, "newComment", tl!("New Comment"), regular(12.5)).clicked() { let _ = app.run("review.newComment", json!({"text": ""})); } @@ -590,10 +661,6 @@ pub(crate) fn flush_comment_drafts(app: &mut WordApp) -> Result<(), String> { /// Style Inspector: the paragraph and character levels at the caret, each with its style and the /// direct formatting on top, and a button to clear each level. fn inspector(app: &mut WordApp, ui: &mut Ui) { - if header(ui, "Style Inspector") { - let _ = app.run("styles.inspector", json!({"value": false})); - return; - } // Read-only, so call it directly: running the command every frame would pay for the command // machinery and consume per-command state (a pending `join_next_undo`) just to draw the pane. let r = wordcraft_engine::cmd::inspector::inspect(&app.session); From af9b00dd4c5c70f74814f726a8bf6e6098121244 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 19 Oct 2026 12:44:47 +0000 Subject: [PATCH 002/199] Task panes: follow craft-ui panes at f4f9e5b (#1756) craft-ui's pane host names each title bar after its pane, so the accessible name is "Move Pane: {title}" ("Move Pane: Styles") in all 22 catalogs. The pane tests find the title bars by that name. --- Cargo.lock | 2 +- crates/ui-egui/Cargo.toml | 2 +- crates/ui-egui/src/i18n/bg.tsv | 2 +- crates/ui-egui/src/i18n/cs.tsv | 2 +- crates/ui-egui/src/i18n/de.tsv | 2 +- crates/ui-egui/src/i18n/es.tsv | 2 +- crates/ui-egui/src/i18n/et.tsv | 2 +- crates/ui-egui/src/i18n/fr.tsv | 2 +- crates/ui-egui/src/i18n/hr.tsv | 2 +- crates/ui-egui/src/i18n/hu.tsv | 2 +- crates/ui-egui/src/i18n/it.tsv | 2 +- crates/ui-egui/src/i18n/ja.tsv | 2 +- crates/ui-egui/src/i18n/ko.tsv | 2 +- crates/ui-egui/src/i18n/pl.tsv | 2 +- crates/ui-egui/src/i18n/pt-br.tsv | 2 +- crates/ui-egui/src/i18n/ru.tsv | 2 +- crates/ui-egui/src/i18n/sr-latn.tsv | 2 +- crates/ui-egui/src/i18n/sr.tsv | 2 +- crates/ui-egui/src/i18n/sv.tsv | 2 +- crates/ui-egui/src/i18n/tr.tsv | 2 +- crates/ui-egui/src/i18n/uk.tsv | 2 +- crates/ui-egui/src/i18n/vi.tsv | 2 +- crates/ui-egui/src/i18n/zh-hans.tsv | 2 +- crates/ui-egui/src/i18n/zh-hant.tsv | 2 +- crates/ui-egui/src/lib.rs | 6 +++--- crates/ui-egui/src/panes.rs | 2 +- 26 files changed, 28 insertions(+), 28 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ded460c881..278aea4671 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -880,7 +880,7 @@ dependencies = [ [[package]] name = "craft-ui" version = "0.1.0" -source = "git+https://github.com/aiongg/craft-libs?rev=c94ab4d313832ea9a84da721a6d3e7a7838dacef#c94ab4d313832ea9a84da721a6d3e7a7838dacef" +source = "git+https://github.com/aiongg/craft-libs?rev=f4f9e5b206a721d28ea1214ef8bf85b8e30bb779#f4f9e5b206a721d28ea1214ef8bf85b8e30bb779" dependencies = [ "egui", ] diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index d7762b82c0..46e53d3c05 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -9,7 +9,7 @@ description = "WordCraft egui front end (Word-style ribbon UI); swappable: nothi workspace = true [dependencies] -craft-ui = { git = "https://github.com/aiongg/craft-libs", rev = "c94ab4d313832ea9a84da721a6d3e7a7838dacef" } +craft-ui = { git = "https://github.com/aiongg/craft-libs", rev = "f4f9e5b206a721d28ea1214ef8bf85b8e30bb779" } wordcraft-geom = { workspace = true } wordcraft-doc = { workspace = true } unicode-bidi = { workspace = true } diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index f62fcc6591..5ab7df8e8f 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2693,5 +2693,5 @@ Dock Right Закачане вдясно Float Откачане Pane Options Опции на панела - Move Pane Преместване на панела + Move Pane: {title} Преместване на панела: {title} Resize Pane Преоразмеряване на панела diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 280765cc72..668a43062a 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2696,5 +2696,5 @@ preview Title Ukázka Dock Right Ukotvit vpravo Float Plovoucí Pane Options Možnosti podokna - Move Pane Přesunout podokno + Move Pane: {title} Přesunout podokno: {title} Resize Pane Změnit velikost podokna diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index b75c438cfb..74aa12c6fc 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2293,5 +2293,5 @@ Dock Right Rechts andocken Float Unverankert Pane Options Bereichsoptionen - Move Pane Bereich verschieben + Move Pane: {title} Bereich verschieben: {title} Resize Pane Bereichsgröße ändern diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 6e0e94e026..a8bd706910 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2660,5 +2660,5 @@ preview Title Título Dock Right Acoplar a la derecha Float Flotante Pane Options Opciones del panel - Move Pane Mover panel + Move Pane: {title} Mover panel: {title} Resize Pane Cambiar tamaño del panel diff --git a/crates/ui-egui/src/i18n/et.tsv b/crates/ui-egui/src/i18n/et.tsv index aeec5279f9..cc2c7b197f 100644 --- a/crates/ui-egui/src/i18n/et.tsv +++ b/crates/ui-egui/src/i18n/et.tsv @@ -2328,5 +2328,5 @@ Dock Right Dokki paremale Float Ujuv Pane Options Paani suvandid - Move Pane Teisalda paan + Move Pane: {title} Teisalda paan: {title} Resize Pane Muuda paani suurust diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index f4d289be76..ea6b5f349c 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2706,5 +2706,5 @@ template Letter Lettre Dock Right Ancrer à droite Float Flottant Pane Options Options du volet - Move Pane Déplacer le volet + Move Pane: {title} Déplacer le volet : {title} Resize Pane Redimensionner le volet diff --git a/crates/ui-egui/src/i18n/hr.tsv b/crates/ui-egui/src/i18n/hr.tsv index 29ebe34792..baa0812bfe 100644 --- a/crates/ui-egui/src/i18n/hr.tsv +++ b/crates/ui-egui/src/i18n/hr.tsv @@ -2704,5 +2704,5 @@ preview Title Naslov Dock Right Usidri desno Float Plutajuće Pane Options Mogućnosti okna - Move Pane Premjesti okno + Move Pane: {title} Premjesti okno: {title} Resize Pane Promijeni veličinu okna diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index c15b031f7b..7293128cad 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2700,5 +2700,5 @@ Dock Right Rögzítés jobbra Float Lebegtetés Pane Options Panel beállításai - Move Pane Panel áthelyezése + Move Pane: {title} Panel áthelyezése: {title} Resize Pane Panel átméretezése diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index a85cf1d4ed..2f368d017f 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2713,5 +2713,5 @@ preview Title Esempio Dock Right Ancora a destra Float Mobile Pane Options Opzioni riquadro - Move Pane Sposta riquadro + Move Pane: {title} Sposta riquadro: {title} Resize Pane Ridimensiona riquadro diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 00cd791af4..762833bece 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2294,5 +2294,5 @@ Dock Right 右にドッキング Float フローティング Pane Options ウィンドウ オプション - Move Pane ウィンドウの移動 + Move Pane: {title} ウィンドウの移動: {title} Resize Pane ウィンドウのサイズ変更 diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 49b47bd027..fd438a8d4f 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -2548,5 +2548,5 @@ preview Title 제목 Dock Right 오른쪽에 고정 Float 떠 있음 Pane Options 창 옵션 - Move Pane 창 이동 + Move Pane: {title} 창 이동: {title} Resize Pane 창 크기 조정 diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index f2be90935a..f6df33d12b 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2709,5 +2709,5 @@ preview Title Tytuł Dock Right Zadokuj po prawej Float Pływające Pane Options Opcje okienka - Move Pane Przenieś okienko + Move Pane: {title} Przenieś okienko: {title} Resize Pane Zmień rozmiar okienka diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index dd6f449c4f..5b5f04e5c9 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2306,5 +2306,5 @@ Dock Right Encaixar à direita Float Flutuante Pane Options Opções do painel - Move Pane Mover painel + Move Pane: {title} Mover painel: {title} Resize Pane Redimensionar painel diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 3e00416600..2f1e0771bd 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2702,5 +2702,5 @@ preview Title Пример Dock Right Закрепить справа Float Открепить Pane Options Параметры области - Move Pane Переместить область + Move Pane: {title} Переместить область: {title} Resize Pane Изменить размер области diff --git a/crates/ui-egui/src/i18n/sr-latn.tsv b/crates/ui-egui/src/i18n/sr-latn.tsv index 3a7fe5f91f..58679b7131 100644 --- a/crates/ui-egui/src/i18n/sr-latn.tsv +++ b/crates/ui-egui/src/i18n/sr-latn.tsv @@ -2304,5 +2304,5 @@ Dock Right Usidri desno Float Plutajuće Pane Options Opcije okna - Move Pane Premesti okno + Move Pane: {title} Premesti okno: {title} Resize Pane Promeni veličinu okna diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index 7e99069f32..cd3104a979 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -2308,5 +2308,5 @@ Dock Right Усидри десно Float Плутајуће Pane Options Опције окна - Move Pane Премести окно + Move Pane: {title} Премести окно: {title} Resize Pane Промени величину окна diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 88d593509d..50f8695fee 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -2719,5 +2719,5 @@ preview Title Titel Dock Right Docka till höger Float Flytande Pane Options Panelalternativ - Move Pane Flytta panel + Move Pane: {title} Flytta panel: {title} Resize Pane Ändra storlek på panel diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 971f4914de..670e1c7bcf 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2701,5 +2701,5 @@ preview Title Örnek Dock Right Sağa Yerleştir Float Serbest Bırak Pane Options Bölme Seçenekleri - Move Pane Bölmeyi Taşı + Move Pane: {title} Bölmeyi Taşı: {title} Resize Pane Bölmeyi Yeniden Boyutlandır diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 01895fb26e..e509988b3f 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2306,5 +2306,5 @@ Dock Right Закріпити праворуч Float Відкріпити Pane Options Параметри панелі - Move Pane Перемістити панель + Move Pane: {title} Перемістити панель: {title} Resize Pane Змінити розмір панелі diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 2b5ba02cec..0a8adfb9ef 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -2744,5 +2744,5 @@ template Letter Thư Dock Right Neo bên phải Float Nổi Pane Options Tùy chọn khung - Move Pane Di chuyển khung + Move Pane: {title} Di chuyển khung: {title} Resize Pane Đổi kích thước khung diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index ae23a8e6ab..921426227b 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2306,5 +2306,5 @@ Dock Right 停靠在右侧 Float 浮动 Pane Options 窗格选项 - Move Pane 移动窗格 + Move Pane: {title} 移动窗格:{title} Resize Pane 调整窗格大小 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index de6f923688..83283ac52b 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2295,5 +2295,5 @@ Dock Right 停駐在右側 Float 浮動 Pane Options 窗格選項 - Move Pane 移動窗格 + Move Pane: {title} 移動窗格:{title} Resize Pane 調整窗格大小 diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 4e8f1463f0..4fd11cd0e2 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -6648,7 +6648,7 @@ mod tests { fn task_panes_share_an_edge_close_and_keep_the_styles_place() { use egui_kittest::kittest::Queryable; let bars = |h: &egui_kittest::Harness<'_, WordApp>| { - let mut bars: Vec = h.get_all_by_label("Move Pane").map(|n| n.rect()).collect(); + let mut bars: Vec = h.get_all_by_label_contains("Move Pane: ").map(|n| n.rect()).collect(); bars.sort_by(|a, b| a.left().total_cmp(&b.left())); bars }; @@ -7976,13 +7976,13 @@ mod tests { assert!(h.state().session.view.focus_mode); assert!(!shown(&h, "Ruler") && !shown(&h, "Focus"), "the ribbon is hidden"); // The pane's title bar: the only pane open is Navigation. - assert!(!shown(&h, "Move Pane"), "and so is the Navigation pane"); + assert!(!shown(&h, "Move Pane: Navigation"), "and so is the Navigation pane"); h.event(egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }); h.step(); h.step(); assert!(!h.state().session.view.focus_mode, "Escape leaves Focus"); assert!(shown(&h, "Ruler"), "and the ribbon is back"); - assert!(shown(&h, "Move Pane"), "with the pane"); + assert!(shown(&h, "Move Pane: Navigation"), "with the pane"); // Immersive Reader is Read Mode, not Focus: its status bar stays. h.state_mut().run("view.immersive", json!({})).unwrap(); h.step(); diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index 673a472e12..9df8c9eb64 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -113,7 +113,7 @@ fn labels() -> PaneLabels { move_end: tl!("Dock Right").into(), float: tl!("Float").into(), close: tl!("Close").into(), - move_pane: tl!("Move Pane").into(), + move_pane: tl!("Move Pane: {title}").into(), resize: tl!("Resize Pane").into(), ..PaneLabels::default() } From 0802788da9f0dd9464c06191139d934efc903ba9 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 19 Oct 2026 20:46:51 +0000 Subject: [PATCH 003/199] Task panes: follow craft-ui panes at 787b706 (#1756) --- Cargo.lock | 2 +- crates/ui-egui/Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 278aea4671..1db4891f70 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -880,7 +880,7 @@ dependencies = [ [[package]] name = "craft-ui" version = "0.1.0" -source = "git+https://github.com/aiongg/craft-libs?rev=f4f9e5b206a721d28ea1214ef8bf85b8e30bb779#f4f9e5b206a721d28ea1214ef8bf85b8e30bb779" +source = "git+https://github.com/aiongg/craft-libs?rev=787b70627461a3c988157ed776e8f6051cbc94c3#787b70627461a3c988157ed776e8f6051cbc94c3" dependencies = [ "egui", ] diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 46e53d3c05..123e79a6f7 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -9,7 +9,7 @@ description = "WordCraft egui front end (Word-style ribbon UI); swappable: nothi workspace = true [dependencies] -craft-ui = { git = "https://github.com/aiongg/craft-libs", rev = "f4f9e5b206a721d28ea1214ef8bf85b8e30bb779" } +craft-ui = { git = "https://github.com/aiongg/craft-libs", rev = "787b70627461a3c988157ed776e8f6051cbc94c3" } wordcraft-geom = { workspace = true } wordcraft-doc = { workspace = true } unicode-bidi = { workspace = true } From e4804235c5e484f5f900f8db8e6e629c995cebd0 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 19 Oct 2026 21:01:32 +0000 Subject: [PATCH 004/199] Task panes: F6 past the last pane gives the document the keyboard (#1756) --- crates/ui-egui/src/panes.rs | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index 9df8c9eb64..6179929eae 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -162,6 +162,9 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { for request in out.requests { apply(app, request); } + if out.to_document { + app.canvas.want_focus = true; + } } /// Run a request from the pane host through the pane's commands and the UI preferences. @@ -938,6 +941,37 @@ mod tests { assert!(h.state().canvas.focused, "Delete returns focus to the document"); assert!(h.state().session.clip_history.items().is_empty(), "the item was deleted"); } + + /// F6 past the last pane gives the document the keyboard, and typing reaches it (#1756). + #[test] + #[cfg(not(target_arch = "wasm32"))] + fn f6_past_the_last_pane_types_into_the_document() { + let mut app = crate::WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::from_text("")), crate::Services::default()); + app.run("view.navigationPane", serde_json::json!({"value": true})).unwrap(); + app.run("edit.clipboardPane", serde_json::json!({"value": true})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state( + |ui, app: &mut crate::WordApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + h.run_steps(4); + assert!(h.state().canvas.focused); + let f6 = egui::Event::Key { key: egui::Key::F6, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }; + for pane in ["Navigation", "Clipboard"] { + h.input_mut().events.push(f6.clone()); + h.run_steps(2); + assert!(!h.state().canvas.focused, "F6 moved the keyboard to the {pane} pane"); + } + h.input_mut().events.push(f6); + h.run_steps(2); + assert!(h.state().canvas.focused, "F6 past the last pane returns to the document"); + h.input_mut().events.push(egui::Event::Text("typed".into())); + h.run_steps(2); + assert_eq!(h.state().session.doc.plain_text(wordcraft_doc::StoryRef::Body).trim(), "typed"); + } } #[cfg(test)] From 080b05a0fddab85e15b662d841ca73fc6ffc1783 Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 20 Oct 2026 18:10:59 +0000 Subject: [PATCH 005/199] Task panes: follow craft-ui panes at 352a830 (#1756) craft-ui 352a830 is the head of storytold/craft-libs#187. The panes API WordCraft uses is unchanged. A floating pane also resizes from its top edge and top corners, through a 4 pt band above the title bar. --- Cargo.lock | 2 +- crates/ui-egui/Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1db4891f70..6e243bb7ee 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -880,7 +880,7 @@ dependencies = [ [[package]] name = "craft-ui" version = "0.1.0" -source = "git+https://github.com/aiongg/craft-libs?rev=787b70627461a3c988157ed776e8f6051cbc94c3#787b70627461a3c988157ed776e8f6051cbc94c3" +source = "git+https://github.com/aiongg/craft-libs?rev=352a8308c5cc53c2ae31326fa38a4a6bcb0ff9eb#352a8308c5cc53c2ae31326fa38a4a6bcb0ff9eb" dependencies = [ "egui", ] diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 123e79a6f7..ed88b4a143 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -9,7 +9,7 @@ description = "WordCraft egui front end (Word-style ribbon UI); swappable: nothi workspace = true [dependencies] -craft-ui = { git = "https://github.com/aiongg/craft-libs", rev = "787b70627461a3c988157ed776e8f6051cbc94c3" } +craft-ui = { git = "https://github.com/aiongg/craft-libs", rev = "352a8308c5cc53c2ae31326fa38a4a6bcb0ff9eb" } wordcraft-geom = { workspace = true } wordcraft-doc = { workspace = true } unicode-bidi = { workspace = true } From 023cda955429b618579a030118cb49629db3bd87 Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 20 Oct 2026 18:10:59 +0000 Subject: [PATCH 006/199] Task panes: shortcuts run right after a pane resize (#1756) A pointer drag on a pane's resize edge or a floating pane's grip leaves no keyboard focus behind, so global Ctrl and F-key shortcuts, which defer to any focused widget, run right after it. The test also covers an edge focused from the keyboard: arrow keys move it and Escape leaves it. --- crates/ui-egui/src/lib.rs | 63 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index f4047a11b9..716f94eef1 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -6725,6 +6725,69 @@ mod tests { assert!(junk.session.view.nav_pane, "the rest of the file still loads"); } + /// A pointer drag on a pane's resize edge leaves no keyboard focus behind, so Ctrl and + /// F-key shortcuts run right after it, docked or floating. A resize edge focused from the + /// keyboard takes the arrow keys until Escape (#1756). + #[test] + fn shortcuts_run_after_a_pane_resize() { + use egui_kittest::kittest::Queryable; + type H = egui_kittest::Harness<'static, WordApp>; + fn key(h: &mut H, key: egui::Key, modifiers: egui::Modifiers) { + h.event(egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }); + h.run_steps(2); + } + fn drag(h: &mut H, from: egui::Pos2, by: egui::Vec2) { + let button = + |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + h.event(egui::Event::PointerMoved(from)); + h.step(); + h.event(button(from, true)); + h.step(); + for t in [0.5, 1.0] { + h.event(egui::Event::PointerMoved(from + by * t)); + h.step(); + } + h.event(button(from + by, false)); + h.run_steps(2); + } + // Ctrl+H opens Replace; Escape closes it again. + fn replace_opens(h: &mut H) -> bool { + key(h, egui::Key::H, egui::Modifiers::COMMAND); + let opened = h.state().dialog.as_ref().map(|d| d.name()) == Some("replace"); + key(h, egui::Key::Escape, egui::Modifiers::NONE); + opened && h.state().dialog.is_none() + } + let mut a = app(); + a.run("view.stylesPane", json!({"value": true})).unwrap(); + let mut h = harness(a); + h.run_steps(4); + + let edge = h.get_by_label("Resize Pane").rect(); + // On the pane's side of the edge: the document's scroll bar lies under its outer half. + drag(&mut h, edge.center() + egui::vec2(1.5, 0.0), egui::vec2(-40.0, 0.0)); + let width = h.state().ui.styles_pane_width.unwrap_or_default(); + assert!((width - 290.0).abs() <= 2.0, "the drag widened the docked pane: {width}"); + assert!(replace_opens(&mut h), "Ctrl+H runs after the drag"); + + h.get_by_label("Resize Pane").focus(); + h.run_steps(2); + key(&mut h, egui::Key::ArrowLeft, egui::Modifiers::NONE); + assert!(h.state().ui.styles_pane_width.is_some_and(|w| w > width), "arrow keys move the focused edge"); + key(&mut h, egui::Key::Escape, egui::Modifiers::NONE); + assert_eq!(h.ctx.memory(|m| m.focused()), None, "Escape leaves the edge"); + assert!(replace_opens(&mut h), "Ctrl+H runs after Escape"); + + h.state_mut().run("view.stylesPaneDock", json!({"dock": "float"})).unwrap(); + h.run_steps(3); + // The bottom-right grip. + let corner = |r: &egui::Rect| r.max.x + r.max.y; + let grip = h.get_all_by_label("Resize Pane").map(|n| n.rect()).max_by(|a, b| corner(a).total_cmp(&corner(b))).unwrap(); + let before = h.state().ui.styles_pane_float; + drag(&mut h, grip.center(), egui::vec2(30.0, 30.0)); + assert_ne!(h.state().ui.styles_pane_float, before, "the drag resized the floating pane"); + assert!(replace_opens(&mut h), "Ctrl+H runs after the floating pane's resize"); + } + #[test] fn prefs_without_user_name_keep_default() { let mut a = app(); From 08028c44c0f58d24b7857e6ba4d145635ce5d154 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 14:41:20 +0000 Subject: [PATCH 007/199] Engine: several open documents; file.new opens another One session holds several open documents. The active document stays in the Session's own fields, so commands and the UI keep working on it; the others are parked as DocState values and swapped in field by field (std::mem::swap, no clones or relayout). Session and DocState are destructured exhaustively, so a new field must be classified as per document or shared before it builds. - Session::documents, activate, add_document, close_document, is_untouched_blank, with_document, find_open, is_open; DocInfo. - Document ids come from a process-wide counter. - file.new opens beside the open documents; file.open replaces an untouched blank document, opens beside the others otherwise, and activates a file that is already open ("alreadyOpen": true). - file.closeDocument {id?, force?} closes a document without a window (refuses unsaved changes unless forced); file.close is unchanged. - view.switchWindows {id?} lists the open documents or activates one. - Envelopes, Labels, Finish & Merge and New from Template open their result as a new document; the main document stays open, with no undo step for the command. - Each document keeps its own recovery worker and copy; Read Aloud readings name their document; with_document tags UI requests with the document they came from. --- ROADMAP.md | 4 +- crates/engine/src/cmd/file.rs | 52 ++- crates/engine/src/cmd/mailings.rs | 10 +- crates/engine/src/cmd/speech.rs | 8 +- crates/engine/src/cmd/tools.rs | 3 +- crates/engine/src/cmd/view.rs | 12 + crates/engine/src/lib.rs | 5 +- crates/engine/src/recovery/desktop.rs | 62 ++- crates/engine/src/session.rs | 271 +++++++++--- crates/engine/src/session/documents.rs | 566 +++++++++++++++++++++++++ crates/engine/src/speech.rs | 4 + crates/engine/src/tests.rs | 15 +- crates/engine/src/tests_documents.rs | 255 +++++++++++ docs/gaps.md | 5 +- docs/parity-checklist.md | 5 +- 15 files changed, 1180 insertions(+), 97 deletions(-) create mode 100644 crates/engine/src/session/documents.rs create mode 100644 crates/engine/src/tests_documents.rs diff --git a/ROADMAP.md b/ROADMAP.md index ef0e2222ac..731d388e51 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~15 points (60% → 75% ready for real work) and ~150–230 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** trivial (progress log: a click in the footer while editing the header moves there; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Slovenian interface catalog, 2,614 entries; previously trivial: Insert Table of Figures dialog for any caption label, #3013; previously trivial: progress log: Home › Underline drop-down, #2738; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: progress log: the scrollbar page tip stays on one line, #3341; previously trivial: progress log: forms protection greys out the control buttons; previously trivial: progress log: a document with many content controls saves in the time the controls take, #13; previously trivial: progress log: Word signature lines kept on save; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: progress log: closing the header returns to the body selection, #3331; previously trivial: progress log: saving to a NAS share mounted by GVFS, #3003; previously trivial: progress log: a picture dragged near the bottom of a page lands there, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: progress log: inline pictures drag to an insertion cursor and stay inline, #2233, #2234; previously trivial: progress log: a text file saved over itself closes without asking, #2376; previously trivial: progress log: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: fixed font inputs in four layout controls; previously trivial: typing no longer starts render threads on every repaint, #3027; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: progress log: banded rows shade as in Word, #3415; previously trivial: progress log: the table handles show while the caret is in the table, #2428; previously trivial: progress log: a click below a floating table reaches the text after it, #2038; previously trivial: progress log: Delete on selected table cells leaves one empty paragraph in each, #2802; previously trivial: progress log: lam-alef in exported PDFs extracts whole with fonts that draw it as two glyphs, #2310; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously trivial: progress log: a text box that keeps the keyboard no longer resets the input method every frame, #2986; previously trivial: progress log: recovery copies and .json files store pictures as base64, #586 follow-up; previously trivial: progress log: legacy menu keys, #1697; previously trivial: progress log: shape gradients and transparency, #1500; previously trivial: progress log: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: progress log: text above a float's anchor wraps around it, #2048; previously trivial: progress log: File › Recover follows the app theme, #3243; previously trivial: progress log: formatting toolbar with the text context menu, #2959; previously trivial: progress log: caret inside ligatures, #1974; 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: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: progress log: a header-cell picture watermark sits behind the text, #1775; previously trivial: symbols keep their font when the text font changes, #2036; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: Add Font imports every selected font file, #1789); previously trivial (RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: Read Aloud speaks each sentence in its own language, #1843; previously trivial: hint-reliant CJK outlines; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: progress log: Turkish case mapping in AutoCorrect, #1923; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: progress log: Hebrew field results and smart quotes, #1670; previously trivial: Backspace removes a selected table, rows or columns, #1844; previously trivial: progress log: mixed Hebrew/Latin text keeps its order in Word, #1543; previously trivial: progress log: web typing in non-Latin text no longer repeats, #1480; previously trivial: progress log: font family caches bounded and math font lookups follow font changes, #1354; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683; previously trivial: progress log: `w:eastAsianLayout` kept on save; previously trivial: table style editor formats every part, #714); previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** trivial (progress log: several open documents in one session; previously trivial: progress log: a click in the footer while editing the header moves there; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Slovenian interface catalog, 2,614 entries; previously trivial: Insert Table of Figures dialog for any caption label, #3013; previously trivial: progress log: Home › Underline drop-down, #2738; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: progress log: the scrollbar page tip stays on one line, #3341; previously trivial: progress log: forms protection greys out the control buttons; previously trivial: progress log: a document with many content controls saves in the time the controls take, #13; previously trivial: progress log: Word signature lines kept on save; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: progress log: closing the header returns to the body selection, #3331; previously trivial: progress log: saving to a NAS share mounted by GVFS, #3003; previously trivial: progress log: a picture dragged near the bottom of a page lands there, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: progress log: inline pictures drag to an insertion cursor and stay inline, #2233, #2234; previously trivial: progress log: a text file saved over itself closes without asking, #2376; previously trivial: progress log: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: fixed font inputs in four layout controls; previously trivial: typing no longer starts render threads on every repaint, #3027; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: progress log: banded rows shade as in Word, #3415; previously trivial: progress log: the table handles show while the caret is in the table, #2428; previously trivial: progress log: a click below a floating table reaches the text after it, #2038; previously trivial: progress log: Delete on selected table cells leaves one empty paragraph in each, #2802; previously trivial: progress log: lam-alef in exported PDFs extracts whole with fonts that draw it as two glyphs, #2310; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously trivial: progress log: a text box that keeps the keyboard no longer resets the input method every frame, #2986; previously trivial: progress log: recovery copies and .json files store pictures as base64, #586 follow-up; previously trivial: progress log: legacy menu keys, #1697; previously trivial: progress log: shape gradients and transparency, #1500; previously trivial: progress log: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: progress log: text above a float's anchor wraps around it, #2048; previously trivial: progress log: File › Recover follows the app theme, #3243; previously trivial: progress log: formatting toolbar with the text context menu, #2959; previously trivial: progress log: caret inside ligatures, #1974; 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: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: progress log: a header-cell picture watermark sits behind the text, #1775; previously trivial: symbols keep their font when the text font changes, #2036; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: Add Font imports every selected font file, #1789); previously trivial (RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: Read Aloud speaks each sentence in its own language, #1843; previously trivial: hint-reliant CJK outlines; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: progress log: Turkish case mapping in AutoCorrect, #1923; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: progress log: Hebrew field results and smart quotes, #1670; previously trivial: Backspace removes a selected table, rows or columns, #1844; previously trivial: progress log: mixed Hebrew/Latin text keeps its order in Word, #1543; previously trivial: progress log: web typing in non-Latin text no longer repeats, #1480; previously trivial: progress log: font family caches bounded and math font lookups follow font changes, #1354; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683; previously trivial: progress log: `w:eastAsianLayout` kept on save; previously trivial: table style editor formats every part, #714); previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** trivial (English dialects in proofing, #1281; previously trivial: triple borders draw three lines, #1740; previously trivial: progress log: double-clicking a later page's footer no longer scrolls up, #2103; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: progress log: pasted blockquotes are indented paragraphs, #1270; previously trivial: progress log: print-area strings translated in every catalog; previously trivial: progress log: comments readable in the dark theme, #2045; previously trivial: progress log: ODT sections, #1379; previously trivial: progress log: table deletes, Change Case and Unlink Fields respect Track Changes and locked content controls, #1329; previously trivial: progress log: rounded rectangle corners, #1476; previously trivial: progress log: Save As type list, #1399; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below. @@ -136,6 +136,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm ## Progress log +- 2026-10-22: Several open documents in one session (engine part of one window per document). File › New opens a new document beside the open ones; File › Open opens beside them too, replaces a blank document nobody has used, and shows a file that is already open instead of opening it twice. Each document keeps its own text, selection, undo history, find state, view and recovery copy. View › Window › Switch Windows (`view.switchWindows`) lists the open documents and switches between them; `file.closeDocument` closes one where no window asks about unsaved changes (agents, headless sessions). Envelopes, Labels, Finish & Merge and New from Template open their result as a new document and leave the main document open as it was. Document ids are unique in the process. The desktop app still shows one document per window; the windows come next. - 2026-10-22: Headers and footers: while editing the header, a single click in the footer edits the footer with the caret where clicked (and the reverse, and another page's own header or footer). The click went to the header being edited and the view scrolled back up to it, so a double-click on the footer rarely landed unless both were on screen. - 2026-10-21: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459). @@ -572,6 +573,7 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps | Date | Change | Summary | |---|---|---| +| 2026-10-22 | trivial | Progress log: several open documents in one session | | 2026-10-22 | trivial | Progress log: a click in the footer while editing the header moves there | | 2026-10-18 | trivial | Explicit New/Open is not interrupted by startup recovery discovery; older copies stay available and Recover follows the interface theme. | | 2026-10-17 | trivial | Progress log: Home › Underline drop-down (#2738) | diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 7b21994e2f..a89bbedf10 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -129,8 +129,11 @@ pub fn specs() -> Vec { sel_result(s) }) .key("Mod+W / Mod+F4") - .params(r#"{"force"?: bool (desktop app's control channel and MCP bridge only: without it a document with unsaved changes is an error and stays open; with it the changes are discarded. Headless sessions never ask)}"#) + .params(r#"{"force"?: bool (desktop app's control channel and MCP bridge only: without it a document with unsaved changes is an error and stays open; with it the changes are discarded. Headless sessions never ask; file.closeDocument closes a document there)}"#) .pure(), + CommandSpec::new("file.closeDocument", "Close Document", "Agents", close_document) + .params(r#"{"id"?: n (an open document, see view.switchWindows; default the active one), "force"?: bool (discard unsaved changes; without it a document with unsaved changes is an error and stays open)} → {"closed": id, "active": id, "documents": [{"id", "title", "path", "dirty", "active"}]}; closing the last document leaves a new blank one"#) + .pure(), CommandSpec::new("file.properties", "Properties", "File › Info", properties) .params(r#"{"title"?, "subject"?, "author"?, "keywords"?, "comments"?, "category"?, "custom"?: {name: string | null (removes)}}"#), CommandSpec::new("file.info", "Info", "File", info).pure(), @@ -192,7 +195,8 @@ fn privacy(s: &Session) -> Value { json!({"removePersonalInfo": s.doc.settings.remove_personal_info, "removeDateTime": s.doc.settings.remove_date_time}) } -/// Create a blank document in the saved proofing language, or use the requested template. +/// Open a new document beside the open ones and make it active: a blank one in the saved +/// proofing language, or the requested template. fn new(s: &mut Session, v: &Value) -> CmdResult { let doc = match p::str(v, "template").unwrap_or("blank") { "sample" => crate::sample::sample_document(), @@ -201,14 +205,13 @@ fn new(s: &mut Session, v: &Value) -> CmdResult { "report" => crate::sample::report(), _ => normal_document(s), }; - s.set_document(doc); - s.path = None; + s.add_document(doc); sel_result(s) } /// A blank document on the Normal template: the region's paper (#838), written in the editing /// language (the templates' text is English). -fn normal_document(s: &Session) -> Document { +pub(crate) fn normal_document(s: &Session) -> Document { let mut d = s.blank_document(); if let Some(lang) = s.prefs.default_language.as_ref().or(s.editing_lang.as_ref()) { d.styles.default_chr.lang = Some(lang.clone()); @@ -216,12 +219,22 @@ fn normal_document(s: &Session) -> Document { d } -/// Open a document, using the saved proofing default only for plain text and Markdown. +/// Open a document, using the saved proofing default only for plain text and Markdown. A file +/// that is already open becomes the active document instead (`"alreadyOpen": true`). The file +/// replaces an untouched blank document; otherwise it opens beside the open documents. fn open(s: &mut Session, v: &Value) -> CmdResult { let Some(path) = p::str(v, "path") else { s.ui_requests.push(json!({"open": "openFile"})); return sel_result(s); }; + if p::str(v, "data").is_none() + && let Some(id) = s.find_open(std::path::Path::new(path)) + { + s.activate(id)?; + return Ok( + json!({"path": s.path, "paragraphs": s.doc.paragraph_count(), "words": s.doc.word_count(), "encrypted": s.password.is_some(), "alreadyOpen": true, "id": id}), + ); + } let password = p::str(v, "password"); let (mut doc, encrypted) = if let Some(data) = p::str(v, "data") { let bytes = super::insert::base64_decode(data).ok_or_else(|| CmdError::Params("bad base64".into()))?; @@ -244,7 +257,11 @@ fn open(s: &mut Session, v: &Value) -> CmdResult { { doc.styles.default_chr.lang = Some(lang.clone()); } - s.set_document(doc); + if s.is_untouched_blank() { + s.set_document(doc); + } else { + s.add_opened_document(doc); + } if let Some(z) = s.doc.settings.zoom { super::view::restore_zoom(s, z); } @@ -253,7 +270,26 @@ fn open(s: &mut Session, v: &Value) -> CmdResult { if encrypted { s.password = password.map(crate::io::Password::new); } - Ok(json!({"path": path, "paragraphs": s.doc.paragraph_count(), "words": s.doc.word_count(), "encrypted": encrypted})) + Ok( + json!({"path": path, "paragraphs": s.doc.paragraph_count(), "words": s.doc.word_count(), "encrypted": encrypted, "alreadyOpen": false, "id": s.document_id()}), + ) +} + +/// Close a document with no window to ask about it (agents, headless sessions). +fn close_document(s: &mut Session, v: &Value) -> CmdResult { + let id = match v.get("id").filter(|x| !x.is_null()) { + None => s.document_id(), + Some(x) => x.as_u64().ok_or_else(|| CmdError::Params("`id` must be an open document's id".into()))?, + }; + if !s.is_open(id) { + return Err(CmdError::Params(format!("no open document has id {id}"))); + } + let dirty = s.documents().iter().any(|d| d.id == id && d.dirty); + if dirty && p::bool(v, "force") != Some(true) { + return Err(CmdError::Failed("the document has unsaved changes: save it first, or pass \"force\": true to discard them".into())); + } + s.close_document(id)?; + Ok(json!({"closed": id, "active": s.document_id(), "documents": s.documents()})) } /// File › Info › Protect Document › Encrypt with Password: the password Word documents are saved diff --git a/crates/engine/src/cmd/mailings.rs b/crates/engine/src/cmd/mailings.rs index eebbc162d4..e1373ca35e 100644 --- a/crates/engine/src/cmd/mailings.rs +++ b/crates/engine/src/cmd/mailings.rs @@ -1036,10 +1036,8 @@ fn finish(s: &mut Session, v: &Value) -> CmdResult { crate::io::save_path(std::path::Path::new(path), &out).map_err(CmdError::Failed)?; return Ok(json!({"records": count, "path": path})); } - s.set_document(out); - s.path = None; + s.add_document(out); s.dirty = true; - s.merge.preview = false; Ok(json!({"records": count})) } @@ -1190,8 +1188,7 @@ fn envelopes(s: &mut Session, v: &Value) -> CmdResult { let mut d = Document::new(); d.last_section = sect; d.body = blocks; - s.set_document(d); - s.path = None; + s.add_document(d); Ok(json!({"width": w, "height": h})) } @@ -1423,8 +1420,7 @@ fn labels(s: &mut Session, v: &Value) -> CmdResult { } } d.body = vec![std::sync::Arc::new(Block::Table(t)), para_block(Paragraph::new())]; - s.set_document(d); - s.path = None; + s.add_document(d); let _ = Pos::body(0, 0); Ok(json!({"labels": if single.is_some() { 1 } else { rows * cols }, "filled": made, "product": prod.id})) } diff --git a/crates/engine/src/cmd/speech.rs b/crates/engine/src/cmd/speech.rs index 651f6d6369..fac23ac830 100644 --- a/crates/engine/src/cmd/speech.rs +++ b/crates/engine/src/cmd/speech.rs @@ -35,7 +35,8 @@ fn read_aloud(s: &mut Session, v: &Value) -> CmdResult { if let Some(b) = p::bool(v, "skipCitations") { s.read_aloud.skip_citations = b; } - if s.read_aloud.state() != State::Stopped { + // A reading of this document pauses or carries on; one of another document gives way. + if s.read_aloud.state() != State::Stopped && s.read_aloud.document == Some(s.document_id()) { s.read_aloud.play_pause(); return status(s, v); } @@ -54,12 +55,13 @@ fn read_aloud(s: &mut Session, v: &Value) -> CmdResult { if queue.is_empty() { return Err(CmdError::Failed("there's nothing to read".into())); } + s.read_aloud.document = Some(s.document_id()); s.read_aloud.start(queue); status(s, v) } fn play_pause(s: &mut Session, v: &Value) -> CmdResult { - if s.read_aloud.progress().1 == 0 { + if s.read_aloud.progress().1 == 0 || s.read_aloud.document != Some(s.document_id()) { return read_aloud(s, v); } s.read_aloud.play_pause(); @@ -92,7 +94,7 @@ fn skip_citations(s: &mut Session, v: &Value) -> CmdResult { let on = p::bool(v, "value").unwrap_or(!s.read_aloud.skip_citations); if on != s.read_aloud.skip_citations { s.read_aloud.skip_citations = on; - if let Some(cur) = s.read_aloud.current() { + if let Some(cur) = s.read_aloud.current().filter(|_| s.read_aloud.document == Some(s.document_id())) { let paused = s.read_aloud.state() == State::Paused; let queue = speech::sentences(&s.doc, &cur.start, None, on); if !queue.is_empty() { diff --git a/crates/engine/src/cmd/tools.rs b/crates/engine/src/cmd/tools.rs index 73668e3575..6da169cdd7 100644 --- a/crates/engine/src/cmd/tools.rs +++ b/crates/engine/src/cmd/tools.rs @@ -492,8 +492,7 @@ pub fn specs() -> Vec { let mut doc = crate::io::open_path(std::path::Path::new(path)).map_err(CmdError::Failed)?; // As in Word, the new document is attached to the template it came from (#2852). doc.settings.attached_template = Some(path.to_string()); - s.set_document(doc); - s.path = None; + s.add_document(doc); s.dirty = true; sel_result(s) }) diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index 99ac8b5ece..96924cd11a 100644 --- a/crates/engine/src/cmd/view.rs +++ b/crates/engine/src/cmd/view.rs @@ -125,6 +125,9 @@ pub fn specs() -> Vec { sel_result(s) }) .pure(), + CommandSpec::new("view.switchWindows", "Switch Windows", "View › Window", switch_windows) + .params(r#"{"id"?: n (make this open document the active one)} → {"documents": [{"id", "title", "path", "dirty", "active"}]} in the order they were opened"#) + .pure(), CommandSpec::new("view.split", "Split", "View › Window", |s, _| { s.ui_requests.push(json!({"toggle": "split"})); sel_result(s) @@ -261,3 +264,12 @@ fn zoom(s: &mut Session, v: &Value) -> CmdResult { } Ok(json!({"zoom": s.view.zoom, "fit": s.view.fit})) } + +/// View › Window › Switch Windows: the open documents, or make one of them active. +fn switch_windows(s: &mut Session, v: &Value) -> CmdResult { + if let Some(x) = v.get("id").filter(|x| !x.is_null()) { + let id = x.as_u64().ok_or_else(|| CmdError::Params("`id` must be an open document's id".into()))?; + s.activate(id)?; + } + Ok(json!({"documents": s.documents()})) +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 0158a6054f..b1aec1ce4d 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -28,7 +28,8 @@ pub use io::Password; pub use keymap::KeyMap; pub use ribbon::RibbonLayout; pub use session::{ - CellRange, ColumnBlock, EditSnapshot, FindState, MathEdit, Prefs, Selection, Session, UNDO_LIST_MAX, ViewState, blank_document, set_system_locale, + CellRange, ColumnBlock, DocInfo, DocState, EditSnapshot, FindState, MathEdit, Prefs, Selection, Session, UNDO_LIST_MAX, ViewState, + blank_document, set_system_locale, }; pub use wordcraft_doc as doc; pub use wordcraft_geom::Region; @@ -170,6 +171,8 @@ mod tests; #[cfg(test)] mod tests_bidi; #[cfg(test)] +mod tests_documents; +#[cfg(test)] mod tests_fonts; #[cfg(test)] mod tests_revision_checks; diff --git a/crates/engine/src/recovery/desktop.rs b/crates/engine/src/recovery/desktop.rs index 738e20a2d5..92bbf7eee2 100644 --- a/crates/engine/src/recovery/desktop.rs +++ b/crates/engine/src/recovery/desktop.rs @@ -401,14 +401,24 @@ pub(crate) struct Recovery { revision: Option, due: Instant, alive: bool, + /// The profile folder and wakeup, for [`Recovery::sibling`]. + root: PathBuf, + wake: Arc, } impl Recovery { + /// Recovery for another open document: its own worker on the same profile folder, keeping + /// that document's copy as this one keeps its document's. + pub fn sibling(&self) -> Option { + Self::start(self.root.clone(), self.wake.clone()).map_err(|e| log::warn!("recovery: {e}")).ok() + } pub fn start(root: PathBuf, wake: Arc) -> Result { + let (worker_root, worker_wake) = (root.clone(), wake.clone()); let (tx, jobs) = mpsc::sync_channel(1); let (events, rx) = mpsc::channel(); std::thread::Builder::new() .name("wordcraft-recovery".into()) .spawn(move || { + let (root, wake) = (worker_root, worker_wake); let mut store = match Store::new(root) { Ok(s) => s, Err(e) => { @@ -452,6 +462,8 @@ impl Recovery { revision: None, due: Instant::now() + INTERVAL, alive: true, + root, + wake, }) } fn queue(&mut self, job: Job) -> Result<(), String> { @@ -1043,6 +1055,31 @@ mod tests { assert!(s.recovery_error().unwrap().contains("document changed")); } + /// A restore asked for in one document lands in that document, even when another document + /// is active while the copy loads. + #[test] + fn delayed_restore_lands_in_the_document_that_asked_for_it() { + let t = Temp::new(); + let mut s = session(&t.0); + s.run("text.insert", &json!({"text": "Saved draft"})).unwrap(); + save_now(&mut s); + drop(s); + let mut s = session(&t.0); + let first = s.document_id(); + let id = s.recovery_documents()[0].id.clone(); + s.run("file.recover", &json!({"restore": id})).unwrap(); + s.run("file.new", &json!({})).unwrap(); + let second = s.document_id(); + s.run("text.insert", &json!({"text": "Other work"})).unwrap(); + settle(&mut s); + assert_eq!(s.doc.plain_text(StoryRef::Body), "Other work", "the active document is left alone"); + s.activate(first).unwrap(); + settle(&mut s); + assert_eq!(s.doc.plain_text(StoryRef::Body), "Saved draft"); + assert_eq!(s.with_document(second, |s| s.doc.plain_text(StoryRef::Body)).unwrap(), "Other work"); + assert_eq!(s.documents().len(), 2); + } + #[test] fn opening_protected_file_and_password_save_never_leave_plaintext_copies() { let t = Temp::new(); @@ -1073,16 +1110,39 @@ mod tests { assert_eq!(fs::read(original).unwrap(), bytes); } + /// Each open document keeps its own recovery copy: switching between documents doesn't start + /// a new copy of either. + #[test] + fn each_open_document_keeps_its_own_copy() { + let t = Temp::new(); + let mut s = session(&t.0); + let first = s.document_id(); + s.run("text.insert", &json!({"text": "First synthetic draft"})).unwrap(); + save_now(&mut s); + s.run("file.new", &json!({})).unwrap(); + settle(&mut s); + s.run("text.insert", &json!({"text": "Second synthetic draft"})).unwrap(); + save_now(&mut s); + s.activate(first).unwrap(); + s.run("text.insert", &json!({"text": ", edited"})).unwrap(); + save_now(&mut s); + let records = fs::read_dir(&t.0).unwrap().filter(|e| e.as_ref().is_ok_and(|e| e.path().is_dir())).count(); + assert_eq!(records, 2, "one copy per document"); + assert_eq!(s.recovery_documents().len(), 1, "the first document's own copy"); + } + #[test] fn protection_after_document_switch_does_not_discard_another_draft() { let t = Temp::new(); let mut s = session(&t.0); s.run("text.insert", &json!({"text": "First synthetic draft"})).unwrap(); save_now(&mut s); + let first = s.document_id(); s.run("file.new", &json!({})).unwrap(); // Protection can be commanded before the host's next recovery tick. s.run("file.encrypt", &json!({"password": "synthetic-password"})).unwrap(); settle(&mut s); - assert_eq!(s.recovery_documents().len(), 1); + assert_eq!(s.with_document(first, |s| s.recovery_documents().len()).unwrap(), 1); + assert_eq!(fs::read_dir(&t.0).unwrap().filter(|e| e.as_ref().is_ok_and(|e| e.path().is_dir())).count(), 1, "the first draft is kept"); } } diff --git a/crates/engine/src/session.rs b/crates/engine/src/session.rs index 1b79dbbdd0..e0df7a7a7e 100644 --- a/crates/engine/src/session.rs +++ b/crates/engine/src/session.rs @@ -25,6 +25,10 @@ use wordcraft_layout::{DocLayout, LayoutCache, LayoutOptions, NumeralMode, ViewM use crate::{CmdError, Registry}; +mod documents; +pub use documents::{DocInfo, DocState}; +use documents::{document_id_mark, next_document_id, untitled_name}; + /// A selection: anchor (where it started) and focus (where the caret is). #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] pub struct Selection { @@ -359,7 +363,32 @@ impl EditSnapshot { } } -/// An editing session. +/// The layout last made and what it was made for: (revision, width, read height, view, read line +/// spacing, layout, proofing, outline, (deletions left out, deletions marked Hidden, body +/// deletions in balloons), numeral display, the word being typed). +/// What `run` keeps to take a failed mutating command back: the document, the selection, the +/// undo and redo stacks, the open typing group, the undo steps dropped so far and the column +/// block. +type RunSnapshot = (Document, Selection, Vec>, Vec>, bool, u64, Option); + +type LayoutEntry = ( + u64, + f32, + f32, + ViewMode, + Option, + Arc, + bool, + Option, + (bool, wordcraft_layout::HideDeleted, bool), + NumeralMode, + Option, +); + +/// An editing session: the app-wide state (clipboard, preferences, macros, the command registry…) +/// and the open documents. The active document's state is in the session's own fields, which +/// every command works on; the other open documents wait in [`DocState`] values and are swapped +/// in when they become active (see [`Session::documents`]). pub struct Session { pub doc: Document, pub sel: Selection, @@ -419,26 +448,16 @@ pub struct Session { /// The newest automatic capitalisation, for Word's "Automatically add words to list" (#1857). pub(crate) autocap: Option, rev: u64, - /// Which document this is: changes when [`Session::set_document`] replaces it (open, new, - /// mail merge, recover), never on an edit. + /// Which document this is, unique in the process: changes when [`Session::set_document`] + /// replaces it (an open into an untouched blank document, recover), never on an edit or when + /// another document becomes active and this one comes back. document_id: u64, + /// The number in an untitled document's name (`Document2`); 0 for one opened from a file. + untitled: u32, + /// `rev` when the document was made, for [`Session::is_untouched_blank`]. + made_rev: u64, cache: LayoutCache, - /// (revision, width, read height, view, read line spacing, layout, proofing, outline, - /// (deletions left out, deletions marked Hidden, body deletions in balloons), numeral display, the word being typed). - #[allow(clippy::type_complexity)] - layout: Option<( - u64, - f32, - f32, - ViewMode, - Option, - Arc, - bool, - Option, - (bool, wordcraft_layout::HideDeleted, bool), - NumeralMode, - Option, - )>, + layout: Option, /// The word being typed, left without a spelling squiggle ([`TypedWord`]). typing: Option, /// Picture edits: edited media key → original media key (Reset Picture). @@ -506,6 +525,12 @@ pub struct Session { edit_marks: Vec, /// How many Go Back presses since the last edit (which place comes next). go_back_step: usize, + /// The open documents other than the active one, in no particular order. + others: Vec, + /// Every open document's id, the active one's too, in the order they were opened. + order: Vec, + /// Untitled documents made so far, for the next one's name (`Document3`). + untitled_count: u32, } /// The number of edit locations Go Back cycles through (Word: four). @@ -658,72 +683,119 @@ impl Session { } pub fn new(doc: Document) -> Self { - let start = doc.start_of(StoryRef::Body); - let bib_style = doc_bib_style(&doc); + let DocState { + doc, + sel, + view, + pending, + path, + file_name, + dirty, + pending_move, + find, + goal_x, + page_hint, + new_header_parts, + hf_return, + history, + undo_evicted, + redo, + typing_open, + join_next, + autocap, + rev, + document_id, + untitled, + made_rev, + cache, + layout, + typing, + originals, + picture_looks, + versions, + autosave, + bib_style, + merge, + math, + password, + #[cfg(not(target_arch = "wasm32"))] + recovery, + also_selected, + column, + column_mode, + widened, + edit_marks, + go_back_step, + } = DocState::new(doc, 1, ViewState::default()); Session { doc, - sel: Selection::caret(start), - view: ViewState::default(), - pending: None, - path: None, - file_name: None, + sel, + view, + pending, + path, + file_name, user_dict_path: None, - dirty: false, + dirty, clipboard: None, - pending_move: None, + pending_move, clipboard_text: String::new(), clipboard_html: String::new(), clip_history: Default::default(), - find: FindState::default(), - goal_x: None, - page_hint: 0, + find, + goal_x, + page_hint, registry: Arc::new(crate::cmd::registry()), author: "WordCraft User".into(), initials: String::new(), painter: None, status: String::new(), - new_header_parts: Vec::new(), - hf_return: None, - history: Vec::new(), - undo_evicted: 0, - redo: Vec::new(), - typing_open: false, - join_next: false, - autocap: None, - rev: 1, - document_id: 1, - cache: LayoutCache::new(), - layout: None, - typing: None, - originals: Default::default(), - picture_looks: Default::default(), + new_header_parts, + hf_return, + history, + undo_evicted, + redo, + typing_open, + join_next, + autocap, + rev, + document_id, + untitled, + made_rev, + cache, + layout, + typing, + originals, + picture_looks, last_command: None, recording: None, macros: Default::default(), - versions: Vec::new(), + versions, building_blocks: Default::default(), - autosave: true, + autosave, bib_style, - merge: Default::default(), + merge, ui_requests: Vec::new(), - math: None, + math, math_latex: false, math_normal_text: false, - also_selected: Vec::new(), + also_selected, prefs: Prefs::default(), system_region: Default::default(), keymap: crate::KeyMap::default(), ribbon: crate::RibbonLayout::default(), editing_lang: None, read_aloud: Default::default(), - password: None, + password, #[cfg(not(target_arch = "wasm32"))] - recovery: None, - column: None, - column_mode: false, - widened: None, - edit_marks: Vec::new(), - go_back_step: 0, + recovery, + column, + column_mode, + widened, + edit_marks, + go_back_step, + others: Vec::new(), + order: vec![document_id], + untitled_count: 1, } } @@ -844,7 +916,7 @@ impl Session { /// The document's file name and full path for fields (`FILENAME`) and layout context. /// Returns `(filename, filepath)`. Unsaved documents with no explicit file name default - /// consistently to `("Document1", "Document1")`. Document metadata title is never used + /// consistently to their name, `("Document1", "Document1")` for the first. Document metadata title is never used /// as a filename fallback (#3449). pub fn resolved_filenames(&self) -> (String, String) { if let Some(path) = &self.path { @@ -854,7 +926,8 @@ impl Session { } else if let Some(name) = &self.file_name { (name.clone(), name.clone()) } else { - ("Document1".to_string(), "Document1".to_string()) + let name = untitled_name(self.untitled); + (name.clone(), name) } } @@ -995,6 +1068,10 @@ impl Session { if untouched && self.doc.plain_text(StoryRef::Body).trim().is_empty() && self.doc.paragraph_count() <= 1 { self.doc.styles.default_chr.lang = Some(self.editing_lang.clone().unwrap_or_else(|| "en-US".into())); self.rev = self.rev.wrapping_add(1); + // Not an edit: the document is as untouched as it was. + if self.made_rev == self.rev.wrapping_sub(1) { + self.made_rev = self.rev; + } self.relayout(); } } @@ -1197,7 +1274,11 @@ impl Session { /// Replace the document (open/new). pub fn set_document(&mut self, doc: Document) { self.autocap = None; - self.document_id = self.document_id.wrapping_add(1); + let replaced = self.document_id; + self.document_id = next_document_id(); + if let Some(at) = self.order.iter_mut().find(|d| **d == replaced) { + *at = self.document_id; + } self.bib_style = doc_bib_style(&doc); self.doc = doc; self.doc.ensure_nonempty(); @@ -1218,6 +1299,7 @@ impl Session { self.reset_history(); self.touch(); self.dirty = false; + self.made_rev = self.rev; self.relayout(); } @@ -1232,8 +1314,8 @@ impl Session { /// /// `restore` is exact when the commands in between only add undo steps (every mutating /// command adds at most one; up to [`SNAPSHOT_HEAD`] may push old steps out of a full - /// stack). Taking steps off the stack (`edit.undo`) or clearing it (`file.new`, - /// `file.open`) in between is not covered. + /// stack). Taking steps off the stack (`edit.undo`), clearing it (`file.open` into + /// a blank document) or switching documents in between is not covered. /// /// The destructuring lists every field, so a new field does not build until someone decides /// whether a snapshot must cover it. @@ -1288,6 +1370,13 @@ impl Session { merge: _, ui_requests: _, document_id: _, + // The untitled name and when the document was made: not edits. + untitled: _, + made_rev: _, + // The other open documents: a snapshot covers the active one. + others: _, + order: _, + untitled_count: _, read_aloud: _, prefs: _, system_region: _, @@ -1382,6 +1471,9 @@ impl Session { /// control channel). Mutating commands are undoable; a failed command leaves the document as /// it was; a panic inside a command becomes an error. pub fn run(&mut self, id: &str, params: &Value) -> Result { + // The document the command starts in, and the first id a document it opens can have. + let home = self.document_id; + let mark = document_id_mark(); let join = std::mem::take(&mut self.join_next); let reg = self.registry.clone(); let Some(spec) = reg.get(id) else { return Err(CmdError::Unknown(id.to_string())) }; @@ -1422,7 +1514,7 @@ impl Session { } // The undo stacks are snapshotted whole, not as a length: commands run nested commands, // whose checkpoints, undos and redos (and evictions at the limit) must be put back too. - let before_doc = if spec.mutates { + let before_doc: Option = if spec.mutates { Some(( self.doc.clone(), self.sel.clone(), @@ -1468,6 +1560,9 @@ impl Session { Err(CmdError::Failed(format!("{id} panicked: {msg}"))) } }; + if self.document_id != home || (result.is_err() && self.order.iter().any(|d| *d >= mark)) { + return self.finish_elsewhere(home, mark, result, before_doc, blocks_before, default_language_before); + } // Restrict Editing: outside the exceptions nothing may change. if result.is_ok() && guard @@ -1535,6 +1630,54 @@ impl Session { result } + /// The end of [`Session::run`] for a command that ended up in another document than it + /// started in (it opened, made, switched to or closed one) or that failed after opening one. + /// The checks that compare the document before and after the command don't apply. + /// Succeeded: the first document loses the undo step made for the command when its text is + /// as it was (opening a document is no edit to undo there). Failed: the documents the command opened close again and the first document is + /// active and as it was. + fn finish_elsewhere( + &mut self, + home: u64, + mark: u64, + result: Result, + before_doc: Option, + blocks_before: Option>, + default_language_before: Option, + ) -> Result { + match &result { + Ok(_) => { + self.put_back_parked(home, before_doc); + self.clamp_selection(); + } + Err(e) => { + self.autocap = None; + self.prefs.default_language = default_language_before; + if self.is_open(home) { + let _ = self.switch_to(home); + let opened: Vec = self.order.iter().copied().filter(|d| *d >= mark && *d != home).collect(); + for d in opened { + let _ = self.close_document(d); + } + if let Some((d, s, h, r, t, ev, c)) = before_doc { + self.doc = d; + self.sel = s; + self.history = h; + self.redo = r; + self.typing_open = t; + self.undo_evicted = ev; + self.column = c; + } + } + if let Some(b) = blocks_before { + self.building_blocks = b; + } + self.status = e.to_string(); + } + } + result + } + /// The paragraph path the caret is in. pub fn caret_path(&self) -> &Path { &self.sel.focus.path diff --git a/crates/engine/src/session/documents.rs b/crates/engine/src/session/documents.rs new file mode 100644 index 0000000000..d27c791d68 --- /dev/null +++ b/crates/engine/src/session/documents.rs @@ -0,0 +1,566 @@ +//! Several open documents in one session. The active document lives in the session's own fields, +//! where every command works on it; the others wait as [`DocState`] values and are swapped in, +//! field by field, when they become active. Swapping moves the fields: nothing is cloned, laid +//! out again or thrown away, so a host may switch documents as often as it likes. + +use std::path::{Path as FsPath, PathBuf}; +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; + +use serde::Serialize; +use serde_json::json; +use wordcraft_doc::props::CharProps; +use wordcraft_doc::{Document, Pos, StoryRef}; +use wordcraft_layout::LayoutCache; + +use super::{ColumnBlock, FindState, LayoutEntry, MathEdit, Selection, Session, TypedWord, Undo, ViewState, Widened, doc_bib_style}; +use crate::CmdError; + +/// Document ids are unique in the process, so an id an agent or a window holds never names +/// another document, even across sessions. +static NEXT_DOCUMENT_ID: AtomicU64 = AtomicU64::new(1); + +pub(super) fn next_document_id() -> u64 { + NEXT_DOCUMENT_ID.fetch_add(1, Ordering::Relaxed) +} + +/// The id the next document will get: every document made after this call has an id at least +/// this big. +pub(super) fn document_id_mark() -> u64 { + NEXT_DOCUMENT_ID.load(Ordering::Relaxed) +} + +/// An untitled document's name: `Document1`, `Document2`… +pub(super) fn untitled_name(n: u32) -> String { + format!("Document{}", n.max(1)) +} + +/// An open document that isn't the active one: every per-document field of [`Session`], waiting +/// to be swapped back in. The session's app-wide state (clipboard, preferences, macros, Quick +/// Parts, the custom dictionary, Read Aloud…) is not here. +pub struct DocState { + pub(super) doc: Document, + pub(super) sel: Selection, + pub(super) view: ViewState, + pub(super) pending: Option, + pub(super) path: Option, + pub(super) file_name: Option, + pub(super) dirty: bool, + pub(super) pending_move: Option, + pub(super) find: FindState, + pub(super) goal_x: Option, + pub(super) page_hint: usize, + pub(super) new_header_parts: Vec, + pub(super) hf_return: Option, + pub(super) history: Vec>, + pub(super) undo_evicted: u64, + pub(super) redo: Vec>, + pub(super) typing_open: bool, + pub(super) join_next: bool, + pub(super) autocap: Option, + pub(super) rev: u64, + pub(super) document_id: u64, + pub(super) untitled: u32, + pub(super) made_rev: u64, + pub(super) cache: LayoutCache, + pub(super) layout: Option, + pub(super) typing: Option, + pub(super) originals: std::collections::HashMap, + pub(super) picture_looks: std::collections::HashMap, + pub(super) versions: Vec<(String, String, Document)>, + pub(super) autosave: bool, + pub(super) bib_style: String, + pub(super) merge: crate::cmd::mailings::MergeState, + pub(super) math: Option, + pub(super) password: Option, + #[cfg(not(target_arch = "wasm32"))] + pub(super) recovery: Option, + pub(super) also_selected: Vec, + pub(super) column: Option, + pub(super) column_mode: bool, + pub(super) widened: Option, + pub(super) edit_marks: Vec, + pub(super) go_back_step: usize, +} + +impl DocState { + /// A fresh document state: caret at the start, no history, not dirty, a new id. + pub(super) fn new(doc: Document, untitled: u32, view: ViewState) -> Self { + let start = doc.start_of(StoryRef::Body); + let bib_style = doc_bib_style(&doc); + DocState { + doc, + sel: Selection::caret(start), + view, + pending: None, + path: None, + file_name: None, + dirty: false, + pending_move: None, + find: FindState::default(), + goal_x: None, + page_hint: 0, + new_header_parts: Vec::new(), + hf_return: None, + history: Vec::new(), + undo_evicted: 0, + redo: Vec::new(), + typing_open: false, + join_next: false, + autocap: None, + rev: 1, + document_id: next_document_id(), + untitled, + made_rev: 1, + cache: LayoutCache::new(), + layout: None, + typing: None, + originals: Default::default(), + picture_looks: Default::default(), + versions: Vec::new(), + autosave: true, + bib_style, + merge: Default::default(), + math: None, + password: None, + #[cfg(not(target_arch = "wasm32"))] + recovery: None, + also_selected: Vec::new(), + column: None, + column_mode: false, + widened: None, + edit_marks: Vec::new(), + go_back_step: 0, + } + } + + /// The document's id ([`Session::document_id`] while it is active). + pub fn id(&self) -> u64 { + self.document_id + } + + fn info(&self) -> DocInfo { + DocInfo { + id: self.document_id, + title: title(self.path.as_deref(), self.file_name.as_deref(), &self.doc, self.untitled), + path: self.path.clone(), + dirty: self.dirty, + active: false, + } + } +} + +/// One open document, as [`Session::documents`] lists it. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DocInfo { + /// The document's id: unique in the process, kept while the document is open. + pub id: u64, + /// The name the title bar shows: the file's name without its extension, else the name given + /// with Rename, else the document's Title property, else `Document1`, `Document2`… (English; + /// a front end names untitled documents in its own language). + pub title: String, + pub path: Option, + /// Changed since it was opened or last saved. + pub dirty: bool, + /// The active document, the one commands work on. + pub active: bool, +} + +fn title(path: Option<&FsPath>, file_name: Option<&str>, doc: &Document, untitled: u32) -> String { + match (path, file_name) { + (Some(p), _) => p.file_stem().map_or_else(|| p.to_string_lossy().to_string(), |s| s.to_string_lossy().to_string()), + (None, Some(name)) => name.to_string(), + (None, None) if !doc.core.title.is_empty() => doc.core.title.clone(), + (None, None) => untitled_name(untitled), + } +} + +impl Session { + /// Swap the active document's state with `d`'s: `d` then holds the document that was active. + fn swap_doc(&mut self, d: &mut DocState) { + use std::mem::swap; + // Every field is listed, so a new one doesn't build until someone decides whether each + // open document has its own (then it goes in `DocState` and is swapped below) or the + // session shares it. + let Session { + // Per document. + doc: _, + sel: _, + view: _, + pending: _, + path: _, + file_name: _, + dirty: _, + pending_move: _, + find: _, + goal_x: _, + page_hint: _, + new_header_parts: _, + hf_return: _, + history: _, + undo_evicted: _, + redo: _, + typing_open: _, + join_next: _, + autocap: _, + rev: _, + document_id: _, + untitled: _, + made_rev: _, + cache: _, + layout: _, + typing: _, + originals: _, + picture_looks: _, + versions: _, + autosave: _, + bib_style: _, + merge: _, + math: _, + password: _, + #[cfg(not(target_arch = "wasm32"))] + recovery: _, + also_selected: _, + column: _, + column_mode: _, + widened: _, + edit_marks: _, + go_back_step: _, + // Shared by every open document. + user_dict_path: _, + clipboard: _, + clipboard_text: _, + clipboard_html: _, + clip_history: _, + registry: _, + author: _, + initials: _, + painter: _, + status: _, + last_command: _, + recording: _, + macros: _, + building_blocks: _, + ui_requests: _, + read_aloud: _, + prefs: _, + system_region: _, + keymap: _, + ribbon: _, + editing_lang: _, + math_latex: _, + math_normal_text: _, + others: _, + order: _, + untitled_count: _, + } = self; + let DocState { + doc, + sel, + view, + pending, + path, + file_name, + dirty, + pending_move, + find, + goal_x, + page_hint, + new_header_parts, + hf_return, + history, + undo_evicted, + redo, + typing_open, + join_next, + autocap, + rev, + document_id, + untitled, + made_rev, + cache, + layout, + typing, + originals, + picture_looks, + versions, + autosave, + bib_style, + merge, + math, + password, + #[cfg(not(target_arch = "wasm32"))] + recovery, + also_selected, + column, + column_mode, + widened, + edit_marks, + go_back_step, + } = d; + swap(&mut self.doc, doc); + swap(&mut self.sel, sel); + swap(&mut self.view, view); + swap(&mut self.pending, pending); + swap(&mut self.path, path); + swap(&mut self.file_name, file_name); + swap(&mut self.dirty, dirty); + swap(&mut self.pending_move, pending_move); + swap(&mut self.find, find); + swap(&mut self.goal_x, goal_x); + swap(&mut self.page_hint, page_hint); + swap(&mut self.new_header_parts, new_header_parts); + swap(&mut self.hf_return, hf_return); + swap(&mut self.history, history); + swap(&mut self.undo_evicted, undo_evicted); + swap(&mut self.redo, redo); + swap(&mut self.typing_open, typing_open); + swap(&mut self.join_next, join_next); + swap(&mut self.autocap, autocap); + swap(&mut self.rev, rev); + swap(&mut self.document_id, document_id); + swap(&mut self.untitled, untitled); + swap(&mut self.made_rev, made_rev); + swap(&mut self.cache, cache); + swap(&mut self.layout, layout); + swap(&mut self.typing, typing); + swap(&mut self.originals, originals); + swap(&mut self.picture_looks, picture_looks); + swap(&mut self.versions, versions); + swap(&mut self.autosave, autosave); + swap(&mut self.bib_style, bib_style); + swap(&mut self.merge, merge); + swap(&mut self.math, math); + swap(&mut self.password, password); + #[cfg(not(target_arch = "wasm32"))] + swap(&mut self.recovery, recovery); + swap(&mut self.also_selected, also_selected); + swap(&mut self.column, column); + swap(&mut self.column_mode, column_mode); + swap(&mut self.widened, widened); + swap(&mut self.edit_marks, edit_marks); + swap(&mut self.go_back_step, go_back_step); + } + + /// Make document `id` active without anything else a switch by the user does. + pub(super) fn switch_to(&mut self, id: u64) -> Result<(), CmdError> { + if id == self.document_id { + return Ok(()); + } + let at = self.others.iter().position(|d| d.document_id == id).ok_or_else(|| unknown(id))?; + let mut d = self.others.swap_remove(at); + self.swap_doc(&mut d); + self.others.push(d); + Ok(()) + } + + /// Take parked document `id` out of the session; false when it isn't parked here. + fn remove_parked(&mut self, id: u64) -> bool { + let Some(at) = self.others.iter().position(|d| d.document_id == id) else { return false }; + self.others.swap_remove(at); + self.order.retain(|d| *d != id); + true + } + + /// Whether document `id` is open in this session (active or not). + pub fn is_open(&self, id: u64) -> bool { + id == self.document_id || self.others.iter().any(|d| d.document_id == id) + } + + /// Every open document in the order they were opened, the active one included. + pub fn documents(&self) -> Vec { + self.order + .iter() + .filter_map(|&id| { + if id == self.document_id { + Some(DocInfo { + id, + title: title(self.path.as_deref(), self.file_name.as_deref(), &self.doc, self.untitled), + path: self.path.clone(), + dirty: self.dirty, + active: true, + }) + } else { + self.others.iter().find(|d| d.document_id == id).map(DocState::info) + } + }) + .collect() + } + + /// Make document `id` the active one, the one commands work on (nothing happens when it + /// already is). A Read Aloud reading of another document stops. An id that isn't open is an + /// error. + pub fn activate(&mut self, id: u64) -> Result<(), CmdError> { + self.switch_to(id)?; + self.stop_reading_elsewhere(); + Ok(()) + } + + /// Open `doc` as another document and make it active; the one that was active stays open. + /// The new document starts as a replaced one does ([`Session::set_document`]): caret at the + /// start, no undo history, not dirty, no path, an untitled name (`Document2`), and the view + /// the active document had. Returns its id. + pub fn add_document(&mut self, doc: Document) -> u64 { + self.untitled_count = self.untitled_count.saturating_add(1); + self.add(doc, self.untitled_count) + } + + /// [`Session::add_document`] for a document opened from a file: it doesn't use up an + /// untitled name. + pub(crate) fn add_opened_document(&mut self, doc: Document) -> u64 { + self.add(doc, 0) + } + + fn add(&mut self, mut doc: Document, untitled: u32) -> u64 { + doc.ensure_nonempty(); + let mut view = self.view.clone(); + view.outline.collapsed.clear(); + let mut d = DocState::new(doc, untitled, view); + #[cfg(not(target_arch = "wasm32"))] + { + d.recovery = self.recovery.as_ref().and_then(crate::recovery::Recovery::sibling); + } + let id = d.document_id; + self.order.push(id); + self.swap_doc(&mut d); + self.others.push(d); + self.stop_reading_elsewhere(); + id + } + + /// Close document `id` without asking: its unsaved changes are lost (callers ask first). + /// Closing the active document makes the one opened before it active (else the one after). + /// Closing the last open document leaves a new blank one, so a session always has an + /// active document; what a window does then is the front end's choice. An id that isn't + /// open is an error. + pub fn close_document(&mut self, id: u64) -> Result<(), CmdError> { + if id != self.document_id { + if !self.remove_parked(id) { + return Err(unknown(id)); + } + return Ok(()); + } + let at = self.order.iter().position(|d| *d == id); + let next = at.and_then(|i| i.checked_sub(1).and_then(|p| self.order.get(p)).or_else(|| self.order.get(i.saturating_add(1)))).copied(); + match next { + Some(n) => { + self.switch_to(n)?; + self.remove_parked(id); + } + None => { + let mut doc = crate::cmd::file::normal_document(self); + doc.ensure_nonempty(); + self.untitled_count = self.untitled_count.saturating_add(1); + let mut view = self.view.clone(); + view.outline.collapsed.clear(); + let mut fresh = DocState::new(doc, self.untitled_count, view); + #[cfg(not(target_arch = "wasm32"))] + { + fresh.recovery = self.recovery.as_ref().and_then(crate::recovery::Recovery::sibling); + } + self.order = vec![fresh.document_id]; + self.swap_doc(&mut fresh); + } + } + self.stop_reading_elsewhere(); + Ok(()) + } + + /// Whether the active document is a blank one nobody has used: no file and no name, never + /// changed, nothing to undo or redo and no text. Opening a file replaces such a document + /// rather than opening beside it. + pub fn is_untouched_blank(&self) -> bool { + self.path.is_none() + && self.file_name.is_none() + && !self.dirty + && self.history.is_empty() + && self.redo.is_empty() + && self.rev == self.made_rev + && self.doc.paragraph_count() <= 1 + && self.doc.plain_text(StoryRef::Body).trim().is_empty() + } + + /// Run `f` with document `id` active, then make the document that was active before active + /// again (unless `f` closed it). For work addressed to a document other than the one a + /// window shows: an agent's command, a result that arrives later. Read Aloud carries on. + /// UI requests `f` makes (`ui_requests`) carry `"document": id` when that isn't the + /// document that was active. An id that isn't open is an error and `f` doesn't run. + pub fn with_document(&mut self, id: u64, f: impl FnOnce(&mut Session) -> R) -> Result { + let home = self.document_id; + self.switch_to(id)?; + let queued = self.ui_requests.len(); + let r = f(self); + if id != home { + for q in self.ui_requests.iter_mut().skip(queued) { + if let Some(o) = q.as_object_mut() { + o.entry("document").or_insert(json!(id)); + } + } + } + if self.document_id != home && self.is_open(home) { + self.switch_to(home)?; + } + Ok(r) + } + + /// The open document saved at `path`, if any: the same file reached by another spelling + /// (`./a.docx`, a symbolic link) counts on desktop. + pub fn find_open(&self, path: &FsPath) -> Option { + #[cfg(not(target_arch = "wasm32"))] + let wanted = std::fs::canonicalize(path).ok(); + #[cfg(target_arch = "wasm32")] + let wanted: Option = None; + let same = |p: &FsPath| { + if p == path { + return true; + } + #[cfg(not(target_arch = "wasm32"))] + if let Some(w) = &wanted { + return std::fs::canonicalize(p).ok().as_ref() == Some(w); + } + let _ = &wanted; + false + }; + self.order.iter().copied().find(|&id| { + if id == self.document_id { + self.path.as_deref().is_some_and(same) + } else { + self.others.iter().find(|d| d.document_id == id).and_then(|d| d.path.as_deref()).is_some_and(same) + } + }) + } + + /// Stop a Read Aloud reading of a document that isn't the active one (it highlights its + /// sentences in the document shown). + fn stop_reading_elsewhere(&mut self) { + if self.read_aloud.document.is_some_and(|d| d != self.document_id) && self.read_aloud.state() != crate::speech::State::Stopped { + self.read_aloud.stop(); + } + } + + /// Take the undo step `run` made for a command back out of the document it started in, when + /// the command ended up in another document (it opened or made one) and left the first + /// document's text as it was: opening a document is no edit to undo there. + pub(super) fn put_back_parked(&mut self, id: u64, before: Option) { + let Some((doc, sel, history, redo, typing_open, evicted, column)) = before else { return }; + if !self.is_open(id) { + return; + } + let _ = self.with_document(id, |s| { + if s.doc != doc { + return; + } + s.sel = sel; + s.history = history; + s.redo = redo; + s.typing_open = typing_open; + s.undo_evicted = evicted; + s.column = column; + }); + } +} + +fn unknown(id: u64) -> CmdError { + CmdError::Params(format!("no open document has id {id}")) +} diff --git a/crates/engine/src/speech.rs b/crates/engine/src/speech.rs index 30d447f783..d06113d634 100644 --- a/crates/engine/src/speech.rs +++ b/crates/engine/src/speech.rs @@ -513,6 +513,9 @@ pub struct ReadAloud { pub skip_citations: bool, /// The player window is open (it stays open after the last sentence until closed). pub open: bool, + /// The document being read ([`crate::Session::document_id`]): the sentences' positions are + /// in it. + pub document: Option, } impl Default for ReadAloud { @@ -526,6 +529,7 @@ impl Default for ReadAloud { backend: if off { Backend::Hold } else { Backend::System }, skip_citations: true, open: false, + document: None, } } } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index fda7aa5c09..b89836b917 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -3829,10 +3829,10 @@ fn document_id_changes_only_when_the_document_is_replaced() { assert_ne!(s.document_id(), second); } -/// Envelopes, Labels and Finish & Merge make a new, untitled document, as Word does, and like New -/// its undo history starts afresh. Undo/Redo across the swap put the wrong document under a file -/// (Undo, Save As, Redo, Save wrote the envelope over the saved file), so it isn't offered; the UI -/// asks to save the replaced document first. +/// Envelopes, Labels and Finish & Merge open a new, untitled document beside the main one, as Word +/// does, and like New its undo history starts afresh. Undo/Redo across documents would put the +/// wrong document under a file (Undo, Save As, Redo, Save wrote the envelope over the saved file), +/// so it isn't offered. The main document stays open as it was, with no undo step for the command. #[test] fn mailings_results_are_new_untitled_documents() { let dir = std::env::temp_dir().join(format!("wordcraft-engine-mailings-{}", std::process::id())); @@ -3852,10 +3852,13 @@ fn mailings_results_are_new_untitled_documents() { let before = text(&s); let document = s.document_id(); + let undo_steps = s.undo_labels().len(); run(&mut s, id, json!({})); let result = text(&s); - assert_ne!(result, before, "{id}: the result replaced the document"); + assert_ne!(result, before, "{id}: the result is the active document"); assert_ne!(s.document_id(), document, "{id}: a different document"); + let main = s.with_document(document, |s| (text(s), s.undo_labels().len())).unwrap(); + assert_eq!(main, (before.clone(), undo_steps), "{id}: the main document stays open as it was"); assert_eq!(s.path, None, "{id}: the result is untitled"); assert_eq!(run(&mut s, "file.save", json!({}))["saved"], false, "{id}: Save asks where (Save As)"); assert_eq!(std::fs::read(&original).unwrap(), on_disk, "{id}: the original file is untouched"); @@ -4922,6 +4925,8 @@ fn one_page_is_restored_when_the_document_is_reopened() { // A percentage is restored as a percentage, the fit mode dropped. run(&mut other, "view.zoom", json!({"value": 80})); run(&mut other, "file.save", json!({})); + // Opening an open file only shows it: close it first to read it again. + run(&mut s, "file.closeDocument", json!({})); run(&mut s, "file.open", json!({"path": path})); assert!(s.view.fit.is_empty() && !s.view.single_column); assert!((s.view.zoom - 0.8).abs() < 1e-6); diff --git a/crates/engine/src/tests_documents.rs b/crates/engine/src/tests_documents.rs new file mode 100644 index 0000000000..329c25c9d0 --- /dev/null +++ b/crates/engine/src/tests_documents.rs @@ -0,0 +1,255 @@ +//! Several open documents in one session: File › New and Open open beside the others, each +//! document keeps its own text, selection, undo and find state, and work addressed to a document +//! by id lands in that document whichever one is active. + +use serde_json::{Value, json}; +use wordcraft_doc::StoryRef; + +use crate::Session; + +fn s() -> Session { + Session::new(wordcraft_doc::Document::new()) +} + +fn run(s: &mut Session, id: &str, v: Value) -> Value { + s.run(id, &v).unwrap_or_else(|e| panic!("{id}: {e}")) +} + +fn text(s: &Session) -> String { + s.doc.plain_text(StoryRef::Body) +} + +fn ids(s: &Session) -> Vec { + s.documents().iter().map(|d| d.id).collect() +} + +fn temp_dir(name: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!("wordcraft-engine-docs-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +#[test] +fn new_opens_another_document_and_each_keeps_its_own_text_and_undo() { + let mut s = s(); + let a = s.document_id(); + run(&mut s, "text.insert", json!({"text": "Alpha"})); + run(&mut s, "file.new", json!({})); + let b = s.document_id(); + assert_ne!(a, b); + assert_eq!(ids(&s), vec![a, b], "opening order"); + let docs = s.documents(); + assert_eq!(docs.iter().map(|d| (d.title.as_str(), d.active)).collect::>(), vec![("Document1", false), ("Document2", true)]); + assert!(docs[0].dirty && !docs[1].dirty); + assert_eq!(text(&s), "", "the new document is active"); + run(&mut s, "text.insert", json!({"text": "Beta"})); + + s.activate(a).unwrap(); + assert_eq!(text(&s), "Alpha"); + assert_eq!(s.document_id(), a, "ids stay with their documents"); + s.activate(a).unwrap(); + s.activate(b).unwrap(); + assert_eq!(text(&s), "Beta"); + run(&mut s, "file.new", json!({})); + assert_eq!(s.documents()[2].title, "Document3"); +} + +/// Undo goes with its document: edit A, edit B, back to A, Undo takes A's edit back and leaves +/// B alone; Redo puts it back. Find state stays with its document too. +#[test] +fn undo_and_find_stay_with_their_document_across_switches() { + let mut s = s(); + let a = s.document_id(); + run(&mut s, "text.insert", json!({"text": "Alpha"})); + s.find.query = "Alpha".into(); + let b = s.add_document(wordcraft_doc::Document::new()); + assert_eq!(s.find.query, "", "a new document has its own find state"); + run(&mut s, "text.insert", json!({"text": "Beta"})); + s.find.query = "Beta".into(); + + s.activate(a).unwrap(); + assert_eq!(s.find.query, "Alpha"); + run(&mut s, "edit.undo", json!({})); + assert_eq!(text(&s), ""); + assert_eq!(s.with_document(b, |s| (text(s), s.undo_depth(), s.find.query.clone())).unwrap(), ("Beta".to_string(), 1, "Beta".to_string())); + run(&mut s, "edit.redo", json!({})); + assert_eq!(text(&s), "Alpha"); + s.activate(b).unwrap(); + run(&mut s, "edit.undo", json!({})); + assert_eq!(text(&s), ""); + assert_eq!(s.with_document(a, |s| text(s)).unwrap(), "Alpha"); +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn open_replaces_an_untouched_blank_document_and_otherwise_opens_beside() { + let dir = temp_dir("open"); + let one = dir.join("one.txt"); + let two = dir.join("two.txt"); + std::fs::write(&one, "One").unwrap(); + std::fs::write(&two, "Two").unwrap(); + + let mut s = s(); + assert!(s.is_untouched_blank()); + let r = run(&mut s, "file.open", json!({"path": one.to_string_lossy()})); + assert_eq!(r["alreadyOpen"], false); + assert_eq!(s.documents().len(), 1, "the untouched blank document was replaced"); + let first = s.document_id(); + assert_eq!(s.documents()[0].title, "one"); + + run(&mut s, "file.open", json!({"path": two.to_string_lossy()})); + assert_eq!(s.documents().len(), 2, "a document with a file stays open"); + let second = s.document_id(); + assert_eq!(text(&s), "Two"); + + // The same file again, by another spelling: it becomes the active document. + let again = dir.join(".").join("one.txt"); + let r = run(&mut s, "file.open", json!({"path": again.to_string_lossy()})); + assert_eq!((r["alreadyOpen"].as_bool(), r["id"].as_u64()), (Some(true), Some(first))); + assert_eq!((s.document_id(), s.documents().len()), (first, 2)); + assert_eq!(s.find_open(&two), Some(second)); + + // A failed open changes nothing. + assert!(s.run("file.open", &json!({"path": dir.join("missing.docx").to_string_lossy()})).is_err()); + assert_eq!((ids(&s), s.document_id()), (vec![first, second], first)); + + // An edited blank document isn't replaced. + let mut s = self::s(); + run(&mut s, "text.insert", json!({"text": "x"})); + run(&mut s, "text.backspace", json!({})); + assert!(!s.is_untouched_blank()); + run(&mut s, "file.open", json!({"path": one.to_string_lossy()})); + assert_eq!(s.documents().len(), 2); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn close_activates_the_previous_document_and_the_last_leaves_a_blank_one() { + let mut s = s(); + let a = s.document_id(); + let b = s.add_document(wordcraft_doc::Document::from_text("B")); + let c = s.add_document(wordcraft_doc::Document::from_text("C")); + s.activate(b).unwrap(); + s.close_document(b).unwrap(); + assert_eq!((ids(&s), s.document_id()), (vec![a, c], a), "the one opened before it"); + s.close_document(c).unwrap(); + s.close_document(a).unwrap(); + assert_eq!(s.documents().len(), 1); + assert!(![a, b, c].contains(&s.document_id())); + assert!(s.is_untouched_blank()); + assert!(s.close_document(a).is_err(), "closed documents are gone"); + + // Closing the first makes the next one active. + let d = s.document_id(); + let e = s.add_document(wordcraft_doc::Document::new()); + s.activate(d).unwrap(); + s.close_document(d).unwrap(); + assert_eq!(s.document_id(), e); +} + +#[test] +fn close_document_refuses_unsaved_changes_unless_forced() { + let mut s = s(); + let a = s.document_id(); + run(&mut s, "text.insert", json!({"text": "Draft"})); + let b = s.add_document(wordcraft_doc::Document::new()); + assert!(s.run("file.closeDocument", &json!({"id": a})).is_err()); + assert_eq!(ids(&s), vec![a, b], "still open"); + let r = run(&mut s, "file.closeDocument", json!({})); + assert_eq!((r["closed"].as_u64(), r["active"].as_u64()), (Some(b), Some(a))); + let r = run(&mut s, "file.closeDocument", json!({"id": a, "force": true})); + assert_eq!(r["documents"].as_array().map(Vec::len), Some(1)); + assert!(s.is_untouched_blank()); + for bad in [json!({"id": -1}), json!({"id": "x"}), json!({"id": u64::MAX}), json!({"id": 1.5}), json!({"id": []})] { + assert!(s.run("file.closeDocument", &bad).is_err(), "{bad}"); + } + assert_eq!(s.documents().len(), 1); +} + +/// Work addressed to a document by id lands in it, whichever document is active, and the active +/// one is left as it was. +#[test] +fn with_document_works_on_the_given_document_and_keeps_the_active_one() { + let mut s = s(); + let a = s.document_id(); + run(&mut s, "text.insert", json!({"text": "Alpha"})); + let b = s.add_document(wordcraft_doc::Document::new()); + run(&mut s, "text.insert", json!({"text": "Beta"})); + let (b_rev, b_undo) = (s.rev(), s.undo_depth()); + + let r = s.with_document(a, |s| { + s.run("text.insert", &json!({"text": " more"})).unwrap(); + s.run("edit.advancedFind", &json!({})).ok(); + s.document_id() + }); + assert_eq!(r.unwrap(), a); + assert_eq!(s.document_id(), b); + assert_eq!((text(&s), s.rev(), s.undo_depth()), ("Beta".to_string(), b_rev, b_undo), "B untouched"); + assert_eq!(s.with_document(a, |s| text(s)).unwrap(), "Alpha more"); + assert!(s.ui_requests.iter().all(|q| q.get("open").is_none() || q["document"] == a), "requests name their document: {:?}", s.ui_requests); + assert!(s.with_document(u64::MAX, |_| ()).is_err()); + + // Closing the active document inside leaves the one it ran on active. + s.with_document(a, |s| s.close_document(b)).unwrap().unwrap(); + assert_eq!(ids(&s), vec![a]); + assert_eq!(s.document_id(), a); +} + +/// A Read Aloud reading names its document: switching documents stops it, and turning citation +/// skipping on in another document doesn't restart it at the first document's positions there. +#[test] +fn read_aloud_belongs_to_its_document() { + let mut s = s(); + let a = s.document_id(); + run(&mut s, "text.insert", json!({"text": "One sentence. Another one."})); + run(&mut s, "caret.docStart", json!({})); + run(&mut s, "review.readAloud", json!({})); + assert_eq!(s.read_aloud.document, Some(a)); + let b = s.add_document(wordcraft_doc::Document::from_text("Other text.")); + assert_eq!(s.read_aloud.state(), crate::speech::State::Stopped, "the reading of A stopped"); + run(&mut s, "readAloud.skipCitations", json!({"value": false})); + assert_eq!(s.read_aloud.document, Some(a), "nothing restarted in B"); + run(&mut s, "review.readAloud", json!({})); + assert_eq!(s.read_aloud.document, Some(b)); +} + +#[test] +fn switch_windows_lists_and_activates() { + let mut s = s(); + let a = s.document_id(); + let b = s.add_document(wordcraft_doc::Document::new()); + let r = run(&mut s, "view.switchWindows", json!({})); + let list = r["documents"].as_array().unwrap(); + assert_eq!( + list.iter().map(|d| (d["id"].as_u64(), d["title"].as_str(), d["active"].as_bool())).collect::>(), + vec![(Some(a), Some("Document1"), Some(false)), (Some(b), Some("Document2"), Some(true))] + ); + assert!(list[0].get("path").is_some() && list[0].get("dirty").is_some()); + run(&mut s, "view.switchWindows", json!({"id": a})); + assert_eq!(s.document_id(), a); + for bad in [json!({"id": 0}), json!({"id": u64::MAX}), json!({"id": -5}), json!({"id": "1"}), json!({"id": 1e300}), json!({"id": {}})] { + assert!(s.run("view.switchWindows", &bad).is_err(), "{bad}"); + assert_eq!(s.document_id(), a); + } + let junk = [json!(null), json!([]), json!({"id": null}), json!({"text": 5})]; + for id in ["view.switchWindows", "file.closeDocument", "file.new"] { + for j in &junk { + let _ = s.run(id, j); + s.clamp_selection(); + let _ = s.layout(); + } + } + assert!(!s.documents().is_empty()); +} + +#[test] +fn document_ids_are_unique_across_sessions_and_replacements() { + let mut one = s(); + let two = s(); + assert_ne!(one.document_id(), two.document_id()); + let before = one.document_id(); + one.set_document(wordcraft_doc::Document::new()); + assert_ne!(one.document_id(), before); + assert_eq!(ids(&one), vec![one.document_id()], "the replacement takes its place in the list"); +} diff --git a/docs/gaps.md b/docs/gaps.md index 09bbfc6197..3a96ca63b5 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where WordCraft falls short of Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-21 · **Change:** trivial (explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: text mini toolbar available with the right-click menu, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: tracked table/row/cell/section property changes record in WordCraft, narrowing gap 5; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: Format Page Numbers dialog carries Word's number formats and Continue from previous section / Start at, #1113; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously trivial: #8: English dialects — en-GB, en-CA, en-AU… flag each other's spellings, #1281; previously trivial: #1: shape gradients and transparency, #1500; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: line breaking: a slash opening a word travels whole to the next line, #2963; previously trivial: recipient list search and merged-only view, #1576; previously trivial: #2 impact restated: only ink rendering and OLE editing remain; previously trivial: #2 narrowed: native ink nested in groups and drawing canvases kept on save; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: automatic Asian/Latin and Asian/number spacing laid out, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: numbered headings, #1793; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: #8 narrowed: Indonesian proofing from the installed Hunspell dictionary, #498; previously trivial: glossary/building blocks closed; previously trivial: Split Cells and Convert Text to Table follow the Word check, #2592; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: #29: a copied equation also goes in the MathML clipboard formats, #1297; previously trivial: #24: the Spike, Ctrl+F3 and Ctrl+Shift+F3; previously trivial: VML freeforms read and kept on save; previously trivial: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: copies keep their formatting on the clipboard as HTML, #1523; previously trivial: sample-text formulas on Enter, #1067; previously trivial: #29: copied equations as MathML, OMML open as #1297, #816; previously trivial: the Font list no longer keeps every previewed font in memory, #848; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** trivial (View windows: several open documents and Switch Windows in the engine; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: text mini toolbar available with the right-click menu, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: tracked table/row/cell/section property changes record in WordCraft, narrowing gap 5; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: Format Page Numbers dialog carries Word's number formats and Continue from previous section / Start at, #1113; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously trivial: #8: English dialects — en-GB, en-CA, en-AU… flag each other's spellings, #1281; previously trivial: #1: shape gradients and transparency, #1500; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: line breaking: a slash opening a word travels whole to the next line, #2963; previously trivial: recipient list search and merged-only view, #1576; previously trivial: #2 impact restated: only ink rendering and OLE editing remain; previously trivial: #2 narrowed: native ink nested in groups and drawing canvases kept on save; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: automatic Asian/Latin and Asian/number spacing laid out, #2358; previously trivial: hint-reliant CJK outlines; previously trivial: numbered headings, #1793; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: #8 narrowed: Indonesian proofing from the installed Hunspell dictionary, #498; previously trivial: glossary/building blocks closed; previously trivial: Split Cells and Convert Text to Table follow the Word check, #2592; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: #29: a copied equation also goes in the MathML clipboard formats, #1297; previously trivial: #24: the Spike, Ctrl+F3 and Ctrl+Shift+F3; previously trivial: VML freeforms read and kept on save; previously trivial: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: floating-table overlap ignores empty line-break lines; previously trivial: copies keep their formatting on the clipboard as HTML, #1523; previously trivial: sample-text formulas on Enter, #1067; previously trivial: #29: copied equations as MathML, OMML open as #1297, #816; previously trivial: the Font list no longer keeps every previewed font in memory, #848; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: beta distance restated: ~15 points, ~150–230 h; previously: first version; every known shortfall from the 2026-10-10 re-measure, ranked) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 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). @@ -80,7 +80,7 @@ Text context menus now include the existing formatting mini toolbar (#2959), inc | 23 | **PDF Reflow** (open a PDF as an editable document, Word for Windows) | | | Users who edit PDFs they receive | 30–50 | [file-format-parity.md](file-format-parity.md) | | 24 | **Default shortcuts**: 140 (macOS) / 158 (Linux/Windows) of Word's ~250 (#330, #339, #343; Customize Keyboard #368, Customize Ribbon and Quick Access Toolbar #379; inspector, macros, strikethrough, file/edit keys, the Spike) | | [ui-parity.md](ui-parity.md) | Power users | 1–3 | [ui-parity.md](ui-parity.md) | | 25 | **In-app AI assistant** (Copilot-style draft, rewrite, summarize) | | Chat PR #178 and AI assistant PR #815 open | Users expecting Copilot | 15–30 + owner (provider) | [target-app-parity.md](target-app-parity.md) | -| 27 | **View windows**: Side by Side, Synchronous Scrolling, Arrange All, Switch Windows | | [parity-checklist.md](parity-checklist.md) (View 25/29); PR #331 open | Comparing documents | 3–5 | [ui-parity.md](ui-parity.md) | +| 27 | **View windows**: Side by Side, Synchronous Scrolling, Arrange All; a window per open document (the engine holds several open documents and Switch Windows lists and switches them) | | [parity-checklist.md](parity-checklist.md) (View 26/29); PR #331 open | Comparing documents | 3–5 | [ui-parity.md](ui-parity.md) | | 26 | **Minor formats**: MHT, Works/WordPerfect | | Word XML Document (Flat OPC) opens and saves, Word 2003 XML opens (#548) and saves (#1081, not yet checked in Word) | Rare | 14–26 | [file-format-parity.md](file-format-parity.md) | | 28 | **Dictate** | | `tools.dictate` missing | Dictation users | 8–15 + owner (speech model) | [hardware-parity.md](hardware-parity.md) | | 29 | **Equations**: ink equations; a copied equation pasting into Word as an editable equation, still to be confirmed (#816, #1297) | | #191 notes; long display equations wrap at operators (#326); a copy holding an equation puts MathML in HTML on the clipboard (#816), and a copy of one equation alone also its MathML in the "MathML" clipboard formats Word reads first (Windows, macOS; #1297; Word/LibreOffice paste not yet verified) | Maths-heavy documents | 2–3 | [layout-parity.md](layout-parity.md) | @@ -125,6 +125,7 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps | Date | Change | Summary | |---|---|---| +| 2026-10-22 | trivial | View windows: several open documents and Switch Windows in the engine | | 2026-10-18 | trivial | Explicit New/Open is not interrupted by startup recovery discovery; older copies stay available and Recover follows the interface theme. | | 2026-10-21 | trivial | Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459) | | 2026-10-21 | trivial | #3172: the ribbon's font name and size boxes, and the dropdown arrows beside them, say what they are to a screen reader | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 764c83f871..0b80be3426 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -2,7 +2,7 @@ Generated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word-processor feature catalog (`crates/engine/src/catalog.rs`) compared with the live command registry. -**498 of 513 catalog features have commands (97.1%).** +**499 of 513 catalog features have commands (97.3%).** | Tab | Live | Total | |---|---|---| @@ -17,7 +17,7 @@ Generated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word- | References | 25 | 25 | | Mailings | 19 | 19 | | Review | 29 | 31 | -| View | 25 | 29 | +| View | 26 | 29 | | Developer | 11 | 11 | | Table Design | 8 | 8 | | Table Layout | 33 | 33 | @@ -60,7 +60,6 @@ Generated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word- - Window › Arrange All (`view.arrangeAll`) - Window › View Side by Side (`view.sideBySide`) - Window › Synchronous Scrolling (`view.syncScroll`) -- Window › Switch Windows (`view.switchWindows`) ### Equation From a2ca57ca3fdb6f353b2a1c218e15c033eae45b7e Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 14:53:21 +0000 Subject: [PATCH 008/199] W1: MCP, control channel and CLI address an open document by id - MCP: execute, batch, type_text, select_text, get_text, inspect_document, render_page and save_document take an optional doc (an open document's id); the headless server runs them through Session::with_document and the bridge passes doc to the app. New list_documents tool; new_document replies with the new document's id. Tool descriptions and the server instructions say that file.new and file.open open another document. - Control channel: engine.execute, document.inspect and ui.render take doc and work on that document while the window keeps the active one; file.close on an addressed document refuses unsaved changes unless forced; ui.* commands need the document on screen. ui.inspect reports document and documents. Unknown or malformed ids are errors. - CLI run starts with one open document; help and docs describe how file.new, file.open and view.switchWindows work in a run. - docs/mcp.md and docs/control-protocol.md describe document ids and doc. --- apps/wordcraft-cli/src/main.rs | 10 ++- apps/wordcraft-cli/tests/documents.rs | 39 ++++++++ crates/mcp/src/lib.rs | 75 +++++++++++----- crates/mcp/src/server.rs | 5 +- crates/mcp/src/tests.rs | 42 +++++++++ crates/mcp/src/tools.rs | 123 ++++++++++++++++++++------ crates/ui-egui/src/control.rs | 105 ++++++++++++++++++++-- crates/ui-egui/src/control_tests.rs | 122 +++++++++++++++++++++++++ docs/control-protocol.md | 36 ++++++-- docs/mcp.md | 30 ++++++- 10 files changed, 519 insertions(+), 68 deletions(-) create mode 100644 apps/wordcraft-cli/tests/documents.rs create mode 100644 crates/ui-egui/src/control_tests.rs diff --git a/apps/wordcraft-cli/src/main.rs b/apps/wordcraft-cli/src/main.rs index 78e4bbfa1a..0517f57b72 100644 --- a/apps/wordcraft-cli/src/main.rs +++ b/apps/wordcraft-cli/src/main.rs @@ -36,7 +36,11 @@ USAGE: {\"file\"?, \"template\"?, \"commands\": [{\"command\": id, \"params\"?: {…}} | id, …], \"save\"?: [OUT, …]}; save formats follow the extension (docx and pdf in one run); - relative paths resolve against the script's folder + relative paths resolve against the script's folder. + file.new and file.open open another document and + make it active; later commands and the saves work on + the active one; view.switchWindows lists the open + documents and with {\"id\": N} makes one active wordcraft-cli commands [--json] list every command wordcraft-cli parity [--markdown] feature-catalog parity wordcraft-cli mcp [--connect HOST:PORT | --author NAME] [--automation-read-root DIR] [--automation-write-root DIR] @@ -472,10 +476,14 @@ fn run>(args: &[S]) -> Result<(), Failure> { let mut s = match (from_file, from_template) { (Some(f), _) => open(&f, password.as_deref())?, // Both go through `file.new`, which puts a blank document on the region's paper (#3152). + // `file.new` opens beside the session's first document, which is then closed: the + // run starts with one open document. (None, t) => { let mut s = session(wordcraft_doc::Document::new()); + let first = s.document_id(); let params = t.map_or_else(|| json!({}), |t| json!({"template": t})); s.run("file.new", ¶ms).map_err(|e| e.to_string())?; + s.close_document(first).map_err(|e| e.to_string())?; s } }; diff --git a/apps/wordcraft-cli/tests/documents.rs b/apps/wordcraft-cli/tests/documents.rs new file mode 100644 index 0000000000..e62a880d7b --- /dev/null +++ b/apps/wordcraft-cli/tests/documents.rs @@ -0,0 +1,39 @@ +//! `wordcraft-cli run` with documents opened along the way: `file.new` and `file.open` open +//! another document and make it active, and `--save` saves the active one. + +use std::path::Path; +use std::process::{Command, Output}; + +fn run(args: &[&str], out: &Path) -> Output { + let mut cli = Command::new(env!("CARGO_BIN_EXE_wordcraft-cli")); + cli.arg("run").args(args).arg("--save").arg(out); + cli.output().unwrap() +} + +#[test] +fn run_saves_the_document_opened_last() { + let dir = std::env::temp_dir().join(format!("wordcraft-cli-documents-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let (src, out) = (dir.join("opened.txt"), dir.join("out.txt")); + std::fs::write(&src, "Opened\n").unwrap(); + let open = format!(r#"file.open={{"path":{}}}"#, serde_json::json!(src.to_string_lossy())); + + // A script that starts a new document and types into it saves that document. + let made = run(&["--template", "sample", "--cmd", "file.new", "--cmd", r#"text.insert={"text":"Fresh"}"#], &out); + let fresh = std::fs::read_to_string(&out).unwrap_or_default(); + // A file opened after typing is what is saved; the typed document stays out of it. + let opened = run(&["--template", "blank", "--cmd", r#"text.insert={"text":"Typed"}"#, "--cmd", &open, "--cmd", "caret.docEnd"], &out); + let reopened = std::fs::read_to_string(&out).unwrap_or_default(); + // A run starts with one open document. + let mut cli = Command::new(env!("CARGO_BIN_EXE_wordcraft-cli")); + let listed = cli.args(["run", "--template", "sample", "--print", "--cmd", "view.switchWindows"]).output().unwrap(); + let _ = std::fs::remove_dir_all(&dir); + + assert!(made.status.success(), "stderr: {}", String::from_utf8_lossy(&made.stderr)); + assert_eq!(fresh.trim(), "Fresh"); + assert!(opened.status.success(), "stderr: {}", String::from_utf8_lossy(&opened.stderr)); + assert_eq!(reopened.trim(), "Opened"); + assert!(listed.status.success(), "stderr: {}", String::from_utf8_lossy(&listed.stderr)); + let stdout = String::from_utf8_lossy(&listed.stdout); + assert_eq!(stdout.matches("\"id\"").count(), 1, "{stdout}"); +} diff --git a/crates/mcp/src/lib.rs b/crates/mcp/src/lib.rs index 9c4a875bfe..d96e77411f 100644 --- a/crates/mcp/src/lib.rs +++ b/crates/mcp/src/lib.rs @@ -35,30 +35,59 @@ impl Headless { self } - fn answer(&mut self, method: &str, params: Value) -> Result { - match method { - "engine.execute" | "command" => { - let id = params.get("command").and_then(Value::as_str).ok_or("missing `command`")?.to_string(); - let p = params.get("params").cloned().filter(|v| !v.is_null()).unwrap_or(json!({})); - self.session.run(&id, &p).map_err(|e| e.to_string()) - } - "engine.commands" => Ok(self.session.registry.describe()), - "document.inspect" => self.session.run("document.inspect", ¶ms).map_err(|e| e.to_string()), - "ui.parity" => Ok(wordcraft_engine::catalog::parity(&self.session.registry)), - "ui.render" | "render.page" => { - let page = params.get("page").and_then(Value::as_u64).unwrap_or(1).max(1) as usize - 1; - let scale = params.get("scale").and_then(Value::as_f64).unwrap_or(1.0).clamp(0.1, 4.0) as f32; - let l = self.session.layout(); - let pg = l.pages.get(page).ok_or_else(|| format!("no page {}", page + 1))?; - let img = wordcraft_render::render_page(&self.session.doc, pg, scale, &Default::default()); - let png = img.to_png(); - Ok( - json!({"png": wordcraft_engine::cmd::insert::base64_encode(&png), "width": img.width, "height": img.height, "pages": l.pages.len()}), - ) - } - m if m.starts_with("ui.") => Err(format!("`{m}` {NEEDS_APP}")), - other => self.session.run(other, ¶ms).map_err(|e| e.to_string()), + /// Answer `method`; a document method addressed to another open document (`doc`) runs on + /// that document, and the active one stays active. + fn answer(&mut self, method: &str, mut params: Value) -> Result { + if !DOC_METHODS.contains(&method) { + return answer_on(&mut self.session, method, params); } + let doc = doc_param(¶ms)?; + if let Some(o) = params.as_object_mut() { + o.remove("doc"); + } + match doc { + None => answer_on(&mut self.session, method, params), + Some(id) => self.session.with_document(id, |s| answer_on(s, method, params)).map_err(|e| e.to_string())?, + } + } +} + +/// The methods that work on one document, and so take `doc`: the id of the open document to work +/// on (`view.switchWindows` lists them), the active one when absent. +pub const DOC_METHODS: &[&str] = &["engine.execute", "command", "document.inspect", "ui.render", "render.page"]; + +/// The document a request addresses with `doc`: `None` (absent or null) is the active document. +/// Anything but a whole number from 0 up is an error; whether it names an open document is for +/// the session to say. +pub fn doc_param(params: &Value) -> Result, String> { + match params.get("doc") { + None | Some(Value::Null) => Ok(None), + Some(v) => v.as_u64().map(Some).ok_or_else(|| "`doc` must be the id of an open document (list_documents lists them)".to_string()), + } +} + +fn answer_on(session: &mut Session, method: &str, params: Value) -> Result { + match method { + "engine.execute" | "command" => { + let id = params.get("command").and_then(Value::as_str).ok_or("missing `command`")?.to_string(); + let p = params.get("params").cloned().filter(|v| !v.is_null()).unwrap_or(json!({})); + session.run(&id, &p).map_err(|e| e.to_string()) + } + "engine.commands" => Ok(session.registry.describe()), + "document.inspect" => session.run("document.inspect", ¶ms).map_err(|e| e.to_string()), + "ui.parity" => Ok(wordcraft_engine::catalog::parity(&session.registry)), + "ui.render" | "render.page" => { + let page = params.get("page").and_then(Value::as_u64).unwrap_or(1).max(1); + let page = usize::try_from(page).unwrap_or(usize::MAX).saturating_sub(1); + let scale = params.get("scale").and_then(Value::as_f64).unwrap_or(1.0).clamp(0.1, 4.0) as f32; + let l = session.layout(); + let pg = l.pages.get(page).ok_or_else(|| format!("no page {}", page.saturating_add(1)))?; + let img = wordcraft_render::render_page(&session.doc, pg, scale, &Default::default()); + let png = img.to_png(); + Ok(json!({"png": wordcraft_engine::cmd::insert::base64_encode(&png), "width": img.width, "height": img.height, "pages": l.pages.len()})) + } + m if m.starts_with("ui.") => Err(format!("`{m}` {NEEDS_APP}")), + other => session.run(other, ¶ms).map_err(|e| e.to_string()), } } diff --git a/crates/mcp/src/server.rs b/crates/mcp/src/server.rs index 63f6764c61..c0b1a40272 100644 --- a/crates/mcp/src/server.rs +++ b/crates/mcp/src/server.rs @@ -22,7 +22,10 @@ const INSTRUCTIONS: &str = "WordCraft is a word processor (a Microsoft Word clon parameters with list_commands and run them with execute (or several with batch). Positions are \ {story:\"body\", path:[block,(row,cell,block)...], off:byteOffset}. Typical flow: new_document → type_text \ \"Hello\" → execute text.newParagraph → execute para.style {style:\"Heading 1\"} → select_text {text:\"Hello\"} → \ -execute format.bold → inspect_document to verify → render_page to look at the result → save {path:\"out.docx\"}."; +execute format.bold → inspect_document to verify → render_page to look at the result → save {path:\"out.docx\"}. \ +Several documents can be open: new_document and open_document open another one and make it active; \ +list_documents gives their ids, and `doc` on execute, batch and the other document tools works on one \ +without switching."; /// Resource URIs. pub const DOC_URI: &str = "wordcraft://document"; diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index 781f24d02b..9872f98d0a 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -152,3 +152,45 @@ fn type_text_stops_at_a_refused_paragraph_break() { assert!(r["result"]["content"][0]["text"].as_str().unwrap().contains("paragraph break refused"), "{r}"); assert_eq!(*calls.lock().unwrap(), vec!["text.insert", "text.newParagraph"]); } + +/// W1: with several documents open, `doc` sends a tool to one of them; the active document stays +/// active and unchanged, and an id that names no open document is an error. +#[test] +fn tools_address_an_open_document_by_id() { + let mut s = Server::new(Box::new(Headless::default())); + let first = tool(&mut s, "new_document", json!({}))["id"].as_u64().expect("new_document gives the id"); + tool(&mut s, "type_text", json!({"text": "First"})); + let second = tool(&mut s, "new_document", json!({}))["id"].as_u64().expect("new_document gives the id"); + assert_ne!(first, second); + tool(&mut s, "type_text", json!({"text": "Second"})); + + tool(&mut s, "execute", json!({"command": "text.insert", "params": {"text": " more"}, "doc": first})); + tool(&mut s, "batch", json!({"commands": [{"command": "text.insert", "params": {"text": " again"}}], "doc": first})); + assert_eq!(tool(&mut s, "get_text", json!({"doc": first}))["text"].as_str().unwrap().trim(), "First more again"); + assert_eq!(tool(&mut s, "get_text", json!({}))["text"].as_str().unwrap().trim(), "Second"); + let inspected = tool(&mut s, "inspect_document", json!({"doc": first})); + assert_eq!(inspected["blocks"][0]["text"], "First more again"); + let r = call(&mut s, 12, "tools/call", json!({"name": "render_page", "arguments": {"doc": first, "scale": 0.2}})); + assert_eq!(r["result"]["content"][0]["type"], "image", "{r}"); + + let list = tool(&mut s, "list_documents", json!({})); + let docs = list["documents"].as_array().unwrap(); + let ids: Vec = docs.iter().filter_map(|d| d["id"].as_u64()).collect(); + assert!(ids.contains(&first) && ids.contains(&second), "{list}"); + let active: Vec = docs.iter().filter(|d| d["active"] == true).filter_map(|d| d["id"].as_u64()).collect(); + assert_eq!(active, vec![second]); + + for bad in [json!(u64::MAX), json!(-1), json!("1"), json!(first.to_string()), json!(1.5), json!(1e300), json!({}), json!([first])] { + for (name, args) in [ + ("execute", json!({"command": "text.insert", "params": {"text": "x"}, "doc": bad})), + ("get_text", json!({"doc": bad})), + ("inspect_document", json!({"doc": bad})), + ("render_page", json!({"doc": bad})), + ] { + let r = call(&mut s, 13, "tools/call", json!({"name": name, "arguments": args})); + assert_eq!(r["result"]["isError"], true, "{name} with doc {bad}: {r}"); + } + } + assert_eq!(tool(&mut s, "get_text", json!({}))["text"].as_str().unwrap().trim(), "Second"); + assert_eq!(tool(&mut s, "get_text", json!({"doc": first}))["text"].as_str().unwrap().trim(), "First more again"); +} diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 47e162a0b8..65c1e11fa8 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -48,6 +48,16 @@ fn n(desc: &str) -> Value { fn obj(props: Value, req: &[&str]) -> Value { json!({"type": "object", "properties": props, "required": req}) } +/// Adds the optional `doc` property to a tool's schema. +fn with_doc(mut schema: Value) -> Value { + if let Some(p) = schema.get_mut("properties").and_then(Value::as_object_mut) { + p.insert("doc".into(), json!({"type": "integer", "minimum": 0, "description": DOC_DESC})); + } + schema +} + +const DOC_DESC: &str = "Id of the open document to work on (list_documents); the active document when left out. The active document stays active."; + fn tool(name: &str, title: &str, desc: &str, schema: Value, ro: bool) -> Value { json!({"name": name, "title": title, "description": desc, "inputSchema": schema, "annotations": {"title": title, "readOnlyHint": ro, "openWorldHint": false}}) } @@ -64,31 +74,31 @@ pub fn tool_definitions() -> Value { tool( "execute", "Run a command", - "Run one command by id with params, e.g. {command:\"format.bold\"} or {command:\"insert.table\", params:{rows:3, cols:4}}.", - obj(json!({"command": s("Command id"), "params": {"type": "object", "description": "Command parameters"}}), &["command"]), + "Run one command by id with params, e.g. {command:\"format.bold\"} or {command:\"insert.table\", params:{rows:3, cols:4}}. Commands work on the active document, or on the open document `doc` names. file.new and file.open open another document beside the open ones and make it active; view.switchWindows {id} makes another one active and file.closeDocument {id, force?} closes one.", + with_doc(obj(json!({"command": s("Command id"), "params": {"type": "object", "description": "Command parameters"}}), &["command"])), false ), tool( "batch", "Run several commands", - "Run commands in order; stops at the first error unless keepGoing.", - obj( + "Run commands in order; stops at the first error unless keepGoing. `doc` sends every command to that open document.", + with_doc(obj( json!({"commands": {"type": "array", "items": {"type": "object", "properties": {"command": {"type": "string"}, "params": {"type": "object"}}, "required": ["command"]}}, "keepGoing": {"type": "boolean"}}), &["commands"] - ), + )), false ), tool( "new_document", "New document", - "Start a document: blank, sample, letter, resume or report.", + "Start a document: blank, sample, letter, resume or report. It opens beside the documents already open and becomes the active one; the reply's `id` addresses it (`doc`).", obj(json!({"template": s("blank|sample|letter|resume|report")}), &[]), false ), tool( "open_document", "Open", - "Open a .docx, .doc, .odt, .rtf, .md, .tex, .html, .txt or .json file, or any other text file (.m3u, .log, .ini…) as plain text. Give `password` for a protected file; without it the call fails asking for one.", + "Open a .docx, .doc, .odt, .rtf, .md, .tex, .html, .txt or .json file, or any other text file (.m3u, .log, .ini…) as plain text. Give `password` for a protected file; without it the call fails asking for one. The file opens beside the documents already open (it replaces a blank one nobody has used) and becomes the active one; a file that is already open becomes active again (`alreadyOpen`). The reply's `id` addresses it (`doc`).", obj(json!({"path": s("File path"), "password": s("Password, if the file is protected")}), &["path"]), false ), @@ -96,36 +106,43 @@ pub fn tool_definitions() -> Value { "save_document", "Save", "Save (format from the extension: docx, pdf, odt, rtf, html, md, tex, txt, png).", - obj(json!({"path": s("File path; omit to save in place")}), &[]), + with_doc(obj(json!({"path": s("File path; omit to save in place")}), &[])), false ), tool( "type_text", "Type text", "Type at the caret (replacing the selection). Use \\n... for line breaks inside text; use execute text.newParagraph for new paragraphs, or `paragraphs: true` to split lines into paragraphs.", - obj(json!({"text": s("Text"), "paragraphs": {"type": "boolean"}}), &["text"]), + with_doc(obj(json!({"text": s("Text"), "paragraphs": {"type": "boolean"}}), &["text"])), false ), tool( "select_text", "Select text", "Select the n-th occurrence of some text.", - obj(json!({"text": s("Text to find"), "occurrence": n("1-based")}), &["text"]), + with_doc(obj(json!({"text": s("Text to find"), "occurrence": n("1-based")}), &["text"])), false ), - tool("get_text", "Document text", "Body text with tracked deletions omitted.", obj(json!({}), &[]), true), + tool("get_text", "Document text", "Body text with tracked deletions omitted.", with_doc(obj(json!({}), &[])), true), tool( "inspect_document", "Inspect document", "Structure: blocks with text, styles, formatting runs, lists, tables, sections, parts, selection, pages.", - obj(json!({}), &[]), + with_doc(obj(json!({}), &[])), true ), tool( "render_page", "Render page", "Render a page to PNG to look at the result.", - obj(json!({"page": n("1-based page"), "scale": n("Pixels per point (default 1)")}), &[]), + with_doc(obj(json!({"page": n("1-based page"), "scale": n("Pixels per point (default 1)")}), &[])), + true + ), + tool( + "list_documents", + "Open documents", + "The open documents in the order they were opened: id, title, path, dirty (unsaved changes) and active (the one commands work on by default). new_document and open_document open another document beside these. Give a document's id as `doc` to execute, batch, type_text, select_text, get_text, inspect_document, render_page or save_document to work on it while it stays in the background; execute view.switchWindows {id} makes it the active one, file.closeDocument {id, force?} closes it.", + obj(json!({}), &[]), true ), tool("parity", "Feature parity", "WordCraft's command coverage of the word-processor feature catalog.", obj(json!({}), &[]), true), @@ -154,8 +171,31 @@ pub fn tool_definitions() -> Value { ]) } -fn exec(b: &mut dyn Backend, id: &str, params: Value) -> Result { - b.call("engine.execute", json!({"command": id, "params": params})) +fn exec(b: &mut dyn Backend, id: &str, params: Value, doc: Option<&Value>) -> Result { + b.call("engine.execute", addressed(json!({"command": id, "params": params}), doc)) +} + +/// `params` with the tool's `doc`, when it gave one. The backend checks it: a session says +/// whether it names an open document. +fn addressed(mut params: Value, doc: Option<&Value>) -> Value { + if let (Some(d), Some(o)) = (doc, params.as_object_mut()) { + o.insert("doc".into(), d.clone()); + } + params +} + +/// The active document's id, added to a reply that opened or made one. +fn with_active_id(b: &mut dyn Backend, r: Result) -> Result { + let mut v = r?; + if v.get("id").is_none() + && let Ok(list) = exec(b, "view.switchWindows", json!({}), None) + && let Some(id) = + list.get("documents").and_then(Value::as_array).and_then(|d| d.iter().find(|d| d["active"] == true)).and_then(|d| d.get("id")) + && let Some(o) = v.as_object_mut() + { + o.insert("id".into(), id.clone()); + } + Ok(v) } fn wrap(r: Result) -> ToolResult { @@ -167,6 +207,7 @@ fn wrap(r: Result) -> ToolResult { pub fn call_tool(b: &mut dyn Backend, name: &str, a: &Value) -> ToolResult { let st = |k: &str| a.get(k).and_then(Value::as_str); + let doc = a.get("doc").filter(|d| !d.is_null()); match name { "list_commands" => { let q = st("query").unwrap_or("").to_lowercase(); @@ -188,7 +229,7 @@ pub fn call_tool(b: &mut dyn Backend, name: &str, a: &Value) -> ToolResult { } } "execute" => match st("command") { - Some(c) => wrap(exec(b, c, a.get("params").cloned().unwrap_or(json!({})))), + Some(c) => wrap(exec(b, c, a.get("params").cloned().unwrap_or(json!({})), doc)), None => ToolResult::error("missing `command`"), }, "batch" => { @@ -196,7 +237,7 @@ pub fn call_tool(b: &mut dyn Backend, name: &str, a: &Value) -> ToolResult { let mut out = Vec::new(); for c in a.get("commands").and_then(Value::as_array).cloned().unwrap_or_default() { let id = c.get("command").and_then(Value::as_str).unwrap_or(""); - let r = exec(b, id, c.get("params").cloned().unwrap_or(json!({}))); + let r = exec(b, id, c.get("params").cloned().unwrap_or(json!({})), doc); let failed = r.is_err(); out.push(match r { Ok(v) => json!({"command": id, "ok": true, "result": v}), @@ -208,13 +249,17 @@ pub fn call_tool(b: &mut dyn Backend, name: &str, a: &Value) -> ToolResult { } ToolResult::json(&Value::Array(out)) } - "new_document" => wrap(exec(b, "file.new", json!({"template": st("template").unwrap_or("blank")}))), - "open_document" => wrap(exec(b, "file.open", json!({"path": st("path").unwrap_or(""), "password": st("password")}))), + "new_document" => { + let r = exec(b, "file.new", json!({"template": st("template").unwrap_or("blank")}), None); + wrap(with_active_id(b, r)) + } + "open_document" => wrap(exec(b, "file.open", json!({"path": st("path").unwrap_or(""), "password": st("password")}), None)), + "list_documents" => wrap(exec(b, "view.switchWindows", json!({}), None)), "save_document" => { let p = st("path"); let ext = p.and_then(|x| x.rsplit('.').next()).unwrap_or("").to_ascii_lowercase(); if ext == "png" { - wrap(exec(b, "file.exportPng", json!({"path": p}))) + wrap(exec(b, "file.exportPng", json!({"path": p}), doc)) } else { wrap(exec( b, @@ -223,6 +268,7 @@ pub fn call_tool(b: &mut dyn Backend, name: &str, a: &Value) -> ToolResult { Some(p) => json!({"path": p}), None => json!({}), }, + doc, )) } } @@ -232,13 +278,13 @@ pub fn call_tool(b: &mut dyn Backend, name: &str, a: &Value) -> ToolResult { let mut last = Ok(Value::Null); for (i, line) in t.split('\n').enumerate() { if i > 0 { - last = exec(b, "text.newParagraph", json!({})); + last = exec(b, "text.newParagraph", json!({}), doc); if last.is_err() { break; } } if !line.is_empty() { - last = exec(b, "text.insert", json!({"text": line, "raw": true})); + last = exec(b, "text.insert", json!({"text": line, "raw": true}), doc); } if last.is_err() { break; @@ -246,19 +292,23 @@ pub fn call_tool(b: &mut dyn Backend, name: &str, a: &Value) -> ToolResult { } wrap(last) } else { - wrap(exec(b, "text.insert", json!({"text": t, "raw": true}))) + wrap(exec(b, "text.insert", json!({"text": t, "raw": true}), doc)) } } - "select_text" => { - wrap(exec(b, "select.text", json!({"text": st("text").unwrap_or(""), "occurrence": a.get("occurrence").cloned().unwrap_or(json!(1))}))) - } - "get_text" => wrap(exec(b, "document.text", json!({}))), - "inspect_document" => wrap(b.call("document.inspect", json!({}))), + "select_text" => wrap(exec( + b, + "select.text", + json!({"text": st("text").unwrap_or(""), "occurrence": a.get("occurrence").cloned().unwrap_or(json!(1))}), + doc, + )), + "get_text" => wrap(exec(b, "document.text", json!({}), doc)), + "inspect_document" => wrap(b.call("document.inspect", addressed(json!({}), doc))), "parity" => wrap(b.call("ui.parity", json!({}))), // Pictures travel inside the reply (`png`), headless or through the app: no file with a // predictable name in the shared temporary folder. "render_page" => { - let params = json!({"page": a.get("page").cloned().unwrap_or(json!(1)), "scale": a.get("scale").cloned().unwrap_or(json!(1.0))}); + let params = + addressed(json!({"page": a.get("page").cloned().unwrap_or(json!(1)), "scale": a.get("scale").cloned().unwrap_or(json!(1.0))}), doc); match b.call("ui.render", params) { Ok(v) => png_result(&v, &json!({"width": v["width"], "height": v["height"], "pages": v["pages"]})), Err(e) => ToolResult::error(e), @@ -323,6 +373,21 @@ mod tests { assert_eq!(forwarded(json!({"path": "/tmp/a.docx"}))["password"], Value::Null); } + /// `doc` travels beside the command, not inside its params, so the app's control channel can + /// run the command on that document; without one nothing is added. + #[test] + fn doc_is_forwarded_beside_the_command() { + let mut b = Recording { seen: None }; + call_tool(&mut b, "execute", &json!({"command": "text.insert", "params": {"text": "x"}, "doc": 7})); + let (_, call) = b.seen.take().expect("the backend was called"); + assert_eq!(call["doc"], json!(7)); + assert!(call["params"].get("doc").is_none(), "{call}"); + call_tool(&mut b, "inspect_document", &json!({"doc": 7})); + assert_eq!(b.seen.take().expect("called").1["doc"], json!(7)); + call_tool(&mut b, "execute", &json!({"command": "text.insert", "params": {"text": "x"}})); + assert!(b.seen.take().expect("called").1.get("doc").is_none()); + } + /// The password has to be in the schema, or a client has no way to know it can be sent. #[test] fn the_schema_advertises_the_password_as_optional() { diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 62975845c4..2803ef2ef1 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -1,7 +1,7 @@ //! Programmatic control of the running app (agents, tests, MCP bridge). //! //! Methods (JSON lines over the host's transport, see `docs/control-protocol.md`): -//! - `engine.execute {command, params}`: run any command (engine or `ui.*`) +//! - `engine.execute {command, params, doc?}`: run any command (engine or `ui.*`) //! - `engine.commands {search?}`: engine and app print commands with enablement and parameters //! - `document.inspect`: document structure; `ui.inspect`: UI state, canvas geometry, perf //! - `ui.wheel {x, y, dx?, dy, unit?, phase?}`: a mouse-wheel / trackpad scroll at a window point. @@ -12,6 +12,10 @@ //! - `ui.screenshot {path?}`: PNG of the window; `ui.render {path?, page?, scale?}`: page PNG //! (without `path`, both reply with the PNG as base64 `png`) //! - `ui.parity`: feature catalog parity +//! +//! `engine.execute`, `document.inspect` and `ui.render` take `doc`, the id of an open document +//! (`view.switchWindows` lists them): they work on that document while the window keeps showing +//! the active one. Without `doc` they work on the active document. use std::sync::mpsc::Sender; @@ -61,6 +65,8 @@ pub fn inspect(app: &mut WordApp, ctx: &egui::Context) -> Value { "focused": app.canvas.focused, "title": app.title_stem(), "dirty": app.session.dirty, + "document": app.session.document_id(), + "documents": app.session.documents(), "perf": {"frameMs": app.frame_ms, "renderMs": app.canvas.render_ms, "layoutMs": app.session.layout().ms}, }) } @@ -113,18 +119,99 @@ pub(crate) fn close_refused(app: &WordApp, id: &str, params: &Value) -> Option<& return None; } if id == "file.close" && app.session.dirty { - return Some("the document has unsaved changes: save it first, or pass {\"force\": true} to discard them and close"); + return Some(UNSAVED); } // Comment text still being typed is unsaved work too (#1348): replacing the document would drop it. (crate::discards_document(id, params) && crate::panes::has_comment_drafts(app)) .then_some("a comment is still being edited: finish it first, or pass {\"force\": true} to discard it") } +const UNSAVED: &str = "the document has unsaved changes: save it first, or pass {\"force\": true} to discard them and close"; + /// The reply to a request that needs a document while none is open (#1129). const NO_DOCUMENT: &str = "no document is open; create one (file.new) or open one (file.open) first"; +/// The methods that take `doc`. +const DOC_METHODS: &[&str] = &["engine.execute", "command", "document.inspect", "ui.render"]; + +/// The open document other than the active one that a request's `doc` names; `None` for the +/// active document (no `doc`, null, or the active document's id). An id that names no open +/// document is an error. +fn addressed(app: &WordApp, p: &Value) -> Result, String> { + let id = match p.get("doc") { + None | Some(Value::Null) => return Ok(None), + Some(v) => v.as_u64().ok_or("`doc` must be the id of an open document (view.switchWindows lists them)")?, + }; + if !app.session.is_open(id) { + return Err(format!("no open document has id {id}")); + } + Ok((id != app.session.document_id()).then_some(id)) +} + +/// The reply to work run on another document: its result, or why it couldn't run or failed. +fn reply(r: Result, F>) -> Outcome { + match r { + Ok(Ok(v)) => ok(v), + Ok(Err(e)) => err(e), + Err(e) => err(e), + } +} + +/// A document method addressed to open document `doc`, which isn't the active one: it runs on +/// that document, and the window keeps showing the active one. +fn handle_in(app: &mut WordApp, doc: u64, method: &str, p: &Value) -> Outcome { + if app.no_document == Some(doc) { + return err(NO_DOCUMENT); + } + match method { + "document.inspect" => reply(app.session.with_document(doc, |s| s.run("document.inspect", p))), + "ui.render" => { + let page = p.get("page").and_then(Value::as_u64).unwrap_or(1); + let scale = p.get("scale").and_then(Value::as_f64).map_or(2.0, |v| v as f32); + let path = p.get("path").and_then(Value::as_str); + let r = app.session.with_document(doc, |s| match path { + Some(path) => s.run("file.exportPng", &json!({"path": path, "page": page, "scale": scale})).map_err(|e| e.to_string()), + None => render_png(s, page, scale), + }); + reply(r) + } + _ => { + let Some(id) = p.get("command").or(p.get("id")).and_then(Value::as_str) else { return err("missing `command`") }; + let params = p.get("params").cloned().filter(|v| !v.is_null()).unwrap_or(json!({})); + if id.starts_with("ui.") { + return err(format!("`{id}` works on the window: make the document active first (view.switchWindows {{\"id\": {doc}}})")); + } + // Close, with the same guard as the window's: unsaved changes need `force`. + if id == "file.close" { + let force = params.get("force").and_then(Value::as_bool) == Some(true); + if !force && app.session.documents().iter().any(|d| d.id == doc && d.dirty) { + return err(UNSAVED); + } + return match app.session.close_document(doc) { + Ok(()) => ok(json!({"closed": true, "id": doc, "documents": app.session.documents()})), + Err(e) => err(e), + }; + } + let r = app.session.with_document(doc, |s| s.run(id, ¶ms)); + // UI requests the command made carry `document`, so the window can tell they aren't + // about the document it shows. + for req in std::mem::take(&mut app.session.ui_requests) { + app.handle_request(&req); + } + reply(r) + } + } +} + pub fn handle(app: &mut WordApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome { let p = &req.params; + if DOC_METHODS.contains(&req.method.as_str()) { + match addressed(app, p) { + Ok(Some(doc)) => return handle_in(app, doc, &req.method, p), + Ok(None) => {} + Err(e) => return err(e), + } + } let s = |k: &str| p.get(k).and_then(Value::as_str); let f = |k: &str| p.get(k).and_then(Value::as_f64).map(|v| v as f32); match req.method.as_str() { @@ -263,7 +350,7 @@ pub fn handle(app: &mut WordApp, ctx: &egui::Context, req: &ControlRequest) -> O }, // No path: the PNG travels in the reply. Never a fixed name in the shared // temporary folder, where another user could have planted a link to a file. - None => match render_png(app, page, scale) { + None => match render_png(&app.session, page, scale) { Ok(v) => ok(v), Err(e) => err(e), }, @@ -298,11 +385,11 @@ pub fn handle(app: &mut WordApp, ctx: &egui::Context, req: &ControlRequest) -> O /// A page rendered by the engine, as base64 PNG in the reply (the same shape as the headless /// MCP server's `ui.render`). -fn render_png(app: &WordApp, page: u64, scale: f32) -> Result { +fn render_png(session: &wordcraft_engine::Session, page: u64, scale: f32) -> Result { let page = usize::try_from(page.max(1)).unwrap_or(usize::MAX).saturating_sub(1); - let l = app.session.export_layout(); - let pg = l.pages.get(page).ok_or_else(|| format!("no page {}", page + 1))?; - let img = wordcraft_render::render_page(&app.session.doc, pg, scale.clamp(0.1, 8.0), &Default::default()); + let l = session.export_layout(); + let pg = l.pages.get(page).ok_or_else(|| format!("no page {}", page.saturating_add(1)))?; + let img = wordcraft_render::render_page(&session.doc, pg, scale.clamp(0.1, 8.0), &Default::default()); let png = img.to_png(); Ok(json!({"png": wordcraft_engine::cmd::insert::base64_encode(&png), "width": img.width, "height": img.height, "pages": l.pages.len()})) } @@ -327,3 +414,7 @@ pub fn save_screenshot(image: &egui::ColorImage, path: Option<&str>) -> Value { } json!({"ok": true, "result": {"path": path, "width": w, "height": h}}) } + +#[cfg(test)] +#[path = "control_tests.rs"] +mod tests; diff --git a/crates/ui-egui/src/control_tests.rs b/crates/ui-egui/src/control_tests.rs new file mode 100644 index 0000000000..fe0a0f3637 --- /dev/null +++ b/crates/ui-egui/src/control_tests.rs @@ -0,0 +1,122 @@ +//! The control channel's `doc`: methods addressed to an open document that isn't the active one. + +use serde_json::{Value, json}; +use wordcraft_engine::Session; + +use super::{ControlRequest, Outcome, handle}; +use crate::{Services, WordApp}; + +fn app() -> WordApp { + WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default()) +} + +/// Send one request the way the control channel does and return the reply. +fn send(a: &mut WordApp, method: &str, params: Value) -> Value { + let ctx = egui::Context::default(); + let (req, _reply) = ControlRequest::new(method, params); + match handle(a, &ctx, &req) { + Outcome::Done(v) => v, + Outcome::Screenshot { .. } => json!({"ok": false, "error": "not a reply"}), + } +} + +fn exec(a: &mut WordApp, command: &str, params: Value) -> Value { + send(a, "engine.execute", json!({"command": command, "params": params})) +} + +fn body(a: &WordApp) -> String { + a.session.doc.plain_text(wordcraft_doc::StoryRef::Body) +} + +/// Two documents, the first typed in and parked behind the second: (first, second). +fn two_documents(a: &mut WordApp) -> (u64, u64) { + let first = a.session.document_id(); + assert_eq!(exec(a, "text.insert", json!({"text": "First"}))["ok"], true); + assert_eq!(exec(a, "file.new", json!({}))["ok"], true); + let second = a.session.document_id(); + assert_ne!(first, second); + assert_eq!(exec(a, "text.insert", json!({"text": "Second"}))["ok"], true); + (first, second) +} + +/// W1: `doc` sends a command, an inspection or a render to a document in the background; the +/// window's document stays active and unchanged, and an id that names no open document is an +/// error. +#[test] +fn methods_address_an_open_document_by_id() { + let mut a = app(); + let (first, second) = two_documents(&mut a); + + let r = send(&mut a, "engine.execute", json!({"command": "text.insert", "params": {"text": " more"}, "doc": first})); + assert_eq!(r["ok"], true, "{r}"); + assert_eq!(a.session.document_id(), second); + assert_eq!(body(&a).trim(), "Second"); + let r = send(&mut a, "document.inspect", json!({"doc": first})); + assert_eq!(r["result"]["blocks"][0]["text"], "First more", "{r}"); + let r = send(&mut a, "ui.render", json!({"doc": first, "scale": 0.2})); + assert!(r["result"]["png"].as_str().is_some_and(|p| !p.is_empty()), "{r}"); + // The active document's own id works like no `doc` at all. + let r = send(&mut a, "engine.execute", json!({"command": "text.insert", "params": {"text": "!"}, "doc": second})); + assert_eq!(r["ok"], true, "{r}"); + assert_eq!(body(&a).trim(), "Second!"); + + let r = send(&mut a, "ui.inspect", json!({})); + assert_eq!(r["result"]["document"], second, "{r}"); + let ids: Vec = r["result"]["documents"].as_array().into_iter().flatten().filter_map(|d| d["id"].as_u64()).collect(); + assert_eq!(ids, vec![first, second]); + + // Window commands need the document on screen. + let r = send(&mut a, "engine.execute", json!({"command": "ui.tab", "params": {"value": "insert"}, "doc": first})); + assert_eq!(r["ok"], false, "{r}"); + + for bad in [json!(u64::MAX), json!(-1), json!("1"), json!(first.to_string()), json!(1.5), json!(1e300), json!({}), json!([first])] { + for (method, params) in [ + ("engine.execute", json!({"command": "text.insert", "params": {"text": "x"}, "doc": bad})), + ("document.inspect", json!({"doc": bad})), + ("ui.render", json!({"doc": bad})), + ] { + let r = send(&mut a, method, params); + assert_eq!(r["ok"], false, "{method} with doc {bad}: {r}"); + } + } + assert_eq!(a.session.document_id(), second); + assert_eq!(body(&a).trim(), "Second!"); + let r = send(&mut a, "document.inspect", json!({"doc": first})); + assert_eq!(r["result"]["blocks"][0]["text"], "First more", "{r}"); +} + +/// W1: `file.close` addressed to a document in the background refuses its unsaved changes +/// unless forced (only a boolean true), as it does for the window's document (#1268), and closes +/// only that document. +#[test] +fn closing_an_addressed_document_keeps_unsaved_work_unless_forced() { + let mut a = app(); + let (first, second) = two_documents(&mut a); + + for params in [json!({}), json!({"force": "true"}), json!({"force": 1})] { + let r = send(&mut a, "engine.execute", json!({"command": "file.close", "params": params, "doc": first})); + assert_eq!(r["ok"], false, "{r}"); + let e = r["error"].as_str().unwrap_or(""); + assert!(e.contains("unsaved") && e.contains("force"), "{e}"); + assert!(a.session.is_open(first)); + } + + let r = send(&mut a, "engine.execute", json!({"command": "file.close", "params": {"force": true}, "doc": first})); + assert_eq!(r["ok"], true, "{r}"); + assert!(!a.session.is_open(first)); + assert_eq!(a.session.document_id(), second); + assert!(a.has_document() && !a.quit_requested); + assert_eq!(body(&a).trim(), "Second"); + let r = send(&mut a, "engine.execute", json!({"command": "text.insert", "params": {"text": "x"}, "doc": first})); + assert_eq!(r["ok"], false, "a closed document can't be addressed: {r}"); + + // A document without unsaved changes closes at once. + assert_eq!(exec(&mut a, "file.new", json!({}))["ok"], true); + let clean = a.session.document_id(); + assert_eq!(exec(&mut a, "file.new", json!({}))["ok"], true); + let active = a.session.document_id(); + let r = send(&mut a, "engine.execute", json!({"command": "file.close", "doc": clean})); + assert_eq!(r["ok"], true, "{r}"); + assert!(!a.session.is_open(clean) && a.session.is_open(second)); + assert_eq!(a.session.document_id(), active); +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 0f09568ae9..681c21cb63 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -52,10 +52,10 @@ and set `WORDCRAFT_CONTROL_KEY` to that app's key. | Method | Params | Result | |---|---|---| -| `engine.execute` | `command`, `params` | the command's result (any id from `engine.commands`, including UI commands `ui.tab`, `ui.dialog` (`{"name": dialog, "part"?: table style part}`; `part` opens New / Modify Table Style (`newTableStyle` / `modifyTableStyle`) on that part: `wholeTable`, `headerRow`, `firstColumn`, `bandedRows`, `nwCell`… as `table.modifyStyle` names them), `ui.backstage`, `ui.zotero.*` (see `docs/zotero.md`), `ui.language` (`{"value": "auto"\|"en"\|"zh-hans"\|"zh-hant"\|"ja"\|"pt-br"\|"es"\|"uk"\|"sr"\|"sr-latn"\|"et"\|"tr"\|"ru"\|"pl"\|"de"\|"it"\|"cs"\|"hu"\|"fr"}`), `ui.theme` (`{"value": "system"\|"light"\|"dark"}`; `system` follows the OS appearance, light when it reports none), `ui.interfaceSize` (`{"value": "auto"}` or `{"percent": 50–300}`, clamped; no value reads it; `auto` keeps the system display scale, a percentage is absolute, so 100 is one screen pixel per point (#822); the result's `percent` is the size drawn at and `auto` whether it follows the system; document zoom stays `view.zoom`), `ui.animations` (`{"value": "system"\|"on"\|"off"}`; no value reads it; `system` follows the system's animation setting, which the desktop app reads on Windows, macOS, Linux and the BSDs and the web app from `prefers-reduced-motion`, and means on where the system doesn't say; the result's `enabled` is what the setting comes to and `system` what the system reports, `null` when it doesn't say), `ui.fileBrowser` (desktop: `{"purpose": "document"\|"picture"\|"recipients"\|"save"}` opens WordCraft's own file browser, for when the system file dialog doesn't show up, #576), `ui.saveAsTemplate` (File › Save As › Save as Word Template: the Save dialog for a `.dotx`, starting in the user templates folder; the result's `dir` is that folder, `null` on the web, #2852), `ui.caretAnimation` (`{"value": true\|false}`; no value reads it; off, the caret jumps and blinks on and off instead of gliding and fading), `ui.startScreen` (`{"value": true\|false}`; no value reads it; on, the default, the desktop and web apps start on File › Home when launched with nothing to open, #766), `ui.alignmentGuides` (`{"value": true\|false}`; no value reads it; on, the default, moving a picture or shape snaps it to the page, the margins and other objects, with guide lines while dragging; Alt moves freely), `ui.quickAccessToolbar` (`{"value": true\|false}`; no value toggles it; false takes the Quick Access Toolbar out of the title bar, as its menu's Hide Quick Access Toolbar does, and true brings it back, #1945), `ui.focusRibbon` (`{"value": true\|false}`; no value toggles it; in Focus, true drops the ribbon down over the page, as the bar's ⋯ does, and false puts it away, as a click on the page does; outside Focus it stays put away, #1678), `ui.contextMenu` (opens the page's context menu below the caret, as Shift+F10 does)…) | +| `engine.execute` | `command`, `params`, `doc?` | the command's result (any id from `engine.commands`, including UI commands `ui.tab`, `ui.dialog` (`{"name": dialog, "part"?: table style part}`; `part` opens New / Modify Table Style (`newTableStyle` / `modifyTableStyle`) on that part: `wholeTable`, `headerRow`, `firstColumn`, `bandedRows`, `nwCell`… as `table.modifyStyle` names them), `ui.backstage`, `ui.zotero.*` (see `docs/zotero.md`), `ui.language` (`{"value": "auto"\|"en"\|"zh-hans"\|"zh-hant"\|"ja"\|"pt-br"\|"es"\|"uk"\|"sr"\|"sr-latn"\|"et"\|"tr"\|"ru"\|"pl"\|"de"\|"it"\|"cs"\|"hu"\|"fr"}`), `ui.theme` (`{"value": "system"\|"light"\|"dark"}`; `system` follows the OS appearance, light when it reports none), `ui.interfaceSize` (`{"value": "auto"}` or `{"percent": 50–300}`, clamped; no value reads it; `auto` keeps the system display scale, a percentage is absolute, so 100 is one screen pixel per point (#822); the result's `percent` is the size drawn at and `auto` whether it follows the system; document zoom stays `view.zoom`), `ui.animations` (`{"value": "system"\|"on"\|"off"}`; no value reads it; `system` follows the system's animation setting, which the desktop app reads on Windows, macOS, Linux and the BSDs and the web app from `prefers-reduced-motion`, and means on where the system doesn't say; the result's `enabled` is what the setting comes to and `system` what the system reports, `null` when it doesn't say), `ui.fileBrowser` (desktop: `{"purpose": "document"\|"picture"\|"recipients"\|"save"}` opens WordCraft's own file browser, for when the system file dialog doesn't show up, #576), `ui.saveAsTemplate` (File › Save As › Save as Word Template: the Save dialog for a `.dotx`, starting in the user templates folder; the result's `dir` is that folder, `null` on the web, #2852), `ui.caretAnimation` (`{"value": true\|false}`; no value reads it; off, the caret jumps and blinks on and off instead of gliding and fading), `ui.startScreen` (`{"value": true\|false}`; no value reads it; on, the default, the desktop and web apps start on File › Home when launched with nothing to open, #766), `ui.alignmentGuides` (`{"value": true\|false}`; no value reads it; on, the default, moving a picture or shape snaps it to the page, the margins and other objects, with guide lines while dragging; Alt moves freely), `ui.quickAccessToolbar` (`{"value": true\|false}`; no value toggles it; false takes the Quick Access Toolbar out of the title bar, as its menu's Hide Quick Access Toolbar does, and true brings it back, #1945), `ui.focusRibbon` (`{"value": true\|false}`; no value toggles it; in Focus, true drops the ribbon down over the page, as the bar's ⋯ does, and false puts it away, as a click on the page does; outside Focus it stays put away, #1678), `ui.contextMenu` (opens the page's context menu below the caret, as Shift+F10 does)…) | | `engine.commands` | — | every command: id, label, location, shortcut, params, enabled | -| `document.inspect` | `text?` | blocks (text, style, runs, lists, tables), parts, sections, selection, pages | -| `ui.inspect` | — | UI state, view, dialog, window size, page rects on screen, caret, comment balloons (`balloons`: id + screen rect; `balloon`: the selected one), perf | +| `document.inspect` | `text?`, `doc?` | blocks (text, style, runs, lists, tables), parts, sections, selection, pages | +| `ui.inspect` | — | UI state, view, dialog, window size, page rects on screen, caret, comment balloons (`balloons`: id + screen rect; `balloon`: the selected one), perf; `document`: the active document's id; `documents`: the open documents (`id`, `title`, `path`, `dirty`, `active`) | | `ui.click` | `x`, `y`, `button?`, `count?`, `shift?`, `cmd?`, `alt?` | real pointer input at window coordinates | | `ui.clickText` | `page` (0-based), `x`, `y` (points from the page's top-left), `count?` | click inside a page | | `ui.move` / `ui.drag` | `x`,`y` / `x`,`y`,`toX`,`toY`,`steps?` | pointer move / drag | @@ -64,12 +64,36 @@ and set `WORDCRAFT_CONTROL_KEY` to that app's key. | `ui.key` | `key` (`B`, `Enter`, `Left`…), `shift?`, `alt?`, `cmd?` | key press through egui (shortcuts apply) | | `ui.text` | `text` | typed text through egui | | `ui.screenshot` | `path?` | PNG of the window: written to `path`, or without one returned as base64 `png` in the reply | -| `ui.render` | `path?`, `page?` (1-based), `scale?` | PNG of a page, rendered by the engine: written to `path`, or without one returned as base64 `png` in the reply (never a fixed name in the shared temporary folder) | +| `ui.render` | `path?`, `page?` (1-based), `scale?`, `doc?` | PNG of a page, rendered by the engine: written to `path`, or without one returned as base64 `png` in the reply (never a fixed name in the shared temporary folder) | | `ui.parity` | — | feature-catalog parity | | `ui.resize` / `ui.focus` / `app.quit` | | window control | Any other method name is treated as a command id: `{"method": "format.bold"}` works. +### Open documents + +Several documents can be open at once. Each has an id, a whole number that stays the same while +the document is open and is never reused by the running app. One document is the active one: the +window shows it, and commands work on it unless told otherwise. + +- `file.new` opens another document and makes it active. `file.open` does the same with a file; + it replaces a blank document nobody has used yet, and a file that is already open becomes + active again (`"alreadyOpen": true`). Its result carries the document's `id`. +- `view.switchWindows` lists the open documents (`{"documents": [{"id", "title", "path", + "dirty", "active"}]}`); with `{"id": n}` it makes that document active. +- `file.closeDocument {"id"?, "force"?}` closes a document without touching the window. +- `engine.execute`, `document.inspect` and `ui.render` take `doc`, the id of the document to work + on, next to their other params. The command runs on that document and the window keeps showing + the active one: + +```json +{"id": 2, "key": "…", "method": "engine.execute", "params": {"command": "text.insert", "params": {"text": "Hello"}, "doc": 3}} +``` + +`doc` left out, or `null`, is the active document. An id that names no open document, or a value +that isn't a whole number from 0 up, is an error and nothing runs. Window commands (`ui.*`) work +on the document on screen only: with the id of another document they are an error. + The `CharProps` and `ParaProps` that `format.set`, `para.set` and `styles.modify` take are listed in [MCP server](mcp.md#character-and-paragraph-properties). @@ -94,7 +118,9 @@ stay); save first (`file.save`), or pass `{"force": true}` to discard the change The bare form `{"method": "file.close", "force": true}` (a method name that is a command id) is guarded the same way, and only a boolean `true` counts as `force`. A document without unsaved changes closes at once (#1268). Closing closes the document, not the app: the window stays and -shows no page, and `file.new` or `file.open` goes on from there (#1712). +shows no page, and `file.new` or `file.open` goes on from there (#1712). With `doc` naming a +document in the background, `file.close` closes that document under the same rule (unsaved +changes need `{"force": true}`), and the window keeps showing the active one. ### Native printing diff --git a/docs/mcp.md b/docs/mcp.md index 5ed66634d1..8b07b6b691 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -33,7 +33,10 @@ PDF in one run without a window: line win. A command is `{"command": id, "params": {…}}` or just the id. `save` is a path or a list; each output's format follows its extension. Relative `file` and `save` paths resolve against the script's folder (the working folder for stdin). `--cmd` flags run after the script's commands and -`--save` after its outputs. A failing command stops the run with `command N (id): …` and exit +`--save` after its outputs. The run starts with one open document. `file.new` and `file.open` +open another one and make it active; later commands and the saves work on the active document. +`view.switchWindows` lists the open documents (with `--print` their ids show), and +`view.switchWindows={"id": n}` makes one active. A failing command stops the run with `command N (id): …` and exit status 1, and nothing is saved. Every output is checked (known extension, folder exists) before the first is written. Paths inside command params (a picture to insert, say) are relative to the working folder, not the script's. Scripts are capped at 4 MiB. ## Tracked changes by an agent @@ -106,8 +109,9 @@ files it was given rather than guarding against other software on the computer. | `list_commands` | every command (filter with `query`) — over 270 of them | | `execute` | run one command: `{command: "insert.table", params: {rows: 3, cols: 4}}` | | `batch` | run several commands in order | -| `new_document` | blank, sample, letter, resume, report | +| `new_document` | blank, sample, letter, resume, report; opens beside the open documents | | `open_document` / `save_document` | docx, doc (open only), odt, rtf, html, md, tex, txt, json; save also pdf, png | +| `list_documents` | the open documents: `id`, `title`, `path`, `dirty`, `active` | | `type_text` | type at the caret (`paragraphs: true` splits lines into paragraphs) | | `select_text` | select the n-th occurrence of some text | | `get_text` / `inspect_document` | read the document back (verify without screenshots) | @@ -115,6 +119,28 @@ files it was given rather than guarding against other software on the computer. | `parity` | feature coverage | | `screenshot`, `click`, `key`, `ui_inspect` | app only (`--connect`) | +### Several documents + +`new_document` and `open_document` (the commands `file.new` and `file.open`) open another +document beside the ones already open and make it the active one; their reply carries its `id`. +`open_document` replaces a blank document nobody has used, and a file that is already open becomes +active again (`alreadyOpen`). `list_documents` lists the open documents with their ids. + +Tools work on the active document. `execute`, `batch`, `type_text`, `select_text`, `get_text`, +`inspect_document`, `render_page` and `save_document` also take `doc`, the id of another open +document: the tool works on that document and the active one stays active. + +```json +{"name": "execute", "arguments": {"command": "text.insert", "params": {"text": "Hello"}, "doc": 3}} +``` + +An id that names no open document, or a value that isn't a whole number from 0 up, is a tool error. +`execute` with `view.switchWindows {"id": n}` makes a document active, and `file.closeDocument +{"id": n, "force"?: true}` closes one (unsaved changes need `force`). Through `--connect`, `doc` +reaches the app's control channel (see [Open documents](control-protocol.md#open-documents)); the +app's window keeps showing the document it showed. `screenshot`, `click` and `key` act on the +window, so they work on the document on screen. + `insert.picture` and `insert.icon` leave the caret after the new picture, so typing next doesn't replace it; Picture Format commands (`picture.*`, `arrange.*`) still act on it. From 3693759149574239c44d6f93b9a8e12ecff98c35 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:01:56 +0000 Subject: [PATCH 009/199] UI: several open documents in one window; Switch Windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The desktop window follows the session's active document. What the window keeps per document (AutoSave's file, comment drafts) is parked while another document shows and comes back with it; everything about the page that was shown (dialogs, menus, drags, composition, status, caches keyed by revision) starts afresh. The page's scroll area is per document, so switching back finds the page where it was left; zoom is in the session. - View › Window › Switch Windows lists the open documents (active marked, unsaved dotted) and shows the one chosen (view.switchWindows). - New, Open, the mailings and personal templates open beside the open documents and no longer ask about unsaved changes. - File › Close shows the next open document; after the last one the window shows no page, as before. - Quitting asks Save / Don't Save / Cancel about each document with unsaved work, showing it; Cancel keeps the app open. - UI requests tagged for a document that isn't shown never act on the shown one: theme changes apply, Close closes that document when it has nothing unsaved, the rest are dropped. - Read Aloud highlights and follows only the document being read. - A late Save As answer shows and saves the document it was for. - Untitled documents are numbered in the interface language. --- ROADMAP.md | 4 +- apps/wordcraft/src/apple_events.rs | 5 +- crates/ui-egui/src/backstage.rs | 26 +-- crates/ui-egui/src/canvas.rs | 52 ++++- crates/ui-egui/src/documents.rs | 289 ++++++++++++++++++++++++++ crates/ui-egui/src/file_dialogs.rs | 17 +- crates/ui-egui/src/i18n/bg.tsv | 3 + crates/ui-egui/src/i18n/cs.tsv | 3 + crates/ui-egui/src/i18n/da.tsv | 3 + crates/ui-egui/src/i18n/de.tsv | 3 + crates/ui-egui/src/i18n/es.tsv | 3 + crates/ui-egui/src/i18n/et.tsv | 3 + crates/ui-egui/src/i18n/fr.tsv | 3 + crates/ui-egui/src/i18n/hr.tsv | 3 + crates/ui-egui/src/i18n/hu.tsv | 3 + crates/ui-egui/src/i18n/it.tsv | 3 + crates/ui-egui/src/i18n/ja.tsv | 3 + crates/ui-egui/src/i18n/ko.tsv | 3 + crates/ui-egui/src/i18n/nl.tsv | 3 + crates/ui-egui/src/i18n/pl.tsv | 3 + crates/ui-egui/src/i18n/pt-br.tsv | 3 + crates/ui-egui/src/i18n/ro.tsv | 3 + crates/ui-egui/src/i18n/ru.tsv | 3 + crates/ui-egui/src/i18n/sl.tsv | 3 + crates/ui-egui/src/i18n/sr-latn.tsv | 3 + crates/ui-egui/src/i18n/sr.tsv | 3 + crates/ui-egui/src/i18n/sv.tsv | 3 + crates/ui-egui/src/i18n/tr.tsv | 3 + crates/ui-egui/src/i18n/uk.tsv | 3 + crates/ui-egui/src/i18n/vi.tsv | 3 + crates/ui-egui/src/i18n/zh-hans.tsv | 3 + crates/ui-egui/src/i18n/zh-hant.tsv | 3 + crates/ui-egui/src/lib.rs | 310 +++++++++++++++------------- crates/ui-egui/src/panes.rs | 5 + crates/ui-egui/src/previews.rs | 5 + crates/ui-egui/src/read_aloud.rs | 10 +- crates/ui-egui/src/ribbon.rs | 10 + docs/ui-parity.md | 5 +- 38 files changed, 636 insertions(+), 180 deletions(-) create mode 100644 crates/ui-egui/src/documents.rs diff --git a/ROADMAP.md b/ROADMAP.md index 731d388e51..cd54d1c1a6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~15 points (60% → 75% ready for real work) and ~150–230 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** trivial (progress log: several open documents in one session; previously trivial: progress log: a click in the footer while editing the header moves there; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Slovenian interface catalog, 2,614 entries; previously trivial: Insert Table of Figures dialog for any caption label, #3013; previously trivial: progress log: Home › Underline drop-down, #2738; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: progress log: the scrollbar page tip stays on one line, #3341; previously trivial: progress log: forms protection greys out the control buttons; previously trivial: progress log: a document with many content controls saves in the time the controls take, #13; previously trivial: progress log: Word signature lines kept on save; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: progress log: closing the header returns to the body selection, #3331; previously trivial: progress log: saving to a NAS share mounted by GVFS, #3003; previously trivial: progress log: a picture dragged near the bottom of a page lands there, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: progress log: inline pictures drag to an insertion cursor and stay inline, #2233, #2234; previously trivial: progress log: a text file saved over itself closes without asking, #2376; previously trivial: progress log: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: fixed font inputs in four layout controls; previously trivial: typing no longer starts render threads on every repaint, #3027; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: progress log: banded rows shade as in Word, #3415; previously trivial: progress log: the table handles show while the caret is in the table, #2428; previously trivial: progress log: a click below a floating table reaches the text after it, #2038; previously trivial: progress log: Delete on selected table cells leaves one empty paragraph in each, #2802; previously trivial: progress log: lam-alef in exported PDFs extracts whole with fonts that draw it as two glyphs, #2310; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously trivial: progress log: a text box that keeps the keyboard no longer resets the input method every frame, #2986; previously trivial: progress log: recovery copies and .json files store pictures as base64, #586 follow-up; previously trivial: progress log: legacy menu keys, #1697; previously trivial: progress log: shape gradients and transparency, #1500; previously trivial: progress log: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: progress log: text above a float's anchor wraps around it, #2048; previously trivial: progress log: File › Recover follows the app theme, #3243; previously trivial: progress log: formatting toolbar with the text context menu, #2959; previously trivial: progress log: caret inside ligatures, #1974; 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: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: progress log: a header-cell picture watermark sits behind the text, #1775; previously trivial: symbols keep their font when the text font changes, #2036; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: Add Font imports every selected font file, #1789); previously trivial (RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: Read Aloud speaks each sentence in its own language, #1843; previously trivial: hint-reliant CJK outlines; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: progress log: Turkish case mapping in AutoCorrect, #1923; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: progress log: Hebrew field results and smart quotes, #1670; previously trivial: Backspace removes a selected table, rows or columns, #1844; previously trivial: progress log: mixed Hebrew/Latin text keeps its order in Word, #1543; previously trivial: progress log: web typing in non-Latin text no longer repeats, #1480; previously trivial: progress log: font family caches bounded and math font lookups follow font changes, #1354; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683; previously trivial: progress log: `w:eastAsianLayout` kept on save; previously trivial: table style editor formats every part, #714); previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** trivial (progress log: several open documents in the desktop window; previously trivial: progress log: several open documents in one session; previously trivial: progress log: a click in the footer while editing the header moves there; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Slovenian interface catalog, 2,614 entries; previously trivial: Insert Table of Figures dialog for any caption label, #3013; previously trivial: progress log: Home › Underline drop-down, #2738; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: progress log: the scrollbar page tip stays on one line, #3341; previously trivial: progress log: forms protection greys out the control buttons; previously trivial: progress log: a document with many content controls saves in the time the controls take, #13; previously trivial: progress log: Word signature lines kept on save; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: progress log: closing the header returns to the body selection, #3331; previously trivial: progress log: saving to a NAS share mounted by GVFS, #3003; previously trivial: progress log: a picture dragged near the bottom of a page lands there, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: progress log: inline pictures drag to an insertion cursor and stay inline, #2233, #2234; previously trivial: progress log: a text file saved over itself closes without asking, #2376; previously trivial: progress log: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: fixed font inputs in four layout controls; previously trivial: typing no longer starts render threads on every repaint, #3027; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: progress log: banded rows shade as in Word, #3415; previously trivial: progress log: the table handles show while the caret is in the table, #2428; previously trivial: progress log: a click below a floating table reaches the text after it, #2038; previously trivial: progress log: Delete on selected table cells leaves one empty paragraph in each, #2802; previously trivial: progress log: lam-alef in exported PDFs extracts whole with fonts that draw it as two glyphs, #2310; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously trivial: progress log: a text box that keeps the keyboard no longer resets the input method every frame, #2986; previously trivial: progress log: recovery copies and .json files store pictures as base64, #586 follow-up; previously trivial: progress log: legacy menu keys, #1697; previously trivial: progress log: shape gradients and transparency, #1500; previously trivial: progress log: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: progress log: text above a float's anchor wraps around it, #2048; previously trivial: progress log: File › Recover follows the app theme, #3243; previously trivial: progress log: formatting toolbar with the text context menu, #2959; previously trivial: progress log: caret inside ligatures, #1974; 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: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: progress log: a header-cell picture watermark sits behind the text, #1775; previously trivial: symbols keep their font when the text font changes, #2036; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: Add Font imports every selected font file, #1789); previously trivial (RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: Read Aloud speaks each sentence in its own language, #1843; previously trivial: hint-reliant CJK outlines; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: progress log: Turkish case mapping in AutoCorrect, #1923; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: progress log: Hebrew field results and smart quotes, #1670; previously trivial: Backspace removes a selected table, rows or columns, #1844; previously trivial: progress log: mixed Hebrew/Latin text keeps its order in Word, #1543; previously trivial: progress log: web typing in non-Latin text no longer repeats, #1480; previously trivial: progress log: font family caches bounded and math font lookups follow font changes, #1354; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683; previously trivial: progress log: `w:eastAsianLayout` kept on save; previously trivial: table style editor formats every part, #714); previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** trivial (English dialects in proofing, #1281; previously trivial: triple borders draw three lines, #1740; previously trivial: progress log: double-clicking a later page's footer no longer scrolls up, #2103; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: progress log: pasted blockquotes are indented paragraphs, #1270; previously trivial: progress log: print-area strings translated in every catalog; previously trivial: progress log: comments readable in the dark theme, #2045; previously trivial: progress log: ODT sections, #1379; previously trivial: progress log: table deletes, Change Case and Unlink Fields respect Track Changes and locked content controls, #1329; previously trivial: progress log: rounded rectangle corners, #1476; previously trivial: progress log: Save As type list, #1399; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below. @@ -136,6 +136,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm ## Progress log +- 2026-10-22: The desktop window works with several open documents (one window per document comes next). View › Window › Switch Windows lists them and shows the one chosen, back where it was left; New, Open and the mailings open beside unsaved work instead of asking about it; File › Close shows the next document; quitting asks about every document with unsaved changes, one at a time, and Cancel keeps the app open. - 2026-10-22: Several open documents in one session (engine part of one window per document). File › New opens a new document beside the open ones; File › Open opens beside them too, replaces a blank document nobody has used, and shows a file that is already open instead of opening it twice. Each document keeps its own text, selection, undo history, find state, view and recovery copy. View › Window › Switch Windows (`view.switchWindows`) lists the open documents and switches between them; `file.closeDocument` closes one where no window asks about unsaved changes (agents, headless sessions). Envelopes, Labels, Finish & Merge and New from Template open their result as a new document and leave the main document open as it was. Document ids are unique in the process. The desktop app still shows one document per window; the windows come next. - 2026-10-22: Headers and footers: while editing the header, a single click in the footer edits the footer with the caret where clicked (and the reverse, and another page's own header or footer). The click went to the header being edited and the view scrolled back up to it, so a double-click on the footer rarely landed unless both were on screen. - 2026-10-21: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459). @@ -573,6 +574,7 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps | Date | Change | Summary | |---|---|---| +| 2026-10-22 | trivial | Progress log: several open documents in the desktop window | | 2026-10-22 | trivial | Progress log: several open documents in one session | | 2026-10-22 | trivial | Progress log: a click in the footer while editing the header moves there | | 2026-10-18 | trivial | Explicit New/Open is not interrupted by startup recovery discovery; older copies stay available and Recover follows the interface theme. | diff --git a/apps/wordcraft/src/apple_events.rs b/apps/wordcraft/src/apple_events.rs index e952a3dfcf..71d0d05b4d 100644 --- a/apps/wordcraft/src/apple_events.rs +++ b/apps/wordcraft/src/apple_events.rs @@ -35,8 +35,9 @@ impl AppleEvents { } /// Open the documents and act on the quit requests that arrived since the last frame. A document -/// opens like File › Open (asking about unsaved changes first); quitting closes the window, which -/// asks the same. +/// opens like File › Open: beside the open ones, in place of an untouched blank one, or, when it +/// is already open, by showing it. Quitting closes the window, which asks about every document +/// with unsaved changes. pub fn poll(inbox: &Inbox, app: &mut WordApp, ctx: &egui::Context) { for e in inbox.drain() { match e { diff --git a/crates/ui-egui/src/backstage.rs b/crates/ui-egui/src/backstage.rs index 909dfd4651..7e9ea3bcff 100644 --- a/crates/ui-egui/src/backstage.rs +++ b/crates/ui-egui/src/backstage.rs @@ -1127,33 +1127,25 @@ mod tests { let _ = std::fs::remove_dir_all(&chosen); } - /// A personal template replaces the document like New does: unsaved changes are asked about - /// first, and nothing is replaced until that is answered. + /// A personal template opens a new document like New does: nothing is asked, and the + /// document with unsaved changes stays open beside it. #[test] - fn a_personal_template_asks_about_unsaved_changes_first() { + fn a_personal_template_opens_beside_unsaved_work() { let dir = personal_dir("dirty"); let memo = dir.join("Memo.dotx"); write_template(&memo, "From the template"); let mut h = new_page_harness(Some(dir.clone())); h.state_mut().run("document.setText", json!({"text": "Unsaved work"})).unwrap(); - assert!(h.state().session.dirty); + let first = h.state().session.document_id(); h.get_by_label("Memo").click(); h.run_steps(3); let body = |h: &egui_kittest::Harness<'static, WordApp>| h.state().session.doc.plain_text(wordcraft_doc::StoryRef::Body); - assert!(matches!(h.state().dialog, Some(crate::dialogs::Dialog::SaveChanges { .. })), "Save changes is asked"); - assert_eq!(body(&h), "Unsaved work", "the document stays until the question is answered"); - h.state_mut().run("ui.saveChanges", json!({"answer": "cancel"})).unwrap(); - h.run_steps(2); - assert_eq!(body(&h), "Unsaved work", "Cancel keeps it"); - assert_eq!(h.state().session.doc.settings.attached_template, None); - h.state_mut().run("ui.backstage", json!({"value": true, "page": "new"})).unwrap(); - h.run_steps(3); - h.get_by_label("Memo").click(); - h.run_steps(3); - h.state_mut().run("ui.saveChanges", json!({"answer": "dontSave"})).unwrap(); - h.run_steps(2); - assert!(body(&h).contains("From the template"), "Don't Save makes the document from the template"); + assert!(h.state().dialog.is_none(), "nothing is asked"); + assert!(body(&h).contains("From the template")); assert_eq!(h.state().session.doc.settings.attached_template.as_deref(), Some(memo.to_string_lossy().as_ref())); + h.state_mut().run("view.switchWindows", json!({"id": first})).unwrap(); + assert_eq!(body(&h), "Unsaved work", "the unsaved document is still open"); + assert!(h.state().session.dirty); drop(h); let _ = std::fs::remove_dir_all(&dir); } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 618f1c454b..9caeec2150 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -198,6 +198,32 @@ pub struct CanvasState { } impl CanvasState { + /// Another document is shown in the window: everything about the page that was shown (drags, + /// menus, the input method's composition, a scroll in progress, rendered pages…) starts + /// afresh. What belongs to the window or the user stays: the colours last applied, the + /// ruler's tab kind, the faces added for interface text, the page area's size. The scroll + /// position comes back by itself, as the page's scroll area keeps one per document in egui's + /// memory. `seen`: the document was shown before, so the view stays where it was rather than + /// going to the caret. + pub(crate) fn show_other_document(&mut self, seen: bool) { + let old = std::mem::take(self); + let ime_reset = !old.ime_preedit.is_empty() || old.ime_preedit_range.is_some(); + *self = CanvasState { + last_highlight: old.last_highlight, + last_font_color: old.last_font_color, + last_shading: old.last_shading, + ime_faces: old.ime_faces, + tab_kind: old.tab_kind, + scale: old.scale, + canvas_rect: old.canvas_rect, + read_viewport: old.read_viewport, + web_viewport: old.web_viewport, + scroll_to_caret: !seen, + ime_reset, + ..CanvasState::default() + }; + } + /// Stop a text-selection drag when the pointer gesture becomes an object drag. pub(crate) fn cancel_text_drag(&mut self) { self.dragging = false; @@ -1171,7 +1197,8 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { }; let delta = wheel_delta + auto_delta + middle_delta; let mut scroll_area = egui::ScrollArea::both() - .id_salt("canvas_scroll") + // One scroll position per open document, so switching back finds the page as it was left. + .id_salt(("canvas_scroll", app.session.document_id())) .auto_shrink([false, false]) // Bringing the caret into view glides there, or jumps with animation effects off. .animated(animate) @@ -4846,17 +4873,17 @@ mod tests { assert!(matches!(h.state().dialog, Some(crate::dialogs::Dialog::SaveChanges { .. }))); } - /// #1348: an agent can't replace the document while comment text is still being typed - /// unless it passes `force`, as with unsaved changes. + /// #1348: an agent can't close the document while comment text is still being typed unless + /// it passes `force`, as with unsaved changes. New and Open open beside it, losing nothing. #[test] - fn an_agent_cannot_replace_the_document_over_a_pending_comment_draft() { + fn an_agent_cannot_close_the_document_over_a_pending_comment_draft() { let (mut h, id) = comment_harness(); h.state_mut().session.dirty = false; click_balloon(&mut h, id); type_in(&mut h, "Typed "); let open = json!({"path": "/nonexistent/other.docx"}); - assert!(crate::control::close_refused(h.state(), "file.open", &open).is_some()); - assert!(crate::control::close_refused(h.state(), "file.new", &json!({})).is_some()); + assert!(crate::control::close_refused(h.state(), "file.open", &open).is_none()); + assert!(crate::control::close_refused(h.state(), "file.new", &json!({})).is_none()); assert!(crate::control::close_refused(h.state(), "file.close", &json!({})).is_some()); assert!(crate::control::close_refused(h.state(), "file.close", &json!({"force": true})).is_none()); assert!(crate::control::close_refused(h.state(), "text.insert", &json!({"text": "x"})).is_none()); @@ -4883,15 +4910,18 @@ mod tests { assert!(h.state_mut().close_requested(), "Don't Save closes"); } - /// #1348: New with a refused draft asks to save; Don't Save makes the new document. + /// #1348: New with a refused draft opens beside it, asking nothing; the draft waits with its + /// document and is there when that document is shown again. #[test] - fn a_refused_comment_draft_can_be_dropped_on_new() { + fn a_refused_comment_draft_stays_with_its_document_on_new() { let (mut h, _) = refused_draft(); let document = h.state().session.document_id(); h.state_mut().run("file.new", json!({})).unwrap(); - assert!(matches!(h.state().dialog, Some(crate::dialogs::Dialog::SaveChanges { .. })), "the save prompt asks about it"); - h.state_mut().run("ui.saveChanges", json!({"answer": "dontSave"})).unwrap(); - assert_ne!(h.state().session.document_id(), document, "Don't Save made the new document"); + assert!(h.state().dialog.is_none(), "nothing is asked"); + assert_ne!(h.state().session.document_id(), document, "New made a document"); + assert!(!crate::panes::has_comment_drafts(h.state()), "the draft isn't the new document's"); + h.state_mut().run("view.switchWindows", json!({"id": document})).unwrap(); + assert!(crate::panes::has_comment_drafts(h.state()), "the draft is back with its document"); } /// #1348: Save with a refused draft saves the rest of the document, keeps the draft and says why. diff --git a/crates/ui-egui/src/documents.rs b/crates/ui-egui/src/documents.rs new file mode 100644 index 0000000000..85a3a5154a --- /dev/null +++ b/crates/ui-egui/src/documents.rs @@ -0,0 +1,289 @@ +//! Several open documents in one window. The window shows the session's active document; what +//! it keeps about a document (AutoSave's file, comment drafts, the page's drags, menus and +//! composition…) follows the active one ([`WordApp::sync_document`]). View › Window › Switch +//! Windows lists the open documents, File › Close closes the shown one and shows the next, and +//! quitting asks about every document with unsaved changes, one after another. + +use serde_json::{Value, json}; +use wordcraft_engine::DocInfo; + +use crate::{WordApp, panes}; + +/// What the window keeps for an open document while another one is shown. +#[derive(Default)] +pub(crate) struct Parked { + /// [`WordApp`]'s `autosave_path`: the file AutoSave writes this document to. + autosave_path: Option, + /// Comment and reply text typed in this document and not yet written to it. + comment_drafts: panes::CommentDrafts, +} + +impl WordApp { + /// Follow the session's active document. When it isn't the one the window last showed, the + /// shown document's own state is put aside (or dropped, if it was closed or replaced), the + /// new one's comes back, and everything about the page that was shown starts afresh: open + /// dialogs and menus, drags, the input method's composition, the status message, caches + /// keyed by revision. Its scroll position and zoom come back with it (the scroll area is per + /// document; the zoom is in the session's view). Runs after every command and every frame. + pub(crate) fn sync_document(&mut self) { + let id = self.session.document_id(); + if id == self.shown_document { + return; + } + let left = std::mem::replace(&mut self.shown_document, id); + let leaving = Parked { autosave_path: self.autosave_path.take(), comment_drafts: std::mem::take(&mut self.comment_drafts) }; + if self.session.is_open(left) { + self.parked.insert(left, leaving); + } + let back = self.parked.remove(&id); + let seen = back.is_some(); + let back = back.unwrap_or_default(); + self.autosave_path = back.autosave_path; + self.comment_drafts = back.comment_drafts; + let session = &self.session; + self.parked.retain(|d, _| session.is_open(*d)); + + self.canvas.show_other_document(seen); + self.controls = Default::default(); + self.dialog = None; + self.colors = None; + self.restrict = crate::dialogs_review::RestrictPane { open: self.restrict.open, ..Default::default() }; + self.status_msg = None; + self.change_picture_target = None; + self.recipient_list_pending = false; + self.dialog_pick = None; + self.focus_ribbon = false; + self.nav_search_focus = false; + self.smart_art_item = None; + self.smart_art_row_focused = false; + self.smart_art_typing = None; + self.read_aloud_at = None; + self.info_editing = None; + // Caches keyed by the revision alone: another document's revision can be the same number. + self.word_count = (u64::MAX, 0); + self.find_fonts_for = None; + self.previews.document_changed(); + // The Start screen's placeholder (#1129) goes once a document is open beside it. + if let Some(placeholder) = self.no_document + && placeholder != id + && self.session.is_open(placeholder) + { + let _ = self.session.close_document(placeholder); + self.parked.remove(&placeholder); + } + } + + /// Show open document `id` in the window (View › Window › Switch Windows, quitting). + pub(crate) fn show_document(&mut self, id: u64) -> Result<(), String> { + self.session.activate(id).map_err(|e| e.to_string())?; + self.sync_document(); + Ok(()) + } + + /// The open documents with work that would be lost on quitting: the shown one first, then + /// the others in the order they were opened. Unsaved changes, a save the browser hasn't + /// confirmed, or comment text not yet written to the document. + fn unsaved_documents(&self) -> Vec { + if !self.has_document() { + return Vec::new(); + } + let active = self.session.document_id(); + let mut out = Vec::new(); + if self.session.dirty || self.save_unconfirmed() || panes::has_comment_drafts(self) { + out.push(active); + } + for d in self.session.documents() { + let unconfirmed = || self.saves_in_flight.iter().any(|s| s.marked_saved && s.document == d.id); + let drafts = || self.parked.get(&d.id).is_some_and(|p| !p.comment_drafts.is_empty()); + if d.id != active && (d.dirty || unconfirmed() || drafts()) { + out.push(d.id); + } + } + out + } + + /// Whether an open document has work that quitting would lose. + pub(crate) fn has_unsaved_documents(&self) -> bool { + !self.unsaved_documents().is_empty() + } + + /// Quitting: show the next document with unsaved work and ask Save / Don't Save / Cancel + /// about it (its answer goes on with [`WordApp::close_document`]'s `quit`). With none left, + /// the app quits. + pub(crate) fn ask_before_quit(&mut self) -> Value { + let Some(&next) = self.unsaved_documents().first() else { + self.quit_requested = true; + return json!({"quit": true}); + }; + if next != self.session.document_id() + && let Err(e) = self.show_document(next) + { + log::error!("quitting: couldn't show document {next}: {e}"); + return json!({"quit": false}); + } + self.ui.backstage = false; + self.run("file.close", json!({"quit": true})).unwrap_or_else(|e| json!({"quit": false, "error": e})) + } +} + +/// The name an open document goes by in the window: the engine's title, with an untitled +/// document's `Document2` in the interface language. +pub fn display_title(d: &DocInfo) -> String { + if d.path.is_none() { + return untitled_title(&d.title).unwrap_or_else(|| d.title.clone()); + } + d.title.clone() +} + +/// `Document2` (the engine's English name for an untitled document) in the interface language, +/// or None for any other name. The catalogs translate `Document1`; the number follows it. +pub(crate) fn untitled_title(english: &str) -> Option { + let n = english.strip_prefix("Document")?; + if n.is_empty() || !n.bytes().all(|b| b.is_ascii_digit()) { + return None; + } + let one = tl!("Document1"); + Some(match one.strip_suffix('1') { + Some(stem) => format!("{stem}{n}"), + None if n == "1" => one.to_string(), + None => format!("{one} ({n})"), + }) +} + +/// View › Window › Switch Windows: the open documents in the order they were opened, the shown +/// one marked, unsaved ones with the title bar's dot. Choosing one shows it. +pub(crate) fn switch_windows_menu(ui: &mut egui::Ui, app: &mut WordApp) { + for d in app.session.documents() { + let label = format!("{}{}", display_title(&d), if d.dirty { " •" } else { "" }); + if ui.add(egui::Button::selectable(d.active, label).min_size(egui::vec2(220.0, 0.0))).clicked() { + let _ = app.run("view.switchWindows", json!({"id": d.id})); + ui.close(); + } + } +} + +#[cfg(test)] +mod tests { + use serde_json::json; + use wordcraft_doc::StoryRef; + use wordcraft_engine::Session; + + use crate::{Services, WordApp}; + + fn app() -> WordApp { + WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default()) + } + + fn harness(a: WordApp) -> egui_kittest::Harness<'static, WordApp> { + egui_kittest::Harness::builder().with_size(egui::vec2(2560.0, 900.0)).build_ui_state( + |ui, app: &mut WordApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + a, + ) + } + + fn text(a: &WordApp) -> String { + a.session.doc.plain_text(StoryRef::Body).trim().to_string() + } + + fn prompt(a: &WordApp) -> Option<&'static str> { + a.dialog.as_ref().map(|d| d.name()) + } + + /// Mod+N with text typed opens a second document beside the first; Switch Windows lists both + /// and brings the first back with its text. + #[test] + fn new_keeps_the_typed_document_and_switch_windows_brings_it_back() { + use egui_kittest::kittest::Queryable; + let mut a = app(); + a.run("text.insert", json!({"text": "First draft"})).unwrap(); + let first = a.session.document_id(); + let mut h = harness(a); + h.run_steps(2); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::N); + h.run_steps(2); + assert_ne!(h.state().session.document_id(), first, "Mod+N shows a new document"); + assert_eq!(text(h.state()), ""); + assert!(h.state().sent_title.starts_with("Document2"), "{}", h.state().sent_title); + + h.state_mut().run("ui.tab", json!({"tab": "View"})).unwrap(); + h.run_steps(2); + h.get_by_label("Switch Windows").click(); + h.run_steps(2); + use egui::accesskit::Role; + assert!(h.query_by_role_and_label(Role::Button, "Document1 •").is_some(), "the first document is listed, unsaved"); + assert!(h.query_by_role_and_label(Role::Button, "Document2").is_some(), "the new one is listed"); + h.get_by_role_and_label(Role::Button, "Document1 •").click(); + h.run_steps(2); + let a = h.state(); + assert_eq!(a.session.document_id(), first); + assert_eq!(text(a), "First draft"); + assert_eq!(a.session.documents().len(), 2); + assert!(a.sent_title.starts_with("Document1"), "{}", a.sent_title); + } + + /// Close with two documents open asks about the shown one's changes; after Don't Save the + /// other one shows. + #[test] + fn close_asks_then_shows_the_other_document() { + let mut a = app(); + a.run("text.insert", json!({"text": "Kept"})).unwrap(); + let kept = a.session.document_id(); + a.run("file.new", json!({})).unwrap(); + assert!(prompt(&a).is_none(), "New asks nothing: nothing is replaced"); + a.run("text.insert", json!({"text": "Dropped"})).unwrap(); + a.run("file.close", json!({})).unwrap(); + assert_eq!(prompt(&a), Some("saveChanges")); + a.run("ui.saveChanges", json!({"answer": "dontSave"})).unwrap(); + assert_eq!(a.session.document_id(), kept); + assert_eq!(text(&a), "Kept"); + assert_eq!(a.session.documents().len(), 1); + assert!(a.has_document() && !a.quit_requested); + } + + /// Closing the window with a document of unsaved changes behind the shown one asks about it + /// (showing it); Cancel keeps the app open, and Don't Save quits. + #[test] + fn quitting_asks_about_a_document_that_is_not_shown() { + let mut a = app(); + a.run("text.insert", json!({"text": "Behind"})).unwrap(); + let behind = a.session.document_id(); + a.run("file.new", json!({})).unwrap(); + assert!(!a.close_requested()); + assert_eq!(a.session.document_id(), behind, "the unsaved document is shown"); + assert_eq!(prompt(&a), Some("saveChanges")); + a.run("ui.saveChanges", json!({"answer": "cancel"})).unwrap(); + assert!(!a.quit_requested); + assert_eq!(a.session.documents().len(), 2, "nothing closed"); + + assert!(!a.close_requested(), "asked again"); + a.run("ui.saveChanges", json!({"answer": "dontSave"})).unwrap(); + assert!(a.quit_requested); + assert!(a.close_requested()); + } + + /// A request an agent's command makes for a document that isn't shown never acts on the + /// shown one: Print doesn't open Backstage over it. + #[test] + fn requests_for_a_document_not_shown_leave_the_window_alone() { + let mut a = app(); + let first = a.session.document_id(); + a.run("file.new", json!({})).unwrap(); + a.session.with_document(first, |s| s.run("file.print", &json!({}))).unwrap().unwrap(); + for req in std::mem::take(&mut a.session.ui_requests) { + assert_eq!(req["document"], first); + a.handle_request(&req); + } + assert!(!a.ui.backstage, "Print for the other document left the window alone"); + } + + #[test] + fn untitled_names_follow_the_interface_language() { + assert_eq!(super::untitled_title("Document3").as_deref(), Some("Document3")); + assert_eq!(super::untitled_title("Documents"), None); + assert_eq!(super::untitled_title("Report"), None); + } +} diff --git a/crates/ui-egui/src/file_dialogs.rs b/crates/ui-egui/src/file_dialogs.rs index f40ebf1867..2ccb8530f1 100644 --- a/crates/ui-egui/src/file_dialogs.rs +++ b/crates/ui-egui/src/file_dialogs.rs @@ -315,8 +315,12 @@ impl WordApp { r } AfterPick::SaveAs { after, document } => { + // The document it was for may wait behind another one: it is shown and saved. if document != self.session.document_id() { - return Err("the document for this save has been replaced".into()); + if !self.session.is_open(document) { + return Err("the document for this save has been closed".into()); + } + self.show_document(document)?; } // The save's error, kept for the Save Changes prompt to explain (#1445). A plain // Save As (Backstage's Browse…, AutoSave) has no prompt to carry it to, so it was @@ -601,7 +605,8 @@ mod tests { let _ = std::fs::remove_dir_all(dir); } - /// A late Save As answer belongs to the imported document that opened the picker. + /// A late Save As answer belongs to the imported document that opened the picker: once that + /// document is closed, it writes nothing. #[test] fn imported_word_stale_save_as_never_writes_the_replacement_document() { for close in [false, true] { @@ -623,8 +628,8 @@ mod tests { } assert!(a.file_dialog_open()); let previous = a.session.document_id(); - // Automation bypasses the user prompt and can replace a document during a picker. - a.execute("file.new", json!({})).unwrap(); + // Automation bypasses the user prompt and can close a document during a picker. + a.execute("file.closeDocument", json!({"force": true})).unwrap(); typed(&mut a); assert_ne!(previous, a.session.document_id()); let draft = a.session.doc.clone(); @@ -960,14 +965,14 @@ mod tests { } /// "Save changes?" → Save on a new document: the held-back Close runs only once the later - /// Save As has actually saved; a cancelled Save As cancels the held-back New. + /// Save As has actually saved; a cancelled Save As cancels it. #[test] fn save_changes_waits_for_the_save_as_answer() { let dir = scratch("prompt"); let path = dir.join("novel.docx"); let (mut a, shown) = async_app(); typed(&mut a); - a.run("file.new", json!({})).unwrap(); + a.run("file.close", json!({})).unwrap(); a.run("ui.saveChanges", json!({"answer": "save"})).unwrap(); answer(&shown, None); a.poll_file_dialog(); diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index f93229681e..4b589cf50f 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -2761,6 +2761,9 @@ Deep Наситено Lit from Bottom Осветено отдолу Center Glow Сияние от центъра + Close Document Затваряне на документа More tabs Още раздели # #3013: Insert Table of Figures Include label and number Включване на етикета и номера + Switch Windows Превключване на прозорци + Switch\nWindows Превключване\nна прозорци diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 5d8c94f9b1..e97f27a375 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -2763,6 +2763,9 @@ preview Title Ukázka Deep Sytý Lit from Bottom Osvětlený zdola Center Glow Záře ze středu + Close Document Zavřít dokument More tabs Další karty # #3013: Insert Table of Figures Include label and number Zahrnout popisek a číslo + Switch Windows Přepnout okna + Switch\nWindows Přepnout\nokna diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 418fa3670a..2ad0f6e2c1 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -2509,6 +2509,7 @@ preview Title Titel Document name Dokumentnavn Rename Omdøb Account Konto + Close Document Luk dokument Privacy Options Indstillinger for beskyttelse af personlige oplysninger Trust Center Center for sikkerhed og beskyttelse Privacy for this document Beskyttelse af personlige oplysninger for dette dokument @@ -2598,4 +2599,6 @@ preview Title Titel Format Shape Pane Format Shape Pane Full Screen View Full Screen View Personal Templates Personal Templates + Switch Windows Skift vinduer + Switch\nWindows Skift\nvinduer Underline Color Underline Color diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index ec04156c07..3650328694 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -2359,6 +2359,7 @@ Deep Tief Lit from Bottom Von unten beleuchtet Center Glow Leuchten aus der Mitte + Close Document Dokument schließen # File › Home greeting Good morning Guten Morgen Good afternoon Guten Tag @@ -2366,3 +2367,5 @@ More tabs Weitere Registerkarten # #3013: Insert Table of Figures Include label and number Bezeichnung und Nummer einbeziehen + Switch Windows Fenster wechseln + Switch\nWindows Fenster\nwechseln diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 85d4fd96b0..ff5a74ead6 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2726,6 +2726,9 @@ preview Title Título Deep Profundo Lit from Bottom Iluminado desde abajo Center Glow Brillo central + Close Document Cerrar documento More tabs Más pestañas # #3013: Insert Table of Figures Include label and number Incluir rótulo y número + Switch Windows Cambiar ventanas + Switch\nWindows Cambiar\nventanas diff --git a/crates/ui-egui/src/i18n/et.tsv b/crates/ui-egui/src/i18n/et.tsv index 4c41381344..5b17c5de79 100644 --- a/crates/ui-egui/src/i18n/et.tsv +++ b/crates/ui-egui/src/i18n/et.tsv @@ -2394,6 +2394,7 @@ Deep Sügav Lit from Bottom Alt valgustatud Center Glow Keskne kuma + Close Document Sulge dokument # File › Home greeting Good morning Tere hommikust Good afternoon Tere päevast @@ -2401,3 +2402,5 @@ More tabs Veel vahekaarte # #3013: Insert Table of Figures Include label and number Lisa silt ja number + Switch Windows Vaheta aknaid + Switch\nWindows Vaheta\naknaid diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index c6ca31e47c..21d152e361 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2772,6 +2772,9 @@ template Letter Lettre Deep Profond Lit from Bottom Éclairé par le bas Center Glow Halo central + Close Document Fermer le document More tabs Plus d'onglets # #3013: Insert Table of Figures Include label and number Inclure l’étiquette et le numéro + Switch Windows Changer de fenêtre + Switch\nWindows Changer de\nfenêtre diff --git a/crates/ui-egui/src/i18n/hr.tsv b/crates/ui-egui/src/i18n/hr.tsv index 1bdb72d520..369ccc6e00 100644 --- a/crates/ui-egui/src/i18n/hr.tsv +++ b/crates/ui-egui/src/i18n/hr.tsv @@ -2771,6 +2771,9 @@ preview Title Naslov Deep Duboko Lit from Bottom Osvijetljeno odozdo Center Glow Sjaj u središtu + Close Document Zatvori dokument More tabs Više kartica # #3013: Insert Table of Figures Include label and number Uključi oznaku i broj + Switch Windows Promijeni prozore + Switch\nWindows Promijeni\nprozore diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 53c147b29d..2c547d0fdc 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -2767,6 +2767,9 @@ Deep Mély Lit from Bottom Alulról megvilágított Center Glow Középső ragyogás + Close Document Dokumentum bezárása More tabs További lapok # #3013: Insert Table of Figures Include label and number Címkével és számmal + Switch Windows Ablakváltás + Switch\nWindows Ablak-\nváltás diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 832be6ed56..cd7d52caf1 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -2783,6 +2783,7 @@ preview Title Esempio # Paper sizes, Go To, bookmarks and print labels the Croatian, Swedish and Vietnamese catalogs added B4 (JIS) 10.12" × 14.33" B4 (JIS) 10,12" × 14,33" B5 (JIS) 7.17" × 10.12" B5 (JIS) 7,17" × 10,12" + Close Document Chiudi documento Executive 7.25" × 10.50" Executive 7,25" × 10,50" Legal 8.50" × 14.00" Legal 8,50" × 14,00" Letter 8.50" × 11.00" Letter 8,50" × 11,00" @@ -2799,3 +2800,5 @@ template Letter Lettera More tabs Altre schede # #3013: Insert Table of Figures Include label and number Includi etichetta e numero + Switch Windows Cambia finestra + Switch\nWindows Cambia\nfinestra diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index d0bb1ed700..3820f1bd62 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2360,6 +2360,7 @@ Deep ディープ Lit from Bottom 下から照明 Center Glow 中央の光彩 + Close Document 文書を閉じる # File › Home greeting Good morning おはようございます Good afternoon こんにちは @@ -2367,3 +2368,5 @@ More tabs その他のタブ # #3013: Insert Table of Figures Include label and number ラベルと番号を含める + Switch Windows ウィンドウの切り替え + Switch\nWindows ウィンドウの\n切り替え diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index aea928d13e..f75d54cfd7 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -2614,6 +2614,9 @@ preview Title 제목 Deep 진하게 Lit from Bottom 아래에서 조명 Center Glow 가운데 광선 + Close Document 문서 닫기 More tabs 탭 더 보기 # #3013: Insert Table of Figures Include label and number 레이블 및 번호 포함 + Switch Windows 창 전환 + Switch\nWindows 창\n전환 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index 85421447df..ae4fd52cb5 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -2601,6 +2601,7 @@ preview Title Titel AutoFit to window Aanpassen aan venster Chapter 1 Hoofdstuk 1 Chicago Chicago + Close Document Document sluiten Commas Komma's Distance from text Afstand tot tekst Distance from text: Afstand tot tekst: @@ -2668,4 +2669,6 @@ preview Title Titel Format Shape Pane Format Shape Pane Full Screen View Full Screen View Personal Templates Personal Templates + Switch Windows Wisselen tussen vensters + Switch\nWindows Wisselen tussen\nvensters Underline Color Underline Color diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 4197f85cdc..8c7d03c48c 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -2783,6 +2783,7 @@ preview Title Tytuł # English text until translated (keys the Italian catalog has) B4 (JIS) 10.12" × 14.33" B4 (JIS) 10.12" × 14.33" B5 (JIS) 7.17" × 10.12" B5 (JIS) 7.17" × 10.12" + Close Document Zamknij dokument Enter bookmark name: Enter bookmark name: Envelope #10 4.12" × 9.50" Envelope #10 4.12" × 9.50" Envelope DL 4.33" × 8.66" Envelope DL 4.33" × 8.66" @@ -2791,6 +2792,8 @@ preview Title Tytuł Legal 8.50" × 14.00" Legal 8.50" × 14.00" Letter 8.50" × 11.00" Letter 8.50" × 11.00" Statement 5.50" × 8.50" Statement 5.50" × 8.50" + Switch Windows Przełącz okna + Switch\nWindows Przełącz\nokna Tabloid 11.00" × 17.00" Tabloid 11.00" × 17.00" This document has no bookmarks. This document has no bookmarks. Type a page number, or +2 / -1. Type a page number, or +2 / -1. diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 35486428f4..8e39f0fa1e 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -2372,6 +2372,7 @@ Deep Profundo Lit from Bottom Iluminado de baixo Center Glow Brilho central + Close Document Fechar Documento # File › Home greeting Good morning Bom dia Good afternoon Boa tarde @@ -2379,3 +2380,5 @@ More tabs Mais guias # #3013: Insert Table of Figures Include label and number Incluir rótulo e número + Switch Windows Alternar Janelas + Switch\nWindows Alternar\nJanelas diff --git a/crates/ui-egui/src/i18n/ro.tsv b/crates/ui-egui/src/i18n/ro.tsv index 1f1098962c..b018953937 100644 --- a/crates/ui-egui/src/i18n/ro.tsv +++ b/crates/ui-egui/src/i18n/ro.tsv @@ -2693,6 +2693,7 @@ template Letter Scrisoare Add gradient stop Add gradient stop Angle Angle Center Glow Center Glow + Close Document Închidere document Continue from previous section Continue from previous section Dark Variations Dark Variations Deep Deep @@ -2756,6 +2757,8 @@ template Letter Scrisoare Show the ribbon Show the ribbon Soft Soft Solid fill Solid fill + Switch Windows Comutare ferestre + Switch\nWindows Comutare\nferestre Templates you save in your templates folder show up here. Templates you save in your templates folder show up here. Underline Color Underline Color User templates: User templates: diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 48b77c61e7..4e157029b8 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -2770,6 +2770,9 @@ preview Title Пример Deep Глубокий Lit from Bottom Освещение снизу Center Glow Свечение из центра + Close Document Закрыть документ More tabs Другие вкладки # #3013: Insert Table of Figures Include label and number Включить подпись и номер + Switch Windows Перейти в другое окно + Switch\nWindows Перейти в\nдругое окно diff --git a/crates/ui-egui/src/i18n/sl.tsv b/crates/ui-egui/src/i18n/sl.tsv index 8bf77d27ae..5adf8740f4 100644 --- a/crates/ui-egui/src/i18n/sl.tsv +++ b/crates/ui-egui/src/i18n/sl.tsv @@ -2645,6 +2645,7 @@ preview Title Naslov AutoFit to window AutoFit to window Center Glow Center Glow Chapter 1 Chapter 1 + Close Document Zapri dokument Commas Commas Continue from previous section Continue from previous section Dark Variations Dark Variations @@ -2740,6 +2741,8 @@ preview Title Naslov Soft Soft Solid fill Solid fill Spike Spike + Switch Windows Preklopi okna + Switch\nWindows Preklopi\nokna Table size Table size Templates you save in your templates folder show up here. Templates you save in your templates folder show up here. The measurement must be between {min} and {max}. The measurement must be between {min} and {max}. diff --git a/crates/ui-egui/src/i18n/sr-latn.tsv b/crates/ui-egui/src/i18n/sr-latn.tsv index 65e5fd9e83..409cab6ed2 100644 --- a/crates/ui-egui/src/i18n/sr-latn.tsv +++ b/crates/ui-egui/src/i18n/sr-latn.tsv @@ -2370,6 +2370,7 @@ Deep Dubok Lit from Bottom Osvetljen odozdo Center Glow Sjaj iz sredine + Close Document Zatvori dokument # File › Home greeting Good morning Dobro jutro Good afternoon Dobar dan @@ -2377,3 +2378,5 @@ More tabs Još kartica # #3013: Insert Table of Figures Include label and number Uključi oznaku i broj + Switch Windows Promeni prozore + Switch\nWindows Promeni\nprozore diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index 910aa9b739..54a35e144d 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -2374,6 +2374,7 @@ Deep Дубок Lit from Bottom Осветљен одоздо Center Glow Сјај из средине + Close Document Затвори документ # File › Home greeting Good morning Добро јутро Good afternoon Добар дан @@ -2381,3 +2382,5 @@ More tabs Још картица # #3013: Insert Table of Figures Include label and number Укључи ознаку и број + Switch Windows Промени прозоре + Switch\nWindows Промени\nпрозоре diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index f1304d4c7e..4859483e62 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -2792,6 +2792,7 @@ template Letter Brev 📂 Browse… 📂 Bläddra… B4 (JIS) 10.12" × 14.33" B4 (JIS) 10,12" × 14,33" B5 (JIS) 7.17" × 10.12" B5 (JIS) 7,17" × 10,12" + Close Document Stäng dokument Envelope #10 4.12" × 9.50" Kuvert #10 4,12" × 9,50" Envelope DL 4.33" × 8.66" Kuvert DL 4,33" × 8,66" Executive 7.25" × 10.50" Executive 7,25" × 10,50" @@ -2802,3 +2803,5 @@ template Letter Brev More tabs Fler flikar # #3013: Insert Table of Figures Include label and number Ta med etikett och nummer + Switch Windows Växla fönster + Switch\nWindows Växla\nfönster diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index b56c6f27fa..66b8185664 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2769,6 +2769,9 @@ preview Title Örnek Deep Koyu Lit from Bottom Alttan aydınlatılmış Center Glow Merkez parıltısı + Close Document Belgeyi Kapat More tabs Diğer sekmeler # #3013: Insert Table of Figures Include label and number Etiketi ve numarayı ekle + Switch Windows Pencere Değiştir + Switch\nWindows Pencere\nDeğiştir diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index be03ee7f95..705f3df67d 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -2372,6 +2372,7 @@ Deep Глибокий Lit from Bottom Освітлення знизу Center Glow Сяйво з центру + Close Document Закрити документ # File › Home greeting Good morning Доброго ранку Good afternoon Добрий день @@ -2379,3 +2380,5 @@ More tabs Інші вкладки # #3013: Insert Table of Figures Include label and number Додавати мітку й номер + Switch Windows Перейти до іншого вікна + Switch\nWindows Перейти до\nіншого вікна diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index 5c7324cc2f..422af6c6dd 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -2811,6 +2811,9 @@ template Letter Thư Deep Sâu Lit from Bottom Chiếu sáng từ dưới Center Glow Phát sáng ở giữa + Close Document Đóng tài liệu More tabs Các tab khác # #3013: Insert Table of Figures Include label and number Bao gồm nhãn và số + Switch Windows Chuyển cửa sổ + Switch\nWindows Chuyển\ncửa sổ diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index eda14a5304..d80810263e 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -2372,6 +2372,7 @@ Deep 深沉 Lit from Bottom 底部照亮 Center Glow 中心光晕 + Close Document 关闭文档 # File › Home greeting Good morning 早上好 Good afternoon 下午好 @@ -2379,3 +2380,5 @@ More tabs 更多选项卡 # #3013: Insert Table of Figures Include label and number 包含标签和编号 + Switch Windows 切换窗口 + Switch\nWindows 切换\n窗口 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index c0e4756a01..6082a0e19f 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -2361,6 +2361,7 @@ Deep 深沉 Lit from Bottom 底部照亮 Center Glow 中央光暈 + Close Document 關閉文件 # File › Home greeting Good morning 早安 Good afternoon 午安 @@ -2368,3 +2369,5 @@ More tabs 更多索引標籤 # #3013: Insert Table of Figures Include label and number 包含標籤和編號 + Switch Windows 切換視窗 + Switch\nWindows 切換\n視窗 diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 5dede3611d..fa3163fe31 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -49,6 +49,7 @@ pub mod dialogs_ribbon; pub mod dialogs_smart_art; pub mod dialogs_table_style; pub mod dialogs_templates; +pub mod documents; pub mod equation_tab; /// The built-in file browser, for desktops without a system file dialog (#576). #[cfg(not(target_arch = "wasm32"))] @@ -659,6 +660,12 @@ pub struct WordApp { no_document: Option, /// Comment and reply text typed but not yet written to the document (#1348). pub(crate) comment_drafts: panes::CommentDrafts, + /// The document the window's per-document state belongs to; when the session's active + /// document is another, [`WordApp::sync_document`] puts that state aside and brings the + /// other's. + shown_document: u64, + /// The per-document state of the open documents not shown. + parked: std::collections::HashMap, } /// The answer to "Do you want to save changes?" (`ui.saveChanges`). @@ -686,6 +693,7 @@ impl WordApp { if let Some(tag) = sys_locale::get_locale() { wordcraft_engine::set_system_locale(&tag); } + let session_document = session.document_id(); WordApp { session, ui: UiState::default(), @@ -760,6 +768,8 @@ impl WordApp { recovery_seen: false, no_document: None, comment_drafts: Default::default(), + shown_document: session_document, + parked: Default::default(), } } @@ -1065,17 +1075,13 @@ impl WordApp { // Its HTML too when it has some (an equation as MathML, #816). crate::system_clipboard::put_result(self, ctx, v); } - // When the document has been replaced (not when Open only showed its picker), a pending - // "Save changes?" and AutoSave's go-ahead were both about the old one. + // Another document is shown (not when Open only showed its picker): the window's state + // for the one it showed is put aside. if self.session.document_id() != document { // A requested New/Open wins over the asynchronous startup recovery scan. // Older copies remain available through File > Recover. self.recovery_seen = true; - self.autosave_path = None; - if matches!(self.dialog, Some(dialogs::Dialog::SaveChanges { .. })) { - self.dialog = None; - } - crate::table_pen::stop(self); + self.sync_document(); } // An explicit save to the document's own file lets AutoSave keep writing there. if let Ok(v) = &r @@ -1294,6 +1300,9 @@ impl WordApp { async_saves::SaveOutcome::Cancelled | async_saves::SaveOutcome::Failed(_) => { if save.marked_saved && save.document == current { self.session.dirty = true; + } else if save.marked_saved { + // Another document is shown: the one saved is unsaved again where it waits. + let _ = self.session.with_document(save.document, |s| s.dirty = true); } } } @@ -1362,33 +1371,46 @@ impl WordApp { return true; } // A comment draft the document refuses is an unsaved change: the prompt can drop it. - let refused = panes::flush_comment_drafts(self).is_err(); - if !self.session.dirty && !self.save_unconfirmed() && !refused { + let _ = panes::flush_comment_drafts(self); + if !self.has_unsaved_documents() { return true; } - // `quit`: once the prompt is answered the window closes too, not just the document. - let _ = self.run("file.close", json!({"quit": true})); - false + // Each document with unsaved work is shown and asked about in turn; Cancel stops. + self.ask_before_quit(); + self.quit_requested } - /// File › Close (Mod+W, Mod+F4) closes the document and keeps the window, as Word does - /// (#1712): it shows no page, as after Esc on the Start screen (#1129), and File › New or Open - /// goes on from there. `{"quit": true}`, from the window's close button, quits instead. With no - /// document open there is nothing to close. Unsaved changes are never discarded unasked: a - /// dirty document closes only with `{"force": true}` (Don't Save in the prompt passes it). + /// File › Close (Mod+W, Mod+F4) closes the shown document and keeps the window, as Word does + /// (#1712): the next open document shows, or, when it was the only one, no page, as after Esc + /// on the Start screen (#1129), and File › New or Open goes on from there. `{"quit": true}`, + /// from the window's close button, goes on quitting: the shown document's prompt has been + /// answered, so it closes and the next document with unsaved changes is asked about + /// ([`WordApp::ask_before_quit`]). With no document open there is nothing to close. Unsaved + /// changes are never discarded unasked: a dirty document closes only with `{"force": true}` + /// (Don't Save in the prompt passes it). fn close_document(&mut self, params: &Value) -> Result { if let Some(e) = control::close_refused(self, "file.close", params) { return Err(e.into()); } + let last = self.session.documents().len() <= 1; if params.get("quit").and_then(Value::as_bool) == Some(true) { - self.quit_requested = true; - return Ok(json!({"quit": true})); + if last { + self.quit_requested = true; + return Ok(json!({"quit": true})); + } + self.session.close_document(self.session.document_id()).map_err(|e| e.to_string())?; + self.sync_document(); + return Ok(self.ask_before_quit()); } if !self.has_document() { return Ok(json!({"closed": false})); } - self.execute("file.new", json!({}))?; - self.no_document = Some(self.session.document_id()); + self.session.close_document(self.session.document_id()).map_err(|e| e.to_string())?; + self.recovery_seen = true; + self.sync_document(); + if last { + self.no_document = Some(self.session.document_id()); + } self.ui.backstage = false; Ok(json!({"closed": true})) } @@ -1439,6 +1461,13 @@ impl WordApp { /// Requests commands make of the UI (open a dialog…). fn handle_request(&mut self, req: &Value) { + // Made for a document that isn't shown (an agent's command run there). + if let Some(document) = req.get("document").and_then(Value::as_u64) + && document != self.session.document_id() + { + self.handle_request_for(document, req); + return; + } // Ctrl+F: type straight into the Navigation pane's search box (#712). if req.get("focus").and_then(Value::as_str) == Some("navSearch") { self.nav_search_focus = true; @@ -1484,6 +1513,33 @@ impl WordApp { } } + /// A request made for document `document`, which isn't shown: the interface theme is the + /// app's and changes; Close closes that document if it has nothing unsaved; the rest (dialogs, + /// panes, focus, scrolling) are about the window's page and are dropped, as the page shows + /// another document. + fn handle_request_for(&mut self, document: u64, req: &Value) { + let theme_only = req.get("interfaceTheme").is_some() || req.get("toggle").and_then(Value::as_str) == Some("interfaceTheme"); + if theme_only { + let mut req = req.clone(); + if let Some(o) = req.as_object_mut() { + o.remove("document"); + } + self.handle_request(&req); + return; + } + if req.get("close").is_some() { + let dirty = self.session.documents().iter().any(|d| d.id == document && d.dirty); + if dirty { + log::warn!("not closing document {document}: it has unsaved changes"); + } else if let Err(e) = self.session.close_document(document) { + log::warn!("closing document {document}: {e}"); + } + self.sync_document(); + return; + } + log::info!("dropped a request for document {document}, which isn't shown: {req}"); + } + /// Follow the desktop's light/dark choice while the app runs (Linux, where winit reports no /// system theme; see [`desktop_theme`]). The desktop app calls this once at start-up; unit /// tests and `ui_shot` don't, so what they draw doesn't depend on the host's desktop theme. @@ -2054,7 +2110,9 @@ impl WordApp { Some(p) => p.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_else(|| tl!("Document1").into()), None => match &self.session.file_name { Some(name) => name.clone(), - None if self.session.doc.core.title.is_empty() => tl!("Document1").into(), + None if self.session.doc.core.title.is_empty() => { + documents::untitled_title(&self.session.resolved_filenames().0).unwrap_or_else(|| tl!("Document1").into()) + } None => self.session.doc.core.title.clone(), }, } @@ -2177,13 +2235,13 @@ impl WordApp { self.drain_inbox(); self.drain_pasted(); self.watch_fonts(ctx); + // A script, an agent or a later result may have made another document the active one. + self.sync_document(); let document = self.session.document_id(); self.session.recovery_tick(); if self.session.document_id() != document { - self.autosave_path = None; - self.dialog = None; self.ui.backstage = false; - self.canvas.want_focus = true; + self.sync_document(); } if !self.recovery_seen && self.session.recovery_ready() { self.recovery_seen = true; @@ -2830,17 +2888,13 @@ fn flushes_comment_drafts(id: &str, params: &Value) -> bool { ) || discards_document(id, params) } -/// User commands that replace or close the document (New, also from a template; `file.open` without a path only shows the -/// file picker; the open that follows is checked). Envelopes, Labels and Finish & Merge make a -/// new document in its place; a merge written to a file (`path`) and an envelope added to this -/// document (`add`) leave it alone. +/// User commands that close or replace the shown document: Close, and restoring a recovered +/// copy in its place. New, Open, Envelopes, Labels and Finish & Merge open their document beside +/// it (Open replaces only an untouched blank document, which has nothing to lose). pub(crate) fn discards_document(id: &str, params: &Value) -> bool { match id { - "file.new" | "file.newFromTemplate" | "file.close" | "mailings.labels" => true, - "mailings.envelopes" => params.get("add").and_then(Value::as_bool) != Some(true), - "file.open" => params.get("path").is_some(), + "file.close" => true, "file.recover" => params.get("restore").is_some(), - "mailings.finish" => params.get("path").and_then(Value::as_str).is_none(), _ => false, } } @@ -5351,27 +5405,19 @@ mod tests { path } - /// Surfaces finding 1: Mod+N (and the File › New tiles) replaced unsaved work without asking. + /// Mod+N (and the File › New tiles) open a new document beside unsaved work, asking nothing: + /// nothing is replaced. #[test] - fn new_asks_before_discarding_unsaved_work() { + fn new_opens_beside_unsaved_work() { let mut a = typed(); - a.run("file.new", json!({})).unwrap(); - assert_eq!(prompt(&a), Some("saveChanges")); - assert!(body_text(&a).contains(UNSAVED), "the document is untouched while the prompt is up"); - assert!(a.session.dirty); - - // Cancel: nothing happens. - a.run("ui.saveChanges", json!({"answer": "cancel"})).unwrap(); - assert_eq!(prompt(&a), None); - assert!(body_text(&a).contains(UNSAVED)); - - // Don't Save: the new document replaces it. + let first = a.session.document_id(); a.run("file.new", json!({"template": "letter"})).unwrap(); - a.run("ui.saveChanges", json!({"answer": "dontSave"})).unwrap(); assert_eq!(prompt(&a), None); assert!(!body_text(&a).contains(UNSAVED)); assert!(!a.session.dirty); assert!(!body_text(&a).trim().is_empty(), "the template the user picked was used"); + a.run("view.switchWindows", json!({"id": first})).unwrap(); + assert!(body_text(&a).contains(UNSAVED) && a.session.dirty, "the unsaved document is still open"); } #[test] @@ -5395,9 +5441,8 @@ mod tests { assert!(a.execute("file.open", json!({"path": path.to_string_lossy()})).is_err(), "a script gets the error"); assert_eq!(prompt(&a), None); a.run("file.open", json!({"path": path.to_string_lossy()})).unwrap(); - a.run("ui.saveChanges", json!({"answer": "dontSave"})).unwrap(); assert_eq!(prompt(&a), Some("password")); - assert!(body_text(&a).contains(UNSAVED), "nothing is replaced until the password fits"); + assert!(body_text(&a).contains(UNSAVED), "nothing opens until the password fits"); let state = serde_json::to_string(&a.dialog).unwrap(); assert!(state.contains("locked.docx") && !state.contains("sesame")); @@ -5410,20 +5455,24 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } - /// Open (after the file is picked), recent files and dropped files all go through `file.open`. + /// Open (after the file is picked), recent files and dropped files all go through `file.open`: + /// the file opens beside unsaved work, asking nothing, and opening it again shows it. #[test] - fn open_asks_before_discarding_unsaved_work() { + fn open_opens_beside_unsaved_work() { let dir = scratch("open"); let path = docx_on_disk(&dir, "Signed contract"); let mut a = typed(); + let first = a.session.document_id(); a.run("file.open", json!({"path": path.to_string_lossy()})).unwrap(); - assert_eq!(prompt(&a), Some("saveChanges")); - assert!(body_text(&a).contains(UNSAVED)); - assert_eq!(a.session.path, None); - - a.run("ui.saveChanges", json!({"answer": "dontSave"})).unwrap(); + assert_eq!(prompt(&a), None); assert!(body_text(&a).contains("Signed contract")); assert_eq!(a.session.path.as_deref(), Some(path.as_path())); + let opened = a.session.document_id(); + a.run("view.switchWindows", json!({"id": first})).unwrap(); + assert!(body_text(&a).contains(UNSAVED) && a.session.dirty); + a.run("file.open", json!({"path": path.to_string_lossy()})).unwrap(); + assert_eq!(a.session.document_id(), opened, "an open file is shown, not opened twice"); + assert_eq!(a.session.documents().len(), 2); let _ = std::fs::remove_dir_all(&dir); } @@ -6144,15 +6193,15 @@ mod tests { } } - /// A prompt about document A outlived an agent replacing A with B, and Don't Save then ran - /// the pending New against B, throwing away B's edits under a question naming A. + /// A prompt about document A outlived an agent showing B instead, and Don't Save then ran + /// the pending Close against B, throwing away B's edits under a question naming A. #[test] fn a_prompt_is_dropped_when_its_document_is_replaced() { let dir = scratch("stale"); let path = docx_on_disk(&dir, "Agent document"); let ctx = egui::Context::default(); let mut a = typed(); - a.run("file.new", json!({})).unwrap(); + a.run("file.close", json!({})).unwrap(); assert_eq!(prompt(&a), Some("saveChanges")); let asked = a.dialog.clone(); for command in [ @@ -6711,11 +6760,11 @@ mod tests { let mut a = app(); a.run("file.open", json!({"path": path.to_string_lossy()})).unwrap(); a.run("text.insert", json!({"text": "Revised "})).unwrap(); - a.run("file.new", json!({})).unwrap(); + a.run("file.close", json!({})).unwrap(); assert_eq!(prompt(&a), Some("saveChanges")); a.run("ui.saveChanges", json!({"answer": "save"})).unwrap(); assert_eq!(prompt(&a), None); - assert_eq!(a.session.path, None, "the new document replaced the saved one"); + assert!(!a.has_document(), "the saved document closed"); let saved = wordcraft_engine::io::open_path(&path).unwrap(); assert!(saved.plain_text(wordcraft_doc::StoryRef::Body).contains("Revised Draft")); let _ = std::fs::remove_dir_all(&dir); @@ -6730,10 +6779,10 @@ mod tests { let picked = dir.join(format!("novel.{ext}")).to_string_lossy().to_string(); let mut a = typed(); a.services.pick_save = Some(Box::new(move |_| Some(picked.clone()))); - a.run("file.new", json!({})).unwrap(); + a.run("file.close", json!({})).unwrap(); a.status_msg = None; let r = a.run("ui.saveChanges", json!({"answer": "save"})).unwrap(); - assert_eq!(r["done"], false, "{ext}: the New was cancelled"); + assert_eq!(r["done"], false, "{ext}: the Close was cancelled"); assert!(dir.join(format!("novel.{ext}")).exists(), "{ext}: the copy was written"); assert_eq!(prompt(&a), None); assert!(body_text(&a).contains(UNSAVED), "{ext}: the document is kept"); @@ -6818,9 +6867,9 @@ mod tests { assert!(a.execute("file.save", json!({})).is_err()); assert!(a.session.dirty); - a.run("file.new", json!({})).unwrap(); + a.run("file.close", json!({})).unwrap(); a.status_msg = None; - // A failed save is an error, not a quiet Cancel (#1445); the New doesn't run. + // A failed save is an error, not a quiet Cancel (#1445); the Close doesn't run. let err = a.run("ui.saveChanges", json!({"answer": "save"})).unwrap_err(); assert!(err.contains("still open") && err.contains("blocked by the browser"), "{err}"); assert!(body_text(&a).contains(UNSAVED)); @@ -6845,21 +6894,21 @@ mod tests { (a, saves, last) } - /// #1315: Save in the prompt before New waits for the picker: New runs only once the file + /// #1315: Save in the prompt before Close waits for the picker: Close runs only once the file /// is written and closed. #[test] - fn web_save_before_new_waits_for_the_picker_to_finish() { + fn web_save_before_close_waits_for_the_picker_to_finish() { let (mut a, saves, last) = web_picker_app(); - a.run("file.new", json!({})).unwrap(); + a.run("file.close", json!({})).unwrap(); assert_eq!(prompt(&a), Some("saveChanges")); let r = a.run("ui.saveChanges", json!({"answer": "save"})).unwrap(); assert_eq!(r["pending"], "save", "{r}"); assert_eq!(saves.pending(), 1); a.settle_async_saves(); - assert!(body_text(&a).contains(UNSAVED), "nothing is replaced while the picker is open"); + assert!(body_text(&a).contains(UNSAVED), "nothing closes while the picker is open"); saves.finish(last.get(), async_saves::SaveOutcome::Saved); a.settle_async_saves(); - assert!(!body_text(&a).contains(UNSAVED), "the New ran once the save finished"); + assert!(!body_text(&a).contains(UNSAVED), "the Close ran once the save finished"); assert!(!a.session.dirty); assert_eq!(prompt(&a), None); } @@ -6867,12 +6916,12 @@ mod tests { /// #1315: cancelling the picker, or a write/close that fails, keeps the document: its text, /// its undo, still unsaved, and the prompt back with the reason. Nothing is replaced. #[test] - fn web_save_before_new_that_is_cancelled_or_fails_keeps_the_document() { + fn web_save_before_close_that_is_cancelled_or_fails_keeps_the_document() { for (outcome, reason) in [(async_saves::SaveOutcome::Cancelled, "cancel"), (async_saves::SaveOutcome::Failed("the disk is full".into()), "the disk is full")] { let (mut a, saves, last) = web_picker_app(); - a.run("file.new", json!({})).unwrap(); + a.run("file.close", json!({})).unwrap(); a.run("ui.saveChanges", json!({"answer": "save"})).unwrap(); a.status_msg = None; saves.finish(last.get(), outcome.clone()); @@ -6892,11 +6941,11 @@ mod tests { /// #1315 review: the prompt's Save is in the picker (ticket 1), the user edits and saves /// again (ticket 2, which marks the document saved at once). Ticket 1 ending doesn't let the - /// held-back New replace the document while ticket 2 hasn't written its edits. + /// held-back Close close the document while ticket 2 hasn't written its edits. #[test] fn web_overlapping_saves_hold_the_replacement() { let (mut a, saves, last) = web_picker_app(); - a.run("file.new", json!({})).unwrap(); + a.run("file.close", json!({})).unwrap(); a.run("ui.saveChanges", json!({"answer": "save"})).unwrap(); let first = last.get(); a.run("text.insert", json!({"text": "Later edit "})).unwrap(); @@ -6909,11 +6958,11 @@ mod tests { } /// #1315 review: edited while the picker was open, then the save ends: those edits aren't - /// in the file, so the prompt asks again instead of running New. + /// in the file, so the prompt asks again instead of running Close. #[test] fn web_save_that_ends_after_an_edit_asks_again() { let (mut a, saves, last) = web_picker_app(); - a.run("file.new", json!({})).unwrap(); + a.run("file.close", json!({})).unwrap(); a.run("ui.saveChanges", json!({"answer": "save"})).unwrap(); a.run("text.insert", json!({"text": "Later edit "})).unwrap(); saves.finish(last.get(), async_saves::SaveOutcome::Saved); @@ -6924,12 +6973,12 @@ mod tests { assert_eq!(prompt_message(&a).as_deref(), Some("")); } - /// #1315 review: the status bar says New/Open is waiting for the save; a cancelled picker + /// #1315 review: the status bar says Close is waiting for the save; a cancelled picker /// says it was cancelled, not that saving failed. #[test] fn web_save_prompt_says_it_waits_and_that_a_cancel_was_a_cancel() { let (mut a, saves, last) = web_picker_app(); - a.run("file.new", json!({})).unwrap(); + a.run("file.close", json!({})).unwrap(); a.status_msg = None; a.run("ui.saveChanges", json!({"answer": "save"})).unwrap(); let status = a.status_msg.as_ref().map(|(m, _)| m.clone()).unwrap_or_default(); @@ -6941,62 +6990,61 @@ mod tests { assert!(!message.contains("Couldn't save"), "a cancel isn't a failure: {message}"); } - /// #1315 review: a document dropped or picked on the web (`file.open` with its bytes) waits - /// for the save too, then opens. + /// #1315 review: a document dropped or picked on the web (`file.open` with its bytes) opens + /// beside one whose save is still in the picker, asking nothing. #[test] - fn web_save_before_open_opens_the_file_once_saved() { + fn web_open_opens_beside_a_save_in_flight() { let dir = scratch("web-open-after-save"); let bytes = std::fs::read(docx_on_disk(&dir, "Picked document")).unwrap(); - let (mut a, saves, last) = web_picker_app(); + let (mut a, _saves, _last) = web_picker_app(); + let first = a.session.document_id(); + a.execute("file.save", json!({})).unwrap(); let data = wordcraft_engine::cmd::insert::base64_encode(&bytes); a.run("file.open", json!({"path": "picked.docx", "data": data})).unwrap(); - assert_eq!(prompt(&a), Some("saveChanges")); - a.run("ui.saveChanges", json!({"answer": "save"})).unwrap(); - a.settle_async_saves(); - assert!(body_text(&a).contains(UNSAVED), "nothing opened while the picker is open"); - saves.finish(last.get(), async_saves::SaveOutcome::Saved); - a.settle_async_saves(); + assert_eq!(prompt(&a), None); assert!(body_text(&a).contains("Picked document"), "the picked file opened: {:?}", body_text(&a)); + a.run("view.switchWindows", json!({"id": first})).unwrap(); + assert!(body_text(&a).contains(UNSAVED), "the first document is still open"); let _ = std::fs::remove_dir_all(&dir); } - /// #1315: while the picker save is in flight the document's save isn't confirmed, so New - /// asks again instead of replacing it. + /// #1315: while the picker save is in flight the document's save isn't confirmed, so Close + /// asks again instead of closing it. #[test] - fn web_new_while_a_picker_save_is_in_flight_asks_first() { + fn web_close_while_a_picker_save_is_in_flight_asks_first() { let (mut a, _saves, _last) = web_picker_app(); a.execute("file.save", json!({})).unwrap(); - a.run("file.new", json!({})).unwrap(); + a.run("file.close", json!({})).unwrap(); assert_eq!(prompt(&a), Some("saveChanges")); assert!(body_text(&a).contains(UNSAVED), "nothing was replaced"); } - /// #1315: a save that ends after the document was replaced neither runs the held-back New on - /// the new document nor marks it unsaved. + /// #1315: a save that ends after another document is shown neither runs the held-back Close + /// on that document nor marks it unsaved. #[test] fn web_picker_save_ending_after_the_document_changed_leaves_the_new_one_alone() { for outcome in [async_saves::SaveOutcome::Saved, async_saves::SaveOutcome::Failed("gone".into())] { let (mut a, saves, last) = web_picker_app(); - a.run("file.new", json!({})).unwrap(); + a.run("file.close", json!({})).unwrap(); a.run("ui.saveChanges", json!({"answer": "save"})).unwrap(); - // Another document replaces it (an agent's or a script's command) while the picker is open. + // Another document is shown (an agent's or a script's command) while the picker is open. a.execute("file.new", json!({})).unwrap(); a.execute("text.insert", json!({"text": "Second document"})).unwrap(); a.session.dirty = false; saves.finish(last.get(), outcome.clone()); a.settle_async_saves(); - assert!(body_text(&a).contains("Second document"), "{outcome:?}: the held-back New didn't run on it"); + assert!(body_text(&a).contains("Second document"), "{outcome:?}: the held-back Close didn't run on it"); assert!(!a.session.dirty, "{outcome:?}: the new document isn't marked unsaved"); assert_eq!(prompt(&a), None, "{outcome:?}"); } } - /// A new document has no path: Save goes through Save As, and cancelling that cancels the New. + /// A new document has no path: Save goes through Save As, and cancelling that cancels the Close. #[test] fn save_as_cancelled_keeps_the_document() { let mut a = typed(); a.services.pick_save = Some(Box::new(|_| None)); - a.run("file.new", json!({})).unwrap(); + a.run("file.close", json!({})).unwrap(); a.run("ui.saveChanges", json!({"answer": "save"})).unwrap(); assert_eq!(prompt(&a), None); assert!(body_text(&a).contains(UNSAVED)); @@ -7100,35 +7148,21 @@ mod tests { } } - /// Envelopes, Labels and Finish & Merge replace the document with a new one, like New, but - /// the ribbon ran them at once and the unsaved document was gone without a question. + /// Envelopes, Labels and Finish & Merge open their result as a new document beside the + /// unsaved one, like New, asking nothing. #[test] - fn mailings_ask_before_replacing_unsaved_work() { + fn mailings_open_beside_unsaved_work() { for id in MAILINGS { let mut a = typed(); a.run("mailings.recipients", json!({"csv": "Name\nAda"})).unwrap(); let document = a.session.document_id(); a.run(id, mailing(id)).unwrap(); - assert_eq!(prompt(&a), Some("saveChanges"), "{id}"); - assert_eq!(a.session.document_id(), document, "{id}: untouched while the prompt is up"); - - a.run("ui.saveChanges", json!({"answer": "cancel"})).unwrap(); - assert_eq!(prompt(&a), None); - assert_eq!(a.session.document_id(), document, "{id}: Cancel keeps the document"); - assert!(body_text(&a).contains(UNSAVED)); - assert!(a.session.dirty); - - a.run(id, mailing(id)).unwrap(); - a.run("ui.saveChanges", json!({"answer": "dontSave"})).unwrap(); - assert_eq!(prompt(&a), None); - assert_ne!(a.session.document_id(), document, "{id}: Don't Save makes the new document"); + assert_eq!(prompt(&a), None, "{id}"); + assert_ne!(a.session.document_id(), document, "{id}: the result is a new document"); assert_eq!(a.session.path, None); + a.run("view.switchWindows", json!({"id": document})).unwrap(); + assert!(body_text(&a).contains(UNSAVED) && a.session.dirty, "{id}: the unsaved document is still open"); } - // A `path` that isn't text merges into a new document, like no path at all. - let mut a = typed(); - a.run("mailings.recipients", json!({"csv": "Name\nAda"})).unwrap(); - a.run("mailings.finish", json!({"path": null})).unwrap(); - assert_eq!(prompt(&a), Some("saveChanges")); } /// A clean document is replaced straight away, like New; a merge written to a file @@ -7157,7 +7191,7 @@ mod tests { } /// The result is a new, untitled document: Save, AutoSave and the title never point at the - /// file the original came from, even after Save in the prompt wrote that file. + /// file the original came from. #[test] fn mailings_results_never_save_over_the_original() { for id in MAILINGS { @@ -7170,14 +7204,9 @@ mod tests { assert!(a.autosaves()); a.run("mailings.recipients", json!({"csv": "Name\nAda"})).unwrap(); a.run("text.insert", json!({"text": "Again "})).unwrap(); - a.run(id, mailing(id)).unwrap(); - a.run("ui.saveChanges", json!({"answer": "save"})).unwrap(); - assert_eq!(prompt(&a), None); let saved = std::fs::read(&path).unwrap(); - assert!( - wordcraft_engine::io::open_path(&path).unwrap().plain_text(wordcraft_doc::StoryRef::Body).contains("Revised Again Signed"), - "{id}: Save wrote the document first" - ); + a.run(id, mailing(id)).unwrap(); + assert_eq!(prompt(&a), None); assert_eq!(a.session.path, None, "{id}"); assert!(!a.saved_here() && !a.autosaves(), "{id}: AutoSave doesn't cover the result"); @@ -7210,26 +7239,28 @@ mod tests { } } - /// Review finding: with undo kept across the swap, an agent's Undo brought document A back - /// under the id of the mailing result B while a prompt about B was up, and Don't Save then - /// threw away A. The history starts afresh instead, so the prompt only ever discards B. + /// Review finding: an agent's Undo while a prompt about the mailing result B was up must not + /// bring document A back under B, where Don't Save would throw A away. The result is its + /// own document: Undo stays in B, and Don't Save closes B and shows A, unsaved work intact. #[test] fn undo_after_a_mailing_cannot_redirect_a_pending_prompt() { for id in MAILINGS { let mut a = typed(); a.execute("mailings.recipients", json!({"csv": "Name\nAda"})).unwrap(); + let first = a.session.document_id(); a.execute(id, json!({})).unwrap(); - let result = body_text(&a); let b = a.session.document_id(); - assert!(a.session.dirty, "{id}: the result is unsaved"); - a.run("file.new", json!({})).unwrap(); + assert_ne!(b, first, "{id}"); + a.run("text.insert", json!({"text": "Edited result "})).unwrap(); + a.run("file.close", json!({})).unwrap(); assert_eq!(prompt(&a), Some("saveChanges"), "{id}"); a.execute("edit.undo", json!({})).unwrap(); assert_eq!(a.session.document_id(), b, "{id}"); - assert_eq!(body_text(&a), result, "{id}: nothing from before the mailing comes back"); a.run("ui.saveChanges", json!({"answer": "dontSave"})).unwrap(); - assert_ne!(a.session.document_id(), b, "{id}: Don't Save discarded the document it asked about"); + assert!(!a.session.is_open(b), "{id}: Don't Save closed the document it asked about"); + assert_eq!(a.session.document_id(), first, "{id}"); + assert!(body_text(&a).contains(UNSAVED) && a.session.dirty, "{id}"); } } @@ -8637,10 +8668,11 @@ mod tests { assert!(!a.recipient_list_pending); inbox.lock().unwrap().push(("notes.txt".into(), b"A plain text document.\nAnother line.\n".to_vec())); a.drain_inbox(); - assert_eq!(prompt(&a), Some("saveChanges"), "the TXT opens like any document"); - a.run("ui.saveChanges", json!({"answer": "dontSave"})).unwrap(); - assert_eq!(a.session.merge.headers, vec!["Name", "City"], "the recipients stay"); + assert_eq!(prompt(&a), None, "the TXT opens like any document, beside the first"); assert!(body_text(&a).contains("A plain text document."), "the TXT opened as a document"); + let first = a.session.documents().first().map(|d| d.id).unwrap(); + a.run("view.switchWindows", json!({"id": first})).unwrap(); + assert_eq!(a.session.merge.headers, vec!["Name", "City"], "the recipients stay with their document"); // A cancelled picker leaves nothing pending once the user does something else. a.run("ui.openRecipientList", json!({})).unwrap(); a.run("format.bold", json!({})).unwrap(); diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index fa37d7a940..3b9afb6113 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -514,6 +514,11 @@ impl CommentDrafts { } self } + + /// Nothing typed and not yet written. + pub(crate) fn is_empty(&self) -> bool { + self.edits.is_empty() && self.replies.is_empty() + } } /// The pending draft of comment `id`'s text in the open document, if any. diff --git a/crates/ui-egui/src/previews.rs b/crates/ui-egui/src/previews.rs index ddbe273c3e..721776f829 100644 --- a/crates/ui-egui/src/previews.rs +++ b/crates/ui-egui/src/previews.rs @@ -296,6 +296,11 @@ impl Previews { Some(h) } + /// Another document is shown: its revisions count apart from the last one's. + pub(crate) fn document_changed(&mut self) { + self.styles_hash = (u64::MAX, 0); + } + /// Hash of the style sheet (recomputed only when the document revision changes). fn styles_key(&mut self, app_doc: &Document, rev: u64) -> u64 { if self.styles_hash.0 != rev { diff --git a/crates/ui-egui/src/read_aloud.rs b/crates/ui-egui/src/read_aloud.rs index 0d3d0fb251..8d77a1bab0 100644 --- a/crates/ui-egui/src/read_aloud.rs +++ b/crates/ui-egui/src/read_aloud.rs @@ -29,7 +29,8 @@ pub fn poll(app: &mut WordApp, ctx: &egui::Context) { app.read_aloud_error = Some(e.clone()); app.status(e); } - let current = app.session.read_aloud.current().map(|u| u.start); + // The caret follows the reading only in the document being read. + let current = if reads_shown_document(app) { app.session.read_aloud.current().map(|u| u.start) } else { None }; if current != app.read_aloud_at { if let Some(start) = ¤t { app.session.sel = wordcraft_engine::Selection::caret(app.session.doc.clamp(start)); @@ -42,13 +43,18 @@ pub fn poll(app: &mut WordApp, ctx: &egui::Context) { /// The page rectangles (in page units) of the sentence being read, to highlight. pub fn highlight(app: &mut WordApp) -> Option<(wordcraft_engine::doc::Pos, wordcraft_engine::doc::Pos)> { - if !app.session.read_aloud.open { + if !app.session.read_aloud.open || !reads_shown_document(app) { return None; } let u = app.session.read_aloud.current()?; Some((app.session.doc.clamp(&u.start), app.session.doc.clamp(&u.end))) } +/// Whether the reading is of the document the window shows: its sentence positions are in it. +fn reads_shown_document(app: &WordApp) -> bool { + app.session.read_aloud.document == Some(app.session.document_id()) +} + /// The player window. pub fn show(app: &mut WordApp, ctx: &egui::Context) { if !app.session.read_aloud.open { diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index c741b85636..c2be6597c2 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -2262,6 +2262,16 @@ fn view(app: &mut WordApp, ui: &mut Ui) { big(ui, app, "newWindow", "New\nWindow", "view.newWindow", json!({}), false); big(ui, app, "arrangeAll", "Arrange\nAll", "view.arrangeAll", json!({}), false); big(ui, app, "split", "Split", "view.split", json!({}), false); + menu_button( + ui, + app, + "switchWindows", + Some("Switch\nWindows"), + "Switch Windows", + true, + "view.switchWindows", + crate::documents::switch_windows_menu, + ); // The Full Screen button carries its name for assistive technology (and for the // regression test that clicks it): big buttons are otherwise drawn unlabeled. Its // enabled state is the command's own, as every other ribbon control reports: the diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 555307d015..0116cfa331 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** trivial (while editing a header or footer, a click in the other one edits it there without scrolling; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Insert Table of Figures dialog: any caption label, #3013; previously trivial: Home › Underline drop-down shows its lines, #2738; previously trivial: the text context menu includes the formatting mini toolbar, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: the scrollbar page tip stays on one line as the page number grows, #3341; previously trivial: File › Home and New: templates and recent documents are buttons named for screen readers, #3176; previously trivial: forms protection greys out the Developer tab's control buttons and Properties; previously trivial: AltGr doesn't open KeyTips, #2388; previously trivial: closing the header or footer puts the caret back where it was in the body, #3331; previously trivial: Format Page Numbers dialog: number format and Continue from previous section / Start at, #1113; previously trivial: inline pictures drag to a dashed insertion cursor and stay inline, #2233, #2234; previously trivial: Save Changes prompt: a text file saved over itself closes, #2376; previously trivial: the equation being edited is framed as in Word: an outline as tall as its line, a grip, an options strip that opens its menu, and its current argument shaded; previously trivial: the table handles show while the caret is in the table, #2428; previously trivial: legacy menu keys, #1697; previously trivial: Shape Fill › Gradient and the Format Shape pane, #1500; previously trivial: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: the text pointer over the page is drawn black or white on Windows, #1746; previously trivial: a click in the header/footer band from the body no longer scrolls to the body text, #2103; previously trivial: File › Recover follows the app theme, #3243; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: dragging the vertical scrollbar names the page at the top of the window, #2551; previously trivial: Add Font imports every selected font file, #1789); previously trivial (File › Close keeps the window, #1712; previously trivial: the Spike: Ctrl+F3 and Ctrl+Shift+F3; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: the tab strip never draws tabs over Comments / Editing / Share, #2026; previously trivial: Columns on selected text, #2024; previously trivial: Different First Page headers reachable from page 1, #2826; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: Read Mode keeps the keyboard, shows whole columns and keeps text clear of the page-turn bar, #1772); previously trivial: font list entries of tall-ascent fonts sit in their row, not cut off, #2570); previously trivial: Split Cells and Convert Text to Table dialogs follow Word's merged-cell limits and Initial column width, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout text fills the window width, #1876; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717)); previously trivial: comment balloons readable in the dark theme, #2045; previously trivial: Recently Used Fonts section at the top of the font list, #2068; previously trivial: web preferences kept between visits, #2132; previously trivial: dialog close control: a Windows-style box at the title bar's right end, the whole box clickable, red on hover, #1885); previously trivial (Modify Table Style formats every table style part, #714); previously trivial: a click on the ribbon chrome hands the keyboard to the page in the same frame, #1093 follow-up; previously trivial: Page Setup vertical alignment, #1320; previously trivial: the app opens on the Home tab every launch, as in Word; previously trivial: no document behind the Start screen, #1129; previously trivial: equation pictures share a few textures, #1002; previously trivial: selection bar in the left margin, #856; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously major: first version; ribbon, dialogs, shortcuts and on-canvas interaction measured from `crates/ui-egui` and the command registry) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** trivial (several open documents in one window: View › Window › Switch Windows lists them and shows one; File › Close shows the next; quitting asks about each one with unsaved changes; previously trivial: while editing a header or footer, a click in the other one edits it there without scrolling; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Insert Table of Figures dialog: any caption label, #3013; previously trivial: Home › Underline drop-down shows its lines, #2738; previously trivial: the text context menu includes the formatting mini toolbar, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: the scrollbar page tip stays on one line as the page number grows, #3341; previously trivial: File › Home and New: templates and recent documents are buttons named for screen readers, #3176; previously trivial: forms protection greys out the Developer tab's control buttons and Properties; previously trivial: AltGr doesn't open KeyTips, #2388; previously trivial: closing the header or footer puts the caret back where it was in the body, #3331; previously trivial: Format Page Numbers dialog: number format and Continue from previous section / Start at, #1113; previously trivial: inline pictures drag to a dashed insertion cursor and stay inline, #2233, #2234; previously trivial: Save Changes prompt: a text file saved over itself closes, #2376; previously trivial: the equation being edited is framed as in Word: an outline as tall as its line, a grip, an options strip that opens its menu, and its current argument shaded; previously trivial: the table handles show while the caret is in the table, #2428; previously trivial: legacy menu keys, #1697; previously trivial: Shape Fill › Gradient and the Format Shape pane, #1500; previously trivial: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: the text pointer over the page is drawn black or white on Windows, #1746; previously trivial: a click in the header/footer band from the body no longer scrolls to the body text, #2103; previously trivial: File › Recover follows the app theme, #3243; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: dragging the vertical scrollbar names the page at the top of the window, #2551; previously trivial: Add Font imports every selected font file, #1789); previously trivial (File › Close keeps the window, #1712; previously trivial: the Spike: Ctrl+F3 and Ctrl+Shift+F3; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: the tab strip never draws tabs over Comments / Editing / Share, #2026; previously trivial: Columns on selected text, #2024; previously trivial: Different First Page headers reachable from page 1, #2826; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: Read Mode keeps the keyboard, shows whole columns and keeps text clear of the page-turn bar, #1772); previously trivial: font list entries of tall-ascent fonts sit in their row, not cut off, #2570); previously trivial: Split Cells and Convert Text to Table dialogs follow Word's merged-cell limits and Initial column width, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout text fills the window width, #1876; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717)); previously trivial: comment balloons readable in the dark theme, #2045; previously trivial: Recently Used Fonts section at the top of the font list, #2068; previously trivial: web preferences kept between visits, #2132; previously trivial: dialog close control: a Windows-style box at the title bar's right end, the whole box clickable, red on hover, #1885); previously trivial (Modify Table Style formats every table style part, #714); previously trivial: a click on the ribbon chrome hands the keyboard to the page in the same frame, #1093 follow-up; previously trivial: Page Setup vertical alignment, #1320; previously trivial: the app opens on the Home tab every launch, as in Word; previously trivial: no document behind the Start screen, #1129; previously trivial: equation pictures share a few textures, #1002; previously trivial: selection bar in the left margin, #856; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously major: first version; ribbon, dialogs, shortcuts and on-canvas interaction measured from `crates/ui-egui` and the command registry) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 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). @@ -35,7 +35,7 @@ Document title opens Rename Document; the account badge opens Options › Genera | Task panes (Navigation, Styles, Comments, Reviewing, Format Picture/Shape, Thesaurus, Accessibility, Clipboard, Selection) | Navigation (long headings wrap onto further lines within the pane instead of expanding it, #759, #3015), Styles, Comments (balloons and cards read in both themes, #2045), Thesaurus, Restrict Editing, Accessibility (grouped findings, jump-to, fixes) and Alt Text (#386), Read Aloud, Zotero; Style Inspector (#236); Clipboard pane (#264) open | all | 70% | 4–6 | | Context menus (right-click) | text, spelling suggestions, fields (Update Field, Edit Field…, Toggle Field Codes, #339; Update Field on a table of contents entry updates the table, #1197, and after a save and reopen, #2182); right-click selects a picture, shape or text box and offers Insert Caption… (#1230) and Wrap Text (styles, Move with Text / Fix Position on Page, #513); tables, the items following the selection as in Word (Insert, Delete › Cells, Columns, Rows or Table whatever is selected (#1198), Merge and Split Cells, Distribute, AutoFit, Text Direction, Insert Caption…, Table Properties…; no Border Styles yet) | rich per context | 65% | 2–4 | | Task panes (Navigation, Styles, Comments, Reviewing, Format Picture/Shape, Thesaurus, Accessibility, Clipboard, Selection) | Navigation (long headings wrap onto further lines within the pane instead of expanding it, #759, #3015), Styles, Comments (typed comment and reply text stays with its document, is written before Save, Export, Print, Close, New or Open, Escape discards it, and the fields are read-only where comments can't change, #1348), Thesaurus, Restrict Editing, Accessibility (grouped findings, jump-to, fixes) and Alt Text (#386), Read Aloud, Zotero; Style Inspector (#236); Clipboard pane (#264) open | all | 70% | 4–6 | -| Backstage (Home, New, Open, Info, Save As, Print, Share, Export, Options) | Home and New separate (#254), Info properties kept (#268), Options in Word's panes with working settings (#485), Print page with preview, page range and copies (#641); launched with nothing to open, the app starts on Home (the Start screen: Blank document, templates, Recent; no document is open until one is picked or opened, so Esc or Back shows an empty window with the ribbon greyed out but for Help, #1129), with File › Options › General › Start-up to turn it off (#766); Info › Inspect Document finds and removes hidden text by its effective formatting (direct, character or paragraph style) in every story: body, headers, footers, notes, comments and text boxes, and lists what it couldn't reach (#1344); File › Close (Mod+W, Mod+F4) closes the document and keeps the window, showing no page, as Word does; the window's close button quits (#1712); each side-menu item has an icon, drawn in code (#1714); New lists personal templates (`.dotx`/`.dotm`) from the user templates folder under Personal, a subfolder per category, and its search covers them; a click makes an untitled document attached to the template; Save As › Save as Word Template starts in that folder; the folder is set in Options › Advanced › File locations (#2852; no thumbnails yet, no workgroup templates) | | 68% | 4–6 | +| Backstage (Home, New, Open, Info, Save As, Print, Share, Export, Options) | Home and New separate (#254), Info properties kept (#268), Options in Word's panes with working settings (#485), Print page with preview, page range and copies (#641); launched with nothing to open, the app starts on Home (the Start screen: Blank document, templates, Recent; no document is open until one is picked or opened, so Esc or Back shows an empty window with the ribbon greyed out but for Help, #1129), with File › Options › General › Start-up to turn it off (#766); Info › Inspect Document finds and removes hidden text by its effective formatting (direct, character or paragraph style) in every story: body, headers, footers, notes, comments and text boxes, and lists what it couldn't reach (#1344); File › New, Open, the mailings and personal templates open their document beside the open ones, so they ask nothing; File › Close (Mod+W, Mod+F4) closes the shown document and keeps the window, showing the next open document, or no page after the last one, as Word does; the window's close button asks about each document with unsaved changes in turn, showing it, and Cancel stops the quit (#1712); each side-menu item has an icon, drawn in code (#1714); New lists personal templates (`.dotx`/`.dotm`) from the user templates folder under Personal, a subfolder per category, and its search covers them; a click makes an untitled document attached to the template; Save As › Save as Word Template starts in that folder; the folder is set in Options › Advanced › File locations (#2852; no thumbnails yet, no workgroup templates) | | 68% | 4–6 | | Rulers: indents, tabs, margins, table columns | indents draggable (one undo, #62); tab stops: corner selector (Left/Center/Right/Decimal/Bar), click to set, drag to move, drag off to clear, per-kind markers, Alt skips the snap, one undo step per gesture, double-click opens the Tabs dialog (#320); table column and row markers drag their borders (one undo) and in a cell the indents are measured from it (#700); the numbers thin out as the page shrinks on screen (every 1, 2, 4… units, never closer than 30 px, the small ticks never closer than 4 px, the text keeps its size), give way to an indent, tab or column marker on them, and the margins get ticks only; both rulers span the caret's page at its own scale in every layout (one column, One Page, Multiple Pages) and follow the caret to another page (#1878) | ✅ | 92% | 1 | | On-canvas objects: select, drag, resize handles, nudge | ✅ tested (#228); a picture or shape behind text can be dragged across the overlapping text (body and header/footer), whether or not it was selected first, while a click without movement still edits that text | ✅ | 75% | — | | On-canvas objects: select, drag, Ctrl+drag to copy, resize handles, nudge | ✅ tested (#228, #1014); an In Line with Text object drags within the text as in Word: a dashed insertion cursor follows the pointer through the text and the drop (or Ctrl+drop copy) lands there, still inline (#2233, #2234) | ✅ | 75% | — | @@ -76,6 +76,7 @@ Footnote and endnote editing keeps body and note text at normal opacity, on ligh | Date | Change | Summary | |---|---|---| +| 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 | | 2026-10-22 | trivial | While editing a header, a single click in the footer (or the reverse, or another page's own header) edits that one with the caret where clicked; the click was placed in the header being edited, and the view scrolled back to it, so a double-click on the footer rarely landed | | 2026-10-18 | trivial | Explicit New/Open is not interrupted by startup recovery discovery; older copies stay available and Recover follows the interface theme. | | 2026-10-19 | trivial | Insert Table of Figures opens a Table of Figures dialog: caption label (Figure, Table, Equation, the user's own and those the document's captions use), Include label and number, page numbers, right margin and tab leader, with a preview; the ribbon button, KeyTip and command search reach it (#3013) | From a8e39fec5332d97aad320d064ed7a54e06762010 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:06:45 +0000 Subject: [PATCH 010/199] Engine: find_open resolves paths through io, inside the readable roots --- crates/engine/src/io.rs | 14 ++++++++++++++ crates/engine/src/session/documents.rs | 17 ++--------------- 2 files changed, 16 insertions(+), 15 deletions(-) diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index 0c25931968..f1829db32d 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -111,6 +111,20 @@ pub fn is_empty_file(_path: &std::path::Path) -> bool { false } +/// The canonical form of `path` (symbolic links and `.`/`..` resolved), for telling whether two +/// spellings name one file; `None` when it can't be resolved or is outside the readable roots. +#[cfg(not(target_arch = "wasm32"))] +pub(crate) fn canonical(path: &std::path::Path) -> Option { + crate::file_access::check_read(&path.to_string_lossy()).ok()?; + std::fs::canonicalize(path).ok() +} + +/// [`canonical`]: on the web files come through the browser, never by path. +#[cfg(target_arch = "wasm32")] +pub(crate) fn canonical(_path: &std::path::Path) -> Option { + None +} + /// Can files with this extension be saved with a password (Word's packages)? pub fn can_encrypt(name: &str) -> bool { is_word_package(&ext_of(name)) diff --git a/crates/engine/src/session/documents.rs b/crates/engine/src/session/documents.rs index d27c791d68..27625a151f 100644 --- a/crates/engine/src/session/documents.rs +++ b/crates/engine/src/session/documents.rs @@ -507,21 +507,8 @@ impl Session { /// The open document saved at `path`, if any: the same file reached by another spelling /// (`./a.docx`, a symbolic link) counts on desktop. pub fn find_open(&self, path: &FsPath) -> Option { - #[cfg(not(target_arch = "wasm32"))] - let wanted = std::fs::canonicalize(path).ok(); - #[cfg(target_arch = "wasm32")] - let wanted: Option = None; - let same = |p: &FsPath| { - if p == path { - return true; - } - #[cfg(not(target_arch = "wasm32"))] - if let Some(w) = &wanted { - return std::fs::canonicalize(p).ok().as_ref() == Some(w); - } - let _ = &wanted; - false - }; + let wanted = crate::io::canonical(path); + let same = |p: &FsPath| p == path || wanted.is_some() && crate::io::canonical(p) == wanted; self.order.iter().copied().find(|&id| { if id == self.document_id { self.path.as_deref().is_some_and(same) From 4480fd50d9dc09c0eb816066588d67d8eb3cc9c5 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:26:38 +0000 Subject: [PATCH 011/199] W2: Recovery: every open document keeps its own copy One recovery worker and store serve every open document. Each document keeps its slot (record id, revision, timer) and is ticked in place, parked or active. Closing a document deletes its copy, Don't Save when quitting does too, and a clean exit covers every open document. File > Recover lists only copies no open document keeps; Restore opens a copy beside the open documents or in place of an untouched blank one. --- ROADMAP.md | 4 +- crates/engine/src/recovery.rs | 157 +++++-- crates/engine/src/recovery/desktop.rs | 593 ++++++++++++++++--------- crates/engine/src/session.rs | 17 +- crates/engine/src/session/documents.rs | 67 ++- crates/ui-egui/src/backstage.rs | 16 +- crates/ui-egui/src/lib.rs | 19 +- docs/recovery.md | 45 +- 8 files changed, 630 insertions(+), 288 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index cd54d1c1a6..8f7ccadecc 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~15 points (60% → 75% ready for real work) and ~150–230 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** trivial (progress log: several open documents in the desktop window; previously trivial: progress log: several open documents in one session; previously trivial: progress log: a click in the footer while editing the header moves there; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Slovenian interface catalog, 2,614 entries; previously trivial: Insert Table of Figures dialog for any caption label, #3013; previously trivial: progress log: Home › Underline drop-down, #2738; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: progress log: the scrollbar page tip stays on one line, #3341; previously trivial: progress log: forms protection greys out the control buttons; previously trivial: progress log: a document with many content controls saves in the time the controls take, #13; previously trivial: progress log: Word signature lines kept on save; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: progress log: closing the header returns to the body selection, #3331; previously trivial: progress log: saving to a NAS share mounted by GVFS, #3003; previously trivial: progress log: a picture dragged near the bottom of a page lands there, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: progress log: inline pictures drag to an insertion cursor and stay inline, #2233, #2234; previously trivial: progress log: a text file saved over itself closes without asking, #2376; previously trivial: progress log: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: fixed font inputs in four layout controls; previously trivial: typing no longer starts render threads on every repaint, #3027; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: progress log: banded rows shade as in Word, #3415; previously trivial: progress log: the table handles show while the caret is in the table, #2428; previously trivial: progress log: a click below a floating table reaches the text after it, #2038; previously trivial: progress log: Delete on selected table cells leaves one empty paragraph in each, #2802; previously trivial: progress log: lam-alef in exported PDFs extracts whole with fonts that draw it as two glyphs, #2310; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously trivial: progress log: a text box that keeps the keyboard no longer resets the input method every frame, #2986; previously trivial: progress log: recovery copies and .json files store pictures as base64, #586 follow-up; previously trivial: progress log: legacy menu keys, #1697; previously trivial: progress log: shape gradients and transparency, #1500; previously trivial: progress log: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: progress log: text above a float's anchor wraps around it, #2048; previously trivial: progress log: File › Recover follows the app theme, #3243; previously trivial: progress log: formatting toolbar with the text context menu, #2959; previously trivial: progress log: caret inside ligatures, #1974; 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: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: progress log: a header-cell picture watermark sits behind the text, #1775; previously trivial: symbols keep their font when the text font changes, #2036; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: Add Font imports every selected font file, #1789); previously trivial (RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: Read Aloud speaks each sentence in its own language, #1843; previously trivial: hint-reliant CJK outlines; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: progress log: Turkish case mapping in AutoCorrect, #1923; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: progress log: Hebrew field results and smart quotes, #1670; previously trivial: Backspace removes a selected table, rows or columns, #1844; previously trivial: progress log: mixed Hebrew/Latin text keeps its order in Word, #1543; previously trivial: progress log: web typing in non-Latin text no longer repeats, #1480; previously trivial: progress log: font family caches bounded and math font lookups follow font changes, #1354; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683; previously trivial: progress log: `w:eastAsianLayout` kept on save; previously trivial: table style editor formats every part, #714); previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** trivial (progress log: every open document keeps its own recovery copy; previously trivial: progress log: several open documents in the desktop window; previously trivial: progress log: several open documents in one session; previously trivial: progress log: a click in the footer while editing the header moves there; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Slovenian interface catalog, 2,614 entries; previously trivial: Insert Table of Figures dialog for any caption label, #3013; previously trivial: progress log: Home › Underline drop-down, #2738; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: progress log: the scrollbar page tip stays on one line, #3341; previously trivial: progress log: forms protection greys out the control buttons; previously trivial: progress log: a document with many content controls saves in the time the controls take, #13; previously trivial: progress log: Word signature lines kept on save; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: progress log: closing the header returns to the body selection, #3331; previously trivial: progress log: saving to a NAS share mounted by GVFS, #3003; previously trivial: progress log: a picture dragged near the bottom of a page lands there, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: progress log: inline pictures drag to an insertion cursor and stay inline, #2233, #2234; previously trivial: progress log: a text file saved over itself closes without asking, #2376; previously trivial: progress log: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: fixed font inputs in four layout controls; previously trivial: typing no longer starts render threads on every repaint, #3027; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: progress log: banded rows shade as in Word, #3415; previously trivial: progress log: the table handles show while the caret is in the table, #2428; previously trivial: progress log: a click below a floating table reaches the text after it, #2038; previously trivial: progress log: Delete on selected table cells leaves one empty paragraph in each, #2802; previously trivial: progress log: lam-alef in exported PDFs extracts whole with fonts that draw it as two glyphs, #2310; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously trivial: progress log: a text box that keeps the keyboard no longer resets the input method every frame, #2986; previously trivial: progress log: recovery copies and .json files store pictures as base64, #586 follow-up; previously trivial: progress log: legacy menu keys, #1697; previously trivial: progress log: shape gradients and transparency, #1500; previously trivial: progress log: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: progress log: text above a float's anchor wraps around it, #2048; previously trivial: progress log: File › Recover follows the app theme, #3243; previously trivial: progress log: formatting toolbar with the text context menu, #2959; previously trivial: progress log: caret inside ligatures, #1974; 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: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: progress log: a header-cell picture watermark sits behind the text, #1775; previously trivial: symbols keep their font when the text font changes, #2036; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: Add Font imports every selected font file, #1789); previously trivial (RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: Read Aloud speaks each sentence in its own language, #1843; previously trivial: hint-reliant CJK outlines; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: progress log: Turkish case mapping in AutoCorrect, #1923; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: progress log: Hebrew field results and smart quotes, #1670; previously trivial: Backspace removes a selected table, rows or columns, #1844; previously trivial: progress log: mixed Hebrew/Latin text keeps its order in Word, #1543; previously trivial: progress log: web typing in non-Latin text no longer repeats, #1480; previously trivial: progress log: font family caches bounded and math font lookups follow font changes, #1354; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683; previously trivial: progress log: `w:eastAsianLayout` kept on save; previously trivial: table style editor formats every part, #714); previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** trivial (English dialects in proofing, #1281; previously trivial: triple borders draw three lines, #1740; previously trivial: progress log: double-clicking a later page's footer no longer scrolls up, #2103; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: progress log: pasted blockquotes are indented paragraphs, #1270; previously trivial: progress log: print-area strings translated in every catalog; previously trivial: progress log: comments readable in the dark theme, #2045; previously trivial: progress log: ODT sections, #1379; previously trivial: progress log: table deletes, Change Case and Unlink Fields respect Track Changes and locked content controls, #1329; previously trivial: progress log: rounded rectangle corners, #1476; previously trivial: progress log: Save As type list, #1399; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below. @@ -136,6 +136,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm ## Progress log +- 2026-10-22: Recovery covers every open document. Each dirty document keeps its own copy, written on the same ten-second cadence whether it is shown or not; closing a document without saving deletes its copy, as saving does, and a clean exit deletes the copies of every document saved or closed since its last copy. File › Recover lists only copies no open document keeps, and Restore opens a copy as another document (or in place of an untouched blank one), so it never replaces work. After a crash with several unsaved documents, the next start lists all of them. - 2026-10-22: The desktop window works with several open documents (one window per document comes next). View › Window › Switch Windows lists them and shows the one chosen, back where it was left; New, Open and the mailings open beside unsaved work instead of asking about it; File › Close shows the next document; quitting asks about every document with unsaved changes, one at a time, and Cancel keeps the app open. - 2026-10-22: Several open documents in one session (engine part of one window per document). File › New opens a new document beside the open ones; File › Open opens beside them too, replaces a blank document nobody has used, and shows a file that is already open instead of opening it twice. Each document keeps its own text, selection, undo history, find state, view and recovery copy. View › Window › Switch Windows (`view.switchWindows`) lists the open documents and switches between them; `file.closeDocument` closes one where no window asks about unsaved changes (agents, headless sessions). Envelopes, Labels, Finish & Merge and New from Template open their result as a new document and leave the main document open as it was. Document ids are unique in the process. The desktop app still shows one document per window; the windows come next. - 2026-10-22: Headers and footers: while editing the header, a single click in the footer edits the footer with the caret where clicked (and the reverse, and another page's own header or footer). The click went to the header being edited and the view scrolled back up to it, so a double-click on the footer rarely landed unless both were on screen. @@ -574,6 +575,7 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps | Date | Change | Summary | |---|---|---| +| 2026-10-22 | trivial | Progress log: every open document keeps its own recovery copy | | 2026-10-22 | trivial | Progress log: several open documents in the desktop window | | 2026-10-22 | trivial | Progress log: several open documents in one session | | 2026-10-22 | trivial | Progress log: a click in the footer while editing the header moves there | diff --git a/crates/engine/src/recovery.rs b/crates/engine/src/recovery.rs index 3306a9d619..ce210f5acc 100644 --- a/crates/engine/src/recovery.rs +++ b/crates/engine/src/recovery.rs @@ -14,7 +14,9 @@ pub struct RecoveryInfo { #[cfg(not(target_arch = "wasm32"))] mod desktop; #[cfg(not(target_arch = "wasm32"))] -pub(crate) use desktop::Recovery; +use desktop::{Applied, Request}; +#[cfg(not(target_arch = "wasm32"))] +pub(crate) use desktop::{DocRecovery, Recovery, Slot}; impl Session { /// The desktop host supplies the profile folder and a thread-safe wakeup. @@ -24,10 +26,13 @@ impl Session { self.recovery = Some(Recovery::start(root, wake)?); Ok(()) } + /// Copies left by an earlier run (or another window's documents that have closed), newest + /// first. The copies this session's open documents keep are not listed: they are not lost. pub fn recovery_documents(&self) -> Vec { #[cfg(not(target_arch = "wasm32"))] if let Some(r) = &self.recovery { - return r.documents.clone(); + let held = |id: &str| self.recovery_slots().any(|(_, _, s)| s.id.as_deref() == Some(id)) || r.closing().any(|c| c == id); + return r.documents.iter().filter(|d| !held(&d.id)).cloned().collect(); } Vec::new() } @@ -52,23 +57,19 @@ impl Session { } None } - /// A delayed restore can't replace edits made while it was loading. Restores have no path. + /// Called every frame: takes in the worker's answer, opens a restored copy, and queues the + /// next job. Every open document's copy is kept, the parked ones' too, without making them + /// active: a frame compares each document's revision and timer, and clones a document only + /// when its copy is due. pub fn recovery_tick(&mut self) { #[cfg(not(target_arch = "wasm32"))] { let Some(mut r) = self.recovery.take() else { return }; - if let Some((snapshot, expected)) = r.poll() { - if expected == (self.document_id(), self.rev()) && self.password.is_none() { - let id = snapshot.info.id; - self.set_document(snapshot.document); - self.path = None; - self.dirty = true; - r.adopt(id, self.document_id(), self.rev()); - } else { - r.error = Some("Recovery cancelled: the document changed while the copy was loading.".into()); - } + if let Some(e) = r.receive(false) { + self.apply_recovery(&mut r, e); } - if let Err(e) = r.tick(self, std::time::Instant::now()) { + self.open_held_restore(&mut r); + if let Err(e) = r.tick(self.recovery_docs(), std::time::Instant::now()) { r.error = Some(e); } if let Some(e) = &r.error { @@ -77,46 +78,119 @@ impl Session { self.recovery = Some(r); } } + #[cfg(not(target_arch = "wasm32"))] + fn apply_recovery(&mut self, r: &mut Recovery, e: desktop::Event) { + match r.apply(e) { + // Opened by the next tick, never in the middle of a command. + Applied::Loaded(snapshot, request) => r.held = Some((snapshot, request)), + applied => { + for d in self.recovery_docs() { + if let Applied::Failed { document, .. } = &applied + && *document != d.id + { + continue; + } + d.slot.apply(&applied); + } + } + } + } + /// A restored copy opens as another document, or replaces the untouched blank document that + /// asked for it once that document is active, so no work is ever replaced. The restored + /// document keeps its copy in the record it came from. + #[cfg(not(target_arch = "wasm32"))] + fn open_held_restore(&mut self, r: &mut Recovery) { + let Some((_, request)) = &r.held else { return }; + let request = *request; + if request.replace && self.is_open(request.document) && request.document != self.document_id() { + return; + } + let Some((snapshot, _)) = r.held.take() else { return }; + let id = snapshot.info.id.clone(); + if !request.replace { + self.add_document(snapshot.document); + } else if request.document == self.document_id() && request.revision == self.rev() && self.password.is_none() { + self.set_document(snapshot.document); + self.path = None; + } else { + r.error = Some("Recovery cancelled: the document changed while the copy was loading.".into()); + return; + } + self.dirty = true; + self.recovery_slot.adopt(id, self.rev()); + } pub fn recovery_wait(&self) -> Option { #[cfg(not(target_arch = "wasm32"))] - if let Some(r) = &self.recovery - && self.dirty - && self.password.is_none() - && !r.busy - { - return r.wait(self.document_id(), self.rev()); + if let Some(r) = &self.recovery { + return r.wait(self.recovery_slots()); } None } - /// Waits for recovery to finish anything already under way, discarding this session's own - /// copy first if it no longer needs one (the document was saved, say). Desktop hosts call - /// this right before the process exits, the same as they wait out pending print jobs: - /// otherwise a save just before close can race the process exiting before a now-redundant - /// recovery copy actually gets removed, so the next launch wrongly offers to recover a - /// document that was in fact saved cleanly. + /// Waits for recovery to finish anything already under way, then deletes the copies no + /// open document needs: closed documents', and those of documents saved or protected since + /// their last copy. Dirty documents keep theirs. Desktop hosts call this right before the + /// process exits, the same as they wait out pending print jobs: otherwise a save just before + /// close can race the process exiting before a now-redundant recovery copy actually gets + /// removed, so the next launch wrongly offers to recover a document that was in fact saved + /// cleanly. pub fn finish_recovery(&mut self) { #[cfg(not(target_arch = "wasm32"))] if let Some(mut r) = self.recovery.take() { - if let Err(e) = r.finish(self) { + if let Some(e) = r.receive(true) { + self.apply_recovery(&mut r, e); + } + let mut discards: Vec<(String, Option)> = r.take_closed().into_iter().map(|id| (id, None)).collect(); + discards.extend(self.recovery_docs().filter(|d| !d.wants_copy).filter_map(|d| d.slot.id.clone().map(|id| (id, Some(d.id))))); + for (id, document) in discards { + match r.discard_now(&id, document) { + Ok(e) => self.apply_recovery(&mut r, e), + Err(e) => r.error = Some(e), + } + } + if let Some(e) = &r.error { log::warn!("recovery: {e}"); } self.recovery = Some(r); } } + /// The user discarded the active document's unsaved changes without closing it (Don't Save + /// when quitting): its recovery copy goes too, and another is made only after a further edit. + pub fn discard_recovery_copy(&mut self) { + #[cfg(not(target_arch = "wasm32"))] + if let Some(r) = &mut self.recovery + && let Some(id) = self.recovery_slot.id.take() + { + r.close(id); + self.recovery_slot.revision = Some(self.rev()); + } + } /// Only an explicit protect/save action waits for the worker and removes plaintext copies. /// A failure prevents applying the password; typing never waits. pub(crate) fn clear_recovery_before_password(&mut self) -> Result<(), CmdError> { #[cfg(not(target_arch = "wasm32"))] - { - let document = self.document_id(); - if let Some(r) = &mut self.recovery - && r.document_is(document) - { - r.purge().map_err(CmdError::Failed)?; - } + if let Some(mut r) = self.recovery.take() { + let result = self.purge_recovery(&mut r); + self.recovery = Some(r); + result.map_err(CmdError::Failed)?; } Ok(()) } + /// Waits out the job under way (it may be this document's copy being written), then deletes + /// the active document's copy. + #[cfg(not(target_arch = "wasm32"))] + fn purge_recovery(&mut self, r: &mut Recovery) -> Result<(), String> { + if let Some(e) = r.receive(true) { + self.apply_recovery(r, e); + } + let Some(id) = self.recovery_slot.id.clone() else { return Ok(()) }; + let e = r.discard_now(&id, Some(self.document_id()))?; + self.apply_recovery(r, e); + if self.recovery_slot.id.is_some() { + return Err(r.error.clone().unwrap_or_else(|| "the recovery copy could not be removed".into())); + } + self.recovery_slot.revision = None; + Ok(()) + } pub(crate) fn recover(&mut self, v: &Value) -> CmdResult { let restore = v.get("restore"); let discard = v.get("discard"); @@ -126,14 +200,19 @@ impl Session { if restore.is_some() || discard.is_some() || v.get("refresh").is_some() { #[cfg(not(target_arch = "wasm32"))] { - let expected = (self.document_id(), self.rev()); + let request = + Request { document: self.document_id(), revision: self.rev(), replace: self.is_untouched_blank() && self.password.is_none() }; + // Only listed copies: never one an open document keeps. + let listed = self.recovery_documents(); + let known = |id: Option<&str>, what: &str| -> Result { + let id = id.ok_or_else(|| CmdError::Params(format!("{what} must be a recovery id")))?; + if listed.iter().any(|d| d.id == id) { Ok(id.to_string()) } else { Err(CmdError::Failed("no such recovery copy".into())) } + }; let r = self.recovery.as_mut().ok_or_else(|| CmdError::Disabled("local recovery requires the desktop host".into()))?; if let Some(id) = restore { - r.restore(id.as_str().ok_or_else(|| CmdError::Params("restore must be a recovery id".into()))?, expected) - .map_err(CmdError::Failed)?; + r.restore(&known(id.as_str(), "restore")?, request).map_err(CmdError::Failed)?; } else if let Some(id) = discard { - r.discard(id.as_str().ok_or_else(|| CmdError::Params("discard must be a recovery id".into()))?, expected) - .map_err(CmdError::Failed)?; + r.discard(&known(id.as_str(), "discard")?).map_err(CmdError::Failed)?; } else if v.get("refresh").and_then(Value::as_bool) == Some(true) { r.refresh().map_err(CmdError::Failed)?; } else { diff --git a/crates/engine/src/recovery/desktop.rs b/crates/engine/src/recovery/desktop.rs index 92bbf7eee2..4570ddefb3 100644 --- a/crates/engine/src/recovery/desktop.rs +++ b/crates/engine/src/recovery/desktop.rs @@ -1,5 +1,4 @@ use super::RecoveryInfo; -use crate::Session; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; use std::fs::{self, File, OpenOptions}; @@ -373,98 +372,175 @@ impl Write for Limited { } } enum Job { - Save(Box), + /// Write `document`'s copy. + Save(Box, u64), Scan, - Load(String, (u64, u64)), - Discard(String), + Load(String, Request), + /// Delete a record: an open document's copy it no longer needs, a closed document's, or one + /// from an earlier run that the user discarded (`None`). + Discard(String, Option), } enum Outcome { Done, Discarded(String), - Loaded(Box, (u64, u64)), + Loaded(Box, Request), } -struct Event { +pub(crate) struct Event { documents: Vec, outcome: Result, /// The failure would repeat for the same document; see [`SaveError`]. permanent: bool, + /// The open document a failed save or discard was for. + document: Option, + /// The record a failed discard was for. + discard: Option, } +/// Who asked for a restore, and whether the copy replaces that document (an untouched blank +/// one) or opens as another document. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct Request { + pub document: u64, + pub revision: u64, + pub replace: bool, +} +/// One open document's recovery state: the record its copy is kept in, the revision that copy +/// holds, and when the next copy may be written. Each open document has its own, parked with it. +#[derive(Clone, Debug)] +pub(crate) struct Slot { + pub id: Option, + pub revision: Option, + pub due: Instant, + /// A failed discard of this document's copy is tried again from then on. + pub retry: Option, +} +impl Default for Slot { + fn default() -> Self { + Self { id: None, revision: None, due: Instant::now() + INTERVAL, retry: None } + } +} +impl Slot { + /// The document now keeps its copy in record `id`, which holds `revision`. + pub fn adopt(&mut self, id: String, revision: u64) { + self.id = Some(id); + self.revision = Some(revision); + self.due = Instant::now() + INTERVAL; + self.retry = None; + } +} +/// A worker report, for the session to apply to its documents. +pub(crate) enum Applied { + Nothing, + /// Record `id` is gone: no document keeps its copy there any more. + Discarded(String), + /// A restored copy, to open. + Loaded(Box, Request), + /// Writing or discarding `document`'s copy failed. + Failed { + document: u64, + discard: bool, + permanent: bool, + }, +} +impl Slot { + /// Take in a report about this document's copy. + pub fn apply(&mut self, applied: &Applied) { + match applied { + Applied::Discarded(id) if self.id.as_deref() == Some(id) => { + self.id = None; + self.retry = None; + } + Applied::Failed { discard, permanent, .. } => { + let now = Instant::now(); + if *discard { + self.retry = Some(now + INTERVAL); + } else if !permanent { + // A transient failure (a lock, an I/O error) is retried at the next + // interval; one the snapshot itself causes waits for the next edit. + self.revision = None; + } + self.due = now + INTERVAL; + } + _ => {} + } + } +} +/// What [`Recovery::tick`] reads from an open document, and the document's [`Slot`]. +pub(crate) struct DocRecovery<'a> { + pub id: u64, + /// Dirty and without a password: the document should have a current copy. + pub wants_copy: bool, + pub rev: u64, + pub doc: &'a Document, + pub path: Option<&'a Path>, + pub slot: &'a mut Slot, +} +/// The process's recovery worker: one thread and one store for every open document, so records +/// are locked once per process and the list of copies is the same whichever document is shown. pub(crate) struct Recovery { tx: mpsc::SyncSender, rx: mpsc::Receiver, + /// Every record the store found or wrote, this process's own included. pub documents: Vec, pub ready: bool, pub busy: bool, pub error: Option, - document: Option, - id: Option, - revision: Option, - due: Instant, alive: bool, - /// The profile folder and wakeup, for [`Recovery::sibling`]. - root: PathBuf, - wake: Arc, + /// Records of closed documents waiting to be deleted, and whether that is queued. + closed: Vec<(String, bool)>, + /// A loaded copy that replaces its (untouched blank) document once that document is active. + pub held: Option<(Box, Request)>, } impl Recovery { - /// Recovery for another open document: its own worker on the same profile folder, keeping - /// that document's copy as this one keeps its document's. - pub fn sibling(&self) -> Option { - Self::start(self.root.clone(), self.wake.clone()).map_err(|e| log::warn!("recovery: {e}")).ok() - } pub fn start(root: PathBuf, wake: Arc) -> Result { - let (worker_root, worker_wake) = (root.clone(), wake.clone()); let (tx, jobs) = mpsc::sync_channel(1); let (events, rx) = mpsc::channel(); std::thread::Builder::new() .name("wordcraft-recovery".into()) .spawn(move || { - let (root, wake) = (worker_root, worker_wake); let mut store = match Store::new(root) { Ok(s) => s, Err(e) => { - let _ = events.send(Event { documents: Vec::new(), outcome: Err(e), permanent: false }); + let _ = events.send(Event { documents: Vec::new(), outcome: Err(e), permanent: false, document: None, discard: None }); wake(); return; } }; let initial = store.scan().map(|_| Outcome::Done); - if events.send(Event { documents: store.inventory(), outcome: initial, permanent: false }).is_err() { + if events.send(Event { documents: store.inventory(), outcome: initial, permanent: false, document: None, discard: None }).is_err() { return; } wake(); while let Ok(job) = jobs.recv() { let mut permanent = false; + let (mut document, mut discard) = (None, None); let result = match job { - Job::Save(s) => store.save(*s).map(|_| Outcome::Done).map_err(|e| { - permanent = e.permanent; - e.message - }), + Job::Save(s, d) => { + document = Some(d); + store.save(*s).map(|_| Outcome::Done).map_err(|e| { + permanent = e.permanent; + e.message + }) + } Job::Scan => store.scan().map(|_| Outcome::Done), - Job::Load(id, expected) => store.load(&id).map(|s| Outcome::Loaded(Box::new(s), expected)), - Job::Discard(id) => store.discard(&id).map(|_| Outcome::Discarded(id)), + Job::Load(id, request) => store.load(&id).map(|s| Outcome::Loaded(Box::new(s), request)), + Job::Discard(id, d) => { + document = d; + let r = store.discard(&id).map(|_| Outcome::Discarded(id.clone())); + discard = Some(id); + r + } }; - if events.send(Event { documents: store.inventory(), outcome: result, permanent }).is_err() { + if result.is_ok() { + (document, discard) = (None, None); + } + if events.send(Event { documents: store.inventory(), outcome: result, permanent, document, discard }).is_err() { break; } wake(); } }) .map_err(|e| e.to_string())?; - Ok(Self { - tx, - rx, - documents: Vec::new(), - ready: false, - busy: true, - error: None, - document: None, - id: None, - revision: None, - due: Instant::now() + INTERVAL, - alive: true, - root, - wake, - }) + Ok(Self { tx, rx, documents: Vec::new(), ready: false, busy: true, error: None, alive: true, closed: Vec::new(), held: None }) } fn queue(&mut self, job: Job) -> Result<(), String> { if !self.alive { @@ -478,33 +554,23 @@ impl Recovery { self.error = None; Ok(()) } - pub fn poll(&mut self) -> Option<(Snapshot, (u64, u64))> { - match self.rx.try_recv() { + /// The worker's answer to the job under way: waits for it when `block`, else only takes one + /// that has arrived. + pub fn receive(&mut self, block: bool) -> Option { + let e = if block { + if !self.busy { + return None; + } + self.rx.recv().map_err(|_| mpsc::TryRecvError::Disconnected) + } else { + self.rx.try_recv() + }; + match e { Ok(e) => { self.busy = false; self.ready = true; - self.documents = e.documents; - let event_permanent = e.permanent; - match e.outcome { - Ok(Outcome::Done) => None, - Ok(Outcome::Discarded(id)) => { - if self.id.as_deref() == Some(&id) { - self.id = None; - } - None - } - Ok(Outcome::Loaded(s, expected)) => Some((*s, expected)), - Err(e) => { - self.error = Some(e); - // A transient failure (a lock, an I/O error) is retried at the next - // interval; one the snapshot itself causes waits for the next edit. - if !event_permanent { - self.revision = None; - } - self.due = Instant::now() + INTERVAL; - None - } - } + self.documents.clone_from(&e.documents); + Some(e) } Err(mpsc::TryRecvError::Empty) => None, Err(mpsc::TryRecvError::Disconnected) => { @@ -517,140 +583,144 @@ impl Recovery { } } } - pub fn adopt(&mut self, id: String, document: u64, revision: u64) { - self.id = Some(id); - self.document = Some(document); - self.revision = Some(revision); - self.due = Instant::now() + INTERVAL; + /// Run `job` now and wait for its answer. + fn run_now(&mut self, job: Job) -> Result { + self.queue(job)?; + self.receive(true).ok_or_else(|| "recovery worker is unavailable".to_string()) } - pub fn tick(&mut self, s: &Session, now: Instant) -> Result<(), String> { - if self.document != Some(s.document_id()) { - self.document = Some(s.document_id()); - self.id = None; - self.revision = None; + /// Discard record `id` now (the worker must be idle) and wait for it. + pub fn discard_now(&mut self, id: &str, document: Option) -> Result { + self.run_now(Job::Discard(id.into(), document)) + } + /// A closed document's record: deleted at the next chance, and not listed meanwhile. + pub fn close(&mut self, id: String) { + if !self.closed.iter().any(|(c, _)| *c == id) { + self.closed.push((id, false)); } + } + /// Records the open documents of this process don't hold any more but haven't deleted yet. + pub fn closing(&self) -> impl Iterator { + self.closed.iter().map(|(id, _)| id.as_str()) + } + /// Closed documents' records not yet queued for deletion. + pub fn take_closed(&mut self) -> Vec { + self.closed + .iter_mut() + .filter(|(_, queued)| !*queued) + .map(|(id, queued)| { + *queued = true; + id.clone() + }) + .collect() + } + /// Take in what the worker reported: the record lists here, and what the session applies + /// to its documents. + pub fn apply(&mut self, e: Event) -> Applied { + match e.outcome { + Ok(Outcome::Done) => Applied::Nothing, + Ok(Outcome::Discarded(id)) => { + self.closed.retain(|(c, _)| *c != id); + Applied::Discarded(id) + } + Ok(Outcome::Loaded(s, request)) => Applied::Loaded(s, request), + Err(message) => { + self.error = Some(message); + if let Some(id) = &e.discard { + // A closed document's copy that can't be deleted stays listed, for the user + // to restore or discard. + self.closed.retain(|(c, _)| c != id); + } + match e.document { + Some(document) => Applied::Failed { document, discard: e.discard.is_some(), permanent: e.permanent }, + None => Applied::Nothing, + } + } + } + } + /// Queue at most one job: a closed document's discard first, then, document by document in + /// the order given, a discard of a copy its document no longer needs (saved, closed without + /// saving, password-protected) or a new copy of a dirty document whose interval has passed. + pub fn tick<'a>(&mut self, docs: impl Iterator>, now: Instant) -> Result<(), String> { if self.busy || !self.ready || !self.alive { return Ok(()); } - if !s.dirty || s.password.is_some() { - if let Some(id) = self.id.clone() { - self.queue(Job::Discard(id))?; + if let Some(at) = self.closed.iter().position(|(_, queued)| !queued) { + let id = self.closed.get(at).map(|(id, _)| id.clone()).ok_or("recovery record list changed")?; + self.queue(Job::Discard(id, None))?; + if let Some(c) = self.closed.get_mut(at) { + c.1 = true; } return Ok(()); } - if self.revision == Some(s.rev()) || now < self.due { + for d in docs { + if !d.wants_copy { + if let Some(id) = d.slot.id.clone() + && d.slot.retry.is_none_or(|t| now >= t) + { + return self.queue(Job::Discard(id, Some(d.id))); + } + continue; + } + if d.slot.revision == Some(d.rev) || now < d.slot.due { + continue; + } + let id = match &d.slot.id { + Some(id) => id.clone(), + None => new_id()?, + }; + let name = d.path.and_then(|p| p.file_name()).map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "Untitled draft".into()); + let info = RecoveryInfo { id: id.clone(), name, saved_at: crate::cmd::now_iso() }; + self.queue(Job::Save(Box::new(Snapshot { version: 1, info, generation: 0, document: d.doc.clone() }), d.id))?; + d.slot.id = Some(id); + d.slot.revision = Some(d.rev); + d.slot.due = now + INTERVAL; return Ok(()); } - let id = match &self.id { - Some(id) => id.clone(), - None => new_id()?, - }; - let name = s.path.as_ref().and_then(|p| p.file_name()).map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "Untitled draft".into()); - let info = RecoveryInfo { id: id.clone(), name, saved_at: crate::cmd::now_iso() }; - self.queue(Job::Save(Box::new(Snapshot { version: 1, info, generation: 0, document: s.doc.clone() })))?; - self.id = Some(id); - self.revision = Some(s.rev()); - self.due = now + INTERVAL; Ok(()) } - pub fn document_is(&self, document: u64) -> bool { - self.document == Some(document) - } - pub fn wait(&self, document: u64, revision: u64) -> Option { - if !self.alive || !self.ready || self.busy || (self.document == Some(document) && self.revision == Some(revision)) { + /// How long until [`tick`](Self::tick) has work, for a host that repaints only on demand. + pub fn wait<'a>(&self, docs: impl Iterator) -> Option { + if !self.alive || !self.ready || self.busy { return None; } - Some(self.due.saturating_duration_since(Instant::now()).max(Duration::from_millis(100))) + let now = Instant::now(); + let min = Duration::from_millis(100); + if self.closed.iter().any(|(_, queued)| !queued) || self.held.as_ref().is_some_and(|(_, r)| !r.replace) { + return Some(min); + } + docs.filter_map(|(wants_copy, rev, slot)| match (wants_copy, &slot.id) { + (false, Some(_)) => Some(slot.retry.map_or(min, |t| t.saturating_duration_since(now))), + (false, None) => None, + (true, _) if slot.revision == Some(rev) => None, + (true, _) => Some(slot.due.saturating_duration_since(now)), + }) + .min() + .map(|d| d.max(min)) } - pub fn restore(&mut self, id: &str, expected: (u64, u64)) -> Result<(), String> { + pub fn restore(&mut self, id: &str, request: Request) -> Result<(), String> { if !self.documents.iter().any(|d| d.id == id) { return Err("no such recovery copy".into()); } - self.queue(Job::Load(id.into(), expected)) + if self.held.is_some() { + return Err("another restored copy is waiting for its document".into()); + } + self.queue(Job::Load(id.into(), request)) } - pub fn discard(&mut self, id: &str, current: (u64, u64)) -> Result<(), String> { + pub fn discard(&mut self, id: &str) -> Result<(), String> { if !self.documents.iter().any(|d| d.id == id) { return Err("no such recovery copy".into()); } - self.queue(Job::Discard(id.into()))?; - if self.id.as_deref() == Some(id) { - self.revision = Some(current.1); - } - Ok(()) + self.queue(Job::Discard(id.into(), None)) } pub fn refresh(&mut self) -> Result<(), String> { self.queue(Job::Scan) } - pub fn purge(&mut self) -> Result<(), String> { - let Some(id) = self.id.clone() else { return Ok(()) }; - if self.busy { - let e = self.rx.recv().map_err(|e| e.to_string())?; - self.documents = e.documents; - self.busy = false; - self.ready = true; - if matches!(e.outcome, Ok(Outcome::Loaded(..))) { - return Err("finish recovery before protecting this document".into()); - } - } - self.queue(Job::Discard(id))?; - let e = self.rx.recv().map_err(|e| e.to_string())?; - self.busy = false; - self.documents = e.documents; - e.outcome?; - self.id = None; - self.revision = None; - Ok(()) - } - /// Waits out any job already in flight (an autosave queued just before shutdown, say), then, - /// if the document turns out to need no recovery copy kept (not dirty, or password-protected - /// — [`tick`](Self::tick)'s own condition), discards it and waits for that too. Desktop hosts - /// call this before the process exits, the same as the print spooler's own pending-job wait - /// (`finish_pending_jobs`): otherwise a save that lands right before close can race the - /// process exiting before the now-redundant recovery copy is actually removed, so the *next* - /// launch wrongly offers to recover a document that was in fact saved cleanly. - pub fn finish(&mut self, s: &Session) -> Result<(), String> { - let mut error = None; - if self.busy { - let e = self.rx.recv().map_err(|e| e.to_string())?; - self.documents = e.documents; - self.busy = false; - self.ready = true; - // An in-flight job failing (e.g. a stray autosave) doesn't mean the document itself - // wasn't saved cleanly by an explicit Save — still try the discard below, so one - // doesn't keep the other from doing its job; log it once there's nothing further to do. - if let Err(e) = e.outcome { - error = Some(e); - } - } - // Same guard `tick` uses: a document switch with no regular tick in between to notice and - // reset `self.id` must not let the *new* document's clean state discard the *previous* - // document's still-live recovery copy under a stale identity. - if self.document == Some(s.document_id()) - && let Some(id) = self.id.clone() - && (!s.dirty || s.password.is_some()) - { - self.queue(Job::Discard(id))?; - let e = self.rx.recv().map_err(|e| e.to_string())?; - self.busy = false; - self.documents = e.documents; - match e.outcome { - Ok(_) => { - self.id = None; - self.revision = None; - } - Err(e) => error = Some(e), - } - } - match error { - Some(e) => Err(e), - None => Ok(()), - } - } } #[cfg(test)] mod tests { use super::*; + use crate::Session; use serde_json::json; use wordcraft_doc::StoryRef; @@ -719,11 +789,28 @@ mod tests { settle(&mut s); s } + /// Every open document's interval has passed. + fn due_now(s: &mut Session) { + for d in s.recovery_docs() { + d.slot.due = Instant::now(); + } + } fn save_now(s: &mut Session) { - s.recovery.as_mut().unwrap().due = Instant::now(); + due_now(s); s.recovery_tick(); settle(s); } + /// The records in the profile folder: the copies on disk. + fn records(root: &Path) -> Vec { + let mut ids: Vec = fs::read_dir(root) + .unwrap() + .filter_map(|e| e.ok()) + .filter(|e| e.path().is_dir()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .collect(); + ids.sort(); + ids + } /// #3140: a save that lands right before the app closes can race the process exiting before /// the now-redundant recovery copy is actually removed, so the next launch wrongly offers to @@ -737,17 +824,13 @@ mod tests { let mut s = session(&profile.0); s.run("document.setText", &json!({"text": "About to be saved"})).unwrap(); save_now(&mut s); - assert_eq!(s.recovery_documents().len(), 1, "a recovery copy exists for the dirty document"); + assert_eq!(records(&profile.0).len(), 1, "a recovery copy exists for the dirty document"); // A real Save does this, then the app can close on the very next frame, with no regular // recovery_tick() in between to notice `!s.dirty` and queue the discard itself. s.dirty = false; - assert_eq!( - s.recovery_documents().len(), - 1, - "confirms the race: setting dirty=false alone (what Save does) doesn't discard anything by itself" - ); + assert_eq!(records(&profile.0).len(), 1, "confirms the race: setting dirty=false alone (what Save does) doesn't discard anything by itself"); s.finish_recovery(); - assert!(s.recovery_documents().is_empty(), "finish_recovery should have discarded the now-redundant copy before returning"); + assert!(records(&profile.0).is_empty(), "finish_recovery should have discarded the now-redundant copy before returning"); } /// `tick` resets `self.id` the moment the session's document no longer matches what it last @@ -761,11 +844,12 @@ mod tests { let mut s = session(&profile.0); s.run("document.setText", &json!({"text": "The document the user is still working on"})).unwrap(); save_now(&mut s); - assert_eq!(s.recovery_documents().len(), 1, "a recovery copy exists for the dirty document"); - s.set_document(Document::new()); // e.g. File > New, with no tick() in between + assert_eq!(records(&profile.0).len(), 1, "a recovery copy exists for the dirty document"); + s.set_document(Document::new()); // the document is replaced, with no tick() in between assert!(!s.dirty, "a freshly switched-to document starts clean"); s.finish_recovery(); - assert_eq!(s.recovery_documents().len(), 1, "switching to a new, unrelated document must not discard the previous one's recovery copy"); + assert_eq!(records(&profile.0).len(), 1, "replacing the document must not discard the previous one's recovery copy"); + assert_eq!(s.recovery_documents().len(), 1, "no open document keeps it: it is listed"); } #[test] @@ -779,6 +863,8 @@ mod tests { let mut s = session(&profile.0); s.run("document.setText", &json!({"text": "Recoverable synthetic draft"})).unwrap(); save_now(&mut s); + drop(s); + let mut s = session(&profile.0); let id = s.recovery_documents()[0].id.clone(); confine(Some(&roots), || { // A command cannot use a file path or choose a different storage folder. @@ -794,9 +880,12 @@ mod tests { assert!(s.path.is_none()); assert!(s.dirty); assert_eq!(s.doc.plain_text(StoryRef::Body), "Recoverable synthetic draft"); - s.run("file.recover", &json!({"discard": id})).unwrap(); - settle(&mut s); + // The restored document keeps its copy there: it isn't listed, and can't be discarded. assert!(s.recovery_documents().is_empty()); + let err = s.run("file.recover", &json!({"discard": id})).unwrap_err(); + assert!(err.to_string().contains("no such recovery copy"), "{err}"); + settle(&mut s); + assert_eq!(records(&profile.0), std::slice::from_ref(&id)); }); assert_eq!(fs::read(&original).unwrap(), b"External synthetic original"); assert_eq!(fs::read_dir(&files.0).unwrap().count(), 1); @@ -878,7 +967,7 @@ mod tests { save_now(&mut s); assert!(s.recovery_error().is_some()); assert!(s.recovery_wait().is_none()); - s.recovery.as_mut().unwrap().due = Instant::now(); + due_now(&mut s); s.recovery_tick(); assert!(!s.recovery_busy(), "the unchanged document was snapshotted again"); assert!(s.recovery_error().is_some()); @@ -887,7 +976,7 @@ mod tests { s.run("text.insert", &json!({"text": "!"})).unwrap(); save_now(&mut s); assert!(s.recovery_error().is_none()); - assert_eq!(s.recovery_documents().len(), 1); + assert_eq!(records(&t.0).len(), 1); } #[test] @@ -969,11 +1058,16 @@ mod tests { settle(&mut s); assert_eq!(s.doc, expected); assert!(s.dirty && s.path.is_none()); + assert!(s.recovery_documents().is_empty(), "the restored document keeps the copy"); + // A crash leaves the restored document's copy, in the same record. + drop(s); + let mut s = session(&t.0); + assert_eq!(s.recovery_documents().len(), 1); s.run("file.recover", &json!({"discard": id})).unwrap(); settle(&mut s); save_now(&mut s); - assert!(s.recovery_documents().is_empty(), "discard isn't undone by the next timer"); - assert_eq!(s.doc, expected, "discard deletes the copy, not the editing document"); + assert!(s.recovery_documents().is_empty()); + assert!(records(&t.0).is_empty()); } #[test] @@ -999,7 +1093,7 @@ mod tests { let saved = t.0.join("recovered.json"); s.run("file.save", &json!({"path": saved})).unwrap(); settle(&mut s); - assert!(s.recovery_documents().is_empty()); + assert!(records(&root).is_empty()); assert_eq!(fs::read(file).unwrap(), original); } @@ -1008,18 +1102,18 @@ mod tests { let t = Temp::new(); let mut s = session(&t.0); s.run("text.insert", &json!({"text": "Confidential synthetic draft"})).unwrap(); - s.recovery.as_mut().unwrap().due = Instant::now(); + due_now(&mut s); s.recovery_tick(); assert!(s.recovery_busy()); s.run("file.encrypt", &json!({"password": "synthetic-password"})).unwrap(); - assert!(s.recovery_documents().is_empty()); + assert!(records(&t.0).is_empty()); s.run("text.insert", &json!({"text": "More confidential edits"})).unwrap(); save_now(&mut s); assert!(fs::read_dir(&t.0).unwrap().next().is_none()); // Removing protection makes future recovery possible again. s.run("file.encrypt", &json!({"password": null})).unwrap(); save_now(&mut s); - assert_eq!(s.recovery_documents().len(), 1); + assert_eq!(records(&t.0).len(), 1); } #[test] @@ -1028,7 +1122,7 @@ mod tests { let mut s = session(&t.0); s.run("text.insert", &json!({"text": "Synthetic text"})).unwrap(); save_now(&mut s); - let id = s.recovery_documents()[0].id.clone(); + let id = records(&t.0).remove(0); let dir = t.0.join(id); // An unknown file prevents removal: don't report that plaintext was purged. let first = Store::files(&dir).unwrap()[0].clone(); @@ -1102,7 +1196,7 @@ mod tests { s.run("file.new", &json!({})).unwrap(); s.run("text.insert", &json!({"text": "New confidential draft"})).unwrap(); save_now(&mut s); - assert_eq!(s.recovery_documents().len(), 1); + assert_eq!(records(&root).len(), 1); let saved = t.0.join("saved.docx"); s.run("file.save", &json!({"path": saved, "password": "synthetic-password"})).unwrap(); settle(&mut s); @@ -1126,9 +1220,8 @@ mod tests { s.activate(first).unwrap(); s.run("text.insert", &json!({"text": ", edited"})).unwrap(); save_now(&mut s); - let records = fs::read_dir(&t.0).unwrap().filter(|e| e.as_ref().is_ok_and(|e| e.path().is_dir())).count(); - assert_eq!(records, 2, "one copy per document"); - assert_eq!(s.recovery_documents().len(), 1, "the first document's own copy"); + assert_eq!(records(&t.0).len(), 2, "one copy per document"); + assert!(s.recovery_documents().is_empty(), "the copies open documents keep are not listed"); } #[test] @@ -1142,7 +1235,109 @@ mod tests { // Protection can be commanded before the host's next recovery tick. s.run("file.encrypt", &json!({"password": "synthetic-password"})).unwrap(); settle(&mut s); - assert_eq!(s.with_document(first, |s| s.recovery_documents().len()).unwrap(), 1); - assert_eq!(fs::read_dir(&t.0).unwrap().filter(|e| e.as_ref().is_ok_and(|e| e.path().is_dir())).count(), 1, "the first draft is kept"); + assert!(s.with_document(first, |s| s.recovery_slot.id.is_some()).unwrap()); + assert_eq!(records(&t.0).len(), 1, "the first draft is kept"); + } + + /// Two dirty documents, one of them parked since before its first copy: both get copies once + /// their interval has passed, without being made active. + #[test] + fn parked_documents_keep_their_copies() { + let t = Temp::new(); + let mut s = session(&t.0); + s.run("text.insert", &json!({"text": "Parked synthetic draft"})).unwrap(); + let parked = s.document_id(); + s.run("file.new", &json!({})).unwrap(); + s.run("text.insert", &json!({"text": "Active synthetic draft"})).unwrap(); + let active = s.document_id(); + assert_ne!(parked, active); + save_now(&mut s); + assert_eq!(s.document_id(), active, "the parked document was not made active"); + assert_eq!(records(&t.0).len(), 2, "both documents have copies"); + // An edit to the parked document reaches its copy too. + s.with_document(parked, |s| s.run("text.insert", &json!({"text": ", edited"})).unwrap()).unwrap(); + let id = s.with_document(parked, |s| s.recovery_slot.id.clone()).unwrap().unwrap(); + save_now(&mut s); + drop(s); + let mut store = Store::new(t.0.clone()).unwrap(); + store.scan().unwrap(); + assert_eq!(load_retrying(&mut store, &id).document.plain_text(StoryRef::Body), "Parked synthetic draft, edited"); + } + + /// Closing a dirty document and saving another delete both their copies; a third document + /// keeps its own. + #[test] + fn closing_or_saving_a_document_deletes_only_its_copy() { + let t = Temp::new(); + let files = Temp::new(); + let mut s = session(&t.0); + let mut ids = Vec::new(); + for text in ["Closed draft", "Saved draft", "Kept draft"] { + if !ids.is_empty() { + s.run("file.new", &json!({})).unwrap(); + } + s.run("text.insert", &json!({"text": text})).unwrap(); + ids.push(s.document_id()); + } + save_now(&mut s); + assert_eq!(records(&t.0).len(), 3); + let kept = s.recovery_slot.id.clone().unwrap(); + s.close_document(ids[0]).unwrap(); + s.with_document(ids[1], |s| s.run("file.save", &json!({"path": files.0.join("saved.json")})).unwrap()).unwrap(); + settle(&mut s); + assert_eq!(records(&t.0), [kept], "only the open dirty document's copy is left"); + assert!(s.recovery_documents().is_empty()); + } + + /// A crash with two dirty documents leaves two copies; the next run lists both and restores + /// each into a document of its own, beside the work already there. + #[test] + fn a_crash_leaves_every_dirty_document_s_copy() { + let t = Temp::new(); + let mut s = session(&t.0); + s.run("text.insert", &json!({"text": "First synthetic draft"})).unwrap(); + s.run("file.new", &json!({})).unwrap(); + s.run("text.insert", &json!({"text": "Second synthetic draft"})).unwrap(); + save_now(&mut s); + drop(s); // no finish_recovery: the process died + let mut s = session(&t.0); + let copies = s.recovery_documents(); + assert_eq!(copies.len(), 2); + s.run("text.insert", &json!({"text": "Work in progress"})).unwrap(); + let work = s.document_id(); + for copy in &copies { + s.run("file.recover", &json!({"restore": copy.id})).unwrap(); + settle(&mut s); + } + assert!(s.recovery_documents().is_empty(), "the restored documents keep their copies"); + let mut texts: Vec = s.documents().iter().map(|d| s.with_document(d.id, |s| s.doc.plain_text(StoryRef::Body)).unwrap()).collect(); + texts.sort(); + assert_eq!(texts, ["First synthetic draft", "Second synthetic draft", "Work in progress"]); + assert!(s.with_document(work, |s| s.dirty).unwrap()); + } + + /// A clean exit deletes the copies of documents saved or closed since their last copy, the + /// parked ones' too, and keeps the dirty ones'. + #[test] + fn finish_recovery_covers_every_open_document() { + let t = Temp::new(); + let files = Temp::new(); + let mut s = session(&t.0); + let mut ids = Vec::new(); + for text in ["Saved draft", "Closed draft", "Dirty draft"] { + if !ids.is_empty() { + s.run("file.new", &json!({})).unwrap(); + } + s.run("text.insert", &json!({"text": text})).unwrap(); + ids.push(s.document_id()); + } + save_now(&mut s); + assert_eq!(records(&t.0).len(), 3); + let dirty = s.recovery_slot.id.clone().unwrap(); + // No tick between these and the exit. + s.with_document(ids[0], |s| s.run("file.save", &json!({"path": files.0.join("saved.json")})).unwrap()).unwrap(); + s.close_document(ids[1]).unwrap(); + s.finish_recovery(); + assert_eq!(records(&t.0), [dirty]); } } diff --git a/crates/engine/src/session.rs b/crates/engine/src/session.rs index e0df7a7a7e..e2c19aa32c 100644 --- a/crates/engine/src/session.rs +++ b/crates/engine/src/session.rs @@ -508,8 +508,12 @@ pub struct Session { /// Password, `file.encrypt`); `None` saves them unencrypted. Kept from a password-protected /// file that was opened, cleared when the document is replaced. pub password: Option, + /// The recovery worker every open document's copy is kept through (desktop hosts only). #[cfg(not(target_arch = "wasm32"))] pub(crate) recovery: Option, + /// The active document's recovery copy. + #[cfg(not(target_arch = "wasm32"))] + pub(crate) recovery_slot: crate::recovery::Slot, /// More pictures and shapes selected along with the one the selection holds (Shift+click), /// for Group. Cleared by any edit or selection change other than adding to it. pub also_selected: Vec, @@ -719,7 +723,7 @@ impl Session { math, password, #[cfg(not(target_arch = "wasm32"))] - recovery, + recovery_slot, also_selected, column, column_mode, @@ -787,7 +791,9 @@ impl Session { read_aloud: Default::default(), password, #[cfg(not(target_arch = "wasm32"))] - recovery, + recovery: None, + #[cfg(not(target_arch = "wasm32"))] + recovery_slot, column, column_mode, widened, @@ -1296,6 +1302,11 @@ impl Session { self.hf_return = None; self.go_back_step = 0; self.view.outline.collapsed.clear(); + // A copy the replaced document kept stays in the recovery list. + #[cfg(not(target_arch = "wasm32"))] + { + self.recovery_slot = Default::default(); + } self.reset_history(); self.touch(); self.dirty = false; @@ -1392,6 +1403,8 @@ impl Session { password: _, #[cfg(not(target_arch = "wasm32"))] recovery: _, + #[cfg(not(target_arch = "wasm32"))] + recovery_slot: _, also_selected: _, // Column selection and a widened selection's ends, like `sel`'s shape: valid only while // `sel` matches them. diff --git a/crates/engine/src/session/documents.rs b/crates/engine/src/session/documents.rs index 27625a151f..b6f8d5d50f 100644 --- a/crates/engine/src/session/documents.rs +++ b/crates/engine/src/session/documents.rs @@ -74,7 +74,7 @@ pub struct DocState { pub(super) math: Option, pub(super) password: Option, #[cfg(not(target_arch = "wasm32"))] - pub(super) recovery: Option, + pub(super) recovery_slot: crate::recovery::Slot, pub(super) also_selected: Vec, pub(super) column: Option, pub(super) column_mode: bool, @@ -124,7 +124,7 @@ impl DocState { math: None, password: None, #[cfg(not(target_arch = "wasm32"))] - recovery: None, + recovery_slot: Default::default(), also_selected: Vec::new(), column: None, column_mode: false, @@ -220,7 +220,7 @@ impl Session { math: _, password: _, #[cfg(not(target_arch = "wasm32"))] - recovery: _, + recovery_slot: _, also_selected: _, column: _, column_mode: _, @@ -254,6 +254,8 @@ impl Session { others: _, order: _, untitled_count: _, + #[cfg(not(target_arch = "wasm32"))] + recovery: _, } = self; let DocState { doc, @@ -291,7 +293,7 @@ impl Session { math, password, #[cfg(not(target_arch = "wasm32"))] - recovery, + recovery_slot, also_selected, column, column_mode, @@ -334,7 +336,7 @@ impl Session { swap(&mut self.math, math); swap(&mut self.password, password); #[cfg(not(target_arch = "wasm32"))] - swap(&mut self.recovery, recovery); + swap(&mut self.recovery_slot, recovery_slot); swap(&mut self.also_selected, also_selected); swap(&mut self.column, column); swap(&mut self.column_mode, column_mode); @@ -358,8 +360,9 @@ impl Session { /// Take parked document `id` out of the session; false when it isn't parked here. fn remove_parked(&mut self, id: u64) -> bool { let Some(at) = self.others.iter().position(|d| d.document_id == id) else { return false }; - self.others.swap_remove(at); + let d = self.others.swap_remove(at); self.order.retain(|d| *d != id); + self.forget_recovery_copy(&d); true } @@ -417,10 +420,6 @@ impl Session { let mut view = self.view.clone(); view.outline.collapsed.clear(); let mut d = DocState::new(doc, untitled, view); - #[cfg(not(target_arch = "wasm32"))] - { - d.recovery = self.recovery.as_ref().and_then(crate::recovery::Recovery::sibling); - } let id = d.document_id; self.order.push(id); self.swap_doc(&mut d); @@ -429,7 +428,48 @@ impl Session { id } - /// Close document `id` without asking: its unsaved changes are lost (callers ask first). + /// A closed document's recovery copy goes with it, as its unsaved changes do. + fn forget_recovery_copy(&mut self, d: &DocState) { + #[cfg(not(target_arch = "wasm32"))] + if let (Some(r), Some(id)) = (&mut self.recovery, &d.recovery_slot.id) { + r.close(id.clone()); + } + #[cfg(target_arch = "wasm32")] + let _ = d; + } + + /// What recovery reads from every open document, the active one first, with each + /// document's slot to update. Nothing is cloned or laid out. + #[cfg(not(target_arch = "wasm32"))] + pub(crate) fn recovery_docs(&mut self) -> impl Iterator> { + let active = crate::recovery::DocRecovery { + id: self.document_id, + wants_copy: self.dirty && self.password.is_none(), + rev: self.rev, + doc: &self.doc, + path: self.path.as_deref(), + slot: &mut self.recovery_slot, + }; + std::iter::once(active).chain(self.others.iter_mut().map(|d| crate::recovery::DocRecovery { + id: d.document_id, + wants_copy: d.dirty && d.password.is_none(), + rev: d.rev, + doc: &d.doc, + path: d.path.as_deref(), + slot: &mut d.recovery_slot, + })) + } + + /// Every open document's recovery slot, with whether it should have a current copy and + /// its revision. + #[cfg(not(target_arch = "wasm32"))] + pub(crate) fn recovery_slots(&self) -> impl Iterator { + std::iter::once((self.dirty && self.password.is_none(), self.rev, &self.recovery_slot)) + .chain(self.others.iter().map(|d| (d.dirty && d.password.is_none(), d.rev, &d.recovery_slot))) + } + + /// Close document `id` without asking: its unsaved changes are lost (callers ask first), + /// and so is its recovery copy. /// Closing the active document makes the one opened before it active (else the one after). /// Closing the last open document leaves a new blank one, so a session always has an /// active document; what a window does then is the front end's choice. An id that isn't @@ -455,12 +495,9 @@ impl Session { let mut view = self.view.clone(); view.outline.collapsed.clear(); let mut fresh = DocState::new(doc, self.untitled_count, view); - #[cfg(not(target_arch = "wasm32"))] - { - fresh.recovery = self.recovery.as_ref().and_then(crate::recovery::Recovery::sibling); - } self.order = vec![fresh.document_id]; self.swap_doc(&mut fresh); + self.forget_recovery_copy(&fresh); } } self.stop_reading_elsewhere(); diff --git a/crates/ui-egui/src/backstage.rs b/crates/ui-egui/src/backstage.rs index 7e9ea3bcff..6c606dec60 100644 --- a/crates/ui-egui/src/backstage.rs +++ b/crates/ui-egui/src/backstage.rs @@ -991,29 +991,27 @@ mod tests { #[test] #[cfg(not(target_arch = "wasm32"))] - fn recovery_restore_uses_save_changes_and_resets_autosave_permission() { + fn recovery_restore_opens_beside_unsaved_work_without_autosave_permission() { let (app, root) = recovery_app(); let mut h = recovery_harness(app); settle_recovery(&mut h); h.state_mut().execute("text.insert", json!({"text": "Current unsaved work"})).unwrap(); + let work = h.state().session.document_id(); h.state_mut().autosave_path = Some(root.join("must-not-write.docx")); h.state_mut().run("file.recover", json!({})).unwrap(); h.run_steps(4); assert_eq!(h.state().session.recovery_documents().len(), 1); h.get_by_label("Restore").click(); - h.run_steps(4); - assert!(matches!(h.state().dialog, Some(crate::dialogs::Dialog::SaveChanges { .. }))); - h.get_by_label("Cancel").click(); - h.run_steps(4); - assert_eq!(h.state().session.doc.plain_text(wordcraft_doc::StoryRef::Body), "Current unsaved work"); - h.get_by_label("Restore").click(); - h.run_steps(4); - h.get_by_label("Don't Save").click(); settle_recovery(&mut h); + assert!(h.state().dialog.is_none(), "nothing is replaced, so nothing to ask"); + assert_ne!(h.state().session.document_id(), work); assert_eq!(h.state().session.doc.plain_text(wordcraft_doc::StoryRef::Body), "Recovered synthetic work"); assert!(h.state().session.dirty && h.state().session.path.is_none()); assert!(h.state().autosave_path.is_none()); assert!(!h.state().ui.backstage); + let kept = h.state_mut().session.with_document(work, |s| s.doc.plain_text(wordcraft_doc::StoryRef::Body)).unwrap(); + assert_eq!(kept, "Current unsaved work"); + assert!(h.state().session.recovery_documents().is_empty(), "the restored document keeps the copy"); assert!(!root.join("must-not-write.docx").exists()); drop(h); let _ = std::fs::remove_dir_all(root); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index fa3163fe31..0525c30e21 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1395,6 +1395,11 @@ impl WordApp { let last = self.session.documents().len() <= 1; if params.get("quit").and_then(Value::as_bool) == Some(true) { if last { + // Don't Save: the document stays open until the app exits, but its changes, + // and so its recovery copy, are discarded. + if params.get("force").and_then(Value::as_bool) == Some(true) { + self.session.discard_recovery_copy(); + } self.quit_requested = true; return Ok(json!({"quit": true})); } @@ -2888,15 +2893,11 @@ fn flushes_comment_drafts(id: &str, params: &Value) -> bool { ) || discards_document(id, params) } -/// User commands that close or replace the shown document: Close, and restoring a recovered -/// copy in its place. New, Open, Envelopes, Labels and Finish & Merge open their document beside -/// it (Open replaces only an untouched blank document, which has nothing to lose). -pub(crate) fn discards_document(id: &str, params: &Value) -> bool { - match id { - "file.close" => true, - "file.recover" => params.get("restore").is_some(), - _ => false, - } +/// User commands that close the shown document: Close. New, Open, restoring a recovered copy, +/// Envelopes, Labels and Finish & Merge open their document beside it (Open and restoring +/// replace only an untouched blank document, which has nothing to lose). +pub(crate) fn discards_document(id: &str, _params: &Value) -> bool { + id == "file.close" } /// Wall-clock milliseconds since the Unix epoch: the system clock, or the browser's on the web. diff --git a/docs/recovery.md b/docs/recovery.md index b4c059f4e8..6640105ca1 100644 --- a/docs/recovery.md +++ b/docs/recovery.md @@ -6,21 +6,35 @@ unless a document was explicitly created or opened before the startup scan compl In particular, a window launched for New/Open keeps the requested document visible; older copies remain available through File › Recover. Recovery uses the regular interface palette so its labels and buttons stay readable in light and dark themes. -Restore opens an unsaved document with no file path. Save As chooses where to keep it. -Recovery never writes to the original document. AutoSave's permission to write to an earlier -file is cleared on restore. +Restore opens the copy as another unsaved document with no file path, beside the open +documents. It replaces only an untouched blank document, so it never replaces work and asks +nothing. Save As chooses where to keep it. Recovery never writes to the original document. +The restored document has no AutoSave permission to write to an earlier file, and keeps its +copy in the record it came from. -Continue without restoring leaves copies available. Discard asks for confirmation and deletes -the selected copy, including its retained generation. It does not delete the editing document. -After discarding that document's copy, another copy is made only after a further edit. -A successful save removes that document's recovery copy on the next worker cycle while it -remains open. Copies left after switching documents can still be explicitly discarded. +File › Recover lists copies from earlier runs: crashed sessions, documents replaced in this +one, and copies whose deletion failed. The copies the open documents keep are not listed; +they are not lost. Continue without restoring leaves copies available. Discard asks for +confirmation and deletes the selected copy, including its retained generation. Documents with a password do not get plaintext recovery copies. Applying a password waits for any pending snapshot, removes the current document's recovery generations, and fails if cleanup fails. Encrypted recovery is not implemented. Documents opened with a password keep recovery disabled for that session. Removing the password allows future recovery again. +## Several open documents + +Every dirty open document keeps its own copy, whether it is shown or not. A crash with several +unsaved documents leaves a copy of each, and the next start lists them all. + +| Event | That document's copy | +|---|---| +| Save | Deleted on the next worker cycle | +| Close without saving (Don't Save, `file.closeDocument`) | Deleted on the next worker cycle | +| Don't Save when quitting | Deleted before the app exits | +| Clean exit | Waits for the worker, then deletes the copies of documents saved, closed or protected since their last copy; dirty documents keep theirs | +| Crash | Kept | + ## Storage and timing Copies live in `recovery/` under the app's profile folder. `WORDCRAFT_PROFILE_DIR` selects a @@ -28,9 +42,11 @@ separate profile for isolated runs, including preferences, logs and control keys `WORDCRAFT_NO_PREFS` disables recovery as well as preferences. Headless engine/CLI sessions and the browser do not create recovery files implicitly. -The desktop host checks for a dirty document every frame but snapshots at most once per ten -seconds, and only when its revision changes. It clones the shared document model for a bounded -worker queue. Encoding, file I/O, full-document validation and discovery run on the worker. +One worker thread and one store serve every open document, so a record is locked once per +process. Every frame the desktop host compares each open document's revision and timer without +making it active, and queues at most one job. A document is snapshotted at most once per ten +seconds, and only when its revision changes. Only a document whose copy is due is cloned (the +shared document model) for the worker. Encoding, file I/O, full-document validation and discovery run on the worker. An unchanged document does not schedule repeated recovery repaints. The most recent edits can be lost if a crash happens before the next snapshot or while its write is still pending. @@ -68,9 +84,10 @@ interval, English fallback for new labels, and no cloud, version-history or brow `file.recover={}` reads cached `{enabled,busy,documents,error}`. Each document has `id`, `name` and `savedAt`. `{"refresh":true}`, `{"restore":"id"}` and `{"discard":"id"}` queue worker -operations. Poll until `busy` is false; inspect `error` for failure. A restore is cancelled if the -document id or revision changes while it loads. Programmatic restores do not open dialogs; -user restores over dirty work use the existing Save Changes prompt. +operations, and accept only listed ids. Poll until `busy` is false; inspect `error` for failure. +A restore asked for in an untouched blank document replaces it once that document is active, +and is cancelled if the document changes or closes while the copy loads. Restores never open +dialogs. `ui.recoveryDiscard={"id":"id"}` opens confirmation; `{"answer":"discard"}` or `{"answer":"cancel"}` answers it. From 3d0fcaccbf7d24390e3c9de947f3d29f204ac0dd Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:28:19 +0000 Subject: [PATCH 012/199] W2: The first blank document from the Start screen is Document1 The Start screen's stand-in document, and the one left after closing the last document, give back their untitled name when it is the newest. --- crates/engine/src/session/documents.rs | 11 +++++++++++ crates/engine/src/tests_documents.rs | 20 ++++++++++++++++++++ crates/ui-egui/src/documents.rs | 14 ++++++++++++++ crates/ui-egui/src/lib.rs | 2 ++ 4 files changed, 47 insertions(+) diff --git a/crates/engine/src/session/documents.rs b/crates/engine/src/session/documents.rs index b6f8d5d50f..d302aa5ff3 100644 --- a/crates/engine/src/session/documents.rs +++ b/crates/engine/src/session/documents.rs @@ -504,6 +504,17 @@ impl Session { Ok(()) } + /// The active document stands in for no document at all (a Start screen's, or what is left + /// after closing the last one): an untouched blank document gives back its untitled name + /// when it is the newest, so the first document a person sees is `Document1`, the next + /// `Document2`. + pub fn release_untitled_name(&mut self) { + if self.untitled != 0 && self.untitled == self.untitled_count && self.is_untouched_blank() { + self.untitled_count = self.untitled_count.saturating_sub(1); + self.untitled = 0; + } + } + /// Whether the active document is a blank one nobody has used: no file and no name, never /// changed, nothing to undo or redo and no text. Opening a file replaces such a document /// rather than opening beside it. diff --git a/crates/engine/src/tests_documents.rs b/crates/engine/src/tests_documents.rs index 329c25c9d0..a1dd44a143 100644 --- a/crates/engine/src/tests_documents.rs +++ b/crates/engine/src/tests_documents.rs @@ -253,3 +253,23 @@ fn document_ids_are_unique_across_sessions_and_replacements() { assert_ne!(one.document_id(), before); assert_eq!(ids(&one), vec![one.document_id()], "the replacement takes its place in the list"); } + +/// A Start screen's stand-in document gives back its name: the first document a person makes is +/// `Document1`, the next `Document2`, and after closing the last one, New makes `Document2` +/// again only if no other document has taken it. +#[test] +fn a_stand_in_document_gives_back_its_untitled_name() { + let mut s = s(); + let placeholder = s.document_id(); + s.release_untitled_name(); + run(&mut s, "file.new", json!({})); + s.close_document(placeholder).unwrap(); + assert_eq!(s.documents()[0].title, "Document1"); + run(&mut s, "file.new", json!({})); + assert_eq!(s.documents()[1].title, "Document2"); + // A document with work keeps its name. + run(&mut s, "text.insert", json!({"text": "Work"})); + s.release_untitled_name(); + run(&mut s, "file.new", json!({})); + assert_eq!(s.documents()[2].title, "Document3"); +} diff --git a/crates/ui-egui/src/documents.rs b/crates/ui-egui/src/documents.rs index 85a3a5154a..1b1c0c16db 100644 --- a/crates/ui-egui/src/documents.rs +++ b/crates/ui-egui/src/documents.rs @@ -286,4 +286,18 @@ mod tests { assert_eq!(super::untitled_title("Documents"), None); assert_eq!(super::untitled_title("Report"), None); } + + /// From the Start screen, Blank document is `Document1` and New after it `Document2`: the + /// Start screen's stand-in document doesn't use up a name. + #[test] + fn start_screen_blank_document_is_document1() { + let mut a = app(); + a.ui.start_screen = true; + assert!(a.start_screen()); + a.run("file.new", json!({})).unwrap(); + let titles = |a: &WordApp| a.session.documents().into_iter().map(|d| d.title).collect::>(); + assert_eq!(titles(&a), ["Document1"]); + a.run("file.new", json!({})).unwrap(); + assert_eq!(titles(&a), ["Document1", "Document2"]); + } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 0525c30e21..9c68eb45e3 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -790,6 +790,7 @@ impl WordApp { self.ui.backstage = true; self.ui.backstage_page = "home".into(); self.no_document = Some(self.session.document_id()); + self.session.release_untitled_name(); } self.ui.start_screen } @@ -1415,6 +1416,7 @@ impl WordApp { self.sync_document(); if last { self.no_document = Some(self.session.document_id()); + self.session.release_untitled_name(); } self.ui.backstage = false; Ok(json!({"closed": true})) From 3963cf3012898e6bd8cfe0333ff1800da05c7592 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 15:28:37 +0000 Subject: [PATCH 013/199] W2: Requests dropped for a document that isn't shown are logged as warnings --- crates/ui-egui/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 9c68eb45e3..5c093d3b49 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1544,7 +1544,7 @@ impl WordApp { self.sync_document(); return; } - log::info!("dropped a request for document {document}, which isn't shown: {req}"); + log::warn!("dropped a request for document {document}, which isn't shown: {req}"); } /// Follow the desktop's light/dark choice while the app runs (Linux, where winit reports no From 4ca965d99029a646df57f6066a6e8c280a40e95a Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 16:09:38 +0000 Subject: [PATCH 014/199] W3a: UI: per-window state that a window swaps in WindowUi holds what one window shows and is doing (canvas, dialogs, menus, status, ribbon tab, Backstage, keytips, file dialogs...) for windows not being drawn; WordApp::swap_window exchanges it with the drawn window's fields by moves. The swap destructures WordApp and UiState exhaustively, so every new field must be classified as per window, per document or app-wide. WordApp::new takes its window defaults from WindowUi::new. --- crates/ui-egui/src/documents.rs | 15 +- crates/ui-egui/src/lib.rs | 133 ++++++++--- crates/ui-egui/src/window_ui.rs | 385 ++++++++++++++++++++++++++++++++ 3 files changed, 491 insertions(+), 42 deletions(-) create mode 100644 crates/ui-egui/src/window_ui.rs diff --git a/crates/ui-egui/src/documents.rs b/crates/ui-egui/src/documents.rs index 1b1c0c16db..a6b5344cb8 100644 --- a/crates/ui-egui/src/documents.rs +++ b/crates/ui-egui/src/documents.rs @@ -1,8 +1,10 @@ //! Several open documents in one window. The window shows the session's active document; what -//! it keeps about a document (AutoSave's file, comment drafts, the page's drags, menus and -//! composition…) follows the active one ([`WordApp::sync_document`]). View › Window › Switch -//! Windows lists the open documents, File › Close closes the shown one and shows the next, and -//! quitting asks about every document with unsaved changes, one after another. +//! it keeps about a document (AutoSave's file, comment drafts) follows the active one, and the +//! window's own state about the page (drags, menus, the composition…) starts afresh when the +//! document changes ([`WordApp::sync_document`]; per-window state is listed in +//! [`crate::window_ui`]). View › Window › Switch Windows lists the open documents, File › Close +//! closes the shown one and shows the next, and quitting asks about every document with unsaved +//! changes, one after another. use serde_json::{Value, json}; use wordcraft_engine::DocInfo; @@ -21,7 +23,8 @@ pub(crate) struct Parked { impl WordApp { /// Follow the session's active document. When it isn't the one the window last showed, the /// shown document's own state is put aside (or dropped, if it was closed or replaced), the - /// new one's comes back, and everything about the page that was shown starts afresh: open + /// new one's comes back, and the window's state about the page that was shown starts afresh + /// (all of it per-window state, [`crate::window_ui::WindowUi`]): open /// dialogs and menus, drags, the input method's composition, the status message, caches /// keyed by revision. Its scroll position and zoom come back with it (the scroll area is per /// document; the zoom is in the session's view). Runs after every command and every frame. @@ -60,7 +63,7 @@ impl WordApp { self.read_aloud_at = None; self.info_editing = None; // Caches keyed by the revision alone: another document's revision can be the same number. - self.word_count = (u64::MAX, 0); + self.word_count = crate::window_ui::NOT_COUNTED; self.find_fonts_for = None; self.previews.document_changed(); // The Start screen's placeholder (#1129) goes once a document is open beside it. diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 5c093d3b49..66ab2ea658 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -86,6 +86,7 @@ pub mod theme; pub mod widgets; pub mod window_close; pub mod window_geometry; +mod window_ui; pub mod wordart_menus; pub mod zotero; @@ -693,39 +694,86 @@ impl WordApp { if let Some(tag) = sys_locale::get_locale() { wordcraft_engine::set_system_locale(&tag); } - let session_document = session.document_id(); + // What the window shows starts as a new window's. + let window_ui::WindowUi { + canvas, + controls, + dialog, + colors, + restrict, + status_msg, + previews, + change_picture_target, + recipient_list_pending, + dialog_pick, + focus_ribbon, + control_click, + keytip_rects, + nav_search_focus, + comment_fonts_for, + find_fonts_for, + sent_title, + sent_full_screen, + seen_full_screen, + escape_free, + word_count, + last_autosave, + equation_prev_tab, + smart_art_item, + smart_art_row_focused, + smart_art_typing, + outline_prev_tab, + read_aloud_at, + awaiting_save, + file_dialog, + info_editing, + shown_document, + #[cfg(not(target_arch = "wasm32"))] + file_browser, + tab, + backstage, + backstage_page, + print, + nav_collapsed, + keytip_prefix, + keytips, + alt_pressed, + alt_chord_used, + alt_key_down, + alt_modifier_seen, + } = window_ui::WindowUi::new(session.document_id()); WordApp { session, - ui: UiState::default(), + ui: UiState { + tab, + backstage, + backstage_page, + print, + nav_collapsed, + keytip_prefix, + keytips, + alt_pressed, + alt_chord_used, + alt_key_down, + alt_modifier_seen, + ..UiState::default() + }, services, printing: Default::default(), print_feedback_seen: None, - canvas: canvas::CanvasState::default(), - controls: Default::default(), - dialog: None, - colors: None, - restrict: Default::default(), - status_msg: None, - previews: previews::Previews::default(), integrated_titlebar: false, mac_titlebar: chrome::MacTitleBar::default(), - focus_ribbon: false, control_rx: None, shot_token: 0, queued_shots: Vec::new(), pending_shots: Vec::new(), synthetic: Vec::new(), - control_click: false, - keytip_rects: Vec::new(), - nav_search_focus: false, styled: false, fonts_hans: false, fonts_korean: false, fonts_system_cjk: false, want_system_cjk: false, - comment_fonts_for: None, recent_fonts_for: Vec::new(), - find_fonts_for: None, fonts_frames: 0, applied_theme: None, native_scale: 1.0, @@ -737,39 +785,52 @@ impl WordApp { system_animations: None, applied_animations: None, frame_ms: 0.0, - sent_title: String::new(), - sent_full_screen: false, - seen_full_screen: None, - escape_free: false, quit_requested: false, autosave: true, - word_count: (0, 0), - last_autosave: 0.0, - equation_prev_tab: None, - smart_art_item: None, - smart_art_typing: None, - smart_art_row_focused: false, - outline_prev_tab: None, zotero: zotero::ZoteroLink::default(), ctx: None, - read_aloud_at: None, read_aloud_error: None, autosave_path: None, saves_in_flight: Vec::new(), - awaiting_save: None, - change_picture_target: None, - recipient_list_pending: false, font_generation: wordcraft_fonts::FontDb::global().generation(), - dialog_pick: None, - file_dialog: None, - #[cfg(not(target_arch = "wasm32"))] - file_browser: file_browser::State::default(), - info_editing: None, recovery_seen: false, no_document: None, comment_drafts: Default::default(), - shown_document: session_document, parked: Default::default(), + canvas, + controls, + dialog, + colors, + restrict, + status_msg, + previews, + change_picture_target, + recipient_list_pending, + dialog_pick, + focus_ribbon, + control_click, + keytip_rects, + nav_search_focus, + comment_fonts_for, + find_fonts_for, + sent_title, + sent_full_screen, + seen_full_screen, + escape_free, + word_count, + last_autosave, + equation_prev_tab, + smart_art_item, + smart_art_row_focused, + smart_art_typing, + outline_prev_tab, + read_aloud_at, + awaiting_save, + file_dialog, + info_editing, + shown_document, + #[cfg(not(target_arch = "wasm32"))] + file_browser, } } diff --git a/crates/ui-egui/src/window_ui.rs b/crates/ui-egui/src/window_ui.rs new file mode 100644 index 0000000000..d79827adf8 --- /dev/null +++ b/crates/ui-egui/src/window_ui.rs @@ -0,0 +1,385 @@ +//! What one window shows and is doing, apart from the rest of the app. [`WordApp`] holds the +//! state of the window being drawn in its own fields, so drawing code reads `app.canvas`, +//! `app.dialog`, `app.ui.tab` whatever window it is; every other window keeps its state in a +//! [`WindowUi`]. [`WordApp::swap_window`] exchanges the two by moving owned fields, so it is +//! cheap enough to run for every window on every frame. +//! +//! Every field of [`WordApp`] and [`UiState`] is one of: +//! - per window: listed in [`WindowUi`] and swapped; +//! - per document: kept for the documents not shown in [`WordApp`]'s `parked` map +//! ([`WordApp::sync_document`]) or in the engine's session; +//! - app-wide: the session, preferences, fonts, theme, the control channel, timers and workers. +//! +//! `swap_window` names every field, so a new field doesn't compile until it is classified. + +use crate::{AwaitingSave, UiState, WordApp, canvas, colors, content_controls, dialogs, dialogs_review, file_dialogs, keytips, previews, print_page}; + +/// A window's state while another window is drawn. Fields mirror their namesakes in [`WordApp`] +/// and, for the ribbon tab, Backstage, printing and keytips, in [`UiState`]. +pub(crate) struct WindowUi { + pub(crate) canvas: canvas::CanvasState, + pub(crate) controls: content_controls::ControlUi, + pub(crate) dialog: Option, + pub(crate) colors: Option, + pub(crate) restrict: dialogs_review::RestrictPane, + pub(crate) status_msg: Option<(String, f64)>, + pub(crate) previews: previews::Previews, + pub(crate) change_picture_target: Option, + pub(crate) recipient_list_pending: bool, + pub(crate) dialog_pick: Option, + pub(crate) focus_ribbon: bool, + pub(crate) control_click: bool, + pub(crate) keytip_rects: Vec<(egui::Rect, String)>, + pub(crate) nav_search_focus: bool, + pub(crate) comment_fonts_for: Option<(u64, u64)>, + pub(crate) find_fonts_for: Option<(String, usize, u64)>, + pub(crate) sent_title: String, + pub(crate) sent_full_screen: bool, + pub(crate) seen_full_screen: Option, + pub(crate) escape_free: bool, + pub(crate) word_count: (u64, usize), + pub(crate) last_autosave: f64, + pub(crate) equation_prev_tab: Option, + pub(crate) smart_art_item: Option, + pub(crate) smart_art_row_focused: bool, + pub(crate) smart_art_typing: Option, + pub(crate) outline_prev_tab: Option, + pub(crate) read_aloud_at: Option, + pub(crate) awaiting_save: Option, + pub(crate) file_dialog: Option, + #[cfg(not(target_arch = "wasm32"))] + pub(crate) file_browser: crate::file_browser::State, + pub(crate) info_editing: Option<(&'static str, u64)>, + pub(crate) shown_document: u64, + // From UiState. + pub(crate) tab: String, + pub(crate) backstage: bool, + pub(crate) backstage_page: String, + pub(crate) print: print_page::PrintSettings, + pub(crate) nav_collapsed: std::collections::HashSet, + pub(crate) keytip_prefix: String, + pub(crate) keytips: keytips::Phase, + pub(crate) alt_pressed: bool, + pub(crate) alt_chord_used: bool, + pub(crate) alt_key_down: bool, + pub(crate) alt_modifier_seen: bool, +} + +/// A word count that matches no document revision, so the next frame counts. +pub(crate) const NOT_COUNTED: (u64, usize) = (u64::MAX, 0); + +impl WindowUi { + /// A new window showing open document `document`: nothing open over the page, Home tab. + pub(crate) fn new(document: u64) -> WindowUi { + let UiState { + tab, + backstage, + backstage_page, + print, + nav_collapsed, + keytip_prefix, + keytips, + alt_pressed, + alt_chord_used, + alt_key_down, + alt_modifier_seen, + .. + } = UiState::default(); + WindowUi { + canvas: Default::default(), + controls: Default::default(), + dialog: None, + colors: None, + restrict: Default::default(), + status_msg: None, + previews: Default::default(), + change_picture_target: None, + recipient_list_pending: false, + dialog_pick: None, + focus_ribbon: false, + control_click: false, + keytip_rects: Vec::new(), + nav_search_focus: false, + comment_fonts_for: None, + find_fonts_for: None, + sent_title: String::new(), + sent_full_screen: false, + seen_full_screen: None, + escape_free: false, + word_count: NOT_COUNTED, + last_autosave: 0.0, + equation_prev_tab: None, + smart_art_item: None, + smart_art_row_focused: false, + smart_art_typing: None, + outline_prev_tab: None, + read_aloud_at: None, + awaiting_save: None, + file_dialog: None, + #[cfg(not(target_arch = "wasm32"))] + file_browser: Default::default(), + info_editing: None, + shown_document: document, + tab, + backstage, + backstage_page, + print, + nav_collapsed, + keytip_prefix, + keytips, + alt_pressed, + alt_chord_used, + alt_key_down, + alt_modifier_seen, + } + } +} + +/// Swap each named field of the drawn window (bound by the caller's destructuring) with its +/// namesake in the [`WindowUi`]. The `WindowUi` pattern is exhaustive, so a field added there +/// and left out here doesn't compile. +macro_rules! swap_fields { + ($w:ident: $($(#[$attr:meta])* $field:ident),* $(,)?) => { + let WindowUi { $($(#[$attr])* $field: _),* } = &*$w; + $($(#[$attr])* std::mem::swap($field, &mut $w.$field);)* + }; +} + +impl WordApp { + /// Exchange the drawn window's state with `w`'s: afterwards the app draws the window `w` + /// held, and `w` holds the one that was drawn. App-wide and per-document state stays. The + /// session's active document is not changed: the caller activates `w`'s document + /// (`shown_document`) with it, or [`WordApp::sync_document`] treats the change as a switch + /// within the window. + #[cfg_attr(not(test), expect(dead_code, reason = "drawing one native window per document swaps windows in"))] + pub(crate) fn swap_window(&mut self, w: &mut WindowUi) { + let WordApp { + // App-wide. + session: _, + services: _, + printing: _, + print_feedback_seen: _, + font_generation: _, + integrated_titlebar: _, + mac_titlebar: _, + control_rx: _, + shot_token: _, + queued_shots: _, + pending_shots: _, + synthetic: _, + styled: _, + fonts_hans: _, + fonts_korean: _, + fonts_system_cjk: _, + want_system_cjk: _, + recent_fonts_for: _, + fonts_frames: _, + applied_theme: _, + native_scale: _, + legacy_interface_size: _, + desktop_theme: _, + desktop_dark: _, + applied_editing: _, + system_animations: _, + applied_animations: _, + frame_ms: _, + quit_requested: _, + autosave: _, + zotero: _, + ctx: _, + read_aloud_error: _, + saves_in_flight: _, + recovery_seen: _, + no_document: _, + // Per document: the shown document's, swapped with `parked` on a document switch. + autosave_path: _, + comment_drafts: _, + parked: _, + // Per window. + ui, + canvas, + controls, + dialog, + colors, + restrict, + status_msg, + previews, + change_picture_target, + recipient_list_pending, + dialog_pick, + focus_ribbon, + control_click, + keytip_rects, + nav_search_focus, + comment_fonts_for, + find_fonts_for, + sent_title, + sent_full_screen, + seen_full_screen, + escape_free, + word_count, + last_autosave, + equation_prev_tab, + smart_art_item, + smart_art_row_focused, + smart_art_typing, + outline_prev_tab, + read_aloud_at, + awaiting_save, + file_dialog, + #[cfg(not(target_arch = "wasm32"))] + file_browser, + info_editing, + shown_document, + } = self; + let UiState { + // App-wide: preferences, recent lists, and the settings the session owns while + // running. + ribbon_collapsed: _, + quick_access_toolbar: _, + recent: _, + recent_symbols: _, + recent_colors: _, + recent_fonts: _, + theme: _, + interface_size: _, + legacy_interface_size: _, + animations: _, + legacy_dark: _, + nav_tab: _, + nav_pane: _, + show_discord: _, + author: _, + window: _, + language: _, + editing: _, + read_aloud_rate: _, + read_aloud_skip_citations: _, + keytips_enabled: _, + dark_page: _, + check_spelling: _, + caret_animation: _, + keyboard: _, + ribbon: _, + caption_labels: _, + last_caption_label: _, + ink_pens: _, + initials: _, + options_pane: _, + recent_count: _, + mini_toolbar: _, + alignment_guides: _, + start_screen: _, + print_backgrounds: _, + update_fields_before_print: _, + dictionary: _, + // Per window. + tab, + backstage, + backstage_page, + print, + nav_collapsed, + keytip_prefix, + keytips, + alt_pressed, + alt_chord_used, + alt_key_down, + alt_modifier_seen, + } = ui; + swap_fields!(w: + canvas, + controls, + dialog, + colors, + restrict, + status_msg, + previews, + change_picture_target, + recipient_list_pending, + dialog_pick, + focus_ribbon, + control_click, + keytip_rects, + nav_search_focus, + comment_fonts_for, + find_fonts_for, + sent_title, + sent_full_screen, + seen_full_screen, + escape_free, + word_count, + last_autosave, + equation_prev_tab, + smart_art_item, + smart_art_row_focused, + smart_art_typing, + outline_prev_tab, + read_aloud_at, + awaiting_save, + file_dialog, + #[cfg(not(target_arch = "wasm32"))] + file_browser, + info_editing, + shown_document, + tab, + backstage, + backstage_page, + print, + nav_collapsed, + keytip_prefix, + keytips, + alt_pressed, + alt_chord_used, + alt_key_down, + alt_modifier_seen, + ); + } +} + +#[cfg(test)] +mod tests { + use serde_json::json; + use wordcraft_engine::Session; + + use super::WindowUi; + use crate::{Services, WordApp, dialogs::Dialog}; + + /// Swapping in a new window shows its fresh state; swapping back restores the first window's + /// dialog, status message, composition, scroll request and tab. The session and the + /// preferences stay put both ways. + #[test] + fn swap_window_round_trip() { + let mut a = WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default()); + a.run("text.insert", json!({"text": "Kept"})).unwrap(); + let doc = a.session.document_id(); + let rev = a.session.rev(); + a.ui.recent_count = 7; + a.ui.tab = "Insert".into(); + a.dialog = + Some(Dialog::SaveChanges { name: "Report".into(), then: "file.close".into(), params: json!({}), document: doc, message: String::new() }); + a.status_msg = Some(("Saved".into(), 1.0)); + a.canvas.ime_preedit = "ni".into(); + a.canvas.ime_preedit_range = Some(0..2); + a.canvas.scroll_to_caret = false; + a.canvas.nav_to_caret = true; + + let mut w = WindowUi::new(doc); + a.swap_window(&mut w); + assert!(a.dialog.is_none() && a.status_msg.is_none()); + assert!(a.canvas.ime_preedit.is_empty() && a.canvas.ime_preedit_range.is_none()); + assert!(a.canvas.scroll_to_caret && !a.canvas.nav_to_caret, "a new window goes to the caret"); + assert_eq!(a.ui.tab, crate::DEFAULT_TAB); + assert_eq!(w.tab, "Insert"); + assert_eq!((a.session.document_id(), a.session.rev(), a.ui.recent_count), (doc, rev, 7), "app-wide state stays"); + + a.swap_window(&mut w); + assert_eq!(a.dialog.as_ref().map(Dialog::name), Some("saveChanges")); + assert_eq!(a.status_msg.as_ref().map(|(m, _)| m.as_str()), Some("Saved")); + assert_eq!((a.canvas.ime_preedit.as_str(), a.canvas.ime_preedit_range.clone()), ("ni", Some(0..2))); + assert!(!a.canvas.scroll_to_caret && a.canvas.nav_to_caret); + assert_eq!(a.ui.tab, "Insert"); + assert!(w.dialog.is_none() && w.tab == crate::DEFAULT_TAB); + assert_eq!((a.session.document_id(), a.session.rev(), a.ui.recent_count), (doc, rev, 7)); + assert_eq!(a.session.doc.plain_text(wordcraft_doc::StoryRef::Body).trim(), "Kept"); + } +} From 9feaa3d480318ea18f5dacfe71120ba021335dfc Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 16:54:37 +0000 Subject: [PATCH 015/199] W3b: UI: one native window per open document New and Open open the document in a window of its own (an egui viewport, craft-window's window list and driver), focused and cascaded; Open replaces an untouched blank document in its window and brings an already-open file's window forward. Each window keeps its own interface state and draws its own document. Close and a window's close button close that window, asking first; the root viewport adopts another window when its own closes; the last window stays. Quitting asks about each document in its window. Requests for another window's document go to that window. Control input, screenshots and the saved geometry follow the active window. The web app keeps one window. --- Cargo.lock | 10 + apps/wordcraft/src/apple_events.rs | 7 +- apps/wordcraft/src/main.rs | 12 +- crates/engine/src/session/documents.rs | 8 + crates/ui-egui/Cargo.toml | 2 + crates/ui-egui/src/backstage.rs | 9 +- crates/ui-egui/src/control.rs | 6 +- crates/ui-egui/src/dialogs.rs | 3 +- crates/ui-egui/src/documents.rs | 82 ++- crates/ui-egui/src/lib.rs | 263 +++++-- crates/ui-egui/src/panes.rs | 2 +- crates/ui-egui/src/previews.rs | 5 +- crates/ui-egui/src/read_aloud.rs | 8 + crates/ui-egui/src/window_ui.rs | 3 +- crates/ui-egui/src/windows.rs | 984 +++++++++++++++++++++++++ crates/ui-egui/src/zotero.rs | 12 + 16 files changed, 1306 insertions(+), 110 deletions(-) create mode 100644 crates/ui-egui/src/windows.rs diff --git a/Cargo.lock b/Cargo.lock index 538b4542f8..3887eda6ec 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -894,6 +894,15 @@ dependencies = [ "egui", ] +[[package]] +name = "craft-window" +version = "0.1.0" +source = "git+https://github.com/aiongg/craft-libs?rev=4fbdea5fcf86ba893d9bc03685d4531cfdd4efc2#4fbdea5fcf86ba893d9bc03685d4531cfdd4efc2" +dependencies = [ + "egui", + "wayland-client", +] + [[package]] name = "crc32fast" version = "1.5.2" @@ -5401,6 +5410,7 @@ version = "0.9.0" dependencies = [ "cfb", "craft-ui", + "craft-window", "egui", "egui_kittest", "image", diff --git a/apps/wordcraft/src/apple_events.rs b/apps/wordcraft/src/apple_events.rs index 71d0d05b4d..64f534392d 100644 --- a/apps/wordcraft/src/apple_events.rs +++ b/apps/wordcraft/src/apple_events.rs @@ -55,7 +55,12 @@ pub fn poll(inbox: &Inbox, app: &mut WordApp, ctx: &egui::Context) { } } } - Event::Quit => ctx.send_viewport_cmd(egui::ViewportCommand::Close), + // Quitting asks about every document with unsaved changes, each in its window; the + // app closes once that's done (`quit_requested`). + Event::Quit => { + app.quit(); + ctx.request_repaint(); + } } } } diff --git a/apps/wordcraft/src/main.rs b/apps/wordcraft/src/main.rs index 85410340da..094ad6c20f 100644 --- a/apps/wordcraft/src/main.rs +++ b/apps/wordcraft/src/main.rs @@ -54,8 +54,9 @@ impl eframe::App for App { ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(size)); ctx.send_viewport_cmd(egui::ViewportCommand::OuterPosition(pos)); } + // The geometry saved for the next start is the active window's. let prev = self.0.ui.window; - self.0.ui.window = ctx.input(|i| WindowGeometry::track(prev, i.viewport(), i.viewport_rect().size())); + self.0.ui.window = ctx.input_for(self.0.active_viewport(), |i| WindowGeometry::track(prev, i.viewport(), i.viewport_rect().size())); // The first frame, once the monitor is known (as `take_rescue` waits for): maximize a // window that opened at its normal size so it never flashed on screen half-opened. let maximize = self.1.and_then(|g| g.maximize_after_open()).filter(|_| ctx.input(|i| i.viewport().monitor_size.is_some())); @@ -65,9 +66,11 @@ impl eframe::App for App { if let Some(command) = maximize { ctx.send_viewport_cmd(command); } - // The window's close button: with unsaved changes, ask first (`WordApp::close_requested`). + // The window's close button: with other windows open only this window closes (the app + // handles that while drawing it); the last one quits, asking first about unsaved changes + // (`WordApp::close_requested`). let closing = ctx.input(|i| i.viewport().close_requested()); - if closing && !self.0.close_requested() { + if closing && (self.0.closes_a_window() || !self.0.close_requested()) { ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); } else if (closing || self.0.quit_requested) && !self.0.printing.busy() { // On macOS, letting eframe drop the window inside `-[NSApplication run]` aborts in @@ -397,6 +400,8 @@ fn main() -> eframe::Result { #[cfg(target_os = "macos")] graphics::configure_indirect_validation(&mut options, graphics::skips_indirect_validation(true, cfg!(target_arch = "x86_64"))); load_user_fonts(); + // Windows opened for further documents start like the first one, before its saved place. + let window_builder = options.viewport.clone(); let restored = saved_window(); if let Some(window) = restored { options.viewport = window.apply(options.viewport); @@ -447,6 +452,7 @@ fn main() -> eframe::Result { } } app.ui.window = restored; + app.set_window_builder(window_builder); app.integrated_titlebar = cfg!(target_os = "macos"); #[cfg(target_os = "macos")] if let Some(bar) = traffic_lights::title_bar() { diff --git a/crates/engine/src/session/documents.rs b/crates/engine/src/session/documents.rs index d302aa5ff3..8d5c2fbbcd 100644 --- a/crates/engine/src/session/documents.rs +++ b/crates/engine/src/session/documents.rs @@ -400,6 +400,14 @@ impl Session { Ok(()) } + /// Make document `id` the active one, as [`Session::activate`] does, but a Read Aloud + /// reading of another document carries on: for a front end that shows each document in a + /// window of its own, and makes each window's document active to draw it. An id that isn't + /// open is an error. + pub fn switch_document(&mut self, id: u64) -> Result<(), CmdError> { + self.switch_to(id) + } + /// Open `doc` as another document and make it active; the one that was active stays open. /// The new document starts as a replaced one does ([`Session::set_document`]): caret at the /// start, no undo history, not dirty, no path, an untitled name (`Document2`), and the view diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 48ed412196..65d3e1056f 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -10,6 +10,8 @@ workspace = true [dependencies] craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "d27646a5f7c8b4fc85d9dd3213b8c88b58e0088d" } +# One native window per open document: the window list, the Window menu and the egui viewports. +craft-window = { git = "https://github.com/aiongg/craft-libs", rev = "4fbdea5fcf86ba893d9bc03685d4531cfdd4efc2", features = ["egui"] } wordcraft-geom = { workspace = true } wordcraft-doc = { workspace = true } unicode-bidi = { workspace = true } diff --git a/crates/ui-egui/src/backstage.rs b/crates/ui-egui/src/backstage.rs index 6c606dec60..cceb2b5fa8 100644 --- a/crates/ui-egui/src/backstage.rs +++ b/crates/ui-egui/src/backstage.rs @@ -325,7 +325,7 @@ fn home_page(app: &mut WordApp, ui: &mut Ui) { fn new_page(app: &mut WordApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); heading(ui, "New"); - let id = egui::Id::new("backstage_template_search"); + let id = app.window_id("backstage_template_search"); let mut query = ui.ctx().data(|d| d.get_temp::(id)).unwrap_or_default(); let search = ui.add(egui::TextEdit::singleline(&mut query).hint_text(tl!("Search for templates")).desired_width(360.0)); if search.changed() { @@ -1073,14 +1073,15 @@ mod tests { assert!(h.query_by_label("Report").is_some(), "the built-in templates stay"); // The search covers both. - h.ctx.data_mut(|d| d.insert_temp(egui::Id::new("backstage_template_search"), "memo".to_string())); + let search = h.state().window_id("backstage_template_search"); + h.ctx.data_mut(|d| d.insert_temp(search, "memo".to_string())); h.run_steps(3); assert!(h.query_by_label("Team memo").is_some(), "search finds a personal template"); assert!(h.query_by_label("Contract").is_none() && h.query_by_label("Report").is_none()); - h.ctx.data_mut(|d| d.insert_temp(egui::Id::new("backstage_template_search"), "zzz".to_string())); + h.ctx.data_mut(|d| d.insert_temp(search, "zzz".to_string())); h.run_steps(3); assert!(h.query_by_label("No templates match your search.").is_some()); - h.ctx.data_mut(|d| d.insert_temp(egui::Id::new("backstage_template_search"), String::new())); + h.ctx.data_mut(|d| d.insert_temp(search, String::new())); h.run_steps(3); h.get_by_label("Team memo").click(); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 2803ef2ef1..112e08e904 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -49,7 +49,7 @@ fn err(e: impl std::fmt::Display) -> Outcome { } pub fn inspect(app: &mut WordApp, ctx: &egui::Context) -> Value { - let r = ctx.content_rect(); + let r = ctx.input_for(app.active_viewport(), |i| i.content_rect()); let caret = crate::canvas::caret_screen(app).map(|(p, h)| json!({"x": p.x, "y": p.y, "h": h})); json!({ "ui": app.prefs(), @@ -358,11 +358,11 @@ pub fn handle(app: &mut WordApp, ctx: &egui::Context, req: &ControlRequest) -> O } "ui.resize" => { let (Some(w), Some(h)) = (f("width"), f("height")) else { return err("missing width/height") }; - ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(egui::vec2(w, h))); + ctx.send_viewport_cmd_to(app.active_viewport(), egui::ViewportCommand::InnerSize(egui::vec2(w, h))); ok(json!({})) } "ui.focus" => { - ctx.send_viewport_cmd(egui::ViewportCommand::Focus); + ctx.send_viewport_cmd_to(app.active_viewport(), egui::ViewportCommand::Focus); app.canvas.want_focus = true; ok(json!({})) } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 3a945bf075..4512a542d8 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -3763,7 +3763,8 @@ const SEARCH_SUGGESTIONS: [&str; 3] = ["review.spelling", "review.wordCount", "f /// Search's "Find … in the document": the Navigation pane opens on its results for `text`. fn find_in_navigation_pane(app: &mut WordApp, ctx: &egui::Context, text: &str) { - ctx.data_mut(|d| d.insert_temp(egui::Id::new("nav_query"), text.to_string())); + let qid = app.window_id("nav_query"); + ctx.data_mut(|d| d.insert_temp(qid, text.to_string())); app.ui.nav_tab = "results".into(); let _ = app.run("view.navigationPane", json!({"value": true})); let _ = app.run("edit.find", json!({"text": text})); diff --git a/crates/ui-egui/src/documents.rs b/crates/ui-egui/src/documents.rs index a6b5344cb8..5f8645ccf9 100644 --- a/crates/ui-egui/src/documents.rs +++ b/crates/ui-egui/src/documents.rs @@ -1,10 +1,11 @@ -//! Several open documents in one window. The window shows the session's active document; what -//! it keeps about a document (AutoSave's file, comment drafts) follows the active one, and the -//! window's own state about the page (drags, menus, the composition…) starts afresh when the -//! document changes ([`WordApp::sync_document`]; per-window state is listed in +//! Several open documents. On the desktop each has a window of its own ([`crate::windows`]); the +//! web app shows them in one window, which shows the session's active document. What the app +//! keeps about a document (AutoSave's file, comment drafts) follows the active one. In one +//! window, the window's own state about the page (drags, menus, the composition…) starts afresh +//! when the document changes ([`WordApp::sync_document`]; per-window state is listed in //! [`crate::window_ui`]). View › Window › Switch Windows lists the open documents, File › Close -//! closes the shown one and shows the next, and quitting asks about every document with unsaved -//! changes, one after another. +//! closes the shown one, and quitting asks about every document with unsaved changes, one after +//! another, each in its window. use serde_json::{Value, json}; use wordcraft_engine::DocInfo; @@ -28,23 +29,22 @@ impl WordApp { /// dialogs and menus, drags, the input method's composition, the status message, caches /// keyed by revision. Its scroll position and zoom come back with it (the scroll area is per /// document; the zoom is in the session's view). Runs after every command and every frame. + /// With native windows ([`crate::windows`]) the window that shows the document comes to the + /// front instead, or a new one opens for it. pub(crate) fn sync_document(&mut self) { let id = self.session.document_id(); + if self.windows.native() { + self.prune_windows(); + } if id == self.shown_document { return; } - let left = std::mem::replace(&mut self.shown_document, id); - let leaving = Parked { autosave_path: self.autosave_path.take(), comment_drafts: std::mem::take(&mut self.comment_drafts) }; - if self.session.is_open(left) { - self.parked.insert(left, leaving); + if self.windows.native() && self.follow_in_windows(id) { + return; } - let back = self.parked.remove(&id); - let seen = back.is_some(); - let back = back.unwrap_or_default(); - self.autosave_path = back.autosave_path; - self.comment_drafts = back.comment_drafts; - let session = &self.session; - self.parked.retain(|d, _| session.is_open(*d)); + let left = std::mem::replace(&mut self.shown_document, id); + let seen = self.carry_document_state(left, id); + self.window_shows(id); self.canvas.show_other_document(seen); self.controls = Default::default(); @@ -76,6 +76,32 @@ impl WordApp { } } + /// The active document is `to` and no longer `from`: `from`'s own state is put aside (or + /// dropped, if it was closed or replaced) and `to`'s comes back. Makes `to` the session's + /// active document. Returns whether `to` had state put aside. + pub(crate) fn carry_document_state(&mut self, from: u64, to: u64) -> bool { + if self.session.document_id() != to + && let Err(e) = self.session.switch_document(to) + { + log::error!("couldn't show document {to}: {e}"); + } + if from == to { + return false; + } + let leaving = Parked { autosave_path: self.autosave_path.take(), comment_drafts: std::mem::take(&mut self.comment_drafts) }; + if self.session.is_open(from) { + self.parked.insert(from, leaving); + } + let back = self.parked.remove(&to); + let seen = back.is_some(); + let back = back.unwrap_or_default(); + self.autosave_path = back.autosave_path; + self.comment_drafts = back.comment_drafts; + let session = &self.session; + self.parked.retain(|d, _| session.is_open(*d)); + seen + } + /// Show open document `id` in the window (View › Window › Switch Windows, quitting). pub(crate) fn show_document(&mut self, id: u64) -> Result<(), String> { self.session.activate(id).map_err(|e| e.to_string())?; @@ -153,13 +179,18 @@ pub(crate) fn untitled_title(english: &str) -> Option { }) } -/// View › Window › Switch Windows: the open documents in the order they were opened, the shown -/// one marked, unsaved ones with the title bar's dot. Choosing one shows it. +/// View › Window › Switch Windows: the open windows (in one window, the open documents) in the +/// order they were opened, this one marked, unsaved ones with the title bar's dot. Choosing one +/// brings its window to the front (in one window, shows the document). pub(crate) fn switch_windows_menu(ui: &mut egui::Ui, app: &mut WordApp) { - for d in app.session.documents() { - let label = format!("{}{}", display_title(&d), if d.dirty { " •" } else { "" }); - if ui.add(egui::Button::selectable(d.active, label).min_size(egui::vec2(220.0, 0.0))).clicked() { - let _ = app.run("view.switchWindows", json!({"id": d.id})); + let entries: Vec<(u64, String, bool)> = if app.windows.native() { + app.switch_windows_entries() + } else { + app.session.documents().into_iter().map(|d| (d.id, format!("{}{}", display_title(&d), if d.dirty { " •" } else { "" }), d.active)).collect() + }; + for (doc, label, current) in entries { + if ui.add(egui::Button::selectable(current, label).min_size(egui::vec2(220.0, 0.0))).clicked() { + let _ = app.run("view.switchWindows", json!({"id": doc})); ui.close(); } } @@ -268,11 +299,12 @@ mod tests { assert!(a.close_requested()); } - /// A request an agent's command makes for a document that isn't shown never acts on the - /// shown one: Print doesn't open Backstage over it. + /// In one window (the web app), a request an agent's command makes for a document that isn't + /// shown never acts on the shown one: Print doesn't open Backstage over it. #[test] fn requests_for_a_document_not_shown_leave_the_window_alone() { let mut a = app(); + a.windows.single(); let first = a.session.document_id(); a.run("file.new", json!({})).unwrap(); a.session.with_document(first, |s| s.run("file.print", &json!({}))).unwrap().unwrap(); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 66ab2ea658..0f1ccf9b74 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -87,6 +87,7 @@ pub mod widgets; pub mod window_close; pub mod window_geometry; mod window_ui; +mod windows; pub mod wordart_menus; pub mod zotero; @@ -367,6 +368,9 @@ pub struct UiState { /// no longer applies (a contextual tab after the selection moved away, a tab hidden in Customize /// Ribbon). Word opens on Home every launch; see [`UiState::tab`]. pub const DEFAULT_TAB: &str = "Home"; + +/// ⌘Q, which only the Mac sends: quit. +const QUIT: egui::KeyboardShortcut = egui::KeyboardShortcut::new(egui::Modifiers::MAC_CMD, egui::Key::Q); /// Most recent documents kept (File › Options › Advanced offers 0–50, as Word does). pub const MAX_RECENT: usize = 50; /// Most custom dictionary words read back from `ui.json`. @@ -667,6 +671,10 @@ pub struct WordApp { shown_document: u64, /// The per-document state of the open documents not shown. parked: std::collections::HashMap, + /// The open windows ([`windows`]). + pub(crate) windows: windows::Windows, + /// The document the last print job was for: its window reports how the job went. + print_document: Option, } /// The answer to "Do you want to save changes?" (`ui.saveChanges`). @@ -797,6 +805,8 @@ impl WordApp { no_document: None, comment_drafts: Default::default(), parked: Default::default(), + windows: windows::Windows::new(shown_document), + print_document: None, canvas, controls, dialog, @@ -1442,9 +1452,10 @@ impl WordApp { self.quit_requested } - /// File › Close (Mod+W, Mod+F4) closes the shown document and keeps the window, as Word does - /// (#1712): the next open document shows, or, when it was the only one, no page, as after Esc - /// on the Start screen (#1129), and File › New or Open goes on from there. `{"quit": true}`, + /// File › Close (Mod+W, Mod+F4) closes the shown document. Its window closes too while other + /// windows are open, and the one focused before it comes forward (in the web app's one window, + /// the next document shows). The last window stays, as Word's does (#1712), with no page, as + /// after Esc on the Start screen (#1129), and File › New or Open goes on from there. `{"quit": true}`, /// from the window's close button, goes on quitting: the shown document's prompt has been /// answered, so it closes and the next document with unsaved changes is asked about /// ([`WordApp::ask_before_quit`]). With no document open there is nothing to close. Unsaved @@ -1465,21 +1476,23 @@ impl WordApp { self.quit_requested = true; return Ok(json!({"quit": true})); } - self.session.close_document(self.session.document_id()).map_err(|e| e.to_string())?; - self.sync_document(); + self.close_shown_document()?; return Ok(self.ask_before_quit()); } if !self.has_document() { return Ok(json!({"closed": false})); } - self.session.close_document(self.session.document_id()).map_err(|e| e.to_string())?; + self.close_shown_document()?; self.recovery_seen = true; - self.sync_document(); if last { self.no_document = Some(self.session.document_id()); self.session.release_untitled_name(); } - self.ui.backstage = false; + // The window that stays leaves the File page; another window that came forward keeps + // its own. + if last || !self.windows.native() { + self.ui.backstage = false; + } Ok(json!({"closed": true})) } @@ -1528,12 +1541,16 @@ impl WordApp { } /// Requests commands make of the UI (open a dialog…). - fn handle_request(&mut self, req: &Value) { - // Made for a document that isn't shown (an agent's command run there). + pub(crate) fn handle_request(&mut self, req: &Value) { + // Made for a document that isn't shown (an agent's command run there): its window handles + // it when it draws. if let Some(document) = req.get("document").and_then(Value::as_u64) && document != self.session.document_id() { - self.handle_request_for(document, req); + let app_wide = req.get("interfaceTheme").is_some() || req.get("toggle").and_then(Value::as_str) == Some("interfaceTheme"); + if app_wide || req.get("close").is_some() || !self.queue_for_window(document, req) { + self.handle_request_for(document, req); + } return; } // Ctrl+F: type straight into the Navigation pane's search box (#712). @@ -1581,10 +1598,10 @@ impl WordApp { } } - /// A request made for document `document`, which isn't shown: the interface theme is the - /// app's and changes; Close closes that document if it has nothing unsaved; the rest (dialogs, - /// panes, focus, scrolling) are about the window's page and are dropped, as the page shows - /// another document. + /// A request made for document `document`, which isn't shown and which no other window + /// handles: the interface theme is the app's and changes; Close closes that document if it + /// has nothing unsaved; the rest (dialogs, panes, focus, scrolling) are about a window's page + /// and are dropped, as no window shows the document. fn handle_request_for(&mut self, document: u64, req: &Value) { let theme_only = req.get("interfaceTheme").is_some() || req.get("toggle").and_then(Value::as_str) == Some("interfaceTheme"); if theme_only { @@ -1605,7 +1622,7 @@ impl WordApp { self.sync_document(); return; } - log::warn!("dropped a request for document {document}, which isn't shown: {req}"); + log::warn!("dropped a request for document {document}, which no window shows: {req}"); } /// Follow the desktop's light/dark choice while the app runs (Linux, where winit reports no @@ -1936,6 +1953,7 @@ impl WordApp { /// Submit the selected print job; the implementation lives beside its localized controls. fn print_to_system(&mut self, p: &Value) -> Result { + self.print_document = Some(self.session.document_id()); printing::submit(self, p) } @@ -2189,6 +2207,11 @@ impl WordApp { /// Terminal feedback also reaches the normal window when printing starts through control. pub fn poll_printing(&mut self, ctx: &egui::Context) { self.printing.poll(ctx); + self.print_feedback(); + } + + /// Say how the print job went, once, in the window showing the printed document. + fn print_feedback(&mut self) { if self.printing.message == self.print_feedback_seen { return; } @@ -2228,17 +2251,6 @@ impl WordApp { } options::sync(self); self.settle_async_saves(); - // SmartArt Text Pane (#2701): Tab demotes and Shift+Tab promote the focused item, as Word's - // pane does. Consuming the key stops Tab from moving the caret to the next row, and so does - // resetting the focus direction: the item keeps focus after its level changes. - if self.session.view.smart_art_pane && self.smart_art_row_focused && self.smart_art_item.is_some() { - let shift_tab = ctx.input_mut(|i| i.consume_key(egui::Modifiers::SHIFT, egui::Key::Tab)); - let tab = ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Tab)); - if shift_tab || tab { - ctx.memory_mut(|m| m.move_focus(egui::FocusDirection::None)); - let _ = self.run(if shift_tab { "smartArt.promote" } else { "smartArt.demote" }, json!({"index": self.smart_art_item.unwrap_or(0)})); - } - } // Chinese text wants the Chinese face before the Japanese one (one glyph style per line). // An installed CJK font is read only once CJK text is shown (#241): a CJK interface, the // language names in Options, or a file name the caption is drawing. A file opened later @@ -2294,12 +2306,16 @@ impl WordApp { if self.ctx.is_none() { self.ctx = Some(ctx.clone()); } - self.poll_printing(ctx); + let native = self.windows.native(); + if native { + self.printing.poll(ctx); + } else { + self.poll_printing(ctx); + } self.drain_control(ctx); - zotero::poll(self, ctx); - read_aloud::poll(self, ctx); - self.clear_stale_change_picture(); - let _ = self.poll_file_dialog(); + // The active window's own work; with native windows each window does it again as it + // draws, for itself. + self.window_work(ctx); self.drain_inbox(); self.drain_pasted(); self.watch_fonts(ctx); @@ -2321,15 +2337,62 @@ impl WordApp { if let Some(wait) = self.session.recovery_wait() { ctx.request_repaint_after(wait); } + if !native { + self.window_input(ctx); + } + self.issue_screenshots(ctx); + self.send_control_input(ctx); + if !self.synthetic.is_empty() { + ctx.request_repaint(); + } + // The open above may have changed the file name the caption draws. + self.ensure_caption_fonts(); + self.ensure_comment_fonts(); + self.ensure_recent_fonts(); + self.ensure_find_fonts(); + self.install_ui_fonts(ctx, lang); + self.report_dirty(); + } + + /// A window's own work, with its state in place: print feedback for its document, Zotero, + /// Read Aloud, its file dialog, AutoSave of its document. Runs in [`WordApp::logic`] for the + /// active window and, with native windows, as each window draws. + fn window_work(&mut self, ctx: &egui::Context) { + if self.windows.native() { + let doc = self.session.document_id(); + if self.print_document.is_none_or(|d| d == doc || !self.session.is_open(d)) { + self.print_feedback(); + } + } + zotero::poll(self, ctx); + read_aloud::poll(self, ctx); + self.clear_stale_change_picture(); + let _ = self.poll_file_dialog(); self.autosave_tick(now_ms()); if self.autosaves() && self.session.dirty { ctx.request_repaint_after(std::time::Duration::from_millis(1000)); } - self.collect_screenshots(ctx); - self.issue_screenshots(ctx); - if !self.synthetic.is_empty() { - ctx.request_repaint(); + } + + /// A window's own input before it draws: the SmartArt Text Pane's Tab keys, ⌘Q, screenshots + /// of it, files dropped on it. Runs in [`WordApp::logic`] with one window, else as each + /// window draws (each native window has its own input). + fn window_input(&mut self, ctx: &egui::Context) { + // SmartArt Text Pane (#2701): Tab demotes and Shift+Tab promote the focused item, as Word's + // pane does. Consuming the key stops Tab from moving the caret to the next row, and so does + // resetting the focus direction: the item keeps focus after its level changes. + if self.session.view.smart_art_pane && self.smart_art_row_focused && self.smart_art_item.is_some() { + let shift_tab = ctx.input_mut(|i| i.consume_key(egui::Modifiers::SHIFT, egui::Key::Tab)); + let tab = ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Tab)); + if shift_tab || tab { + ctx.memory_mut(|m| m.move_focus(egui::FocusDirection::None)); + let _ = self.run(if shift_tab { "smartArt.promote" } else { "smartArt.demote" }, json!({"index": self.smart_art_item.unwrap_or(0)})); + } } + if self.windows.native() && ctx.input_mut(|i| i.consume_shortcut(&QUIT)) { + self.quit(); + } + self.collect_screenshots(ctx); #[cfg(not(target_arch = "wasm32"))] let mut placed_picture = false; #[cfg(not(target_arch = "wasm32"))] @@ -2354,13 +2417,12 @@ impl WordApp { }; } } - // The open above may have changed the file name the caption draws. - self.ensure_caption_fonts(); - self.ensure_comment_fonts(); - self.ensure_recent_fonts(); - self.ensure_find_fonts(); - self.install_ui_fonts(ctx, lang); - self.report_dirty(); + // The drawn window's caption, comments and Find results borrow faces it may not have. + if self.windows.native() { + self.ensure_caption_fonts(); + self.ensure_comment_fonts(); + self.ensure_find_fonts(); + } } /// Install the interface fonts when the language, the CJK fallback, or a caption fallback @@ -2369,17 +2431,29 @@ impl WordApp { let system_cjk = self.fonts_system_cjk || self.want_system_cjk || lang.uses_cjk(); if !self.styled || lang.prefers_hans() != self.fonts_hans || lang.prefers_korean() != self.fonts_korean || system_cjk != self.fonts_system_cjk { - theme::install_fonts_language_extra(ctx, lang.prefers_hans(), lang.prefers_korean(), system_cjk, &self.canvas.ime_faces); + // Every window's borrowed faces: the fonts are the app's. + let mut faces = self.canvas.ime_faces.clone(); + for w in self.other_windows() { + for face in &w.canvas.ime_faces { + if !faces.iter().any(|f| f.id() == face.id()) { + faces.push(face.clone()); + } + } + } + theme::install_fonts_language_extra(ctx, lang.prefers_hans(), lang.prefers_korean(), system_cjk, &faces); self.fonts_hans = lang.prefers_hans(); self.fonts_korean = lang.prefers_korean(); self.fonts_system_cjk = system_cjk; // Mod with -, = and 0 are Word shortcuts (optional hyphen, subscript, paragraph spacing); // egui's keyboard zoom would also scale the whole window on them. Zoom is View › Zoom. // egui quits on Command-Q, which is Ctrl+Q off the Mac: Word's Reset Paragraph - // Formatting there (#1218). Keep ⌘Q, which only the Mac sends. + // Formatting there (#1218). Keep ⌘Q, which only the Mac sends. egui would close only the + // window it was pressed in, so with native windows the app quits itself + // ([`WordApp::window_logic_before`]). + let quit = (!self.windows.native()).then_some(QUIT).into_iter().collect(); ctx.options_mut(|o| { o.zoom_with_keyboard = false; - o.quit_shortcuts = vec![egui::KeyboardShortcut::new(egui::Modifiers::MAC_CMD, egui::Key::Q)]; + o.quit_shortcuts = quit; }); self.styled = true; } @@ -2447,23 +2521,17 @@ impl WordApp { } /// Read a Control-click as the right-click on macOS, then inject synthetic input events from - /// the control channel (one pointer event per frame). + /// the control channel (one pointer event per frame) when the active window draws in the root + /// viewport; [`windows::ViewportInput`] does both for the other windows. pub fn raw_input_hook(&mut self, raw: &mut egui::RawInput) { if cfg!(target_os = "macos") { control_click_as_secondary(&mut raw.events, &mut self.control_click); } - if self.synthetic.is_empty() { + if self.ctx.as_ref().is_some_and(|ctx| !self.control_input_is_roots(ctx)) { return; } - let n = match self.synthetic.first() { - Some(egui::Event::PointerMoved(_) | egui::Event::PointerButton { .. }) => 1, - _ => self.synthetic.iter().position(|e| matches!(e, egui::Event::Key { pressed: false, .. })).map_or(self.synthetic.len(), |i| i + 1), - }; - if let Some(egui::Event::PointerMoved(p) | egui::Event::PointerButton { pos: p, .. }) = self.synthetic.first() { - raw.events.push(egui::Event::PointerMoved(*p)); - } - let n = n.min(self.synthetic.len()); - if self.synthetic.iter().take(n).any(|e| matches!(e, egui::Event::Key { .. } | egui::Event::Text(_))) { + let Some(events) = self.next_synthetic() else { return }; + if events.iter().any(|e| matches!(e, egui::Event::Key { .. } | egui::Event::Text(_))) { // Control input belongs to this window's remembered widget even while another app // is active. Give only this input frame egui focus, without requesting OS activation. raw.focused = true; @@ -2472,11 +2540,44 @@ impl WordApp { self.canvas.focused = ctx.memory(|m| m.has_focus(id)); } } - raw.events.extend(self.synthetic.drain(..n)); + raw.events.extend(events); } - /// Lay out the window. + /// The control channel's next input frame: one pointer event (after a move to it), or keys + /// and text up to the next key release. + fn next_synthetic(&mut self) -> Option> { + if self.synthetic.is_empty() { + return None; + } + let n = match self.synthetic.first() { + Some(egui::Event::PointerMoved(_) | egui::Event::PointerButton { .. }) => 1, + _ => self.synthetic.iter().position(|e| matches!(e, egui::Event::Key { pressed: false, .. })).map_or(self.synthetic.len(), |i| i + 1), + }; + let mut events = Vec::new(); + if let Some(egui::Event::PointerMoved(p) | egui::Event::PointerButton { pos: p, .. }) = self.synthetic.first() { + events.push(egui::Event::PointerMoved(*p)); + } + let n = n.min(self.synthetic.len()); + events.extend(self.synthetic.drain(..n)); + Some(events) + } + + /// Lay out the windows: on the desktop each open document's in its own native window + /// ([`windows`]), on the web the one window. pub fn ui(&mut self, ui: &mut egui::Ui) { + if self.windows.native() { + self.windows_ui(ui); + } else { + self.window_ui(ui); + } + } + + /// Lay out the drawn window. A command that brings another window's state in (New, Open, + /// Switch Windows) ends this window's drawing for the frame: the rest would be the other + /// window's. + fn window_ui(&mut self, ui: &mut egui::Ui) { + let window = self.windows.shown(); + let moved = |app: &WordApp| app.windows.shown() != window; let t0 = now_ms(); let ctx = ui.ctx().clone(); i18n::set_current(i18n::Lang::from_pref(&self.ui.language)); @@ -2517,38 +2618,58 @@ impl WordApp { chrome::screen_bar(self, ui, screen); } else { chrome::title_bar(self, ui); + if moved(self) { + return; + } ribbon::show(self, ui); + if moved(self) { + return; + } chrome::status_bar(self, ui); panes::show(self, ui); } + if moved(self) { + return; + } let fill = if screen == chrome::Screen::Focus { chrome::FOCUS_CANVAS } else { t.canvas }; egui::CentralPanel::default().frame(egui::Frame::NONE.fill(fill)).show(ui, |ui| { canvas::show(self, ui); }); - if self.focus_ribbon { + if self.focus_ribbon && !moved(self) { chrome::focus_ribbon(self, &ctx); } } + if moved(self) { + return; + } content_controls::popup(self, &ctx); dialogs::show(self, &ctx); + if moved(self) { + return; + } #[cfg(not(target_arch = "wasm32"))] file_browser::show(self, &ctx); colors::show(self, &ctx); zotero::show_alert(self, &ctx); read_aloud::show(self, &ctx); crate::keytips::show(self, &ctx, ui); + if moved(self) { + return; + } keys::global_shortcuts(self, &ctx); + if moved(self) { + return; + } self.refocus_after_chrome_click(&ctx); if let Some(url) = self.canvas.open_url.take() { ctx.open_url(egui::OpenUrl::new_tab(url)); } - let title = if self.has_document() { - format!("{}{} - WordCraft", self.title_stem(), if self.session.dirty { " •" } else { "" }) - } else { - "WordCraft".to_string() - }; + // With native windows the viewport driver gives the window its title. + let title = self.window_title(); if title != self.sent_title { - ctx.send_viewport_cmd(egui::ViewportCommand::Title(title.clone())); + if !self.windows.driven() { + ctx.send_viewport_cmd(egui::ViewportCommand::Title(title.clone())); + } self.sent_title = title; } self.sync_full_screen(&ctx); @@ -2700,12 +2821,14 @@ impl WordApp { self.control_rx = Some(rx); } + /// Ask the active window for the screenshots `ui.screenshot` queued. fn issue_screenshots(&mut self, ctx: &egui::Context) { let now = now_ms(); + let viewport = self.active_viewport(); self.queued_shots.retain_mut(|(token, at, frames)| { *frames += 1; if now >= *at && *frames >= 3 { - ctx.send_viewport_cmd(egui::ViewportCommand::Screenshot(egui::UserData::new(*token))); + ctx.send_viewport_cmd_to(viewport, egui::ViewportCommand::Screenshot(egui::UserData::new(*token))); false } else { true @@ -6707,7 +6830,8 @@ mod tests { assert!(!closes(&h), "Ctrl+Q doesn't close the window"); h.event(key(egui::Key::Q, egui::Modifiers::MAC_CMD | egui::Modifiers::COMMAND)); h.step(); - assert!(closes(&h), "⌘Q still quits on the Mac"); + // Quitting asks about the typed text first, in the document's window. + assert_eq!(h.state().dialog.as_ref().map(|d| d.name()), Some("saveChanges"), "⌘Q still quits on the Mac"); } /// #3424: Pressing Ctrl+Alt+Q (German shortcut for @, or AltGr+Q) types '@' and does not @@ -8909,7 +9033,8 @@ mod tests { let key = |key, modifiers| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }; h.event(egui::Event::Text("Kilns fire clay. Kilns cool.".into())); h.step(); - let nav_query = |h: &egui_kittest::Harness| h.ctx.data(|d| d.get_temp::(egui::Id::new("nav_query"))).unwrap_or_default(); + let nav_query = + |h: &egui_kittest::Harness| h.ctx.data(|d| d.get_temp::(h.state().window_id("nav_query"))).unwrap_or_default(); // Ctrl+F: the pane, and typing goes into its search box. h.event(key(egui::Key::F, egui::Modifiers::COMMAND)); h.step(); diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index 3b9afb6113..35eabcf428 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -122,7 +122,7 @@ fn nav(app: &mut WordApp, ui: &mut Ui) { let _ = app.run("view.navigationPane", json!({"value": false})); return; } - let qid = egui::Id::new("nav_query"); + let qid = app.window_id("nav_query"); let mut q = ui.data(|d| d.get_temp::(qid)).unwrap_or_default(); // Ctrl+F with text selected searches for it, as Word does (a selection within one line). let focus = std::mem::take(&mut app.nav_search_focus); diff --git a/crates/ui-egui/src/previews.rs b/crates/ui-egui/src/previews.rs index 721776f829..fd0fe5b539 100644 --- a/crates/ui-egui/src/previews.rs +++ b/crates/ui-egui/src/previews.rs @@ -522,7 +522,8 @@ pub fn style_gallery(app: &mut WordApp, ui: &mut Ui, state: &Value) { let rev = app.session.rev(); let skey = app.previews.styles_key(&app.session.doc, rev); let ppp = ui.ctx().pixels_per_point(); - let start = ui.data(|d| d.get_temp::(egui::Id::new("gallery_start"))).unwrap_or(0).min(styles.len().saturating_sub(1)); + let start_id = app.window_id("gallery_start"); + let start = ui.data(|d| d.get_temp::(start_id)).unwrap_or(0).min(styles.len().saturating_sub(1)); // A compact ribbon group (#662, #1285) trims the gallery to fewer tiles before it folds. let visible = if crate::ribbon_fit::compact(ui.ctx()) { STYLE_TILES_COMPACT } else { STYLE_TILES }; crate::ribbon_fit::add_saving(ui.ctx(), (STYLE_TILES - STYLE_TILES_COMPACT) as f32 * 76.0); @@ -595,7 +596,7 @@ pub fn style_gallery(app: &mut WordApp, ui: &mut Ui, state: &Value) { let _ = app.run("view.stylesPane", json!({"value": true})); } else { let n = if *delta < 0 { start.saturating_sub(visible) } else { (start + visible).min(styles.len().saturating_sub(1)) }; - ui.data_mut(|d| d.insert_temp(egui::Id::new("gallery_start"), n)); + ui.data_mut(|d| d.insert_temp(start_id, n)); } } } diff --git a/crates/ui-egui/src/read_aloud.rs b/crates/ui-egui/src/read_aloud.rs index 8d77a1bab0..ea275b477d 100644 --- a/crates/ui-egui/src/read_aloud.rs +++ b/crates/ui-egui/src/read_aloud.rs @@ -60,6 +60,14 @@ pub fn show(app: &mut WordApp, ctx: &egui::Context) { if !app.session.read_aloud.open { return; } + // With a window per document, the player is in the window of the document being read. + if app.windows.native() + && let Some(d) = app.session.read_aloud.document + && d != app.session.document_id() + && app.session.is_open(d) + { + return; + } let t = Tokens::get(ctx); let state = app.session.read_aloud.state(); let (index, count) = app.session.read_aloud.progress(); diff --git a/crates/ui-egui/src/window_ui.rs b/crates/ui-egui/src/window_ui.rs index d79827adf8..95c8b99333 100644 --- a/crates/ui-egui/src/window_ui.rs +++ b/crates/ui-egui/src/window_ui.rs @@ -151,7 +151,6 @@ impl WordApp { /// session's active document is not changed: the caller activates `w`'s document /// (`shown_document`) with it, or [`WordApp::sync_document`] treats the change as a switch /// within the window. - #[cfg_attr(not(test), expect(dead_code, reason = "drawing one native window per document swaps windows in"))] pub(crate) fn swap_window(&mut self, w: &mut WindowUi) { let WordApp { // App-wide. @@ -191,6 +190,8 @@ impl WordApp { saves_in_flight: _, recovery_seen: _, no_document: _, + windows: _, + print_document: _, // Per document: the shown document's, swapped with `parked` on a document switch. autosave_path: _, comment_drafts: _, diff --git a/crates/ui-egui/src/windows.rs b/crates/ui-egui/src/windows.rs new file mode 100644 index 0000000000..2ff535c23c --- /dev/null +++ b/crates/ui-egui/src/windows.rs @@ -0,0 +1,984 @@ +//! One native window per open document. Each window is an egui viewport, driven by +//! [`craft_window::Viewports`]: one window draws in the root viewport, every other one in an +//! immediate viewport of its own, and each draws the whole interface (title bar, ribbon, page, +//! status bar, dialogs) for its own document. [`WordApp`]'s fields hold the drawn window's state +//! ([`crate::window_ui`]); the other windows keep theirs in their [`Window`]. Between frames the +//! fields hold the active window's (the one focused last), so the control channel, the MCP +//! bridge and the host's menus act on it. +//! +//! A frame: [`WordApp::logic`] does the app-wide work once (the control channel, recovery, +//! timers, printing). [`WordApp::ui`] then draws the windows in turn: for each it swaps the +//! window's state in, makes its document the session's active one, does the window's own work +//! (its file dialog, AutoSave, dropped files, Read Aloud, requests queued for it) and draws it. +//! Last, the active window's state comes back. +//! +//! Which window shows what follows the session ([`WordApp::sync_document`]): a document made +//! active that no window shows (New, Open) gets a new window, focused and cascaded from the last +//! one; a document a window shows brings that window to the front; a window whose document closed +//! closes, and the window focused before it comes forward. Opening a file replaces a blank +//! document nobody used in its own window, and the last window stays open with no document (the +//! empty window the Start screen also leaves). The web app has one window and switches documents +//! in it ([`crate::documents`]). + +use std::collections::HashMap; + +use craft_window::{Document, Frame, Viewports, WindowEvent, WindowId, WindowList, WindowMenu}; +use egui::{ViewportCommand, ViewportId}; +use serde_json::{Value, json}; + +use crate::{WordApp, documents, window_ui::WindowUi}; + +/// The window title for a document called `name`. +pub(crate) fn title(name: &str, dirty: bool) -> String { + format!("{name}{} - WordCraft", if dirty { " •" } else { "" }) +} + +/// An open window. +pub(crate) struct Window { + /// The app's key for the window. A window made while the windows are being drawn gets its + /// [`WindowId`] once the frame's drawing ends. + key: u64, + id: Option, + /// The document it shows. + doc: u64, + /// Its state while another window's is in [`WordApp`]'s fields; `None` for that window. + ui: Option>, + /// Requests commands made for its document while another window was drawn + /// ([`WordApp::handle_request`]), handled when it draws next. + requests: Vec, + /// The title it last drew, for its viewport. + title: String, +} + +/// The app's windows. See the module documentation. +pub struct Windows { + /// Separate native windows (desktop). Off, one window shows every document in turn (web). + native: bool, + /// Every window, with its key, for the viewport driver: opening and focus order, positions. + /// Lent to the driver while the windows draw. + list: WindowList, + viewports: Option, + /// Open windows in opening order. + open: Vec, + /// The window whose state is in [`WordApp`]'s fields. + shown: u64, + next_key: u64, + /// The windows are being drawn: `list` is lent out, so windows opened or closed now join or + /// leave it when the drawing ends. + showing: bool, + /// Window keys, most recently focused first, as the drawing started. + recent: Vec, + /// A window to bring to the front when the drawing ends. + focus: Option, + /// View › Window › Switch Windows and the window titles, as the frame's drawing started. + menu: WindowMenu, + /// What a new window's viewport starts from: size, icon, app id ([`WordApp::set_window_builder`]). + builder: egui::ViewportBuilder, + /// [`ViewportInput`] is registered with egui. + input_plugin: bool, + /// The viewport driver draws the windows this frame, and titles them. + driven: bool, +} + +impl Windows { + /// One window, showing document `doc`. + pub(crate) fn new(doc: u64) -> Windows { + let mut list = WindowList::new(); + let id = list.open(1); + let mut viewports = Viewports::new(Frame::Desktop); + viewports.set_root(Some(id)); + Windows { + native: cfg!(not(target_arch = "wasm32")), + list, + viewports: Some(viewports), + open: vec![Window { key: 1, id: Some(id), doc, ui: None, requests: Vec::new(), title: String::new() }], + shown: 1, + next_key: 2, + showing: false, + recent: vec![1], + focus: None, + menu: WindowMenu::default(), + builder: egui::ViewportBuilder::default().with_inner_size([1440.0, 920.0]).with_min_inner_size([760.0, 480.0]), + input_plugin: false, + driven: false, + } + } + + /// Whether documents get native windows of their own. + pub(crate) fn native(&self) -> bool { + self.native + } + + /// One window for every document, as in the web app. + #[cfg(test)] + pub(crate) fn single(&mut self) { + self.native = false; + } + + /// Whether the viewport driver titles the windows (else the drawn window sends its title). + pub(crate) fn driven(&self) -> bool { + self.driven + } + + /// The key of the window whose state is in [`WordApp`]'s fields. + pub(crate) fn shown(&self) -> u64 { + self.shown + } + + fn get(&self, key: u64) -> Option<&Window> { + self.open.iter().find(|w| w.key == key) + } + + fn get_mut(&mut self, key: u64) -> Option<&mut Window> { + self.open.iter_mut().find(|w| w.key == key) + } + + /// The window showing document `doc`, the most recently focused if several do. + fn of_doc(&self, doc: u64) -> Option { + self.recent_keys().into_iter().find(|&k| self.get(k).is_some_and(|w| w.doc == doc)) + } + + /// Open windows, most recently focused first; windows opened during this frame's drawing + /// come first. + fn recent_keys(&self) -> Vec { + let mut keys: Vec = self.open.iter().rev().filter(|w| w.id.is_none()).map(|w| w.key).collect(); + let listed: Vec = + if self.showing { self.recent.clone() } else { self.list.recent().filter_map(|id| self.list.get(id).copied()).collect() }; + keys.extend(listed.into_iter().filter(|&k| self.get(k).is_some())); + for w in &self.open { + if !keys.contains(&w.key) { + keys.push(w.key); + } + } + keys + } + + /// The document of the window focused last before `key`'s, if any. + fn next_doc(&self, key: u64) -> Option { + if !self.native { + return None; + } + self.recent_keys().into_iter().find(|&k| k != key).and_then(|k| self.get(k)).map(|w| w.doc) + } + + /// The window's viewport. + fn viewport(&self, key: u64) -> ViewportId { + match (self.get(key).and_then(|w| w.id), &self.viewports) { + (Some(id), Some(v)) => v.viewport_id(id), + _ => ViewportId::ROOT, + } + } + + /// The title View › Window › Switch Windows gives the window, as the frame started. + fn menu_title(&self, key: u64) -> Option<&str> { + self.menu.title(self.get(key)?.id?) + } + + /// Bring the list up to date with the windows opened, closed and focused since it was last + /// lent out, and give the root viewport a window when its own closed. + fn settle(&mut self, ctx: Option<&egui::Context>) { + if self.showing { + return; + } + let gone: Vec = self.list.ids().filter(|id| !self.open.iter().any(|w| w.id == Some(*id))).collect(); + for id in gone { + self.list.close(id); + } + let active = self.list.active(); + let mut opened = false; + for w in self.open.iter_mut().filter(|w| w.id.is_none()) { + w.id = Some(self.list.open(w.key)); + opened = true; + } + let focus = self.focus.take().and_then(|k| self.get(k)).and_then(|w| w.id); + // A window opened behind the others (nothing asked to focus it) leaves the active one so. + if let Some(id) = focus.or(active.filter(|_| opened)) { + self.list.focus(id); + } + let Some(viewports) = self.viewports.as_mut() else { return }; + if !viewports.root().is_some_and(|r| self.list.contains(r)) + && let Some(next) = self.list.active() + { + match ctx { + Some(ctx) => viewports.adopt(ctx, &self.list, next), + None => viewports.set_root(Some(next)), + } + } + if let (Some(id), Some(ctx)) = (focus, ctx) { + viewports.focus(ctx, id); + } + if opened && let Some(ctx) = ctx { + ctx.request_repaint(); + } + } +} + +/// Control-channel input for a window in a viewport of its own: eframe's raw-input hook +/// ([`WordApp::raw_input_hook`]) sees only the root viewport's input, and egui runs a plugin's +/// input hook for every viewport. On macOS it also reads Control-click as the secondary button +/// in those windows, as the raw-input hook does in the root's. +#[derive(Default)] +pub(crate) struct ViewportInput { + /// The viewport the queued events go to, on its next pass. + target: Option, + events: Vec, + /// A viewport whose pass got keyboard input this way, until its window has looked. + keys_for: Option, + control_click: HashMap, +} + +impl egui::Plugin for ViewportInput { + fn debug_name(&self) -> &'static str { + "wordcraft_viewport_input" + } + + fn input_hook(&mut self, _ctx: &egui::Context, input: &mut egui::RawInput) { + let viewport = input.viewport_id; + if viewport == ViewportId::ROOT { + return; + } + if cfg!(target_os = "macos") { + crate::control_click_as_secondary(&mut input.events, self.control_click.entry(viewport).or_default()); + } + if self.target != Some(viewport) || self.events.is_empty() { + return; + } + self.target = None; + let events = std::mem::take(&mut self.events); + if events.iter().any(|e| matches!(e, egui::Event::Key { .. } | egui::Event::Text(_))) { + input.focused = true; + self.keys_for = Some(viewport); + } + input.events.extend(events); + } +} + +impl WordApp { + /// How many windows are open. + pub fn window_count(&self) -> usize { + if self.windows.native { self.windows.open.len() } else { 1 } + } + + /// The egui viewport of the active window, the one focused last: control input, + /// `ui.screenshot`, `ui.resize` and the saved window geometry are its. + pub fn active_viewport(&self) -> ViewportId { + if !self.windows.native { + return ViewportId::ROOT; + } + let key = self.windows.recent_keys().first().copied().unwrap_or(self.windows.shown); + self.windows.viewport(key) + } + + /// The viewport new windows start from: the host's size, icon and app id. Their title and + /// place are the app's. + pub fn set_window_builder(&mut self, builder: egui::ViewportBuilder) { + self.windows.builder = builder; + } + + /// The root viewport was asked to close while other windows are open: only its window closes + /// (through [`WindowEvent::CloseRequested`] in [`WordApp::ui`]), so the host keeps the app + /// running. + pub fn closes_a_window(&self) -> bool { + self.window_count() > 1 && !self.quit_requested + } + + /// Quit the app (the app menu's Quit): ask about every document with unsaved work, each in + /// its window, as [`WordApp::close_requested`] does. True when the app may quit now; the + /// host closes it once `quit_requested` is set. + pub fn quit(&mut self) -> bool { + let go = self.close_requested(); + if go { + self.quit_requested = true; + } + go + } + + /// Put the drawn window's state away and draw window `key`, with its document active. False + /// when there is no such window. + pub(crate) fn enter_window(&mut self, key: u64) -> bool { + if key == self.windows.shown { + return true; + } + let Some(mut state) = self.windows.get_mut(key).and_then(|w| w.ui.take()) else { + return false; + }; + let from = self.shown_document; + self.swap_window(&mut state); + let left = std::mem::replace(&mut self.windows.shown, key); + if let Some(w) = self.windows.get_mut(left) { + w.doc = state.shown_document; + w.ui = Some(state); + } + let to = self.shown_document; + self.carry_document_state(from, to); + true + } + + /// Open a new window for open document `doc`, focused, and draw it. The window that was drawn + /// leaves the File page: whatever it was opening is open. + fn open_window(&mut self, doc: u64) { + let key = self.windows.next_key; + self.windows.next_key = self.windows.next_key.saturating_add(1); + let mut state = Box::new(WindowUi::new(doc)); + // Faces borrowed for the interface (non-Latin file names) stay installed. + state.canvas.ime_faces = self.canvas.ime_faces.clone(); + self.ui.backstage = false; + self.windows.open.push(Window { key, id: None, doc, ui: Some(state), requests: Vec::new(), title: String::new() }); + self.enter_window(key); + self.focus_window(key); + } + + /// The drawn window's document has closed: draw window `to` and close this one. + fn close_shown_window(&mut self, to: u64) { + let closing = self.windows.shown; + if !self.enter_window(to) { + return; + } + self.windows.open.retain(|w| w.key != closing); + self.focus_window(to); + } + + /// Bring window `key` to the front (now, or once the frame's drawing ends) and make it the + /// active one. + pub(crate) fn focus_window(&mut self, key: u64) { + self.windows.focus = Some(key); + self.windows.settle(self.ctx.as_ref()); + } + + /// Keep one window per open document: windows whose document closed (an agent closed it) + /// close, and documents with no window (an agent made one while another was active) get one + /// behind the others. The active document is left to [`WordApp::sync_document`]. + pub(crate) fn prune_windows(&mut self) { + let shown = self.windows.shown; + let session = &self.session; + let before = self.windows.open.len(); + self.windows.open.retain(|w| w.key == shown || session.is_open(w.doc)); + let mut changed = self.windows.open.len() != before; + let active = self.session.document_id(); + for d in self.session.documents() { + if d.id == active || Some(d.id) == self.no_document || d.id == self.shown_document || self.windows.of_doc(d.id).is_some() { + continue; + } + let key = self.windows.next_key; + self.windows.next_key = self.windows.next_key.saturating_add(1); + let mut state = Box::new(WindowUi::new(d.id)); + state.canvas.ime_faces = self.canvas.ime_faces.clone(); + self.windows.open.push(Window { key, id: None, doc: d.id, ui: Some(state), requests: Vec::new(), title: String::new() }); + changed = true; + } + if changed { + self.windows.settle(self.ctx.as_ref()); + } + } + + /// [`WordApp::sync_document`] with native windows: the session's active document `id` isn't + /// the drawn window's. Another open document shows in its window, which comes to the front, + /// or in a new window; when the drawn window's document has closed, its window closes and the + /// one showing `id` comes forward. False when the window itself is to show `id` instead: its + /// document was replaced, or it is the Start screen's empty window. + pub(crate) fn follow_in_windows(&mut self, id: u64) -> bool { + let left = self.shown_document; + let stand_in = self.no_document == Some(left); + if self.session.is_open(left) && !stand_in { + match self.windows.of_doc(id).filter(|&k| k != self.windows.shown) { + Some(key) => { + self.enter_window(key); + self.focus_window(key); + } + None => self.open_window(id), + } + return true; + } + if !self.session.is_open(left) + && let Some(key) = self.windows.of_doc(id).filter(|&k| k != self.windows.shown) + { + self.close_shown_window(key); + return true; + } + false + } + + /// The drawn window now shows document `id` (its own was replaced). + pub(crate) fn window_shows(&mut self, id: u64) { + let shown = self.windows.shown; + if let Some(w) = self.windows.get_mut(shown) { + w.doc = id; + } + } + + /// Close the active document. With windows, its window closes and the window focused before + /// it comes forward; else the window shows the next document. The session leaves a new blank + /// document when it was the last. + pub(crate) fn close_shown_document(&mut self) -> Result<(), String> { + let doc = self.session.document_id(); + let next = self.windows.next_doc(self.windows.shown); + self.session.close_document(doc).map_err(|e| e.to_string())?; + if let Some(next) = next + && self.session.is_open(next) + && let Err(e) = self.session.switch_document(next) + { + log::warn!("closing document {doc}: couldn't show document {next}: {e}"); + } + self.sync_document(); + Ok(()) + } + + /// A request made for open document `document`, which another window shows: that window + /// handles it when it draws next, and comes to the front when the request opens something + /// (a dialog, the Print page). False when no other window shows the document (or windows + /// aren't native). + pub(crate) fn queue_for_window(&mut self, document: u64, req: &Value) -> bool { + if !self.windows.native { + return false; + } + let Some(key) = self.windows.of_doc(document).filter(|&k| k != self.windows.shown) else { return false }; + let opens = req.get("open").is_some(); + if opens && !self.windows.showing { + // Between frames: the window comes forward and opens it now. + self.enter_window(key); + self.focus_window(key); + self.handle_request(req); + return true; + } + if let Some(w) = self.windows.get_mut(key) { + w.requests.push(req.clone()); + } + if opens { + self.focus_window(key); + } + if let Some(ctx) = &self.ctx { + ctx.request_repaint(); + } + true + } + + /// View › Window › Switch Windows with native windows: every window by its title, the active + /// one marked, unsaved ones with the title bar's dot. Choosing one brings it to the front. + pub(crate) fn switch_windows_entries(&self) -> Vec<(u64, String, bool)> { + self.windows + .menu + .entries() + .iter() + .filter_map(|e| { + let w = self.windows.open.iter().find(|w| w.id == Some(e.id))?; + Some((w.doc, format!("{}{}", e.title, if e.dirty { " •" } else { "" }), w.key == self.windows.shown)) + }) + .collect() + } + + /// The drawn window's title: its document's name as View › Window › Switch Windows gives it. + pub(crate) fn window_title(&self) -> String { + if !self.has_document() { + return "WordCraft".to_string(); + } + let name = self.windows.menu_title(self.windows.shown).map(str::to_string).unwrap_or_else(|| self.title_stem()); + title(&name, self.session.dirty) + } + + /// Draw every window, each in its viewport (see the module documentation). + pub(crate) fn windows_ui(&mut self, ui: &mut egui::Ui) { + let ctx = ui.ctx().clone(); + self.prune_windows(); + self.windows.settle(Some(&ctx)); + self.windows.menu = self.window_menu(&self.windows.list); + // A backend that can't open native windows (egui embeds them in the root: tests, + // `ui_shot`) shows the active window alone. Embedded, every window would draw the same + // widgets in one viewport. + self.windows.driven = !ctx.embed_viewports() && self.windows.viewports.is_some(); + if !self.windows.driven { + let active = self.windows.recent_keys().first().copied().unwrap_or(self.windows.shown); + self.draw_window(ui, active); + self.after_windows(&ctx); + return; + } + let Some(mut viewports) = self.windows.viewports.take() else { return }; + let mut list = std::mem::take(&mut self.windows.list); + self.windows.recent = list.recent().filter_map(|id| list.get(id).copied()).collect(); + let titles: HashMap = self.windows.open.iter().map(|w| (w.key, w.title.clone())).collect(); + let base = self.windows.builder.clone(); + self.windows.showing = true; + let events = viewports.show( + ui, + &mut list, + |_, key| match titles.get(key).filter(|t| !t.is_empty()) { + Some(t) => base.clone().with_title(t.clone()), + None => base.clone().with_title("WordCraft"), + }, + |ui, _, key| self.draw_window(ui, *key), + ); + self.windows.showing = false; + self.windows.list = list; + self.windows.viewports = Some(viewports); + self.window_events(&ctx, &events); + self.after_windows(&ctx); + } + + /// After the frame's drawing: windows opened, closed or focused meanwhile settle, and the + /// active window's state comes back. + fn after_windows(&mut self, ctx: &egui::Context) { + self.prune_windows(); + self.windows.settle(Some(ctx)); + if let Some(&active) = self.windows.recent_keys().first() { + self.enter_window(active); + } + } + + /// View › Window › Switch Windows for the windows in `list`. + fn window_menu(&self, list: &WindowList) -> WindowMenu { + let docs = self.session.documents(); + let doc_of: HashMap = self.windows.open.iter().map(|w| (w.key, w.doc)).collect(); + WindowMenu::new(list, |_, key| { + let doc = doc_of.get(key).copied().unwrap_or_default(); + match docs.iter().find(|d| d.id == doc) { + Some(d) => Document { key: doc, name: documents::display_title(d), dirty: d.dirty }, + None => Document { key: doc, name: String::new(), dirty: false }, + } + }) + } + + /// Draw window `key` into its viewport's `ui`. + fn draw_window(&mut self, ui: &mut egui::Ui, key: u64) { + if !self.enter_window(key) { + return; + } + let ctx = ui.ctx().clone(); + let queued = self.windows.get_mut(key).map(|w| std::mem::take(&mut w.requests)).unwrap_or_default(); + for req in queued { + self.handle_request(&req); + } + self.control_keys_arrived(&ctx); + self.window_work(&ctx); + self.window_input(&ctx); + if self.windows.shown != key { + return; + } + self.window_ui(ui); + let title = self.sent_title.clone(); + if let Some(w) = self.windows.get_mut(key) { + w.title = title; + } + } + + /// The windows' focus changes and close requests from this frame. + fn window_events(&mut self, ctx: &egui::Context, events: &[WindowEvent]) { + for event in events { + let WindowEvent::CloseRequested(id) = *event else { continue }; + let Some(key) = self.windows.list.get(id).copied() else { continue }; + // The last window's close button quits: the host asks first ([`WordApp::close_requested`]). + if self.windows.open.len() <= 1 || self.quit_requested { + continue; + } + if self.windows.viewports.as_ref().and_then(Viewports::root) == Some(id) { + ctx.send_viewport_cmd_to(ViewportId::ROOT, ViewportCommand::CancelClose); + } + self.close_window(key); + } + } + + /// The close button of window `key` (not the last one): File › Close in that window, which + /// asks about unsaved changes first. + pub(crate) fn close_window(&mut self, key: u64) { + if !self.enter_window(key) { + return; + } + self.focus_window(key); + if !self.has_document() { + // An empty window beside others: nothing to ask about. + let _ = self.close_shown_document(); + return; + } + if let Err(e) = self.run("file.close", json!({})) { + log::warn!("closing a window: {e}"); + } + } + + /// The control channel's next input, for the active window when it has a viewport of its own: + /// handed to [`ViewportInput`], which gives it to that viewport's next pass. + pub(crate) fn send_control_input(&mut self, ctx: &egui::Context) { + if self.synthetic.is_empty() || self.control_input_is_roots(ctx) { + return; + } + if !self.windows.input_plugin { + ctx.add_plugin(ViewportInput::default()); + self.windows.input_plugin = true; + } + let target = self.active_viewport(); + let waiting = ctx.with_plugin(|p: &mut ViewportInput| !p.events.is_empty()).unwrap_or(true); + if waiting { + return; + } + let Some(events) = self.next_synthetic() else { return }; + ctx.with_plugin(|p: &mut ViewportInput| { + p.target = Some(target); + p.events = events; + }); + ctx.request_repaint_of(target); + } + + /// Whether control input goes in through the root viewport's raw input: one window, the + /// active window drawn there, or viewports embedded in the root (tests). + pub(crate) fn control_input_is_roots(&self, ctx: &egui::Context) -> bool { + !self.windows.native || ctx.embed_viewports() || self.active_viewport() == ViewportId::ROOT + } + + /// Keys from the control channel reached this window's viewport: the page counts as focused + /// when its widget has the keyboard, as [`WordApp::raw_input_hook`] does for the root. + fn control_keys_arrived(&mut self, ctx: &egui::Context) { + if !self.windows.input_plugin { + return; + } + let viewport = ctx.viewport_id(); + let arrived = ctx + .with_plugin(|p: &mut ViewportInput| { + let hit = p.keys_for == Some(viewport); + if hit { + p.keys_for = None; + } + hit + }) + .unwrap_or(false); + if arrived && let Some((id, _)) = self.canvas.widget_id { + self.canvas.focused = ctx.memory(|m| m.has_focus(id)); + } + } + + /// The id of the drawn window's own `name` in egui's memory, which every viewport shares: a + /// search box's text, a gallery's first tile. + pub(crate) fn window_id(&self, name: &'static str) -> egui::Id { + egui::Id::new(name).with(self.windows.shown) + } + + /// The windows other than the drawn one, for state every window contributes to (the faces + /// borrowed for the interface). + pub(crate) fn other_windows(&self) -> impl Iterator { + self.windows.open.iter().filter_map(|w| w.ui.as_deref()) + } +} + +#[cfg(test)] +mod tests { + use std::cell::RefCell; + use std::collections::HashMap; + + use egui::{RawInput, ViewportCommand, ViewportEvent, ViewportId, ViewportIdMap, ViewportInfo}; + use serde_json::json; + use wordcraft_doc::StoryRef; + use wordcraft_engine::Session; + + use crate::{Services, WordApp}; + + thread_local! { + /// What the test backend tells each viewport about itself (focus, close requests). + static INFOS: RefCell> = RefCell::new(ViewportIdMap::default()); + /// Input events for each viewport's next pass. + static EVENTS: RefCell>> = RefCell::new(HashMap::new()); + /// The viewports that drew in the last frame, in order. + static DRAWN: RefCell> = const { RefCell::new(Vec::new()) }; + /// Viewport commands the app sent, by viewport. + static COMMANDS: RefCell>> = RefCell::new(HashMap::new()); + /// Textures uploaded (page rasters, the font atlas) since the counter was reset. + static UPLOADS: RefCell = const { RefCell::new(0) }; + /// Each viewport's last input-method output. + static IME: RefCell>> = RefCell::new(HashMap::new()); + } + + fn input(id: ViewportId) -> RawInput { + let mut raw = RawInput { + viewport_id: id, + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1440.0, 900.0))), + ..Default::default() + }; + raw.viewports = INFOS.with(|i| i.borrow().clone()); + raw.focused = raw.viewports.entry(id).or_default().focused.unwrap_or(false); + raw.events = EVENTS.with(|e| e.borrow_mut().remove(&id).unwrap_or_default()); + raw + } + + fn keep(out: egui::FullOutput, id: ViewportId) { + DRAWN.with(|d| d.borrow_mut().push(id)); + UPLOADS.with(|u| *u.borrow_mut() += out.textures_delta.set.len()); + IME.with(|i| i.borrow_mut().insert(id, out.platform_output.ime)); + for (v, o) in &out.viewport_output { + COMMANDS.with(|c| c.borrow_mut().entry(*v).or_default().extend(o.commands.iter().cloned())); + } + out.drop_without_applying_deltas(); + } + + /// A backend with native viewports, as eframe's: each immediate viewport runs a pass of its + /// own, with its own input. + fn backend() -> egui::Context { + INFOS.with(|i| i.borrow_mut().clear()); + EVENTS.with(|e| e.borrow_mut().clear()); + COMMANDS.with(|c| c.borrow_mut().clear()); + UPLOADS.with(|u| *u.borrow_mut() = 0); + let ctx = egui::Context::default(); + ctx.set_embed_viewports(false); + egui::Context::set_immediate_viewport_renderer(|ctx, viewport| { + let id = viewport.ids.this; + let mut raw = input(id); + raw.viewports.entry(id).or_default().parent = Some(viewport.ids.parent); + let mut cb = viewport.viewport_ui_cb; + keep(ctx.run_ui(raw, |ui| cb(ui)), id); + }); + ctx + } + + /// One frame of every window. + fn frame(ctx: &egui::Context, a: &mut WordApp) { + DRAWN.with(|d| d.borrow_mut().clear()); + let mut raw = input(ViewportId::ROOT); + a.raw_input_hook(&mut raw); + let out = ctx.run_ui(raw, |ui| { + a.logic(ui.ctx()); + a.ui(ui); + }); + keep(out, ViewportId::ROOT); + INFOS.with(|i| i.borrow_mut().values_mut().for_each(|v| v.events.clear())); + } + + fn frames(ctx: &egui::Context, a: &mut WordApp, n: usize) { + for _ in 0..n { + frame(ctx, a); + } + } + + /// Focus viewport `id` alone, as the desktop does when a window is clicked. + fn focus(id: ViewportId) { + INFOS.with(|i| { + let mut i = i.borrow_mut(); + for v in i.values_mut() { + v.focused = Some(false); + } + i.entry(id).or_default().focused = Some(true); + }); + } + + fn sent(id: ViewportId, cmd: &ViewportCommand) -> bool { + COMMANDS.with(|c| c.borrow().get(&id).is_some_and(|v| v.contains(cmd))) + } + + fn app(text: &str) -> WordApp { + let mut a = WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default()); + a.run("text.insert", json!({"text": text})).unwrap(); + a + } + + fn text(a: &WordApp) -> String { + a.session.doc.plain_text(StoryRef::Body).trim().to_string() + } + + fn prompt(a: &WordApp) -> Option<&'static str> { + a.dialog.as_ref().map(|d| d.name()) + } + + /// New opens the document in a second native window, focused, with a fresh interface; the + /// first keeps its document, its ribbon tab and its open dialog, and gets them back when it + /// is focused again. + #[test] + fn new_opens_a_second_window_and_each_keeps_its_state() { + let ctx = backend(); + let mut a = app("First"); + let first = a.session.document_id(); + frames(&ctx, &mut a, 3); + a.ui.tab = "Insert".into(); + a.run("ui.dialog", json!({"name": "paragraph"})).unwrap(); + assert_eq!(prompt(&a), Some("paragraph")); + + a.run("file.new", json!({})).unwrap(); + let second = a.session.document_id(); + assert_ne!(second, first); + assert_eq!(a.window_count(), 2); + assert!(a.dialog.is_none() && a.ui.tab == crate::DEFAULT_TAB, "the new window starts afresh"); + frames(&ctx, &mut a, 3); + let child = a.active_viewport(); + assert_ne!(child, ViewportId::ROOT, "the new window has a viewport of its own"); + assert_eq!(DRAWN.with(|d| d.borrow().clone()), [child, ViewportId::ROOT], "both windows draw each frame"); + frames(&ctx, &mut a, 3); + assert!(UPLOADS.with(|u| *u.borrow()) > 0, "the pages were rendered"); + UPLOADS.with(|u| *u.borrow_mut() = 0); + frames(&ctx, &mut a, 2); + assert_eq!(UPLOADS.with(|u| *u.borrow()), 0, "a window whose document didn't change renders no page again"); + assert!(a.sent_title.starts_with("Document2"), "{}", a.sent_title); + assert_eq!(a.session.document_id(), second); + a.run("text.insert", json!({"text": "Second"})).unwrap(); + + focus(ViewportId::ROOT); + frames(&ctx, &mut a, 2); + assert_eq!(a.active_viewport(), ViewportId::ROOT); + assert_eq!(a.session.document_id(), first); + assert_eq!((text(&a), a.ui.tab.as_str(), prompt(&a)), ("First".into(), "Insert", Some("paragraph"))); + focus(child); + frames(&ctx, &mut a, 2); + assert_eq!((text(&a), a.ui.tab.as_str(), prompt(&a)), ("Second".into(), crate::DEFAULT_TAB, None)); + } + + fn docx_on_disk(name: &str, body: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!("wordcraft-windows-{name}-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join(format!("{name}.docx")); + let mut s = Session::new(wordcraft_doc::Document::new()); + s.run("text.insert", &json!({"text": body})).unwrap(); + s.run("file.save", &json!({"path": path.to_string_lossy()})).unwrap(); + path + } + + /// Open of a file a window already shows brings that window to the front; Open with only an + /// untouched blank document open replaces it in its own window. + #[test] + fn open_focuses_the_files_window_or_reuses_a_blank_one() { + let path = docx_on_disk("report", "Report"); + let ctx = backend(); + let mut a = WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default()); + frames(&ctx, &mut a, 2); + let window = a.windows.shown(); + a.run("file.open", json!({"path": path.to_string_lossy()})).unwrap(); + assert_eq!((a.window_count(), a.windows.shown(), text(&a).as_str()), (1, window, "Report"), "the blank document's window shows it"); + let report = a.session.document_id(); + + a.run("file.new", json!({})).unwrap(); + frames(&ctx, &mut a, 2); + assert_eq!(a.window_count(), 2); + assert_ne!(a.session.document_id(), report); + COMMANDS.with(|c| c.borrow_mut().clear()); + let r = a.run("file.open", json!({"path": path.to_string_lossy()})).unwrap(); + assert_eq!(r["alreadyOpen"], true); + assert_eq!((a.window_count(), a.windows.shown(), a.session.document_id()), (2, window, report)); + frames(&ctx, &mut a, 1); + assert!(sent(ViewportId::ROOT, &ViewportCommand::Focus), "the file's window comes to the front"); + assert_eq!(a.session.document_id(), report); + std::fs::remove_dir_all(path.parent().unwrap()).ok(); + } + + /// A window's close button closes it, asking first about unsaved changes; after Don't Save the + /// other window comes forward. When the root viewport's window closes, the other window moves + /// into the root viewport. + #[test] + fn closing_a_window_asks_first_and_the_root_adopts_another() { + let ctx = backend(); + let mut a = app("Kept"); + a.run("file.save", json!({"path": docx_on_disk("kept", "Kept").to_string_lossy()})).unwrap(); + let kept = a.session.document_id(); + frames(&ctx, &mut a, 2); + a.run("file.new", json!({})).unwrap(); + a.run("text.insert", json!({"text": "Dropped"})).unwrap(); + frames(&ctx, &mut a, 2); + let child = a.active_viewport(); + + INFOS.with(|i| i.borrow_mut().entry(child).or_default().events.push(ViewportEvent::Close)); + frame(&ctx, &mut a); + assert_eq!(prompt(&a), Some("saveChanges"), "the dirty window asks"); + assert_eq!((a.window_count(), a.active_viewport()), (2, child)); + a.run("ui.saveChanges", json!({"answer": "dontSave"})).unwrap(); + assert_eq!((a.window_count(), a.session.document_id(), text(&a).as_str()), (1, kept, "Kept")); + assert_eq!(a.active_viewport(), ViewportId::ROOT); + frames(&ctx, &mut a, 2); + assert_eq!(DRAWN.with(|d| d.borrow().clone()), [ViewportId::ROOT], "the closed window's viewport is gone"); + + a.run("file.new", json!({})).unwrap(); + let other = a.windows.shown(); + frames(&ctx, &mut a, 2); + COMMANDS.with(|c| c.borrow_mut().clear()); + INFOS.with(|i| i.borrow_mut().entry(ViewportId::ROOT).or_default().events.push(ViewportEvent::Close)); + assert!(a.closes_a_window(), "the host leaves the root's close to the app"); + frame(&ctx, &mut a); + assert!(sent(ViewportId::ROOT, &ViewportCommand::CancelClose), "the app keeps running"); + assert!(!a.quit_requested); + assert_eq!((a.window_count(), a.windows.shown()), (1, other)); + assert_eq!(a.active_viewport(), ViewportId::ROOT, "the remaining window moved into the root viewport"); + frames(&ctx, &mut a, 2); + assert_eq!(DRAWN.with(|d| d.borrow().clone()), [ViewportId::ROOT]); + assert!(a.has_document() && a.session.documents().len() == 1); + } + + /// A request an agent's command makes for the document of another window is that window's: + /// Print opens the Print page there and brings it forward, and the window that was active + /// keeps its own page. + #[test] + fn a_request_for_another_windows_document_is_handled_there() { + let ctx = backend(); + let mut a = app("First"); + let first = a.session.document_id(); + frames(&ctx, &mut a, 2); + a.run("file.new", json!({})).unwrap(); + let second = a.windows.shown(); + frames(&ctx, &mut a, 2); + a.session.with_document(first, |s| s.run("file.print", &json!({}))).unwrap().unwrap(); + for req in std::mem::take(&mut a.session.ui_requests) { + assert_eq!(req["document"], first); + a.handle_request(&req); + } + assert_eq!(a.session.document_id(), first, "the first window comes forward"); + assert!(a.ui.backstage && a.ui.backstage_page == "print"); + frames(&ctx, &mut a, 2); + assert_eq!(a.active_viewport(), ViewportId::ROOT); + assert!(a.ui.backstage, "the Print page stays in its window"); + let other = a.windows.get(second).and_then(|w| w.ui.as_ref()).map(|w| w.backstage); + assert_eq!(other, Some(false), "the other window keeps its page"); + } + + /// A composition in one window shows and commits only there: the other window draws no + /// composition and its document gets none of the text. + #[test] + fn a_composition_stays_in_its_window() { + let ctx = backend(); + let mut a = app("A"); + frames(&ctx, &mut a, 2); + a.run("file.new", json!({})).unwrap(); + a.run("text.insert", json!({"text": "B"})).unwrap(); + let b = a.active_viewport(); + focus(ViewportId::ROOT); + frames(&ctx, &mut a, 8); + assert_eq!(a.active_viewport(), ViewportId::ROOT); + assert!(a.canvas.focused, "the page has the keyboard"); + EVENTS.with(|e| { + e.borrow_mut() + .entry(ViewportId::ROOT) + .or_default() + .push(egui::Event::Ime(egui::ImeEvent::Preedit { text: "にほん".into(), active_range_chars: None })) + }); + frames(&ctx, &mut a, 2); + assert_eq!(a.canvas.ime_preedit, "にほん"); + let other = a.windows.open.iter().find_map(|w| w.ui.as_ref()); + assert!(other.is_some_and(|w| w.canvas.ime_preedit.is_empty()), "the other window composes nothing"); + assert!(IME.with(|i| i.borrow().get(&ViewportId::ROOT).cloned().flatten()).is_some(), "the composing window places the candidate window"); + + focus(b); + frames(&ctx, &mut a, 2); + assert!(a.canvas.ime_preedit.is_empty(), "the focused window has no composition"); + EVENTS.with(|e| e.borrow_mut().entry(ViewportId::ROOT).or_default().push(egui::Event::Ime(egui::ImeEvent::Commit("にほん".into())))); + frames(&ctx, &mut a, 2); + assert_eq!(text(&a), "B", "a commit in the other window doesn't land here"); + } + + /// Quitting with two documents of unsaved changes asks about each in its window, the window + /// coming to the front; Cancel keeps both. + #[test] + fn quitting_asks_about_each_document_in_its_window() { + let ctx = backend(); + let mut a = app("One"); + let one = a.session.document_id(); + frames(&ctx, &mut a, 2); + a.run("file.new", json!({})).unwrap(); + a.run("text.insert", json!({"text": "Two"})).unwrap(); + let two = a.session.document_id(); + let child = a.active_viewport(); + frames(&ctx, &mut a, 2); + + assert!(!a.quit()); + assert_eq!((a.session.document_id(), prompt(&a)), (two, Some("saveChanges")), "the active window asks first"); + a.run("ui.saveChanges", json!({"answer": "cancel"})).unwrap(); + assert!(!a.quit_requested); + assert_eq!((a.window_count(), a.session.documents().len()), (2, 2), "Cancel keeps both"); + + assert!(!a.quit()); + a.run("ui.saveChanges", json!({"answer": "dontSave"})).unwrap(); + assert_eq!((a.session.document_id(), prompt(&a)), (one, Some("saveChanges")), "then the other window asks"); + assert_eq!(a.window_count(), 1); + COMMANDS.with(|c| c.borrow_mut().clear()); + frame(&ctx, &mut a); + assert!(sent(ViewportId::ROOT, &ViewportCommand::Focus), "its window comes to the front"); + assert!(!DRAWN.with(|d| d.borrow().contains(&child))); + a.run("ui.saveChanges", json!({"answer": "dontSave"})).unwrap(); + assert!(a.quit_requested); + } +} diff --git a/crates/ui-egui/src/zotero.rs b/crates/ui-egui/src/zotero.rs index e5c4ef32b2..2fff8966b8 100644 --- a/crates/ui-egui/src/zotero.rs +++ b/crates/ui-egui/src/zotero.rs @@ -156,6 +156,11 @@ pub fn poll(app: &mut WordApp, ctx: &egui::Context) { if app.zotero.busy.is_none() { return; } + // Each window polls as it draws: the window showing the document the command started in + // answers it. + if app.windows.native() && app.session.document_id() != app.zotero.document && app.session.is_open(app.zotero.document) { + return; + } let mut changed = false; // One alert at a time: Zotero sends nothing else while it waits for the answer. while app.zotero.alert.is_none() { @@ -217,6 +222,10 @@ pub fn poll(app: &mut WordApp, ctx: &egui::Context) { /// Zotero's question, as a dialog. pub fn show_alert(app: &mut WordApp, ctx: &egui::Context) { let Some(a) = app.zotero.alert.as_ref() else { return }; + // In the window showing the document Zotero is working on. + if app.windows.native() && app.session.document_id() != app.zotero.document && app.session.is_open(app.zotero.document) { + return; + } // Buttons and the value Zotero expects for each. // macOS puts the default button last (rightmost), with Cancel just before it. let buttons: &[(&str, i64)] = match (a.buttons, cfg!(target_os = "macos")) { @@ -336,6 +345,9 @@ mod tests { json!(["Document_complete", [1]]), ]); let mut app = WordApp::new(Session::new(Document::new()), Services::default()); + // One window showing each document in turn (the web app); with a window per document, + // the first document's window carries on with the citation. + app.windows.single(); app.zotero.options.addr = addr; let ctx = egui::Context::default(); app.ctx = Some(ctx.clone()); From 3a6f3fbb17bc68ddeb959b2b307c2a126d0928de Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 16:54:37 +0000 Subject: [PATCH 016/199] W3b: docs: one window per document (control protocol, UI parity, progress log) --- ROADMAP.md | 4 +++- docs/control-protocol.md | 42 ++++++++++++++++++++++++++-------------- docs/ui-parity.md | 3 ++- 3 files changed, 33 insertions(+), 16 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 8f7ccadecc..f685f29ab0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~15 points (60% → 75% ready for real work) and ~150–230 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** trivial (progress log: every open document keeps its own recovery copy; previously trivial: progress log: several open documents in the desktop window; previously trivial: progress log: several open documents in one session; previously trivial: progress log: a click in the footer while editing the header moves there; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Slovenian interface catalog, 2,614 entries; previously trivial: Insert Table of Figures dialog for any caption label, #3013; previously trivial: progress log: Home › Underline drop-down, #2738; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: progress log: the scrollbar page tip stays on one line, #3341; previously trivial: progress log: forms protection greys out the control buttons; previously trivial: progress log: a document with many content controls saves in the time the controls take, #13; previously trivial: progress log: Word signature lines kept on save; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: progress log: closing the header returns to the body selection, #3331; previously trivial: progress log: saving to a NAS share mounted by GVFS, #3003; previously trivial: progress log: a picture dragged near the bottom of a page lands there, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: progress log: inline pictures drag to an insertion cursor and stay inline, #2233, #2234; previously trivial: progress log: a text file saved over itself closes without asking, #2376; previously trivial: progress log: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: fixed font inputs in four layout controls; previously trivial: typing no longer starts render threads on every repaint, #3027; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: progress log: banded rows shade as in Word, #3415; previously trivial: progress log: the table handles show while the caret is in the table, #2428; previously trivial: progress log: a click below a floating table reaches the text after it, #2038; previously trivial: progress log: Delete on selected table cells leaves one empty paragraph in each, #2802; previously trivial: progress log: lam-alef in exported PDFs extracts whole with fonts that draw it as two glyphs, #2310; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously trivial: progress log: a text box that keeps the keyboard no longer resets the input method every frame, #2986; previously trivial: progress log: recovery copies and .json files store pictures as base64, #586 follow-up; previously trivial: progress log: legacy menu keys, #1697; previously trivial: progress log: shape gradients and transparency, #1500; previously trivial: progress log: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: progress log: text above a float's anchor wraps around it, #2048; previously trivial: progress log: File › Recover follows the app theme, #3243; previously trivial: progress log: formatting toolbar with the text context menu, #2959; previously trivial: progress log: caret inside ligatures, #1974; 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: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: progress log: a header-cell picture watermark sits behind the text, #1775; previously trivial: symbols keep their font when the text font changes, #2036; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: Add Font imports every selected font file, #1789); previously trivial (RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: Read Aloud speaks each sentence in its own language, #1843; previously trivial: hint-reliant CJK outlines; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: progress log: Turkish case mapping in AutoCorrect, #1923; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: progress log: Hebrew field results and smart quotes, #1670; previously trivial: Backspace removes a selected table, rows or columns, #1844; previously trivial: progress log: mixed Hebrew/Latin text keeps its order in Word, #1543; previously trivial: progress log: web typing in non-Latin text no longer repeats, #1480; previously trivial: progress log: font family caches bounded and math font lookups follow font changes, #1354; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683; previously trivial: progress log: `w:eastAsianLayout` kept on save; previously trivial: table style editor formats every part, #714); previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** trivial (progress log: one native window per open document; previously trivial: progress log: every open document keeps its own recovery copy; previously trivial: progress log: several open documents in the desktop window; previously trivial: progress log: several open documents in one session; previously trivial: progress log: a click in the footer while editing the header moves there; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Slovenian interface catalog, 2,614 entries; previously trivial: Insert Table of Figures dialog for any caption label, #3013; previously trivial: progress log: Home › Underline drop-down, #2738; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: progress log: the scrollbar page tip stays on one line, #3341; previously trivial: progress log: forms protection greys out the control buttons; previously trivial: progress log: a document with many content controls saves in the time the controls take, #13; previously trivial: progress log: Word signature lines kept on save; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: progress log: closing the header returns to the body selection, #3331; previously trivial: progress log: saving to a NAS share mounted by GVFS, #3003; previously trivial: progress log: a picture dragged near the bottom of a page lands there, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: progress log: inline pictures drag to an insertion cursor and stay inline, #2233, #2234; previously trivial: progress log: a text file saved over itself closes without asking, #2376; previously trivial: progress log: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: fixed font inputs in four layout controls; previously trivial: typing no longer starts render threads on every repaint, #3027; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: progress log: banded rows shade as in Word, #3415; previously trivial: progress log: the table handles show while the caret is in the table, #2428; previously trivial: progress log: a click below a floating table reaches the text after it, #2038; previously trivial: progress log: Delete on selected table cells leaves one empty paragraph in each, #2802; previously trivial: progress log: lam-alef in exported PDFs extracts whole with fonts that draw it as two glyphs, #2310; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously trivial: progress log: a text box that keeps the keyboard no longer resets the input method every frame, #2986; previously trivial: progress log: recovery copies and .json files store pictures as base64, #586 follow-up; previously trivial: progress log: legacy menu keys, #1697; previously trivial: progress log: shape gradients and transparency, #1500; previously trivial: progress log: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: progress log: text above a float's anchor wraps around it, #2048; previously trivial: progress log: File › Recover follows the app theme, #3243; previously trivial: progress log: formatting toolbar with the text context menu, #2959; previously trivial: progress log: caret inside ligatures, #1974; 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: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: progress log: a header-cell picture watermark sits behind the text, #1775; previously trivial: symbols keep their font when the text font changes, #2036; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: Add Font imports every selected font file, #1789); previously trivial (RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: Read Aloud speaks each sentence in its own language, #1843; previously trivial: hint-reliant CJK outlines; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: progress log: Turkish case mapping in AutoCorrect, #1923; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: progress log: Hebrew field results and smart quotes, #1670; previously trivial: Backspace removes a selected table, rows or columns, #1844; previously trivial: progress log: mixed Hebrew/Latin text keeps its order in Word, #1543; previously trivial: progress log: web typing in non-Latin text no longer repeats, #1480; previously trivial: progress log: font family caches bounded and math font lookups follow font changes, #1354; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683; previously trivial: progress log: `w:eastAsianLayout` kept on save; previously trivial: table style editor formats every part, #714); previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** trivial (English dialects in proofing, #1281; previously trivial: triple borders draw three lines, #1740; previously trivial: progress log: double-clicking a later page's footer no longer scrolls up, #2103; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: progress log: pasted blockquotes are indented paragraphs, #1270; previously trivial: progress log: print-area strings translated in every catalog; previously trivial: progress log: comments readable in the dark theme, #2045; previously trivial: progress log: ODT sections, #1379; previously trivial: progress log: table deletes, Change Case and Unlink Fields respect Track Changes and locked content controls, #1329; previously trivial: progress log: rounded rectangle corners, #1476; previously trivial: progress log: Save As type list, #1399; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below. @@ -136,6 +136,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm ## Progress log +- 2026-10-22: One native window per open document on the desktop. File › New and Open (from the ribbon, File, shortcuts, the file manager, the command line or the control channel) open the document in a window of its own, focused and placed a step below and right of the last one; opening a file replaces a blank document nobody has used in its own window, and a file that is already open brings its window to the front. Each window keeps its own ribbon tab, File page, dialogs, panes, scroll position and composition. View › Window › Switch Windows lists the windows and brings the chosen one forward. File › Close and a window's close button close that window, asking about unsaved changes first; the last window stays open with no document. Quitting asks about each document with unsaved changes in its own window, and Cancel keeps everything open. The control channel's input and screenshots go to the window focused last. The web app keeps one window. - 2026-10-22: Recovery covers every open document. Each dirty document keeps its own copy, written on the same ten-second cadence whether it is shown or not; closing a document without saving deletes its copy, as saving does, and a clean exit deletes the copies of every document saved or closed since its last copy. File › Recover lists only copies no open document keeps, and Restore opens a copy as another document (or in place of an untouched blank one), so it never replaces work. After a crash with several unsaved documents, the next start lists all of them. - 2026-10-22: The desktop window works with several open documents (one window per document comes next). View › Window › Switch Windows lists them and shows the one chosen, back where it was left; New, Open and the mailings open beside unsaved work instead of asking about it; File › Close shows the next document; quitting asks about every document with unsaved changes, one at a time, and Cancel keeps the app open. - 2026-10-22: Several open documents in one session (engine part of one window per document). File › New opens a new document beside the open ones; File › Open opens beside them too, replaces a blank document nobody has used, and shows a file that is already open instead of opening it twice. Each document keeps its own text, selection, undo history, find state, view and recovery copy. View › Window › Switch Windows (`view.switchWindows`) lists the open documents and switches between them; `file.closeDocument` closes one where no window asks about unsaved changes (agents, headless sessions). Envelopes, Labels, Finish & Merge and New from Template open their result as a new document and leave the main document open as it was. Document ids are unique in the process. The desktop app still shows one document per window; the windows come next. @@ -575,6 +576,7 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps | Date | Change | Summary | |---|---|---| +| 2026-10-22 | trivial | Progress log: one native window per open document | | 2026-10-22 | trivial | Progress log: every open document keeps its own recovery copy | | 2026-10-22 | trivial | Progress log: several open documents in the desktop window | | 2026-10-22 | trivial | Progress log: several open documents in one session | diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 681c21cb63..101c932738 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -73,18 +73,24 @@ Any other method name is treated as a command id: `{"method": "format.bold"}` wo ### Open documents Several documents can be open at once. Each has an id, a whole number that stays the same while -the document is open and is never reused by the running app. One document is the active one: the -window shows it, and commands work on it unless told otherwise. +the document is open and is never reused by the running app. On the desktop each open document +has a native window of its own; the web app shows them one at a time in its one window. One +document is the active one: on the desktop, the document of the window focused last (the active +window); in the browser, the one the window shows. Commands work on it unless told otherwise. -- `file.new` opens another document and makes it active. `file.open` does the same with a file; - it replaces a blank document nobody has used yet, and a file that is already open becomes - active again (`"alreadyOpen": true`). Its result carries the document's `id`. +- `file.new` opens another document in a new window, focused, and makes it active. `file.open` + does the same with a file; it replaces a blank document nobody has used yet in that document's + window, and a file that is already open becomes active again and its window comes to the front + (`"alreadyOpen": true`). Its result carries the document's `id`. - `view.switchWindows` lists the open documents (`{"documents": [{"id", "title", "path", - "dirty", "active"}]}`); with `{"id": n}` it makes that document active. -- `file.closeDocument {"id"?, "force"?}` closes a document without touching the window. + "dirty", "active"}]}`); with `{"id": n}` it makes that document active and brings its window to + the front. +- `file.closeDocument {"id"?, "force"?}` closes a document; its window closes with it unless it + is the last window. - `engine.execute`, `document.inspect` and `ui.render` take `doc`, the id of the document to work - on, next to their other params. The command runs on that document and the window keeps showing - the active one: + on, next to their other params. The command runs on that document and the active window stays + active. A dialog or page the command opens (Save As, Print) opens in that document's window, + which comes to the front: ```json {"id": 2, "key": "…", "method": "engine.execute", "params": {"command": "text.insert", "params": {"text": "Hello"}, "doc": 3}} @@ -92,7 +98,14 @@ window shows it, and commands work on it unless told otherwise. `doc` left out, or `null`, is the active document. An id that names no open document, or a value that isn't a whole number from 0 up, is an error and nothing runs. Window commands (`ui.*`) work -on the document on screen only: with the id of another document they are an error. +on the active window only: with the id of another document they are an error. + +### The active window + +Input and window methods go to the active window, the one focused last: `ui.click`, `ui.key`, +`ui.text`, `ui.wheel` and the other input methods (coordinates are that window's), +`ui.screenshot` (a PNG of that window), `ui.inspect` (its size), `ui.resize` and `ui.focus`. To +work in another window, make its document active first with `view.switchWindows {"id": n}`. The `CharProps` and `ParaProps` that `format.set`, `para.set` and `styles.modify` take are listed in [MCP server](mcp.md#character-and-paragraph-properties). @@ -117,10 +130,11 @@ document with unsaved changes it replies with an error (the app keeps running an stay); save first (`file.save`), or pass `{"force": true}` to discard the changes and close. The bare form `{"method": "file.close", "force": true}` (a method name that is a command id) is guarded the same way, and only a boolean `true` counts as `force`. A document without unsaved -changes closes at once (#1268). Closing closes the document, not the app: the window stays and -shows no page, and `file.new` or `file.open` goes on from there (#1712). With `doc` naming a -document in the background, `file.close` closes that document under the same rule (unsaved -changes need `{"force": true}`), and the window keeps showing the active one. +changes closes at once (#1268). Closing closes the document and its window, and the window +focused before it becomes the active one. The last window stays open with no page, and +`file.new` or `file.open` goes on from there (#1712); closing it doesn't quit the app. With +`doc` naming a document in the background, `file.close` closes that document and its window under +the same rule (unsaved changes need `{"force": true}`), and the active window stays active. ### Native printing diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 0116cfa331..bcee769746 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** trivial (several open documents in one window: View › Window › Switch Windows lists them and shows one; File › Close shows the next; quitting asks about each one with unsaved changes; previously trivial: while editing a header or footer, a click in the other one edits it there without scrolling; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Insert Table of Figures dialog: any caption label, #3013; previously trivial: Home › Underline drop-down shows its lines, #2738; previously trivial: the text context menu includes the formatting mini toolbar, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: the scrollbar page tip stays on one line as the page number grows, #3341; previously trivial: File › Home and New: templates and recent documents are buttons named for screen readers, #3176; previously trivial: forms protection greys out the Developer tab's control buttons and Properties; previously trivial: AltGr doesn't open KeyTips, #2388; previously trivial: closing the header or footer puts the caret back where it was in the body, #3331; previously trivial: Format Page Numbers dialog: number format and Continue from previous section / Start at, #1113; previously trivial: inline pictures drag to a dashed insertion cursor and stay inline, #2233, #2234; previously trivial: Save Changes prompt: a text file saved over itself closes, #2376; previously trivial: the equation being edited is framed as in Word: an outline as tall as its line, a grip, an options strip that opens its menu, and its current argument shaded; previously trivial: the table handles show while the caret is in the table, #2428; previously trivial: legacy menu keys, #1697; previously trivial: Shape Fill › Gradient and the Format Shape pane, #1500; previously trivial: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: the text pointer over the page is drawn black or white on Windows, #1746; previously trivial: a click in the header/footer band from the body no longer scrolls to the body text, #2103; previously trivial: File › Recover follows the app theme, #3243; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: dragging the vertical scrollbar names the page at the top of the window, #2551; previously trivial: Add Font imports every selected font file, #1789); previously trivial (File › Close keeps the window, #1712; previously trivial: the Spike: Ctrl+F3 and Ctrl+Shift+F3; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: the tab strip never draws tabs over Comments / Editing / Share, #2026; previously trivial: Columns on selected text, #2024; previously trivial: Different First Page headers reachable from page 1, #2826; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: Read Mode keeps the keyboard, shows whole columns and keeps text clear of the page-turn bar, #1772); previously trivial: font list entries of tall-ascent fonts sit in their row, not cut off, #2570); previously trivial: Split Cells and Convert Text to Table dialogs follow Word's merged-cell limits and Initial column width, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout text fills the window width, #1876; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717)); previously trivial: comment balloons readable in the dark theme, #2045; previously trivial: Recently Used Fonts section at the top of the font list, #2068; previously trivial: web preferences kept between visits, #2132; previously trivial: dialog close control: a Windows-style box at the title bar's right end, the whole box clickable, red on hover, #1885); previously trivial (Modify Table Style formats every table style part, #714); previously trivial: a click on the ribbon chrome hands the keyboard to the page in the same frame, #1093 follow-up; previously trivial: Page Setup vertical alignment, #1320; previously trivial: the app opens on the Home tab every launch, as in Word; previously trivial: no document behind the Start screen, #1129; previously trivial: equation pictures share a few textures, #1002; previously trivial: selection bar in the left margin, #856; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously major: first version; ribbon, dialogs, shortcuts and on-canvas interaction measured from `crates/ui-egui` and the command registry) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** trivial (one native window per open document: New and Open open a window of their own, Switch Windows brings a window forward, Close and the close button close the window, quitting asks in each window; previously trivial: several open documents in one window: View › Window › Switch Windows lists them and shows one; File › Close shows the next; quitting asks about each one with unsaved changes; previously trivial: while editing a header or footer, a click in the other one edits it there without scrolling; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Insert Table of Figures dialog: any caption label, #3013; previously trivial: Home › Underline drop-down shows its lines, #2738; previously trivial: the text context menu includes the formatting mini toolbar, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: the scrollbar page tip stays on one line as the page number grows, #3341; previously trivial: File › Home and New: templates and recent documents are buttons named for screen readers, #3176; previously trivial: forms protection greys out the Developer tab's control buttons and Properties; previously trivial: AltGr doesn't open KeyTips, #2388; previously trivial: closing the header or footer puts the caret back where it was in the body, #3331; previously trivial: Format Page Numbers dialog: number format and Continue from previous section / Start at, #1113; previously trivial: inline pictures drag to a dashed insertion cursor and stay inline, #2233, #2234; previously trivial: Save Changes prompt: a text file saved over itself closes, #2376; previously trivial: the equation being edited is framed as in Word: an outline as tall as its line, a grip, an options strip that opens its menu, and its current argument shaded; previously trivial: the table handles show while the caret is in the table, #2428; previously trivial: legacy menu keys, #1697; previously trivial: Shape Fill › Gradient and the Format Shape pane, #1500; previously trivial: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: the text pointer over the page is drawn black or white on Windows, #1746; previously trivial: a click in the header/footer band from the body no longer scrolls to the body text, #2103; previously trivial: File › Recover follows the app theme, #3243; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: dragging the vertical scrollbar names the page at the top of the window, #2551; previously trivial: Add Font imports every selected font file, #1789); previously trivial (File › Close keeps the window, #1712; previously trivial: the Spike: Ctrl+F3 and Ctrl+Shift+F3; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: the tab strip never draws tabs over Comments / Editing / Share, #2026; previously trivial: Columns on selected text, #2024; previously trivial: Different First Page headers reachable from page 1, #2826; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: Read Mode keeps the keyboard, shows whole columns and keeps text clear of the page-turn bar, #1772); previously trivial: font list entries of tall-ascent fonts sit in their row, not cut off, #2570); previously trivial: Split Cells and Convert Text to Table dialogs follow Word's merged-cell limits and Initial column width, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout text fills the window width, #1876; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717)); previously trivial: comment balloons readable in the dark theme, #2045; previously trivial: Recently Used Fonts section at the top of the font list, #2068; previously trivial: web preferences kept between visits, #2132; previously trivial: dialog close control: a Windows-style box at the title bar's right end, the whole box clickable, red on hover, #1885); previously trivial (Modify Table Style formats every table style part, #714); previously trivial: a click on the ribbon chrome hands the keyboard to the page in the same frame, #1093 follow-up; previously trivial: Page Setup vertical alignment, #1320; previously trivial: the app opens on the Home tab every launch, as in Word; previously trivial: no document behind the Start screen, #1129; previously trivial: equation pictures share a few textures, #1002; previously trivial: selection bar in the left margin, #856; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously major: first version; ribbon, dialogs, shortcuts and on-canvas interaction measured from `crates/ui-egui` and the command registry) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 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). @@ -76,6 +76,7 @@ Footnote and endnote editing keeps body and note text at normal opacity, on ligh | Date | Change | Summary | |---|---|---| +| 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 | | 2026-10-22 | trivial | While editing a header, a single click in the footer (or the reverse, or another page's own header) edits that one with the caret where clicked; the click was placed in the header being edited, and the view scrolled back to it, so a double-click on the footer rarely landed | | 2026-10-18 | trivial | Explicit New/Open is not interrupted by startup recovery discovery; older copies stay available and Recover follows the interface theme. | From 6c595dfd160218b1757f3b25ff62a3da59ce666a Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 16:56:50 +0000 Subject: [PATCH 017/199] W3b: a window moved into the root viewport keeps its place on the page; the active viewport is the root's when windows are embedded --- crates/ui-egui/src/canvas.rs | 5 +++-- crates/ui-egui/src/windows.rs | 39 +++++++++++++++++++++++++---------- 2 files changed, 31 insertions(+), 13 deletions(-) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 9caeec2150..c44995770e 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -138,8 +138,9 @@ pub struct CanvasState { /// The scroll offset and its maximum at the end of last frame. pub(crate) scroll_offset: egui::Vec2, scroll_max: egui::Vec2, - /// Explicit page turn to apply on the next frame (reading navigation buttons/keys). - scroll_request: Option, + /// Explicit scroll position to apply on the next frame (reading navigation buttons/keys, a + /// window moving into another viewport). + pub(crate) scroll_request: Option, /// The viewport (the content's visible rect on screen) at the end of last frame; the /// drag-select edge auto-scroll decides this frame's scroll from it (#688). pub(crate) viewport: Option, diff --git a/crates/ui-egui/src/windows.rs b/crates/ui-egui/src/windows.rs index 2ff535c23c..6e60370164 100644 --- a/crates/ui-egui/src/windows.rs +++ b/crates/ui-egui/src/windows.rs @@ -260,9 +260,10 @@ impl WordApp { } /// The egui viewport of the active window, the one focused last: control input, - /// `ui.screenshot`, `ui.resize` and the saved window geometry are its. + /// `ui.screenshot`, `ui.resize` and the saved window geometry are its. The root viewport + /// when the windows don't have viewports of their own (the web app, embedded viewports). pub fn active_viewport(&self) -> ViewportId { - if !self.windows.native { + if !self.windows.native || !self.windows.driven { return ViewportId::ROOT; } let key = self.windows.recent_keys().first().copied().unwrap_or(self.windows.shown); @@ -275,9 +276,9 @@ impl WordApp { self.windows.builder = builder; } - /// The root viewport was asked to close while other windows are open: only its window closes - /// (through [`WindowEvent::CloseRequested`] in [`WordApp::ui`]), so the host keeps the app - /// running. + /// Whether a close request for the root viewport closes only the window drawn there: true + /// while other windows are open. [`WordApp::ui`] closes it (a [`WindowEvent::CloseRequested`]), + /// and the host keeps the app running. pub fn closes_a_window(&self) -> bool { self.window_count() > 1 && !self.quit_requested } @@ -342,7 +343,22 @@ impl WordApp { /// active one. pub(crate) fn focus_window(&mut self, key: u64) { self.windows.focus = Some(key); + self.settle_windows(); + } + + /// [`Windows::settle`]; a window that moved into the root viewport keeps its place on the + /// page (the page's scroll state is the viewport's). + fn settle_windows(&mut self) { + let root = |w: &Windows| w.viewports.as_ref().and_then(Viewports::root); + let before = root(&self.windows); self.windows.settle(self.ctx.as_ref()); + let Some(adopted) = root(&self.windows).filter(|r| before.is_some_and(|b| b != *r)) else { return }; + let Some(key) = self.windows.list.get(adopted).copied() else { return }; + if key == self.windows.shown { + self.canvas.scroll_request = Some(self.canvas.scroll_offset); + } else if let Some(w) = self.windows.get_mut(key).and_then(|w| w.ui.as_mut()) { + w.canvas.scroll_request = Some(w.canvas.scroll_offset); + } } /// Keep one window per open document: windows whose document closed (an agent closed it) @@ -367,7 +383,7 @@ impl WordApp { changed = true; } if changed { - self.windows.settle(self.ctx.as_ref()); + self.settle_windows(); } } @@ -479,7 +495,7 @@ impl WordApp { pub(crate) fn windows_ui(&mut self, ui: &mut egui::Ui) { let ctx = ui.ctx().clone(); self.prune_windows(); - self.windows.settle(Some(&ctx)); + self.settle_windows(); self.windows.menu = self.window_menu(&self.windows.list); // A backend that can't open native windows (egui embeds them in the root: tests, // `ui_shot`) shows the active window alone. Embedded, every window would draw the same @@ -488,7 +504,7 @@ impl WordApp { if !self.windows.driven { let active = self.windows.recent_keys().first().copied().unwrap_or(self.windows.shown); self.draw_window(ui, active); - self.after_windows(&ctx); + self.after_windows(); return; } let Some(mut viewports) = self.windows.viewports.take() else { return }; @@ -510,14 +526,14 @@ impl WordApp { self.windows.list = list; self.windows.viewports = Some(viewports); self.window_events(&ctx, &events); - self.after_windows(&ctx); + self.after_windows(); } /// After the frame's drawing: windows opened, closed or focused meanwhile settle, and the /// active window's state comes back. - fn after_windows(&mut self, ctx: &egui::Context) { + fn after_windows(&mut self) { self.prune_windows(); - self.windows.settle(Some(ctx)); + self.settle_windows(); if let Some(&active) = self.windows.recent_keys().first() { self.enter_window(active); } @@ -885,6 +901,7 @@ mod tests { assert!(!a.quit_requested); assert_eq!((a.window_count(), a.windows.shown()), (1, other)); assert_eq!(a.active_viewport(), ViewportId::ROOT, "the remaining window moved into the root viewport"); + assert_eq!(a.canvas.scroll_request, Some(a.canvas.scroll_offset), "its page stays where it was"); frames(&ctx, &mut a, 2); assert_eq!(DRAWN.with(|d| d.borrow().clone()), [ViewportId::ROOT]); assert!(a.has_document() && a.session.documents().len() == 1); From 338b5d6360c0d6f6759ad2279ed3aa64c6f63aca Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 16:57:38 +0000 Subject: [PATCH 018/199] W3b: test: control typing reaches a window in its own viewport --- crates/ui-egui/src/windows.rs | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/crates/ui-egui/src/windows.rs b/crates/ui-egui/src/windows.rs index 6e60370164..3c07a8b06a 100644 --- a/crates/ui-egui/src/windows.rs +++ b/crates/ui-egui/src/windows.rs @@ -967,6 +967,28 @@ mod tests { assert_eq!(text(&a), "B", "a commit in the other window doesn't land here"); } + /// Control-channel typing goes to the active window when it has a viewport of its own (the + /// root's raw-input hook doesn't see that window's input), once. + #[test] + fn control_typing_reaches_the_active_window() { + let ctx = backend(); + let mut a = app("First"); + frames(&ctx, &mut a, 2); + a.run("file.new", json!({})).unwrap(); + let child = a.active_viewport(); + focus(child); + frames(&ctx, &mut a, 8); + assert!(a.canvas.focused, "the new window's page has the keyboard"); + let (req, _reply) = crate::ControlRequest::new("ui.text", json!({"text": "Typed"})); + assert!(matches!(crate::control::handle(&mut a, &ctx, &req), crate::control::Outcome::Done(_))); + frames(&ctx, &mut a, 4); + assert_eq!(text(&a), "Typed"); + assert!(a.synthetic.is_empty()); + focus(ViewportId::ROOT); + frames(&ctx, &mut a, 2); + assert_eq!(text(&a), "First", "the other window got none of it"); + } + /// Quitting with two documents of unsaved changes asks about each in its window, the window /// coming to the front; Cancel keeps both. #[test] From a6d5820e16ed2842b45f01d7017da79f9999fda9 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 17:14:18 +0000 Subject: [PATCH 019/199] W3b review: quit asks from the empty window; background requests keep the active document - Quitting from the Start screen's empty window asks about other windows' documents. - A request for another window's document is handled when that window draws; the active window and document stay. - Keys after a window closes or goes to the back in the same frame are dropped. - A window whose document closed isn't entered. - A window's title is kept only when it is still the drawn one after drawing. - Control input queued for a closed viewport is dropped instead of stalling. - Comment reply state is keyed by document and comment. --- crates/ui-egui/src/documents.rs | 10 +-- crates/ui-egui/src/keys.rs | 14 +++ crates/ui-egui/src/lib.rs | 8 +- crates/ui-egui/src/panes.rs | 8 +- crates/ui-egui/src/windows.rs | 150 ++++++++++++++++++++++++-------- 5 files changed, 141 insertions(+), 49 deletions(-) diff --git a/crates/ui-egui/src/documents.rs b/crates/ui-egui/src/documents.rs index 5f8645ccf9..035261d596 100644 --- a/crates/ui-egui/src/documents.rs +++ b/crates/ui-egui/src/documents.rs @@ -111,20 +111,18 @@ impl WordApp { /// The open documents with work that would be lost on quitting: the shown one first, then /// the others in the order they were opened. Unsaved changes, a save the browser hasn't - /// confirmed, or comment text not yet written to the document. + /// confirmed, or comment text not yet written to the document. The Start screen's + /// placeholder is never one, though other windows' documents are. fn unsaved_documents(&self) -> Vec { - if !self.has_document() { - return Vec::new(); - } let active = self.session.document_id(); let mut out = Vec::new(); - if self.session.dirty || self.save_unconfirmed() || panes::has_comment_drafts(self) { + if self.has_document() && (self.session.dirty || self.save_unconfirmed() || panes::has_comment_drafts(self)) { out.push(active); } for d in self.session.documents() { let unconfirmed = || self.saves_in_flight.iter().any(|s| s.marked_saved && s.document == d.id); let drafts = || self.parked.get(&d.id).is_some_and(|p| !p.comment_drafts.is_empty()); - if d.id != active && (d.dirty || unconfirmed() || drafts()) { + if d.id != active && Some(d.id) != self.no_document && (d.dirty || unconfirmed() || drafts()) { out.push(d.id); } } diff --git a/crates/ui-egui/src/keys.rs b/crates/ui-egui/src/keys.rs index abf9513909..c7d1864689 100644 --- a/crates/ui-egui/src/keys.rs +++ b/crates/ui-egui/src/keys.rs @@ -238,10 +238,14 @@ pub fn canvas_events(app: &mut WordApp, ctx: &egui::Context) { return; } let events = ctx.input(|i| i.events.clone()); + let window = app.windows.shown(); if app.session.view.read_mode { // Read Mode is for navigation and selection, not typing into the document. The ribbon's // Edit Document button or Escape returns to Print Layout before editing. for e in &events { + if app.windows.shown() != window { + break; + } match e { // Not while a dialog or menu that takes Escape is open. egui::Event::Key { key: Key::Escape, pressed: true, .. } if app.escape_free => { @@ -276,6 +280,11 @@ pub fn canvas_events(app: &mut WordApp, ctx: &egui::Context) { app.canvas.paste_mods = Some(held); } for e in events { + // A key closed the window (Mod+W) or brought another forward: the frame's other keys + // were typed in this one and are dropped. + if app.windows.shown() != window { + break; + } // Which presses reached the canvas, and the modifiers held with Mod, in event order: Mod // can go down and up within one frame, leaving the frame's modifiers without it. match &e { @@ -712,7 +721,12 @@ pub fn global_shortcuts(app: &mut WordApp, ctx: &egui::Context) { return; } let events = ctx.input(|i| i.events.clone()); + let window = app.windows.shown(); for e in events { + // The rest of the keys were pressed in a window that closed or went to the back. + if app.windows.shown() != window { + break; + } if let egui::Event::Key { key, pressed: true, modifiers, .. } = e { if leave_full_screen(app, key, modifiers, false) { continue; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 0f1ccf9b74..94d58b4b7f 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1438,12 +1438,14 @@ impl WordApp { if self.quit_requested { return true; } - // No document open (#1129): nothing to save. - if !self.has_document() { + // Nothing to save, also with no document open in this window (#1129) and none elsewhere. + if !self.has_unsaved_documents() { return true; } // A comment draft the document refuses is an unsaved change: the prompt can drop it. - let _ = panes::flush_comment_drafts(self); + if self.has_document() { + let _ = panes::flush_comment_drafts(self); + } if !self.has_unsaved_documents() { return true; } diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index 35eabcf428..a6f511dd6f 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -442,6 +442,8 @@ fn comments(app: &mut WordApp, ui: &mut Ui) { ui.separator(); let list = app.session.run("review.comments", &json!({})).unwrap_or_default(); let t = Tokens::get(ui.ctx()); + // Comment ids are the document's own; another window's document has the same ones. + let document = app.session.document_id(); egui::ScrollArea::vertical().show(ui, |ui| { for c in list.as_array().cloned().unwrap_or_default() { let id = c.get("id").and_then(Value::as_u64).unwrap_or(0); @@ -460,7 +462,7 @@ fn comments(app: &mut WordApp, ui: &mut Ui) { }); // Editable comment text (one undo step per edit, like the balloons). comment_editor(app, ui, id as u32, None, false); - let open_key = egui::Id::new(("comment_reply_open", id)); + let open_key = egui::Id::new(("comment_reply_open", document, id)); let mut replying = ui.data(|d| d.get_temp::(open_key)).unwrap_or(false); ui.horizontal(|ui| { if ui.small_button(tl!("Go to")).clicked() @@ -471,7 +473,7 @@ fn comments(app: &mut WordApp, ui: &mut Ui) { if ui.small_button(tl!("Reply")).clicked() { replying = true; ui.data_mut(|d| d.insert_temp(open_key, true)); - ui.data_mut(|d| d.insert_temp(egui::Id::new(("comment_reply_focus", id)), true)); + ui.data_mut(|d| d.insert_temp(egui::Id::new(("comment_reply_focus", document, id)), true)); } let resolved = c.get("resolved").and_then(Value::as_bool).unwrap_or(false); if ui.small_button(if resolved { tl!("Reopen") } else { tl!("Resolve") }).clicked() { @@ -482,7 +484,7 @@ fn comments(app: &mut WordApp, ui: &mut Ui) { } }); if replying { - let focus = ui.data_mut(|d| d.remove_temp::(egui::Id::new(("comment_reply_focus", id)))).unwrap_or(false); + let focus = ui.data_mut(|d| d.remove_temp::(egui::Id::new(("comment_reply_focus", document, id)))).unwrap_or(false); if reply_editor(app, ui, id as u32, None, focus) { ui.data_mut(|d| d.remove::(open_key)); } diff --git a/crates/ui-egui/src/windows.rs b/crates/ui-egui/src/windows.rs index 3c07a8b06a..303af77c74 100644 --- a/crates/ui-egui/src/windows.rs +++ b/crates/ui-egui/src/windows.rs @@ -295,14 +295,17 @@ impl WordApp { } /// Put the drawn window's state away and draw window `key`, with its document active. False - /// when there is no such window. + /// when there is no such window, or its document has closed ([`WordApp::prune_windows`] + /// closes it then); the drawn window stays. pub(crate) fn enter_window(&mut self, key: u64) -> bool { if key == self.windows.shown { return true; } - let Some(mut state) = self.windows.get_mut(key).and_then(|w| w.ui.take()) else { + let session = &self.session; + let Some(w) = self.windows.get_mut(key).filter(|w| w.ui.as_ref().is_some_and(|ui| session.is_open(ui.shown_document))) else { return false; }; + let Some(mut state) = w.ui.take() else { return false }; let from = self.shown_document; self.swap_window(&mut state); let left = std::mem::replace(&mut self.windows.shown, key); @@ -390,24 +393,22 @@ impl WordApp { /// [`WordApp::sync_document`] with native windows: the session's active document `id` isn't /// the drawn window's. Another open document shows in its window, which comes to the front, /// or in a new window; when the drawn window's document has closed, its window closes and the - /// one showing `id` comes forward. False when the window itself is to show `id` instead: its - /// document was replaced, or it is the Start screen's empty window. + /// one showing `id` comes forward. The Start screen's empty window stays beside the window + /// that shows `id`. False when the window itself is to show `id` instead: its document was + /// replaced, or it is the Start screen's empty window and no other window shows `id`. pub(crate) fn follow_in_windows(&mut self, id: u64) -> bool { let left = self.shown_document; let stand_in = self.no_document == Some(left); - if self.session.is_open(left) && !stand_in { - match self.windows.of_doc(id).filter(|&k| k != self.windows.shown) { - Some(key) => { - self.enter_window(key); - self.focus_window(key); - } - None => self.open_window(id), + let other = self.windows.of_doc(id).filter(|&k| k != self.windows.shown); + if self.session.is_open(left) { + match other { + Some(key) if self.enter_window(key) => self.focus_window(key), + _ if stand_in => return false, + _ => self.open_window(id), } return true; } - if !self.session.is_open(left) - && let Some(key) = self.windows.of_doc(id).filter(|&k| k != self.windows.shown) - { + if let Some(key) = other { self.close_shown_window(key); return true; } @@ -440,28 +441,17 @@ impl WordApp { } /// A request made for open document `document`, which another window shows: that window - /// handles it when it draws next, and comes to the front when the request opens something - /// (a dialog, the Print page). False when no other window shows the document (or windows - /// aren't native). + /// handles it when it draws next (a dialog opens there). The active window and document stay + /// as they are, so the control channel and agents go on working where they were. False when + /// no other window shows the document (or windows aren't native). pub(crate) fn queue_for_window(&mut self, document: u64, req: &Value) -> bool { if !self.windows.native { return false; } let Some(key) = self.windows.of_doc(document).filter(|&k| k != self.windows.shown) else { return false }; - let opens = req.get("open").is_some(); - if opens && !self.windows.showing { - // Between frames: the window comes forward and opens it now. - self.enter_window(key); - self.focus_window(key); - self.handle_request(req); - return true; - } if let Some(w) = self.windows.get_mut(key) { w.requests.push(req.clone()); } - if opens { - self.focus_window(key); - } if let Some(ctx) = &self.ctx { ctx.request_repaint(); } @@ -569,6 +559,11 @@ impl WordApp { return; } self.window_ui(ui); + // Drawing can close the window or bring another forward: the fields then hold another + // window's state. + if self.windows.shown != key { + return; + } let title = self.sent_title.clone(); if let Some(w) = self.windows.get_mut(key) { w.title = title; @@ -618,7 +613,17 @@ impl WordApp { ctx.add_plugin(ViewportInput::default()); self.windows.input_plugin = true; } + self.settle_windows(); let target = self.active_viewport(); + // Input queued for a window that has closed since (or moved into the root viewport) + // would wait forever. + let live: Vec = self.windows.open.iter().map(|w| self.windows.viewport(w.key)).filter(|&v| v != ViewportId::ROOT).collect(); + ctx.with_plugin(|p: &mut ViewportInput| { + if p.target.is_some_and(|t| !live.contains(&t)) { + p.target = None; + p.events.clear(); + } + }); let waiting = ctx.with_plugin(|p: &mut ViewportInput| !p.events.is_empty()).unwrap_or(true); if waiting { return; @@ -908,29 +913,100 @@ mod tests { } /// A request an agent's command makes for the document of another window is that window's: - /// Print opens the Print page there and brings it forward, and the window that was active - /// keeps its own page. + /// Print opens the Print page there when it draws, and the active window and document stay + /// as they are, so the agent's next command works where it did. #[test] fn a_request_for_another_windows_document_is_handled_there() { let ctx = backend(); let mut a = app("First"); let first = a.session.document_id(); + let first_window = a.windows.shown(); frames(&ctx, &mut a, 2); a.run("file.new", json!({})).unwrap(); - let second = a.windows.shown(); + let second = a.session.document_id(); frames(&ctx, &mut a, 2); + let child = a.active_viewport(); a.session.with_document(first, |s| s.run("file.print", &json!({}))).unwrap().unwrap(); for req in std::mem::take(&mut a.session.ui_requests) { assert_eq!(req["document"], first); a.handle_request(&req); } - assert_eq!(a.session.document_id(), first, "the first window comes forward"); - assert!(a.ui.backstage && a.ui.backstage_page == "print"); + assert_eq!((a.session.document_id(), a.active_viewport()), (second, child), "the active document stays"); + assert!(!a.ui.backstage); frames(&ctx, &mut a, 2); - assert_eq!(a.active_viewport(), ViewportId::ROOT); - assert!(a.ui.backstage, "the Print page stays in its window"); - let other = a.windows.get(second).and_then(|w| w.ui.as_ref()).map(|w| w.backstage); - assert_eq!(other, Some(false), "the other window keeps its page"); + assert_eq!((a.session.document_id(), a.active_viewport()), (second, child)); + assert!(!a.ui.backstage, "the active window keeps its page"); + let other = a.windows.get(first_window).and_then(|w| w.ui.as_ref()).map(|w| (w.backstage, w.backstage_page.as_str())); + assert_eq!(other, Some((true, "print")), "the Print page opened in the document's window"); + } + + /// Quitting from the Start screen's empty window asks about the documents other windows + /// show (one an agent made and typed in). + #[test] + fn quitting_from_the_empty_window_asks_about_the_other_windows() { + let ctx = backend(); + let mut a = WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default()); + frames(&ctx, &mut a, 2); + a.run("file.close", json!({})).unwrap(); + assert!(!a.has_document()); + let empty = a.windows.shown(); + let placeholder = a.session.document_id(); + let other = a.session.with_document(placeholder, |s| s.add_document(wordcraft_doc::Document::new())).unwrap(); + a.session.with_document(other, |s| s.run("text.insert", &json!({"text": "Agent"}))).unwrap().unwrap(); + frames(&ctx, &mut a, 2); + assert_eq!((a.window_count(), a.windows.shown()), (2, empty), "the agent's document has a window behind"); + assert!(!a.has_document()); + + assert!(!a.quit()); + assert_eq!((a.session.document_id(), prompt(&a), text(&a).as_str()), (other, Some("saveChanges"), "Agent")); + assert_ne!(a.windows.shown(), empty, "the document's own window asks"); + a.run("ui.saveChanges", json!({"answer": "dontSave"})).unwrap(); + assert!(a.quit_requested); + } + + /// Mod+W in a window with no changes closes it; the keys typed after it in the same frame + /// don't reach the window that comes forward. + #[test] + fn keys_after_closing_a_window_go_nowhere() { + let ctx = backend(); + let mut a = app("First"); + frames(&ctx, &mut a, 2); + a.run("file.new", json!({})).unwrap(); + let child = a.active_viewport(); + focus(child); + frames(&ctx, &mut a, 8); + assert!(a.canvas.focused); + let mac = cfg!(target_os = "macos"); + let modifiers = egui::Modifiers { ctrl: !mac, mac_cmd: mac, command: true, ..Default::default() }; + EVENTS.with(|e| { + e.borrow_mut().entry(child).or_default().extend([ + egui::Event::Key { key: egui::Key::W, physical_key: None, pressed: true, repeat: false, modifiers }, + egui::Event::Text("x".into()), + ]) + }); + frame(&ctx, &mut a); + assert_eq!(a.window_count(), 1, "Mod+W closed the window"); + frames(&ctx, &mut a, 2); + assert_eq!(text(&a), "First"); + } + + /// A window whose document closed without the windows following (an agent closed it) isn't + /// drawn or made active; the next frame closes it. + #[test] + fn a_window_whose_document_closed_is_not_entered() { + let ctx = backend(); + let mut a = app("First"); + let first = a.session.document_id(); + let first_window = a.windows.shown(); + frames(&ctx, &mut a, 2); + a.run("file.new", json!({})).unwrap(); + let (second, second_window) = (a.session.document_id(), a.windows.shown()); + frames(&ctx, &mut a, 2); + a.session.close_document(first).unwrap(); + assert!(!a.enter_window(first_window)); + assert_eq!((a.windows.shown(), a.session.document_id()), (second_window, second)); + frames(&ctx, &mut a, 2); + assert_eq!((a.window_count(), a.session.document_id()), (1, second)); } /// A composition in one window shows and commits only there: the other window draws no From f2c21cd62ce6c263a52f93d68d72d25c831b7864 Mon Sep 17 00:00:00 2001 From: aiongg Date: Thu, 22 Oct 2026 17:30:33 +0000 Subject: [PATCH 020/199] W6: View: New Window opens another window on the document Each window on a document keeps its own view (selection, zoom, view mode, panes) as an engine WindowView, moved in and out of the session as the windows draw and clamped to edits made through another window. The document, undo history and save state are shared. Two windows on one document are titled Name:1 and Name:2; closing one of them asks nothing, the last asks, and quitting asks once per document. Switch Windows lists windows. The layout memo keeps two layouts and keys on every view option, so windows with different views don't lay out again each frame. --- ROADMAP.md | 3 +- crates/engine/src/lib.rs | 2 +- crates/engine/src/session.rs | 63 ++++++--- crates/engine/src/session/documents.rs | 6 +- crates/engine/src/session/views.rs | 154 ++++++++++++++++++++++ crates/engine/src/tests_documents.rs | 56 ++++++++ crates/ui-egui/src/documents.rs | 13 +- crates/ui-egui/src/lib.rs | 12 +- crates/ui-egui/src/windows.rs | 175 +++++++++++++++++++++++-- docs/ui-parity.md | 3 +- 10 files changed, 445 insertions(+), 42 deletions(-) create mode 100644 crates/engine/src/session/views.rs diff --git a/ROADMAP.md b/ROADMAP.md index f685f29ab0..297200e8d4 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~15 points (60% → 75% ready for real work) and ~150–230 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** trivial (progress log: one native window per open document; previously trivial: progress log: every open document keeps its own recovery copy; previously trivial: progress log: several open documents in the desktop window; previously trivial: progress log: several open documents in one session; previously trivial: progress log: a click in the footer while editing the header moves there; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Slovenian interface catalog, 2,614 entries; previously trivial: Insert Table of Figures dialog for any caption label, #3013; previously trivial: progress log: Home › Underline drop-down, #2738; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: progress log: the scrollbar page tip stays on one line, #3341; previously trivial: progress log: forms protection greys out the control buttons; previously trivial: progress log: a document with many content controls saves in the time the controls take, #13; previously trivial: progress log: Word signature lines kept on save; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: progress log: closing the header returns to the body selection, #3331; previously trivial: progress log: saving to a NAS share mounted by GVFS, #3003; previously trivial: progress log: a picture dragged near the bottom of a page lands there, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: progress log: inline pictures drag to an insertion cursor and stay inline, #2233, #2234; previously trivial: progress log: a text file saved over itself closes without asking, #2376; previously trivial: progress log: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: fixed font inputs in four layout controls; previously trivial: typing no longer starts render threads on every repaint, #3027; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: progress log: banded rows shade as in Word, #3415; previously trivial: progress log: the table handles show while the caret is in the table, #2428; previously trivial: progress log: a click below a floating table reaches the text after it, #2038; previously trivial: progress log: Delete on selected table cells leaves one empty paragraph in each, #2802; previously trivial: progress log: lam-alef in exported PDFs extracts whole with fonts that draw it as two glyphs, #2310; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously trivial: progress log: a text box that keeps the keyboard no longer resets the input method every frame, #2986; previously trivial: progress log: recovery copies and .json files store pictures as base64, #586 follow-up; previously trivial: progress log: legacy menu keys, #1697; previously trivial: progress log: shape gradients and transparency, #1500; previously trivial: progress log: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: progress log: text above a float's anchor wraps around it, #2048; previously trivial: progress log: File › Recover follows the app theme, #3243; previously trivial: progress log: formatting toolbar with the text context menu, #2959; previously trivial: progress log: caret inside ligatures, #1974; 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: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: progress log: a header-cell picture watermark sits behind the text, #1775; previously trivial: symbols keep their font when the text font changes, #2036; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: Add Font imports every selected font file, #1789); previously trivial (RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: Read Aloud speaks each sentence in its own language, #1843; previously trivial: hint-reliant CJK outlines; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: progress log: Turkish case mapping in AutoCorrect, #1923; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: progress log: Hebrew field results and smart quotes, #1670; previously trivial: Backspace removes a selected table, rows or columns, #1844; previously trivial: progress log: mixed Hebrew/Latin text keeps its order in Word, #1543; previously trivial: progress log: web typing in non-Latin text no longer repeats, #1480; previously trivial: progress log: font family caches bounded and math font lookups follow font changes, #1354; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683; previously trivial: progress log: `w:eastAsianLayout` kept on save; previously trivial: table style editor formats every part, #714); previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** trivial (progress log: View › New Window; previously trivial: progress log: one native window per open document; previously trivial: progress log: every open document keeps its own recovery copy; previously trivial: progress log: several open documents in the desktop window; previously trivial: progress log: several open documents in one session; previously trivial: progress log: a click in the footer while editing the header moves there; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Slovenian interface catalog, 2,614 entries; previously trivial: Insert Table of Figures dialog for any caption label, #3013; previously trivial: progress log: Home › Underline drop-down, #2738; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: progress log: the scrollbar page tip stays on one line, #3341; previously trivial: progress log: forms protection greys out the control buttons; previously trivial: progress log: a document with many content controls saves in the time the controls take, #13; previously trivial: progress log: Word signature lines kept on save; previously trivial: a Thai run with Latin finds one installed font covering it all on Linux too, #1549; previously trivial: progress log: closing the header returns to the body selection, #3331; previously trivial: progress log: saving to a NAS share mounted by GVFS, #3003; previously trivial: progress log: a picture dragged near the bottom of a page lands there, #3169; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: progress log: inline pictures drag to an insertion cursor and stay inline, #2233, #2234; previously trivial: progress log: a text file saved over itself closes without asking, #2376; previously trivial: progress log: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: fixed font inputs in four layout controls; previously trivial: typing no longer starts render threads on every repaint, #3027; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: progress log: banded rows shade as in Word, #3415; previously trivial: progress log: the table handles show while the caret is in the table, #2428; previously trivial: progress log: a click below a floating table reaches the text after it, #2038; previously trivial: progress log: Delete on selected table cells leaves one empty paragraph in each, #2802; previously trivial: progress log: lam-alef in exported PDFs extracts whole with fonts that draw it as two glyphs, #2310; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously trivial: progress log: a text box that keeps the keyboard no longer resets the input method every frame, #2986; previously trivial: progress log: recovery copies and .json files store pictures as base64, #586 follow-up; previously trivial: progress log: legacy menu keys, #1697; previously trivial: progress log: shape gradients and transparency, #1500; previously trivial: progress log: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: progress log: text above a float's anchor wraps around it, #2048; previously trivial: progress log: File › Recover follows the app theme, #3243; previously trivial: progress log: formatting toolbar with the text context menu, #2959; previously trivial: progress log: caret inside ligatures, #1974; 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: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: progress log: a header-cell picture watermark sits behind the text, #1775; previously trivial: symbols keep their font when the text font changes, #2036; previously trivial: opening a DOCX no longer copies its pictures; previously trivial: Add Font imports every selected font file, #1789); previously trivial (RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: Read Aloud speaks each sentence in its own language, #1843; previously trivial: hint-reliant CJK outlines; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: progress log: Turkish case mapping in AutoCorrect, #1923; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: progress log: Hebrew field results and smart quotes, #1670; previously trivial: Backspace removes a selected table, rows or columns, #1844; previously trivial: progress log: mixed Hebrew/Latin text keeps its order in Word, #1543; previously trivial: progress log: web typing in non-Latin text no longer repeats, #1480; previously trivial: progress log: font family caches bounded and math font lookups follow font changes, #1354; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683; previously trivial: progress log: `w:eastAsianLayout` kept on save; previously trivial: table style editor formats every part, #714); previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** trivial (English dialects in proofing, #1281; previously trivial: triple borders draw three lines, #1740; previously trivial: progress log: double-clicking a later page's footer no longer scrolls up, #2103; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: a word after a slash wraps whole, like Word, #2963; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: progress log: pasted blockquotes are indented paragraphs, #1270; previously trivial: progress log: print-area strings translated in every catalog; previously trivial: progress log: comments readable in the dark theme, #2045; previously trivial: progress log: ODT sections, #1379; previously trivial: progress log: table deletes, Change Case and Unlink Fields respect Track Changes and locked content controls, #1329; previously trivial: progress log: rounded rectangle corners, #1476; previously trivial: progress log: Save As type list, #1399; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below. @@ -136,6 +136,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm ## Progress log +- 2026-10-22: View › Window › New Window opens a second window on the document (desktop). The windows are titled `Name:1` and `Name:2` and each keeps its own caret, scroll position, zoom and view; edits and Undo are the document's and show in both. Closing one of them asks nothing; the last one asks about unsaved changes, and quitting asks once per document. - 2026-10-22: One native window per open document on the desktop. File › New and Open (from the ribbon, File, shortcuts, the file manager, the command line or the control channel) open the document in a window of its own, focused and placed a step below and right of the last one; opening a file replaces a blank document nobody has used in its own window, and a file that is already open brings its window to the front. Each window keeps its own ribbon tab, File page, dialogs, panes, scroll position and composition. View › Window › Switch Windows lists the windows and brings the chosen one forward. File › Close and a window's close button close that window, asking about unsaved changes first; the last window stays open with no document. Quitting asks about each document with unsaved changes in its own window, and Cancel keeps everything open. The control channel's input and screenshots go to the window focused last. The web app keeps one window. - 2026-10-22: Recovery covers every open document. Each dirty document keeps its own copy, written on the same ten-second cadence whether it is shown or not; closing a document without saving deletes its copy, as saving does, and a clean exit deletes the copies of every document saved or closed since its last copy. File › Recover lists only copies no open document keeps, and Restore opens a copy as another document (or in place of an untouched blank one), so it never replaces work. After a crash with several unsaved documents, the next start lists all of them. - 2026-10-22: The desktop window works with several open documents (one window per document comes next). View › Window › Switch Windows lists them and shows the one chosen, back where it was left; New, Open and the mailings open beside unsaved work instead of asking about it; File › Close shows the next document; quitting asks about every document with unsaved changes, one at a time, and Cancel keeps the app open. diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index b1aec1ce4d..babf9bbc2f 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -28,7 +28,7 @@ pub use io::Password; pub use keymap::KeyMap; pub use ribbon::RibbonLayout; pub use session::{ - CellRange, ColumnBlock, DocInfo, DocState, EditSnapshot, FindState, MathEdit, Prefs, Selection, Session, UNDO_LIST_MAX, ViewState, + CellRange, ColumnBlock, DocInfo, DocState, EditSnapshot, FindState, MathEdit, Prefs, Selection, Session, UNDO_LIST_MAX, ViewState, WindowView, blank_document, set_system_locale, }; pub use wordcraft_doc as doc; diff --git a/crates/engine/src/session.rs b/crates/engine/src/session.rs index e2c19aa32c..0aef2dac55 100644 --- a/crates/engine/src/session.rs +++ b/crates/engine/src/session.rs @@ -26,8 +26,10 @@ use wordcraft_layout::{DocLayout, LayoutCache, LayoutOptions, NumeralMode, ViewM use crate::{CmdError, Registry}; mod documents; +mod views; pub use documents::{DocInfo, DocState}; use documents::{document_id_mark, next_document_id, untitled_name}; +pub use views::WindowView; /// A selection: anchor (where it started) and focus (where the caret is). #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] @@ -363,14 +365,15 @@ impl EditSnapshot { } } -/// The layout last made and what it was made for: (revision, width, read height, view, read line -/// spacing, layout, proofing, outline, (deletions left out, deletions marked Hidden, body -/// deletions in balloons), numeral display, the word being typed). /// What `run` keeps to take a failed mutating command back: the document, the selection, the /// undo and redo stacks, the open typing group, the undo steps dropped so far and the column /// block. type RunSnapshot = (Document, Selection, Vec>, Vec>, bool, u64, Option); +/// A layout made and what it was made for: (revision, width, read height, view, read line +/// spacing, layout, proofing, outline, (deletions left out, deletions marked Hidden, body +/// deletions in balloons), numeral display, the word being typed, (hidden text shown, field +/// codes shown)). type LayoutEntry = ( u64, f32, @@ -383,8 +386,13 @@ type LayoutEntry = ( (bool, wordcraft_layout::HideDeleted, bool), NumeralMode, Option, + (bool, bool), ); +/// The layouts last made, newest first. Two, so two windows on one document with different +/// views ([`WindowView`]) each find theirs as they draw in turn. +type LayoutSlots = [Option; 2]; + /// An editing session: the app-wide state (clipboard, preferences, macros, the command registry…) /// and the open documents. The active document's state is in the session's own fields, which /// every command works on; the other open documents wait in [`DocState`] values and are swapped @@ -457,7 +465,7 @@ pub struct Session { /// `rev` when the document was made, for [`Session::is_untouched_blank`]. made_rev: u64, cache: LayoutCache, - layout: Option, + layout: LayoutSlots, /// The word being typed, left without a spelling squiggle ([`TypedWord`]). typing: Option, /// Picture edits: edited media key → original media key (Reset Picture). @@ -953,19 +961,30 @@ impl Session { let deleted = (hide_deleted, self.prefs.markup.hidden_deletions(), self.revisions_in_balloons()); let outline = (mode == ViewMode::Outline).then(|| self.view.outline.view()); let typing = self.typing_word(); - if let Some((r, w, h, m, sp, l, pf, o, hd, n, t)) = &self.layout - && *r == self.rev - && (*w == width || mode == ViewMode::Print) - && *h == height - && *m == mode - && *sp == spacing - && *pf == self.view.proofing - && *n == self.view.numeral - && *hd == deleted - && *o == outline - && *t == typing + let shown = (self.view.marks || self.prefs.marks.hidden, self.view.field_codes); + let (rev, proofing, numeral) = (self.rev, self.view.proofing, self.view.numeral); + let fits = |e: &Option| { + e.as_ref().is_some_and(|(r, w, h, m, sp, _, pf, o, hd, n, t, sh)| { + *r == rev + && (*w == width || mode == ViewMode::Print) + && *h == height + && *m == mode + && *sp == spacing + && *pf == proofing + && *n == numeral + && *hd == deleted + && *o == outline + && *t == typing + && *sh == shown + }) + }; + if !fits(&self.layout[0]) && fits(&self.layout[1]) { + self.layout.swap(0, 1); + } + if fits(&self.layout[0]) + && let Some(e) = &self.layout[0] { - return l.clone(); + return e.5.clone(); } let (fn_name, fn_path) = self.resolved_filenames(); let (filename, filepath): (Option>, Option>) = (Some(Arc::from(fn_name.as_str())), Some(Arc::from(fn_path.as_str()))); @@ -974,14 +993,14 @@ impl Session { web_width: width, read_height: height, read_line_spacing: spacing.map(f32::from_bits), - show_hidden: self.view.marks || self.prefs.marks.hidden, + show_hidden: shown.0, hide_deleted, hide_marked: deleted.1, deletions_in_balloons: deleted.2, proofing: self.view.proofing, numeral: self.view.numeral.resolve(SYSTEM_LOCALE.get().map(String::as_str)), comment_balloons: false, - field_codes: self.view.field_codes, + field_codes: shown.1, outline: outline.clone(), proof: wordcraft_layout::ProofOptions { grammar: self.prefs.mark_grammar, spelling: self.prefs.spelling() }, typing_word: typing, @@ -989,7 +1008,11 @@ impl Session { filepath: filepath.clone(), }; let l = Arc::new(wordcraft_layout::layout(&self.doc, &mut self.cache, &opts)); - self.layout = Some((self.rev, width, height, mode, spacing, l.clone(), self.view.proofing, outline, deleted, self.view.numeral, typing)); + let [newest, _] = std::mem::take(&mut self.layout); + self.layout = [ + Some((self.rev, width, height, mode, spacing, l.clone(), self.view.proofing, outline, deleted, self.view.numeral, typing, shown)), + newest, + ]; l } @@ -1050,7 +1073,7 @@ impl Session { /// Invalidate the cached layout (fonts changed etc.). pub fn relayout(&mut self) { - self.layout = None; + self.layout = Default::default(); self.cache.clear(); } diff --git a/crates/engine/src/session/documents.rs b/crates/engine/src/session/documents.rs index 8d5c2fbbcd..e76859d462 100644 --- a/crates/engine/src/session/documents.rs +++ b/crates/engine/src/session/documents.rs @@ -13,7 +13,7 @@ use wordcraft_doc::props::CharProps; use wordcraft_doc::{Document, Pos, StoryRef}; use wordcraft_layout::LayoutCache; -use super::{ColumnBlock, FindState, LayoutEntry, MathEdit, Selection, Session, TypedWord, Undo, ViewState, Widened, doc_bib_style}; +use super::{ColumnBlock, FindState, LayoutSlots, MathEdit, Selection, Session, TypedWord, Undo, ViewState, Widened, doc_bib_style}; use crate::CmdError; /// Document ids are unique in the process, so an id an agent or a window holds never names @@ -63,7 +63,7 @@ pub struct DocState { pub(super) untitled: u32, pub(super) made_rev: u64, pub(super) cache: LayoutCache, - pub(super) layout: Option, + pub(super) layout: LayoutSlots, pub(super) typing: Option, pub(super) originals: std::collections::HashMap, pub(super) picture_looks: std::collections::HashMap, @@ -113,7 +113,7 @@ impl DocState { untitled, made_rev: 1, cache: LayoutCache::new(), - layout: None, + layout: Default::default(), typing: None, originals: Default::default(), picture_looks: Default::default(), diff --git a/crates/engine/src/session/views.rs b/crates/engine/src/session/views.rs new file mode 100644 index 0000000000..98287badca --- /dev/null +++ b/crates/engine/src/session/views.rs @@ -0,0 +1,154 @@ +//! Several windows on one document. The document, its undo history, dirty flag, path and save +//! state are shared; each window has its own [`WindowView`]: where its caret is and how it shows +//! the document. The session holds one view per document (the drawn window's, for the active +//! document); a front end keeps the other windows' views and moves them in and out with +//! [`Session::take_view`] and [`Session::put_view`] as it draws each window. + +use wordcraft_doc::props::CharProps; +use wordcraft_doc::{Pos, StoryRef}; + +use super::{ColumnBlock, MathEdit, Selection, Session, TypedWord, ViewState, Widened}; + +/// One window's view of a document: its selection and caret state, and how it shows the page +/// (zoom, view mode, panes, ruler…). Positions in it may be stale after edits made through +/// another window; [`Session::put_view`] makes them valid again. +pub struct WindowView { + sel: Selection, + view: ViewState, + pending: Option, + goal_x: Option, + page_hint: usize, + hf_return: Option, + typing_open: bool, + join_next: bool, + typing: Option, + math: Option, + also_selected: Vec, + column: Option, + column_mode: bool, + widened: Option, + /// The document and its revision when the view was taken out. + document_id: u64, + rev: u64, +} + +impl WindowView { + /// The document this view is of. + pub fn document_id(&self) -> u64 { + self.document_id + } + + /// The view's selection. + pub fn selection(&self) -> &Selection { + &self.sel + } + + /// How the view shows the document. + pub fn view(&self) -> &ViewState { + &self.view + } +} + +impl Session { + /// Take the active document's view out of the session, for a window that stops being drawn. + /// The session is left with a caret at the start of the document and the default view + /// until [`Session::put_view`] puts a view back. + pub fn take_view(&mut self) -> WindowView { + use std::mem::{replace, take}; + let start = self.doc.start_of(StoryRef::Body); + WindowView { + sel: replace(&mut self.sel, Selection::caret(start)), + view: take(&mut self.view), + pending: self.pending.take(), + goal_x: self.goal_x.take(), + page_hint: take(&mut self.page_hint), + hf_return: self.hf_return.take(), + typing_open: take(&mut self.typing_open), + join_next: take(&mut self.join_next), + typing: self.typing.take(), + math: self.math.take(), + also_selected: take(&mut self.also_selected), + column: self.column.take(), + column_mode: take(&mut self.column_mode), + widened: self.widened.take(), + document_id: self.document_id, + rev: self.rev, + } + } + + /// A copy of the active document's view, for a new window on it (View › New Window). + pub fn copy_view(&self) -> WindowView { + WindowView { + sel: self.sel.clone(), + view: self.view.clone(), + pending: self.pending.clone(), + goal_x: self.goal_x, + page_hint: self.page_hint, + hf_return: self.hf_return.clone(), + // The new window hasn't typed: its first key starts an undo step of its own. + typing_open: false, + join_next: false, + typing: None, + math: self.math.clone(), + also_selected: self.also_selected.clone(), + column: self.column.clone(), + column_mode: self.column_mode, + widened: self.widened.clone(), + document_id: self.document_id, + rev: self.rev, + } + } + + /// Make `v` the active document's view. When the document changed since `v` was taken + /// (another window edited it, or `v` is of another document), its positions are clamped into + /// the document, what only made sense for the text it was taken from (a column block, a + /// widened table selection, more selected objects, the word being typed) is dropped, and the + /// window's typing no longer joins its last undo step. + pub fn put_view(&mut self, v: WindowView) { + let WindowView { + sel, + view, + pending, + goal_x, + page_hint, + hf_return, + typing_open, + join_next, + typing, + math, + also_selected, + column, + column_mode, + widened, + document_id, + rev, + } = v; + let current = document_id == self.document_id && rev == self.rev; + self.sel = sel; + self.view = view; + self.pending = pending; + self.goal_x = goal_x; + self.page_hint = page_hint; + self.column_mode = column_mode; + self.math = math; + if current { + self.hf_return = hf_return; + self.typing_open = typing_open; + self.join_next = join_next; + self.typing = typing; + self.also_selected = also_selected; + self.column = column; + self.widened = widened; + return; + } + self.hf_return = hf_return.map(|s| Selection { anchor: self.doc.clamp(&s.anchor), focus: self.doc.clamp(&s.focus) }); + self.typing_open = false; + self.join_next = false; + self.typing = None; + self.also_selected = Vec::new(); + self.column = None; + self.widened = None; + self.goal_x = None; + self.clamp_selection(); + } +} diff --git a/crates/engine/src/tests_documents.rs b/crates/engine/src/tests_documents.rs index a1dd44a143..6c83716fe7 100644 --- a/crates/engine/src/tests_documents.rs +++ b/crates/engine/src/tests_documents.rs @@ -273,3 +273,59 @@ fn a_stand_in_document_gives_back_its_untitled_name() { run(&mut s, "file.new", json!({})); assert_eq!(s.documents()[2].title, "Document3"); } + +/// Two windows on one document: each keeps its own caret and zoom; a caret inside text another +/// window deleted is clamped, so typing there lands at the end of what is left; Undo in either +/// window undoes the document's last edit, and typing in one window never joins the other's +/// undo step. +#[test] +fn two_views_of_one_document_keep_their_own_caret_and_share_undo() { + let mut s = s(); + run(&mut s, "text.insert", json!({"text": "Hello world"})); + let mut b = s.copy_view(); + let a_caret = s.sel.focus.clone(); + + // Window B: zoom in; its caret is after "world". + let mut a = s.take_view(); + s.put_view(b); + run(&mut s, "view.zoom", json!({"value": 150})); + b = s.take_view(); + s.put_view(a); + assert_eq!(s.view.zoom, 1.0, "window A keeps its zoom"); + + // Window A deletes " world", where B's caret is. + run(&mut s, "select.text", json!({"text": " world"})); + run(&mut s, "text.delete", json!({})); + assert_eq!(text(&s).trim(), "Hello"); + a = s.take_view(); + s.put_view(b); + assert!((s.view.zoom - 1.5).abs() < 1e-6, "window B keeps its zoom"); + assert_eq!(s.sel.focus.off, 5, "B's caret is clamped into what is left"); + run(&mut s, "text.insert", json!({"text": "!"})); + assert_eq!(text(&s).trim(), "Hello!"); + + // Undo in A undoes B's typing; Undo in B then undoes A's delete. + b = s.take_view(); + s.put_view(a); + run(&mut s, "edit.undo", json!({})); + assert_eq!(text(&s).trim(), "Hello"); + a = s.take_view(); + s.put_view(b); + run(&mut s, "edit.undo", json!({})); + assert_eq!(text(&s).trim(), "Hello world"); + + // Typing in A, then B, then A again: three undo steps. + b = s.take_view(); + s.put_view(a); + s.sel = crate::Selection::caret(a_caret); + run(&mut s, "text.insert", json!({"text": "x"})); + a = s.take_view(); + s.put_view(b); + run(&mut s, "text.insert", json!({"text": "y"})); + b = s.take_view(); + s.put_view(a); + run(&mut s, "text.insert", json!({"text": "z"})); + run(&mut s, "edit.undo", json!({})); + assert!(!text(&s).contains('z') && text(&s).contains('x') && text(&s).contains('y'), "{}", text(&s)); + drop(b); +} diff --git a/crates/ui-egui/src/documents.rs b/crates/ui-egui/src/documents.rs index 035261d596..601dd3c8f2 100644 --- a/crates/ui-egui/src/documents.rs +++ b/crates/ui-egui/src/documents.rs @@ -179,16 +179,21 @@ pub(crate) fn untitled_title(english: &str) -> Option { /// View › Window › Switch Windows: the open windows (in one window, the open documents) in the /// order they were opened, this one marked, unsaved ones with the title bar's dot. Choosing one -/// brings its window to the front (in one window, shows the document). +/// brings that window to the front (in one window, shows the document). pub(crate) fn switch_windows_menu(ui: &mut egui::Ui, app: &mut WordApp) { - let entries: Vec<(u64, String, bool)> = if app.windows.native() { + let native = app.windows.native(); + let entries: Vec<(u64, String, bool)> = if native { app.switch_windows_entries() } else { app.session.documents().into_iter().map(|d| (d.id, format!("{}{}", display_title(&d), if d.dirty { " •" } else { "" }), d.active)).collect() }; - for (doc, label, current) in entries { + for (key, label, current) in entries { if ui.add(egui::Button::selectable(current, label).min_size(egui::vec2(220.0, 0.0))).clicked() { - let _ = app.run("view.switchWindows", json!({"id": doc})); + if native { + app.show_window(key); + } else { + let _ = app.run("view.switchWindows", json!({"id": key})); + } ui.close(); } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 94d58b4b7f..aec650718b 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -986,7 +986,10 @@ impl WordApp { self.dialog = dialogs::Dialog::open(name, self); return Ok(json!({"pending": name})); } - if (self.session.dirty || self.save_unconfirmed() || refused.is_some()) && discards_document(id, ¶ms) { + if (self.session.dirty || self.save_unconfirmed() || refused.is_some()) + && discards_document(id, ¶ms) + && !self.closes_only_its_window(id, ¶ms) + { let name = self.prompt_name(); self.dialog = Some(dialogs::Dialog::SaveChanges { name, @@ -1454,7 +1457,8 @@ impl WordApp { self.quit_requested } - /// File › Close (Mod+W, Mod+F4) closes the shown document. Its window closes too while other + /// File › Close (Mod+W, Mod+F4) closes the shown document, or only its window while other + /// windows show the document (View › New Window), asking nothing then. Its window closes too while other /// windows are open, and the one focused before it comes forward (in the web app's one window, /// the next document shows). The last window stays, as Word's does (#1712), with no page, as /// after Esc on the Start screen (#1129), and File › New or Open goes on from there. `{"quit": true}`, @@ -1464,6 +1468,9 @@ impl WordApp { /// changes are never discarded unasked: a dirty document closes only with `{"force": true}` /// (Don't Save in the prompt passes it). fn close_document(&mut self, params: &Value) -> Result { + if self.closes_only_its_window("file.close", params) && self.close_window_only() { + return Ok(json!({"closed": true, "window": true})); + } if let Some(e) = control::close_refused(self, "file.close", params) { return Err(e.into()); } @@ -1592,6 +1599,7 @@ impl WordApp { } } "pasteSpecial" => self.dialog = Some(dialogs::Dialog::paste_special(self, req)), + "newWindow" => self.new_window(), other => self.dialog = dialogs::Dialog::open(other, self), } } diff --git a/crates/ui-egui/src/windows.rs b/crates/ui-egui/src/windows.rs index 303af77c74..b83a5e207e 100644 --- a/crates/ui-egui/src/windows.rs +++ b/crates/ui-egui/src/windows.rs @@ -19,12 +19,20 @@ //! document nobody used in its own window, and the last window stays open with no document (the //! empty window the Start screen also leaves). The web app has one window and switches documents //! in it ([`crate::documents`]). +//! +//! View › New Window opens another window on a document. Each window on it has its own view of +//! it (selection, zoom, view mode, panes: [`WindowView`]); the session holds the drawn window's, +//! and the others keep theirs in their [`Window`]. What the app keeps per document +//! (AutoSave's file, comment drafts) stays in one place, shared by its windows. Closing one of a +//! document's windows leaves the document open and asks nothing; closing its last window closes +//! it. use std::collections::HashMap; use craft_window::{Document, Frame, Viewports, WindowEvent, WindowId, WindowList, WindowMenu}; use egui::{ViewportCommand, ViewportId}; use serde_json::{Value, json}; +use wordcraft_engine::WindowView; use crate::{WordApp, documents, window_ui::WindowUi}; @@ -48,6 +56,10 @@ pub(crate) struct Window { requests: Vec, /// The title it last drew, for its viewport. title: String, + /// Its view of the document (caret, zoom, view mode…) while another window on the same + /// document is drawn. `None` while the session holds it: for the drawn window, and for the + /// window on each other document whose view is parked with that document. + view: Option, } /// The app's windows. See the module documentation. @@ -91,7 +103,7 @@ impl Windows { native: cfg!(not(target_arch = "wasm32")), list, viewports: Some(viewports), - open: vec![Window { key: 1, id: Some(id), doc, ui: None, requests: Vec::new(), title: String::new() }], + open: vec![Window { key: 1, id: Some(id), doc, ui: None, requests: Vec::new(), title: String::new(), view: None }], shown: 1, next_key: 2, showing: false, @@ -306,6 +318,7 @@ impl WordApp { return false; }; let Some(mut state) = w.ui.take() else { return false }; + let view = w.view.take(); let from = self.shown_document; self.swap_window(&mut state); let left = std::mem::replace(&mut self.windows.shown, key); @@ -315,23 +328,76 @@ impl WordApp { } let to = self.shown_document; self.carry_document_state(from, to); + if let Some(view) = view { + self.swap_view_in(key, to, view); + } true } + /// Window `key`, on document `doc` (now the session's active one), brings its own view of the + /// document: the view the session holds goes to the window on `doc` it belongs to (the one + /// without a view of its own), and `view` takes its place, made valid for edits made since. + fn swap_view_in(&mut self, key: u64, doc: u64, view: WindowView) { + let held = self.session.take_view(); + if let Some(owner) = self.windows.open.iter_mut().find(|w| w.key != key && w.doc == doc && w.view.is_none()) { + owner.view = Some(held); + } + self.session.put_view(view); + } + /// Open a new window for open document `doc`, focused, and draw it. The window that was drawn - /// leaves the File page: whatever it was opening is open. - fn open_window(&mut self, doc: u64) { + /// leaves the File page: whatever it was opening is open. `view` is the new window's view of + /// a document another window shows (View › New Window); `None` for a document no window + /// shows yet. + fn open_window(&mut self, doc: u64, view: Option) { let key = self.windows.next_key; self.windows.next_key = self.windows.next_key.saturating_add(1); let mut state = Box::new(WindowUi::new(doc)); // Faces borrowed for the interface (non-Latin file names) stay installed. state.canvas.ime_faces = self.canvas.ime_faces.clone(); self.ui.backstage = false; - self.windows.open.push(Window { key, id: None, doc, ui: Some(state), requests: Vec::new(), title: String::new() }); + self.windows.open.push(Window { key, id: None, doc, ui: Some(state), requests: Vec::new(), title: String::new(), view }); self.enter_window(key); self.focus_window(key); } + /// View › New Window: another window on the drawn window's document, with the same caret, + /// zoom and view, focused and cascaded from the others. Each window then has its own caret + /// and view; the document, its undo history and its save state are shared. In one window + /// (the web app) and with no document open, nothing happens. + pub(crate) fn new_window(&mut self) { + if !self.windows.native || !self.has_document() { + return; + } + let view = self.session.copy_view(); + self.open_window(self.shown_document, Some(view)); + } + + /// The window that comes forward when the drawn window closes and other windows show its + /// document, which then stays open: the one of all the others focused last. `None` when the + /// drawn window is its document's only one. + pub(crate) fn other_window_on_document(&self) -> Option { + let shown = self.windows.shown; + let doc = self.windows.get(shown)?.doc; + if !self.windows.native || !self.windows.open.iter().any(|w| w.key != shown && w.doc == doc) { + return None; + } + self.windows.recent_keys().into_iter().find(|&k| k != shown) + } + + /// Whether command `id` (File › Close) closes the drawn window alone, asking nothing: other + /// windows show its document. Quitting closes documents, not windows. + pub(crate) fn closes_only_its_window(&self, id: &str, params: &Value) -> bool { + id == "file.close" && params.get("quit").and_then(Value::as_bool) != Some(true) && self.other_window_on_document().is_some() + } + + /// Close the drawn window alone: other windows show its document, which stays open as it is. + pub(crate) fn close_window_only(&mut self) -> bool { + let Some(next) = self.other_window_on_document() else { return false }; + self.close_shown_window(next); + true + } + /// The drawn window's document has closed: draw window `to` and close this one. fn close_shown_window(&mut self, to: u64) { let closing = self.windows.shown; @@ -364,7 +430,7 @@ impl WordApp { } } - /// Keep one window per open document: windows whose document closed (an agent closed it) + /// Keep a window for every open document: windows whose document closed (an agent closed it) /// close, and documents with no window (an agent made one while another was active) get one /// behind the others. The active document is left to [`WordApp::sync_document`]. pub(crate) fn prune_windows(&mut self) { @@ -382,7 +448,7 @@ impl WordApp { self.windows.next_key = self.windows.next_key.saturating_add(1); let mut state = Box::new(WindowUi::new(d.id)); state.canvas.ime_faces = self.canvas.ime_faces.clone(); - self.windows.open.push(Window { key, id: None, doc: d.id, ui: Some(state), requests: Vec::new(), title: String::new() }); + self.windows.open.push(Window { key, id: None, doc: d.id, ui: Some(state), requests: Vec::new(), title: String::new(), view: None }); changed = true; } if changed { @@ -404,7 +470,7 @@ impl WordApp { match other { Some(key) if self.enter_window(key) => self.focus_window(key), _ if stand_in => return false, - _ => self.open_window(id), + _ => self.open_window(id, None), } return true; } @@ -458,8 +524,10 @@ impl WordApp { true } - /// View › Window › Switch Windows with native windows: every window by its title, the active - /// one marked, unsaved ones with the title bar's dot. Choosing one brings it to the front. + /// View › Window › Switch Windows with native windows: every window by its title (two + /// windows on one document as `Name:1`, `Name:2`) with its key, the active one marked, + /// unsaved ones with the title bar's dot. Choosing one brings it to the front + /// ([`WordApp::show_window`]). pub(crate) fn switch_windows_entries(&self) -> Vec<(u64, String, bool)> { self.windows .menu @@ -467,11 +535,18 @@ impl WordApp { .iter() .filter_map(|e| { let w = self.windows.open.iter().find(|w| w.id == Some(e.id))?; - Some((w.doc, format!("{}{}", e.title, if e.dirty { " •" } else { "" }), w.key == self.windows.shown)) + Some((w.key, format!("{}{}", e.title, if e.dirty { " •" } else { "" }), w.key == self.windows.shown)) }) .collect() } + /// Bring window `key` to the front and make it the active one, with its document. + pub(crate) fn show_window(&mut self, key: u64) { + if self.enter_window(key) { + self.focus_window(key); + } + } + /// The drawn window's title: its document's name as View › Window › Switch Windows gives it. pub(crate) fn window_title(&self) -> String { if !self.has_document() { @@ -1096,4 +1171,84 @@ mod tests { a.run("ui.saveChanges", json!({"answer": "dontSave"})).unwrap(); assert!(a.quit_requested); } + + fn window_labels(a: &WordApp) -> Vec { + let mut l: Vec = a.switch_windows_entries().into_iter().map(|(_, label, _)| label).collect(); + l.sort(); + l + } + + /// View › New Window opens a second window on the document, titled `Name:1` / `Name:2`. Text + /// typed in one shows in the other; each keeps its own caret and zoom. Typing in a window + /// whose caret was in text the other window deleted lands at the end of what is left, and + /// Undo in that window undoes its own typing, then the other window's delete. + #[test] + fn new_window_shows_the_document_twice_each_with_its_own_caret_and_zoom() { + let ctx = backend(); + let mut a = app("Hello world"); + let doc = a.session.document_id(); + frames(&ctx, &mut a, 2); + a.run("view.newWindow", json!({})).unwrap(); + assert_eq!((a.window_count(), a.session.document_id()), (2, doc)); + frames(&ctx, &mut a, 3); + let b = a.active_viewport(); + assert_ne!(b, ViewportId::ROOT); + assert_eq!(window_labels(&a), ["Document1:1 •", "Document1:2 •"]); + assert_eq!(a.window_title(), super::title("Document1:2", true)); + + a.run("view.zoom", json!({"value": 150})).unwrap(); + a.run("text.insert", json!({"text": "!"})).unwrap(); + focus(ViewportId::ROOT); + frames(&ctx, &mut a, 2); + assert_eq!(a.active_viewport(), ViewportId::ROOT); + assert_eq!(text(&a), "Hello world!", "the edit shows in the other window"); + assert_eq!((a.session.view.zoom, a.session.sel.focus.off), (1.0, 11), "window A keeps its zoom and caret"); + + a.run("select.text", json!({"text": " world!"})).unwrap(); + a.run("text.delete", json!({})).unwrap(); + focus(b); + frames(&ctx, &mut a, 2); + assert_eq!(a.active_viewport(), b); + assert!((a.session.view.zoom - 1.5).abs() < 1e-6, "window B keeps its zoom"); + a.run("text.insert", json!({"text": "?"})).unwrap(); + assert_eq!(text(&a), "Hello?", "B's caret was clamped into what is left"); + a.run("edit.undo", json!({})).unwrap(); + assert_eq!(text(&a), "Hello"); + a.run("edit.undo", json!({})).unwrap(); + assert_eq!(text(&a), "Hello world!", "Undo in B undoes A's delete"); + frames(&ctx, &mut a, 2); + assert_eq!(a.session.documents().len(), 1); + } + + /// Closing one of two windows on a document asks nothing, even with unsaved changes, and the + /// other window goes back to the plain name; closing the last one asks. Quitting with a + /// document in two windows asks once. + #[test] + fn closing_one_of_two_windows_on_a_document_asks_nothing() { + let ctx = backend(); + let mut a = app("Kept"); + let doc = a.session.document_id(); + frames(&ctx, &mut a, 2); + a.run("view.newWindow", json!({})).unwrap(); + frames(&ctx, &mut a, 2); + let child = a.active_viewport(); + INFOS.with(|i| i.borrow_mut().entry(child).or_default().events.push(ViewportEvent::Close)); + frame(&ctx, &mut a); + assert_eq!(prompt(&a), None, "no prompt while another window shows the document"); + assert_eq!((a.window_count(), a.session.document_id(), text(&a).as_str()), (1, doc, "Kept")); + assert!(a.session.dirty); + frames(&ctx, &mut a, 2); + assert_eq!(a.window_title(), super::title("Document1", true)); + a.run("file.close", json!({})).unwrap(); + assert_eq!(prompt(&a), Some("saveChanges"), "the last window asks"); + a.run("ui.saveChanges", json!({"answer": "cancel"})).unwrap(); + + a.run("view.newWindow", json!({})).unwrap(); + frames(&ctx, &mut a, 2); + assert_eq!(a.window_count(), 2); + assert!(!a.quit()); + assert_eq!(prompt(&a), Some("saveChanges")); + a.run("ui.saveChanges", json!({"answer": "dontSave"})).unwrap(); + assert!(a.quit_requested, "one prompt for the document"); + } } diff --git a/docs/ui-parity.md b/docs/ui-parity.md index bcee769746..f0d037c71b 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** trivial (one native window per open document: New and Open open a window of their own, Switch Windows brings a window forward, Close and the close button close the window, quitting asks in each window; previously trivial: several open documents in one window: View › Window › Switch Windows lists them and shows one; File › Close shows the next; quitting asks about each one with unsaved changes; previously trivial: while editing a header or footer, a click in the other one edits it there without scrolling; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Insert Table of Figures dialog: any caption label, #3013; previously trivial: Home › Underline drop-down shows its lines, #2738; previously trivial: the text context menu includes the formatting mini toolbar, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: the scrollbar page tip stays on one line as the page number grows, #3341; previously trivial: File › Home and New: templates and recent documents are buttons named for screen readers, #3176; previously trivial: forms protection greys out the Developer tab's control buttons and Properties; previously trivial: AltGr doesn't open KeyTips, #2388; previously trivial: closing the header or footer puts the caret back where it was in the body, #3331; previously trivial: Format Page Numbers dialog: number format and Continue from previous section / Start at, #1113; previously trivial: inline pictures drag to a dashed insertion cursor and stay inline, #2233, #2234; previously trivial: Save Changes prompt: a text file saved over itself closes, #2376; previously trivial: the equation being edited is framed as in Word: an outline as tall as its line, a grip, an options strip that opens its menu, and its current argument shaded; previously trivial: the table handles show while the caret is in the table, #2428; previously trivial: legacy menu keys, #1697; previously trivial: Shape Fill › Gradient and the Format Shape pane, #1500; previously trivial: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: the text pointer over the page is drawn black or white on Windows, #1746; previously trivial: a click in the header/footer band from the body no longer scrolls to the body text, #2103; previously trivial: File › Recover follows the app theme, #3243; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: dragging the vertical scrollbar names the page at the top of the window, #2551; previously trivial: Add Font imports every selected font file, #1789); previously trivial (File › Close keeps the window, #1712; previously trivial: the Spike: Ctrl+F3 and Ctrl+Shift+F3; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: the tab strip never draws tabs over Comments / Editing / Share, #2026; previously trivial: Columns on selected text, #2024; previously trivial: Different First Page headers reachable from page 1, #2826; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: Read Mode keeps the keyboard, shows whole columns and keeps text clear of the page-turn bar, #1772); previously trivial: font list entries of tall-ascent fonts sit in their row, not cut off, #2570); previously trivial: Split Cells and Convert Text to Table dialogs follow Word's merged-cell limits and Initial column width, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout text fills the window width, #1876; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717)); previously trivial: comment balloons readable in the dark theme, #2045; previously trivial: Recently Used Fonts section at the top of the font list, #2068; previously trivial: web preferences kept between visits, #2132; previously trivial: dialog close control: a Windows-style box at the title bar's right end, the whole box clickable, red on hover, #1885); previously trivial (Modify Table Style formats every table style part, #714); previously trivial: a click on the ribbon chrome hands the keyboard to the page in the same frame, #1093 follow-up; previously trivial: Page Setup vertical alignment, #1320; previously trivial: the app opens on the Home tab every launch, as in Word; previously trivial: no document behind the Start screen, #1129; previously trivial: equation pictures share a few textures, #1002; previously trivial: selection bar in the left margin, #856; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously major: first version; ribbon, dialogs, shortcuts and on-canvas interaction measured from `crates/ui-egui` and the command registry) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** trivial (View › Window › New Window opens another window on the document, each with its own caret and view; previously trivial: one native window per open document: New and Open open a window of their own, Switch Windows brings a window forward, Close and the close button close the window, quitting asks in each window; previously trivial: several open documents in one window: View › Window › Switch Windows lists them and shows one; File › Close shows the next; quitting asks about each one with unsaved changes; previously trivial: while editing a header or footer, a click in the other one edits it there without scrolling; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: Insert Table of Figures dialog: any caption label, #3013; previously trivial: Home › Underline drop-down shows its lines, #2738; previously trivial: the text context menu includes the formatting mini toolbar, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: the scrollbar page tip stays on one line as the page number grows, #3341; previously trivial: File › Home and New: templates and recent documents are buttons named for screen readers, #3176; previously trivial: forms protection greys out the Developer tab's control buttons and Properties; previously trivial: AltGr doesn't open KeyTips, #2388; previously trivial: closing the header or footer puts the caret back where it was in the body, #3331; previously trivial: Format Page Numbers dialog: number format and Continue from previous section / Start at, #1113; previously trivial: inline pictures drag to a dashed insertion cursor and stay inline, #2233, #2234; previously trivial: Save Changes prompt: a text file saved over itself closes, #2376; previously trivial: the equation being edited is framed as in Word: an outline as tall as its line, a grip, an options strip that opens its menu, and its current argument shaded; previously trivial: the table handles show while the caret is in the table, #2428; previously trivial: legacy menu keys, #1697; previously trivial: Shape Fill › Gradient and the Format Shape pane, #1500; previously trivial: Focus fills the screen, and Full Screen View shows the pages alone, #1678; previously trivial: the text pointer over the page is drawn black or white on Windows, #1746; previously trivial: a click in the header/footer band from the body no longer scrolls to the body text, #2103; previously trivial: File › Recover follows the app theme, #3243; previously trivial: long navigation headings wrap onto further lines within the pane, #3015; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: dragging the vertical scrollbar names the page at the top of the window, #2551; previously trivial: Add Font imports every selected font file, #1789); previously trivial (File › Close keeps the window, #1712; previously trivial: the Spike: Ctrl+F3 and Ctrl+Shift+F3; previously trivial: Ctrl+wheel zoom no longer scrolls the document, #1743; previously trivial: a mouse drag on a table leaves the view where it is, #1738; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: the tab strip never draws tabs over Comments / Editing / Share, #2026; previously trivial: Columns on selected text, #2024; previously trivial: Different First Page headers reachable from page 1, #2826; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: Read Mode keeps the keyboard, shows whole columns and keeps text clear of the page-turn bar, #1772); previously trivial: font list entries of tall-ascent fonts sit in their row, not cut off, #2570); previously trivial: Split Cells and Convert Text to Table dialogs follow Word's merged-cell limits and Initial column width, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout text fills the window width, #1876; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717)); previously trivial: comment balloons readable in the dark theme, #2045; previously trivial: Recently Used Fonts section at the top of the font list, #2068; previously trivial: web preferences kept between visits, #2132; previously trivial: dialog close control: a Windows-style box at the title bar's right end, the whole box clickable, red on hover, #1885); previously trivial (Modify Table Style formats every table style part, #714); previously trivial: a click on the ribbon chrome hands the keyboard to the page in the same frame, #1093 follow-up; previously trivial: Page Setup vertical alignment, #1320; previously trivial: the app opens on the Home tab every launch, as in Word; previously trivial: no document behind the Start screen, #1129; previously trivial: equation pictures share a few textures, #1002; previously trivial: selection bar in the left margin, #856; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously major: first version; ribbon, dialogs, shortcuts and on-canvas interaction measured from `crates/ui-egui` and the command registry) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 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). @@ -76,6 +76,7 @@ Footnote and endnote editing keeps body and note text at normal opacity, on ligh | Date | Change | Summary | |---|---|---| +| 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 | | 2026-10-22 | trivial | While editing a header, a single click in the footer (or the reverse, or another page's own header) edits that one with the caret where clicked; the click was placed in the header being edited, and the view scrolled back to it, so a double-click on the footer rarely landed | From 08da5c5433d0b494bc3ab6e7dcb5d9de34cd73c2 Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Thu, 22 Oct 2026 15:11:51 +0100 Subject: [PATCH 021/199] #3699: MCP bridge sends the control key only to 127.0.0.1, where the app listens Audit finding 9 (CWE-522). The bridge sent the reusable control key to any loopback peer (127.0.0.0/8, ::1) and tried every address `localhost` resolved to, while the app binds only 127.0.0.1. Another local user could listen on ::1 or 127.0.0.2 and receive the key. Now `localhost` connects to 127.0.0.1 only, and the key goes only to a 127.0.0.1 peer. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/mcp/src/backend.rs | 86 +++++++++++++++++++++++++++------------ docs/control-protocol.md | 4 +- 2 files changed, 63 insertions(+), 27 deletions(-) diff --git a/crates/mcp/src/backend.rs b/crates/mcp/src/backend.rs index ac32f5d32b..1cf17c61d5 100644 --- a/crates/mcp/src/backend.rs +++ b/crates/mcp/src/backend.rs @@ -1,7 +1,7 @@ //! Where MCP tool calls end up: a control-channel method call. use std::io::{BufRead, BufReader, Write}; -use std::net::{IpAddr, SocketAddr, TcpStream, ToSocketAddrs}; +use std::net::{IpAddr, Ipv4Addr, SocketAddr, TcpStream, ToSocketAddrs}; use std::path::Path; use std::time::Duration; @@ -26,7 +26,7 @@ pub struct Remote { addr: String, conn: Option<(BufReader, TcpStream)>, next_id: u64, - /// The connection leads to a loopback address: requests carry the control key. + /// The connection leads to the app's 127.0.0.1: requests carry the control key. send_key: bool, /// The control key, looked up once per connection (the app picks a new one at every start). key: Option, @@ -55,25 +55,46 @@ fn find_key(addr: &str) -> Result { lookup_key(std::env::var("WORDCRAFT_CONTROL_KEY").ok(), wordcraft_control_key::settings_dir().as_deref(), port) } -/// Whether `addr` (`HOST:PORT`, as given to `--connect`) names a loopback address: 127.0.0.0/8, -/// ::1 or `localhost`. Other host names are not resolved: they never count. -fn is_loopback_addr(addr: &str) -> bool { - let loopback = |ip: IpAddr| ip.to_canonical().is_loopback(); - if let Ok(sa) = addr.parse::() { - return loopback(sa.ip()); - } - if let Ok(ip) = addr.parse::() { - return loopback(ip); - } +/// The only address the app's control server binds (`apps/wordcraft/src/control_server.rs`). +const APP_IP: IpAddr = IpAddr::V4(Ipv4Addr::LOCALHOST); + +/// The host part of `addr` (`HOST:PORT`, `[V6]:PORT`), without brackets. +fn host_of(addr: &str) -> &str { let host = addr.rsplit_once(':').map_or(addr, |(h, _)| h); - let host = host.strip_prefix('[').and_then(|h| h.strip_suffix(']')).unwrap_or(host); - host.parse::().map_or_else(|_| host.eq_ignore_ascii_case("localhost"), loopback) + host.strip_prefix('[').and_then(|h| h.strip_suffix(']')).unwrap_or(host) } -/// The key goes only to a loopback `--connect` address, and only when the connection really -/// leads to one (`peer`, so a `localhost` that resolves elsewhere gets no key either). +/// Whether `addr` (`HOST:PORT`, as given to `--connect`) names the app's own address: +/// 127.0.0.1 or `localhost`. Other loopback addresses (127.0.0.2, ::1) don't count: the app never +/// listens there, so whatever does is not the app. Other host names are not resolved. +fn is_app_addr(addr: &str) -> bool { + let app = |ip: IpAddr| ip.to_canonical() == APP_IP; + if let Ok(sa) = addr.parse::() { + return app(sa.ip()); + } + if let Ok(ip) = addr.parse::() { + return app(ip); + } + let host = host_of(addr); + host.parse::().map_or_else(|_| host.eq_ignore_ascii_case("localhost"), app) +} + +/// The key goes only to the app's `--connect` address, and only when the connection really +/// leads to 127.0.0.1 (`peer`): a listener on another loopback address (::1, 127.0.0.2), which +/// any local user can open, never sees it. fn may_send_key(addr: &str, peer: Option) -> bool { - is_loopback_addr(addr) && peer.is_some_and(|p| p.ip().to_canonical().is_loopback()) + is_app_addr(addr) && peer.is_some_and(|p| p.ip().to_canonical() == APP_IP) +} + +/// Where to connect for `addr`: `localhost` means the app's 127.0.0.1 (not whatever the resolver +/// lists first, which may be ::1); anything else as resolved. +fn targets(addr: &str) -> std::io::Result> { + if host_of(addr).eq_ignore_ascii_case("localhost") + && let Some(port) = addr.rsplit_once(':').and_then(|(_, p)| p.parse::().ok()) + { + return Ok(vec![SocketAddr::new(APP_IP, port)]); + } + Ok(addr.to_socket_addrs()?.collect()) } impl Remote { @@ -95,11 +116,12 @@ impl Remote { &self.addr } - /// Why requests go without the control key (the address is not loopback), if they do. + /// Why requests go without the control key (the address is not the app's 127.0.0.1), if they + /// do. pub fn key_warning(&self) -> Option { (!self.send_key).then(|| { format!( - "{} is not a loopback address, so the control key is not sent (it only goes to 127.0.0.0/8, ::1 and localhost) and the app refuses requests; reach the app through 127.0.0.1, for example with an SSH tunnel", + "{} is not the app's address, so the control key is not sent (it only goes to 127.0.0.1 and localhost, where the app listens) and the app refuses requests; reach the app through 127.0.0.1, for example with an SSH tunnel", self.addr ) }) @@ -109,7 +131,7 @@ impl Remote { self.conn = None; self.key = None; let mut last = std::io::Error::new(std::io::ErrorKind::NotFound, format!("cannot resolve {}", self.addr)); - for sa in self.addr.to_socket_addrs()? { + for sa in targets(&self.addr)? { match TcpStream::connect_timeout(&sa, Duration::from_millis(800)) { Ok(s) => { s.set_nodelay(true).ok(); @@ -256,15 +278,12 @@ mod tests { } #[test] - fn key_only_for_loopback_addresses() { + fn key_only_for_the_apps_address() { let peer = |s: &str| s.parse::().ok(); for (addr, at) in [ ("127.0.0.1:7981", "127.0.0.1:7981"), - ("127.0.0.2:7981", "127.0.0.2:7981"), - ("[::1]:7981", "[::1]:7981"), ("[::ffff:127.0.0.1]:7981", "[::ffff:127.0.0.1]:7981"), ("localhost:7981", "127.0.0.1:7981"), - ("localhost:7981", "[::1]:7981"), ("LocalHost:7981", "127.0.0.1:7981"), ] { assert!(may_send_key(addr, peer(at)), "{addr} at {at}"); @@ -274,6 +293,11 @@ mod tests { ("10.0.0.7:7981", "10.0.0.7:7981"), ("[fe80::1]:7981", "[fe80::1]:7981"), ("0.0.0.0:7981", "127.0.0.1:7981"), + // Loopback addresses the app never binds: another local user's listener (#3699). + ("127.0.0.2:7981", "127.0.0.2:7981"), + ("[::1]:7981", "[::1]:7981"), + ("localhost:7981", "[::1]:7981"), + ("127.0.0.1:7981", "127.0.0.2:7981"), // Host names other than localhost: even when they lead to this machine. ("workstation:7981", "127.0.1.1:7981"), ("example.org:7981", "93.184.215.14:7981"), @@ -287,6 +311,16 @@ mod tests { assert!(!may_send_key("127.0.0.1:7981", None)); } + #[test] + fn localhost_connects_to_the_apps_address_only() { + // Not the resolver's list, which may put ::1 (where anyone can listen) first. + let at: SocketAddr = "127.0.0.1:7981".parse().unwrap(); + assert_eq!(targets("localhost:7981").unwrap(), vec![at]); + assert_eq!(targets("LOCALHOST:7981").unwrap(), vec![at]); + assert_eq!(targets("127.0.0.1:7981").unwrap(), vec![at]); + assert_eq!(targets("[::1]:7981").unwrap(), vec!["[::1]:7981".parse::().unwrap()]); + } + /// A stand-in for the app on 127.0.0.1: every request line goes to the returned channel and /// gets `answer(request)`: a reply and whether to close the connection after it, or `None` /// to close it without a reply (the app quit). @@ -377,9 +411,9 @@ mod tests { let finder: KeyFinder = Box::new(|_| Err("the key must not be read for another host".to_string())); let mut r = Remote::connect_with(&format!("127.0.0.1:{port}"), finder).unwrap(); r.send_key = false; - assert!(r.key_warning().is_some_and(|w| w.contains("not a loopback address"))); + assert!(r.key_warning().is_some_and(|w| w.contains("not the app's address"))); let e = r.call("document.inspect", json!({})).unwrap_err(); - assert!(e.contains("not a loopback address") && e.contains("not sent"), "{e}"); + assert!(e.contains("not the app's address") && e.contains("not sent"), "{e}"); assert!(seen.recv_timeout(WAIT).unwrap().get("key").is_none()); } } diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 08c62c3d91..a88e0be9f9 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -43,7 +43,9 @@ cat ~/.config/wordcraft/control-key.local-7981 # Linux, outside Flatpak, port `wordcraft-cli mcp --connect` takes the key from `WORDCRAFT_CONTROL_KEY`, or else from the key file for the port it connects to, and reads it again on every new connection (the app may have -restarted). It sends the key only to loopback addresses (`127.0.0.0/8`, `::1`, `localhost`). For +restarted). It sends the key only to the app's own address, `127.0.0.1` (`localhost` connects +there too, never to `::1`): another loopback address such as `::1` or `127.0.0.2` is not where the +app listens, so whoever listens there gets no key (#3699). For any other host it sends no key, says so, and the app refuses its requests: to drive an app on another machine, forward its port to `127.0.0.1` (for example `ssh -L 7981:127.0.0.1:7981 host`) and set `WORDCRAFT_CONTROL_KEY` to that app's key. From 3d51cd3db264f01ff36bf973824e97abec4d8193 Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Thu, 22 Oct 2026 15:13:28 +0100 Subject: [PATCH 022/199] #3692: MCP type_text types its paragraphs as one edit and one undo step Audit finding 18 (CWE-407). `type_text` with `paragraphs: true` ran a text.insert and a text.newParagraph per line, each a mutating command with its own whole-document undo snapshot, so a long text did quadratic work and filled the undo history. It now sends one raw text.insert with `\r` paragraph breaks (Enter's split): one edit, one undo step, all or nothing. More than MAX_TYPED_PARAGRAPHS (10 000) lines is refused before anything is typed. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/mcp/src/tests.rs | 43 +++++++++++++++++++++++++++++++++-------- crates/mcp/src/tools.rs | 30 ++++++++++++++-------------- 2 files changed, 49 insertions(+), 24 deletions(-) diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index 781f24d02b..e000371344 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -120,17 +120,16 @@ fn headless_sessions_keep_the_dictionary_in_memory() { assert!(h.session.user_dict_path.is_none()); } -/// A backend whose paragraph break is refused while text insertion would succeed. +/// A backend that keeps every command it is sent and refuses text with a paragraph break. struct RefusesParagraph { - calls: std::sync::Arc>>, + calls: std::sync::Arc>>, } impl crate::backend::Backend for RefusesParagraph { fn call(&mut self, _method: &str, params: Value) -> Result { - let command = params["command"].as_str().unwrap_or("").to_string(); - self.calls.lock().unwrap().push(command.clone()); - match command.as_str() { - "text.newParagraph" => Err("paragraph break refused".into()), + self.calls.lock().unwrap().push(params.clone()); + match params["params"]["text"].as_str() { + Some(t) if t.contains('\r') => Err("paragraph break refused".into()), _ => Ok(json!({"ok": true})), } } @@ -142,7 +141,8 @@ impl crate::backend::Backend for RefusesParagraph { } } -/// #1356: a refused paragraph break is reported and the next line is not typed after it. +/// #1356: a refused paragraph break is reported, and nothing of the text goes in without it: the +/// lines are one command, all or nothing. #[test] fn type_text_stops_at_a_refused_paragraph_break() { let calls = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); @@ -150,5 +150,32 @@ fn type_text_stops_at_a_refused_paragraph_break() { let r = call(&mut s, 11, "tools/call", json!({"name": "type_text", "arguments": {"text": "one\ntwo", "paragraphs": true}})); assert_eq!(r["result"]["isError"], true, "{r}"); assert!(r["result"]["content"][0]["text"].as_str().unwrap().contains("paragraph break refused"), "{r}"); - assert_eq!(*calls.lock().unwrap(), vec!["text.insert", "text.newParagraph"]); + let calls = calls.lock().unwrap(); + assert_eq!(calls.len(), 1, "{calls:?}"); + assert_eq!(calls[0]["command"], "text.insert"); + assert_eq!(calls[0]["params"]["text"], "one\rtwo"); +} + +/// #3692: typed paragraphs are one edit and one undo step, not a command (and a whole-document +/// undo snapshot) per line; too many lines are refused before anything is typed. +#[test] +fn type_text_paragraphs_are_one_undo_step_with_a_line_cap() { + let mut s = Server::new(Box::new(Headless::default())); + tool(&mut s, "new_document", json!({})); + let lines: Vec = (0..2000).map(|i| format!("Line {i}")).collect(); + tool(&mut s, "type_text", json!({"text": lines.join("\r\n"), "paragraphs": true})); + let text = tool(&mut s, "get_text", json!({}))["text"].as_str().unwrap().to_string(); + assert!(text.contains("Line 0") && text.contains("Line 1999"), "{}", text.len()); + let paras = |s: &mut Server| tool(s, "inspect_document", json!({}))["blocks"].as_array().unwrap().len(); + assert_eq!(paras(&mut s), 2000); + tool(&mut s, "execute", json!({"command": "edit.undo"})); + assert_eq!(paras(&mut s), 1, "one Undo takes all the lines back out"); + assert!(!tool(&mut s, "get_text", json!({}))["text"].as_str().unwrap().contains("Line")); + + let calls = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let mut s = Server::new(Box::new(RefusesParagraph { calls: calls.clone() })); + let many = "x\n".repeat(crate::tools::MAX_TYPED_PARAGRAPHS); + let r = call(&mut s, 12, "tools/call", json!({"name": "type_text", "arguments": {"text": many, "paragraphs": true}})); + assert_eq!(r["result"]["isError"], true, "{r}"); + assert!(calls.lock().unwrap().is_empty(), "refused before any command"); } diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 47e162a0b8..3a46629b2f 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -102,7 +102,7 @@ pub fn tool_definitions() -> Value { tool( "type_text", "Type text", - "Type at the caret (replacing the selection). Use \\n... for line breaks inside text; use execute text.newParagraph for new paragraphs, or `paragraphs: true` to split lines into paragraphs.", + "Type at the caret (replacing the selection). Use \\n... for line breaks inside text; use execute text.newParagraph for new paragraphs, or `paragraphs: true` to split lines into paragraphs (one undo step, at most 10000 lines).", obj(json!({"text": s("Text"), "paragraphs": {"type": "boolean"}}), &["text"]), false ), @@ -154,6 +154,9 @@ pub fn tool_definitions() -> Value { ]) } +/// Most paragraphs `type_text` with `paragraphs: true` types in one call. +pub const MAX_TYPED_PARAGRAPHS: usize = 10_000; + fn exec(b: &mut dyn Backend, id: &str, params: Value) -> Result { b.call("engine.execute", json!({"command": id, "params": params})) } @@ -229,22 +232,17 @@ pub fn call_tool(b: &mut dyn Backend, name: &str, a: &Value) -> ToolResult { "type_text" => { let t = st("text").unwrap_or(""); if a.get("paragraphs").and_then(Value::as_bool).unwrap_or(false) { - let mut last = Ok(Value::Null); - for (i, line) in t.split('\n').enumerate() { - if i > 0 { - last = exec(b, "text.newParagraph", json!({})); - if last.is_err() { - break; - } - } - if !line.is_empty() { - last = exec(b, "text.insert", json!({"text": line, "raw": true})); - } - if last.is_err() { - break; - } + // One `text.insert`: `\r` is a paragraph break there (Enter's split), so the + // lines go in as one edit and one undo step, all or nothing, instead of a command + // (and a whole-document undo snapshot) per line. + let t = t.replace("\r\n", "\n").replace('\r', "\n"); + let lines = t.split(['\n', '\u{2029}']).count(); + if lines > MAX_TYPED_PARAGRAPHS { + return ToolResult::error(format!( + "`text` has {lines} lines; type at most {MAX_TYPED_PARAGRAPHS} paragraphs per call" + )); } - wrap(last) + wrap(exec(b, "text.insert", json!({"text": t.replace('\n', "\r"), "raw": true}))) } else { wrap(exec(b, "text.insert", json!({"text": t, "raw": true}))) } From 8ce3eb58c73b6da7b35b957e3cb17da7bb281c13 Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Thu, 22 Oct 2026 15:20:45 +0100 Subject: [PATCH 023/199] #3735: automation writes check the directory entry they replace, not only the link's target Findings 41 and 69. check_write resolved a path through links and compared the target with the write root, but safe_save puts its temporary file beside the path given and renames it over that entry; rename_file removes it. A link outside the root pointing in passed the check while the write landed outside. Write access now also requires the entry itself (resolved parent + literal name) inside the root; the existing target check still refuses an inside link that points out. Tests: file_access::tests::links_are_followed_before_comparing (extended), mcp automation_roots::a_link_outside_the_write_root_into_it_is_not_written. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/engine/src/file_access.rs | 43 ++++++++++++++++++++++++++-- crates/mcp/tests/automation_roots.rs | 28 ++++++++++++++++++ 2 files changed, 69 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/file_access.rs b/crates/engine/src/file_access.rs index 3d1e808805..8e6fdaf171 100644 --- a/crates/engine/src/file_access.rs +++ b/crates/engine/src/file_access.rs @@ -10,6 +10,9 @@ //! is a link that leads nowhere). //! - The result must lie inside the root, compared by whole components (`/work2` isn't inside //! `/work`), without regard to case on Windows. +//! - Writing replaces or removes a directory entry, so for write access the entry itself (its +//! folder's links followed, its own name as given) must lie inside the root as well: a link +//! outside the root that leads into it is not a way to write outside (#3735). //! - Read and write access are separate: a root given alone grants only its own kind of access. //! //! Roots are in force on a thread inside [`confine`], and in the whole process after @@ -95,7 +98,14 @@ impl AutomationRoots { Access::Write => Err(format!("automation filesystem access is not granted: write authority is absent: {path}")), }; }; - if inside(&resolve(path)?, folder) { + // Writing replaces or removes the directory entry `path` names (a safe save renames its + // temporary file over it, a rename removes it): that entry must be inside the root too, + // not only what a link there leads to (#3735). + let entry_inside = || match access { + Access::Read => Ok(true), + Access::Write => resolve_entry(path).map(|entry| inside(&entry, folder)), + }; + if inside(&resolve(path)?, folder) && entry_inside()? { return Ok(()); } let root = shown.display(); @@ -201,9 +211,27 @@ fn resolve(path: &str) -> Result { return Err(format!("automation path rejected: reserved device names are not allowed: {path}")); } let absolute = std::path::absolute(path).map_err(|e| format!("automation path rejected: cannot resolve {path}: {e}"))?; + resolve_absolute(path, &absolute) +} + +/// The directory entry `path` names: its folder resolved as [`resolve`] resolves it, then its own +/// name as given, so a link there is the entry itself, not what it leads to. That entry is what +/// replacing or removing the file changes. +fn resolve_entry(path: &str) -> Result { + let absolute = std::path::absolute(path).map_err(|e| format!("automation path rejected: cannot resolve {path}: {e}"))?; + let (Some(folder), Some(name)) = (absolute.parent(), absolute.file_name()) else { + return Err(format!("automation path rejected: not a file name: {path}")); + }; + let mut entry = resolve_absolute(path, folder)?; + entry.push(name); + Ok(entry) +} + +/// [`resolve`] for `absolute`, `path` made absolute (`path` as given, for messages). +fn resolve_absolute(path: &str, absolute: &Path) -> Result { // The names below the nearest existing folder, last first. let mut missing: Vec = vec![]; - let mut at = absolute.as_path(); + let mut at = absolute; loop { match std::fs::canonicalize(at) { Ok(mut found) => { @@ -382,6 +410,17 @@ mod tests { // A link to a folder inside the root stays inside. assert!(made(&base.join("in/sub"), &base.join("in/alias"))); assert_eq!(roots.check(&s(base.join("in/alias/x.docx")), Access::Read), Ok(())); + // A link outside the write root that leads into it: writing would replace the link, + // outside (#3735). One inside that leads to a file inside is fine. + std::fs::write(base.join("out/doc.docx"), "x").unwrap(); + assert!(made(&base.join("out/doc.docx"), &base.join("outside/alias.docx"))); + let e = roots.check(&s(base.join("outside/alias.docx")), Access::Write).unwrap_err(); + assert!(e.contains("outside the write root"), "the link itself is outside: {e}"); + assert!(made(&base.join("out/doc.docx"), &base.join("out/alias.docx"))); + assert_eq!(roots.check(&s(base.join("out/alias.docx")), Access::Write), Ok(())); + // A folder link outside that leads into the root: the entry is inside, as the system opens it. + assert!(made(&base.join("out"), &base.join("outside/to-out"))); + assert_eq!(roots.check(&s(base.join("outside/to-out/new.docx")), Access::Write), Ok(())); // A root given through a link is the folder it leads to. assert!(made(&base.join("in"), &base.join("in-link"))); let linked = AutomationRoots::new(Some(&base.join("in-link")), None).unwrap().unwrap(); diff --git a/crates/mcp/tests/automation_roots.rs b/crates/mcp/tests/automation_roots.rs index 415ab99d81..7b618ac499 100644 --- a/crates/mcp/tests/automation_roots.rs +++ b/crates/mcp/tests/automation_roots.rs @@ -208,3 +208,31 @@ fn links_out_of_a_root_are_refused() { refused(&mut server, "save_document", json!({"path": s(&base.join("out/away/doc.docx"))}), "outside the write root"); assert!(!base.join("outside/doc.docx").exists()); } + +/// The names in `dir`, sorted. +fn names(dir: &Path) -> Vec { + let mut names: Vec = std::fs::read_dir(dir).unwrap().map(|e| e.unwrap().file_name().to_string_lossy().into_owned()).collect(); + names.sort(); + names +} + +#[cfg(unix)] +#[test] +fn a_link_outside_the_write_root_into_it_is_not_written() { + // #3735: the link's target is inside the write root, but a save replaces the link itself, + // and its temporary file would sit beside it, outside. + let (mut server, base) = confined("link-in", true, true); + std::fs::write(base.join("out/doc.docx"), "inside").unwrap(); + std::os::unix::fs::symlink(base.join("out/doc.docx"), base.join("outside/alias.docx")).unwrap(); + let before = names(&base.join("outside")); + ok(&mut server, "type_text", json!({"text": "Hello"})); + refused(&mut server, "save_document", json!({"path": s(&base.join("outside/alias.docx"))}), "outside the write root"); + let (error, text) = run(&mut server, "file.exportPdf", json!({"path": s(&base.join("outside/alias.docx"))})); + assert!(error && text.contains("outside the write root"), "{text}"); + assert_eq!(names(&base.join("outside")), before, "no temporary file, nothing replaced"); + assert!(std::fs::symlink_metadata(base.join("outside/alias.docx")).unwrap().file_type().is_symlink()); + assert_eq!(std::fs::read_to_string(base.join("out/doc.docx")).unwrap(), "inside"); + // Saving to the real file inside still works. + ok(&mut server, "save_document", json!({"path": s(&base.join("out/doc.docx"))})); + assert_ne!(std::fs::read(base.join("out/doc.docx")).unwrap(), b"inside"); +} From 02fffea7c99c4bc528f18bd7fbf852bed02eff2a Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Thu, 22 Oct 2026 15:24:14 +0100 Subject: [PATCH 024/199] #3736: HTML pictures load only from inside the automation read root Finding 54. Opening an HTML file checks the file through its links, but its pictures load relative to the folder the given path names. Through a link outside the read root to a page inside it, pictures beside the link (outside the root) were embedded. Each picture's canonical path is now checked with check_read before it is read; unconfined opens are unchanged. Test: tests::html_pictures_load_only_from_inside_the_read_root. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/engine/src/io.rs | 5 ++++- crates/engine/src/tests.rs | 23 +++++++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index 27e7020535..1e959c49ae 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -506,7 +506,7 @@ impl LocalImages { left.map(|_| ()) } - /// The canonical path `src` names, if it's inside the folder. + /// The canonical path `src` names, if it's inside the folder and may be read. fn resolve(&self, src: &str) -> Option { let src = src.split(['#', '?']).next().unwrap_or(""); if src.is_empty() || src.starts_with("//") || src.split_once(':').is_some_and(|(scheme, _)| !scheme.contains('/')) { @@ -520,6 +520,9 @@ impl LocalImages { let dir = self.dir.as_ref()?; // Canonical paths have `..` and symlinks resolved, so this keeps both inside the folder. let path = std::fs::canonicalize(dir.join(rel)).ok()?; + // The folder is the one the HTML path names, which may be a link from outside the + // automation read root; the picture itself must be inside it (#3736). + crate::file_access::check_read(&path.to_string_lossy()).ok()?; path.starts_with(dir).then_some(path) } } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 44747c0ab9..1a9cc45389 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -4112,6 +4112,29 @@ fn html_pictures_through_a_symlink_out_of_the_folder_are_not_loaded() { assert!(doc.plain_text(StoryRef::Body).contains("Link")); } +#[cfg(unix)] +#[test] +fn html_pictures_load_only_from_inside_the_read_root() { + // #3736: the HTML file is checked through its link, but its pictures load from beside the + // link. Opened through a link outside the read root, they must not come from there. + let root = scratch_dir("html-img-root"); + for d in ["in", "outside"] { + std::fs::create_dir_all(root.join(d)).unwrap(); + } + std::fs::write(root.join("in/page.html"), r#"

Pic

"#).unwrap(); + std::fs::write(root.join("in/pic.png"), tiny_png()).unwrap(); + std::fs::write(root.join("outside/pic.png"), tiny_png()).unwrap(); + std::os::unix::fs::symlink(root.join("in/page.html"), root.join("outside/page.html")).unwrap(); + let roots = crate::file_access::AutomationRoots::new(Some(&root.join("in")), None).unwrap(); + let through_link = crate::file_access::confine(roots.as_ref(), || crate::io::open_path(&root.join("outside/page.html"))); + let direct = crate::file_access::confine(roots.as_ref(), || crate::io::open_path(&root.join("in/page.html"))); + let _ = std::fs::remove_dir_all(&root); + let through_link = through_link.unwrap(); + assert!(through_link.media.is_empty(), "no picture from outside the read root"); + assert!(through_link.plain_text(StoryRef::Body).contains("Pic")); + assert_eq!(direct.unwrap().media.len(), 1, "pictures inside the root still load"); +} + /// `file.open` on a path reads only a regular file: a named pipe blocked until a writer came, and /// `/dev/zero` read until memory ran out. #[cfg(unix)] From 6142567b6a180cd15dd805bfd17f3981d4281a18 Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Thu, 22 Oct 2026 15:29:05 +0100 Subject: [PATCH 025/199] #3737: dictionary file and folder settings held to the automation roots Finding 34 and #3737 (WC-35), same root cause: proofing paths bypassed file_access. persist_user_dictionary now checks the dictionary file for read and write (entry included, so its sibling temporary file too) and keeps the words in memory when refused. folder_setting checks dictionariesFolder and userTemplatesFolder with check_read before probing the folder, so a confined session can't install or save one outside the read root. Finding 34 is defensive today: only the desktop app sets user_dict_path, and it runs unconfined. Test: tests::confined_sessions_neither_save_the_dictionary_nor_choose_folders_outside_their_roots. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/engine/src/cmd/options.rs | 2 ++ crates/engine/src/session.rs | 10 +++++++--- crates/engine/src/tests.rs | 27 +++++++++++++++++++++++++++ 3 files changed, 36 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/options.rs b/crates/engine/src/cmd/options.rs index 74777db995..0c676b750a 100644 --- a/crates/engine/src/cmd/options.rs +++ b/crates/engine/src/cmd/options.rs @@ -263,6 +263,8 @@ fn folder_setting(key: &str, f: &str, local_only: bool) -> Result if !path.is_absolute() { return Err(CmdError::Params(format!("`{key}`: {f:?} isn't a full path"))); } + // Its files are read from now on, and on every start: only from inside the read root (#3737). + crate::file_access::check_read(&path.to_string_lossy()).map_err(CmdError::Failed)?; if !path.is_dir() { return Err(CmdError::Failed(format!("no folder at {}", path.display()))); } diff --git a/crates/engine/src/session.rs b/crates/engine/src/session.rs index fb9999c74e..fa24909887 100644 --- a/crates/engine/src/session.rs +++ b/crates/engine/src/session.rs @@ -663,10 +663,14 @@ impl Session { /// Save the custom dictionary through [`Session::user_dict_path`], if the host wired one up, /// with `removed` (words just taken out) left out of the merge with the file. A failed save - /// keeps the words in memory and never fails the command (#40). + /// keeps the words in memory and never fails the command (#40), as does a file outside the + /// automation roots in force: it is read, then replaced beside itself (#3737). pub(crate) fn persist_user_dictionary(&self, removed: &[String]) { - if let Some(path) = self.user_dict_path.as_deref() - && let Err(e) = wordcraft_proof::save_user_dictionary(path, removed) + let Some(path) = self.user_dict_path.as_deref() else { return }; + let name = path.to_string_lossy(); + if let Err(e) = crate::file_access::check_read(&name) + .and_then(|()| crate::file_access::check_write(&name)) + .and_then(|()| wordcraft_proof::save_user_dictionary(path, removed).map_err(|e| e.to_string())) { log::warn!("custom dictionary: save failed: {e}"); } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 1a9cc45389..4f1aabf0b8 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -224,6 +224,33 @@ fn custom_dictionary_words_persist_through_their_file() { let _ = std::fs::remove_dir_all(&dir); } +#[test] +fn confined_sessions_neither_save_the_dictionary_nor_choose_folders_outside_their_roots() { + // #3737 (findings 34 and WC-35): the custom dictionary file and the folder settings are + // held to the automation roots like any other path. + let base = scratch_dir("dict-roots"); + for d in ["in", "out", "outside"] { + std::fs::create_dir_all(base.join(d)).unwrap(); + } + let roots = crate::file_access::AutomationRoots::new(Some(&base.join("in")), Some(&base.join("out"))).unwrap(); + let mut s = s(); + let outside = base.join("outside/custom-dictionary.txt"); + s.user_dict_path = Some(outside.clone()); + let confined = |s: &mut Session, cmd: &str, v: serde_json::Value| crate::file_access::confine(roots.as_ref(), || s.run(cmd, &v)); + assert!(confined(&mut s, "review.addToDictionary", json!({"word": "zxqengconfined"})).is_ok()); + assert!(wordcraft_proof::user_dictionary().contains(&"zxqengconfined".to_string()), "kept in memory"); + assert!(!outside.exists(), "nothing written outside the write root"); + for key in ["dictionariesFolder", "userTemplatesFolder"] { + assert!(confined(&mut s, "file.options", json!({key: base.join("outside").to_string_lossy()})).is_err(), "{key}"); + } + assert_eq!((s.prefs.dictionaries_folder.as_deref(), s.prefs.user_templates_folder.as_deref()), (None, None)); + // A folder inside the read root is still taken. + let inside = base.join("in"); + assert!(confined(&mut s, "file.options", json!({"userTemplatesFolder": inside.to_string_lossy()})).is_ok()); + let _ = std::fs::remove_dir_all(&base); + assert_eq!(s.prefs.user_templates_folder.as_deref(), Some(inside.to_string_lossy().as_ref())); +} + #[test] fn add_to_dictionary_takes_only_words_the_file_keeps() { // #40: the same rule as loading the dictionary file, so nothing added is dropped on the next load. From 66e92e36985e34d09c8279997a3556cff66c2a63 Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Thu, 22 Oct 2026 15:32:43 +0100 Subject: [PATCH 026/199] #3692: formatting restriction refuses Copy Styles Finding 50. check_formatting blocked Update Styles and Attach Template with update, but not styles.copyFrom, which replaces style definitions (locked flag included) from another file. Under "limit formatting to a selection of styles" an editor could import changed formatting and unlock a locked style without the password. styles.copyFrom is now refused like Update Styles. copy_styles' callers are all blocked under the restriction, so its lock handling is left as is. Test: cmd::protect::tests::formatting_restriction_refuses_attach_template_update (extended). Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/engine/src/cmd/protect.rs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/protect.rs b/crates/engine/src/cmd/protect.rs index a1824a863a..5beb67eadb 100644 --- a/crates/engine/src/cmd/protect.rs +++ b/crates/engine/src/cmd/protect.rs @@ -406,6 +406,8 @@ fn check_formatting(s: &Session, id: &str, params: &Value) -> Result<(), CmdErro | "table.modifyStyle" | "table.deleteStyle" | "tools.updateStyles" + // Copies style definitions in from another file, locked flags included (#3692). + | "styles.copyFrom" ) // Attach Template updates the styles from it when asked (`linkStyles` is only a setting). || (id == "tools.attachTemplate" && p::bool(params, "update") == Some(true)); @@ -745,7 +747,8 @@ mod tests { } /// Formatting limited to styles: attaching a template refuses to update the styles from it - /// (it would bypass the Update Styles block); attaching without updating still works. + /// (it would bypass the Update Styles block), nor can Copy Styles; attaching without + /// updating still works. #[test] fn formatting_restriction_refuses_attach_template_update() { let mut t = Document::from_text("Template."); @@ -759,6 +762,12 @@ mod tests { let e = s.run("tools.attachTemplate", &json!({"path": "Report.dotx", "update": true, "data": data})).unwrap_err().to_string(); assert!(e.contains("formatting is limited"), "{e}"); assert_eq!(s.doc, before); + // Copying styles in replaces their definitions, locked flag included (#3692). + s.doc.styles.styles.iter_mut().filter(|st| st.id == "Heading1").for_each(|st| st.locked = true); + let before = s.doc.clone(); + let e = s.run("styles.copyFrom", &json!({"path": "Report.dotx", "styles": ["Heading 1"], "data": data})).unwrap_err().to_string(); + assert!(e.contains("formatting is limited"), "{e}"); + assert_eq!(s.doc, before); s.run("tools.attachTemplate", &json!({"path": "Report.dotx"})).unwrap(); assert_eq!(s.doc.settings.attached_template.as_deref(), Some("Report.dotx")); } From c7dfd92ff1722a6c329830ac71760b5599d2134b Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Thu, 22 Oct 2026 15:34:26 +0100 Subject: [PATCH 027/199] #3698: template paths refuse the NT namespace and are checked again after joining the document's folder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Finding 78 and WC-02. is_network_path looked only at the first two bytes, so `\??\UNC\host\share\T.dotx` (any separator mix) passed as local, and local_path checked the reference before joining it to the document's folder, so a relative reference from a document named `\\host\share\x` (file.open with data) became a remote path after the check. The classifier now also refuses `\??\…` unless a drive letter follows, and local_path checks the completed path. Pure string logic, the same on every platform; the same helper guards the folder settings. Test: cmd::templates::tests::template_references_become_local_paths (extended). Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/engine/src/cmd/templates.rs | 39 ++++++++++++++++++++++++------ 1 file changed, 31 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/cmd/templates.rs b/crates/engine/src/cmd/templates.rs index 60d4044a05..c59f3e1e41 100644 --- a/crates/engine/src/cmd/templates.rs +++ b/crates/engine/src/cmd/templates.rs @@ -231,18 +231,27 @@ pub fn local_path(target: &str, base: Option<&Path>) -> Option { return None; } let p = PathBuf::from(&path); - if p.is_relative() - && !is_windows_absolute(&path) - && let Some(dir) = base.and_then(Path::parent) - { - return Some(dir.join(p)); - } - Some(p) + let p = match base.and_then(Path::parent) { + Some(dir) if p.is_relative() && !is_windows_absolute(&path) => dir.join(p), + _ => p, + }; + // Checked again once joined: the document's own folder may be on another machine (#3698). + (!is_network_path(&p.to_string_lossy())).then_some(p) } +/// Does `p` name another machine or a device, whatever separators it mixes: `\\host\share`, +/// `//host`, `\\?\UNC\…`, `\\.\…`, and the NT namespace `\??\…` unless a drive letter +/// follows (`\??\C:\…`, #3698). Purely lexical, so it is the same on every platform. pub(crate) fn is_network_path(p: &str) -> bool { + let sep = |c: Option<&u8>| matches!(c, Some(b'\\' | b'/')); let b = p.as_bytes(); - matches!((b.first(), b.get(1)), (Some(b'\\' | b'/'), Some(b'\\' | b'/'))) + if sep(b.first()) && sep(b.get(1)) { + return true; + } + // `\??\` then anything but `X:` (and the end or a separator). + let nt = sep(b.first()) && b.get(1) == Some(&b'?') && b.get(2) == Some(&b'?') && sep(b.get(3)); + let drive = b.get(4).is_some_and(u8::is_ascii_alphabetic) && b.get(5) == Some(&b':') && (b.len() == 6 || sep(b.get(6))); + nt && !drive } fn is_windows_absolute(p: &str) -> bool { @@ -504,9 +513,23 @@ mod tests { "file://evil.example/share/T.dotx", "file:////evil.example/share/T.dotx", "FILE://Evil.Example/T.dotx", + // The NT namespace (#3698), with any separators. + "\\??\\UNC\\evil.example\\share\\T.dotx", + "/??/UNC/evil.example/share/T.dotx", + "\\??/UNC\\evil.example/share/T.dotx", + "\\??\\GLOBALROOT\\Device\\Mup\\evil.example\\share\\T.dotx", + "\\\\.\\UNC\\evil.example\\share\\T.dotx", ] { assert_eq!(local_path(remote, Some(Path::new("/docs/Letter.docx"))), None, "{remote}"); + assert_eq!(local_path(remote, None), None, "{remote} without a folder"); } + // A relative reference from a document whose folder is on another machine (#3698). + assert_eq!(local_path("T.dotx", Some(Path::new("//evil.example/share/Letter.docx"))), None); + assert_eq!(local_path("file:T.dotx", Some(Path::new("//evil.example/share/Letter.docx"))), None); + // A drive in the NT namespace is local. + assert!(!is_network_path("\\??\\C:\\Templates\\T.dotx")); + assert!(!is_network_path("\\??\\C:")); + assert!(is_network_path("\\??\\C:evil")); assert_eq!(percent_decode("100%"), "100%"); assert_eq!(percent_decode("%zz%4"), "%zz%4"); } From 4f5e596c715ee2d827eed4e6078f5108fee60ebf Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Thu, 22 Oct 2026 15:35:02 +0100 Subject: [PATCH 028/199] =?UTF-8?q?#3735:=20docs=20=E2=80=94=20automation?= =?UTF-8?q?=20roots,=20type=5Ftext=20limit,=20Copy=20Styles=20under=20prot?= =?UTF-8?q?ection?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Progress log row for group J (findings 9, 18, 34, 41, 50, 54, 69, 78); docs/mcp.md says what the roots now cover; the Restrict Editing row notes Copy Styles. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/file-format-parity.md | 3 ++- docs/mcp.md | 12 ++++++++---- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 076855fcf5..cde6c8e809 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -93,7 +93,7 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen | Fields (`w:fldChar`, `w:fldSimple`), TOC, cross-references, `ADDIN` citations | ✅ | ✅ | Field codes Word supports but we don't evaluate keep their cached result. Textual `REF` fields with `\h` export internal PDF links without refreshing cached text; existing enclosing links take precedence. `wordcraft-cli convert --update-fields` explicitly runs field updates before saving; ordinary conversion preserves caches and locked fields stay unchanged. Instruction-only paragraphs carry hidden marks instead of blank lines; an empty result paragraph keeps its line. Imported TOCs preserve actual result boundaries, formatted entries and nested fields without relying on TOC styles. `EQ \o` (overstrike, enclosed characters) is drawn; enclosed characters are written as Word stores them, their style in the code's run sizes (#297); a cached picture (`INCLUDEPICTURE`) stays as the content of a range field (#465). A `HYPERLINK` field reads as a link, its result ordinary text (tabs, nested `PAGEREF`), and is written back as `w:hyperlink`; its tooltip (`\o`) and target frame (`\t`) are dropped (#2174) | | Move tracking (`w:moveFrom`/`w:moveTo`, `w:moveFromRangeStart/End`, `w:moveToRangeStart/End`), moved paragraph marks | ✅ | ✅ | #400: both ends linked by the range name; ranges rewritten around each moved stretch (ids renumbered); a move missing an end or a range end opens as a plain deletion/insertion. ODT/PDF export the result (moved-away text hidden like deletions); RTF writes a move as a plain tracked deletion and insertion | | Content controls (`w:sdt`) | ✅ | ✅ | Block, inline, row and cell level, nested; title, tag, id, lock, placeholder, temporary, rich/plain text, `w14:checkbox`, combo box, drop-down list, date, picture, building block gallery, `w15` repeating sections; other `w:sdtPr` children (data binding, appearance) kept verbatim (#381). A TOC's control is rebuilt by the TOC when the TOC has the heading-and-entries form; around any other TOC it is kept as read (#2427). Replace All respects content-edit and control-deletion locks and refuses a batch before making any edit | -| Restrict Editing (`w:documentProtection` edit/formatting/enforcement and password hash, `w:locked` styles, `w:permStart`/`w:permEnd` exceptions) | ✅ | ✅ | #412: SHA-512 hash with salt and spin count written; Word's legacy `w:cryptAlgorithmSid`/`w:hash` form (SHA-1/-256/-384/-512, ECMA-376 Part 4 §14.8.1) is kept and verified; MD2/MD4/MD5, `w:algIdExt` and other unsupported forms are refused, never unlocked; `w:colFirst`/`w:colLast` table-column ranges read as plain ranges; forms mode (`w:edit="forms"`) lets edits inside fillable content controls (`w:lock` content-editable, innermost wins) run, everything else rolls back; row-wrapped controls and control deletion remain refused; `review.restrict` reports `formFields`; Find Next Region covers form fields (the pane button shows for forms documents); under active protection, `developer.properties` refuses `lockEdit`/`lockDelete` changes, as Word blocks property edits until Stop Protection; under forms protection it refuses every property, and inserting or removing content controls is refused too (Word greys out the Developer tab's control buttons and Properties and offers no Remove Content Control), while under the other modes the title, tag and placeholder stay editable; a file command that changes the open document is checked like any other, so the privacy options, the properties and the inspector's removals are refused on a protected document, and restoring a stored version keeps the protection and its password rather than the snapshot's stale ones; the protection of a protected document only changes through the explicit stop (`review.restrict {"stop": true}` — the "none" mode is the shorthand for it and doesn't stop a protected document), the session author used for new tracked changes and comments can't change through `file.setAuthor` while the document is protected (the CLI's `--author` uses the same protection check), and the post-check compares the document's settings, metadata, custom properties, list definitions, bibliography sources and the comments' resolved flags as well as its text and formatting; named exceptions match a self-declared user name, not an authenticated identity | +| Restrict Editing (`w:documentProtection` edit/formatting/enforcement and password hash, `w:locked` styles, `w:permStart`/`w:permEnd` exceptions) | ✅ | ✅ | #412: SHA-512 hash with salt and spin count written; Word's legacy `w:cryptAlgorithmSid`/`w:hash` form (SHA-1/-256/-384/-512, ECMA-376 Part 4 §14.8.1) is kept and verified; MD2/MD4/MD5, `w:algIdExt` and other unsupported forms are refused, never unlocked; `w:colFirst`/`w:colLast` table-column ranges read as plain ranges; forms mode (`w:edit="forms"`) lets edits inside fillable content controls (`w:lock` content-editable, innermost wins) run, everything else rolls back; row-wrapped controls and control deletion remain refused; `review.restrict` reports `formFields`; Find Next Region covers form fields (the pane button shows for forms documents); under active protection, `developer.properties` refuses `lockEdit`/`lockDelete` changes, as Word blocks property edits until Stop Protection; under forms protection it refuses every property, and inserting or removing content controls is refused too (Word greys out the Developer tab's control buttons and Properties and offers no Remove Content Control), while under the other modes the title, tag and placeholder stay editable; a file command that changes the open document is checked like any other, so the privacy options, the properties and the inspector's removals are refused on a protected document, and restoring a stored version keeps the protection and its password rather than the snapshot's stale ones; the protection of a protected document only changes through the explicit stop (`review.restrict {"stop": true}` — the "none" mode is the shorthand for it and doesn't stop a protected document), the session author used for new tracked changes and comments can't change through `file.setAuthor` while the document is protected (the CLI's `--author` uses the same protection check), and the post-check compares the document's settings, metadata, custom properties, list definitions, bibliography sources and the comments' resolved flags as well as its text and formatting; named exceptions match a self-declared user name, not an authenticated identity; formatting-only protection also refuses Copy Styles (`styles.copyFrom`, #3692) | | Legacy form fields (`w:ffData`), check boxes, drop-downs | 🟡 | ✅ | The field definition is kept raw and saved back. Check boxes (`FORMCHECKBOX`) are drawn as Word draws them: a square as tall as the text's line, inset 1.16 pt, 0.72 pt border, an X when checked (`w:checked`, else `w:default`), `w:size` honoured; drop-downs (`FORMDROPDOWN`) show their selected entry; text fields (`FORMTEXT`) show their result. Not yet: filling them in, Protect Document's forms mode for legacy fields | | DrawingML pictures inline and anchored (`wp:anchor`), wrap square/tight/through/top-bottom | ✅ | ✅ | Tight/through read and written with their `wp:wrapPolygon` (and `edited`); without one, a shape's outline is written (a picture's rectangle). `wrapText` (both sides, left, right, largest) read and written for square, tight and through | | Legacy form fields (`w:ffData`), check boxes, drop-downs | ❌ | ❌ | | @@ -252,6 +252,7 @@ Imported text fields use the first nonempty cached result run's formatting on th | Date | Change | Summary | |---|---|---| +| 2026-10-22 | trivial | Formatting-only protection refuses Copy Styles, as it does Update Styles (#3692) | | 2026-10-23 | trivial | A relationship the markup names without the prefix is read as it wrote: an unqualified `id` on an element that carries a relationship names that relationship, and the writer keeps the attribute with the id the new package holds the part under; an ordinary `id` — a VML shape's, a drawing's — names no part (#3411 follow-up) | | 2026-10-21 | trivial | The reader takes the relationship ids the markup refers to from the relationship attributes of the main document it has already parsed, so a document with many relationships opens in the time its part takes, and an id that only appears as text is not a reference (#3411) | | 2026-10-21 | trivial | HTML export writes ruby (furigana, phonetic guides) as `basereading`, in the base text's run formatting, and HTML import reads ``/``/`` back into one ruby object: `` fallback parentheses and the tags are not text, an unclosed element ends at the next block, and malformed ruby keeps its text. HTML states no alignment, size or raise, so the Phonetic Guide's defaults apply (#1681) | diff --git a/docs/mcp.md b/docs/mcp.md index 5ed66634d1..99bf754d54 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -63,11 +63,15 @@ config works across the suite) keep the agent to the folders you give it: `file.newFromTemplate`, `insert.textFromFile` and `insert.object`, `insert.picture` and `picture.change` with a `path`, `mailings.recipients` with a `path` (csv, tsv, txt, xlsx, xlsm, ods), `review.compare` (`path` and `original`), the templates and documents `tools.updateStyles`, - `styles.organizer` and `styles.copyFrom` read. The pictures an opened HTML file names are read - from beside that file, so they are inside the read root too (a link that leads out is not followed). + `styles.organizer` and `styles.copyFrom` read, and the folders `file.options` takes + (`dictionariesFolder`, `userTemplatesFolder`). The pictures an opened HTML file names are read + from beside that file and must be inside the read root too, even when the file was opened through + a link (#3736). - Every file a command writes must lie inside the **write root**: `save_document`, `file.save`, `file.saveAs`, `file.rename` (the file and its new name), `file.exportPdf`, `file.exportPng`, `file.printPdf`, - `file.saveTemplate`, and `mailings.finish` with a `path`. + `file.saveTemplate`, and `mailings.finish` with a `path`. A save replaces the name it is given, + so that name must be inside the write root as well as what it leads to: a link outside the root + pointing in is refused (#3735). - Read and write access are separate. Give the same folder twice for both; a root left out grants none of its access (with only `--automation-read-root`, nothing can be written to disk). Without either flag nothing changes. @@ -108,7 +112,7 @@ files it was given rather than guarding against other software on the computer. | `batch` | run several commands in order | | `new_document` | blank, sample, letter, resume, report | | `open_document` / `save_document` | docx, doc (open only), odt, rtf, html, md, tex, txt, json; save also pdf, png | -| `type_text` | type at the caret (`paragraphs: true` splits lines into paragraphs) | +| `type_text` | type at the caret (`paragraphs: true` splits lines into paragraphs: one undo step, at most 10,000 lines) | | `select_text` | select the n-th occurrence of some text | | `get_text` / `inspect_document` | read the document back (verify without screenshots) | | `render_page` | a page as PNG | From 8a18026a13acee7514e151ed496d38d218a89682 Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Thu, 22 Oct 2026 15:57:23 +0100 Subject: [PATCH 029/199] #3692: rustfmt type_text Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/mcp/src/tools.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 3a46629b2f..c666fbe3f9 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -238,9 +238,7 @@ pub fn call_tool(b: &mut dyn Backend, name: &str, a: &Value) -> ToolResult { let t = t.replace("\r\n", "\n").replace('\r', "\n"); let lines = t.split(['\n', '\u{2029}']).count(); if lines > MAX_TYPED_PARAGRAPHS { - return ToolResult::error(format!( - "`text` has {lines} lines; type at most {MAX_TYPED_PARAGRAPHS} paragraphs per call" - )); + return ToolResult::error(format!("`text` has {lines} lines; type at most {MAX_TYPED_PARAGRAPHS} paragraphs per call")); } wrap(exec(b, "text.insert", json!({"text": t.replace('\n', "\r"), "raw": true}))) } else { From 3a8ff6b6bc071a6e0dc85ae7bc0884d612efa8af Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Thu, 22 Oct 2026 22:21:35 +0100 Subject: [PATCH 030/199] #3692: typed paragraphs break like Enter, still one command and one undo step Ultrareview finding 2. MCP `type_text` with `paragraphs: true` sent one raw text.insert with `\r` breaks, which skipped Enter: a break could land in a one-line, check-box or drop-down content control (text.newParagraph refuses there), and Enter's AutoFormat (--- borders, +---+ tables) no longer ran. text.insert takes `paragraphs: true`: each line is typed raw and each break runs text.newParagraph's own implementation (`enter`), inside the one command, so there is one checkpoint. Where Enter would refuse, the command fails and Session::run puts the document and history back (all or nothing). Enter's AutoFormat normally records the plain Enter as its own undo step after the fact (push_undo, a whole-document clone); inside typed paragraphs that step is skipped, so AutoFormatted lines add no snapshot each. MCP sends `paragraphs: true` and keeps MAX_TYPED_PARAGRAPHS. Tests: mcp type_text_paragraphs_are_refused_in_a_one_line_control, type_text_paragraphs_autoformat_like_enter_in_one_undo_step. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/engine/src/cmd/text.rs | 60 ++++++++++++++++++++++++++++------- crates/mcp/src/tests.rs | 52 +++++++++++++++++++++++++++--- crates/mcp/src/tools.rs | 9 +++--- 3 files changed, 102 insertions(+), 19 deletions(-) diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index d83d16425c..614086256b 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -10,7 +10,7 @@ use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p}; pub fn specs() -> Vec { vec![ - CommandSpec::new("text.insert", "Type Text", "Editing", insert).params(r#"{"text": string}"#), + CommandSpec::new("text.insert", "Type Text", "Editing", insert).params(r#"{"text": string, "raw"?: bool, "paragraphs"?: bool}"#), CommandSpec::new("text.newParagraph", "New Paragraph", "Editing", new_paragraph).key("Enter"), CommandSpec::new("text.lineBreak", "Line Break", "Editing", |s, _| ins_char(s, '\n')).key("Shift+Enter"), CommandSpec::new("text.pageBreak", "Page Break", "Insert › Pages", |s, _| ins_char(s, PAGE_BREAK)).key("Mod+Enter"), @@ -99,6 +99,9 @@ fn typing_options(s: &mut Session, text: &str) { fn insert(s: &mut Session, v: &Value) -> CmdResult { let text = p::req_str(v, "text")?; let raw = p::bool(v, "raw").unwrap_or(false); + if p::bool(v, "paragraphs").unwrap_or(false) { + return insert_paragraphs(s, text); + } typing_options(s, text); let t = if raw { None } else { autoformat(s, text) }; type_text(s, t.as_deref().unwrap_or(text))?; @@ -576,30 +579,65 @@ fn new_paragraph(s: &mut Session, _: &Value) -> CmdResult { if super::controls::refuses_paragraph(s) { return sel_result(s); } + enter(s, true)?; + sel_result(s) +} + +/// `text.insert` with `paragraphs`: each line typed as it is (`raw`), and between lines the +/// paragraph break Enter makes ([`enter`]: its AutoFormat and AutoCorrect), all in one command, so +/// one undo step instead of a command and a whole-document snapshot per line (#3692). Where +/// Enter would do nothing (a one-line control, a check box, a list) the text is refused and, +/// like any failed command, nothing of it stays. +fn insert_paragraphs(s: &mut Session, text: &str) -> CmdResult { + typing_options(s, text); + let text = text.replace("\r\n", "\n"); + for (i, line) in text.split(['\n', '\r', '\u{2029}']).enumerate() { + if i > 0 { + if super::controls::refuses_paragraph(s) { + return Err(CmdError::Disabled("text.insert: this content control takes no paragraph breaks".into())); + } + enter(s, false)?; + } + if !line.is_empty() { + type_text(s, line)?; + } + } + sel_result(s) +} + +/// Enter's paragraph break once a control has allowed it: `=rand()` sample text, AutoFormat of a +/// border or table line, AutoCorrect of the word it ends, else a plain split. With `undo_step` +/// an AutoFormat is recorded as its own undo step after the plain Enter, as in Word; without it +/// (typed paragraphs, one undo step for all) no extra snapshot is taken. +fn enter(s: &mut Session, undo_step: bool) -> Result<(), CmdError> { super::controls::prepare_edit(s)?; if let Some(text) = sample_text(s) { let end = s.sel.focus.clone(); s.sel = Selection { anchor: Pos { off: 0, ..end.clone() }, focus: end }; - type_text(s, &text)?; - return sel_result(s); + return type_text(s, &text); } let mut at = delete_selection(s)?; let auto = border_line(s, &at).map(EnterAutoFormat::Border).or_else(|| table_line(s, &at).map(EnterAutoFormat::Table)); if let Some(auto) = auto { // Word: Undo right after reverts only the AutoFormat, so the typed characters come back // and the Enter stays. Record that plain Enter as its own undo step. - let (doc, sel) = (s.doc.clone(), s.sel.clone()); - let new = split_para(s, &at)?; - s.sel = Selection::caret(new); - let plain_doc = std::mem::replace(&mut s.doc, doc); - let plain_sel = std::mem::replace(&mut s.sel, sel); + let plain = if undo_step { + let (doc, sel) = (s.doc.clone(), s.sel.clone()); + let new = split_para(s, &at)?; + s.sel = Selection::caret(new); + Some((std::mem::replace(&mut s.doc, doc), std::mem::replace(&mut s.sel, sel))) + } else { + None + }; match auto { EnterAutoFormat::Border(line) => apply_border_line(s, &at, line)?, EnterAutoFormat::Table(cols) => apply_table_line(s, &at, &cols)?, } s.goal_x = None; - s.push_undo("AutoFormat", plain_doc, plain_sel); - return sel_result(s); + if let Some((plain_doc, plain_sel)) = plain { + s.push_undo("AutoFormat", plain_doc, plain_sel); + } + return Ok(()); } // Enter finishes a word like a space does ("teh" → "the", sentence capitals, links). if s.sel.is_collapsed() && at.off > 0 { @@ -610,7 +648,7 @@ fn new_paragraph(s: &mut Session, _: &Value) -> CmdResult { let new = split_para(s, &at)?; s.sel = Selection::caret(new); s.goal_x = None; - sel_result(s) + Ok(()) } /// In a table: next cell (adds a row at the end). At the start of a list item: demote. Else a tab. diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index e000371344..de99ddd57c 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -120,7 +120,7 @@ fn headless_sessions_keep_the_dictionary_in_memory() { assert!(h.session.user_dict_path.is_none()); } -/// A backend that keeps every command it is sent and refuses text with a paragraph break. +/// A backend that keeps every command it is sent and refuses typed paragraphs. struct RefusesParagraph { calls: std::sync::Arc>>, } @@ -128,8 +128,8 @@ struct RefusesParagraph { impl crate::backend::Backend for RefusesParagraph { fn call(&mut self, _method: &str, params: Value) -> Result { self.calls.lock().unwrap().push(params.clone()); - match params["params"]["text"].as_str() { - Some(t) if t.contains('\r') => Err("paragraph break refused".into()), + match params["params"]["paragraphs"].as_bool() { + Some(true) => Err("paragraph break refused".into()), _ => Ok(json!({"ok": true})), } } @@ -153,7 +153,8 @@ fn type_text_stops_at_a_refused_paragraph_break() { let calls = calls.lock().unwrap(); assert_eq!(calls.len(), 1, "{calls:?}"); assert_eq!(calls[0]["command"], "text.insert"); - assert_eq!(calls[0]["params"]["text"], "one\rtwo"); + assert_eq!(calls[0]["params"]["text"], "one\ntwo"); + assert_eq!(calls[0]["params"]["paragraphs"], true); } /// #3692: typed paragraphs are one edit and one undo step, not a command (and a whole-document @@ -179,3 +180,46 @@ fn type_text_paragraphs_are_one_undo_step_with_a_line_cap() { assert_eq!(r["result"]["isError"], true, "{r}"); assert!(calls.lock().unwrap().is_empty(), "refused before any command"); } + +/// Ultrareview of #3692: each typed paragraph break is Enter's own (text.newParagraph's +/// implementation), so a one-line content control refuses it, and all of the text with it. +#[test] +fn type_text_paragraphs_are_refused_in_a_one_line_control() { + let mut s = Server::new(Box::new(Headless::default())); + tool(&mut s, "new_document", json!({})); + tool(&mut s, "type_text", json!({"text": "Name: "})); + tool(&mut s, "execute", json!({"command": "developer.plainText", "params": {"tag": "name"}})); + tool(&mut s, "execute", json!({"command": "caret.right"})); + let before = tool(&mut s, "get_text", json!({}))["text"].clone(); + let r = call(&mut s, 13, "tools/call", json!({"name": "type_text", "arguments": {"text": "Ada\nLovelace", "paragraphs": true}})); + assert_eq!(r["result"]["isError"], true, "{r}"); + assert_eq!(tool(&mut s, "get_text", json!({}))["text"], before, "nothing typed"); + assert_eq!(tool(&mut s, "inspect_document", json!({}))["blocks"].as_array().unwrap().len(), 1); +} + +/// Ultrareview of #3692: Enter's AutoFormat runs on each typed paragraph break ("---" becomes a +/// border, "+---+---+" a table), and the lines are still one undo step however many of them +/// AutoFormat changes. +#[test] +fn type_text_paragraphs_autoformat_like_enter_in_one_undo_step() { + let mut s = Server::new(Box::new(Headless::default())); + tool(&mut s, "new_document", json!({})); + tool(&mut s, "type_text", json!({"text": "Above\n---\nBelow\n+---+---+\nCell", "paragraphs": true})); + let blocks = tool(&mut s, "inspect_document", json!({}))["blocks"].clone(); + let blocks = blocks.as_array().unwrap(); + assert_eq!(blocks[0]["text"], "Above", "{blocks:?}"); + assert!(blocks[0]["props"]["borders"]["bottom"].is_object(), "--- is a border: {blocks:?}"); + assert_eq!(blocks[1]["text"], "Below", "{blocks:?}"); + assert_eq!(blocks[2]["type"], "table", "+---+---+ is a table: {blocks:?}"); + assert!(blocks[2].to_string().contains("Cell"), "typing goes on in its first cell: {blocks:?}"); + tool(&mut s, "execute", json!({"command": "edit.undo"})); + assert_eq!(tool(&mut s, "inspect_document", json!({}))["blocks"].as_array().unwrap().len(), 1, "one Undo"); + assert_eq!(tool(&mut s, "get_text", json!({}))["text"].as_str().unwrap().trim(), ""); + + // Many AutoFormatted lines: still one undo step (no snapshot per line). + let lines: Vec<&str> = (0..500).flat_map(|_| ["Text", "==="]).collect(); + tool(&mut s, "type_text", json!({"text": lines.join("\n"), "paragraphs": true})); + assert_eq!(tool(&mut s, "inspect_document", json!({}))["blocks"].as_array().unwrap().len(), 501); + tool(&mut s, "execute", json!({"command": "edit.undo"})); + assert_eq!(tool(&mut s, "inspect_document", json!({}))["blocks"].as_array().unwrap().len(), 1, "one Undo"); +} diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index c666fbe3f9..d706d49e9a 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -232,15 +232,16 @@ pub fn call_tool(b: &mut dyn Backend, name: &str, a: &Value) -> ToolResult { "type_text" => { let t = st("text").unwrap_or(""); if a.get("paragraphs").and_then(Value::as_bool).unwrap_or(false) { - // One `text.insert`: `\r` is a paragraph break there (Enter's split), so the - // lines go in as one edit and one undo step, all or nothing, instead of a command - // (and a whole-document undo snapshot) per line. + // One `text.insert` with `paragraphs`: each break is Enter's own (its AutoFormat, + // and its refusal in a one-line control), but the lines go in as one edit and one + // undo step, all or nothing, instead of a command (and a whole-document undo + // snapshot) per line. let t = t.replace("\r\n", "\n").replace('\r', "\n"); let lines = t.split(['\n', '\u{2029}']).count(); if lines > MAX_TYPED_PARAGRAPHS { return ToolResult::error(format!("`text` has {lines} lines; type at most {MAX_TYPED_PARAGRAPHS} paragraphs per call")); } - wrap(exec(b, "text.insert", json!({"text": t.replace('\n', "\r"), "raw": true}))) + wrap(exec(b, "text.insert", json!({"text": t, "raw": true, "paragraphs": true}))) } else { wrap(exec(b, "text.insert", json!({"text": t, "raw": true}))) } From 6b23f3b368d5b6e0b4e16e905217597ab6200a46 Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Thu, 22 Oct 2026 22:21:35 +0100 Subject: [PATCH 031/199] #3698: template bytes need no local path; verbatim and device paths with a drive are local MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ultrareview finding 4. (a) load_other resolved the template name against the document's folder even when its bytes came in `data`, so a document opened from a share or a `\\?\` path could not attach, update or organise styles from a relative name. With bytes nothing is read: the name only gives the format. Without bytes local_path still refuses a relative reference from a document on a share (no NTLM leak). (b) is_network_path called `\\?\C:\…` and `\\.\C:\…` network; a drive letter after the verbatim, device or NT prefix is local. `\\?\UNC\…`, `\\.\UNC\…`, other devices and `\\host\…` stay refused. Tests: cmd::templates::tests::template_bytes_need_no_local_path (new), template_references_become_local_paths (extended). Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/engine/src/cmd/templates.rs | 69 ++++++++++++++++++++++++------ 1 file changed, 57 insertions(+), 12 deletions(-) diff --git a/crates/engine/src/cmd/templates.rs b/crates/engine/src/cmd/templates.rs index c59f3e1e41..110427879c 100644 --- a/crates/engine/src/cmd/templates.rs +++ b/crates/engine/src/cmd/templates.rs @@ -185,17 +185,22 @@ fn load_other(name: &str, data: Option<&str>, base: Option<&Path>) -> Result MAX_PATH { return Err(CmdError::Params("`path` is too long".into())); } - let local = local_path(name, base).ok_or_else(|| CmdError::Failed(format!("`{name}` is not a local file")))?; - let ext = local.extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default(); + // With the bytes given nothing is read, so the name only gives the format: no path is + // resolved, and a document on a share (or a `\\?\` path) can still use a relative name. + let file = match data { + Some(_) => PathBuf::from(name), + None => local_path(name, base).ok_or_else(|| CmdError::Failed(format!("`{name}` is not a local file")))?, + }; + let ext = file.extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default(); if matches!(ext.as_str(), "" | "txt" | "text") { return Err(CmdError::Params(format!("`{name}` is not a document or template with styles"))); } match data { Some(d) => { let bytes = super::insert::base64_decode(d).ok_or_else(|| CmdError::Params("bad base64".into()))?; - crate::io::open_bytes(&local.to_string_lossy(), &bytes).map_err(CmdError::Failed) + crate::io::open_bytes(name, &bytes).map_err(CmdError::Failed) } - None => crate::io::open_path(&local).map_err(CmdError::Failed), + None => crate::io::open_path(&file).map_err(CmdError::Failed), } } @@ -240,18 +245,25 @@ pub fn local_path(target: &str, base: Option<&Path>) -> Option { } /// Does `p` name another machine or a device, whatever separators it mixes: `\\host\share`, -/// `//host`, `\\?\UNC\…`, `\\.\…`, and the NT namespace `\??\…` unless a drive letter -/// follows (`\??\C:\…`, #3698). Purely lexical, so it is the same on every platform. +/// `//host`, `\\?\UNC\…`, `\\.\UNC\…`, other devices, and the NT namespace `\??\…`. The +/// verbatim, device and NT prefixes are local when a drive letter follows (`\\?\C:\…`, +/// `\\.\C:\…`, `\??\C:\…`, #3698). Purely lexical, so it is the same on every platform. pub(crate) fn is_network_path(p: &str) -> bool { let sep = |c: Option<&u8>| matches!(c, Some(b'\\' | b'/')); let b = p.as_bytes(); - if sep(b.first()) && sep(b.get(1)) { - return true; - } - // `\??\` then anything but `X:` (and the end or a separator). - let nt = sep(b.first()) && b.get(1) == Some(&b'?') && b.get(2) == Some(&b'?') && sep(b.get(3)); + // `\\?\`, `\\.\` or `\??\`, then `X:` and the end or a separator. + let prefix = sep(b.first()) + && sep(b.get(3)) + && match (b.get(1), b.get(2)) { + (Some(b'?'), Some(b'?')) => true, + (c, Some(b'?' | b'.')) => sep(c), + _ => false, + }; let drive = b.get(4).is_some_and(u8::is_ascii_alphabetic) && b.get(5) == Some(&b':') && (b.len() == 6 || sep(b.get(6))); - nt && !drive + if prefix { + return !drive; + } + sep(b.first()) && sep(b.get(1)) } fn is_windows_absolute(p: &str) -> bool { @@ -498,6 +510,29 @@ mod tests { let _ = std::fs::remove_dir_all(path.parent().unwrap()); } + /// Ultrareview of #3698: with the template's bytes given (`data`) nothing is read from a path, + /// so a document opened from a share or a `\\?\` path can still attach, update and organise + /// styles from a template named relative to it. Without the bytes, the share is never read. + #[test] + fn template_bytes_need_no_local_path() { + let data = crate::cmd::insert::base64_encode(&wordcraft_docx::write(&template()).unwrap()); + for doc_path in ["//fileserver/share/Letter.docx", "//?/C:/docs/Letter.docx"] { + let mut s = Session::new(Document::new()); + s.path = Some(PathBuf::from(doc_path)); + s.run("tools.attachTemplate", &json!({"path": "Report.dotx", "data": data, "update": true})).unwrap(); + assert_eq!(s.doc.styles.get("Heading1").unwrap().chr.size, Some(31.0), "{doc_path}"); + s.run("tools.updateStyles", &json!({"path": "Report.dotx", "data": data})).unwrap(); + let listed = s.run("styles.organizer", &json!({"path": "Report.dotx", "data": data})).unwrap(); + assert!(listed["file"]["styles"].as_array().unwrap().iter().any(|x| x["name"] == "Callout"), "{doc_path}"); + s.run("styles.copyFrom", &json!({"path": "Report.dotx", "data": data, "styles": ["Callout"]})).unwrap(); + assert!(s.doc.styles.get("Callout").is_some(), "{doc_path}"); + } + let mut s = Session::new(Document::new()); + s.path = Some(PathBuf::from("//fileserver/share/Letter.docx")); + let err = s.run("tools.updateStyles", &json!({"path": "Report.dotx"})).unwrap_err().to_string(); + assert!(err.contains("not a local file"), "{err}"); + } + #[test] fn template_references_become_local_paths() { assert_eq!(local_path("file:///C:/Users/A%20B/T.dotx", None), Some(PathBuf::from("C:/Users/A B/T.dotx"))); @@ -530,6 +565,16 @@ mod tests { assert!(!is_network_path("\\??\\C:\\Templates\\T.dotx")); assert!(!is_network_path("\\??\\C:")); assert!(is_network_path("\\??\\C:evil")); + // Verbatim and device paths with a drive are local; with `UNC` or anything else they aren't. + for local in ["\\\\?\\C:\\Templates\\T.dotx", "\\\\.\\C:\\Templates\\T.dotx", "//?/C:/Templates/T.dotx", "\\\\?\\C:"] { + assert!(!is_network_path(local), "{local}"); + } + for remote in + ["\\\\?\\UNC\\host\\share\\T.dotx", "//./UNC/host/share/T.dotx", "\\\\host\\share\\T.dotx", "\\\\?\\C:evil", "\\\\.\\PhysicalDrive0"] + { + assert!(is_network_path(remote), "{remote}"); + } + assert_eq!(local_path("T.dotx", Some(Path::new("//?/C:/docs/Letter.docx"))), Some(PathBuf::from("//?/C:/docs/T.dotx"))); assert_eq!(percent_decode("100%"), "100%"); assert_eq!(percent_decode("%zz%4"), "%zz%4"); } From 6766118d05cc4d987833929f168a96500573b381 Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Fri, 23 Oct 2026 04:27:23 +0100 Subject: [PATCH 032/199] =?UTF-8?q?#3692:=20review=20=E2=80=94=20doc=20com?= =?UTF-8?q?ments=20on=20the=20functions=20this=20branch=20touched=20that?= =?UTF-8?q?=20had=20none?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01CX1hMKX28764Lrx5moxs1o --- crates/engine/src/cmd/text.rs | 4 ++++ crates/engine/src/session.rs | 1 + crates/mcp/src/backend.rs | 3 +++ crates/mcp/src/tools.rs | 3 +++ 4 files changed, 11 insertions(+) diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 614086256b..5ce435e277 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -8,6 +8,7 @@ use wordcraft_doc::{Block, ListKind, Path, Pos, StoryRef, Table}; use super::{delete_selection, non_mac, sel_result, split_para, type_text}; use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p}; +/// The text-entry commands: typing, Enter, breaks, Tab, deleting and typed symbols. pub fn specs() -> Vec { vec![ CommandSpec::new("text.insert", "Type Text", "Editing", insert).params(r#"{"text": string, "raw"?: bool, "paragraphs"?: bool}"#), @@ -96,6 +97,8 @@ fn typing_options(s: &mut Session, text: &str) { } } +/// `text.insert`: type `text` at the selection as the keyboard does (AutoCorrect and +/// AutoFormat unless `raw`), or, with `paragraphs`, line by line with Enter between. fn insert(s: &mut Session, v: &Value) -> CmdResult { let text = p::req_str(v, "text")?; let raw = p::bool(v, "raw").unwrap_or(false); @@ -574,6 +577,7 @@ fn sample_text(s: &Session) -> Option { Some(text) } +/// `text.newParagraph`: Enter, unless the selection is in a control that takes no paragraph break. fn new_paragraph(s: &mut Session, _: &Value) -> CmdResult { // Enter in a one-line plain text control (or a check box or list) does nothing, as in Word. if super::controls::refuses_paragraph(s) { diff --git a/crates/engine/src/session.rs b/crates/engine/src/session.rs index fa24909887..7b25a21f74 100644 --- a/crates/engine/src/session.rs +++ b/crates/engine/src/session.rs @@ -637,6 +637,7 @@ fn typed_range(text: &str, off: usize) -> Option<(usize, usize)> { text.get(start..off)?.chars().any(char::is_alphanumeric).then_some((start, end)) } +/// The name of `doc`'s bibliography style, APA when it names none we know. fn doc_bib_style(doc: &Document) -> String { doc.settings.bib_style.as_deref().and_then(crate::cmd::citations::find_style).map_or("APA", |st| st.name).to_string() } diff --git a/crates/mcp/src/backend.rs b/crates/mcp/src/backend.rs index 1cf17c61d5..fe26b2dbb3 100644 --- a/crates/mcp/src/backend.rs +++ b/crates/mcp/src/backend.rs @@ -112,6 +112,7 @@ impl Remote { Ok(r) } + /// The address the app is reached at, as given. pub fn addr(&self) -> &str { &self.addr } @@ -127,6 +128,8 @@ impl Remote { }) } + /// Drop the connection and connect again to the first of the address's targets that answers, + /// deciding afresh whether the control key may go to it. fn reconnect(&mut self) -> std::io::Result<()> { self.conn = None; self.key = None; diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index d706d49e9a..cc127d8a79 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -52,6 +52,7 @@ fn tool(name: &str, title: &str, desc: &str, schema: Value, ro: bool) -> Value { json!({"name": name, "title": title, "description": desc, "inputSchema": schema, "annotations": {"title": title, "readOnlyHint": ro, "openWorldHint": false}}) } +/// Every MCP tool with its title, description, input schema and annotations (`tools/list`). pub fn tool_definitions() -> Value { json!([ tool( @@ -168,6 +169,8 @@ fn wrap(r: Result) -> ToolResult { } } +/// Run tool `name` with arguments `a` through backend `b` (`tools/call`); unknown tools and +/// failed calls are error results, never panics. pub fn call_tool(b: &mut dyn Backend, name: &str, a: &Value) -> ToolResult { let st = |k: &str| a.get(k).and_then(Value::as_str); match name { From 3d944e735af51ab42a66cdc66e6a5a23b6a68d44 Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Fri, 23 Oct 2026 11:32:58 +0100 Subject: [PATCH 033/199] =?UTF-8?q?#3692:=20review=20=E2=80=94=20the=20typ?= =?UTF-8?q?ed-paragraph=20line=20cap=20is=20text.insert's=20own,=20so=20co?= =?UTF-8?q?ntrol-channel=20and=20CLI=20calls=20are=20held=20to=20it=20too?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The MCP tool no longer counts lines itself; it quotes the engine's MAX_TYPED_PARAGRAPHS. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01CX1hMKX28764Lrx5moxs1o --- crates/engine/src/cmd/text.rs | 16 +++++++++++++--- crates/engine/src/tests.rs | 30 ++++++++++++++++++++++++++++++ crates/mcp/src/tests.rs | 11 ++++++----- crates/mcp/src/tools.rs | 16 ++++++---------- docs/mcp.md | 2 +- 5 files changed, 56 insertions(+), 19 deletions(-) diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 5ce435e277..1781de945c 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -11,7 +11,7 @@ use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p}; /// The text-entry commands: typing, Enter, breaks, Tab, deleting and typed symbols. pub fn specs() -> Vec { vec![ - CommandSpec::new("text.insert", "Type Text", "Editing", insert).params(r#"{"text": string, "raw"?: bool, "paragraphs"?: bool}"#), + CommandSpec::new("text.insert", "Type Text", "Editing", insert).params(r#"{"text": string, "raw"?: bool, "paragraphs"?: bool} (paragraphs: each line a paragraph, one undo step, at most 10000 lines)"#), CommandSpec::new("text.newParagraph", "New Paragraph", "Editing", new_paragraph).key("Enter"), CommandSpec::new("text.lineBreak", "Line Break", "Editing", |s, _| ins_char(s, '\n')).key("Shift+Enter"), CommandSpec::new("text.pageBreak", "Page Break", "Insert › Pages", |s, _| ins_char(s, PAGE_BREAK)).key("Mod+Enter"), @@ -587,14 +587,24 @@ fn new_paragraph(s: &mut Session, _: &Value) -> CmdResult { sel_result(s) } +/// Most lines `text.insert` with `paragraphs` types in one call, for every caller (control +/// channel, CLI, MCP): each line is an Enter, with its AutoFormat and AutoCorrect, so the work +/// grows with the line count. More is refused before anything is typed (#3692). +pub const MAX_TYPED_PARAGRAPHS: usize = 10_000; + /// `text.insert` with `paragraphs`: each line typed as it is (`raw`), and between lines the /// paragraph break Enter makes ([`enter`]: its AutoFormat and AutoCorrect), all in one command, so /// one undo step instead of a command and a whole-document snapshot per line (#3692). Where /// Enter would do nothing (a one-line control, a check box, a list) the text is refused and, -/// like any failed command, nothing of it stays. +/// like any failed command, nothing of it stays. More than [`MAX_TYPED_PARAGRAPHS`] lines are +/// refused before anything is typed. fn insert_paragraphs(s: &mut Session, text: &str) -> CmdResult { - typing_options(s, text); let text = text.replace("\r\n", "\n"); + let lines = text.split(['\n', '\r', '\u{2029}']).count(); + if lines > MAX_TYPED_PARAGRAPHS { + return Err(CmdError::Params(format!("text.insert: `text` has {lines} lines; type at most {MAX_TYPED_PARAGRAPHS} paragraphs per call"))); + } + typing_options(s, &text); for (i, line) in text.split(['\n', '\r', '\u{2029}']).enumerate() { if i > 0 { if super::controls::refuses_paragraph(s) { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 4f1aabf0b8..08a0c04d2a 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -3368,6 +3368,36 @@ fn failed_command_leaves_document_unchanged() { assert_eq!(text(&s), before); } +/// Review of #3692: the line cap on typed paragraphs is the engine's, so a control-channel or +/// CLI `text.insert` is held to it as the MCP tool is. At the cap every line goes in as one undo +/// step; one line more is refused before anything is typed. +#[test] +fn typed_paragraphs_are_capped_for_every_caller() { + use crate::CmdError; + use crate::cmd::text::MAX_TYPED_PARAGRAPHS; + let mut s = s(); + run(&mut s, "text.insert", json!({"text": "keep"})); + let (before, depth) = (body_texts(&s), s.undo_depth()); + // "x\n" repeated ends in an empty line: one over the cap. A lone "\r" counts as a break too. + for over in ["x\n".repeat(MAX_TYPED_PARAGRAPHS), format!("{}x", "x\r\n".repeat(MAX_TYPED_PARAGRAPHS - 1)) + "\rx"] { + match s.run("text.insert", &json!({"text": over, "paragraphs": true})) { + Err(CmdError::Params(m)) => assert!(m.contains("10001 lines") && m.contains("at most 10000"), "{m}"), + r => panic!("not refused: {r:?}"), + } + assert_eq!(body_texts(&s), before, "nothing typed"); + assert_eq!(s.undo_depth(), depth, "no undo step"); + } + let at_cap = vec!["y"; MAX_TYPED_PARAGRAPHS].join("\r\n"); + run(&mut s, "text.insert", json!({"text": at_cap, "paragraphs": true})); + let paras = body_texts(&s); + assert_eq!(paras.len(), MAX_TYPED_PARAGRAPHS); + // Each break is Enter's own, AutoCorrect included: the first sentence gets its capital. + assert_eq!((paras.first().map(String::as_str), paras.last().map(String::as_str)), (Some("Keepy"), Some("y"))); + assert!(s.undo(), "undo"); + assert_eq!(body_texts(&s).len(), 1, "one undo step takes every line back out"); + assert!(!body_texts(&s).iter().any(|p| p.contains('y'))); +} + #[test] fn hostile_params_never_panic() { let reg = cmd::registry(); diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index de99ddd57c..8bc4a444e4 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -158,7 +158,7 @@ fn type_text_stops_at_a_refused_paragraph_break() { } /// #3692: typed paragraphs are one edit and one undo step, not a command (and a whole-document -/// undo snapshot) per line; too many lines are refused before anything is typed. +/// undo snapshot) per line; too many lines are refused by the engine before anything is typed. #[test] fn type_text_paragraphs_are_one_undo_step_with_a_line_cap() { let mut s = Server::new(Box::new(Headless::default())); @@ -173,12 +173,13 @@ fn type_text_paragraphs_are_one_undo_step_with_a_line_cap() { assert_eq!(paras(&mut s), 1, "one Undo takes all the lines back out"); assert!(!tool(&mut s, "get_text", json!({}))["text"].as_str().unwrap().contains("Line")); - let calls = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); - let mut s = Server::new(Box::new(RefusesParagraph { calls: calls.clone() })); - let many = "x\n".repeat(crate::tools::MAX_TYPED_PARAGRAPHS); + // One line over the engine's cap ("x\n" repeated gives a last, empty line): refused, nothing typed. + let many = "x\n".repeat(wordcraft_engine::cmd::text::MAX_TYPED_PARAGRAPHS); let r = call(&mut s, 12, "tools/call", json!({"name": "type_text", "arguments": {"text": many, "paragraphs": true}})); assert_eq!(r["result"]["isError"], true, "{r}"); - assert!(calls.lock().unwrap().is_empty(), "refused before any command"); + assert!(r["result"]["content"][0]["text"].as_str().unwrap().contains("at most 10000 paragraphs"), "{r}"); + assert_eq!(paras(&mut s), 1, "nothing typed"); + assert!(!tool(&mut s, "get_text", json!({}))["text"].as_str().unwrap().contains('x')); } /// Ultrareview of #3692: each typed paragraph break is Enter's own (text.newParagraph's diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index cc127d8a79..54f3873fbd 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -103,7 +103,10 @@ pub fn tool_definitions() -> Value { tool( "type_text", "Type text", - "Type at the caret (replacing the selection). Use \\n... for line breaks inside text; use execute text.newParagraph for new paragraphs, or `paragraphs: true` to split lines into paragraphs (one undo step, at most 10000 lines).", + &format!( + "Type at the caret (replacing the selection). Use \\n... for line breaks inside text; use execute text.newParagraph for new paragraphs, or `paragraphs: true` to split lines into paragraphs (one undo step, at most {} lines).", + wordcraft_engine::cmd::text::MAX_TYPED_PARAGRAPHS + ), obj(json!({"text": s("Text"), "paragraphs": {"type": "boolean"}}), &["text"]), false ), @@ -155,9 +158,6 @@ pub fn tool_definitions() -> Value { ]) } -/// Most paragraphs `type_text` with `paragraphs: true` types in one call. -pub const MAX_TYPED_PARAGRAPHS: usize = 10_000; - fn exec(b: &mut dyn Backend, id: &str, params: Value) -> Result { b.call("engine.execute", json!({"command": id, "params": params})) } @@ -238,12 +238,8 @@ pub fn call_tool(b: &mut dyn Backend, name: &str, a: &Value) -> ToolResult { // One `text.insert` with `paragraphs`: each break is Enter's own (its AutoFormat, // and its refusal in a one-line control), but the lines go in as one edit and one // undo step, all or nothing, instead of a command (and a whole-document undo - // snapshot) per line. - let t = t.replace("\r\n", "\n").replace('\r', "\n"); - let lines = t.split(['\n', '\u{2029}']).count(); - if lines > MAX_TYPED_PARAGRAPHS { - return ToolResult::error(format!("`text` has {lines} lines; type at most {MAX_TYPED_PARAGRAPHS} paragraphs per call")); - } + // snapshot) per line. The engine splits the lines and holds them to its cap + // (`MAX_TYPED_PARAGRAPHS`), for this tool and every other caller alike. wrap(exec(b, "text.insert", json!({"text": t, "raw": true, "paragraphs": true}))) } else { wrap(exec(b, "text.insert", json!({"text": t, "raw": true}))) diff --git a/docs/mcp.md b/docs/mcp.md index 99bf754d54..e0dde5f7d2 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -112,7 +112,7 @@ files it was given rather than guarding against other software on the computer. | `batch` | run several commands in order | | `new_document` | blank, sample, letter, resume, report | | `open_document` / `save_document` | docx, doc (open only), odt, rtf, html, md, tex, txt, json; save also pdf, png | -| `type_text` | type at the caret (`paragraphs: true` splits lines into paragraphs: one undo step, at most 10,000 lines) | +| `type_text` | type at the caret (`paragraphs: true` splits lines into paragraphs: one undo step, at most 10,000 lines — the cap is `text.insert`'s own, so a control-channel or CLI call is held to it too) | | `select_text` | select the n-th occurrence of some text | | `get_text` / `inspect_document` | read the document back (verify without screenshots) | | `render_page` | a page as PNG | From 6f7da7345fc5cc70c2cea399b1791d63ebf60a7e Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 04:12:56 +0000 Subject: [PATCH 034/199] W1: every way of opening a file goes through WordApp::open_file --- apps/wordcraft/src/apple_events.rs | 2 +- crates/ui-egui/src/backstage.rs | 4 +- crates/ui-egui/src/documents.rs | 16 ++++++++ crates/ui-egui/src/file_browser.rs | 7 ++-- crates/ui-egui/src/file_dialogs.rs | 11 +++--- crates/ui-egui/src/lib.rs | 2 +- crates/ui-egui/src/windows.rs | 62 ++++++++++++++++++++++++++++++ 7 files changed, 92 insertions(+), 12 deletions(-) diff --git a/apps/wordcraft/src/apple_events.rs b/apps/wordcraft/src/apple_events.rs index 64f534392d..0eae596a9d 100644 --- a/apps/wordcraft/src/apple_events.rs +++ b/apps/wordcraft/src/apple_events.rs @@ -43,7 +43,7 @@ pub fn poll(inbox: &Inbox, app: &mut WordApp, ctx: &egui::Context) { match e { Event::Open(paths) => { for p in paths.iter().filter_map(|p| p.to_str()) { - match app.run("file.open", serde_json::json!({"path": p})) { + match app.open_file(p, None) { // Leave Backstage, as opening from the Open dialog does. Ok(_) => app.ui.backstage = false, Err(e) => { diff --git a/crates/ui-egui/src/backstage.rs b/crates/ui-egui/src/backstage.rs index 771cd13768..8b3a5084ad 100644 --- a/crates/ui-egui/src/backstage.rs +++ b/crates/ui-egui/src/backstage.rs @@ -564,8 +564,8 @@ pub(crate) fn forget_save_as_error(ctx: &egui::Context) { /// Open a file from the Recent list. A file that can't be opened (deleted, moved, unreadable) /// leaves File open so the list can say why, rather than closing it on a greyed-out window. -fn open_recent(app: &mut WordApp, path: &str) -> Result<(), String> { - app.run("file.open", json!({"path": path}))?; +pub(crate) fn open_recent(app: &mut WordApp, path: &str) -> Result<(), String> { + app.open_file(path, None)?; app.ui.backstage = false; app.canvas.want_focus = true; Ok(()) diff --git a/crates/ui-egui/src/documents.rs b/crates/ui-egui/src/documents.rs index 601dd3c8f2..c843e19d2a 100644 --- a/crates/ui-egui/src/documents.rs +++ b/crates/ui-egui/src/documents.rs @@ -102,6 +102,22 @@ impl WordApp { seen } + /// Open the file at `path` for the window showing document `into` (`None`: the active + /// document's window). Every way of opening a file comes through here, so each follows the + /// same rule: an untouched blank document is replaced in its own window, else the file opens + /// in a window of its own (or its window comes forward if it is already open). A file a + /// dialog picked arrives while any window draws, so the document it was asked from is made + /// active again first. + pub fn open_file(&mut self, path: &str, into: Option) -> Result { + if let Some(id) = into + && id != self.session.document_id() + && self.session.is_open(id) + { + self.show_document(id)?; + } + self.run("file.open", json!({"path": path})) + } + /// Show open document `id` in the window (View › Window › Switch Windows, quitting). pub(crate) fn show_document(&mut self, id: u64) -> Result<(), String> { self.session.activate(id).map_err(|e| e.to_string())?; diff --git a/crates/ui-egui/src/file_browser.rs b/crates/ui-egui/src/file_browser.rs index 502d6a3aa5..5c5765506e 100644 --- a/crates/ui-egui/src/file_browser.rs +++ b/crates/ui-egui/src/file_browser.rs @@ -286,7 +286,7 @@ impl WordApp { FileDialogRequest::Save { name: self.default_save_name(), dir: None }, AfterPick::SaveAs { after: crate::file_dialogs::AfterSave::Nothing, document: self.session.document_id() }, ), - _ => (FileDialogRequest::Open { purpose: "document".into() }, AfterPick::OpenDocument), + _ => (FileDialogRequest::Open { purpose: "document".into() }, AfterPick::OpenDocument { document: self.session.document_id() }), }; self.show_file_browser(req, after, false); } @@ -529,7 +529,7 @@ mod tests { std::fs::create_dir(dir.join("drafts")).unwrap(); std::fs::write(dir.join("drafts").join("old.docx"), b"x").unwrap(); let req = FileDialogRequest::Save { name: "Document1.docx".into(), dir: None }; - let mut b = FileBrowser::new(req, AfterPick::OpenDocument, dir.clone(), false); + let mut b = FileBrowser::new(req, AfterPick::OpenDocument { document: 0 }, dir.clone(), false); b.name = "drafts".into(); assert_eq!(b.accept(), Step::Stay); assert_eq!(b.dir, dir.join("drafts")); @@ -577,7 +577,8 @@ mod tests { fn an_empty_folder_says_there_are_no_items() { let dir = scratch("empty_state"); let mut a = WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default()); - let mut b = FileBrowser::new(FileDialogRequest::Open { purpose: "document".into() }, AfterPick::OpenDocument, dir.clone(), false); + let mut b = + FileBrowser::new(FileDialogRequest::Open { purpose: "document".into() }, AfterPick::OpenDocument { document: 0 }, dir.clone(), false); b.navigate(dir.clone()); assert!(matches!(&b.listing, Ok(l) if l.entries.is_empty()), "the folder starts empty"); a.file_browser.open = Some(Box::new(b)); diff --git a/crates/ui-egui/src/file_dialogs.rs b/crates/ui-egui/src/file_dialogs.rs index 2ccb8530f1..7f375e8c1f 100644 --- a/crates/ui-egui/src/file_dialogs.rs +++ b/crates/ui-egui/src/file_dialogs.rs @@ -56,8 +56,8 @@ pub fn open_filters(purpose: &str) -> &'static [(&'static str, &'static [&'stati /// What to do with the path picked in a dialog. pub(crate) enum AfterPick { - /// Open the picked document. - OpenDocument, + /// Open the picked document from the window showing `document` ([`WordApp::open_file`]). + OpenDocument { document: u64 }, /// Insert the picked picture, or replace the pending Change Picture target. Picture, /// Save the document there, then carry on. @@ -242,9 +242,9 @@ impl WordApp { /// Do what the dialog was for with the picked path (`None`: the user cancelled). pub(crate) fn finish_pick(&mut self, after: AfterPick, picked: Option) -> Result { match after { - AfterPick::OpenDocument => { + AfterPick::OpenDocument { document } => { let Some(path) = picked else { return Ok(json!({"cancelled": true})) }; - let r = self.run("file.open", json!({"path": path})); + let r = self.open_file(&path, Some(document)); self.ui.backstage = false; r } @@ -450,7 +450,8 @@ impl WordApp { f("document"); return; } - let _ = self.ask_file(FileDialogRequest::Open { purpose: "document".into() }, AfterPick::OpenDocument); + let _ = + self.ask_file(FileDialogRequest::Open { purpose: "document".into() }, AfterPick::OpenDocument { document: self.session.document_id() }); } pub(crate) fn pick_picture(&mut self) { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index cb1af01b3f..971674b4ea 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -2479,7 +2479,7 @@ impl WordApp { } else if wordcraft_fonts::is_font_file_name(&lp) { self.run("font.add", json!({"path": path})) } else { - self.run("file.open", json!({"path": path})) + self.open_file(&path, None) }; } } diff --git a/crates/ui-egui/src/windows.rs b/crates/ui-egui/src/windows.rs index b83a5e207e..0031b2085b 100644 --- a/crates/ui-egui/src/windows.rs +++ b/crates/ui-egui/src/windows.rs @@ -1251,4 +1251,66 @@ mod tests { a.run("ui.saveChanges", json!({"answer": "dontSave"})).unwrap(); assert!(a.quit_requested, "one prompt for the document"); } + + /// Two windows, the second (focused) on an untouched blank document: an app whose host + /// answers file dialogs only when the test does, plus the dialog's answer channel. + fn blank_beside_a_document() -> (egui::Context, WordApp, Shown, u64) { + let shown: Shown = std::rc::Rc::default(); + let log = shown.clone(); + let services = Services { + file_dialog: Some(Box::new(move |_| { + let (tx, rx) = std::sync::mpsc::channel(); + log.borrow_mut().push(tx); + rx + })), + ..Default::default() + }; + let ctx = backend(); + let mut a = WordApp::new(Session::new(wordcraft_doc::Document::new()), services); + frames(&ctx, &mut a, 2); + a.run("file.open", json!({"path": docx_on_disk("first", "First").to_string_lossy()})).unwrap(); + a.run("file.new", json!({})).unwrap(); + frames(&ctx, &mut a, 2); + assert!(a.window_count() == 2 && a.session.is_untouched_blank()); + let blank = a.windows.shown(); + (ctx, a, shown, blank) + } + + type Shown = std::rc::Rc>>>; + + /// Each way of opening a file replaces an untouched blank document in its own window, even + /// when the file arrives while another window draws: File › Open, ⌘O and the File page's + /// Browse (an asynchronous dialog), the File page's and Start screen's recent list, and + /// Finder (between frames, in the focused window). + #[test] + fn every_open_replaces_an_untouched_blank_document_in_its_window() { + let second = docx_on_disk("second", "Second"); + let path = second.to_string_lossy().to_string(); + let opens: [(&str, fn(&mut WordApp, &egui::Context, &Shown, &str)); 3] = [ + ("dialog", |a, ctx, shown, path| { + a.run("ui.backstage", json!({"value": true, "page": "open"})).unwrap(); + a.run("ui.openFileDialog", json!({})).unwrap(); + frames(ctx, a, 2); + let tx = shown.borrow_mut().pop().unwrap(); + tx.send(Ok(vec![path.to_string()])).unwrap(); + frames(ctx, a, 3); + }), + ("recent", |a, ctx, _, path| { + crate::backstage::open_recent(a, path).unwrap(); + frames(ctx, a, 2); + }), + ("finder", |a, ctx, _, path| { + a.open_file(path, None).unwrap(); + frames(ctx, a, 2); + }), + ]; + for (how, open) in opens { + let (ctx, mut a, shown, blank) = blank_beside_a_document(); + open(&mut a, &ctx, &shown, &path); + assert_eq!(a.window_count(), 2, "{how}: no third window"); + assert!(a.enter_window(blank), "{how}: the blank document's window is still open"); + assert_eq!(text(&a), "Second", "{how}: it shows the opened file"); + } + std::fs::remove_dir_all(second.parent().unwrap()).ok(); + } } From 753c624a7d98732fb85cf09e3bedc87e4433d401 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 06:14:27 +0000 Subject: [PATCH 035/199] W1: the log says why an opened file didn't replace the active document --- crates/engine/src/cmd/file.rs | 10 +++++---- crates/engine/src/session/documents.rs | 28 ++++++++++++++++++-------- 2 files changed, 26 insertions(+), 12 deletions(-) diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index db8ce49554..4e0b424a1a 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -265,10 +265,12 @@ fn open(s: &mut Session, v: &Value) -> CmdResult { { doc.styles.default_chr.lang = Some(lang.clone()); } - if s.is_untouched_blank() { - s.set_document(doc); - } else { - s.add_opened_document(doc); + match s.used_because() { + None => s.set_document(doc), + Some(why) => { + log::info!("file.open: opened beside document {}, not in its place: {why}", s.document_id()); + s.add_opened_document(doc); + } } if let Some(z) = s.doc.settings.zoom { super::view::restore_zoom(s, z); diff --git a/crates/engine/src/session/documents.rs b/crates/engine/src/session/documents.rs index c8570d426b..b07fb55477 100644 --- a/crates/engine/src/session/documents.rs +++ b/crates/engine/src/session/documents.rs @@ -529,14 +529,26 @@ impl Session { /// changed, nothing to undo or redo and no text. Opening a file replaces such a document /// rather than opening beside it. pub fn is_untouched_blank(&self) -> bool { - self.path.is_none() - && self.file_name.is_none() - && !self.dirty - && self.history.is_empty() - && self.redo.is_empty() - && self.rev == self.made_rev - && self.doc.paragraph_count() <= 1 - && self.doc.plain_text(StoryRef::Body).trim().is_empty() + self.used_because().is_none() + } + + /// Why the active document doesn't count as an untouched blank one, if it doesn't. + pub(crate) fn used_because(&self) -> Option<&'static str> { + if self.path.is_some() { + Some("it has a file") + } else if self.file_name.is_some() { + Some("it has a name") + } else if self.dirty { + Some("it has unsaved changes") + } else if !self.history.is_empty() || !self.redo.is_empty() { + Some("it has undo or redo steps") + } else if self.rev != self.made_rev { + Some("it changed since it was made") + } else if self.doc.paragraph_count() > 1 || !self.doc.plain_text(StoryRef::Body).trim().is_empty() { + Some("it has text") + } else { + None + } } /// Run `f` with document `id` active, then make the document that was active before active From 3db25e4aa948465f70f323aa64151ff1a7e062a3 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 06:51:04 +0000 Subject: [PATCH 036/199] Tests from main: mailing results open beside the source document --- crates/engine/src/tests.rs | 9 ++++++--- crates/ui-egui/src/dialogs.rs | 13 ++++++++----- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 7f8db21aac..18f6861d37 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -8560,9 +8560,10 @@ fn a_rejected_comparison_keeps_the_current_versions() { assert_eq!(s.file_name.as_deref(), Some("original.docx")); } -/// A rejected mailing result must keep the source document's save target and versions. +/// A mailing result opens beside a protected source document, which keeps its save target, +/// versions, password and text. #[test] -fn a_rejected_mailing_result_keeps_the_current_file_identity() { +fn a_mailing_result_beside_a_protected_document_keeps_its_file_identity() { use wordcraft_doc::{CharProps, InlineObject}; let mut s = Session::new(wordcraft_doc::Document::from_text("Protected source.")); @@ -8580,7 +8581,9 @@ fn a_rejected_mailing_result_keeps_the_current_file_identity() { s.password = Some(crate::io::Password::new("synthetic file password")); s.bib_style = "Chicago".into(); - assert!(s.run("mailings.envelopes", &json!({"delivery": "Recipient"})).is_err()); + s.run("mailings.envelopes", &json!({"delivery": "Recipient"})).unwrap(); + assert_ne!(s.document_id(), before_id, "the envelope is a new document"); + s.activate(before_id).unwrap(); assert_eq!(s.doc, before); assert_eq!(s.versions.len(), 1); assert_eq!(s.path.as_deref(), Some(std::path::Path::new("source.docx"))); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index e028273c89..ef8dcd1ad1 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -4449,15 +4449,16 @@ mod tests { /// #1346: a check box the selected paragraphs differ in shows as neither on nor off; a click /// and OK turn it on in all of them, and the other fields they differ in stay as they were. - /// #3028: the records dialog refuses a range with no records itself, before Save Changes asks - /// about the unsaved document; a good range goes on to that question. + /// #3028: the records dialog refuses a range with no records itself; a good range merges into + /// a new document beside the unsaved one, asking nothing. #[test] - fn merge_records_dialog_checks_the_range_before_asking_to_save() { + fn merge_records_dialog_checks_the_range_before_merging() { use egui_kittest::kittest::Queryable; let mut app = app_with("Dear "); let _ = app.run("mailings.recipients", json!({"csv": "Name\nAda\nAlan\nGrace"})); let _ = app.run("text.insert", json!({"text": "unsaved"})); assert!(app.session.dirty); + let source = app.session.document_id(); app.dialog = Dialog::open("mergeRecords", &mut app); if let Some(Dialog::MergeRecords { choice, from, to, .. }) = &mut app.dialog { *choice = 2; @@ -4491,8 +4492,10 @@ mod tests { for _ in 0..2 { h.step(); } - assert_eq!(h.state().dialog.as_ref().map(|d| d.name()), Some("saveChanges")); - assert!(h.state().session.doc.plain_text(wordcraft_doc::StoryRef::Body).contains("unsaved"), "nothing merged yet"); + assert!(h.state().dialog.is_none(), "nothing to ask about: the merge opens beside"); + assert_ne!(h.state().session.document_id(), source, "the merge is a new document"); + h.state_mut().run("view.switchWindows", json!({"id": source})).unwrap(); + assert!(h.state().session.doc.plain_text(wordcraft_doc::StoryRef::Body).contains("unsaved"), "the unsaved document is still open"); } #[test] From 4c246f082aa7cb68d636c97c85cd9cd909dacf2f Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 20:11:30 +0000 Subject: [PATCH 037/199] Task panes: F6 stays in dialogs and runs user bindings (#1756) --- crates/ui-egui/src/content_controls.rs | 7 ++ crates/ui-egui/src/panes.rs | 99 +++++++++++++++++++++++++- crates/ui-egui/src/zotero.rs | 7 ++ 3 files changed, 112 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/content_controls.rs b/crates/ui-egui/src/content_controls.rs index 2febf59634..186ad339d1 100644 --- a/crates/ui-egui/src/content_controls.rs +++ b/crates/ui-egui/src/content_controls.rs @@ -25,6 +25,13 @@ pub struct ControlUi { popup: Option, } +impl ControlUi { + /// Whether a control's list or calendar is open. + pub(crate) fn popup_open(&self) -> bool { + self.popup.is_some() + } +} + struct Popup { /// Content start of the control it belongs to. at: Pos, diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index 5c39b9d029..0de362de17 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -159,6 +159,39 @@ impl PaneContent for Content<'_> { } } +const HOST: &str = "task_panes"; + +/// F6 and Shift+F6 move the keyboard through the panes and back to the document. The key stays +/// with an open dialog, file browser, popup or keytips (Customize Keyboard records it), and an +/// F6 the user bound to a command runs that command instead. +fn route_f6(app: &WordApp, ctx: &egui::Context) { + let busy = app.dialog.is_some() + || app.file_dialog_open() + || app.colors.is_some() + || app.controls.popup_open() + || app.zotero.alert_open() + || app.ui.keytips != crate::keytips::Phase::Off + || egui::Popup::is_any_open(ctx); + if busy { + return; + } + let reg = &app.session.registry; + let bound = |m: egui::Modifiers| app.session.keymap.resolve(reg, &crate::keys::combo(m, "F6", true)).is_some(); + let mut cycle = None; + ctx.input_mut(|input| { + input.events.retain(|e| match e { + egui::Event::Key { key: egui::Key::F6, pressed: true, modifiers: m, .. } if !m.command && !m.ctrl && !m.mac_cmd && !bound(*m) => { + cycle = Some(m.shift); + false + } + _ => true, + }); + }); + if let Some(reverse) = cycle { + craft_ui::panes::focus_next(ctx, HOST, reverse); + } +} + /// Draw the open panes around the document. Call before the canvas's `CentralPanel`. pub fn show(app: &mut WordApp, ui: &mut Ui) { let t = Tokens::get(ui.ctx()); @@ -166,9 +199,11 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { if !open.contains(&Pane::FormatShape) { crate::format_shape::forget_drag(ui.ctx()); } + route_f6(app, ui.ctx()); let styles = styles_state(app); - let out = PaneHost::new("task_panes") + let out = PaneHost::new(HOST) .policy(SidePolicy::Columns) + .f6(false) .labels(labels()) .frame(egui::Frame::NONE.fill(t.panel).stroke(Stroke::new(1.0, t.border))) .body_margin(egui::Margin::same(10)) @@ -1053,6 +1088,68 @@ mod tests { assert_eq!(h.state().session.doc.plain_text(wordcraft_doc::StoryRef::Body).trim(), "typed"); } + fn pane_harness(app: crate::WordApp) -> egui_kittest::Harness<'static, crate::WordApp> { + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state( + |ui, app: &mut crate::WordApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + h.run_steps(4); + h + } + + fn press(h: &mut egui_kittest::Harness<'static, crate::WordApp>, key: egui::Key, modifiers: egui::Modifiers) { + h.input_mut().events.push(egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }); + h.run_steps(2); + } + + /// F6 and Shift+F6 with a dialog open leave the keyboard in the dialog: no pane title bar and + /// no document behind it takes it. + #[test] + #[cfg(not(target_arch = "wasm32"))] + fn f6_in_an_open_dialog_keeps_focus_there() { + for panes in [true, false] { + let mut app = crate::WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::from_text("")), crate::Services::default()); + app.run("view.navigationPane", serde_json::json!({"value": panes})).unwrap(); + app.run("ui.dialog", serde_json::json!({"name": "replace"})).unwrap(); + let mut h = pane_harness(app); + let focused = h.ctx.memory(|m| m.focused()); + for shift in [false, true] { + press(&mut h, egui::Key::F6, if shift { egui::Modifiers::SHIFT } else { egui::Modifiers::NONE }); + assert_eq!(h.ctx.memory(|m| m.focused()), focused, "panes open: {panes}, shift: {shift}"); + assert!(!h.state().canvas.focused && h.state().dialog.is_some(), "panes open: {panes}, shift: {shift}"); + } + } + } + + /// An F6 the user bound runs its command; plain F6 still moves to the panes when only + /// Shift+F6 is bound. + #[test] + #[cfg(not(target_arch = "wasm32"))] + fn a_user_bound_f6_runs_its_command() { + for key in ["F6", "Shift+F6"] { + let mut app = crate::WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::from_text("")), crate::Services::default()); + app.run("view.navigationPane", serde_json::json!({"value": true})).unwrap(); + app.session.keymap = wordcraft_engine::keymap::KeyMap::from_value(&serde_json::json!({"assigned": {key: "view.focus"}})); + let mut h = pane_harness(app); + assert!(h.state().canvas.focused); + let m = if key == "F6" { egui::Modifiers::NONE } else { egui::Modifiers::SHIFT }; + press(&mut h, egui::Key::F6, m); + assert!(h.state().session.view.focus_mode, "{key} ran its command"); + press(&mut h, egui::Key::F6, m); + assert!(!h.state().session.view.focus_mode, "{key} ran its command again"); + if key == "Shift+F6" { + h.state_mut().canvas.want_focus = true; + h.run_steps(2); + press(&mut h, egui::Key::F6, egui::Modifiers::NONE); + assert!(!h.state().canvas.focused, "plain F6 moved the keyboard to the pane"); + } + } + } + /// #3032: Delete is greyed out for built-in styles in the Styles pane (the engine refuses /// them — `crates/engine/src/cmd/para.rs`), while custom styles keep it enabled. #[test] diff --git a/crates/ui-egui/src/zotero.rs b/crates/ui-egui/src/zotero.rs index e5c4ef32b2..97ee45cde9 100644 --- a/crates/ui-egui/src/zotero.rs +++ b/crates/ui-egui/src/zotero.rs @@ -46,6 +46,13 @@ pub struct ZoteroLink { methods: Vec, } +impl ZoteroLink { + /// Whether an alert from Zotero is waiting for an answer. + pub(crate) fn alert_open(&self) -> bool { + self.alert.is_some() + } +} + /// Focus requests from the bridge. struct AppHost<'a> { ctx: &'a egui::Context, From 8e0353ae41b7d68a06c85f5c1f5d746512823fa7 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 20:12:55 +0000 Subject: [PATCH 038/199] Task panes: shortcuts run while a pane title bar has focus (#1756) --- crates/ui-egui/src/keys.rs | 5 +++-- crates/ui-egui/src/panes.rs | 22 ++++++++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/keys.rs b/crates/ui-egui/src/keys.rs index abf9513909..68541737b5 100644 --- a/crates/ui-egui/src/keys.rs +++ b/crates/ui-egui/src/keys.rs @@ -705,10 +705,11 @@ fn is_global_shortcut(key: Key, m: Modifiers) -> bool { m.command || m.alt || is_function_key(key) } -/// Command shortcuts when the canvas isn't focused but no text field is either. +/// Command shortcuts when the canvas isn't focused and no text field is: nothing has the +/// keyboard, or a button or a pane's title bar does. pub fn global_shortcuts(app: &mut WordApp, ctx: &egui::Context) { // Customize Keyboard records the keys pressed; they never run commands meanwhile. - if app.canvas.focused || ctx.egui_wants_keyboard_input() || matches!(app.dialog, Some(crate::dialogs::Dialog::CustomizeKeyboard { .. })) { + if app.canvas.focused || ctx.text_edit_focused() || matches!(app.dialog, Some(crate::dialogs::Dialog::CustomizeKeyboard { .. })) { return; } let events = ctx.input(|i| i.events.clone()); diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index 0de362de17..5533753772 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -1125,6 +1125,28 @@ mod tests { } } + /// Ctrl and F-key shortcuts run while F6 has put the keyboard on a pane's title bar; a pane's + /// text field keeps Ctrl+H for itself. + #[test] + #[cfg(not(target_arch = "wasm32"))] + fn shortcuts_run_while_a_pane_title_bar_has_focus() { + let mut app = crate::WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::from_text("")), crate::Services::default()); + app.run("view.navigationPane", serde_json::json!({"value": true})).unwrap(); + let mut h = pane_harness(app); + press(&mut h, egui::Key::F6, egui::Modifiers::NONE); + assert!(!h.state().canvas.focused && h.ctx.memory(|m| m.focused()).is_some(), "the title bar has the keyboard"); + press(&mut h, egui::Key::H, egui::Modifiers::COMMAND); + assert_eq!(h.state().dialog.as_ref().map(|d| d.name()), Some("replace"), "Ctrl+H opened Replace"); + press(&mut h, egui::Key::Escape, egui::Modifiers::NONE); + assert!(h.state().dialog.is_none()); + + h.state_mut().nav_search_focus = true; + h.run_steps(2); + assert!(h.ctx.text_edit_focused()); + press(&mut h, egui::Key::H, egui::Modifiers::COMMAND); + assert!(h.state().dialog.is_none(), "the search field keeps Ctrl+H"); + } + /// An F6 the user bound runs its command; plain F6 still moves to the panes when only /// Shift+F6 is bound. #[test] From 6fac5994f75187e45fa8db5f831bba480b5283e5 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 20:13:42 +0000 Subject: [PATCH 039/199] Task panes: bump only craft-ui; craft-print, craft-pdf and craft-i18n stay at main's revs (#1756) --- Cargo.lock | 1556 ++--------------------------------- Cargo.toml | 4 +- apps/wordcraft/Cargo.toml | 2 +- apps/wordcraft/src/print.rs | 2 +- crates/ui-egui/Cargo.toml | 4 +- 5 files changed, 59 insertions(+), 1509 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8eb446a43f..1510c03264 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -129,7 +129,7 @@ checksum = "35f0f96ce78e38c3dc6d8948aa8163d06385be74000f3c7a95bf1eef35d3ea32" dependencies = [ "cipher", "cpubits", - "cpufeatures 0.3.1", + "cpufeatures", ] [[package]] @@ -452,48 +452,12 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" -[[package]] -name = "aws-lc-rs" -version = "1.18.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "faac5829c2b74c28f830747e7818ccfb684261b5f48a1118b1e2a13d36dfab13" -dependencies = [ - "aws-lc-sys", - "untrusted 0.7.1", - "zeroize", -] - -[[package]] -name = "aws-lc-sys" -version = "0.46.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1622d8446a2d4b2ce0c7eefc73dd43a99779028d5ee5c2dd8073a658ba8a2bc" -dependencies = [ - "cc", - "cmake", - "dunce", - "fs_extra", - "pkg-config", -] - [[package]] name = "base64" version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" -[[package]] -name = "base64" -version = "0.23.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" - -[[package]] -name = "base64ct" -version = "1.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" - [[package]] name = "bit-set" version = "0.8.0" @@ -536,15 +500,6 @@ version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" -[[package]] -name = "block-buffer" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" -dependencies = [ - "generic-array", -] - [[package]] name = "block-buffer" version = "0.12.1" @@ -738,17 +693,6 @@ dependencies = [ "libc", ] -[[package]] -name = "chacha20" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" -dependencies = [ - "cfg-if", - "cpufeatures 0.3.1", - "rand_core 0.10.1", -] - [[package]] name = "chardetng" version = "1.0.0" @@ -766,12 +710,7 @@ version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ - "iana-time-zone", - "js-sys", "num-traits", - "serde", - "wasm-bindgen", - "windows-link", ] [[package]] @@ -780,7 +719,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" dependencies = [ - "crypto-common 0.2.2", + "crypto-common", "inout", ] @@ -793,54 +732,12 @@ dependencies = [ "error-code", ] -[[package]] -name = "cmake" -version = "0.1.58" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" -dependencies = [ - "cc", -] - [[package]] name = "cmov" version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" -[[package]] -name = "cmpv2" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "961b955a666e25ee5a1091d219128d6e6401e3dab84efb1a2bf6b4035d797b39" -dependencies = [ - "crmf", - "der", - "spki", - "x509-cert", -] - -[[package]] -name = "cms" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b77c319abfd5219629c45c34c89ba945ed3c5e49fcde9d16b6c3885f118a730" -dependencies = [ - "const-oid 0.9.6", - "der", - "spki", - "x509-cert", -] - -[[package]] -name = "cobs" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" -dependencies = [ - "thiserror 2.0.21", -] - [[package]] name = "codespan-reporting" version = "0.13.1" @@ -896,12 +793,6 @@ dependencies = [ "crossbeam-utils", ] -[[package]] -name = "const-oid" -version = "0.9.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" - [[package]] name = "const-oid" version = "0.10.2" @@ -918,16 +809,6 @@ dependencies = [ "libc", ] -[[package]] -name = "core-foundation" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" -dependencies = [ - "core-foundation-sys", - "libc", -] - [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -941,7 +822,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" dependencies = [ "bitflags 1.3.2", - "core-foundation 0.9.4", + "core-foundation", "core-graphics-types", "foreign-types", "libc", @@ -954,7 +835,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" dependencies = [ "bitflags 1.3.2", - "core-foundation 0.9.4", + "core-foundation", "libc", ] @@ -970,15 +851,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" -[[package]] -name = "cpufeatures" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" -dependencies = [ - "libc", -] - [[package]] name = "cpufeatures" version = "0.3.1" @@ -991,30 +863,18 @@ dependencies = [ [[package]] name = "craft-i18n" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3#16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3" +source = "git+https://github.com/storytold/craft-libs?rev=f7cc8a7df15e824b02cf73859567a6242d3a7de4#f7cc8a7df15e824b02cf73859567a6242d3a7de4" dependencies = [ - "fixed_decimal", - "icu_decimal", - "icu_locale_core", - "icu_locale_fallback", - "icu_normalizer", - "icu_plurals", - "icu_properties", - "icu_provider", - "icu_provider_adapters", - "icu_provider_blob", + "intl_pluralrules", "log", - "objc2-foundation 0.3.2", "sys-locale", - "unicode-bidi", - "windows-registry", - "writeable", + "unic-langid", ] [[package]] name = "craft-pdf" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3#16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3" +source = "git+https://github.com/storytold/craft-libs?rev=9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef#9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef" dependencies = [ "krilla", "thiserror 2.0.21", @@ -1023,14 +883,9 @@ dependencies = [ [[package]] name = "craft-print" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3#16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3" +source = "git+https://github.com/storytold/craft-libs?rev=d27646a5f7c8b4fc85d9dd3213b8c88b58e0088d#d27646a5f7c8b4fc85d9dd3213b8c88b58e0088d" dependencies = [ - "craft-winprint-gdi", - "miniz_oxide 0.8.9", - "roxmltree", "tempfile", - "winprint", - "winsafe", ] [[package]] @@ -1045,14 +900,6 @@ dependencies = [ "objc2-foundation 0.3.2", ] -[[package]] -name = "craft-winprint-gdi" -version = "0.1.0" -source = "git+https://github.com/storytold/winprint.rs?rev=417ac3c4d14d3da5485a99c7d5ce8f05664ec312#417ac3c4d14d3da5485a99c7d5ce8f05664ec312" -dependencies = [ - "windows-sys 0.61.2", -] - [[package]] name = "crc32fast" version = "1.5.2" @@ -1062,18 +909,6 @@ dependencies = [ "cfg-if", ] -[[package]] -name = "crmf" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36fe21b96d5b87f5de4b5b7202ec41c00110ac817ce6728fe75fb2fe5962ed92" -dependencies = [ - "cms", - "der", - "spki", - "x509-cert", -] - [[package]] name = "crossbeam-channel" version = "0.5.17" @@ -1114,16 +949,6 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" -[[package]] -name = "crypto-common" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" -dependencies = [ - "generic-array", - "typenum", -] - [[package]] name = "crypto-common" version = "0.2.2" @@ -1148,30 +973,6 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" -[[package]] -name = "der" -version = "0.7.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" -dependencies = [ - "const-oid 0.9.6", - "der_derive", - "flagset", - "pem-rfc7468", - "zeroize", -] - -[[package]] -name = "der_derive" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8034092389675178f570469e6c3b0465d3d30b4505c294a6550db47f3c17ad18" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "derive_arbitrary" version = "1.4.2" @@ -1196,25 +997,15 @@ dependencies = [ "rayon", ] -[[package]] -name = "digest" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" -dependencies = [ - "block-buffer 0.10.4", - "crypto-common 0.1.7", -] - [[package]] name = "digest" version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ - "block-buffer 0.12.1", - "const-oid 0.10.2", - "crypto-common 0.2.2", + "block-buffer", + "const-oid", + "crypto-common", "ctutils", ] @@ -1277,12 +1068,6 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" -[[package]] -name = "dunce" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" - [[package]] name = "ecolor" version = "0.36.2" @@ -1596,45 +1381,18 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3296eb97d869113eaf148a6bdf01884e90b3828ad7ae2340b0f93362fc158611" -[[package]] -name = "filetime" -version = "0.2.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" -dependencies = [ - "cfg-if", - "libc", -] - [[package]] name = "find-msvc-tools" version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" -[[package]] -name = "fixed_decimal" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2214fb00f669ee96bee5bb29dfb1ddabe058b2bc426817efc79afa03c9bc55ae" -dependencies = [ - "displaydoc", - "smallvec", - "writeable", -] - [[package]] name = "fixedbitset" version = "0.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" -[[package]] -name = "flagset" -version = "0.4.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7ac824320a75a52197e8f2d787f6a38b6718bb6897a35142d749af3c0e8f4fe" - [[package]] name = "flate2" version = "1.1.10" @@ -1655,28 +1413,6 @@ dependencies = [ "num-traits", ] -[[package]] -name = "fmt-derive" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1cb4a4b45c26455885f8b462db12978e51f60cc914337bccb9a36faf93410169" -dependencies = [ - "fmt-derive-proc", -] - -[[package]] -name = "fmt-derive-proc" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7704da0760734344ce8753f15b21e97c6be85dc5a8f2c3a963fbd042b3380d23" -dependencies = [ - "proc-macro-crate", - "proc-macro-error", - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "fmv-macos-events" version = "0.1.0" @@ -1760,22 +1496,6 @@ dependencies = [ "percent-encoding", ] -[[package]] -name = "fs_extra" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" - -[[package]] -name = "futures-channel" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" -dependencies = [ - "futures-core", - "futures-sink", -] - [[package]] name = "futures-core" version = "0.3.34" @@ -1812,12 +1532,6 @@ dependencies = [ "syn 3.0.6", ] -[[package]] -name = "futures-sink" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" - [[package]] name = "futures-task" version = "0.3.34" @@ -1831,25 +1545,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures-core", - "futures-io", "futures-macro", - "futures-sink", "futures-task", - "memchr", "pin-project-lite", "slab", ] -[[package]] -name = "generic-array" -version = "0.14.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" -dependencies = [ - "typenum", - "version_check", -] - [[package]] name = "gethostname" version = "1.1.0" @@ -1869,19 +1570,6 @@ dependencies = [ "unicode-width", ] -[[package]] -name = "getrandom" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" -dependencies = [ - "cfg-if", - "js-sys", - "libc", - "wasi", - "wasm-bindgen", -] - [[package]] name = "getrandom" version = "0.3.4" @@ -1903,11 +1591,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", - "js-sys", "libc", "r-efi 6.0.0", - "rand_core 0.10.1", - "wasm-bindgen", ] [[package]] @@ -2065,25 +1750,6 @@ dependencies = [ "euclid", ] -[[package]] -name = "h2" -version = "0.4.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d29020232d6aa3fb1daca64c1127cf662cf97f254ae16c18c05b8ab635fc118" -dependencies = [ - "atomic-waker", - "bytes", - "fnv", - "futures-core", - "futures-sink", - "http", - "indexmap", - "slab", - "tokio", - "tokio-util", - "tracing", -] - [[package]] name = "half" version = "2.7.1" @@ -2232,12 +1898,6 @@ dependencies = [ "zune-jpeg", ] -[[package]] -name = "heck" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" - [[package]] name = "hermit-abi" version = "0.5.3" @@ -2256,48 +1916,9 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" dependencies = [ - "digest 0.11.3", + "digest", ] -[[package]] -name = "http" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" -dependencies = [ - "bytes", - "itoa", -] - -[[package]] -name = "http-body" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" -dependencies = [ - "bytes", - "http", -] - -[[package]] -name = "http-body-util" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" -dependencies = [ - "bytes", - "futures-core", - "http", - "http-body", - "pin-project-lite", -] - -[[package]] -name = "httparse" -version = "1.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" - [[package]] name = "hybrid-array" version = "0.4.15" @@ -2307,92 +1928,6 @@ dependencies = [ "typenum", ] -[[package]] -name = "hyper" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" -dependencies = [ - "atomic-waker", - "bytes", - "futures-channel", - "futures-core", - "h2", - "http", - "http-body", - "httparse", - "itoa", - "pin-project-lite", - "smallvec", - "tokio", - "want", -] - -[[package]] -name = "hyper-rustls" -version = "0.27.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" -dependencies = [ - "http", - "hyper", - "hyper-util", - "rustls", - "tokio", - "tokio-rustls", - "tower-service", -] - -[[package]] -name = "hyper-util" -version = "0.1.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" -dependencies = [ - "base64 0.23.1", - "bytes", - "futures-channel", - "futures-util", - "http", - "http-body", - "httparse", - "hyper", - "ipnet", - "libc", - "percent-encoding", - "pin-project-lite", - "socket2", - "system-configuration", - "tokio", - "tower-service", - "tracing", - "windows-registry", -] - -[[package]] -name = "iana-time-zone" -version = "0.1.65" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" -dependencies = [ - "android_system_properties", - "core-foundation-sys", - "iana-time-zone-haiku", - "js-sys", - "log", - "wasm-bindgen", - "windows-core", -] - -[[package]] -name = "iana-time-zone-haiku" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" -dependencies = [ - "cc", -] - [[package]] name = "icu_collections" version = "2.3.0" @@ -2407,23 +1942,6 @@ dependencies = [ "zerovec", ] -[[package]] -name = "icu_decimal" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9eb8f655bba2d0c0459e43a5b0e6cd3cd388109898fb3d85d048165e8b0abd08" -dependencies = [ - "displaydoc", - "fixed_decimal", - "icu_locale_core", - "icu_pattern", - "icu_plurals", - "icu_provider", - "serde", - "writeable", - "zerovec", -] - [[package]] name = "icu_locale_core" version = "2.3.0" @@ -2432,32 +1950,11 @@ checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" dependencies = [ "displaydoc", "litemap", - "serde", "tinystr", "writeable", "zerovec", ] -[[package]] -name = "icu_locale_fallback" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "251af8e57c9400e3eb58242fe5b8b1152b2a64fdf4cf632f923c38ccee6f2fa9" -dependencies = [ - "icu_locale_core", - "icu_locale_fallback_data", - "icu_provider", - "potential_utf", - "tinystr", - "zerovec", -] - -[[package]] -name = "icu_locale_fallback_data" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "decf2a22ec8fa68f1a0c1129a3f8583f8f8bc24e8b9ccbe98ead99f62a4dc3a8" - [[package]] name = "icu_normalizer" version = "2.3.0" @@ -2469,9 +1966,6 @@ dependencies = [ "icu_properties", "icu_provider", "smallvec", - "utf16_iter", - "utf8_iter", - "write16", "zerovec", ] @@ -2481,32 +1975,6 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" -[[package]] -name = "icu_pattern" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b3db611446bd92c246b03e4aa0bafa71ddb194af431ddbb42b9d70e4bfce1a0" -dependencies = [ - "displaydoc", - "either", - "serde", - "writeable", - "zerovec", -] - -[[package]] -name = "icu_plurals" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e475e6766ef87b1d3c1f97be6363995b0bfaeddbc789671615df598a97ff0593" -dependencies = [ - "displaydoc", - "fixed_decimal", - "icu_provider", - "serde", - "zerovec", -] - [[package]] name = "icu_properties" version = "2.3.0" @@ -2536,9 +2004,6 @@ checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" dependencies = [ "displaydoc", "icu_locale_core", - "postcard", - "serde", - "stable_deref_trait", "writeable", "yoke", "zerofrom", @@ -2546,30 +2011,6 @@ dependencies = [ "zerovec", ] -[[package]] -name = "icu_provider_adapters" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df887bc4f55dbc773c7c45a0c7dfa75be886f7b7812830a71ab7271cc1801820" -dependencies = [ - "icu_locale_fallback", - "icu_provider", -] - -[[package]] -name = "icu_provider_blob" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5916b44fac319d3f58e4d8d805a627df19e27937b582cd99fcde3bb6b7ce1b0e" -dependencies = [ - "icu_provider", - "postcard", - "serde", - "writeable", - "zerotrie", - "zerovec", -] - [[package]] name = "idna" version = "1.1.0" @@ -2646,10 +2087,13 @@ dependencies = [ ] [[package]] -name = "ipnet" -version = "2.12.2" +name = "intl_pluralrules" +version = "7.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" +checksum = "078ea7b7c29a2b4df841a7f6ac8775ff6074020c6776d48491ce2268e068f972" +dependencies = [ + "unic-langid", +] [[package]] name = "is-docker" @@ -2794,9 +2238,9 @@ dependencies = [ [[package]] name = "krilla" version = "0.8.2" -source = "git+https://github.com/storytold/craft-libs?rev=16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3#16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3" +source = "git+https://github.com/storytold/craft-libs?rev=9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef#9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef" dependencies = [ - "base64 0.22.1", + "base64", "bumpalo", "flate2", "float-cmp", @@ -2893,9 +2337,6 @@ name = "litemap" version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" -dependencies = [ - "serde_core", -] [[package]] name = "litrs" @@ -2918,12 +2359,6 @@ version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" -[[package]] -name = "lru-slab" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" - [[package]] name = "memchr" version = "2.8.3" @@ -2948,12 +2383,6 @@ dependencies = [ "autocfg", ] -[[package]] -name = "mime" -version = "0.3.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" - [[package]] name = "miniz_oxide" version = "0.8.9" @@ -2974,17 +2403,6 @@ dependencies = [ "simd-adler32", ] -[[package]] -name = "mio" -version = "1.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" -dependencies = [ - "libc", - "wasi", - "windows-sys 0.61.2", -] - [[package]] name = "moxcms" version = "0.8.1" @@ -3443,12 +2861,6 @@ dependencies = [ "libc", ] -[[package]] -name = "openssl-probe" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" - [[package]] name = "orbclient" version = "0.3.55" @@ -3538,25 +2950,6 @@ dependencies = [ "ryu", ] -[[package]] -name = "pem" -version = "3.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" -dependencies = [ - "base64 0.22.1", - "serde_core", -] - -[[package]] -name = "pem-rfc7468" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" -dependencies = [ - "base64ct", -] - [[package]] name = "peniko" version = "0.6.1" @@ -3742,24 +3135,12 @@ dependencies = [ "portable-atomic", ] -[[package]] -name = "postcard" -version = "1.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" -dependencies = [ - "cobs", - "serde", -] - [[package]] name = "potential_utf" version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" dependencies = [ - "serde_core", - "writeable", "zerovec", ] @@ -3787,30 +3168,6 @@ dependencies = [ "toml_edit", ] -[[package]] -name = "proc-macro-error" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" -dependencies = [ - "proc-macro-error-attr", - "proc-macro2", - "quote", - "syn 1.0.109", - "version_check", -] - -[[package]] -name = "proc-macro-error-attr" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" -dependencies = [ - "proc-macro2", - "quote", - "version_check", -] - [[package]] name = "proc-macro2" version = "1.0.107" @@ -3836,7 +3193,7 @@ dependencies = [ "bit-vec 0.8.0", "bitflags 2.13.2", "num-traits", - "rand 0.9.5", + "rand", "rand_chacha", "rand_xorshift", "regex-syntax", @@ -3872,63 +3229,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "quinn" -version = "0.11.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11" -dependencies = [ - "bytes", - "cfg_aliases", - "pin-project-lite", - "quinn-proto", - "quinn-udp", - "rustc-hash 2.1.3", - "rustls", - "socket2", - "thiserror 2.0.21", - "tokio", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-proto" -version = "0.11.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe" -dependencies = [ - "aws-lc-rs", - "bytes", - "getrandom 0.4.3", - "lru-slab", - "rand 0.10.3", - "rand_pcg", - "ring", - "rustc-hash 2.1.3", - "rustls", - "rustls-pki-types", - "slab", - "thiserror 2.0.21", - "tinyvec", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-udp" -version = "0.5.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af66907df18639dcf4db56ca65490cabc4b27a97dbadd96f2926cca73298f016" -dependencies = [ - "cfg_aliases", - "libc", - "once_cell", - "socket2", - "tracing", - "windows-sys 0.52.0", -] - [[package]] name = "quote" version = "1.0.47" @@ -3957,18 +3257,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" dependencies = [ "rand_chacha", - "rand_core 0.9.5", -] - -[[package]] -name = "rand" -version = "0.10.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" -dependencies = [ - "chacha20", - "getrandom 0.4.3", - "rand_core 0.10.1", + "rand_core", ] [[package]] @@ -3978,15 +3267,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", - "rand_core 0.9.5", + "rand_core", ] -[[package]] -name = "rand_core" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" - [[package]] name = "rand_core" version = "0.9.5" @@ -3996,28 +3279,13 @@ dependencies = [ "getrandom 0.3.4", ] -[[package]] -name = "rand_core" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" - -[[package]] -name = "rand_pcg" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" -dependencies = [ - "rand_core 0.10.1", -] - [[package]] name = "rand_xorshift" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" dependencies = [ - "rand_core 0.9.5", + "rand_core", ] [[package]] @@ -4169,49 +3437,6 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" -[[package]] -name = "reqwest" -version = "0.13.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029" -dependencies = [ - "base64 0.23.1", - "bytes", - "encoding_rs", - "futures-channel", - "futures-core", - "futures-util", - "h2", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-rustls", - "hyper-util", - "js-sys", - "log", - "mime", - "percent-encoding", - "pin-project-lite", - "quinn", - "rustls", - "rustls-pki-types", - "rustls-platform-verifier", - "serde", - "serde_json", - "serde_urlencoded", - "sync_wrapper", - "tokio", - "tokio-rustls", - "tower", - "tower-http", - "tower-service", - "url", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", -] - [[package]] name = "rfd" version = "0.17.2" @@ -4239,26 +3464,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "ring" -version = "0.17.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" -dependencies = [ - "cc", - "cfg-if", - "getrandom 0.2.17", - "libc", - "untrusted 0.9.0", - "windows-sys 0.52.0", -] - -[[package]] -name = "roxmltree" -version = "0.20.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" - [[package]] name = "rustc-hash" version = "1.1.0" @@ -4306,81 +3511,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "rustls" -version = "0.23.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" -dependencies = [ - "aws-lc-rs", - "once_cell", - "rustls-pki-types", - "rustls-webpki", - "subtle", - "zeroize", -] - -[[package]] -name = "rustls-native-certs" -version = "0.8.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a073f5dc7469f984c52ad2752b63b0807745133b6de880b7b64c1ac4c48aec4" -dependencies = [ - "openssl-probe", - "rustls-pki-types", - "schannel", - "security-framework", -] - -[[package]] -name = "rustls-pki-types" -version = "1.15.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" -dependencies = [ - "web-time", - "zeroize", -] - -[[package]] -name = "rustls-platform-verifier" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98" -dependencies = [ - "core-foundation 0.10.1", - "core-foundation-sys", - "jni", - "log", - "once_cell", - "rustls", - "rustls-native-certs", - "rustls-platform-verifier-android", - "rustls-webpki", - "security-framework", - "security-framework-sys", - "webpki-root-certs", - "windows-sys 0.52.0", -] - -[[package]] -name = "rustls-platform-verifier-android" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f" - -[[package]] -name = "rustls-webpki" -version = "0.103.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" -dependencies = [ - "aws-lc-rs", - "ring", - "rustls-pki-types", - "untrusted 0.9.0", -] - [[package]] name = "rustversion" version = "1.0.23" @@ -4405,12 +3535,6 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" -[[package]] -name = "ryu-js" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04d056b875a9d2e6cb9a61d127afee9ac5999b9f87bcb32079d1318e505be714" - [[package]] name = "same-file" version = "1.0.6" @@ -4420,15 +3544,6 @@ dependencies = [ "winapi-util", ] -[[package]] -name = "schannel" -version = "0.1.30" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cb23c424d8ee841bb4d813677ca237e2a610067d594fec130f87d6093495ab5" -dependencies = [ - "windows-sys 0.61.2", -] - [[package]] name = "scoped-tls" version = "1.0.1" @@ -4441,29 +3556,6 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" -[[package]] -name = "security-framework" -version = "3.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" -dependencies = [ - "bitflags 2.13.2", - "core-foundation 0.10.1", - "core-foundation-sys", - "libc", - "security-framework-sys", -] - -[[package]] -name = "security-framework-sys" -version = "2.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" -dependencies = [ - "core-foundation-sys", - "libc", -] - [[package]] name = "self_cell" version = "1.3.0" @@ -4519,17 +3611,6 @@ dependencies = [ "zmij", ] -[[package]] -name = "serde_json_canonicalizer" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe52319a927259afbfa5180c5157cd8167edfd3e8c254f9558c7fef44c5649f2" -dependencies = [ - "ryu-js", - "serde", - "serde_json", -] - [[package]] name = "serde_repr" version = "0.1.21" @@ -4550,29 +3631,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "serde_urlencoded" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" -dependencies = [ - "form_urlencoded", - "itoa", - "ryu", - "serde", -] - -[[package]] -name = "sha1" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" -dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", - "digest 0.10.7", -] - [[package]] name = "sha1" version = "0.11.0" @@ -4580,19 +3638,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" dependencies = [ "cfg-if", - "cpufeatures 0.3.1", - "digest 0.11.3", -] - -[[package]] -name = "sha2" -version = "0.10.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" -dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", - "digest 0.10.7", + "cpufeatures", + "digest", ] [[package]] @@ -4602,8 +3649,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" dependencies = [ "cfg-if", - "cpufeatures 0.3.1", - "digest 0.11.3", + "cpufeatures", + "digest", ] [[package]] @@ -4622,175 +3669,6 @@ dependencies = [ "libc", ] -[[package]] -name = "signature" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" -dependencies = [ - "rand_core 0.6.4", -] - -[[package]] -name = "sigstore-bundle" -version = "0.6.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e7dc53c8a941858d01479622dbb7650aaa2ee0ca1fb58fb6cdddbfc3064abbb" -dependencies = [ - "base64 0.22.1", - "hex", - "serde", - "serde_json", - "sigstore-crypto", - "sigstore-merkle", - "sigstore-rekor", - "sigstore-tsa", - "sigstore-types", - "thiserror 2.0.21", -] - -[[package]] -name = "sigstore-crypto" -version = "0.6.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da90c3d9af638898a5fdc347479b18f950bb0dde11ecf2244f94cd28135da53b" -dependencies = [ - "aws-lc-rs", - "base64 0.22.1", - "const-oid 0.9.6", - "der", - "digest 0.10.7", - "pem", - "rand_core 0.10.1", - "sha2 0.10.9", - "signature", - "sigstore-types", - "spki", - "thiserror 2.0.21", - "tracing", - "x509-cert", -] - -[[package]] -name = "sigstore-merkle" -version = "0.6.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c0d53558825c77716855c13794306fff766854c1bf92948e77d7890da3f2455" -dependencies = [ - "base64 0.22.1", - "hex", - "sigstore-crypto", - "sigstore-types", - "thiserror 2.0.21", -] - -[[package]] -name = "sigstore-rekor" -version = "0.6.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "641fdaaa40d0cd6e249cf1671c2240beff1a3ece578062cf43e750779e8db2b3" -dependencies = [ - "base64 0.22.1", - "hex", - "reqwest", - "serde", - "serde_json", - "sigstore-crypto", - "sigstore-merkle", - "sigstore-types", - "thiserror 2.0.21", - "url", -] - -[[package]] -name = "sigstore-trust-root" -version = "0.6.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6b0d2550f05c096400066e858db13d91f9de3b750a7541fb1291c1dc80ca290" -dependencies = [ - "base64 0.22.1", - "chrono", - "hex", - "rustls-pki-types", - "serde", - "serde_json", - "sigstore-crypto", - "sigstore-types", - "thiserror 2.0.21", - "x509-cert", -] - -[[package]] -name = "sigstore-tsa" -version = "0.6.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bc86c8abc1ece6832236e7e34e9baebb5d4d40490c0332b814a0d8624ca7255" -dependencies = [ - "aws-lc-rs", - "base64 0.22.1", - "chrono", - "cmpv2", - "cms", - "const-oid 0.9.6", - "der", - "hex", - "rand 0.10.3", - "reqwest", - "rustls-pki-types", - "rustls-webpki", - "sigstore-crypto", - "sigstore-types", - "thiserror 2.0.21", - "tracing", - "x509-cert", - "x509-tsp", -] - -[[package]] -name = "sigstore-types" -version = "0.6.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8d870c9bcfdf83396ac2bd2d6a23c5d09ec02cc9fda50fa1cbb3dd71a9bee53" -dependencies = [ - "base64 0.22.1", - "chrono", - "hex", - "pem", - "serde", - "serde_json", - "thiserror 2.0.21", -] - -[[package]] -name = "sigstore-verify" -version = "0.6.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d30aff69036e4579b416956e9ba68cec45b793116f78f10842d04c7f8b924bab" -dependencies = [ - "base64 0.22.1", - "chrono", - "cms", - "const-oid 0.9.6", - "hex", - "pem", - "rustls-pki-types", - "rustls-webpki", - "serde", - "serde_json", - "serde_json_canonicalizer", - "sigstore-bundle", - "sigstore-crypto", - "sigstore-merkle", - "sigstore-rekor", - "sigstore-trust-root", - "sigstore-tsa", - "sigstore-types", - "thiserror 2.0.21", - "tls_codec", - "tracing", - "x509-cert", -] - [[package]] name = "simd-adler32" version = "0.3.10" @@ -4945,16 +3823,6 @@ dependencies = [ "serde", ] -[[package]] -name = "socket2" -version = "0.6.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" -dependencies = [ - "libc", - "windows-sys 0.60.2", -] - [[package]] name = "spirv" version = "0.4.0+sdk-1.4.341.0" @@ -4964,16 +3832,6 @@ dependencies = [ "bitflags 2.13.2", ] -[[package]] -name = "spki" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" -dependencies = [ - "base64ct", - "der", -] - [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -4992,27 +3850,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" -[[package]] -name = "strum" -version = "0.28.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" -dependencies = [ - "strum_macros", -] - -[[package]] -name = "strum_macros" -version = "0.28.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "subsetter" version = "0.2.6" @@ -5025,22 +3862,6 @@ dependencies = [ "write-fonts", ] -[[package]] -name = "subtle" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" - -[[package]] -name = "syn" -version = "1.0.109" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" -dependencies = [ - "proc-macro2", - "unicode-ident", -] - [[package]] name = "syn" version = "2.0.119" @@ -5063,15 +3884,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "sync_wrapper" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" -dependencies = [ - "futures-core", -] - [[package]] name = "synstructure" version = "0.14.0" @@ -5095,38 +3907,6 @@ dependencies = [ "web-sys", ] -[[package]] -name = "system-configuration" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" -dependencies = [ - "bitflags 2.13.2", - "core-foundation 0.9.4", - "system-configuration-sys", -] - -[[package]] -name = "system-configuration-sys" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "tar" -version = "0.4.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" -dependencies = [ - "filetime", - "libc", - "xattr", -] - [[package]] name = "tempfile" version = "3.27.0" @@ -5240,63 +4020,13 @@ version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" -[[package]] -name = "tls_codec" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de2e01245e2bb89d6f05801c564fa27624dbd7b1846859876c7dad82e90bf6b" -dependencies = [ - "tls_codec_derive", - "zeroize", -] - -[[package]] -name = "tls_codec_derive" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d2e76690929402faae40aebdda620a2c0e25dd6d3b9afe48867dfd95991f4bd" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "tokio" version = "1.53.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044" dependencies = [ - "bytes", - "libc", - "mio", "pin-project-lite", - "socket2", - "windows-sys 0.61.2", -] - -[[package]] -name = "tokio-rustls" -version = "0.26.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" -dependencies = [ - "rustls", - "tokio", -] - -[[package]] -name = "tokio-util" -version = "0.7.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e464cf451ba96ebfc6f9b6542f17ee8b8956e33f1e40d9690624e59d7a7f8a4b" -dependencies = [ - "bytes", - "futures-core", - "futures-sink", - "libc", - "pin-project-lite", - "tokio", ] [[package]] @@ -5350,51 +4080,6 @@ version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" -[[package]] -name = "tower" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" -dependencies = [ - "futures-core", - "futures-util", - "pin-project-lite", - "sync_wrapper", - "tokio", - "tower-layer", - "tower-service", -] - -[[package]] -name = "tower-http" -version = "0.6.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" -dependencies = [ - "bitflags 2.13.2", - "bytes", - "futures-util", - "http", - "http-body", - "pin-project-lite", - "tower", - "tower-layer", - "tower-service", - "url", -] - -[[package]] -name = "tower-layer" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" - -[[package]] -name = "tower-service" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" - [[package]] name = "tracing" version = "0.1.44" @@ -5438,12 +4123,6 @@ dependencies = [ "petgraph", ] -[[package]] -name = "try-lock" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" - [[package]] name = "type-map" version = "0.5.1" @@ -5476,6 +4155,24 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" +[[package]] +name = "unic-langid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ba52c9b05311f4f6e62d5d9d46f094bd6e84cb8df7b3ef952748d752a7d05" +dependencies = [ + "unic-langid-impl", +] + +[[package]] +name = "unic-langid-impl" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce1bf08044d4b7a94028c93786f8566047edc11110595914de93362559bc658" +dependencies = [ + "tinystr", +] + [[package]] name = "unicode-bidi" version = "0.3.18" @@ -5527,18 +4224,6 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" -[[package]] -name = "untrusted" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" - -[[package]] -name = "untrusted" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" - [[package]] name = "url" version = "2.5.8" @@ -5551,12 +4236,6 @@ dependencies = [ "serde", ] -[[package]] -name = "utf16_iter" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246" - [[package]] name = "utf8_iter" version = "1.0.4" @@ -5659,21 +4338,6 @@ dependencies = [ "winapi-util", ] -[[package]] -name = "want" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b" -dependencies = [ - "try-lock", -] - -[[package]] -name = "wasi" -version = "0.11.1+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" - [[package]] name = "wasip2" version = "1.0.4+wasi-0.2.12" @@ -5910,15 +4574,6 @@ dependencies = [ "web-sys", ] -[[package]] -name = "webpki-root-certs" -version = "1.0.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e49d0e65abc00c142e8f9428da991da9f1a250c39be97459901eb12b9b49f62a" -dependencies = [ - "rustls-pki-types", -] - [[package]] name = "weezl" version = "0.1.12" @@ -6200,17 +4855,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows-registry" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" -dependencies = [ - "windows-link", - "windows-result", - "windows-strings", -] - [[package]] name = "windows-result" version = "0.4.1" @@ -6418,7 +5062,7 @@ dependencies = [ "calloop 0.13.0", "cfg_aliases", "concurrent-queue", - "core-foundation 0.9.4", + "core-foundation", "core-graphics", "cursor-icon", "dpi", @@ -6463,27 +5107,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "winprint" -version = "0.2.1" -source = "git+https://github.com/storytold/winprint.rs?rev=5f80b9baf6b58937c401656634e42d33f00ddf49#5f80b9baf6b58937c401656634e42d33f00ddf49" -dependencies = [ - "flate2", - "fmt-derive", - "reqwest", - "scopeguard", - "sigstore-trust-root", - "sigstore-types", - "sigstore-verify", - "strum", - "tar", - "thiserror 2.0.21", - "windows", - "windows-core", - "windows-implement", - "xml", -] - [[package]] name = "winresource" version = "0.1.31" @@ -6494,12 +5117,6 @@ dependencies = [ "version_check", ] -[[package]] -name = "winsafe" -version = "0.0.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ef0ffc427f045c0cc9ebffd6f4f91153dcd2a1547b5c3c29ee3f541e16e95c6" - [[package]] name = "wit-bindgen" version = "0.57.1" @@ -6579,7 +5196,7 @@ version = "0.10.0" name = "wordcraft-doc" version = "0.10.0" dependencies = [ - "base64 0.22.1", + "base64", "proptest", "serde", "serde_json", @@ -6599,7 +5216,7 @@ dependencies = [ "miniz_oxide 0.8.9", "proptest", "serde_json", - "sha1 0.11.0", + "sha1", "thiserror 2.0.21", "wordcraft-doc", "wordcraft-geom", @@ -6610,7 +5227,7 @@ name = "wordcraft-docx" version = "0.10.0" dependencies = [ "aes", - "base64 0.22.1", + "base64", "cfb", "chrono", "getrandom 0.3.4", @@ -6619,8 +5236,8 @@ dependencies = [ "log", "proptest", "quick-xml", - "sha1 0.11.0", - "sha2 0.11.0", + "sha1", + "sha2", "thiserror 2.0.21", "unicode-bidi", "wordcraft-doc", @@ -6856,20 +5473,11 @@ dependencies = [ "read-fonts 0.39.2", ] -[[package]] -name = "write16" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936" - [[package]] name = "writeable" version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" -dependencies = [ - "either", -] [[package]] name = "wuff" @@ -6914,41 +5522,6 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" -[[package]] -name = "x509-cert" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1301e935010a701ae5f8655edc0ad17c44bad3ac5ce8c39185f75453b720ae94" -dependencies = [ - "const-oid 0.9.6", - "der", - "sha1 0.10.7", - "signature", - "spki", - "tls_codec", -] - -[[package]] -name = "x509-tsp" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5ceece934a21607055b7ac5c25adb56a2ff559804b10705dc674d1d838c15e1" -dependencies = [ - "cmpv2", - "cms", - "der", -] - -[[package]] -name = "xattr" -version = "1.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" -dependencies = [ - "libc", - "rustix 1.1.5", -] - [[package]] name = "xcursor" version = "0.3.11" @@ -6974,12 +5547,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" -[[package]] -name = "xml" -version = "1.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f45bb2c13fec6a6cb4c0f76a7e94839e110a14ec803ec2940777a94c347bc52" - [[package]] name = "xml-rs" version = "0.8.29" @@ -7174,20 +5741,6 @@ name = "zeroize" version = "1.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" -dependencies = [ - "zeroize_derive", -] - -[[package]] -name = "zeroize_derive" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] [[package]] name = "zerotrie" @@ -7196,11 +5749,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" dependencies = [ "displaydoc", - "litemap", - "serde_core", "yoke", "zerofrom", - "zerovec", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 713dcaafaa..8861fcd088 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -54,7 +54,7 @@ harfrust = "0.13" wuff = "0.2.9" vello_cpu = "0.2" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "bmp", "tiff"] } -craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3", version = "=0.1.0", default-features = false, features = ["write"] } +craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef", version = "=0.1.0", default-features = false, features = ["write"] } quick-xml = "0.41" zip = { version = "3", default-features = false, features = ["deflate"] } # zlib streams (compressed metafile pictures in `.doc` drawings); already in the lock as a dependency of other crates. @@ -110,4 +110,4 @@ debug = 0 # Required by craft-pdf: the backend rejects writers that failed during unwinding. [patch.crates-io] -krilla = { git = "https://github.com/storytold/craft-libs", rev = "16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3" } +krilla = { git = "https://github.com/storytold/craft-libs", rev = "9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef" } diff --git a/apps/wordcraft/Cargo.toml b/apps/wordcraft/Cargo.toml index f51439e27a..dbad7e93f3 100644 --- a/apps/wordcraft/Cargo.toml +++ b/apps/wordcraft/Cargo.toml @@ -73,7 +73,7 @@ windows = { version = "0.62", features = ["UI_ViewManagement", "System_UserProfi clipboard-win = { version = "5.4", features = ["std"] } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] -craft-print = { git = "https://github.com/storytold/craft-libs", rev = "16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3" } +craft-print = { git = "https://github.com/storytold/craft-libs", rev = "d27646a5f7c8b4fc85d9dd3213b8c88b58e0088d" } rfd = { workspace = true } # Waits for an `rfd::AsyncFileDialog` on a background thread (the dialogs don't block the UI, #94); # rfd itself already depends on it. diff --git a/apps/wordcraft/src/print.rs b/apps/wordcraft/src/print.rs index b92e68b3e0..22d0c64dc4 100644 --- a/apps/wordcraft/src/print.rs +++ b/apps/wordcraft/src/print.rs @@ -7,7 +7,7 @@ pub fn discover() -> Discover { let _ = std::thread::Builder::new().name("print-discovery".into()).spawn(move || { let printers = craft_print::printers() .into_iter() - .map(|printer| Printer { name: printer.name, label: printer.description, default: printer.default }) + .map(|printer| Printer { name: printer.name, label: String::new(), default: printer.default }) .collect(); let _ = sender.send(printers); }); diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index d5e8885720..ede5665a62 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -12,8 +12,8 @@ workspace = true craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3" } # Interface translations: lookup, fallback, plurals and system-locale matching over the craft-i18n # catalogs that `build.rs` embeds from `CRAFT_I18N_DIR` (storytold/craft-i18n, pinned in -# `craft-i18n.rev`). -craft-i18n = { git = "https://github.com/storytold/craft-libs", rev = "16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3" } +# `craft-i18n.rev`). Its own rev: lazy per-locale parsing (storytold/craft-libs#247). +craft-i18n = { git = "https://github.com/storytold/craft-libs", rev = "f7cc8a7df15e824b02cf73859567a6242d3a7de4" } wordcraft-geom = { workspace = true } wordcraft-doc = { workspace = true } unicode-bidi = { workspace = true } From f06a1d3ba528e364dbf510fc9ad2cb00afc757c1 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 20:17:53 +0000 Subject: [PATCH 040/199] Task panes: rows and fields fit a 200 pt pane (#1756) --- crates/ui-egui/src/dialogs_review.rs | 26 +++++----- crates/ui-egui/src/panes.rs | 71 ++++++++++++++++++++++++++-- 2 files changed, 80 insertions(+), 17 deletions(-) diff --git a/crates/ui-egui/src/dialogs_review.rs b/crates/ui-egui/src/dialogs_review.rs index 69861a3415..460c46f5ad 100644 --- a/crates/ui-egui/src/dialogs_review.rs +++ b/crates/ui-egui/src/dialogs_review.rs @@ -609,7 +609,7 @@ fn setup_ui(app: &mut WordApp, ui: &mut Ui, st: &mut RestrictPane) { if st.formatting && st.show_styles { let allowed = st.allowed.get_or_insert_with(Vec::new); ui.label(egui::RichText::new(tl!("Checked styles are allowed:")).small()); - ui.horizontal(|ui| { + ui.horizontal_wrapped(|ui| { if ui.small_button(tl!("All")).clicked() { *allowed = styles.iter().map(|(id, _)| id.clone()).collect(); } @@ -634,7 +634,7 @@ fn setup_ui(app: &mut WordApp, ui: &mut Ui, st: &mut RestrictPane) { ui.checkbox(&mut st.editing, tl!("Only allow this kind of editing:")); ui.add_enabled_ui(st.editing, |ui| { let current = EDIT_MODES.get(st.mode).map(|m| m.1).unwrap_or(""); - egui::ComboBox::from_id_salt("restrict_mode").selected_text(tl!(current)).width(220.0).show_ui(ui, |ui| { + egui::ComboBox::from_id_salt("restrict_mode").selected_text(tl!(current)).width(ui.available_width().min(220.0)).show_ui(ui, |ui| { for (i, (_, label)) in EDIT_MODES.iter().enumerate() { ui.selectable_value(&mut st.mode, i, tl!(label)); } @@ -665,7 +665,7 @@ fn setup_ui(app: &mut WordApp, ui: &mut Ui, st: &mut RestrictPane) { } else { ui.label(egui::RichText::new(tl!("The password is optional; without it anyone can stop the protection.")).small()); passwords(ui, st, true); - ui.horizontal(|ui| { + ui.horizontal_wrapped(|ui| { // macOS puts Cancel before OK. let mac = cfg!(target_os = "macos"); let mut cancel = mac && ui.button(tl!("Cancel")).clicked(); @@ -703,17 +703,15 @@ fn setup_ui(app: &mut WordApp, ui: &mut Ui, st: &mut RestrictPane) { error(ui, &st.message); } +/// Each label above its field, so both fit the narrowest pane. fn passwords(ui: &mut Ui, st: &mut RestrictPane, confirm: bool) { - egui::Grid::new("restrict_pw").num_columns(2).spacing(vec2(8.0, 4.0)).show(ui, |ui| { - ui.label(tl!("Password:")); - ui.add(egui::TextEdit::singleline(st.password.as_mut_string()).password(true).desired_width(140.0)); - ui.end_row(); - if confirm { - ui.label(tl!("Confirm password:")); - ui.add(egui::TextEdit::singleline(st.confirm.as_mut_string()).password(true).desired_width(140.0)); - ui.end_row(); - } - }); + let width = ui.available_width().min(220.0); + ui.label(tl!("Password:")); + ui.add(egui::TextEdit::singleline(st.password.as_mut_string()).password(true).desired_width(width)); + if confirm { + ui.label(tl!("Confirm password:")); + ui.add(egui::TextEdit::singleline(st.confirm.as_mut_string()).password(true).desired_width(width)); + } } fn enforced_ui(app: &mut WordApp, ui: &mut Ui, st: &mut RestrictPane, mode: &str) { @@ -753,7 +751,7 @@ fn enforced_ui(app: &mut WordApp, ui: &mut Ui, st: &mut RestrictPane, mode: &str } else { ui.label(egui::RichText::new(tl!("Type the password to stop the protection.")).small()); passwords(ui, st, false); - ui.horizontal(|ui| { + ui.horizontal_wrapped(|ui| { // macOS puts Cancel before OK. let mac = cfg!(target_os = "macos"); let cancel_first = mac && ui.button(tl!("Cancel")).clicked(); diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index 5533753772..979bcb1fc3 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -272,7 +272,7 @@ fn nav(app: &mut WordApp, ui: &mut Ui) { } } ui.add_space(4.0); - ui.horizontal(|ui| { + ui.horizontal_wrapped(|ui| { for (k, l) in [("headings", "Headings"), ("pages", "Pages"), ("results", "Results")] { if ui.selectable_label(app.ui.nav_tab == k, tl!(l)).clicked() { app.ui.nav_tab = k.into(); @@ -435,7 +435,7 @@ fn nav_heading(ui: &mut Ui, level: u8, text: &str, selected: bool, collapsed: Op /// Items collected by Copy and Cut, newest first; clicking one pastes it at the caret. fn clipboard(app: &mut WordApp, ui: &mut Ui) { let items: Vec = app.session.clip_history.items().iter().map(|i| i.preview()).collect(); - ui.horizontal(|ui| { + ui.horizontal_wrapped(|ui| { if ui.add_enabled(!items.is_empty(), egui::Button::new(tl!("Paste All"))).clicked() { let _ = app.run("edit.pasteAllClipboard", json!({})); app.canvas.want_focus = true; @@ -1017,7 +1017,9 @@ fn camel_words(s: &str) -> String { #[cfg(test)] mod tests { - use super::comment_stamp; + use super::{Content, Pane, comment_stamp}; + use craft_ui::panes::PaneContent; + use egui::vec2; #[test] fn comment_stamp_keeps_date_and_time() { @@ -1172,6 +1174,69 @@ mod tests { } } + /// Every pane's body fits a pane at the 200 pt minimum width (the host clips what doesn't): + /// button rows wrap and fields narrow. Restrict Editing shows its style list and password + /// fields. + #[test] + #[cfg(not(target_arch = "wasm32"))] + fn pane_bodies_fit_the_narrowest_pane() { + // 200 pt less the 10 pt body margins and the frame's 1 pt strokes. + const BODY: f32 = 178.0; + let mut app = crate::WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::from_text("Hello")), crate::Services::default()); + app.run("select.all", serde_json::json!({})).unwrap(); + app.run("edit.copy", serde_json::json!({})).unwrap(); + app.restrict = crate::dialogs_review::RestrictPane { + open: true, + formatting: true, + show_styles: true, + editing: true, + asking: true, + ..Default::default() + }; + let panes = [ + Pane::Navigation, + Pane::Clipboard, + Pane::Styles, + Pane::StyleInspector, + Pane::Accessibility, + Pane::AltText, + Pane::FormatShape, + Pane::RestrictEditing, + Pane::Comments, + ]; + let widths = std::rc::Rc::new(std::cell::RefCell::new(Vec::new())); + let seen = widths.clone(); + let mut fonts = false; + let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(400.0, 1200.0)).build_ui_state( + move |ui, app: &mut crate::WordApp| { + // Fonts take effect the frame after they are installed. + if !fonts { + crate::theme::install_fonts(ui.ctx()); + fonts = true; + return; + } + let mut row = Vec::new(); + for pane in panes { + let width = ui + .allocate_ui_with_layout(vec2(BODY, 1200.0), egui::Layout::top_down(egui::Align::Min), |ui| { + ui.set_max_width(BODY); + Content { app: &mut *app }.body(ui, &pane); + ui.min_rect().width() + }) + .inner; + row.push((pane, width)); + } + *seen.borrow_mut() = row; + }, + app, + ); + h.run_steps(3); + let widths = widths.borrow(); + assert_eq!(widths.len(), panes.len()); + let wide: Vec<_> = widths.iter().filter(|(_, width)| *width > BODY + 0.5).collect(); + assert!(wide.is_empty(), "wider than the pane: {wide:?}"); + } + /// #3032: Delete is greyed out for built-in styles in the Styles pane (the engine refuses /// them — `crates/engine/src/cmd/para.rs`), while custom styles keep it enabled. #[test] From 401d811fa0789b66391473e65749d296a443898e Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 20:20:20 +0000 Subject: [PATCH 041/199] Task panes: every pane keeps its dock or float place between runs (#1756) --- crates/ui-egui/src/lib.rs | 9 ++- crates/ui-egui/src/panes.rs | 157 +++++++++++++++++++++++++++++++++++- 2 files changed, 158 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 50ed89a7c9..0555e9f78a 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -286,6 +286,9 @@ pub struct UiState { /// The docked Styles pane's width this run; like the other panes' widths, not kept between runs. #[serde(skip)] pub styles_pane_width: Option, + /// Where each of the other task panes sits, by [`panes::Pane::key`] (#1756). + #[serde(deserialize_with = "panes::lenient_places")] + pub pane_places: std::collections::BTreeMap, /// Headings whose sub-headings the Navigation pane hides (#1997); per session, not saved. #[serde(skip)] pub nav_collapsed: std::collections::HashSet, @@ -406,6 +409,7 @@ impl Default for UiState { styles_pane_dock: wordcraft_engine::StylesPaneDock::Right, styles_pane_float: None, styles_pane_width: None, + pane_places: Default::default(), nav_collapsed: Default::default(), show_discord: true, author: String::new(), @@ -506,10 +510,7 @@ fn lenient_styles_pane_dock<'de, D: serde::Deserializer<'de>>(d: D) -> Result>(d: D) -> Result, D::Error> { - let v = Value::deserialize(d)?; - let Some([x, y, w, h]) = v.as_array().map(Vec::as_slice) else { return Ok(None) }; - let n = |v: &Value| v.as_f64().map(|n| n as f32).filter(|n| n.is_finite()); - Ok(n(x).zip(n(y)).zip(n(w)).zip(n(h)).map(|(((x, y), w), h)| [x, y, w, h])) + Ok(panes::float_rect(&Value::deserialize(d)?)) } /// `recentFonts` as saved, through [`recent_fonts_from`]: an entry that isn't a name is dropped, diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index 979bcb1fc3..5557560a0f 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -65,6 +65,35 @@ impl Pane { PaneState::docked(edge, width) } + /// The pane's key in `ui.json`'s `panePlaces`. + pub fn key(self) -> &'static str { + match self { + Self::SmartArtText => "smartArtText", + Self::Navigation => "navigation", + Self::Clipboard => "clipboard", + Self::Styles => "styles", + Self::StyleInspector => "styleInspector", + Self::Accessibility => "accessibility", + Self::AltText => "altText", + Self::FormatShape => "formatShape", + Self::RestrictEditing => "restrictEditing", + Self::Comments => "comments", + } + } + + const ALL: [Self; 10] = [ + Self::SmartArtText, + Self::Navigation, + Self::Clipboard, + Self::Styles, + Self::StyleInspector, + Self::Accessibility, + Self::AltText, + Self::FormatShape, + Self::RestrictEditing, + Self::Comments, + ]; + /// The command that closes the pane; Restrict Editing has none and closes in [`close`]. fn close_command(self) -> Option<&'static str> { match self { @@ -103,8 +132,77 @@ pub fn open(app: &WordApp) -> Vec { .collect() } +/// Where a task pane other than Styles sits, kept in `ui.json` (#1756). Styles keeps its place in +/// `view.stylesPaneDock` and `stylesPaneFloat`. +#[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct SavedPane { + /// `None`: the pane's default edge. + #[serde(skip_serializing_if = "Option::is_none")] + pub dock: Option, + /// `[x, y, width, height]` of the floating pane, also kept while it is docked. + #[serde(skip_serializing_if = "Option::is_none")] + pub float: Option<[f32; 4]>, + /// The docked width this run; like the Styles pane's, not kept between runs. + #[serde(skip)] + pub width: Option, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum SavedDock { + Left, + Right, + Float, +} + +/// `panePlaces` as saved: unknown panes, places and rects are dropped, not the whole file. +pub(crate) fn lenient_places<'de, D: serde::Deserializer<'de>>(d: D) -> Result, D::Error> { + let v = ::deserialize(d)?; + let Some(map) = v.as_object() else { return Ok(BTreeMap::new()) }; + let dock = |v: &Value| match v.as_str() { + Some("left") => Some(SavedDock::Left), + Some("right") => Some(SavedDock::Right), + Some("float") => Some(SavedDock::Float), + _ => None, + }; + Ok(Pane::ALL + .iter() + .filter(|pane| **pane != Pane::Styles) + .filter_map(|pane| { + let saved = map.get(pane.key())?; + let place = SavedPane { dock: saved.get("dock").and_then(dock), float: saved.get("float").and_then(float_rect), width: None }; + Some((pane.key().to_string(), place)) + }) + .collect()) +} + +/// Four finite numbers as `[x, y, width, height]`; `craft_ui::panes` brings a saved rect back +/// inside the window. +pub(crate) fn float_rect(v: &Value) -> Option<[f32; 4]> { + let [x, y, w, h] = v.as_array().map(Vec::as_slice)? else { return None }; + let n = |v: &Value| v.as_f64().map(|n| n as f32).filter(|n| n.is_finite()); + n(x).zip(n(y)).zip(n(w)).zip(n(h)).map(|(((x, y), w), h)| [x, y, w, h]) +} + +/// A pane's place and size: Styles' from `view.stylesPaneDock` and its own preferences, the +/// others' from `panePlaces`. +fn pane_state(app: &WordApp, pane: Pane) -> PaneState { + if pane == Pane::Styles { + return styles_state(app); + } + let default = pane.default_state(); + let Some(saved) = app.ui.pane_places.get(pane.key()) else { return default }; + let place = match saved.dock { + Some(SavedDock::Left) => Place::Docked(Edge::Start), + Some(SavedDock::Right) => Place::Docked(Edge::End), + Some(SavedDock::Float) => Place::Floating, + None => default.place, + }; + PaneState { place, size: saved.width.or(default.size), float_rect: saved.float } +} + /// The Styles pane's place and size, from `view.stylesPaneDock` and the UI preferences (#1756). -/// Other panes keep theirs in the host for the session. fn styles_state(app: &WordApp) -> PaneState { let place = match app.session.view.styles_pane_dock { StylesPaneDock::Right => Place::Docked(Edge::End), @@ -200,14 +298,14 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) { crate::format_shape::forget_drag(ui.ctx()); } route_f6(app, ui.ctx()); - let styles = styles_state(app); + let states: Vec<(Pane, PaneState)> = open.iter().map(|pane| (*pane, pane_state(app, *pane))).collect(); let out = PaneHost::new(HOST) .policy(SidePolicy::Columns) .f6(false) .labels(labels()) .frame(egui::Frame::NONE.fill(t.panel).stroke(Stroke::new(1.0, t.border))) .body_margin(egui::Margin::same(10)) - .show(ui, &open, |pane| (*pane == Pane::Styles).then_some(styles), &mut Content { app }); + .show(ui, &open, |pane| states.iter().find(|(p, _)| p == pane).map(|(_, state)| *state), &mut Content { app }); for request in out.requests { apply(app, request); } @@ -231,7 +329,18 @@ pub(crate) fn apply(app: &mut WordApp, request: PaneRequest) { } PaneRequest::Resize(Pane::Styles, width) => app.ui.styles_pane_width = Some(width), PaneRequest::FloatRect(Pane::Styles, rect) => app.ui.styles_pane_float = Some(rect), - PaneRequest::Place(..) | PaneRequest::Resize(..) | PaneRequest::FloatRect(..) | PaneRequest::Focus(_) => {} + PaneRequest::Place(pane, place) => { + let dock = match place { + Place::Docked(Edge::Start) => SavedDock::Left, + Place::Docked(Edge::End) => SavedDock::Right, + Place::Floating => SavedDock::Float, + Place::Docked(Edge::Bottom) => return, + }; + app.ui.pane_places.entry(pane.key().into()).or_default().dock = Some(dock); + } + PaneRequest::Resize(pane, width) => app.ui.pane_places.entry(pane.key().into()).or_default().width = Some(width), + PaneRequest::FloatRect(pane, rect) => app.ui.pane_places.entry(pane.key().into()).or_default().float = Some(rect), + PaneRequest::Focus(_) => {} } } @@ -1237,6 +1346,46 @@ mod tests { assert!(wide.is_empty(), "wider than the pane: {wide:?}"); } + /// A pane other than Styles floated from its menu comes back floating, at the same rect, after + /// a restart; junk in `panePlaces` reads as the defaults (#1756). + #[test] + #[cfg(not(target_arch = "wasm32"))] + fn a_floated_pane_floats_again_after_a_restart() { + use craft_ui::panes::Place; + use egui_kittest::kittest::Queryable; + let mut app = crate::WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::from_text("")), crate::Services::default()); + app.run("view.navigationPane", serde_json::json!({"value": true})).unwrap(); + let mut h = pane_harness(app); + let bar = h.get_by_label_contains("Move Pane: ").rect(); + let on_row = |r: egui::Rect| r.center().y > bar.top() && r.center().y < bar.bottom(); + h.get_all_by_label("Pane Options").find(|n| on_row(n.rect())).expect("the menu button beside the title bar").click(); + h.run_steps(2); + h.get_by_label("Float").click(); + h.run_steps(3); + assert_eq!(super::pane_state(h.state(), Pane::Navigation).place, Place::Floating); + let rect = h.state().ui.pane_places.get("navigation").and_then(|p| p.float).expect("the float rect is kept"); + + let saved = serde_json::to_vec(&h.state().prefs()).unwrap(); + let mut next = crate::WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), crate::Services::default()); + next.apply_prefs(serde_json::from_slice(&saved).unwrap()); + let state = super::pane_state(&next, Pane::Navigation); + assert_eq!((state.place, state.float_rect), (Place::Floating, Some(rect))); + assert_eq!(super::pane_state(&next, Pane::Clipboard), Pane::Clipboard.default_state(), "other panes keep their defaults"); + + let mut junk = crate::WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), crate::Services::default()); + junk.apply_prefs( + serde_json::from_str( + r#"{"panePlaces": {"navigation": {"dock": "top", "float": [1, "x", 3, 4]}, "clipboard": 7, "nope": {}}, "navPane": true}"#, + ) + .unwrap(), + ); + assert_eq!(super::pane_state(&junk, Pane::Navigation), Pane::Navigation.default_state(), "junk reads as the default"); + assert!(junk.session.view.nav_pane, "the rest of the file still loads"); + let mut odd = crate::WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), crate::Services::default()); + odd.apply_prefs(serde_json::from_str(r#"{"panePlaces": [1, 2], "navPane": true}"#).unwrap()); + assert!(odd.ui.pane_places.is_empty() && odd.session.view.nav_pane); + } + /// #3032: Delete is greyed out for built-in styles in the Styles pane (the engine refuses /// them — `crates/engine/src/cmd/para.rs`), while custom styles keep it enabled. #[test] From 01e32ff8635c49c398764f9827754b5ffaafa89d Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 22:41:02 +0000 Subject: [PATCH 042/199] Pin craft-libs at 6e7f285 for craft-ui docking, craft-i18n, craft-print, craft-pdf and krilla --- Cargo.lock | 10 +++++----- Cargo.toml | 4 ++-- apps/wordcraft/Cargo.toml | 2 +- crates/ui-egui/Cargo.toml | 6 +++--- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2ead7e7959..9c9022b455 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -991,7 +991,7 @@ dependencies = [ [[package]] name = "craft-i18n" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=17744430be327d0257496a6a31c8d38fe20a6756#17744430be327d0257496a6a31c8d38fe20a6756" +source = "git+https://github.com/storytold/craft-libs?rev=6e7f28518dd1cb57e1624bdc269c3c14316b13ae#6e7f28518dd1cb57e1624bdc269c3c14316b13ae" dependencies = [ "fixed_decimal", "icu_decimal", @@ -1014,7 +1014,7 @@ dependencies = [ [[package]] name = "craft-pdf" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=17744430be327d0257496a6a31c8d38fe20a6756#17744430be327d0257496a6a31c8d38fe20a6756" +source = "git+https://github.com/storytold/craft-libs?rev=6e7f28518dd1cb57e1624bdc269c3c14316b13ae#6e7f28518dd1cb57e1624bdc269c3c14316b13ae" dependencies = [ "krilla", "thiserror 2.0.21", @@ -1023,7 +1023,7 @@ dependencies = [ [[package]] name = "craft-print" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=17744430be327d0257496a6a31c8d38fe20a6756#17744430be327d0257496a6a31c8d38fe20a6756" +source = "git+https://github.com/storytold/craft-libs?rev=6e7f28518dd1cb57e1624bdc269c3c14316b13ae#6e7f28518dd1cb57e1624bdc269c3c14316b13ae" dependencies = [ "craft-winprint-gdi", "miniz_oxide 0.8.9", @@ -1036,7 +1036,7 @@ dependencies = [ [[package]] name = "craft-ui" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3#16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3" +source = "git+https://github.com/storytold/craft-libs?rev=6e7f28518dd1cb57e1624bdc269c3c14316b13ae#6e7f28518dd1cb57e1624bdc269c3c14316b13ae" dependencies = [ "egui", "log", @@ -2794,7 +2794,7 @@ dependencies = [ [[package]] name = "krilla" version = "0.8.2" -source = "git+https://github.com/storytold/craft-libs?rev=17744430be327d0257496a6a31c8d38fe20a6756#17744430be327d0257496a6a31c8d38fe20a6756" +source = "git+https://github.com/storytold/craft-libs?rev=6e7f28518dd1cb57e1624bdc269c3c14316b13ae#6e7f28518dd1cb57e1624bdc269c3c14316b13ae" dependencies = [ "base64 0.22.1", "bumpalo", diff --git a/Cargo.toml b/Cargo.toml index 7dc0744b0f..1398af5fa2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -54,7 +54,7 @@ harfrust = "0.13" wuff = "0.2.9" vello_cpu = "0.2" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "bmp", "tiff"] } -craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "17744430be327d0257496a6a31c8d38fe20a6756", version = "=0.1.0", default-features = false, features = ["write"] } +craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "6e7f28518dd1cb57e1624bdc269c3c14316b13ae", version = "=0.1.0", default-features = false, features = ["write"] } quick-xml = "0.41" zip = { version = "3", default-features = false, features = ["deflate"] } # zlib streams (compressed metafile pictures in `.doc` drawings); already in the lock as a dependency of other crates. @@ -110,4 +110,4 @@ debug = 0 # Required by craft-pdf: the backend rejects writers that failed during unwinding. [patch.crates-io] -krilla = { git = "https://github.com/storytold/craft-libs", rev = "17744430be327d0257496a6a31c8d38fe20a6756" } +krilla = { git = "https://github.com/storytold/craft-libs", rev = "6e7f28518dd1cb57e1624bdc269c3c14316b13ae" } diff --git a/apps/wordcraft/Cargo.toml b/apps/wordcraft/Cargo.toml index e255bafc5a..00054b687c 100644 --- a/apps/wordcraft/Cargo.toml +++ b/apps/wordcraft/Cargo.toml @@ -73,7 +73,7 @@ windows = { version = "0.62", features = ["UI_ViewManagement", "System_UserProfi clipboard-win = { version = "5.4", features = ["std"] } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] -craft-print = { git = "https://github.com/storytold/craft-libs", rev = "17744430be327d0257496a6a31c8d38fe20a6756" } +craft-print = { git = "https://github.com/storytold/craft-libs", rev = "6e7f28518dd1cb57e1624bdc269c3c14316b13ae" } rfd = { workspace = true } # Waits for an `rfd::AsyncFileDialog` on a background thread (the dialogs don't block the UI, #94); # rfd itself already depends on it. diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 416e0c58d6..6f63941a97 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -9,12 +9,12 @@ description = "WordCraft egui front end (Word-style ribbon UI); swappable: nothi workspace = true [dependencies] -craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "16fb3d46e1eaa0d455465e4e66a3ac7b17b55bd3" } +craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "6e7f28518dd1cb57e1624bdc269c3c14316b13ae" } # Interface translations: lookup, fallback, plurals and system-locale matching over the craft-i18n # catalogs that `build.rs` embeds from `CRAFT_I18N_DIR` (storytold/craft-i18n, pinned in -# `craft-i18n.rev`). Its own rev, ahead of craft-ui's: lazy per-locale parsing +# `craft-i18n.rev`). Same craft-libs rev as craft-ui; it brings lazy per-locale parsing # (storytold/craft-libs#247), ICU4X plural rules and bidi isolates around inserted values. -craft-i18n = { git = "https://github.com/storytold/craft-libs", rev = "17744430be327d0257496a6a31c8d38fe20a6756" } +craft-i18n = { git = "https://github.com/storytold/craft-libs", rev = "6e7f28518dd1cb57e1624bdc269c3c14316b13ae" } wordcraft-geom = { workspace = true } wordcraft-doc = { workspace = true } unicode-bidi = { workspace = true } From 7f236c3ec582d895bcf25f378cd6443b156fc00c Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 22:41:36 +0000 Subject: [PATCH 043/199] Print pane: show each printer's spooler description as its label --- apps/wordcraft/src/print.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/wordcraft/src/print.rs b/apps/wordcraft/src/print.rs index 58eb23286e..b8947a61e7 100644 --- a/apps/wordcraft/src/print.rs +++ b/apps/wordcraft/src/print.rs @@ -7,7 +7,7 @@ pub fn discover() -> Discover { let _ = std::thread::Builder::new().name("print-discovery".into()).spawn(move || { let printers = craft_print::printers() .into_iter() - .map(|printer| Printer { name: printer.name, label: String::new(), default: printer.default }) + .map(|printer| Printer { name: printer.name, label: printer.description, default: printer.default }) .collect(); let _ = sender.send(printers); }); From 4f071a5b881d326f039287eff5e157e79f6f234c Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 23:23:51 +0000 Subject: [PATCH 044/199] Shortcuts: a focused dialog button keeps Ctrl+Z and other shortcuts from the document --- crates/ui-egui/src/keys.rs | 9 ++++++-- crates/ui-egui/src/panes.rs | 41 ++++++++++++++++++++++++++++++------- 2 files changed, 41 insertions(+), 9 deletions(-) diff --git a/crates/ui-egui/src/keys.rs b/crates/ui-egui/src/keys.rs index 68541737b5..431391b863 100644 --- a/crates/ui-egui/src/keys.rs +++ b/crates/ui-egui/src/keys.rs @@ -706,10 +706,15 @@ fn is_global_shortcut(key: Key, m: Modifiers) -> bool { } /// Command shortcuts when the canvas isn't focused and no text field is: nothing has the -/// keyboard, or a button or a pane's title bar does. +/// keyboard, or a button or a pane's title bar does. A focused widget in an open dialog or popup +/// keeps the keys, so Ctrl+Z on a dialog button can't undo the document behind it. pub fn global_shortcuts(app: &mut WordApp, ctx: &egui::Context) { // Customize Keyboard records the keys pressed; they never run commands meanwhile. - if app.canvas.focused || ctx.text_edit_focused() || matches!(app.dialog, Some(crate::dialogs::Dialog::CustomizeKeyboard { .. })) { + if app.canvas.focused + || ctx.text_edit_focused() + || (ctx.egui_wants_keyboard_input() && crate::panes::overlay_open(app, ctx)) + || matches!(app.dialog, Some(crate::dialogs::Dialog::CustomizeKeyboard { .. })) + { return; } let events = ctx.input(|i| i.events.clone()); diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index f8500daade..62963bf443 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -259,18 +259,23 @@ impl PaneContent for Content<'_> { const HOST: &str = "task_panes"; -/// F6 and Shift+F6 move the keyboard through the panes and back to the document. The key stays -/// with an open dialog, file browser, popup or keytips (Customize Keyboard records it), and an -/// F6 the user bound to a command runs that command instead. -fn route_f6(app: &WordApp, ctx: &egui::Context) { - let busy = app.dialog.is_some() +/// Whether a dialog, file browser, colors dialog, content-control popup, Zotero alert, keytips +/// or egui popup is open: the keyboard belongs to it, not to the panes or the document. +pub(crate) fn overlay_open(app: &WordApp, ctx: &egui::Context) -> bool { + app.dialog.is_some() || app.file_dialog_open() || app.colors.is_some() || app.controls.popup_open() || app.zotero.alert_open() || app.ui.keytips != crate::keytips::Phase::Off - || egui::Popup::is_any_open(ctx); - if busy { + || egui::Popup::is_any_open(ctx) +} + +/// F6 and Shift+F6 move the keyboard through the panes and back to the document. The key stays +/// with an open dialog, file browser, popup or keytips (Customize Keyboard records it), and an +/// F6 the user bound to a command runs that command instead. +fn route_f6(app: &WordApp, ctx: &egui::Context) { + if overlay_open(app, ctx) { return; } let reg = &app.session.registry; @@ -1247,6 +1252,28 @@ mod tests { assert!(h.state().dialog.is_none(), "the search field keeps Ctrl+H"); } + /// A dialog button with the keyboard keeps Ctrl+Z away from the document behind the dialog. + #[test] + #[cfg(not(target_arch = "wasm32"))] + fn shortcuts_stay_out_of_the_document_while_a_dialog_button_has_focus() { + use egui_kittest::kittest::Queryable; + + let mut app = crate::WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::from_text("")), crate::Services::default()); + app.run("text.insert", serde_json::json!({"text": "kept"})).unwrap(); + app.run("ui.dialog", serde_json::json!({"name": "wordCount"})).unwrap(); + let mut h = pane_harness(app); + // Two buttons are labelled Close (the dialog's title bar and its own row); either will do. + if let Some(close) = h.query_all_by_label("Close").next() { + close.focus(); + } + h.run_steps(2); + assert!(!h.state().canvas.focused, "the document gave up the keyboard"); + assert!(!h.ctx.text_edit_focused() && h.ctx.memory(|m| m.focused()).is_some(), "Close has the keyboard"); + press(&mut h, egui::Key::Z, egui::Modifiers::COMMAND); + assert_eq!(h.state().session.doc.plain_text(wordcraft_doc::StoryRef::Body).trim(), "kept", "Ctrl+Z left the document alone"); + assert_eq!(h.state().dialog.as_ref().map(|d| d.name()), Some("wordCount")); + } + /// An F6 the user bound runs its command; plain F6 still moves to the panes when only /// Shift+F6 is bound. #[test] From f501f7cb5f6f375e4d23d0f0a625b1effe702b62 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 24 Oct 2026 23:33:00 +0000 Subject: [PATCH 045/199] Task panes: Dock Left, Dock Right and Float run ui.paneDock for every pane but Styles --- crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/panes.rs | 68 ++++++++++++++++++++++++++++++++++--- docs/control-protocol.md | 2 +- 3 files changed, 66 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 0f44e8c0e0..587ffed6e7 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1660,6 +1660,7 @@ impl WordApp { self.canvas.want_focus = true; json!({"pending": "contextMenu"}) } + "ui.paneDock" => return Some(panes::dock_command(self, p)), "ui.collapseRibbon" => { self.ui.ribbon_collapsed = !self.ui.ribbon_collapsed; json!({"collapsed": self.ui.ribbon_collapsed}) diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index 62963bf443..12120265bb 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -336,12 +336,12 @@ pub(crate) fn apply(app: &mut WordApp, request: PaneRequest) { PaneRequest::FloatRect(Pane::Styles, rect) => app.ui.styles_pane_float = Some(rect), PaneRequest::Place(pane, place) => { let dock = match place { - Place::Docked(Edge::Start) => SavedDock::Left, - Place::Docked(Edge::End) => SavedDock::Right, - Place::Floating => SavedDock::Float, + Place::Docked(Edge::Start) => "left", + Place::Docked(Edge::End) => "right", + Place::Floating => "float", Place::Docked(Edge::Bottom) => return, }; - app.ui.pane_places.entry(pane.key().into()).or_default().dock = Some(dock); + let _ = app.run("ui.paneDock", json!({"pane": pane.key(), "dock": dock})); } PaneRequest::Resize(pane, width) => app.ui.pane_places.entry(pane.key().into()).or_default().width = Some(width), PaneRequest::FloatRect(pane, rect) => app.ui.pane_places.entry(pane.key().into()).or_default().float = Some(rect), @@ -349,6 +349,37 @@ pub(crate) fn apply(app: &mut WordApp, request: PaneRequest) { } } +/// `ui.paneDock {"pane": key, "dock"?: "left" | "right" | "float"}` (#1756): docks or floats a +/// task pane, as its menu's Dock Left, Dock Right and Float do; without `dock` it reads the +/// pane's place. `pane` is the pane's `panePlaces` key; Styles goes through `view.stylesPaneDock`. +pub(crate) fn dock_command(app: &mut WordApp, p: &Value) -> Result { + let key = p.get("pane").and_then(Value::as_str).ok_or("`pane` must be a task pane's key, such as \"navigation\"")?; + let pane = Pane::ALL.into_iter().find(|pane| pane.key() == key).ok_or_else(|| format!("unknown task pane `{key}`"))?; + let dock = match p.get("dock") { + None => None, + Some(v) => Some(match v.as_str() { + Some("left") => SavedDock::Left, + Some("right") => SavedDock::Right, + Some("float") => SavedDock::Float, + _ => return Err("`dock` must be left, right or float".into()), + }), + }; + if pane == Pane::Styles { + let params = p.get("dock").map_or_else(|| json!({}), |d| json!({"dock": d})); + let result = app.run("view.stylesPaneDock", params)?; + return Ok(json!({"pane": key, "dock": result.get("dock")})); + } + if let Some(dock) = dock { + app.ui.pane_places.entry(key.into()).or_default().dock = Some(dock); + } + let name = match pane_state(app, pane).place { + Place::Docked(Edge::Start) => "left", + Place::Floating => "float", + Place::Docked(_) => "right", + }; + Ok(json!({"pane": key, "dock": name})) +} + fn close(app: &mut WordApp, pane: Pane) { match pane.close_command() { Some(id) => { @@ -1402,6 +1433,35 @@ mod tests { assert!(odd.ui.pane_places.is_empty() && odd.session.view.nav_pane); } + /// `ui.paneDock` docks or floats any pane and reads its place, as the pane's menu does + /// (#1756); Styles goes through `view.stylesPaneDock`; junk is refused and changes nothing. + #[test] + fn pane_dock_command_docks_floats_and_reads_each_pane() { + use craft_ui::panes::{Edge, Place}; + use serde_json::json; + let mut app = crate::WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::from_text("")), crate::Services::default()); + assert_eq!(app.run("ui.paneDock", json!({"pane": "navigation"})).unwrap(), json!({"pane": "navigation", "dock": "left"})); + assert_eq!(app.run("ui.paneDock", json!({"pane": "comments"})).unwrap(), json!({"pane": "comments", "dock": "right"})); + assert_eq!(app.run("ui.paneDock", json!({"pane": "navigation", "dock": "float"})).unwrap()["dock"], "float"); + assert_eq!(super::pane_state(&app, Pane::Navigation).place, Place::Floating); + app.run("ui.paneDock", json!({"pane": "comments", "dock": "left"})).unwrap(); + assert_eq!(super::pane_state(&app, Pane::Comments).place, Place::Docked(Edge::Start)); + assert_eq!(app.run("ui.paneDock", json!({"pane": "styles", "dock": "float"})).unwrap(), json!({"pane": "styles", "dock": "float"})); + assert_eq!(app.session.view.styles_pane_dock, wordcraft_engine::StylesPaneDock::Floating); + assert!(!app.ui.pane_places.contains_key("styles"), "Styles keeps its place in the view state"); + for junk in [ + json!({}), + json!({"pane": "nope", "dock": "left"}), + json!({"pane": 3}), + json!({"pane": "navigation", "dock": "top"}), + json!({"pane": "navigation", "dock": 1}), + json!({"pane": "navigation", "dock": null}), + ] { + assert!(app.run("ui.paneDock", junk.clone()).is_err(), "{junk}"); + } + assert_eq!(super::pane_state(&app, Pane::Navigation).place, Place::Floating, "junk changed nothing"); + } + /// #3032: Delete is greyed out for built-in styles in the Styles pane (the engine refuses /// them — `crates/engine/src/cmd/para.rs`), while custom styles keep it enabled. #[test] diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 08c62c3d91..f0adffa3b8 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -52,7 +52,7 @@ and set `WORDCRAFT_CONTROL_KEY` to that app's key. | Method | Params | Result | |---|---|---| -| `engine.execute` | `command`, `params` | the command's result (any id from `engine.commands`, including UI commands `ui.tab`, `ui.dialog` (`{"name": dialog, "part"?: table style part}`; `part` opens New / Modify Table Style (`newTableStyle` / `modifyTableStyle`) on that part: `wholeTable`, `headerRow`, `firstColumn`, `bandedRows`, `nwCell`… as `table.modifyStyle` names them), `ui.backstage`, `ui.zotero.*` (see `docs/zotero.md`), `ui.language` (`{"value": "auto"\|"en"\|"zh-hans"\|"zh-hant"\|"ja"\|"pt-br"\|"es"\|"uk"\|"sr"\|"sr-latn"\|"et"\|"tr"\|"ru"\|"pl"\|"de"\|"it"\|"cs"\|"hu"\|"fr"}`), `ui.theme` (`{"value": "system"\|"light"\|"dark"}`; `system` follows the OS appearance, light when it reports none), `ui.interfaceSize` (`{"value": "auto"}` or `{"percent": 50–300}`, clamped; no value reads it; `auto` keeps the system display scale, a percentage is absolute, so 100 is one screen pixel per point (#822); the result's `percent` is the size drawn at and `auto` whether it follows the system; document zoom stays `view.zoom`), `ui.animations` (`{"value": "system"\|"on"\|"off"}`; no value reads it; `system` follows the system's animation setting, which the desktop app reads on Windows, macOS, Linux and the BSDs and the web app from `prefers-reduced-motion`, and means on where the system doesn't say; the result's `enabled` is what the setting comes to and `system` what the system reports, `null` when it doesn't say), `ui.fileBrowser` (desktop: `{"purpose": "document"\|"picture"\|"recipients"\|"save"}` opens WordCraft's own file browser, for when the system file dialog doesn't show up, #576), `ui.saveAsTemplate` (File › Save As › Save as Word Template: the Save dialog for a `.dotx`, starting in the user templates folder; the result's `dir` is that folder, `null` on the web, #2852), `ui.caretAnimation` (`{"value": true\|false}`; no value reads it; off, the caret jumps and blinks on and off instead of gliding and fading), `ui.startScreen` (`{"value": true\|false}`; no value reads it; on, the default, the desktop and web apps start on File › Home when launched with nothing to open, #766), `ui.alignmentGuides` (`{"value": true\|false}`; no value reads it; on, the default, moving a picture or shape snaps it to the page, the margins and other objects, with guide lines while dragging; Alt moves freely), `ui.quickAccessToolbar` (`{"value": true\|false}`; no value toggles it; false takes the Quick Access Toolbar out of the title bar, as its menu's Hide Quick Access Toolbar does, and true brings it back, #1945), `ui.focusRibbon` (`{"value": true\|false}`; no value toggles it; in Focus, true drops the ribbon down over the page, as the bar's ⋯ does, and false puts it away, as a click on the page does; outside Focus it stays put away, #1678), `ui.contextMenu` (opens the page's context menu below the caret, as Shift+F10 does)…) | +| `engine.execute` | `command`, `params` | the command's result (any id from `engine.commands`, including UI commands `ui.tab`, `ui.dialog` (`{"name": dialog, "part"?: table style part}`; `part` opens New / Modify Table Style (`newTableStyle` / `modifyTableStyle`) on that part: `wholeTable`, `headerRow`, `firstColumn`, `bandedRows`, `nwCell`… as `table.modifyStyle` names them), `ui.backstage`, `ui.zotero.*` (see `docs/zotero.md`), `ui.language` (`{"value": "auto"\|"en"\|"zh-hans"\|"zh-hant"\|"ja"\|"pt-br"\|"es"\|"uk"\|"sr"\|"sr-latn"\|"et"\|"tr"\|"ru"\|"pl"\|"de"\|"it"\|"cs"\|"hu"\|"fr"}`), `ui.theme` (`{"value": "system"\|"light"\|"dark"}`; `system` follows the OS appearance, light when it reports none), `ui.interfaceSize` (`{"value": "auto"}` or `{"percent": 50–300}`, clamped; no value reads it; `auto` keeps the system display scale, a percentage is absolute, so 100 is one screen pixel per point (#822); the result's `percent` is the size drawn at and `auto` whether it follows the system; document zoom stays `view.zoom`), `ui.animations` (`{"value": "system"\|"on"\|"off"}`; no value reads it; `system` follows the system's animation setting, which the desktop app reads on Windows, macOS, Linux and the BSDs and the web app from `prefers-reduced-motion`, and means on where the system doesn't say; the result's `enabled` is what the setting comes to and `system` what the system reports, `null` when it doesn't say), `ui.fileBrowser` (desktop: `{"purpose": "document"\|"picture"\|"recipients"\|"save"}` opens WordCraft's own file browser, for when the system file dialog doesn't show up, #576), `ui.saveAsTemplate` (File › Save As › Save as Word Template: the Save dialog for a `.dotx`, starting in the user templates folder; the result's `dir` is that folder, `null` on the web, #2852), `ui.caretAnimation` (`{"value": true\|false}`; no value reads it; off, the caret jumps and blinks on and off instead of gliding and fading), `ui.startScreen` (`{"value": true\|false}`; no value reads it; on, the default, the desktop and web apps start on File › Home when launched with nothing to open, #766), `ui.alignmentGuides` (`{"value": true\|false}`; no value reads it; on, the default, moving a picture or shape snaps it to the page, the margins and other objects, with guide lines while dragging; Alt moves freely), `ui.quickAccessToolbar` (`{"value": true\|false}`; no value toggles it; false takes the Quick Access Toolbar out of the title bar, as its menu's Hide Quick Access Toolbar does, and true brings it back, #1945), `ui.focusRibbon` (`{"value": true\|false}`; no value toggles it; in Focus, true drops the ribbon down over the page, as the bar's ⋯ does, and false puts it away, as a click on the page does; outside Focus it stays put away, #1678), `ui.contextMenu` (opens the page's context menu below the caret, as Shift+F10 does), `ui.paneDock` (`{"pane": "navigation"\|"clipboard"\|"styles"\|"styleInspector"\|"accessibility"\|"altText"\|"formatShape"\|"restrictEditing"\|"comments"\|"smartArtText", "dock"?: "left"\|"right"\|"float"}`; no `dock` reads it; docks or floats a task pane as its menu does, kept between runs; Styles is `view.stylesPaneDock`, #1756)…) | | `engine.commands` | — | every command: id, label, location, shortcut, params, enabled | | `document.inspect` | `text?` | blocks (text, style, runs, lists, tables), parts, sections, selection, pages | | `ui.inspect` | — | UI state, view, dialog, window size, page rects on screen, caret, comment balloons (`balloons`: id + screen rect; `balloon`: the selected one), perf | From 9129d713ca832a3efa46e95de377dbc8dfa86212 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sun, 25 Oct 2026 00:14:31 +0000 Subject: [PATCH 046/199] Escape during a task pane drag cancels the drag and stays in full screen --- crates/ui-egui/src/keys.rs | 11 ++++++++--- crates/ui-egui/src/panes.rs | 28 ++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/keys.rs b/crates/ui-egui/src/keys.rs index 431391b863..71542f8ab4 100644 --- a/crates/ui-egui/src/keys.rs +++ b/crates/ui-egui/src/keys.rs @@ -465,7 +465,7 @@ pub fn canvas_events(app: &mut WordApp, ctx: &egui::Context) { } continue; } - if leave_full_screen(app, key, modifiers, true) { + if leave_full_screen(app, ctx, key, modifiers, true) { continue; } app.canvas.swallow_text = dispatch(app, key, modifiers) && modifiers.alt; @@ -479,12 +479,17 @@ pub fn canvas_events(app: &mut WordApp, ctx: &egui::Context) { /// took it: a pen, a text box, a header, a dialog, a menu or a popup, a drag on the page, and in /// the document (`canvas`) a selection, which Escape collapses first (`select.collapse`), as in a /// normal window. -fn leave_full_screen(app: &mut WordApp, key: Key, m: Modifiers, canvas: bool) -> bool { +fn leave_full_screen(app: &mut WordApp, ctx: &egui::Context, key: Key, m: Modifiers, canvas: bool) -> bool { let screen = crate::chrome::Screen::of(&app.session.view).exit_command(); let Some(id) = screen.or(app.session.view.full_screen.then_some("view.fullScreen")) else { return false }; if key != Key::Escape || !m.is_none() || !app.escape_free { return false; } + // Escape with a button held (dragging a task pane's title bar, say) cancels the drag only. + // egui has already dropped the drag itself, so the held button is what is left of it. + if ctx.input(|i| i.pointer.any_down()) { + return false; + } let c = &app.canvas; if c.obj_drag.is_some() || c.table_resize.is_some() || c.table_drag.is_some() || c.link_from.is_some() { return false; @@ -720,7 +725,7 @@ pub fn global_shortcuts(app: &mut WordApp, ctx: &egui::Context) { let events = ctx.input(|i| i.events.clone()); for e in events { if let egui::Event::Key { key, pressed: true, modifiers, .. } = e { - if leave_full_screen(app, key, modifiers, false) { + if leave_full_screen(app, ctx, key, modifiers, false) { continue; } if is_global_shortcut(key, modifiers) { diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index 12120265bb..5274f083db 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -1305,6 +1305,34 @@ mod tests { assert_eq!(h.state().dialog.as_ref().map(|d| d.name()), Some("wordCount")); } + /// Escape during a pane's title-bar drag cancels the drag and leaves full screen on. + #[test] + #[cfg(not(target_arch = "wasm32"))] + fn escape_during_a_pane_drag_keeps_full_screen() { + use egui_kittest::kittest::Queryable; + + let mut app = crate::WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::from_text("")), crate::Services::default()); + app.run("view.navigationPane", serde_json::json!({"value": true})).unwrap(); + app.run("view.fullScreen", serde_json::json!({"value": true})).unwrap(); + let mut h = pane_harness(app); + let title = h.get_by_label_contains("Move Pane: ").rect().center(); + let button = + |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }; + h.input_mut().events.extend([egui::Event::PointerMoved(title), button(title, true)]); + h.run_steps(2); + let away = title + egui::vec2(300.0, 200.0); + h.input_mut().events.push(egui::Event::PointerMoved(away)); + h.run_steps(2); + assert!(h.ctx.dragged_id().is_some(), "the title bar is dragged"); + press(&mut h, egui::Key::Escape, egui::Modifiers::NONE); + assert!(h.state().session.view.full_screen); + h.input_mut().events.push(button(away, false)); + h.run_steps(2); + assert!(h.state().session.view.full_screen); + let docked = craft_ui::panes::Place::Docked(craft_ui::panes::Edge::Start); + assert_eq!(super::pane_state(h.state(), Pane::Navigation).place, docked, "the drag was cancelled"); + } + /// An F6 the user bound runs its command; plain F6 still moves to the panes when only /// Shift+F6 is bound. #[test] From 0bdd2a3e040199cc3b5b0288a45af89a0845e137 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sun, 25 Oct 2026 00:37:48 +0000 Subject: [PATCH 047/199] Pin craft-libs at 869928d (craft-print verifies Windows print capabilities, craft-libs#303) --- Cargo.lock | 122 ++++++++------------------------------ Cargo.toml | 4 +- apps/wordcraft/Cargo.toml | 2 +- crates/ui-egui/Cargo.toml | 4 +- 4 files changed, 29 insertions(+), 103 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8be5da985c..5e14ec336c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -240,7 +240,7 @@ dependencies = [ "objc2-foundation 0.3.2", "parking_lot", "percent-encoding", - "windows-sys 0.60.2", + "windows-sys 0.59.0", "wl-clipboard-rs", "x11rb", ] @@ -991,7 +991,7 @@ dependencies = [ [[package]] name = "craft-i18n" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=6e7f28518dd1cb57e1624bdc269c3c14316b13ae#6e7f28518dd1cb57e1624bdc269c3c14316b13ae" +source = "git+https://github.com/storytold/craft-libs?rev=869928d2a06a14fccd921dc9e94cd15e4ad18e18#869928d2a06a14fccd921dc9e94cd15e4ad18e18" dependencies = [ "fixed_decimal", "icu_decimal", @@ -1014,7 +1014,7 @@ dependencies = [ [[package]] name = "craft-pdf" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=6e7f28518dd1cb57e1624bdc269c3c14316b13ae#6e7f28518dd1cb57e1624bdc269c3c14316b13ae" +source = "git+https://github.com/storytold/craft-libs?rev=869928d2a06a14fccd921dc9e94cd15e4ad18e18#869928d2a06a14fccd921dc9e94cd15e4ad18e18" dependencies = [ "krilla", "thiserror 2.0.21", @@ -1023,7 +1023,7 @@ dependencies = [ [[package]] name = "craft-print" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=6e7f28518dd1cb57e1624bdc269c3c14316b13ae#6e7f28518dd1cb57e1624bdc269c3c14316b13ae" +source = "git+https://github.com/storytold/craft-libs?rev=869928d2a06a14fccd921dc9e94cd15e4ad18e18#869928d2a06a14fccd921dc9e94cd15e4ad18e18" dependencies = [ "craft-winprint-gdi", "miniz_oxide 0.8.9", @@ -1036,7 +1036,7 @@ dependencies = [ [[package]] name = "craft-ui" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=6e7f28518dd1cb57e1624bdc269c3c14316b13ae#6e7f28518dd1cb57e1624bdc269c3c14316b13ae" +source = "git+https://github.com/storytold/craft-libs?rev=869928d2a06a14fccd921dc9e94cd15e4ad18e18#869928d2a06a14fccd921dc9e94cd15e4ad18e18" dependencies = [ "egui", "log", @@ -1525,7 +1525,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -2794,7 +2794,7 @@ dependencies = [ [[package]] name = "krilla" version = "0.8.2" -source = "git+https://github.com/storytold/craft-libs?rev=6e7f28518dd1cb57e1624bdc269c3c14316b13ae#6e7f28518dd1cb57e1624bdc269c3c14316b13ae" +source = "git+https://github.com/storytold/craft-libs?rev=869928d2a06a14fccd921dc9e94cd15e4ad18e18#869928d2a06a14fccd921dc9e94cd15e4ad18e18" dependencies = [ "base64 0.22.1", "bumpalo", @@ -3494,7 +3494,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -3926,7 +3926,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -4303,7 +4303,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -4360,7 +4360,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -4952,7 +4952,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -5137,7 +5137,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix 1.1.5", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -6114,7 +6114,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -6235,7 +6235,7 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets 0.52.6", + "windows-targets", ] [[package]] @@ -6244,16 +6244,7 @@ version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", + "windows-targets", ] [[package]] @@ -6271,31 +6262,14 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", -] - -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", ] [[package]] @@ -6313,96 +6287,48 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - [[package]] name = "windows_i686_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - [[package]] name = "winit" version = "0.30.13" diff --git a/Cargo.toml b/Cargo.toml index 1398af5fa2..55b52d9788 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -54,7 +54,7 @@ harfrust = "0.13" wuff = "0.2.9" vello_cpu = "0.2" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "bmp", "tiff"] } -craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "6e7f28518dd1cb57e1624bdc269c3c14316b13ae", version = "=0.1.0", default-features = false, features = ["write"] } +craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "869928d2a06a14fccd921dc9e94cd15e4ad18e18", version = "=0.1.0", default-features = false, features = ["write"] } quick-xml = "0.41" zip = { version = "3", default-features = false, features = ["deflate"] } # zlib streams (compressed metafile pictures in `.doc` drawings); already in the lock as a dependency of other crates. @@ -110,4 +110,4 @@ debug = 0 # Required by craft-pdf: the backend rejects writers that failed during unwinding. [patch.crates-io] -krilla = { git = "https://github.com/storytold/craft-libs", rev = "6e7f28518dd1cb57e1624bdc269c3c14316b13ae" } +krilla = { git = "https://github.com/storytold/craft-libs", rev = "869928d2a06a14fccd921dc9e94cd15e4ad18e18" } diff --git a/apps/wordcraft/Cargo.toml b/apps/wordcraft/Cargo.toml index 00054b687c..3d9356a8d3 100644 --- a/apps/wordcraft/Cargo.toml +++ b/apps/wordcraft/Cargo.toml @@ -73,7 +73,7 @@ windows = { version = "0.62", features = ["UI_ViewManagement", "System_UserProfi clipboard-win = { version = "5.4", features = ["std"] } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] -craft-print = { git = "https://github.com/storytold/craft-libs", rev = "6e7f28518dd1cb57e1624bdc269c3c14316b13ae" } +craft-print = { git = "https://github.com/storytold/craft-libs", rev = "869928d2a06a14fccd921dc9e94cd15e4ad18e18" } rfd = { workspace = true } # Waits for an `rfd::AsyncFileDialog` on a background thread (the dialogs don't block the UI, #94); # rfd itself already depends on it. diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index c17e3f0e5d..c880541a91 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -9,12 +9,12 @@ description = "WordCraft egui front end (Word-style ribbon UI); swappable: nothi workspace = true [dependencies] -craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "6e7f28518dd1cb57e1624bdc269c3c14316b13ae" } +craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "869928d2a06a14fccd921dc9e94cd15e4ad18e18" } # Interface translations: lookup, fallback, plurals and system-locale matching over the craft-i18n # catalogs that `build.rs` embeds from `CRAFT_I18N_DIR` (storytold/craft-i18n, pinned in # `craft-i18n.rev`). Same craft-libs rev as craft-ui; it brings lazy per-locale parsing # (storytold/craft-libs#247), ICU4X plural rules and bidi isolates around inserted values. -craft-i18n = { git = "https://github.com/storytold/craft-libs", rev = "6e7f28518dd1cb57e1624bdc269c3c14316b13ae" } +craft-i18n = { git = "https://github.com/storytold/craft-libs", rev = "869928d2a06a14fccd921dc9e94cd15e4ad18e18" } wordcraft-geom = { workspace = true, features = ["image"] } wordcraft-doc = { workspace = true } unicode-bidi = { workspace = true } From 823ce76e398b3d0e1a4c6b46832e05f58edbdeb0 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sun, 25 Oct 2026 03:55:42 +0000 Subject: [PATCH 048/199] Pin craft-libs at 8a503cf (Adobe-style drop targets, craft-libs#310) --- Cargo.lock | 24 ++++++++++++------------ Cargo.toml | 4 ++-- apps/wordcraft/Cargo.toml | 2 +- crates/ui-egui/Cargo.toml | 4 ++-- 4 files changed, 17 insertions(+), 17 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5e14ec336c..e3af381854 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -991,7 +991,7 @@ dependencies = [ [[package]] name = "craft-i18n" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=869928d2a06a14fccd921dc9e94cd15e4ad18e18#869928d2a06a14fccd921dc9e94cd15e4ad18e18" +source = "git+https://github.com/storytold/craft-libs?rev=8a503cfe79f0a4e7da2e167e01e71155668e0cee#8a503cfe79f0a4e7da2e167e01e71155668e0cee" dependencies = [ "fixed_decimal", "icu_decimal", @@ -1014,7 +1014,7 @@ dependencies = [ [[package]] name = "craft-pdf" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=869928d2a06a14fccd921dc9e94cd15e4ad18e18#869928d2a06a14fccd921dc9e94cd15e4ad18e18" +source = "git+https://github.com/storytold/craft-libs?rev=8a503cfe79f0a4e7da2e167e01e71155668e0cee#8a503cfe79f0a4e7da2e167e01e71155668e0cee" dependencies = [ "krilla", "thiserror 2.0.21", @@ -1023,7 +1023,7 @@ dependencies = [ [[package]] name = "craft-print" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=869928d2a06a14fccd921dc9e94cd15e4ad18e18#869928d2a06a14fccd921dc9e94cd15e4ad18e18" +source = "git+https://github.com/storytold/craft-libs?rev=8a503cfe79f0a4e7da2e167e01e71155668e0cee#8a503cfe79f0a4e7da2e167e01e71155668e0cee" dependencies = [ "craft-winprint-gdi", "miniz_oxide 0.8.9", @@ -1036,7 +1036,7 @@ dependencies = [ [[package]] name = "craft-ui" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=869928d2a06a14fccd921dc9e94cd15e4ad18e18#869928d2a06a14fccd921dc9e94cd15e4ad18e18" +source = "git+https://github.com/storytold/craft-libs?rev=8a503cfe79f0a4e7da2e167e01e71155668e0cee#8a503cfe79f0a4e7da2e167e01e71155668e0cee" dependencies = [ "egui", "log", @@ -1525,7 +1525,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2794,7 +2794,7 @@ dependencies = [ [[package]] name = "krilla" version = "0.8.2" -source = "git+https://github.com/storytold/craft-libs?rev=869928d2a06a14fccd921dc9e94cd15e4ad18e18#869928d2a06a14fccd921dc9e94cd15e4ad18e18" +source = "git+https://github.com/storytold/craft-libs?rev=8a503cfe79f0a4e7da2e167e01e71155668e0cee#8a503cfe79f0a4e7da2e167e01e71155668e0cee" dependencies = [ "base64 0.22.1", "bumpalo", @@ -3494,7 +3494,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3926,7 +3926,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4303,7 +4303,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4360,7 +4360,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -5137,7 +5137,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix 1.1.5", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -6114,7 +6114,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 55b52d9788..638f2eea5f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -54,7 +54,7 @@ harfrust = "0.13" wuff = "0.2.9" vello_cpu = "0.2" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "bmp", "tiff"] } -craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "869928d2a06a14fccd921dc9e94cd15e4ad18e18", version = "=0.1.0", default-features = false, features = ["write"] } +craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "8a503cfe79f0a4e7da2e167e01e71155668e0cee", version = "=0.1.0", default-features = false, features = ["write"] } quick-xml = "0.41" zip = { version = "3", default-features = false, features = ["deflate"] } # zlib streams (compressed metafile pictures in `.doc` drawings); already in the lock as a dependency of other crates. @@ -110,4 +110,4 @@ debug = 0 # Required by craft-pdf: the backend rejects writers that failed during unwinding. [patch.crates-io] -krilla = { git = "https://github.com/storytold/craft-libs", rev = "869928d2a06a14fccd921dc9e94cd15e4ad18e18" } +krilla = { git = "https://github.com/storytold/craft-libs", rev = "8a503cfe79f0a4e7da2e167e01e71155668e0cee" } diff --git a/apps/wordcraft/Cargo.toml b/apps/wordcraft/Cargo.toml index 3d9356a8d3..37a674f407 100644 --- a/apps/wordcraft/Cargo.toml +++ b/apps/wordcraft/Cargo.toml @@ -73,7 +73,7 @@ windows = { version = "0.62", features = ["UI_ViewManagement", "System_UserProfi clipboard-win = { version = "5.4", features = ["std"] } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] -craft-print = { git = "https://github.com/storytold/craft-libs", rev = "869928d2a06a14fccd921dc9e94cd15e4ad18e18" } +craft-print = { git = "https://github.com/storytold/craft-libs", rev = "8a503cfe79f0a4e7da2e167e01e71155668e0cee" } rfd = { workspace = true } # Waits for an `rfd::AsyncFileDialog` on a background thread (the dialogs don't block the UI, #94); # rfd itself already depends on it. diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index c880541a91..0b2e81a45a 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -9,12 +9,12 @@ description = "WordCraft egui front end (Word-style ribbon UI); swappable: nothi workspace = true [dependencies] -craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "869928d2a06a14fccd921dc9e94cd15e4ad18e18" } +craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "8a503cfe79f0a4e7da2e167e01e71155668e0cee" } # Interface translations: lookup, fallback, plurals and system-locale matching over the craft-i18n # catalogs that `build.rs` embeds from `CRAFT_I18N_DIR` (storytold/craft-i18n, pinned in # `craft-i18n.rev`). Same craft-libs rev as craft-ui; it brings lazy per-locale parsing # (storytold/craft-libs#247), ICU4X plural rules and bidi isolates around inserted values. -craft-i18n = { git = "https://github.com/storytold/craft-libs", rev = "869928d2a06a14fccd921dc9e94cd15e4ad18e18" } +craft-i18n = { git = "https://github.com/storytold/craft-libs", rev = "8a503cfe79f0a4e7da2e167e01e71155668e0cee" } wordcraft-geom = { workspace = true, features = ["image"] } wordcraft-doc = { workspace = true } unicode-bidi = { workspace = true } From f199efde84785944c61bbd9dbaf56a4ce7a7c9a1 Mon Sep 17 00:00:00 2001 From: aiongg Date: Sun, 25 Oct 2026 06:07:10 +0000 Subject: [PATCH 049/199] Pin craft-libs at 4dc543c (task pane drop previews show where the pane lands, craft-libs#312) --- Cargo.lock | 24 ++++++++++++------------ Cargo.toml | 4 ++-- apps/wordcraft/Cargo.toml | 2 +- crates/ui-egui/Cargo.toml | 4 ++-- 4 files changed, 17 insertions(+), 17 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e3af381854..cfc346e383 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -991,7 +991,7 @@ dependencies = [ [[package]] name = "craft-i18n" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=8a503cfe79f0a4e7da2e167e01e71155668e0cee#8a503cfe79f0a4e7da2e167e01e71155668e0cee" +source = "git+https://github.com/storytold/craft-libs?rev=4dc543cb449cc232e4c3d58f78a05c1ab169db9e#4dc543cb449cc232e4c3d58f78a05c1ab169db9e" dependencies = [ "fixed_decimal", "icu_decimal", @@ -1014,7 +1014,7 @@ dependencies = [ [[package]] name = "craft-pdf" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=8a503cfe79f0a4e7da2e167e01e71155668e0cee#8a503cfe79f0a4e7da2e167e01e71155668e0cee" +source = "git+https://github.com/storytold/craft-libs?rev=4dc543cb449cc232e4c3d58f78a05c1ab169db9e#4dc543cb449cc232e4c3d58f78a05c1ab169db9e" dependencies = [ "krilla", "thiserror 2.0.21", @@ -1023,7 +1023,7 @@ dependencies = [ [[package]] name = "craft-print" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=8a503cfe79f0a4e7da2e167e01e71155668e0cee#8a503cfe79f0a4e7da2e167e01e71155668e0cee" +source = "git+https://github.com/storytold/craft-libs?rev=4dc543cb449cc232e4c3d58f78a05c1ab169db9e#4dc543cb449cc232e4c3d58f78a05c1ab169db9e" dependencies = [ "craft-winprint-gdi", "miniz_oxide 0.8.9", @@ -1036,7 +1036,7 @@ dependencies = [ [[package]] name = "craft-ui" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=8a503cfe79f0a4e7da2e167e01e71155668e0cee#8a503cfe79f0a4e7da2e167e01e71155668e0cee" +source = "git+https://github.com/storytold/craft-libs?rev=4dc543cb449cc232e4c3d58f78a05c1ab169db9e#4dc543cb449cc232e4c3d58f78a05c1ab169db9e" dependencies = [ "egui", "log", @@ -1525,7 +1525,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -2794,7 +2794,7 @@ dependencies = [ [[package]] name = "krilla" version = "0.8.2" -source = "git+https://github.com/storytold/craft-libs?rev=8a503cfe79f0a4e7da2e167e01e71155668e0cee#8a503cfe79f0a4e7da2e167e01e71155668e0cee" +source = "git+https://github.com/storytold/craft-libs?rev=4dc543cb449cc232e4c3d58f78a05c1ab169db9e#4dc543cb449cc232e4c3d58f78a05c1ab169db9e" dependencies = [ "base64 0.22.1", "bumpalo", @@ -3494,7 +3494,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -3926,7 +3926,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -4303,7 +4303,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -4360,7 +4360,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -5137,7 +5137,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix 1.1.5", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -6114,7 +6114,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 638f2eea5f..4f24147d1c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -54,7 +54,7 @@ harfrust = "0.13" wuff = "0.2.9" vello_cpu = "0.2" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "bmp", "tiff"] } -craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "8a503cfe79f0a4e7da2e167e01e71155668e0cee", version = "=0.1.0", default-features = false, features = ["write"] } +craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "4dc543cb449cc232e4c3d58f78a05c1ab169db9e", version = "=0.1.0", default-features = false, features = ["write"] } quick-xml = "0.41" zip = { version = "3", default-features = false, features = ["deflate"] } # zlib streams (compressed metafile pictures in `.doc` drawings); already in the lock as a dependency of other crates. @@ -110,4 +110,4 @@ debug = 0 # Required by craft-pdf: the backend rejects writers that failed during unwinding. [patch.crates-io] -krilla = { git = "https://github.com/storytold/craft-libs", rev = "8a503cfe79f0a4e7da2e167e01e71155668e0cee" } +krilla = { git = "https://github.com/storytold/craft-libs", rev = "4dc543cb449cc232e4c3d58f78a05c1ab169db9e" } diff --git a/apps/wordcraft/Cargo.toml b/apps/wordcraft/Cargo.toml index 37a674f407..6a61d8e8a6 100644 --- a/apps/wordcraft/Cargo.toml +++ b/apps/wordcraft/Cargo.toml @@ -73,7 +73,7 @@ windows = { version = "0.62", features = ["UI_ViewManagement", "System_UserProfi clipboard-win = { version = "5.4", features = ["std"] } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] -craft-print = { git = "https://github.com/storytold/craft-libs", rev = "8a503cfe79f0a4e7da2e167e01e71155668e0cee" } +craft-print = { git = "https://github.com/storytold/craft-libs", rev = "4dc543cb449cc232e4c3d58f78a05c1ab169db9e" } rfd = { workspace = true } # Waits for an `rfd::AsyncFileDialog` on a background thread (the dialogs don't block the UI, #94); # rfd itself already depends on it. diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 0b2e81a45a..614ab7f154 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -9,12 +9,12 @@ description = "WordCraft egui front end (Word-style ribbon UI); swappable: nothi workspace = true [dependencies] -craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "8a503cfe79f0a4e7da2e167e01e71155668e0cee" } +craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "4dc543cb449cc232e4c3d58f78a05c1ab169db9e" } # Interface translations: lookup, fallback, plurals and system-locale matching over the craft-i18n # catalogs that `build.rs` embeds from `CRAFT_I18N_DIR` (storytold/craft-i18n, pinned in # `craft-i18n.rev`). Same craft-libs rev as craft-ui; it brings lazy per-locale parsing # (storytold/craft-libs#247), ICU4X plural rules and bidi isolates around inserted values. -craft-i18n = { git = "https://github.com/storytold/craft-libs", rev = "8a503cfe79f0a4e7da2e167e01e71155668e0cee" } +craft-i18n = { git = "https://github.com/storytold/craft-libs", rev = "4dc543cb449cc232e4c3d58f78a05c1ab169db9e" } wordcraft-geom = { workspace = true, features = ["image"] } wordcraft-doc = { workspace = true } unicode-bidi = { workspace = true } From 49600e0344995954b4dee72c08f41ff0fed4d438 Mon Sep 17 00:00:00 2001 From: skylartaylor Date: Sun, 25 Oct 2026 21:07:35 +0100 Subject: [PATCH 050/199] egui037: cover formatting copy and paste shortcuts --- crates/ui-egui/src/lib.rs | 64 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index f546dead9e..1b63624f87 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -6821,6 +6821,70 @@ mod tests { assert_eq!(h.state().session.doc.plain_text(wordcraft_doc::StoryRef::Body), "Kilns"); } + /// PR #4241: Ctrl/Cmd+Shift+C copies the selection's formatting and Ctrl/Cmd+Shift+V pastes it + /// onto another selection, as in Word — not the text. egui 0.37 delivers those chords as keys + /// (only Cmd/Ctrl without extra modifiers become copy/paste events), so the registry's + /// `Mod+Shift+C`/`Mod+Shift+V` commands must run. + #[test] + fn ctrl_shift_c_and_v_copy_and_paste_formatting_not_text() { + // A native event carries ⌘ (mac_cmd and command) on macOS, Ctrl (ctrl and command) + // elsewhere; COMMAND+SHIFT is the logical chord both platforms resolve. + let native = if cfg!(target_os = "macos") { + egui::Modifiers::MAC_CMD | egui::Modifiers::COMMAND | egui::Modifiers::SHIFT + } else { + egui::Modifiers::CTRL | egui::Modifiers::COMMAND | egui::Modifiers::SHIFT + }; + let copied = |h: &egui_kittest::Harness| -> Vec { + h.output() + .platform_output + .commands + .iter() + .filter_map(|c| match c { + egui::OutputCommand::CopyText(t) => Some(t.clone()), + _ => None, + }) + .collect() + }; + let bold = |h: &mut egui_kittest::Harness| -> Option { + h.state_mut().run("format.state", json!({})).unwrap().get("bold").and_then(Value::as_bool) + }; + for modifiers in [egui::Modifiers::COMMAND | egui::Modifiers::SHIFT, native] { + let mut a = app(); + a.run("text.insert", json!({"text": "Bold plain"})).unwrap(); + a.run("select.text", json!({"text": "Bold"})).unwrap(); + a.run("format.bold", json!({})).unwrap(); + a.run("select.text", json!({"text": "plain"})).unwrap(); + a.run("edit.copy", json!({})).unwrap(); + let clipboard_entries = a.session.clip_history.len(); + assert_eq!(a.session.clipboard_text, "plain"); + assert!(clipboard_entries > 0); + a.run("select.text", json!({"text": "Bold"})).unwrap(); + let mut h = harness(a); + h.run_steps(6); + assert!(h.state().canvas.focused, "the canvas's key handler has the keyboard"); + assert_eq!(bold(&mut h), Some(true), "the source selection is bold"); + let key = |key, modifiers| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }; + h.event(key(egui::Key::C, modifiers)); + h.step(); + assert!(h.state().session.painter.is_some(), "{modifiers:?}: Copy Formatting picked up the selection's formatting"); + assert_eq!(h.state().session.clip_history.len(), clipboard_entries, "{modifiers:?}: Copy Formatting preserved clipboard history"); + assert_eq!(h.state().session.clipboard_text, "plain", "{modifiers:?}: the existing clipboard text was preserved"); + assert!(copied(&h).is_empty(), "{modifiers:?}: no text reached the system clipboard: {:?}", copied(&h)); + assert_eq!(body_text(h.state()), "Bold plain", "{modifiers:?}: copying formatting left the document text alone"); + + h.state_mut().run("select.text", json!({"text": "plain"})).unwrap(); + assert_eq!(bold(&mut h), Some(false), "the target selection is plain before the paste"); + h.event(key(egui::Key::V, modifiers)); + h.step(); + assert_eq!(bold(&mut h), Some(true), "{modifiers:?}: Paste Formatting made the target bold"); + assert!(h.state().session.painter.is_none(), "{modifiers:?}: the non-sticky painter is used up"); + assert_eq!(body_text(h.state()), "Bold plain", "{modifiers:?}: pasting formatting left the document text alone"); + assert_eq!(h.state().session.clip_history.len(), clipboard_entries, "{modifiers:?}: Paste Formatting preserved clipboard history"); + assert_eq!(h.state().session.clipboard_text, "plain", "{modifiers:?}: Paste Formatting preserved the clipboard text"); + assert!(copied(&h).is_empty(), "{modifiers:?}: pasting formatting put no text on the system clipboard: {:?}", copied(&h)); + } + } + /// #1669: with Shift held, Backspace still erases (#707) and Delete cuts the selection, doing /// nothing without one, as in Word for Windows. (Windows sends Shift+Delete as a Cut; macOS and /// Linux send the key, which reached no command.) From 5ee71b4862b7105bbacbc74000faf1b806837bfe Mon Sep 17 00:00:00 2001 From: skylar Date: Sun, 25 Oct 2026 15:34:24 -0600 Subject: [PATCH 051/199] chore(deps): refresh safe dependency bumps serde_json 1.0.154, proptest 1.12, base64 0.23, arboard 3.7, open 5.4.5, winresource 0.1.34, pollster 1.0, miniz_oxide 0.9, sha2 0.11.1, libc 0.2.191. --- Cargo.lock | 261 +++++++++++++------------------------- Cargo.toml | 6 +- apps/wordcraft/Cargo.toml | 2 +- crates/proof/Cargo.toml | 2 +- 4 files changed, 94 insertions(+), 177 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index dc052fa739..cfcbb47b57 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -221,9 +221,9 @@ dependencies = [ [[package]] name = "arboard" -version = "3.6.1" +version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" +checksum = "aeb9553e62312c8756a5979254fc6d7da8bbeb5986a1e17037fab932f7037917" dependencies = [ "clipboard-win", "image", @@ -235,7 +235,7 @@ dependencies = [ "objc2-foundation 0.3.2", "parking_lot", "percent-encoding", - "windows-sys 0.60.2", + "windows-sys 0.59.0", "wl-clipboard-rs", "x11rb", ] @@ -489,15 +489,6 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" -[[package]] -name = "bit-set" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" -dependencies = [ - "bit-vec 0.8.0", -] - [[package]] name = "bit-set" version = "0.10.0" @@ -508,10 +499,13 @@ dependencies = [ ] [[package]] -name = "bit-vec" -version = "0.8.0" +name = "bit-set" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" +checksum = "56d87354e4229f54a44f7bf2435906a4656dba36026ab6eaca629a2c436a691c" +dependencies = [ + "bit-vec 0.10.1", +] [[package]] name = "bit-vec" @@ -519,6 +513,16 @@ version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" +[[package]] +name = "bit-vec" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5727b15fa97d4f4fee0a3b7c3d550ed0269f54329207b86388de918604e31269" +dependencies = [ + "borsh", + "serde", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -580,6 +584,30 @@ dependencies = [ "piper", ] +[[package]] +name = "borsh" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "553c5d846a6ba5150c65e3b1b8ec073bcf1abc20f9b7220de384a4443ea4e20a" +dependencies = [ + "borsh-derive", + "bytes", + "cfg_aliases", +] + +[[package]] +name = "borsh-derive" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12cdfe656708a01f89b451a7d36466e6fe6c414de0aa18fc54f864f6f9ca9f56" +dependencies = [ + "once_cell", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.6", +] + [[package]] name = "brotli" version = "8.0.4" @@ -1545,7 +1573,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -2842,9 +2870,9 @@ checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" [[package]] name = "libc" -version = "0.2.190" +version = "0.2.191" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" [[package]] name = "libloading" @@ -3457,9 +3485,9 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "open" -version = "5.4.4" +version = "5.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa576c76302b7b808eecc68061e67336c47833ef9d22caa74dda10fa9675eebc" +checksum = "8d8c8b8e725ad26404d6896eb2f064294a0a7b4f1d9120210d85544684c4ca95" dependencies = [ "is-wsl", "libc", @@ -3516,7 +3544,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -3846,15 +3874,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958" -[[package]] -name = "ppv-lite86" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] - [[package]] name = "presser" version = "0.3.1" @@ -3911,16 +3930,17 @@ checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" [[package]] name = "proptest" -version = "1.11.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +checksum = "b8530004ccb15eae51c7e40009fbe317f341f804db54dc033eec1c50be28cfa0" dependencies = [ - "bit-set 0.8.0", - "bit-vec 0.8.0", + "bit-set 0.11.1", + "bit-vec 0.10.1", "bitflags 2.13.2", + "chacha20", + "core_detect", "num-traits", - "rand 0.9.5", - "rand_chacha", + "rand", "rand_xorshift", "regex-syntax", "rusty-fork", @@ -3994,7 +4014,7 @@ dependencies = [ "bytes", "getrandom 0.4.3", "lru-slab", - "rand 0.10.3", + "rand", "rand_pcg", "ring", "rustc-hash 2.1.3", @@ -4018,7 +4038,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -4042,16 +4062,6 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" -[[package]] -name = "rand" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" -dependencies = [ - "rand_chacha", - "rand_core 0.9.5", -] - [[package]] name = "rand" version = "0.10.3" @@ -4063,31 +4073,12 @@ dependencies = [ "rand_core 0.10.1", ] -[[package]] -name = "rand_chacha" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" -dependencies = [ - "ppv-lite86", - "rand_core 0.9.5", -] - [[package]] name = "rand_core" version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" -[[package]] -name = "rand_core" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" -dependencies = [ - "getrandom 0.3.4", -] - [[package]] name = "rand_core" version = "0.10.1" @@ -4105,11 +4096,11 @@ dependencies = [ [[package]] name = "rand_xorshift" -version = "0.4.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +checksum = "60aa6af80be32871323012e02e6e65f8a7cc7890931ae421d217ad8fe0df2ccf" dependencies = [ - "rand_core 0.9.5", + "rand_core 0.10.1", ] [[package]] @@ -4395,7 +4386,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -4454,7 +4445,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -4612,9 +4603,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.151" +version = "1.0.154" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" dependencies = [ "itoa", "memchr", @@ -4701,9 +4692,9 @@ dependencies = [ [[package]] name = "sha2" -version = "0.11.0" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19" dependencies = [ "cfg-if", "cpufeatures 0.3.1", @@ -4838,7 +4829,7 @@ dependencies = [ "const-oid 0.9.6", "der", "hex", - "rand 0.10.3", + "rand", "reqwest", "rustls-pki-types", "rustls-webpki", @@ -5238,10 +5229,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.3", + "getrandom 0.3.4", "once_cell", "rustix 1.1.5", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -6278,7 +6269,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -6399,7 +6390,7 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets 0.52.6", + "windows-targets", ] [[package]] @@ -6408,16 +6399,7 @@ version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", + "windows-targets", ] [[package]] @@ -6435,31 +6417,14 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", -] - -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", ] [[package]] @@ -6477,96 +6442,48 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - [[package]] name = "windows_i686_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - [[package]] name = "winit" version = "0.30.13" @@ -6659,9 +6576,9 @@ dependencies = [ [[package]] name = "winresource" -version = "0.1.31" +version = "0.1.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0986a8b1d586b7d3e4fe3d9ea39fb451ae22869dcea4aa109d287a374d866087" +checksum = "a2bc6d36480b829272a5bdabf08e6dca8004761c1da7e35efef800540c66221b" dependencies = [ "toml", "version_check", @@ -6716,7 +6633,7 @@ dependencies = [ "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", "open", - "pollster 0.4.0", + "pollster 1.0.1", "rfd", "serde_json", "windows", @@ -6753,7 +6670,7 @@ version = "0.11.0" name = "wordcraft-doc" version = "0.11.0" dependencies = [ - "base64 0.22.1", + "base64 0.23.1", "proptest", "serde", "serde_json", @@ -6770,7 +6687,7 @@ dependencies = [ "chrono", "image", "log", - "miniz_oxide 0.8.9", + "miniz_oxide 0.9.1", "proptest", "serde_json", "sha1 0.11.0", @@ -6784,7 +6701,7 @@ name = "wordcraft-docx" version = "0.11.0" dependencies = [ "aes", - "base64 0.22.1", + "base64 0.23.1", "cfb", "chrono", "getrandom 0.3.4", @@ -6794,7 +6711,7 @@ dependencies = [ "proptest", "quick-xml 0.41.0", "sha1 0.11.0", - "sha2 0.11.0", + "sha2 0.11.1", "thiserror 2.0.21", "unicode-bidi", "wordcraft-doc", @@ -6939,7 +6856,7 @@ name = "wordcraft-proof" version = "0.11.0" dependencies = [ "log", - "miniz_oxide 0.8.9", + "miniz_oxide 0.9.1", "proptest", "unicode-normalization", ] diff --git a/Cargo.toml b/Cargo.toml index 80b5bcc1cd..165eeb1bbd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -59,7 +59,7 @@ craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0. quick-xml = "0.41" zip = { version = "3", default-features = false, features = ["deflate"] } # zlib streams (compressed metafile pictures in `.doc` drawings); already in the lock as a dependency of other crates. -miniz_oxide = "0.8" +miniz_oxide = "0.9" cfb = "0.15" # Legacy text encodings (GBK/GB18030, Big5, Shift_JIS, EUC-KR, single-byte code pages) for plain # text and CSV recipient lists (#778): encoding_rs ((Apache-2.0 OR MIT) AND BSD-3-Clause) and its @@ -71,7 +71,7 @@ aes = "0.9" sha1 = "0.11" sha2 = "0.11" hmac = "0.13" -base64 = "0.22" +base64 = "0.23" getrandom = "0.3" zeroize = "1" # Unix open flags (O_NONBLOCK for the engine's checked file reads, #3666); already in the lock as a @@ -86,7 +86,7 @@ egui_kittest = "0.37" # A monotonic clock that works on native and in the browser (`performance.now()`). web-time = "1.1" rfd = "0.17" -arboard = { version = "3.6", default-features = false, features = ["wayland-data-control"] } +arboard = { version = "3.7", default-features = false, features = ["wayland-data-control"] } [workspace.lints.rust] unsafe_code = "forbid" diff --git a/apps/wordcraft/Cargo.toml b/apps/wordcraft/Cargo.toml index f1d27bc589..aee3872697 100644 --- a/apps/wordcraft/Cargo.toml +++ b/apps/wordcraft/Cargo.toml @@ -77,7 +77,7 @@ craft-print = { git = "https://github.com/storytold/craft-libs", rev = "17744430 rfd = { workspace = true } # Waits for an `rfd::AsyncFileDialog` on a background thread (the dialogs don't block the UI, #94); # rfd itself already depends on it. -pollster = "0.4" +pollster = "1" # Pictures and copied files on the system clipboard (#45); egui's paste only carries text. Already # in the build through egui-winit, with the same features. arboard = { workspace = true, features = ["image-data"] } diff --git a/crates/proof/Cargo.toml b/crates/proof/Cargo.toml index 234684a9fe..4c1a5b1121 100644 --- a/crates/proof/Cargo.toml +++ b/crates/proof/Cargo.toml @@ -10,7 +10,7 @@ workspace = true [dependencies] log = { workspace = true } -miniz_oxide = "0.8" +miniz_oxide = "0.9" unicode-normalization = "0.1" [dev-dependencies] From a1aa17fc1421ba7e71e74570045dfd0621254ed7 Mon Sep 17 00:00:00 2001 From: skylar Date: Sun, 25 Oct 2026 16:53:12 -0600 Subject: [PATCH 052/199] fix(deps): keep miniz_oxide at 0.8 --- Cargo.lock | 4 ++-- Cargo.toml | 2 +- crates/proof/Cargo.toml | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index cfcbb47b57..065c2c8815 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6687,7 +6687,7 @@ dependencies = [ "chrono", "image", "log", - "miniz_oxide 0.9.1", + "miniz_oxide 0.8.9", "proptest", "serde_json", "sha1 0.11.0", @@ -6856,7 +6856,7 @@ name = "wordcraft-proof" version = "0.11.0" dependencies = [ "log", - "miniz_oxide 0.9.1", + "miniz_oxide 0.8.9", "proptest", "unicode-normalization", ] diff --git a/Cargo.toml b/Cargo.toml index 165eeb1bbd..4e5407a6a5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -59,7 +59,7 @@ craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0. quick-xml = "0.41" zip = { version = "3", default-features = false, features = ["deflate"] } # zlib streams (compressed metafile pictures in `.doc` drawings); already in the lock as a dependency of other crates. -miniz_oxide = "0.9" +miniz_oxide = "0.8" cfb = "0.15" # Legacy text encodings (GBK/GB18030, Big5, Shift_JIS, EUC-KR, single-byte code pages) for plain # text and CSV recipient lists (#778): encoding_rs ((Apache-2.0 OR MIT) AND BSD-3-Clause) and its diff --git a/crates/proof/Cargo.toml b/crates/proof/Cargo.toml index 4c1a5b1121..234684a9fe 100644 --- a/crates/proof/Cargo.toml +++ b/crates/proof/Cargo.toml @@ -10,7 +10,7 @@ workspace = true [dependencies] log = { workspace = true } -miniz_oxide = "0.9" +miniz_oxide = "0.8" unicode-normalization = "0.1" [dev-dependencies] From 68407a34cdb7547353afdfca51ad81c577aa7a2d Mon Sep 17 00:00:00 2001 From: skylar Date: Sun, 25 Oct 2026 16:57:11 -0600 Subject: [PATCH 053/199] chore(deps): bump miniz_oxide to 0.9 --- Cargo.lock | 4 ++-- Cargo.toml | 2 +- crates/proof/Cargo.toml | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 065c2c8815..cfcbb47b57 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6687,7 +6687,7 @@ dependencies = [ "chrono", "image", "log", - "miniz_oxide 0.8.9", + "miniz_oxide 0.9.1", "proptest", "serde_json", "sha1 0.11.0", @@ -6856,7 +6856,7 @@ name = "wordcraft-proof" version = "0.11.0" dependencies = [ "log", - "miniz_oxide 0.8.9", + "miniz_oxide 0.9.1", "proptest", "unicode-normalization", ] diff --git a/Cargo.toml b/Cargo.toml index 4e5407a6a5..165eeb1bbd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -59,7 +59,7 @@ craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0. quick-xml = "0.41" zip = { version = "3", default-features = false, features = ["deflate"] } # zlib streams (compressed metafile pictures in `.doc` drawings); already in the lock as a dependency of other crates. -miniz_oxide = "0.8" +miniz_oxide = "0.9" cfb = "0.15" # Legacy text encodings (GBK/GB18030, Big5, Shift_JIS, EUC-KR, single-byte code pages) for plain # text and CSV recipient lists (#778): encoding_rs ((Apache-2.0 OR MIT) AND BSD-3-Clause) and its diff --git a/crates/proof/Cargo.toml b/crates/proof/Cargo.toml index 234684a9fe..4c1a5b1121 100644 --- a/crates/proof/Cargo.toml +++ b/crates/proof/Cargo.toml @@ -10,7 +10,7 @@ workspace = true [dependencies] log = { workspace = true } -miniz_oxide = "0.8" +miniz_oxide = "0.9" unicode-normalization = "0.1" [dev-dependencies] From b729cf0cb444cb7741dde05ffa19cb4a5faef796 Mon Sep 17 00:00:00 2001 From: skylar Date: Sun, 25 Oct 2026 16:57:14 -0600 Subject: [PATCH 054/199] fix(proof): keep inflate error output out of messages --- crates/proof/src/affix.rs | 4 ++-- crates/proof/src/dict.rs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/proof/src/affix.rs b/crates/proof/src/affix.rs index c2580bca77..7fb7d968ac 100644 --- a/crates/proof/src/affix.rs +++ b/crates/proof/src/affix.rs @@ -877,8 +877,8 @@ impl AffixDict { /// Read data written by [`AffixDict::to_bytes`]. pub fn from_bytes(data: &[u8]) -> Result { - let raw = - miniz_oxide::inflate::decompress_to_vec_with_limit(data, 64 << 20).map_err(|e| format!("affix dictionary: inflate failed: {e:?}"))?; + let raw = miniz_oxide::inflate::decompress_to_vec_with_limit(data, 64 << 20) + .map_err(|e| format!("affix dictionary: inflate failed: {:?}", e.status))?; let words_at = raw.windows(7).position(|w| w == b"\nWORDS\n").ok_or("affix dictionary: no WORDS section")?; let head = std::str::from_utf8(raw.get(..words_at).unwrap_or_default()).map_err(|_| "affix dictionary: bad utf-8 in the rules")?; let mut lines = head.lines(); diff --git a/crates/proof/src/dict.rs b/crates/proof/src/dict.rs index 83a54d4437..789fb6a870 100644 --- a/crates/proof/src/dict.rs +++ b/crates/proof/src/dict.rs @@ -78,7 +78,7 @@ impl Dictionary { /// Deserialize data written by [`Dictionary::to_bytes`]. pub fn from_bytes(data: &[u8]) -> Result { - let raw = miniz_oxide::inflate::decompress_to_vec(data).map_err(|e| format!("hyphenation dictionary: inflate failed: {e:?}"))?; + let raw = miniz_oxide::inflate::decompress_to_vec(data).map_err(|e| format!("hyphenation dictionary: inflate failed: {:?}", e.status))?; let body = raw.strip_prefix(MAGIC).ok_or("hyphenation dictionary: bad header")?; let mut d = Dictionary { words: String::with_capacity(body.len()), offs: vec![0], masks: Vec::new() }; let mut cur: Vec = Vec::new(); From e73ce9c2742a08c37d186f413e0d8744bba100cd Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Thu, 22 Oct 2026 16:15:41 +0100 Subject: [PATCH 055/199] #3642: .doc reader checks the compound file's DIFAT before cfb builds the FAT Finding 63. cfb 0.15 appends a whole sector of FAT entries for every FAT sector the DIFAT lists and validates the FAT only afterwards, so a small file whose DIFAT chain names one sector thousands of times made it allocate ~128x the file size (2048 DIFAT sectors: 133 MB, 370 ms) before failing. docbin now walks the header DIFAT and chain the way cfb does (same trimming, one step per sector) and refuses a file listing more FAT sectors than it has sectors, so the FAT is never larger than the file. Both is_encrypted and read_with_password go through it. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/docbin/src/lib.rs | 68 ++++++++++++++++++++++++++++++++++++-- crates/docbin/src/tests.rs | 35 ++++++++++++++++++++ 2 files changed, 101 insertions(+), 2 deletions(-) diff --git a/crates/docbin/src/lib.rs b/crates/docbin/src/lib.rs index b9179c7d28..b4e97a2193 100644 --- a/crates/docbin/src/lib.rs +++ b/crates/docbin/src/lib.rs @@ -88,10 +88,74 @@ pub enum DocbinError { /// Is this `.doc` password-protected (XOR obfuscation, RC4 or RC4 CryptoAPI)? False for /// anything that is not a readable Word 97-2003 file. pub fn is_encrypted(bytes: &[u8]) -> bool { - let Ok(mut comp) = cfb::CompoundFile::open(std::io::Cursor::new(bytes)) else { return false }; + let Ok(mut comp) = open_container(bytes) else { return false }; stream(&mut comp, "WordDocument").is_some_and(|w| fib::peek_encryption(&w).is_some()) } +/// Open the compound file around a `.doc`, after [`cfb_precheck`]. +fn open_container(bytes: &[u8]) -> Result>, DocbinError> { + cfb_precheck(bytes).map_err(DocbinError::Container)?; + cfb::CompoundFile::open(std::io::Cursor::new(bytes)).map_err(|e| DocbinError::Container(e.to_string())) +} + +/// Check a compound file's FAT layout before `cfb` reads it ([MS-CFB] §2.2 header, §2.5 +/// DIFAT). `cfb` appends a whole sector of FAT entries for every FAT sector the DIFAT lists and +/// only validates the FAT once it is built, so a small header naming one sector over and over +/// makes it allocate gigabytes. Here the DIFAT is walked the way `cfb` walks it (header entries, +/// then the DIFAT chain, at most one step per sector; trailing zero and free entries trimmed +/// the same way) and the file is refused when it lists more FAT sectors than the file has +/// sectors, so the FAT `cfb` builds is never larger than the file. Anything else that is wrong +/// is left for `cfb` to report. +fn cfb_precheck(bytes: &[u8]) -> Result<(), String> { + const MAGIC: [u8; 8] = [0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1]; + const FREE: u32 = 0xFFFF_FFFF; + const END_OF_CHAIN: u32 = 0xFFFF_FFFE; + const HEADER_ENTRIES: usize = 109; + let u16_at = |at: usize| bytes.get(at..at.checked_add(2)?).map(|b| u16::from_le_bytes([b[0], b[1]])); + let u32_at = |at: usize| bytes.get(at..at.checked_add(4)?).map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]])); + if bytes.len() < 512 || bytes.get(..8) != Some(MAGIC.as_slice()) { + return Ok(()); + } + let sector_len: usize = match u16_at(0x1A) { + Some(3) => 512, + Some(4) => 4096, + _ => return Ok(()), + }; + if bytes.len() < sector_len { + return Ok(()); + } + // `cfb` counts the sectors after the header sector, the last one possibly partial. + let num_sectors = bytes.len().div_ceil(sector_len) - 1; + let num_fat_sectors = u32_at(0x2C).unwrap_or(0) as usize; + let mut difat: Vec = (0..HEADER_ENTRIES).filter_map(|i| u32_at(0x4C + i * 4)).collect(); + let mut next = u32_at(0x44).unwrap_or(END_OF_CHAIN); + let mut steps = 0usize; + while next != END_OF_CHAIN && next != FREE { + steps += 1; + // `cfb` refuses a chain sector past the file or seen before; a chain longer than the + // file has sectors must have done one of the two. + if steps > num_sectors || next as usize >= num_sectors { + return Err(format!("the DIFAT chain is longer than the file or leaves it (sector {next})")); + } + let at = (next as usize + 1) * sector_len; + let entries = sector_len / 4 - 1; + difat.extend((0..entries).map(|i| u32_at(at + i * 4).unwrap_or(FREE))); + next = u32_at(at + entries * 4).unwrap_or(END_OF_CHAIN); + } + while difat.len() > HEADER_ENTRIES && difat.len() > num_fat_sectors && difat.last() == Some(&0) { + difat.pop(); + } + while difat.last() == Some(&FREE) { + difat.pop(); + } + // Each listed FAT sector adds a whole sector of entries. A file can't hold more FAT sectors + // than it has sectors, and below that the FAT is no larger than the file. + if difat.len() > num_sectors { + return Err(format!("the DIFAT lists {} FAT sectors in a file of {num_sectors} sectors", difat.len())); + } + Ok(()) +} + /// Read a `.doc` (Word 97-2003 binary) file into a [`Document`]. A password-protected file /// gives [`DocbinError::PasswordRequired`]; see [`read_with_password`]. pub fn read(bytes: &[u8]) -> Result { @@ -102,7 +166,7 @@ pub fn read(bytes: &[u8]) -> Result { /// 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 { - let mut comp = cfb::CompoundFile::open(std::io::Cursor::new(bytes)).map_err(|e| DocbinError::Container(e.to_string()))?; + 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 { return Err(DocbinError::Limit(format!("WordDocument stream is {} bytes", word.len()))); diff --git a/crates/docbin/src/tests.rs b/crates/docbin/src/tests.rs index 0a66c157b3..bbf9199543 100644 --- a/crates/docbin/src/tests.rs +++ b/crates/docbin/src/tests.rs @@ -2392,3 +2392,38 @@ fn paragraph_borders_read_from_both_forms() { assert_eq!((top.style, top.width, top.space), (wordcraft_doc::props::BorderStyle::Single, 1.0, 2.0)); assert_eq!(top.color, Some(wordcraft_doc::Rgb(0xFF, 0, 0))); } + +/// #3642: a small compound file whose DIFAT names one FAT sector thousands of times made `cfb` +/// build a FAT of hundreds of megabytes before checking it. The precheck refuses it first. +#[test] +fn a_difat_repeating_one_fat_sector_is_refused_before_cfb_builds_the_fat() { + const S: usize = 512; + const FREE: u32 = 0xFFFF_FFFF; + let chain = 2048u32; // DIFAT sectors 1..=chain, sector 0 is the "FAT" + let mut f = vec![0u8; S * (chain as usize + 2)]; + f[..8].copy_from_slice(&[0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1]); + f[0x18..0x1A].copy_from_slice(&0x3Eu16.to_le_bytes()); + f[0x1A..0x1C].copy_from_slice(&3u16.to_le_bytes()); + f[0x1C..0x1E].copy_from_slice(&0xFFFEu16.to_le_bytes()); + f[0x1E..0x20].copy_from_slice(&9u16.to_le_bytes()); + f[0x20..0x22].copy_from_slice(&6u16.to_le_bytes()); + f[0x2C..0x30].copy_from_slice(&FREE.to_le_bytes()); // no zero trimming + f[0x38..0x3C].copy_from_slice(&4096u32.to_le_bytes()); + f[0x44..0x48].copy_from_slice(&1u32.to_le_bytes()); + f[0x48..0x4C].copy_from_slice(&chain.to_le_bytes()); + for i in 0..109 { + f[0x4C + i * 4..0x50 + i * 4].copy_from_slice(&0u32.to_le_bytes()); + } + for d in 1..=chain { + let at = (d as usize + 1) * S; + let next = if d == chain { 0xFFFF_FFFE } else { d + 1 }; + f[at + S - 4..at + S].copy_from_slice(&next.to_le_bytes()); + } + let t = std::time::Instant::now(); + assert!(super::cfb_precheck(&f).is_err_and(|e| e.contains("FAT sectors in a file of"))); + assert!(matches!(read(&f), Err(super::DocbinError::Container(e)) if e.contains("FAT sectors in a file of"))); + assert!(!super::is_encrypted(&f)); + assert!(t.elapsed().as_secs() < 2, "refused in {:?}", t.elapsed()); + // A file cfb wrote itself passes. + assert!(super::cfb_precheck(&cfb_file(&[("WordDocument", &[0u8; 4096])])).is_ok()); +} From dc3a999081af0a0cc77358551aca83519f769df6 Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Thu, 22 Oct 2026 16:17:46 +0100 Subject: [PATCH 056/199] #3708: .doc hyperlink target copied once per run, charged to the char budget Finding 75. finish_field cloned the HYPERLINK target into every result character's CharProps and ParaBuild::push then compared it per character, so a long target over a long result was quadratic (250k x 250k: 2.6 s). Equal-formatted result characters now extend the current run; each run start copies the target once and charges its length to the document's character budget, past which the rest of the result is kept unlinked. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/docbin/src/lib.rs | 35 +++++++++++++++++++++++++++++------ crates/docbin/src/tests.rs | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 6 deletions(-) diff --git a/crates/docbin/src/lib.rs b/crates/docbin/src/lib.rs index b4e97a2193..484efb08ef 100644 --- a/crates/docbin/src/lib.rs +++ b/crates/docbin/src/lib.rs @@ -429,6 +429,14 @@ impl ParaBuild { } self.text.push(c); } + + /// Append `c` to the current run, whose properties the caller knows it matches. + fn extend(&mut self, c: char) { + if let Some(r) = self.runs.last_mut() { + r.len += c.len_utf8(); + } + self.text.push(c); + } } /// Walk a CP range, splitting paragraphs at 0x0D/0x07 marks, applying the direct character @@ -554,7 +562,7 @@ fn walk(ctx: &WalkCtx, cp_start: u32, cp_end: u32, pics: &mut media::Pictures) - f.depth -= 1; field = Some(f); } else { - finish_field(&mut pb, f); + finish_field(&mut pb, f, ctx.budget); } } cp = cp.saturating_add(1); @@ -630,17 +638,32 @@ struct FieldBuild { /// Emit a finished field: HYPERLINK keeps its result text as a linked run; every other /// field becomes one inline object with its cached result. -fn finish_field(pb: &mut ParaBuild, f: FieldBuild) { +/// +/// The target is copied once per run of equally formatted result text, not per character, +/// and each copy is charged to the document's character `budget`; once that is spent the +/// rest of the result is kept as plain text (#3708). +fn finish_field(pb: &mut ParaBuild, f: FieldBuild, budget: &std::cell::Cell) { let instr = f.instr.trim().to_string(); let result = f.sep.unwrap_or_default(); - let result_text: String = result.iter().map(|(c, _)| *c).collect(); if let Some(target) = hyperlink_target(&instr) { - for (c, mut props) in result { - props.link = Some(target.clone()); - pb.push(c, props); + let cost = target.len() as u64; + let mut run: Option = None; + for (c, props) in result { + if run.as_ref() == Some(&props) { + pb.extend(c); + continue; + } + let mut linked = props.clone(); + if budget.get() >= cost { + budget.set(budget.get() - cost); + linked.link = Some(target.clone()); + } + pb.push(c, linked); + run = Some(props); } 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() }); } diff --git a/crates/docbin/src/tests.rs b/crates/docbin/src/tests.rs index bbf9199543..cf42176fe4 100644 --- a/crates/docbin/src/tests.rs +++ b/crates/docbin/src/tests.rs @@ -2427,3 +2427,39 @@ fn a_difat_repeating_one_fat_sector_is_refused_before_cfb_builds_the_fat() { // A file cfb wrote itself passes. assert!(super::cfb_precheck(&cfb_file(&[("WordDocument", &[0u8; 4096])])).is_ok()); } + +/// #3708: a long HYPERLINK target over a long result was copied (and compared) once per result +/// character. It is now copied once per run. +#[test] +fn a_long_hyperlink_over_a_long_result_is_one_linked_run() { + let n = 250_000; + let target = "x".repeat(n); + let text: &'static str = Box::leak(format!("\u{13}HYPERLINK \"http://{target}\"\u{14}{}\u{15}\r", "y".repeat(n)).into_boxed_str()); + let spec = structured_spec(text, text.chars().count() as u32); + let t = std::time::Instant::now(); + let doc = read(&struct_doc(&spec)).expect("opens"); + // Copying the target per character took ~2.5 s here; once per run takes milliseconds. + assert!(t.elapsed().as_millis() < 1000, "opened in {:?}", t.elapsed()); + let p0 = doc.body.first().and_then(|b| b.as_para()).expect("para"); + let linked: Vec<_> = p0.runs.iter().filter(|r| r.props.link.is_some()).collect(); + assert_eq!(linked.len(), 1); + assert_eq!(linked.first().map(|r| r.len), Some(n)); +} + +/// #3708: a result whose formatting changes every character pays for a target copy per run +/// out of the character budget; past it the text stays, unlinked. +#[test] +fn hyperlink_target_copies_are_charged_to_the_char_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 f = super::FieldBuild { depth: 1, instr: format!("HYPERLINK \"{}\"", "u".repeat(100)), sep: Some(result) }; + let mut pb = super::ParaBuild::default(); + let budget = std::cell::Cell::new(10_000); + super::finish_field(&mut pb, f, &budget); + assert_eq!(pb.text.len(), 1000, "all text kept"); + let linked: usize = pb.runs.iter().filter_map(|r| r.props.link.as_ref()).map(|l| l.len()).sum(); + assert!(linked <= 10_000, "linked bytes {linked}"); + assert_eq!(pb.runs.iter().filter(|r| r.props.link.is_some()).count(), 100); + assert_eq!(budget.get(), 0); +} From 36e43fa6723ef06e1754210a6ec36108be7e7fb1 Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Thu, 22 Oct 2026 16:20:14 +0100 Subject: [PATCH 057/199] #3692: .doc piece Prm resolved once per formatting change, under a work budget Finding 64. push_formatted re-ran char_props and apply_prm for every character, so a single piece whose Prm names a 63 KB Prc over 100k characters took 15 s. walk now keeps the last resolved CharProps and reuses it while the CHPX slice, the piece's raw Prm and the paragraph style are unchanged. Prm1 grpprl bytes interpreted are charged to a document-wide MAX_PRM_WORK (64 MiB) budget, so files alternating pieces per character can't multiply the work; past it the Prm is skipped. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/docbin/src/lib.rs | 67 ++++++++++++++++++++++++++++---------- crates/docbin/src/piece.rs | 6 ++++ crates/docbin/src/tests.rs | 39 ++++++++++++++++++++++ 3 files changed, 94 insertions(+), 18 deletions(-) diff --git a/crates/docbin/src/lib.rs b/crates/docbin/src/lib.rs index 484efb08ef..2ae5a2965a 100644 --- a/crates/docbin/src/lib.rs +++ b/crates/docbin/src/lib.rs @@ -49,6 +49,9 @@ 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; +/// Bytes of piece Prm grpprls (Prcs, up to 64 KB each) interpreted per document; past it the +/// remaining pieces' Prm formatting is skipped (#3692). +const MAX_PRM_WORK: u64 = 64 << 20; /// `sprmCPicLocation`: the fc of a picture in the Data stream. const C_PIC_LOCATION: u16 = 0x6A03; /// `sprmCSymbol`: the record font and character of a symbol character ([MS-DOC] §2.6.1); the @@ -239,6 +242,7 @@ pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result) -> Result { /// 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, + /// Prm grpprl bytes still to interpret ([`MAX_PRM_WORK`]). + prm_work: &'a std::cell::Cell, } /// State while assembling one paragraph from per-CP formatting. @@ -448,7 +455,7 @@ impl ParaBuild { /// The range is clamped to the last piece's CP, and unreadable stretches (gaps, truncated /// pieces) are skipped a whole piece at a time, so the work is bounded by the text that /// really exists, not by the CP numbers a file claims. -fn walk(ctx: &WalkCtx, cp_start: u32, cp_end: u32, pics: &mut media::Pictures) -> Vec { +fn walk<'a>(ctx: &WalkCtx<'a>, cp_start: u32, cp_end: u32, pics: &mut media::Pictures) -> Vec { let cp_end = cp_end.min(ctx.pieces.cp_end()); let mut out = Vec::new(); let mut pb = ParaBuild::default(); @@ -457,6 +464,7 @@ fn walk(ctx: &WalkCtx, cp_start: u32, cp_end: u32, pics: &mut media::Pictures) - // Field assembly state: instruction characters are swallowed, the result is kept until // the end character, then emitted as one object (hyperlinks keep their result text). let mut field: Option = None; + let mut memo: Option> = None; while cp < cp_end { // Bookmark boundaries at this CP (main-document walks only; subdocument walks get // an empty table and `cp` never matches the main-document CPs). @@ -604,16 +612,16 @@ fn walk(ctx: &WalkCtx, cp_start: u32, cp_end: u32, pics: &mut media::Pictures) - pb.objects.push(InlineObject::NoteRef { kind: *kind, id: *id, custom: String::new() }); } } - '\t' => push_formatted(&mut pb, cp, '\t', ctx, field.as_mut()), - '\u{B}' => push_formatted(&mut pb, cp, '\n', ctx, field.as_mut()), - '\u{C}' => push_formatted(&mut pb, cp, '\u{C}', ctx, field.as_mut()), - '\u{E}' => push_formatted(&mut pb, cp, '\u{E}', ctx, field.as_mut()), - '\u{1E}' => push_formatted(&mut pb, cp, '\u{2011}', ctx, field.as_mut()), - '\u{1F}' => push_formatted(&mut pb, cp, '\u{AD}', ctx, field.as_mut()), + '\t' => push_formatted(&mut pb, cp, '\t', ctx, field.as_mut(), &mut memo), + '\u{B}' => push_formatted(&mut pb, cp, '\n', ctx, field.as_mut(), &mut memo), + '\u{C}' => push_formatted(&mut pb, cp, '\u{C}', ctx, field.as_mut(), &mut memo), + '\u{E}' => push_formatted(&mut pb, cp, '\u{E}', ctx, field.as_mut(), &mut memo), + '\u{1E}' => push_formatted(&mut pb, cp, '\u{2011}', ctx, field.as_mut(), &mut memo), + '\u{1F}' => push_formatted(&mut pb, cp, '\u{AD}', ctx, field.as_mut(), &mut memo), // Object anchors, comment references and other special characters are dropped // (or handled by later passes). c if (c as u32) < 0x20 => {} - c => push_formatted(&mut pb, cp, c, ctx, field.as_mut()), + c => push_formatted(&mut pb, cp, c, ctx, field.as_mut(), &mut memo), } cp = cp.saturating_add(units); } @@ -789,19 +797,28 @@ fn num_of(ilfo: i32) -> Option { /// plus the piece's Prm, over the paragraph style's base character props. Inside a field, /// the character is recorded into the instruction or the cached result instead of (or as /// well as) the visible text. -fn push_formatted(pb: &mut ParaBuild, cp: u32, c: char, ctx: &WalkCtx, field: Option<&mut FieldBuild>) { +fn push_formatted<'a>(pb: &mut ParaBuild, cp: u32, c: char, ctx: &WalkCtx<'a>, field: Option<&mut FieldBuild>, memo: &mut Option>) { let fc = ctx.pieces.fc_of_cp(cp).unwrap_or(0); let grpprl = ctx.chpx_bins.chpx(ctx.word, fc); - let default; - let base = match pb.props.style.as_deref().and_then(|id| ctx.sheet.get(id)) { - Some(st) => &st.chr, + let prm = ctx.pieces.prm_of(cp); + let style = pb.props.style.as_deref(); + let mut props = match memo.as_ref().filter(|m| std::ptr::eq(m.chpx, grpprl) && m.prm == prm && m.style.as_deref() == style) { + Some(m) => m.props.clone(), None => { - default = CharProps::default(); - &default + let default; + let base = match style.and_then(|id| ctx.sheet.get(id)) { + Some(st) => &st.chr, + None => { + default = CharProps::default(); + &default + } + }; + let mut props = char_props(grpprl, ctx.fonts, base); + apply_prm(&mut props, ctx.pieces.piece_prm(cp), ctx.fonts, base, ctx.prm_work); + *memo = Some(FmtMemo { chpx: grpprl, prm, style: style.map(str::to_owned), props: props.clone() }); + props } }; - let mut props = char_props(grpprl, ctx.fonts, base); - apply_prm(&mut props, ctx.pieces.piece_prm(cp), ctx.fonts, base); // A symbol character (Insert › Symbol) is stored as the placeholder U+0028 with // sprmCSymbol carrying the font and the character code ([MS-DOC] §2.6.1). let mut c = c; @@ -826,8 +843,20 @@ fn push_formatted(pb: &mut ParaBuild, cp: u32, c: char, ctx: &WalkCtx, field: Op } } -/// Apply a piece's Prm (Prm0 short sprm or Prm1 grpprl) to character properties. -fn apply_prm(props: &mut CharProps, prm: PrmRef<'_>, fonts: &[String], base: &CharProps) { +/// The last character formatting resolved, reused while the CHPX, the piece's Prm and the +/// paragraph style stay the same, so a Prm grpprl (up to 64 KB) is interpreted once per +/// formatting change, not once per character (#3692). +struct FmtMemo<'a> { + /// The CHPX grpprl, compared by address: it is a slice of the WordDocument stream. + chpx: &'a [u8], + prm: u16, + style: Option, + props: CharProps, +} + +/// Apply a piece's Prm (Prm0 short sprm or Prm1 grpprl) to character properties. A Prm1 +/// grpprl is charged to `work`; once that is spent the grpprl is skipped. +fn apply_prm(props: &mut CharProps, prm: PrmRef<'_>, fonts: &[String], base: &CharProps, work: &std::cell::Cell) { match prm { PrmRef::None => {} PrmRef::Sprm0 { isprm, val } => { @@ -840,6 +869,8 @@ fn apply_prm(props: &mut CharProps, prm: PrmRef<'_>, fonts: &[String], base: &Ch } } PrmRef::Grpprl(Some(g)) => { + let Some(left) = work.get().checked_sub(g.len() as u64) else { return }; + work.set(left); for prl in sprm::iter(g) { if prl.sgc() == 2 { fmt::apply_char(props, &prl, base, fonts); diff --git a/crates/docbin/src/piece.rs b/crates/docbin/src/piece.rs index a7274c9d9d..d52f968b7a 100644 --- a/crates/docbin/src/piece.rs +++ b/crates/docbin/src/piece.rs @@ -117,6 +117,12 @@ impl PieceTable { } } + /// The raw Prm of the piece holding `cp` (0, no properties, when there is none): two CPs + /// with the same value get the same [`PrmRef`]. + pub(crate) fn prm_of(&self, cp: u32) -> u16 { + self.piece_of(cp).map_or(0, |p| p.prm) + } + fn piece_of(&self, cp: u32) -> Option<&Pcd> { let i = self.cps.partition_point(|&c| c <= cp).checked_sub(1)?; if i >= self.pieces.len() { diff --git a/crates/docbin/src/tests.rs b/crates/docbin/src/tests.rs index cf42176fe4..c211b0b388 100644 --- a/crates/docbin/src/tests.rs +++ b/crates/docbin/src/tests.rs @@ -2463,3 +2463,42 @@ fn hyperlink_target_copies_are_charged_to_the_char_budget() { assert_eq!(pb.runs.iter().filter(|r| r.props.link.is_some()).count(), 100); assert_eq!(budget.get(), 0); } + +/// #3692: a piece's Prm grpprl (a Prc of up to 64 KB) was re-interpreted for every character. +/// It is now resolved once per formatting change. +#[test] +fn a_large_prc_is_interpreted_once_per_formatting_change() { + let prc = grpprl(&vec![(0x0835u16, &[0x01u8][..]); 21_000]); + let text: &'static str = Box::leak(format!("{}\r", "a".repeat(100_000)).into_boxed_str()); + let spec = RichSpec { + text, + styles: vec![(1, 0x0FFF, "Normal", Vec::new(), Vec::new())], + fonts: vec!["Times New Roman"], + chpx_runs: vec![(0, Vec::new())], + papx_entries: vec![(0, 0, Vec::new())], + }; + let bytes = rich_doc_with(&spec, &RichExtra { prcs: vec![prc], prm: 0x0001, ..Default::default() }); + let t = std::time::Instant::now(); + let doc = read(&bytes).expect("opens"); + assert!(t.elapsed().as_millis() < 1000, "opened in {:?}", t.elapsed()); + let p = para0(&doc); + assert_eq!(p.runs.len(), 1); + assert_eq!(p.runs.first().and_then(|r| r.props.bold), Some(true)); +} + +/// #3692: Prm grpprls are charged to a document-wide work budget; once it is spent the Prm +/// is skipped (the text keeps its CHPX formatting). +#[test] +fn prm_grpprls_past_the_work_budget_are_skipped() { + use wordcraft_doc::props::CharProps; + let g = grpprl(&[(0x0835, &[0x01])]); + let work = std::cell::Cell::new(g.len() as u64 + 1); + let base = CharProps::default(); + let mut first = CharProps::default(); + super::apply_prm(&mut first, crate::piece::PrmRef::Grpprl(Some(&g)), &[], &base, &work); + assert_eq!(first.bold, Some(true)); + let mut second = CharProps::default(); + super::apply_prm(&mut second, crate::piece::PrmRef::Grpprl(Some(&g)), &[], &base, &work); + assert_eq!(second.bold, None); + assert_eq!(work.get(), 1); +} From 878bac84ba2955934ba86df6177767a4519b2519 Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Thu, 22 Oct 2026 16:22:09 +0100 Subject: [PATCH 058/199] #3709: .doc inline-picture alt text copies charged to a work budget Finding 68. Pictures::get cloned the cached Inline (alt text up to 4096 UTF-16 units) for every '\x01' anchor, and the char budget allows ~64M anchors, so one picture referenced repeatedly could copy hundreds of GB. get now returns a reference; walk copies the alt text per anchor only while the document-wide work budget lasts, then leaves it empty (the picture itself is still placed). The Prm work budget from #3692 becomes one shared MAX_WORK (64 MiB) for repeated work beyond the text: Prm grpprls interpreted, hyperlink targets (#3708, moved off the char budget so long links in small files are never dropped) and alt text copied. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/docbin/src/lib.rs | 42 ++++++++++++++++++------------ crates/docbin/src/media.rs | 17 ++++++------ crates/docbin/src/tests.rs | 12 ++++----- crates/docbin/src/tests/drawing.rs | 20 ++++++++++++++ 4 files changed, 60 insertions(+), 31 deletions(-) diff --git a/crates/docbin/src/lib.rs b/crates/docbin/src/lib.rs index 2ae5a2965a..2b18591321 100644 --- a/crates/docbin/src/lib.rs +++ b/crates/docbin/src/lib.rs @@ -49,9 +49,10 @@ 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; -/// Bytes of piece Prm grpprls (Prcs, up to 64 KB each) interpreted per document; past it the -/// remaining pieces' Prm formatting is skipped (#3692). -const MAX_PRM_WORK: u64 = 64 << 20; +/// Bytes of repeated work per document beyond the text itself: piece Prm grpprls (Prcs, up +/// to 64 KB each) interpreted (#3692), hyperlink targets (#3708) and picture alt text (#3709) +/// copied into every run or anchor. Past it Prms are skipped and copies left empty. +const MAX_WORK: u64 = 64 << 20; /// `sprmCPicLocation`: the fc of a picture in the Data stream. const C_PIC_LOCATION: u16 = 0x6A03; /// `sprmCSymbol`: the record font and character of a symbol character ([MS-DOC] §2.6.1); the @@ -242,7 +243,7 @@ pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result) -> Result { /// 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, - /// Prm grpprl bytes still to interpret ([`MAX_PRM_WORK`]). - prm_work: &'a std::cell::Cell, + /// Repeated-work bytes left ([`MAX_WORK`]). + work: &'a std::cell::Cell, } /// State while assembling one paragraph from per-CP formatting. @@ -570,7 +571,7 @@ fn walk<'a>(ctx: &WalkCtx<'a>, cp_start: u32, cp_end: u32, pics: &mut media::Pic f.depth -= 1; field = Some(f); } else { - finish_field(&mut pb, f, ctx.budget); + finish_field(&mut pb, f, ctx.work); } } cp = cp.saturating_add(1); @@ -582,12 +583,21 @@ fn walk<'a>(ctx: &WalkCtx<'a>, cp_start: u32, cp_end: u32, pics: &mut media::Pic let fc_pic = pic_location(ctx.chpx_bins.chpx(ctx.word, fc)); match fc_pic.and_then(|at| pics.get(ctx.data, at)) { Some(p) => { + // Every anchor of a picture gets its own copy of the alt text; the + // copies are charged to the work budget (#3709). + let alt = match ctx.work.get().checked_sub(p.alt.len() as u64) { + Some(left) => { + ctx.work.set(left); + p.alt.clone() + } + None => String::new(), + }; pb.push('\u{FFFC}', CharProps::default()); pb.objects.push(InlineObject::Image { - media: p.key, + media: p.key.clone(), w: p.w, h: p.h, - alt: p.alt, + alt, float: Default::default(), crop: p.crop, ole: None, @@ -648,9 +658,9 @@ struct FieldBuild { /// field becomes one inline object with its cached result. /// /// The target is copied once per run of equally formatted result text, not per character, -/// and each copy is charged to the document's character `budget`; once that is spent the -/// rest of the result is kept as plain text (#3708). -fn finish_field(pb: &mut ParaBuild, f: FieldBuild, budget: &std::cell::Cell) { +/// and each copy is charged to `work` ([`MAX_WORK`]); once that is spent the rest of the +/// result is kept as plain text (#3708). +fn finish_field(pb: &mut ParaBuild, f: FieldBuild, work: &std::cell::Cell) { let instr = f.instr.trim().to_string(); let result = f.sep.unwrap_or_default(); if let Some(target) = hyperlink_target(&instr) { @@ -662,8 +672,8 @@ fn finish_field(pb: &mut ParaBuild, f: FieldBuild, budget: &std::cell::Cell continue; } let mut linked = props.clone(); - if budget.get() >= cost { - budget.set(budget.get() - cost); + if let Some(left) = work.get().checked_sub(cost) { + work.set(left); linked.link = Some(target.clone()); } pb.push(c, linked); @@ -814,7 +824,7 @@ fn push_formatted<'a>(pb: &mut ParaBuild, cp: u32, c: char, ctx: &WalkCtx<'a>, f } }; let mut props = char_props(grpprl, ctx.fonts, base); - apply_prm(&mut props, ctx.pieces.piece_prm(cp), ctx.fonts, base, ctx.prm_work); + apply_prm(&mut props, ctx.pieces.piece_prm(cp), ctx.fonts, base, ctx.work); *memo = Some(FmtMemo { chpx: grpprl, prm, style: style.map(str::to_owned), props: props.clone() }); props } diff --git a/crates/docbin/src/media.rs b/crates/docbin/src/media.rs index 52e0aa9109..314f70eae5 100644 --- a/crates/docbin/src/media.rs +++ b/crates/docbin/src/media.rs @@ -67,16 +67,15 @@ impl Pictures { /// The inline picture at `fc_pic` in the Data stream, copying its bytes the first time. /// `None` when the header is malformed, the picture can't be read, or a picture /// count/byte cap is reached. - pub(crate) fn get(&mut self, data: &[u8], fc_pic: u32) -> Option { - if let Some(hit) = self.by_fc.get(&fc_pic) { - return hit.clone(); + pub(crate) fn get(&mut self, data: &[u8], fc_pic: u32) -> Option<&Inline> { + if !self.by_fc.contains_key(&fc_pic) { + if self.by_fc.len().saturating_add(self.by_bse.len()) >= MAX_PICTURES { + return None; + } + let pic = self.read(data, fc_pic); + self.by_fc.insert(fc_pic, pic); } - if self.by_fc.len().saturating_add(self.by_bse.len()) >= MAX_PICTURES { - return None; - } - let pic = self.read(data, fc_pic); - self.by_fc.insert(fc_pic, pic.clone()); - pic + self.by_fc.get(&fc_pic)?.as_ref() } /// Read the inline picture at `fc_pic`: nothing is decoded or copied beyond what is left of diff --git a/crates/docbin/src/tests.rs b/crates/docbin/src/tests.rs index c211b0b388..afd29c5085 100644 --- a/crates/docbin/src/tests.rs +++ b/crates/docbin/src/tests.rs @@ -1732,9 +1732,9 @@ fn picture_bytes_are_capped_per_document() { let data: Vec = [a.clone(), b].concat(); let mut pics = crate::media::Pictures::default(); pics.limit = 1000; - let first = pics.get(&data, 0).expect("first fits"); + let first = pics.get(&data, 0).cloned().expect("first fits"); // Same location again: no new bytes. - assert_eq!(pics.get(&data, 0).map(|p| p.key), Some(first.key.clone())); + assert_eq!(pics.get(&data, 0).map(|p| p.key.clone()), Some(first.key.clone())); // A second distinct picture would exceed the cap. assert!(pics.get(&data, a.len() as u32).is_none()); assert_eq!(pics.media.len(), 1); @@ -1754,8 +1754,8 @@ fn rejected_inline_pictures_are_not_read_again() { assert!(pics.get(&small, 0).is_none()); // The rejected picture took nothing off the cap, and anchors sharing a picture share it. let at = big.len() as u32; - let fits = pics.get(&data, at).expect("fits"); - assert_eq!(pics.get(&data, at).map(|p| p.key), Some(fits.key)); + let fits = pics.get(&data, at).cloned().expect("fits"); + assert_eq!(pics.get(&data, at).map(|p| p.key.clone()), Some(fits.key)); assert_eq!(pics.media.len(), 1); // Filling the cap exactly works; after that, a place never tried before isn't read. let rest = picf_with_png(700); @@ -2447,9 +2447,9 @@ fn a_long_hyperlink_over_a_long_result_is_one_linked_run() { } /// #3708: a result whose formatting changes every character pays for a target copy per run -/// out of the character budget; past it the text stays, unlinked. +/// out of the work budget; past it the text stays, unlinked. #[test] -fn hyperlink_target_copies_are_charged_to_the_char_budget() { +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(); diff --git a/crates/docbin/src/tests/drawing.rs b/crates/docbin/src/tests/drawing.rs index 4007408e11..fc453d54cf 100644 --- a/crates/docbin/src/tests/drawing.rs +++ b/crates/docbin/src/tests/drawing.rs @@ -332,3 +332,23 @@ fn dib_conversion_caps_header_and_encoded_bytes() { assert!(normal.bytes.len() > bmp_size && normal.bytes.len() <= 512); assert_eq!(image::load_from_memory(&normal.bytes).unwrap().width(), 1); } + +/// #3709: every anchor of one inline picture got its own copy of the alt text (up to 4096 +/// UTF-16 units). Copies are charged to the document's work budget; past it alt is empty. +#[test] +fn alt_text_copies_across_many_anchors_are_bounded() { + let alt: String = "€".repeat(4096); // 12 KB as UTF-8 + let shape = sp(&[fsp(75, 1025, 0xA00), fopt(&[(0x0104, 1)], &[(0x0381, wz(&alt))])]); + let data = picf_shape(&[shape, fbse_png(&tiny_png())].concat()); + let text: &'static str = Box::leak(format!("{}\r", "\u{1}".repeat(8000)).into_boxed_str()); + let ccp = text.chars().count() as u32; + let spec = StructSpec { text, data_stream: data, chpx_runs: vec![(0, grpprl(&[(0x6A03, &0u32.to_le_bytes())]))], ..structured_spec(text, ccp) }; + let doc = read(&struct_doc(&spec)).expect("opens"); + let p0 = doc.body.first().and_then(|b| b.as_para()).expect("para"); + let alts: Vec<&str> = p0.objects.iter().filter_map(|o| if let InlineObject::Image { alt, .. } = o { Some(alt.as_str()) } else { None }).collect(); + assert_eq!(alts.len(), 8000, "every anchor keeps its picture"); + assert_eq!(alts.first().copied(), Some(alt.as_str())); + let total: usize = alts.iter().map(|a| a.len()).sum(); + assert!(total <= 64 << 20, "alt bytes {total}"); + assert_eq!(alts.last().copied(), Some("")); +} From 0ecdf2ff2dc28ea503cd843330a84117f08fd31a Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Thu, 22 Oct 2026 16:24:22 +0100 Subject: [PATCH 059/199] #3710: .doc floating shapes built once per spid; anchor copies charged to the work budget Finding 49. drawing::anchors built a fresh object for every PlcSpa entry, so one group of 1000 members named by 5000 entries built 5M children (1.4 s, ~1 GB) with the per-group child cap resetting each time. Word anchors each shape once, so a repeated spid is now skipped: every node of the shape tree is built at most once, bounded by the shared OfficeArt record budget. walk also cloned each floating object every time a story holding its anchor was walked; each copy is now charged by size (a group with all its members) to the document-wide MAX_WORK budget. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/docbin/src/drawing.rs | 6 ++++++ crates/docbin/src/lib.rs | 14 ++++++++++++-- crates/docbin/src/tests/drawing.rs | 24 ++++++++++++++++++++++++ 3 files changed, 42 insertions(+), 2 deletions(-) diff --git a/crates/docbin/src/drawing.rs b/crates/docbin/src/drawing.rs index 461d6b372c..f5c02699f3 100644 --- a/crates/docbin/src/drawing.rs +++ b/crates/docbin/src/drawing.rs @@ -81,12 +81,18 @@ pub(crate) fn anchors( ) -> Vec<(u32, InlineObject)> { let Some(pair) = pair else { return Vec::new() }; let mut out = Vec::new(); + let mut seen = std::collections::HashSet::new(); for (cp, spa) in plc(table, pair, CB_SPA) { let i32_at = |o: usize| spa.get(o..o + 4).and_then(|b| b.try_into().ok()).map(i32::from_le_bytes).unwrap_or(0); let spid = i32_at(0) as u32; let [xl, yt, xr, yb] = [4, 8, 12, 16].map(i32_at); let flags = spa.get(20..22).map(|b| u16::from_le_bytes([b[0], b[1]])).unwrap_or(0); let Some(node) = shapes.get(&spid) else { continue }; + // Word anchors each shape once. A repeated spid would build another copy of the shape, + // and of every member of a group, per anchor (#3710). + if !seen.insert(spid) { + continue; + } let tw = |v: i64| (v as f32 / 20.0).clamp(-2.0 * MAX_PT, 2.0 * MAX_PT); let (mut x, mut y) = (tw(xl as i64), tw(yt as i64)); let (mut w, mut h) = (tw(xr as i64 - xl as i64).clamp(0.0, MAX_PT), tw(yb as i64 - yt as i64).clamp(0.0, MAX_PT)); diff --git a/crates/docbin/src/lib.rs b/crates/docbin/src/lib.rs index 2b18591321..4c6c0c4419 100644 --- a/crates/docbin/src/lib.rs +++ b/crates/docbin/src/lib.rs @@ -50,8 +50,9 @@ const MAX_PARTS: usize = 50_000; /// WordDocument stream (see [`read`]); this caps even a maximal stream. const MAX_TOTAL_CHARS: u64 = 64 << 20; /// Bytes of repeated work per document beyond the text itself: piece Prm grpprls (Prcs, up -/// to 64 KB each) interpreted (#3692), hyperlink targets (#3708) and picture alt text (#3709) -/// copied into every run or anchor. Past it Prms are skipped and copies left empty. +/// 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, +/// copied strings left empty and floating objects dropped. const MAX_WORK: u64 = 64 << 20; /// `sprmCPicLocation`: the fc of a picture in the Data stream. const C_PIC_LOCATION: u16 = 0x6A03; @@ -609,7 +610,16 @@ fn walk<'a>(ctx: &WalkCtx<'a>, cp_start: u32, cp_end: u32, pics: &mut media::Pic '\u{8}' => { // Drawn object anchor: the floating shape or picture anchored at this CP. let at = ctx.drawn.partition_point(|(c, _)| *c < cp); + // Each copy (a story can be walked more than once) is charged to the work budget + // by size, a group with all its members (#3710). for (_, obj) in ctx.drawn.get(at..).unwrap_or_default().iter().take_while(|(c, _)| *c == cp) { + let parts = match obj { + InlineObject::Group { children, .. } => children.len().saturating_add(1), + _ => 1, + }; + let cost = (parts as u64).saturating_mul(std::mem::size_of::() as u64); + let Some(left) = ctx.work.get().checked_sub(cost) else { continue }; + ctx.work.set(left); pb.push('\u{FFFC}', CharProps::default()); pb.objects.push(obj.clone()); } diff --git a/crates/docbin/src/tests/drawing.rs b/crates/docbin/src/tests/drawing.rs index fc453d54cf..49851883ba 100644 --- a/crates/docbin/src/tests/drawing.rs +++ b/crates/docbin/src/tests/drawing.rs @@ -352,3 +352,27 @@ fn alt_text_copies_across_many_anchors_are_bounded() { assert!(total <= 64 << 20, "alt bytes {total}"); assert_eq!(alts.last().copied(), Some("")); } + +/// #3710: every PlcSpa entry naming a group built another copy of all its members. A shape is +/// anchored once; repeated spids are skipped. +#[test] +fn a_group_named_by_many_anchors_is_built_once() { + let rect = |l: i32| rec(0, 0, 0xF00F, &[l, 0, l + 10, 10].iter().flat_map(|v| v.to_le_bytes()).collect::>()); + let head = sp(&[fsp(0, 3000, 0x201), rec(1, 0, 0xF009, &[0i32, 0, 10_000, 10].iter().flat_map(|v| v.to_le_bytes()).collect::>())]); + let members: Vec> = (0..1000).map(|i| sp(&[fsp(1, 4000 + i as u32, 0xA02), rect(i * 10)])).collect(); + let group = container(0xF003, &[vec![head], members].concat()); + let art = art_content(&[], &[group]); + let n = 5000u32; + let cps: Vec = (1..=n + 1).collect(); + let spas: Vec = (0..n).flat_map(|_| spa(3000, [0, 0, 1440, 1440], spa_flags(0, 0, 3, false))).collect(); + let text = "A\u{8}\u{8}\u{8}B\r"; + let ccp = text.chars().count() as u32; + let spec = StructSpec { text, extra_pairs: vec![(40, plc(&cps, &spas)), (50, art)], ..structured_spec(text, ccp) }; + let t = std::time::Instant::now(); + let doc = read(&struct_doc(&spec)).expect("opens"); + assert!(t.elapsed().as_millis() < 1000, "opened in {:?}", t.elapsed()); + let p0 = doc.body.first().and_then(|b| b.as_para()).expect("para"); + let groups: Vec = + p0.objects.iter().filter_map(|o| if let InlineObject::Group { children, .. } = o { Some(children.len()) } else { None }).collect(); + assert_eq!(groups, [1000]); +} From b2f1197f847fcec3a98a49c65f52aa424066f7c3 Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Thu, 22 Oct 2026 16:27:52 +0100 Subject: [PATCH 060/199] #3711: .doc picture decoding has a document-wide work budget; aliased delay-stream BLIPs decode once Finding 60 (also #3712). Metafile inflation and DIB decoding had per-picture caps only: thousands of BLIP store entries aliasing one delay-stream record each inflated and copied it again, failed inflations cost nothing, and an RLE DIB tiny on disk decoded to its full declared w x h x 4 pixels. Now every inflate (its declared size, success or not) and every DIB decode (its declared pixels) is charged to a 512 MB per-document decode budget before it runs; past it metafiles are skipped and DIBs stay BMP placeholders. Store entries naming the same (foDelay, end) record share one decode and one media key. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/docbin/src/media.rs | 38 +++++++++-- crates/docbin/src/officeart.rs | 45 ++++++++++--- crates/docbin/src/tests/drawing.rs | 100 +++++++++++++++++++++++++++-- 3 files changed, 159 insertions(+), 24 deletions(-) diff --git a/crates/docbin/src/media.rs b/crates/docbin/src/media.rs index 314f70eae5..c4314011ea 100644 --- a/crates/docbin/src/media.rs +++ b/crates/docbin/src/media.rs @@ -17,6 +17,10 @@ const OFF_PICMID: usize = 28; const MAX_PICTURES: usize = 5_000; /// Most picture bytes one document copies out of the Data stream, all pictures together. pub(crate) const MAX_MEDIA_BYTES: usize = 256 << 20; +/// Most bytes one document decodes pictures into (metafiles inflated, DIB pixels), charged +/// per attempt whether it succeeds or not: retained media is capped separately, by +/// `MAX_MEDIA_BYTES` (#3711, #3712). +const MAX_DECODE_WORK: usize = 512 << 20; /// The document's pictures, keyed by their location in the Data stream: anchors that share a /// `fc_pic` share one media entry (or one failure), and the total bytes copied are capped, so a @@ -28,11 +32,16 @@ pub(crate) struct Pictures { by_fc: HashMap>, /// BLIP store index → media key (`None`: unreadable, not retried). by_bse: HashMap>, + /// (foDelay, end) of a BLIP record outside the store → media key: store entries that alias + /// one record share one decode (`None`: failed, not retried). + by_delay: HashMap<(usize, usize), Option>, /// The media entries, merged into the document at the end. pub(crate) media: BTreeMap>>, total: usize, /// The byte cap (`MAX_MEDIA_BYTES`; smaller in tests). pub(crate) limit: usize, + /// Decode work left (`MAX_DECODE_WORK`; smaller in tests). + pub(crate) work: usize, /// Anchors whose picture was dropped (only the first few are logged). dropped: usize, /// Pictures kept as placeholders (formats we can't draw). @@ -44,9 +53,11 @@ impl Default for Pictures { Pictures { by_fc: HashMap::new(), by_bse: HashMap::new(), + by_delay: HashMap::new(), media: BTreeMap::new(), total: 0, limit: MAX_MEDIA_BYTES, + work: MAX_DECODE_WORK, dropped: 0, placeholders: 0, } @@ -85,7 +96,7 @@ impl Pictures { if room == 0 { return None; } - match locate(data, fc_pic, room)? { + match locate(data, fc_pic, room, &mut self.work)? { Found::Range(pic) => { let bytes = data.get(pic.range.clone()).filter(|b| b.len() <= room)?.to_vec(); let key = self.insert(bytes, pic.ext, false)?; @@ -108,12 +119,25 @@ impl Pictures { return None; } let fbse = bstore.get((pib as usize).checked_sub(1)?)?; - let room = self.limit.saturating_sub(self.total); - let key = officeart::fbse_blip(fbse, delay, room).and_then(|b| self.insert(b.bytes, b.ext, b.placeholder)); + let key = self.decode_bse(fbse, delay); self.by_bse.insert(pib, key.clone()); key } + /// Decode a store entry's BLIP, once per delay-stream record however many entries name it. + fn decode_bse(&mut self, fbse: &[u8], delay: &[u8]) -> Option { + let (rec, at) = officeart::fbse_record(fbse, delay)?; + if let Some(hit) = at.and_then(|at| self.by_delay.get(&at)) { + return hit.clone(); + } + let room = self.limit.saturating_sub(self.total); + let key = officeart::blip(&rec, room, &mut self.work).and_then(|b| self.insert(b.bytes, b.ext, b.placeholder)); + if let Some(at) = at { + self.by_delay.insert(at, key.clone()); + } + key + } + /// Add picture bytes as a new media entry, within the byte cap. fn insert(&mut self, bytes: Vec, ext: &str, placeholder: bool) -> Option { let total = self.total.checked_add(bytes.len()).filter(|&t| t <= self.limit)?; @@ -160,7 +184,7 @@ const MM_SHAPEFILE: u16 = 0x0066; /// Locate the picture at `fc_pic` in the Data stream ([MS-DOC] §2.9.192 PICFAndOfficeArtData); /// a shape's picture is decoded only up to `room` bytes. -fn locate(data: &[u8], fc_pic: u32, room: usize) -> Option { +fn locate(data: &[u8], fc_pic: u32, room: usize, work: &mut usize) -> Option { let base = fc_pic as usize; let pic = data.get(base..)?; let lcb = u32::from_le_bytes(pic.get(0..4)?.try_into().ok()?) as usize; @@ -179,7 +203,7 @@ fn locate(data: &[u8], fc_pic: u32, room: usize) -> Option { if mm == MM_SHAPEFILE { at = at.checked_add(1 + *pic.get(at)? as usize)?; } - let (blip, props) = inline_shape(pic.get(at..end)?, data, room)?; + let (blip, props) = inline_shape(pic.get(at..end)?, data, room, work)?; // The goal size is before cropping; the crop fractions take their share off it. let [cl, ct, cr, cb] = props.crop; let w = (dxa_goal / 20.0 * (1.0 - cl - cr).max(0.01) * scale(mx)).clamp(1.0, 1584.0); @@ -198,13 +222,13 @@ fn locate(data: &[u8], fc_pic: u32, room: usize) -> Option { /// An `OfficeArtInlineSpContainer`: the picture shape, then the `OfficeArtFBSE` records its /// `pib` indexes (BLIPs embedded, or at `foDelay` in the Data stream). `room` caps the bytes /// the BLIP decodes to. -fn inline_shape(buf: &[u8], data: &[u8], room: usize) -> Option<(officeart::Blip, officeart::Props)> { +fn inline_shape(buf: &[u8], data: &[u8], room: usize, work: &mut usize) -> Option<(officeart::Blip, officeart::Props)> { let mut budget = 10_000usize; let recs = officeart::children(buf, &mut budget); let sp = recs.iter().find(|r| r.typ == officeart::rt::SP_CONTAINER).map(|r| officeart::shape(r.body, &mut budget))?; let fbses: Vec<&[u8]> = recs.iter().filter(|r| r.typ == officeart::rt::FBSE).map(|r| r.body).collect(); let fbse = sp.props.pib.and_then(|p| fbses.get((p as usize).checked_sub(1)?)).or(fbses.first())?; - let blip = officeart::fbse_blip(fbse, data, room)?; + let blip = officeart::fbse_blip(fbse, data, room, work)?; Some((blip, sp.props)) } diff --git a/crates/docbin/src/officeart.rs b/crates/docbin/src/officeart.rs index 12d4a7d2e1..84d68b9d7f 100644 --- a/crates/docbin/src/officeart.rs +++ b/crates/docbin/src/officeart.rs @@ -112,23 +112,32 @@ pub(crate) struct Blip { /// The picture of an `OfficeArtFBSE` body: its embedded BLIP, or the BLIP stored at `foDelay` /// in `delay` (the WordDocument stream for the document's BLIP store, the Data stream for /// inline pictures). `limit` caps the bytes produced. -pub(crate) fn fbse_blip(fbse: &[u8], delay: &[u8], limit: usize) -> Option { +pub(crate) fn fbse_blip(fbse: &[u8], delay: &[u8], limit: usize, work: &mut usize) -> Option { + let (rec, _) = fbse_record(fbse, delay)?; + blip(&rec, limit, work) +} + +/// The BLIP record of an `OfficeArtFBSE`: embedded after it, or in the delay stream, where +/// its (`foDelay`, end) location is returned too (store entries can share one record). +pub(crate) fn fbse_record<'a>(fbse: &'a [u8], delay: &'a [u8]) -> Option<(Rec<'a>, Option<(usize, usize)>)> { // btWin32, btMacOS, rgbUid[16], tag, size, cRef, foDelay, unused, cbName, unused×2. let size = u32_at(fbse, 20)? as usize; let fo_delay = u32_at(fbse, 28)? as usize; let cb_name = *fbse.get(33)? as usize; let at = 36usize.checked_add(cb_name)?; if let Some((rec, _)) = fbse.get(at..).and_then(record) { - return blip(&rec, limit); + return Some((rec, None)); } // Not embedded: the record sits in the delay stream. let end = fo_delay.checked_add(size.max(8))?.min(delay.len()); let (rec, _) = record(delay.get(fo_delay..end)?)?; - blip(&rec, limit) + Some((rec, Some((fo_delay, end)))) } -/// Decode one `OfficeArtBlip*` record ([MS-ODRAW] §2.2.23-§2.2.31). -pub(crate) fn blip(rec: &Rec, limit: usize) -> Option { +/// Decode one `OfficeArtBlip*` record ([MS-ODRAW] §2.2.23-§2.2.31). `work` is the +/// document's decode budget: inflating a metafile or decoding a DIB is charged to it before +/// it runs, whether it succeeds or not, and nothing is decoded once it is spent. +pub(crate) fn blip(rec: &Rec, limit: usize, work: &mut usize) -> Option { // Each type has two instance values; the odd one carries a second 16-byte UID. let uids = if rec.inst & 1 == 1 { 32 } else { 16 }; match rec.typ { @@ -141,7 +150,7 @@ pub(crate) fn blip(rec: &Rec, limit: usize) -> Option { rt::BLIP_PNG => Some(Blip { bytes: data.to_vec(), ext: "png", placeholder: false }), rt::BLIP_JPEG | rt::BLIP_JPEG_CMYK => Some(Blip { bytes: data.to_vec(), ext: "jpeg", placeholder: false }), rt::BLIP_TIFF => Some(Blip { bytes: data.to_vec(), ext: "tiff", placeholder: false }), - _ => dib_to_png(data, limit), + _ => dib_to_png(data, limit, work), } } rt::BLIP_EMF | rt::BLIP_WMF | rt::BLIP_PICT => { @@ -154,7 +163,11 @@ pub(crate) fn blip(rec: &Rec, limit: usize) -> Option { let raw = raw.get(..cb_save.min(raw.len()))?; let cap = limit.min(MAX_METAFILE); let bytes = if compression == 0 { - let want = if cb_size > 0 { cb_size.min(cap) } else { cap }; + let want = (if cb_size > 0 { cb_size.min(cap) } else { cap }).min(*work); + if want == 0 { + return None; + } + *work -= want; miniz_oxide::inflate::decompress_to_vec_zlib_with_limit(raw, want).ok()? } else { if raw.len() > cap { @@ -177,8 +190,9 @@ pub(crate) fn blip(rec: &Rec, limit: usize) -> Option { /// A device-independent bitmap (BITMAPINFOHEADER + palette + pixels) as a PNG: the DIB gets /// the 14-byte BMP file header it lacks, is decoded with size limits and re-encoded. When the -/// bitmap can't be decoded the BMP bytes are kept (and render as a grey placeholder). -fn dib_to_png(dib: &[u8], limit: usize) -> Option { +/// bitmap can't be decoded, or its declared pixels (4 bytes each) don't fit in `work`, the +/// BMP bytes are kept (and render as a grey placeholder). +fn dib_to_png(dib: &[u8], limit: usize, work: &mut usize) -> Option { let header = u32_at(dib, 0)? as usize; if !(12..=124).contains(&header) { return None; @@ -203,6 +217,17 @@ fn dib_to_png(dib: &[u8], limit: usize) -> Option { bmp.extend_from_slice(&[0, 0, 0, 0]); bmp.extend_from_slice(&u32::try_from(off_bits).ok()?.to_le_bytes()); bmp.extend_from_slice(dib); + let placeholder = |bmp| Some(Blip { bytes: bmp, ext: "bmp", placeholder: true }); + // The declared size is what decoding allocates, however small the (RLE) data is. + let (w, h) = if header == 12 { + (u64::from(u16_at(dib, 4)?), u64::from(u16_at(dib, 6)?)) + } else { + (u64::from(u32_at(dib, 4)?), u64::from((u32_at(dib, 8)? as i32).unsigned_abs())) + }; + let Some(left) = w.saturating_mul(h).saturating_mul(4).try_into().ok().and_then(|cost: usize| work.checked_sub(cost)) else { + return placeholder(bmp); + }; + *work = left; let mut reader = image::ImageReader::new(std::io::Cursor::new(&bmp)); reader.set_format(image::ImageFormat::Bmp); let mut limits = image::Limits::default(); @@ -217,7 +242,7 @@ fn dib_to_png(dib: &[u8], limit: usize) -> Option { { Some(Blip { bytes: png.bytes, ext: "png", placeholder: false }) } - _ => Some(Blip { bytes: bmp, ext: "bmp", placeholder: true }), + _ => placeholder(bmp), } } diff --git a/crates/docbin/src/tests/drawing.rs b/crates/docbin/src/tests/drawing.rs index 49851883ba..eeff4651b8 100644 --- a/crates/docbin/src/tests/drawing.rs +++ b/crates/docbin/src/tests/drawing.rs @@ -247,7 +247,7 @@ fn dib_becomes_png_and_compressed_metafiles_inflate() { dib.extend_from_slice(&[0, 0, 255, 0, 255, 0, 0, 0]); let r = rec(0, 0x7A8, 0xF01F, &[[0u8; 16].as_slice(), &[0xFF], &dib].concat()); let (dib_rec, _) = officeart::record(&r).unwrap(); - let blip = officeart::blip(&dib_rec, 1 << 20).expect("DIB decodes"); + let blip = officeart::blip(&dib_rec, 1 << 20, &mut { usize::MAX }).expect("DIB decodes"); assert_eq!(blip.ext, "png"); let img = image::load_from_memory(&blip.bytes).unwrap().to_rgb8(); assert_eq!((img.width(), img.height(), img.get_pixel(0, 0).0), (2, 1, [255, 0, 0])); @@ -262,7 +262,7 @@ fn dib_becomes_png_and_compressed_metafiles_inflate() { hdr.extend_from_slice(&[0x00, 0xFE]); // compression = deflate, filter let r = rec(0, 0x216, 0xF01B, &[[0u8; 16].as_slice(), &hdr, &packed].concat()); let (wmf_rec, _) = officeart::record(&r).unwrap(); - let blip = officeart::blip(&wmf_rec, 1 << 20).expect("WMF inflates"); + let blip = officeart::blip(&wmf_rec, 1 << 20, &mut { usize::MAX }).expect("WMF inflates"); assert_eq!((blip.ext, blip.bytes), ("wmf", wmf)); } @@ -281,10 +281,10 @@ fn deeply_nested_groups_and_lying_lengths_are_bounded() { hdr.extend_from_slice(&[0, 0]); let r = rec(0, 0x3D4, 0xF01A, &[[0u8; 16].as_slice(), &hdr, &[0x78, 0x9C, 1, 2, 3]].concat()); let (emf, _) = officeart::record(&r).unwrap(); - assert!(officeart::blip(&emf, 1 << 20).is_none()); + assert!(officeart::blip(&emf, 1 << 20, &mut { usize::MAX }).is_none()); let mut fbse = vec![0u8; 36]; fbse[28..32].copy_from_slice(&u32::MAX.to_le_bytes()); - assert!(officeart::fbse_blip(&fbse, &[0u8; 64], 1 << 20).is_none()); + assert!(officeart::fbse_blip(&fbse, &[0u8; 64], 1 << 20, &mut { usize::MAX }).is_none()); } proptest! { @@ -322,12 +322,12 @@ fn dib_conversion_caps_header_and_encoded_bytes() { let record = rec(0, 0x7A8, officeart::rt::BLIP_DIB, &[[0u8; 16].as_slice(), &[0xFF], &dib].concat()); let (record, _) = officeart::record(&record).unwrap(); let bmp_size = 14 + dib.len(); - assert!(officeart::blip(&record, bmp_size - 1).is_none(), "the BMP header also needs room"); - let fallback = officeart::blip(&record, bmp_size).expect("the BMP fallback fits exactly"); + assert!(officeart::blip(&record, bmp_size - 1, &mut { usize::MAX }).is_none(), "the BMP header also needs room"); + let fallback = officeart::blip(&record, bmp_size, &mut { usize::MAX }).expect("the BMP fallback fits exactly"); assert_eq!((fallback.ext, fallback.placeholder, fallback.bytes.len()), ("bmp", true, bmp_size)); let img = image::load_from_memory(&fallback.bytes).unwrap().to_rgb8(); assert_eq!((img.width(), img.height(), img.get_pixel(0, 0).0), (1, 1, [90, 60, 30])); - let normal = officeart::blip(&record, 512).expect("normal conversion fits"); + let normal = officeart::blip(&record, 512, &mut { usize::MAX }).expect("normal conversion fits"); assert_eq!((normal.ext, normal.placeholder), ("png", false)); assert!(normal.bytes.len() > bmp_size && normal.bytes.len() <= 512); assert_eq!(image::load_from_memory(&normal.bytes).unwrap().width(), 1); @@ -376,3 +376,89 @@ fn a_group_named_by_many_anchors_is_built_once() { p0.objects.iter().filter_map(|o| if let InlineObject::Group { children, .. } = o { Some(children.len()) } else { None }).collect(); assert_eq!(groups, [1000]); } + +/// A deflated WMF BLIP record declaring `size` uncompressed bytes. +fn packed_wmf(size: u32, packed: &[u8]) -> Vec { + let mut hdr = size.to_le_bytes().to_vec(); + hdr.extend_from_slice(&[0u8; 24]); // rcBounds, ptSize + hdr.extend_from_slice(&(packed.len() as u32).to_le_bytes()); + hdr.extend_from_slice(&[0x00, 0xFE]); // compression = deflate, filter + rec(0, 0x216, officeart::rt::BLIP_WMF, &[[0u8; 16].as_slice(), &hdr, packed].concat()) +} + +/// An `OfficeArtFBSE` body whose BLIP (of `size` bytes) sits at `fo_delay` in the delay stream. +fn delayed_fbse(size: usize, fo_delay: usize) -> Vec { + let mut fbse = vec![0u8; 36]; + fbse[20..24].copy_from_slice(&(size as u32).to_le_bytes()); + fbse[28..32].copy_from_slice(&(fo_delay as u32).to_le_bytes()); + fbse +} + +/// #3711: store entries aliasing one delay-stream metafile each inflated (and copied) it again. +/// Now the record is decoded once and every entry shares its media key. +#[test] +fn store_entries_aliasing_one_delayed_metafile_decode_it_once() { + let wmf = vec![0u8; 1 << 20]; + let delay = packed_wmf(wmf.len() as u32, &miniz_oxide::deflate::compress_to_vec_zlib(&wmf, 6)); + let fbse = delayed_fbse(delay.len(), 0); + let store: Vec<&[u8]> = vec![&fbse; 5_000]; + let mut pics = crate::media::Pictures::default(); + pics.work = 8 << 20; + let keys: Vec<_> = (1..=store.len() as u32).map(|pib| pics.bse(pib, &store, &delay)).collect(); + assert!(keys.iter().all(|k| k.is_some() && *k == keys[0]), "every entry shares one picture"); + assert_eq!(pics.work, (8 << 20) - wmf.len(), "inflated once"); +} + +/// #3711: distinct metafiles that fail to inflate are charged their declared size anyway, so +/// a document of them stops decoding once the work budget is spent. +#[test] +fn failing_metafile_inflations_spend_the_decode_budget() { + let records: Vec> = (0..10u8).map(|i| packed_wmf(1 << 20, &[0x78, 0x9C, i, 1, 2, 3])).collect(); + let delay = records.concat(); + let mut at = 0; + let fbses: Vec> = records + .iter() + .map(|r| { + at += r.len(); + delayed_fbse(r.len(), at - r.len()) + }) + .collect(); + let store: Vec<&[u8]> = fbses.iter().map(Vec::as_slice).collect(); + let mut pics = crate::media::Pictures::default(); + pics.work = 3 << 20; + for pib in 1..=store.len() as u32 { + assert!(pics.bse(pib, &store, &delay).is_none()); + } + assert_eq!(pics.work, 0, "three attempts spent the budget; the rest weren't tried"); +} + +/// #3712: a DIB's declared pixels are charged before decoding (an RLE bitmap is tiny on disk but +/// decodes to w × h × 4 bytes); past the budget it stays a BMP placeholder, undecoded. +#[test] +fn dib_decoding_is_charged_its_declared_pixels() { + let blip_of = |dib: &[u8]| rec(0, 0x7A8, officeart::rt::BLIP_DIB, &[[0u8; 16].as_slice(), &[0xFF], dib].concat()); + let one = blip_of(&one_pixel_dib()); + let (one, _) = officeart::record(&one).unwrap(); + let mut work = 3; + assert_eq!(officeart::blip(&one, 512, &mut work).map(|b| (b.ext, b.placeholder)), Some(("bmp", true))); + assert_eq!(work, 3, "nothing decoded, nothing charged"); + let mut work = 4; + assert_eq!(officeart::blip(&one, 512, &mut work).map(|b| (b.ext, b.placeholder)), Some(("png", false))); + assert_eq!(work, 0); + + // 8-bit RLE declaring 16384 × 16384 (1 GiB decoded) in a few bytes. + let mut big = [40u32, 16_384, 16_384].iter().flat_map(|v| v.to_le_bytes()).collect::>(); + big.extend_from_slice(&1u16.to_le_bytes()); + big.extend_from_slice(&8u16.to_le_bytes()); + big.extend_from_slice(&1u32.to_le_bytes()); // BI_RLE8 + big.extend_from_slice(&[0u8; 12]); + big.extend_from_slice(&1u32.to_le_bytes()); // one palette colour + big.extend_from_slice(&[0u8; 4]); // clrImportant + big.extend_from_slice(&[0u8; 4]); // the palette colour + big.extend_from_slice(&[0, 0, 0, 1]); // end of line, end of bitmap + let big = blip_of(&big); + let (big, _) = officeart::record(&big).unwrap(); + let mut work = 512 << 20; + assert_eq!(officeart::blip(&big, 1 << 20, &mut work).map(|b| (b.ext, b.placeholder)), Some(("bmp", true))); + assert_eq!(work, 512 << 20); +} From a58a7618be4f87a82e3edf9e41452939655e6863 Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Thu, 22 Oct 2026 16:28:17 +0100 Subject: [PATCH 061/199] =?UTF-8?q?#3692:=20docs=20=E2=80=94=20.doc=20read?= =?UTF-8?q?er=20hostile-file=20bounds=20in=20the=20progress=20log=20and=20?= =?UTF-8?q?file-format=20parity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Findings 49, 60, 63, 64, 68, 75: progress-log row and a note on the .doc parity row. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/file-format-parity.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 41f370a1e4..365d226aad 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -49,7 +49,7 @@ Weights are the share of a Word user's file traffic (estimate). Read/write: ✅ | `.docx` Word Document (ECMA-376 Transitional) | 70% | R/W | ✅ | ✅ | ~65% | 70–110 | `crates/docx/tests` (roundtrip 34, fixtures 29, malformed 8, bidi 5, bidi_runs 12, citations 5); synthetic fixtures only | Word opens our files. Untested on a real-world corpus. Tracked changes inside a field result stay revisions (the field is kept as a range, #1037). Details below | | `.docx` Strict Open XML | 1% | R/W | ✅ (namespace mapping in `xml.rs`) | ❌ | ~60% | 2–4 | `strict_namespace_and_bad_numbers`, `strict_line_heights_with_a_unit_are_read_as_point_multiples` | Writing Strict is rare; unit-suffixed measures (`13.90pt`) read as points (#2329) | | `.docm` / `.dotx` / `.dotm` | 3% | R/W | ✅ | ✅ | ~65% | incl. above | `macro_packages.rs` | Macros and signatures kept on save (#172); macros never run | -| `.doc` Word 97-2003 (and `.dot`) | 8% | R/W | 🟡 | ❌ | ~60% | 25–40 | `crates/docbin/src/tests.rs`, `tests/drawing.rs` | Spec-based reader ([MS-DOC]): text, formatting, styles, sections, headers/footers, tables, lists, notes, fields, bookmarks; inline and floating pictures (PNG, JPEG, DIB, TIFF, EMF, WMF; Macintosh PICT as a sized placeholder) with crop, alt text, position and wrap; basic shapes, lines, groups and text boxes ([MS-ODRAW]). Table cell shading from every SHD record (`sprmTDefTableShd`, `sprmTDefTableShdRaw` and their 2nd/3rd ranges, with the Word 97 `Shd80` as fallback; a clear pattern shows its background colour); the row-end mark is not a cell, and each table has its own grid and properties (#1380). Horizontally merged rows stored as fewer, wider cells come with #1123. Freeform shapes, z-order and Word 6/95 files are dropped or refused. Password-protected files (XOR obfuscation, RC4, RC4 CryptoAPI) open after a password prompt, from the public specs; not yet tried on a file Word wrote (AES-encrypted ones are refused). No writer. DIB conversion caps BMP bytes (header included) and encoded PNG at the supplied BLIP byte limit, and uses it as the decoder's allocation limit; a BMP fallback is kept if it fits (related #1334 and #1554). | +| `.doc` Word 97-2003 (and `.dot`) | 8% | R/W | 🟡 | ❌ | ~60% | 25–40 | `crates/docbin/src/tests.rs`, `tests/drawing.rs` | Spec-based reader ([MS-DOC]): text, formatting, styles, sections, headers/footers, tables, lists, notes, fields, bookmarks; inline and floating pictures (PNG, JPEG, DIB, TIFF, EMF, WMF; Macintosh PICT as a sized placeholder) with crop, alt text, position and wrap; basic shapes, lines, groups and text boxes ([MS-ODRAW]). Table cell shading from every SHD record (`sprmTDefTableShd`, `sprmTDefTableShdRaw` and their 2nd/3rd ranges, with the Word 97 `Shd80` as fallback; a clear pattern shows its background colour); the row-end mark is not a cell, and each table has its own grid and properties (#1380). Horizontally merged rows stored as fewer, wider cells come with #1123. Freeform shapes, z-order and Word 6/95 files are dropped or refused. Password-protected files (XOR obfuscation, RC4, RC4 CryptoAPI) open after a password prompt, from the public specs; not yet tried on a file Word wrote (AES-encrypted ones are refused). No writer. DIB conversion caps BMP bytes (header included) and encoded PNG at the supplied BLIP byte limit, and uses it as the decoder's allocation limit; a BMP fallback is kept if it fits (related #1334 and #1554). Hostile files are bounded: a sector table longer than the file is refused before parsing (#3642); formatting and link runs are read once per change, not per character (#3692, #3708); copies of alt text, link targets and drawn objects share a 64 MB budget (#3709, #3710); picture decoding shares a 512 MB budget and aliased store entries decode once (#3711, #3712). | | PDF export | 8% | W | — | ✅ | ~80% | 6–10 | `crates/pdf/src/tests.rs` | krilla: embedded subsets, selectable text incl. field results (#133); Hebrew and Arabic copy and search as whole words (right-to-left glyphs written in visual order, #1383, also with fonts that draw lam-alef as two glyphs at one x, #2310); All Caps and Small Caps text extracts as the capitals shown, as from Word's PDFs, and never changes the case of other text (#1473), links, outline, tagging. Outline destinations follow leading page breaks to the heading text. Fonts it can't subset become outlines (#132). Print Markup (#589): comments in balloons beside each page (scaled to its paper) and tracked changes; HTML/Markdown/text copies end with a comment list. Horizontal and vertical lines inside groups are drawn (#1474). No PDF/A option, no "best for printing/online" choice | | PDF open (PDF Reflow, Word for Windows) | 1% | R | ❌ | — | 0% | 30–50 | — | Converting a PDF into an editable document. A sibling app (PdfCraft) may supply the parser | | `.rtf` Rich Text Format | 3% | R/W | 🟡 | 🟡 | ~50% | 10–15 | `crates/formats/src/tests.rs` | ~1,500 lines: text, character/paragraph formatting, styles, lists, tables, pictures (`\shppict`, #154), fields, bookmarks. 8-bit text in the document's code page (`\ansicpg`) or the current font's (`\fcharset`, `\cpg`): Cyrillic, Central European, Greek, Turkish, Hebrew, Arabic, Baltic, Vietnamese, Thai (was Windows-1252 only), Mac OS Roman (`\mac`, `\fcharset77`) and DOS code pages (`\pc` 437, `\pca` 850, `\cpg` 437/737/850/852/857/866, `\fcharset254`) (#1653). East Asian multibyte code pages decoded as runs: 932 Shift-JIS (`\fcharset128`, Japanese), 936 GBK (134, Simplified Chinese), 949 UHC/windows-949 (129, Korean) and 950 Big5 (136, Traditional Chinese) through `\ansicpg`, `\cpg` or the font's charset, escaped or raw, a lead byte and its trail byte together; invalid sequences read as U+FFFD, and a run that starts `EF BB BF` is text, not a BOM (#1653). Pasted from the system clipboard where the platform offers it (Windows, Wayland) when there is no HTML (#687). Tracked insertions and deletions read and written (`\revtbl`, `\revised`/`\deleted`, authors, `\revdttm` dates), so Accept/Reject All work after opening or a round trip; tracked paragraph marks (`\par` inside `\revised`/`\deleted` groups, including a `\sect` inside a deletion) and character and paragraph formatting revisions (`\crauth`/`\crdate` with `\oldcprops`, `\prauth`/`\prdate` with `\oldpprops`) read and written; section and table formatting revisions (`\oldsprops`, `\oldtprops`) not yet. Sections on import (#1378): each `\sect` ends a section with its own page size and orientation, margins, header/footer distance, columns, break type, page-number restart, start and format, title page and headers/footers (those it leaves out link to the previous); export writes every section too (break type, page size and orientation, margins, header/footer distance, columns, page numbering, title page; not headers/footers). Thin: comments, notes, RTL | @@ -268,6 +268,7 @@ Repeated DOCX style IDs use the last complete definition. The original regressio | 2026-10-25 | trivial | Repeated DOCX style IDs: repeated save/reopen regression for whole-definition replacement. | | 2026-10-25 | 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`) | | 2026-10-23 | trivial | cu20we: preserve and render outline caps across DOCX/VML, screen and PDF; synthetic regressions; readiness estimates unchanged | +| 2026-10-22 | trivial | `.doc` reader bounds hostile files: oversized sector tables refused, per-character re-reads removed, copy and decode budgets (#3642, #3692, #3708-#3712) | | 2026-10-23 | trivial | A relationship the markup names without the prefix is read as it wrote: an unqualified `id` on an element that carries a relationship names that relationship, and the writer keeps the attribute with the id the new package holds the part under; an ordinary `id` — a VML shape's, a drawing's — names no part (#3411 follow-up) | | 2026-10-21 | trivial | The reader takes the relationship ids the markup refers to from the relationship attributes of the main document it has already parsed, so a document with many relationships opens in the time its part takes, and an id that only appears as text is not a reference (#3411) | | 2026-10-23 | trivial | DOCX: TOC styles Word stores under ids in another language (`Sumrio1` named `toc 1`, `CabealhodoSumrio` named `TOC Heading`) read as `TOC1`…`TOC9` and `TOCHeading` unless the file uses those ids, so Update Table builds the entries in the document's own TOC styles (#3891); a TOC whose heading ends a section (entries in columns after a continuous break) is written and read as the engine's TOC, with the page-number tab at the column's edge (#3889) | From bbeb7e22535509cab3f7d998f640554c761d440b Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Thu, 22 Oct 2026 17:56:58 +0100 Subject: [PATCH 062/199] #3642: compound-file precheck shared in wordcraft-doc; .docx encryption sniff and .xls reader call it too Finding 63 follow-up. #3642 names three cfb call sites: the .doc reader, the encrypted-package sniff and decrypt in docx/crypt.rs (reached for any .docx name), and the .xls reader. The DIFAT precheck moves from docbin to wordcraft_doc::cfb_guard::check (bytes only, no cfb dependency) and runs before every CompoundFile::open in the three crates. The crypt.rs change is only the two precheck calls (open PR #2125 also edits that file). Regression tests use the shared hostile sample (cfb_guard::repeating_difat_sample): docbin a_difat_repeating_one_fat_sector_is_refused_before_cfb_builds_the_fat, docx tests/malformed.rs and formats sheet::xls a_repeating_difat_is_refused_before_cfb_opens_it. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/doc/src/cfb_guard.rs | 100 ++++++++++++++++++++++++++++++ crates/doc/src/lib.rs | 1 + crates/docbin/src/lib.rs | 62 +----------------- crates/docbin/src/tests.rs | 26 +------- crates/docx/src/crypt.rs | 3 +- crates/docx/tests/it/malformed.rs | 9 +++ crates/formats/src/sheet/xls.rs | 12 +++- 7 files changed, 127 insertions(+), 86 deletions(-) create mode 100644 crates/doc/src/cfb_guard.rs diff --git a/crates/doc/src/cfb_guard.rs b/crates/doc/src/cfb_guard.rs new file mode 100644 index 0000000000..c50bd9314d --- /dev/null +++ b/crates/doc/src/cfb_guard.rs @@ -0,0 +1,100 @@ +//! A bytes-only check of an OLE compound file ([MS-CFB]) before the `cfb` crate opens it +//! (#3642). Every reader of `.doc`, `.xls` and encrypted Office packages calls [`check`] first. + +/// Check a compound file's FAT layout before `cfb` reads it ([MS-CFB] §2.2 header, §2.5 +/// DIFAT). `cfb` appends a whole sector of FAT entries for every FAT sector the DIFAT lists and +/// only validates the FAT once it is built, so a small header naming one sector over and over +/// makes it allocate gigabytes. Here the DIFAT is walked the way `cfb` walks it (header entries, +/// then the DIFAT chain, at most one step per sector; trailing zero and free entries trimmed +/// the same way) and the file is refused when it lists more FAT sectors than the file has +/// sectors, so the FAT `cfb` builds is never larger than the file. Anything else that is wrong +/// is left for `cfb` to report. +pub fn check(bytes: &[u8]) -> Result<(), String> { + const MAGIC: [u8; 8] = [0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1]; + const FREE: u32 = 0xFFFF_FFFF; + const END_OF_CHAIN: u32 = 0xFFFF_FFFE; + const HEADER_ENTRIES: usize = 109; + let u16_at = |at: usize| bytes.get(at..at.checked_add(2)?).map(|b| u16::from_le_bytes([b[0], b[1]])); + let u32_at = |at: usize| bytes.get(at..at.checked_add(4)?).map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]])); + if bytes.len() < 512 || bytes.get(..8) != Some(MAGIC.as_slice()) { + return Ok(()); + } + let sector_len: usize = match u16_at(0x1A) { + Some(3) => 512, + Some(4) => 4096, + _ => return Ok(()), + }; + if bytes.len() < sector_len { + return Ok(()); + } + // `cfb` counts the sectors after the header sector, the last one possibly partial. + let num_sectors = bytes.len().div_ceil(sector_len) - 1; + let num_fat_sectors = u32_at(0x2C).unwrap_or(0) as usize; + let mut difat: Vec = (0..HEADER_ENTRIES).filter_map(|i| u32_at(0x4C + i * 4)).collect(); + let mut next = u32_at(0x44).unwrap_or(END_OF_CHAIN); + let mut steps = 0usize; + while next != END_OF_CHAIN && next != FREE { + steps += 1; + // `cfb` refuses a chain sector past the file or seen before; a chain longer than the + // file has sectors must have done one of the two. + if steps > num_sectors || next as usize >= num_sectors { + return Err(format!("the DIFAT chain is longer than the file or leaves it (sector {next})")); + } + let at = (next as usize + 1) * sector_len; + let entries = sector_len / 4 - 1; + difat.extend((0..entries).map(|i| u32_at(at + i * 4).unwrap_or(FREE))); + next = u32_at(at + entries * 4).unwrap_or(END_OF_CHAIN); + } + while difat.len() > HEADER_ENTRIES && difat.len() > num_fat_sectors && difat.last() == Some(&0) { + difat.pop(); + } + while difat.last() == Some(&FREE) { + difat.pop(); + } + // Each listed FAT sector adds a whole sector of entries. A file can't hold more FAT sectors + // than it has sectors, and below that the FAT is no larger than the file. + if difat.len() > num_sectors { + return Err(format!("the DIFAT lists {} FAT sectors in a file of {num_sectors} sectors", difat.len())); + } + Ok(()) +} + +/// A compound file of about 1 MB whose DIFAT chain lists one FAT sector 2048 × 127 times: +/// without [`check`], `cfb` builds a FAT of about 130 MB for it. Regression input for the +/// readers' tests. +#[doc(hidden)] +pub fn repeating_difat_sample() -> Vec { + const S: usize = 512; + const CHAIN: u32 = 2048; // DIFAT sectors 1..=CHAIN, sector 0 is the "FAT" + let mut f = vec![0u8; S * (CHAIN as usize + 2)]; + let mut put = |at: usize, v: &[u8]| { + if let Some(dst) = f.get_mut(at..at + v.len()) { + dst.copy_from_slice(v); + } + }; + put(0, &[0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1]); + put(0x18, &0x3Eu16.to_le_bytes()); + put(0x1A, &3u16.to_le_bytes()); + put(0x1C, &0xFFFEu16.to_le_bytes()); + put(0x1E, &9u16.to_le_bytes()); + put(0x20, &6u16.to_le_bytes()); + put(0x2C, &u32::MAX.to_le_bytes()); // no zero trimming + put(0x38, &4096u32.to_le_bytes()); + put(0x44, &1u32.to_le_bytes()); + put(0x48, &CHAIN.to_le_bytes()); + // The 109 header entries are all 0 already; each chain sector's entries too. + for d in 1..=CHAIN { + let next = if d == CHAIN { 0xFFFF_FFFE } else { d + 1 }; + put((d as usize + 2) * S - 4, &next.to_le_bytes()); + } + f +} + +#[cfg(test)] +mod tests { + #[test] + fn a_repeating_difat_is_refused_and_a_small_file_passes() { + assert!(super::check(&super::repeating_difat_sample()).is_err_and(|e| e.contains("FAT sectors in a file of"))); + assert!(super::check(b"not a compound file").is_ok()); + } +} diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index b3ac326aed..75fa27ac47 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -12,6 +12,7 @@ pub mod bidi; mod byte_store; +pub mod cfb_guard; pub mod chart; mod cjk_numbers; pub mod cluster; diff --git a/crates/docbin/src/lib.rs b/crates/docbin/src/lib.rs index 4c6c0c4419..b3fbc2361e 100644 --- a/crates/docbin/src/lib.rs +++ b/crates/docbin/src/lib.rs @@ -97,70 +97,12 @@ pub fn is_encrypted(bytes: &[u8]) -> bool { stream(&mut comp, "WordDocument").is_some_and(|w| fib::peek_encryption(&w).is_some()) } -/// Open the compound file around a `.doc`, after [`cfb_precheck`]. +/// Open the compound file around a `.doc`, after [`wordcraft_doc::cfb_guard::check`]. fn open_container(bytes: &[u8]) -> Result>, DocbinError> { - cfb_precheck(bytes).map_err(DocbinError::Container)?; + wordcraft_doc::cfb_guard::check(bytes).map_err(DocbinError::Container)?; cfb::CompoundFile::open(std::io::Cursor::new(bytes)).map_err(|e| DocbinError::Container(e.to_string())) } -/// Check a compound file's FAT layout before `cfb` reads it ([MS-CFB] §2.2 header, §2.5 -/// DIFAT). `cfb` appends a whole sector of FAT entries for every FAT sector the DIFAT lists and -/// only validates the FAT once it is built, so a small header naming one sector over and over -/// makes it allocate gigabytes. Here the DIFAT is walked the way `cfb` walks it (header entries, -/// then the DIFAT chain, at most one step per sector; trailing zero and free entries trimmed -/// the same way) and the file is refused when it lists more FAT sectors than the file has -/// sectors, so the FAT `cfb` builds is never larger than the file. Anything else that is wrong -/// is left for `cfb` to report. -fn cfb_precheck(bytes: &[u8]) -> Result<(), String> { - const MAGIC: [u8; 8] = [0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1]; - const FREE: u32 = 0xFFFF_FFFF; - const END_OF_CHAIN: u32 = 0xFFFF_FFFE; - const HEADER_ENTRIES: usize = 109; - let u16_at = |at: usize| bytes.get(at..at.checked_add(2)?).map(|b| u16::from_le_bytes([b[0], b[1]])); - let u32_at = |at: usize| bytes.get(at..at.checked_add(4)?).map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]])); - if bytes.len() < 512 || bytes.get(..8) != Some(MAGIC.as_slice()) { - return Ok(()); - } - let sector_len: usize = match u16_at(0x1A) { - Some(3) => 512, - Some(4) => 4096, - _ => return Ok(()), - }; - if bytes.len() < sector_len { - return Ok(()); - } - // `cfb` counts the sectors after the header sector, the last one possibly partial. - let num_sectors = bytes.len().div_ceil(sector_len) - 1; - let num_fat_sectors = u32_at(0x2C).unwrap_or(0) as usize; - let mut difat: Vec = (0..HEADER_ENTRIES).filter_map(|i| u32_at(0x4C + i * 4)).collect(); - let mut next = u32_at(0x44).unwrap_or(END_OF_CHAIN); - let mut steps = 0usize; - while next != END_OF_CHAIN && next != FREE { - steps += 1; - // `cfb` refuses a chain sector past the file or seen before; a chain longer than the - // file has sectors must have done one of the two. - if steps > num_sectors || next as usize >= num_sectors { - return Err(format!("the DIFAT chain is longer than the file or leaves it (sector {next})")); - } - let at = (next as usize + 1) * sector_len; - let entries = sector_len / 4 - 1; - difat.extend((0..entries).map(|i| u32_at(at + i * 4).unwrap_or(FREE))); - next = u32_at(at + entries * 4).unwrap_or(END_OF_CHAIN); - } - while difat.len() > HEADER_ENTRIES && difat.len() > num_fat_sectors && difat.last() == Some(&0) { - difat.pop(); - } - while difat.last() == Some(&FREE) { - difat.pop(); - } - // Each listed FAT sector adds a whole sector of entries. A file can't hold more FAT sectors - // than it has sectors, and below that the FAT is no larger than the file. - if difat.len() > num_sectors { - return Err(format!("the DIFAT lists {} FAT sectors in a file of {num_sectors} sectors", difat.len())); - } - Ok(()) -} - /// Read a `.doc` (Word 97-2003 binary) file into a [`Document`]. A password-protected file /// gives [`DocbinError::PasswordRequired`]; see [`read_with_password`]. pub fn read(bytes: &[u8]) -> Result { diff --git a/crates/docbin/src/tests.rs b/crates/docbin/src/tests.rs index afd29c5085..ba9bd92f1f 100644 --- a/crates/docbin/src/tests.rs +++ b/crates/docbin/src/tests.rs @@ -2397,35 +2397,13 @@ fn paragraph_borders_read_from_both_forms() { /// build a FAT of hundreds of megabytes before checking it. The precheck refuses it first. #[test] fn a_difat_repeating_one_fat_sector_is_refused_before_cfb_builds_the_fat() { - const S: usize = 512; - const FREE: u32 = 0xFFFF_FFFF; - let chain = 2048u32; // DIFAT sectors 1..=chain, sector 0 is the "FAT" - let mut f = vec![0u8; S * (chain as usize + 2)]; - f[..8].copy_from_slice(&[0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1]); - f[0x18..0x1A].copy_from_slice(&0x3Eu16.to_le_bytes()); - f[0x1A..0x1C].copy_from_slice(&3u16.to_le_bytes()); - f[0x1C..0x1E].copy_from_slice(&0xFFFEu16.to_le_bytes()); - f[0x1E..0x20].copy_from_slice(&9u16.to_le_bytes()); - f[0x20..0x22].copy_from_slice(&6u16.to_le_bytes()); - f[0x2C..0x30].copy_from_slice(&FREE.to_le_bytes()); // no zero trimming - f[0x38..0x3C].copy_from_slice(&4096u32.to_le_bytes()); - f[0x44..0x48].copy_from_slice(&1u32.to_le_bytes()); - f[0x48..0x4C].copy_from_slice(&chain.to_le_bytes()); - for i in 0..109 { - f[0x4C + i * 4..0x50 + i * 4].copy_from_slice(&0u32.to_le_bytes()); - } - for d in 1..=chain { - let at = (d as usize + 1) * S; - let next = if d == chain { 0xFFFF_FFFE } else { d + 1 }; - f[at + S - 4..at + S].copy_from_slice(&next.to_le_bytes()); - } + let f = wordcraft_doc::cfb_guard::repeating_difat_sample(); let t = std::time::Instant::now(); - assert!(super::cfb_precheck(&f).is_err_and(|e| e.contains("FAT sectors in a file of"))); assert!(matches!(read(&f), Err(super::DocbinError::Container(e)) if e.contains("FAT sectors in a file of"))); assert!(!super::is_encrypted(&f)); assert!(t.elapsed().as_secs() < 2, "refused in {:?}", t.elapsed()); // A file cfb wrote itself passes. - assert!(super::cfb_precheck(&cfb_file(&[("WordDocument", &[0u8; 4096])])).is_ok()); + assert!(wordcraft_doc::cfb_guard::check(&cfb_file(&[("WordDocument", &[0u8; 4096])])).is_ok()); } /// #3708: a long HYPERLINK target over a long result was copied (and compared) once per result diff --git a/crates/docx/src/crypt.rs b/crates/docx/src/crypt.rs index 2087d9bda0..091bd1a83d 100644 --- a/crates/docx/src/crypt.rs +++ b/crates/docx/src/crypt.rs @@ -74,7 +74,7 @@ const NS_PASSWORD: &str = "http://schemas.microsoft.com/office/2006/keyEncryptor /// Is this an encrypted Office package (a compound file with `EncryptionInfo` and /// `EncryptedPackage` streams) rather than a zip? pub fn is_encrypted(bytes: &[u8]) -> bool { - if bytes.get(..8) != Some(&CFB_MAGIC[..]) { + if bytes.get(..8) != Some(&CFB_MAGIC[..]) || wordcraft_doc::cfb_guard::check(bytes).is_err() { return false; } match cfb::CompoundFile::open(Cursor::new(bytes)) { @@ -87,6 +87,7 @@ pub fn is_encrypted(bytes: &[u8]) -> bool { /// [`DocxError::PasswordRequired`] once it knows the file is one it can decrypt; a password that /// doesn't fit gives [`DocxError::WrongPassword`]. pub fn decrypt(bytes: &[u8], password: Option<&str>) -> Result, DocxError> { + wordcraft_doc::cfb_guard::check(bytes).map_err(|e| bad(format!("not a readable compound file ({e})")))?; let mut comp = cfb::CompoundFile::open(Cursor::new(bytes)).map_err(|e| bad(format!("not a readable compound file ({e})")))?; let info = stream(&mut comp, "/EncryptionInfo", MAX_INFO)?; let package = stream(&mut comp, "/EncryptedPackage", bytes.len() as u64)?; diff --git a/crates/docx/tests/it/malformed.rs b/crates/docx/tests/it/malformed.rs index 15a4b263c7..67eedf78d5 100644 --- a/crates/docx/tests/it/malformed.rs +++ b/crates/docx/tests/it/malformed.rs @@ -342,3 +342,12 @@ fn long_header_reference_chain_is_cut_off() { assert!(!d.parts.is_empty()); assert!(d.parts.len() <= 8, "followed {} chained headers", d.parts.len()); } + +/// #3642: a compound file whose DIFAT names one FAT sector thousands of times is refused by the +/// encrypted-package sniff (reached for any `.docx` name) before `cfb` builds its FAT. +#[test] +fn a_repeating_difat_is_refused_before_cfb_opens_it() { + let f = wordcraft_doc::cfb_guard::repeating_difat_sample(); + assert!(!wordcraft_docx::is_encrypted(&f)); + assert!(matches!(wordcraft_docx::decrypt(&f, None), Err(e) if e.to_string().contains("FAT sectors in a file of"))); +} diff --git a/crates/formats/src/sheet/xls.rs b/crates/formats/src/sheet/xls.rs index 3a70f5308a..df6deb7669 100644 --- a/crates/formats/src/sheet/xls.rs +++ b/crates/formats/src/sheet/xls.rs @@ -44,7 +44,7 @@ const TABLE: u16 = 0x0236; /// Whether `bytes` is a compound file holding an Excel workbook stream. pub fn is_xls(bytes: &[u8]) -> bool { - if !bytes.starts_with(&MAGIC) { + if !bytes.starts_with(&MAGIC) || wordcraft_doc::cfb_guard::check(bytes).is_err() { return false; } let Ok(comp) = cfb::CompoundFile::open(Cursor::new(bytes)) else { return false }; @@ -68,6 +68,7 @@ pub fn read_rows(bytes: &[u8], pick: super::Pick<'_>) -> Result<(Vec, Ve } fn workbook_stream(bytes: &[u8]) -> Result, String> { + wordcraft_doc::cfb_guard::check(bytes).map_err(|e| format!("not a valid Excel 97–2003 workbook ({e})"))?; let mut comp = cfb::CompoundFile::open(Cursor::new(bytes)).map_err(|e| format!("not a valid Excel 97–2003 workbook ({e})"))?; if !comp.is_stream("/Workbook") { if comp.is_stream("/Book") { @@ -540,6 +541,15 @@ mod tests { r.iter().map(|r| r.iter().map(|c| c.to_string()).collect()).collect() } + /// #3642: a DIFAT naming one FAT sector thousands of times is refused before `cfb` builds + /// a FAT far larger than the file. + #[test] + fn a_repeating_difat_is_refused_before_cfb_opens_it() { + let f = wordcraft_doc::cfb_guard::repeating_difat_sample(); + assert!(!is_xls(&f)); + assert!(sheet_names(&f).is_err_and(|e| e.contains("FAT sectors in a file of"))); + } + #[test] fn reads_a_biff8_workbook() { let book = build(&[ From dd6585f16619475ff47a0cb888d34985f6473836 Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Thu, 22 Oct 2026 19:53:16 +0100 Subject: [PATCH 063/199] #3710: .doc floating-object copies charged their alt text and group members' too CodeRabbit review: the anchor copy cost counted only the struct size, so an aliased picture's alt text (up to ~12 KB) was copied per walk uncharged. Recursion is bounded by MAX_GROUP_DEPTH. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/docbin/src/lib.rs | 19 +++++++++++++------ crates/docbin/src/tests/drawing.rs | 27 +++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 6 deletions(-) diff --git a/crates/docbin/src/lib.rs b/crates/docbin/src/lib.rs index b3fbc2361e..12e4f57afd 100644 --- a/crates/docbin/src/lib.rs +++ b/crates/docbin/src/lib.rs @@ -553,13 +553,9 @@ fn walk<'a>(ctx: &WalkCtx<'a>, cp_start: u32, cp_end: u32, pics: &mut media::Pic // Drawn object anchor: the floating shape or picture anchored at this CP. let at = ctx.drawn.partition_point(|(c, _)| *c < cp); // Each copy (a story can be walked more than once) is charged to the work budget - // by size, a group with all its members (#3710). + // by what a clone copies: a picture's alt text, a group with all its members (#3710). for (_, obj) in ctx.drawn.get(at..).unwrap_or_default().iter().take_while(|(c, _)| *c == cp) { - let parts = match obj { - InlineObject::Group { children, .. } => children.len().saturating_add(1), - _ => 1, - }; - let cost = (parts as u64).saturating_mul(std::mem::size_of::() as u64); + let cost = object_cost(obj); let Some(left) = ctx.work.get().checked_sub(cost) else { continue }; ctx.work.set(left); pb.push('\u{FFFC}', CharProps::default()); @@ -612,6 +608,17 @@ struct FieldBuild { /// The target is copied once per run of equally formatted result text, not per character, /// and each copy is charged to `work` ([`MAX_WORK`]); once that is spent the rest of the /// result is kept as plain text (#3708). +/// Bytes a clone of `obj` copies: the object itself plus a picture's alt text, a group with all +/// its members (#3710). +fn object_cost(obj: &InlineObject) -> u64 { + let shallow = std::mem::size_of::() as u64; + match obj { + InlineObject::Image { alt, .. } => shallow.saturating_add(alt.len() as u64), + InlineObject::Group { children, .. } => children.iter().fold(shallow, |n, c| n.saturating_add(object_cost(&c.obj))), + _ => shallow, + } +} + fn finish_field(pb: &mut ParaBuild, f: FieldBuild, work: &std::cell::Cell) { let instr = f.instr.trim().to_string(); let result = f.sep.unwrap_or_default(); diff --git a/crates/docbin/src/tests/drawing.rs b/crates/docbin/src/tests/drawing.rs index eeff4651b8..bbf80a5db6 100644 --- a/crates/docbin/src/tests/drawing.rs +++ b/crates/docbin/src/tests/drawing.rs @@ -377,6 +377,33 @@ fn a_group_named_by_many_anchors_is_built_once() { assert_eq!(groups, [1000]); } +/// #3710: a floating picture's copy is charged its alt text too, and a group every member's. +#[test] +fn a_floating_copy_is_charged_its_alt_text() { + let image = |alt: String| InlineObject::Image { + media: String::new(), + w: 1.0, + h: 1.0, + alt, + float: Default::default(), + crop: Default::default(), + ole: None, + }; + let bare = crate::object_cost(&image(String::new())); + assert_eq!(crate::object_cost(&image("€".repeat(4096))), bare + 3 * 4096); + let member = |obj| wordcraft_doc::para::GroupChild { x: 0.0, y: 0.0, obj }; + let group = InlineObject::Group { + w: 1.0, + h: 1.0, + float: Default::default(), + ch_w: 1.0, + ch_h: 1.0, + children: vec![member(image("x".repeat(100))), member(image(String::new()))], + canvas: None, + }; + assert_eq!(crate::object_cost(&group), 3 * bare + 100); +} + /// A deflated WMF BLIP record declaring `size` uncompressed bytes. fn packed_wmf(size: u32, packed: &[u8]) -> Vec { let mut hdr = size.to_le_bytes().to_vec(); From 3aadd0a4af8bd3460105c6f2a6da19df3127c68f Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Fri, 23 Oct 2026 02:45:56 +0100 Subject: [PATCH 064/199] =?UTF-8?q?#3642:=20review=20=E2=80=94=20compound-?= =?UTF-8?q?file=20check=20refuses=20a=20FAT=20sector=20listed=20twice=20or?= =?UTF-8?q?=20more=20FAT=20sectors=20than=20the=20file=20needs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01CX1hMKX28764Lrx5moxs1o --- crates/doc/src/cfb_guard.rs | 46 +++++++++++++++++++++++++++++++------ 1 file changed, 39 insertions(+), 7 deletions(-) diff --git a/crates/doc/src/cfb_guard.rs b/crates/doc/src/cfb_guard.rs index c50bd9314d..5e9befb544 100644 --- a/crates/doc/src/cfb_guard.rs +++ b/crates/doc/src/cfb_guard.rs @@ -6,9 +6,10 @@ /// only validates the FAT once it is built, so a small header naming one sector over and over /// makes it allocate gigabytes. Here the DIFAT is walked the way `cfb` walks it (header entries, /// then the DIFAT chain, at most one step per sector; trailing zero and free entries trimmed -/// the same way) and the file is refused when it lists more FAT sectors than the file has -/// sectors, so the FAT `cfb` builds is never larger than the file. Anything else that is wrong -/// is left for `cfb` to report. +/// the same way) and the file is refused when it lists one FAT sector twice, or more FAT sectors +/// than its sectors need (one FAT sector covers `sector_len / 4` of them, plus one spare), so the +/// FAT `cfb` builds is about the size the file needs. Anything else that is wrong is left for +/// `cfb` to report. pub fn check(bytes: &[u8]) -> Result<(), String> { const MAGIC: [u8; 8] = [0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1]; const FREE: u32 = 0xFFFF_FFFF; @@ -51,10 +52,17 @@ pub fn check(bytes: &[u8]) -> Result<(), String> { while difat.last() == Some(&FREE) { difat.pop(); } - // Each listed FAT sector adds a whole sector of entries. A file can't hold more FAT sectors - // than it has sectors, and below that the FAT is no larger than the file. - if difat.len() > num_sectors { - return Err(format!("the DIFAT lists {} FAT sectors in a file of {num_sectors} sectors", difat.len())); + // Each listed FAT sector adds a whole sector of entries. A FAT sector holds the entries of + // `sector_len / 4` sectors, so a file needs no more FAT sectors than that covers (one spare), + // and none of them twice. A free entry in the middle is left for `cfb` to refuse. + let listed: Vec = difat.into_iter().filter(|&s| s != FREE).collect(); + let needed = num_sectors.div_ceil(sector_len / 4).saturating_add(1); + if listed.len() > needed { + return Err(format!("the DIFAT lists {} FAT sectors in a file of {num_sectors} sectors", listed.len())); + } + let mut seen = std::collections::HashSet::with_capacity(listed.len()); + if let Some(dup) = listed.iter().find(|&&s| !seen.insert(s)) { + return Err(format!("the DIFAT lists FAT sector {dup} twice")); } Ok(()) } @@ -97,4 +105,28 @@ mod tests { assert!(super::check(&super::repeating_difat_sample()).is_err_and(|e| e.contains("FAT sectors in a file of"))); assert!(super::check(b"not a compound file").is_ok()); } + + /// A 512-byte-sector file of `sectors` sectors after the header, whose header DIFAT lists + /// `fat` (the rest free). + fn header_difat(sectors: usize, fat: &[u32]) -> Vec { + let mut f = vec![0u8; 512 * (sectors + 1)]; + f[..8].copy_from_slice(&[0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1]); + f[0x1A..0x1C].copy_from_slice(&3u16.to_le_bytes()); + f[0x2C..0x30].copy_from_slice(&(fat.len() as u32).to_le_bytes()); + f[0x44..0x48].copy_from_slice(&0xFFFF_FFFEu32.to_le_bytes()); + for i in 0..109 { + let v = fat.get(i).copied().unwrap_or(u32::MAX); + f[0x4C + i * 4..0x50 + i * 4].copy_from_slice(&v.to_le_bytes()); + } + f + } + + #[test] + fn a_fat_sector_listed_twice_or_more_than_the_file_needs_is_refused() { + // Each list is shorter than the file has sectors, which the guard used to accept. + assert!(super::check(&header_difat(10, &[0, 0])).is_err_and(|e| e.contains("FAT sector 0 twice"))); + assert!(super::check(&header_difat(10, &[0, 1, 2])).is_err_and(|e| e.contains("3 FAT sectors"))); + assert!(super::check(&header_difat(10, &[0])).is_ok()); + assert!(super::check(&header_difat(200, &[0, 1])).is_ok()); + } } From bd3d260837b9a901244c42b86985f4647b3aa089 Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Fri, 23 Oct 2026 02:45:56 +0100 Subject: [PATCH 065/199] =?UTF-8?q?#3708:=20review=20=E2=80=94=20finish=5F?= =?UTF-8?q?field's=20doc=20comment=20back=20on=20finish=5Ffield?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01CX1hMKX28764Lrx5moxs1o --- crates/docbin/src/lib.rs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/crates/docbin/src/lib.rs b/crates/docbin/src/lib.rs index 12e4f57afd..549de5a4de 100644 --- a/crates/docbin/src/lib.rs +++ b/crates/docbin/src/lib.rs @@ -602,12 +602,6 @@ struct FieldBuild { sep: Option>, } -/// Emit a finished field: HYPERLINK keeps its result text as a linked run; every other -/// field becomes one inline object with its cached result. -/// -/// The target is copied once per run of equally formatted result text, not per character, -/// and each copy is charged to `work` ([`MAX_WORK`]); once that is spent the rest of the -/// result is kept as plain text (#3708). /// Bytes a clone of `obj` copies: the object itself plus a picture's alt text, a group with all /// its members (#3710). fn object_cost(obj: &InlineObject) -> u64 { @@ -619,6 +613,12 @@ fn object_cost(obj: &InlineObject) -> u64 { } } +/// Emit a finished field: HYPERLINK keeps its result text as a linked run; every other +/// field becomes one inline object with its cached result. +/// +/// The target is copied once per run of equally formatted result text, not per character, +/// and each copy is charged to `work` ([`MAX_WORK`]); once that is spent the rest of the +/// result is kept as plain text (#3708). fn finish_field(pb: &mut ParaBuild, f: FieldBuild, work: &std::cell::Cell) { let instr = f.instr.trim().to_string(); let result = f.sep.unwrap_or_default(); From 17d19b42c5dc58d8ee39ad5826cbc52dd8e64948 Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Fri, 23 Oct 2026 11:43:17 +0100 Subject: [PATCH 066/199] =?UTF-8?q?#3642:=20review=20=E2=80=94=20the=20DIF?= =?UTF-8?q?AT=20sample=20is=20test-only,=20valid=20.doc/.xls=20files=20pas?= =?UTF-8?q?s=20the=20guard,=20and=20budget=20drops=20are=20logged?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - repeating_difat_sample is built only for tests: this crate's, and the readers' through a `test-support` feature they turn on as a dev-dependency; it is no longer in the production API. - Tests write valid compound files with the cfb crate in Word's and Excel's shapes (WordDocument + table stream with Data, summary and ObjectPool streams; Workbook with summary streams), in 512- and 4096-byte sectors, plus an 8 MB one whose FAT sectors spill into a DIFAT chain; each passes cfb_guard::check and opens (no Word- or Excel-made files are added). - The .doc work budget notes what it leaves out (floating objects, link targets, alt text, Prm formatting) and logs one warning per kind once the document is read (#3692). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01CX1hMKX28764Lrx5moxs1o --- crates/doc/Cargo.toml | 5 ++ crates/doc/src/cfb_guard.rs | 5 +- crates/docbin/Cargo.toml | 1 + crates/docbin/src/lib.rs | 115 ++++++++++++++++++++++++----- crates/docbin/src/tests.rs | 91 ++++++++++++++++++++++- crates/docbin/src/tests/drawing.rs | 2 + crates/docx/Cargo.toml | 1 + crates/formats/Cargo.toml | 1 + crates/formats/src/sheet/xls.rs | 44 +++++++++++ docs/file-format-parity.md | 5 +- 10 files changed, 242 insertions(+), 28 deletions(-) diff --git a/crates/doc/Cargo.toml b/crates/doc/Cargo.toml index 9ab62d34d8..5a6d6622e4 100644 --- a/crates/doc/Cargo.toml +++ b/crates/doc/Cargo.toml @@ -8,6 +8,11 @@ description = "WordCraft document model: stories, paragraphs, runs, tables, sect [lints] workspace = true +[features] +# Test inputs other crates' tests share (`cfb_guard::repeating_difat_sample`); turned on only as a +# dev-dependency, so never built into the app. +test-support = [] + [dependencies] wordcraft-geom = { workspace = true } base64 = { workspace = true } diff --git a/crates/doc/src/cfb_guard.rs b/crates/doc/src/cfb_guard.rs index 5e9befb544..ec32e4a1a8 100644 --- a/crates/doc/src/cfb_guard.rs +++ b/crates/doc/src/cfb_guard.rs @@ -69,8 +69,9 @@ pub fn check(bytes: &[u8]) -> Result<(), String> { /// A compound file of about 1 MB whose DIFAT chain lists one FAT sector 2048 × 127 times: /// without [`check`], `cfb` builds a FAT of about 130 MB for it. Regression input for the -/// readers' tests. -#[doc(hidden)] +/// readers' tests: built only for tests, this crate's and (through the `test-support` feature, +/// which they turn on as a dev-dependency) the readers', never into the app. +#[cfg(any(test, feature = "test-support"))] pub fn repeating_difat_sample() -> Vec { const S: usize = 512; const CHAIN: u32 = 2048; // DIFAT sectors 1..=CHAIN, sector 0 is the "FAT" diff --git a/crates/docbin/Cargo.toml b/crates/docbin/Cargo.toml index 87e9ea1fd5..c819c33885 100644 --- a/crates/docbin/Cargo.toml +++ b/crates/docbin/Cargo.toml @@ -20,5 +20,6 @@ log = { workspace = true } chrono = { workspace = true } [dev-dependencies] +wordcraft-doc = { workspace = true, features = ["test-support"] } proptest = { workspace = true } serde_json = { workspace = true } diff --git a/crates/docbin/src/lib.rs b/crates/docbin/src/lib.rs index 549de5a4de..845171251a 100644 --- a/crates/docbin/src/lib.rs +++ b/crates/docbin/src/lib.rs @@ -52,8 +52,88 @@ const MAX_TOTAL_CHARS: u64 = 64 << 20; /// 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, -/// copied strings left empty and floating objects dropped. +/// copied strings left empty and floating objects dropped, each kind logged once a document +/// ([`Work::log`]). const MAX_WORK: u64 = 64 << 20; + +/// What the [`MAX_WORK`] budget pays for, and leaves out once it is spent. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Cost { + /// A piece's Prm grpprl: past the budget the piece keeps its CHPX formatting only. + Formatting, + /// A hyperlink target copied into a run of its result: past it the text is kept, unlinked. + LinkTarget, + /// A picture's alt text copied into an anchor: past it the picture has none. + AltText, + /// A floating object copied into its anchor: past it the object is left out. + FloatingObject, +} + +impl Cost { + const ALL: [Cost; 4] = [Cost::Formatting, Cost::LinkTarget, Cost::AltText, Cost::FloatingObject]; + + /// What is left out, for the log. + fn left_out(self) -> &'static str { + match self { + Cost::Formatting => "some text keeps only part of its formatting", + Cost::LinkTarget => "some hyperlinks' text is kept without its link", + Cost::AltText => "some pictures are left without their alt text", + Cost::FloatingObject => "some floating shapes and pictures are left out", + } + } +} + +/// One document's [`MAX_WORK`] budget, and what it has left out (each kind logged once when +/// the document has been read, [`Work::log`]). +struct Work { + left: std::cell::Cell, + dropped: std::cell::Cell<[bool; 4]>, +} + +impl Work { + fn new(budget: u64) -> Self { + Work { left: std::cell::Cell::new(budget), dropped: std::cell::Cell::new([false; 4]) } + } + + /// Spends `cost` bytes on `what`: false (and `what` noted as left out) when they aren't left. + fn charge(&self, cost: u64, what: Cost) -> bool { + match self.left.get().checked_sub(cost) { + Some(left) => { + self.left.set(left); + true + } + None => { + let mut dropped = self.dropped.get(); + if let Some(d) = Cost::ALL.iter().position(|c| *c == what).and_then(|i| dropped.get_mut(i)) { + *d = true; + } + self.dropped.set(dropped); + false + } + } + } + + /// The kinds of work left out so far, in [`Cost::ALL`] order. + fn dropped(&self) -> Vec { + Cost::ALL.iter().zip(self.dropped.get()).filter(|(_, d)| *d).map(|(c, _)| *c).collect() + } + + /// Logs one warning for each kind left out. + fn log(&self) { + let dropped = self.dropped(); + for c in &dropped { + log::warn!(".doc: the document repeats more than WordCraft copies ({MAX_WORK} bytes): {}", c.left_out()); + } + #[cfg(test)] + LAST_DROPPED.with(|d| *d.borrow_mut() = dropped); + } +} + +#[cfg(test)] +thread_local! { + /// What the last document read on this thread left out for the work budget ([`Work::log`]). + static LAST_DROPPED: std::cell::RefCell> = const { std::cell::RefCell::new(Vec::new()) }; +} /// `sprmCPicLocation`: the fc of a picture in the Data stream. const C_PIC_LOCATION: u16 = 0x6A03; /// `sprmCSymbol`: the record font and character of a symbol character ([MS-DOC] §2.6.1); the @@ -186,7 +266,7 @@ pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result) -> Result { /// 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, - /// Repeated-work bytes left ([`MAX_WORK`]). - work: &'a std::cell::Cell, + /// Repeated-work bytes left ([`MAX_WORK`]), and what they left out. + work: &'a Work, } /// State while assembling one paragraph from per-CP formatting. @@ -528,13 +609,7 @@ fn walk<'a>(ctx: &WalkCtx<'a>, cp_start: u32, cp_end: u32, pics: &mut media::Pic Some(p) => { // Every anchor of a picture gets its own copy of the alt text; the // copies are charged to the work budget (#3709). - let alt = match ctx.work.get().checked_sub(p.alt.len() as u64) { - Some(left) => { - ctx.work.set(left); - p.alt.clone() - } - None => String::new(), - }; + let alt = if ctx.work.charge(p.alt.len() as u64, Cost::AltText) { p.alt.clone() } else { String::new() }; pb.push('\u{FFFC}', CharProps::default()); pb.objects.push(InlineObject::Image { media: p.key.clone(), @@ -555,9 +630,9 @@ fn walk<'a>(ctx: &WalkCtx<'a>, cp_start: u32, cp_end: u32, pics: &mut media::Pic // Each copy (a story can be walked more than once) is charged to the work budget // by what a clone copies: a picture's alt text, a group with all its members (#3710). for (_, obj) in ctx.drawn.get(at..).unwrap_or_default().iter().take_while(|(c, _)| *c == cp) { - let cost = object_cost(obj); - let Some(left) = ctx.work.get().checked_sub(cost) else { continue }; - ctx.work.set(left); + if !ctx.work.charge(object_cost(obj), Cost::FloatingObject) { + continue; + } pb.push('\u{FFFC}', CharProps::default()); pb.objects.push(obj.clone()); } @@ -619,7 +694,7 @@ fn object_cost(obj: &InlineObject) -> u64 { /// The target is copied once per run of equally formatted result text, not per character, /// and each copy is charged to `work` ([`MAX_WORK`]); once that is spent the rest of the /// result is kept as plain text (#3708). -fn finish_field(pb: &mut ParaBuild, f: FieldBuild, work: &std::cell::Cell) { +fn finish_field(pb: &mut ParaBuild, f: FieldBuild, work: &Work) { let instr = f.instr.trim().to_string(); let result = f.sep.unwrap_or_default(); if let Some(target) = hyperlink_target(&instr) { @@ -631,8 +706,7 @@ fn finish_field(pb: &mut ParaBuild, f: FieldBuild, work: &std::cell::Cell) continue; } let mut linked = props.clone(); - if let Some(left) = work.get().checked_sub(cost) { - work.set(left); + if work.charge(cost, Cost::LinkTarget) { linked.link = Some(target.clone()); } pb.push(c, linked); @@ -825,7 +899,7 @@ struct FmtMemo<'a> { /// Apply a piece's Prm (Prm0 short sprm or Prm1 grpprl) to character properties. A Prm1 /// grpprl is charged to `work`; once that is spent the grpprl is skipped. -fn apply_prm(props: &mut CharProps, prm: PrmRef<'_>, fonts: &[String], base: &CharProps, work: &std::cell::Cell) { +fn apply_prm(props: &mut CharProps, prm: PrmRef<'_>, fonts: &[String], base: &CharProps, work: &Work) { match prm { PrmRef::None => {} PrmRef::Sprm0 { isprm, val } => { @@ -838,8 +912,9 @@ fn apply_prm(props: &mut CharProps, prm: PrmRef<'_>, fonts: &[String], base: &Ch } } PrmRef::Grpprl(Some(g)) => { - let Some(left) = work.get().checked_sub(g.len() as u64) else { return }; - work.set(left); + if !work.charge(g.len() as u64, Cost::Formatting) { + return; + } for prl in sprm::iter(g) { if prl.sgc() == 2 { fmt::apply_char(props, &prl, base, fonts); diff --git a/crates/docbin/src/tests.rs b/crates/docbin/src/tests.rs index ba9bd92f1f..568268ce3a 100644 --- a/crates/docbin/src/tests.rs +++ b/crates/docbin/src/tests.rs @@ -2406,6 +2406,66 @@ fn a_difat_repeating_one_fat_sector_is_refused_before_cfb_builds_the_fat() { assert!(wordcraft_doc::cfb_guard::check(&cfb_file(&[("WordDocument", &[0u8; 4096])])).is_ok()); } +/// A compound file `cfb` writes in `version`: `streams` at its root, then `extra` streams under +/// an `ObjectPool` storage (where Word keeps embedded objects). +fn cfb_file_in(version: cfb::Version, streams: &[(&str, &[u8])], extra: &[(&str, &[u8])]) -> Vec { + let mut comp = cfb::CompoundFile::create_with_version(version, std::io::Cursor::new(Vec::new())).expect("cfb create"); + for (name, data) in streams { + comp.create_stream(format!("/{name}")).expect("cfb stream").write_all(data).expect("cfb write"); + } + comp.create_storage("/ObjectPool").expect("cfb storage"); + for (name, data) in extra { + comp.create_stream(format!("/ObjectPool/{name}")).expect("cfb stream").write_all(data).expect("cfb write"); + } + comp.into_inner().into_inner() +} + +/// #3642 review: valid compound files pass the guard and open, so it refuses only the hostile +/// ones. The repository keeps no files Word made, so these are written by the `cfb` crate in +/// the shape of Word 97–2003 files: the WordDocument and table streams of a .doc these tests +/// build, with the summary, Data and embedded-object streams Word adds, in 512-byte sectors +/// (version 3, Word's own) and 4096-byte ones (version 4); and one with a Data stream of 8 MB, +/// past what the header's 109 FAT sector slots map in 512-byte sectors (109 × 128 sectors, +/// about 7 MB), so its FAT sectors are listed in a DIFAT chain too, which the guard walks. +#[test] +fn valid_doc_shaped_compound_files_pass_the_guard_and_open() { + let doc = struct_doc(&structured_spec("Hello world\r", 12)); + let mut comp = cfb::CompoundFile::open(std::io::Cursor::new(doc.as_slice())).expect("cfb open"); + // The table stream is 1Table or 0Table, as the FIB's fWhichTblStm says: kept under its name. + let table_name = if comp.is_stream("/1Table") { "1Table" } else { "0Table" }; + let mut stream = |name: &str| { + let mut v = Vec::new(); + std::io::Read::read_to_end(&mut comp.open_stream(name).expect("stream"), &mut v).expect("read"); + v + }; + let (word, table) = (stream("/WordDocument"), stream(&format!("/{table_name}"))); + let summary = vec![0xFEu8; 4096]; + let data = |n: usize| (0..n).map(|i| (i % 251) as u8).collect::>(); + let small = data(200_000); + let big = data(8 << 20); + let ole = data(70_000); + let files = [ + ("as built", doc.clone()), + ( + "version 3", + cfb_file_in( + cfb::Version::V3, + &[("WordDocument", &word), (table_name, &table), ("Data", &small), ("\u{5}SummaryInformation", &summary)], + &[("_1234567890", &ole)], + ), + ), + ("version 4", cfb_file_in(cfb::Version::V4, &[("WordDocument", &word), (table_name, &table), ("Data", &small)], &[("_1", &ole)])), + ("with a DIFAT chain", cfb_file_in(cfb::Version::V3, &[("WordDocument", &word), (table_name, &table), ("Data", &big)], &[])), + ]; + let difat_sectors = |f: &[u8]| f.get(0x48..0x4C).map_or(0, |b| u32::from_le_bytes([b[0], b[1], b[2], b[3]])); + assert!(difat_sectors(&files[3].1) > 0, "the 8 MB file lists FAT sectors past the header's"); + for (what, f) in &files { + assert_eq!(wordcraft_doc::cfb_guard::check(f), Ok(()), "{what}"); + let doc = read(f).unwrap_or_else(|e| panic!("{what}: {e}")); + assert_eq!(body_texts(&doc).first().map(String::as_str), Some("Hello world"), "{what}"); + } +} + /// #3708: a long HYPERLINK target over a long result was copied (and compared) once per result /// character. It is now copied once per run. #[test] @@ -2433,13 +2493,35 @@ fn hyperlink_target_copies_are_charged_to_the_work_budget() { let result: Vec<(char, CharProps)> = (0..1000).map(|i| ('a', if i % 2 == 0 { bold.clone() } else { CharProps::default() })).collect(); let f = super::FieldBuild { depth: 1, instr: format!("HYPERLINK \"{}\"", "u".repeat(100)), sep: Some(result) }; let mut pb = super::ParaBuild::default(); - let budget = std::cell::Cell::new(10_000); + let budget = super::Work::new(10_000); super::finish_field(&mut pb, f, &budget); assert_eq!(pb.text.len(), 1000, "all text kept"); let linked: usize = pb.runs.iter().filter_map(|r| r.props.link.as_ref()).map(|l| l.len()).sum(); assert!(linked <= 10_000, "linked bytes {linked}"); assert_eq!(pb.runs.iter().filter(|r| r.props.link.is_some()).count(), 100); - assert_eq!(budget.get(), 0); + assert_eq!(budget.left.get(), 0); + assert_eq!(budget.dropped(), [super::Cost::LinkTarget], "the unlinked text is noted, to be logged"); +} + +/// #3871 review: what the work budget leaves out is noted once per kind, in a fixed order, and +/// logged once per kind when the document has been read; nothing is noted while it lasts. +#[test] +fn the_work_budget_notes_each_kind_it_leaves_out_once() { + use super::Cost::*; + let work = super::Work::new(10); + assert!(work.charge(10, AltText)); + assert!(work.dropped().is_empty(), "spent to the last byte, nothing left out"); + assert!(work.charge(0, LinkTarget), "a free copy is never refused"); + for _ in 0..3 { + assert!(!work.charge(1, FloatingObject)); + } + assert!(!work.charge(1, AltText)); + assert_eq!(work.dropped(), [AltText, FloatingObject]); + work.log(); + assert_eq!(super::LAST_DROPPED.with(|d| d.borrow().clone()), [AltText, FloatingObject]); + // A document within the budget leaves nothing out. + read(&struct_doc(&structured_spec("Hello\r", 6))).expect("opens"); + assert!(super::LAST_DROPPED.with(|d| d.borrow().is_empty())); } /// #3692: a piece's Prm grpprl (a Prc of up to 64 KB) was re-interpreted for every character. @@ -2470,7 +2552,7 @@ fn a_large_prc_is_interpreted_once_per_formatting_change() { fn prm_grpprls_past_the_work_budget_are_skipped() { use wordcraft_doc::props::CharProps; let g = grpprl(&[(0x0835, &[0x01])]); - let work = std::cell::Cell::new(g.len() as u64 + 1); + let work = super::Work::new(g.len() as u64 + 1); let base = CharProps::default(); let mut first = CharProps::default(); super::apply_prm(&mut first, crate::piece::PrmRef::Grpprl(Some(&g)), &[], &base, &work); @@ -2478,5 +2560,6 @@ fn prm_grpprls_past_the_work_budget_are_skipped() { let mut second = CharProps::default(); super::apply_prm(&mut second, crate::piece::PrmRef::Grpprl(Some(&g)), &[], &base, &work); assert_eq!(second.bold, None); - assert_eq!(work.get(), 1); + assert_eq!(work.left.get(), 1); + assert_eq!(work.dropped(), [super::Cost::Formatting]); } diff --git a/crates/docbin/src/tests/drawing.rs b/crates/docbin/src/tests/drawing.rs index bbf80a5db6..3d4955ad40 100644 --- a/crates/docbin/src/tests/drawing.rs +++ b/crates/docbin/src/tests/drawing.rs @@ -351,6 +351,8 @@ fn alt_text_copies_across_many_anchors_are_bounded() { let total: usize = alts.iter().map(|a| a.len()).sum(); assert!(total <= 64 << 20, "alt bytes {total}"); assert_eq!(alts.last().copied(), Some("")); + // Leaving it out is logged, once (#3871 review). + assert_eq!(crate::LAST_DROPPED.with(|d| d.borrow().clone()), [crate::Cost::AltText]); } /// #3710: every PlcSpa entry naming a group built another copy of all its members. A shape is diff --git a/crates/docx/Cargo.toml b/crates/docx/Cargo.toml index 4dd1d510c0..5e16d7e4c2 100644 --- a/crates/docx/Cargo.toml +++ b/crates/docx/Cargo.toml @@ -31,6 +31,7 @@ zeroize = { workspace = true } getrandom = { workspace = true, features = ["wasm_js"] } [dev-dependencies] +wordcraft-doc = { workspace = true, features = ["test-support"] } proptest = { workspace = true } image = { workspace = true } zip = { workspace = true } diff --git a/crates/formats/Cargo.toml b/crates/formats/Cargo.toml index bf447f2752..faf6e41583 100644 --- a/crates/formats/Cargo.toml +++ b/crates/formats/Cargo.toml @@ -25,4 +25,5 @@ chrono = { workspace = true } js-sys = "0.3" [dev-dependencies] +wordcraft-doc = { workspace = true, features = ["test-support"] } proptest = { workspace = true } diff --git a/crates/formats/src/sheet/xls.rs b/crates/formats/src/sheet/xls.rs index df6deb7669..9d8dcfb123 100644 --- a/crates/formats/src/sheet/xls.rs +++ b/crates/formats/src/sheet/xls.rs @@ -550,6 +550,50 @@ mod tests { assert!(sheet_names(&f).is_err_and(|e| e.contains("FAT sectors in a file of"))); } + /// #3642 review: valid workbooks pass the guard and open. The repository keeps no files + /// Excel made, so these are written by the `cfb` crate in the shape of Excel 97–2003 ones: + /// the Workbook stream [`build`] writes with the summary streams Excel adds, in 512-byte + /// sectors (version 3, Excel's own) and 4096-byte ones (version 4); and one with an 8 MB + /// stream beside it, past what the header's 109 FAT sector slots map in 512-byte sectors, so + /// its FAT sectors are listed in a DIFAT chain too, which the guard walks. + #[test] + fn valid_xls_shaped_compound_files_pass_the_guard_and_open() { + use std::io::{Read, Write}; + let book = build(&[("Sheet1", rows(&[&["a", "1"], &["b", "2"]]))]).unwrap(); + let mut workbook = Vec::new(); + cfb::CompoundFile::open(Cursor::new(book.as_slice())).unwrap().open_stream("/Workbook").unwrap().read_to_end(&mut workbook).unwrap(); + let file = |version: cfb::Version, streams: &[(&str, &[u8])]| { + let mut comp = cfb::CompoundFile::create_with_version(version, Cursor::new(Vec::new())).unwrap(); + for (name, data) in streams { + comp.create_stream(format!("/{name}")).unwrap().write_all(data).unwrap(); + } + comp.into_inner().into_inner() + }; + let summary = vec![0xFEu8; 4096]; + let big: Vec = (0..8usize << 20).map(|i| (i % 251) as u8).collect(); + let files = [ + ("as built", book.clone()), + ( + "version 3", + file( + cfb::Version::V3, + &[("Workbook", &workbook), ("\u{5}SummaryInformation", &summary), ("\u{5}DocumentSummaryInformation", &summary)], + ), + ), + ("version 4", file(cfb::Version::V4, &[("Workbook", &workbook), ("\u{5}SummaryInformation", &summary)])), + ("with a DIFAT chain", file(cfb::Version::V3, &[("Workbook", &workbook), ("Ctls", &big)])), + ]; + let difat_sectors = |f: &[u8]| f.get(0x48..0x4C).map_or(0, |b| u32::from_le_bytes([b[0], b[1], b[2], b[3]])); + assert!(difat_sectors(&files[3].1) > 0, "the 8 MB file lists FAT sectors past the header's"); + for (what, f) in &files { + assert_eq!(wordcraft_doc::cfb_guard::check(f), Ok(()), "{what}"); + assert!(is_xls(f), "{what}"); + assert_eq!(sheet_names(f).unwrap(), vec!["Sheet1"], "{what}"); + let (_, r) = read_rows(f, Pick::Index(0)).unwrap(); + assert_eq!(r, rows(&[&["a", "1"], &["b", "2"]]), "{what}"); + } + } + #[test] fn reads_a_biff8_workbook() { let book = build(&[ diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 365d226aad..c2b352424f 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity with Microsoft Word -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-25 · **Change:** trivial (repeated DOCX style IDs: save/reopen regression; previously trivial: Markdown code spans keep line-broken text literal, #3738; 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: localized TOC style ids read as the TOC styles, and a TOC in columns keeps its layout through Update Table, #3889; previously trivial: cu20we: outline caps; previously trivial: a relationship the markup names without the prefix is read as it wrote: an unqualified `id` on an element that carries a relationship names that relationship, and the writer keeps the attribute with the id the new package holds the part under; an ordinary `id` — a VML shape's, a drawing's — names no part; previously trivial: the reader takes the relationship ids the markup refers to from the relationship attributes of the main document it has already parsed, so a document with many relationships opens in the time its part takes, #3411; previously trivial: a table cell span from a `.json` file stays within 63 columns through Insert Column and Merge Cells, #3637; previously trivial: the file commands that change the document are checked on a protected document, the protection of a protected document needs an explicit stop, the session author can't change through `file.setAuthor` under protection (the CLI's `--author` uses the same protection check), and the post-check compares the settings and the metadata; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: `majorBidi`/`minorBidi` theme fonts follow the settings' theme font language, as measured in Word; previously trivial: Word signature lines are kept on save, still signable; previously trivial: Times New Roman keeps Liberation Serif under the roman-font rule; previously trivial: shape gradients and transparency, #1500; previously trivial: a folder that keeps no Unix modes, such as a NAS share mounted by GVFS, still takes a save, #3003; previously trivial: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: table style band sizes written, a style without them unbanded as in Word, #3415; previously trivial: HTML export writes ruby (furigana, phonetic guides) and HTML import reads it back, #1681; previously trivial: RTF import decodes the East Asian multibyte code pages, #1653; previously trivial: PDF export: lam-alef drawn as two glyphs at one x extracts in reading order, #2310; previously trivial: native .json stores pictures and kept parts as base64, #586 follow-up; previously trivial: ODT headers and footers read from each master page, #863; previously trivial: RTF import compares heading and code formatting with the file's stylesheet; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: Word XML and web pages saved as `.doc` open by what they hold, #2853; previously trivial: a VML shape in a header cell is always placed on the page, user-checked in Word for Mac, #1775; previously trivial: `w:sym` characters keep their symbol font when the text's font changes and are written back as `w:sym`, #2036; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: two lines in one and horizontal in vertical (`w:eastAsianLayout`) kept on save; previously trivial: PDF outline targets follow leading page breaks; previously trivial: positional tabs `w:ptab` read and kept on save, #2367; previously trivial: DOCX: mixed right-to-left runs split and marked `w:rtl` with their language, #1543; previously trivial: `w:doNotTrackMoves` round-trips and turns move tracking off; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: DOCX glossary documents (building blocks) read, kept and written; previously trivial: cached field results keep their visible run formatting; previously trivial: cached REF results retain hyperlink destinations; previously trivial: CLI conversions can explicitly refresh fields; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: a pasted HTML `
` is an indented paragraph, not the Quote style, #1270); previously trivial: every table style part round-trips, #714; previously trivial: VML freeforms read and kept on save; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: `flowChartOr` draws its circle and cross spokes, as in Word; previously trivial: RTF: Mac OS Roman and DOS text, #1653; previously trivial: VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: PDF export draws horizontal and vertical lines inside groups, #1474; previously trivial: symbol-font text exported as Unicode, #776; previously trivial: text-box margins, Resize shape to fit text and freeforms in groups, #743; previously trivial: formatted paste from the system clipboard's HTML and RTF, #687; previously trivial: independent complex-script formatting inheritance and round-trip regression tests; previously trivial: import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-25 · **Change:** trivial (the `.doc` reader logs what its copy budget leaves out, once a kind per document; previously trivial: repeated DOCX style IDs: save/reopen regression; previously trivial: Markdown code spans keep line-broken text literal, #3738; 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: localized TOC style ids read as the TOC styles, and a TOC in columns keeps its layout through Update Table, #3889; previously trivial: cu20we: outline caps; previously trivial: a relationship the markup names without the prefix is read as it wrote: an unqualified `id` on an element that carries a relationship names that relationship, and the writer keeps the attribute with the id the new package holds the part under; an ordinary `id` — a VML shape's, a drawing's — names no part; previously trivial: the reader takes the relationship ids the markup refers to from the relationship attributes of the main document it has already parsed, so a document with many relationships opens in the time its part takes, #3411; previously trivial: a table cell span from a `.json` file stays within 63 columns through Insert Column and Merge Cells, #3637; previously trivial: the file commands that change the document are checked on a protected document, the protection of a protected document needs an explicit stop, the session author can't change through `file.setAuthor` under protection (the CLI's `--author` uses the same protection check), and the post-check compares the settings and the metadata; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: `majorBidi`/`minorBidi` theme fonts follow the settings' theme font language, as measured in Word; previously trivial: Word signature lines are kept on save, still signable; previously trivial: Times New Roman keeps Liberation Serif under the roman-font rule; previously trivial: shape gradients and transparency, #1500; previously trivial: a folder that keeps no Unix modes, such as a NAS share mounted by GVFS, still takes a save, #3003; previously trivial: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: table style band sizes written, a style without them unbanded as in Word, #3415; previously trivial: HTML export writes ruby (furigana, phonetic guides) and HTML import reads it back, #1681; previously trivial: RTF import decodes the East Asian multibyte code pages, #1653; previously trivial: PDF export: lam-alef drawn as two glyphs at one x extracts in reading order, #2310; previously trivial: native .json stores pictures and kept parts as base64, #586 follow-up; previously trivial: ODT headers and footers read from each master page, #863; previously trivial: RTF import compares heading and code formatting with the file's stylesheet; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: Word XML and web pages saved as `.doc` open by what they hold, #2853; previously trivial: a VML shape in a header cell is always placed on the page, user-checked in Word for Mac, #1775; previously trivial: `w:sym` characters keep their symbol font when the text's font changes and are written back as `w:sym`, #2036; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: two lines in one and horizontal in vertical (`w:eastAsianLayout`) kept on save; previously trivial: PDF outline targets follow leading page breaks; previously trivial: positional tabs `w:ptab` read and kept on save, #2367; previously trivial: DOCX: mixed right-to-left runs split and marked `w:rtl` with their language, #1543; previously trivial: `w:doNotTrackMoves` round-trips and turns move tracking off; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: DOCX glossary documents (building blocks) read, kept and written; previously trivial: cached field results keep their visible run formatting; previously trivial: cached REF results retain hyperlink destinations; previously trivial: CLI conversions can explicitly refresh fields; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: a pasted HTML `
` is an indented paragraph, not the Quote style, #1270); previously trivial: every table style part round-trips, #714; previously trivial: VML freeforms read and kept on save; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: `flowChartOr` draws its circle and cross spokes, as in Word; previously trivial: RTF: Mac OS Roman and DOS text, #1653; previously trivial: VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: PDF export draws horizontal and vertical lines inside groups, #1474; previously trivial: symbol-font text exported as Unicode, #776; previously trivial: text-box margins, Resize shape to fit text and freeforms in groups, #743; previously trivial: formatted paste from the system clipboard's HTML and RTF, #687; previously trivial: independent complex-script formatting inheritance and round-trip regression tests; previously trivial: import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats Every format Word opens or saves, what WordCraft does with it, and how it's tested. Fidelity is the share of a typical real-world file's content and formatting that survives (read: shows correctly; @@ -49,7 +49,7 @@ Weights are the share of a Word user's file traffic (estimate). Read/write: ✅ | `.docx` Word Document (ECMA-376 Transitional) | 70% | R/W | ✅ | ✅ | ~65% | 70–110 | `crates/docx/tests` (roundtrip 34, fixtures 29, malformed 8, bidi 5, bidi_runs 12, citations 5); synthetic fixtures only | Word opens our files. Untested on a real-world corpus. Tracked changes inside a field result stay revisions (the field is kept as a range, #1037). Details below | | `.docx` Strict Open XML | 1% | R/W | ✅ (namespace mapping in `xml.rs`) | ❌ | ~60% | 2–4 | `strict_namespace_and_bad_numbers`, `strict_line_heights_with_a_unit_are_read_as_point_multiples` | Writing Strict is rare; unit-suffixed measures (`13.90pt`) read as points (#2329) | | `.docm` / `.dotx` / `.dotm` | 3% | R/W | ✅ | ✅ | ~65% | incl. above | `macro_packages.rs` | Macros and signatures kept on save (#172); macros never run | -| `.doc` Word 97-2003 (and `.dot`) | 8% | R/W | 🟡 | ❌ | ~60% | 25–40 | `crates/docbin/src/tests.rs`, `tests/drawing.rs` | Spec-based reader ([MS-DOC]): text, formatting, styles, sections, headers/footers, tables, lists, notes, fields, bookmarks; inline and floating pictures (PNG, JPEG, DIB, TIFF, EMF, WMF; Macintosh PICT as a sized placeholder) with crop, alt text, position and wrap; basic shapes, lines, groups and text boxes ([MS-ODRAW]). Table cell shading from every SHD record (`sprmTDefTableShd`, `sprmTDefTableShdRaw` and their 2nd/3rd ranges, with the Word 97 `Shd80` as fallback; a clear pattern shows its background colour); the row-end mark is not a cell, and each table has its own grid and properties (#1380). Horizontally merged rows stored as fewer, wider cells come with #1123. Freeform shapes, z-order and Word 6/95 files are dropped or refused. Password-protected files (XOR obfuscation, RC4, RC4 CryptoAPI) open after a password prompt, from the public specs; not yet tried on a file Word wrote (AES-encrypted ones are refused). No writer. DIB conversion caps BMP bytes (header included) and encoded PNG at the supplied BLIP byte limit, and uses it as the decoder's allocation limit; a BMP fallback is kept if it fits (related #1334 and #1554). Hostile files are bounded: a sector table longer than the file is refused before parsing (#3642); formatting and link runs are read once per change, not per character (#3692, #3708); copies of alt text, link targets and drawn objects share a 64 MB budget (#3709, #3710); picture decoding shares a 512 MB budget and aliased store entries decode once (#3711, #3712). | +| `.doc` Word 97-2003 (and `.dot`) | 8% | R/W | 🟡 | ❌ | ~60% | 25–40 | `crates/docbin/src/tests.rs`, `tests/drawing.rs` | Spec-based reader ([MS-DOC]): text, formatting, styles, sections, headers/footers, tables, lists, notes, fields, bookmarks; inline and floating pictures (PNG, JPEG, DIB, TIFF, EMF, WMF; Macintosh PICT as a sized placeholder) with crop, alt text, position and wrap; basic shapes, lines, groups and text boxes ([MS-ODRAW]). Table cell shading from every SHD record (`sprmTDefTableShd`, `sprmTDefTableShdRaw` and their 2nd/3rd ranges, with the Word 97 `Shd80` as fallback; a clear pattern shows its background colour); the row-end mark is not a cell, and each table has its own grid and properties (#1380). Horizontally merged rows stored as fewer, wider cells come with #1123. Freeform shapes, z-order and Word 6/95 files are dropped or refused. Password-protected files (XOR obfuscation, RC4, RC4 CryptoAPI) open after a password prompt, from the public specs; not yet tried on a file Word wrote (AES-encrypted ones are refused). No writer. DIB conversion caps BMP bytes (header included) and encoded PNG at the supplied BLIP byte limit, and uses it as the decoder's allocation limit; a BMP fallback is kept if it fits (related #1334 and #1554). Hostile files are bounded: a sector table longer than the file is refused before parsing (#3642); formatting and link runs are read once per change, not per character (#3692, #3708); copies of alt text, link targets and drawn objects share a 64 MB budget (#3709, #3710), and what it leaves out is logged once a kind per document; picture decoding shares a 512 MB budget and aliased store entries decode once (#3711, #3712). | | PDF export | 8% | W | — | ✅ | ~80% | 6–10 | `crates/pdf/src/tests.rs` | krilla: embedded subsets, selectable text incl. field results (#133); Hebrew and Arabic copy and search as whole words (right-to-left glyphs written in visual order, #1383, also with fonts that draw lam-alef as two glyphs at one x, #2310); All Caps and Small Caps text extracts as the capitals shown, as from Word's PDFs, and never changes the case of other text (#1473), links, outline, tagging. Outline destinations follow leading page breaks to the heading text. Fonts it can't subset become outlines (#132). Print Markup (#589): comments in balloons beside each page (scaled to its paper) and tracked changes; HTML/Markdown/text copies end with a comment list. Horizontal and vertical lines inside groups are drawn (#1474). No PDF/A option, no "best for printing/online" choice | | PDF open (PDF Reflow, Word for Windows) | 1% | R | ❌ | — | 0% | 30–50 | — | Converting a PDF into an editable document. A sibling app (PdfCraft) may supply the parser | | `.rtf` Rich Text Format | 3% | R/W | 🟡 | 🟡 | ~50% | 10–15 | `crates/formats/src/tests.rs` | ~1,500 lines: text, character/paragraph formatting, styles, lists, tables, pictures (`\shppict`, #154), fields, bookmarks. 8-bit text in the document's code page (`\ansicpg`) or the current font's (`\fcharset`, `\cpg`): Cyrillic, Central European, Greek, Turkish, Hebrew, Arabic, Baltic, Vietnamese, Thai (was Windows-1252 only), Mac OS Roman (`\mac`, `\fcharset77`) and DOS code pages (`\pc` 437, `\pca` 850, `\cpg` 437/737/850/852/857/866, `\fcharset254`) (#1653). East Asian multibyte code pages decoded as runs: 932 Shift-JIS (`\fcharset128`, Japanese), 936 GBK (134, Simplified Chinese), 949 UHC/windows-949 (129, Korean) and 950 Big5 (136, Traditional Chinese) through `\ansicpg`, `\cpg` or the font's charset, escaped or raw, a lead byte and its trail byte together; invalid sequences read as U+FFFD, and a run that starts `EF BB BF` is text, not a BOM (#1653). Pasted from the system clipboard where the platform offers it (Windows, Wayland) when there is no HTML (#687). Tracked insertions and deletions read and written (`\revtbl`, `\revised`/`\deleted`, authors, `\revdttm` dates), so Accept/Reject All work after opening or a round trip; tracked paragraph marks (`\par` inside `\revised`/`\deleted` groups, including a `\sect` inside a deletion) and character and paragraph formatting revisions (`\crauth`/`\crdate` with `\oldcprops`, `\prauth`/`\prdate` with `\oldpprops`) read and written; section and table formatting revisions (`\oldsprops`, `\oldtprops`) not yet. Sections on import (#1378): each `\sect` ends a section with its own page size and orientation, margins, header/footer distance, columns, break type, page-number restart, start and format, title page and headers/footers (those it leaves out link to the previous); export writes every section too (break type, page size and orientation, margins, header/footer distance, columns, page numbering, title page; not headers/footers). Thin: comments, notes, RTL | @@ -268,6 +268,7 @@ Repeated DOCX style IDs use the last complete definition. The original regressio | 2026-10-25 | trivial | Repeated DOCX style IDs: repeated save/reopen regression for whole-definition replacement. | | 2026-10-25 | 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`) | | 2026-10-23 | trivial | cu20we: preserve and render outline caps across DOCX/VML, screen and PDF; synthetic regressions; readiness estimates unchanged | +| 2026-10-23 | trivial | `.doc` reader logs what its copy budget leaves out (floating objects, link targets, alt text, Prm formatting), once a kind per document (#3692) | | 2026-10-22 | trivial | `.doc` reader bounds hostile files: oversized sector tables refused, per-character re-reads removed, copy and decode budgets (#3642, #3692, #3708-#3712) | | 2026-10-23 | trivial | A relationship the markup names without the prefix is read as it wrote: an unqualified `id` on an element that carries a relationship names that relationship, and the writer keeps the attribute with the id the new package holds the part under; an ordinary `id` — a VML shape's, a drawing's — names no part (#3411 follow-up) | | 2026-10-21 | trivial | The reader takes the relationship ids the markup refers to from the relationship attributes of the main document it has already parsed, so a document with many relationships opens in the time its part takes, and an id that only appears as text is not a reference (#3411) | From 58898d5168c38855ddc5e5abe0684b8b02d5915b Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Fri, 23 Oct 2026 15:14:06 +0100 Subject: [PATCH 067/199] =?UTF-8?q?#3692:=20review=20=E2=80=94=20docbin=20?= =?UTF-8?q?timing=20tests=20get=20a=20margin=20a=20busy=20CI=20machine=20c?= =?UTF-8?q?an't=20break?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wall-clock bounds of 1-2 s failed on loaded runners (checked against the pre-fix behaviour, re-enabled in the worktree): - DIFAT precheck: no clock; the precheck's own error is the proof it ran before cfb. - long hyperlink: no clock (the old per-character copy took ~2.5 s, too close to any safe bound); finish_field on the same field charges the work budget exactly one target copy. - large Prc: 1 s -> 10 s; ~60 ms now, interpreting it per character took 88 s. - group named by many anchors: 1 s -> 10 s backstop; ~10 ms now. The old code (6-8 s here) is caught by the group count, [1000; 3] instead of [1000]. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01CX1hMKX28764Lrx5moxs1o --- crates/docbin/src/tests.rs | 16 ++++++++++------ crates/docbin/src/tests/drawing.rs | 4 +++- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/crates/docbin/src/tests.rs b/crates/docbin/src/tests.rs index 568268ce3a..e23cd50241 100644 --- a/crates/docbin/src/tests.rs +++ b/crates/docbin/src/tests.rs @@ -2398,10 +2398,9 @@ fn paragraph_borders_read_from_both_forms() { #[test] fn a_difat_repeating_one_fat_sector_is_refused_before_cfb_builds_the_fat() { let f = wordcraft_doc::cfb_guard::repeating_difat_sample(); - let t = std::time::Instant::now(); + // The precheck's own error is the proof that it ran before cfb (no clock to skew). assert!(matches!(read(&f), Err(super::DocbinError::Container(e)) if e.contains("FAT sectors in a file of"))); assert!(!super::is_encrypted(&f)); - assert!(t.elapsed().as_secs() < 2, "refused in {:?}", t.elapsed()); // A file cfb wrote itself passes. assert!(wordcraft_doc::cfb_guard::check(&cfb_file(&[("WordDocument", &[0u8; 4096])])).is_ok()); } @@ -2474,14 +2473,18 @@ fn a_long_hyperlink_over_a_long_result_is_one_linked_run() { let target = "x".repeat(n); let text: &'static str = Box::leak(format!("\u{13}HYPERLINK \"http://{target}\"\u{14}{}\u{15}\r", "y".repeat(n)).into_boxed_str()); let spec = structured_spec(text, text.chars().count() as u32); - let t = std::time::Instant::now(); let doc = read(&struct_doc(&spec)).expect("opens"); - // Copying the target per character took ~2.5 s here; once per run takes milliseconds. - assert!(t.elapsed().as_millis() < 1000, "opened in {:?}", t.elapsed()); let p0 = doc.body.first().and_then(|b| b.as_para()).expect("para"); let linked: Vec<_> = p0.runs.iter().filter(|r| r.props.link.is_some()).collect(); assert_eq!(linked.len(), 1); 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 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); + assert_eq!(u64::MAX - work.left.get(), ("http://".len() + n) as u64, "one copy of the target"); } /// #3708: a result whose formatting changes every character pays for a target copy per run @@ -2540,7 +2543,8 @@ fn a_large_prc_is_interpreted_once_per_formatting_change() { let bytes = rich_doc_with(&spec, &RichExtra { prcs: vec![prc], prm: 0x0001, ..Default::default() }); let t = std::time::Instant::now(); let doc = read(&bytes).expect("opens"); - assert!(t.elapsed().as_millis() < 1000, "opened in {:?}", t.elapsed()); + // About 60 ms on a loaded machine; interpreting the Prc per character took 88 s. + assert!(t.elapsed().as_secs() < 10, "opened in {:?}", t.elapsed()); let p = para0(&doc); assert_eq!(p.runs.len(), 1); assert_eq!(p.runs.first().and_then(|r| r.props.bold), Some(true)); diff --git a/crates/docbin/src/tests/drawing.rs b/crates/docbin/src/tests/drawing.rs index 3d4955ad40..041267c16c 100644 --- a/crates/docbin/src/tests/drawing.rs +++ b/crates/docbin/src/tests/drawing.rs @@ -372,7 +372,9 @@ fn a_group_named_by_many_anchors_is_built_once() { let spec = StructSpec { text, extra_pairs: vec![(40, plc(&cps, &spas)), (50, art)], ..structured_spec(text, ccp) }; let t = std::time::Instant::now(); let doc = read(&struct_doc(&spec)).expect("opens"); - assert!(t.elapsed().as_millis() < 1000, "opened in {:?}", t.elapsed()); + // About 10 ms on a loaded machine, so 10 s is a backstop; the old code (a copy per entry, + // 6-8 s here) is caught by the group count below, one group per anchor ([1000; 3]). + assert!(t.elapsed().as_secs() < 10, "opened in {:?}", t.elapsed()); let p0 = doc.body.first().and_then(|b| b.as_para()).expect("para"); let groups: Vec = p0.objects.iter().filter_map(|o| if let InlineObject::Group { children, .. } = o { Some(children.len()) } else { None }).collect(); From 90efee7636c7922a9f7ebe34241fb819f1a1c7ed Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Thu, 22 Oct 2026 19:12:38 +0100 Subject: [PATCH 068/199] #3692: Zotero lists each note's fields once, within a field and code-byte budget Audit finding 12 (CWE-400): fields::list expanded a note's fields again for every body reference to it, so a crafted file with thousands of references to one citation-heavy endnote multiplied into millions of copied fields on the UI thread. Each note part is now enumerated once, at its first reference, and the list stops at MAX_FIELDS fields / MAX_CODE_BYTES of codes. Note references are walked with the paragraph's objects directly instead of object_at per marker. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/zotero/src/fields.rs | 36 +++++++++++++++++---- crates/zotero/src/lib.rs | 2 ++ crates/zotero/src/tests_limits.rs | 52 +++++++++++++++++++++++++++++++ 3 files changed, 84 insertions(+), 6 deletions(-) create mode 100644 crates/zotero/src/tests_limits.rs diff --git a/crates/zotero/src/fields.rs b/crates/zotero/src/fields.rs index c3776f9a51..21f7d2cc07 100644 --- a/crates/zotero/src/fields.rs +++ b/crates/zotero/src/fields.rs @@ -63,7 +63,14 @@ fn story_fields(doc: &Document, story: StoryRef, note_index: u32) -> Vec .collect() } +/// Most fields [`list`] hands Zotero (no real document comes near it). +pub const MAX_FIELDS: usize = 100_000; +/// Most bytes of field codes [`list`] hands Zotero, all fields together. +pub const MAX_CODE_BYTES: usize = 64 * 1024 * 1024; + /// Every Zotero field in document order: the body's, with each note's fields at its reference. +/// A note the body references more than once (a crafted file) is listed at its first reference +/// only. Past [`MAX_FIELDS`] fields or [`MAX_CODE_BYTES`] of codes the rest are left out. pub fn list(doc: &Document) -> Vec { enum Ev { Field(ZField), @@ -71,10 +78,12 @@ pub fn list(doc: &Document) -> Vec { } let mut evs: Vec<(Pos, Ev)> = story_fields(doc, StoryRef::Body, 0).into_iter().map(|f| (f.start.clone(), Ev::Field(f))).collect(); let (mut foot, mut end) = (0u32, 0u32); + let mut seen = std::collections::HashSet::new(); for path in doc.para_paths(StoryRef::Body) { let Some(p) = doc.para(StoryRef::Body, &path) else { continue }; - for off in p.object_offsets() { - if let Some(InlineObject::NoteRef { kind, id, custom }) = p.object_at(off) { + // The k-th object marker holds the k-th object. + for (off, obj) in p.object_offsets().into_iter().zip(&p.objects) { + if let InlineObject::NoteRef { kind, id, custom } = obj { let n = match kind { NoteKind::Footnote => &mut foot, NoteKind::Endnote => &mut end, @@ -82,16 +91,31 @@ pub fn list(doc: &Document) -> Vec { if custom.is_empty() { *n = n.saturating_add(1); } - evs.push((Pos::new(StoryRef::Body, path.clone(), off), Ev::Note(*id, (*n).max(1)))); + if seen.insert(*id) { + evs.push((Pos::new(StoryRef::Body, path.clone(), off), Ev::Note(*id, (*n).max(1)))); + } } } } evs.sort_by(|a, b| a.0.cmp(&b.0)); let mut out = Vec::new(); + let mut bytes = 0usize; + let mut take = |f: ZField, out: &mut Vec| { + bytes = bytes.saturating_add(f.code.len()); + let fits = out.len() < MAX_FIELDS && bytes <= MAX_CODE_BYTES; + if fits { + out.push(f); + } + fits + }; for (_, ev) in evs { - match ev { - Ev::Field(f) => out.push(f), - Ev::Note(id, n) => out.extend(story_fields(doc, StoryRef::Part(id), n)), + let fits = match ev { + Ev::Field(f) => take(f, &mut out), + Ev::Note(id, n) => story_fields(doc, StoryRef::Part(id), n).into_iter().all(|f| take(f, &mut out)), + }; + if !fits { + log::warn!("Zotero: the document has more citation fields than WordCraft hands Zotero; the rest are left out"); + break; } } out diff --git a/crates/zotero/src/lib.rs b/crates/zotero/src/lib.rs index 40eba55ebe..4c50bab040 100644 --- a/crates/zotero/src/lib.rs +++ b/crates/zotero/src/lib.rs @@ -41,3 +41,5 @@ pub enum ZoteroError { #[cfg(test)] mod tests; +#[cfg(test)] +mod tests_limits; diff --git a/crates/zotero/src/tests_limits.rs b/crates/zotero/src/tests_limits.rs new file mode 100644 index 0000000000..93cefc62c9 --- /dev/null +++ b/crates/zotero/src/tests_limits.rs @@ -0,0 +1,52 @@ +//! Hostile documents and calls stay cheap: work grows with the document, not with how often a +//! crafted file or call repeats something (#3692). + +use std::time::{Duration, Instant}; + +use wordcraft_doc::para::{NoteKind, OBJ}; +use wordcraft_doc::{CharProps, Document, InlineObject, Paragraph, PartKind, Run, StoryRef, para_block}; + +use crate::fields; + +const CITE: &str = r#"ADDIN ZOTERO_ITEM CSL_CITATION {"citationID":"k1","citationItems":[{"id":7}]}"#; + +/// A paragraph of `objects`, one marker each, in one plain run. +fn objects_para(objects: Vec) -> Paragraph { + let mut p = Paragraph::new(); + p.text = std::iter::repeat_n(OBJ, objects.len()).collect(); + p.runs = vec![Run { len: p.text.len(), props: CharProps::default() }]; + p.objects = objects; + p +} + +/// `n` empty Zotero citation fields in a row. +fn citations(n: usize) -> Vec { + (0..n).flat_map(|_| [InlineObject::FieldStart { instr: CITE.into(), locked: false, dirty: false }, InlineObject::FieldEnd]).collect() +} + +// Finding 12: one endnote referenced many times is listed once, not once per reference. +#[test] +fn a_note_referenced_many_times_is_listed_once() { + let mut doc = Document::from_text(""); + let note = doc.add_part(PartKind::Endnote, vec![para_block(objects_para(citations(200)))]); + let refs = (0..5_000).map(|_| InlineObject::NoteRef { kind: NoteKind::Endnote, id: note, custom: String::new() }).collect(); + doc.body = vec![para_block(objects_para(refs))]; + let t = Instant::now(); + let list = fields::list(&doc); + assert_eq!(list.len(), 200, "each field once, at the note's first reference"); + assert!(list.iter().all(|f| f.start.story == StoryRef::Part(note) && f.note_index == 1)); + assert!(t.elapsed() < Duration::from_secs(2), "{:?}", t.elapsed()); +} + +#[test] +fn listing_stops_at_the_field_budget() { + let mut doc = Document::from_text(""); + // Many short paragraphs: the per-paragraph object scan is another finding's (#3614). + doc.body = (0..fields::MAX_FIELDS / 100 + 1).map(|_| para_block(objects_para(citations(100)))).collect(); + assert_eq!(fields::list(&doc).len(), fields::MAX_FIELDS); + // Codes count too: a few huge codes fill the byte budget before the field budget. + let big = format!("{CITE}{}", "x".repeat(fields::MAX_CODE_BYTES / 4 - 1000)); + let huge = (0..5).flat_map(|_| [InlineObject::FieldStart { instr: big.clone(), locked: false, dirty: false }, InlineObject::FieldEnd]).collect(); + doc.body = vec![para_block(objects_para(huge))]; + assert_eq!(fields::list(&doc).len(), 4); +} From 020194ebdb5e1a80963d35e6a41015b959593c91 Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Thu, 22 Oct 2026 19:13:00 +0100 Subject: [PATCH 069/199] #3692: Zotero rich text is built in one pass, not re-normalized per run Audit finding 16 (CWE-407): rich::from_doc added each formatting run through Paragraph::insert_text, which splits and normalizes every earlier run, so a compact paragraph of alternating formatting was quadratic on the UI thread (100,000 runs: over 9 minutes; now 0.1 s). Runs are appended, merged only with the run before, and the paragraph is normalized once. A cumulative MAX_RUNS budget keeps the text but stops adding runs past it. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/zotero/src/rich.rs | 22 +++++++++++++++++++--- crates/zotero/src/tests_limits.rs | 20 ++++++++++++++++++++ 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/crates/zotero/src/rich.rs b/crates/zotero/src/rich.rs index 2fabb62bb8..6285ecf3eb 100644 --- a/crates/zotero/src/rich.rs +++ b/crates/zotero/src/rich.rs @@ -4,10 +4,12 @@ use wordcraft_doc::edit::Fragment; use wordcraft_doc::para::OBJ; -use wordcraft_doc::{Block, CharProps, Document, Paragraph}; +use wordcraft_doc::{Block, CharProps, Document, Paragraph, Run}; /// Most paragraphs taken from one text (a bibliography of thousands of entries is fine). const MAX_PARAS: usize = 100_000; +/// Most formatting runs taken from one text; past it, text keeps the formatting before it. +pub(crate) const MAX_RUNS: usize = 1_000_000; fn style_props(base: &CharProps, src: &CharProps) -> CharProps { let mut p = base.clone(); @@ -40,16 +42,30 @@ fn style_props(base: &CharProps, src: &CharProps) -> CharProps { fn from_doc(d: &Document, base: &CharProps) -> Vec { let mut out = Vec::new(); + let mut runs = 0usize; for b in d.body.iter().take(MAX_PARAS) { let Some(src) = b.as_para() else { continue }; let mut p = Paragraph::new(); p.mark = base.clone(); + // Appended run by run, merging only with the run before; normalized once at the end. for (range, props) in src.run_ranges() { let Some(t) = src.text.get(range) else { continue }; let t: String = t.chars().filter(|c| *c != OBJ).collect(); - let at = p.len(); - let _ = p.insert_text(at, &t, &style_props(base, props)); + if t.is_empty() { + continue; + } + let props = style_props(base, props); + p.text.push_str(&t); + match p.runs.last_mut() { + Some(last) if last.props == props || runs >= MAX_RUNS => last.len += t.len(), + _ => { + p.runs.push(Run { len: t.len(), props }); + runs += 1; + } + } } + p.normalize(); + p.touch(); out.push(p); } out diff --git a/crates/zotero/src/tests_limits.rs b/crates/zotero/src/tests_limits.rs index 93cefc62c9..471d385922 100644 --- a/crates/zotero/src/tests_limits.rs +++ b/crates/zotero/src/tests_limits.rs @@ -50,3 +50,23 @@ fn listing_stops_at_the_field_budget() { doc.body = vec![para_block(objects_para(huge))]; assert_eq!(fields::list(&doc).len(), 4); } + +// Finding 16: rich text with alternating formatting is built in one pass, not run by run with +// every earlier run merged again. +#[test] +fn rich_text_with_many_runs_is_linear() { + let html: String = "ab".repeat(50_000); + let t = Instant::now(); + let frag = crate::rich::fragment(&html, true, &CharProps::default()); + let elapsed = t.elapsed(); + let Some(wordcraft_doc::Block::Para(p)) = frag.blocks.first() else { panic!("no paragraph") }; + assert_eq!(p.text, "ab".repeat(50_000)); + assert_eq!(p.runs.len(), 100_000); + assert_eq!(p.runs.iter().map(|r| r.len).sum::(), p.text.len()); + assert!(elapsed < Duration::from_secs(2), "{elapsed:?}"); + // Neighbours with the same formatting are one run. + let frag = crate::rich::fragment("abc", true, &CharProps::default()); + let Some(wordcraft_doc::Block::Para(p)) = frag.blocks.first() else { panic!("no paragraph") }; + assert_eq!((p.text.as_str(), p.runs.len()), ("abc", 2)); + assert_eq!(p.props_of_char(1).italic, Some(true)); +} From ab26f22c4ac8729c1a72760aafd7c9b1a595f247 Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Thu, 22 Oct 2026 19:15:34 +0100 Subject: [PATCH 070/199] #3692: Document_convert checks each distinct field id once against one indexed list Audit finding 32 (CWE-407): Document_convert rebuilt the whole field list for every id it was given, duplicates included, before deciding whether anything had to move, so one valid id repeated 100,000 times took 18 s on the UI thread (now milliseconds). Ids are deduplicated (a repeated id takes its last note type), all of them are checked against one indexed snapshot before anything moves, already-placed citations are skipped from the snapshot, and the index is rebuilt only after a citation actually moves. The id list is capped at fields::MAX_FIELDS, the most fields Zotero is ever handed. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/zotero/src/bridge.rs | 76 +++++++++++++++++++++++++------ crates/zotero/src/tests_limits.rs | 50 +++++++++++++++++++- 2 files changed, 110 insertions(+), 16 deletions(-) diff --git a/crates/zotero/src/bridge.rs b/crates/zotero/src/bridge.rs index b8dfaedac6..36cb2d0b44 100644 --- a/crates/zotero/src/bridge.rs +++ b/crates/zotero/src/bridge.rs @@ -5,6 +5,8 @@ //! Field ids handed to Zotero stay valid for the transaction: they are kept in a list parallel //! to the document-order list of Zotero fields, updated as fields are inserted and removed. +use std::collections::HashMap; + use serde_json::{Value, json}; use wordcraft_doc::edit::Fragment; use wordcraft_doc::para::OBJ; @@ -274,21 +276,8 @@ impl Bridge { // citation moves between the text, footnotes and endnotes (switching styles). "Document_convert" => { let Value::Array(ids) = arg(c, 1)? else { return Err("field ids must be an array".into()) }; - let ids: Vec = ids.iter().take(100_000).cloned().collect(); - let notes = c.args.get(3).cloned().unwrap_or(Value::Null); - for (i, idv) in ids.iter().enumerate() { - let id = match idv { - Value::Number(n) => n.as_u64(), - Value::String(x) => x.trim().parse().ok(), - _ => None, - } - .ok_or("bad field id")?; - let note = match ¬es { - Value::Array(a) => a.get(i).and_then(Value::as_i64).unwrap_or(0), - v => v.as_i64().unwrap_or(0), - }; - self.convert_field(s, id, note)?; - } + let notes = c.args.get(3).unwrap_or(&Value::Null); + self.convert_fields(s, ids, notes)?; Ok(Value::Null) } "Field_convert" => { @@ -484,6 +473,49 @@ impl Bridge { Ok(()) } + /// `Document_convert`: each id once (listed again, its last note type wins), every id + /// checked against one indexed list of the fields before anything moves. Only a citation + /// that actually moves costs a fresh list. + fn convert_fields(&mut self, s: &mut Session, ids: &[Value], notes: &Value) -> Result<(), String> { + let mut todo: Vec<(u64, i64)> = Vec::new(); + let mut slot: HashMap = HashMap::new(); + for (i, idv) in ids.iter().take(fields::MAX_FIELDS).enumerate() { + let id = match idv { + Value::Number(n) => n.as_u64(), + Value::String(x) => x.trim().parse().ok(), + _ => None, + } + .ok_or("bad field id")?; + let note = match notes { + Value::Array(a) => a.get(i).and_then(Value::as_i64).unwrap_or(0), + v => v.as_i64().unwrap_or(0), + }; + match slot.get(&id).and_then(|k| todo.get_mut(*k)) { + Some(t) => t.1 = note, + None => { + slot.insert(id, todo.len()); + todo.push((id, note)); + } + } + } + let index = |ids: &[u64]| -> HashMap { ids.iter().enumerate().map(|(k, id)| (*id, k)).collect() }; + let mut list = self.fields(s); + let mut at = index(&self.ids); + if let Some((id, _)) = todo.iter().find(|(id, _)| !at.contains_key(id)) { + return Err(format!("no field with id {id}")); + } + for (id, note) in todo { + let f = at.get(&id).and_then(|k| list.get(*k)).ok_or_else(|| format!("no field with id {id}"))?; + if !moves(s, f, note) { + continue; + } + self.convert_field(s, id, note)?; + list = self.fields(s); + at = index(&self.ids); + } + Ok(()) + } + fn convert_placeholders(&mut self, s: &mut Session, ph: &[Value], note: i64) -> R { let mut ids = Vec::new(); for v in ph { @@ -519,6 +551,20 @@ impl Bridge { } } +/// Whether moving `f` to the text (`note` 0), a footnote (1) or an endnote (2) changes anything, +/// as [`Bridge::convert_field`] decides it. +fn moves(s: &Session, f: &ZField, note: i64) -> bool { + let current = match f.start.story { + StoryRef::Body => 0, + StoryRef::Part(pid) => match s.doc.parts.get(&pid).map(|p| p.kind) { + Some(PartKind::Footnote) => 1, + Some(PartKind::Endnote) => 2, + _ => return false, + }, + }; + current != if matches!(note, 1 | 2) { note } else { 0 } +} + /// Delete the selection; returns where it collapsed. fn delete_selection(s: &mut Session) -> Result { let (a, b) = s.sel.ordered(); diff --git a/crates/zotero/src/tests_limits.rs b/crates/zotero/src/tests_limits.rs index 471d385922..53d8eaafc1 100644 --- a/crates/zotero/src/tests_limits.rs +++ b/crates/zotero/src/tests_limits.rs @@ -3,10 +3,13 @@ use std::time::{Duration, Instant}; +use serde_json::{Value, json}; use wordcraft_doc::para::{NoteKind, OBJ}; use wordcraft_doc::{CharProps, Document, InlineObject, Paragraph, PartKind, Run, StoryRef, para_block}; +use wordcraft_engine::Session; -use crate::fields; +use crate::wire::Call; +use crate::{Bridge, Headless, fields}; const CITE: &str = r#"ADDIN ZOTERO_ITEM CSL_CITATION {"citationID":"k1","citationItems":[{"id":7}]}"#; @@ -70,3 +73,48 @@ fn rich_text_with_many_runs_is_linear() { assert_eq!((p.text.as_str(), p.runs.len()), ("abc", 2)); assert_eq!(p.props_of_char(1).italic, Some(true)); } + +/// Call `method` on a bridge over `s`. +fn call(b: &mut Bridge, s: &mut Session, method: &str, args: Value) -> Result { + let Value::Array(args) = args else { return Err("args must be an array".into()) }; + b.handle(s, &mut Headless::default(), &Call::new(method, args)) +} + +// Finding 32: Document_convert checks each distinct id once against one list of the fields. +#[test] +fn convert_with_one_id_repeated_is_one_check() { + let mut doc = Document::from_text(""); + doc.body = (0..300).map(|_| para_block(objects_para(citations(1)))).collect(); + let mut s = Session::new(doc); + let mut b = Bridge::new(); + call(&mut b, &mut s, "Application_getActiveDocument", json!([3])).unwrap(); + let got = call(&mut b, &mut s, "Document_getFields", json!([1, "Field"])).unwrap(); + let first = got[0][0].clone(); + let ids = vec![first.clone(); 100_000]; + let before = format!("{:?}", s.doc.body); + let t = Instant::now(); + call(&mut b, &mut s, "Document_convert", json!([1, ids, 0, 0])).unwrap(); + assert!(t.elapsed() < Duration::from_secs(2), "{:?}", t.elapsed()); + assert_eq!(format!("{:?}", s.doc.body), before, "already in the text: nothing to do"); + // An unknown id is refused before anything moves. + let r = call(&mut b, &mut s, "Document_convert", json!([1, [first.clone(), 999_999_999u64], 0, [1, 1]])); + assert!(r.is_err()); + assert!(s.doc.parts.is_empty(), "nothing converted"); +} + +#[test] +fn convert_with_a_repeated_id_takes_its_last_note_type() { + let mut doc = Document::from_text(""); + doc.body = (0..3).map(|_| para_block(objects_para(citations(1)))).collect(); + let mut s = Session::new(doc); + let mut b = Bridge::new(); + call(&mut b, &mut s, "Application_getActiveDocument", json!([3])).unwrap(); + let got = call(&mut b, &mut s, "Document_getFields", json!([1, "Field"])).unwrap(); + let (a, c) = (got[0][0].clone(), got[0][2].clone()); + call(&mut b, &mut s, "Document_convert", json!([1, [a.clone(), c, a], 0, [2, 1, 1]])).unwrap(); + let kinds: Vec = s.doc.parts.values().map(|p| p.kind).collect(); + assert_eq!(kinds, vec![PartKind::Footnote, PartKind::Footnote]); + // Ids stay valid: the fields are where Zotero now expects them. + let got = call(&mut b, &mut s, "Document_getFields", json!([1, "Field"])).unwrap(); + assert_eq!(got[2], json!([1, 0, 2])); +} From d8d75e44739370102ff53be42d5fdc857b08bc0d Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Thu, 22 Oct 2026 19:21:10 +0100 Subject: [PATCH 071/199] #3692: wordcraft-cli zotero escapes peer text on stderr Audit finding 79 (CWE-117): the CLI printed Zotero's alert text (and, with --trace, the method name and call/reply JSON) to stderr as received, so a peer on Zotero's port could end the "Zotero:" line, forge following output or send terminal escape sequences. escape_peer shows backslashes, control characters (C0, DEL, C1), line separators and bidi overrides as visible escapes (\\, \n, \u{1b}), keeping each record on its own attributable line. Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/wordcraft-cli/src/main.rs | 36 +++++++++++++++++++++++++++++++--- 1 file changed, 33 insertions(+), 3 deletions(-) diff --git a/apps/wordcraft-cli/src/main.rs b/apps/wordcraft-cli/src/main.rs index 752f9f68b2..0817d18b34 100644 --- a/apps/wordcraft-cli/src/main.rs +++ b/apps/wordcraft-cli/src/main.rs @@ -657,14 +657,14 @@ fn run>(args: &[S]) -> Result<(), Failure> { let out = wordcraft_zotero::client::run_command(&opts, command, &mut |call| { let r = bridge.handle(&mut s, &mut host, call); if trace { - eprintln!("← {} {}", call.method, clip(&Value::Array(call.args.clone()).to_string())); - eprintln!("→ {}", clip(&String::from_utf8_lossy(&wordcraft_zotero::wire::encode_reply(&r)))); + eprintln!("← {} {}", escape_peer(&call.method), escape_peer(&clip(&Value::Array(call.args.clone()).to_string()))); + eprintln!("→ {}", escape_peer(&clip(&String::from_utf8_lossy(&wordcraft_zotero::wire::encode_reply(&r))))); } r }) .map_err(|e| e.to_string())?; for a in &host.alerts { - eprintln!("Zotero: {a}"); + eprintln!("Zotero: {}", escape_peer(a)); } eprintln!("{} calls, {}", out.calls, if out.completed { "completed" } else { "ended without completing (cancelled?)" }); if let Some(o) = arg_value(&rest, "--save") { @@ -687,6 +687,27 @@ fn clip(s: &str) -> String { } } +/// Text from another program (Zotero, or whatever answered on its port) for one diagnostic +/// line: backslashes, control characters (newline, escape, C1…), line separators and bidi +/// overrides are shown as escapes (`\\`, `\n`, `\u{1b}`), so it can't end its line, drive the +/// terminal or pass for other output. +fn escape_peer(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for c in s.chars() { + match c { + '\\' => out.push_str("\\\\"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + c if c.is_control() || matches!(c, '\u{061c}' | '\u{200e}' | '\u{200f}' | '\u{2028}'..='\u{202e}' | '\u{2066}'..='\u{2069}') => { + out.extend(c.escape_unicode()) + } + c => out.push(c), + } + } + out +} + fn parity_markdown(p: &Value) -> String { let mut s = String::from( "# WordCraft feature parity\n\nGenerated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word-processor feature catalog (`crates/engine/src/catalog.rs`) compared with the live command registry.\n\n", @@ -733,6 +754,15 @@ mod tests { values.iter().map(|value| (*value).into()).collect() } + #[test] + fn peer_text_stays_on_one_visible_line() { + let alert = "Done.\nwrote /etc/passwd\r\u{1b}[2J\u{9b}31m\u{202e}txt.exe\\n\tok é"; + let shown = escape_peer(alert); + assert_eq!(shown, "Done.\\nwrote /etc/passwd\\r\\u{1b}[2J\\u{9b}31m\\u{202e}txt.exe\\\\n\\tok é"); + assert!(!shown.chars().any(|c| c.is_control())); + assert_eq!(escape_peer("Citation (Doe, 2020) — “ok”"), "Citation (Doe, 2020) — “ok”"); + } + #[test] fn validators_accept_only_the_documented_options() { assert!(validate_options("info", &args(&["file.docx"]), PASSWORD_OPTIONS).is_ok()); From a9cc06dc0f326f502a29598114a0e8b3c51ace96 Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Thu, 22 Oct 2026 19:27:19 +0100 Subject: [PATCH 072/199] #3692: Zotero client talks only to this computer and, on Linux, only to our own user Audit finding 21 (CWE-306): run_command handed document callback authority to whatever accepted the TCP connection. Zotero's protocol is fixed and has no authentication, so WordCraft now establishes what it can about the peer itself: - the address must be loopback, refused before connecting (ZoteroError::Refused); - on Linux/Android, before the command is sent, the kernel socket table (/proc/net/tcp, tcp6) must show the listener's end of this connection owned by the same UID as ours; another user's listener, or one whose owner can't be established, is refused. No unsafe, no new dependencies. macOS, Windows and the BSDs have no such table without OS calls; there the loopback rule is the guard. The residual risk is documented in docs/zotero.md. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/zotero/src/client.rs | 3 + crates/zotero/src/lib.rs | 5 + crates/zotero/src/peer.rs | 191 ++++++++++++++++++++++++++++++++++++ docs/zotero.md | 21 ++++ 4 files changed, 220 insertions(+) create mode 100644 crates/zotero/src/peer.rs diff --git a/crates/zotero/src/client.rs b/crates/zotero/src/client.rs index bb6fc98b9e..e7a73354e1 100644 --- a/crates/zotero/src/client.rs +++ b/crates/zotero/src/client.rs @@ -88,7 +88,10 @@ pub struct Outcome { /// Run `command`, answering every call with `answer`. pub fn run_command(opts: &Options, command: Command, answer: &mut dyn FnMut(&Call) -> Result) -> Result { + crate::peer::check_addr(&opts.addr)?; let mut stream = TcpStream::connect_timeout(&opts.addr, opts.connect_timeout).map_err(|e| ZoteroError::NotRunning(e.to_string()))?; + // Before Zotero sees anything: the command, and then the document, go only to our own user. + crate::peer::check(&stream)?; let _ = stream.set_nodelay(true); stream.set_read_timeout(Some(opts.idle_timeout)).map_err(|e| ZoteroError::Io(e.to_string()))?; wire::write_frame(&mut stream, 0, &wire::command_payload(command.wire_name()))?; diff --git a/crates/zotero/src/lib.rs b/crates/zotero/src/lib.rs index 4c50bab040..7dc26eb8e9 100644 --- a/crates/zotero/src/lib.rs +++ b/crates/zotero/src/lib.rs @@ -18,6 +18,7 @@ mod bridge; pub mod client; pub mod fields; +mod peer; mod rich; pub mod wire; @@ -37,6 +38,10 @@ pub enum ZoteroError { /// Zotero sent something that isn't the protocol. #[error("unexpected data from Zotero: {0}")] Protocol(String), + /// What answered on Zotero's port may not be Zotero (another user's program, or another + /// computer); it gets no access to the document. + #[error("WordCraft won't let this program edit the document: {0}")] + Refused(String), } #[cfg(test)] diff --git a/crates/zotero/src/peer.rs b/crates/zotero/src/peer.rs new file mode 100644 index 0000000000..00173c5c4e --- /dev/null +++ b/crates/zotero/src/peer.rs @@ -0,0 +1,191 @@ +//! Who answered on Zotero's port. Zotero's protocol has no handshake that proves the other end +//! is Zotero, and whatever answers gets to read and rewrite the open document. So WordCraft +//! only connects to this computer (a loopback address), and on Linux checks that the program +//! listening there runs as the same user, from the kernel's socket table (`/proc/net/tcp`): +//! another account on a shared machine that took the port while Zotero wasn't running is +//! refused. Other systems have no such table to read without OS calls this crate doesn't make; +//! there the loopback rule is the guard (see `docs/zotero.md`). + +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr, TcpStream}; + +use crate::ZoteroError; + +/// Whether `addr` is on this computer; anything else is refused before connecting. +pub(crate) fn check_addr(addr: &SocketAddr) -> Result<(), ZoteroError> { + if addr.ip().to_canonical().is_loopback() { + Ok(()) + } else { + Err(ZoteroError::Refused(format!("{addr} is not on this computer; WordCraft only talks to Zotero on 127.0.0.1"))) + } +} + +/// Refuse the program at the other end of `stream` when it may not be ours (see the module +/// documentation). +pub(crate) fn check(stream: &TcpStream) -> Result<(), ZoteroError> { + let io = |e: std::io::Error| ZoteroError::Io(e.to_string()); + let (local, peer) = (stream.local_addr().map_err(io)?, stream.peer_addr().map_err(io)?); + check_addr(&peer)?; + owner_check(local, peer) +} + +#[cfg(any(target_os = "linux", target_os = "android"))] +fn owner_check(local: SocketAddr, peer: SocketAddr) -> Result<(), ZoteroError> { + use std::io::Read; + /// Longest socket table read (a busy server's table is a few MB). + const MAX_TABLE: u64 = 64 * 1024 * 1024; + let mut table = String::new(); + for path in ["/proc/net/tcp", "/proc/net/tcp6"] { + if let Ok(f) = std::fs::File::open(path) { + let mut part = String::new(); + if f.take(MAX_TABLE).read_to_string(&mut part).is_ok() { + table.push_str(&part); + } + } + } + same_owner(&table, local, peer) +} + +#[cfg(not(any(target_os = "linux", target_os = "android")))] +fn owner_check(_local: SocketAddr, _peer: SocketAddr) -> Result<(), ZoteroError> { + Ok(()) +} + +/// From a `/proc/net/tcp`-format socket table: whether the socket at `peer` connected to +/// `local` (the listener's side) belongs to the same user as ours. A peer that can't be found +/// is refused too: its owner can't be established. +#[cfg_attr(not(any(target_os = "linux", target_os = "android")), allow(dead_code))] +pub(crate) fn same_owner(table: &str, local: SocketAddr, peer: SocketAddr) -> Result<(), ZoteroError> { + let key = |a: SocketAddr| (a.ip().to_canonical(), a.port()); + let (local, peer) = (key(local), key(peer)); + let (mut ours, mut theirs) = (None, None); + for line in table.lines() { + let f: Vec<&str> = line.split_whitespace().take(8).collect(); + let (Some(l), Some(r), Some(uid)) = (f.get(1).and_then(|a| parse_addr(a)), f.get(2).and_then(|a| parse_addr(a)), f.get(7)) else { + continue; + }; + let Ok(uid) = uid.parse::() else { continue }; + let (l, r) = (key(l), key(r)); + if l == local && r == peer { + ours = Some(uid); + } else if l == peer && r == local { + theirs = Some(uid); + } + } + match (ours, theirs) { + (Some(a), Some(b)) if a == b => Ok(()), + (Some(_), Some(b)) => { + Err(ZoteroError::Refused(format!("the program on port {} runs as another user (uid {b}), so it isn't your Zotero", peer.1))) + } + _ => Err(ZoteroError::Refused(format!("WordCraft couldn't tell which user's program is on port {}", peer.1))), + } +} + +/// `0100007F:5A5C` (IPv4) or 32 hex digits and a port (IPv6): each 32-bit word as the kernel +/// prints it, in this machine's byte order. +#[cfg_attr(not(any(target_os = "linux", target_os = "android")), allow(dead_code))] +fn parse_addr(s: &str) -> Option { + let (ip, port) = s.split_once(':')?; + let port = u16::from_str_radix(port, 16).ok()?; + let word = |k: usize| ip.get(k * 8..k * 8 + 8).and_then(|w| u32::from_str_radix(w, 16).ok()).map(u32::to_ne_bytes); + let ip = match ip.len() { + 8 => IpAddr::V4(Ipv4Addr::from(word(0)?)), + 32 => { + let mut b = [0u8; 16]; + for (k, w) in b.as_chunks_mut::<4>().0.iter_mut().enumerate() { + *w = word(k)?; + } + IpAddr::V6(Ipv6Addr::from(b)) + } + _ => return None, + }; + Some(SocketAddr::new(ip, port)) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// `a` the way the kernel's socket table prints it. + fn hex(a: SocketAddr) -> String { + let ip = match a.ip() { + IpAddr::V4(v4) => format!("{:08X}", u32::from_ne_bytes(v4.octets())), + IpAddr::V6(v6) => v6.octets().as_chunks::<4>().0.iter().map(|w| format!("{:08X}", u32::from_ne_bytes(*w))).collect(), + }; + format!("{ip}:{:04X}", a.port()) + } + + fn row(l: SocketAddr, r: SocketAddr, uid: u32) -> String { + format!(" 0: {} {} 01 00000000:00000000 00:00000000 00000000 {uid:>4} 0 12345 1 0000000000000000 20 4 30 10 -1\n", hex(l), hex(r)) + } + + const HEADER: &str = " sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode\n"; + + fn addrs() -> (SocketAddr, SocketAddr) { + ("127.0.0.1:41234".parse().unwrap(), "127.0.0.1:23116".parse().unwrap()) + } + + #[test] + fn addresses_parse_as_the_kernel_prints_them() { + for a in ["127.0.0.1:23116", "[::1]:23116", "[::ffff:127.0.0.1]:80"] { + let a: SocketAddr = a.parse().unwrap(); + assert_eq!(parse_addr(&hex(a)), Some(a)); + } + for bad in ["", "0100007F", "0100007F:", "XYZ00000:0050", "0100007F:12345", "0100:0050"] { + assert_eq!(parse_addr(bad), None, "{bad}"); + } + } + + #[test] + fn a_listener_of_another_user_is_refused() { + let (local, peer) = addrs(); + let same = format!("{HEADER}{}{}", row(local, peer, 1000), row(peer, local, 1000)); + assert_eq!(same_owner(&same, local, peer), Ok(())); + let other = format!("{HEADER}{}{}", row(local, peer, 1000), row(peer, local, 1001)); + assert!(matches!(same_owner(&other, local, peer), Err(ZoteroError::Refused(e)) if e.contains("another user"))); + // Not found (or the table couldn't be read): the owner can't be established. + let missing = format!("{HEADER}{}", row(local, peer, 1000)); + assert!(matches!(same_owner(&missing, local, peer), Err(ZoteroError::Refused(_)))); + assert!(matches!(same_owner("", local, peer), Err(ZoteroError::Refused(_)))); + // Junk lines are skipped. + let junk = format!("{HEADER}garbage\n 1: zz:zz yy:yy 01 x x x x\n{}{}", row(local, peer, 7), row(peer, local, 7)); + assert_eq!(same_owner(&junk, local, peer), Ok(())); + } + + #[test] + fn a_dual_stack_listener_is_matched_through_its_mapped_address() { + let (local, peer) = addrs(); + let mapped = |a: SocketAddr| match a.ip() { + IpAddr::V4(v4) => SocketAddr::new(IpAddr::V6(v4.to_ipv6_mapped()), a.port()), + IpAddr::V6(_) => a, + }; + let table = format!("{HEADER}{}{}", row(local, peer, 1000), row(mapped(peer), mapped(local), 1001)); + assert!(matches!(same_owner(&table, local, peer), Err(ZoteroError::Refused(e)) if e.contains("another user"))); + } + + #[test] + fn only_this_computer_is_contacted() { + for ok in ["127.0.0.1:23116", "127.9.9.9:1", "[::1]:23116", "[::ffff:127.0.0.1]:23116"] { + assert_eq!(check_addr(&ok.parse().unwrap()), Ok(()), "{ok}"); + } + for bad in ["192.0.2.1:23116", "0.0.0.0:23116", "[2001:db8::1]:23116", "[::]:23116"] { + assert!(matches!(check_addr(&bad.parse().unwrap()), Err(ZoteroError::Refused(_))), "{bad}"); + } + } + + #[test] + fn another_computer_is_refused_before_connecting() { + let opts = crate::client::Options { addr: "192.0.2.1:23116".parse().unwrap(), ..Default::default() }; + let r = crate::client::run_command(&opts, crate::Command::Refresh, &mut |_| Ok(serde_json::Value::Null)); + assert!(matches!(r, Err(ZoteroError::Refused(_))), "{r:?}"); + } + + /// A listener this process opened runs as us: the real socket table agrees. + #[cfg(any(target_os = "linux", target_os = "android"))] + #[test] + fn our_own_listener_passes_the_owner_check() { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let stream = TcpStream::connect(listener.local_addr().unwrap()).unwrap(); + let _accepted = listener.accept().unwrap(); + assert_eq!(check(&stream), Ok(())); + } +} diff --git a/docs/zotero.md b/docs/zotero.md index f05b62c6e2..ed486b4ff7 100644 --- a/docs/zotero.md +++ b/docs/zotero.md @@ -61,6 +61,27 @@ Word's "Field" in the file.) - Zotero's document transfer (Export/Import Document) isn't needed and isn't supported. - The web version can't reach Zotero (browsers can't open the socket). +## Who can drive the document + +Zotero's protocol has no handshake that proves the program on port 23116 is Zotero, and that +program can read the citations and edit the open document. WordCraft limits who that can be: + +- It only connects to this computer (a loopback address such as `127.0.0.1`); any other address + is refused before connecting. +- On Linux it checks, in the kernel's socket table (`/proc/net/tcp`, `/proc/net/tcp6`), that the + program listening on the port runs as the same user as WordCraft, before sending anything. If + another account on a shared machine took the port while Zotero wasn't running, or the owner + can't be established (the table can't be read), WordCraft refuses with an error and the + document isn't touched. +- Remaining risk: macOS, Windows and the BSDs have no such table to read without OS calls + WordCraft doesn't make (it uses no `unsafe` code), so there only the loopback rule applies: + on a computer shared with other accounts, a program another user leaves on port 23116 while + Zotero is closed would be taken for Zotero. A program running as you is trusted on every + system; it could change your documents anyway. +- `wordcraft-cli zotero` prints Zotero's messages (and `--trace` lines) with control + characters, line breaks and backslashes shown as escapes (`\n`, `\u{1b}`, `\\`), so a peer + can't forge other output or drive the terminal. + ## How it works `crates/zotero` implements Zotero's published word-processor wire protocol (port 23116): From 7ee101a5b99f2f5e25b31a3aecf80d609b63da2b Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Thu, 22 Oct 2026 22:05:54 +0100 Subject: [PATCH 073/199] #3692: Zotero owner check refuses only another user's listener; unlisted falls back to loopback Ultrareview finding 3: on Linux, when /proc/net/tcp(6) doesn't list the listener on Zotero's port (WSL2 mirrored networking with Zotero on Windows, a local proxy, a sandbox), every connection was refused, so Zotero couldn't be used at all there. Per the owner's decision, refuse only when both sockets are found and the listener's uid differs from ours; otherwise log at debug and apply the plain loopback rule, as on macOS/Windows/BSD. Test: an_unlisted_listener_falls_back_to_the_loopback_rule. docs/zotero.md and the progress-log row say so. Non-Linux owner check stays with #3831. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/zotero/src/peer.rs | 31 +++++++++++++++++++++---------- docs/zotero.md | 14 ++++++++------ 2 files changed, 29 insertions(+), 16 deletions(-) diff --git a/crates/zotero/src/peer.rs b/crates/zotero/src/peer.rs index 00173c5c4e..a9c2ccbe4f 100644 --- a/crates/zotero/src/peer.rs +++ b/crates/zotero/src/peer.rs @@ -3,8 +3,9 @@ //! only connects to this computer (a loopback address), and on Linux checks that the program //! listening there runs as the same user, from the kernel's socket table (`/proc/net/tcp`): //! another account on a shared machine that took the port while Zotero wasn't running is -//! refused. Other systems have no such table to read without OS calls this crate doesn't make; -//! there the loopback rule is the guard (see `docs/zotero.md`). +//! refused. A listener the table doesn't show (WSL2 mirrored networking with Zotero on Windows, +//! a local proxy, a sandbox) leaves only the loopback rule, as on other systems, which have no +//! such table to read without OS calls this crate doesn't make (see `docs/zotero.md`). use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr, TcpStream}; @@ -50,9 +51,9 @@ fn owner_check(_local: SocketAddr, _peer: SocketAddr) -> Result<(), ZoteroError> Ok(()) } -/// From a `/proc/net/tcp`-format socket table: whether the socket at `peer` connected to -/// `local` (the listener's side) belongs to the same user as ours. A peer that can't be found -/// is refused too: its owner can't be established. +/// From a `/proc/net/tcp`-format socket table: refuse when the socket at `peer` connected to +/// `local` (the listener's side) belongs to another user than ours. When either socket isn't +/// listed (or the table couldn't be read) the owner is unknown and the loopback rule decides. #[cfg_attr(not(any(target_os = "linux", target_os = "android")), allow(dead_code))] pub(crate) fn same_owner(table: &str, local: SocketAddr, peer: SocketAddr) -> Result<(), ZoteroError> { let key = |a: SocketAddr| (a.ip().to_canonical(), a.port()); @@ -76,7 +77,10 @@ pub(crate) fn same_owner(table: &str, local: SocketAddr, peer: SocketAddr) -> Re (Some(_), Some(b)) => { Err(ZoteroError::Refused(format!("the program on port {} runs as another user (uid {b}), so it isn't your Zotero", peer.1))) } - _ => Err(ZoteroError::Refused(format!("WordCraft couldn't tell which user's program is on port {}", peer.1))), + _ => { + log::debug!("Zotero: port {} isn't in this system's socket table; trusting the loopback address alone", peer.1); + Ok(()) + } } } @@ -142,15 +146,22 @@ mod tests { assert_eq!(same_owner(&same, local, peer), Ok(())); let other = format!("{HEADER}{}{}", row(local, peer, 1000), row(peer, local, 1001)); assert!(matches!(same_owner(&other, local, peer), Err(ZoteroError::Refused(e)) if e.contains("another user"))); - // Not found (or the table couldn't be read): the owner can't be established. - let missing = format!("{HEADER}{}", row(local, peer, 1000)); - assert!(matches!(same_owner(&missing, local, peer), Err(ZoteroError::Refused(_)))); - assert!(matches!(same_owner("", local, peer), Err(ZoteroError::Refused(_)))); // Junk lines are skipped. let junk = format!("{HEADER}garbage\n 1: zz:zz yy:yy 01 x x x x\n{}{}", row(local, peer, 7), row(peer, local, 7)); assert_eq!(same_owner(&junk, local, peer), Ok(())); } + /// WSL2 mirrored networking (Zotero on Windows), a local proxy or a sandbox: the listener + /// isn't in our table, so the loopback rule decides, as on other systems. + #[test] + fn an_unlisted_listener_falls_back_to_the_loopback_rule() { + let (local, peer) = addrs(); + let missing = format!("{HEADER}{}", row(local, peer, 1000)); + assert_eq!(same_owner(&missing, local, peer), Ok(())); + // The table couldn't be read. + assert_eq!(same_owner("", local, peer), Ok(())); + } + #[test] fn a_dual_stack_listener_is_matched_through_its_mapped_address() { let (local, peer) = addrs(); diff --git a/docs/zotero.md b/docs/zotero.md index ed486b4ff7..601749d598 100644 --- a/docs/zotero.md +++ b/docs/zotero.md @@ -70,13 +70,15 @@ program can read the citations and edit the open document. WordCraft limits who is refused before connecting. - On Linux it checks, in the kernel's socket table (`/proc/net/tcp`, `/proc/net/tcp6`), that the program listening on the port runs as the same user as WordCraft, before sending anything. If - another account on a shared machine took the port while Zotero wasn't running, or the owner - can't be established (the table can't be read), WordCraft refuses with an error and the - document isn't touched. + another account on a shared machine took the port while Zotero wasn't running, WordCraft + refuses with an error and the document isn't touched. When the table doesn't show the + listener (WSL2 mirrored networking with Zotero running on Windows, a local proxy, a sandbox) + or can't be read, its owner is unknown and only the loopback rule applies, as below. - Remaining risk: macOS, Windows and the BSDs have no such table to read without OS calls - WordCraft doesn't make (it uses no `unsafe` code), so there only the loopback rule applies: - on a computer shared with other accounts, a program another user leaves on port 23116 while - Zotero is closed would be taken for Zotero. A program running as you is trusted on every + WordCraft doesn't make (it uses no `unsafe` code), so there only the loopback rule applies + (and on Linux when the listener isn't in the table): on a computer shared with other + accounts, a program another user leaves on port 23116 while Zotero is closed would be taken + for Zotero. A program running as you is trusted on every system; it could change your documents anyway. - `wordcraft-cli zotero` prints Zotero's messages (and `--trace` lines) with control characters, line breaks and backslashes shown as escapes (`\n`, `\u{1b}`, `\\`), so a peer From 65f3c3418f494dd64e47157ac5e27ca5e0a7c878 Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Thu, 22 Oct 2026 22:27:01 +0100 Subject: [PATCH 074/199] #3692: Zotero owner check falls back to this process's uid when our socket row is missing Ultrareview finding 3 follow-up (owner's call): with our own socket's row missing from /proc/net/tcp(6), a listed listener of another user was let through on the loopback rule. Our uid now falls back to the owner of /proc/self, so a listed listener with a different uid is refused either way. An unlisted listener still falls back to the loopback rule. Test: a_listed_listener_is_checked_against_our_process_uid. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/zotero/src/peer.rs | 46 +++++++++++++++++++++++++++++---------- 1 file changed, 34 insertions(+), 12 deletions(-) diff --git a/crates/zotero/src/peer.rs b/crates/zotero/src/peer.rs index a9c2ccbe4f..e808f03d2d 100644 --- a/crates/zotero/src/peer.rs +++ b/crates/zotero/src/peer.rs @@ -43,7 +43,9 @@ fn owner_check(local: SocketAddr, peer: SocketAddr) -> Result<(), ZoteroError> { } } } - same_owner(&table, local, peer) + // Our uid when our own socket's row is missing: `/proc/self` belongs to this process's user. + let me = std::fs::metadata("/proc/self").ok().map(|m| std::os::unix::fs::MetadataExt::uid(&m)); + same_owner(&table, local, peer, me) } #[cfg(not(any(target_os = "linux", target_os = "android")))] @@ -52,10 +54,11 @@ fn owner_check(_local: SocketAddr, _peer: SocketAddr) -> Result<(), ZoteroError> } /// From a `/proc/net/tcp`-format socket table: refuse when the socket at `peer` connected to -/// `local` (the listener's side) belongs to another user than ours. When either socket isn't -/// listed (or the table couldn't be read) the owner is unknown and the loopback rule decides. +/// `local` (the listener's side) belongs to another user than ours. Our uid is our own socket's +/// row, else `me` (this process's). When the listener isn't listed (or the table couldn't be +/// read, or our uid is unknown) its owner is unknown and the loopback rule decides. #[cfg_attr(not(any(target_os = "linux", target_os = "android")), allow(dead_code))] -pub(crate) fn same_owner(table: &str, local: SocketAddr, peer: SocketAddr) -> Result<(), ZoteroError> { +pub(crate) fn same_owner(table: &str, local: SocketAddr, peer: SocketAddr, me: Option) -> Result<(), ZoteroError> { let key = |a: SocketAddr| (a.ip().to_canonical(), a.port()); let (local, peer) = (key(local), key(peer)); let (mut ours, mut theirs) = (None, None); @@ -72,13 +75,16 @@ pub(crate) fn same_owner(table: &str, local: SocketAddr, peer: SocketAddr) -> Re theirs = Some(uid); } } - match (ours, theirs) { + match (ours.or(me), theirs) { (Some(a), Some(b)) if a == b => Ok(()), (Some(_), Some(b)) => { Err(ZoteroError::Refused(format!("the program on port {} runs as another user (uid {b}), so it isn't your Zotero", peer.1))) } _ => { - log::debug!("Zotero: port {} isn't in this system's socket table; trusting the loopback address alone", peer.1); + log::debug!( + "Zotero: couldn't tell which user runs the program on port {} (not in the socket table); trusting the loopback address alone", + peer.1 + ); Ok(()) } } @@ -143,12 +149,12 @@ mod tests { fn a_listener_of_another_user_is_refused() { let (local, peer) = addrs(); let same = format!("{HEADER}{}{}", row(local, peer, 1000), row(peer, local, 1000)); - assert_eq!(same_owner(&same, local, peer), Ok(())); + assert_eq!(same_owner(&same, local, peer, None), Ok(())); let other = format!("{HEADER}{}{}", row(local, peer, 1000), row(peer, local, 1001)); - assert!(matches!(same_owner(&other, local, peer), Err(ZoteroError::Refused(e)) if e.contains("another user"))); + assert!(matches!(same_owner(&other, local, peer, None), Err(ZoteroError::Refused(e)) if e.contains("another user"))); // Junk lines are skipped. let junk = format!("{HEADER}garbage\n 1: zz:zz yy:yy 01 x x x x\n{}{}", row(local, peer, 7), row(peer, local, 7)); - assert_eq!(same_owner(&junk, local, peer), Ok(())); + assert_eq!(same_owner(&junk, local, peer, None), Ok(())); } /// WSL2 mirrored networking (Zotero on Windows), a local proxy or a sandbox: the listener @@ -157,9 +163,25 @@ mod tests { fn an_unlisted_listener_falls_back_to_the_loopback_rule() { let (local, peer) = addrs(); let missing = format!("{HEADER}{}", row(local, peer, 1000)); - assert_eq!(same_owner(&missing, local, peer), Ok(())); + assert_eq!(same_owner(&missing, local, peer, None), Ok(())); // The table couldn't be read. - assert_eq!(same_owner("", local, peer), Ok(())); + assert_eq!(same_owner("", local, peer, None), Ok(())); + assert_eq!(same_owner("", local, peer, Some(1000)), Ok(())); + } + + /// Our own socket's row missing doesn't hide another user's listener: our uid comes from + /// the process itself. + #[test] + fn a_listed_listener_is_checked_against_our_process_uid() { + let (local, peer) = addrs(); + let only_theirs = format!("{HEADER}{}", row(peer, local, 1001)); + assert!(matches!(same_owner(&only_theirs, local, peer, Some(1000)), Err(ZoteroError::Refused(e)) if e.contains("another user"))); + assert_eq!(same_owner(&only_theirs, local, peer, Some(1001)), Ok(())); + // Neither our row nor our process uid: unknown, so the loopback rule decides. + assert_eq!(same_owner(&only_theirs, local, peer, None), Ok(())); + // Our socket's row is ours by definition and wins over the process uid. + let both = format!("{HEADER}{}{}", row(local, peer, 1001), row(peer, local, 1001)); + assert_eq!(same_owner(&both, local, peer, Some(1000)), Ok(())); } #[test] @@ -170,7 +192,7 @@ mod tests { IpAddr::V6(_) => a, }; let table = format!("{HEADER}{}{}", row(local, peer, 1000), row(mapped(peer), mapped(local), 1001)); - assert!(matches!(same_owner(&table, local, peer), Err(ZoteroError::Refused(e)) if e.contains("another user"))); + assert!(matches!(same_owner(&table, local, peer, None), Err(ZoteroError::Refused(e)) if e.contains("another user"))); } #[test] From e251c960d9745eb791452543129b3fa173a1279e Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Fri, 23 Oct 2026 01:57:00 +0100 Subject: [PATCH 075/199] =?UTF-8?q?#3692:=20review=20=E2=80=94=20doc=20com?= =?UTF-8?q?ments=20on=20the=20functions=20the=20Zotero=20hardening=20added?= =?UTF-8?q?=20or=20changed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01CX1hMKX28764Lrx5moxs1o --- apps/wordcraft-cli/src/main.rs | 2 ++ crates/zotero/src/bridge.rs | 1 + crates/zotero/src/peer.rs | 4 ++++ crates/zotero/src/rich.rs | 3 +++ 4 files changed, 10 insertions(+) diff --git a/apps/wordcraft-cli/src/main.rs b/apps/wordcraft-cli/src/main.rs index 0817d18b34..c8e46c93cc 100644 --- a/apps/wordcraft-cli/src/main.rs +++ b/apps/wordcraft-cli/src/main.rs @@ -387,6 +387,8 @@ fn valid_connect_addr(addr: &str) -> bool { !host.is_empty() && port.parse::().is_ok() } +/// Run the subcommand named by `args` (the command line after the program name). Files named +/// there are opened by their real names, which may not be valid Unicode. fn run>(args: &[S]) -> Result<(), Failure> { let script_path = script_operand(args); let (args, file) = text_args(args)?; diff --git a/crates/zotero/src/bridge.rs b/crates/zotero/src/bridge.rs index 36cb2d0b44..50ea5fcd5f 100644 --- a/crates/zotero/src/bridge.rs +++ b/crates/zotero/src/bridge.rs @@ -194,6 +194,7 @@ impl Bridge { } } + /// Answer one call by its method name; [`Bridge::handle`] wraps it so a panic becomes an error. fn dispatch(&mut self, s: &mut Session, host: &mut dyn Host, c: &Call) -> R { match c.method.as_str() { "Application_getActiveDocument" => { diff --git a/crates/zotero/src/peer.rs b/crates/zotero/src/peer.rs index e808f03d2d..8e6ea6c4e4 100644 --- a/crates/zotero/src/peer.rs +++ b/crates/zotero/src/peer.rs @@ -29,6 +29,7 @@ pub(crate) fn check(stream: &TcpStream) -> Result<(), ZoteroError> { owner_check(local, peer) } +/// Refuse a listener at `peer` that the kernel's socket tables say belongs to another user. #[cfg(any(target_os = "linux", target_os = "android"))] fn owner_check(local: SocketAddr, peer: SocketAddr) -> Result<(), ZoteroError> { use std::io::Read; @@ -48,6 +49,7 @@ fn owner_check(local: SocketAddr, peer: SocketAddr) -> Result<(), ZoteroError> { same_owner(&table, local, peer, me) } +/// No socket table to read here: the loopback rule alone decides. #[cfg(not(any(target_os = "linux", target_os = "android")))] fn owner_check(_local: SocketAddr, _peer: SocketAddr) -> Result<(), ZoteroError> { Ok(()) @@ -124,12 +126,14 @@ mod tests { format!("{ip}:{:04X}", a.port()) } + /// A socket table row for the connection from `l` to `r`, owned by `uid`. fn row(l: SocketAddr, r: SocketAddr, uid: u32) -> String { format!(" 0: {} {} 01 00000000:00000000 00:00000000 00000000 {uid:>4} 0 12345 1 0000000000000000 20 4 30 10 -1\n", hex(l), hex(r)) } const HEADER: &str = " sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode\n"; + /// Our side and Zotero's listening side of a loopback connection. fn addrs() -> (SocketAddr, SocketAddr) { ("127.0.0.1:41234".parse().unwrap(), "127.0.0.1:23116".parse().unwrap()) } diff --git a/crates/zotero/src/rich.rs b/crates/zotero/src/rich.rs index 6285ecf3eb..7ff3377f1c 100644 --- a/crates/zotero/src/rich.rs +++ b/crates/zotero/src/rich.rs @@ -40,6 +40,9 @@ fn style_props(base: &CharProps, src: &CharProps) -> CharProps { p } +/// The body paragraphs of `d` (Zotero's RTF or HTML, read), at most [`MAX_PARAS`], formatted on +/// top of `base` with the run styling they carry (past [`MAX_RUNS`] runs, text keeps the +/// formatting before it). Objects are dropped. fn from_doc(d: &Document, base: &CharProps) -> Vec { let mut out = Vec::new(); let mut runs = 0usize; From bf2a379ec6fd0b08144f79d343c1a4ede6be72db Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Fri, 23 Oct 2026 11:13:33 +0100 Subject: [PATCH 076/199] =?UTF-8?q?#3692:=20review=20=E2=80=94=20say=20pla?= =?UTF-8?q?inly=20the=20same-user=20Zotero=20check=20is=20Linux=20only;=20?= =?UTF-8?q?Windows=20and=20macOS=20rely=20on=20loopback=20alone?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01CX1hMKX28764Lrx5moxs1o --- crates/zotero/src/client.rs | 3 ++- crates/zotero/src/lib.rs | 4 ++-- crates/zotero/src/peer.rs | 22 ++++++++++++++-------- docs/zotero.md | 23 +++++++++++++---------- 4 files changed, 31 insertions(+), 21 deletions(-) diff --git a/crates/zotero/src/client.rs b/crates/zotero/src/client.rs index e7a73354e1..f6e31c83aa 100644 --- a/crates/zotero/src/client.rs +++ b/crates/zotero/src/client.rs @@ -90,7 +90,8 @@ pub struct Outcome { pub fn run_command(opts: &Options, command: Command, answer: &mut dyn FnMut(&Call) -> Result) -> Result { crate::peer::check_addr(&opts.addr)?; let mut stream = TcpStream::connect_timeout(&opts.addr, opts.connect_timeout).map_err(|e| ZoteroError::NotRunning(e.to_string()))?; - // Before Zotero sees anything: the command, and then the document, go only to our own user. + // Before Zotero sees anything: the command, and then the document, go only to this computer + // and, on Linux only, not to a listener the socket table shows belongs to another user. crate::peer::check(&stream)?; let _ = stream.set_nodelay(true); stream.set_read_timeout(Some(opts.idle_timeout)).map_err(|e| ZoteroError::Io(e.to_string()))?; diff --git a/crates/zotero/src/lib.rs b/crates/zotero/src/lib.rs index 7dc26eb8e9..cf4c934431 100644 --- a/crates/zotero/src/lib.rs +++ b/crates/zotero/src/lib.rs @@ -38,8 +38,8 @@ pub enum ZoteroError { /// Zotero sent something that isn't the protocol. #[error("unexpected data from Zotero: {0}")] Protocol(String), - /// What answered on Zotero's port may not be Zotero (another user's program, or another - /// computer); it gets no access to the document. + /// What answered on Zotero's port may not be Zotero (another computer, on any system; on + /// Linux only, another user's program); it gets no access to the document. #[error("WordCraft won't let this program edit the document: {0}")] Refused(String), } diff --git a/crates/zotero/src/peer.rs b/crates/zotero/src/peer.rs index 8e6ea6c4e4..07df98e79f 100644 --- a/crates/zotero/src/peer.rs +++ b/crates/zotero/src/peer.rs @@ -1,11 +1,16 @@ //! Who answered on Zotero's port. Zotero's protocol has no handshake that proves the other end -//! is Zotero, and whatever answers gets to read and rewrite the open document. So WordCraft -//! only connects to this computer (a loopback address), and on Linux checks that the program -//! listening there runs as the same user, from the kernel's socket table (`/proc/net/tcp`): -//! another account on a shared machine that took the port while Zotero wasn't running is -//! refused. A listener the table doesn't show (WSL2 mirrored networking with Zotero on Windows, -//! a local proxy, a sandbox) leaves only the loopback rule, as on other systems, which have no -//! such table to read without OS calls this crate doesn't make (see `docs/zotero.md`). +//! is Zotero, and whatever answers gets to read and rewrite the open document. Two checks: +//! +//! - **Every system:** WordCraft only connects to this computer (a loopback address). +//! - **Linux only:** the program listening there must not run as another user, from the +//! kernel's socket table (`/proc/net/tcp`): another account on a shared machine that took the +//! port while Zotero wasn't running is refused. A listener the table doesn't show (WSL2 +//! mirrored networking with Zotero on Windows, a local proxy, a sandbox) leaves only the +//! loopback rule. +//! +//! On Windows, macOS and the BSDs there is **no same-user check**: the loopback rule is the +//! only one, so any local account's listener on the port is taken for Zotero. Those systems have +//! no such table to read without OS calls this crate doesn't make (see `docs/zotero.md`, #3831). use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr, TcpStream}; @@ -49,7 +54,8 @@ fn owner_check(local: SocketAddr, peer: SocketAddr) -> Result<(), ZoteroError> { same_owner(&table, local, peer, me) } -/// No socket table to read here: the loopback rule alone decides. +/// Windows, macOS, the BSDs: no same-user check (no socket table to read without OS calls this +/// crate doesn't make, #3831); the loopback rule alone decides. #[cfg(not(any(target_os = "linux", target_os = "android")))] fn owner_check(_local: SocketAddr, _peer: SocketAddr) -> Result<(), ZoteroError> { Ok(()) diff --git a/docs/zotero.md b/docs/zotero.md index 601749d598..cf5813494c 100644 --- a/docs/zotero.md +++ b/docs/zotero.md @@ -64,22 +64,25 @@ Word's "Field" in the file.) ## Who can drive the document Zotero's protocol has no handshake that proves the program on port 23116 is Zotero, and that -program can read the citations and edit the open document. WordCraft limits who that can be: +program can read the citations and edit the open document. WordCraft limits who that can be. +In short: **every system** gets the loopback rule; **only Linux** also checks that the listener +isn't another user's. On Windows and macOS (and the BSDs) the loopback rule is the only check. -- It only connects to this computer (a loopback address such as `127.0.0.1`); any other address - is refused before connecting. -- On Linux it checks, in the kernel's socket table (`/proc/net/tcp`, `/proc/net/tcp6`), that the +- Every system: it only connects to this computer (a loopback address such as `127.0.0.1`); + any other address is refused before connecting. +- Linux only: it checks, in the kernel's socket table (`/proc/net/tcp`, `/proc/net/tcp6`), that the program listening on the port runs as the same user as WordCraft, before sending anything. If another account on a shared machine took the port while Zotero wasn't running, WordCraft refuses with an error and the document isn't touched. When the table doesn't show the listener (WSL2 mirrored networking with Zotero running on Windows, a local proxy, a sandbox) or can't be read, its owner is unknown and only the loopback rule applies, as below. -- Remaining risk: macOS, Windows and the BSDs have no such table to read without OS calls - WordCraft doesn't make (it uses no `unsafe` code), so there only the loopback rule applies - (and on Linux when the listener isn't in the table): on a computer shared with other - accounts, a program another user leaves on port 23116 while Zotero is closed would be taken - for Zotero. A program running as you is trusted on every - system; it could change your documents anyway. +- Windows, macOS and the BSDs: **no same-user check.** They have no such table to read + without OS calls WordCraft doesn't make (it uses no `unsafe` code), so only the loopback rule + applies there (and on Linux when the listener isn't in the table). Remaining risk: on a + computer shared with other accounts, a program another user leaves on port 23116 while + Zotero is closed would be taken for Zotero. An owner check for these systems is tracked in + #3831. A program running as you is trusted on every system; it could change your documents + anyway. - `wordcraft-cli zotero` prints Zotero's messages (and `--trace` lines) with control characters, line breaks and backslashes shown as escapes (`\n`, `\u{1b}`, `\\`), so a peer can't forge other output or drive the terminal. From d785c05c53583b663937ab55c0d4d0ed4ea590df Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Fri, 23 Oct 2026 16:22:50 +0100 Subject: [PATCH 077/199] =?UTF-8?q?#3692:=20review=20=E2=80=94=20zotero=20?= =?UTF-8?q?timing=20tests=20get=20a=20margin=20a=20busy=20CI=20machine=20c?= =?UTF-8?q?an't=20break?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wall-clock bounds of 2 s failed on loaded runners (checked against the pre-fix code, put back in the worktree): - note referenced many times: no clock; listing the note once per reference gave a million fields, which the count of 200 catches. - rich text with many runs: 2 s -> 10 s; 0.35 s now, merging after every run ran past 9 minutes. - Document_convert with one id repeated: 2 s -> 10 s, 1,000 fields (was 300); 22 ms now, the per-id check took 81 s with 300 fields and grows with the field count. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01CX1hMKX28764Lrx5moxs1o --- crates/zotero/src/tests_limits.rs | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/crates/zotero/src/tests_limits.rs b/crates/zotero/src/tests_limits.rs index 53d8eaafc1..ce6b12c673 100644 --- a/crates/zotero/src/tests_limits.rs +++ b/crates/zotero/src/tests_limits.rs @@ -34,11 +34,10 @@ fn a_note_referenced_many_times_is_listed_once() { let note = doc.add_part(PartKind::Endnote, vec![para_block(objects_para(citations(200)))]); let refs = (0..5_000).map(|_| InlineObject::NoteRef { kind: NoteKind::Endnote, id: note, custom: String::new() }).collect(); doc.body = vec![para_block(objects_para(refs))]; - let t = Instant::now(); + // No clock: listing the note once per reference gave a million fields, which the count catches. let list = fields::list(&doc); assert_eq!(list.len(), 200, "each field once, at the note's first reference"); assert!(list.iter().all(|f| f.start.story == StoryRef::Part(note) && f.note_index == 1)); - assert!(t.elapsed() < Duration::from_secs(2), "{:?}", t.elapsed()); } #[test] @@ -66,7 +65,8 @@ fn rich_text_with_many_runs_is_linear() { assert_eq!(p.text, "ab".repeat(50_000)); assert_eq!(p.runs.len(), 100_000); assert_eq!(p.runs.iter().map(|r| r.len).sum::(), p.text.len()); - assert!(elapsed < Duration::from_secs(2), "{elapsed:?}"); + // Under half a second on a loaded machine; merging again after every run ran past 9 minutes. + assert!(elapsed < Duration::from_secs(10), "{elapsed:?}"); // Neighbours with the same formatting are one run. let frag = crate::rich::fragment("abc", true, &CharProps::default()); let Some(wordcraft_doc::Block::Para(p)) = frag.blocks.first() else { panic!("no paragraph") }; @@ -84,7 +84,9 @@ fn call(b: &mut Bridge, s: &mut Session, method: &str, args: Value) -> Result Date: Thu, 22 Oct 2026 15:12:03 +0100 Subject: [PATCH 078/199] =?UTF-8?q?#3613:=20affix=20rules=20=E2=80=94=20ch?= =?UTF-8?q?ecker=20follows=20merged=20flag=20masks,=20forms=20bounded,=20s?= =?UTF-8?q?uffix=20index=20hashed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part of #3613 (audit findings 2 and 6). - Checker: `suffixed` and `form_has_flag` OR the flags of a group's matching rules into one mask and recurse once, not once per rule. Same answer (the walk is a disjunction over flags); 16k self-continuing empty-append rules: 6 s -> <1 ms per check (debug). - Finding 2: `forms` expands each (form, continuation) pair once, walks continuation rules through a per-flag rule list, and stops at MAX_FORMS (4096) forms / MAX_FORMS_WORK rule visits, so a dictionary of self-continuing rules can't allocate R^2 strings per stem. - Finding 6: the suffix index finds a rule's (append, strip) group by hash, not by scanning the append's groups: building with many strips per append is linear. Not done here (rest of #3613): a work budget for distinct-strip chains in the checker, rule caps in parse_aff, proofing off the UI thread. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/proof/src/affix.rs | 164 +++++++++++++++++++++++++++++++++----- 1 file changed, 144 insertions(+), 20 deletions(-) diff --git a/crates/proof/src/affix.rs b/crates/proof/src/affix.rs index 7fb7d968ac..fefc7f6ac8 100644 --- a/crates/proof/src/affix.rs +++ b/crates/proof/src/affix.rs @@ -120,6 +120,11 @@ fn parse_cond(text: &str) -> Result, String> { const MAX_CHAIN: u8 = 2; /// How many suffixes may sit under the outermost one: the depth `form_has_flag` walks. const INNER_CHAIN: u8 = MAX_CHAIN - 1; +/// Most forms [`AffixDict::forms`] lists for one stem (real stems have a few hundred at most). +pub const MAX_FORMS: usize = 4096; +/// Most rules [`AffixDict::forms`] tries along continuation chains and prefix–suffix cross +/// products for one stem: real dictionaries need a few thousand, hostile ones are cut off. +const MAX_FORMS_WORK: usize = 1 << 18; /// One prefix or suffix rule. #[derive(Clone, Debug)] @@ -182,6 +187,8 @@ pub struct AffixDict { suffixes: Vec, /// Appended string → groups of suffix rules by stripped string. sfx_index: FxMap, Vec>, + /// Per flag bit: the suffix rules of that flag, in rule order. + sfx_by_flag: Vec>, /// Longest appended suffix, in chars. max_sfx_chars: usize, try_chars: String, @@ -484,14 +491,27 @@ impl AffixDict { /// Build the suffix index and the stem hash table. fn index(&mut self) { let mut idx: FxMap, Vec> = FxMap::default(); + // Each (appended, stripped) pair's group, found by hash rather than by scanning the + // appended string's groups: a dictionary with many strips per suffix stays linear. + let mut group_of: FxMap<(&str, &str), usize> = FxMap::default(); + let mut by_flag: Vec> = vec![Vec::new(); 128]; for (i, r) in self.suffixes.iter().enumerate() { let groups = idx.entry(r.add.clone()).or_default(); let Ok(i) = u32::try_from(i) else { break }; - match groups.iter_mut().find(|g| g.strip == r.strip) { + match group_of.get(&(&*r.add, &*r.strip)).and_then(|&g| groups.get_mut(g)) { Some(g) => g.rules.push(i), - None => groups.push(SfxGroup { strip: r.strip.clone(), rules: vec![i] }), + None => { + group_of.insert((&r.add, &r.strip), groups.len()); + groups.push(SfxGroup { strip: r.strip.clone(), rules: vec![i] }); + } + } + if r.bit != 0 + && let Some(list) = by_flag.get_mut(r.bit.trailing_zeros() as usize) + { + list.push(i); } } + self.sfx_by_flag = by_flag; self.max_sfx_chars = self.suffixes.iter().map(|r| r.add.chars().count()).max().unwrap_or(0); self.sfx_index = idx; let n = self.len(); @@ -596,10 +616,14 @@ impl AffixDict { None } - /// Does `w` carry flag `bit` — as a stem, or as the form of a suffix rule whose - /// continuation classes include `bit` (chained suffixation, depth-bounded)? - fn form_has_flag(&self, w: &str, bit: u128, depth: u8, excluded: u128) -> bool { - if self.stem_flags_excluding(w, excluded).is_some_and(|f| f & bit != 0) { + /// Does `w` carry any flag of `flags` — as a stem, or as the form of a suffix rule whose + /// continuation classes include one (chained suffixation, depth-bounded)? + /// + /// The rules of a group that could lead to `w` are merged into one mask of their flags and + /// followed once, not once per rule: the answer is the same, but thousands of rules of one + /// flag cost one walk, not one each (#3613). + fn form_has_flag(&self, w: &str, flags: u128, depth: u8, excluded: u128) -> bool { + if self.stem_flags_excluding(w, excluded).is_some_and(|f| f & flags != 0) { return true; } if depth == 0 { @@ -614,10 +638,14 @@ impl AffixDict { root.clear(); root.push_str(base); root.push_str(&g.strip); - for r in g.rules.iter().filter_map(|&i| self.suffixes.get(i as usize)) { - if r.cont & bit != 0 && r.cond_matches(&root, true) && self.form_has_flag(&root, r.bit, depth - 1, excluded) { - return true; - } + let inner = g + .rules + .iter() + .filter_map(|&i| self.suffixes.get(i as usize)) + .filter(|r| r.cont & flags != 0 && r.cond_matches(&root, true)) + .fold(0u128, |m, r| m | r.bit); + if inner != 0 && self.form_has_flag(&root, inner, depth - 1, excluded) { + return true; } } } @@ -690,9 +718,13 @@ impl AffixDict { return true; } // Continuation classes: `root` need not be a stem carrying this rule's flag — - // it may be a form that gained the flag from another rule's continuation. - if need == 0 && rules().any(|r| r.cond_matches(&root, true) && self.form_has_flag(&root, r.bit, INNER_CHAIN, excluded)) { - return true; + // it may be a form that gained the flag from another rule's continuation. The + // matching rules' flags are looked for together, in one walk. + if need == 0 { + let flags = rules().filter(|r| r.cond_matches(&root, true)).fold(0u128, |m, r| m | r.bit); + if flags != 0 && self.form_has_flag(&root, flags, INNER_CHAIN, excluded) { + return true; + } } } } @@ -725,13 +757,19 @@ impl AffixDict { } } - /// Every form of the stem `stem` (itself included), unsorted, possibly with repeats. + /// Every form of the stem `stem` (itself included), unsorted, possibly with repeats; at most + /// [`MAX_FORMS`], and at most [`MAX_FORMS_WORK`] rules tried along the continuation chains and + /// cross products, so a dictionary with thousands of self-continuing rules can't make one stem + /// cost millions of strings (#3613). pub fn forms(&self, stem: &str) -> Vec { let Some(f) = self.stem_flags(stem) else { return Vec::new() }; let mut out = vec![stem.to_string()]; let mut cross_sfx = Vec::new(); - // Suffix forms queued with the continuation classes they carry, for chained expansion. + // Suffix forms queued with the continuation classes they carry, for chained expansion; + // each (form, classes) pair is expanded once. let mut chain: Vec<(String, u128)> = Vec::new(); + let mut queued: std::collections::HashSet<(String, u128)> = std::collections::HashSet::new(); + let mut work = 0usize; for r in &self.suffixes { if f & r.bit == 0 || !r.cond_matches(stem, true) { continue; @@ -741,29 +779,39 @@ impl AffixDict { if r.cross { cross_sfx.push(form.clone()); } - if r.cont != 0 { + if r.cont != 0 && queued.insert((form.clone(), r.cont)) { chain.push((form.clone(), r.cont)); } out.push(form); + if out.len() >= MAX_FORMS { + return out; + } } // Continuation classes chain suffixes (`workable/B` → `workables`), up to [`MAX_CHAIN`] // suffixes in all — the same bound the checker walks. - for _ in 0..INNER_CHAIN { + 'chain: for _ in 0..INNER_CHAIN { let level = std::mem::take(&mut chain); if level.is_empty() { break; } for (form, flags) in level { - for r in &self.suffixes { - if flags & r.bit == 0 || !r.cond_matches(&form, true) { + for r in self.suffixes_with(flags).into_iter().filter_map(|i| self.suffixes.get(i as usize)) { + work += 1; + if work > MAX_FORMS_WORK { + break 'chain; + } + if !r.cond_matches(&form, true) { continue; } let Some(base) = form.strip_suffix(&*r.strip) else { continue }; let next = format!("{base}{}", r.add); - if r.cont != 0 { + if r.cont != 0 && queued.insert((next.clone(), r.cont)) { chain.push((next.clone(), r.cont)); } out.push(next); + if out.len() >= MAX_FORMS { + return out; + } } } } @@ -775,15 +823,38 @@ impl AffixDict { out.push(format!("{}{rest}", r.add)); if r.cross { for s in &cross_sfx { + work += 1; + if out.len() >= MAX_FORMS || work > MAX_FORMS_WORK { + return out; + } if let Some(rest) = s.strip_prefix(&*r.strip) { out.push(format!("{}{rest}", r.add)); } } } + if out.len() >= MAX_FORMS { + return out; + } } out } + /// The suffix rules carrying any flag of `mask`, in rule order. + fn suffixes_with(&self, mask: u128) -> Vec { + let mut rules: Vec = Vec::new(); + let mut lists = 0; + for (bit, list) in self.sfx_by_flag.iter().enumerate() { + if bit < 128 && mask & (1u128 << bit) != 0 && !list.is_empty() { + rules.extend_from_slice(list); + lists += 1; + } + } + if lists > 1 { + rules.sort_unstable(); + } + rules + } + /// The `TRY` letters, most frequent first (suggestions try these). pub fn try_chars(&self) -> &str { &self.try_chars @@ -1216,6 +1287,59 @@ pub(crate) mod tests { } } + #[test] + fn continuation_classes_merge_the_flags_of_a_group() { + // `s` under B and under C share one group (same append, same strip): a form carrying + // either flag takes it, and the stem carrying neither doesn't. + let aff = "SET UTF-8\nSFX A N 1\nSFX A 0 able/C .\nSFX D N 1\nSFX D 0 er/B .\nSFX B N 1\nSFX B 0 s .\nSFX C N 1\nSFX C 0 s [e]\n"; + let d = AffixDict::from_hunspell(aff, "2\nwork/A\nplay/D\n", &[]).unwrap(); + assert!(d.contains("workables") && d.contains("players")); + assert!(!d.contains("works") && !d.contains("plays") && !d.contains("workabless")); + assert!(d.forms("work").iter().any(|f| f == "workables") && d.forms("play").iter().any(|f| f == "players")); + } + + /// `n` suffix rules of flag A on one stem, `rule(i)` giving each rule's strip, append and condition. + fn many_rules(n: usize, rule: impl Fn(usize) -> String) -> AffixDict { + let mut aff = format!("SET UTF-8\nSFX A Y {n}\n"); + for i in 0..n { + aff.push_str(&format!("SFX A {}\n", rule(i))); + } + AffixDict::from_hunspell(&aff, "1\nkot/A\n", &[]).unwrap() + } + + #[test] + fn self_continuing_rules_check_in_linear_time() { + // Thousands of empty-append rules that continue with their own flag: each check used to + // follow every rule from every rule, millions of steps a word (#3613). + let d = many_rules(16_000, |_| "0 0/A .".into()); + let t = std::time::Instant::now(); + assert!(d.check("kot") && !d.check("pies") && !d.check("kotx")); + assert!(t.elapsed().as_secs_f64() < 2.0, "{:?}", t.elapsed()); + } + + #[test] + fn forms_of_self_continuing_rules_are_bounded() { + // Every rule's form continues with every rule: millions of strings for one stem (#3613). + let same = many_rules(3000, |_| "0 0/A .".into()); + let distinct = many_rules(3000, |i| format!("0 x{i}/A .")); + let t = std::time::Instant::now(); + let (a, b) = (same.forms("kot"), distinct.forms("kot")); + assert!(t.elapsed().as_secs_f64() < 2.0, "{:?}", t.elapsed()); + assert!(a.len() <= MAX_FORMS && b.len() <= MAX_FORMS, "{} {}", a.len(), b.len()); + assert!(b.iter().any(|f| f == "kotx0x1"), "chained forms are still listed"); + } + + #[test] + fn many_strips_per_suffix_index_in_linear_time() { + // One append with thousands of strips: the index used to scan the append's groups for + // each rule. + let t = std::time::Instant::now(); + let d = many_rules(30_000, |i| format!("k{i} y .")); + assert!(t.elapsed().as_secs_f64() < 2.0, "{:?}", t.elapsed()); + assert_eq!(d.sfx_index.get("y").map(Vec::len), Some(30_000)); + assert!(!d.contains("koty")); + } + #[test] fn compact_format_round_trips_continuations_and_forbidden() { let aff = "SET UTF-8\nFLAG UTF-8\nFORBIDDENWORD ý\nSFX A N 1\nSFX A 0 able/B .\nSFX B N 1\nSFX B 0 s .\n"; From e4d2dafa9d1e26dd5a26c823ae65ab9d48628a98 Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Thu, 22 Oct 2026 15:13:52 +0100 Subject: [PATCH 079/199] =?UTF-8?q?#3661:=20bounded=20dictionary=20reads?= =?UTF-8?q?=20=E2=80=94=20hyphenation=20inflate=20limit,=20capped=20Hunspe?= =?UTF-8?q?ll=20and=20Turkish=20reads?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit finding 4, and the rest of #3661. - dict.rs: `Dictionary::from_bytes` inflates with `decompress_to_vec_with_limit` (64 MiB; the bundled en-US one is ~1 MB) and caps the words it keeps (bytes and count): front coding lets a 2-byte line spell out a 255-byte word, so the inflated size alone doesn't bound storage. - hunspell.rs: one `read_capped` reader — regular files only, size checked on the open handle and the read itself bounded by `take(MAX_FILE + 1)`, so no TOCTOU between metadata and read and no endless read from a FIFO or device. - tr: the Turkish loader reads through `read_capped` and decodes with `hunspell::decode` (it read both files whole, with no cap at all). Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/proof/src/dict.rs | 48 +++++++++++++++++++++++++++++-- crates/proof/src/hunspell.rs | 56 ++++++++++++++++++++++++++++++------ crates/proof/src/tr/mod.rs | 23 +++++++-------- 3 files changed, 105 insertions(+), 22 deletions(-) diff --git a/crates/proof/src/dict.rs b/crates/proof/src/dict.rs index 789fb6a870..81eb0de1b4 100644 --- a/crates/proof/src/dict.rs +++ b/crates/proof/src/dict.rs @@ -14,6 +14,11 @@ pub struct Dictionary { } const MAGIC: &[u8] = b"DCHYPH1\n"; +/// Largest inflated dictionary, and most bytes of words kept from one (the bundled US English one +/// is about 1 MB). +const MAX_BYTES: usize = 64 << 20; +/// Most words read from one dictionary (the bundled one has about 160k). +const MAX_ENTRIES: usize = 4_000_000; impl Dictionary { /// Build from `(lowercase word, break mask)` pairs (sorted and de-duplicated here; the first @@ -76,9 +81,16 @@ impl Dictionary { miniz_oxide::deflate::compress_to_vec(&raw, 10) } - /// Deserialize data written by [`Dictionary::to_bytes`]. + /// Deserialize data written by [`Dictionary::to_bytes`]. Data that inflates past + /// [`MAX_BYTES`], or holds more than [`MAX_ENTRIES`] words or [`MAX_BYTES`] of them, is refused. pub fn from_bytes(data: &[u8]) -> Result { - let raw = miniz_oxide::inflate::decompress_to_vec(data).map_err(|e| format!("hyphenation dictionary: inflate failed: {:?}", e.status))?; + Dictionary::from_bytes_within(data, MAX_BYTES, MAX_ENTRIES) + } + + /// [`Dictionary::from_bytes`] with the limits given. + fn from_bytes_within(data: &[u8], max_bytes: usize, max_entries: usize) -> Result { + let raw = miniz_oxide::inflate::decompress_to_vec_with_limit(data, max_bytes) + .map_err(|e| format!("hyphenation dictionary: inflate failed: {:?}", e.status))?; let body = raw.strip_prefix(MAGIC).ok_or("hyphenation dictionary: bad header")?; let mut d = Dictionary { words: String::with_capacity(body.len()), offs: vec![0], masks: Vec::new() }; let mut cur: Vec = Vec::new(); @@ -94,6 +106,11 @@ impl Dictionary { i = end + 1; let s = std::str::from_utf8(&cur).map_err(|_| "hyphenation dictionary: bad utf-8")?; let (w, m) = parse_hyphenated(s); + // Front coding lets a short line repeat a long prefix: bound what is kept, not only + // what is read. + if d.masks.len() >= max_entries || d.words.len() + w.len() > max_bytes { + return Err("hyphenation dictionary: too large".into()); + } d.words.push_str(&w); d.offs.push(d.words.len() as u32); d.masks.push(m); @@ -147,4 +164,31 @@ mod tests { assert_eq!(back.get("naïveté").map(|m| hyphenated("naïveté", m)), Some("naïve-té".to_string())); assert!(back.get("dog").is_none()); } + + #[test] + fn oversized_data_is_refused() { + const LIMIT: usize = 1 << 16; + let within = |raw: &[u8]| Dictionary::from_bytes_within(&miniz_oxide::deflate::compress_to_vec(raw, 6), LIMIT, 1000); + // Data that inflates past the limit is refused before anything is decoded. + let mut raw = MAGIC.to_vec(); + raw.resize(LIMIT + 1, b'a'); + assert!(within(&raw).unwrap_err().contains("inflate failed")); + // Short lines that each repeat a long prefix: small to inflate, but the words they spell + // out would be ~100x larger. Kept storage is bounded too. + let mut raw = MAGIC.to_vec(); + raw.push(0); + raw.extend_from_slice(&[b'a'; 255]); + raw.push(b'\n'); + for _ in 0..(LIMIT / 255 + 2) { + raw.extend_from_slice(&[255, b'\n']); + } + assert!(raw.len() < LIMIT); + assert!(within(&raw).unwrap_err().contains("too large")); + // So is the number of words. + let words: Vec<(String, u64)> = (0..1001).map(|i| (format!("w{i}"), 0)).collect(); + let raw = miniz_oxide::inflate::decompress_to_vec(&Dictionary::from_entries(words).to_bytes()).unwrap(); + assert!(within(&raw).unwrap_err().contains("too large")); + // The real limits leave the bundled dictionary whole. + assert!(Dictionary::en_us().len() > 100_000); + } } diff --git a/crates/proof/src/hunspell.rs b/crates/proof/src/hunspell.rs index 0e4303beb2..a5b41214d0 100644 --- a/crates/proof/src/hunspell.rs +++ b/crates/proof/src/hunspell.rs @@ -140,16 +140,31 @@ pub fn find_in(dir: &Path, names: &[&str]) -> Option<(PathBuf, PathBuf)> { }) } +/// Read a dictionary file: a regular file of at most [`MAX_FILE`] bytes. The read itself is +/// bounded, so a file that grows after its size is checked (or a filesystem that reports a wrong +/// size) can't make it read more (#3661). +pub(crate) fn read_capped(p: &Path) -> Result, String> { + use std::io::Read; + let err = |e: std::io::Error| format!("{}: {e}", p.display()); + let f = std::fs::File::open(p).map_err(err)?; + let meta = f.metadata().map_err(err)?; + if !meta.is_file() { + return Err(format!("{}: not a regular file", p.display())); + } + if meta.len() > MAX_FILE { + return Err(format!("{}: too large ({} bytes)", p.display(), meta.len())); + } + let mut buf = Vec::new(); + f.take(MAX_FILE + 1).read_to_end(&mut buf).map_err(err)?; + if buf.len() as u64 > MAX_FILE { + return Err(format!("{}: too large", p.display())); + } + Ok(buf) +} + /// Read a Hunspell pair from disk (size-capped), decoded per the `.aff`'s `SET` line. pub fn load(aff: &Path, dic: &Path) -> Result { - let read = |p: &Path| -> Result, String> { - let len = std::fs::metadata(p).map_err(|e| format!("{}: {e}", p.display()))?.len(); - if len > MAX_FILE { - return Err(format!("{}: too large ({len} bytes)", p.display())); - } - std::fs::read(p).map_err(|e| format!("{}: {e}", p.display())) - }; - let (aff_text, dic_text) = decode(&read(aff)?, &read(dic)?)?; + let (aff_text, dic_text) = decode(&read_capped(aff)?, &read_capped(dic)?)?; let source = format!("Hunspell dictionary {}", dic.display()); AffixDict::from_hunspell(&aff_text, &dic_text, &[source.as_str()]) } @@ -283,4 +298,29 @@ mod tests { assert!(load(&dir.join("missing.aff"), &dic).is_err()); let _ = std::fs::remove_dir_all(&dir); } + + #[test] + fn reads_are_bounded_and_regular_files_only() { + let dir = std::env::temp_dir().join(format!("wordcraft-hunspell-cap-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let aff = dir.join("xx.aff"); + std::fs::write(&aff, "SET UTF-8\n").unwrap(); + // One byte over the cap (sparse: nothing is written) is refused without reading it. + let big = dir.join("big.dic"); + std::fs::File::create(&big).unwrap().set_len(MAX_FILE + 1).unwrap(); + let t = std::time::Instant::now(); + assert!(load(&aff, &big).unwrap_err().contains("too large")); + // A directory, and on Unix an endless device, are not dictionary files. + assert!(load(&aff, &dir).unwrap_err().contains("not a regular file")); + #[cfg(unix)] + assert!(load(&aff, Path::new("/dev/zero")).unwrap_err().contains("not a regular file")); + assert!(t.elapsed().as_secs_f64() < 1.0, "{:?}", t.elapsed()); + // Turkish reads through the same reader. + let (tr_aff, tr_dic) = (dir.join("tr_TR.aff"), dir.join("tr_TR.dic")); + std::fs::write(&tr_aff, "SET UTF-8\nSFX 1 Y 1\nSFX 1 0 lar .\n").unwrap(); + std::fs::write(&tr_dic, "1\nkitap/1\n").unwrap(); + assert!(crate::tr::load(&tr_aff, &tr_dic).unwrap().contains("kitaplar")); + assert!(crate::tr::load(&tr_aff, &big).unwrap_err().contains("too large")); + let _ = std::fs::remove_dir_all(&dir); + } } diff --git a/crates/proof/src/tr/mod.rs b/crates/proof/src/tr/mod.rs index bcab1546bf..25ee424c42 100644 --- a/crates/proof/src/tr/mod.rs +++ b/crates/proof/src/tr/mod.rs @@ -44,18 +44,17 @@ fn load_installed() -> TrDict { log::info!("Turkish spelling: no Hunspell tr_TR dictionary installed"); return TrDict::default(); }; - let read = |p: &std::path::Path| std::fs::read_to_string(p); - match (read(&aff), read(&dic)) { - (Ok(aff_text), Ok(dic_text)) => crate::hunspell::note_load(NAMES[0], &dic, TrDict::from_hunspell(&aff_text, &dic_text)).unwrap_or_else(|e| { - log::warn!("Turkish spelling: {e}"); - TrDict::default() - }), - (a, d) => { - let reason = a.err().or(d.err()).map_or_else(String::new, |e| e.to_string()); - let _ = crate::hunspell::note_load::<()>(NAMES[0], &dic, Err(reason)); - TrDict::default() - } - } + crate::hunspell::note_load(NAMES[0], &dic, load(&aff, &dic)).unwrap_or_else(|e| { + log::warn!("Turkish spelling: {e}"); + TrDict::default() + }) +} + +/// Read a Turkish Hunspell pair from disk: size-capped and decoded per the `.aff`'s `SET` line, +/// like the other languages' dictionaries (#3661). +pub(crate) fn load(aff: &std::path::Path, dic: &std::path::Path) -> Result { + let (aff_text, dic_text) = crate::hunspell::decode(&crate::hunspell::read_capped(aff)?, &crate::hunspell::read_capped(dic)?)?; + TrDict::from_hunspell(&aff_text, &dic_text) } /// Use this Turkish dictionary from now on. From 2cff091c1a63a56990f85579db38e47bd48e1840 Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Thu, 22 Oct 2026 15:15:27 +0100 Subject: [PATCH 080/199] #3657: Russian hyphenation and hyphenated output linear in word length Audit findings 10 (#3657) and 15 (#3692). - ru::hyphen::points: the cuts come in increasing order, so a repeat can only be the last one; compare with `out.last()` instead of `out.contains` per syllable (O(V^2) on a long Cyrillic word, during layout). - hyphenate_word_in: walk the sorted break points alongside the chars instead of searching the point list for every char. Same output. Test: russian_hyphenation_and_hyphenated_output_are_linear (8x the letters costs ~8x; the old code measured 31x and 30x). Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/proof/src/hyphen.rs | 30 +++++++++++++++++++++++++++--- crates/proof/src/ru/hyphen.rs | 4 +++- 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/crates/proof/src/hyphen.rs b/crates/proof/src/hyphen.rs index ed9f77f9de..b203ff1a2a 100644 --- a/crates/proof/src/hyphen.rs +++ b/crates/proof/src/hyphen.rs @@ -320,10 +320,14 @@ pub fn hyphenate_word(word: &str, lim: &Limits) -> String { /// `word`, a word in `lang`, with `-` at every hyphenation point (tests and diagnostics). pub fn hyphenate_word_in(word: &str, lim: &Limits, lang: ProofLang) -> String { - let pts = hyphen_points_in(word, lim, lang); - let mut s = String::with_capacity(word.len() + pts.len()); + let mut pts = hyphen_points_in(word, lim, lang); + pts.sort_unstable(); + // Walk the points alongside the chars rather than searching them for each char. + let mut next = pts.into_iter().peekable(); + let mut s = String::with_capacity(word.len() + 8); for (i, ch) in word.chars().enumerate() { - if pts.contains(&i) { + if next.next_if_eq(&i).is_some() { + while next.next_if_eq(&i).is_some() {} s.push('-'); } s.push(ch); @@ -706,6 +710,26 @@ mod tests { } } + #[test] + fn russian_hyphenation_and_hyphenated_output_are_linear() { + // A Russian word breaks at every syllable: each break used to be checked against all the + // earlier ones (#3657), and the hyphenated output searched every break for each char. + let word = |k: usize| "мама".repeat(k); + assert_eq!(word_breaks_in(&word(2), ProofLang::Ru).0, [2, 4, 6]); + assert_eq!(hyphenate_word_in(&word(2), &Limits::default(), ProofLang::Ru), "ма-ма-ма-ма"); + // The breaks' rescan is a cheap one per break: it takes longer words to stand out. + for (what, k, repeat, f) in [ + ("points", 1000, 3, (|w: &str| drop(hyphen_points_in(w, &Limits::default(), ProofLang::Ru))) as fn(&str)), + ("hyphenated", 400, 20, |w: &str| drop(hyphenate_word_in(w, &Limits::default(), ProofLang::Ru))), + ] { + let (small, big) = (word(k), word(8 * k)); + let time = |w: &str| (0..repeat).for_each(|_| f(w)); + let (t1, t8, ratio) = cost_ratio(8, || time(&small), || time(&big)); + eprintln!("{what}: t(n) {t1:.4} s, t(8n) {t8:.4} s, ratio {ratio:.1}"); + assert!(ratio < 24.0, "8x the letters, not linear: {what} {ratio:.1}"); + } + } + #[test] fn heuristic_fallback() { let c: Vec = "happen".chars().collect(); diff --git a/crates/proof/src/ru/hyphen.rs b/crates/proof/src/ru/hyphen.rs index c02aa12728..33d19b7712 100644 --- a/crates/proof/src/ru/hyphen.rs +++ b/crates/proof/src/ru/hyphen.rs @@ -57,7 +57,9 @@ pub fn points(c: &[char]) -> Vec { while c.get(cut).is_some_and(|&x| sticks_left(x)) && cut < end { cut += 1; } - if cut >= 2 && n - cut >= 2 && !out.contains(&cut) { + // Cuts come in increasing order (each lies after the previous pair's vowel), so a repeat + // can only be the last one: no scan of the whole list per syllable (#3657). + if cut >= 2 && n - cut >= 2 && out.last().is_none_or(|&l| l < cut) { out.push(cut); } } From c187515ba103a431a5f3b1d11e64aab9de1a9a07 Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Thu, 22 Oct 2026 15:15:56 +0100 Subject: [PATCH 081/199] #3625: Turkish suggestions and lookups have a word-length cap Audit finding 20. - tr::suggest_with refuses words over 64 letters (and words with spaces), like the other languages' suggesters (40 there; Turkish agglutinated words run past 40). Each candidate edit copies the whole word, so a long word cost O(n^2) per suggestion request on the UI thread. - TrDict::contains returns false past MAX_WORD_BYTES (120, the affix engine's bound) instead of collecting the char indices of the whole word. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/proof/src/tr/dict.rs | 3 ++- crates/proof/src/tr/suggest.rs | 22 +++++++++++++++++++++- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/crates/proof/src/tr/dict.rs b/crates/proof/src/tr/dict.rs index ed57cbca7b..4b59ccc297 100644 --- a/crates/proof/src/tr/dict.rs +++ b/crates/proof/src/tr/dict.rs @@ -87,7 +87,8 @@ impl TrDict { /// Check if word is correct (stem or affixed). pub fn contains(&self, word: &str) -> bool { - if self.is_empty() || word.is_empty() { + // Longer input is never a dictionary word, and isn't scanned (#3625). + if self.is_empty() || word.is_empty() || word.len() > crate::affix::MAX_WORD_BYTES { return false; } diff --git a/crates/proof/src/tr/suggest.rs b/crates/proof/src/tr/suggest.rs index dedc1bb266..4f4f3d66d4 100644 --- a/crates/proof/src/tr/suggest.rs +++ b/crates/proof/src/tr/suggest.rs @@ -46,6 +46,12 @@ const COMMON_REPLACEMENTS: &[(&str, &str)] = &[ ("deyil", "değil"), ]; +/// Longest word (in letters) given suggestions. Every candidate is a fresh copy of the word, so +/// the cost grows with the square of its length (#3625); Turkish words run longer than most +/// (`Çekoslovakyalılaştıramadıklarımızdanmışsınız` is 43 letters), so the cap is above the other +/// languages' 40. +const MAX_LETTERS: usize = 64; + /// Produce spelling suggestions for a misspelled Turkish word. pub fn suggest(word: &str, max: usize) -> Vec { let dict = super::dictionary(); @@ -54,7 +60,8 @@ pub fn suggest(word: &str, max: usize) -> Vec { /// Suggest with a given dictionary. pub fn suggest_with(d: &TrDict, word: &str, max: usize) -> Vec { - if word.is_empty() || max == 0 { + let n = word.chars().count(); + if n == 0 || n > MAX_LETTERS || max == 0 || word.contains(char::is_whitespace) { return Vec::new(); } @@ -277,6 +284,19 @@ mod tests { assert!(sugs.contains(&"geldin mi".to_string())); } + #[test] + fn overlong_words_get_no_suggestions_promptly() { + let d = TrDict::from_hunspell("SET UTF-8\nSFX 1 Y 1\nSFX 1 0 lar .\n", "2\nkitap/1\nsonra\n").unwrap(); + assert!(suggest_with(&d, "sorna", 3).contains(&"sonra".to_string())); + assert!(suggest_with(&d, "kitaplr", 3).contains(&"kitaplar".to_string())); + let t = std::time::Instant::now(); + let long = "a".repeat(100_000); + assert!(suggest_with(&d, &long, 8).is_empty()); + assert!(!d.contains(&long) && !d.contains(&format!("kitap{long}"))); + assert!(!super::super::is_correct_with(&d, &long)); + assert!(t.elapsed().as_secs_f64() < 1.0, "{:?}", t.elapsed()); + } + #[test] fn turkish_to_lower_handles_i() { assert_eq!(turkish_to_lower("IŞIK"), "ışık"); From 5cd68ee632ff9b112e33911fb23d01f917c54061 Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Thu, 22 Oct 2026 15:16:30 +0100 Subject: [PATCH 082/199] #3692: progress log and gap 3 note for the proofing robustness fixes Audit findings 2, 4, 6, 10, 15, 20 (#3613 part, #3661, #3657, #3625). Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/gaps.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/gaps.md b/docs/gaps.md index e1af69a465..8e424edc86 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -35,6 +35,8 @@ Document renaming: desktop renaming requires a filesystem that supports hard lin Comment command safety (#1690, finding 3): Delete, Resolve, Reply and Edit reject explicit fractional, negative or wrong-type IDs before editing, rather than truncating floats or using the caret. Synthetic tests cover refusal without document or history changes, valid operations with undo/redo and repeated DOCX saves. This narrows the data-path risks in gap #3; the other audit findings and broader stability gate are outside this change. +Proofing robustness (security audit, #3613 part, #3661, #3657, #3625): installed Hunspell dictionaries with thousands of self-continuing rules check each word in linear time and list a bounded number of forms; dictionary files are read with a bounded read (Turkish included) and the hyphenation dictionary inflates under a 64 MiB limit; Russian hyphenation and Turkish suggestions no longer grow with the square of the word's length. This narrows the freeze risks in gap #3; distinct-strip rule chains and proofing off the UI thread remain (#3613). + Font-menu reliability: clearing a query with no matches now shows the recent section from its first row, including its heading, in a scrollable list. Fixed synthetic-family widget and full-ribbon tests cover this case and matching-query controls (#2068). Additive paragraph spacing retains the full before-space after a section page break; ordinary overlapping spacing is unchanged. Other pagination gaps remain open. @@ -158,6 +160,7 @@ Small follow-ups from the 2026-10-25 field, table and AutoFormat batch (#4232): | 2026-10-23 | trivial | cu20we: paragraph-relative box floats use the pre-spacing origin; header/footer/cell/text-box regressions; readiness estimates unchanged | | 2026-10-24 | trivial | Exact-spacing table contact uses the specified line height in row selection and word skipping; large/small mark regressions in modes 11/12/14/15 (#3977); shape estimates unchanged | | 2026-10-24 | trivial | Floating tables count exact line-bottom contact; shapes keep strict overlap (#3977); original boundary/skip regressions in modes 11/12/14/15, expected Word rule from cyril265; readiness estimates unchanged | +| 2026-10-22 | trivial | Gap 3: proofing robustness — linear affix rule walks, bounded dictionary reads, linear Russian hyphenation, Turkish word cap (#3613 part, #3661, #3657, #3625); readiness estimates unchanged | | 2026-10-22 | trivial | Gap 10: group connector rerouting preserves rotated/flipped free and unresolved endpoints under nonuniform scaling, with safe degenerate-dimension and display-size-cap handling; routing around shapes and readiness estimates unchanged | | 2026-10-21 | trivial | The ranked gap list held two rows each for #19, #20 and #21; the stale copies are gone, so each gap appears once, and the #20 row carries the measured figure (#3411) | | 2026-10-21 | trivial | #20 narrowed: a document with many relationships opens in the time its part takes (#3411) | From 6a66004a06d0343a84802710b75608743e93eb30 Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Fri, 23 Oct 2026 02:45:26 +0100 Subject: [PATCH 083/199] =?UTF-8?q?#3625:=20review=20=E2=80=94=20"bir=20ka?= =?UTF-8?q?=C3=A7"=20still=20gets=20"birka=C3=A7":=20the=20replacement=20t?= =?UTF-8?q?able=20is=20checked=20before=20multi-word=20input=20is=20refuse?= =?UTF-8?q?d?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01CX1hMKX28764Lrx5moxs1o --- crates/proof/src/tr/suggest.rs | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/crates/proof/src/tr/suggest.rs b/crates/proof/src/tr/suggest.rs index 4f4f3d66d4..38020dedb7 100644 --- a/crates/proof/src/tr/suggest.rs +++ b/crates/proof/src/tr/suggest.rs @@ -61,7 +61,7 @@ pub fn suggest(word: &str, max: usize) -> Vec { /// Suggest with a given dictionary. pub fn suggest_with(d: &TrDict, word: &str, max: usize) -> Vec { let n = word.chars().count(); - if n == 0 || n > MAX_LETTERS || max == 0 || word.contains(char::is_whitespace) { + if n == 0 || n > MAX_LETTERS || max == 0 { return Vec::new(); } @@ -79,6 +79,10 @@ pub fn suggest_with(d: &TrDict, word: &str, max: usize) -> Vec { } } } + // Only the table takes more than one word (`bir kaç`). + if word.contains(char::is_whitespace) { + return suggestions; + } // 2. Diacritic substitutions (`c` -> `ç`, `g` -> `ğ`, `ı` -> `i`, `o` -> `ö`, `s` -> `ş`, `u` -> `ü`) if !d.is_empty() { @@ -297,6 +301,14 @@ mod tests { assert!(t.elapsed().as_secs_f64() < 1.0, "{:?}", t.elapsed()); } + #[test] + fn two_words_get_only_the_tables_replacement() { + let d = TrDict::from_hunspell("SET UTF-8\n", "2\nbirkaç\nsonra\n").unwrap(); + assert_eq!(suggest_with(&d, "bir kaç", 3), vec!["birkaç".to_string()]); + assert_eq!(suggest_with(&d, "Bir kaç", 3), vec!["birkaç".to_string()]); + assert!(suggest_with(&d, "sorna sorna", 3).is_empty()); + } + #[test] fn turkish_to_lower_handles_i() { assert_eq!(turkish_to_lower("IŞIK"), "ışık"); From 5a5aa591cd84259f4076978de93153fa13c5a0ba Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Fri, 23 Oct 2026 11:27:52 +0100 Subject: [PATCH 084/199] =?UTF-8?q?#3661:=20review=20=E2=80=94=20ISO=20885?= =?UTF-8?q?9-9=20Turkish=20dictionaries=20load=20through=20the=20capped=20?= =?UTF-8?q?reader?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Asked to confirm a non-UTF-8 Turkish dictionary still loads: before this PR tr_TR was read with read_to_string, so a non-UTF-8 file never loaded; now it goes through read_capped and decode, which didn't know ISO 8859-9 (Latin-5, the encoding of older tr_TR dictionaries) and refused it as unsupported. decode now reads `SET ISO8859-9`, and a test writes an ISO 8859-9 .aff/.dic to disk and finds words with ğ ı ş İ ç ö ü, as stems and with suffixes. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01CX1hMKX28764Lrx5moxs1o --- crates/proof/src/hunspell.rs | 47 ++++++++++++++++++++++++++++++++++- docs/spelling-dictionaries.md | 8 +++--- 2 files changed, 51 insertions(+), 4 deletions(-) diff --git a/crates/proof/src/hunspell.rs b/crates/proof/src/hunspell.rs index a5b41214d0..e86c7f6876 100644 --- a/crates/proof/src/hunspell.rs +++ b/crates/proof/src/hunspell.rs @@ -174,6 +174,23 @@ fn decode_latin1(bytes: &[u8]) -> String { bytes.iter().map(|&b| char::from(b)).collect() } +/// ISO 8859-9 (Latin-5, Turkish): ISO 8859-1 with Ğ İ Ş ğ ı ş in place of Ð Ý Þ ð ý þ. The +/// encoding of older Turkish dictionaries (`SET ISO8859-9`). +fn decode_latin5(bytes: &[u8]) -> String { + bytes + .iter() + .map(|&b| match b { + 0xd0 => 'Ğ', + 0xdd => 'İ', + 0xde => 'Ş', + 0xf0 => 'ğ', + 0xfd => 'ı', + 0xfe => 'ş', + _ => char::from(b), + }) + .collect() +} + /// ISO 8859-2 code points 0xA0..=0xFF (0x00..=0x9F map to themselves). const ISO_8859_2_HIGH: &str = "\u{a0}Ą˘Ł¤ĽŚ§¨ŠŞŤŹ\u{ad}ŽŻ°ą˛ł´ľśˇ¸šşťź˝žżŔÁÂĂÄĹĆÇČÉĘËĚÍÎĎĐŃŇÓÔŐÖ×ŘŮÚŰÜÝŢßŕáâăäĺćçčéęëěíîďđńňóôőö÷řůúűüýţ˙"; @@ -199,7 +216,8 @@ fn decode_high(bytes: &[u8], high: &str) -> String { /// Decode an `.aff`/`.dic` pair to UTF-8 from the encoding the `.aff`'s `SET` line names /// (UTF-8 when there is none; ISO 8859-2 is the other one Polish dictionaries use, ISO 8859-1 the -/// one the original Portuguese VERO uses, KOI8-R and Windows-1251 the ones older Russian dictionaries use). +/// one the original Portuguese VERO uses, ISO 8859-9 the one older Turkish dictionaries use, KOI8-R +/// and Windows-1251 the ones older Russian dictionaries use). pub fn decode(aff: &[u8], dic: &[u8]) -> Result<(String, String), String> { let head = String::from_utf8_lossy(aff.get(..aff.len().min(8192)).unwrap_or_default()).to_string(); let set = head.lines().find_map(|l| l.trim_start_matches('\u{feff}').strip_prefix("SET ")).map(str::trim).unwrap_or("UTF-8").to_ascii_uppercase(); @@ -210,6 +228,7 @@ pub fn decode(aff: &[u8], dic: &[u8]) -> Result<(String, String), String> { } "ISO8859-1" | "ISO-8859-1" => Ok((decode_latin1(aff), decode_latin1(dic))), "ISO8859-2" | "ISO-8859-2" => Ok((decode_latin2(aff), decode_latin2(dic))), + "ISO8859-9" | "ISO-8859-9" => Ok((decode_latin5(aff), decode_latin5(dic))), "KOI8-R" | "KOI8R" => Ok((decode_high(aff, KOI8_R_HIGH), decode_high(dic, KOI8_R_HIGH))), "CP1251" | "WINDOWS-1251" | "MICROSOFT-CP1251" => Ok((decode_high(aff, CP1251_HIGH), decode_high(dic, CP1251_HIGH))), other => Err(format!("unsupported encoding {other}")), @@ -228,6 +247,8 @@ mod tests { assert_eq!(d, "2\nżółw\n"); let (_, d) = decode("SET UTF-8\n".as_bytes(), "żółw".as_bytes()).unwrap(); assert_eq!(d, "żółw"); + let (_, d) = decode(b"SET ISO8859-9\n", b"\xd0\xdd\xde\xf0\xfd\xfe\xd6\xe7").unwrap(); + assert_eq!(d, "ĞİŞğışÖç"); assert!(decode(b"SET SHIFT_JIS\n", b"").is_err()); assert!(decode(b"SET UTF-8\n", b"\xff\xfe").is_err()); } @@ -323,4 +344,28 @@ mod tests { assert!(crate::tr::load(&tr_aff, &big).unwrap_err().contains("too large")); let _ = std::fs::remove_dir_all(&dir); } + + /// A Turkish dictionary in ISO 8859-9, the encoding older tr_TR dictionaries use, read from + /// disk through the capped reader and decoded per its `SET` line: its words with Turkish + /// letters (ğ ı ş İ, and ç ö ü from Latin-1) are found, as stems and with a suffix. Before the + /// Turkish dictionary went through this reader it was read as UTF-8, which these bytes are not. + #[test] + fn a_turkish_dictionary_in_iso_8859_9_loads_from_disk() { + let dir = std::env::temp_dir().join(format!("wordcraft-hunspell-latin5-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let (aff, dic) = (dir.join("tr_TR.aff"), dir.join("tr_TR.dic")); + std::fs::write(&aff, b"SET ISO8859-9\nSFX 1 Y 2\nSFX 1 0 lar .\nSFX 1 0 \xfd .\nSFX 2 Y 1\nSFX 2 0 ler .\n").unwrap(); + // ağaç/1 (-lar, -ı), ışık, şişe/2 (-ler), İzmir, öğrenci/2, güneş. + let words: &[u8] = b"6\na\xf0a\xe7/1\n\xfd\xfe\xfdk\n\xfei\xfee/2\n\xddzmir\n\xf6\xf0renci/2\ng\xfcne\xfe\n"; + std::fs::write(&dic, words).unwrap(); + assert!(String::from_utf8(words.to_vec()).is_err(), "the .dic is not UTF-8"); + let d = crate::tr::load(&aff, &dic).unwrap(); + for w in ["ağaç", "ağaçlar", "ağaçı", "ışık", "şişe", "şişeler", "İzmir", "öğrenci", "öğrenciler", "güneş"] { + assert!(d.contains(w), "{w}"); + } + // The six letters ISO 8859-9 puts in place of ISO 8859-1's aren't read as those. + assert!(!d.contains("aðaç") && !d.contains("ýþýk") && !d.contains("Ýzmir")); + assert!(!d.contains("ağaçs") && !d.contains("ışıklar") && !d.contains("şişelar"), "only the rules' suffixes, on the stems that take them"); + let _ = std::fs::remove_dir_all(&dir); + } } diff --git a/docs/spelling-dictionaries.md b/docs/spelling-dictionaries.md index 276f1463f0..70354e7e0c 100644 --- a/docs/spelling-dictionaries.md +++ b/docs/spelling-dictionaries.md @@ -1,6 +1,6 @@ # Installing spelling dictionaries -> **Last reviewed:** 2026-10-16 · **Last updated:** 2026-10-23 · **Change:** minor (Spanish: `es_ES`, `es_MX`, `es_419` and `es_UY` with their fallbacks, #3360, #3553; previously trivial: every Hunspell language shows (no dictionary), not only Polish and Portuguese, and Indonesian is listed, #3150; previously trivial: ISO 8859-1 dictionaries are read, #2824) +> **Last reviewed:** 2026-10-16 · **Last updated:** 2026-10-23 · **Change:** minor (ISO 8859-9 Turkish dictionaries are read, #3692; previously minor: Spanish: `es_ES`, `es_MX`, `es_419` and `es_UY` with their fallbacks, #3360, #3553; previously trivial: every Hunspell language shows (no dictionary), not only Polish and Portuguese, and Indonesian is listed, #3150; previously trivial: ISO 8859-1 dictionaries are read, #2824) English spelling works out of the box. Polish, Russian, Portuguese, Czech, Italian, Croatian, Turkish, Indonesian and Spanish spelling use a standard **Hunspell** dictionary (an `.aff` and a `.dic` file) that @@ -44,8 +44,9 @@ Spanish regions of the Americas (Argentina, Colombia, the United States…) the American one found. LibreOffice's `dict-es` has them all. Uruguayan Spanish also accepts the `vos` forms of the dictionary's verbs (`tenés`, `vení`, `decime`, `decímelo`). -The files may be UTF-8 or the encoding named on the `.aff`'s `SET` line (ISO 8859-1, ISO 8859-2, KOI8-R -and Windows-1251 are read as well; the original VERO `pt_BR` is ISO 8859-1). +The files may be UTF-8 or the encoding named on the `.aff`'s `SET` line (ISO 8859-1, ISO 8859-2, +ISO 8859-9, KOI8-R and Windows-1251 are read as well; the original VERO `pt_BR` is ISO 8859-1, older +Turkish `tr_TR` dictionaries ISO 8859-9). ## Where WordCraft looks @@ -141,6 +142,7 @@ Never commit dictionary files to this repository: they stay on the user's system |---|---|---| | 2026-10-22 | minor | Spanish (#3360, #3553): the file names for Spain, Mexico, Latin America and Uruguay and their fallbacks | | 2026-10-22 | trivial | Italian, Croatian, Turkish, Russian, Czech and Indonesian without a dictionary now show **(no dictionary)** (or **(dictionary unreadable)**) too, as this page already said; before, only Polish and Portuguese did. Indonesian is listed with its file names (#3150) | +| 2026-10-23 | trivial | ISO 8859-9 (`SET ISO8859-9`) dictionaries are read, so an older Turkish `tr_TR` loads without converting it to UTF-8; the Turkish dictionary is read through the same size-capped reader and `SET` decoding as the others (#3692) | | 2026-10-18 | trivial | ISO 8859-1 (`SET ISO8859-1`) dictionaries are read, so the original VERO `pt_BR` works without converting it to UTF-8 (#2824) | | 2026-10-18 | minor | An installed dictionary that can't be read shows **(dictionary unreadable)** in the status bar, with the file and reason in its tooltip and in File › Options › Proofing (#2830) | | 2026-10-16 | major | First version: the file names per language, the search order and folders on Windows, macOS and Linux, installing `pt_BR` on each, and how to check it works | From 6bcd997be8645578b50fe1c5268ec7151beda662 Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Fri, 23 Oct 2026 14:20:40 +0100 Subject: [PATCH 085/199] =?UTF-8?q?#3692:=20review=20=E2=80=94=20proof=20t?= =?UTF-8?q?iming=20tests=20get=20a=20margin=20a=20busy=20CI=20machine=20ca?= =?UTF-8?q?n't=20break?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wall-clock bounds of 1-2 s failed on loaded runners. Each bound is now 10 s, at least 10x the fixed code's time and well under the old code's on the same input (measured by running the pre-fix code): - self-continuing rules: 40,000 rules (was 16,000); ~1 ms now, old checker 14 s at 16,000. - many strips per suffix: 200,000 rules (was 30,000); 0.4 s now, old index 3 s at 30,000 and 11 min at 300,000. - forms of self-continuing rules: the MAX_FORMS count catches the old code (9 million forms); the time bound is a backstop. - overlong Turkish words: ~20 ms now; without the caps it ran past 5 minutes. - capped Hunspell reads: no clock; the "not a regular file" error is the proof (the old reader never returned from /dev/zero). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01CX1hMKX28764Lrx5moxs1o --- crates/proof/src/affix.rs | 22 ++++++++++++++-------- crates/proof/src/hunspell.rs | 5 ++--- crates/proof/src/tr/suggest.rs | 3 ++- 3 files changed, 18 insertions(+), 12 deletions(-) diff --git a/crates/proof/src/affix.rs b/crates/proof/src/affix.rs index fefc7f6ac8..03fc27a6bd 100644 --- a/crates/proof/src/affix.rs +++ b/crates/proof/src/affix.rs @@ -1310,11 +1310,13 @@ pub(crate) mod tests { #[test] fn self_continuing_rules_check_in_linear_time() { // Thousands of empty-append rules that continue with their own flag: each check used to - // follow every rule from every rule, millions of steps a word (#3613). - let d = many_rules(16_000, |_| "0 0/A .".into()); + // follow every rule from every rule, millions of steps a word (#3613). The checks take + // about a millisecond; the old checker took 14 s for two of them at 16,000 rules, so at + // 40,000 (quadratic: minutes) the 10 s bound leaves a busy machine a wide margin. + let d = many_rules(40_000, |_| "0 0/A .".into()); let t = std::time::Instant::now(); assert!(d.check("kot") && !d.check("pies") && !d.check("kotx")); - assert!(t.elapsed().as_secs_f64() < 2.0, "{:?}", t.elapsed()); + assert!(t.elapsed().as_secs_f64() < 10.0, "{:?}", t.elapsed()); } #[test] @@ -1324,7 +1326,9 @@ pub(crate) mod tests { let distinct = many_rules(3000, |i| format!("0 x{i}/A .")); let t = std::time::Instant::now(); let (a, b) = (same.forms("kot"), distinct.forms("kot")); - assert!(t.elapsed().as_secs_f64() < 2.0, "{:?}", t.elapsed()); + // The cap on the count is what tells the old code (9 million forms each) apart; the + // generous time bound (tens of ms here) only backs up the rule-visit cap. + assert!(t.elapsed().as_secs_f64() < 10.0, "{:?}", t.elapsed()); assert!(a.len() <= MAX_FORMS && b.len() <= MAX_FORMS, "{} {}", a.len(), b.len()); assert!(b.iter().any(|f| f == "kotx0x1"), "chained forms are still listed"); } @@ -1332,11 +1336,13 @@ pub(crate) mod tests { #[test] fn many_strips_per_suffix_index_in_linear_time() { // One append with thousands of strips: the index used to scan the append's groups for - // each rule. + // each rule. Building 200,000 rules takes well under a second; the old index took 3 s at + // 30,000 and over 10 minutes at 300,000. + let n = 200_000; let t = std::time::Instant::now(); - let d = many_rules(30_000, |i| format!("k{i} y .")); - assert!(t.elapsed().as_secs_f64() < 2.0, "{:?}", t.elapsed()); - assert_eq!(d.sfx_index.get("y").map(Vec::len), Some(30_000)); + let d = many_rules(n, |i| format!("k{i} y .")); + assert!(t.elapsed().as_secs_f64() < 10.0, "{:?}", t.elapsed()); + assert_eq!(d.sfx_index.get("y").map(Vec::len), Some(n)); assert!(!d.contains("koty")); } diff --git a/crates/proof/src/hunspell.rs b/crates/proof/src/hunspell.rs index e86c7f6876..3449c8cab7 100644 --- a/crates/proof/src/hunspell.rs +++ b/crates/proof/src/hunspell.rs @@ -329,13 +329,12 @@ mod tests { // One byte over the cap (sparse: nothing is written) is refused without reading it. let big = dir.join("big.dic"); std::fs::File::create(&big).unwrap().set_len(MAX_FILE + 1).unwrap(); - let t = std::time::Instant::now(); assert!(load(&aff, &big).unwrap_err().contains("too large")); - // A directory, and on Unix an endless device, are not dictionary files. + // A directory, and on Unix an endless device, are not dictionary files. (No clock: a + // reader that read `/dev/zero` would never return, so the error itself is the proof.) assert!(load(&aff, &dir).unwrap_err().contains("not a regular file")); #[cfg(unix)] assert!(load(&aff, Path::new("/dev/zero")).unwrap_err().contains("not a regular file")); - assert!(t.elapsed().as_secs_f64() < 1.0, "{:?}", t.elapsed()); // Turkish reads through the same reader. let (tr_aff, tr_dic) = (dir.join("tr_TR.aff"), dir.join("tr_TR.dic")); std::fs::write(&tr_aff, "SET UTF-8\nSFX 1 Y 1\nSFX 1 0 lar .\n").unwrap(); diff --git a/crates/proof/src/tr/suggest.rs b/crates/proof/src/tr/suggest.rs index 38020dedb7..c5f4db8959 100644 --- a/crates/proof/src/tr/suggest.rs +++ b/crates/proof/src/tr/suggest.rs @@ -298,7 +298,8 @@ mod tests { assert!(suggest_with(&d, &long, 8).is_empty()); assert!(!d.contains(&long) && !d.contains(&format!("kitap{long}"))); assert!(!super::super::is_correct_with(&d, &long)); - assert!(t.elapsed().as_secs_f64() < 1.0, "{:?}", t.elapsed()); + // Tens of milliseconds with the caps; without them this ran for over 5 minutes. + assert!(t.elapsed().as_secs_f64() < 10.0, "{:?}", t.elapsed()); } #[test] From 7f203bc1c8d472840eaf97b1d900c9bd61b87c46 Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Mon, 19 Oct 2026 01:49:43 +0100 Subject: [PATCH 086/199] #39: per-user Windows installer that needs no administrator rights Build wordcraft.wxs a second time as a dual-purpose package (Scope=perUserOrMachine: ALLUSERS=2, MSIINSTALLPERUSER=1), shipped as wordcraft--windows-user-.msi. Registry keys move from HKLM to HKMU so the same components land in HKLM per-machine and HKCU per-user. Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 14 +++-- ROADMAP.md | 1 + docs/releasing.md | 2 +- packaging/windows/package.ps1 | 31 ++++++---- packaging/windows/wordcraft.wxs | 104 +++++++++++++++++--------------- 5 files changed, 87 insertions(+), 65 deletions(-) diff --git a/README.md b/README.md index 42340c3133..b52cf3e518 100644 --- a/README.md +++ b/README.md @@ -221,13 +221,15 @@ closer pagination fidelity, a stability sweep and native printing. Details and e ### Windows -| Build | Installer | Portable | -|---|---|---| -| x64 (64-bit Intel/AMD) | `wordcraft--windows-x64.msi` | `wordcraft--windows-x64-portable.zip` | -| arm64 (Snapdragon and other ARM PCs) | `wordcraft--windows-arm64.msi` | `wordcraft--windows-arm64-portable.zip` | -| x86 (32-bit) | `wordcraft--windows-x86.msi` | `wordcraft--windows-x86-portable.zip` | +| Build | Installer (all users) | Installer (just me) | Portable | +|---|---|---|---| +| x64 (64-bit Intel/AMD) | `wordcraft--windows-x64.msi` | `wordcraft--windows-user-x64.msi` | `wordcraft--windows-x64-portable.zip` | +| arm64 (Snapdragon and other ARM PCs) | `wordcraft--windows-arm64.msi` | `wordcraft--windows-user-arm64.msi` | `wordcraft--windows-arm64-portable.zip` | +| x86 (32-bit) | `wordcraft--windows-x86.msi` | `wordcraft--windows-user-x86.msi` | `wordcraft--windows-x86-portable.zip` | -Installers and executables are code-signed. +The all-users installer puts WordCraft in Program Files and needs administrator rights. The +just-me installer needs none: it installs into `%LOCALAPPDATA%\Programs\WordCraft` for your +account only. Installers and executables are code-signed. **If the app doesn't open on Windows:** the desktop app initializes only DirectX 12 by default. Letting wgpu also create an OpenGL instance can crash some graphics drivers (AMD's diff --git a/ROADMAP.md b/ROADMAP.md index 2d0b45ddd2..9072433bdb 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -210,6 +210,7 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target | 2026-10-20 | Restrict Editing holds for the file commands too: the privacy options, the properties and the inspector's removals are refused on a protected document, and restoring a stored version keeps the protection and its password instead of the snapshot's stale ones | | 2026-10-17 | Ribbon hints' reasons for a greyed-out control are translated in every interface language; a test fails if a reason it finds in the command sources, or the "Open a document first" hint, has no translation (#2678). | | 2026-10-17 | Ribbon hover hints: a disabled control says why (the command's own reason), split-button arrows, menu buttons and the font, size and outline boxes have hints, and hints show on keyboard focus (#1086). | +| 2026-10-16 | Windows per-user installer (#39): each release also ships `wordcraft--windows-user-.msi`, built from the same WiX source as a dual-purpose package, so it installs into `%LOCALAPPDATA%\Programs` for the current user with no administrator rights; registry keys moved to HKMU so both installers share one set of components | | 2026-10-14 | DOCX row table border exceptions render and survive saving. Explicit cell borders win; a row exception does not change other rows. | | 2026-10-14 | Tab stops edited on the ruler: the corner selector picks Left/Center/Right/Decimal/Bar, a click sets a stop (Alt skips the snap), dragging moves it with a guide line and position tooltip, dragging off the ruler clears it; markers draw per kind and mirror for RTL; one `para.tabs` call and one undo step per gesture | | 2026-10-12 | Fields keep `w:dirty` through DOCX saves (#877): a begin marker flagged by a generator that couldn't compute a result (pandoc-style TOC placeholders) keeps the flag on open/save — as a buffered field or a range field — so Word still refreshes it on open; computing a result (Update Field, Update Table) clears it, like Word's F9 | diff --git a/docs/releasing.md b/docs/releasing.md index 411af7593e..fc2503550a 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -11,7 +11,7 @@ this file has the WordCraft specifics. | Platform | Artifacts | Signing | |---|---|---| | macOS | universal `WordCraft.app` in a `.dmg`, universal CLI zip | Developer ID + notarization (`APPLE_*` secrets) | -| Windows | x64, x86 and arm64 `.msi` + portable `.zip` | Azure Trusted Signing (`AZURE_*` secrets) | +| Windows | x64, x86 and arm64 `.msi` (per-machine, and a per-user `-windows-user-.msi` that needs no admin rights) + portable `.zip` | Azure Trusted Signing (`AZURE_*` secrets) | | Linux | x86_64 and aarch64 `.AppImage` (+ `.zsync` for AppImageUpdate), `.flatpak` bundle, `.deb`, `.rpm`, `.tar.gz`; Flathub manifest | checksums | | Linux riscv64 | `.tar.gz` (cross-compiled on `ubuntu-24.04`, glibc >= 2.39; CLI smoke-tested under QEMU) | checksums | | FreeBSD | x86_64 `.tar.gz` | checksums | diff --git a/packaging/windows/package.ps1 b/packaging/windows/package.ps1 index 5223527598..e89a25e432 100644 --- a/packaging/windows/package.ps1 +++ b/packaging/windows/package.ps1 @@ -5,6 +5,7 @@ .DESCRIPTION Produces, in $env:DIST (default: dist/release): wordcraft--windows-.msi per-machine installer (WiX v5) + wordcraft--windows-user-.msi per-user installer, no admin rights (#39) wordcraft--windows--portable.zip wordcraft.exe + wordcraft-cli.exe The binaries link the C runtime statically (+crt-static), so neither the MSI nor the portable @@ -142,18 +143,26 @@ $rtf = '{\rtf1\ansi\ansicpg1252\deff0{\fonttbl{\f0\fswiss\fcharset0 Tahoma;}}\vi ($paragraphs -join "\par`r`n") + "\par`r`n}`r`n" [System.IO.File]::WriteAllText($LicenseRtf, $rtf, [System.Text.Encoding]::ASCII) +# Two MSIs from one source: per-machine (Program Files, needs admin) and per-user (#39: no admin, +# into %LOCALAPPDATA%\Programs; see wordcraft.wxs). The per-user name puts "user" before the arch +# so the per-machine MSI sorts first: the shared windows-arm64 workflow installs the first *.msi +# it finds and checks Program Files. $Msi = Join-Path $Dist "wordcraft-$Version-windows-$Arch.msi" -Invoke-Native 'wix build' { - wix build (Join-Path $PSScriptRoot 'wordcraft.wxs') -arch $Arch ` - -ext WixToolset.UI.wixext -ext WixToolset.Util.wixext ` - -d "Version=$MsiVersion" -d "BinDir=$Stage" -d "IconPath=$(Join-Path $Root 'assets\app-icon\wordcraft.ico')" ` - -d "LicenseRtf=$LicenseRtf" ` - -d "AppNoticesDir=$AppNotices" -d "DocsDir=$Docs" -d "HasCraftFontLicences=$HasCraftFontLicences" ` - -o $Msi +$UserMsi = Join-Path $Dist "wordcraft-$Version-windows-user-$Arch.msi" +foreach ($build in @(@($Msi, 'perMachine'), @($UserMsi, 'perUserOrMachine'))) { + $out = $build[0] + Invoke-Native "wix build ($($build[1]))" { + wix build (Join-Path $PSScriptRoot 'wordcraft.wxs') -arch $Arch ` + -ext WixToolset.UI.wixext -ext WixToolset.Util.wixext ` + -d "Version=$MsiVersion" -d "BinDir=$Stage" -d "IconPath=$(Join-Path $Root 'assets\app-icon\wordcraft.ico')" ` + -d "LicenseRtf=$LicenseRtf" ` + -d "AppNoticesDir=$AppNotices" -d "DocsDir=$Docs" -d "HasCraftFontLicences=$HasCraftFontLicences" -d "Scope=$($build[1])" ` + -o $out + } + # wix writes its debug symbols (.wixpdb) next to the MSI; keep them out of the release assets. + Remove-Item -Force -ErrorAction SilentlyContinue ([IO.Path]::ChangeExtension($out, '.wixpdb')) + & (Join-Path $PSScriptRoot 'sign.ps1') $out } -# wix writes its debug symbols (.wixpdb) next to the MSI; keep them out of the release assets. -Remove-Item -Force -ErrorAction SilentlyContinue ([IO.Path]::ChangeExtension($Msi, '.wixpdb')) -& (Join-Path $PSScriptRoot 'sign.ps1') $Msi # ---- portable zip ------------------------------------------------------------------------------ $Portable = Join-Path $TargetDir "windows-package\wordcraft-$Version-windows-$Arch-portable" @@ -182,4 +191,4 @@ if ($Arch -ne 'arm64' -or $HostArch -eq 'arm64') { } else { Write-Output "skipping wordcraft-cli --version: an $Arch build doesn't run on this $HostArch machine" } -Get-Item $Msi, $Zip | Format-Table Name, Length +Get-Item $Msi, $UserMsi, $Zip | Format-Table Name, Length diff --git a/packaging/windows/wordcraft.wxs b/packaging/windows/wordcraft.wxs index 27aa89ca25..5be96705b1 100644 --- a/packaging/windows/wordcraft.wxs +++ b/packaging/windows/wordcraft.wxs @@ -5,16 +5,26 @@ Built by package.ps1: wix build wordcraft.wxs -arch x64 -ext WixToolset.UI.wixext -ext WixToolset.Util.wixext - -d Version=1.2.3 -d BinDir=... -d IconPath=... -d LicenseRtf=... -o out.msi + -d Version=1.2.3 -d BinDir=... -d IconPath=... -d LicenseRtf=... [-d Scope=perUserOrMachine] -o out.msi Per-machine install into Program Files with a Start Menu shortcut. The installer shows the standard WiX dialogs: welcome + license, progress, and a finish page. + Built a second time with -d Scope=perUserOrMachine for people without administrator rights + (#39): a dual-purpose package that WiX gives ALLUSERS=2 and MSIINSTALLPERUSER=1, so it installs + for the current user with no UAC prompt. Windows then redirects Program Files to + %LOCALAPPDATA%\Programs, the Start Menu to the user's own, and HKMU to HKCU, so the same + components work in both packages. Both share the UpgradeCode: each upgrades its own earlier + versions (Windows Installer only matches products installed in the same context). + UpgradeCode and the component GUIDs below belong to WordCraft alone. Never copy them from (or into) a sibling Craft app: Windows Installer treats every package sharing an UpgradeCode as one product, so a shared code makes one app's installer refuse to install ("a newer version is already installed") or silently uninstall the other. packaging-lint.yml checks this. --> + + + @@ -24,7 +34,7 @@ Manufacturer="Learning Machines LLC" Version="$(var.Version)" UpgradeCode="075497F9-E861-45D0-9C7F-A74F8C34E6BA" - Scope="perMachine" + Scope="$(var.Scope)" Compressed="yes" Language="1033"> @@ -88,7 +98,7 @@ Icon="WordcraftIcon.ico" IconIndex="0" /> - + - - - - - - + + + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - - + + + + - - - + + + - - - + + + From c1eb02f23c22106731c5e2d0a5c22fc73effe25c Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Mon, 19 Oct 2026 01:51:22 +0100 Subject: [PATCH 087/199] #39: README: installing both Windows installers gives two entries Review follow-up (FredApps): the all-users and just-me MSIs install side by side, so pick one. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_012WEhZXSxzS8orX7f9J3qpr --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index b52cf3e518..b0445a01eb 100644 --- a/README.md +++ b/README.md @@ -229,7 +229,8 @@ closer pagination fidelity, a stability sweep and native printing. Details and e The all-users installer puts WordCraft in Program Files and needs administrator rights. The just-me installer needs none: it installs into `%LOCALAPPDATA%\Programs\WordCraft` for your -account only. Installers and executables are code-signed. +account only. Choose one: installing both gives two WordCraft entries in Settings › Apps and two +in the Start menu. Installers and executables are code-signed. **If the app doesn't open on Windows:** the desktop app initializes only DirectX 12 by default. Letting wgpu also create an OpenGL instance can crash some graphics drivers (AMD's From 6c20b3e609033119f93464c9b7eb2230bad2e390 Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Thu, 22 Oct 2026 15:26:43 +0100 Subject: [PATCH 088/199] #3692: proof_text walks the runs once Finding 29: proof_text looked up each character's run with props_of_char, walking the runs from the first for every character (quadratic in a paragraph of many runs). A run cursor advancing with the text gives the same props in one pass. Adds layout::tests_linear (sharing tests.rs's cost_ratio helpers) with proof_text_walks_the_runs_once. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/layout/src/lib.rs | 2 ++ crates/layout/src/para.rs | 12 ++++++++- crates/layout/src/tests.rs | 6 ++--- crates/layout/src/tests_linear.rs | 45 +++++++++++++++++++++++++++++++ 4 files changed, 61 insertions(+), 4 deletions(-) create mode 100644 crates/layout/src/tests_linear.rs diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index 23f855c5e4..464a7178c4 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -4935,6 +4935,8 @@ mod tests_ligature; #[cfg(test)] mod tests_nbsp; #[cfg(test)] +mod tests_linear; +#[cfg(test)] mod tests_numeral; #[cfg(test)] mod tests_pages; diff --git a/crates/layout/src/para.rs b/crates/layout/src/para.rs index 11e8f95c83..3dff3a8bec 100644 --- a/crates/layout/src/para.rs +++ b/crates/layout/src/para.rs @@ -3319,8 +3319,18 @@ fn proof_issues( /// U+0001 bytes (neither words nor spaces). pub fn proof_text(p: &wordcraft_doc::Paragraph) -> String { let mut out = String::with_capacity(p.text.len()); + // The run holding each character ([`Paragraph::props_of_char`]), found by walking the runs + // along with the text rather than from the first run for every character. + let mut runs = p.runs.iter(); + let mut run = runs.next(); + let mut end = run.map_or(0, |r| r.len); for (i, c) in p.text.char_indices() { - let deleted = c == wordcraft_doc::para::OBJ || p.props_of_char(i).del.is_some(); + while run.is_some() && i >= end { + run = runs.next(); + end = end.saturating_add(run.map_or(0, |r| r.len)); + } + let props = run.map_or(&p.mark, |r| &r.props); + let deleted = c == wordcraft_doc::para::OBJ || props.del.is_some(); if deleted { for _ in 0..c.len_utf8() { out.push('\u{1}'); diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index 7ed459c39d..6c63582ea3 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -738,7 +738,7 @@ fn hidden_text_adds_no_line_break_opportunity_when_not_shown() { /// How much bigger the second document is in the linear-time tests. At 8x a linear layout costs /// about 8x as much and a quadratic one about 64x, so a limit of 24x stays far from both. -const SCALE: usize = 8; +pub(crate) const SCALE: usize = 8; /// The CPU time `f` takes on this thread, in seconds. On Linux it is the scheduler's count of the /// time the thread actually ran, so the time it waits while other work on a busy machine has the @@ -761,7 +761,7 @@ fn cpu_cost(f: &mut impl FnMut()) -> f64 { /// cheapest of several runs after a warm-up, the runs alternating so a busy spell slows both sizes /// alike; `small` is timed `scale` times over per run, so both timings are long against the clock's /// resolution. -fn cost_ratio(scale: usize, mut small: impl FnMut(), mut big: impl FnMut()) -> (f64, f64, f64) { +pub(crate) fn cost_ratio(scale: usize, mut small: impl FnMut(), mut big: impl FnMut()) -> (f64, f64, f64) { small(); big(); let mut small_scaled = || (0..scale).for_each(|_| small()); @@ -774,7 +774,7 @@ fn cost_ratio(scale: usize, mut small: impl FnMut(), mut big: impl FnMut()) -> ( } /// [`cost_ratio`] of laying out `doc(SCALE * n)` against `doc(n)`. -fn layout_time_ratio(doc: impl Fn(usize) -> Document, n: usize, opts: &LayoutOptions) -> (f64, f64, f64) { +pub(crate) fn layout_time_ratio(doc: impl Fn(usize) -> Document, n: usize, opts: &LayoutOptions) -> (f64, f64, f64) { let (small, big) = (doc(n), doc(SCALE * n)); cost_ratio( SCALE, diff --git a/crates/layout/src/tests_linear.rs b/crates/layout/src/tests_linear.rs new file mode 100644 index 0000000000..1e6ca61a9d --- /dev/null +++ b/crates/layout/src/tests_linear.rs @@ -0,0 +1,45 @@ +//! Lookups that once rescanned the page, the paragraph or the document for every item they +//! looked up (#3692, #3706): an input 8x the size must cost about 8x as much, not 64x. + +use super::tests::{SCALE, cost_ratio, layout_time_ratio}; +use super::*; +use wordcraft_doc::para::Run; +use wordcraft_doc::{Pos, Table}; + +fn lay(d: &Document, opts: &LayoutOptions) -> DocLayout { + layout(d, &mut LayoutCache::new(), opts) +} + +fn assert_linear(what: &str, (t1, tk, ratio): (f64, f64, f64)) { + eprintln!("{what}: t(n) {t1:.4} s, t(8n) {tk:.4} s, ratio {ratio:.1}"); + assert!(ratio < 24.0, "{what}: 8x the input took {ratio:.1}x the time ({t1:.4} s → {tk:.4} s): not linear"); +} + +/// [`cost_ratio`] of `f(big)` against `f(small)`, each repeated `reps` times so the timings are +/// long against the clock's tick. +fn ratio_of(reps: usize, small: &T, big: &T, f: impl Fn(&T)) -> (f64, f64, f64) { + cost_ratio(SCALE, || (0..reps).for_each(|_| f(small)), || (0..reps).for_each(|_| f(big))) +} + +/// The first laid-out body paragraph on page 0. +fn first_para(l: &DocLayout) -> Option<&ParaLayout> { + l.pages.first()?.items.iter().find_map(|it| match it { + Placed::Lines { para, story: StoryRef::Body, .. } => Some(para.as_ref()), + _ => None, + }) +} + +/// Finding 29: proofing text found each character's run from the paragraph's first run. +#[test] +fn proof_text_walks_the_runs_once() { + // One run per character, every other one deleted. + let para = |n: usize| { + let mut p = Paragraph::with_text(&"a".repeat(n), CharProps::default()); + p.runs = (0..n).map(|i| Run { len: 1, props: CharProps { del: (i % 2 == 1).then_some(0), ..Default::default() } }).collect(); + p + }; + assert_eq!(para::proof_text(¶(5)), "a\u{1}a\u{1}a"); + let (small, big) = (para(50_000), para(SCALE * 50_000)); + assert_linear("proof_text", ratio_of(8, &small, &big, |p| drop(std::hint::black_box(para::proof_text(p))))); +} + From 7d31c01f6f549192bc154b6ce38b7c2b8413c63d Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Thu, 22 Oct 2026 15:27:00 +0100 Subject: [PATCH 089/199] #3692: bidi clicks and caret steps walk a line's carets once Finding 5: off_at_x and visual_step on a bidirectional line called x_of for every caret offset, each searching the clusters from the line's start (quadratic in a line of many zero-width offsets, e.g. hidden text). ParaLayout::line_carets walks them with a cursor; the first matching cluster never moves back for non-decreasing offsets, so the x's are the same. Tests: bidi_clicks_and_caret_steps_take_linear_time, line_carets_match_x_of. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/layout/src/hit.rs | 4 +-- crates/layout/src/para.rs | 43 ++++++++++++++++++++------- crates/layout/src/tests_linear.rs | 49 +++++++++++++++++++++++++++++++ 3 files changed, 84 insertions(+), 12 deletions(-) diff --git a/crates/layout/src/hit.rs b/crates/layout/src/hit.rs index 8a4840479c..63c7b41c79 100644 --- a/crates/layout/src/hit.rs +++ b/crates/layout/src/hit.rs @@ -722,8 +722,8 @@ impl DocLayout { let l = para.lines.get(li)?; let here = para.x_of(li, pos.off)?; let mut best: Option<(usize, f32)> = None; - for off in para.line_offsets(li) { - let Some(x) = para.x_of(li, off) else { continue }; + for (off, x) in para.line_carets(li) { + let Some(x) = x else { continue }; let ahead = if left { x < here - 0.01 } else { x > here + 0.01 }; let d = (x - here).abs(); if ahead && best.is_none_or(|b| d < b.1) { diff --git a/crates/layout/src/para.rs b/crates/layout/src/para.rs index 3dff3a8bec..7218079977 100644 --- a/crates/layout/src/para.rs +++ b/crates/layout/src/para.rs @@ -3107,20 +3107,42 @@ impl ParaLayout { let k = self.clusters.partition_point(|c| c.start < off); self.clusters.get(..k).into_iter().flatten().rev().find(|c| self.is_edit_boundary(c.start)).map_or(0, |c| c.start) }; + let mut from = l.c0; + self.x_from(l, off, &mut from) + } + /// [`Self::x_of`] on line `l`, looking for the cluster at `off` from cluster `*from` on (the + /// clusters before it must all end at or before `off`: so they do when `from` was left there + /// by an offset no greater), and leaving `from` at the cluster found. Walks a line's carets + /// in order in one pass over its clusters. + fn x_from(&self, l: &Line, off: usize, from: &mut usize) -> Option { if !l.vis.is_empty() { - return self.x_of_bidi(l, off); + return self.x_of_bidi(l, off, from); } - for k in l.c0..l.c1 { + for k in *from..l.c1 { let c = self.clusters.get(k)?; - if off <= c.start { - return l.xs.get(k - l.c0).copied(); - } - if off < c.end { + if off <= c.start || off < c.end { + *from = k; return l.xs.get(k - l.c0).copied(); } } + *from = l.c1; l.xs.last().copied() } + /// Each caret offset on line `li` ([`Self::line_offsets`]) with its x ([`Self::x_of`]). + pub fn line_carets(&self, li: usize) -> Vec<(usize, Option)> { + let Some(l) = self.lines.get(li) else { return Vec::new() }; + let (mut from, mut last) = (l.c0, 0); + self.line_offsets(li) + .into_iter() + .map(|off| { + if off < last { + from = l.c0; + } + last = off; + (off, self.x_from(l, off, &mut from)) + }) + .collect() + } /// The farthest right a caret goes on left-to-right line `li`: spaces at the end of a line /// hang past its right edge, but the caret after them stays at the edge, as in Word (#2146); /// text that overflows the line still carries it. `None` on bidirectional lines. @@ -3155,8 +3177,9 @@ impl ParaLayout { /// edge of right-to-left text), except where `off` leaves an embedded run (the cluster before /// it has the higher level), which keeps the caret at that run's trailing edge, so each /// visual boundary of the common mixed cases has its own offset. - fn x_of_bidi(&self, l: &Line, off: usize) -> Option { - let k = (l.c0..l.c1).find(|&k| self.clusters.get(k).is_some_and(|c| off < c.end || off <= c.start)); + fn x_of_bidi(&self, l: &Line, off: usize, from: &mut usize) -> Option { + let k = (*from..l.c1).find(|&k| self.clusters.get(k).is_some_and(|c| off < c.end || off <= c.start)); + *from = k.unwrap_or(l.c1); let Some(k) = k else { return Some(l.end_x()) }; let c = self.clusters.get(k)?; let here = l.vis.get(k - l.c0)?; @@ -3204,8 +3227,8 @@ impl ParaLayout { let Some(l) = self.lines.get(li) else { return self.text_len }; if !l.vis.is_empty() { let mut best = (l.start, f32::MAX); - for off in self.line_offsets(li) { - let d = self.x_of(li, off).map_or(f32::MAX, |cx| (cx - x).abs()); + for (off, cx) in self.line_carets(li) { + let d = cx.map_or(f32::MAX, |cx| (cx - x).abs()); if d < best.1 { best = (off, d); } diff --git a/crates/layout/src/tests_linear.rs b/crates/layout/src/tests_linear.rs index 1e6ca61a9d..fa26b2200d 100644 --- a/crates/layout/src/tests_linear.rs +++ b/crates/layout/src/tests_linear.rs @@ -43,3 +43,52 @@ fn proof_text_walks_the_runs_once() { assert_linear("proof_text", ratio_of(8, &small, &big, |p| drop(std::hint::black_box(para::proof_text(p))))); } +/// A right-to-left paragraph whose one line holds `n` hidden letters: caret offsets with no width. +fn bidi_hidden(n: usize) -> Document { + let head = "سلام "; + let mut d = Document::from_text(&format!("{head}{} world", "x".repeat(n))); + let at = Pos::body(0, 0); + d.format_paragraphs(&at, &at, &|p| p.bidi = Some(true)).unwrap(); + d.format_range(&Pos::body(0, head.len()), &Pos::body(0, head.len() + n), &|c| c.hidden = Some(true)).unwrap(); + d +} + +/// Finding 5: a click on a bidirectional line and a visual caret step measured every caret +/// offset from the line's first cluster. +#[test] +fn bidi_clicks_and_caret_steps_take_linear_time() { + let opts = LayoutOptions { show_hidden: false, ..Default::default() }; + let (ds, db) = (bidi_hidden(5000), bidi_hidden(SCALE * 5000)); + let (ls, lb) = (lay(&ds, &opts), lay(&db, &opts)); + let (Some(ps), Some(pb)) = (first_para(&ls), first_para(&lb)) else { panic!("no paragraph") }; + assert_eq!(pb.lines.len(), 1, "the hidden letters stay on one line"); + let ratio = ratio_of(32, &(ps, &ls), &(pb, &lb), |(p, l)| { + std::hint::black_box(p.off_at_x(0, 1.0)); + std::hint::black_box(l.visual_step(&Pos::body(0, 0), true, 0)); + }); + assert_linear("bidi hit test", ratio); +} + +/// The carets of a line walked in one pass are where `x_of` puts each of them, on bidirectional +/// and left-to-right lines alike. +#[test] +fn line_carets_match_x_of() { + let docs = [bidi_hidden(20), Document::from_text("Plain left-to-right text, long enough to wrap. ".repeat(6).as_str()), { + let mut d = Document::from_text("سلام world 123 دنیا (مرحبا) end"); + let at = Pos::body(0, 0); + d.format_paragraphs(&at, &at, &|p| p.bidi = Some(true)).unwrap(); + d + }]; + for d in &docs { + for show_hidden in [false, true] { + let l = lay(d, &LayoutOptions { show_hidden, ..Default::default() }); + let Some(p) = first_para(&l) else { panic!("no paragraph") }; + for li in 0..p.lines.len() { + let want: Vec<_> = p.line_offsets(li).into_iter().map(|off| (off, p.x_of(li, off))).collect(); + assert!(!want.is_empty()); + assert_eq!(p.line_carets(li), want, "line {li}"); + } + } + } +} + From 4d027de4022aecb9bc79abd357b9fbcb71ac3595 Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Thu, 22 Oct 2026 15:27:00 +0100 Subject: [PATCH 090/199] #3692: math text runs find character boundaries in one walk Finding 23: Ctx::glyphs found each character's first glyph by scanning the shaped glyphs from the start (quadratic in a long run). The first glyph at or past a boundary never moves back as the boundary grows, so one walk gives the same cuts. Test: math::tests::a_long_text_run_lays_out_in_linear_time. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/layout/src/math.rs | 32 +++++++++++++++++++++++--------- 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/crates/layout/src/math.rs b/crates/layout/src/math.rs index 7291e89dd6..a581635bc4 100644 --- a/crates/layout/src/math.rs +++ b/crates/layout/src/math.rs @@ -631,19 +631,18 @@ impl Ctx { last_gid = Some(g.gid); } b.w = x; - // Character boundaries: where the first glyph of each character starts. + // Character boundaries: where the first glyph of each character starts. The glyph for a + // boundary is never before the previous boundary's, so one walk of the glyphs finds all. let mut cuts = Vec::with_capacity(text.len() + 1); + let (mut j, mut gx) = (0usize, 0.0f32); for (cb, _) in text.char_indices() { - let mut gx = 0.0f32; - let mut found = None; - for g in &shaped { - if g.cluster >= cb { - found = Some(gx); - break; - } + while let Some(g) = shaped.get(j) + && g.cluster < cb + { gx += g.x_advance as f32 * k; + j += 1; } - cuts.push(found.unwrap_or(x)); + cuts.push(if j < shaped.len() { gx } else { x }); } cuts.push(x); b.cuts = cuts; @@ -2374,4 +2373,19 @@ mod tests { } } } + + /// Finding 23: each character's boundary in a run of text was found by walking the run's + /// glyphs from the first. + #[test] + fn a_long_text_run_lays_out_in_linear_time() { + use crate::tests::{SCALE, cost_ratio}; + use wordcraft_doc::math::*; + let eq = |n: usize| Math { nodes: vec![MNode::Run(MRun { nor: true, ..MRun::new("a".repeat(n)) })], ..Default::default() }; + let (small, big) = (eq(400), eq(SCALE * 400)); + // Each timed 16 times over, so the timings are long against the clock's tick. + let lay_16 = |m: &Math| (0..16).for_each(|_| drop(std::hint::black_box(lay_in(m, "", false, 1e6, &MathProps::default())))); + let (t1, tk, ratio) = cost_ratio(SCALE, || lay_16(&small), || lay_16(&big)); + eprintln!("t(n) {t1:.4} s, t(8n) {tk:.4} s, ratio {ratio:.1}"); + assert!(ratio < 24.0, "8x the text took {ratio:.1}x the time ({t1:.4} s → {tk:.4} s): not linear"); + } } From 629711582fd550babfd39b9520ed66ca134c1f49 Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Thu, 22 Oct 2026 15:27:01 +0100 Subject: [PATCH 091/199] #3692: autofit counts the grid's columns once Finding 3: column_content called Table::cols (a walk of every row) for each spanning cell, and there can be one in every row. Hoisted out of the loop; the end is also added with saturating_add. Test: autofit_tables_with_a_spanning_cell_in_every_row_lay_out_in_linear_time. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/layout/src/table.rs | 4 +++- crates/layout/src/tests_linear.rs | 18 ++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/crates/layout/src/table.rs b/crates/layout/src/table.rs index d31967af22..1a1f5e5b91 100644 --- a/crates/layout/src/table.rs +++ b/crates/layout/src/table.rs @@ -1144,8 +1144,10 @@ fn column_content(ctx: &mut Ctx, t: &Table, style: Option<&TableStyleProps>, dep } spanning.extend(row_gaps(row, g).map(|(g, span, w)| (g, span, (w, w)))); } + // The grid's width once: counting it walks every row, and there can be a spanning cell in each. + let ncols = t.cols(); for (g, span, need) in spanning { - let Some(cs) = cols.get_mut(g..(g + span).min(t.cols())) else { continue }; + let Some(cs) = cols.get_mut(g..g.saturating_add(span).min(ncols)) else { continue }; let n = cs.len().max(1) as f32; let (have_lo, have_hi) = cs.iter().fold((0.0, 0.0), |(l, h), c| (l + c.0, h + c.1)); for c in cs { diff --git a/crates/layout/src/tests_linear.rs b/crates/layout/src/tests_linear.rs index fa26b2200d..58542956ec 100644 --- a/crates/layout/src/tests_linear.rs +++ b/crates/layout/src/tests_linear.rs @@ -92,3 +92,21 @@ fn line_carets_match_x_of() { } } +/// Finding 3: autofitting a table counted its grid's columns again for every spanning cell. +#[test] +fn autofit_tables_with_a_spanning_cell_in_every_row_lay_out_in_linear_time() { + let doc = |n: usize| { + let mut t = Table::new(n, 3, 468.0); + for row in &mut t.rows { + row.cells.truncate(2); + if let Some(c) = row.cells.first_mut() { + c.props.span = 2; + } + } + let mut d = Document::from_text("x"); + d.insert_block(StoryRef::Body, &Path::top(0), Block::Table(t)).unwrap(); + d + }; + assert_linear("spanning cells", layout_time_ratio(doc, 400, &LayoutOptions::default())); +} + From 897add99a89af2e2041d594a11927d7f5bfa22da Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Thu, 22 Oct 2026 15:27:01 +0100 Subject: [PATCH 092/199] #3692: headers and footers linked through sections once per layout Finding 13: linked() and defining_width() walked back through the sections before each section, per section and again per page (layout_pass, headers_footers, footer_table_wraps, header_table_areas). HfLinks resolves every section's linked header and footer sets incrementally once (each inherits the previous one's for the parts it leaves out) and keeps, per part id, the sections naming it, so the defining section is a binary search. Same sets and widths as before. Test: headers_inherited_through_many_sections_lay_out_in_linear_time. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/layout/src/lib.rs | 147 +++++++++++++++++++----------- crates/layout/src/tests_linear.rs | 23 +++++ 2 files changed, 118 insertions(+), 52 deletions(-) diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index 464a7178c4..e5a9d3ef2f 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -743,6 +743,8 @@ struct Ctx<'a> { /// Each body float's latest placement: on which page and with what wrap area, when it is /// above its anchor paragraph with text before it ([`EarlyFloats`]). floats_above: HashMap<(usize, usize), Option<(usize, WrapArea)>>, + /// The document's sections with their headers and footers linked ([`HfLinks`]). + hf: Arc>, } /// A header or footer (`story`) laid out for page `number` of a section (`sect`, `mirror`): its @@ -2340,8 +2342,10 @@ fn layout_pass( footer_wraps: HashMap::new(), pin: None, floats_above: HashMap::new(), + hf: Arc::new(HfLinks::new(doc.sections())), }; - let sections = doc.sections(); + let hf = Arc::clone(&ctx.hf); + let sections = &hf.sections; let web = matches!(opts.view, ViewMode::Web | ViewMode::Draft | ViewMode::Outline); let read = opts.view == ViewMode::Read; let reflow = web || read; @@ -2471,9 +2475,9 @@ fn layout_pass( ctx.fields.section = u32::try_from(si).unwrap_or(u32::MAX).saturating_add(1); ctx.fields.section_pages = totals.section(si); // Headers and footers left out are linked to the previous section's, and take its room. - let (heads, feet) = (linked(§ions, si, |s| s.headers), linked(§ions, si, |s| s.footers)); - let hb = |i: Option| defining_width(§ions, si, |s| s.headers, i); - let fb = |i: Option| defining_width(§ions, si, |s| s.footers, i); + let (heads, feet) = (hf.heads.set(si), hf.feet.set(si)); + let hb = |i: Option| hf.defining_width(false, si, i); + let fb = |i: Option| hf.defining_width(true, si, i); let body_top = if reflow || turn.is_turned() { sect.margin_top } else { @@ -2513,7 +2517,7 @@ fn layout_pass( { pb.sect = prev; pb.sect_idx = si - 1; - let (ph, pf) = (linked(§ions, si - 1, |s| s.headers), linked(§ions, si - 1, |s| s.footers)); + let (ph, pf) = (hf.heads.set(si - 1), hf.feet.set(si - 1)); let top = body_top_for(&mut ctx, prev, ph.default); pb.foot_top = foot_top_for(&mut ctx, prev, pf.default); pb.new_page(block, top); @@ -2627,7 +2631,7 @@ fn layout_pass( } } if !reflow { - let found = Totals::of(&pages, §ions); + let found = Totals::of(&pages, sections); if ctx.uses_totals && may_repeat && found != *totals { return Pass::Again(found); } @@ -2635,13 +2639,13 @@ fn layout_pass( // Page references in headers and footers are filled in after pagination, like their page // totals: they don't make the body lay out again. if headers_footers_refer_to_pages(doc) { - let marks = bookmark_pages(doc, &pages, §ions); + let marks = bookmark_pages(doc, &pages, sections); ctx.fields.bookmarks_key = marks_key(&marks); ctx.fields.bookmark_pages = Arc::new(marks); } - headers_footers(&mut ctx, &mut pages, §ions, &found); + headers_footers(&mut ctx, &mut pages, sections, &found); if doc.settings.mirror_margins { - mirror_even_pages(&mut pages, §ions); + mirror_even_pages(&mut pages, sections); } } // Evict paragraph layouts that weren't used this pass if the cache grew large. @@ -2924,23 +2928,75 @@ fn section_side(start: SectionStart, restart: Option, last: u32, pages: usi } } -/// The text width of the section that defines header or footer `id`, when section `si` inherits -/// it rather than naming it (`None` when it names it). Measured in Word: an inherited part's -/// percentage-width tables are sized by that section, though it's laid out on `si`'s pages. -fn defining_width(sections: &[(usize, &SectionProps)], si: usize, get: fn(&SectionProps) -> HeaderSet, id: Option) -> Option { - let id = id?; - let names = |s: &SectionProps| { - let h = get(s); - [h.default, h.first, h.even].contains(&Some(id)) - }; - let own = sections.get(si)?.1; - if names(own) { - return None; - } - sections.iter().take(si).rev().find(|(_, s)| names(s)).map(|(_, s)| s.text_width()).filter(|w| (w - own.text_width()).abs() > 0.01) +/// The document's sections with their headers and footers (Word's "link to previous"), each +/// section's resolved once for the whole layout in document order rather than by looking back +/// over the sections before it whenever one is needed. +struct HfLinks<'a> { + sections: Vec<(usize, &'a SectionProps)>, + heads: Linked, + feet: Linked, } -/// Lay out with percentage-width tables sized by `base` (see [`defining_width`]). +impl<'a> HfLinks<'a> { + fn new(sections: Vec<(usize, &'a SectionProps)>) -> Self { + let heads = Linked::new(§ions, |s| s.headers); + let feet = Linked::new(§ions, |s| s.footers); + HfLinks { sections, heads, feet } + } + + /// The text width of the section that defines header (or, `footer`, footer) `id`, when + /// section `si` inherits it rather than naming it (`None` when it names it). Measured in + /// Word: an inherited part's percentage-width tables are sized by that section, though it's + /// laid out on `si`'s pages. + fn defining_width(&self, footer: bool, si: usize, id: Option) -> Option { + let id = id?; + let linked = if footer { &self.feet } else { &self.heads }; + let own = self.sections.get(si)?.1; + if linked.own.get(si).is_some_and(|h| [h.default, h.first, h.even].contains(&Some(id))) { + return None; + } + // The last section before `si` naming it. + let namers = linked.namers.get(&id)?; + let k = *namers.get(namers.partition_point(|&k| k < si).checked_sub(1)?)?; + self.sections.get(k).map(|(_, s)| s.text_width()).filter(|w| (w - own.text_width()).abs() > 0.01) + } +} + +/// The headers (or footers) of every section: those it names, and with those it leaves out +/// inherited from the sections before it. +struct Linked { + own: Vec, + linked: Vec, + /// The sections naming each part, in order. + namers: HashMap>, +} + +impl Linked { + fn new(sections: &[(usize, &SectionProps)], get: fn(&SectionProps) -> HeaderSet) -> Self { + let own: Vec = sections.iter().map(|(_, s)| get(s)).collect(); + let mut linked = Vec::with_capacity(own.len()); + let mut namers: HashMap> = HashMap::new(); + let mut prev = HeaderSet::default(); + for (si, o) in own.iter().enumerate() { + prev = HeaderSet { default: o.default.or(prev.default), first: o.first.or(prev.first), even: o.even.or(prev.even) }; + linked.push(prev); + for id in [o.default, o.first, o.even].into_iter().flatten() { + let v = namers.entry(id).or_default(); + if v.last() != Some(&si) { + v.push(si); + } + } + } + Linked { own, linked, namers } + } + + /// Section `si`'s headers (or footers), those it leaves out inherited. + fn set(&self, si: usize) -> HeaderSet { + self.linked.get(si).copied().unwrap_or_default() + } +} + +/// Lay out with percentage-width tables sized by `base` (see [`HfLinks::defining_width`]). fn with_pct_base(ctx: &mut Ctx, base: Option, f: impl FnOnce(&mut Ctx) -> T) -> T { let before = std::mem::replace(&mut ctx.pct_base, base); let out = f(ctx); @@ -3400,11 +3456,12 @@ fn footer_table_wraps(ctx: &mut Ctx, pb: &PageBuilder) -> Vec { } ctx.footer_wraps.insert(key, Vec::new()); let doc = ctx.doc; - let Some((_, sect)) = ctx.sections.get(page.section).copied() else { return Vec::new() }; + let hf = Arc::clone(&ctx.hf); + let Some((_, sect)) = hf.sections.get(page.section) else { return Vec::new() }; if sect.text_direction.is_turned() { return Vec::new(); } - let set = linked(&ctx.sections, page.section, |s| s.footers); + let set = hf.feet.set(page.section); let id = if sect.title_page && first { set.first } else if doc.settings.even_odd_headers && page.number.is_multiple_of(2) { @@ -3416,7 +3473,7 @@ fn footer_table_wraps(ctx: &mut Ctx, pb: &PageBuilder) -> Vec { let Some(part) = doc.parts.get(&id) else { return Vec::new() }; let blocks = part.blocks.clone(); let x = sect.margin_left + sect.gutter; - let base = defining_width(&ctx.sections, page.section, |s| s.footers, Some(id)); + let base = hf.defining_width(true, page.section, Some(id)); let counters = std::mem::take(&mut ctx.counters); let boxes = std::mem::take(&mut ctx.boxes); let chains = std::mem::take(&mut ctx.chains); @@ -4534,11 +4591,12 @@ fn header_table_areas(ctx: &mut Ctx, pb: &PageBuilder) -> Vec { if let Some(areas) = ctx.header_tables.get(&key) { return areas.clone(); } - let Some((_, sect)) = ctx.sections.get(page.section).copied() else { return Vec::new() }; + let hf = Arc::clone(&ctx.hf); + let Some((_, sect)) = hf.sections.get(page.section) else { return Vec::new() }; if sect.text_direction.is_turned() { return Vec::new(); } - let set = linked(&ctx.sections, page.section, |s| s.headers); + let set = hf.heads.set(page.section); let id = if sect.title_page && first { set.first } else if ctx.doc.settings.even_odd_headers && page.number.is_multiple_of(2) { @@ -4551,7 +4609,7 @@ fn header_table_areas(ctx: &mut Ctx, pb: &PageBuilder) -> Vec { let blocks = part.blocks.clone(); let x = sect.margin_left + sect.gutter; let frame = PageFrame { sect, origin: (x, sect.header), number: page.number, mirror: ctx.mirror }; - let base = defining_width(&ctx.sections, page.section, |s| s.headers, Some(id)); + let base = hf.defining_width(false, page.section, Some(id)); let counters = std::mem::take(&mut ctx.counters); let boxes = std::mem::take(&mut ctx.boxes); let chains = std::mem::take(&mut ctx.chains); @@ -4704,22 +4762,6 @@ fn below(excl: &[WrapArea], mut y: f32, x0: f32, x1: f32) -> f32 { y } -/// Section `si`'s headers (or footers, as `get` picks), with those it leaves out inherited from -/// the sections before it (Word's "link to previous"). -fn linked(sections: &[(usize, &SectionProps)], si: usize, get: fn(&SectionProps) -> HeaderSet) -> HeaderSet { - let mut set = sections.get(si).map(|(_, s)| get(s)).unwrap_or_default(); - for (_, s) in sections.iter().take(si).rev() { - if set.default.is_some() && set.first.is_some() && set.even.is_some() { - break; - } - let o = get(s); - set.default = set.default.or(o.default); - set.first = set.first.or(o.first); - set.even = set.even.or(o.even); - } - set -} - /// Whether a header, a footer or a text box shows a `PAGEREF` field, which they resolve from the /// paginated body (see [`bookmark_pages`]). fn headers_footers_refer_to_pages(doc: &Document) -> bool { @@ -4798,6 +4840,7 @@ fn marks_key(marks: &fields::BookmarkPages) -> u64 { fn headers_footers(ctx: &mut Ctx, pages: &mut [Page], sections: &[(usize, &SectionProps)], totals: &Totals) { let total = pages.len() as u32; + let hf = Arc::clone(&ctx.hf); let even_odd = ctx.doc.settings.even_odd_headers; let styles = styleref::headers_use_styleref(ctx.doc).then(|| ctx.cache.style_index(ctx.doc)); let mut first_of_section: HashSet = HashSet::new(); @@ -4810,8 +4853,8 @@ fn headers_footers(ctx: &mut Ctx, pages: &mut [Page], sections: &[(usize, &Secti for (i, page) in pages.iter_mut().enumerate() { let Some((_, sect)) = sections.get(page.section) else { continue }; // Inherit header/footer references from earlier sections (Word's "link to previous"). - let pick = |get: fn(&SectionProps) -> HeaderSet| -> Option { - let set = linked(sections, page.section, get); + let pick = |linked: &Linked| -> Option { + let set = linked.set(page.section); if sect.title_page && first_of_section.contains(&i) { return set.first; } @@ -4820,8 +4863,8 @@ fn headers_footers(ctx: &mut Ctx, pages: &mut [Page], sections: &[(usize, &Secti } set.default }; - let hid = pick(|s| s.headers); - let fid = pick(|s| s.footers); + let hid = pick(&hf.heads); + let fid = pick(&hf.feet); page.hf_slot = if sect.title_page && first_of_section.contains(&i) { HfSlot::First } else if even_odd && page.number % 2 == 0 { @@ -4849,7 +4892,7 @@ fn headers_footers(ctx: &mut Ctx, pages: &mut [Page], sections: &[(usize, &Secti { let blocks = part.blocks.clone(); let frame = PageFrame { sect, origin: (x, sect.header), number: page.number, mirror: ctx.mirror }; - let base = defining_width(sections, page.section, |s| s.headers, hid); + let base = hf.defining_width(false, page.section, hid); let (mut items, behind, front) = with_pin(ctx, id, sect, page.number, |ctx| { with_pct_base(ctx, base, |c| layout_box(c, StoryRef::Part(id), &blocks, &[], w, BoxContext::Other, 0, Some(frame)).items) }); @@ -4863,7 +4906,7 @@ fn headers_footers(ctx: &mut Ctx, pages: &mut [Page], sections: &[(usize, &Secti && let Some(part) = ctx.doc.parts.get(&id) { let blocks = part.blocks.clone(); - let base = defining_width(sections, page.section, |s| s.footers, fid); + let base = hf.defining_width(true, page.section, fid); // The footer's height fixes where it sits, which page-relative floats need. let (h, ..) = with_pin(ctx, id, sect, page.number, |ctx| { with_pct_base(ctx, base, |c| layout_box(c, StoryRef::Part(id), &blocks, &[], w, BoxContext::Other, 0, None).height) diff --git a/crates/layout/src/tests_linear.rs b/crates/layout/src/tests_linear.rs index 58542956ec..46045c6aeb 100644 --- a/crates/layout/src/tests_linear.rs +++ b/crates/layout/src/tests_linear.rs @@ -110,3 +110,26 @@ fn autofit_tables_with_a_spanning_cell_in_every_row_lay_out_in_linear_time() { assert_linear("spanning cells", layout_time_ratio(doc, 400, &LayoutOptions::default())); } +/// Finding 13: each section's headers and footers were found by walking back through the +/// sections before it, for every section and again for every page. +#[test] +fn headers_inherited_through_many_sections_lay_out_in_linear_time() { + let doc = |n: usize| { + let mut d = Document::from_text(&vec!["x"; n].join("\n")); + let head = d.add_part(wordcraft_doc::PartKind::Header, vec![wordcraft_doc::para_block(Paragraph::with_text("Head", CharProps::default()))]); + for i in 0..n.saturating_sub(1) { + let mut s = SectionProps { start: SectionStart::Continuous, ..Default::default() }; + if i == 0 { + s.headers.default = Some(head); + } + d.para_mut(StoryRef::Body, &Path::top(i)).unwrap().section = Some(Box::new(s)); + } + d.last_section.start = SectionStart::Continuous; + d + }; + // Every page shows the first section's header. + let l = lay(&doc(200), &LayoutOptions::default()); + assert!(l.pages.iter().all(|p| !p.header.is_empty()), "the header is inherited"); + assert_linear("sections", layout_time_ratio(doc, 2000, &LayoutOptions::default())); +} + From 5a07c46534326f4cea9c75d33c0be11bffae6d17 Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Thu, 22 Oct 2026 15:27:01 +0100 Subject: [PATCH 093/199] #3692: selections across a header index each page's pieces once Finding 28: column_segments and selection_rects called pieces() per paragraph, and for a header, footer or note each call scanned the whole page (pieces_on). PartPages indexes a page's lines of the story by paragraph the first time the page is looked at; the page order (hint first, continued notes) is unchanged. Test: selecting_a_long_header_takes_linear_time. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/layout/src/hit.rs | 62 ++++++++++++++++++++++++++++--- crates/layout/src/tests_linear.rs | 22 +++++++++++ 2 files changed, 79 insertions(+), 5 deletions(-) diff --git a/crates/layout/src/hit.rs b/crates/layout/src/hit.rs index 63c7b41c79..188b89f060 100644 --- a/crates/layout/src/hit.rs +++ b/crates/layout/src/hit.rs @@ -1,5 +1,7 @@ //! Hit testing and caret geometry. +use std::collections::HashMap; + use wordcraft_doc::para::Wrap; use wordcraft_doc::props::TextDirection; use wordcraft_doc::{Document, Path, Pos, StoryRef}; @@ -140,6 +142,38 @@ fn text_at(p: &Page, index: usize, x: f32, y: f32) -> Option { }) } +/// The pieces of story `story`'s paragraphs on each page, by paragraph, for the pages looked at +/// so far ([`DocLayout::pieces_in`]). +struct PartPages<'a> { + story: StoryRef, + pages: HashMap>>, +} + +impl<'a> PartPages<'a> { + fn new(story: StoryRef) -> Self { + PartPages { story, pages: HashMap::new() } + } + + /// The pieces of the paragraph at `path` on page `pi` of `lay`, as [`DocLayout::pieces_on`] + /// gives them. + fn on(&mut self, lay: &'a DocLayout, pi: usize, path: &Path) -> Vec<(usize, &'a Placed)> { + let Some(p) = lay.pages.get(pi) else { return Vec::new() }; + let story = self.story; + let index = self.pages.entry(pi).or_insert_with(|| { + let mut index: HashMap<&'a Path, Vec<&'a Placed>> = HashMap::new(); + for it in items_of(p, story) { + if let Placed::Lines { story: s, path, .. } = it + && *s == story + { + index.entry(path).or_default().push(it); + } + } + index + }); + index.get(path).map(|v| v.iter().map(|it| (pi, *it)).collect()).unwrap_or_default() + } +} + fn items_of(page: &Page, story: StoryRef) -> Box + '_> { match story { StoryRef::Body => Box::new(page.items.iter()), @@ -417,6 +451,11 @@ impl DocLayout { .map(|v| v.iter().filter_map(|(pi, ii)| self.pages.get(*pi).and_then(|p| p.items.get(*ii)).map(|it| (*pi, it))).collect()) .unwrap_or_default(); } + self.part_pieces(page_hint, &mut |pi| self.pieces_on(pi, story, path)) + } + + /// Pieces of a paragraph of a story part (see [`Self::pieces`]), `on` giving those on a page. + fn part_pieces<'a>(&'a self, page_hint: usize, on: &mut dyn FnMut(usize) -> Vec<(usize, &'a Placed)>) -> Vec<(usize, &'a Placed)> { // Headers/footers repeat: prefer the hinted page, else the first that shows the part. let mut order: Vec = (0..self.pages.len()).collect(); if page_hint < order.len() { @@ -424,7 +463,7 @@ impl DocLayout { order.insert(0, page_hint); } for pi in order { - let mut found = self.pieces_on(pi, story, path); + let mut found = on(pi); if found.is_empty() { continue; } @@ -439,7 +478,7 @@ impl DocLayout { let mut k = pi; while lo > 0 && k > 0 { k -= 1; - let more = self.pieces_on(k, story, path); + let more = on(k); if more.is_empty() { break; } @@ -449,7 +488,7 @@ impl DocLayout { let mut k = pi; while hi < n && k + 1 < self.pages.len() { k += 1; - let more = self.pieces_on(k, story, path); + let more = on(k); if more.is_empty() { break; } @@ -461,6 +500,17 @@ impl DocLayout { Vec::new() } + /// [`Self::pieces`] for each of many paragraphs of `pages.story`: a part's pieces on a page + /// are looked up in that page's index, made the first time it is looked at, so a selection + /// over many header, footer or text box paragraphs walks each page's items once rather than + /// once for every paragraph. + fn pieces_in<'a>(&'a self, pages: &mut PartPages<'a>, path: &Path, page_hint: usize) -> Vec<(usize, &'a Placed)> { + if pages.story == StoryRef::Body { + return self.pieces(StoryRef::Body, path, page_hint); + } + self.part_pieces(page_hint, &mut |pi| pages.on(self, pi, path)) + } + /// Pieces of a paragraph of story part `story` on page `pi`. fn pieces_on(&self, pi: usize, story: StoryRef, path: &Path) -> Vec<(usize, &Placed)> { let Some(p) = self.pages.get(pi) else { return Vec::new() }; @@ -629,8 +679,9 @@ impl DocLayout { if a.story != b.story || !left.is_finite() || !right.is_finite() { return out; } + let mut pages = PartPages::new(a.story); for path in doc.paths_between(a, b) { - for (_, it) in self.pieces(a.story, &path, page_hint) { + for (_, it) in self.pieces_in(&mut pages, &path, page_hint) { let Placed::Lines { para, l0, l1, x, .. } = it else { continue }; let first = if path == a.path { para.line_of(a.off) } else { 0 }; let last = if path == b.path { para.line_of(b.off) } else { usize::MAX }; @@ -656,8 +707,9 @@ impl DocLayout { pub fn selection_rects(&self, doc: &Document, a: &Pos, b: &Pos, page_hint: usize) -> Vec<(usize, Rect)> { let (a, b) = if a <= b { (a, b) } else { (b, a) }; let mut out = Vec::new(); + let mut pages = PartPages::new(a.story); for path in doc.paths_between(a, b) { - for (pi, it) in self.pieces(a.story, &path, page_hint) { + for (pi, it) in self.pieces_in(&mut pages, &path, page_hint) { let Placed::Lines { para, l0, l1, x, y, turn, .. } = it else { continue }; let Some(first) = para.lines.get(*l0) else { continue }; // Rectangles in the lines' own frame, turned onto the page. diff --git a/crates/layout/src/tests_linear.rs b/crates/layout/src/tests_linear.rs index 46045c6aeb..9e54440e5b 100644 --- a/crates/layout/src/tests_linear.rs +++ b/crates/layout/src/tests_linear.rs @@ -133,3 +133,25 @@ fn headers_inherited_through_many_sections_lay_out_in_linear_time() { assert_linear("sections", layout_time_ratio(doc, 2000, &LayoutOptions::default())); } +/// A header of `n` short paragraphs. +fn tall_header(n: usize) -> (Document, u32) { + let mut d = Document::from_text("Body"); + let paras = (0..n).map(|i| wordcraft_doc::para_block(Paragraph::with_text(&format!("h{i}"), CharProps::default()))).collect(); + let id = d.add_part(wordcraft_doc::PartKind::Header, paras); + d.last_section.headers.default = Some(id); + (d, id) +} + +/// Finding 28: selecting across a header looked through the whole page for each paragraph. +#[test] +fn selecting_a_long_header_takes_linear_time() { + let n = 300; + let ((ds, ids), (db, idb)) = (tall_header(n), tall_header(SCALE * n)); + let (ls, lb) = (lay(&ds, &LayoutOptions::default()), lay(&db, &LayoutOptions::default())); + let ends = |id: u32, n: usize| (Pos::new(StoryRef::Part(id), Path::top(0), 0), Pos::new(StoryRef::Part(id), Path::top(n - 1), 2)); + let ((a, b), (c, e)) = (ends(ids, n), ends(idb, SCALE * n)); + assert!(lb.selection_rects(&db, &c, &e, 0).len() >= SCALE * n, "a rectangle per paragraph"); + let ratio = ratio_of(32, &(&ds, &ls, a, b), &(&db, &lb, c, e), |(d, l, a, b)| drop(std::hint::black_box(l.selection_rects(d, a, b, 0)))); + assert_linear("header selection", ratio); +} + From 342855315eef417a51eafc1edb4aca9a977d5dae Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Thu, 22 Oct 2026 15:27:11 +0100 Subject: [PATCH 094/199] #3692: clipped cells looked up by path, not scanned per line Finding 1: cell_clip searched every clipped cell on the page for each line drawn (quadratic in a page of many exact-height cells). clipped_cells now indexes them by story and content path, keeping each one's order; a line looks up only its path's prefixes and takes the earliest, the same cell the scan found first. Test: drawing_many_clipped_cells_takes_linear_time. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/layout/src/display.rs | 41 +++++++++++++++++++------------ crates/layout/src/tests_linear.rs | 29 ++++++++++++++++++++++ 2 files changed, 54 insertions(+), 16 deletions(-) diff --git a/crates/layout/src/display.rs b/crates/layout/src/display.rs index f7f4d510a6..cb41a75034 100644 --- a/crates/layout/src/display.rs +++ b/crates/layout/src/display.rs @@ -1,5 +1,7 @@ //! Page → draw items, shared by the raster renderer, the PDF exporter and thumbnails. +use std::collections::HashMap; + use wordcraft_doc::graphic::{Graphic, GraphicItem, GraphicKind, PathSeg, TextAlign}; use wordcraft_doc::para::{InlineObject, ShapeKind}; use wordcraft_doc::props::{Border, BorderStyle, Rgb, TextColor, TextDirection, Underline}; @@ -617,24 +619,31 @@ pub fn object_display(doc: &Document, page: &Page, o: &InlineObject, rect: Rect, } /// The cells among `items` whose content is cut off at their edge (see [`Placed::Cell`]'s -/// `clip`): their story, the path of their content (table, row, cell) and their area. -fn clipped_cells(items: &[Placed]) -> Vec<(StoryRef, Vec, Rect)> { - items - .iter() - .filter_map(|it| match it { - Placed::Cell { rect, table, row, cell, story, clip: true, .. } => { - let mut prefix = table.0.clone(); - prefix.extend([u32::try_from(*row).ok()?, u32::try_from(*cell).ok()?]); - Some((*story, prefix, *rect)) - } - _ => None, - }) - .collect() +/// `clip`), by story and the path of their content (table, row, cell): their area, and the +/// order they come in (the first of two that hold a line clips it). +type Clips = HashMap, (usize, Rect)>>; + +fn clipped_cells(items: &[Placed]) -> Clips { + let mut clips = Clips::new(); + let cells = items.iter().filter_map(|it| match it { + Placed::Cell { rect, table, row, cell, story, clip: true, .. } => { + let mut prefix = table.0.clone(); + prefix.extend([u32::try_from(*row).ok()?, u32::try_from(*cell).ok()?]); + Some((*story, prefix, *rect)) + } + _ => None, + }); + for (i, (story, prefix, rect)) in cells.enumerate() { + clips.entry(story).or_default().entry(prefix).or_insert((i, rect)); + } + clips } -/// The area to clip a line at `path` of `story` to: its cell's, when that cell is clipped. -fn cell_clip(clips: &[(StoryRef, Vec, Rect)], story: StoryRef, path: &[u32]) -> Option { - clips.iter().find(|(s, prefix, _)| *s == story && path.starts_with(prefix)).map(|(_, _, r)| *r) +/// The area to clip a line at `path` of `story` to: its cell's, when that cell is clipped. Only +/// the cells holding the line are looked up, not every clipped cell on the page. +fn cell_clip(clips: &Clips, story: StoryRef, path: &[u32]) -> Option { + let cells = clips.get(&story)?; + (0..=path.len()).filter_map(|n| cells.get(path.get(..n)?)).min_by_key(|(i, _)| *i).map(|(_, r)| *r) } /// The text boxes among `items` whose text is warped (Text Effects › Transform, drawn bent): their diff --git a/crates/layout/src/tests_linear.rs b/crates/layout/src/tests_linear.rs index 9e54440e5b..b3420a6cad 100644 --- a/crates/layout/src/tests_linear.rs +++ b/crates/layout/src/tests_linear.rs @@ -155,3 +155,32 @@ fn selecting_a_long_header_takes_linear_time() { assert_linear("header selection", ratio); } +/// A table of `n` rows of 60 cells, each row exactly half a point high, so every cell clips its +/// (empty) paragraph and the whole table fits on the first page. +fn clipped_table(n: usize) -> Document { + let mut t = Table::new(n, 60, 468.0); + for row in &mut t.rows { + row.props.height = Some(0.5); + row.props.height_rule = HeightRule::Exact; + } + let mut d = Document::from_text("x"); + d.insert_block(StoryRef::Body, &Path::top(0), Block::Table(t)).unwrap(); + d +} + +/// Finding 1: drawing a line in a clipped cell looked through every clipped cell on the page. +#[test] +fn drawing_many_clipped_cells_takes_linear_time() { + let (ds, db) = (clipped_table(16), clipped_table(SCALE * 16)); + let (ls, lb) = (lay(&ds, &LayoutOptions::default()), lay(&db, &LayoutOptions::default())); + let clips = |d: &Document, l: &DocLayout| { + l.pages + .first() + .map_or(0, |p| display::page_display(d, p, &Default::default()).iter().filter(|i| matches!(i, display::Draw::Clip { .. })).count()) + }; + assert_eq!(clips(&db, &lb), SCALE * 16 * 60, "every cell's line is clipped, all on the first page"); + let opts = display::DisplayOptions::default(); + let ratio = ratio_of(4, &(&ds, &ls), &(&db, &lb), |(d, l)| drop(std::hint::black_box(display::page_display(d, &l.pages[0], &opts)))); + assert_linear("clipped cells", ratio); +} + From ee96c4c99de789abeb889baac666f93caf13285a Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Thu, 22 Oct 2026 15:27:12 +0100 Subject: [PATCH 095/199] #3706: author colours ranked once per drawing, not per run or comment Finding 30: author_index rebuilt the list of revision authors for every tracked run and paragraph mark drawn, and balloons rebuilt the comment-author list for every comment (quadratic in revisions or comments). display::author_order ranks authors by first appearance once; page_display, page_layers and object_display build the revision order lazily once per call and pass it down, and balloons builds the comment order once per layout. Same colours. Tests: drawing_tracked_changes_by_many_authors_takes_linear_time, tracked_changes_colour_by_author_in_order_of_appearance, balloons_for_many_authors_lay_out_in_linear_time. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/layout/src/balloons.rs | 17 ++---- crates/layout/src/display.rs | 87 +++++++++++++++++++------------ crates/layout/src/tests_linear.rs | 61 ++++++++++++++++++++++ 3 files changed, 118 insertions(+), 47 deletions(-) diff --git a/crates/layout/src/balloons.rs b/crates/layout/src/balloons.rs index ec20b0db03..d20c7987a6 100644 --- a/crates/layout/src/balloons.rs +++ b/crates/layout/src/balloons.rs @@ -20,7 +20,7 @@ use wordcraft_doc::props::{Border, BorderStyle, CharProps, Rgb, TextColor}; use wordcraft_doc::{Document, Paragraph, Pos, StoryRef}; use wordcraft_geom::Rect; -use crate::display::revision_color; +use crate::display::{author_order, revision_color}; use crate::{BoxContext, BoxLayout, Ctx, DocLayout, Edges, Placed, fit_box, layout_box, push_para}; /// Width of the markup area beside each page (points): the editor's. @@ -103,17 +103,6 @@ pub fn replies(doc: &Document) -> BTreeMap> { m } -/// The comment authors in order of first appearance (for their colours). -fn author_index(doc: &Document, author: &str) -> u32 { - let mut seen: Vec<&str> = Vec::new(); - for c in doc.comments.values() { - if !seen.contains(&c.author.as_str()) { - seen.push(&c.author); - } - } - seen.iter().position(|a| *a == author).unwrap_or(0) as u32 -} - fn author_name(a: &str) -> &str { if a.trim().is_empty() { "Author" } else { a } } @@ -132,10 +121,12 @@ pub(crate) fn add(ctx: &mut Ctx, lay: &mut DocLayout) { let card_w = MARKUP_AREA - 2.0 * SIDE; let inner = card_w - STRIPE - 2.0 * PAD; let mut cards: Vec = Vec::new(); + // The comment authors in order of first appearance (for their colours), once for every card. + let authors = author_order(doc.comments.values().map(|c| c.author.as_str())); for (id, pos) in anchors { let Some(caret) = lay.caret_on(&pos, 0) else { continue }; let Some(c) = doc.comments.get(&id) else { continue }; - let color = revision_color(author_index(doc, &c.author)); + let color = revision_color(authors.get(c.author.as_str()).copied().unwrap_or(0)); let mut thread = vec![id]; thread.extend(threads.get(&id).into_iter().flatten().copied()); let x = STRIPE + PAD; diff --git a/crates/layout/src/display.rs b/crates/layout/src/display.rs index cb41a75034..6eacf9c2cb 100644 --- a/crates/layout/src/display.rs +++ b/crates/layout/src/display.rs @@ -532,8 +532,9 @@ fn stroke_of(u: Underline) -> Stroke { pub fn page_display(doc: &Document, page: &Page, opts: &DisplayOptions) -> Vec { let mut out = Vec::new(); let (ha, ba) = dimming(opts); - stories_display(doc, &[&page.header, &page.footer], opts, ha, &mut out); - stories_display(doc, &[&page.items], opts, ba, &mut out); + let authors = Authors::new(doc); + stories_display(doc, &[&page.header, &page.footer], opts, &authors, ha, &mut out); + stories_display(doc, &[&page.items], opts, &authors, ba, &mut out); body_change_bars(doc, page, opts, ba, &mut out); out } @@ -544,8 +545,9 @@ pub fn page_display(doc: &Document, page: &Page, opts: &DisplayOptions) -> Vec [(f32, Vec); 2] { let (ha, ba) = dimming(opts); let (mut hf, mut body) = (Vec::new(), Vec::new()); - stories_display(doc, &[&page.header, &page.footer], opts, 1.0, &mut hf); - stories_display(doc, &[&page.items], opts, 1.0, &mut body); + let authors = Authors::new(doc); + stories_display(doc, &[&page.header, &page.footer], opts, &authors, 1.0, &mut hf); + stories_display(doc, &[&page.items], opts, &authors, 1.0, &mut body); body_change_bars(doc, page, opts, 1.0, &mut body); [(ha, hf), (ba, body)] } @@ -563,7 +565,7 @@ fn body_change_bars(doc: &Document, page: &Page, opts: &DisplayOptions, alpha: f } /// Draw items for the placed items of `stories`, faded by `a`. -fn stories_display(doc: &Document, stories: &[&Vec], opts: &DisplayOptions, a: f32, out: &mut Vec) { +fn stories_display(doc: &Document, stories: &[&Vec], opts: &DisplayOptions, authors: &Authors, a: f32, out: &mut Vec) { let hidden = opts.hide_object.as_ref().map(|p| Hidden::new(doc, p)); for items in stories { let warps = warped_boxes(doc, items); @@ -578,7 +580,7 @@ fn stories_display(doc: &Document, stories: &[&Vec], opts: &DisplayOptio && let Some(rect) = cell_clip(&clips, *story, &path.0) { let mut inner = Vec::new(); - item(doc, it, opts, a, &mut inner); + item(doc, it, opts, authors, a, &mut inner); out.push(Draw::Clip { rect, items: inner }); continue; } @@ -592,10 +594,10 @@ fn stories_display(doc: &Document, stories: &[&Vec], opts: &DisplayOptio if let Some((_, rect, warp)) = warps.iter().find(|(w, ..)| w == id) { let lines: Vec<&Placed> = items.iter().filter(|i| matches!(i, Placed::Lines { story: StoryRef::Part(s), .. } if s == id)).collect(); - warped_text(doc, &lines, *rect, warp, opts, a, out); + warped_text(doc, &lines, *rect, warp, opts, authors, a, out); } } - _ => item(doc, it, opts, a, out), + _ => item(doc, it, opts, authors, a, out), } } } @@ -608,11 +610,12 @@ pub fn object_display(doc: &Document, page: &Page, o: &InlineObject, rect: Rect, let mut out = Vec::new(); object_draws(o, rect, Spin::default(), 1.0, opts, &mut out); let Some(id) = text_box else { return out }; + let authors = Authors::new(doc); for items in [&page.items, &page.header, &page.footer] { let lines: Vec<&Placed> = items.iter().filter(|i| matches!(i, Placed::Lines { story: StoryRef::Part(s), .. } if *s == id)).collect(); match warped_boxes(doc, items).into_iter().find(|(w, ..)| *w == id) { - Some((_, rect, warp)) if !lines.is_empty() => warped_text(doc, &lines, rect, &warp, opts, 1.0, &mut out), - _ => lines.into_iter().for_each(|it| item(doc, it, opts, 1.0, &mut out)), + Some((_, rect, warp)) if !lines.is_empty() => warped_text(doc, &lines, rect, &warp, opts, &authors, 1.0, &mut out), + _ => lines.into_iter().for_each(|it| item(doc, it, opts, &authors, 1.0, &mut out)), } } out @@ -670,12 +673,13 @@ fn warped_text( rect: Rect, warp: &wordcraft_doc::wordart::TextWarp, opts: &DisplayOptions, + authors: &Authors, alpha: f32, out: &mut Vec, ) { let mut raw = Vec::new(); for it in lines { - item(doc, it, opts, alpha, &mut raw); + item(doc, it, opts, authors, alpha, &mut raw); } // The letters: runs with effects as they are, plain runs from their glyphs. let mut arts: Vec<(BezPath, Box, Rgb, f32)> = Vec::new(); @@ -797,13 +801,13 @@ fn char_box(b: &Border, x0: f32, x1: f32, top: f32, bottom: f32, alpha: f32, out out.push(rule(r, top, r, bottom, b, alpha)); } -fn item(doc: &Document, it: &Placed, opts: &DisplayOptions, alpha: f32, out: &mut Vec) { - item_in(doc, it, opts, alpha, Spin::default(), out) +fn item(doc: &Document, it: &Placed, opts: &DisplayOptions, authors: &Authors, alpha: f32, out: &mut Vec) { + item_in(doc, it, opts, authors, alpha, Spin::default(), out) } /// [`item`] drawn inside the turn `outer` (a turned Drawing Canvas's members): a shape's /// effects follow all the turns it is drawn inside. -fn item_in(doc: &Document, it: &Placed, opts: &DisplayOptions, alpha: f32, outer: Spin, out: &mut Vec) { +fn item_in(doc: &Document, it: &Placed, opts: &DisplayOptions, authors: &Authors, alpha: f32, outer: Spin, out: &mut Vec) { match it { Placed::Fill { rect, color } => out.push(Draw::Fill { rect: *rect, color: *color, alpha, text_bg: false }), Placed::Rule { x0, y0, x1, y1, border } => out.push(rule(*x0, *y0, *x1, *y1, border, alpha)), @@ -823,14 +827,14 @@ fn item_in(doc: &Document, it: &Placed, opts: &DisplayOptions, alpha: f32, outer Placed::Clip { rect, spin, items } => { let mut inner = Vec::new(); for it in items { - item_in(doc, it, opts, alpha, spin.within(outer), &mut inner); + item_in(doc, it, opts, authors, alpha, spin.within(outer), &mut inner); } spun(*spin, *rect, vec![Draw::Clip { rect: *rect, items: inner }], out) } Placed::Cell { .. } | Placed::Object { .. } => {} Placed::Lines { story, path, para, l0, l1, x, y, turn } if turn.is_turned() => { let mut items = Vec::new(); - lines(doc, *story, path, para, *l0, *l1, 0.0, 0.0, opts, alpha, *turn == TextDirection::Down, &mut items); + lines(doc, *story, path, para, *l0, *l1, 0.0, 0.0, opts, authors, alpha, *turn == TextDirection::Down, &mut items); // Link areas are axis-aligned page rectangles: turned text gets none. for d in &mut items { if let Draw::Glyphs { link, .. } = d { @@ -842,7 +846,7 @@ fn item_in(doc: &Document, it: &Placed, opts: &DisplayOptions, alpha: f32, outer } out.push(Draw::Turned { x: *x, y: *y, turn: *turn, items }); } - Placed::Lines { story, path, para, l0, l1, x, y, .. } => lines(doc, *story, path, para, *l0, *l1, *x, *y, opts, alpha, false, out), + Placed::Lines { story, path, para, l0, l1, x, y, .. } => lines(doc, *story, path, para, *l0, *l1, *x, *y, opts, authors, alpha, false, out), } } @@ -860,6 +864,7 @@ fn lines( x: f32, y: f32, opts: &DisplayOptions, + authors: &Authors, alpha: f32, down: bool, out: &mut Vec, @@ -1087,7 +1092,7 @@ fn lines( let shown = opts.markup && m.insertions_deletions; let del = rc.del.filter(|_| shown); let ins = rc.ins.filter(|_| shown && del.is_none()); - let author = |r: u32| revision_color(doc.revisions.get(r as usize).map(|v| author_index(doc, &v.author)).unwrap_or(0)); + let author = |r: u32| authors.color(r); // Moved text: the Moves marks and colours (by default green, double strikethrough // where it was, double underline where it went). let moved_to = doc.move_name(ins).is_some(); @@ -1420,18 +1425,12 @@ fn lines( let mx = if line.rtl { ex - 1.0 - size * 0.6 } else { ex + 1.0 }; // A tracked (inserted or deleted) paragraph mark is drawn in its author's colour. let m = &opts.revisions; - let by_author = |r: u32| revision_color(doc.revisions.get(r as usize).map(|v| author_index(doc, &v.author)).unwrap_or(0)); + let by_author = |r: u32| authors.color(r); let color = para.filter(|_| opts.markup && m.insertions_deletions).and_then(|p| match (p.mark.ins, p.mark.del) { (_, Some(r)) if doc.move_name(Some(r)).is_some() => Some(m.move_from_color.unwrap_or_else(|| by_author(r))), (Some(r), None) if doc.move_name(Some(r)).is_some() => Some(m.move_to_color.unwrap_or_else(|| by_author(r))), - (_, Some(r)) => Some( - m.delete_color - .unwrap_or_else(|| revision_color(doc.revisions.get(r as usize).map(|v| author_index(doc, &v.author)).unwrap_or(0))), - ), - (Some(r), None) => Some( - m.insert_color - .unwrap_or_else(|| revision_color(doc.revisions.get(r as usize).map(|v| author_index(doc, &v.author)).unwrap_or(0))), - ), + (_, Some(r)) => Some(m.delete_color.unwrap_or_else(|| by_author(r))), + (Some(r), None) => Some(m.insert_color.unwrap_or_else(|| by_author(r))), _ => None, }); // The last paragraph of a section ends in a section break instead of a plain ¶. @@ -1860,14 +1859,34 @@ fn text_glyphs(doc: &Document, r: Rect, it: &GraphicItem, alpha: f32, k: f32) -> }) } -fn author_index(doc: &Document, author: &str) -> u32 { - let mut seen: Vec<&str> = Vec::new(); - for r in &doc.revisions { - if !seen.contains(&r.author.as_str()) { - seen.push(&r.author); - } +/// Each author's place among `authors` in order of first appearance (which colour marks their +/// changes and comments), found in one pass. +pub(crate) fn author_order<'a>(authors: impl Iterator) -> HashMap<&'a str, u32> { + let mut seen = HashMap::new(); + for a in authors { + let n = u32::try_from(seen.len()).unwrap_or(u32::MAX); + seen.entry(a).or_insert(n); + } + seen +} + +/// The colours of the document's revision authors ([`author_order`]), worked out once for a +/// drawing, when its first tracked change is drawn, not once for every change. +struct Authors<'a> { + doc: &'a Document, + order: std::cell::OnceCell>, +} + +impl<'a> Authors<'a> { + fn new(doc: &'a Document) -> Self { + Authors { doc, order: std::cell::OnceCell::new() } + } + + /// The colour of revision `r`'s author. + fn color(&self, r: u32) -> Rgb { + let order = self.order.get_or_init(|| author_order(self.doc.revisions.iter().map(|v| v.author.as_str()))); + revision_color(self.doc.revisions.get(r as usize).and_then(|v| order.get(v.author.as_str()).copied()).unwrap_or(0)) } - seen.iter().position(|a| *a == author).unwrap_or(0) as u32 } /// How far a legacy check box's square sits inside the room it takes on its line, on every side. diff --git a/crates/layout/src/tests_linear.rs b/crates/layout/src/tests_linear.rs index b3420a6cad..3c086c0154 100644 --- a/crates/layout/src/tests_linear.rs +++ b/crates/layout/src/tests_linear.rs @@ -184,3 +184,64 @@ fn drawing_many_clipped_cells_takes_linear_time() { assert_linear("clipped cells", ratio); } +/// `n` words, each inserted by its own author, at a size that fits them all on the first page. +fn tracked_words(n: usize) -> Document { + let mut d = Document::from_text("x"); + let p = Paragraph { + text: "word ".repeat(n), + runs: (0..n).map(|i| Run { len: 5, props: CharProps { ins: u32::try_from(i).ok(), size: Some(1.0), ..Default::default() } }).collect(), + ..Paragraph::with_text("", CharProps::default()) + }; + d.body = vec![wordcraft_doc::para_block(p)]; + d.revisions = (0..n).map(|i| wordcraft_doc::Revision::new(wordcraft_doc::RevisionKind::Insert, format!("Author {i}"), "")).collect(); + d +} + +/// Finding 30 (#3706): every tracked run ranked its author among all the document's revisions. +#[test] +fn drawing_tracked_changes_by_many_authors_takes_linear_time() { + let (ds, db) = (tracked_words(1000), tracked_words(SCALE * 1000)); + let (ls, lb) = (lay(&ds, &LayoutOptions::default()), lay(&db, &LayoutOptions::default())); + let opts = display::DisplayOptions { markup: true, ..Default::default() }; + assert_eq!(lb.pages.len(), 1); + let ratio = ratio_of(4, &(&ds, &ls), &(&db, &lb), |(d, l)| drop(std::hint::black_box(display::page_display(d, &l.pages[0], &opts)))); + assert_linear("tracked runs", ratio); +} + +/// Each author keeps the colour of their first revision's place among the authors. +#[test] +fn tracked_changes_colour_by_author_in_order_of_appearance() { + let mut d = tracked_words(3); + // Words 0 and 2 by Ana, word 1 by Ben: Ana first, Ben second. + for (r, a) in d.revisions.iter_mut().zip(["Ana", "Ben", "Ana"]) { + r.author = a.into(); + } + let l = lay(&d, &LayoutOptions::default()); + let opts = display::DisplayOptions { markup: true, ..Default::default() }; + let colours: Vec<_> = display::page_display(&d, &l.pages[0], &opts) + .into_iter() + .filter_map(|i| if let display::Draw::Glyphs { color, .. } = i { Some(color) } else { None }) + .collect(); + let (ana, ben) = (display::revision_color(0), display::revision_color(1)); + assert_ne!(ana, ben); + assert_eq!(colours, [ana, ben, ana]); +} + +/// Finding 30 (#3706): every comment balloon ranked its author among all the comments. +#[test] +fn balloons_for_many_authors_lay_out_in_linear_time() { + let doc = |n: usize| { + let mut d = Document::from_text(&"word ".repeat(n)); + for i in (0..n).rev() { + let part = d.add_part(wordcraft_doc::PartKind::Comment, vec![wordcraft_doc::para_block(Paragraph::with_text("c", CharProps::default()))]); + let id = u32::try_from(i).unwrap(); + d.comments.insert(id, wordcraft_doc::Comment { author: format!("Author {i}"), part, ..Default::default() }); + let p = CharProps::default(); + d.insert_object(&Pos::body(0, 5 * i + 4), InlineObject::CommentEnd { id }, &p).unwrap(); + d.insert_object(&Pos::body(0, 5 * i), InlineObject::CommentStart { id }, &p).unwrap(); + } + d + }; + let opts = LayoutOptions { comment_balloons: true, ..Default::default() }; + assert_linear("comment balloons", layout_time_ratio(doc, 120, &opts)); +} From 0674573b452090dab446777fa0db29aabfc97eb4 Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Thu, 22 Oct 2026 15:27:12 +0100 Subject: [PATCH 096/199] #3692: progress log and layout parity note for linear-time lookups Findings 1, 3, 5, 13, 23, 28, 29 and 30 (#3706): one Progress log row, one Performance row in layout-parity.md, revision-history rows. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/layout-parity.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/layout-parity.md b/docs/layout-parity.md index 0251aba93c..d35ce0a1cd 100644 --- a/docs/layout-parity.md +++ b/docs/layout-parity.md @@ -196,6 +196,7 @@ No overall parity estimate changes from these probes. | Scrolling a 1,096-page document that is one 5,300-row table (#811) | ~2.4 ms idle, ~4.5 ms median scroll frame (was ~600 ms); opening still lays out for ~4–8 s | not measured | measured 2026-10-12, release, headless (`tests_perf::big_table_scroll_bench`) | | A word with no break opportunity in it, laid out beside a floating object (#3412) | 1 MiB: 29.4 s → 0.35 s; 4 MiB: 472 s → 1.4 s | not measured | measured 2026-10-21, on 9b4b948c before the fix and with it (`layout::tests::a_long_unbreakable_word_lays_out_in_linear_time`, `layout::tests::a_megabyte_long_word_lays_out_quickly`) | | The same word, with a font that pulls half its glyphs back so that its advances alternate in sign (#3412) | 8× the word costs 52× the time before the fix, 15× with it | not measured | measured 2026-10-22, release, on `00bc6a4b` before the fix and with it (`layout::tests::a_word_with_alternating_advances_lays_out_in_linear_time`) | +| Pages, sections and paragraphs with thousands of clipped cells, tracked authors, comment authors, sections, header paragraphs, spanning cells, hidden bidi offsets or math characters (#3692, #3706) | linear: each lookup is indexed or walked once (was a rescan per item) | not measured | `layout::tests_linear`, `math::tests::a_long_text_run_lays_out_in_linear_time` | A word with no break opportunity in it takes a line per few characters, and measuring the word beside a floating object walked the whole paragraph for every one of those lines, so the layout @@ -271,6 +272,7 @@ layout suite are pending; this is not a new Word/native-IME acceptance claim. | 2026-10-24 | trivial | Floating tables count exact line-bottom contact; shapes keep strict overlap (#3977); original boundary/skip regressions in modes 11/12/14/15, expected Word rule from cyril265; readiness estimates unchanged | | 2026-10-23 | trivial | cu20we: preserve and render outline caps across DOCX/VML, screen and PDF; synthetic regressions; readiness estimates unchanged | | 2026-10-23 | trivial | cu20we: paragraph-relative box floats use the pre-spacing origin; header/footer/cell/text-box regressions; readiness estimates unchanged | +| 2026-10-22 | trivial | Layout lookups linear in clipped cells, authors, sections, header paragraphs and bidi offsets (#3692, #3706) | | 2026-10-22 | trivial | Group connector rerouting preserves displayed free and unresolved endpoints under nonuniform scaling while resolved ends keep their connection-site path; degenerate dimensions and unrepresentable fallback display spans are guarded; synthetic model/engine coverage only, readiness estimates unchanged | | 2026-10-21 | trivial | A word with no break opportunity in it, laid out beside a floating object, no longer lays out quadratically: every word is measured once, before the line is broken (#3412) | | 2026-10-23 | trivial | An empty paragraph whose mark is hidden is laid out as nothing, like a deleted one: no room, no list number (still counted, as in Word) and no shading, in the body and in table cells, headers and text boxes (#3982) | From 74dbec41078ad8360996e78d543a3ba0dbe10aeb Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Thu, 22 Oct 2026 20:55:12 +0100 Subject: [PATCH 097/199] #3692: findings 3 and 13 tests fail clearly on the old code Finding 3: the table's rows carry no cells, only a column with a width skipped before and after, so each row adds two spanning entries for next to no layout and counting the grid per entry dominates (old code 75x for 8x the rows, fixed code 4-8x). Finding 13: sections that link to the previous section's headers and footers are laid out against the same sections naming them (title page on, so first-page parts are looked up too); old code 6.1x the time, fixed code 1.0x, limit 2x. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/layout/src/tests_linear.rs | 69 ++++++++++++++++++++----------- 1 file changed, 44 insertions(+), 25 deletions(-) diff --git a/crates/layout/src/tests_linear.rs b/crates/layout/src/tests_linear.rs index 3c086c0154..8494e0315a 100644 --- a/crates/layout/src/tests_linear.rs +++ b/crates/layout/src/tests_linear.rs @@ -95,42 +95,61 @@ fn line_carets_match_x_of() { /// Finding 3: autofitting a table counted its grid's columns again for every spanning cell. #[test] fn autofit_tables_with_a_spanning_cell_in_every_row_lay_out_in_linear_time() { + // Rows with no cells, only a column skipped before and after them with a width: two + // spanning entries a row for next to no layout, so counting the grid per entry dominates. let doc = |n: usize| { - let mut t = Table::new(n, 3, 468.0); - for row in &mut t.rows { - row.cells.truncate(2); - if let Some(c) = row.cells.first_mut() { - c.props.span = 2; - } + let mut t = Table::new(n, 1, 468.0); + for row in t.rows.iter_mut().skip(1) { + row.cells.clear(); + row.props.grid_before = 1; + row.props.w_before = Some(10.0); + row.props.grid_after = 1; + row.props.w_after = Some(10.0); } let mut d = Document::from_text("x"); d.insert_block(StoryRef::Body, &Path::top(0), Block::Table(t)).unwrap(); d }; - assert_linear("spanning cells", layout_time_ratio(doc, 400, &LayoutOptions::default())); + assert_linear("spanning cells", layout_time_ratio(doc, 2000, &LayoutOptions::default())); +} + +/// `n` continuous sections of an empty paragraph each, each with a different first page. The +/// first section names every header and footer; the others name them too, or (`inherit`) name +/// none and link to the previous section's. +fn sections(n: usize, inherit: bool) -> Document { + use wordcraft_doc::PartKind::{Footer, Header}; + let mut d = Document::from_text(&"\n".repeat(n.saturating_sub(1))); + let mut part = |k| d.add_part(k, vec![wordcraft_doc::para_block(Paragraph::with_text("Head", CharProps::default()))]); + let ids = [part(Header), part(Header), part(Header), part(Footer), part(Footer), part(Footer)]; + let named = SectionProps { + start: SectionStart::Continuous, + title_page: true, + headers: HeaderSet { default: Some(ids[0]), first: Some(ids[1]), even: Some(ids[2]) }, + footers: HeaderSet { default: Some(ids[3]), first: Some(ids[4]), even: Some(ids[5]) }, + ..Default::default() + }; + let linked = SectionProps { headers: HeaderSet::default(), footers: HeaderSet::default(), ..named.clone() }; + for i in 0..n.saturating_sub(1) { + let s = if i == 0 || !inherit { named.clone() } else { linked.clone() }; + d.para_mut(StoryRef::Body, &Path::top(i)).unwrap().section = Some(Box::new(s)); + } + d.last_section = if inherit { linked } else { named }; + d } /// Finding 13: each section's headers and footers were found by walking back through the -/// sections before it, for every section and again for every page. +/// sections before it, for every section and again for every page. Linked to the previous +/// section's, they must cost no more to lay out than named in each section. #[test] fn headers_inherited_through_many_sections_lay_out_in_linear_time() { - let doc = |n: usize| { - let mut d = Document::from_text(&vec!["x"; n].join("\n")); - let head = d.add_part(wordcraft_doc::PartKind::Header, vec![wordcraft_doc::para_block(Paragraph::with_text("Head", CharProps::default()))]); - for i in 0..n.saturating_sub(1) { - let mut s = SectionProps { start: SectionStart::Continuous, ..Default::default() }; - if i == 0 { - s.headers.default = Some(head); - } - d.para_mut(StoryRef::Body, &Path::top(i)).unwrap().section = Some(Box::new(s)); - } - d.last_section.start = SectionStart::Continuous; - d - }; - // Every page shows the first section's header. - let l = lay(&doc(200), &LayoutOptions::default()); - assert!(l.pages.iter().all(|p| !p.header.is_empty()), "the header is inherited"); - assert_linear("sections", layout_time_ratio(doc, 2000, &LayoutOptions::default())); + let l = lay(§ions(200, true), &LayoutOptions::default()); + assert!(l.pages.iter().all(|p| !p.header.is_empty() && !p.footer.is_empty()), "the header and footer are inherited"); + let n = 8000; + let (named, inherited) = (sections(n, false), sections(n, true)); + let lay_once = |d: &Document| drop(std::hint::black_box(lay(d, &LayoutOptions::default()))); + let (t_named, t_inherited, ratio) = cost_ratio(1, || lay_once(&named), || lay_once(&inherited)); + eprintln!("{n} sections: named {t_named:.4} s, inherited {t_inherited:.4} s, ratio {ratio:.1}"); + assert!(ratio < 2.0, "{n} inheriting sections took {ratio:.1}x the time of named ones ({t_named:.4} s → {t_inherited:.4} s)"); } /// A header of `n` short paragraphs. From ddb67eaa45d01b9bff7891a34f8f0c27c84384da Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Fri, 23 Oct 2026 11:06:16 +0100 Subject: [PATCH 098/199] =?UTF-8?q?#3692:=20review=20=E2=80=94=20linear-ti?= =?UTF-8?q?me=20tests=20count=20lookup=20steps=20instead=20of=20timing=20t?= =?UTF-8?q?hem?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The layout's linear-time tests (#3866) asserted CPU-time ratios, which a busy CI machine can skew. Each walk that once ran again for every item it served (a line's clusters, a page's items, the revision authors, the sections, a table's rows, a run's glyphs, the clipped cells) now counts the elements it looks at in a test-only counter (crate::work, nothing outside tests), and the tests assert 8x the input takes at most 12x the steps (quadratic: ~64x). proof_text, whose walk is the document model's, stays timed, and a ratio over the limit is measured twice more before it fails. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01CX1hMKX28764Lrx5moxs1o --- crates/layout/src/display.rs | 10 ++- crates/layout/src/hit.rs | 9 +++ crates/layout/src/lib.rs | 3 + crates/layout/src/math.rs | 11 +-- crates/layout/src/para.rs | 6 +- crates/layout/src/table.rs | 15 +++- crates/layout/src/tests_linear.rs | 120 +++++++++++++++++++----------- crates/layout/src/work.rs | 29 ++++++++ 8 files changed, 145 insertions(+), 58 deletions(-) create mode 100644 crates/layout/src/work.rs diff --git a/crates/layout/src/display.rs b/crates/layout/src/display.rs index 6eacf9c2cb..ca83e65f82 100644 --- a/crates/layout/src/display.rs +++ b/crates/layout/src/display.rs @@ -637,6 +637,7 @@ fn clipped_cells(items: &[Placed]) -> Clips { _ => None, }); for (i, (story, prefix, rect)) in cells.enumerate() { + crate::work::steps(1); clips.entry(story).or_default().entry(prefix).or_insert((i, rect)); } clips @@ -646,7 +647,13 @@ fn clipped_cells(items: &[Placed]) -> Clips { /// the cells holding the line are looked up, not every clipped cell on the page. fn cell_clip(clips: &Clips, story: StoryRef, path: &[u32]) -> Option { let cells = clips.get(&story)?; - (0..=path.len()).filter_map(|n| cells.get(path.get(..n)?)).min_by_key(|(i, _)| *i).map(|(_, r)| *r) + (0..=path.len()) + .filter_map(|n| { + crate::work::steps(1); + cells.get(path.get(..n)?) + }) + .min_by_key(|(i, _)| *i) + .map(|(_, r)| *r) } /// The text boxes among `items` whose text is warped (Text Effects › Transform, drawn bent): their @@ -1864,6 +1871,7 @@ fn text_glyphs(doc: &Document, r: Rect, it: &GraphicItem, alpha: f32, k: f32) -> pub(crate) fn author_order<'a>(authors: impl Iterator) -> HashMap<&'a str, u32> { let mut seen = HashMap::new(); for a in authors { + crate::work::steps(1); let n = u32::try_from(seen.len()).unwrap_or(u32::MAX); seen.entry(a).or_insert(n); } diff --git a/crates/layout/src/hit.rs b/crates/layout/src/hit.rs index 188b89f060..bb0955ad8b 100644 --- a/crates/layout/src/hit.rs +++ b/crates/layout/src/hit.rs @@ -174,7 +174,16 @@ impl<'a> PartPages<'a> { } } +/// The items of `page` that `story`'s pieces are among (each one looked at counted as a step of +/// [`crate::work`]). fn items_of(page: &Page, story: StoryRef) -> Box + '_> { + let items = story_items(page, story); + #[cfg(test)] + let items: Box + '_> = Box::new(items.inspect(|_| crate::work::steps(1))); + items +} + +fn story_items(page: &Page, story: StoryRef) -> Box + '_> { match story { StoryRef::Body => Box::new(page.items.iter()), // Headers and footers, and the text boxes in them, are drawn with the page's header and diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index e5a9d3ef2f..1ea54b14df 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -22,6 +22,7 @@ mod table; mod textbox; mod vertical; pub mod wordart; +mod work; use std::collections::{HashMap, HashSet}; use std::hash::{Hash, Hasher}; @@ -2950,6 +2951,7 @@ impl<'a> HfLinks<'a> { /// laid out on `si`'s pages. fn defining_width(&self, footer: bool, si: usize, id: Option) -> Option { let id = id?; + work::steps(1); let linked = if footer { &self.feet } else { &self.heads }; let own = self.sections.get(si)?.1; if linked.own.get(si).is_some_and(|h| [h.default, h.first, h.even].contains(&Some(id))) { @@ -2978,6 +2980,7 @@ impl Linked { let mut namers: HashMap> = HashMap::new(); let mut prev = HeaderSet::default(); for (si, o) in own.iter().enumerate() { + work::steps(1); prev = HeaderSet { default: o.default.or(prev.default), first: o.first.or(prev.first), even: o.even.or(prev.even) }; linked.push(prev); for id in [o.default, o.first, o.even].into_iter().flatten() { diff --git a/crates/layout/src/math.rs b/crates/layout/src/math.rs index a581635bc4..9f1874ad7e 100644 --- a/crates/layout/src/math.rs +++ b/crates/layout/src/math.rs @@ -639,6 +639,7 @@ impl Ctx { while let Some(g) = shaped.get(j) && g.cluster < cb { + crate::work::steps(1); gx += g.x_advance as f32 * k; j += 1; } @@ -2378,14 +2379,10 @@ mod tests { /// glyphs from the first. #[test] fn a_long_text_run_lays_out_in_linear_time() { - use crate::tests::{SCALE, cost_ratio}; + use crate::tests_linear::assert_linear_steps; use wordcraft_doc::math::*; let eq = |n: usize| Math { nodes: vec![MNode::Run(MRun { nor: true, ..MRun::new("a".repeat(n)) })], ..Default::default() }; - let (small, big) = (eq(400), eq(SCALE * 400)); - // Each timed 16 times over, so the timings are long against the clock's tick. - let lay_16 = |m: &Math| (0..16).for_each(|_| drop(std::hint::black_box(lay_in(m, "", false, 1e6, &MathProps::default())))); - let (t1, tk, ratio) = cost_ratio(SCALE, || lay_16(&small), || lay_16(&big)); - eprintln!("t(n) {t1:.4} s, t(8n) {tk:.4} s, ratio {ratio:.1}"); - assert!(ratio < 24.0, "8x the text took {ratio:.1}x the time ({t1:.4} s → {tk:.4} s): not linear"); + let lay = |m: &Math| drop(std::hint::black_box(lay_in(m, "", false, 1e6, &MathProps::default()))); + assert_linear_steps("math text run", 400, eq, lay); } } diff --git a/crates/layout/src/para.rs b/crates/layout/src/para.rs index 7218079977..abbecc13ae 100644 --- a/crates/layout/src/para.rs +++ b/crates/layout/src/para.rs @@ -3119,6 +3119,7 @@ impl ParaLayout { return self.x_of_bidi(l, off, from); } for k in *from..l.c1 { + crate::work::steps(1); let c = self.clusters.get(k)?; if off <= c.start || off < c.end { *from = k; @@ -3178,7 +3179,10 @@ impl ParaLayout { /// it has the higher level), which keeps the caret at that run's trailing edge, so each /// visual boundary of the common mixed cases has its own offset. fn x_of_bidi(&self, l: &Line, off: usize, from: &mut usize) -> Option { - let k = (*from..l.c1).find(|&k| self.clusters.get(k).is_some_and(|c| off < c.end || off <= c.start)); + let k = (*from..l.c1).find(|&k| { + crate::work::steps(1); + self.clusters.get(k).is_some_and(|c| off < c.end || off <= c.start) + }); *from = k.unwrap_or(l.c1); let Some(k) = k else { return Some(l.end_x()) }; let c = self.clusters.get(k)?; diff --git a/crates/layout/src/table.rs b/crates/layout/src/table.rs index 1a1f5e5b91..d2e24e8910 100644 --- a/crates/layout/src/table.rs +++ b/crates/layout/src/table.rs @@ -71,6 +71,13 @@ struct Turned { top: f32, } +/// The number of grid columns of `t` ([`Table::cols`]), which walks every row (each row counted +/// as a step of [`crate::work`]). +fn grid_cols(t: &Table) -> usize { + crate::work::steps(t.rows.len()); + t.cols() +} + /// How long the lines laid out in `items` are without the room they didn't use: the length /// turned text needs so that nothing wraps. fn natural_length(items: &[Placed]) -> f32 { @@ -178,7 +185,7 @@ pub fn measure_table_columns(doc: &wordcraft_doc::Document, t: &Table) -> Vec<(f use wordcraft_doc::Block; let style = t.props.style.as_deref().and_then(|id| doc.styles.table_style(id)); let margins_def = default_margins(t, style.as_ref()); - let ncols = t.cols().clamp(1, wordcraft_doc::table::MAX_COLS); + let ncols = grid_cols(t).clamp(1, wordcraft_doc::table::MAX_COLS); let fields = crate::FieldCtx::default(); let mut cols = vec![(0.0f32, 0.0f32); ncols]; // Cells spanning several columns: (first column, span, min, max), applied after the others. @@ -360,7 +367,7 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava // Cell text formatting and looks per set of style parts, built once per table. let mut cell_text: Vec<(Region, CellText)> = Vec::new(); let mut looks: Vec<(Region, RegionLook)> = Vec::new(); - let ncols = t.cols().max(1); + let ncols = grid_cols(t).max(1); // Column widths. let mut grid: Vec = if t.grid.len() == ncols { // Grid units can be narrower than a text box, including omitted row-end columns. @@ -1122,7 +1129,7 @@ fn region_text(st: &TableStyleProps, region: Region) -> CellText { /// with their own. `None` when every cell is empty. fn column_content(ctx: &mut Ctx, t: &Table, style: Option<&TableStyleProps>, depth: usize) -> Option> { let margins_def = default_margins(t, style); - let mut cols = vec![(0.0f32, 0.0f32); t.cols().max(1)]; + let mut cols = vec![(0.0f32, 0.0f32); grid_cols(t).max(1)]; let mut spanning = Vec::new(); let mut any = false; for (ri, row) in t.rows.iter().enumerate() { @@ -1145,7 +1152,7 @@ fn column_content(ctx: &mut Ctx, t: &Table, style: Option<&TableStyleProps>, dep spanning.extend(row_gaps(row, g).map(|(g, span, w)| (g, span, (w, w)))); } // The grid's width once: counting it walks every row, and there can be a spanning cell in each. - let ncols = t.cols(); + let ncols = grid_cols(t); for (g, span, need) in spanning { let Some(cs) = cols.get_mut(g..g.saturating_add(span).min(ncols)) else { continue }; let n = cs.len().max(1) as f32; diff --git a/crates/layout/src/tests_linear.rs b/crates/layout/src/tests_linear.rs index 8494e0315a..e49409693c 100644 --- a/crates/layout/src/tests_linear.rs +++ b/crates/layout/src/tests_linear.rs @@ -1,7 +1,11 @@ //! Lookups that once rescanned the page, the paragraph or the document for every item they //! looked up (#3692, #3706): an input 8x the size must cost about 8x as much, not 64x. +//! +//! The cost is the steps the lookups' walks count ([`crate::work`]), not a timing, so a busy +//! machine can't make a linear lookup look quadratic or the other way round. Only proofing text, +//! whose walk is the document model's, is timed. -use super::tests::{SCALE, cost_ratio, layout_time_ratio}; +use super::tests::{SCALE, cost_ratio}; use super::*; use wordcraft_doc::para::Run; use wordcraft_doc::{Pos, Table}; @@ -10,15 +14,24 @@ fn lay(d: &Document, opts: &LayoutOptions) -> DocLayout { layout(d, &mut LayoutCache::new(), opts) } -fn assert_linear(what: &str, (t1, tk, ratio): (f64, f64, f64)) { - eprintln!("{what}: t(n) {t1:.4} s, t(8n) {tk:.4} s, ratio {ratio:.1}"); - assert!(ratio < 24.0, "{what}: 8x the input took {ratio:.1}x the time ({t1:.4} s → {tk:.4} s): not linear"); +/// The most steps 8x the input may take, against the steps of the input: linear walks take +/// about 8x (a little more where a fixed cost is shared out), quadratic ones about 64x. +const MAX_STEP_RATIO: f64 = 1.5 * SCALE as f64; + +/// Asserts `f` takes about [`SCALE`] times the steps ([`crate::work`]) on `make(SCALE * n)` as on +/// `make(n)`, not [`SCALE`]² times. Making the inputs isn't counted. +pub(crate) fn assert_linear_steps(what: &str, n: usize, make: impl Fn(usize) -> T, f: impl Fn(&T)) { + let (small, big) = (make(n), make(SCALE * n)); + let (s, b) = (work::count(|| f(&small)), work::count(|| f(&big))); + let ratio = b as f64 / s.max(1) as f64; + eprintln!("{what}: {s} steps for n, {b} for {SCALE}n, ratio {ratio:.1}"); + assert!(s > 0, "{what}: no steps counted, so the walk this test watches no longer counts them (crate::work)"); + assert!(ratio <= MAX_STEP_RATIO, "{what}: {SCALE}x the input took {ratio:.1}x the steps ({s} → {b}): not linear"); } -/// [`cost_ratio`] of `f(big)` against `f(small)`, each repeated `reps` times so the timings are -/// long against the clock's tick. -fn ratio_of(reps: usize, small: &T, big: &T, f: impl Fn(&T)) -> (f64, f64, f64) { - cost_ratio(SCALE, || (0..reps).for_each(|_| f(small)), || (0..reps).for_each(|_| f(big))) +/// [`assert_linear_steps`] for laying out `doc(n)`. +fn assert_layout_linear(what: &str, n: usize, doc: impl Fn(usize) -> Document, opts: &LayoutOptions) { + assert_linear_steps(what, n, doc, |d| drop(std::hint::black_box(lay(d, opts)))); } /// The first laid-out body paragraph on page 0. @@ -39,8 +52,22 @@ fn proof_text_walks_the_runs_once() { p }; assert_eq!(para::proof_text(¶(5)), "a\u{1}a\u{1}a"); + // The runs are the document model's, whose walk counts no steps, so this one is timed: the + // CPU time this thread ran (`cost_ratio`). At 50,000 runs the old walk took 64x as long for + // 8x the runs, and the new one about 8x; a limit of 24x stays far from both, and a ratio over + // it is measured again, twice, before the test fails, so a busy spell can't fail it alone. let (small, big) = (para(50_000), para(SCALE * 50_000)); - assert_linear("proof_text", ratio_of(8, &small, &big, |p| drop(std::hint::black_box(para::proof_text(p))))); + let proof_8 = |p: &Paragraph| (0..8).for_each(|_| drop(std::hint::black_box(para::proof_text(p)))); + let mut ratios = Vec::new(); + for _ in 0..3 { + let (t1, tk, ratio) = cost_ratio(SCALE, || proof_8(&small), || proof_8(&big)); + eprintln!("proof_text: t(n) {t1:.4} s, t(8n) {tk:.4} s, ratio {ratio:.1}"); + if ratio < 24.0 { + return; + } + ratios.push(ratio); + } + panic!("proof_text: 8x the runs took {ratios:.1?}x the time, three times over: not linear"); } /// A right-to-left paragraph whose one line holds `n` hidden letters: caret offsets with no width. @@ -58,15 +85,14 @@ fn bidi_hidden(n: usize) -> Document { #[test] fn bidi_clicks_and_caret_steps_take_linear_time() { let opts = LayoutOptions { show_hidden: false, ..Default::default() }; - let (ds, db) = (bidi_hidden(5000), bidi_hidden(SCALE * 5000)); - let (ls, lb) = (lay(&ds, &opts), lay(&db, &opts)); - let (Some(ps), Some(pb)) = (first_para(&ls), first_para(&lb)) else { panic!("no paragraph") }; - assert_eq!(pb.lines.len(), 1, "the hidden letters stay on one line"); - let ratio = ratio_of(32, &(ps, &ls), &(pb, &lb), |(p, l)| { + let laid = |n: usize| lay(&bidi_hidden(n), &opts); + let Some(pb) = first_para(&laid(SCALE * 5000)).map(|p| p.lines.len()) else { panic!("no paragraph") }; + assert_eq!(pb, 1, "the hidden letters stay on one line"); + assert_linear_steps("bidi hit test", 5000, laid, |l| { + let Some(p) = first_para(l) else { panic!("no paragraph") }; std::hint::black_box(p.off_at_x(0, 1.0)); std::hint::black_box(l.visual_step(&Pos::body(0, 0), true, 0)); }); - assert_linear("bidi hit test", ratio); } /// The carets of a line walked in one pass are where `x_of` puts each of them, on bidirectional @@ -110,7 +136,7 @@ fn autofit_tables_with_a_spanning_cell_in_every_row_lay_out_in_linear_time() { d.insert_block(StoryRef::Body, &Path::top(0), Block::Table(t)).unwrap(); d }; - assert_linear("spanning cells", layout_time_ratio(doc, 2000, &LayoutOptions::default())); + assert_layout_linear("spanning cells", 2000, doc, &LayoutOptions::default()); } /// `n` continuous sections of an empty paragraph each, each with a different first page. The @@ -144,12 +170,11 @@ fn sections(n: usize, inherit: bool) -> Document { fn headers_inherited_through_many_sections_lay_out_in_linear_time() { let l = lay(§ions(200, true), &LayoutOptions::default()); assert!(l.pages.iter().all(|p| !p.header.is_empty() && !p.footer.is_empty()), "the header and footer are inherited"); - let n = 8000; - let (named, inherited) = (sections(n, false), sections(n, true)); - let lay_once = |d: &Document| drop(std::hint::black_box(lay(d, &LayoutOptions::default()))); - let (t_named, t_inherited, ratio) = cost_ratio(1, || lay_once(&named), || lay_once(&inherited)); - eprintln!("{n} sections: named {t_named:.4} s, inherited {t_inherited:.4} s, ratio {ratio:.1}"); - assert!(ratio < 2.0, "{n} inheriting sections took {ratio:.1}x the time of named ones ({t_named:.4} s → {t_inherited:.4} s)"); + let opts = LayoutOptions::default(); + assert_layout_linear("inherited headers", 1000, |n| sections(n, true), &opts); + let steps = |inherit: bool| work::count(|| drop(std::hint::black_box(lay(§ions(1000, inherit), &opts)))); + let (named, inherited) = (steps(false), steps(true)); + assert!(inherited <= 2 * named, "1000 inheriting sections took {inherited} steps, named ones {named}"); } /// A header of `n` short paragraphs. @@ -164,14 +189,16 @@ fn tall_header(n: usize) -> (Document, u32) { /// Finding 28: selecting across a header looked through the whole page for each paragraph. #[test] fn selecting_a_long_header_takes_linear_time() { - let n = 300; - let ((ds, ids), (db, idb)) = (tall_header(n), tall_header(SCALE * n)); - let (ls, lb) = (lay(&ds, &LayoutOptions::default()), lay(&db, &LayoutOptions::default())); - let ends = |id: u32, n: usize| (Pos::new(StoryRef::Part(id), Path::top(0), 0), Pos::new(StoryRef::Part(id), Path::top(n - 1), 2)); - let ((a, b), (c, e)) = (ends(ids, n), ends(idb, SCALE * n)); - assert!(lb.selection_rects(&db, &c, &e, 0).len() >= SCALE * n, "a rectangle per paragraph"); - let ratio = ratio_of(32, &(&ds, &ls, a, b), &(&db, &lb, c, e), |(d, l, a, b)| drop(std::hint::black_box(l.selection_rects(d, a, b, 0)))); - assert_linear("header selection", ratio); + // The header laid out, and the selection from its first paragraph to its last. + let laid = |n: usize| { + let (d, id) = tall_header(n); + let l = lay(&d, &LayoutOptions::default()); + let ends = (Pos::new(StoryRef::Part(id), Path::top(0), 0), Pos::new(StoryRef::Part(id), Path::top(n - 1), 2)); + (d, l, ends) + }; + let (d, l, (a, b)) = laid(SCALE * 300); + assert!(l.selection_rects(&d, &a, &b, 0).len() >= SCALE * 300, "a rectangle per paragraph"); + assert_linear_steps("header selection", 300, laid, |(d, l, (a, b))| drop(std::hint::black_box(l.selection_rects(d, a, b, 0)))); } /// A table of `n` rows of 60 cells, each row exactly half a point high, so every cell clips its @@ -190,17 +217,16 @@ fn clipped_table(n: usize) -> Document { /// Finding 1: drawing a line in a clipped cell looked through every clipped cell on the page. #[test] fn drawing_many_clipped_cells_takes_linear_time() { - let (ds, db) = (clipped_table(16), clipped_table(SCALE * 16)); - let (ls, lb) = (lay(&ds, &LayoutOptions::default()), lay(&db, &LayoutOptions::default())); - let clips = |d: &Document, l: &DocLayout| { - l.pages - .first() - .map_or(0, |p| display::page_display(d, p, &Default::default()).iter().filter(|i| matches!(i, display::Draw::Clip { .. })).count()) + let laid = |n: usize| { + let d = clipped_table(n); + let l = lay(&d, &LayoutOptions::default()); + (d, l) }; - assert_eq!(clips(&db, &lb), SCALE * 16 * 60, "every cell's line is clipped, all on the first page"); let opts = display::DisplayOptions::default(); - let ratio = ratio_of(4, &(&ds, &ls), &(&db, &lb), |(d, l)| drop(std::hint::black_box(display::page_display(d, &l.pages[0], &opts)))); - assert_linear("clipped cells", ratio); + let draw = |(d, l): &(Document, DocLayout)| l.pages.first().map(|p| display::page_display(d, p, &opts)).unwrap_or_default(); + let clips = draw(&laid(SCALE * 16)).iter().filter(|i| matches!(i, display::Draw::Clip { .. })).count(); + assert_eq!(clips, SCALE * 16 * 60, "every cell's line is clipped, all on the first page"); + assert_linear_steps("clipped cells", 16, laid, |dl| drop(std::hint::black_box(draw(dl)))); } /// `n` words, each inserted by its own author, at a size that fits them all on the first page. @@ -219,12 +245,16 @@ fn tracked_words(n: usize) -> Document { /// Finding 30 (#3706): every tracked run ranked its author among all the document's revisions. #[test] fn drawing_tracked_changes_by_many_authors_takes_linear_time() { - let (ds, db) = (tracked_words(1000), tracked_words(SCALE * 1000)); - let (ls, lb) = (lay(&ds, &LayoutOptions::default()), lay(&db, &LayoutOptions::default())); + let laid = |n: usize| { + let d = tracked_words(n); + let l = lay(&d, &LayoutOptions::default()); + (d, l) + }; + assert_eq!(laid(SCALE * 1000).1.pages.len(), 1); let opts = display::DisplayOptions { markup: true, ..Default::default() }; - assert_eq!(lb.pages.len(), 1); - let ratio = ratio_of(4, &(&ds, &ls), &(&db, &lb), |(d, l)| drop(std::hint::black_box(display::page_display(d, &l.pages[0], &opts)))); - assert_linear("tracked runs", ratio); + let draw = |(d, l): &(Document, DocLayout)| l.pages.first().map(|p| display::page_display(d, p, &opts)).unwrap_or_default(); + // Every run is drawn in its author's colour, so the authors are ranked at least once. + assert_linear_steps("tracked runs", 1000, laid, |dl| drop(std::hint::black_box(draw(dl)))); } /// Each author keeps the colour of their first revision's place among the authors. @@ -262,5 +292,5 @@ fn balloons_for_many_authors_lay_out_in_linear_time() { d }; let opts = LayoutOptions { comment_balloons: true, ..Default::default() }; - assert_linear("comment balloons", layout_time_ratio(doc, 120, &opts)); + assert_layout_linear("comment balloons", 120, doc, &opts); } diff --git a/crates/layout/src/work.rs b/crates/layout/src/work.rs new file mode 100644 index 0000000000..b36368e162 --- /dev/null +++ b/crates/layout/src/work.rs @@ -0,0 +1,29 @@ +//! A count of the steps the layout's lookups take, for the linear-time tests (#3692): they check +//! that an input 8x the size costs about 8x the steps, which a busy machine can't skew the way it +//! skews a timing. Each walk that once ran again for every item it served (a line's clusters, a +//! page's items, the revision authors, the sections, a table's rows) counts the elements it +//! looks at, so a caller that walks it once per item again shows up as a quadratic count. Outside +//! tests the count is nothing. + +#[cfg(test)] +thread_local! { + /// The steps counted on this thread (layout runs on the thread that calls it). + static STEPS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// Count `n` steps of a walk (nothing outside tests). +#[inline(always)] +pub(crate) fn steps(n: usize) { + #[cfg(test)] + STEPS.with(|s| s.set(s.get().saturating_add(n as u64))); + #[cfg(not(test))] + let _ = n; +} + +/// The steps `f` takes. +#[cfg(test)] +pub(crate) fn count(f: impl FnOnce()) -> u64 { + let before = STEPS.with(std::cell::Cell::get); + f(); + STEPS.with(std::cell::Cell::get).saturating_sub(before) +} From 17e648bde79c6bdc0cb966d3ab4042c425a7bd89 Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Sun, 25 Oct 2026 23:59:44 +0000 Subject: [PATCH 099/199] =?UTF-8?q?#3692:=20rebase=20onto=20main=20?= =?UTF-8?q?=E2=80=94=20sort=20the=20merged=20test=20module=20declarations?= =?UTF-8?q?=20(rustfmt)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- crates/layout/src/lib.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index 1ea54b14df..0fd59bfce3 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -4979,10 +4979,10 @@ mod tests_grid; #[cfg(test)] mod tests_ligature; #[cfg(test)] -mod tests_nbsp; -#[cfg(test)] mod tests_linear; #[cfg(test)] +mod tests_nbsp; +#[cfg(test)] mod tests_numeral; #[cfg(test)] mod tests_pages; From 1fa73832641728d0284035fe545ac37df8fc8456 Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Mon, 26 Oct 2026 00:14:42 +0000 Subject: [PATCH 100/199] =?UTF-8?q?#3692:=20rebase=20onto=20main=20?= =?UTF-8?q?=E2=80=94=20drop=20the=20Ctx=20sections=20list=20the=20linked?= =?UTF-8?q?=20header/footer=20index=20replaces?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- crates/layout/src/lib.rs | 6 ------ 1 file changed, 6 deletions(-) diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index 0fd59bfce3..f639078ec1 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -695,11 +695,6 @@ enum Pass { /// Walk state while paginating. struct Ctx<'a> { doc: &'a Document, - /// The body's sections ([`Document::sections`]) for one pass, with the block index each - /// starts after: header and footer wrap work asks for them per page, and rebuilding the - /// list walked the whole body every time, so a long document paid a body-length scan per - /// page. The document does not change during a pass. - sections: Vec<(usize, &'a SectionProps)>, cache: &'a mut LayoutCache, opts: &'a LayoutOptions, counters: Counters, @@ -2309,7 +2304,6 @@ fn layout_pass( }); let mut ctx = Ctx { doc, - sections: doc.sections(), cache, opts, hide_deleted: opts.hides_deleted(StoryRef::Body), From af9d5c954198cc88e848d9199b85b1fe06686146 Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Thu, 22 Oct 2026 18:08:51 +0100 Subject: [PATCH 101/199] #3692: bitmap glyph PNG header probe keeps the glyph's budget Finding 0. decode_png probed the PNG's size with the image crate's default limits, and the PNG decoder inflates an iCCP profile while reading the header: a tiny colour-font glyph could carry a few-KB chunk that inflates to hundreds of MB before the size check. The probe and the decode now share one reader with the glyph's own size and byte budget; past it the profile is dropped and the glyph still draws. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/render/src/color_glyph.rs | 44 +++++++++++++++++++++++++------- 1 file changed, 35 insertions(+), 9 deletions(-) diff --git a/crates/render/src/color_glyph.rs b/crates/render/src/color_glyph.rs index 8b169d9b6c..549fa5ccd6 100644 --- a/crates/render/src/color_glyph.rs +++ b/crates/render/src/color_glyph.rs @@ -102,18 +102,11 @@ fn decode_png(png: &[u8], w: u32, h: u32) -> Option { if w == 0 || h == 0 || w > side || h > side { return None; } - let reader = || image::ImageReader::with_format(std::io::Cursor::new(png), image::ImageFormat::Png); let fits = |(iw, ih): (u32, u32)| iw > 0 && ih > 0 && iw <= w && ih <= h; - if !fits(reader().into_dimensions().ok()?) { + if !fits(glyph_png_reader(png, w, h).into_dimensions().ok()?) { return None; } - let mut limits = image::Limits::default(); - limits.max_image_width = Some(w); - limits.max_image_height = Some(h); - limits.max_alloc = Some(w as u64 * h as u64 * 4 * 2 + 64 * 1024); - let mut r = reader(); - r.limits(limits); - let img = r.decode().ok()?.to_rgba8(); + let img = glyph_png_reader(png, w, h).decode().ok()?.to_rgba8(); let (iw, ih) = img.dimensions(); if !fits((iw, ih)) { return None; @@ -121,6 +114,19 @@ fn decode_png(png: &[u8], w: u32, h: u32) -> Option { crate::premul_pixmap(iw, ih, img.as_raw()) } +/// A reader for a bitmap glyph's PNG with the glyph's budget: at most `w`×`h` and about two RGBA +/// buffers of it. Every read goes through it, the header probe too: reading the header inflates +/// metadata such as an ICC profile, which the default budget would let run to hundreds of MB. +fn glyph_png_reader(png: &[u8], w: u32, h: u32) -> image::ImageReader> { + let mut limits = image::Limits::default(); + limits.max_image_width = Some(w); + limits.max_image_height = Some(h); + limits.max_alloc = Some(u64::from(w).saturating_mul(u64::from(h)).saturating_mul(8).saturating_add(64 * 1024)); + let mut r = image::ImageReader::with_format(std::io::Cursor::new(png), image::ImageFormat::Png); + r.limits(limits); + r +} + /// A pixmap from premultiplied BGRA rows. fn bgra_pixmap(bgra: &[u8], w: u32, h: u32) -> Option { if w == 0 || h == 0 || w > u16::MAX as u32 || h > u16::MAX as u32 { @@ -436,4 +442,24 @@ mod tests { let side = wordcraft_fonts::color::MAX_BITMAP_SIDE; assert!(decode_png(&png(4, 4), side + 1, 4).is_none(), "past the cap"); } + + /// A tiny bitmap glyph whose PNG carries a big ICC profile (it compresses to almost nothing): + /// the header probe already inflates it, so the probe is held to the glyph's budget too, not + /// the decoder's default hundreds of MB. Past the budget the profile is dropped (the glyph is + /// still drawn, without it). + #[test] + fn bitmap_png_header_probe_keeps_the_glyph_budget() { + use image::ImageDecoder; + let mut out = vec![]; + let mut enc = image::codecs::png::PngEncoder::new(&mut out); + image::ImageEncoder::set_icc_profile(&mut enc, vec![0u8; 4 << 20]).unwrap(); + image::ImageEncoder::write_image(enc, &[200, 0, 0, 255], 1, 1, image::ExtendedColorType::Rgba8).unwrap(); + assert!(out.len() < 64 * 1024, "the profile compresses: {} bytes", out.len()); + let profile = |r: image::ImageReader>| r.into_decoder().unwrap().icc_profile().unwrap().map(|p| p.len()); + let unbounded = image::ImageReader::with_format(std::io::Cursor::new(out.as_slice()), image::ImageFormat::Png); + assert_eq!(profile(unbounded), Some(4 << 20), "the default budget inflates the 4 MB profile"); + assert_eq!(profile(glyph_png_reader(&out, 1, 1)), None, "the glyph budget stops it at the header"); + assert_eq!(decode_png(&out, 1, 1).map(|p| (p.width(), p.height())), Some((1, 1))); + assert_eq!(glyph_png_reader(&png(4, 4), 4, 4).into_dimensions().ok(), Some((4, 4)), "a plain glyph reads"); + } } From f82319deb7f79684c2650014db8b4c5303a11f0e Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Thu, 22 Oct 2026 18:08:57 +0100 Subject: [PATCH 102/199] #3692: picture decode budgets charge the decoder's own pixel size Finding 37. decode_fits charged every picture 8 bytes a pixel (RGBA8 decode plus its copy), but a 16-bit or float image decodes to 8-16 bytes a pixel before conversion, so the real peak was up to 2.5x the budget. geom::decode_peak_fits takes the decoder's native bytes per pixel; render's decode_pixmap_within and pdf's load_image_within read it from the decoder (header only, same limits) before decoding. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/geom/src/lib.rs | 2 +- crates/geom/src/pixels.rs | 25 +++++++++++++++++++++++-- crates/pdf/src/lib.rs | 27 ++++++++++++++++++--------- crates/pdf/src/tests.rs | 7 +++++++ crates/render/src/lib.rs | 31 +++++++++++++++++++++++-------- 5 files changed, 72 insertions(+), 20 deletions(-) diff --git a/crates/geom/src/lib.rs b/crates/geom/src/lib.rs index 48998abd32..868ccf025d 100644 --- a/crates/geom/src/lib.rs +++ b/crates/geom/src/lib.rs @@ -13,7 +13,7 @@ pub mod picture; mod region; pub use region::Region; mod pixels; -pub use pixels::{MAX_DECODED_BYTES, MAX_RASTER_PIXELS, decode_fits, fit_pixels, rgba_bytes, rgba_fits}; +pub use pixels::{MAX_DECODED_BYTES, MAX_RASTER_PIXELS, decode_fits, decode_peak_fits, fit_pixels, rgba_bytes, rgba_fits}; /// Points per inch. pub const PT_PER_IN: f32 = 72.0; diff --git a/crates/geom/src/pixels.rs b/crates/geom/src/pixels.rs index 64258a71f8..263cadfce2 100644 --- a/crates/geom/src/pixels.rs +++ b/crates/geom/src/pixels.rs @@ -18,9 +18,18 @@ pub fn rgba_fits(w: u32, h: u32, budget: u64) -> bool { } /// Whether decoding a `w`×`h` picture fits `budget` bytes at its peak: the decoder's buffer and -/// the RGBA conversion (or its premultiplied copy) are alive together, so two RGBA buffers. +/// the RGBA conversion (or its premultiplied copy) are alive together, so two RGBA buffers. For a +/// decoder whose buffer isn't four bytes a pixel, see [`decode_peak_fits`]. pub fn decode_fits(w: u32, h: u32, budget: u64) -> bool { - w > 0 && h > 0 && rgba_bytes(w, h).and_then(|b| b.checked_mul(2)).is_some_and(|b| b <= budget) + decode_peak_fits(w, h, 4, budget) +} + +/// [`decode_fits`] for a decoder that hands back `native` bytes a pixel (8 for 16-bit RGBA, 16 for +/// float RGBA): its buffer lives alongside the RGBA8 conversion, then the conversion alongside its +/// premultiplied copy, so the peak is the larger of `native + 4` and `8` bytes a pixel. +pub fn decode_peak_fits(w: u32, h: u32, native: u64, budget: u64) -> bool { + let per_pixel = native.saturating_add(4).max(8); + w > 0 && h > 0 && (w as u64).checked_mul(h as u64).and_then(|p| p.checked_mul(per_pixel)).is_some_and(|b| b <= budget) } /// `w`×`h` shrunk (aspect kept, each side at least 1) until it holds at most `max_pixels`. @@ -53,6 +62,18 @@ mod tests { assert!(!decode_fits(0, 8, u64::MAX)); } + /// A 16-bit RGBA decode (8 bytes a pixel) lives alongside its 4-byte RGBA8 conversion: 12 bytes + /// a pixel at the peak, not the 8 two RGBA8 buffers take. + #[test] + fn wide_decoder_buffers_are_charged_at_their_size() { + assert!(decode_peak_fits(16, 8, 4, 1024), "RGBA8: two RGBA8 buffers"); + assert!(decode_peak_fits(16, 8, 1, 1024), "grey: the premultiplied copy is the peak"); + assert!(!decode_peak_fits(16, 8, 8, 1024), "RGBA16 needs 1536 bytes"); + assert!(decode_peak_fits(16, 8, 8, 1536)); + assert!(!decode_peak_fits(16, 8, 16, 2559), "RGBA32F: 20 bytes a pixel"); + assert!(!decode_peak_fits(u32::MAX, u32::MAX, u64::MAX, u64::MAX), "overflow is refused"); + } + #[test] fn fit_pixels_keeps_normal_sizes_and_shrinks_huge_ones() { assert_eq!(fit_pixels(612, 792, MAX_RASTER_PIXELS), (612, 792)); diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index 8fa8b236de..7b6001219c 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -1903,19 +1903,28 @@ fn load_image(data: &Arc>) -> Option { /// [`load_image`] with an explicit RGBA byte budget (a small one in tests). fn load_image_within(data: &Arc>, budget: u64) -> Option { + use image::ImageDecoder; let fmt = image::guess_format(data).ok()?; - // Header-only size check before anything is allocated (decode buffer + RGBA conversion are - // charged); the side limits and the default 512 MB allocation cap stay as a second guard. - let (hw, hh) = image::ImageReader::new(std::io::Cursor::new(data.as_slice())).with_guessed_format().ok()?.into_dimensions().ok()?; - if !wordcraft_geom::decode_fits(hw, hh, budget) { - return None; - } - let mut reader = image::ImageReader::new(std::io::Cursor::new(data.as_slice())).with_guessed_format().ok()?; let mut limits = image::Limits::default(); limits.max_image_width = Some(MAX_IMAGE_SIDE); limits.max_image_height = Some(MAX_IMAGE_SIDE); - reader.limits(limits); - let dec = reader.decode().ok()?; + let reader = || { + let mut r = image::ImageReader::new(std::io::Cursor::new(data.as_slice())).with_guessed_format().ok()?; + r.limits(limits.clone()); + Some(r) + }; + // Header-only size check before anything is allocated (the decoder's buffer at its own pixel + // size, 8 bytes for 16-bit RGBA, plus the RGBA conversion are charged); the side limits and + // the default 512 MB allocation cap apply to the header read and the decode alike. + let (hw, hh, native) = { + let d = reader()?.into_decoder().ok()?; + let (w, h) = d.dimensions(); + (w, h, u64::from(d.color_type().bytes_per_pixel())) + }; + if !wordcraft_geom::decode_peak_fits(hw, hh, native, budget) { + return None; + } + let dec = reader()?.decode().ok()?; let (w, h) = (dec.width(), dec.height()); if w == 0 || h == 0 || w > MAX_IMAGE_SIDE || h > MAX_IMAGE_SIDE { return None; diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index 2d572d7ea2..cc2831ad94 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -2140,6 +2140,13 @@ fn image_budget_is_checked_from_the_header() { // The decoded image and its RGBA conversion are charged: two buffers at peak. assert!(load_image_within(&data, 1024).is_some()); assert!(load_image_within(&data, 1023).is_none()); + // 16-bit RGBA decodes to 8 bytes a pixel, alive with its RGBA8 conversion: 12 bytes a pixel. + let mut wide = Vec::new(); + let img16 = image::ImageBuffer::, _>::from_fn(16, 8, |x, y| image::Rgba([x as u16 * 999, y as u16, 9, 65535])); + image::DynamicImage::ImageRgba16(img16).write_to(&mut std::io::Cursor::new(&mut wide), image::ImageFormat::Png).unwrap(); + let wide = Arc::new(wide); + assert!(load_image_within(&wide, 1024).is_none(), "two RGBA8 buffers' worth is not enough for RGBA16"); + assert!(load_image_within(&wide, 1536).is_some()); // A header (valid CRC) claiming 30000x30000 with no pixel data reads fine, then the budget refuses it. let hdr = ihdr_only_png(30_000, 30_000); let dims = image::ImageReader::new(std::io::Cursor::new(hdr.as_slice())).with_guessed_format().unwrap().into_dimensions().unwrap(); diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 78b5ff1107..959a801ea0 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -209,17 +209,26 @@ pub fn decode_pixmap(bytes: &[u8]) -> Option { /// [`decode_pixmap`] with an explicit RGBA byte budget (a small one in tests). fn decode_pixmap_within(bytes: &[u8], budget: u64) -> Option { - // The header gives the size; refuse before the decoder (and the RGBA copy) allocate anything. - let (hw, hh) = image_size(bytes)?; - if !wordcraft_geom::decode_fits(hw, hh, budget) { - return None; - } - let mut reader = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().ok()?; + use image::ImageDecoder; let mut limits = image::Limits::default(); limits.max_image_width = Some(MAX_DECODE_SIDE); limits.max_image_height = Some(MAX_DECODE_SIDE); - reader.limits(limits); - let img = reader.decode().ok()?.into_rgba8(); + let reader = || { + let mut r = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().ok()?; + r.limits(limits.clone()); + Some(r) + }; + // The header gives the size and the decoder's pixel format (16-bit RGBA takes 8 bytes a pixel + // before its RGBA8 copy); refuse before the decoder (and the RGBA copy) allocate anything. + let (hw, hh, native) = { + let d = reader()?.into_decoder().ok()?; + let (w, h) = d.dimensions(); + (w, h, u64::from(d.color_type().bytes_per_pixel())) + }; + if !wordcraft_geom::decode_peak_fits(hw, hh, native, budget) { + return None; + } + let img = reader()?.decode().ok()?.into_rgba8(); let (w, h) = img.dimensions(); premul_pixmap(w, h, img.as_raw()) } @@ -1719,6 +1728,12 @@ mod tests { // Decode, the RGBA conversion and the premultiplied copy are charged: two buffers at peak. assert!(decode_pixmap_within(&buf, 1024).is_some()); assert!(decode_pixmap_within(&buf, 1023).is_none()); + // 16-bit RGBA decodes to 8 bytes a pixel, alive with its RGBA8 copy: 12 bytes a pixel. + let mut wide = Vec::new(); + let img16 = image::ImageBuffer::, _>::from_fn(16, 8, |x, y| image::Rgba([x as u16 * 999, y as u16, 9, 65535])); + image::DynamicImage::ImageRgba16(img16).write_to(&mut std::io::Cursor::new(&mut wide), image::ImageFormat::Png).unwrap(); + assert!(decode_pixmap_within(&wide, 1024).is_none(), "two RGBA8 buffers' worth is not enough for RGBA16"); + assert!(decode_pixmap_within(&wide, 1536).is_some()); // A header (valid CRC) claiming 60000x60000 with no pixel data reads fine, then the budget refuses it. let hdr = ihdr_only_png(60_000, 60_000); assert_eq!(image_size(&hdr), Some((60_000, 60_000))); From 0d5a149093d6b0de4c0c2cff730261f3c4aeb7fa Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Thu, 22 Oct 2026 18:09:04 +0100 Subject: [PATCH 103/199] #3692: PNG/JPEG export refuses pixels that don't match the page size Finding 26. Rendered::to_png resized its pixel buffer to width*height*4 when the lengths differed, so a Rendered claiming a huge size (its fields are public) allocated that much with no budget; to_jpeg asserted inside the encoder on the same mismatch. Both now encode only pixels that are exactly width*height RGBA within the raster cap, and return empty (logged) otherwise. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/render/src/lib.rs | 41 ++++++++++++++++++++++++++++++++++------ 1 file changed, 35 insertions(+), 6 deletions(-) diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 959a801ea0..88a6bcd534 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -57,15 +57,21 @@ impl Rendered { } out } + /// Whether the pixels are exactly `width`×`height` RGBA, within a page raster's budget + /// ([`wordcraft_geom::MAX_RASTER_PIXELS`]): the fields are public, so an encoder is never asked + /// to pad a short buffer out to a huge claimed size (or, for JPEG, to assert on a mismatch). + fn encodable(&self) -> bool { + let budget = wordcraft_geom::MAX_RASTER_PIXELS.saturating_mul(4); + wordcraft_geom::rgba_fits(self.width, self.height, budget) + && wordcraft_geom::rgba_bytes(self.width, self.height).is_some_and(|b| b == self.pixels.len() as u64) + } /// PNG bytes (empty on failure, logged). pub fn to_png(&self) -> Vec { - let mut px = self.to_straight(); - let Some(len) = wordcraft_geom::rgba_bytes(self.width, self.height).and_then(|b| usize::try_from(b).ok()) else { - log::error!("PNG encode: size {}×{} overflows", self.width, self.height); + if !self.encodable() { + log::error!("PNG encode: {} bytes for {}×{} pixels", self.pixels.len(), self.width, self.height); return Vec::new(); - }; - px.resize(len, 0); - let Some(img) = image::RgbaImage::from_raw(self.width, self.height, px) else { + } + let Some(img) = image::RgbaImage::from_raw(self.width, self.height, self.to_straight()) else { log::error!("PNG encode: bad size {}×{}", self.width, self.height); return Vec::new(); }; @@ -76,7 +82,12 @@ impl Rendered { } buf } + /// JPEG bytes on white (empty on failure, logged). pub fn to_jpeg(&self, quality: u8) -> Vec { + if !self.encodable() { + log::error!("JPEG encode: {} bytes for {}×{} pixels", self.pixels.len(), self.width, self.height); + return Vec::new(); + } let rgba = self.to_straight(); let rgb: Vec = rgba .as_chunks::<4>() @@ -1740,6 +1751,24 @@ mod tests { assert!(decode_pixmap(&hdr).is_none()); } + /// A `Rendered` whose pixels don't match its claimed size isn't padded out to that size: a + /// 60000×60000 claim over no pixels would allocate 14 GB. It, and any mismatch, encodes to + /// nothing (JPEG used to assert on the length). + #[test] + fn encoders_refuse_pixels_that_dont_match_the_size() { + let huge = Rendered { width: 60_000, height: 60_000, pixels: Vec::new() }; + assert!(huge.to_png().is_empty()); + assert!(huge.to_jpeg(80).is_empty()); + let short = Rendered { width: 2, height: 2, pixels: vec![255; 4] }; + assert!(short.to_png().is_empty()); + assert!(short.to_jpeg(80).is_empty()); + let long = Rendered { width: 1, height: 1, pixels: vec![255; 8] }; + assert!(long.to_png().is_empty()); + let ok = Rendered { width: 2, height: 2, pixels: vec![255; 16] }; + assert!(!ok.to_png().is_empty()); + assert!(!ok.to_jpeg(80).is_empty()); + } + #[test] fn page_raster_is_capped_by_total_pixels() { let d = Document::new(); From 05ee2e01269e058f8649a715a4f6ddcde0ec6a19 Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Thu, 22 Oct 2026 18:09:47 +0100 Subject: [PATCH 104/199] #3692: metafile text far larger than the page is left out Finding 27. An EMF/WMF text record's font height is file data; a glyph billions of device pixels tall reached vello's flattening, which subdivides its curves for ever. draw_vector_text now skips a glyph whose device bounds are non-finite, larger than 65536 px a side, or off the raster. Text at normal sizes draws as before. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/render/src/lib.rs | 50 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 49 insertions(+), 1 deletion(-) diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 88a6bcd534..567072e186 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -410,13 +410,25 @@ fn draw_vector_text(ctx: &mut RenderContext, t: &wordcraft_metafile::Text, tf: A let k = lay.em / face.upem.max(1.0); ctx.set_paint(rgba_color(t.color)); let db = FontDb::global(); + let canvas = kurbo::Rect::new(0.0, 0.0, f64::from(ctx.width()), f64::from(ctx.height())); for (g, (x, y)) in shaped.iter().zip(&lay.glyphs) { let o = db.outline(&face, g.gid); - ctx.set_transform(tf * Affine::translate((*x, *y)) * Affine::scale(k)); + let m = tf * Affine::translate((*x, *y)) * Affine::scale(k); + // A record's font size is file data: a glyph many canvases tall (or off the canvas) would + // have its outline flattened into millions of segments for nothing, so it is left out. + let b = m.transform_rect_bbox(o.bounding_box()); + let sane = [b.x0, b.y0, b.x1, b.y1].iter().all(|v| v.is_finite()) && b.width() <= MAX_GLYPH_PIXELS && b.height() <= MAX_GLYPH_PIXELS; + if !sane || !b.overlaps(canvas) { + continue; + } + ctx.set_transform(m); ctx.fill_path(&o); } } +/// Largest metafile glyph drawn, in device pixels per side (four times the widest canvas). +const MAX_GLYPH_PIXELS: f64 = 65_536.0; + /// One device pixel in page points under `view` (its mean axis scale); 1 for a degenerate transform. fn device_pixel(view: Affine) -> f64 { let c = view.as_coeffs(); @@ -2379,6 +2391,42 @@ mod tests { assert!(dark > 50, "the text is painted inside the picture: {dark} dark pixels"); } + /// An EMF text run whose anchor sits inside the picture but whose font is billions of units + /// tall: each glyph's curves would span billions of device pixels, which the rasteriser + /// subdivides for ever. Glyphs that big (or off the raster) are left out; the page renders. + #[test] + fn metafile_text_with_a_huge_font_is_left_out() { + let mut font = emf_rec(82, &le32(&[1, -2_000_000_000, 0, 0, 0, 400, 0, 0])); + let mut name: Vec = "Arial".encode_utf16().flat_map(|u| u.to_le_bytes()).collect(); + name.resize(64, 0); + font.extend(name); + font[4..8].copy_from_slice(&104u32.to_le_bytes()); + let mut text = emf_rec(84, &le32(&[0, 0, 100, 100, 1, 0, 0, 5, 40, 4, 76, 0, 0, 0, 0, 0, 0])); + text.extend("WOWS".encode_utf16().flat_map(|u| u.to_le_bytes())); + text[4..8].copy_from_slice(&84u32.to_le_bytes()); + let mut emf = red_right_half_emf()[..88].to_vec(); + emf.extend([font, emf_rec(37, &le32(&[1])), emf_rec(24, &le32(&[0])), text, emf_rec(14, &[0; 12])].concat()); + let mut d = Document::new(); + let media = d.add_media(emf, "emf"); + let mut p = wordcraft_doc::Paragraph::new(); + let obj = wordcraft_doc::para::InlineObject::Image { + media, + w: 70.0, + h: 70.0, + alt: String::new(), + float: Default::default(), + crop: [0.0; 4], + ole: None, + }; + p.insert_object(0, obj, &Default::default()).unwrap(); + d.body = vec![wordcraft_doc::para_block(p)]; + let l = layout(&d, &mut LayoutCache::new(), &LayoutOptions::default()); + let start = std::time::Instant::now(); + let img = render_page(&d, &l.pages[0], 1.0, &RenderOptions::default()); + assert!(start.elapsed() < std::time::Duration::from_secs(2), "{:?}", start.elapsed()); + assert_eq!(img.width, 612); + } + #[test] fn unreadable_metafile_keeps_the_grey_box() { let mut d = Document::new(); From 2a647661fbc4663cdad73ce98f7bf0ef1aa95b9a Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Thu, 22 Oct 2026 18:09:47 +0100 Subject: [PATCH 105/199] #3692: dash patterns too fine or broken to draw are drawn solid Finding 36. kurbo's dash iterator stops advancing when a dash is so small that subtracting it from the segment length changes nothing (and loops on negative entries), so a document dash pattern like [1e-30, 1e-30] hung the renderer. Before calling kurbo, safe_dashes drops a pattern that is negative, non-finite, zero-length, or would cut the stroke into more than 100 000 pieces for its path's length bound; the stroke is drawn solid. kurbo is unchanged; normal patterns dash as before. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/render/src/lib.rs | 74 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 69 insertions(+), 5 deletions(-) diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 567072e186..9c7639f778 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -887,6 +887,44 @@ fn wave_path(x0: f64, y0: f64, x1: f64, y1: f64, amp: f64) -> BezPath { p } +/// Most pieces one dashed stroke may be cut into; past it the stroke is drawn solid. kurbo's dash +/// iterator makes no progress along a segment when a dash is too small next to it (it never ends), +/// and every piece is more stroke to build. +const MAX_DASH_PIECES: f64 = 100_000.0; + +/// `dashes` to stroke a path at most `len` long with, or none (solid) when they can't be drawn: a +/// negative or non-finite length, a pattern that adds up to nothing, or more than +/// [`MAX_DASH_PIECES`] pieces along the path. +fn safe_dashes(dashes: impl IntoIterator, len: f64) -> Vec { + let d: Vec = dashes.into_iter().take(MAX_DASH_PIECES as usize + 1).collect(); + let period: f64 = d.iter().sum(); + let ok = d.iter().all(|v| v.is_finite() && *v >= 0.0) && period > 0.0 && period.is_finite() && len.is_finite(); + // Each repeat of the pattern is up to `d.len()` pieces; a partial repeat at each end counts too. + if ok && (len / period + 2.0) * d.len() as f64 <= MAX_DASH_PIECES { d } else { Vec::new() } +} + +/// An upper bound on `path`'s length: its control polygon's (a curve is no longer than it). +fn path_len_bound(path: &BezPath) -> f64 { + let (mut len, mut at, mut start) = (0.0, kurbo::Point::ZERO, kurbo::Point::ZERO); + for el in path.elements() { + let pts: &[kurbo::Point] = match el { + kurbo::PathEl::MoveTo(p) => { + (at, start) = (*p, *p); + continue; + } + kurbo::PathEl::LineTo(p) => std::slice::from_ref(p), + kurbo::PathEl::QuadTo(a, b) => &[*a, *b], + kurbo::PathEl::CurveTo(a, b, c) => &[*a, *b, *c], + kurbo::PathEl::ClosePath => std::slice::from_ref(&start), + }; + for p in pts { + len += at.distance(*p); + at = *p; + } + } + len +} + /// Rasterize one display item with `view` mapping its current point coordinates to pixels. /// `visible` is a culling area in those point coordinates; nested clips intersect it and turns /// transform it into the child's coordinates. Shape and path strokes apply their stored caps, @@ -915,10 +953,10 @@ fn draw(ctx: &mut RenderContext, doc: &Document, it: &Draw, view: Affine, visibl // every end, blobbing over the neighbouring border at a table's corners (#2892). Wavy // underlines keep round ends, as the PDF draws them. let mut st = kurbo::Stroke::new(w).with_caps(kurbo::Cap::Butt); - if let Some(d) = stroke.dashes() { - st = st.with_dashes(0.0, d.iter().map(|k| f64::from(*k) * w)); - } let (x0, y0, x1, y1) = (*x0 as f64, *y0 as f64, *x1 as f64, *y1 as f64); + if let Some(d) = stroke.dashes() { + st = st.with_dashes(0.0, safe_dashes(d.iter().map(|k| f64::from(*k) * w), (x1 - x0).hypot(y1 - y0))); + } let line = |ctx: &mut RenderContext, ox: f64, oy: f64| { let mut p = BezPath::new(); p.move_to((x0 + ox, y0 + oy)); @@ -1094,7 +1132,7 @@ fn draw(ctx: &mut RenderContext, doc: &Document, it: &Draw, view: Affine, visibl // Width 0 (or none) is a hairline. let w = if stroke_width.is_finite() && *stroke_width > 0.0 { stroke_width.clamp(0.25, 200.0) } else { 0.75 }; ctx.set_paint(color(opts.ink(*s), 1.0)); - ctx.set_stroke(kurbo::Stroke::new(w as f64).with_caps(screen_cap(*cap)).with_dashes(0.0, dash.iter().map(|v| f64::from(*v)))); + ctx.set_stroke(kurbo::Stroke::new(w as f64).with_caps(screen_cap(*cap)).with_dashes(0.0, safe_dashes(dash.iter().map(|v| f64::from(*v)), path_len_bound(&path)))); ctx.stroke_path(&path); } } @@ -1185,7 +1223,7 @@ fn draw(ctx: &mut RenderContext, doc: &Document, it: &Draw, view: Affine, visibl ctx.set_stroke( kurbo::Stroke::new(if stroke_width.is_nan() { 0.25 } else { stroke_width.clamp(0.25, 200.0) } as f64) .with_caps(screen_cap(*cap)) - .with_dashes(0.0, dash.iter().map(|v| f64::from(*v))), + .with_dashes(0.0, safe_dashes(dash.iter().map(|v| f64::from(*v)), path_len_bound(&path))), ); ctx.stroke_path(&path); } @@ -2579,6 +2617,32 @@ mod tests { assert_eq!(image.pixel(832, 800), [255, 255, 255, 255], "inside the first gap"); } + /// Dash lengths too small to cut the path make kurbo's dash iterator spin without moving along + /// it (and every piece is more stroke to build): such a pattern is drawn solid, as are negative + /// and non-finite ones. A normal dash pattern still dashes. + #[test] + fn hostile_dash_patterns_draw_solid_and_finish() { + use wordcraft_doc::graphic::PathSeg; + let line = |dash: Vec| Draw::Path { + segs: vec![PathSeg::Move(10.0, 20.0), PathSeg::Line(90.0, 20.0)], + fill: None, + stroke: Some(Rgb(0, 0, 0)), + stroke_width: 2.0, + dash, + }; + let start = std::time::Instant::now(); + for dash in [vec![1e-30, 1e-30], vec![1e-30], vec![-1.0, 2.0], vec![f32::NAN, 1.0], vec![f32::INFINITY, 1.0], vec![0.0, 0.0]] { + let img = render_items(&Document::new(), &[line(dash.clone())], 0.0, 0.0, 100.0, 40.0, 1.0, &RenderOptions::default()); + assert!(img.pixel(50, 20)[3] > 200, "{dash:?} draws solid"); + } + assert!(start.elapsed() < std::time::Duration::from_secs(2), "{:?}", start.elapsed()); + let dashed = render_items(&Document::new(), &[line(vec![10.0, 10.0])], 0.0, 0.0, 100.0, 40.0, 1.0, &RenderOptions::default()); + assert!(dashed.pixel(15, 20)[3] > 200 && dashed.pixel(25, 20)[3] == 0, "a normal pattern dashes"); + assert!(safe_dashes([3.0, 1.0], 1000.0).len() == 2); + assert!(safe_dashes([1e-3, 1e-3], 1000.0).is_empty(), "a million pieces"); + assert!(safe_dashes(std::iter::repeat_n(1.0, 200_000), 1.0).is_empty(), "too many entries"); + } + #[test] fn flowchart_or_spokes() { let r = kurbo::Rect::new(0.0, 0.0, 10.0, 10.0); From c98efffc0638581c2ccfb3a5afde765719fcf7dc Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Thu, 22 Oct 2026 18:09:47 +0100 Subject: [PATCH 106/199] #3692: empty polygon groups in metafiles reserve nothing Finding 33. geom::subpaths reserved pts + counts ops up front, so a poly-polygon record with a long list of zero counts reserved room for ops it never emits, and the canvas charged only ops.len() against MAX_BYTES. subpaths now sizes the vector exactly, and Canvas::push charges what each item holds (capacity) for paths, bitmaps and text. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/metafile/src/canvas.rs | 9 ++++++--- crates/metafile/src/geom.rs | 12 +++++++++++- crates/metafile/src/tests.rs | 15 +++++++++++++++ 3 files changed, 32 insertions(+), 4 deletions(-) diff --git a/crates/metafile/src/canvas.rs b/crates/metafile/src/canvas.rs index 03a8f34532..bb6ecd3167 100644 --- a/crates/metafile/src/canvas.rs +++ b/crates/metafile/src/canvas.rs @@ -284,9 +284,12 @@ impl Canvas { fn push(&mut self, raw: Raw) { let cost = match &raw { - Raw::Path { ops, .. } => ops.len() * std::mem::size_of::(), - Raw::Bitmap { img, .. } => img.px.len(), - Raw::Text(t) => t.text.len() + t.font.len() + t.dx.len() * std::mem::size_of::(), + // What is held, capacity and all. + Raw::Path { ops, .. } => ops.capacity().saturating_mul(std::mem::size_of::()), + Raw::Bitmap { img, .. } => img.px.capacity(), + Raw::Text(t) => { + t.text.capacity().saturating_add(t.font.capacity()).saturating_add(t.dx.capacity().saturating_mul(std::mem::size_of::())) + } }; if self.items.len() >= MAX_ITEMS || self.used.saturating_add(cost) > MAX_BYTES { self.full = true; diff --git a/crates/metafile/src/geom.rs b/crates/metafile/src/geom.rs index 0547de18ba..0c2b772cf1 100644 --- a/crates/metafile/src/geom.rs +++ b/crates/metafile/src/geom.rs @@ -99,7 +99,17 @@ fn arc(cx: f64, cy: f64, rx: f64, ry: f64, a0: f64, sweep: f64) -> Vec { /// Consecutive subpaths: `counts[i]` points each, taken in order from `pts`. Counts that run past the /// point list are cut short rather than failing the whole record. pub(crate) fn subpaths(pts: &[P], counts: &[usize], close: bool) -> Vec { - let mut ops = Vec::with_capacity(pts.len() + counts.len()); + // The exact size first: empty groups add no ops, so a long count list of zeros (file data) + // mustn't reserve room the canvas budget never sees. + let (mut n, mut at) = (0usize, 0usize); + for &c in counts { + let end = at.saturating_add(c).min(pts.len()); + if end > at { + n = n.saturating_add(end - at + usize::from(close)); + } + at = end; + } + let mut ops = Vec::with_capacity(n); let mut at = 0usize; for &c in counts { let end = at.saturating_add(c).min(pts.len()); diff --git a/crates/metafile/src/tests.rs b/crates/metafile/src/tests.rs index eaa5b16740..a71481ffaa 100644 --- a/crates/metafile/src/tests.rs +++ b/crates/metafile/src/tests.rs @@ -1053,3 +1053,18 @@ fn plain_wmf_target_comes_from_a_window_that_was_read() { let (x0, y0, x1, y1) = rect_extent(&p.items[0]); assert!(close(x0, 0.0) && close(y0, 0.0) && close(x1, 200.0) && close(y1, 100.0), "{x0} {y0} {x1} {y1}"); } + +/// A poly-polygon's group counts are file data: a long list of empty groups adds no ops, so it +/// mustn't reserve room for them either (the canvas charges what a path holds, capacity included). +#[test] +fn empty_poly_groups_reserve_nothing() { + let pts = [(0.0, 0.0), (10.0, 0.0), (10.0, 10.0)]; + let mut counts = vec![0usize; 100_000]; + counts.push(3); + let ops = crate::geom::subpaths(&pts, &counts, true); + assert_eq!(ops.len(), 4, "M L L Z"); + assert!(ops.capacity() <= 8, "capacity {}", ops.capacity()); + // Counts past the points are cut short, and the size still matches. + let ops = crate::geom::subpaths(&pts, &[2, 5, 4], false); + assert_eq!((ops.len(), ops.capacity()), (3, 3)); +} From 0aae9d05db6d5ecba8d7c10cabe07336fd2092a1 Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Thu, 22 Oct 2026 18:09:57 +0100 Subject: [PATCH 107/199] #3692: PDF export looks bookmarks up case-blind through an index Finding 22. A link whose bookmark name differed in case from the bookmark fell back to scanning the whole bookmark map with eq_ignore_ascii_case, per link (bookmarks x links), and the HashMap's order made which of several same-but-for-case bookmarks won arbitrary. The exporter now keeps an exact and an ASCII-folded index, each filled first-in-document-order. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/pdf/src/lib.rs | 15 +++++++++------ crates/pdf/src/tests.rs | 8 ++++++++ 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index 7b6001219c..4b76816eac 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -747,7 +747,9 @@ struct Exporter<'a> { /// Bookmark positions by name (the first of a name wins), gathered once: looking each internal /// link up by walking the whole document made a long document with a linked table of contents /// take seconds to export. - bookmarks: std::cell::OnceCell>, + /// Bookmarks by exact name, and by ASCII-lowercased name for the case-blind fallback; the + /// first in document order wins either way. + bookmarks: std::cell::OnceCell<(HashMap, HashMap)>, } /// What the content being drawn is, for tagging. @@ -1544,14 +1546,15 @@ impl Exporter<'_> { /// Where a bookmark is: (output page, x, y) — the page and line its start lands on, which /// in a paragraph split across pages need not be the paragraph's first page. fn bookmark(&self, name: &str) -> Option<(usize, f32, f32)> { - let marks = self.bookmarks.get_or_init(|| { - let mut m = HashMap::new(); + let (exact, folded) = self.bookmarks.get_or_init(|| { + let (mut exact, mut folded) = (HashMap::new(), HashMap::new()); for (n, pos) in self.doc.bookmarks() { - m.entry(n).or_insert(pos); + folded.entry(n.to_ascii_lowercase()).or_insert_with(|| pos.clone()); + exact.entry(n).or_insert(pos); } - m + (exact, folded) }); - let pos = marks.get(name).or_else(|| marks.iter().find(|(k, _)| k.eq_ignore_ascii_case(name)).map(|(_, v)| v))?.clone(); + let pos = exact.get(name).or_else(|| folded.get(&name.to_ascii_lowercase()))?.clone(); if let Some(c) = self.lay.caret(&pos) && let Some(out) = self.out_index.get(&c.page) { diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index cc2831ad94..c5afa3b527 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -1323,6 +1323,7 @@ fn bookmark_destination_is_the_page_of_its_start() { let mut p = Paragraph::with_text("Paragraph start\u{c}Bookmark target", CharProps::default()); let at = "Paragraph start\u{c}".len(); p.insert_object(at, InlineObject::BookmarkStart { name: "target".into() }, &CharProps::default()).unwrap(); + p.insert_object(at, InlineObject::BookmarkStart { name: "First".into() }, &CharProps::default()).unwrap(); p.insert_object(0, InlineObject::BookmarkStart { name: "first".into() }, &CharProps::default()).unwrap(); d.body = vec![para_block(p)]; let lay = layout(&d, &mut LayoutCache::new(), &LayoutOptions::default()); @@ -1354,6 +1355,13 @@ fn bookmark_destination_is_the_page_of_its_start() { let (page_ci, x_ci, y_ci) = ex.bookmark("TARGET").unwrap(); assert_eq!(page_ci, 1); assert_eq!((x_ci, y_ci), (x, y)); + // An exact name wins; names that differ only in case fall back to the first in the document + // (a folded index, not a scan of a hash map in arbitrary order for every link). + assert_eq!(ex.bookmark("First").map(|b| b.0), Some(1)); + assert_eq!(ex.bookmark("FIRST").map(|b| b.0), Some(0)); + assert!(ex.bookmark("absent").is_none()); + let (exact, folded) = ex.bookmarks.get().unwrap(); + assert_eq!((exact.len(), folded.len()), (3, 2)); // Whitespace in ISO date string is parsed successfully assert!(parse_iso(" 2024-05-06T07:08:09Z \n").is_some()); From f4c9c89c642b7094e49aeecb430e41c07a18b249 Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Thu, 22 Oct 2026 18:09:57 +0100 Subject: [PATCH 108/199] #3692: PDF export finds cluster ends and clipped cells without rescans Finding 14: each glyph's text end was found by scanning the following glyphs for a larger cluster, quadratic in a run of glyphs sharing one cluster (a letter with many combining marks); cluster_ends does it in one backward pass. Finding 31: every placed line was drawn with a clone of every clipped table cell on its page (lines x cells); the page's clipped cells are now indexed once by story and path and each line takes only its own cell, the first in page order as layout picks it. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/pdf/src/lib.rs | 64 +++++++++++++++++++++++++++++++++++------ crates/pdf/src/tests.rs | 59 +++++++++++++++++++++++++++++++++++++ 2 files changed, 114 insertions(+), 9 deletions(-) diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index 4b76816eac..9781a04662 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -920,8 +920,9 @@ impl Exporter<'_> { placeholders: false, ..Default::default() }; + let clips = clipped_cells(page); for it in page.header.iter().chain(page.footer.iter()) { - let draws = self.draws(page, it, &dopts); + let draws = self.draws(page, it, &clips, &dopts); self.tagged(s, Role::Artifact(ArtifactType::Other), None, |me, s| { for d in &draws { me.draw(s, d); @@ -929,7 +930,7 @@ impl Exporter<'_> { }); } for it in &page.items { - let draws = self.draws(page, it, &dopts); + let draws = self.draws(page, it, &clips, &dopts); match it { Placed::Lines { story, path, para, .. } => { let idx = self.entry(*story, path, para); @@ -992,13 +993,14 @@ impl Exporter<'_> { } /// Display items for one placed item. - fn draws(&self, page: &Page, it: &Placed, dopts: &DisplayOptions) -> Vec { + fn draws(&self, page: &Page, it: &Placed, clips: &CellClips, dopts: &DisplayOptions) -> Vec { let mut items = vec![it.clone()]; // Lines in a cell cut off at its edge are drawn inside its clip: `page_display` finds the - // cell among the items (cells themselves draw nothing). - if matches!(it, Placed::Lines { .. }) { - let clipped = page.header.iter().chain(&page.footer).chain(&page.items).filter(|c| matches!(c, Placed::Cell { clip: true, .. })); - items.extend(clipped.cloned()); + // cell among the items (cells themselves draw nothing). Only that line's cell goes along. + if let Placed::Lines { story, path, .. } = it + && let Some(cell) = clip_cell(clips, *story, &path.0) + { + items.push(cell.clone()); } let one = Page { w: page.w, h: page.h, section: page.section, number: page.number, items, body: page.body, ..Default::default() }; page_display(self.doc, &one, dopts) @@ -1706,11 +1708,12 @@ impl Exporter<'_> { match font { Some(font) => { let upem = face.upem.max(1.0) as f32; + let ends = cluster_ends(&shaped.iter().map(|g| g.cluster).collect::>(), t.text.len()); let kg: Vec = shaped .iter() .enumerate() .map(|(i, g)| { - let end = shaped.iter().skip(i + 1).map(|n| n.cluster).find(|cl| *cl > g.cluster).unwrap_or(t.text.len()); + let end = ends.get(i).copied().unwrap_or(t.text.len()); let start = g.cluster.min(end); let x = lay.glyphs.get(i).map_or(0.0, |p| p.0); let adv = match lay.glyphs.get(i + 1) { @@ -1761,11 +1764,12 @@ impl Exporter<'_> { if font.is_none() && !self.outlined.contains(&face.id()) { return; } + let ends = cluster_ends(&shaped.iter().map(|g| g.cluster).collect::>(), text.len()); let glyphs: Vec = shaped .iter() .enumerate() .map(|(i, g)| { - let end = shaped.iter().skip(i + 1).map(|n| n.cluster).find(|cl| *cl > g.cluster).unwrap_or(text.len()); + let end = ends.get(i).copied().unwrap_or(text.len()); let start = g.cluster.min(end); KrillaGlyph::new( GlyphId::new(g.gid), @@ -1894,6 +1898,48 @@ fn path_from(b: &kurbo::BezPath) -> Option { pb.finish() } +/// A page's cells whose content is cut off at their edge, by story and content path (table, +/// row, cell), with their place in header, footer, items order (the first wins, as in layout). +type CellClips<'a> = HashMap, (usize, &'a Placed)>>; + +fn clipped_cells(page: &Page) -> CellClips<'_> { + let mut clips: CellClips = HashMap::new(); + for (order, it) in page.header.iter().chain(&page.footer).chain(&page.items).enumerate() { + if let Placed::Cell { table, row, cell, story, clip: true, .. } = it + && let (Ok(row), Ok(cell)) = (u32::try_from(*row), u32::try_from(*cell)) + { + let mut prefix = table.0.clone(); + prefix.extend([row, cell]); + clips.entry(*story).or_default().entry(prefix).or_insert((order, it)); + } + } + clips +} + +/// The clipped cell a line at `path` of `story` is in: the first one whose path starts it. A +/// lookup per prefix of the (short) path instead of passing every clipped cell with every line. +fn clip_cell<'a>(clips: &CellClips<'a>, story: StoryRef, path: &[u32]) -> Option<&'a Placed> { + let cells = clips.get(&story)?; + (0..=path.len()).filter_map(|k| cells.get(path.get(..k)?)).min_by_key(|(order, _)| *order).map(|(_, it)| *it) +} + +/// Where each glyph's text ends: the cluster (byte offset) of the first later glyph whose cluster +/// is past the glyph's own, else `len`. One backward pass keeping the later clusters that can +/// still be an answer, so a long run of glyphs sharing a cluster (combining marks) stays linear. +fn cluster_ends(clusters: &[usize], len: usize) -> Vec { + let mut ends = vec![len; clusters.len()]; + // Later clusters, nearest last, each greater than every one nearer. + let mut later: Vec = Vec::new(); + for (end, &c) in ends.iter_mut().zip(clusters).rev() { + while later.last().is_some_and(|&n| n <= c) { + later.pop(); + } + *end = later.last().copied().unwrap_or(len); + later.push(c); + } + ends +} + /// Decode a picture for embedding (validated with the `image` crate first, so a broken file /// can't fail the whole export). fn load_image(data: &Arc>) -> Option { diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index c5afa3b527..1a8a20b546 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -2426,3 +2426,62 @@ fn only_safe_link_targets_become_annotations() { .collect(); assert_eq!(uris, ["https://example.com/", "mailto:ada@example.com"]); } + +/// A glyph's text runs to the first later glyph with a cluster past its own (the end of the text +/// without one), found in one pass: a long run of glyphs sharing a cluster used to rescan the rest +/// of the run for every glyph. +#[test] +fn cluster_ends_match_the_next_larger_cluster_in_linear_time() { + let brute = |cl: &[usize], len: usize| -> Vec { + (0..cl.len()).map(|i| cl.iter().skip(i + 1).copied().find(|n| *n > cl[i]).unwrap_or(len)).collect::>() + }; + for cl in [vec![], vec![0], vec![0, 0, 3, 3, 5], vec![5, 3, 3, 0], vec![2, 0, 4, 1, 1, 6, 3], vec![0, 7, 2, 2, 9, 1]] { + assert_eq!(cluster_ends(&cl, 10), brute(&cl, 10), "{cl:?}"); + } + // One base letter and 200 000 combining marks in its cluster, then one more letter. + let mut cl = vec![0; 200_001]; + cl.push(400_002); + let start = std::time::Instant::now(); + let ends = cluster_ends(&cl, 400_004); + assert!(start.elapsed() < std::time::Duration::from_secs(1), "{:?}", start.elapsed()); + assert!(ends[..200_001].iter().all(|e| *e == 400_002)); + assert_eq!(ends.last(), Some(&400_004)); +} + +/// A line in a cell cut off at its edge is drawn with that cell's clip, found by path in an index +/// built once per page: every line used to carry a clone of every clipped cell on the page, so a +/// page of exact-height rows cost lines × cells. +#[test] +fn a_line_finds_its_clipped_cell_by_path() { + let cell = |story, table: Vec, row, cell, clip| Placed::Cell { + rect: Rect::default(), + covered: false, + table: DocPath(table), + row, + cell, + story, + clip, + }; + let mut page = Page { items: vec![cell(StoryRef::Body, vec![0], 0, 1, false), cell(StoryRef::Body, vec![2], 1, 0, true)], ..Default::default() }; + // A nested clipped cell inside the first one; the outer one comes first in the page. + page.items.push(cell(StoryRef::Body, vec![2, 1, 0, 3], 0, 0, true)); + page.footer.push(cell(StoryRef::Part(7), vec![0], 0, 0, true)); + let clips = clipped_cells(&page); + let found = |story, path: &[u32]| { + clip_cell(&clips, story, path).and_then(|c| if let Placed::Cell { table, .. } = c { Some(table.0.clone()) } else { None }) + }; + assert_eq!(found(StoryRef::Body, &[2, 1, 0, 0]), Some(vec![2])); + assert_eq!(found(StoryRef::Body, &[2, 1, 0, 3, 0, 0, 1]), Some(vec![2]), "the first cell in page order wins, as in layout"); + assert_eq!(found(StoryRef::Body, &[0, 0, 1, 0]), None, "that cell isn't clipped"); + assert_eq!(found(StoryRef::Body, &[2, 0, 0, 0]), None, "another row"); + assert_eq!(found(StoryRef::Part(7), &[0, 0, 0, 2]), Some(vec![0])); + assert_eq!(found(StoryRef::Body, &[0, 0, 0, 2]), None, "another story"); + + // 20 000 clipped cells and as many lines: a lookup each, not a pass over the cells each. + let big = Page { items: (0..20_000).map(|r| cell(StoryRef::Body, vec![0], r, 0, true)).collect(), ..Default::default() }; + let start = std::time::Instant::now(); + let clips = clipped_cells(&big); + let hits = (0..20_000u32).filter(|r| clip_cell(&clips, StoryRef::Body, &[0, *r, 0, 0]).is_some()).count(); + assert_eq!(hits, 20_000); + assert!(start.elapsed() < std::time::Duration::from_secs(1), "{:?}", start.elapsed()); +} From 1225f561876b2a2c78f6c97873e5103c800caa39 Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Fri, 23 Oct 2026 11:28:04 +0100 Subject: [PATCH 109/199] =?UTF-8?q?#3692:=20review=20=E2=80=94=20left-out?= =?UTF-8?q?=20metafile=20glyphs=20and=20over-budget=20pictures=20are=20log?= =?UTF-8?q?ged?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A metafile text run's glyphs larger than 65,536 device pixels (or of no finite size) were left out silently; the first time any are this run a warning says how many (once a run: a page is drawn on every repaint). A picture refused by decode_peak_fits is logged too, once per picture since the refusal is cached with it. The 12 bytes a pixel charged to 16-bit images is the decoder's true peak: image's decoder_to_vec allocates w×h×8 for Rgba16 and into_rgba8 copies to RGBA8 while it is alive. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01CX1hMKX28764Lrx5moxs1o --- crates/render/src/lib.rs | 42 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 9c7639f778..976fa9f581 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -237,6 +237,10 @@ fn decode_pixmap_within(bytes: &[u8], budget: u64) -> Option { (w, h, u64::from(d.color_type().bytes_per_pixel())) }; if !wordcraft_geom::decode_peak_fits(hw, hh, native, budget) { + // Logged once a picture: its refusal is cached with it (`pixmap_for`). + log::warn!( + "a {hw}×{hh} picture of {native} bytes a pixel needs more than {budget} bytes to decode: it is not drawn (in the text, a grey box takes its place)" + ); return None; } let img = reader()?.decode().ok()?.into_rgba8(); @@ -411,6 +415,7 @@ fn draw_vector_text(ctx: &mut RenderContext, t: &wordcraft_metafile::Text, tf: A ctx.set_paint(rgba_color(t.color)); let db = FontDb::global(); let canvas = kurbo::Rect::new(0.0, 0.0, f64::from(ctx.width()), f64::from(ctx.height())); + let mut oversized = 0usize; for (g, (x, y)) in shaped.iter().zip(&lay.glyphs) { let o = db.outline(&face, g.gid); let m = tf * Affine::translate((*x, *y)) * Affine::scale(k); @@ -418,17 +423,39 @@ fn draw_vector_text(ctx: &mut RenderContext, t: &wordcraft_metafile::Text, tf: A // have its outline flattened into millions of segments for nothing, so it is left out. let b = m.transform_rect_bbox(o.bounding_box()); let sane = [b.x0, b.y0, b.x1, b.y1].iter().all(|v| v.is_finite()) && b.width() <= MAX_GLYPH_PIXELS && b.height() <= MAX_GLYPH_PIXELS; - if !sane || !b.overlaps(canvas) { + if !sane { + oversized = oversized.saturating_add(1); + continue; + } + if !b.overlaps(canvas) { continue; } ctx.set_transform(m); ctx.fill_path(&o); } + note_oversized_glyphs(&OVERSIZED_GLYPHS_NOTED, oversized); } /// Largest metafile glyph drawn, in device pixels per side (four times the widest canvas). const MAX_GLYPH_PIXELS: f64 = 65_536.0; +/// Whether metafile glyphs left out for their size have been logged ([`note_oversized_glyphs`]). +static OVERSIZED_GLYPHS_NOTED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); + +/// Logs that `n` glyphs of a metafile text run were left out for being larger than +/// [`MAX_GLYPH_PIXELS`] (or of no finite size), the first time any are this run (`noted`): a +/// page is drawn again on every repaint, so a warning each time would flood the log. True when +/// this call logged it. +fn note_oversized_glyphs(noted: &std::sync::atomic::AtomicBool, n: usize) -> bool { + if n == 0 || noted.swap(true, std::sync::atomic::Ordering::Relaxed) { + return false; + } + log::warn!( + "a picture's text has {n} letter(s) larger than {MAX_GLYPH_PIXELS} pixels drawn, left out (logged once a run; later ones are left out too)" + ); + true +} + /// One device pixel in page points under `view` (its mean axis scale); 1 for a degenerate transform. fn device_pixel(view: Affine) -> f64 { let c = view.as_coeffs(); @@ -2463,6 +2490,19 @@ mod tests { let img = render_page(&d, &l.pages[0], 1.0, &RenderOptions::default()); assert!(start.elapsed() < std::time::Duration::from_secs(2), "{:?}", start.elapsed()); assert_eq!(img.width, 612); + // Leaving them out is logged (once a run, so it may have been by another test already). + assert!(OVERSIZED_GLYPHS_NOTED.load(std::sync::atomic::Ordering::Relaxed), "the left-out glyphs are logged"); + } + + /// Glyphs left out for their size are logged the first time only: a page is drawn on every + /// repaint. A run with none left out logs nothing and doesn't use up the warning. + #[test] + fn oversized_glyphs_are_logged_once_a_run() { + let noted = std::sync::atomic::AtomicBool::new(false); + assert!(!note_oversized_glyphs(¬ed, 0), "nothing left out, nothing logged"); + assert!(!noted.load(std::sync::atomic::Ordering::Relaxed)); + assert!(note_oversized_glyphs(¬ed, 4), "the first glyphs left out are logged"); + assert!(!note_oversized_glyphs(¬ed, 2), "later ones aren't"); } #[test] From 77402c4e7e9e4e9998a30c0d14746cbc5cfc8d9d Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Fri, 23 Oct 2026 16:22:44 +0100 Subject: [PATCH 110/199] =?UTF-8?q?#3692:=20review=20=E2=80=94=20pdf=20and?= =?UTF-8?q?=20render=20timing=20tests=20get=20a=20margin=20a=20busy=20CI?= =?UTF-8?q?=20machine=20can't=20break?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wall-clock bounds of 1-2 s failed on loaded runners. Each bound is now 10 s, at least 10x the fixed code's time on a loaded machine and well under the old code's on the same input: - cluster ends: a million marks (was 200,000); 30 ms now. The rescan (the test's brute) took 2.9 s at 50,000 and is quadratic: about 20 minutes at a million. - clipped-cell lookups: 100,000 cells and lines (was 20,000); 0.25 s now, a pass over the cells per line would be 10^10 steps. - metafile text with a huge font: ~20 ms now; with the size check off it ran past 4 minutes. - hostile dash patterns: milliseconds now; drawn as dashes they ran past 4 minutes. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01CX1hMKX28764Lrx5moxs1o --- crates/pdf/src/tests.rs | 29 +++++++++++++++++------------ crates/render/src/lib.rs | 6 ++++-- 2 files changed, 21 insertions(+), 14 deletions(-) diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index 1a8a20b546..4f81f9e1a9 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -2438,14 +2438,17 @@ fn cluster_ends_match_the_next_larger_cluster_in_linear_time() { for cl in [vec![], vec![0], vec![0, 0, 3, 3, 5], vec![5, 3, 3, 0], vec![2, 0, 4, 1, 1, 6, 3], vec![0, 7, 2, 2, 9, 1]] { assert_eq!(cluster_ends(&cl, 10), brute(&cl, 10), "{cl:?}"); } - // One base letter and 200 000 combining marks in its cluster, then one more letter. - let mut cl = vec![0; 200_001]; - cl.push(400_002); + // One base letter and a million combining marks in its cluster, then one more letter. + // Milliseconds; the rescan (`brute`) took 2.9 s at 50,000 and grows with the square, so + // about 20 minutes here. + let n = 1_000_000; + let mut cl = vec![0; n + 1]; + cl.push(2 * n + 2); let start = std::time::Instant::now(); - let ends = cluster_ends(&cl, 400_004); - assert!(start.elapsed() < std::time::Duration::from_secs(1), "{:?}", start.elapsed()); - assert!(ends[..200_001].iter().all(|e| *e == 400_002)); - assert_eq!(ends.last(), Some(&400_004)); + let ends = cluster_ends(&cl, 2 * n + 4); + assert!(start.elapsed() < std::time::Duration::from_secs(10), "{:?}", start.elapsed()); + assert!(ends[..=n].iter().all(|e| *e == 2 * n + 2)); + assert_eq!(ends.last(), Some(&(2 * n + 4))); } /// A line in a cell cut off at its edge is drawn with that cell's clip, found by path in an index @@ -2477,11 +2480,13 @@ fn a_line_finds_its_clipped_cell_by_path() { assert_eq!(found(StoryRef::Part(7), &[0, 0, 0, 2]), Some(vec![0])); assert_eq!(found(StoryRef::Body, &[0, 0, 0, 2]), None, "another story"); - // 20 000 clipped cells and as many lines: a lookup each, not a pass over the cells each. - let big = Page { items: (0..20_000).map(|r| cell(StoryRef::Body, vec![0], r, 0, true)).collect(), ..Default::default() }; + // 100 000 clipped cells and as many lines: a lookup each, not a pass over the cells each + // (10^10 steps, minutes; the lookups take well under a second on a loaded machine). + let n = 100_000; + let big = Page { items: (0..n).map(|r| cell(StoryRef::Body, vec![0], r, 0, true)).collect(), ..Default::default() }; let start = std::time::Instant::now(); let clips = clipped_cells(&big); - let hits = (0..20_000u32).filter(|r| clip_cell(&clips, StoryRef::Body, &[0, *r, 0, 0]).is_some()).count(); - assert_eq!(hits, 20_000); - assert!(start.elapsed() < std::time::Duration::from_secs(1), "{:?}", start.elapsed()); + let hits = (0..n as u32).filter(|r| clip_cell(&clips, StoryRef::Body, &[0, *r, 0, 0]).is_some()).count(); + assert_eq!(hits, n); + assert!(start.elapsed() < std::time::Duration::from_secs(10), "{:?}", start.elapsed()); } diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 976fa9f581..ba31ca3b41 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -2488,7 +2488,8 @@ mod tests { let l = layout(&d, &mut LayoutCache::new(), &LayoutOptions::default()); let start = std::time::Instant::now(); let img = render_page(&d, &l.pages[0], 1.0, &RenderOptions::default()); - assert!(start.elapsed() < std::time::Duration::from_secs(2), "{:?}", start.elapsed()); + // About 20 ms without the huge glyphs; drawing them ran past 4 minutes without finishing. + assert!(start.elapsed() < std::time::Duration::from_secs(10), "{:?}", start.elapsed()); assert_eq!(img.width, 612); // Leaving them out is logged (once a run, so it may have been by another test already). assert!(OVERSIZED_GLYPHS_NOTED.load(std::sync::atomic::Ordering::Relaxed), "the left-out glyphs are logged"); @@ -2675,7 +2676,8 @@ mod tests { let img = render_items(&Document::new(), &[line(dash.clone())], 0.0, 0.0, 100.0, 40.0, 1.0, &RenderOptions::default()); assert!(img.pixel(50, 20)[3] > 200, "{dash:?} draws solid"); } - assert!(start.elapsed() < std::time::Duration::from_secs(2), "{:?}", start.elapsed()); + // Milliseconds drawn solid; drawn as dashes they ran past 4 minutes without finishing. + assert!(start.elapsed() < std::time::Duration::from_secs(10), "{:?}", start.elapsed()); let dashed = render_items(&Document::new(), &[line(vec![10.0, 10.0])], 0.0, 0.0, 100.0, 40.0, 1.0, &RenderOptions::default()); assert!(dashed.pixel(15, 20)[3] > 200 && dashed.pixel(25, 20)[3] == 0, "a normal pattern dashes"); assert!(safe_dashes([3.0, 1.0], 1000.0).len() == 2); From b7403f41adeb6387a191afd224640c3a4093f3aa Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Mon, 26 Oct 2026 00:01:30 +0000 Subject: [PATCH 111/199] =?UTF-8?q?#3692:=20rebase=20onto=20main=20?= =?UTF-8?q?=E2=80=94=20rustfmt=20after=20the=20merge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- crates/render/src/lib.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index ba31ca3b41..41f772ae20 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -1159,7 +1159,11 @@ fn draw(ctx: &mut RenderContext, doc: &Document, it: &Draw, view: Affine, visibl // Width 0 (or none) is a hairline. let w = if stroke_width.is_finite() && *stroke_width > 0.0 { stroke_width.clamp(0.25, 200.0) } else { 0.75 }; ctx.set_paint(color(opts.ink(*s), 1.0)); - ctx.set_stroke(kurbo::Stroke::new(w as f64).with_caps(screen_cap(*cap)).with_dashes(0.0, safe_dashes(dash.iter().map(|v| f64::from(*v)), path_len_bound(&path)))); + ctx.set_stroke( + kurbo::Stroke::new(w as f64) + .with_caps(screen_cap(*cap)) + .with_dashes(0.0, safe_dashes(dash.iter().map(|v| f64::from(*v)), path_len_bound(&path))), + ); ctx.stroke_path(&path); } } From 91c030bc9f8c8cb3a9e43a004c9d0726a425cf99 Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Mon, 26 Oct 2026 00:25:08 +0000 Subject: [PATCH 112/199] =?UTF-8?q?#3692:=20rebase=20onto=20main=20?= =?UTF-8?q?=E2=80=94=20the=20dash=20test's=20Draw::Path=20takes=20the=20ne?= =?UTF-8?q?w=20cap=20field?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- crates/render/src/lib.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 41f772ae20..318904fbe3 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -2674,6 +2674,7 @@ mod tests { stroke: Some(Rgb(0, 0, 0)), stroke_width: 2.0, dash, + cap: Default::default(), }; let start = std::time::Instant::now(); for dash in [vec![1e-30, 1e-30], vec![1e-30], vec![-1.0, 2.0], vec![f32::NAN, 1.0], vec![f32::INFINITY, 1.0], vec![0.0, 0.0]] { From 89a64d1ef5e39cfa1a05b45b2906eb178a9ec0cf Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Thu, 22 Oct 2026 16:04:24 +0100 Subject: [PATCH 113/199] =?UTF-8?q?#3692:=20sheet=20import=20=E2=80=94=20l?= =?UTF-8?q?inear=20duplicate-header=20naming;=20ODS=20string=20attributes?= =?UTF-8?q?=20capped=20and=20over-budget=20rows=20refused=20before=20copyi?= =?UTF-8?q?ng?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Finding 40: field_names restarted every repeated header at "Name 2" and compared each candidate with every name so far (cubic). It now keeps the names in an ASCII-lowercased set and remembers each base name's next number; existing headers like "Name 2" stay collision-free and the output is unchanged. Finding 47: an ODS office:string-value bypassed MAX_CELL_CHARS and was copied for each repeated column before the text budget was checked. The attribute is now capped like text, a row stops being built (and is refused) once its text can't fit the remaining budget, and Grid::push measures what fits before cloning. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/formats/src/sheet.rs | 104 ++++++++++++++++++++++++++++++------ 1 file changed, 88 insertions(+), 16 deletions(-) diff --git a/crates/formats/src/sheet.rs b/crates/formats/src/sheet.rs index 520fca2f80..392b425c06 100644 --- a/crates/formats/src/sheet.rs +++ b/crates/formats/src/sheet.rs @@ -18,7 +18,7 @@ //! all), sheets, rows, columns, cells and string lengths are capped, repeat counts are clamped to //! what is left, and nothing recurses, so XML depth costs nothing. -use std::collections::{BTreeMap, HashMap}; +use std::collections::{BTreeMap, HashMap, HashSet}; use std::io::{Cursor, Read}; use quick_xml::XmlVersion; @@ -138,18 +138,28 @@ pub fn read_rows(bytes: &[u8], pick: Pick<'_>) -> Result<(Vec, Vec Vec { let mut out: Vec = Vec::with_capacity(row.len()); + // The names given so far, ASCII-lowercased, and per base name the next number to try: a set + // lookup per candidate, and each base resumes where it stopped, instead of rescanning every + // name from `2` for every repeat (cubic on a row of equal headers). + let mut taken: HashSet = HashSet::with_capacity(row.len()); + let mut next: HashMap = HashMap::new(); for (i, h) in row.iter().enumerate() { let base = match h.trim() { "" => format!("Column {}", i + 1), t => t.to_string(), }; - let taken = |n: &str, out: &[String]| out.iter().any(|o| o.eq_ignore_ascii_case(n)); let mut name = base.clone(); - let mut k = 2usize; - while taken(&name, &out) { - name = format!("{base} {k}"); - k = k.saturating_add(1); + if taken.contains(&name.to_ascii_lowercase()) { + let k = next.entry(base.to_ascii_lowercase()).or_insert(2); + loop { + name = format!("{base} {k}"); + *k = k.saturating_add(1); + if !taken.contains(&name.to_ascii_lowercase()) { + break; + } + } } + taken.insert(name.to_ascii_lowercase()); out.push(name); } out @@ -350,14 +360,14 @@ impl Grid { } let mut left = times; while left > 0 && !self.full() { - let room = MAX_CELLS.saturating_sub(self.cells); - let mut r = row.clone(); - r.truncate(room); + // What fits is measured on `row` itself; only a copy that will be kept is made. + let keep = row.len().min(MAX_CELLS.saturating_sub(self.cells)); + let r = row.get(..keep).unwrap_or_default(); if !self.text.take(r.iter().map(String::len).sum()) { return; } self.cells = self.cells.saturating_add(r.len()); - self.rows.push(r); + self.rows.push(r.to_vec()); left -= 1; } } @@ -854,7 +864,9 @@ fn ods_read(xml: &[u8], want: Option) -> Result<(Vec, Vec, u64, u64)> = None; // cells, pending blank cells, repeat + // Cells, pending blank cells, repeat, and the cells' text bytes (`None` once the row is over + // the text budget and so refused). + let mut row: Option<(Vec, u64, u64, Option)> = None; let mut cell: Option = None; let (mut in_p, mut skip) = (0usize, 0usize); walk(xml, |ev| match ev { @@ -875,7 +887,7 @@ fn ods_read(xml: &[u8], want: Option) -> Result<(Vec, Vec { let repeat = attr(e, b"number-rows-repeated").and_then(|v| v.trim().parse().ok()).unwrap_or(1u64).max(1); - row = Some((Vec::new(), 0, repeat)); + row = Some((Vec::new(), 0, repeat, Some(0))); } b"table-cell" | b"covered-table-cell" if row.is_some() => { let repeat = attr(e, b"number-columns-repeated").and_then(|v| v.trim().parse().ok()).unwrap_or(1u64).max(1); @@ -959,7 +971,8 @@ fn ods_read(xml: &[u8], want: Option) -> Result<(Vec, Vec) -> Result<(Vec, Vec MAX_TEXT_BYTES.saturating_sub(grid.text.0) { + cells.clear(); + *budget = None; + return; + } cells.resize(cells.len() + blanks, String::new()); *pending = 0; - let times = c.repeat.min(MAX_COLS.saturating_sub(cells.len()) as u64) as usize; cells.extend(std::iter::repeat_n(text, times)); } b"table-row" => { - if let Some((cells, _, repeat)) = row.take() { + if let Some((cells, _, repeat, Some(_))) = row.take() { grid.push(cells, repeat); } } @@ -997,7 +1018,12 @@ fn ods_value(c: &OCell) -> String { ("time", Some(v)) => iso_time(v).unwrap_or_else(|| c.text.clone()), ("boolean", Some(v)) => if v.eq_ignore_ascii_case("true") || v == "1" { "TRUE" } else { "FALSE" }.into(), ("percentage" | "currency", Some(v)) if c.text.trim().is_empty() => v.parse::().map(number).unwrap_or_else(|_| c.text.clone()), - ("string", Some(v)) if c.text.trim().is_empty() => v.to_string(), + ("string", Some(v)) if c.text.trim().is_empty() => { + // Attribute text obeys the same per-cell cap as `` text. + let mut s = String::new(); + push_capped(&mut s, v); + s + } _ => c.text.clone(), } } @@ -1204,4 +1230,50 @@ mod tests { assert_eq!(rows.len(), 1); assert_eq!(rows[0], vec!["TRUE", "World", "Name"]); } + + #[test] + fn repeated_headers_are_numbered_in_linear_time() { + // Every repeat used to restart at `Name 2` and compare each candidate with every name + // so far: cubic in the number of equal headers. + let row = vec!["Name".to_string(); 5_000]; + let t = std::time::Instant::now(); + let names = field_names(&row); + assert!(t.elapsed() < std::time::Duration::from_secs(2), "{:?}", t.elapsed()); + assert_eq!(names.len(), 5_000); + assert_eq!(names[0], "Name"); + assert_eq!(names[4_999], "Name 5000"); + // Names a header already has stay collision-free, ignoring case. + let row: Vec = ["Name", "Name 2", "Name", "name", "", "Column 5", "NAME 2"].iter().map(|s| s.to_string()).collect(); + assert_eq!(field_names(&row), ["Name", "Name 2", "Name 3", "name 4", "Column 5", "Column 5 2", "NAME 2 2"]); + } + + fn ods(rows: &str) -> Vec { + let content = format!( + r#"{rows}"# + ); + zip(&[("mimetype", "application/vnd.oasis.opendocument.spreadsheet"), ("content.xml", &content)]) + } + + #[test] + fn ods_string_attributes_are_capped_before_repeating() { + // An `office:string-value` is a cell's text: it obeys MAX_CELL_CHARS like `` + // does, so repeating it across the columns can't build a huge row first. + let long = "v".repeat(MAX_CELL_CHARS * 4); + let book = ods(&format!( + r#""# + )); + let (_, rows) = read_rows(&book, Pick::Index(0)).unwrap(); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].len(), 3); + assert!(rows[0].iter().all(|c| c.chars().count() == MAX_CELL_CHARS)); + + // A row whose repeated text alone is over the text budget is dropped as it is built + // (it was built in full, then cloned, then refused); the rows after it still count. + let wide = "€".repeat(MAX_CELL_CHARS); + let book = ods(&format!( + r#"next"# + )); + let (_, rows) = read_rows(&book, Pick::Index(0)).unwrap(); + assert_eq!(rows, vec![vec!["next".to_string()]]); + } } From 42c254c87a466f05147e1e54fcabf549d01a3a83 Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Thu, 22 Oct 2026 16:10:16 +0100 Subject: [PATCH 114/199] #3705: Para::trim_with drops the leading blank spans with one drain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Finding 74: trimming removed each leading whitespace-only inline with Vec::remove(0), shifting the rest every time. A paragraph opening with many separately formatted line breaks (HTML

…, LaTeX \\) took quadratic time on open or paste. The removable prefix is now counted and drained once; the first remaining span is trimmed as before. 200,000 pairs ran past 60 s before, now milliseconds. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/formats/src/html.rs | 12 ++++++++++++ crates/formats/src/model.rs | 38 +++++++++++++++++++++++++++++-------- 2 files changed, 42 insertions(+), 8 deletions(-) diff --git a/crates/formats/src/html.rs b/crates/formats/src/html.rs index fa6ec57997..06510d6b3e 100644 --- a/crates/formats/src/html.rs +++ b/crates/formats/src/html.rs @@ -2424,4 +2424,16 @@ mod tests { assert!(html.contains("vertical-align:middle")); assert!(html.contains("vertical-align:bottom")); } + + #[test] + fn many_formatted_leading_breaks_trim_quickly() { + // #3705: a paragraph opening with alternating bold and italic `
` was trimmed one + // span at a time with `Vec::remove(0)`. + let html = format!("

{}x

", "

".repeat(100_000)); + let t = std::time::Instant::now(); + let f = parse(&html); + assert!(t.elapsed() < std::time::Duration::from_secs(2), "{:?}", t.elapsed()); + let texts: Vec = f.blocks.iter().filter_map(|b| if let FBlock::Para(p) = b { Some(p.text()) } else { None }).collect(); + assert_eq!(texts, ["x"]); + } } diff --git a/crates/formats/src/model.rs b/crates/formats/src/model.rs index ef050223ea..46adc7fcef 100644 --- a/crates/formats/src/model.rs +++ b/crates/formats/src/model.rs @@ -239,15 +239,13 @@ impl Para { /// Remove the characters in `start` from the start of the paragraph's text and those in /// `end` from its end. pub fn trim_with(&mut self, start: &[char], end: &[char]) { - while let Some(Inline::Text(t, _)) = self.inlines.first_mut() { - let n = t.trim_start_matches(start).len(); - let cut = t.len() - n; + // The leading spans that trim to nothing go in one `drain`: removing them one at a time + // shifted the rest each time, quadratic in a run of separately formatted line breaks. + let k = self.inlines.iter().take_while(|i| matches!(i, Inline::Text(t, _) if t.trim_start_matches(start).is_empty())).count(); + self.inlines.drain(..k); + if let Some(Inline::Text(t, _)) = self.inlines.first_mut() { + let cut = t.len() - t.trim_start_matches(start).len(); t.replace_range(..cut, ""); - if t.is_empty() { - self.inlines.remove(0); - } else { - break; - } } while let Some(Inline::Text(t, _)) = self.inlines.last_mut() { let n = t.trim_end_matches(end).len(); @@ -1558,4 +1556,28 @@ mod tests { assert_eq!(t.rows[1].len(), 2); assert!(t.rows[1][0].covered); } + + #[test] + fn trim_drops_a_long_run_of_blank_spans_in_linear_time() { + // Separately formatted line breaks don't merge; each used to be removed with + // `Vec::remove(0)`, shifting all the rest (quadratic). + let mut p = Para::default(); + let (b, i) = (Fmt { bold: true, ..Default::default() }, Fmt { italic: true, ..Default::default() }); + for _ in 0..200_000 { + p.push_text("\n", &b); + p.push_text(" ", &i); + } + p.push_text(" x ", &Fmt::default()); + let t = std::time::Instant::now(); + p.trim(); + assert!(t.elapsed() < std::time::Duration::from_secs(1), "{:?}", t.elapsed()); + assert_eq!(p.inlines, vec![Inline::Text("x".into(), Fmt::default())]); + // The first span left keeps its formatting and loses only its leading blanks. + let mut p = Para::default(); + p.push_text(" \t", &b); + p.push_text(" a ", &i); + p.push_text("b ", &b); + p.trim(); + assert_eq!(p.inlines, vec![Inline::Text("a ".into(), i), Inline::Text("b".into(), b)]); + } } From f6398e29a90fd725228e0494ea6827163bc1ee83 Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Thu, 22 Oct 2026 16:11:05 +0100 Subject: [PATCH 115/199] #3714: Markdown import tracks the text before each delimiter instead of searching back for it Finding 67: when emphasis was flattened, every delimiter run searched backwards for the nearest text node to borrow its formatting, reaching the start of the paragraph when the paragraph was all delimiters (or links): quadratic. The last text node's formatting is now carried forward through the loop, and nothing is looked up for a delimiter with no literal left. The result is the same. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/formats/src/markdown.rs | 43 +++++++++++++++++++++++----------- 1 file changed, 29 insertions(+), 14 deletions(-) diff --git a/crates/formats/src/markdown.rs b/crates/formats/src/markdown.rs index e483cc928d..8274effe0e 100644 --- a/crates/formats/src/markdown.rs +++ b/crates/formats/src/markdown.rs @@ -1202,18 +1202,16 @@ fn emit(mut nodes: Vec) -> Vec { } out.push(Inline::Text(t.to_string(), f)); }; - let base_of = |nodes: &[Node], k: usize| -> Fmt { - // Delimiter literals take the formatting (link, …) of neighbouring text. - for j in (0..k).rev() { - if let Some(Node::Text(_, f)) = nodes.get(j) { - return Fmt { bold: false, italic: false, strike: false, ..f.clone() }; - } - } - Fmt::default() - }; - for (k, n) in nodes.iter().enumerate() { + // Delimiter literals take the formatting (link, …) of the text before them: the last text + // node, tracked here rather than searched for backwards from every delimiter (quadratic on + // a paragraph of delimiters). + let mut last_text: Option<&Fmt> = None; + for n in &nodes { match n { - Node::Text(t, f) => push(&mut out, t, f, em, strong, strike), + Node::Text(t, f) => { + push(&mut out, t, f, em, strong, strike); + last_text = Some(f); + } Node::Inl(Inline::Text(t, f)) => push(&mut out, t, f, em, strong, strike), Node::Inl(other) => out.push(other.clone()), Node::Delim { ch, left, opens, closes, .. } => { @@ -1224,9 +1222,11 @@ fn emit(mut nodes: Vec) -> Vec { Emph::Strike => strike = strike.saturating_sub(1), } } - let lit: String = std::iter::repeat_n(*ch, *left).collect(); - let f = base_of(&nodes, k); - push(&mut out, &lit, &f, em, strong, strike); + if *left > 0 { + let lit: String = std::iter::repeat_n(*ch, *left).collect(); + let f = last_text.map_or_else(Fmt::default, |f| Fmt { bold: false, italic: false, strike: false, ..f.clone() }); + push(&mut out, &lit, &f, em, strong, strike); + } for e in opens { match e { Emph::Em => em += 1, @@ -2152,4 +2152,19 @@ mod tests { let texts: Vec = flow.blocks.iter().filter_map(|b| if let FBlock::Para(p) = b { Some(p.text()) } else { None }).collect(); assert_eq!(texts, ["Hello world!", "After comment."]); } + + #[test] + fn a_paragraph_of_delimiters_parses_in_linear_time() { + // #3714: each delimiter's literal looked back for the text before it, to the start of + // the paragraph when there was none (quadratic). + let src = "*_".repeat(200_000); + let t = std::time::Instant::now(); + let out = inl(&src); + assert!(t.elapsed() < std::time::Duration::from_secs(2), "{:?}", t.elapsed()); + let text: String = out.iter().filter_map(|i| if let Inline::Text(t, _) = i { Some(t.as_str()) } else { None }).collect(); + assert!(text.chars().all(|c| matches!(c, '*' | '_')), "only delimiters come out"); + // Literal delimiters still take the link of the text before them. + let s = inl("[a](http://x.org) [b*](http://y.org)"); + assert!(s.iter().any(|i| matches!(i, Inline::Text(t, f) if t == "b*" && f.link.as_deref() == Some("http://y.org"))), "{s:?}"); + } } From e9fe3d29b6056e53b15a92ef4040bb020c0395ab Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Thu, 22 Oct 2026 16:12:05 +0100 Subject: [PATCH 116/199] #3692: Markdown export escapes link destinations instead of deleting characters Finding 53: link_target removed `<`, `>` and newlines from a destination after is_safe_href had accepted it, so an accepted relative target such as `java` (or `/`, a backslash that a reader would treat as an escape, and `&` that starts an entity reference, so a CommonMark reader gets back exactly the checked target; control characters, which the check ignores, are still left out. The importer now ends a `<...>` destination at the first unescaped `>`, so these round-trip. Ordinary destinations are written as before. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/formats/src/markdown.rs | 75 +++++++++++++++++++++++++++++++--- 1 file changed, 70 insertions(+), 5 deletions(-) diff --git a/crates/formats/src/markdown.rs b/crates/formats/src/markdown.rs index 8274effe0e..3617af530c 100644 --- a/crates/formats/src/markdown.rs +++ b/crates/formats/src/markdown.rs @@ -799,10 +799,18 @@ fn link_dest(s: &str, at: usize) -> Option<(String, usize)> { i += ws; let r = s.get(i..)?; let (dest, used) = if let Some(inner) = r.strip_prefix('<') { - let end = inner.find(['>', '\n'])?; - if inner.get(end..)?.starts_with('\n') { - return None; - } + // The first `>` not escaped by a backslash ends it; a line break first means no link. + let b = inner.as_bytes(); + let mut k = 0; + let end = loop { + match b.get(k)? { + b'\\' => k += 1, + b'>' => break k, + b'\n' => return None, + _ => {} + } + k += 1; + }; (inner.get(..end)?.to_string(), end + 2) } else { let mut depth = 0i32; @@ -1436,8 +1444,35 @@ fn inlines_md_impl(inlines: &[Inline], in_table: bool) -> String { out } +/// `l` as a Markdown link destination that reads back as exactly `l`, the target +/// `is_safe_href` passed. What Markdown would decode (a backslash escape, an entity reference) +/// or end the destination on (`<`, `>`) is backslash-escaped, never deleted: deleting `<` or `>` +/// could turn an accepted relative target into `javascript:…` or `//host`. Control characters +/// can't be written in a destination and are left out, as the safety check ignores them. fn link_target(l: &str) -> String { - if l.contains([' ', '(', ')', '<', '>']) { format!("<{}>", l.replace(['<', '>', '\n'], "")) } else { l.to_string() } + let l: String = l.chars().filter(|c| !c.is_ascii_control()).collect(); + let mut out = String::with_capacity(l.len() + 2); + for (i, c) in l.char_indices() { + let rest = l.get(i + c.len_utf8()..).unwrap_or(""); + match c { + '<' | '>' => out.push('\\'), + // A backslash is literal unless punctuation (or the closing `)`/`>`) follows it. + '\\' if rest.chars().next().is_none_or(|n| n.is_ascii_punctuation()) => out.push('\\'), + '&' if entity_like(rest) => out.push('\\'), + _ => {} + } + out.push(c); + } + if l.contains([' ', '(', ')', '<', '>']) { format!("<{out}>") } else { out } +} + +/// Whether `rest` (the text after a `&`) starts like an entity reference (`amp;`, `#106;`, +/// `#x6A;`), which a Markdown reader would decode. +fn entity_like(rest: &str) -> bool { + let Some(end) = within(rest, 34).find(';') else { return false }; + let body = rest.get(..end).unwrap_or(""); + let body = body.strip_prefix('#').map_or(body, |n| n.strip_prefix(['x', 'X']).unwrap_or(n)); + !body.is_empty() && body.chars().all(|c| c.is_ascii_alphanumeric()) } fn marker(m: Mark) -> &'static str { @@ -2167,4 +2202,34 @@ mod tests { let s = inl("[a](http://x.org) [b*](http://y.org)"); assert!(s.iter().any(|i| matches!(i, Inline::Text(t, f) if t == "b*" && f.link.as_deref() == Some("http://y.org"))), "{s:?}"); } + + #[test] + fn exported_link_destinations_read_back_unchanged() { + // Removing `<` and `>` from a destination after the safety check could make a relative + // target a script or network-path one (`java)"); + for l in [ + "javab c", + "javascript:alert(1)", + "javascript:x", + "&", + "javascript\\:x", + "dir\\", + "dir\\(1)\\", + "q?a=1&b=2", + ] { + let out = md(l); + let back = inl(&out); + let got: Vec<_> = back.iter().filter_map(|i| if let Inline::Text(_, f) = i { f.link.clone() } else { None }).collect(); + assert_eq!(got, [l.to_string()], "{l:?} → {out:?}"); + } + // Ordinary destinations are written as before. + assert_eq!(md("https://example.com/a?b=1&c=2"), "[x](https://example.com/a?b=1&c=2)"); + assert_eq!(md("https://example.com/a b"), "[x]()"); + assert_eq!(md("https://e.com/x\ny"), "[x](https://e.com/xy)"); + } } From faa8221bca4b6e9b68b702ea9d6e3729a75d47d4 Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Thu, 22 Oct 2026 16:13:40 +0100 Subject: [PATCH 117/199] =?UTF-8?q?#3692:=20LaTeX=20=E2=80=94=20verbatim?= =?UTF-8?q?=20delimiter=20check=20after=20control=20characters=20go;=20mul?= =?UTF-8?q?ticolumn/multirow=20unwrapping=20bounded?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Finding 57: Writer::code replaced `\end{verbatim}` in a code line and then removed control characters, so `\end{verbatim}` became a real closing delimiter and the rest of the line was compiled as TeX. Control characters are now removed first and the replacement runs on the final text. Finding 52: the table reader unwrapped every nested \multicolumn / \multirow in a cell, rescanning the remaining braced content each time (quadratic; the nesting limit didn't apply). At most MAX_NEST wrappers are unwrapped now; real tables use one or two, which read as before. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/formats/src/latex.rs | 46 +++++++++++++++++++++++++++++++++---- 1 file changed, 41 insertions(+), 5 deletions(-) diff --git a/crates/formats/src/latex.rs b/crates/formats/src/latex.rs index 10de892120..663784624e 100644 --- a/crates/formats/src/latex.rs +++ b/crates/formats/src/latex.rs @@ -243,8 +243,10 @@ impl Writer { } self.out.push_str("\\begin{verbatim}\n"); for l in lines { - // The environment ends at the first `\end{verbatim}`, so keep it from appearing. - let l: String = l.replace("\\end{verbatim}", "\\end {verbatim}").chars().filter(|c| *c == '\t' || !c.is_control()).collect(); + // The environment ends at the first `\end{verbatim}`, so keep it from appearing. The + // control characters go first: removing one afterwards could join a delimiter back up. + let l: String = l.chars().filter(|c| *c == '\t' || !c.is_control()).collect(); + let l = l.replace("\\end{verbatim}", "\\end {verbatim}"); self.out.push_str(&l); self.out.push('\n'); } @@ -2671,19 +2673,24 @@ fn table(toks: &[Tok], spec: &str, depth: usize) -> FTable { for cell_toks in r { let mut cell = Cell::default(); let mut content = trim_cell(cell_toks); - // `\multicolumn{n}{spec}{content}`, `\multirow{n}{width}{content}`. + // `\multicolumn{n}{spec}{content}`, `\multirow{n}{width}{content}`. Each wrapper + // rescans its content, so only MAX_NEST of them are unwrapped (a cell nests one or + // two); further ones stay in the content. let mut sub = Parser::new(content, depth + 1); + let mut wrappers = 0usize; loop { sub.skip_spaces(); match sub.t.get(sub.pos) { - Some(Tok::Cmd(c)) if c == "multicolumn" => { + Some(Tok::Cmd(c)) if c == "multicolumn" && wrappers < MAX_NEST => { + wrappers += 1; sub.pos += 1; cell.colspan = sub.arg_raw().trim().parse::().unwrap_or(1).clamp(1, 63); let _ = sub.arg_raw(); content = sub.arg_tokens(); sub = Parser::new(content, depth + 1); } - Some(Tok::Cmd(c)) if c == "multirow" || c == "multirow*" => { + Some(Tok::Cmd(c)) if (c == "multirow" || c == "multirow*") && wrappers < MAX_NEST => { + wrappers += 1; sub.pos += 1; let _ = sub.opt_arg(); cell.rowspan = sub.arg_raw().trim().parse::().map(|n| n.unsigned_abs().clamp(1, 1000) as u32).unwrap_or(1); @@ -3234,4 +3241,33 @@ Caf\'e na\"ive \c{c}a \v{s}\'{e} and {\bfseries grouped bold} after. let Some(Block::Table(bt_bordered)) = back_bordered.body.first().map(|b| &**b) else { panic!() }; assert!(bt_bordered.props.style.is_some(), "imported table should remain bordered"); } + + #[test] + fn verbatim_end_is_kept_out_after_control_characters_go() { + // A control character inside `\end{verbatim}` was removed after the check for it, so + // the code line closed the environment and the rest ran as TeX. + let mut d = Document::new(); + let line = "\\end{verb\u{E}atim}\\input{/etc/passwd}"; + d.body = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(line, Default::default()).styled(model::CODE_STYLE))]; + let out = export(&d); + assert_eq!(out.matches("\\end{verbatim}").count(), 1, "{out}"); + assert!(out.contains("\\end {verbatim}\\input"), "{out}"); + } + + #[test] + fn nested_multicolumn_wrappers_are_bounded() { + // Each `\multicolumn` unwrapped rescanned the rest of the cell; nested ones were all + // unwrapped, quadratic in the nesting. + let n = 50_000; + let src = format!("\\begin{{tabular}}{{l}}{}x{}\\\\\\end{{tabular}}", "\\multicolumn{1}{c}{".repeat(n), "}".repeat(n)); + let t = std::time::Instant::now(); + let f = parse(&src); + assert!(t.elapsed() < std::time::Duration::from_secs(2), "{:?}", t.elapsed()); + assert!(f.blocks.iter().any(|b| matches!(b, FBlock::Table(_)))); + // One or two wrappers, as written in real tables, are still read. + let f = parse("\\begin{tabular}{ll}\\multicolumn{2}{c}{\\multirow{2}{*}{Both}}\\\\\\end{tabular}"); + let Some(FBlock::Table(t)) = f.blocks.first() else { panic!("{:?}", f.blocks) }; + assert_eq!((t.rows[0][0].colspan, t.rows[0][0].rowspan), (2, 2)); + assert_eq!(t.rows[0][0].text(), "Both"); + } } From 79e3610cbbe4dd1581f5a0e23a77747ccde4e20b Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Thu, 22 Oct 2026 16:24:47 +0100 Subject: [PATCH 118/199] #3713: importers drop overlong font names and links; spans share them; runs' copies budgeted Findings 58 and 65. Every span deep-copied its inherited link and font strings, so one long name or Markdown definition times many spans or references expanded a small file into gigabytes. Fmt now holds both as Arc (spans and Markdown references share one copy), names over 256 bytes and links over 8 KiB are dropped at the importers (RTF, HTML, Markdown, ODT, LaTeX), and the document builder stops giving runs link and font copies past 64 MiB. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/formats/src/html.rs | 6 +-- crates/formats/src/latex.rs | 15 ++++--- crates/formats/src/markdown.rs | 21 +++++---- crates/formats/src/model.rs | 56 +++++++++++++++++++----- crates/formats/src/odt.rs | 9 ++-- crates/formats/src/rtf.rs | 22 ++++++---- crates/formats/src/tests.rs | 78 ++++++++++++++++++++++++++++++++-- 7 files changed, 162 insertions(+), 45 deletions(-) diff --git a/crates/formats/src/html.rs b/crates/formats/src/html.rs index 06510d6b3e..4970855ed4 100644 --- a/crates/formats/src/html.rs +++ b/crates/formats/src/html.rs @@ -724,7 +724,7 @@ fn apply_style(css: &str, f: &mut Fmt, align: &mut Option, preserve: &mut if model::is_mono(&fam) || fam.eq_ignore_ascii_case("monospace") { f.code = true; } else if !fam.is_empty() && !matches!(fam.to_ascii_lowercase().as_str(), "serif" | "sans-serif" | "inherit" | "initial") { - f.font = Some(fam); + f.font = model::kept_font(&fam).or(f.font.take()); } } "vertical-align" => { @@ -1226,7 +1226,7 @@ impl<'r> Builder<'r> { if model::is_mono(&fam) { fmt.code = true; } else if !fam.is_empty() { - fmt.font = Some(fam); + fmt.font = model::kept_font(&fam).or(fmt.font.take()); } } if let Some(sz) = attr(attrs, "size") { @@ -1245,7 +1245,7 @@ impl<'r> Builder<'r> { } "a" => { if let Some(h) = attr(attrs, "href").filter(|h| wordcraft_doc::link::is_safe_href(h)) { - fmt.link = Some(h.trim().to_string()); + fmt.link = model::kept_link(h.trim()); } } _ => {} diff --git a/crates/formats/src/latex.rs b/crates/formats/src/latex.rs index 663784624e..18d807fdd1 100644 --- a/crates/formats/src/latex.rs +++ b/crates/formats/src/latex.rs @@ -2335,20 +2335,21 @@ impl<'a> Parser<'a> { } } "href" => { - let url = self.arg_raw(); - self.with_arg(move |f| f.link = Some(url)); + let url = model::kept_link(&self.arg_raw()); + self.with_arg(move |f| f.link = url); } "url" | "nolinkurl" => { let url = self.arg_raw(); let mut f = self.fmt(); if name == "url" { - f.link = Some(url.clone()); + f.link = model::kept_link(&url); } self.para.push_text(&url, &f); } "hyperlink" => { let target = self.arg_raw(); - self.with_arg(move |f| f.link = Some(format!("#{target}"))); + let link = model::kept_link(&format!("#{target}")); + self.with_arg(move |f| f.link = link); } "hypertarget" => { let target = self.arg_raw(); @@ -2362,7 +2363,7 @@ impl<'a> Parser<'a> { "ref" | "eqref" | "pageref" | "autoref" | "cref" | "Cref" | "nameref" => { let l = self.arg_raw(); let mut f = self.fmt(); - f.link = Some(format!("#{l}")); + f.link = model::kept_link(&format!("#{l}")); self.para.push_text(&l, &f); } "cite" | "citep" | "citet" | "parencite" | "textcite" | "autocite" => { @@ -3168,13 +3169,13 @@ Caf\'e na\"ive \c{c}a \v{s}\'{e} and {\bfseries grouped bold} after. let FBlock::Para(p) = &flow.blocks[0] else { panic!() }; assert_eq!( p.inlines[0], - Inline::Text("Homepage".to_string(), Fmt { link: Some("https://example.com/~user/page".to_string()), ..Default::default() }) + Inline::Text("Homepage".to_string(), Fmt { link: Some("https://example.com/~user/page".into()), ..Default::default() }) ); assert_eq!( p.inlines[2], Inline::Text( "https://example.com/~user/page".to_string(), - Fmt { link: Some("https://example.com/~user/page".to_string()), ..Default::default() } + Fmt { link: Some("https://example.com/~user/page".into()), ..Default::default() } ) ); } diff --git a/crates/formats/src/markdown.rs b/crates/formats/src/markdown.rs index 3617af530c..e5346cca7e 100644 --- a/crates/formats/src/markdown.rs +++ b/crates/formats/src/markdown.rs @@ -10,6 +10,7 @@ //! Export writes the same constructs; pictures become `![alt](media-name)` placeholders. use std::collections::HashMap; +use std::sync::Arc; use wordcraft_doc::{Align, Document}; @@ -33,7 +34,8 @@ pub fn import_on(text: &str, page: &wordcraft_doc::SectionProps) -> Document { } /// Link reference definitions: the normalised label to its destination. -pub type Refs = HashMap; +/// A destination is shared by every reference to it, not copied into each (#3713). +pub type Refs = HashMap>; /// A label as CommonMark compares them: case-folded, and any run of white space one space /// (§4.7, "matches"). `[FOO bar]` and `[foo BAR]` are the same label. @@ -141,7 +143,7 @@ fn take_definitions(lines: &mut Vec) -> Refs { if starts_block && let Some((label, dest)) = link_definition(line) { // The first definition of a label wins, as CommonMark says, and definitions may // follow one another. - refs.entry(label).or_insert(dest); + refs.entry(label).or_insert(dest.into()); return false; } starts_block = is_blank(line) || atx_heading(t).is_some() || thematic_break(t); @@ -991,7 +993,7 @@ fn tokenize(s: &str, base: &Fmt, depth: usize, refs: &Refs) -> Vec { let dest = link_dest(s, close + 1); scan_budget = scan_budget.saturating_sub(dest.as_ref().map_or(s.len(), |d| d.1).saturating_sub(close)); let (dest, end) = dest?; - return Some((text.to_string(), dest, end)); + return Some((text.to_string(), Arc::from(dest), end)); } // A reference link: `[text][label]`, `[text][]` and `[text]` all take // their destination from a definition collected before parsing (#3238). @@ -1028,7 +1030,8 @@ fn tokenize(s: &str, base: &Fmt, depth: usize, refs: &Refs) -> Vec { Some((inner, dest, end)) if base.link.is_none() => { flush!(); let mut f = base.clone(); - f.link = Some(dest); + // A destination too long to keep leaves its text unlinked. + f.link = (dest.len() <= model::MAX_LINK).then_some(dest); for x in parse_inlines(&inner, &f, depth + 1, refs) { nodes.push(Node::Inl(x)); } @@ -1055,7 +1058,7 @@ fn tokenize(s: &str, base: &Fmt, depth: usize, refs: &Refs) -> Vec { if is_auto { flush!(); let mut f = base.clone(); - f.link = Some(if inner.contains('@') && !inner.contains(':') { format!("mailto:{inner}") } else { inner.to_string() }); + f.link = Some(if inner.contains('@') && !inner.contains(':') { format!("mailto:{inner}").into() } else { inner.into() }); push_text(&mut nodes, inner, &f); i += inner.len() + 2; text_start = i; @@ -1386,7 +1389,7 @@ fn inlines_md_impl(inlines: &[Inline], in_table: bool) -> String { } let mut out = String::new(); let mut stack: Vec = Vec::new(); - let mut link: Option = None; + let mut link: Option> = None; let close_all = |out: &mut String, stack: &mut Vec| { while let Some(m) = stack.pop() { out.push_str(marker(m)); @@ -1675,7 +1678,7 @@ mod tests { let s = inl("see [the *site*](http://x.org \"T\") and `a*b*` \\*lit\\* & "); let link: Vec<_> = s .iter() - .filter_map(|i| if let Inline::Text(t, f) = i { f.link.as_ref().map(|l| (t.clone(), l.clone(), f.italic)) } else { None }) + .filter_map(|i| if let Inline::Text(t, f) = i { f.link.as_ref().map(|l| (t.clone(), l.to_string(), f.italic)) } else { None }) .collect(); assert_eq!(link[0], ("the ".into(), "http://x.org".into(), false)); assert_eq!(link[1], ("site".into(), "http://x.org".into(), true)); @@ -1696,7 +1699,7 @@ mod tests { .inlines .iter() .filter_map(|x| match x { - Inline::Text(t, f) => f.link.clone().map(|l| (t.clone(), l)), + Inline::Text(t, f) => f.link.as_deref().map(|l| (t.clone(), l.to_string())), _ => None, }) .collect(), @@ -2224,7 +2227,7 @@ mod tests { ] { let out = md(l); let back = inl(&out); - let got: Vec<_> = back.iter().filter_map(|i| if let Inline::Text(_, f) = i { f.link.clone() } else { None }).collect(); + let got: Vec<_> = back.iter().filter_map(|i| if let Inline::Text(_, f) = i { f.link.as_deref().map(str::to_string) } else { None }).collect(); assert_eq!(got, [l.to_string()], "{l:?} → {out:?}"); } // Ordinary destinations are written as before. diff --git a/crates/formats/src/model.rs b/crates/formats/src/model.rs index 46adc7fcef..156269b1be 100644 --- a/crates/formats/src/model.rs +++ b/crates/formats/src/model.rs @@ -19,6 +19,26 @@ pub const MONO_FONT: &str = "Courier New"; pub const CODE_STYLE: &str = "Code"; /// Nesting limit for tables in tables (hostile input). pub const MAX_DEPTH: usize = 8; +/// Longest font name the importers keep, in bytes (as the .docx reader); a longer one (no real +/// font has one) is dropped (#3713). +pub const MAX_FONT_NAME: usize = 256; +/// Longest hyperlink target the importers keep, in bytes; a longer link is dropped and its text +/// kept (#3713). +pub const MAX_LINK: usize = 8 << 10; +/// Link and font bytes the document's runs may carry between them. A flow's spans share one +/// copy of a name ([`Fmt`]), but every run of a document owns its own: past this budget, runs +/// lose their link and font rather than multiply a small file into gigabytes (#3713). +const MAX_RUN_STRINGS: usize = 64 << 20; + +/// `name` as a span's font, unless it is longer than [`MAX_FONT_NAME`]. +pub fn kept_font(name: &str) -> Option> { + (name.len() <= MAX_FONT_NAME).then(|| name.into()) +} + +/// `target` as a span's link, unless it is longer than [`MAX_LINK`]. +pub fn kept_link(target: &str) -> Option> { + (target.len() <= MAX_LINK).then(|| target.into()) +} /// Character formatting the text formats can express. #[derive(Clone, Debug, Default, PartialEq)] @@ -30,11 +50,13 @@ pub struct Fmt { pub code: bool, pub sup: bool, pub sub: bool, - pub link: Option, + /// Shared: every span of a link holds the same target. + pub link: Option>, pub color: Option, /// Points. pub size: Option, - pub font: Option, + /// Shared, as `link`. + pub font: Option>, /// Background (highlight / shading). pub background: Option, /// Tracked insertion of this text (formats that keep revisions: RTF). @@ -429,10 +451,10 @@ pub fn char_props(f: &Fmt) -> CharProps { } else { None }, - link: f.link.clone().filter(|l| !l.is_empty()), + link: f.link.as_deref().filter(|l| !l.is_empty()).map(str::to_string), color: f.color.map(TextColor::Rgb), size: f.size.filter(|s| s.is_finite() && *s >= 1.0 && *s <= 1638.0), - font: if f.code { Some(MONO_FONT.to_string()) } else { f.font.clone().filter(|s| !s.is_empty()) }, + font: if f.code { Some(MONO_FONT.to_string()) } else { f.font.as_deref().filter(|s| !s.is_empty()).map(str::to_string) }, shading: f.background, ..Default::default() } @@ -516,6 +538,8 @@ struct Builder<'a> { doc: &'a mut Document, /// Revisions already in `doc` by (kind, author, date), so each change is added once. revs: std::collections::HashMap<(u8, RevMark), u32>, + /// Link and font bytes given to runs so far ([`MAX_RUN_STRINGS`]). + run_strings: usize, } fn rev_key(kind: RevisionKind) -> u8 { @@ -535,7 +559,7 @@ impl<'a> Builder<'a> { revs.entry((rev_key(r.kind), mark)).or_insert(i as u32); } } - Builder { doc, revs } + Builder { doc, revs, run_strings: 0 } } /// The document revision for a tracked change, added the first time it is seen. @@ -550,6 +574,17 @@ impl<'a> Builder<'a> { i } + /// `cp` as a run may keep it: without its link and font once the runs' copies of them are + /// over budget. + fn budget(&mut self, mut cp: CharProps) -> CharProps { + let n = cp.link.as_ref().map_or(0, String::len).saturating_add(cp.font.as_ref().map_or(0, String::len)); + match self.run_strings.checked_add(n).filter(|&t| t <= MAX_RUN_STRINGS) { + Some(t) => self.run_strings = t, + None => (cp.link, cp.font) = (None, None), + } + cp + } + fn para(&mut self, p: &Para, list_num: Option) -> Paragraph { let mut out = Paragraph::new(); out.props = para_props(p, list_num); @@ -575,11 +610,12 @@ impl<'a> Builder<'a> { match i { Inline::Text(t, f) => { let t = clean_text(t); - let mut cp = char_props(f); + let mut cp = self.budget(char_props(f)); cp.ins = f.ins.as_ref().map(|m| self.revision(RevisionKind::Insert, m)); cp.del = f.del.as_ref().map(|m| self.revision(RevisionKind::Delete, m)); if let Some(c) = &f.fmt_change { - cp.fmt_change = PropChange::boxed(self.revision(RevisionKind::Format, &c.mark), char_props(&c.old)); + let old = self.budget(char_props(&c.old)); + cp.fmt_change = PropChange::boxed(self.revision(RevisionKind::Format, &c.mark), old); } if p.kind == Kind::Code { // Code's font comes from its style, before a change as after it. @@ -614,7 +650,7 @@ impl<'a> Builder<'a> { } Inline::Ruby { base, ruby, fmt } => { let (base, ruby) = (clean_text(base), clean_text(ruby)); - let cp = char_props(fmt); + let cp = self.budget(char_props(fmt)); // A source without a base or without a reading (malformed markup) is just its // text: a guide that annotates nothing is no guide. if base.is_empty() || ruby.is_empty() { @@ -881,13 +917,13 @@ fn fmt_of(doc: &Document, cp: &CharProps, in_code: bool) -> Fmt { code, sup: e.vert_align == Some(VertAlign::Superscript), sub: e.vert_align == Some(VertAlign::Subscript), - link: e.link.clone(), + link: e.link.as_deref().map(Arc::from), color: match e.color { Some(TextColor::Rgb(c)) => Some(c), _ => None, }, size: e.size, - font: if code || in_code { None } else { e.shown_font().map(str::to_string) }, + font: if code || in_code { None } else { e.shown_font().map(Arc::from) }, background: e.highlight.and_then(|h| h.rgb()).or(e.shading), ins: None, del: None, diff --git a/crates/formats/src/odt.rs b/crates/formats/src/odt.rs index c73720819b..6be9608110 100644 --- a/crates/formats/src/odt.rs +++ b/crates/formats/src/odt.rs @@ -19,6 +19,7 @@ use std::collections::HashMap; use std::io::{Cursor, Read, Write}; +use std::sync::Arc; use quick_xml::XmlVersion; use quick_xml::events::{BytesStart, Event}; @@ -1034,7 +1035,7 @@ struct FmtDelta { color: Option, background: Option, size: Option, - font: Option, + font: Option>, code: Option, } @@ -1340,7 +1341,7 @@ impl Styles { if model::is_mono(&fam) || get(&a, "style:font-pitch").is_some_and(|p| p.trim().eq_ignore_ascii_case("fixed")) { d.code = Some(true); } else if !fam.is_empty() { - d.font = Some(fam); + d.font = model::kept_font(&fam); } } } @@ -1446,7 +1447,7 @@ impl Styles { /// The document's default font: the Standard paragraph style's, else the default style's. fn doc_font(&self) -> Option { - self.map.get("Standard").filter(|s| !s.auto).and_then(|s| s.fmt.font.clone()).or_else(|| self.default_font.clone()) + self.map.get("Standard").filter(|s| !s.auto).and_then(|s| s.fmt.font.as_deref().map(str::to_string)).or_else(|| self.default_font.clone()) } fn chain<'a>(&'a self, name: &'a str) -> Vec<(&'a str, &'a StyleInfo)> { @@ -2053,7 +2054,7 @@ impl Body<'_> { "a" => { let mut f = self.cur_fmt(); if let Some(h) = get(a, "xlink:href").filter(|h| !h.is_empty()) { - f.link = Some(h.to_string()); + f.link = model::kept_link(h); } if let Some(s) = get(a, "text:style-name") { f = self.styles.text_fmt(s, &f); diff --git a/crates/formats/src/rtf.rs b/crates/formats/src/rtf.rs index 280b2feae2..7b06aa082f 100644 --- a/crates/formats/src/rtf.rs +++ b/crates/formats/src/rtf.rs @@ -19,6 +19,7 @@ //! tables. Group nesting and picture sizes are capped. use std::collections::HashMap; +use std::sync::Arc; use wordcraft_doc::props::VAlign; use wordcraft_doc::{Align, Document, Rgb}; @@ -306,11 +307,13 @@ impl Writer { "\\nosupersub" }); } - let font = |f: &Fmt| if f.code { Some(model::MONO_FONT.to_string()) } else { f.font.clone() }; + fn font(f: &Fmt) -> Option<&str> { + if f.code { Some(model::MONO_FONT) } else { f.font.as_deref() } + } let old_font = font(old); if old_font != font(cur) { // No font is the document's default font, font 0 (Normal's). - let i = old_font.map_or(0, |n| self.t.font(&n)); + let i = old_font.map_or(0, |n| self.t.font(n)); ctl.push_str(&format!("\\f{i}")); } let half_points = |s: Option| s.map(|s| (s * 2.0).round().clamp(2.0, 3276.0) as i32); @@ -1106,7 +1109,7 @@ struct Reader { st: St, stack: Vec, overflow: usize, - fonts: HashMap, + fonts: HashMap>, font_cur: i32, font_name: String, /// Code pages of the fonts whose charset names one (`\fcharset204` → 1251). @@ -1398,7 +1401,9 @@ impl Reader { fn commit_font(&mut self) { let name = std::mem::take(&mut self.font_name).trim().trim_end_matches(';').trim().to_string(); - if !name.is_empty() && self.fonts.len() < 4096 { + if let Some(name) = model::kept_font(&name).filter(|n| !n.is_empty()) + && self.fonts.len() < 4096 + { self.fonts.insert(self.font_cur, name); if let Some(cp) = self.font_cp_cur.take() { self.font_cps.insert(self.font_cur, cp); @@ -1626,7 +1631,8 @@ impl Reader { for c in s.chars() { if c == ';' { self.commit_font(); - } else { + } else if self.font_name.len() <= model::MAX_FONT_NAME { + // One past the limit is enough for `commit_font` to drop the name. self.font_name.push(c); } } @@ -1646,7 +1652,7 @@ impl Reader { for c in s.chars() { if c == ';' { self.commit_style(); - } else { + } else if self.style_name.len() < model::MAX_FONT_NAME { self.style_name.push(c); } } @@ -2329,7 +2335,7 @@ impl Reader { "fldrslt" => { let link = self.st.field.and_then(|i| self.fields.get(i)).and_then(|s| hyperlink(s)); if link.is_some() { - self.st.fmt.link = link; + self.st.fmt.link = link.as_deref().and_then(model::kept_link); } } _ => {} @@ -2807,7 +2813,7 @@ fn parse_full(bytes: &[u8]) -> Result { if blocks.is_empty() { blocks.push(FBlock::Para(Para::default())); } - let default_font = r.fonts.get(&r.deff).filter(|n| !model::is_mono(n)).cloned(); + let default_font = r.fonts.get(&r.deff).filter(|n| !model::is_mono(n)).map(|n| n.to_string()); Ok(Parsed { flow: Flow { blocks, meta: r.meta }, page: r.page, last_section, default_font, headers_footers }) } diff --git a/crates/formats/src/tests.rs b/crates/formats/src/tests.rs index ca6b6f9926..5ef26d5f19 100644 --- a/crates/formats/src/tests.rs +++ b/crates/formats/src/tests.rs @@ -749,7 +749,7 @@ fn code_spans_and_links_around_backticks_read_as_before() { let flow = crate::markdown::parse("\\``e` `` a ` b `` then ``` and `c` [x `]` y](http://a.org) [p ` q](http://b.org) ``f"); let FBlock::Para(p) = &flow.blocks[0] else { panic!("{:?}", flow.blocks) }; let spans: Vec<(String, bool, Option)> = - p.inlines.iter().filter_map(|i| if let model::Inline::Text(t, f) = i { Some((t.clone(), f.code, f.link.clone())) } else { None }).collect(); + p.inlines.iter().filter_map(|i| if let model::Inline::Text(t, f) = i { Some((t.clone(), f.code, f.link.as_deref().map(str::to_string))) } else { None }).collect(); let code: Vec<&str> = spans.iter().filter(|s| s.1).map(|s| s.0.as_str()).collect(); assert_eq!(code, ["e", "a ` b", "c", "]"], "{spans:?}"); let linked = |url: &str| spans.iter().filter(|s| s.2.as_deref() == Some(url)).map(|s| s.0.as_str()).collect::(); @@ -766,7 +766,7 @@ fn latex_href_keeps_ampersand() { .iter() .filter_map(|b| if let FBlock::Para(p) = b { Some(p) } else { None }) .flat_map(|p| p.inlines.iter()) - .filter_map(|i| if let model::Inline::Text(_, f) = i { Some(f.link.clone()) } else { None }) + .filter_map(|i| if let model::Inline::Text(_, f) = i { Some(f.link.as_deref().map(str::to_string)) } else { None }) .collect(); assert_eq!(links, [Some("https://e.com/a?b=1&c=2".to_string())]); @@ -775,7 +775,7 @@ fn latex_href_keeps_ampersand() { .iter() .filter_map(|b| if let FBlock::Para(p) = b { Some(p) } else { None }) .flat_map(|p| p.inlines.iter()) - .filter_map(|i| if let model::Inline::Text(t, f) = i { Some((t.to_string(), f.link.clone())) } else { None }) + .filter_map(|i| if let model::Inline::Text(t, f) = i { Some((t.to_string(), f.link.as_deref().map(str::to_string))) } else { None }) .collect(); let url = "https://e.com/a?b=1&c=2".to_string(); assert_eq!(texts, [(url.clone(), Some(url))]); @@ -789,7 +789,7 @@ fn brackets_do_not_turn_later_links_into_text() { let links = |md: &str| { let flow = crate::markdown::parse(md); let FBlock::Para(p) = &flow.blocks[0] else { panic!("{:?}", flow.blocks) }; - let mut urls: Vec = p.inlines.iter().filter_map(|i| if let model::Inline::Text(_, f) = i { f.link.clone() } else { None }).collect(); + let mut urls: Vec = p.inlines.iter().filter_map(|i| if let model::Inline::Text(_, f) = i { f.link.as_deref().map(str::to_string) } else { None }).collect(); urls.dedup(); urls }; @@ -875,3 +875,73 @@ fn hidden_drawings_are_left_out_of_exports() { // Two pictures, one hidden: one comes out. assert_eq!(crate::html::export(&d).matches(" Vec<(String, model::Fmt)> { + flow.blocks + .iter() + .filter_map(|b| if let FBlock::Para(p) = b { Some(p) } else { None }) + .flat_map(|p| p.inlines.iter()) + .filter_map(|i| if let model::Inline::Text(t, f) = i { Some((t.clone(), f.clone())) } else { None }) + .collect() +} + +/// A font name or link target too long for any real one is dropped, its text kept: every run +/// carried its own copy, so one long name times many runs was gigabytes (#3713). +#[test] +fn overlong_font_names_and_links_are_dropped() { + let long = "x".repeat(1 << 20); + let rtf = format!("{{\\rtf1{{\\fonttbl{{\\f0 Arial;}}{{\\f1 {long};}}}}\\f1 {}}}", "{\\b a}a".repeat(1000)); + let flow = crate::rtf::parse(rtf.as_bytes()).unwrap(); + let sp = spans(&flow); + assert!(sp.iter().map(|s| s.0.len()).sum::() >= 2000); + assert!(sp.iter().all(|(_, f)| f.font.is_none()), "an overlong RTF font name is no font"); + // A real one still is. + let flow = crate::rtf::parse(b"{\\rtf1{\\fonttbl{\\f0 Arial;}{\\f1 Georgia;}}\\f1 a}").unwrap(); + assert_eq!(spans(&flow)[0].1.font.as_deref(), Some("Georgia")); + + let html = format!("

kept too

"); + let sp = spans(&crate::html::parse(&html)); + assert!(sp.iter().any(|(t, _)| t.contains("kept")) && sp.iter().any(|(t, _)| t.contains("too"))); + assert!(sp.iter().all(|(_, f)| f.font.is_none() && f.link.is_none()), "{:?}", sp.iter().map(|s| &s.0).collect::>()); + + let sp = spans(&crate::markdown::parse(&format!("[kept](https://e.com/{long})"))); + assert_eq!(sp.len(), 1); + assert_eq!((sp[0].0.as_str(), sp[0].1.link.is_none()), ("kept", true)); +} + +/// Spans of one link share its target, and so do references to one definition: a long +/// definition and many references no longer copy the destination into each (#3713). +#[test] +fn spans_share_their_link_target() { + let dest = format!("https://e.com/{}", "x".repeat(4000)); + let md = format!("{}\n\n[r]: {dest}\n", "[a][r] **[b][r]** ".repeat(10_000)); + let sp = spans(&crate::markdown::parse(&md)); + let links: Vec<_> = sp.iter().filter_map(|(_, f)| f.link.as_ref()).collect(); + assert_eq!(links.len(), 20_000); + assert!(links.iter().all(|l| Arc::ptr_eq(l, links[0])), "one copy of the destination"); + + let html = format!("{}", "aa".repeat(1000)); + let sp = spans(&crate::html::parse(&html)); + assert!(sp.len() >= 2000); + let first = sp[0].1.link.clone().unwrap(); + assert!(sp.iter().all(|(_, f)| f.link.as_ref().is_some_and(|l| Arc::ptr_eq(l, &first)))); +} + +/// The document's runs own their link and font strings; past a budget further runs keep their +/// text but lose them, so a flow of shared names can't expand into gigabytes (#3713). +#[test] +fn run_link_and_font_copies_are_budgeted() { + let link: Arc = format!("https://e.com/{}", "x".repeat(model::MAX_LINK - 20)).into(); + let mut p = model::Para::default(); + for i in 0..20_000 { + let f = model::Fmt { bold: i % 2 == 0, link: Some(link.clone()), ..Default::default() }; + p.push_text("a", &f); + } + let doc = model::to_doc(&model::Flow { blocks: vec![FBlock::Para(p)], ..Default::default() }); + let paras: Vec<_> = doc.body.iter().filter_map(|b| if let Block::Para(p) = &**b { Some(p) } else { None }).collect(); + assert_eq!(paras.iter().map(|p| p.text.len()).sum::(), 20_000, "all text kept"); + let runs: Vec<_> = paras.iter().flat_map(|p| p.runs.iter()).collect(); + let kept: usize = runs.iter().filter_map(|r| r.props.link.as_ref()).map(String::len).sum(); + assert!(kept > 0 && kept <= 64 << 20, "{kept}"); +} From 9bee82220e89abdcf35e5631db28bd9cd73af53c Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Thu, 22 Oct 2026 16:24:47 +0100 Subject: [PATCH 119/199] Findings 40, 47, 52, 53, 57, 58, 65, 67, 74, 77 (#3692, #3705, #3713, Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/file-format-parity.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index c2b352424f..2f58bcbe16 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity with Microsoft Word -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-25 · **Change:** trivial (the `.doc` reader logs what its copy budget leaves out, once a kind per document; previously trivial: repeated DOCX style IDs: save/reopen regression; previously trivial: Markdown code spans keep line-broken text literal, #3738; 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: localized TOC style ids read as the TOC styles, and a TOC in columns keeps its layout through Update Table, #3889; previously trivial: cu20we: outline caps; previously trivial: a relationship the markup names without the prefix is read as it wrote: an unqualified `id` on an element that carries a relationship names that relationship, and the writer keeps the attribute with the id the new package holds the part under; an ordinary `id` — a VML shape's, a drawing's — names no part; previously trivial: the reader takes the relationship ids the markup refers to from the relationship attributes of the main document it has already parsed, so a document with many relationships opens in the time its part takes, #3411; previously trivial: a table cell span from a `.json` file stays within 63 columns through Insert Column and Merge Cells, #3637; previously trivial: the file commands that change the document are checked on a protected document, the protection of a protected document needs an explicit stop, the session author can't change through `file.setAuthor` under protection (the CLI's `--author` uses the same protection check), and the post-check compares the settings and the metadata; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: `majorBidi`/`minorBidi` theme fonts follow the settings' theme font language, as measured in Word; previously trivial: Word signature lines are kept on save, still signable; previously trivial: Times New Roman keeps Liberation Serif under the roman-font rule; previously trivial: shape gradients and transparency, #1500; previously trivial: a folder that keeps no Unix modes, such as a NAS share mounted by GVFS, still takes a save, #3003; previously trivial: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: table style band sizes written, a style without them unbanded as in Word, #3415; previously trivial: HTML export writes ruby (furigana, phonetic guides) and HTML import reads it back, #1681; previously trivial: RTF import decodes the East Asian multibyte code pages, #1653; previously trivial: PDF export: lam-alef drawn as two glyphs at one x extracts in reading order, #2310; previously trivial: native .json stores pictures and kept parts as base64, #586 follow-up; previously trivial: ODT headers and footers read from each master page, #863; previously trivial: RTF import compares heading and code formatting with the file's stylesheet; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: Word XML and web pages saved as `.doc` open by what they hold, #2853; previously trivial: a VML shape in a header cell is always placed on the page, user-checked in Word for Mac, #1775; previously trivial: `w:sym` characters keep their symbol font when the text's font changes and are written back as `w:sym`, #2036; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: two lines in one and horizontal in vertical (`w:eastAsianLayout`) kept on save; previously trivial: PDF outline targets follow leading page breaks; previously trivial: positional tabs `w:ptab` read and kept on save, #2367; previously trivial: DOCX: mixed right-to-left runs split and marked `w:rtl` with their language, #1543; previously trivial: `w:doNotTrackMoves` round-trips and turns move tracking off; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: DOCX glossary documents (building blocks) read, kept and written; previously trivial: cached field results keep their visible run formatting; previously trivial: cached REF results retain hyperlink destinations; previously trivial: CLI conversions can explicitly refresh fields; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: a pasted HTML `
` is an indented paragraph, not the Quote style, #1270); previously trivial: every table style part round-trips, #714; previously trivial: VML freeforms read and kept on save; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: `flowChartOr` draws its circle and cross spokes, as in Word; previously trivial: RTF: Mac OS Roman and DOS text, #1653; previously trivial: VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: PDF export draws horizontal and vertical lines inside groups, #1474; previously trivial: symbol-font text exported as Unicode, #776; previously trivial: text-box margins, Resize shape to fit text and freeforms in groups, #743; previously trivial: formatted paste from the system clipboard's HTML and RTF, #687; previously trivial: independent complex-script formatting inheritance and round-trip regression tests; previously trivial: import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-25 · **Change:** trivial (the `.doc` reader logs what its copy budget leaves out, once a kind per document; importers bound hostile expansion: font names, links, ODS repeats, LaTeX wrappers, Markdown delimiters, #3692/#3713; previously trivial: repeated DOCX style IDs: save/reopen regression; previously trivial: Markdown code spans keep line-broken text literal, #3738; 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: localized TOC style ids read as the TOC styles, and a TOC in columns keeps its layout through Update Table, #3889; previously trivial: cu20we: outline caps; previously trivial: a relationship the markup names without the prefix is read as it wrote: an unqualified `id` on an element that carries a relationship names that relationship, and the writer keeps the attribute with the id the new package holds the part under; an ordinary `id` — a VML shape's, a drawing's — names no part; previously trivial: the reader takes the relationship ids the markup refers to from the relationship attributes of the main document it has already parsed, so a document with many relationships opens in the time its part takes, #3411; previously trivial: a table cell span from a `.json` file stays within 63 columns through Insert Column and Merge Cells, #3637; previously trivial: the file commands that change the document are checked on a protected document, the protection of a protected document needs an explicit stop, the session author can't change through `file.setAuthor` under protection (the CLI's `--author` uses the same protection check), and the post-check compares the settings and the metadata; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: `majorBidi`/`minorBidi` theme fonts follow the settings' theme font language, as measured in Word; previously trivial: Word signature lines are kept on save, still signable; previously trivial: Times New Roman keeps Liberation Serif under the roman-font rule; previously trivial: shape gradients and transparency, #1500; previously trivial: a folder that keeps no Unix modes, such as a NAS share mounted by GVFS, still takes a save, #3003; previously trivial: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: table style band sizes written, a style without them unbanded as in Word, #3415; previously trivial: HTML export writes ruby (furigana, phonetic guides) and HTML import reads it back, #1681; previously trivial: RTF import decodes the East Asian multibyte code pages, #1653; previously trivial: PDF export: lam-alef drawn as two glyphs at one x extracts in reading order, #2310; previously trivial: native .json stores pictures and kept parts as base64, #586 follow-up; previously trivial: ODT headers and footers read from each master page, #863; previously trivial: RTF import compares heading and code formatting with the file's stylesheet; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: Word XML and web pages saved as `.doc` open by what they hold, #2853; previously trivial: a VML shape in a header cell is always placed on the page, user-checked in Word for Mac, #1775; previously trivial: `w:sym` characters keep their symbol font when the text's font changes and are written back as `w:sym`, #2036; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: two lines in one and horizontal in vertical (`w:eastAsianLayout`) kept on save; previously trivial: PDF outline targets follow leading page breaks; previously trivial: positional tabs `w:ptab` read and kept on save, #2367; previously trivial: DOCX: mixed right-to-left runs split and marked `w:rtl` with their language, #1543; previously trivial: `w:doNotTrackMoves` round-trips and turns move tracking off; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: DOCX glossary documents (building blocks) read, kept and written; previously trivial: cached field results keep their visible run formatting; previously trivial: cached REF results retain hyperlink destinations; previously trivial: CLI conversions can explicitly refresh fields; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: a pasted HTML `
` is an indented paragraph, not the Quote style, #1270); previously trivial: every table style part round-trips, #714; previously trivial: VML freeforms read and kept on save; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: `flowChartOr` draws its circle and cross spokes, as in Word; previously trivial: RTF: Mac OS Roman and DOS text, #1653; previously trivial: VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: PDF export draws horizontal and vertical lines inside groups, #1474; previously trivial: symbol-font text exported as Unicode, #776; previously trivial: text-box margins, Resize shape to fit text and freeforms in groups, #743; previously trivial: formatted paste from the system clipboard's HTML and RTF, #687; previously trivial: independent complex-script formatting inheritance and round-trip regression tests; previously trivial: import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats Every format Word opens or saves, what WordCraft does with it, and how it's tested. Fidelity is the share of a typical real-world file's content and formatting that survives (read: shows correctly; @@ -121,7 +121,7 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen | `w:fitText` | ❌ | ❌ | East Asian layout | | Glossary document (building blocks) | ✅ | ✅ | `w:docPart` entries (name, style, gallery, category, types, behaviours, description, guid, body) are read into `Document::glossary` and offered by Insert › Quick Parts and AutoText; `save` with `"in": "document"` stores one in the file. An untouched glossary is written back byte for byte with its own parts (styles, settings, fonts…); an edited one, or one whose parts weren't kept, is written from the model as `word/glossary1.xml` with copies of the document's styles and numbering. The glossary's own styles, settings and fonts, text boxes inside entries, and the notes and comments of a selection saved into the document don't survive an edit. Tests: `crates/docx/tests/it/glossary.rs` | | `w:altChunk`, sub-documents | ❌ | ❌ | | -| Hostile input | ✅ | — | `malformed.rs`, capped allocations, never-crash standard | +| Hostile input | ✅ | — | `malformed.rs`, capped allocations, never-crash standard. Text importers drop font names over 256 bytes and links over 8 KiB, share a link or font between spans, and cap the runs' copies of them at 64 MiB (#3713) | | Document paragraph defaults (`w:pPrDefault`) | ✅ | ✅ | Omission and explicit empty defaults remain distinct across repeated saves; nonempty defaults and later default-spacing edits are written | ## Saving many content controls @@ -268,6 +268,7 @@ Repeated DOCX style IDs use the last complete definition. The original regressio | 2026-10-25 | trivial | Repeated DOCX style IDs: repeated save/reopen regression for whole-definition replacement. | | 2026-10-25 | 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`) | | 2026-10-23 | trivial | cu20we: preserve and render outline caps across DOCX/VML, screen and PDF; synthetic regressions; readiness estimates unchanged | +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-25 · **Change:** trivial (the `.doc` reader logs what its copy budget leaves out, once a kind per document; previously trivial: repeated DOCX style IDs: save/reopen regression; previously trivial: Markdown code spans keep line-broken text literal, #3738; 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: localized TOC style ids read as the TOC styles, and a TOC in columns keeps its layout through Update Table, #3889; previously trivial: cu20we: outline caps; previously trivial: a relationship the markup names without the prefix is read as it wrote: an unqualified `id` on an element that carries a relationship names that relationship, and the writer keeps the attribute with the id the new package holds the part under; an ordinary `id` — a VML shape's, a drawing's — names no part; previously trivial: the reader takes the relationship ids the markup refers to from the relationship attributes of the main document it has already parsed, so a document with many relationships opens in the time its part takes, #3411; previously trivial: a table cell span from a `.json` file stays within 63 columns through Insert Column and Merge Cells, #3637; previously trivial: the file commands that change the document are checked on a protected document, the protection of a protected document needs an explicit stop, the session author can't change through `file.setAuthor` under protection (the CLI's `--author` uses the same protection check), and the post-check compares the settings and the metadata; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: `majorBidi`/`minorBidi` theme fonts follow the settings' theme font language, as measured in Word; previously trivial: Word signature lines are kept on save, still signable; previously trivial: Times New Roman keeps Liberation Serif under the roman-font rule; previously trivial: shape gradients and transparency, #1500; previously trivial: a folder that keeps no Unix modes, such as a NAS share mounted by GVFS, still takes a save, #3003; previously trivial: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: table style band sizes written, a style without them unbanded as in Word, #3415; previously trivial: HTML export writes ruby (furigana, phonetic guides) and HTML import reads it back, #1681; previously trivial: RTF import decodes the East Asian multibyte code pages, #1653; previously trivial: PDF export: lam-alef drawn as two glyphs at one x extracts in reading order, #2310; previously trivial: native .json stores pictures and kept parts as base64, #586 follow-up; previously trivial: ODT headers and footers read from each master page, #863; previously trivial: RTF import compares heading and code formatting with the file's stylesheet; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: Word XML and web pages saved as `.doc` open by what they hold, #2853; previously trivial: a VML shape in a header cell is always placed on the page, user-checked in Word for Mac, #1775; previously trivial: `w:sym` characters keep their symbol font when the text's font changes and are written back as `w:sym`, #2036; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: two lines in one and horizontal in vertical (`w:eastAsianLayout`) kept on save; previously trivial: PDF outline targets follow leading page breaks; previously trivial: positional tabs `w:ptab` read and kept on save, #2367; previously trivial: DOCX: mixed right-to-left runs split and marked `w:rtl` with their language, #1543; previously trivial: `w:doNotTrackMoves` round-trips and turns move tracking off; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: DOCX glossary documents (building blocks) read, kept and written; previously trivial: cached field results keep their visible run formatting; previously trivial: cached REF results retain hyperlink destinations; previously trivial: CLI conversions can explicitly refresh fields; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: a pasted HTML `
` is an indented paragraph, not the Quote style, #1270); previously trivial: every table style part round-trips, #714; previously trivial: VML freeforms read and kept on save; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: `flowChartOr` draws its circle and cross spokes, as in Word; previously trivial: RTF: Mac OS Roman and DOS text, #1653; previously trivial: VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: PDF export draws horizontal and vertical lines inside groups, #1474; previously trivial: symbol-font text exported as Unicode, #776; previously trivial: text-box margins, Resize shape to fit text and freeforms in groups, #743; previously trivial: formatted paste from the system clipboard's HTML and RTF, #687; previously trivial: independent complex-script formatting inheritance and round-trip regression tests; previously trivial: import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats | 2026-10-23 | trivial | `.doc` reader logs what its copy budget leaves out (floating objects, link targets, alt text, Prm formatting), once a kind per document (#3692) | | 2026-10-22 | trivial | `.doc` reader bounds hostile files: oversized sector tables refused, per-character re-reads removed, copy and decode budgets (#3642, #3692, #3708-#3712) | | 2026-10-23 | trivial | A relationship the markup names without the prefix is read as it wrote: an unqualified `id` on an element that carries a relationship names that relationship, and the writer keeps the attribute with the id the new package holds the part under; an ordinary `id` — a VML shape's, a drawing's — names no part (#3411 follow-up) | From d8501a5aeb58a85418e70bc9a8663945c2477241 Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Thu, 22 Oct 2026 16:47:09 +0100 Subject: [PATCH 120/199] #3713: rustfmt Finding 58 follow-up: formatting only. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/formats/src/markdown.rs | 3 ++- crates/formats/src/tests.rs | 13 +++++++++---- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/crates/formats/src/markdown.rs b/crates/formats/src/markdown.rs index e5346cca7e..7970729e3d 100644 --- a/crates/formats/src/markdown.rs +++ b/crates/formats/src/markdown.rs @@ -2227,7 +2227,8 @@ mod tests { ] { let out = md(l); let back = inl(&out); - let got: Vec<_> = back.iter().filter_map(|i| if let Inline::Text(_, f) = i { f.link.as_deref().map(str::to_string) } else { None }).collect(); + let got: Vec<_> = + back.iter().filter_map(|i| if let Inline::Text(_, f) = i { f.link.as_deref().map(str::to_string) } else { None }).collect(); assert_eq!(got, [l.to_string()], "{l:?} → {out:?}"); } // Ordinary destinations are written as before. diff --git a/crates/formats/src/tests.rs b/crates/formats/src/tests.rs index 5ef26d5f19..16eb2afcac 100644 --- a/crates/formats/src/tests.rs +++ b/crates/formats/src/tests.rs @@ -748,8 +748,11 @@ fn code_spans_and_links_around_backticks_read_as_before() { // inside a code span in link text, and link text with a backtick that nothing closes. let flow = crate::markdown::parse("\\``e` `` a ` b `` then ``` and `c` [x `]` y](http://a.org) [p ` q](http://b.org) ``f"); let FBlock::Para(p) = &flow.blocks[0] else { panic!("{:?}", flow.blocks) }; - let spans: Vec<(String, bool, Option)> = - p.inlines.iter().filter_map(|i| if let model::Inline::Text(t, f) = i { Some((t.clone(), f.code, f.link.as_deref().map(str::to_string))) } else { None }).collect(); + let spans: Vec<(String, bool, Option)> = p + .inlines + .iter() + .filter_map(|i| if let model::Inline::Text(t, f) = i { Some((t.clone(), f.code, f.link.as_deref().map(str::to_string))) } else { None }) + .collect(); let code: Vec<&str> = spans.iter().filter(|s| s.1).map(|s| s.0.as_str()).collect(); assert_eq!(code, ["e", "a ` b", "c", "]"], "{spans:?}"); let linked = |url: &str| spans.iter().filter(|s| s.2.as_deref() == Some(url)).map(|s| s.0.as_str()).collect::(); @@ -789,7 +792,8 @@ fn brackets_do_not_turn_later_links_into_text() { let links = |md: &str| { let flow = crate::markdown::parse(md); let FBlock::Para(p) = &flow.blocks[0] else { panic!("{:?}", flow.blocks) }; - let mut urls: Vec = p.inlines.iter().filter_map(|i| if let model::Inline::Text(_, f) = i { f.link.as_deref().map(str::to_string) } else { None }).collect(); + let mut urls: Vec = + p.inlines.iter().filter_map(|i| if let model::Inline::Text(_, f) = i { f.link.as_deref().map(str::to_string) } else { None }).collect(); urls.dedup(); urls }; @@ -900,7 +904,8 @@ fn overlong_font_names_and_links_are_dropped() { let flow = crate::rtf::parse(b"{\\rtf1{\\fonttbl{\\f0 Arial;}{\\f1 Georgia;}}\\f1 a}").unwrap(); assert_eq!(spans(&flow)[0].1.font.as_deref(), Some("Georgia")); - let html = format!("

kept too

"); + let html = + format!("

kept too

"); let sp = spans(&crate::html::parse(&html)); assert!(sp.iter().any(|(t, _)| t.contains("kept")) && sp.iter().any(|(t, _)| t.contains("too"))); assert!(sp.iter().all(|(_, f)| f.font.is_none() && f.link.is_none()), "{:?}", sp.iter().map(|s| &s.0).collect::>()); From 582f4118764eb3981c7208f8775e1b3b519570e9 Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Thu, 22 Oct 2026 19:56:02 +0100 Subject: [PATCH 121/199] =?UTF-8?q?#3692:=20Markdown=20export=20wraps=20li?= =?UTF-8?q?nk=20destinations=20with=20any=20Unicode=20whitespace=20in=20?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit review: only an ASCII space triggered the angle brackets, but the reader ends a bare destination at any whitespace (U+00A0, U+3000), so such a target read back as plain text. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/formats/src/markdown.rs | 2 +- crates/formats/src/tests.rs | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/crates/formats/src/markdown.rs b/crates/formats/src/markdown.rs index 7970729e3d..cbf23326ab 100644 --- a/crates/formats/src/markdown.rs +++ b/crates/formats/src/markdown.rs @@ -1466,7 +1466,7 @@ fn link_target(l: &str) -> String { } out.push(c); } - if l.contains([' ', '(', ')', '<', '>']) { format!("<{out}>") } else { out } + if l.contains(|c: char| c.is_whitespace() || matches!(c, '(' | ')' | '<' | '>')) { format!("<{out}>") } else { out } } /// Whether `rest` (the text after a `&`) starts like an entity reference (`amp;`, `#106;`, diff --git a/crates/formats/src/tests.rs b/crates/formats/src/tests.rs index 16eb2afcac..dc23445347 100644 --- a/crates/formats/src/tests.rs +++ b/crates/formats/src/tests.rs @@ -867,6 +867,21 @@ fn exported_links_keep_only_safe_targets() { } } +/// A link target with any whitespace (a no-break or ideographic space too) is written in `<…>`, +/// so the reader, which ends a bare destination at the first whitespace, reads it back whole. +#[test] +fn markdown_link_targets_with_unicode_spaces_read_back() { + for l in ["https://e.com/a\u{a0}b", "https://e.com/a\u{3000}b", "https://e.com/a b"] { + let mut d = Document::new(); + let mut p = Paragraph::with_text("", CharProps::default()); + p.insert_text(0, "go", &CharProps { link: Some(l.to_string()), ..Default::default() }).unwrap(); + d.body = vec![para_block(p)]; + let md = String::from_utf8(export("md", &d).unwrap().unwrap()).unwrap(); + let sp = spans(&crate::markdown::parse(&md)); + assert_eq!(sp.iter().find(|(t, _)| t == "go").and_then(|(_, f)| f.link.as_deref()), Some(l), "md: {md}"); + } +} + #[test] fn hidden_drawings_are_left_out_of_exports() { let mut d = wordcraft_doc::Document::from_text("x"); From 41de47398a5d19da9d19d7e09e1f79619bf29b01 Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Fri, 23 Oct 2026 03:31:20 +0100 Subject: [PATCH 122/199] =?UTF-8?q?#3713:=20review=20=E2=80=94=20ODS=20rep?= =?UTF-8?q?eated=20cells=20are=20budgeted=20only=20for=20the=20copies=20th?= =?UTF-8?q?e=20grid=20can=20still=20keep?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With one cell slot left, a long value repeated a thousand times was refused for all its copies' text although Grid::push keeps only the first, which fits. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01CX1hMKX28764Lrx5moxs1o --- crates/formats/src/sheet.rs | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/crates/formats/src/sheet.rs b/crates/formats/src/sheet.rs index 392b425c06..97cf1f8680 100644 --- a/crates/formats/src/sheet.rs +++ b/crates/formats/src/sheet.rs @@ -979,9 +979,10 @@ fn ods_read(xml: &[u8], want: Option) -> Result<(Vec, Vec"#, + MAX_COLS - 1 + )); + let (_, rows) = read_rows(&book, Pick::Index(0)).unwrap(); + assert_eq!(rows.len(), full + 2); + assert_eq!(rows.last(), Some(&vec![wide])); + assert_eq!(rows.iter().map(Vec::len).sum::(), MAX_CELLS); + } } From 2c4f4669c605dd053342b2db8064b55239ae99fa Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Fri, 23 Oct 2026 11:08:38 +0100 Subject: [PATCH 123/199] =?UTF-8?q?#3713:=20review=20=E2=80=94=20importer?= =?UTF-8?q?=20limits=20that=20drop=20fonts,=20links,=20style=20names=20or?= =?UTF-8?q?=20sheet=20rows=20log=20a=20warning=20once=20per=20document?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every limit this branch added that leaves data out now says so: - font names over 256 bytes and links over 8 KiB (HTML, ODT, RTF, LaTeX, Markdown), and RTF style names cut to 256 bytes; - runs' links and fonts past the 64 MiB run-string budget; - spreadsheet rows refused for being over the text budget (ODS and Grid::push), once per sheet. model::dropped logs each kind once per imported document; each importer's entry point starts a new document with begin_import. Tests capture the warnings (crate::test_log) and check each is logged once, again for the next document, and not at all when nothing is dropped. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01CX1hMKX28764Lrx5moxs1o --- crates/formats/src/html.rs | 3 ++ crates/formats/src/latex.rs | 1 + crates/formats/src/lib.rs | 2 + crates/formats/src/markdown.rs | 5 +++ crates/formats/src/model.rs | 73 +++++++++++++++++++++++++++++++--- crates/formats/src/odt.rs | 1 + crates/formats/src/rtf.rs | 3 ++ crates/formats/src/sheet.rs | 32 ++++++++++++++- crates/formats/src/test_log.rs | 36 +++++++++++++++++ crates/formats/src/tests.rs | 39 +++++++++++++++++- 10 files changed, 188 insertions(+), 7 deletions(-) create mode 100644 crates/formats/src/test_log.rs diff --git a/crates/formats/src/html.rs b/crates/formats/src/html.rs index 4970855ed4..f1eb1693cf 100644 --- a/crates/formats/src/html.rs +++ b/crates/formats/src/html.rs @@ -1588,17 +1588,20 @@ fn collapse_ws(s: &str) -> String { /// Parse HTML into a document. pub fn import(s: &str) -> Document { + model::begin_import(); model::to_doc(&parse(s)) } /// [`import`], loading pictures that aren't `data:` URIs through `images`. pub fn import_with(s: &str, images: ImageLoader) -> Document { + model::begin_import(); model::to_doc(&parse_with(s, images)) } /// [`import_with`] onto `page` (its size and margins), which tables size to: HTML gives no page /// size, so the caller picks it (the region's paper, #1381). pub fn import_on(s: &str, images: ImageLoader, page: &wordcraft_doc::SectionProps) -> Document { + model::begin_import(); model::to_doc_in(&parse_with(s, images), page.clone()) } diff --git a/crates/formats/src/latex.rs b/crates/formats/src/latex.rs index 18d807fdd1..a79f3a9f02 100644 --- a/crates/formats/src/latex.rs +++ b/crates/formats/src/latex.rs @@ -1057,6 +1057,7 @@ fn math_seq(t: &[MTok], pos: &mut usize, depth: usize, in_opt: bool) -> String { /// Import a LaTeX source file. pub fn import(bytes: &[u8]) -> Document { + model::begin_import(); let mut d = model::to_doc(&parse(&crate::txt::decode(bytes.get(..MAX_SOURCE).unwrap_or(bytes)))); d.ensure_nonempty(); d diff --git a/crates/formats/src/lib.rs b/crates/formats/src/lib.rs index 6678b5c954..8af0d2a503 100644 --- a/crates/formats/src/lib.rs +++ b/crates/formats/src/lib.rs @@ -91,5 +91,7 @@ pub fn export_with(ext: &str, doc: &Document, opts: &ExportOptions) -> Option Document { + model::begin_import(); model::to_doc(&parse(text)) } /// [`import`] onto `page`: Markdown gives no page size (#1381). pub fn import_on(text: &str, page: &wordcraft_doc::SectionProps) -> Document { + model::begin_import(); model::to_doc_in(&parse(text), page.clone()) } @@ -1032,6 +1034,9 @@ fn tokenize(s: &str, base: &Fmt, depth: usize, refs: &Refs) -> Vec { let mut f = base.clone(); // A destination too long to keep leaves its text unlinked. f.link = (dest.len() <= model::MAX_LINK).then_some(dest); + if f.link.is_none() { + model::dropped(model::Dropped::Link); + } for x in parse_inlines(&inner, &f, depth + 1, refs) { nodes.push(Node::Inl(x)); } diff --git a/crates/formats/src/model.rs b/crates/formats/src/model.rs index 156269b1be..61ae4aea5a 100644 --- a/crates/formats/src/model.rs +++ b/crates/formats/src/model.rs @@ -30,14 +30,72 @@ pub const MAX_LINK: usize = 8 << 10; /// lose their link and font rather than multiply a small file into gigabytes (#3713). const MAX_RUN_STRINGS: usize = 64 << 20; -/// `name` as a span's font, unless it is longer than [`MAX_FONT_NAME`]. +/// `name` as a span's font, unless it is longer than [`MAX_FONT_NAME`] (then reported, see +/// [`dropped`]). pub fn kept_font(name: &str) -> Option> { - (name.len() <= MAX_FONT_NAME).then(|| name.into()) + let kept = (name.len() <= MAX_FONT_NAME).then(|| name.into()); + if kept.is_none() { + dropped(Dropped::Font); + } + kept } -/// `target` as a span's link, unless it is longer than [`MAX_LINK`]. +/// `target` as a span's link, unless it is longer than [`MAX_LINK`] (then reported, see +/// [`dropped`]). pub fn kept_link(target: &str) -> Option> { - (target.len() <= MAX_LINK).then(|| target.into()) + let kept = (target.len() <= MAX_LINK).then(|| target.into()); + if kept.is_none() { + dropped(Dropped::Link); + } + kept +} + +/// What an importer leaves out to stay within a limit (review of #3873: no limit drops data +/// without saying so). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Dropped { + /// A font name over [`MAX_FONT_NAME`]. + Font, + /// A link target over [`MAX_LINK`]. + Link, + /// Runs' links and fonts past [`MAX_RUN_STRINGS`]. + RunStrings, + /// A style name cut to [`MAX_FONT_NAME`]. + StyleName, +} + +impl Dropped { + fn message(self) -> String { + match self { + Dropped::Font => format!("import: font names longer than {MAX_FONT_NAME} bytes are dropped; that text keeps the surrounding font"), + Dropped::Link => format!("import: links longer than {MAX_LINK} bytes are dropped; their text is kept"), + Dropped::RunStrings => { + format!("import: the document's runs carry more than {MAX_RUN_STRINGS} bytes of links and font names; later runs lose them") + } + Dropped::StyleName => format!("import: style names longer than {MAX_FONT_NAME} bytes are cut short"), + } + } +} + +thread_local! { + /// The kinds of [`Dropped`] already reported for the document being imported on this thread. + /// Per thread because a document is parsed and built on one, and the parsers drop things + /// from places that have no log of their own to hand. + static REPORTED: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// Log that the importer left something of `kind` out: once per kind per imported document +/// ([`begin_import`]), not once per item. +pub(crate) fn dropped(kind: Dropped) { + let bit = 1u8 << (kind as u8); + if REPORTED.with(|r| r.replace(r.get() | bit)) & bit == 0 { + log::warn!("{}", kind.message()); + } +} + +/// Start a new document's reports: each importer's entry point calls this first. +pub(crate) fn begin_import() { + REPORTED.with(|r| r.set(0)); } /// Character formatting the text formats can express. @@ -580,7 +638,12 @@ impl<'a> Builder<'a> { let n = cp.link.as_ref().map_or(0, String::len).saturating_add(cp.font.as_ref().map_or(0, String::len)); match self.run_strings.checked_add(n).filter(|&t| t <= MAX_RUN_STRINGS) { Some(t) => self.run_strings = t, - None => (cp.link, cp.font) = (None, None), + None => { + if cp.link.is_some() || cp.font.is_some() { + dropped(Dropped::RunStrings); + } + (cp.link, cp.font) = (None, None); + } } cp } diff --git a/crates/formats/src/odt.rs b/crates/formats/src/odt.rs index 6be9608110..472507f94f 100644 --- a/crates/formats/src/odt.rs +++ b/crates/formats/src/odt.rs @@ -2627,6 +2627,7 @@ fn parse_tracked(bytes: &[u8]) -> Result<(Flow, SectionProps, Option, Opti /// Parse an `.odt` package into a document. pub fn import(bytes: &[u8]) -> Result { + model::begin_import(); let (flow, last, track, font, parts) = parse_tracked(bytes)?; let mut doc = model::to_doc(&flow); if font.is_some() { diff --git a/crates/formats/src/rtf.rs b/crates/formats/src/rtf.rs index 7b06aa082f..2a9503fbd6 100644 --- a/crates/formats/src/rtf.rs +++ b/crates/formats/src/rtf.rs @@ -1654,6 +1654,8 @@ impl Reader { self.commit_style(); } else if self.style_name.len() < model::MAX_FONT_NAME { self.style_name.push(c); + } else { + model::dropped(model::Dropped::StyleName); } } } @@ -2856,6 +2858,7 @@ fn column_grid(rows: &mut [Vec], rights: &[Vec]) -> Vec { /// Parse RTF bytes into a document. pub fn import(bytes: &[u8]) -> Result { + model::begin_import(); let Parsed { flow, last_section, default_font, headers_footers, .. } = parse_full(bytes)?; let mut doc = model::to_doc_in(&flow, last_section); // Each section's own headers and footers; those it leaves out stay linked to the previous. diff --git a/crates/formats/src/sheet.rs b/crates/formats/src/sheet.rs index 97cf1f8680..e3ff85f298 100644 --- a/crates/formats/src/sheet.rs +++ b/crates/formats/src/sheet.rs @@ -343,6 +343,8 @@ struct Grid { rows: Vec>, cells: usize, text: TextBudget, + /// A row has been left out for its text (and logged, once per sheet). + refused: bool, } impl Grid { @@ -350,6 +352,15 @@ impl Grid { self.rows.len() >= MAX_ROWS || self.cells >= MAX_CELLS || self.text.0 >= MAX_TEXT_BYTES } + /// Note that a row is left out because its text is over what is left of [`MAX_TEXT_BYTES`]: + /// logged once per sheet, not once per row. + fn refuse(&mut self) { + if !self.refused { + self.refused = true; + log::warn!("sheet: rows with more text than the {MAX_TEXT_BYTES} byte budget has left are left out of the recipient list"); + } + } + /// Keep `row` (trailing blanks trimmed) `times` times, as far as the caps allow. fn push(&mut self, mut row: Vec, times: u64) { while row.last().is_some_and(String::is_empty) { @@ -364,6 +375,7 @@ impl Grid { let keep = row.len().min(MAX_CELLS.saturating_sub(self.cells)); let r = row.get(..keep).unwrap_or_default(); if !self.text.take(r.iter().map(String::len).sum()) { + self.refuse(); return; } self.cells = self.cells.saturating_add(r.len()); @@ -989,6 +1001,7 @@ fn ods_read(xml: &[u8], want: Option) -> Result<(Vec, Vec MAX_TEXT_BYTES.saturating_sub(grid.text.0) { cells.clear(); *budget = None; + grid.refuse(); return; } cells.resize(cells.len() + blanks, String::new()); @@ -1274,8 +1287,25 @@ mod tests { let book = ods(&format!( r#"next"# )); - let (_, rows) = read_rows(&book, Pick::Index(0)).unwrap(); + let ((_, rows), warned) = crate::test_log::warnings(|| read_rows(&book, Pick::Index(0)).unwrap()); assert_eq!(rows, vec![vec!["next".to_string()]]); + // The row left out is said to be (review of #3873). + assert_eq!(warned.iter().filter(|w| w.contains("are left out of the recipient list")).count(), 1, "{warned:?}"); + } + + #[test] + fn rows_over_the_text_budget_are_logged_once_per_sheet() { + let wide = "€".repeat(MAX_CELL_CHARS); + let over = format!( + r#""# + ); + let book = ods(&format!("{over}{over}{over}next")); + let ((_, rows), warned) = crate::test_log::warnings(|| read_rows(&book, Pick::Index(0)).unwrap()); + assert_eq!(rows, vec![vec!["next".to_string()]]); + assert_eq!(warned.iter().filter(|w| w.contains("recipient list")).count(), 1, "one warning, not one per row: {warned:?}"); + let book = ods("fine"); + let (_, warned) = crate::test_log::warnings(|| read_rows(&book, Pick::Index(0)).unwrap()); + assert!(warned.is_empty(), "{warned:?}"); } #[test] diff --git a/crates/formats/src/test_log.rs b/crates/formats/src/test_log.rs new file mode 100644 index 0000000000..745ce21fe1 --- /dev/null +++ b/crates/formats/src/test_log.rs @@ -0,0 +1,36 @@ +//! Test helper: the warnings this crate logs, captured per test thread, so a test can check that +//! a limit which leaves something out says so. + +use std::cell::RefCell; + +struct Capture; + +thread_local! { + static LINES: RefCell> = const { RefCell::new(Vec::new()) }; +} + +impl log::Log for Capture { + fn enabled(&self, m: &log::Metadata) -> bool { + m.level() <= log::Level::Warn + } + + fn log(&self, r: &log::Record) { + if self.enabled(r.metadata()) { + LINES.with(|l| l.borrow_mut().push(r.args().to_string())); + } + } + + fn flush(&self) {} +} + +static CAPTURE: Capture = Capture; + +/// `f`'s result and the warnings logged on this thread while it ran. +pub(crate) fn warnings(f: impl FnOnce() -> T) -> (T, Vec) { + // Only this logger is ever installed in a test binary; a second call keeps it. + let _ = log::set_logger(&CAPTURE); + log::set_max_level(log::LevelFilter::Warn); + LINES.with(|l| l.borrow_mut().clear()); + let out = f(); + (out, LINES.with(|l| std::mem::take(&mut *l.borrow_mut()))) +} diff --git a/crates/formats/src/tests.rs b/crates/formats/src/tests.rs index dc23445347..bc2442d80a 100644 --- a/crates/formats/src/tests.rs +++ b/crates/formats/src/tests.rs @@ -958,10 +958,47 @@ fn run_link_and_font_copies_are_budgeted() { let f = model::Fmt { bold: i % 2 == 0, link: Some(link.clone()), ..Default::default() }; p.push_text("a", &f); } - let doc = model::to_doc(&model::Flow { blocks: vec![FBlock::Para(p)], ..Default::default() }); + model::begin_import(); + let (doc, warned) = crate::test_log::warnings(|| model::to_doc(&model::Flow { blocks: vec![FBlock::Para(p)], ..Default::default() })); let paras: Vec<_> = doc.body.iter().filter_map(|b| if let Block::Para(p) = &**b { Some(p) } else { None }).collect(); assert_eq!(paras.iter().map(|p| p.text.len()).sum::(), 20_000, "all text kept"); let runs: Vec<_> = paras.iter().flat_map(|p| p.runs.iter()).collect(); let kept: usize = runs.iter().filter_map(|r| r.props.link.as_ref()).map(String::len).sum(); assert!(kept > 0 && kept <= 64 << 20, "{kept}"); + // The runs that lose theirs are said to, once for the document (review of #3873). + assert_eq!(warned.iter().filter(|w| w.contains("later runs lose them")).count(), 1, "{warned:?}"); +} + +/// Every limit that leaves a font name, link or style name out says so, once per kind per +/// document rather than once per item (review of #3873; the tracker complains of silent loss). +#[test] +fn dropped_names_and_links_are_logged_once_per_document() { + let long = "x".repeat(model::MAX_LINK + 1); + let html = format!( + "

a b c

" + ); + let count = |w: &[String], what: &str| w.iter().filter(|l| l.contains(what)).count(); + for _ in 0..2 { + // Each import is its own document: the second is reported too. + let (doc, warned) = crate::test_log::warnings(|| crate::html::import(&html)); + assert!(doc.body.len() == 1, "the text is kept"); + assert_eq!(count(&warned, "font names longer"), 1, "{warned:?}"); + assert_eq!(count(&warned, "links longer"), 1, "{warned:?}"); + } + let (_, warned) = crate::test_log::warnings(|| crate::html::import("

fine

")); + assert!(warned.is_empty(), "nothing left out, nothing said: {warned:?}"); + + let md = format!("[a](https://e.com/{long}) [b](https://e.com/{long})"); + let (_, warned) = crate::test_log::warnings(|| crate::markdown::import(&md)); + assert_eq!(count(&warned, "links longer"), 1, "{warned:?}"); + + let rtf = + format!("{{\\rtf1{{\\fonttbl{{\\f0 Arial;}}{{\\f1 {long};}}{{\\f2 {long};}}}}{{\\stylesheet{{\\s1 {long};}}{{\\s2 {long};}}}}\\f1 a\\f2 b}}"); + let (_, warned) = crate::test_log::warnings(|| crate::rtf::import(rtf.as_bytes()).unwrap()); + assert_eq!(count(&warned, "font names longer"), 1, "{warned:?}"); + assert_eq!(count(&warned, "style names longer"), 1, "{warned:?}"); + + let tex = format!("\\href{{https://e.com/{long}}}{{a}} \\url{{https://e.com/{long}}}"); + let (_, warned) = crate::test_log::warnings(|| crate::latex::import(tex.as_bytes())); + assert_eq!(count(&warned, "links longer"), 1, "{warned:?}"); } From 124243b1becc8056b23c8ddb210d7fa193420f31 Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Fri, 23 Oct 2026 13:20:09 +0100 Subject: [PATCH 124/199] Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01CX1hMKX28764Lrx5moxs1o --- docs/file-format-parity.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 2f58bcbe16..1ba787f062 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -271,6 +271,7 @@ Repeated DOCX style IDs use the last complete definition. The original regressio > **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-25 · **Change:** trivial (the `.doc` reader logs what its copy budget leaves out, once a kind per document; previously trivial: repeated DOCX style IDs: save/reopen regression; previously trivial: Markdown code spans keep line-broken text literal, #3738; 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: localized TOC style ids read as the TOC styles, and a TOC in columns keeps its layout through Update Table, #3889; previously trivial: cu20we: outline caps; previously trivial: a relationship the markup names without the prefix is read as it wrote: an unqualified `id` on an element that carries a relationship names that relationship, and the writer keeps the attribute with the id the new package holds the part under; an ordinary `id` — a VML shape's, a drawing's — names no part; previously trivial: the reader takes the relationship ids the markup refers to from the relationship attributes of the main document it has already parsed, so a document with many relationships opens in the time its part takes, #3411; previously trivial: a table cell span from a `.json` file stays within 63 columns through Insert Column and Merge Cells, #3637; previously trivial: the file commands that change the document are checked on a protected document, the protection of a protected document needs an explicit stop, the session author can't change through `file.setAuthor` under protection (the CLI's `--author` uses the same protection check), and the post-check compares the settings and the metadata; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: `majorBidi`/`minorBidi` theme fonts follow the settings' theme font language, as measured in Word; previously trivial: Word signature lines are kept on save, still signable; previously trivial: Times New Roman keeps Liberation Serif under the roman-font rule; previously trivial: shape gradients and transparency, #1500; previously trivial: a folder that keeps no Unix modes, such as a NAS share mounted by GVFS, still takes a save, #3003; previously trivial: a text frame in the paragraph defaults frames nothing, #2360; previously trivial: table style band sizes written, a style without them unbanded as in Word, #3415; previously trivial: HTML export writes ruby (furigana, phonetic guides) and HTML import reads it back, #1681; previously trivial: RTF import decodes the East Asian multibyte code pages, #1653; previously trivial: PDF export: lam-alef drawn as two glyphs at one x extracts in reading order, #2310; previously trivial: native .json stores pictures and kept parts as base64, #586 follow-up; previously trivial: ODT headers and footers read from each master page, #863; previously trivial: RTF import compares heading and code formatting with the file's stylesheet; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: Word XML and web pages saved as `.doc` open by what they hold, #2853; previously trivial: a VML shape in a header cell is always placed on the page, user-checked in Word for Mac, #1775; previously trivial: `w:sym` characters keep their symbol font when the text's font changes and are written back as `w:sym`, #2036; previously trivial: RTF export writes sections (gap 19); previously trivial: ODT export writes sections (gap 19); previously trivial: two lines in one and horizontal in vertical (`w:eastAsianLayout`) kept on save; previously trivial: PDF outline targets follow leading page breaks; previously trivial: positional tabs `w:ptab` read and kept on save, #2367; previously trivial: DOCX: mixed right-to-left runs split and marked `w:rtl` with their language, #1543; previously trivial: `w:doNotTrackMoves` round-trips and turns move tracking off; previously trivial: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: DOCX glossary documents (building blocks) read, kept and written; previously trivial: cached field results keep their visible run formatting; previously trivial: cached REF results retain hyperlink destinations; previously trivial: CLI conversions can explicitly refresh fields; previously trivial: a copied equation also goes in the clipboard's MathML formats, #1297; previously trivial: a pasted HTML `
` is an indented paragraph, not the Quote style, #1270); previously trivial: every table style part round-trips, #714; previously trivial: VML freeforms read and kept on save; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: `flowChartOr` draws its circle and cross spokes, as in Word; previously trivial: RTF: Mac OS Roman and DOS text, #1653; previously trivial: VML text boxes keep their internal margins, fit to their text and vertical alignment; hidden drawings not shown; previously trivial: PDF export draws horizontal and vertical lines inside groups, #1474; previously trivial: symbol-font text exported as Unicode, #776; previously trivial: text-box margins, Resize shape to fit text and freeforms in groups, #743; previously trivial: formatted paste from the system clipboard's HTML and RTF, #687; previously trivial: independent complex-script formatting inheritance and round-trip regression tests; previously trivial: import/export fixes for TXT, RTF, ODT, HTML and LaTeX, #433–#440; previously major: first version; every format Word reads or writes, measured from the readers and writers on origin/main) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4, plus Word for Windows' extra formats | 2026-10-23 | trivial | `.doc` reader logs what its copy budget leaves out (floating objects, link targets, alt text, Prm formatting), once a kind per document (#3692) | | 2026-10-22 | trivial | `.doc` reader bounds hostile files: oversized sector tables refused, per-character re-reads removed, copy and decode budgets (#3642, #3692, #3708-#3712) | +| 2026-10-22 | trivial | Importer hardening (#3692, #3705, #3713, #3714): spreadsheet header numbering and ODS repeated cells, LaTeX wrapper nesting and verbatim ends, Markdown delimiter runs, blank-span trimming and link destinations, and font/link copies per run are bounded or escaped; normal documents unchanged | | 2026-10-23 | trivial | A relationship the markup names without the prefix is read as it wrote: an unqualified `id` on an element that carries a relationship names that relationship, and the writer keeps the attribute with the id the new package holds the part under; an ordinary `id` — a VML shape's, a drawing's — names no part (#3411 follow-up) | | 2026-10-21 | trivial | The reader takes the relationship ids the markup refers to from the relationship attributes of the main document it has already parsed, so a document with many relationships opens in the time its part takes, and an id that only appears as text is not a reference (#3411) | | 2026-10-23 | trivial | DOCX: TOC styles Word stores under ids in another language (`Sumrio1` named `toc 1`, `CabealhodoSumrio` named `TOC Heading`) read as `TOC1`…`TOC9` and `TOCHeading` unless the file uses those ids, so Update Table builds the entries in the document's own TOC styles (#3891); a TOC whose heading ends a section (entries in columns after a continuous break) is written and read as the engine's TOC, with the page-number tab at the column's edge (#3889) | From 22df7caf4deac7dfd4e43a81713b72de8898d18f Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Fri, 23 Oct 2026 14:50:14 +0100 Subject: [PATCH 125/199] =?UTF-8?q?#3692:=20review=20=E2=80=94=20formats?= =?UTF-8?q?=20timing=20tests=20get=20a=20margin=20a=20busy=20CI=20machine?= =?UTF-8?q?=20can't=20break?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit many_formatted_leading_breaks_trim_quickly failed at its 2 s bound on a machine at load ~50 (it takes about a second there). Bounds now sit at least 10x over the fixed code's time on a loaded machine and far under the old code's on the same input (measured by running the pre-fix code, capped at 400 s): - HTML leading breaks: 2 s -> 20 s; ~1 s now, old trim ran past 6 minutes. - LaTeX nested multicolumn: 2 s -> 10 s; under 1 s now, old unwrapping took 213 s. - Markdown delimiters: 2 s -> 10 s; under 1 s now, old backwards search past 6 minutes. - Para::trim blank spans: 1 s -> 10 s; 0.16 s now, old Vec::remove(0) past 6 minutes. - repeated sheet headers: 2 s -> 10 s; 20 ms now, old numbering past 6 minutes. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01CX1hMKX28764Lrx5moxs1o --- crates/formats/src/html.rs | 3 ++- crates/formats/src/latex.rs | 3 ++- crates/formats/src/markdown.rs | 3 ++- crates/formats/src/model.rs | 3 ++- crates/formats/src/sheet.rs | 3 ++- 5 files changed, 10 insertions(+), 5 deletions(-) diff --git a/crates/formats/src/html.rs b/crates/formats/src/html.rs index f1eb1693cf..a3d76578c4 100644 --- a/crates/formats/src/html.rs +++ b/crates/formats/src/html.rs @@ -2435,7 +2435,8 @@ mod tests { let html = format!("

{}x

", "

".repeat(100_000)); let t = std::time::Instant::now(); let f = parse(&html); - assert!(t.elapsed() < std::time::Duration::from_secs(2), "{:?}", t.elapsed()); + // About a second on a loaded machine; trimming one span at a time ran past 6 minutes. + assert!(t.elapsed() < std::time::Duration::from_secs(20), "{:?}", t.elapsed()); let texts: Vec = f.blocks.iter().filter_map(|b| if let FBlock::Para(p) = b { Some(p.text()) } else { None }).collect(); assert_eq!(texts, ["x"]); } diff --git a/crates/formats/src/latex.rs b/crates/formats/src/latex.rs index a79f3a9f02..d2c35f3cbb 100644 --- a/crates/formats/src/latex.rs +++ b/crates/formats/src/latex.rs @@ -3264,7 +3264,8 @@ Caf\'e na\"ive \c{c}a \v{s}\'{e} and {\bfseries grouped bold} after. let src = format!("\\begin{{tabular}}{{l}}{}x{}\\\\\\end{{tabular}}", "\\multicolumn{1}{c}{".repeat(n), "}".repeat(n)); let t = std::time::Instant::now(); let f = parse(&src); - assert!(t.elapsed() < std::time::Duration::from_secs(2), "{:?}", t.elapsed()); + // Under a second on a loaded machine; unwrapping every wrapper took 213 s. + assert!(t.elapsed() < std::time::Duration::from_secs(10), "{:?}", t.elapsed()); assert!(f.blocks.iter().any(|b| matches!(b, FBlock::Table(_)))); // One or two wrappers, as written in real tables, are still read. let f = parse("\\begin{tabular}{ll}\\multicolumn{2}{c}{\\multirow{2}{*}{Both}}\\\\\\end{tabular}"); diff --git a/crates/formats/src/markdown.rs b/crates/formats/src/markdown.rs index 56d631e7af..9196c1968a 100644 --- a/crates/formats/src/markdown.rs +++ b/crates/formats/src/markdown.rs @@ -2203,7 +2203,8 @@ mod tests { let src = "*_".repeat(200_000); let t = std::time::Instant::now(); let out = inl(&src); - assert!(t.elapsed() < std::time::Duration::from_secs(2), "{:?}", t.elapsed()); + // Under a second on a loaded machine; the backwards search ran past 6 minutes. + assert!(t.elapsed() < std::time::Duration::from_secs(10), "{:?}", t.elapsed()); let text: String = out.iter().filter_map(|i| if let Inline::Text(t, _) = i { Some(t.as_str()) } else { None }).collect(); assert!(text.chars().all(|c| matches!(c, '*' | '_')), "only delimiters come out"); // Literal delimiters still take the link of the text before them. diff --git a/crates/formats/src/model.rs b/crates/formats/src/model.rs index 61ae4aea5a..ccc1f1c0cb 100644 --- a/crates/formats/src/model.rs +++ b/crates/formats/src/model.rs @@ -1669,7 +1669,8 @@ mod tests { p.push_text(" x ", &Fmt::default()); let t = std::time::Instant::now(); p.trim(); - assert!(t.elapsed() < std::time::Duration::from_secs(1), "{:?}", t.elapsed()); + // A fraction of a second; `Vec::remove(0)` per span ran past 6 minutes. + assert!(t.elapsed() < std::time::Duration::from_secs(10), "{:?}", t.elapsed()); assert_eq!(p.inlines, vec![Inline::Text("x".into(), Fmt::default())]); // The first span left keeps its formatting and loses only its leading blanks. let mut p = Para::default(); diff --git a/crates/formats/src/sheet.rs b/crates/formats/src/sheet.rs index e3ff85f298..f7f300812e 100644 --- a/crates/formats/src/sheet.rs +++ b/crates/formats/src/sheet.rs @@ -1252,7 +1252,8 @@ mod tests { let row = vec!["Name".to_string(); 5_000]; let t = std::time::Instant::now(); let names = field_names(&row); - assert!(t.elapsed() < std::time::Duration::from_secs(2), "{:?}", t.elapsed()); + // Milliseconds; the old numbering ran past 6 minutes. + assert!(t.elapsed() < std::time::Duration::from_secs(10), "{:?}", t.elapsed()); assert_eq!(names.len(), 5_000); assert_eq!(names[0], "Name"); assert_eq!(names[4_999], "Name 5000"); From 4963e30eed6de7299b18eb7b6ca7ba302e2edca1 Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Thu, 22 Oct 2026 19:32:25 +0100 Subject: [PATCH 126/199] #3692: a row's gridAfter is bounded like the DOCX reader's Audit finding 35: Table::cols() added a deserialized row's grid_after (u32) unchecked, and layout_table sized its column vectors from it, so a small native JSON document could ask for a 16 GB grid (and overflow the sum on 32-bit wasm). Row::grid_after() now clamps to MAX_COLS as first_grid() does and the importer does; cols() sums with saturating adds; layout reads the bounded value. Test: tests_budget::a_huge_grid_after_is_bounded_like_the_docx_readers. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/doc/src/table.rs | 9 ++++++++- crates/layout/src/lib.rs | 2 ++ crates/layout/src/table.rs | 4 ++-- crates/layout/src/tests_budget.rs | 24 ++++++++++++++++++++++++ 4 files changed, 36 insertions(+), 3 deletions(-) create mode 100644 crates/layout/src/tests_budget.rs diff --git a/crates/doc/src/table.rs b/crates/doc/src/table.rs index d3c0280676..76911dd4f8 100644 --- a/crates/doc/src/table.rs +++ b/crates/doc/src/table.rs @@ -48,6 +48,12 @@ impl Row { pub fn first_grid(&self) -> usize { (self.props.grid_before as usize).min(MAX_COLS) } + + /// Grid columns `w:gridAfter` leaves empty after the row's last cell, bounded as the DOCX + /// reader bounds them (a native document can say anything). + pub fn grid_after(&self) -> usize { + (self.props.grid_after as usize).min(MAX_COLS) + } } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)] @@ -97,7 +103,8 @@ impl Table { } pub fn cols(&self) -> usize { - let row_cols = |r: &Row| r.first_grid() + r.cells.iter().map(Cell::span).sum::() + r.props.grid_after as usize; + let row_cols = + |r: &Row| r.first_grid().saturating_add(r.cells.iter().map(Cell::span).fold(0, usize::saturating_add)).saturating_add(r.grid_after()); self.grid.len().max(self.rows.iter().map(row_cols).max().unwrap_or(0)) } diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index f639078ec1..4481829906 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -4967,6 +4967,8 @@ mod tests_autospace; #[cfg(test)] mod tests_bidi; #[cfg(test)] +mod tests_budget; +#[cfg(test)] mod tests_complex; #[cfg(test)] mod tests_grid; diff --git a/crates/layout/src/table.rs b/crates/layout/src/table.rs index d2e24e8910..169eda6b9f 100644 --- a/crates/layout/src/table.rs +++ b/crates/layout/src/table.rs @@ -537,7 +537,7 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava let row_borders = row.props.exceptions.as_ref().and_then(|e| e.borders).unwrap_or_default(); // A row that skips grid columns (`w:gridBefore`/`w:gridAfter`) has its edge at its own // first and last cell. - let (first_g, last_g) = (g == row.first_grid(), g + span >= ncols.saturating_sub(row.props.grid_after as usize)); + let (first_g, last_g) = (g == row.first_grid(), g + span >= ncols.saturating_sub(row.grid_after())); let rb = look.map(|l| region_borders(&l.borders, (ri, nrows), (first_g, last_g))).unwrap_or_default(); let mut borders = Borders { top: edge(cb.top.or(if ri == 0 { row_borders.top } else { row_borders.between }).or(rb.top), ri == 0, tb.top, tb.between), @@ -1170,7 +1170,7 @@ fn column_content(ctx: &mut Ctx, t: &Table, style: Option<&TableStyleProps>, dep /// columns, width in points). Column widths make room for them like for an empty cell. fn row_gaps(row: &Row, end: usize) -> impl Iterator { let p = &row.props; - [(0, row.first_grid(), p.w_before), (end, p.grid_after as usize, p.w_after)] + [(0, row.first_grid(), p.w_before), (end, row.grid_after(), p.w_after)] .into_iter() .filter(|&(_, n, _)| n > 0) .filter_map(|(g, n, w)| w.filter(|w| w.is_finite() && *w > 0.0).map(|w| (g, n, w.min(1584.0)))) diff --git a/crates/layout/src/tests_budget.rs b/crates/layout/src/tests_budget.rs new file mode 100644 index 0000000000..c98efc3d6f --- /dev/null +++ b/crates/layout/src/tests_budget.rs @@ -0,0 +1,24 @@ +//! Hostile documents whose layout work or memory would otherwise grow far past their size: each +//! is bounded by a cap, and real documents stay well under it. + +use super::*; +use wordcraft_doc::Block; +use wordcraft_doc::table::{MAX_COLS, Table}; + +fn lay(doc: &Document) -> DocLayout { + let mut c = LayoutCache::new(); + layout(doc, &mut c, &LayoutOptions::default()) +} + +#[test] +fn a_huge_grid_after_is_bounded_like_the_docx_readers() { + // A native document can say a row leaves four billion columns empty after its cells. + let mut t = Table::new(1, 2, 468.0); + t.rows[0].props.grid_after = u32::MAX; + t.grid.clear(); + assert!(t.cols() <= 2 + MAX_COLS, "{} columns", t.cols()); + let mut d = Document::from_text(""); + d.insert_block(StoryRef::Body, &Path::top(1), Block::Table(t)).unwrap(); + let l = lay(&d); + assert!(l.pages[0].items.iter().any(|i| matches!(i, Placed::Cell { row: 0, cell: 1, .. }))); +} From 8efba59b35feafcc5115a4faeb79a4bd3f432aa4 Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Thu, 22 Oct 2026 19:33:18 +0100 Subject: [PATCH 127/199] #3692: a linked text-box chain lays its story out at most 16 widths Audit finding 7: flow() laid the head box's whole story out again at every distinct box line length in the chain and kept each layout while splitting, so a chain of many one-line boxes of different widths multiplied the story's layout work and memory by the chain length. Past MAX_FLOW_WIDTHS (16) distinct lengths the text stops: the box that would need another layout and the rest of the chain show nothing, as overflow past the last box is hidden. Work is now at most 16x the story. Test: tests_budget::a_linked_chain_lays_its_story_out_at_a_bounded_number_of_widths. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/layout/src/tests_budget.rs | 44 ++++++++++++++++++++++++++++++- crates/layout/src/textbox.rs | 11 ++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/crates/layout/src/tests_budget.rs b/crates/layout/src/tests_budget.rs index c98efc3d6f..09834fbc31 100644 --- a/crates/layout/src/tests_budget.rs +++ b/crates/layout/src/tests_budget.rs @@ -2,14 +2,56 @@ //! is bounded by a cap, and real documents stay well under it. use super::*; -use wordcraft_doc::Block; use wordcraft_doc::table::{MAX_COLS, Table}; +use wordcraft_doc::{Block, PartKind, Pos, TextBody, para_block}; fn lay(doc: &Document) -> DocLayout { let mut c = LayoutCache::new(); layout(doc, &mut c, &LayoutOptions::default()) } +/// A `w` × `h` text box showing `text`, inline at the end of body paragraph `para`; its story id. +fn text_box(d: &mut Document, para: usize, text: &str, w: f32, h: f32) -> u32 { + let id = d.add_part(PartKind::TextBox, vec![para_block(Paragraph::with_text(text, Default::default()))]); + if let Some(p) = d.parts.get_mut(&id) { + p.body = TextBody::default(); + } + let shape = InlineObject::Shape { + kind: wordcraft_doc::para::ShapeKind::TextBox, + w, + h, + fill: None, + stroke: None, + stroke_width: 1.0, + float: Default::default(), + story: Some(id), + effects: Default::default(), + freeform: None, + extra: Default::default(), + }; + let off = d.para(StoryRef::Body, &Path::top(para)).map_or(0, |p| p.len()); + d.insert_object(&Pos::body(para, off), shape, &Default::default()).unwrap(); + id +} + +#[test] +fn a_linked_chain_lays_its_story_out_at_a_bounded_number_of_widths() { + // Forty linked boxes, each a line deep and each a different width, over a long story: every + // width would lay the whole story out again. Past the cap, the chain's later boxes show nothing. + let n = 40; + let mut d = Document::from_text(&vec!["p"; n].join("\n")); + let story = "Words that run on and on through every box of the chain. ".repeat(200); + let ids: Vec = (0..n).map(|i| text_box(&mut d, i, if i == 0 { &story } else { "" }, 100.0 + 3.0 * i as f32, 28.0)).collect(); + for w in ids.windows(2) { + d.parts.get_mut(&w[0]).unwrap().body.next = Some(w[1]); + } + let l = lay(&d); + let shown = + l.pages.iter().flat_map(|p| &p.items).filter(|it| matches!(it, Placed::Lines { story: StoryRef::Part(s), .. } if *s == ids[0])).count(); + assert!(shown > 1, "the first boxes still show the story: {shown}"); + assert!(shown <= textbox::MAX_FLOW_WIDTHS, "{shown} boxes laid the story out"); +} + #[test] fn a_huge_grid_after_is_bounded_like_the_docx_readers() { // A native document can say a row leaves four billion columns empty after its cells. diff --git a/crates/layout/src/textbox.rs b/crates/layout/src/textbox.rs index c3b87f7c77..8042cb5061 100644 --- a/crates/layout/src/textbox.rs +++ b/crates/layout/src/textbox.rs @@ -208,6 +208,12 @@ struct Cut { byte: usize, } +/// Most line lengths (and text directions) a chain's story is laid out at. Each lays the whole +/// story out again and is kept while the chain is split, so a chain of boxes that each differ in +/// width would multiply its story's layout work and memory; boxes needing another one show no +/// text. Far above a real chain's few box sizes. +pub(crate) const MAX_FLOW_WIDTHS: usize = 16; + /// Split the text of `chain`'s first box over its boxes. fn flow(ctx: &mut Ctx, chain: &[u32], depth: usize) -> Vec> { let Some(&head) = chain.first() else { return Vec::new() }; @@ -236,6 +242,11 @@ fn flow(ctx: &mut Ctx, chain: &[u32], depth: usize) -> Vec> { let key = (len.to_bits(), body.vert); let items = match layouts.iter().find(|(k, _)| *k == key) { Some((_, items)) => items.clone(), + // Past the cap the text stops here: this box and the rest show none. + None if layouts.len() >= MAX_FLOW_WIDTHS => { + out.push(Vec::new()); + continue; + } None => { (ctx.counters, ctx.eq_count) = (snap.0.clone(), snap.1); let items = lay(ctx, head, &blocks, len, body.vert, depth); From 96579e6b4e48739900d9da6c8b0320a55c1cc02c Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Thu, 22 Oct 2026 19:33:55 +0100 Subject: [PATCH 128/199] #3692: repeated table header rows are capped by items, not rows Audit finding 11: place_table cloned every item of the header rows onto each page a table spills onto, but its cap (200 000) counted rows. An exact-height header row keeps all its overflowing paragraphs without growing, so one header row of thousands of items over hundreds of pages placed millions of copies. The cap now counts placed items (MAX_HEADER_ITEMS = 500 000, a row costing at least one) and the header set repeats whole or not at all; past it later pages carry no header rows and pagination still finishes. Test: tests_budget::repeated_header_items_are_capped_across_the_table. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/layout/src/lib.rs | 30 ++++++++++++++-------------- crates/layout/src/tests_budget.rs | 33 +++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 15 deletions(-) diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index 4481829906..d324770026 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -4404,6 +4404,14 @@ fn row_can_start(row: &wordcraft_doc::Row, laid: &table::RowLayout, room: f32, p table::split_row(laid, room).is_some() } +/// Header rows are re-placed on every page a table spills onto, so a table is O(header items × +/// pages) placed items — and an exact-height header row keeps all its overflowing text without +/// growing, so a few KB of document could otherwise fill memory. Once this many items (a row +/// counts at least one) have been repeated across a table, later pages carry no header rows; +/// pagination still finishes. Far above any real table (three ordinary header rows over some +/// 5 000 pages). +const MAX_HEADER_ITEMS: usize = 500_000; + fn place_table(ctx: &mut Ctx, pb: &mut PageBuilder, t: &wordcraft_doc::Table, block: usize, body_top: f32) { pb.prev = None; let width = pb.col_w(); @@ -4424,13 +4432,10 @@ fn place_table(ctx: &mut Ctx, pb: &mut PageBuilder, t: &wordcraft_doc::Table, bl // A table in the text flow starts below the floating objects in its way. below_floats(ctx, pb, pb.col_x() + tl.x, pb.col_x() + tl.x + tl.width); let header_rows: Vec = (0..t.rows.len()).take_while(|r| t.rows.get(*r).is_some_and(|row| row.props.header)).collect(); - // Header rows are re-placed on every page a table spills onto, so a table with many header - // rows that each spill onto their own page is O(header rows × pages) placed items — a few KB - // of document can otherwise fill memory. Once this many header rows have been repeated across - // the whole table, later pages carry none; pagination still finishes. Far above any real - // table (e.g. 3 header rows over 30 000 pages). - const MAX_HEADER_REPEATS: usize = 200_000; + // Items repeated so far, and what one more repeat of the header rows costs (see + // `MAX_HEADER_ITEMS`). let mut header_repeats = 0usize; + let header_cost = header_rows.iter().filter_map(|i| tl.rows.get(*i)).map(|r| r.items.len().max(1)).fold(0, usize::saturating_add); // Resolve kept chains once; nested paragraphs cannot keep their enclosing row. let mut kept: Vec<(usize, f32)> = (0..tl.rows.len()).map(|i| (i, 0.0)).collect(); for i in (0..tl.rows.len().saturating_sub(1)).rev() { @@ -4530,15 +4535,10 @@ fn place_table(ctx: &mut Ctx, pb: &mut PageBuilder, t: &wordcraft_doc::Table, bl } pb.advance(block, body_top); // Repeat header rows, up to this table's cap. - if !header_rows.contains(&ri) && header_repeats < MAX_HEADER_REPEATS { - for hr in &header_rows { - if header_repeats >= MAX_HEADER_REPEATS { - break; - } - if let Some(h) = tl.rows.get(*hr) { - place(pb, h); - header_repeats += 1; - } + if !header_rows.contains(&ri) && header_repeats.saturating_add(header_cost) <= MAX_HEADER_ITEMS { + header_repeats += header_cost; + for h in header_rows.iter().filter_map(|hr| tl.rows.get(*hr)) { + place(pb, h); } } fresh = true; diff --git a/crates/layout/src/tests_budget.rs b/crates/layout/src/tests_budget.rs index 09834fbc31..7a1c4d569a 100644 --- a/crates/layout/src/tests_budget.rs +++ b/crates/layout/src/tests_budget.rs @@ -2,6 +2,7 @@ //! is bounded by a cap, and real documents stay well under it. use super::*; +use wordcraft_doc::props::HeightRule; use wordcraft_doc::table::{MAX_COLS, Table}; use wordcraft_doc::{Block, PartKind, Pos, TextBody, para_block}; @@ -52,6 +53,38 @@ fn a_linked_chain_lays_its_story_out_at_a_bounded_number_of_widths() { assert!(shown <= textbox::MAX_FLOW_WIDTHS, "{shown} boxes laid the story out"); } +#[test] +fn repeated_header_items_are_capped_across_the_table() { + // An exact-height header row keeps thousands of overflowing paragraphs without growing, and + // repeats on each of hundreds of pages: every page would copy all of them. + let mut t = Table::new(201, 1, 468.0); + t.rows[0].props.header = true; + t.rows[0].props.height = Some(20.0); + t.rows[0].props.height_rule = HeightRule::Exact; + t.rows[0].cells[0].blocks = (0..3000).map(|_| para_block(Paragraph::with_text("x", Default::default()))).collect(); + for row in t.rows.iter_mut().skip(1) { + row.props.height = Some(600.0); + row.props.height_rule = HeightRule::Exact; + } + let mut d = Document::from_text(""); + d.insert_block(StoryRef::Body, &Path::top(1), Block::Table(t)).unwrap(); + let l = lay(&d); + assert!(l.pages.len() >= 200, "{} pages", l.pages.len()); + let items: usize = l.pages.iter().map(|p| p.items.len()).sum(); + // The first page's header and each page's body row come on top of the repeats. + assert!(items <= MAX_HEADER_ITEMS + 3000 + 50 * l.pages.len(), "{items} items placed"); + // An ordinary table's header still repeats on every page. + let mut t = Table::new(80, 2, 468.0); + t.rows[0].props.header = true; + let mut d = Document::from_text(""); + d.insert_block(StoryRef::Body, &Path::top(1), Block::Table(t)).unwrap(); + let l = lay(&d); + assert!(l.pages.len() >= 2); + for p in &l.pages { + assert!(p.items.iter().any(|i| matches!(i, Placed::Cell { row: 0, .. })), "the header repeats"); + } +} + #[test] fn a_huge_grid_after_is_bounded_like_the_docx_readers() { // A native document can say a row leaves four billion columns empty after its cells. From b578be045932eead5a55760d692b42b9711eadcc Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Thu, 22 Oct 2026 19:34:48 +0100 Subject: [PATCH 129/199] Audit finding 38: the outline cache keys on media and crop, but working out any crop's outline decoded the whole shared picture again, so many small picture references with distinct crops multiplied full-image decodes without repeating the encoded image. Layout now keeps the last picture's transparency (one alpha byte per pixel) and reuses it for every crop of it, and charges each decode's pixels against MAX_DECODED_PIXELS (2^28) per layout; past it pictures wrap as their rectangles. Tests: tests_budget::a_picture_cropped_many_ways_is_decoded_once, tests_budget::decoding_pictures_stops_at_the_layouts_budget. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/layout/src/contour.rs | 78 +++++++++++++++++++++++++++---- crates/layout/src/lib.rs | 7 ++- crates/layout/src/tests_budget.rs | 44 +++++++++++++++++ 3 files changed, 119 insertions(+), 10 deletions(-) diff --git a/crates/layout/src/contour.rs b/crates/layout/src/contour.rs index d58d0e0c8d..ce02027fbf 100644 --- a/crates/layout/src/contour.rs +++ b/crates/layout/src/contour.rs @@ -164,21 +164,81 @@ pub fn contour_area(r: Rect, float: &Float, outline: &[(f32, f32)]) -> Option<(R /// Largest picture (each side, pixels) whose transparency is looked at. const MAX_PICTURE_SIDE: u32 = 8192; -/// The outline of a picture's opaque pixels within `crop` (left, top, right, bottom fractions), -/// in unit coordinates of the cropped picture. `None` for pictures without transparency (or -/// that don't decode): their rectangle is their outline. -pub fn picture_outline(bytes: &[u8], crop: [f32; 4]) -> Option> { +/// Most picture pixels decoded for their transparency in one layout. Every picture a document +/// wraps tightly is decoded in full whatever its crop; past this, pictures wrap as their +/// rectangles. Room for four pictures of the largest size looked at, or hundreds of photos. +pub(crate) const MAX_DECODED_PIXELS: u64 = 1 << 28; + +/// A picture's transparency: one alpha byte per pixel, row by row. +struct Alpha { + w: u32, + h: u32, + a: Vec, +} + +impl Alpha { + fn opaque(&self, x: u32, y: u32) -> bool { + x < self.w + && (y as usize) + .checked_mul(self.w as usize) + .and_then(|r| r.checked_add(x as usize)) + .and_then(|i| self.a.get(i)) + .is_some_and(|a| *a >= 128) + } +} + +/// Pictures' transparency decoded during one layout. A picture shown with many crops (each an +/// outline of its own) is decoded once, and decoding stops at [`MAX_DECODED_PIXELS`]. +#[derive(Default)] +pub(crate) struct Alphas { + /// The picture decoded last: its bytes (address and length) and transparency (`None`: none). + last: Option<((usize, usize), Option>)>, + /// Pixels decoded so far. + pub(crate) decoded: u64, +} + +impl Alphas { + /// The outline of picture `bytes`'s opaque pixels within `crop` (left, top, right, bottom + /// fractions), in unit coordinates of the cropped picture. `None` for pictures without + /// transparency (or that don't decode, or past the budget): their rectangle is their outline. + pub(crate) fn outline(&mut self, bytes: &[u8], crop: [f32; 4]) -> Option> { + let key = (bytes.as_ptr() as usize, bytes.len()); + let alpha = match &self.last { + Some((k, alpha)) if *k == key => alpha.clone(), + _ => { + let alpha = decode_alpha(bytes, &mut self.decoded).map(Arc::new); + self.last = Some((key, alpha.clone())); + alpha + } + }; + alpha_in_crop(&*alpha?, crop) + } +} + +/// The transparency of picture `bytes`, charging its pixels to `decoded`. `None` for pictures +/// without transparency, that don't decode, or that would take `decoded` past +/// [`MAX_DECODED_PIXELS`]. +fn decode_alpha(bytes: &[u8], decoded: &mut u64) -> Option { let pic = wordcraft_geom::picture::probe(bytes).ok()?; // Formats that can't be transparent aren't decoded. if pic.format == image::ImageFormat::Jpeg || pic.width > MAX_PICTURE_SIDE || pic.height > MAX_PICTURE_SIDE { return None; } + let pixels = u64::from(pic.width) * u64::from(pic.height); + let total = decoded.checked_add(pixels).filter(|t| *t <= MAX_DECODED_PIXELS)?; + *decoded = total; let img = pic.decode(wordcraft_geom::MAX_DECODED_BYTES).ok()?; if !img.color().has_alpha() { return None; } let img = img.into_rgba8(); - let (iw, ih) = img.dimensions(); + let (w, h) = img.dimensions(); + Some(Alpha { w, h, a: img.pixels().map(|p| p.0[3]).collect() }) +} + +/// The outline of `img`'s opaque pixels within `crop`: see [`Alphas::outline`]. +fn alpha_in_crop(img: &Alpha, crop: [f32; 4]) -> Option> { + let (iw, ih) = (img.w, img.h); // The part the renderers draw (#1899): a sliver keeps its partly covered pixels. let [cl, ct, vw, vh] = wordcraft_metafile::crop_window(crop); let (fw, fh) = (f64::from(iw), f64::from(ih)); @@ -197,9 +257,7 @@ pub fn picture_outline(bytes: &[u8], crop: [f32; 4]) -> Option> let opaque = |gx: usize, gy: usize| { let (px0, py0) = (x0 + gx as u32 * cell, y0 + gy as u32 * cell); let (px1, py1) = ((px0 + cell).min(x1), (py0 + cell).min(y1)); - (py0..py1) - .step_by(step as usize) - .any(|y| (px0..px1).step_by(step as usize).any(|x| img.get_pixel_checked(x, y).is_some_and(|p| p.0[3] >= 128))) + (py0..py1).step_by(step as usize).any(|y| (px0..px1).step_by(step as usize).any(|x| img.opaque(x, y))) }; let pts = alpha_outline(gw as usize, gh as usize, opaque)?; // Grid points onto the picture as the renderers draw the window, whose edges fall within @@ -214,6 +272,10 @@ pub fn picture_outline(bytes: &[u8], crop: [f32; 4]) -> Option> mod tests { use super::*; + fn picture_outline(bytes: &[u8], crop: [f32; 4]) -> Option> { + Alphas::default().outline(bytes, crop) + } + fn diamond(through: bool) -> Contour { Contour::new(&[(50.0, 0.0), (100.0, 50.0), (50.0, 100.0), (0.0, 50.0)], 0.0, 0.0, 0.0, through).unwrap().1 } diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index d324770026..35720aae24 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -741,6 +741,8 @@ struct Ctx<'a> { floats_above: HashMap<(usize, usize), Option<(usize, WrapArea)>>, /// The document's sections with their headers and footers linked ([`HfLinks`]). hf: Arc>, + /// Pictures' transparency, decoded for Tight and Through wrapping ([`Ctx::wrap_outline`]). + alphas: contour::Alphas, } /// A header or footer (`story`) laid out for page `number` of a section (`sect`, `mirror`): its @@ -795,7 +797,7 @@ impl Ctx<'_> { /// The outline Tight and Through wrapping follow around `o` (see [`contour::wrap_outline`]); /// pictures' are worked out once per picture and crop. fn wrap_outline(&mut self, o: &InlineObject) -> Option>> { - let (doc, cache) = (self.doc, &mut *self.cache); + let (doc, cache, alphas) = (self.doc, &mut *self.cache, &mut self.alphas); contour::wrap_outline(o, |media, crop| { let bytes = doc.media.get(media)?; let key = (media.to_string(), Arc::as_ptr(bytes) as usize, bytes.len(), crop.map(f32::to_bits)); @@ -805,7 +807,7 @@ impl Ctx<'_> { if cache.outlines.len() >= 256 { cache.outlines.clear(); } - let outline = contour::picture_outline(bytes, crop).map(Arc::new); + let outline = alphas.outline(bytes, crop).map(Arc::new); cache.outlines.insert(key, outline.clone()); outline }) @@ -2338,6 +2340,7 @@ fn layout_pass( pin: None, floats_above: HashMap::new(), hf: Arc::new(HfLinks::new(doc.sections())), + alphas: Default::default(), }; let hf = Arc::clone(&ctx.hf); let sections = &hf.sections; diff --git a/crates/layout/src/tests_budget.rs b/crates/layout/src/tests_budget.rs index 7a1c4d569a..3604b471e2 100644 --- a/crates/layout/src/tests_budget.rs +++ b/crates/layout/src/tests_budget.rs @@ -97,3 +97,47 @@ fn a_huge_grid_after_is_bounded_like_the_docx_readers() { let l = lay(&d); assert!(l.pages[0].items.iter().any(|i| matches!(i, Placed::Cell { row: 0, cell: 1, .. }))); } + +/// A `size` × `size` PNG, transparent but for an opaque disc in the middle. +fn disc_png(size: u32) -> Vec { + let c = size as f32 / 2.0; + let img = image::RgbaImage::from_fn(size, size, |x, y| { + let (dx, dy) = (x as f32 + 0.5 - c, y as f32 + 0.5 - c); + image::Rgba([0, 0, 0, if dx * dx + dy * dy < c * c * 0.5 { 255 } else { 0 }]) + }); + let mut buf = Vec::new(); + image::DynamicImage::from(img).write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png).unwrap(); + buf +} + +#[test] +fn a_picture_cropped_many_ways_is_decoded_once() { + let png = disc_png(64); + let mut alphas = contour::Alphas::default(); + for i in 0..300 { + let f = i as f32 / 1000.0; + assert!(alphas.outline(&png, [f, f, 0.0, 0.0]).is_some(), "crop {i} follows the disc"); + } + assert_eq!(alphas.decoded, 64 * 64, "one decode for every crop"); + // A crop's outline is the one the picture alone gives. + let mut fresh = contour::Alphas::default(); + assert_eq!(alphas.outline(&png, [0.1, 0.0, 0.2, 0.0]), fresh.outline(&png, [0.1, 0.0, 0.2, 0.0])); +} + +#[test] +fn decoding_pictures_stops_at_the_layouts_budget() { + let (a, b) = (disc_png(64), disc_png(32)); + let mut alphas = contour::Alphas::default(); + alphas.decoded = contour::MAX_DECODED_PIXELS - 64 * 64 + 1; + // Too many pixels left to decode: the picture wraps as its rectangle. + assert!(alphas.outline(&a, [0.0; 4]).is_none()); + // A smaller one still fits. + assert!(alphas.outline(&b, [0.0; 4]).is_some()); + assert!(alphas.decoded <= contour::MAX_DECODED_PIXELS); + // Pictures alternating past the budget aren't decoded again. + for _ in 0..10 { + assert!(alphas.outline(&a, [0.0; 4]).is_none()); + let _ = alphas.outline(&b, [0.0; 4]); + } + assert!(alphas.decoded <= contour::MAX_DECODED_PIXELS); +} From ed78539488ca7377b86af3275457b206c6dfd6dc Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Thu, 22 Oct 2026 19:35:01 +0100 Subject: [PATCH 130/199] Progress log and layout-parity notes for audit findings 7, 11, 35, 38. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/layout-parity.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/layout-parity.md b/docs/layout-parity.md index d35ce0a1cd..1f9fc717d5 100644 --- a/docs/layout-parity.md +++ b/docs/layout-parity.md @@ -59,7 +59,7 @@ Exact line spacing keeps the run font baseline around tall equations; automatic | Body top below a tall header, footer pushes body up | ✅ | #138; a footer taller than the bottom margin moves the body bottom up (#453), and a header or footer linked to the previous section takes its room too (#454) | — | | Tables: row border exceptions (`w:tblPrEx/w:tblBorders`) | ✅ | `row_borders_stay_on_row`, `row_nil_overrides_style`: explicit cell borders win; row exceptions precede table styles and table borders | — | | Footnotes at the page bottom, endnotes at the end, note numbering | 🟡 | A long note continues onto the next page at a line boundary (its first line stays with the reference), first in that page's note area under a full-width continuation separator (#352); the document's own separator, continuation separator and continuation notice are drawn (the notice under a note that continues); on a page with footnotes a paragraph ending there keeps its space after above the separator, or its last lines go to the next page with the note (#1116); a long endnote continues on the next column or page at a line boundary (#456), and the caret finds note text on its continuation page (#457); each section's note options: footnotes below the text, endnotes at the end of each section, start at, numbering restarted each section or each page (counted from where the notes land, at most three passes) (#385) | 1–2 | -| Tables: row heights (at least/exact), rows split across pages, header rows repeat, Can't Split | ✅ | 2026-10-06, #138; omitted cell-margin edges inherit current table defaults; every cell in a row gets the row's largest top and bottom margin; an exact height covers the top border, top margin and text, the bottom margin comes on top, and an exact row never splits; a row split across pages keeps its cells' bottom margin, border and last space after on the first page, widow/orphan control applies in its cells, each cell continues at the top of the next page and an at-least row keeps its minimum; an at-least row with less room than its minimum on a page that already holds something starts the next page whole (Allow row to break on or off, vAlign centre too), as observed in Word for Mac 2026-10-15 (a row of about 2.6 cm), #1742; that repeated header rows go with it and that a row taller than a page splits from the top of the next page come from the issue and synthetic tests, not a Word check (`…_an_at_least_row_with_no_room_starts_the_next_page_whole_either_way_breaking_is_set`, `…_an_at_least_row_taller_than_a_page_splits_from_the_top_of_the_next_page`, `layout::tests::a_row_shares_its_largest_top_and_bottom_margins`, `…_a_split_row_keeps_its_bottom_margin_and_widow_control`, `…_split_row_cells_each_continue_at_the_top`) | — | +| Tables: row heights (at least/exact), rows split across pages, header rows repeat, Can't Split | ✅ | 2026-10-06, #138; omitted cell-margin edges inherit current table defaults; every cell in a row gets the row's largest top and bottom margin; an exact height covers the top border, top margin and text, the bottom margin comes on top, and an exact row never splits; a row split across pages keeps its cells' bottom margin, border and last space after on the first page, widow/orphan control applies in its cells, each cell continues at the top of the next page and an at-least row keeps its minimum; an at-least row with less room than its minimum on a page that already holds something starts the next page whole (Allow row to break on or off, vAlign centre too), as observed in Word for Mac 2026-10-15 (a row of about 2.6 cm), #1742; that repeated header rows go with it and that a row taller than a page splits from the top of the next page come from the issue and synthetic tests, not a Word check (`…_an_at_least_row_with_no_room_starts_the_next_page_whole_either_way_breaking_is_set`, `…_an_at_least_row_taller_than_a_page_splits_from_the_top_of_the_next_page`, `layout::tests::a_row_shares_its_largest_top_and_bottom_margins`, `…_a_split_row_keeps_its_bottom_margin_and_widow_control`, `…_split_row_cells_each_continue_at_the_top`); a row's `gridAfter` is bounded to 63 columns in native documents too, and repeated header rows stop after 500 000 copied items across a table (hostile documents, #3692) | — | | Tables: autofit to contents/window, fixed widths, Word 2013 edge | 🟡 | #137; #44: AutoFit Contents measures each column's narrowest and widest text and shares the width like Word, once (not live as you type); consistent a percentage width past 100% is kept, as Word does; a table with its own preferred width keeps it, however far past the margin, and only an automatic-width table over 1.5× the text width is squeezed to the margins (Word squeezes it as soon as it is wider) (#1721); cell widths override stale grid proportions when their totals agree (`cell_widths_override_grid`); conflicting widths and spans still differ | 3–5 | | Floating tables (`w:tblpPr`) | 🟡 | #137; one placed on the page or margin that would cover visible text before it and does not fit below it moves to the next page (#534); empty line-break lines do not count as text; a click below one, outside its cells, goes to the text beside or after it, not to its last row, as checked in Word (#2038); exact line-bottom contact counts for tables only (#3977), both in row-span selection and word skipping; exact-spacing table contact uses the specified height rather than paragraph-mark metrics, with original Source Sans 3 controls in modes 11/12/14/15; expected Word rule from [cyril265's Word for Mac 16.114.1 measurements](https://github.com/storytold/wordcraft/issues/3977#issuecomment-6374217574), not a new Word run of these originals; anchor-order replay remains #3790's scope; other overlap rules untested | 2–3 | | Nested tables | 🟡 | render; polish missing | 2–4 | @@ -67,8 +67,8 @@ Exact line spacing keeps the run font baseline around tall equations; automatic | Shape fills: gradients and transparency | 🟡 | #1500: linear (angle, `scaled`) and circular gradients and transparent fills drawn on screen, PNG and PDF (axial/radial shadings), soft edges fading the real fill; rectangular and shape-following path gradients drawn circular, `tileRect`/`flip` not drawn, outline gradients drawn in their middle colour, `rotWithShape="0"` drawn as turning with the shape | 2–4 | | Text wrap: square, top-and-bottom, behind, in front | ✅ | 2026-10-06; beside an object, a line's first word that doesn't fit the strip goes on whole to the row's next strip or the next row, instead of being split (#1376, `layout::tests::a_word_too_wide_for_the_strip_beside_a_float_moves_on_whole`; inferred from a reporter's comparison, not yet measured in Word) a paragraph running on into the next column or page is broken again there around that column's shapes, its own floating objects left where they are (#1374). Text in an earlier column doesn't yet wrap around a shape anchored in a later one (#1158) | #884: an object dragged on a multi-column page anchors to a paragraph in the column it lands in, so that column's text wraps around it. Text laid out before the anchor (an earlier column) doesn't yet wrap around an object anchored later on the page, nor do lines a paragraph continues into the next column (broken once, in the column it starts in) | | Text wrap: square, top-and-bottom, behind, in front | ✅ | 2026-10-06; beside an object, a line's first word that doesn't fit the strip goes on whole to the row's next strip or the next row, instead of being split (#1376, `layout::tests::a_word_too_wide_for_the_strip_beside_a_float_moves_on_whole`; inferred from a reporter's comparison, not yet measured in Word) a paragraph running on into the next column or page is broken again there around that column's shapes, its own floating objects left where they are (#1374). Text in an earlier column doesn't yet wrap around a shape anchored in a later one (#1158) | #884: an object dragged on a multi-column page anchors to a paragraph in the column it lands in, so that column's text wraps around it. Paragraphs above an object's anchor wrap around it when it is placed higher up the page (the page is laid out again with it known from the start, #2048; when that wrap pushes the anchor onto the next page, the first page keeps the room and the object goes with its anchor, and a layout that still hasn't settled moves the object down to its paragraph rather than over text); text in an earlier column beside it doesn't yet, nor do lines a paragraph continues into the next column (broken once, in the column it starts in) | -| Text wrap: tight, through (contour, wrap polygon) | 🟡 | #354: lines follow the wrap polygon, or the outline worked out from the object (shape geometry; a picture's opaque pixels as a convex hull; otherwise the rectangle), turned and flipped with it, kept the left/right distance from text; Through also uses gaps inside the outline. Wrap side (`wrapText`: both sides, left only, right only, largest side per line) applies to square, tight and through wraps, turned objects included; picture outlines are convex; not compared page-for-page with Word | 1–2 | -| Text boxes, overflow, linked text boxes | ✅ | #46; linked text boxes (#369): the text runs on through each box in turn at that box's line length, overflow past the last box hidden; Align Text top / middle / bottom; the box's own internal margins, and a box set to Resize shape to fit text shows all its text (#743) | — | +| Text wrap: tight, through (contour, wrap polygon) | 🟡 | #354: lines follow the wrap polygon, or the outline worked out from the object (shape geometry; a picture's opaque pixels as a convex hull; otherwise the rectangle), turned and flipped with it, kept the left/right distance from text; Through also uses gaps inside the outline. Wrap side (`wrapText`: both sides, left only, right only, largest side per line) applies to square, tight and through wraps, turned objects included; picture outlines are convex; a picture is decoded once for all its crops, and at most 2^28 pixels per layout are decoded (later pictures wrap as rectangles, #3692); not compared page-for-page with Word | 1–2 | +| Text boxes, overflow, linked text boxes | ✅ | #46; linked text boxes (#369): the text runs on through each box in turn at that box's line length, overflow past the last box hidden; Align Text top / middle / bottom; the box's own internal margins, and a box set to Resize shape to fit text shows all its text (#743); a chain's story is laid out at no more than 16 different box widths, later boxes needing another show nothing (#3692) | — | | Rotated objects and text | 🟡 | #332: pictures, shapes, charts and groups drawn turned and flipped about their centre (screen and PDF); square wrap and inline lines keep clear of the rotated bounds; hit testing on the turned shape. Text in a rotated text box stays upright | 2–3 | | Group connector rerouting | 🟡 | Glued ends keep their existing connection-site path; free ends, missing targets and invalid sites retain their displayed rotated and flipped position when the connector frame is rebuilt, including under nonuniform group scaling. Model and engine regressions cover all connector kinds, both ends, rotations/flips, unit/uniform/nonuniform scale, idempotent rerouting, degenerate dimensions without nonfinite writes, and fallback spans beyond the 4000 pt display-size cap without endpoint movement or repeated shrinkage. Non-invertible display scales and unrepresentable fallback spans leave connectors needing fallback endpoints unchanged; fully resolved connectors still reroute. Invalid child extents use the renderer's unit-scale fallback. Routing around obstacles remains missing; no new native Word comparison | — | | Page borders (from page edge or text), page colour, watermark | ✅ | | — | @@ -273,6 +273,7 @@ layout suite are pending; this is not a new Word/native-IME acceptance claim. | 2026-10-23 | trivial | cu20we: preserve and render outline caps across DOCX/VML, screen and PDF; synthetic regressions; readiness estimates unchanged | | 2026-10-23 | trivial | cu20we: paragraph-relative box floats use the pre-spacing origin; header/footer/cell/text-box regressions; readiness estimates unchanged | | 2026-10-22 | trivial | Layout lookups linear in clipped cells, authors, sections, header paragraphs and bidi offsets (#3692, #3706) | +| 2026-10-22 | trivial | Layout work bounds for hostile documents: linked-box chain widths, repeated header items, `gridAfter`, picture decodes (#3692) | | 2026-10-22 | trivial | Group connector rerouting preserves displayed free and unresolved endpoints under nonuniform scaling while resolved ends keep their connection-site path; degenerate dimensions and unrepresentable fallback display spans are guarded; synthetic model/engine coverage only, readiness estimates unchanged | | 2026-10-21 | trivial | A word with no break opportunity in it, laid out beside a floating object, no longer lays out quadratically: every word is measured once, before the line is broken (#3412) | | 2026-10-23 | trivial | An empty paragraph whose mark is hidden is laid out as nothing, like a deleted one: no room, no list number (still counted, as in Word) and no shading, in the body and in table cells, headers and text boxes (#3982) | From 32e4900bc5ea341d3aace37657b0a2081fc8812e Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Thu, 22 Oct 2026 22:18:47 +0100 Subject: [PATCH 131/199] #3692: a linked chain past 16 widths reuses the nearest; opaque pictures and budget refusals don't cost a later picture its outline Ultrareview finding 6: boxes past a chain's 16th distinct width (compared by exact float equality) showed no text, which resized boxes hit easily. Widths are now told apart to a twip, and past the cap a box takes the nearest layout already made (same direction, widest that fits, else the narrowest). Work stays bounded at 16 story layouts. Ultrareview finding 7 (also raised by CodeRabbit): opaque non-JPEG pictures were decoded and charged to the per-layout pixel budget though they give no outline, and a budget refusal was cached in LayoutCache as a permanent "no outline". The header (read via into_decoder, PNG tRNS included) now says whether a picture can be transparent before anything is charged; Alphas::outline reports OverBudget distinctly from Ok(None), and Ctx::wrap_outline (via picture_outline) doesn't keep a refusal in LayoutCache::outlines. Within one layout a refused picture is remembered so its header isn't re-read. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/layout/src/contour.rs | 52 ++++++++++++------ crates/layout/src/lib.rs | 35 ++++++++---- crates/layout/src/tests_budget.rs | 90 +++++++++++++++++++++++++++---- crates/layout/src/textbox.rs | 21 +++++--- docs/layout-parity.md | 4 +- 5 files changed, 156 insertions(+), 46 deletions(-) diff --git a/crates/layout/src/contour.rs b/crates/layout/src/contour.rs index ce02027fbf..48432f4f77 100644 --- a/crates/layout/src/contour.rs +++ b/crates/layout/src/contour.rs @@ -5,6 +5,7 @@ //! A [`Contour`] is built once per placed object; each line then asks it which stretches of //! the line's band it takes ([`Contour::occupied`]), a walk over its edges. +use std::collections::HashSet; use std::hash::{Hash, Hasher}; use std::sync::Arc; @@ -187,12 +188,19 @@ impl Alpha { } } +/// A picture whose transparency would take its layout past [`MAX_DECODED_PIXELS`]: it wraps +/// as its rectangle in this layout only. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct OverBudget; + /// Pictures' transparency decoded during one layout. A picture shown with many crops (each an /// outline of its own) is decoded once, and decoding stops at [`MAX_DECODED_PIXELS`]. #[derive(Default)] pub(crate) struct Alphas { /// The picture decoded last: its bytes (address and length) and transparency (`None`: none). last: Option<((usize, usize), Option>)>, + /// Pictures refused for the budget (bytes' address and length), not looked at again. + refused: HashSet<(usize, usize)>, /// Pixels decoded so far. pub(crate) decoded: u64, } @@ -200,40 +208,50 @@ pub(crate) struct Alphas { impl Alphas { /// The outline of picture `bytes`'s opaque pixels within `crop` (left, top, right, bottom /// fractions), in unit coordinates of the cropped picture. `None` for pictures without - /// transparency (or that don't decode, or past the budget): their rectangle is their outline. - pub(crate) fn outline(&mut self, bytes: &[u8], crop: [f32; 4]) -> Option> { + /// transparency (or that don't decode), and [`OverBudget`] past the budget: their rectangle + /// is their outline. + pub(crate) fn outline(&mut self, bytes: &[u8], crop: [f32; 4]) -> Result>, OverBudget> { let key = (bytes.as_ptr() as usize, bytes.len()); let alpha = match &self.last { Some((k, alpha)) if *k == key => alpha.clone(), + _ if self.refused.contains(&key) => return Err(OverBudget), _ => { - let alpha = decode_alpha(bytes, &mut self.decoded).map(Arc::new); + let alpha = decode_alpha(bytes, &mut self.decoded).inspect_err(|_| { + self.refused.insert(key); + })?; + let alpha = alpha.map(Arc::new); self.last = Some((key, alpha.clone())); alpha } }; - alpha_in_crop(&*alpha?, crop) + Ok(alpha.and_then(|a| alpha_in_crop(&a, crop))) } } /// The transparency of picture `bytes`, charging its pixels to `decoded`. `None` for pictures -/// without transparency, that don't decode, or that would take `decoded` past -/// [`MAX_DECODED_PIXELS`]. -fn decode_alpha(bytes: &[u8], decoded: &mut u64) -> Option { - let pic = wordcraft_geom::picture::probe(bytes).ok()?; +/// without transparency or that don't decode; [`OverBudget`] for one that would take `decoded` +/// past [`MAX_DECODED_PIXELS`]. Pictures whose header says they're opaque cost nothing. +fn decode_alpha(bytes: &[u8], decoded: &mut u64) -> Result, OverBudget> { + use image::ImageDecoder; + let Ok(pic) = wordcraft_geom::picture::probe(bytes) else { return Ok(None) }; // Formats that can't be transparent aren't decoded. if pic.format == image::ImageFormat::Jpeg || pic.width > MAX_PICTURE_SIDE || pic.height > MAX_PICTURE_SIDE { - return None; + return Ok(None); + } + // Only the header is read here (a PNG's `tRNS` counts as transparency). + let has_alpha = image::ImageReader::with_format(std::io::Cursor::new(bytes), pic.format) + .into_decoder() + .ok() + .is_some_and(|d| d.color_type().has_alpha()); + if !has_alpha { + return Ok(None); } let pixels = u64::from(pic.width) * u64::from(pic.height); - let total = decoded.checked_add(pixels).filter(|t| *t <= MAX_DECODED_PIXELS)?; - *decoded = total; - let img = pic.decode(wordcraft_geom::MAX_DECODED_BYTES).ok()?; - if !img.color().has_alpha() { - return None; - } + *decoded = decoded.checked_add(pixels).filter(|t| *t <= MAX_DECODED_PIXELS).ok_or(OverBudget)?; + let Ok(img) = pic.decode(wordcraft_geom::MAX_DECODED_BYTES) else { return Ok(None) }; let img = img.into_rgba8(); let (w, h) = img.dimensions(); - Some(Alpha { w, h, a: img.pixels().map(|p| p.0[3]).collect() }) + Ok(Some(Alpha { w, h, a: img.pixels().map(|p| p.0[3]).collect() })) } /// The outline of `img`'s opaque pixels within `crop`: see [`Alphas::outline`]. @@ -273,7 +291,7 @@ mod tests { use super::*; fn picture_outline(bytes: &[u8], crop: [f32; 4]) -> Option> { - Alphas::default().outline(bytes, crop) + Alphas::default().outline(bytes, crop).ok().flatten() } fn diamond(through: bool) -> Contour { diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index 35720aae24..2b59874c65 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -535,7 +535,7 @@ pub struct LayoutCache { measures: HashMap, /// Outlines of pictures' opaque pixels (Tight and Through wrapping), by media key, the /// media's bytes (address and length) and crop. - outlines: HashMap<(String, usize, usize, [u32; 4]), Option>>>, + outlines: Outlines, env: u64, used: HashSet, /// The STYLEREF style index of the last pass and what it was built from: the body blocks @@ -793,6 +793,29 @@ fn with_pin(ctx: &mut Ctx, id: u32, sect: &SectionProps, number: u32, f: impl (out, behind, front) } +/// Picture outlines by media name, bytes (address and length) and crop ([`LayoutCache`]). +type Outlines = HashMap<(String, usize, usize, [u32; 4]), Option>>>; + +/// Picture `bytes`'s outline within `crop`, kept in `outlines` under `key`. One refused for this +/// layout's decoding budget wraps as its rectangle and isn't kept: a later layout may decode it. +fn picture_outline( + outlines: &mut Outlines, + alphas: &mut contour::Alphas, + key: (String, usize, usize, [u32; 4]), + bytes: &[u8], + crop: [f32; 4], +) -> Option>> { + if let Some(o) = outlines.get(&key) { + return o.clone(); + } + let outline = alphas.outline(bytes, crop).ok()?.map(Arc::new); + if outlines.len() >= 256 { + outlines.clear(); + } + outlines.insert(key, outline.clone()); + outline +} + impl Ctx<'_> { /// The outline Tight and Through wrapping follow around `o` (see [`contour::wrap_outline`]); /// pictures' are worked out once per picture and crop. @@ -801,15 +824,7 @@ impl Ctx<'_> { contour::wrap_outline(o, |media, crop| { let bytes = doc.media.get(media)?; let key = (media.to_string(), Arc::as_ptr(bytes) as usize, bytes.len(), crop.map(f32::to_bits)); - if let Some(o) = cache.outlines.get(&key) { - return o.clone(); - } - if cache.outlines.len() >= 256 { - cache.outlines.clear(); - } - let outline = alphas.outline(bytes, crop).map(Arc::new); - cache.outlines.insert(key, outline.clone()); - outline + picture_outline(&mut cache.outlines, alphas, key, bytes, crop) }) } diff --git a/crates/layout/src/tests_budget.rs b/crates/layout/src/tests_budget.rs index 3604b471e2..e3ead629dc 100644 --- a/crates/layout/src/tests_budget.rs +++ b/crates/layout/src/tests_budget.rs @@ -38,7 +38,8 @@ fn text_box(d: &mut Document, para: usize, text: &str, w: f32, h: f32) -> u32 { #[test] fn a_linked_chain_lays_its_story_out_at_a_bounded_number_of_widths() { // Forty linked boxes, each a line deep and each a different width, over a long story: every - // width would lay the whole story out again. Past the cap, the chain's later boxes show nothing. + // width would lay the whole story out again. Past the cap, later boxes reuse the nearest + // width already laid out, so every box still shows its share of the story. let n = 40; let mut d = Document::from_text(&vec!["p"; n].join("\n")); let story = "Words that run on and on through every box of the chain. ".repeat(200); @@ -47,10 +48,42 @@ fn a_linked_chain_lays_its_story_out_at_a_bounded_number_of_widths() { d.parts.get_mut(&w[0]).unwrap().body.next = Some(w[1]); } let l = lay(&d); - let shown = - l.pages.iter().flat_map(|p| &p.items).filter(|it| matches!(it, Placed::Lines { story: StoryRef::Part(s), .. } if *s == ids[0])).count(); - assert!(shown > 1, "the first boxes still show the story: {shown}"); - assert!(shown <= textbox::MAX_FLOW_WIDTHS, "{shown} boxes laid the story out"); + let lines: Vec<&Placed> = + l.pages.iter().flat_map(|p| &p.items).filter(|it| matches!(it, Placed::Lines { story: StoryRef::Part(s), .. } if *s == ids[0])).collect(); + assert_eq!(lines.len(), n, "every box shows the story"); + // Each width the story was laid out at is its own paragraph layout. + let mut widths: Vec = + lines.iter().filter_map(|it| if let Placed::Lines { para, .. } = it { Some(Arc::as_ptr(para) as usize) } else { None }).collect(); + widths.sort_unstable(); + widths.dedup(); + assert!(widths.len() <= textbox::MAX_FLOW_WIDTHS, "{} widths laid the story out", widths.len()); +} + +#[test] +fn boxes_a_hair_apart_in_width_share_a_layout() { + // Resized boxes differ by fractions of a point; those within a twentieth of a point of each + // other share one layout of the story. + let n = 30; + let mut d = Document::from_text(&vec!["p"; n].join("\n")); + let story = "Words that run on and on through every box of the chain. ".repeat(100); + let ids: Vec = (0..n).map(|i| text_box(&mut d, i, if i == 0 { &story } else { "" }, 150.0 + 0.0005 * i as f32, 28.0)).collect(); + for w in ids.windows(2) { + d.parts.get_mut(&w[0]).unwrap().body.next = Some(w[1]); + } + let l = lay(&d); + let mut widths: Vec = l + .pages + .iter() + .flat_map(|p| &p.items) + .filter_map(|it| match it { + Placed::Lines { story: StoryRef::Part(s), para, .. } if *s == ids[0] => Some(Arc::as_ptr(para) as usize), + _ => None, + }) + .collect(); + assert_eq!(widths.len(), n, "every box shows the story"); + widths.sort_unstable(); + widths.dedup(); + assert_eq!(widths.len(), 1, "one layout for every box"); } #[test] @@ -116,7 +149,7 @@ fn a_picture_cropped_many_ways_is_decoded_once() { let mut alphas = contour::Alphas::default(); for i in 0..300 { let f = i as f32 / 1000.0; - assert!(alphas.outline(&png, [f, f, 0.0, 0.0]).is_some(), "crop {i} follows the disc"); + assert!(alphas.outline(&png, [f, f, 0.0, 0.0]).is_ok_and(|o| o.is_some()), "crop {i} follows the disc"); } assert_eq!(alphas.decoded, 64 * 64, "one decode for every crop"); // A crop's outline is the one the picture alone gives. @@ -129,15 +162,52 @@ fn decoding_pictures_stops_at_the_layouts_budget() { let (a, b) = (disc_png(64), disc_png(32)); let mut alphas = contour::Alphas::default(); alphas.decoded = contour::MAX_DECODED_PIXELS - 64 * 64 + 1; - // Too many pixels left to decode: the picture wraps as its rectangle. - assert!(alphas.outline(&a, [0.0; 4]).is_none()); + // Too many pixels left to decode: the picture wraps as its rectangle, and says why. + assert_eq!(alphas.outline(&a, [0.0; 4]), Err(contour::OverBudget)); // A smaller one still fits. - assert!(alphas.outline(&b, [0.0; 4]).is_some()); + assert!(alphas.outline(&b, [0.0; 4]).is_ok_and(|o| o.is_some())); assert!(alphas.decoded <= contour::MAX_DECODED_PIXELS); // Pictures alternating past the budget aren't decoded again. for _ in 0..10 { - assert!(alphas.outline(&a, [0.0; 4]).is_none()); + assert_eq!(alphas.outline(&a, [0.0; 4]), Err(contour::OverBudget)); let _ = alphas.outline(&b, [0.0; 4]); } assert!(alphas.decoded <= contour::MAX_DECODED_PIXELS); } + +/// A `size` × `size` PNG with no transparency. +fn opaque_png(size: u32) -> Vec { + let img = image::RgbImage::from_pixel(size, size, image::Rgb([0, 0, 0])); + let mut buf = Vec::new(); + image::DynamicImage::from(img).write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png).unwrap(); + buf +} + +#[test] +fn opaque_pictures_take_none_of_the_decoding_budget() { + // Pictures whose header says they have no transparency have no outline to find: decoding + // them would only spend the budget a transparent picture after them needs. + let (opaque, disc) = (opaque_png(64), disc_png(64)); + let mut alphas = contour::Alphas::default(); + alphas.decoded = contour::MAX_DECODED_PIXELS - 64 * 64; + for _ in 0..3 { + assert_eq!(alphas.outline(&opaque, [0.0; 4]), Ok(None)); + } + assert!(alphas.outline(&disc, [0.0; 4]).is_ok_and(|o| o.is_some()), "the transparent picture still has its outline"); + assert_eq!(alphas.decoded, contour::MAX_DECODED_PIXELS); +} + +#[test] +fn a_picture_refused_for_the_budget_is_not_cached_across_layouts() { + // One layout ran out of budget before this picture; the next has a budget of its own. + let disc = disc_png(64); + let mut outlines = HashMap::new(); + let key = ("p".to_string(), 1, 2, [0; 4]); + let mut spent = contour::Alphas::default(); + spent.decoded = contour::MAX_DECODED_PIXELS; + assert!(picture_outline(&mut outlines, &mut spent, key.clone(), &disc, [0.0; 4]).is_none()); + assert!(outlines.is_empty(), "the refusal isn't kept"); + let mut fresh = contour::Alphas::default(); + assert!(picture_outline(&mut outlines, &mut fresh, key.clone(), &disc, [0.0; 4]).is_some()); + assert!(outlines.get(&key).is_some_and(|o| o.is_some()), "the outline is kept"); +} diff --git a/crates/layout/src/textbox.rs b/crates/layout/src/textbox.rs index 8042cb5061..975b393904 100644 --- a/crates/layout/src/textbox.rs +++ b/crates/layout/src/textbox.rs @@ -210,10 +210,16 @@ struct Cut { /// Most line lengths (and text directions) a chain's story is laid out at. Each lays the whole /// story out again and is kept while the chain is split, so a chain of boxes that each differ in -/// width would multiply its story's layout work and memory; boxes needing another one show no -/// text. Far above a real chain's few box sizes. +/// width would multiply its story's layout work and memory; boxes needing another one take the +/// nearest already laid out. Far above a real chain's few box sizes. pub(crate) const MAX_FLOW_WIDTHS: usize = 16; +/// Line lengths are told apart to a twentieth of a point (a twip): resized boxes a hair apart +/// share one layout. +fn flow_key(len: f32, vert: TextVert) -> (i32, TextVert) { + ((len * 20.0).round() as i32, vert) +} + /// Split the text of `chain`'s first box over its boxes. fn flow(ctx: &mut Ctx, chain: &[u32], depth: usize) -> Vec> { let Some(&head) = chain.first() else { return Vec::new() }; @@ -227,7 +233,7 @@ fn flow(ctx: &mut Ctx, chain: &[u32], depth: usize) -> Vec> { // time), sharing a layout with the boxes before it of the same length. let snap = (ctx.counters.clone(), ctx.eq_count); let mut after = None; - let mut layouts: Vec<((u32, TextVert), Vec)> = Vec::new(); + let mut layouts: Vec<((i32, TextVert), Vec)> = Vec::new(); let mut out = Vec::with_capacity(chain.len()); // Where the next box's text starts: `Some(None)` at the beginning, `None` when all is shown. let mut start: Option> = Some(None); @@ -239,13 +245,14 @@ fn flow(ctx: &mut Ctx, chain: &[u32], depth: usize) -> Vec> { let body = ctx.doc.parts.get(id).map(|p| p.body).unwrap_or_default(); let (w, h) = sizes.get(id).copied().unwrap_or((0.0, 0.0)); let (len, across) = frame(&body, w, h); - let key = (len.to_bits(), body.vert); + let key = flow_key(len, body.vert); let items = match layouts.iter().find(|(k, _)| *k == key) { Some((_, items)) => items.clone(), - // Past the cap the text stops here: this box and the rest show none. + // Past the cap the box takes the nearest layout: the same direction first, then the + // widest line length that fits it, else the narrowest. None if layouts.len() >= MAX_FLOW_WIDTHS => { - out.push(Vec::new()); - continue; + let near = layouts.iter().min_by_key(|((k, v), _)| (*v != key.1, *k > key.0, k.abs_diff(key.0))); + near.map(|(_, items)| items.clone()).unwrap_or_default() } None => { (ctx.counters, ctx.eq_count) = (snap.0.clone(), snap.1); diff --git a/docs/layout-parity.md b/docs/layout-parity.md index 1f9fc717d5..2d06368c92 100644 --- a/docs/layout-parity.md +++ b/docs/layout-parity.md @@ -67,8 +67,8 @@ Exact line spacing keeps the run font baseline around tall equations; automatic | Shape fills: gradients and transparency | 🟡 | #1500: linear (angle, `scaled`) and circular gradients and transparent fills drawn on screen, PNG and PDF (axial/radial shadings), soft edges fading the real fill; rectangular and shape-following path gradients drawn circular, `tileRect`/`flip` not drawn, outline gradients drawn in their middle colour, `rotWithShape="0"` drawn as turning with the shape | 2–4 | | Text wrap: square, top-and-bottom, behind, in front | ✅ | 2026-10-06; beside an object, a line's first word that doesn't fit the strip goes on whole to the row's next strip or the next row, instead of being split (#1376, `layout::tests::a_word_too_wide_for_the_strip_beside_a_float_moves_on_whole`; inferred from a reporter's comparison, not yet measured in Word) a paragraph running on into the next column or page is broken again there around that column's shapes, its own floating objects left where they are (#1374). Text in an earlier column doesn't yet wrap around a shape anchored in a later one (#1158) | #884: an object dragged on a multi-column page anchors to a paragraph in the column it lands in, so that column's text wraps around it. Text laid out before the anchor (an earlier column) doesn't yet wrap around an object anchored later on the page, nor do lines a paragraph continues into the next column (broken once, in the column it starts in) | | Text wrap: square, top-and-bottom, behind, in front | ✅ | 2026-10-06; beside an object, a line's first word that doesn't fit the strip goes on whole to the row's next strip or the next row, instead of being split (#1376, `layout::tests::a_word_too_wide_for_the_strip_beside_a_float_moves_on_whole`; inferred from a reporter's comparison, not yet measured in Word) a paragraph running on into the next column or page is broken again there around that column's shapes, its own floating objects left where they are (#1374). Text in an earlier column doesn't yet wrap around a shape anchored in a later one (#1158) | #884: an object dragged on a multi-column page anchors to a paragraph in the column it lands in, so that column's text wraps around it. Paragraphs above an object's anchor wrap around it when it is placed higher up the page (the page is laid out again with it known from the start, #2048; when that wrap pushes the anchor onto the next page, the first page keeps the room and the object goes with its anchor, and a layout that still hasn't settled moves the object down to its paragraph rather than over text); text in an earlier column beside it doesn't yet, nor do lines a paragraph continues into the next column (broken once, in the column it starts in) | -| Text wrap: tight, through (contour, wrap polygon) | 🟡 | #354: lines follow the wrap polygon, or the outline worked out from the object (shape geometry; a picture's opaque pixels as a convex hull; otherwise the rectangle), turned and flipped with it, kept the left/right distance from text; Through also uses gaps inside the outline. Wrap side (`wrapText`: both sides, left only, right only, largest side per line) applies to square, tight and through wraps, turned objects included; picture outlines are convex; a picture is decoded once for all its crops, and at most 2^28 pixels per layout are decoded (later pictures wrap as rectangles, #3692); not compared page-for-page with Word | 1–2 | -| Text boxes, overflow, linked text boxes | ✅ | #46; linked text boxes (#369): the text runs on through each box in turn at that box's line length, overflow past the last box hidden; Align Text top / middle / bottom; the box's own internal margins, and a box set to Resize shape to fit text shows all its text (#743); a chain's story is laid out at no more than 16 different box widths, later boxes needing another show nothing (#3692) | — | +| Text wrap: tight, through (contour, wrap polygon) | 🟡 | #354: lines follow the wrap polygon, or the outline worked out from the object (shape geometry; a picture's opaque pixels as a convex hull; otherwise the rectangle), turned and flipped with it, kept the left/right distance from text; Through also uses gaps inside the outline. Wrap side (`wrapText`: both sides, left only, right only, largest side per line) applies to square, tight and through wraps, turned objects included; picture outlines are convex; a picture is decoded once for all its crops, pictures whose header says they are opaque are not decoded, and at most 2^28 pixels per layout are decoded (later pictures wrap as rectangles in that layout, #3692); not compared page-for-page with Word | 1–2 | +| Text boxes, overflow, linked text boxes | ✅ | #46; linked text boxes (#369): the text runs on through each box in turn at that box's line length, overflow past the last box hidden; Align Text top / middle / bottom; the box's own internal margins, and a box set to Resize shape to fit text shows all its text (#743); a chain's story is laid out at no more than 16 box widths (told apart to a twip), later boxes needing another taking the nearest one (#3692) | — | | Rotated objects and text | 🟡 | #332: pictures, shapes, charts and groups drawn turned and flipped about their centre (screen and PDF); square wrap and inline lines keep clear of the rotated bounds; hit testing on the turned shape. Text in a rotated text box stays upright | 2–3 | | Group connector rerouting | 🟡 | Glued ends keep their existing connection-site path; free ends, missing targets and invalid sites retain their displayed rotated and flipped position when the connector frame is rebuilt, including under nonuniform group scaling. Model and engine regressions cover all connector kinds, both ends, rotations/flips, unit/uniform/nonuniform scale, idempotent rerouting, degenerate dimensions without nonfinite writes, and fallback spans beyond the 4000 pt display-size cap without endpoint movement or repeated shrinkage. Non-invertible display scales and unrepresentable fallback spans leave connectors needing fallback endpoints unchanged; fully resolved connectors still reroute. Invalid child extents use the renderer's unit-scale fallback. Routing around obstacles remains missing; no new native Word comparison | — | | Page borders (from page edge or text), page colour, watermark | ✅ | | — | From 0f9b926497ce9226170ec22d52ac2721133296e1 Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Fri, 23 Oct 2026 01:54:23 +0100 Subject: [PATCH 132/199] =?UTF-8?q?#3692:=20review=20=E2=80=94=20a=20pictu?= =?UTF-8?q?re's=20transparency=20is=20kept=20for=20the=20whole=20layout,?= =?UTF-8?q?=20not=20just=20the=20last=20one=20decoded?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Crops alternating between pictures decoded each again and again, spending the budget on pictures that fit in it. Every picture charged to the budget keeps its alpha (bounded by the same 2^28-pixel budget). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01CX1hMKX28764Lrx5moxs1o --- crates/layout/src/contour.rs | 19 ++++++++++--------- crates/layout/src/tests_budget.rs | 14 ++++++++++++++ 2 files changed, 24 insertions(+), 9 deletions(-) diff --git a/crates/layout/src/contour.rs b/crates/layout/src/contour.rs index 48432f4f77..e0a12778df 100644 --- a/crates/layout/src/contour.rs +++ b/crates/layout/src/contour.rs @@ -5,7 +5,7 @@ //! A [`Contour`] is built once per placed object; each line then asks it which stretches of //! the line's band it takes ([`Contour::occupied`]), a walk over its edges. -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use std::hash::{Hash, Hasher}; use std::sync::Arc; @@ -194,11 +194,12 @@ impl Alpha { pub(crate) struct OverBudget; /// Pictures' transparency decoded during one layout. A picture shown with many crops (each an -/// outline of its own) is decoded once, and decoding stops at [`MAX_DECODED_PIXELS`]. +/// outline of its own) is decoded once, however its crops interleave with other pictures', and +/// decoding stops at [`MAX_DECODED_PIXELS`] (which also bounds what is kept). #[derive(Default)] pub(crate) struct Alphas { - /// The picture decoded last: its bytes (address and length) and transparency (`None`: none). - last: Option<((usize, usize), Option>)>, + /// Pictures decoded so far, by their bytes (address and length): their transparency (`None`: none). + seen: HashMap<(usize, usize), Option>>, /// Pictures refused for the budget (bytes' address and length), not looked at again. refused: HashSet<(usize, usize)>, /// Pixels decoded so far. @@ -212,15 +213,15 @@ impl Alphas { /// is their outline. pub(crate) fn outline(&mut self, bytes: &[u8], crop: [f32; 4]) -> Result>, OverBudget> { let key = (bytes.as_ptr() as usize, bytes.len()); - let alpha = match &self.last { - Some((k, alpha)) if *k == key => alpha.clone(), - _ if self.refused.contains(&key) => return Err(OverBudget), - _ => { + let alpha = match self.seen.get(&key) { + Some(alpha) => alpha.clone(), + None if self.refused.contains(&key) => return Err(OverBudget), + None => { let alpha = decode_alpha(bytes, &mut self.decoded).inspect_err(|_| { self.refused.insert(key); })?; let alpha = alpha.map(Arc::new); - self.last = Some((key, alpha.clone())); + self.seen.insert(key, alpha.clone()); alpha } }; diff --git a/crates/layout/src/tests_budget.rs b/crates/layout/src/tests_budget.rs index e3ead629dc..9e95ca076f 100644 --- a/crates/layout/src/tests_budget.rs +++ b/crates/layout/src/tests_budget.rs @@ -157,6 +157,20 @@ fn a_picture_cropped_many_ways_is_decoded_once() { assert_eq!(alphas.outline(&png, [0.1, 0.0, 0.2, 0.0]), fresh.outline(&png, [0.1, 0.0, 0.2, 0.0])); } +#[test] +fn pictures_cropped_in_turn_are_each_decoded_once() { + // Crops alternating between two pictures don't decode either again: enough of them would + // otherwise spend the budget on pictures that fit in it. + let (a, b) = (disc_png(64), disc_png(32)); + let mut alphas = contour::Alphas::default(); + for i in 0..300 { + let f = i as f32 / 1000.0; + assert!(alphas.outline(&a, [f, 0.0, 0.0, 0.0]).is_ok_and(|o| o.is_some()), "crop {i} of a"); + assert!(alphas.outline(&b, [0.0, f, 0.0, 0.0]).is_ok_and(|o| o.is_some()), "crop {i} of b"); + } + assert_eq!(alphas.decoded, 64 * 64 + 32 * 32, "one decode per picture"); +} + #[test] fn decoding_pictures_stops_at_the_layouts_budget() { let (a, b) = (disc_png(64), disc_png(32)); From 1ae38ec2be76b47e6adf7d6486a940a9448587de Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Fri, 23 Oct 2026 01:54:23 +0100 Subject: [PATCH 133/199] =?UTF-8?q?#3692:=20review=20=E2=80=94=20stacked?= =?UTF-8?q?=20linked=20boxes=20share=20one=20layout;=20past=20the=20cap=20?= =?UTF-8?q?a=20box=20takes=20one=20in=20its=20own=20direction?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stacked text is laid out at STACK_WIDTH whatever the frame, so its flow key now is too. The nearest-layout fallback only picks layouts of the box's TextVert; the first box in a direction is laid out (at most a few more layouts). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01CX1hMKX28764Lrx5moxs1o --- crates/layout/src/tests_budget.rs | 53 +++++++++++++++++++++++++++++++ crates/layout/src/textbox.rs | 16 +++++----- docs/layout-parity.md | 2 +- 3 files changed, 62 insertions(+), 9 deletions(-) diff --git a/crates/layout/src/tests_budget.rs b/crates/layout/src/tests_budget.rs index 9e95ca076f..72e1d343bf 100644 --- a/crates/layout/src/tests_budget.rs +++ b/crates/layout/src/tests_budget.rs @@ -59,6 +59,59 @@ fn a_linked_chain_lays_its_story_out_at_a_bounded_number_of_widths() { assert!(widths.len() <= textbox::MAX_FLOW_WIDTHS, "{} widths laid the story out", widths.len()); } +/// The paragraph layouts (by address) showing story `head` across the document. +fn story_layouts(l: &DocLayout, head: u32) -> Vec { + l.pages + .iter() + .flat_map(|p| &p.items) + .filter_map(|it| match it { + Placed::Lines { story: StoryRef::Part(s), para, .. } if *s == head => Some(Arc::as_ptr(para) as usize), + _ => None, + }) + .collect() +} + +#[test] +fn stacked_boxes_of_any_width_share_a_layout() { + // Stacked text is laid out one letter to a line whatever the box's width. + let n = 20; + let mut d = Document::from_text(&vec!["p"; n].join("\n")); + let story = "Stacked letters run on through every box. ".repeat(20); + let ids: Vec = (0..n).map(|i| text_box(&mut d, i, if i == 0 { &story } else { "" }, 100.0 + 3.0 * i as f32, 28.0)).collect(); + for id in &ids { + d.parts.get_mut(id).unwrap().body.vert = wordcraft_doc::props::TextVert::Stacked; + } + for w in ids.windows(2) { + d.parts.get_mut(&w[0]).unwrap().body.next = Some(w[1]); + } + let mut widths = story_layouts(&lay(&d), ids[0]); + assert_eq!(widths.len(), n, "every box shows the story"); + widths.sort_unstable(); + widths.dedup(); + assert_eq!(widths.len(), 1, "one layout for every stacked box"); +} + +#[test] +fn past_the_cap_a_box_takes_a_layout_in_its_own_direction() { + // The cap is full of horizontal widths when a vertical box comes: it lays the story out + // its own way rather than showing a horizontal layout. + let n = textbox::MAX_FLOW_WIDTHS + 1; + let mut d = Document::from_text(&vec!["p"; n].join("\n")); + let story = "Words that run on and on through every box of the chain. ".repeat(200); + let ids: Vec = (0..n).map(|i| text_box(&mut d, i, if i == 0 { &story } else { "" }, 100.0 + 3.0 * i as f32, 28.0)).collect(); + if let Some(last) = ids.last() { + d.parts.get_mut(last).unwrap().body.vert = wordcraft_doc::props::TextVert::Vert; + } + for w in ids.windows(2) { + d.parts.get_mut(&w[0]).unwrap().body.next = Some(w[1]); + } + let mut widths = story_layouts(&lay(&d), ids[0]); + assert_eq!(widths.len(), n, "every box shows the story"); + widths.sort_unstable(); + widths.dedup(); + assert_eq!(widths.len(), n, "the vertical box has a layout of its own"); +} + #[test] fn boxes_a_hair_apart_in_width_share_a_layout() { // Resized boxes differ by fractions of a point; those within a twentieth of a point of each diff --git a/crates/layout/src/textbox.rs b/crates/layout/src/textbox.rs index 975b393904..4ebe96c9cc 100644 --- a/crates/layout/src/textbox.rs +++ b/crates/layout/src/textbox.rs @@ -215,8 +215,9 @@ struct Cut { pub(crate) const MAX_FLOW_WIDTHS: usize = 16; /// Line lengths are told apart to a twentieth of a point (a twip): resized boxes a hair apart -/// share one layout. +/// share one layout. Stacked text is laid out at [`STACK_WIDTH`] whatever the box's size. fn flow_key(len: f32, vert: TextVert) -> (i32, TextVert) { + let len = if vert == TextVert::Stacked { STACK_WIDTH } else { len }; ((len * 20.0).round() as i32, vert) } @@ -246,14 +247,13 @@ fn flow(ctx: &mut Ctx, chain: &[u32], depth: usize) -> Vec> { let (w, h) = sizes.get(id).copied().unwrap_or((0.0, 0.0)); let (len, across) = frame(&body, w, h); let key = flow_key(len, body.vert); - let items = match layouts.iter().find(|(k, _)| *k == key) { + // Past the cap the box takes the nearest layout in its direction: the widest line length + // that fits it, else the narrowest. The first box in a direction is laid out (one more + // layout for each of the few directions at most). + let near = || layouts.iter().filter(|((_, v), _)| *v == key.1).min_by_key(|((k, _), _)| (*k > key.0, k.abs_diff(key.0))); + let kept = layouts.iter().find(|(k, _)| *k == key).or_else(|| if layouts.len() >= MAX_FLOW_WIDTHS { near() } else { None }); + let items = match kept { Some((_, items)) => items.clone(), - // Past the cap the box takes the nearest layout: the same direction first, then the - // widest line length that fits it, else the narrowest. - None if layouts.len() >= MAX_FLOW_WIDTHS => { - let near = layouts.iter().min_by_key(|((k, v), _)| (*v != key.1, *k > key.0, k.abs_diff(key.0))); - near.map(|(_, items)| items.clone()).unwrap_or_default() - } None => { (ctx.counters, ctx.eq_count) = (snap.0.clone(), snap.1); let items = lay(ctx, head, &blocks, len, body.vert, depth); diff --git a/docs/layout-parity.md b/docs/layout-parity.md index 2d06368c92..23252ea7fe 100644 --- a/docs/layout-parity.md +++ b/docs/layout-parity.md @@ -68,7 +68,7 @@ Exact line spacing keeps the run font baseline around tall equations; automatic | Text wrap: square, top-and-bottom, behind, in front | ✅ | 2026-10-06; beside an object, a line's first word that doesn't fit the strip goes on whole to the row's next strip or the next row, instead of being split (#1376, `layout::tests::a_word_too_wide_for_the_strip_beside_a_float_moves_on_whole`; inferred from a reporter's comparison, not yet measured in Word) a paragraph running on into the next column or page is broken again there around that column's shapes, its own floating objects left where they are (#1374). Text in an earlier column doesn't yet wrap around a shape anchored in a later one (#1158) | #884: an object dragged on a multi-column page anchors to a paragraph in the column it lands in, so that column's text wraps around it. Text laid out before the anchor (an earlier column) doesn't yet wrap around an object anchored later on the page, nor do lines a paragraph continues into the next column (broken once, in the column it starts in) | | Text wrap: square, top-and-bottom, behind, in front | ✅ | 2026-10-06; beside an object, a line's first word that doesn't fit the strip goes on whole to the row's next strip or the next row, instead of being split (#1376, `layout::tests::a_word_too_wide_for_the_strip_beside_a_float_moves_on_whole`; inferred from a reporter's comparison, not yet measured in Word) a paragraph running on into the next column or page is broken again there around that column's shapes, its own floating objects left where they are (#1374). Text in an earlier column doesn't yet wrap around a shape anchored in a later one (#1158) | #884: an object dragged on a multi-column page anchors to a paragraph in the column it lands in, so that column's text wraps around it. Paragraphs above an object's anchor wrap around it when it is placed higher up the page (the page is laid out again with it known from the start, #2048; when that wrap pushes the anchor onto the next page, the first page keeps the room and the object goes with its anchor, and a layout that still hasn't settled moves the object down to its paragraph rather than over text); text in an earlier column beside it doesn't yet, nor do lines a paragraph continues into the next column (broken once, in the column it starts in) | | Text wrap: tight, through (contour, wrap polygon) | 🟡 | #354: lines follow the wrap polygon, or the outline worked out from the object (shape geometry; a picture's opaque pixels as a convex hull; otherwise the rectangle), turned and flipped with it, kept the left/right distance from text; Through also uses gaps inside the outline. Wrap side (`wrapText`: both sides, left only, right only, largest side per line) applies to square, tight and through wraps, turned objects included; picture outlines are convex; a picture is decoded once for all its crops, pictures whose header says they are opaque are not decoded, and at most 2^28 pixels per layout are decoded (later pictures wrap as rectangles in that layout, #3692); not compared page-for-page with Word | 1–2 | -| Text boxes, overflow, linked text boxes | ✅ | #46; linked text boxes (#369): the text runs on through each box in turn at that box's line length, overflow past the last box hidden; Align Text top / middle / bottom; the box's own internal margins, and a box set to Resize shape to fit text shows all its text (#743); a chain's story is laid out at no more than 16 box widths (told apart to a twip), later boxes needing another taking the nearest one (#3692) | — | +| Text boxes, overflow, linked text boxes | ✅ | #46; linked text boxes (#369): the text runs on through each box in turn at that box's line length, overflow past the last box hidden; Align Text top / middle / bottom; the box's own internal margins, and a box set to Resize shape to fit text shows all its text (#743); a chain's story is laid out at no more than 16 box widths (told apart to a twip), later boxes needing another taking the nearest one in their text direction (#3692) | — | | Rotated objects and text | 🟡 | #332: pictures, shapes, charts and groups drawn turned and flipped about their centre (screen and PDF); square wrap and inline lines keep clear of the rotated bounds; hit testing on the turned shape. Text in a rotated text box stays upright | 2–3 | | Group connector rerouting | 🟡 | Glued ends keep their existing connection-site path; free ends, missing targets and invalid sites retain their displayed rotated and flipped position when the connector frame is rebuilt, including under nonuniform group scaling. Model and engine regressions cover all connector kinds, both ends, rotations/flips, unit/uniform/nonuniform scale, idempotent rerouting, degenerate dimensions without nonfinite writes, and fallback spans beyond the 4000 pt display-size cap without endpoint movement or repeated shrinkage. Non-invertible display scales and unrepresentable fallback spans leave connectors needing fallback endpoints unchanged; fully resolved connectors still reroute. Invalid child extents use the renderer's unit-scale fallback. Routing around obstacles remains missing; no new native Word comparison | — | | Page borders (from page edge or text), page colour, watermark | ✅ | | — | From 3cffe22c004bfe650585293e5867b973834398d9 Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Fri, 23 Oct 2026 11:22:16 +0100 Subject: [PATCH 134/199] =?UTF-8?q?#3692:=20review=20=E2=80=94=20picture?= =?UTF-8?q?=20outline=20cache=20keys=20stay=20unique:=20the=20per-pass=20o?= =?UTF-8?q?ne=20documented,=20the=20lasting=20one=20holds=20its=20bytes'?= =?UTF-8?q?=20allocation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Alphas keys decoded pictures by their bytes' address and length, which is sound only while the document is borrowed for the layout pass; that is now said where the key is. LayoutCache's outlines outlive the documents they were made for and use the same key, so each entry now keeps a Weak reference to the bytes: their allocation can't be freed and the address reused by another picture of the same length while the entry is kept. MAX_FLOW_WIDTHS spells out which layout a box past the 16-width cap reuses. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01CX1hMKX28764Lrx5moxs1o --- crates/layout/src/contour.rs | 6 ++++++ crates/layout/src/lib.rs | 18 ++++++++++++------ crates/layout/src/tests_budget.rs | 20 ++++++++++++++++++-- crates/layout/src/textbox.rs | 6 ++++-- 4 files changed, 40 insertions(+), 10 deletions(-) diff --git a/crates/layout/src/contour.rs b/crates/layout/src/contour.rs index e0a12778df..d5f18dc8ad 100644 --- a/crates/layout/src/contour.rs +++ b/crates/layout/src/contour.rs @@ -199,6 +199,12 @@ pub(crate) struct OverBudget; #[derive(Default)] pub(crate) struct Alphas { /// Pictures decoded so far, by their bytes (address and length): their transparency (`None`: none). + /// + /// The address tells pictures apart only while their bytes can't be freed and the address + /// reused for other bytes of the same length: an `Alphas` lives for one layout pass (in its + /// `Ctx`), and the document whose media hold the bytes is borrowed for the whole pass, so no + /// picture's bytes are freed while it's kept. Kept any longer, it would need to hold on to + /// the bytes, as [`crate::LayoutCache`]'s outlines do. seen: HashMap<(usize, usize), Option>>, /// Pictures refused for the budget (bytes' address and length), not looked at again. refused: HashSet<(usize, usize)>, diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index 2b59874c65..6771be76b6 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -534,7 +534,9 @@ pub struct LayoutCache { /// Narrowest and widest content of paragraphs measured for table autofit ([`Ctx::para_measured`]). measures: HashMap, /// Outlines of pictures' opaque pixels (Tight and Through wrapping), by media key, the - /// media's bytes (address and length) and crop. + /// media's bytes (address and length) and crop. Kept from one layout to the next, so each + /// entry holds a weak reference to its bytes: their allocation isn't freed while the entry is + /// kept, so no other picture's bytes can come to have its address ([`Outlines`]). outlines: Outlines, env: u64, used: HashSet, @@ -793,8 +795,12 @@ fn with_pin(ctx: &mut Ctx, id: u32, sect: &SectionProps, number: u32, f: impl (out, behind, front) } -/// Picture outlines by media name, bytes (address and length) and crop ([`LayoutCache`]). -type Outlines = HashMap<(String, usize, usize, [u32; 4]), Option>>>; +/// Picture outlines by media name, bytes (address and length) and crop ([`LayoutCache`]), each +/// with a weak reference to those bytes. The cache outlives the documents it is used with, and +/// an address names the same bytes only while their allocation lives: the weak reference keeps +/// it (not the bytes in it), so a picture loaded later can't be given a freed picture's address +/// and length and be taken for it. +type Outlines = HashMap<(String, usize, usize, [u32; 4]), (std::sync::Weak>, Option>>)>; /// Picture `bytes`'s outline within `crop`, kept in `outlines` under `key`. One refused for this /// layout's decoding budget wraps as its rectangle and isn't kept: a later layout may decode it. @@ -802,17 +808,17 @@ fn picture_outline( outlines: &mut Outlines, alphas: &mut contour::Alphas, key: (String, usize, usize, [u32; 4]), - bytes: &[u8], + bytes: &Arc>, crop: [f32; 4], ) -> Option>> { - if let Some(o) = outlines.get(&key) { + if let Some((_, o)) = outlines.get(&key) { return o.clone(); } let outline = alphas.outline(bytes, crop).ok()?.map(Arc::new); if outlines.len() >= 256 { outlines.clear(); } - outlines.insert(key, outline.clone()); + outlines.insert(key, (Arc::downgrade(bytes), outline.clone())); outline } diff --git a/crates/layout/src/tests_budget.rs b/crates/layout/src/tests_budget.rs index 72e1d343bf..483ec44976 100644 --- a/crates/layout/src/tests_budget.rs +++ b/crates/layout/src/tests_budget.rs @@ -267,7 +267,7 @@ fn opaque_pictures_take_none_of_the_decoding_budget() { #[test] fn a_picture_refused_for_the_budget_is_not_cached_across_layouts() { // One layout ran out of budget before this picture; the next has a budget of its own. - let disc = disc_png(64); + let disc = Arc::new(disc_png(64)); let mut outlines = HashMap::new(); let key = ("p".to_string(), 1, 2, [0; 4]); let mut spent = contour::Alphas::default(); @@ -276,5 +276,21 @@ fn a_picture_refused_for_the_budget_is_not_cached_across_layouts() { assert!(outlines.is_empty(), "the refusal isn't kept"); let mut fresh = contour::Alphas::default(); assert!(picture_outline(&mut outlines, &mut fresh, key.clone(), &disc, [0.0; 4]).is_some()); - assert!(outlines.get(&key).is_some_and(|o| o.is_some()), "the outline is kept"); + assert!(outlines.get(&key).is_some_and(|(_, o)| o.is_some()), "the outline is kept"); +} + +/// The outline cache outlives the documents it is used with and tells pictures apart by their +/// bytes' address: each entry keeps that allocation (a weak reference, not the bytes' owner), so +/// it can't be freed and the address given to another picture's bytes while the entry is kept. +#[test] +fn a_kept_outline_holds_on_to_its_pictures_allocation() { + let disc = Arc::new(disc_png(64)); + let mut outlines = HashMap::new(); + let key = ("p".to_string(), Arc::as_ptr(&disc) as usize, disc.len(), [0; 4]); + assert!(picture_outline(&mut outlines, &mut contour::Alphas::default(), key.clone(), &disc, [0.0; 4]).is_some()); + let Some((weak, _)) = outlines.get(&key) else { panic!("the outline is kept") }; + assert!(std::ptr::eq(weak.as_ptr(), Arc::as_ptr(&disc)), "the entry refers to its own picture's bytes"); + assert_eq!((Arc::strong_count(&disc), Arc::weak_count(&disc)), (1, 1), "a weak reference: the document still owns the bytes"); + drop(disc); + assert!(weak.upgrade().is_none(), "the bytes are freed with the document; only their allocation is kept"); } diff --git a/crates/layout/src/textbox.rs b/crates/layout/src/textbox.rs index 4ebe96c9cc..22ace35d11 100644 --- a/crates/layout/src/textbox.rs +++ b/crates/layout/src/textbox.rs @@ -210,8 +210,10 @@ struct Cut { /// Most line lengths (and text directions) a chain's story is laid out at. Each lays the whole /// story out again and is kept while the chain is split, so a chain of boxes that each differ in -/// width would multiply its story's layout work and memory; boxes needing another one take the -/// nearest already laid out. Far above a real chain's few box sizes. +/// width would multiply its story's layout work and memory. Past 16, a box whose length isn't +/// among them takes the nearest narrower one laid out in its text direction, so its lines still +/// fit across it (they stop short of its far edge), else the narrowest, whose lines are longer +/// than the box. Far above a real chain's few box sizes. pub(crate) const MAX_FLOW_WIDTHS: usize = 16; /// Line lengths are told apart to a twentieth of a point (a twip): resized boxes a hair apart From 3ee1978910da4d854bf15e71719d170e724a61cf Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Mon, 26 Oct 2026 00:01:24 +0000 Subject: [PATCH 135/199] =?UTF-8?q?#3692:=20rebase=20onto=20main=20?= =?UTF-8?q?=E2=80=94=20rustfmt=20after=20the=20merge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- crates/layout/src/contour.rs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/crates/layout/src/contour.rs b/crates/layout/src/contour.rs index d5f18dc8ad..e185aed1fe 100644 --- a/crates/layout/src/contour.rs +++ b/crates/layout/src/contour.rs @@ -246,10 +246,8 @@ fn decode_alpha(bytes: &[u8], decoded: &mut u64) -> Result, OverBu return Ok(None); } // Only the header is read here (a PNG's `tRNS` counts as transparency). - let has_alpha = image::ImageReader::with_format(std::io::Cursor::new(bytes), pic.format) - .into_decoder() - .ok() - .is_some_and(|d| d.color_type().has_alpha()); + let has_alpha = + image::ImageReader::with_format(std::io::Cursor::new(bytes), pic.format).into_decoder().ok().is_some_and(|d| d.color_type().has_alpha()); if !has_alpha { return Ok(None); } From 5155d2c18c0d482377cb222c06fef64f6ea28ca5 Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Thu, 22 Oct 2026 16:39:46 +0100 Subject: [PATCH 136/199] #3703: WOFF and WOFF2 sizes are checked before the decoder allocates from them Findings 39 and 70. 39: wuff inflates each WOFF 1.0 table into a buffer sized from the directory's origLength, before anything checks it against our 128 MB cap, so a tiny file could ask for gigabytes. The directory's table sizes are now summed first and a file over MAX_DECODED_BYTES is TooLarge. 70: wuff's WOFF2 glyf reconstruction allocates a glyph's points from its nPoints counts and checks the 65536-point limit only afterwards. The brotli stream is now decompressed by us (brotli-decompressor, already in the tree through wuff) and every transformed glyf table's per-glyph point totals are checked before wuff unpacks them; a glyph over the limit (which wuff would refuse anyway) is refused up front. Co-Authored-By: Claude Opus 5.5 (1M context) --- Cargo.lock | 1 + Cargo.toml | 2 + crates/fonts/Cargo.toml | 1 + crates/fonts/src/fontdb/user.rs | 214 ++++++++++++++++++++++++++++++-- 4 files changed, 211 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index cfcbb47b57..e88ebb28c1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6755,6 +6755,7 @@ dependencies = [ name = "wordcraft-fonts" version = "0.11.0" dependencies = [ + "brotli-decompressor", "harfrust 0.13.3", "kurbo", "log", diff --git a/Cargo.toml b/Cargo.toml index 165eeb1bbd..6bdbf2ef9c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -52,6 +52,8 @@ harfrust = "0.13" # WOFF / WOFF2 web fonts added by the user (#582): pure Rust (MIT); its brotli and zlib decoders # (brotli-decompressor, flate2) are already in the tree. wuff = "0.2.9" +# WOFF2's brotli stream, decoded ahead of wuff so its glyph point counts are checked first (#3703). +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 = "17744430be327d0257496a6a31c8d38fe20a6756", version = "=0.1.0", default-features = false, features = ["write"] } diff --git a/crates/fonts/Cargo.toml b/crates/fonts/Cargo.toml index 9107da44f1..8c0322a7d6 100644 --- a/crates/fonts/Cargo.toml +++ b/crates/fonts/Cargo.toml @@ -13,6 +13,7 @@ kurbo = { workspace = true } skrifa = { workspace = true } harfrust = { workspace = true } wuff = { workspace = true } +brotli-decompressor = { workspace = true } unicode-bidi = { workspace = true } unicode-script = { workspace = true } log = { workspace = true } diff --git a/crates/fonts/src/fontdb/user.rs b/crates/fonts/src/fontdb/user.rs index bc7ce6a6dd..7e0ead7ed7 100644 --- a/crates/fonts/src/fontdb/user.rs +++ b/crates/fonts/src/fontdb/user.rs @@ -149,8 +149,24 @@ pub fn decode_font_file(bytes: &[u8]) -> Result, UserFontError> { } let decoded = match sniff(bytes).ok_or(UserFontError::NotAFont)? { FontFormat::TrueType | FontFormat::OpenType | FontFormat::Collection => return Ok(bytes.to_vec()), - FontFormat::Woff => unwrap_web_font(|| wuff::decompress_woff1(bytes))?, - FontFormat::Woff2 => unwrap_web_font(|| wuff::decompress_woff2(bytes))?, + FontFormat::Woff => { + check_woff1_size(bytes)?; + unwrap_web_font(|| wuff::decompress_woff1(bytes))? + } + FontFormat::Woff2 => { + let mut too_many_points = false; + let unwrapped = unwrap_web_font(|| { + wuff::decompress_woff2_with_custom_brotli(bytes, &mut |data, size| { + let stream = unbrotli(data, size)?; + too_many_points = !woff2_points_fit(bytes, &stream); + if too_many_points { Err(TOO_MANY_POINTS.into()) } else { Ok(stream) } + }) + }); + if too_many_points { + return Err(UserFontError::BadWebFont(TOO_MANY_POINTS.into())); + } + unwrapped? + } }; if decoded.len() > MAX_DECODED_BYTES { return Err(UserFontError::TooLarge { bytes: decoded.len() }); @@ -161,6 +177,126 @@ pub fn decode_font_file(bytes: &[u8]) -> Result, UserFontError> { } } +/// WOFF 1.0: the font its table directory describes must fit [`MAX_DECODED_BYTES`]. The decoder +/// sizes each table's buffer from the directory's `origLength` before inflating it (#3703). +/// A short header or directory is left to the decoder to report. +fn check_woff1_size(bytes: &[u8]) -> Result<(), UserFontError> { + let u32_at = |at: usize| bytes.get(at..at.saturating_add(4)).and_then(|b| b.try_into().ok()).map(u32::from_be_bytes); + let tables = bytes.get(12..14).and_then(|n| n.try_into().ok()).map_or(0, |n| usize::from(u16::from_be_bytes(n))); + let mut total = 12 + 16 * tables; + for i in 0..tables { + let entry = 44 + 20 * i; + let (Some(compressed), Some(orig)) = (u32_at(entry + 8), u32_at(entry + 12)) else { break }; + total = total.saturating_add((compressed.max(orig) as usize).saturating_add(3) & !3); + } + if total > MAX_DECODED_BYTES { + return Err(UserFontError::TooLarge { bytes: total }); + } + Ok(()) +} + +/// Why a WOFF2 file whose glyph has more points than a glyph may is refused. +const TOO_MANY_POINTS: &str = "a glyph has more than 65536 points"; + +/// WOFF2's brotli stream, which must decompress to exactly `size` bytes (the decoder has checked +/// `size` against its own cap and the compression ratio). +fn unbrotli(data: &[u8], size: usize) -> Result, Box> { + use std::io::Read; + let mut out = Vec::with_capacity(size); + brotli_decompressor::Decompressor::new(data, 4096).take(size as u64 + 1).read_to_end(&mut out)?; + if out.len() != size { + return Err("the compressed data doesn't have the size the directory gives".into()); + } + Ok(out) +} + +/// Does every glyph in the WOFF2 file's transformed `glyf` tables have at most 65536 points, as +/// the decoder requires? It allocates a glyph's points from its counts before checking them, so a +/// small file could claim gigabytes (#3703). `stream` is the decompressed table data; anything +/// that doesn't parse is refused too (the decoder would refuse it). +fn woff2_points_fit(file: &[u8], stream: &[u8]) -> bool { + woff2_transformed_glyf(file, stream).is_some_and(|tables| tables.iter().all(|t| glyf_points_fit(t).unwrap_or(false))) +} + +/// The transformed `glyf` tables in `stream`, found from the WOFF2 table directory in `file` +/// (tables follow each other in directory order). +fn woff2_transformed_glyf<'a>(file: &[u8], stream: &'a [u8]) -> Option> { + let tables = u16::from_be_bytes(file.get(12..14)?.try_into().ok()?); + let (mut at, mut offset, mut glyf) = (48usize, 0usize, Vec::new()); + for _ in 0..tables { + let flags = *file.get(at)?; + at += 1; + let tag: Option<&[u8]> = if flags & 63 == 63 { + at += 4; + Some(file.get(at - 4..at)?) + } else { + None + }; + let is = |index: u8, name: &[u8]| flags & 63 == index || tag == Some(name); + let (is_glyf, is_loca) = (is(10, b"glyf"), is(11, b"loca")); + let orig = base128(file, &mut at)?; + // glyf and loca are transformed at version 0, other tables at any other version. + let transformed = if is_glyf || is_loca { flags >> 6 == 0 } else { flags >> 6 != 0 }; + let len = if transformed { base128(file, &mut at)? } else { orig }; + let end = offset.checked_add(len)?; + if is_glyf && transformed { + glyf.push(stream.get(offset..end)?); + } + offset = end; + } + Some(glyf) +} + +/// WOFF2's UIntBase128. +fn base128(b: &[u8], at: &mut usize) -> Option { + let mut v: u32 = 0; + for i in 0..5 { + let byte = *b.get(*at)?; + *at += 1; + if (i == 0 && byte == 0x80) || v >> 25 != 0 { + return None; + } + v = v << 7 | u32::from(byte & 0x7f); + if byte & 0x80 == 0 { + return usize::try_from(v).ok(); + } + } + None +} + +/// Does every glyph of the transformed `glyf` table `t` have at most 65536 points? +fn glyf_points_fit(t: &[u8]) -> Option { + let u32_at = |at: usize| t.get(at..at + 4).and_then(|b| b.try_into().ok()).map(u32::from_be_bytes).map(|v| v as usize); + let glyphs = usize::from(u16::from_be_bytes(t.get(4..6)?.try_into().ok()?)); + let contours_end = 36usize.checked_add(u32_at(8)?)?; + let contours = t.get(36..contours_end)?; + let points = t.get(contours_end..contours_end.checked_add(u32_at(12)?)?)?; + let mut at = 0; + for g in 0..glyphs { + let n = i16::from_be_bytes(contours.get(2 * g..2 * g + 2)?.try_into().ok()?); + let mut total = 0usize; + for _ in 0..n.max(0) { + // 255UInt16 + let code = *points.get(at)?; + at += 1; + let mut byte = || { + at += 1; + points.get(at - 1).copied().map(usize::from) + }; + total += match code { + 253 => byte()? << 8 | byte()?, + 254 => byte()? + 253 * 2, + 255 => byte()? + 253, + v => usize::from(v), + }; + if total > 1 << 16 { + return Some(false); + } + } + } + Some(true) +} + /// Run a WOFF/WOFF2 decoder. The decoder checks its input, but it is third-party code reading /// hostile bytes: a panic in it is caught (native) and reported as a damaged file. fn unwrap_web_font(decode: impl FnOnce() -> Result, wuff::WuffErr>) -> Result, UserFontError> { @@ -564,25 +700,89 @@ pub(crate) mod tests { base128(data.len() as u32, &mut dir); stream.extend_from_slice(data); } - let compressed = brotli_stored(&stream); + let sfnt: usize = 12 + 16 * t.len() + t.iter().map(|(_, d)| d.len().next_multiple_of(4)).sum::(); + woff2_file(&font[..4], t.len(), &dir, &stream, sfnt) + } + + /// A WOFF2 file: header, the table directory `dir` of `n` tables, `stream` in stored brotli blocks. + fn woff2_file(flavor: &[u8], n: usize, dir: &[u8], stream: &[u8], sfnt: usize) -> Vec { + let compressed = brotli_stored(stream); let mut out = Vec::new(); out.extend_from_slice(b"wOF2"); - out.extend_from_slice(&font[..4]); + out.extend_from_slice(flavor); let total = (48 + dir.len() + compressed.len()).next_multiple_of(4); out.extend_from_slice(&(total as u32).to_be_bytes()); - out.extend_from_slice(&(t.len() as u16).to_be_bytes()); + out.extend_from_slice(&(n as u16).to_be_bytes()); out.extend_from_slice(&0u16.to_be_bytes()); - let sfnt: usize = 12 + 16 * t.len() + t.iter().map(|(_, d)| d.len().next_multiple_of(4)).sum::(); out.extend_from_slice(&(sfnt as u32).to_be_bytes()); out.extend_from_slice(&(compressed.len() as u32).to_be_bytes()); out.extend_from_slice(&[0, 1, 0, 0]); out.extend_from_slice(&[0; 20]); - out.extend(dir); + out.extend_from_slice(dir); out.extend(compressed); out.resize(total, 0); out } + /// A WOFF2 file holding a transformed `glyf` (and `loca`) whose one glyph has two contours of + /// `points` points each (and no coordinates: the decoder finds it damaged in the end). + fn woff2_glyph_with(points: u16) -> Vec { + let total = 2 * points as usize; + let mut glyf = vec![0, 0, 0, 0, 0, 1, 0, 0]; // reserved, optionFlags, numGlyphs 1, indexFormat + // nContour, nPoints, flag, glyph, composite, bbox and instruction stream sizes. + for size in [2, 6, total, 0, 0, 4, 0] { + glyf.extend_from_slice(&(size as u32).to_be_bytes()); + } + glyf.extend_from_slice(&2i16.to_be_bytes()); + let [hi, lo] = points.to_be_bytes(); + glyf.extend_from_slice(&[253, hi, lo, 253, hi, lo]); // 255UInt16 in its word form + glyf.resize(glyf.len() + total, 1); // on-curve flags + glyf.extend_from_slice(&[0; 4]); // no explicit bounding boxes + let mut dir = vec![10]; // glyf, transform version 0 + base128(4096, &mut dir); + base128(glyf.len() as u32, &mut dir); + dir.push(11); // loca, transformed away + base128(8, &mut dir); + base128(0, &mut dir); + woff2_file(&[0, 1, 0, 0], 2, &dir, &glyf, 12 + 32 + 4096 + 8) + } + + /// A WOFF 1.0 file whose one table is a zlib stream of nothing that claims to inflate to `orig` + /// bytes. + fn woff1_claiming(orig: u32) -> Vec { + let mut out = b"wOFF\0\x01\0\0".to_vec(); + out.extend_from_slice(&72u32.to_be_bytes()); // length + out.extend_from_slice(&1u16.to_be_bytes()); // numTables + out.extend_from_slice(&0u16.to_be_bytes()); + out.extend_from_slice(&orig.saturating_add(28).to_be_bytes()); // totalSfntSize + out.extend_from_slice(&[0, 1, 0, 0]); + out.extend_from_slice(&[0; 20]); + out.extend_from_slice(b"glyf"); + for v in [64, 8, orig, 0] { + out.extend_from_slice(&u32::to_be_bytes(v)); // offset, compLength, origLength, checksum + } + out.extend_from_slice(&[0x78, 0x9c, 0x03, 0x00, 0x00, 0x00, 0x00, 0x01]); + out + } + + #[test] + fn a_woff_claiming_a_huge_table_is_refused_before_inflating() { + // #3703: the decoder sizes a table's buffer from the directory before inflating it. + for orig in [200 << 20, u32::MAX] { + assert!(matches!(decode_font_file(&woff1_claiming(orig)), Err(UserFontError::TooLarge { .. })), "{orig}"); + } + // A small claim reaches the decoder, which finds the data doesn't inflate to it. + assert!(matches!(decode_font_file(&woff1_claiming(64)), Err(UserFontError::BadWebFont(_)))); + } + + #[test] + fn a_woff2_glyph_with_too_many_points_is_refused_before_unpacking() { + // #3703: the decoder allocates a glyph's points from these counts before checking them. + assert_eq!(decode_font_file(&woff2_glyph_with(40_000)), Err(UserFontError::BadWebFont(TOO_MANY_POINTS.into()))); + // Within the limit the scan passes the file on, to the decoder. + assert!(matches!(decode_font_file(&woff2_glyph_with(3)), Err(UserFontError::BadWebFont(e)) if e != TOO_MANY_POINTS)); + } + #[test] fn an_added_font_is_listed_resolved_and_measured() { let db = db(); From 6c6326486f573f9b737e4b90f55dfbf5aaace49e Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Thu, 22 Oct 2026 16:40:48 +0100 Subject: [PATCH 137/199] #3692: font names are debug-quoted in log warnings Finding 46: install_font logged the caller-supplied file name raw when the font couldn't be stored, and the desktop log writes line breaks as they come, so a name could forge extra log records. The name is now logged with {:?} (escaped and quoted), and so are the font-derived family names in the two glyph-outline warnings, which come from a document's own fonts. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/fonts/src/fontdb.rs | 4 ++-- crates/fonts/src/fontdb/user.rs | 41 ++++++++++++++++++++++++++++++++- 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index 8402846909..973c7427da 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -194,13 +194,13 @@ impl FontFace { self.location(), HintingOptions { engine: Engine::Interpreter, target: Target::Mono }, ) - .map_err(|e| log::warn!("Cannot initialize outline interpreter for {}: {e}", self.family)) + .map_err(|e| log::warn!("Cannot initialize outline interpreter for {:?}: {e}", self.family)) .ok() }); let settings = hinter.as_ref().map_or_else(|| DrawSettings::unhinted(Size::unscaled(), self.location()), |h| DrawSettings::hinted(h, false)); if let Err(e) = g.draw(settings, &mut pen) { - log::warn!("Cannot draw glyph {gid} in {}: {e}", self.family); + log::warn!("Cannot draw glyph {gid} in {:?}: {e}", self.family); return BezPath::new(); } } diff --git a/crates/fonts/src/fontdb/user.rs b/crates/fonts/src/fontdb/user.rs index 7e0ead7ed7..09bbf84708 100644 --- a/crates/fonts/src/fontdb/user.rs +++ b/crates/fonts/src/fontdb/user.rs @@ -366,7 +366,8 @@ impl FontDb { None } Err(e) => { - log::warn!("couldn't store the font {name} in {}: {e}", dir.display()); + // Debug-quoted: a line break in the supplied name must not start a forged log record. + log::warn!("couldn't store the font {name:?} in {}: {e}", dir.display()); Some(format!("added for this session only: couldn't store it in {}: {e}", dir.display())) } }, @@ -903,6 +904,44 @@ pub(crate) mod tests { let _ = std::fs::remove_dir_all(&dir); } + /// The messages logged while this test binary runs. + fn logged() -> &'static std::sync::Mutex> { + static LOG: std::sync::OnceLock>> = std::sync::OnceLock::new(); + struct Capture; + impl log::Log for Capture { + fn enabled(&self, _: &log::Metadata) -> bool { + true + } + fn log(&self, r: &log::Record) { + if let Some(l) = LOG.get() { + l.lock().unwrap().push(r.args().to_string()); + } + } + fn flush(&self) {} + } + LOG.get_or_init(|| { + let _ = log::set_logger(&Capture); + log::set_max_level(log::LevelFilter::Warn); + std::sync::Mutex::new(Vec::new()) + }) + } + + #[test] + fn a_supplied_name_is_logged_escaped() { + // #3692: a line break in the name must not start a forged record in the log file. + let log = logged(); + let not_a_folder = temp_dir("not-a-folder"); + std::fs::write(¬_a_folder, b"x").unwrap(); + let db = db(); + db.load_user_fonts(¬_a_folder); + let r = db.install_font("a\nERROR forged record.ttf", &renamed("SourceSans3-Regular.ttf", FAMILY)).unwrap(); + assert!(r.warning.is_some(), "the font can't be stored"); + let lines = log.lock().unwrap(); + let line = lines.iter().find(|l| l.contains("forged record")).unwrap(); + assert!(!line.contains('\n'), "{line}"); + let _ = std::fs::remove_file(¬_a_folder); + } + #[test] fn stored_names_are_safe_and_unique() { let ttf = renamed("SourceSans3-Regular.ttf", FAMILY); From ce7e4ea28eeb9710dff3bec754ef4a137fe5c1ab Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Thu, 22 Oct 2026 16:43:28 +0100 Subject: [PATCH 138/199] #3716: a font file's faces are capped before they are built Finding 59: enumerate_faces took a collection's member count straight from its header (a TTC can list one table directory millions of times) and font_faces read every fvar named instance (up to 65535, with a linear style dedupe and a name-table lookup each). Every entry became a FontFace kept for the session, and add_fonts then checked each new face against every face in the database. Now a file yields at most 256 faces (MAX_FILE_FACES; user fonts ask for one more so they can still be refused as "more than 256 faces"), named instances are read to the same cap, a face with the family and style of an earlier one in the file is skipped (repeated collection offsets), and add_fonts dedupes against an indexed set. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/fonts/src/fontdb.rs | 109 ++++++++++++++++++++++++++--- crates/fonts/src/fontdb/user.rs | 2 +- crates/fonts/src/tests_sysfonts.rs | 2 +- 3 files changed, 101 insertions(+), 12 deletions(-) diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index 973c7427da..e3111a3c86 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -825,7 +825,8 @@ fn font_faces(f: &skrifa::FontRef<'_>, index: u32) -> Vec { let east_asian = east_asian_font(f); let axes = f.axes(); let mut named: Vec = Vec::new(); - for ni in f.named_instances().iter() { + // At most one more than a file may add, so a caller can tell there are more (#3716). + for ni in f.named_instances().iter().take(MAX_FILE_FACES + 1) { let Some(style) = name(f, &[ni.subfamily_name_id()]) else { continue }; if named.iter().any(|n| n.style.eq_ignore_ascii_case(&style)) { continue; @@ -854,15 +855,25 @@ fn font_faces(f: &skrifa::FontRef<'_>, index: u32) -> Vec { }] } -/// Parse every face in `data` (a font file or collection); a variable font yields one face per -/// named instance. -fn enumerate_faces(data: &[u8]) -> Vec { +/// Most faces one font file adds (collection members, variable fonts' named instances). +const MAX_FILE_FACES: usize = 256; + +/// Parse the faces in `data` (a font file or collection), at most `limit` of them; a variable +/// font yields one face per named instance. A face with the family and style of an earlier one +/// is left out (a collection can list one font many times, #3716). +fn enumerate_faces(data: &[u8], limit: usize) -> Vec { let count = match FileRef::new(data) { Ok(FileRef::Font(_)) => 1, - Ok(FileRef::Collection(c)) => c.len(), + Ok(FileRef::Collection(c)) => c.len().min(u32::try_from(limit).unwrap_or(u32::MAX)), Err(_) => 0, }; - (0..count).filter_map(|i| skrifa::FontRef::from_index(data, i).ok().map(|f| font_faces(&f, i))).flatten().collect() + let mut seen = std::collections::HashSet::new(); + (0..count) + .filter_map(|i| skrifa::FontRef::from_index(data, i).ok().map(|f| font_faces(&f, i))) + .flatten() + .filter(|f| seen.insert((f.family.to_ascii_lowercase(), f.style.to_ascii_lowercase()))) + .take(limit) + .collect() } /// The compiled-in Source Sans 3 Regular, for a database that somehow has no faces at all. @@ -986,7 +997,7 @@ impl FontDb { let others = crate::CRAFT_FONTS.iter().filter(|f| !f.scripts.contains(&"Jpan")); let craft = crate::japanese_document_fonts().into_iter().chain(others).map(|f| f.bytes); for data in BUNDLED.iter().copied().chain(craft) { - for f in enumerate_faces(data) { + for f in enumerate_faces(data, MAX_FILE_FACES) { if let Some(mut face) = make_face(FontBytes::Static(data), f.index, f.family, f.style, f.legacy, f.coords) { face.local_names = f.local; faces.push(Arc::new(face)); @@ -1173,7 +1184,7 @@ impl FontDb { for (bytes, name) in files { let data = Arc::new(bytes); let name: Option = name.map(|n| n.trim().chars().take(MAX_FAMILY_NAME).collect()).filter(|n: &String| !n.is_empty()); - for f in enumerate_faces(&data) { + for f in enumerate_faces(&data, MAX_FILE_FACES) { if let Some(mut face) = make_face(FontBytes::Owned(data.clone()), f.index, f.family, f.style, f.legacy, f.coords) { face.local_names = f.local; if let Some(n) = name.as_deref().filter(|n| !face.named(n) && !face.local_names.iter().any(|l| l.name.eq_ignore_ascii_case(n))) { @@ -1185,9 +1196,12 @@ impl FontDb { } } let mut faces = self.faces.write().unwrap_or_else(std::sync::PoisonError::into_inner); + // Indexed: a document's fonts may add hundreds of faces to thousands (#3716). + let key = |f: &FontFace| (f.family.to_ascii_lowercase(), f.style.to_ascii_lowercase()); + let mut present: std::collections::HashSet<_> = faces.iter().map(|g| key(g)).collect(); let mut added = 0; for f in new { - if !faces.iter().any(|g| g.family.eq_ignore_ascii_case(&f.family) && g.style.eq_ignore_ascii_case(&f.style)) { + if present.insert(key(&f)) { faces.push(f); added += 1; } @@ -1387,7 +1401,9 @@ impl FontDb { (best.path.clone(), best.family.clone(), best.style.clone()) }; let data = Arc::new(read_fallback_file(&path)?); - let found = enumerate_faces(&data).into_iter().find(|f| f.family.eq_ignore_ascii_case(&fam) && f.style.eq_ignore_ascii_case(&style))?; + let found = enumerate_faces(&data, MAX_FILE_FACES) + .into_iter() + .find(|f| f.family.eq_ignore_ascii_case(&fam) && f.style.eq_ignore_ascii_case(&style))?; let mut face = make_face(FontBytes::Owned(data), found.index, found.family, found.style, found.legacy, found.coords)?; face.local_names = found.local; Some(Arc::new(face)) @@ -2127,4 +2143,77 @@ mod tests { assert!(!db.has_family("NotAFont-Bold")); assert_eq!(db.face("Source Serif 4", "Regular").style, "Regular"); } + + /// A variable font holding just `name` and `fvar` tables: family "Var Test", one `wght` axis + /// and `instances` named instances, each with its own style name. + fn variable_font(instances: u16) -> Vec { + let mut names: Vec<(u16, String)> = vec![(1, "Var Test".into()), (2, "Regular".into())]; + names.extend((0..instances).map(|i| (256 + i, format!("Style {i}")))); + let mut name = vec![0, 0]; + name.extend_from_slice(&(names.len() as u16).to_be_bytes()); + name.extend_from_slice(&(6 + 12 * names.len() as u16).to_be_bytes()); + let mut strings = Vec::new(); + for (id, text) in &names { + let utf16: Vec = text.encode_utf16().flat_map(u16::to_be_bytes).collect(); + for v in [3, 1, 0x409, *id, utf16.len() as u16, strings.len() as u16] { + name.extend_from_slice(&v.to_be_bytes()); + } + strings.extend(utf16); + } + name.extend(strings); + let mut fvar = Vec::new(); + for v in [1u16, 0, 16, 2, 1, 20, instances, 8] { + fvar.extend_from_slice(&v.to_be_bytes()); + } + fvar.extend_from_slice(b"wght"); + for v in [100u32 << 16, 400 << 16, 900 << 16] { + fvar.extend_from_slice(&v.to_be_bytes()); + } + fvar.extend_from_slice(&[0, 0, 0, 2]); // flags, axisNameId + for i in 0..instances { + fvar.extend_from_slice(&(256 + i).to_be_bytes()); + fvar.extend_from_slice(&[0, 0]); + fvar.extend_from_slice(&(400u32 << 16).to_be_bytes()); + } + mini_font(&[(*b"fvar", fvar), (*b"name", name)]) + } + + /// A collection listing `font` `members` times. + fn collection_of(font: &[u8], members: u32) -> Vec { + let start = 12 + 4 * members; + let mut out = b"ttcf\0\x01\0\0".to_vec(); + out.extend_from_slice(&members.to_be_bytes()); + for _ in 0..members { + out.extend_from_slice(&start.to_be_bytes()); + } + let mut font = font.to_vec(); + let tables = u16::from_be_bytes([font[4], font[5]]) as usize; + for i in 0..tables { + let at = 12 + 16 * i + 8; + let offset = u32::from_be_bytes(font[at..at + 4].try_into().unwrap()) + start; + font[at..at + 4].copy_from_slice(&offset.to_be_bytes()); + } + out.extend(font); + out + } + + #[test] + fn named_instances_and_collection_members_are_capped() { + // #3716: a file's faces are capped before they are built, however the file multiplies them. + let font = variable_font(300); + let faces = font_faces(&skrifa::FontRef::new(&font).unwrap(), 0); + assert_eq!(faces.len(), MAX_FILE_FACES + 1, "one more than the cap, so a caller can tell"); + assert_eq!(faces[0].style, "Style 0"); + assert_eq!(enumerate_faces(&font, MAX_FILE_FACES).len(), MAX_FILE_FACES); + // A million members, all the same font: read once, and no more than the cap. + let started = std::time::Instant::now(); + let ttc = collection_of(BUNDLED[0], 1_000_000); + let found = enumerate_faces(&ttc, MAX_FILE_FACES); + assert_eq!(found.iter().map(|f| (f.family.as_str(), f.style.as_str())).collect::>(), [(FALLBACK_FAMILY, "Regular")]); + let db = FontDb::with_font_dirs(vec![]); + assert_eq!(db.add_font(collection_of(&font, 1_000_000)), MAX_FILE_FACES); + // A font the user adds is refused instead. + assert!(matches!(db.install_font("v.ttf", &variable_font(300)), Err(user::UserFontError::Unusable(e)) if e.contains("more than 256"))); + assert!(started.elapsed() < std::time::Duration::from_secs(2), "{:?}", started.elapsed()); + } } diff --git a/crates/fonts/src/fontdb/user.rs b/crates/fonts/src/fontdb/user.rs index 09bbf84708..b81f89d693 100644 --- a/crates/fonts/src/fontdb/user.rs +++ b/crates/fonts/src/fontdb/user.rs @@ -403,7 +403,7 @@ impl FontDb { if FileRef::new(&sfnt).is_err() { return Err(UserFontError::Unusable("not a valid OpenType file".into())); } - let found = enumerate_faces(&sfnt); + let found = enumerate_faces(&sfnt, MAX_FACES_PER_FILE + 1); if found.is_empty() { return Err(UserFontError::Unusable("no face with a family name".into())); } diff --git a/crates/fonts/src/tests_sysfonts.rs b/crates/fonts/src/tests_sysfonts.rs index 5d9c965df8..5a0912c4b2 100644 --- a/crates/fonts/src/tests_sysfonts.rs +++ b/crates/fonts/src/tests_sysfonts.rs @@ -270,7 +270,7 @@ fn the_scan_lists_a_variable_fonts_named_instances_as_loading_it_does() { return; }; let data = std::fs::read(path).unwrap(); - let loaded: Vec<(String, String, String)> = enumerate_faces(&data).into_iter().map(|f| (f.family, f.style, f.legacy)).collect(); + let loaded: Vec<(String, String, String)> = enumerate_faces(&data, MAX_FILE_FACES).into_iter().map(|f| (f.family, f.style, f.legacy)).collect(); assert_eq!(names(path), loaded); if loaded.len() > 1 { // Instances other than Regular, Bold and Italic have names of their own. From b246ea166abe2bd8230bc9abacda0d07e5f28d5e Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Thu, 22 Oct 2026 16:48:05 +0100 Subject: [PATCH 139/199] #3692: COLR gradient stops and clip paths count against a per-glyph budget Finding 61: Recorder copied every gradient's stop list (and every clip glyph's outline) per op, capped only by op count, so a compact COLR graph reusing one long stop list could expand to billions of stops. Stops and path elements now share MAX_PAYLOAD (65,536) per glyph, charged before copying; past it the glyph falls back to its outline and no further clip outlines are built. The render cache charges each glyph's real ops, stops and path bytes (ops_bytes) instead of a flat 64 bytes per op. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/fonts/src/color.rs | 73 ++++++++++++++++++++++++++++++-- crates/render/src/color_glyph.rs | 2 +- 2 files changed, 71 insertions(+), 4 deletions(-) diff --git a/crates/fonts/src/color.rs b/crates/fonts/src/color.rs index 897f31c96b..1f03641b68 100644 --- a/crates/fonts/src/color.rs +++ b/crates/fonts/src/color.rs @@ -18,6 +18,9 @@ use crate::FontFace; /// Upper bound on the ops one colour glyph may expand to. pub const MAX_OPS: usize = 20_000; +/// Upper bound on the gradient stops and clip-path elements one colour glyph may copy in total: a +/// paint graph can reference one long stop list or outline from every op. +pub const MAX_PAYLOAD: usize = 1 << 16; /// Upper bound on a bitmap glyph's width and height in pixels. pub const MAX_BITMAP_SIDE: u32 = 2048; @@ -175,12 +178,26 @@ pub fn bitmap_placement(bg: &skrifa::bitmap::BitmapGlyph<'_>, upem: f64) -> Opti finite.then_some(ColorBitmap { data, width: w, height: h, rect, ppem: bg.ppem_y }) } +/// Approximate heap bytes held by `ops`, counting each op's copied stops and path elements. +pub fn ops_bytes(ops: &[ColorOp]) -> usize { + let payload = |op: &ColorOp| match op { + ColorOp::PushClip(p) => p.elements().len().saturating_mul(std::mem::size_of::()), + ColorOp::Fill(ColorPaint::Linear { stops, .. } | ColorPaint::Radial { stops, .. } | ColorPaint::Sweep { stops, .. }) => { + stops.len().saturating_mul(std::mem::size_of::()) + } + _ => 0, + }; + ops.iter().fold(ops.len().saturating_mul(std::mem::size_of::()), |n, op| n.saturating_add(payload(op))) +} + /// A [`ColorPainter`] that records the paint calls as [`ColorOp`]s, resolving palette colours and -/// clip glyphs. Stops recording (and [`Recorder::finish`] gives None) past [`MAX_OPS`]. +/// clip glyphs. Stops recording (and [`Recorder::finish`] gives None) past [`MAX_OPS`] or +/// [`MAX_PAYLOAD`]. pub struct Recorder<'a, F: Fn(GlyphId) -> BezPath> { palette: &'a [[u8; 4]], outline: F, ops: Vec, + payload: usize, overflow: bool, } @@ -188,7 +205,7 @@ impl<'a, F: Fn(GlyphId) -> BezPath> Recorder<'a, F> { /// A recorder with palette `palette` (straight RGBA) and clip outlines (font units, y up) from /// `outline`. pub fn new(palette: &'a [[u8; 4]], outline: F) -> Self { - Recorder { palette, outline, ops: vec![], overflow: false } + Recorder { palette, outline, ops: vec![], payload: 0, overflow: false } } /// The recorded ops, None when the glyph was too big or its pushes and pops don't balance. @@ -218,6 +235,15 @@ impl<'a, F: Fn(GlyphId) -> BezPath> Recorder<'a, F> { self.ops.push(op); } + /// Charge `n` stops or path elements to the glyph's payload; false (and overflow) past the budget. + fn charge(&mut self, n: usize) -> bool { + self.payload = self.payload.saturating_add(n); + if self.payload > MAX_PAYLOAD { + self.overflow = true; + } + !self.overflow + } + fn color(&self, palette_index: u16, alpha: f32) -> GlyphColor { let alpha = if alpha.is_finite() { alpha.clamp(0.0, 1.0) } else { 1.0 }; if palette_index == 0xFFFF { @@ -249,8 +275,13 @@ impl BezPath> ColorPainter for Recorder<'_, F> { self.push(ColorOp::PopTransform); } fn push_clip_glyph(&mut self, glyph_id: GlyphId) { + if self.overflow { + return; + } let p = (self.outline)(glyph_id); - self.push(ColorOp::PushClip(p)); + if self.charge(p.elements().len()) { + self.push(ColorOp::PushClip(p)); + } } fn push_clip_box(&mut self, b: BoundingBox) { let r = Rect::new(b.x_min as f64, b.y_min as f64, b.x_max as f64, b.y_max as f64); @@ -260,6 +291,15 @@ impl BezPath> ColorPainter for Recorder<'_, F> { self.push(ColorOp::PopClip); } fn fill(&mut self, brush: Brush<'_>) { + let stops = match &brush { + Brush::Solid { .. } => 0, + Brush::LinearGradient { color_stops, .. } | Brush::RadialGradient { color_stops, .. } | Brush::SweepGradient { color_stops, .. } => { + color_stops.len() + } + }; + if !self.charge(stops) { + return; + } let paint = match brush { Brush::Solid { palette_index, alpha } => ColorPaint::Solid(self.color(palette_index, alpha)), Brush::LinearGradient { p0, p1, color_stops, extend } => { @@ -337,6 +377,33 @@ mod tests { assert!(r.finish().is_none()); } + #[test] + fn gradient_stops_and_clip_paths_share_a_payload_budget() { + // A few hundred fills reusing one long stop list would copy it per fill, far under MAX_OPS. + let stops = vec![ColorStop { offset: 0.5, palette_index: 0, alpha: 1.0 }; 4096]; + let fills = MAX_PAYLOAD / stops.len() + 1; + assert!(fills < MAX_OPS); + let outlines = std::cell::Cell::new(0); + let count = |g| { + outlines.set(outlines.get() + 1); + square(g) + }; + let mut r = Recorder::new(&[], count); + for _ in 0..fills { + r.fill(Brush::LinearGradient { p0: Default::default(), p1: Default::default(), color_stops: &stops, extend: Extend::Pad }); + } + assert!(r.ops.len() < fills, "stops past the budget are not copied"); + r.push_clip_glyph(GlyphId::new(1)); + r.pop_clip(); + assert_eq!(outlines.get(), 0, "no clip outline is built once the budget is spent"); + assert!(r.finish().is_none()); + // Within budget nothing changes, and the byte estimate counts the copied stops. + let mut r = Recorder::new(&[], square); + r.fill(Brush::LinearGradient { p0: Default::default(), p1: Default::default(), color_stops: &stops, extend: Extend::Pad }); + let ops = r.finish().unwrap(); + assert!(ops_bytes(&ops) >= stops.len() * std::mem::size_of::()); + } + fn bitmap(origin: skrifa::bitmap::Origin, data: skrifa::bitmap::BitmapData<'_>) -> skrifa::bitmap::BitmapGlyph<'_> { skrifa::bitmap::BitmapGlyph { data, diff --git a/crates/render/src/color_glyph.rs b/crates/render/src/color_glyph.rs index 8b169d9b6c..9a77c1a7a6 100644 --- a/crates/render/src/color_glyph.rs +++ b/crates/render/src/color_glyph.rs @@ -23,7 +23,7 @@ pub(crate) enum Drawable { impl Drawable { fn bytes(&self) -> usize { match self { - Drawable::Layers(ops) => ops.len().saturating_mul(64), + Drawable::Layers(ops) => wordcraft_fonts::color::ops_bytes(ops), Drawable::Bitmap { pixmap, .. } => pixmap.width() as usize * pixmap.height() as usize * 4, } } From bcce02ab369c4a29864dc9ee0e472ea8b050fe05 Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Thu, 22 Oct 2026 16:56:36 +0100 Subject: [PATCH 140/199] #3692: confined automation adds or removes fonts only when the font store is inside its write root Finding 44: font.add stored the file in the user font folder and font.remove deleted stored files directly, bypassing the automation write root (#769). Both now check the user font folder against the write policy in force before touching the database or the disk; with no folder (headless) a font still lasts for the session only. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/engine/src/cmd/font.rs | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/crates/engine/src/cmd/font.rs b/crates/engine/src/cmd/font.rs index dfb7a48166..6160f84713 100644 --- a/crates/engine/src/cmd/font.rs +++ b/crates/engine/src/cmd/font.rs @@ -43,6 +43,15 @@ fn read_font_file(path: &str) -> Result, CmdError> { crate::io::read_file_capped(path, wordcraft_fonts::MAX_FONT_FILE_BYTES as u64, "font file").map_err(CmdError::Failed) } +/// Adding or removing a font writes to the user font folder: automation confined to a write root +/// (#769) may do that only when the folder is inside it. +fn check_font_store(dir: Option<&std::path::Path>) -> Result<(), CmdError> { + match dir { + Some(dir) => crate::file_access::check_write(&dir.to_string_lossy()).map_err(CmdError::Failed), + None => Ok(()), + } +} + fn add(s: &mut Session, v: &Value) -> CmdResult { if p::str(v, "url").is_some() { return Err(CmdError::Failed( @@ -71,6 +80,7 @@ fn add(s: &mut Session, v: &Value) -> CmdResult { s.push_ui_request(json!({"open": "addFont"})); return Ok(json!({"pending": true})); }; + check_font_store(FontDb::global().user_font_dir().as_deref())?; let r = FontDb::global().install_font(&name, &bytes).map_err(|e| CmdError::Failed(e.to_string()))?; // Text already set in the family (shown in a fallback until now) takes the new font. s.relayout(); @@ -100,8 +110,33 @@ fn list(_: &mut Session, v: &Value) -> CmdResult { fn remove(s: &mut Session, v: &Value) -> CmdResult { let family = p::str(v, "family").filter(|f| !f.trim().is_empty()).ok_or_else(|| CmdError::Params("family is required".into()))?; + check_font_store(FontDb::global().user_font_dir().as_deref())?; let gone = FontDb::global().remove_user_font(family.trim()).map_err(|e| CmdError::Failed(e.to_string()))?; s.relayout(); s.status = format!("Removed {}", family.trim()); Ok(json!({"removed": gone.iter().map(font_json).collect::>()})) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn confined_automation_changes_only_a_font_store_inside_its_write_root() { + let root = std::env::temp_dir().join(format!("wordcraft-font-store-{}", std::process::id())); + let outside = std::env::temp_dir().join(format!("wordcraft-font-store-out-{}", std::process::id())); + std::fs::create_dir_all(&root).unwrap(); + std::fs::create_dir_all(&outside).unwrap(); + let write = crate::file_access::AutomationRoots::new(None, Some(&root)).unwrap(); + let read_only = crate::file_access::AutomationRoots::new(Some(&root), None).unwrap(); + crate::file_access::confine(write.as_ref(), || { + assert!(check_font_store(None).is_ok(), "no folder: the font lasts for the session"); + assert!(check_font_store(Some(&root.join("fonts"))).is_ok()); + assert!(check_font_store(Some(&outside)).is_err()); + }); + crate::file_access::confine(read_only.as_ref(), || assert!(check_font_store(Some(&root)).is_err())); + assert!(check_font_store(Some(&outside)).is_ok(), "unconfined: the person at the keyboard"); + let _ = std::fs::remove_dir_all(&root); + let _ = std::fs::remove_dir_all(&outside); + } +} From c7148d6ba9ba5e955a79723b269af3a809ca4edb Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Thu, 22 Oct 2026 17:00:58 +0100 Subject: [PATCH 141/199] #3692: hint-reliant fonts with huge zones are drawn unhinted, and a slow hinted glyph turns hinting off Finding 62 (partial): skrifa 0.47 caps a hinting run at 1M instructions but not the points each moves (SHZ, IUP... walk a whole zone), and a font opts into the interpreter by its family name. Faces asking for a twilight zone over 1,024 points or a stack over 4,096 values get no interpreter; a glyph whose hinting buffer passes 256 KiB is drawn unhinted; and (native) one hinted glyph over 50 ms turns hinting off for its face. Residual: a face within the limits can still spend about 1M x zone size point moves on one glyph (and its prep program) before the strike lands; the full fix is a point-work budget in skrifa or an isolated worker with a deadline. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/fonts/src/fontdb.rs | 93 +++++++++++++++++++++++++++++++++----- 1 file changed, 82 insertions(+), 11 deletions(-) diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index e3111a3c86..818f086d09 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -7,7 +7,7 @@ use std::sync::{Arc, Mutex, RwLock}; use kurbo::BezPath; use skrifa::instance::{Location, LocationRef, Size}; -use skrifa::outline::{DrawSettings, Engine, HintingInstance, HintingOptions, OutlinePen, Target}; +use skrifa::outline::{DrawSettings, Engine, Hinting, HintingInstance, HintingOptions, OutlinePen, Target}; use skrifa::raw::FileRef; use skrifa::string::StringId; use skrifa::{GlyphId, MetadataProvider}; @@ -88,8 +88,24 @@ pub struct FontFace { bmp: std::sync::OnceLock>, // Some legacy CJK fonts assemble their contours with TrueType instructions. outline_hinter: std::sync::OnceLock>, + /// Set once a hinted glyph ran too long: the face is drawn unhinted from then on. + hinting_off: std::sync::atomic::AtomicBool, } +/// Limits on what a face's TrueType instructions may run over (#3692). skrifa's interpreter budget +/// counts instructions, not the points each one moves (SHZ, IUP… walk a whole zone), so a font with +/// huge zones turns a short program into billions of point moves. Real fonts stay far below: a +/// twilight zone of tens to hundreds of points, a stack of up to ~2,000 values, a few thousand +/// points a glyph. Past them, glyphs are drawn unhinted. +const MAX_HINT_TWILIGHT: u16 = 1024; +const MAX_HINT_STACK: u16 = 4096; +/// skrifa's buffer for one hinted glyph (points, stack, twilight zone, CVT and storage). +const MAX_HINT_GLYPH_BYTES: usize = 256 * 1024; +/// A hinted glyph slower than this turns hinting off for its face. A face under the limits above +/// can still take seconds for one glyph: this keeps it to one. Native only (no clock on wasm). +#[cfg(not(target_arch = "wasm32"))] +const HINT_STRIKE: std::time::Duration = std::time::Duration::from_millis(50); + /// A cheap `Copy` handle to a face. Faces are never unloaded, so the handle lives for the rest of /// the process; unlike cloning an `Arc`, copying it touches no shared reference count (which made /// parallel composition scale negatively). @@ -181,11 +197,39 @@ impl FontFace { if let Some(f) = self.skrifa() && let Some(g) = f.outline_glyphs().get(GlyphId::new(gid)) { - let hinter = self.outline_hinter.get_or_init(|| { + let hinter = + self.hinter().filter(|_| !self.hinting_off.load(Ordering::Relaxed) && g.draw_memory_size(Hinting::Embedded) <= MAX_HINT_GLYPH_BYTES); + #[cfg(not(target_arch = "wasm32"))] + let started = std::time::Instant::now(); + let settings = hinter.map_or_else(|| DrawSettings::unhinted(Size::unscaled(), self.location()), |h| DrawSettings::hinted(h, false)); + let drawn = g.draw(settings, &mut pen); + #[cfg(not(target_arch = "wasm32"))] + if hinter.is_some() && started.elapsed() > HINT_STRIKE && !self.hinting_off.swap(true, Ordering::Relaxed) { + log::warn!("Hinting glyph {gid} in {:?} took {:?}: drawing the face unhinted", self.family, started.elapsed()); + } + if let Err(e) = drawn { + log::warn!("Cannot draw glyph {gid} in {:?}: {e}", self.family); + return BezPath::new(); + } + } + pen.0 + } + /// The face's TrueType interpreter, for the fonts that need one to assemble their contours and + /// whose zones are within the limits ([`MAX_HINT_TWILIGHT`]). + fn hinter(&self) -> Option<&HintingInstance> { + self.outline_hinter + .get_or_init(|| { + use skrifa::raw::TableProvider; + let f = self.skrifa()?; let outlines = f.outline_glyphs(); if !outlines.require_interpreter() { return None; } + let zones = f.maxp().ok().map(|m| (m.max_twilight_points().unwrap_or(0), m.max_stack_elements().unwrap_or(0))); + if !zones.is_some_and(|(twilight, stack)| twilight <= MAX_HINT_TWILIGHT && stack <= MAX_HINT_STACK) { + log::warn!("{:?} asks for hinting zones of {zones:?}: drawing it unhinted", self.family); + return None; + } // At UPEM pixels, hinted coordinates remain in font units. This // executes contour assembly without tying the cache to view zoom. HintingInstance::new( @@ -196,15 +240,8 @@ impl FontFace { ) .map_err(|e| log::warn!("Cannot initialize outline interpreter for {:?}: {e}", self.family)) .ok() - }); - let settings = - hinter.as_ref().map_or_else(|| DrawSettings::unhinted(Size::unscaled(), self.location()), |h| DrawSettings::hinted(h, false)); - if let Err(e) = g.draw(settings, &mut pen) { - log::warn!("Cannot draw glyph {gid} in {:?}: {e}", self.family); - return BezPath::new(); - } - } - pen.0 + }) + .as_ref() } /// Is `family` this face's typographic, legacy, full or PostScript name? pub fn named(&self, family: &str) -> bool { @@ -935,6 +972,7 @@ fn make_face(bytes: FontBytes, index: u32, family: String, style: String, legacy index, bmp: std::sync::OnceLock::new(), outline_hinter: std::sync::OnceLock::new(), + hinting_off: Default::default(), }) } @@ -2197,6 +2235,39 @@ mod tests { out } + /// JetBrains Mono renamed "MingLiU JBMono" (a name the interpreter is required for), with its + /// `maxp` twilight-zone size set to `twilight`. + fn hint_reliant_font(twilight: u16) -> Vec { + let mut data = std::fs::read(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/fonts/JetBrainsMono-Regular.ttf")).unwrap(); + let utf16 = |s: &str| s.encode_utf16().flat_map(u16::to_be_bytes).collect::>(); + for (from, to) in [(utf16("JetBrains Mono"), utf16("MingLiU JBMono")), (b"JetBrains Mono".to_vec(), b"MingLiU JBMono".to_vec())] { + while let Some(at) = data.windows(from.len()).position(|w| w == from) { + data[at..at + to.len()].copy_from_slice(&to); + } + } + let tables = u16::from_be_bytes([data[4], data[5]]) as usize; + let entry = (0..tables).map(|i| 12 + 16 * i).find(|e| &data[*e..e + 4] == b"maxp").unwrap(); + let maxp = u32::from_be_bytes(data[entry + 8..entry + 12].try_into().unwrap()) as usize; + data[maxp + 16..maxp + 18].copy_from_slice(&twilight.to_be_bytes()); + data + } + + #[test] + fn a_font_asking_for_huge_hinting_zones_is_drawn_unhinted() { + // #3692: skrifa's budget counts instructions, not the points each one moves. + let db = FontDb::with_font_dirs(vec![]); + db.add_font(hint_reliant_font(660)); + let face = db.face("MingLiU JBMono", "Regular"); + assert_eq!(face.family, "MingLiU JBMono"); + assert!(face.hinter().is_some(), "a real font's zones are hinted"); + assert!(!face.glyph_outline(face.glyph_for('a')).elements().is_empty()); + let db = FontDb::with_font_dirs(vec![]); + db.add_font(hint_reliant_font(u16::MAX)); + let face = db.face("MingLiU JBMono", "Regular"); + assert!(face.hinter().is_none(), "a huge twilight zone is not hinted"); + assert!(!face.glyph_outline(face.glyph_for('a')).elements().is_empty(), "drawn unhinted"); + } + #[test] fn named_instances_and_collection_members_are_capped() { // #3716: a file's faces are capped before they are built, however the file multiplies them. From 0f1a127e63c70a4d16bcd3c673dc7f031e4adbb3 Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Thu, 22 Oct 2026 17:01:42 +0100 Subject: [PATCH 142/199] Findings 39, 70, 59, 46, 61, 44, 62: record the new font limits where the matching parity rows and the MCP write-root list live. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/file-format-parity.md | 3 ++- docs/layout-parity.md | 5 +++-- docs/mcp.md | 3 ++- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index c2b352424f..bb96a62c6b 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -111,7 +111,7 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen | Native Word ink (`w14:contentPart`, InkML) | 🟡 | 🟡 | Drawing-level ink kept opaque with its InkML part, relationship and content type; direct run-level `w:contentPart` and run-level `mc:AlternateContent` keep the native branch and VML picture fallback. Direct content parts remain direct through repeated save/reopen cycles, without a native rendering. Drawing-level ink shows the preview or a missing-picture box. Ink members of groups (`wpg:wgp`, nested groups too) and drawing canvases (`wpc:wpc`), bare or in `mc:AlternateContent` with a `pic:pic` fallback, keep their markup, part, relationship and place among the members; they are written at the member's current position in the group and drawn from their fallback picture. Such a group can't be ungrouped yet (Ungroup is unavailable, so the ink is never left in a form it can't be saved in). Their part is dropped with the group (no orphan part); ink whose part is missing or isn't InkML gives way to its fallback, else is dropped. No native stroke rendering, eraser, conversion or replay | | 3D models | ❌ | ❌ | Dropped on read, not preserved on save | | Equations (OMML `m:oMath`) | ✅ | ✅ | #191; manual breaks (`m:brk`) and the settings' `m:mathPr` wrapping options (#326). Every ECMA-376 §22.1 element is read and laid out (pinned by `read::math::tests::every_omml_element_keeps_its_content`); equations straight in the body or a cell (no `w:p`), behind `mc:AlternateContent Requires="m"`, or with runs inside hyperlinks, simple fields, content controls and tracked insertions inside the equation keep their content; `w:sym` and Symbol-font text in math become Unicode (#1273). Not yet: Equation Editor 3.0 / MathType OLE objects show their WMF preview only (text in #1994), `m:mathFont` (Cambria Math is always asked for), `m:dispDef`/`m:defJc`/`m:lMargin`, tracked deletions shown struck through inside equations | -| Themes, font table, settings, compatibility mode | ✅ | ✅ | `w:allowSpaceOfSameStyleInTable` read and written in schema order; absent/false default off, enabled flag retained in modes 11/12/14/15. Embedded fonts (`w:embedRegular`, `w:embedBold`…) lay out and export the text set in them for the session (#1176), subsets Word embeds ("embed only the characters used", a made-up family such as `___WRD_EMBED_SUB_46` inside) found by the font table's name (#2240); the font table, its relationships and the embedded font parts are written back unchanged (#770); attached template (`w:attachedTemplate` and its external relationship) and `w:linkStyles` round-trip (#377). `w:removePersonalInformation` and `w:removeDateAndTime` round-trip and are honoured on save (#1062): comment and revision authors written as "Author" (initials "A"), no `dc:creator`/`cp:lastModifiedBy`, no annotation dates; the open document keeps its values (`file.privacyOptions` sets them, `file.privacy` reads them; File › Options › Save › Privacy for this document). The template path comes from the file, so opening never reads it: styles update from it only on an explicit Update Styles. Settings the model doesn't hold (other `w:compatSetting`s, rsids, document variables, proofing state, colour scheme mapping…) survive a save: the writer merges into the file's own `settings.xml` ([below](#settings-preservation)); `w:latentStyles`, `w:default` on localized default styles and lists' `w:nsid`/`w:tmpl`/`w:multiLevelType` round-trip | +| Themes, font table, settings, compatibility mode | ✅ | ✅ | `w:allowSpaceOfSameStyleInTable` read and written in schema order; absent/false default off, enabled flag retained in modes 11/12/14/15. Embedded fonts (`w:embedRegular`, `w:embedBold`…) lay out and export the text set in them for the session (#1176), subsets Word embeds ("embed only the characters used", a made-up family such as `___WRD_EMBED_SUB_46` inside) found by the font table's name (#2240); an embedded font file yields at most 256 faces (collection members and named instances, #3716); the font table, its relationships and the embedded font parts are written back unchanged (#770); attached template (`w:attachedTemplate` and its external relationship) and `w:linkStyles` round-trip (#377). `w:removePersonalInformation` and `w:removeDateAndTime` round-trip and are honoured on save (#1062): comment and revision authors written as "Author" (initials "A"), no `dc:creator`/`cp:lastModifiedBy`, no annotation dates; the open document keeps its values (`file.privacyOptions` sets them, `file.privacy` reads them; File › Options › Save › Privacy for this document). The template path comes from the file, so opening never reads it: styles update from it only on an explicit Update Styles. Settings the model doesn't hold (other `w:compatSetting`s, rsids, document variables, proofing state, colour scheme mapping…) survive a save: the writer merges into the file's own `settings.xml` ([below](#settings-preservation)); `w:latentStyles`, `w:default` on localized default styles and lists' `w:nsid`/`w:tmpl`/`w:multiLevelType` round-trip | | Custom XML parts, document properties, bibliography sources | 🟡 | 🟡 | Custom XML stores reached from the package/main document preserve opaque data, properties and relationships; binding evaluation remains absent. Custom properties and bibliography sources round-trip (#532). Word's own bibliography file (`b:Sources`, `Sources.xml`) imports into Manage Sources, sharing the reader of a document's own bibliography part (#2640) | | Section text direction (`w:textDirection` in `w:sectPr`, §17.6.20) | ✅ | ✅ | `tbRl`, `btLr` (and `tbRlV`/`tbLrV`, read as `tbRl`); `lrTbV` and unknown values read as horizontal (#491) | | Section vertical alignment (`w:vAlign` in `w:sectPr`, §17.6.23) | ✅ | ✅ | `top`, `center`, `bottom` and `both` (Justified, read as Center before #1320); unknown values read as top, top writes nothing (`crates/docx/tests/it/section_valign.rs`). ODT/RTF don't carry it | @@ -270,6 +270,7 @@ Repeated DOCX style IDs use the last complete definition. The original regressio | 2026-10-23 | trivial | cu20we: preserve and render outline caps across DOCX/VML, screen and PDF; synthetic regressions; readiness estimates unchanged | | 2026-10-23 | trivial | `.doc` reader logs what its copy budget leaves out (floating objects, link targets, alt text, Prm formatting), once a kind per document (#3692) | | 2026-10-22 | trivial | `.doc` reader bounds hostile files: oversized sector tables refused, per-character re-reads removed, copy and decode budgets (#3642, #3692, #3708-#3712) | +| 2026-10-22 | trivial | An embedded font file yields at most 256 faces however many collection members or named instances it lists (#3716) | | 2026-10-23 | trivial | A relationship the markup names without the prefix is read as it wrote: an unqualified `id` on an element that carries a relationship names that relationship, and the writer keeps the attribute with the id the new package holds the part under; an ordinary `id` — a VML shape's, a drawing's — names no part (#3411 follow-up) | | 2026-10-21 | trivial | The reader takes the relationship ids the markup refers to from the relationship attributes of the main document it has already parsed, so a document with many relationships opens in the time its part takes, and an id that only appears as text is not a reference (#3411) | | 2026-10-23 | trivial | DOCX: TOC styles Word stores under ids in another language (`Sumrio1` named `toc 1`, `CabealhodoSumrio` named `TOC Heading`) read as `TOC1`…`TOC9` and `TOCHeading` unless the file uses those ids, so Update Table builds the entries in the document's own TOC styles (#3891); a TOC whose heading ends a section (entries in columns after a continuous break) is written and read as the engine's TOC, with the page-number tab at the column's edge (#3889) | diff --git a/docs/layout-parity.md b/docs/layout-parity.md index d35ce0a1cd..32e6b82ceb 100644 --- a/docs/layout-parity.md +++ b/docs/layout-parity.md @@ -23,7 +23,7 @@ Exact line spacing keeps the run font baseline around tall equations; automatic | Feature | State | Evidence / gap | Hours | |---|---|---|---| | 2026-10-16 | trivial | Split-cell content retains its resolved top margin and border band on continuation pages, matching original Word Mac16.113.2 controls. Synthetic regressions cover margins, borders, line spacing and image frames. | -| Hint-reliant CJK glyph outlines (DFKai-SB / 標楷體) | 🟡 | Required TrueType interpreter runs at units-per-em size; local Windows glyph-bounds regression and synthetic DOCX page/window comparison pass. Other platforms and hint-reliant families remain unmeasured; no native Word comparison. | — | +| Hint-reliant CJK glyph outlines (DFKai-SB / 標楷體) | 🟡 | Required TrueType interpreter runs at units-per-em size; local Windows glyph-bounds regression and synthetic DOCX page/window comparison pass. Other platforms and hint-reliant families remain unmeasured; no native Word comparison. A font asking for a twilight zone over 1,024 points or a stack over 4,096 values, a glyph past 256 KiB of hinting buffer, or (desktop) a face whose glyph took over 50 ms to hint is drawn unhinted (#3692). | — | | Subpoint table grid columns | 🟡 | Preserve positive grid widths rather than imposing a text-box minimum; ECMA-376 §17.4.16. Native Word for Mac 16.113.2 build26092012 confirms narrow trailing grid units. `layout::tests::subpoint_grid_keeps_widths`, `tiny_grid_scales_to_width`. Tiny totals normalize without overflowing the scale factor. Zero and invalid widths retain their fallback. Border painting remains separate. | n/a | | Line breaking (UAX #14 with Word's exceptions: no break after `/`, a break after `-` before a digit) | ✅ | `layout::tests::no_line_break_right_after_a_slash`, `layout::tests::a_hyphen_before_digits_is_a_break_opportunity_like_word`, `layout::tests::a_bookmark_is_no_line_break_opportunity` | — | | A break before a slash that starts a word (" /word" wraps whole; not after a no-break space) | 🟡 | `layout::tests::a_word_after_a_slash_wraps_whole`, `layout::tests::a_no_break_space_before_a_slash_still_holds`; behaviour from the #2963 report, not compared with Word yet | — | @@ -35,7 +35,7 @@ Exact line spacing keeps the run font baseline around tall equations; automatic | Missing Thai document fonts (TH Sarabun New, Angsana New, Cordia New, Browallia New, the UPC families, Leelawadee): one Thai substitute face for the whole run, Latin letters, digits and ฿ included, so the run keeps one font at one size as Word's Cordia New substitution does | ✅ | `fonts::word::tests::a_missing_thai_font_resolves_to_an_installed_thai_face`, `fonts::fontdb::tests::a_thai_run_with_latin_falls_back_to_one_face`, `layout::tests_complex::a_missing_thai_font_draws_the_whole_run_in_one_thai_face`, `layout::tests_complex::a_thai_substitute_without_latin_hands_the_run_to_one_face_covering_it`, `fonts::fontdb::tests_sysfonts::a_run_no_preferred_face_covers_whole_loads_an_installed_font_that_does` (#1549). Windows: Cordia New, then Leelawadee UI, Tahoma, Noto Sans Thai; macOS: Thonburi (Sathu for the serif families); Linux: Noto Sans Thai, then the TLWG fonts. The first listed face with Latin letters and digits as well as Thai wins. Other Thai names (`TH …`, `…UPC`, names with `Thai`) use the same lists. When no listed face is installed, or the one found lacks the run's Thai or Latin letters, the run moves to one installed face covering its Thai and Latin text if there is one, even when a preferred Thai family without Latin letters (Noto Sans Thai) is installed; that face's whole family is loaded, so regular text keeps the Regular face. Only Thai fonts: a missing Latin font keeps Latin text in its own substitute, and a run with its own complex-script font keeps it for the Thai letters, as Word does | — | | Distributed / Thai distributed alignment | 🟡 | read; layout untested | 1–2 | | Default font metrics (Aptos, Calibri, Cambria…) | 🟡 | Missing Arial Unicode MS tries Arial then the Arial substitutes, case-insensitively (`fonts::word::tests::unicode_arial_is_proportional`); glyph coverage and exact Word line height can differ. Carlito/Caladea/Liberation are metric-compatible for Calibri/Cambria/Arial/Times/Courier; **Aptos (Word's default since 2024) has no metric-compatible open substitute**, so new Word documents wrap differently ([typing-parity.md](typing-parity.md) Known gaps). Needs an owner decision (commission or license a metric-matched font) | 6–10 + owner | -| Colour emoji and colour fonts on screen and in page renders: COLR v0/v1 paint graphs (solid fills, linear, radial and sweep gradients, composite modes) and bitmap strikes (Apple Color Emoji `sbix`, Noto Color Emoji `CBDT`) drawn in the font's colours at the layout's position and size; a dark page doesn't invert them, the same as pictures | 🟡 | `fonts::color` (skrifa), `render::color_glyph`; `render::tests_color_glyphs::emoji_are_drawn_in_colour` and `emoji_keep_their_colours_on_a_dark_page` (system font, skip without one), `render::color_glyph::tests`, `fonts::color::tests` (#1269). CBDT is verified on a real page with Noto Color Emoji; COLR v0/v1 and sbix are implemented to the OpenType spec and tested with synthetic data, but not yet seen on a real font (Segoe UI Emoji, COLRv1 Noto, Apple Color Emoji). PDF export already drew them through krilla | 1–2 | +| Colour emoji and colour fonts on screen and in page renders: COLR v0/v1 paint graphs (solid fills, linear, radial and sweep gradients, composite modes) and bitmap strikes (Apple Color Emoji `sbix`, Noto Color Emoji `CBDT`) drawn in the font's colours at the layout's position and size; a dark page doesn't invert them, the same as pictures | 🟡 | `fonts::color` (skrifa), `render::color_glyph`; `render::tests_color_glyphs::emoji_are_drawn_in_colour` and `emoji_keep_their_colours_on_a_dark_page` (system font, skip without one), `render::color_glyph::tests`, `fonts::color::tests` (#1269). A glyph copies at most 65,536 gradient stops and clip-path elements in all, else it falls back to its outline, and the cache charges what it keeps (#3692). CBDT is verified on a real page with Noto Color Emoji; COLR v0/v1 and sbix are implemented to the OpenType spec and tested with synthetic data, but not yet seen on a real font (Segoe UI Emoji, COLRv1 Noto, Apple Color Emoji). PDF export already drew them through krilla | 1–2 | | Full font names in document font requests | ✅ | Exact English (or first available) OpenType name ID4 lookup in loaded faces and the native lazy catalog; family and legacy-family matches keep priority. No style-suffix guessing; named variable-instance aliases remain unsupported | 0 | | Symbol fonts (Symbol including its PCL6 alias, Wingdings, Wingdings 2/3, Webdings) without the proprietary font: each character, stored as U+F020–U+F0FF or a plain 8-bit code, is drawn as its Unicode equivalent from a face that has it; PDF text and copies read as those symbols | ✅ | `doc::symbol_fonts` tables (Adobe Symbol table and Unicode L2/12-368), `layout::tests::wingdings_text_draws_and_reads_as_its_unicode_symbols` (#776). Shapes come from Noto Sans Symbols / Symbols 2, DejaVu Sans or Segoe UI Symbol, so they differ from Word's; Wingdings 0xFF (not in Unicode) still has no symbol. A line keeps the symbol font's own height (Symbol, Wingdings: the ascent and descent Word's PDFs give them), so a Symbol bullet makes its line as tall as in Word, `layout::tests::a_symbol_bullet_sizes_its_line_by_the_symbol_font`. Apple's one-em Symbol takes the same height, as in Word for Mac (`fonts::word::tests::a_one_em_symbol_face_takes_windows_symbols_line`) | 0 | | List bullets stored as a private-use character (U+E000–U+F8FF) in a font that isn't installed and isn't a mapped symbol-font code: drawn as the level's default bullet (• ○ ▪), not as whatever an installed icon font has at that code | ✅ | `layout::tests::private_use_bullet_no_font_draws_shows_the_levels_default_bullet` (#1683: U+F081 showed Font Awesome's Twitter logo) | — | @@ -273,6 +273,7 @@ layout suite are pending; this is not a new Word/native-IME acceptance claim. | 2026-10-23 | trivial | cu20we: preserve and render outline caps across DOCX/VML, screen and PDF; synthetic regressions; readiness estimates unchanged | | 2026-10-23 | trivial | cu20we: paragraph-relative box floats use the pre-spacing origin; header/footer/cell/text-box regressions; readiness estimates unchanged | | 2026-10-22 | trivial | Layout lookups linear in clipped cells, authors, sections, header paragraphs and bidi offsets (#3692, #3706) | +| 2026-10-22 | trivial | Hostile fonts (#3692): hint-reliant fonts with huge hinting zones, or a slow hinted glyph, are drawn unhinted; a colour glyph's gradient stops and clip paths share one budget | | 2026-10-22 | trivial | Group connector rerouting preserves displayed free and unresolved endpoints under nonuniform scaling while resolved ends keep their connection-site path; degenerate dimensions and unrepresentable fallback display spans are guarded; synthetic model/engine coverage only, readiness estimates unchanged | | 2026-10-21 | trivial | A word with no break opportunity in it, laid out beside a floating object, no longer lays out quadratically: every word is measured once, before the line is broken (#3412) | | 2026-10-23 | trivial | An empty paragraph whose mark is hidden is laid out as nothing, like a deleted one: no room, no list number (still counted, as in Word) and no shading, in the body and in table cells, headers and text boxes (#3982) | diff --git a/docs/mcp.md b/docs/mcp.md index 634691776a..f44ecd9aba 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -70,7 +70,8 @@ config works across the suite) keep the agent to the folders you give it: from beside that file, so they are inside the read root too (a link that leads out is not followed). - Every file a command writes must lie inside the **write root**: `save_document`, `file.save`, `file.saveAs`, `file.rename` (the file and its new name), `file.exportPdf`, `file.exportPng`, `file.printPdf`, - `file.saveTemplate`, and `mailings.finish` with a `path`. + `file.saveTemplate`, and `mailings.finish` with a `path`. `font.add` and `font.remove` change the + user font folder, so they are refused when it is outside the write root (#3692). - Read and write access are separate. Give the same folder twice for both; a root left out grants none of its access (with only `--automation-read-root`, nothing can be written to disk). Without either flag nothing changes. From bbe1dad1e7e643b44fc755793b49a2217046746a Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Thu, 22 Oct 2026 21:07:11 +0100 Subject: [PATCH 143/199] #3692: hinting turns off after three slow glyphs, not one CodeRabbit review of finding 62's fix: one hinted draw slowed by a busy machine (scheduler pause, page fault) switched hinting off for the face for the rest of the process, leaving hint-reliant faces with timing-dependent missing strokes. A strike counter now drops hinting only at the third slow glyph. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/fonts/src/fontdb.rs | 34 ++++++++++++++++++++++++++-------- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index 818f086d09..f554b1720f 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -88,8 +88,8 @@ pub struct FontFace { bmp: std::sync::OnceLock>, // Some legacy CJK fonts assemble their contours with TrueType instructions. outline_hinter: std::sync::OnceLock>, - /// Set once a hinted glyph ran too long: the face is drawn unhinted from then on. - hinting_off: std::sync::atomic::AtomicBool, + /// Hinted glyphs that ran too long; at [`MAX_HINT_STRIKES`] the face is drawn unhinted. + hint_strikes: std::sync::atomic::AtomicU8, } /// Limits on what a face's TrueType instructions may run over (#3692). skrifa's interpreter budget @@ -101,10 +101,13 @@ const MAX_HINT_TWILIGHT: u16 = 1024; const MAX_HINT_STACK: u16 = 4096; /// skrifa's buffer for one hinted glyph (points, stack, twilight zone, CVT and storage). const MAX_HINT_GLYPH_BYTES: usize = 256 * 1024; -/// A hinted glyph slower than this turns hinting off for its face. A face under the limits above -/// can still take seconds for one glyph: this keeps it to one. Native only (no clock on wasm). +/// A hinted glyph slower than this is a strike against its face; [`MAX_HINT_STRIKES`] of them turn +/// hinting off. A face under the limits above can still take seconds for one glyph: this keeps it +/// to a few, while one draw slowed by a busy machine doesn't drop hinting. Native only (no clock +/// on wasm). #[cfg(not(target_arch = "wasm32"))] const HINT_STRIKE: std::time::Duration = std::time::Duration::from_millis(50); +const MAX_HINT_STRIKES: u8 = 3; /// A cheap `Copy` handle to a face. Faces are never unloaded, so the handle lives for the rest of /// the process; unlike cloning an `Arc`, copying it touches no shared reference count (which made @@ -190,6 +193,11 @@ impl FontFace { pub fn id(&self) -> u32 { self.id } + /// Counts a slow hinted draw; true when it is the one that turns hinting off. + #[cfg(not(target_arch = "wasm32"))] + fn strike_hinting(&self) -> bool { + self.hint_strikes.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |n| (n < MAX_HINT_STRIKES).then_some(n + 1)) == Ok(MAX_HINT_STRIKES - 1) + } /// Glyph `gid`'s outline in font units, y-down (flipped), uncached: for faces the database /// doesn't keep (font list previews); others go through [`FontDb::outline`]'s cache. pub fn glyph_outline(&self, gid: u32) -> BezPath { @@ -197,14 +205,15 @@ impl FontFace { if let Some(f) = self.skrifa() && let Some(g) = f.outline_glyphs().get(GlyphId::new(gid)) { - let hinter = - self.hinter().filter(|_| !self.hinting_off.load(Ordering::Relaxed) && g.draw_memory_size(Hinting::Embedded) <= MAX_HINT_GLYPH_BYTES); + let hinter = self.hinter().filter(|_| { + self.hint_strikes.load(Ordering::Relaxed) < MAX_HINT_STRIKES && g.draw_memory_size(Hinting::Embedded) <= MAX_HINT_GLYPH_BYTES + }); #[cfg(not(target_arch = "wasm32"))] let started = std::time::Instant::now(); let settings = hinter.map_or_else(|| DrawSettings::unhinted(Size::unscaled(), self.location()), |h| DrawSettings::hinted(h, false)); let drawn = g.draw(settings, &mut pen); #[cfg(not(target_arch = "wasm32"))] - if hinter.is_some() && started.elapsed() > HINT_STRIKE && !self.hinting_off.swap(true, Ordering::Relaxed) { + if hinter.is_some() && started.elapsed() > HINT_STRIKE && self.strike_hinting() { log::warn!("Hinting glyph {gid} in {:?} took {:?}: drawing the face unhinted", self.family, started.elapsed()); } if let Err(e) = drawn { @@ -972,7 +981,7 @@ fn make_face(bytes: FontBytes, index: u32, family: String, style: String, legacy index, bmp: std::sync::OnceLock::new(), outline_hinter: std::sync::OnceLock::new(), - hinting_off: Default::default(), + hint_strikes: Default::default(), }) } @@ -1975,6 +1984,15 @@ mod tests_sysfonts; #[cfg(test)] mod tests { + #[test] + #[cfg(not(target_arch = "wasm32"))] + fn one_slow_hinted_glyph_does_not_turn_hinting_off() { + let db = super::FontDb::with_font_dirs(vec![]); + let face = db.face("Anything", "Regular"); + let strikes: Vec = (0..5).map(|_| face.strike_hinting()).collect(); + assert_eq!(strikes, [false, false, true, false, false]); + assert_eq!(face.hint_strikes.load(std::sync::atomic::Ordering::Relaxed), super::MAX_HINT_STRIKES); + } #[test] #[ignore = "requires a locally licensed kaiu.ttf; set WORDCRAFT_TEST_KAI_FONT"] fn kai_contours_include_complete_character_body() { From e541a6f62fb6395fe1038b4877f3ec1535c753e7 Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Thu, 22 Oct 2026 21:57:45 +0100 Subject: [PATCH 144/199] #3692: hinting turns off only when slow draws are a real share of a face's draws Ultrareview finding 5: the slow-draw count only grew, so three draws slowed by a busy machine at any point in a session switched hinting off until restart, breaking hint-reliant faces (MingLiU, DFKai-SB). Hinting now stops only when at least three draws were slow and they are at least one in 16 of the face's hinted draws: a face slow throughout still stops at its third glyph, and slow glyphs spread among fast ones are still bounded. Counting interpreter work instead of wall-clock stays with #3827. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/fonts/src/fontdb.rs | 68 ++++++++++++++++++++++++++------------ 1 file changed, 47 insertions(+), 21 deletions(-) diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index f554b1720f..9b4ef93721 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -88,8 +88,13 @@ pub struct FontFace { bmp: std::sync::OnceLock>, // Some legacy CJK fonts assemble their contours with TrueType instructions. outline_hinter: std::sync::OnceLock>, - /// Hinted glyphs that ran too long; at [`MAX_HINT_STRIKES`] the face is drawn unhinted. - hint_strikes: std::sync::atomic::AtomicU8, + /// Hinted draws so far, those that ran too long, and whether that turned hinting off + /// ([`FontFace::count_hinted`]). + #[cfg_attr(target_arch = "wasm32", allow(dead_code))] + hint_draws: std::sync::atomic::AtomicU32, + #[cfg_attr(target_arch = "wasm32", allow(dead_code))] + hint_slow: std::sync::atomic::AtomicU32, + hinting_off: std::sync::atomic::AtomicBool, } /// Limits on what a face's TrueType instructions may run over (#3692). skrifa's interpreter budget @@ -101,13 +106,18 @@ const MAX_HINT_TWILIGHT: u16 = 1024; const MAX_HINT_STACK: u16 = 4096; /// skrifa's buffer for one hinted glyph (points, stack, twilight zone, CVT and storage). const MAX_HINT_GLYPH_BYTES: usize = 256 * 1024; -/// A hinted glyph slower than this is a strike against its face; [`MAX_HINT_STRIKES`] of them turn -/// hinting off. A face under the limits above can still take seconds for one glyph: this keeps it -/// to a few, while one draw slowed by a busy machine doesn't drop hinting. Native only (no clock -/// on wasm). +/// A hinted glyph slower than this is a strike against its face. A face under the limits above can +/// still take seconds for one glyph, so [`MIN_HINT_STRIKES`] strikes that are also at least one +/// in [`HINT_SLOW_RATIO`] of its hinted draws turn hinting off: a face that is slow throughout +/// stops after a few glyphs, while the odd draw slowed by a busy machine leaves a face hinted. +/// ponytail: wall-clock, not interpreter work (skrifa has no work bound yet, #3827). Native only +/// (no clock on wasm). #[cfg(not(target_arch = "wasm32"))] const HINT_STRIKE: std::time::Duration = std::time::Duration::from_millis(50); -const MAX_HINT_STRIKES: u8 = 3; +#[cfg(not(target_arch = "wasm32"))] +const MIN_HINT_STRIKES: u32 = 3; +#[cfg(not(target_arch = "wasm32"))] +const HINT_SLOW_RATIO: u32 = 16; /// A cheap `Copy` handle to a face. Faces are never unloaded, so the handle lives for the rest of /// the process; unlike cloning an `Arc`, copying it touches no shared reference count (which made @@ -193,10 +203,16 @@ impl FontFace { pub fn id(&self) -> u32 { self.id } - /// Counts a slow hinted draw; true when it is the one that turns hinting off. + /// Counts a hinted draw, `slow` when it ran past [`HINT_STRIKE`]; true when it is the one that + /// turns hinting off. #[cfg(not(target_arch = "wasm32"))] - fn strike_hinting(&self) -> bool { - self.hint_strikes.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |n| (n < MAX_HINT_STRIKES).then_some(n + 1)) == Ok(MAX_HINT_STRIKES - 1) + fn count_hinted(&self, slow: bool) -> bool { + let draws = self.hint_draws.fetch_add(1, Ordering::Relaxed).saturating_add(1); + if !slow { + return false; + } + let slow = self.hint_slow.fetch_add(1, Ordering::Relaxed).saturating_add(1); + slow >= MIN_HINT_STRIKES && slow.saturating_mul(HINT_SLOW_RATIO) >= draws && !self.hinting_off.swap(true, Ordering::Relaxed) } /// Glyph `gid`'s outline in font units, y-down (flipped), uncached: for faces the database /// doesn't keep (font list previews); others go through [`FontDb::outline`]'s cache. @@ -205,15 +221,14 @@ impl FontFace { if let Some(f) = self.skrifa() && let Some(g) = f.outline_glyphs().get(GlyphId::new(gid)) { - let hinter = self.hinter().filter(|_| { - self.hint_strikes.load(Ordering::Relaxed) < MAX_HINT_STRIKES && g.draw_memory_size(Hinting::Embedded) <= MAX_HINT_GLYPH_BYTES - }); + let hinter = + self.hinter().filter(|_| !self.hinting_off.load(Ordering::Relaxed) && g.draw_memory_size(Hinting::Embedded) <= MAX_HINT_GLYPH_BYTES); #[cfg(not(target_arch = "wasm32"))] let started = std::time::Instant::now(); let settings = hinter.map_or_else(|| DrawSettings::unhinted(Size::unscaled(), self.location()), |h| DrawSettings::hinted(h, false)); let drawn = g.draw(settings, &mut pen); #[cfg(not(target_arch = "wasm32"))] - if hinter.is_some() && started.elapsed() > HINT_STRIKE && self.strike_hinting() { + if hinter.is_some() && self.count_hinted(started.elapsed() > HINT_STRIKE) { log::warn!("Hinting glyph {gid} in {:?} took {:?}: drawing the face unhinted", self.family, started.elapsed()); } if let Err(e) = drawn { @@ -981,7 +996,9 @@ fn make_face(bytes: FontBytes, index: u32, family: String, style: String, legacy index, bmp: std::sync::OnceLock::new(), outline_hinter: std::sync::OnceLock::new(), - hint_strikes: Default::default(), + hint_draws: Default::default(), + hint_slow: Default::default(), + hinting_off: Default::default(), }) } @@ -1986,12 +2003,21 @@ mod tests_sysfonts; mod tests { #[test] #[cfg(not(target_arch = "wasm32"))] - fn one_slow_hinted_glyph_does_not_turn_hinting_off() { - let db = super::FontDb::with_font_dirs(vec![]); - let face = db.face("Anything", "Regular"); - let strikes: Vec = (0..5).map(|_| face.strike_hinting()).collect(); - assert_eq!(strikes, [false, false, true, false, false]); - assert_eq!(face.hint_strikes.load(std::sync::atomic::Ordering::Relaxed), super::MAX_HINT_STRIKES); + fn occasional_slow_hinted_glyphs_do_not_turn_hinting_off() { + let off = |pattern: &[bool]| { + let face = + super::make_face(super::FontBytes::Static(super::BUNDLED[0]), 0, "T".into(), "Regular".into(), "T".into(), Vec::new()).unwrap(); + pattern.iter().position(|&slow| face.count_hinted(slow)) + }; + // A face slow throughout stops at the third strike. + assert_eq!(off(&[true; 5]), Some(2)); + // Three draws slowed by a busy machine among many: hinting stays on. + let mut busy = vec![false; 100]; + busy.extend([true; 3]); + assert_eq!(off(&busy), None); + // Slow glyphs spread among fast ones still stop it once they are one in 16. + let spread: Vec = (0..64).map(|i| i % 8 == 7).collect(); + assert_eq!(off(&spread), Some(23)); } #[test] #[ignore = "requires a locally licensed kaiu.ttf; set WORDCRAFT_TEST_KAI_FONT"] From d69c386f8fecd4031a5eaa362d54679cf70febe5 Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Fri, 23 Oct 2026 01:51:42 +0100 Subject: [PATCH 145/199] =?UTF-8?q?#3692:=20review=20=E2=80=94=20a=20font'?= =?UTF-8?q?s=20temporary=20file=20is=20created=20afresh,=20so=20a=20link?= =?UTF-8?q?=20planted=20at=20its=20name=20is=20not=20written=20through?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01CX1hMKX28764Lrx5moxs1o --- crates/fonts/src/fontdb/user.rs | 40 +++++++++++++++++++++++++++++++-- 1 file changed, 38 insertions(+), 2 deletions(-) diff --git a/crates/fonts/src/fontdb/user.rs b/crates/fonts/src/fontdb/user.rs index b81f89d693..d31feebf46 100644 --- a/crates/fonts/src/fontdb/user.rs +++ b/crates/fonts/src/fontdb/user.rs @@ -553,8 +553,20 @@ fn write_unique(dir: &Path, name: &str, bytes: &[u8]) -> std::io::Result { + std::fs::remove_file(&tmp)?; + create()? + } + r => r?, + }; + let written = std::io::Write::write_all(&mut file, bytes); + drop(file); + if let Err(e) = written.and_then(|()| std::fs::rename(&tmp, &path)) { let _ = std::fs::remove_file(&tmp); return Err(e); } @@ -950,4 +962,28 @@ pub(crate) mod tests { assert_eq!(stored_name("-rf.otf", b"OTTO"), "font-rf.otf"); assert_eq!(unique_name("a.ttf", |n| n == "a.ttf" || n == "a-2.ttf"), "a-3.ttf"); } + + #[cfg(unix)] + #[test] + fn a_link_planted_at_the_temporary_name_is_not_written_through() { + // #3703 review: the temporary file is created afresh, so a planted link can't carry the + // font bytes to a file outside the folder. + let root = std::env::temp_dir().join(format!("wordcraft-font-part-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + let dir = root.join("fonts"); + std::fs::create_dir_all(&dir).unwrap(); + let outside = root.join("outside.txt"); + std::fs::write(&outside, b"keep me").unwrap(); + std::os::unix::fs::symlink(&outside, dir.join(".a.ttf.part")).unwrap(); + let path = write_unique(&dir, "a.ttf", b"font bytes").unwrap(); + assert_eq!(std::fs::read(&outside).unwrap(), b"keep me"); + assert_eq!(path, dir.join("a.ttf")); + assert_eq!(std::fs::read(&path).unwrap(), b"font bytes"); + assert!(!std::fs::symlink_metadata(&path).unwrap().file_type().is_symlink()); + assert!(std::fs::symlink_metadata(dir.join(".a.ttf.part")).is_err(), "the temporary name is gone"); + // A leftover from a crash doesn't block the next add either. + std::fs::write(dir.join(".b.ttf.part"), b"half a font").unwrap(); + assert_eq!(std::fs::read(write_unique(&dir, "b.ttf", b"whole").unwrap()).unwrap(), b"whole"); + let _ = std::fs::remove_dir_all(&root); + } } From 68d45318bbc976a9c358316ee0254f3ef6b3f05a Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Fri, 23 Oct 2026 01:51:43 +0100 Subject: [PATCH 146/199] =?UTF-8?q?#3692:=20review=20=E2=80=94=20a=20colou?= =?UTF-8?q?r=20glyph's=20clip=20outline=20is=20drawn=20within=20the=20payl?= =?UTF-8?q?oad=20left,=20refused=20before=20it=20is=20built=20whole?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01CX1hMKX28764Lrx5moxs1o --- crates/fonts/src/color.rs | 45 ++++++++++++++++------- crates/fonts/src/fontdb.rs | 74 +++++++++++++++++++++++++++++++++----- 2 files changed, 98 insertions(+), 21 deletions(-) diff --git a/crates/fonts/src/color.rs b/crates/fonts/src/color.rs index 1f03641b68..3e0a35641d 100644 --- a/crates/fonts/src/color.rs +++ b/crates/fonts/src/color.rs @@ -127,10 +127,10 @@ impl FontFace { if let Some(cg) = ColorGlyphCollection::new(&f).get(glyph) { let palette: Vec<[u8; 4]> = ColorPalettes::new(&f).get(0).map(|p| p.colors().iter().map(|c| [c.red, c.green, c.blue, c.alpha]).collect()).unwrap_or_default(); - let mut rec = Recorder::new(&palette, |g: GlyphId| { - let mut p = self.glyph_outline(g.to_u32()); + let mut rec = Recorder::new(&palette, |g: GlyphId, room: usize| { + let mut p = self.glyph_outline_capped(g.to_u32(), room)?; p.apply_affine(Affine::FLIP_Y); - p + Some(p) }); if cg.paint(self.location(), &mut rec).is_ok() && let Some(ops) = rec.finish() @@ -193,7 +193,7 @@ pub fn ops_bytes(ops: &[ColorOp]) -> usize { /// A [`ColorPainter`] that records the paint calls as [`ColorOp`]s, resolving palette colours and /// clip glyphs. Stops recording (and [`Recorder::finish`] gives None) past [`MAX_OPS`] or /// [`MAX_PAYLOAD`]. -pub struct Recorder<'a, F: Fn(GlyphId) -> BezPath> { +pub struct Recorder<'a, F: Fn(GlyphId, usize) -> Option> { palette: &'a [[u8; 4]], outline: F, ops: Vec, @@ -201,9 +201,9 @@ pub struct Recorder<'a, F: Fn(GlyphId) -> BezPath> { overflow: bool, } -impl<'a, F: Fn(GlyphId) -> BezPath> Recorder<'a, F> { +impl<'a, F: Fn(GlyphId, usize) -> Option> Recorder<'a, F> { /// A recorder with palette `palette` (straight RGBA) and clip outlines (font units, y up) from - /// `outline`. + /// `outline`, which is given the path elements left in the budget and gives None past them. pub fn new(palette: &'a [[u8; 4]], outline: F) -> Self { Recorder { palette, outline, ops: vec![], payload: 0, overflow: false } } @@ -267,7 +267,7 @@ fn pt(p: skrifa::raw::types::Point) -> Point { Point::new(p.x as f64, p.y as f64) } -impl BezPath> ColorPainter for Recorder<'_, F> { +impl Option> ColorPainter for Recorder<'_, F> { fn push_transform(&mut self, t: Transform) { self.push(ColorOp::PushTransform(Affine::new([t.xx, t.yx, t.xy, t.yy, t.dx, t.dy].map(|v| v as f64)))); } @@ -278,7 +278,11 @@ impl BezPath> ColorPainter for Recorder<'_, F> { if self.overflow { return; } - let p = (self.outline)(glyph_id); + // Built within what is left of the budget: a huge outline is refused while it is drawn. + let Some(p) = (self.outline)(glyph_id, MAX_PAYLOAD.saturating_sub(self.payload)) else { + self.overflow = true; + return; + }; if self.charge(p.elements().len()) { self.push(ColorOp::PushClip(p)); } @@ -326,8 +330,8 @@ impl BezPath> ColorPainter for Recorder<'_, F> { mod tests { use super::*; - fn square(_: GlyphId) -> BezPath { - kurbo::Shape::to_path(&Rect::new(0.0, 0.0, 10.0, 10.0), 0.1) + fn square(_: GlyphId, room: usize) -> Option { + Some(kurbo::Shape::to_path(&Rect::new(0.0, 0.0, 10.0, 10.0), 0.1)).filter(|p| p.elements().len() <= room) } #[test] @@ -384,9 +388,9 @@ mod tests { let fills = MAX_PAYLOAD / stops.len() + 1; assert!(fills < MAX_OPS); let outlines = std::cell::Cell::new(0); - let count = |g| { + let count = |g, room| { outlines.set(outlines.get() + 1); - square(g) + square(g, room) }; let mut r = Recorder::new(&[], count); for _ in 0..fills { @@ -404,6 +408,23 @@ mod tests { assert!(ops_bytes(&ops) >= stops.len() * std::mem::size_of::()); } + #[test] + fn a_clip_outline_is_drawn_within_the_payload_left() { + // #3716 review: the outline is told the room left, so an oversized one is never built whole. + let stops = vec![ColorStop { offset: 0.5, palette_index: 0, alpha: 1.0 }; MAX_PAYLOAD - 3]; + let rooms = std::cell::RefCell::new(vec![]); + let seen = |g, room| { + rooms.borrow_mut().push(room); + square(g, room) + }; + let mut r = Recorder::new(&[], seen); + r.fill(Brush::LinearGradient { p0: Default::default(), p1: Default::default(), color_stops: &stops, extend: Extend::Pad }); + r.push_clip_glyph(GlyphId::new(1)); + r.pop_clip(); + assert_eq!(*rooms.borrow(), [3]); + assert!(r.finish().is_none(), "a square needs more than the 3 elements left"); + } + fn bitmap(origin: skrifa::bitmap::Origin, data: skrifa::bitmap::BitmapData<'_>) -> skrifa::bitmap::BitmapGlyph<'_> { skrifa::bitmap::BitmapGlyph { data, diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index 9b4ef93721..931655903f 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -217,7 +217,16 @@ impl FontFace { /// Glyph `gid`'s outline in font units, y-down (flipped), uncached: for faces the database /// doesn't keep (font list previews); others go through [`FontDb::outline`]'s cache. pub fn glyph_outline(&self, gid: u32) -> BezPath { - let mut pen = FlipPen(BezPath::new()); + self.draw_outline(gid, usize::MAX).path + } + /// [`FontFace::glyph_outline`], or None when the outline has more than `max_elements` path + /// elements: drawing stops copying them at the limit, so a huge glyph is never built whole. + pub fn glyph_outline_capped(&self, gid: u32, max_elements: usize) -> Option { + let pen = self.draw_outline(gid, max_elements); + (!pen.over).then_some(pen.path) + } + fn draw_outline(&self, gid: u32, max_elements: usize) -> FlipPen { + let mut pen = FlipPen { path: BezPath::new(), room: max_elements, over: false }; if let Some(f) = self.skrifa() && let Some(g) = f.outline_glyphs().get(GlyphId::new(gid)) { @@ -233,10 +242,10 @@ impl FontFace { } if let Err(e) = drawn { log::warn!("Cannot draw glyph {gid} in {:?}: {e}", self.family); - return BezPath::new(); + pen.path = BezPath::new(); } } - pen.0 + pen } /// The face's TrueType interpreter, for the fonts that need one to assemble their contours and /// whose zones are within the limits ([`MAX_HINT_TWILIGHT`]). @@ -1974,23 +1983,55 @@ impl FontDb { } } -struct FlipPen(BezPath); +/// Collects an outline flipped to y-down, keeping at most `room` path elements (`over` once it +/// had to drop one). +struct FlipPen { + path: BezPath, + room: usize, + over: bool, +} + +impl FlipPen { + /// Take one element's room; false (and `over`) when there is none left. + fn take(&mut self) -> bool { + match self.room.checked_sub(1) { + Some(room) if !self.over => { + self.room = room; + true + } + _ => { + self.over = true; + false + } + } + } +} impl OutlinePen for FlipPen { fn move_to(&mut self, x: f32, y: f32) { - self.0.move_to((x as f64, -y as f64)); + if self.take() { + self.path.move_to((x as f64, -y as f64)); + } } fn line_to(&mut self, x: f32, y: f32) { - self.0.line_to((x as f64, -y as f64)); + if self.take() { + self.path.line_to((x as f64, -y as f64)); + } } fn quad_to(&mut self, cx0: f32, cy0: f32, x: f32, y: f32) { - self.0.quad_to((cx0 as f64, -cy0 as f64), (x as f64, -y as f64)); + if self.take() { + self.path.quad_to((cx0 as f64, -cy0 as f64), (x as f64, -y as f64)); + } } fn curve_to(&mut self, cx0: f32, cy0: f32, cx1: f32, cy1: f32, x: f32, y: f32) { - self.0.curve_to((cx0 as f64, -cy0 as f64), (cx1 as f64, -cy1 as f64), (x as f64, -y as f64)); + if self.take() { + self.path.curve_to((cx0 as f64, -cy0 as f64), (cx1 as f64, -cy1 as f64), (x as f64, -y as f64)); + } } fn close(&mut self) { - self.0.close_path(); + if self.take() { + self.path.close_path(); + } } } @@ -2051,6 +2092,21 @@ mod tests { } } + #[test] + fn a_capped_outline_is_refused_while_it_is_drawn() { + // #3716 review: a colour glyph's clip outline stops growing at the payload left. + let db = FontDb::with_font_dirs(Vec::new()); + db.set_system_fallback(false); + let face = db.face(FALLBACK_FAMILY, "Regular"); + let gid = face.glyph_for('g'); + let whole = face.glyph_outline(gid); + let n = whole.elements().len(); + assert!(n > 4, "{n}"); + assert_eq!(face.glyph_outline_capped(gid, n).as_ref().map(BezPath::elements), Some(whole.elements())); + assert!(face.glyph_outline_capped(gid, n - 1).is_none()); + assert!(face.glyph_outline_capped(gid, 0).is_none()); + } + #[test] fn run_fallback_is_cached_per_char_set() { let db = FontDb::with_font_dirs(Vec::new()); From 87d3ea5363750be994c5813453b8bf8581123e11 Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Fri, 23 Oct 2026 01:52:13 +0100 Subject: [PATCH 147/199] Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01CX1hMKX28764Lrx5moxs1o --- docs/layout-parity.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/layout-parity.md b/docs/layout-parity.md index 32e6b82ceb..c82dad91b8 100644 --- a/docs/layout-parity.md +++ b/docs/layout-parity.md @@ -23,7 +23,7 @@ Exact line spacing keeps the run font baseline around tall equations; automatic | Feature | State | Evidence / gap | Hours | |---|---|---|---| | 2026-10-16 | trivial | Split-cell content retains its resolved top margin and border band on continuation pages, matching original Word Mac16.113.2 controls. Synthetic regressions cover margins, borders, line spacing and image frames. | -| Hint-reliant CJK glyph outlines (DFKai-SB / 標楷體) | 🟡 | Required TrueType interpreter runs at units-per-em size; local Windows glyph-bounds regression and synthetic DOCX page/window comparison pass. Other platforms and hint-reliant families remain unmeasured; no native Word comparison. A font asking for a twilight zone over 1,024 points or a stack over 4,096 values, a glyph past 256 KiB of hinting buffer, or (desktop) a face whose glyph took over 50 ms to hint is drawn unhinted (#3692). | — | +| Hint-reliant CJK glyph outlines (DFKai-SB / 標楷體) | 🟡 | Required TrueType interpreter runs at units-per-em size; local Windows glyph-bounds regression and synthetic DOCX page/window comparison pass. Other platforms and hint-reliant families remain unmeasured; no native Word comparison. A font asking for a twilight zone over 1,024 points or a stack over 4,096 values, or a glyph past 256 KiB of hinting buffer, is drawn unhinted; on desktop, once at least three of a face's hinted draws have taken over 50 ms and those slow draws are at least 1/16 of its hinted draws, its later glyphs are drawn unhinted (#3692). | — | | Subpoint table grid columns | 🟡 | Preserve positive grid widths rather than imposing a text-box minimum; ECMA-376 §17.4.16. Native Word for Mac 16.113.2 build26092012 confirms narrow trailing grid units. `layout::tests::subpoint_grid_keeps_widths`, `tiny_grid_scales_to_width`. Tiny totals normalize without overflowing the scale factor. Zero and invalid widths retain their fallback. Border painting remains separate. | n/a | | Line breaking (UAX #14 with Word's exceptions: no break after `/`, a break after `-` before a digit) | ✅ | `layout::tests::no_line_break_right_after_a_slash`, `layout::tests::a_hyphen_before_digits_is_a_break_opportunity_like_word`, `layout::tests::a_bookmark_is_no_line_break_opportunity` | — | | A break before a slash that starts a word (" /word" wraps whole; not after a no-break space) | 🟡 | `layout::tests::a_word_after_a_slash_wraps_whole`, `layout::tests::a_no_break_space_before_a_slash_still_holds`; behaviour from the #2963 report, not compared with Word yet | — | @@ -273,7 +273,7 @@ layout suite are pending; this is not a new Word/native-IME acceptance claim. | 2026-10-23 | trivial | cu20we: preserve and render outline caps across DOCX/VML, screen and PDF; synthetic regressions; readiness estimates unchanged | | 2026-10-23 | trivial | cu20we: paragraph-relative box floats use the pre-spacing origin; header/footer/cell/text-box regressions; readiness estimates unchanged | | 2026-10-22 | trivial | Layout lookups linear in clipped cells, authors, sections, header paragraphs and bidi offsets (#3692, #3706) | -| 2026-10-22 | trivial | Hostile fonts (#3692): hint-reliant fonts with huge hinting zones, or a slow hinted glyph, are drawn unhinted; a colour glyph's gradient stops and clip paths share one budget | +| 2026-10-22 | trivial | Hostile fonts (#3692): hint-reliant fonts with huge hinting zones are drawn unhinted, and a face draws its later glyphs unhinted once at least three of its hinted draws took over 50 ms and those are at least 1/16 of its hinted draws; a colour glyph's gradient stops and clip paths share one budget | | 2026-10-22 | trivial | Group connector rerouting preserves displayed free and unresolved endpoints under nonuniform scaling while resolved ends keep their connection-site path; degenerate dimensions and unrepresentable fallback display spans are guarded; synthetic model/engine coverage only, readiness estimates unchanged | | 2026-10-21 | trivial | A word with no break opportunity in it, laid out beside a floating object, no longer lays out quadratically: every word is measured once, before the line is broken (#3412) | | 2026-10-23 | trivial | An empty paragraph whose mark is hidden is laid out as nothing, like a deleted one: no room, no list number (still counted, as in Word) and no shading, in the body and in table cells, headers and text boxes (#3982) | From 57e9e0f2441fb8cbcc60c26c8bd1266a43254565 Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Fri, 23 Oct 2026 05:19:20 +0100 Subject: [PATCH 148/199] =?UTF-8?q?#3703:=20review=20=E2=80=94=20font=20st?= =?UTF-8?q?ore=20adds=20run=20one=20at=20a=20time,=20so=20racing=20adds=20?= =?UTF-8?q?never=20share=20a=20temporary=20or=20a=20file?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit on #3869: two concurrent install_font calls could pick the same destination and temporary, and the leftover-unlink retry could remove the other call's open temporary. write_unique now holds a process-wide lock. In-process only (noted in a ponytail comment). Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01CX1hMKX28764Lrx5moxs1o --- crates/fonts/src/fontdb/user.rs | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/crates/fonts/src/fontdb/user.rs b/crates/fonts/src/fontdb/user.rs index d31feebf46..c67fb6a213 100644 --- a/crates/fonts/src/fontdb/user.rs +++ b/crates/fonts/src/fontdb/user.rs @@ -549,6 +549,11 @@ fn unique_name(name: &str, taken: impl Fn(&str) -> bool) -> String { /// Write `bytes` to a new file in `dir` named `name` (or a numbered variant when that exists), /// through a temporary file so a crash never leaves half a font. fn write_unique(dir: &Path, name: &str, bytes: &[u8]) -> std::io::Result { + // One store at a time: two adds racing for the same name would otherwise pick the same file + // and unlink each other's temporary. ponytail: in-process only; two app instances adding the + // same name at the same instant can still collide (the later rename wins). + static STORE: std::sync::Mutex<()> = std::sync::Mutex::new(()); + let _held = STORE.lock().unwrap_or_else(std::sync::PoisonError::into_inner); std::fs::create_dir_all(dir)?; let name = unique_name(name, |n| dir.join(n).exists()); let path = dir.join(&name); @@ -986,4 +991,26 @@ pub(crate) mod tests { assert_eq!(std::fs::read(write_unique(&dir, "b.ttf", b"whole").unwrap()).unwrap(), b"whole"); let _ = std::fs::remove_dir_all(&root); } + + #[test] + fn fonts_stored_at_once_under_one_name_each_get_their_own_file() { + // #3703 review: concurrent adds of one name never share a temporary or a destination. + let dir = std::env::temp_dir().join(format!("wordcraft-font-race-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let paths: Vec<_> = (0..8u8) + .map(|i| { + let dir = dir.clone(); + std::thread::spawn(move || (i, write_unique(&dir, "a.ttf", &[i; 64]).unwrap())) + }) + .collect::>() + .into_iter() + .map(|t| t.join().unwrap()) + .collect(); + let distinct: std::collections::HashSet<_> = paths.iter().map(|(_, p)| p.clone()).collect(); + assert_eq!(distinct.len(), 8); + for (i, p) in &paths { + assert_eq!(std::fs::read(p).unwrap(), vec![*i; 64], "{}", p.display()); + } + let _ = std::fs::remove_dir_all(&dir); + } } From b059da2d4e52ad40a9cb54f8de6e65d08308f737 Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Fri, 23 Oct 2026 11:09:43 +0100 Subject: [PATCH 149/199] =?UTF-8?q?#3692:=20review=20=E2=80=94=20docs:=20w?= =?UTF-8?q?hy=20the=20hinting=20breaker=20is=20timed,=20and=20what=20brotl?= =?UTF-8?q?i-decompressor=20brings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hinting breaker stays wall-clock: skrifa's interpreter budget is fixed, counts instructions rather than the points each one moves, and reports no work count, so there is nothing deterministic to bound. The comment now says the result can depend on the machine by design, and that only faces needing the interpreter for their contours are hinted at all. Cargo.toml notes that brotli-decompressor is the crate and version wuff already uses: pure Rust, forbid(unsafe_code) by default, wasm-ready, BSD-3-Clause/MIT. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01CX1hMKX28764Lrx5moxs1o --- Cargo.toml | 2 ++ crates/fonts/src/fontdb.rs | 11 +++++++++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 6bdbf2ef9c..ee319c50ad 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -53,6 +53,8 @@ harfrust = "0.13" # (brotli-decompressor, flate2) are already in the tree. wuff = "0.2.9" # WOFF2's brotli stream, decoded ahead of wuff so its glyph point counts are checked first (#3703). +# The same crate and version wuff already decodes it with (no new code in the build): pure Rust, +# `forbid(unsafe_code)` in its default build, no_std-capable (builds for wasm), BSD-3-Clause/MIT. brotli-decompressor = "5" vello_cpu = "0.2" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "bmp", "tiff"] } diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index 931655903f..7da5d927c2 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -110,8 +110,15 @@ const MAX_HINT_GLYPH_BYTES: usize = 256 * 1024; /// still take seconds for one glyph, so [`MIN_HINT_STRIKES`] strikes that are also at least one /// in [`HINT_SLOW_RATIO`] of its hinted draws turn hinting off: a face that is slow throughout /// stops after a few glyphs, while the odd draw slowed by a busy machine leaves a face hinted. -/// ponytail: wall-clock, not interpreter work (skrifa has no work bound yet, #3827). Native only -/// (no clock on wasm). +/// +/// Wall-clock, not interpreter work: skrifa's own budget (a million instructions a program, not +/// settable) counts instructions, not the points each one moves, and it exposes no count of the +/// work done, so time is the only measure there is (#3827). Whether such a face stays hinted can +/// therefore depend on the machine, by design: only faces that need the interpreter to assemble +/// their contours (legacy CJK fonts such as DFKai-SB) are hinted at all, and those typically hint +/// a glyph in microseconds, so 50 ms is far above an honest face on a loaded machine; a face +/// that trips it is drawn unhinted (its outlines possibly misassembled) rather than freezing the +/// app. Native only (no clock on wasm). #[cfg(not(target_arch = "wasm32"))] const HINT_STRIKE: std::time::Duration = std::time::Duration::from_millis(50); #[cfg(not(target_arch = "wasm32"))] From 2d9bcb6b1b36d6f8a8c71c77b1e438b20f1ce492 Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Fri, 23 Oct 2026 14:44:44 +0100 Subject: [PATCH 150/199] =?UTF-8?q?#3692:=20review=20=E2=80=94=20fonts=20t?= =?UTF-8?q?iming=20test=20gets=20a=20margin=20a=20busy=20CI=20machine=20ca?= =?UTF-8?q?n't=20break?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit named_instances_and_collection_members_are_capped: 2 s -> 10 s. The face counts in the test (one face for a million identical members, MAX_FILE_FACES from add_font) are what catch the old code, which built a face per member; the time (under 0.1 s on a loaded machine, so 10 s is over 100x) only backs up "read once". Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01CX1hMKX28764Lrx5moxs1o --- crates/fonts/src/fontdb.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index 7da5d927c2..2ccb4cc744 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -2392,6 +2392,8 @@ mod tests { assert_eq!(db.add_font(collection_of(&font, 1_000_000)), MAX_FILE_FACES); // A font the user adds is refused instead. assert!(matches!(db.install_font("v.ttf", &variable_font(300)), Err(user::UserFontError::Unusable(e)) if e.contains("more than 256"))); - assert!(started.elapsed() < std::time::Duration::from_secs(2), "{:?}", started.elapsed()); + // The face counts above are what tell the old code (a face per member) apart; the time + // (under 0.1 s on a loaded machine) only backs up "read once", with room for a busy CI. + assert!(started.elapsed() < std::time::Duration::from_secs(10), "{:?}", started.elapsed()); } } From 2337ff75fa7b77987ec4bc7c0e8bcf2321ef3094 Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Thu, 22 Oct 2026 16:03:09 +0100 Subject: [PATCH 151/199] #3723: zip entries that fail their CRC check are charged to the package total Finding 43. Package::open_limited dropped an entry whose read failed before adding the bytes it had already inflated to the running total, so many entries with a wrong CRC (checked only after the whole stream inflates) could each inflate up to the part limit for free. The bytes are now charged first and the size check runs before the read result is looked at. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/docx/src/package.rs | 30 ++++++++++++++++++++++++++---- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/crates/docx/src/package.rs b/crates/docx/src/package.rs index f98a3a709f..bc143ac740 100644 --- a/crates/docx/src/package.rs +++ b/crates/docx/src/package.rs @@ -223,15 +223,17 @@ impl Package { let room = max_part.min(max_total.saturating_sub(total)); let mut buf = Vec::with_capacity(f.size().min(16 * 1024 * 1024) as usize); let read = (&mut f).take(room + 1).read_to_end(&mut buf); + // Charged whether or not the read succeeds: an entry inflates in full before its CRC + // is checked, so failed entries would otherwise inflate without limit (#3723). + total = total.saturating_add(buf.len() as u64); + if buf.len() as u64 > room { + return Err(DocxError::Limit(format!("part {name} decompresses past the size limit"))); + } if let Err(e) = read { // A corrupt entry: keep going unless it's a part we can't do without. log::warn!("docx: zip entry {name} unreadable: {e}"); continue; } - if buf.len() as u64 > room { - return Err(DocxError::Limit(format!("part {name} decompresses past the size limit"))); - } - total = total.saturating_add(buf.len() as u64); files.insert(name, Arc::new(buf)); } Ok(Package { files }) @@ -429,6 +431,26 @@ mod tests { assert!(Package::open_limited(&z, 10_000_000, 10_000_000).is_ok()); } + #[test] + fn entries_that_fail_their_check_still_count_against_the_total() { + // Each entry inflates in full before its CRC is checked; a failed one was dropped without + // being charged, so any number of them inflated (#3723). + let names: Vec = (0..20).map(|i| format!("p{i}.bin")).collect(); + let entries: Vec<(&str, Vec)> = names.iter().map(|n| (n.as_str(), vec![0u8; 1_000_000])).collect(); + let mut z = zip_of(&entries); + let mut patched = 0; + for sig in [[0x50u8, 0x4B, 0x01, 0x02], [0x50, 0x4B, 0x03, 0x04]] { + let crc_at = if sig[2] == 1 { 16 } else { 14 }; + let found: Vec = z.windows(4).enumerate().filter(|(_, w)| *w == sig).map(|(i, _)| i).collect(); + for pos in found { + z[pos + crc_at] ^= 0xFF; + patched += 1; + } + } + assert_eq!(patched, 40); + assert!(matches!(Package::open_limited(&z, 2_000_000, 4_000_000), Err(DocxError::Limit(_)))); + } + #[test] fn lying_declared_size_is_rejected_or_bounded() { let mut z = zip_of(&[("a.xml", b"".to_vec())]); From 12afc7541100d2b2461daed800893710df6ec5a9 Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Thu, 22 Oct 2026 16:04:19 +0100 Subject: [PATCH 152/199] Finding 42. read_comments checked each candidate id for a comment whose w:id isn't a number against every id mapped so far, so a comments part of many non-numeric ids took time quadratic in their count (49,000 comments: 2.4 s, now 0.35 s in a debug build). The ids mapped before the part are collected into a set once; ids given in the part are already in `entries`. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/docx/src/read/mod.rs | 6 +++++- crates/docx/tests/it/malformed.rs | 29 +++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/crates/docx/src/read/mod.rs b/crates/docx/src/read/mod.rs index 414993b97c..3902f81003 100644 --- a/crates/docx/src/read/mod.rs +++ b/crates/docx/src/read/mod.rs @@ -1363,6 +1363,10 @@ impl Reader<'_> { let mut by_para: HashMap = HashMap::new(); let mut next_id = 0u32; let mut entries: BTreeMap = BTreeMap::new(); + // Ids given before this part, looked up rather than scanned for every generated id: a + // scan per comment made many non-numeric ids quadratic (#3692). Ids given here are in + // `entries`. + let given: HashSet = self.comment_map.values().copied().collect(); for c in root.children("w:comment") { if self.doc.parts.len() >= MAX_PARTS { break; @@ -1374,7 +1378,7 @@ impl Reader<'_> { let id = match file_id.parse::() { Ok(v) if !entries.contains_key(&v) => v, _ => { - while entries.contains_key(&next_id) || self.comment_map.values().any(|v| *v == next_id) { + while entries.contains_key(&next_id) || given.contains(&next_id) { next_id += 1; } next_id diff --git a/crates/docx/tests/it/malformed.rs b/crates/docx/tests/it/malformed.rs index 67eedf78d5..c2496de54c 100644 --- a/crates/docx/tests/it/malformed.rs +++ b/crates/docx/tests/it/malformed.rs @@ -351,3 +351,32 @@ fn a_repeating_difat_is_refused_before_cfb_opens_it() { assert!(!wordcraft_docx::is_encrypted(&f)); assert!(matches!(wordcraft_docx::decrypt(&f, None), Err(e) if e.to_string().contains("FAT sectors in a file of"))); } + +#[test] +fn comments_with_non_numeric_ids_open_in_linear_time() { + // Each generated id was checked against every id given so far, so a comments part with many + // ids that aren't numbers took time quadratic in their count (#3692). + const RELS_NS: &str = "http://schemas.openxmlformats.org/package/2006/relationships"; + const COMMENTS_REL: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments"; + let n = 49_000; + let mut comments = format!(""); + for i in 0..n { + comments.push_str(&format!(r#""#)); + } + comments.push_str(""); + let doc = format!("body"); + let rels = format!(r#""#); + let z = zip(&[ + ("_rels/.rels", ROOT_RELS.as_bytes()), + ("word/document.xml", doc.as_bytes()), + ("word/_rels/document.xml.rels", rels.as_bytes()), + ("word/comments.xml", comments.as_bytes()), + ]); + let t = std::time::Instant::now(); + let d = read(&z).unwrap(); + let took = t.elapsed(); + assert_eq!(d.comments.len(), n); + let ids: std::collections::HashSet<_> = d.comments.keys().collect(); + assert_eq!(ids.len(), n, "every comment has its own id"); + assert!(took.as_secs_f32() < 2.0, "opening took {took:?}"); +} From 4c5930dac1c2f01756bd8aa5a2dabe9157aad085 Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Thu, 22 Oct 2026 16:05:00 +0100 Subject: [PATCH 153/199] #3692: embedded fonts are copied once per file and within a byte budget Finding 51. embedded_fonts copied a part's bytes for every font-table reference, so the 64 references allowed to one 256 MB part could make 16 GB of copies on open. A reference repeating one already taken (same part, key and name) is skipped, and copies stop at MAX_FONT_BYTES (one part's limit), checked before each copy is made. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/docx/src/fonts.rs | 69 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/crates/docx/src/fonts.rs b/crates/docx/src/fonts.rs index f63930b571..7329c122af 100644 --- a/crates/docx/src/fonts.rs +++ b/crates/docx/src/fonts.rs @@ -4,6 +4,8 @@ //! back. [`embedded_fonts`] hands their font files to whoever lays the document out, undoing the //! obfuscation Word applies to them (ECMA-376 Part 1, 17.8.1). +use std::collections::HashSet; + use wordcraft_doc::Document; use crate::package::{EMBEDDED_PARTS, EmbeddedManifest, FONT_TABLE_PART}; @@ -11,6 +13,11 @@ use crate::package::{EMBEDDED_PARTS, EmbeddedManifest, FONT_TABLE_PART}; /// Most font files taken from one document. const MAX_FONTS: usize = 64; +/// Most bytes of font files copied out of one document: one part's limit, however many +/// references the font table makes (each reference is a copy; 64 of them to one large part +/// were 64 copies, #3692). +const MAX_FONT_BYTES: u64 = crate::package::MAX_PART; + /// The font table's references to embedded font files, one per style. const EMBEDS: [&str; 4] = ["w:embedRegular", "w:embedBold", "w:embedItalic", "w:embedBoldItalic"]; @@ -28,6 +35,11 @@ pub struct EmbeddedFont { /// fonts: that is up to the font database. Empty for a document read from anything but a package /// with a font table. pub fn embedded_fonts(doc: &Document) -> Vec { + fonts_within(doc, MAX_FONT_BYTES) +} + +/// [`embedded_fonts`], copying at most `budget` bytes of font files. +fn fonts_within(doc: &Document, mut budget: u64) -> Vec { let Some(table) = doc.passthrough.get(FONT_TABLE_PART).and_then(|b| crate::xml::parse(b).ok()) else { return Vec::new(); }; @@ -36,6 +48,8 @@ pub fn embedded_fonts(doc: &Document) -> Vec { return Vec::new(); }; let mut out = Vec::new(); + // A reference repeating one already taken (same file, key and name) gives the same font. + let mut taken: HashSet<(&str, [u8; 16], &str)> = HashSet::new(); let embeds = table .children("w:font") .flat_map(|f| f.els().filter(|e| EMBEDS.contains(&e.name.as_str())).map(move |e| (f.attr("w:name").unwrap_or(""), e))); @@ -61,6 +75,15 @@ pub fn embedded_fonts(doc: &Document) -> Vec { }, None => [0; 16], }; + if !taken.insert((rel.target.as_str(), key, name)) { + continue; + } + // Checked before the copy is made. + let Some(left) = budget.checked_sub(bytes.len() as u64) else { + log::warn!("docx: embedded fonts past {MAX_FONT_BYTES} bytes; {} is not loaded", rel.target); + continue; + }; + budget = left; let mut data = bytes.to_vec(); for (i, b) in data.iter_mut().take(32).enumerate() { *b ^= key.get(i % 16).copied().unwrap_or(0); @@ -83,3 +106,49 @@ fn font_key(guid: &str) -> Option<[u8; 16]> { } Some(key) } + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use super::*; + + /// A document whose font table makes `refs` references (`(font name, part)`) to font parts + /// `parts` (`(name, size)`). + fn doc_with(refs: &[(&str, &str)], parts: &[(&str, usize)]) -> Document { + let mut table = String::from( + r#""#, + ); + let mut manifest = format!("P\t{FONT_TABLE_PART}\tapplication/xml\n"); + for (i, (font, part)) in refs.iter().enumerate() { + table.push_str(&format!(r#""#)); + manifest.push_str(&format!("R\t{FONT_TABLE_PART}\trId{i}\tfont\t{part}\tI\n")); + } + table.push_str(""); + let mut doc = Document::default(); + doc.passthrough.insert(FONT_TABLE_PART.into(), Arc::new(table.into_bytes())); + doc.passthrough.insert(EMBEDDED_PARTS.into(), Arc::new(manifest.into_bytes())); + for (part, size) in parts { + doc.passthrough.insert((*part).into(), Arc::new(vec![7u8; *size])); + } + doc + } + + #[test] + fn a_font_referenced_again_is_copied_once() { + // Each reference was its own copy: the 64 references allowed to one 256 MB part were + // 16 GB of copies (#3692). + let refs = vec![("Body", "word/fonts/a.odttf"); MAX_FONTS]; + let fonts = embedded_fonts(&doc_with(&refs, &[("word/fonts/a.odttf", 1000)])); + assert_eq!(fonts.len(), 1); + } + + #[test] + fn font_copies_stop_at_the_byte_budget() { + let refs: Vec<(String, &str)> = (0..MAX_FONTS).map(|i| (format!("Font {i}"), "word/fonts/a.odttf")).collect(); + let refs: Vec<(&str, &str)> = refs.iter().map(|(n, p)| (n.as_str(), *p)).collect(); + let doc = doc_with(&refs, &[("word/fonts/a.odttf", 1000)]); + assert_eq!(fonts_within(&doc, 3500).len(), 3, "three copies fit"); + assert_eq!(embedded_fonts(&doc).len(), MAX_FONTS, "well within the real budget"); + } +} From 639964b0ede80144081da1e6c3e418989dcf36ab Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Thu, 22 Oct 2026 16:06:09 +0100 Subject: [PATCH 154/199] #3692: a glossary is written from the model when the privacy settings ask for changes Finding 72. An unchanged glossary was carried byte for byte, judged by a fingerprint of its entries that leaves out the document's privacy settings, so a save with "remove personal information" or "remove date and time" kept the authors and dates of the glossary's tracked changes. With either setting on, the glossary is now written from the model, which applies them. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/docx/src/write/mod.rs | 5 ++++- crates/docx/tests/it/glossary.rs | 28 ++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/crates/docx/src/write/mod.rs b/crates/docx/src/write/mod.rs index b6a66186ad..427c0df3ea 100644 --- a/crates/docx/src/write/mod.rs +++ b/crates/docx/src/write/mod.rs @@ -272,7 +272,10 @@ pub(crate) fn write_parts(doc: &Document, flavor: Flavor) -> ResultAdded clause"#; + let bytes = package( + &[("rId7", GLOSSARY_REL, "glossary/document.xml")], + &[ + ("[Content_Types].xml", content_types(&[("/word/glossary/document.xml", GLOSSARY_CT)])), + ("word/glossary/document.xml", glossary_xml(tracked)), + ], + ); + let doc = wordcraft_docx::read(&bytes).unwrap(); + let as_read = save(&doc); + let kept = text(&as_read, &glossary_target(&as_read).expect("a glossary")); + assert!(kept.contains("Quill Ramsey"), "the fixture's glossary is kept verbatim: {kept}"); + for (personal, dates) in [(true, false), (false, true), (true, true)] { + let mut private = doc.clone(); + private.settings.remove_personal_info = personal; + private.settings.remove_date_time = dates; + let files = save(&private); + let g = text(&files, &glossary_target(&files).expect("a glossary")); + assert!(g.contains("Added clause"), "{g}"); + assert_eq!(g.contains("Quill Ramsey"), !personal, "{g}"); + assert_eq!(g.contains("2026-03-04T05:06:07Z"), !dates, "{g}"); + } +} + /// The relationships part of a part in `word/`. fn glossary_rels(part: &str) -> String { let (dir, file) = part.rsplit_once('/').unwrap(); From 1955793aa637bd8017606f6ba15e162a8d2f3415 Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Thu, 22 Oct 2026 16:07:57 +0100 Subject: [PATCH 155/199] #3648: a chart part is written within the chart limits whatever the model Finding 76. chart_xml trusted its ChartSpec to be sanitized, but a .json document is deserialised without ChartSpec::sanitize, and every series repeats every category, so a chart past the limits multiplied into a gigabyte-sized part on save. chart_xml now writes a bounded, sanitized copy: only the series and points the limits keep are copied before sanitizing. (Sanitizing on .json open, and SmartArt, are in the engine and not covered here.) Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/docx/src/chart_spec.rs | 48 ++++++++++++++++++++++++++++++++--- 1 file changed, 45 insertions(+), 3 deletions(-) diff --git a/crates/docx/src/chart_spec.rs b/crates/docx/src/chart_spec.rs index 698db44c0d..33de668900 100644 --- a/crates/docx/src/chart_spec.rs +++ b/crates/docx/src/chart_spec.rs @@ -6,7 +6,7 @@ //! [`spec_of`] turns a chart part back into the model only when writing that model again gives //! the same markup, so a chart with anything the model leaves out stays as it was read. -use wordcraft_doc::chart::{ChartSeries, ChartSpec, ChartType, LegendPos}; +use wordcraft_doc::chart::{ChartSeries, ChartSpec, ChartType, LegendPos, MAX_POINTS, MAX_SERIES}; use wordcraft_doc::graphic::GraphicItem; use wordcraft_doc::props::Rgb; @@ -23,9 +23,10 @@ pub(crate) const CHART_URI: &str = "http://schemas.openxmlformats.org/drawingml/ const CAT_AX: &str = "500000001"; const VAL_AX: &str = "500000002"; -/// The chart part for `spec`: a complete XML document. `spec` should be sanitized -/// ([`ChartSpec::sanitize`]); a value that isn't finite is written as a gap either way. +/// The chart part for `spec`: a complete XML document, written as [`ChartSpec::sanitize`] leaves +/// it (a value that isn't finite is a gap). pub fn chart_xml(spec: &ChartSpec) -> String { + let spec = &bounded(spec); let mut w = W::new(); w.open("c:chartSpace", &[("xmlns:c", NS_C), ("xmlns:a", NS_A), ("xmlns:r", NS_R)]); w.empty("c:roundedCorners", &[("val", "0")]); @@ -79,6 +80,26 @@ pub fn chart_xml(spec: &ChartSpec) -> String { w.s } +/// `spec` held to the chart limits. A model deserialised as is (a `.json` document) never went +/// through [`ChartSpec::sanitize`], and every series repeats every category, so its size would +/// multiply (#3648). Only what the limits keep is copied before sanitizing. +fn bounded(spec: &ChartSpec) -> ChartSpec { + ChartSpec { + kind: spec.kind, + categories: spec.categories.iter().take(MAX_POINTS).cloned().collect(), + series: spec + .series + .iter() + .take(MAX_SERIES) + .map(|s| ChartSeries { name: s.name.clone(), values: s.values.iter().take(MAX_POINTS).copied().collect() }) + .collect(), + title: spec.title.clone(), + legend: spec.legend, + data_labels: spec.data_labels, + } + .sanitized() +} + /// What `spec` draws as, `w` × `h` points, in the colours of `theme` (series take the accents in /// turn). Empty when the chart has no series. pub fn chart_items(spec: &ChartSpec, theme: &[Rgb], w: f32, h: f32) -> Vec { @@ -421,6 +442,27 @@ mod tests { e.els().map(|c| c.name.as_str()).collect() } + #[test] + fn a_chart_past_the_limits_is_written_within_them() { + // A model deserialised as is (a `.json` document) never went through `sanitize`, and every + // series repeats every category: 300 series of 4,500 points wrote 2.7 million points (#3648). + use wordcraft_doc::chart::MAX_CELLS; + let spec = ChartSpec { + kind: ChartType::Column, + categories: (0..4500).map(|i| i.to_string()).collect(), + series: (0..300).map(|i| ChartSeries { name: format!("S{i}"), values: vec![Some(1.0); 4500] }).collect(), + title: None, + legend: None, + data_labels: false, + }; + let x = chart_xml(&spec); + let series = x.matches("").count(); + let points = x.matches(" Date: Thu, 22 Oct 2026 16:10:52 +0100 Subject: [PATCH 156/199] #3742: kept XML is inlined in Flat OPC only when it is one well-formed element Finding 55: flat.rs xml_fragment inlined kept part bytes into pkg:xmlData after a prefix check only, so a part whose text closed the wrapper () wrote parts of its own into the saved .xml - e.g. a second core properties part. Now the bytes are inlined only when quick-xml reads them as exactly one balanced element with nothing but whitespace, comments and PIs around it; anything else is written as pkg:binaryData and still round-trips. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/docx/src/flat.rs | 89 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 88 insertions(+), 1 deletion(-) diff --git a/crates/docx/src/flat.rs b/crates/docx/src/flat.rs index 6c3b0bcef6..5cb44a0637 100644 --- a/crates/docx/src/flat.rs +++ b/crates/docx/src/flat.rs @@ -437,7 +437,46 @@ fn xml_fragment<'a>(ct: &str, bytes: &'a [u8]) -> Option<&'a str> { Some(rest) => rest.split_once("?>").map(|(_, r)| r)?.trim_start(), None => s, }; - (s.starts_with('<') && !s.contains(" bool { + let mut r = quick_xml::Reader::from_str(s); + let (mut depth, mut roots) = (0usize, 0usize); + loop { + let attrs_ok = |e: &BytesStart| e.attributes().all(|a| a.is_ok()); + match r.read_event() { + Ok(Event::Start(e)) => { + if !attrs_ok(&e) || (depth == 0 && roots > 0) { + return false; + } + roots += usize::from(depth == 0); + depth += 1; + } + Ok(Event::Empty(e)) => { + if !attrs_ok(&e) || (depth == 0 && roots > 0) { + return false; + } + roots += usize::from(depth == 0); + } + Ok(Event::End(_)) => match depth.checked_sub(1) { + Some(d) => depth = d, + None => return false, + }, + Ok(Event::Text(t)) if depth == 0 => { + if !t.iter().all(u8::is_ascii_whitespace) { + return false; + } + } + Ok(Event::CData(_) | Event::GeneralRef(_)) if depth == 0 => return false, + Ok(Event::Decl(_) | Event::DocType(_)) | Err(_) => return false, + Ok(Event::Eof) => return depth == 0 && roots == 1, + Ok(_) => {} + } + } } #[cfg(test)] @@ -494,6 +533,54 @@ mod tests { assert!(matches!(unpack(package(xml::MAX_DEPTH + 1).as_bytes()), Err(DocxError::Limit(_)))); } + #[test] + fn kept_xml_that_would_break_out_of_its_part_is_written_in_base64() { + // A part kept from a file is written back as it was read; inlined as is, markup that + // closes the wrapper adds parts of its own (here a second core properties part, read + // before the real one) (#3742). + let evil = format!( + "Injected", + "http://schemas.openxmlformats.org/package/2006/metadata/core-properties" + ); + let w_ns = "xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\""; + let rel = |id: &str, kind: &str, target: &str| format!(""); + let rels = format!( + "{}{}", + rel("rId1", crate::package::rt::OFFICE_DOC, "word/document.xml"), + rel("rId2", crate::package::rt::CUSTOM_XML, "customXml/item1.xml") + ); + let types = ""; + let z = crate::package::zip_entries(&[ + ("[Content_Types].xml".into(), types.as_bytes().to_vec()), + ("_rels/.rels".into(), rels.into_bytes()), + ("word/document.xml".into(), format!("").into_bytes()), + ("customXml/item1.xml".into(), evil.clone().into_bytes()), + ]) + .unwrap(); + let mut doc = crate::read(&z).unwrap(); + assert!(doc.passthrough.values().any(|b| b.as_slice() == evil.as_bytes()), "the part is kept as read"); + doc.core.title = "Real".into(); + let flat = write(&doc).unwrap(); + let text = String::from_utf8_lossy(&flat); + assert_eq!(text.matches("pkg:name=\"/docProps/core.xml\"").count(), 1, "{text}"); + let back = read(&flat).unwrap(); + assert_eq!(back.core.title, "Real"); + assert!(back.passthrough.values().any(|b| b.as_slice() == evil.as_bytes()), "the part survives, in base64"); + } + + #[test] + fn only_one_well_formed_element_is_inlined() { + let ct = "application/xml"; + for ok in ["", "\nt & \n", ""] { + assert!(xml_fragment(ct, ok.as_bytes()).is_some(), "{ok}"); + } + for bad in + ["", "", "", "", "text", "&", "", "", ""] + { + assert!(xml_fragment(ct, bad.as_bytes()).is_none(), "{bad}"); + } + } + #[test] fn repeated_and_nameless_parts_count_against_the_size_limits() { let (mut files, mut types, mut total) = (BTreeMap::new(), Vec::new(), 0u64); From 60a64acec376ee05fee84b00b73834edd8659fc6 Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Thu, 22 Oct 2026 16:13:28 +0100 Subject: [PATCH 157/199] #3704: a part named in several casings is kept, cached and written once Finding 73: Package lookup matched part names case-insensitively, but relationship targets kept the spelling they were written in, so every casing of one part became its own media entry, graphic parse and passthrough key, and a save wrote a full copy per spelling (64 spellings of a 64 KB picture: 64 media parts). Package now keeps a lowercase index, Package::canonical returns the stored spelling, and rels() resolves internal targets to it, so every downstream cache is keyed by the one part; get_shared uses the index instead of a linear scan. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/docx/src/package.rs | 39 +++++++++++++++++++----- crates/docx/tests/it/passthrough.rs | 46 +++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 8 deletions(-) diff --git a/crates/docx/src/package.rs b/crates/docx/src/package.rs index bc143ac740..ddf106d747 100644 --- a/crates/docx/src/package.rs +++ b/crates/docx/src/package.rs @@ -191,6 +191,9 @@ pub fn rel_is(t: &str, full: &str) -> bool { /// picture, an embedded object) is handed over rather than copied. pub struct Package { files: BTreeMap>>, + /// Lowercased part name → its stored spelling (the first in name order): part names are + /// case-insensitive (ECMA-376 Part 2 §6.2.2.3), so every spelling is one part. + lower: HashMap, } impl Package { @@ -236,13 +239,30 @@ impl Package { } files.insert(name, Arc::new(buf)); } - Ok(Package { files }) + Ok(Package::new(files)) } /// A package from parts already in memory (a Flat OPC file, a converted document), by part /// name without the leading `/`. pub fn from_parts(files: BTreeMap>) -> Package { - Package { files: files.into_iter().map(|(name, bytes)| (name, Arc::new(bytes))).collect() } + Package::new(files.into_iter().map(|(name, bytes)| (name, Arc::new(bytes))).collect()) + } + + fn new(files: BTreeMap>>) -> Package { + let mut lower = HashMap::new(); + for name in files.keys() { + lower.entry(name.to_ascii_lowercase()).or_insert_with(|| name.clone()); + } + Package { files, lower } + } + + /// The stored spelling of part `name` (leading `/` ignored; case-insensitive fallback). + pub fn canonical(&self, name: &str) -> Option<&str> { + let n = name.trim_start_matches('/'); + match self.files.get_key_value(n) { + Some((k, _)) => Some(k), + None => self.lower.get(&n.to_ascii_lowercase()).map(String::as_str), + } } /// Part bytes by name (leading `/` ignored; case-insensitive fallback). @@ -253,11 +273,7 @@ impl Package { /// [`Package::get`], as the shared bytes themselves: for a part the document keeps whole, which /// then costs no copy (a picture document's pictures used to be held twice while it opened). pub fn get_shared(&self, name: &str) -> Option<&Arc>> { - let n = name.trim_start_matches('/'); - if let Some(v) = self.files.get(n) { - return Some(v); - } - self.files.iter().find(|(k, _)| k.eq_ignore_ascii_case(n)).map(|(_, v)| v) + self.canonical(name).and_then(|n| self.files.get(n)) } /// [`Package::xml`] for the main document part: malformed XML is an error, not a truncated read. @@ -291,7 +307,14 @@ impl Package { for r in root.els().filter(|e| e.local() == "Relationship") { let (Some(id), Some(target)) = (r.attr("Id"), r.attr("Target")) else { continue }; let external = r.attr("TargetMode").is_some_and(|m| m.eq_ignore_ascii_case("External")); - let resolved = if external { target.to_string() } else { resolve(dir, target) }; + // Internal targets name the part as stored, so a part reached under several spellings + // is kept, cached and written once (#3704). + let resolved = if external { + target.to_string() + } else { + let path = resolve(dir, target); + self.canonical(&path).map_or(path, str::to_string) + }; rels.list.push(Rel { id: id.to_string(), kind: r.attr("Type").unwrap_or("").to_string(), target: resolved, external }); } rels diff --git a/crates/docx/tests/it/passthrough.rs b/crates/docx/tests/it/passthrough.rs index 3f29bd50f1..65d4e3495e 100644 --- a/crates/docx/tests/it/passthrough.rs +++ b/crates/docx/tests/it/passthrough.rs @@ -357,3 +357,49 @@ fn extended_properties_are_kept_without_words_counts_and_personal_ones_follow_th assert!(!private.contains("Company") && !private.contains("Manager"), "{private}"); assert!(private.contains("")); } + +/// `name` with its letters' case flipped by the bits of `i`. +fn spelling(name: &str, i: u32) -> String { + let mut bit = 0; + name.chars() + .map(|c| { + if !c.is_ascii_alphabetic() { + return c; + } + bit += 1; + if i >> (bit - 1) & 1 == 1 { c.to_ascii_uppercase() } else { c.to_ascii_lowercase() } + }) + .collect() +} + +#[test] +fn a_part_reached_under_many_spellings_is_kept_and_written_once() { + // Part names are case-insensitive (ECMA-376 Part 2 §6.2.2.3): 64 relationships naming one picture + // and one unmodelled part in 64 casings are two parts, not 128 copies on save (#3704). + let ns = r#"xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture""#; + let n = 64; + let mut body = String::new(); + let mut doc_rels = Vec::new(); + for i in 0..n { + body.push_str(&format!( + r#""#, + i + 1 + )); + doc_rels.push((format!("rIdP{i}"), format!("{REL}/image"), spelling("media/image1.png", i))); + doc_rels.push((format!("rIdU{i}"), "urn:example:future-relationship".to_string(), spelling("unknownPart.xml", i))); + } + let doc_rels: Vec<(&str, &str, &str)> = doc_rels.iter().map(|(a, b, c)| (a.as_str(), b.as_str(), c.as_str())).collect(); + let picture = format!("\u{89}PNG\r\n\u{1a}\n{}", "0".repeat(64 * 1024)); + let unknown = format!(r#"{}"#, "x".repeat(64 * 1024)); + let ct = CT.replace(" Date: Thu, 22 Oct 2026 16:13:47 +0100 Subject: [PATCH 158/199] Progress log row and the file-format parity Hostile input line for findings 42, 43, 51, 55, 72, 73 and 76. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/file-format-parity.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index c2b352424f..82dafdb426 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -121,7 +121,7 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen | `w:fitText` | ❌ | ❌ | East Asian layout | | Glossary document (building blocks) | ✅ | ✅ | `w:docPart` entries (name, style, gallery, category, types, behaviours, description, guid, body) are read into `Document::glossary` and offered by Insert › Quick Parts and AutoText; `save` with `"in": "document"` stores one in the file. An untouched glossary is written back byte for byte with its own parts (styles, settings, fonts…); an edited one, or one whose parts weren't kept, is written from the model as `word/glossary1.xml` with copies of the document's styles and numbering. The glossary's own styles, settings and fonts, text boxes inside entries, and the notes and comments of a selection saved into the document don't survive an edit. Tests: `crates/docx/tests/it/glossary.rs` | | `w:altChunk`, sub-documents | ❌ | ❌ | | -| Hostile input | ✅ | — | `malformed.rs`, capped allocations, never-crash standard | +| Hostile input | ✅ | — | `malformed.rs`, capped allocations, never-crash standard. Failed zip entries count against the size limit, case variants of a part name are one part, embedded fonts and charts are written within budgets, and Flat OPC inlines kept XML only when it is one well-formed element (2026-10-22) | | Document paragraph defaults (`w:pPrDefault`) | ✅ | ✅ | Omission and explicit empty defaults remain distinct across repeated saves; nonempty defaults and later default-spacing edits are written | ## Saving many content controls @@ -270,6 +270,7 @@ Repeated DOCX style IDs use the last complete definition. The original regressio | 2026-10-23 | trivial | cu20we: preserve and render outline caps across DOCX/VML, screen and PDF; synthetic regressions; readiness estimates unchanged | | 2026-10-23 | trivial | `.doc` reader logs what its copy budget leaves out (floating objects, link targets, alt text, Prm formatting), once a kind per document (#3692) | | 2026-10-22 | trivial | `.doc` reader bounds hostile files: oversized sector tables refused, per-character re-reads removed, copy and decode budgets (#3642, #3692, #3708-#3712) | +| 2026-10-22 | trivial | Hostile input: DOCX package, comment, font, chart and Flat OPC hardening (#3692, #3723, #3704, #3742, #3648) | | 2026-10-23 | trivial | A relationship the markup names without the prefix is read as it wrote: an unqualified `id` on an element that carries a relationship names that relationship, and the writer keeps the attribute with the id the new package holds the part under; an ordinary `id` — a VML shape's, a drawing's — names no part (#3411 follow-up) | | 2026-10-21 | trivial | The reader takes the relationship ids the markup refers to from the relationship attributes of the main document it has already parsed, so a document with many relationships opens in the time its part takes, and an id that only appears as text is not a reference (#3411) | | 2026-10-23 | trivial | DOCX: TOC styles Word stores under ids in another language (`Sumrio1` named `toc 1`, `CabealhodoSumrio` named `TOC Heading`) read as `TOC1`…`TOC9` and `TOCHeading` unless the file uses those ids, so Update Table builds the entries in the document's own TOC styles (#3891); a TOC whose heading ends a section (entries in columns after a continuous break) is written and read as the engine's TOC, with the page-number tab at the column's edge (#3889) | From 6dc062d132820268f338fc25a177d708f0c7f4d9 Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Thu, 22 Oct 2026 22:04:09 +0100 Subject: [PATCH 159/199] #3692: a private save keeps the glossary's parts, without names and dates Ultrareview finding 1. With "remove personal information" or "remove date and time" set, the glossary was always written from the model (01c78899), which holds only the entries the reader took: one without a name, past the entry cap or with a bad content type was lost, and with none left the whole glossary part went. The kept parts are now written with the same names and dates removed that the writer leaves out of the main document (annotation authors become "Author", comment initials "A", annotation and UTC dates go; a people part's authors and presence too), rewritten with quick-xml and matched by namespace. A part that can't be rewritten (malformed, UTF-16) falls back to the model, so the privacy guarantee holds either way. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/docx/src/write/embed.rs | 113 ++++++++++++++++++++++++++++++- crates/docx/src/write/mod.rs | 13 ++-- crates/docx/tests/it/glossary.rs | 69 +++++++++++++++++++ 3 files changed, 189 insertions(+), 6 deletions(-) diff --git a/crates/docx/src/write/embed.rs b/crates/docx/src/write/embed.rs index 6e04956143..869d91a401 100644 --- a/crates/docx/src/write/embed.rs +++ b/crates/docx/src/write/embed.rs @@ -48,6 +48,8 @@ pub(crate) struct EmbedWriter<'d> { pub parts: Vec<(String, Vec, String, PartRels)>, /// Leave out the main document's kept glossary (it's written from the model instead). pub drop_glossary: bool, + /// Kept parts written with these bytes instead of their own (see [`Self::scrub_glossary`]). + scrubbed: HashMap>, } impl<'d> EmbedWriter<'d> { @@ -93,6 +95,7 @@ impl<'d> EmbedWriter<'d> { markup_bytes: MAX_MARKUP_BYTES, parts: Vec::new(), drop_glossary: false, + scrubbed: HashMap::new(), } } @@ -230,6 +233,39 @@ impl<'d> EmbedWriter<'d> { }) } + /// Have the main document's kept glossary, and the parts it reaches, written without the + /// personal information (`names`) and dates the privacy settings remove (#3692). False when a + /// part's markup can't be rewritten: the glossary is then written from the model. + pub fn scrub_glossary(&mut self, names: bool, dates: bool) -> bool { + let Some(roots) = self.passthrough.get(crate::package::UNMODELLED_ROOTS) else { return true }; + let mut todo: Vec = String::from_utf8_lossy(roots) + .lines() + .take(MAX_EMBEDDED_RELS * 2) + .filter_map(|line| match line.split('\t').collect::>().as_slice() { + [source, kind, target] if *source == "D" && rel_is(kind, rt::GLOSSARY) => Some(target.to_string()), + _ => None, + }) + .collect(); + let mut seen: HashSet = HashSet::new(); + while let Some(p) = todo.pop() { + if seen.len() >= MAX_EMBEDDED_PARTS || !seen.insert(p.clone()) { + continue; + } + let (Some(info), Some(bytes)) = (self.manifest.parts.get(&p), self.passthrough.get(&p)) else { continue }; + todo.extend(info.rels.iter().filter(|r| !r.external).map(|r| r.target.clone())); + if !(info.content_type.ends_with("xml") || p.to_ascii_lowercase().ends_with(".xml")) { + continue; + } + match scrub_private(bytes, names, dates) { + Some(b) => { + self.scrubbed.insert(p, b); + } + None => return false, + } + } + true + } + /// What copying the parts reached from `roots` costs against the copy budget. fn tree_cost(&self, roots: &[&str]) -> u64 { let mut seen: HashSet<&str> = HashSet::new(); @@ -283,8 +319,9 @@ impl<'d> EmbedWriter<'d> { rels.add_with_id(&r.id, &r.kind, &relative(&new, &target), false); } let ct = if info.content_type.is_empty() { "application/octet-stream".to_string() } else { info.content_type }; + let bytes = self.scrubbed.get(&orig).cloned().unwrap_or_else(|| bytes.to_vec()); written.push(self.parts.len()); - self.parts.push((new, bytes.to_vec(), ct, rels)); + self.parts.push((new, bytes, ct, rels)); } Some(name) } @@ -640,6 +677,80 @@ fn patch_rel_ids(bytes: &[u8], ids: &HashMap) -> Vec { out } +/// `bytes` (kept XML) without the personal information and dates the privacy settings remove, as +/// the writer leaves them out of the parts it writes: annotation authors become "Author" and +/// comment initials "A" (`names`); annotation dates and UTC dates go (`dates`); and with `names`, +/// a people part's authors become "Author" and their presence information goes. Attributes are +/// matched by namespace, so any prefix is caught. `None` when the markup can't be rewritten +/// (malformed, or not UTF-8). +fn scrub_private(bytes: &[u8], names: bool, dates: bool) -> Option> { + use std::borrow::Cow; + + use quick_xml::events::Event; + use quick_xml::events::attributes::Attribute; + use quick_xml::name::{QName, ResolveResult}; + use quick_xml::reader::NsReader; + use quick_xml::writer::Writer; + + const WML: &[u8] = b"http://schemas.openxmlformats.org/wordprocessingml/2006/main"; + const WML_STRICT: &[u8] = b"http://purl.oclc.org/ooxml/wordprocessingml/main"; + const W15: &[u8] = b"http://schemas.microsoft.com/office/word/2012/wordml"; + // UTF-16 (or other wide encodings) would pass through unread. + if bytes.contains(&0) || bytes.starts_with(&[0xFF, 0xFE]) || bytes.starts_with(&[0xFE, 0xFF]) { + return None; + } + let ns_is = |r: &ResolveResult, ns: &[u8]| matches!(r, ResolveResult::Bound(n) if n.as_ref() == ns); + let wml = |r: &ResolveResult| ns_is(r, WML) || ns_is(r, WML_STRICT); + let mut r = NsReader::from_reader(bytes); + r.config_mut().check_end_names = false; + let mut out = Writer::new(Vec::with_capacity(bytes.len())); + let mut buf = Vec::new(); + let mut skip = Vec::new(); + loop { + buf.clear(); + let (res, ev) = r.read_resolved_event_into(&mut buf).ok()?; + let (in_wml, in_w15) = (wml(&res), ns_is(&res, W15)); + let ev = match ev { + Event::Eof => break, + Event::Start(ref e) | Event::Empty(ref e) if names && in_w15 && e.local_name().as_ref() == b"presenceInfo" => { + if matches!(ev, Event::Start(_)) { + let end = e.name().as_ref().to_vec(); + r.read_to_end_into(QName(&end), &mut skip).ok()?; + skip.clear(); + } + continue; + } + Event::Start(ref e) | Event::Empty(ref e) => { + let mut el = e.to_owned(); + el.clear_attributes(); + for a in e.attributes().with_checks(false) { + let a = a.ok()?; + let (ares, local) = r.resolver_mut().resolve_attribute(a.key); + // An unprefixed attribute of a WordprocessingML element is read as its own. + let wml_attr = wml(&ares) || (matches!(ares, ResolveResult::Unbound) && in_wml); + let value: Option<&[u8]> = match local.as_ref() { + b"author" if names && (wml_attr || ns_is(&ares, W15)) => Some(super::ANONYMOUS_AUTHOR.as_bytes()), + b"initials" if names && wml_attr => Some(super::ANONYMOUS_INITIALS.as_bytes()), + b"date" if dates && wml_attr => None, + b"dateUtc" if dates => None, + _ => { + el.push_attribute(a); + continue; + } + }; + if let Some(v) = value { + el.push_attribute(Attribute { key: a.key, value: Cow::Borrowed(v) }); + } + } + if matches!(ev, Event::Start(_)) { Event::Start(el) } else { Event::Empty(el) } + } + ev => ev, + }; + out.write_event(ev).ok()?; + } + Some(out.into_inner()) +} + /// A VML `style` with the object's size, rotation and flips, and its offset when it floats /// (`position:absolute`). fn vml_style_with(style: &str, w: f32, h: f32, float: &Float) -> String { diff --git a/crates/docx/src/write/mod.rs b/crates/docx/src/write/mod.rs index 427c0df3ea..36a46de645 100644 --- a/crates/docx/src/write/mod.rs +++ b/crates/docx/src/write/mod.rs @@ -272,10 +272,13 @@ pub(crate) fn write_parts(doc: &Document, flavor: Flavor) -> Result Vec { /// The author the saved file gives a comment or tracked change when the document removes personal /// information on save (`w:removePersonalInformation`), as Word does. pub(crate) const ANONYMOUS_AUTHOR: &str = "Author"; -const ANONYMOUS_INITIALS: &str = "A"; +pub(crate) const ANONYMOUS_INITIALS: &str = "A"; /// The `w:author` written for an annotation by `author`: "Author" when the document removes /// personal information on save, "Unknown" when it has none. diff --git a/crates/docx/tests/it/glossary.rs b/crates/docx/tests/it/glossary.rs index 3d1521550f..8d7b9f0bcd 100644 --- a/crates/docx/tests/it/glossary.rs +++ b/crates/docx/tests/it/glossary.rs @@ -262,6 +262,75 @@ fn an_unchanged_glossary_follows_the_privacy_settings() { } } +#[test] +fn a_private_save_keeps_the_glossary_entries_the_model_passed_over() { + // The privacy settings had the glossary written from the model, which holds only the + // entries the reader took: one without a name was lost, and with no other entry the whole + // glossary went. The kept parts are now written without the names and dates instead. + let nameless = r#"Nameless entrynoted"#; + // The comments part binds WordprocessingML to another prefix: names are matched by namespace. + let comments = format!( + r#"A note"# + ); + let bytes = package( + &[("rId7", GLOSSARY_REL, "glossary/document.xml")], + &[ + ( + "[Content_Types].xml", + content_types(&[ + ("/word/glossary/document.xml", GLOSSARY_CT), + ("/word/glossary/comments.xml", "application/vnd.openxmlformats-officedocument.wordprocessingml.comments+xml"), + ]), + ), + ("word/glossary/document.xml", glossary_xml(nameless)), + ("word/glossary/_rels/document.xml.rels", rels(&[("rId1", &format!("{REL}/comments"), "comments.xml")])), + ("word/glossary/comments.xml", comments), + ], + ); + let doc = wordcraft_docx::read(&bytes).unwrap(); + assert!(doc.glossary.is_empty(), "the reader passes over an entry without a name"); + for (personal, dates) in [(true, false), (false, true), (true, true)] { + let mut private = doc.clone(); + private.settings.remove_personal_info = personal; + private.settings.remove_date_time = dates; + let files = save(&private); + let target = glossary_target(&files).expect("the glossary survives"); + let g = text(&files, &target); + assert!(g.contains("Nameless entry"), "{g}"); + assert_eq!(g.contains("Quill Ramsey"), !personal, "{g}"); + assert_eq!(g.contains("2026-03-04T05:06:07Z"), !dates, "{g}"); + let c = files.iter().find(|(n, _)| n.starts_with("word/glossary/comments")).map(|(_, b)| String::from_utf8_lossy(b).into_owned()); + let c = c.expect("the glossary's comments survive"); + assert!(c.contains("A note"), "{c}"); + assert_eq!(c.contains("Quill Ramsey"), !personal, "{c}"); + assert_eq!(c.contains("x:initials=\"QR\""), !personal, "{c}"); + assert_eq!(c.contains("2026-03-04T05:06:07Z"), !dates, "{c}"); + } +} + +#[test] +fn a_kept_glossary_that_cant_be_rewritten_privately_is_written_from_the_model() { + // UTF-16 markup isn't rewritten in place: the model, which applies the settings, is written. + let tracked = r#"Added clause"#; + let utf16: Vec = [0xFF, 0xFE].into_iter().chain(glossary_xml(tracked).encode_utf16().flat_map(u16::to_le_bytes)).collect(); + let bytes = zip(&[ + ("_rels/.rels", rels(&[("rIdMain", &format!("{REL}/officeDocument"), "word/document.xml")]).into_bytes()), + ( + "word/document.xml", + format!(r#"Body"#).into_bytes(), + ), + ("word/_rels/document.xml.rels", rels(&[("rId7", GLOSSARY_REL, "glossary/document.xml")]).into_bytes()), + ("[Content_Types].xml", content_types(&[("/word/glossary/document.xml", GLOSSARY_CT)]).into_bytes()), + ("word/glossary/document.xml", utf16), + ]); + let mut doc = wordcraft_docx::read(&bytes).unwrap(); + assert_eq!(doc.glossary.len(), 1); + doc.settings.remove_personal_info = true; + let files = save(&doc); + let g = text(&files, &glossary_target(&files).expect("a glossary")); + assert!(g.contains("Added clause") && !g.contains("Quill Ramsey"), "{g}"); +} + /// The relationships part of a part in `word/`. fn glossary_rels(part: &str) -> String { let (dir, file) = part.rsplit_once('/').unwrap(); From eb2f0dab464e042529037492840daca47c108795 Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Fri, 23 Oct 2026 01:51:49 +0100 Subject: [PATCH 160/199] =?UTF-8?q?#3742:=20review=20=E2=80=94=20a=20kept?= =?UTF-8?q?=20XML=20part=20with=20a=20malformed=20comment=20goes=20out=20i?= =?UTF-8?q?n=20base64?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01CX1hMKX28764Lrx5moxs1o --- crates/docx/src/flat.rs | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/crates/docx/src/flat.rs b/crates/docx/src/flat.rs index 5cb44a0637..a11b69f156 100644 --- a/crates/docx/src/flat.rs +++ b/crates/docx/src/flat.rs @@ -445,6 +445,7 @@ fn xml_fragment<'a>(ct: &str, bytes: &'a [u8]) -> Option<&'a str> { /// close the wrapper and add parts of its own, so it goes out as `pkg:binaryData` (#3742). fn one_element(s: &str) -> bool { let mut r = quick_xml::Reader::from_str(s); + r.config_mut().check_comments = true; let (mut depth, mut roots) = (0usize, 0usize); loop { let attrs_ok = |e: &BytesStart| e.attributes().all(|a| a.is_ok()); @@ -574,9 +575,19 @@ mod tests { for ok in ["", "\nt & \n", ""] { assert!(xml_fragment(ct, ok.as_bytes()).is_some(), "{ok}"); } - for bad in - ["", "", "", "", "text", "&", "", "", ""] - { + for bad in [ + "", + "", + "", + "", + "text", + "&", + "", + "", + "", + "", + "", + ] { assert!(xml_fragment(ct, bad.as_bytes()).is_none(), "{bad}"); } } From c14e18cfa83a8096a4ed6a1d2c447fe1e67f73ce Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Fri, 23 Oct 2026 01:51:49 +0100 Subject: [PATCH 161/199] =?UTF-8?q?#3704:=20review=20=E2=80=94=20zip=20ent?= =?UTF-8?q?ries=20whose=20names=20differ=20only=20in=20case=20keep=20one?= =?UTF-8?q?=20stored=20spelling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01CX1hMKX28764Lrx5moxs1o --- crates/docx/src/package.rs | 30 +++++++++++++++++++++++------- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/crates/docx/src/package.rs b/crates/docx/src/package.rs index ddf106d747..9ced85c4d6 100644 --- a/crates/docx/src/package.rs +++ b/crates/docx/src/package.rs @@ -248,21 +248,26 @@ impl Package { Package::new(files.into_iter().map(|(name, bytes)| (name, Arc::new(bytes))).collect()) } - fn new(files: BTreeMap>>) -> Package { + fn new(mut files: BTreeMap>>) -> Package { let mut lower = HashMap::new(); for name in files.keys() { lower.entry(name.to_ascii_lowercase()).or_insert_with(|| name.clone()); } + // Entries whose names differ only in case are one part: keep the first spelling, so every + // spelling of the name reaches the same stored entry (#3704). + files.retain(|name, _| { + let kept = lower.get(&name.to_ascii_lowercase()) == Some(name); + if !kept { + log::warn!("docx: dropping zip entry {name}, a second spelling of a part"); + } + kept + }); Package { files, lower } } - /// The stored spelling of part `name` (leading `/` ignored; case-insensitive fallback). + /// The stored spelling of part `name` (leading `/` ignored; case-insensitive). pub fn canonical(&self, name: &str) -> Option<&str> { - let n = name.trim_start_matches('/'); - match self.files.get_key_value(n) { - Some((k, _)) => Some(k), - None => self.lower.get(&n.to_ascii_lowercase()).map(String::as_str), - } + self.lower.get(&name.trim_start_matches('/').to_ascii_lowercase()).map(String::as_str) } /// Part bytes by name (leading `/` ignored; case-insensitive fallback). @@ -445,6 +450,17 @@ mod tests { zip_entries(&e).unwrap() } + #[test] + fn case_colliding_entries_are_one_part() { + let z = zip_of(&[("word/media/Image.png", b"upper".to_vec()), ("word/media/image.png", b"lower".to_vec())]); + let p = Package::open(&z).unwrap(); + for spelling in ["word/media/Image.png", "word/media/image.png", "/WORD/MEDIA/IMAGE.PNG"] { + assert_eq!(p.canonical(spelling), Some("word/media/Image.png"), "{spelling}"); + assert_eq!(p.get(spelling), Some(&b"upper"[..]), "{spelling}"); + } + assert_eq!(p.files.len(), 1); + } + #[test] fn decompression_bomb_is_rejected() { let z = zip_of(&[("word/document.xml", vec![b' '; 2_000_000])]); From 5819b1ca62a61ce39e6d2bcb291e873ab69be142 Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Fri, 23 Oct 2026 01:51:49 +0100 Subject: [PATCH 162/199] =?UTF-8?q?#3692:=20review=20=E2=80=94=20comment-i?= =?UTF-8?q?d=20test=20checks=20the=20ids,=20not=20a=20wall-clock=20deadlin?= =?UTF-8?q?e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01CX1hMKX28764Lrx5moxs1o --- crates/docx/tests/it/malformed.rs | 3 --- 1 file changed, 3 deletions(-) diff --git a/crates/docx/tests/it/malformed.rs b/crates/docx/tests/it/malformed.rs index c2496de54c..a714f27b36 100644 --- a/crates/docx/tests/it/malformed.rs +++ b/crates/docx/tests/it/malformed.rs @@ -372,11 +372,8 @@ fn comments_with_non_numeric_ids_open_in_linear_time() { ("word/_rels/document.xml.rels", rels.as_bytes()), ("word/comments.xml", comments.as_bytes()), ]); - let t = std::time::Instant::now(); let d = read(&z).unwrap(); - let took = t.elapsed(); assert_eq!(d.comments.len(), n); let ids: std::collections::HashSet<_> = d.comments.keys().collect(); assert_eq!(ids.len(), n, "every comment has its own id"); - assert!(took.as_secs_f32() < 2.0, "opening took {took:?}"); } From 364688a8c3b2ef63d0bc392a0b324ca185c2d92c Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Fri, 23 Oct 2026 11:08:28 +0100 Subject: [PATCH 163/199] =?UTF-8?q?#3704:=20review=20=E2=80=94=20case-vari?= =?UTF-8?q?ant=20zip=20names=20keep=20the=20spelling=20a=20relationship=20?= =?UTF-8?q?names;=20chart,=20font=20and=20glossary=20limits=20log=20once?= =?UTF-8?q?=20per=20save?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Folding zip entries whose names differ only in case now keeps the spelling a relationship part targets, and the first in byte order only when no relationship names any of them. The fold logs one warning per package, not one per entry. Limits that leave data out now say so, once per document or save: - a chart past the chart limits on save (series or points dropped); - embedded fonts past the byte budget (one warning with the count); - a kept glossary that can't be cleaned of personal information and is written from the model instead. Tests capture the warnings (crate::test_log) and check each is logged once. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01CX1hMKX28764Lrx5moxs1o --- crates/docx/src/chart_spec.rs | 13 +++++- crates/docx/src/fonts.rs | 14 +++++- crates/docx/src/lib.rs | 2 + crates/docx/src/package.rs | 77 +++++++++++++++++++++++++++----- crates/docx/src/test_log.rs | 36 +++++++++++++++ crates/docx/src/write/embed.rs | 51 ++++++++++++++++++++- crates/docx/tests/it/glossary.rs | 30 ++++++++++++- 7 files changed, 205 insertions(+), 18 deletions(-) create mode 100644 crates/docx/src/test_log.rs diff --git a/crates/docx/src/chart_spec.rs b/crates/docx/src/chart_spec.rs index 33de668900..4097002b6b 100644 --- a/crates/docx/src/chart_spec.rs +++ b/crates/docx/src/chart_spec.rs @@ -26,7 +26,18 @@ const VAL_AX: &str = "500000002"; /// The chart part for `spec`: a complete XML document, written as [`ChartSpec::sanitize`] leaves /// it (a value that isn't finite is a gap). pub fn chart_xml(spec: &ChartSpec) -> String { - let spec = &bounded(spec); + chart_part(spec).0 +} + +/// [`chart_xml`], and whether the chart limits left series or points of `spec` out of it. +pub(crate) fn chart_part(spec: &ChartSpec) -> (String, bool) { + let kept = bounded(spec); + let points = spec.series.iter().map(|s| s.values.len()).max().unwrap_or(0).max(spec.categories.len()); + let cut = kept.series.len() < spec.series.len() || kept.points() < points; + (write_chart(&kept), cut) +} + +fn write_chart(spec: &ChartSpec) -> String { let mut w = W::new(); w.open("c:chartSpace", &[("xmlns:c", NS_C), ("xmlns:a", NS_A), ("xmlns:r", NS_R)]); w.empty("c:roundedCorners", &[("val", "0")]); diff --git a/crates/docx/src/fonts.rs b/crates/docx/src/fonts.rs index 7329c122af..66e92ae0b8 100644 --- a/crates/docx/src/fonts.rs +++ b/crates/docx/src/fonts.rs @@ -40,6 +40,7 @@ pub fn embedded_fonts(doc: &Document) -> Vec { /// [`embedded_fonts`], copying at most `budget` bytes of font files. fn fonts_within(doc: &Document, mut budget: u64) -> Vec { + let budget_total = budget; let Some(table) = doc.passthrough.get(FONT_TABLE_PART).and_then(|b| crate::xml::parse(b).ok()) else { return Vec::new(); }; @@ -50,6 +51,7 @@ fn fonts_within(doc: &Document, mut budget: u64) -> Vec { let mut out = Vec::new(); // A reference repeating one already taken (same file, key and name) gives the same font. let mut taken: HashSet<(&str, [u8; 16], &str)> = HashSet::new(); + let mut over_budget = 0usize; let embeds = table .children("w:font") .flat_map(|f| f.els().filter(|e| EMBEDS.contains(&e.name.as_str())).map(move |e| (f.attr("w:name").unwrap_or(""), e))); @@ -80,7 +82,7 @@ fn fonts_within(doc: &Document, mut budget: u64) -> Vec { } // Checked before the copy is made. let Some(left) = budget.checked_sub(bytes.len() as u64) else { - log::warn!("docx: embedded fonts past {MAX_FONT_BYTES} bytes; {} is not loaded", rel.target); + over_budget += 1; continue; }; budget = left; @@ -90,6 +92,10 @@ fn fonts_within(doc: &Document, mut budget: u64) -> Vec { } out.push(EmbeddedFont { name: name.to_string(), data }); } + if over_budget > 0 { + // Once for the document, not once per font. + log::warn!("docx: embedded fonts past the {budget_total} byte budget; {over_budget} of them are not loaded"); + } out } @@ -148,7 +154,11 @@ mod tests { let refs: Vec<(String, &str)> = (0..MAX_FONTS).map(|i| (format!("Font {i}"), "word/fonts/a.odttf")).collect(); let refs: Vec<(&str, &str)> = refs.iter().map(|(n, p)| (n.as_str(), *p)).collect(); let doc = doc_with(&refs, &[("word/fonts/a.odttf", 1000)]); - assert_eq!(fonts_within(&doc, 3500).len(), 3, "three copies fit"); + let (fonts, warned) = crate::test_log::warnings(|| fonts_within(&doc, 3500)); + assert_eq!(fonts.len(), 3, "three copies fit"); + let budget: Vec<_> = warned.iter().filter(|w| w.contains("byte budget")).collect(); + assert_eq!(budget.len(), 1, "one warning for the document, not one per font: {warned:?}"); + assert!(budget.iter().all(|w| w.contains(&format!("{} of them", MAX_FONTS - 3))), "{budget:?}"); assert_eq!(embedded_fonts(&doc).len(), MAX_FONTS, "well within the real budget"); } } diff --git a/crates/docx/src/lib.rs b/crates/docx/src/lib.rs index 27cb0e6da1..72cfdebfa9 100644 --- a/crates/docx/src/lib.rs +++ b/crates/docx/src/lib.rs @@ -16,6 +16,8 @@ mod fonts; mod package; mod read; mod smart_art; +#[cfg(test)] +mod test_log; mod units; mod wordml2003; mod wordml2003_write; diff --git a/crates/docx/src/package.rs b/crates/docx/src/package.rs index 9ced85c4d6..a7511fd2bc 100644 --- a/crates/docx/src/package.rs +++ b/crates/docx/src/package.rs @@ -1,6 +1,7 @@ //! OPC package access: bounded zip reading, relationships, part names. -use std::collections::{BTreeMap, HashMap}; +use std::collections::hash_map::Entry; +use std::collections::{BTreeMap, HashMap, HashSet}; use std::io::{Cursor, Read, Write}; use std::sync::Arc; @@ -249,19 +250,31 @@ impl Package { } fn new(mut files: BTreeMap>>) -> Package { - let mut lower = HashMap::new(); + let mut lower: HashMap = HashMap::new(); + let mut collides = false; for name in files.keys() { - lower.entry(name.to_ascii_lowercase()).or_insert_with(|| name.clone()); - } - // Entries whose names differ only in case are one part: keep the first spelling, so every - // spelling of the name reaches the same stored entry (#3704). - files.retain(|name, _| { - let kept = lower.get(&name.to_ascii_lowercase()) == Some(name); - if !kept { - log::warn!("docx: dropping zip entry {name}, a second spelling of a part"); + match lower.entry(name.to_ascii_lowercase()) { + Entry::Occupied(_) => collides = true, + Entry::Vacant(v) => { + v.insert(name.clone()); + } } - kept - }); + } + if !collides { + return Package { files, lower }; + } + // Entries whose names differ only in case are one part, so every spelling of the name + // reaches the same stored entry (#3704). The spelling kept is the one a relationship + // names, so the part the document points at is the one read; the first in byte order only + // when no relationship names any of them. + let named = rel_targets(&files); + for name in files.keys().rev().filter(|n| named.contains(n.as_str())) { + lower.insert(name.to_ascii_lowercase(), name.clone()); + } + let before = files.len(); + files.retain(|name, _| lower.get(&name.to_ascii_lowercase()) == Some(name)); + let dropped = before.saturating_sub(files.len()); + log::warn!("docx: {dropped} zip entries are other spellings of parts already read; they are left out"); Package { files, lower } } @@ -394,6 +407,24 @@ fn split_dir(part: &str) -> (&str, &str) { } } +/// Every internal part name the relationship parts in `files` target, spelled as they spell it. +fn rel_targets(files: &BTreeMap>>) -> HashSet { + let mut named = HashSet::new(); + for (name, bytes) in files { + // `dir/_rels/file.rels` relates `dir/file`, so its targets resolve against `dir`. + let Some(at) = name.rfind("_rels/").filter(|_| name.to_ascii_lowercase().ends_with(".rels")) else { continue }; + let dir = name.get(..at).unwrap_or("").trim_end_matches('/'); + let Ok(root) = xml::parse(bytes) else { continue }; + for r in root.els().filter(|e| e.local() == "Relationship") { + let external = r.attr("TargetMode").is_some_and(|m| m.eq_ignore_ascii_case("External")); + if let (Some(target), false) = (r.attr("Target"), external) { + named.insert(resolve(dir, target)); + } + } + } + named +} + /// Resolve a relative target against a source directory into a package path. pub fn resolve(dir: &str, target: &str) -> String { let target = target.split('#').next().unwrap_or(target); @@ -461,6 +492,28 @@ mod tests { assert_eq!(p.files.len(), 1); } + #[test] + fn the_spelling_a_relationship_names_is_kept() { + // The first spelling in byte order ("Image.png") was kept even when the document's + // relationship named the other one, so the picture shown was not the one it pointed at. + let rels = r#""#; + let z = zip_of(&[ + ("word/_rels/document.xml.rels", rels.as_bytes().to_vec()), + ("word/media/Image.png", b"upper".to_vec()), + ("word/media/image.png", b"lower".to_vec()), + ("word/media/Other.png", b"first".to_vec()), + ("word/media/other.png", b"second".to_vec()), + ]); + let (p, warned) = crate::test_log::warnings(|| Package::open(&z).unwrap()); + assert_eq!(p.canonical("word/media/IMAGE.png"), Some("word/media/image.png")); + assert_eq!(p.get("word/media/Image.png"), Some(&b"lower"[..])); + assert_eq!(p.get("word/media/OTHER.PNG"), Some(&b"first"[..]), "no relationship names it: first in byte order"); + assert_eq!(p.files.len(), 3); + let folded: Vec<_> = warned.iter().filter(|w| w.contains("other spellings")).collect(); + assert_eq!(folded.len(), 1, "one warning for the package: {warned:?}"); + assert!(folded.iter().all(|w| w.contains("2 zip entries")), "{folded:?}"); + } + #[test] fn decompression_bomb_is_rejected() { let z = zip_of(&[("word/document.xml", vec![b' '; 2_000_000])]); diff --git a/crates/docx/src/test_log.rs b/crates/docx/src/test_log.rs new file mode 100644 index 0000000000..745ce21fe1 --- /dev/null +++ b/crates/docx/src/test_log.rs @@ -0,0 +1,36 @@ +//! Test helper: the warnings this crate logs, captured per test thread, so a test can check that +//! a limit which leaves something out says so. + +use std::cell::RefCell; + +struct Capture; + +thread_local! { + static LINES: RefCell> = const { RefCell::new(Vec::new()) }; +} + +impl log::Log for Capture { + fn enabled(&self, m: &log::Metadata) -> bool { + m.level() <= log::Level::Warn + } + + fn log(&self, r: &log::Record) { + if self.enabled(r.metadata()) { + LINES.with(|l| l.borrow_mut().push(r.args().to_string())); + } + } + + fn flush(&self) {} +} + +static CAPTURE: Capture = Capture; + +/// `f`'s result and the warnings logged on this thread while it ran. +pub(crate) fn warnings(f: impl FnOnce() -> T) -> (T, Vec) { + // Only this logger is ever installed in a test binary; a second call keeps it. + let _ = log::set_logger(&CAPTURE); + log::set_max_level(log::LevelFilter::Warn); + LINES.with(|l| l.borrow_mut().clear()); + let out = f(); + (out, LINES.with(|l| std::mem::take(&mut *l.borrow_mut()))) +} diff --git a/crates/docx/src/write/embed.rs b/crates/docx/src/write/embed.rs index 869d91a401..405b901e4f 100644 --- a/crates/docx/src/write/embed.rs +++ b/crates/docx/src/write/embed.rs @@ -50,6 +50,8 @@ pub(crate) struct EmbedWriter<'d> { pub drop_glossary: bool, /// Kept parts written with these bytes instead of their own (see [`Self::scrub_glossary`]). scrubbed: HashMap>, + /// A chart past the chart limits has been written (and the loss logged, once per save). + chart_cut: bool, } impl<'d> EmbedWriter<'d> { @@ -96,6 +98,7 @@ impl<'d> EmbedWriter<'d> { parts: Vec::new(), drop_glossary: false, scrubbed: HashMap::new(), + chart_cut: false, } } @@ -260,7 +263,12 @@ impl<'d> EmbedWriter<'d> { Some(b) => { self.scrubbed.insert(p, b); } - None => return false, + None => { + log::warn!( + "docx: the glossary's part {p} can't be cleaned of personal information; the glossary is written from its entries, without what WordCraft doesn't model" + ); + return false; + } } } true @@ -445,7 +453,12 @@ impl super::Writer<'_> { /// related from the story part through `rels`. pub(super) fn chart_graphic(&mut self, spec: &wordcraft_doc::chart::ChartSpec, rels: &mut PartRels) -> String { let name = self.embeds.fresh("word/charts/chart", ".xml"); - let bytes = crate::chart_spec::chart_xml(spec).into_bytes(); + let (xml, cut) = crate::chart_spec::chart_part(spec); + if cut && !self.embeds.chart_cut { + self.embeds.chart_cut = true; + log::warn!("docx: a chart has more series or points than a chart part holds; the rest are left out of the file"); + } + let bytes = xml.into_bytes(); self.embeds.parts.push((name.clone(), bytes, crate::chart_spec::CHART_CT.to_string(), PartRels::default())); let id = rels.add(crate::package::rt::CHART, &relative(STORY_PART, &name), false); let mut w = xml::W::default(); @@ -820,6 +833,40 @@ fn valid_part_name(name: &str) -> String { mod tests { use super::*; + #[test] + fn charts_cut_to_the_limits_on_save_say_so_once() { + // A model deserialised as is can hold more than a chart part keeps; the save left the rest + // out without a word (review of #3872). + use std::sync::Arc; + use wordcraft_doc::chart::{ChartSeries, ChartSpec, ChartType, MAX_SERIES}; + use wordcraft_doc::graphic::{Graphic, GraphicKind}; + use wordcraft_doc::{CharProps, InlineObject, Pos}; + let chart = |series: usize| { + let spec = ChartSpec { + kind: ChartType::Column, + categories: vec!["a".into(), "b".into()], + series: (0..series).map(|i| ChartSeries { name: format!("S{i}"), values: vec![Some(1.0); 2] }).collect(), + title: None, + legend: None, + data_labels: false, + }; + let graphic = Graphic { kind: GraphicKind::Chart, w: 100.0, h: 100.0, chart: Some(Arc::new(spec)), ..Default::default() }; + InlineObject::Graphic { w: 100.0, h: 100.0, alt: String::new(), float: Default::default(), graphic: Arc::new(graphic) } + }; + let save = |charts: Vec| { + let mut doc = wordcraft_doc::Document::new(); + for c in charts { + doc.insert_object(&Pos::body(0, 0), c, &CharProps::default()).unwrap(); + } + crate::test_log::warnings(|| crate::write(&doc).unwrap()).1 + }; + let cut = |w: &Vec| w.iter().filter(|w| w.contains("chart")).count(); + let warned = save(vec![chart(MAX_SERIES + 1), chart(MAX_SERIES + 5), chart(2)]); + assert_eq!(cut(&warned), 1, "one warning for the save, not one per chart: {warned:?}"); + let warned = save(vec![chart(3)]); + assert_eq!(cut(&warned), 0, "nothing left out: {warned:?}"); + } + #[test] fn data_part_drawing_ids_are_rewritten_once() { let ids: HashMap = [("rId9".to_string(), "rId2".to_string()), ("rId2".to_string(), "rId3".to_string())].into_iter().collect(); diff --git a/crates/docx/tests/it/glossary.rs b/crates/docx/tests/it/glossary.rs index 8d7b9f0bcd..92e07a6514 100644 --- a/crates/docx/tests/it/glossary.rs +++ b/crates/docx/tests/it/glossary.rs @@ -326,9 +326,37 @@ fn a_kept_glossary_that_cant_be_rewritten_privately_is_written_from_the_model() let mut doc = wordcraft_docx::read(&bytes).unwrap(); assert_eq!(doc.glossary.len(), 1); doc.settings.remove_personal_info = true; - let files = save(&doc); + let (files, warned) = warnings(|| save(&doc)); let g = text(&files, &glossary_target(&files).expect("a glossary")); assert!(g.contains("Added clause") && !g.contains("Quill Ramsey"), "{g}"); + // What the model doesn't hold is lost: the save says so (review of #3872). + assert_eq!(warned.iter().filter(|w| w.contains("written from its entries")).count(), 1, "{warned:?}"); +} + +/// `f`'s result and the warnings logged on this thread while it ran. +fn warnings(f: impl FnOnce() -> T) -> (T, Vec) { + use std::cell::RefCell; + struct Capture; + thread_local! { + static LINES: RefCell> = const { RefCell::new(Vec::new()) }; + } + impl log::Log for Capture { + fn enabled(&self, m: &log::Metadata) -> bool { + m.level() <= log::Level::Warn + } + fn log(&self, r: &log::Record) { + if self.enabled(r.metadata()) { + LINES.with(|l| l.borrow_mut().push(r.args().to_string())); + } + } + fn flush(&self) {} + } + static CAPTURE: Capture = Capture; + let _ = log::set_logger(&CAPTURE); + log::set_max_level(log::LevelFilter::Warn); + LINES.with(|l| l.borrow_mut().clear()); + let out = f(); + (out, LINES.with(|l| std::mem::take(&mut *l.borrow_mut()))) } /// The relationships part of a part in `word/`. From 01a75b99b1bf644f1b77ae7bad3607ee1c64f745 Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Thu, 22 Oct 2026 18:05:47 +0100 Subject: [PATCH 164/199] #3692: add_media looks up duplicates by content instead of scanning every image Finding 45 (CWE-407): Document::add_media compared each new image with every stored one, so an import with many distinct small images did quadratic work (40k images: 213 s before, 0.26 s after). A MediaIndex cache (not saved, ignored by ==) maps bytes to their first key, is rebuilt when media changed size behind its back, and every hit is checked against media. Generated image{n} names also skip taken names once instead of on every add. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/doc/src/lib.rs | 74 +++++++++++++++++++++++++++++++++++++---- crates/doc/src/tests.rs | 38 +++++++++++++++++++++ 2 files changed, 105 insertions(+), 7 deletions(-) diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 75fa27ac47..4301d03792 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -859,6 +859,50 @@ pub struct Document { /// The document's own building blocks (its glossary document), in file order. #[serde(skip_serializing_if = "Vec::is_empty")] pub glossary: Vec, + /// Lookup cache for [`Document::add_media`]; not part of the document (never saved, ignored + /// by `==`). + #[serde(skip)] + pub media_index: MediaIndex, +} + +/// [`Document::media`] by content, so adding media is linear in the bytes added rather than a +/// scan of every earlier image (CWE-407). Rebuilt whenever `media` changed size behind its back; +/// a hit is always checked against `media`, so a stale entry can at worst miss a duplicate. +#[derive(Clone, Default)] +pub struct MediaIndex { + by_bytes: std::collections::HashMap>, String>, + /// `media.len()` when the index last matched it. + len: usize, + /// The number after the last generated `image{n}` key, so taken names are probed once. + next: usize, +} + +impl MediaIndex { + fn rebuild(&mut self, media: &BTreeMap>>) { + self.by_bytes.clear(); + // The first key (in key order) of equal bytes wins, as a scan would find it. + for (k, v) in media.iter().rev() { + self.by_bytes.insert(v.clone(), k.clone()); + } + self.len = media.len(); + self.next = 0; + } + + fn find(&self, media: &BTreeMap>>, bytes: &Vec) -> Option { + self.by_bytes.get(bytes).filter(|k| media.get(*k).is_some_and(|v| **v == *bytes)).cloned() + } +} + +impl PartialEq for MediaIndex { + fn eq(&self, _: &Self) -> bool { + true + } +} + +impl std::fmt::Debug for MediaIndex { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("MediaIndex") + } } impl Default for Document { @@ -887,6 +931,7 @@ impl Document { media: BTreeMap::new(), passthrough: BTreeMap::new(), glossary: Vec::new(), + media_index: MediaIndex::default(), } } @@ -1563,17 +1608,32 @@ impl Document { /// Add a media item; returns its key (deduplicated by content). pub fn add_media(&mut self, bytes: Vec, ext: &str) -> String { - if let Some((k, _)) = self.media.iter().find(|(_, v)| v.as_slice() == bytes.as_slice()) { - return k.clone(); + let ix = &mut self.media_index; + if ix.len != self.media.len() { + ix.rebuild(&self.media); } - let n = self.media.len() + 1; - let mut key = format!("image{n}.{ext}"); - let mut i = n; + if let Some(k) = ix.find(&self.media, &bytes) { + return k; + } + if ix.by_bytes.contains_key(&bytes) { + // Stale (`media` was edited directly without changing its size): look again. + ix.rebuild(&self.media); + if let Some(k) = ix.find(&self.media, &bytes) { + return k; + } + } + let n = self.media.len().saturating_add(1); + let mut i = n.max(ix.next); + let mut key = format!("image{i}.{ext}"); while self.media.contains_key(&key) { - i += 1; + i = i.saturating_add(1); key = format!("image{i}.{ext}"); } - self.media.insert(key.clone(), Arc::new(bytes)); + let bytes = Arc::new(bytes); + self.media.insert(key.clone(), bytes.clone()); + ix.by_bytes.entry(bytes).or_insert_with(|| key.clone()); + ix.len = self.media.len(); + ix.next = i.saturating_add(1); key } diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs index 18e69c8325..6097a52ed8 100644 --- a/crates/doc/src/tests.rs +++ b/crates/doc/src/tests.rs @@ -90,6 +90,44 @@ fn parts_and_media() { assert_ne!(k1, k3); } +#[test] +fn many_distinct_media_add_in_linear_time() { + // An import with tens of thousands of small distinct images (same length, differing only at + // the end) used to compare each against every earlier one. + let mut d = Document::new(); + let t = std::time::Instant::now(); + let n = 40_000u32; + for i in 0..n { + let mut bytes = vec![7u8; 256]; + bytes.extend(i.to_le_bytes()); + d.add_media(bytes, "png"); + } + assert_eq!(d.media.len(), n as usize); + // Duplicates still find their first copy, including media inserted directly. + let mut first = vec![7u8; 256]; + first.extend(0u32.to_le_bytes()); + assert_eq!(d.add_media(first, "png"), "image1.png"); + d.media.insert("direct.png".into(), Arc::new(vec![9, 9])); + assert_eq!(d.add_media(vec![9, 9], "png"), "direct.png"); + assert!(t.elapsed().as_secs_f32() < 2.0, "{:?}", t.elapsed()); + // The lookup cache isn't part of the document. + let mut e = d.clone(); + e.media_index = MediaIndex::default(); + assert_eq!(d, e); +} + +#[test] +fn media_keys_skip_taken_names_once() { + let mut d = Document::new(); + // A file whose media already use the names add_media would pick next. + for i in 2..2002u32 { + d.media.insert(format!("image{i}.png"), Arc::new(i.to_le_bytes().to_vec())); + } + assert_eq!(d.add_media(vec![0], "png"), "image2002.png"); + assert_eq!(d.add_media(vec![1], "png"), "image2003.png"); + assert_eq!(d.add_media(vec![2], "png"), "image2004.png"); +} + #[test] fn free_ids_skip_taken_ones_instead_of_overflowing() { assert_eq!(free_id(None, std::iter::empty(), 1), Some(1)); From acfc3d5041fec075a632b32675bdd70ce0263864 Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Thu, 22 Oct 2026 18:07:33 +0100 Subject: [PATCH 165/199] #3692: function-name lookahead in linear math stops at the longest name Finding 48 (CWE-407): P::word scanned and copied the rest of an alphabetic run for every letter that didn't start a function name, so a 20,000-letter equation did ~200 million steps outside the parser's step budget (5 parses: 8.3 s before, well under 0.1 s after). The scan now stops one past the longest name in FUNCTION_NAMES (computed at compile time); a longer run is never a name, as before. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/doc/src/math.rs | 17 +++++++++++++++++ crates/doc/src/math_linear.rs | 18 ++++++++++++++++-- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/crates/doc/src/math.rs b/crates/doc/src/math.rs index d620303484..650f92079d 100644 --- a/crates/doc/src/math.rs +++ b/crates/doc/src/math.rs @@ -1095,4 +1095,21 @@ mod tests { let deep = "√".repeat(3000); let _ = parse_linear(&deep); } + + #[test] + fn a_long_run_of_letters_parses_in_linear_time() { + // Each letter that doesn't start a function name used to rescan (and copy) the rest of + // the run, so a 20,000-letter equation did 200 million steps. + let t = std::time::Instant::now(); + for _ in 0..5 { + let e = parse_linear(&"x".repeat(30_000)); + assert!(matches!(e.as_slice(), [MNode::Run(r)] if r.text.len() == 20_000), "{:?}", e.len()); + } + assert!(t.elapsed().as_secs_f32() < 1.0, "{:?}", t.elapsed()); + // Function names are still found at the start of a run, and only as whole runs. + assert!(matches!(parse_linear("sin x").as_slice(), [MNode::Func { .. }])); + assert!(matches!(parse_linear("arcsin x").as_slice(), [MNode::Func { .. }])); + assert!(matches!(parse_linear("sinx").as_slice(), [MNode::Run(_)])); + assert!(matches!(parse_linear("arcsinhx").as_slice(), [MNode::Run(_)])); + } } diff --git a/crates/doc/src/math_linear.rs b/crates/doc/src/math_linear.rs index 1820c248d1..3f077f3778 100644 --- a/crates/doc/src/math_linear.rs +++ b/crates/doc/src/math_linear.rs @@ -18,6 +18,18 @@ pub const FUNCTION_NAMES: &[&str] = &[ "tanh", ]; +/// Length of the longest of [`FUNCTION_NAMES`]. +const MAX_NAME: usize = { + let (mut max, mut i) = (0, 0); + while i < FUNCTION_NAMES.len() { + if FUNCTION_NAMES[i].len() > max { + max = FUNCTION_NAMES[i].len(); + } + i += 1; + } + max +}; + /// Function names whose subscripts go underneath (`lim_(n→∞)`). const LIMIT_NAMES: &[&str] = &["lim", "liminf", "limsup", "max", "min", "sup", "inf", "det", "gcd", "Pr"]; @@ -410,10 +422,12 @@ impl P { fn word(&mut self, stops: &[char]) -> Arg { let start = self.i; let mut end = start; - while self.c.get(end).is_some_and(char::is_ascii_alphabetic) { + // Look no further than the longest name (and one past it, to see the run is longer): a + // scan to the end of every run made a long run of letters quadratic (CWE-407). + while end - start <= MAX_NAME && self.c.get(end).is_some_and(char::is_ascii_alphabetic) { end += 1; } - let word: String = self.c.get(start..end).unwrap_or(&[]).iter().collect(); + let word: String = if end - start > MAX_NAME { String::new() } else { self.c.get(start..end).unwrap_or(&[]).iter().collect() }; if !FUNCTION_NAMES.contains(&word.as_str()) { self.i = start + 1; return vec![text(self.c.get(start).copied().unwrap_or(' '))]; From 3a1c91f2d4af88f607f60f6580298cb9cb50343f Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Thu, 22 Oct 2026 18:19:09 +0100 Subject: [PATCH 166/199] #3614: crates/doc walks a paragraph's objects in one pass Finding 71 (CWE-407): loops of object_offsets + object_at recounted the objects before every offset, so walking a paragraph's objects was quadratic (40k markers: 360 s for the regression test before, 0.2 s after). Paragraph::objects_with_offsets yields (offset, object) in one pass, and every such walk in crates/doc uses it: the field scan (field_ranges, run on every docx load), control events, bookmarks, perm ranges, text_box_anchor, past_field_ends, typing_pos and the markers a range delete keeps. Also: perm_ranges keeps open ranges per id instead of searching a list for each end; kept_control_markers pairs each container with its table wraps once (was once per marker) and probes a set instead of every range. Part of #3614: the engine, docx reader and UI callers it lists live outside crates/doc and still use object_at in loops; they can move to objects_with_offsets separately. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/doc/src/control.rs | 64 +++++++++++++++++++++++++------------ crates/doc/src/edit.rs | 22 +++++++++---- crates/doc/src/fields.rs | 13 +++++--- crates/doc/src/lib.rs | 32 +++++++++---------- crates/doc/src/para.rs | 11 +++++++ crates/doc/src/tests.rs | 67 +++++++++++++++++++++++++++++++++++++++ 6 files changed, 160 insertions(+), 49 deletions(-) diff --git a/crates/doc/src/control.rs b/crates/doc/src/control.rs index b9eb2c6a99..1396ae39d9 100644 --- a/crates/doc/src/control.rs +++ b/crates/doc/src/control.rs @@ -14,6 +14,8 @@ //! outside what's deleted (so deleting across a control's edge empties it rather than breaking //! it); writers call [`Document::balance_controls`] so a saved file always has whole controls. +use std::collections::{BTreeMap, BTreeSet}; + use serde::{Deserialize, Serialize}; use crate::para::OBJ; @@ -312,10 +314,10 @@ pub fn control_events(bl: &Blocks) -> Vec> { if !p.objects.iter().any(|o| matches!(o, InlineObject::ControlStart { .. } | InlineObject::ControlEnd)) { continue; } - for off in p.object_offsets() { - match p.object_at(off) { - Some(InlineObject::ControlStart { control }) => out.push(ControlEvent::Open { block: i, off: Some(off), control }), - Some(InlineObject::ControlEnd) => out.push(ControlEvent::Close { block: i, off: Some(off) }), + for (off, o) in p.objects_with_offsets() { + match o { + InlineObject::ControlStart { control } => out.push(ControlEvent::Open { block: i, off: Some(off), control }), + InlineObject::ControlEnd => out.push(ControlEvent::Close { block: i, off: Some(off) }), _ => {} } } @@ -639,15 +641,21 @@ impl Document { out.push((r.end.path.clone(), r.end.off)); } } - // Markers paired with a table wrap: their partner is never deleted with them. + // Markers paired with a table wrap: their partner is never deleted with them. Each lookup + // is a set probe, and each container is paired once, not once per marker (#3614). + let in_ranges: BTreeSet<(&Path, usize)> = ranges.iter().flat_map(|r| [(&r.start.path, r.start.off), (&r.end.path, r.end.off)]).collect(); + let mut table_paired: BTreeMap<&[u32], BTreeSet<(usize, usize)>> = BTreeMap::new(); for path in &paths { let Some(p) = self.para(a.story, path) else { continue }; - for off in p.object_offsets() { - if !inside(path, off) || !matches!(p.object_at(off), Some(InlineObject::ControlStart { .. } | InlineObject::ControlEnd)) { + for (off, o) in p.objects_with_offsets() { + if !inside(path, off) + || !matches!(o, InlineObject::ControlStart { .. } | InlineObject::ControlEnd) + || in_ranges.contains(&(path, off)) + { continue; } - let paired = ranges.iter().any(|r| (r.start.path == *path && r.start.off == off) || (r.end.path == *path && r.end.off == off)); - if !paired && self.paired_with_table(a.story, path, off) { + let paired = table_paired.entry(path.parent()).or_insert_with(|| self.table_paired_markers(a.story, path)); + if paired.contains(&(path.last(), off)) { out.push((path.clone(), off)); } } @@ -657,16 +665,26 @@ impl Document { out } - /// Whether the marker at `path`/`off` is paired with a table wrap's open or close. - fn paired_with_table(&self, s: StoryRef, path: &Path, off: usize) -> bool { - let Some(bl) = self.container(s, path) else { return false }; + /// (block, offset) of the paragraph markers in `path`'s container that are paired with a + /// table wrap's open or close. + fn table_paired_markers(&self, s: StoryRef, path: &Path) -> BTreeSet<(usize, usize)> { + let Some(bl) = self.container(s, path) else { return BTreeSet::new() }; let events = control_events(bl); let (pairs, _) = pair_events(&events); - let me = events.iter().position(|e| e.block() == path.last() && e.off() == Some(off)); - let Some(me) = me else { return false }; - pairs.iter().any(|(o, c, _)| { - (*o == me && events.get(*c).is_some_and(|e| e.off().is_none())) || (*c == me && events.get(*o).is_some_and(|e| e.off().is_none())) - }) + let mut out = BTreeSet::new(); + for (o, c, _) in pairs { + let (Some(open), Some(close)) = (events.get(o), events.get(c)) else { continue }; + match (open.off(), close.off()) { + (Some(off), None) => { + out.insert((open.block(), off)); + } + (None, Some(off)) => { + out.insert((close.block(), off)); + } + _ => {} + } + } + out } /// Where typing at `p` should go: a caret at the start of a paragraph, before the start of @@ -674,8 +692,11 @@ impl Document { /// end, goes inside it too (the control is around the whole paragraph). pub fn typing_pos(&self, p: &Pos) -> Pos { let Some(para) = self.para_at(p) else { return p.clone() }; - let is_start = |off: usize| matches!(para.object_at(off), Some(InlineObject::ControlStart { control }) if control.block); - let is_end = |off: usize| matches!(para.object_at(off), Some(InlineObject::ControlEnd)); + // The objects once, looked up by offset (object_at per step rescans the text). + let objs: Vec<(usize, &InlineObject)> = para.objects_with_offsets().collect(); + let obj = |off: usize| objs.binary_search_by_key(&off, |(o, _)| *o).ok().and_then(|k| objs.get(k)).map(|(_, o)| *o); + let is_start = |off: usize| matches!(obj(off), Some(InlineObject::ControlStart { control }) if control.block); + let is_end = |off: usize| matches!(obj(off), Some(InlineObject::ControlEnd)); let w = OBJ.len_utf8(); let mut off = p.off; // Only block starts before the caret (or none) and block starts right after it. @@ -688,8 +709,9 @@ impl Document { } if p.off == para.len() && off >= w && is_end(off - w) { // Step back over the trailing ends that close block-level controls. - let ranges = self.control_ranges(p.story); - while off >= w && is_end(off - w) && ranges.iter().any(|r| r.end.path == p.path && r.end.off == off - w && r.control.block) { + let block_ends: BTreeSet = + self.control_ranges(p.story).into_iter().filter(|r| r.end.path == p.path && r.control.block).map(|r| r.end.off).collect(); + while off >= w && is_end(off - w) && block_ends.contains(&(off - w)) { off -= w; } return Pos { off, ..p.clone() }; diff --git a/crates/doc/src/edit.rs b/crates/doc/src/edit.rs index 9107139aff..456e2d3286 100644 --- a/crates/doc/src/edit.rs +++ b/crates/doc/src/edit.rs @@ -485,13 +485,21 @@ impl Document { let (ia, ib) = (a.path.last(), b.path.last()); // Kept markers of the paragraphs in between move to the join. let mut moved: Vec<(InlineObject, CharProps)> = Vec::new(); - for (path, off) in &kept { - if *path != a.path - && *path != b.path - && let Some(p) = self.para(a.story, path) - && let Some(o) = p.object_at(*off) - { - moved.push((o.clone(), p.props_of_char(*off).clone())); + // `kept` is sorted: one walk of each paragraph's objects and runs (#3614). + for group in kept.chunk_by(|x, y| x.0 == y.0) { + let Some((path, _)) = group.first() else { continue }; + if *path == a.path || *path == b.path { + continue; + } + let Some(p) = self.para(a.story, path) else { continue }; + let mut runs = p.run_ranges().peekable(); + for (off, o) in p.objects_with_offsets() { + if group.binary_search_by_key(&off, |(_, k)| *k).is_err() { + continue; + } + while runs.next_if(|(r, _)| r.end <= off).is_some() {} + let props = runs.peek().filter(|(r, _)| r.contains(&off)).map_or(&p.mark, |(_, c)| *c); + moved.push((o.clone(), props.clone())); } } { diff --git a/crates/doc/src/fields.rs b/crates/doc/src/fields.rs index 29b050e603..fec8900fb7 100644 --- a/crates/doc/src/fields.rs +++ b/crates/doc/src/fields.rs @@ -43,12 +43,12 @@ fn scan(story: StoryRef, bl: &Blocks, prefix: &mut Vec, depth: usize, out: match &**b { Block::Para(p) => { let path = Path(prefix.clone()); - for off in p.object_offsets() { - match p.object_at(off) { - Some(InlineObject::FieldStart { instr, locked, .. }) => { + for (off, o) in p.objects_with_offsets() { + match o { + InlineObject::FieldStart { instr, locked, .. } => { open.push((instr.clone(), *locked, Pos::new(story, path.clone(), off))); } - Some(InlineObject::FieldEnd) => match open.pop() { + InlineObject::FieldEnd => match open.pop() { Some((instr, locked, start)) => { let depth = open.len(); out.ranges.push(FieldRange { instr, locked, start, end: Pos::new(story, path.clone(), off), depth }); @@ -99,7 +99,10 @@ impl Document { pub fn past_field_ends(&self, p: &Pos) -> Pos { let Some(para) = self.para_at(p) else { return p.clone() }; let mut off = p.off; - while matches!(para.object_at(off), Some(InlineObject::FieldEnd)) { + for (at, o) in para.objects_with_offsets().skip_while(|(at, _)| *at < p.off) { + if at != off || !matches!(o, InlineObject::FieldEnd) { + break; + } off += OBJ.len_utf8(); } Pos { off, ..p.clone() } diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 4301d03792..6fa852080a 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -1147,8 +1147,8 @@ impl Document { for story in stories { for path in self.para_paths(story) { let Some(p) = self.para(story, &path) else { continue }; - for off in p.object_offsets() { - if p.object_at(off).is_some_and(|o| o.text_boxes().contains(&part)) { + for (off, o) in p.objects_with_offsets() { + if o.text_boxes().contains(&part) { return Some(Pos { story, path, off: off + para::OBJ.len_utf8() }); } } @@ -1668,22 +1668,22 @@ impl Document { let mut out: Vec = Vec::new(); let stories = std::iter::once(StoryRef::Body).chain(self.parts.keys().map(|k| StoryRef::Part(*k))); for story in stories { - let mut open: Vec = Vec::new(); + // Open ranges by id, latest last (an end closes the latest open range of its id). + let mut open: BTreeMap> = BTreeMap::new(); for path in self.para_paths(story) { let Some(p) = self.para(story, &path) else { continue }; - for off in p.object_offsets() { - match p.object_at(off) { - Some(InlineObject::PermStart { id, group, editor }) => { + for (off, o) in p.objects_with_offsets() { + match o { + InlineObject::PermStart { id, group, editor } => { let start = Pos { story, path: path.clone(), off: off + para::OBJ.len_utf8() }; - open.push(out.len()); + open.entry(*id).or_default().push(out.len()); out.push(PermRange { id: *id, group: group.clone(), editor: editor.clone(), start: start.clone(), end: start }); } - Some(InlineObject::PermEnd { id }) => { - if let Some(k) = open.iter().rposition(|i| out.get(*i).is_some_and(|r| r.id == *id)) { - let i = open.remove(k); - if let Some(r) = out.get_mut(i) { - r.end = Pos { story, path: path.clone(), off }; - } + InlineObject::PermEnd { id } => { + if let Some(i) = open.get_mut(id).and_then(Vec::pop) + && let Some(r) = out.get_mut(i) + { + r.end = Pos { story, path: path.clone(), off }; } } _ => {} @@ -1692,7 +1692,7 @@ impl Document { } // A range left open runs to the end of its story. let end = self.end_of(story); - for i in open { + for i in open.into_values().flatten() { if let Some(r) = out.get_mut(i) { r.end = end.clone(); } @@ -1706,8 +1706,8 @@ impl Document { let mut out = Vec::new(); for path in self.para_paths(StoryRef::Body) { if let Some(p) = self.para(StoryRef::Body, &path) { - for off in p.object_offsets() { - if let Some(InlineObject::BookmarkStart { name }) = p.object_at(off) { + for (off, o) in p.objects_with_offsets() { + if let InlineObject::BookmarkStart { name } = o { out.push((name.clone(), Pos { story: StoryRef::Body, path: path.clone(), off })); } } diff --git a/crates/doc/src/para.rs b/crates/doc/src/para.rs index 9add66c65a..94738db5d5 100644 --- a/crates/doc/src/para.rs +++ b/crates/doc/src/para.rs @@ -1089,6 +1089,12 @@ impl Paragraph { pub fn object_offsets(&self) -> Vec { self.text.char_indices().filter(|(_, c)| *c == OBJ).map(|(i, _)| i).collect() } + /// (byte offset, object) for every object, in text order, in one pass. Prefer it to + /// [`Paragraph::object_offsets`] + [`Paragraph::object_at`], which counts the objects before + /// each offset again, so a loop over a paragraph's objects is quadratic (#3614). + pub fn objects_with_offsets(&self) -> impl Iterator + '_ { + self.text.char_indices().filter(|(_, c)| *c == OBJ).map(|(i, _)| i).zip(self.objects.iter()) + } /// Insert plain text (U+FFFC is dropped) at `off` with `props`. Returns the inserted length. pub fn insert_text(&mut self, off: usize, s: &str, props: &CharProps) -> Result { @@ -1559,6 +1565,11 @@ mod tests { assert!(matches!(p.object_at(0), Some(InlineObject::BookmarkStart { .. }))); let off = p.object_offsets()[1]; assert!(matches!(p.object_at(off), Some(InlineObject::Field { .. }))); + // The one-pass walk agrees with offsets + object_at. + let one_pass: Vec<(usize, &InlineObject)> = p.objects_with_offsets().collect(); + let each: Vec<(usize, &InlineObject)> = p.object_offsets().into_iter().filter_map(|o| Some((o, p.object_at(o)?))).collect(); + assert_eq!(one_pass, each); + assert_eq!(one_pass.len(), 2); assert_eq!(p.plain_text(), "a1b"); p.delete(0, 3).unwrap(); assert_eq!(p.objects.len(), 1); diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs index 6097a52ed8..06833ec13b 100644 --- a/crates/doc/src/tests.rs +++ b/crates/doc/src/tests.rs @@ -561,3 +561,70 @@ fn custom_props_set_get_remove() { assert!(!d.remove_custom_prop("other")); assert_eq!(d.custom_props.len(), 1); } + +/// A paragraph that is nothing but `objs` (built directly: inserting one at a time is slow too). +fn para_of_objects(objs: Vec) -> Paragraph { + let mut p = Paragraph::new(); + p.text = para::OBJ.to_string().repeat(objs.len()); + p.runs = vec![Run { len: p.text.len(), props: CharProps::default() }]; + p.objects = objs; + p +} + +#[test] +fn object_walks_are_linear_in_the_paragraph() { + // #3614: loops of object_offsets + object_at recounted the objects before each one, so a + // paragraph of 40,000 markers took seconds per walk. + let n = 40_000u32; + let w = para::OBJ.len_utf8(); + let mut objs: Vec = Vec::new(); + for i in 0..n { + objs.push(InlineObject::BookmarkStart { name: format!("b{i}") }); + objs.push(fs("ADDIN x")); + objs.push(InlineObject::PermStart { id: i, group: "everyone".into(), editor: String::new() }); + } + // Ranges close in the order they opened (the worst case for a backwards search). + for i in 0..n { + objs.push(InlineObject::FieldEnd); + objs.push(InlineObject::PermEnd { id: i }); + } + let mut d = Document::new(); + d.body = vec![Arc::new(Block::Para(para_of_objects(objs)))]; + let t = std::time::Instant::now(); + assert_eq!(d.bookmarks().len(), n as usize); + assert_eq!(d.field_ranges(StoryRef::Body).len(), n as usize); + let perms = d.perm_ranges(); + assert_eq!(perms.len(), n as usize); + assert!(perms.iter().all(|r| r.end.off > r.start.off), "every range closed by its own end"); + assert_eq!(d.text_box_anchor(7), None); + // A caret before a long row of field ends moves past all of them. + let first_end = 3 * n as usize * w; + let p = d.past_field_ends(&Pos::body(0, first_end)); + assert_eq!(p.off, first_end + w); + assert!(t.elapsed().as_secs_f32() < 2.0, "{:?}", t.elapsed()); + + let ends: Vec = (0..n).map(|_| InlineObject::FieldEnd).collect(); + d.body = vec![Arc::new(Block::Para(para_of_objects(ends)))]; + let t = std::time::Instant::now(); + assert_eq!(d.past_field_ends(&Pos::body(0, 0)).off, n as usize * w); + // Typing at the end of a row of block-level control starts. + let block = ContentControl { block: true, ..Default::default() }; + let starts: Vec = (0..n).map(|_| InlineObject::ControlStart { control: Box::new(block.clone()) }).collect(); + d.body = vec![Arc::new(Block::Para(para_of_objects(starts)))]; + let end = Pos::body(0, n as usize * w); + assert_eq!(d.typing_pos(&end), end); + assert!(t.elapsed().as_secs_f32() < 2.0, "{:?}", t.elapsed()); +} + +#[test] +fn deleting_across_many_lone_control_markers_is_not_quadratic() { + let n = 1_500; + let starts: Vec = (0..n).map(|_| InlineObject::ControlStart { control: Box::default() }).collect(); + let mut d = Document::from_text("before\n\nafter"); + d.body[1] = Arc::new(Block::Para(para_of_objects(starts))); + let t = std::time::Instant::now(); + let at = d.delete_range(&Pos::body(0, 3), &Pos::body(2, 2)).unwrap(); + assert_eq!(at, Pos::body(0, 3)); + assert_eq!(d.plain_text(StoryRef::Body), "befter"); + assert!(t.elapsed().as_secs_f32() < 2.0, "{:?}", t.elapsed()); +} From f235208630086e35b26a7846f55bc756a35a0561 Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Thu, 22 Oct 2026 18:20:42 +0100 Subject: [PATCH 167/199] #3692: pasted text boxes share what they need not copy, within a byte budget Finding 80 (CWE-400): adopting a fragment deep-copied a box's source story once per text box showing it, including boxes inside the story being copied, so 4096 boxes (the story budget) pointing at one large story copied it 4096 times. Now: a box showing a story it is inside gets no story (a cycle); blocks without text boxes are shared with the source (copy-on-write Arcs) instead of copied; and a paste adopts at most MAX_PASTED_BOX_BYTES (32 MiB, estimated text + runs + objects) of box content, charged before copying, after which later boxes keep no story, as when the story budget runs out. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/doc/src/edit.rs | 119 +++++++++++++++++++++++++++++++++++++---- 1 file changed, 108 insertions(+), 11 deletions(-) diff --git a/crates/doc/src/edit.rs b/crates/doc/src/edit.rs index 456e2d3286..e9bc75f6a2 100644 --- a/crates/doc/src/edit.rs +++ b/crates/doc/src/edit.rs @@ -7,7 +7,7 @@ use std::sync::Arc; use serde::{Deserialize, Serialize}; -use crate::para::InlineObject; +use crate::para::{InlineObject, Run}; use crate::props::{CharProps, ParaProps}; use crate::{Block, Blocks, DocError, Document, Paragraph, Part, PartKind, Path, Pos, Result, para_block}; use crate::{Table, table::MAX_TABLE_DEPTH}; @@ -188,6 +188,33 @@ fn fit_tables(b: Block, room: usize, out: &mut Vec) { const MAX_BOX_DEPTH: usize = 8; /// Most text box stories one paste may create (hostile documents can fan out). const MAX_PASTED_BOXES: usize = 4096; +/// Most text box content (estimated bytes, see [`block_cost`]) one paste may adopt: thousands of +/// boxes can show the same large story, and each needs a story of its own. +const MAX_PASTED_BOX_BYTES: usize = 32 << 20; + +/// What a paste may still spend on text box stories. +struct BoxBudget { + stories: usize, + bytes: usize, + /// The source stories being copied, innermost last: a box inside one of them showing it + /// again is a cycle. + active: Vec, +} + +/// Rough size of a block's content, and whether it holds text boxes (and so must be copied). +fn block_cost(b: &Block) -> (usize, bool) { + let (mut cost, mut boxes) = (std::mem::size_of::(), false); + each_para(b, 0, &mut |p| { + let items = p + .runs + .len() + .saturating_mul(std::mem::size_of::()) + .saturating_add(p.objects.len().saturating_mul(std::mem::size_of::())); + cost = cost.saturating_add(p.text.len()).saturating_add(items); + boxes |= box_ids(p).next().is_some(); + }); + (cost, boxes) +} /// Call `f` on every paragraph of a block, in document order, however deep its tables nest. The /// walk keeps its own stack instead of recursing, so it is complete: a text box it doesn't reach @@ -752,7 +779,7 @@ impl Document { /// Give every text box in `blocks` a new story of its own, copied from `parts` (or, for a /// fragment without them, from this document's text box of that id). A box whose story can't /// be found keeps no story rather than showing an unrelated one. - fn adopt_text_boxes(&mut self, blocks: &mut [Block], parts: &BTreeMap, depth: usize, budget: &mut usize) { + fn adopt_text_boxes(&mut self, blocks: &mut [Block], parts: &BTreeMap, depth: usize, budget: &mut BoxBudget) { let mut any = false; for b in blocks.iter() { each_para(b, 0, &mut |p| any |= box_ids(p).next().is_some()); @@ -778,18 +805,40 @@ impl Document { } } - /// A new text box story copied from `old`; its own boxes get new stories too. - fn adopt_text_box(&mut self, old: u32, parts: &BTreeMap, depth: usize, budget: &mut usize) -> Option { - if depth >= MAX_BOX_DEPTH || *budget == 0 { + /// A new text box story copied from `old`; its own boxes get new stories too. Blocks without + /// text boxes are shared with the source (copy-on-write), the others copied. A box showing a + /// story it is inside, or past the paste's budget, gets none (CWE-400). + fn adopt_text_box(&mut self, old: u32, parts: &BTreeMap, depth: usize, budget: &mut BoxBudget) -> Option { + if depth >= MAX_BOX_DEPTH || budget.stories == 0 || budget.active.contains(&old) { return None; } let src = parts.get(&old).or_else(|| self.parts.get(&old).filter(|p| p.kind == PartKind::TextBox))?; - let mut blocks: Vec = src.blocks.iter().map(|b| (**b).clone()).collect(); + // Charge the whole story before copying any of it; once a story doesn't fit, the paste + // adopts no more (as when it runs out of stories). + let costs: Vec<(usize, bool)> = src.blocks.iter().map(|b| block_cost(b)).collect(); + let cost = costs.iter().fold(0usize, |a, (c, _)| a.saturating_add(*c)); + if cost > budget.bytes { + budget.bytes = 0; + return None; + } + budget.bytes -= cost; + budget.stories -= 1; + let src_blocks = src.blocks.clone(); // The copy keeps its text direction and alignment, not its link (a copy stands alone). let body = crate::TextBody { next: None, ..src.body }; - *budget -= 1; - self.adopt_text_boxes(&mut blocks, parts, depth + 1, budget); - let id = self.try_add_part(PartKind::TextBox, blocks.into_iter().map(Arc::new).collect()).ok()?; + budget.active.push(old); + let mut blocks: Blocks = Vec::with_capacity(src_blocks.len()); + for (b, (_, boxes)) in src_blocks.into_iter().zip(costs) { + if boxes { + let mut copy = (*b).clone(); + self.adopt_text_boxes(std::slice::from_mut(&mut copy), parts, depth + 1, budget); + blocks.push(Arc::new(copy)); + } else { + blocks.push(b); + } + } + budget.active.pop(); + let id = self.try_add_part(PartKind::TextBox, blocks).ok()?; if let Some(p) = self.parts.get_mut(&id) { p.body = body; } @@ -811,7 +860,7 @@ impl Document { return Err(DocError::BadPath(pos.path.to_string())); } // Pasted text boxes get stories of their own (never shared with the copy's source). - let mut budget = MAX_PASTED_BOXES; + let mut budget = BoxBudget { stories: MAX_PASTED_BOXES, bytes: MAX_PASTED_BOX_BYTES, active: Vec::new() }; self.adopt_text_boxes(&mut blocks, &frag.parts, 0, &mut budget); // Single paragraph: inline insert keeping runs. if blocks.len() == 1 @@ -1204,7 +1253,55 @@ mod tests { let before = d.parts.len(); d.insert_fragment(&Pos::body(0, 0), &f).unwrap(); let added = d.parts.len() - before; - assert!((1..=MAX_BOX_DEPTH).contains(&added), "{added}"); + // The copy's inner box would show the story being copied: a cycle, so it gets none. + assert_eq!(added, 1); + } + + /// A fragment whose body is one paragraph of `refs` text boxes all showing story 1. + fn fan_out(story: Vec>, refs: usize) -> Fragment { + let mut p = Paragraph::new(); + for _ in 0..refs { + p.insert_object(0, box_shape(Some(1)), &CharProps::default()).unwrap(); + } + let mut parts = BTreeMap::new(); + parts.insert(1, crate::Part { kind: PartKind::TextBox, blocks: story, body: Default::default() }); + Fragment { blocks: vec![Block::Para(p)], parts } + } + + #[test] + fn pasted_boxes_share_the_blocks_they_need_not_copy() { + let big = para_block(Paragraph::with_text(&"x".repeat(100_000), CharProps::default())); + let f = fan_out(vec![big.clone()], 3); + let mut d = doc(&["x"]); + d.insert_fragment(&Pos::body(0, 1), &f).unwrap(); + let got = boxes_in(&d, &Path::top(0)); + assert_eq!(got.len(), 3); + let ids: Vec = got.into_iter().flatten().collect(); + assert_eq!(ids.len(), 3, "each box has a story"); + assert!(ids.windows(2).all(|w| w[0] != w[1]), "stories of their own: {ids:?}"); + for id in ids { + let b = d.parts[&id].blocks.first().unwrap(); + assert!(Arc::ptr_eq(b, &big), "no text box inside: shared, not copied"); + } + } + + #[test] + fn pasted_box_copies_are_capped_in_bytes() { + // Story 1 is a 1 MiB paragraph that holds a text box itself, so each box showing it needs + // its own copy; 64 of them used to copy 64 MiB. + let mut p = Paragraph::with_text(&"x".repeat(1 << 20), CharProps::default()); + p.insert_object(0, box_shape(Some(999)), &CharProps::default()).unwrap(); + let f = fan_out(vec![para_block(p)], 64); + let mut d = doc(&["x"]); + let before = d.parts.len(); + d.insert_fragment(&Pos::body(0, 1), &f).unwrap(); + let added = d.parts.len() - before; + let copied: usize = d.parts.values().flat_map(|p| p.blocks.iter()).map(|b| if let Block::Para(p) = &**b { p.text.len() } else { 0 }).sum(); + assert!((1..64).contains(&added), "{added}"); + assert!(copied <= MAX_PASTED_BOX_BYTES, "{copied}"); + // The boxes past the cap keep no story rather than a shared one. + let got = boxes_in(&d, &Path::top(0)); + assert_eq!(got.iter().flatten().count(), added); } #[test] From 722a6ab1c01c5202131048f8cf9fd88228715e7f Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Thu, 22 Oct 2026 18:28:44 +0100 Subject: [PATCH 168/199] Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/gaps.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/gaps.md b/docs/gaps.md index 8e424edc86..42a5ffef7e 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -45,6 +45,8 @@ Exact line spacing keeps the run font baseline around tall equations; automatic Legacy Word imports (#2445): interactive Save for `.doc`/`.dot` uses the existing DOCX Save As dialog. Cancel and write failures retain the draft and original path; Save before Close continues only after a successful supported-format save. This narrows save reliability under gap #3; binary `.doc` writing remains unsupported. Late Save As answers are rejected before writing if automation has replaced the document. +Document model hangs (#3614, #3692): paragraph object walks, image deduplication, linear-equation letter runs and text box paste no longer do quadratic work or unbounded copies on hostile files. This narrows gap #3; the engine, DOCX reader and UI loops named in #3614 remain. + Active footnote and endnote text stays readable while editing (#2545). This fixes a display-context defect; the broader fidelity, accessibility and readiness gates below are unchanged. Russian language-aware sorting now keeps ё with е instead of after я, with the diaeresis as a secondary difference. Composed and decomposed ё and й compare equally. Fixed synthetic collation tests cover order, case and index headings; this does not establish full Cyrillic or CLDR collation parity. @@ -161,6 +163,7 @@ Small follow-ups from the 2026-10-25 field, table and AutoFormat batch (#4232): | 2026-10-24 | trivial | Exact-spacing table contact uses the specified line height in row selection and word skipping; large/small mark regressions in modes 11/12/14/15 (#3977); shape estimates unchanged | | 2026-10-24 | trivial | Floating tables count exact line-bottom contact; shapes keep strict overlap (#3977); original boundary/skip regressions in modes 11/12/14/15, expected Word rule from cyril265; readiness estimates unchanged | | 2026-10-22 | trivial | Gap 3: proofing robustness — linear affix rule walks, bounded dictionary reads, linear Russian hyphenation, Turkish word cap (#3613 part, #3661, #3657, #3625); readiness estimates unchanged | +| 2026-10-22 | trivial | Gap #3 note: document model quadratic scans and text box paste copies bounded (#3692, part of #3614) | | 2026-10-22 | trivial | Gap 10: group connector rerouting preserves rotated/flipped free and unresolved endpoints under nonuniform scaling, with safe degenerate-dimension and display-size-cap handling; routing around shapes and readiness estimates unchanged | | 2026-10-21 | trivial | The ranked gap list held two rows each for #19, #20 and #21; the stale copies are gone, so each gap appears once, and the #20 row carries the measured figure (#3411) | | 2026-10-21 | trivial | #20 narrowed: a document with many relationships opens in the time its part takes (#3411) | From 72cbbe096e4bde9c0333bcc62f4217f7eef9cb74 Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Fri, 23 Oct 2026 01:54:48 +0100 Subject: [PATCH 169/199] =?UTF-8?q?#3692:=20review=20=E2=80=94=20the=20lon?= =?UTF-8?q?g-letter-run=20math=20test=20asserts=20results=20only,=20no=20w?= =?UTF-8?q?all-clock=20limit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01CX1hMKX28764Lrx5moxs1o --- crates/doc/src/math.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/crates/doc/src/math.rs b/crates/doc/src/math.rs index 650f92079d..5e930df7f7 100644 --- a/crates/doc/src/math.rs +++ b/crates/doc/src/math.rs @@ -1099,13 +1099,12 @@ mod tests { #[test] fn a_long_run_of_letters_parses_in_linear_time() { // Each letter that doesn't start a function name used to rescan (and copy) the rest of - // the run, so a 20,000-letter equation did 200 million steps. - let t = std::time::Instant::now(); + // the run, so a 20,000-letter equation did 200 million steps. (No wall-clock assert: a + // loaded runner would fail it; a regression shows as a slow test.) for _ in 0..5 { let e = parse_linear(&"x".repeat(30_000)); assert!(matches!(e.as_slice(), [MNode::Run(r)] if r.text.len() == 20_000), "{:?}", e.len()); } - assert!(t.elapsed().as_secs_f32() < 1.0, "{:?}", t.elapsed()); // Function names are still found at the start of a run, and only as whole runs. assert!(matches!(parse_linear("sin x").as_slice(), [MNode::Func { .. }])); assert!(matches!(parse_linear("arcsin x").as_slice(), [MNode::Func { .. }])); From 26187afac23d2cb8c690a6a40f1cf88dabea6a76 Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Fri, 23 Oct 2026 02:44:56 +0100 Subject: [PATCH 170/199] =?UTF-8?q?#3692:=20review=20=E2=80=94=20pasted=20?= =?UTF-8?q?text=20box=20copies=20are=20charged=20for=20every=20payload=20t?= =?UTF-8?q?heir=20objects=20copy=20(group=20members,=20equations,=20kept?= =?UTF-8?q?=20XML),=20not=20only=20the=20object=20slots?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01CX1hMKX28764Lrx5moxs1o --- crates/doc/src/edit.rs | 105 ++++++++++++++++++++++++++++++++++++++++- crates/doc/src/math.rs | 18 +++++++ 2 files changed, 121 insertions(+), 2 deletions(-) diff --git a/crates/doc/src/edit.rs b/crates/doc/src/edit.rs index e9bc75f6a2..40c48daced 100644 --- a/crates/doc/src/edit.rs +++ b/crates/doc/src/edit.rs @@ -210,12 +210,78 @@ fn block_cost(b: &Block) -> (usize, bool) { .len() .saturating_mul(std::mem::size_of::()) .saturating_add(p.objects.len().saturating_mul(std::mem::size_of::())); - cost = cost.saturating_add(p.text.len()).saturating_add(items); + let objects = p.objects.iter().fold(0usize, |n, o| n.saturating_add(object_cost(o))); + cost = cost.saturating_add(p.text.len()).saturating_add(items).saturating_add(objects); boxes |= box_ids(p).next().is_some(); }); (cost, boxes) } +/// Rough heap bytes a clone of `o` copies beyond its own storage: its strings and lists, and its +/// group members however deep. What it shares through `Arc` (pictures' embedded objects, charts, +/// freeforms, wrap polygons, gradients) is not copied, so not counted. +fn object_cost(o: &InlineObject) -> usize { + let float = |f: &crate::para::Float| f.alt.text.len().saturating_add(f.alt.title.len()); + let mut cost = 0usize; + let mut todo = vec![o]; + while let Some(o) = todo.pop() { + let add = match o { + InlineObject::Image { media, alt, float: f, .. } => media.len().saturating_add(alt.len()).saturating_add(float(f)), + InlineObject::Graphic { alt, float: f, .. } => alt.len().saturating_add(float(f)), + InlineObject::Shape { float: f, extra, .. } => { + let warp = extra.warp.as_ref().map_or(0, |w| { + let adj = w.adj.iter().map(|(name, _)| std::mem::size_of::<(String, i64)>().saturating_add(name.len())); + adj.fold(w.preset.len(), usize::saturating_add) + }); + float(f).saturating_add(warp) + } + InlineObject::Group { float: f, children, .. } => { + todo.extend(children.iter().map(|c| &c.obj)); + children.len().saturating_mul(std::mem::size_of::()).saturating_add(float(f)) + } + InlineObject::Field { instr, result, form, .. } => instr.len().saturating_add(result.len()).saturating_add(form.len()), + InlineObject::FieldStart { instr, .. } => instr.len(), + InlineObject::ControlStart { control } => std::mem::size_of::().saturating_add(serialized_len(control)), + InlineObject::NoteRef { custom, .. } => custom.len(), + InlineObject::BookmarkStart { name } | InlineObject::BookmarkEnd { name } => name.len(), + InlineObject::PermStart { group, editor, .. } => group.len().saturating_add(editor.len()), + InlineObject::Equation { linear, math, .. } => linear.len().saturating_add(math.heap_bytes()), + InlineObject::Ruby { base, ruby, lang, props, .. } => { + [base.len(), ruby.len(), lang.len(), std::mem::size_of::(), serialized_len(props)] + .into_iter() + .fold(0, usize::saturating_add) + } + InlineObject::Opaque { format, xml, text } => format.len().saturating_add(xml.len()).saturating_add(text.len()), + InlineObject::FieldEnd + | InlineObject::ControlEnd + | InlineObject::CommentStart { .. } + | InlineObject::CommentEnd { .. } + | InlineObject::PermEnd { .. } + | InlineObject::PositionalTab { .. } => 0, + }; + cost = cost.saturating_add(add); + } + cost +} + +/// Length of `v` written as JSON: at least the bytes of every string and list it owns. For the +/// nested formatting and control types, which share nothing through `Arc`. +fn serialized_len(v: &T) -> usize { + struct Count(usize); + impl std::io::Write for Count { + fn write(&mut self, b: &[u8]) -> std::io::Result { + self.0 = self.0.saturating_add(b.len()); + Ok(b.len()) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + let mut n = Count(0); + let _ = serde_json::to_writer(&mut n, v); + n.0 +} + /// Call `f` on every paragraph of a block, in document order, however deep its tables nest. The /// walk keeps its own stack instead of recursing, so it is complete: a text box it doesn't reach /// is shown nowhere, which pruning relies on (#1339). `_depth` is unused (callers keep passing it). @@ -809,7 +875,8 @@ impl Document { /// text boxes are shared with the source (copy-on-write), the others copied. A box showing a /// story it is inside, or past the paste's budget, gets none (CWE-400). fn adopt_text_box(&mut self, old: u32, parts: &BTreeMap, depth: usize, budget: &mut BoxBudget) -> Option { - if depth >= MAX_BOX_DEPTH || budget.stories == 0 || budget.active.contains(&old) { + // A spent byte budget stops before costing the story again (that walks its payloads). + if depth >= MAX_BOX_DEPTH || budget.stories == 0 || budget.bytes == 0 || budget.active.contains(&old) { return None; } let src = parts.get(&old).or_else(|| self.parts.get(&old).filter(|p| p.kind == PartKind::TextBox))?; @@ -1304,6 +1371,40 @@ mod tests { assert_eq!(got.iter().flatten().count(), added); } + #[test] + fn pasted_box_copies_count_the_objects_they_copy() { + // #3614 review: a story holding a text box and a 1 MiB kept-for-round-trip object costs + // the object's XML too, not only its slot: 64 copies used to copy 64 MiB of it. + let mut p = Paragraph::with_text("x", CharProps::default()); + p.insert_object(0, box_shape(Some(999)), &CharProps::default()).unwrap(); + let opaque = InlineObject::Opaque { format: "docx".into(), xml: "x".repeat(1 << 20), text: String::new() }; + p.insert_object(0, opaque, &CharProps::default()).unwrap(); + let f = fan_out(vec![para_block(p)], 64); + let mut d = doc(&["x"]); + let before = d.parts.len(); + d.insert_fragment(&Pos::body(0, 1), &f).unwrap(); + let added = d.parts.len() - before; + let xml = |o: &InlineObject| if let InlineObject::Opaque { xml, .. } = o { xml.len() } else { 0 }; + let copied: usize = + d.parts.values().flat_map(|p| p.blocks.iter()).map(|b| if let Block::Para(p) = &**b { p.objects.iter().map(xml).sum() } else { 0 }).sum(); + assert!((1..64).contains(&added), "{added}"); + assert!(copied <= MAX_PASTED_BOX_BYTES, "{copied}"); + // Nested group members and equations count too. + let member = |obj| crate::para::GroupChild { x: 0.0, y: 0.0, obj }; + let deep = InlineObject::Opaque { format: String::new(), xml: "y".repeat(5000), text: String::new() }; + let inner = + InlineObject::Group { w: 1.0, h: 1.0, float: Default::default(), ch_w: 1.0, ch_h: 1.0, children: vec![member(deep)], canvas: None }; + let outer = + InlineObject::Group { w: 1.0, h: 1.0, float: Default::default(), ch_w: 1.0, ch_h: 1.0, children: vec![member(inner)], canvas: None }; + assert!(object_cost(&outer) >= 5000); + let eq = InlineObject::Equation { + linear: String::new(), + display: false, + math: crate::math::Math { nodes: crate::math::parse_linear(&"z".repeat(5000)), ..Default::default() }, + }; + assert!(object_cost(&eq) >= 5000); + } + #[test] fn tables_in_paths() { let mut d = doc(&["before", "after"]); diff --git a/crates/doc/src/math.rs b/crates/doc/src/math.rs index 5e930df7f7..f4ee463810 100644 --- a/crates/doc/src/math.rs +++ b/crates/doc/src/math.rs @@ -41,6 +41,24 @@ impl Math { pub fn is_empty(&self) -> bool { self.nodes.is_empty() } + + /// Rough heap bytes a clone copies: every node, its argument lists and its run text. + /// Iterative, so it costs at most the size of the structure. + pub(crate) fn heap_bytes(&self) -> usize { + let mut n = self.omml.len(); + let mut todo: Vec<&MNode> = self.nodes.iter().collect(); + while let Some(node) = todo.pop() { + n = n.saturating_add(std::mem::size_of::()); + if let MNode::Run(r) = node { + n = n.saturating_add(r.text.len()).saturating_add(r.font.as_ref().map_or(0, String::len)); + } + each_arg(node, &mut |a| { + n = n.saturating_add(std::mem::size_of::()); + todo.extend(a); + }); + } + n + } } /// Display equation justification (`m:oMathParaPr/m:jc`). From bec112a4b8b2a0f265b15d53ef347032f407599c Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Fri, 23 Oct 2026 11:19:57 +0100 Subject: [PATCH 171/199] =?UTF-8?q?#3692:=20review=20=E2=80=94=20pasted=20?= =?UTF-8?q?text=20boxes=20skip=20only=20the=20box=20over=20the=20budget,?= =?UTF-8?q?=20and=20say=20so;=20media=5Findex=20private;=20kept=20markers'?= =?UTF-8?q?=20order=20pinned?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - A text box whose story doesn't fit what is left of the paste's byte budget gets no story, but later boxes with smaller stories are still copied while the budget lasts (it used to zero the budget). Each source story is costed once per paste, so skipping a big story many times doesn't walk it again. The paste logs one warning with the number of boxes left empty. wordcraft-doc now depends on log. - Document::media_index is a private field and MediaIndex a private type: a lookup cache, not API. The one struct-update literal outside the crate (a formats test) builds the document field by field. - kept_control_markers documents that delete_range relies on its sorted, deduplicated output; delete_range sorts it if it ever isn't, and control_markers_kept_are_sorted pins it. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01CX1hMKX28764Lrx5moxs1o --- Cargo.lock | 1 + crates/doc/Cargo.toml | 1 + crates/doc/src/control.rs | 2 + crates/doc/src/edit.rs | 87 ++++++++++++++++++++++++++++------ crates/doc/src/lib.rs | 6 ++- crates/doc/src/test_log.rs | 36 ++++++++++++++ crates/doc/src/tests.rs | 20 ++++++++ crates/formats/src/markdown.rs | 3 +- 8 files changed, 138 insertions(+), 18 deletions(-) create mode 100644 crates/doc/src/test_log.rs diff --git a/Cargo.lock b/Cargo.lock index cfcbb47b57..5f19b3527a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6671,6 +6671,7 @@ name = "wordcraft-doc" version = "0.11.0" dependencies = [ "base64 0.23.1", + "log", "proptest", "serde", "serde_json", diff --git a/crates/doc/Cargo.toml b/crates/doc/Cargo.toml index 5a6d6622e4..aff28f8d91 100644 --- a/crates/doc/Cargo.toml +++ b/crates/doc/Cargo.toml @@ -16,6 +16,7 @@ test-support = [] [dependencies] wordcraft-geom = { workspace = true } base64 = { workspace = true } +log = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } diff --git a/crates/doc/src/control.rs b/crates/doc/src/control.rs index 1396ae39d9..31ff6814af 100644 --- a/crates/doc/src/control.rs +++ b/crates/doc/src/control.rs @@ -660,6 +660,8 @@ impl Document { } } } + // Sorted and without repeats: `delete_range` groups this by paragraph and binary-searches + // each group (`control_markers_kept_are_sorted` pins it). out.sort(); out.dedup(); out diff --git a/crates/doc/src/edit.rs b/crates/doc/src/edit.rs index 40c48daced..1c5b877666 100644 --- a/crates/doc/src/edit.rs +++ b/crates/doc/src/edit.rs @@ -199,6 +199,17 @@ struct BoxBudget { /// The source stories being copied, innermost last: a box inside one of them showing it /// again is a cycle. active: Vec, + /// Each source story's cost ([`block_cost`]), worked out once per paste: a story too big for + /// what is left is not walked again for every box that shows it. + costs: BTreeMap, + /// Boxes left without a story because the paste's budget couldn't hold theirs. + skipped: usize, +} + +impl BoxBudget { + fn new() -> BoxBudget { + BoxBudget { stories: MAX_PASTED_BOXES, bytes: MAX_PASTED_BOX_BYTES, active: Vec::new(), costs: BTreeMap::new(), skipped: 0 } + } } /// Rough size of a block's content, and whether it holds text boxes (and so must be copied). @@ -567,7 +578,13 @@ impl Document { } // Content control markers whose partner is outside the range stay (the control is // emptied, not broken). - let kept = self.kept_control_markers(&a, &b); + let mut kept = self.kept_control_markers(&a, &b); + // The walk below groups `kept` by paragraph (`chunk_by`) and looks offsets up + // (`binary_search`), so it must be sorted; `kept_control_markers` returns it so, and this + // keeps a change there from quietly losing markers. + if !kept.is_sorted() { + kept.sort(); + } let kept_in = |path: &Path| -> Vec { kept.iter().filter(|(p, _)| p == path).map(|(_, o)| *o).collect() }; if a.path == b.path { self.para_mut(a.story, &a.path)?.delete_except(a.off, b.off, &kept_in(&a.path))?; @@ -873,29 +890,34 @@ impl Document { /// A new text box story copied from `old`; its own boxes get new stories too. Blocks without /// text boxes are shared with the source (copy-on-write), the others copied. A box showing a - /// story it is inside, or past the paste's budget, gets none (CWE-400). + /// story it is inside, or whose story the paste's budget can't hold, gets none (CWE-400); + /// only that box: a later box with a smaller story is still copied while the budget lasts. fn adopt_text_box(&mut self, old: u32, parts: &BTreeMap, depth: usize, budget: &mut BoxBudget) -> Option { - // A spent byte budget stops before costing the story again (that walks its payloads). - if depth >= MAX_BOX_DEPTH || budget.stories == 0 || budget.bytes == 0 || budget.active.contains(&old) { + if depth >= MAX_BOX_DEPTH || budget.active.contains(&old) { + return None; + } + if budget.stories == 0 { + budget.skipped = budget.skipped.saturating_add(1); return None; } let src = parts.get(&old).or_else(|| self.parts.get(&old).filter(|p| p.kind == PartKind::TextBox))?; - // Charge the whole story before copying any of it; once a story doesn't fit, the paste - // adopts no more (as when it runs out of stories). - let costs: Vec<(usize, bool)> = src.blocks.iter().map(|b| block_cost(b)).collect(); - let cost = costs.iter().fold(0usize, |a, (c, _)| a.saturating_add(*c)); - if cost > budget.bytes { - budget.bytes = 0; + // Charge the whole story before copying any of it. Its cost is worked out once per paste + // (that walks its payloads), however many boxes show it. + let cost = *budget.costs.entry(old).or_insert_with(|| src.blocks.iter().fold(0usize, |a, b| a.saturating_add(block_cost(b).0))); + let Some(left) = budget.bytes.checked_sub(cost) else { + budget.skipped = budget.skipped.saturating_add(1); return None; - } - budget.bytes -= cost; + }; + budget.bytes = left; budget.stories -= 1; let src_blocks = src.blocks.clone(); // The copy keeps its text direction and alignment, not its link (a copy stands alone). let body = crate::TextBody { next: None, ..src.body }; budget.active.push(old); let mut blocks: Blocks = Vec::with_capacity(src_blocks.len()); - for (b, (_, boxes)) in src_blocks.into_iter().zip(costs) { + for b in src_blocks { + let mut boxes = false; + each_para(&b, 0, &mut |p| boxes |= box_ids(p).next().is_some()); if boxes { let mut copy = (*b).clone(); self.adopt_text_boxes(std::slice::from_mut(&mut copy), parts, depth + 1, budget); @@ -927,8 +949,15 @@ impl Document { return Err(DocError::BadPath(pos.path.to_string())); } // Pasted text boxes get stories of their own (never shared with the copy's source). - let mut budget = BoxBudget { stories: MAX_PASTED_BOXES, bytes: MAX_PASTED_BOX_BYTES, active: Vec::new() }; + let mut budget = BoxBudget::new(); self.adopt_text_boxes(&mut blocks, &frag.parts, 0, &mut budget); + if budget.skipped > 0 { + // Once per paste, not once per box (review of #3874). + log::warn!( + "paste: {} text boxes are left empty; their stories are past what one paste copies ({MAX_PASTED_BOXES} stories, {MAX_PASTED_BOX_BYTES} bytes)", + budget.skipped + ); + } // Single paragraph: inline insert keeping runs. if blocks.len() == 1 && let Some(Block::Para(src)) = blocks.first() @@ -1352,6 +1381,30 @@ mod tests { } } + #[test] + fn a_box_over_the_budget_leaves_later_smaller_boxes_their_stories() { + // Review of #3874: once one story didn't fit, every later box in the paste was left + // empty, however small its story. Only the box that doesn't fit is. + let big = para_block(Paragraph::with_text(&"x".repeat(100_000), CharProps::default())); + let small = para_block(Paragraph::with_text("small", CharProps::default())); + let mut p = Paragraph::new(); + for story in [2, 1, 2, 1] { + p.insert_object(0, box_shape(Some(story)), &CharProps::default()).unwrap(); + } + let mut parts = BTreeMap::new(); + parts.insert(1, crate::Part { kind: PartKind::TextBox, blocks: vec![big], body: Default::default() }); + parts.insert(2, crate::Part { kind: PartKind::TextBox, blocks: vec![small], body: Default::default() }); + let mut blocks = vec![Block::Para(p)]; + let mut d = doc(&["x"]); + let mut budget = BoxBudget { bytes: 50_000, ..BoxBudget::new() }; + d.adopt_text_boxes(&mut blocks, &parts, 0, &mut budget); + let Some(Block::Para(p)) = blocks.first() else { panic!() }; + let got: Vec> = p.objects.iter().map(|o| if let InlineObject::Shape { story, .. } = o { *story } else { None }).collect(); + assert_eq!(got.iter().map(Option::is_some).collect::>(), [false, true, false, true], "{got:?}"); + assert_eq!(budget.skipped, 2); + assert_eq!(budget.costs.len(), 2, "each story costed once"); + } + #[test] fn pasted_box_copies_are_capped_in_bytes() { // Story 1 is a 1 MiB paragraph that holds a text box itself, so each box showing it needs @@ -1361,8 +1414,12 @@ mod tests { let f = fan_out(vec![para_block(p)], 64); let mut d = doc(&["x"]); let before = d.parts.len(); - d.insert_fragment(&Pos::body(0, 1), &f).unwrap(); + let (_, warned) = crate::test_log::warnings(|| d.insert_fragment(&Pos::body(0, 1), &f).unwrap()); let added = d.parts.len() - before; + // The boxes left empty are said to be, once for the paste (review of #3874). + let empty: Vec<_> = warned.iter().filter(|w| w.contains("text boxes are left empty")).collect(); + assert_eq!(empty.len(), 1, "{warned:?}"); + assert!(empty[0].starts_with(&format!("paste: {} text boxes", 64 - added)), "{empty:?}"); let copied: usize = d.parts.values().flat_map(|p| p.blocks.iter()).map(|b| if let Block::Para(p) = &**b { p.text.len() } else { 0 }).sum(); assert!((1..64).contains(&added), "{added}"); assert!(copied <= MAX_PASTED_BOX_BYTES, "{copied}"); diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 6fa852080a..ada156750e 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -862,14 +862,14 @@ pub struct Document { /// Lookup cache for [`Document::add_media`]; not part of the document (never saved, ignored /// by `==`). #[serde(skip)] - pub media_index: MediaIndex, + media_index: MediaIndex, } /// [`Document::media`] by content, so adding media is linear in the bytes added rather than a /// scan of every earlier image (CWE-407). Rebuilt whenever `media` changed size behind its back; /// a hit is always checked against `media`, so a stale entry can at worst miss a duplicate. #[derive(Clone, Default)] -pub struct MediaIndex { +struct MediaIndex { by_bytes: std::collections::HashMap>, String>, /// `media.len()` when the index last matched it. len: usize, @@ -1889,5 +1889,7 @@ fn story_word_count_of(bl: &Blocks, numbering: &Numbering, styles: &styles::Styl total } +#[cfg(test)] +mod test_log; #[cfg(test)] mod tests; diff --git a/crates/doc/src/test_log.rs b/crates/doc/src/test_log.rs new file mode 100644 index 0000000000..745ce21fe1 --- /dev/null +++ b/crates/doc/src/test_log.rs @@ -0,0 +1,36 @@ +//! Test helper: the warnings this crate logs, captured per test thread, so a test can check that +//! a limit which leaves something out says so. + +use std::cell::RefCell; + +struct Capture; + +thread_local! { + static LINES: RefCell> = const { RefCell::new(Vec::new()) }; +} + +impl log::Log for Capture { + fn enabled(&self, m: &log::Metadata) -> bool { + m.level() <= log::Level::Warn + } + + fn log(&self, r: &log::Record) { + if self.enabled(r.metadata()) { + LINES.with(|l| l.borrow_mut().push(r.args().to_string())); + } + } + + fn flush(&self) {} +} + +static CAPTURE: Capture = Capture; + +/// `f`'s result and the warnings logged on this thread while it ran. +pub(crate) fn warnings(f: impl FnOnce() -> T) -> (T, Vec) { + // Only this logger is ever installed in a test binary; a second call keeps it. + let _ = log::set_logger(&CAPTURE); + log::set_max_level(log::LevelFilter::Warn); + LINES.with(|l| l.borrow_mut().clear()); + let out = f(); + (out, LINES.with(|l| std::mem::take(&mut *l.borrow_mut()))) +} diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs index 06833ec13b..16ba0a2675 100644 --- a/crates/doc/src/tests.rs +++ b/crates/doc/src/tests.rs @@ -628,3 +628,23 @@ fn deleting_across_many_lone_control_markers_is_not_quadratic() { assert_eq!(d.plain_text(StoryRef::Body), "befter"); assert!(t.elapsed().as_secs_f32() < 2.0, "{:?}", t.elapsed()); } + +#[test] +fn control_markers_kept_are_sorted() { + // `delete_range` groups the kept markers by paragraph and binary-searches each group, so + // `kept_control_markers` must hand them over sorted and without repeats (review of #3874). + let start = || InlineObject::ControlStart { control: Box::default() }; + let mut d = Document::from_text("before"); + // Controls open and close across paragraphs: 1 closes the one 0 opens, 3 the one 2 opens… + for i in 0..12 { + let objs = if i % 2 == 0 { vec![start(), start()] } else { vec![InlineObject::ControlEnd, InlineObject::ControlEnd] }; + d.body.push(Arc::new(Block::Para(para_of_objects(objs)))); + } + d.body.push(Arc::new(Block::Para(Paragraph::with_text("after", CharProps::default())))); + let w = para::OBJ.len_utf8(); + for (a, b) in [(Pos::body(2, w), Pos::body(11, w)), (Pos::body(1, w), Pos::body(12, w)), (Pos::body(3, w), Pos::body(8, 0))] { + let kept = d.kept_control_markers(&a, &b); + assert!(!kept.is_empty(), "{a:?}..{b:?}"); + assert!(kept.windows(2).all(|p| p[0] < p[1]), "sorted, no repeats: {kept:?}"); + } +} diff --git a/crates/formats/src/markdown.rs b/crates/formats/src/markdown.rs index e483cc928d..a74a5556c3 100644 --- a/crates/formats/src/markdown.rs +++ b/crates/formats/src/markdown.rs @@ -2067,7 +2067,8 @@ mod tests { use wordcraft_doc::{CharProps, Document, Paragraph, StoryRef, para_block}; let cases = ["Shopping\n- milk\n# not a heading", "a\n1. x", "a\n2) x", "a\n+ x", "a\n---", "a\n***", "a\n==", "a\n--", "a\n> q"]; for s in cases { - let d = Document { body: vec![para_block(Paragraph::with_text(s, CharProps::default()))], ..Default::default() }; + let mut d = Document::new(); + d.body = vec![para_block(Paragraph::with_text(s, CharProps::default()))]; let md = export(&d); let back = import(&md); assert_eq!(back.body.len(), 1, "{s:?} -> {md:?}"); From def2b9bd3123bb68491010f005c1d6a5a039814d Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Fri, 23 Oct 2026 14:26:27 +0100 Subject: [PATCH 172/199] =?UTF-8?q?#3692:=20review=20=E2=80=94=20doc=20tim?= =?UTF-8?q?ing=20tests=20get=20a=20margin=20a=20busy=20CI=20machine=20can'?= =?UTF-8?q?t=20break?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wall-clock bounds of 2 s failed on loaded runners. Each bound is now 10 s, at least 10x the fixed code's time and well under the old code's on the same input (measured by running the pre-fix code under the same load): - many distinct media: 0.5 s now; the old scan of every image took 131 s. - object walks: under 0.3 s now; the old walks took 360 s (#3614). - delete across lone control markers: 3,000 markers (was 1,500); 1 ms now, the old delete took 18 s at 1,500 and 114 s at 3,000. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01CX1hMKX28764Lrx5moxs1o --- crates/doc/src/tests.rs | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs index 16ba0a2675..67256fd78c 100644 --- a/crates/doc/src/tests.rs +++ b/crates/doc/src/tests.rs @@ -109,7 +109,8 @@ fn many_distinct_media_add_in_linear_time() { assert_eq!(d.add_media(first, "png"), "image1.png"); d.media.insert("direct.png".into(), Arc::new(vec![9, 9])); assert_eq!(d.add_media(vec![9, 9], "png"), "direct.png"); - assert!(t.elapsed().as_secs_f32() < 2.0, "{:?}", t.elapsed()); + // About half a second on a loaded machine; the old scan took over two minutes. + assert!(t.elapsed().as_secs_f32() < 10.0, "{:?}", t.elapsed()); // The lookup cache isn't part of the document. let mut e = d.clone(); e.media_index = MediaIndex::default(); @@ -601,7 +602,8 @@ fn object_walks_are_linear_in_the_paragraph() { let first_end = 3 * n as usize * w; let p = d.past_field_ends(&Pos::body(0, first_end)); assert_eq!(p.off, first_end + w); - assert!(t.elapsed().as_secs_f32() < 2.0, "{:?}", t.elapsed()); + // Under 0.3 s on a loaded machine; the old walks took minutes (360 s for the first test). + assert!(t.elapsed().as_secs_f32() < 10.0, "{:?}", t.elapsed()); let ends: Vec = (0..n).map(|_| InlineObject::FieldEnd).collect(); d.body = vec![Arc::new(Block::Para(para_of_objects(ends)))]; @@ -613,12 +615,13 @@ fn object_walks_are_linear_in_the_paragraph() { d.body = vec![Arc::new(Block::Para(para_of_objects(starts)))]; let end = Pos::body(0, n as usize * w); assert_eq!(d.typing_pos(&end), end); - assert!(t.elapsed().as_secs_f32() < 2.0, "{:?}", t.elapsed()); + assert!(t.elapsed().as_secs_f32() < 10.0, "{:?}", t.elapsed()); } #[test] fn deleting_across_many_lone_control_markers_is_not_quadratic() { - let n = 1_500; + // A millisecond now; the old delete took 18 s at 1,500 markers and 114 s at 3,000. + let n = 3_000; let starts: Vec = (0..n).map(|_| InlineObject::ControlStart { control: Box::default() }).collect(); let mut d = Document::from_text("before\n\nafter"); d.body[1] = Arc::new(Block::Para(para_of_objects(starts))); @@ -626,7 +629,7 @@ fn deleting_across_many_lone_control_markers_is_not_quadratic() { let at = d.delete_range(&Pos::body(0, 3), &Pos::body(2, 2)).unwrap(); assert_eq!(at, Pos::body(0, 3)); assert_eq!(d.plain_text(StoryRef::Body), "befter"); - assert!(t.elapsed().as_secs_f32() < 2.0, "{:?}", t.elapsed()); + assert!(t.elapsed().as_secs_f32() < 10.0, "{:?}", t.elapsed()); } #[test] From ee7e98eafbe5ab2a48589bdf7cfad84cffc512a3 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 25 Oct 2026 21:22:09 -0400 Subject: [PATCH 173/199] hotfix: same_content must mention media_index (E0027 after #3872) --- crates/doc/src/lib.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index ada156750e..6090ecacd0 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -1007,6 +1007,7 @@ impl Document { media, passthrough, glossary, + media_index: _, } = self; let Document { body: other_body, @@ -1025,6 +1026,7 @@ impl Document { media: other_media, passthrough: other_passthrough, glossary: other_glossary, + media_index: _, } = other; same_blocks(body, other_body) && same_parts(parts, other_parts) From 4bb3679e4139aea66d3c25de52d06143fbbb52c0 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 25 Oct 2026 21:28:59 -0400 Subject: [PATCH 174/199] Sonar: use PoisonError::into_inner, explicit test imports, [[ in import-cert - proof: pass PoisonError::into_inner instead of an identity closure (S1612). - layout tests: replace glob super::* imports with explicit names (S2208). - packaging/macos/import-cert.sh: use [[ for the status test (S7688). --- crates/layout/src/tests_linear.rs | 5 ++++- crates/layout/src/tests_nbsp.rs | 2 +- crates/layout/src/tests_table_contact.rs | 2 +- crates/proof/src/es/mod.rs | 10 +++++----- packaging/macos/import-cert.sh | 2 +- 5 files changed, 12 insertions(+), 9 deletions(-) diff --git a/crates/layout/src/tests_linear.rs b/crates/layout/src/tests_linear.rs index e49409693c..a90036b4e0 100644 --- a/crates/layout/src/tests_linear.rs +++ b/crates/layout/src/tests_linear.rs @@ -6,7 +6,10 @@ //! whose walk is the document model's, is timed. use super::tests::{SCALE, cost_ratio}; -use super::*; +use super::{ + Block, CharProps, DocLayout, Document, HeaderSet, HeightRule, InlineObject, LayoutCache, LayoutOptions, ParaLayout, Paragraph, Path, Placed, + SectionProps, SectionStart, StoryRef, display, layout, para, work, +}; use wordcraft_doc::para::Run; use wordcraft_doc::{Pos, Table}; diff --git a/crates/layout/src/tests_nbsp.rs b/crates/layout/src/tests_nbsp.rs index b8dbc9aad7..f9ee301b2d 100644 --- a/crates/layout/src/tests_nbsp.rs +++ b/crates/layout/src/tests_nbsp.rs @@ -1,7 +1,7 @@ //! A no-break space is laid out as wide as an ordinary space, whatever the font's own U+00A0 //! glyph measures (Lucida Bright draws it twice as wide). -use super::*; +use super::{Document, LayoutCache, LayoutOptions, Placed, layout}; /// Inter with its U+00A0 glyph twice as wide as its space, under the family name "Wnbsp". /// Built from the bundled font so the test needs no installed font. diff --git a/crates/layout/src/tests_table_contact.rs b/crates/layout/src/tests_table_contact.rs index 8f3800c961..65a86f0f5f 100644 --- a/crates/layout/src/tests_table_contact.rs +++ b/crates/layout/src/tests_table_contact.rs @@ -1,7 +1,7 @@ //! #3977: floating tables include a line's bottom edge; shapes keep a strict boundary. //! The expected distinction is cyril265's Word for Mac 16.114.1 observation in the issue. -use super::*; +use super::{Arc, Block, Document, LayoutCache, LayoutOptions, Placed, StoryRef, layout}; use wordcraft_doc::para::{Anchor, Float, InlineObject, ShapeKind, Wrap}; use wordcraft_doc::props::{HeightRule, LineSpacing, ParaProps, TableFloat}; use wordcraft_doc::{CharProps, Paragraph, Pos, Table}; diff --git a/crates/proof/src/es/mod.rs b/crates/proof/src/es/mod.rs index 94c6511abd..351a69b04c 100644 --- a/crates/proof/src/es/mod.rs +++ b/crates/proof/src/es/mod.rs @@ -16,7 +16,7 @@ mod suggest; pub(crate) mod test_dict; pub mod voseo; -use std::sync::{Arc, RwLock}; +use std::sync::{Arc, PoisonError, RwLock}; use crate::ProofLang; use crate::affix::AffixDict; @@ -62,11 +62,11 @@ pub fn dictionary_name(lang: ProofLang) -> &'static str { /// see [`has_dictionary`]). pub fn dictionary(lang: ProofLang) -> Arc { let Some(slot) = DICTS.get(variety(lang).0) else { return Arc::new(AffixDict::default()) }; - if let Some(d) = slot.read().unwrap_or_else(|e| e.into_inner()).as_ref() { + if let Some(d) = slot.read().unwrap_or_else(PoisonError::into_inner).as_ref() { return d.clone(); } let loaded = Arc::new(load_installed(lang)); - let mut slot = slot.write().unwrap_or_else(|e| e.into_inner()); + let mut slot = slot.write().unwrap_or_else(PoisonError::into_inner); // Another thread (or an install) may have filled it meanwhile: that one wins. slot.get_or_insert(loaded).clone() } @@ -90,7 +90,7 @@ fn load_installed(lang: ProofLang) -> AffixDict { pub fn install_dictionary(lang: ProofLang, aff: &str, dic: &str) -> Result<(), String> { let d = AffixDict::from_hunspell(aff, dic, &[])?; if let Some(slot) = DICTS.get(variety(lang).0) { - *slot.write().unwrap_or_else(|e| e.into_inner()) = Some(Arc::new(d)); + *slot.write().unwrap_or_else(PoisonError::into_inner) = Some(Arc::new(d)); } Ok(()) } @@ -99,7 +99,7 @@ pub fn install_dictionary(lang: ProofLang, aff: &str, dic: &str) -> Result<(), S /// (after [`crate::hunspell::set_dictionary_dir`] or a dictionary was installed). pub fn reload_dictionary() { for slot in &DICTS { - *slot.write().unwrap_or_else(|e| e.into_inner()) = None; + *slot.write().unwrap_or_else(PoisonError::into_inner) = None; } } diff --git a/packaging/macos/import-cert.sh b/packaging/macos/import-cert.sh index a4e79ea8b4..341d012f62 100755 --- a/packaging/macos/import-cert.sh +++ b/packaging/macos/import-cert.sh @@ -30,7 +30,7 @@ KEYCHAIN="$WORK/wordcraft-signing.keychain-db" cleanup() { local status=$? rm -f "$CERT" - if [ "$status" -ne 0 ]; then + if [[ "$status" -ne 0 ]]; then security delete-keychain "$KEYCHAIN" 2>/dev/null || true rm -rf "$WORK" fi From 4fd9075e99ca700073d73bf8ee2a476bba372c84 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 25 Oct 2026 21:30:49 -0400 Subject: [PATCH 175/199] Sonar: split flood fill, CSV separator, noise collapse and numbered backspace Extract helpers with unchanged behaviour to bring these functions under the cognitive-complexity limit (S3776): - engine cmd/objects.rs clear_background: border_pixels, run_span, push_open_runs. - engine cmd/tools.rs collapse_noise_matches: flatten_region (Kind is now module-level). - engine cmd/mailings.rs parse_csv: csv_separator, push_csv_row. - engine cmd/text.rs backspace_inner: remove_number. --- crates/engine/src/cmd/mailings.rs | 28 +++++++----- crates/engine/src/cmd/objects.rs | 71 +++++++++++++++++++------------ crates/engine/src/cmd/text.rs | 36 +++++++++------- crates/engine/src/cmd/tools.rs | 27 +++++++----- 4 files changed, 98 insertions(+), 64 deletions(-) diff --git a/crates/engine/src/cmd/mailings.rs b/crates/engine/src/cmd/mailings.rs index 488b9bc4a1..8007456366 100644 --- a/crates/engine/src/cmd/mailings.rs +++ b/crates/engine/src/cmd/mailings.rs @@ -522,7 +522,8 @@ fn insert_field(s: &mut Session, instr: &str, placeholder: &str) -> CmdResult { } /// Parse CSV (RFC 4180-ish: quotes, doubled quotes, commas/semicolons/tabs). -pub fn parse_csv(text: &str) -> Vec> { +/// The field separator: whichever of `,` `;` and tab the first line (outside quotes) uses most. +fn csv_separator(text: &str) -> char { let first = text.lines().next().unwrap_or(""); let mut quoted = false; let mut unquoted = String::new(); @@ -533,7 +534,20 @@ pub fn parse_csv(text: &str) -> Vec> { unquoted.push(c); } } - let sep = [',', ';', '\t'].into_iter().max_by_key(|c| unquoted.matches(*c).count()).unwrap_or(','); + [',', ';', '\t'].into_iter().max_by_key(|c| unquoted.matches(*c).count()).unwrap_or(',') +} + +/// Ends the current row: kept when it has any non-empty field, otherwise dropped. +fn push_csv_row(rows: &mut Vec>, row: &mut Vec) { + if row.iter().any(|x| !x.is_empty()) { + rows.push(std::mem::take(row)); + } else { + row.clear(); + } +} + +pub fn parse_csv(text: &str) -> Vec> { + let sep = csv_separator(text); let mut rows = Vec::new(); let mut row = Vec::new(); let mut cell = String::new(); @@ -560,11 +574,7 @@ pub fn parse_csv(text: &str) -> Vec> { chars.next(); } row.push(std::mem::take(&mut cell)); - if row.iter().any(|x| !x.is_empty()) { - rows.push(std::mem::take(&mut row)); - } else { - row.clear(); - } + push_csv_row(&mut rows, &mut row); } else { cell.push(c); } @@ -573,9 +583,7 @@ pub fn parse_csv(text: &str) -> Vec> { } } row.push(cell); - if row.iter().any(|x| !x.is_empty()) { - rows.push(row); - } + push_csv_row(&mut rows, &mut row); rows } diff --git a/crates/engine/src/cmd/objects.rs b/crates/engine/src/cmd/objects.rs index b99c302522..7528319318 100644 --- a/crates/engine/src/cmd/objects.rs +++ b/crates/engine/src/cmd/objects.rs @@ -1396,27 +1396,13 @@ fn clear_background(rgba: &mut image::RgbaImage, tol: f32) -> usize { !seen.get(at(x, y)).copied().unwrap_or(true) && rgba.get_pixel_checked(x, y).is_some_and(|p| close(p.0)) }; let mut seen = vec![false; (w as usize) * (h as usize)]; - let mut stack: Vec<(u32, u32)> = Vec::new(); - for x in 0..w { - stack.push((x, 0)); - stack.push((x, h - 1)); - } - for y in 0..h { - stack.push((0, y)); - stack.push((w - 1, y)); - } + let mut stack = border_pixels(w, h); let mut most = stack.len(); while let Some((x, y)) = stack.pop() { if !open(rgba, &seen, x, y) { continue; } - let (mut x0, mut x1) = (x, x); - while x0 > 0 && open(rgba, &seen, x0 - 1, y) { - x0 -= 1; - } - while x1 + 1 < w && open(rgba, &seen, x1 + 1, y) { - x1 += 1; - } + let (x0, x1) = run_span(x, y, w, |xi, yi| open(rgba, &seen, xi, yi)); for xi in x0..=x1 { if let Some(s) = seen.get_mut(at(xi, y)) { *s = true; @@ -1426,23 +1412,54 @@ fn clear_background(rgba: &mut image::RgbaImage, tol: f32) -> usize { } } for ny in [y.checked_sub(1), y.checked_add(1).filter(|&n| n < h)].into_iter().flatten() { - let mut xi = x0; - while xi <= x1 { - if open(rgba, &seen, xi, ny) { - stack.push((xi, ny)); - while xi <= x1 && open(rgba, &seen, xi, ny) { - xi += 1; - } - } else { - xi += 1; - } - } + push_open_runs(&mut stack, x0, x1, ny, |xi, yi| open(rgba, &seen, xi, yi)); } most = most.max(stack.len()); } most } +/// Every pixel on the image's edge, the flood-fill seeds. +fn border_pixels(w: u32, h: u32) -> Vec<(u32, u32)> { + let mut stack = Vec::new(); + for x in 0..w { + stack.push((x, 0)); + stack.push((x, h - 1)); + } + for y in 0..h { + stack.push((0, y)); + stack.push((w - 1, y)); + } + stack +} + +/// The horizontal run of open pixels around `(x, y)`, both ends included. +fn run_span(x: u32, y: u32, w: u32, open: impl Fn(u32, u32) -> bool) -> (u32, u32) { + let (mut x0, mut x1) = (x, x); + while x0 > 0 && open(x0 - 1, y) { + x0 -= 1; + } + while x1 + 1 < w && open(x1 + 1, y) { + x1 += 1; + } + (x0, x1) +} + +/// Queues the first pixel of each open run in row `ny` between `x0` and `x1`. +fn push_open_runs(stack: &mut Vec<(u32, u32)>, x0: u32, x1: u32, ny: u32, open: impl Fn(u32, u32) -> bool) { + let mut xi = x0; + while xi <= x1 { + if open(xi, ny) { + stack.push((xi, ny)); + while xi <= x1 && open(xi, ny) { + xi += 1; + } + } else { + xi += 1; + } + } +} + fn frame(img: image::DynamicImage, border: u32, color: [u8; 4]) -> Result { let (w, h) = grown(img.width(), img.height(), border).ok_or_else(too_large)?; let mut out = canvas(w, h, color)?; diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index ee7ebaafa5..3ec8006744 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -916,6 +916,24 @@ fn backspace(s: &mut Session, v: &Value) -> CmdResult { /// numbering while keeping the text position, takes a List Paragraph out of the list, outdents, /// joins the previous paragraph or moves into a preceding table; the command wrapper restores a /// content-control placeholder. +/// The first Backspace at a numbered paragraph's start removes the number but keeps the item's +/// indent (Word): the text stays where it was, so the indent becomes the paragraph's own. +fn remove_number(s: &mut Session, f: &Pos, n: NumRef) -> CmdResult { + let text_at = s.doc.numbering.level(n.num, n.level).map(|l| l.indent); + let p = s.doc.para_mut(f.story, &f.path)?; + if p.props.style.as_deref() == Some("ListParagraph") { + if p.props.indent_left.is_none() && p.props.indent_left_chars.is_none_or(|c| c == 0.0) { + p.props.indent_left = text_at; + } + if p.props.indent_first.is_none() && p.props.indent_first_chars.is_none_or(|c| c == 0.0) { + p.props.indent_first = Some(0.0); + } + } + p.props.numbering = Some(NumRef { num: 0, level: 0 }); + p.touch(); + sel_result(s) +} + fn backspace_inner(s: &mut Session, _: &Value) -> CmdResult { s.goal_x = None; s.pending = None; @@ -949,22 +967,8 @@ fn backspace_inner(s: &mut Session, _: &Value) -> CmdResult { return sel_result(s); } // At the start of a paragraph. - if para.props.numbering.is_some_and(|n| n.num != 0) { - // First Backspace removes the number but keeps the item's indent (Word): the text stays - // where it was, so the indent becomes the paragraph's own (`num: 0` alone drops it). - let text_at = para.props.numbering.and_then(|n| s.doc.numbering.level(n.num, n.level)).map(|l| l.indent); - let p = s.doc.para_mut(f.story, &f.path)?; - if p.props.style.as_deref() == Some("ListParagraph") { - if p.props.indent_left.is_none() && p.props.indent_left_chars.is_none_or(|c| c == 0.0) { - p.props.indent_left = text_at; - } - if p.props.indent_first.is_none() && p.props.indent_first_chars.is_none_or(|c| c == 0.0) { - p.props.indent_first = Some(0.0); - } - } - p.props.numbering = Some(NumRef { num: 0, level: 0 }); - p.touch(); - return sel_result(s); + if let Some(n) = para.props.numbering.filter(|n| n.num != 0) { + return remove_number(s, &f, n); } if para.props.style.as_deref() == Some("ListParagraph") { // The second one takes it out of the list's indent instead of joining paragraphs. diff --git a/crates/engine/src/cmd/tools.rs b/crates/engine/src/cmd/tools.rs index 695501aea5..ea9386b9ce 100644 --- a/crates/engine/src/cmd/tools.rs +++ b/crates/engine/src/cmd/tools.rs @@ -1554,12 +1554,13 @@ const NOISE_MATCH_LEN: usize = 1; /// stripped before running its table, so it's left untouched regardless of length; only a short /// match produced by the table's own backtracking, which can only ever be interior, is a /// candidate. +#[derive(Clone, Copy, PartialEq)] +enum Kind { + Keep, + Fold, +} + fn collapse_noise_matches(ops: &[Op]) -> Vec { - #[derive(Clone, Copy, PartialEq)] - enum Kind { - Keep, - Fold, - } let mut runs: Vec<(Op, usize, Kind)> = Vec::new(); let mut k = 0; while k < ops.len() { @@ -1586,12 +1587,7 @@ fn collapse_noise_matches(ops: &[Op]) -> Vec { } let region = &runs[start..i]; if region.iter().any(|(_, _, kind)| *kind == Kind::Fold) { - // Flatten the region: every original character first (in its own order), then every - // revised character (in its own order) — one clean delete, one clean insert. - let dels: usize = region.iter().map(|(op, len, kind)| if *kind == Kind::Fold || *op == Op::Del { *len } else { 0 }).sum(); - let inss: usize = region.iter().map(|(op, len, kind)| if *kind == Kind::Fold || *op == Op::Ins { *len } else { 0 }).sum(); - out.extend(std::iter::repeat_n(Op::Del, dels)); - out.extend(std::iter::repeat_n(Op::Ins, inss)); + flatten_region(&mut out, region); } else { for (op, len, _) in region { out.extend(std::iter::repeat_n(*op, *len)); @@ -1601,6 +1597,15 @@ fn collapse_noise_matches(ops: &[Op]) -> Vec { out } +/// Flattens a region with a folded run: every original character first (in its own order), then +/// every revised character (in its own order) — one clean delete, one clean insert. +fn flatten_region(out: &mut Vec, region: &[(Op, usize, Kind)]) { + let dels: usize = region.iter().map(|(op, len, kind)| if *kind == Kind::Fold || *op == Op::Del { *len } else { 0 }).sum(); + let inss: usize = region.iter().map(|(op, len, kind)| if *kind == Kind::Fold || *op == Op::Ins { *len } else { 0 }).sum(); + out.extend(std::iter::repeat_n(Op::Del, dels)); + out.extend(std::iter::repeat_n(Op::Ins, inss)); +} + /// Words with the spaces after them ("The ", "cat "), or single characters. fn tokens(s: &str, characters: bool) -> Vec { if characters { From 66601038df94ce538741fb31a025c6ce0fc6bdec Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 25 Oct 2026 21:52:01 -0400 Subject: [PATCH 176/199] tests(layout): build the nbsp test document without struct-update syntax (#4380) --- crates/layout/src/tests_nbsp.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/layout/src/tests_nbsp.rs b/crates/layout/src/tests_nbsp.rs index b8dbc9aad7..d2908fba70 100644 --- a/crates/layout/src/tests_nbsp.rs +++ b/crates/layout/src/tests_nbsp.rs @@ -37,7 +37,8 @@ fn wide_nbsp_family() -> &'static str { /// The width of the first line of `text` set in the wide-nbsp font. fn line_width(text: &str) -> f32 { let chr = wordcraft_doc::CharProps { font: Some(wide_nbsp_family().into()), size: Some(12.0), ..Default::default() }; - let d = Document { body: vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(text, chr))], ..Document::new() }; + let mut d = Document::new(); + d.body = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(text, chr))]; let mut c = LayoutCache::new(); let l = layout(&d, &mut c, &LayoutOptions::default()); for p in &l.pages { From 4eda77a2de238e642cf4c33ca2b1521b7fbe23e2 Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Mon, 26 Oct 2026 02:08:04 +0000 Subject: [PATCH 177/199] =?UTF-8?q?#3876:=20review=20=E2=80=94=20Zotero=20?= =?UTF-8?q?owner=20check=20streams=20the=20socket=20table,=20and=20an=20un?= =?UTF-8?q?readable=20table=20refuses?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Linux same-user check read /proc/net/tcp{,6} through a 64 MiB take(). Hitting the cap was silent, so a local user binding ~450k loopback listeners pushed the connection rows past it; both uids came back unknown and the "trust loopback" arm let their fake Zotero listener on 23116 through. A truncated last line could also glue to the next header. Now each table is streamed line by line (BufRead, nothing kept, no total cap, lines capped at 4 KiB) and stops once both rows are found. Any read error refuses. "Unknown, trust loopback" remains only for a listener absent from a table read in full (WSL2, proxies). Tests: rows after 70 MiB of filler, and a read error mid-table. rich.rs: document that the MAX_RUNS cap still allows one first run per paragraph (bounded by MAX_PARAS). Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/zotero/src/peer.rs | 157 ++++++++++++++++++++++++++++++++------ crates/zotero/src/rich.rs | 2 +- 2 files changed, 135 insertions(+), 24 deletions(-) diff --git a/crates/zotero/src/peer.rs b/crates/zotero/src/peer.rs index 07df98e79f..68e2185364 100644 --- a/crates/zotero/src/peer.rs +++ b/crates/zotero/src/peer.rs @@ -35,23 +35,25 @@ pub(crate) fn check(stream: &TcpStream) -> Result<(), ZoteroError> { } /// Refuse a listener at `peer` that the kernel's socket tables say belongs to another user. +/// A table that can't be read in full (I/O error) is a refusal, never "unknown". #[cfg(any(target_os = "linux", target_os = "android"))] fn owner_check(local: SocketAddr, peer: SocketAddr) -> Result<(), ZoteroError> { - use std::io::Read; - /// Longest socket table read (a busy server's table is a few MB). - const MAX_TABLE: u64 = 64 * 1024 * 1024; - let mut table = String::new(); + use std::io::BufReader; + let mut rows = Rows::default(); for path in ["/proc/net/tcp", "/proc/net/tcp6"] { - if let Ok(f) = std::fs::File::open(path) { - let mut part = String::new(); - if f.take(MAX_TABLE).read_to_string(&mut part).is_ok() { - table.push_str(&part); - } + // A missing file (no IPv6) is an absent table; any other failure is a refusal. + match std::fs::File::open(path) { + Ok(f) => scan_table(BufReader::new(f), local, peer, &mut rows)?, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(unreadable(&e)), + } + if rows.done() { + break; } } // Our uid when our own socket's row is missing: `/proc/self` belongs to this process's user. let me = std::fs::metadata("/proc/self").ok().map(|m| std::os::unix::fs::MetadataExt::uid(&m)); - same_owner(&table, local, peer, me) + decide(&rows, peer, me) } /// Windows, macOS, the BSDs: no same-user check (no socket table to read without OS calls this @@ -61,16 +63,39 @@ fn owner_check(_local: SocketAddr, _peer: SocketAddr) -> Result<(), ZoteroError> Ok(()) } -/// From a `/proc/net/tcp`-format socket table: refuse when the socket at `peer` connected to -/// `local` (the listener's side) belongs to another user than ours. Our uid is our own socket's -/// row, else `me` (this process's). When the listener isn't listed (or the table couldn't be -/// read, or our uid is unknown) its owner is unknown and the loopback rule decides. -#[cfg_attr(not(any(target_os = "linux", target_os = "android")), allow(dead_code))] -pub(crate) fn same_owner(table: &str, local: SocketAddr, peer: SocketAddr, me: Option) -> Result<(), ZoteroError> { +/// The uids found for the two rows of one connection. +#[derive(Default)] +struct Rows { + ours: Option, + theirs: Option, +} + +impl Rows { + fn done(&self) -> bool { + self.ours.is_some() && self.theirs.is_some() + } +} + +fn unreadable(e: &std::io::Error) -> ZoteroError { + ZoteroError::Refused(format!("couldn't read the system's socket table ({e}), so the program on the Zotero port can't be checked")) +} + +/// Stream a `/proc/net/tcp`-format table line by line (nothing is kept, so the table's size +/// doesn't matter and nothing is truncated), recording the uids of the connection's two rows. +/// Stops once both are found. Any read error is a refusal: a table we couldn't finish reading +/// must never count as "the listener isn't listed". +fn scan_table(mut table: impl std::io::BufRead, local: SocketAddr, peer: SocketAddr, rows: &mut Rows) -> Result<(), ZoteroError> { let key = |a: SocketAddr| (a.ip().to_canonical(), a.port()); let (local, peer) = (key(local), key(peer)); - let (mut ours, mut theirs) = (None, None); - for line in table.lines() { + let mut line = Vec::new(); + loop { + line.clear(); + // Bounded line: a row is ~150 bytes; read at most 4 KiB of one (the rest is skipped). + let n = read_line_capped(&mut table, &mut line).map_err(|e| unreadable(&e))?; + if n == 0 { + return Ok(()); + } + let line = String::from_utf8_lossy(&line); let f: Vec<&str> = line.split_whitespace().take(8).collect(); let (Some(l), Some(r), Some(uid)) = (f.get(1).and_then(|a| parse_addr(a)), f.get(2).and_then(|a| parse_addr(a)), f.get(7)) else { continue; @@ -78,26 +103,67 @@ pub(crate) fn same_owner(table: &str, local: SocketAddr, peer: SocketAddr, me: O let Ok(uid) = uid.parse::() else { continue }; let (l, r) = (key(l), key(r)); if l == local && r == peer { - ours = Some(uid); + rows.ours = Some(uid); } else if l == peer && r == local { - theirs = Some(uid); + rows.theirs = Some(uid); + } + if rows.done() { + return Ok(()); } } - match (ours.or(me), theirs) { +} + +/// Read one line into `out`, keeping at most 4 KiB of it; returns the bytes consumed (0 at the end). +fn read_line_capped(r: &mut impl std::io::BufRead, out: &mut Vec) -> std::io::Result { + const CAP: usize = 4096; + let mut total = 0; + loop { + let buf = r.fill_buf()?; + if buf.is_empty() { + return Ok(total); + } + let (take, found) = match buf.iter().position(|b| *b == b'\n') { + Some(i) => (i + 1, true), + None => (buf.len(), false), + }; + let keep = take.min(CAP.saturating_sub(out.len())); + out.extend_from_slice(buf.get(..keep).unwrap_or_default()); + r.consume(take); + total += take; + if found { + return Ok(total); + } + } +} + +/// Refuse when the connection's rows show another user. Our uid is our own socket's row, else +/// `me` (this process's). When the listener isn't listed in a table that was read in full (WSL2, +/// a proxy, a sandbox) or our uid is unknown, its owner is unknown and the loopback rule decides. +fn decide(rows: &Rows, peer: SocketAddr, me: Option) -> Result<(), ZoteroError> { + match (rows.ours.or(me), rows.theirs) { (Some(a), Some(b)) if a == b => Ok(()), (Some(_), Some(b)) => { - Err(ZoteroError::Refused(format!("the program on port {} runs as another user (uid {b}), so it isn't your Zotero", peer.1))) + Err(ZoteroError::Refused(format!("the program on port {} runs as another user (uid {b}), so it isn't your Zotero", peer.port()))) } _ => { log::debug!( "Zotero: couldn't tell which user runs the program on port {} (not in the socket table); trusting the loopback address alone", - peer.1 + peer.port() ); Ok(()) } } } +/// From an in-memory `/proc/net/tcp`-format table (tests and callers with a string): see +/// [`scan_table`] and [`decide`]. +#[cfg_attr(not(test), allow(dead_code))] +pub(crate) fn same_owner(table: &str, local: SocketAddr, peer: SocketAddr, me: Option) -> Result<(), ZoteroError> { + let mut rows = Rows::default(); + scan_table(table.as_bytes(), local, peer, &mut rows)?; + decide(&rows, peer, me) +} + /// `0100007F:5A5C` (IPv4) or 32 hex digits and a port (IPv6): each 32-bit word as the kernel /// prints it, in this machine's byte order. #[cfg_attr(not(any(target_os = "linux", target_os = "android")), allow(dead_code))] @@ -205,6 +271,51 @@ mod tests { assert!(matches!(same_owner(&table, local, peer, None), Err(ZoteroError::Refused(e)) if e.contains("another user"))); } + /// A reader that yields `data`, then fails. + struct Failing(std::io::Cursor>); + impl std::io::Read for Failing { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + match self.0.read(buf)? { + 0 => Err(std::io::Error::other("injected")), + n => Ok(n), + } + } + } + + /// The real attack: a hostile user floods the table with listener rows so the connection's + /// rows sit past any fixed cap. Streaming finds them wherever they are. + #[test] + fn rows_far_into_a_huge_table_are_still_found() { + let (local, peer) = addrs(); + let filler = row("127.0.0.1:1".parse().unwrap(), "0.0.0.0:0".parse().unwrap(), 1001); + let mut table = String::from(HEADER); + // About 70 MiB of rows, past the old 64 MiB cap. + for _ in 0..(70 * 1024 * 1024 / filler.len()) { + table.push_str(&filler); + } + table.push_str(&row(local, peer, 1000)); + table.push_str(&row(peer, local, 1001)); + assert!(table.len() > 64 * 1024 * 1024); + assert!(matches!(same_owner(&table, local, peer, None), Err(ZoteroError::Refused(e)) if e.contains("another user"))); + } + + /// A table that fails mid-read is a refusal, not "listener unknown, trust loopback". + #[test] + fn a_table_that_fails_to_read_is_refused() { + let (local, peer) = addrs(); + let data = format!("{HEADER}{}", row(local, peer, 1000)).into_bytes(); + let mut rows = Rows::default(); + let r = scan_table(std::io::BufReader::new(Failing(std::io::Cursor::new(data))), local, peer, &mut rows); + assert!(matches!(r, Err(ZoteroError::Refused(ref e)) if e.contains("socket table")), "{r:?}"); + } + + #[test] + fn an_overlong_line_is_skipped_without_gluing_to_the_next() { + let (local, peer) = addrs(); + let table = format!("{HEADER}{}\n{}{}", "x".repeat(100_000), row(local, peer, 1000), row(peer, local, 1001)); + assert!(matches!(same_owner(&table, local, peer, None), Err(ZoteroError::Refused(_)))); + } + #[test] fn only_this_computer_is_contacted() { for ok in ["127.0.0.1:23116", "127.9.9.9:1", "[::1]:23116", "[::ffff:127.0.0.1]:23116"] { diff --git a/crates/zotero/src/rich.rs b/crates/zotero/src/rich.rs index 7ff3377f1c..705a7e15ec 100644 --- a/crates/zotero/src/rich.rs +++ b/crates/zotero/src/rich.rs @@ -42,7 +42,7 @@ fn style_props(base: &CharProps, src: &CharProps) -> CharProps { /// The body paragraphs of `d` (Zotero's RTF or HTML, read), at most [`MAX_PARAS`], formatted on /// top of `base` with the run styling they carry (past [`MAX_RUNS`] runs, text keeps the -/// formatting before it). Objects are dropped. +/// formatting before it; each paragraph still needs its first run, so at most [`MAX_PARAS`] more). Objects are dropped. fn from_doc(d: &Document, base: &CharProps) -> Vec { let mut out = Vec::new(); let mut runs = 0usize; From b094b299589b9588e70f13c31914b18aaffc8e68 Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Mon, 26 Oct 2026 01:20:22 +0000 Subject: [PATCH 178/199] =?UTF-8?q?#3869:=20review=20=E2=80=94=20WOFF2=20p?= =?UTF-8?q?oint=20pre-check=20reads=20glyf=20nContours=20as=20u16=20like?= =?UTF-8?q?=20the=20decoder=20(only=200xFFFF=20is=20composite)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01HUodYU9r4mPHed4pPsjHC2 --- crates/fonts/src/fontdb/user.rs | 32 +++++++++++++++++++++++++------- 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/crates/fonts/src/fontdb/user.rs b/crates/fonts/src/fontdb/user.rs index c67fb6a213..8e0da9e524 100644 --- a/crates/fonts/src/fontdb/user.rs +++ b/crates/fonts/src/fontdb/user.rs @@ -273,9 +273,11 @@ fn glyf_points_fit(t: &[u8]) -> Option { let points = t.get(contours_end..contours_end.checked_add(u32_at(12)?)?)?; let mut at = 0; for g in 0..glyphs { - let n = i16::from_be_bytes(contours.get(2 * g..2 * g + 2)?.try_into().ok()?); + // The decoder reads the count as unsigned and only 0xFFFF is a composite glyph (no points + // here): 0x8000..=0xFFFE are that many contours, so they are counted, not read as negative. + let n = u16::from_be_bytes(contours.get(2 * g..2 * g + 2)?.try_into().ok()?); let mut total = 0usize; - for _ in 0..n.max(0) { + for _ in 0..if n == 0xFFFF { 0 } else { n } { // 255UInt16 let code = *points.get(at)?; at += 1; @@ -745,15 +747,20 @@ pub(crate) mod tests { /// A WOFF2 file holding a transformed `glyf` (and `loca`) whose one glyph has two contours of /// `points` points each (and no coordinates: the decoder finds it damaged in the end). fn woff2_glyph_with(points: u16) -> Vec { - let total = 2 * points as usize; + let [hi, lo] = points.to_be_bytes(); + woff2_glyph_with_contours(2, &[253, hi, lo].repeat(2), 2 * points as usize) + } + + /// As [`woff2_glyph_with`], for a glyph whose contour count is `n` and whose point-count stream + /// is `counts`, with `total` flags. + fn woff2_glyph_with_contours(n: u16, counts: &[u8], total: usize) -> Vec { let mut glyf = vec![0, 0, 0, 0, 0, 1, 0, 0]; // reserved, optionFlags, numGlyphs 1, indexFormat // nContour, nPoints, flag, glyph, composite, bbox and instruction stream sizes. - for size in [2, 6, total, 0, 0, 4, 0] { + for size in [2, counts.len(), total, 0, 0, 4, 0] { glyf.extend_from_slice(&(size as u32).to_be_bytes()); } - glyf.extend_from_slice(&2i16.to_be_bytes()); - let [hi, lo] = points.to_be_bytes(); - glyf.extend_from_slice(&[253, hi, lo, 253, hi, lo]); // 255UInt16 in its word form + glyf.extend_from_slice(&n.to_be_bytes()); + glyf.extend_from_slice(counts); // 255UInt16 point counts glyf.resize(glyf.len() + total, 1); // on-curve flags glyf.extend_from_slice(&[0; 4]); // no explicit bounding boxes let mut dir = vec![10]; // glyf, transform version 0 @@ -801,6 +808,17 @@ pub(crate) mod tests { assert!(matches!(decode_font_file(&woff2_glyph_with(3)), Err(UserFontError::BadWebFont(e)) if e != TOO_MANY_POINTS)); } + #[test] + fn a_woff2_glyph_with_a_contour_count_past_i16_max_is_counted() { + // #3703: the decoder reads the contour count as unsigned (only 0xFFFF is a composite), so + // 0x8000..=0xFFFE are that many contours; read as negative they counted no points and the + // decoder then allocated about 1 GB for a 1.3 MB font. + for n in [0x8000u16, 0xFFFE] { + let file = woff2_glyph_with_contours(n, &vec![3; usize::from(n)], 3 * usize::from(n)); + assert_eq!(decode_font_file(&file), Err(UserFontError::BadWebFont(TOO_MANY_POINTS.into())), "{n:#x}"); + } + } + #[test] fn an_added_font_is_listed_resolved_and_measured() { let db = db(); From ddda3c46701d38db74ad870bc478bc9b0a1a576b Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Mon, 26 Oct 2026 02:09:31 +0000 Subject: [PATCH 179/199] =?UTF-8?q?#3869:=20review=20=E2=80=94=20the=20dra?= =?UTF-8?q?w=20that=20turns=20hinting=20off=20is=20redrawn=20unhinted,=20s?= =?UTF-8?q?o=20the=20outline=20cache=20never=20keeps=20a=20hinted=20glyph?= =?UTF-8?q?=20from=20a=20tripped=20breaker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01HUodYU9r4mPHed4pPsjHC2 --- crates/fonts/src/fontdb.rs | 48 +++++++++++++++++++++++++++++++++++--- 1 file changed, 45 insertions(+), 3 deletions(-) diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index 2ccb4cc744..8ab5451e8b 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -95,6 +95,8 @@ pub struct FontFace { #[cfg_attr(target_arch = "wasm32", allow(dead_code))] hint_slow: std::sync::atomic::AtomicU32, hinting_off: std::sync::atomic::AtomicBool, + /// A hinted draw slower than this is a strike ([`HINT_STRIKE`]; tests shorten it). + hint_strike: std::time::Duration, } /// Limits on what a face's TrueType instructions may run over (#3692). skrifa's interpreter budget @@ -233,7 +235,7 @@ impl FontFace { (!pen.over).then_some(pen.path) } fn draw_outline(&self, gid: u32, max_elements: usize) -> FlipPen { - let mut pen = FlipPen { path: BezPath::new(), room: max_elements, over: false }; + let mut pen = FlipPen { path: BezPath::new(), room: max_elements, over: false, hinted: false }; if let Some(f) = self.skrifa() && let Some(g) = f.outline_glyphs().get(GlyphId::new(gid)) { @@ -242,11 +244,19 @@ impl FontFace { #[cfg(not(target_arch = "wasm32"))] let started = std::time::Instant::now(); let settings = hinter.map_or_else(|| DrawSettings::unhinted(Size::unscaled(), self.location()), |h| DrawSettings::hinted(h, false)); - let drawn = g.draw(settings, &mut pen); + pen.hinted = hinter.is_some(); + let mut drawn = g.draw(settings, &mut pen); #[cfg(not(target_arch = "wasm32"))] - if hinter.is_some() && self.count_hinted(started.elapsed() > HINT_STRIKE) { + if hinter.is_some() && self.count_hinted(started.elapsed() > self.hint_strike) { log::warn!("Hinting glyph {gid} in {:?} took {:?}: drawing the face unhinted", self.family, started.elapsed()); } + // A draw that was hinted when hinting went off (by itself, or by another thread's draw + // meanwhile) is the kind that was slow or misassembled: draw it again unhinted, so + // the outline cache never keeps it (#3692). + if pen.hinted && self.hinting_off.load(Ordering::Relaxed) { + pen = FlipPen { path: BezPath::new(), room: max_elements, over: false, hinted: false }; + drawn = g.draw(DrawSettings::unhinted(Size::unscaled(), self.location()), &mut pen); + } if let Err(e) = drawn { log::warn!("Cannot draw glyph {gid} in {:?}: {e}", self.family); pen.path = BezPath::new(); @@ -1015,6 +1025,7 @@ fn make_face(bytes: FontBytes, index: u32, family: String, style: String, legacy hint_draws: Default::default(), hint_slow: Default::default(), hinting_off: Default::default(), + hint_strike: HINT_STRIKE, }) } @@ -1996,6 +2007,8 @@ struct FlipPen { path: BezPath, room: usize, over: bool, + /// Drawn with the face's TrueType interpreter. + hinted: bool, } impl FlipPen { @@ -2375,6 +2388,35 @@ mod tests { assert!(!face.glyph_outline(face.glyph_for('a')).elements().is_empty(), "drawn unhinted"); } + #[test] + #[cfg(not(target_arch = "wasm32"))] + fn the_draw_that_turns_hinting_off_is_redrawn_unhinted_for_the_outline_cache() { + // #3692: the glyph whose slow hinted draw trips the breaker may be misassembled; it must not be + // what the outline cache keeps. + let data = hint_reliant_font(660); + let mut face = super::make_face( + super::FontBytes::Owned(std::sync::Arc::new(data)), + 0, + "MingLiU JBMono".into(), + "Regular".into(), + "MingLiU JBMono".into(), + Vec::new(), + ) + .unwrap(); + assert!(face.hinter().is_some()); + face.hint_strike = std::time::Duration::ZERO; // every hinted draw is slow + let gid = face.glyph_for('a'); + assert!(face.draw_outline(gid, usize::MAX).hinted, "hinted while the breaker is closed"); + assert!(face.draw_outline(gid, usize::MAX).hinted); + let tripping = face.draw_outline(gid, usize::MAX); + assert!(face.hinting_off.load(std::sync::atomic::Ordering::Relaxed), "the third slow draw trips the breaker"); + assert!(!tripping.hinted, "the tripping draw is returned unhinted"); + assert!(!tripping.path.elements().is_empty()); + // And what the cache keeps is that outline. + let db = FontDb::with_font_dirs(vec![]); + assert_eq!(db.outline(&face, gid).elements(), tripping.path.elements()); + } + #[test] fn named_instances_and_collection_members_are_capped() { // #3716: a file's faces are capped before they are built, however the file multiplies them. From b654049545aa41cae0bd50f4b2c4221c05ea961e Mon Sep 17 00:00:00 2001 From: Martin Ball <98639962+benandez@users.noreply.github.com> Date: Mon, 26 Oct 2026 02:30:32 +0000 Subject: [PATCH 180/199] =?UTF-8?q?#3874:=20review=20=E2=80=94=20text=20bo?= =?UTF-8?q?x=20paste=20charges=20only=20what=20it=20copies;=20stories=20sh?= =?UTF-8?q?aring=20Arc=20blocks=20(PDF=20imports)=20keep=20their=20text=20?= =?UTF-8?q?(#4381)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Claude Opus 5.5 (1M context) --- crates/doc/src/edit.rs | 42 ++++++++++++++++++++++++++++++++++++------ 1 file changed, 36 insertions(+), 6 deletions(-) diff --git a/crates/doc/src/edit.rs b/crates/doc/src/edit.rs index 1c5b877666..64a75b4446 100644 --- a/crates/doc/src/edit.rs +++ b/crates/doc/src/edit.rs @@ -188,8 +188,9 @@ fn fit_tables(b: Block, room: usize, out: &mut Vec) { const MAX_BOX_DEPTH: usize = 8; /// Most text box stories one paste may create (hostile documents can fan out). const MAX_PASTED_BOXES: usize = 4096; -/// Most text box content (estimated bytes, see [`block_cost`]) one paste may adopt: thousands of -/// boxes can show the same large story, and each needs a story of its own. +/// Most text box content (estimated bytes, see [`block_cost`]) one paste may copy: thousands of +/// boxes can show the same story, and each needs a story of its own. Blocks without text boxes +/// are shared between those stories, not copied, so they are not charged per box. const MAX_PASTED_BOX_BYTES: usize = 32 << 20; /// What a paste may still spend on text box stories. @@ -901,9 +902,16 @@ impl Document { return None; } let src = parts.get(&old).or_else(|| self.parts.get(&old).filter(|p| p.kind == PartKind::TextBox))?; - // Charge the whole story before copying any of it. Its cost is worked out once per paste - // (that walks its payloads), however many boxes show it. - let cost = *budget.costs.entry(old).or_insert_with(|| src.blocks.iter().fold(0usize, |a, b| a.saturating_add(block_cost(b).0))); + // Charge what the copy really allocates before making any of it: the block slots, and the + // blocks holding text boxes (deep-copied below). The rest are shared `Arc`s, however many + // boxes show them, so they cost nothing more. Worked out once per paste. + let cost = *budget.costs.entry(old).or_insert_with(|| { + let slots = src.blocks.len().saturating_mul(std::mem::size_of::>()); + src.blocks.iter().fold(slots, |a, b| match block_cost(b) { + (c, true) => a.saturating_add(c), + (_, false) => a, + }) + }); let Some(left) = budget.bytes.checked_sub(cost) else { budget.skipped = budget.skipped.saturating_add(1); return None; @@ -1385,7 +1393,10 @@ mod tests { fn a_box_over_the_budget_leaves_later_smaller_boxes_their_stories() { // Review of #3874: once one story didn't fit, every later box in the paste was left // empty, however small its story. Only the box that doesn't fit is. - let big = para_block(Paragraph::with_text(&"x".repeat(100_000), CharProps::default())); + // (Story 1 holds a text box, so each box showing it needs a real copy.) + let mut bp = Paragraph::with_text(&"x".repeat(100_000), CharProps::default()); + bp.insert_object(0, box_shape(Some(999)), &CharProps::default()).unwrap(); + let big = para_block(bp); let small = para_block(Paragraph::with_text("small", CharProps::default())); let mut p = Paragraph::new(); for story in [2, 1, 2, 1] { @@ -1405,6 +1416,25 @@ mod tests { assert_eq!(budget.costs.len(), 2, "each story costed once"); } + #[test] + fn many_boxes_sharing_one_story_paste_with_their_text() { + // Review of #3874: a document converted from PDF has thousands of boxes showing story + // data they share. 64 boxes x a 1 MiB block is 64 MiB counted per box (over the 32 MiB + // cap) but the block is shared, not copied, so every box keeps its text. + let big = para_block(Paragraph::with_text(&"x".repeat(1 << 20), CharProps::default())); + let f = fan_out(vec![big.clone()], 64); + let mut d = doc(&["x"]); + let (_, warned) = crate::test_log::warnings(|| d.insert_fragment(&Pos::body(0, 1), &f).unwrap()); + assert!(!warned.iter().any(|w| w.contains("text boxes are left empty")), "{warned:?}"); + let ids: Vec = boxes_in(&d, &Path::top(0)).into_iter().flatten().collect(); + assert_eq!(ids.len(), 64, "every box keeps a story"); + for id in ids { + let b = d.parts[&id].blocks.first().unwrap(); + assert!(Arc::ptr_eq(b, &big), "shared, so not charged per box"); + assert_eq!(d.parts[&id].blocks.len(), 1); + } + } + #[test] fn pasted_box_copies_are_capped_in_bytes() { // Story 1 is a 1 MiB paragraph that holds a text box itself, so each box showing it needs From e216e7afc1bd103080f151b3cd9ff9f12b96c412 Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Mon, 26 Oct 2026 02:36:16 +0000 Subject: [PATCH 181/199] =?UTF-8?q?#3876:=20review=20=E2=80=94=20docs=20sa?= =?UTF-8?q?y=20an=20unreadable=20table=20refuses;=20EINTR=20is=20retried;?= =?UTF-8?q?=20the=20huge-table=20test=20drives=20the=20real=20read=20path?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - docs/zotero.md and peer.rs module docs: a read error, or an open error other than NotFound, refuses (sandboxes denying /proc/net/tcp: AppArmor, Snap, Flatpak, are refused). - read_line_capped retries ErrorKind::Interrupted like std's read_line. - New scan_tables (what owner_check calls) takes the table opener; the 70 MiB test drives it, so reinstating take(64 MiB) there fails the test. Added tests for EINTR and a denied open. - Row parsing uses split_whitespace().nth() instead of collecting a Vec. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/zotero/src/peer.rs | 125 +++++++++++++++++++++++++++++--------- docs/zotero.md | 6 +- 2 files changed, 101 insertions(+), 30 deletions(-) diff --git a/crates/zotero/src/peer.rs b/crates/zotero/src/peer.rs index 68e2185364..3175487a05 100644 --- a/crates/zotero/src/peer.rs +++ b/crates/zotero/src/peer.rs @@ -4,9 +4,10 @@ //! - **Every system:** WordCraft only connects to this computer (a loopback address). //! - **Linux only:** the program listening there must not run as another user, from the //! kernel's socket table (`/proc/net/tcp`): another account on a shared machine that took the -//! port while Zotero wasn't running is refused. A listener the table doesn't show (WSL2 -//! mirrored networking with Zotero on Windows, a local proxy, a sandbox) leaves only the -//! loopback rule. +//! port while Zotero wasn't running is refused. A listener the tables don't show (WSL2 +//! mirrored networking with Zotero on Windows, a local proxy) leaves only the loopback rule, +//! but only when the tables were read in full: a read error, or an open error other than +//! "not found" (a sandbox that denies `/proc/net/tcp`: AppArmor, Snap, Flatpak), refuses. //! //! On Windows, macOS and the BSDs there is **no same-user check**: the loopback rule is the //! only one, so any local account's listener on the port is taken for Zotero. Those systems have @@ -38,19 +39,7 @@ pub(crate) fn check(stream: &TcpStream) -> Result<(), ZoteroError> { /// A table that can't be read in full (I/O error) is a refusal, never "unknown". #[cfg(any(target_os = "linux", target_os = "android"))] fn owner_check(local: SocketAddr, peer: SocketAddr) -> Result<(), ZoteroError> { - use std::io::BufReader; - let mut rows = Rows::default(); - for path in ["/proc/net/tcp", "/proc/net/tcp6"] { - // A missing file (no IPv6) is an absent table; any other failure is a refusal. - match std::fs::File::open(path) { - Ok(f) => scan_table(BufReader::new(f), local, peer, &mut rows)?, - Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} - Err(e) => return Err(unreadable(&e)), - } - if rows.done() { - break; - } - } + let rows = scan_tables(["/proc/net/tcp", "/proc/net/tcp6"], |p| std::fs::File::open(p), local, peer)?; // Our uid when our own socket's row is missing: `/proc/self` belongs to this process's user. let me = std::fs::metadata("/proc/self").ok().map(|m| std::os::unix::fs::MetadataExt::uid(&m)); decide(&rows, peer, me) @@ -63,6 +52,28 @@ fn owner_check(_local: SocketAddr, _peer: SocketAddr) -> Result<(), ZoteroError> Ok(()) } +/// Scan the tables named by `paths` (opened by `open`) in order, stopping once both rows are +/// found. A table that doesn't exist (no IPv6) is skipped; any other open or read error refuses. +fn scan_tables( + paths: [&str; 2], + open: impl Fn(&str) -> std::io::Result, + local: SocketAddr, + peer: SocketAddr, +) -> Result { + let mut rows = Rows::default(); + for path in paths { + match open(path) { + Ok(f) => scan_table(std::io::BufReader::new(f), local, peer, &mut rows)?, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(unreadable(&e)), + } + if rows.done() { + break; + } + } + Ok(rows) +} + /// The uids found for the two rows of one connection. #[derive(Default)] struct Rows { @@ -96,8 +107,8 @@ fn scan_table(mut table: impl std::io::BufRead, local: SocketAddr, peer: SocketA return Ok(()); } let line = String::from_utf8_lossy(&line); - let f: Vec<&str> = line.split_whitespace().take(8).collect(); - let (Some(l), Some(r), Some(uid)) = (f.get(1).and_then(|a| parse_addr(a)), f.get(2).and_then(|a| parse_addr(a)), f.get(7)) else { + let mut f = line.split_whitespace(); + let (Some(l), Some(r), Some(uid)) = (f.nth(1).and_then(parse_addr), f.next().and_then(parse_addr), f.nth(4)) else { continue; }; let Ok(uid) = uid.parse::() else { continue }; @@ -118,7 +129,11 @@ fn read_line_capped(r: &mut impl std::io::BufRead, out: &mut Vec) -> std::io const CAP: usize = 4096; let mut total = 0; loop { - let buf = r.fill_buf()?; + let buf = match r.fill_buf() { + Ok(b) => b, + Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue, + Err(e) => return Err(e), + }; if buf.is_empty() { return Ok(total); } @@ -282,21 +297,75 @@ mod tests { } } + /// Repeats `unit` for `left` bytes, then ends: a huge table without holding it in memory. + struct Table { + parts: Vec<(Vec, usize)>, + part: usize, + at: usize, + } + impl std::io::Read for Table { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + let Some((unit, times)) = self.parts.get(self.part) else { return Ok(0) }; + if self.at >= unit.len() * times { + self.part += 1; + self.at = 0; + return self.read(buf); + } + let n = buf.len().min(unit.len() - self.at % unit.len()); + let off = self.at % unit.len(); + buf[..n].copy_from_slice(&unit[off..off + n]); + self.at += n; + Ok(n) + } + } + /// The real attack: a hostile user floods the table with listener rows so the connection's - /// rows sit past any fixed cap. Streaming finds them wherever they are. + /// rows sit past any fixed cap. The real read path (`scan_tables`, which `owner_check` + /// calls on `/proc/net/tcp*`) finds them wherever they are. #[test] fn rows_far_into_a_huge_table_are_still_found() { let (local, peer) = addrs(); let filler = row("127.0.0.1:1".parse().unwrap(), "0.0.0.0:0".parse().unwrap(), 1001); - let mut table = String::from(HEADER); - // About 70 MiB of rows, past the old 64 MiB cap. - for _ in 0..(70 * 1024 * 1024 / filler.len()) { - table.push_str(&filler); + // More than 64 MiB of rows (the old cap), then the connection's two rows. + let times = 70 * 1024 * 1024 / filler.len(); + assert!(filler.len() * times > 64 * 1024 * 1024); + let tail = format!("{}{}", row(local, peer, 1000), row(peer, local, 1001)).into_bytes(); + let open = |p: &str| match p { + "t4" => { + Ok(Table { parts: vec![(HEADER.as_bytes().to_vec(), 1), (filler.clone().into_bytes(), times), (tail.clone(), 1)], part: 0, at: 0 }) + } + _ => Err(std::io::Error::from(std::io::ErrorKind::NotFound)), + }; + let rows = scan_tables(["t4", "t6"], open, local, peer).unwrap(); + assert_eq!((rows.ours, rows.theirs), (Some(1000), Some(1001))); + assert!(matches!(decide(&rows, peer, None), Err(ZoteroError::Refused(e)) if e.contains("another user"))); + } + + /// A signal interrupting a read is retried, not taken for an unreadable table. + #[test] + fn an_interrupted_read_is_retried() { + struct Eintr(bool, std::io::Cursor>); + impl std::io::Read for Eintr { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + if !std::mem::replace(&mut self.0, true) { + return Err(std::io::ErrorKind::Interrupted.into()); + } + self.1.read(buf) + } } - table.push_str(&row(local, peer, 1000)); - table.push_str(&row(peer, local, 1001)); - assert!(table.len() > 64 * 1024 * 1024); - assert!(matches!(same_owner(&table, local, peer, None), Err(ZoteroError::Refused(e)) if e.contains("another user"))); + let (local, peer) = addrs(); + let data = format!("{HEADER}{}{}", row(local, peer, 7), row(peer, local, 7)).into_bytes(); + let open = |_: &str| Ok(Eintr(false, std::io::Cursor::new(data.clone()))); + let rows = scan_tables(["a", "b"], open, local, peer).unwrap(); + assert_eq!((rows.ours, rows.theirs), (Some(7), Some(7))); + } + + /// A sandbox that denies the table (not "not found") refuses. + #[test] + fn a_table_that_cannot_be_opened_is_refused() { + let (local, peer) = addrs(); + let open = |_: &str| -> std::io::Result<&[u8]> { Err(std::io::ErrorKind::PermissionDenied.into()) }; + assert!(matches!(scan_tables(["a", "b"], open, local, peer), Err(ZoteroError::Refused(_)))); } /// A table that fails mid-read is a refusal, not "listener unknown, trust loopback". diff --git a/docs/zotero.md b/docs/zotero.md index cf5813494c..9a0eb14fb3 100644 --- a/docs/zotero.md +++ b/docs/zotero.md @@ -74,8 +74,10 @@ isn't another user's. On Windows and macOS (and the BSDs) the loopback rule is t program listening on the port runs as the same user as WordCraft, before sending anything. If another account on a shared machine took the port while Zotero wasn't running, WordCraft refuses with an error and the document isn't touched. When the table doesn't show the - listener (WSL2 mirrored networking with Zotero running on Windows, a local proxy, a sandbox) - or can't be read, its owner is unknown and only the loopback rule applies, as below. + listener (WSL2 mirrored networking with Zotero running on Windows, a local proxy), its owner + is unknown and only the loopback rule applies, as below. That holds only when the tables were + read in full: a read error, or an open error other than "not found", **refuses**. Sandboxes + that deny `/proc/net/tcp` (AppArmor, Snap, Flatpak confinement) are therefore refused. - Windows, macOS and the BSDs: **no same-user check.** They have no such table to read without OS calls WordCraft doesn't make (it uses no `unsafe` code), so only the loopback rule applies there (and on Linux when the listener isn't in the table). Remaining risk: on a From ed1296a1d4a1c72618a69bec8c45341aebe9a868 Mon Sep 17 00:00:00 2001 From: Martin Ball <98639962+benandez@users.noreply.github.com> Date: Mon, 26 Oct 2026 02:37:10 +0000 Subject: [PATCH 182/199] #3871 follow-up: .doc picture decode charges zlib input and DIB copies; budget scales with the file (#4382) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * #3871: review — metafile input charged to the decode budget; inline BLIPs deduped; budget scales with input - A compressed metafile is charged max(inflated size, zlib input), so a record claiming to inflate to nothing still costs its input. - Inline pictures share one decode per BLIP record, cached by (stream, foDelay, record's own length) instead of the FBSE size field. - The decode budget is max(512 MB, 64 x input bytes), capped at 2 GiB, so documents of many large legitimate DIBs decode; high-ratio bombs stay capped. - Decode-budget refusals get their own log line. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01HUodYU9r4mPHed4pPsjHC2 * #3871: review — a DIB's copy into a BMP is charged to the decode budget before it is made The charge was w*h*4 after the copy, so a 1x1 header followed by megabytes of tail cost 4 bytes per picture. It is now max(pixels, DIB length), charged first; a bitmap whose copy doesn't fit is dropped rather than copied. The doc comment on the budget scale now says RLE8/RLE4 bitmaps (about 510x) rely on the 512 MB floor. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01HUodYU9r4mPHed4pPsjHC2 --------- Co-authored-by: Claude Sonnet 5.5 --- crates/docbin/src/lib.rs | 2 +- crates/docbin/src/media.rs | 59 +++++++---- crates/docbin/src/officeart.rs | 81 +++++++++++---- crates/docbin/src/tests/drawing.rs | 158 +++++++++++++++++++++++++---- 4 files changed, 241 insertions(+), 59 deletions(-) diff --git a/crates/docbin/src/lib.rs b/crates/docbin/src/lib.rs index 845171251a..7734c72220 100644 --- a/crates/docbin/src/lib.rs +++ b/crates/docbin/src/lib.rs @@ -259,7 +259,7 @@ pub fn read_with_password(bytes: &[u8], password: Option<&str>) -> Result usize { + input.saturating_mul(DECODE_WORK_PER_INPUT_BYTE).clamp(floor, floor.max(MAX_DECODE_WORK_CEILING)) +} /// The document's pictures, keyed by their location in the Data stream: anchors that share a /// `fc_pic` share one media entry (or one failure), and the total bytes copied are capped, so a @@ -32,16 +46,17 @@ pub(crate) struct Pictures { by_fc: HashMap>, /// BLIP store index → media key (`None`: unreadable, not retried). by_bse: HashMap>, - /// (foDelay, end) of a BLIP record outside the store → media key: store entries that alias - /// one record share one decode (`None`: failed, not retried). - by_delay: HashMap<(usize, usize), Option>, + /// (stream, foDelay, record length) of a BLIP record outside the store → media key: store + /// entries and inline pictures that alias one record share one decode (`None`: failed, not + /// retried). The stream is told apart by its address. + by_delay: HashMap<(usize, usize, usize), Option>, /// The media entries, merged into the document at the end. pub(crate) media: BTreeMap>>, total: usize, /// The byte cap (`MAX_MEDIA_BYTES`; smaller in tests). pub(crate) limit: usize, /// Decode work left (`MAX_DECODE_WORK`; smaller in tests). - pub(crate) work: usize, + pub(crate) work: officeart::Decode, /// Anchors whose picture was dropped (only the first few are logged). dropped: usize, /// Pictures kept as placeholders (formats we can't draw). @@ -57,7 +72,7 @@ impl Default for Pictures { media: BTreeMap::new(), total: 0, limit: MAX_MEDIA_BYTES, - work: MAX_DECODE_WORK, + work: officeart::Decode::new(MAX_DECODE_WORK), dropped: 0, placeholders: 0, } @@ -75,6 +90,12 @@ pub(crate) struct Inline { } impl Pictures { + /// An empty store whose decode budget scales with the `input` stream bytes (WordDocument + /// plus Data) the pictures can come from. + pub(crate) fn for_input(input: usize) -> Self { + Pictures { work: officeart::Decode::new(decode_work(input, MAX_DECODE_WORK)), ..Pictures::default() } + } + /// The inline picture at `fc_pic` in the Data stream, copying its bytes the first time. /// `None` when the header is malformed, the picture can't be read, or a picture /// count/byte cap is reached. @@ -96,16 +117,13 @@ impl Pictures { if room == 0 { return None; } - match locate(data, fc_pic, room, &mut self.work)? { + match locate(data, fc_pic, self)? { Found::Range(pic) => { let bytes = data.get(pic.range.clone()).filter(|b| b.len() <= room)?.to_vec(); let key = self.insert(bytes, pic.ext, false)?; Some(Inline { key, w: pic.w, h: pic.h, crop: [0.0; 4], alt: String::new() }) } - Found::Shape { w, h, blip, props } => { - let key = self.insert(blip.bytes, blip.ext, blip.placeholder)?; - Some(Inline { key, w, h, crop: props.crop, alt: props.alt }) - } + Found::Shape { w, h, key, props } => Some(Inline { key: key?, w, h, crop: props.crop, alt: props.alt }), } } @@ -127,6 +145,7 @@ impl Pictures { /// Decode a store entry's BLIP, once per delay-stream record however many entries name it. fn decode_bse(&mut self, fbse: &[u8], delay: &[u8]) -> Option { let (rec, at) = officeart::fbse_record(fbse, delay)?; + let at = at.map(|(fo, used)| (delay.as_ptr() as usize, fo, used)); if let Some(hit) = at.and_then(|at| self.by_delay.get(&at)) { return hit.clone(); } @@ -174,7 +193,7 @@ struct Located { /// `OfficeArtInlineSpContainer` (Word 97 and later) whose BLIP is decoded. enum Found { Range(Located), - Shape { w: f32, h: f32, blip: officeart::Blip, props: officeart::Props }, + Shape { w: f32, h: f32, key: Option, props: officeart::Props }, } /// `PICF.mfpf.mm` values of pictures stored as a shape (`MM_SHAPE`) or a shape with a file @@ -183,8 +202,8 @@ const MM_SHAPE: u16 = 0x0064; const MM_SHAPEFILE: u16 = 0x0066; /// Locate the picture at `fc_pic` in the Data stream ([MS-DOC] §2.9.192 PICFAndOfficeArtData); -/// a shape's picture is decoded only up to `room` bytes. -fn locate(data: &[u8], fc_pic: u32, room: usize, work: &mut usize) -> Option { +/// the BLIP is decoded once per record and charged to the decode budget. +fn locate(data: &[u8], fc_pic: u32, pics: &mut Pictures) -> Option { let base = fc_pic as usize; let pic = data.get(base..)?; let lcb = u32::from_le_bytes(pic.get(0..4)?.try_into().ok()?) as usize; @@ -203,12 +222,12 @@ fn locate(data: &[u8], fc_pic: u32, room: usize, work: &mut usize) -> Option Option Option<(officeart::Blip, officeart::Props)> { +/// `pib` indexes (BLIPs embedded, or at `foDelay` in the Data stream). The BLIP is decoded +/// once per record, however many pictures (and FBSE sizes) name it. +fn inline_shape(buf: &[u8], data: &[u8], pics: &mut Pictures) -> Option<(Option, officeart::Props)> { let mut budget = 10_000usize; let recs = officeart::children(buf, &mut budget); let sp = recs.iter().find(|r| r.typ == officeart::rt::SP_CONTAINER).map(|r| officeart::shape(r.body, &mut budget))?; let fbses: Vec<&[u8]> = recs.iter().filter(|r| r.typ == officeart::rt::FBSE).map(|r| r.body).collect(); let fbse = sp.props.pib.and_then(|p| fbses.get((p as usize).checked_sub(1)?)).or(fbses.first())?; - let blip = officeart::fbse_blip(fbse, data, room, work)?; - Some((blip, sp.props)) + let key = pics.decode_bse(fbse, data); + Some((key, sp.props)) } /// Offset of the first embedded raster image inside the payload, skipping the small diff --git a/crates/docbin/src/officeart.rs b/crates/docbin/src/officeart.rs index 84d68b9d7f..6d35d929d9 100644 --- a/crates/docbin/src/officeart.rs +++ b/crates/docbin/src/officeart.rs @@ -112,16 +112,50 @@ pub(crate) struct Blip { /// The picture of an `OfficeArtFBSE` body: its embedded BLIP, or the BLIP stored at `foDelay` /// in `delay` (the WordDocument stream for the document's BLIP store, the Data stream for /// inline pictures). `limit` caps the bytes produced. -pub(crate) fn fbse_blip(fbse: &[u8], delay: &[u8], limit: usize, work: &mut usize) -> Option { +#[cfg(test)] +pub(crate) fn fbse_blip(fbse: &[u8], delay: &[u8], limit: usize, work: &mut Decode) -> Option { let (rec, _) = fbse_record(fbse, delay)?; blip(&rec, limit, work) } +/// The document's decode budget: bytes of inflating and pixel decoding left, and how many +/// attempts it has refused (only the first few are logged). +pub(crate) struct Decode { + pub(crate) left: usize, + refused: usize, +} + +impl Decode { + pub(crate) fn new(left: usize) -> Self { + Decode { left, refused: 0 } + } + + /// Take `cost` off the budget; `false` (and a log line) when it doesn't fit. + fn charge(&mut self, cost: usize, what: &str) -> bool { + match self.left.checked_sub(cost) { + Some(left) if cost > 0 => { + self.left = left; + true + } + _ => { + if self.refused < 10 { + log::warn!( + "docbin: a {what} needing {cost} bytes of decode work is over the decode budget ({} left) and was not decoded", + self.left + ); + } + self.refused = self.refused.saturating_add(1); + false + } + } + } +} + /// The BLIP record of an `OfficeArtFBSE`: embedded after it, or in the delay stream, where -/// its (`foDelay`, end) location is returned too (store entries can share one record). +/// its (`foDelay`, record length) location is returned too (entries can share one record, +/// whatever sizes their FBSEs claim: the record's own header is what counts). pub(crate) fn fbse_record<'a>(fbse: &'a [u8], delay: &'a [u8]) -> Option<(Rec<'a>, Option<(usize, usize)>)> { // btWin32, btMacOS, rgbUid[16], tag, size, cRef, foDelay, unused, cbName, unused×2. - let size = u32_at(fbse, 20)? as usize; let fo_delay = u32_at(fbse, 28)? as usize; let cb_name = *fbse.get(33)? as usize; let at = 36usize.checked_add(cb_name)?; @@ -129,15 +163,14 @@ pub(crate) fn fbse_record<'a>(fbse: &'a [u8], delay: &'a [u8]) -> Option<(Rec<'a return Some((rec, None)); } // Not embedded: the record sits in the delay stream. - let end = fo_delay.checked_add(size.max(8))?.min(delay.len()); - let (rec, _) = record(delay.get(fo_delay..end)?)?; - Some((rec, Some((fo_delay, end)))) + let (rec, used) = record(delay.get(fo_delay..)?)?; + Some((rec, Some((fo_delay, used)))) } /// Decode one `OfficeArtBlip*` record ([MS-ODRAW] §2.2.23-§2.2.31). `work` is the /// document's decode budget: inflating a metafile or decoding a DIB is charged to it before /// it runs, whether it succeeds or not, and nothing is decoded once it is spent. -pub(crate) fn blip(rec: &Rec, limit: usize, work: &mut usize) -> Option { +pub(crate) fn blip(rec: &Rec, limit: usize, work: &mut Decode) -> Option { // Each type has two instance values; the odd one carries a second 16-byte UID. let uids = if rec.inst & 1 == 1 { 32 } else { 16 }; match rec.typ { @@ -163,11 +196,12 @@ pub(crate) fn blip(rec: &Rec, limit: usize, work: &mut usize) -> Option { let raw = raw.get(..cb_save.min(raw.len()))?; let cap = limit.min(MAX_METAFILE); let bytes = if compression == 0 { - let want = (if cb_size > 0 { cb_size.min(cap) } else { cap }).min(*work); - if want == 0 { + let want = if cb_size > 0 { cb_size.min(cap) } else { cap }; + // The compressed stream is read whole whatever it inflates to: a record that + // claims to inflate to nothing still costs its input (#3871). + if !work.charge(want.max(raw.len()), "metafile") { return None; } - *work -= want; miniz_oxide::inflate::decompress_to_vec_zlib_with_limit(raw, want).ok()? } else { if raw.len() > cap { @@ -192,7 +226,7 @@ pub(crate) fn blip(rec: &Rec, limit: usize, work: &mut usize) -> Option { /// the 14-byte BMP file header it lacks, is decoded with size limits and re-encoded. When the /// bitmap can't be decoded, or its declared pixels (4 bytes each) don't fit in `work`, the /// BMP bytes are kept (and render as a grey placeholder). -fn dib_to_png(dib: &[u8], limit: usize, work: &mut usize) -> Option { +fn dib_to_png(dib: &[u8], limit: usize, work: &mut Decode) -> Option { let header = u32_at(dib, 0)? as usize; if !(12..=124).contains(&header) { return None; @@ -211,6 +245,20 @@ fn dib_to_png(dib: &[u8], limit: usize, work: &mut usize) -> Option { let masks = if header == 40 && (compression == 3 || compression == 6) { if compression == 3 { 12 } else { 16 } } else { 0 }; let off_bits = 14usize.checked_add(header)?.checked_add(colors * entry)?.checked_add(masks)?; let file_size = 14usize.checked_add(dib.len()).filter(|&size| size <= limit)?; + // The declared size is what decoding allocates, however small the (RLE) data is. + let (w, h) = if header == 12 { + (u64::from(u16_at(dib, 4)?), u64::from(u16_at(dib, 6)?)) + } else { + (u64::from(u32_at(dib, 4)?), u64::from((u32_at(dib, 8)? as i32).unsigned_abs())) + }; + let cost = w.saturating_mul(h).saturating_mul(4).try_into().unwrap_or(usize::MAX); + // Copying the DIB into a BMP is work too, however few pixels it declares: it is charged + // before the copy is made (#3871). A bitmap whose pixels don't fit still keeps its BMP + // bytes as a placeholder, if the copy fits. + let decode = work.charge(cost.max(dib.len()), "bitmap"); + if !decode && !work.charge(dib.len(), "bitmap") { + return None; + } let mut bmp = Vec::with_capacity(file_size); bmp.extend_from_slice(b"BM"); bmp.extend_from_slice(&u32::try_from(file_size).ok()?.to_le_bytes()); @@ -218,16 +266,9 @@ fn dib_to_png(dib: &[u8], limit: usize, work: &mut usize) -> Option { bmp.extend_from_slice(&u32::try_from(off_bits).ok()?.to_le_bytes()); bmp.extend_from_slice(dib); let placeholder = |bmp| Some(Blip { bytes: bmp, ext: "bmp", placeholder: true }); - // The declared size is what decoding allocates, however small the (RLE) data is. - let (w, h) = if header == 12 { - (u64::from(u16_at(dib, 4)?), u64::from(u16_at(dib, 6)?)) - } else { - (u64::from(u32_at(dib, 4)?), u64::from((u32_at(dib, 8)? as i32).unsigned_abs())) - }; - let Some(left) = w.saturating_mul(h).saturating_mul(4).try_into().ok().and_then(|cost: usize| work.checked_sub(cost)) else { + if !decode { return placeholder(bmp); - }; - *work = left; + } let mut reader = image::ImageReader::new(std::io::Cursor::new(&bmp)); reader.set_format(image::ImageFormat::Bmp); let mut limits = image::Limits::default(); diff --git a/crates/docbin/src/tests/drawing.rs b/crates/docbin/src/tests/drawing.rs index 041267c16c..b754206320 100644 --- a/crates/docbin/src/tests/drawing.rs +++ b/crates/docbin/src/tests/drawing.rs @@ -247,7 +247,7 @@ fn dib_becomes_png_and_compressed_metafiles_inflate() { dib.extend_from_slice(&[0, 0, 255, 0, 255, 0, 0, 0]); let r = rec(0, 0x7A8, 0xF01F, &[[0u8; 16].as_slice(), &[0xFF], &dib].concat()); let (dib_rec, _) = officeart::record(&r).unwrap(); - let blip = officeart::blip(&dib_rec, 1 << 20, &mut { usize::MAX }).expect("DIB decodes"); + let blip = officeart::blip(&dib_rec, 1 << 20, &mut officeart::Decode::new(usize::MAX)).expect("DIB decodes"); assert_eq!(blip.ext, "png"); let img = image::load_from_memory(&blip.bytes).unwrap().to_rgb8(); assert_eq!((img.width(), img.height(), img.get_pixel(0, 0).0), (2, 1, [255, 0, 0])); @@ -262,7 +262,7 @@ fn dib_becomes_png_and_compressed_metafiles_inflate() { hdr.extend_from_slice(&[0x00, 0xFE]); // compression = deflate, filter let r = rec(0, 0x216, 0xF01B, &[[0u8; 16].as_slice(), &hdr, &packed].concat()); let (wmf_rec, _) = officeart::record(&r).unwrap(); - let blip = officeart::blip(&wmf_rec, 1 << 20, &mut { usize::MAX }).expect("WMF inflates"); + let blip = officeart::blip(&wmf_rec, 1 << 20, &mut officeart::Decode::new(usize::MAX)).expect("WMF inflates"); assert_eq!((blip.ext, blip.bytes), ("wmf", wmf)); } @@ -281,10 +281,10 @@ fn deeply_nested_groups_and_lying_lengths_are_bounded() { hdr.extend_from_slice(&[0, 0]); let r = rec(0, 0x3D4, 0xF01A, &[[0u8; 16].as_slice(), &hdr, &[0x78, 0x9C, 1, 2, 3]].concat()); let (emf, _) = officeart::record(&r).unwrap(); - assert!(officeart::blip(&emf, 1 << 20, &mut { usize::MAX }).is_none()); + assert!(officeart::blip(&emf, 1 << 20, &mut officeart::Decode::new(usize::MAX)).is_none()); let mut fbse = vec![0u8; 36]; fbse[28..32].copy_from_slice(&u32::MAX.to_le_bytes()); - assert!(officeart::fbse_blip(&fbse, &[0u8; 64], 1 << 20, &mut { usize::MAX }).is_none()); + assert!(officeart::fbse_blip(&fbse, &[0u8; 64], 1 << 20, &mut officeart::Decode::new(usize::MAX)).is_none()); } proptest! { @@ -322,12 +322,12 @@ fn dib_conversion_caps_header_and_encoded_bytes() { let record = rec(0, 0x7A8, officeart::rt::BLIP_DIB, &[[0u8; 16].as_slice(), &[0xFF], &dib].concat()); let (record, _) = officeart::record(&record).unwrap(); let bmp_size = 14 + dib.len(); - assert!(officeart::blip(&record, bmp_size - 1, &mut { usize::MAX }).is_none(), "the BMP header also needs room"); - let fallback = officeart::blip(&record, bmp_size, &mut { usize::MAX }).expect("the BMP fallback fits exactly"); + assert!(officeart::blip(&record, bmp_size - 1, &mut officeart::Decode::new(usize::MAX)).is_none(), "the BMP header also needs room"); + let fallback = officeart::blip(&record, bmp_size, &mut officeart::Decode::new(usize::MAX)).expect("the BMP fallback fits exactly"); assert_eq!((fallback.ext, fallback.placeholder, fallback.bytes.len()), ("bmp", true, bmp_size)); let img = image::load_from_memory(&fallback.bytes).unwrap().to_rgb8(); assert_eq!((img.width(), img.height(), img.get_pixel(0, 0).0), (1, 1, [90, 60, 30])); - let normal = officeart::blip(&record, 512, &mut { usize::MAX }).expect("normal conversion fits"); + let normal = officeart::blip(&record, 512, &mut officeart::Decode::new(usize::MAX)).expect("normal conversion fits"); assert_eq!((normal.ext, normal.placeholder), ("png", false)); assert!(normal.bytes.len() > bmp_size && normal.bytes.len() <= 512); assert_eq!(image::load_from_memory(&normal.bytes).unwrap().width(), 1); @@ -434,10 +434,10 @@ fn store_entries_aliasing_one_delayed_metafile_decode_it_once() { let fbse = delayed_fbse(delay.len(), 0); let store: Vec<&[u8]> = vec![&fbse; 5_000]; let mut pics = crate::media::Pictures::default(); - pics.work = 8 << 20; + pics.work = officeart::Decode::new(8 << 20); let keys: Vec<_> = (1..=store.len() as u32).map(|pib| pics.bse(pib, &store, &delay)).collect(); assert!(keys.iter().all(|k| k.is_some() && *k == keys[0]), "every entry shares one picture"); - assert_eq!(pics.work, (8 << 20) - wmf.len(), "inflated once"); + assert_eq!(pics.work.left, (8 << 20) - wmf.len(), "inflated once"); } /// #3711: distinct metafiles that fail to inflate are charged their declared size anyway, so @@ -456,11 +456,11 @@ fn failing_metafile_inflations_spend_the_decode_budget() { .collect(); let store: Vec<&[u8]> = fbses.iter().map(Vec::as_slice).collect(); let mut pics = crate::media::Pictures::default(); - pics.work = 3 << 20; + pics.work = officeart::Decode::new(3 << 20); for pib in 1..=store.len() as u32 { assert!(pics.bse(pib, &store, &delay).is_none()); } - assert_eq!(pics.work, 0, "three attempts spent the budget; the rest weren't tried"); + assert_eq!(pics.work.left, 0, "three attempts spent the budget; the rest weren't tried"); } /// #3712: a DIB's declared pixels are charged before decoding (an RLE bitmap is tiny on disk but @@ -470,12 +470,13 @@ fn dib_decoding_is_charged_its_declared_pixels() { let blip_of = |dib: &[u8]| rec(0, 0x7A8, officeart::rt::BLIP_DIB, &[[0u8; 16].as_slice(), &[0xFF], dib].concat()); let one = blip_of(&one_pixel_dib()); let (one, _) = officeart::record(&one).unwrap(); - let mut work = 3; - assert_eq!(officeart::blip(&one, 512, &mut work).map(|b| (b.ext, b.placeholder)), Some(("bmp", true))); - assert_eq!(work, 3, "nothing decoded, nothing charged"); - let mut work = 4; + let mut work = officeart::Decode::new(3); + assert!(officeart::blip(&one, 512, &mut work).is_none(), "not even the copy fits: nothing is copied"); + assert_eq!(work.left, 3, "nothing decoded, nothing charged"); + let dib_len = one.body.len() - 17; + let mut work = officeart::Decode::new(dib_len); assert_eq!(officeart::blip(&one, 512, &mut work).map(|b| (b.ext, b.placeholder)), Some(("png", false))); - assert_eq!(work, 0); + assert_eq!(work.left, 0, "the copy is charged, not just 4 bytes a pixel"); // 8-bit RLE declaring 16384 × 16384 (1 GiB decoded) in a few bytes. let mut big = [40u32, 16_384, 16_384].iter().flat_map(|v| v.to_le_bytes()).collect::>(); @@ -489,7 +490,128 @@ fn dib_decoding_is_charged_its_declared_pixels() { big.extend_from_slice(&[0, 0, 0, 1]); // end of line, end of bitmap let big = blip_of(&big); let (big, _) = officeart::record(&big).unwrap(); - let mut work = 512 << 20; + let mut work = officeart::Decode::new(512 << 20); assert_eq!(officeart::blip(&big, 1 << 20, &mut work).map(|b| (b.ext, b.placeholder)), Some(("bmp", true))); - assert_eq!(work, 512 << 20); + assert_eq!(work.left, (512 << 20) - (big.body.len() - 17), "only the copy of the DIB was charged, not its declared pixels"); +} + +/// An EMF `OfficeArtBlip` record (compressed) with `raw` as its stream, claiming `cb_size`. +fn emf_blip(cb_size: u32, raw: &[u8]) -> Vec { + let mut hdr = vec![0u8; 34]; + hdr[0..4].copy_from_slice(&cb_size.to_le_bytes()); + hdr[28..32].copy_from_slice(&(raw.len() as u32).to_le_bytes()); + hdr[32] = 0; // compressed + hdr[33] = 0xFE; + rec(0, 0x3D4, 0xF01A, &[[0u8; 16].as_slice(), &hdr, raw].concat()) +} + +/// #3871 review: the zlib input is charged to the decode budget, not just what it inflates to. +#[test] +fn compressed_metafile_input_is_charged_to_the_decode_budget() { + let raw = vec![0x5Au8; 1 << 20]; // not even zlib: costs its input all the same + let r = emf_blip(1, &raw); + let (emf, _) = officeart::record(&r).unwrap(); + let mut work = officeart::Decode::new(10 << 20); + assert!(officeart::blip(&emf, 64 << 20, &mut work).is_none()); + assert_eq!(work.left, (10 << 20) - raw.len(), "1 MB of input charged, however little it claims to inflate to"); + // Ten more of them exhaust the budget; none is inflated past it. + let mut work = officeart::Decode::new(3 << 20); + for _ in 0..10 { + let _ = officeart::blip(&emf, 64 << 20, &mut work); + } + assert_eq!(work.left, 0, "three attempts fit, the rest are refused"); +} + +/// An inline `PICFAndOfficeArtData` (shape, FBSE) whose BLIP sits at `fo_delay` in Data; the +/// FBSE claims `size` bytes. +fn inline_pic(fo_delay: u32, size: u32) -> Vec { + let mut fbse = vec![0u8; 36]; + fbse[20..24].copy_from_slice(&size.to_le_bytes()); + fbse[28..32].copy_from_slice(&fo_delay.to_le_bytes()); + let body = [container(0xF004, &[]), rec(2, 6, 0xF007, &fbse)].concat(); + let mut picf = vec![0u8; 0x44]; + picf[0..4].copy_from_slice(&((0x44 + body.len()) as u32).to_le_bytes()); + picf[4..6].copy_from_slice(&0x44u16.to_le_bytes()); + picf[6..8].copy_from_slice(&0x64u16.to_le_bytes()); // MM_SHAPE + picf[28..30].copy_from_slice(&1440i16.to_le_bytes()); + picf[30..32].copy_from_slice(&1440i16.to_le_bytes()); + [picf, body].concat() +} + +/// #3871 review: inline pictures that alias one BLIP record share one decode, whatever sizes +/// their FBSEs claim and wherever their PICFs sit. +#[test] +fn inline_pictures_aliasing_one_blip_record_are_decoded_once() { + let blip = emf_blip(1, &vec![0x5Au8; 64 << 10]); + let a = inline_pic(0, 70_000); + let b = inline_pic(0, 80_000); + let c = inline_pic(0, 90_000); + let blip_at = (a.len() + b.len() + c.len()) as u32; + let (a, b, c) = (inline_pic(blip_at, 70_000), inline_pic(blip_at, 80_000), inline_pic(blip_at, 90_000)); + let data = [a.clone(), b.clone(), c, blip].concat(); + let mut pics = crate::media::Pictures::default(); + let start = pics.work.left; + let _ = pics.get(&data, 0); + let _ = pics.get(&data, a.len() as u32); + let _ = pics.get(&data, (a.len() + b.len()) as u32); + assert_eq!(start - pics.work.left, 64 << 10, "one record, one charge"); +} + +/// A 1-bit-per-pixel DIB of `side`×`side` pixels with `bytes` of pixel data. +fn dib_1bpp(side: u32, bytes: usize) -> Vec { + let mut d = vec![0u8; 40]; + d[0..4].copy_from_slice(&40u32.to_le_bytes()); + d[4..8].copy_from_slice(&side.to_le_bytes()); + d[8..12].copy_from_slice(&side.to_le_bytes()); + d[12..14].copy_from_slice(&1u16.to_le_bytes()); + d[14..16].copy_from_slice(&1u16.to_le_bytes()); + d.extend_from_slice(&[0, 0, 0, 0, 255, 255, 255, 0]); // palette + d.extend(std::iter::repeat_n(0u8, bytes)); + d +} + +fn dib_record(dib: &[u8]) -> Vec { + rec(0, 0x7A8, 0xF01F, &[[0u8; 16].as_slice(), &[0xFF], dib].concat()) +} + +/// #3871 review: the decode budget scales with the input, so a document of many legitimate +/// large scans decodes them all; a high-ratio set is still capped. +#[test] +fn the_decode_budget_scales_with_the_input_but_still_caps_bombs() { + let floor = 1 << 20; + // 20 legitimate 1024² 1-bit scans: 4 MB charged each, 128 KB of file each. + let real = dib_record(&dib_1bpp(1024, 1024 * 1024 / 8)); + let (rec_real, _) = officeart::record(&real).unwrap(); + let input = 20 * real.len(); + let mut work = officeart::Decode::new(crate::media::decode_work(input, floor)); + for i in 0..20 { + let b = officeart::blip(&rec_real, 64 << 20, &mut work).unwrap(); + assert!(!b.placeholder, "scan {i} decodes"); + } + // Declared 20000² (1.6 GB each) in a few dozen bytes each: a placeholder, never decoded. + let bomb = dib_record(&dib_1bpp(20_000, 16)); + let (rec_bomb, _) = officeart::record(&bomb).unwrap(); + let mut work = officeart::Decode::new(crate::media::decode_work(20 * bomb.len(), floor)); + for _ in 0..20 { + assert!(officeart::blip(&rec_bomb, 64 << 20, &mut work).unwrap().placeholder); + } + assert!(work.left <= crate::media::decode_work(20 * bomb.len(), floor), "nothing was refunded or invented"); +} + +/// #3871 review: the copy of a DIB into a BMP is charged before it is made, however few pixels +/// the header declares; with the budget spent nothing is copied at all. +#[test] +fn a_dib_with_a_large_tail_is_charged_for_its_copy() { + let big = dib_record(&dib_1bpp(1, 1 << 20)); // 1 pixel declared, 1 MB of trailing bytes + let (big, _) = officeart::record(&big).unwrap(); + let dib_len = big.body.len() - 17; + let mut work = officeart::Decode::new(10 << 20); + assert!(!officeart::blip(&big, 64 << 20, &mut work).unwrap().placeholder); + assert_eq!(10 << 20, work.left + dib_len, "charged for the bytes, not just 4 per pixel"); + // Room for the copy: decoded. Not even that: nothing is copied. + let mut work = officeart::Decode::new(dib_len + 1); + assert!(officeart::blip(&big, 64 << 20, &mut work).is_some()); + let mut work = officeart::Decode::new(dib_len - 1); + assert!(officeart::blip(&big, 64 << 20, &mut work).is_none()); + assert_eq!(work.left, dib_len - 1); } From 328a58dd038a0121525a26c4cb221751c84b50a7 Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Mon, 26 Oct 2026 02:43:10 +0000 Subject: [PATCH 183/199] =?UTF-8?q?#3869:=20review=20=E2=80=94=20hint=5Fst?= =?UTF-8?q?rike=20is=20native-only=20like=20HINT=5FSTRIKE=20(wasm=20build)?= =?UTF-8?q?;=20composite=20and=200x7FFF=20contour-count=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01HUodYU9r4mPHed4pPsjHC2 --- crates/fonts/src/fontdb.rs | 2 ++ crates/fonts/src/fontdb/user.rs | 10 +++++++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs index 8ab5451e8b..9e8472bc30 100644 --- a/crates/fonts/src/fontdb.rs +++ b/crates/fonts/src/fontdb.rs @@ -96,6 +96,7 @@ pub struct FontFace { hint_slow: std::sync::atomic::AtomicU32, hinting_off: std::sync::atomic::AtomicBool, /// A hinted draw slower than this is a strike ([`HINT_STRIKE`]; tests shorten it). + #[cfg(not(target_arch = "wasm32"))] hint_strike: std::time::Duration, } @@ -1025,6 +1026,7 @@ fn make_face(bytes: FontBytes, index: u32, family: String, style: String, legacy hint_draws: Default::default(), hint_slow: Default::default(), hinting_off: Default::default(), + #[cfg(not(target_arch = "wasm32"))] hint_strike: HINT_STRIKE, }) } diff --git a/crates/fonts/src/fontdb/user.rs b/crates/fonts/src/fontdb/user.rs index 8e0da9e524..f7cd0fd918 100644 --- a/crates/fonts/src/fontdb/user.rs +++ b/crates/fonts/src/fontdb/user.rs @@ -813,12 +813,20 @@ pub(crate) mod tests { // #3703: the decoder reads the contour count as unsigned (only 0xFFFF is a composite), so // 0x8000..=0xFFFE are that many contours; read as negative they counted no points and the // decoder then allocated about 1 GB for a 1.3 MB font. - for n in [0x8000u16, 0xFFFE] { + for n in [0x7FFFu16, 0x8000, 0xFFFE] { let file = woff2_glyph_with_contours(n, &vec![3; usize::from(n)], 3 * usize::from(n)); assert_eq!(decode_font_file(&file), Err(UserFontError::BadWebFont(TOO_MANY_POINTS.into())), "{n:#x}"); } } + #[test] + fn a_woff2_composite_glyph_is_not_counted_as_having_points() { + // 0xFFFF marks a composite glyph: the point check skips it (the decoder reads its own + // streams), so it is not refused for its points. + let file = woff2_glyph_with_contours(0xFFFF, &[], 0); + assert!(matches!(decode_font_file(&file), Err(UserFontError::BadWebFont(e)) if e != TOO_MANY_POINTS)); + } + #[test] fn an_added_font_is_listed_resolved_and_measured() { let db = db(); From 45c4197bc16da9069c0936b4c029b74b9aea3bf5 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 02:44:26 +0000 Subject: [PATCH 184/199] Restrict Editing: the protection list and password fields carry their labels' names --- crates/ui-egui/src/dialogs_review.rs | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/crates/ui-egui/src/dialogs_review.rs b/crates/ui-egui/src/dialogs_review.rs index 7cb3e9e13f..b1f38ccb9e 100644 --- a/crates/ui-egui/src/dialogs_review.rs +++ b/crates/ui-egui/src/dialogs_review.rs @@ -643,14 +643,16 @@ fn setup_ui(app: &mut WordApp, ui: &mut Ui, st: &mut RestrictPane) { }); } heading(ui, "2. Editing restrictions"); - ui.checkbox(&mut st.editing, tl!("Only allow this kind of editing:")); + let editing = ui.checkbox(&mut st.editing, tl!("Only allow this kind of editing:")); ui.add_enabled_ui(st.editing, |ui| { let current = EDIT_MODES.get(st.mode).map(|m| m.1).unwrap_or(""); - egui::ComboBox::from_id_salt("restrict_mode").selected_text(tl!(current)).width(ui.available_width().min(220.0)).show_ui(ui, |ui| { - for (i, (_, label)) in EDIT_MODES.iter().enumerate() { - ui.selectable_value(&mut st.mode, i, tl!(label)); - } - }); + let combo = + egui::ComboBox::from_id_salt("restrict_mode").selected_text(tl!(current)).width(ui.available_width().min(220.0)).show_ui(ui, |ui| { + for (i, (_, label)) in EDIT_MODES.iter().enumerate() { + ui.selectable_value(&mut st.mode, i, tl!(label)); + } + }); + combo.response.labelled_by(editing.id); }); let mode = EDIT_MODES.get(st.mode).map(|m| m.0).unwrap_or("readOnly"); if st.editing && matches!(mode, "readOnly" | "comments") { @@ -718,11 +720,11 @@ fn setup_ui(app: &mut WordApp, ui: &mut Ui, st: &mut RestrictPane) { /// Each label above its field, so both fit the narrowest pane. fn passwords(ui: &mut Ui, st: &mut RestrictPane, confirm: bool) { let width = ui.available_width().min(220.0); - ui.label(tl!("Password:")); - ui.add(egui::TextEdit::singleline(st.password.as_mut_string()).password(true).desired_width(width)); + let label = ui.label(tl!("Password:")); + ui.add(egui::TextEdit::singleline(st.password.as_mut_string()).password(true).desired_width(width)).labelled_by(label.id); if confirm { - ui.label(tl!("Confirm password:")); - ui.add(egui::TextEdit::singleline(st.confirm.as_mut_string()).password(true).desired_width(width)); + let label = ui.label(tl!("Confirm password:")); + ui.add(egui::TextEdit::singleline(st.confirm.as_mut_string()).password(true).desired_width(width)).labelled_by(label.id); } } From 8bc825825f7fc9c2da839d8b3c3f8e7b40e8bf77 Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Mon, 26 Oct 2026 02:50:03 +0000 Subject: [PATCH 185/199] =?UTF-8?q?#3876:=20review=20=E2=80=94=20scan=5Fta?= =?UTF-8?q?bles=20allowed=20unused=20off=20Linux;=20sandbox=20and=20empty-?= =?UTF-8?q?table=20comments=20match=20the=20refusal=20rule?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01HUodYU9r4mPHed4pPsjHC2 --- crates/zotero/src/peer.rs | 9 +++++---- docs/zotero.md | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/crates/zotero/src/peer.rs b/crates/zotero/src/peer.rs index 3175487a05..09abd8de43 100644 --- a/crates/zotero/src/peer.rs +++ b/crates/zotero/src/peer.rs @@ -7,7 +7,7 @@ //! port while Zotero wasn't running is refused. A listener the tables don't show (WSL2 //! mirrored networking with Zotero on Windows, a local proxy) leaves only the loopback rule, //! but only when the tables were read in full: a read error, or an open error other than -//! "not found" (a sandbox that denies `/proc/net/tcp`: AppArmor, Snap, Flatpak), refuses. +//! "not found" (e.g. a sandbox profile that denies `/proc/net/tcp`), refuses. //! //! On Windows, macOS and the BSDs there is **no same-user check**: the loopback rule is the //! only one, so any local account's listener on the port is taken for Zotero. Those systems have @@ -54,6 +54,7 @@ fn owner_check(_local: SocketAddr, _peer: SocketAddr) -> Result<(), ZoteroError> /// Scan the tables named by `paths` (opened by `open`) in order, stopping once both rows are /// found. A table that doesn't exist (no IPv6) is skipped; any other open or read error refuses. +#[cfg_attr(not(any(target_os = "linux", target_os = "android")), allow(dead_code))] fn scan_tables( paths: [&str; 2], open: impl Fn(&str) -> std::io::Result, @@ -153,7 +154,7 @@ fn read_line_capped(r: &mut impl std::io::BufRead, out: &mut Vec) -> std::io /// Refuse when the connection's rows show another user. Our uid is our own socket's row, else /// `me` (this process's). When the listener isn't listed in a table that was read in full (WSL2, -/// a proxy, a sandbox) or our uid is unknown, its owner is unknown and the loopback rule decides. +/// a proxy) or our uid is unknown, its owner is unknown and the loopback rule decides. fn decide(rows: &Rows, peer: SocketAddr, me: Option) -> Result<(), ZoteroError> { match (rows.ours.or(me), rows.theirs) { (Some(a), Some(b)) if a == b => Ok(()), @@ -248,14 +249,14 @@ mod tests { assert_eq!(same_owner(&junk, local, peer, None), Ok(())); } - /// WSL2 mirrored networking (Zotero on Windows), a local proxy or a sandbox: the listener + /// WSL2 mirrored networking (Zotero on Windows) or a local proxy: the listener /// isn't in our table, so the loopback rule decides, as on other systems. #[test] fn an_unlisted_listener_falls_back_to_the_loopback_rule() { let (local, peer) = addrs(); let missing = format!("{HEADER}{}", row(local, peer, 1000)); assert_eq!(same_owner(&missing, local, peer, None), Ok(())); - // The table couldn't be read. + // An empty table: the listener isn't listed. assert_eq!(same_owner("", local, peer, None), Ok(())); assert_eq!(same_owner("", local, peer, Some(1000)), Ok(())); } diff --git a/docs/zotero.md b/docs/zotero.md index 9a0eb14fb3..4e96520ad5 100644 --- a/docs/zotero.md +++ b/docs/zotero.md @@ -77,7 +77,7 @@ isn't another user's. On Windows and macOS (and the BSDs) the loopback rule is t listener (WSL2 mirrored networking with Zotero running on Windows, a local proxy), its owner is unknown and only the loopback rule applies, as below. That holds only when the tables were read in full: a read error, or an open error other than "not found", **refuses**. Sandboxes - that deny `/proc/net/tcp` (AppArmor, Snap, Flatpak confinement) are therefore refused. + that deny `/proc/net/tcp` (some AppArmor or Snap profiles, for example) are therefore refused. - Windows, macOS and the BSDs: **no same-user check.** They have no such table to read without OS calls WordCraft doesn't make (it uses no `unsafe` code), so only the loopback rule applies there (and on Linux when the listener isn't in the table). Remaining risk: on a From e9dd2520965527cdd12838ee38124191d74b805f Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Mon, 26 Oct 2026 02:46:53 +0000 Subject: [PATCH 186/199] =?UTF-8?q?#3866:=20review=20=E2=80=94=20PDF=20exp?= =?UTF-8?q?ort=20builds=20the=20revision=20authors=20and=20clipped=20cells?= =?UTF-8?q?=20once=20per=20page;=20part=5Fpieces=20finds=20a=20paragraph's?= =?UTF-8?q?=20first=20page=20from=20one=20index;=20grid=5Fcols=20once;=20b?= =?UTF-8?q?alloon=20colour=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/layout/src/display.rs | 72 ++++++++++--- crates/layout/src/hit.rs | 51 ++++++--- crates/layout/src/table.rs | 6 +- crates/layout/src/tests_linear.rs | 171 ++++++++++++++++++++++++++++++ crates/pdf/src/lib.rs | 48 +-------- crates/pdf/src/tests.rs | 42 +------- 6 files changed, 278 insertions(+), 112 deletions(-) diff --git a/crates/layout/src/display.rs b/crates/layout/src/display.rs index ca83e65f82..827ad8f734 100644 --- a/crates/layout/src/display.rs +++ b/crates/layout/src/display.rs @@ -533,12 +533,42 @@ pub fn page_display(doc: &Document, page: &Page, opts: &DisplayOptions) -> Vec { + doc: &'a Document, + page: &'a Page, + authors: Authors<'a>, + clips: Clips, +} + +impl<'a> ItemDisplay<'a> { + pub fn new(doc: &'a Document, page: &'a Page) -> Self { + let cells: Vec = + page.header.iter().chain(&page.footer).chain(&page.items).filter(|c| matches!(c, Placed::Cell { clip: true, .. })).cloned().collect(); + ItemDisplay { doc, page, authors: Authors::new(doc), clips: clipped_cells(&cells) } + } + + /// The draw items of `it` alone, as [`page_display`] gives them for a page holding just it + /// (and, for a line in a clipped cell, the cell's clip). + pub fn draws(&self, it: &Placed, opts: &DisplayOptions) -> Vec { + let mut out = Vec::new(); + let items = std::slice::from_ref(it); + let (_, ba) = dimming(opts); + stories_display(self.doc, &[items], Some(&self.clips), opts, &self.authors, ba, &mut out); + body_change_bars(self.doc, self.page, items, opts, ba, &mut out); + out + } +} + /// A page's draw items as two layers, its headers and footers then its body, each at full /// strength with the opacity to draw it at. A dimmed layer is faded as one picture, as Word dims /// it: where its lines or fills overlap (a table's borders meeting) they don't darken each other. @@ -546,9 +576,9 @@ pub fn page_layers(doc: &Document, page: &Page, opts: &DisplayOptions) -> [(f32, let (ha, ba) = dimming(opts); let (mut hf, mut body) = (Vec::new(), Vec::new()); let authors = Authors::new(doc); - stories_display(doc, &[&page.header, &page.footer], opts, &authors, 1.0, &mut hf); - stories_display(doc, &[&page.items], opts, &authors, 1.0, &mut body); - body_change_bars(doc, page, opts, 1.0, &mut body); + stories_display(doc, &[&page.header[..], &page.footer[..]], None, opts, &authors, 1.0, &mut hf); + stories_display(doc, &[&page.items[..]], None, opts, &authors, 1.0, &mut body); + body_change_bars(doc, page, &page.items, opts, 1.0, &mut body); [(ha, hf), (ba, body)] } @@ -558,18 +588,34 @@ fn dimming(opts: &DisplayOptions) -> (f32, f32) { (a(opts.dim_header), a(opts.dim_body)) } -fn body_change_bars(doc: &Document, page: &Page, opts: &DisplayOptions, alpha: f32, out: &mut Vec) { +fn body_change_bars(doc: &Document, page: &Page, items: &[Placed], opts: &DisplayOptions, alpha: f32, out: &mut Vec) { if opts.markup && (opts.revisions.insertions_deletions || opts.revisions.formatting) && opts.revisions.changed_lines != ChangeBar::None { - change_bars(doc, page, &opts.revisions, alpha, out); + change_bars(doc, page, items, &opts.revisions, alpha, out); } } /// Draw items for the placed items of `stories`, faded by `a`. -fn stories_display(doc: &Document, stories: &[&Vec], opts: &DisplayOptions, authors: &Authors, a: f32, out: &mut Vec) { +/// `shared` gives the page's clipped cells when the caller worked them out already. +fn stories_display( + doc: &Document, + stories: &[&[Placed]], + shared: Option<&Clips>, + opts: &DisplayOptions, + authors: &Authors, + a: f32, + out: &mut Vec, +) { let hidden = opts.hide_object.as_ref().map(|p| Hidden::new(doc, p)); for items in stories { let warps = warped_boxes(doc, items); - let clips = clipped_cells(items); + let own; + let clips = match shared { + Some(c) => c, + None => { + own = clipped_cells(items); + &own + } + }; let mut done: Vec = Vec::new(); for it in items.iter() { // The object being dragged is drawn by its preview instead: leave it out here. @@ -577,7 +623,7 @@ fn stories_display(doc: &Document, stories: &[&Vec], opts: &DisplayOptio continue; } if let Placed::Lines { story, path, .. } = it - && let Some(rect) = cell_clip(&clips, *story, &path.0) + && let Some(rect) = cell_clip(clips, *story, &path.0) { let mut inner = Vec::new(); item(doc, it, opts, authors, a, &mut inner); @@ -726,7 +772,7 @@ pub const CHANGE_BAR: Rgb = Rgb(0x50, 0x50, 0x50); /// Bars in the margin beside body lines with tracked insertions or deletions, or (when shown) /// tracked formatting changes of their text, paragraph or paragraph mark. -fn change_bars(doc: &Document, page: &Page, m: &MarkupOptions, alpha: f32, out: &mut Vec) { +fn change_bars(doc: &Document, page: &Page, items: &[Placed], m: &MarkupOptions, alpha: f32, out: &mut Vec) { // Outside: the left margin, or the outer one of a right-hand page with mirrored margins. let right = match m.changed_lines { ChangeBar::Right => true, @@ -735,7 +781,7 @@ fn change_bars(doc: &Document, page: &Page, m: &MarkupOptions, alpha: f32, out: }; let bx = if right { page.body.right() + 9.0 } else { page.body.x - 9.0 }; let color = m.changed_lines_color.unwrap_or(CHANGE_BAR); - for it in &page.items { + for it in items { let Placed::Lines { story, path, para: pl, l0, l1, y, turn, .. } = it else { continue }; if turn.is_turned() { continue; diff --git a/crates/layout/src/hit.rs b/crates/layout/src/hit.rs index bb0955ad8b..2b1bb66d4c 100644 --- a/crates/layout/src/hit.rs +++ b/crates/layout/src/hit.rs @@ -147,17 +147,40 @@ fn text_at(p: &Page, index: usize, x: f32, y: f32) -> Option { struct PartPages<'a> { story: StoryRef, pages: HashMap>>, + /// The first page that shows each paragraph, made the first time one is asked for, in one + /// walk of the pages (not one walk of them for every paragraph). + first: Option>, } impl<'a> PartPages<'a> { fn new(story: StoryRef) -> Self { - PartPages { story, pages: HashMap::new() } + PartPages { story, pages: HashMap::new(), first: None } + } + + /// The first page of `lay` that shows a piece of the paragraph at `path`. + fn first_page(&mut self, lay: &'a DocLayout, path: &Path) -> Option { + let story = self.story; + let first = self.first.get_or_insert_with(|| { + let mut first: HashMap<&'a Path, usize> = HashMap::new(); + for (pi, p) in lay.pages.iter().enumerate() { + for it in items_of(p, story) { + if let Placed::Lines { story: s, path, .. } = it + && *s == story + { + first.entry(path).or_insert(pi); + } + } + } + first + }); + first.get(path).copied() } /// The pieces of the paragraph at `path` on page `pi` of `lay`, as [`DocLayout::pieces_on`] /// gives them. fn on(&mut self, lay: &'a DocLayout, pi: usize, path: &Path) -> Vec<(usize, &'a Placed)> { let Some(p) = lay.pages.get(pi) else { return Vec::new() }; + crate::work::steps(1); let story = self.story; let index = self.pages.entry(pi).or_insert_with(|| { let mut index: HashMap<&'a Path, Vec<&'a Placed>> = HashMap::new(); @@ -460,21 +483,24 @@ impl DocLayout { .map(|v| v.iter().filter_map(|(pi, ii)| self.pages.get(*pi).and_then(|p| p.items.get(*ii)).map(|it| (*pi, it))).collect()) .unwrap_or_default(); } - self.part_pieces(page_hint, &mut |pi| self.pieces_on(pi, story, path)) + let mut first = || (0..self.pages.len()).find(|pi| !self.pieces_on(*pi, story, path).is_empty()); + self.part_pieces(page_hint, &mut first, &mut |pi| self.pieces_on(pi, story, path)) } /// Pieces of a paragraph of a story part (see [`Self::pieces`]), `on` giving those on a page. - fn part_pieces<'a>(&'a self, page_hint: usize, on: &mut dyn FnMut(usize) -> Vec<(usize, &'a Placed)>) -> Vec<(usize, &'a Placed)> { + fn part_pieces<'a>( + &'a self, + page_hint: usize, + first: &mut dyn FnMut() -> Option, + on: &mut dyn FnMut(usize) -> Vec<(usize, &'a Placed)>, + ) -> Vec<(usize, &'a Placed)> { // Headers/footers repeat: prefer the hinted page, else the first that shows the part. - let mut order: Vec = (0..self.pages.len()).collect(); - if page_hint < order.len() { - order.retain(|p| *p != page_hint); - order.insert(0, page_hint); - } - for pi in order { - let mut found = on(pi); + let hinted = if page_hint < self.pages.len() { on(page_hint) } else { Vec::new() }; + let start = if hinted.is_empty() { first() } else { Some(page_hint) }; + if let Some(pi) = start { + let mut found = if hinted.is_empty() { on(pi) } else { hinted }; if found.is_empty() { - continue; + return found; } // A note continued over pages has the rest of the paragraph on the pages before or // after (a repeated header shows all of it on every page: one page is enough). @@ -517,7 +543,8 @@ impl DocLayout { if pages.story == StoryRef::Body { return self.pieces(StoryRef::Body, path, page_hint); } - self.part_pieces(page_hint, &mut |pi| pages.on(self, pi, path)) + let first = pages.first_page(self, path); + self.part_pieces(page_hint, &mut || first, &mut |pi| pages.on(self, pi, path)) } /// Pieces of a paragraph of story part `story` on page `pi`. diff --git a/crates/layout/src/table.rs b/crates/layout/src/table.rs index 169eda6b9f..08e50361cc 100644 --- a/crates/layout/src/table.rs +++ b/crates/layout/src/table.rs @@ -1129,7 +1129,9 @@ fn region_text(st: &TableStyleProps, region: Region) -> CellText { /// with their own. `None` when every cell is empty. fn column_content(ctx: &mut Ctx, t: &Table, style: Option<&TableStyleProps>, depth: usize) -> Option> { let margins_def = default_margins(t, style); - let mut cols = vec![(0.0f32, 0.0f32); grid_cols(t).max(1)]; + // The grid's width once: counting it walks every row. + let ncols = grid_cols(t); + let mut cols = vec![(0.0f32, 0.0f32); ncols.max(1)]; let mut spanning = Vec::new(); let mut any = false; for (ri, row) in t.rows.iter().enumerate() { @@ -1151,8 +1153,6 @@ fn column_content(ctx: &mut Ctx, t: &Table, style: Option<&TableStyleProps>, dep } spanning.extend(row_gaps(row, g).map(|(g, span, w)| (g, span, (w, w)))); } - // The grid's width once: counting it walks every row, and there can be a spanning cell in each. - let ncols = grid_cols(t); for (g, span, need) in spanning { let Some(cs) = cols.get_mut(g..g.saturating_add(span).min(ncols)) else { continue }; let n = cs.len().max(1) as f32; diff --git a/crates/layout/src/tests_linear.rs b/crates/layout/src/tests_linear.rs index e49409693c..b242b00c3c 100644 --- a/crates/layout/src/tests_linear.rs +++ b/crates/layout/src/tests_linear.rs @@ -201,6 +201,35 @@ fn selecting_a_long_header_takes_linear_time() { assert_linear_steps("header selection", 300, laid, |(d, l, (a, b))| drop(std::hint::black_box(l.selection_rects(d, a, b, 0)))); } +/// An endnote of `n` paragraphs, a few lines each, continued over many pages. +fn long_endnote(n: usize) -> (Document, u32) { + let mut d = Document::from_text("Body"); + // One huge letter to a paragraph, one line high enough for a page, so the pages are as many as + // the paragraphs and each paragraph costs next to nothing. + let huge = CharProps { size: Some(400.0), ..Default::default() }; + let paras = (0..n).map(|_| wordcraft_doc::para_block(Paragraph::with_text("n", huge.clone()))).collect(); + let id = d.add_part(wordcraft_doc::PartKind::Endnote, paras); + let r = InlineObject::NoteRef { kind: wordcraft_doc::para::NoteKind::Endnote, id, custom: String::new() }; + d.insert_object(&Pos::body(0, 4), r, &Default::default()).unwrap(); + (d, id) +} + +/// #3866: selecting across an endnote over many pages probed the pages from the first for each +/// paragraph (paragraphs x pages). +#[test] +fn selecting_a_long_endnote_takes_linear_time() { + let laid = |n: usize| { + let (d, id) = long_endnote(n); + let l = lay(&d, &LayoutOptions::default()); + let ends = (Pos::new(StoryRef::Part(id), Path::top(0), 0), Pos::new(StoryRef::Part(id), Path::top(n - 1), 2)); + (d, l, ends) + }; + let (d, l, (a, b)) = laid(SCALE * 20); + assert!(l.pages.len() >= SCALE * 20 / 2, "the endnote runs over many pages: {}", l.pages.len()); + assert!(l.selection_rects(&d, &a, &b, 0).len() >= SCALE * 20, "a rectangle per paragraph"); + assert_linear_steps("endnote selection", 20, laid, |(d, l, (a, b))| drop(std::hint::black_box(l.selection_rects(d, a, b, 0)))); +} + /// A table of `n` rows of 60 cells, each row exactly half a point high, so every cell clips its /// (empty) paragraph and the whole table fits on the first page. fn clipped_table(n: usize) -> Document { @@ -294,3 +323,145 @@ fn balloons_for_many_authors_lay_out_in_linear_time() { let opts = LayoutOptions { comment_balloons: true, ..Default::default() }; assert_layout_linear("comment balloons", 120, doc, &opts); } + +/// `n` one-word paragraphs, each inserted by its own author, small enough for one page. +fn tracked_paras(n: usize) -> Document { + let mut d = Document::from_text("x"); + d.body = (0..n) + .map(|i| { + let run = Run { len: 1, props: CharProps { ins: u32::try_from(i).ok(), size: Some(1.0), ..Default::default() } }; + let mut p = Paragraph { runs: vec![run], ..Paragraph::with_text("w", CharProps::default()) }; + p.mark.size = Some(1.0); + p.props.space_before = Some(0.0); + p.props.space_after = Some(0.0); + wordcraft_doc::para_block(p) + }) + .collect(); + d.revisions = (0..n).map(|i| wordcraft_doc::Revision::new(wordcraft_doc::RevisionKind::Insert, format!("Author {i}"), "")).collect(); + d +} + +/// Draws every item of the first page on its own, as the PDF export does. +fn draw_items((d, l): &(Document, DocLayout), opts: &display::DisplayOptions) { + let Some(p) = l.pages.first() else { return }; + let shown = display::ItemDisplay::new(d, p); + for it in p.header.iter().chain(&p.footer).chain(&p.items) { + std::hint::black_box(shown.draws(it, opts)); + } +} + +/// #3866: the PDF export drew each item with a `page_display` of its own, which ranked the revision +/// authors again for every item with a tracked change (lines x revisions). +#[test] +fn drawing_items_one_by_one_ranks_the_authors_once() { + let laid = |n: usize| { + let d = tracked_paras(n); + let l = lay(&d, &LayoutOptions::default()); + (d, l) + }; + assert_eq!(laid(SCALE * 40).1.pages.len(), 1); + let opts = display::DisplayOptions { markup: true, ..Default::default() }; + assert_linear_steps("items drawn one by one", 40, laid, |dl| draw_items(dl, &opts)); +} + +/// #3866: the same for the clipped cells, which each item's `page_display` collected again +/// (lines x clipped cells). +#[test] +fn drawing_items_one_by_one_collects_the_clipped_cells_once() { + let laid = |n: usize| { + let d = clipped_table(n); + let l = lay(&d, &LayoutOptions::default()); + (d, l) + }; + let opts = display::DisplayOptions::default(); + assert_linear_steps("clipped cells, items one by one", 16, laid, |dl| draw_items(dl, &opts)); +} + +/// #3866: a comment's balloon is in its author's colour, authors numbered in order of appearance. +#[test] +fn balloons_are_in_their_authors_colours() { + let mut d = Document::from_text("one two three"); + for (i, a) in ["Ana", "Ben", "Ana"].into_iter().enumerate() { + let part = d.add_part(wordcraft_doc::PartKind::Comment, vec![wordcraft_doc::para_block(Paragraph::with_text("c", CharProps::default()))]); + let id = u32::try_from(i).unwrap(); + d.comments.insert(id, wordcraft_doc::Comment { author: a.into(), part, ..Default::default() }); + let p = CharProps::default(); + d.insert_object(&Pos::body(0, 0), InlineObject::CommentEnd { id }, &p).unwrap(); + d.insert_object(&Pos::body(0, 0), InlineObject::CommentStart { id }, &p).unwrap(); + } + let l = lay(&d, &LayoutOptions { comment_balloons: true, ..Default::default() }); + // Each card's stripe is a 3 pt wide fill in the author's colour; the cards run down the page. + let mut stripes: Vec<_> = l.pages[0] + .items + .iter() + .filter_map(|it| match it { + Placed::Fill { rect, color } if rect.w == 3.0 => Some((rect.y, *color)), + _ => None, + }) + .collect(); + stripes.sort_by(|a, b| a.0.total_cmp(&b.0)); + let (ana, ben) = (display::revision_color(0), display::revision_color(1)); + assert_ne!(ana, ben); + assert_eq!(stripes.iter().map(|s| s.1).collect::>(), [ana, ben, ana]); +} + +/// A page with tracked changes by several authors in its body and header, and exact-height +/// tables, one nested in a cell of another, whose cells clip their lines. +fn mixed_page() -> (Document, DocLayout) { + let mut d = tracked_paras(6); + let mut hp = Paragraph::with_text("head", CharProps::default()); + hp.runs = vec![Run { len: 4, props: CharProps { ins: Some(2), ..Default::default() } }]; + let id = d.add_part(wordcraft_doc::PartKind::Header, vec![wordcraft_doc::para_block(hp)]); + d.last_section.headers.default = Some(id); + let exact = |rows, cols| { + let mut t = Table::new(rows, cols, 200.0); + for row in &mut t.rows { + row.props.height = Some(0.5); + row.props.height_rule = HeightRule::Exact; + } + t + }; + // A cell with text in it holds a nested table. + let mut outer = exact(2, 2); + outer.rows[0].cells[0].blocks.push(std::sync::Arc::new(Block::Table(exact(2, 2)))); + d.insert_block(StoryRef::Body, &Path::top(1), Block::Table(outer)).unwrap(); + d.insert_block(StoryRef::Body, &Path::top(0), Block::Table(exact(2, 3))).unwrap(); + let l = lay(&d, &LayoutOptions::default()); + (d, l) +} + +/// What drawing `it` on a page of just it (and every clipped cell, for a line) gave before the +/// page's authors and clipped cells were shared: a `page_display` of its own. +fn drawn_alone(d: &Document, page: &Page, it: &Placed, opts: &display::DisplayOptions) -> Vec { + let mut items = vec![it.clone()]; + if matches!(it, Placed::Lines { .. }) { + let cells = page.header.iter().chain(&page.footer).chain(&page.items).filter(|c| matches!(c, Placed::Cell { clip: true, .. })); + items.extend(cells.cloned()); + } + let one = Page { w: page.w, h: page.h, section: page.section, number: page.number, items, body: page.body, ..Default::default() }; + display::page_display(d, &one, opts) +} + +/// #3866: drawing the items of a page one by one with the page's shared authors and clipped +/// cells gives what a `page_display` of each alone gave. +#[test] +fn drawing_items_one_by_one_matches_a_page_display_of_each() { + let (d, l) = mixed_page(); + let page = &l.pages[0]; + let clipped = page.items.iter().filter(|c| matches!(c, Placed::Cell { clip: true, .. })).count(); + assert!(clipped >= 8, "clipped cells, nested ones among them: {clipped}"); + assert!(!page.header.is_empty(), "a header on the page"); + for markup in [true, false] { + let opts = display::DisplayOptions { markup, ..Default::default() }; + let shown = display::ItemDisplay::new(&d, page); + let (mut clips, mut coloured) = (0, 0); + for it in page.header.iter().chain(&page.footer).chain(&page.items) { + let (got, want) = (shown.draws(it, &opts), drawn_alone(&d, page, it, &opts)); + assert_eq!(format!("{got:?}"), format!("{want:?}"), "markup {markup}: {it:?}"); + clips += got.iter().filter(|x| matches!(x, display::Draw::Clip { .. })).count(); + coloured += got.iter().filter(|x| matches!(x, display::Draw::Glyphs { color, .. } if *color != Rgb(0, 0, 0))).count(); + } + assert!(clips > 0, "some lines are clipped"); + assert!(!markup || coloured > 0, "some tracked text is in an author's colour"); + } +} diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index 9781a04662..f0e65c4d49 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -47,7 +47,7 @@ use wordcraft_doc::{Document, Path as DocPath, Rgb, StoryRef}; use wordcraft_fonts::FaceRef; use wordcraft_geom::Rect; use wordcraft_layout::display::Paint; -use wordcraft_layout::display::{DisplayOptions, Draw, Stroke as LineStyle, page_display}; +use wordcraft_layout::display::{DisplayOptions, Draw, ItemDisplay, Stroke as LineStyle}; use wordcraft_layout::para::{ClKind, LineEnd}; use wordcraft_layout::{DocLayout, LayoutCache, LayoutOptions, NumeralMode, Page, Placed, layout}; use wordcraft_metafile::{Picture, PlacedItem}; @@ -920,9 +920,10 @@ impl Exporter<'_> { placeholders: false, ..Default::default() }; - let clips = clipped_cells(page); + // What the items share (the authors' colours, the clipped cells) is worked out once per page. + let shown = ItemDisplay::new(self.doc, page); for it in page.header.iter().chain(page.footer.iter()) { - let draws = self.draws(page, it, &clips, &dopts); + let draws = shown.draws(it, &dopts); self.tagged(s, Role::Artifact(ArtifactType::Other), None, |me, s| { for d in &draws { me.draw(s, d); @@ -930,7 +931,7 @@ impl Exporter<'_> { }); } for it in &page.items { - let draws = self.draws(page, it, &clips, &dopts); + let draws = shown.draws(it, &dopts); match it { Placed::Lines { story, path, para, .. } => { let idx = self.entry(*story, path, para); @@ -992,20 +993,6 @@ impl Exporter<'_> { } } - /// Display items for one placed item. - fn draws(&self, page: &Page, it: &Placed, clips: &CellClips, dopts: &DisplayOptions) -> Vec { - let mut items = vec![it.clone()]; - // Lines in a cell cut off at its edge are drawn inside its clip: `page_display` finds the - // cell among the items (cells themselves draw nothing). Only that line's cell goes along. - if let Placed::Lines { story, path, .. } = it - && let Some(cell) = clip_cell(clips, *story, &path.0) - { - items.push(cell.clone()); - } - let one = Page { w: page.w, h: page.h, section: page.section, number: page.number, items, body: page.body, ..Default::default() }; - page_display(self.doc, &one, dopts) - } - /// A heading's text for bookmarks and tags: what the pages show. That leaves out what its /// layout `pl` left out (hidden text, resolved as laid out, so table formatting counts too) and, /// without markup, tracked deletions. @@ -1898,31 +1885,6 @@ fn path_from(b: &kurbo::BezPath) -> Option { pb.finish() } -/// A page's cells whose content is cut off at their edge, by story and content path (table, -/// row, cell), with their place in header, footer, items order (the first wins, as in layout). -type CellClips<'a> = HashMap, (usize, &'a Placed)>>; - -fn clipped_cells(page: &Page) -> CellClips<'_> { - let mut clips: CellClips = HashMap::new(); - for (order, it) in page.header.iter().chain(&page.footer).chain(&page.items).enumerate() { - if let Placed::Cell { table, row, cell, story, clip: true, .. } = it - && let (Ok(row), Ok(cell)) = (u32::try_from(*row), u32::try_from(*cell)) - { - let mut prefix = table.0.clone(); - prefix.extend([row, cell]); - clips.entry(*story).or_default().entry(prefix).or_insert((order, it)); - } - } - clips -} - -/// The clipped cell a line at `path` of `story` is in: the first one whose path starts it. A -/// lookup per prefix of the (short) path instead of passing every clipped cell with every line. -fn clip_cell<'a>(clips: &CellClips<'a>, story: StoryRef, path: &[u32]) -> Option<&'a Placed> { - let cells = clips.get(&story)?; - (0..=path.len()).filter_map(|k| cells.get(path.get(..k)?)).min_by_key(|(order, _)| *order).map(|(_, it)| *it) -} - /// Where each glyph's text ends: the cluster (byte offset) of the first later glyph whose cluster /// is past the glyph's own, else `len`. One backward pass keeping the later clusters that can /// still be an answer, so a long run of glyphs sharing a cluster (combining marks) stays linear. diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index 4f81f9e1a9..f6548d9c79 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -2039,7 +2039,7 @@ fn bundled_unicode_glyphs_are_visible_and_extractable() { let lay = layout(&d, &mut LayoutCache::new(), &LayoutOptions::default()); let mut drawn = String::new(); for page in &lay.pages { - for draw in page_display(&d, page, &DisplayOptions::default()) { + for draw in wordcraft_layout::display::page_display(&d, page, &DisplayOptions::default()) { if let Draw::Glyphs { face, glyphs, text, .. } = draw { assert_eq!(face.data(), bundled.data(), "page glyphs must use the bundled face without system fallback"); assert!(glyphs.iter().all(|(id, _, _)| *id != 0), "page glyphs must not use .notdef"); @@ -2450,43 +2450,3 @@ fn cluster_ends_match_the_next_larger_cluster_in_linear_time() { assert!(ends[..=n].iter().all(|e| *e == 2 * n + 2)); assert_eq!(ends.last(), Some(&(2 * n + 4))); } - -/// A line in a cell cut off at its edge is drawn with that cell's clip, found by path in an index -/// built once per page: every line used to carry a clone of every clipped cell on the page, so a -/// page of exact-height rows cost lines × cells. -#[test] -fn a_line_finds_its_clipped_cell_by_path() { - let cell = |story, table: Vec, row, cell, clip| Placed::Cell { - rect: Rect::default(), - covered: false, - table: DocPath(table), - row, - cell, - story, - clip, - }; - let mut page = Page { items: vec![cell(StoryRef::Body, vec![0], 0, 1, false), cell(StoryRef::Body, vec![2], 1, 0, true)], ..Default::default() }; - // A nested clipped cell inside the first one; the outer one comes first in the page. - page.items.push(cell(StoryRef::Body, vec![2, 1, 0, 3], 0, 0, true)); - page.footer.push(cell(StoryRef::Part(7), vec![0], 0, 0, true)); - let clips = clipped_cells(&page); - let found = |story, path: &[u32]| { - clip_cell(&clips, story, path).and_then(|c| if let Placed::Cell { table, .. } = c { Some(table.0.clone()) } else { None }) - }; - assert_eq!(found(StoryRef::Body, &[2, 1, 0, 0]), Some(vec![2])); - assert_eq!(found(StoryRef::Body, &[2, 1, 0, 3, 0, 0, 1]), Some(vec![2]), "the first cell in page order wins, as in layout"); - assert_eq!(found(StoryRef::Body, &[0, 0, 1, 0]), None, "that cell isn't clipped"); - assert_eq!(found(StoryRef::Body, &[2, 0, 0, 0]), None, "another row"); - assert_eq!(found(StoryRef::Part(7), &[0, 0, 0, 2]), Some(vec![0])); - assert_eq!(found(StoryRef::Body, &[0, 0, 0, 2]), None, "another story"); - - // 100 000 clipped cells and as many lines: a lookup each, not a pass over the cells each - // (10^10 steps, minutes; the lookups take well under a second on a loaded machine). - let n = 100_000; - let big = Page { items: (0..n).map(|r| cell(StoryRef::Body, vec![0], r, 0, true)).collect(), ..Default::default() }; - let start = std::time::Instant::now(); - let clips = clipped_cells(&big); - let hits = (0..n as u32).filter(|r| clip_cell(&clips, StoryRef::Body, &[0, *r, 0, 0]).is_some()).count(); - assert_eq!(hits, n); - assert!(start.elapsed() < std::time::Duration::from_secs(10), "{:?}", start.elapsed()); -} From a44e3ec8f969825b3cae93c6063f0395eea7a3d3 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 03:02:07 +0000 Subject: [PATCH 187/199] W1: pin craft-libs e8b4dde5 --- Cargo.lock | 56 ++++++++++++++++++++++++++------------- Cargo.toml | 6 ++--- NOTICE | 2 +- apps/wordcraft/Cargo.toml | 2 +- crates/ui-egui/Cargo.toml | 4 +-- docs/shared-pdf.md | 2 +- 6 files changed, 46 insertions(+), 26 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0f64d8d769..6ac24dce67 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -235,7 +235,7 @@ dependencies = [ "objc2-foundation 0.3.2", "parking_lot", "percent-encoding", - "windows-sys 0.59.0", + "windows-sys 0.61.2", "wl-clipboard-rs", "x11rb", ] @@ -1011,10 +1011,15 @@ dependencies = [ "libc", ] +[[package]] +name = "craft-fonts-build" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=e8b4dde5137fe41823326b55018c31302218d3a4#e8b4dde5137fe41823326b55018c31302218d3a4" + [[package]] name = "craft-i18n" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=17744430be327d0257496a6a31c8d38fe20a6756#17744430be327d0257496a6a31c8d38fe20a6756" +source = "git+https://github.com/storytold/craft-libs?rev=e8b4dde5137fe41823326b55018c31302218d3a4#e8b4dde5137fe41823326b55018c31302218d3a4" dependencies = [ "fixed_decimal", "icu_decimal", @@ -1037,7 +1042,7 @@ dependencies = [ [[package]] name = "craft-pdf" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=17744430be327d0257496a6a31c8d38fe20a6756#17744430be327d0257496a6a31c8d38fe20a6756" +source = "git+https://github.com/storytold/craft-libs?rev=e8b4dde5137fe41823326b55018c31302218d3a4#e8b4dde5137fe41823326b55018c31302218d3a4" dependencies = [ "krilla", "thiserror 2.0.21", @@ -1046,10 +1051,10 @@ dependencies = [ [[package]] name = "craft-print" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=17744430be327d0257496a6a31c8d38fe20a6756#17744430be327d0257496a6a31c8d38fe20a6756" +source = "git+https://github.com/storytold/craft-libs?rev=e8b4dde5137fe41823326b55018c31302218d3a4#e8b4dde5137fe41823326b55018c31302218d3a4" dependencies = [ "craft-winprint-gdi", - "miniz_oxide 0.8.9", + "miniz_oxide 0.9.1", "roxmltree", "tempfile", "winprint", @@ -1059,15 +1064,27 @@ dependencies = [ [[package]] name = "craft-ui" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=25da1314d443171b4adeaad2cd9454b273f35c6f#25da1314d443171b4adeaad2cd9454b273f35c6f" +source = "git+https://github.com/storytold/craft-libs?rev=e8b4dde5137fe41823326b55018c31302218d3a4#e8b4dde5137fe41823326b55018c31302218d3a4" dependencies = [ + "craft-fonts-build", + "craft-i18n", "egui", + "epaint", + "js-sys", + "log", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", + "skrifa 0.44.0", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", ] [[package]] name = "craft-update" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=495e5971bc024aa96e6ed318cb6e7cd3a0103184#495e5971bc024aa96e6ed318cb6e7cd3a0103184" +source = "git+https://github.com/storytold/craft-libs?rev=e8b4dde5137fe41823326b55018c31302218d3a4#e8b4dde5137fe41823326b55018c31302218d3a4" dependencies = [ "chrono", "dunce", @@ -1573,7 +1590,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2826,7 +2843,7 @@ dependencies = [ [[package]] name = "krilla" version = "0.8.2" -source = "git+https://github.com/storytold/craft-libs?rev=17744430be327d0257496a6a31c8d38fe20a6756#17744430be327d0257496a6a31c8d38fe20a6756" +source = "git+https://github.com/storytold/craft-libs?rev=e8b4dde5137fe41823326b55018c31302218d3a4#e8b4dde5137fe41823326b55018c31302218d3a4" dependencies = [ "base64 0.22.1", "bumpalo", @@ -3544,7 +3561,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4038,7 +4055,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4338,9 +4355,12 @@ dependencies = [ [[package]] name = "roxmltree" -version = "0.20.0" +version = "0.21.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" +checksum = "9da7f59328b09405b181fee1018925fad50f157afb43c0c0f5c522c256dd5907" +dependencies = [ + "memchr", +] [[package]] name = "rustc-hash" @@ -4386,7 +4406,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4445,7 +4465,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -5229,10 +5249,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.3", "once_cell", "rustix 1.1.5", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -6269,7 +6289,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index ee319c50ad..21909fd4bd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -58,8 +58,8 @@ 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 = "17744430be327d0257496a6a31c8d38fe20a6756", version = "=0.1.0", default-features = false, features = ["write"] } -craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184", features = ["egui"] } +craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "e8b4dde5137fe41823326b55018c31302218d3a4", version = "=0.1.0", default-features = false, features = ["write"] } +craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "e8b4dde5137fe41823326b55018c31302218d3a4", features = ["egui"] } quick-xml = "0.41" zip = { version = "3", default-features = false, features = ["deflate"] } # zlib streams (compressed metafile pictures in `.doc` drawings); already in the lock as a dependency of other crates. @@ -120,7 +120,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 = "17744430be327d0257496a6a31c8d38fe20a6756" } +krilla = { git = "https://github.com/storytold/craft-libs", rev = "e8b4dde5137fe41823326b55018c31302218d3a4" } 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" } diff --git a/NOTICE b/NOTICE index 7fd6e61209..023da4449a 100644 --- a/NOTICE +++ b/NOTICE @@ -101,7 +101,7 @@ list (author, source, license) is in ATTRIBUTION.md. Rust dependencies (egui, eframe, vello_cpu, skrifa, harfrust, krilla, quick-xml, zip, image, …) are permissively licensed (MIT/Apache-2.0/BSD/ISC/Zlib) and fetched through crates.io or pinned Git sources. The craft-pdf writer and Krilla 0.8.2 backend are pinned to storytold/craft-libs revision -17744430be327d0257496a6a31c8d38fe20a6756. The backend retains its MIT OR Apache-2.0 licensing +e8b4dde5137fe41823326b55018c31302218d3a4. The backend retains its MIT OR Apache-2.0 licensing and upstream bundled material; see that revision's licenses/pdf-writer/ and ATTRIBUTION.md. The backend patch prevents finalization during an unwind and rejects a writer after failed cleanup. WordCraft enables only writing; PDF import and its image decoders are not enabled. diff --git a/apps/wordcraft/Cargo.toml b/apps/wordcraft/Cargo.toml index aee3872697..0a12fbbf7c 100644 --- a/apps/wordcraft/Cargo.toml +++ b/apps/wordcraft/Cargo.toml @@ -73,7 +73,7 @@ windows = { version = "0.62", features = ["UI_ViewManagement", "System_UserProfi clipboard-win = { version = "5.4", features = ["std"] } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] -craft-print = { git = "https://github.com/storytold/craft-libs", rev = "17744430be327d0257496a6a31c8d38fe20a6756" } +craft-print = { git = "https://github.com/storytold/craft-libs", rev = "e8b4dde5137fe41823326b55018c31302218d3a4" } rfd = { workspace = true } # Waits for an `rfd::AsyncFileDialog` on a background thread (the dialogs don't block the UI, #94); # rfd itself already depends on it. diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 1a257045f9..1a31617d36 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -9,12 +9,12 @@ description = "WordCraft egui front end (Word-style ribbon UI); swappable: nothi workspace = true [dependencies] -craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "25da1314d443171b4adeaad2cd9454b273f35c6f" } +craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "e8b4dde5137fe41823326b55018c31302218d3a4" } # Interface translations: lookup, fallback, plurals and system-locale matching over the craft-i18n # catalogs that `build.rs` embeds from `CRAFT_I18N_DIR` (storytold/craft-i18n, pinned in # `craft-i18n.rev`). Its own rev, ahead of craft-ui's: lazy per-locale parsing # (storytold/craft-libs#247), ICU4X plural rules and bidi isolates around inserted values. -craft-i18n = { git = "https://github.com/storytold/craft-libs", rev = "17744430be327d0257496a6a31c8d38fe20a6756" } +craft-i18n = { git = "https://github.com/storytold/craft-libs", rev = "e8b4dde5137fe41823326b55018c31302218d3a4" } wordcraft-geom = { workspace = true, features = ["image"] } wordcraft-doc = { workspace = true } wordcraft-fonts = { workspace = true } diff --git a/docs/shared-pdf.md b/docs/shared-pdf.md index 99b36bb161..417ec24ae8 100644 --- a/docs/shared-pdf.md +++ b/docs/shared-pdf.md @@ -2,7 +2,7 @@ WordCraft uses `craft-pdf` with default features disabled and `write` enabled. Both that dependency and the required root Krilla patch use the immutable `storytold/craft-libs` revision -`17744430be327d0257496a6a31c8d38fe20a6756`. Rust 1.95 or newer is required. The revision must be +`e8b4dde5137fe41823326b55018c31302218d3a4`. Rust 1.95 or newer is required. The revision must be available from the remote before a fresh checkout can resolve it. The shared writer checks a backend marker at compile time so an unpatched Krilla cannot replace the required backend. From 8634995c20ad57210884729a7ac7552758da5322 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 03:03:36 +0000 Subject: [PATCH 188/199] W1: control-channel doc tests in their own module beside the control tests --- crates/ui-egui/src/control.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index f86372648b..e33b6aa4b2 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -1208,4 +1208,4 @@ mod screenshot_tests { #[cfg(test)] #[path = "control_tests.rs"] -mod tests; +mod doc_tests; From 5e7790ea85128adc426e6169d86a3a5bceb82720 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 03:03:36 +0000 Subject: [PATCH 189/199] W1: requests a command makes in another document keep their document id when the queue is full --- crates/engine/src/session/documents.rs | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/session/documents.rs b/crates/engine/src/session/documents.rs index 6d8b43a8bc..9a3ad1b8ed 100644 --- a/crates/engine/src/session/documents.rs +++ b/crates/engine/src/session/documents.rs @@ -521,14 +521,18 @@ impl Session { pub fn with_document(&mut self, id: u64, f: impl FnOnce(&mut Session) -> R) -> Result { let home = self.document_id; self.switch_to(id)?; - let queued = self.ui_requests.len(); + // The queue drops its oldest entries when full, so `f`'s requests are told apart by + // collecting them on their own rather than by position. + let earlier = std::mem::take(&mut self.ui_requests); let r = f(self); - if id != home { - for q in self.ui_requests.iter_mut().skip(queued) { - if let Some(o) = q.as_object_mut() { - o.entry("document").or_insert(json!(id)); - } + let made = std::mem::replace(&mut self.ui_requests, earlier); + for mut q in made { + if id != home + && let Some(o) = q.as_object_mut() + { + o.entry("document").or_insert(json!(id)); } + self.push_ui_request(q); } if self.document_id != home && self.is_open(home) { self.switch_to(home)?; From 4576b8e624c3ba44f9a07383eb53bcc41e003341 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 03:04:24 +0000 Subject: [PATCH 190/199] W1: a command that ends in another document counts as a state change for the input method --- crates/engine/src/session.rs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/session.rs b/crates/engine/src/session.rs index c995499691..e303eddd93 100644 --- a/crates/engine/src/session.rs +++ b/crates/engine/src/session.rs @@ -2059,7 +2059,7 @@ impl Session { } }; if self.document_id != home || (result.is_err() && self.order.iter().any(|d| *d >= mark)) { - return self.finish_elsewhere(home, mark, result, before_doc, blocks_before, default_language_before); + return self.finish_elsewhere(home, mark, result, before_doc, blocks_before, default_language_before, pending_application_change_before); } // Restrict Editing: outside the exceptions nothing may change. if result.is_ok() @@ -2240,13 +2240,22 @@ impl Session { before_doc: Option, blocks_before: Option>, default_language_before: Option, + pending_application_change_before: bool, ) -> Result { + // Another document is a state change the input method has to see, never an IME edit. + self.ime_edit_outcome = ImeEditOutcome::default(); match &result { Ok(_) => { self.put_back_parked(home, before_doc); self.clamp_selection(); + self.ime_state_seen = Some(self.current_ime_caret_state()); + self.reset_ime_state_revision(); + if !self.ime_origin { + self.pending_ime_application_change = true; + } } Err(e) => { + self.pending_ime_application_change = pending_application_change_before; self.autocap = None; self.prefs.default_language = default_language_before; if self.is_open(home) { From 24986cdb2347c1e4d7d8e1a7eb352e586221bceb Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 03:11:00 +0000 Subject: [PATCH 191/199] W3: new app fields classified as app-wide; window ids and the test host on egui 0.37 --- Cargo.lock | 12 ++++++++++++ crates/ui-egui/src/window_ui.rs | 15 ++++++++++++--- crates/ui-egui/src/windows.rs | 6 +++--- 3 files changed, 27 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6ac24dce67..3f308f2300 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1106,6 +1106,17 @@ dependencies = [ "zip 9.1.0", ] +[[package]] +name = "craft-window" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=e8b4dde5137fe41823326b55018c31302218d3a4#e8b4dde5137fe41823326b55018c31302218d3a4" +dependencies = [ + "egui", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "wayland-client", +] + [[package]] name = "craft-winprint-gdi" version = "0.1.0" @@ -6906,6 +6917,7 @@ dependencies = [ "craft-i18n", "craft-ui", "craft-update", + "craft-window", "egui", "egui_kittest", "image", diff --git a/crates/ui-egui/src/window_ui.rs b/crates/ui-egui/src/window_ui.rs index 95c8b99333..91cd571c34 100644 --- a/crates/ui-egui/src/window_ui.rs +++ b/crates/ui-egui/src/window_ui.rs @@ -162,16 +162,25 @@ impl WordApp { integrated_titlebar: _, mac_titlebar: _, control_rx: _, - shot_token: _, - queued_shots: _, - pending_shots: _, + updates_rt: _, + // The control channel's screenshots and input go to the active window, whichever it is. + shots: _, + capture_epoch: _, + surface_geometry: _, + capture_geometry: _, synthetic: _, + synthetic_pending: _, + synthetic_injected: _, + // The current command's origin, set and cleared around it. + ime_origin: _, styled: _, fonts_hans: _, fonts_korean: _, fonts_system_cjk: _, want_system_cjk: _, recent_fonts_for: _, + // File › Recover lists the copies of every window's documents. + recover_fonts_for: _, fonts_frames: _, applied_theme: _, native_scale: _, diff --git a/crates/ui-egui/src/windows.rs b/crates/ui-egui/src/windows.rs index 303af77c74..338b2ae550 100644 --- a/crates/ui-egui/src/windows.rs +++ b/crates/ui-egui/src/windows.rs @@ -666,7 +666,7 @@ impl WordApp { /// The id of the drawn window's own `name` in egui's memory, which every viewport shares: a /// search box's text, a gallery's first tile. pub(crate) fn window_id(&self, name: &'static str) -> egui::Id { - egui::Id::new(name).with(self.windows.shown) + egui::Id::unique(name).with(self.windows.shown) } /// The windows other than the drawn one, for state every window contributes to (the faces @@ -718,7 +718,7 @@ mod tests { fn keep(out: egui::FullOutput, id: ViewportId) { DRAWN.with(|d| d.borrow_mut().push(id)); UPLOADS.with(|u| *u.borrow_mut() += out.textures_delta.set.len()); - IME.with(|i| i.borrow_mut().insert(id, out.platform_output.ime)); + IME.with(|i| i.borrow_mut().insert(id, out.platform_output.ime.clone())); for (v, o) in &out.viewport_output { COMMANDS.with(|c| c.borrow_mut().entry(*v).or_default().extend(o.commands.iter().cloned())); } @@ -748,7 +748,7 @@ mod tests { fn frame(ctx: &egui::Context, a: &mut WordApp) { DRAWN.with(|d| d.borrow_mut().clear()); let mut raw = input(ViewportId::ROOT); - a.raw_input_hook(&mut raw); + a.raw_input_hook(ctx, &mut raw); let out = ctx.run_ui(raw, |ui| { a.logic(ui.ctx()); a.ui(ui); From ecdc90cdc45fe26fd3370bdf79d538521ec9cfdd Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Mon, 26 Oct 2026 03:15:35 +0000 Subject: [PATCH 192/199] =?UTF-8?q?#3866:=20review=20=E2=80=94=20port=20th?= =?UTF-8?q?e=20clipped-cell=20lookup=20test=20(nesting,=20first=20in=20pag?= =?UTF-8?q?e=20order,=20other=20story/row)=20to=20the=20layout=20crate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/layout/src/display.rs | 50 ++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/crates/layout/src/display.rs b/crates/layout/src/display.rs index 827ad8f734..f537790f96 100644 --- a/crates/layout/src/display.rs +++ b/crates/layout/src/display.rs @@ -2188,3 +2188,53 @@ fn upright_east_asian(items: Vec) -> Vec { } out } + +#[cfg(test)] +mod tests_clips { + use super::*; + use wordcraft_doc::Path as DocPath; + + /// A cell whose area is told apart by `x`. + fn cell(story: StoryRef, table: Vec, row: usize, cell: usize, clip: bool, x: f32) -> Placed { + Placed::Cell { rect: Rect::new(x, 0.0, 1.0, 1.0), covered: false, table: DocPath(table), row, cell, story, clip } + } + + /// Which cell (by its `x`) clips a line at `path` of `story`. + fn found(clips: &Clips, story: StoryRef, path: &[u32]) -> Option { + cell_clip(clips, story, path).map(|r| r.x) + } + + /// A line in a cell cut off at its edge is drawn with that cell's clip, found by path in an + /// index built once per page: every line used to look through every clipped cell on the page, + /// so a page of exact-height rows cost lines x cells. + #[test] + fn a_line_finds_its_clipped_cell_by_path() { + let mut page = Page { + items: vec![cell(StoryRef::Body, vec![0], 0, 1, false, 1.0), cell(StoryRef::Body, vec![2], 1, 0, true, 2.0)], + ..Default::default() + }; + // A nested clipped cell inside the second one; the outer one comes first in the page. + page.items.push(cell(StoryRef::Body, vec![2, 1, 0, 3], 0, 0, true, 3.0)); + page.footer.push(cell(StoryRef::Part(7), vec![0], 0, 0, true, 4.0)); + // The page's cells in header, footer, items order, as the PDF export and layout see them. + let all: Vec = page.header.iter().chain(&page.footer).chain(&page.items).cloned().collect(); + let clips = clipped_cells(&all); + assert_eq!(found(&clips, StoryRef::Body, &[2, 1, 0, 0]), Some(2.0)); + assert_eq!(found(&clips, StoryRef::Body, &[2, 1, 0, 3, 0, 0, 1]), Some(2.0), "the first cell in page order wins, as in layout"); + assert_eq!(found(&clips, StoryRef::Body, &[0, 0, 1, 0]), None, "that cell isn't clipped"); + assert_eq!(found(&clips, StoryRef::Body, &[2, 0, 0, 0]), None, "another row"); + assert_eq!(found(&clips, StoryRef::Part(7), &[0, 0, 0, 2]), Some(4.0)); + assert_eq!(found(&clips, StoryRef::Body, &[0, 0, 0, 2]), None, "another story"); + } + + /// Thousands of clipped cells and as many lines: a lookup each, not a pass over the cells each. + #[test] + fn looking_up_many_clipped_cells_takes_linear_time() { + let make = |n: usize| (0..n).map(|r| cell(StoryRef::Body, vec![0], r, 0, true, r as f32)).collect::>(); + crate::tests_linear::assert_linear_steps("clip lookups", 1000, make, |cells| { + let clips = clipped_cells(cells); + let hits = (0..cells.len() as u32).filter(|r| cell_clip(&clips, StoryRef::Body, &[0, *r, 0, 0]).is_some()).count(); + assert_eq!(hits, cells.len()); + }); + } +} From b9c074a5437a876287ea3601222b6c7276fb2555 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 03:26:01 +0000 Subject: [PATCH 193/199] W6: a window's input method joins and equation session stay with that window --- crates/engine/src/session/views.rs | 17 +++++++++++++++-- crates/engine/src/tests_documents.rs | 17 +++++++++++++++++ 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/session/views.rs b/crates/engine/src/session/views.rs index 98287badca..48c991eae3 100644 --- a/crates/engine/src/session/views.rs +++ b/crates/engine/src/session/views.rs @@ -7,7 +7,7 @@ use wordcraft_doc::props::CharProps; use wordcraft_doc::{Pos, StoryRef}; -use super::{ColumnBlock, MathEdit, Selection, Session, TypedWord, ViewState, Widened}; +use super::{ColumnBlock, MathEdit, Selection, Session, TypedWord, ViewState, Widened, next_math_session}; /// One window's view of a document: its selection and caret state, and how it shows the page /// (zoom, view mode, panes, ruler…). Positions in it may be stale after edits made through @@ -21,6 +21,8 @@ pub struct WindowView { hf_return: Option, typing_open: bool, join_next: bool, + equation_join_next: bool, + ime_text_join: Option, typing: Option, math: Option, also_selected: Vec, @@ -65,6 +67,8 @@ impl Session { hf_return: self.hf_return.take(), typing_open: take(&mut self.typing_open), join_next: take(&mut self.join_next), + equation_join_next: take(&mut self.equation_join_next), + ime_text_join: self.ime_text_join.take(), typing: self.typing.take(), math: self.math.take(), also_selected: take(&mut self.also_selected), @@ -88,8 +92,11 @@ impl Session { // The new window hasn't typed: its first key starts an undo step of its own. typing_open: false, join_next: false, + equation_join_next: false, + ime_text_join: None, typing: None, - math: self.math.clone(), + // An equation editing session is an input method's owner: the new window's is its own. + math: self.math.clone().and_then(|m| Some(MathEdit { session: next_math_session()?, ..m })), also_selected: self.also_selected.clone(), column: self.column.clone(), column_mode: self.column_mode, @@ -114,6 +121,8 @@ impl Session { hf_return, typing_open, join_next, + equation_join_next, + ime_text_join, typing, math, also_selected, @@ -135,6 +144,8 @@ impl Session { self.hf_return = hf_return; self.typing_open = typing_open; self.join_next = join_next; + self.equation_join_next = equation_join_next; + self.ime_text_join = ime_text_join; self.typing = typing; self.also_selected = also_selected; self.column = column; @@ -144,6 +155,8 @@ impl Session { self.hf_return = hf_return.map(|s| Selection { anchor: self.doc.clamp(&s.anchor), focus: self.doc.clamp(&s.focus) }); self.typing_open = false; self.join_next = false; + self.equation_join_next = false; + self.ime_text_join = None; self.typing = None; self.also_selected = Vec::new(); self.column = None; diff --git a/crates/engine/src/tests_documents.rs b/crates/engine/src/tests_documents.rs index 6c83716fe7..36ecb471f2 100644 --- a/crates/engine/src/tests_documents.rs +++ b/crates/engine/src/tests_documents.rs @@ -329,3 +329,20 @@ fn two_views_of_one_document_keep_their_own_caret_and_share_undo() { assert!(!text(&s).contains('z') && text(&s).contains('x') && text(&s).contains('y'), "{}", text(&s)); drop(b); } + +/// An input method's promise that the next insertion joins its replacement belongs to the +/// window that made it: another window on the document typing at the same place gets an undo +/// step of its own. +#[test] +fn input_method_joins_stay_with_their_window() { + let mut s = s(); + run(&mut s, "text.insert", json!({"text": "Hello"})); + s.join_next_ime_text_replacement(); + let b = s.copy_view(); + let a = s.take_view(); + s.put_view(b); + run(&mut s, "text.insert", json!({"text": "y"})); + run(&mut s, "edit.undo", json!({})); + assert_eq!(text(&s).trim(), "Hello", "B's typing is its own undo step"); + drop(a); +} From 6dca584bbdb196107f0eaaecf1557ec544a74fcd Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Sun, 25 Oct 2026 23:29:51 -0400 Subject: [PATCH 194/199] Task panes: drop a pane outside or inside a docked pane to order a side (#4386) * Task panes: a pane dropped over a docked pane's outer or inner third docks outside or inside it, and the order is kept (craft-libs#357) * Pin craft-libs at b74f1b6 (pane order on an edge, craft-libs#357) * Pin craft-libs at b926b5c (craft-libs main) --- Cargo.lock | 25 +++++-- Cargo.toml | 6 +- NOTICE | 2 +- apps/wordcraft/Cargo.toml | 2 +- crates/ui-egui/Cargo.toml | 4 +- crates/ui-egui/src/lib.rs | 5 ++ crates/ui-egui/src/panes.rs | 129 +++++++++++++++++++++++++++++++++--- docs/control-protocol.md | 2 +- docs/shared-pdf.md | 2 +- 9 files changed, 154 insertions(+), 23 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c9052a8c77..244e0f453d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1011,10 +1011,15 @@ dependencies = [ "libc", ] +[[package]] +name = "craft-fonts-build" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=b926b5c03e256c86ca10934f58d03f628810b5b7#b926b5c03e256c86ca10934f58d03f628810b5b7" + [[package]] name = "craft-i18n" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=1e6f127844ec61b3b8afdcc2bb76e7cfe470153f#1e6f127844ec61b3b8afdcc2bb76e7cfe470153f" +source = "git+https://github.com/storytold/craft-libs?rev=b926b5c03e256c86ca10934f58d03f628810b5b7#b926b5c03e256c86ca10934f58d03f628810b5b7" dependencies = [ "fixed_decimal", "icu_decimal", @@ -1037,7 +1042,7 @@ dependencies = [ [[package]] name = "craft-pdf" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=1e6f127844ec61b3b8afdcc2bb76e7cfe470153f#1e6f127844ec61b3b8afdcc2bb76e7cfe470153f" +source = "git+https://github.com/storytold/craft-libs?rev=b926b5c03e256c86ca10934f58d03f628810b5b7#b926b5c03e256c86ca10934f58d03f628810b5b7" dependencies = [ "krilla", "thiserror 2.0.21", @@ -1046,7 +1051,7 @@ dependencies = [ [[package]] name = "craft-print" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=1e6f127844ec61b3b8afdcc2bb76e7cfe470153f#1e6f127844ec61b3b8afdcc2bb76e7cfe470153f" +source = "git+https://github.com/storytold/craft-libs?rev=b926b5c03e256c86ca10934f58d03f628810b5b7#b926b5c03e256c86ca10934f58d03f628810b5b7" dependencies = [ "craft-winprint-gdi", "miniz_oxide 0.9.1", @@ -1059,19 +1064,27 @@ dependencies = [ [[package]] name = "craft-ui" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=1e6f127844ec61b3b8afdcc2bb76e7cfe470153f#1e6f127844ec61b3b8afdcc2bb76e7cfe470153f" +source = "git+https://github.com/storytold/craft-libs?rev=b926b5c03e256c86ca10934f58d03f628810b5b7#b926b5c03e256c86ca10934f58d03f628810b5b7" dependencies = [ + "craft-fonts-build", + "craft-i18n", "egui", + "epaint", + "js-sys", "log", "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", + "skrifa 0.44.0", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", ] [[package]] name = "craft-update" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=1e6f127844ec61b3b8afdcc2bb76e7cfe470153f#1e6f127844ec61b3b8afdcc2bb76e7cfe470153f" +source = "git+https://github.com/storytold/craft-libs?rev=b926b5c03e256c86ca10934f58d03f628810b5b7#b926b5c03e256c86ca10934f58d03f628810b5b7" dependencies = [ "chrono", "dunce", @@ -2830,7 +2843,7 @@ dependencies = [ [[package]] name = "krilla" version = "0.8.2" -source = "git+https://github.com/storytold/craft-libs?rev=1e6f127844ec61b3b8afdcc2bb76e7cfe470153f#1e6f127844ec61b3b8afdcc2bb76e7cfe470153f" +source = "git+https://github.com/storytold/craft-libs?rev=b926b5c03e256c86ca10934f58d03f628810b5b7#b926b5c03e256c86ca10934f58d03f628810b5b7" dependencies = [ "base64 0.22.1", "bumpalo", diff --git a/Cargo.toml b/Cargo.toml index b183f3a1f4..fe7d7c9c71 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -58,8 +58,8 @@ 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 = "1e6f127844ec61b3b8afdcc2bb76e7cfe470153f", version = "=0.1.0", default-features = false, features = ["write"] } -craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "1e6f127844ec61b3b8afdcc2bb76e7cfe470153f", features = ["egui"] } +craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "b926b5c03e256c86ca10934f58d03f628810b5b7", version = "=0.1.0", default-features = false, features = ["write"] } +craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "b926b5c03e256c86ca10934f58d03f628810b5b7", features = ["egui"] } quick-xml = "0.41" zip = { version = "3", default-features = false, features = ["deflate"] } # zlib streams (compressed metafile pictures in `.doc` drawings); already in the lock as a dependency of other crates. @@ -120,7 +120,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 = "1e6f127844ec61b3b8afdcc2bb76e7cfe470153f" } +krilla = { git = "https://github.com/storytold/craft-libs", rev = "b926b5c03e256c86ca10934f58d03f628810b5b7" } 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" } diff --git a/NOTICE b/NOTICE index a7462a69ca..82e79a0a98 100644 --- a/NOTICE +++ b/NOTICE @@ -101,7 +101,7 @@ list (author, source, license) is in ATTRIBUTION.md. Rust dependencies (egui, eframe, vello_cpu, skrifa, harfrust, krilla, quick-xml, zip, image, …) are permissively licensed (MIT/Apache-2.0/BSD/ISC/Zlib) and fetched through crates.io or pinned Git sources. The craft-pdf writer and Krilla 0.8.2 backend are pinned to storytold/craft-libs revision -1e6f127844ec61b3b8afdcc2bb76e7cfe470153f. The backend retains its MIT OR Apache-2.0 licensing +b926b5c03e256c86ca10934f58d03f628810b5b7. The backend retains its MIT OR Apache-2.0 licensing and upstream bundled material; see that revision's licenses/pdf-writer/ and ATTRIBUTION.md. The backend patch prevents finalization during an unwind and rejects a writer after failed cleanup. WordCraft enables only writing; PDF import and its image decoders are not enabled. diff --git a/apps/wordcraft/Cargo.toml b/apps/wordcraft/Cargo.toml index 8e129d1aa8..85227c169c 100644 --- a/apps/wordcraft/Cargo.toml +++ b/apps/wordcraft/Cargo.toml @@ -73,7 +73,7 @@ windows = { version = "0.62", features = ["UI_ViewManagement", "System_UserProfi clipboard-win = { version = "5.4", features = ["std"] } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] -craft-print = { git = "https://github.com/storytold/craft-libs", rev = "1e6f127844ec61b3b8afdcc2bb76e7cfe470153f" } +craft-print = { git = "https://github.com/storytold/craft-libs", rev = "b926b5c03e256c86ca10934f58d03f628810b5b7" } rfd = { workspace = true } # Waits for an `rfd::AsyncFileDialog` on a background thread (the dialogs don't block the UI, #94); # rfd itself already depends on it. diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 599fa540e1..8add305554 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -9,12 +9,12 @@ description = "WordCraft egui front end (Word-style ribbon UI); swappable: nothi workspace = true [dependencies] -craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "1e6f127844ec61b3b8afdcc2bb76e7cfe470153f" } +craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "b926b5c03e256c86ca10934f58d03f628810b5b7" } # Interface translations: lookup, fallback, plurals and system-locale matching over the craft-i18n # catalogs that `build.rs` embeds from `CRAFT_I18N_DIR` (storytold/craft-i18n, pinned in # `craft-i18n.rev`). Same craft-libs rev as craft-ui; it brings lazy per-locale parsing # (storytold/craft-libs#247), ICU4X plural rules and bidi isolates around inserted values. -craft-i18n = { git = "https://github.com/storytold/craft-libs", rev = "1e6f127844ec61b3b8afdcc2bb76e7cfe470153f" } +craft-i18n = { git = "https://github.com/storytold/craft-libs", rev = "b926b5c03e256c86ca10934f58d03f628810b5b7" } wordcraft-geom = { workspace = true, features = ["image"] } wordcraft-doc = { workspace = true } wordcraft-fonts = { workspace = true } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 61cf2448c9..32949f317a 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -304,6 +304,10 @@ pub struct UiState { /// Where each of the other task panes sits, by [`panes::Pane::key`] (#1756). #[serde(deserialize_with = "panes::lenient_places")] pub pane_places: std::collections::BTreeMap, + /// The task panes in the order the user put them, by [`panes::Pane::key`]: each edge shows its + /// panes in this order, the first outermost. Empty: the default order. + #[serde(deserialize_with = "panes::lenient_order", skip_serializing_if = "Vec::is_empty")] + pub pane_order: Vec, /// Headings whose sub-headings the Navigation pane hides (#1997); per session, not saved. #[serde(skip)] pub nav_collapsed: std::collections::HashSet, @@ -425,6 +429,7 @@ impl Default for UiState { styles_pane_float: None, styles_pane_width: None, pane_places: Default::default(), + pane_order: Vec::new(), nav_collapsed: Default::default(), show_discord: true, author: String::new(), diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index bf96e38879..5125dbb868 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -17,7 +17,7 @@ use wordcraft_engine::StylesPaneDock; use crate::WordApp; use crate::theme::{Tokens, regular, semibold}; -/// The task panes, in the order they open: each edge's first pane is outermost. +/// The task panes, in their default order: each edge's first pane is outermost. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub enum Pane { /// SmartArt Design › Text Pane, while a SmartArt graphic WordCraft can edit is selected. @@ -94,6 +94,10 @@ impl Pane { Self::Comments, ]; + fn from_key(key: &str) -> Option { + Self::ALL.into_iter().find(|pane| pane.key() == key) + } + /// The command that closes the pane; Restrict Editing has none and closes in [`close`]. fn close_command(self) -> Option<&'static str> { match self { @@ -111,11 +115,45 @@ impl Pane { } } -/// The panes open now, from the session's view flags. +/// Every pane in the order the user left them (`paneOrder` in `ui.json`), then the panes that +/// list lacks in their default order. Each edge shows its panes in this order, the first outermost. +fn order(app: &WordApp) -> Vec { + let mut order: Vec = app.ui.pane_order.iter().filter_map(|key| Pane::from_key(key)).collect(); + for pane in Pane::ALL { + if !order.contains(&pane) { + order.push(pane); + } + } + order +} + +/// Put `pane` directly before `before` in the order, outside it on their edge (`None`: after +/// every pane). +fn move_before(app: &mut WordApp, pane: Pane, before: Option) { + let mut order = order(app); + order.retain(|p| *p != pane); + let at = before.and_then(|b| order.iter().position(|p| *p == b)).unwrap_or(order.len()); + order.insert(at, pane); + app.ui.pane_order = order.iter().map(|p| p.key().to_string()).collect(); +} + +/// `paneOrder` as saved: unknown and repeated keys are dropped, not the whole file. +pub(crate) fn lenient_order<'de, D: serde::Deserializer<'de>>(d: D) -> Result, D::Error> { + let v = ::deserialize(d)?; + let mut keys: Vec = Vec::new(); + for key in v.as_array().map(Vec::as_slice).unwrap_or_default().iter().take(Pane::ALL.len() * 4).filter_map(Value::as_str) { + if Pane::from_key(key).is_some() && !keys.iter().any(|k| k == key) { + keys.push(key.to_string()); + } + } + Ok(keys) +} + +/// The panes open now, from the session's view flags, in the user's order. pub fn open(app: &WordApp) -> Vec { let v = &app.session.view; let smart_art = v.smart_art_pane && wordcraft_engine::cmd::smart_art::selected_smart_art(&app.session).is_some(); - [ + let mut open = [ (Pane::SmartArtText, smart_art), (Pane::Navigation, v.nav_pane), (Pane::Clipboard, v.clipboard_pane), @@ -129,7 +167,10 @@ pub fn open(app: &WordApp) -> Vec { ] .into_iter() .filter_map(|(pane, shown)| shown.then_some(pane)) - .collect() + .collect::>(); + let order = order(app); + open.sort_by_key(|pane| order.iter().position(|p| p == pane).unwrap_or(usize::MAX)); + open } /// Where a task pane other than Styles sits, kept in `ui.json` (#1756). Styles keeps its place in @@ -345,16 +386,21 @@ pub(crate) fn apply(app: &mut WordApp, request: PaneRequest) { } PaneRequest::Resize(pane, width) => app.ui.pane_places.entry(pane.key().into()).or_default().width = Some(width), PaneRequest::FloatRect(pane, rect) => app.ui.pane_places.entry(pane.key().into()).or_default().float = Some(rect), + PaneRequest::Before(pane, before) => { + let _ = app.run("ui.paneDock", json!({"pane": pane.key(), "before": before.map(Pane::key)})); + } PaneRequest::Focus(_) => {} } } -/// `ui.paneDock {"pane": key, "dock"?: "left" | "right" | "float"}` (#1756): docks or floats a -/// task pane, as its menu's Dock Left, Dock Right and Float do; without `dock` it reads the -/// pane's place. `pane` is the pane's `panePlaces` key; Styles goes through `view.stylesPaneDock`. +/// `ui.paneDock {"pane": key, "dock"?: "left" | "right" | "float", "before"?: key | null}` (#1756): +/// docks or floats a task pane, as its menu's Dock Left, Dock Right and Float do; without `dock` +/// it reads the pane's place. `pane` is the pane's `panePlaces` key; Styles goes through +/// `view.stylesPaneDock`. `before` puts the pane directly before that pane in the order, outside +/// it on their edge, as a drop over a docked pane does; `null` puts it after every pane. pub(crate) fn dock_command(app: &mut WordApp, p: &Value) -> Result { let key = p.get("pane").and_then(Value::as_str).ok_or("`pane` must be a task pane's key, such as \"navigation\"")?; - let pane = Pane::ALL.into_iter().find(|pane| pane.key() == key).ok_or_else(|| format!("unknown task pane `{key}`"))?; + let pane = Pane::from_key(key).ok_or_else(|| format!("unknown task pane `{key}`"))?; let dock = match p.get("dock") { None => None, Some(v) => Some(match v.as_str() { @@ -364,6 +410,17 @@ pub(crate) fn dock_command(app: &mut WordApp, p: &Value) -> Result return Err("`dock` must be left, right or float".into()), }), }; + let before = match p.get("before") { + None => None, + Some(Value::Null) => Some(None), + Some(v) => match v.as_str().and_then(Pane::from_key) { + Some(before) if before != pane => Some(Some(before)), + _ => return Err("`before` must be another task pane's key or null".into()), + }, + }; + if let Some(before) = before { + move_before(app, pane, before); + } if pane == Pane::Styles { let params = p.get("dock").map_or_else(|| json!({}), |d| json!({"dock": d})); let result = app.run("view.stylesPaneDock", params)?; @@ -1336,6 +1393,62 @@ mod tests { assert_eq!(super::pane_state(h.state(), Pane::Navigation).place, docked, "the drag was cancelled"); } + /// With Navigation docked left, Comments dropped over Navigation's outer third docks left of + /// it, where the preview showed, and the order survives a restart; over Navigation's inner + /// third it goes back inside. + #[test] + #[cfg(not(target_arch = "wasm32"))] + fn comments_dropped_over_navigations_outer_third_docks_left_of_it() { + use egui_kittest::kittest::Queryable; + + let mut app = crate::WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::from_text("")), crate::Services::default()); + app.run("view.navigationPane", serde_json::json!({"value": true})).unwrap(); + app.run("view.commentsPane", serde_json::json!({"value": true})).unwrap(); + let mut h = pane_harness(app); + // A docked pane's title bar starts at its panel's left edge. + let panel = |h: &egui_kittest::Harness<'static, crate::WordApp>, pane: Pane| h.get_by_label(&format!("Move Pane: {}", pane.title())).rect(); + let button = + |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }; + let drag = |h: &mut egui_kittest::Harness<'static, crate::WordApp>, to: egui::Pos2| { + let from = h.get_by_label("Move Pane: Comments").rect().center(); + h.input_mut().events.extend([egui::Event::PointerMoved(from), button(from, true)]); + h.run_steps(1); + h.input_mut().events.push(egui::Event::PointerMoved(from + egui::vec2(-8.0, 6.0))); + h.run_steps(1); + h.input_mut().events.push(egui::Event::PointerMoved(to)); + h.run_steps(1); + h.input_mut().events.push(button(to, false)); + h.run_steps(3); + }; + let nav = panel(&h, Pane::Navigation); + drag(&mut h, egui::pos2(nav.left() + 60.0, nav.center().y)); + let (comments, nav_after) = (panel(&h, Pane::Comments), panel(&h, Pane::Navigation)); + assert_eq!(comments.left(), nav.left(), "Comments is outermost"); + assert!(nav_after.left() > comments.right(), "Navigation sits inside it"); + assert_eq!(super::open(h.state()), vec![Pane::Comments, Pane::Navigation]); + + let saved = serde_json::to_vec(&h.state().prefs()).unwrap(); + let mut next = crate::WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), crate::Services::default()); + next.apply_prefs(serde_json::from_slice(&saved).unwrap()); + next.session.view.comments_pane = true; + assert_eq!(super::open(&next), vec![Pane::Comments, Pane::Navigation], "the order is kept"); + + let nav = panel(&h, Pane::Navigation); + let width = Pane::Navigation.default_state().size.unwrap(); + drag(&mut h, egui::pos2(nav.left() + width * 0.85, nav.center().y)); + assert_eq!(super::open(h.state()), vec![Pane::Navigation, Pane::Comments]); + assert!(panel(&h, Pane::Comments).left() > panel(&h, Pane::Navigation).right(), "back inside Navigation"); + + let mut junk = crate::WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), crate::Services::default()); + junk.apply_prefs(serde_json::from_str(r#"{"paneOrder": ["comments", 3, "nope", "comments", "navigation"], "navPane": true}"#).unwrap()); + assert_eq!(junk.ui.pane_order, vec!["comments", "navigation"], "unknown and repeated keys are dropped"); + for bad in [serde_json::json!({"pane": "comments", "before": "comments"}), serde_json::json!({"pane": "comments", "before": 7})] { + assert!(junk.run("ui.paneDock", bad.clone()).is_err(), "{bad}"); + } + junk.run("ui.paneDock", serde_json::json!({"pane": "navigation", "before": null})).unwrap(); + assert_eq!(junk.ui.pane_order.last().map(String::as_str), Some("navigation")); + } + /// An F6 the user bound runs its command; plain F6 still moves to the panes when only /// Shift+F6 is bound. #[test] diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 513ef0e818..014966bf95 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -54,7 +54,7 @@ and set `WORDCRAFT_CONTROL_KEY` to that app's key. | Method | Params | Result | |---|---|---| -| `engine.execute` | `command`, `params` | the command's result (any id from `engine.commands`, including UI commands `ui.tab`, `ui.dialog` (`{"name": dialog, "part"?: table style part}`; `part` opens New / Modify Table Style (`newTableStyle` / `modifyTableStyle`) on that part: `wholeTable`, `headerRow`, `firstColumn`, `bandedRows`, `nwCell`… as `table.modifyStyle` names them), `ui.backstage`, `ui.zotero.*` (see `docs/zotero.md`), `ui.language` (`{"value": "auto"\|"en"\|"zh-hans"\|"zh-hant"\|"ja"\|"pt-br"\|"es"\|"uk"\|"sr"\|"sr-latn"\|"et"\|"tr"\|"ru"\|"pl"\|"de"\|"it"\|"cs"\|"hu"\|"fr"}`), `ui.theme` (`{"value": "system"\|"light"\|"dark"}`; `system` follows the OS appearance, light when it reports none), `ui.interfaceSize` (`{"value": "auto"}` or `{"percent": 50–300}`, clamped; no value reads it; `auto` keeps the system display scale, a percentage is absolute, so 100 is one screen pixel per point (#822); the result's `percent` is the size drawn at and `auto` whether it follows the system; document zoom stays `view.zoom`), `ui.animations` (`{"value": "system"\|"on"\|"off"}`; no value reads it; `system` follows the system's animation setting, which the desktop app reads on Windows, macOS, Linux and the BSDs and the web app from `prefers-reduced-motion`, and means on where the system doesn't say; the result's `enabled` is what the setting comes to and `system` what the system reports, `null` when it doesn't say), `ui.fileBrowser` (desktop: `{"purpose": "document"\|"picture"\|"recipients"\|"save"}` opens WordCraft's own file browser, for when the system file dialog doesn't show up, #576), `ui.saveAsTemplate` (File › Save As › Save as Word Template: the Save dialog for a `.dotx`, starting in the user templates folder; the result's `dir` is that folder, `null` on the web, #2852), `ui.caretAnimation` (`{"value": true\|false}`; no value reads it; off, the caret jumps and blinks on and off instead of gliding and fading), `ui.startScreen` (`{"value": true\|false}`; no value reads it; on, the default, the desktop and web apps start on File › Home when launched with nothing to open, #766), `ui.alignmentGuides` (`{"value": true\|false}`; no value reads it; on, the default, moving a picture or shape snaps it to the page, the margins and other objects, with guide lines while dragging; Alt moves freely), `ui.quickAccessToolbar` (`{"value": true\|false}`; no value toggles it; false takes the Quick Access Toolbar out of the title bar, as its menu's Hide Quick Access Toolbar does, and true brings it back, #1945), `ui.focusRibbon` (`{"value": true\|false}`; no value toggles it; in Focus, true drops the ribbon down over the page, as the bar's ⋯ does, and false puts it away, as a click on the page does; outside Focus it stays put away, #1678), `ui.contextMenu` (opens the page's context menu below the caret, as Shift+F10 does), `ui.paneDock` (`{"pane": "navigation"\|"clipboard"\|"styles"\|"styleInspector"\|"accessibility"\|"altText"\|"formatShape"\|"restrictEditing"\|"comments"\|"smartArtText", "dock"?: "left"\|"right"\|"float"}`; no `dock` reads it; docks or floats a task pane as its menu does, kept between runs; Styles is `view.stylesPaneDock`, #1756)…) | +| `engine.execute` | `command`, `params` | the command's result (any id from `engine.commands`, including UI commands `ui.tab`, `ui.dialog` (`{"name": dialog, "part"?: table style part}`; `part` opens New / Modify Table Style (`newTableStyle` / `modifyTableStyle`) on that part: `wholeTable`, `headerRow`, `firstColumn`, `bandedRows`, `nwCell`… as `table.modifyStyle` names them), `ui.backstage`, `ui.zotero.*` (see `docs/zotero.md`), `ui.language` (`{"value": "auto"\|"en"\|"zh-hans"\|"zh-hant"\|"ja"\|"pt-br"\|"es"\|"uk"\|"sr"\|"sr-latn"\|"et"\|"tr"\|"ru"\|"pl"\|"de"\|"it"\|"cs"\|"hu"\|"fr"}`), `ui.theme` (`{"value": "system"\|"light"\|"dark"}`; `system` follows the OS appearance, light when it reports none), `ui.interfaceSize` (`{"value": "auto"}` or `{"percent": 50–300}`, clamped; no value reads it; `auto` keeps the system display scale, a percentage is absolute, so 100 is one screen pixel per point (#822); the result's `percent` is the size drawn at and `auto` whether it follows the system; document zoom stays `view.zoom`), `ui.animations` (`{"value": "system"\|"on"\|"off"}`; no value reads it; `system` follows the system's animation setting, which the desktop app reads on Windows, macOS, Linux and the BSDs and the web app from `prefers-reduced-motion`, and means on where the system doesn't say; the result's `enabled` is what the setting comes to and `system` what the system reports, `null` when it doesn't say), `ui.fileBrowser` (desktop: `{"purpose": "document"\|"picture"\|"recipients"\|"save"}` opens WordCraft's own file browser, for when the system file dialog doesn't show up, #576), `ui.saveAsTemplate` (File › Save As › Save as Word Template: the Save dialog for a `.dotx`, starting in the user templates folder; the result's `dir` is that folder, `null` on the web, #2852), `ui.caretAnimation` (`{"value": true\|false}`; no value reads it; off, the caret jumps and blinks on and off instead of gliding and fading), `ui.startScreen` (`{"value": true\|false}`; no value reads it; on, the default, the desktop and web apps start on File › Home when launched with nothing to open, #766), `ui.alignmentGuides` (`{"value": true\|false}`; no value reads it; on, the default, moving a picture or shape snaps it to the page, the margins and other objects, with guide lines while dragging; Alt moves freely), `ui.quickAccessToolbar` (`{"value": true\|false}`; no value toggles it; false takes the Quick Access Toolbar out of the title bar, as its menu's Hide Quick Access Toolbar does, and true brings it back, #1945), `ui.focusRibbon` (`{"value": true\|false}`; no value toggles it; in Focus, true drops the ribbon down over the page, as the bar's ⋯ does, and false puts it away, as a click on the page does; outside Focus it stays put away, #1678), `ui.contextMenu` (opens the page's context menu below the caret, as Shift+F10 does), `ui.paneDock` (`{"pane": "navigation"\|"clipboard"\|"styles"\|"styleInspector"\|"accessibility"\|"altText"\|"formatShape"\|"restrictEditing"\|"comments"\|"smartArtText", "dock"?: "left"\|"right"\|"float", "before"?: pane\|null}`; no `dock` reads it; docks or floats a task pane as its menu does, kept between runs; Styles is `view.stylesPaneDock`, #1756; `before` puts the pane directly before that pane in the order, outside it on their edge, as a drop over a docked pane's outer third does, and `null` after every pane; the order is kept between runs)…) | | `engine.commands` | — | every command: id, label, location, shortcut, params, enabled | | `document.inspect` | `text?` | blocks (text, style, runs, lists, tables), parts, sections, selection, pages | | `ui.inspect` | — | UI state, view, dialog, window size, page rects on screen, caret, comment balloons (`balloons`: id + screen rect; `balloon`: the selected one), perf | diff --git a/docs/shared-pdf.md b/docs/shared-pdf.md index a3216940dc..92596b59ab 100644 --- a/docs/shared-pdf.md +++ b/docs/shared-pdf.md @@ -2,7 +2,7 @@ WordCraft uses `craft-pdf` with default features disabled and `write` enabled. Both that dependency and the required root Krilla patch use the immutable `storytold/craft-libs` revision -`1e6f127844ec61b3b8afdcc2bb76e7cfe470153f`. Rust 1.95 or newer is required. The revision must be +`b926b5c03e256c86ca10934f58d03f628810b5b7`. Rust 1.95 or newer is required. The revision must be available from the remote before a fresh checkout can resolve it. The shared writer checks a backend marker at compile time so an unpatched Krilla cannot replace the required backend. From d805dd94f18459bf40237978d9288939a7223cd0 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 03:34:39 +0000 Subject: [PATCH 195/199] W3b: ui.screenshot captures the active window, not the first one --- crates/ui-egui/src/control.rs | 73 +++++++++++++++++++++++------------ crates/ui-egui/src/lib.rs | 29 ++++++++------ crates/ui-egui/src/windows.rs | 51 ++++++++++++++++++++++++ 3 files changed, 118 insertions(+), 35 deletions(-) diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 518e17d2c0..efa28f266e 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -456,20 +456,29 @@ impl SurfaceGeometry { /// Derives this frame's capturable surface from raw viewport data, rejecting a missing, non-finite /// or zero-sized visible surface and recording minimized or occluded state. pub(crate) fn from_raw(raw: &egui::RawInput, ctx: &egui::Context) -> Option { - let viewport = raw.viewports.get(&raw.viewport_id)?; + Self::from_viewport(raw.viewports.get(&raw.viewport_id)?, raw.screen_rect, raw.max_texture_side, ctx) + } + + /// [`Self::from_raw`] for one viewport's info, content rectangle and texture limit. + pub(crate) fn from_viewport( + viewport: &egui::ViewportInfo, + screen_rect: Option, + max_texture_side: Option, + ctx: &egui::Context, + ) -> Option { let minimized = viewport.minimized == Some(true); let occluded = viewport.occluded == Some(true); // A minimized/occluded surface may report no rectangle at all. Its stale size is harmless // because such a surface is never capturable; a visible surface without a rectangle is not // fresh enough to use. - let rect = match viewport.inner_rect.or(raw.screen_rect) { + let rect = match viewport.inner_rect.or(screen_rect) { Some(rect) => rect, None if minimized || occluded => ctx.content_rect(), None => return None, }; let zoom = ctx.zoom_factor(); let pixels_per_point = viewport.native_pixels_per_point.map_or_else(|| ctx.pixels_per_point(), |native| native * zoom); - let max_texture_side = raw.max_texture_side.filter(|side| *side > 0).unwrap_or_else(|| ctx.input(|i| i.max_texture_side)); + let max_texture_side = max_texture_side.filter(|side| *side > 0).unwrap_or_else(|| ctx.input(|i| i.max_texture_side)); if !rect.width().is_finite() || !rect.height().is_finite() || rect.width() <= 0.0 || rect.height() <= 0.0 { return None; } @@ -479,9 +488,10 @@ impl SurfaceGeometry { Some(Self { viewport: rect.size(), pixels_per_point, max_texture_side, minimized, occluded }) } - /// Pairs the surface geometry with the document id a capture belongs to. - pub(crate) fn with_document(self, document: u64) -> CaptureGeometry { - CaptureGeometry { surface: self, document } + /// Pairs the surface geometry with the window's viewport and the document id a capture + /// belongs to. + pub(crate) fn with_target(self, viewport: egui::ViewportId, document: u64) -> CaptureGeometry { + CaptureGeometry { surface: self, viewport, document } } } @@ -491,6 +501,8 @@ impl SurfaceGeometry { #[derive(Clone, Copy, Debug, PartialEq)] pub(crate) struct CaptureGeometry { surface: SurfaceGeometry, + /// The active window's viewport, which the capture command goes to. + viewport: egui::ViewportId, document: u64, } @@ -500,6 +512,18 @@ impl CaptureGeometry { self.document } + /// Why a capture admitted for `viewport` and `document` can't go on against this geometry: + /// another window was brought forward, or the window shows another document. + fn moved_reason(self, viewport: egui::ViewportId, document: u64) -> Option<&'static str> { + if self.viewport != viewport { + Some("screenshot failed: another window was brought forward while the capture was queued") + } else if self.document != document { + Some("screenshot failed: the document changed while the capture was queued") + } else { + None + } + } + /// Explains why the surface cannot be captured, when it is minimized or fully occluded, and /// returns `None` when it can. pub(crate) fn unavailable_reason(self) -> Option<&'static str> { @@ -569,6 +593,9 @@ pub(crate) struct Shot { pub(crate) retries: u32, /// The document the request was admitted for. It never changes; a document switch cancels. pub(crate) document: u64, + /// The viewport of the window the request was admitted for. It never changes; bringing + /// another window forward cancels. + pub(crate) viewport: egui::ViewportId, pub(crate) geometry: CaptureGeometry, pub(crate) epoch: u64, pub(crate) expected: [usize; 2], @@ -623,6 +650,8 @@ fn next_token(app: &mut WordApp) -> u64 { /// earlier request. pub(crate) fn admit_screenshot(app: &mut WordApp, ctx: &egui::Context, path: Option, reply: Sender) { let token = next_token(app); + // An earlier request in this batch may have brought another window forward. + app.refresh_capture_geometry(ctx); let Some(geometry) = app.capture_geometry.map(|g| CaptureGeometry { document: app.session.document_id(), ..g }) else { reject_shot(reply, token, "screenshot failed: the window geometry is not known yet"); return; @@ -684,6 +713,7 @@ pub(crate) fn admit_screenshot(app: &mut WordApp, ctx: &egui::Context, path: Opt issued: false, retries: 0, document: geometry.document(), + viewport: geometry.viewport, geometry, epoch: app.capture_epoch, expected, @@ -708,7 +738,8 @@ pub(crate) fn issue_screenshots(app: &mut WordApp, ctx: &egui::Context) { let shot = &app.shots.requests[i]; (shot.issued, shot.ready_at, shot.frames) }; - if issued { + // Each window issues its own captures, from a pass that drew its layout. + if issued || app.shots.requests[i].viewport != ctx.viewport_id() { continue; } let frames = if first_pass { frames.saturating_add(1) } else { frames }; @@ -724,8 +755,8 @@ pub(crate) fn issue_screenshots(app: &mut WordApp, ctx: &egui::Context) { failures.push((i, format!("screenshot failed: {reason} while the capture was queued"))); continue; } - if current.document() != app.shots.requests[i].document { - failures.push((i, "screenshot failed: the document changed while the capture was queued".into())); + if let Some(reason) = current.moved_reason(app.shots.requests[i].viewport, app.shots.requests[i].document) { + failures.push((i, reason.into())); continue; } let Some(expected) = current.expected_size() else { @@ -762,7 +793,7 @@ pub(crate) fn issue_screenshots(app: &mut WordApp, ctx: &egui::Context) { shot.bytes = bytes; shot.epoch = epoch; if let Some(callback) = shot.callback.take() { - ctx.send_viewport_cmd_to(shot.viewport(), egui::ViewportCommand::Screenshot(callback)); + ctx.send_viewport_cmd_to(shot.viewport, egui::ViewportCommand::Screenshot(callback)); shot.issued = true; } } @@ -778,13 +809,6 @@ pub(crate) fn issue_screenshots(app: &mut WordApp, ctx: &egui::Context) { } } -impl Shot { - /// The screenshot callback's viewport: the root window. - fn viewport(&self) -> egui::ViewportId { - egui::ViewportId::ROOT - } -} - /// A completed image is either stale because the surface changed (safe to retry once within the /// deadline) or invalid for this request (report it at once; retrying cannot help). enum ImageCheck { @@ -850,6 +874,13 @@ fn take_screenshot_action(app: &mut WordApp, index: usize, now: f64, current: Op } return ShotAction::Remove(error_reply(shot.token, "no frame was presented before the screenshot timeout")); } + // Before the issue check: a window that closed never draws to issue its capture. + if let Some(reason) = current.and_then(|geometry| geometry.moved_reason(shot.viewport, shot.document)) { + if let Some(slot) = shot.slot.take() { + slot.cancel(); + } + return ShotAction::Remove(error_reply(shot.token, reason)); + } if !shot.issued { return ShotAction::Keep; } @@ -859,12 +890,6 @@ fn take_screenshot_action(app: &mut WordApp, index: usize, now: f64, current: Op } return ShotAction::Remove(error_reply(shot.token, format!("screenshot failed: {reason} while the capture was pending"))); } - if current.is_some_and(|geometry| geometry.document() != shot.document) { - if let Some(slot) = shot.slot.take() { - slot.cancel(); - } - return ShotAction::Remove(error_reply(shot.token, "screenshot failed: the document changed while the capture was queued")); - } shot.slot.as_ref().and_then(|slot| slot.take()) }; match completed { @@ -1080,7 +1105,7 @@ mod tests { /// Builds a capture geometry fixture for document id 7. fn geometry(viewport: egui::Vec2, pixels_per_point: f32, max_texture_side: usize) -> CaptureGeometry { - CaptureGeometry { surface: surface(viewport, pixels_per_point, max_texture_side), document: 7 } + CaptureGeometry { surface: surface(viewport, pixels_per_point, max_texture_side), viewport: egui::ViewportId::ROOT, document: 7 } } /// Creates a black test image of the given size. diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 1cb1c36577..8dd017d3b2 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -2478,15 +2478,9 @@ impl WordApp { if self.ctx.is_none() { self.ctx = Some(ctx.clone()); } - // Capture geometry/document identity is current before control admission and completion. - // The epoch is monotonic: resizing back to an earlier size is still a new epoch. - let capture_geometry = self.surface_geometry.map(|surface| surface.with_document(self.session.document_id())); - if let Some(geometry) = capture_geometry - && self.capture_geometry != Some(geometry) - { - self.capture_geometry = Some(geometry); - self.capture_epoch = self.capture_epoch.wrapping_add(1); - } + // Capture geometry/window/document identity is current before control admission and + // completion. + self.refresh_capture_geometry(ctx); let native = self.windows.native(); if native { self.printing.poll(ctx); @@ -2724,7 +2718,9 @@ impl WordApp { // Capture the surface from the raw input itself. `InputState::viewport_rect` and the // last painted screen rect are stale in a hidden `run_logic` tick; RawInput is the host's // current answer, including minimized/occluded and a Wayland `inner_rect: None`. - if let Some(geometry) = control::SurfaceGeometry::from_raw(raw, ctx) { + if raw.viewport_id == egui::ViewportId::ROOT + && let Some(geometry) = control::SurfaceGeometry::from_raw(raw, ctx) + { // A hidden resize and change back still invalidate an older capture, even if // neither intermediate geometry reaches a drawing or logic pass. if self.surface_geometry != Some(geometry) { @@ -3092,6 +3088,17 @@ impl WordApp { } } + /// Make the capture geometry the active window's surface, viewport and document. The epoch is + /// monotonic: resizing back to an earlier size is still a new epoch. + pub(crate) fn refresh_capture_geometry(&mut self, ctx: &egui::Context) { + let (viewport, surface) = self.active_surface_geometry(ctx); + let geometry = surface.map(|surface| surface.with_target(viewport, self.session.document_id())); + if self.capture_geometry != geometry { + self.capture_geometry = geometry; + self.capture_epoch = self.capture_epoch.wrapping_add(1); + } + } + /// Drains queued control requests, dispatches each through `control::handle`, sends its reply or /// admits a screenshot, then puts the receiver back. fn drain_control(&mut self, ctx: &egui::Context) { @@ -9744,7 +9751,7 @@ mod tests { let mut raw = egui::RawInput { screen_rect: Some(rect), ..Default::default() }; raw.viewports.get_mut(&egui::ViewportId::ROOT).unwrap().native_pixels_per_point = Some(1.5); let surface = control::SurfaceGeometry::from_raw(&raw, &ctx).expect("screen rect fallback"); - let geometry = surface.with_document(7); + let geometry = surface.with_target(egui::ViewportId::ROOT, 7); assert_eq!(geometry.expected_size(), Some([480, 300])); assert_eq!(geometry.expected_bytes(), Some(480 * 300 * 4)); } diff --git a/crates/ui-egui/src/windows.rs b/crates/ui-egui/src/windows.rs index 338b2ae550..2982e606e1 100644 --- a/crates/ui-egui/src/windows.rs +++ b/crates/ui-egui/src/windows.rs @@ -270,6 +270,18 @@ impl WordApp { self.windows.viewport(key) } + /// The active window's viewport and surface, for `ui.screenshot`: the root's from this + /// frame's raw input, another window's from the input it last drew with (the host's raw-input + /// hook sees only the root's). + pub(crate) fn active_surface_geometry(&self, ctx: &egui::Context) -> (ViewportId, Option) { + let viewport = self.active_viewport(); + if viewport == ViewportId::ROOT { + return (viewport, self.surface_geometry); + } + let (info, screen_rect, max_texture_side) = ctx.input_for(viewport, |i| (i.viewport().clone(), i.raw.screen_rect, i.raw.max_texture_side)); + (viewport, crate::control::SurfaceGeometry::from_viewport(&info, screen_rect, max_texture_side, ctx)) + } + /// The viewport new windows start from: the host's size, icon and app id. Their title and /// place are the app's. pub fn set_window_builder(&mut self, builder: egui::ViewportBuilder) { @@ -1065,6 +1077,45 @@ mod tests { assert_eq!(text(&a), "First", "the other window got none of it"); } + /// `ui.screenshot` captures the active window: the capture is sized from its viewport and the + /// command goes to that viewport. A capture still pending when another window comes forward + /// fails with an error instead of capturing that window. + #[test] + fn screenshot_captures_the_active_window() { + let ctx = backend(); + let mut a = app("First"); + frames(&ctx, &mut a, 2); + a.run("file.new", json!({})).unwrap(); + let child = a.active_viewport(); + let rect = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0)); + INFOS.with(|i| i.borrow_mut().entry(child).or_default().inner_rect = Some(rect)); + focus(child); + frames(&ctx, &mut a, 4); + + let (reply, replies) = std::sync::mpsc::channel(); + crate::control::admit_screenshot(&mut a, &ctx, None, reply); + assert!(replies.try_recv().is_err(), "the capture is admitted"); + assert_eq!(a.shots.requests.iter().map(|s| (s.viewport, s.expected)).collect::>(), [(child, [800, 600])]); + a.shots.requests.iter_mut().for_each(|s| s.ready_at = 0.0); + COMMANDS.with(|c| c.borrow_mut().clear()); + frames(&ctx, &mut a, 4); + let shot_to = + |id: ViewportId| COMMANDS.with(|c| c.borrow().get(&id).is_some_and(|v| v.iter().any(|c| matches!(c, ViewportCommand::Screenshot(_))))); + assert!(shot_to(child), "the active window is captured"); + assert!(!shot_to(ViewportId::ROOT), "the first window is not"); + + let (reply, later) = std::sync::mpsc::channel(); + crate::control::admit_screenshot(&mut a, &ctx, None, reply); + focus(ViewportId::ROOT); + frames(&ctx, &mut a, 2); + for r in [replies, later] { + let v = r.try_recv().expect("the capture was answered"); + assert_eq!(v["ok"], false); + assert!(v["error"].as_str().is_some_and(|e| e.contains("another window")), "{v}"); + } + assert!(a.shots.requests.is_empty()); + } + /// Quitting with two documents of unsaved changes asks about each in its window, the window /// coming to the front; Cancel keeps both. #[test] From f93c0cbe522ac9df5407b3bcc73971fd9f5e51e1 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 03:57:59 +0000 Subject: [PATCH 196/199] W1: the merge-records test expects the result beside the unsaved source, with no save prompt --- crates/ui-egui/src/dialogs.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index eb82bdf312..bb29b90aee 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -4801,6 +4801,7 @@ mod tests { let _ = app.run("mailings.recipients", json!({"csv": "Name\nAda\nAlan\nGrace"})); let _ = app.run("text.insert", json!({"text": "unsaved"})); assert!(app.session.dirty); + let source = app.session.document_id(); app.dialog = Dialog::open("mergeRecords", &mut app); if let Some(Dialog::MergeRecords { choice, from, to, .. }) = &mut app.dialog { *choice = 2; @@ -4836,8 +4837,9 @@ mod tests { for _ in 0..2 { h.step(); } - assert_eq!(h.state().dialog.as_ref().map(|d| d.name()), Some("saveChanges")); - assert!(h.state().session.doc.plain_text(wordcraft_doc::StoryRef::Body).contains("unsaved"), "nothing merged yet"); + // The merged document opens beside the unsaved source, so nothing asks to save it. + assert_eq!(h.state().dialog.as_ref().map(|d| d.name()), None); + assert_ne!(h.state().session.document_id(), source, "the merge result is a new document"); } #[test] From e71c9ce31d411736613ffb9b9c5a09c47c087cac Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Mon, 26 Oct 2026 04:01:47 +0000 Subject: [PATCH 197/199] =?UTF-8?q?#3866:=20review=20=E2=80=94=20the=20fir?= =?UTF-8?q?st-page=20index=20is=20built=20only=20when=20the=20hinted=20pag?= =?UTF-8?q?e=20doesn't=20show=20the=20paragraph?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/layout/src/hit.rs | 5 +++-- crates/layout/src/tests_linear.rs | 24 ++++++++++++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/crates/layout/src/hit.rs b/crates/layout/src/hit.rs index 2b1bb66d4c..148ce88cdf 100644 --- a/crates/layout/src/hit.rs +++ b/crates/layout/src/hit.rs @@ -543,8 +543,9 @@ impl DocLayout { if pages.story == StoryRef::Body { return self.pieces(StoryRef::Body, path, page_hint); } - let first = pages.first_page(self, path); - self.part_pieces(page_hint, &mut || first, &mut |pi| pages.on(self, pi, path)) + // The first-page index is made only when the hinted page doesn't show the paragraph. + let pages = std::cell::RefCell::new(pages); + self.part_pieces(page_hint, &mut || pages.borrow_mut().first_page(self, path), &mut |pi| pages.borrow_mut().on(self, pi, path)) } /// Pieces of a paragraph of story part `story` on page `pi`. diff --git a/crates/layout/src/tests_linear.rs b/crates/layout/src/tests_linear.rs index b242b00c3c..c05a475ffe 100644 --- a/crates/layout/src/tests_linear.rs +++ b/crates/layout/src/tests_linear.rs @@ -465,3 +465,27 @@ fn drawing_items_one_by_one_matches_a_page_display_of_each() { assert!(!markup || coloured > 0, "some tracked text is in an author's colour"); } } + +/// #3866: a header paragraph on the hinted page is found there, without a walk of every page of a +/// long document to find the first page that shows it. +#[test] +fn selecting_a_header_on_the_hinted_page_does_not_walk_every_page() { + let laid = |n: usize| { + let mut d = Document::from_text("n"); + let huge = CharProps { size: Some(400.0), ..Default::default() }; + d.body = (0..n).map(|_| wordcraft_doc::para_block(Paragraph::with_text("n", huge.clone()))).collect(); + let id = d.add_part(wordcraft_doc::PartKind::Header, vec![wordcraft_doc::para_block(Paragraph::with_text("head", CharProps::default()))]); + d.last_section.headers.default = Some(id); + let l = lay(&d, &LayoutOptions::default()); + let ends = (Pos::new(StoryRef::Part(id), Path::top(0), 0), Pos::new(StoryRef::Part(id), Path::top(0), 2)); + (d, l, ends) + }; + let steps = |n: usize| { + let (d, l, (a, b)) = laid(n); + assert!(l.pages.len() >= n, "a page to each paragraph: {}", l.pages.len()); + work::count(|| drop(std::hint::black_box(l.selection_rects(&d, &a, &b, 0)))) + }; + let (small, big) = (steps(20), steps(160)); + eprintln!("hinted header: {small} steps for 20 pages, {big} for 160"); + assert!(small > 0 && big <= small + 8, "the pages aren't walked: {small} → {big}"); +} From b94821ea1629766e4aaa279bac126829ec21cb4a Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Mon, 26 Oct 2026 04:05:47 +0000 Subject: [PATCH 198/199] =?UTF-8?q?#3866:=20review=20=E2=80=94=20count=20a?= =?UTF-8?q?=20step=20per=20page=20walked=20in=20the=20first-page=20index,?= =?UTF-8?q?=20so=20the=20hinted-page=20test=20sees=20an=20eager=20build?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/layout/src/hit.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/layout/src/hit.rs b/crates/layout/src/hit.rs index 148ce88cdf..c0f5f20bc4 100644 --- a/crates/layout/src/hit.rs +++ b/crates/layout/src/hit.rs @@ -163,6 +163,7 @@ impl<'a> PartPages<'a> { let first = self.first.get_or_insert_with(|| { let mut first: HashMap<&'a Path, usize> = HashMap::new(); for (pi, p) in lay.pages.iter().enumerate() { + crate::work::steps(1); for it in items_of(p, story) { if let Placed::Lines { story: s, path, .. } = it && *s == story From 49c2f61373d5c0eca7e9f07a1f52d36726e79d73 Mon Sep 17 00:00:00 2001 From: Martin Ball Date: Mon, 26 Oct 2026 08:08:46 +0000 Subject: [PATCH 199/199] #3866: tests_linear imports Page and Rgb (main's layout tests didn't compile after #4392) #4392's tests used Page and Rgb, which other changes merged around it no longer brought into scope, so main's CI gate fails with E0425/E0422 in tests_linear.rs. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01HUodYU9r4mPHed4pPsjHC2 --- crates/layout/src/tests_linear.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/layout/src/tests_linear.rs b/crates/layout/src/tests_linear.rs index 31b451d8dd..4dc2d741dc 100644 --- a/crates/layout/src/tests_linear.rs +++ b/crates/layout/src/tests_linear.rs @@ -7,10 +7,11 @@ use super::tests::{SCALE, cost_ratio}; use super::{ - Block, CharProps, DocLayout, Document, HeaderSet, HeightRule, InlineObject, LayoutCache, LayoutOptions, ParaLayout, Paragraph, Path, Placed, - SectionProps, SectionStart, StoryRef, display, layout, para, work, + Block, CharProps, DocLayout, Document, HeaderSet, HeightRule, InlineObject, LayoutCache, LayoutOptions, Page, ParaLayout, Paragraph, Path, + Placed, SectionProps, SectionStart, StoryRef, display, layout, para, work, }; use wordcraft_doc::para::Run; +use wordcraft_doc::props::Rgb; use wordcraft_doc::{Pos, Table}; fn lay(d: &Document, opts: &LayoutOptions) -> DocLayout {