From b744953d70f795c2ae48de497d17cf0d23ed9460 Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Sat, 10 Oct 2026 01:51:21 -0400 Subject: [PATCH] Span columns: text after a spanning paragraph flows below it (#276) A spanning paragraph closes the band of columns above it. Those columns are balanced first and the paragraph sits below the deepest of them; the columns after it start below the paragraph, so text that moves to the next column no longer restarts at the frame top over the heading. A spanning paragraph that doesn't fit moves to the next frame. Span counts below the column count set the paragraph across the first columns only. --- crates/compose/src/lib.rs | 318 ++++++++++++++++++++++++++++++++---- crates/compose/src/table.rs | 2 +- crates/compose/src/tests.rs | 156 ++++++++++++++++++ 3 files changed, 439 insertions(+), 37 deletions(-) diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index b67bb1e..5283487 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -24,6 +24,7 @@ pub mod xref; use std::collections::HashMap; use std::ops::Range; +use std::rc::Rc; use std::sync::{Arc, Mutex}; use designcraft_doc::{ @@ -470,7 +471,17 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C .zip(&cols) .map(|(f, columns)| FrameText { frame: f.id, vertical: f.vertical, columns: columns.clone(), ..Default::default() }) .collect(); - let mut cur = Cursor { fi: 0, col: 0, last_baseline: None, last_descent: 0.0, last_reference: 0.0, pending: 0.0 }; + let mut cur = Cursor { + fi: 0, + col: 0, + last_baseline: None, + last_descent: 0.0, + last_reference: 0.0, + pending: 0.0, + pi: 0, + band: Band::default(), + limits: Rc::default(), + }; let para_ranges = story.para_ranges(); let np = para_ranges.len(); let hyph_exceptions = doc.hyphenation_exception_map(); @@ -504,14 +515,33 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C let mut notes = Notes::new(doc, story); let mut note_snaps: Vec<(u32, Option, usize)> = Vec::with_capacity(np); let mut var_cache: std::collections::HashMap, std::sync::Arc>> = Default::default(); + // Column balancing above spanning paragraphs: settled and trial bottoms by band, the search + // in progress, and a budget for the re-lays. + 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(); + // Paragraphs set across columns (vertical justification moves their frames' lines together). + let mut span_paras = vec![false; np]; 'paras: while pi < np { let prange = para_ranges[pi].clone(); + cur.pi = pi; + cur.limits = Rc::clone(&limits); let snap = Snapshot::take(&out, &cur, list_counter); snaps.truncate(pi); snaps.push(snap); note_snaps.truncate(pi); note_snaps.push((notes.num, notes.key, notes.placed.len())); info.truncate(pi); + // A balancing trial whose band ran past its frame: re-lay the band with more room. + if trial.as_ref().is_some_and(|t| cur.fi > t.key.0) + && let Some(j) = trial_failed(&mut trial, &mut limits, 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; + } let pf = &story.paras[pi]; let (pp, base_chars) = doc.styles.resolve_para(pf); if let Some(t) = story.para_table(pi) { @@ -528,6 +558,12 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C let start_at = (cur.fi, cur.col); let anchor = prange.start + story.text[prange.clone()].find(story::TABLE_ANCHOR).unwrap_or(0); if !table::place_table(doc, t, anchor, pi, &pp, frames, &cols, &mut cur, &mut out, opts) { + if let Some(j) = trial_failed(&mut trial, &mut limits, 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; + } out.overset_at = Some(prange.start); break 'paras; } @@ -611,7 +647,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C // Nested line styles: find where the first lines end in this column, restyle them, and // look again (the style changes the widths) until the lines settle. let cols_here = &cols[cur.fi]; - let col = cols_here[cur.col.min(cols_here.len() - 1)]; + let col = span_rect(cols_here, cols_here[cur.col.min(cols_here.len() - 1)], pp.span_columns); let spacing = spacing_for(&pp, base_chars.size); let mut lines: Vec<(std::ops::Range, String)> = Vec::new(); for _ in 0..3 { @@ -702,17 +738,63 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C designcraft_doc::Leading::Points(v) => v, }; let spacing = spacing_for(&pp, base_size); + // A paragraph set across the columns moves on to the next frame where others move on to + // the next column. + let spanning = matches!(pp.span_columns, SpanColumns::Span(n) if n != 1); + span_paras[pi] = spanning; + let next_column = |cur: &mut Cursor| if spanning { cur.next_frame() } else { cur.next_column(&cols) }; // Paragraph start options. if force_col[pi] { - cur.next_column(&cols); + next_column(&mut cur); } if cur.last_baseline.is_some() { match pp.start_paragraph { - StartParagraph::NextColumn => cur.next_column(&cols), + StartParagraph::NextColumn => next_column(&mut cur), StartParagraph::NextFrame | StartParagraph::NextPage | StartParagraph::NextOddPage | StartParagraph::NextEvenPage => cur.next_frame(), StartParagraph::Anywhere => {} } } + if spanning && cur.fi < frames.len() { + // Close the band: the paragraph goes below the deepest of its columns, which are + // balanced first. + let key = cur.band_key(); + let deepest = out.frames[cur.fi] + .lines + .get(cur.band.line0..) + .unwrap_or_default() + .iter() + .map(|l| (l.baseline, l.descent)) + .max_by(|a, b| (a.0 + a.1).total_cmp(&(b.0 + b.1))); + if let Some((baseline, descent)) = deepest { + let bottom = baseline + descent; + let multi = cols[cur.fi].len() > 1; + let active = trial.as_ref().is_some_and(|t| t.key == key && t.span == pi); + if !active && let Some(t) = trial.take() { + set_limit(&mut limits, t.key, None); + } + if multi && (active || limit_of(&limits, key).is_none() && balance_runs < balance_budget) { + let top = cur.clip(cols[cur.fi][0]).y0; + let mut t = trial.take().unwrap_or(Trial { key, span: pi, 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(&mut limits, key, Some(next)); + if !t.done { + trial = Some(t); + balance_runs += 1; + rewind(key.1, &snaps, ¬e_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap); + pi = key.1; + continue 'paras; + } + } + cur.col = 0; + cur.last_baseline = Some(baseline); + cur.last_descent = descent; + } else if cur.col != 0 { + cur.col = 0; + cur.resume(); + } + } if cur.last_baseline.is_some() { cur.pending += pp.space_before; } @@ -727,21 +809,22 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C let mut first_line_rect: Option<(usize, f64, f64, f64)> = None; // frame, baseline, ascent, x-span loop { if cur.fi >= frames.len() { + if let Some(j) = trial_failed(&mut trial, &mut limits, 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; + } out.overset_at = Some(glyphs.get(g0).map(|g| g.byte).unwrap_or(prange.start)); // The part of the paragraph that fits keeps its shading and border. para_box_decos(&mut out, pi, &pp); break 'paras; } let f = &frames[cur.fi]; - let col = cols[cur.fi][cur.col.min(cols[cur.fi].len() - 1)]; - let col = match pp.span_columns { - SpanColumns::Span(n) if cur.col == 0 => { - let n = if n == 0 { cols[cur.fi].len() } else { (n as usize).min(cols[cur.fi].len()) }; - let other = cols[cur.fi][n - 1]; - Rect::new(col.x0.min(other.x0), col.y0, col.x1.max(other.x1), col.y1) - } - _ => col, - }; + let base = cols[cur.fi][cur.col.min(cols[cur.fi].len() - 1)]; + let col = cur.clip(base); + // A spanning paragraph isn't held to the balanced bottom of the columns above it. + let col = if spanning { span_rect(&cols[cur.fi], Rect::new(col.x0, col.y0, col.x1, base.y1), pp.span_columns) } else { col }; // Estimate slots for the breaker with the paragraph's base leading. let est_first = cur.next_baseline(f, col, base_leading, base_chars.size * 0.75, &pp); let slots = estimate_slots(f, col, est_first, base_leading, base_chars.size, &glyphs[g0..], &pp, line_no); @@ -817,7 +900,11 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C remaining: breaks.len() - k, here: (cur.fi, cur.col), }; - let action = if restores < 4 * np + 64 { keep_violation(&ctx, &pp, &info, &force_col, &line_cap) } else { None }; + let action = if restores < (4 * np + 64) * (balance_runs + 1) { + keep_violation(&ctx, &pp, &info, &force_col, &line_cap) + } else { + None + }; if let Some(action) = action { restores += 1; let j = match action { @@ -830,20 +917,14 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C j } }; - for k in j + 1..np { - force_col[k] = false; - line_cap[k] = None; - } - snaps[j].restore(&mut out, &mut cur, &mut list_counter); - (notes.num, notes.key) = (note_snaps[j].0, note_snaps[j].1); - notes.placed.truncate(note_snaps[j].2); + rewind(j, &snaps, ¬e_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap); pi = j; continue 'paras; } } // Next column / frame; re-break the rest of the paragraph there. g0 = s; - cur.next_column(&cols); + next_column(&mut cur); col_first_line = line_no; moved = true; break; @@ -905,7 +986,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C let brk = glyphs.get(g0 + b.next.saturating_sub(1)).map(|g| g.ch); let jumped = match brk { Some(story::COLUMN_BREAK) => { - cur.next_column(&cols); + next_column(&mut cur); true } Some(story::FRAME_BREAK) | Some(story::PAGE_BREAK) => { @@ -941,6 +1022,20 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C keep_first: pp.keep_first as usize, keep_last: pp.keep_last as usize, }); + // The columns below a spanning paragraph start under it. + if spanning + && let Some(ft) = out.frames.get(cur.fi) + && let Some(l) = ft.lines.last().filter(|l| l.para == pi) + { + let above = Above { + top: l.baseline + l.descent, + baseline: l.baseline, + descent: l.descent, + reference: cur.last_reference, + pending: pp.space_after, + }; + cur.band = Band { para: pi + 1, line0: ft.lines.len(), above: Some(above) }; + } // Rules and shading for the paragraph. if let Some((fi, bl, asc, _)) = first_line_rect { let ft = &mut out.frames[fi]; @@ -980,7 +1075,8 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C ft.range = p..p; } let before: Vec = ft.tables.iter().map(|t| ft.lines.get(t.line).map_or(0.0, |l| l.baseline)).collect(); - vertical_justify(ft, f); + let spanned = ft.columns.len() > 1 && ft.lines.iter().any(|l| span_paras.get(l.para) == Some(&true)); + vertical_justify(ft, f, spanned); for (t, b) in ft.tables.iter_mut().zip(before) { let now = ft.lines.get(t.line).map_or(b, |l| l.baseline); t.shift(now - b); @@ -1049,6 +1145,14 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C out } +/// The columns a spanning paragraph is set across, from `col`'s top to its bottom. +fn span_rect(cols: &[Rect], col: Rect, span: SpanColumns) -> Rect { + let SpanColumns::Span(n) = span else { return col }; + let n = if n == 0 { cols.len() } else { (n as usize).min(cols.len()) }; + let (Some(a), Some(b)) = (cols.first(), n.checked_sub(1).and_then(|i| cols.get(i))) else { return col }; + Rect::new(a.x0.min(b.x0), col.y0, a.x1.max(b.x1), col.y1) +} + /// Flag the lines of paragraphs whose Keep Options the layout couldn't honour: lines kept /// together that split, too few lines at the bottom (orphans) or top (widows) of a column, or a /// keep-with-next paragraph separated from the next one. @@ -1244,6 +1348,31 @@ impl Snapshot { } } +/// Re-lay from paragraph `j`: back to its snapshot and footnote numbering, with fresh keep +/// decisions for the paragraphs after it. +#[allow(clippy::too_many_arguments)] +fn rewind( + j: usize, + snaps: &[Snapshot], + note_snaps: &[(u32, Option, usize)], + notes: &mut Notes, + out: &mut ComposedStory, + cur: &mut Cursor, + list_counter: &mut u32, + force_col: &mut [bool], + line_cap: &mut [Option], +) { + force_col.iter_mut().skip(j + 1).for_each(|f| *f = false); + line_cap.iter_mut().skip(j + 1).for_each(|c| *c = None); + if let Some(s) = snaps.get(j) { + s.restore(out, cur, list_counter); + } + if let Some(&(num, key, placed)) = note_snaps.get(j) { + (notes.num, notes.key) = (num, key); + notes.placed.truncate(placed); + } +} + /// Where a placed paragraph sits (for keep resolution). #[derive(Clone, Debug)] struct ParaInfo { @@ -1364,23 +1493,136 @@ struct Cursor { last_reference: f64, /// Space before/after waiting to be added to the next line. pending: f64, + /// The paragraph being laid. + pi: usize, + /// The band of columns the cursor is in. + band: Band, + /// Column bottoms that balance bands, by band key (kept by the compose loop). + limits: Rc>, +} + +/// A band's frame and first paragraph. +type BandKey = (usize, usize); + +/// A run of the frame's columns: the whole frame, or the part between spanning paragraphs. +/// Text fills its columns one after the other; a spanning paragraph closes it. +#[derive(Clone, Copy, Debug, Default)] +struct Band { + /// First paragraph laid (at least in part) in the band: balancing re-lays from there. + para: usize, + /// Lines of the frame above the band. + line0: usize, + /// Below a spanning paragraph, its last line: every column of the band continues from it. + above: Option, +} + +#[derive(Clone, Copy, Debug)] +struct Above { + /// Bottom of the spanning paragraph's last line (the band's top). + top: f64, + baseline: f64, + descent: f64, + reference: f64, + /// The spanning paragraph's space after. + pending: f64, +} + +/// Balancing the columns above a spanning paragraph: a search for the lowest column bottom at +/// which the band's text still fits its frame, re-laying the band at each trial bottom. +#[derive(Debug)] +struct Trial { + key: BandKey, + /// The spanning paragraph that closes the band. + span: usize, + /// Highest bottom known not to fit / lowest known to fit. + lo: f64, + hi: f64, + tries: u32, + /// The band is laid at `hi` for good. + done: bool, +} + +impl Trial { + /// The next bottom to try: half way, or `hi` once settled. + fn next(&mut self) -> f64 { + self.tries += 1; + if self.hi - self.lo > 0.5 && self.tries < 16 { + (self.lo + self.hi) / 2.0 + } else { + self.done = true; + self.hi + } + } +} + +/// A balancing trial whose band didn't fit its frame: records the next bottom to try in +/// `limits` (or, when even the settled bottom fails or the budget is spent, leaves the band +/// unbalanced) and returns the paragraph to re-lay from. +fn trial_failed(trial: &mut Option, limits: &mut Rc>, budget_left: bool) -> Option { + let t = trial.as_mut()?; + let key = t.key; + let bottom = if t.done || !budget_left { + *trial = None; + f64::INFINITY + } else { + t.lo = limit_of(limits, key).unwrap_or(t.lo); + t.next() + }; + set_limit(limits, key, Some(bottom)); + Some(key.1) +} + +fn set_limit(limits: &mut Rc>, key: BandKey, bottom: Option) { + let v = Rc::make_mut(limits); + v.retain(|(k, _)| *k != key); + if let Some(b) = bottom { + v.push((key, b)); + } +} + +fn limit_of(limits: &[(BandKey, f64)], key: BandKey) -> Option { + limits.iter().find(|(k, _)| *k == key).map(|&(_, b)| b) } impl Cursor { fn next_column(&mut self, cols: &[Vec]) { self.col += 1; if self.fi < cols.len() && self.col >= cols[self.fi].len() { - self.col = 0; - self.fi += 1; + self.next_frame(); + } else { + self.resume(); } - self.last_baseline = None; - self.pending = 0.0; } fn next_frame(&mut self) { self.fi += 1; self.col = 0; - self.last_baseline = None; - self.pending = 0.0; + self.band = Band { para: self.pi, ..Band::default() }; + self.resume(); + } + /// Start the current column at the top of the band. + fn resume(&mut self) { + match self.band.above { + Some(a) => { + self.last_baseline = Some(a.baseline); + self.last_descent = a.descent; + self.last_reference = a.reference; + self.pending = a.pending; + } + None => { + self.last_baseline = None; + self.pending = 0.0; + } + } + } + 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. + 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)); + Rect::new(col.x0, y0, col.x1, y1) } /// Baseline for the next line with leading `lead` and ascent `asc`. fn next_baseline(&self, f: &FrameSpec, col: Rect, lead: f64, asc: f64, _pp: &ParaProps) -> f64 { @@ -2122,16 +2364,20 @@ fn hyphenation_points(text: &str, glyphs: &[Glyph], pp: &ParaProps, exceptions: out } -fn vertical_justify(ft: &mut FrameText, f: &FrameSpec) { +/// `spanned`: a paragraph spans the frame's columns, so the lines move together. +fn vertical_justify(ft: &mut FrameText, f: &FrameSpec, spanned: bool) { if ft.lines.is_empty() || f.opts.vertical_justification == VerticalJustification::Top { return; } - // Per column. - let ncols = ft.columns.len().max(1) as u32; + if spanned && f.opts.vertical_justification == VerticalJustification::Justify { + return; + } + // Per column (or all lines together). + let ncols = if spanned { 1 } else { ft.columns.len().max(1) as u32 }; for c in 0..ncols { - let idx: Vec = ft.lines.iter().enumerate().filter(|(_, l)| l.column == c).map(|(i, _)| i).collect(); - let (Some(&a), Some(&z)) = (idx.first(), idx.last()) else { continue }; - let bottom = ft.lines[z].baseline + ft.lines[z].descent; + let idx: Vec = ft.lines.iter().enumerate().filter(|(_, l)| spanned || l.column == c).map(|(i, _)| i).collect(); + let (Some(&a), Some(_)) = (idx.first(), idx.last()) else { continue }; + let bottom = idx.iter().map(|&i| ft.lines[i].baseline + ft.lines[i].descent).fold(f64::NEG_INFINITY, f64::max); let space = f.area.y1 - bottom; if space <= 0.0 { continue; diff --git a/crates/compose/src/table.rs b/crates/compose/src/table.rs index 529c9b7..f163299 100644 --- a/crates/compose/src/table.rs +++ b/crates/compose/src/table.rs @@ -124,7 +124,7 @@ pub(crate) fn place_table( if cur.fi >= frames.len() || table.nrows() == 0 || table.ncols() == 0 { return false; } - let col_rect = |cur: &Cursor| cols[cur.fi][cur.col.min(cols[cur.fi].len() - 1)]; + let col_rect = |cur: &Cursor| cur.clip(cols[cur.fi][cur.col.min(cols[cur.fi].len() - 1)]); let mut col = col_rect(cur); // Column widths: scaled to fit the measure. let avail = (col.width() - pp.left_indent - pp.right_indent).max(1.0); diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 256d9a1..19a446f 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -1889,3 +1889,159 @@ fn vertical_lines_fit_the_em_box() { assert!((l.baseline - col.y0 - ascent).abs() < 1e-9, "{family}: {} {}", l.baseline, col.y0); } } + +// ---------- span columns ---------- + +/// A story of `before` body paragraphs, a heading spanning all columns, and `after` body +/// paragraphs, in a three-column frame. Returns the heading's paragraph index. +fn span_doc(before: usize, after: usize, rect: Rect) -> (Document, StoryId, ItemId, usize) { + let mut paras: Vec<&str> = vec![LOREM; before]; + paras.push("A heading across the columns"); + paras.extend(vec![LOREM; after]); + let (mut d, sid, fid) = doc_with(¶s.join("\n"), rect, ParaAttrs::default()); + d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.columns = 3; + d.story_mut(sid).unwrap().paras[before].para.span_columns = Some(SpanColumns::Span(0)); + (d, sid, fid, before) +} + +/// The line's leading slot (glyph ascent and descent may reach into the neighbouring lines). +fn line_box(l: &Line) -> (f64, f64) { + (l.baseline - 0.75 * l.leading, l.baseline + 0.25 * l.leading) +} + +/// No two lines of a frame share any area. +fn assert_no_overlap(ft: &FrameText) { + for (i, a) in ft.lines.iter().enumerate() { + for b in &ft.lines[i + 1..] { + let ((at, ab), (bt, bb)) = (line_box(a), line_box(b)); + let x_overlap = a.x0 < b.x1 - 0.5 && b.x0 < a.x1 - 0.5; + let y_overlap = at < bb - 0.5 && bt < ab - 0.5; + assert!( + !(x_overlap && y_overlap), + "lines overlap: para {} col {} y {:.1}..{:.1} and para {} col {} y {:.1}..{:.1}", + a.para, + a.column, + at, + ab, + b.para, + b.column, + bt, + bb + ); + } + } +} + +/// The heading spans the frame; text before it sits above it and text after it below it. +fn assert_span_layout(ft: &FrameText, h: usize) { + let heading: Vec<&Line> = ft.lines.iter().filter(|l| l.para == h).collect(); + assert!(!heading.is_empty()); + let (c0, cn) = (ft.columns[0], ft.columns[ft.columns.len() - 1]); + for l in &heading { + assert!((l.x0 - c0.x0.min(cn.x0)).abs() < 1e-6 && (l.x1 - c0.x1.max(cn.x1)).abs() < 1e-6, "heading spans {}..{}", l.x0, l.x1); + } + let top = heading.iter().map(|l| line_box(l).0).fold(f64::INFINITY, f64::min); + let bottom = heading.iter().map(|l| line_box(l).1).fold(f64::NEG_INFINITY, f64::max); + for l in ft.lines.iter().filter(|l| l.para < h) { + assert!(line_box(l).1 <= top + 0.5, "para {} col {} ends at {} below the heading top {top}", l.para, l.column, line_box(l).1); + } + for l in ft.lines.iter().filter(|l| l.para > h) { + assert!(line_box(l).0 >= bottom - 0.5, "para {} col {} starts at {} above the heading bottom {bottom}", l.para, l.column, line_box(l).0); + } + assert_no_overlap(ft); +} + +#[test] +fn text_after_a_spanning_paragraph_flows_below_it() { + let (d, sid, _, h) = span_doc(2, 3, Rect::new(0.0, 0.0, 540.0, 300.0)); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + assert!(!cs.is_overset()); + let ft = &cs.frames[0]; + assert_span_layout(ft, h); + // The text before is balanced across the three columns above the heading. + let before: Vec<&Line> = ft.lines.iter().filter(|l| l.para < h).collect(); + let per_col = |c: u32| before.iter().filter(|l| l.column == c).count(); + let counts = [per_col(0), per_col(1), per_col(2)]; + assert!(counts.iter().all(|&n| n > 0), "{counts:?}"); + assert!(counts.iter().max().unwrap() - counts.iter().min().unwrap() <= 1, "{counts:?}"); + // The text after starts right below the heading in every column, on the same baseline. + let bottom = ft.lines.iter().filter(|l| l.para == h).map(|l| l.baseline).fold(f64::NEG_INFINITY, f64::max); + let firsts: Vec = (0..3).map(|c| ft.lines.iter().find(|l| l.para > h && l.column == c).map(|l| l.baseline).unwrap()).collect(); + for b in &firsts { + assert!(*b > bottom && *b < bottom + 40.0, "{firsts:?} after {bottom}"); + } + assert!((firsts[1] - firsts[2]).abs() < 1e-6, "{firsts:?}"); +} + +#[test] +fn spanning_heading_kept_with_next_and_spanning_two_of_three_columns() { + let (mut d, sid, _, h) = span_doc(2, 3, Rect::new(0.0, 0.0, 540.0, 300.0)); + d.story_mut(sid).unwrap().paras[h].para.keep_with_next = Some(2); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + assert!(!cs.is_overset()); + assert_span_layout(&cs.frames[0], h); + // Spanning two of three columns: the third column beside the heading stays empty. + d.story_mut(sid).unwrap().paras[h].para.span_columns = Some(SpanColumns::Span(2)); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let ft = &cs.frames[0]; + let heading = ft.lines.iter().find(|l| l.para == h).unwrap(); + assert!((heading.x0 - ft.columns[0].x0).abs() < 1e-6 && (heading.x1 - ft.columns[1].x1).abs() < 1e-6); + assert_no_overlap(ft); + let bottom = heading.baseline + 0.25 * heading.leading; + assert!(ft.lines.iter().filter(|l| l.para > h).all(|l| line_box(l).0 >= bottom - 0.5)); + assert!((0..3).all(|c| ft.lines.iter().any(|l| l.para > h && l.column == c))); +} + +#[test] +fn spanning_paragraph_at_the_top_and_end_of_a_frame() { + let (d, sid, _, h) = span_doc(0, 4, Rect::new(0.0, 0.0, 540.0, 300.0)); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + assert!(!cs.is_overset()); + let ft = &cs.frames[0]; + assert_span_layout(ft, h); + assert!(ft.lines[0].para == h && ft.lines[0].baseline < 20.0); + assert!((1..3).all(|c| ft.lines.iter().any(|l| l.column == c))); + + let (d, sid, _, h) = span_doc(3, 0, Rect::new(0.0, 0.0, 540.0, 300.0)); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + assert!(!cs.is_overset()); + assert_span_layout(&cs.frames[0], h); +} + +#[test] +fn text_after_a_spanning_paragraph_threads_into_the_next_frame() { + let (mut d, sid, f1, h) = span_doc(2, 8, Rect::new(0.0, 0.0, 540.0, 200.0)); + let lid = d.default_layer(); + let (f2, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(0.0, 300.0, 540.0, 700.0), lid, "", ParaFormat::default()).unwrap(); + d.item_mut(f2).unwrap().text_frame_mut().unwrap().options.columns = 3; + d.thread(f1, f2).unwrap(); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + assert!(!cs.is_overset(), "overset at {:?}", cs.overset_at); + assert_span_layout(&cs.frames[0], h); + let (a, b) = (&cs.frames[0], &cs.frames[1]); + assert!((0..3).all(|c| a.lines.iter().any(|l| l.para > h && l.column == c))); + assert!(!b.lines.is_empty() && b.lines[0].para > h && b.lines[0].baseline < 320.0); + assert_no_overlap(b); + // Every line of the story is set once, in order. + let lines = all_lines(&cs); + for w in lines.windows(2) { + assert!(w[1].range.start >= w[0].range.end, "{:?} then {:?}", w[0].range, w[1].range); + } + assert_eq!(lines.last().unwrap().range.end, d.story(sid).unwrap().text.len()); +} + +#[test] +fn spanning_paragraph_in_rtl_and_vertical_frames() { + let (mut d, sid, _, h) = span_doc(2, 4, Rect::new(0.0, 0.0, 540.0, 500.0)); + d.story_mut(sid).unwrap().direction = designcraft_doc::TextDirection::RightToLeft; + let cs = compose_story(&d, sid, &ComposeOptions::default()); + assert!(!cs.is_overset()); + assert_span_layout(&cs.frames[0], h); + + let (mut d, sid, _, h) = span_doc(2, 4, Rect::new(0.0, 0.0, 540.0, 540.0)); + d.story_mut(sid).unwrap().vertical = true; + let cs = compose_story(&d, sid, &ComposeOptions::default()); + assert!(!cs.frames[0].lines.is_empty()); + assert_no_overlap(&cs.frames[0]); + assert!(cs.frames[0].lines.iter().any(|l| l.para == h)); +}