Files
vectorcraft/crates/text/src/tests_justify.rs
T
dotCooCoo 1cea163b11 Paragraph: Insert Kashidas justifies Arabic lines with kashidas
The reference app justifies Arabic text with kashidas, set per paragraph
with the Paragraph panel's Insert Kashidas list (None, Short, Medium,
Long, Stylistic). VectorCraft had no kashidas, so a justified Arabic
line was spaced out at its word spaces like a Latin one.

The Paragraph panel, with the Indic options shown, now has Insert
Kashidas. On a justified horizontal area type line that is shorter
than its measure, each Arabic word with joined letters gets one kashida
between its last pair of them, drawn as tatweels (U+0640) in the font
of the letter after it. The kashidas share the line's room equally,
each at most a quarter of an em (Short), half an em (Medium) or an em
(Long); a Stylistic kashida is a row of the font's own tatweels, up to
an em. The word spaces take the room the kashidas leave, so the line
keeps its width, and line breaks do not change. The letter after a
kashida advances over it, so the caret before that letter, a selection
of it and a click on it take the kashida in. None, the default, leaves
lines as they were.

In horizontal type that is not on a path, the caret in a letter drawn
with several glyphs (a letter with a vowel mark) is now placed by the
whole letter, as hit testing already measured it, instead of by its
first glyph.

text.setFormat takes kashida. The setting is saved with the paragraph
only when it is not None, paragraph styles carry it, and Reset Panel
sets it back to None.

Docs: docs/mcp.md, docs/target-app-parity.md.
2026-10-21 16:46:22 +07:00

323 lines
18 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);
}
/// 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");
}