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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CgGsJkVe5GYG9qK6VFdECa
This commit is contained in:
Claude
2026-10-30 17:02:17 +00:00
parent d48b650320
commit 239ac1bbb2
6 changed files with 102 additions and 74 deletions
+30 -38
View File
@@ -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: `<fullPara />`, `<paraText />` (without the list number), `<paraNum />`,
@@ -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<String> {
let mut v = self.xref_values(target)?;
v.page = PAGE_NUM_BLOCK.into();
Some(expand(self.xref_format(format).map_or("<fullPara />", |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 <pageNum />: Last, First. \"<txtAnchrName />.\" YYYY. Volume X, issue Y.";
assert_eq!(literal_parts(def), ["Page ", ": Last, First. \"", ".\" YYYY. Volume X, issue Y."]);
assert!(matches_format("Page <pageNum />: Last, First. \"Advent Horizon.\" YYYY. Volume X, issue Y.", def));
assert!(!matches_format("Page <pageNum />: Bove, Vinny. \"Advent Horizon.\" 1996. Volume 6, issue 5.", def));
assert!(matches_format("anything", "<paraText />"));
fn edited_text_fills_in_its_page_numbers() {
assert_eq!(expand_edited("p. <pageNum /> and <pageNum />", "4"), "p. 4 and 4");
}
+8 -13
View File
@@ -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("<fullPara />", |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)
+6
View File
@@ -210,6 +210,8 @@ struct Importer<'r> {
/// The file's text variables (`None`: it defines none) and their Self → index.
text_vars: Option<Vec<designcraft_doc::vars::TextVariable>>,
text_var_ids: HashMap<String, usize>,
/// The Self of each cross-reference page number variable (`XrefPageNumberType`).
xref_page_vars: HashSet<String>,
/// Creation and modification times (Unix seconds) from the package metadata.
created: Option<i64>,
modified: Option<i64>,
@@ -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)
}
}
+7 -1
View File
@@ -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;
}
+20 -16
View File
@@ -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("<fullPara />", |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<String>,
}
/// The text a `CrossReferenceSource` holds, with its live page numbers (InDesign's
/// `XRefPageNumber` text variable) as `<pageNum />`. Returns the text and the page shown.
fn xref_saved_text(e: &El, text: &mut String, page: &mut Option<String>, 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<String>) -> 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 `<pageNum />`. Tracked insertions count;
/// tracked deletions don't. Returns the text and the page shown.
fn xref_saved_text(e: &El, page_vars: &HashSet<String>, text: &mut String, page: &mut Option<String>, 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),
_ => {}
}
}
+31 -6
View File
@@ -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#"<?xml version="1.0" encoding="UTF-8"?>
<idPkg:Story xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging" DOMVersion="16.0">
<Story Self="s1"><ParagraphStyleRange AppliedParagraphStyle="ParagraphStyle/$ID/NormalParagraphStyle">
<CrossReferenceSource Self="x1" AppliedFormat="n"><CharacterStyleRange AppliedCharacterStyle="CharacterStyle/$ID/[No character style]"><Content>Page </Content></CharacterStyleRange><CharacterStyleRange AppliedCharacterStyle="CharacterStyle/$ID/[No character style]"><TextVariableInstance Self="v1" Name="&lt;?AID 001b?&gt;TV XRefPageNumber" ResultText="7" AssociatedTextVariable="dTextVariablen&lt;?AID 001b?&gt;TV XRefPageNumber"/></CharacterStyleRange><CharacterStyleRange AppliedCharacterStyle="CharacterStyle/$ID/[No character style]"><Content>: Bove, Vinny. "Advent Horizon." 1996.</Content></CharacterStyleRange></CrossReferenceSource>
<CrossReferenceSource Self="x1" AppliedFormat="n"><CharacterStyleRange AppliedCharacterStyle="CharacterStyle/$ID/[No character style]"><Content>Page </Content></CharacterStyleRange><CharacterStyleRange AppliedCharacterStyle="CharacterStyle/$ID/[No character style]"><TextVariableInstance Self="v1" Name="Seitenzahl" ResultText="7" AssociatedTextVariable="dTextVariablen&lt;?AID 001b?&gt;TV XRefPageNumber"/></CharacterStyleRange><CharacterStyleRange AppliedCharacterStyle="CharacterStyle/$ID/[No character style]"><Content>: Bove, Vinny. </Content><Change Self="c1" ChangeType="InsertedText"><Content>"Advent Horizon."</Content></Change><Change Self="c2" ChangeType="DeletedText"><Content> (draft)</Content></Change><Content> 1996.</Content></CharacterStyleRange></CrossReferenceSource>
<CharacterStyleRange AppliedCharacterStyle="CharacterStyle/$ID/[No character style]"><Content> and </Content></CharacterStyleRange>
<CrossReferenceSource Self="x2" AppliedFormat="n"><CharacterStyleRange AppliedCharacterStyle="CharacterStyle/$ID/[No character style]"><Content>"Intro" on page </Content><TextVariableInstance Self="v2" Name="&lt;?AID 001b?&gt;TV XRefPageNumber" ResultText="3" AssociatedTextVariable="dTextVariablen&lt;?AID 001b?&gt;TV XRefPageNumber"/></CharacterStyleRange></CrossReferenceSource>
</ParagraphStyleRange></Story></idPkg:Story>"#;
@@ -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 <pageNum />: 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 <pageNum />"), "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 <pageNum />".into() });
for t in ["Page <pageNum />, revised", "Page <pageNum />", "Seite <pageNum />"] {
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<Option<&str>> = s.xrefs.iter().map(|x| x.text.as_deref()).collect();
assert_eq!(got, [Some("Page <pageNum />, revised"), None, Some("Seite <pageNum />")]);
}