diff --git a/ROADMAP.md b/ROADMAP.md index 68835cacf..6b2f858b2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~15 points (60% → 75% ready for real work) and ~150–230 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** trivial (progress log: ODT sections, #1379; previously trivial: progress log: table deletes, Change Case and Unlink Fields respect Track Changes and locked content controls, #1329; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** trivial (progress log: tables nest 24 deep at most, #1596; previously trivial: progress log: ODT sections, #1379; previously trivial: progress log: table deletes, Change Case and Unlink Fields respect Track Changes and locked content controls, #1329; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 > **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below. @@ -136,6 +136,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | Date | What landed | |---|---| +| 2026-10-14 | Tables nest 24 deep at most, the depth the .docx reader and writer keep (#1596): pasting a table into its own cell again and again no longer builds a document that crashes when it is saved as JSON, copied, closed or replaced by a new one. A table that would go deeper arrives as its paragraphs, text kept, and Insert › Table in a cell 24 tables deep says why it can't. Word has no limit | | 2026-10-13 | Rounded rectangles (#1476): a `roundRect` keeps the corner radius its file gives it (`adj`), on screen, in PDF and when saved; all were drawn with a 16% corner | | 2026-10-13 | PAGEREF in headers and footers (#1477): "Page X of Y" with Y a page reference to a bookmark at each letter's end shows each letter's own total instead of the result stored in the file | | 2026-10-13 | Replace All respects locked content controls with Track Changes on or off; it refuses the entire batch before editing when any match would change locked content or remove a control locked against deletion | @@ -317,6 +318,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | Date | Change | Summary | |---|---|---| +| 2026-10-14 | trivial | Progress log: tables nest 24 deep at most (#1596) | | 2026-10-13 | trivial | Progress log: rounded rectangle corners (#1476) | | 2026-10-13 | trivial | Progress log: PAGEREF in headers and footers (#1477) | | 2026-10-13 | trivial | Replace All checks content-control locks before editing; a refused batch leaves the document and undo history unchanged; existing deletion-lock rules reused, with tracked/untracked regression coverage | diff --git a/crates/doc/src/edit.rs b/crates/doc/src/edit.rs index 6c36f9295..a68f7712b 100644 --- a/crates/doc/src/edit.rs +++ b/crates/doc/src/edit.rs @@ -10,6 +10,7 @@ use serde::{Deserialize, Serialize}; use crate::para::InlineObject; use crate::props::{CharProps, ParaProps}; use crate::{Block, Blocks, DocError, Document, Paragraph, Part, PartKind, Path, Pos, Result, para_block}; +use crate::{Table, table::MAX_TABLE_DEPTH}; /// A copied piece of a document: blocks, where the first and last paragraphs may be partial. #[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)] @@ -58,6 +59,53 @@ fn order(a: &Pos, b: &Pos) -> (Pos, Pos) { if a <= b { (a.clone(), b.clone()) } else { (b.clone(), a.clone()) } } +/// The blocks in `t`'s cells, row by row. +fn cell_blocks(t: &Table) -> impl DoubleEndedIterator { + t.rows.iter().flat_map(|r| r.cells.iter()).flat_map(|c| c.blocks.iter()).map(|b| &**b) +} + +/// How many tables deep `t` goes, itself included: 1 when its cells hold no table (without +/// recursion: a pasted fragment may be deep). +fn table_height(t: &Table) -> usize { + let mut most = 1; + let mut stack: Vec<(&Block, usize)> = cell_blocks(t).map(|b| (b, 1)).collect(); + while let Some((b, d)) = stack.pop() { + if let Block::Table(inner) = b { + let d = d.saturating_add(1); + most = most.max(d); + stack.extend(cell_blocks(inner).map(|cb| (cb, d))); + } + } + most +} + +/// `b` as blocks nesting at most `room` tables deep: a table that would go deeper is replaced by +/// its paragraphs (tables in its cells too, document order), as the DOCX reader does. +fn fit_tables(b: Block, room: usize, out: &mut Vec) { + match b { + Block::Table(t) if room == 0 => { + let mut stack: Vec<&Block> = cell_blocks(&t).rev().collect(); + while let Some(cb) = stack.pop() { + match cb { + Block::Para(p) => out.push(Block::Para(p.clone())), + Block::Table(inner) => stack.extend(cell_blocks(inner).rev()), + } + } + } + Block::Table(mut t) if table_height(&t) > room => { + for cell in t.rows.iter_mut().flat_map(|r| r.cells.iter_mut()) { + let mut kept = Vec::new(); + for cb in std::mem::take(&mut cell.blocks) { + fit_tables(Arc::unwrap_or_clone(cb), room.saturating_sub(1), &mut kept); + } + cell.blocks = kept.into_iter().map(Arc::new).collect(); + } + out.push(Block::Table(t)); + } + b => out.push(b), + } +} + /// Nesting limit when following text boxes inside text boxes. const MAX_BOX_DEPTH: usize = 8; /// Most text box stories one paste may create (hostile documents can fan out). @@ -274,8 +322,14 @@ impl Document { Ok(Pos { story: pos.story, path: pos.path.with_last(i + 1), off: 0 }) } - /// Insert a block before the block at `path` (same container). + /// Insert a block before the block at `path` (same container). A table that would nest + /// deeper than [`MAX_TABLE_DEPTH`] is refused. pub fn insert_block(&mut self, story: crate::StoryRef, path: &Path, block: Block) -> Result<()> { + if let Block::Table(t) = &block + && path.depth().saturating_add(table_height(t)) > MAX_TABLE_DEPTH + { + return Err(DocError::Invalid(format!("tables can't nest more than {MAX_TABLE_DEPTH} deep"))); + } let i = path.last(); let bl = self.container_mut(story, path)?; if i > bl.len() { @@ -563,7 +617,12 @@ impl Document { /// Insert a fragment at `pos`; returns the position after the pasted content. pub fn insert_fragment(&mut self, pos: &Pos, frag: &Fragment) -> Result { - let mut blocks = frag.blocks.clone(); + // Tables that would nest deeper than the limit here become their paragraphs (#1596). + let room = MAX_TABLE_DEPTH.saturating_sub(pos.path.depth()); + let mut blocks = Vec::with_capacity(frag.blocks.len()); + for b in frag.blocks.iter().cloned() { + fit_tables(b, room, &mut blocks); + } if blocks.is_empty() { return Ok(pos.clone()); } @@ -700,6 +759,73 @@ mod tests { assert!(d.plain_text(StoryRef::Body).starts_with("XYalpha")); } + /// The first paragraph of the deepest cell, following the first table at each level. + fn deepest(d: &Document) -> Path { + let (mut path, mut blocks) = (Vec::new(), &d.body); + while let Some((i, t)) = blocks.iter().enumerate().find_map(|(i, b)| if let Block::Table(t) = &**b { Some((i, t)) } else { None }) { + path.extend([i as u32, 0, 0]); + blocks = &t.rows[0].cells[0].blocks; + } + path.push(0); + Path(path) + } + + fn body_height(d: &Document) -> usize { + d.body.iter().filter_map(|b| if let Block::Table(t) = &**b { Some(table_height(t)) } else { None }).max().unwrap_or(0) + } + + /// How many times `c` is in `blocks`, the deepest cells too. + fn count(blocks: &[Arc], c: char) -> usize { + blocks + .iter() + .map(|b| match &**b { + Block::Para(p) => p.text.matches(c).count(), + Block::Table(t) => t.rows.iter().flat_map(|r| &r.cells).map(|cell| count(&cell.blocks, c)).sum(), + }) + .sum() + } + + #[test] + fn tables_nest_at_most_max_table_depth() { + let mut d = doc(&["x", ""]); + for _ in 0..MAX_TABLE_DEPTH { + let at = deepest(&d); + d.insert_block(StoryRef::Body, &at, Block::Table(Table::new(1, 1, 100.0))).unwrap(); + } + assert_eq!(body_height(&d), MAX_TABLE_DEPTH); + // One more in the deepest cell is refused and changes nothing; a paragraph is fine. + let (at, before) = (deepest(&d), d.clone()); + assert_eq!(at.depth(), MAX_TABLE_DEPTH); + assert!(d.insert_block(StoryRef::Body, &at, Block::Table(Table::new(1, 1, 100.0))).is_err()); + assert_eq!(d, before); + d.insert_block(StoryRef::Body, &at, Block::Para(Paragraph::new())).unwrap(); + // One level up, a table fits but a table holding a table doesn't. + let up = Path(at.0[..at.0.len() - 3].to_vec()); + let mut two = Table::new(1, 1, 100.0); + two.rows[0].cells[0].blocks.insert(0, Arc::new(Block::Table(Table::new(1, 1, 100.0)))); + assert!(d.insert_block(StoryRef::Body, &up, Block::Table(two)).is_err()); + d.insert_block(StoryRef::Body, &up, Block::Table(Table::new(1, 1, 100.0))).unwrap(); + assert_eq!(body_height(&d), MAX_TABLE_DEPTH); + } + + #[test] + fn pasting_a_table_into_its_own_cell_stops_at_max_table_depth() { + // #1596: copy everything and paste it into the deepest cell, again and again: each round + // doubled the nesting, until saving, copying or closing the document overflowed the stack. + let mut d = doc(&["x", ""]); + d.insert_block(StoryRef::Body, &Path::top(1), Block::Table(Table::new(1, 1, 100.0))).unwrap(); + d.insert_text(&Pos { story: StoryRef::Body, path: Path(vec![1, 0, 0, 0]), off: 0 }, "a", &CharProps::default()).unwrap(); + for _ in 0..8 { + let all = d.copy_range(&Pos::body(0, 0), &Pos::body(d.body.len() - 1, 0)); + let at = deepest(&d); + let off = d.para(StoryRef::Body, &at).map_or(0, Paragraph::len); + d.insert_fragment(&Pos { story: StoryRef::Body, path: at, off }, &all).unwrap(); + } + assert_eq!(body_height(&d), MAX_TABLE_DEPTH); + // Nothing is lost: every copy's text is there, below the limit as paragraphs. + assert_eq!((count(&d.body, 'x'), count(&d.body, 'a')), (256, 256)); + } + fn text_box(d: &mut Document, pos: &Pos, text: &str) -> u32 { let id = d.add_part(PartKind::TextBox, vec![para_block(Paragraph::with_text(text, CharProps::default()))]); d.insert_object(pos, box_shape(Some(id)), &CharProps::default()).unwrap(); diff --git a/crates/doc/src/table.rs b/crates/doc/src/table.rs index 104bf96b8..b412b47e1 100644 --- a/crates/doc/src/table.rs +++ b/crates/doc/src/table.rs @@ -58,6 +58,10 @@ 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; impl Table { /// A `rows × cols` table spanning `width` points, "Table Grid" style. diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index eec8ffffa..c47ca1249 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1261,6 +1261,27 @@ 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. +#[test] +fn tables_nest_at_most_24_deep() { + 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 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_eq!(s.doc, before); + run(&mut s, "edit.paste", json!({"text": "b", "html": "
b
"})); + let cell = s.sel.focus.path.clone(); + let blocks: Vec<_> = (0..).map_while(|i| s.doc.block(StoryRef::Body, &cell.with_last(i))).collect(); + assert!(blocks.iter().all(|b| b.as_table().is_none()), "no table below the limit"); + assert!(blocks.iter().any(|b| b.as_para().is_some_and(|p| p.text.contains('b'))), "the pasted text is there"); +} + /// AutoFit Contents (#44) measures the text: a column of short words narrows, a column with a /// long sentence widens (wrapping within the page), and the widths are written to the grid. #[test] diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index baa2d3c97..80b40b91b 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # WordCraft parity with Microsoft Word -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (table border drags with Ctrl and Alt, from the left edge and of selected cells, #711; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: all 12 bibliography styles, #383; previously trivial: formatting revisions landed, #41; previously trivial: Draw Table and Eraser landed, #303; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; replaces the parity tables that lived in ROADMAP.md) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** trivial (tables nest 24 deep at most, #1596; 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 @@ -60,7 +60,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). Missing: Asian Typography, kerning/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). 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 24 deep at most, as the .docx reader and writer keep them (#1596; Word has no limit). 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). | @@ -251,6 +251,7 @@ can be used without committing Word output. | Date | Change | Summary | |---|---|---| +| 2026-10-14 | trivial | Tables: nesting stops at 24 levels, the depth .docx keeps (#1596); Tables row updated, no percentage change | | 2026-10-12 | minor | Italian proofing: it-IT spelling, accent-first suggestions and conservative grammar; Proofing row updated, 42% → 44% | | 2026-10-11 | trivial | Tables: Ctrl, Alt, left-edge and selected-cells border drags (#711); Tables row updated, no percentage change | | 2026-10-11 | trivial | Tables: column and row markers on the rulers (#700); Tables row updated, no percentage change |