From 239ac1bbb2c3a9fcc5b6c30fa9918e3d2628e585 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 17:02:17 +0000 Subject: [PATCH] Cross-references: saved text counts as generated only on an exact match An imported cross-reference's text is regenerated only when it is exactly what its format generates for its destination (Document::drop_generated_xref_text, once the destinations are resolved); "Page 7, revised" is an edit and is kept, as is the text of one whose destination is gone. The loose in-order match (matches_format) is gone. Page numbers are recognised by the instance's AssociatedTextVariable (a cross-reference page number variable, or InDesign's name for it when the file doesn't define it) rather than its Name, and text inside tracked-change wrappers counts as shown (insertions in, deletions out). Export writes generated text with its page numbers as the live variable too, so it reads back as generated. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CgGsJkVe5GYG9qK6VFdECa --- crates/doc/src/xref.rs | 68 +++++++++++++----------------- crates/idml/src/export/story.rs | 21 ++++----- crates/idml/src/import.rs | 6 +++ crates/idml/src/import/document.rs | 8 +++- crates/idml/src/import/story.rs | 36 +++++++++------- crates/idml/src/tests.rs | 37 +++++++++++++--- 6 files changed, 102 insertions(+), 74 deletions(-) diff --git a/crates/doc/src/xref.rs b/crates/doc/src/xref.rs index 07c195212..0ecd92dd5 100644 --- a/crates/doc/src/xref.rs +++ b/crates/doc/src/xref.rs @@ -53,36 +53,6 @@ pub fn expand_edited(text: &str, page: &str) -> String { text.replace(PAGE_NUM_BLOCK, page) } -/// The literal text between a format's building blocks, in order. -pub fn literal_parts(definition: &str) -> Vec<&str> { - let mut out = Vec::new(); - let mut rest = definition; - while let Some(i) = rest.find('<') { - let Some(j) = rest[i..].find('>') else { break }; - if i > 0 { - out.push(&rest[..i]); - } - rest = &rest[i + j + 1..]; - } - if !rest.is_empty() { - out.push(rest); - } - out -} - -/// Does `text` (a cross-reference's text as saved) still read as its format would generate it: -/// every literal part of the definition, in order? Text that doesn't was edited by hand. -pub fn matches_format(text: &str, definition: &str) -> bool { - let mut rest = text; - for part in literal_parts(definition) { - match rest.find(part) { - Some(i) => rest = &rest[i + part.len()..], - None => return false, - } - } - true -} - /// A cross-reference format: building blocks between literal text. /// /// Blocks: ``, `` (without the list number), ``, @@ -355,6 +325,35 @@ impl Story { } impl Document { + /// The text a cross-reference to `target` in `format` generates, with each page number as + /// [`PAGE_NUM_BLOCK`] (the form saved text takes); `None` when the destination is gone. + pub fn generated_xref_text(&self, target: u64, format: &str) -> Option { + let mut v = self.xref_values(target)?; + v.page = PAGE_NUM_BLOCK.into(); + Some(expand(self.xref_format(format).map_or("", |f| f.definition.as_str()), &v)) + } + + /// Forget saved cross-reference text that is exactly what its format generates for its + /// destination, so it is generated again (and follows edits to the destination). Any other + /// saved text was typed over the generated text, or its destination is gone: it is kept as + /// shown. + pub fn drop_generated_xref_text(&mut self) { + let generated: Vec<(StoryId, usize)> = self + .stories + .iter() + .flat_map(|(sid, st)| st.xrefs.iter().enumerate().map(move |(k, x)| (*sid, k, x))) + .filter(|(_, _, x)| x.text.as_ref().is_some_and(|t| self.generated_xref_text(x.target, &x.format).as_ref() == Some(t))) + .map(|(sid, k, _)| (sid, k)) + .collect(); + for (sid, k) in generated { + if let Some(x) = self.story_mut(sid).and_then(|st| st.xrefs.get_mut(k)) { + let x = Arc::make_mut(x); + x.text = None; + x.page = None; + } + } + } + /// (story, byte) of anchor `id`. pub fn find_anchor(&self, id: u64) -> Option<(StoryId, usize)> { if id == 0 { @@ -433,15 +432,8 @@ pub fn clean(s: &str) -> String { mod tests { use super::*; - /// Saved text that still reads as its format produced it is generated again; text typed - /// over it is kept. #[test] - fn edited_text_is_recognised() { - let def = "Page : Last, First. \".\" YYYY. Volume X, issue Y."; - assert_eq!(literal_parts(def), ["Page ", ": Last, First. \"", ".\" YYYY. Volume X, issue Y."]); - assert!(matches_format("Page : Last, First. \"Advent Horizon.\" YYYY. Volume X, issue Y.", def)); - assert!(!matches_format("Page : Bove, Vinny. \"Advent Horizon.\" 1996. Volume 6, issue 5.", def)); - assert!(matches_format("anything", "")); + fn edited_text_fills_in_its_page_numbers() { assert_eq!(expand_edited("p. and ", "4"), "p. 4 and 4"); } diff --git a/crates/idml/src/export/story.rs b/crates/idml/src/export/story.rs index 1fcefd659..9e013e0a4 100644 --- a/crates/idml/src/export/story.rs +++ b/crates/idml/src/export/story.rs @@ -327,14 +327,13 @@ impl<'a> Ex<'a> { segs } - /// A cross-reference source's text: `generated`, or the text typed over it, its page numbers - /// as InDesign's live cross-reference page number. - fn xref_content(&mut self, c: &mut El, x: &designcraft_doc::CrossRef, generated: String) { - let Some(t) = &x.text else { - let mut content = El::new("Content"); - content.children.push(Node::Text(generated)); - c.push(content); - return; + /// A cross-reference source's text: what its format generates, or the text typed over it, its + /// page numbers as InDesign's live cross-reference page number (as InDesign writes them, so + /// generated text reads back as generated). + fn xref_content(&mut self, c: &mut El, x: &designcraft_doc::CrossRef) { + let t = match &x.text { + Some(t) => t.clone(), + None => self.d.generated_xref_text(x.target, &x.format).unwrap_or_else(|| "??".into()), }; let page = x.page.clone().unwrap_or_else(|| "?".into()); for (n, part) in t.split(designcraft_doc::xref::PAGE_NUM_BLOCK).enumerate() { @@ -447,12 +446,8 @@ impl<'a> Ex<'a> { if let Some(x) = s.xrefs.get(k) { let me = self.fresh(); let fmt = self.d.xref_formats.iter().position(|ff| ff.name == x.format).map_or("n".to_string(), xref_format_self); - let text = self.d.xref_values(x.target).map_or("??".into(), |mut v| { - v.page = "?".into(); - designcraft_doc::xref::expand(self.d.xref_format(&x.format).map_or("", |ff| ff.definition.as_str()), &v) - }); let mut c = self.csr_el(&f); - self.xref_content(&mut c, x, text); + self.xref_content(&mut c, x); let mut src = El::new("CrossReferenceSource") .attr("Self", &me) .attr("AppliedFormat", fmt) diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index 2c6185c5c..535e32145 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -210,6 +210,8 @@ struct Importer<'r> { /// The file's text variables (`None`: it defines none) and their Self → index. text_vars: Option>, text_var_ids: HashMap, + /// The Self of each cross-reference page number variable (`XrefPageNumberType`). + xref_page_vars: HashSet, /// Creation and modification times (Unix seconds) from the package metadata. created: Option, modified: Option, @@ -283,6 +285,7 @@ impl<'r> Importer<'r> { index_xrefs: Vec::new(), text_vars: None, text_var_ids: HashMap::new(), + xref_page_vars: HashSet::new(), created: None, modified: None, repaired_tab_lists: 0, @@ -610,6 +613,9 @@ impl<'r> Importer<'r> { { d.settings.zero_point = zp; } + // Cross-reference text the format generates exactly is generated again; any other was + // typed over it and is kept (needs the destinations, resolved above). + d.drop_generated_xref_text(); Ok(d) } } diff --git a/crates/idml/src/import/document.rs b/crates/idml/src/import/document.rs index dce466381..cf2ba166c 100644 --- a/crates/idml/src/import/document.rs +++ b/crates/idml/src/import/document.rs @@ -133,7 +133,13 @@ impl<'r> Importer<'r> { pub(super) fn text_variable(&mut self, e: &El) { use designcraft_doc::vars::{TextVariable, Use, VAR_MAX, VarKind}; let ty = e.get("VariableType").unwrap_or(""); - // Cross-reference internals; export writes its own. + // Cross-reference internals; export writes its own. Their page numbers are recognised + // inside cross-reference text. + if ty == "XrefPageNumberType" + && let Some(id) = e.get("Self") + { + self.xref_page_vars.insert(id.to_string()); + } if ty.is_empty() || ty.starts_with("Xref") { return; } diff --git a/crates/idml/src/import/story.rs b/crates/idml/src/import/story.rs index b798266fe..579b1e004 100644 --- a/crates/idml/src/import/story.rs +++ b/crates/idml/src/import/story.rs @@ -50,16 +50,11 @@ impl<'r> Importer<'r> { for (k, x) in xrefs.into_iter().enumerate() { self.xref_srcs.insert(x.me, (id, k)); let format = self.xref_format_names.get(&x.format).cloned().unwrap_or_else(|| "Full Paragraph & Page Number".into()); - // Text typed over the generated text is kept (InDesign shows it until the - // cross-reference is updated); text the format still produces is generated again. - let definition = self.xref_formats.iter().find(|f| f.name == format).map_or("", |f| f.definition.as_str()); - let edited = !x.text.is_empty() && !designcraft_doc::xref::matches_format(&x.text, definition); - xref_list.push(Arc::new(designcraft_doc::CrossRef { - target: 0, - format, - text: edited.then_some(x.text), - page: x.page.filter(|_| edited), - })); + // The text as saved: once the destinations are known, text the format generates + // exactly is dropped to be generated again (`drop_generated_xref_text`), and text + // typed over it is kept, as InDesign shows it until the cross-reference is updated. + let saved = !x.text.is_empty(); + xref_list.push(Arc::new(designcraft_doc::CrossRef { target: 0, format, text: saved.then_some(x.text), page: x.page.filter(|_| saved) })); } let pref = e.find("StoryPreference"); let optical_margin = designcraft_doc::OpticalMargin { @@ -205,7 +200,7 @@ impl<'r> Importer<'r> { "CrossReferenceSource" => { // The generated text is regenerated from the format. let (mut text, mut page) = (String::new(), None); - xref_saved_text(c, &mut text, &mut page, 0); + xref_saved_text(c, &self.xref_page_vars, &mut text, &mut page, 0); b.xrefs.push(XrefSource { me: c.get("Self").unwrap_or("").to_string(), format: c.get("AppliedFormat").unwrap_or("").to_string(), @@ -431,23 +426,32 @@ struct XrefSource { page: Option, } -/// The text a `CrossReferenceSource` holds, with its live page numbers (InDesign's -/// `XRefPageNumber` text variable) as ``. Returns the text and the page shown. -fn xref_saved_text(e: &El, text: &mut String, page: &mut Option, depth: usize) { +/// Whether a `TextVariableInstance` shows a cross-reference page number: its +/// `AssociatedTextVariable` is a variable of that type (`page_vars`), or, in a file that doesn't +/// define it, has the name InDesign gives that variable. +fn is_xref_page_number(e: &El, page_vars: &HashSet) -> bool { + e.get("AssociatedTextVariable").is_some_and(|v| page_vars.contains(v) || (page_vars.is_empty() && v.ends_with("TV XRefPageNumber"))) +} + +/// The text a `CrossReferenceSource` holds as shown, with its live page numbers (InDesign's +/// cross-reference page number variable, `page_vars`) as ``. Tracked insertions count; +/// tracked deletions don't. Returns the text and the page shown. +fn xref_saved_text(e: &El, page_vars: &HashSet, text: &mut String, page: &mut Option, depth: usize) { if depth > 16 { return; } for c in e.elements() { match c.local() { "Content" => text.push_str(&c.text_content()), - "TextVariableInstance" if c.get("Name").is_some_and(|n| n.contains("XRefPageNumber")) => { + "TextVariableInstance" if is_xref_page_number(c, page_vars) => { text.push_str(designcraft_doc::xref::PAGE_NUM_BLOCK); if page.is_none() { *page = c.get("ResultText").map(str::to_string); } } "TextVariableInstance" => text.push_str(c.get("ResultText").unwrap_or("")), - "CharacterStyleRange" => xref_saved_text(c, text, page, depth + 1), + "Change" if c.get("ChangeType") == Some("DeletedText") => {} + "CharacterStyleRange" | "Change" => xref_saved_text(c, page_vars, text, page, depth + 1), _ => {} } } diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index f6e48d2da..35ceec24a 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -1311,6 +1311,7 @@ fn round_trips_cross_references() { assert_eq!(s.text, d.story(sid).unwrap().text); assert_eq!(s.xrefs.len(), 1); assert_eq!(s.xrefs[0].format, "Paragraph Text"); + assert_eq!(s.xrefs[0].text, None, "generated text reads back as generated"); let (tsid, tpos) = back.find_anchor(s.xrefs[0].target).expect("target resolves"); assert_eq!((tsid, tpos), (s.id, 0)); let mine = back.xref_format("Mine").unwrap(); @@ -4786,15 +4787,15 @@ fn epub_split_document_round_trips() { } } -/// Text typed over a cross-reference's generated text is kept, its page number live (InDesign's -/// XRefPageNumber variable); text the format still produces is generated again. The edits -/// survive export and import. +/// Text typed over a cross-reference's generated text is kept as shown, its page number live +/// (InDesign's cross-reference page number variable, found by its `AssociatedTextVariable`), +/// tracked insertions in and deletions out. The edits survive export and import. #[test] fn keeps_edited_cross_reference_text() { let story = r#" -Page : Bove, Vinny. "Advent Horizon." 1996. +Page : Bove, Vinny. "Advent Horizon." (draft) 1996. and "Intro" on page "#; @@ -4804,12 +4805,36 @@ fn keeps_edited_cross_reference_text() { assert_eq!(s.xrefs.len(), 2); assert_eq!(s.xrefs[0].text.as_deref(), Some("Page : Bove, Vinny. \"Advent Horizon.\" 1996.")); assert_eq!(s.xrefs[0].page.as_deref(), Some("7")); - assert_eq!(s.xrefs[1].text, None, "unedited text follows its format"); - // Its destination is gone: the page it last showed. + // Its destination is gone: the text and page it last showed. assert_eq!(designcraft_compose::xref::xref_text(&d, &s.xrefs[0], None), "Page 7: Bove, Vinny. \"Advent Horizon.\" 1996."); + assert_eq!(s.xrefs[1].text.as_deref(), Some("\"Intro\" on page "), "with no destination to generate from, the text as shown"); let back = import_idml(&export_idml(&d)).unwrap(); back.check().unwrap(); let b = back.stories.values().find(|s| !s.xrefs.is_empty()).unwrap(); assert_eq!(b.xrefs[0].text, s.xrefs[0].text); assert_eq!(b.xrefs[0].page, s.xrefs[0].page); } + +/// Saved cross-reference text counts as generated only when it is exactly what its format +/// generates for its destination: text added after it (or anywhere else) is an edit, and kept. +#[test] +fn only_exactly_generated_cross_reference_text_is_regenerated() { + let mut d = Document::new(&NewDocument::default()); + let lid = d.default_layer(); + let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(40.0, 40.0, 400.0, 400.0), lid, "Results\nSee", ParaFormat::default()).unwrap(); + let target = d.paragraph_anchor(sid, 0).unwrap(); + d.xref_formats.push(designcraft_doc::XrefFormat { name: "Page".into(), definition: "Page ".into() }); + for t in ["Page , revised", "Page ", "Seite "] { + let st = d.story_mut(sid).unwrap(); + let end = st.len(); + st.insert(end, " "); + let x = designcraft_doc::CrossRef { target, format: "Page".into(), text: Some(t.into()), page: Some("1".into()) }; + st.insert_xref(end + 1, x); + } + d.check().unwrap(); + let back = import_idml(&export_idml(&d)).unwrap(); + back.check().unwrap(); + let s = back.stories.values().find(|s| !s.xrefs.is_empty()).unwrap(); + let got: Vec> = s.xrefs.iter().map(|x| x.text.as_deref()).collect(); + assert_eq!(got, [Some("Page , revised"), None, Some("Seite ")]); +}