Text boxes in tables nested deeper than 16 survive edits (#1339) (#1591)

The model's paragraph walk (each_para / each_para_mut) stopped 16 tables
down, while a .docx opens with tables nested up to 24 deep and pasting can
nest them further. Text box pruning, which runs after every edit, took a
box shown only from deeper for one shown nowhere and dropped its story,
so an unrelated edit lost the box's text. The same walk decides which
boxes a copy takes along and which pasted shapes get stories of their
own, and serves reading order, the next drawing id and note conversion.

The walk now keeps its own stack instead of recursing, so it reaches
every paragraph however deep tables nest, in the same document order and
with no call stack to run out of; a paragraph on its own still needs no
allocation. The signature is unchanged (the depth argument is now
unused), so every caller builds as before.

Tests: a box 17 tables down in an opened .docx survives an unrelated
edit and a save, while a box shown nowhere still goes; boxes 17 and 40
tables down, in a box and in a header are kept, counted in reading order,
and new drawing ids go above the deep shape's; a copied deep table
pastes into another document showing its own box's text.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
rabis7575
2026-10-14 19:37:59 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 663de991df
commit 4175d68be5
4 changed files with 153 additions and 20 deletions
+2
View File
@@ -309,6 +309,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
| 2026-10-11 | File › Options in Word's panes (#485): General, Display, Proofing, Save, Language, Accessibility, Advanced, Agents; initials, individual formatting marks, spelling ignore options, custom dictionary, overtype, typing replaces selection, measurement units, recent-documents count, print options; `file.options` reads and sets them headlessly |
| 2026-10-14 | Word XML Document (Flat OPC) reading is bounded everywhere in the file (#1353): nesting and elements are capped as the XML tree reader caps a part's, every child of the package and every part's data count against the part count and size limits (a repeated part name too), and data is checked before it's copied; base64 written as CDATA or with character references reads as the same bytes, and an entity in it is an error instead of bytes gone missing |
| 2026-10-11 | Right-click Wrap Text submenu on pictures, shapes and text boxes: wrapping styles, Move with Text / Fix Position on Page (#513) |
| 2026-10-14 | Text boxes in tables nested deeper than 16 survive edits (#1339): the paragraph walk behind text box pruning, copy and paste, reading order, drawing ids and note conversion goes through tables however deep they nest instead of stopping 16 tables down, so an unrelated edit no longer drops the text of a box shown only from deeper, and a copied deep table takes its boxes along |
| 2026-10-11 | Caret animation: keys and typing glide the caret to its new place in 100 ms along the curve measured from Word for Windows, and the blink fades; clicks and scripted moves don't glide. File › Options › Advanced › Animation turns it off, kept between runs; `ui.caretAnimation` for agents |
| 2026-10-11 | Paragraph dialog options (#512): mirror indents (`w:mirrorIndents`, laid out on even pages), same-style spacing, suppress line numbers, don't hyphenate |
| 2026-10-11 | Screen readers can read the document canvas (#488): paragraphs, headings, lists, tables, links, pictures and the caret exposed through AccessKit |
@@ -527,6 +528,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
| 2026-10-10 | trivial | Draw Table and Eraser (#303): catalog 393/431 (91.2%) |
| 2026-10-10 | trivial | Progress log: charts, SmartArt and OLE objects kept on DOCX save (#319) |
| 2026-10-10 | trivial | Progress log: contour wrap (#354) |
| 2026-10-14 | trivial | Progress log: text boxes in deeply nested tables kept through edits (#1339) |
| 2026-10-10 | trivial | Progress log: free rotation and flips (#332) |
| 2026-10-10 | trivial | Progress log: Windows OpenGL fallback without a DirectX 12 driver (#316) |
| 2026-10-14 | trivial | Progress log: Flat OPC reading bounded everywhere in the file, CDATA base64 read (#1353) |
+29 -20
View File
@@ -63,37 +63,46 @@ const MAX_BOX_DEPTH: usize = 8;
/// Most text box stories one paste may create (hostile documents can fan out).
const MAX_PASTED_BOXES: usize = 4096;
/// Call `f` on every paragraph of a block (tables descended, depth-limited).
pub(crate) fn each_para<'a>(b: &'a Block, depth: usize, f: &mut dyn FnMut(&'a Paragraph)) {
match b {
Block::Para(p) => f(p),
Block::Table(t) if depth < 16 => {
for cell in t.rows.iter().flat_map(|r| r.cells.iter()) {
for cb in &cell.blocks {
each_para(cb, depth + 1, f);
}
/// Call `f` on every paragraph of a block, in document order, however deep its tables nest. The
/// walk keeps its own stack instead of recursing, so it is complete: a text box it doesn't reach
/// is shown nowhere, which pruning relies on (#1339). `_depth` is unused (callers keep passing it).
pub(crate) fn each_para<'a>(b: &'a Block, _depth: usize, f: &mut dyn FnMut(&'a Paragraph)) {
// Most blocks are paragraphs, which need no stack.
if let Block::Para(p) = b {
return f(p);
}
let mut todo = vec![b];
while let Some(b) = todo.pop() {
match b {
Block::Para(p) => f(p),
Block::Table(t) => {
// Last first, so they come off the stack in document order.
let cells = t.rows.iter().rev().flat_map(|r| r.cells.iter().rev());
todo.extend(cells.flat_map(|c| c.blocks.iter().rev()).map(|cb| &**cb));
}
}
Block::Table(_) => {}
}
}
pub(crate) fn each_para_mut(b: &mut Block, depth: usize, f: &mut dyn FnMut(&mut Paragraph)) {
match b {
Block::Para(p) => f(p),
Block::Table(t) if depth < 16 => {
for cell in t.rows.iter_mut().flat_map(|r| r.cells.iter_mut()) {
for cb in &mut cell.blocks {
each_para_mut(Arc::make_mut(cb), depth + 1, f);
}
/// [`each_para`], changing the paragraphs.
pub(crate) fn each_para_mut(b: &mut Block, _depth: usize, f: &mut dyn FnMut(&mut Paragraph)) {
if let Block::Para(p) = b {
return f(p);
}
let mut todo = vec![b];
while let Some(b) = todo.pop() {
match b {
Block::Para(p) => f(p),
Block::Table(t) => {
let cells = t.rows.iter_mut().rev().flat_map(|r| r.cells.iter_mut().rev());
todo.extend(cells.flat_map(|c| c.blocks.iter_mut().rev()).map(Arc::make_mut));
}
}
Block::Table(_) => {}
}
}
/// Whether a paragraph anywhere in `b` (also inside tables) uses paragraph or character style
/// `id` (depth-limited like `each_para`). Table styles are the engine's `table.deleteStyle`'s job.
/// `id` (tables to 16 levels deep). Table styles are the engine's `table.deleteStyle`'s job.
fn uses_style(b: &Block, id: &str, depth: usize) -> bool {
let is = |s: &Option<String>| s.as_deref() == Some(id);
match b {
+78
View File
@@ -281,6 +281,84 @@ fn prune_text_boxes_drops_only_unshown_ones() {
assert_eq!(d.prune_text_boxes(), 0);
}
/// `blocks` in the only cell of `levels` tables, each nested in the next.
fn nested_tables(levels: usize, blocks: Blocks) -> Blocks {
(0..levels).fold(blocks, |inner, _| {
let mut t = Table::new(1, 1, 100.0);
t.rows[0].cells[0].blocks = inner;
vec![Arc::new(Block::Table(t))]
})
}
/// A paragraph showing text box story `story` (drawing id `id`).
fn box_para(story: u32, id: u32) -> Blocks {
let shape = InlineObject::Shape {
kind: para::ShapeKind::TextBox,
w: 50.0,
h: 20.0,
fill: None,
stroke: None,
stroke_width: 0.0,
float: Default::default(),
story: Some(story),
freeform: None,
effects: Default::default(),
extra: connector::ShapeExtra { id, ..Default::default() },
};
let mut p = Paragraph::with_text("cell", CharProps::default());
p.insert_object(0, shape, &CharProps::default()).unwrap();
vec![para_block(p)]
}
#[test]
fn text_boxes_shown_from_deeply_nested_tables_are_kept() {
// #1339: the paragraph walk stopped 16 tables down, so a box shown only from deeper was taken
// for unshown and the next edit pruned its story.
let para_of = |t: &str| vec![para_block(Paragraph::with_text(t, CharProps::default()))];
let mut d = Document::from_text("body");
let at_17 = d.add_part(PartKind::TextBox, para_of("17 tables down"));
let at_40 = d.add_part(PartKind::TextBox, para_of("40 tables down"));
let inner = d.add_part(PartKind::TextBox, para_of("deep in a box"));
let outer = d.add_part(PartKind::TextBox, nested_tables(18, box_para(inner, 0)));
let in_header = d.add_part(PartKind::TextBox, para_of("deep in a header"));
let hdr = d.add_part(PartKind::Header, nested_tables(20, box_para(in_header, 0)));
let unshown = d.add_part(PartKind::TextBox, para_of("shown nowhere"));
d.body.extend(nested_tables(17, box_para(at_17, 0)));
d.body.extend(nested_tables(40, box_para(at_40, 7)));
d.body.extend(box_para(outer, 0));
assert_eq!(d.prune_text_boxes(), 1);
assert!(!d.parts.contains_key(&unshown));
let kept: Vec<u32> = d.parts.keys().copied().collect();
assert_eq!(kept, vec![at_17, at_40, inner, outer, in_header, hdr]);
assert_eq!(d.prune_text_boxes(), 0);
// Counted in reading order, the nested box after its own; new drawing ids above the deep one.
let part = StoryRef::Part;
assert_eq!(d.counted_stories(), vec![StoryRef::Body, part(at_17), part(at_40), part(outer), part(inner)]);
assert_eq!(d.next_shape_id(), 8);
}
#[test]
fn a_deeply_nested_table_is_copied_with_its_text_boxes() {
// A copy takes the box 17 tables down along, so pasting it into another document shows its
// text, not the other document's box that happens to have the same id.
let para_of = |t: &str| vec![para_block(Paragraph::with_text(t, CharProps::default()))];
let mut d = Document::from_text("body");
let deep = d.add_part(PartKind::TextBox, para_of("deep box"));
d.body.extend(nested_tables(17, box_para(deep, 0)));
let frag = d.body_fragment();
assert_eq!(frag.parts.keys().copied().collect::<Vec<_>>(), vec![deep]);
let mut e = Document::new();
let own = e.add_part(PartKind::TextBox, para_of("its own box"));
assert_eq!(own, deep, "the same id in both documents");
e.body = box_para(own, 0);
let end = e.end_of(StoryRef::Body);
e.insert_fragment(&end, &frag).unwrap();
let mut shown = Vec::new();
e.objects_in_reading_order(&mut |o| shown.extend(o.text_box()));
let texts: Vec<String> = shown.iter().map(|id| e.plain_text(StoryRef::Part(*id))).collect();
assert_eq!(texts, vec!["its own box", "deep box"]);
}
#[test]
fn notes_in_text_box_follow_nested_boxes_once() {
let shape = |story| InlineObject::Shape {
+44
View File
@@ -2766,6 +2766,50 @@ fn deleted_text_box_takes_its_text_with_it_until_undo() {
assert_eq!(s.doc.word_count_including_notes(), 2);
}
#[test]
fn text_box_in_a_table_nested_seventeen_deep_survives_an_edit_and_a_save() {
// #1339: opened from a .docx, a box shown only from a table nested deeper than 16 was taken
// for unshown, so the next unrelated edit pruned its story and saving dropped its text.
use wordcraft_doc::{Block, Document, InlineObject, Paragraph, PartKind, Path, Table, para_block};
let mut d = Document::from_text("Intro");
let id = d.add_part(PartKind::TextBox, vec![para_block(Paragraph::with_text("Deep box", Default::default()))]);
let shape = InlineObject::Shape {
kind: wordcraft_doc::para::ShapeKind::TextBox,
w: 72.0,
h: 36.0,
fill: None,
stroke: None,
stroke_width: 0.75,
float: Default::default(),
story: Some(id),
freeform: None,
effects: Default::default(),
extra: Default::default(),
};
let mut p = Paragraph::with_text("Cell", Default::default());
p.insert_object(0, shape, &Default::default()).unwrap();
d.body.extend((0..17).fold(vec![para_block(p)], |inner, _| {
let mut t = Table::new(1, 1, 300.0);
t.rows[0].cells[0].blocks = inner;
vec![std::sync::Arc::new(Block::Table(t))]
}));
// The innermost cell's paragraph: the table after "Intro", then row, cell and block 0 down.
let path = Path([vec![1], vec![0; 17 * 3]].concat());
let deep_box = |doc: &Document| {
let id = doc.para(StoryRef::Body, &path)?.objects.iter().find_map(InlineObject::text_box)?;
doc.parts.get(&id).filter(|p| p.kind == PartKind::TextBox).map(|_| doc.plain_text(StoryRef::Part(id)))
};
let mut s = Session::new(wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap());
assert_eq!(deep_box(&s.doc).as_deref(), Some("Deep box"), "opened");
let unshown = s.doc.add_part(PartKind::TextBox, vec![para_block(Paragraph::with_text("Shown nowhere", Default::default()))]);
run(&mut s, "text.insert", json!({"text": "Edited. "}));
assert_eq!(deep_box(&s.doc).as_deref(), Some("Deep box"), "kept through the edit");
assert!(!s.doc.parts.contains_key(&unshown), "a box shown nowhere still goes");
let back = wordcraft_docx::read(&wordcraft_docx::write(&s.doc).unwrap()).unwrap();
assert_eq!(deep_box(&back).as_deref(), Some("Deep box"), "saved");
assert!(back.plain_text(StoryRef::Body).starts_with("Edited. Intro"));
}
#[test]
fn word_count_with_an_object_selected_counts_the_document() {
let mut s = s();