From e248b631659c79b169413d2005290bc38256b9f4 Mon Sep 17 00:00:00 2001 From: VoiceLessQ Date: Wed, 28 Oct 2026 21:46:56 -0200 Subject: [PATCH 1/3] Autoflow: a frame a Start Paragraph option ends early sets no fill rate (fixes #1209) fit() counted the frame before a paragraph that starts on the next column, frame, page or odd/even page as a full frame. With a short title before such a paragraph, autoflow estimated a page per few bytes left and added up to 2000 pages before removing them again: placing 2000 words took 35.6 s in the app, now 0.36 s, with the same pages. The option is resolved through the paragraph style, so an imported Word style counts the same as a local override. --- ROADMAP.md | 1 + crates/engine/src/cmd/place_text.rs | 15 ++++++--- crates/engine/src/cmd/place_text/tests.rs | 37 +++++++++++++++++++++++ 3 files changed, 49 insertions(+), 4 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index c02176bab..20525e4a6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -380,6 +380,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-28** · Autoflow no longer sizes the rest of a story by a frame that a Start Paragraph option (next column, frame, page, or odd or even page) ends early (#1209). With a title before a chapter that starts on the next odd page, it estimated a page per few characters, added up to 2000 pages (the autoflow limit) and removed them again: placing 2000 words took 36 s in the app, now 0.4 s, with the same pages. No readiness percentage change. - **2026-10-27** · An iconic panel's flyout stays open while you work in the canvas or a text field: Auto-Collapse Iconic Panels is off by default, and preferences saved with it on start with it off. Preferences › Interface › Panels holds the setting next to the column bar's menu item. No readiness percentage change. - **2026-10-24** · Column rules run from the top line's ascent to the bottom line's descent, as InDesign draws them, instead of from the top to the bottom of the columns, on screen, in PDF and in PowerPoint export. The top and bottom insets shorten the rule from those lines. Existing documents draw their column rules at the new length. No readiness percentage change. - **2026-10-20** · Edit › Keyboard Shortcuts is craft-keymap's shared dialog: commands grouped by menu with Show and Filter, recording that asks before taking a key from another command, undo and redo, named shortcut sets (edits of Default become Custom; an imported set becomes a set of its own), an on-screen US keyboard, InDesign Show Set files reviewed before they apply, JSON export and an HTML summary. ui.json keeps the `shortcuts` map earlier versions read, for the active set. No readiness percentage change. diff --git a/crates/engine/src/cmd/place_text.rs b/crates/engine/src/cmd/place_text.rs index 4f9aaf1aa..7932a185c 100644 --- a/crates/engine/src/cmd/place_text.rs +++ b/crates/engine/src/cmd/place_text.rs @@ -3,7 +3,7 @@ //! until the whole story fits (InDesign's Shift-click with a loaded text cursor). use designcraft_doc::{ - CharacterStyle, Content, Document, ParaFormat, ParagraphStyle, SpreadRef, Story, StoryId, TextDirection, TextFileLink, TextSel, + CharacterStyle, Content, Document, ParaFormat, ParagraphStyle, SpreadRef, StartParagraph, Story, StoryId, TextDirection, TextFileLink, TextSel, }; use designcraft_geom::Rect; use serde_json::{Value, json}; @@ -199,14 +199,21 @@ pub(crate) fn reimport( /// Is the story overset in its frames, and how much text do its full frames hold (bytes, frames)? /// A frame a column, frame or page break ends early is left out: it says nothing about how much -/// a frame holds. +/// a frame holds. So is one a Start Paragraph option ends early (#1209). fn fit(d: &Document, sid: StoryId) -> (Option, usize, usize) { let cs = designcraft_compose::compose_story(d, sid, &Default::default()); - let text = d.story(sid).map_or("", |s| s.text.as_str()); + let Some(story) = d.story(sid) else { return (cs.overset_at, 0, 0) }; + let text = story.text.as_str(); + // A frame's range stops before the paragraph's return. + let moved_on = |end: usize| { + let next = if text.as_bytes().get(end) == Some(&b'\n') { end.saturating_add(1) } else { end }; + next.checked_sub(1).is_some_and(|i| text.as_bytes().get(i) == Some(&b'\n')) + && story.paras.get(story.para_at(next)).is_some_and(|p| d.styles.resolve_para(p).0.start_paragraph != StartParagraph::Anywhere) + }; let (mut held, mut full) = (0, 0); for f in &cs.frames { let Some(shown) = text.get(f.range.clone()) else { continue }; - if !shown.is_empty() && !shown.chars().any(designcraft_doc::is_break_char) { + if !shown.is_empty() && !shown.chars().any(designcraft_doc::is_break_char) && !moved_on(f.range.end) { held += shown.len(); full += 1; } diff --git a/crates/engine/src/cmd/place_text/tests.rs b/crates/engine/src/cmd/place_text/tests.rs index 006762ab2..bdce2a975 100644 --- a/crates/engine/src/cmd/place_text/tests.rs +++ b/crates/engine/src/cmd/place_text/tests.rs @@ -307,3 +307,40 @@ fn autoflow_adds_the_pages_odd_and_even_page_breaks_ask_for() { assert_eq!(designcraft_compose::compose_story(&d, sid, &Default::default()).overset_at, None, "{brk:?}"); } } + +#[test] +fn autoflow_sizes_no_frame_by_one_a_start_option_ends_early() { + // Regression (#1209): the title frame a start option ends early isn't a frame's worth of text. + use std::io::Write; + const W: &str = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main""#; + let para = |style: &str, text: &str| format!(r#"{text}"#); + let body = [para("Title", "Front"), para("Chapter", "Chapter one"), para("Body", "lorem ipsum dolor sit amet ".repeat(60).trim())].concat(); + let styles: String = ["Title", "Chapter", "Body"] + .iter() + .map(|s| format!(r#""#)) + .collect(); + let mut w = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + let o = zip::write::SimpleFileOptions::default(); + for (n, c) in [ + ("word/document.xml", format!(r#"{body}"#)), + ("word/styles.xml", format!(r#"{styles}"#)), + ] { + w.start_file(n, o).unwrap(); + w.write_all(c.as_bytes()).unwrap(); + } + let docx = w.finish().unwrap().into_inner(); + for (start, pages) in [("nextOddPage", 5), ("nextEvenPage", 4)] { + let mut s = Session::new(); + s.execute("file.new", &json!({"pages": 3, "facingPages": true})).unwrap(); + s.execute("style.paragraph.create", &json!({"name": "Chapter", "para": {"startParagraph": start}})).unwrap(); + let ids = s.doc().unwrap().doc.next_id; + let r = s + .execute("file.place", &json!({"base64": super::super::file::base64_encode(&docx), "name": "front.docx", "page": 3, "autoflow": true})) + .unwrap(); + assert_eq!(r["overset"], false, "{start}: {r}"); + let d = &s.doc().unwrap().doc; + assert_eq!(d.page_count(), pages, "{start}: {r}"); + // A few ids per page it keeps, not hundreds for pages it builds and removes. + assert!(d.next_id - ids < 100, "{start}: {} ids", d.next_id - ids); + } +} From ca85be89d2c3a4a1df225a807be9eb4fe2ab8ab7 Mon Sep 17 00:00:00 2001 From: VoiceLessQ Date: Wed, 28 Oct 2026 22:32:07 -0200 Subject: [PATCH 2/3] Autoflow: skip deleted paragraphs and ask for every forced frame at once (#1209) The start option lookup now reads the next paragraph composing places, past tracked-deleted ones. Frames that start options and breaks force are counted and asked for in one round, so a story with a paragraph per page no longer gets one frame per round. --- ROADMAP.md | 2 +- crates/compose/src/lib.rs | 4 +-- crates/engine/src/cmd/place_text.rs | 34 ++++++++++++++++------- crates/engine/src/cmd/place_text/tests.rs | 33 ++++++++++++++++++++++ 4 files changed, 60 insertions(+), 13 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 20525e4a6..deda0dc4f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -380,7 +380,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. -- **2026-10-28** · Autoflow no longer sizes the rest of a story by a frame that a Start Paragraph option (next column, frame, page, or odd or even page) ends early (#1209). With a title before a chapter that starts on the next odd page, it estimated a page per few characters, added up to 2000 pages (the autoflow limit) and removed them again: placing 2000 words took 36 s in the app, now 0.4 s, with the same pages. No readiness percentage change. +- **2026-10-28** · Autoflow no longer sizes the rest of a story by a frame that a Start Paragraph option (next column, frame, page, or odd or even page) ends early (#1209). With a title before a chapter that starts on the next odd page, it estimated a page per few characters, added up to 2000 pages (the autoflow limit) and removed them again: placing 2000 words took 36 s in the app, now 0.4 s, with the same pages. It looks past tracked-deleted paragraphs for the option, as composing does, and asks for every frame that start options and breaks force in one round: 200 one-line chapters on their own pages take 9 ms (58 ms before). No readiness percentage change. - **2026-10-27** · An iconic panel's flyout stays open while you work in the canvas or a text field: Auto-Collapse Iconic Panels is off by default, and preferences saved with it on start with it off. Preferences › Interface › Panels holds the setting next to the column bar's menu item. No readiness percentage change. - **2026-10-24** · Column rules run from the top line's ascent to the bottom line's descent, as InDesign draws them, instead of from the top to the bottom of the columns, on screen, in PDF and in PowerPoint export. The top and bottom insets shorten the rule from those lines. Existing documents draw their column rules at the new length. No readiness percentage change. - **2026-10-20** · Edit › Keyboard Shortcuts is craft-keymap's shared dialog: commands grouped by menu with Show and Filter, recording that asks before taking a key from another command, undo and redo, named shortcut sets (edits of Default become Custom; an imported set becomes a set of its own), an on-screen US keyboard, InDesign Show Set files reviewed before they apply, JSON export and an HTML summary. ui.json keeps the `shortcuts` map earlier versions read, for the active set. No readiness percentage change. diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 88becd85f..131e5273c 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -3483,8 +3483,8 @@ 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 { +/// the return before them (then they join a paragraph and add nothing to it). Composing skips them. +pub 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) { diff --git a/crates/engine/src/cmd/place_text.rs b/crates/engine/src/cmd/place_text.rs index 7932a185c..961f6f630 100644 --- a/crates/engine/src/cmd/place_text.rs +++ b/crates/engine/src/cmd/place_text.rs @@ -197,18 +197,24 @@ pub(crate) fn reimport( Ok(imp.warnings) } -/// Is the story overset in its frames, and how much text do its full frames hold (bytes, frames)? +/// Is the story overset in its frames, how much text do its full frames hold (bytes, frames), and +/// how many frames does the overset text start on its own (breaks, Start Paragraph options)? /// A frame a column, frame or page break ends early is left out: it says nothing about how much /// a frame holds. So is one a Start Paragraph option ends early (#1209). -fn fit(d: &Document, sid: StoryId) -> (Option, usize, usize) { +fn fit(d: &Document, sid: StoryId) -> (Option, usize, usize, usize) { let cs = designcraft_compose::compose_story(d, sid, &Default::default()); - let Some(story) = d.story(sid) else { return (cs.overset_at, 0, 0) }; + let Some(story) = d.story(sid) else { return (cs.overset_at, 0, 0, 0) }; let text = story.text.as_str(); - // A frame's range stops before the paragraph's return. + let deleted = designcraft_compose::deleted_paras(story, &story.para_ranges()); + let starts = |p: usize| { + !deleted.get(p).copied().unwrap_or(false) + && story.paras.get(p).is_some_and(|pf| d.styles.resolve_para(pf).0.start_paragraph != StartParagraph::Anywhere) + }; + // A frame's range stops before the paragraph's return; composing skips deleted paragraphs. let moved_on = |end: usize| { let next = if text.as_bytes().get(end) == Some(&b'\n') { end.saturating_add(1) } else { end }; next.checked_sub(1).is_some_and(|i| text.as_bytes().get(i) == Some(&b'\n')) - && story.paras.get(story.para_at(next)).is_some_and(|p| d.styles.resolve_para(p).0.start_paragraph != StartParagraph::Anywhere) + && (story.para_at(next)..story.paras.len()).find(|&p| !deleted.get(p).copied().unwrap_or(false)).is_some_and(starts) }; let (mut held, mut full) = (0, 0); for f in &cs.frames { @@ -218,7 +224,14 @@ fn fit(d: &Document, sid: StoryId) -> (Option, usize, usize) { full += 1; } } - (cs.overset_at, held, full) + let forced = cs.overset_at.map_or(0, |at| { + let first = story.para_at(at); + // The paragraph the overset text starts with counts when it starts there. + let from = if at.checked_sub(1).is_none_or(|i| text.as_bytes().get(i) == Some(&b'\n')) { first } else { first.saturating_add(1) }; + let breaks = text.get(at..).map_or(0, |t| t.chars().filter(|&c| designcraft_doc::is_break_char(c)).count()); + (from..story.paras.len()).filter(|&p| starts(p)).count().saturating_add(breaks) + }); + (cs.overset_at, held, full, forced) } /// The frames autoflow sets on a page: one per column inside the margins, in the order a story @@ -263,12 +276,13 @@ pub(crate) fn autoflow(d: &mut Document, sid: StoryId, max_pages: usize, cmd: &s // The next page of the document autoflow may set frames on; it only moves forward. let mut following = 0; loop { - let (overset, held, full) = fit(d, sid); + let (overset, held, full, forced) = fit(d, sid); let Some(at) = overset else { break }; let len = d.story(sid).map_or(0, |s| s.len()); - // Frames still needed at the rate the full ones are filled (at least one); with no full - // frame to go by yet, one more. - let want = held.checked_div(full).map_or(1, |per_frame| len.saturating_sub(at).div_ceil(per_frame.max(1))).max(1); + // Frames still needed at the rate the full ones are filled, plus one per frame the text + // starts on its own (at least one); with neither to go by yet, one more. + let rate = held.checked_div(full).map_or(0, |per_frame| len.saturating_sub(at).div_ceil(per_frame.max(1))); + let want = rate.saturating_add(forced).max(1); let last = *d.story(sid).and_then(|s| s.frames.last()).ok_or_else(|| bad(cmd, "the story has no frame"))?; let page = d.page_of_item(last).unwrap_or(d.page_count().saturating_sub(1)); let layer = d.default_layer(); diff --git a/crates/engine/src/cmd/place_text/tests.rs b/crates/engine/src/cmd/place_text/tests.rs index bdce2a975..183a46074 100644 --- a/crates/engine/src/cmd/place_text/tests.rs +++ b/crates/engine/src/cmd/place_text/tests.rs @@ -344,3 +344,36 @@ fn autoflow_sizes_no_frame_by_one_a_start_option_ends_early() { assert!(d.next_id - ids < 100, "{start}: {} ids", d.next_id - ids); } } + +#[test] +fn autoflow_looks_past_a_deleted_paragraph_for_the_start_option() { + // The title frame ends before a tracked deletion; the chapter after it starts on an odd page. + use designcraft_doc::{ChangeMark, build::NewDocument}; + let mut d = Document::new(&NewDocument { pages: 1, facing_pages: false, primary_text_frame: true, ..Default::default() }); + let sid = d.settings.primary_story.unwrap(); + let st = d.story_mut(sid).unwrap(); + st.insert(0, &format!("Front\nGone\nChapter one\n{}", "lorem ipsum dolor sit amet ".repeat(400).trim())); + st.paras[2].para.start_paragraph = Some(StartParagraph::NextOddPage); + st.format_chars(6..11, |f| f.over.change = Some(ChangeMark::Deleted)); + let ids = d.next_id; + assert_eq!(autoflow(&mut d, sid, 2000, "file.place", false).unwrap(), 4); + assert_eq!(designcraft_compose::compose_story(&d, sid, &Default::default()).overset_at, None); + assert!(d.next_id - ids < 100, "{} ids", d.next_id - ids); +} + +#[test] +fn autoflow_asks_for_every_frame_the_start_options_ask_for_at_once() { + // A paragraph per page: no frame sets a fill rate, yet one round shouldn't add one frame. + use designcraft_doc::build::NewDocument; + let mut d = Document::new(&NewDocument { pages: 1, facing_pages: false, primary_text_frame: true, ..Default::default() }); + let sid = d.settings.primary_story.unwrap(); + let st = d.story_mut(sid).unwrap(); + st.insert(0, (0..200).map(|i| format!("Chapter {i}\n")).collect::().trim_end()); + for p in &mut st.paras { + p.para.start_paragraph = Some(StartParagraph::NextPage); + } + let ids = d.next_id; + assert_eq!(autoflow(&mut d, sid, 2000, "file.place", false).unwrap(), 199); + assert_eq!(designcraft_compose::compose_story(&d, sid, &Default::default()).overset_at, None); + assert!(d.next_id - ids < 2000, "{} ids", d.next_id - ids); +} From 89ccfbb18c9e21e0bc4a222f2dfb051019e5a123 Mon Sep 17 00:00:00 2001 From: VoiceLessQ Date: Wed, 28 Oct 2026 23:08:41 -0200 Subject: [PATCH 3/3] Autoflow: count each forced move once, and only visible breaks (#1209) fit() counted every break character in the overset text and every Start Paragraph option. A break followed by a paragraph whose option it already meets moved the text once but asked for two frames, and breaks that tracked changes delete or a hidden condition hides asked for frames that composing never uses. Autoflow added those pages and removed them again: 200 chapters that end in page breaks and start on the next page took 61095 ids and 430 ms, now 798 ids and 16 ms. Each paragraph composing places now counts once, when a visible break ends the paragraph before it or its start option sends it on; after a break it is at the top of a frame, where the option only fixes odd or even page parity. A visible break inside a paragraph counts once more. previous_visible_break and the new visible_break in designcraft-compose are public, so the estimate and the composer use one rule. --- ROADMAP.md | 2 +- crates/compose/src/lib.rs | 21 +++++++---- crates/engine/src/cmd/place_text.rs | 35 +++++++++++++----- crates/engine/src/cmd/place_text/tests.rs | 44 +++++++++++++++++++++++ 4 files changed, 86 insertions(+), 16 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index deda0dc4f..435dd5d39 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -380,7 +380,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. -- **2026-10-28** · Autoflow no longer sizes the rest of a story by a frame that a Start Paragraph option (next column, frame, page, or odd or even page) ends early (#1209). With a title before a chapter that starts on the next odd page, it estimated a page per few characters, added up to 2000 pages (the autoflow limit) and removed them again: placing 2000 words took 36 s in the app, now 0.4 s, with the same pages. It looks past tracked-deleted paragraphs for the option, as composing does, and asks for every frame that start options and breaks force in one round: 200 one-line chapters on their own pages take 9 ms (58 ms before). No readiness percentage change. +- **2026-10-28** · Autoflow no longer sizes the rest of a story by a frame that a Start Paragraph option (next column, frame, page, or odd or even page) ends early (#1209). With a title before a chapter that starts on the next odd page, it estimated a page per few characters, added up to 2000 pages (the autoflow limit) and removed them again: placing 2000 words took 36 s in the app, now 0.4 s, with the same pages. It looks past tracked-deleted paragraphs for the option, as composing does, and asks for every frame that start options and breaks force in one round: 200 one-line chapters on their own pages take 9 ms (58 ms before). A break and the start option it meets count once, and breaks that tracked changes delete or a hidden condition hides don't count. No readiness percentage change. - **2026-10-27** · An iconic panel's flyout stays open while you work in the canvas or a text field: Auto-Collapse Iconic Panels is off by default, and preferences saved with it on start with it off. Preferences › Interface › Panels holds the setting next to the column bar's menu item. No readiness percentage change. - **2026-10-24** · Column rules run from the top line's ascent to the bottom line's descent, as InDesign draws them, instead of from the top to the bottom of the columns, on screen, in PDF and in PowerPoint export. The top and bottom insets shorten the rule from those lines. Existing documents draw their column rules at the new length. No readiness percentage change. - **2026-10-20** · Edit › Keyboard Shortcuts is craft-keymap's shared dialog: commands grouped by menu with Show and Filter, recording that asks before taking a key from another command, undo and redo, named shortcut sets (edits of Default become Custom; an imported set becomes a set of its own), an on-screen US keyboard, InDesign Show Set files reviewed before they apply, JSON export and an HTML summary. ui.json keeps the `shortcuts` map earlier versions read, for the active set. No readiness percentage change. diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 131e5273c..177733f99 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -930,18 +930,25 @@ fn line_has_wrapping_exclusion(frame: &FrameSpec, range: Range, column: R }) } -fn previous_visible_break(doc: &Document, story: &Story, ranges: &[Range], deleted: &[bool], pi: usize) -> Option { +/// The visible break ending the last placed paragraph before `pi`, which sends `pi` on. +pub fn previous_visible_break(doc: &Document, story: &Story, ranges: &[Range], deleted: &[bool], pi: usize) -> Option { (0..pi).rev().find(|&p| !deleted.get(p).copied().unwrap_or(false)).and_then(|p| { let range = ranges.get(p)?; - let (index, character) = story.text.get(range.clone())?.char_indices().next_back()?; - let start = break_start(character)?; - let (_, chars) = doc.styles.resolve_para(story.paras.get(p)?); - let props = doc.styles.resolve_char(&chars, story.char_format_at(range.start + index + character.len_utf8())); - let gone = props.change == designcraft_doc::ChangeMark::Deleted || doc.conditions_hide(&props.conditions); - (!gone).then_some(start) + let (index, _) = story.text.get(range.clone())?.char_indices().next_back()?; + visible_break(doc, story, p, range.start + index) }) } +/// The start option of the break at byte `at` in paragraph `p`, unless deleted or hidden. +pub fn visible_break(doc: &Document, story: &Story, p: usize, at: usize) -> Option { + let character = story.text.get(at..)?.chars().next()?; + let start = break_start(character)?; + let (_, chars) = doc.styles.resolve_para(story.paras.get(p)?); + let props = doc.styles.resolve_char(&chars, story.char_format_at(at + character.len_utf8())); + let gone = props.change == designcraft_doc::ChangeMark::Deleted || doc.conditions_hide(&props.conditions); + (!gone).then_some(start) +} + fn named_list_labels(doc: &Document, story: &Story, ranges: &[Range], deleted: &[bool]) -> Vec> { let mut counters: std::collections::HashMap = std::collections::HashMap::new(); story diff --git a/crates/engine/src/cmd/place_text.rs b/crates/engine/src/cmd/place_text.rs index 961f6f630..852db171e 100644 --- a/crates/engine/src/cmd/place_text.rs +++ b/crates/engine/src/cmd/place_text.rs @@ -205,16 +205,15 @@ fn fit(d: &Document, sid: StoryId) -> (Option, usize, usize, usize) { let cs = designcraft_compose::compose_story(d, sid, &Default::default()); let Some(story) = d.story(sid) else { return (cs.overset_at, 0, 0, 0) }; let text = story.text.as_str(); - let deleted = designcraft_compose::deleted_paras(story, &story.para_ranges()); - let starts = |p: usize| { - !deleted.get(p).copied().unwrap_or(false) - && story.paras.get(p).is_some_and(|pf| d.styles.resolve_para(pf).0.start_paragraph != StartParagraph::Anywhere) - }; + let ranges = story.para_ranges(); + let deleted = designcraft_compose::deleted_paras(story, &ranges); + let placed = |p: usize| !deleted.get(p).copied().unwrap_or(false); + let starts = |p: usize| placed(p) && story.paras.get(p).is_some_and(|pf| d.styles.resolve_para(pf).0.start_paragraph != StartParagraph::Anywhere); // A frame's range stops before the paragraph's return; composing skips deleted paragraphs. let moved_on = |end: usize| { let next = if text.as_bytes().get(end) == Some(&b'\n') { end.saturating_add(1) } else { end }; next.checked_sub(1).is_some_and(|i| text.as_bytes().get(i) == Some(&b'\n')) - && (story.para_at(next)..story.paras.len()).find(|&p| !deleted.get(p).copied().unwrap_or(false)).is_some_and(starts) + && (story.para_at(next)..story.paras.len()).find(|&p| placed(p)).is_some_and(starts) }; let (mut held, mut full) = (0, 0); for f in &cs.frames { @@ -228,8 +227,28 @@ fn fit(d: &Document, sid: StoryId) -> (Option, usize, usize, usize) { let first = story.para_at(at); // The paragraph the overset text starts with counts when it starts there. let from = if at.checked_sub(1).is_none_or(|i| text.as_bytes().get(i) == Some(&b'\n')) { first } else { first.saturating_add(1) }; - let breaks = text.get(at..).map_or(0, |t| t.chars().filter(|&c| designcraft_doc::is_break_char(c)).count()); - (from..story.paras.len()).filter(|&p| starts(p)).count().saturating_add(breaks) + // Once per paragraph: after a break, its start option only fixes odd or even page parity. + let sent = (from..ranges.len()) + .filter(|&p| starts(p) || (placed(p) && designcraft_compose::previous_visible_break(d, story, &ranges, &deleted, p).is_some())) + .count(); + // A break inside a paragraph moves the rest of it. + let inside: usize = (first..ranges.len()) + .filter(|&p| placed(p)) + .filter_map(|p| Some((p, ranges.get(p)?.clone()))) + .map(|(p, r)| { + let Some(t) = text.get(r.clone()) else { return 0 }; + let last = t.char_indices().next_back().map(|(i, _)| i); + t.char_indices() + .filter(|&(i, c)| { + designcraft_doc::is_break_char(c) + && Some(i) != last + && r.start.saturating_add(i) >= at + && designcraft_compose::visible_break(d, story, p, r.start.saturating_add(i)).is_some() + }) + .count() + }) + .sum(); + sent.saturating_add(inside) }); (cs.overset_at, held, full, forced) } diff --git a/crates/engine/src/cmd/place_text/tests.rs b/crates/engine/src/cmd/place_text/tests.rs index 183a46074..8275e7ab2 100644 --- a/crates/engine/src/cmd/place_text/tests.rs +++ b/crates/engine/src/cmd/place_text/tests.rs @@ -377,3 +377,47 @@ fn autoflow_asks_for_every_frame_the_start_options_ask_for_at_once() { assert_eq!(designcraft_compose::compose_story(&d, sid, &Default::default()).overset_at, None); assert!(d.next_id - ids < 2000, "{} ids", d.next_id - ids); } + +#[test] +fn autoflow_asks_no_frame_for_a_deleted_or_hidden_break() { + // Composing skips breaks in the overset text that tracked changes delete or a condition hides. + use designcraft_doc::{ChangeMark, Condition, PAGE_BREAK, build::NewDocument}; + for hide in [false, true] { + let mut d = Document::new(&NewDocument { pages: 1, facing_pages: false, primary_text_frame: true, ..Default::default() }); + d.conditions.push(Condition { name: "Hidden".into(), color: [0, 0, 0], visible: false }); + let sid = d.settings.primary_story.unwrap(); + let st = d.story_mut(sid).unwrap(); + let body = "lorem ipsum dolor sit amet ".repeat(300); + let gone = format!("x{PAGE_BREAK}\n").repeat(200); + st.insert(0, &format!("{}\n{gone}{}", body.trim(), body.trim())); + let at = body.trim().len() + 1; + if hide { + for (i, _) in gone.match_indices(PAGE_BREAK) { + st.format_chars(at + i..at + i + PAGE_BREAK.len_utf8(), |f| f.over.conditions = Some(vec!["Hidden".into()])); + } + } else { + st.format_chars(at..at + gone.len(), |f| f.over.change = Some(ChangeMark::Deleted)); + } + let ids = d.next_id; + autoflow(&mut d, sid, 2000, "file.place", false).unwrap(); + assert_eq!(designcraft_compose::compose_story(&d, sid, &Default::default()).overset_at, None, "hide {hide}"); + assert!(d.next_id - ids < 100, "hide {hide}: {} pages, {} ids", d.page_count(), d.next_id - ids); + } +} + +#[test] +fn autoflow_counts_a_break_and_the_start_option_it_meets_once() { + // Each chapter ends in a page break and starts on the next page: one new page each, not two. + use designcraft_doc::{PAGE_BREAK, build::NewDocument}; + let mut d = Document::new(&NewDocument { pages: 1, facing_pages: false, primary_text_frame: true, ..Default::default() }); + let sid = d.settings.primary_story.unwrap(); + let st = d.story_mut(sid).unwrap(); + st.insert(0, (0..200).map(|i| format!("Chapter {i}{PAGE_BREAK}\n")).collect::().trim_end()); + for p in &mut st.paras { + p.para.start_paragraph = Some(StartParagraph::NextPage); + } + let ids = d.next_id; + assert_eq!(autoflow(&mut d, sid, 2000, "file.place", false).unwrap(), 199); + assert_eq!(designcraft_compose::compose_story(&d, sid, &Default::default()).overset_at, None); + assert!(d.next_id - ids < 2000, "{} ids", d.next_id - ids); +}