From 1350f62bf066b6d062f759eeba6fc118bad70d2f Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Wed, 4 Nov 2026 13:59:15 +0100 Subject: [PATCH 1/3] =?UTF-8?q?#2726:=20AccessKit=20focus=20guard=20?= =?UTF-8?q?=E2=80=94=20every=20tree=20update=20carries=20its=20focused=20n?= =?UTF-8?q?ode=20(#2742)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- Cargo.lock | 1 + ROADMAP.md | 1 + crates/ui-egui/Cargo.toml | 3 + crates/ui-egui/src/a11y.rs | 123 +++++++++++++++++++++++++++++++++++++ crates/ui-egui/src/app.rs | 3 + 5 files changed, 131 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index 91f605aeb4..07ca64e954 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7043,6 +7043,7 @@ dependencies = [ name = "wordcraft-ui-egui" version = "0.15.1" dependencies = [ + "accesskit_consumer", "cfb", "chrono", "craft-i18n", diff --git a/ROADMAP.md b/ROADMAP.md index d3e1c58b4c..109b75bca3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -301,6 +301,7 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target | 2026-10-18 | Clearing a font query with no matches keeps Recently Used Fonts visible at the top of a scrollable list. The initial scroll position no longer skips its heading; measured keyboard scrolling is unchanged (#2068). | | 2026-10-19 | Centred numbered headings after a leading page break keep their number before the text (#2964). The label uses the alignment of the line it is drawn on; heading text positions and page count stay the same. | | 2026-10-17 | Edit Recipient List finds records (#1576): a Find in list box hides rows with no cell containing the text (any case) and counts the matches, and "Show only records that will be merged" hides the rows the filter leaves out; both only change the view. Rows are striped, and edits and removes go to the right record however the grid is narrowed | +| 2026-10-17 | AccessKit focus guard (#2726): every tree update egui sends carries its focused node, so a frame that drops it falls back to the root instead of panicking in `accesskit_consumer` and closing the window on Windows | | 2026-10-17 | Equation arrays centre a row that has no `&` among rows that do, as Word does, so case equations like Word's "First Property of Radicals" no longer push the aligned row right (#2617). | | 2026-10-17 | Formatting revisions: a table, row, cell or section property change made while tracking now records as `w:tblPrChange`/`w:trPrChange`/`w:tcPrChange`/`w:sectPrChange` (structure stays out), so Reject All restores and Accept All keeps it; test tracked_table_changes_are_kept_as_formatting_changes | | 2026-10-16 | English dialects (#1281, #1045): English (UK), (Australia), (Canada) and the other Englishes are proofed in their own spelling — *colour* is flagged in a US document and *color* in a British one, with the right form suggested first — from our own list of American/British spellings, so nothing to install. Review › Language lists them, the .docx `w:lang` keeps them, and an English interface on a British (or Canadian, Australian…) system starts new documents in that English | diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 9104787589..cf513e8fa7 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -61,6 +61,9 @@ wordcraft-engine = { workspace = true, features = ["test-support"] } egui = { workspace = true, features = ["default_fonts", "monochrome_emoji_fonts"] } egui_kittest = { workspace = true, features = ["wgpu", "snapshot"] } zip = { workspace = true } +# #2726's regression test: the consumer the Windows adapter runs, fed with our updates. +# Pinned to the version egui already brings into the tree, so nothing is built twice. +accesskit_consumer = "=0.39.1" [build-dependencies] serde_json = { workspace = true } diff --git a/crates/ui-egui/src/a11y.rs b/crates/ui-egui/src/a11y.rs index 6963c5e375..6761925abc 100644 --- a/crates/ui-egui/src/a11y.rs +++ b/crates/ui-egui/src/a11y.rs @@ -75,6 +75,39 @@ pub struct Tree { pub selection: Option, } +/// #2726: AccessKit's contract is that every update's focus is in the node list, and +/// `accesskit_consumer` treats a breach as fatal: `validate_global` panics on the UI thread, +/// which closes the window (the Windows adapter reports `Focused ID #… is not in the node list`). +/// egui can send an update that drops a focused widget's node, and a screen reader's focus +/// request targets nodes in this document description, which the list carries only while the +/// layout, the pages on screen and the selection are the same as when it asked. This guard runs +/// on every update egui produces, before it reaches the adapter: a focus the update does not +/// carry falls back to the root, so the reader starts from the top of the document instead of +/// the process dying. +pub struct FocusGuard; + +impl egui::plugin::Plugin for FocusGuard { + fn debug_name(&self) -> &'static str { + "a11y focus guard" + } + + fn output_hook(&mut self, _ctx: &egui::Context, output: &mut egui::FullOutput) { + if let Some(update) = &mut output.platform_output.accesskit_update { + guard_update(update); + } + } +} + +/// Point the focus at the root when this update's node list does not contain it. +fn guard_update(update: &mut egui::accesskit::TreeUpdate) { + if update.nodes.iter().any(|(id, _)| *id == update.focus) { + return; + } + if let Some(root) = update.tree.as_ref().map(|t| t.root) { + update.focus = root; + } +} + /// The pages to describe: those meeting `view`, and one either side. pub fn page_window(rects: &[Rect], view: Rect) -> Option<(usize, usize)> { let first = rects.iter().position(|r| r.intersects(view))?; @@ -782,6 +815,96 @@ mod tests { ); } + /// #2726: the Windows adapter's consumer dies on an update whose focus is not in the node + /// list (`Focused ID #… is not in the node list`). A drop like that is fatal: `Tree::new` + /// panics. The guard moves the focus to the root, and the same update is accepted. + #[test] + fn a_focus_the_update_does_not_carry_falls_back_to_the_root() { + let root = egui::accesskit_root_id().accesskit_id(); + let lost = egui::Id::unique("run_that_was_dropped").accesskit_id(); + let make = |focus: accesskit::NodeId| accesskit::TreeUpdate { + nodes: vec![ + (root, { + let mut n = Node::new(Role::Window); + n.push_child(egui::Id::unique("kept").accesskit_id()); + n + }), + (egui::Id::unique("kept").accesskit_id(), Node::new(Role::TextRun)), + ], + tree: Some(accesskit::TreeInfo::new(root)), + tree_id: accesskit::TreeId::ROOT, + focus, + }; + // Red without the guard: the raw update breaks the consumer's contract. + let raw = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| accesskit_consumer::Tree::new(make(lost), true))); + assert!(raw.is_err(), "the update that drops the focused node must break the consumer"); + let mut fixed = make(lost); + guard_update(&mut fixed); + assert_eq!(fixed.focus, root); + assert!( + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| accesskit_consumer::Tree::new(fixed, true))).is_ok(), + "the guarded update is accepted" + ); + } + + /// Every update the app sends — idle frames, typing, scrolling, selection, dialogs — feeds + /// the real Windows consumer without breaking its contract: its focus is in the node list. + #[test] + fn every_update_the_app_sends_is_valid_for_the_consumer() { + let mut app = crate::WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default()); + app.run("text.insert", json!({"text": "Hello word"})).unwrap(); + app.run("para.bullets", json!({})).unwrap(); + app.run("insert.table", json!({"rows": 2, "cols": 2})).unwrap(); + app.run("text.insert", json!({"text": "Cell"})).unwrap(); + let ctx = egui::Context::default(); + ctx.enable_accesskit(); + let mut handler = NoopHandler; + let mut tree: Option = None; + for frame in 0..10 { + let mut input = egui::RawInput { + screen_rect: Some(Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1440.0, 900.0))), + time: Some(frame as f64 * 0.1), + ..Default::default() + }; + if frame == 5 { + // A screen reader asks to focus a text run deep in the document description. + let (doc, _) = app.canvas.widget_id.expect("the canvas widget"); + input.events.push(egui::Event::AccessKitActionRequest(accesskit::ActionRequest { + action: accesskit::Action::Focus, + target_node: doc.with(("para", [0u32])).with(("run", 0usize, 0usize)).accesskit_id(), + target_tree: accesskit::TreeId::ROOT, + data: None, + })); + } + let mut out = ctx.run_ui(input, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }); + out.textures_delta.clear(); + let Some(update) = out.platform_output.accesskit_update else { continue }; + tree = Some(match tree { + None => accesskit_consumer::Tree::new(update, true), + Some(mut t) => { + t.update_and_process_changes(update, &mut handler); + t + } + }); + } + let tree = tree.expect("the app sends AccessKit updates"); + // The consumer's focus is one of its nodes. + let focus = tree.state().focus_id_in_tree(); + assert!(tree.state().has_node(focus), "the consumer's focus is in the node list"); + } + + struct NoopHandler; + + impl accesskit_consumer::TreeChangeHandler for NoopHandler { + fn node_added(&mut self, _node: &accesskit_consumer::NodeRef<'_>) {} + fn node_updated(&mut self, _old_node: &accesskit_consumer::NodeRef<'_>, _new_node: &accesskit_consumer::NodeRef<'_>) {} + fn focus_moved(&mut self, _old_node: Option<&accesskit_consumer::NodeRef<'_>>, _new_node: Option<&accesskit_consumer::NodeRef<'_>>) {} + fn node_removed(&mut self, _node: &accesskit_consumer::NodeRef<'_>) {} + } + /// Hostile layouts and junk geometry don't panic and the tree stays bounded. #[test] fn junk_geometry_is_harmless() { diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 69782aac2c..fbb3172ab3 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -223,6 +223,9 @@ impl WordApp { /// Per-frame logic before layout: control requests, screenshots, shortcuts, file drops. pub fn logic(&mut self, ctx: &egui::Context) { + // #2726: egui's AccessKit update must carry the focused node in its node list; a frame + // that drops it panics in `accesskit_consumer` and closes the window (see `a11y::FocusGuard`). + ctx.add_plugin(super::a11y::FocusGuard); updates_ui::tick(self, ctx); crate::language_packs::tick(self); let lang = i18n::Lang::from_pref(&self.ui.language); From 560565cdd75327d3fe2e1f540649b4ad0b2315f2 Mon Sep 17 00:00:00 2001 From: VoiceLessQ <44067308+VoiceLessQ@users.noreply.github.com> Date: Wed, 4 Nov 2026 11:13:36 -0200 Subject: [PATCH 2/3] Audit fixes for 12 issues: import limits, escaping, link schemes, table depth (#3862) --- crates/control-key/src/lib.rs | 1 + crates/doc/src/edit/tests.rs | 8 +- crates/doc/src/table.rs | 10 +- crates/engine/src/cmd/index.rs | 37 ++++++- crates/engine/src/cmd/insert/objects.rs | 3 +- crates/engine/src/cmd/insert/tests.rs | 15 +++ crates/engine/src/cmd/mailings.rs | 19 ++-- crates/engine/src/cmd/mailings/merge.rs | 18 ++-- crates/engine/src/cmd/mailings/tests.rs | 23 +++++ crates/engine/src/cmd/references.rs | 82 ++++++++++------ crates/engine/src/tests.rs | 79 ++++++++++++++- crates/formats/src/html.rs | 2 + crates/formats/src/html/css.rs | 44 ++++++++- crates/formats/src/html/export.rs | 20 +++- crates/formats/src/html/tests.rs | 38 +++++++ crates/formats/src/markdown.rs | 8 ++ crates/formats/src/markdown/blocks.rs | 125 ++++++++++++++++-------- crates/formats/src/markdown/tests.rs | 61 ++++++++++++ crates/formats/src/model/types.rs | 19 ++++ crates/formats/src/odt.rs | 2 +- crates/formats/src/odt/body.rs | 16 ++- crates/formats/src/odt/import.rs | 7 +- crates/formats/src/odt/limits.rs | 2 + crates/formats/src/odt/page_parts.rs | 7 +- crates/formats/src/odt/tests.rs | 50 ++++++++++ crates/formats/src/rtf/export.rs | 8 +- crates/formats/src/rtf/tests.rs | 34 +++++++ crates/formats/src/rtf/text.rs | 24 ++++- crates/pdf/src/tests.rs | 40 +++++++- docs/target-app-parity.md | 5 +- 30 files changed, 679 insertions(+), 128 deletions(-) diff --git a/crates/control-key/src/lib.rs b/crates/control-key/src/lib.rs index 1c562f40ab..1f56b58686 100644 --- a/crates/control-key/src/lib.rs +++ b/crates/control-key/src/lib.rs @@ -1,5 +1,6 @@ //! The control channel's key file, one rule for the app (which writes it) and the MCP bridge //! (which reads it): `/control-key.`. See `docs/control-protocol.md`, Keys. +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] use std::path::{Path, PathBuf}; diff --git a/crates/doc/src/edit/tests.rs b/crates/doc/src/edit/tests.rs index bb7eec6f16..430de05c9c 100644 --- a/crates/doc/src/edit/tests.rs +++ b/crates/doc/src/edit/tests.rs @@ -663,13 +663,15 @@ fn removing_a_whole_table_keeps_a_control_the_range_only_partly_covers() { #[test] fn a_table_nested_deeper_than_the_paragraph_walk_is_never_removed_whole() { - // 20 tables, each in the first cell of the one around it, ahead of that cell's paragraph: - // the innermost paragraphs come first in the document but the paragraph walk stops short. + // The deepest nesting allowed, each table in the first cell of the one around it, ahead of + // that cell's paragraph: the innermost paragraphs come first in the document but the + // paragraph walk stops short. + const { assert!(MAX_TABLE_DEPTH > crate::PARA_WALK_DEPTH) }; let mut t = Table::new(1, 1, 200.0); if let Some(cell) = t.rows.get_mut(0).and_then(|r| r.cells.get_mut(0)) { cell.blocks = vec![para_block(Paragraph::with_text("deep", CharProps::default()))]; } - for _ in 0..20 { + for _ in 1..MAX_TABLE_DEPTH { let mut outer = Table::new(1, 1, 200.0); if let Some(cell) = outer.rows.get_mut(0).and_then(|r| r.cells.get_mut(0)) { cell.blocks.insert(0, Arc::new(Block::Table(t))); diff --git a/crates/doc/src/table.rs b/crates/doc/src/table.rs index abdda89241..bb951aa672 100644 --- a/crates/doc/src/table.rs +++ b/crates/doc/src/table.rs @@ -71,10 +71,12 @@ pub struct Table { /// Hard limits that keep hostile input from exhausting memory. pub const MAX_ROWS: usize = 32_767; pub const MAX_COLS: usize = 63; -/// Most tables nested one inside another: the depth the DOCX reader and writer keep. Pasting -/// and inserting stop there too, so no edit builds a document deep enough to overflow the stack -/// when it is saved, copied or dropped (#1596). -pub const MAX_TABLE_DEPTH: usize = 24; +/// Most tables nested one inside another. Pasting and inserting stop there, so no edit builds a +/// document deep enough to overflow the stack when it is saved, copied or dropped (#1596), or +/// deeper than a `.json` save reads back: serde_json reads 127 nested containers, a table level +/// takes six, and the deepest cell of a header, note or text box needs up to 15 more for its +/// paragraph and the richest content measured (#3618). The DOCX reader and writer keep 24. +pub const MAX_TABLE_DEPTH: usize = 18; impl Table { /// Grid columns `lo..hi` that [`Table::slice`] copies for `rows`/`cols` (widened over merged cells), diff --git a/crates/engine/src/cmd/index.rs b/crates/engine/src/cmd/index.rs index d2479bf353..fc082ba642 100644 --- a/crates/engine/src/cmd/index.rs +++ b/crates/engine/src/cmd/index.rs @@ -10,7 +10,7 @@ use wordcraft_doc::props::{CharProps, TabAlign, TabLeader, TabStop}; use wordcraft_doc::section::SectionStart; use wordcraft_doc::{Block, Paragraph, Path, Pos, StoryRef}; -use super::references::{FieldCode, leader}; +use super::references::{FieldCode, field_arg, leader}; use super::sel_result; use crate::{CmdError, CmdResult, CommandSpec, Session, p}; @@ -44,11 +44,12 @@ fn xe_instr(s: &Session, v: &Value, main: &str) -> Result { let mut instr = if sub.is_empty() { format!("XE \"{main}\"") } else { format!("XE \"{main}:{sub}\"") }; if let Some(see) = p::str(v, "crossReference").map(|t| clean(t, 200)).filter(|t| !t.is_empty()) { instr.push_str(&format!(" \\t \"{see}\"")); - } else if let Some(bm) = p::str(v, "bookmark").filter(|b| !b.trim().is_empty()) { + } else if let Some(bm) = p::str(v, "bookmark").map(str::trim).filter(|b| !b.is_empty()) { if !s.doc.bookmarks().iter().any(|(n, _)| n == bm) { return Err(CmdError::Params(format!("no bookmark `{bm}`"))); } - instr.push_str(&format!(" \\r {}", bm.trim())); + // A name read from a file may hold a space, a quote or a backslash. + instr.push_str(&format!(" \\r {}", field_arg(bm))); } if p::bool(v, "bold").unwrap_or(false) { instr.push_str(" \\b"); @@ -393,6 +394,36 @@ mod tests { s.doc.body.iter().filter_map(|b| b.as_para()).filter(|p| p.props.style.as_deref() == Some(ENTRY_STYLE)).map(|p| p.plain_text()).collect() } + /// #3669: the bookmark is looked up trimmed, and a name with a space or a quote (one a file + /// can hold) is quoted in the XE field, so it reads back whole. + #[test] + fn mark_entry_trims_and_quotes_its_bookmark() { + let mut s = Session::new(wordcraft_doc::Document::from_text("Intro text.\nMore text.")); + s.run("select.text", &json!({"text": "Intro"})).unwrap(); + s.run("insert.bookmark", &json!({"name": "Intro"})).unwrap(); + let odd = "My \"Mark\" \\b"; + let p = s.doc.para_mut(StoryRef::Body, &Path::top(1)).unwrap(); + p.insert_object(0, InlineObject::BookmarkStart { name: odd.into() }, &CharProps::default()).unwrap(); + s.run("caret.docEnd", &json!({})).unwrap(); + s.run("references.markEntry", &json!({"entry": "Alpha", "bookmark": " Intro "})).unwrap(); + s.run("references.markEntry", &json!({"entry": "Beta", "bookmark": odd})).unwrap(); + let mut instrs: Vec = s + .doc + .body + .iter() + .filter_map(|b| b.as_para()) + .flat_map(|p| p.objects.iter()) + .filter_map(|o| match o { + InlineObject::Field { instr, .. } if instr.starts_with("XE") => Some(instr.clone()), + _ => None, + }) + .collect(); + instrs.sort(); + assert_eq!(instrs, vec![r#"XE "Alpha" \r Intro"#, r#"XE "Beta" \r "My \"Mark\" \\b""#]); + let beta = FieldCode::parse(&instrs[1]); + assert_eq!((beta.arg('r'), beta.has('b')), (Some(odd), false)); + } + /// #402: Mark Entry writes XE switches (subentry, bold page, cross-reference, Mark All) and /// the INDEX field's switches change the index: run-in subentries, letter headings, a tab /// before right-aligned page numbers, and two columns in a continuous section. diff --git a/crates/engine/src/cmd/insert/objects.rs b/crates/engine/src/cmd/insert/objects.rs index 2937d2dae4..d5e621b9a2 100644 --- a/crates/engine/src/cmd/insert/objects.rs +++ b/crates/engine/src/cmd/insert/objects.rs @@ -137,7 +137,8 @@ fn place_picture(s: &mut Session, bytes: Vec, width: Option, alt: &str) let max_w = s.doc.sections().first().map(|(_, sp)| sp.text_width()).unwrap_or(468.0); let (mut w, mut h) = (natural_w, natural_h); if let Some(want) = width { - let k = want.max(4.0) / w.max(1.0); + // At most 22 inches, as for icons: a huge width made the height of a tall picture infinite. + let k = want.clamp(4.0, 1584.0) / w.max(1.0); w *= k; h *= k; } diff --git a/crates/engine/src/cmd/insert/tests.rs b/crates/engine/src/cmd/insert/tests.rs index fbff175e70..5624d813ad 100644 --- a/crates/engine/src/cmd/insert/tests.rs +++ b/crates/engine/src/cmd/insert/tests.rs @@ -1,5 +1,20 @@ use super::*; +/// #3619: a huge finite width can't make a tall picture's height infinite, which a `.json` save +/// writes as `null` and can't read back. +#[test] +fn a_huge_picture_width_keeps_the_size_finite() { + let img = image::RgbaImage::from_pixel(2, 400, image::Rgba([10, 20, 30, 255])); + let mut png = Vec::new(); + image::DynamicImage::ImageRgba8(img).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + let mut s = Session::new(wordcraft_doc::Document::new()); + let r = s.run("insert.picture", &json!({"data": base64_encode(&png), "width": 3e38})).unwrap(); + let (w, h) = (r["width"].as_f64().unwrap(), r["height"].as_f64().unwrap()); + assert!(w.is_finite() && h.is_finite() && (h / w - 200.0).abs() < 0.01, "{w} x {h}"); + let saved = crate::io::save_bytes("a.json", &s.doc).unwrap(); + assert!(crate::io::open_bytes("a.json", &saved).is_ok()); +} + /// A 4 × 4 RGB TIFF that says its strips are JPEG (they aren't): its header still reads. fn jpeg_strip_tiff() -> Vec { let mut t = Vec::new(); diff --git a/crates/engine/src/cmd/mailings.rs b/crates/engine/src/cmd/mailings.rs index a0c6fa2a60..17881014d6 100644 --- a/crates/engine/src/cmd/mailings.rs +++ b/crates/engine/src/cmd/mailings.rs @@ -7,6 +7,7 @@ use wordcraft_doc::props::{Align, CharProps}; use wordcraft_doc::section::{SectionProps, SectionStart}; use wordcraft_doc::{Block, Blocks, Document, Paragraph, Part, PartKind, Pos, StoryRef, Table, para_block}; +use super::references::{field_arg, field_words, quoted}; use super::{delete_selection, sel_result}; use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p}; @@ -122,7 +123,7 @@ pub fn specs() -> Vec { .pure(), CommandSpec::new("mailings.insertField", "Insert Merge Field", "Mailings › Write & Insert Fields", |s, v| { let f = p::req_str(v, "field")?.to_string(); - insert_field(s, &format!("MERGEFIELD {}", quote(&f)), &format!("«{f}»")) + insert_field(s, &format!("MERGEFIELD {}", field_arg(&f)), &format!("«{f}»")) }) .params(r#"{"field": string}"#) .key(super::non_mac("Alt+Shift+F")), @@ -136,14 +137,14 @@ pub fn specs() -> Vec { let rule = p::str(v, "rule").unwrap_or("NEXT").to_ascii_uppercase(); let instr = match rule.as_str() { "IF" => format!( - "IF {{ MERGEFIELD {} }} = \"{}\" \"{}\" \"{}\"", - quote(p::str(v, "field").unwrap_or("Field")), - p::str(v, "value").unwrap_or(""), - p::str(v, "then").unwrap_or(""), - p::str(v, "else").unwrap_or("") + "IF {{ MERGEFIELD {} }} = {} {} {}", + field_arg(p::str(v, "field").unwrap_or("Field")), + quoted(p::str(v, "value").unwrap_or("")), + quoted(p::str(v, "then").unwrap_or("")), + quoted(p::str(v, "else").unwrap_or("")) ), "SKIPIF" | "NEXTIF" => { - format!("{rule} {{ MERGEFIELD {} }} = \"{}\"", quote(p::str(v, "field").unwrap_or("Field")), p::str(v, "value").unwrap_or("")) + format!("{rule} {{ MERGEFIELD {} }} = {}", field_arg(p::str(v, "field").unwrap_or("Field")), quoted(p::str(v, "value").unwrap_or(""))) } "MERGEREC" => "MERGEREC".into(), _ => "NEXT".into(), @@ -227,10 +228,6 @@ pub fn specs() -> Vec { ] } -fn quote(f: &str) -> String { - if f.contains(' ') { format!("\"{f}\"") } else { f.to_string() } -} - fn insert_field(s: &mut Session, instr: &str, placeholder: &str) -> CmdResult { let props = s.typing_props(); let at = delete_selection(s)?; diff --git a/crates/engine/src/cmd/mailings/merge.rs b/crates/engine/src/cmd/mailings/merge.rs index 7b1c99b5e1..80fad25da7 100644 --- a/crates/engine/src/cmd/mailings/merge.rs +++ b/crates/engine/src/cmd/mailings/merge.rs @@ -1,6 +1,6 @@ use super::{ Block, Blocks, CmdError, CmdResult, Document, InlineObject, Paragraph, Part, PartKind, SectionProps, SectionStart, Session, StoryRef, Value, - json, p, para_block, + field_words, json, p, para_block, }; pub(super) fn step(s: &mut Session, d: i64) -> CmdResult { @@ -41,9 +41,7 @@ pub(super) fn mergefield_name(instr: &str) -> Option { if !kind.eq_ignore_ascii_case("MERGEFIELD") { return None; } - let rest = rest.trim(); - let name = if let Some(r) = rest.strip_prefix('"') { r.split('"').next().unwrap_or("") } else { rest.split_whitespace().next().unwrap_or("") }; - Some(name.to_string()) + Some(field_words(rest).into_iter().next().map(|(name, _)| name).unwrap_or_default()) } /// Which columns hold address parts. @@ -103,17 +101,17 @@ fn rule_field(t: &str) -> Option { /// Comparison of a rule condition `{ MERGEFIELD X } op "value" …` (IF, SKIPIF, NEXTIF) for /// `row`: whether it holds, and the quoted texts after the value (IF's true / false text). -fn condition<'a>(s: &Session, t: &'a str, row: usize) -> (bool, Vec<&'a str>) { +fn condition(s: &Session, t: &str, row: usize) -> (bool, Vec) { let field = rule_field(t).unwrap_or_default(); let rest = t.split_once('}').map(|(_, r)| r).unwrap_or(""); let op: String = rest.trim_start().chars().take_while(|c| matches!(c, '<'..='>')).collect(); - let mut quoted = rest.split('"').skip(1).step_by(2); - let cmp = quoted.next().unwrap_or(""); + let mut texts = field_words(rest).into_iter().filter(|(_, q)| *q).map(|(w, _)| w); + let cmp = texts.next().unwrap_or_default(); let v = value(s, row, &field); // Numbers compare as numbers (Word: "7" = "7.0"), everything else as text. let ord = match (v.trim().parse::(), cmp.trim().parse::()) { (Ok(a), Ok(b)) => a.partial_cmp(&b), - _ => Some(v.as_str().cmp(cmp)), + _ => Some(v.as_str().cmp(cmp.as_str())), }; use std::cmp::Ordering::{Equal, Greater, Less}; let holds = match op.as_str() { @@ -124,7 +122,7 @@ fn condition<'a>(s: &Session, t: &'a str, row: usize) -> (bool, Vec<&'a str>) { ">=" => matches!(ord, Some(Greater | Equal)), _ => ord == Some(Equal), }; - (holds, quoted.collect()) + (holds, texts.collect()) } /// The first word of a field instruction, upper-cased. @@ -149,7 +147,7 @@ fn act(s: &Session, instr: &str, row: usize) -> Option { // IF { MERGEFIELD X } = "v" "then" "else" (an IF on other fields isn't ours to touch). "IF" if rule_field(t).is_some() => { let (holds, texts) = condition(s, t, row); - Act::Text(texts.get(if holds { 0 } else { 1 }).copied().unwrap_or("").to_string()) + Act::Text(texts.get(if holds { 0 } else { 1 }).cloned().unwrap_or_default()) } _ => return None, }) diff --git a/crates/engine/src/cmd/mailings/tests.rs b/crates/engine/src/cmd/mailings/tests.rs index b7f5eefe14..9c6eef7c43 100644 --- a/crates/engine/src/cmd/mailings/tests.rs +++ b/crates/engine/src/cmd/mailings/tests.rs @@ -307,6 +307,29 @@ fn if_rule_previews_its_result() { assert_eq!(s.doc.plain_text(StoryRef::Body), "Selected\nOther"); } +/// #3681: quotes and backslashes in a rule's texts and field name stay in them. +#[test] +fn rule_texts_keep_their_quotes() { + let mut s = Session::new(Document::new()); + let rows = json!([{"Say \"hi\"": "a\\b", "Name": "O\"Neil"}, {"Say \"hi\"": "no", "Name": "Plain"}]); + s.run("mailings.recipients", &json!({"rows": rows})).unwrap(); + s.run("mailings.start", &json!({"kind": "directory"})).unwrap(); + let rule = json!({"rule": "IF", "field": "Say \"hi\"", "value": "a\\b", "then": "Dear \"friend\"", "else": "C:\\dir\\"}); + s.run("mailings.rules", &rule).unwrap(); + s.run("mailings.preview", &json!({"value": true})).unwrap(); + assert_eq!(s.doc.plain_text(StoryRef::Body), "Dear \"friend\""); + s.run("mailings.next", &json!({})).unwrap(); + assert_eq!(s.doc.plain_text(StoryRef::Body), "C:\\dir\\"); + // SKIPIF compares with the value as typed. + let mut s = Session::new(Document::new()); + s.run("mailings.recipients", &json!({"rows": rows})).unwrap(); + s.run("mailings.start", &json!({"kind": "directory"})).unwrap(); + s.run("mailings.rules", &json!({"rule": "SKIPIF", "field": "Name", "value": "O\"Neil"})).unwrap(); + s.run("mailings.insertField", &json!({"field": "Name"})).unwrap(); + s.run("mailings.finish", &json!({})).unwrap(); + assert_eq!(s.doc.plain_text(StoryRef::Body), "Plain"); +} + /// #430: merge fields in headers and footers are previewed and merged; each letter gets its /// own header. #[test] diff --git a/crates/engine/src/cmd/references.rs b/crates/engine/src/cmd/references.rs index 48bb678d8f..e364fa8d74 100644 --- a/crates/engine/src/cmd/references.rs +++ b/crates/engine/src/cmd/references.rs @@ -110,37 +110,61 @@ pub struct FieldCode { pub switches: Vec<(char, Option)>, } +/// A field instruction's words, each with whether it was quoted (see [`FieldCode`]). +pub fn field_words(instr: &str) -> Vec<(String, bool)> { + let mut words: Vec<(String, bool)> = Vec::new(); + let mut chars = instr.chars().peekable(); + while let Some(&c) = chars.peek() { + if c.is_whitespace() { + chars.next(); + } else if c == '"' { + chars.next(); + let mut w = String::new(); + while let Some(c) = chars.next() { + match c { + '"' => break, + '\\' if matches!(chars.peek(), Some('"' | '\\')) => w.extend(chars.next()), + c => w.push(c), + } + } + words.push((w, true)); + } else { + let mut w = String::new(); + while let Some(&c) = chars.peek() { + if c.is_whitespace() || (c == '"' && !w.is_empty()) { + break; + } + w.push(c); + chars.next(); + } + words.push((w, false)); + } + } + words +} + +/// `s` as quoted field-code text, which [`field_words`] reads back as it was. +pub fn quoted(s: &str) -> String { + let mut o = String::with_capacity(s.len() + 2); + o.push('"'); + for c in s.chars() { + if matches!(c, '"' | '\\') { + o.push('\\'); + } + o.push(c); + } + o.push('"'); + o +} + +/// `s` as one field-code argument: as it is when it's a single plain word, else [`quoted`]. +pub fn field_arg(s: &str) -> String { + if s.is_empty() || s.contains(|c: char| c.is_whitespace() || matches!(c, '"' | '\\')) { quoted(s) } else { s.to_string() } +} + impl FieldCode { pub fn parse(instr: &str) -> FieldCode { - let mut words: Vec<(String, bool)> = Vec::new(); - let mut chars = instr.chars().peekable(); - while let Some(&c) = chars.peek() { - if c.is_whitespace() { - chars.next(); - } else if c == '"' { - chars.next(); - let mut w = String::new(); - while let Some(c) = chars.next() { - match c { - '"' => break, - '\\' if matches!(chars.peek(), Some('"' | '\\')) => w.extend(chars.next()), - c => w.push(c), - } - } - words.push((w, true)); - } else { - let mut w = String::new(); - while let Some(&c) = chars.peek() { - if c.is_whitespace() || (c == '"' && !w.is_empty()) { - break; - } - w.push(c); - chars.next(); - } - words.push((w, false)); - } - } - let mut words = words.into_iter().peekable(); + let mut words = field_words(instr).into_iter().peekable(); let mut f = FieldCode { name: words.next().map(|(w, _)| w.trim_start_matches('=').to_ascii_uppercase()).unwrap_or_default(), ..Default::default() }; while let Some((w, quoted)) = words.next() { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 7d89267e16..fcc2abe72c 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2570,19 +2570,22 @@ fn select_extend_from_a_table_paragraph_selects_the_document() { assert_eq!(b, s.doc.end_of(StoryRef::Body)); } -/// #1596: tables nest 24 deep at most, the depth the DOCX reader and writer keep. Insert › Table -/// in the deepest cell says so; a table pasted there brings its text as paragraphs. +/// #1596, #3618: tables nest only as deep as a `.json` save (and so an AutoRecover snapshot) +/// reads back. Insert › Table in the deepest cell says so; a table pasted there brings its text +/// as paragraphs. #[test] -fn tables_nest_at_most_24_deep() { +fn tables_nest_only_as_deep_as_a_json_save_reads() { use wordcraft_doc::table::MAX_TABLE_DEPTH; let mut s = s(); for _ in 0..MAX_TABLE_DEPTH { run(&mut s, "insert.table", json!({"rows": 1, "cols": 1})); } assert_eq!(s.sel.focus.path.depth(), MAX_TABLE_DEPTH); + let saved = crate::io::save_bytes("deep.json", &s.doc).unwrap(); + assert_eq!(crate::io::open_bytes("deep.json", &saved).map(|d| d == s.doc), Ok(true)); let before = s.doc.clone(); let e = s.run("insert.table", &json!({"rows": 1, "cols": 1})).unwrap_err(); - assert!(e.to_string().contains("24 deep"), "{e}"); + assert!(e.to_string().contains(&format!("{MAX_TABLE_DEPTH} deep")), "{e}"); assert_eq!(s.doc, before); run(&mut s, "edit.paste", json!({"text": "b", "html": "
b
"})); let cell = s.sel.focus.path.clone(); @@ -2591,6 +2594,44 @@ fn tables_nest_at_most_24_deep() { assert!(blocks.iter().any(|b| b.as_para().is_some_and(|p| p.text.contains('b'))), "the pasted text is there"); } +/// #3618: a text box story sits two JSON levels below the body, and its deepest cell still reads +/// back from a `.json` save with the richest content measured pasted in: a gradient shape, and +/// gradient text effects changed with Track Changes on (the change keeps the old effects too). +#[test] +fn the_deepest_cell_of_a_text_box_reads_back_with_rich_content() { + use wordcraft_doc::table::MAX_TABLE_DEPTH; + let mut s = s(); + run(&mut s, "insert.shape", json!({"kind": "rectangle"})); + run(&mut s, "shape.fill", json!({"gradient": {"stops": [{"pos": 0, "color": "FF0000"}, {"pos": 100, "color": "0000FF"}]}})); + run(&mut s, "select.collapse", json!({"end": true})); + run(&mut s, "text.insert", json!({"text": "word"})); + run(&mut s, "select.text", json!({"text": "word"})); + run(&mut s, "format.textEffects", json!({"fill": {"gradient": ["FF0000", "0000FF"]}, "shadow": {"color": "FF0000"}})); + run(&mut s, "review.trackChanges", json!({"on": true})); + run(&mut s, "format.textEffects", json!({"fill": {"gradient": ["00FF00", "0000FF"]}, "glow": {"color": "00FF00"}})); + run(&mut s, "review.trackChanges", json!({"on": false})); + run(&mut s, "select.all", json!({})); + run(&mut s, "edit.copy", json!({})); + run(&mut s, "select.collapse", json!({"end": true})); + run(&mut s, "insert.textBox", json!({"text": "in box"})); + for _ in 0..MAX_TABLE_DEPTH { + run(&mut s, "insert.table", json!({"rows": 1, "cols": 1})); + } + assert_ne!(s.sel.focus.story, StoryRef::Body); + assert_eq!(s.sel.focus.path.depth(), MAX_TABLE_DEPTH); + run(&mut s, "edit.paste", json!({})); + // Commands don't reach this deep (the paragraph walk stops at 16), so check the paste did. + let para = s.doc.block(s.sel.focus.story, &s.sel.focus.path).and_then(|b| b.as_para()).unwrap(); + let gradient = |f: &Option| matches!(f, Some(wordcraft_doc::fill::Fill::Gradient(_))); + assert!(para.objects.iter().any(|o| matches!(o, wordcraft_doc::InlineObject::Shape { fill, .. } if gradient(fill))), "{para:?}"); + assert!( + para.runs.iter().any(|r| r.props.text_effects.is_some() && r.props.fmt_change.as_ref().is_some_and(|c| c.old.text_effects.is_some())), + "{para:?}" + ); + let saved = crate::io::save_bytes("deep.json", &s.doc).unwrap(); + assert_eq!(crate::io::open_bytes("deep.json", &saved).map(|d| d == s.doc), Ok(true)); +} + /// AutoFit Contents (#44) measures the text: a column of short words narrows, a column with a /// long sentence widens (wrapping within the page), and the widths are written to the grid. #[test] @@ -4593,13 +4634,41 @@ fn hostile_params_never_panic() { for j in &junk { let mut s = Session::new(crate::sample::sample_document()); assert_ne!(s.read_aloud.backend, crate::speech::Backend::System, "tests must never start real speech"); - let _ = s.run(spec.id, j); + let r = s.run(spec.id, j); + assert!(!caught_panic(®, &r), "{} panicked on {j}: {r:?}", spec.id); s.clamp_selection(); let _ = s.layout(); } } } +/// A panic that `Session::run`'s guard turned into an error, in a command or one it ran: still a +/// crash path (#3718). Only the guard's own `"{id} panicked: "` counts, not an error that echoes +/// those words. +fn caught_panic(reg: &crate::Registry, r: &Result) -> bool { + matches!(r, Err(crate::CmdError::Failed(m)) if m.split_once(" panicked: ").is_some_and(|(id, _)| reg.get(id).is_some())) +} + +#[test] +fn caught_panic_flags_a_command_that_panics() { + let mut s = Session::new(crate::sample::sample_document()); + let boom = crate::CommandSpec::new("test.boom", "Boom", "", |_, _| panic!("boom")).pure(); + // Outline's commands run Paragraph ones: the inner command's name is in the error (review of #4207). + let outer = crate::CommandSpec::new("test.outer", "Outer", "", |s, v| s.run("test.boom", v)).pure(); + s.registry = std::sync::Arc::new(crate::Registry::new(vec![boom, outer])); + let boom = s.run("test.boom", &json!({})); + assert!(caught_panic(&s.registry, &boom)); + let nested = s.run("test.outer", &json!({})); + assert!(matches!(&nested, Err(crate::CmdError::Failed(m)) if m.starts_with("test.boom panicked: ")), "{nested:?}"); + assert!(caught_panic(&s.registry, &nested)); + assert!(!caught_panic(&s.registry, &Err(crate::CmdError::Failed("no picture".into())))); + // A command's own error can quote what it was given. + let mut full = Session::new(crate::sample::sample_document()); + let echoed = full.run("developer.control", &json!({"control": "x panicked: y"})); + assert!(matches!(&echoed, Err(crate::CmdError::Failed(m)) if m.contains(" panicked: ")), "{echoed:?}"); + assert!(!caught_panic(&full.registry, &echoed), "{echoed:?}"); +} + #[test] fn inspect_reports_structure() { let mut s = Session::new(crate::sample::sample_document()); diff --git a/crates/formats/src/html.rs b/crates/formats/src/html.rs index a4f4bfd060..af29271e7f 100644 --- a/crates/formats/src/html.rs +++ b/crates/formats/src/html.rs @@ -42,5 +42,7 @@ pub use export::{esc, export, export_flow, export_fragment, inlines_html}; use import::parse_in; pub use import::{PAGE_FONT, import, import_on, import_page, import_with, parse, parse_with}; +#[cfg(test)] +use css::css_unescape; #[cfg(test)] mod tests; diff --git a/crates/formats/src/html/css.rs b/crates/formats/src/html/css.rs index 0fcf54227a..9dc6d75262 100644 --- a/crates/formats/src/html/css.rs +++ b/crates/formats/src/html/css.rs @@ -46,6 +46,48 @@ pub(super) fn strip_important(v: &str) -> &str { s } +/// Decode CSS escapes (CSS Syntax §4.3.7): `\` and one to six hex digits, with one whitespace +/// after them, is that code point; `\` and any other character is that character. CR LF, CR and +/// form feed count as one newline, as CSS reads them (§3.3). +pub(super) fn css_unescape(s: &str) -> String { + let mut o = String::with_capacity(s.len()); + let mut it = s.chars().peekable(); + // Takes a newline (CR LF as one) or other whitespace off the front, if there is one. + let skip_space = |it: &mut std::iter::Peekable| match it.peek() { + Some('\r') => { + it.next(); + it.next_if_eq(&'\n'); + } + Some(' ' | '\t' | '\n' | '\u{C}') => { + it.next(); + } + _ => {} + }; + while let Some(c) = it.next() { + if c != '\\' { + o.push(c); + continue; + } + let mut hex = String::new(); + while hex.len() < 6 && it.peek().is_some_and(char::is_ascii_hexdigit) { + hex.extend(it.next()); + } + if hex.is_empty() { + // Before a newline it continues the line; at the end it is dropped. + match it.peek() { + Some('\r' | '\n' | '\u{C}') => skip_space(&mut it), + Some(_) => o.extend(it.next()), + None => {} + } + continue; + } + skip_space(&mut it); + let cp = u32::from_str_radix(&hex, 16).unwrap_or(0); + o.push(char::from_u32(cp).filter(|c| *c != '\0').unwrap_or('\u{FFFD}')); + } + o +} + /// Apply a `style` attribute to formatting / block properties. pub(super) fn apply_style( css: &str, @@ -88,7 +130,7 @@ pub(super) fn apply_style( } } "font-family" => { - let fam = v.split(',').next().unwrap_or("").trim().trim_matches(['"', '\'']).to_string(); + let fam = css_unescape(v.split(',').next().unwrap_or("").trim().trim_matches(['"', '\''])); 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") { diff --git a/crates/formats/src/html/export.rs b/crates/formats/src/html/export.rs index 0862ca04d0..64136afb03 100644 --- a/crates/formats/src/html/export.rs +++ b/crates/formats/src/html/export.rs @@ -19,6 +19,24 @@ pub fn esc(s: &str) -> String { o } +/// A font name as a quoted CSS string. Quotes, `\` and control characters would end the string, +/// and `;` and `,` the declaration or the family list for a reader that splits on them, so all of +/// these are written as hex escapes ([`css_unescape`] reads them back). +fn css_font_name(name: &str) -> String { + let mut o = String::with_capacity(name.len() + 2); + o.push('\''); + for c in name.chars() { + match c { + '\0' => o.push('\u{FFFD}'), + '\'' | '"' | '\\' | ';' | ',' => o.push_str(&format!("\\{:x} ", u32::from(c))), + c if c.is_control() => o.push_str(&format!("\\{:x} ", u32::from(c))), + c => o.push(c), + } + } + o.push('\''); + o +} + fn align_css(a: Option) -> Option<&'static str> { match a? { Align::Center => Some("text-align:center"), @@ -92,7 +110,7 @@ fn fmt_tags(f: &Fmt) -> (String, Vec<&'static str>) { css.push(format!("font-size:{}pt", trim_num(s))); } if let Some(fam) = &f.font { - css.push(format!("font-family:'{}'", esc(&fam.replace('\'', "")))); + css.push(format!("font-family:{}", esc(&css_font_name(fam)))); } if !css.is_empty() { open.push_str(&format!("", css.join(";"))); diff --git a/crates/formats/src/html/tests.rs b/crates/formats/src/html/tests.rs index 179cf322fe..3281dcc237 100644 --- a/crates/formats/src/html/tests.rs +++ b/crates/formats/src/html/tests.rs @@ -314,6 +314,44 @@ fn leading_space_survives_round_trip() { assert_eq!(back.plain_text(Default::default()).replace('\u{a0}', " "), " lead"); } +#[test] +fn css_escapes_read_every_kind_of_newline() { + // A backslash before CR LF, CR, LF or form feed continues the line; after hex digits, one + // of them (CR LF as one) ends the escape. + assert_eq!(css_unescape("A\\\r\nB\\\rC\\\nD\\\u{C}E"), "ABCDE"); + assert_eq!(css_unescape("\\41\r\nB \\42\u{C}C \\43 D"), "AB BC CD"); + assert_eq!(css_unescape("x\\'y\\"), "x'y"); +} + +#[test] +fn font_names_are_written_as_css_strings() { + // A font name could end the CSS string or the declaration and add CSS of its own (#3739). + let fonts = ["Arial\n;position:fixed", "Back\\slash", "O'Brien \"Sans\"", "A,B", "Times New Roman", "M+ 1p"]; + let mut p = Para::default(); + for (i, font) in fonts.iter().enumerate() { + p.push_text(&format!("t{i}"), &Fmt { font: Some((*font).into()), ..Default::default() }); + } + let html = export(&model::to_doc(&Flow { blocks: vec![FBlock::Para(p)], meta: Meta::default() })); + assert!(html.contains("font-family:'Times New Roman'") && html.contains("font-family:'M+ 1p'"), "{html}"); + let styles: Vec<&str> = html.split(" style=\"").skip(1).filter_map(|s| s.split('"').next()).filter(|s| s.contains("font-family")).collect(); + assert_eq!(styles.len(), fonts.len(), "{html}"); + for s in styles { + assert!(!s.contains([';', '\n']) && s.matches('\'').count() == 2, "{s}"); + } + // Read back, each run has its font as it was. + let back = parse(&html); + let runs: Vec<(String, Option)> = back + .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 Inline::Text(t, f) = i { Some((t.clone(), f.font.as_deref().map(String::from))) } else { None }) + .collect(); + for (i, font) in fonts.iter().enumerate() { + assert!(runs.contains(&(format!("t{i}"), Some(font.to_string()))), "{font:?} in {runs:?}"); + } +} + /// A document that reads "I read " then a phonetic guide over `base`, then " daily.". fn ruby_doc(base: &str, ruby: &str) -> Document { let props = wordcraft_doc::CharProps::default(); diff --git a/crates/formats/src/markdown.rs b/crates/formats/src/markdown.rs index 460bb9c990..046935599e 100644 --- a/crates/formats/src/markdown.rs +++ b/crates/formats/src/markdown.rs @@ -12,6 +12,7 @@ use std::collections::HashMap; use std::sync::Arc; +use wordcraft_doc::table::{MAX_COLS, MAX_ROWS}; use wordcraft_doc::{Align, Document}; use crate::html::decode_entity; @@ -21,6 +22,11 @@ mod blocks; mod export; mod inlines; +/// Table cells a Markdown document makes in all, header rows included (hostile input). A short +/// row is padded to its header's width, so a 197 KB file of one-cell rows under a 63-column +/// header made 2 million cells and took 3 to 5 GB to convert; past this, rows are left out. +pub(crate) const MAX_TABLE_CELLS: usize = 1 << 18; + pub use blocks::{Refs, import, import_on, parse}; pub(crate) use export::escape; pub use export::export; @@ -28,5 +34,7 @@ pub use export::export; use export::{escape_line_start, inlines_md, table_md, write_blocks}; pub use inlines::parse_inlines; +#[cfg(test)] +use blocks::split_row; #[cfg(test)] mod tests; diff --git a/crates/formats/src/markdown/blocks.rs b/crates/formats/src/markdown/blocks.rs index 3a1bb2a269..051c3ef4e1 100644 --- a/crates/formats/src/markdown/blocks.rs +++ b/crates/formats/src/markdown/blocks.rs @@ -1,5 +1,5 @@ use super::inlines::parse_inlines; -use super::{Align, Arc, Cell, Document, FBlock, FTable, Fmt, HashMap, Kind, ListInfo, Para, model}; +use super::{Align, Arc, Cell, Document, FBlock, FTable, Fmt, HashMap, Kind, ListInfo, MAX_COLS, MAX_ROWS, Para, model}; /// Nesting limit for block containers (quotes, list items) and inline links. pub(super) const MAX_NEST: usize = 32; @@ -143,7 +143,8 @@ pub fn parse(text: &str) -> model::Flow { let text = text.strip_prefix('\u{FEFF}').unwrap_or(text); let mut lines: Vec = text.split('\n').map(|l| expand_tabs(l.trim_end_matches('\r'))).collect(); let refs = take_definitions(&mut lines); - let mut blocks = parse_blocks(&lines, 0, 0, &refs); + let budget = std::cell::Cell::new(super::MAX_TABLE_CELLS); + let mut blocks = parse_blocks(&lines, 0, 0, &refs, &budget); // A leading "Title" from front matter isn't supported; a first-level heading stays a heading. if blocks.is_empty() { blocks.push(FBlock::Para(Para::default())); @@ -271,47 +272,69 @@ fn list_marker(t: &str) -> Option { if rest.is_empty() || rest.starts_with([' ', '\t']) { Some(m) } else { None } } -fn split_row(l: &str) -> Vec { +/// A table row without its leading and trailing pipes (an escaped last pipe stays). +fn row_body(l: &str) -> &str { let t = l.trim(); let t = t.strip_prefix('|').unwrap_or(t); - let t = if let Some(before) = t.strip_suffix('|') { + if let Some(before) = t.strip_suffix('|') { let num_bs = before.chars().rev().take_while(|&c| c == '\\').count(); if num_bs % 2 == 0 { before } else { t } } else { t - }; + } +} + +/// The first `keep` cells of a table row, and how many cells it has: the rest are only counted. +pub(super) fn split_row(l: &str, keep: usize) -> (Vec, usize) { let mut cells = Vec::new(); + let mut count = 0usize; let mut cur = String::new(); - let mut chars = t.chars().peekable(); + let mut chars = row_body(l).chars().peekable(); while let Some(c) = chars.next() { + let full = cells.len() >= keep; match c { '\\' => match chars.peek() { Some(&'|') => { - cur.push('|'); + if !full { + cur.push('|'); + } chars.next(); } Some(&'\\') => { - cur.push('\\'); - cur.push('\\'); + if !full { + cur.push_str("\\\\"); + } chars.next(); } - _ => cur.push('\\'), + _ if !full => cur.push('\\'), + _ => {} }, - '|' => cells.push(std::mem::take(&mut cur).trim().to_string()), - _ => cur.push(c), + '|' => { + if !full { + cells.push(std::mem::take(&mut cur).trim().to_string()); + } + count += 1; + } + _ if !full => cur.push(c), + _ => {} } } - cells.push(cur.trim().to_string()); - cells + if cells.len() < keep { + cells.push(cur.trim().to_string()); + } + (cells, count + 1) } -fn delimiter_row(l: &str) -> Option>> { +/// The alignments of the first `keep` cells of a delimiter row, and how many cells it has; every +/// cell is checked, so a bad one past `keep` still means it isn't a delimiter row. +fn delimiter_row(l: &str, keep: usize) -> Option<(Vec>, usize)> { if !l.contains('-') { return None; } - let cells = split_row(l); let mut out = Vec::new(); - for c in cells { + let mut count = 0usize; + // A valid cell is only dashes, colons and spaces, so an escaped pipe can't be in one. + for c in row_body(l).split('|') { let c = c.trim(); let left = c.starts_with(':'); let right = c.ends_with(':'); @@ -319,14 +342,17 @@ fn delimiter_row(l: &str) -> Option>> { if core.is_empty() || !core.chars().all(|x| x == '-') { return None; } - out.push(match (left, right) { - (true, true) => Some(Align::Center), - (false, true) => Some(Align::Right), - (true, false) => Some(Align::Left), - _ => None, - }); + if out.len() < keep { + out.push(match (left, right) { + (true, true) => Some(Align::Center), + (false, true) => Some(Align::Right), + (true, false) => Some(Align::Left), + _ => None, + }); + } + count += 1; } - Some(out) + Some((out, count)) } /// Does this line start a block that interrupts a paragraph? @@ -386,7 +412,8 @@ fn code_para(line: &str) -> FBlock { } /// Parse block structure. `level` is the list nesting level for lists found here. -fn parse_blocks(lines: &[String], depth: usize, level: u8, refs: &Refs) -> Vec { +/// `budget`: the table cells the document may still make ([`super::MAX_TABLE_CELLS`]). +fn parse_blocks(lines: &[String], depth: usize, level: u8, refs: &Refs, budget: &std::cell::Cell) -> Vec { let mut out = Vec::new(); let mut para: Vec = Vec::new(); let mut i = 0; @@ -460,32 +487,52 @@ fn parse_blocks(lines: &[String], depth: usize, level: u8, refs: &Refs) -> Vec') { flush_para(&mut para, &mut out, depth, refs); - parse_quote(lines, &mut i, depth, level, refs, &mut out); + parse_quote(lines, &mut i, depth, level, refs, budget, &mut out); continue; } if para.is_empty() && t.contains('|') - && let Some(aligns) = lines.get(i + 1).and_then(|l| delimiter_row(l)) + && let Some((aligns, width)) = lines.get(i + 1).and_then(|l| delimiter_row(l, MAX_COLS)) { - let head = split_row(t); - if head.len() == aligns.len() { + let (head, head_width) = split_row(t, MAX_COLS); + if head_width == width { let mut table = FTable::default(); let cell = |s: &str, header: bool, a: Option| { let p = Para { align: a, inlines: parse_inlines(s, &Fmt::default(), depth, refs), ..Default::default() }; Cell { blocks: vec![FBlock::Para(p)], header, ..Default::default() } }; - table.rows.push(head.iter().zip(&aligns).map(|(s, a)| cell(s, true, *a)).collect()); + // The document keeps no more columns or rows than this, nor cells past its budget, + // so those aren't built; their lines are still the table's. + let cols = aligns.len(); + let fits = |rows: usize| rows < MAX_ROWS && budget.get() >= cols; + let mut cut = head_width > cols; + if fits(0) { + budget.set(budget.get() - cols); + table.rows.push(head.iter().zip(&aligns).map(|(s, a)| cell(s, true, *a)).collect()); + } else { + cut = true; + } i += 2; while let Some(l) = lines.get(i) { if is_blank(l) || !l.contains('|') || interrupts(l) { break; } - let mut cells = split_row(l); - cells.resize(aligns.len(), String::new()); - table.rows.push(cells.iter().zip(&aligns).map(|(s, a)| cell(s, false, *a)).collect()); + if !table.rows.is_empty() && fits(table.rows.len()) { + budget.set(budget.get() - cols); + let (mut cells, _) = split_row(l, cols); + cells.resize(cols, String::new()); + table.rows.push(cells.iter().zip(&aligns).map(|(s, a)| cell(s, false, *a)).collect()); + } else { + cut = true; + } i += 1; } - out.push(FBlock::Table(table)); + if cut { + model::dropped(model::Dropped::TableCells); + } + if !table.rows.is_empty() { + out.push(FBlock::Table(table)); + } continue; } } @@ -493,7 +540,7 @@ fn parse_blocks(lines: &[String], depth: usize, level: u8, refs: &Refs) -> Vec) { out.extend(code.into_iter().map(code_para)); } -fn parse_quote(lines: &[String], i: &mut usize, depth: usize, level: u8, refs: &Refs, out: &mut Vec) { +fn parse_quote(lines: &[String], i: &mut usize, depth: usize, level: u8, refs: &Refs, budget: &std::cell::Cell, out: &mut Vec) { let mut inner = Vec::new(); while let Some(line) = lines.get(*i) { let trimmed = line.trim_start_matches([' ', '\t']); @@ -541,7 +588,7 @@ fn parse_quote(lines: &[String], i: &mut usize, depth: usize, level: u8, refs: & out.push(FBlock::Para(p)); return; } - for mut block in parse_blocks(&inner, depth + 1, level, refs) { + for mut block in parse_blocks(&inner, depth + 1, level, refs, budget) { if let FBlock::Para(p) = &mut block && p.kind == Kind::Normal { @@ -579,7 +626,7 @@ fn marker_space(ind: usize, marker_len: usize, after: &str) -> (usize, usize) { } /// Parse a list starting at `lines[i]`; returns the index after it. -fn parse_list(lines: &[String], mut i: usize, depth: usize, level: u8, out: &mut Vec, refs: &Refs) -> usize { +fn parse_list(lines: &[String], mut i: usize, depth: usize, level: u8, out: &mut Vec, refs: &Refs, budget: &std::cell::Cell) -> usize { let Some(first) = lines.get(i).and_then(|l| list_marker(l.trim_start_matches([' ', '\t']))) else { return i + 1 }; while let Some(line) = lines.get(i) { let ind = indent(line); @@ -626,7 +673,7 @@ fn parse_list(lines: &[String], mut i: usize, depth: usize, level: u8, out: &mut p.push_text(&item.join(" "), &Fmt::default()); vec![FBlock::Para(p)] } else { - parse_blocks(&item, depth + 1, level.saturating_add(1).min(8), refs) + parse_blocks(&item, depth + 1, level.saturating_add(1).min(8), refs, budget) }; let mut placed = false; if inner.is_empty() { diff --git a/crates/formats/src/markdown/tests.rs b/crates/formats/src/markdown/tests.rs index 32c3cef60b..51b1c7b7c4 100644 --- a/crates/formats/src/markdown/tests.rs +++ b/crates/formats/src/markdown/tests.rs @@ -356,6 +356,67 @@ fn three_column_table_with_distinctive_text() { assert!(matches!(&p2.inlines[0], Inline::Text(_, f) if !f.code)); } +#[test] +fn table_cells_past_the_document_limits_are_not_built() { + // Every body row was padded to the header's width before any cap (#3701). + let wide = format!("{}|\n{}|\n|\n| b |\n", "|a".repeat(50_000), "|-".repeat(50_000)); + crate::model::begin_import(); + let flow = parse(&wide); + let FBlock::Table(t) = &flow.blocks[0] else { panic!("expected table") }; + assert_eq!(t.rows.len(), 3); + assert!(t.rows.iter().all(|r| r.len() == MAX_COLS)); + assert_eq!(t.rows[2][0].text(), "b"); + assert!(crate::model::Dropped::TableCells.reported(), "the columns left out are logged"); + // Rows past the limit still belong to the table, so the text after it reads as before. + let tall = format!("| a |\n| - |\n{}\nafter\n", "| x |\n".repeat(MAX_ROWS + 10)); + crate::model::begin_import(); + let flow = parse(&tall); + let FBlock::Table(t) = &flow.blocks[0] else { panic!("expected table") }; + assert_eq!(t.rows.len(), MAX_ROWS); + let FBlock::Para(p) = &flow.blocks[1] else { panic!("expected paragraph") }; + assert_eq!(p.text(), "after"); + assert_eq!(flow.blocks.len(), 2); + assert!(crate::model::Dropped::TableCells.reported(), "the rows left out are logged"); +} + +/// A short row is padded to its header's width, so a small file of one-cell rows under wide +/// headers made millions of cells (CodeRabbit on #3862). The document's tables share one cell +/// budget: rows past it are left out and logged, and their lines make no paragraphs. +#[test] +fn tables_share_one_cell_budget() { + let header = |n: usize| format!("|{}|\n|{}|\n", vec![" h "; n].join("|"), vec!["-"; n].join("|")); + let fits = crate::markdown::MAX_TABLE_CELLS / MAX_COLS; + let src = format!("{}{}\nafter\n\n{}| 1 | 2 |\n", header(MAX_COLS), "| x |\n".repeat(fits + 100), header(2)); + crate::model::begin_import(); + let flow = parse(&src); + let tables: Vec<&FTable> = flow.blocks.iter().filter_map(|b| if let FBlock::Table(t) = b { Some(t) } else { None }).collect(); + let cells: usize = tables.iter().flat_map(|t| &t.rows).map(Vec::len).sum(); + assert!(cells <= crate::markdown::MAX_TABLE_CELLS, "{cells} cells"); + assert_eq!(tables.iter().map(|t| t.rows.len()).collect::>(), [fits], "the second table is past the budget"); + let texts: Vec = flow.blocks.iter().filter_map(|b| if let FBlock::Para(p) = b { Some(p.text()) } else { None }).collect(); + assert_eq!(texts, ["after"]); + assert!(crate::model::Dropped::TableCells.reported()); + // Within the budget a table is whole and nothing is logged. + crate::model::begin_import(); + let flow = parse(&format!("{}| 1 | 2 |\n", header(2))); + let [FBlock::Table(t)] = flow.blocks.as_slice() else { panic!("{:?}", flow.blocks) }; + assert_eq!(t.rows.len(), 2); + assert!(!crate::model::Dropped::TableCells.reported()); +} + +#[test] +fn a_wide_table_still_checks_every_header_and_delimiter_cell() { + // Only the first 63 cells are kept, but every one is counted and checked. + let head = "|a".repeat(100) + "|\n"; + let delim = |bad: Option| (0..100).map(|i| if Some(i) == bad { "|x" } else { "|-" }).collect::() + "|\n"; + let is_table = |md: String| matches!(parse(&md).blocks.first(), Some(FBlock::Table(_))); + assert!(is_table(format!("{head}{}| b |\n", delim(None)))); + assert!(!is_table(format!("{head}{}| b |\n", delim(Some(80)))), "a bad delimiter cell past the kept ones"); + assert!(!is_table(format!("{}|\n{}| b |\n", "|a".repeat(99), delim(None))), "99 header cells, 100 delimiter cells"); + let (cells, n) = split_row(r"| a | b \| c | d |", 2); + assert_eq!((cells, n), (vec!["a".to_string(), "b | c".to_string()], 3)); +} + #[test] fn regular_table_cells_containing_escaped_pipes() { let md = "| Option | Description |\n| --- | --- |\n| Regular \\| Pipe | Normal text cell |\n"; diff --git a/crates/formats/src/model/types.rs b/crates/formats/src/model/types.rs index 0747af5780..0bdb37038b 100644 --- a/crates/formats/src/model/types.rs +++ b/crates/formats/src/model/types.rs @@ -54,6 +54,10 @@ pub(crate) enum Dropped { RunStrings, /// A style name cut to [`MAX_FONT_NAME`]. StyleName, + /// ODT `text:s` spaces past the document's budget. + Spaces, + /// Markdown table rows and columns past the table limits or the document's cell budget. + TableCells, } impl Dropped { @@ -65,8 +69,23 @@ impl Dropped { 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"), + Dropped::Spaces => { + "import: the document's `text:s` elements ask for more spaces than it keeps; past that each stands for one".to_string() + } + Dropped::TableCells => format!( + "import: Markdown tables keep {} columns and {} rows at most, and {} cells in all; the rest are left out", + wordcraft_doc::table::MAX_COLS, + wordcraft_doc::table::MAX_ROWS, + crate::markdown::MAX_TABLE_CELLS + ), } } + + /// Whether this thread's import has logged this kind ([`dropped`]). + #[cfg(test)] + pub(crate) fn reported(self) -> bool { + REPORTED.with(|r| r.get() & (1u8 << (self as u8)) != 0) + } } /// Log that the importer left something of `kind` out: once per kind per imported document diff --git a/crates/formats/src/odt.rs b/crates/formats/src/odt.rs index aeb1ad0597..d5185dae94 100644 --- a/crates/formats/src/odt.rs +++ b/crates/formats/src/odt.rs @@ -43,7 +43,7 @@ use import::Pages; use import::{Pictures, apply_page_layout, read_entry, read_meta}; pub use import::{import, parse}; #[cfg(test)] -use limits::MAX_XML_DEPTH; +use limits::{MAX_EXTRA_SPACES, MAX_XML_DEPTH}; pub use package::export; use styles::Styles; #[cfg(test)] diff --git a/crates/formats/src/odt/body.rs b/crates/formats/src/odt/body.rs index 628c7257a4..7b7c0c7de6 100644 --- a/crates/formats/src/odt/body.rs +++ b/crates/formats/src/odt/body.rs @@ -1,5 +1,5 @@ use super::import::Pages; -use super::limits::{MAX_INFO_CHARS, MAX_OPEN_INSERTIONS, MAX_REGIONS, MAX_SECTIONS, MAX_XML_DEPTH}; +use super::limits::{MAX_EXTRA_SPACES, MAX_INFO_CHARS, MAX_OPEN_INSERTIONS, MAX_REGIONS, MAX_SECTIONS, MAX_XML_DEPTH}; use super::styles::Styles; use super::xml::{Attrs, Ev, events, get, length, local}; use super::{Cell, FBlock, FTable, Fmt, HashMap, Inline, Kind, ListInfo, Para, RevMark, RevisionKind, SectionProps, SectionStart, make_img, model}; @@ -116,6 +116,8 @@ pub(super) struct Body<'a> { para: Option, /// The text read last ended in a space collapsed from XML whitespace (not a `text:s`). xml_space_end: bool, + /// What is left of [`MAX_EXTRA_SPACES`]. + pub(super) spaces_left: usize, para_cont: bool, /// Open lists: (style name, item open, item used). lists: Vec<(Option, bool)>, @@ -169,6 +171,7 @@ impl<'a> Body<'a> { fmt: Vec::new(), para: None, xml_space_end: false, + spaces_left: MAX_EXTRA_SPACES, para_cont: false, lists: Vec::new(), lists_saved: Vec::new(), @@ -821,9 +824,16 @@ impl Body<'_> { } self.fmt.push(fmt); } - "s" => { + // Outside a paragraph `push_text` drops spaces, so they cost no budget. + "s" if self.para.is_some() => { let count = get(a, "text:c").and_then(|v| v.trim().parse::().ok()).unwrap_or(1).clamp(1, 1000); - self.push_text(&" ".repeat(count)); + let want = count.saturating_sub(1); + let extra = want.min(self.spaces_left); + if extra < want { + model::dropped(model::Dropped::Spaces); + } + self.spaces_left -= extra; + self.push_text(&" ".repeat(1 + extra)); } "tab" => self.push_text("\t"), "line-break" => self.push_text("\n"), diff --git a/crates/formats/src/odt/import.rs b/crates/formats/src/odt/import.rs index 39742d3e83..c46171a0ff 100644 --- a/crates/formats/src/odt/import.rs +++ b/crates/formats/src/odt/import.rs @@ -1,5 +1,5 @@ use super::body::Body; -use super::limits::{MAX_ENTRY, MAX_SECTIONS}; +use super::limits::{MAX_ENTRY, MAX_EXTRA_SPACES, MAX_SECTIONS}; use super::styles::{Styles, column_sep, columns_of}; use super::xml::{Attrs, Ev, events, get, length, local}; use super::{Columns, Cursor, Document, FBlock, Flow, HashMap, Meta, Para, Read, SectionProps, model, page_parts}; @@ -138,8 +138,11 @@ fn parse_tracked(bytes: &[u8]) -> Result<(Flow, SectionProps, Option, Opti // A frame may point at any entry, as often as it likes: `Pictures` keeps that bounded. pictures.get(&mut z, path) }; - let parts = styles_xml.as_deref().map(|x| page_parts::read(x, &styles, &mut pages, &mut fetch)).unwrap_or_default(); + // One `text:s` budget for the headers and footers and then the body. + let mut spaces_left = MAX_EXTRA_SPACES; + let parts = styles_xml.as_deref().map(|x| page_parts::read(x, &styles, &mut pages, &mut fetch, &mut spaces_left)).unwrap_or_default(); let mut body = Body::new(&styles, &pages, &mut fetch); + body.spaces_left = spaces_left; events(&content, |ev| body.feed(ev)); let mut blocks = body.take_blocks(); // An open section with nothing in it (after a column section at the end) isn't one: the diff --git a/crates/formats/src/odt/limits.rs b/crates/formats/src/odt/limits.rs index 48d765588c..f65aafa86c 100644 --- a/crates/formats/src/odt/limits.rs +++ b/crates/formats/src/odt/limits.rs @@ -15,3 +15,5 @@ pub(super) const MAX_OPEN_INSERTIONS: usize = 64; /// Longest change author or date kept, in characters. pub(super) const MAX_INFO_CHARS: usize = 256; +/// Most spaces the document's `text:s` elements add beyond the one each stands for (hostile input). +pub(super) const MAX_EXTRA_SPACES: usize = 4 << 20; diff --git a/crates/formats/src/odt/page_parts.rs b/crates/formats/src/odt/page_parts.rs index ce7ed90477..b20b1a821d 100644 --- a/crates/formats/src/odt/page_parts.rs +++ b/crates/formats/src/odt/page_parts.rs @@ -58,8 +58,9 @@ pub(super) struct Parts { /// Read each visible header and footer of the master pages in `pages`, and name it on its master /// page's section as part id 1, 2, … in order, so the sections the body starts carry them. -/// [`add`] puts those parts into the document. -pub(super) fn read(xml: &[u8], styles: &Styles, pages: &mut Pages, fetch: &mut dyn FnMut(&str) -> Option>) -> Parts { +/// [`add`] puts those parts into the document. `spaces_left` is the document's +/// [`MAX_EXTRA_SPACES`] budget, which the body read after them goes on with. +pub(super) fn read(xml: &[u8], styles: &Styles, pages: &mut Pages, fetch: &mut dyn FnMut(&str) -> Option>, spaces_left: &mut usize) -> Parts { let mut found: Vec<(String, Slot, Vec)> = Vec::new(); let mut master: Option = None; // The slot being read, its nesting depth and whether it is kept. @@ -109,9 +110,11 @@ pub(super) fn read(xml: &[u8], styles: &Styles, pages: &mut Pages, fetch: &mut d for (name, kind, evs) in found { let mut reader = Body::new(styles, &none, fetch); reader.in_text = true; + reader.spaces_left = *spaces_left; for ev in evs { reader.feed(ev); } + *spaces_left = reader.spaces_left; out.parts.push((if kind.footer() { PartKind::Footer } else { PartKind::Header }, reader.take_blocks())); if let Some(sec) = pages.masters.get_mut(&name) { kind.set(sec, out.parts.len() as u32); diff --git a/crates/formats/src/odt/tests.rs b/crates/formats/src/odt/tests.rs index a73c806353..ff0cc6db2c 100644 --- a/crates/formats/src/odt/tests.rs +++ b/crates/formats/src/odt/tests.rs @@ -488,6 +488,24 @@ fn deeply_nested_header_content_does_not_panic() { assert_eq!(part_text(&d, d.last_section.footers.default), "After"); } +#[test] +fn headers_and_footers_share_the_text_s_budget_with_the_body() { + // Each header and footer was read with a budget of its own (#3700). + let run = r#""#.repeat(3_000); + let styles = format!( + r#"h{run}f{run}"# + ); + let content = format!( + r#"b{run}"# + ); + let d = import(&package(&content, Some(&styles))).unwrap(); + let texts = + [part_text(&d, d.last_section.headers.default), part_text(&d, d.last_section.footers.default), d.plain_text(wordcraft_doc::StoryRef::Body)]; + // One space per element in all three, and one budget of extra spaces between them. + assert_eq!(texts.iter().map(|t| t.matches(' ').count()).sum::(), 3 * 3_000 + MAX_EXTRA_SPACES); + assert!(texts[0].starts_with("h ") && texts[1].starts_with("f ") && texts[2].starts_with("b ")); +} + #[test] fn master_pages_and_column_sections_become_sections() { use wordcraft_doc::section::SectionStart; @@ -1049,6 +1067,38 @@ fn explicit_spaces_at_paragraph_ends_are_kept() { } } +#[test] +fn text_s_spaces_have_a_budget_for_the_whole_document() { + // 5,000 elements asking for 1,000 spaces each would add 5 million (#3700). + let tags = 5_000; + let run = r#""#.repeat(tags); + let content = format!( + r#"a{run}bcd"# + ); + crate::model::begin_import(); + let f = parse(&package(&content, None)).unwrap(); + let texts: Vec = paras(&f).iter().map(|p| p.text()).collect(); + // Once the budget is spent, each element still stands for one space, and the cut is logged. + assert_eq!(texts[0].len(), 2 + tags + MAX_EXTRA_SPACES); + assert!(texts[0].starts_with("a ") && texts[0].ends_with(" b")); + assert_eq!(texts[1], "c d"); + assert!(crate::model::Dropped::Spaces.reported()); +} + +#[test] +fn text_s_outside_a_paragraph_spends_no_budget() { + // Spaces outside a paragraph are dropped, so they mustn't use up the budget (#3700). + let stray = r#""#.repeat(5_000); + let content = format!( + r#"{stray}ab"# + ); + crate::model::begin_import(); + let f = parse(&package(&content, None)).unwrap(); + let texts: Vec = paras(&f).iter().map(|p| p.text()).collect(); + assert_eq!(texts, vec![format!("a{}b", " ".repeat(1000))]); + assert!(!crate::model::Dropped::Spaces.reported(), "nothing was cut"); +} + #[test] fn test_asian_complex_and_extended_text_properties() { let content = format!( diff --git a/crates/formats/src/rtf/export.rs b/crates/formats/src/rtf/export.rs index e9610a5f37..8f1a6ea1ea 100644 --- a/crates/formats/src/rtf/export.rs +++ b/crates/formats/src/rtf/export.rs @@ -33,6 +33,12 @@ pub fn esc(s: &str) -> String { o } +/// [`esc`] for a `\fonttbl` name: `;` would end the entry, so it is written `\'3b`, and control +/// characters, which `esc` writes as `\line` or `\tab`, are dropped. +fn esc_font(name: &str) -> String { + esc(&name.replace(char::is_control, "")).replace(';', "\\'3b") +} + pub(super) struct Tables { pub(super) fonts: Vec, pub(super) colors: Vec, @@ -684,7 +690,7 @@ fn export_with(m: &Meta, w: &Writer, body: &str, sec: &wordcraft_doc::SectionPro let mut out = String::from("{\\rtf1\\ansi\\ansicpg1252\\deff0\\deflang1033\\uc1\n{\\fonttbl"); for (i, f) in w.t.fonts.iter().enumerate() { let fam = if model::is_mono(f) { "\\fmodern" } else { "\\fswiss" }; - out.push_str(&format!("{{\\f{i}{fam}\\fcharset0 {};}}", esc(f))); + out.push_str(&format!("{{\\f{i}{fam}\\fcharset0 {};}}", esc_font(f))); } out.push_str("}\n{\\colortbl;"); for c in &w.t.colors { diff --git a/crates/formats/src/rtf/tests.rs b/crates/formats/src/rtf/tests.rs index acff0a4e10..7212187061 100644 --- a/crates/formats/src/rtf/tests.rs +++ b/crates/formats/src/rtf/tests.rs @@ -1229,6 +1229,40 @@ fn a_code_paragraph_round_trips_without_a_direct_code_font() { assert_eq!((f.size, f.bold, f.code, f.font.as_deref()), (None, false, false, None), "{rtf}"); } +/// Escaped semicolons in a font name stop at the limit, as other characters do (review of #3862). +#[test] +fn escaped_semicolons_in_a_font_name_stop_at_the_limit() { + let mut r = reader::Reader::default(); + r.st.dest = state::Dest::FontTbl; + for _ in 0..100_000 { + r.ch(';'); + } + assert_eq!(r.font_name.len(), model::MAX_FONT_NAME + 1); + let rtf = format!(r"{{\rtf1{{\fonttbl{{\f0 {};}}}}\f0 x\par}}", r"\'3b".repeat(model::MAX_FONT_NAME + 1)); + let runs = text_runs(rtf.as_bytes()); + assert_eq!(run_fmt(&runs, "x").font, None); +} + +#[test] +fn a_semicolon_in_a_font_name_round_trips() { + // `;` ends a `\fonttbl` entry, so it is written `\'3b` and read back as part of the name (#3740). + let fonts = [("one", "Semi;Colon"), ("two", "Semi;Other"), ("three", "Tail;"), ("four", "Line\nBreak")]; + let mut p = Para::default(); + for (t, font) in fonts { + p.push_text(t, &Fmt { font: Some(font.into()), ..Default::default() }); + } + let flow = Flow { blocks: vec![FBlock::Para(p)], meta: Meta::default() }; + let rtf = export(&model::to_doc(&flow)); + assert!(rtf.contains(r"Semi\'3bColon;") && !rtf.contains(r"\line"), "{rtf}"); + let runs = text_runs(rtf.as_bytes()); + for (t, font) in fonts { + assert_eq!(run_fmt(&runs, t).font.as_deref(), Some(font.replace('\n', "").as_str()), "{rtf}"); + } + // A Unicode escape is part of the name too, in a multibyte code page as well; a literal `;` ends it. + let runs = text_runs(br"{\rtf1\ansi\ansicpg932{\fonttbl{\f0 A\u59?B;}{\f1 C\'3bD;}}\f0 x {\f1 y}\par}"); + assert_eq!((run_fmt(&runs, "x ").font.as_deref(), run_fmt(&runs, "y").font.as_deref()), (Some("A;B"), Some("C;D"))); +} + #[test] fn a_stylesheet_entry_without_a_number_is_normal() { // An entry without `\sN` is style 0; it doesn't replace the entry before it. diff --git a/crates/formats/src/rtf/text.rs b/crates/formats/src/rtf/text.rs index 3714b7bb85..49a3d7238c 100644 --- a/crates/formats/src/rtf/text.rs +++ b/crates/formats/src/rtf/text.rs @@ -11,7 +11,7 @@ fn multibyte_encoding(code_page: u16) -> Option<&'static encoding_rs::Encoding> impl Reader { pub(super) fn commit_font(&mut self) { - let name = std::mem::take(&mut self.font_name).trim().trim_end_matches(';').trim().to_string(); + let name = std::mem::take(&mut self.font_name).trim().to_string(); if let Some(name) = model::kept_font(&name).filter(|n| !n.is_empty()) && self.fonts.len() < 4096 { @@ -36,6 +36,11 @@ impl Reader { self.skip_chars -= 1; return; } + // `\'3b` in a font name is a `;` in every code page, and part of the name (see `ch`). + if b == b';' && self.st.dest == Dest::FontTbl { + self.ch(';'); + return; + } let cp = self.code_page(); let Some(encoding) = multibyte_encoding(cp) else { self.finish_multibyte(); @@ -106,10 +111,11 @@ impl Reader { /// Big5 or UHC (a valid character pair followed by a lead byte) would be dropped as a UTF-8 /// BOM and the rest decoded as UTF-8. pub(super) fn run(&mut self, chunk: &[u8]) { - if self.skip_chars > 0 { - for b in chunk { - self.byte(*b); - } + // The fallback bytes after `\uN` are skipped; the rest of the run is text like any other. + let skip = self.skip_chars.min(chunk.len()); + self.skip_chars -= skip; + let chunk = chunk.get(skip..).unwrap_or_default(); + if chunk.is_empty() { return; } let cp = self.code_page(); @@ -302,6 +308,14 @@ impl Reader { return; } self.finish_multibyte(); + // Only a literal `;` ends a font name: an escaped one (`\'3b`, `\u59?`) belongs to it. + if c == ';' && self.st.dest == Dest::FontTbl { + // Held to the limit as other characters are; one past it drops the name. + if self.font_name.len() <= model::MAX_FONT_NAME { + self.font_name.push(c); + } + return; + } let mut buf = [0u8; 4]; self.text(c.encode_utf8(&mut buf)); } diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index 7e05daadec..a251f02b8c 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -1393,6 +1393,35 @@ fn bookmark_destination_is_the_page_of_its_start() { assert!(parse_iso(" 2024-05-06T07:08:09Z \n").is_some()); } +#[test] +fn only_web_and_mail_links_become_uri_actions() { + // A script, file, data or UNC target exports as plain text, as in the other exporters (#3717). + let links = [ + ("web", "https://example.com/ok"), + ("mail", "mailto:a@example.com"), + ("script", "javascript:alert(1)"), + ("file", "file:///etc/passwd"), + ("data", "data:text/html,x"), + ("unc", r"\\host\share"), + ]; + let mut p = Paragraph::with_text("Links:", CharProps::default()); + for (text, link) in links { + let n = p.len(); + p.insert_text(n, &format!(" {text}"), &CharProps { link: Some(link.into()), ..Default::default() }).unwrap(); + } + let mut d = Document::new(); + d.body = vec![para_block(p)]; + let raw = String::from_utf8_lossy(&export(&d, &PdfOptions { compress: false, ..Default::default() }).unwrap()).into_owned(); + // `/URI (target)` per link (an action also says `/S /URI`). + assert_eq!(raw.matches("/URI (").count() + raw.matches("/URI(").count(), 2); + assert!(raw.contains("example.com/ok") && raw.contains("a@example.com")); + for bad in ["javascript", "passwd", "text/html", "share"] { + assert!(!raw.contains(bad), "{bad}"); + } + let all = squash(&extract_text(&export(&d, &PdfOptions::default()).unwrap()).concat()); + assert!(all.contains("Links: web mail script file data unc"), "{all:?}"); +} + #[test] fn ref_fields_export_cached_links() { use hayro_syntax::object::{Array, Dict}; @@ -2433,12 +2462,13 @@ fn only_safe_link_targets_become_annotations() { use hayro_syntax::object::{Array, Dict, String as PdfString}; let mut d = Document::new(); - d.body = ["https://example.com/", "mailto:ada@example.com", "javascript:alert(1)", "file:///etc/passwd"] - .into_iter() - .map(|url| para_block(Paragraph::with_text("Click here", CharProps { link: Some(url.into()), ..Default::default() }))) - .collect(); + d.body = + ["https://example.com/", "mailto:ada@example.com", "javascript:alert(1)", "file:///etc/passwd", "data:text/html,hi", r"\\server\share\f.doc"] + .into_iter() + .map(|url| para_block(Paragraph::with_text("Click here", CharProps { link: Some(url.into()), ..Default::default() }))) + .collect(); let bytes = export(&d, &PdfOptions::default()).unwrap(); - assert_eq!(squash(&extract_text(&bytes)[0]).matches("Click here").count(), 4, "unlinked text stays"); + assert_eq!(squash(&extract_text(&bytes)[0]).matches("Click here").count(), 6, "unlinked text stays"); let pdf = hayro_syntax::Pdf::new(bytes).unwrap(); let uris: Vec = pdf.pages()[0] .raw() diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 994b240c2e..7ffdeaad9d 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # WordCraft parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** trivial (Tables row: Table Properties applies to the selected rows, columns and cells, #4857; no percentage change); previously trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (Insert Table of Figures lists any caption label, #3013; previously trivial: Performance row: typing no longer starts render threads on every repaint, #3027; previously trivial: shapes inserted in a row are all kept, #2019; previously trivial: East Asian typography: `w:eastAsianLayout` kept on save, not laid out; previously trivial: Ink to Shape recognises arrows; previously trivial: Windows Magnifier follows the caret onto a new line, #1119; previously trivial: table border drags with Ctrl and Alt, from the left edge and of selected cells, #711; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: all 12 bibliography styles, #383; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; replaces the parity tables that lived in ROADMAP.md) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** trivial (Tables row: tables nest 18 deep at most, so a .json save reads back, #3618); previously trivial (Tables row: Table Properties applies to the selected rows, columns and cells, #4857; no percentage change); previously trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (Insert Table of Figures lists any caption label, #3013; previously trivial: Performance row: typing no longer starts render threads on every repaint, #3027; previously trivial: shapes inserted in a row are all kept, #2019; previously trivial: East Asian typography: `w:eastAsianLayout` kept on save, not laid out; previously trivial: Ink to Shape recognises arrows; previously trivial: Windows Magnifier follows the caret onto a new line, #1119; previously trivial: table border drags with Ctrl and Alt, from the left edge and of selected cells, #711; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: all 12 bibliography styles, #383; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; replaces the parity tables that lived in ROADMAP.md) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 The authoritative assessment of how close WordCraft is to Microsoft Word. [`ROADMAP.md`](../ROADMAP.md) summarizes it; [`gaps.md`](gaps.md) itemizes every shortfall; the deep checklists are @@ -62,7 +62,7 @@ full parity for the area. | Character and paragraph formatting | 11 | 82% | 85% | 8–12 | Nearly every property, Font/Paragraph dialogs, character border, Phonetic Guide (#288), Enclose Characters (#297). Kerning for fonts in the Font dialog. Missing: Asian Typography, ligature/stylistic-set options in the Font dialog's Advanced tab. | | Styles and themes | 8 | 74% | 75% | 8–12 | Gallery, pane, create/modify, style sets, themes. Style Inspector landed (#236). Missing: Manage Styles (#270 open), style separators, linked-style edge cases; Word ships 11 Quick Style sets plus dozens of themes, we ship our own smaller set. | | Lists | 6 | 78% | 75% | 5–8 | Bullets, numbering, multilevel, restart, `w:lvlOverride` levels (#134), Word's list AutoFormat and Enter/Backspace behaviour. Missing: Define New Multilevel List dialog depth, list styles, legal numbering edge cases. | -| Tables | 8 | 76% | 72% | 10–16 | Merge/split, styles (cell text takes style formatting, #104), custom table styles saved in the .docx (#256), Word 2013 edge, floating tables, rows split across pages, sort, formula, cell gridlines (#243), Draw Table and Eraser (#303), tables nested 24 deep at most, as the .docx reader and writer keep them (#1596; Word has no limit). Cell Margins opens an editable dialog initialized from table defaults and preserves unchanged values (#1960). Missing: splitting or erasing around vertically merged cells, nested-table polish. Dragging column and row borders and the resize handle on the page (#217) and the column and row markers on the rulers (#700) landed, with Word's Ctrl, Alt, left-edge and selected-cells drags (#711), and so did selecting columns, rows and cells, the "+" insert buttons, the move handle and double-click AutoFit on the page, Select Column, and drags and Shift+arrows over cells selecting whole cells; right-clicking a cell offers the table's commands, following the selection as in Word (Delete Cells, Rows, Columns or Table, Merge and Split Cells, Distribute, AutoFit, Text Direction, Insert Caption…, Table Properties…); cell text direction landed (#245). | +| Tables | 8 | 76% | 72% | 10–16 | Merge/split, styles (cell text takes style formatting, #104), custom table styles saved in the .docx (#256), Word 2013 edge, floating tables, rows split across pages, sort, formula, cell gridlines (#243), Draw Table and Eraser (#303), tables nested 18 deep at most, so a .json save or AutoRecover snapshot reads back even with rich content in the deepest cell (#1596, #3618; the .docx reader and writer keep 24; Word has no limit). Cell Margins opens an editable dialog initialized from table defaults and preserves unchanged values (#1960). Missing: splitting or erasing around vertically merged cells, nested-table polish. Dragging column and row borders and the resize handle on the page (#217) and the column and row markers on the rulers (#700) landed, with Word's Ctrl, Alt, left-edge and selected-cells drags (#711), and so did selecting columns, rows and cells, the "+" insert buttons, the move handle and double-click AutoFit on the page, Select Column, and drags and Shift+arrows over cells selecting whole cells; right-clicking a cell offers the table's commands, following the selection as in Word (Delete Cells, Rows, Columns or Table, Merge and Split Cells, Distribute, AutoFit, Text Direction, Insert Caption…, Table Properties…); cell text direction landed (#245). | | Page layout and sections | 7 | 70% | 72% | 12–20 | Margins, size, columns, breaks, page borders, line numbers, vertical alignment, drop caps, hyphenation. Columns don't balance; RTL sections missing. Vertical sections (tbRl/btLr) landed (#491). Detail: [layout-parity.md](layout-parity.md). | | Headers, footers, page numbers, fields | 5 | 70% | 70% | 8–12 | First/even/odd, link to previous, TOC as an updatable field (#52), cross-references to real bookmarks (#227). Field coverage is a subset of Word's ~90 field codes; Field dialog missing. | | Footnotes and endnotes | 3 | 60% | 60% | 3–5 | Placed and editable, numbers from Word files (#103); long notes continue onto the next page under the continuation separator (#352). Footnote and Endnote dialog with options per section: below text, end of section, number format, start at, restart each section or page, custom mark, convert (#385). | @@ -253,6 +253,7 @@ can be used without committing Word output. | Date | Change | Summary | |---|---|---| +| 2026-10-30 | trivial | Tables row: tables nest 18 deep at most, so a `.json` save or AutoRecover snapshot reads back, rich content in the deepest cell included (it was 24, which a `.json` save could not reopen); the .docx reader and writer still keep 24 (#3618); no percentage change | | 2026-10-29 | trivial | Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880). Filtered/sorted order, product geometry and single-label mode retained; readiness estimates unchanged. | | 2026-10-18 | trivial | References: Insert Table of Figures dialog; a table of figures lists any caption label (`TOC \c`), without label and number (`\a`), without page numbers (`\n`) or with them after the text (`\p`); Word's switch order is recognised and the leader kept on update (#3013); no percentage change | | 2026-10-15 | trivial | Pictures, shapes, text boxes: Insert › Shapes adds the new shape beside the selection, as Word draws it, so a second shape no longer replaces the first (still selected after its insert) and selected text stays; a picture still replaces a selected picture, as in Word (#2019) | From ed231fd02705a67a6b251336143abc3228bc6cca Mon Sep 17 00:00:00 2001 From: Rees <109076338+OPUZZLES@users.noreply.github.com> Date: Wed, 4 Nov 2026 14:49:31 +0100 Subject: [PATCH 3/3] Walk Print Markup comment objects once (#3614) (#4744) Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 4 +- crates/formats/src/comments.rs | 8 +- crates/formats/src/comments/tests.rs | 108 +++++++++++++++++++++++++++ docs/file-format-parity.md | 5 +- docs/gaps.md | 5 +- 5 files changed, 125 insertions(+), 5 deletions(-) create mode 100644 crates/formats/src/comments/tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index 109b75bca3..c321422a5f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -4,7 +4,7 @@ **Stage: alpha** · next: beta, ~15 points (60% → 75% ready for real work) and ~150–230 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** trivial (progress log: the dialogs of the mirrored Arabic interface read right to left: label-and-field rows, tables (list columns, the Models table), tile galleries and the Add/Remove arrows follow the reading direction, through one `rtl::Table` helper that draws exactly as before in English; no ranked gap closed, readiness estimates unchanged); previously trivial (progress log: fonts that aren't installed are flagged in the font box and status bar); previously trivial (progress log: Options › Language redesign, CP4.3); previously trivial (progress log: the missing-pack bar on a fresh install, the caret's typing language, and the spelling menu heading in the interface language, CP4.3); previously trivial (progress log: Language pack languages in every language list, CP4.3); previously trivial (progress log: craft-proof pin, French elision and Valencian, CP4.3); previously trivial (progress log: a long comment's card and balloon stop at ten lines, #3382); previously trivial (progress log: a pasted text box story is charged for the formatting it copies); previously trivial (progress log: a .doc table row keeps one override per cell); previously trivial (progress log: a .doc file's sections find their paragraphs by index); previously minor (Arabic interface language with mirrored UI, #1195); previously trivial (progress log: Replace and Remove Table of Contents with Word's title above a section break, #4041); previously trivial (progress log: a password-protected file named on the command line asks for its password after the startup document is replaced, #5299); previously trivial (progress log: language-packs-2 published and pinned, 75 Language packs); previously trivial (progress log: language-packs-1 published and pinned, CP4.4); previously trivial (progress log: Language packs, CP4);; previously trivial (DOCX table rows and cells inside selected alternate-content branches survive open and repeated saves (#5219); broader corpus qualification and readiness estimates unchanged); previously trivial (progress log: a list inside a Markdown block quote stays quoted); previously trivial (progress log: arrowheads keep their kind, width and length and are drawn at Word's sizes); previously trivial (progress log: the line between columns for selected text, #5178); previously trivial (progress log: .doc standard font, #5066); previously trivial (progress log: a table row doesn't split off a picture that doesn't fit, #4272); previously trivial (progress log: a middle or bottom cell of a row split across pages lines up on the first page); previously trivial (progress log: tab leader dots spaced and placed as in Word); previously trivial (progress log: a list level above counts its first number at the first deeper item); previously trivial (progress log: paragraph side borders lie outside the text, #5036); previously trivial (progress log: a paragraph naming an undefined style is formatted as Normal, #5077); previously trivial (progress log: TOC page numbers as PAGEREF fields, #5097); previously trivial (progress log: Backstage keytips, #4960); previously trivial (Progress log: only the printer's PDF leaves out links; Save these pages as PDF keeps them, #4734); previously trivial (progress log: Chinese and Japanese text counts a word per character, #4547; previously trivial: aligned-copy review fixes retain the document axis through scrolling and include secondary selections (#4764)); previously trivial (connector rerouting indexes laid-out object positions once per batch (#3631); previously trivial (progress log: corporate authors in bibliographies, #4848); previously trivial (custom-property bookmark-link preservation, #4767); previously trivial (progress log: Table Properties follows the selection, #4857); previously trivial (Relative font-size commands preserve mixed sizes in the selection (#4903); existing button steps and one-point shortcuts are unchanged; readiness estimates unchanged); previously trivial (Arabic Backspace removes one combining mark and keeps its base letter (#4757); previously trivial (note conversion walks reference offsets and formatting runs together, #4750); previously trivial (DOCX comment-ID save lookups); previously trivial (progress log: RTF pictures stored as Windows or Enhanced Metafiles are imported, #4259); previously trivial (#4658 bounds nested Markdown reference-label work); previously trivial (HTML import keeps preformatted lines with CR or CRLF input newlines, #4810); previously trivial (progress log: Insert › Shapes icon grid, #4349); previously trivial (progress log: AutoCorrect Options with no document open, #4585); previously trivial (progress log: window fallback geometry and equation symbol tooltips); previously trivial (progress log: window titles omit NUL characters, #3634); previously trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (progress log: Custom document properties dialog, #2982; previously trivial: progress log: View › New Window; previously trivial: progress log: interface translations come from storytold/craft-i18n through the `craft-i18n` crate; previously trivial: progress log: disabled-control reasons translated in every interface language, #2678; previously trivial: progress log: mail merge record navigation and record ranges for printing, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: group connector rerouting preserves rotated and flipped free or unresolved endpoints under nonuniform group scaling; readiness estimates unchanged; previously trivial: progress log: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: progress log: a relationship the markup names without the prefix is read as it wrote, and an ordinary `id` names no part; previously trivial: progress log: a document with many relationships opens in the time its part takes, and an id the text only mentions is not a reference, #3411; previously trivial: progress log: read only and comments-only protection grey out edits; previously trivial: progress log: an empty paragraph with a hidden mark shows nothing, not even its list number, #3982; previously trivial: progress log: "NaN" in the Font dialog's Size box no longer closes the app, #3663; previously trivial: progress log: a table cell span from a `.json` file no longer overflows, #3637; 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; progress docs restructured) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** trivial (Print Markup comment discovery walks paragraph objects once, #3614); previously trivial (progress log: the dialogs of the mirrored Arabic interface read right to left: label-and-field rows, tables (list columns, the Models table), tile galleries and the Add/Remove arrows follow the reading direction, through one `rtl::Table` helper that draws exactly as before in English; no ranked gap closed, readiness estimates unchanged); previously trivial (progress log: fonts that aren't installed are flagged in the font box and status bar); previously trivial (progress log: Options › Language redesign, CP4.3); previously trivial (progress log: the missing-pack bar on a fresh install, the caret's typing language, and the spelling menu heading in the interface language, CP4.3); previously trivial (progress log: Language pack languages in every language list, CP4.3); previously trivial (progress log: craft-proof pin, French elision and Valencian, CP4.3); previously trivial (progress log: a long comment's card and balloon stop at ten lines, #3382); previously trivial (progress log: a pasted text box story is charged for the formatting it copies); previously trivial (progress log: a .doc table row keeps one override per cell); previously trivial (progress log: a .doc file's sections find their paragraphs by index); previously minor (Arabic interface language with mirrored UI, #1195); previously trivial (progress log: Replace and Remove Table of Contents with Word's title above a section break, #4041); previously trivial (progress log: a password-protected file named on the command line asks for its password after the startup document is replaced, #5299); previously trivial (progress log: language-packs-2 published and pinned, 75 Language packs); previously trivial (progress log: language-packs-1 published and pinned, CP4.4); previously trivial (progress log: Language packs, CP4);; previously trivial (DOCX table rows and cells inside selected alternate-content branches survive open and repeated saves (#5219); broader corpus qualification and readiness estimates unchanged); previously trivial (progress log: a list inside a Markdown block quote stays quoted); previously trivial (progress log: arrowheads keep their kind, width and length and are drawn at Word's sizes); previously trivial (progress log: the line between columns for selected text, #5178); previously trivial (progress log: .doc standard font, #5066); previously trivial (progress log: a table row doesn't split off a picture that doesn't fit, #4272); previously trivial (progress log: a middle or bottom cell of a row split across pages lines up on the first page); previously trivial (progress log: tab leader dots spaced and placed as in Word); previously trivial (progress log: a list level above counts its first number at the first deeper item); previously trivial (progress log: paragraph side borders lie outside the text, #5036); previously trivial (progress log: a paragraph naming an undefined style is formatted as Normal, #5077); previously trivial (progress log: TOC page numbers as PAGEREF fields, #5097); previously trivial (progress log: Backstage keytips, #4960); previously trivial (Progress log: only the printer's PDF leaves out links; Save these pages as PDF keeps them, #4734); previously trivial (progress log: Chinese and Japanese text counts a word per character, #4547; previously trivial: aligned-copy review fixes retain the document axis through scrolling and include secondary selections (#4764)); previously trivial (connector rerouting indexes laid-out object positions once per batch (#3631); previously trivial (progress log: corporate authors in bibliographies, #4848); previously trivial (custom-property bookmark-link preservation, #4767); previously trivial (progress log: Table Properties follows the selection, #4857); previously trivial (Relative font-size commands preserve mixed sizes in the selection (#4903); existing button steps and one-point shortcuts are unchanged; readiness estimates unchanged); previously trivial (Arabic Backspace removes one combining mark and keeps its base letter (#4757); previously trivial (note conversion walks reference offsets and formatting runs together, #4750); previously trivial (DOCX comment-ID save lookups); previously trivial (progress log: RTF pictures stored as Windows or Enhanced Metafiles are imported, #4259); previously trivial (#4658 bounds nested Markdown reference-label work); previously trivial (HTML import keeps preformatted lines with CR or CRLF input newlines, #4810); previously trivial (progress log: Insert › Shapes icon grid, #4349); previously trivial (progress log: AutoCorrect Options with no document open, #4585); previously trivial (progress log: window fallback geometry and equation symbol tooltips); previously trivial (progress log: window titles omit NUL characters, #3634); previously trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (progress log: Custom document properties dialog, #2982; previously trivial: progress log: View › New Window; previously trivial: progress log: interface translations come from storytold/craft-i18n through the `craft-i18n` crate; previously trivial: progress log: disabled-control reasons translated in every interface language, #2678; previously trivial: progress log: mail merge record navigation and record ranges for printing, #3028; previously trivial: selection bar for right-to-left paragraphs and table rows; previously trivial: group connector rerouting preserves rotated and flipped free or unresolved endpoints under nonuniform group scaling; readiness estimates unchanged; previously trivial: progress log: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: progress log: a relationship the markup names without the prefix is read as it wrote, and an ordinary `id` names no part; previously trivial: progress log: a document with many relationships opens in the time its part takes, and an id the text only mentions is not a reference, #3411; previously trivial: progress log: read only and comments-only protection grey out edits; previously trivial: progress log: an empty paragraph with a hidden mark shows nothing, not even its list number, #3982; previously trivial: progress log: "NaN" in the Font dialog's Size box no longer closes the app, #3663; previously trivial: progress log: a table cell span from a `.json` file no longer overflows, #3637; 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; progress docs restructured) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below. WordCraft aims for complete parity with Microsoft Word, then goes further on speed, openness and @@ -139,6 +139,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm ## Progress log +- 2026-10-28: Print Markup HTML, Markdown and text copies discover comment anchors in one paragraph walk, so unrelated bookmark markers no longer cause repeated prefix scans (#3614). Existing quotes, replies, order and export limits are unchanged; original synthetic semantic and scaling regressions cover the traversal. - 2026-10-30: Layout resource caps (#3867, #4385) and linear-time passes (#3866, #4392): repeated table-header rows are capped by item counts, a clipped group counting every nested member, the wrap cache by decoded pixels and the widths a linked text box chain lays out by their lines and items; PDF export, hit testing, tracked-change and clipped-table costs, and long endnote selections no longer grow quadratically. Synthetic regression tests only; no new Word comparison. - 2026-11-03: Proofing (CP4.3): craft-proof moves to craft-libs main with #589 and #590. French spelling suggestions offer the elided form first (`Jadore` → `J'adore`, before `J adore`), and Valencian text (`ca-ES-valencia`) is proofed with the `ca-valencia` pack instead of plain Catalan. Readiness unchanged. - 2026-11-03: A long comment no longer grows its card or balloon without limit (#3382). Its field in the Comments pane and in a selected balloon stops at ten lines and scrolls, keeping the caret in view; a balloon that isn't selected shows the first ten lines of the comment and of each reply, and clicking it opens the scrolling field. The buttons and the next comment stay in view. @@ -717,6 +718,7 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps | Date | Change | Summary | |---|---|---| +| 2026-10-28 | trivial | Print Markup text-copy comment discovery walks paragraph objects once (#3614); quotes, order, limits and readiness estimates unchanged | | 2026-11-03 | trivial | Progress log: Language pack languages in every language list (CP4.3) | | 2026-11-03 | trivial | Progress log: the missing-pack bar on a fresh install, the caret's typing language, and the spelling menu heading in the interface language (CP4.3) | | 2026-11-03 | trivial | Progress log: Options › Language and Proofing redesign (CP4.3) | diff --git a/crates/formats/src/comments.rs b/crates/formats/src/comments.rs index 547440a51e..75fc97d3a9 100644 --- a/crates/formats/src/comments.rs +++ b/crates/formats/src/comments.rs @@ -56,9 +56,10 @@ pub fn notes(doc: &Document) -> Vec { let mut open: Vec = Vec::new(); for path in doc.para_paths(StoryRef::Body) { let Some(p) = doc.para(StoryRef::Body, &path) else { continue }; - for (off, c) in p.text.char_indices() { + let mut objects = p.objects.iter(); + for c in p.text.chars() { if c == OBJ { - match p.object_at(off) { + match objects.next() { Some(InlineObject::CommentStart { id }) => { if seen.insert(*id) { order.push(*id); @@ -198,3 +199,6 @@ fn text(notes: &[Note]) -> String { } s } + +#[cfg(test)] +mod tests; diff --git a/crates/formats/src/comments/tests.rs b/crates/formats/src/comments/tests.rs new file mode 100644 index 0000000000..f91e7626e0 --- /dev/null +++ b/crates/formats/src/comments/tests.rs @@ -0,0 +1,108 @@ +use super::*; +use wordcraft_doc::{Block, CharProps, Comment, Paragraph, PartKind, Run, Table, para_block}; + +fn paragraph(text: String, objects: Vec) -> Paragraph { + let len = text.len(); + Paragraph { text, objects, runs: vec![Run { len, props: CharProps::default() }], ..Paragraph::new() } +} + +fn comment(doc: &mut Document, id: u32, author: &str, parent: Option, text: &str) { + let part = doc.add_part(PartKind::Comment, vec![para_block(Paragraph::with_text(text, CharProps::default()))]); + doc.comments.insert(id, Comment { author: author.into(), part, parent, ..Default::default() }); +} + +#[test] +fn marker_walk_preserves_comment_quotes_order_and_unpaired_objects() { + let mut doc = Document::new(); + comment(&mut doc, 9, " Ada ", None, "First note"); + comment(&mut doc, 3, " ", Some(9), "Reply text"); + comment(&mut doc, 5, "", None, "Second note"); + comment(&mut doc, 7, "Grace", None, "Unanchored"); + comment(&mut doc, 12, "Linus", None, "Cell note"); + doc.body = vec![para_block(paragraph( + format!("é漢{OBJ}α {OBJ}β{OBJ} γ{OBJ}{OBJ}"), + vec![ + InlineObject::CommentStart { id: 9 }, + InlineObject::CommentStart { id: 5 }, + InlineObject::CommentEnd { id: 9 }, + InlineObject::CommentEnd { id: 5 }, + // No marker for this object: it must not anchor the unanchored comment. + InlineObject::BookmarkStart { name: "unrelated".into() }, + InlineObject::CommentStart { id: 7 }, + ], + ))]; + // Missing object storage must not replay an earlier comment marker. + doc.body.push(para_block(paragraph(format!("{OBJ}{OBJ}tail"), vec![]))); + let mut table = Table::new(1, 1, 200.0); + table.rows[0].cells[0].blocks = + vec![para_block(paragraph(format!("{OBJ}cell{OBJ}"), vec![InlineObject::CommentStart { id: 12 }, InlineObject::CommentEnd { id: 12 }]))]; + doc.body.push(std::sync::Arc::new(Block::Table(table))); + let before = doc.clone(); + let actual = notes(&doc); + assert_eq!( + actual, + vec![ + Note { author: "Ada".into(), quote: "α β".into(), text: "First note".into(), replies: vec![("Author".into(), "Reply text".into())] }, + Note { author: "Author".into(), quote: "β γ".into(), text: "Second note".into(), replies: vec![] }, + Note { author: "Linus".into(), quote: "cell".into(), text: "Cell note".into(), replies: vec![] }, + Note { author: "Grace".into(), quote: "".into(), text: "Unanchored".into(), replies: vec![] }, + ] + ); + assert_eq!(doc, before, "export discovery must leave the document unchanged"); +} + +fn dense_markers(n: usize) -> Document { + let mut doc = Document::new(); + comment(&mut doc, 9, "Ada", None, "Kept note"); + let mut objects = Vec::with_capacity(2 * n + 2); + for k in 0..n { + objects.push(InlineObject::BookmarkStart { name: format!("bookmark{k}") }); + objects.push(InlineObject::BookmarkEnd { name: format!("bookmark{k}") }); + } + objects.extend([InlineObject::CommentStart { id: 9 }, InlineObject::CommentEnd { id: 9 }]); + doc.body = vec![para_block(paragraph(format!("{}{OBJ}é漢 quote{OBJ}", OBJ.to_string().repeat(2 * n)), objects))]; + doc +} + +// Ignore scheduler delays. An absent, failed, decreasing or nonadvancing counter supplies no +// performance evidence; the semantic assertions below still run on every platform. +fn cpu_cost(mut f: impl FnMut()) -> Option { + let ran = || std::fs::read_to_string("/proc/thread-self/schedstat").ok()?.split_whitespace().next()?.parse::().ok(); + let before = ran(); + f(); + match (before, ran()) { + (Some(a), Some(b)) if b > a => Some((b - a) as f64 * 1e-9), + _ => None, + } +} + +/// Unrelated markers must add one walk, rather than a fresh paragraph-prefix scan per marker. +#[test] +fn comment_export_discovery_scales_with_unrelated_markers() { + let (small, big) = (dense_markers(2_000), dense_markers(16_000)); + let expected = vec![Note { author: "Ada".into(), quote: "é漢 quote".into(), text: "Kept note".into(), replies: vec![] }]; + assert_eq!(notes(&small), expected); + assert_eq!(notes(&big), expected); + let (mut t1, mut t8) = (f64::INFINITY, f64::INFINITY); + for _ in 0..3 { + let a = cpu_cost(|| { + for _ in 0..8 { + std::hint::black_box(notes(&small)); + } + }); + let b = cpu_cost(|| { + std::hint::black_box(notes(&big)); + }); + if let (Some(a), Some(b)) = (a, b) { + t1 = t1.min(a / 8.0); + t8 = t8.min(b); + } + } + if t1.is_finite() && t8.is_finite() { + let ratio = t8 / t1; + eprintln!("comment discovery CPU: small={t1:.6}s big={t8:.6}s ratio={ratio:.2}"); + assert!(ratio < 24.0, "8x the markers cost {ratio:.2}x CPU ({t1:.6}s versus {t8:.6}s)"); + } else { + eprintln!("comment discovery scaling skipped: no advancing thread CPU samples"); + } +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index c5dbe1ca07..bfa207c3cd 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-11-03 · **Change:** trivial (Replace and Remove Table of Contents treat Word's title above a section break as Word does, #4041); previously trivial (a password-protected .docx opened from the file manager asks for its password also when the startup document was replaced, #5299); previously minor (`w:altChunk` brought in on open; MHT opens; HTML style sheets, paragraph margins and Word's web-page defaults; broader corpus qualification and readiness estimates unchanged); previously trivial (a VML horizontal rule (`o:hr`) is drawn across its line at Word's measured width and kept on save, with rotation and flips, #3956); previously trivial (soft edges on a see-through solid fill fade from the fill's own opacity, #1600); previously trivial (DOCX table rows and cells inside selected alternate-content branches survive open and repeated saves (#5219); broader corpus qualification and readiness estimates unchanged); previously trivial (a list inside a Markdown block quote is written back inside the quote); previously trivial (arrowheads keep their kind, width and length and are drawn at Word's sizes); previously trivial (.doc: the default font is the stylesheet's standard font (Aptos in Word 365 files), #5066); previously trivial (a paragraph naming an undefined style is formatted as Normal, as in Word, #5077); previously trivial (a hyperlinked TOC writes its page numbers as `PAGEREF _Toc… \h` fields, as Word does, #5097); previously trivial (HTML import ends the empty comments and at once, #5040); previously trivial (chart and SmartArt text the font lacks is drawn in a fallback face, so Chinese labels no longer show boxes, #4720); previously trivial (bibliography sources keep a corporate author (`b:Corporate`): read, written, cited and listed whole, #4848); previously trivial (custom-property bookmark-link preservation, #4767); previously trivial (note conversion preserves content with an ordered reference/run walk, #4750); previously trivial (DOCX comment-ID save lookups); previously trivial (built-in heading styles have their outline level without `w:outlineLvl`, #4696); previously trivial (DOCX comment ranges inside footnotes and endnotes are kept on read, #4548); previously trivial (RTF import and export keep Windows and Enhanced Metafile pictures (`\wmetafile`, `\emfblip`), #4259); previously trivial (#4658 bounds nested Markdown reference-label work); previously trivial (HTML import keeps preformatted lines with CR or CRLF input newlines, #4810); previously trivial (DOCX parts stored under another casing keep their embedded fonts through a save, and a private save of a kept glossary escapes the attribute values it rewrites; previously 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 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-03 · **Change:** trivial (Print Markup comment discovery walks paragraph objects once, #3614); previously trivial (Replace and Remove Table of Contents treat Word's title above a section break as Word does, #4041); previously trivial (a password-protected .docx opened from the file manager asks for its password also when the startup document was replaced, #5299); previously minor (`w:altChunk` brought in on open; MHT opens; HTML style sheets, paragraph margins and Word's web-page defaults; broader corpus qualification and readiness estimates unchanged); previously trivial (a VML horizontal rule (`o:hr`) is drawn across its line at Word's measured width and kept on save, with rotation and flips, #3956); previously trivial (soft edges on a see-through solid fill fade from the fill's own opacity, #1600); previously trivial (DOCX table rows and cells inside selected alternate-content branches survive open and repeated saves (#5219); broader corpus qualification and readiness estimates unchanged); previously trivial (a list inside a Markdown block quote is written back inside the quote); previously trivial (arrowheads keep their kind, width and length and are drawn at Word's sizes); previously trivial (.doc: the default font is the stylesheet's standard font (Aptos in Word 365 files), #5066); previously trivial (a paragraph naming an undefined style is formatted as Normal, as in Word, #5077); previously trivial (a hyperlinked TOC writes its page numbers as `PAGEREF _Toc… \h` fields, as Word does, #5097); previously trivial (HTML import ends the empty comments and at once, #5040); previously trivial (chart and SmartArt text the font lacks is drawn in a fallback face, so Chinese labels no longer show boxes, #4720); previously trivial (bibliography sources keep a corporate author (`b:Corporate`): read, written, cited and listed whole, #4848); previously trivial (custom-property bookmark-link preservation, #4767); previously trivial (note conversion preserves content with an ordered reference/run walk, #4750); previously trivial (DOCX comment-ID save lookups); previously trivial (built-in heading styles have their outline level without `w:outlineLvl`, #4696); previously trivial (DOCX comment ranges inside footnotes and endnotes are kept on read, #4548); previously trivial (RTF import and export keep Windows and Enhanced Metafile pictures (`\wmetafile`, `\emfblip`), #4259); previously trivial (#4658 bounds nested Markdown reference-label work); previously trivial (HTML import keeps preformatted lines with CR or CRLF input newlines, #4810); previously trivial (DOCX parts stored under another casing keep their embedded fonts through a save, and a private save of a kept glossary escapes the attribute values it rewrites; previously 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; @@ -12,6 +12,8 @@ gives ~64%; we discount it to ~60% because DOCX fidelity has never been checked files. DOCX dominates the weight: a Word user sends and receives .docx; everything else is occasional. +Print Markup HTML, Markdown and text copies collect comment anchors with one forward object walk (#3614). Unicode quotes, tables, overlapping comment ranges, replies and unmatched object storage retain their behavior. Quote limits, body-only anchor traversal and readiness estimates are unchanged. + ## Drawing payloads DOCX textboxes retain their own frame and story when they contain an inline picture. @@ -294,6 +296,7 @@ Table rows and cells inside `mc:AlternateContent` use the same selected-branch p | Date | Change | Summary | |---|---|---| +| 2026-10-28 | trivial | Print Markup text-copy comment discovery walks paragraph objects once (#3614); quotes, order, limits and readiness estimates unchanged | | 2026-11-03 | trivial | Replace Table of Contents keeps Word's title above the section break over entries in columns, and Remove takes the title, the blank paragraph and its break with the table, as observed in Word for Mac (#4041) | | 2026-11-02 | minor | `w:altChunk` content (MHT, HTML, RTF, text, nested documents) brought in on open, where it stands; `.mht`/`.mhtml` open; HTML reads `style` element rules, paragraph margins, indents and line height, cell widths and borders, and an opened page takes Word's web-page defaults. Insert › Text from File keeps the file's pictures and lists | | 2026-11-02 | trivial | DOCX: a VML horizontal rule (`v:rect o:hr`, a web page's `
` pasted into Word) is drawn across its line as Word draws it — the whole width inside the paragraph's indents, or `o:hrpct` of it, or its own width, placed by `o:hralign` — instead of as a 1 pt dot; in a table cell it follows the column; it saves back as the same VML rule | diff --git a/docs/gaps.md b/docs/gaps.md index 8da68b9f5c..76453e23b4 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-11-03 · **Change:** trivial (#11: the dialogs of the mirrored Arabic interface read right to left: label-and-field rows, tables (list columns, the Models table), tile galleries and the Add/Remove arrows follow the reading direction, through one `rtl::Table` helper that draws exactly as before in English; no ranked gap closed, readiness estimates unchanged); previously trivial (#4: fonts that aren't installed are flagged in the font box and status bar); previously trivial (#8: Options › Language lists Your languages and Add a language… replaces the Language packs table); previously trivial (#8: the missing-pack bar works on a fresh install and counts the caret's language); previously trivial (#8: Language pack languages are in Set Proofing Language, Review › Language and the status bar; built-in proofing is named as such); previously trivial (#20: layout resource caps and linear-time passes (#3867, #3866)); previously trivial (#3 narrowed: a pasted text box story is charged for the formatting it copies); previously trivial (#3 narrowed: a .doc table row keeps one override per cell); previously trivial (#3 narrowed: a .doc file's sections find their paragraphs by index); previously minor (#11: Arabic interface with mirrored shell landed, #1195; hi/id/pt-PT and Hebrew UI remain); previously trivial (#8: language-packs-2 is published and pinned: 75 packs, adding Danish, German (Germany, Austria, Switzerland), Esperanto, Estonian, Persian, Croatian, Hungarian, Norwegian (Bokmål, Nynorsk), Dutch and Swedish (Sweden, Finland); no ranked gap changed); previously trivial (#8: language-packs-1 is published and pinned, 61 packs: Lithuanian, Romanian, Catalan, French, Greek, Slovak, Serbian and Latvian install as Language packs; Dutch, German and the other compounding languages wait on craft-proof; no ranked gap changed); previously minor (#8: Language packs: every other language's spelling and hyphenation installs from File › Options › Language or the missing-pack bar, through the font store; Lithuanian and Romanian are available as a Language pack once language-packs-1 is published; Dutch, Catalan, French, Greek, Slovak, Serbian and Latvian packs wait on craft-proof engine support); previously trivial (gap 8: English spelling reads SCOWL 2026, one dictionary per English, #40, #520, #5205; no ranked gap changed);; previously trivial (DOCX table rows and cells inside selected alternate-content branches survive open and repeated saves (#5219); broader corpus qualification and readiness estimates unchanged); previously trivial (#26 narrowed: MHT opens and `w:altChunk` content is brought in on open; saving MHT remains) previously trivial (gap 8 points at craft-proof in craft-libs, its data in craft-i18n, #5202; no ranked gap changed); previously trivial (arrowheads keep their kind, width and length and are drawn at Word's sizes; no ranked gap changed); previously trivial (.doc default font from the stylesheet's standard font, #5066; no ranked gap changed); previously trivial (a middle or bottom cell of a row split across pages lines up on the first page; no ranked gap changed); previously trivial (tab leader dots spaced and placed as in Word; no ranked gap changed); previously trivial (a list level above counts its first number at the first deeper item; no ranked gap changed); previously trivial (only the PDF sent to the printer leaves out link annotations; Save these pages as PDF keeps them, #4734; no ranked gap changed); previously trivial (documents with links print: the print job leaves out link annotations, #4734; no ranked gap changed; previously trivial: aligned-copy review fixes retain the document axis through scrolling and include secondary selections (#4764)); previously trivial (connector rerouting indexes laid-out object positions once per batch (#3631); previously trivial (custom-property bookmark-link preservation, #4767); previously trivial (Relative font-size commands preserve mixed sizes in the selection (#4903); existing button steps and one-point shortcuts are unchanged; readiness estimates unchanged); previously trivial (Arabic Backspace removes one combining mark and keeps its base letter (#4757); previously trivial (note conversion reference-run membership uses one ordered walk, #4750); previously trivial (DOCX comment-ID save lookups); previously trivial (gap 19: RTF reads and writes WMF and EMF pictures, #4259); previously trivial (#4658 bounds nested Markdown reference-label work); previously trivial (HTML import keeps preformatted lines with CR or CRLF input newlines, #4810); previously trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously 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 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-03 · **Change:** trivial (Print Markup comment discovery walks paragraph objects once, #3614); previously trivial (#11: the dialogs of the mirrored Arabic interface read right to left: label-and-field rows, tables (list columns, the Models table), tile galleries and the Add/Remove arrows follow the reading direction, through one `rtl::Table` helper that draws exactly as before in English; no ranked gap closed, readiness estimates unchanged); previously trivial (#4: fonts that aren't installed are flagged in the font box and status bar); previously trivial (#8: Options › Language lists Your languages and Add a language… replaces the Language packs table); previously trivial (#8: the missing-pack bar works on a fresh install and counts the caret's language); previously trivial (#8: Language pack languages are in Set Proofing Language, Review › Language and the status bar; built-in proofing is named as such); previously trivial (#20: layout resource caps and linear-time passes (#3867, #3866)); previously trivial (#3 narrowed: a pasted text box story is charged for the formatting it copies); previously trivial (#3 narrowed: a .doc table row keeps one override per cell); previously trivial (#3 narrowed: a .doc file's sections find their paragraphs by index); previously minor (#11: Arabic interface with mirrored shell landed, #1195; hi/id/pt-PT and Hebrew UI remain); previously trivial (#8: language-packs-2 is published and pinned: 75 packs, adding Danish, German (Germany, Austria, Switzerland), Esperanto, Estonian, Persian, Croatian, Hungarian, Norwegian (Bokmål, Nynorsk), Dutch and Swedish (Sweden, Finland); no ranked gap changed); previously trivial (#8: language-packs-1 is published and pinned, 61 packs: Lithuanian, Romanian, Catalan, French, Greek, Slovak, Serbian and Latvian install as Language packs; Dutch, German and the other compounding languages wait on craft-proof; no ranked gap changed); previously minor (#8: Language packs: every other language's spelling and hyphenation installs from File › Options › Language or the missing-pack bar, through the font store; Lithuanian and Romanian are available as a Language pack once language-packs-1 is published; Dutch, Catalan, French, Greek, Slovak, Serbian and Latvian packs wait on craft-proof engine support); previously trivial (gap 8: English spelling reads SCOWL 2026, one dictionary per English, #40, #520, #5205; no ranked gap changed);; previously trivial (DOCX table rows and cells inside selected alternate-content branches survive open and repeated saves (#5219); broader corpus qualification and readiness estimates unchanged); previously trivial (#26 narrowed: MHT opens and `w:altChunk` content is brought in on open; saving MHT remains) previously trivial (gap 8 points at craft-proof in craft-libs, its data in craft-i18n, #5202; no ranked gap changed); previously trivial (arrowheads keep their kind, width and length and are drawn at Word's sizes; no ranked gap changed); previously trivial (.doc default font from the stylesheet's standard font, #5066; no ranked gap changed); previously trivial (a middle or bottom cell of a row split across pages lines up on the first page; no ranked gap changed); previously trivial (tab leader dots spaced and placed as in Word; no ranked gap changed); previously trivial (a list level above counts its first number at the first deeper item; no ranked gap changed); previously trivial (only the PDF sent to the printer leaves out link annotations; Save these pages as PDF keeps them, #4734; no ranked gap changed); previously trivial (documents with links print: the print job leaves out link annotations, #4734; no ranked gap changed; previously trivial: aligned-copy review fixes retain the document axis through scrolling and include secondary selections (#4764)); previously trivial (connector rerouting indexes laid-out object positions once per batch (#3631); previously trivial (custom-property bookmark-link preservation, #4767); previously trivial (Relative font-size commands preserve mixed sizes in the selection (#4903); existing button steps and one-point shortcuts are unchanged; readiness estimates unchanged); previously trivial (Arabic Backspace removes one combining mark and keeps its base letter (#4757); previously trivial (note conversion reference-run membership uses one ordered walk, #4750); previously trivial (DOCX comment-ID save lookups); previously trivial (gap 19: RTF reads and writes WMF and EMF pictures, #4259); previously trivial (#4658 bounds nested Markdown reference-label work); previously trivial (HTML import keeps preformatted lines with CR or CRLF input newlines, #4810); previously trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously 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 > **Highlight update:** 2026-10-25 · Highlight shortcut toggles the last chosen colour off when the whole selection has it; explicit colour commands remain setters (#4085). Readiness estimates unchanged. > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** trivial (aligned-copy review fixes retain the document axis through scrolling and include secondary selections (#4764)); previously trivial (#15: bibliography sources keep a corporate author, #4848); previously trivial (Table Properties applies to the selected rows, columns and cells, #4857); previously trivial (Relative font-size commands preserve mixed sizes in the selection (#4903); existing button steps and one-point shortcuts are unchanged; readiness estimates unchanged); previously trivial (Arabic Backspace removes one combining mark and keeps its base letter (#4757); previously trivial (Recipient labels continue across product-sized sheets instead of stopping after the first sheet (#4880)); previously trivial (#7: Custom Document Properties dialog, #2982; previously trivial: egui 0.37 migration: the interface catalogs stay shared and pinned by `craft-i18n.rev` at `9205108cfdced5177706264038848b68b2422d2b` (27 languages, English fallback); input-method handling has new regressions but managed CI passed, and native IME acceptance plus native-speaker catalog review remain open; previously trivial: Markdown code spans keep line-broken text literal, #3738; previously trivial: small follow-ups from the field, table and AutoFormat batch, #4232; previously trivial: highlight target includes selected paragraph marks, #4085; previously trivial: full-target highlight check beyond 10,000 runs, #4085; previously trivial: Exact-spacing table contact uses the specified line height rather than paragraph-mark metrics (#3977); previously trivial: cu20we: restrict pre-spacing float origins to paragraph anchors; document cap helpers and regressions; previously trivial: a command that changes nothing is not an edit: no undo step, no unsaved changes, no relayout, and the redo stack survives it; previously minor: #8 narrowed: Spanish proofing (Spain, Mexico, Latin America, Uruguay) from the installed Hunspell dictionaries, #3360, #3553; previously trivial: gap 10: group connector rerouting preserves transformed free and unresolved endpoints under nonuniform scaling; obstacle routing still missing; readiness estimates unchanged; previously trivial: a word with no break opportunity in it no longer makes the layout quadratic, #3412; previously trivial: the ranked gap list held two rows each for #19, #20 and #21; each gap appears once now; a document with many relationships opens in the time its part takes, #3411; previously trivial: the file commands that change the document are checked on a protected document, the protection of a protected document needs an explicit stop, the session author can't change through `file.setAuthor` under protection (the CLI's `--author` uses the same protection check), and the post-check compares the settings and the metadata; previously trivial: explicit documents keep the editor when recovery discovery finishes; readable recovery controls; previously trivial: text mini toolbar available with the right-click menu, #2959; previously trivial: personal templates in File › New and Save as Word Template, #2852; previously trivial: tracked table/row/cell/section property changes record in WordCraft, narrowing gap 5; previously trivial: a document with many content controls saves in the time the controls take, #13; previously trivial: Format Page Numbers dialog carries Word's number formats and Continue from previous section / Start at, #1113; previously trivial: centred list labels after leading page breaks, #2964; previously trivial: fixed font inputs in four layout controls; previously trivial: a right, centre or decimal tab whose text doesn't fit, or a left tab past the right indent, goes to the next line with its text, #1840; previously trivial: Bullets and Numbering toggle off style-inherited lists at their effective level, including basedOn chains and numbered ListParagraph styles; other paragraphs and list definitions stay unchanged (related to #3459); previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously trivial: #8: English dialects — en-GB, en-CA, en-AU… flag each other's spellings, #1281; previously trivial: #1: shape gradients and transparency, #1500; previously trivial: text typed into a content control takes the control's formatting; localized placeholder styles recognised; previously trivial: forms protection keeps its content controls: no inserting, Properties or Remove Content Control; previously trivial: clearing a no-match font query keeps the recent heading visible, #2068; previously trivial: line breaking: a slash opening a word travels whole to the next line, #2963; previously trivial: recipient list search and merged-only view, #1576; previously trivial: #2 impact restated: only ink rendering and OLE editing remain; previously trivial: #2 narrowed: native ink nested in groups and drawing canvases kept on save; previously trivial: opening a DOCX no 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). @@ -47,6 +47,8 @@ Legacy Word imports (#2445): interactive Save for `.doc`/`.dot` uses the existin 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. +Print Markup text-copy exports also walk paragraph objects once when collecting comment quotes (#3614). Body/table traversal, quote limits, reply order and exported text remain unchanged. Other engine, reader and UI scans remain separate. + 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. @@ -157,6 +159,7 @@ Table rows and cells inside `mc:AlternateContent` use the same selected-branch p | Date | Change | Summary | |---|---|---| +| 2026-10-28 | trivial | Print Markup text-copy comment discovery walks paragraph objects once (#3614); quotes, order, limits and readiness estimates unchanged | | 2026-10-30 | trivial | #20 narrowed: layout resource caps and linear-time passes (#3867, #3866) | | 2026-11-03 | minor | #11: Arabic interface with mirrored shell (#1195); key languages missing are now hi and id (plus pt-PT); Hebrew UI still open | | 2026-11-03 | trivial | #8 (CP4.3): one language list: Set Proofing Language, Review › Language and the status bar offer the Language pack languages (Bulgarian was in none); every published pack named in English; Valencian is `ca-ES-valencia`; gap 8 names built-in proofing |