mirror of
https://github.com/storytold/wordcraft.git
synced 2026-10-30 13:27:27 -06:00
409 lines
18 KiB
Rust
409 lines
18 KiB
Rust
use super::boxes::{BoxContext, BoxLayout, layout_box};
|
|
use super::ctx::Ctx;
|
|
use super::floats::left_out_objects;
|
|
use super::page::{PageBuilder, item_bottom};
|
|
use super::placed::Placed;
|
|
use super::*;
|
|
|
|
/// Footnote and endnote numbers (each kind counted separately), in document order, as each
|
|
/// section's options say: starting at its "start at", numbered on, or starting again in each
|
|
/// section or (footnotes) on each page. `pages` gives the page each footnote was placed on by an
|
|
/// earlier pass (empty: numbered on). With `hide_deleted`, notes whose reference mark is a
|
|
/// tracked deletion are left out, as in the final text.
|
|
pub(crate) fn note_info(doc: &Document, hide_deleted: HideDeleted, pages: &HashMap<u32, usize>) -> HashMap<u32, fields::NoteNum> {
|
|
use wordcraft_doc::section::NoteRestart;
|
|
let sections = doc.sections();
|
|
let opts: Vec<[wordcraft_doc::section::NoteOptions; 2]> =
|
|
sections.iter().map(|(_, s)| [doc.note_options(s, false), doc.note_options(s, true)]).collect();
|
|
let mut m = HashMap::new();
|
|
// Per kind (footnotes, endnotes): the last number, and the section and page of the last note.
|
|
let mut last: [Option<(u32, usize, Option<usize>)>; 2] = [None, None];
|
|
let mut order = [0u32; 2];
|
|
let mut si = 0usize;
|
|
// Reading order, text boxes included where they are. A hidden reference mark still takes
|
|
// its number, as in Word; a deleted one (or one in a deleted or hidden text box) does not,
|
|
// and `para_notes` doesn't place its note either.
|
|
let mut cur: Option<(usize, Vec<bool>)> = None;
|
|
doc.objects_in_reading_order_by_block(&mut |block, p, k| {
|
|
let key = p as *const Paragraph as usize;
|
|
if cur.as_ref().is_none_or(|(at, _)| *at != key) {
|
|
let gone = left_out_objects(doc, p, None, true, hide_deleted);
|
|
cur = Some((key, (0..p.objects.len()).map(gone).collect()));
|
|
}
|
|
if cur.as_ref().is_some_and(|(_, gone)| gone.get(k).copied().unwrap_or(false)) {
|
|
return false;
|
|
}
|
|
if let Some(InlineObject::NoteRef { kind, id, .. }) = p.objects.get(k) {
|
|
while sections.get(si).is_some_and(|(end, _)| block > *end) && si + 1 < sections.len() {
|
|
si += 1;
|
|
}
|
|
let ki = usize::from(*kind == wordcraft_doc::para::NoteKind::Endnote);
|
|
let Some(o) = opts.get(si).and_then(|o| o.get(ki)) else { return true };
|
|
let page = if ki == 0 { pages.get(id).copied() } else { None };
|
|
let Some(slot) = last.get_mut(ki) else { return true };
|
|
let n = match *slot {
|
|
None => o.num_start,
|
|
Some((prev, psect, ppage)) => {
|
|
let again = match o.num_restart {
|
|
NoteRestart::Continuous => false,
|
|
NoteRestart::EachSect => psect != si,
|
|
NoteRestart::EachPage => page.is_some() && page != ppage,
|
|
};
|
|
if again { o.num_start } else { prev.saturating_add(1) }
|
|
}
|
|
};
|
|
*slot = Some((n, si, page));
|
|
let ord = order.get_mut(ki).map(|c| {
|
|
*c = c.saturating_add(1);
|
|
*c
|
|
});
|
|
m.insert(*id, fields::NoteNum { n, fmt: o.num_fmt, section: si, order: ord.unwrap_or(0) });
|
|
}
|
|
true
|
|
});
|
|
m
|
|
}
|
|
|
|
/// Note part ids → numbers, numbered on (see [`note_info`]).
|
|
#[cfg(test)]
|
|
pub(crate) fn note_numbers(doc: &Document, hide_deleted: HideDeleted) -> HashMap<u32, u32> {
|
|
note_info(doc, hide_deleted, &HashMap::new()).into_iter().map(|(id, n)| (id, n.n)).collect()
|
|
}
|
|
|
|
/// The page each footnote's text starts on.
|
|
pub(crate) fn footnote_pages(doc: &Document, pages: &[Page]) -> HashMap<u32, usize> {
|
|
let mut m = HashMap::new();
|
|
for (pi, p) in pages.iter().enumerate() {
|
|
for it in &p.items {
|
|
if let Placed::Lines { story: StoryRef::Part(id), .. } | Placed::Cell { story: StoryRef::Part(id), .. } = it
|
|
&& doc.parts.get(id).is_some_and(|part| part.kind == wordcraft_doc::PartKind::Footnote)
|
|
{
|
|
m.entry(*id).or_insert(pi);
|
|
}
|
|
}
|
|
}
|
|
m
|
|
}
|
|
|
|
/// Gap above the footnote separator and its length.
|
|
pub(crate) const NOTE_SEP: f32 = 12.0;
|
|
|
|
/// Length of the default (short) note separator line.
|
|
pub(crate) const NOTE_SEP_W: f32 = 144.0;
|
|
|
|
/// The story of text drawn on a page that belongs to no story of the document (line numbers,
|
|
/// note separators and continuation notices): not hit by clicks.
|
|
pub const DECOR_STORY: StoryRef = StoryRef::Part(u32::MAX);
|
|
|
|
/// A note separator or continuation notice laid out from y = 0 (its items and height).
|
|
pub(crate) type SepBox = (Vec<Placed>, f32);
|
|
|
|
/// The document's own footnote or endnote separators and continuation notice, laid out (`None`:
|
|
/// the defaults).
|
|
#[derive(Clone, Default)]
|
|
pub(crate) struct NoteSeps {
|
|
pub(crate) sep: Option<SepBox>,
|
|
pub(crate) cont: Option<SepBox>,
|
|
pub(crate) notice: Option<SepBox>,
|
|
}
|
|
|
|
impl NoteSeps {
|
|
/// Lay out `seps` `width` wide.
|
|
pub(crate) fn new(ctx: &mut Ctx, seps: &wordcraft_doc::NoteSeparators, width: f32) -> NoteSeps {
|
|
let mut lay = |blocks: &Option<Blocks>| blocks.as_ref().map(|b| sep_box(ctx, b, width));
|
|
NoteSeps { sep: lay(&seps.separator), cont: lay(&seps.continuation_separator), notice: lay(&seps.continuation_notice) }
|
|
}
|
|
/// Height of the separator above a page's notes (`cont`: the continuation separator, when the
|
|
/// first is continued from the page before).
|
|
pub(crate) fn sep_h(&self, cont: bool) -> f32 {
|
|
let own = if cont { &self.cont } else { &self.sep };
|
|
own.as_ref().map_or(NOTE_SEP, |s| s.1)
|
|
}
|
|
/// Height of the notice below a note that continues on the next page.
|
|
pub(crate) fn notice_h(&self) -> f32 {
|
|
self.notice.as_ref().map_or(0.0, |s| s.1)
|
|
}
|
|
}
|
|
|
|
/// A note separator or continuation notice story (`blocks`) laid out `width` wide.
|
|
fn sep_box(ctx: &mut Ctx, blocks: &Blocks, width: f32) -> SepBox {
|
|
let bl = layout_box(ctx, DECOR_STORY, blocks, &[], width, BoxContext::Other, 0, None);
|
|
let mut items = bl.items;
|
|
items.extend(separator_rules(blocks, &items));
|
|
(items, bl.height)
|
|
}
|
|
|
|
/// The lines drawn by the note separators (see [`InlineObject::note_separator`]) in a separator
|
|
/// story's laid-out `items` (`blocks` its paragraphs): across each one's place in its line, half
|
|
/// way down the line.
|
|
fn separator_rules(blocks: &Blocks, items: &[Placed]) -> Vec<Placed> {
|
|
let mut rules = Vec::new();
|
|
for it in items {
|
|
let Placed::Lines { para, path, l0, l1, x, y, turn: TextDirection::Horizontal, .. } = it else { continue };
|
|
let [i] = path.0.as_slice() else { continue };
|
|
let Some(Block::Para(p)) = blocks.get(*i as usize).map(|b| &**b) else { continue };
|
|
let Some(first) = para.lines.get(*l0) else { continue };
|
|
for line in para.lines.get(*l0..*l1).unwrap_or(&[]) {
|
|
for k in line.c0..line.c1 {
|
|
let Some(para::ClKind::Object(oi)) = para.clusters.get(k).map(|c| c.kind) else { continue };
|
|
if p.objects.get(oi).and_then(InlineObject::as_note_separator).is_none() {
|
|
continue;
|
|
}
|
|
let (Some(a), Some(b)) = (line.cl_left(k), line.cl_right(k)) else { continue };
|
|
let ry = y + line.top - first.top + line.height / 2.0;
|
|
rules.push(Placed::Rule { x0: x + a.min(b), y0: ry, x1: x + a.max(b), y1: ry, border: Border::single(0.5) });
|
|
}
|
|
}
|
|
}
|
|
rules
|
|
}
|
|
|
|
/// The most pages added after the body for footnotes still continuing.
|
|
pub(crate) const MAX_NOTE_PAGES: usize = 10_000;
|
|
|
|
/// A footnote waiting for the bottom of a page: its items laid out from y = 0, and its height.
|
|
#[derive(Clone)]
|
|
pub(crate) struct NoteBox {
|
|
pub(crate) id: u32,
|
|
pub(crate) items: Vec<Placed>,
|
|
pub(crate) h: f32,
|
|
/// The rest of a note that didn't fit on an earlier page (drawn below the continuation
|
|
/// separator).
|
|
pub(crate) cont: bool,
|
|
}
|
|
|
|
/// The vertical extent of an item laid out in a horizontal frame.
|
|
pub(crate) fn v_span(it: &Placed) -> (f32, f32) {
|
|
if let Some(r) = it.turned_bounds() {
|
|
return (r.y, r.y + r.h);
|
|
}
|
|
match it {
|
|
Placed::Lines { para, l0, l1, y, .. } => (*y, item_bottom(*y, para, *l0, *l1).unwrap_or(*y)),
|
|
Placed::Fill { rect, .. }
|
|
| Placed::Image { rect, .. }
|
|
| Placed::Shape { rect, .. }
|
|
| Placed::Graphic { rect, .. }
|
|
| Placed::Clip { rect, .. }
|
|
| Placed::Cell { rect, .. }
|
|
| Placed::Object { rect, .. } => (rect.y, rect.y + rect.h),
|
|
Placed::Rule { y0, y1, .. } => (y0.min(*y1), y0.max(*y1)),
|
|
}
|
|
}
|
|
|
|
impl NoteBox {
|
|
/// Lines of the note's own paragraphs, which may be split between pages (not those of its
|
|
/// tables or text boxes).
|
|
fn splittable(&self, it: &Placed) -> bool {
|
|
matches!(it, Placed::Lines { story: StoryRef::Part(s), path, turn: TextDirection::Horizontal, .. } if *s == self.id && path.0.len() == 1)
|
|
}
|
|
|
|
/// Where the note may be split, ascending: the bottoms of its lines that have more lines
|
|
/// below them and don't cut through a picture, table or other unsplittable item.
|
|
pub(crate) fn cuts(&self) -> Vec<f32> {
|
|
let blocked: Vec<(f32, f32)> =
|
|
self.items.iter().filter(|it| !self.splittable(it) && !matches!(it, Placed::Fill { .. } | Placed::Rule { .. })).map(v_span).collect();
|
|
let mut last_top = f32::MIN;
|
|
let mut cuts = Vec::new();
|
|
for it in &self.items {
|
|
let Placed::Lines { para, l0, l1, y, .. } = it else { continue };
|
|
if !self.splittable(it) {
|
|
continue;
|
|
}
|
|
let Some(first) = para.lines.get(*l0) else { continue };
|
|
for l in para.lines.get(*l0..*l1).unwrap_or(&[]) {
|
|
last_top = last_top.max(y + l.top - first.top);
|
|
cuts.push(y + l.top - first.top + l.height);
|
|
}
|
|
}
|
|
cuts.retain(|&b| b > 0.01 && b <= last_top + 0.01 && !blocked.iter().any(|&(t, e)| t < b - 0.01 && e > b + 0.01));
|
|
cuts.sort_by(f32::total_cmp);
|
|
cuts.dedup_by(|a, b| (*a - *b).abs() < 0.01);
|
|
cuts
|
|
}
|
|
|
|
/// The least of the note that must stay with its reference: its first line (all of it if it
|
|
/// can't be split).
|
|
pub(crate) fn first_piece(&self) -> f32 {
|
|
self.cuts().first().copied().unwrap_or(self.h).min(self.h)
|
|
}
|
|
|
|
/// Split the note at line bottom `cut`: the part above (`cut` tall) and the rest, moved up to
|
|
/// start at 0 and marked as a continuation.
|
|
pub(crate) fn split(self, cut: f32) -> (NoteBox, NoteBox) {
|
|
let (mut head, mut tail) = (Vec::new(), Vec::new());
|
|
for it in self.items {
|
|
let (top, bottom) = v_span(&it);
|
|
if bottom <= cut + 0.01 {
|
|
head.push(it);
|
|
continue;
|
|
}
|
|
if top >= cut - 0.01 {
|
|
tail.push(it);
|
|
continue;
|
|
}
|
|
match it {
|
|
Placed::Lines { story, path, para, l0, l1, x, y, turn } => {
|
|
let Some(first) = para.lines.get(l0) else { continue };
|
|
let ft = first.top;
|
|
// The first line that ends below the cut starts the tail.
|
|
let k = (l0..l1).find(|&k| para.lines.get(k).is_none_or(|l| y + l.top - ft + l.height > cut + 0.01)).unwrap_or(l1);
|
|
let ky = para.lines.get(k).map_or(y, |l| y + l.top - ft);
|
|
if k > l0 {
|
|
head.push(Placed::Lines { story, path: path.clone(), para: para.clone(), l0, l1: k, x, y, turn });
|
|
}
|
|
if k < l1 {
|
|
tail.push(Placed::Lines { story, path, para, l0: k, l1, x, y: ky, turn });
|
|
}
|
|
}
|
|
Placed::Fill { rect, color } => {
|
|
head.push(Placed::Fill { rect: Rect::new(rect.x, rect.y, rect.w, cut - rect.y), color });
|
|
tail.push(Placed::Fill { rect: Rect::new(rect.x, cut, rect.w, rect.y + rect.h - cut), color });
|
|
}
|
|
Placed::Rule { x0, y0, x1, y1, border } => {
|
|
head.push(Placed::Rule { x0, y0: y0.min(cut), x1, y1: y1.min(cut), border });
|
|
tail.push(Placed::Rule { x0, y0: y0.max(cut), x1, y1: y1.max(cut), border });
|
|
}
|
|
// Not split (cuts avoid them): stays above.
|
|
other => head.push(other),
|
|
}
|
|
}
|
|
// The rest starts at its first line (the space between paragraphs goes with the break).
|
|
let start = tail.iter().filter(|it| matches!(it, Placed::Lines { .. })).map(|it| v_span(it).0).fold(f32::MAX, f32::min);
|
|
let start = if start == f32::MAX { cut } else { start.max(cut) };
|
|
for it in &mut tail {
|
|
it.translate(0.0, -start);
|
|
// Shading cut at the break starts at the rest's top.
|
|
if let Placed::Fill { rect, .. } = it
|
|
&& rect.y < 0.0
|
|
{
|
|
rect.h = (rect.h + rect.y).max(0.0);
|
|
rect.y = 0.0;
|
|
}
|
|
}
|
|
let rest = (self.h - start).max(0.0);
|
|
(NoteBox { id: self.id, items: head, h: cut, cont: self.cont }, NoteBox { id: self.id, items: tail, h: rest, cont: true })
|
|
}
|
|
}
|
|
|
|
/// Passes the layout makes at most for footnotes numbered again on each page: their numbers
|
|
/// depend on the pages they land on, which (barely) depend on the numbers' widths.
|
|
pub(crate) const MAX_NOTE_PASSES: usize = 3;
|
|
|
|
/// Place endnotes `ids` below the text so far: a separator (the document's own, else a short
|
|
/// line), then each note, moving to the next column or page when one doesn't fit.
|
|
pub(crate) fn place_endnotes(ctx: &mut Ctx, pb: &mut PageBuilder, ids: Vec<u32>, block: usize) {
|
|
if ids.is_empty() || pb.web {
|
|
return;
|
|
}
|
|
// The document's own endnote separator, else a short line.
|
|
let width = pb.col_w();
|
|
if let Some((items, h)) = ctx.doc.endnote_separators.separator.as_ref().map(|b| sep_box(ctx, b, width)) {
|
|
let (x, y) = (pb.col_x(), pb.y);
|
|
if let Some(pg) = pb.page() {
|
|
pg.items.extend(items.into_iter().map(|mut it| {
|
|
it.translate(x, y);
|
|
it
|
|
}));
|
|
}
|
|
pb.y += h;
|
|
} else {
|
|
pb.y += 12.0;
|
|
let (x, ry) = (pb.col_x(), pb.y);
|
|
if let Some(pg) = pb.page() {
|
|
pg.items.push(Placed::Rule { x0: x, y0: ry, x1: x + NOTE_SEP_W, y1: ry, border: Border::single(0.5) });
|
|
}
|
|
pb.y += 8.0;
|
|
}
|
|
let mut added = 0usize;
|
|
for id in ids {
|
|
let Some(part) = ctx.doc.parts.get(&id) else { continue };
|
|
let blocks = part.blocks.clone();
|
|
let BoxLayout { items, height: h, .. } = layout_box(ctx, StoryRef::Part(id), &blocks, &[], pb.col_w(), BoxContext::Other, 0, None);
|
|
// A note longer than the room left continues at a line break in the next column or on
|
|
// a new page (Word). Each page takes at least a line, so this ends; the page cap is a
|
|
// backstop (the rest then runs off the last page).
|
|
let mut note = NoteBox { id, items, h, cont: false };
|
|
loop {
|
|
let room = pb.bottom - pb.y;
|
|
let cut = if note.h <= room + 0.01 || added >= MAX_NOTE_PAGES {
|
|
None
|
|
} else {
|
|
let cuts = note.cuts();
|
|
match cuts.iter().rev().find(|&&c| c <= room + 0.01).or(cuts.first().filter(|_| pb.at_top())) {
|
|
Some(&c) => Some(c),
|
|
None if pb.at_top() => None,
|
|
None => {
|
|
pb.advance(block, pb.top);
|
|
added += 1;
|
|
continue;
|
|
}
|
|
}
|
|
};
|
|
let (head, rest) = match cut {
|
|
Some(c) => {
|
|
let (head, rest) = note.split(c);
|
|
(head, Some(rest))
|
|
}
|
|
None => (note, None),
|
|
};
|
|
let (x, y) = (pb.col_x(), pb.y);
|
|
if let Some(pg) = pb.page() {
|
|
for mut it in head.items {
|
|
it.translate(x, y);
|
|
pg.items.push(it);
|
|
}
|
|
}
|
|
pb.y += head.h;
|
|
let Some(rest) = rest else { break };
|
|
note = rest;
|
|
pb.advance(block, pb.top);
|
|
added += 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Footnotes referenced in a paragraph laid out as `pl`: in its text, and inside its text boxes
|
|
/// (on the box's cluster), as (cluster, note id).
|
|
pub(crate) fn para_notes(doc: &Document, p: &Paragraph, pl: &ParaLayout) -> Vec<(usize, u32)> {
|
|
let mut notes = pl.notes.clone();
|
|
if p.objects.iter().any(|o| !o.text_boxes().is_empty()) {
|
|
for (ci, c) in pl.clusters.iter().enumerate() {
|
|
if let para::ClKind::Object(oi) = c.kind
|
|
&& let Some(o) = p.objects.get(oi).filter(|o| !o.frame().is_some_and(|(_, _, f)| f.hidden))
|
|
{
|
|
for id in o.text_boxes() {
|
|
notes.extend(doc.notes_in_text_box(id).into_iter().map(|n| (ci, n)));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
notes
|
|
}
|
|
|
|
/// Footnotes referenced in the cell lines of a placed table row (or part of one) go to the
|
|
/// bottom of the page, like those of body paragraphs; notes that don't fit there continue on
|
|
/// the next page (see [`PageBuilder::flush_notes`]).
|
|
pub(crate) fn table_notes(ctx: &mut Ctx, pb: &mut PageBuilder, items: &[Placed]) {
|
|
if pb.web {
|
|
return;
|
|
}
|
|
let mut ids: Vec<u32> = Vec::new();
|
|
for it in items {
|
|
let Placed::Lines { story: StoryRef::Body, para, l0, l1, .. } = it else { continue };
|
|
let (Some(a), Some(b)) = (para.lines.get(*l0), para.lines.get(l1.saturating_sub(1))) else { continue };
|
|
for (ci, id) in ¶.notes {
|
|
if *ci >= a.c0 && *ci < b.c1 && !ids.contains(id) && !pb.notes.iter().any(|n| n.id == *id) {
|
|
ids.push(*id);
|
|
}
|
|
}
|
|
}
|
|
let before = pb.notes_h();
|
|
for id in ids {
|
|
let Some(part) = ctx.doc.parts.get(&id).filter(|p| p.kind == wordcraft_doc::PartKind::Footnote) else { continue };
|
|
let blocks = part.blocks.clone();
|
|
let BoxLayout { items, height: h, .. } = layout_box(ctx, StoryRef::Part(id), &blocks, &[], pb.sect.text_width(), BoxContext::Other, 0, None);
|
|
pb.notes.push(NoteBox { id, items, h, cont: false });
|
|
}
|
|
pb.bottom -= pb.notes_h() - before;
|
|
}
|