Files
vectorcraft/crates/text/src/tests_justify.rs
T
VictorandClaude Opus 5.5 a275496ef4 G4: Paragraph panel menu › Justification… (word and letter spacing, glyph scaling, auto leading, single word justification)
The Paragraph panel menu's Justification… was a disabled stub, and justified
lines stretched word spaces between fixed limits (80% to 150%).

Each paragraph now has Justification options: Word Spacing, Letter Spacing
and Glyph Scaling (Minimum, Desired, Maximum, in percent), Auto Leading and
Single Word Justification. Desired applies to every line; a justified line
fills its measure with word spacing first, then letter spacing, then glyph
scaling, each within its range, and past them its word spaces take the
rest. The Every-line Composer scores lines by that flexibility. Auto
Leading sets the leading of characters whose leading is Auto. A justified
line holding one word is spread between its letters (Full Justify) or
aligned left, centre or right.

The defaults are the documented ones (80/100/133, 0/0/0, 100/100/100, 120,
Full Justify), saved only when they differ. Because the default maximum
word spacing is 133%, justified paragraphs break differently than before;
the Every-line Composer's second pass is now unbounded for justified text
(it used to give up past four times the stretch and fall back to the
Single-line Composer, which the narrower default range would make common),
and lines that can't stretch at all are still refused.

text.setFormat takes wordSpacing, letterSpacing, glyphScaling ([min,
desired, max] or {min?, desired?, max?}), autoLeading and
singleWordJustification; Minimum ≤ Desired ≤ Maximum must hold or nothing
changes. Paragraph styles carry the options (New and Redefine capture
them), and Reset Panel resets them. Justification… opens a dialog
(ui.justification) with Preview; OK is disabled while a row is out of
order. Live SVG text places characters the options move, and a glyph that
Glyph Scaling scales is stretched to its width; PDF uses the glyphs'
transforms. Vertical type isn't glyph scaled; type on a path doesn't use
the options.

Docs: docs/mcp.md, docs/type-parity.md, docs/gaps.md.

Closes #2755.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 12:40:14 +07:00

155 lines
7.8 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Paragraph panel menu › Justification…: word spacing, letter spacing, glyph scaling, Single Word
//! Justification and Auto Leading in layout.
use super::*;
use kurbo::{Affine, Shape};
use vectorcraft_doc::{CharStyle, JustificationOptions, Justify, SingleWordJustify, TextKind};
use vectorcraft_geom::PathData;
fn db() -> &'static FontDb {
FontDb::global()
}
fn style(size: f64) -> CharStyle {
CharStyle { size, ..CharStyle::default() }
}
fn area(text: &str, width: f64, justify: Justify, j: JustificationOptions) -> TextObject {
let mut t = TextObject::point(Point::ZERO, text, style(20.0));
t.kind = TextKind::Area { frame: PathData::from_bezpath(&Rect::new(0.0, 0.0, width, 1000.0).to_path(0.1)) };
t.xf = Affine::IDENTITY;
t.para.justify = justify;
t.para.justification = j;
t
}
fn point(text: &str, j: JustificationOptions) -> TextObject {
let mut t = TextObject::point(Point::ZERO, text, style(20.0));
t.xf = Affine::IDENTITY;
t.para.justification = j;
t
}
/// The glyph of the character at byte `b`.
fn at(l: &TextLayout, b: usize) -> &PositionedGlyph {
l.glyphs.iter().find(|g| g.byte == b).unwrap()
}
fn width(g: &PositionedGlyph) -> f64 {
g.outline.bounding_box().width()
}
/// Desired values apply to unjustified type: word spaces are that share of the font's space,
/// letters are followed by that share of a space, and glyphs are drawn that wide.
#[test]
fn desired_spacing_and_scaling_apply_to_every_line() {
let plain = layout(db(), &point("ab cd", JustificationOptions::default()));
let space = at(&plain, 2).advance;
let a = at(&plain, 0).advance;
assert!(space > 0.0 && a > 0.0);
let words = layout(db(), &point("ab cd", JustificationOptions { word_spacing: [0.0, 200.0, 300.0], ..JustificationOptions::default() }));
assert!((at(&words, 2).advance - 2.0 * space).abs() < 1e-6, "a word space at 200%");
assert!((at(&words, 0).advance - a).abs() < 1e-6, "letters unchanged");
let letters = layout(db(), &point("ab cd", JustificationOptions { letter_spacing: [0.0, 50.0, 50.0], ..JustificationOptions::default() }));
assert!((at(&letters, 0).advance - (a + 0.5 * space)).abs() < 1e-6, "half a space after a");
assert!((at(&letters, 1).advance - at(&plain, 1).advance).abs() < 1e-6, "no letter spacing before a word space");
assert!((at(&letters, 4).advance - at(&plain, 4).advance).abs() < 1e-6, "nor after the last letter");
let scaled = layout(db(), &point("ab cd", JustificationOptions { glyph_scaling: [50.0, 150.0, 200.0], ..JustificationOptions::default() }));
assert!((at(&scaled, 0).advance - 1.5 * a).abs() < 1e-6, "advance at 150%");
assert!((width(at(&scaled, 0)) - 1.5 * width(at(&plain, 0))).abs() < 1e-6, "outline at 150%");
assert!((at(&scaled, 2).advance - space).abs() < 1e-6, "word spaces aren't scaled");
}
/// The line's text from a layout, one string per line.
fn lines(l: &TextLayout, text: &str) -> Vec<String> {
l.lines.iter().map(|li| text.get(li.start..li.end).unwrap_or_default().trim_end().to_string()).collect()
}
/// A justified line fills its measure with word spacing first, then letter spacing, then glyph
/// scaling, each within its range; past them the word spaces take the rest.
#[test]
fn justified_lines_use_word_then_letter_spacing_then_glyph_scaling() {
let text = "ab cd ef gh";
let natural = layout(db(), &point("ab cd", JustificationOptions::default()));
let space = at(&natural, 2).advance;
let full = natural.lines[0].x1 - natural.lines[0].x0;
// The four letters' width.
let letters_w = full - space;
// "ab cd" on the first line, `room` points short of the measure.
let line = |room: f64, j: JustificationOptions| {
let w = full + room;
let l = layout(db(), &area(text, w, Justify::JustifyLeft, j));
assert_eq!(lines(&l, text).first().map(String::as_str), Some("ab cd"), "{room}");
l
};
let fixed = JustificationOptions { word_spacing: [100.0, 100.0, 100.0], ..JustificationOptions::default() };
// Word spacing within its range: only the space grows.
let l = line(space * 0.2, JustificationOptions { word_spacing: [80.0, 100.0, 150.0], letter_spacing: [0.0, 0.0, 10.0], ..fixed.clone() });
assert!((at(&l, 2).advance - space * 1.2).abs() < 1e-6);
assert!((at(&l, 0).advance - at(&natural, 0).advance).abs() < 1e-6);
// No word spacing range: the letter gaps (a–b and c–d) share the room.
let l = line(4.0, JustificationOptions { letter_spacing: [0.0, 0.0, 50.0], ..fixed.clone() });
assert!((at(&l, 2).advance - space).abs() < 1e-6, "the space keeps its width");
assert!((at(&l, 0).advance - (at(&natural, 0).advance + 2.0)).abs() < 1e-6);
assert!((at(&l, 1).advance - at(&natural, 1).advance).abs() < 1e-6, "no gap before the space");
// Only glyph scaling: the four letters widen, their outlines with them.
let l = line(letters_w * 0.05, JustificationOptions { glyph_scaling: [90.0, 100.0, 110.0], ..fixed.clone() });
assert!((at(&l, 0).advance - at(&natural, 0).advance * 1.05).abs() < 1e-6, "half of the range");
assert!((width(at(&l, 0)) - width(at(&natural, 0)) * 1.05).abs() < 1e-6);
let end = at(&l, 4).origin.x + at(&l, 4).advance;
assert!((end - (full + letters_w * 0.05)).abs() < 1e-6, "the line reaches the edge: {end}");
// Every range used up: the word space takes the rest.
let l = line(10.0, JustificationOptions { letter_spacing: [0.0, 0.0, 10.0], ..fixed });
let letters = 2.0 * 0.1 * space;
assert!((at(&l, 2).advance - (space + 10.0 - letters)).abs() < 1e-6);
}
/// Single Word Justification: a justified line holding one word is spread between its letters
/// (Full Justify, the default) or aligned as chosen.
#[test]
fn single_word_lines_follow_single_word_justification() {
let text = "Typography internationalization";
let word = layout(db(), &point("Typography", JustificationOptions::default()));
// "Typography" alone on the first line, 30 pt short of the measure.
let w = word.lines[0].x1 - word.lines[0].x0 + 30.0;
let first = |sw: SingleWordJustify| {
let l = layout(db(), &area(text, w, Justify::JustifyLeft, JustificationOptions { single_word: sw, ..JustificationOptions::default() }));
assert_eq!(lines(&l, text).first().map(String::as_str), Some("Typography"));
let li = &l.lines[0];
let g: Vec<_> = l.glyphs[li.glyph_start..li.glyph_end].iter().filter(|g| g.byte < "Typography".len()).collect();
(g.first().unwrap().origin.x, g.last().map(|g| g.origin.x + g.advance).unwrap())
};
let (x0, x1) = first(SingleWordJustify::FullJustify);
assert!(x0.abs() < 1e-6 && (x1 - w).abs() < 1e-3, "spread across: {x0}..{x1} of {w}");
let (x0, x1) = first(SingleWordJustify::AlignLeft);
assert!(x0.abs() < 1e-6 && (x1 - (w - 30.0)).abs() < 1e-3, "{x0}..{x1}");
let (r0, r1) = first(SingleWordJustify::AlignRight);
assert!((r1 - w).abs() < 1e-3 && (r0 - 30.0).abs() < 1e-3, "{r0}..{r1}");
let (c0, c1) = first(SingleWordJustify::AlignCenter);
assert!((c0 - 15.0).abs() < 1e-3 && (c1 - (w - 15.0)).abs() < 1e-3, "centred: {c0}..{c1}");
}
/// Auto Leading: characters whose leading is Auto are set at the paragraph's share of their size;
/// an explicit leading is kept.
#[test]
fn auto_leading_sets_the_leading_of_auto_characters() {
let text = "one\ntwo";
let gap = |t: &TextObject| {
let l = layout(db(), t);
l.lines[1].baseline - l.lines[0].baseline
};
let t = area(text, 300.0, Justify::Left, JustificationOptions::default());
assert!((gap(&t) - 24.0).abs() < 1e-6, "120% of 20 pt");
let mut t = area(text, 300.0, Justify::Left, JustificationOptions { auto_leading: 200.0, ..JustificationOptions::default() });
assert!((gap(&t) - 40.0).abs() < 1e-6, "200% of 20 pt");
t.runs[0].style.leading = Some(30.0);
assert!((gap(&t) - 30.0).abs() < 1e-6, "explicit leading kept");
}