mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-13 21:17:48 +00:00
Merge pull request #7087 from dotCooCoo/feat/every-line-composer-shaped-area
Type: the Every-line Composer sets mixed sizes in shaped and wrapped area type
This commit is contained in:
+82
-17
@@ -61,10 +61,51 @@ pub(crate) struct Params {
|
||||
pub hyphen_limit: usize,
|
||||
}
|
||||
|
||||
struct Node {
|
||||
/// The search gave up: it ran past its budget ([`Measure::line`]).
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct Exhausted;
|
||||
|
||||
/// Where each line of a paragraph goes and how wide it is: the measure a line gets may depend on
|
||||
/// the lines set above it (their heights move it down a shaped frame, or past a wrap object).
|
||||
pub(crate) trait Measure {
|
||||
/// What the measure of the next line depends on; breaks reached with equal states are
|
||||
/// interchangeable from there on.
|
||||
type State: Clone + PartialEq;
|
||||
/// The state of the paragraph's first line.
|
||||
fn first(&mut self) -> Self::State;
|
||||
/// The width of the line of glyphs `pos..end` set from `state`, and the state of the line
|
||||
/// after it; `Ok(None)` when the line can't be set (the frame is full), and [`Exhausted`] when
|
||||
/// the search is to stop.
|
||||
fn line(&mut self, state: &Self::State, pos: usize, end: usize) -> Result<Option<(f64, Self::State)>, Exhausted>;
|
||||
/// Whether a line from `state` longer than `pos..end`, which [`Measure::line`] just measured,
|
||||
/// may get more room than it. While it may, a line from `state` that is too long, or that
|
||||
/// can't be set, does not end the reach of `state`. Lines from a state are all as wide by
|
||||
/// default.
|
||||
fn may_widen(&mut self, _state: &Self::State, _pos: usize, _end: usize) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Line widths by line number: `width(k)` for line `k`, lines from `uniform_from` on all as wide.
|
||||
struct ByLine<'a> {
|
||||
width: &'a dyn Fn(usize) -> f64,
|
||||
uniform_from: usize,
|
||||
}
|
||||
|
||||
impl Measure for ByLine<'_> {
|
||||
type State = usize;
|
||||
fn first(&mut self) -> usize {
|
||||
0
|
||||
}
|
||||
fn line(&mut self, &line: &usize, _: usize, _: usize) -> Result<Option<(f64, usize)>, Exhausted> {
|
||||
Ok(Some(((self.width)(line), (line + 1).min(self.uniform_from))))
|
||||
}
|
||||
}
|
||||
|
||||
struct Node<S> {
|
||||
pos: usize,
|
||||
/// Line number of the next line, clamped to `uniform_from`.
|
||||
line: usize,
|
||||
/// The measure of the next line (its line number, clamped to `uniform_from`, for [`compose`]).
|
||||
state: S,
|
||||
fitness: u8,
|
||||
demerits: f64,
|
||||
/// Lines in a row ending with a hyphen up to this break (at most `hyphen_limit`, or 1 with no
|
||||
@@ -131,6 +172,19 @@ pub(crate) fn compose(
|
||||
cands: &[Breakpoint],
|
||||
flex: &[Flex],
|
||||
p: &Params,
|
||||
) -> Option<Vec<(usize, bool)>> {
|
||||
compose_with(g, &mut ByLine { width, uniform_from: p.uniform_from }, start_credit, end_credit, cands, flex, p)
|
||||
}
|
||||
|
||||
/// [`compose`] with each line's width from `measure`, which follows where the lines above it went.
|
||||
pub(crate) fn compose_with<M: Measure>(
|
||||
g: &[SGlyph],
|
||||
measure: &mut M,
|
||||
start_credit: &dyn Fn(usize) -> f64,
|
||||
end_credit: &dyn Fn(usize) -> f64,
|
||||
cands: &[Breakpoint],
|
||||
flex: &[Flex],
|
||||
p: &Params,
|
||||
) -> Option<Vec<(usize, bool)>> {
|
||||
let n = g.len();
|
||||
if n == 0 {
|
||||
@@ -165,14 +219,14 @@ pub(crate) fn compose(
|
||||
all.push(Breakpoint { end: n, hyphen: 0.0 });
|
||||
let (adj_demerits, hyphen_penalty) =
|
||||
if p.ragged { (ADJ_DEMERITS_RAGGED, HYPHEN_PENALTY_RAGGED) } else { (ADJ_DEMERITS_JUSTIFIED, HYPHEN_PENALTY) };
|
||||
let mut nodes = vec![Node { pos: 0, line: 0, fitness: 1, demerits: 0.0, hyphens: 0, prev: None }];
|
||||
let mut nodes = vec![Node { pos: 0, state: measure.first(), fitness: 1, demerits: 0.0, hyphens: 0, prev: None }];
|
||||
let mut active: Vec<usize> = vec![0];
|
||||
for bp in &all {
|
||||
let last = bp.end == n;
|
||||
let hyphen = bp.hyphen > 0.0;
|
||||
// `automatic` is true for a hyphenation point and false for a soft hyphen typed in the text.
|
||||
let automatic = hyphen && !bp.end.checked_sub(1).and_then(|k| g.get(k)).is_some_and(SGlyph::is_soft_hyphen);
|
||||
let mut best: Vec<(usize, u8, usize, f64, usize)> = vec![]; // (line, fitness, hyphens, demerits, from)
|
||||
let mut best: Vec<(M::State, u8, usize, f64, usize)> = vec![]; // (state, fitness, hyphens, demerits, from)
|
||||
let mut keep = Vec::with_capacity(active.len());
|
||||
for &ai in &active {
|
||||
let Some(a) = nodes.get(ai) else { continue };
|
||||
@@ -182,13 +236,25 @@ pub(crate) fn compose(
|
||||
// last glyph's advance, so a longer line from the same node is never narrower.
|
||||
let end = if hyphen || t <= a.pos { 0.0 } else { line_end_credit(t) };
|
||||
let natural = w[t] - w[a.pos] - credit - end + bp.hyphen;
|
||||
let lw = width(a.line);
|
||||
// Adjustment ratio, badness and whether the line ends the active node's reach.
|
||||
let Some(ratio) = adjustment_ratio(natural, lw, st[t] - st[a.pos], sh[t] - sh[a.pos], last, p) else { continue };
|
||||
if ratio < -1.0 {
|
||||
// Too long already; longer lines from this node only get worse.
|
||||
// A line that can't be set (no room left in the frame) ends this node's reach, unless a
|
||||
// longer line may be placed elsewhere (a taller line steps down the frame by its own
|
||||
// leading).
|
||||
let Some((lw, next)) = measure.line(&a.state, a.pos, bp.end).ok()? else {
|
||||
if measure.may_widen(&a.state, a.pos, bp.end) {
|
||||
keep.push(ai);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
};
|
||||
// Adjustment ratio, badness and whether the line ends the active node's reach.
|
||||
let ratio = adjustment_ratio(natural, lw, st[t] - st[a.pos], sh[t] - sh[a.pos], last, p);
|
||||
let Some(ratio) = ratio.filter(|&r| r >= -1.0 || r.is_nan()) else {
|
||||
// Too long already; longer lines from this node only get worse, unless they may
|
||||
// get more room (a taller line set lower down a shaped frame).
|
||||
if measure.may_widen(&a.state, a.pos, bp.end) {
|
||||
keep.push(ai);
|
||||
}
|
||||
continue;
|
||||
};
|
||||
keep.push(ai);
|
||||
if automatic && p.hyphen_limit > 0 && a.hyphens >= p.hyphen_limit {
|
||||
continue;
|
||||
@@ -214,22 +280,21 @@ pub(crate) fn compose(
|
||||
d += adj_demerits;
|
||||
}
|
||||
let total = a.demerits + d;
|
||||
let line = (a.line + 1).min(p.uniform_from);
|
||||
// With a limit, breaks reached with different runs of hyphenated lines stay apart.
|
||||
let hyphens = match (hyphen, p.hyphen_limit) {
|
||||
(false, _) => 0,
|
||||
(true, 0) => 1,
|
||||
(true, limit) => (a.hyphens + 1).min(limit),
|
||||
};
|
||||
match best.iter_mut().find(|b| b.0 == line && b.1 == fit && b.2 == hyphens) {
|
||||
match best.iter_mut().find(|b| b.0 == next && b.1 == fit && b.2 == hyphens) {
|
||||
Some(b) if b.3 <= total => {}
|
||||
Some(b) => *b = (line, fit, hyphens, total, ai),
|
||||
None => best.push((line, fit, hyphens, total, ai)),
|
||||
Some(b) => *b = (next, fit, hyphens, total, ai),
|
||||
None => best.push((next, fit, hyphens, total, ai)),
|
||||
}
|
||||
}
|
||||
active = keep;
|
||||
for (line, fitness, hyphens, demerits, from) in best {
|
||||
nodes.push(Node { pos: bp.end, line, fitness, demerits, hyphens, prev: Some(from) });
|
||||
for (state, fitness, hyphens, demerits, from) in best {
|
||||
nodes.push(Node { pos: bp.end, state, fitness, demerits, hyphens, prev: Some(from) });
|
||||
active.push(nodes.len() - 1);
|
||||
}
|
||||
if active.is_empty() || active.len() > MAX_ACTIVE {
|
||||
|
||||
@@ -31,6 +31,7 @@ mod flow;
|
||||
mod on_path;
|
||||
mod region;
|
||||
mod ruby;
|
||||
mod shaped;
|
||||
mod vertical;
|
||||
|
||||
pub(crate) use bidi::{is_rtl, para_bidi};
|
||||
|
||||
@@ -26,14 +26,18 @@ impl Metrics {
|
||||
pub(super) fn max(g: &[SGlyph]) -> Option<Self> {
|
||||
let mut it = g.iter();
|
||||
let first = Self::of(it.next()?);
|
||||
Some(it.fold(first, |m, g| Self {
|
||||
asc: m.asc.max(g.ascent),
|
||||
desc: m.desc.max(g.descent),
|
||||
lead: m.lead.max(g.leading),
|
||||
cap: m.cap.max(g.cap),
|
||||
xh: m.xh.max(g.xh),
|
||||
top: m.top.max(Self::of(g).top),
|
||||
}))
|
||||
Some(it.fold(first, |m, g| m.union(Self::of(g))))
|
||||
}
|
||||
/// The larger of each of the metrics of `self` and `o`: those of a line holding both.
|
||||
pub(super) fn union(self, o: Self) -> Self {
|
||||
Self {
|
||||
asc: self.asc.max(o.asc),
|
||||
desc: self.desc.max(o.desc),
|
||||
lead: self.lead.max(o.lead),
|
||||
cap: self.cap.max(o.cap),
|
||||
xh: self.xh.max(o.xh),
|
||||
top: self.top.max(o.top),
|
||||
}
|
||||
}
|
||||
/// Distance from the frame top to the first baseline.
|
||||
pub(super) fn first_baseline(&self, fb: FirstBaseline, min: f64) -> f64 {
|
||||
@@ -67,6 +71,9 @@ pub(super) struct Pen<'r> {
|
||||
/// Baseline steps still allowed over all the frame's cells and placements (shared by this pen's
|
||||
/// copies): see [`MAX_TOTAL_STEPS`].
|
||||
pub(super) steps_left: std::rc::Rc<std::cell::Cell<usize>>,
|
||||
/// What is left of the every-line composer's search over the frame's paragraphs (shared by
|
||||
/// this pen's copies): see [`super::shaped::Budget`].
|
||||
pub(super) search: std::rc::Rc<super::shaped::Budget>,
|
||||
}
|
||||
|
||||
/// The largest number of steps layout takes to move a line down a frame cell to find room for it:
|
||||
@@ -103,9 +110,24 @@ impl Pen<'_> {
|
||||
let b = self.prev.map_or(0.0, |b| self.next_baseline(b, est));
|
||||
return Some((b, f64::NEG_INFINITY, f64::INFINITY, false));
|
||||
};
|
||||
if !self.queued.is_empty() {
|
||||
// A further span was measured across the height of the line set before it on its baseline.
|
||||
// This line gets the part of it that is free across its own height: a taller line can
|
||||
// reach a wrap object the line before it stayed clear of. A span with no such part is
|
||||
// left empty, and so is a part the line's height made narrower than `min`.
|
||||
while !self.queued.is_empty() {
|
||||
let (b, x0, x1) = self.queued.remove(0);
|
||||
return Some((b, x0, x1, true));
|
||||
let Some(r) = regions.get(self.ri) else { return Some((b, x0, x1, true)) };
|
||||
let mut free = r.spans(b - est.asc, b + est.desc).into_iter().filter_map(|(s, e)| {
|
||||
let (s, e) = (s.max(x0), e.min(x1));
|
||||
let room = e - s - ind_l - ind_r;
|
||||
let narrowed = s > x0 + EPS || e < x1 - EPS;
|
||||
(room > est.asc.max(1.0) && (!narrowed || room + EPS >= min)).then_some((s, e))
|
||||
});
|
||||
let Some((s, e)) = free.next() else { continue };
|
||||
// An object inside the span splits it: the parts after the first stay queued.
|
||||
let rest: Vec<_> = free.map(|(s, e)| (b, s, e)).collect();
|
||||
self.queued.splice(0..0, rest);
|
||||
return Some((b, s, e, true));
|
||||
}
|
||||
loop {
|
||||
let r = regions.get(self.ri)?;
|
||||
@@ -841,6 +863,7 @@ fn a_pen_out_of_baseline_steps_finds_no_room() {
|
||||
next_top: None,
|
||||
no_room_for: f64::INFINITY,
|
||||
steps_left: std::rc::Rc::new(std::cell::Cell::new(steps)),
|
||||
search: Default::default(),
|
||||
};
|
||||
assert!(pen(MAX_TOTAL_STEPS).place(est, 0.0, 0.0, 0.0).is_some());
|
||||
assert!(pen(0).place(est, 0.0, 0.0, 0.0).is_none());
|
||||
|
||||
@@ -24,9 +24,15 @@ fn pushes_in(para: &ParaStyle) -> bool {
|
||||
/// Paragraphs longer than this (glyphs) are always broken line by line.
|
||||
const MAX_COMPOSE_GLYPHS: usize = 200_000;
|
||||
|
||||
/// Whether every line of area type in `regions` is as wide however tall it is and wherever it is set:
|
||||
/// rectangular cells without text wrap.
|
||||
fn constant_width(regions: Option<&[Region]>) -> bool {
|
||||
regions.is_some_and(|r| r.iter().all(|r| (r.rect || r.polys.is_empty()) && r.wraps.is_empty()))
|
||||
}
|
||||
|
||||
/// Every-line composition of paragraph glyphs `sg` if applicable (area text, justified or ragged,
|
||||
/// with uniform line metrics or in rectangular cells without text wrap); `None` falls back to the
|
||||
/// greedy single-line composer. With `continued`, the paragraph continues from the frame before,
|
||||
/// in any frame shape, with or without text wrap); `None` falls back to the greedy single-line
|
||||
/// composer. With `continued`, the paragraph continues from the frame before,
|
||||
/// and its first composed line is not its first line. `line_end` is how much narrower a line
|
||||
/// ending with each glyph is set ([`ParagraphData::line_end`]).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
@@ -66,14 +72,11 @@ fn compose_para(
|
||||
let m = Metrics::of(&sg[0]);
|
||||
// Line widths are independent of the breaks when every line has the same metrics, or when
|
||||
// a line's width does not depend on how far down the frame it is set: rectangular cells
|
||||
// without text wrap. Then a paragraph whose characters differ in line metrics (type size,
|
||||
// font, leading, vertical scale, an inline graphic taller than the text) is composed too. In
|
||||
// other shapes, or with text wrap, a taller line can move the lines below it to where the
|
||||
// frame is narrower, so such a paragraph is broken line by line.
|
||||
let plain = pen.regions.is_some_and(|r| r.iter().all(|r| (r.rect || r.polys.is_empty()) && r.wraps.is_empty()));
|
||||
if !plain && sg.iter().any(|g| Metrics::of(g) != m) {
|
||||
return None;
|
||||
}
|
||||
// without text wrap. In other shapes, or with text wrap, a paragraph whose characters differ
|
||||
// in line metrics (type size, font, leading, vertical scale, an inline graphic taller than
|
||||
// the text) can have a taller line move the lines below it to where the frame is narrower:
|
||||
// each line the composer tries is then placed after the lines it follows (`shaped::Shaped`).
|
||||
let shaped = !constant_width(pen.regions) && sg.iter().any(|g| Metrics::of(g) != m);
|
||||
let total: f64 = sg.iter().map(|g| g.adv).sum();
|
||||
// The same rule as the line loop below: an opening bracket starting a wrapped line gives up
|
||||
// the space before it.
|
||||
@@ -86,7 +89,7 @@ fn compose_para(
|
||||
let mut acc = 0.0;
|
||||
// A line's width and placement depend on the sum of its indents alone, which the mirrored
|
||||
// indents of a right-to-left list ([`line_indents`]) keep.
|
||||
while widths.len() < sg.len() {
|
||||
while !shaped && widths.len() < sg.len() {
|
||||
let first = widths.is_empty();
|
||||
let ind_l = para.laid_out_left_indent() + if first && !continued { para.first_line_indent } else { 0.0 };
|
||||
// Only the first line's first word is known here. Later lines are placed as the composed
|
||||
@@ -103,7 +106,11 @@ fn compose_para(
|
||||
break;
|
||||
}
|
||||
}
|
||||
let last = *widths.last()?;
|
||||
let last = match widths.last() {
|
||||
Some(&last) => last,
|
||||
None if shaped => 0.0,
|
||||
None => return None,
|
||||
};
|
||||
let width = |k: usize| widths.get(k).copied().unwrap_or(last);
|
||||
// Lines from `uniform_from` on are interchangeable (same width).
|
||||
let uniform_from = widths.iter().rposition(|&w| (w - last).abs() > EPS).map_or(0, |k| k + 1);
|
||||
@@ -119,6 +126,14 @@ fn compose_para(
|
||||
let (strict, loose) = (1.0, f64::INFINITY);
|
||||
// A line's last character is set narrower, as the line loop sets it.
|
||||
let end_credit = |end: usize| end.checked_sub(1).and_then(|k| line_end.get(k)).copied().unwrap_or(0.0);
|
||||
if shaped {
|
||||
// Both passes share the placements tried and one search budget.
|
||||
let mirror = mirrors_indents(para, rtl, cx.vertical);
|
||||
let mut measure = super::shaped::Shaped::new(sg, line_end, para, &start_credit, continued, mirror, pen);
|
||||
let mut pass = |p: &Params| crate::composer::compose_with(sg, &mut measure, &start_credit, &end_credit, &cands, flex, p);
|
||||
let lines = pass(¶ms(strict));
|
||||
return lines.or_else(|| pass(¶ms(loose)));
|
||||
}
|
||||
let pass = |p: &Params| compose(sg, &width, &start_credit, &end_credit, &cands, flex, p);
|
||||
pass(¶ms(strict)).or_else(|| pass(¶ms(loose)))
|
||||
}
|
||||
@@ -142,7 +157,7 @@ fn mirrors_indents(para: &ParaStyle, rtl: bool, vertical: bool) -> bool {
|
||||
/// the right and the right indent from the left: the bullet of a right-to-left list hangs at the
|
||||
/// right, and the lines it wraps to line up with the text after the bullet. A list level past the
|
||||
/// first adds to the left indent ([`ParaStyle::laid_out_left_indent`]), mirrored with it.
|
||||
fn line_indents(para: &ParaStyle, first: bool, mirror: bool) -> (f64, f64) {
|
||||
pub(super) fn line_indents(para: &ParaStyle, first: bool, mirror: bool) -> (f64, f64) {
|
||||
let start = para.laid_out_left_indent() + if first { para.first_line_indent } else { 0.0 };
|
||||
if mirror { (para.right_indent, start) } else { (start, para.right_indent) }
|
||||
}
|
||||
@@ -162,6 +177,7 @@ pub(super) fn flow(cx: &mut Ctx<'_>, paras: &[Range<usize>], t: &TextObject, reg
|
||||
next_top: None,
|
||||
no_room_for: f64::INFINITY,
|
||||
steps_left: std::rc::Rc::new(std::cell::Cell::new(super::breaking::MAX_TOTAL_STEPS)),
|
||||
search: Default::default(),
|
||||
};
|
||||
let markers = list_markers(t, paras);
|
||||
for (pi, pr) in paras.iter().enumerate() {
|
||||
@@ -392,6 +408,12 @@ fn place_flow_line(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, context: &mut PlacementC
|
||||
}
|
||||
// The line's first word, as narrow as the line sets it at both ends.
|
||||
let word = word_width(sg, *i, line_end, para.kinsoku);
|
||||
// A composed line in a frame whose lines are not all as wide is placed at its own tallest
|
||||
// metrics, as the composer placed it (`shaped::Shaped`): its room is the room across its height.
|
||||
let est = match composed.as_ref().and_then(|lines| lines.get(*para_line)) {
|
||||
Some(&(end, _)) if end > *i && !constant_width(pen.regions) => sg.get(*i..end).and_then(Metrics::max).unwrap_or(est),
|
||||
_ => est,
|
||||
};
|
||||
// Place, break, then settle the baseline on the line's real metrics (moving on to the
|
||||
// next row/column if it no longer fits).
|
||||
loop {
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
//! Every-line composition where a line's width depends on the lines above it: area type in a
|
||||
//! shape other than a rectangle, or with text wrap, whose characters differ in line metrics (type
|
||||
//! size, font, leading, vertical scale, an inline graphic taller than the text). A taller line
|
||||
//! moves the lines below it down, to where the frame may be narrower or a wrap object splits the
|
||||
//! line. Each line the composer tries is placed from where the lines above it left the pen, at its
|
||||
//! own tallest metrics, as the line loop in [`super::flow`] places a composed line: the span it
|
||||
//! gets is the room across its whole height.
|
||||
|
||||
use std::cell::Cell;
|
||||
use std::collections::HashMap;
|
||||
use std::rc::Rc;
|
||||
|
||||
use super::breaking::{Metrics, Pen, word_width};
|
||||
use super::flow::line_indents;
|
||||
use super::{ParaStyle, SGlyph};
|
||||
use crate::composer::{Exhausted, Measure};
|
||||
|
||||
/// The most lines the composer tries to place for one paragraph before it stops and the paragraph
|
||||
/// is broken line by line.
|
||||
const MAX_PROBES: usize = 1_000_000;
|
||||
|
||||
/// The most baseline steps placing those lines takes in all (see [`super::breaking::MAX_TOTAL_STEPS`]).
|
||||
const MAX_STEPS: usize = 2_000_000;
|
||||
|
||||
/// The most lines the composer tries to place for all the paragraphs of one frame's layout.
|
||||
const MAX_LAYOUT_PROBES: usize = 2 * MAX_PROBES;
|
||||
|
||||
/// The most baseline steps placing them takes over all the paragraphs of one frame's layout.
|
||||
const MAX_LAYOUT_STEPS: usize = 2 * MAX_STEPS;
|
||||
|
||||
/// What is left of the composer's search over one frame's layout: the lines it may still try to
|
||||
/// place and the baseline steps placing them may still take. Every paragraph's search takes what
|
||||
/// it spends from it, and stops when it runs out, or at its own limits of [`MAX_PROBES`] and
|
||||
/// [`MAX_STEPS`]. It is separate from the layout's own steps ([`Pen::steps_left`]), which the
|
||||
/// search leaves to the line loop.
|
||||
pub(super) struct Budget {
|
||||
probes: Cell<usize>,
|
||||
steps: Cell<usize>,
|
||||
}
|
||||
|
||||
impl Budget {
|
||||
/// A budget of `probes` lines to try and `steps` baseline steps.
|
||||
pub(super) fn new(probes: usize, steps: usize) -> Self {
|
||||
Self { probes: Cell::new(probes), steps: Cell::new(steps) }
|
||||
}
|
||||
|
||||
/// The lines left to try and the baseline steps left.
|
||||
#[cfg(test)]
|
||||
pub(super) fn left(&self) -> (usize, usize) {
|
||||
(self.probes.get(), self.steps.get())
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Budget {
|
||||
fn default() -> Self {
|
||||
Self::new(MAX_LAYOUT_PROBES, MAX_LAYOUT_STEPS)
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a line starts: glyph `pos`, with the pen where the lines above it left it.
|
||||
struct Start<'r> {
|
||||
pos: usize,
|
||||
/// The paragraph's first line (it takes the first-line indent).
|
||||
first: bool,
|
||||
pen: Pen<'r>,
|
||||
/// The line placed at each of the metrics it was tried with: its width and the pen after it,
|
||||
/// or `None` when the frame has no room for it.
|
||||
placed: Vec<(Metrics, Option<(f64, Pen<'r>)>)>,
|
||||
/// The metrics of glyphs `pos..to`, measured so far.
|
||||
to: usize,
|
||||
m: Option<Metrics>,
|
||||
}
|
||||
|
||||
/// What a line's placement depends on: its first glyph, whether it is the paragraph's first line,
|
||||
/// and the pen's cell, baselines, pending space, narrowest word without room and further spans.
|
||||
type Key = (usize, bool, usize, Option<u64>, Option<u64>, u64, u64, Vec<[u64; 3]>);
|
||||
|
||||
fn key(pos: usize, first: bool, pen: &Pen<'_>) -> Key {
|
||||
(
|
||||
pos,
|
||||
first,
|
||||
pen.ri,
|
||||
pen.prev.map(f64::to_bits),
|
||||
pen.next_top.map(f64::to_bits),
|
||||
pen.pending.to_bits(),
|
||||
pen.no_room_for.to_bits(),
|
||||
pen.queued.iter().map(|&(b, x0, x1)| [b.to_bits(), x0.to_bits(), x1.to_bits()]).collect(),
|
||||
)
|
||||
}
|
||||
|
||||
/// What placing a line reads from the paragraph.
|
||||
struct Para<'a> {
|
||||
sg: &'a [SGlyph],
|
||||
/// How much narrower a line ending with each glyph is set (`ParagraphData::line_end`).
|
||||
line_end: &'a [f64],
|
||||
para: &'a ParaStyle,
|
||||
/// The width a line starting with each glyph gives back (Mojikumi's flush opening bracket).
|
||||
start_credit: &'a dyn Fn(usize) -> f64,
|
||||
mirror: bool,
|
||||
/// The baseline steps left to the search.
|
||||
steps: Rc<Cell<usize>>,
|
||||
}
|
||||
|
||||
/// The measures of a paragraph's lines in a frame where they depend on the lines above.
|
||||
pub(super) struct Shaped<'a, 'r> {
|
||||
para: Para<'a>,
|
||||
continued: bool,
|
||||
pen: Pen<'r>,
|
||||
starts: Vec<Start<'r>>,
|
||||
known: HashMap<Key, usize>,
|
||||
/// The lines tried so far, and the most this search may try.
|
||||
probes: usize,
|
||||
max_probes: usize,
|
||||
/// The baseline steps this search started with.
|
||||
max_steps: usize,
|
||||
/// The layout's budget, which takes what the search spent when it ends.
|
||||
budget: Rc<Budget>,
|
||||
/// The tallest metrics of glyphs `k..` for each `k`, `None` past the last glyph.
|
||||
rest: Vec<Option<Metrics>>,
|
||||
}
|
||||
|
||||
impl<'a, 'r> Shaped<'a, 'r> {
|
||||
/// The measures of the lines of glyphs `sg` of paragraph `para`, set from `pen`. `line_end`
|
||||
/// and `start_credit` are how much narrower a line ending with each glyph is set and how much
|
||||
/// a line starting with it gives back; `continued`: the paragraph's first line is in the
|
||||
/// frame before; `mirror`: its indents are mirrored. The search places its lines with a copy
|
||||
/// of `pen` and takes its baseline steps from the layout's search budget ([`Pen::search`]),
|
||||
/// so the frame's layout keeps its own steps.
|
||||
pub(super) fn new(
|
||||
sg: &'a [SGlyph],
|
||||
line_end: &'a [f64],
|
||||
para: &'a ParaStyle,
|
||||
start_credit: &'a dyn Fn(usize) -> f64,
|
||||
continued: bool,
|
||||
mirror: bool,
|
||||
pen: &Pen<'r>,
|
||||
) -> Self {
|
||||
let mut pen = pen.clone();
|
||||
let budget = Rc::clone(&pen.search);
|
||||
let max_steps = budget.steps.get().min(pen.steps_left.get()).min(MAX_STEPS);
|
||||
let steps = Rc::new(Cell::new(max_steps));
|
||||
pen.steps_left = Rc::clone(&steps);
|
||||
let mut rest = vec![None; sg.len() + 1];
|
||||
for (k, g) in sg.iter().enumerate().rev() {
|
||||
let m = Metrics::of(g);
|
||||
rest[k] = Some(rest[k + 1].map_or(m, |r: Metrics| r.union(m)));
|
||||
}
|
||||
Self {
|
||||
para: Para { sg, line_end, para, start_credit, mirror, steps },
|
||||
continued,
|
||||
pen,
|
||||
starts: vec![],
|
||||
known: HashMap::new(),
|
||||
probes: 0,
|
||||
max_probes: budget.probes.get().min(MAX_PROBES),
|
||||
max_steps,
|
||||
budget,
|
||||
rest,
|
||||
}
|
||||
}
|
||||
|
||||
/// The start of a line at glyph `pos` with `pen`: an existing one when its placement is the same.
|
||||
fn start(&mut self, pos: usize, first: bool, pen: Pen<'r>) -> usize {
|
||||
let k = key(pos, first, &pen);
|
||||
if let Some(&id) = self.known.get(&k) {
|
||||
return id;
|
||||
}
|
||||
self.starts.push(Start { pos, first, pen, placed: vec![], to: pos, m: None });
|
||||
let id = self.starts.len() - 1;
|
||||
self.known.insert(k, id);
|
||||
id
|
||||
}
|
||||
}
|
||||
|
||||
impl Measure for Shaped<'_, '_> {
|
||||
type State = usize;
|
||||
|
||||
fn first(&mut self) -> usize {
|
||||
let pen = self.pen.clone();
|
||||
self.start(0, !self.continued, pen)
|
||||
}
|
||||
|
||||
fn line(&mut self, &id: &usize, pos: usize, end: usize) -> Result<Option<(f64, usize)>, Exhausted> {
|
||||
if self.probes >= self.max_probes {
|
||||
return Err(Exhausted);
|
||||
}
|
||||
self.probes += 1;
|
||||
let Some(start) = self.starts.get_mut(id) else { return Ok(None) };
|
||||
let Some((width, pen)) = line(&self.para, start, pos, end)? else { return Ok(None) };
|
||||
Ok(Some((width, self.start(end, false, pen))))
|
||||
}
|
||||
|
||||
/// A longer line is placed as this one is, and gets the same room, unless a glyph after it
|
||||
/// changes the line's tallest metrics.
|
||||
fn may_widen(&mut self, &id: &usize, pos: usize, end: usize) -> bool {
|
||||
let Some(start) = self.starts.get(id) else { return false };
|
||||
let m = if start.pos == pos && start.to == end { start.m } else { self.para.sg.get(pos..end).and_then(Metrics::max) };
|
||||
match (m, self.rest.get(end).copied().flatten()) {
|
||||
(Some(m), Some(rest)) => m.union(rest) != m,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The search's lines tried and baseline steps spent come off the layout's budget, so the
|
||||
/// paragraphs of one frame share it.
|
||||
impl Drop for Shaped<'_, '_> {
|
||||
fn drop(&mut self) {
|
||||
let spent = self.max_steps.saturating_sub(self.para.steps.get());
|
||||
self.budget.steps.set(self.budget.steps.get().saturating_sub(spent));
|
||||
self.budget.probes.set(self.budget.probes.get().saturating_sub(self.probes));
|
||||
}
|
||||
}
|
||||
|
||||
/// The width of the line of glyphs `pos..end` from `start`, and the pen after it; `None` when
|
||||
/// the frame has no room for it.
|
||||
fn line<'r>(p: &Para<'_>, start: &mut Start<'r>, pos: usize, end: usize) -> Result<Option<(f64, Pen<'r>)>, Exhausted> {
|
||||
if start.pos != pos || end <= pos {
|
||||
return Ok(None);
|
||||
}
|
||||
// The line's tallest metrics, measured on from the glyphs already measured.
|
||||
if end < start.to {
|
||||
start.m = p.sg.get(pos..end).and_then(Metrics::max);
|
||||
start.to = end;
|
||||
} else if end > start.to {
|
||||
let more = p.sg.get(start.to..end).and_then(Metrics::max);
|
||||
start.m = match (start.m, more) {
|
||||
(Some(m), Some(more)) => Some(m.union(more)),
|
||||
(m, more) => m.or(more),
|
||||
};
|
||||
start.to = end;
|
||||
}
|
||||
let Some(m) = start.m else { return Ok(None) };
|
||||
if let Some((_, placed)) = start.placed.iter().find(|(at, _)| *at == m) {
|
||||
return Ok(placed.clone());
|
||||
}
|
||||
let placed = place(p, start, m)?;
|
||||
start.placed.push((m, placed.clone()));
|
||||
Ok(placed)
|
||||
}
|
||||
|
||||
/// The line from `start` placed with metrics `m`: its width and the pen after it, as the line loop
|
||||
/// places and settles it; `None` when no frame cell has room for it.
|
||||
fn place<'r>(p: &Para<'_>, start: &Start<'r>, m: Metrics) -> Result<Option<(f64, Pen<'r>)>, Exhausted> {
|
||||
let (ind_l, ind_r) = line_indents(p.para, start.first, p.mirror);
|
||||
let word = word_width(p.sg, start.pos, p.line_end, p.para.kinsoku) - (p.start_credit)(start.pos);
|
||||
let mut pen = start.pen.clone();
|
||||
loop {
|
||||
match pen.place(m, ind_l, ind_r, word) {
|
||||
Some((baseline, x0, x1, _)) if baseline.is_finite() && (x1 - x0).is_finite() => {
|
||||
if baseline + m.desc > pen.bottom() + 0.01 {
|
||||
// A further span of the line above, too low for this line: the next row or column.
|
||||
pen.ri += 1;
|
||||
pen.prev = None;
|
||||
pen.pending = 0.0;
|
||||
pen.queued.clear();
|
||||
continue;
|
||||
}
|
||||
pen.pending = 0.0;
|
||||
pen.settled(baseline, m);
|
||||
// Further spans of this line band share the settled baseline.
|
||||
for queued in &mut pen.queued {
|
||||
queued.0 = baseline;
|
||||
}
|
||||
return Ok(Some((x1 - x0 - ind_l - ind_r, pen)));
|
||||
}
|
||||
Some(_) => return Ok(None),
|
||||
// Out of steps: the frame may still have room further down.
|
||||
None if p.steps.get() == 0 => return Err(Exhausted),
|
||||
None => return Ok(None),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -533,6 +533,241 @@ fn composition_counts_the_narrower_line_end() {
|
||||
assert_eq!(lines(38.0, &half, &[Breakpoint { end: 4, hyphen: 0.0 }]), Some(vec![(4, false), (7, false)]));
|
||||
}
|
||||
|
||||
/// A line measure for `compose_with`: every line is `width` wide, its state is the glyph it starts
|
||||
/// at, and the search stops once `calls` lines were measured.
|
||||
struct Measured {
|
||||
width: f64,
|
||||
calls: usize,
|
||||
/// A line from a start past this one finds the frame full.
|
||||
full_after: usize,
|
||||
}
|
||||
|
||||
impl crate::composer::Measure for Measured {
|
||||
type State = usize;
|
||||
fn first(&mut self) -> usize {
|
||||
0
|
||||
}
|
||||
fn line(&mut self, &start: &usize, _: usize, end: usize) -> Result<Option<(f64, usize)>, crate::composer::Exhausted> {
|
||||
if self.calls == 0 {
|
||||
return Err(crate::composer::Exhausted);
|
||||
}
|
||||
self.calls -= 1;
|
||||
Ok((start <= self.full_after).then_some((self.width, end)))
|
||||
}
|
||||
}
|
||||
|
||||
/// The every-line composer with a line measure that follows the lines above: it breaks as with
|
||||
/// fixed widths when the widths are the same, finds no composition when the frame is full before
|
||||
/// the paragraph ends, and gives up when the measure's search stops.
|
||||
#[test]
|
||||
fn composition_with_a_measure_from_the_lines_above() {
|
||||
let text = "가나다 라마바";
|
||||
let glyphs = korean(text, false);
|
||||
let cands = candidates(text, &glyphs, None, Kinsoku::Hard);
|
||||
let params = Params { justify_last: false, ragged: true, tolerance: f64::INFINITY, uniform_from: 0, hyphen_limit: 0 };
|
||||
let with = |calls: usize, full_after: usize| {
|
||||
let mut measure = Measured { width: 50.0, calls, full_after };
|
||||
crate::composer::compose_with(&glyphs, &mut measure, &|_| 0.0, &|_| 0.0, &cands, &[], ¶ms)
|
||||
};
|
||||
let fixed = compose(&glyphs, &|_| 50.0, &|_| 0.0, &|_| 0.0, &cands, &[], ¶ms);
|
||||
assert_eq!(fixed, Some(vec![(4, false), (7, false)]));
|
||||
assert_eq!(with(usize::MAX, usize::MAX), fixed);
|
||||
assert_eq!(with(usize::MAX, 0), None, "no room for a second line");
|
||||
assert_eq!(with(1, usize::MAX), None, "the search stopped");
|
||||
}
|
||||
|
||||
/// A line measure for `compose_with` whose lines are `narrow` wide, and `wide` wide when they reach
|
||||
/// past glyph `from` (a line made taller there, and set lower in a frame that widens).
|
||||
struct Widening {
|
||||
narrow: f64,
|
||||
wide: f64,
|
||||
from: usize,
|
||||
}
|
||||
|
||||
impl crate::composer::Measure for Widening {
|
||||
type State = usize;
|
||||
fn first(&mut self) -> usize {
|
||||
0
|
||||
}
|
||||
fn line(&mut self, _: &usize, _: usize, end: usize) -> Result<Option<(f64, usize)>, crate::composer::Exhausted> {
|
||||
Ok(Some((if end > self.from { self.wide } else { self.narrow }, end)))
|
||||
}
|
||||
fn may_widen(&mut self, _: &usize, _: usize, end: usize) -> bool {
|
||||
end <= self.from
|
||||
}
|
||||
}
|
||||
|
||||
/// A line too long for its room does not end the composer's search from its start while a longer
|
||||
/// line may get more room: the longer line is tried and set.
|
||||
#[test]
|
||||
fn composition_tries_a_longer_line_that_gets_more_room() {
|
||||
let text = "가나다 라마바";
|
||||
let glyphs = korean(text, false);
|
||||
let cands = candidates(text, &glyphs, None, Kinsoku::Hard);
|
||||
let params = Params { justify_last: false, ragged: true, tolerance: f64::INFINITY, uniform_from: 0, hyphen_limit: 0 };
|
||||
// "가나다" is 30 wide and does not fit 25; the whole paragraph reaches past glyph 4 and fits 100.
|
||||
let mut measure = Widening { narrow: 25.0, wide: 100.0, from: 4 };
|
||||
let lines = crate::composer::compose_with(&glyphs, &mut measure, &|_| 0.0, &|_| 0.0, &cands, &[], ¶ms);
|
||||
assert_eq!(lines, Some(vec![(7, false)]));
|
||||
}
|
||||
|
||||
/// A line measure for `compose_with` whose lines can't be set until they reach past glyph `from`
|
||||
/// (a line made taller there steps down the frame by its own leading, to a band the shorter
|
||||
/// line's steps miss), and are `wide` wide from there.
|
||||
struct Unplaced {
|
||||
wide: f64,
|
||||
from: usize,
|
||||
}
|
||||
|
||||
impl crate::composer::Measure for Unplaced {
|
||||
type State = usize;
|
||||
fn first(&mut self) -> usize {
|
||||
0
|
||||
}
|
||||
fn line(&mut self, _: &usize, _: usize, end: usize) -> Result<Option<(f64, usize)>, crate::composer::Exhausted> {
|
||||
Ok((end > self.from).then_some((self.wide, end)))
|
||||
}
|
||||
fn may_widen(&mut self, _: &usize, _: usize, end: usize) -> bool {
|
||||
end <= self.from
|
||||
}
|
||||
}
|
||||
|
||||
/// A line that can't be set does not end the composer's search from its start while a longer line
|
||||
/// may be placed elsewhere: the longer line is tried and set.
|
||||
#[test]
|
||||
fn composition_tries_a_longer_line_after_one_that_cannot_be_set() {
|
||||
let text = "가나다 라마바";
|
||||
let glyphs = korean(text, false);
|
||||
let cands = candidates(text, &glyphs, None, Kinsoku::Hard);
|
||||
let params = Params { justify_last: false, ragged: true, tolerance: f64::INFINITY, uniform_from: 0, hyphen_limit: 0 };
|
||||
// "가나다" finds no room; the whole paragraph reaches past glyph 4 and fits 100.
|
||||
let mut measure = Unplaced { wide: 100.0, from: 4 };
|
||||
let lines = crate::composer::compose_with(&glyphs, &mut measure, &|_| 0.0, &|_| 0.0, &cands, &[], ¶ms);
|
||||
assert_eq!(lines, Some(vec![(7, false)]));
|
||||
}
|
||||
|
||||
/// A further span beside a wrap object goes to the line set in it with the room across that line's
|
||||
/// own height. A line taller than the one before it on the baseline keeps clear of an object the
|
||||
/// shorter line did not reach, and moves down when the span has no room for it.
|
||||
#[test]
|
||||
fn a_further_span_is_measured_across_the_height_of_its_line() {
|
||||
use std::cell::Cell;
|
||||
use std::rc::Rc;
|
||||
let frame = Rect::new(0.0, 0.0, 400.0, 400.0).to_path(0.1);
|
||||
let wrap = |r: Rect| vectorcraft_doc::WrapShape {
|
||||
path: PathData::from_bezpath(&r.to_path(0.1)),
|
||||
wrap: vectorcraft_doc::TextWrap { offset: 0.0, invert: false },
|
||||
};
|
||||
let small = breaking::Metrics { asc: 10.0, desc: 3.0, lead: 14.0, cap: 7.0, xh: 5.0, top: 10.0 };
|
||||
let tall = breaking::Metrics { asc: 30.0, desc: 9.0, lead: 42.0, cap: 21.0, xh: 15.0, top: 30.0 };
|
||||
// An object down the middle of the frame splits every line in two, and `above` lies right of
|
||||
// it over y 20 to 40. A small line after a baseline of 50 is set at 64, over y 54 to 67, clear
|
||||
// of `above`; a tall line in the span right of it on that baseline covers y 34 to 73.
|
||||
// The tall line starts with a word `word` wide.
|
||||
let set = |above: Rect, word: f64| {
|
||||
let regions = region::Region::cells(&frame, &LayoutOptions::default(), &[wrap(Rect::new(180.0, 0.0, 220.0, 400.0)), wrap(above)]);
|
||||
let mut pen = breaking::Pen {
|
||||
regions: Some(®ions),
|
||||
ri: 0,
|
||||
prev: Some(50.0),
|
||||
pending: 0.0,
|
||||
fb: FirstBaseline::Ascent,
|
||||
fb_min: 0.0,
|
||||
queued: vec![],
|
||||
model: LeadingModel::RomanBaseline,
|
||||
next_top: None,
|
||||
no_room_for: f64::INFINITY,
|
||||
steps_left: Rc::new(Cell::new(breaking::MAX_TOTAL_STEPS)),
|
||||
search: Default::default(),
|
||||
};
|
||||
let left = pen.place(small, 0.0, 0.0, 0.0);
|
||||
pen.settled(64.0, small);
|
||||
let right = pen.place(tall, 0.0, 0.0, word);
|
||||
(left, right)
|
||||
};
|
||||
let near = |got: Option<(f64, f64, f64, bool)>, want: (f64, f64, f64, bool)| {
|
||||
got.is_some_and(|g| (g.0 - want.0).abs() < 0.01 && (g.1 - want.1).abs() < 0.01 && (g.2 - want.2).abs() < 0.01 && g.3 == want.3)
|
||||
};
|
||||
// `above` covers the right end of the span: the tall line gets the part left of it.
|
||||
let (left, right) = set(Rect::new(300.0, 20.0, 400.0, 40.0), 0.0);
|
||||
assert!(near(left, (64.0, 0.0, 180.0, false)), "{left:?}");
|
||||
assert!(near(right, (64.0, 220.0, 300.0, true)), "{right:?}");
|
||||
// That part is 80 wide: a tall line that starts with a wider word goes on the next baseline.
|
||||
let (_, right) = set(Rect::new(300.0, 20.0, 400.0, 40.0), 100.0);
|
||||
assert!(near(right, (106.0, 0.0, 180.0, false)), "{right:?}");
|
||||
// `above` covers the whole span: the tall line goes on the next baseline, left of the object.
|
||||
let (left, right) = set(Rect::new(220.0, 20.0, 400.0, 40.0), 0.0);
|
||||
assert!(near(left, (64.0, 0.0, 180.0, false)), "{left:?}");
|
||||
assert!(near(right, (106.0, 0.0, 180.0, false)), "{right:?}");
|
||||
// `above` lies below the tall line: the span is as wide for it as for the small line, and it
|
||||
// takes the span whatever its first word, as a further span always did.
|
||||
let (_, right) = set(Rect::new(300.0, 300.0, 400.0, 320.0), 200.0);
|
||||
assert!(near(right, (64.0, 220.0, 400.0, true)), "{right:?}");
|
||||
}
|
||||
|
||||
/// The every-line composer's searches for the paragraphs of one frame share one budget: each takes
|
||||
/// the lines it tried and the baseline steps it spent from it, the layout's own steps stay as they
|
||||
/// were, and once the budget runs out the next paragraph's search stops (it is broken line by line).
|
||||
#[test]
|
||||
fn shaped_composition_searches_share_the_frame_budget() {
|
||||
use super::shaped::{Budget, Shaped};
|
||||
use std::cell::Cell;
|
||||
use std::rc::Rc;
|
||||
let text = "aa bb cc dd ee ff gg hh ii jj";
|
||||
let mut glyphs = full_width(text);
|
||||
glyphs[3].ascent = 30.0;
|
||||
let cands = candidates(text, &glyphs, None, Kinsoku::Hard);
|
||||
// 60 pt wide down to 40 pt, and 30 pt wide below.
|
||||
let frame = BezPath::from_vec(vec![
|
||||
PathEl::MoveTo((0.0, 0.0).into()),
|
||||
PathEl::LineTo((60.0, 0.0).into()),
|
||||
PathEl::LineTo((60.0, 40.0).into()),
|
||||
PathEl::LineTo((30.0, 40.0).into()),
|
||||
PathEl::LineTo((30.0, 400.0).into()),
|
||||
PathEl::LineTo((0.0, 400.0).into()),
|
||||
PathEl::ClosePath,
|
||||
]);
|
||||
let regions = region::Region::cells(&frame, &LayoutOptions::default(), &[]);
|
||||
let para = ParaStyle::default();
|
||||
let params = Params { justify_last: false, ragged: true, tolerance: f64::INFINITY, uniform_from: 0, hyphen_limit: 0 };
|
||||
let pen = |search: Rc<Budget>| breaking::Pen {
|
||||
regions: Some(®ions),
|
||||
ri: 0,
|
||||
prev: None,
|
||||
pending: 0.0,
|
||||
fb: FirstBaseline::Ascent,
|
||||
fb_min: 0.0,
|
||||
queued: vec![],
|
||||
model: LeadingModel::default(),
|
||||
next_top: None,
|
||||
no_room_for: f64::INFINITY,
|
||||
steps_left: Rc::new(Cell::new(breaking::MAX_TOTAL_STEPS)),
|
||||
search,
|
||||
};
|
||||
let compose = |pen: &breaking::Pen<'_>| {
|
||||
let mut measure = Shaped::new(&glyphs, &[], ¶, &|_| 0.0, false, false, pen);
|
||||
crate::composer::compose_with(&glyphs, &mut measure, &|_| 0.0, &|_| 0.0, &cands, &[], ¶ms)
|
||||
};
|
||||
// One paragraph's search, with room to spare.
|
||||
let one = pen(Rc::new(Budget::default()));
|
||||
let before = one.search.left();
|
||||
let lines = compose(&one);
|
||||
assert!(lines.is_some());
|
||||
let after = one.search.left();
|
||||
let (probes, steps) = (before.0 - after.0, before.1 - after.1);
|
||||
assert!(probes > 0 && steps > 0, "{probes} lines tried, {steps} steps");
|
||||
assert_eq!(one.steps_left.get(), breaking::MAX_TOTAL_STEPS, "the layout's own steps");
|
||||
// A budget of three such searches: three paragraphs are composed, the fourth is not.
|
||||
let three = pen(Rc::new(Budget::new(3 * probes, 3 * steps)));
|
||||
for k in 0..3 {
|
||||
assert_eq!(compose(&three), lines, "paragraph {k}");
|
||||
}
|
||||
assert_eq!(three.search.left(), (0, 0));
|
||||
assert_eq!(compose(&three), None, "the budget ran out");
|
||||
assert_eq!(three.search.left(), (0, 0));
|
||||
assert_eq!(three.steps_left.get(), breaking::MAX_TOTAL_STEPS, "the layout's own steps");
|
||||
}
|
||||
|
||||
/// The glyphs of `text`, each 10 pt wide, with Korean words kept whole unless `anywhere` (Korean
|
||||
/// Line Break Anywhere on).
|
||||
fn korean(text: &str, anywhere: bool) -> Vec<SGlyph> {
|
||||
|
||||
@@ -334,14 +334,14 @@ fn composer_x_mixed_sizes() {
|
||||
assert_eq!(x.line, 0);
|
||||
assert!(l.lines[0].ascent > l.lines[1].ascent + 1.0, "{:?}", l.lines);
|
||||
// A rectangle that wraps around an object, and a frame that isn't a rectangle (a corner cut),
|
||||
// both far below the text, break mixed sizes line by line and still break uniform sizes with
|
||||
// the every-line composer.
|
||||
// both far below the text, break mixed sizes and uniform sizes alike with the every-line
|
||||
// composer.
|
||||
let below = PathData::from_bezpath(&Rect::new(0.0, 180.0, 20.0, 190.0).to_path(0.1));
|
||||
for (x, want) in [(&wide, every), (&big, greedy)] {
|
||||
for x in [&wide, &big] {
|
||||
let mut t = area(runs(x.clone()), Rect::new(0.0, 0.0, w, 200.0));
|
||||
t.wrap = vec![vectorcraft_doc::WrapShape { path: below.clone(), wrap: Default::default() }];
|
||||
t.para.composer = Composer::EveryLine;
|
||||
assert_eq!(line_texts(&t, &layout(db(), &t)), want, "wrap, {}", x.size);
|
||||
assert_eq!(line_texts(&t, &layout(db(), &t)), every, "wrap, {}", x.size);
|
||||
}
|
||||
let cut = kurbo::BezPath::from_vec(vec![
|
||||
kurbo::PathEl::MoveTo((0.0, 0.0).into()),
|
||||
@@ -351,14 +351,176 @@ fn composer_x_mixed_sizes() {
|
||||
kurbo::PathEl::LineTo((0.0, 200.0).into()),
|
||||
kurbo::PathEl::ClosePath,
|
||||
]);
|
||||
for (x, want) in [(&wide, every), (&big, greedy)] {
|
||||
for x in [&wide, &big] {
|
||||
let mut t = area(runs(x.clone()), Rect::ZERO);
|
||||
t.kind = TextKind::Area { frame: PathData::from_bezpath(&cut) };
|
||||
t.para.composer = Composer::EveryLine;
|
||||
assert_eq!(line_texts(&t, &layout(db(), &t)), want, "cut corner, {}", x.size);
|
||||
assert_eq!(line_texts(&t, &layout(db(), &t)), every, "cut corner, {}", x.size);
|
||||
}
|
||||
}
|
||||
|
||||
/// Text with the word `X` set in `x` and the rest in 12 pt.
|
||||
fn with_x(x: CharStyle) -> Vec<TextRun> {
|
||||
let st = sans(12.0);
|
||||
vec![
|
||||
run("Destroy all creatures with toughness ", st.clone()),
|
||||
run("X", x),
|
||||
run(" or less. Then draw a card for each creature destroyed this way.", st),
|
||||
]
|
||||
}
|
||||
|
||||
/// The width the text of [`with_x`] is set in.
|
||||
fn x_width() -> f64 {
|
||||
natural_width("Destroy all creatures with toughness X or", 12.0) + 2.0
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn composer_x_mixed_sizes_in_a_stepped_frame() {
|
||||
// A frame as wide as the first line down to 40 pt and half as wide below. A 36 pt word makes
|
||||
// the line it is on reach below the step, where the frame is narrow: the every-line composer
|
||||
// breaks the paragraph with each line's measure where its height sets it, not as the
|
||||
// single-line composer does, and every line keeps within its room.
|
||||
let w = x_width();
|
||||
let set = |x: CharStyle, composer: Composer| {
|
||||
let mut t = stepped(w, w * 0.5, 40.0, 300.0);
|
||||
t.runs = with_x(x);
|
||||
t.para.composer = composer;
|
||||
let l = layout(db(), &t);
|
||||
assert!(past_their_room(&t, &l).is_empty() && !l.overflow, "{composer:?}: {:?}", l.lines);
|
||||
(line_texts(&t, &l), l)
|
||||
};
|
||||
let (every, l) = set(sans(36.0), Composer::EveryLine);
|
||||
assert_eq!(every, ["Destroy all creatures with toughness", "X or less. Then", "draw a card for each", "creature destroyed", "this way."]);
|
||||
// The 36 pt line reaches below the step and is set at the narrow measure.
|
||||
let tall = &l.lines[1];
|
||||
assert!(tall.ascent > 30.0 && tall.baseline + tall.descent > 40.0 && tall.avail.1 < w * 0.5 + 0.01, "{:?}", l.lines);
|
||||
let (greedy, _) = set(sans(36.0), Composer::SingleLine);
|
||||
assert_ne!(every, greedy);
|
||||
// Uniform sizes keep the breaks they had.
|
||||
let (uniform, _) = set(sans(12.0), Composer::EveryLine);
|
||||
assert_eq!(uniform, ["Destroy all creatures with toughness X or", "less. Then draw a card for each creature", "destroyed this way."]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn composer_x_mixed_sizes_around_text_wrap() {
|
||||
// A wrap object across the middle of the frame from 40 pt down splits the lines beside it in
|
||||
// two. The line of the 36 pt word, which starts with a 12 pt word, falls beside it, and the
|
||||
// every-line composer measures each of the two spans across the line's whole height: no
|
||||
// glyph runs into the object.
|
||||
let w = x_width();
|
||||
let object = Rect::new(w * 0.4, 40.0, w * 0.6, 70.0);
|
||||
let set = |composer: Composer| {
|
||||
let mut runs = with_x(sans(36.0));
|
||||
// A no-break space keeps the 36 pt word on the line of the 12 pt word before it.
|
||||
runs[0] = run("Destroy all creatures with toughness\u{a0}", sans(12.0));
|
||||
let mut t = area(runs, Rect::new(0.0, 0.0, w, 300.0));
|
||||
t.wrap = vec![vectorcraft_doc::WrapShape { path: PathData::from_bezpath(&object.to_path(0.1)), wrap: Default::default() }];
|
||||
t.para.composer = composer;
|
||||
let l = layout(db(), &t);
|
||||
(line_texts(&t, &l), l, t)
|
||||
};
|
||||
let (every, l, t) = set(Composer::EveryLine);
|
||||
assert_eq!(
|
||||
every,
|
||||
["Destroy all creatures with", "toughness\u{a0}X", "or less. Then", "draw a card for", "each creature", "destroyed this", "way."]
|
||||
);
|
||||
assert!(past_their_room(&t, &l).is_empty() && !l.overflow, "{:?}", l.lines);
|
||||
// Two spans share each baseline beside the object.
|
||||
assert_eq!(l.lines[1].baseline, l.lines[2].baseline);
|
||||
assert!(l.lines[1].ascent > 30.0 && l.lines[2].avail.0 > object.x1, "{:?}", l.lines);
|
||||
let text = t.plain_text();
|
||||
let hits = l.glyphs.iter().filter(|g| {
|
||||
let li = &l.lines[g.line];
|
||||
let across = li.baseline - li.ascent < object.y1 && li.baseline + li.descent > object.y0;
|
||||
across && !text[g.byte..].starts_with(char::is_whitespace) && g.origin.x < object.x1 && g.origin.x + g.advance > object.x0
|
||||
});
|
||||
assert_eq!(hits.count(), 0, "{:?}", l.lines);
|
||||
let (greedy, _, _) = set(Composer::SingleLine);
|
||||
assert_ne!(every, greedy);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn composer_x_mixed_sizes_in_a_frame_that_widens() {
|
||||
// A frame as wide as "Destroy all" down to 20 pt and as wide as the first line of
|
||||
// `composer_x_mixed_sizes_in_a_stepped_frame` below. The second line is too long for the narrow
|
||||
// part until it takes in the 48 pt word, which sets it lower, where the frame is wide: the
|
||||
// every-line composer tries that longer line and sets it there.
|
||||
let w = x_width();
|
||||
let narrow = natural_width("Destroy all", 12.0) + 2.0;
|
||||
let set = |composer: Composer| {
|
||||
let mut t = stepped(narrow, w, 20.0, 300.0);
|
||||
t.runs = with_x(sans(48.0));
|
||||
t.para.composer = composer;
|
||||
let l = layout(db(), &t);
|
||||
assert!(past_their_room(&t, &l).is_empty() && !l.overflow, "{composer:?}: {:?}", l.lines);
|
||||
(line_texts(&t, &l), l)
|
||||
};
|
||||
let (every, l) = set(Composer::EveryLine);
|
||||
assert_eq!(every, ["Destroy all", "creatures with toughness X or less.", "Then draw a card for each creature", "destroyed this way."]);
|
||||
let tall = &l.lines[1];
|
||||
assert!(tall.ascent > 40.0 && tall.baseline - tall.ascent > 20.0 && tall.avail.1 > narrow + 1.0, "{:?}", l.lines);
|
||||
let (greedy, _) = set(Composer::SingleLine);
|
||||
assert_eq!(greedy, ["Destroy all", "creatures", "with toughness X or less. Then draw", "a card for each creature destroyed this", "way."]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn composer_x_mixed_sizes_overflow_a_shaped_frame() {
|
||||
// A stepped frame too short for the paragraph: the lines that fit are set, in order, each once
|
||||
// and within its room, and the frame overflows.
|
||||
let w = x_width();
|
||||
let mut t = stepped(w, w * 0.5, 40.0, 75.0);
|
||||
t.runs = with_x(sans(36.0));
|
||||
t.para.composer = Composer::EveryLine;
|
||||
let l = layout(db(), &t);
|
||||
assert!(l.overflow && l.lines.len() >= 2, "{:?}", l.lines);
|
||||
assert_eq!(l.lines[0].start, 0);
|
||||
assert!(l.lines.windows(2).all(|p| p[0].end == p[1].start && p[0].start < p[0].end), "{:?}", l.lines);
|
||||
assert!(past_their_room(&t, &l).is_empty(), "{:?}", l.lines);
|
||||
assert!(l.lines.iter().all(|li| li.baseline + li.descent <= 75.0 + 0.01), "{:?}", l.lines);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn composer_x_mixed_sizes_long_paragraph_in_a_shaped_frame() {
|
||||
// A long paragraph of mixed sizes in a stepped frame is laid out whole, its lines in order and
|
||||
// each within its room, whether the composer's search finishes or stops at its limits.
|
||||
let w = x_width();
|
||||
let mut t = stepped(w, w * 0.5, 40.0, 100_000.0);
|
||||
t.runs = (0..150).flat_map(|_| with_x(sans(36.0))).collect();
|
||||
t.para.composer = Composer::EveryLine;
|
||||
let l = layout(db(), &t);
|
||||
assert!(!l.overflow, "{} lines", l.lines.len());
|
||||
assert_eq!(l.lines.last().map(|li| li.end), Some(t.plain_text().len()));
|
||||
assert!(l.lines.windows(2).all(|p| p[0].end == p[1].start), "lines out of order");
|
||||
assert!(past_their_room(&t, &l).is_empty());
|
||||
}
|
||||
|
||||
/// A frame `w` wide down to `y`, and `narrow` wide below it.
|
||||
fn stepped(w: f64, narrow: f64, y: f64, bottom: f64) -> TextObject {
|
||||
let frame = kurbo::BezPath::from_vec(vec![
|
||||
kurbo::PathEl::MoveTo((0.0, 0.0).into()),
|
||||
kurbo::PathEl::LineTo((w, 0.0).into()),
|
||||
kurbo::PathEl::LineTo((w, y).into()),
|
||||
kurbo::PathEl::LineTo((narrow, y).into()),
|
||||
kurbo::PathEl::LineTo((narrow, bottom).into()),
|
||||
kurbo::PathEl::LineTo((0.0, bottom).into()),
|
||||
kurbo::PathEl::ClosePath,
|
||||
]);
|
||||
let mut t = area(vec![], Rect::ZERO);
|
||||
t.kind = TextKind::Area { frame: PathData::from_bezpath(&frame) };
|
||||
t
|
||||
}
|
||||
|
||||
/// The lines of `l` whose glyphs reach past either end of the room they were set in.
|
||||
fn past_their_room(t: &TextObject, l: &TextLayout) -> Vec<usize> {
|
||||
let text = t.plain_text();
|
||||
let past = |i: usize| {
|
||||
let (a, b) = l.lines[i].avail;
|
||||
let mut glyphs = l.glyphs.iter().filter(|g| g.line == i && !text[g.byte..].starts_with(char::is_whitespace));
|
||||
glyphs.any(|g| g.origin.x < a - 0.01 || g.origin.x + g.advance > b + 0.01)
|
||||
};
|
||||
(0..l.lines.len()).filter(|&i| past(i)).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn composer_x_mixed_line_metrics() {
|
||||
// In a rectangle the every-line composer also breaks a paragraph whose characters differ in
|
||||
|
||||
+6
-2
@@ -9918,8 +9918,12 @@ justified text gets even word spacing, ragged text (left, centre or right aligne
|
||||
that fits to the next line to avoid a lone short word on the last line or a full line next to a short one.
|
||||
Single-line fills each line as far as it goes. Point type and type on a path have no line width, so both composers
|
||||
give the same result there. A paragraph whose characters differ in line metrics (type size, font, leading or vertical
|
||||
scale, or an inline graphic taller than the text) is composed with Every-line in rectangular area type, rows and
|
||||
columns included; in area type of another shape, or with text wrap, it falls back to Single-line.
|
||||
scale, or an inline graphic taller than the text) is composed with Every-line in area type of any shape, rows,
|
||||
columns and text wrap included. In a frame that is not a rectangle, or with text wrap, each line it tries is measured
|
||||
where the lines above it put it, across the line's own height, so a taller line that reaches where the frame is
|
||||
narrower, or beside a wrap object, gets the room there. Such a paragraph falls back to Single-line when the frame has
|
||||
no room for all of it, or when the search for its breaks passes VectorCraft's own limits (1,000,000 lines tried and
|
||||
2,000,000 baseline steps for one paragraph, 2,000,000 lines and 4,000,000 steps for all the paragraphs of a frame).
|
||||
The UI command `type.lineComposer` (Cmd+Alt+Shift+C, in remote mode `press_key {key: "C", mods: {cmd: true, alt: true,
|
||||
shift: true}}` or `invoke_menu`) sets `singleLine` when every paragraph of the selected type (or every paragraph the
|
||||
Type tool's selection touches) uses Every-line, else `everyLine`, and returns the composer it set.
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user