fix(engine, layout): invalidate layout cache on filename change and unify untitled fallback

This commit is contained in:
Divyanshu Gupta
2026-10-22 01:01:54 +05:30
parent 93dc6000f9
commit c5de19a287
5 changed files with 140 additions and 51 deletions
+3 -15
View File
@@ -193,6 +193,7 @@ fn shown_text(s: &mut Session, f: &FieldAt) -> String {
.map(|p| l.pages.iter().filter(|pg| pg.section == p.section).count() as u32)
.unwrap_or_else(|| u32::try_from(l.pages.len()).unwrap_or(u32::MAX));
let page_format = page.and_then(|p| s.doc.sections().get(p.section).map(|(_, sec)| sec.page_num_format)).unwrap_or_default();
let (fn_name, fn_path) = s.resolved_filenames();
let ctx = wordcraft_layout::fields::FieldCtx {
page: page.map(|p| p.number).unwrap_or(1),
page_format,
@@ -201,21 +202,8 @@ fn shown_text(s: &mut Session, f: &FieldAt) -> String {
section,
title: s.doc.core.title.as_str().into(),
author: s.doc.core.creator.as_str().into(),
filename: s
.path
.as_ref()
.and_then(|p| p.file_name())
.map(|n| n.to_string_lossy().to_string())
.or_else(|| s.file_name.clone())
.unwrap_or_else(|| "Document1".into())
.into(),
filepath: s
.path
.as_ref()
.map(|p| p.to_string_lossy().to_string())
.or_else(|| s.file_name.clone())
.unwrap_or_else(|| "Document1".into())
.into(),
filename: fn_name.into(),
filepath: fn_path.into(),
..Default::default()
};
wordcraft_layout::fields::field_text(&instr, &result, &ctx).0
+31 -10
View File
@@ -1014,16 +1014,8 @@ pub fn update_fields_in(s: &mut Session, all: bool) -> Result<(), CmdError> {
"AUTHOR" => updates.push((k, s.doc.core.creator.clone())),
"NUMWORDS" => updates.push((k, s.doc.word_count().to_string())),
"FILENAME" => {
let text = if has_switch(instr, 'p') {
s.path.as_ref().map(|p| p.to_string_lossy().to_string()).or_else(|| s.file_name.clone()).unwrap_or_else(|| "Document1".into())
} else {
s.path
.as_ref()
.and_then(|p| p.file_name())
.map(|n| n.to_string_lossy().to_string())
.or_else(|| s.file_name.clone())
.unwrap_or_else(|| "Document1".into())
};
let (fn_name, fn_path) = s.resolved_filenames();
let text = if has_switch(instr, 'p') { fn_path } else { fn_name };
updates.push((k, text));
}
_ => {}
@@ -1351,6 +1343,35 @@ mod tests {
assert_eq!(fields[1], (r"FILENAME \p".into(), "/docs/my_thesis.docx".into()));
}
#[test]
fn update_fields_filename_titled_unsaved() {
let mut doc = wordcraft_doc::Document::new();
doc.core.title = "Important Document".into();
let mut s = Session::new(doc);
s.run("insert.field", &serde_json::json!({"instr": "FILENAME"})).unwrap();
let fields = story_fields(&s, StoryRef::Body);
assert_eq!(fields[0], ("FILENAME".into(), "Document1".into()));
let l = s.layout();
let text: String = l.pages[0]
.items
.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();
assert!(text.contains("Document1"), "live layout renders Document1: {text}");
assert!(!text.contains("Important Document"), "live layout must not render title: {text}");
s.run("references.updateFields", &serde_json::json!({})).unwrap();
let fields_after = story_fields(&s, StoryRef::Body);
assert_eq!(fields_after[0], ("FILENAME".into(), "Document1".into()));
}
/// #1706 (Word check): Update Field with the caret in the body leaves a header's fields alone.
#[test]
fn update_fields_in_the_body_leaves_headers_alone() {
+26 -12
View File
@@ -803,6 +803,22 @@ impl Session {
if set.is_empty() { self.author.split_whitespace().filter_map(|w| w.chars().next()).collect() } else { set.to_string() }
}
/// The document's file name and full path for fields (`FILENAME`) and layout context.
/// Returns `(filename, filepath)`. Unsaved documents with no explicit file name default
/// consistently to `("Document1", "Document1")`. Document metadata title is never used
/// as a filename fallback (#3449).
pub fn resolved_filenames(&self) -> (String, String) {
if let Some(path) = &self.path {
let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string_lossy().to_string());
let full = path.to_string_lossy().to_string();
(name, full)
} else if let Some(name) = &self.file_name {
(name.clone(), name.clone())
} else {
("Document1".to_string(), "Document1".to_string())
}
}
/// The current layout (recomputed when the document or view changed).
pub fn layout(&mut self) -> Arc<DocLayout> {
let mode = if self.view.read_mode { ViewMode::Read } else { self.view.mode };
@@ -833,17 +849,8 @@ impl Session {
{
return l.clone();
}
let (filename, filepath): (Option<Arc<str>>, Option<Arc<str>>) = if let Some(path) = &self.path {
let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string_lossy().to_string());
let full = path.to_string_lossy().to_string();
(Some(Arc::from(name.as_str())), Some(Arc::from(full.as_str())))
} else if let Some(name) = &self.file_name {
(Some(Arc::from(name.as_str())), Some(Arc::from(name.as_str())))
} else if !self.doc.core.title.is_empty() {
(Some(Arc::from(self.doc.core.title.as_str())), Some(Arc::from(self.doc.core.title.as_str())))
} else {
(Some(Arc::from("Document1")), Some(Arc::from("Document1")))
};
let (fn_name, fn_path) = self.resolved_filenames();
let (filename, filepath): (Option<Arc<str>>, Option<Arc<str>>) = (Some(Arc::from(fn_name.as_str())), Some(Arc::from(fn_path.as_str())));
let opts = LayoutOptions {
view: mode,
web_width: width,
@@ -912,7 +919,14 @@ impl Session {
/// [`Session::export_layout`]; with `markup` (Print Markup) each page also has the markup
/// area with the comments in balloons.
pub fn export_layout_with(&self, markup: bool) -> Arc<DocLayout> {
let opts = LayoutOptions { view: ViewMode::Print, comment_balloons: markup && self.prefs.markup.comments, ..Default::default() };
let (fn_name, fn_path) = self.resolved_filenames();
let opts = LayoutOptions {
view: ViewMode::Print,
comment_balloons: markup && self.prefs.markup.comments,
filename: Some(Arc::from(fn_name.as_str())),
filepath: Some(Arc::from(fn_path.as_str())),
..Default::default()
};
Arc::new(wordcraft_layout::layout(&self.doc, &mut LayoutCache::new(), &opts))
}
+16 -14
View File
@@ -587,20 +587,22 @@ fn env_hash(doc: &Document, opts: &LayoutOptions) -> u64 {
let s = serde_json::to_string(&(&doc.styles, &doc.numbering, doc.settings.default_tab, &doc.settings.footnote_format)).unwrap_or_default();
// Outline view lays paragraphs out changed (no indents; plain without text formatting).
let outline = (opts.view == ViewMode::Outline).then(|| opts.outline.as_ref().is_some_and(|o| o.plain));
hash_of(&(
s,
opts.show_hidden,
opts.hide_deleted,
opts.hide_marked,
opts.deletions_in_balloons,
opts.proofing,
opts.numeral,
opts.field_codes,
opts.proof,
wordcraft_proof::dictionary_generation(),
hyphenation_key(doc),
outline,
))
let mut h = std::collections::hash_map::DefaultHasher::new();
s.hash(&mut h);
opts.show_hidden.hash(&mut h);
opts.hide_deleted.hash(&mut h);
opts.hide_marked.hash(&mut h);
opts.deletions_in_balloons.hash(&mut h);
opts.proofing.hash(&mut h);
opts.numeral.hash(&mut h);
opts.field_codes.hash(&mut h);
opts.proof.hash(&mut h);
wordcraft_proof::dictionary_generation().hash(&mut h);
hyphenation_key(doc).hash(&mut h);
outline.hash(&mut h);
opts.filename.hash(&mut h);
opts.filepath.hash(&mut h);
h.finish()
}
/// The hyphenation settings, hashable (the zone as its bits).
+64
View File
@@ -9423,3 +9423,67 @@ fn caret_height_ignores_line_spacing() {
assert!((single - wide).abs() < 0.01, "{spacing:?}: single {single}, wide {wide}");
}
}
#[test]
fn layout_cache_invalidates_on_changed_filename() {
let mut doc = Document::new();
let mut p = Paragraph::new();
p.insert_object(
0,
InlineObject::Field { instr: "FILENAME".into(), result: String::new(), locked: false, dirty: false, code: false, form: String::new() },
&Default::default(),
)
.unwrap();
doc.body = vec![wordcraft_doc::para_block(p)];
let shown = |l: &DocLayout| -> String {
l.pages[0]
.items
.iter()
.find_map(|it| if let Placed::Lines { para, .. } = it { para.shown.first().map(|s| s.1.clone()) } else { None })
.unwrap_or_default()
};
let mut cache = LayoutCache::new();
let opts_a = LayoutOptions { filename: Some(Arc::from("a.docx")), filepath: Some(Arc::from("/docs/a.docx")), ..Default::default() };
let l_a = layout(&doc, &mut cache, &opts_a);
let text_a = shown(&l_a);
assert_eq!(text_a, "a.docx", "first layout shows a.docx");
let opts_b = LayoutOptions { filename: Some(Arc::from("b.docx")), filepath: Some(Arc::from("/docs/b.docx")), ..Default::default() };
let l_b = layout(&doc, &mut cache, &opts_b);
let text_b = shown(&l_b);
assert_eq!(text_b, "b.docx", "second layout with same cache shows b.docx");
}
#[test]
fn layout_cache_invalidates_on_changed_filepath() {
let mut doc = Document::new();
let mut p = Paragraph::new();
p.insert_object(
0,
InlineObject::Field { instr: r"FILENAME \p".into(), result: String::new(), locked: false, dirty: false, code: false, form: String::new() },
&Default::default(),
)
.unwrap();
doc.body = vec![wordcraft_doc::para_block(p)];
let shown = |l: &DocLayout| -> String {
l.pages[0]
.items
.iter()
.find_map(|it| if let Placed::Lines { para, .. } = it { para.shown.first().map(|s| s.1.clone()) } else { None })
.unwrap_or_default()
};
let mut cache = LayoutCache::new();
let opts_a = LayoutOptions { filename: Some(Arc::from("a.docx")), filepath: Some(Arc::from("/path/one/a.docx")), ..Default::default() };
let l_a = layout(&doc, &mut cache, &opts_a);
let text_a = shown(&l_a);
assert_eq!(text_a, "/path/one/a.docx", "first layout shows /path/one/a.docx");
let opts_b = LayoutOptions { filename: Some(Arc::from("a.docx")), filepath: Some(Arc::from("/path/two/a.docx")), ..Default::default() };
let l_b = layout(&doc, &mut cache, &opts_b);
let text_b = shown(&l_b);
assert_eq!(text_b, "/path/two/a.docx", "second layout with same cache shows /path/two/a.docx");
}