mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-30 21:26:48 -06:00
Merge #295: Text: Split Columns sets a paragraph in sub-columns within its column
Conflict: crates/compose/src/lib.rs: #295's split_cfg block and sub-column-aware next_column closure, then next's `go` closure unchanged (StartParagraph::NextColumn and column breaks reach the next sub-column; other start options go through Cursor::start, whose next_frame/next_page start a new split part) Conflict: crates/compose/src/tests.rs: both sides' tests (closing brace restored after next's last test) Conflict: crates/idml/src/tests.rs: both sides' tests (closing brace restored after next's last test)
This commit is contained in:
+241
-18
@@ -501,6 +501,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();
|
||||
@@ -541,12 +543,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);
|
||||
@@ -683,6 +693,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 {
|
||||
@@ -813,7 +824,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)
|
||||
}
|
||||
};
|
||||
let go = |cur: &mut Cursor, start| match start {
|
||||
StartParagraph::NextColumn => next_column(cur),
|
||||
start => cur.start(start, &cols, frames, doc),
|
||||
@@ -874,6 +900,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.
|
||||
@@ -905,6 +938,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_asc = base_chars.size * 0.75;
|
||||
let est_tops = Tops { typo_ascent: est_asc, cap: est_asc * 0.72, xh: est_asc * 0.5 };
|
||||
@@ -987,7 +1021,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
|
||||
baseline = snap(baseline);
|
||||
}
|
||||
}
|
||||
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() };
|
||||
@@ -1009,7 +1043,32 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
|
||||
};
|
||||
let fits = baseline + fit_descent <= 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,
|
||||
@@ -1099,6 +1158,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,
|
||||
@@ -1241,6 +1305,47 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C
|
||||
let (color, tint) = rule_color(r, &below);
|
||||
ft.decos.push(Deco { rect: Rect::new(x0 + r.left_indent, y, x1 - r.right_indent, y + r.weight), color, 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;
|
||||
}
|
||||
@@ -1250,13 +1355,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);
|
||||
@@ -1395,11 +1503,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)));
|
||||
}
|
||||
}
|
||||
@@ -1771,11 +1878,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)]
|
||||
@@ -1802,8 +1986,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.
|
||||
@@ -1814,7 +1998,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;
|
||||
@@ -1830,7 +2014,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 {
|
||||
@@ -1841,10 +2025,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 {
|
||||
@@ -1852,7 +2036,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)
|
||||
}
|
||||
|
||||
@@ -1863,6 +2047,7 @@ impl Cursor {
|
||||
self.next_frame();
|
||||
} else {
|
||||
self.resume();
|
||||
self.split_part();
|
||||
}
|
||||
}
|
||||
fn next_frame(&mut self) {
|
||||
@@ -1870,6 +2055,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) {
|
||||
|
||||
@@ -4186,3 +4186,102 @@ fn a_line_that_exactly_fills_the_measure_fits() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 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<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);
|
||||
}
|
||||
|
||||
@@ -794,6 +794,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(),
|
||||
|
||||
@@ -1429,6 +1429,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);
|
||||
}
|
||||
|
||||
@@ -1368,6 +1368,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") {
|
||||
|
||||
@@ -1950,3 +1950,20 @@ fn complete_uniform_native_style_fields_keep_legacy_identity_on_idml_roundtrip()
|
||||
assert_eq!(table.border.as_ref(), Some(&stroke));
|
||||
assert!(table.borders.iter().all(designcraft_doc::CellStrokeAttrs::is_empty));
|
||||
}
|
||||
|
||||
#[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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user