From 8ce1292d0e81af43127976278db539356901bca3 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Wed, 14 Oct 2026 17:04:51 +0200 Subject: [PATCH] Footnotes: Span Footnotes Across Columns sets notes across the frame (fixes #915) --- crates/compose/src/lib.rs | 15 +++++++++- crates/compose/src/notes.rs | 59 ++++++++++++++++++++++++++++++++----- crates/compose/src/tests.rs | 38 ++++++++++++++++++++++++ docs/target-app-parity.md | 2 +- 4 files changed, 105 insertions(+), 9 deletions(-) diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index c6312e9f..320519e6 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -1021,7 +1021,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C // Footnotes referenced on this line need room at the bottom of the column too // (a line at the top of a column is set anyway). let line_notes = if notes.active() { notes.refs_in(line_glyphs) } else { Vec::new() }; - let col_w = cols[cur.fi][cur.col.min(cols[cur.fi].len() - 1)].width(); + let col_w = notes.area(&cols[cur.fi], cur.col).map_or(0.0, |r| r.width()); let reserve = notes.reserve(doc, cur.fi, cur.col, &line_notes, col_w, f, opts); let reserve = if cur.last_baseline.is_none() { notes.reserve(doc, cur.fi, cur.col, &[], col_w, f, opts) } else { reserve }; let fits = baseline + desc <= col.y1 - reserve + 0.01 && !capped; @@ -1169,9 +1169,22 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C keep_violation: false, drop_cap: drop_box, }); + let placed_notes = !line_notes.is_empty(); for k in line_notes { notes.place(doc, k, cur.fi, cur.col, col_w, f, opts); } + // Notes spanning the columns take room from the columns before this one too: re-lay + // the frame with that room left. + if placed_notes { + let r = notes.reserve(doc, cur.fi, cur.col, &[], col_w, f, opts); + if notes.earlier_columns_collide(&out.frames[cur.fi].lines, cur.fi, cur.col, &cols[cur.fi], r) + && let Some(j) = out.frames[cur.fi].lines.first().map(|l| l.para) + { + rewind(j, &snaps, ¬e_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap); + pi = j; + continue 'paras; + } + } if !sub_objects.is_empty() { let ft = &mut out.frames[cur.fi]; let li = ft.lines.len() - 1; diff --git a/crates/compose/src/notes.rs b/crates/compose/src/notes.rs index a37c1aa4..19138a46 100644 --- a/crates/compose/src/notes.rs +++ b/crates/compose/src/notes.rs @@ -5,6 +5,9 @@ //! the first footnote and the space between footnotes). A line that would collide moves to the //! next column with its footnotes. After the story is laid out the footnotes of each column are //! stacked against the column bottom, with the separator rule above the first one. +//! +//! With Span Footnotes Across Columns the notes of a frame share one area across all its columns, +//! composed at the frame's text width: every column of the frame makes room for it. use std::collections::HashMap; use std::ops::Range; @@ -38,13 +41,39 @@ pub(crate) struct Notes { /// Composed footnote text by (index, label, width). cache: HashMap<(usize, String, u64), (Arc, f64)>, notes: Vec>, + /// Footnotes span the columns of their frame. + span: bool, + /// Room a frame's earlier columns had to leave for spanning notes placed in a later column; + /// kept across re-lays so the columns before the reference make room on the next pass. + floor: HashMap, } impl Notes { pub fn new(doc: &Document, story: &Story) -> Notes { let refs: Vec = if story.notes.is_empty() { Vec::new() } else { story.text.match_indices(FOOTNOTE_REF).map(|(i, _)| i).collect() }; let start = if refs.is_empty() { 1 } else { doc.footnote_start(story.id) }; - Notes { numbers: vec![start; refs.len()], refs, num: start, key: None, placed: Vec::new(), cache: HashMap::new(), notes: story.notes.clone() } + Notes { + numbers: vec![start; refs.len()], + refs, + num: start, + key: None, + placed: Vec::new(), + cache: HashMap::new(), + notes: story.notes.clone(), + span: doc.footnote_options.span_columns, + floor: HashMap::new(), + } + } + + /// Whether a note placed in (fi, col) shares the footnote area of column (`fi`, `ci`). + fn same_area(&self, p: &Placed, fi: usize, ci: usize) -> bool { + p.fi == fi && (self.span || p.col == ci) + } + + /// The footnote area of column `col` of a frame with columns `cols`: the column, or all of + /// them when notes span columns. + pub fn area(&self, cols: &[Rect], col: usize) -> Option { + if self.span { cols.iter().copied().reduce(|a, b| a.union(b)) } else { cols.get(col.min(cols.len().saturating_sub(1))).copied() } } pub fn active(&self) -> bool { @@ -127,7 +156,7 @@ impl Notes { let o = &doc.footnote_options; let mut n = 0usize; let mut h = 0.0; - for p in self.placed.iter().filter(|p| p.fi == fi && p.col == col) { + for p in self.placed.iter().filter(|p| self.same_area(p, fi, col)) { n += 1; h += p.h; } @@ -135,7 +164,23 @@ impl Notes { n += 1; h += self.compose(doc, k, w, f, opts).1; } - if n == 0 { 0.0 } else { h + o.space_between * (n - 1) as f64 + o.space_before } + let r = if n == 0 { 0.0 } else { h + o.space_between * (n - 1) as f64 + o.space_before }; + if self.span { r.max(self.floor.get(&fi).copied().unwrap_or(0.0)) } else { r } + } + + /// After spanning notes were placed in column `col` of frame `fi`: whether a line of an earlier + /// column now runs into their area. The area is then remembered for the frame, to re-lay it. + pub fn earlier_columns_collide(&mut self, lines: &[crate::Line], fi: usize, col: usize, cols: &[Rect], reserve: f64) -> bool { + if !self.span || col == 0 || reserve <= self.floor.get(&fi).copied().unwrap_or(0.0) + 0.01 { + return false; + } + let hit = lines + .iter() + .any(|l| (l.column as usize) < col && cols.get(l.column as usize).is_some_and(|c| l.baseline + l.descent > c.y1 - reserve + 0.01)); + if hit { + self.floor.insert(fi, reserve); + } + hit } pub fn place(&mut self, doc: &Document, k: usize, fi: usize, col: usize, w: f64, f: &FrameSpec, opts: &ComposeOptions) { @@ -152,13 +197,13 @@ impl Notes { return; } let o = &doc.footnote_options; - let mut groups: Vec<(usize, usize)> = self.placed.iter().map(|p| (p.fi, p.col)).collect(); + let mut groups: Vec<(usize, usize)> = self.placed.iter().map(|p| (p.fi, if self.span { 0 } else { p.col })).collect(); groups.dedup(); groups.sort_unstable(); groups.dedup(); for (fi, ci) in groups { - let (Some(_), Some(col)) = (frames.get(fi), cols.get(fi).and_then(|c| c.get(ci))) else { continue }; - let here: Vec<&Placed> = self.placed.iter().filter(|p| p.fi == fi && p.col == ci).collect(); + let (Some(_), Some(col)) = (frames.get(fi), cols.get(fi).filter(|c| ci < c.len()).and_then(|c| self.area(c, ci))) else { continue }; + let here: Vec<&Placed> = self.placed.iter().filter(|p| self.same_area(p, fi, ci)).collect(); let total: f64 = here.iter().map(|p| p.h).sum::() + o.space_between * (here.len() - 1) as f64; let top = col.y1 - total; let ft = &mut out.frames[fi]; @@ -177,7 +222,7 @@ impl Notes { ft.notes.push(PlacedNote { id: note.id, index: p.k, - column: ci as u32, + column: p.col as u32, label: o.label(self.numbers[p.k], true), origin: Point::new(col.x0, y), rect: Rect::new(col.x0, y, col.x1, y + p.h), diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index a20ec78b..432437fd 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -1269,6 +1269,44 @@ fn footnote_line_at_column_top_still_sets() { assert_eq!(cs.frames[0].notes.len(), 1); } +#[test] +fn footnotes_span_columns_across_the_frame_bottom() { + // Span Footnotes Across Columns: the note area runs under every column of the frame and each + // column's text ends above it, also the columns before the one holding the reference. + let text = [LOREM; 8].join(" "); + let note = "A long footnote, long enough to wrap if it is set one column wide rather than across the frame."; + let compose = |at: usize, span: bool| { + let (mut d, sid, fid) = doc_with(&text, Rect::new(36.0, 36.0, 436.0, 236.0), ParaAttrs::default()); + d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.columns = 2; + d.footnote_options.span_columns = span; + d.story_mut(sid).unwrap().insert_note(at, note, ParaFormat::default()); + let space_before = d.footnote_options.space_before; + (compose_story(&d, sid, &ComposeOptions::default()), space_before) + }; + let plain = compose(10, false).0; + // A reference halfway down the second column. + let in_col1 = plain.frames[0].lines.iter().filter(|l| l.column == 1).nth(5).unwrap().range.start + 5; + for (at, ref_col) in [(10, 0), (in_col1, 1)] { + let (cs, space_before) = compose(at, true); + let ft = &cs.frames[0]; + let (c0, c1) = (ft.columns[0], ft.columns[1]); + assert_eq!(ft.notes.len(), 1); + let n = &ft.notes[0]; + assert_eq!(n.column, ref_col, "ref at {at}"); + assert!((n.rect.x0 - c0.x0).abs() < 1e-6 && (n.rect.x1 - c1.x1).abs() < 1e-6, "ref at {at}: note {:?} spans the frame", n.rect); + assert!((n.rect.y1 - c0.y1).abs() < 1e-6, "ref at {at}: note ends at the frame bottom"); + for col in 0..2 { + let last = ft.lines.iter().rfind(|l| l.column == col).unwrap(); + assert!(last.baseline + last.descent <= n.rect.y0 - space_before + 0.01, "ref at {at}: column {col} text clears the note"); + } + assert!(n.text.frames[0].lines.iter().all(|l| l.x1 - l.x0 > c0.width() + 1.0), "ref at {at}: note set at the frame width"); + } + // Off: the note stays in the column holding its reference. + let n = &compose(in_col1, false).0.frames[0].notes[0]; + assert_eq!(n.column, 1); + assert!((n.rect.x0 - plain.frames[0].columns[1].x0).abs() < 1e-6 && (n.rect.x1 - plain.frames[0].columns[1].x1).abs() < 1e-6); +} + #[test] fn hj_severity_shades() { use crate::hj_severity; diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index daaa3f44..d6b5bddd 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -277,7 +277,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Hyperlinks (text or frames → URL / e-mail / page) and bookmarks, exported as PDF link annotations and outline. - EPUB 3 (reflowable) export: stories in reading order, CSS from paragraph/character styles, images, navigation (`file.exportEpub`, CLI `--export x.epub`). - Data Merge (one linked CSV, TSV, text, or Excel file; text, image, QR, and hyperlink placeholders; preview; a new document with one record per page or records tiled on a single page), spell checking (public-domain Moby list + document dictionary, suggestions), Step and Repeat (count or grid, with a previewing dialog), snippets, object styles, Numbering & Section Options. -- Footnotes: Type › Insert Footnote (caret moves into the note), Document Footnote Options (numbering style incl. symbols, start/restart per page/spread/section, prefix/suffix, reference position/character style, paragraph style, separator, spacing, first baseline, rule above); notes composed at the bottom of the referencing column with the body text making room; edited in place on the canvas; rendered, exported to PDF (real text) and IDML (InDesign's structure, verified opening in InDesign 2026) and imported from IDML; `footnote.*` commands. +- Footnotes: Type › Insert Footnote (caret moves into the note), Document Footnote Options (numbering style incl. symbols, start/restart per page/spread/section, prefix/suffix, reference position/character style, paragraph style, separator, spacing, first baseline, rule above); notes composed at the bottom of the referencing column with the body text making room, or across all the columns of the frame with Span Footnotes Across Columns; edited in place on the canvas; rendered, exported to PDF (real text) and IDML (InDesign's structure, verified opening in InDesign 2026) and imported from IDML; `footnote.*` commands. - Paragraph Rules: Rule Above / Rule Below (on, weight, colour, tint, column or text width, offset, left/right indent) set in Type › Paragraph Rules…, the Paragraph panel menu and Paragraph Style Options › Paragraph Rules; inherited style values shown, and `type.para` / `style.paragraph.edit` change only the rule fields named. - Cross-references and text anchors: Insert Cross-Reference (paragraph by style, or text anchor), 11 formats with InDesign's building blocks (full/partial paragraph, paragraph text/number, page number, anchor name, chapter, file name) plus user formats; resolved live at composition — never out of date, no Update step; unresolved destinations show `??`; PDF link annotations to the destination page; IDML export/import as InDesign cross-reference sources, hyperlinks, text destinations and formats (verified in InDesign 2026); `xref.*` / `anchor.*` commands. - Index: page references (topic from the selection or up to 4 levels, sort keys; current page, to end of story, next n paragraphs, suppressed, See / See also), Generate / Update Index (section headings, nested or run-in, page ranges merged, Index Title / Section Head / Level 1–4 styles); IDML export/import as InDesign topics, page references and topic cross-references (verified in InDesign 2026); `index.*` commands.