diff --git a/ROADMAP.md b/ROADMAP.md index f826a3767..e9c469780 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -129,6 +129,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-12** · Text Frame Options › Balance Columns (`object.textFrameOptions {balanceColumns}`, IDML `VerticalBalanceColumns`) evens out the columns of the frame a story ends in, from its last column break on; frames the text runs on from fill normally (#609). No readiness percentage change. - **2026-10-12** · Orderly native exit writes the latest recovery state after finalizing current quit approvals; incomplete or stale plans keep their documents, and Undo to the saved state clears stale recovery. Unavailable or failed recovery is reported. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change. - **2026-10-12** · Recovered documents keep a checkpoint until saved, closed or reverted, including a second crash before autosave. Restarted document IDs and explicit recovery folders preserve other snapshots; failed republishing retains the original. No readiness percentage change. - **2026-10-12** · Book numbering and style updates prepare every chapter before saving. Only closed native chapters are changed; imported IDML remains a read-only style/PDF source. If a later disk write fails, the error names the completed chapters; the batch is not a whole-book transaction. No readiness percentage change. diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 3dd7c25b3..14df05cb7 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -535,7 +535,10 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C let mut limits: Rc> = Rc::default(); let mut trial: Option = None; let mut balance_runs = 0usize; - let balance_budget = 20 * story.paras.iter().filter(|p| matches!(doc.styles.resolve_para(p).0.span_columns, SpanColumns::Span(_))).count(); + let balance_frames = frames.iter().zip(&cols).filter(|(f, c)| f.opts.balance_columns && c.len() > 1).count(); + let balance_budget = 20 + * (story.paras.iter().filter(|p| matches!(doc.styles.resolve_para(p).0.span_columns, SpanColumns::Span(_))).count() + + usize::from(balance_frames > 0)); // Balancing split-column blocks that text follows. let mut split_limits: Rc> = Rc::default(); let mut split_trial: Option> = None; @@ -545,8 +548,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C // and in split columns. let mut span_paras = vec![false; np]; let mut split_paras = vec![false; np]; - 'paras: while pi < np { - let prange = para_ranges[pi].clone(); + 'paras: while pi <= np { cur.pi = pi; cur.limits = Rc::clone(&limits); cur.split_limits = Rc::clone(&split_limits); @@ -565,6 +567,19 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C pi = j; continue 'paras; } + if pi >= np { + // The story ends here: Balance Columns evens out the columns of its last frame. + if balance_frames > 0 + && let Some(j) = balance_end(&mut trial, &mut limits, &cur, &cols, frames, &out, np, balance_runs < balance_budget) + { + balance_runs += 1; + rewind(j, &snaps, ¬e_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap); + pi = j; + continue 'paras; + } + break 'paras; + } + let prange = para_ranges[pi].clone(); 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. @@ -792,7 +807,13 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C // A paragraph start or a break character; Next Column while a paragraph spans columns is // the next frame, as above. let begin_at = |cur: &mut Cursor, start: StartParagraph| match start { - StartParagraph::NextColumn => next_column(cur), + StartParagraph::NextColumn => { + let (fi, col) = (cur.fi, cur.col); + next_column(cur); + if cur.fi == fi && cur.col != col { + cur.band.tail = Some((cur.pi, cur.col)); + } + } other => cur.start(other, &cols, frames, doc), }; // Paragraph start options. @@ -1132,7 +1153,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C reference: cur.last_reference, pending: pp.space_after, }; - cur.band = Band { para: pi + 1, line0: ft.lines.len(), above: Some(above) }; + cur.band = Band { para: pi + 1, line0: ft.lines.len(), above: Some(above), tail: None }; } // Rules and shading for the paragraph. if let Some((fi, bl, asc, _)) = first_line_rect { @@ -1820,6 +1841,9 @@ struct Band { line0: usize, /// Below a spanning paragraph, its last line: every column of the band continues from it. above: Option, + /// After the band's last explicit column break: the paragraph after it and the column it + /// starts. Balance Columns evens out only the columns from there on. + tail: Option<(usize, usize)>, } #[derive(Clone, Copy, Debug)] @@ -1878,6 +1902,54 @@ fn trial_failed(trial: &mut Option>, limits: &mut Rc, + limits: &mut Rc>, + cur: &Cursor, + cols: &[Vec], + frames: &[FrameSpec], + out: &ComposedStory, + np: usize, + budget_left: bool, +) -> Option { + let fi = cur.fi; + let (para, col0) = cur.band.tail.unwrap_or((cur.band.para, 0)); + let key = (fi, para); + let active = trial.as_ref().is_some_and(|t| t.key == key && t.span == np); + if !active && let Some(t) = trial.take() { + set_limit(limits, t.key, None); + } + let columns = cols.get(fi)?; + if !frames.get(fi)?.opts.balance_columns || col0.saturating_add(1) >= columns.len() { + return None; + } + let lines: Vec<&Line> = + out.frames.get(fi)?.lines.get(cur.band.line0..).unwrap_or_default().iter().filter(|l| l.column as usize >= col0).collect(); + let bottom = lines.iter().map(|l| l.baseline + l.descent).filter(|b| b.is_finite()).max_by(f64::total_cmp)?; + if lines.len() < 2 || !(active || limit_of(limits, key).is_none() && budget_left) { + return None; + } + let top = cur.clip(*columns.get(col0)?).y0; + if !top.is_finite() || bottom <= top { + return None; + } + let mut t = trial.take().unwrap_or(Trial { key, span: np, lo: top, hi: bottom, tries: 0, done: false }); + // This layout fits: its bottom is the best so far. + t.hi = t.hi.min(bottom); + let next = if t.done { t.hi } else { t.next() }; + set_limit(limits, key, Some(next)); + if t.done { + return None; + } + *trial = Some(t); + Some(key.1) +} + fn set_limit(limits: &mut Rc>, key: K, bottom: Option) { let v = Rc::make_mut(limits); v.retain(|(k, _)| *k != key); @@ -1962,11 +2034,14 @@ impl Cursor { fn band_key(&self) -> BandKey { (self.fi, self.band.para) } - /// Column `col` of the current frame limited to the band: below the spanning paragraph above - /// it and, while balancing, above the trial bottom. + /// The current column of the frame (`col`) limited to the band: below the spanning paragraph + /// above it and, while balancing, above the trial bottom (Balance Columns limits only the + /// columns after the band's last column break). fn clip(&self, col: Rect) -> Rect { let y0 = self.band.above.map_or(col.y0, |a| a.top.max(col.y0).min(col.y1.max(col.y0))); - let y1 = limit_of(&self.limits, self.band_key()).map_or(col.y1, |b| b.min(col.y1).max(y0)); + let tail = self.band.tail.filter(|&(_, c)| self.col >= c).map(|(p, _)| (self.fi, p)); + let limit = limit_of(&self.limits, self.band_key()).or_else(|| tail.and_then(|k| limit_of(&self.limits, k))); + let y1 = limit.filter(|b| !b.is_nan()).map_or(col.y1, |b| b.min(col.y1).max(y0)); Rect::new(col.x0, y0, col.x1, y1) } /// Move on as a paragraph start option (or a break character) asks. diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index f5d0113c1..f1e13db19 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -2788,3 +2788,114 @@ fn empty_paragraphs_get_no_bullet_or_number() { assert_eq!(list_labels(&cs, LABEL_CHARS), ["1.", "", "2.", ""], "list `{name}`"); } } + +// ---------- balance columns ---------- + +/// A story of `paras` body paragraphs in one frame of `columns` columns, Balance Columns `on`. +fn balance_doc(paras: usize, columns: u32, rect: Rect, on: bool) -> (Document, StoryId, ItemId) { + let (mut d, sid, fid) = doc_with(&vec![LOREM; paras].join("\n"), rect, ParaAttrs::default()); + let o = &mut d.item_mut(fid).unwrap().text_frame_mut().unwrap().options; + o.columns = columns; + o.balance_columns = on; + (d, sid, fid) +} + +fn column_line_counts(ft: &FrameText) -> Vec { + (0..ft.columns.len() as u32).map(|c| ft.lines.iter().filter(|l| l.column == c).count()).collect() +} + +/// The baseline of each column's last line. +fn column_ends(ft: &FrameText) -> Vec { + (0..ft.columns.len() as u32).map(|c| ft.lines.iter().filter(|l| l.column == c).map(|l| l.baseline).fold(f64::NEG_INFINITY, f64::max)).collect() +} + +#[test] +fn balance_columns_evens_out_the_columns_the_story_ends_in() { + let rect = Rect::new(0.0, 0.0, 400.0, 300.0); + let (d, sid, _) = balance_doc(3, 2, rect, false); + let off = compose_story(&d, sid, &ComposeOptions::default()); + let counts = column_line_counts(&off.frames[0]); + assert!(counts[1] > 0 && counts[0] > counts[1] + 2, "fills the first column: {counts:?}"); + + let (d, sid, _) = balance_doc(3, 2, rect, true); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + assert!(!cs.is_overset()); + let ft = &cs.frames[0]; + assert_no_overlap(ft); + let on = column_line_counts(ft); + assert_eq!(on.iter().sum::(), counts.iter().sum::(), "the same lines"); + assert!(on[0] >= on[1] && on[0] - on[1] <= 1, "{on:?}"); + let ends = column_ends(ft); + assert!((ends[0] - ends[1]).abs() <= 14.4 + 1e-6, "{ends:?}"); + assert_eq!(all_lines(&cs).last().unwrap().range.end, d.story(sid).unwrap().text.len()); + + // A paragraph kept together stays in one column while the columns still balance. + let (mut d, sid, _) = balance_doc(3, 2, rect, true); + let p = &mut d.story_mut(sid).unwrap().paras[1].para; + p.keep_lines_together = Some(true); + p.keep_all_lines = Some(true); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + assert!(!cs.is_overset()); + let kept: Vec = cs.frames[0].lines.iter().filter(|l| l.para == 1).map(|l| l.column).collect(); + assert!(kept.iter().all(|&c| c == kept[0]), "{kept:?}"); + let c = column_line_counts(&cs.frames[0]); + assert!(c[1] > 0 && c[0].max(c[1]) < counts[0], "{c:?} vs {counts:?}"); + + // Three columns: the first two are equal, the last at most two lines shorter. + let (d, sid, _) = balance_doc(3, 3, Rect::new(0.0, 0.0, 540.0, 500.0), true); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + assert!(!cs.is_overset()); + let c = column_line_counts(&cs.frames[0]); + let total: usize = c.iter().sum(); + assert_eq!(c[0], total.div_ceil(3), "{c:?}"); + assert!(c[1] <= c[0] && c[2] <= c[1] && c[0] - c[2] <= 2 && c[2] > 0, "{c:?}"); + assert_no_overlap(&cs.frames[0]); + + // Vertical stories balance their columns too (bands across the turned frame). + let (mut d, sid, _) = balance_doc(2, 2, Rect::new(0.0, 0.0, 400.0, 400.0), true); + d.story_mut(sid).unwrap().vertical = true; + let cs = compose_story(&d, sid, &ComposeOptions::default()); + assert!(!cs.is_overset()); + let c = column_line_counts(&cs.frames[0]); + assert!(c[1] > 0 && c[0].abs_diff(c[1]) <= 1, "{c:?}"); +} + +#[test] +fn balance_columns_leaves_a_frame_the_text_runs_on_from() { + let rect = Rect::new(0.0, 0.0, 400.0, 150.0); + let layout = |on: bool| { + let (mut d, sid, f1) = balance_doc(4, 2, rect, on); + let lid = d.default_layer(); + let (f2, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(0.0, 300.0, 400.0, 700.0), lid, "", ParaFormat::default()).unwrap(); + let o = &mut d.item_mut(f2).unwrap().text_frame_mut().unwrap().options; + o.columns = 2; + o.balance_columns = on; + d.thread(f1, f2).unwrap(); + compose_story(&d, sid, &ComposeOptions::default()) + }; + let (off, on) = (layout(false), layout(true)); + assert!(!on.is_overset()); + let first = |cs: &ComposedStory| cs.frames[0].lines.iter().map(|l| (l.column, l.baseline, l.range.clone())).collect::>(); + assert_eq!(first(&on), first(&off), "the first frame fills normally"); + let c = column_line_counts(&on.frames[1]); + assert!(c[1] > 0 && c[0] - c[1] <= 1, "the frame the story ends in is balanced: {c:?}"); +} + +#[test] +fn balance_columns_starts_after_a_column_break() { + // The first paragraph ends with a column break: the first column holds it alone and the + // other two share the rest. + let text = format!("{LOREM}{}\n{}", designcraft_doc::story::COLUMN_BREAK, [LOREM; 3].join("\n")); + let (mut d, sid, fid) = doc_with(&text, Rect::new(0.0, 0.0, 540.0, 500.0), ParaAttrs::default()); + let o = &mut d.item_mut(fid).unwrap().text_frame_mut().unwrap().options; + o.columns = 3; + o.balance_columns = true; + let cs = compose_story(&d, sid, &ComposeOptions::default()); + assert!(!cs.is_overset()); + let ft = &cs.frames[0]; + assert_no_overlap(ft); + assert!(ft.lines.iter().filter(|l| l.column == 0).all(|l| l.para == 0)); + assert!(ft.lines.iter().filter(|l| l.para == 0).all(|l| l.column == 0)); + let c = column_line_counts(ft); + assert!(c[2] > 0 && c[1] >= c[2] && c[1] - c[2] <= 1, "{c:?}"); +} diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index 23ec99bdf..555a4ae8d 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -1454,3 +1454,43 @@ fn list_numbering_format_expression_and_bullet_import_and_round_trip() { check(&d); check(&import_idml(&export_idml(&d)).unwrap()); } + +#[test] +fn vertical_balance_columns_balances_the_imported_frame() { + let text = "Balanced columns end at about the same depth when the story ends in the frame. ".repeat(4); + let designmap = r#" + + + "#; + let spread = r#" + + + + + + + + + + + "#; + let story = format!( + r#" + {text} + "# + ); + let mut d = import_idml(&zip_files(&[("designmap.xml", designmap), ("Spreads/Spread_s.xml", spread), ("Stories/Story_s1.xml", &story)])).unwrap(); + let sid = *d.stories.keys().next().unwrap(); + let per_column = |d: &Document| { + let cs = designcraft_compose::compose_story(d, sid, &Default::default()); + assert!(!cs.is_overset()); + let ft = &cs.frames[0]; + [0, 1].map(|c| ft.lines.iter().filter(|l| l.column == c).count()) + }; + let on = per_column(&d); + assert!(on[1] > 0 && on[0] - on[1] <= 1, "balanced: {on:?}"); + let fid = d.stories[&sid].frames[0]; + d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.balance_columns = false; + let off = per_column(&d); + assert!(off[0] > off[1] + 1, "unbalanced: {off:?}"); +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 14d2552e8..ada71b19d 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -427,7 +427,7 @@ fn seed_text_frame_options(app: &DesignApp, d: &mut Dialog) { let units = app.session.active().map(|s| s.doc.settings.horizontal_units).unwrap_or(Unit::Picas); let fm = |v: f64| json!(format_measure(v, units)); let shown = json!({ - "columns": o.columns, "gutter": fm(o.gutter), + "columns": o.columns, "gutter": fm(o.gutter), "balanceColumns": o.balance_columns, "insetTop": fm(o.inset[0]), "insetLeft": fm(o.inset[1]), "insetBottom": fm(o.inset[2]), "insetRight": fm(o.inset[3]), "verticalJustification": serde_json::to_value(o.vertical_justification).unwrap_or(Value::Null), "columnRule": o.column_rule, "columnRuleWeight": format_measure(o.column_rule_weight, Unit::Points), "columnRuleColor": o.column_rule_color, @@ -459,6 +459,7 @@ fn text_frame_options_dialog(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) text_field(ui, d, "gutter", 80.0); ui.end_row(); }); + check(ui, d, "balanceColumns", tr("Balance Columns")); ui.add_space(6.0); crate::rtl::label(ui, egui::RichText::new(tr("Inset spacing")).font(semibold(12.0))); egui::Grid::new("tfo_inset").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { @@ -551,6 +552,9 @@ fn confirm_text_frame_options(app: &mut DesignApp, d: &mut Dialog) -> Result