From 4e710a920bfbb1923ffa004d265e8f978bc51df5 Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sun, 11 Oct 2026 21:10:17 +0200 Subject: [PATCH] Footnote and Endnote options per section (#385) (#390) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- ROADMAP.md | 1 + crates/doc/src/edit.rs | 81 ++++++++++ crates/doc/src/lib.rs | 44 +++++- crates/doc/src/section.rs | 97 ++++++++++++ crates/docx/src/read/mod.rs | 2 + crates/docx/src/read/props.rs | 16 +- crates/docx/src/write/mod.rs | 9 +- crates/docx/src/write/story.rs | 27 +++- crates/docx/tests/fixtures.rs | 5 +- crates/docx/tests/roundtrip.rs | 38 +++++ crates/engine/src/cmd/citations.rs | 13 +- crates/engine/src/cmd/page.rs | 2 +- crates/engine/src/cmd/references.rs | 122 +++++++++++++- crates/engine/src/tests.rs | 51 ++++++ crates/layout/src/fields.rs | 23 ++- crates/layout/src/lib.rs | 237 ++++++++++++++++++++-------- crates/layout/src/para.rs | 10 +- crates/layout/src/tests.rs | 72 +++++++++ crates/ui-egui/src/dialogs.rs | 8 + crates/ui-egui/src/dialogs_notes.rs | 201 +++++++++++++++++++++++ crates/ui-egui/src/i18n/es.tsv | 12 ++ crates/ui-egui/src/i18n/et.tsv | 12 ++ crates/ui-egui/src/i18n/ja.tsv | 12 ++ crates/ui-egui/src/i18n/pt-br.tsv | 12 ++ crates/ui-egui/src/i18n/ru.tsv | 12 ++ crates/ui-egui/src/i18n/sr-latn.tsv | 12 ++ crates/ui-egui/src/i18n/sr.tsv | 12 ++ crates/ui-egui/src/i18n/tr.tsv | 12 ++ crates/ui-egui/src/i18n/uk.tsv | 12 ++ crates/ui-egui/src/i18n/zh-hans.tsv | 12 ++ crates/ui-egui/src/i18n/zh-hant.tsv | 12 ++ crates/ui-egui/src/lib.rs | 3 + crates/ui-egui/src/ribbon.rs | 2 +- docs/file-format-parity.md | 3 +- docs/gaps.md | 1 + docs/layout-parity.md | 3 +- docs/target-app-parity.md | 3 +- 37 files changed, 1099 insertions(+), 107 deletions(-) create mode 100644 crates/ui-egui/src/dialogs_notes.rs diff --git a/ROADMAP.md b/ROADMAP.md index 737a7c2..c903e22 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -131,6 +131,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | Date | What landed | |---|---| +| 2026-10-11 | Footnote and Endnote dialog from the References › Footnotes launcher: options per section or document (below text, end of section, number format, start at, restart each section or page), custom marks, convert footnotes ↔ endnotes; kept in DOCX (#385) | | 2026-10-11 | Shape Format › Text: text direction, Align Text and linked text boxes (Create Link / Break Link), read and written in DOCX (#369) | | 2026-10-11 | Mail-merge recipients from Excel (.xlsx/.xlsm) and OpenDocument (.ods) spreadsheets, with Select Table to pick the sheet (#334) | | 2026-10-11 | Insert › Icons (#309): a library of 61 original icons drawn in code, a searchable picker and Icon Color | diff --git a/crates/doc/src/edit.rs b/crates/doc/src/edit.rs index b56c3a4..cc37969 100644 --- a/crates/doc/src/edit.rs +++ b/crates/doc/src/edit.rs @@ -133,6 +133,87 @@ fn box_ids(p: &Paragraph) -> impl Iterator + '_ { } impl Document { + /// Convert notes between footnotes and endnotes: footnotes (`foot`) become endnotes and + /// endnotes (`end`) footnotes, both at once to swap them. Each note's kind changes, as do its + /// reference marks wherever they are (the body, text boxes, the note's own mark) and the + /// note styles (Footnote Text and Footnote Reference to their endnote twins and back). + /// Returns how many notes changed. + pub fn convert_notes(&mut self, foot: bool, end: bool) -> usize { + use crate::para::NoteKind; + let flips: std::collections::HashSet = self + .parts + .iter() + .filter(|(_, p)| (foot && p.kind == PartKind::Footnote) || (end && p.kind == PartKind::Endnote)) + .map(|(id, _)| *id) + .collect(); + if flips.is_empty() { + return 0; + } + let swap = |s: &mut Option| { + let to = match s.as_deref() { + Some("FootnoteText") => "EndnoteText", + Some("EndnoteText") => "FootnoteText", + Some("FootnoteReference") => "EndnoteReference", + Some("EndnoteReference") => "FootnoteReference", + _ => return, + }; + *s = Some(to.to_string()); + }; + let refers = |p: &Paragraph| p.objects.iter().any(|o| matches!(o, InlineObject::NoteRef { id, .. } if flips.contains(id))); + // In a paragraph: flip the converted notes' marks and their runs' reference style; in a + // converted note's own story also the paragraph style. + let convert = |p: &mut Paragraph, in_note: bool| { + if !in_note && !refers(p) { + return; + } + let offs = p.object_offsets(); + let mut at = Vec::new(); + for (k, o) in p.objects.iter_mut().enumerate() { + if let InlineObject::NoteRef { kind, id, .. } = o + && flips.contains(id) + { + *kind = if *kind == NoteKind::Footnote { NoteKind::Endnote } else { NoteKind::Footnote }; + at.extend(offs.get(k).copied()); + } + } + let mut start = 0usize; + for r in &mut p.runs { + let end = start.saturating_add(r.len); + if at.iter().any(|&o| o >= start && o < end) { + swap(&mut r.props.style); + } + start = end; + } + if in_note { + swap(&mut p.props.style); + swap(&mut p.mark.style); + } + p.touch(); + }; + let touches = |b: &Block| { + let mut any = false; + each_para(b, 0, &mut |p| any |= refers(p)); + any + }; + for b in self.body.iter_mut() { + if touches(b) { + each_para_mut(Arc::make_mut(b), 0, &mut |p| convert(p, false)); + } + } + for (id, part) in self.parts.iter_mut() { + let own = flips.contains(id); + if own { + part.kind = if part.kind == PartKind::Footnote { PartKind::Endnote } else { PartKind::Footnote }; + } + for b in part.blocks.iter_mut() { + if own || touches(b) { + each_para_mut(Arc::make_mut(b), 0, &mut |p| convert(p, own)); + } + } + } + flips.len() + } + /// Point every paragraph and run that uses paragraph or character style `from` at `to` /// (`None`: the default style), in every story, tables included. Returns how many paragraphs /// changed. (Tables' own table styles are retargeted by the engine's table style deletion.) diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 103d9a9..30fdc1f 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -374,6 +374,12 @@ pub struct Settings { pub theme_name: String, pub footnote_format: section::NumFormat, pub endnote_format: section::NumFormat, + /// The document's footnote and endnote placement, start and restart (`w:footnotePr` / + /// `w:endnotePr` in the settings); sections may override them. + #[serde(skip_serializing_if = "section::NoteProps::is_empty")] + pub footnote_pr: section::NoteProps, + #[serde(skip_serializing_if = "section::NoteProps::is_empty")] + pub endnote_pr: section::NoteProps, pub protection: Option, /// Word compatibility mode the document is laid out in (`compatibilityMode`): 15 for Word /// 2013 and later, which places a table's border at the margin rather than its text and lets @@ -444,6 +450,8 @@ impl Default for Settings { theme_name: "Craft".into(), footnote_format: section::NumFormat::Decimal, endnote_format: section::NumFormat::LowerRoman, + footnote_pr: section::NoteProps::default(), + endnote_pr: section::NoteProps::default(), protection: None, compat_mode: COMPAT_MODE_CURRENT, grid_h: DEFAULT_GRID, @@ -720,6 +728,31 @@ impl Document { v } + /// The footnote (`endnote` false) or endnote options in effect in section `sect`: its own, + /// else the document's, else Word's defaults (footnotes at the page bottom in 1, 2, 3; + /// endnotes at the end of the document in i, ii, iii; numbered on through the document). + /// Placements that don't apply to the kind fall back to its default. + pub fn note_options(&self, sect: &SectionProps, endnote: bool) -> section::NoteOptions { + use section::NotePos; + let (own, doc, fmt) = if endnote { + (§.endnote_pr, &self.settings.endnote_pr, self.settings.endnote_format) + } else { + (§.footnote_pr, &self.settings.footnote_pr, self.settings.footnote_format) + }; + let pos = own.pos.or(doc.pos); + let pos = if endnote { + pos.filter(|p| matches!(p, NotePos::SectEnd | NotePos::DocEnd)).unwrap_or(NotePos::DocEnd) + } else { + pos.filter(|p| matches!(p, NotePos::PageBottom | NotePos::BeneathText)).unwrap_or(NotePos::PageBottom) + }; + section::NoteOptions { + pos, + num_fmt: own.num_fmt.unwrap_or(fmt), + num_start: own.num_start.or(doc.num_start).unwrap_or(1).clamp(1, section::MAX_NOTE_START), + num_restart: own.num_restart.or(doc.num_restart).unwrap_or_default(), + } + } + /// The section that contains top-level body block `block` (index into `sections()`). pub fn section_index_of(&self, block: usize) -> usize { self.sections().iter().position(|(end, _)| block <= *end).unwrap_or(0) @@ -882,6 +915,15 @@ impl Document { self.walk_objects(&self.body, &mut BoxBudget::default(), f); } + /// Like [`Document::objects_in_reading_order_at`], also giving the top-level body block each + /// object is in (for objects in text boxes, the block of the box's anchor). + pub fn objects_in_reading_order_by_block(&self, f: &mut dyn FnMut(usize, &Paragraph, usize) -> bool) { + let mut budget = BoxBudget::default(); + for (i, b) in self.body.iter().enumerate() { + self.walk_objects(std::slice::from_ref(b), &mut budget, &mut |p, k| f(i, p, k)); + } + } + /// The footnotes and endnotes referenced inside text box story `part` (nested boxes too), in /// reading order. pub fn notes_in_text_box(&self, part: u32) -> Vec { @@ -901,7 +943,7 @@ impl Document { out } - fn walk_objects(&self, blocks: &Blocks, budget: &mut BoxBudget, f: &mut dyn FnMut(&Paragraph, usize) -> bool) { + fn walk_objects(&self, blocks: &[Arc], budget: &mut BoxBudget, f: &mut dyn FnMut(&Paragraph, usize) -> bool) { for b in blocks { edit::each_para(b, 0, &mut |p| { for (k, o) in p.objects.iter().enumerate() { diff --git a/crates/doc/src/section.rs b/crates/doc/src/section.rs index ad59fde..c27bbe5 100644 --- a/crates/doc/src/section.rs +++ b/crates/doc/src/section.rs @@ -213,6 +213,96 @@ fn capitalize(s: &str) -> String { out } +/// Where footnotes or endnotes are placed (ECMA-376 §17.18.33/§17.18.20, `ST_FtnPos`/`ST_EdnPos`). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub enum NotePos { + /// Footnotes: at the bottom of the page. + #[default] + PageBottom, + /// Footnotes: right below the page's last line of text. + BeneathText, + /// Endnotes: at the end of each section. + SectEnd, + /// Endnotes: at the end of the document. + DocEnd, +} + +impl NotePos { + pub fn ooxml(self) -> &'static str { + match self { + NotePos::PageBottom => "pageBottom", + NotePos::BeneathText => "beneathText", + NotePos::SectEnd => "sectEnd", + NotePos::DocEnd => "docEnd", + } + } + pub fn from_ooxml(s: &str) -> Option { + [NotePos::PageBottom, NotePos::BeneathText, NotePos::SectEnd, NotePos::DocEnd].into_iter().find(|p| p.ooxml() == s) + } +} + +/// When note numbering starts again (ECMA-376 §17.18.66, `ST_RestartNumber`). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub enum NoteRestart { + #[default] + Continuous, + EachSect, + EachPage, +} + +impl NoteRestart { + pub fn ooxml(self) -> &'static str { + match self { + NoteRestart::Continuous => "continuous", + NoteRestart::EachSect => "eachSect", + NoteRestart::EachPage => "eachPage", + } + } + pub fn from_ooxml(s: &str) -> Option { + [NoteRestart::Continuous, NoteRestart::EachSect, NoteRestart::EachPage].into_iter().find(|r| r.ooxml() == s) + } +} + +/// The highest note number "start at" takes (Word's own limit is lower; this keeps hostile files +/// from overflowing the counters). +pub const MAX_NOTE_START: u32 = 32_767; + +/// Footnote or endnote options as a document or a section states them (ECMA-376 §17.11.11 and +/// §17.11.4, `w:footnotePr` / `w:endnotePr`): unset fields come from the level above (a +/// section's from the document's, the document's from Word's defaults). The document's number +/// format is `Settings::footnote_format` / `endnote_format`, so its `num_fmt` stays unset. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, Default)] +#[serde(default, rename_all = "camelCase")] +pub struct NoteProps { + #[serde(skip_serializing_if = "Option::is_none")] + pub pos: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub num_fmt: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub num_start: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub num_restart: Option, +} + +impl NoteProps { + pub fn is_empty(&self) -> bool { + *self == NoteProps::default() + } +} + +/// Footnote or endnote options in effect for a section (see [`crate::Document::note_options`]). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct NoteOptions { + pub pos: NotePos, + pub num_fmt: NumFormat, + /// 1..=[`MAX_NOTE_START`]. + pub num_start: u32, + pub num_restart: NoteRestart, +} + /// Header/footer story ids (in `Document::parts`) for a section. #[derive(Clone, Copy, Debug, PartialEq, Eq, Default, Serialize, Deserialize)] #[serde(default)] @@ -299,6 +389,11 @@ pub struct SectionProps { /// Tracked change of the section's properties (`w:sectPrChange`). #[serde(skip_serializing_if = "Option::is_none")] pub fmt_change: Option>>, + /// The section's own footnote and endnote options (unset: the document's). + #[serde(skip_serializing_if = "NoteProps::is_empty")] + pub footnote_pr: NoteProps, + #[serde(skip_serializing_if = "NoteProps::is_empty")] + pub endnote_pr: NoteProps, } impl Default for SectionProps { @@ -327,6 +422,8 @@ impl Default for SectionProps { page_borders: None, rtl: false, fmt_change: None, + footnote_pr: NoteProps::default(), + endnote_pr: NoteProps::default(), } } } diff --git a/crates/docx/src/read/mod.rs b/crates/docx/src/read/mod.rs index 2721263..c59a7de 100644 --- a/crates/docx/src/read/mod.rs +++ b/crates/docx/src/read/mod.rs @@ -725,11 +725,13 @@ impl Reader<'_> { if let Some(f) = k.child_val("w:numFmt") { s.footnote_format = NumFormat::from_ooxml(f); } + s.footnote_pr = props::note_pr(k, false); } "w:endnotePr" => { if let Some(f) = k.child_val("w:numFmt") { s.endnote_format = NumFormat::from_ooxml(f); } + s.endnote_pr = props::note_pr(k, false); } "w:documentProtection" => { let enforced = k.attr("w:enforcement").is_some_and(|v| !matches!(v, "0" | "false" | "off")); diff --git a/crates/docx/src/read/props.rs b/crates/docx/src/read/props.rs index 0fba7bb..8af6621 100644 --- a/crates/docx/src/read/props.rs +++ b/crates/docx/src/read/props.rs @@ -6,7 +6,7 @@ use wordcraft_doc::props::{ Align, Border, BorderStyle, Borders, CellProps, CharProps, HeightRule, Highlight, LineSpacing, NumRef, ParaProps, Rgb, RowProps, TabAlign, TabLeader, TabStop, TableFloat, TableLook, TableProps, TextColor, TextDirection, Underline, VAlign, VMerge, VertAlign, }; -use wordcraft_doc::section::{Columns, LineNumberRestart, LineNumbering, NumFormat, SectionProps, SectionStart}; +use wordcraft_doc::section::{Columns, LineNumberRestart, LineNumbering, NotePos, NoteProps, NoteRestart, NumFormat, SectionProps, SectionStart}; use crate::units::{int, measure, on_off, tw, u32_of}; use crate::xml::El; @@ -486,6 +486,8 @@ pub fn sectpr(e: &El) -> (SectionProps, Vec) { } s.columns = c; } + "w:footnotePr" => s.footnote_pr = note_pr(k, true), + "w:endnotePr" => s.endnote_pr = note_pr(k, true), "w:titlePg" => s.title_page = on_off(k), "w:bidi" => s.rtl = on_off(k), "w:pgNumType" => { @@ -524,6 +526,18 @@ pub fn sectpr(e: &El) -> (SectionProps, Vec) { (s, refs) } +/// `w:footnotePr` / `w:endnotePr` (ECMA-376 §17.11): placement, number format (only kept when +/// `fmt`: the document's goes to its own setting), start and restart. A start out of range is +/// clamped; unknown values are left unset. +pub fn note_pr(e: &El, fmt: bool) -> NoteProps { + NoteProps { + pos: e.child_val("w:pos").and_then(NotePos::from_ooxml), + num_fmt: if fmt { e.child_val("w:numFmt").map(NumFormat::from_ooxml) } else { None }, + num_start: e.child_val("w:numStart").and_then(int).map(|v| v.clamp(1, wordcraft_doc::section::MAX_NOTE_START as i64) as u32), + num_restart: e.child_val("w:numRestart").and_then(NoteRestart::from_ooxml), + } +} + /// `w:tblpPr`: a floating table's anchors, offsets or alignments and distances from text. fn table_float(e: &El) -> TableFloat { use wordcraft_doc::para::{Anchor, FloatAlign}; diff --git a/crates/docx/src/write/mod.rs b/crates/docx/src/write/mod.rs index 70558f3..4a29724 100644 --- a/crates/docx/src/write/mod.rs +++ b/crates/docx/src/write/mod.rs @@ -858,11 +858,12 @@ fn settings_xml(doc: &Document, footnotes: bool, endnotes: bool) -> Vec { } } w.val("w:characterSpacingControl", "doNotCompress"); - for (tag, fmt, used, el) in - [("w:footnotePr", s.footnote_format, footnotes, "w:footnote"), ("w:endnotePr", s.endnote_format, endnotes, "w:endnote")] - { + for (tag, fmt, pr, used, el) in [ + ("w:footnotePr", s.footnote_format, &s.footnote_pr, footnotes, "w:footnote"), + ("w:endnotePr", s.endnote_format, &s.endnote_pr, endnotes, "w:endnote"), + ] { w.open(tag, &[]); - w.val("w:numFmt", fmt.ooxml()); + story::note_pr_inner(&mut w, &wordcraft_doc::section::NoteProps { num_fmt: Some(fmt), ..*pr }); if used { w.empty(el, &[("w:id", "-1")]); w.empty(el, &[("w:id", "0")]); diff --git a/crates/docx/src/write/story.rs b/crates/docx/src/write/story.rs index bd504d3..6c0539e 100644 --- a/crates/docx/src/write/story.rs +++ b/crates/docx/src/write/story.rs @@ -3,7 +3,7 @@ use wordcraft_doc::effects::ShapeEffects; use wordcraft_doc::para::{Anchor, Float, NoteKind, ShapeKind, Wrap}; use wordcraft_doc::props::{CharProps, PropChange, Rgb}; -use wordcraft_doc::section::{LineNumberRestart, SectionProps, SectionStart}; +use wordcraft_doc::section::{LineNumberRestart, NoteProps, SectionProps, SectionStart}; use wordcraft_doc::table::Table; use wordcraft_doc::{Block, Blocks, InlineObject, Paragraph, RevisionKind}; @@ -1024,6 +1024,14 @@ impl Writer<'_> { /// `w:sectPr` content after the header and footer references. fn sectpr_body(w: &mut W, s: &SectionProps) { { + // The section's own note options come before `w:type` (ECMA-376 §17.6.17). + for (tag, pr) in [("w:footnotePr", &s.footnote_pr), ("w:endnotePr", &s.endnote_pr)] { + if !pr.is_empty() { + w.open(tag, &[]); + note_pr_inner(w, pr); + w.close(tag); + } + } let start = match s.start { SectionStart::NextPage => "nextPage", SectionStart::Continuous => "continuous", @@ -1293,3 +1301,20 @@ fn solid_fill(w: &mut W, c: Option) { None => w.empty("a:noFill", &[]), } } + +/// The children of a `w:footnotePr` / `w:endnotePr` in schema order (`w:pos`, `w:numFmt`, +/// `w:numStart`, `w:numRestart`; ECMA-376 §17.11.11), for the fields that are set. +pub(super) fn note_pr_inner(w: &mut W, pr: &NoteProps) { + if let Some(p) = pr.pos { + w.val("w:pos", p.ooxml()); + } + if let Some(f) = pr.num_fmt { + w.val("w:numFmt", f.ooxml()); + } + if let Some(n) = pr.num_start { + w.val("w:numStart", &n.clamp(1, wordcraft_doc::section::MAX_NOTE_START).to_string()); + } + if let Some(r) = pr.num_restart { + w.val("w:numRestart", r.ooxml()); + } +} diff --git a/crates/docx/tests/fixtures.rs b/crates/docx/tests/fixtures.rs index 1cc418d..63997fd 100644 --- a/crates/docx/tests/fixtures.rs +++ b/crates/docx/tests/fixtures.rs @@ -177,7 +177,7 @@ fn styles_numbering_settings_notes_comments_from_parts() { r#""# ); let settings = format!( - r#""# + r#""# ); let footnotes = format!( r#" Note text"# @@ -244,6 +244,9 @@ fn styles_numbering_settings_notes_comments_from_parts() { assert!(d.settings.track_changes && d.settings.even_odd_headers); assert_eq!(d.settings.default_tab, 35.4); assert_eq!(d.settings.footnote_format, wordcraft_doc::section::NumFormat::LowerRoman); + // A hostile start is clamped, an unknown position left out. + let fp = d.settings.footnote_pr; + assert_eq!((fp.pos, fp.num_start, fp.num_restart), (None, Some(1), Some(wordcraft_doc::section::NoteRestart::EachPage))); // Body. let p = paras(&d); assert_eq!(p[0].props.style.as_deref(), Some("1")); diff --git a/crates/docx/tests/roundtrip.rs b/crates/docx/tests/roundtrip.rs index c41874e..1b1da68 100644 --- a/crates/docx/tests/roundtrip.rs +++ b/crates/docx/tests/roundtrip.rs @@ -529,6 +529,44 @@ fn notes_round_trip() { assert_eq!(r.settings.endnote_format, NumFormat::UpperRoman); } +/// #385: footnote and endnote options round-trip for the document (settings) and per section +/// (`w:sectPr`), in schema order: `w:footnotePr` and `w:endnotePr` before `w:type`. +#[test] +fn note_options_round_trip_per_section() { + use wordcraft_doc::section::{NotePos, NoteProps, NoteRestart}; + let mut d = Document::new(); + d.settings.footnote_format = NumFormat::UpperLetter; + d.settings.footnote_pr = + NoteProps { pos: Some(NotePos::BeneathText), num_start: Some(4), num_restart: Some(NoteRestart::EachPage), ..Default::default() }; + d.settings.endnote_pr = NoteProps { pos: Some(NotePos::SectEnd), ..Default::default() }; + let mut first = Paragraph::with_text("Section one", CharProps::default()); + let own = NoteProps { num_fmt: Some(NumFormat::LowerRoman), num_start: Some(7), num_restart: Some(NoteRestart::EachSect), ..Default::default() }; + first.section = Some(Box::new(SectionProps { + footnote_pr: own, + endnote_pr: NoteProps { num_fmt: Some(NumFormat::Decimal), ..Default::default() }, + ..Default::default() + })); + d.body = vec![para_block(first), para_block(Paragraph::with_text("Section two", CharProps::default()))]; + let bytes = wordcraft_docx::write(&d).unwrap(); + let r = wordcraft_docx::read(&bytes).unwrap(); + assert_eq!(r.settings.footnote_format, NumFormat::UpperLetter); + assert_eq!((r.settings.footnote_pr, r.settings.endnote_pr), (d.settings.footnote_pr, d.settings.endnote_pr)); + let sections = r.sections(); + assert_eq!(sections[0].1.footnote_pr, own); + assert_eq!(sections[0].1.endnote_pr.num_fmt, Some(NumFormat::Decimal)); + assert!(sections[1].1.footnote_pr.is_empty() && sections[1].1.endnote_pr.is_empty()); + assert_eq!(r.note_options(sections[0].1, false).num_start, 7); + assert_eq!(r.note_options(sections[1].1, false).num_start, 4); + let mut z = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap(); + let mut xml = String::new(); + std::io::Read::read_to_string(&mut z.by_name("word/document.xml").unwrap(), &mut xml).unwrap(); + let at = |s: &str| xml.find(s).unwrap_or_else(|| panic!("{s} missing")); + assert!( + at("") + < at(" Vec { }), 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})) diff --git a/crates/engine/src/cmd/page.rs b/crates/engine/src/cmd/page.rs index 6b67939..7601650 100644 --- a/crates/engine/src/cmd/page.rs +++ b/crates/engine/src/cmd/page.rs @@ -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 = s.doc.sections().iter().map(|(e, _)| *e).collect(); diff --git a/crates/engine/src/cmd/references.rs b/crates/engine/src/cmd/references.rs index ce1500e..0ec7a54 100644 --- a/crates/engine/src/cmd/references.rs +++ b/crates/engine/src/cmd/references.rs @@ -32,10 +32,21 @@ pub fn specs() -> Vec { .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, 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 = 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(§, false), + "endnote": s.doc.note_options(§, true), + "footnoteFormat": s.doc.settings.footnote_format, + "endnoteFormat": s.doc.settings.endnote_format, + })) +} + fn note_refs(s: &Session) -> Vec { let mut v = Vec::new(); for path in s.doc.para_paths(StoryRef::Body) { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index a648350..18c2957 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2942,3 +2942,54 @@ fn enclose_characters_make_an_eq_field_and_come_back_out() { assert_eq!(text(&s), "No 12 and 字 hereA"); assert_eq!(field(&s), Some(("eq \\o\\ac(△,A)".to_string(), "A".to_string()))); } + +/// #385: note options for the whole document or one section, and converting notes, which undoes +/// in one step. +#[test] +fn note_options_per_section_and_convert_notes() { + use wordcraft_doc::PartKind; + use wordcraft_doc::para::{InlineObject, NoteKind}; + let mut s = s(); + run(&mut s, "text.insert", json!({"text": "One"})); + let a = run(&mut s, "references.footnote", json!({"text": "First", "mark": "*"}))["id"].as_u64().unwrap() as u32; + s.sel = crate::Selection::caret(s.doc.end_of(StoryRef::Body)); + run(&mut s, "layout.break", json!({"kind": "nextPage"})); + run(&mut s, "text.insert", json!({"text": "Two"})); + let b = run(&mut s, "references.footnote", json!({"text": "Second"}))["id"].as_u64().unwrap() as u32; + s.sel = crate::Selection::caret(s.doc.end_of(StoryRef::Body)); + // The second section restarts at 3 in letters; the first keeps the document's options. + let got = run(&mut s, "references.noteOptions", json!({"numFmt": "lowerLetter", "numStart": 3, "numRestart": "eachSect", "scope": "section"})); + assert_eq!((got["footnote"]["numFmt"].as_str(), got["footnote"]["numStart"].as_u64()), (Some("lowerLetter"), Some(3))); + let sections = s.doc.sections(); + assert!(sections[0].1.footnote_pr.is_empty()); + assert_eq!(sections[1].1.footnote_pr.num_start, Some(3)); + // Hostile or wrong values are refused or clamped. + assert!(s.run("references.noteOptions", &json!({"kind": "endnote", "pos": "pageBottom"})).is_err()); + assert!(s.run("references.noteOptions", &json!({"kind": "endnote", "numRestart": "eachPage"})).is_err()); + let got = run(&mut s, "references.noteOptions", json!({"kind": "endnote", "pos": "sectEnd", "numStart": -5})); + assert_eq!((got["endnote"]["pos"].as_str(), got["endnote"]["numStart"].as_u64()), (Some("sectEnd"), Some(1))); + // The whole document: the section's own start gives way. + run(&mut s, "references.noteOptions", json!({"numStart": 2})); + assert_eq!(s.doc.sections()[1].1.footnote_pr.num_start, None); + assert_eq!(s.doc.settings.footnote_pr.num_start, Some(2)); + // Convert footnotes to endnotes: kinds, marks and styles; the custom mark stays. One undo. + let kinds = |s: &Session| -> Vec { + let mut v = Vec::new(); + s.doc.objects_in_reading_order(&mut |o| { + if let InlineObject::NoteRef { kind, .. } = o { + v.push(*kind); + } + }); + v + }; + assert_eq!(run(&mut s, "references.convertNotes", json!({"to": "endnote"}))["converted"], 2); + assert_eq!(kinds(&s), vec![NoteKind::Endnote, NoteKind::Endnote]); + assert!([a, b].iter().all(|id| s.doc.parts[id].kind == PartKind::Endnote)); + let note = s.doc.para(StoryRef::Part(a), &wordcraft_doc::Path::top(0)).unwrap(); + assert_eq!(note.props.style.as_deref(), Some("EndnoteText")); + assert!(matches!(¬e.objects[0], InlineObject::NoteRef { kind: NoteKind::Endnote, custom, .. } if custom == "*")); + run(&mut s, "edit.undo", json!({})); + assert_eq!(kinds(&s), vec![NoteKind::Footnote, NoteKind::Footnote]); + assert_eq!(s.doc.parts[&b].kind, PartKind::Footnote); + assert!(s.run("references.convertNotes", &json!({"to": "sideways"})).is_err()); +} diff --git a/crates/layout/src/fields.rs b/crates/layout/src/fields.rs index eb79ee7..e703f8d 100644 --- a/crates/layout/src/fields.rs +++ b/crates/layout/src/fields.rs @@ -16,17 +16,32 @@ pub struct FieldCtx { /// Pages in the current section. pub section_pages: u32, pub section: u32, - /// Note part id → number, in document order. - pub notes: Arc>, + /// Note part id → its number. + pub notes: Arc>, /// Document title / author for TITLE / AUTHOR fields. pub title: Arc, pub author: Arc, pub filename: Arc, } +/// A footnote's or endnote's number, as its section's options give it. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct NoteNum { + pub n: u32, + pub fmt: NumFormat, + /// Index of the section (`Document::sections`) its reference is in. + pub section: usize, + /// Place among the notes of its kind, in document order. + pub order: u32, +} + impl FieldCtx { - pub fn note_number(&self, id: u32) -> u32 { - self.notes.get(&id).copied().unwrap_or(1) + /// The mark of note `id` (its number in its format); `fmt` for a note not counted. + pub fn note_mark(&self, id: u32, fmt: NumFormat) -> String { + match self.notes.get(&id) { + Some(n) => n.fmt.format(n.n), + None => fmt.format(1), + } } /// A key for caches: the parts of the context a page-dependent paragraph depends on. pub fn page_key(&self) -> (u32, u32, u32, u32) { diff --git a/crates/layout/src/lib.rs b/crates/layout/src/lib.rs index 9501c6f..44dd8de 100644 --- a/crates/layout/src/lib.rs +++ b/crates/layout/src/lib.rs @@ -538,15 +538,25 @@ fn left_out_objects(doc: &Document, p: &Paragraph, table_chr: Option<&CharProps> move |k| left.get(k).copied().unwrap_or(false) } -/// Note part ids in document order → numbers (footnotes and endnotes numbered separately). With -/// `hide_deleted`, notes whose reference mark is a tracked deletion are left out, as in the final text. -fn note_numbers(doc: &Document, hide_deleted: bool) -> HashMap { +/// 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. +fn note_info(doc: &Document, hide_deleted: bool, pages: &HashMap) -> HashMap { + 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(); - let (mut f, mut e) = (0u32, 0u32); + // Per kind (footnotes, endnotes): the last number, and the section and page of the last note. + let mut last: [Option<(u32, usize, Option)>; 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 text box) does not. let mut cur: Option<(usize, Vec)> = None; - doc.objects_in_reading_order_at(&mut |p, k| { + 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 deleted = left_out_objects(doc, p, None, true, hide_deleted); @@ -556,23 +566,57 @@ fn note_numbers(doc: &Document, hide_deleted: bool) -> HashMap { return false; } if let Some(InlineObject::NoteRef { kind, id, .. }) = p.objects.get(k) { - let n = match kind { - wordcraft_doc::para::NoteKind::Footnote => { - f += 1; - f - } - wordcraft_doc::para::NoteKind::Endnote => { - e += 1; - e + 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) } } }; - m.insert(*id, n); + *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)] +fn note_numbers(doc: &Document, hide_deleted: bool) -> HashMap { + note_info(doc, hide_deleted, &HashMap::new()).into_iter().map(|(id, n)| (id, n.n)).collect() +} + +/// The page each footnote's text starts on. +fn footnote_pages(doc: &Document, pages: &[Page]) -> HashMap { + 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 +} + /// Lay out a block list into a free-standing box of `width` (no page breaks): table cells, /// headers, footers, notes, text boxes. Returns items relative to (0, 0) and the height. `frame` /// says where the box sits on its page, for floating objects positioned relative to the page or @@ -890,6 +934,10 @@ struct PageBuilder<'a> { balance: Option<(usize, f32)>, /// The page with the last explicit column break. col_break_page: Option, + /// The section's footnotes go right below its text, not at the page bottom ... + sect_beneath: bool, + /// ... and so do the current page's (set as the page starts). + beneath: bool, } /// Gap above the footnote separator and its length. @@ -1119,7 +1167,12 @@ impl PageBuilder<'_> { // The note area starts below the text (a page of continued notes alone: at its top). let area = self.bottom.max(self.body_floor()); let fits = total <= self.orig_bottom - area + 0.01; - let top = if fits { self.orig_bottom - total } else { area }; + let top = match (fits, self.beneath) { + (false, _) => area, + // Below the page's last line of text. + (true, true) => self.body_floor().min(self.orig_bottom - total), + (true, false) => self.orig_bottom - total, + }; let notes = std::mem::take(&mut self.notes); // A page that starts with a continued note gets the continuation separator. let cont = notes.first().is_some_and(|n| n.cont); @@ -1202,6 +1255,7 @@ impl PageBuilder<'_> { fn new_page(&mut self, first_block: usize, body_top: f32) { self.apply_valign(); self.flush_notes(); + self.beneath = self.sect_beneath; self.excl.clear(); self.float_tables.clear(); if self.sect.line_numbers.as_ref().is_some_and(|l| l.restart == wordcraft_doc::section::LineNumberRestart::Page) { @@ -1299,8 +1353,34 @@ impl PageBuilder<'_> { } } +/// 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. +const MAX_NOTE_PASSES: usize = 3; + /// Lay out the whole document. pub fn layout(doc: &Document, cache: &mut LayoutCache, opts: &LayoutOptions) -> DocLayout { + let t0 = now_ms(); + let mut pages = HashMap::new(); + let mut l = layout_pass(doc, cache, opts, &pages); + let per_page = opts.view == ViewMode::Print + && doc.sections().iter().any(|(_, s)| doc.note_options(s, false).num_restart == wordcraft_doc::section::NoteRestart::EachPage); + if per_page { + // Number each page's footnotes from where the pass before placed them, until they stay. + for _ in 1..MAX_NOTE_PASSES { + let placed = footnote_pages(doc, &l.pages); + if placed == pages { + break; + } + pages = placed; + l = layout_pass(doc, cache, opts, &pages); + } + } + l.ms = now_ms() - t0; + l +} + +/// One layout pass, numbering footnotes restarted on each page by `note_pages` ([`note_info`]). +fn layout_pass(doc: &Document, cache: &mut LayoutCache, opts: &LayoutOptions, note_pages: &HashMap) -> DocLayout { let t0 = now_ms(); let env = env_hash(doc, opts); if env != cache.env { @@ -1308,10 +1388,10 @@ pub fn layout(doc: &Document, cache: &mut LayoutCache, opts: &LayoutOptions) -> cache.env = env; } cache.used.clear(); - let notes = note_numbers(doc, opts.hide_deleted); + let notes = note_info(doc, opts.hide_deleted, note_pages); let notes_hash = hash_of(&{ - let mut v: Vec<_> = notes.iter().map(|(a, b)| (*a, *b)).collect(); - v.sort(); + let mut v: Vec<_> = notes.iter().map(|(a, b)| (*a, b.n, b.fmt)).collect(); + v.sort_by_key(|(a, ..)| *a); v }); let mut ctx = Ctx { @@ -1380,6 +1460,21 @@ pub fn layout(doc: &Document, cache: &mut LayoutCache, opts: &LayoutOptions) -> content_bottom: 0.0, balance: None, col_break_page: None, + sect_beneath: false, + beneath: false, + }; + // Endnotes in document order, with the section of their reference; placed at the end of a + // section that keeps them there, else at the end of the document. + let mut endnotes: std::collections::VecDeque<(u32, usize)> = { + let mut ids: Vec<(u32, u32, usize)> = ctx + .fields + .notes + .iter() + .filter(|(id, _)| doc.parts.get(id).is_some_and(|p| p.kind == wordcraft_doc::PartKind::Endnote)) + .map(|(id, n)| (n.order, *id, n.section)) + .collect(); + ids.sort(); + ids.into_iter().map(|(_, id, sect)| (id, sect)).collect() }; // Text boxes laid out in balancing attempts that were thrown away. let mut boxes_discarded = 0usize; @@ -1388,6 +1483,8 @@ pub fn layout(doc: &Document, cache: &mut LayoutCache, opts: &LayoutOptions) -> let sect: &SectionProps = if web { sect_ref } else { sect }; pb.sect = sect; pb.sect_idx = si; + let (foot, end_opts) = (doc.note_options(sect, false), doc.note_options(sect, true)); + pb.sect_beneath = foot.pos == wordcraft_doc::section::NotePos::BeneathText; if !web && !doc.footnote_separators.is_empty() { pb.seps = NoteSeps::new(&mut ctx, &doc.footnote_separators, sect.text_width()); } @@ -1446,59 +1543,22 @@ pub fn layout(doc: &Document, cache: &mut LayoutCache, opts: &LayoutOptions) -> if let Some((first, snap)) = from { boxes_discarded += balance_columns(&mut ctx, &mut pb, (first, block), &snap, body_top); } + // Endnotes at the end of the section: those referenced so far. + if end_opts.pos == wordcraft_doc::section::NotePos::SectEnd && !pb.pages.is_empty() { + let mut here = Vec::new(); + while let Some((id, _)) = endnotes.front().filter(|(_, s)| *s <= si).copied() { + endnotes.pop_front(); + here.push(id); + } + place_endnotes(&mut ctx, &mut pb, here, block.saturating_sub(1)); + } } if pb.pages.is_empty() { pb.new_page(0, sect_ref.margin_top); } - // Endnotes after the last paragraph. - let endnotes: Vec = { - let mut ids: Vec<(u32, u32)> = ctx - .fields - .notes - .iter() - .filter(|(id, _)| doc.parts.get(id).is_some_and(|p| p.kind == wordcraft_doc::PartKind::Endnote)) - .map(|(a, b)| (*b, *a)) - .collect(); - ids.sort(); - ids.into_iter().map(|(_, id)| id).collect() - }; - if !endnotes.is_empty() && !web { - // The document's own endnote separator, else a short line. - let width = pb.col_w(); - if let Some((items, h)) = doc.endnote_separators.separator.as_ref().map(|b| sep_box(&mut 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; - } - for id in endnotes { - let Some(part) = doc.parts.get(&id) else { continue }; - let blocks = part.blocks.clone(); - let BoxLayout { items, height: h, .. } = layout_box(&mut ctx, StoryRef::Part(id), &blocks, &[], pb.col_w(), None, 0, None); - if pb.y + h > pb.bottom && !pb.at_top() { - pb.advance(block, pb.top); - } - let (x, y) = (pb.col_x(), pb.y); - if let Some(pg) = pb.page() { - for mut it in items { - it.translate(x, y); - pg.items.push(it); - } - } - pb.y += h; - } - } + // The rest of the endnotes after the last paragraph. + let rest: Vec = endnotes.into_iter().map(|(id, _)| id).collect(); + place_endnotes(&mut ctx, &mut pb, rest, block); pb.apply_valign(); pb.flush_notes(); // Footnotes still continuing after the last page get pages of their own. Each page takes at @@ -1693,6 +1753,49 @@ fn balance_columns<'a>(ctx: &mut Ctx, pb: &mut PageBuilder<'a>, blocks: (usize, thrown } +/// 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. +fn place_endnotes(ctx: &mut Ctx, pb: &mut PageBuilder, ids: Vec, 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; + } + 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(), None, 0, None); + if pb.y + h > pb.bottom && !pb.at_top() { + pb.advance(block, pb.top); + } + let (x, y) = (pb.col_x(), pb.y); + if let Some(pg) = pb.page() { + for mut it in items { + it.translate(x, y); + pg.items.push(it); + } + } + pb.y += h; + } +} + fn item_bottom(y: f32, para: &ParaLayout, l0: usize, l1: usize) -> Option { let f = para.lines.get(l0)?; let l = para.lines.get(l1.checked_sub(1)?)?; diff --git a/crates/layout/src/para.rs b/crates/layout/src/para.rs index 90fc02b..994d98e 100644 --- a/crates/layout/src/para.rs +++ b/crates/layout/src/para.rs @@ -1010,11 +1010,11 @@ pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout { } Some(InlineObject::NoteRef { kind, id, custom }) => { let num = if custom.is_empty() { - let n = env.fields.note_number(*id); - match kind { - NoteKind::Footnote => doc.settings.footnote_format.format(n), - NoteKind::Endnote => doc.settings.endnote_format.format(n), - } + let fmt = match kind { + NoteKind::Footnote => doc.settings.footnote_format, + NoteKind::Endnote => doc.settings.endnote_format, + }; + env.fields.note_mark(*id, fmt) } else { custom.clone() }; diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index f6f386c..54b151e 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -3424,3 +3424,75 @@ fn columns_before_a_page_break_or_a_column_break_are_not_balanced() { // The next section starts below the taller column. assert!(br > bl + 50.0 && after >= br - 0.5, "columns end at {bl} and {br}, next section at {after}"); } + +/// The glyphs of each note reference mark in the body, by note id. +fn note_marks(l: &DocLayout) -> HashMap> { + let mut marks = HashMap::new(); + for it in l.pages.iter().flat_map(|p| p.items.iter()) { + if let Placed::Lines { story: StoryRef::Body, para, .. } = it { + for (ci, id) in ¶.notes { + let c = ¶.clusters[*ci]; + marks.insert(*id, para.glyphs[c.g0 as usize..c.g1 as usize].iter().map(|g| g.gid).collect::>()); + } + } + } + marks +} + +#[test] +fn footnotes_restart_on_each_page_and_section() { + use wordcraft_doc::section::{NoteProps, NoteRestart}; + let mut d = Document::from_text(&"Body text line.\n".repeat(120)); + let a = footnote(&mut d, &Pos::body(2, 4), "First note."); + let b = footnote(&mut d, &Pos::body(4, 4), "Second note."); + let c = footnote(&mut d, &Pos::body(110, 4), "A later page's note."); + let l = lay(&d); + let page = |l: &DocLayout, id: u32| l.caret(&d.start_of(StoryRef::Part(id))).unwrap().page; + assert!(page(&l, c) > page(&l, a), "the third note is on a later page"); + let marks = note_marks(&l); + assert_ne!(marks[&c], marks[&a], "numbered on: 3 is not 1"); + // Restart each page: the later page's note is 1 again; the first page counts 1, 2. + d.settings.footnote_pr = NoteProps { num_restart: Some(NoteRestart::EachPage), ..Default::default() }; + let marks = note_marks(&lay(&d)); + assert_eq!(marks[&c], marks[&a]); + assert_ne!(marks[&b], marks[&a]); + // Restart each section, starting at 5, in a second section that starts at paragraph 50. + d.settings.footnote_pr = NoteProps::default(); + d.para_mut(StoryRef::Body, &Path::top(49)).unwrap().section = Some(Box::new(SectionProps::default())); + d.last_section.footnote_pr = NoteProps { num_restart: Some(NoteRestart::EachSect), num_start: Some(5), ..Default::default() }; + let nums = note_numbers(&d, false); + assert_eq!((nums[&a], nums[&b], nums[&c]), (1, 2, 5)); +} + +#[test] +fn endnotes_at_section_end_and_footnotes_below_text() { + use wordcraft_doc::section::{NotePos, NoteProps}; + let mut d = Document::from_text("Alpha\nBeta\nGamma\nDelta"); + let eid = d.add_part( + wordcraft_doc::PartKind::Endnote, + vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text("An endnote.", Default::default()))], + ); + d.insert_object( + &Pos::body(0, 5), + InlineObject::NoteRef { kind: wordcraft_doc::para::NoteKind::Endnote, id: eid, custom: String::new() }, + &Default::default(), + ) + .unwrap(); + // Two sections, the second on a new page. + d.para_mut(StoryRef::Body, &Path::top(1)).unwrap().section = Some(Box::new(SectionProps::default())); + let end_page = |d: &Document| { + let l = lay(d); + (l.caret(&d.start_of(StoryRef::Part(eid))).unwrap().page, l.caret(&Pos::body(2, 0)).unwrap().page) + }; + assert_eq!(end_page(&d), (1, 1), "end of document: after the second section's text"); + d.settings.endnote_pr = NoteProps { pos: Some(NotePos::SectEnd), ..Default::default() }; + assert_eq!(end_page(&d), (0, 1), "end of section: on the first section's page"); + // A footnote below the text sits right under the last line, above where it'd be at the bottom. + let fid = footnote(&mut d, &Pos::body(0, 2), "A footnote."); + let top = |d: &Document| lay(d).caret(&d.start_of(StoryRef::Part(fid))).unwrap().top; + let bottom = top(&d); + d.settings.footnote_pr = NoteProps { pos: Some(NotePos::BeneathText), ..Default::default() }; + let below = top(&d); + let text = lay(&d).caret(&Pos::body(1, 0)).unwrap(); + assert!(below > text.top + text.height && below < bottom - 100.0, "{below} {bottom} {text:?}"); +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 4b05440..b753dca 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -300,6 +300,10 @@ pub enum Dialog { text: String, shape: String, }, + /// References › Footnotes › Footnote and Endnote (#385). + NoteOptions { + form: Box, + }, } /// The Table Properties dialog's fields. Lengths are in the interface unit @@ -719,6 +723,7 @@ impl Dialog { Dialog::CustomizeKeyboard { .. } => "customizeKeyboard", Dialog::CustomizeRibbon { .. } => "customizeRibbon", Dialog::Enclose { .. } => "enclose", + Dialog::NoteOptions { .. } => "noteOptions", } } @@ -918,6 +923,7 @@ impl Dialog { "customizeQuickAccess" => { Dialog::CustomizeRibbon { form: Box::new(crate::dialogs_ribbon::RibbonForm::read(app, crate::dialogs_ribbon::Page::QuickAccess)) } } + "noteOptions" => Dialog::NoteOptions { form: Box::new(crate::dialogs_notes::NoteForm::read(app)) }, _ => return None, }) } @@ -1081,6 +1087,7 @@ pub fn show(app: &mut WordApp, ctx: &egui::Context) { Dialog::CustomizeKeyboard { .. } => "Customize Keyboard", Dialog::CustomizeRibbon { .. } => "Customize Ribbon", Dialog::Enclose { .. } => "Enclose Characters", + Dialog::NoteOptions { .. } => "Footnote and Endnote", }; let id = egui::Id::new(("dialog", title)); let shown = place_dialog(ctx, id, egui::Window::new(tl!(title)).id(id).collapsible(false).resizable(false)).open(&mut open).show(ctx, |ui| { @@ -2067,6 +2074,7 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool { } cancel } + Dialog::NoteOptions { form } => crate::dialogs_notes::note_options(app, ui, form), Dialog::FindRecipient { text, message } => { ui.horizontal(|ui| { ui.label(tl!("Find:")); diff --git a/crates/ui-egui/src/dialogs_notes.rs b/crates/ui-egui/src/dialogs_notes.rs new file mode 100644 index 0000000..ed2ec53 --- /dev/null +++ b/crates/ui-egui/src/dialogs_notes.rs @@ -0,0 +1,201 @@ +//! Footnote and Endnote (References › Footnotes launcher, #385): where notes go, how they are +//! numbered, for the whole document or the caret's section; Convert; Insert with a custom mark. +//! The form ends in commands (`references.noteOptions`, `references.convertNotes`, +//! `references.footnote` / `references.endnote`), so agents get the same result without it. + +use egui::{Ui, vec2}; +use serde::Serialize; +use serde_json::{Value, json}; + +use crate::WordApp; +use crate::theme::semibold; + +/// Number formats notes offer: OOXML name and a sample (not translated). +const FORMATS: [(&str, &str); 5] = [ + ("decimal", "1, 2, 3, …"), + ("lowerLetter", "a, b, c, …"), + ("upperLetter", "A, B, C, …"), + ("lowerRoman", "i, ii, iii, …"), + ("upperRoman", "I, II, III, …"), +]; + +/// The dialog's fields; `[footnotes, endnotes]` where each kind has its own. +#[derive(Clone, Debug, PartialEq, Serialize)] +pub struct NoteForm { + /// Which kind the format fields and Insert are for. + pub endnote: bool, + pub pos: [String; 2], + pub fmt: [String; 2], + pub start: [u32; 2], + pub restart: [String; 2], + /// A custom mark for Insert (empty: numbered). + pub mark: String, + /// Apply to the whole document (else the caret's section). + pub whole: bool, +} + +impl NoteForm { + /// The options in effect at the caret. + pub fn read(app: &mut WordApp) -> NoteForm { + let v = app.session.run("references.noteOptions", &json!({})).unwrap_or_default(); + let s = |kind: &str, k: &str, d: &str| v.get(kind).and_then(|o| o.get(k)).and_then(Value::as_str).unwrap_or(d).to_string(); + let n = |kind: &str| v.get(kind).and_then(|o| o.get("numStart")).and_then(Value::as_u64).unwrap_or(1).clamp(1, 32_767) as u32; + NoteForm { + endnote: false, + pos: [s("footnote", "pos", "pageBottom"), s("endnote", "pos", "docEnd")], + fmt: [s("footnote", "numFmt", "decimal"), s("endnote", "numFmt", "lowerRoman")], + start: [n("footnote"), n("endnote")], + restart: [s("footnote", "numRestart", "continuous"), s("endnote", "numRestart", "continuous")], + mark: String::new(), + whole: true, + } + } + + /// `references.noteOptions` parameters for the chosen kind. + pub fn params(&self) -> Value { + let k = usize::from(self.endnote); + let get = |a: &[String; 2]| a.get(k).cloned().unwrap_or_default(); + json!({ + "kind": if self.endnote { "endnote" } else { "footnote" }, + "pos": get(&self.pos), + "numFmt": get(&self.fmt), + "numStart": self.start.get(k).copied().unwrap_or(1), + "numRestart": get(&self.restart), + "scope": if self.whole { "document" } else { "section" }, + }) + } +} + +/// A choice from `options` ((value, label)) in a drop-down; `tr` translates the labels. +fn choice(ui: &mut Ui, id: &str, value: &mut String, options: &[(&str, &str)], tr: bool) { + let show = |l: &str| if tr { tl!(l).to_string() } else { l.to_string() }; + let label = options.iter().find(|(v, _)| *v == value.as_str()).map(|(_, l)| show(l)).unwrap_or_default(); + egui::ComboBox::from_id_salt(id).selected_text(label).width(170.0).show_ui(ui, |ui| { + for (v, l) in options { + ui.selectable_value(value, v.to_string(), show(l)); + } + }); +} + +/// Footnote and Endnote. Returns true to close. +pub fn note_options(app: &mut WordApp, ui: &mut Ui, f: &mut NoteForm) -> bool { + let heading = |ui: &mut Ui, s: &str| { + ui.add_space(4.0); + ui.label(egui::RichText::new(tl!(s)).font(semibold(12.5))); + }; + heading(ui, "Location"); + let mut convert = None; + egui::Grid::new("notes_location").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| { + ui.radio_value(&mut f.endnote, false, tl!("Footnotes")); + ui.add_enabled_ui(!f.endnote, |ui| { + choice(ui, "notes_fpos", &mut f.pos[0], &[("pageBottom", "Bottom of Page"), ("beneathText", "Below text")], true); + }); + ui.end_row(); + ui.radio_value(&mut f.endnote, true, tl!("Endnotes")); + ui.add_enabled_ui(f.endnote, |ui| { + choice(ui, "notes_epos", &mut f.pos[1], &[("sectEnd", "End of section"), ("docEnd", "End of document")], true); + }); + ui.end_row(); + ui.label(""); + ui.menu_button(format!("{}…", tl!("Convert")), |ui| { + for (to, l) in [("endnote", "Footnotes to endnotes"), ("footnote", "Endnotes to footnotes"), ("swap", "Swap footnotes and endnotes")] { + if ui.button(tl!(l)).clicked() { + convert = Some(to); + ui.close(); + } + } + }); + ui.end_row(); + }); + if let Some(to) = convert + && let Err(e) = app.run("references.convertNotes", json!({ "to": to })) + { + app.status(e); + } + heading(ui, "Format"); + let k = usize::from(f.endnote); + egui::Grid::new("notes_format").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| { + ui.label(tl!("Number format")); + if let Some(v) = f.fmt.get_mut(k) { + choice(ui, "notes_fmt", v, &FORMATS, false); + } + ui.end_row(); + ui.label(tl!("Custom mark:")); + ui.add(egui::TextEdit::singleline(&mut f.mark).desired_width(60.0).char_limit(10)); + ui.end_row(); + ui.label(tl!("Start at:")); + if let Some(v) = f.start.get_mut(k) { + ui.add(egui::DragValue::new(v).range(1..=32_767).speed(0.2)); + } + ui.end_row(); + ui.label(tl!("Numbering")); + if let Some(v) = f.restart.get_mut(k) { + let each_page: &[(&str, &str)] = if f.endnote { &[] } else { &[("eachPage", "Restart Each Page")] }; + let options: Vec<(&str, &str)> = + [("continuous", "Continuous numbering"), ("eachSect", "Restart Each Section")].into_iter().chain(each_page.iter().copied()).collect(); + choice(ui, "notes_restart", v, &options, true); + } + ui.end_row(); + ui.label(tl!("Apply to:")); + let mut scope = if f.whole { "document" } else { "section" }.to_string(); + choice(ui, "notes_scope", &mut scope, &[("document", "Whole document"), ("section", "This section")], true); + f.whole = scope == "document"; + ui.end_row(); + }); + // Insert / Apply / Cancel. + let (mut insert, mut apply, mut cancel) = (false, false, false); + ui.add_space(8.0); + ui.horizontal(|ui| { + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + cancel = ui.button(tl!("Cancel")).clicked() || ui.input(|i| i.key_pressed(egui::Key::Escape)); + apply = ui.button(tl!("Apply")).clicked(); + let label = if f.endnote { "Insert Endnote" } else { "Insert Footnote" }; + insert = ui.add(egui::Button::new(egui::RichText::new(tl!(label)).color(egui::Color32::WHITE)).fill(crate::theme::APP_COLOR)).clicked() + || ui.input(|i| i.key_pressed(egui::Key::Enter)); + }); + }); + if insert || apply { + if let Err(e) = app.run("references.noteOptions", f.params()) { + app.status(e); + return false; + } + if insert { + let id = if f.endnote { "references.endnote" } else { "references.footnote" }; + if let Err(e) = app.run(id, json!({ "mark": f.mark.trim() })) { + app.status(e); + } + } + } + insert || apply || cancel +} + +#[cfg(test)] +mod tests { + use serde_json::json; + use wordcraft_engine::Session; + + use crate::dialogs::Dialog; + use crate::{Services, WordApp}; + + /// #385: the Footnotes group's launcher opens Footnote and Endnote; its settings apply to the + /// caret's section. Scripts get the options, never the dialog. + #[test] + fn footnote_and_endnote_opens_from_the_launcher_and_applies() { + let mut a = WordApp::new(Session::new(wordcraft_doc::Document::new()), Services::default()); + assert_eq!(a.run("references.noteOptions", json!({})).unwrap(), json!({"pending": "noteOptions"})); + let Some(Dialog::NoteOptions { form }) = &mut a.dialog else { panic!("dialog") }; + assert_eq!((form.pos[0].as_str(), form.fmt[1].as_str(), form.start[0]), ("pageBottom", "lowerRoman", 1)); + form.pos[0] = "beneathText".into(); + form.restart[0] = "eachPage".into(); + form.whole = false; + let params = form.params(); + a.dialog = None; + a.run("references.noteOptions", params).unwrap(); + let o = a.session.doc.note_options(&a.session.doc.last_section, false); + assert_eq!(o.pos, wordcraft_doc::section::NotePos::BeneathText); + assert!(a.session.doc.settings.footnote_pr.is_empty(), "this section only"); + let got = a.execute("references.noteOptions", json!({})).unwrap(); + assert_eq!(got["footnote"]["numRestart"], "eachPage"); + assert!(a.dialog.is_none()); + } +} diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index c4b5554..9a4a91b 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -1448,3 +1448,15 @@ Middle En medio Click the empty text box the text should continue in (Esc cancels) Haga clic en el cuadro de texto vacío donde debe continuar el texto (Esc cancela) Not linked: click a text box Sin vincular: haga clic en un cuadro de texto + Location Ubicación + Endnotes Notas finales + Below text Debajo del texto + End of section Final de la sección + End of document Final del documento + Footnotes to endnotes Notas al pie a notas finales + Endnotes to footnotes Notas finales a notas al pie + Swap footnotes and endnotes Intercambiar notas al pie y notas finales + Custom mark: Marca personalizada: + Continuous numbering Numeración continua + Apply Aplicar + Convert Notes Convertir notas diff --git a/crates/ui-egui/src/i18n/et.tsv b/crates/ui-egui/src/i18n/et.tsv index 552e243..d3e99e2 100644 --- a/crates/ui-egui/src/i18n/et.tsv +++ b/crates/ui-egui/src/i18n/et.tsv @@ -1442,3 +1442,15 @@ Rotate all text 270° Pööra kogu teksti 270° Rotate all text 90° Pööra kogu teksti 90° Stacked Virnas + Location Asukoht + Endnotes Lõpumärkused + Below text Teksti all + End of section Jaotise lõpus + End of document Dokumendi lõpus + Footnotes to endnotes Allmärkused lõpumärkusteks + Endnotes to footnotes Lõpumärkused allmärkusteks + Swap footnotes and endnotes Vaheta all- ja lõpumärkused + Custom mark: Kohandatud märk: + Continuous numbering Pidev nummerdus + Apply Rakenda + Convert Notes Teisenda märkused diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index f73e524..be07e7d 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -1432,3 +1432,15 @@ Middle 中央 Click the empty text box the text should continue in (Esc cancels) テキストの続きを入れる空のテキスト ボックスをクリックしてください (Esc でキャンセル) Not linked: click a text box リンクされていません: テキスト ボックスをクリックしてください + Location 場所 + Endnotes 文末脚注 + Below text 本文の直後 + End of section セクションの最後 + End of document 文書の最後 + Footnotes to endnotes 脚注を文末脚注に変換 + Endnotes to footnotes 文末脚注を脚注に変換 + Swap footnotes and endnotes 脚注と文末脚注を入れ替え + Custom mark: 独自の記号: + Continuous numbering 通し番号 + Apply 適用 + Convert Notes 注の変換 diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 844e15f..7301e21 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -1444,3 +1444,15 @@ Middle Meio Click the empty text box the text should continue in (Esc cancels) Clique na caixa de texto vazia em que o texto deve continuar (Esc cancela) Not linked: click a text box Não vinculado: clique em uma caixa de texto + Location Local + Endnotes Notas de fim + Below text Abaixo do texto + End of section Fim da seção + End of document Fim do documento + Footnotes to endnotes Notas de rodapé em notas de fim + Endnotes to footnotes Notas de fim em notas de rodapé + Swap footnotes and endnotes Trocar notas de rodapé e notas de fim + Custom mark: Marca personalizada: + Continuous numbering Numeração contínua + Apply Aplicar + Convert Notes Converter notas diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 8534824..a013440 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -1822,3 +1822,15 @@ preview Title Пример Middle По центру Click the empty text box the text should continue in (Esc cancels) Щёлкните пустую надпись, в которой должен продолжиться текст (Esc — отмена) Not linked: click a text box Не связано: щёлкните надпись + Location Расположение + Endnotes Концевые сноски + Below text Под текстом + End of section Конец раздела + End of document Конец документа + Footnotes to endnotes Сноски в концевые сноски + Endnotes to footnotes Концевые сноски в сноски + Swap footnotes and endnotes Поменять сноски и концевые сноски + Custom mark: Особый знак: + Continuous numbering Сквозная нумерация + Apply Применить + Convert Notes Преобразовать сноски diff --git a/crates/ui-egui/src/i18n/sr-latn.tsv b/crates/ui-egui/src/i18n/sr-latn.tsv index 5b9ffdf..8ff65ac 100644 --- a/crates/ui-egui/src/i18n/sr-latn.tsv +++ b/crates/ui-egui/src/i18n/sr-latn.tsv @@ -1442,3 +1442,15 @@ Middle Sredina Click the empty text box the text should continue in (Esc cancels) Kliknite prazno polje za tekst u kom tekst treba da se nastavi (Esc otkazuje) Not linked: click a text box Nije povezano: kliknite polje za tekst + Location Lokacija + Endnotes Endnote + Below text Ispod teksta + End of section Kraj odeljka + End of document Kraj dokumenta + Footnotes to endnotes Fusnote u endnote + Endnotes to footnotes Endnote u fusnote + Swap footnotes and endnotes Zameni fusnote i endnote + Custom mark: Posebna oznaka: + Continuous numbering Neprekidna numeracija + Apply Primeni + Convert Notes Pretvori napomene diff --git a/crates/ui-egui/src/i18n/sr.tsv b/crates/ui-egui/src/i18n/sr.tsv index 7ff4c58..22c7892 100644 --- a/crates/ui-egui/src/i18n/sr.tsv +++ b/crates/ui-egui/src/i18n/sr.tsv @@ -1446,3 +1446,15 @@ Middle Средина Click the empty text box the text should continue in (Esc cancels) Кликните празно поље за текст у ком текст треба да се настави (Esc отказује) Not linked: click a text box Није повезано: кликните поље за текст + Location Локација + Endnotes Ендноте + Below text Испод текста + End of section Крај одељка + End of document Крај документа + Footnotes to endnotes Фусноте у ендноте + Endnotes to footnotes Ендноте у фусноте + Swap footnotes and endnotes Замени фусноте и ендноте + Custom mark: Посебна ознака: + Continuous numbering Непрекидна нумерација + Apply Примени + Convert Notes Претвори напомене diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index 0beb053..9a5c6c6 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -1441,3 +1441,15 @@ Middle Orta Click the empty text box the text should continue in (Esc cancels) Metnin devam edeceği boş metin kutusuna tıklayın (Esc iptal eder) Not linked: click a text box Bağlı değil: bir metin kutusuna tıklayın + Location Konum + Endnotes Son notlar + Below text Metnin altında + End of section Bölüm sonu + End of document Belge sonu + Footnotes to endnotes Dipnotlardan son notlara + Endnotes to footnotes Son notlardan dipnotlara + Swap footnotes and endnotes Dipnotları ve son notları değiştir + Custom mark: Özel işaret: + Continuous numbering Sürekli numaralandırma + Apply Uygula + Convert Notes Notları Dönüştür diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index f2d18d0..aab7ff8 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -1444,3 +1444,15 @@ Middle Посередині Click the empty text box the text should continue in (Esc cancels) Клацніть порожнє текстове поле, у якому має продовжитися текст (Esc — скасувати) Not linked: click a text box Не зв'язано: клацніть текстове поле + Location Розташування + Endnotes Кінцеві виноски + Below text Під текстом + End of section Кінець розділу + End of document Кінець документа + Footnotes to endnotes Виноски на кінцеві виноски + Endnotes to footnotes Кінцеві виноски на виноски + Swap footnotes and endnotes Поміняти виноски й кінцеві виноски + Custom mark: Власна позначка: + Continuous numbering Наскрізна нумерація + Apply Застосувати + Convert Notes Перетворити примітки diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index c88100e..ec06464 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -1444,3 +1444,15 @@ Middle 中部 Click the empty text box the text should continue in (Esc cancels) 单击要接续文字的空文本框(按 Esc 取消) Not linked: click a text box 未链接:请单击文本框 + Location 位置 + Endnotes 尾注 + Below text 文字下方 + End of section 节的结尾 + End of document 文档结尾 + Footnotes to endnotes 脚注转换为尾注 + Endnotes to footnotes 尾注转换为脚注 + Swap footnotes and endnotes 脚注和尾注互换 + Custom mark: 自定义标记: + Continuous numbering 连续编号 + Apply 应用 + Convert Notes 转换注释 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 2bdb962..dc546d6 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -1433,3 +1433,15 @@ Middle 中間 Click the empty text box the text should continue in (Esc cancels) 按一下要接續文字的空白文字方塊(按 Esc 取消) Not linked: click a text box 未連結:請按一下文字方塊 + Location 位置 + Endnotes 章節附註 + Below text 文字下方 + End of section 節的結尾 + End of document 文件結尾 + Footnotes to endnotes 註腳轉換為章節附註 + Endnotes to footnotes 章節附註轉換為註腳 + Swap footnotes and endnotes 註腳與章節附註互換 + Custom mark: 自訂標記: + Continuous numbering 連續編號 + Apply 套用 + Convert Notes 轉換附註 diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 94f8f64..f6751bc 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -22,6 +22,7 @@ pub mod dialogs; pub mod dialogs_insert; pub mod dialogs_keyboard; pub mod dialogs_lists; +pub mod dialogs_notes; pub mod dialogs_para; pub mod dialogs_ribbon; pub mod equation_tab; @@ -1190,6 +1191,8 @@ fn input_dialog(id: &str, params: &Value) -> Option<&'static str> { "review.wordCount" if params.as_object().is_none_or(|m| m.is_empty()) => Some("wordCount"), // Enclose Characters without a shape (null is one: it takes the enclosure away). "format.enclose" if params.get("shape").is_none() => Some("enclose"), + // Footnote and Endnote without settings shows its dialog (#385). + "references.noteOptions" if params.as_object().is_none_or(|m| m.is_empty()) => Some("noteOptions"), _ => None, } } diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 6e85ca7..0699914 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -1024,7 +1024,7 @@ fn references(app: &mut WordApp, ui: &mut Ui) { small(ui, app, "updateTable", Some("Update Table"), "Update Table", "references.updateToc", json!({}), false); }); }); - group(ui, "Footnotes", None, app, |ui, app| { + group(ui, "Footnotes", Some("references.noteOptions"), app, |ui, app| { big(ui, app, "footnote", "Insert\nFootnote", "references.footnote", json!({}), false); stack(ui, |ui| { small(ui, app, "endnote", Some("Insert Endnote"), "Insert Endnote", "references.endnote", json!({}), false); diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 46f3d9d..d6cf3ba 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -49,7 +49,7 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen | Text, runs, paragraph and character properties, styles, numbering, sections | ✅ | ✅ | Including complex-script props (`w:rtl`, `w:cs`, `w:szCs`, `w:bCs`…), East Asian fonts kept apart from Latin (#111) | | Tables incl. floating (`w:tblpPr`), table styles and conditional formatting | ✅ | ✅ | Custom table styles round-trip (#256); right-to-left tables (`w:bidiVisual`) and sections (`w:bidi`) round-trip (#362) | | Headers/footers (first, even/odd), page borders, line numbers, gutter, mirror margins | ✅ | ✅ | | -| Footnotes, endnotes, comments | ✅ | ✅ | The document's own note separators and continuation notice (`w:type` `separator`, `continuationSeparator`, `continuationNotice`) round-trip as ids -1, 0 and 1 (#352); `commentsExtended` written; `commentsIds`, modern threaded comments (`w16cex`) partly | +| Footnotes, endnotes, comments | ✅ | ✅ | The document's own note separators and continuation notice (`w:type` `separator`, `continuationSeparator`, `continuationNotice`) round-trip as ids -1, 0 and 1 (#352); note options (`w:pos`, `w:numFmt`, `w:numStart`, `w:numRestart`) round-trip in the settings and in each `w:sectPr` (#385); `commentsExtended` written; `commentsIds`, modern threaded comments (`w16cex`) partly | | Tracked insertions and deletions (`w:ins`/`w:del`), paragraph-mark revisions | ✅ | ✅ | #125, #244 | | Formatting revisions (`w:rPrChange`, `w:pPrChange`, `w:sectPrChange`, `w:tblPrChange`, `w:trPrChange`, `w:tcPrChange`, `w:numberingChange`) | ✅ | ✅ | #41: kept on open and save, schema order (change element last); changes inside `styles.xml` are dropped. RTF/ODT ignore them | | Move tracking (`w:moveFrom`/`w:moveTo`) | 🟡 | ❌ | Read as plain insert/delete | @@ -100,6 +100,7 @@ Measured by grepping `crates/docx/src/read` and `src/write` for the OOXML elemen | 2026-10-10 | trivial | `wrapText` on square, tight and through wraps read and written (#354) | | 2026-10-10 | trivial | Wrap polygons for Tight and Through wrapping read and written (#354) | | 2026-10-10 | trivial | DrawingML rotation and flips read and written (#332) | +| 2026-10-11 | trivial | Footnote and endnote options round-trip for the document and per section (#385) | | 2026-10-10 | trivial | Equation breaks and `m:mathPr` wrapping options round-trip (#326) | | 2026-10-10 | trivial | Custom table styles round-trip (#256 merged) | | 2026-10-10 | major | First version: Word's full format list, read/write status, DOCX element coverage from the source, fidelity and hours | diff --git a/docs/gaps.md b/docs/gaps.md index c064817..76fe800 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -64,6 +64,7 @@ The same gaps grouped the way the parity documents are, for agents working in on | Date | Change | Summary | |---|---|---| +| 2026-10-11 | trivial | #4: note options per section (below text, end of section, restart each section/page) and the Footnote and Endnote dialog (#385) | | 2026-10-11 | trivial | #10: Shape Format › Text: text direction, align text and linked text boxes (#369) | | 2026-10-11 | trivial | #16: right-to-left tables (`w:bidiVisual`) and sections (`w:bidi`) laid out mirrored, with `table.rtl` and `layout.sectionRtl` (#362) | | 2026-10-11 | trivial | #4: columns balance before a continuous section break (#350) | diff --git a/docs/layout-parity.md b/docs/layout-parity.md index ca9a1e3..7e1341c 100644 --- a/docs/layout-parity.md +++ b/docs/layout-parity.md @@ -31,7 +31,7 @@ font can't match Word's metrics. | Columns, column breaks, separators, balancing | ✅ | Columns balance on a section's last page when the next section starts continuous (also at the document end, which Word only balances when the document ends with a continuous break); not after next-page/even/odd breaks or on a page with a column break; keep-together, keep-with-next, widow/orphan control and unsplittable rows can leave them slightly uneven (#350) | — | | Section breaks (next page, continuous, even, odd), different first page, odd/even headers | ✅ | Section breaks don't show in Draft view (#42) | 1–2 | | Body top below a tall header, footer pushes body up | ✅ | #138 | — | -| Footnotes at the page bottom, endnotes at the end, note numbering | 🟡 | A long note continues onto the next page at a line boundary (its first line stays with the reference), first in that page's note area under a full-width continuation separator (#352); the document's own separator, continuation separator and continuation notice are drawn (the notice under a note that continues; endnotes use the document's separator but don't continue onto a page of their own); per-section restart partial | 1–3 | +| Footnotes at the page bottom, endnotes at the end, note numbering | 🟡 | A long note continues onto the next page at a line boundary (its first line stays with the reference), first in that page's note area under a full-width continuation separator (#352); the document's own separator, continuation separator and continuation notice are drawn (the notice under a note that continues; endnotes use the document's separator but don't continue onto a page of their own); each section's note options: footnotes below the text, endnotes at the end of each section, start at, numbering restarted each section or each page (counted from where the notes land, at most three passes) (#385) | 1–2 | | Tables: row heights (at least/exact), rows split across pages, header rows repeat, Can't Split | ✅ | 2026-10-06, #138 | — | | Tables: autofit to contents/window, fixed widths, Word 2013 edge | 🟡 | #137; #44: AutoFit Contents measures each column's narrowest and widest text and shares the width like Word, once (not live as you type); cell preferred widths and spans still differ | 3–5 | | Floating tables (`w:tblpPr`) | 🟡 | #137; overlap rules untested | 2–3 | @@ -79,6 +79,7 @@ font can't match Word's metrics. | 2026-10-10 | trivial | Wrap side: text on both sides, left only, right only or the largest side of an object (#354) | | 2026-10-10 | trivial | Tight and Through wrapping follow the object's outline (#354) | | 2026-10-10 | trivial | Rotated and flipped objects laid out and drawn (#332) | +| 2026-10-11 | trivial | Footnotes below text, endnotes at the end of each section, note numbering restarted per section or page (#385) | | 2026-10-10 | trivial | Long display equations wrap across lines (#326) | | 2026-10-10 | trivial | AutoFit Contents measures the text (#44) | | 2026-10-10 | major | First version: layout and pagination checklist with evidence from `crates/layout`, PRs and issues | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 8d3818a..31f6d75 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -63,7 +63,7 @@ full parity for the area. | Tables | 8 | 76% | 72% | 10–16 | Merge/split, styles (cell text takes style formatting, #104), custom table styles saved in the .docx (#256), Word 2013 edge, floating tables, rows split across pages, sort, formula, cell gridlines (#243), Draw Table and Eraser (#303). Missing: splitting or erasing around vertically merged cells, nested-table polish, interactive column resize (#49 open, #217). Cell text direction landed (#245). | | Page layout and sections | 7 | 70% | 72% | 12–20 | Margins, size, columns, breaks, page borders, line numbers, vertical alignment, drop caps, hyphenation. Columns don't balance; no document grid; no vertical text; RTL sections missing. Detail: [layout-parity.md](layout-parity.md). | | Headers, footers, page numbers, fields | 5 | 70% | 70% | 8–12 | First/even/odd, link to previous, TOC as an updatable field (#52), cross-references to real bookmarks (#227). Field coverage is a subset of Word's ~90 field codes; Field dialog missing. | -| Footnotes and endnotes | 3 | 60% | 60% | 5–8 | Placed and editable, numbers from Word files (#103). Long notes don't continue onto the next page; no continuation separator; no note options per section. | +| Footnotes and endnotes | 3 | 60% | 60% | 3–5 | Placed and editable, numbers from Word files (#103); long notes continue onto the next page under the continuation separator (#352). Footnote and Endnote dialog with options per section: below text, end of section, number format, start at, restart each section or page, custom mark, convert (#385). | | References (TOC, citations, bibliography, captions, index, TOA) | 5 | 65% | 60% | 12–20 | Working first versions, Zotero integration (#189), Zotero/Mendeley `ADDIN` fields round-trip. All 12 of Word's bibliography styles (APA, MLA, Chicago, IEEE, Harvard, ISO 690 author-date and numerical, Turabian, GB/T 7714, GOST name and title sort, SIST02; #383); source manager depth; bibliography sources not yet in DOCX (#169 open). | | Review (proofing UI, comments, track changes, compare, protect) | 8 | 72% | 75% | 15–25 | Comment balloons, tracked insert/delete incl. paragraph marks (#244, #125), compare, restrict editing. Formatting revisions (#41): kept in DOCX, recorded while tracking, accepted/rejected, "Formatted: …" balloons and change bars. Missing: move tracking, insert/delete balloons, Translate, Block Authors, modern comment threads/mentions. Lowered: these gaps are confirmed by source (no `rPrChange` anywhere). | | Proofing (spelling, grammar, thesaurus, languages) | 5 | 35% | (in Review) | 25–45 | English only: one dictionary (Moby list plus our supplement of modern words such as "desktop", #520), a rule-based grammar checker. Word ships 120 proofing tools for ~50 languages plus Editor (style refinements, similarity). Issues #25, #40, #100. | @@ -249,6 +249,7 @@ can be used without committing Word output. | Date | Change | Summary | |---|---|---| +| 2026-10-11 | trivial | Footnotes and endnotes: Footnote and Endnote dialog, options per section (position, format, start, restart each section/page), convert notes (#385); no percentage change | | 2026-10-11 | trivial | Pictures, shapes, text boxes: text direction, Align Text and linked text boxes (Create Link / Break Link), DOCX `bodyPr` `vert`/`anchor` and `wps:linkedTxbx` (#369); no percentage change | | 2026-10-10 | trivial | Drawing Canvas (#344): Insert › Shapes › New Drawing Canvas and Draw › Drawing Canvas live; catalog 402/434 → 404/434 (93.1%) | | 2026-10-11 | trivial | Mailings: Excel and OpenDocument spreadsheets as recipient lists (#334); score unchanged |