Footnote and Endnote options per section (#385) (#390)

- Model: NoteProps (pos, numFmt, numStart, numRestart) in the settings and
  each section; Document::note_options resolves section > document > defaults.
- DOCX: w:footnotePr / w:endnotePr read and written in settings and every
  w:sectPr in schema order; hostile numStart clamped (ECMA-376 §17.11).
- Layout: footnotes below the text, endnotes at the end of each section,
  numbering restarted per section or per page (from where notes land, at
  most three passes), per-section number formats.
- Commands: references.noteOptions takes kind/pos/numFmt/numStart/
  numRestart/scope; references.convertNotes (footnote/endnote/swap, one
  undo step); references.footnote/endnote take a custom mark.
- UI: Footnote and Endnote dialog from the Footnotes group launcher;
  strings in every catalog.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Valentin Klein
2026-10-11 15:10:17 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent f8f1d80c38
commit 4e710a920b
37 changed files with 1099 additions and 107 deletions
+3 -10
View File
@@ -358,16 +358,9 @@ pub fn specs() -> Vec<CommandSpec> {
}),
CommandSpec::new("insert.crossReference", "Cross-reference", "Insert › Links", cross_ref)
.params(r#"{"to": "heading|bookmark|figure|table", "target": string (text / name / number), "show"?: "text|page|number|aboveBelow"} or {} to list targets"#),
CommandSpec::new("references.noteOptions", "Footnote and Endnote", "References › Footnotes", |s, v| {
if let Some(f) = p::str(v, "footnoteFormat") {
s.doc.settings.footnote_format = wordcraft_doc::section::NumFormat::from_ooxml(f);
}
if let Some(f) = p::str(v, "endnoteFormat") {
s.doc.settings.endnote_format = wordcraft_doc::section::NumFormat::from_ooxml(f);
}
Ok(json!({"footnoteFormat": s.doc.settings.footnote_format, "endnoteFormat": s.doc.settings.endnote_format}))
})
.params(r#"{"footnoteFormat"?: "decimal|lowerRoman|upperRoman|lowerLetter|upperLetter", "endnoteFormat"?: …}"#),
CommandSpec::new("references.noteOptions", "Footnote and Endnote", "References › Footnotes", super::references::note_options).params(
r#"{"kind"?: "footnote|endnote", "pos"?: "pageBottom|beneathText" (footnotes) | "sectEnd|docEnd" (endnotes), "numFmt"?: "decimal|lowerRoman|upperRoman|lowerLetter|upperLetter|…", "numStart"?: 1-32767, "numRestart"?: "continuous|eachSect|eachPage" (each page: footnotes only), "scope"?: "document|section", "footnoteFormat"?: …, "endnoteFormat"?: …} — no settings: the options in effect at the caret"#,
),
CommandSpec::new("references.researcher", "Researcher", "References › Research", |s, _| {
s.status = "Researcher needs an online service; WordCraft keeps your documents offline.".into();
Ok(json!({"available": false}))
+1 -1
View File
@@ -103,7 +103,7 @@ pub fn sect(s: &Session) -> SectionProps {
}
/// Apply to the section(s) of the selection.
fn with_sect(s: &mut Session, f: impl Fn(&mut SectionProps)) -> CmdResult {
pub(crate) fn with_sect(s: &mut Session, f: impl Fn(&mut SectionProps)) -> CmdResult {
let (a, b) = s.sel.ordered();
let (i0, i1) = (a.path.0.first().copied().unwrap_or(0) as usize, b.path.0.first().copied().unwrap_or(0) as usize);
let ends: Vec<usize> = s.doc.sections().iter().map(|(e, _)| *e).collect();
+114 -8
View File
@@ -32,10 +32,21 @@ pub fn specs() -> Vec<CommandSpec> {
.params(r#"{"level": 0 (do not show) | 1-9}"#),
CommandSpec::new("references.footnote", "Insert Footnote", "References › Footnotes", |s, v| note(s, v, NoteKind::Footnote))
.key("Mod+Alt+F")
.params(r#"{"text"?: string}"#),
.params(r#"{"text"?: string, "mark"?: string (a custom mark instead of the number)}"#),
CommandSpec::new("references.endnote", "Insert Endnote", "References › Footnotes", |s, v| note(s, v, NoteKind::Endnote))
.key("Mod+Alt+D")
.params(r#"{"text"?: string}"#),
.params(r#"{"text"?: string, "mark"?: string (a custom mark instead of the number)}"#),
CommandSpec::new("references.convertNotes", "Convert Notes", "References › Footnotes", |s, v| {
let (foot, end) = match p::req_str(v, "to")? {
"endnote" | "endnotes" => (true, false),
"footnote" | "footnotes" => (false, true),
"swap" => (true, true),
t => return Err(CmdError::Params(format!("unknown `to` {t:?}; use endnote, footnote or swap"))),
};
let n = s.doc.convert_notes(foot, end);
Ok(json!({"converted": n}))
})
.params(r#"{"to": "endnote" (footnotes become endnotes) | "footnote" (endnotes become footnotes) | "swap"}"#),
CommandSpec::new("references.nextFootnote", "Next Footnote", "References › Footnotes", |s, _| {
let caret = s.sel.focus.clone();
let refs = note_refs(s);
@@ -193,6 +204,8 @@ pub fn update_toc(s: &mut Session) -> Result<(), CmdError> {
fn note(s: &mut Session, v: &Value, kind: NoteKind) -> CmdResult {
let text = p::str(v, "text").unwrap_or("").to_string();
// A custom mark: a few characters, as Word's dialog takes.
let custom: String = p::str(v, "mark").unwrap_or("").trim().chars().filter(|c| !c.is_control()).take(10).collect();
let (pk, style, refstyle) = match kind {
NoteKind::Footnote => (PartKind::Footnote, "FootnoteText", "FootnoteReference"),
NoteKind::Endnote => (PartKind::Endnote, "EndnoteText", "EndnoteReference"),
@@ -201,23 +214,116 @@ fn note(s: &mut Session, v: &Value, kind: NoteKind) -> CmdResult {
let id = s.doc.add_part(pk, Vec::new());
para.insert_object(
0,
InlineObject::NoteRef { kind, id, custom: String::new() },
InlineObject::NoteRef { kind, id, custom: custom.clone() },
&CharProps { style: Some(refstyle.into()), ..Default::default() },
)?;
let len = para.len();
para.insert_text(len, &format!(" {text}"), &CharProps::default())?;
s.doc.set_story(StoryRef::Part(id), vec![para_block(para)])?;
let at = delete_selection(s)?;
s.doc.insert_object(
&at,
InlineObject::NoteRef { kind, id, custom: String::new() },
&CharProps { style: Some(refstyle.into()), ..Default::default() },
)?;
s.doc.insert_object(&at, InlineObject::NoteRef { kind, id, custom }, &CharProps { style: Some(refstyle.into()), ..Default::default() })?;
// Like Word, the caret moves into the new note.
s.sel = Selection::caret(s.doc.end_of(StoryRef::Part(id)));
Ok(json!({"id": id}))
}
/// `references.noteOptions`: footnote or endnote placement, number format, start and restart,
/// for the whole document (each section then follows it) or the selection's sections.
pub(super) fn note_options(s: &mut Session, v: &Value) -> CmdResult {
use wordcraft_doc::section::{MAX_NOTE_START, NotePos, NoteProps, NoteRestart, NumFormat};
let fmt = |k: &str| -> Result<Option<NumFormat>, CmdError> {
match p::str(v, k) {
None => Ok(None),
Some(f) if NumFormat::from_ooxml(f).ooxml() == f => Ok(Some(NumFormat::from_ooxml(f))),
Some(f) => Err(CmdError::Params(format!("unknown number format {f:?}"))),
}
};
// The earlier form: the document's number formats.
let (old_foot, old_end) = (fmt("footnoteFormat")?, fmt("endnoteFormat")?);
let endnote = match p::str(v, "kind") {
None | Some("footnote") => false,
Some("endnote") => true,
Some(k) => return Err(CmdError::Params(format!("unknown `kind` {k:?}; use footnote or endnote"))),
};
let pos = match p::str(v, "pos") {
None => None,
Some(x) => match NotePos::from_ooxml(x) {
Some(p @ (NotePos::PageBottom | NotePos::BeneathText)) if !endnote => Some(p),
Some(p @ (NotePos::SectEnd | NotePos::DocEnd)) if endnote => Some(p),
_ => {
let ok = if endnote { "sectEnd or docEnd" } else { "pageBottom or beneathText" };
return Err(CmdError::Params(format!("`pos` {x:?} doesn't apply here; use {ok}")));
}
},
};
let num_restart = match p::str(v, "numRestart") {
None => None,
Some(r) => match NoteRestart::from_ooxml(r) {
Some(NoteRestart::EachPage) if endnote => return Err(CmdError::Params("endnotes can't start again on each page".into())),
Some(r) => Some(r),
None => return Err(CmdError::Params(format!("unknown `numRestart` {r:?}; use continuous, eachSect or eachPage"))),
},
};
let change = NoteProps {
pos,
num_fmt: fmt("numFmt")?,
num_start: p::f32(v, "numStart").map(|n| n.round().clamp(1.0, MAX_NOTE_START as f32) as u32),
num_restart,
};
let whole = match p::str(v, "scope") {
None | Some("document") => true,
Some("section") => false,
Some(x) => return Err(CmdError::Params(format!("unknown `scope` {x:?}; use document or section"))),
};
// Set `from`'s fields on `to`; `clear` instead unsets the fields `from` sets.
let merge = |to: &mut NoteProps, from: &NoteProps, clear: bool| {
if from.pos.is_some() {
to.pos = if clear { None } else { from.pos };
}
if from.num_fmt.is_some() {
to.num_fmt = if clear { None } else { from.num_fmt };
}
if from.num_start.is_some() {
to.num_start = if clear { None } else { from.num_start };
}
if from.num_restart.is_some() {
to.num_restart = if clear { None } else { from.num_restart };
}
};
fn pick(sect: &mut wordcraft_doc::SectionProps, endnote: bool) -> &mut NoteProps {
if endnote { &mut sect.endnote_pr } else { &mut sect.footnote_pr }
}
if change.is_empty() {
// Nothing to change: the options in effect.
} else if whole {
let st = &mut s.doc.settings;
if let Some(f) = change.num_fmt {
*(if endnote { &mut st.endnote_format } else { &mut st.footnote_format }) = f;
}
merge(if endnote { &mut st.endnote_pr } else { &mut st.footnote_pr }, &NoteProps { num_fmt: None, ..change }, false);
// Every section follows the document now.
let ends: Vec<usize> = s.doc.sections().iter().map(|(e, _)| *e).collect();
for e in ends {
merge(pick(s.doc.section_mut(e), endnote), &change, true);
}
} else {
super::page::with_sect(s, |sect| merge(pick(sect, endnote), &change, false))?;
}
if let Some(f) = old_foot {
s.doc.settings.footnote_format = f;
}
if let Some(f) = old_end {
s.doc.settings.endnote_format = f;
}
let sect = super::page::sect(s);
Ok(json!({
"footnote": s.doc.note_options(&sect, false),
"endnote": s.doc.note_options(&sect, true),
"footnoteFormat": s.doc.settings.footnote_format,
"endnoteFormat": s.doc.settings.endnote_format,
}))
}
fn note_refs(s: &Session) -> Vec<Pos> {
let mut v = Vec::new();
for path in s.doc.para_paths(StoryRef::Body) {