From d66f34b77477e8193a15f07aa7f0d353d30bbde0 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:57:14 +0000 Subject: [PATCH] refactor(engine): split cmd/references.rs into references/ --- crates/engine/src/cmd/references.rs | 1107 +---------------- crates/engine/src/cmd/references/fields.rs | 315 +++++ crates/engine/src/cmd/references/notes.rs | 138 ++ .../engine/src/cmd/references/properties.rs | 102 ++ crates/engine/src/cmd/references/toc.rs | 544 ++++++++ 5 files changed, 1115 insertions(+), 1091 deletions(-) create mode 100644 crates/engine/src/cmd/references/fields.rs create mode 100644 crates/engine/src/cmd/references/notes.rs create mode 100644 crates/engine/src/cmd/references/properties.rs create mode 100644 crates/engine/src/cmd/references/toc.rs diff --git a/crates/engine/src/cmd/references.rs b/crates/engine/src/cmd/references.rs index ae01aa9271..48bb678d8f 100644 --- a/crates/engine/src/cmd/references.rs +++ b/crates/engine/src/cmd/references.rs @@ -10,6 +10,22 @@ 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}; +mod fields; +mod notes; +mod properties; +mod toc; + +use fields::caption; +pub use fields::{CHAPTER_SEPARATORS, refresh_date_fields, seq_format, update_fields, update_fields_in, update_seq}; +pub(super) use notes::note_options; +use notes::{note, note_refs}; +pub use properties::{bookmark_text, date_picture}; +#[cfg(test)] +use toc::cached_toc_tabs; +pub use toc::{caret_in_toc, leader, level_range, toc_instr, update_toc}; +pub(crate) use toc::{headings, toc_bookmark}; +use toc::{insert_toc, remove_toc, toc_range}; + pub fn specs() -> Vec { vec![ CommandSpec::new("references.toc", "Table of Contents", "References › Table of Contents", insert_toc).params( @@ -158,1096 +174,5 @@ impl FieldCode { } } -/// 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::().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::().trim().to_string(); - let pairs: Vec = 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 { - p.props.style.as_deref()?.strip_prefix("Heading")?.trim().parse::().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 { - 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::().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::().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)) -} - -/// Removes the TOC blocks `start..=end` and returns where the TOC was. A field paragraph that ends -/// a section stays, emptied, so its section break isn't lost (#3889). A heading paragraph of its -/// own just above the field's paragraph (Word's tables start the field after their heading) goes -/// with the table. -fn remove_toc(s: &mut Session, start: usize, end: usize) -> Result { - for i in (start + 1..=end).rev() { - s.doc.remove_block(StoryRef::Body, &Path::top(i))?; - } - let at = match s.doc.para_mut(StoryRef::Body, &Path::top(start)) { - Ok(p) if p.section.is_some() => { - *p = Paragraph { section: p.section.take(), ..Paragraph::new() }; - start + 1 - } - _ => { - s.doc.remove_block(StoryRef::Body, &Path::top(start))?; - start - } - }; - let titled = start - .checked_sub(1) - .and_then(|i| s.doc.body.get(i)) - .and_then(|b| b.as_para()) - .is_some_and(|p| p.props.style.as_deref() == Some("TOCHeading") && p.section.is_none() && toc_field(p).is_none()); - if titled { - s.doc.remove_block(StoryRef::Body, &Path::top(start - 1))?; - return Ok(at - 1); - } - Ok(at) -} - -/// 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.is_table_of_figures() - && 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(); - // Or one holding only the end of Word's TOC control, still styled as an entry: Update Table - // would delete it with the old entries anyway. - let leftover = |p: &Paragraph| { - p.is_empty() - || (p.objects.iter().all(|o| matches!(o, InlineObject::ControlEnd)) - && p.text.chars().all(|c| c == OBJ) - && p.props.style.as_deref().is_some_and(|st| st.starts_with("TOC") && st != "TOCHeading")) - }; - if at.path.depth() == 0 - && i > 0 - && s.doc.para_at(&at).is_some_and(leftover) - // 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. So is one - // ending the section before, as above entries in columns (#3889). - && 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()) - }) - { - 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 { - p.objects.iter().find_map(|o| match o { - InlineObject::Field { instr, .. } => { - let f = FieldCode::parse(instr); - (f.name == "TOC" && !f.is_table_of_figures()).then(|| instr.clone()) - } - _ => None, - }) -} - -/// The first cached number-separator tab formatting at each TOC level. -fn cached_toc_tabs(s: &Session) -> Vec<(String, Vec)> { - 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.is_table_of_figures()) - .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)> = 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)) => { - let start = remove_toc(s, start, end)?; - 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 { - bookmark_heading(s, &Path::top(i), lead) -} - -fn bookmark_heading(s: &mut Session, path: &Path, lead: usize) -> Result { - 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::().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, tabs: &[(String, Vec)]) -> 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.is_table_of_figures() { - 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 column 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. In columns, the first - // column's, as Word does (#3889); after a field paragraph ending a section, the next section's. - let after_break = s.doc.body.get(start).and_then(|b| b.as_para()).is_some_and(|p| p.section.is_some()); - let section = s.doc.section_index_of(start + usize::from(after_break)); - let column = |x: &wordcraft_doc::SectionProps| x.column_boxes().first().map_or(x.text_width(), |(_, w)| *w); - let width = s.doc.sections().get(section).map_or_else(|| column(&super::page::sect(s)), |(_, x)| column(x)); - let entries = toc_entries(s, &f); - let links: Vec> = 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, ¶.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.1Title`); 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 { - 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, CmdError> { - match p::str(v, k) { - None => Ok(None), - Some(f) if NumFormat::from_ooxml(f).ooxml() == f => Ok(Some(NumFormat::from_ooxml(f))), - Some(f) => Err(CmdError::Params(format!("unknown number format {f:?}"))), - } - }; - // The earlier form: the document's number formats. - let (old_foot, old_end) = (fmt("footnoteFormat")?, fmt("endnoteFormat")?); - let endnote = match p::str(v, "kind") { - None | Some("footnote") => false, - Some("endnote") => true, - Some(k) => return Err(CmdError::Params(format!("unknown `kind` {k:?}; use footnote or endnote"))), - }; - let pos = match p::str(v, "pos") { - None => None, - Some(x) => match NotePos::from_ooxml(x) { - Some(p @ (NotePos::PageBottom | NotePos::BeneathText)) if !endnote => Some(p), - Some(p @ (NotePos::SectEnd | NotePos::DocEnd)) if endnote => Some(p), - _ => { - let ok = if endnote { "sectEnd or docEnd" } else { "pageBottom or beneathText" }; - return Err(CmdError::Params(format!("`pos` {x:?} doesn't apply here; use {ok}"))); - } - }, - }; - let num_restart = match p::str(v, "numRestart") { - None => None, - Some(r) => match NoteRestart::from_ooxml(r) { - Some(NoteRestart::EachPage) if endnote => return Err(CmdError::Params("endnotes can't start again on each page".into())), - Some(r) => Some(r), - None => return Err(CmdError::Params(format!("unknown `numRestart` {r:?}; use continuous, eachSect or eachPage"))), - }, - }; - let change = NoteProps { - pos, - num_fmt: fmt("numFmt")?, - num_start: p::f32(v, "numStart").map(|n| n.round().clamp(1.0, MAX_NOTE_START as f32) as u32), - num_restart, - }; - let whole = match p::str(v, "scope") { - None | Some("document") => true, - Some("section") => false, - Some(x) => return Err(CmdError::Params(format!("unknown `scope` {x:?}; use document or section"))), - }; - // Set `from`'s fields on `to`; `clear` instead unsets the fields `from` sets. - let merge = |to: &mut NoteProps, from: &NoteProps, clear: bool| { - if from.pos.is_some() { - to.pos = if clear { None } else { from.pos }; - } - if from.num_fmt.is_some() { - to.num_fmt = if clear { None } else { from.num_fmt }; - } - if from.num_start.is_some() { - to.num_start = if clear { None } else { from.num_start }; - } - if from.num_restart.is_some() { - to.num_restart = if clear { None } else { from.num_restart }; - } - }; - fn pick(sect: &mut wordcraft_doc::SectionProps, endnote: bool) -> &mut NoteProps { - if endnote { &mut sect.endnote_pr } else { &mut sect.footnote_pr } - } - if change.is_empty() { - // Nothing to change: the options in effect. - } else if whole { - let st = &mut s.doc.settings; - if let Some(f) = change.num_fmt { - *(if endnote { &mut st.endnote_format } else { &mut st.footnote_format }) = f; - } - merge(if endnote { &mut st.endnote_pr } else { &mut st.footnote_pr }, &NoteProps { num_fmt: None, ..change }, false); - // Every section follows the document now. - let ends: Vec = s.doc.sections().iter().map(|(e, _)| *e).collect(); - for e in ends { - merge(pick(s.doc.section_mut(e), endnote), &change, true); - } - } else { - super::page::with_sect(s, |sect| merge(pick(sect, endnote), &change, false))?; - } - if let Some(f) = old_foot { - s.doc.settings.footnote_format = f; - } - if let Some(f) = old_end { - s.doc.settings.endnote_format = f; - } - let sect = super::page::sect(s); - Ok(json!({ - "footnote": s.doc.note_options(§, false), - "endnote": s.doc.note_options(§, true), - "footnoteFormat": s.doc.settings.footnote_format, - "endnoteFormat": s.doc.settings.endnote_format, - })) -} - -fn note_refs(s: &Session, kind: Option) -> Vec { - 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::>().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 = Default::default(); - // Built on the first STYLEREF (field results don't change what it finds). - let mut styles: Option = 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::().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::().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 { wordcraft_layout::fields::field_number(instr, fmt, 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, text_result(instr, 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, the document -/// information fields (TITLE, SUBJECT, AUTHOR, KEYWORDS, COMMENTS, LASTSAVEDBY, REVNUM, NUMWORDS, -/// NUMCHARS, FILENAME, DOCPROPERTY) 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 = Default::default(); - let formats: Vec = 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 = - if all { std::iter::once(StoryRef::Body).chain(s.doc.parts.keys().map(|k| StoryRef::Part(*k))).collect() } else { vec![caret_story] }; - // The document properties as the fields read and set them (`SUBJECT "New subject"`): a field - // after one that sets a property shows the new value. - let mut core = s.doc.core.clone(); - for (story, path) in stories.into_iter().flat_map(|st| s.doc.para_paths(st).into_iter().map(move |p| (st, p))).collect::>() { - 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, text_result(instr, 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); - wordcraft_layout::fields::field_number(instr, fmt, *num) - }) - } else { - None - }; - if let Some(r) = r { - updates.push((k, if name == "REF" { text_result(instr, r) } else { r })); - } - } - // The summary properties; a quoted text after the name replaces the property. - "TITLE" | "SUBJECT" | "AUTHOR" | "KEYWORDS" | "COMMENTS" => { - if let Some(v) = new_property_value(instr) { - set_summary_property(&mut core, &name, v); - } - updates.push((k, text_result(instr, summary_property(&core, &name).to_string()))); - } - "LASTSAVEDBY" => updates.push((k, text_result(instr, core.last_modified_by.clone()))), - "NUMWORDS" => updates.push((k, count_text(instr, s.doc.word_count()))), - "NUMCHARS" => { - let chars = s.doc.plain_text(StoryRef::Body).chars().filter(|c| !c.is_whitespace()).count(); - updates.push((k, count_text(instr, chars))); - } - "REVNUM" => updates.push((k, count_text(instr, core.revision as usize))), - // DOCPROPERTY : a custom property first, then a built-in one (#1798). - "DOCPROPERTY" => { - if let Some(name) = doc_property_arg(instr) - && let Some(v) = doc_property(&s.doc, &core, &name) - { - updates.push((k, text_result(instr, v))); - } - } - "FILENAME" => { - let (fn_name, fn_path) = s.resolved_filenames(); - let text = if has_switch(instr, 'p') { fn_path } else { fn_name }; - updates.push((k, text_result(instr, text))); - } - _ => {} - } - } - if core != s.doc.core { - s.doc.core.clone_from(&core); - } - 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 = 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, text_result(instr, 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(); - } - } -} - -/// The summary property a TITLE, SUBJECT, AUTHOR, KEYWORDS or COMMENTS field shows. -fn summary_property<'a>(core: &'a wordcraft_doc::CoreProps, field: &str) -> &'a str { - match field { - "TITLE" => &core.title, - "SUBJECT" => &core.subject, - "AUTHOR" => &core.creator, - "KEYWORDS" => &core.keywords, - "COMMENTS" => &core.description, - _ => "", - } -} - -fn set_summary_property(core: &mut wordcraft_doc::CoreProps, field: &str, value: String) { - match field { - "TITLE" => core.title = value, - "SUBJECT" => core.subject = value, - "AUTHOR" => core.creator = value, - "KEYWORDS" => core.keywords = value, - "COMMENTS" => core.description = value, - _ => {} - } -} - -/// The new value a summary-property field gives its property: the argument after the field's -/// name (`SUBJECT "Quarterly report"`, or one word without quotes: `SUBJECT Draft`), at most 255 -/// characters as Word allows. -fn new_property_value(instr: &str) -> Option { - FieldCode::parse(instr).args.first().map(|a| a.chars().take(255).collect()) -} - -/// A count (NUMWORDS, NUMCHARS, REVNUM) in the field's `\*` number format, digits without one or -/// when the format does not cover the count (see [`super::table_formula::number_format_covers`]), -/// with the field's capitalization switches. -fn count_text(instr: &str, n: usize) -> String { - let fmt = wordcraft_layout::fields::format_switch(instr) - .filter(|f| super::table_formula::number_format_covers(*f, n as f64)) - .unwrap_or(NumFormat::Decimal); - wordcraft_layout::fields::field_number(instr, fmt, u32::try_from(n).unwrap_or(u32::MAX)) -} - -/// A field's computed text with its capitalization switches (`\* Upper`, `\* Lower`, `\* Caps`, -/// `\* FirstCap`) applied in order. -fn text_result(instr: &str, text: String) -> String { - wordcraft_layout::fields::apply_case_switch(instr, text) -} - -/// The property name of a `DOCPROPERTY` field: the word (or quoted text) after the field name, -/// before any switch (`DOCPROPERTY "Project Name" \* MERGEFORMAT`). -fn doc_property_arg(instr: &str) -> Option { - let rest = instr.trim().split_once(char::is_whitespace)?.1.trim_start(); - let name = match rest.strip_prefix('"') { - Some(q) => q.split('"').next().unwrap_or(""), - None => rest.split([' ', '\\']).next().unwrap_or(""), - }; - (!name.is_empty()).then(|| name.to_string()) -} - -/// The value of a document property under the name a `DOCPROPERTY` field uses: a custom property -/// when one has this name, else the built-in property (English names, case-insensitively, as -/// Word's field accepts) from `core`. `None` for a name this application doesn't know. -fn doc_property(doc: &wordcraft_doc::Document, core: &wordcraft_doc::CoreProps, name: &str) -> Option { - if let Some(v) = doc.custom_prop(name) { - return Some(v.to_string()); - } - let c = core; - Some(match name.trim().to_ascii_lowercase().as_str() { - "title" => c.title.clone(), - "subject" => c.subject.clone(), - "author" | "creator" => c.creator.clone(), - "keywords" => c.keywords.clone(), - "comments" | "description" => c.description.clone(), - "category" => c.category.clone(), - "lastmodifiedby" | "last modified by" | "lastsavedby" | "last saved by" => c.last_modified_by.clone(), - "revisionnumber" | "revision number" | "revision" => c.revision.to_string(), - _ => return None, - }) -} - -/// A field's `\@` date picture: the quoted text after it (switches may follow), or one word. -pub fn date_picture(instr: &str) -> Option { - 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 { - 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; diff --git a/crates/engine/src/cmd/references/fields.rs b/crates/engine/src/cmd/references/fields.rs new file mode 100644 index 0000000000..658a9b7b89 --- /dev/null +++ b/crates/engine/src/cmd/references/fields.rs @@ -0,0 +1,315 @@ +use super::properties::{ + bookmark_text, count_text, date_picture, doc_property, doc_property_arg, new_property_value, set_summary_property, summary_property, text_result, +}; +use super::toc::{heading_style_level, update_toc}; +use super::*; + +/// 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). +pub(super) 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::>().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 crate::cmd::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 = Default::default(); + // Built on the first STYLEREF (field results don't change what it finds). + let mut styles: Option = 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::().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::().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 { wordcraft_layout::fields::field_number(instr, fmt, 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, text_result(instr, 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, the document +/// information fields (TITLE, SUBJECT, AUTHOR, KEYWORDS, COMMENTS, LASTSAVEDBY, REVNUM, NUMWORDS, +/// NUMCHARS, FILENAME, DOCPROPERTY) 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 = Default::default(); + let formats: Vec = 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 = + if all { std::iter::once(StoryRef::Body).chain(s.doc.parts.keys().map(|k| StoryRef::Part(*k))).collect() } else { vec![caret_story] }; + // The document properties as the fields read and set them (`SUBJECT "New subject"`): a field + // after one that sets a property shows the new value. + let mut core = s.doc.core.clone(); + for (story, path) in stories.into_iter().flat_map(|st| s.doc.para_paths(st).into_iter().map(move |p| (st, p))).collect::>() { + 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 = crate::cmd::default_lang(s); + let text = match crate::cmd::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, text_result(instr, crate::cmd::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); + wordcraft_layout::fields::field_number(instr, fmt, *num) + }) + } else { + None + }; + if let Some(r) = r { + updates.push((k, if name == "REF" { text_result(instr, r) } else { r })); + } + } + // The summary properties; a quoted text after the name replaces the property. + "TITLE" | "SUBJECT" | "AUTHOR" | "KEYWORDS" | "COMMENTS" => { + if let Some(v) = new_property_value(instr) { + set_summary_property(&mut core, &name, v); + } + updates.push((k, text_result(instr, summary_property(&core, &name).to_string()))); + } + "LASTSAVEDBY" => updates.push((k, text_result(instr, core.last_modified_by.clone()))), + "NUMWORDS" => updates.push((k, count_text(instr, s.doc.word_count()))), + "NUMCHARS" => { + let chars = s.doc.plain_text(StoryRef::Body).chars().filter(|c| !c.is_whitespace()).count(); + updates.push((k, count_text(instr, chars))); + } + "REVNUM" => updates.push((k, count_text(instr, core.revision as usize))), + // DOCPROPERTY : a custom property first, then a built-in one (#1798). + "DOCPROPERTY" => { + if let Some(name) = doc_property_arg(instr) + && let Some(v) = doc_property(&s.doc, &core, &name) + { + updates.push((k, text_result(instr, v))); + } + } + "FILENAME" => { + let (fn_name, fn_path) = s.resolved_filenames(); + let text = if has_switch(instr, 'p') { fn_path } else { fn_name }; + updates.push((k, text_result(instr, text))); + } + _ => {} + } + } + if core != s.doc.core { + s.doc.core.clone_from(&core); + } + 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(()); + } + crate::cmd::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 = 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, text_result(instr, crate::cmd::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(); + } + } +} diff --git a/crates/engine/src/cmd/references/notes.rs b/crates/engine/src/cmd/references/notes.rs new file mode 100644 index 0000000000..8877aa9037 --- /dev/null +++ b/crates/engine/src/cmd/references/notes.rs @@ -0,0 +1,138 @@ +use super::*; + +pub(super) 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(in crate::cmd) fn note_options(s: &mut Session, v: &Value) -> CmdResult { + use wordcraft_doc::section::{MAX_NOTE_START, NotePos, NoteProps, NoteRestart, NumFormat}; + let fmt = |k: &str| -> Result, CmdError> { + match p::str(v, k) { + None => Ok(None), + Some(f) if NumFormat::from_ooxml(f).ooxml() == f => Ok(Some(NumFormat::from_ooxml(f))), + Some(f) => Err(CmdError::Params(format!("unknown number format {f:?}"))), + } + }; + // The earlier form: the document's number formats. + let (old_foot, old_end) = (fmt("footnoteFormat")?, fmt("endnoteFormat")?); + let endnote = match p::str(v, "kind") { + None | Some("footnote") => false, + Some("endnote") => true, + Some(k) => return Err(CmdError::Params(format!("unknown `kind` {k:?}; use footnote or endnote"))), + }; + let pos = match p::str(v, "pos") { + None => None, + Some(x) => match NotePos::from_ooxml(x) { + Some(p @ (NotePos::PageBottom | NotePos::BeneathText)) if !endnote => Some(p), + Some(p @ (NotePos::SectEnd | NotePos::DocEnd)) if endnote => Some(p), + _ => { + let ok = if endnote { "sectEnd or docEnd" } else { "pageBottom or beneathText" }; + return Err(CmdError::Params(format!("`pos` {x:?} doesn't apply here; use {ok}"))); + } + }, + }; + let num_restart = match p::str(v, "numRestart") { + None => None, + Some(r) => match NoteRestart::from_ooxml(r) { + Some(NoteRestart::EachPage) if endnote => return Err(CmdError::Params("endnotes can't start again on each page".into())), + Some(r) => Some(r), + None => return Err(CmdError::Params(format!("unknown `numRestart` {r:?}; use continuous, eachSect or eachPage"))), + }, + }; + let change = NoteProps { + pos, + num_fmt: fmt("numFmt")?, + num_start: p::f32(v, "numStart").map(|n| n.round().clamp(1.0, MAX_NOTE_START as f32) as u32), + num_restart, + }; + let whole = match p::str(v, "scope") { + None | Some("document") => true, + Some("section") => false, + Some(x) => return Err(CmdError::Params(format!("unknown `scope` {x:?}; use document or section"))), + }; + // Set `from`'s fields on `to`; `clear` instead unsets the fields `from` sets. + let merge = |to: &mut NoteProps, from: &NoteProps, clear: bool| { + if from.pos.is_some() { + to.pos = if clear { None } else { from.pos }; + } + if from.num_fmt.is_some() { + to.num_fmt = if clear { None } else { from.num_fmt }; + } + if from.num_start.is_some() { + to.num_start = if clear { None } else { from.num_start }; + } + if from.num_restart.is_some() { + to.num_restart = if clear { None } else { from.num_restart }; + } + }; + fn pick(sect: &mut wordcraft_doc::SectionProps, endnote: bool) -> &mut NoteProps { + if endnote { &mut sect.endnote_pr } else { &mut sect.footnote_pr } + } + if change.is_empty() { + // Nothing to change: the options in effect. + } else if whole { + let st = &mut s.doc.settings; + if let Some(f) = change.num_fmt { + *(if endnote { &mut st.endnote_format } else { &mut st.footnote_format }) = f; + } + merge(if endnote { &mut st.endnote_pr } else { &mut st.footnote_pr }, &NoteProps { num_fmt: None, ..change }, false); + // Every section follows the document now. + let ends: Vec = s.doc.sections().iter().map(|(e, _)| *e).collect(); + for e in ends { + merge(pick(s.doc.section_mut(e), endnote), &change, true); + } + } else { + crate::cmd::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 = crate::cmd::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, + })) +} + +pub(super) fn note_refs(s: &Session, kind: Option) -> Vec { + 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 +} diff --git a/crates/engine/src/cmd/references/properties.rs b/crates/engine/src/cmd/references/properties.rs new file mode 100644 index 0000000000..7f77fc2404 --- /dev/null +++ b/crates/engine/src/cmd/references/properties.rs @@ -0,0 +1,102 @@ +use super::*; + +/// The summary property a TITLE, SUBJECT, AUTHOR, KEYWORDS or COMMENTS field shows. +pub(super) fn summary_property<'a>(core: &'a wordcraft_doc::CoreProps, field: &str) -> &'a str { + match field { + "TITLE" => &core.title, + "SUBJECT" => &core.subject, + "AUTHOR" => &core.creator, + "KEYWORDS" => &core.keywords, + "COMMENTS" => &core.description, + _ => "", + } +} + +pub(super) fn set_summary_property(core: &mut wordcraft_doc::CoreProps, field: &str, value: String) { + match field { + "TITLE" => core.title = value, + "SUBJECT" => core.subject = value, + "AUTHOR" => core.creator = value, + "KEYWORDS" => core.keywords = value, + "COMMENTS" => core.description = value, + _ => {} + } +} + +/// The new value a summary-property field gives its property: the argument after the field's +/// name (`SUBJECT "Quarterly report"`, or one word without quotes: `SUBJECT Draft`), at most 255 +/// characters as Word allows. +pub(super) fn new_property_value(instr: &str) -> Option { + FieldCode::parse(instr).args.first().map(|a| a.chars().take(255).collect()) +} + +/// A count (NUMWORDS, NUMCHARS, REVNUM) in the field's `\*` number format, digits without one or +/// when the format does not cover the count (see [`super::table_formula::number_format_covers`]), +/// with the field's capitalization switches. +pub(super) fn count_text(instr: &str, n: usize) -> String { + let fmt = wordcraft_layout::fields::format_switch(instr) + .filter(|f| crate::cmd::table_formula::number_format_covers(*f, n as f64)) + .unwrap_or(NumFormat::Decimal); + wordcraft_layout::fields::field_number(instr, fmt, u32::try_from(n).unwrap_or(u32::MAX)) +} + +/// A field's computed text with its capitalization switches (`\* Upper`, `\* Lower`, `\* Caps`, +/// `\* FirstCap`) applied in order. +pub(super) fn text_result(instr: &str, text: String) -> String { + wordcraft_layout::fields::apply_case_switch(instr, text) +} + +/// The property name of a `DOCPROPERTY` field: the word (or quoted text) after the field name, +/// before any switch (`DOCPROPERTY "Project Name" \* MERGEFORMAT`). +pub(super) fn doc_property_arg(instr: &str) -> Option { + let rest = instr.trim().split_once(char::is_whitespace)?.1.trim_start(); + let name = match rest.strip_prefix('"') { + Some(q) => q.split('"').next().unwrap_or(""), + None => rest.split([' ', '\\']).next().unwrap_or(""), + }; + (!name.is_empty()).then(|| name.to_string()) +} + +/// The value of a document property under the name a `DOCPROPERTY` field uses: a custom property +/// when one has this name, else the built-in property (English names, case-insensitively, as +/// Word's field accepts) from `core`. `None` for a name this application doesn't know. +pub(super) fn doc_property(doc: &wordcraft_doc::Document, core: &wordcraft_doc::CoreProps, name: &str) -> Option { + if let Some(v) = doc.custom_prop(name) { + return Some(v.to_string()); + } + let c = core; + Some(match name.trim().to_ascii_lowercase().as_str() { + "title" => c.title.clone(), + "subject" => c.subject.clone(), + "author" | "creator" => c.creator.clone(), + "keywords" => c.keywords.clone(), + "comments" | "description" => c.description.clone(), + "category" => c.category.clone(), + "lastmodifiedby" | "last modified by" | "lastsavedby" | "last saved by" => c.last_modified_by.clone(), + "revisionnumber" | "revision number" | "revision" => c.revision.to_string(), + _ => return None, + }) +} + +/// A field's `\@` date picture: the quoted text after it (switches may follow), or one word. +pub fn date_picture(instr: &str) -> Option { + 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 { + 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()) +} diff --git a/crates/engine/src/cmd/references/toc.rs b/crates/engine/src/cmd/references/toc.rs new file mode 100644 index 0000000000..a475ff26c6 --- /dev/null +++ b/crates/engine/src/cmd/references/toc.rs @@ -0,0 +1,544 @@ +use super::*; + +/// 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::().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::().trim().to_string(); + let pairs: Vec = 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. +pub(super) fn heading_style_level(p: &Paragraph) -> Option { + p.props.style.as_deref()?.strip_prefix("Heading")?.trim().parse::().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 { + 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::().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::().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. +pub(super) 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)) +} + +/// Removes the TOC blocks `start..=end` and returns where the TOC was. A field paragraph that ends +/// a section stays, emptied, so its section break isn't lost (#3889). A heading paragraph of its +/// own just above the field's paragraph (Word's tables start the field after their heading) goes +/// with the table. +pub(super) fn remove_toc(s: &mut Session, start: usize, end: usize) -> Result { + for i in (start + 1..=end).rev() { + s.doc.remove_block(StoryRef::Body, &Path::top(i))?; + } + let at = match s.doc.para_mut(StoryRef::Body, &Path::top(start)) { + Ok(p) if p.section.is_some() => { + *p = Paragraph { section: p.section.take(), ..Paragraph::new() }; + start + 1 + } + _ => { + s.doc.remove_block(StoryRef::Body, &Path::top(start))?; + start + } + }; + let titled = start + .checked_sub(1) + .and_then(|i| s.doc.body.get(i)) + .and_then(|b| b.as_para()) + .is_some_and(|p| p.props.style.as_deref() == Some("TOCHeading") && p.section.is_none() && toc_field(p).is_none()); + if titled { + s.doc.remove_block(StoryRef::Body, &Path::top(start - 1))?; + return Ok(at - 1); + } + Ok(at) +} + +/// 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.is_table_of_figures() + && 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(); + // Or one holding only the end of Word's TOC control, still styled as an entry: Update Table + // would delete it with the old entries anyway. + let leftover = |p: &Paragraph| { + p.is_empty() + || (p.objects.iter().all(|o| matches!(o, InlineObject::ControlEnd)) + && p.text.chars().all(|c| c == OBJ) + && p.props.style.as_deref().is_some_and(|st| st.starts_with("TOC") && st != "TOCHeading")) + }; + if at.path.depth() == 0 + && i > 0 + && s.doc.para_at(&at).is_some_and(leftover) + // 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. So is one + // ending the section before, as above entries in columns (#3889). + && 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()) + }) + { + 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 { + p.objects.iter().find_map(|o| match o { + InlineObject::Field { instr, .. } => { + let f = FieldCode::parse(instr); + (f.name == "TOC" && !f.is_table_of_figures()).then(|| instr.clone()) + } + _ => None, + }) +} + +/// The first cached number-separator tab formatting at each TOC level. +pub(super) fn cached_toc_tabs(s: &Session) -> Vec<(String, Vec)> { + 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.is_table_of_figures()) + .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)> = 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 +} + +pub(super) 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)) => { + let start = remove_toc(s, start, end)?; + 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 { + bookmark_heading(s, &Path::top(i), lead) +} + +fn bookmark_heading(s: &mut Session, path: &Path, lead: usize) -> Result { + 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::().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, tabs: &[(String, Vec)]) -> 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.is_table_of_figures() { + 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 column 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. In columns, the first + // column's, as Word does (#3889); after a field paragraph ending a section, the next section's. + let after_break = s.doc.body.get(start).and_then(|b| b.as_para()).is_some_and(|p| p.section.is_some()); + let section = s.doc.section_index_of(start + usize::from(after_break)); + let column = |x: &wordcraft_doc::SectionProps| x.column_boxes().first().map_or(x.text_width(), |(_, w)| *w); + let width = s.doc.sections().get(section).map_or_else(|| column(&crate::cmd::page::sect(s)), |(_, x)| column(x)); + let entries = toc_entries(s, &f); + let links: Vec> = 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, ¶.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.1Title`); 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 { + 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 +}