Files
designcraft/crates/compose/src/notes.rs
T
2026-10-14 22:05:10 -04:00

263 lines
12 KiB
Rust

//! Footnote layout: numbering, room at the bottom of columns, and placement.
//!
//! When a line holding footnote references is set, each referenced footnote is composed at the
//! column width and the column's text area shrinks by its height (plus the minimum space before
//! 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.
//!
//! The footnotes of a column read as one text: the first footnote's first baseline sits at the
//! 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.
use std::collections::HashMap;
use std::ops::Range;
use std::sync::Arc;
use designcraft_doc::notes::NoteRestart;
use designcraft_doc::{Document, FOOTNOTE_REF, ItemId, Story, TextFrameOptions};
use designcraft_geom::{Point, Rect};
use crate::shape::{Glyph, SubstCtx};
use crate::{ComposeOptions, ComposedStory, Deco, FrameSpec, PlacedNote};
/// A footnote assigned to a column.
pub(crate) struct Placed {
k: usize,
fi: usize,
col: usize,
m: NoteMetrics,
text: Arc<ComposedStory>,
}
/// Vertical metrics of a composed footnote, relative to the top of its composed text.
#[derive(Clone, Copy, Debug, Default)]
pub(crate) struct NoteMetrics {
/// First and last baselines (the first one includes the First Baseline offset).
first: f64,
last: f64,
/// Leading of the first line: its baseline's distance below the previous footnote's last.
lead: f64,
/// Descent of the last line.
descent: f64,
}
/// Height of footnotes stacked baseline to baseline, from the top of the footnote area to the
/// last footnote's descent.
fn stack_height<'a>(notes: impl IntoIterator<Item = &'a NoteMetrics>, space_between: f64) -> f64 {
let mut h: Option<f64> = None;
let mut descent = 0.0;
for m in notes {
let last = match h {
None => m.last,
Some(prev) => prev + space_between + m.lead + (m.last - m.first),
};
h = Some(last);
descent = m.descent;
}
h.map_or(0.0, |h| h + descent)
}
pub(crate) struct Notes {
/// Byte offsets of the reference characters (index = footnote index).
refs: Vec<usize>,
/// Number of each footnote (assigned when its paragraph is shaped).
numbers: Vec<u32>,
/// Next number and the restart unit (page / spread / section start) it counts in.
pub num: u32,
pub key: Option<usize>,
pub placed: Vec<Placed>,
/// Composed footnote text by (index, label, width).
cache: HashMap<(usize, String, u64), (Arc<ComposedStory>, NoteMetrics)>,
notes: Vec<Arc<designcraft_doc::Footnote>>,
}
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() }
}
pub fn active(&self) -> bool {
!self.refs.is_empty() && self.refs.len() == self.notes.len()
}
/// Number the references in a paragraph (shaped on `page`) and hand their labels to the shaper.
pub fn number_refs(&mut self, doc: &Document, prange: Range<usize>, page: Option<usize>, sub: &mut SubstCtx) {
let o = &doc.footnote_options;
let key = page.and_then(|p| match o.restart {
NoteRestart::Never => None,
NoteRestart::Page => Some(p),
NoteRestart::Spread => doc.page_loc(p).map(|l| l.0),
NoteRestart::Section => Some(doc.section_of(p).map_or(0, |s| s.start)),
});
let a = self.refs.partition_point(|&b| b < prange.start);
let b = self.refs.partition_point(|&b| b < prange.end);
for k in a..b {
if o.restart != NoteRestart::Never && key != self.key {
self.key = key;
self.num = o.start_at;
}
self.numbers[k] = self.num;
self.num += 1;
sub.notes.insert(self.refs[k], o.label(self.numbers[k], false));
}
sub.note_position = o.ref_position;
sub.note_style = (o.ref_char_style != designcraft_doc::NO_CHAR_STYLE).then(|| o.ref_char_style.clone());
}
/// Footnote indices referenced by a line's glyphs.
pub fn refs_in(&self, glyphs: &[Glyph]) -> Vec<usize> {
let mut v: Vec<usize> = Vec::new();
for g in glyphs.iter().filter(|g| g.ch == FOOTNOTE_REF && g.len > 0) {
if let Ok(k) = self.refs.binary_search(&g.byte)
&& !v.contains(&k)
{
v.push(k);
}
}
v
}
fn compose(&mut self, doc: &Document, k: usize, w: f64, f: &FrameSpec, opts: &ComposeOptions) -> (Arc<ComposedStory>, NoteMetrics) {
let o = &doc.footnote_options;
let label = format!("{}{}", o.label(self.numbers[k], true), o.separator);
let key = (k, label.clone(), w.to_bits());
if let Some(c) = self.cache.get(&key) {
return c.clone();
}
let spec = FrameSpec {
id: ItemId(0),
area: Rect::new(0.0, 0.0, w.max(1.0), 1.0e6),
opts: TextFrameOptions { first_baseline: o.first_baseline, first_baseline_min: o.first_baseline_min, ..Default::default() },
vertical: false,
exclusions: vec![],
page_name: f.page_name.clone(),
section_marker: f.section_marker.clone(),
page: f.page,
grid: None,
left_page: f.left_page,
page_rect: None,
};
let nopts = ComposeOptions {
page_name: opts.page_name.clone(),
section_marker: opts.section_marker.clone(),
page: opts.page,
running: opts.running.clone(),
label: Some(label),
xrefs: opts.xrefs.clone(),
};
let cs = crate::compose(doc, &self.notes[k].text, std::slice::from_ref(&spec), &nopts);
let lines = cs.frames.first().map(|ft| ft.lines.as_slice()).unwrap_or_default();
let m = match (lines.first(), lines.last()) {
(Some(a), Some(z)) => NoteMetrics { first: a.baseline, last: z.baseline, lead: a.leading, descent: z.descent },
_ => NoteMetrics::default(),
};
let v = (Arc::new(cs), m);
self.cache.insert(key, v.clone());
v
}
/// Height the footnotes of column (fi, col) take, with `extra` footnotes added.
#[allow(clippy::too_many_arguments)]
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();
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 }
}
pub fn place(&mut self, doc: &Document, k: usize, fi: usize, col: usize, w: f64, f: &FrameSpec, opts: &ComposeOptions) {
if self.placed.iter().any(|p| p.k == k) {
return;
}
let (text, m) = self.compose(doc, k, w, f, opts);
self.placed.push(Placed { k, fi, col, m, text });
}
/// Stack each column's footnotes against its bottom, rule above the first.
pub fn finish(&mut self, doc: &Document, story: &Story, frames: &[FrameSpec], cols: &[Vec<Rect>], out: &mut ComposedStory) {
if self.placed.is_empty() {
return;
}
let o = &doc.footnote_options;
let mut groups: Vec<(usize, usize)> = self.placed.iter().map(|p| (p.fi, 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 total = stack_height(here.iter().map(|p| &p.m), o.space_between);
let top = col.y1 - total;
let ft = &mut out.frames[fi];
if o.rule.on && o.rule.weight > 0.0 {
let x0 = col.x0 + o.rule.left_indent;
let y = top + o.rule.offset - o.rule.weight;
ft.decos.push(Deco {
rect: Rect::new(x0, y, (x0 + o.rule.width).min(col.x1), y + o.rule.weight),
color: o.rule.color.clone(),
tint: o.rule.tint,
});
}
// Previous footnote's last baseline and bottom.
let mut prev: Option<(f64, f64)> = None;
for p in here {
let Some(note) = story.notes.get(p.k) else { continue };
let (y, y0) = match prev {
None => (top, top),
Some((last, bottom)) => {
let first = last + o.space_between + p.m.lead;
(first - p.m.first, (bottom + o.space_between).min(first))
}
};
let y1 = y + p.m.last + p.m.descent;
ft.notes.push(PlacedNote {
id: note.id,
index: p.k,
column: ci 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),
text: p.text.clone(),
source: Arc::from(note.text.text.as_str()),
});
prev = Some((y + p.m.last, y1));
}
}
}
}
/// The placed footnote with id `id`: (frame index, footnote).
pub fn find_note(cs: &ComposedStory, id: u64) -> Option<(usize, &PlacedNote)> {
cs.frames.iter().enumerate().find_map(|(fi, ft)| ft.notes.iter().find(|n| n.id == id).map(|n| (fi, n)))
}
/// Footnote text hit at frame-inner point `p`: (footnote id, byte in the footnote's story).
pub fn hit_note(cs: &ComposedStory, fi: usize, p: Point) -> Option<(u64, usize)> {
hit_note_with(cs, fi, p, crate::hit)
}
/// [`hit_note`] with the hit test for the note's text (see [`crate::hit_cell_with`]).
pub fn hit_note_with(cs: &ComposedStory, fi: usize, p: Point, hit: fn(&ComposedStory, usize, Point) -> Option<usize>) -> Option<(u64, usize)> {
let ft = cs.frames.get(fi)?;
let n = ft.notes.iter().find(|n| n.rect.contains(p))?;
let local = Point::new(p.x - n.origin.x, p.y - n.origin.y);
Some((n.id, hit(&n.text, 0, local).unwrap_or(0)))
}
/// Caret geometry for byte `pos` of a footnote's story, in frame inner space:
/// (frame index, x, baseline, ascent, descent).
pub fn note_caret(cs: &ComposedStory, id: u64, pos: usize) -> Option<(usize, f64, f64, f64, f64)> {
let (fi, n) = find_note(cs, id)?;
match crate::caret(&n.text, pos) {
Some((_, x, bl, a, d)) => Some((fi, x + n.origin.x, bl + n.origin.y, a, d)),
None => Some((fi, n.origin.x, n.origin.y + 10.0, 9.0, 3.0)),
}
}