mirror of
https://github.com/storytold/designcraft.git
synced 2026-11-01 22:26:49 -06:00
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:
+30
-38
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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="<?AID 001b?>TV XRefPageNumber" ResultText="7" AssociatedTextVariable="dTextVariablen<?AID 001b?>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<?AID 001b?>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="<?AID 001b?>TV XRefPageNumber" ResultText="3" AssociatedTextVariable="dTextVariablen<?AID 001b?>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 />")]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user