mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-13 01:14:29 -06:00
Layout kept no record of a run's face, so each paragraph looked up the face of its first character three times for its line metrics and once more to shape it. The layout now keeps each run's face the first time it is looked up and uses it for shaping, for paragraph metrics, for on-path type's empty line and for the column offset of vertical point type. Glyphs, positions, bounds and baselines do not change. A test-only helper lays text out looking every face up at each use, and a new test requires point, vertical point, area and path type laid out both ways to match exactly. The layouts of 5,000 short point type objects take a median of 215 million CPU cycles of the laying-out thread instead of 236 million in a release build (11 alternating runs of each build on Windows 11, measured while other builds were running).
247 lines
11 KiB
Rust
247 lines
11 KiB
Rust
use super::*;
|
|
use std::sync::Arc;
|
|
|
|
fn synthetic_glyphs(specs: &[(usize, usize, f64, char)]) -> Vec<SGlyph> {
|
|
// The face is only a required SGlyph field here; advances and cluster boundaries below are
|
|
// entirely synthetic, so the regressions don't depend on a particular font's shaping.
|
|
static DB: std::sync::OnceLock<FontDb> = std::sync::OnceLock::new();
|
|
let db = DB.get_or_init(|| FontDb::with_font_dirs(vec![]));
|
|
let face = db.face("Source Sans 3", "Regular").expect("bundled test face");
|
|
specs
|
|
.iter()
|
|
.map(|&(byte, len, adv, ch)| SGlyph {
|
|
face: Arc::clone(&face),
|
|
gid: 0,
|
|
byte,
|
|
len,
|
|
run: 0,
|
|
adv,
|
|
dx: 0.0,
|
|
dy: 0.0,
|
|
kern_shift: 0.0,
|
|
sx: 1.0,
|
|
sy: 1.0,
|
|
bshift: 0.0,
|
|
rotation: 0.0,
|
|
ascent: 8.0,
|
|
descent: 2.0,
|
|
leading: 0.0,
|
|
cap: 6.0,
|
|
xh: 4.0,
|
|
ch,
|
|
tcy: None,
|
|
inline: None,
|
|
word_end: false,
|
|
lead: 0.0,
|
|
proportional: false,
|
|
vpal: None,
|
|
level: unicode_bidi::Level::ltr(),
|
|
no_break: false,
|
|
vertical_roman: false,
|
|
cursive: false,
|
|
hyphen_language: crate::hyphen::Language::English,
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn source_range(glyphs: &[SGlyph], start: usize, end: usize) -> Range<usize> {
|
|
let first = glyphs.get(start).expect("nonempty source range");
|
|
let last = glyphs.get(end - 1).expect("nonempty source range");
|
|
first.byte..last.byte + last.len
|
|
}
|
|
|
|
/// Both composers consume language-specific points even when a font subdivides vowel signs.
|
|
#[test]
|
|
fn indic_hyphenation_reaches_both_composers_without_splitting_marks() {
|
|
let opts = HyphenationOptions { words_longer_than: 0, after_first: 1, before_last: 1, ..Default::default() };
|
|
for (tag, syllable) in [
|
|
("hi", "कि"),
|
|
("mr", "कि"),
|
|
("gu", "કિ"),
|
|
("bn", "কি"),
|
|
("pa", "ਕਿ"),
|
|
("ta", "கி"),
|
|
("te", "కి"),
|
|
("kn", "ಕಿ"),
|
|
("ml", "കി"),
|
|
("or", "କି"),
|
|
("as", "কি"),
|
|
] {
|
|
let text = syllable.repeat(8);
|
|
// Deliberately subdivide each grapheme, as a fallback font may do. Marks are word
|
|
// characters but must never become a break, even at a shaping cluster boundary.
|
|
let specs: Vec<_> = text.char_indices().map(|(byte, ch)| (byte, ch.len_utf8(), 1.0, ch)).collect();
|
|
let mut glyphs = synthetic_glyphs(&specs);
|
|
for glyph in &mut glyphs {
|
|
glyph.hyphen_language = crate::hyphen::Language::from_tag(Some(tag));
|
|
glyph.sx = 0.001;
|
|
}
|
|
let cands = candidates(&text, &glyphs, Some(&opts), Kinsoku::None);
|
|
assert_eq!(cands.iter().filter(|b| b.hyphen > 0.0).map(|b| b.end).collect::<Vec<_>>(), (2..16).step_by(2).collect::<Vec<_>>(), "{tag}");
|
|
let (end, hyphen) = break_line(&text, &glyphs, 0, 4.5, &[], 0.0, Some((&opts, 0.0)), Burasagari::None, Kinsoku::None);
|
|
assert_eq!((end, hyphen), (4, true), "{tag}");
|
|
let params = Params { justify_last: false, ragged: true, tolerance: 1.0, uniform_from: 0, hyphen_limit: 0 };
|
|
let lines = compose(&glyphs, &|_| 4.5, &|_| 0.0, &cands, &[], ¶ms).expect("every-line composition");
|
|
assert!(lines.iter().any(|(_, hyphen)| *hyphen), "{tag}");
|
|
assert!(lines.iter().all(|(end, _)| end % 2 == 0), "{tag}: {lines:?}");
|
|
let limited = Params { hyphen_limit: 1, ..params };
|
|
if let Some(lines) = compose(&glyphs, &|_| 4.5, &|_| 0.0, &cands, &[], &limited) {
|
|
assert!(lines.windows(2).all(|pair| !pair[0].1 || !pair[1].1), "{tag}: {lines:?}");
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn composition_trim_stays_along_the_line_when_a_character_rotates() {
|
|
let db = FontDb::with_font_dirs(vec![]);
|
|
let style = CharStyle::default();
|
|
let opts = LayoutOptions::default();
|
|
for angle in [0.0_f64, std::f64::consts::FRAC_PI_4, std::f64::consts::FRAC_PI_2] {
|
|
let origin = Point::new(100.0, 80.0);
|
|
let pre = Affine::translate(origin.to_vec2()) * Affine::rotate(angle);
|
|
let draw = |rotation: f64, lead: f64| {
|
|
let mut glyph = synthetic_glyphs(&[(0, 1, 30.0, 'A')]).remove(0);
|
|
glyph.sx = 0.02;
|
|
glyph.sy = 0.02;
|
|
glyph.dx = 2.0;
|
|
glyph.dy = -1.0;
|
|
glyph.bshift = 4.0;
|
|
glyph.kern_shift = 3.0;
|
|
glyph.rotation = rotation;
|
|
glyph.lead = lead;
|
|
let mut cx = Ctx {
|
|
on_path: angle != 0.0,
|
|
db: &db,
|
|
text: "A",
|
|
runs: vec![(0..1, &style)],
|
|
inlines: vec![None],
|
|
faces: vec![std::cell::OnceCell::new()],
|
|
default: style.clone(),
|
|
opts: &opts,
|
|
vertical: false,
|
|
system_layout: false,
|
|
fractional_widths: true,
|
|
upright_cluster: None,
|
|
out: TextLayout::default(),
|
|
};
|
|
cx.emit(&glyph, pre, origin, angle, 30.0 - lead, 0);
|
|
cx.out.glyphs.remove(0)
|
|
};
|
|
let expected = Vec2::new(-angle.cos(), -angle.sin()) * 5.0;
|
|
for rotation in [0.0, 45.0, 90.0, -90.0] {
|
|
let (plain, trimmed) = (draw(rotation, 0.0), draw(rotation, 5.0));
|
|
assert!(!trimmed.outline.elements().is_empty(), "fixture has drawn geometry");
|
|
let shift = trimmed.xf * Point::ZERO - plain.xf * Point::ZERO;
|
|
assert!((shift - expected).hypot() < 1e-9, "line {angle}, character {rotation}: {shift:?} versus {expected:?}");
|
|
let pivot_shift = trimmed.xf * trimmed.pivot - plain.xf * plain.pivot;
|
|
assert!((pivot_shift - expected).hypot() < 1e-9, "composition moves the turning point along the line: {pivot_shift:?}");
|
|
let unrotated = draw(0.0, 5.0);
|
|
let actual = (trimmed.xf * trimmed.unturned).as_coeffs();
|
|
assert!(actual.iter().zip(unrotated.xf.as_coeffs()).all(|(a, b)| (a - b).abs() < 1e-9), "removing rotation keeps composition placement");
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn greedy_wrap_keeps_an_oversized_first_cluster_and_its_source_range() {
|
|
let text = "بَت";
|
|
let glyphs = synthetic_glyphs(&[(0, 4, 2.0, 'ب'), (0, 4, 2.0, 'ب'), (4, 2, 1.0, 'ت')]);
|
|
|
|
let (end, hyphenated) = break_line(text, &glyphs, 0, 3.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard);
|
|
|
|
assert!(!hyphenated);
|
|
assert_eq!(end, 2, "oversized base-plus-mark cluster must stay together");
|
|
assert_eq!(source_range(&glyphs, 0, end), 0..4);
|
|
assert_eq!(glyphs.get(end).map(|g| g.byte), Some(4));
|
|
}
|
|
|
|
#[test]
|
|
fn greedy_wrap_keeps_an_oversized_cluster_after_a_line_start_atomic() {
|
|
let text = "بَتَث";
|
|
let glyphs = synthetic_glyphs(&[(0, 4, 1.0, 'ب'), (4, 4, 2.0, 'ت'), (4, 4, 2.0, 'ت'), (8, 2, 1.0, 'ث')]);
|
|
|
|
let (first_end, _) = break_line(text, &glyphs, 0, 3.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard);
|
|
assert_eq!(first_end, 1);
|
|
assert_eq!(source_range(&glyphs, 0, first_end), 0..4);
|
|
|
|
let (second_end, hyphenated) = break_line(text, &glyphs, first_end, 3.0, &[], 0.0, None, Burasagari::None, Kinsoku::Hard);
|
|
|
|
assert!(!hyphenated);
|
|
assert_eq!(second_end, 3, "oversized cluster at the next line start must stay together");
|
|
assert_eq!(source_range(&glyphs, first_end, second_end), 4..8);
|
|
assert_eq!(glyphs.get(second_end).map(|g| g.byte), Some(8));
|
|
}
|
|
|
|
#[test]
|
|
fn narrow_arabic_area_wrap_keeps_base_and_mark_together_when_supported() {
|
|
let db = FontDb::global();
|
|
let Some(face) = db.face_covering('ب').filter(|face| face.covers('َ')) else {
|
|
// Arabic integration coverage depends on an installed font; synthetic tests above are
|
|
// the font-independent regression oracle.
|
|
return;
|
|
};
|
|
let text = "بَت";
|
|
let style = CharStyle { font_family: face.family.clone(), font_style: face.style.clone(), size: 20.0, ..CharStyle::default() };
|
|
let bidi = para_bidi(text, None);
|
|
let levels = bidi.as_ref().map_or(&[][..], |info| &info.levels);
|
|
let mut shaped = Vec::new();
|
|
shape_range(db, text, 0..text.len(), &[(0..text.len(), &style)], &[], &OtFeatures::default(), levels, &mut shaped);
|
|
let Some(first) = shaped.first() else { return };
|
|
let first_end = shaped.iter().take_while(|g| g.byte == first.byte).count();
|
|
if first.byte != 0 || first_end < 2 || first_end >= shaped.len() {
|
|
return;
|
|
}
|
|
// Pick a narrow width that fits glyphs before one positive-advance glyph but not that next
|
|
// glyph. This ensures the old per-glyph loop would have returned inside the first cluster.
|
|
let mut prefix = shaped.first().map_or(0.0, |g| g.adv);
|
|
let width = (1..first_end).find_map(|k| {
|
|
let advance = shaped.get(k)?.adv;
|
|
if prefix > EPS && advance > EPS {
|
|
Some(prefix)
|
|
} else {
|
|
prefix += advance;
|
|
None
|
|
}
|
|
});
|
|
let Some(width) = width else { return };
|
|
|
|
let mut object = TextObject::point(Point::ZERO, text, style);
|
|
object.kind = TextKind::Area { frame: PathData::from_bezpath(&Rect::new(0.0, 0.0, width, 100.0).to_path(0.1)) };
|
|
let result = layout_with(db, &object, &LayoutOptions::default());
|
|
|
|
let next_cluster_byte = shaped.get(first_end).map(|g| g.byte);
|
|
assert_eq!(result.lines.first().map(|line| line.end), next_cluster_byte);
|
|
}
|
|
|
|
/// No Break keeps the marked characters on one line, including across a space, and moves a
|
|
/// marked run that does not fit to the next line whole.
|
|
#[test]
|
|
fn no_break_keeps_marked_characters_on_one_line() {
|
|
let text = "A B";
|
|
let plain = synthetic_glyphs(&[(0, 1, 10.0, 'A'), (1, 1, 4.0, ' '), (2, 1, 10.0, 'B')]);
|
|
let (end, hyphen) = break_line(text, &plain, 0, 16.0, &[], 0.0, None, Burasagari::None, Kinsoku::None);
|
|
assert!(!hyphen);
|
|
assert_eq!(end, 2, "without the mark the line ends after the space");
|
|
|
|
let mut marked = plain;
|
|
for g in &mut marked {
|
|
g.no_break = true;
|
|
}
|
|
let (end, hyphen) = break_line(text, &marked, 0, 16.0, &[], 0.0, None, Burasagari::None, Kinsoku::None);
|
|
assert!(!hyphen);
|
|
assert_eq!(end, 3, "the marked words stay together even though they are wider than the line");
|
|
|
|
// 日 and 本 may break between them. Marked, they move together when they do not fit after "A ".
|
|
let text = "A 日本";
|
|
let mut glyphs = synthetic_glyphs(&[(0, 1, 8.0, 'A'), (1, 1, 2.0, ' '), (2, 3, 10.0, '日'), (5, 3, 10.0, '本')]);
|
|
glyphs[2].no_break = true;
|
|
glyphs[3].no_break = true;
|
|
let (end, _) = break_line(text, &glyphs, 0, 22.0, &[], 0.0, None, Burasagari::None, Kinsoku::None);
|
|
assert_eq!(end, 2, "the marked pair moves to the next line together");
|
|
let (end, _) = break_line(text, &glyphs, end, 15.0, &[], 0.0, None, Burasagari::None, Kinsoku::None);
|
|
assert_eq!(end, 4, "on its own line the pair stays together even when it is wider than the line");
|
|
|
|
let breaks = candidates(text, &glyphs, None, Kinsoku::None);
|
|
assert!(breaks.iter().all(|b| b.end != 3), "no break between the marked characters: {breaks:?}");
|
|
}
|