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** · 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.
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
@@ -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, &note_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap, &mut no_hyph);
+            rewind(
+                j,
+                &snaps,
+                &note_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, &note_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap, &mut no_hyph);
+                rewind(
+                    j,
+                    &snaps,
+                    &note_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, &note_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap, &mut no_hyph);
+                    rewind(
+                        j,
+                        &snaps,
+                        &note_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,
+                        &note_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, &note_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap, &mut no_hyph);
+                    rewind(
+                        j,
+                        &snaps,
+                        &note_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, &note_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap, &mut no_hyph);
+                    rewind(
+                        pi,
+                        &snaps,
+                        &note_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) {
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
@@ -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;
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
@@ -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]);
+}
