Text: Split Columns sets a paragraph in sub-columns within its column (#295)

Consecutive paragraphs with the same Split Columns settings form a split
block: their text fills the sub-columns of the column one after the
other from the block's top, inset by the outside gutter and separated by
the inside gutter, then moves on to the next column. When text follows
the block, its last part is balanced across the sub-columns and the text
continues below the deepest one at the full column width; a block that
ends the story fills its sub-columns in turn. Keep options don't apply
between a block's lines.

IDML: SplitColumnInsideGutter and SplitColumnOutsideGutter import and
export (paragraph attributes split_inside_gutter, split_outside_gutter).

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-10-10 04:27:59 -04:00
1 parent c657d8545e
commit fc2ce5556f
6 files changed
+364 -18

No files matched your search

+241 -18
View File
@@ -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<Trial> = 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<Vec<(SplitKey, f64)>> = Rc::default();
let mut split_trial: Option<Trial<SplitKey>> = 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<usize>, 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, &note_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, &note_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<f64> = 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<u32> = 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<Vec<(BandKey, f64)>>,
/// The split-column block the text is in.
split: Option<SplitBlock>,
/// Bottoms that balance split blocks, by block part (kept by the compose loop).
split_limits: Rc<Vec<(SplitKey, f64)>>,
}
/// 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<SplitCfg> {
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<f64>,
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<usize>,
/// 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<f64>,
}
/// 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<K = BandKey> {
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<K> Trial<K> {
/// 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<Trial>, limits: &mut Rc<Vec<(BandKey, f64)>>, budget_left: bool) -> Option<usize> {
fn trial_failed<K: TrialKey>(trial: &mut Option<Trial<K>>, limits: &mut Rc<Vec<(K, f64)>>, budget_left: bool) -> Option<usize> {
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<Trial>, limits: &mut Rc<Vec<(BandKey, f64)>>,
t.next()
};
set_limit(limits, key, Some(bottom));
Some(key.1)
Some(key.para())
}
fn set_limit(limits: &mut Rc<Vec<(BandKey, f64)>>, key: BandKey, bottom: Option<f64>) {
fn set_limit<K: TrialKey>(limits: &mut Rc<Vec<(K, f64)>>, key: K, bottom: Option<f64>) {
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<Vec<(BandKey, f64)>>, key: BandKey, bottom: Option<
}
}
fn limit_of(limits: &[(BandKey, f64)], key: BandKey) -> Option<f64> {
fn limit_of<K: TrialKey>(limits: &[(K, f64)], key: K) -> Option<f64> {
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<SplitKey> {
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) {
+99
View File
@@ -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(&paras.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<usize>, subs: &[(f64, f64)]) -> Vec<Vec<&'a Line>> {
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<usize> = 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<f64> = 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<String> {
+3
View File
@@ -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<TabStop> = Vec::new(),
+2
View File
@@ -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);
}
+2
View File
@@ -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") {
+17
View File
@@ -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#"<idPkg:Story xmlns:idPkg="http://ns.adobe.com/AdobeInDesign/idml/1.0/packaging"><Story Self="s1">
<ParagraphStyleRange AppliedParagraphStyle="ParagraphStyle/$ID/[No paragraph style]" SpanColumnType="SplitColumns" SplitColumnInsideGutter="27" SplitColumnOutsideGutter="4.5">
<Properties><SpanSplitColumnCount type="short">3</SpanSplitColumnCount></Properties>
<CharacterStyleRange AppliedCharacterStyle="CharacterStyle/$ID/[No character style]"><Content>Split list</Content></CharacterStyleRange>
</ParagraphStyleRange></Story></idPkg:Story>"#;
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(