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) {
