Merge pull request #7481 from dotCooCoo/feat/every-line-composer-shaped-overflow

Type: the Every-line Composer sets the visible lines of a mixed-size paragraph that overflows a shaped frame
This commit is contained in:
BFlat
2026-11-14 16:51:54 +07:00
committed by GitHub
5 changed files with 199 additions and 34 deletions
+57 -24
View File
@@ -5,6 +5,11 @@
//! 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.
//!
//! A paragraph the frame cannot hold is still composed whole: a line the frame has no room for,
//! and every line after it, is measured as wide as the span of the last line set in the frame,
//! less its own indents. The line loop sets only the lines that fit, and the rest overflows or
//! goes on in the next threaded frame, which composes it again at its own measures.
use std::cell::Cell;
use std::collections::HashMap;
@@ -63,19 +68,33 @@ struct Start<'r> {
/// 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 width of the span the line above was set in, `None` for the paragraph's first line.
span: Option<f64>,
/// The line above was past the frame's end: this line is too, at the width of `span`.
past: bool,
/// The line placed at each of the metrics it was tried with, or `None` when it can't be set.
placed: Vec<(Metrics, Option<Placed<'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]>);
/// A line set from a [`Start`]: its width, the pen after it, the width of the span it was set
/// in, and whether it is past the frame's end (measured, but not in the frame).
#[derive(Clone)]
struct Placed<'r> {
width: f64,
pen: Pen<'r>,
span: f64,
past: bool,
}
fn key(pos: usize, first: bool, pen: &Pen<'_>) -> Key {
/// What a line's placement depends on: its first glyph, whether it is the paragraph's first line,
/// the pen's cell, baselines, pending space, narrowest word without room and further spans, the
/// span of the line above and whether the line is past the frame's end.
type Key = (usize, bool, usize, Option<u64>, Option<u64>, u64, u64, Vec<[u64; 3]>, Option<u64>, bool);
fn key(pos: usize, first: bool, pen: &Pen<'_>, span: Option<f64>, past: bool) -> Key {
(
pos,
first,
@@ -85,6 +104,8 @@ fn key(pos: usize, first: bool, pen: &Pen<'_>) -> Key {
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(),
span.map(f64::to_bits),
past,
)
}
@@ -159,13 +180,14 @@ impl<'a, 'r> Shaped<'a, 'r> {
}
}
/// 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);
/// The start of a line at glyph `pos` with `pen`, after a line set in a span `span` wide (past
/// the frame's end with `past`): an existing one when its placement is the same.
fn start(&mut self, pos: usize, first: bool, pen: Pen<'r>, span: Option<f64>, past: bool) -> usize {
let k = key(pos, first, &pen, span, past);
if let Some(&id) = self.known.get(&k) {
return id;
}
self.starts.push(Start { pos, first, pen, placed: vec![], to: pos, m: None });
self.starts.push(Start { pos, first, pen, span, past, placed: vec![], to: pos, m: None });
let id = self.starts.len() - 1;
self.known.insert(k, id);
id
@@ -177,7 +199,7 @@ impl Measure for Shaped<'_, '_> {
fn first(&mut self) -> usize {
let pen = self.pen.clone();
self.start(0, !self.continued, pen)
self.start(0, !self.continued, pen, None, false)
}
fn line(&mut self, &id: &usize, pos: usize, end: usize) -> Result<Option<(f64, usize)>, Exhausted> {
@@ -186,14 +208,14 @@ impl Measure for Shaped<'_, '_> {
}
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))))
let Some(placed) = line(&self.para, start, pos, end)? else { return Ok(None) };
Ok(Some((placed.width, self.start(end, false, placed.pen, Some(placed.span), placed.past))))
}
/// 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.
/// changes the line's tallest metrics. Past the frame's end every line is as wide.
fn may_widen(&mut self, &id: &usize, pos: usize, end: usize) -> bool {
let Some(start) = self.starts.get(id) else { return false };
let Some(start) = self.starts.get(id).filter(|start| !start.past) 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,
@@ -212,12 +234,14 @@ impl Drop for Shaped<'_, '_> {
}
}
/// 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> {
/// The line of glyphs `pos..end` from `start`; `None` when it can't be set.
fn line<'r>(p: &Para<'_>, start: &mut Start<'r>, pos: usize, end: usize) -> Result<Option<Placed<'r>>, Exhausted> {
if start.pos != pos || end <= pos {
return Ok(None);
}
if start.past {
return Ok(past_end(p, start));
}
// 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);
@@ -239,9 +263,18 @@ fn line<'r>(p: &Para<'_>, start: &mut Start<'r>, pos: usize, end: usize) -> Resu
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> {
/// A line from `start` past the frame's end: as wide as the span of the last line set in the frame,
/// less its indents, and the pen left where the frame ran out. `None` when the paragraph has no line
/// in the frame to measure it by.
fn past_end<'r>(p: &Para<'_>, start: &Start<'r>) -> Option<Placed<'r>> {
let span = start.span.filter(|span| span.is_finite())?;
let (ind_l, ind_r) = line_indents(p.para, start.first, p.mirror);
Some(Placed { width: span - ind_l - ind_r, pen: start.pen.clone(), span, past: true })
}
/// The line from `start` placed with metrics `m`, as the line loop places and settles it; past the
/// frame's end ([`past_end`]) when no frame cell has room for it.
fn place<'r>(p: &Para<'_>, start: &Start<'r>, m: Metrics) -> Result<Option<Placed<'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();
@@ -262,12 +295,12 @@ fn place<'r>(p: &Para<'_>, start: &Start<'r>, m: Metrics) -> Result<Option<(f64,
for queued in &mut pen.queued {
queued.0 = baseline;
}
return Ok(Some((x1 - x0 - ind_l - ind_r, pen)));
return Ok(Some(Placed { width: x1 - x0 - ind_l - ind_r, pen, span: x1 - x0, past: false }));
}
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),
None => return Ok(past_end(p, start)),
}
}
}
+60 -6
View File
@@ -465,18 +465,72 @@ fn composer_x_mixed_sizes_in_a_frame_that_widens() {
#[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.
// A stepped frame too short for the paragraph: the every-line composer still breaks the lines
// that fit as one paragraph, with the lines past the frame's end measured as wide as the last
// line the frame holds. Here that is the narrow part, so the lines that fit break as in a frame
// tall enough for the whole paragraph, and not as the single-line composer breaks them. They
// 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);
let set = |bottom: f64, composer: Composer| {
let mut t = stepped(w, w * 0.5, 40.0, bottom);
t.runs = with_x(sans(36.0));
t.para.composer = composer;
let l = layout(db(), &t);
(line_texts(&t, &l), l, t)
};
let (every, l, t) = set(75.0, Composer::EveryLine);
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);
assert_eq!(every, ["Destroy all creatures with toughness", "X or less. Then", "draw a card for each"]);
let (whole, ..) = set(300.0, Composer::EveryLine);
assert_eq!(every, whole[..every.len()]);
let (greedy, ..) = set(75.0, Composer::SingleLine);
assert_ne!(every, greedy);
// A frame with no room for a line sets none.
for composer in [Composer::EveryLine, Composer::SingleLine] {
let mut t = stepped(w, w * 0.5, 4.0, 8.0);
t.runs = with_x(sans(36.0));
t.para.composer = composer;
let l = layout(db(), &t);
assert!(l.lines.is_empty() && l.overflow, "{composer:?}: {:?}", l.lines);
}
}
#[test]
fn composer_x_mixed_sizes_overflow_around_text_wrap() {
// A rectangle that wraps around an object beside its lower lines, too short for the
// paragraph: the every-line composer breaks the lines that fit as one paragraph, each span
// measured across its line's whole height, and the frame overflows. 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));
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, 80.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!(l.overflow && l.lines.len() >= 3, "{:?}", l.lines);
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);
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);
// The lines that fit break as in `composer_x_mixed_sizes_around_text_wrap`'s taller frame.
assert_eq!(every, ["Destroy all creatures with", "toughness\u{a0}X", "or less. Then", "draw a card for", "each creature"]);
let (greedy, ..) = set(Composer::SingleLine);
assert_ne!(every, greedy);
}
#[test]
+74
View File
@@ -397,6 +397,80 @@ fn a_continued_paragraph_fills_the_frame_it_continues_into() {
assert_eq!(checked, 16);
}
/// Area type in a frame `w` wide down to `y` and `narrow` wide below it, down to `bottom`.
fn stepped_frame(w: f64, narrow: f64, y: f64, bottom: f64) -> TextObject {
let mut t = frame(0.0);
let path = 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,
]);
t.kind = TextKind::Area { frame: PathData::from_bezpath(&path) };
t
}
/// A paragraph of mixed type sizes that a stepped frame cannot hold goes on in the next frame. The
/// every-line composer breaks the lines the first frame holds as one paragraph, as in a frame tall
/// enough for all of it and not as the single-line composer breaks them, and the first frame
/// takes the text of those lines. The next frame sets the rest at its own width, without the
/// first-line indent, and no character is lost or repeated. The frames take the same shares as
/// when each is measured with the whole rest of the story.
#[test]
fn a_mixed_size_paragraph_continued_from_a_shaped_frame_is_composed_in_both() {
use vectorcraft_doc::Composer;
let db = FontDb::global();
let small = CharStyle { size: 12.0, ..CharStyle::default() };
let big = CharStyle { size: 36.0, ..CharStyle::default() };
let probe = layout(db, &TextObject::point(vectorcraft_geom::Point::ZERO, "Destroy all creatures with toughness X or", small.clone()));
let w = probe.lines[0].x1 - probe.lines[0].x0 + 2.0;
let story = vec![
TextRun { text: "Destroy all creatures with toughness ".into(), style: small.clone(), inline: None },
TextRun { text: "X".into(), style: big, inline: None },
TextRun { text: " or less. Then draw a card for each creature destroyed this way.\nSecond paragraph.".into(), style: small, inline: None },
];
let text: String = story.iter().map(|r| r.text.as_str()).collect();
let (a, tall) = (stepped_frame(w, w * 0.5, 40.0, 85.0), stepped_frame(w, w * 0.5, 40.0, 300.0));
let b = sized(200.0, w * 0.75, 300.0);
let texts = |lay: &crate::TextLayout, shown: &str| lay.lines.iter().map(|l| shown[l.start..l.end].trim_end().to_string()).collect::<Vec<_>>();
let shares = |composer: Composer, indent: f64| {
let para = ParaStyle { composer, first_line_indent: indent, ..Default::default() };
let paras = [para.clone(), para];
let parts = distribute(db, &[&a, &b], &story, &paras);
assert_eq!(parts.iter().map(|p| p.continues).collect::<Vec<_>>(), [false, true], "{composer:?}");
let joined: String = parts.iter().flat_map(|p| &p.runs).map(|r| r.text.as_str()).collect();
assert_eq!(joined, text, "{composer:?}: no character lost or repeated");
same_as_whole_rest(db, &[&a, &b], &story, &paras, &format!("{composer:?}, indent {indent}"));
let shown = |p: &Slice| p.runs.iter().map(|r| r.text.as_str()).collect::<String>();
let lays: Vec<_> = [&a, &b].iter().zip(&parts).map(|(f, p)| laid(db, f, p)).collect();
assert!(lays.iter().all(|l| !l.overflow), "{composer:?}: each frame holds its share");
let whole =
laid(db, &tall, &Slice { runs: story.clone(), paras: paras.to_vec(), list_count: 0, list_level_counts: vec![], continues: false });
let first = texts(&lays[0], &shown(&parts[0]));
// The first frame takes the text of the lines it sets.
assert_eq!(shown(&parts[0]).split_whitespace().collect::<Vec<_>>(), first.join(" ").split_whitespace().collect::<Vec<_>>());
(first, texts(&whole, &text), lays, parts)
};
for indent in [0.0, 20.0] {
let (every, whole, lays, parts) = shares(Composer::EveryLine, indent);
assert!(every.len() >= 2, "{every:?}");
assert_eq!(every, whole[..every.len()], "indent {indent}");
if indent == 0.0 {
assert_eq!(every, ["Destroy all creatures with toughness", "X or less. Then", "draw a card for each"]);
let (greedy, ..) = shares(Composer::SingleLine, indent);
assert_eq!(greedy, ["Destroy all creatures with toughness X", "or less. Then draw a", "card for each", "creature destroyed"]);
}
// The next frame starts at its left edge, and its lines keep within it.
assert!((line_start(&lays[1], 0) - 200.0).abs() < 1e-6, "{}", line_start(&lays[1], 0));
assert!(lays[1].lines.iter().all(|l| l.x1 <= 200.0 + w * 0.75 + 1e-6), "{:?}", lays[1].lines);
assert!((line_start(&lays[0], 0) - indent).abs() < 1e-6, "{}", line_start(&lays[0], 0));
assert!(parts[1].continues && lays[1].lines.len() >= 2, "{:?}", lays[1].lines);
}
}
/// `frames` holding the runs of `parts`, for [`continued`].
fn filled(frames: &[&TextObject], parts: &[Slice]) -> Vec<TextObject> {
frames
+7 -3
View File
@@ -10494,9 +10494,13 @@ give the same result there. A paragraph whose characters differ in line metrics
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).
narrower, or beside a wrap object, gets the room there. When the frame has no room for all of such a paragraph, the
lines it holds are still composed with the whole paragraph: VectorCraft measures the lines past the frame's end as
wide as the span of the last line the frame holds, less their indents. Those lines overflow, or go on in the next
threaded frame, which composes them again at its own widths. Such a paragraph falls back to Single-line when the
frame has no room for its first line, 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