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.
This commit is contained in:
VoiceLessQ
2026-10-28 23:08:41 -02:00
parent ca85be89d2
commit 89ccfbb18c
4 changed files with 86 additions and 16 deletions
+1 -1
View File
@@ -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.
+14 -7
View File
@@ -930,18 +930,25 @@ fn line_has_wrapping_exclusion(frame: &FrameSpec, range: Range<usize>, column: R
})
}
fn previous_visible_break(doc: &Document, story: &Story, ranges: &[Range<usize>], deleted: &[bool], pi: usize) -> Option<StartParagraph> {
/// 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<usize>], deleted: &[bool], pi: usize) -> Option<StartParagraph> {
(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<StartParagraph> {
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<usize>], deleted: &[bool]) -> Vec<Option<String>> {
let mut counters: std::collections::HashMap<String, ListCounter> = std::collections::HashMap::new();
story
+27 -8
View File
@@ -205,16 +205,15 @@ fn fit(d: &Document, sid: StoryId) -> (Option<usize>, 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, 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)
}
+44
View File
@@ -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::<String>().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);
}