mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-26 11:16:50 -06:00
expect() calls become let-else / fallbacks. A parent spread without pages (possible in a .designcraft file) no longer underflows in parent_page_for, and check() rejects a next_id so large that allocating ids would overflow. Both crates deny the panicking lints. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
185 lines
6.9 KiB
Rust
185 lines
6.9 KiB
Rust
//! Endnotes (Type › Insert Endnote).
|
||
//!
|
||
//! An endnote reference is an [`ENDNOTE_REF`] character; the story's [`Story::endnotes`] holds one
|
||
//! entry per reference in text order (like footnotes). Numbering runs through the document in
|
||
//! page order. The endnote frame shows a story that is generated from every endnote: a heading
|
||
//! and one numbered paragraph per endnote ([`Document::endnote_text`]); its text isn't typed
|
||
//! into, endnotes are edited through their references.
|
||
|
||
use std::sync::Arc;
|
||
|
||
use serde::{Deserialize, Serialize};
|
||
|
||
use crate::attrs::NumberStyle;
|
||
use crate::ids::StoryId;
|
||
use crate::notes::Footnote;
|
||
use crate::story::{BASIC_PARAGRAPH, CharFormat, ParaFormat, Story};
|
||
|
||
/// Endnote reference character (the number shown in the text).
|
||
pub const ENDNOTE_REF: char = '\u{E00F}';
|
||
|
||
/// Editorial note anchor (Type › Notes): never printed; the note's text is in
|
||
/// [`Story::editorial`], one per anchor in text order.
|
||
pub const NOTE_MARK: char = '\u{E010}';
|
||
|
||
/// An editorial note.
|
||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||
#[serde(rename_all = "camelCase")]
|
||
pub struct EditorialNote {
|
||
pub id: u64,
|
||
pub author: String,
|
||
pub text: String,
|
||
}
|
||
|
||
/// Document Endnote Options.
|
||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||
#[serde(rename_all = "camelCase", default)]
|
||
pub struct EndnoteOptions {
|
||
pub style: NumberStyle,
|
||
pub start_at: u32,
|
||
pub prefix: String,
|
||
pub suffix: String,
|
||
/// Endnote frame heading (empty: none) and its paragraph style.
|
||
pub heading: String,
|
||
pub heading_style: String,
|
||
/// Endnote entries' paragraph style.
|
||
pub para_style: String,
|
||
/// Between the number and the endnote text.
|
||
pub separator: String,
|
||
}
|
||
|
||
impl Default for EndnoteOptions {
|
||
fn default() -> Self {
|
||
EndnoteOptions {
|
||
style: NumberStyle::Arabic,
|
||
start_at: 1,
|
||
prefix: String::new(),
|
||
suffix: String::new(),
|
||
heading: "Endnotes".into(),
|
||
heading_style: BASIC_PARAGRAPH.into(),
|
||
para_style: BASIC_PARAGRAPH.into(),
|
||
separator: "\t".into(),
|
||
}
|
||
}
|
||
}
|
||
|
||
impl EndnoteOptions {
|
||
pub fn label(&self, n: u32) -> String {
|
||
format!("{}{}{}", self.prefix, self.style.format(n), self.suffix)
|
||
}
|
||
}
|
||
|
||
impl Story {
|
||
/// Index (in [`Story::endnotes`]) of the endnote whose reference is the k-th before `pos`.
|
||
pub fn endnotes_before(&self, pos: usize) -> usize {
|
||
let pos = crate::story::floor_char_boundary(&self.text, pos);
|
||
self.text[..pos].matches(ENDNOTE_REF).count()
|
||
}
|
||
|
||
pub fn endnote(&self, id: u64) -> Option<&Footnote> {
|
||
self.endnotes.iter().find(|n| n.id == id).map(|n| &**n)
|
||
}
|
||
|
||
/// Mutable endnote (copy-on-write); bumps the story revision.
|
||
pub fn endnote_mut(&mut self, id: u64) -> Option<&mut Footnote> {
|
||
let n = self.endnotes.iter_mut().find(|n| n.id == id)?;
|
||
self.rev += 1;
|
||
Some(Arc::make_mut(n))
|
||
}
|
||
|
||
/// Insert an endnote reference at `pos` with the given text. Returns the endnote id.
|
||
pub fn insert_endnote(&mut self, pos: usize, text: &str, para: ParaFormat) -> u64 {
|
||
let pos = crate::story::floor_char_boundary(&self.text, pos.min(self.len()));
|
||
let fmt = self.char_format_at(pos).clone();
|
||
self.insert_with(pos, &ENDNOTE_REF.to_string(), fmt);
|
||
let k = self.endnotes_before(pos);
|
||
let note = Arc::make_mut(&mut self.endnotes[k]);
|
||
note.text = Story::with_text(StoryId(0), text, para);
|
||
note.id
|
||
}
|
||
|
||
/// Insert empty endnotes for `n` new reference characters before which `k` references sit.
|
||
pub(crate) fn endnotes_inserted(&mut self, k: usize, n: usize) {
|
||
for i in 0..n {
|
||
let id = self.endnotes.iter().map(|x| x.id).max().unwrap_or(0) + 1;
|
||
let mut text = Story::new(StoryId(0));
|
||
text.chars[0].format = CharFormat::default();
|
||
self.endnotes.insert((k + i).min(self.endnotes.len()), Arc::new(Footnote { id, text }));
|
||
}
|
||
}
|
||
|
||
pub(crate) fn check_endnotes(&self) -> Result<(), String> {
|
||
let n = self.text.matches(ENDNOTE_REF).count();
|
||
if n != self.endnotes.len() {
|
||
return Err(format!("story {}: {n} endnote references, {} endnotes", self.id.0, self.endnotes.len()));
|
||
}
|
||
Ok(())
|
||
}
|
||
}
|
||
|
||
impl crate::Document {
|
||
/// Stories with endnotes in numbering order (page of their first frame, then id).
|
||
fn endnote_order(&self) -> Vec<&Story> {
|
||
let key = |st: &Story| -> (usize, u64) { (st.frames.first().and_then(|f| self.page_of_item(*f)).unwrap_or(usize::MAX), st.id.0) };
|
||
let mut v: Vec<&Story> = self.stories.values().map(|s| s.as_ref()).filter(|s| !s.endnotes.is_empty()).collect();
|
||
v.sort_by_key(|s| key(s));
|
||
v
|
||
}
|
||
|
||
/// Number of the first endnote of story `sid`.
|
||
pub fn endnote_start(&self, sid: StoryId) -> u32 {
|
||
let mut n = self.endnote_options.start_at;
|
||
for st in self.endnote_order() {
|
||
if st.id == sid {
|
||
break;
|
||
}
|
||
n += st.endnotes.len() as u32;
|
||
}
|
||
n
|
||
}
|
||
|
||
/// The endnote frame's text: the heading, then "number separator text" per endnote, in order.
|
||
pub fn endnote_text(&self, id: StoryId) -> Story {
|
||
let o = &self.endnote_options;
|
||
let para = |style: &str| ParaFormat { style: style.to_string(), ..Default::default() };
|
||
let mut out = Story::with_text(id, &o.heading, para(&o.heading_style));
|
||
let mut n = o.start_at;
|
||
for st in self.endnote_order() {
|
||
for note in &st.endnotes {
|
||
let at = out.len();
|
||
let first = at == 0 && o.heading.is_empty();
|
||
if !first {
|
||
out.insert(at, "\n");
|
||
}
|
||
let pi = out.paras.len() - 1;
|
||
out.paras[pi] = para(&o.para_style);
|
||
let at = out.len();
|
||
out.insert_with(at, &format!("{}{}", o.label(n), o.separator), CharFormat::default());
|
||
for (r, f) in note.text.runs() {
|
||
let at = out.len();
|
||
out.insert_with(at, ¬e.text.text[r].replace('\n', " "), f.clone());
|
||
}
|
||
n += 1;
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
/// Bring the endnote frame's story up to date with the endnotes (after any edit).
|
||
pub fn sync_endnote_story(&mut self) {
|
||
let Some(sid) = self.endnote_story else { return };
|
||
let Some(cur) = self.stories.get(&sid) else {
|
||
self.endnote_story = None;
|
||
return;
|
||
};
|
||
let fresh = self.endnote_text(sid);
|
||
if cur.text == fresh.text && cur.paras == fresh.paras && cur.chars == fresh.chars {
|
||
return;
|
||
}
|
||
let Some(st) = self.stories.get_mut(&sid) else { return };
|
||
let st = Arc::make_mut(st);
|
||
let (frames, rev) = (std::mem::take(&mut st.frames), st.rev);
|
||
*st = Story { frames, rev: rev + 1, ..fresh };
|
||
}
|
||
}
|