Files
wordcraft/crates/engine/src/cmd/references.rs
T
8c812bf1d6 #1230: Insert Caption on pictures and shapes; Different First Page in Page Setup (#2615)
* #1230: Insert Caption on pictures and shapes; Page Setup header and footer options

- Right-clicking a picture, shape, text box or chart offers Insert Caption…, as in Word.
- references.caption with a picture or shape selected puts the caption next to the
  paragraph holding it, in the same cell or text box (it used to go after the whole table).
- The Caption dialog proposes Figure for a picture selected in a table, not Table.
- Page Setup: Headers and footers check boxes (Different Odd & Even Pages, Different First
  Page), applied on OK only if changed, in one undo step; a title page can go unnumbered.
- Tests: engine caption_for_an_object_in_a_cell_stays_in_the_cell; kittest
  right_click_on_a_picture_offers_insert_caption and
  page_setup_different_first_page_leaves_the_title_page_unnumbered.
- Docs: ui-parity, target-app-parity, ROADMAP progress log.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P8veLveuUsJJm6UEcQPEuW

* ui-parity: keep Insert Caption on the context-menu row

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-18 11:45:24 -04:00

1424 lines
70 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! References tab: table of contents, footnotes/endnotes, captions; field updates.
use serde_json::{Value, json};
use wordcraft_doc::numbering::{Counters, LevelSuffix};
use wordcraft_doc::para::{InlineObject, NoteKind, OBJ};
use wordcraft_doc::props::{CharProps, TabAlign, TabLeader, TabStop};
use wordcraft_doc::section::NumFormat;
use wordcraft_doc::{Block, Paragraph, PartKind, Path, Pos, StoryRef, para_block};
use super::{delete_selection, sel_result};
use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p};
pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("references.toc", "Table of Contents", "References › Table of Contents", insert_toc).params(
r#"{"levels"?: 1-9, "title"?: string, "pageNumbers"?: bool, "rightAlign"?: bool, "tabLeader"?: "dot|hyphen|underscore|none", "hyperlinks"?: bool, "headingStyles"?: bool, "outlineLevels"?: bool, "tcFields"?: bool, "styleLevels"?: {style name: 1-9}} (replaces an existing table; {} updates it)"#,
),
CommandSpec::new("references.updateToc", "Update Table", "References › Table of Contents", |s, _| {
update_toc(s)?;
sel_result(s)
}),
CommandSpec::new("references.removeToc", "Remove Table of Contents", "References › Table of Contents", |s, _| {
if let Some((start, end)) = toc_range(s) {
for i in (start..=end).rev() {
s.doc.remove_block(StoryRef::Body, &Path::top(i))?;
}
s.clamp_selection();
}
sel_result(s)
}),
CommandSpec::new("references.addText", "Add Text", "References › Table of Contents", |s, v| {
let level = p::u64(v, "level").unwrap_or(0).min(9) as u8;
super::para::fmt(s, &|pp| pp.outline_level = if level == 0 { Some(9) } else { Some(level - 1) })
})
.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, "mark"?: string (a custom mark instead of the number)}"#),
CommandSpec::new("references.endnote", "Insert Endnote", "References › Footnotes", |s, v| note(s, v, NoteKind::Endnote))
// macOS takes ⌘⌥D to show and hide the Dock; Word for Mac uses ⌘⌥E.
.key(super::mac_or("Mod+Alt+E", "Mod+Alt+D"))
.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, Some(NoteKind::Footnote));
if let Some(p) = refs.iter().find(|p| **p > caret).or(refs.first()) {
s.sel = Selection::caret(p.clone());
}
sel_result(s)
})
.key(super::non_mac("Alt+Shift+> / Alt+Shift+."))
.pure(),
CommandSpec::new("references.notes", "Show Notes", "References › Footnotes", |s, _| {
let mut out = Vec::new();
for (id, part) in &s.doc.parts {
if matches!(part.kind, PartKind::Footnote | PartKind::Endnote) {
out.push(json!({"id": id, "kind": part.kind, "text": s.doc.plain_text(StoryRef::Part(*id))}));
}
}
Ok(Value::Array(out))
})
.pure(),
CommandSpec::new("references.caption", "Insert Caption", "References › Captions", caption)
.params(r#"{"label"?: "Figure|Table|Equation|any label", "text"?: string, "position"?: "below|above", "excludeLabel"?: bool, "format"?: "ARABIC|alphabetic|ALPHABETIC|roman|ROMAN", "chapter"?: heading level 1-9, "separator"?: "-|.|:|—|–"}"#),
// F9: update fields; the selected ones that showed their code show their result again.
CommandSpec::new("references.updateFields", "Update Field", "References", |s, v| {
update_fields_in(s, v.get("all").and_then(Value::as_bool).unwrap_or(false))?;
super::fields::show_results(s)?;
sel_result(s)
})
.params(r#"{"all"?: bool (the body and every header, footer, note and text box, wherever the caret is; default: the story the caret is in)}"#)
.key("F9"),
]
}
/// A field instruction split into its name, positional arguments and switches (ECMA-376
/// §17.16.1): `TOC \o "1-3" \h` → name `TOC`, switches `o` = `1-3` and `h`. Quoted text stays
/// whole (without the quotes; `\"` and `\\` inside it are a quote and a backslash). A switch
/// takes the word after it as its argument unless that word is another switch.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct FieldCode {
pub name: String,
pub args: Vec<String>,
/// Switch letter (lower-cased; `*`, `@`, `#` for the general switches) and argument.
pub switches: Vec<(char, Option<String>)>,
}
impl FieldCode {
pub fn parse(instr: &str) -> FieldCode {
let mut words: Vec<(String, bool)> = Vec::new();
let mut chars = instr.chars().peekable();
while let Some(&c) = chars.peek() {
if c.is_whitespace() {
chars.next();
} else if c == '"' {
chars.next();
let mut w = String::new();
while let Some(c) = chars.next() {
match c {
'"' => break,
'\\' if matches!(chars.peek(), Some('"' | '\\')) => w.extend(chars.next()),
c => w.push(c),
}
}
words.push((w, true));
} else {
let mut w = String::new();
while let Some(&c) = chars.peek() {
if c.is_whitespace() || (c == '"' && !w.is_empty()) {
break;
}
w.push(c);
chars.next();
}
words.push((w, false));
}
}
let mut words = words.into_iter().peekable();
let mut f =
FieldCode { name: words.next().map(|(w, _)| w.trim_start_matches('=').to_ascii_uppercase()).unwrap_or_default(), ..Default::default() };
while let Some((w, quoted)) = words.next() {
let sw = if quoted { None } else { w.strip_prefix('\\').and_then(|r| r.chars().next()) };
match sw {
Some(c) => {
let arg = match words.peek() {
Some((n, q)) if *q || !n.starts_with('\\') => words.next().map(|(n, _)| n),
_ => None,
};
f.switches.push((c.to_ascii_lowercase(), arg));
}
None => f.args.push(w),
}
}
f
}
pub fn has(&self, c: char) -> bool {
self.switches.iter().any(|(k, _)| *k == c)
}
/// The argument of switch `c`, if the switch is there with one.
pub fn arg(&self, c: char) -> Option<&str> {
self.switches.iter().find(|(k, _)| *k == c).and_then(|(_, a)| a.as_deref())
}
}
/// A level range such as `1-3` (or one level, `2`), clamped to 1–9.
pub fn level_range(s: &str) -> Option<(u8, u8)> {
let mut it = s.split('-').map(|x| x.trim().parse::<u8>().ok());
let a = it.next().flatten()?.clamp(1, 9);
let b = match it.next() {
Some(b) => b?.clamp(1, 9),
None => a,
};
Some((a.min(b), a.max(b)))
}
/// The tab leader named in a command parameter.
pub fn leader(name: &str) -> TabLeader {
match name {
"none" => TabLeader::None,
"hyphen" => TabLeader::Hyphen,
"underscore" => TabLeader::Underscore,
"middleDot" => TabLeader::MiddleDot,
_ => TabLeader::Dot,
}
}
/// The TOC field instruction for `references.toc`'s parameters (ECMA-376 §17.16.5.68): `\o`
/// heading styles, `\h` hyperlinks, `\z` hide tab and page numbers in Web view, `\u` outline
/// levels, `\f`/`\l` TC fields, `\n` without page numbers, `\p` separator, `\t` styles with levels.
pub fn toc_instr(v: &Value) -> String {
let levels = p::u64(v, "levels").unwrap_or(3).clamp(1, 9);
let on = |k: &str, d: bool| p::bool(v, k).unwrap_or(d);
let mut s = String::from("TOC");
if on("headingStyles", true) {
s.push_str(&format!(" \\o \"1-{levels}\""));
}
if on("hyperlinks", true) {
s.push_str(" \\h");
}
s.push_str(" \\z");
if on("outlineLevels", true) {
s.push_str(" \\u");
}
if on("tcFields", false) {
s.push_str(&format!(" \\f \\l \"1-{levels}\""));
}
if !on("pageNumbers", true) {
s.push_str(&format!(" \\n \"1-{levels}\""));
} else if !on("rightAlign", true) {
s.push_str(" \\p \" \"");
}
let clean = |n: &str| n.chars().filter(|c| !matches!(c, '"' | ',' | '\\')).take(64).collect::<String>().trim().to_string();
let pairs: Vec<String> = match v.get("styleLevels") {
Some(Value::Object(m)) => m
.iter()
.take(64)
.filter_map(|(name, l)| Some((clean(name), l.as_u64()?.clamp(1, 9))))
.filter(|(n, _)| !n.is_empty())
.map(|(n, l)| format!("{n},{l}"))
.collect(),
_ => Vec::new(),
};
if !pairs.is_empty() {
s.push_str(&format!(" \\t \"{}\"", pairs.join(",")));
}
s
}
/// A TOC entry: the paragraph it points at, its level (0-based), its text, and the leading page
/// breaks of that paragraph (its page is where the text after them lands).
struct TocEntry {
path: Path,
level: u8,
text: String,
lead: usize,
}
/// The headings of the body, in document order, as (body block, level 1–9, text, leading page
/// breaks): what Insert › Link › Place in This Document lists.
pub(crate) fn headings(s: &Session) -> Vec<(usize, u8, String, usize)> {
let f = FieldCode::parse("TOC \\o \"1-9\" \\u");
toc_entries(s, &f).into_iter().filter(|e| e.path.depth() == 0).map(|e| (e.path.last(), e.level.saturating_add(1), e.text, e.lead)).collect()
}
/// The heading level (1–9) of a built-in heading style.
fn heading_style_level(p: &Paragraph) -> Option<u8> {
p.props.style.as_deref()?.strip_prefix("Heading")?.trim().parse::<u8>().ok().filter(|l| (1..=9).contains(l))
}
/// The entries a TOC field collects from the body, in document order: built-in heading styles
/// in the `\o` range, paragraphs with an outline level (`\u`), styles listed in `\t`, and TC
/// fields (`\f`, levels `\l`).
fn toc_entries(s: &Session, f: &FieldCode) -> Vec<TocEntry> {
let o = f.has('o').then(|| f.arg('o').and_then(level_range).unwrap_or((1, 9)));
let max = o.map(|r| r.1).unwrap_or(9);
let styles: Vec<(String, u8)> = f
.arg('t')
.map(|t| {
let parts: Vec<&str> = t.split([',', ';']).map(str::trim).collect();
parts.chunks(2).filter_map(|c| Some((c.first()?.to_string(), c.get(1)?.parse::<u8>().ok()?.clamp(1, 9)))).collect()
})
.unwrap_or_default();
let tc = f.has('f').then(|| f.arg('f').unwrap_or("").to_ascii_uppercase());
let tc_levels = f.arg('l').and_then(level_range).unwrap_or((1, 9));
let mut v = Vec::new();
// Every numbered paragraph advances the list counters, so an entry carries the number its
// heading shows (`1.1`), followed by the level's separator, as Word writes it.
let mut counters = Counters::default();
for path in s.doc.para_paths(StoryRef::Body) {
let Some(p) = s.doc.para(StoryRef::Body, &path) else { continue };
let number = p
.props
.numbering
.or_else(|| s.doc.styles.resolve_para(&p.props).numbering)
.filter(|n| n.num != 0)
.and_then(|n| counters.next_label(&s.doc.numbering, n.num, n.level))
.filter(|(label, def)| !label.is_empty() && !matches!(def.format, NumFormat::Bullet | NumFormat::None))
.map(|(label, def)| match def.suffix {
LevelSuffix::Tab => format!("{label}\t"),
LevelSuffix::Space => format!("{label} "),
LevelSuffix::Nothing => label,
});
let style = p.props.style.as_deref().unwrap_or("Normal");
if style.starts_with("TOC") {
continue;
}
let lead = p.text.bytes().take_while(|b| *b == 0x0C).count();
let by_style =
styles.iter().find(|(n, _)| s.doc.styles.get(style).is_some_and(|st| st.name.eq_ignore_ascii_case(n)) || n == style).map(|(_, l)| *l);
// `Some(9)` direct outline level is body text: "Do Not Show in Table of Contents".
let by_outline = f.has('u').then_some(p.props.outline_level).flatten().filter(|l| *l < 9).map(|l| l + 1).filter(|l| *l <= max);
let hidden = f.has('u') && p.props.outline_level == Some(9);
let by_heading = o.and_then(|(a, b)| {
let l = heading_style_level(p).or_else(|| {
s.doc
.styles
.resolve_para(&wordcraft_doc::ParaProps { style: p.props.style.clone(), ..Default::default() })
.outline_level
.map(|l| l + 1)
})?;
(a..=b).contains(&l).then_some(l)
});
if let Some(l) = by_style.or(by_outline).or(if hidden { None } else { by_heading }) {
let t = p.plain_text().replace(['\u{000C}', '\n', '\t'], " ").trim().to_string();
if !t.is_empty() {
let text = number.map_or_else(|| t.clone(), |n| format!("{n}{t}"));
v.push(TocEntry { path: path.clone(), level: l - 1, text, lead });
}
}
if let Some(id) = &tc {
for off in p.object_offsets() {
let Some(InlineObject::Field { instr, .. }) = p.object_at(off) else { continue };
let c = FieldCode::parse(instr);
if c.name != "TC" {
continue;
}
let kind = c.arg('f').unwrap_or("C").to_ascii_uppercase();
let l = c.arg('l').and_then(|l| l.trim().parse::<u8>().ok()).unwrap_or(1).clamp(1, 9);
let text = c.args.first().map(|t| t.trim().to_string()).unwrap_or_default();
if (id.is_empty() || *id == kind) && (tc_levels.0..=tc_levels.1).contains(&l) && !text.is_empty() {
v.push(TocEntry { path: path.clone(), level: l - 1, text, lead: off });
}
}
}
}
v
}
/// The TOC block range in the body: the paragraph holding the TOC field and the TOC entries after
/// it. A TOC with `\c` is a table of figures, kept by the generated lists.
fn toc_range(s: &Session) -> Option<(usize, usize)> {
let start = s.doc.body.iter().position(|b| b.as_para().is_some_and(|p| toc_field(p).is_some()))?;
let mut end = start;
while let Some(Block::Para(p)) = s.doc.body.get(end + 1).map(|b| &**b) {
if p.props.style.as_deref().is_some_and(|st| st.starts_with("TOC") && st != "TOCHeading") {
end += 1;
} else {
break;
}
}
Some((start, end))
}
/// The caret is in the table of contents (its heading or an entry), where Word's context menu
/// offers Update Field for the whole table.
pub fn caret_in_toc(s: &Session) -> bool {
let at = &s.sel.focus;
if at.story != StoryRef::Body || at.path.depth() != 0 {
return false;
}
if toc_range(s).is_some_and(|(start, end)| (start..=end).contains(&at.path.last())) {
return true;
}
// A saved or imported TOC is a range field spanning its entries until Update Table turns it
// into the engine's TOC (#2182, a save and reopen; not the flow in the report). Only the
// range Update Table would convert counts, so the menu never refreshes a different TOC.
s.doc.field_ranges(StoryRef::Body).into_iter().find(|r| convertible_toc_range(s, r)).is_some_and(|r| {
// The caret at the field's own marker (offset 0 of the first entry) and at the end of
// the last entry belong to the range as well.
(r.start.path.last()..=r.end.path.last()).contains(&at.path.last())
})
}
/// The range field `range_toc_to_field` converts: an unlocked, top-level TOC (not a table of
/// figures) when the body has no engine TOC yet.
fn convertible_toc_range(s: &Session, r: &wordcraft_doc::FieldRange) -> bool {
let field = FieldCode::parse(&r.instr);
toc_range(s).is_none()
&& field.name == "TOC"
&& !field.has('c')
&& r.depth == 0
&& !r.locked
&& r.start.path.depth() == 0
&& r.end.path.depth() == 0
}
/// A TOC imported as a range field (`FieldStart`…`FieldEnd`: one without a TOC heading, or with
/// content around it the engine's TOC can't reproduce) becomes the engine's TOC before an update,
/// so Update Table refreshes it rather than adding a second one: an empty `TOC` field where the
/// range started, its old result gone, and the regenerated entries after that paragraph (text
/// that followed the range moves below them). Only an unlocked, top-level TOC that isn't inside
/// another range field, and only when the body has no engine TOC already.
fn range_toc_to_field(s: &mut Session) -> Result<(), CmdError> {
if toc_range(s).is_some() {
return Ok(());
}
let Some(r) = s.doc.field_ranges(StoryRef::Body).into_iter().find(|r| convertible_toc_range(s, r)) else {
return Ok(());
};
let Some(after_end) = r.end.off.checked_add(OBJ.len_utf8()) else { return Ok(()) };
let end = Pos { off: after_end, ..r.end.clone() };
let props = s.doc.para_at(&r.start).map(|p| p.props_of_char(r.start.off).clone()).unwrap_or_default();
let mut at = s.doc.delete_range(&r.start, &end)?;
// A range that filled its own paragraphs right under a TOC heading (as Word and other writers
// lay it out) leaves an empty paragraph behind, which would show as a blank line above the
// entries. The field goes at the end of the heading instead, as `insert_toc` places it.
let i = at.path.last();
if at.path.depth() == 0
&& i > 0
&& s.doc.para_at(&at).is_some_and(|p| p.is_empty())
// The paragraph before may also be the empty one the writer leaves where the engine's TOC
// field was (the field is saved opening in the first entry), so the next save and update
// doesn't add a blank line each time (#2182). A blank line the user typed right above a
// range TOC is reused the same way, so deleting it later deletes the TOC field.
&& s.doc.body.get(i - 1).and_then(|b| b.as_para()).is_some_and(|p| {
p.props.style.as_deref() == Some("TOCHeading") || (p.is_empty() && p.objects.is_empty() && p.section.is_none())
})
{
s.doc.remove_block(StoryRef::Body, &at.path)?;
let heading = Path::top(i - 1);
let len = s.doc.para(StoryRef::Body, &heading).map_or(0, |p| p.len());
at = Pos { story: StoryRef::Body, path: heading, off: len };
}
let field = InlineObject::Field {
instr: r.instr.trim().to_string(),
result: String::new(),
locked: false,
dirty: false,
code: false,
form: String::new(),
};
let after = s.doc.insert_object(&at, field, &props)?;
if s.doc.para_at(&after).is_some_and(|p| after.off < p.len()) {
s.doc.split_paragraph(&after)?;
}
s.clamp_selection();
Ok(())
}
/// The TOC field instruction in `p` (not a table of figures).
fn toc_field(p: &Paragraph) -> Option<String> {
p.objects.iter().find_map(|o| match o {
InlineObject::Field { instr, .. } => {
let f = FieldCode::parse(instr);
(f.name == "TOC" && !f.has('c')).then(|| instr.clone())
}
_ => None,
})
}
/// The first cached number-separator tab formatting at each TOC level.
fn cached_toc_tabs(s: &Session) -> Vec<(String, Vec<TabStop>)> {
let span = toc_range(s).map(|(start, end)| (start.saturating_add(1), end)).or_else(|| {
s.doc.field_ranges(StoryRef::Body).into_iter().find_map(|r| {
let f = FieldCode::parse(&r.instr);
(r.depth == 0 && !r.locked && r.start.path.depth() == 0 && r.end.path.depth() == 0 && f.name == "TOC" && !f.has('c'))
.then_some((r.start.path.last(), r.end.path.last()))
})
});
let Some(blocks) = span.and_then(|(start, end)| s.doc.body.get(start..=end)) else { return Vec::new() };
let mut tabs: Vec<(String, Vec<TabStop>)> = Vec::new();
for p in blocks.iter().filter_map(|b| b.as_para()) {
let Some(style) = &p.props.style else { continue };
let Some(direct) = &p.props.tabs else { continue };
if !direct.iter().any(|t| matches!(t.align, TabAlign::Left | TabAlign::Center | TabAlign::Decimal)) {
continue;
}
if !style.starts_with("TOC") || style == "TOCHeading" || tabs.iter().any(|(id, _)| id == style) {
continue;
}
tabs.push((style.clone(), direct.clone()));
}
tabs
}
fn insert_toc(s: &mut Session, v: &Value) -> CmdResult {
let tabs = cached_toc_tabs(s);
range_toc_to_field(s)?;
let replace = v.as_object().is_some_and(|m| !m.is_empty());
// Replacing a table puts the new one where the old one was, in place of an empty paragraph.
let mut placeholder = false;
let at = match toc_range(s) {
// `{}` updates the table there; settings replace it where it is.
Some(_) if !replace => {
regenerate_toc(s, None, &tabs)?;
return sel_result(s);
}
Some((start, end)) => {
for i in (start..=end).rev() {
s.doc.remove_block(StoryRef::Body, &Path::top(i))?;
}
s.doc.insert_block(StoryRef::Body, &Path::top(start), Block::Para(Paragraph::new()))?;
placeholder = true;
s.sel = Selection::caret(Pos { story: StoryRef::Body, path: Path::top(start), off: 0 });
s.sel.focus.clone()
}
None => delete_selection(s)?,
};
let title = p::str(v, "title").unwrap_or("Contents").to_string();
if at.story != StoryRef::Body || at.path.depth() > 0 {
return Err(CmdError::Failed("a table of contents goes in the main text".into()));
}
// Title paragraph carrying the TOC field marker.
let mut head = Paragraph::with_text(&title, CharProps::default()).styled("TOCHeading");
head.insert_object(
head.len(),
InlineObject::Field { instr: toc_instr(v), result: String::new(), locked: false, dirty: false, code: false, form: String::new() },
&CharProps::default(),
)?;
let i = if s.doc.para_at(&at).is_some_and(|p| p.is_empty()) {
if placeholder {
s.doc.remove_block(StoryRef::Body, &at.path)?;
}
at.path.last()
} else {
s.doc.split_paragraph(&at)?.path.last()
};
s.doc.insert_block(StoryRef::Body, &Path::top(i), Block::Para(head))?;
let leader = leader(p::str(v, "tabLeader").unwrap_or("dot"));
regenerate_toc(s, Some(leader), &tabs)?;
s.clamp_selection();
sel_result(s)
}
/// A hidden `_Toc` bookmark around the heading at body block `i` (after its leading page breaks),
/// reusing one already there; its name.
pub(crate) fn toc_bookmark(s: &mut Session, i: usize, lead: usize) -> Result<String, CmdError> {
bookmark_heading(s, &Path::top(i), lead)
}
fn bookmark_heading(s: &mut Session, path: &Path, lead: usize) -> Result<String, CmdError> {
const MARK: usize = wordcraft_doc::para::OBJ.len_utf8();
let para = s.doc.para(StoryRef::Body, path).ok_or_else(|| CmdError::Failed("heading is gone".into()))?;
if let Some(InlineObject::BookmarkStart { name }) = para.object_at(lead)
&& name.starts_with("_Toc")
{
return Ok(name.clone());
}
let next =
s.doc.bookmarks().iter().filter_map(|(n, _)| n.strip_prefix("_Toc").and_then(|d| d.parse::<u64>().ok())).max().unwrap_or(0).saturating_add(1);
let name = format!("_Toc{next:09}");
let end = para.len();
let props = CharProps::default();
let pos = |off| Pos { story: StoryRef::Body, path: path.clone(), off };
s.doc.insert_object(&pos(end), InlineObject::BookmarkEnd { name: name.clone() }, &props)?;
s.doc.insert_object(&pos(lead), InlineObject::BookmarkStart { name: name.clone() }, &props)?;
// The caret in that paragraph stays on the same text.
let shift = |p: &mut Pos| {
if p.story == StoryRef::Body && &p.path == path {
if p.off > end {
p.off = p.off.saturating_add(2 * MARK);
} else if p.off >= lead && p.off > 0 {
p.off = p.off.saturating_add(MARK);
}
}
};
shift(&mut s.sel.anchor);
shift(&mut s.sel.focus);
Ok(name)
}
/// Regenerate the TOC entries with page numbers (two layout passes: entries change pagination).
pub fn update_toc(s: &mut Session) -> Result<(), CmdError> {
let tabs = cached_toc_tabs(s);
regenerate_toc(s, None, &tabs)
}
/// Rebuild the entries the TOC field's switches ask for. The tab leader is `leader`, or the one
/// the entries had (Word keeps it in the entries' tab stops, not in the field).
fn regenerate_toc(s: &mut Session, leader: Option<TabLeader>, tabs: &[(String, Vec<TabStop>)]) -> Result<(), CmdError> {
range_toc_to_field(s)?;
let Some((start, end)) = toc_range(s) else { return Ok(()) };
let Some(instr) = s.doc.body.get(start).and_then(|b| b.as_para()).and_then(toc_field) else { return Ok(()) };
// A TOC field imported with an inline placeholder/cached result must have its result cleared so
// that the engine's TOC shape matches `toc_span` and the DOCX writer spans the field across entries.
if let Ok(p) = s.doc.para_mut(StoryRef::Body, &Path::top(start)) {
for o in &mut p.objects {
if let InlineObject::Field { instr, result, .. } = o {
let f = FieldCode::parse(instr);
if f.name == "TOC" && !f.has('c') {
result.clear();
}
}
}
p.touch();
}
let f = FieldCode::parse(&instr);
let leader = leader.unwrap_or_else(|| {
s.doc
.body
.get(start + 1)
.filter(|_| end > start)
.and_then(|b| b.as_para())
.and_then(|p| p.props.tabs.as_ref()?.last().map(|t| t.leader))
.unwrap_or(TabLeader::Dot)
});
for i in (start + 1..=end).rev() {
s.doc.remove_block(StoryRef::Body, &Path::top(i))?;
}
// The text width of the TOC's own section, not the caret's: after Select All the caret sits
// at the end of the document, possibly in a wider (landscape) section.
let section = s.doc.section_index_of(start);
let width = s.doc.sections().get(section).map_or_else(|| super::page::sect(s).text_width(), |(_, x)| x.text_width());
let entries = toc_entries(s, &f);
let links: Vec<Option<String>> = if f.has('h') {
let mut v = Vec::new();
for e in &entries {
// TC fields link to their paragraph's start.
let lead = s.doc.para(StoryRef::Body, &e.path).map_or(0, |p| p.text.bytes().take_while(|b| *b == 0x0C).count());
v.push(Some(format!("#{}", bookmark_heading(s, &e.path, lead)?)));
}
v
} else {
vec![None; entries.len()]
};
let no_pages = f.has('n').then(|| f.arg('n').and_then(level_range).unwrap_or((1, 9)));
let sep = f.arg('p').map(str::to_string).unwrap_or_else(|| "\t".into());
for pass in 0..2 {
// Pass 1 lays out with the entries in place, so headings below the TOC sit `entries.len()`
// blocks further down and their pages account for the TOC's own length.
s.touch();
let l = s.layout();
if pass == 1 {
// Replace the page numbers now that the entries exist.
for _ in 0..entries.len() {
s.doc.remove_block(StoryRef::Body, &Path::top(start + 1))?;
}
}
let shift = if pass == 0 { 0 } else { entries.len() };
for (k, e) in entries.iter().enumerate() {
let mut target = e.path.clone();
if let Some(top) = target.0.first_mut()
&& (*top as usize) > start
{
*top = top.saturating_add(u32::try_from(shift).unwrap_or(u32::MAX));
}
let page =
l.caret(&Pos { story: StoryRef::Body, path: target, off: e.lead }).and_then(|c| l.pages.get(c.page)).map(|pg| pg.number).unwrap_or(1);
let show_page = no_pages.is_none_or(|(a, b)| !(a..=b).contains(&(e.level + 1)));
let text = if show_page { format!("{}{sep}{page}", e.text) } else { e.text.clone() };
let props = CharProps { link: links.get(k).cloned().flatten(), ..Default::default() };
let mut para = Paragraph::with_text(&text, props).styled(&format!("TOC{}", e.level + 1));
para.mark = CharProps::default();
if e.text.contains('\t') {
para.props.tabs = tabs.iter().find(|(style, _)| Some(style) == para.props.style.as_ref()).map(|(_, tabs)| tabs.clone());
}
if show_page && sep.contains('\t') {
let right = TabStop { pos: width - 0.5, align: TabAlign::Right, leader };
para.props.tabs = Some(entry_tabs(s, &para.props, &e.text, right));
}
s.doc.insert_block(StoryRef::Body, &Path::top(start + 1 + k), Block::Para(para))?;
}
}
s.touch();
Ok(())
}
/// The tab stops of a TOC entry: the right-aligned stop for its page number, and, when the
/// entry text itself has no tab, Clear stops for the left, centre and decimal stops its style
/// sets before that. Those stops are for a number set off by a tab (`1.1<tab>Title`); with the
/// number set off by a space, or no number, a short entry's page tab would stop at one of them
/// (without the leader), and so would the tab on the last line of an entry that wraps.
fn entry_tabs(s: &Session, props: &wordcraft_doc::ParaProps, text: &str, right: TabStop) -> Vec<TabStop> {
let mut tabs = props.tabs.clone().unwrap_or_default();
tabs.retain(|t| t.pos < right.pos - 0.5 && !matches!(t.align, TabAlign::Right | TabAlign::Bar));
if !text.contains('\t') {
let style = s.doc.styles.resolve_para(&wordcraft_doc::ParaProps { style: props.style.clone(), ..Default::default() });
for t in style.tabs {
if t.pos < right.pos - 0.5 && !matches!(t.align, TabAlign::Right | TabAlign::Bar) {
tabs.push(TabStop { pos: t.pos, align: TabAlign::Clear, leader: TabLeader::None });
}
}
}
tabs.push(right);
tabs
}
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"),
};
let mut para = Paragraph::new().styled(style);
let id = s.doc.try_add_part(pk, Vec::new())?;
para.insert_object(
0,
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 }, &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, kind: Option<NoteKind>) -> Vec<Pos> {
let mut v = Vec::new();
for path in s.doc.para_paths(StoryRef::Body) {
let Some(p) = s.doc.para(StoryRef::Body, &path) else { continue };
for o in p.object_offsets() {
if let Some(InlineObject::NoteRef { kind: k, .. }) = p.object_at(o)
&& (kind.is_none() || kind == Some(*k))
{
v.push(Pos { story: StoryRef::Body, path: path.clone(), off: o });
}
}
}
v
}
/// The `\*` format switch value for a caption's number format (OOXML names work too).
pub fn seq_format(name: &str) -> &'static str {
match name {
"alphabetic" | "lowerLetter" => "alphabetic",
"ALPHABETIC" | "upperLetter" => "ALPHABETIC",
"roman" | "lowerRoman" => "roman",
"ROMAN" | "upperRoman" => "ROMAN",
_ => "ARABIC",
}
}
/// Separators Word offers between a chapter number and a caption number.
pub const CHAPTER_SEPARATORS: [&str; 5] = ["-", ".", ":", "\u{2014}", "\u{2013}"];
/// Insert a caption paragraph below (or above) the caret's paragraph, table or object: the label,
/// a chapter number (`STYLEREF n \s` and a separator) and a `SEQ label \* format \s n` number
/// (ECMA-376 §17.16.5.56, §17.16.5.59).
fn caption(s: &mut Session, v: &Value) -> CmdResult {
let label: String = p::str(v, "label").unwrap_or("Figure").chars().filter(|c| !matches!(c, '"' | '\\') && !c.is_control()).take(40).collect();
let label = if label.trim().is_empty() { "Figure".to_string() } else { label.trim().to_string() };
// SEQ identifiers are one word: Word joins a multi-word label with underscores.
let ident = label.split_whitespace().collect::<Vec<_>>().join("_");
let text = p::str(v, "text").unwrap_or("").to_string();
let above = p::str(v, "position") == Some("above");
let format = seq_format(p::str(v, "format").unwrap_or("ARABIC"));
let chapter = p::u64(v, "chapter").filter(|l| (1..=9).contains(l));
let sep = p::str(v, "separator").and_then(|x| CHAPTER_SEPARATORS.iter().find(|s| **s == x)).copied().unwrap_or("-");
let mut para = Paragraph::new().styled("Caption");
let field = |instr: String| InlineObject::Field { instr, result: String::new(), locked: false, dirty: false, code: false, form: String::new() };
if !p::bool(v, "excludeLabel").unwrap_or(false) {
para.insert_text(0, &format!("{label} "), &CharProps::default())?;
}
let mut seq = format!("SEQ {ident} \\* {format}");
if let Some(l) = chapter {
para.insert_object(para.len(), field(format!("STYLEREF {l} \\s")), &CharProps::default())?;
para.insert_text(para.len(), sep, &CharProps::default())?;
seq.push_str(&format!(" \\s {l}"));
}
para.insert_object(para.len(), field(seq), &CharProps::default())?;
if !text.is_empty() {
para.insert_text(para.len(), &format!(": {text}"), &CharProps::default())?;
}
// A selected picture, shape or chart: next to the paragraph holding it, in the same cell or
// text box (#1230). Otherwise next to the caret's top-level block: a paragraph, or the whole
// table it is in.
let f = s.sel.focus.clone();
let at = match super::objects::object_selection(s) {
Some((pos, _)) => {
let k = pos.path.last();
pos.path.with_last(if above { k } else { k.saturating_add(1) })
}
None => {
let top = f.path.0.first().copied().unwrap_or(0) as usize;
Path::top(if above { top } else { top.saturating_add(1) })
}
};
s.doc.insert_block(f.story, &at, Block::Para(para))?;
update_seq(s)?;
let off = s.doc.para(f.story, &at).map_or(0, |p| p.len());
s.sel = Selection::caret(Pos { story: f.story, path: at, off });
sel_result(s)
}
/// Renumber the body's SEQ fields in document order, honouring `\* format`, `\s level` (restart
/// after each heading of that level), `\r n` (reset to n), `\c` (repeat) and `\h` (hide), and
/// fill body STYLEREF fields ([`wordcraft_layout::styleref`]): chapter numbers (`STYLEREF n \s`,
/// the last level-n heading's number, or its count when the heading isn't numbered) and the
/// text or number of the nearest paragraph of a style.
pub fn update_seq(s: &mut Session) -> Result<(), CmdError> {
let mut headings = [0u32; 9];
let mut seq: std::collections::HashMap<String, (u32, u32)> = Default::default();
// Built on the first STYLEREF (field results don't change what it finds).
let mut styles: Option<wordcraft_layout::styleref::StyleIndex> = None;
for path in s.doc.para_paths(StoryRef::Body) {
let Some(p) = s.doc.para(StoryRef::Body, &path) else { continue };
if let Some(l) = heading_style_level(p)
&& let Some(k) = (l as usize).checked_sub(1)
&& let Some(count) = headings.get_mut(k)
{
*count = count.saturating_add(1);
}
let mut updates: Vec<(usize, String)> = Vec::new();
for (k, o) in p.objects.iter().enumerate() {
let InlineObject::Field { instr, locked: false, .. } = o else { continue };
let f = FieldCode::parse(instr);
match f.name.as_str() {
"SEQ" => {
let Some(id) = f.args.first() else { continue };
let restart =
f.arg('s').and_then(|l| l.parse::<usize>().ok()).and_then(|l| l.checked_sub(1)).and_then(|l| headings.get(l)).copied();
let n = seq.entry(id.clone()).or_insert((0, 0));
if let Some(epoch) = restart
&& n.1 != epoch
{
*n = (0, epoch);
}
if let Some(r) = f.arg('r').and_then(|r| r.parse::<u32>().ok()) {
n.0 = r;
} else if !f.has('c') {
n.0 = n.0.saturating_add(1);
}
let fmt = wordcraft_layout::fields::format_switch(instr).unwrap_or_default();
updates.push((k, if f.has('h') { String::new() } else { fmt.format(n.0) }));
}
"STYLEREF" => {
let ix = styles.get_or_insert_with(|| wordcraft_layout::styleref::StyleIndex::build(&s.doc));
if let Some(i) = ix.index_of(&path) {
updates.push((k, ix.eval(instr, wordcraft_layout::styleref::At::Body(i))));
}
}
_ => {}
}
}
if updates.is_empty() {
continue;
}
let para = s.doc.para_mut(StoryRef::Body, &path)?;
for (k, r) in updates {
if let Some(InlineObject::Field { result, dirty, .. }) = para.objects.get_mut(k) {
// A computed result is no longer stale: Word's F9 clears `w:dirty` too.
*result = r;
*dirty = false;
}
}
para.touch();
}
Ok(())
}
/// Whether `instr` has the one-letter switch `\sw`, also run together with another (`\p\h`).
fn has_switch(instr: &str, sw: char) -> bool {
instr.split('\\').skip(1).any(|s| {
let mut chars = s.chars();
chars.next().is_some_and(|c| c.eq_ignore_ascii_case(&sw)) && chars.next().is_none_or(|c| !c.is_alphanumeric())
})
}
/// Update fields of the story being edited, as Word's F9 does: dates, REF/PAGEREF, TITLE, AUTHOR
/// and NUMWORDS in the body, or in the header, footer, note or text box the caret is in (the body's
/// fields stay as they are then). SEQ numbering, citations and TOC are body-only, so they run only
/// when the caret is in the body.
pub fn update_fields(s: &mut Session) -> Result<(), CmdError> {
update_fields_in(s, false)
}
/// [`update_fields`], or with `all` the whole document: the body (with SEQ, citations and TOC) and
/// every part story, wherever the caret is (what printing needs).
pub fn update_fields_in(s: &mut Session, all: bool) -> Result<(), CmdError> {
let caret_story = s.sel.focus.story;
let in_body = all || caret_story == StoryRef::Body;
if in_body {
update_seq(s)?;
}
let mut pages: std::collections::HashMap<String, (u32, NumFormat)> = Default::default();
let formats: Vec<NumFormat> = s.doc.sections().into_iter().map(|(_, sec)| sec.page_num_format).collect();
let marks = s.doc.bookmarks();
for (name, pos) in marks {
let l = s.layout();
let page_info = l
.caret(&pos)
.and_then(|c| l.pages.get(c.page))
.map(|p| {
let format = formats.get(p.section).copied().unwrap_or_default();
(p.number, format)
})
.unwrap_or((1, NumFormat::Decimal));
pages.entry(name).or_insert(page_info);
}
let stories: Vec<StoryRef> =
if all { std::iter::once(StoryRef::Body).chain(s.doc.parts.keys().map(|k| StoryRef::Part(*k))).collect() } else { vec![caret_story] };
for (story, path) in stories.into_iter().flat_map(|st| s.doc.para_paths(st).into_iter().map(move |p| (st, p))).collect::<Vec<_>>() {
let Some(p) = s.doc.para(story, &path) else { continue };
if !p.objects.iter().any(|o| matches!(o, InlineObject::Field { .. })) {
continue;
}
let mut updates: Vec<(usize, String)> = Vec::new();
for (k, o) in p.objects.iter().enumerate() {
let InlineObject::Field { instr, locked: false, .. } = o else { continue };
// A table formula reads the cells around its own cell again.
if instr.trim_start().starts_with('=') {
if let Some((tp, r, c)) = path.cell()
&& let Some(t) = s.doc.table(StoryRef::Body, &tp)
{
let default_lang = super::default_lang(s);
let text = match super::table_formula::evaluate_field_with(t, Some(&s.doc), default_lang.as_deref(), r, c, instr) {
Ok(text) => text,
Err(e) => format!("!{e}"),
};
updates.push((k, text));
}
continue;
}
let name = wordcraft_layout::fields::field_name(instr);
match name.as_str() {
"DATE" | "TIME" | "CREATEDATE" | "SAVEDATE" | "PRINTDATE" => {
let pic = date_picture(instr).unwrap_or_else(|| if name == "TIME" { "h:mm am/pm".into() } else { "M/d/yyyy".into() });
updates.push((k, super::insert::format_date(&pic)));
}
"REF" | "PAGEREF" => {
let f = FieldCode::parse(instr);
let target = f.args.first().map(|s| s.split('\\').next().unwrap_or("").trim_matches('"')).unwrap_or("");
let r = if name == "REF" {
if !has_switch(instr, 'p') { bookmark_text(&s.doc, target) } else { None }
} else if !has_switch(instr, 'p') {
pages.get(target).map(|(num, page_fmt)| {
let fmt = wordcraft_layout::fields::format_switch(instr).unwrap_or(*page_fmt);
fmt.format(*num)
})
} else {
None
};
if let Some(r) = r {
updates.push((k, r));
}
}
"TITLE" => updates.push((k, s.doc.core.title.clone())),
"AUTHOR" => updates.push((k, s.doc.core.creator.clone())),
"NUMWORDS" => updates.push((k, s.doc.word_count().to_string())),
_ => {}
}
}
if updates.is_empty() {
continue;
}
let para = s.doc.para_mut(story, &path)?;
for (k, r) in updates {
if let Some(InlineObject::Field { result, dirty, .. }) = para.objects.get_mut(k) {
// A computed result is no longer stale: Word's F9 clears `w:dirty` too.
*result = r;
*dirty = false;
}
}
para.touch();
}
if !in_body {
return Ok(());
}
super::citations::update_citations(s)?;
update_toc(s)
}
/// Refresh every unlocked `DATE` and `TIME` field to now, as Word does whenever it shows,
/// prints or exports a document: a letter written from a template carries today's date, not the
/// date the template was last saved (#1672).
///
/// Only those two. `CREATEDATE`, `SAVEDATE` and `PRINTDATE` record when something happened and
/// are not "now"; `REF`, `PAGEREF`, `TOC` and citations are refreshed on an explicit update
/// (`fields.updateAll`) and not on open, again as Word does. A field carrying `w:fldLock` keeps
/// its stored result, which is what the lock is for.
pub fn refresh_date_fields(doc: &mut wordcraft_doc::Document) {
let stories: Vec<StoryRef> = std::iter::once(StoryRef::Body).chain(doc.parts.keys().copied().map(StoryRef::Part)).collect();
for story in stories {
for path in doc.para_paths(story) {
let Some(p) = doc.para(story, &path) else { continue };
let mut updates: Vec<(usize, String)> = Vec::new();
for (k, o) in p.objects.iter().enumerate() {
let InlineObject::Field { instr, locked: false, .. } = o else { continue };
let name = wordcraft_layout::fields::field_name(instr);
if !matches!(name.as_str(), "DATE" | "TIME") {
continue;
}
// Word's default picture when the code carries no `\@` switch.
let pic = date_picture(instr).unwrap_or_else(|| if name == "TIME" { "h:mm am/pm".into() } else { "M/d/yyyy".into() });
updates.push((k, super::insert::format_date(&pic)));
}
if updates.is_empty() {
continue;
}
let Ok(para) = doc.para_mut(story, &path) else { continue };
for (k, r) in updates {
if let Some(InlineObject::Field { result, .. }) = para.objects.get_mut(k) {
*result = r;
}
}
para.touch();
}
}
}
/// A field's `\@` date picture: the quoted text after it (switches may follow), or one word.
pub fn date_picture(instr: &str) -> Option<String> {
let rest = instr.split_once("\\@")?.1.trim_start();
let pic = match rest.strip_prefix('"') {
Some(q) => q.split('"').next().unwrap_or(""),
None => rest.split_whitespace().next().unwrap_or(""),
};
(!pic.is_empty()).then(|| pic.to_string())
}
/// The text a bookmark marks (field results included), up to its end in the same paragraph or
/// the paragraph's end. `None` when there is no such bookmark.
pub fn bookmark_text(doc: &wordcraft_doc::Document, name: &str) -> Option<String> {
let (_, start) = doc.bookmarks().into_iter().find(|(n, _)| n == name)?;
let para = doc.para(start.story, &start.path)?;
let end = para
.object_offsets()
.into_iter()
.find(|o| *o > start.off && matches!(para.object_at(*o), Some(InlineObject::BookmarkEnd { name: n }) if n == name))
.unwrap_or(para.len());
Some(doc.copy_range(&start, &Pos { off: end, ..start.clone() }).plain_text().trim().to_string())
}
#[cfg(test)]
mod tests {
use super::*;
fn fields(s: &Session) -> Vec<(String, String)> {
let mut out = Vec::new();
for path in s.doc.para_paths(StoryRef::Body) {
for o in &s.doc.para(StoryRef::Body, &path).unwrap().objects {
if let InlineObject::Field { instr, result, .. } = o {
out.push((instr.clone(), result.clone()));
}
}
}
out
}
/// #775: body STYLEREF fields show the nearest paragraph of the style before them.
#[test]
fn body_styleref_finds_the_heading_before_it() {
let mut s = Session::new(wordcraft_doc::Document::from_text("Intro\nChapter One\ntext here\nChapter Two\nend"));
for t in ["Chapter One", "Chapter Two"] {
s.run("select.text", &json!({"text": t})).unwrap();
s.run("styles.apply", &json!({"style": "Heading 1"})).unwrap();
}
s.run("select.text", &json!({"text": "text here"})).unwrap();
s.run("caret.end", &json!({})).unwrap();
s.run("insert.field", &json!({"instr": r#"STYLEREF "Heading 1" \* MERGEFORMAT"#})).unwrap();
s.run("caret.docEnd", &json!({})).unwrap();
s.run("insert.field", &json!({"instr": "STYLEREF 1 \\p"})).unwrap();
s.run("caret.docStart", &json!({})).unwrap();
s.run("insert.field", &json!({"instr": "STYLEREF \"Heading 1\" \\p"})).unwrap();
s.run("insert.field", &json!({"instr": "STYLEREF \"No Such Style\""})).unwrap();
let results: Vec<String> = fields(&s).into_iter().map(|f| f.1).collect();
assert_eq!(results, ["below", wordcraft_layout::styleref::NO_TEXT, "Chapter One", "above"]);
}
/// #775: a template's running header `STYLEREF "heading 1"` (a complex field, opened from
/// DOCX) shows each page's chapter, and keeps its code through a save.
#[test]
fn docx_styleref_header_shows_each_page_s_chapter() {
let ns = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships""#;
let heading = |t: &str, brk: bool| {
let b = if brk { "<w:pageBreakBefore/>" } else { "" };
format!(
r#"<w:p><w:pPr><w:pStyle w:val="Heading1"/>{b}</w:pPr><w:r><w:t>{t}</w:t></w:r></w:p><w:p><w:r><w:t>Body of {t}.</w:t></w:r></w:p>"#
)
};
let body = format!(
r#"<?xml version="1.0" encoding="UTF-8"?><w:document {ns}><w:body>{}{}{}<w:sectPr><w:headerReference w:type="default" r:id="rIdH"/></w:sectPr></w:body></w:document>"#,
heading("Rivers", false),
heading("Mountains", true),
heading("Deserts", true)
);
let header = format!(
r#"<w:hdr {ns}><w:p><w:r><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:instrText xml:space="preserve"> STYLEREF "heading 1" \* MERGEFORMAT </w:instrText></w:r><w:r><w:fldChar w:fldCharType="separate"/></w:r><w:r><w:t>Cached</w:t></w:r><w:r><w:fldChar w:fldCharType="end"/></w:r></w:p></w:hdr>"#
);
let styles = format!(
r#"<w:styles {ns}><w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/></w:style><w:style w:type="paragraph" w:styleId="Heading1"><w:name w:val="heading 1"/><w:basedOn w:val="Normal"/></w:style></w:styles>"#
);
let root = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
let rels = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rIdH" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/header" Target="header1.xml"/><Relationship Id="rIdS" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>"#;
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
for (name, text) in [
("_rels/.rels", root),
("word/document.xml", body.as_str()),
("word/_rels/document.xml.rels", rels),
("word/header1.xml", header.as_str()),
("word/styles.xml", styles.as_str()),
] {
zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
std::io::Write::write_all(&mut zw, text.as_bytes()).unwrap();
}
let bytes = zw.finish().unwrap().into_inner();
let headers = |s: &mut Session| -> Vec<String> {
s.layout()
.pages
.iter()
.map(|p| {
p.header
.iter()
.filter_map(|it| {
if let wordcraft_layout::Placed::Lines { para, .. } = it {
Some(para.shown.iter().map(|x| x.1.clone()).collect::<String>())
} else {
None
}
})
.collect::<String>()
})
.collect()
};
let mut s = Session::new(crate::io::open_bytes("chapters.docx", &bytes).unwrap());
assert_eq!(headers(&mut s), ["Rivers", "Mountains", "Deserts"]);
let saved = crate::io::save_bytes("chapters.docx", &s.doc).unwrap();
let mut again = Session::new(crate::io::open_bytes("chapters.docx", &saved).unwrap());
assert_eq!(headers(&mut again), ["Rivers", "Mountains", "Deserts"]);
let hid = again.doc.last_section.headers.default.unwrap();
let instr = again.doc.para_paths(StoryRef::Part(hid)).iter().find_map(|p| {
again.doc.para(StoryRef::Part(hid), p).unwrap().objects.iter().find_map(|o| match o {
InlineObject::Field { instr, .. } => Some(instr.clone()),
_ => None,
})
});
assert!(instr.is_some_and(|i| i.contains(r#"STYLEREF "heading 1""#)));
}
/// #402: Custom Table of Contents settings become TOC switches, and updating honours them:
/// levels (`\o`), a style with a level (`\t`), no hyperlinks, page numbers after a space
/// (`\p`), or none at all (`\n`).
#[test]
fn toc_switches_shape_the_entries() {
let mut s = Session::new(wordcraft_doc::Document::from_text("Intro\nPart\nDeep\nNote"));
for (text, style) in [("Intro", "Heading 1"), ("Part", "Heading 2"), ("Deep", "Heading 3"), ("Note", "Quote")] {
s.run("select.text", &json!({"text": text})).unwrap();
s.run("styles.apply", &json!({"style": style})).unwrap();
}
s.run("caret.docStart", &json!({})).unwrap();
s.run("references.toc", &json!({"levels": 2, "hyperlinks": false, "rightAlign": false, "styleLevels": {"Quote": 2}})).unwrap();
assert_eq!(fields(&s)[0].0, "TOC \\o \"1-2\" \\z \\u \\p \" \" \\t \"Quote,2\"");
let entries: Vec<(String, String)> = s
.doc
.body
.iter()
.filter_map(|b| b.as_para())
.filter(|p| p.props.style.as_deref().is_some_and(|st| st.starts_with("TOC") && st != "TOCHeading"))
.map(|p| (p.props.style.clone().unwrap(), p.plain_text()))
.collect();
assert_eq!(entries, [("TOC1", "Intro 1"), ("TOC2", "Part 1"), ("TOC2", "Note 1")].map(|(a, b)| (a.to_string(), b.to_string())));
assert!(s.doc.bookmarks().is_empty(), "no hyperlinks, no _Toc bookmarks");
// Settings again replace the table in place; hyperlinks bookmark the headings.
s.run("references.toc", &json!({"levels": 3, "pageNumbers": false})).unwrap();
assert_eq!(s.doc.plain_text(StoryRef::Body).matches("Contents").count(), 1);
let toc: Vec<String> =
s.doc.body.iter().filter_map(|b| b.as_para()).filter(|p| p.props.style.as_deref() == Some("TOC3")).map(|p| p.plain_text()).collect();
assert_eq!(toc, ["Deep"]);
let first = s.doc.body.iter().filter_map(|b| b.as_para()).find(|p| p.props.style.as_deref() == Some("TOC1")).unwrap();
let link = first.runs[0].props.link.clone().unwrap();
assert!(s.doc.bookmarks().iter().any(|(n, _)| format!("#{n}") == link), "{link}");
}
/// #402: Caption numbering — a custom label above the paragraph, letters, and chapter
/// numbers that restart after each Heading 1 (`STYLEREF 1 \s`, `SEQ … \s 1`).
#[test]
fn caption_numbering_with_chapters() {
let mut s = Session::new(wordcraft_doc::Document::from_text("One\nPic\nPic two\nTwo\nPic three"));
for t in ["One", "Two"] {
s.run("select.text", &json!({"text": t})).unwrap();
s.run("styles.apply", &json!({"style": "Heading 1"})).unwrap();
}
let opts = json!({"label": "Photo Plate", "format": "upperLetter", "chapter": 1, "separator": "."});
for t in ["Pic three", "Pic two", "Pic"] {
s.run("select.text", &json!({"text": t})).unwrap();
let mut v = opts.clone();
v["position"] = json!("above");
v["text"] = json!(t);
s.run("references.caption", &v).unwrap();
}
let caps: Vec<String> =
s.doc.body.iter().filter_map(|b| b.as_para()).filter(|p| p.props.style.as_deref() == Some("Caption")).map(|p| p.plain_text()).collect();
assert_eq!(caps, ["Photo Plate 1.A: Pic", "Photo Plate 1.B: Pic two", "Photo Plate 2.A: Pic three"]);
assert!(fields(&s).contains(&("SEQ Photo_Plate \\* ALPHABETIC \\s 1".to_string(), "A".to_string())), "{:?}", fields(&s));
// The caption sits above its paragraph; excluding the label leaves the number.
let i = s.doc.body.iter().position(|b| b.as_para().is_some_and(|p| p.plain_text() == "Pic")).unwrap();
assert_eq!(s.doc.body[i - 1].as_para().unwrap().plain_text(), "Photo Plate 1.A: Pic");
s.run("caret.docEnd", &json!({})).unwrap();
s.run("references.caption", &json!({"label": "Table", "excludeLabel": true, "format": "ROMAN"})).unwrap();
assert_eq!(s.doc.body.last().unwrap().as_para().unwrap().plain_text(), "I");
}
#[test]
fn update_fields_ref_and_pageref() {
let mut s = Session::new(wordcraft_doc::Document::from_text("Bookmark target text here.\nSecond paragraph."));
s.run("select.text", &json!({"text": "Bookmark target text"})).unwrap();
s.run("insert.bookmark", &json!({"name": "TargetMark"})).unwrap();
s.run("caret.docEnd", &json!({})).unwrap();
s.run("insert.field", &json!({"instr": "REF \"TargetMark\" \\h", "result": "old"})).unwrap();
s.run("insert.field", &json!({"instr": "REF TargetMark\\h", "result": "old"})).unwrap();
s.run("insert.field", &json!({"instr": "PAGEREF \"TargetMark\" \\* Roman", "result": "0"})).unwrap();
s.run("insert.field", &json!({"instr": "PAGEREF TargetMark \\p", "result": "above"})).unwrap();
s.run("insert.field", &json!({"instr": "REF TargetMark \\p", "result": "above"})).unwrap();
update_fields(&mut s).unwrap();
let res: Vec<(String, String)> = fields(&s);
assert_eq!(res[0], ("REF \"TargetMark\" \\h".into(), "Bookmark target text".into()));
assert_eq!(res[1], ("REF TargetMark\\h".into(), "Bookmark target text".into()));
assert_eq!(res[2], ("PAGEREF \"TargetMark\" \\* Roman".into(), "I".into()));
assert_eq!(res[3], ("PAGEREF TargetMark \\p".into(), "above".into()));
assert_eq!(res[4], ("REF TargetMark \\p".into(), "above".into()));
}
/// A document with a body AUTHOR field and a header holding AUTHOR, NUMWORDS and a locked TITLE,
/// all stale; the author is "Ada".
fn doc_with_header_fields() -> (wordcraft_doc::Document, u32) {
use wordcraft_doc::{CharProps, Paragraph, PartKind};
let field = |instr: &str, locked: bool| InlineObject::Field {
instr: instr.into(),
result: "stale".into(),
locked,
dirty: false,
code: false,
form: String::new(),
};
let mut doc = wordcraft_doc::Document::from_text("one two three");
doc.core.creator = "Ada".into();
let mut p = Paragraph::new();
for (instr, locked) in [("AUTHOR", false), ("NUMWORDS", false), ("TITLE", true)] {
let off = p.len();
p.insert_object(off, field(instr, locked), &CharProps::default()).unwrap();
}
let body_path = doc.para_paths(StoryRef::Body).remove(0);
doc.para_mut(StoryRef::Body, &body_path).unwrap().insert_object(0, field("AUTHOR", false), &CharProps::default()).unwrap();
let hdr = doc.add_part(PartKind::Header, vec![std::sync::Arc::new(wordcraft_doc::Block::Para(p))]);
(doc, hdr)
}
fn story_fields(s: &Session, story: StoryRef) -> Vec<(String, String)> {
let mut out = Vec::new();
for path in s.doc.para_paths(story) {
for o in &s.doc.para(story, &path).unwrap().objects {
if let InlineObject::Field { instr, result, .. } = o {
out.push((instr.clone(), result.clone()));
}
}
}
out
}
/// #1706 (Word check): Update Field with the caret in the body leaves a header's fields alone.
#[test]
fn update_fields_in_the_body_leaves_headers_alone() {
let (doc, hdr) = doc_with_header_fields();
let mut s = Session::new(doc);
s.run("references.updateFields", &json!({})).unwrap();
assert_eq!(story_fields(&s, StoryRef::Body)[0], ("AUTHOR".to_string(), "Ada".to_string()));
assert!(story_fields(&s, StoryRef::Part(hdr)).iter().all(|(_, r)| r == "stale"), "{:?}", story_fields(&s, StoryRef::Part(hdr)));
}
/// #1706: with the caret in a header (or other part), Update Field refreshes that part's
/// fields, but not locked ones and not the body's.
#[test]
fn update_fields_in_a_header_updates_that_header() {
let (doc, hdr) = doc_with_header_fields();
let mut s = Session::new(doc);
let path = s.doc.para_paths(StoryRef::Part(hdr)).remove(0);
s.sel = crate::session::Selection::caret(Pos::new(StoryRef::Part(hdr), path, 0));
s.run("references.updateFields", &json!({})).unwrap();
let got = story_fields(&s, StoryRef::Part(hdr));
assert_eq!(got.len(), 3, "{got:?}");
assert_eq!(got[0].1, "Ada");
assert_eq!(got[1].1, s.doc.word_count().to_string());
assert_eq!(got[2].1, "stale", "a locked field stays as it was");
assert!(story_fields(&s, StoryRef::Body).iter().all(|(_, r)| r == "stale"), "the body is not touched");
}
/// #1706: `all: true` (printing) updates the whole document even with the caret in a header.
#[test]
fn update_fields_all_covers_every_story() {
let (doc, hdr) = doc_with_header_fields();
let mut s = Session::new(doc);
let path = s.doc.para_paths(StoryRef::Part(hdr)).remove(0);
s.sel = crate::session::Selection::caret(Pos::new(StoryRef::Part(hdr), path, 0));
s.run("references.updateFields", &json!({"all": true})).unwrap();
let got = story_fields(&s, StoryRef::Part(hdr));
assert_eq!((got[0].1.as_str(), got[2].1.as_str()), ("Ada", "stale"), "{got:?}");
assert_eq!(story_fields(&s, StoryRef::Body)[0].1, "Ada");
// Junk values fall back to the caret's story.
s.run("references.updateFields", &json!({"all": "yes"})).unwrap();
}
#[test]
fn toc_tabs_ignore_nonentries() {
let mut s = Session::new(wordcraft_doc::Document::from_text("Before\nContents\nOld\t9\nAfter"));
let stop = |pos| TabStop { pos, align: TabAlign::Left, leader: TabLeader::None };
for (i, pos) in [(0, 96.0), (1, 84.0), (2, 24.0)] {
let p = s.doc.para_mut(StoryRef::Body, &Path::top(i)).unwrap();
p.props.style = Some("TOC2".into());
p.props.tabs = Some(vec![stop(pos)]);
}
s.doc
.para_mut(StoryRef::Body, &Path::top(1))
.unwrap()
.insert_object(
0,
InlineObject::Field { instr: "TOC".into(), result: String::new(), locked: false, dirty: false, code: false, form: String::new() },
&CharProps::default(),
)
.unwrap();
assert_eq!(cached_toc_tabs(&s), vec![("TOC2".into(), vec![stop(24.0)])]);
}
/// #1230: a selected picture or shape in a table cell gets its caption in the cell, under
/// (or over) the paragraph that holds it, not after the whole table; the number counts on.
#[test]
fn caption_for_an_object_in_a_cell_stays_in_the_cell() {
let mut s = Session::new(wordcraft_doc::Document::new());
s.run("insert.table", &json!({"rows": 1, "cols": 2})).unwrap();
s.run("insert.shape", &json!({"kind": "ellipse", "width": 60, "height": 40})).unwrap();
let (at, _) = crate::cmd::objects::selected(&s).expect("the shape is selected");
let (table, r, c) = at.path.cell().expect("the shape is in a cell");
s.run("references.caption", &json!({"label": "Figure", "text": "Kiln"})).unwrap();
let cell = |s: &Session| s.doc.table(StoryRef::Body, &table).unwrap().rows[r].cells[c].blocks.clone();
let texts: Vec<String> = cell(&s).iter().filter_map(|b| b.as_para()).map(|p| p.plain_text()).collect();
assert_eq!(texts.len(), 2, "{texts:?}");
assert_eq!(texts[1], "Figure 1: Kiln", "the caption is under the shape, in the cell");
assert_eq!(s.doc.body.iter().filter(|b| b.as_para().is_some_and(|p| p.props.style.as_deref() == Some("Caption"))).count(), 0);
// Above: over the shape's paragraph, and it is Figure 1 (the one below becomes 2).
let end = Pos { off: at.off + wordcraft_doc::para::OBJ.len_utf8(), ..at.clone() };
s.sel = Selection { anchor: at.clone(), focus: end };
assert!(crate::cmd::objects::object_selection(&s).is_some());
s.run("references.caption", &json!({"label": "Figure", "text": "Lid", "position": "above"})).unwrap();
let texts: Vec<String> = cell(&s).iter().filter_map(|b| b.as_para()).map(|p| p.plain_text()).collect();
assert_eq!(texts.len(), 3, "{texts:?}");
assert_eq!((texts[0].as_str(), texts[2].as_str()), ("Figure 1: Lid", "Figure 2: Kiln"));
// A picture outside a table: the caption goes right after its paragraph.
let mut s = Session::new(wordcraft_doc::Document::from_text("Before\nAfter"));
s.run("select.text", &json!({"text": "Before"})).unwrap();
s.run("caret.end", &json!({})).unwrap();
s.run("insert.shape", &json!({"kind": "ellipse", "width": 60, "height": 40})).unwrap();
s.run("references.caption", &json!({})).unwrap();
let texts: Vec<String> = s.doc.body.iter().filter_map(|b| b.as_para()).map(|p| p.plain_text()).collect();
assert_eq!(texts.get(1).map(String::as_str), Some("Figure 1"), "{texts:?}");
}
}