From 30ba47211e0bec45d33e7328b10ecfca654d1b95 Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 12 Oct 2026 19:18:45 +0000 Subject: [PATCH] Text frames: Balance Columns evens out the last frame's columns (#609) In the frame a story ends in, Balance Columns lowers the bottom of the columns after the last explicit column break as far as the text still fits, reusing the bounded search that balances columns above spanning paragraphs. Frames the text runs on from fill normally. Text Frame Options gets a Balance Columns checkbox. --- ROADMAP.md | 1 + crates/compose/src/lib.rs | 91 +++++++++++++++++++++++++--- crates/compose/src/tests.rs | 111 ++++++++++++++++++++++++++++++++++ crates/idml/src/tests.rs | 40 ++++++++++++ crates/ui-egui/src/dialogs.rs | 11 +++- crates/ui-egui/src/i18n.rs | 1 + docs/target-app-parity.md | 1 + docs/typography-parity.md | 1 + 8 files changed, 247 insertions(+), 10 deletions(-) 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