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 21:10:17 +02:00 committed by GitHub
parent f8f1d80c38
commit 4e710a920b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
37 changed files with 1099 additions and 107 deletions

View File

@ -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 |

View File

@ -133,6 +133,87 @@ fn box_ids(p: &Paragraph) -> impl Iterator<Item = u32> + '_ {
}
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<u32> = 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<String>| {
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.)

View File

@ -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<String>,
/// 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 {
(&sect.endnote_pr, &self.settings.endnote_pr, self.settings.endnote_format)
} else {
(&sect.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<u32> {
@ -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<Block>], 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() {

View File

@ -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> {
[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> {
[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<NotePos>,
#[serde(skip_serializing_if = "Option::is_none")]
pub num_fmt: Option<NumFormat>,
#[serde(skip_serializing_if = "Option::is_none")]
pub num_start: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub num_restart: Option<NoteRestart>,
}
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<Box<crate::props::PropChange<SectionProps>>>,
/// 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(),
}
}
}

View File

@ -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"));

View File

@ -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<HfRef>) {
}
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<HfRef>) {
(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};

View File

@ -858,11 +858,12 @@ fn settings_xml(doc: &Document, footnotes: bool, endnotes: bool) -> Vec<u8> {
}
}
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")]);

View File

@ -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<wordcraft_doc::props::Rgb>) {
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());
}
}

View File

@ -177,7 +177,7 @@ fn styles_numbering_settings_notes_comments_from_parts() {
r#"<w:numbering {W_NS}><w:abstractNum w:abstractNumId="7"><w:name w:val="L"/><w:lvl w:ilvl="0"><w:start w:val="3"/><w:numFmt w:val="upperLetter"/><w:lvlText w:val="%1)"/><w:lvlJc w:val="right"/><w:pPr><w:ind w:left="720" w:hanging="360"/></w:pPr></w:lvl><w:lvl w:ilvl="1"><w:numFmt w:val="bullet"/><w:suff w:val="space"/><w:lvlText w:val="o"/><w:rPr><w:rFonts w:ascii="Courier New"/></w:rPr></w:lvl></w:abstractNum><w:num w:numId="2"><w:abstractNumId w:val="7"/><w:lvlOverride w:ilvl="0"><w:startOverride w:val="5"/></w:lvlOverride></w:num></w:numbering>"#
);
let settings = format!(
r#"<w:settings {W_NS}><w:trackRevisions/><w:defaultTabStop w:val="708"/><w:evenAndOddHeaders w:val="1"/><w:footnotePr><w:numFmt w:val="lowerRoman"/></w:footnotePr></w:settings>"#
r#"<w:settings {W_NS}><w:trackRevisions/><w:defaultTabStop w:val="708"/><w:evenAndOddHeaders w:val="1"/><w:footnotePr><w:pos w:val="sideways"/><w:numFmt w:val="lowerRoman"/><w:numStart w:val="-99999999999"/><w:numRestart w:val="eachPage"/></w:footnotePr></w:settings>"#
);
let footnotes = format!(
r#"<w:footnotes {W_NS}><w:footnote w:type="separator" w:id="-1"><w:p><w:r><w:separator/></w:r></w:p></w:footnote><w:footnote w:id="1"><w:p><w:r><w:footnoteRef/></w:r><w:r><w:t xml:space="preserve"> Note text</w:t></w:r></w:p></w:footnote></w:footnotes>"#
@ -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"));

View File

@ -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("<w:footnotePr><w:numFmt w:val=\"lowerRoman\"/><w:numStart w:val=\"7\"/><w:numRestart w:val=\"eachSect\"/></w:footnotePr>")
< at("<w:type")
);
}
/// `references.footnote` starts the note's own text with a reference to the note, so the editor shows
/// its number. In the file that is the `w:footnoteRef` mark; a `w:footnoteReference` there would make
/// the note cite itself, which LibreOffice refuses to open.

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}))

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();

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) {

View File

@ -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<NoteKind> {
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!(&note.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());
}

View File

@ -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<HashMap<u32, u32>>,
/// Note part id → its number.
pub notes: Arc<HashMap<u32, NoteNum>>,
/// Document title / author for TITLE / AUTHOR fields.
pub title: Arc<str>,
pub author: Arc<str>,
pub filename: Arc<str>,
}
/// 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) {

View File

@ -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<u32, u32> {
/// 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<u32, usize>) -> HashMap<u32, fields::NoteNum> {
use wordcraft_doc::section::NoteRestart;
let sections = doc.sections();
let opts: Vec<[wordcraft_doc::section::NoteOptions; 2]> =
sections.iter().map(|(_, s)| [doc.note_options(s, false), doc.note_options(s, true)]).collect();
let mut m = HashMap::new();
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<usize>)>; 2] = [None, None];
let mut order = [0u32; 2];
let mut si = 0usize;
// Reading order, text boxes included where they are. A hidden reference mark still takes
// its number, as in Word; a deleted one (or one in a deleted text box) does not.
let mut cur: Option<(usize, Vec<bool>)> = 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<u32, u32> {
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<u32, u32> {
note_info(doc, hide_deleted, &HashMap::new()).into_iter().map(|(id, n)| (id, n.n)).collect()
}
/// The page each footnote's text starts on.
fn footnote_pages(doc: &Document, pages: &[Page]) -> HashMap<u32, usize> {
let mut m = HashMap::new();
for (pi, p) in pages.iter().enumerate() {
for it in &p.items {
if let Placed::Lines { story: StoryRef::Part(id), .. } | Placed::Cell { story: StoryRef::Part(id), .. } = it
&& doc.parts.get(id).is_some_and(|part| part.kind == wordcraft_doc::PartKind::Footnote)
{
m.entry(*id).or_insert(pi);
}
}
}
m
}
/// 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<usize>,
/// 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<u32, usize>) -> 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<u32> = {
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<u32> = 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<u32>, block: usize) {
if ids.is_empty() || pb.web {
return;
}
// The document's own endnote separator, else a short line.
let width = pb.col_w();
if let Some((items, h)) = ctx.doc.endnote_separators.separator.as_ref().map(|b| sep_box(ctx, b, width)) {
let (x, y) = (pb.col_x(), pb.y);
if let Some(pg) = pb.page() {
pg.items.extend(items.into_iter().map(|mut it| {
it.translate(x, y);
it
}));
}
pb.y += h;
} else {
pb.y += 12.0;
let (x, ry) = (pb.col_x(), pb.y);
if let Some(pg) = pb.page() {
pg.items.push(Placed::Rule { x0: x, y0: ry, x1: x + NOTE_SEP_W, y1: ry, border: Border::single(0.5) });
}
pb.y += 8.0;
}
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<f32> {
let f = para.lines.get(l0)?;
let l = para.lines.get(l1.checked_sub(1)?)?;

View File

@ -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()
};

View File

@ -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<u32, Vec<u32>> {
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 &para.notes {
let c = &para.clusters[*ci];
marks.insert(*id, para.glyphs[c.g0 as usize..c.g1 as usize].iter().map(|g| g.gid).collect::<Vec<_>>());
}
}
}
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:?}");
}

View File

@ -300,6 +300,10 @@ pub enum Dialog {
text: String,
shape: String,
},
/// References › Footnotes › Footnote and Endnote (#385).
NoteOptions {
form: Box<crate::dialogs_notes::NoteForm>,
},
}
/// 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:"));

View File

@ -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());
}
}

View File

@ -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

Can't render this file because it contains an unexpected character in line 6 and column 25.

View File

@ -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

Can't render this file because it has a wrong number of fields in line 12.

View File

@ -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 注の変換

Can't render this file because it has a wrong number of fields in line 3.

View File

@ -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

Can't render this file because it contains an unexpected character in line 6 and column 25.

View File

@ -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 Преобразовать сноски

Can't render this file because it contains an unexpected character in line 7 and column 25.

View File

@ -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

Can't render this file because it contains an unexpected character in line 6 and column 25.

View File

@ -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 Претвори напомене

Can't render this file because it contains an unexpected character in line 11 and column 25.

View File

@ -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

Can't render this file because it has a wrong number of fields in line 18.

View File

@ -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 Перетворити примітки

Can't render this file because it contains an unexpected character in line 6 and column 31.

View File

@ -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 转换注释

Can't render this file because it contains an unexpected character in line 7 and column 25.

View File

@ -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 轉換附註

Can't render this file because it has a wrong number of fields in line 4.

View File

@ -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,
}
}

View File

@ -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);

View File

@ -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 |

View File

@ -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) |

View File

@ -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 |

View File

@ -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 |