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