From ed88f3e4b5f6775a92e2ab4a2e7fa7d993477ff6 Mon Sep 17 00:00:00 2001 From: KnottyDyes <47539659+KnottyDyes@users.noreply.github.com> Date: Wed, 28 Oct 2026 01:52:25 -0600 Subject: [PATCH] Compose: tables clear the footnote area Reserve column and spanning footnotes when fitting inline table row groups. Continue rows in the next column or frame when the remaining space is too small, and include cell-local footnotes in measured row height. Fixes #1901 Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 4 + crates/compose/src/lib.rs | 15 +++- crates/compose/src/table.rs | 29 ++++-- crates/compose/src/tests.rs | 172 ++++++++++++++++++++++++++++++++++++ 4 files changed, 211 insertions(+), 9 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index c02176bab..7cc6acf73 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -12,6 +12,10 @@ 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. +- **2026-10-28** · Inline table rows leave room for column and spanning footnotes, continuing in + the next column or frame when needed (#1901). Cell-local footnotes contribute to row height + and sit inside the cell rather than outside its measuring frame. Vertical justification keeps + paragraphs and table fragments above the footnote area. No readiness percentage change. - **2026-10-25** · The application bar uses craft-window's shared title-bar code. A drag that starts on a floating panel over the bar moves the panel, not the window, and in full screen the bar no longer zooms or moves the window. No readiness percentage change. diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 88becd85f..58454b228 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -1099,7 +1099,7 @@ fn place_table_paragraph(input: TableParagraphInput<'_>) -> TableParagraphResult } let at_top = cursor.last_baseline.is_none(); let start_at = (cursor.fi, cursor.col); - if !table::place_table(doc, table, anchor, paragraph, props, frames, columns, cursor, out, opts) { + if !table::place_table(doc, table, anchor, paragraph, props, frames, columns, cursor, out, notes, opts) { if let Some(j) = trial_failed(trial, limits, *balance_runs < balance_budget) { *balance_runs += 1; rewind(j, snaps, note_snaps, notes, out, cursor, list_counter, force_col, line_cap, no_hyph); @@ -3108,7 +3108,7 @@ fn finish_composition(input: FinishInput<'_>) -> ComposedStory { let before: Vec = ft.tables.iter().map(|t| ft.lines.get(t.line).map_or(0.0, |l| l.baseline)).collect(); let spanned = ft.columns.len() > 1 && ft.lines.iter().any(|l| span_paras.get(l.para) == Some(&true)) || ft.lines.iter().any(|l| split_paras.get(l.para) == Some(&true)); - vertical_justify(ft, f, spanned); + vertical_justify(ft, f, spanned, &doc.footnote_options); for (t, b) in ft.tables.iter_mut().zip(before) { let now = ft.lines.get(t.line).map_or(b, |l| l.baseline); t.shift(now - b); @@ -5320,7 +5320,7 @@ fn hyphenation_points( } /// `spanned`: a paragraph spans the frame's columns, so the lines move together. -fn vertical_justify(ft: &mut FrameText, f: &FrameSpec, spanned: bool) { +fn vertical_justify(ft: &mut FrameText, f: &FrameSpec, spanned: bool, notes: &designcraft_doc::FootnoteOptions) { if ft.lines.is_empty() || f.opts.vertical_justification == VerticalJustification::Top { return; } @@ -5333,7 +5333,14 @@ fn vertical_justify(ft: &mut FrameText, f: &FrameSpec, spanned: bool) { let idx: Vec = ft.lines.iter().enumerate().filter(|(_, l)| spanned || l.column == c).map(|(i, _)| i).collect(); let (Some(&a), Some(_)) = (idx.first(), idx.last()) else { continue }; let bottom = idx.iter().map(|&i| ft.lines[i].baseline + ft.lines[i].descent).fold(f64::NEG_INFINITY, f64::max); - let space = f.area.y1 - bottom; + let bottom = ft.tables.iter().filter(|t| spanned || t.column == c).map(|t| t.rect.y1).fold(bottom, f64::max); + let limit = ft + .notes + .iter() + .filter(|n| spanned || notes.span_columns || n.column == c) + .map(|n| n.rect.y0 - notes.space_before) + .fold(f.area.y1, f64::min); + let space = limit - bottom; if space <= 0.0 { continue; } diff --git a/crates/compose/src/table.rs b/crates/compose/src/table.rs index c620622a3..dc579d8a7 100644 --- a/crates/compose/src/table.rs +++ b/crates/compose/src/table.rs @@ -122,7 +122,7 @@ impl CellLineExtent { /// Compose a cell story at width `w` (content space, top-left at the origin). fn compose_cell(doc: &Document, cell: &Cell, w: f64, opts: &ComposeOptions, (left_page, right_page): (bool, bool)) -> CellLayout { - let spec = FrameSpec { + let mut spec = FrameSpec { id: ItemId(0), area: Rect::new(0.0, 0.0, w.max(1.0), 1.0e6), opts: TextFrameOptions { first_baseline: cell.first_baseline, first_baseline_min: cell.first_baseline_min, ..TextFrameOptions::default() }, @@ -137,7 +137,15 @@ fn compose_cell(doc: &Document, cell: &Cell, w: f64, opts: &ComposeOptions, (lef right_page, page_rect: None, }; - let cs = crate::compose(doc, &cell.text, std::slice::from_ref(&spec), opts); + let mut cs = crate::compose(doc, &cell.text, std::slice::from_ref(&spec), opts); + // The measuring frame is deliberately tall. Recompose at the actual content height so + // cell-local footnotes sit inside the row rather than at the measuring frame's bottom. + if let Some(frame) = cs.frames.first().filter(|f| !f.notes.is_empty()) { + let body = frame.lines.iter().map(|l| l.baseline + l.descent).fold(0.0, f64::max); + let note_top = frame.notes.first().map_or(spec.area.y1, |n| n.rect.y0); + spec.area.y1 = body + doc.footnote_options.space_before + (spec.area.y1 - note_top); + cs = crate::compose(doc, &cell.text, std::slice::from_ref(&spec), opts); + } let line_heights: Vec = cs .frames .first() @@ -157,7 +165,8 @@ fn compose_cell(doc: &Document, cell: &Cell, w: f64, opts: &ComposeOptions, (lef .unwrap_or_default(); // Check all lines: a large descender or lowered inline object on an earlier line can // extend below the final baseline when leading is fixed. - let content_h = line_heights.iter().copied().fold(0.0, f64::max); + let note_bottom = cs.frames.first().map_or(0.0, |f| f.notes.iter().map(|n| n.rect.y1).fold(0.0, f64::max)); + let content_h = line_heights.iter().copied().fold(note_bottom, f64::max); CellLayout { text: Arc::new(cs), content_h, line_heights } } @@ -174,6 +183,7 @@ pub(crate) fn place_table( cols: &[Vec], cur: &mut Cursor, out: &mut ComposedStory, + notes: &mut crate::notes::Notes, opts: &ComposeOptions, ) -> bool { if cur.fi >= frames.len() || table.nrows() == 0 || table.ncols() == 0 { @@ -292,6 +302,13 @@ pub(crate) fn place_table( } }; place_rows(0..header, &mut y, false, &mut frag_rows); + // Table rows do not add story footnotes; cell-local notes are composed separately. + let column_reserve = |notes: &mut crate::notes::Notes, cur: &Cursor, col: Rect| { + let note_w = notes.area(&cols[cur.fi], cur.col).map_or(col.width(), |a| a.width()); + let column = crate::notes::NoteColumn { frame_index: cur.fi, column_index: cur.col, width: note_w, frame: &frames[cur.fi] }; + notes.reserve(doc, column, &[], opts) + }; + let mut reserve = column_reserve(notes, cur, col); let mut r = header; loop { if r >= body_end { @@ -299,9 +316,10 @@ pub(crate) fn place_table( } let ge = group_end[r].min(body_end).max(r + 1); let gh = sum(r, ge); + let bottom = col.y1 - reserve; let at_col_top = cur.last_baseline.is_none() && (top - col.y0).abs() < 1e-6; - if y + gh + footer_h <= col.y1 + 0.01 || (body_placed == 0 && at_col_top) { - if y + gh + footer_h > col.y1 + 0.01 && body_placed == 0 && at_col_top && y + gh > col.y1 + 0.01 { + if y + gh + footer_h <= bottom + 0.01 || (body_placed == 0 && at_col_top && reserve == 0.0) { + if y + gh + footer_h > bottom + 0.01 && body_placed == 0 && at_col_top && y + gh > bottom + 0.01 { // A row group taller than an empty column can never fit. return false; } @@ -324,6 +342,7 @@ pub(crate) fn place_table( return false; } col = col_rect(cur); + reserve = column_reserve(notes, cur, col); y = col.y0; top = y; frag_rows.clear(); diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 3083261fb..2f66eca93 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -1994,6 +1994,178 @@ fn footnotes_sit_at_the_column_bottom_and_push_text() { assert!(note_caret(&cs, n.id, 0).is_some()); } +fn justified_content_clears_footnotes(mode: designcraft_doc::VerticalJustification, table: bool) { + for span in [false, true] { + let (mut d, sid, fid) = doc_with("First paragraph.\nSecond paragraph.", Rect::new(0.0, 0.0, 420.0, 240.0), ParaAttrs::default()); + d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.columns = 2; + d.footnote_options.span_columns = span; + d.footnote_options.space_before = 60.0; + let st = d.story_mut(sid).unwrap(); + st.insert_note(5, "Synthetic footnote.", ParaFormat::default()); + if table { + let mut t = designcraft_doc::Table::new(77, 1, 1, 0, 0, 180.0); + t.rows[0].mode = designcraft_doc::RowHeightMode::Exactly; + t.rows[0].height = 90.0; + st.insert_table(st.len(), t); + } + let top = compose_story(&d, sid, &ComposeOptions::default()); + d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.vertical_justification = mode; + + let cs = compose_story(&d, sid, &ComposeOptions::default()); + + assert!(!cs.is_overset()); + let ft = cs.frame(fid).unwrap(); + let original = top.frame(fid).unwrap(); + assert_eq!(ft.notes.len(), 1); + assert_eq!(ft.notes[0].rect, original.notes[0].rect, "footnotes must not move"); + let limit = ft.notes[0].rect.y0 - d.footnote_options.space_before; + assert!(ft.lines.iter().all(|l| l.baseline + l.descent <= limit + 0.01), "lines overlap footnotes in {mode:?}, span {span}"); + assert!(ft.tables.iter().flat_map(|t| &t.cells).all(|c| c.rect.y1 <= limit + 0.01), "cells overlap footnotes in {mode:?}, span {span}"); + assert!(ft.lines.last().unwrap().baseline > original.lines.last().unwrap().baseline + 0.01, "content must still move down"); + if table { + assert_eq!(ft.tables.len(), 1); + assert!(ft.tables[0].rect.y1 > original.tables[0].rect.y1 + 0.01, "table must still move down"); + } + } +} + +#[test] +fn bottom_justified_paragraphs_clear_footnotes() { + justified_content_clears_footnotes(designcraft_doc::VerticalJustification::Bottom, false); +} + +#[test] +fn bottom_justified_tables_clear_footnotes() { + justified_content_clears_footnotes(designcraft_doc::VerticalJustification::Bottom, true); +} + +#[test] +fn center_justified_tables_clear_footnotes() { + justified_content_clears_footnotes(designcraft_doc::VerticalJustification::Center, true); +} + +#[test] +fn vertically_justified_tables_clear_footnotes() { + justified_content_clears_footnotes(designcraft_doc::VerticalJustification::Justify, true); +} + +#[test] +fn vertical_justification_counts_table_fragment_bottoms() { + use designcraft_doc::VerticalJustification::{Bottom, Center, Justify}; + let (mut d, sid, fid) = doc_with("Reference.", Rect::new(0.0, 0.0, 200.0, 240.0), ParaAttrs::default()); + let st = d.story_mut(sid).unwrap(); + st.insert_note(5, "Synthetic footnote.", ParaFormat::default()); + let mut table = designcraft_doc::Table::new(77, 1, 1, 0, 0, 180.0); + table.rows[0].mode = designcraft_doc::RowHeightMode::Exactly; + table.rows[0].height = 120.0; + st.insert_table(st.len(), table); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + for mode in [Bottom, Center, Justify] { + let mut ft = cs.frame(fid).unwrap().clone(); + let t = &ft.tables[0]; + let (anchor, bottom, baseline) = (t.line, t.rect.y1, t.rect.y0); + // Exercise a fragment extending below its anchor, without a later text line hiding it. + ft.lines.truncate(anchor + 1); + ft.lines[anchor].baseline = baseline; + let mut spec = frame_specs(&d, sid).remove(0); + spec.opts.vertical_justification = mode; + + vertical_justify(&mut ft, &spec, false, &d.footnote_options); + + let moved = ft.lines[anchor].baseline - baseline; + let limit = ft.notes[0].rect.y0 - d.footnote_options.space_before; + assert!(moved > 0.01); + assert!(bottom + moved <= limit + 0.01, "fragment overlaps footnotes in {mode:?}"); + } +} + +#[test] +fn table_rows_clear_footnotes_and_continue_in_the_next_column() { + for span in [false, true] { + for row_height in [24.0, 150.0] { + let (mut d, sid, fid) = doc_with("First paragraph.\nSecond paragraph.", Rect::new(0.0, 0.0, 420.0, 200.0), ParaAttrs::default()); + d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.columns = 2; + d.footnote_options.span_columns = span; + let st = d.story_mut(sid).unwrap(); + st.insert_note(5, "First synthetic footnote.", ParaFormat::default()); + st.insert_note(25, "Second synthetic footnote.", ParaFormat::default()); + let mut table = designcraft_doc::Table::new(77, 8, 1, 0, 0, 180.0); + for row in &mut table.rows { + row.mode = designcraft_doc::RowHeightMode::Exactly; + row.height = row_height; + } + st.insert_table(st.len(), table); + let (next, _) = + d.add_text_frame(SpreadRef::Doc(0), Rect::new(0.0, 220.0, 420.0, 1720.0), d.default_layer(), "", ParaFormat::default()).unwrap(); + d.thread(fid, next).unwrap(); + + let cs = compose_story(&d, sid, &ComposeOptions::default()); + + assert!(!cs.is_overset()); + let ft = cs.frame(fid).unwrap(); + assert_eq!(ft.notes.len(), 2); + let top = ft.notes[0].rect.y0 - d.footnote_options.space_before; + for frag in &ft.tables { + if span || frag.column == 0 { + assert!(frag.cells.iter().all(|c| c.rect.y1 <= top + 0.01), "rows overlap footnotes: {:?}, top {top}", frag.rect); + } + } + assert!(cs.frames.iter().skip(1).any(|f| !f.tables.is_empty()) || ft.tables.iter().any(|t| t.column == 1)); + let rows: Vec<_> = cs.frames.iter().flat_map(|f| &f.tables).flat_map(|t| &t.cells).filter(|c| !c.repeated).map(|c| c.row).collect(); + assert_eq!(rows, (0..8).collect::>()); + if row_height == 150.0 { + assert!(!ft.tables.iter().any(|t| t.column == 0), "the whole table moves when no row fits"); + } + } + } +} + +#[test] +fn table_reports_overset_when_every_remaining_column_has_footnotes() { + let (mut d, sid, fid) = doc_with("Reference.", Rect::new(0.0, 0.0, 420.0, 200.0), ParaAttrs::default()); + d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.columns = 3; + d.footnote_options.span_columns = true; + d.footnote_options.space_before = 40.0; + let st = d.story_mut(sid).unwrap(); + st.insert_note(5, "Synthetic spanning footnote.", ParaFormat::default()); + let mut table = designcraft_doc::Table::new(77, 2, 1, 0, 0, 100.0); + for row in &mut table.rows { + row.mode = designcraft_doc::RowHeightMode::Exactly; + row.height = 90.0; + } + table.cells[0].row_span = 2; + let anchor = st.insert_table(st.len(), table); + + let cs = compose_story(&d, sid, &ComposeOptions::default()); + + assert!(cs.is_overset()); + assert_eq!(cs.overset_at, Some(anchor)); + let ft = cs.frame(fid).unwrap(); + assert_eq!(ft.notes.len(), 1); + assert!(ft.notes[0].rect.y0 - d.footnote_options.space_before < 180.0); + assert!(cs.frames.iter().all(|f| f.tables.is_empty()), "the unplaced row group must remain overset"); +} + +#[test] +fn table_cell_footnotes_fit_inside_the_row() { + let (mut d, sid, _) = doc_with("", Rect::new(0.0, 0.0, 200.0, 200.0), ParaAttrs::default()); + let mut table = designcraft_doc::Table::new(77, 1, 1, 0, 0, 180.0); + let cell = &mut table.cells[0]; + cell.text.insert(0, "Cell reference."); + cell.text.insert_note(5, "Synthetic cell footnote.", ParaFormat::default()); + d.story_mut(sid).unwrap().insert_table(0, table); + + let cs = compose_story(&d, sid, &ComposeOptions::default()); + + let cell = &cs.frames[0].tables[0].cells[0]; + assert!(!cell.overset); + let frame = &cell.text.frames[0]; + assert_eq!(frame.notes.len(), 1); + let note = &frame.notes[0]; + assert!(cell.origin.y + note.rect.y1 <= cell.clip.y1 + 0.01, "cell footnote must remain within the row"); + assert!(frame.lines.iter().all(|l| l.baseline + l.descent <= note.rect.y0 - d.footnote_options.space_before + 0.01)); +} + #[test] fn footnotes_stack_baseline_to_baseline() { // Single-line footnotes read as one text: consecutive baselines sit one leading (plus