From f1a1f318bac694897ce3e49e8a3cd1107cdc2aa5 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 12 Oct 2026 12:00:42 +0000 Subject: [PATCH] IDML: tracked deleted text takes no room in the layout (#581) --- crates/compose/src/lib.rs | 54 +++++++++++++++++++++++++++++++++++++-- crates/idml/src/export.rs | 37 ++++++++++++++++++++++++--- crates/idml/src/import.rs | 12 +++++++++ crates/idml/src/tests.rs | 52 +++++++++++++++++++++++++++++++++++++ 4 files changed, 149 insertions(+), 6 deletions(-) diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 3dd7c25b3..1c24df693 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -498,6 +498,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C }; let para_ranges = story.para_ranges(); let np = para_ranges.len(); + let deleted = deleted_paras(story, ¶_ranges); let hyph_exceptions = doc.hyphenation_exception_map(); let mut list_counter: u32 = 0; // Named lists number independently of layout: one pass up front. @@ -507,9 +508,10 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C .paras .iter() .zip(¶_ranges) - .map(|(p, r)| { + .zip(&deleted) + .map(|((p, r), del)| { let (pp, _) = doc.styles.resolve_para(p); - if pp.list_type != designcraft_doc::ListType::Numbers || pp.list_name.is_empty() || r.is_empty() { + if pp.list_type != designcraft_doc::ListType::Numbers || pp.list_name.is_empty() || r.is_empty() || *del { return None; } let c = counters.entry(pp.list_name.clone()).or_insert_with(|| doc.list_start(story.id, &pp.list_name)); @@ -565,6 +567,27 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C pi = j; continue 'paras; } + if deleted.get(pi).copied().unwrap_or(false) { + // A deleted paragraph takes no room; for keeps it stands in for the one before it. + let filler = info.last().map_or( + ParaInfo { + start: (cur.fi, cur.col), + at_top: cur.last_baseline.is_none(), + end: (cur.fi, cur.col), + lines: 0, + lines_in_end_col: 0, + keep_with_next: 0, + keep_together: false, + keep_all: false, + keep_first: 1, + keep_last: 1, + }, + |p| ParaInfo { lines_in_end_col: 0, ..p.clone() }, + ); + info.push(filler); + pi += 1; + continue; + } let pf = &story.paras[pi]; let (pp, base_chars) = doc.styles.resolve_para(pf); // A break character that ends the previous paragraph sends this one on, like a start option. @@ -1590,6 +1613,33 @@ fn rewind( } } +/// Paragraphs that tracked changes delete: all their text is deleted, and so is their return or +/// the return before them (then they join a paragraph and add nothing to it). +fn deleted_paras(story: &Story, ranges: &[Range]) -> Vec { + let mut spans: Vec> = Vec::new(); + for (r, f) in story.runs() { + if f.over.change != Some(designcraft_doc::ChangeMark::Deleted) { + continue; + } + match spans.last_mut() { + Some(l) if l.end == r.start => l.end = r.end, + _ => spans.push(r), + } + } + if spans.is_empty() { + return vec![false; ranges.len()]; + } + let covered = |r: Range| { + r.is_empty() || spans.get(spans.partition_point(|s| s.end <= r.start)).is_some_and(|s| s.start <= r.start && s.end >= r.end) + }; + let return_deleted = |r: &Range| r.end < story.text.len() && covered(r.end..r.end + 1); + ranges + .iter() + .enumerate() + .map(|(i, r)| covered(r.clone()) && (return_deleted(r) || i.checked_sub(1).and_then(|p| ranges.get(p)).is_some_and(return_deleted))) + .collect() +} + /// Where a placed paragraph sits (for keep resolution). #[derive(Clone, Debug)] struct ParaInfo { diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs index 9d73562b9..8e9706ca0 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -2170,14 +2170,34 @@ impl<'a> Ex<'a> { flush_content(&mut pending, &mut out); let is_last_seg = k + 1 == nseg; // A break character that ends the paragraph was written as its `Br`. - if is_last_seg && !last && !ends_with_break { - out.push(Node::El(El::new("Br"))); - } - if !out.is_empty() || csrs.is_empty() { + let para_br = is_last_seg && !last && !ends_with_break; + if !out.is_empty() || csrs.is_empty() || para_br { let mut c = self.csr_el(&f); c.children.extend(out); csrs.push(c); } + // Tracked changes wrap the text they mark. + if let Some(kind) = change_type(f.over.change) { + for c in csrs.iter_mut().filter(|c| c.name == "CharacterStyleRange" && !c.children.is_empty()) { + let mut ch = El::new("Change").attr("Self", self.fresh()).attr("ChangeType", kind); + ch.children = std::mem::take(&mut c.children); + c.push(ch); + } + } + if para_br && let Some(c) = csrs.last_mut() { + let nl = s.runs().find(|(rr, _)| rr.contains(&r.end)).and_then(|(_, nf)| change_type(nf.over.change)); + match nl { + None => c.push(El::new("Br")), + Some(kind) => match c.children.last_mut() { + Some(Node::El(ch)) if ch.name == "Change" && ch.get("ChangeType") == Some(kind) => ch.push(El::new("Br")), + _ => { + let mut ch = El::new("Change").attr("Self", self.fresh()).attr("ChangeType", kind); + ch.push(El::new("Br")); + c.push(ch); + } + }, + } + } for c in csrs { psr.push(c); } @@ -2665,3 +2685,12 @@ fn topic_self(path: &[String]) -> String { } s } + +/// The IDML `ChangeType` of a tracked change. +fn change_type(mark: Option) -> Option<&'static str> { + match mark { + Some(designcraft_doc::ChangeMark::Deleted) => Some("DeletedText"), + Some(designcraft_doc::ChangeMark::Inserted) => Some("InsertedText"), + _ => None, + } +} diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index 46c3253b6..91f486995 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -1745,6 +1745,7 @@ impl<'r> Importer<'r> { let style = c.get("AppliedCharacterStyle").map(|r| self.char_style_ref(r)).unwrap_or_else(|| st::NO_CHAR_STYLE.into()); let mut over = pchars.clone(); over.merge(&self.char_attrs(c)); + over.change = over.change.or(cf.over.change); let ncf = CharFormat { style, over }; let bt = c.get("ParagraphBreakType").filter(|t| *t != "Anywhere"); b.last = ncf.clone(); @@ -1875,6 +1876,17 @@ impl<'r> Importer<'r> { b.push(&designcraft_doc::OBJECT_MARK.to_string(), cf); } } + "Change" => { + // Tracked changes: deleted text stays in the story, marked, and takes no + // room in the layout. + let mut ncf = cf.clone(); + match c.get("ChangeType") { + Some("DeletedText") => ncf.over.change = Some(designcraft_doc::ChangeMark::Deleted), + Some("InsertedText" | "MovedText") => ncf.over.change = Some(designcraft_doc::ChangeMark::Inserted), + _ => {} + } + self.walk_story(c, b, pf, pchars, &ncf, brk); + } "Properties" | "Note" | "TextFrame" | "StoryPreference" | "InCopyExportOption" | "TextVariableInstance" => {} _ => self.walk_story(c, b, pf, pchars, cf, brk), }, diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index 23ec99bdf..9a397b2e7 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -1454,3 +1454,55 @@ fn list_numbering_format_expression_and_bullet_import_and_round_trip() { check(&d); check(&import_idml(&export_idml(&d)).unwrap()); } + +fn tracked_story(body: &str) -> String { + format!( + r#" + + {body} +"# + ) +} + +const CSR: &str = r#""#; + +#[test] +fn tracked_deleted_text_takes_no_room_and_round_trips() { + let psr = r#""#; + let kwn = r#""#; + let tracked = tracked_story(&format!( + r#"{psr}{CSR}The first paragraph keeps a deleted word.
+{kwn}{CSR}
+{psr}{CSR}New second paragraph."# + )); + let plain = tracked_story(&format!( + r#"{psr}{CSR}The first paragraph keeps a word.
+{psr}{CSR}New second paragraph."# + )); + let lines = |d: &Document| { + let sid = *d.stories.keys().next().unwrap(); + let cs = designcraft_compose::compose_story(d, sid, &Default::default()); + cs.frames.iter().flat_map(|f| f.lines.iter().map(|l| (l.baseline, l.x0, l.end_x))).collect::>() + }; + let d = import_idml_with(&fixture_with_story(&tracked), &|_| None).unwrap(); + let story = d.stories.values().next().unwrap(); + assert_eq!(story.text, "The first paragraph keeps a deleted word.\n\nNew second paragraph."); + let marks = |s: &designcraft_doc::Story| s.runs().map(|(r, f)| (s.text[r].to_string(), f.over.change)).collect::>(); + use designcraft_doc::ChangeMark::{Deleted, Inserted}; + let want = vec![ + ("The first paragraph keeps a ".to_string(), None), + ("deleted ".to_string(), Some(Deleted)), + ("word.\n".to_string(), None), + ("\n".to_string(), Some(Deleted)), + ("New ".to_string(), Some(Inserted)), + ("second paragraph.".to_string(), None), + ]; + assert_eq!(marks(story), want); + let reference = import_idml_with(&fixture_with_story(&plain), &|_| None).unwrap(); + let expected = lines(&reference); + assert!(expected.len() >= 2); + assert_eq!(lines(&d), expected); + // Export writes the changes back as `Change` elements. + let back = import_idml(&export_idml(&d)).unwrap(); + assert_eq!(marks(back.stories.values().next().unwrap()), want); +}