diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index ebb4bbd6..3dd7c25b 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -493,6 +493,8 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C pi: 0, band: Band::default(), limits: Rc::default(), + split: None, + split_limits: Rc::default(), }; let para_ranges = story.para_ranges(); let np = para_ranges.len(); @@ -534,12 +536,20 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C 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). + // Balancing split-column blocks that text follows. + let mut split_limits: Rc> = Rc::default(); + let mut split_trial: Option> = None; + let mut split_runs = 0usize; + let split_budget = 20 * story.paras.iter().filter(|p| matches!(doc.styles.resolve_para(p).0.span_columns, SpanColumns::Split(_))).count(); + // Paragraphs set across columns (vertical justification moves their frames' lines together), + // 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(); cur.pi = pi; cur.limits = Rc::clone(&limits); + cur.split_limits = Rc::clone(&split_limits); let snap = Snapshot::take(&out, &cur, list_counter); snaps.truncate(pi); snaps.push(snap); @@ -662,6 +672,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C // look again (the style changes the widths) until the lines settle. let cols_here = &cols[cur.fi]; let col = span_rect(cols_here, cols_here[cur.col.min(cols_here.len() - 1)], pp.span_columns); + let col = SplitCfg::of(&pp).map_or(col, |c| c.sub_rect(col, 0)); let spacing = spacing_for(&pp, base_chars.size); let mut lines: Vec<(std::ops::Range, String)> = Vec::new(); for _ in 0..3 { @@ -762,7 +773,22 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C // 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) }; + // Consecutive paragraphs with the same Split Columns settings share a split block; within + // it, a paragraph moves on to the next sub-column. + let split_cfg = SplitCfg::of(&pp); + split_paras[pi] = split_cfg.is_some(); + if cur.split.is_some_and(|s| Some(s.cfg) != split_cfg) { + cur.split = None; + } + let next_column = |cur: &mut Cursor| { + if spanning { + cur.next_frame() + } else if cur.can_next_sub() { + cur.next_sub() + } else { + cur.next_column(&cols) + } + }; // 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 { @@ -825,6 +851,13 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C if cur.last_baseline.is_some() { cur.pending += pp.space_before; } + if let Some(cfg) = split_cfg + && cur.split.is_none() + { + let start = cur.state(); + cur.split = Some(SplitBlock { cfg, sub: 0, sub_lines: 0, part_lines: 0, line0: None, para: pi, start, top: None }); + cur.split_part(); + } let at_top = cur.last_baseline.is_none(); let start_at = (cur.fi, cur.col); // Rule above / shading track the paragraph's first line. @@ -852,6 +885,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C 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 }; + let col = cur.split_rect(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); @@ -914,7 +948,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C } } } - let capped = line_cap[pi] == Some(line_no) && line_no > col_first_line; + let capped = cur.split.is_none() && line_cap[pi] == Some(line_no) && line_no > col_first_line; // Footnotes referenced on this line need room at the bottom of the column too // (a line at the top of a column is set anyway). let line_notes = if notes.active() { notes.refs_in(line_glyphs) } else { Vec::new() }; @@ -923,7 +957,32 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C let reserve = if cur.last_baseline.is_none() { notes.reserve(doc, cur.fi, cur.col, &[], col_w, f, opts) } else { reserve }; let fits = baseline + desc <= col.y1 - reserve + 0.01 && !capped; if !fits { - if !capped { + if cur.can_next_sub() { + g0 = s; + cur.next_sub(); + col_first_line = line_no; + moved = true; + break; + } + // A split block that doesn't fit above its trial bottom: re-lay it lower. + if let Some(key) = cur.split_key() + && limit_of(&split_limits, key).is_some_and(f64::is_finite) + { + let j = if split_trial.as_ref().is_some_and(|t| t.key == key) { + trial_failed(&mut split_trial, &mut split_limits, split_runs < split_budget) + } else { + set_limit(&mut split_limits, key, Some(f64::INFINITY)); + Some(key.2) + }; + if let Some(j) = j { + split_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; + } + } + // Keep options don't apply between a split block's lines. + if !capped && cur.split.is_none_or(|s| s.part_lines == 0) { let ctx = KeepCtx { pi, line_no, @@ -988,6 +1047,11 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C if line_no == 0 { first_line_rect = Some((cur.fi, baseline, asc, x0)); } + if let Some(sb) = cur.split.as_mut() { + sb.line0.get_or_insert(ft.lines.len()); + sb.sub_lines += 1; + sb.part_lines += 1; + } ft.lines.push(Line { column: cur.col as u32, baseline, @@ -1099,6 +1163,47 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C let (x0, x1) = if r.column_width { (l.x0, l.x1) } else { (l.x0, l.end_x) }; ft.decos.push(Deco { rect: Rect::new(x0 + r.left_indent, y, x1 - r.right_indent, y + r.weight), color: r.color.clone(), tint: r.tint }); } + // The end of a split block: text after it continues below its deepest sub-column, which + // are balanced first. A block that ends the story fills its sub-columns in turn. + if let Some(sb) = cur.split { + let next = + story.paras.get(pi + 1).filter(|_| story.para_table(pi + 1).is_none()).and_then(|p| SplitCfg::of(&doc.styles.resolve_para(p).0)); + if next != Some(sb.cfg) { + cur.split = None; + let part = out.frames.get(cur.fi).and_then(|ft| { + let lines = ft.lines.get(sb.line0?..)?; + let top = lines.first().map(|l| l.baseline - l.ascent)?; + let deepest = lines.iter().map(|l| (l.baseline, l.descent)).max_by(|a, b| (a.0 + a.1).total_cmp(&(b.0 + b.1)))?; + Some((sb.top.unwrap_or(top), deepest)) + }); + if pi + 1 < np + && let Some((top, (baseline, descent))) = part + { + let key = (cur.fi, cur.col, sb.para); + let active = split_trial.as_ref().is_some_and(|t| t.key == key); + if !active && let Some(t) = split_trial.take() { + set_limit(&mut split_limits, t.key, None); + } + if sb.part_lines > 1 && (active || limit_of(&split_limits, key).is_none() && split_runs < split_budget) { + let bottom = baseline + descent; + let mut t = split_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 split_limits, key, Some(next)); + if !t.done { + split_trial = Some(t); + split_runs += 1; + rewind(key.2, &snaps, ¬e_snaps, &mut notes, &mut out, &mut cur, &mut list_counter, &mut force_col, &mut line_cap); + pi = key.2; + continue 'paras; + } + } + cur.last_baseline = Some(baseline); + cur.last_descent = descent; + } + } + } cur.pending += pp.space_after; pi += 1; } @@ -1108,13 +1213,16 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C let f = &frames[fi]; if let (Some(a), Some(z)) = (ft.lines.first(), ft.lines.last()) { ft.range = a.range.start..z.range.end; - ft.content_height = z.baseline + z.descent - f.area.y0; + // The deepest line of the last column (split columns can end higher up). + let bottom = ft.lines.iter().filter(|l| l.column == z.column).map(|l| l.baseline + l.descent).fold(z.baseline + z.descent, f64::max); + ft.content_height = bottom - f.area.y0; } else { let p = ft_prev_end(&out.overset_at, story.text.len()); 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(); - let spanned = ft.columns.len() > 1 && ft.lines.iter().any(|l| span_paras.get(l.para) == Some(&true)); + let spanned = ft.columns.len() > 1 && ft.lines.iter().any(|l| span_paras.get(l.para) == Some(&true)) + || ft.lines.iter().any(|l| split_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); @@ -1253,11 +1361,10 @@ fn para_box_decos(out: &mut ComposedStory, pi: usize, pp: &ParaProps) { } let mut parts: Vec<(usize, Rect)> = Vec::new(); for (fi, ft) in out.frames.iter().enumerate() { - let mut cols_seen: Vec = ft.lines.iter().filter(|l| l.para == pi).map(|l| l.column).collect(); - cols_seen.dedup(); - for c in cols_seen { - let lines: Vec<&Line> = ft.lines.iter().filter(|l| l.para == pi && l.column == c).collect(); - if let (Some(a), Some(z)) = (lines.first(), lines.last()) { + // A part ends where the next line moves to another column or sub-column. + let lines: Vec<&Line> = ft.lines.iter().filter(|l| l.para == pi).collect(); + for part in lines.chunk_by(|a, b| a.column == b.column && b.baseline > a.baseline) { + if let (Some(a), Some(z)) = (part.first(), part.last()) { parts.push((fi, Rect::new(a.x0, a.baseline - a.ascent, a.x1, z.baseline + z.descent))); } } @@ -1621,11 +1728,88 @@ struct Cursor { band: Band, /// Column bottoms that balance bands, by band key (kept by the compose loop). limits: Rc>, + /// The split-column block the text is in. + split: Option, + /// Bottoms that balance split blocks, by block part (kept by the compose loop). + split_limits: Rc>, } /// A band's frame and first paragraph. type BandKey = (usize, usize); +/// A split block's part in one column: frame, column and the paragraph it starts in. +type SplitKey = (usize, usize, usize); + +/// What a balancing trial re-lays from. +trait TrialKey: Copy + PartialEq { + fn para(&self) -> usize; +} + +impl TrialKey for BandKey { + fn para(&self) -> usize { + self.1 + } +} + +impl TrialKey for SplitKey { + fn para(&self) -> usize { + self.2 + } +} + +/// Split Columns settings: sub-columns, inside and outside gutters. +#[derive(Clone, Copy, Debug, PartialEq)] +struct SplitCfg { + n: usize, + inside: f64, + outside: f64, +} + +impl SplitCfg { + fn of(pp: &ParaProps) -> Option { + let SpanColumns::Split(n) = pp.span_columns else { return None }; + let gutter = |g: f64| if g.is_finite() { g.max(0.0) } else { 0.0 }; + Some(SplitCfg { n: (n as usize).clamp(2, 64), inside: gutter(pp.split_inside_gutter), outside: gutter(pp.split_outside_gutter) }) + } + /// Sub-column `sub` of `col`. + fn sub_rect(&self, col: Rect, sub: usize) -> Rect { + let (x0, x1) = (col.x0 + self.outside, col.x1 - self.outside); + let w = ((x1 - x0 - self.inside * (self.n - 1) as f64) / self.n as f64).max(1.0); + let x = x0 + sub.min(self.n - 1) as f64 * (w + self.inside); + Rect::new(x, col.y0, x + w, col.y1) + } +} + +/// Where the cursor resumes: the line above (if any) and the space waiting below it. +#[derive(Clone, Copy, Debug)] +struct Resume { + last_baseline: Option, + last_descent: f64, + last_reference: f64, + pending: f64, +} + +/// Consecutive paragraphs with the same Split Columns settings, set in sub-columns of their +/// column: text fills the sub-columns one after the other from the block's top, then moves on to +/// the next column. Text after the block continues below its deepest sub-column. +#[derive(Clone, Copy, Debug)] +struct SplitBlock { + cfg: SplitCfg, + /// The sub-column the text is in. + sub: usize, + /// Lines set in the current sub-column, and in the block's part in this column. + sub_lines: usize, + part_lines: usize, + /// The part's first line in its frame. + line0: Option, + /// The paragraph the part starts in (balancing re-lays from there). + para: usize, + /// Every sub-column starts from here. + start: Resume, + /// The part's top. + top: Option, +} + /// 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)] @@ -1652,8 +1836,8 @@ struct Above { /// 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, +struct Trial { + key: K, /// The spanning paragraph that closes the band. span: usize, /// Highest bottom known not to fit / lowest known to fit. @@ -1664,7 +1848,7 @@ struct Trial { done: bool, } -impl Trial { +impl Trial { /// The next bottom to try: half way, or `hi` once settled. fn next(&mut self) -> f64 { self.tries += 1; @@ -1680,7 +1864,7 @@ impl Trial { /// 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 { +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 { @@ -1691,10 +1875,10 @@ fn trial_failed(trial: &mut Option, limits: &mut Rc>, t.next() }; set_limit(limits, key, Some(bottom)); - Some(key.1) + Some(key.para()) } -fn set_limit(limits: &mut Rc>, key: BandKey, bottom: Option) { +fn set_limit(limits: &mut Rc>, key: K, bottom: Option) { let v = Rc::make_mut(limits); v.retain(|(k, _)| *k != key); if let Some(b) = bottom { @@ -1702,7 +1886,7 @@ fn set_limit(limits: &mut Rc>, key: BandKey, bottom: Option< } } -fn limit_of(limits: &[(BandKey, f64)], key: BandKey) -> Option { +fn limit_of(limits: &[(K, f64)], key: K) -> Option { limits.iter().find(|(k, _)| *k == key).map(|&(_, b)| b) } @@ -1713,6 +1897,7 @@ impl Cursor { self.next_frame(); } else { self.resume(); + self.split_part(); } } fn next_frame(&mut self) { @@ -1720,6 +1905,44 @@ impl Cursor { self.col = 0; self.band = Band { para: self.pi, ..Band::default() }; self.resume(); + self.split_part(); + } + fn state(&self) -> Resume { + Resume { last_baseline: self.last_baseline, last_descent: self.last_descent, last_reference: self.last_reference, pending: self.pending } + } + /// Start a split block (or its part in a new column) here. + fn split_part(&mut self) { + let start = self.state(); + let top = start.last_baseline.map(|b| b + start.last_descent); + let para = self.pi; + if let Some(s) = self.split.as_mut() { + *s = SplitBlock { sub: 0, sub_lines: 0, part_lines: 0, line0: None, para, start, top, ..*s }; + } + } + /// Move on to the next sub-column of the split block, back at the block's top. + fn next_sub(&mut self) { + let Some(s) = self.split.as_mut() else { return }; + s.sub += 1; + s.sub_lines = 0; + let r = s.start; + self.last_baseline = r.last_baseline; + self.last_descent = r.last_descent; + self.last_reference = r.last_reference; + self.pending = r.pending; + } + /// Within a split block, the next sub-column when there's one to go to. + fn can_next_sub(&self) -> bool { + self.split.is_some_and(|s| s.sub_lines > 0 && s.sub + 1 < s.cfg.n) + } + fn split_key(&self) -> Option { + self.split.map(|s| (self.fi, self.col, s.para)) + } + /// The sub-column the split block's text is in, within `col` and above its balanced bottom. + fn split_rect(&self, col: Rect) -> Rect { + let Some(s) = self.split else { return col }; + let r = s.cfg.sub_rect(col, s.sub); + let y1 = self.split_key().and_then(|k| limit_of(&self.split_limits, k)).map_or(col.y1, |b| b.min(col.y1).max(col.y0)); + Rect::new(r.x0, r.y0, r.x1, y1) } /// Start the current column at the top of the band. fn resume(&mut self) { diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 0013d986..0d1fe2d2 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -2602,6 +2602,105 @@ fn spanning_paragraph_in_rtl_and_vertical_frames() { assert!(cs.frames[0].lines.iter().any(|l| l.para == h)); } +// ---------- split columns ---------- + +/// A story of a body paragraph, `split` paragraphs in split columns and `after` body paragraphs. +/// Returns the index of the first split paragraph. +fn split_doc(split: usize, after: usize, rect: Rect, cfg: (u32, f64, f64)) -> (Document, StoryId, ItemId, usize) { + let mut paras: Vec<&str> = vec![LOREM; 1 + split + after]; + paras[0] = "An introduction set at the full measure of the column, before the split block."; + let (mut d, sid, fid) = doc_with(¶s.join("\n"), rect, ParaAttrs::default()); + let st = d.story_mut(sid).unwrap(); + for p in &mut st.paras[1..=split] { + p.para.span_columns = Some(SpanColumns::Split(cfg.0)); + p.para.split_inside_gutter = Some(cfg.1); + p.para.split_outside_gutter = Some(cfg.2); + } + (d, sid, fid, 1) +} + +/// The sub-columns of `col`: (x0, x1) of each. +fn sub_columns(col: Rect, (n, inside, outside): (u32, f64, f64)) -> Vec<(f64, f64)> { + let w = (col.width() - 2.0 * outside - inside * (n - 1) as f64) / n as f64; + (0..n).map(|k| col.x0 + outside + k as f64 * (w + inside)).map(|x| (x, x + w)).collect() +} + +/// The split paragraphs' lines, by sub-column. +fn split_lines<'a>(ft: &'a FrameText, paras: std::ops::Range, subs: &[(f64, f64)]) -> Vec> { + let mut by_sub = vec![Vec::new(); subs.len()]; + for l in ft.lines.iter().filter(|l| paras.contains(&l.para)) { + let k = subs.iter().position(|&(x0, x1)| (l.x0 - x0).abs() < 1e-6 && (l.x1 - x1).abs() < 1e-6); + let k = k.unwrap_or_else(|| panic!("line of para {} at {}..{} is in no sub-column of {subs:?}", l.para, l.x0, l.x1)); + by_sub[k].push(l); + } + by_sub +} + +#[test] +fn split_paragraphs_are_set_in_balanced_sub_columns() { + for cfg in [(2, 12.0, 10.0), (3, 6.0, 0.0)] { + let (d, sid, _, s) = split_doc(2, 1, Rect::new(0.0, 0.0, 400.0, 700.0), cfg); + let cs = compose_story(&d, sid, &ComposeOptions::default()); + assert!(!cs.is_overset()); + let ft = &cs.frames[0]; + assert_no_overlap(ft); + let by_sub = split_lines(ft, s..s + 2, &sub_columns(ft.columns[0], cfg)); + let counts: Vec = by_sub.iter().map(Vec::len).collect(); + assert!(counts.iter().all(|&n| n > 0), "{cfg:?}: {counts:?}"); + assert!(counts.iter().max().unwrap() - counts.iter().min().unwrap() <= 1, "{cfg:?}: balanced {counts:?}"); + // Every sub-column starts on the same baseline, below the paragraph before the block. + let firsts: Vec = by_sub.iter().map(|v| v[0].baseline).collect(); + assert!(firsts.iter().all(|b| (b - firsts[0]).abs() < 1e-6), "{cfg:?}: {firsts:?}"); + let intro = ft.lines.iter().filter(|l| l.para == 0).map(|l| line_box(l).1).fold(f64::NEG_INFINITY, f64::max); + assert!(firsts[0] > intro && firsts[0] < intro + 20.0, "{cfg:?}: block at {} after {intro}", firsts[0]); + // The text after the block continues at the full measure below its deepest sub-column. + let bottom = by_sub.iter().flatten().map(|l| line_box(l).1).fold(f64::NEG_INFINITY, f64::max); + let next = ft.lines.iter().find(|l| l.para == s + 2).unwrap(); + assert!(line_box(next).0 >= bottom - 0.5 && line_box(next).0 < bottom + 20.0, "{cfg:?}: {} after {bottom}", line_box(next).0); + assert!((next.x0 - ft.columns[0].x0).abs() < 1e-6 && (next.x1 - ft.columns[0].x1).abs() < 1e-6); + } +} + +#[test] +fn split_block_in_the_second_column_and_at_the_story_end() { + let cfg = (2, 12.0, 0.0); + let (mut d, sid, fid, s) = split_doc(3, 0, Rect::new(0.0, 0.0, 540.0, 400.0), cfg); + d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.columns = 2; + d.story_mut(sid).unwrap().paras[s].para.start_paragraph = Some(StartParagraph::NextColumn); + 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.para >= s).all(|l| l.column == 1)); + let by_sub = split_lines(ft, s..s + 3, &sub_columns(ft.columns[1], cfg)); + // Nothing follows the block: it fills its first sub-column to the bottom of the frame, then + // the next. + let (a, b) = (&by_sub[0], &by_sub[1]); + assert!(!a.is_empty() && !b.is_empty() && a.len() > b.len() + 1, "{} and {} lines", a.len(), b.len()); + let low = a.last().unwrap(); + assert!(low.baseline + low.descent > 400.0 - low.leading, "the first sub-column ends at {}", low.baseline); + assert!((a[0].baseline - b[0].baseline).abs() < 1e-6); +} + +#[test] +fn split_block_flows_into_the_next_column() { + let cfg = (2, 12.0, 0.0); + let (mut d, sid, fid, s) = split_doc(6, 1, Rect::new(0.0, 0.0, 540.0, 200.0), cfg); + d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.columns = 2; + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let ft = &cs.frames[0]; + assert_no_overlap(ft); + for c in 0..2 { + let in_col: Vec<&Line> = ft.lines.iter().filter(|l| l.column == c as u32).collect(); + let lines: Vec<&Line> = in_col.iter().copied().filter(|l| (s..s + 6).contains(&l.para)).collect(); + let subs = sub_columns(ft.columns[c], cfg); + assert!(subs.iter().all(|&(x0, x1)| lines.iter().any(|l| (l.x0 - x0).abs() < 1e-6 && (l.x1 - x1).abs() < 1e-6)), "column {c}"); + } + // The block continues at the top of the second column. + let top = ft.lines.iter().find(|l| l.column == 1).unwrap(); + assert!((s..s + 6).contains(&top.para) && top.baseline < 20.0); +} + /// Each paragraph's list label as text: its label glyphs (laid before the paragraph's own text) /// matched back to characters of the label font; tabs and other invisible glyphs are skipped. fn list_labels(cs: &ComposedStory, alphabet: &str) -> Vec { diff --git a/crates/doc/src/attrs.rs b/crates/doc/src/attrs.rs index a1f723d1..b99740e2 100644 --- a/crates/doc/src/attrs.rs +++ b/crates/doc/src/attrs.rs @@ -860,6 +860,9 @@ attr_set! { keep_last: u32 = 2, start_paragraph: StartParagraph = StartParagraph::Anywhere, span_columns: SpanColumns = SpanColumns::Single, + /// Split Columns: the space between sub-columns and the inset from the column's edges. + split_inside_gutter: f64 = 6.0, + split_outside_gutter: f64 = 0.0, rule_above: Rule = Rule::default(), rule_below: Rule = Rule::default(), tabs: Vec = Vec::new(), diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs index 25567e44..9d73562b 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -1438,6 +1438,8 @@ impl<'a> Ex<'a> { } } } + n!(split_inside_gutter, "SplitColumnInsideGutter"); + n!(split_outside_gutter, "SplitColumnOutsideGutter"); if let Some(r) = &a.rule_above { self.rule(el, props, "RuleAbove", r); } diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index fb237844..d85d9ee0 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -1327,6 +1327,8 @@ impl<'r> Importer<'r> { _ => SpanColumns::Single, }); } + a.split_inside_gutter = e.num("SplitColumnInsideGutter").filter(|v| v.is_finite()); + a.split_outside_gutter = e.num("SplitColumnOutsideGutter").filter(|v| v.is_finite()); a.rule_above = self.rule(e, "RuleAbove"); a.rule_below = self.rule(e, "RuleBelow"); if let Some(tl) = e.prop_el("TabList") { diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index 163fd0b2..7b747587 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -1340,6 +1340,23 @@ fn layers_import_and_export_in_stacking_order() { assert_eq!(back.spreads[0].items[0].layer, back.layers[1].id); } +#[test] +fn split_columns_import_and_round_trip() { + let story = r#" + + 3 + Split list + "#; + let doc = import_idml(&fixture_with_story(story)).unwrap(); + let para = |d: &Document| d.stories.values().find(|s| s.text.contains("Split list")).unwrap().paras[0].para.clone(); + let a = para(&doc); + assert_eq!(a.span_columns, Some(designcraft_doc::SpanColumns::Split(3))); + assert_eq!(a.split_inside_gutter, Some(27.0)); + assert_eq!(a.split_outside_gutter, Some(4.5)); + let b = para(&import_idml(&export_idml(&doc)).unwrap()); + assert_eq!((b.span_columns, b.split_inside_gutter, b.split_outside_gutter), (a.span_columns, a.split_inside_gutter, a.split_outside_gutter)); +} + #[test] fn list_numbering_format_expression_and_bullet_import_and_round_trip() { let styles = STYLES.replace(