mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-14 14:41:52 +00:00
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:
@@ -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)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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, ¶s);
|
||||
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, ¶s, &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
@@ -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
Reference in New Issue
Block a user