Browse / github.com/storytold/designcraft / patch
Tables: Keep All Lines Together keeps a table whole, with its kept heading
Keep Options: a table with Keep All Lines Together splits, and leaves its kept heading behind A table whose anchor paragraph has Keep All Lines Together still splits across columns when it starts near the bottom, and a heading set to Keep with Next stays at the bottom of the previous column. InDesign moves the table (and the kept heading) to the next column when the whole table fits there.
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- alichaudhry55 (imported from pull request #1774, open)
- Base
4be45a713af087057273b5dccee35c444fcd23bb- Size
- +267 −5 in 4 files
- Review
- pending
- Shared
- 2026-10-28
- Id
aecd8dbe1c0eff4cba7227f3
How it went
No other operator has reported using it yet.
Diff
raw · ROADMAP.md crates/compose/src/lib.rs crates/compose/src/table.rs crates/compose/src/tests.rs
@@ -359,6 +359,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. - **2026-10-24** · Anchored text frames, such as marginal notes, compose where their host story places them: they align to the baseline grid at that position, and page-number markers and spine-relative alignment inside them read the host's page. They composed against a grid measured from their stored position and on no page, so a note set to Only Align First Line to Grid could sit a fraction of a line off the body text's grid line. No readiness percentage change.+- **2026-10-24** · Keep Options for tables: a table whose anchor paragraph has Keep All Lines Together moves to the next column whole when it fits an empty column, and a heading kept with it (Keep with Next) goes with it. The table split across columns and left the heading behind. A table taller than a column still splits. No readiness percentage change. - **2026-10-24** · New documents start with Hyphenate Capitalized Words, Hyphenate Last Word and Hyphenate Across Column off in [Basic Paragraph], as in InDesign. Opened and imported files keep the values they carry. No readiness percentage change. - **2026-10-24** · The baseline grid's Relative To setting applies: with Top Margin, the grid starts at each page's own top margin in text composition, on the canvas and for snapping. It started at the page top, so grid-aligned text sat on the wrong lines and a page could fit one line fewer. No readiness percentage change. - **2026-10-24** · Custom-positioned anchored objects honour Relative to Spine (`anchored.options {spineRelative}`, IDML `SpineRelative`): the settings describe a left-hand page and mirror on a right-hand page, so marginal notes stay in the outside margin on both pages of a spread. On right-hand pages they took the left-hand placement and landed in the inside margin, across the spine or over the body text. No readiness percentage change.@@ -893,6 +893,8 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C let mut snaps: Vec<Snapshot> = Vec::with_capacity(np); let mut info: Vec<ParaInfo> = Vec::with_capacity(np); let mut force_col = vec![false; np];+ // Where a forced paragraph must start, when a kept table it leads moved further than one column.+ let mut force_to: Vec<Option<(usize, usize)>> = vec![None; np]; let mut line_cap: Vec<Option<usize>> = vec![None; np]; // Hyphenate Across Column off: the hyphenation points (story bytes) a paragraph is re-laid // without, because a line ending at one of them ended a column or frame. Each re-lay leaves@@ -939,7 +941,19 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C && let Some(j) = trial_failed(&mut trial, &mut limits, balance_runs < balance_budget) { balance_runs += 1;- rewind(j, &snaps, ¬e_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap, &mut no_hyph);+ rewind(+ j,+ &snaps,+ ¬e_snaps,+ &mut notes,+ &mut out,+ &mut cur,+ &mut list_counter,+ &mut force_col,+ &mut line_cap,+ &mut no_hyph,+ &mut force_to,+ ); pi = j; continue 'paras; }@@ -949,7 +963,19 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C && let Some(j) = balance_end(&mut trial, &mut limits, &cur, &cols, frames, &out, np, balance_runs < balance_budget) { balance_runs += 1;- rewind(j, &snaps, ¬e_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap, &mut no_hyph);+ rewind(+ j,+ &snaps,+ ¬e_snaps,+ &mut notes,+ &mut out,+ &mut cur,+ &mut list_counter,+ &mut force_col,+ &mut line_cap,+ &mut no_hyph,+ &mut force_to,+ ); pi = j; continue 'paras; }@@ -1003,13 +1029,59 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C if !table::place_table(doc, t, anchor, pi, &pp, frames, &cols, &mut cur, &mut out, opts) { if let Some(j) = trial_failed(&mut trial, &mut limits, balance_runs < balance_budget) { balance_runs += 1;- rewind(j, &snaps, ¬e_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap, &mut no_hyph);+ rewind(+ j,+ &snaps,+ ¬e_snaps,+ &mut notes,+ &mut out,+ &mut cur,+ &mut list_counter,+ &mut force_col,+ &mut line_cap,+ &mut no_hyph,+ &mut force_to,+ ); pi = j; continue 'paras; } out.overset_at = Some(prange.start); break 'paras; }+ // Table anchors must honour the preceding keep-with-next chain too.+ if (cur.fi, cur.col) != start_at && pp.keep_all_lines {+ let mut j = pi;+ while j > 0 && info.get(j - 1).is_some_and(|p| p.keep_with_next > 0 && p.end == start_at) {+ j -= 1;+ }+ let dest = (cur.fi, cur.col);+ // Once replayed at the table's column, the chain goes on again when the table, set+ // below it, moved on whole to a later column: the column held the table alone.+ let whole = out.frames.get(cur.fi).and_then(|f| f.tables.last()).is_some_and(|t| t.first && t.last);+ let again = whole && force_to.get(j).copied().flatten().is_some_and(|d| dest > d);+ let first = info.get(j).is_some_and(|p| !p.at_top) && !force_col.get(j).copied().unwrap_or(true);+ if j < pi && (first || again) && restores < 4 * np + 64 {+ restores += 1;+ force_col[j] = true;+ rewind(+ j,+ &snaps,+ ¬e_snaps,+ &mut notes,+ &mut out,+ &mut cur,+ &mut list_counter,+ &mut force_col,+ &mut line_cap,+ &mut no_hyph,+ &mut force_to,+ );+ // Replay the chain where the table went, not just one column on.+ force_to[j] = Some(dest);+ pi = j;+ continue 'paras;+ }+ } info.push(ParaInfo { start: start_at, at_top,@@ -1348,6 +1420,11 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C if force_col[pi] { next_column(&mut cur); }+ if let Some(dest) = force_to.get(pi).copied().flatten() {+ while cur.fi < frames.len() && (cur.fi, cur.col) < dest {+ next_column(&mut cur);+ }+ } if cur.last_baseline.is_some() { go(&mut cur, pp.start_paragraph); } else {@@ -1394,6 +1471,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C &mut force_col, &mut line_cap, &mut no_hyph,+ &mut force_to, ); pi = key.1; continue 'paras;@@ -1449,7 +1527,19 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C if cur.fi >= frames.len() { if let Some(j) = trial_failed(&mut trial, &mut limits, balance_runs < balance_budget) { balance_runs += 1;- rewind(j, &snaps, ¬e_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap, &mut no_hyph);+ rewind(+ j,+ &snaps,+ ¬e_snaps,+ &mut notes,+ &mut out,+ &mut cur,+ &mut list_counter,+ &mut force_col,+ &mut line_cap,+ &mut no_hyph,+ &mut force_to,+ ); pi = j; continue 'paras; }@@ -1684,7 +1774,19 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C { hyph_relays += 1; no_hyph[pi].push(at);- rewind(pi, &snaps, ¬e_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap, &mut no_hyph);+ rewind(+ pi,+ &snaps,+ ¬e_snaps,+ &mut notes,+ &mut out,+ &mut cur,+ &mut list_counter,+ &mut force_col,+ &mut line_cap,+ &mut no_hyph,+ &mut force_to,+ ); continue 'paras; } if !fits {@@ -1719,6 +1821,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C &mut force_col, &mut line_cap, &mut no_hyph,+ &mut force_to, ); pi = j; continue 'paras;@@ -1763,6 +1866,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C &mut force_col, &mut line_cap, &mut no_hyph,+ &mut force_to, ); pi = j; continue 'paras;@@ -1886,6 +1990,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C &mut force_col, &mut line_cap, &mut no_hyph,+ &mut force_to, ); pi = j; continue 'paras;@@ -2058,6 +2163,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C &mut force_col, &mut line_cap, &mut no_hyph,+ &mut force_to, ); pi = key.2; continue 'paras;@@ -2424,8 +2530,10 @@ fn rewind( force_col: &mut [bool], line_cap: &mut [Option<usize>], no_hyph: &mut [Vec<usize>],+ force_to: &mut [Option<(usize, usize)>], ) { force_col.iter_mut().skip(j + 1).for_each(|f| *f = false);+ force_to.iter_mut().skip(j + 1).for_each(|f| *f = None); line_cap.iter_mut().skip(j + 1).for_each(|c| *c = None); no_hyph.iter_mut().skip(j + 1).for_each(Vec::clear); if let Some(s) = snaps.get(j) {@@ -281,6 +281,31 @@ pub(crate) fn place_table( Some(b) => b + cur.last_descent + cur.pending + o.space_before, None => col.y0, };+ // A keep-all anchor makes a table atomic: it moves to the first following column (in any+ // frame) that holds it whole, at the widths composed for this column (a narrower column+ // would need the table recomposed). When none does, it splits from here, as other tables do.+ let whole_height: f64 = heights.iter().sum();+ if pp.keep_all_lines && y + whole_height > col.y1 + 0.01 {+ let here = cur.clone();+ let mut found = None;+ while found.is_none() {+ cur.next_column(cols);+ if cur.fi >= frames.len() {+ break;+ }+ let c = col_rect(cur);+ if c.y0 + whole_height <= c.y1 + 0.01 && pp.left_indent + total_w + pp.right_indent <= c.width() + 0.01 {+ found = Some(c);+ }+ }+ match found {+ Some(c) => {+ col = c;+ y = c.y0;+ }+ None => *cur = here,+ }+ } let mut frag_rows: Vec<(usize, f64, bool)> = Vec::new(); // (row, y, repeated) let mut first_frag = true; let mut body_placed = 0usize;@@ -7746,3 +7746,131 @@ fn anchored_text_frames_compose_against_the_grid_where_they_are_placed() { Arc::make_mut(&mut d.spreads[1]).pages[1].side = designcraft_doc::PageSide::Left; assert!(!Arc::ptr_eq(&before, &cache.get(&d, nsid, None))); }++#[test]+fn kept_table_skips_a_column_too_narrow_for_it() {+ let mut d = Document::new(&NewDocument::default());+ let lid = d.default_layer();+ let filler = (0..8).map(|k| format!("Filler {k}")).collect::<Vec<_>>().join("\n");+ let (first, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(0.0, 0.0, 400.0, 130.0), lid, &filler, ParaFormat::default()).unwrap();+ let (narrow, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(0.0, 160.0, 150.0, 460.0), lid, "", ParaFormat::default()).unwrap();+ let (wide, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(0.0, 480.0, 400.0, 780.0), lid, "", ParaFormat::default()).unwrap();+ d.thread(first, narrow).unwrap();+ d.thread(narrow, wide).unwrap();+ let st = d.story_mut(sid).unwrap();+ let end = st.len();+ let mut table = designcraft_doc::Table::new(77, 4, 2, 0, 0, 300.0);+ for row in &mut table.rows {+ row.height = 24.0;+ }+ st.insert_table(end, table);+ let i = st.paras.iter().position(|p| p.table.is_some()).unwrap();+ st.paras[i].para.keep_all_lines = Some(true);+ let cs = compose_story(&d, sid, &ComposeOptions::default());+ let frames: Vec<_> = cs.frames.iter().filter(|f| !f.tables.is_empty()).map(|f| f.frame).collect();+ assert_eq!(frames, vec![wide], "a kept table moves only to a column wide enough for it");+}++#[test]+fn kept_heading_and_table_skip_a_column_that_holds_only_the_table() {+ let mut d = Document::new(&NewDocument::default());+ let lid = d.default_layer();+ let text = [(0..6).map(|k| format!("Filler {k}")).collect::<Vec<_>>().join("\n"), "Synthetic heading".into()].join("\n");+ let (first, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(0.0, 0.0, 400.0, 130.0), lid, &text, ParaFormat::default()).unwrap();+ let (short, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(0.0, 160.0, 400.0, 210.0), lid, "", ParaFormat::default()).unwrap();+ // Holds the 96 pt table alone, not with the heading above it.+ let (snug, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(0.0, 240.0, 400.0, 340.0), lid, "", ParaFormat::default()).unwrap();+ let (tall, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(0.0, 370.0, 400.0, 670.0), lid, "", ParaFormat::default()).unwrap();+ d.thread(first, short).unwrap();+ d.thread(short, snug).unwrap();+ d.thread(snug, tall).unwrap();+ let st = d.story_mut(sid).unwrap();+ let end = st.len();+ let mut table = designcraft_doc::Table::new(77, 4, 2, 0, 0, 200.0);+ for row in &mut table.rows {+ row.height = 24.0;+ }+ st.insert_table(end, table);+ let i = st.paras.iter().position(|p| p.table.is_some()).unwrap();+ st.paras[i].para.keep_all_lines = Some(true);+ st.paras[i - 1].para.keep_with_next = Some(1);+ let cs = compose_story(&d, sid, &ComposeOptions::default());+ let table_frames: Vec<_> = cs.frames.iter().filter(|f| !f.tables.is_empty()).map(|f| f.frame).collect();+ let heading_frames: Vec<_> = cs.frames.iter().filter(|f| f.lines.iter().any(|l| l.para == i - 1)).map(|f| f.frame).collect();+ assert_eq!(table_frames, vec![tall]);+ assert_eq!(heading_frames, vec![tall], "heading and table go on together to the first column that holds both");+}++#[test]+fn kept_heading_follows_its_table_past_a_short_frame() {+ let mut d = Document::new(&NewDocument::default());+ let lid = d.default_layer();+ let text = [(0..6).map(|k| format!("Filler {k}")).collect::<Vec<_>>().join("\n"), "Synthetic heading".into()].join("\n");+ let (first, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(0.0, 0.0, 400.0, 130.0), lid, &text, ParaFormat::default()).unwrap();+ let (short, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(0.0, 160.0, 400.0, 210.0), lid, "", ParaFormat::default()).unwrap();+ let (tall, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(0.0, 240.0, 400.0, 540.0), lid, "", ParaFormat::default()).unwrap();+ d.thread(first, short).unwrap();+ d.thread(short, tall).unwrap();+ let st = d.story_mut(sid).unwrap();+ let end = st.len();+ let mut table = designcraft_doc::Table::new(77, 4, 2, 0, 0, 200.0);+ for row in &mut table.rows {+ row.height = 24.0;+ }+ st.insert_table(end, table);+ let i = st.paras.iter().position(|p| p.table.is_some()).unwrap();+ st.paras[i].para.keep_all_lines = Some(true);+ st.paras[i - 1].para.keep_with_next = Some(1);+ let cs = compose_story(&d, sid, &ComposeOptions::default());+ let table_frames: Vec<_> = cs.frames.iter().filter(|f| !f.tables.is_empty()).map(|f| f.frame).collect();+ let heading_frames: Vec<_> = cs.frames.iter().filter(|f| f.lines.iter().any(|l| l.para == i - 1)).map(|f| f.frame).collect();+ assert_eq!(table_frames, vec![tall]);+ assert_eq!(heading_frames, vec![tall], "the kept heading goes where its table went");+ let _ = short;+}++#[test]+fn kept_table_skips_a_frame_too_short_for_it() {+ let mut d = Document::new(&NewDocument::default());+ let lid = d.default_layer();+ let filler = (0..8).map(|k| format!("Filler {k}")).collect::<Vec<_>>().join("\n");+ let (first, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(0.0, 0.0, 400.0, 130.0), lid, &filler, ParaFormat::default()).unwrap();+ let (short, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(0.0, 160.0, 400.0, 210.0), lid, "", ParaFormat::default()).unwrap();+ let (tall, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(0.0, 240.0, 400.0, 540.0), lid, "", ParaFormat::default()).unwrap();+ d.thread(first, short).unwrap();+ d.thread(short, tall).unwrap();+ let st = d.story_mut(sid).unwrap();+ let end = st.len();+ let mut table = designcraft_doc::Table::new(77, 4, 2, 0, 0, 200.0);+ for row in &mut table.rows {+ row.height = 24.0;+ }+ st.insert_table(end, table);+ let i = st.paras.iter().position(|p| p.table.is_some()).unwrap();+ st.paras[i].para.keep_all_lines = Some(true);+ let cs = compose_story(&d, sid, &ComposeOptions::default());+ let frames: Vec<_> = cs.frames.iter().filter(|f| !f.tables.is_empty()).map(|f| f.frame).collect();+ assert_eq!(frames, vec![tall], "the whole table goes to the first frame that holds it");+ assert!(cs.frames.iter().flat_map(|f| &f.tables).all(|t| t.first && t.last));+}++#[test]+fn kept_table_moves_whole_with_its_heading() {+ let (mut d, sid, _) = keep_doc(6, &["Synthetic heading"], 160.0);+ let st = d.story_mut(sid).unwrap();+ let anchor = st.text.len();+ let mut table = designcraft_doc::Table::new(77, 4, 2, 0, 0, 100.0);+ for row in &mut table.rows {+ row.height = 24.0;+ }+ st.insert_table(anchor, table);+ let i = st.paras.iter().position(|p| p.table.is_some()).unwrap();+ st.paras[i].para.keep_all_lines = Some(true);+ st.paras[i - 1].para.keep_with_next = Some(1);+ let cs = compose_story(&d, sid, &ComposeOptions::default());+ let fragments: Vec<_> = cs.frames.iter().flat_map(|f| &f.tables).collect();+ assert_eq!(fragments.len(), 1);+ assert!(fragments[0].first && fragments[0].last);+ let cols = column_of_para(&cs, i - 1);+ assert_eq!(cols, vec![fragments[0].column]);+}