Type: the Single-line Composer measures a line's room across its tallest character

In area type of a shape other than a rectangle, or with text wrap, the
Single-line Composer placed each line at the metrics of its first
character and broke it in the room there. When a later character on the
line was taller, the baseline moved down by the difference without
measuring the room again, so the line could reach into a narrower part
of the frame or into a wrap object.

- A line that turns out taller than it was placed is placed again at
  metrics that hold both and broken again in the room there.
- When that leaves the taller character for the next line, the line is
  set at its own metrics where the room there is no narrower, and takes
  the words before that character that fit there. Otherwise it stays
  where it was placed.

Tests: crates/text/src/tests/combos.rs (a stepped frame and text wrap;
the two mixed-size tests now expect the Single-line Composer to break
as the Every-line Composer does there).

Docs: docs/mcp.md, docs/target-app-parity.md.
This commit is contained in:
dotCooCoo
2026-11-13 20:46:39 -08:00
parent 1e4e3534f5
commit cf7a72e21b
4 changed files with 107 additions and 12 deletions
+65 -7
View File
@@ -24,6 +24,10 @@ fn pushes_in(para: &ParaStyle) -> bool {
/// Paragraphs longer than this (glyphs) are always broken line by line.
const MAX_COMPOSE_GLYPHS: usize = 200_000;
/// The widths tried, halving the difference each time, for the longest break of a line placed again
/// at its own metrics in more room than it was broken in ([`place_flow_line`]).
const REFIT_STEPS: usize = 24;
/// 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 {
@@ -415,25 +419,31 @@ fn place_flow_line(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, context: &mut PlacementC
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)) {
let composed_line = composed.as_ref().and_then(|lines| lines.get(*para_line)).is_some_and(|&(end, _)| end > *i);
let mut 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,
};
// A line broken here in such a frame is placed again when it turns out taller than it was
// placed: its room is the room across its own height.
let refit = !composed_line && pen.regions.is_some() && !constant_width(pen.regions);
// 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 {
let before = refit.then(|| pen.clone());
let first_in_region = pen.prev.is_none();
let Some((mut baseline, x0, x1, same_baseline)) = pen.place(est, ind_l, ind_r, word) else {
let Some((mut baseline, mut x0, mut x1, same_baseline)) = pen.place(est, ind_l, ind_r, word) else {
cx.out.overflow = cx.text.len() > if *i < n { sg[*i].byte } else { pr.start };
return None;
};
let width = x1 - x0 - ind_l - ind_r;
let hyphenate = (para.hyphenate && (*hyphen_limit == 0 || *hyphen_run < *hyphen_limit)).then_some((&para.hyphenation, *zone));
let (end, hyph, pushed_in) = match composed.as_ref().and_then(|lines| lines.get(*para_line)) {
let glyphs: &[SGlyph] = sg;
// The line broken in room `width` wide: its end, whether it ends with a hyphen, and whether
// it pushed characters in.
let break_in = |width: f64| match composed.as_ref().and_then(|lines| lines.get(*para_line)) {
Some(&(end, hyph)) if end > *i => (end, hyph, false),
_ if *i < n => {
let start = *i;
let glyphs: &[SGlyph] = sg;
// A horizontal line with right-to-left text is drawn in another order than its
// text's, and its tabs take their stops in the order they are drawn: whether the line
// ending before glyph `end` fits is measured as it is drawn ([`drawn_width`]).
@@ -478,8 +488,56 @@ fn place_flow_line(cx: &mut Ctx<'_>, pen: &mut Pen<'_>, context: &mut PlacementC
}
_ => (n, false, false),
};
let m = Metrics::max(&sg[*i..end]).unwrap_or(*pm);
baseline += if same_baseline || (pen.regions.is_none() && first_in_region) {
let (mut end, mut hyph, mut pushed_in) = break_in(x1 - x0 - ind_l - ind_r);
let metrics = |end: usize| glyphs.get(*i..end).and_then(Metrics::max).unwrap_or(*pm);
let m = metrics(end);
// The baseline moves from where `est` placed it to where the line's metrics set it, unless
// the line was placed at metrics of its own (below).
let mut settle = true;
if let Some(before) = before
&& m != est
{
if m.union(est) != est {
// Taller than it was placed: place it again at metrics that hold both, and break it
// again in the room there.
*pen = before;
est = est.union(m);
continue;
}
// Broken again in the room of a taller line, it no longer holds what made that line
// taller. It goes where its own metrics place it when the room there holds the room it
// was broken in, and otherwise stays where it was placed, inside the room measured.
settle = false;
let mut own = before;
if let Some((b, a, z, _)) = own.place(m, ind_l, ind_r, word)
&& a <= x0 + EPS
&& z >= x1 - EPS
{
// The room there may hold more of the line, up to what would make it taller than
// `m`: the longest break that does not is between the two widths (a line breaks no
// earlier in more room).
let (mut lo, mut hi) = (x1 - x0 - ind_l - ind_r, z - a - ind_l - ind_r);
let wider = break_in(hi);
if metrics(wider.0).union(m) == m {
(end, hyph, pushed_in) = wider;
} else {
for _ in 0..REFIT_STEPS {
let mid = (lo + hi) / 2.0;
let line = break_in(mid);
if metrics(line.0).union(m) == m {
(end, hyph, pushed_in) = line;
lo = mid;
} else {
hi = mid;
}
}
}
*pen = own;
(baseline, x0, x1) = (b, a, z);
}
}
let m = metrics(end);
baseline += if !settle || same_baseline || (pen.regions.is_none() && first_in_region) {
0.0
} else if pen.model == LeadingModel::EmBoxTop {
// The em box top is fixed (the frame's top, or the line above's): the baseline
+37 -4
View File
@@ -378,8 +378,8 @@ fn x_width() -> f64 {
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.
// breaks the paragraph with each line's measure where its height sets it, 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);
@@ -394,8 +394,10 @@ fn composer_x_mixed_sizes_in_a_stepped_frame() {
// 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);
// The single-line composer measures each line across its height too, and breaks this
// paragraph the same way.
let (greedy, _) = set(sans(36.0), Composer::SingleLine);
assert_ne!(every, greedy);
assert_eq!(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."]);
@@ -435,8 +437,10 @@ fn composer_x_mixed_sizes_around_text_wrap() {
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 single-line composer measures each line across its height too, and breaks this
// paragraph the same way.
let (greedy, _, _) = set(Composer::SingleLine);
assert_ne!(every, greedy);
assert_eq!(every, greedy);
}
#[test]
@@ -463,6 +467,35 @@ fn composer_x_mixed_sizes_in_a_frame_that_widens() {
assert_eq!(greedy, ["Destroy all", "creatures", "with toughness X or less. Then draw", "a card for each creature destroyed this", "way."]);
}
#[test]
fn single_line_composer_measures_a_line_across_its_tallest_glyph() {
// A frame wide enough for "Destroy all creatures X" down to 20 pt and as wide as "Destroy all"
// below, and a rectangle as wide with an object (6 pt wrap offset) beside that part. The first
// line starts with 12 pt words and takes in a 36 pt word, which sets it lower and reaches below
// 20 pt: the single-line composer measures its room across that height, every line that
// reaches the narrow part is set in it, and the first line keeps the 12 pt words that fit
// above it.
let w = natural_width("Destroy all creatures X", 12.0) + 30.0;
let narrow = natural_width("Destroy all", 12.0) + 2.0;
let runs = vec![run("Destroy all creatures ", sans(12.0)), run("X", sans(36.0)), run(" or less. Then draw a card.", sans(12.0))];
let object = Rect::new(narrow + 6.0, 26.0, w + 10.0, 300.0);
let mut beside = area(runs.clone(), Rect::new(0.0, 0.0, w, 300.0));
beside.wrap = vec![vectorcraft_doc::WrapShape { path: PathData::from_bezpath(&object.to_path(0.1)), wrap: Default::default() }];
let mut shaped = stepped(w, narrow, 20.0, 300.0);
shaped.runs = runs;
for (name, mut t) in [("stepped frame", shaped), ("text wrap", beside)] {
t.para.composer = Composer::SingleLine;
let l = layout(db(), &t);
assert!(past_their_room(&t, &l).is_empty() && !l.overflow, "{name}: {:?}", l.lines);
let low = l.lines.iter().filter(|li| li.baseline + li.descent > 20.01);
assert!(low.clone().all(|li| li.avail.1 <= narrow + 0.01), "{name}: {:?}", l.lines);
// The 36 pt word is set, on a line in the narrow part.
assert!(low.clone().any(|li| li.ascent > 30.0), "{name}: {:?}", l.lines);
assert_eq!(line_texts(&t, &l)[0], "Destroy all creatures", "{name}: {:?}", l.lines);
assert!(l.lines[0].baseline < 20.0 && l.lines[0].avail.1 > w - 0.01, "{name}: {:?}", l.lines);
}
}
#[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
+4
View File
@@ -10448,6 +10448,10 @@ where the lines above it put it, across the line's own height, so a taller line
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).
Single-line measures each line's room the same way: a line that takes in a taller character is placed again across its
new height and broken again in the room there. When that leaves the taller character for the next line, the line is
set at its own height where the room there is no narrower, with the words before that character that fit there, and
otherwise stays where it was placed.
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