diff --git a/ROADMAP.md b/ROADMAP.md index 2fee5036f..0ee1849f7 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -524,6 +524,7 @@ Newest first. - **2026-10-18** · Table of Contents reserves the title field's width so Contents is readable next to its style picker; the grid previously squeezed it to a few characters. No readiness percentage change. - **2026-10-17** · Inline and anchored objects paint under their frame's text, on screen and in PDF, as InDesign draws them: a picture whose frame reaches into the next line (a negative Y offset) no longer hides the top of that line. No readiness percentage change. - **2026-10-17** · Running headers: *first/last on page* read a page in text order (story order, then column by column), not by height — a multi-column page's right header showed the lowest entry of column 1 instead of the page's last entry. No readiness percentage change. +- **2026-10-17** · Cross-references keep text typed over their generated text (IDML import and export), with the page number still live, as InDesign shows them until updated; a cross-reference whose destination is gone shows its saved text and page instead of "??"; and a cross-reference's text breaks at its spaces instead of inside words. No readiness percentage change. - **2026-10-17** · Parent pages: while parents are edited, the document pages' hidden characters, spelling squiggles and other overlays no longer pile onto the right parent page. No readiness percentage change. - **2026-10-16** · A Direct Selection click on an image whose frame is already selected selects the image, so Delete removes only the image and leaves the empty frame, as in InDesign. No readiness percentage change. - **2026-10-16** · Type › Reload Fonts (`font.reload`): fonts installed, edited or removed while DesignCraft runs are read again without a restart, and the open documents recompose with them (#328, #547). An edited font file is noticed by its length and modification time; bundled, added and document fonts stay as they are. InDesign does this by itself, so noticing changes automatically is still open (gap 6). No readiness percentage change. diff --git a/crates/compose/src/shape.rs b/crates/compose/src/shape.rs index d972cad30..078848672 100644 --- a/crates/compose/src/shape.rs +++ b/crates/compose/src/shape.rs @@ -1201,7 +1201,12 @@ fn shape_segment( for (gi, sg) in shaped.iter().enumerate() { let up = upright.get(gi).copied().unwrap_or(false); let (byte, len, ch) = if replacement.is_some() { - (range.start, if gi == 0 { range.len() } else { 0 }, text[range.start..].chars().next().unwrap_or(' ')) + let marker = text[range.start..].chars().next().unwrap_or(' '); + // A cross-reference's text is real text in InDesign: its spaces are word spaces the + // line may break at (text variables and page numbers stay whole). The first glyph + // keeps the marker, as it owns the marker's bytes. + let space = gi > 0 && marker == designcraft_doc::XREF_MARK && src.get(sg.cluster..).is_some_and(|t| t.starts_with(' ')); + (range.start, if gi == 0 { range.len() } else { 0 }, if space { ' ' } else { marker }) } else { let cl = range.start + sg.cluster; let end = shaped[gi + 1..].iter().map(|r| range.start + r.cluster).find(|&c| c > cl).unwrap_or(range.end); diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index d7b87cea9..7b5e1024f 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -8409,3 +8409,34 @@ fn anchored_text_frames_compose_against_the_grid_where_they_are_placed() { Arc::make_mut(&mut d.spreads[1]).pages[1].side = designcraft_doc::PageSide::Left; assert!(!Arc::ptr_eq(&before, &cache.get(&d, nsid, None))); } + +/// A cross-reference's text breaks at its spaces like ordinary text (it is real text in +/// InDesign), not inside words; edited text whose destination is gone shows its saved page. +#[test] +fn cross_reference_text_breaks_at_its_spaces() { + let (mut d, sid, _) = doc_with("See X.", Rect::new(36.0, 36.0, 110.0, 400.0), ParaAttrs::default()); + let at = d.story(sid).unwrap().text.find('X').unwrap(); + let st = d.story_mut(sid).unwrap(); + st.delete(at..at + 1); + st.insert_xref( + at, + designcraft_doc::CrossRef { + text: Some("Page : Bove, Vinny. \"Advent Horizon.\" 1996. Volume 6, issue 5.".into()), + page: Some("7".into()), + ..Default::default() + }, + ); + let x = d.story(sid).unwrap().xrefs[0].clone(); + let shown = crate::xref::xref_text(&d, &x, None); + assert_eq!(shown, "Page 7: Bove, Vinny. \"Advent Horizon.\" 1996. Volume 6, issue 5."); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let lines: Vec = + cs.frames[0].lines.iter().map(|l| l.glyphs.iter().filter(|g| g.visible).map(|g| g.rendered_char).collect::()).collect(); + assert!(lines.len() > 3, "{lines:?}"); + let words: Vec<&str> = format!("See {shown}.").leak().split(' ').collect(); + for l in &lines { + for w in l.split(' ').filter(|w| !w.is_empty()) { + assert!(words.contains(&w), "a line breaks inside a word: {w:?} in {lines:?}"); + } + } +} diff --git a/crates/compose/src/xref.rs b/crates/compose/src/xref.rs index b33714203..1d72f1ee1 100644 --- a/crates/compose/src/xref.rs +++ b/crates/compose/src/xref.rs @@ -52,8 +52,14 @@ impl XrefIndex { } /// The text a cross-reference shows ("??" when its destination is gone, like an unresolved -/// reference in typesetting). +/// reference in typesetting, unless the text was edited). pub fn xref_text(doc: &Document, x: &xref::CrossRef, index: Option<&XrefIndex>) -> String { + // Edited text stays as typed; its page numbers still follow the destination, or show the + // page last shown when the destination is gone (as in InDesign). + if let Some(t) = &x.text { + let live = index.filter(|_| doc.xref_values(x.target).is_some()).and_then(|i| i.page(x.target)); + return xref::expand_edited(t, live.or(x.page.as_deref()).unwrap_or("?")); + } let Some(mut v) = doc.xref_values(x.target) else { return "??".into() }; v.page = index.and_then(|i| i.page(x.target)).unwrap_or("?").to_string(); let def = doc.xref_format(&x.format).map_or("", |f| f.definition.as_str()); diff --git a/crates/doc/src/xref.rs b/crates/doc/src/xref.rs index d3456a291..080112c2f 100644 --- a/crates/doc/src/xref.rs +++ b/crates/doc/src/xref.rs @@ -29,13 +29,28 @@ pub struct TextAnchor { pub name: String, } -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct CrossRef { /// The destination anchor (0 = unresolved, e.g. pasted without its destination). pub target: u64, /// Name of the format in [`Document::xref_formats`]. pub format: String, + /// Text typed over the generated text (InDesign keeps a cross-reference's edited text until + /// it is updated): shown as is, except that each `` shows the destination's page. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub text: Option, + /// The page last shown (for edited text whose destination is gone). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub page: Option, +} + +/// The `` building block, as it appears in [`CrossRef::text`]. +pub const PAGE_NUM_BLOCK: &str = ""; + +/// Edited cross-reference text with its page numbers filled in (quotes kept as typed). +pub fn expand_edited(text: &str, page: &str) -> String { + text.replace(PAGE_NUM_BLOCK, page) } /// A cross-reference format: building blocks between literal text. @@ -212,7 +227,7 @@ impl Story { if n > 0 { let k = self.marks_before(XREF_MARK, pos); for i in 0..n { - self.xrefs.insert(k + i, Arc::new(CrossRef { target: 0, format: String::new() })); + self.xrefs.insert(k + i, Arc::new(CrossRef::default())); } } let n = text.matches(crate::index::INDEX_MARK).count(); @@ -282,7 +297,7 @@ impl Story { let n = self.text.matches(XREF_MARK).count(); self.xrefs.truncate(n); while self.xrefs.len() < n { - self.xrefs.push(Arc::new(CrossRef { target: 0, format: String::new() })); + self.xrefs.push(Arc::new(CrossRef::default())); } let n = self.text.matches(crate::index::INDEX_MARK).count(); self.index_refs.truncate(n); @@ -324,6 +339,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 { @@ -415,6 +459,11 @@ mod tests { assert!(out.contains("Intro > the start"), "{out}"); } + #[test] + fn edited_text_fills_in_its_page_numbers() { + assert_eq!(expand_edited("p. and ", "4"), "p. 4 and 4"); + } + #[test] fn formats_expand() { let v = @@ -434,7 +483,7 @@ mod tests { let mut s = Story::with_text(StoryId(3), "Heading\nBody text.", Default::default()); let a = s.insert_anchor(0, "Heading"); assert_eq!(a >> 24, 3); - s.insert_xref(13, CrossRef { target: a, format: "Page Number".into() }); + s.insert_xref(13, CrossRef { target: a, format: "Page Number".into(), ..Default::default() }); s.check().unwrap(); assert_eq!(s.anchor_pos(a), Some(0)); s.insert(0, "New "); diff --git a/crates/engine/src/cmd/xref.rs b/crates/engine/src/cmd/xref.rs index 446148095..7106b1ed0 100644 --- a/crates/engine/src/cmd/xref.rs +++ b/crates/engine/src/cmd/xref.rs @@ -94,7 +94,7 @@ fn insert(s: &mut Session, p: &Value) -> Result { let r = t2.range(); let r = r.start.min(st.len())..r.end.min(st.len()); st.delete(r.clone()); - st.insert_xref(r.start, CrossRef { target, format }); + st.insert_xref(r.start, CrossRef { target, format, ..Default::default() }); let pos = r.start + designcraft_doc::XREF_MARK.len_utf8(); sel.text = Some(TextSel { anchor: pos, focus: pos, ..t2 }); Ok(json!({"target": target, "pos": r.start})) @@ -169,7 +169,10 @@ fn set_format(s: &mut Session, p: &Value) -> Result { } let st = d.story_mut(sid).ok_or(designcraft_doc::DocError::NoStory(sid))?; let x = st.xrefs.get_mut(k).ok_or_else(|| bad("xref.setFormat", "no such cross-reference"))?; - std::sync::Arc::make_mut(x).format = format; + let x = std::sync::Arc::make_mut(x); + x.format = format; + // A new format generates the text again, replacing any edits (InDesign does the same). + x.text = None; st.rev = st.rev.wrapping_add(1); Ok(Value::Null) }) diff --git a/crates/idml/src/export/story.rs b/crates/idml/src/export/story.rs index 97c2837ec..9e013e0a4 100644 --- a/crates/idml/src/export/story.rs +++ b/crates/idml/src/export/story.rs @@ -327,6 +327,33 @@ impl<'a> Ex<'a> { segs } + /// 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() { + if n > 0 { + c.push( + El::new("TextVariableInstance") + .attr("Self", self.fresh()) + .attr("Name", "TV XRefPageNumber") + .attr("ResultText", &page) + .attr("AssociatedTextVariable", "dTextVariablenTV XRefPageNumber"), + ); + } + if !part.is_empty() { + let mut content = El::new("Content"); + content.children.push(Node::Text(part.to_string())); + c.push(content); + } + } + } + fn story_para(&mut self, s: &Story, pi: usize, r: std::ops::Range, last: bool) -> El { let pf = s.paras.get(pi).cloned().unwrap_or_default(); let mut psr = self.psr_el(&pf); @@ -419,14 +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); - let mut content = El::new("Content"); - content.children.push(Node::Text(text)); - c.push(content); + 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 4c048f637..3b78413ef 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 5471f4f96..579b1e004 100644 --- a/crates/idml/src/import/story.rs +++ b/crates/idml/src/import/story.rs @@ -47,10 +47,14 @@ impl<'r> Importer<'r> { anchor_list.push(Arc::new(designcraft_doc::TextAnchor { id: aid, name })); } let mut xref_list = Vec::new(); - for (k, (me, fmt)) in xrefs.into_iter().enumerate() { - self.xref_srcs.insert(me, (id, k)); - let format = self.xref_format_names.get(&fmt).cloned().unwrap_or_else(|| "Full Paragraph & Page Number".into()); - xref_list.push(Arc::new(designcraft_doc::CrossRef { target: 0, format })); + 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()); + // 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 { @@ -195,7 +199,14 @@ impl<'r> Importer<'r> { } "CrossReferenceSource" => { // The generated text is regenerated from the format. - b.xrefs.push((c.get("Self").unwrap_or("").to_string(), c.get("AppliedFormat").unwrap_or("").to_string())); + let (mut text, mut page) = (String::new(), None); + 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(), + text, + page, + }); // A source at paragraph level wraps a range; inside a range it holds the // text directly and takes that range's format. let f = c.find("CharacterStyleRange").map_or_else(|| cf.clone(), |r| self.range_format(r, pchars, cf)); @@ -402,10 +413,50 @@ struct StoryBuilder { /// Text destinations (Self, name) and cross-reference sources (Self, format Self) in order. anchors: Vec<(String, String)>, index_refs: Vec, - xrefs: Vec<(String, String)>, + xrefs: Vec, objects: Vec, } +/// A cross-reference source as saved: its id, its format's id, and its text with each page number +/// as `` (and the page it last showed). +struct XrefSource { + me: String, + format: String, + text: String, + page: Option, +} + +/// 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 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("")), + "Change" if c.get("ChangeType") == Some("DeletedText") => {} + "CharacterStyleRange" | "Change" => xref_saved_text(c, page_vars, text, page, depth + 1), + _ => {} + } + } +} + impl StoryBuilder { /// A table gets an anchor paragraph of its own. fn push_table(&mut self, t: Table, pf: &ParaFormat, cf: &CharFormat) { diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index c61304883..d9e00975d 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -1306,7 +1306,7 @@ fn round_trips_cross_references() { d.add_text_frame(SpreadRef::Doc(0), Rect::new(40.0, 40.0, 400.0, 400.0), lid, "Results: found\nSee .", ParaFormat::default()).unwrap(); let target = d.paragraph_anchor(sid, 0).unwrap(); let at = d.story(sid).unwrap().text.find(" .").unwrap() + 1; - d.story_mut(sid).unwrap().insert_xref(at, designcraft_doc::CrossRef { target, format: "Paragraph Text".into() }); + d.story_mut(sid).unwrap().insert_xref(at, designcraft_doc::CrossRef { target, format: "Paragraph Text".into(), ..Default::default() }); d.xref_formats.push(designcraft_doc::XrefFormat { name: "Mine".into(), definition: "(, p. )".into(), @@ -1318,6 +1318,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(); @@ -4924,3 +4925,55 @@ fn epub_split_document_round_trips() { assert_eq!(import_idml(&export_idml(&d)).unwrap().settings.epub_split, want, "{attrs}"); } } + +/// 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." (draft) 1996. + and +"Intro" on page +"#; + let d = import_idml(&fixture_with_story(story)).unwrap(); + d.check().unwrap(); + let s = d.stories.values().find(|s| !s.xrefs.is_empty()).expect("story with cross-references"); + 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")); + // 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 ")]); +}