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 <noreply@anthropic.com>
This commit is contained in:
KnottyDyes
2026-10-28 18:44:34 -06:00
co-authored by Claude Opus 5.5
parent 855d606325
commit ed88f3e4b5
4 changed files with 211 additions and 9 deletions
+4
View File
@@ -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.
+11 -4
View File
@@ -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<f64> = 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<usize> = 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;
}
+24 -5
View File
@@ -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<f64> = 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<Rect>],
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();
+172
View File
@@ -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::<Vec<_>>());
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