mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-25 18:56:50 -06:00
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:
+2
-1
@@ -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
@@ -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, ©);
|
||||
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, ©);
|
||||
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
@@ -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
@@ -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");
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user