mirror of
https://github.com/storytold/designcraft.git
synced 2026-11-01 22:26:49 -06:00
Text wrap: lines jump an object by whole leadings; a column's first line starts below a top object
A line clears a Jump Object wrap with its leading box, [baseline - leading, baseline]. It stays above the object while its baseline is at or above the wrap top, so its descenders may hang into the wrap. The next line moves down by whole leadings from the line above, after any space before or after, until its leading box clears the wrap bottom. A line that would start a column under an object starts as if the frame top were the wrap bottom (the frame's first baseline offset below it). Wrap offsets, negative ones included, set the wrap's edges. Other wraps that leave no usable slot, such as a full-width bounding box, push the line so its top lands exactly on the wrap bottom: the search jumps from one exclusion bottom edge to the next. Lines aligned to the baseline grid snap to the next grid line after a push and are re-checked, so they stay on the grid. The breaker's slot estimate uses the same rules.
This commit is contained in:
+125
-48
@@ -691,7 +691,8 @@ pub fn compose(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &Compo
|
||||
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 };
|
||||
let est_first = cur.next_baseline(f, col, base_leading, est_asc, est_tops, &pp);
|
||||
let slots = estimate_slots(f, col, est_first, base_leading, base_chars.size, &glyphs[g0..], &pp, line_no);
|
||||
let est_jump = cur.jump(f, base_leading, est_asc, est_tops);
|
||||
let slots = estimate_slots(f, col, est_first, est_jump, base_leading, base_chars.size, &glyphs[g0..], &pp, line_no);
|
||||
let width = |j: usize| -> f64 {
|
||||
let (x0, x1) = slots.get(j).copied().unwrap_or((col.x0, col.x1));
|
||||
let ind = pp.left_indent + pp.right_indent + if line_no + j == 0 { pp.first_line_indent } else { 0.0 };
|
||||
@@ -711,34 +712,30 @@ pub fn compose(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &Compo
|
||||
let (s, e) = (g0 + b.start, g0 + b.end);
|
||||
let line_glyphs = &glyphs[s..e.max(s)];
|
||||
let (asc, tops, desc, lead) = line_metrics(line_glyphs, &glyphs, s, base_leading, base_chars.size, db, &base_chars);
|
||||
let mut baseline = cur.next_baseline(f, col, lead, asc, tops, &pp);
|
||||
// Baseline grid.
|
||||
if let Some((g_start, inc)) = f.grid
|
||||
&& (pp.grid_align == GridAlign::AllLines || (pp.grid_align == GridAlign::FirstLineOnly && line_no == 0))
|
||||
&& inc > 0.0
|
||||
{
|
||||
let n = ((baseline - g_start) / inc - 1e-6).ceil();
|
||||
baseline = g_start + n * inc;
|
||||
}
|
||||
// Wrap: push the line down until a slot exists.
|
||||
// Baseline grid: the next grid line at or below.
|
||||
let grid = f.grid.filter(|&(_, inc)| {
|
||||
inc > 0.0 && (pp.grid_align == GridAlign::AllLines || (pp.grid_align == GridAlign::FirstLineOnly && line_no == 0))
|
||||
});
|
||||
let snap = |y: f64| grid.map_or(y, |(g_start, inc)| g_start + ((y - g_start) / inc - 1e-6).ceil() * inc);
|
||||
let mut baseline = snap(cur.next_baseline(f, col, lead, asc, tops, &pp));
|
||||
// Wrap: push the line down to the first position with a slot (back on the grid).
|
||||
let (mut x0, mut x1) = (col.x0, col.x1);
|
||||
if !f.exclusions.is_empty() {
|
||||
let jump = cur.jump(f, lead, asc, tops);
|
||||
let mut tries = 0;
|
||||
loop {
|
||||
match free_slot(f, col, baseline - asc, baseline + desc, base_size) {
|
||||
Some((a, b)) => {
|
||||
x0 = a;
|
||||
x1 = b;
|
||||
break;
|
||||
}
|
||||
None => {
|
||||
baseline += 1.0;
|
||||
tries += 1;
|
||||
if baseline > col.y1 || tries > 4000 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let Some((nb, (a, z))) = wrap_slot(f, col, baseline, asc, desc, jump, base_size) else {
|
||||
// No room in this column.
|
||||
baseline = baseline.max(col.y1) + 1.0;
|
||||
break;
|
||||
};
|
||||
let pushed = nb > baseline;
|
||||
(baseline, x0, x1) = (nb, a, z);
|
||||
tries += 1;
|
||||
if !pushed || grid.is_none() || tries > 4000 {
|
||||
break;
|
||||
}
|
||||
baseline = snap(baseline);
|
||||
}
|
||||
}
|
||||
let capped = line_cap[pi] == Some(line_no) && line_no > col_first_line;
|
||||
@@ -1294,22 +1291,34 @@ impl Cursor {
|
||||
fn next_baseline(&self, f: &FrameSpec, col: Rect, lead: f64, asc: f64, tops: Tops, _pp: &ParaProps) -> f64 {
|
||||
match self.last_baseline {
|
||||
Some(b) => b + lead + self.pending,
|
||||
None => {
|
||||
let off = match f.opts.first_baseline {
|
||||
// Vertical frames measure from the ascent.
|
||||
FirstBaseline::Ascent if f.opts.vertical => asc,
|
||||
FirstBaseline::CapHeight if f.opts.vertical => asc * 0.72,
|
||||
FirstBaseline::XHeight if f.opts.vertical => asc * 0.5,
|
||||
FirstBaseline::Ascent => tops.typo_ascent,
|
||||
FirstBaseline::CapHeight => tops.cap,
|
||||
FirstBaseline::XHeight => tops.xh,
|
||||
FirstBaseline::Leading => lead,
|
||||
FirstBaseline::Fixed => 0.0,
|
||||
};
|
||||
col.y0 + off.max(f.opts.first_baseline_min)
|
||||
}
|
||||
None => col.y0 + first_baseline_offset(f, lead, asc, tops),
|
||||
}
|
||||
}
|
||||
/// How the next line clears a Jump Object wrap: by whole leadings below the line above, or, first
|
||||
/// in its column, as at the top of a frame whose top is the wrap bottom.
|
||||
fn jump(&self, f: &FrameSpec, lead: f64, asc: f64, tops: Tops) -> Jump {
|
||||
match self.last_baseline {
|
||||
Some(_) => Jump { top: lead, step: lead },
|
||||
None => Jump { top: first_baseline_offset(f, lead, asc, tops), step: 0.0 },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The frame's first baseline offset below the top of a column, for a line with leading `lead`,
|
||||
/// ascent `asc` and first baseline heights `tops`.
|
||||
fn first_baseline_offset(f: &FrameSpec, lead: f64, asc: f64, tops: Tops) -> f64 {
|
||||
let off = match f.opts.first_baseline {
|
||||
// Vertical frames measure from the ascent.
|
||||
FirstBaseline::Ascent if f.opts.vertical => asc,
|
||||
FirstBaseline::CapHeight if f.opts.vertical => asc * 0.72,
|
||||
FirstBaseline::XHeight if f.opts.vertical => asc * 0.5,
|
||||
FirstBaseline::Ascent => tops.typo_ascent,
|
||||
FirstBaseline::CapHeight => tops.cap,
|
||||
FirstBaseline::XHeight => tops.xh,
|
||||
FirstBaseline::Leading => lead,
|
||||
FirstBaseline::Fixed => 0.0,
|
||||
};
|
||||
off.max(f.opts.first_baseline_min)
|
||||
}
|
||||
|
||||
/// A line's tallest typographic ascender, cap height and x height above its baseline: the
|
||||
@@ -1354,6 +1363,64 @@ fn line_metrics(
|
||||
(asc, tops, desc, lead)
|
||||
}
|
||||
|
||||
/// How a line clears Jump Object wraps. The line occupies `[baseline − top, baseline]` (its leading,
|
||||
/// or its first baseline offset at the top of a column) and must not overlap the wrap; it may hang its
|
||||
/// descenders into the wrap. Below a wrap it moves down from its place in whole `step`s until it clears
|
||||
/// the wrap bottom (`step` 0: its top lands on the wrap bottom).
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct Jump {
|
||||
top: f64,
|
||||
step: f64,
|
||||
}
|
||||
|
||||
/// Slack for float error when a line is tested against a Jump Object wrap's edges.
|
||||
const JUMP_EPS: f64 = 1e-6;
|
||||
|
||||
/// The first baseline at or below `baseline` where a line with ascent `asc` and descent `desc` has a
|
||||
/// free slot in `col`, and that slot; None if there is none above the column bottom.
|
||||
///
|
||||
/// A line under a Jump Object wrap moves below it as `jump` says. Other wraps are rectangles, so the
|
||||
/// free space only grows where one in the line's band ends: the search jumps from one such bottom
|
||||
/// edge to the next, and a line that clears a wrap has its top exactly on the wrap's bottom.
|
||||
fn wrap_slot(f: &FrameSpec, col: Rect, baseline: f64, asc: f64, desc: f64, jump: Jump, size: f64) -> Option<(f64, (f64, f64))> {
|
||||
let top = jump.top.max(0.0);
|
||||
let mut b = baseline;
|
||||
// Each step passes at least one wrap; the cap bounds hostile geometry.
|
||||
for _ in 0..4000 {
|
||||
let jumped = f
|
||||
.exclusions
|
||||
.iter()
|
||||
.filter(|e| e.mode == WrapMode::JumpObject)
|
||||
.map(|e| e.rect)
|
||||
.filter(|r| r.x0 < col.x1 && r.x1 > col.x0 && b > r.y0 + JUMP_EPS && b - top < r.y1 - JUMP_EPS)
|
||||
.fold(f64::NEG_INFINITY, |m, r| m.max(r.y1));
|
||||
b = if jumped > f64::NEG_INFINITY {
|
||||
if jump.step > 0.0 {
|
||||
// Whole steps from the line's own place (at least one step past the line above).
|
||||
baseline + ((jumped + top - baseline - JUMP_EPS / 2.0) / jump.step).ceil().max(0.0) * jump.step
|
||||
} else {
|
||||
jumped + top
|
||||
}
|
||||
} else if let Some(slot) = free_slot(f, col, b - asc, b + desc, size) {
|
||||
return Some((b, slot));
|
||||
} else {
|
||||
let (y0, y1) = (b - asc, b + desc);
|
||||
let edge = f
|
||||
.exclusions
|
||||
.iter()
|
||||
.filter(|e| e.mode != WrapMode::JumpObject)
|
||||
.map(|e| e.rect)
|
||||
.filter(|r| r.y0 < y1 && r.y1 > y0)
|
||||
.fold(f64::INFINITY, |m, r| m.min(r.y1));
|
||||
edge + asc
|
||||
};
|
||||
if b.is_nan() || b > col.y1 {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Widest free horizontal interval of `col` in the band, or None if blocked.
|
||||
fn free_slot(f: &FrameSpec, col: Rect, y0: f64, y1: f64, size: f64) -> Option<(f64, f64)> {
|
||||
let mut free = vec![(col.x0, col.x1)];
|
||||
@@ -1362,7 +1429,6 @@ fn free_slot(f: &FrameSpec, col: Rect, y0: f64, y1: f64, size: f64) -> Option<(f
|
||||
let overlaps_band = r.y0 < y1 && r.y1 > y0;
|
||||
match ex.mode {
|
||||
WrapMode::JumpToNextColumn if y1 > r.y0 && r.x0 < col.x1 && r.x1 > col.x0 => return None,
|
||||
WrapMode::JumpObject if overlaps_band && r.x0 < col.x1 && r.x1 > col.x0 => return None,
|
||||
WrapMode::BoundingBox | WrapMode::Contour if overlaps_band => {
|
||||
let mut next = Vec::new();
|
||||
for (a, b) in free {
|
||||
@@ -1386,7 +1452,17 @@ fn free_slot(f: &FrameSpec, col: Rect, y0: f64, y1: f64, size: f64) -> Option<(f
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn estimate_slots(f: &FrameSpec, col: Rect, first: f64, lead: f64, size: f64, glyphs: &[Glyph], pp: &ParaProps, line_no: usize) -> Vec<(f64, f64)> {
|
||||
fn estimate_slots(
|
||||
f: &FrameSpec,
|
||||
col: Rect,
|
||||
first: f64,
|
||||
jump: Jump,
|
||||
lead: f64,
|
||||
size: f64,
|
||||
glyphs: &[Glyph],
|
||||
pp: &ParaProps,
|
||||
line_no: usize,
|
||||
) -> Vec<(f64, f64)> {
|
||||
let _ = (pp, line_no);
|
||||
if f.exclusions.is_empty() {
|
||||
return vec![];
|
||||
@@ -1396,20 +1472,21 @@ fn estimate_slots(f: &FrameSpec, col: Rect, first: f64, lead: f64, size: f64, gl
|
||||
let n = ((total / (col.width().max(10.0) * 0.5)).ceil() as usize + 2).min(2000);
|
||||
let mut v = Vec::with_capacity(n);
|
||||
let mut b = first;
|
||||
let mut jump = jump;
|
||||
for _ in 0..n {
|
||||
let mut tries = 0;
|
||||
let slot = loop {
|
||||
if let Some(s) = free_slot(f, col, b - size * 0.8, b + size * 0.25, size) {
|
||||
break s;
|
||||
let slot = match wrap_slot(f, col, b, size * 0.8, size * 0.25, jump, size) {
|
||||
Some((nb, s)) => {
|
||||
b = nb;
|
||||
s
|
||||
}
|
||||
b += 1.0;
|
||||
tries += 1;
|
||||
if b > col.y1 || tries > 4000 {
|
||||
break (col.x0, col.x1);
|
||||
None => {
|
||||
b = b.max(col.y1);
|
||||
(col.x0, col.x1)
|
||||
}
|
||||
};
|
||||
v.push(slot);
|
||||
b += lead;
|
||||
jump = Jump { top: lead, step: lead };
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
@@ -146,6 +146,241 @@ fn wrap_pushes_text_aside() {
|
||||
assert!(below.x0 < 1.0);
|
||||
}
|
||||
|
||||
/// Insert rectangles with wrap, given as (bounds, [top, left, bottom, right] offsets, mode).
|
||||
fn add_wrap_objects(d: &mut Document, objects: &[(Rect, [f64; 4], WrapMode)]) {
|
||||
let lid = d.default_layer();
|
||||
for &(r, offsets, mode) in objects {
|
||||
let id = ItemId(d.alloc());
|
||||
let mut it = designcraft_doc::Item::new(id, lid, designcraft_doc::Shape::Rectangle, designcraft_geom::shapes::rectangle(r));
|
||||
it.wrap.mode = mode;
|
||||
it.wrap.offsets = offsets;
|
||||
d.insert_item(SpreadRef::Doc(0), it, None).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// 10 pt on 12 pt text in a 288 × 528 frame at (36, 36), with wrap objects.
|
||||
fn wrap_doc(para: ParaAttrs, objects: &[(Rect, [f64; 4], WrapMode)]) -> (Document, StoryId) {
|
||||
let text = [LOREM; 6].join(" ");
|
||||
let (mut d, sid, _) = doc_with(&text, Rect::new(36.0, 36.0, 324.0, 564.0), para);
|
||||
d.story_mut(sid).unwrap().format_chars(0..text.len(), |f| {
|
||||
f.over.size = Some(10.0);
|
||||
f.over.leading = Some(designcraft_doc::Leading::Points(12.0));
|
||||
});
|
||||
add_wrap_objects(&mut d, objects);
|
||||
(d, sid)
|
||||
}
|
||||
|
||||
/// The first line whose baseline is below the top of `ex`.
|
||||
fn first_line_below<'a>(cs: &'a ComposedStory, ex: &Exclusion) -> &'a Line {
|
||||
cs.frames[0].lines.iter().find(|l| l.baseline > ex.rect.y0).unwrap()
|
||||
}
|
||||
|
||||
/// 14 pt on 18 pt `text` in a frame from y 62.36 to 532.91 with first baseline offset Leading, so lines
|
||||
/// sit at 80.36 + n × 18 (the setup measured in InDesign), with Jump Object wraps given as
|
||||
/// (bounds, [top, left, bottom, right] offsets).
|
||||
fn jump_doc(text: &str, objects: &[(Rect, [f64; 4])]) -> (Document, StoryId, ItemId) {
|
||||
let (mut d, sid, fid) = doc_with(text, Rect::new(36.0, 62.36, 324.0, 532.91), ParaAttrs::default());
|
||||
d.story_mut(sid).unwrap().format_chars(0..text.len(), |f| {
|
||||
f.over.size = Some(14.0);
|
||||
f.over.leading = Some(designcraft_doc::Leading::Points(18.0));
|
||||
});
|
||||
d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.first_baseline = FirstBaseline::Leading;
|
||||
let objects: Vec<_> = objects.iter().map(|&(r, o)| (r, o, WrapMode::JumpObject)).collect();
|
||||
add_wrap_objects(&mut d, &objects);
|
||||
(d, sid, fid)
|
||||
}
|
||||
|
||||
/// Baselines of the last line above the top of the first wrap and of the first line below it.
|
||||
fn around_wrap(d: &Document, sid: StoryId) -> (Option<f64>, f64) {
|
||||
let specs = frame_specs(d, sid);
|
||||
let ex = &specs[0].exclusions[0];
|
||||
let cs = compose_story(d, sid, &ComposeOptions::default());
|
||||
let lines = &cs.frames[0].lines;
|
||||
let above = lines.iter().rev().find(|l| l.baseline <= ex.rect.y0 + 1e-6).map(|l| l.baseline);
|
||||
(above, first_line_below(&cs, ex).baseline)
|
||||
}
|
||||
|
||||
fn near(a: f64, b: f64) -> bool {
|
||||
(a - b).abs() < 0.01
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jump_object_moves_the_next_line_down_by_whole_leadings() {
|
||||
// As measured: last baseline above 386.41, wrap bottom 472.77 → 494.40 (6 leadings). Here the last
|
||||
// line above is at 386.36 and the wrap ends 86.36 below it.
|
||||
let text = [LOREM; 8].join(" ");
|
||||
let (d, sid, _) = jump_doc(&text, &[(Rect::new(36.0, 395.0, 200.0, 472.72), [0.0; 4])]);
|
||||
let (above, below) = around_wrap(&d, sid);
|
||||
assert!(near(above.unwrap(), 386.36), "{above:?}");
|
||||
assert!(near(below, 386.36 + 6.0 * 18.0), "{below}");
|
||||
// The lines after it keep the leading.
|
||||
let cs = compose_story(&d, sid, &ComposeOptions::default());
|
||||
let next = cs.frames[0].lines.iter().find(|l| l.baseline > below + 1.0).unwrap();
|
||||
assert!(near(next.baseline, below + 18.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jump_object_clears_the_wrap_with_the_leading_not_the_ascent() {
|
||||
// As measured: last 152.38, wrap bottom 246.90 → 278.38. Clearing the wrap with the line's ascent
|
||||
// instead of its leading would put the line one leading higher (here the wrap ends a little higher,
|
||||
// as the test font's ascent is larger).
|
||||
let text = [LOREM; 8].join(" ");
|
||||
let (d, sid, _) = jump_doc(&text, &[(Rect::new(36.0, 160.0, 324.0, 244.0), [0.0; 4])]);
|
||||
let (above, below) = around_wrap(&d, sid);
|
||||
assert!(near(above.unwrap(), 152.36), "{above:?}");
|
||||
assert!(near(below, 152.36 + 7.0 * 18.0), "{below}");
|
||||
let cs = compose_story(&d, sid, &ComposeOptions::default());
|
||||
let asc = cs.frames[0].lines[0].ascent;
|
||||
let by_ascent = (1..).map(|k| 152.36 + k as f64 * 18.0).find(|b| b - asc >= 244.0).unwrap();
|
||||
assert!(!near(by_ascent, below), "the case must tell the two rules apart ({asc})");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jump_object_adds_space_before_once() {
|
||||
// As measured: a heading at 78.36, then a paragraph with 60 pt space before, wrap bottom 439.71 →
|
||||
// 462.36 = 78.36 + 60 + 18 × 18.
|
||||
let text = format!("Heading\n{}", [LOREM; 6].join(" "));
|
||||
let (mut d, sid, _) = jump_doc(&text, &[(Rect::new(36.0, 90.0, 324.0, 439.71), [0.0; 4])]);
|
||||
d.story_mut(sid).unwrap().format_paras(10..10, |p| p.para.space_before = Some(60.0));
|
||||
let (above, below) = around_wrap(&d, sid);
|
||||
assert!(near(above.unwrap(), 80.36), "{above:?}");
|
||||
assert!(near(below, 80.36 + 60.0 + 18.0 * 18.0), "{below}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jump_object_at_the_frame_top_starts_text_like_a_frame_top() {
|
||||
// As measured: wrap bottom 266.83 → first baseline 284.835 (Leading offset: one leading below).
|
||||
let text = [LOREM; 8].join(" ");
|
||||
let (mut d, sid, fid) = jump_doc(&text, &[(Rect::new(36.0, 50.0, 324.0, 266.83), [0.0; 4])]);
|
||||
let cs = compose_story(&d, sid, &ComposeOptions::default());
|
||||
let lines = &cs.frames[0].lines;
|
||||
assert!(near(lines[0].baseline, 266.83 + 18.0), "{}", lines[0].baseline);
|
||||
assert!(near(lines[1].baseline, 266.83 + 36.0), "{}", lines[1].baseline);
|
||||
// Ascent offset: the line's ascent below the wrap.
|
||||
d.item_mut(fid).unwrap().text_frame_mut().unwrap().options.first_baseline = FirstBaseline::Ascent;
|
||||
let cs = compose_story(&d, sid, &ComposeOptions::default());
|
||||
let l = &cs.frames[0].lines[0];
|
||||
assert!(near(l.baseline, 266.83 + l.ascent), "{} vs {}", l.baseline, l.ascent);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_above_jump_object_stays_while_its_baseline_is_above_the_wrap() {
|
||||
let text = [LOREM; 8].join(" ");
|
||||
// The line at 386.36 hangs its descenders into the object and stays.
|
||||
let (d, sid, _) = jump_doc(&text, &[(Rect::new(36.0, 386.36, 324.0, 420.0), [0.0; 4])]);
|
||||
let (above, below) = around_wrap(&d, sid);
|
||||
assert!(near(above.unwrap(), 386.36), "{above:?}");
|
||||
assert!(near(below, 440.36), "{below}");
|
||||
// A wrap starting just above its baseline makes it jump.
|
||||
let (d, sid, _) = jump_doc(&text, &[(Rect::new(36.0, 386.3, 324.0, 420.0), [0.0; 4])]);
|
||||
let (above, below) = around_wrap(&d, sid);
|
||||
assert!(near(above.unwrap(), 368.36), "{above:?}");
|
||||
assert!(near(below, 440.36), "{below}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn negative_jump_offsets_shrink_the_wrap() {
|
||||
let text = [LOREM; 8].join(" ");
|
||||
let (d, sid, _) = jump_doc(&text, &[(Rect::new(36.0, 300.0, 324.0, 500.0), [-20.0, 0.0, -30.0, 0.0])]);
|
||||
let specs = frame_specs(&d, sid);
|
||||
let r = specs[0].exclusions[0].rect;
|
||||
assert!(near(r.y0, 320.0) && near(r.y1, 470.0), "{r:?}");
|
||||
let (above, below) = around_wrap(&d, sid);
|
||||
assert!(near(above.unwrap(), 314.36), "{above:?}");
|
||||
assert!(near(below, 494.36), "{below}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jump_object_down_to_the_frame_bottom_sends_text_to_the_next_frame() {
|
||||
let text = [LOREM; 8].join(" ");
|
||||
let (mut d, sid, a) = jump_doc(&text, &[(Rect::new(36.0, 300.0, 324.0, 540.0), [0.0; 4])]);
|
||||
let lid = d.default_layer();
|
||||
let (b, _) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(350.0, 62.36, 550.0, 532.91), lid, "", ParaFormat::default()).unwrap();
|
||||
d.item_mut(b).unwrap().text_frame_mut().unwrap().options.first_baseline = FirstBaseline::Leading;
|
||||
d.thread(a, b).unwrap();
|
||||
let cs = compose_story(&d, sid, &ComposeOptions::default());
|
||||
let last = cs.frames[0].lines.last().unwrap();
|
||||
assert!(near(last.baseline, 296.36), "{}", last.baseline);
|
||||
assert!(near(cs.frames[1].lines[0].baseline, 62.36 + 18.0), "{}", cs.frames[1].lines[0].baseline);
|
||||
assert_eq!(cs.frames[0].range.end, cs.frames[1].range.start);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_width_bounding_box_wrap_resumes_exactly_below() {
|
||||
// A bounding-box wrap across the whole column leaves no slot: the line jumps it like Jump Object.
|
||||
let (d, sid) = wrap_doc(ParaAttrs::default(), &[(Rect::new(20.0, 100.0, 340.0, 150.25), [0.0; 4], WrapMode::BoundingBox)]);
|
||||
let specs = frame_specs(&d, sid);
|
||||
let cs = compose_story(&d, sid, &ComposeOptions::default());
|
||||
let ex = &specs[0].exclusions[0];
|
||||
let l = first_line_below(&cs, ex);
|
||||
assert!((l.baseline - l.ascent - ex.rect.y1).abs() < 0.01, "{} vs {}", l.baseline - l.ascent, ex.rect.y1);
|
||||
// A slot narrower than 1.5 × the size is no slot either.
|
||||
let (d, sid) = wrap_doc(ParaAttrs::default(), &[(Rect::new(50.0, 100.0, 340.0, 150.25), [0.0; 4], WrapMode::BoundingBox)]);
|
||||
let specs = frame_specs(&d, sid);
|
||||
let cs = compose_story(&d, sid, &ComposeOptions::default());
|
||||
let ex = &specs[0].exclusions[0];
|
||||
let l = first_line_below(&cs, ex);
|
||||
assert!((l.baseline - l.ascent - ex.rect.y1).abs() < 0.01, "{} vs {}", l.baseline - l.ascent, ex.rect.y1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jump_object_keeps_grid_aligned_lines_on_the_grid() {
|
||||
// The wrap ends at 166.5, off the 12 pt grid; a second object blocks the grid line the line jumps
|
||||
// to, so the line moves on to the grid line after that object.
|
||||
let objects = [
|
||||
(Rect::new(36.0, 100.0, 200.0, 160.5), [0.0, 0.0, 6.0, 0.0], WrapMode::JumpObject),
|
||||
(Rect::new(250.0, 178.0, 324.0, 190.0), [0.0; 4], WrapMode::JumpObject),
|
||||
];
|
||||
let (d, sid) = wrap_doc(ParaAttrs { grid_align: Some(GridAlign::AllLines), ..Default::default() }, &objects);
|
||||
let specs = frame_specs(&d, sid);
|
||||
let (g0, inc) = specs[0].grid.unwrap();
|
||||
let cs = compose_story(&d, sid, &ComposeOptions::default());
|
||||
let on_grid = |b: f64| (((b - g0) / inc).round() * inc + g0 - b).abs() < 1e-6;
|
||||
let lines = &cs.frames[0].lines;
|
||||
assert!(lines.iter().all(|l| on_grid(l.baseline)), "{:?}", lines.iter().map(|l| l.baseline).collect::<Vec<_>>());
|
||||
let exs = &specs[0].exclusions;
|
||||
let l = first_line_below(&cs, &exs[0]);
|
||||
// Naive oracle: the first grid line whose leading band clears the objects.
|
||||
let clear = |b: f64, exs: &[Exclusion]| exs.iter().all(|e| b <= e.rect.y0 || b - l.leading >= e.rect.y1);
|
||||
let want = |exs: &[Exclusion]| (0..).map(|n| g0 + n as f64 * inc).find(|&b| b > exs[0].rect.y0 && clear(b, exs)).unwrap();
|
||||
assert!((l.baseline - want(exs)).abs() < 1e-6, "baseline {} should be on grid line {}", l.baseline, want(exs));
|
||||
assert!(want(&exs[..1]) < want(exs), "the second object pushed the line again");
|
||||
|
||||
// First line only: a later line of the paragraph jumps by whole leadings from the line above.
|
||||
let (d, sid) = wrap_doc(ParaAttrs { grid_align: Some(GridAlign::FirstLineOnly), ..Default::default() }, &objects[..1]);
|
||||
let specs = frame_specs(&d, sid);
|
||||
let cs = compose_story(&d, sid, &ComposeOptions::default());
|
||||
let ex = &specs[0].exclusions[0];
|
||||
let l = first_line_below(&cs, ex);
|
||||
assert!(!l.first_in_para);
|
||||
let above = cs.frames[0].lines.iter().rev().find(|a| a.baseline <= ex.rect.y0).unwrap().baseline;
|
||||
let want = (1..).map(|k| above + k as f64 * 12.0).find(|b| b - 12.0 >= ex.rect.y1).unwrap();
|
||||
assert!((l.baseline - want).abs() < 1e-6, "{} vs {want}", l.baseline);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_wrap_geometry_terminates() {
|
||||
let (d, sid) = wrap_doc(ParaAttrs { grid_align: Some(GridAlign::AllLines), ..Default::default() }, &[]);
|
||||
let mut specs = frame_specs(&d, sid);
|
||||
let ex = |x0: f64, y0: f64, x1: f64, y1: f64, mode| Exclusion { rect: Rect::new(x0, y0, x1, y1), mode };
|
||||
let f = &mut specs[0];
|
||||
f.exclusions.push(ex(f64::NAN, f64::NAN, f64::NAN, f64::NAN, WrapMode::JumpObject));
|
||||
f.exclusions.push(ex(-1e308, 300.0, 1e308, f64::INFINITY, WrapMode::JumpObject));
|
||||
f.exclusions.push(ex(0.0, f64::NEG_INFINITY, 400.0, 40.0, WrapMode::BoundingBox));
|
||||
// A wall of overlapping strips, each ending 0.25 pt below the previous.
|
||||
for i in 0..3000 {
|
||||
let y = 60.0 + i as f64 * 0.25;
|
||||
f.exclusions.push(ex(0.0, y, 400.0, y + 1.0, if i % 2 == 0 { WrapMode::JumpObject } else { WrapMode::BoundingBox }));
|
||||
}
|
||||
f.grid = Some((0.0, 1e-300));
|
||||
let story = d.story(sid).unwrap();
|
||||
let cs = compose(&d, story, &specs, &ComposeOptions::default());
|
||||
assert!(cs.is_overset());
|
||||
for l in &cs.frames[0].lines {
|
||||
assert!(l.baseline.is_finite() && l.baseline - l.ascent >= 40.0 && l.baseline + l.descent <= 300.0, "{}", l.baseline);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paragraph_composer_is_no_worse_than_greedy() {
|
||||
// Sum of squared slack over lines (excluding last) should not exceed the greedy result.
|
||||
|
||||
Reference in New Issue
Block a user