IDML: tracked deleted text takes no room in the layout (#581)

This commit is contained in:
aiongg
2026-10-12 12:00:42 +00:00
parent 3384224419
commit f1a1f318ba
4 changed files with 149 additions and 6 deletions
+52 -2
View File
@@ -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, &para_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(&para_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<usize>]) -> Vec<bool> {
let mut spans: Vec<Range<usize>> = 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<usize>| {
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<usize>| 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 {
+33 -4
View File
@@ -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<designcraft_doc::ChangeMark>) -> Option<&'static str> {
match mark {
Some(designcraft_doc::ChangeMark::Deleted) => Some("DeletedText"),
Some(designcraft_doc::ChangeMark::Inserted) => Some("InsertedText"),
_ => None,
}
}
+12
View File
@@ -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),
},
+52
View File
@@ -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#"<?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">{body}</Story>
</idPkg:Story>"#
)
}
const CSR: &str = r#"<CharacterStyleRange AppliedCharacterStyle="CharacterStyle/$ID/[No character style]">"#;
#[test]
fn tracked_deleted_text_takes_no_room_and_round_trips() {
let psr = r#"<ParagraphStyleRange AppliedParagraphStyle="ParagraphStyle/$ID/NormalParagraphStyle">"#;
let kwn = r#"<ParagraphStyleRange AppliedParagraphStyle="ParagraphStyle/$ID/NormalParagraphStyle" KeepWithNext="2">"#;
let tracked = tracked_story(&format!(
r#"{psr}{CSR}<Content>The first paragraph keeps a </Content><Change Self="c1" ChangeType="DeletedText"><Content>deleted </Content></Change><Content>word.</Content><Br/></CharacterStyleRange></ParagraphStyleRange>
{kwn}{CSR}<Change Self="c2" ChangeType="DeletedText"><Br/></Change></CharacterStyleRange></ParagraphStyleRange>
{psr}{CSR}<Change Self="c3" ChangeType="InsertedText"><Content>New </Content></Change><Content>second paragraph.</Content></CharacterStyleRange></ParagraphStyleRange>"#
));
let plain = tracked_story(&format!(
r#"{psr}{CSR}<Content>The first paragraph keeps a word.</Content><Br/></CharacterStyleRange></ParagraphStyleRange>
{psr}{CSR}<Content>New second paragraph.</Content></CharacterStyleRange></ParagraphStyleRange>"#
));
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::<Vec<_>>()
};
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::<Vec<_>>();
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);
}