Files
designcraft/crates/compose/src/notes.rs
T
2026-10-03 00:55:34 -04:00

214 lines
9.1 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.
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,
h: f64,
text: Arc<ComposedStory>,
}
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>, f64)>,
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>, f64) {
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() },
exclusions: vec![],
page_name: f.page_name.clone(),
page: f.page,
grid: None,
left_page: f.left_page,
page_rect: None,
};
let nopts = ComposeOptions {
page_name: opts.page_name.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 h = cs.frames.first().and_then(|ft| ft.lines.last()).map_or(0.0, |l| l.baseline + l.descent);
let v = (Arc::new(cs), h);
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 mut n = 0usize;
let mut h = 0.0;
for p in self.placed.iter().filter(|p| p.fi == fi && p.col == col) {
n += 1;
h += p.h;
}
for &k in extra {
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 }
}
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, h) = self.compose(doc, k, w, f, opts);
self.placed.push(Placed { k, fi, col, h, 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: f64 = here.iter().map(|p| p.h).sum::<f64>() + o.space_between * (here.len() - 1) as f64;
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,
});
}
let mut y = top;
for p in here {
let note = &story.notes[p.k];
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, y, col.x1, y + p.h),
text: p.text.clone(),
source: Arc::from(note.text.text.as_str()),
});
y += p.h + o.space_between;
}
}
}
}
/// 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)> {
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, crate::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)),
}
}