Files
wordcraft/crates/engine/src/cmd/references/fields.rs
T

316 lines
16 KiB
Rust

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::<Vec<_>>().join("_");
let text = p::str(v, "text").unwrap_or("").to_string();
let above = p::str(v, "position") == Some("above");
let format = seq_format(p::str(v, "format").unwrap_or("ARABIC"));
let chapter = p::u64(v, "chapter").filter(|l| (1..=9).contains(l));
let sep = p::str(v, "separator").and_then(|x| CHAPTER_SEPARATORS.iter().find(|s| **s == x)).copied().unwrap_or("-");
let mut para = Paragraph::new().styled("Caption");
let field = |instr: String| InlineObject::Field { instr, result: String::new(), locked: false, dirty: false, code: false, form: String::new() };
if !p::bool(v, "excludeLabel").unwrap_or(false) {
para.insert_text(0, &format!("{label} "), &CharProps::default())?;
}
let mut seq = format!("SEQ {ident} \\* {format}");
if let Some(l) = chapter {
para.insert_object(para.len(), field(format!("STYLEREF {l} \\s")), &CharProps::default())?;
para.insert_text(para.len(), sep, &CharProps::default())?;
seq.push_str(&format!(" \\s {l}"));
}
para.insert_object(para.len(), field(seq), &CharProps::default())?;
if !text.is_empty() {
para.insert_text(para.len(), &format!(": {text}"), &CharProps::default())?;
}
// A selected picture, shape or chart: next to the paragraph holding it, in the same cell or
// text box (#1230). Otherwise next to the caret's top-level block: a paragraph, or the whole
// table it is in.
let f = s.sel.focus.clone();
let at = match 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<String, (u32, u32)> = Default::default();
// Built on the first STYLEREF (field results don't change what it finds).
let mut styles: Option<wordcraft_layout::styleref::StyleIndex> = None;
for path in s.doc.para_paths(StoryRef::Body) {
let Some(p) = s.doc.para(StoryRef::Body, &path) else { continue };
if let Some(l) = heading_style_level(p)
&& let Some(k) = (l as usize).checked_sub(1)
&& let Some(count) = headings.get_mut(k)
{
*count = count.saturating_add(1);
}
let mut updates: Vec<(usize, String)> = Vec::new();
for (k, o) in p.objects.iter().enumerate() {
let InlineObject::Field { instr, locked: false, .. } = o else { continue };
let f = FieldCode::parse(instr);
match f.name.as_str() {
"SEQ" => {
let Some(id) = f.args.first() else { continue };
let restart =
f.arg('s').and_then(|l| l.parse::<usize>().ok()).and_then(|l| l.checked_sub(1)).and_then(|l| headings.get(l)).copied();
let n = seq.entry(id.clone()).or_insert((0, 0));
if let Some(epoch) = restart
&& n.1 != epoch
{
*n = (0, epoch);
}
if let Some(r) = f.arg('r').and_then(|r| r.parse::<u32>().ok()) {
n.0 = r;
} else if !f.has('c') {
n.0 = n.0.saturating_add(1);
}
let fmt = wordcraft_layout::fields::format_switch(instr).unwrap_or_default();
updates.push((k, if f.has('h') { String::new() } else { 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<String, (u32, NumFormat)> = Default::default();
let formats: Vec<NumFormat> = s.doc.sections().into_iter().map(|(_, sec)| sec.page_num_format).collect();
let marks = s.doc.bookmarks();
for (name, pos) in marks {
let l = s.layout();
let page_info = l
.caret(&pos)
.and_then(|c| l.pages.get(c.page))
.map(|p| {
let format = formats.get(p.section).copied().unwrap_or_default();
(p.number, format)
})
.unwrap_or((1, NumFormat::Decimal));
pages.entry(name).or_insert(page_info);
}
let stories: Vec<StoryRef> =
if all { std::iter::once(StoryRef::Body).chain(s.doc.parts.keys().map(|k| StoryRef::Part(*k))).collect() } else { vec![caret_story] };
// 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::<Vec<_>>() {
let Some(p) = s.doc.para(story, &path) else { continue };
if !p.objects.iter().any(|o| matches!(o, InlineObject::Field { .. })) {
continue;
}
let mut updates: Vec<(usize, String)> = Vec::new();
for (k, o) in p.objects.iter().enumerate() {
let InlineObject::Field { instr, locked: false, .. } = o else { continue };
// A table formula reads the cells around its own cell again.
if instr.trim_start().starts_with('=') {
if let Some((tp, r, c)) = path.cell()
&& let Some(t) = s.doc.table(StoryRef::Body, &tp)
{
let default_lang = 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 <name>: 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<StoryRef> = std::iter::once(StoryRef::Body).chain(doc.parts.keys().copied().map(StoryRef::Part)).collect();
for story in stories {
for path in doc.para_paths(story) {
let Some(p) = doc.para(story, &path) else { continue };
let mut updates: Vec<(usize, String)> = Vec::new();
for (k, o) in p.objects.iter().enumerate() {
let InlineObject::Field { instr, locked: false, .. } = o else { continue };
let name = wordcraft_layout::fields::field_name(instr);
if !matches!(name.as_str(), "DATE" | "TIME") {
continue;
}
// Word's default picture when the code carries no `\@` switch.
let pic = date_picture(instr).unwrap_or_else(|| if name == "TIME" { "h:mm am/pm".into() } else { "M/d/yyyy".into() });
updates.push((k, 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();
}
}
}