mirror of
https://github.com/storytold/wordcraft.git
synced 2026-10-31 13:57:18 -06:00
* #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:
co-authored by
Claude Opus 5.5
Dmitrii Khlapov
Brandon Thomas
parent
8e0f42e4bc
commit
cf9a4ff7fa
@@ -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`).
|
||||
|
||||
Reference in New Issue
Block a user