# Conflicts:
#	crates/compose/src/notes.rs
This commit is contained in:
Brandon Thomas
2026-10-14 23:05:24 -04:00
4 changed files with 105 additions and 9 deletions
+14 -1
View File
@@ -1056,7 +1056,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 };
// As in InDesign, a line is set when its baseline fits: its descenders may hang below the frame.
@@ -1205,9 +1205,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, &note_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;
+52 -7
View File
@@ -10,6 +10,9 @@
//! First Baseline offset below the top of the footnote area, and each later footnote's first
//! baseline sits its first line's leading (plus Space Between Footnotes) below the previous
//! footnote's last baseline. Only the last footnote's descent counts toward the area's height.
//!
//! 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;
@@ -71,13 +74,39 @@ pub(crate) struct Notes {
/// Composed footnote text by (index, label, width).
cache: HashMap<(usize, String, u64), (Arc<ComposedStory>, NoteMetrics)>,
notes: Vec<Arc<designcraft_doc::Footnote>>,
/// 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<usize, f64>,
}
impl Notes {
pub fn new(doc: &Document, story: &Story) -> Notes {
let refs: Vec<usize> = 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<Rect> {
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 {
@@ -165,11 +194,27 @@ impl Notes {
pub fn reserve(&mut self, doc: &Document, fi: usize, col: usize, extra: &[usize], w: f64, f: &FrameSpec, opts: &ComposeOptions) -> f64 {
let o = &doc.footnote_options;
let (space_between, space_before) = (o.space_between, o.space_before);
let mut ms: Vec<NoteMetrics> = self.placed.iter().filter(|p| p.fi == fi && p.col == col).map(|p| p.m).collect();
let mut ms: Vec<NoteMetrics> = self.placed.iter().filter(|p| self.same_area(p, fi, col)).map(|p| p.m).collect();
for &k in extra {
ms.push(self.compose(doc, k, w, f, opts).1);
}
if ms.is_empty() { 0.0 } else { stack_height(&ms, space_between) + space_before }
let r = if ms.is_empty() { 0.0 } else { stack_height(&ms, space_between) + 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) {
@@ -186,13 +231,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 = stack_height(here.iter().map(|p| &p.m), o.space_between);
let top = col.y1 - total;
let ft = &mut out.frames[fi];
@@ -220,7 +265,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, y0, col.x1, y1),
+38
View File
@@ -1372,6 +1372,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;
+1 -1
View File
@@ -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.