Show fresh SEQ, STYLEREF and REF results in PDF export and printing (#5162)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
pakiat
2026-11-04 16:01:58 +01:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 958867b7e8
commit 2df2bfc30e
10 changed files with 692 additions and 70 deletions
+3 -1
View File
@@ -16,7 +16,9 @@ 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 use fields::{
CHAPTER_SEPARATORS, REFERENCE_NOT_FOUND, output_fields, 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};
+198 -44
View File
@@ -1,5 +1,6 @@
use super::properties::{
bookmark_text, count_text, date_picture, doc_property, doc_property_arg, new_property_value, set_summary_property, summary_property, text_result,
bookmark_ends_in_its_paragraph, bookmark_text, bookmark_text_at, 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::{
@@ -76,66 +77,219 @@ pub(super) fn caption(s: &mut Session, v: &Value) -> CmdResult {
/// 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 };
// Numbers first: a STYLEREF to a heading that holds a SEQ field shows its new number.
let numbers = seq_results(&s.doc);
apply_results(&mut s.doc, numbers)?;
let refs = styleref_results(&s.doc);
apply_results(&mut s.doc, refs)
}
/// A field's fresh result: its story and paragraph, its object index there and the text.
type FieldResult = (StoryRef, Path, usize, String);
/// The SEQ numbering so far while the document is walked in order.
#[derive(Default)]
struct SeqCount {
/// How many headings of each level there have been (for `\s`).
headings: [u32; 9],
/// Per sequence: its number, and the heading count it was last restarted at.
seq: std::collections::HashMap<String, (u32, u32)>,
/// Text boxes already counted: a story shown twice, or inside itself, counts once.
boxes: std::collections::HashSet<u32>,
out: Vec<FieldResult>,
}
/// The fresh results of the unlocked SEQ fields in the body and in the text boxes anchored there
/// ([`update_seq`]). A text box's fields count where the box is anchored, before the fields after
/// the anchor in its paragraph and after those before it (measured in Word for Windows).
fn seq_results(doc: &wordcraft_doc::Document) -> Vec<FieldResult> {
let mut count = SeqCount::default();
for path in doc.para_paths(StoryRef::Body) {
let Some(p) = 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)
&& let Some(n) = count.headings.get_mut(k)
{
*count = count.saturating_add(1);
*n = n.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)))));
count_seq(doc, StoryRef::Body, &path, p, &mut count);
}
count.out
}
/// Number the SEQ fields of paragraph `p` (at `path` in `story`) and of the text boxes anchored in it.
fn count_seq(doc: &wordcraft_doc::Document, story: StoryRef, path: &Path, p: &Paragraph, count: &mut SeqCount) {
for (k, o) in p.objects.iter().enumerate() {
for id in o.text_boxes() {
if count.boxes.insert(id) {
let part = StoryRef::Part(id);
for bp in doc.para_paths(part) {
if let Some(boxed) = doc.para(part, &bp) {
count_seq(doc, part, &bp, boxed, count);
}
}
_ => {}
}
}
if updates.is_empty() {
let InlineObject::Field { instr, locked: false, .. } = o else { continue };
let f = FieldCode::parse(instr);
if f.name != "SEQ" {
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;
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| count.headings.get(l)).copied();
let n = count.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();
let r = if f.has('h') { String::new() } else { wordcraft_layout::fields::field_number(instr, fmt, n.0) };
count.out.push((story, path.clone(), k, r));
}
}
/// The fresh results of the body's unlocked STYLEREF fields ([`update_seq`]), from the text
/// `doc` holds: the SEQ fields in the headings they find should already show their numbers.
fn styleref_results(doc: &wordcraft_doc::Document) -> Vec<FieldResult> {
let mut out = Vec::new();
// Built on the first STYLEREF.
let mut styles: Option<wordcraft_layout::styleref::StyleIndex> = None;
for path in doc.para_paths(StoryRef::Body) {
let Some(p) = doc.para(StoryRef::Body, &path) else { continue };
for (k, o) in p.objects.iter().enumerate() {
let InlineObject::Field { instr, locked: false, .. } = o else { continue };
if FieldCode::parse(instr).name != "STYLEREF" {
continue;
}
let ix = styles.get_or_insert_with(|| wordcraft_layout::styleref::StyleIndex::build(doc));
if let Some(i) = ix.index_of(&path) {
out.push((StoryRef::Body, path.clone(), k, text_result(instr, ix.eval(instr, wordcraft_layout::styleref::At::Body(i)))));
}
}
}
out
}
/// What Word prints for a `REF` to a bookmark the document doesn't have.
pub const REFERENCE_NOT_FOUND: &str = "Error! Reference source not found.";
/// The fresh results of the body's unlocked REF fields as output shows them: the bookmark's
/// text, or Word's error when the document lacks the bookmark (measured in Word for Windows);
/// `\p` ("above" or "below") and the paragraph number switches `\r`, `\n` and `\w` keep the
/// stored result, and so does a REF to a bookmark that ends in a later paragraph: Word shows such
/// a bookmark's paragraphs as paragraphs of the result, which a field's one-paragraph result
/// can't hold (measured in Word for Windows). A bookmark in a header, footer, footnote, endnote,
/// comment or text box is found too, and a REF to it shows that text as `stored` holds it, its
/// own fields not yet refreshed, as Word's output does (measured in Word for Windows). The
/// document's bookmarks are looked up once, and each bookmark's text read once, however many
/// fields refer to it.
fn ref_results(doc: &wordcraft_doc::Document, stored: &wordcraft_doc::Document) -> Vec<FieldResult> {
let mut out = Vec::new();
let mut starts: std::collections::HashMap<String, Pos> = std::collections::HashMap::new();
for (name, start) in doc.bookmarks().into_iter().chain(part_bookmarks(doc)) {
// The first of two bookmarks with one name, as `bookmark_text` takes.
starts.entry(name).or_insert(start);
}
// A bookmark's text, `None` when it ends in a later paragraph.
let mut texts: std::collections::HashMap<String, Option<String>> = std::collections::HashMap::new();
for path in doc.para_paths(StoryRef::Body) {
let Some(p) = doc.para(StoryRef::Body, &path) else { continue };
for (k, o) in p.objects.iter().enumerate() {
let InlineObject::Field { instr, locked: false, .. } = o else { continue };
// `\r`, `\n` and `\w` show the bookmarked paragraph's number, which isn't computed
// here: those keep their stored result rather than print the paragraph's text.
if wordcraft_layout::fields::field_name(instr) != "REF" || ['p', 'r', 'n', 'w'].iter().any(|&sw| has_switch(instr, sw)) {
continue;
}
let f = FieldCode::parse(instr);
let target = f.args.first().map(|s| s.split('\\').next().unwrap_or("").trim_matches('"')).unwrap_or("");
let Some(start) = starts.get(target) else {
out.push((StoryRef::Body, path.clone(), k, REFERENCE_NOT_FOUND.to_string()));
continue;
};
let text = texts.entry(target.to_string()).or_insert_with(|| {
let source = if start.story == StoryRef::Body { doc } else { stored };
bookmark_ends_in_its_paragraph(source, target, start).then(|| bookmark_text_at(source, target, start)).flatten()
});
if let Some(r) = text.clone() {
out.push((StoryRef::Body, path.clone(), k, text_result(instr, r)));
}
}
}
out
}
/// The bookmarks outside the body (headers, footers, notes, comments and text boxes), in story
/// order.
fn part_bookmarks(doc: &wordcraft_doc::Document) -> Vec<(String, Pos)> {
let mut out = Vec::new();
for id in doc.parts.keys() {
let story = StoryRef::Part(*id);
for path in doc.para_paths(story) {
let Some(p) = doc.para(story, &path) else { continue };
for (off, o) in p.objects_with_offsets() {
if let InlineObject::BookmarkStart { name } = o {
out.push((name.clone(), Pos { story, path: path.clone(), off }));
}
}
}
}
out
}
/// Put `results` into their fields, clearing `w:dirty` (a computed result is no longer stale:
/// Word's F9 clears it too).
fn apply_results(doc: &mut wordcraft_doc::Document, results: Vec<FieldResult>) -> Result<(), CmdError> {
for (story, path, k, r) in results {
let para = doc.para_mut(story, &path)?;
if let Some(InlineObject::Field { result, dirty, .. }) = para.objects.get_mut(k) {
*result = r;
*dirty = false;
}
para.touch();
}
Ok(())
}
/// `results` less those its fields already show.
fn changed(doc: &wordcraft_doc::Document, mut results: Vec<FieldResult>) -> Vec<FieldResult> {
results.retain(|(story, path, k, r)| {
!matches!(doc.para(*story, path).and_then(|p| p.objects.get(*k)), Some(InlineObject::Field { result, .. }) if result == r)
});
results
}
/// The document as Word prints and exports it (PDF, print jobs, page images). Laying a document
/// out for output, Word fills in the body's unlocked SEQ, STYLEREF and REF fields, even with
/// "Update fields before printing" off, while NUMWORDS, FILENAME, the summary properties and
/// formulas keep their stored results (measured with Word for Windows, printing and exporting
/// fields whose stored results were wrong). The document itself keeps its stored results.
/// `None` when every such field already shows its fresh result, so nothing is copied then.
pub fn output_fields(doc: &wordcraft_doc::Document) -> Option<wordcraft_doc::Document> {
// Numbers first, then the fields that read them: a STYLEREF to a heading holding a SEQ field,
// and a REF to a bookmarked caption, show the new number.
let seq = changed(doc, seq_results(doc));
let mut out = (!seq.is_empty()).then(|| doc.clone());
if let Some(d) = out.as_mut() {
apply_results(d, seq).ok()?;
}
let styles = changed(out.as_ref().unwrap_or(doc), styleref_results(out.as_ref().unwrap_or(doc)));
if !styles.is_empty() {
apply_results(out.get_or_insert_with(|| doc.clone()), styles).ok()?;
}
let refs = changed(out.as_ref().unwrap_or(doc), ref_results(out.as_ref().unwrap_or(doc), doc));
if !refs.is_empty() {
apply_results(out.get_or_insert_with(|| doc.clone()), refs).ok()?;
}
out
}
/// 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| {
+16 -1
View File
@@ -92,11 +92,26 @@ pub fn date_picture(instr: &str) -> Option<String> {
/// the paragraph's end. `None` when there is no such bookmark.
pub fn bookmark_text(doc: &wordcraft_doc::Document, name: &str) -> Option<String> {
let (_, start) = doc.bookmarks().into_iter().find(|(n, _)| n == name)?;
bookmark_text_at(doc, name, &start)
}
/// Does the bookmark `name` that starts at `start` end in the paragraph it starts in?
pub(super) fn bookmark_ends_in_its_paragraph(doc: &wordcraft_doc::Document, name: &str, start: &Pos) -> bool {
doc.para(start.story, &start.path).is_some_and(|para| {
para.object_offsets()
.into_iter()
.any(|o| o > start.off && matches!(para.object_at(o), Some(InlineObject::BookmarkEnd { name: n }) if n == name))
})
}
/// [`bookmark_text`] for the bookmark `name` that starts at `start`, for a caller that has
/// looked the document's bookmarks up once.
pub(super) fn bookmark_text_at(doc: &wordcraft_doc::Document, name: &str, start: &Pos) -> Option<String> {
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())
Some(doc.copy_range(start, &Pos { off: end, ..start.clone() }).plain_text().trim().to_string())
}
+350
View File
@@ -511,3 +511,353 @@ fn caption_for_an_object_in_a_cell_stays_in_the_cell() {
let texts: Vec<String> = s.doc.body.iter().filter_map(|b| b.as_para()).map(|p| p.plain_text()).collect();
assert_eq!(texts.get(1).map(String::as_str), Some("Figure 1"), "{texts:?}");
}
/// Output (PDF, print, page images) shows the body's unlocked SEQ, STYLEREF and REF fields with
/// their fresh results, as Word fills them in when it prints or exports even with "Update fields
/// before printing" off; NUMWORDS and a locked field keep their stored results there, and the
/// editor and the document keep every stored result.
#[test]
fn output_shows_fresh_seq_styleref_and_ref_results() {
let mut s = Session::new(wordcraft_doc::Document::from_text("Refs\nChapter One\nTarget words"));
s.run("select.text", &json!({"text": "Chapter One"})).unwrap();
s.run("styles.apply", &json!({"style": "Heading 1"})).unwrap();
s.run("select.text", &json!({"text": "Target words"})).unwrap();
s.run("insert.bookmark", &json!({"name": "Mark"})).unwrap();
s.run("caret.docEnd", &json!({})).unwrap();
for (instr, result) in [
(r#"SEQ Figure \* ARABIC"#, "7"),
(r#"STYLEREF "Heading 1""#, "stale style"),
("REF Mark \\h", "stale text"),
("NUMWORDS", "999"),
(r#"SEQ Table \* ARABIC"#, "9"),
] {
s.run("insert.field", &json!({"instr": instr, "result": result})).unwrap();
s.run("text.insert", &json!({"text": " "})).unwrap();
}
// Stale stored results (inserting a field computes its result), the last field locked.
let stored = ["7", "stale style", "stale text", "999", "9"];
let last = s.doc.para_paths(StoryRef::Body).pop().unwrap();
let para = s.doc.para_mut(StoryRef::Body, &last).unwrap();
let mut stale = stored.iter();
for o in &mut para.objects {
if let InlineObject::Field { result, locked, .. } = o {
*result = stale.next().unwrap().to_string();
*locked = stale.len() == 0;
}
}
para.touch();
let shown = |l: &wordcraft_layout::DocLayout| -> Vec<String> {
l.pages
.iter()
.flat_map(|pg| &pg.items)
.flat_map(|it| match it {
wordcraft_layout::Placed::Lines { para, .. } => para.shown.iter().map(|s| s.1.clone()).collect(),
_ => vec![],
})
.collect()
};
assert_eq!(shown(&s.export_layout()), ["1", "Chapter One", "Target words", "999", "9"]);
assert_eq!(shown(&s.layout()), stored, "the editor shows the stored results");
assert_eq!(fields(&s).iter().map(|f| f.1.as_str()).collect::<Vec<_>>(), stored, "the document keeps them");
let pdf = s.print_pdf(&json!({})).unwrap().0;
assert!(pdf.len() > 1000);
// Once the fields are current, output copies nothing.
update_seq(&mut s).unwrap();
update_fields(&mut s).unwrap();
assert!(output_fields(&s.doc).is_none());
}
#[test]
fn output_numbers_text_box_captions_where_their_boxes_are_anchored() {
// Measured in Word for Windows, every SEQ result stored as a stale 9: output numbers a text
// box's SEQ field where the box is anchored (box B before C in its paragraph, box F after E),
// and a REF to a bookmark in a box shows the box's text as stored.
let ns = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:v="urn:schemas-microsoft-com:vml""#;
let run = |t: &str| format!(r#"<w:r><w:t xml:space="preserve">{t}</w:t></w:r>"#);
let field = |instr: &str, stored: &str| {
format!(
r#"<w:r><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:instrText xml:space="preserve"> {instr} </w:instrText></w:r><w:r><w:fldChar w:fldCharType="separate"/></w:r>{}<w:r><w:fldChar w:fldCharType="end"/></w:r>"#,
run(stored)
)
};
let seq = |label: &str| format!("{}{}", run(&format!("{label} Fig ")), field("SEQ Fig \\* ARABIC", "9"));
let text_box = |inner: String| {
format!(
r##"<w:r><w:pict><v:shape type="#_x0000_t202" style="position:absolute;margin-left:300pt;width:150pt;height:30pt"><v:textbox><w:txbxContent><w:p>{inner}</w:p></w:txbxContent></v:textbox></v:shape></w:pict></w:r>"##
)
};
let marked = format!(r#"<w:bookmarkStart w:id="1" w:name="boxmark"/>{}<w:bookmarkEnd w:id="1"/>"#, seq("B"));
let paras = [
seq("A"),
format!("{}{}{}", run("anchor first: "), text_box(marked), seq("C")),
seq("D"),
format!("{}{}", seq("E"), text_box(seq("F"))),
seq("G"),
format!("{}{}", run("ref: "), field("REF boxmark \\h", "stored")),
];
let body: String = paras.iter().map(|p| format!("<w:p>{p}</w:p>")).collect();
let document = format!("<w:document {ns}><w:body>{body}<w:sectPr/></w:body></w:document>");
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 mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
for (name, text) in [("_rels/.rels", root), ("word/document.xml", document.as_str())] {
zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
std::io::Write::write_all(&mut zw, text.as_bytes()).unwrap();
}
let s = Session::new(crate::io::open_bytes("boxes.docx", &zw.finish().unwrap().into_inner()).unwrap());
let out = crate::cmd::references::output_fields(&s.doc).expect("fresh numbers");
// The SEQ and REF results in reading order, text boxes where they are anchored.
let mut results = Vec::new();
out.objects_in_reading_order(&mut |o| {
if let InlineObject::Field { instr, result, .. } = o {
results.push((instr.split_whitespace().next().unwrap_or("").to_string(), result.clone()));
}
});
let seqs: Vec<&str> = results.iter().filter(|(n, _)| n == "SEQ").map(|(_, r)| r.as_str()).collect();
assert_eq!(seqs, ["1", "2", "3", "4", "5", "6", "7"], "{results:?}");
let refs: Vec<&str> = results.iter().filter(|(n, _)| n == "REF").map(|(_, r)| r.as_str()).collect();
assert_eq!(refs, ["B Fig 9"], "{results:?}");
}
/// Printed or exported, a body REF to a bookmark in a header, footer, footnote, endnote or
/// comment shows the bookmarked text, as Word's output does (measured in Word for Windows, each
/// REF stored as "stale"), rather than Word's missing-bookmark error.
#[test]
fn output_shows_references_to_bookmarks_outside_the_body() {
let ns = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships""#;
let mark = |name: &str, id: u32| {
format!(r#"<w:bookmarkStart w:id="{id}" w:name="{name}"/><w:r><w:t>Target {name}</w:t></w:r><w:bookmarkEnd w:id="{id}"/>"#)
};
let field = |name: &str| {
format!(
r#"<w:p><w:r><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:instrText xml:space="preserve"> REF {name} \h </w:instrText></w:r><w:r><w:fldChar w:fldCharType="separate"/></w:r><w:r><w:t>stale</w:t></w:r><w:r><w:fldChar w:fldCharType="end"/></w:r></w:p>"#
)
};
let names = ["HDR", "FTR", "FN", "EN", "CMT"];
let refs: String = names.iter().map(|n| field(n)).collect();
let document = format!(
r#"<w:document {ns}><w:body>{refs}<w:p><w:r><w:t>Body </w:t></w:r><w:r><w:footnoteReference w:id="1"/></w:r><w:r><w:endnoteReference w:id="1"/></w:r><w:commentRangeStart w:id="0"/><w:r><w:t>commented</w:t></w:r><w:commentRangeEnd w:id="0"/><w:r><w:commentReference w:id="0"/></w:r></w:p><w:sectPr><w:headerReference w:type="default" r:id="rIdH"/><w:footerReference w:type="default" r:id="rIdF"/></w:sectPr></w:body></w:document>"#
);
let header = format!(r#"<w:hdr {ns}><w:p>{}</w:p></w:hdr>"#, mark("HDR", 1));
let footer = format!(r#"<w:ftr {ns}><w:p>{}</w:p></w:ftr>"#, mark("FTR", 2));
let footnotes =
format!(r#"<w:footnotes {ns}><w:footnote w:id="1"><w:p><w:r><w:footnoteRef/></w:r>{}</w:p></w:footnote></w:footnotes>"#, mark("FN", 3));
let endnotes = format!(r#"<w:endnotes {ns}><w:endnote w:id="1"><w:p><w:r><w:endnoteRef/></w:r>{}</w:p></w:endnote></w:endnotes>"#, mark("EN", 4));
let comments = format!(r#"<w:comments {ns}><w:comment w:id="0" w:author="probe"><w:p>{}</w:p></w:comment></w:comments>"#, mark("CMT", 5));
let rel = "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
let rels = format!(
r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rIdH" Type="{rel}/header" Target="header1.xml"/><Relationship Id="rIdF" Type="{rel}/footer" Target="footer1.xml"/><Relationship Id="rIdN" Type="{rel}/footnotes" Target="footnotes.xml"/><Relationship Id="rIdE" Type="{rel}/endnotes" Target="endnotes.xml"/><Relationship Id="rIdC" Type="{rel}/comments" Target="comments.xml"/></Relationships>"#
);
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 files = [
("_rels/.rels", root.to_string()),
("word/_rels/document.xml.rels", rels),
("word/document.xml", document),
("word/header1.xml", header),
("word/footer1.xml", footer),
("word/footnotes.xml", footnotes),
("word/endnotes.xml", endnotes),
("word/comments.xml", comments),
];
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
for (name, text) in &files {
zw.start_file(*name, zip::write::SimpleFileOptions::default()).unwrap();
std::io::Write::write_all(&mut zw, text.as_bytes()).unwrap();
}
let s = Session::new(crate::io::open_bytes("stories.docx", &zw.finish().unwrap().into_inner()).unwrap());
let out = crate::cmd::references::output_fields(&s.doc).expect("fresh references");
let mut results = Vec::new();
for path in out.para_paths(StoryRef::Body) {
for o in &out.para(StoryRef::Body, &path).unwrap().objects {
if let InlineObject::Field { result, .. } = o {
results.push(result.clone());
}
}
}
let expected: Vec<String> = names.iter().map(|n| format!("Target {n}")).collect();
assert_eq!(results, expected);
}
/// Printed or exported, a REF to a bookmark the document doesn't have shows Word's error
/// (measured in Word for Windows: "Error! Reference source not found."); the editor and the
/// document keep the stored result, and a REF showing "above" or "below" (`\p`) keeps it too.
#[test]
fn output_shows_words_error_for_a_reference_to_a_missing_bookmark() {
let mut s = Session::new(wordcraft_doc::Document::from_text("See "));
s.run("caret.docEnd", &json!({})).unwrap();
for instr in ["REF NoSuchMark \\h", "REF NoSuchMark \\p"] {
s.run("insert.field", &json!({"instr": instr, "result": "x"})).unwrap();
s.run("text.insert", &json!({"text": " "})).unwrap();
}
let para = s.doc.para_mut(StoryRef::Body, &wordcraft_doc::Path::top(0)).unwrap();
for o in &mut para.objects {
if let InlineObject::Field { result, .. } = o {
*result = "stale".into();
}
}
para.touch();
let shown = |l: &wordcraft_layout::DocLayout| -> Vec<String> {
l.pages[0]
.items
.iter()
.flat_map(|it| match it {
wordcraft_layout::Placed::Lines { para, .. } => para.shown.iter().map(|s| s.1.clone()).collect(),
_ => vec![],
})
.collect()
};
assert_eq!(shown(&s.export_layout()), [REFERENCE_NOT_FOUND, "stale"]);
assert_eq!(shown(&s.layout()), ["stale", "stale"], "the editor shows the stored result");
assert_eq!(fields(&s).iter().map(|f| f.1.as_str()).collect::<Vec<_>>(), ["stale", "stale"], "the document keeps it");
}
/// A REF showing the bookmarked paragraph's number (`\r`, `\n`, `\w`), which output doesn't
/// compute, keeps its stored number when printed or exported rather than show the paragraph's
/// text; a plain REF to the same bookmark is filled in.
#[test]
fn output_keeps_paragraph_number_references_as_stored() {
let mut s = Session::new(wordcraft_doc::Document::from_text("Target heading\nRefs "));
s.run("select.text", &json!({"text": "Target heading"})).unwrap();
s.run("insert.bookmark", &json!({"name": "Mark"})).unwrap();
s.run("caret.docEnd", &json!({})).unwrap();
let instrs = ["REF Mark \\r \\h", "REF Mark \\n", "REF Mark \\w \\h", "REF Mark \\h"];
for instr in instrs {
s.run("insert.field", &json!({"instr": instr, "result": "x"})).unwrap();
s.run("text.insert", &json!({"text": " "})).unwrap();
}
let last = s.doc.para_paths(StoryRef::Body).pop().unwrap();
let para = s.doc.para_mut(StoryRef::Body, &last).unwrap();
for o in &mut para.objects {
if let InlineObject::Field { result, .. } = o {
*result = "2.1".into();
}
}
para.touch();
let out = output_fields(&s.doc).expect("the plain REF changes");
let results: Vec<String> = out
.para(StoryRef::Body, &last)
.unwrap()
.objects
.iter()
.filter_map(|o| match o {
InlineObject::Field { result, .. } => Some(result.clone()),
_ => None,
})
.collect();
assert_eq!(results, ["2.1", "2.1", "2.1", "Target heading"]);
}
/// The results of the fields in the paragraph at `path` of `d`.
fn results_in(d: &wordcraft_doc::Document, path: &wordcraft_doc::Path) -> Vec<String> {
d.para(StoryRef::Body, path)
.unwrap()
.objects
.iter()
.filter_map(|o| match o {
InlineObject::Field { result, .. } => Some(result.clone()),
_ => None,
})
.collect()
}
/// Set every field result in the last body paragraph of `s` to `stale`; that paragraph's path.
fn stale_last_paragraph(s: &mut Session, stale: &str) -> wordcraft_doc::Path {
let last = s.doc.para_paths(StoryRef::Body).pop().unwrap();
let para = s.doc.para_mut(StoryRef::Body, &last).unwrap();
for o in &mut para.objects {
if let InlineObject::Field { result, .. } = o {
*result = stale.into();
}
}
para.touch();
last
}
/// Word shows a REF to a bookmark that ends in a later paragraph as paragraphs of the result
/// (measured in Word for Windows: "one" and "Beta" on two lines for a bookmark from "one" to
/// "Beta"), which a field's one-paragraph result can't hold: output keeps the stored result
/// rather than show the first paragraph's part alone.
#[test]
fn output_keeps_a_reference_to_a_bookmark_over_paragraphs_as_stored() {
let mut s = Session::new(wordcraft_doc::Document::from_text("Alpha one\nBeta two\nShort mark\nRefs "));
let at = |block: usize, off: usize| wordcraft_doc::Pos { story: StoryRef::Body, path: wordcraft_doc::Path::top(block), off };
s.sel = crate::Selection { anchor: at(0, 6), focus: at(1, 4) };
s.run("insert.bookmark", &json!({"name": "Span"})).unwrap();
s.run("select.text", &json!({"text": "Short mark"})).unwrap();
s.run("insert.bookmark", &json!({"name": "Short"})).unwrap();
s.run("caret.docEnd", &json!({})).unwrap();
for instr in ["REF Span \\h", "REF Short \\h"] {
s.run("insert.field", &json!({"instr": instr, "result": "x"})).unwrap();
s.run("text.insert", &json!({"text": " "})).unwrap();
}
let last = stale_last_paragraph(&mut s, "stored");
let out = output_fields(&s.doc).expect("the short bookmark's REF changes");
assert_eq!(results_in(&out, &last), ["stored", "Short mark"]);
}
/// A STYLEREF to a heading holding a SEQ field shows the heading with the SEQ field's new number,
/// in output and after F9: the numbers are filled in before the style references read them.
#[test]
fn a_style_reference_reads_the_headings_new_sequence_number() {
let mut s = Session::new(wordcraft_doc::Document::from_text("Chapter \nBody\nRefs "));
s.run("select.text", &json!({"text": "Chapter "})).unwrap();
s.run("styles.apply", &json!({"style": "Heading 1"})).unwrap();
s.sel = crate::Selection::caret(wordcraft_doc::Pos { story: StoryRef::Body, path: wordcraft_doc::Path::top(0), off: 8 });
s.run("insert.field", &json!({"instr": "SEQ Chapter \\* ARABIC", "result": "x"})).unwrap();
s.run("caret.docEnd", &json!({})).unwrap();
s.run("insert.field", &json!({"instr": "STYLEREF \"Heading 1\"", "result": "x"})).unwrap();
// Stale stored results: the heading's number 9, the reference "stale".
let heading = wordcraft_doc::Path::top(0);
let para = s.doc.para_mut(StoryRef::Body, &heading).unwrap();
for o in &mut para.objects {
if let InlineObject::Field { result, .. } = o {
*result = "9".into();
}
}
para.touch();
let last = stale_last_paragraph(&mut s, "stale");
let out = output_fields(&s.doc).expect("the fields change");
assert_eq!(results_in(&out, &heading), ["1"]);
assert_eq!(results_in(&out, &last), ["Chapter 1"]);
update_seq(&mut s).unwrap();
assert_eq!(results_in(&s.doc, &last), ["Chapter 1"], "F9's renumbering too");
}
/// Output looks the bookmarks up once for all of a document's REF fields: several fields to one
/// bookmark, fields to others, and one to a bookmark the document lacks each show what
/// `bookmark_text` (F9's lookup) gives, or Word's error.
#[test]
fn output_gives_every_reference_its_bookmarks_text() {
let mut s = Session::new(wordcraft_doc::Document::from_text("First target\nSecond target\nRefs "));
for (text, name) in [("First target", "A"), ("Second target", "B")] {
s.run("select.text", &json!({"text": text})).unwrap();
s.run("insert.bookmark", &json!({"name": name})).unwrap();
}
s.run("caret.docEnd", &json!({})).unwrap();
let targets = ["A", "B", "A", "Missing", "A"];
for t in targets {
s.run("insert.field", &json!({"instr": format!("REF {t} \\h"), "result": "x"})).unwrap();
s.run("text.insert", &json!({"text": " "})).unwrap();
}
let last = s.doc.para_paths(StoryRef::Body).pop().unwrap();
let para = s.doc.para_mut(StoryRef::Body, &last).unwrap();
for o in &mut para.objects {
if let InlineObject::Field { result, .. } = o {
*result = "stale".into();
}
}
para.touch();
let out = output_fields(&s.doc).expect("the stale results change");
let results: Vec<String> = out
.para(StoryRef::Body, &last)
.unwrap()
.objects
.iter()
.filter_map(|o| match o {
InlineObject::Field { result, .. } => Some(result.clone()),
_ => None,
})
.collect();
let expected: Vec<String> = targets.iter().map(|t| super::bookmark_text(&s.doc, t).unwrap_or_else(|| REFERENCE_NOT_FOUND.to_string())).collect();
assert_eq!(results, expected);
assert_eq!(results, ["First target", "Second target", "First target", REFERENCE_NOT_FOUND, "First target"]);
}
+5
View File
@@ -31,6 +31,9 @@ pub fn save(ext: &str, doc: &Document, opts: &crate::io::ExportOptions) -> Optio
"xml" => Some(wordcraft_docx::write_flat(doc).map_err(|e| e.to_string())),
"png" => Some(render_png(doc, 0, 2.0, opts)),
"pdf" => {
// Output shows the fields Word fills in as it prints (see `output_fields`).
let out = crate::cmd::references::output_fields(doc);
let doc = out.as_ref().unwrap_or(doc);
let pdf = wordcraft_pdf::PdfOptions {
include_markup: opts.markup,
comments: opts.markup && opts.comments,
@@ -46,6 +49,8 @@ pub fn save(ext: &str, doc: &Document, opts: &crate::io::ExportOptions) -> Optio
/// Render page `page` as PNG at `scale` px/pt. With Print Markup's comments the page has its
/// markup area with the comments in balloons (page images always show tracked changes).
pub fn render_png(doc: &Document, page: usize, scale: f32, opts: &crate::io::ExportOptions) -> Result<Vec<u8>, String> {
let out = crate::cmd::references::output_fields(doc);
let doc = out.as_ref().unwrap_or(doc);
let lo = wordcraft_layout::LayoutOptions { comment_balloons: opts.markup && opts.comments, numeral: opts.numeral, ..Default::default() };
let l = wordcraft_layout::layout(doc, &mut wordcraft_layout::LayoutCache::new(), &lo);
let p = l.pages.get(page).ok_or_else(|| format!("no page {}", page + 1))?;
+4 -2
View File
@@ -219,7 +219,9 @@ impl Session {
}
// Print Markup (File › Print) decides whether tracked changes and comments print.
let markup = crate::cmd::file::export_options(self, v);
let lay = self.export_layout_with(markup.markup);
let out = crate::cmd::references::output_fields(&self.doc);
let doc = out.as_ref().unwrap_or(&self.doc);
let lay = self.export_layout_of(doc, markup.markup);
let plan = plan(v, lay.pages.len(), self.current_pages(&lay))?;
let mut single = v.clone();
single["copies"] = json!(1);
@@ -238,7 +240,7 @@ impl Session {
links,
..Default::default()
};
let bytes = wordcraft_pdf::export_layout(&self.doc, &lay, &opts).map_err(|e| CmdError::Failed(e.to_string()))?;
let bytes = wordcraft_pdf::export_layout(doc, &lay, &opts).map_err(|e| CmdError::Failed(e.to_string()))?;
Ok((bytes, plan))
}
}
+9 -2
View File
@@ -161,8 +161,15 @@ impl Session {
}
/// [`Session::export_layout`]; with `markup` (Print Markup) each page also has the markup
/// area with the comments in balloons.
/// area with the comments in balloons. Fields Word fills in for output show their fresh
/// results ([`crate::cmd::references::output_fields`]).
pub fn export_layout_with(&self, markup: bool) -> Arc<DocLayout> {
let out = crate::cmd::references::output_fields(&self.doc);
self.export_layout_of(out.as_ref().unwrap_or(&self.doc), markup)
}
/// [`Session::export_layout_with`] of `doc`, this document as it is output.
pub(crate) fn export_layout_of(&self, doc: &Document, markup: bool) -> Arc<DocLayout> {
let (fn_name, fn_path) = self.resolved_filenames();
let opts = LayoutOptions {
view: ViewMode::Print,
@@ -172,7 +179,7 @@ impl Session {
filepath: Some(Arc::from(fn_path.as_str())),
..Default::default()
};
Arc::new(wordcraft_layout::layout(&self.doc, &mut LayoutCache::new(), &opts))
Arc::new(wordcraft_layout::layout(doc, &mut LayoutCache::new(), &opts))
}
/// Invalidate the cached layout (fonts changed etc.).