From a619a4a9d5daf10590b19426c781ceaba4fe7db3 Mon Sep 17 00:00:00 2001 From: Dietmar Rietsch Date: Thu, 18 Jun 2026 09:57:25 +0200 Subject: [PATCH] fix(canvas): seed an empty paragraph when text is poured into a fresh cell MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A table cell created by `insertTable` carries ZERO paragraphs. The first text pour into it (the paged.sheet cell pour) located the end of an empty stream — `locate` pins an empty `paragraphs` to `paragraph_idx 0` via `saturating_sub(1)` — and `insert_one_segment` then indexed `paragraphs[0]` on an empty slice, panicking with `index out of bounds: the len is 0 but the index is 0` (mutate.rs:264). The panic poisoned the wasm, so every later call failed with the wasm-bindgen "recursive use of an object" guard — a wall of errors that masked the real cause. `apply_insert_text` now seeds one empty `Paragraph` when the resolved cell (or any) paragraph stream is empty, so the first write has a stream to land in. Tests: a new cell_text regression (insertTable → insertText into the fresh empty cell does not panic) + a tablecell_wire pin of the cell-pour op shapes. Co-Authored-By: Dietmar Rietsch Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/paged-canvas/src/mutate.rs | 10 ++++++ crates/paged-canvas/tests/cell_text.rs | 39 +++++++++++++++++++++ crates/paged-canvas/tests/tablecell_wire.rs | 38 ++++++++++++++++++++ 3 files changed, 87 insertions(+) create mode 100644 crates/paged-canvas/tests/tablecell_wire.rs diff --git a/crates/paged-canvas/src/mutate.rs b/crates/paged-canvas/src/mutate.rs index 229a9d1..2aa08be 100644 --- a/crates/paged-canvas/src/mutate.rs +++ b/crates/paged-canvas/src/mutate.rs @@ -193,6 +193,16 @@ fn apply_insert_text( }); } + // A freshly-created table cell (and any other empty paragraph stream) + // carries ZERO paragraphs. `locate` pins an empty stream to + // `paragraph_idx 0` (via `saturating_sub(1)`), and `insert_one_segment` + // would then index an empty slice — the `index out of bounds: len is 0 + // but the index is 0` panic the sheet cell-pour hit. Seed one empty + // paragraph so the first write into a new cell has a stream to land in. + if paragraphs.is_empty() { + paragraphs.push(paged_parse::Paragraph::default()); + } + // Phase 3 Gap-D: split text on `\n`. Each segment becomes a // contiguous insert within a (possibly new) paragraph. Multiple // `\n`s in the source split into multiple paragraphs. diff --git a/crates/paged-canvas/tests/cell_text.rs b/crates/paged-canvas/tests/cell_text.rs index 82cabdf..e99148b 100644 --- a/crates/paged-canvas/tests/cell_text.rs +++ b/crates/paged-canvas/tests/cell_text.rs @@ -244,6 +244,45 @@ fn insert_text_into_cell_then_read_back() { } } +#[test] +fn pour_text_into_a_freshly_inserted_empty_table_cell_does_not_panic() { + // Regression (the sheet cell-pour panic): a cell created by `insertTable` + // carries ZERO paragraphs. The first text pour into it located the end of + // an empty stream and indexed `paragraphs[0]` on an empty slice — + // `index out of bounds: the len is 0 but the index is 0` in + // `insert_one_segment`. `apply_insert_text` now seeds one empty paragraph + // when the cell stream is empty, so the first write lands cleanly. + let mut model = load_model(); + let out = model + .apply_mutation(&Mutation::InsertTable { + story_id: "u10".into(), + rows: 2, + cols: 2, + header_rows: 0, + footer_rows: 0, + column_widths: vec![], + row_heights: vec![], + }) + .expect("insert a 2x2 table into the story"); + let table_id = match out.created_id { + Some(paged_canvas::ElementId::Table { table_id, .. }) => table_id, + other => panic!("insertTable must report a Table createdId, got {other:?}"), + }; + // The brand-new cell (0,0) has no paragraphs; pouring text MUST NOT panic. + model + .apply_mutation(&Mutation::InsertText { + story_id: "u10".into(), + offset: 0, + text: "Hi".into(), + cell: Some(TextCellAddr { + table_id, + row: 0, + col: 0, + }), + }) + .expect("pour text into the fresh empty cell"); +} + #[test] fn insert_into_cell_undo_restores_exact_prior_content() { let mut model = load_model(); diff --git a/crates/paged-canvas/tests/tablecell_wire.rs b/crates/paged-canvas/tests/tablecell_wire.rs new file mode 100644 index 0000000..4171dbb --- /dev/null +++ b/crates/paged-canvas/tests/tablecell_wire.rs @@ -0,0 +1,38 @@ +// Wire pin: the EXACT op shapes paged.sheet's lowering emits for a +// native-table cell pour must deserialize as the wire `Mutation`. (A bundle +// bug once nested the whole Table id as `table_id`, which the engine rejected +// with "invalid type: map, expected a string" — this pins the correct shapes +// so a regression fails loudly here.) + +use paged_canvas::Mutation; + +fn try_de(label: &str, json: &str) { + match serde_json::from_str::(json) { + Ok(_) => println!("OK {label}"), + Err(e) => println!("FAIL {label}: {e}"), + } +} + +#[test] +fn sheet_cell_pour_ops_deserialize() { + // 1. cell fill — setElementProperty on a tableCell, colorRef string value. + try_de( + "cellFillColor", + r#"{"op":"setElementProperty","args":{"elementId":{"kind":"tableCell","id":{"story_id":"u1","table_id":"t1","row":0,"col":0}},"path":"cellFillColor","value":{"type":"colorRef","value":"Color/Black"}}}"#, + ); + // 2. edge stroke — setElementProperty on a tableCell, length value. + try_de( + "cellTopEdgeStrokeWeight", + r#"{"op":"setElementProperty","args":{"elementId":{"kind":"tableCell","id":{"story_id":"u1","table_id":"t1","row":0,"col":0}},"path":"cellTopEdgeStrokeWeight","value":{"type":"length","value":0.5}}}"#, + ); + // 3. text pour — insertText with the cell qualifier (TextCellAddr). + try_de( + "insertText.cell", + r#"{"op":"insertText","args":{"storyId":"u1","offset":0,"text":"hi","cell":{"tableId":"t1","row":0,"col":0}}}"#, + ); + // 4. span — setCellSpan. + try_de( + "setCellSpan", + r#"{"op":"setCellSpan","args":{"storyId":"u1","tableId":"t1","row":0,"col":0,"rowSpan":2,"columnSpan":1}}"#, + ); +}