Merge pull request #3295 from dotCooCoo/feat/every-line-mixed-sizes

Type: the Every-line Composer composes paragraphs that mix type sizes in rectangular area type
This commit is contained in:
BFlat
2026-10-21 17:28:46 +07:00
committed by GitHub
4 changed files with 140 additions and 8 deletions
+15 -6
View File
@@ -1607,7 +1607,9 @@ fn hyphen_breaks(text: &str, g: &[SGlyph], ws: usize, we: usize, opts: &Hyphenat
}
/// Break candidates for the every-line composer: hyphenation points under `hyphenate` (the
/// paragraph's Hyphenation options) when it is given.
/// paragraph's Hyphenation options) when it is given. No break comes before a space: a line that
/// may end after a dash, a Thai word or a space ends after the spaces that follow it, as in
/// [`break_line`].
fn candidates(text: &str, g: &[SGlyph], hyphenate: Option<&HyphenationOptions>, kinsoku: Kinsoku) -> Vec<Breakpoint> {
let mut v = vec![];
let mut ws = 0;
@@ -1622,7 +1624,7 @@ fn candidates(text: &str, g: &[SGlyph], hyphenate: Option<&HyphenationOptions>,
}
}
let hy = if gl.is_soft_hyphen() { hyphen_glyph(gl).adv } else { 0.0 };
if j + 1 < g.len() && g[j + 1].byte != gl.byte && kinsoku_allows(g, j, kinsoku) {
if j + 1 < g.len() && g[j + 1].byte != gl.byte && !g[j + 1].is_space() && kinsoku_allows(g, j, kinsoku) {
v.push(Breakpoint { end: j + 1, hyphen: hy });
}
ws = j + 1;
@@ -1643,8 +1645,9 @@ fn candidates(text: &str, g: &[SGlyph], hyphenate: Option<&HyphenationOptions>,
/// Paragraphs longer than this (glyphs) are always broken line by line.
const MAX_COMPOSE_GLYPHS: usize = 200_000;
/// Every-line composition of paragraph glyphs `sg` if applicable (area text with uniform line
/// metrics, justified or ragged); `None` falls back to the greedy single-line composer.
/// 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.
fn compose_para(cx: &Ctx<'_>, sg: &[SGlyph], flex: &[Flex], para: &ParaStyle, pen: &Pen<'_>, rtl: bool) -> Option<Vec<(usize, bool)>> {
let ragged = matches!(para.justify, Justify::Auto | Justify::Left | Justify::Center | Justify::Right);
let composer = cx.opts.composer.unwrap_or(para.composer);
@@ -1658,10 +1661,16 @@ fn compose_para(cx: &Ctx<'_>, sg: &[SGlyph], flex: &[Flex], para: &ParaStyle, pe
return None;
}
let m = Metrics::of(&sg[0]);
if sg.iter().any(|g| Metrics::of(g) != m) {
// 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;
}
// Line widths are independent of the breaks when every line has the same metrics.
let total: f64 = sg.iter().map(|g| g.adv).sum();
let mut sim = pen.clone();
let mut widths = vec![];
+120
View File
@@ -294,6 +294,126 @@ fn composer_x_inline_graphics() {
}
}
#[test]
fn composer_x_mixed_sizes() {
// A 24 pt word among 12 pt text: in a rectangle the every-line composer breaks the paragraph as
// it breaks uniform 12 pt text in which that word is set at 200% horizontal scale, as wide as
// at 24 pt, and not as the single-line composer does.
let st = sans(12.0);
let runs = |x: CharStyle| vec![run("Destroy all creatures with toughness ", st.clone()), run("X", x), run(" or less.", st.clone())];
let big = sans(24.0);
let wide = CharStyle { h_scale: 200.0, ..sans(12.0) };
let mut probe = area(runs(big.clone()), Rect::new(0.0, 0.0, 10_000.0, 100.0));
probe.runs[2] = run(" or", st.clone());
let w = layout(db(), &probe).lines[0].x1 + 2.0;
let breaks = |x: &CharStyle, composer: Composer| {
let mut t = area(runs(x.clone()), Rect::new(0.0, 0.0, w, 200.0));
t.para.composer = composer;
line_texts(&t, &layout(db(), &t))
};
let greedy = ["Destroy all creatures with toughness X or", "less."];
let every = ["Destroy all creatures with toughness X", "or less."];
assert_eq!(breaks(&wide, Composer::EveryLine), every, "uniform sizes");
assert_eq!(breaks(&wide, Composer::SingleLine), greedy, "uniform sizes");
assert_eq!(breaks(&big, Composer::EveryLine), every, "mixed sizes");
assert_eq!(breaks(&big, Composer::SingleLine), greedy, "mixed sizes");
// The same in a rectangle split into two columns, one line in each.
let mut t = area(runs(big.clone()), Rect::new(0.0, 0.0, 2.0 * w + 12.0, 34.0));
(t.area.columns, t.area.gutter) = (2, 12.0);
t.para.composer = Composer::EveryLine;
let l = layout(db(), &t);
assert_eq!(line_texts(&t, &l), every, "two columns");
assert!(!l.overflow && l.lines[1].x0 > w, "{:?}", l.lines);
// The 24 pt word's line is set at its own size.
let mut t = area(runs(big.clone()), Rect::new(0.0, 0.0, w, 200.0));
t.para.composer = Composer::EveryLine;
let l = layout(db(), &t);
let x = l.glyphs.iter().find(|g| t.plain_text()[g.byte..].starts_with('X')).unwrap();
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.
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)] {
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);
}
let cut = kurbo::BezPath::from_vec(vec![
kurbo::PathEl::MoveTo((0.0, 0.0).into()),
kurbo::PathEl::LineTo((w, 0.0).into()),
kurbo::PathEl::LineTo((w, 190.0).into()),
kurbo::PathEl::LineTo((w - 10.0, 200.0).into()),
kurbo::PathEl::LineTo((0.0, 200.0).into()),
kurbo::PathEl::ClosePath,
]);
for (x, want) in [(&wide, every), (&big, greedy)] {
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);
}
}
#[test]
fn composer_x_mixed_line_metrics() {
// In a rectangle the every-line composer also breaks a paragraph whose characters differ in
// other line metrics: vertical scale, leading, font, and an inline graphic raised above the text.
let st = sans(12.0);
let wide = CharStyle { h_scale: 200.0, ..sans(12.0) };
let xs = [
("vertical scale", run("X", CharStyle { v_scale: 200.0, ..wide.clone() })),
("leading", run("X", CharStyle { leading: Some(30.0), ..wide.clone() })),
("font", run("X", serif(12.0, 0.0))),
("inline graphic", sym(0.7, 20.0, st.clone())),
];
let mut wrong = vec![];
for (name, x) in xs {
let runs = |end: &str| vec![run("Destroy all creatures with toughness ", st.clone()), x.clone(), run(end, st.clone())];
let w = layout(db(), &area(runs(" or"), Rect::new(0.0, 0.0, 10_000.0, 100.0))).lines[0].x1 + 2.0;
let greedy = [format!("Destroy all creatures with toughness {} or", x.text), "less.".to_string()];
let every = [format!("Destroy all creatures with toughness {}", x.text), "or less.".to_string()];
for (composer, want) in [(Composer::EveryLine, every), (Composer::SingleLine, greedy)] {
let mut t = area(runs(" or less."), Rect::new(0.0, 0.0, w, 200.0));
t.para.composer = composer;
let got = line_texts(&t, &layout(db(), &t));
if got != want {
wrong.push((name, composer, got));
}
}
}
assert!(wrong.is_empty(), "{wrong:?}");
}
#[test]
fn composer_x_lines_start_after_the_spaces() {
// A line may end after a dash or between two spaces, but the every-line composer breaks after
// the spaces that follow, as the single-line composer does: no line starts with a space, in
// uniform 12 pt text and with a 24 pt word in it.
let st = sans(12.0);
let runs = |x: CharStyle| {
vec![run("Destroy all creatures \u{2014} with toughness ", st.clone()), run("X", x), run(" or less \u{2014} then draw a card.", st.clone())]
};
let mut wrong = vec![];
for x in [sans(12.0), sans(24.0)] {
for justify in [Justify::Left, Justify::JustifyLeft] {
for w in (40..400).step_by(4) {
let mut t = area(runs(x.clone()), Rect::new(0.0, 0.0, f64::from(w), 2000.0));
t.para.composer = Composer::EveryLine;
t.para.justify = justify;
let text = t.plain_text();
let l = layout(db(), &t);
if l.lines.iter().any(|li| text[li.start..].starts_with(' ')) {
wrong.push((x.size, justify, w, line_texts(&t, &l)));
}
}
}
}
assert!(wrong.is_empty(), "{wrong:?}");
}
const INLINE: char = vectorcraft_doc::text::INLINE_CHAR;
// ---------- vertical alignment × … ----------
+3 -1
View File
@@ -4441,7 +4441,9 @@ when it is Single-line). Every-line, the default, picks the breaks of the whole
justified text gets even word spacing, ragged text (left, centre or right aligned) an even rag, so it may move a word
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; paragraphs that mix type sizes fall back to Single-line.
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.
```json
{"name":"run_command","arguments":{"command":"text.setFormat","params":{"ids":[7],"composer":"singleLine"}}}
File diff suppressed because one or more lines are too long