diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs
index 5156dbfa9..f17ec0ac5 100644
--- a/crates/compose/src/lib.rs
+++ b/crates/compose/src/lib.rs
@@ -890,12 +890,16 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
let pf = &story.paras[pi];
let (mut pp, base_chars) = doc.styles.resolve_para(pf);
// A break character that ends the previous paragraph sends this one on, like a start option.
- let after_break = pi
- .checked_sub(1)
- .and_then(|p| para_ranges.get(p))
- .and_then(|r| story.text.get(r.clone()))
- .and_then(|t| t.chars().next_back())
- .and_then(break_start);
+ // Deleted paragraphs in between don't count, nor does a deleted or hidden break.
+ let after_break = (0..pi).rev().find(|p| !deleted.get(*p).copied().unwrap_or(false)).and_then(|p| {
+ let r = para_ranges.get(p)?;
+ let (i, c) = story.text.get(r.clone())?.char_indices().next_back()?;
+ let start = break_start(c)?;
+ let (_, chars) = doc.styles.resolve_para(story.paras.get(p)?);
+ let props = doc.styles.resolve_char(&chars, story.char_format_at(r.start + i + c.len_utf8()));
+ let gone = props.change == designcraft_doc::ChangeMark::Deleted || doc.conditions_hide(&props.conditions);
+ (!gone).then_some(start)
+ });
if let Some(t) = story.para_table(pi) {
if cur.last_baseline.is_some() {
cur.start(after_break.unwrap_or(pp.start_paragraph), &cols, frames, doc);
diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs
index df7159967..852df3189 100644
--- a/crates/idml/src/import.rs
+++ b/crates/idml/src/import.rs
@@ -2040,7 +2040,9 @@ impl<'r> Importer<'r> {
"ParagraphStyleRange" => {
let style = c.get("AppliedParagraphStyle").map(|r| self.para_style_ref(r)).unwrap_or_else(|| pf.style.clone());
let para = self.para_attrs(c);
- let chars = self.char_attrs(c);
+ let mut chars = self.char_attrs(c);
+ // A paragraph range inside a tracked change is part of that change.
+ chars.change = chars.change.or(cf.over.change);
let npf = ParaFormat { style, para, chars: CharAttrs::default(), table: None };
if b.fresh
&& let Some(last) = b.paras.last_mut()
diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs
index 2f0092337..404def0f5 100644
--- a/crates/idml/src/tests.rs
+++ b/crates/idml/src/tests.rs
@@ -3290,3 +3290,86 @@ fn same_para_style_spacing_from_a_based_on_style_element_closes_the_gap() {
assert!((baseline(1) - baseline(0) - 13.0).abs() < 1e-6, "{} {}", baseline(0), baseline(1));
assert!((baseline(3) - baseline(2) - 13.0 - 4.5 - 18.0).abs() < 1e-6, "{} {}", baseline(2), baseline(3));
}
+
+/// Composed lines (baseline, left, right) of the only story.
+fn story_lines(d: &Document) -> Vec<(f64, f64, f64)> {
+ 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()
+}
+
+const PSR: &str = r#""#;
+
+/// Lays out like the same story without the deleted paragraph.
+fn assert_deleted_para_takes_no_room(deleted: &str, deleted_text: &str) {
+ let tracked = tracked_story(&format!(
+ r#"{PSR}{CSR}First live paragraph.
+{deleted}
+{PSR}{CSR}Second live paragraph."#
+ ));
+ let plain = tracked_story(&format!(
+ r#"{PSR}{CSR}First live paragraph.
+{PSR}{CSR}Second live paragraph."#
+ ));
+ let d = import_idml_with(&fixture_with_story(&tracked), &|_| None).unwrap();
+ let story = d.stories.values().next().unwrap();
+ let marks: Vec<_> = story.runs().map(|(r, f)| (story.text[r].to_string(), f.over.change)).collect();
+ let want = vec![
+ ("First live paragraph.\n".to_string(), None),
+ (deleted_text.to_string(), Some(designcraft_doc::ChangeMark::Deleted)),
+ ("Second live paragraph.".to_string(), None),
+ ];
+ assert_eq!(marks, want);
+ let expected = story_lines(&import_idml_with(&fixture_with_story(&plain), &|_| None).unwrap());
+ assert_eq!(expected.len(), 2);
+ assert_eq!(story_lines(&d), expected);
+}
+
+#[test]
+fn tracked_deleted_paragraph_range_stays_deleted() {
+ assert_deleted_para_takes_no_room(
+ &format!(
+ r#"{PSR}{CSR}Deleted text
"#
+ ),
+ "Deleted text\n",
+ );
+}
+
+#[test]
+fn tracked_deleted_frame_break_does_not_break() {
+ let fb = "\u{E003}";
+ let nf = r#""#;
+ // The change around the paragraph range.
+ assert_deleted_para_takes_no_room(
+ &format!(
+ r#"{PSR}{CSR}Gone{nf}
"#
+ ),
+ &format!("Gone{fb}\n"),
+ );
+ // The change inside the character range.
+ assert_deleted_para_takes_no_room(
+ &format!(
+ r#"{PSR}{nf}Gone
"#
+ ),
+ &format!("Gone{fb}\n"),
+ );
+}
+
+#[test]
+fn live_frame_break_before_deleted_paragraph_still_breaks() {
+ let nf = r#""#;
+ let first =
+ format!(r#"{PSR}{CSR}First live paragraph.{nf}
"#);
+ let second = format!(r#"{PSR}{CSR}Second live paragraph."#);
+ let deleted = format!(
+ r#"{PSR}{CSR}Gone
"#
+ );
+ let import = |body: String| import_idml_with(&fixture_with_story(&tracked_story(&body)), &|_| None).unwrap();
+ let reference = import(format!("{first}\n{second}"));
+ let sid = *reference.stories.keys().next().unwrap();
+ let per_frame: Vec =
+ designcraft_compose::compose_story(&reference, sid, &Default::default()).frames.iter().map(|f| f.lines.len()).collect();
+ assert!(per_frame.first() == Some(&1) && per_frame.get(1).is_some_and(|n| *n > 0), "the break sends the second paragraph on");
+ let expected = story_lines(&reference);
+ assert_eq!(story_lines(&import(format!("{first}\n{deleted}\n{second}"))), expected);
+}