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