collective

Browse / github.com/storytold/designcraft / patch

Tables: a table in a paragraph that spans columns uses the full measure

Tables: a table in a paragraph that spans columns is squeezed into one column A table anchored in a paragraph set to Span Columns (Span All, or 2 of 2) in a multi-column text frame is laid out in the first column's width only. The text after it carries on in the columns beside it instead of below it, and when the table continues it moves to the next column rather than the next frame. In InDesign the table takes the spanned measure, sits below the balanced text above it, and every column resumes below the table. Repro (synthetic): a 2-column, 400 pt wide frame; two paragraphs of text; a 3×2 table in a paragraph with Span Columns: Span All; six paragraphs after. Expected

Status
open
Score
0.00
Adopters
0 other operators
Author
alichaudhry55 (imported from pull request #1766, open)
Base
4be45a713af087057273b5dccee35c444fcd23bb
Size
+236 −132 in 4 files
Review
pending
Shared
2026-10-28
Id
a3693175fd74e84aa162bbe8

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

diff --git a/ROADMAP.md b/ROADMAP.md--- a/ROADMAP.md+++ b/ROADMAP.md@@ -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** · Tables: a table whose anchor paragraph spans columns (Span Columns) is set across the frame's full measure, below the balanced text above it; it continues in the next frame rather than the next column, and every column resumes below it. It was squeezed into one column. 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.diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs--- a/crates/compose/src/lib.rs+++ b/crates/compose/src/lib.rs@@ -990,15 +990,131 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C             let gone = props.change == designcraft_doc::ChangeMark::Deleted || doc.conditions_hide(&props.conditions);             (!gone).then_some(start)         });-        if let Some(t) = story.para_table(pi) {-            let start = after_break.unwrap_or(pp.start_paragraph);+        // A paragraph set across the columns moves on to the next frame where others move on to+        // the next column.+        let spanning = matches!(pp.span_columns, SpanColumns::Span(n) if n != 1);+        span_paras[pi] = spanning;+        // Span space applies where the paragraph spans more than one column; in a one-column frame+        // a span changes nothing.+        let spans_in = |fi: usize| spanning && cols.get(fi).is_some_and(|c| c.len() > 1);+        // Consecutive paragraphs with the same Split Columns settings share a split block; within+        // it, a paragraph moves on to the next sub-column. A table ends the block, as at its end.+        let split_cfg = if story.para_table(pi).is_some() { None } else { SplitCfg::of(&pp) };+        split_paras[pi] = split_cfg.is_some();+        if cur.split.is_some_and(|s| Some(s.cfg) != split_cfg) {+            cur.split = None;+        }+        let next_column = |cur: &mut Cursor| {+            if spanning {+                cur.next_frame()+            } else if cur.can_next_sub() {+                cur.next_sub()+            } else {+                cur.next_column(&cols)+            }+        };+        // A paragraph start or a break character; Next Column while a paragraph spans columns is+        // the next frame, as above.+        let go = |cur: &mut Cursor, start: StartParagraph| match start {+            StartParagraph::NextColumn => {+                let (fi, col) = (cur.fi, cur.col);+                next_column(cur);+                if cur.fi == fi && cur.col != col {+                    cur.band.tail = Some((cur.pi, cur.col));+                }+            }+            other => cur.start(other, &cols, frames, doc),+        };+        // Paragraph start options. At the top of a frame there is nothing to break away from, but+        // an odd or even page start still has to land on a page of that parity.+        if let Some(start) = after_break {             if cur.last_baseline.is_some() {-                cur.start(start, &cols, frames, doc);+                go(&mut cur, start);             } else {                 cur.start_at_top(start, frames, doc);             }-            let at_top = cur.last_baseline.is_none();-            let start_at = (cur.fi, cur.col);+        }+        if force_col[pi] {+            next_column(&mut cur);+        }+        if cur.last_baseline.is_some() {+            go(&mut cur, pp.start_paragraph);+        } else {+            cur.start_at_top(pp.start_paragraph, frames, doc);+        }+        // Text in the columns above a spanning paragraph, or a split block starting below text.+        let mut text_above = cur.split.is_none() && split_cfg.is_some();+        if spanning && cur.fi < frames.len() {+            // Close the band: the paragraph goes below the deepest of its columns, which are+            // balanced first.+            let key = cur.band_key();+            let deepest = out.frames[cur.fi]+                .lines+                .get(cur.band.line0..)+                .unwrap_or_default()+                .iter()+                .map(|l| (l.baseline, l.descent))+                .max_by(|a, b| (a.0 + a.1).total_cmp(&(b.0 + b.1)));+            if let Some((baseline, descent)) = deepest {+                let bottom = baseline + descent;+                let multi = cols[cur.fi].len() > 1;+                let active = trial.as_ref().is_some_and(|t| t.key == key && t.span == pi);+                if !active && let Some(t) = trial.take() {+                    set_limit(&mut limits, t.key, None);+                }+                if multi && (active || limit_of(&limits, key).is_none() && balance_runs < balance_budget) {+                    let top = cur.clip(cols[cur.fi][0]).y0;+                    let mut t = trial.take().unwrap_or(Trial { key, span: pi, lo: top, hi: bottom, tries: 0, done: false });+                    // This layout fits: its bottom is the best so far.+                    t.hi = t.hi.min(bottom);+                    let next = if t.done { t.hi } else { t.next() };+                    set_limit(&mut limits, key, Some(next));+                    if !t.done {+                        trial = Some(t);+                        balance_runs += 1;+                        rewind(+                            key.1,+                            &snaps,+                            &note_snaps,+                            &mut notes,+                            &mut out,+                            &mut cur,+                            &mut list_counter,+                            &mut force_col,+                            &mut line_cap,+                            &mut no_hyph,+                        );+                        pi = key.1;+                        continue 'paras;+                    }+                }+                cur.col = 0;+                cur.last_baseline = Some(baseline);+                cur.last_descent = descent;+                text_above = multi;+            } else if cur.col != 0 {+                cur.col = 0;+                cur.resume();+            }+        }+        if cur.last_baseline.is_some() {+            // Same style as the previous paragraph with a same-style spacing: it replaces space after + before.+            let same_style = pi.checked_sub(1).and_then(|p| story.paras.get(p)).is_some_and(|prev| prev.style == pf.style && prev.table.is_none());+            match pp.same_para_style_spacing {+                Some(gap) if same_style => cur.pending = gap,+                _ => cur.pending += if text_above { pp.space_before.max(pp.span_space_before) } else { pp.space_before },+            }+        }+        if let Some(cfg) = split_cfg+            && cur.split.is_none()+        {+            let start = cur.state();+            cur.split = Some(SplitBlock { cfg, sub: 0, sub_lines: 0, part_lines: 0, line0: None, para: pi, start, top: None });+            cur.split_part();+        }+        let at_top = cur.last_baseline.is_none();+        let start_at = (cur.fi, cur.col);+        if let Some(t) = story.para_table(pi) {             let anchor = prange.start + story.text[prange.clone()].find(story::TABLE_ANCHOR).unwrap_or(0);             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) {@@ -1024,7 +1140,26 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C             });             list_counter = ListCounter::default();             boxes[pi] = None;-            cur.pending += pp.space_after;+            cur.pending += if spans_in(cur.fi) { pp.space_after.max(pp.span_space_after) } else { pp.space_after };+            if spanning && let Some(ft) = out.frames.get(cur.fi) {+                // Every following column begins below the spanning table, including after a+                // table continued through more than one frame.+                if let Some(baseline) = cur.last_baseline {+                    cur.band = Band {+                        para: pi + 1,+                        line0: ft.lines.len(),+                        above: Some(Above {+                            top: baseline + cur.last_descent,+                            baseline,+                            descent: cur.last_descent,+                            reference: cur.last_reference,+                            aki_below: cur.aki_below,+                            pending: cur.pending,+                        }),+                        tail: None,+                    };+                }+            }             pi += 1;             continue;         }@@ -1301,130 +1436,6 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C                 designcraft_doc::Leading::Points(v) => v,             })         });-        // A paragraph set across the columns moves on to the next frame where others move on to-        // the next column.-        let spanning = matches!(pp.span_columns, SpanColumns::Span(n) if n != 1);-        span_paras[pi] = spanning;-        // Span space applies where the paragraph spans more than one column; in a one-column frame-        // a span changes nothing.-        let spans_in = |fi: usize| spanning && cols.get(fi).is_some_and(|c| c.len() > 1);-        // Consecutive paragraphs with the same Split Columns settings share a split block; within-        // it, a paragraph moves on to the next sub-column.-        let split_cfg = SplitCfg::of(&pp);-        split_paras[pi] = split_cfg.is_some();-        if cur.split.is_some_and(|s| Some(s.cfg) != split_cfg) {-            cur.split = None;-        }-        let next_column = |cur: &mut Cursor| {-            if spanning {-                cur.next_frame()-            } else if cur.can_next_sub() {-                cur.next_sub()-            } else {-                cur.next_column(&cols)-            }-        };-        // A paragraph start or a break character; Next Column while a paragraph spans columns is-        // the next frame, as above.-        let go = |cur: &mut Cursor, start: StartParagraph| match start {-            StartParagraph::NextColumn => {-                let (fi, col) = (cur.fi, cur.col);-                next_column(cur);-                if cur.fi == fi && cur.col != col {-                    cur.band.tail = Some((cur.pi, cur.col));-                }-            }-            other => cur.start(other, &cols, frames, doc),-        };-        // Paragraph start options. At the top of a frame there is nothing to break away from, but-        // an odd or even page start still has to land on a page of that parity.-        if let Some(start) = after_break {-            if cur.last_baseline.is_some() {-                go(&mut cur, start);-            } else {-                cur.start_at_top(start, frames, doc);-            }-        }-        if force_col[pi] {-            next_column(&mut cur);-        }-        if cur.last_baseline.is_some() {-            go(&mut cur, pp.start_paragraph);-        } else {-            cur.start_at_top(pp.start_paragraph, frames, doc);-        }-        // Text in the columns above a spanning paragraph, or a split block starting below text.-        let mut text_above = cur.split.is_none() && split_cfg.is_some();-        if spanning && cur.fi < frames.len() {-            // Close the band: the paragraph goes below the deepest of its columns, which are-            // balanced first.-            let key = cur.band_key();-            let deepest = out.frames[cur.fi]-                .lines-                .get(cur.band.line0..)-                .unwrap_or_default()-                .iter()-                .map(|l| (l.baseline, l.descent))-                .max_by(|a, b| (a.0 + a.1).total_cmp(&(b.0 + b.1)));-            if let Some((baseline, descent)) = deepest {-                let bottom = baseline + descent;-                let multi = cols[cur.fi].len() > 1;-                let active = trial.as_ref().is_some_and(|t| t.key == key && t.span == pi);-                if !active && let Some(t) = trial.take() {-                    set_limit(&mut limits, t.key, None);-                }-                if multi && (active || limit_of(&limits, key).is_none() && balance_runs < balance_budget) {-                    let top = cur.clip(cols[cur.fi][0]).y0;-                    let mut t = trial.take().unwrap_or(Trial { key, span: pi, lo: top, hi: bottom, tries: 0, done: false });-                    // This layout fits: its bottom is the best so far.-                    t.hi = t.hi.min(bottom);-                    let next = if t.done { t.hi } else { t.next() };-                    set_limit(&mut limits, key, Some(next));-                    if !t.done {-                        trial = Some(t);-                        balance_runs += 1;-                        rewind(-                            key.1,-                            &snaps,-                            &note_snaps,-                            &mut notes,-                            &mut out,-                            &mut cur,-                            &mut list_counter,-                            &mut force_col,-                            &mut line_cap,-                            &mut no_hyph,-                        );-                        pi = key.1;-                        continue 'paras;-                    }-                }-                cur.col = 0;-                cur.last_baseline = Some(baseline);-                cur.last_descent = descent;-                text_above = multi;-            } else if cur.col != 0 {-                cur.col = 0;-                cur.resume();-            }-        }-        if cur.last_baseline.is_some() {-            // Same style as the previous paragraph with a same-style spacing: it replaces space after + before.-            let same_style = pi.checked_sub(1).and_then(|p| story.paras.get(p)).is_some_and(|prev| prev.style == pf.style && prev.table.is_none());-            match pp.same_para_style_spacing {-                Some(gap) if same_style => cur.pending = gap,-                _ => cur.pending += if text_above { pp.space_before.max(pp.span_space_before) } else { pp.space_before },-            }-        }-        if let Some(cfg) = split_cfg-            && cur.split.is_none()-        {-            let start = cur.state();-            cur.split = Some(SplitBlock { cfg, sub: 0, sub_lines: 0, part_lines: 0, line0: None, para: pi, start, top: None });-            cur.split_part();-        }-        let at_top = cur.last_baseline.is_none();-        let start_at = (cur.fi, cur.col);         // Rule above / shading track the paragraph's first line.         let mut g0 = 0usize;         let mut line_no = 0usize;diff --git a/crates/compose/src/table.rs b/crates/compose/src/table.rs--- a/crates/compose/src/table.rs+++ b/crates/compose/src/table.rs@@ -179,7 +179,17 @@ pub(crate) fn place_table(     if cur.fi >= frames.len() || table.nrows() == 0 || table.ncols() == 0 {         return false;     }-    let col_rect = |cur: &Cursor| cur.clip(cols[cur.fi][cur.col.min(cols[cur.fi].len() - 1)]);+    let spanning = matches!(pp.span_columns, designcraft_doc::SpanColumns::Span(n) if n != 1);+    let col_rect = |cur: &Cursor| {+        let base = cols[cur.fi][cur.col.min(cols[cur.fi].len() - 1)];+        let col = cur.clip(base);+        if spanning {+            // A span clears the balanced band above it and uses the full frame height.+            crate::span_rect(&cols[cur.fi], Rect::new(col.x0, col.y0, col.x1, base.y1), pp.span_columns)+        } else {+            col+        }+    };     let mut col = col_rect(cur);     // Column widths: scaled to fit the measure.     let avail = (col.width() - pp.left_indent - pp.right_indent).max(1.0);@@ -319,7 +329,11 @@ pub(crate) fn place_table(             first_frag = false;         }         let was_first = first_frag;-        cur.next_column(cols);+        if spanning {+            cur.next_frame();+        } else {+            cur.next_column(cols);+        }         if cur.fi >= frames.len() {             return false;         }diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs--- a/crates/compose/src/tests.rs+++ b/crates/compose/src/tests.rs@@ -1616,6 +1616,65 @@ fn table_rows_fit_their_content() {     assert!(tf.strokes.len() >= 4 * 3 + 4); } +#[test]+fn spanning_table_uses_all_columns_and_body_resumes_below() {+    let (mut d, sid, fid) = table_doc(Rect::new(0.0, 0.0, 400.0, 700.0), filled(3, 2, 1, 400.0));+    let o = &mut d.item_mut(fid).unwrap().text_frame_mut().unwrap().options;+    o.columns = 2;+    o.gutter = 20.0;+    let st = d.story_mut(sid).unwrap();+    st.insert(0, &format!("{LOREM}\n{LOREM}\n"));+    let pi = st.paras.iter().position(|p| p.table.is_some()).unwrap();+    st.paras[pi].para.span_columns = Some(SpanColumns::Span(0));+    let end = st.len();+    st.insert(end, &format!("\n{}", [LOREM; 6].join("\n")));+    let cs = compose_story(&d, sid, &ComposeOptions::default());+    assert!(!cs.is_overset());+    let ft = &cs.frames[0];+    let t = &ft.tables[0];+    assert!((t.rect.width() - 400.0).abs() < 1e-6, "span width: {}", t.rect.width());+    assert!(ft.lines.iter().filter(|l| l.para < pi).all(|l| l.baseline + l.descent <= t.rect.y0 + 0.01));+    let following: Vec<_> = ft.lines.iter().filter(|l| l.para > pi).collect();+    assert!(following.iter().any(|l| l.column == 1), "exercise both columns below the table");+    assert!(following.iter().all(|l| l.baseline > t.rect.y1), "every column must resume below the table");+}++#[test]+fn spanning_table_continues_in_next_frame_with_repeated_header() {+    let mut d = Document::new(&NewDocument::default());+    let lid = d.default_layer();+    let (first, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(0.0, 0.0, 400.0, 130.0), lid, "", ParaFormat::default()).unwrap();+    let mut previous = first;+    for i in 0..4 {+        let y = 160.0 * (i + 1) as f64;+        let (next, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(0.0, y, 400.0, y + 130.0), lid, "", ParaFormat::default()).unwrap();+        d.thread(previous, next).unwrap();+        previous = next;+    }+    for id in d.story(sid).unwrap().frames.clone() {+        d.item_mut(id).unwrap().text_frame_mut().unwrap().options.columns = 2;+    }+    let mut t = filled(8, 2, 1, 400.0);+    for row in &mut t.rows {+        row.height = 30.0;+        row.mode = designcraft_doc::RowHeightMode::Exactly;+    }+    let st = d.story_mut(sid).unwrap();+    st.insert_table(0, t);+    st.paras.iter_mut().find(|p| p.table.is_some()).unwrap().para.span_columns = Some(SpanColumns::Span(2));+    let cs = compose_story(&d, sid, &ComposeOptions::default());+    assert!(!cs.is_overset());+    let fragments: Vec<_> = cs.frames.iter().flat_map(|f| &f.tables).collect();+    assert!(fragments.len() > 1);+    for frag in &fragments {+        assert!((frag.rect.width() - 400.0).abs() < 1e-6, "span width: {}", frag.rect.width());+        assert_eq!(frag.column, 0, "a spanning fragment must not continue in column two");+        assert!(frag.cell(0, 0).is_some(), "repeat the header in each frame");+    }+    let rows: Vec<_> = fragments.iter().flat_map(|f| &f.cells).filter(|c| c.col == 0 && c.row > 0).map(|c| c.row).collect();+    assert_eq!(rows, (1..9).collect::<Vec<_>>());+}+ #[test] fn table_columns_scale_to_the_text_column() {     let (d, sid, _) = table_doc(Rect::new(0.0, 0.0, 200.0, 1000.0), filled(2, 4, 0, 600.0));@@ -6030,6 +6089,25 @@ fn a_line_that_exactly_fills_the_measure_fits() {  // ---------- split columns ---------- +#[test]+fn a_table_in_a_split_block_ends_it_and_the_text_after_stays_below() {+    // A short frame: the second paragraph after the table moves on to the next sub-column, which+    // must start below the table, not at the top of the block before it.+    let cfg = (2, 12.0, 0.0);+    let (mut d, sid, _, first) = split_doc(3, 0, Rect::new(0.0, 0.0, 400.0, 260.0), cfg);+    let st = d.story_mut(sid).unwrap();+    let at = st.para_ranges()[first + 1].start;+    st.insert_table(at, filled(3, 2, 1, 300.0));+    let ti = st.paras.iter().position(|p| p.table.is_some()).unwrap();+    st.paras[ti].para.span_columns = Some(SpanColumns::Split(cfg.0));+    let cs = compose_story(&d, sid, &ComposeOptions::default());+    let ft = &cs.frames[0];+    let t = &ft.tables[0];+    let after: Vec<_> = ft.lines.iter().filter(|l| l.para > ti).collect();+    assert!(after.len() > 1);+    assert!(after.iter().all(|l| l.baseline > t.rect.y1), "text after the table must stay below it");+}+ /// A story of a body paragraph, `split` paragraphs in split columns and `after` body paragraphs. /// Returns the index of the first split paragraph. fn split_doc(split: usize, after: usize, rect: Rect, cfg: (u32, f64, f64)) -> (Document, StoryId, ItemId, usize) {