Files
designcraft/crates/engine/src/cmd/notes.rs
T
NetterSilverandClaude Haiku 5.5 f0ce96138c Saturate footnote and endnote numbering at u32::MAX
Found while testing crates/compose/src/notes.rs:133 (also crates/doc/src/notes.rs:265 footnote_start and crates/doc/src/endnotes.rs:150 endnote_start)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 09:07:37 +00:00

236 lines
10 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Type › Insert Footnote, Document Footnote Options, footnote inspection and editing.
use designcraft_doc::{CellAddr, FootnoteOptions, ParaFormat, StoryId, TextSel};
use serde_json::{Value, json};
use super::{CommandSpec, bad, cmd, has_doc};
use crate::{Result, Session};
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"footnote.insert",
"Insert Footnote",
["Type"],
None,
"{text?} — a footnote reference at the insertion point; the caret moves into the new footnote's text",
in_story_text,
insert
),
cmd!(
"footnote.options",
"Document Footnote Options…",
["Type"],
None,
"{style?: arabic|upperRoman|lowerRoman|upperLetters|lowerLetters|symbols|kanji, startAt?, restart?: never|page|spread|section, prefix?, suffix?, affixIn?: none|reference|text|both, refPosition?: superscript|subscript|normal|otSuperscript, refCharStyle?, paraStyle?, separator?, spaceBefore?, spaceBetween?, firstBaseline?: ascent|capHeight|leading|xHeight|emboxHeight|fixed, firstBaselineMin?, spanColumns?, rule?: {on, weight, color, tint, width, offset, leftIndent}} → the options (no params: just read them)",
has_doc,
options
),
cmd!(query "footnote.list", "Footnotes", [], None, "{story?} → [{story, id, index, anchor, text}] in story order", has_doc, list),
cmd!("footnote.setText", "Set Footnote Text", [], None, "{story, id, text} — replaces the footnote's text", has_doc, set_text),
cmd!("footnote.delete", "Delete Footnote", [], None, "{story, id} — deletes the reference (and so the footnote)", has_doc, delete),
cmd!(
noundo "footnote.goToReference",
"Go to Footnote Reference",
["Type"],
None,
"{} — from footnote text, puts the caret after the footnote's reference",
in_note,
go_to_reference
),
]
}
fn in_story_text(s: &Session) -> std::result::Result<(), String> {
super::has_text(s)?;
match s.active().and_then(|d| d.selection.text) {
Some(t) if t.cell.is_some() => Err("footnotes can't be inserted in table cells or other footnotes".into()),
_ => Ok(()),
}
}
fn in_note(s: &Session) -> std::result::Result<(), String> {
super::has_text(s)?;
match s.active().and_then(|d| d.selection.text) {
Some(t) if t.cell.is_some_and(|c| c.footnote_id().is_some()) => Ok(()),
_ => Err("the insertion point is not in footnote text".into()),
}
}
fn insert(s: &mut Session, p: &Value) -> Result<Value> {
let text = super::text_param(p, "text");
s.edit(|d, sel| {
let t = sel.text.ok_or_else(|| bad("footnote.insert", "no insertion point"))?;
let para = ParaFormat { style: d.footnote_options.para_style.clone(), ..Default::default() };
let st = d.story_mut(t.story).ok_or(designcraft_doc::DocError::NoStory(t.story))?;
let r = t.range();
let r = r.start.min(st.len())..r.end.min(st.len());
st.delete(r.clone());
let id = st.insert_note(r.start, &text, para).ok_or_else(|| bad("footnote.insert", "the story's footnotes don't match their references"))?;
sel.text = Some(TextSel { story: t.story, anchor: text.len(), focus: text.len(), frame: t.frame, cell: Some(CellAddr::footnote(id)) });
Ok(json!({"story": t.story.0, "id": id}))
})
}
/// Overlay `p` onto `base` (objects merge key by key).
fn merge(base: &mut Value, p: &Value) {
match (base, p) {
(Value::Object(b), Value::Object(o)) => {
for (k, v) in o {
match b.get_mut(k) {
Some(slot) if slot.is_object() && v.is_object() => merge(slot, v),
_ => {
b.insert(k.clone(), v.clone());
}
}
}
}
(b, v) => *b = v.clone(),
}
}
fn options(s: &mut Session, p: &Value) -> Result<Value> {
let cur = s.doc()?.doc.footnote_options.clone();
if p.as_object().is_none_or(|o| o.is_empty()) {
return Ok(serde_json::to_value(&cur).unwrap_or_default());
}
let mut v = serde_json::to_value(&cur).unwrap_or_default();
merge(&mut v, p);
let next: FootnoteOptions = serde_json::from_value(v).map_err(|e| bad("footnote.options", e.to_string()))?;
let out = serde_json::to_value(&next).unwrap_or_default();
if next != cur {
s.edit(|d, _| {
d.footnote_options = next;
Ok(Value::Null)
})?;
}
Ok(out)
}
fn story_id(p: &Value, cmd: &str) -> Result<StoryId> {
p.get("story").and_then(Value::as_u64).map(StoryId).ok_or_else(|| bad(cmd, "missing story"))
}
fn note_id(p: &Value, cmd: &str) -> Result<u64> {
p.get("id").and_then(Value::as_u64).ok_or_else(|| bad(cmd, "missing id"))
}
fn list(s: &mut Session, p: &Value) -> Result<Value> {
let d = &s.doc()?.doc;
let only = p.get("story").and_then(Value::as_u64).map(StoryId);
let mut out = Vec::new();
for st in d.stories.values().filter(|st| only.is_none_or(|o| o == st.id)) {
for (k, n) in st.notes.iter().enumerate() {
out.push(json!({"story": st.id.0, "id": n.id, "index": k, "anchor": st.note_anchor(n.id), "text": n.text.text}));
}
}
Ok(Value::Array(out))
}
fn set_text(s: &mut Session, p: &Value) -> Result<Value> {
let sid = story_id(p, "footnote.setText")?;
let id = note_id(p, "footnote.setText")?;
let text = super::text_param(p, "text");
s.edit(|d, _| {
let st = d.story_mut(sid).ok_or(designcraft_doc::DocError::NoStory(sid))?;
let n = st.note_mut(id).ok_or_else(|| bad("footnote.setText", format!("no footnote {id}")))?;
let len = n.text.len();
n.text.replace(0..len, &text);
Ok(Value::Null)
})
}
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
let sid = story_id(p, "footnote.delete")?;
let id = note_id(p, "footnote.delete")?;
s.edit(|d, sel| {
let st = d.story_mut(sid).ok_or(designcraft_doc::DocError::NoStory(sid))?;
let at = st.note_anchor(id).ok_or_else(|| bad("footnote.delete", format!("no footnote {id}")))?;
st.delete(at..at + designcraft_doc::FOOTNOTE_REF.len_utf8());
if sel.text.is_some_and(|t| t.story == sid && t.cell == Some(CellAddr::footnote(id))) {
sel.text = Some(TextSel::caret(sid, at));
}
Ok(Value::Null)
})
}
fn go_to_reference(s: &mut Session, _: &Value) -> Result<Value> {
let st = s.doc_mut()?;
let t = st.selection.text.ok_or_else(|| bad("footnote.goToReference", "no caret"))?;
let id = t.cell.and_then(|c| c.footnote_id()).ok_or_else(|| bad("footnote.goToReference", "not in footnote text"))?;
let at = st.doc.story(t.story).and_then(|x| x.note_anchor(id)).ok_or_else(|| bad("footnote.goToReference", "footnote not found"))?;
let pos = at + designcraft_doc::FOOTNOTE_REF.len_utf8();
st.selection.text = Some(TextSel { anchor: pos, focus: pos, cell: None, ..t });
st.bump_revision();
Ok(json!({"story": t.story.0, "pos": pos}))
}
#[cfg(test)]
mod tests {
use super::*;
fn session_with_text() -> (Session, StoryId) {
let mut s = Session::new();
s.execute("file.new", &json!({"pages": 1})).unwrap();
let r = s.execute("frame.create", &json!({"rect": [72, 72, 300, 400], "content": "text", "text": "Body text with a note here."})).unwrap();
let sid = s.doc().unwrap().doc.item(designcraft_doc::ItemId(r["id"].as_u64().unwrap())).unwrap().text_frame().unwrap().story;
(s, sid)
}
#[test]
fn insert_type_and_undo_footnotes() {
let (mut s, sid) = session_with_text();
s.execute("text.select", &json!({"story": sid.0, "anchor": 16, "focus": 16})).unwrap();
let r = s.execute("footnote.insert", &json!({})).unwrap();
let id = r["id"].as_u64().unwrap();
// Typing goes into the footnote.
s.execute("text.insert", &json!({"text": "A source."})).unwrap();
let st = s.doc().unwrap().doc.story(sid).unwrap().clone();
assert_eq!(st.notes.len(), 1);
assert_eq!(st.notes[0].text.text, "A source.");
assert_eq!(st.text.matches(designcraft_doc::FOOTNOTE_REF).count(), 1);
// The footnote is laid out in the frame.
let d = s.doc().unwrap().doc.clone();
let cs = s.cache.get(&d, sid, None);
assert_eq!(cs.frames[0].notes.len(), 1);
// Back to the reference.
let r = s.execute("footnote.goToReference", &json!({})).unwrap();
assert_eq!(r["pos"], json!(16 + designcraft_doc::FOOTNOTE_REF.len_utf8()));
// Listing, editing, options.
let l = s.execute("footnote.list", &json!({})).unwrap();
assert_eq!(l[0]["text"], "A source.");
s.execute("footnote.setText", &json!({"story": sid.0, "id": id, "text": "Changed."})).unwrap();
assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().notes[0].text.text, "Changed.");
let o = s.execute("footnote.options", &json!({"style": "lowerRoman", "rule": {"width": 100.0}})).unwrap();
assert_eq!(o["style"], "lowerRoman");
assert_eq!(o["rule"]["width"], 100.0);
assert_eq!(o["rule"]["on"], true);
assert!(s.execute("footnote.options", &json!({"restart": "bogus"})).is_err());
// Deleting removes the reference; undo brings everything back.
s.execute("footnote.delete", &json!({"story": sid.0, "id": id})).unwrap();
assert!(s.doc().unwrap().doc.story(sid).unwrap().notes.is_empty());
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.doc().unwrap().doc.story(sid).unwrap().notes.len(), 1);
}
#[test]
fn not_in_cells() {
let (mut s, sid) = session_with_text();
s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 0})).unwrap();
s.execute("footnote.insert", &json!({"text": "x"})).unwrap();
// Caret is now in footnote text: no nested footnotes.
assert!(s.execute("footnote.insert", &json!({})).is_err());
}
#[test]
fn huge_start_at_does_not_overflow() {
let (mut s, sid) = session_with_text();
s.execute("text.select", &json!({"story": sid.0, "anchor": 16, "focus": 16})).unwrap();
s.execute("footnote.insert", &json!({"text": "x"})).unwrap();
s.execute("footnote.options", &json!({"startAt": 4294967295u32})).unwrap();
let d = s.doc().unwrap().doc.clone();
let cs = s.cache.get(&d, sid, None);
assert_eq!(cs.frames[0].notes.len(), 1);
}
}