- 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:
parent
f8f1d80c38
commit
4e710a920b
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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.)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
(§.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<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() {
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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")]);
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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}))
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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(§, 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<Pos> {
|
||||
let mut v = Vec::new();
|
||||
for path in s.doc.para_paths(StoryRef::Body) {
|
||||
|
|
|
|||
|
|
@ -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!(¬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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)?)?;
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 ¶.notes {
|
||||
let c = ¶.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:?}");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:"));
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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.
|
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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) |
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
Loading…
Reference in New Issue