fix(tables): keep each table anchor in its own paragraph

Backspace at the start of the second of two consecutive tables merged their
anchor paragraphs: Story::delete kept one table id, the other table was
dropped and the paragraph held two anchors. Pasting a table next to an anchor
replaced the existing table the same way, and pasting several paragraphs
right before an anchor left its table unreferenced. Saving succeeded;
reopening failed with "paragraph must hold exactly one anchor".

fix_tables now splits a table paragraph that holds further anchors, so every
anchor gets its own paragraph again. delete and insert_story tell it which
table each surviving anchor belongs to (fix_tables_with), and insert_story
fixes tables up once after the whole paste instead of after every run.

Regression tests in doc (Backspace between tables, deleting the middle of
three, paste before/after an anchor, paragraphs pasted before an anchor) and
engine (text.delete and edit.paste through commands, then a file round-trip);
all failed before the fix.

Claude-Session: https://claude.ai/code/session_01La6SDgsmdZfgfDDmx3wiGU
This commit is contained in:
Noah Scholz
2026-10-13 08:17:51 +00:00
parent 64f0ca8fa6
commit 65bb4e3f66
5 changed files with 168 additions and 27 deletions
+2 -1
View File
@@ -2,7 +2,7 @@
**Stage: pre-alpha** · next: alpha, ~0% and ~15–25 h away; beta ~28 points and ~420–750 h beyond that (the core-workflow gate, not the percentage: ready for real work is already ~47%)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (cancelable macOS Dock Quit) · **Target:** Adobe InDesign 2026
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (table anchors keep their own paragraphs) · **Target:** Adobe InDesign 2026
DesignCraft aims at full Adobe InDesign parity, and to be better: faster, open (documented JSON
format + IDML), scriptable by agents (MCP), and available on the web.
@@ -129,6 +129,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m
Newest first.
- **2026-10-13** · Backspace between two consecutive tables, pasting a table next to a table anchor, and pasting several paragraphs right before one keep every table on its own anchor paragraph. Previously a table was dropped or left without its anchor, and the saved file failed to reopen. No readiness percentage change.
- **2026-10-12** · macOS Dock Quit uses the same unsaved-document prompts as window and menu Quit. Repeated requests keep the current answers; Cancel retains every tab and unsaved recovery checkpoint, and completed Save/Don't Save answers exit through the ordinary event loop. No readiness percentage change.
- **2026-10-12** · Orderly native exit writes the latest recovery state after finalizing current quit approvals; incomplete or stale plans keep their documents, and Undo to the saved state clears stale recovery. Unavailable or failed recovery is reported. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change.
- **2026-10-12** · Recovered documents keep a checkpoint until saved, closed or reverted, including a second crash before autosave. Restarted document IDs and explicit recovery folders preserve other snapshots; failed republishing retains the original. No readiness percentage change.
+44 -11
View File
@@ -57,18 +57,25 @@ impl Story {
if src.text.is_empty() {
return pos;
}
// Text first (creates empty marker entries), then formats and marker payloads.
// The table anchored in the paragraph at `pos` keeps its anchor wherever the paste moves it.
let p0 = self.para_at(pos);
let own = self.paras.get(p0).and_then(|p| p.table).and_then(|id| {
let r = self.para_ranges().get(p0)?.clone();
Some((r.start + self.text.get(r)?.find(TABLE_ANCHOR)?, id))
});
// Text first (creates empty marker entries), then formats and marker payloads; tables are
// fixed up once at the end.
let mut at = pos;
for (r, f) in src.runs() {
if r.is_empty() {
continue;
}
self.insert_with(at, &src.text[r.clone()], f.clone());
self.insert_with_raw(at, &src.text[r.clone()], f.clone());
at += r.len();
}
let end = at;
let mut claims: Vec<(usize, u64)> = own.map(|(a, id)| (if a >= pos { a + (end - pos) } else { a }, id)).into_iter().collect();
// Paragraph formats of the pasted paragraphs after the first.
let p0 = self.para_at(pos);
for (k, pf) in src.paras.iter().enumerate().skip(1) {
if let Some(p) = self.paras.get_mut(p0 + k) {
*p = crate::story::ParaFormat { table: None, ..pf.clone() };
@@ -106,25 +113,25 @@ impl Story {
*slot = Arc::new(crate::xref::TextAnchor { id, name: a.name.clone() });
}
}
// Tables: re-attach by paragraph (fresh ids so a pasted copy never shares an id).
// Tables: re-attach at their anchors (fresh ids so a pasted copy never shares an id).
if !src.tables.is_empty() {
let mut next = self.tables.keys().max().copied().unwrap_or(0) + 1;
for (k, pf) in src.paras.iter().enumerate() {
let ranges = src.para_ranges();
for (pf, r) in src.paras.iter().zip(ranges) {
let Some(tid) = pf.table else { continue };
let Some(t) = src.tables.get(&tid) else { continue };
let pi = p0 + k;
let Some(k) = src.text.get(r.clone()).and_then(|t| t.find(TABLE_ANCHOR)) else { continue };
let mut t = (**t).clone();
t.id = next;
next += 1;
claims.push((pos + r.start + k, t.id));
self.tables.insert(t.id, Arc::new(t));
if let Some(p) = self.paras.get_mut(pi) {
p.table = Some(next - 1);
}
}
self.fix_tables();
}
let splits = self.fix_tables_with(&claims);
self.rev += 1;
end
// Past any return the fix-up put inside the pasted text.
end + splits.iter().filter(|&&s| s < end).count()
}
}
@@ -133,6 +140,32 @@ mod tests {
use super::*;
use crate::story::ParaFormat;
#[test]
fn pasting_a_table_next_to_another_gives_each_its_own_paragraph() {
let mut s = Story::with_text(StoryId(1), "x", ParaFormat::default());
let a = s.insert_table(1, crate::Table::new(5, 1, 1, 0, 0, 10.0));
let n = TABLE_ANCHOR.len_utf8();
let copy = s.extract(a..a + n);
// After the anchor: the copy follows the original.
let mut after = s.clone();
let end = after.insert_story(a + n, &copy);
after.check().unwrap();
assert_eq!(after.text, format!("x\n{TABLE_ANCHOR}\n{TABLE_ANCHOR}\n"));
assert_eq!((after.table_anchor(5), after.table_anchor(6)), (Some(a), Some(a + n + 1)));
assert_eq!(end, a + 2 * n + 1);
// Before the anchor: the copy comes first and the original keeps its table.
let mut before = s.clone();
let end = before.insert_story(a, &copy);
before.check().unwrap();
assert_eq!((before.table_anchor(6), before.table_anchor(5)), (Some(a), Some(a + n + 1)));
assert_eq!(end, a + n);
// Paragraphs pasted before the anchor keep the table too.
let mut text = s.clone();
text.insert_story(a, &Story::with_text(StoryId(2), "Hello\nWorld", ParaFormat::default()));
text.check().unwrap();
assert_eq!(text.table_anchor(5), Some(a + "Hello\nWorld".len()));
}
#[test]
fn copy_paste_keeps_formatting_and_notes() {
let mut a = Story::with_text(StoryId(1), "Alpha beta\nGamma delta", ParaFormat::default());
+14 -8
View File
@@ -255,7 +255,8 @@ impl Story {
self.insert_with_raw(pos, text, fmt);
}
fn insert_with_raw(&mut self, pos: usize, text: &str, fmt: CharFormat) {
/// [`Story::insert_with`] without the table fix-up.
pub(crate) fn insert_with_raw(&mut self, pos: usize, text: &str, fmt: CharFormat) {
if text.is_empty() {
return;
}
@@ -332,20 +333,25 @@ impl Story {
self.marks_deleted(a, b);
let pi = self.para_at(a);
let removed = self.text[a..b].bytes().filter(|c| *c == b'\n').count();
// Merged paragraphs keep a table whose anchor survives the deletion.
// Merged paragraphs keep the tables whose anchors survive the deletion (the fix-up gives
// each its own paragraph again).
let mut claims = Vec::new();
if removed > 0 && !self.tables.is_empty() {
let ranges = self.para_ranges();
let first = self.paras[pi].table.filter(|_| self.text[ranges[pi].start..a].contains(TABLE_ANCHOR));
let last = self.paras[pi + removed]
.table
.filter(|_| self.text[b.min(ranges[pi + removed].end)..ranges[pi + removed].end].contains(TABLE_ANCHOR));
self.paras[pi].table = first.or(last);
let anchor = |pi: usize, r: std::ops::Range<usize>| {
self.paras[pi].table.and_then(|id| self.text[r.clone()].find(TABLE_ANCHOR).map(|k| (r.start + k, id)))
};
let first = anchor(pi, ranges[pi].start..a);
let end = ranges[pi + removed].end;
let last = anchor(pi + removed, b.min(end)..end).map(|(at, id)| (at.saturating_sub(b - a), id));
self.paras[pi].table = first.or(last).map(|(_, id)| id);
claims.extend(first.into_iter().chain(last));
}
self.paras.drain(pi + 1..pi + 1 + removed);
self.text.replace_range(a..b, "");
self.remove_run_bytes(a, b - a);
self.normalize();
self.fix_tables();
self.fix_tables_with(&claims);
self.rev += 1;
}
+71 -7
View File
@@ -1012,21 +1012,61 @@ impl Story {
/// Keep paragraphs and tables consistent after an edit: a paragraph keeps its table only if it
/// still contains an anchor character, every table is referenced once, and tables nobody
/// references are dropped.
/// references are dropped. An anchor that shares a table's paragraph with another anchor gets
/// a paragraph of its own again, so a table paragraph always holds exactly one anchor.
pub(crate) fn fix_tables(&mut self) {
self.fix_tables_with(&[]);
}
/// [`Story::fix_tables`] for an edit that knows which table an anchor belongs to: `claims` are
/// `(anchor byte offset, table id)` pairs that win over the paragraphs' table ids (a paragraph
/// names one table, so merging two anchor paragraphs would otherwise lose one). Every other
/// table takes the first unclaimed anchor of its paragraph. Returns the byte offsets, before
/// the fix-up, where it inserted paragraph returns.
pub(crate) fn fix_tables_with(&mut self, claims: &[(usize, u64)]) -> Vec<usize> {
if self.tables.is_empty() && self.paras.iter().all(|p| p.table.is_none()) {
return;
return Vec::new();
}
let ranges = self.para_ranges();
let mut owner = std::collections::BTreeMap::new();
let mut used = std::collections::HashSet::new();
for (p, r) in self.paras.iter_mut().zip(ranges) {
if let Some(id) = p.table
&& (!self.text[r].contains(TABLE_ANCHOR) || !self.tables.contains_key(&id) || !used.insert(id))
for &(at, id) in claims {
if self.text.get(at..).is_some_and(|t| t.starts_with(TABLE_ANCHOR))
&& self.tables.contains_key(&id)
&& !owner.contains_key(&at)
&& used.insert(id)
{
p.table = None;
owner.insert(at, id);
}
}
let ranges = self.para_ranges();
for (p, r) in self.paras.iter().zip(&ranges) {
let Some(id) = p.table.filter(|id| self.tables.contains_key(id) && !used.contains(id)) else { continue };
let free = self.text[r.clone()].match_indices(TABLE_ANCHOR).map(|(k, _)| r.start + k).find(|at| !owner.contains_key(at));
if let Some(at) = free {
used.insert(id);
owner.insert(at, id);
}
}
// A paragraph holding a table's anchor and other anchors is split before each later anchor.
let mut splits = Vec::new();
for r in &ranges {
let anchors: Vec<usize> = self.text[r.clone()].match_indices(TABLE_ANCHOR).map(|(k, _)| r.start + k).collect();
if anchors.len() > 1 && anchors.iter().any(|at| owner.contains_key(at)) {
splits.extend(anchors.into_iter().skip(1));
}
}
for &at in splits.iter().rev() {
self.insert_raw(at, "\n");
}
// Each owned anchor, moved past the returns inserted before it, names its paragraph's table.
let owner: std::collections::BTreeMap<usize, u64> =
owner.into_iter().map(|(at, id)| (at + splits.partition_point(|&s| s <= at), id)).collect();
let ranges = self.para_ranges();
for (p, r) in self.paras.iter_mut().zip(ranges) {
p.table = owner.range(r).next().map(|(_, &id)| id);
}
self.tables.retain(|id, _| used.contains(id));
splits
}
/// Check table invariants (part of [`Story::check`]).
@@ -1097,6 +1137,30 @@ mod tests {
assert!(s.tables.is_empty());
}
#[test]
fn deleting_the_return_between_two_tables_keeps_both() {
// Backspace at the start of the second table's paragraph.
let mut s = story("x");
let a1 = s.insert_table(1, Table::new(1, 1, 1, 0, 0, 10.0));
let a2 = s.insert_table(s.len(), Table::new(2, 1, 1, 0, 0, 10.0));
let before = s.text.clone();
s.delete(a2 - 1..a2);
s.check().unwrap();
assert_eq!(s.text, before);
assert_eq!((s.table_anchor(1), s.table_anchor(2)), (Some(a1), Some(a2)));
// Deleting the table between two others keeps the outer ones on their own anchors.
let mut s = story("x");
for id in 1..=3 {
s.insert_table(s.len(), Table::new(id, 1, 1, 0, 0, 10.0));
}
let (a1, a3) = (s.table_anchor(1).unwrap(), s.table_anchor(3).unwrap());
s.delete(a1 + TABLE_ANCHOR.len_utf8()..a3);
s.check().unwrap();
assert_eq!(s.text, format!("x\n{TABLE_ANCHOR}\n{TABLE_ANCHOR}\n"));
assert_eq!(s.tables.keys().copied().collect::<Vec<_>>(), [1, 3]);
assert_eq!(s.table_anchor(3), Some(a1 + TABLE_ANCHOR.len_utf8() + 1));
}
#[test]
fn edits_around_the_anchor_keep_the_table() {
let mut s = story("ab\ncd");
+37
View File
@@ -177,6 +177,43 @@ fn file_roundtrip_keeps_tables() {
assert_eq!(st.tables.values().next().unwrap().cells[0].text.text, "A1");
}
#[test]
fn backspace_and_paste_between_tables_keep_both_and_the_file_reopens() {
let n = designcraft_doc::TABLE_ANCHOR.len_utf8();
let anchor_of = |s: &Session, sid: u64, tid: u64| s.doc().unwrap().doc.story(doc::StoryId(sid)).unwrap().table_anchor(tid).unwrap();
let reopens_with = |s: &Session, sid: u64, ids: &[u64]| {
let back = crate::cmd::file_from(&crate::cmd::file_bytes(&s.doc().unwrap().doc)).unwrap();
let st = back.story(doc::StoryId(sid)).unwrap();
assert!(ids.iter().all(|id| st.table_anchor(*id).is_some()), "{:?}", st.tables.keys());
assert_eq!(st.tables.len(), ids.len());
};
// Backspace at the start of the second of two consecutive tables.
let (mut s, sid, _) = session_with_frame();
let t1 = s.execute("table.insert", &json!({"rows": 2, "cols": 2})).unwrap()["table"].as_u64().unwrap();
let len = s.doc().unwrap().doc.story(doc::StoryId(sid)).unwrap().len();
s.execute("text.select", &json!({"story": sid, "anchor": len})).unwrap();
let t2 = s.execute("table.insert", &json!({"rows": 2, "cols": 2})).unwrap()["table"].as_u64().unwrap();
let a2 = anchor_of(&s, sid, t2);
s.execute("text.select", &json!({"story": sid, "anchor": a2})).unwrap();
s.execute("text.delete", &json!({})).unwrap();
check(&s);
reopens_with(&s, sid, &[t1, t2]);
// Pasting a copy of a table right after its anchor.
let (mut s, sid, _) = session_with_frame();
let t1 = s.execute("table.insert", &json!({"rows": 2, "cols": 2})).unwrap()["table"].as_u64().unwrap();
let a1 = anchor_of(&s, sid, t1);
s.execute("text.select", &json!({"story": sid, "anchor": a1, "focus": a1 + n})).unwrap();
s.execute("edit.copy", &json!({})).unwrap();
s.execute("text.select", &json!({"story": sid, "anchor": a1 + n})).unwrap();
let pasted = s.execute("edit.paste", &json!({})).unwrap();
check(&s);
assert_eq!(pasted["pos"], a1 + 2 * n + 1);
let st = s.doc().unwrap().doc.story(doc::StoryId(sid)).unwrap().clone();
let copy = st.tables.keys().copied().find(|id| *id != t1).unwrap();
assert_eq!((st.table_anchor(t1), st.table_anchor(copy)), (Some(a1), Some(a1 + n + 1)));
reopens_with(&s, sid, &[t1, copy]);
}
fn table_for(s: &Session, sid: u64, tid: u64) -> &doc::Table {
s.doc().unwrap().doc.story(doc::StoryId(sid)).unwrap().tables.get(&tid).unwrap()
}