mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-14 19:27:07 -04:00
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.
This commit is contained in:
1 parent
3de58750cd
commit
b744953d70
3 files changed
+439
-37
No files matched your search
+282
-36
@@ -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>, usize)> = Vec::with_capacity(np);
|
||||
let mut var_cache: std::collections::HashMap<Option<usize>, std::sync::Arc<Vec<String>>> = 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<Vec<(BandKey, f64)>> = Rc::default();
|
||||
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).
|
||||
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<usize>, 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<f64> = 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>, usize)],
|
||||
notes: &mut Notes,
|
||||
out: &mut ComposedStory,
|
||||
cur: &mut Cursor,
|
||||
list_counter: &mut u32,
|
||||
force_col: &mut [bool],
|
||||
line_cap: &mut [Option<usize>],
|
||||
) {
|
||||
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<Vec<(BandKey, f64)>>,
|
||||
}
|
||||
|
||||
/// 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<Above>,
|
||||
}
|
||||
|
||||
#[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<Trial>, limits: &mut Rc<Vec<(BandKey, f64)>>, budget_left: bool) -> Option<usize> {
|
||||
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<Vec<(BandKey, f64)>>, key: BandKey, bottom: Option<f64>) {
|
||||
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<f64> {
|
||||
limits.iter().find(|(k, _)| *k == key).map(|&(_, b)| b)
|
||||
}
|
||||
|
||||
impl Cursor {
|
||||
fn next_column(&mut self, cols: &[Vec<Rect>]) {
|
||||
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<usize> = 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<usize> = 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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<f64> = (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));
|
||||
}
|
||||
Reference in new issue
Block a user