mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-31 21:56:50 -06:00
Merge PR #704
This commit is contained in:
@@ -7,6 +7,7 @@
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (bounded control click/drag input) · **Target:** Adobe InDesign 2026
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (text stroke behind fill) · **Target:** Adobe InDesign 2026
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (text threads by hand; the alpha gate waits on Primary Text Frame page fixes; Links source paths) · **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.
|
||||
@@ -160,6 +161,7 @@ Newest first.
|
||||
readiness percentage change.
|
||||
- **2026-10-13** · Layout ▸ Table of Contents opens a real dialog: title and title style, Include Paragraph Styles and Other Styles lists with Add and Remove, and per style its entry style, page number position (after, before, none), separator and level. Replace Existing regenerates the current table of contents in place; otherwise OK makes a new frame on the page in view. IDML export writes the definition as a TOC style that import reads back, so Update works after a round trip. No readiness percentage change.
|
||||
- **2026-10-13** · Control-channel clicks and drags validate positive counts and the complete pending-event budget before queuing input (#732). Oversized requests leave pending gestures intact, preventing unbounded click-count allocation. No readiness percentage change.
|
||||
- **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** · Text strokes can sit behind the fill: the stroke lies wholly outside the letters at its full weight on screen, in PNG and in PDF, set from the Properties panel or Character Style Options. IDML `StrokeAlignment` on text imports and exports as this setting, so imported text with outside-aligned strokes keeps its letter shapes (#619). No readiness percentage change.
|
||||
- **2026-10-12** · Placed Word documents keep their bullets and numbering (#626): `numbering.xml` levels become bullet or numbered paragraphs with Word's number formats, label text, start numbers, restarts and list indents, also when a paragraph style carries the list. 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()
|
||||
}
|
||||
|
||||
/// Append the whole of `other` as new paragraphs after this story's text (threading two
|
||||
@@ -193,6 +200,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
@@ -1105,21 +1105,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`]).
|
||||
@@ -1219,6 +1259,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");
|
||||
|
||||
@@ -492,6 +492,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