STYLEREF fields show each page's result in headers and footers (#775) (#882)

* #775: STYLEREF fields in headers and footers show each page's result

Running headers/footers with STYLEREF (e.g. the current chapter title) are
evaluated per page at layout time (ECMA-376 §17.16.5.59): the page's first
paragraph of the style (\l: last), else the nearest before, else after.
Style names/ids, heading levels (STYLEREF 1), character styles, and the
\n \r \w \s \t \p switches. Body STYLEREF fields update with the other
fields. STYLEREF added to Insert › Quick Parts › Field with its options.

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

* #775: STYLEREF error text in our own words

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

* Review fixes for #882: translate the STYLEREF Field dialog strings

Adds the style picker label, the six STYLEREF switch labels and the
STYLEREF description to all 18 interface catalogs.

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

* STYLEREF: reuse the style index while the body and styles are unchanged

Headers with STYLEREF rebuilt and hashed the body's style index on every
layout pass (echelon's follow-up note on #882). The layout cache now keeps
the index with the body blocks it came from (shared, so an edited block is
a different pointer) and the styles/numbering hash, and rebuilds it only
when either changed.

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

* vi: STYLEREF field strings

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>
This commit is contained in:
Dmitrii Khlapov
2026-10-14 16:56:47 -04:00
committed by GitHub
co-authored by Claude Opus 5.5 Dmitrii Khlapov Brandon Thomas
parent 8e0f42e4bc
commit cf9a4ff7fa
29 changed files with 931 additions and 37 deletions
+98 -34
View File
@@ -712,43 +712,23 @@ fn caption(s: &mut Session, v: &Value) -> CmdResult {
sel_result(s)
}
/// The number part of a heading's list 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()
}
/// 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 chapter-number `STYLEREF n \s` fields with the last level-n heading's number (its list
/// label, or its count when the heading isn't numbered).
/// 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 counters = wordcraft_doc::numbering::Counters::default();
let mut headings = [0u32; 9];
let mut chapter: [String; 9] = Default::default();
let mut heading_text: [String; 9] = Default::default();
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 };
let label = 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))
.map(|(l, _)| l);
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 n = label.map(|l| chapter_number(&l)).filter(|n| !n.is_empty()).unwrap_or_else(|| count.to_string());
if let Some(c) = chapter.get_mut(k) {
*c = n;
}
if let Some(t) = heading_text.get_mut(k) {
*t = p.plain_text().trim().to_string();
}
}
let mut updates: Vec<(usize, String)> = Vec::new();
for (k, o) in p.objects.iter().enumerate() {
@@ -774,15 +754,9 @@ pub fn update_seq(s: &mut Session) -> Result<(), CmdError> {
updates.push((k, if f.has('h') { String::new() } else { fmt.format(n.0) }));
}
"STYLEREF" => {
let level = f
.args
.first()
.and_then(|a| a.strip_prefix("Heading").unwrap_or(a).trim().parse::<usize>().ok())
.and_then(|l| l.checked_sub(1));
let Some(level) = level else { continue };
let r = if f.has('s') || f.has('n') { chapter.get(level) } else { heading_text.get(level) };
if let Some(r) = r {
updates.push((k, r.clone()));
let ix = styles.get_or_insert_with(|| wordcraft_layout::styleref::StyleIndex::build(&s.doc));
if let Some(i) = ix.index_of(&path) {
updates.push((k, ix.eval(instr, wordcraft_layout::styleref::At::Body(i))));
}
}
_ => {}
@@ -892,6 +866,96 @@ mod tests {
out
}
/// #775: body STYLEREF fields show the nearest paragraph of the style before them.
#[test]
fn body_styleref_finds_the_heading_before_it() {
let mut s = Session::new(wordcraft_doc::Document::from_text("Intro\nChapter One\ntext here\nChapter Two\nend"));
for t in ["Chapter One", "Chapter Two"] {
s.run("select.text", &json!({"text": t})).unwrap();
s.run("styles.apply", &json!({"style": "Heading 1"})).unwrap();
}
s.run("select.text", &json!({"text": "text here"})).unwrap();
s.run("caret.end", &json!({})).unwrap();
s.run("insert.field", &json!({"instr": r#"STYLEREF "Heading 1" \* MERGEFORMAT"#})).unwrap();
s.run("caret.docEnd", &json!({})).unwrap();
s.run("insert.field", &json!({"instr": "STYLEREF 1 \\p"})).unwrap();
s.run("caret.docStart", &json!({})).unwrap();
s.run("insert.field", &json!({"instr": "STYLEREF \"Heading 1\" \\p"})).unwrap();
s.run("insert.field", &json!({"instr": "STYLEREF \"No Such Style\""})).unwrap();
let results: Vec<String> = fields(&s).into_iter().map(|f| f.1).collect();
assert_eq!(results, ["below", wordcraft_layout::styleref::NO_TEXT, "Chapter One", "above"]);
}
/// #775: a template's running header `STYLEREF "heading 1"` (a complex field, opened from
/// DOCX) shows each page's chapter, and keeps its code through a save.
#[test]
fn docx_styleref_header_shows_each_page_s_chapter() {
let ns = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships""#;
let heading = |t: &str, brk: bool| {
let b = if brk { "<w:pageBreakBefore/>" } else { "" };
format!(
r#"<w:p><w:pPr><w:pStyle w:val="Heading1"/>{b}</w:pPr><w:r><w:t>{t}</w:t></w:r></w:p><w:p><w:r><w:t>Body of {t}.</w:t></w:r></w:p>"#
)
};
let body = format!(
r#"<?xml version="1.0" encoding="UTF-8"?><w:document {ns}><w:body>{}{}{}<w:sectPr><w:headerReference w:type="default" r:id="rIdH"/></w:sectPr></w:body></w:document>"#,
heading("Rivers", false),
heading("Mountains", true),
heading("Deserts", true)
);
let header = format!(
r#"<w:hdr {ns}><w:p><w:r><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:instrText xml:space="preserve"> STYLEREF "heading 1" \* MERGEFORMAT </w:instrText></w:r><w:r><w:fldChar w:fldCharType="separate"/></w:r><w:r><w:t>Cached</w:t></w:r><w:r><w:fldChar w:fldCharType="end"/></w:r></w:p></w:hdr>"#
);
let styles = format!(
r#"<w:styles {ns}><w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/></w:style><w:style w:type="paragraph" w:styleId="Heading1"><w:name w:val="heading 1"/><w:basedOn w:val="Normal"/></w:style></w:styles>"#
);
let root = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
let rels = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rIdH" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/header" Target="header1.xml"/><Relationship Id="rIdS" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>"#;
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
for (name, text) in [
("_rels/.rels", root),
("word/document.xml", body.as_str()),
("word/_rels/document.xml.rels", rels),
("word/header1.xml", header.as_str()),
("word/styles.xml", styles.as_str()),
] {
zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
std::io::Write::write_all(&mut zw, text.as_bytes()).unwrap();
}
let bytes = zw.finish().unwrap().into_inner();
let headers = |s: &mut Session| -> Vec<String> {
s.layout()
.pages
.iter()
.map(|p| {
p.header
.iter()
.filter_map(|it| {
if let wordcraft_layout::Placed::Lines { para, .. } = it {
Some(para.shown.iter().map(|x| x.1.clone()).collect::<String>())
} else {
None
}
})
.collect::<String>()
})
.collect()
};
let mut s = Session::new(crate::io::open_bytes("chapters.docx", &bytes).unwrap());
assert_eq!(headers(&mut s), ["Rivers", "Mountains", "Deserts"]);
let saved = crate::io::save_bytes("chapters.docx", &s.doc).unwrap();
let mut again = Session::new(crate::io::open_bytes("chapters.docx", &saved).unwrap());
assert_eq!(headers(&mut again), ["Rivers", "Mountains", "Deserts"]);
let hid = again.doc.last_section.headers.default.unwrap();
let instr = again.doc.para_paths(StoryRef::Part(hid)).iter().find_map(|p| {
again.doc.para(StoryRef::Part(hid), p).unwrap().objects.iter().find_map(|o| match o {
InlineObject::Field { instr, .. } => Some(instr.clone()),
_ => None,
})
});
assert!(instr.is_some_and(|i| i.contains(r#"STYLEREF "heading 1""#)));
}
/// #402: Custom Table of Contents settings become TOC switches, and updating honours them:
/// levels (`\o`), a style with a level (`\t`), no hyperlinks, page numbers after a space
/// (`\p`), or none at all (`\n`).