Files
vectorcraft/crates/text/src/tests_justify.rs
T
dotCooCoo 7d6eb3246e Type: Optical Margin Alignment hangs punctuation in right-to-left paragraphs
Optical Margin Alignment and Roman Hanging Punctuation left every
right-to-left paragraph of point and area type, and type on a path
whose first paragraph runs right to left, as it was: the quotation
marks, full stops and commas at the ends of its lines stayed inside
the margins.

A right-to-left line now hangs at the margins its alignment places it
against, as a left-to-right line does. Auto aligns a right-to-left
line at its right, so it hangs there. The line is read from right to
left: where it hangs at its left margin, a full stop and the quotation
mark after it that end the line hang together. In vertical type,
where the characters of a right-to-left paragraph run from the top
down, the pair is read from the top.

Type on a path whose first paragraph runs right to left hangs at its
brackets by the same rule. In horizontal type, Auto places it at the
end bracket, so it hangs there. On a closed path, its hang at the end
bracket gets the space between the end and start brackets before its
hang at the start bracket does, as in right-aligned type.

A justified right-to-left line of one word is spread between its
letters as they are drawn. The gap after each letter went to the
letter that follows it in the text, so the two leftmost letters
touched and the word ended one gap short of the right margin.

Docs: docs/mcp.md, docs/type-parity.md, docs/target-app-parity.md.
2026-10-21 23:15:07 +07:00

341 lines
19 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::text::Kashida;
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);
}
/// Full Justify spreads a right-to-left word between its letters as they are drawn: the word runs
/// from the left edge to the right edge, with the same gap between each two letters.
#[test]
fn a_justified_right_to_left_word_is_spread_between_its_drawn_letters() {
let text = "שלום";
let natural = layout(db(), &point(text, JustificationOptions::default()));
let l = layout(db(), &area(text, 300.0, Justify::JustifyAll, JustificationOptions::default()));
// The letters from left to right: ם, ו, ל, ש.
let mut drawn: Vec<&PositionedGlyph> = l.glyphs.iter().collect();
drawn.sort_by(|a, b| a.origin.x.total_cmp(&b.origin.x));
assert!(drawn.len() == 4 && drawn.iter().all(|g| g.rtl), "{drawn:?}");
let gaps: Vec<f64> = drawn.windows(2).map(|p| p[1].origin.x - p[0].origin.x - at(&natural, p[0].byte).advance).collect();
let last = drawn[3];
assert!(drawn[0].origin.x.abs() < 1e-6, "{:?}", drawn[0].origin);
assert!((last.origin.x + at(&natural, last.byte).advance - 300.0).abs() < 1e-6, "{:?}", last.origin);
assert!(gaps.iter().all(|&g| g > 1.0 && (g - gaps[0]).abs() < 1e-6), "{gaps:?}");
}
/// 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}");
}
/// An OpenType (GSUB) Arabic face with a tatweel: craft-fonts' Noto Sans Arabic when the build has
/// craft-fonts (`CRAFT_FONTS_DIR`), else an installed one. None skips the test.
fn arabic_face(test: &str) -> Option<std::sync::Arc<FontFace>> {
let opentype = |face: &FontFace| {
use skrifa::raw::TableProvider;
face.skrifa().is_some_and(|f| f.gsub().is_ok())
};
let face = ["Noto Sans Arabic", "Arial", "Geeza Pro"]
.into_iter()
.filter_map(|name| db().face(name, "Regular"))
.find(|face| face.covers('ب') && face.covers('د') && face.covers('\u{0640}') && opentype(face));
match &face {
Some(face) => eprintln!("ASSERTIONS RUN: {test}; Arabic font {} {}", face.family, face.style),
None => eprintln!("SKIPPED: {test}; no font covers Arabic and the tatweel"),
}
face
}
/// Arabic type at 20 pt in `face`, in a frame 300 pt wide, aligned as `justify` with `kashida`.
fn arabic_area(face: &FontFace, text: &str, justify: Justify, kashida: Kashida) -> TextObject {
let st = CharStyle { font_family: face.family.clone(), font_style: face.style.clone(), size: 20.0, ..CharStyle::default() };
let mut t = TextObject::point(Point::ZERO, text, st);
t.kind = TextKind::Area { frame: PathData::from_bezpath(&Rect::new(0.0, 0.0, 300.0, 1000.0).to_path(0.1)) };
t.xf = Affine::IDENTITY;
t.para.justify = justify;
t.para.kashida = kashida;
t
}
/// The kashida glyphs of a layout: tatweels that stand for no character.
fn kashidas<'a>(l: &'a TextLayout, face: &FontFace) -> Vec<&'a PositionedGlyph> {
let tatweel = face.glyph_for('\u{0640}');
l.glyphs.iter().filter(|g| g.len == 0 && g.gid == tatweel).collect()
}
/// Insert Kashidas: a justified Arabic line lengthens each word with one kashida between its last
/// two joined letters, up to a quarter, half or whole em (Stylistic: whole tatweels up to an em),
/// before it widens the word space. The line keeps its width, the kashida fills the gap between
/// the letters it joins, and None leaves the line as it was.
#[test]
fn justified_arabic_lines_take_their_room_on_kashidas() {
let Some(face) = arabic_face("justified_arabic_lines_take_their_room_on_kashidas") else { return };
// Two words whose last letters join (teh and beh, meem and dal) and one whose letters (waw, reh,
// dal) join none after them.
let text = "كتب ورد محمد";
let spaces: Vec<usize> = text.match_indices(' ').map(|(b, _)| b).collect();
let off = layout(db(), &arabic_area(&face, text, Justify::JustifyAll, Kashida::None));
assert!(off.glyphs.iter().all(|g| g.len > 0), "no kashidas without Insert Kashidas");
let measure = |l: &TextLayout| l.lines[0].x1 - l.lines[0].x0;
assert!((measure(&off) - 300.0).abs() < 1e-6, "the line fills the frame: {}", measure(&off));
let space = at(&off, spaces[0]).advance;
let tatweel = face.advance(face.glyph_for('\u{0640}')) * 20.0 / face.units_per_em();
for (kind, longest) in [(Kashida::Short, 5.0), (Kashida::Medium, 10.0), (Kashida::Long, 20.0), (Kashida::Stylistic, 20.0)] {
let on = layout(db(), &arabic_area(&face, text, Justify::JustifyAll, kind));
let k = kashidas(&on, &face);
let mut bytes: Vec<usize> = k.iter().map(|g| g.byte).collect();
bytes.sort_unstable();
bytes.dedup();
assert_eq!(bytes, ["كتب".len() - 'ب'.len_utf8(), text.len() - 'د'.len_utf8()], "{kind:?}: before the last letter of each word");
let each = if kind == Kashida::Stylistic { (longest / tatweel).floor() * tatweel } else { longest };
let mut total = 0.0;
for &b in &bytes {
let (start, end, count) = kashida_span(&on, &face, b);
assert!((end - start - each).abs() < 1e-6, "{kind:?}: {start}..{end}");
total += end - start;
if kind == Kashida::Stylistic {
assert!((count as f64 * tatweel - each).abs() < 1e-6, "{kind:?}: whole tatweels");
}
// The letter after the kashida in the text advances over it to the letter before it,
// so the caret before that letter and a selection of it reach the letter before it.
let after = on.glyphs.iter().filter(|o| o.byte == b && o.len > 0);
let len = after.clone().map(|o| o.len).max().unwrap();
let ink = after.clone().map(|o| o.origin.x).fold(f64::MAX, f64::min);
assert!(ink < start && (after.map(|o| o.origin.x + o.advance).fold(f64::MIN, f64::max) - end).abs() < 1e-6, "{kind:?}");
let caret = caret_position(&on, b).0.x;
assert!((caret - end).abs() < 1e-6, "{kind:?}: the caret before the letter: {caret} {end}");
let x_max = selection_quads(&on, b, b + len).iter().flatten().map(|p| p.x).fold(f64::MIN, f64::max);
assert!((x_max - end).abs() < 1e-6, "{kind:?}: the selection takes the kashida in: {x_max} {end}");
}
assert!((measure(&on) - measure(&off)).abs() < 1e-6, "{kind:?}: same width");
for &b in &spaces {
assert!((at(&on, b).advance - (space - total / 2.0)).abs() < 1e-6, "{kind:?}: the spaces give up the kashidas' length");
}
}
// A click on a letter and its kashida goes to the nearer edge of the two taken together: dal is
// narrower than a Long kashida.
let on = layout(db(), &arabic_area(&face, text, Justify::JustifyAll, Kashida::Long));
let dal = text.len() - 'د'.len_utf8();
let (start, end, _) = kashida_span(&on, &face, dal);
let ink = on.glyphs.iter().filter(|o| o.byte == dal && o.len > 0).map(|o| o.origin.x).fold(f64::MAX, f64::min);
let middle = (ink + end) / 2.0;
assert!(start < middle, "the kashida is longer than the letter: {ink} {start} {end}");
assert_eq!(byte_in_line(&on, 0, (start + middle) / 2.0), text.len(), "left of the middle: the letter's left edge");
assert_eq!(byte_in_line(&on, 0, (middle + end) / 2.0), dal, "right of the middle: its right edge");
// A letter with a vowel mark is a cluster of two glyphs: the caret before it and a selection of
// it still reach the letter before the kashida.
let marked = "كتبَ ورد";
let on = layout(db(), &arabic_area(&face, marked, Justify::JustifyAll, Kashida::Medium));
let beh = "كت".len();
let (_, end, _) = kashida_span(&on, &face, beh);
assert!(on.glyphs.iter().filter(|o| o.byte == beh && o.len > 0).count() >= 2, "beh and fatha");
assert!((caret_position(&on, beh).0.x - end).abs() < 1e-6, "{} {end}", caret_position(&on, beh).0.x);
let x_max = selection_quads(&on, beh, "كتبَ".len()).iter().flatten().map(|p| p.x).fold(f64::MIN, f64::max);
assert!((x_max - end).abs() < 1e-6, "{x_max} {end}");
// Punctuation ends a word as a space does.
let text = "كتب/محمد";
let on = layout(db(), &arabic_area(&face, text, Justify::JustifyAll, Kashida::Medium));
let mut bytes: Vec<usize> = kashidas(&on, &face).iter().map(|g| g.byte).collect();
bytes.sort_unstable();
assert_eq!(bytes, ["كتب".len() - 'ب'.len_utf8(), text.len() - 'د'.len_utf8()]);
// At 50% horizontal scale an em is half as wide; the line still fills the frame.
for kind in [Kashida::Long, Kashida::Stylistic] {
let text = "كتب ورد محمد";
let mut t = arabic_area(&face, text, Justify::JustifyAll, kind);
for r in &mut t.runs {
r.style.h_scale = 50.0;
}
let half = layout(db(), &t);
let each = if kind == Kashida::Stylistic { (10.0 / (tatweel * 0.5)).floor() * tatweel * 0.5 } else { 10.0 };
for b in ["كتب".len() - 'ب'.len_utf8(), text.len() - 'د'.len_utf8()] {
let (start, end, _) = kashida_span(&half, &face, b);
assert!((end - start - each).abs() < 1e-6, "{kind:?} at 50%: {start}..{end}");
}
assert!((measure(&half) - 300.0).abs() < 1e-6, "{kind:?} at 50%: {}", measure(&half));
}
}
/// Where the kashida before the cluster at byte `b` starts, where the letter before it in the text
/// starts (the kashida's end), and how many tatweels draw it.
fn kashida_span(l: &TextLayout, face: &FontFace, b: usize) -> (f64, f64, usize) {
let k: Vec<_> = kashidas(l, face).into_iter().filter(|g| g.byte == b).collect();
let start = k.iter().map(|g| g.origin.x).fold(f64::MAX, f64::min);
let end = l.glyphs.iter().filter(|o| o.byte + o.len == b && o.len > 0).map(|o| o.origin.x).fold(f64::MAX, f64::min);
(start, end, k.len())
}
/// Insert Kashidas changes only justified horizontal lines of Arabic: right-aligned and Auto
/// Arabic paragraphs, a justified Latin one and justified vertical Arabic type are set as with
/// None.
#[test]
fn kashidas_leave_unjustified_and_latin_lines_as_they_were() {
let same = |a: &TextLayout, b: &TextLayout| {
assert_eq!(a.glyphs.len(), b.glyphs.len());
for (x, y) in a.glyphs.iter().zip(&b.glyphs) {
assert_eq!((x.gid, x.byte, x.len, x.origin, x.advance), (y.gid, y.byte, y.len, y.origin, y.advance));
}
};
let latin = |k: Kashida| {
let mut t = area("ab cd", 300.0, Justify::JustifyAll, JustificationOptions::default());
t.para.kashida = k;
layout(db(), &t)
};
same(&latin(Kashida::Long), &latin(Kashida::None));
let Some(face) = arabic_face("kashidas_leave_unjustified_and_latin_lines_as_they_were") else { return };
let arabic = |j: Justify, k: Kashida| layout(db(), &arabic_area(&face, "كتب محمد", j, k));
same(&arabic(Justify::Right, Kashida::Long), &arabic(Justify::Right, Kashida::None));
same(&arabic(Justify::Auto, Kashida::Medium), &arabic(Justify::Auto, Kashida::None));
assert!(!kashidas(&arabic(Justify::JustifyAll, Kashida::Medium), &face).is_empty());
// Vertical type is set as with None too.
let vertical = |k: Kashida| {
let mut t = arabic_area(&face, "كتب محمد", Justify::JustifyAll, k);
t.vertical = true;
layout(db(), &t)
};
same(&vertical(Kashida::Medium), &vertical(Kashida::None));
}
/// 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");
}