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); +}