Files
wordcraft/crates/layout/src/styleref.rs
T
b3af58602b STYLEREF review follow-ups: headers stay cached, switch labels in our own words (#1286) (#1294)
* #1286: key STYLEREF header caches on their per-page results; STYLEREF N finds a localized heading N

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #1286: reword the STYLEREF switch labels in our own words, in every catalog

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #1286: drop a stray table row from the ROADMAP progress log

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* #1286: layout-parity STYLEREF row (carried over the rebase)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* vi: STYLEREF switch labels as reworded

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-14 17:22:36 -04:00

366 lines
15 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! STYLEREF fields (ECMA-376 Part 1 §17.16.5.59): the text, or the paragraph number, of the
//! nearest paragraph (or run) with a given style.
//!
//! In a header or footer the search starts on the page the header is drawn on: its first
//! paragraph of the style (with `\l`, its last), else the nearest one before the page, else the
//! nearest one after. In the body it starts at the field's own paragraph and goes back towards
//! the start of the document, then forward. Headers and footers are evaluated at layout time,
//! page by page ([`crate::FieldCtx::styleref`]); body fields are updated like other fields.
//!
//! Switches: `\l` searches the page bottom-up; `\n` the paragraph number, `\r` and `\w` the
//! number in full context (all levels; `\r` is treated like `\w`), `\s` a chapter number (the
//! number alone, or the heading's count when it isn't numbered); `\t` drops the text around
//! the number; `\p` adds "above" / "below".
use std::collections::HashMap;
use wordcraft_doc::numbering::Counters;
use wordcraft_doc::para::InlineObject;
use wordcraft_doc::section::NumFormat;
use wordcraft_doc::{Document, Path, StoryRef, StyleKind};
/// The result when no paragraph has the style (or the style doesn't exist).
pub const NO_TEXT: &str = "Error: no paragraph in this style.";
/// Which number a STYLEREF shows instead of the text.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum Number {
/// The text (no number switch).
#[default]
Text,
/// `\n`: the paragraph's own number.
Plain,
/// `\r` / `\w`: the number with the levels above it.
Full,
/// `\s`: a chapter number.
Chapter,
}
/// A parsed STYLEREF instruction.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct StyleRefCode {
/// Style name or id, or a heading level `1`–`9`.
pub style: String,
/// `\l`: the last paragraph of the style on the page.
pub last: bool,
pub number: Number,
/// `\p`: "above" / "below".
pub position: bool,
/// `\t`: only the number, without the text of its level.
pub suppress: bool,
}
/// Parse `STYLEREF "Heading 1" \l \n …`. Quoted text stays whole; an unquoted name of several
/// words is joined back up.
pub fn parse(instr: &str) -> StyleRefCode {
let mut words: Vec<(String, bool)> = Vec::new();
let mut chars = instr.chars().peekable();
while let Some(&c) = chars.peek() {
if c.is_whitespace() {
chars.next();
} else if c == '"' {
chars.next();
let mut w = String::new();
while let Some(c) = chars.next() {
match c {
'"' => break,
'\\' if matches!(chars.peek(), Some('"' | '\\')) => w.extend(chars.next()),
c => w.push(c),
}
}
words.push((w, true));
} else {
let mut w = String::new();
while let Some(&c) = chars.peek() {
if c.is_whitespace() || c == '"' {
break;
}
w.push(c);
chars.next();
}
words.push((w, false));
}
}
let mut code = StyleRefCode::default();
let mut it = words.into_iter().skip(1);
while let Some((w, quoted)) = it.next() {
let switch = if quoted { None } else { w.strip_prefix('\\').map(|r| r.chars().next().map(|c| c.to_ascii_lowercase())) };
match switch {
Some(Some('l')) => code.last = true,
Some(Some('n')) => code.number = Number::Plain,
Some(Some('r' | 'w')) => code.number = Number::Full,
Some(Some('s')) => code.number = Number::Chapter,
Some(Some('p')) => code.position = true,
Some(Some('t')) => code.suppress = true,
// General switches (`\* MERGEFORMAT`, `\@ …`, `\# …`) take a word.
Some(Some('*' | '@' | '#')) => {
it.next();
}
Some(_) => {}
None if code.style.is_empty() => code.style = w,
None if !quoted => {
code.style.push(' ');
code.style.push_str(&w);
}
None => {}
}
}
code
}
/// What the search looks for.
#[derive(Clone, Debug, PartialEq, Eq)]
enum Target {
Para(String),
Char(String),
}
/// A body paragraph, as STYLEREF sees it.
#[derive(Clone, Debug)]
struct Entry {
path: Path,
style: String,
text: String,
/// Character-styled runs: (style id, text).
runs: Vec<(String, String)>,
/// The list label as shown (`Chapter 2.`).
label: Option<String>,
/// Each level's value up to the paragraph's, formatted; and whether the label's own
/// template shows that level.
levels: Vec<(String, bool)>,
/// How many paragraphs of its style there are up to this one.
ordinal: u32,
}
/// Where a STYLEREF field is.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum At {
/// In a header or footer of a page whose body starts at entry `first`; entries
/// `first..=last` are on the page (`last` is `None` when no body paragraph is).
Page { first: usize, last: Option<usize> },
/// In body paragraph `i`.
Body(usize),
}
/// The body's paragraphs with their styles, text and numbers, in document order.
#[derive(Clone, Debug, Default)]
pub struct StyleIndex {
entries: Vec<Entry>,
by_path: HashMap<Path, usize>,
/// Lower-cased style id or name → (id, character style).
styles: HashMap<String, (String, bool)>,
}
fn clean(s: &str) -> String {
s.chars().map(|c| if c.is_control() { ' ' } else { c }).collect::<String>().trim().to_string()
}
impl StyleIndex {
pub fn build(doc: &Document) -> StyleIndex {
let mut styles = HashMap::new();
for st in &doc.styles.styles {
let v = (st.id.clone(), st.kind == StyleKind::Character);
styles.entry(st.name.to_lowercase()).or_insert_with(|| v.clone());
styles.insert(st.id.to_lowercase(), v);
}
let mut counters = Counters::default();
let mut ordinals: HashMap<String, u32> = HashMap::new();
let mut entries = Vec::new();
let mut by_path = HashMap::new();
for path in doc.para_paths(StoryRef::Body) {
let Some(p) = doc.para(StoryRef::Body, &path) else { continue };
let style = p.props.style.clone().unwrap_or_else(|| "Normal".to_string());
let ord = ordinals.entry(style.clone()).or_insert(0);
*ord = ord.saturating_add(1);
let ordinal = *ord;
let num = p.props.numbering.or_else(|| doc.styles.resolve_para(&p.props).numbering).filter(|n| n.num != 0);
let (mut label, mut levels) = (None, Vec::new());
if let Some(n) = num
&& let Some((l, def)) = counters.next_label(&doc.numbering, n.num, n.level)
&& def.format != NumFormat::Bullet
{
let vals = counters.values(&doc.numbering, n.num).unwrap_or_default();
for (k, v) in vals.iter().enumerate().take(usize::from(n.level.min(8)) + 1) {
let Some(lv) = doc.numbering.level(n.num, k as u8) else { continue };
if matches!(lv.format, NumFormat::None | NumFormat::Bullet) {
continue;
}
let v = if *v == 0 { lv.start } else { *v };
let fmt = if def.legal && lv.format != NumFormat::DecimalZero { NumFormat::Decimal } else { lv.format };
levels.push((fmt.format(v), def.text.contains(&format!("%{}", k + 1))));
}
label = Some(l);
}
let mut runs: Vec<(String, String)> = Vec::new();
let mut off = 0usize;
for r in &p.runs {
let end = off.saturating_add(r.len);
if let Some(cs) = &r.props.style
&& let Some(t) = p.text.get(off..end)
{
let t: String = t.chars().filter(|c| *c != wordcraft_doc::para::OBJ).collect();
match runs.last_mut() {
Some((s, prev)) if s == cs => prev.push_str(&t),
_ => runs.push((cs.clone(), t)),
}
}
off = end;
}
by_path.insert(path.clone(), entries.len());
entries.push(Entry { path, style, text: clean(&p.plain_text()), runs, label, levels, ordinal });
}
StyleIndex { entries, by_path, styles }
}
/// The entry of the body paragraph at `path`.
pub fn index_of(&self, path: &Path) -> Option<usize> {
self.by_path.get(path).copied()
}
/// Where the header or footer of a page is, from the body paragraphs on the page (by path)
/// and the page's first top-level block.
pub fn page_at<'a>(&self, paths: impl Iterator<Item = &'a Path>, first_block: usize) -> At {
let mut range: Option<(usize, usize)> = None;
for i in paths.filter_map(|p| self.index_of(p)) {
range = Some(range.map_or((i, i), |(a, b)| (a.min(i), b.max(i))));
}
match range {
Some((first, last)) => At::Page { first, last: Some(last) },
None => {
let first = self.entries.iter().position(|e| e.path.0.first().is_some_and(|b| *b as usize >= first_block));
At::Page { first: first.unwrap_or(self.entries.len()), last: None }
}
}
}
fn target(&self, style: &str) -> Option<Target> {
let style = style.trim();
if let Ok(n) = style.parse::<u8>() {
if !(1..=9).contains(&n) {
return None;
}
// The built-in style is named `heading N` whatever its id (localized Word writes
// ids like `berschrift1`); else an id `HeadingN`.
let id = self
.styles
.get(&format!("heading {n}"))
.or_else(|| self.styles.get(&format!("heading{n}")))
.map(|s| s.0.clone())
.unwrap_or_else(|| format!("Heading{n}"));
return Some(Target::Para(id));
}
let (id, chr) = self.styles.get(&style.to_lowercase())?;
Some(if *chr { Target::Char(id.clone()) } else { Target::Para(id.clone()) })
}
fn text_of(&self, i: usize, t: &Target) -> Option<String> {
let e = self.entries.get(i)?;
match t {
Target::Para(id) => (e.style == *id).then(|| e.text.clone()),
Target::Char(id) => e.runs.iter().find(|(s, _)| s == id).map(|(_, t)| clean(t)),
}
}
/// The field's result.
pub fn eval(&self, instr: &str, at: At) -> String {
let code = parse(instr);
let Some(target) = self.target(&code.style) else { return NO_TEXT.to_string() };
let n = self.entries.len();
// Candidates in search order, with whether each one counts as below the field (lazy:
// headers evaluate this for every page on every pass).
let order: Box<dyn Iterator<Item = (usize, bool)>> = match at {
At::Page { first, last } => {
let on = match last {
Some(l) if first <= l => first..l.min(n.saturating_sub(1)).saturating_add(1),
_ => 0..0,
};
let after = last.map_or(first, |l| l.saturating_add(1));
let page: Box<dyn Iterator<Item = usize>> = if code.last { Box::new(on.rev()) } else { Box::new(on) };
Box::new(page.map(|i| (i, true)).chain((0..first.min(n)).rev().map(|i| (i, false))).chain((after..n).map(|i| (i, true))))
}
At::Body(i) => Box::new((0..=i.min(n.saturating_sub(1))).rev().map(|k| (k, false)).chain((i.saturating_add(1)..n).map(|k| (k, true)))),
};
let Some((i, text, below)) = order.into_iter().find_map(|(i, below)| self.text_of(i, &target).map(|t| (i, t, below))) else {
return NO_TEXT.to_string();
};
let Some(e) = self.entries.get(i) else { return NO_TEXT.to_string() };
let mut out = match code.number {
Number::Text if code.position => String::new(),
Number::Text => text,
Number::Chapter => e.label.as_deref().map(chapter_number).filter(|s| !s.is_empty()).unwrap_or_else(|| e.ordinal.to_string()),
Number::Plain | Number::Full => {
let own = match &e.label {
Some(l) if code.suppress => suppress(l, &e.levels),
Some(l) => l.trim().trim_end_matches('.').to_string(),
None => e.ordinal.to_string(),
};
if code.number == Number::Full && e.label.is_some() {
// Levels above that the label's own template doesn't show.
let above: Vec<&str> =
e.levels.iter().take(e.levels.len().saturating_sub(1)).filter(|(_, shown)| !shown).map(|(v, _)| v.as_str()).collect();
if above.is_empty() { own } else { format!("{}.{own}", above.join(".")) }
} else {
own
}
}
};
if code.position {
if !out.is_empty() {
out.push(' ');
}
out.push_str(if below { "below" } else { "above" });
}
out
}
}
/// The number part of a label (`Chapter 2.` → `2`).
fn chapter_number(label: &str) -> String {
let word = label.split_whitespace().last().unwrap_or("");
word.trim_matches(|c: char| !c.is_alphanumeric()).to_string()
}
/// `\t`: the label without its words that hold no number (`Chapter 2.` → `2`).
fn suppress(label: &str, levels: &[(String, bool)]) -> String {
let kept: Vec<&str> = label.split_whitespace().filter(|w| levels.iter().any(|(v, _)| !v.is_empty() && w.contains(v.as_str()))).collect();
let s = if kept.is_empty() { label.trim().to_string() } else { kept.join(" ") };
s.trim_end_matches('.').to_string()
}
/// Does `p` hold a STYLEREF field?
pub fn has_styleref(p: &wordcraft_doc::Paragraph) -> bool {
p.objects.iter().any(is_styleref)
}
fn is_styleref(o: &InlineObject) -> bool {
matches!(o, InlineObject::Field { instr, .. } if crate::fields::field_name(instr) == "STYLEREF")
}
/// Does any header or footer of `doc` hold a STYLEREF field?
pub fn headers_use_styleref(doc: &Document) -> bool {
doc.parts.keys().any(|id| doc.para_paths(StoryRef::Part(*id)).iter().any(|p| doc.para(StoryRef::Part(*id), p).is_some_and(has_styleref)))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_names_levels_and_switches() {
let c = parse(r#"STYLEREF "Heading 1" \l \n \t \* MERGEFORMAT"#);
assert_eq!(c.style, "Heading 1");
assert!(c.last && c.suppress && !c.position);
assert_eq!(c.number, Number::Plain);
assert_eq!(parse("STYLEREF 1 \\s").style, "1");
assert_eq!(parse("STYLEREF 1 \\s").number, Number::Chapter);
assert_eq!(parse("STYLEREF Heading 2 \\p").style, "Heading 2");
assert_eq!(parse("STYLEREF").style, "");
}
#[test]
fn suppress_keeps_the_number() {
assert_eq!(suppress("Chapter 2.", &[("2".into(), true)]), "2");
assert_eq!(chapter_number("Chapter IV."), "IV");
}
}