Files
photocraft/crates/text/src/tests.rs
T
8fd4aa4cc2 fix(psd): an unedited save writes Photoshop's Txt2 back unchanged (#2374) (#2427)
* fix(psd): an unedited save writes Photoshop's Txt2 back unchanged (#2374)

Since #1517 every PSD save with type layers rebuilt the document's Txt2
block: it replaced everything under /1 with the text-object list, emptied
objects no layer names, and cut each kept object's style runs down to the
auto-kern mode alone. Photoshop then refused to open an unedited re-save
("Could not complete your request because of a program error").

build_txt2 now starts from the file's own block: the other /1 keys and
unnamed objects are kept, a kept object's style runs keep every style key
and only get /11 set (split where a mode changes inside a run), and a block
whose content comes out unchanged is returned byte for byte.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(psd): Photoshop type files keep Txt2 byte-for-byte on an unedited save (#2374)

Corpus test over corpus/photoshop/text: import, export, re-import, and
compare the Txt2 global block. All 56 Photoshop-authored type files pass;
the previous build_txt2 fails on the first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: vivekprojects-GIT <179340152+vivekprojects-GIT@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 13:11:42 -04:00

1278 lines
65 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.
use photocraft_color::{Color, PixelFormat, SampleType};
use photocraft_doc::TextLayer;
use photocraft_doc::text::{Caps, CharStyle, FontFeature, Orientation, ParagraphRun, ParagraphStyle, TextAlign, TextDirection, TextRun, TextShape};
use photocraft_geom::Affine;
use crate::{TextEngine, fonts};
fn point(text: &str, size_pt: f32) -> TextLayer {
TextLayer { text: text.into(), font_family: "Inter".into(), size_pt, ..Default::default() }
}
fn styled(text: &str, style: CharStyle) -> TextLayer {
TextLayer { text: text.into(), runs: vec![TextRun { len: text.len(), style }], ..Default::default() }
}
fn with_para(mut t: TextLayer, p: ParagraphStyle) -> TextLayer {
t.paragraphs = vec![ParagraphRun { len: t.text.len(), style: p }];
t
}
fn width(l: &crate::TextLayout) -> f32 {
l.lines.iter().map(|l| l.x1 - l.x0).fold(0.0, f32::max)
}
fn alpha_sum(s: &photocraft_raster::Surface, r: photocraft_geom::Rect) -> f64 {
let n = s.channels();
s.read_region(r).chunks_exact(n).map(|p| f64::from(p[n - 1])).sum()
}
#[test]
fn bundled_fonts_cover_latin() {
let mut e = TextEngine::new();
let fams = e.fonts.families();
assert!(fams.iter().any(|f| f == "Inter"), "{fams:?}");
assert!(fams.iter().any(|f| f == "JetBrains Mono"), "{fams:?}");
let l = e.layout(&point("Hello, Wörld! 0123", 12.0), 72.0);
assert_eq!(l.glyphs.len(), "Hello, Wörld! 0123".chars().count());
assert!(l.glyphs.iter().all(|g| g.id != 0), "no .notdef");
assert_eq!(e.fonts.faces("Inter").len(), 3);
}
#[test]
fn registering_fonts_moves_the_generation() {
let mut e = TextEngine::new();
let g = fonts::generation();
e.fonts.register_font_data(fonts::INTER_REGULAR.to_vec());
assert!(fonts::generation() > g);
}
#[test]
fn literal_psd_tabs_shape_as_whitespace_without_shifting_text_offsets() {
let mut e = TextEngine::new();
let src = "A\tB";
let l = e.layout(&point(src, 20.0), 72.0);
let plain = e.layout(&point("AB", 20.0), 72.0);
assert_eq!(l.lines.len(), 1);
assert!(l.glyphs.iter().all(|g| g.id != 0), "a tab must not render a tofu glyph");
assert!(width(&l) > width(&plain), "the tab reserves whitespace");
assert!(l.clusters.iter().all(|c| c.range.end <= src.len()), "the source byte offsets stay valid");
assert!(l.caret(2).0 > l.caret(1).0, "caret moves across the tab");
}
#[test]
fn metrics_are_stable_and_scale_with_dpi() {
let mut e = TextEngine::new();
let a = e.layout(&point("Hamburgefonstiv", 12.0), 72.0);
let w = width(&a);
// Inter 12 px: stable to a tenth of a pixel across runs.
let again = width(&e.layout(&point("Hamburgefonstiv", 12.0), 72.0));
assert_eq!(w, again);
assert!((80.0..110.0).contains(&w), "{w}");
let b = e.layout(&point("Hamburgefonstiv", 12.0), 144.0);
assert!((width(&b) - 2.0 * w).abs() < 0.05, "{} vs {}", width(&b), 2.0 * w);
// Point text: first baseline at the anchor.
assert_eq!(a.lines[0].baseline, 0.0);
assert!(a.lines[0].ascent > 8.0 && a.lines[0].ascent < 13.0);
}
#[test]
fn small_caps_are_synthesized_when_the_font_has_no_small_caps_feature() {
let style = CharStyle { font_family: "Inter".into(), size_pt: 40.0, caps: Caps::SmallCaps, ..Default::default() };
let mut engine = TextEngine::new();
let small = engine.layout(&styled("aA", style), 72.0);
let upper = engine.layout(&styled("AA", CharStyle { font_family: "Inter".into(), size_pt: 40.0, ..Default::default() }), 72.0);
assert_eq!(small.glyphs.len(), 2);
assert_eq!(small.glyphs[0].id, upper.glyphs[0].id, "lowercase uses the uppercase glyph");
assert_eq!(small.glyphs[1].id, upper.glyphs[1].id, "uppercase remains uppercase");
let size = |layout: &crate::TextLayout, glyph: usize| layout.faces[layout.glyphs[glyph].face as usize].size_px;
assert!((size(&small, 0) - 28.0).abs() < 0.01, "{}", size(&small, 0));
assert!((size(&small, 1) - 40.0).abs() < 0.01, "{}", size(&small, 1));
}
#[test]
fn auto_and_explicit_leading() {
let mut e = TextEngine::new();
let l = e.layout(&point("one\ntwo\rthree", 10.0), 72.0);
assert_eq!(l.lines.len(), 3);
assert!((l.lines[1].baseline - 12.0).abs() < 1e-3, "auto leading = 1.2 × size");
assert!((l.lines[2].baseline - 24.0).abs() < 1e-3);
assert_eq!(&"one\ntwo\rthree"[l.lines[2].range.clone()], "three");
let t = styled("a\nb", CharStyle { size_pt: 10.0, leading_pt: Some(30.0), ..Default::default() });
let l = e.layout(&t, 144.0);
assert!((l.lines[1].baseline - 60.0).abs() < 1e-3, "{}", l.lines[1].baseline);
// Space before/after in points.
let t = with_para(point("a\nb", 10.0), ParagraphStyle { space_after_pt: 5.0, ..Default::default() });
let l = e.layout(&t, 72.0);
assert!((l.lines[1].baseline - 17.0).abs() < 1e-3, "{}", l.lines[1].baseline);
}
#[test]
fn box_text_wraps_inside_width() {
let mut e = TextEngine::new();
let mut t = point("The quick brown fox jumps over the lazy dog again and again", 12.0);
t.shape = TextShape::Box { x: 10.0, y: 20.0, width: 100.0, height: 1000.0 };
let l = e.layout(&t, 72.0);
assert!(l.lines.len() >= 3, "{}", l.lines.len());
for line in &l.lines {
assert!(line.x0 >= 10.0 - 1e-3 && line.x1 <= 110.0 + 1e-3, "{line:?}");
}
// First baseline = top + ascender height ('d'), a bit less than the hhea ascent.
let drop = l.lines[0].baseline - 20.0;
assert!(drop > 0.65 * 12.0 && drop < l.lines[0].ascent, "{drop}");
// Lines cover the text in order.
assert_eq!(l.lines[0].range.start, 0);
for w in l.lines.windows(2) {
assert!(w[1].range.start >= w[0].range.end);
}
// Overflowing lines are hidden, like Photoshop.
t.shape = TextShape::Box { x: 0.0, y: 0.0, width: 100.0, height: 30.0 };
let l2 = e.layout(&t, 72.0);
assert_eq!(l2.lines.len(), 2);
}
#[test]
fn alignment_point_and_box() {
let mut e = TextEngine::new();
let c = e.layout(&with_para(point("Centered", 20.0), ParagraphStyle { align: TextAlign::Center, ..Default::default() }), 72.0);
let ln = &c.lines[0];
assert!((ln.x0 + ln.x1).abs() < 0.01, "centered on anchor: {ln:?}");
let r = e.layout(&with_para(point("Right", 20.0), ParagraphStyle { align: TextAlign::Right, ..Default::default() }), 72.0);
assert!(r.lines[0].x1.abs() < 0.01 && r.lines[0].x0 < -10.0);
let mut t = point("aaa bbb ccc ddd eee fff ggg hhh iii jjj kkk", 12.0);
t.shape = TextShape::Box { x: 0.0, y: 0.0, width: 120.0, height: 500.0 };
let t = with_para(t, ParagraphStyle { align: TextAlign::JustifyLeft, ..Default::default() });
let l = e.layout(&t, 72.0);
assert!(l.lines.len() > 1);
let first = &l.lines[0];
// Justified line: last glyph ends at the right edge (within a pixel).
let last_glyph_x = l.glyphs.iter().filter(|g| (g.y - first.baseline).abs() < 1e-3).map(|g| g.x).fold(f32::MIN, f32::max);
assert!(last_glyph_x > 110.0, "{last_glyph_x}");
let right = with_para(
TextLayer { shape: TextShape::Box { x: 0.0, y: 0.0, width: 200.0, height: 100.0 }, ..point("end", 12.0) },
ParagraphStyle { align: TextAlign::Right, ..Default::default() },
);
let l = e.layout(&right, 72.0);
assert!((l.lines[0].x1 - 200.0).abs() < 0.5, "{:?}", l.lines[0]);
}
#[test]
fn rtl_text_is_reordered() {
let mut e = TextEngine::new();
// Hebrew isn't in the bundled fonts, but bidi still resolves (glyphs may be .notdef).
let text = "abc שלום def";
let l = e.layout(&point(text, 12.0), 72.0);
let heb: Vec<_> = l.clusters.iter().filter(|c| text[c.range.clone()].chars().all(|ch| ('\u{0590}'..='\u{05FF}').contains(&ch))).collect();
assert_eq!(heb.len(), 4);
assert!(heb.iter().all(|c| c.rtl));
// Visual order: the first logical Hebrew letter is rightmost among them.
let first = heb.iter().min_by_key(|c| c.range.start).unwrap();
assert!(heb.iter().all(|c| c.x <= first.x + 1e-3));
// Forced RTL paragraph: the Latin run ends up to the right of the Hebrew.
let t = with_para(point("שלום abc", 12.0), ParagraphStyle { direction: TextDirection::Rtl, ..Default::default() });
let l = e.layout(&t, 72.0);
let a = l.clusters.iter().find(|c| &"שלום abc"[c.range.clone()] == "a").unwrap();
let shin = l.clusters.iter().find(|c| c.range.start == 0).unwrap();
assert!(a.x < shin.x, "abc left of the Hebrew in an RTL paragraph");
assert!(l.clusters.iter().all(|c| c.range.end <= "שלום abc".len()));
}
/// Builds a TrueType collection from standalone fonts (table offsets rebased).
fn make_ttc(fonts: &[&[u8]]) -> Vec<u8> {
let header_len = 12 + 4 * fonts.len();
let mut out = Vec::new();
out.extend_from_slice(b"ttcf");
out.extend_from_slice(&0x0001_0000u32.to_be_bytes());
out.extend_from_slice(&(fonts.len() as u32).to_be_bytes());
let mut offsets = Vec::new();
let mut body: Vec<u8> = Vec::new();
for f in fonts {
while !(header_len + body.len()).is_multiple_of(4) {
body.push(0);
}
let base = (header_len + body.len()) as u32;
offsets.push(base);
let mut copy = f.to_vec();
let n = u16::from_be_bytes([copy[4], copy[5]]) as usize;
for i in 0..n {
let at = 12 + i * 16 + 8;
let off = u32::from_be_bytes(copy[at..at + 4].try_into().unwrap());
copy[at..at + 4].copy_from_slice(&(off + base).to_be_bytes());
}
body.extend(copy);
}
for o in offsets {
out.extend_from_slice(&o.to_be_bytes());
}
out.extend(body);
out
}
#[test]
fn truetype_collections_load() {
let ttc = make_ttc(&[fonts::BUNDLED[0].1.as_slice(), fonts::BUNDLED[3].1.as_slice()]);
assert_eq!(fonts::face_count(&ttc), 2);
let mut db = fonts::FontDb::new();
// A fresh DB without the bundled mono font would miss it; register the collection anyway
// and check both faces are indexed (families already present gain faces).
let before = db.faces("JetBrains Mono").len();
let names = db.register_font_data(ttc);
assert!(names.iter().any(|n| n == "Inter") && names.iter().any(|n| n == "JetBrains Mono"), "{names:?}");
assert_eq!(db.faces("JetBrains Mono").len(), before + 1);
// Layout with the TTC-backed face works.
let mut e = TextEngine { fonts: db, layouter: crate::layout::Layouter::new() };
let t = styled("mono", CharStyle { font_family: "JetBrains Mono".into(), ..Default::default() });
let l = e.layout(&t, 72.0);
assert!(l.glyphs.iter().all(|g| g.id != 0));
}
#[test]
fn opentype_features_and_tracking() {
let mut e = TextEngine::new();
let ids = |e: &mut TextEngine, feats: Vec<FontFeature>, lig: bool| {
let t = styled("a0", CharStyle { features: feats, ligatures: lig, ..Default::default() });
e.layout(&t, 72.0).glyphs.iter().map(|g| g.id).collect::<Vec<_>>()
};
let plain = ids(&mut e, vec![], true);
let alt = ids(&mut e, vec![FontFeature { tag: "zero".into(), value: 1 }], true);
assert_ne!(plain, alt, "Inter's `zero` feature selects the slashed zero");
let a = width(&e.layout(&styled("tracking", CharStyle::default()), 72.0));
let b = width(&e.layout(&styled("tracking", CharStyle { tracking: 100.0, ..Default::default() }), 72.0));
// 100/1000 em at 12 px per letter (8 letters; trailing spacing counts too).
assert!((b - a - 8.0 * 1.2).abs() < 1.3, "{a} → {b}");
}
#[test]
fn raster_coverage_scales_and_depths_agree() {
let mut e = TextEngine::new();
let t = |size: f32| TextLayer { transform: Affine::translate(10.0, 50.0), ..point("Ink", size) };
let (_, r12) = e.render(&t(12.0), 72.0, PixelFormat::RGBA8);
let (_, r24) = e.render(&t(24.0), 72.0, PixelFormat::RGBA8);
let s12 = alpha_sum(&r12.surface, r12.rect);
let s24 = alpha_sum(&r24.surface, r24.rect);
assert!(s12 > 20.0, "{s12}");
let ratio = s24 / s12;
assert!((3.6..4.4).contains(&ratio), "ink ∝ size²: {ratio}");
// Placement: anchored at (10, 50) baseline.
assert!(r12.rect.x0 >= 8 && r12.rect.x0 <= 11 && r12.rect.y1 <= 53 && r12.rect.y1 >= 50, "{:?}", r12.rect);
for fmt in [PixelFormat::RGBA16, PixelFormat::RGBA32F, PixelFormat::GRAYA8, PixelFormat::CMYKA8] {
let (_, r) = e.render(&t(12.0), 72.0, fmt);
assert_eq!(r.rect, r12.rect);
let s = alpha_sum(&r.surface, r.rect);
assert!((s - s12).abs() / s12 < 0.01, "{fmt:?}: {s} vs {s12}");
}
// Deterministic.
let (_, again) = e.render(&t(12.0), 72.0, PixelFormat::RGBA8);
assert_eq!(again.surface, r12.surface);
// Golden-ish total ink for "Ink" in Inter 12 px (±3%).
assert!((s12 - GOLDEN_INK_12).abs() / GOLDEN_INK_12 < 0.03, "ink sum {s12}");
}
/// Sum of alpha for "Ink", Inter Regular 12 px (measured once; guards rasterizer regressions).
const GOLDEN_INK_12: f64 = 44.4;
#[test]
fn styles_change_pixels() {
let mut e = TextEngine::new();
let base = CharStyle { size_pt: 30.0, ..Default::default() };
let ink = |e: &mut TextEngine, s: CharStyle| {
let (_, r) = e.render(&TextLayer { transform: Affine::translate(5.0, 40.0), ..styled("Hi", s) }, 72.0, PixelFormat::RGBA8);
(alpha_sum(&r.surface, r.rect), r.rect)
};
let (plain, prect) = ink(&mut e, base.clone());
let (bold, _) = ink(&mut e, CharStyle { faux_bold: true, ..base.clone() });
assert!(bold > plain * 1.1, "{plain} → {bold}");
let (under, urect) = ink(&mut e, CharStyle { underline: true, ..base.clone() });
assert!(under > plain && urect.y1 > prect.y1);
let (_, irect) = ink(&mut e, CharStyle { faux_italic: true, ..base.clone() });
assert!(irect.x1 > prect.x1, "slanted top extends right");
let (_, srect) = ink(&mut e, CharStyle { baseline_shift_pt: 10.0, ..base.clone() });
assert_eq!(srect.y0, prect.y0 - 10);
let (_, hrect) = ink(&mut e, CharStyle { horizontal_scale: 2.0, ..base.clone() });
assert!(hrect.width() as f32 > prect.width() as f32 * 1.7);
}
#[test]
fn multicolor_runs() {
let mut e = TextEngine::new();
let red = CharStyle { size_pt: 40.0, color: Color::rgb(1.0, 0.0, 0.0), ..Default::default() };
let blue = CharStyle { color: Color::rgb(0.0, 0.0, 1.0), ..red.clone() };
let t = TextLayer {
text: "HH".into(),
runs: vec![TextRun { len: 1, style: red }, TextRun { len: 1, style: blue }],
transform: Affine::translate(0.0, 40.0),
..Default::default()
};
let (l, r) = e.render(&t, 72.0, PixelFormat::RGBA8);
assert_eq!(l.glyphs.len(), 2);
let px = r.surface.read_region(r.rect);
let opaque: Vec<&[f32; 4]> = px.as_chunks::<4>().0.iter().filter(|p| p[3] > 0.99).collect();
assert!(opaque.iter().any(|p| p[0] > 0.99 && p[2] < 0.01));
assert!(opaque.iter().any(|p| p[2] > 0.99 && p[0] < 0.01));
// CMYK target keeps colour in the document model.
let (_, rc) = e.render(&t, 72.0, PixelFormat::CMYKA8);
assert!(rc.surface.read_region(rc.rect).as_chunks::<5>().0.iter().any(|p| p[4] > 0.99 && p[1] > 0.9 && p[2] > 0.9));
}
#[test]
fn hit_test_and_caret() {
let mut e = TextEngine::new();
let l = e.layout(&point("abc\ndef", 20.0), 72.0);
assert_eq!(l.hit_test(-5.0, -5.0), 0);
assert_eq!(l.hit_test(1000.0, -5.0), 3);
assert_eq!(l.hit_test(-5.0, 24.0), 4);
let (x, top, bottom) = l.caret(1);
assert!(x > 0.0 && top < 0.0 && bottom > 0.0);
let (x2, ..) = l.caret(2);
assert!(x2 > x);
assert_eq!(l.caret(3).0, l.lines[0].x1);
}
#[test]
fn empty_text_and_empty_lines() {
let mut e = TextEngine::new();
let l = e.layout(&point("", 12.0), 72.0);
assert_eq!(l.lines.len(), 1);
assert!(l.glyphs.is_empty());
let l = e.layout(&point("a\n\nb", 12.0), 72.0);
assert_eq!(l.lines.len(), 3);
assert!((l.lines[2].baseline - 2.0 * 14.4).abs() < 1e-3);
let (_, r) = e.render(&point("", 12.0), 72.0, PixelFormat::RGBA8);
assert_eq!(r.rect.width(), 0);
}
/// U+0003 is a forced line break inside a paragraph (as PSD type stores Shift+Return): a new
/// line with the paragraph's leading and no paragraph spacing, never a missing-glyph box.
#[test]
fn forced_line_break_starts_a_line_in_the_same_paragraph() {
let mut e = TextEngine::new();
let text = "one\u{3}two";
let spaced = ParagraphStyle { space_before_pt: 5.0, space_after_pt: 5.0, ..Default::default() };
let l = e.layout(&with_para(point(text, 10.0), spaced.clone()), 72.0);
assert_eq!(l.lines.len(), 2, "{:?}", l.lines);
assert_eq!(text[l.lines[0].range.clone()].trim_end_matches('\u{3}'), "one");
assert_eq!(&text[l.lines[1].range.clone()], "two");
assert_eq!((l.lines[0].paragraph, l.lines[1].paragraph), (0, 0));
assert!((l.lines[1].baseline - 12.0).abs() < 1e-3, "auto leading, no paragraph spacing: {}", l.lines[1].baseline);
assert!(l.glyphs.iter().all(|g| g.id != 0), "no .notdef for the break");
// A paragraph break in the same place adds the spacing.
let p = e.layout(&with_para(point("one\ntwo", 10.0), spaced), 72.0);
assert!((p.lines[1].baseline - 22.0).abs() < 1e-3, "{}", p.lines[1].baseline);
// Centred lines are centred on their own, and box text breaks there too.
let c = e.layout(&with_para(point("a\u{3}wide line", 20.0), ParagraphStyle { align: TextAlign::Center, ..Default::default() }), 72.0);
assert_eq!(c.lines.len(), 2);
assert!((c.lines[0].x0 + c.lines[0].x1).abs() < 0.01 && (c.lines[1].x0 + c.lines[1].x1).abs() < 0.01, "{:?}", c.lines);
let mut b = point("short\u{3}next", 12.0);
b.shape = TextShape::Box { x: 0.0, y: 0.0, width: 500.0, height: 500.0 };
assert_eq!(e.layout(&b, 72.0).lines.len(), 2);
// Rendered at every depth: two rows of ink, and nothing after the first line's text.
for fmt in [PixelFormat::RGBA8, PixelFormat::RGBA16, PixelFormat::RGBA32F] {
let (l, r) = e.render(&TextLayer { transform: Affine::translate(0.0, 40.0), ..point(text, 20.0) }, 72.0, fmt);
let row = |i: usize| {
let ln = &l.lines[i];
photocraft_geom::Rect::new(-2, (ln.baseline - ln.ascent + 40.0) as i32, 200, (ln.baseline + 40.0) as i32)
};
assert!(alpha_sum(&r.surface, row(0)) > 1.0 && alpha_sum(&r.surface, row(1)) > 1.0, "{fmt:?}");
let one_end = l.lines[0].x1.ceil() as i32 + 2;
assert!(alpha_sum(&r.surface, photocraft_geom::Rect::new(one_end, row(0).y0, 200, row(0).y1)) < 1e-3, "{fmt:?}: ink after the first line's text");
}
}
#[test]
fn postscript_names() {
let g = fonts::guess_from_postscript("MyriadPro-BoldIt");
assert_eq!((g.family.as_str(), g.weight, g.italic), ("Myriad Pro", 700, true));
let g = fonts::guess_from_postscript("TimesNewRomanPSMT");
assert_eq!((g.family.as_str(), g.weight, g.italic), ("Times New Roman", 400, false));
let g = fonts::guess_from_postscript("Arial-BoldMT");
assert_eq!((g.family.as_str(), g.weight), ("Arial", 700));
let mut db = fonts::FontDb::new();
let r = db.resolve_postscript("Inter-SemiBold");
assert_eq!((r.family.as_str(), r.weight, r.exact), ("Inter", 600, true));
// Unknown fonts fall back gracefully in layout.
let mut e = TextEngine::new();
let t = styled("x", CharStyle { font_family: "Nonexistent Sans".into(), postscript_name: Some("Nope-Bold".into()), ..Default::default() });
assert!(e.layout(&t, 72.0).glyphs[0].id != 0);
}
#[test]
fn depth_is_respected() {
let mut e = TextEngine::new();
let (_, r) = e.render(&TextLayer { transform: Affine::translate(2.0, 20.0), ..point("a", 20.0) }, 72.0, PixelFormat::RGBA16);
assert_eq!(r.surface.format().sample, SampleType::U16);
assert!(r.surface.format().alpha);
}
/// Visual check: `PHOTOCRAFT_TEXT_DUMP=/tmp/t.ppm cargo test -p photocraft-text dump -- --ignored`.
#[test]
#[ignore]
fn dump_sample() {
use photocraft_doc::text::{Caps, TextAlign};
let Some(path) = std::env::var_os("PHOTOCRAFT_TEXT_DUMP") else {
return;
};
let mut e = if std::env::var_os("PHOTOCRAFT_TEXT_SYSTEM").is_some() { TextEngine::with_system_fonts() } else { TextEngine::new() };
let s = CharStyle { size_pt: 28.0, ..Default::default() };
let text =
"Photocraft Type\nBold faux, italic faux, underline\nשלום عربي mixed ✓\nJustified paragraph text wraps inside the box nicely and evenly across lines.";
let runs = vec![
TextRun { len: 16, style: CharStyle { size_pt: 40.0, color: Color::rgb(0.1, 0.3, 0.9), caps: Caps::Normal, ..s.clone() } },
TextRun { len: 5, style: CharStyle { faux_bold: true, ..s.clone() } },
TextRun { len: 13, style: CharStyle { faux_italic: true, color: Color::rgb(0.8, 0.1, 0.1), ..s.clone() } },
TextRun { len: 11, style: CharStyle { underline: true, strikethrough: false, ..s.clone() } },
TextRun { len: 1000, style: CharStyle { size_pt: 20.0, tracking: 20.0, ..s.clone() } },
];
let t = TextLayer {
text: text.into(),
runs,
paragraphs: vec![
ParagraphRun { len: 45, style: ParagraphStyle { align: TextAlign::Left, ..Default::default() } },
ParagraphRun { len: 1000, style: ParagraphStyle { align: TextAlign::JustifyLeft, space_before_pt: 6.0, ..Default::default() } },
],
shape: TextShape::Box { x: 0.0, y: 0.0, width: 560.0, height: 400.0 },
transform: Affine::translate(20.0, 20.0),
..Default::default()
};
let (_, r) = e.render(&t, 72.0, PixelFormat::RGBA8);
let (w, h) = (600u32, 360u32);
let mut out = format!("P6 {w} {h} 255\n").into_bytes();
for y in 0..h as i32 {
for x in 0..w as i32 {
let p = r.surface.pixel(x, y);
let a = p[3];
for c in &p[..3] {
out.push(((c * a + (1.0 - a)) * 255.0).round() as u8);
}
}
}
std::fs::write(path, out).unwrap();
}
/// Variable-font axes reach the outlines (uses a system variable font when one is present).
#[test]
fn variable_font_axes() {
let candidates =
["/System/Library/Fonts/SFNS.ttf", "/usr/share/fonts/truetype/noto/NotoSans-VariableFont_wdth,wght.ttf", "C:\\Windows\\Fonts\\bahnschrift.ttf"];
let Some(bytes) = candidates.iter().find_map(|p| std::fs::read(p).ok()) else {
return;
};
let mut e = TextEngine::new();
let fams = e.fonts.register_font_data(bytes);
let Some(fam) = fams.first().cloned() else {
return;
};
if !e.fonts.faces(&fam).iter().any(|f| f.axes.iter().any(|a| a.0 == "wght")) {
return;
}
let ink = |e: &mut TextEngine, w: f32| {
let s = CharStyle {
font_family: fam.clone(),
size_pt: 40.0,
variations: vec![photocraft_doc::text::FontVariation { axis: "wght".into(), value: w }],
..Default::default()
};
let (_, r) = e.render(&TextLayer { transform: Affine::translate(0.0, 50.0), ..styled("Weight", s) }, 72.0, PixelFormat::RGBA8);
alpha_sum(&r.surface, r.rect)
};
let light = ink(&mut e, 200.0);
let heavy = ink(&mut e, 800.0);
assert!(heavy > light * 1.5, "{light} → {heavy}");
}
#[test]
fn warp_bends_rendered_text_and_outlines() {
use photocraft_doc::text::TextWarp;
let mut e = TextEngine::new();
let mut t = point("WARPED TEXT", 24.0);
t.transform = Affine::translate(20.0, 60.0);
let fmt = PixelFormat::RGBA8;
let (_, flat) = e.render(&t, 72.0, fmt);
t.warp = Some(TextWarp { style: "warpArc".into(), value: 60.0, horizontal: true, ..Default::default() });
let (layout, arced) = e.render(&t, 72.0, fmt);
// An arc pushes the ends down: the warped ink is taller and pixels differ.
assert!(arced.rect.height() > flat.rect.height() + 10, "{:?} vs {:?}", arced.rect, flat.rect);
assert!(alpha_sum(&arced.surface, arced.rect) > 0.0);
// Outlines follow the same warp: the first glyph's outline sits lower than the middle one's.
let warp = crate::render::layout_warp(&layout, t.warp.as_ref());
let outs = crate::render::outlines(&layout, &t.transform, warp.as_ref());
assert_eq!(outs.len(), layout.glyphs.iter().filter(|g| g.id != 0).count() - 1, "space has no outline");
let low_y = |els: &Vec<crate::render::PathEl>| {
els.iter()
.filter_map(|e| match e {
crate::render::PathEl::MoveTo(p) | crate::render::PathEl::LineTo(p) => Some(p[1]),
_ => None,
})
.fold(f64::MIN, f64::max)
};
assert!(low_y(&outs[0]) > low_y(&outs[outs.len() / 2]) + 5.0);
// Unwarped outlines stay within the flat raster's bounds.
let flat_outs = crate::render::outlines(&layout, &t.transform, None);
let r = flat.rect;
for els in &flat_outs {
for el in els {
if let crate::render::PathEl::MoveTo(p) | crate::render::PathEl::LineTo(p) = el {
assert!(p[0] >= f64::from(r.x0) && p[0] <= f64::from(r.x1) && p[1] >= f64::from(r.y0) && p[1] <= f64::from(r.y1));
}
}
}
}
/// `Txt2` as Photoshop writes it (a bare `/key value` sequence; text objects at `/1 /1`, style
/// runs at `/0 /6 /0`, auto-kern mode at `/11`), for the layer text `text`.
fn txt2_with_modes(text: &str, modes: &[(usize, i64)]) -> Vec<u8> {
let mut v = b"\n\n/98 << /0 14 >> /0 << >> /1 << /1 [ << /0 << /0 (\xfe\xff".to_vec();
for u in format!("{text}\r").encode_utf16() {
let b = u.to_be_bytes();
for x in b {
if matches!(x, b'(' | b')' | b'\\') {
v.push(b'\\');
}
v.push(x);
}
}
v.extend_from_slice(b") /6 << /0 [ ");
for (len, m) in modes {
v.extend_from_slice(format!("<< /0 << /0 << /0 (\u{fe}\u{ff}) /6 << /0 0 /11 {m} >> >> >> /1 {len} >> ").as_bytes());
}
v.extend_from_slice(b"] >> >> >> ] >>");
v
}
fn with_text_index(tysh: &[u8], index: i32) -> Vec<u8> {
let mut t = crate::psd::parse_tysh(tysh).unwrap();
t.text.items.retain(|(k, _)| !k.is("TextIndex"));
t.text.items.push((photocraft_psd::descriptor::Id::new("TextIndex"), photocraft_psd::descriptor::Value::Integer(index)));
crate::psd::write_tysh(&t)
}
/// Optical (and "0") kerning only lives in `Txt2`: import applies it when the layer's
/// `TextIndex` object still holds the same text.
#[test]
fn txt2_carries_optical_kerning() {
use photocraft_doc::text::Kerning;
let t = styled("AVA", CharStyle::default());
let tysh = with_text_index(&crate::psd::build_tysh(&t, 72.0, None), 0);
let mut back = crate::psd::text_layer_from_tysh(&tysh, 72.0).unwrap();
assert!(back.char_runs().iter().all(|r| r.style.kerning == Kerning::Metrics));
// "AVA\r": two optical characters, then manual ("0") for the last and the break.
let txt2 = crate::psd::parse_txt2(&txt2_with_modes("AVA", &[(2, 2), (2, 0)])).unwrap();
crate::psd::apply_txt2(&mut back, &tysh, &txt2);
let modes: Vec<(usize, Kerning)> = back.char_runs().iter().map(|r| (r.len, r.style.kerning)).collect();
assert_eq!(modes, vec![(2, Kerning::Optical), (1, Kerning::Off)]);
// Stale Txt2 (other text), another index or garbage: nothing changes, nothing panics.
for (data, index) in [(txt2_with_modes("AVX", &[(4, 2)]), 0), (txt2_with_modes("AVA", &[(4, 2)]), 3), (b"<< /1 [ (x".to_vec(), 0), (Vec::new(), 0)] {
let tysh = with_text_index(&crate::psd::build_tysh(&t, 72.0, None), index);
let mut l = crate::psd::text_layer_from_tysh(&tysh, 72.0).unwrap();
if let Some(txt2) = crate::psd::parse_txt2(&data) {
crate::psd::apply_txt2(&mut l, &tysh, &txt2);
}
assert!(
l.char_runs().iter().all(|r| r.style.kerning == Kerning::Metrics),
"case {index}: {:?}",
l.char_runs().iter().map(|r| r.style.kerning).collect::<Vec<_>>()
);
}
// Run lengths longer than the text, zero lengths and odd modes are tolerated.
let txt2 = crate::psd::parse_txt2(&txt2_with_modes("AVA", &[(0, 2), (100, 2), (5, 9)])).unwrap();
let mut l = crate::psd::text_layer_from_tysh(&tysh, 72.0).unwrap();
crate::psd::apply_txt2(&mut l, &tysh, &txt2);
assert!(l.char_runs().iter().all(|r| r.style.kerning == Kerning::Optical));
}
/// A `Txt2` written by [`crate::psd::build_txt2`] (what PSD export writes for the type layers)
/// restores every auto-kern mode on import (#1348: a new Optical layer used to reopen as
/// Metrics, because EngineData's `AutoKerning true` covers both).
#[test]
fn build_txt2_round_trips_kerning_modes() {
use photocraft_doc::text::Kerning;
let style = |kerning: Kerning, kern: f32| CharStyle { kerning, kern, ..Default::default() };
let t = runs_of("AVAT", &[(2, style(Kerning::Optical, 0.0)), (1, style(Kerning::Metrics, 0.0)), (1, style(Kerning::Off, 0.0))]);
let tysh = with_text_index(&crate::psd::build_tysh(&t, 72.0, None), 0);
let mut back = crate::psd::text_layer_from_tysh(&tysh, 72.0).unwrap();
assert!(
back.char_runs().iter().all(|r| r.style.kerning != Kerning::Optical),
"EngineData alone can't say Optical: {:?}",
back.char_runs().iter().map(|r| r.style.kerning).collect::<Vec<_>>()
);
let txt2 = crate::psd::parse_txt2(&crate::psd::build_txt2(&[(0, &t)], None)).unwrap();
crate::psd::apply_txt2(&mut back, &tysh, &txt2);
let modes: Vec<(usize, Kerning)> = back.char_runs().iter().map(|r| (r.len, r.style.kerning)).collect();
assert_eq!(modes, vec![(2, Kerning::Optical), (1, Kerning::Metrics), (1, Kerning::Off)]);
// A manual kern (a nonzero pair value) is the manual mode, as in the EngineData pair fields.
let manual = runs_of("AB", &[(1, style(Kerning::Metrics, 50.0)), (1, style(Kerning::Metrics, 0.0))]);
let tysh = with_text_index(&crate::psd::build_tysh(&manual, 72.0, None), 0);
let mut back = crate::psd::text_layer_from_tysh(&tysh, 72.0).unwrap();
let txt2 = crate::psd::parse_txt2(&crate::psd::build_txt2(&[(0, &manual)], None)).unwrap();
crate::psd::apply_txt2(&mut back, &tysh, &txt2);
let modes: Vec<(usize, Kerning)> = back.char_runs().iter().map(|r| (r.len, r.style.kerning)).collect();
assert_eq!(modes, vec![(1, Kerning::Off), (1, Kerning::Metrics)]);
}
/// A kept `Txt2` object — same text, so its extras are still true — keeps everything the file
/// held beyond the regenerated keys (Photoshop stores glyph pen positions under `/21 /1`), and a
/// changed text drops those extras with the stale runs. The block's own extras always survive.
#[test]
fn txt2_keeps_a_kept_objects_extras_and_drops_them_with_its_text() {
use crate::engine_data::Value as E;
use photocraft_doc::text::Kerning;
let style = |kerning: Kerning, kern: f32| CharStyle { kerning, kern, ..Default::default() };
// The file's block: object 0 with `/21 /1` pen positions, a model extra, and a block extra.
let obj = E::Dict(vec![
(
"0".into(),
E::Dict(vec![("0".into(), E::String("AB\r".into())), ("6".into(), E::Dict(vec![("0".into(), E::Array(vec![]))])), ("keep".into(), E::Int(7))]),
),
("21".into(), E::Dict(vec![("1".into(), E::Array(vec![E::Real(1.5), E::Real(2.5)]))])),
]);
let prev = crate::engine_data::write_bare(&[
("98".into(), E::Dict(vec![("0".into(), E::Int(14))])),
("0".into(), E::dict()),
("1".into(), E::Dict(vec![("1".into(), E::Array(vec![obj]))])),
("extra".into(), E::Int(3)),
]);
// Same text: the extras survive, the style runs are ours.
let t = runs_of("AB", &[(1, style(Kerning::Optical, 0.0)), (1, style(Kerning::Metrics, 0.0))]);
let out = crate::psd::parse_txt2(&crate::psd::build_txt2(&[(0, &t)], Some(&prev))).unwrap();
assert_eq!(out.get("extra").and_then(E::as_i64), Some(3), "block extras survive");
let object = out.path(&["1", "1"]).and_then(E::as_array).unwrap()[0].clone();
assert_eq!(object.path(&["21", "1"]).and_then(E::as_array).map(|items| items.len()), Some(2), "pen positions survive an unchanged text");
assert_eq!(object.path(&["0", "keep"]).and_then(E::as_i64), Some(7), "model extras survive");
assert_eq!(object.path(&["0", "6", "0"]).and_then(E::as_array).map(|items| items.len()), Some(2), "the style runs are regenerated");
// Changed text: the extras go stale with the old runs and are dropped.
let t = runs_of("XY", &[(1, style(Kerning::Optical, 0.0)), (1, style(Kerning::Metrics, 0.0))]);
let out = crate::psd::parse_txt2(&crate::psd::build_txt2(&[(0, &t)], Some(&prev))).unwrap();
assert_eq!(out.get("extra").and_then(E::as_i64), Some(3), "block extras survive a text change too");
let object = out.path(&["1", "1"]).and_then(E::as_array).unwrap()[0].clone();
assert!(object.get("21").is_none(), "pen positions of another text are dropped: {:?}", object.get("21"));
assert!(object.path(&["0", "keep"]).is_none(), "model extras of another text are dropped");
}
/// A `Txt2` shaped like Photoshop's: document resources and settings beside the text objects
/// (`/0`, `/1 /0`, `/1 /2`), style runs carrying a full style (font, size, leading) besides the
/// auto-kern mode, a Latin-1 string without a byte-order mark, reals, and a second object no
/// layer names. `modes` are the object's `(UTF-16 length, auto-kern mode)` runs for "AVA\r".
fn photoshop_txt2(modes: &[(usize, i64)]) -> Vec<u8> {
let mut v = b"\n\n/98 << /0 14 >> /0 << /1 << /0 [ << /0 << /0 (MyriadPro-Regular) /2 0 >> >> ] >> >> /1 << /0 << /0 1 /1 .5 >> /1 [ << /0 << /0 (\xfe\xff\0A\0V\0A\0\r) /6 << /0 [ ".to_vec();
for (len, m) in modes {
v.extend_from_slice(format!("<< /0 << /0 << /0 (\u{fe}\u{ff}) /6 << /0 0 /1 12.0 /2 14.5 /11 {m} >> >> >> /1 {len} >> ").as_bytes());
}
v.extend_from_slice(
b"] >> /7 << /0 1 >> >> /21 << /1 [ 1.25 2.5 ] >> >> << /0 << /0 (\xfe\xff\0B\0\r) >> /21 << /1 [ 3.0 ] >> >> ] /2 << /0 7 >> >> /3 << /0 .25 >>",
);
v
}
/// #2374: an unedited save hands Photoshop back its own `Txt2`, byte for byte. Rebuilding it
/// dropped `/1 /0` and `/1 /2`, emptied unnamed objects and cut every style run down to its
/// auto-kern mode, and Photoshop refused to open the file.
#[test]
fn unchanged_txt2_is_written_back_byte_for_byte() {
use photocraft_doc::text::Kerning;
let prev = photoshop_txt2(&[(2, 1), (2, 1)]);
let tysh = with_text_index(&crate::psd::build_tysh(&styled("AVA", CharStyle::default()), 72.0, None), 0);
let mut t = crate::psd::text_layer_from_tysh(&tysh, 72.0).unwrap();
crate::psd::apply_txt2(&mut t, &tysh, &crate::psd::parse_txt2(&prev).unwrap());
assert!(t.char_runs().iter().all(|r| r.style.kerning == Kerning::Metrics));
assert_eq!(crate::psd::build_txt2(&[(0, &t)], Some(&prev)), prev);
// An optical run reads back and writes back the same way.
let prev = photoshop_txt2(&[(1, 2), (3, 1)]);
crate::psd::apply_txt2(&mut t, &tysh, &crate::psd::parse_txt2(&prev).unwrap());
assert_eq!(t.char_runs().first().map(|r| r.style.kerning), Some(Kerning::Optical));
assert_eq!(crate::psd::build_txt2(&[(0, &t)], Some(&prev)), prev);
}
/// An edited auto-kern mode changes only `/11` (splitting the run it falls inside); the file's
/// style keys, the other `/1` keys and the objects no layer names all survive.
#[test]
fn edited_txt2_keeps_the_files_styles_and_objects() {
use crate::engine_data::Value as E;
use photocraft_doc::text::Kerning;
let prev = photoshop_txt2(&[(4, 1)]);
let style = |kerning: Kerning| CharStyle { kerning, ..Default::default() };
let t = runs_of("AVA", &[(1, style(Kerning::Optical)), (2, style(Kerning::Metrics))]);
let out = crate::psd::parse_txt2(&crate::psd::build_txt2(&[(0, &t)], Some(&prev))).unwrap();
let before = crate::psd::parse_txt2(&prev).unwrap();
for key in [&["0"][..], &["1", "0"], &["1", "2"], &["3"]] {
assert_eq!(out.path(key), before.path(key), "/{} survives", key.join(" /"));
}
let objects = out.path(&["1", "1"]).and_then(E::as_array).unwrap();
assert_eq!(objects.len(), 2, "the object no layer names is kept");
assert_eq!(objects.get(1), before.path(&["1", "1"]).and_then(E::as_array).unwrap().get(1));
let object = &objects[0];
assert_eq!(object.path(&["0", "7", "0"]).and_then(E::as_i64), Some(1), "model extras survive");
assert!(object.path(&["21", "1"]).is_some(), "pen positions survive an unchanged text");
let runs = object.path(&["0", "6", "0"]).and_then(E::as_array).unwrap();
let got: Vec<(i64, i64)> =
runs.iter().map(|r| (r.get("1").and_then(E::as_i64).unwrap(), r.path(&["0", "0", "6", "11"]).and_then(E::as_i64).unwrap())).collect();
assert_eq!(got, vec![(1, 2), (3, 1)], "the run is split where the mode changes");
for r in runs {
assert_eq!(r.path(&["0", "0", "6", "1"]).and_then(E::as_f64), Some(12.0), "the font size survives");
assert_eq!(r.path(&["0", "0", "6", "2"]).and_then(E::as_f64), Some(14.5), "the leading survives");
}
// And it reads back with the edited modes.
let tysh = with_text_index(&crate::psd::build_tysh(&t, 72.0, None), 0);
let mut back = crate::psd::text_layer_from_tysh(&tysh, 72.0).unwrap();
crate::psd::apply_txt2(&mut back, &tysh, &out);
let modes: Vec<(usize, Kerning)> = back.char_runs().iter().map(|r| (r.len, r.style.kerning)).collect();
assert_eq!(modes, vec![(1, Kerning::Optical), (2, Kerning::Metrics)]);
}
/// The file's style runs are only reused when they cover the text: runs of another length (or
/// hostile lengths) fall back to generated runs, without panicking.
#[test]
fn txt2_runs_that_dont_cover_the_text_are_regenerated() {
use crate::engine_data::Value as E;
let t = runs_of("AVA", &[(3, CharStyle::default())]);
for modes in [&[(3, 1)][..], &[(9, 1)], &[(i64::MAX as usize, 1), (2, 1)], &[]] {
let prev = photoshop_txt2(modes);
let out = crate::psd::parse_txt2(&crate::psd::build_txt2(&[(0, &t)], Some(&prev))).unwrap();
let runs = out.path(&["1", "1"]).and_then(E::as_array).unwrap()[0].path(&["0", "6", "0"]).and_then(E::as_array).unwrap();
let total: i64 = runs.iter().filter_map(|r| r.get("1").and_then(E::as_i64)).sum();
assert_eq!(total, 4, "{modes:?}");
assert!(out.path(&["1", "2"]).is_some());
}
}
/// A file-controlled `TextIndex` must not size the save: out-of-range numbers are ignored (the
/// slot array is sized by real text objects, never by a file's numbers) and reading one back is
/// a no-op, so a hostile file degrades instead of allocating gigabytes on export.
#[test]
fn hostile_text_index_is_ignored_not_sized() {
use crate::engine_data::Value as E;
let t = runs_of("AB", &[(2, CharStyle::default())]);
let tysh = crate::psd::build_tysh(&t, 72.0, None);
assert_eq!(crate::psd::text_index(&crate::psd::set_text_index(&tysh, 0).unwrap()), Some(0));
assert_eq!(crate::psd::text_index(&crate::psd::set_text_index(&tysh, crate::psd::MAX_TEXT_INDEX).unwrap()), Some(crate::psd::MAX_TEXT_INDEX));
for hostile in [crate::psd::MAX_TEXT_INDEX + 1, i32::MAX] {
assert_eq!(crate::psd::text_index(&crate::psd::set_text_index(&tysh, hostile).unwrap()), None, "{hostile}");
let out = crate::psd::parse_txt2(&crate::psd::build_txt2(&[(hostile, &t)], None)).unwrap();
let slots = out.path(&["1", "1"]).and_then(E::as_array).unwrap();
assert!(slots.is_empty(), "{hostile} sized {} slots", slots.len());
}
// The bound is real but generous: the last honoured number lands at its slot.
let out = crate::psd::parse_txt2(&crate::psd::build_txt2(&[(crate::psd::MAX_TEXT_INDEX, &t)], None)).unwrap();
let slots = out.path(&["1", "1"]).and_then(E::as_array).unwrap();
assert_eq!(slots.len(), crate::psd::MAX_TEXT_INDEX as usize + 1);
}
/// Photopea needs an enabled fill, not just FillColor, to paint text after an edit. Inspect the
/// serialized TySh rather than our renderer, which does not consume the PSD fill flag.
#[test]
fn psd_text_fill_is_enabled_in_runs_and_new_default_styles() {
let mut t = point("AB", 100.0);
t.runs = vec![
TextRun { len: 1, style: CharStyle { color: Color::rgb(0.2, 0.4, 0.6), ..Default::default() } },
TextRun { len: 1, style: CharStyle { faux_bold: true, size_pt: 100.0, ..Default::default() } },
];
for shape in [TextShape::Point, TextShape::Box { x: 0.0, y: 0.0, width: 300.0, height: 200.0 }] {
t.shape = shape;
let tysh = crate::psd::parse_tysh(&crate::psd::build_tysh(&t, 72.0, None)).unwrap();
let data = crate::psd::engine_data(&tysh.text).unwrap();
let runs = data.path(&["EngineDict", "StyleRun", "RunArray"]).unwrap().as_array().unwrap();
assert_eq!(runs.len(), 2);
for run in runs {
assert_eq!(run.path(&["StyleSheet", "StyleSheetData", "FillFlag"]).and_then(crate::engine_data::Value::as_bool), Some(true));
}
for key in ["ResourceDict", "DocumentResources"] {
let sheets = data.path(&[key, "StyleSheetSet"]).unwrap().as_array().unwrap();
assert_eq!(sheets[0].path(&["StyleSheetData", "FillFlag"]).and_then(crate::engine_data::Value::as_bool), Some(true));
}
let back = crate::psd::text_layer_from_tysh(&crate::psd::write_tysh(&tysh), 72.0).unwrap();
assert_eq!(back.text, t.text);
assert_eq!(back.runs.len(), t.runs.len());
for (actual, expected) in back.runs.iter().zip(&t.runs) {
assert_eq!(actual.style.color, expected.style.color);
assert_eq!(actual.style.size_pt, expected.style.size_pt);
assert_eq!(actual.style.faux_bold, expected.style.faux_bold);
}
}
}
#[test]
fn psd_text_fill_is_enabled_after_editing_a_legacy_export() {
use crate::engine_data::Value as E;
// A legacy PhotoCraft export has a normal style and character runs without FillFlag.
let mut t = point("Before", 40.0);
let mut legacy = crate::psd::parse_tysh(&crate::psd::build_tysh(&t, 72.0, None)).unwrap();
fn remove_fill_flags(value: &mut E) {
match value {
E::Dict(items) => {
items.retain(|(key, _)| key != "FillFlag");
for (_, value) in items {
remove_fill_flags(value);
}
}
E::Array(items) => items.iter_mut().for_each(remove_fill_flags),
_ => {}
}
}
let mut data = crate::psd::engine_data(&legacy.text).unwrap();
remove_fill_flags(&mut data);
for (key, value) in &mut legacy.text.items {
if key.as_bytes() == b"EngineData" {
*value = photocraft_psd::descriptor::Value::RawData(crate::engine_data::write(&data));
}
}
t.psd_raw = Some(crate::psd::write_tysh(&legacy).into());
t.text = "After".into();
let tysh = crate::psd::parse_tysh(&crate::psd::build_tysh(&t, 72.0, None)).unwrap();
let data = crate::psd::engine_data(&tysh.text).unwrap();
let runs = data.path(&["EngineDict", "StyleRun", "RunArray"]).unwrap().as_array().unwrap();
assert!(!runs.is_empty());
for run in runs {
assert_eq!(run.path(&["StyleSheet", "StyleSheetData", "FillFlag"]).and_then(E::as_bool), Some(true));
}
}
/// EngineData pair fields: manual kerning and "no automatic kerning" round-trip exactly, in
/// Photoshop's form (see `psd::pair_runs`).
#[test]
fn psd_round_trips_manual_kerning() {
use photocraft_doc::text::Kerning::{Metrics as M, Off as O};
let s = CharStyle::default();
let cases: Vec<Vec<(usize, photocraft_doc::text::Kerning, f32)>> = vec![
vec![(1, O, 100.0), (1, O, -50.0), (2, M, 0.0)],
vec![(1, M, 0.0), (1, O, 0.0), (2, M, 0.0)],
vec![(4, O, 0.0)],
vec![(2, M, 0.0), (1, O, 25.0), (1, M, 0.0)],
vec![(3, M, 0.0), (1, O, 300.0)],
];
for runs in cases {
let t = runs_of("AVAT", &runs.iter().map(|&(len, kerning, kern)| (len, CharStyle { kerning, kern, ..s.clone() })).collect::<Vec<_>>());
let back = crate::psd::text_layer_from_tysh(&crate::psd::build_tysh(&t, 72.0, None), 72.0).unwrap();
let per = |t: &TextLayer| t.char_runs().iter().flat_map(|r| std::iter::repeat_n((r.style.kerning, r.style.kern), r.len)).collect::<Vec<_>>();
let mut want = per(&t);
// The last character's mode has no EngineData slot (it reads back as Metrics unless
// it has a manual kern).
if let Some(last) = want.last_mut()
&& last.1 == 0.0
{
last.0 = M;
}
assert_eq!(per(&back), want, "{runs:?}");
}
}
#[test]
fn psd_round_trips_antialias_opentype_and_warp() {
use photocraft_doc::text::{AntiAlias, TextWarp};
let style = CharStyle {
font_family: "Inter".into(),
size_pt: 20.0,
features: vec![FontFeature { tag: "swsh".into(), value: 1 }, FontFeature { tag: "frac".into(), value: 1 }],
discretionary_ligatures: true,
..Default::default()
};
for aa in [AntiAlias::Windows, AntiAlias::WindowsLcd, AntiAlias::Crisp, AntiAlias::None] {
let mut t = styled("1/2 Swash", style.clone());
t.antialias = aa;
t.warp = Some(TextWarp { style: "warpFlag".into(), value: -35.0, horizontal_distortion: 10.0, vertical_distortion: 0.0, horizontal: false });
let bytes = crate::psd::build_tysh(&t, 72.0, None);
let back = crate::psd::text_layer_from_tysh(&bytes, 72.0).unwrap();
assert_eq!(back.antialias, aa);
assert_eq!(back.warp, t.warp);
let st = &back.char_runs()[0].style;
assert!(st.discretionary_ligatures);
let mut tags: Vec<&str> = st.features.iter().map(|f| f.tag.as_str()).collect();
tags.sort_unstable();
assert_eq!(tags, ["frac", "swsh"]);
}
}
/// #1469: Some older PSD writers combine a zero PointBase with an enormous local ink origin.
/// The TySh transform cancels that origin, so importing it as a zero-based layout puts text far
/// off-canvas. Fold the descriptor origin into the imported transform and preserve it on a TySh
/// round trip so every editing and export path uses the same position.
#[test]
fn legacy_tysh_point_origin_imports_and_round_trips_on_canvas() {
use photocraft_psd::descriptor::{Descriptor, Id, Value as D};
let source = styled("Name", CharStyle { font_family: "Inter".into(), size_pt: 120.0, ..Default::default() });
let mut tysh = crate::psd::parse_tysh(&crate::psd::build_tysh(&source, 72.0, None)).unwrap();
tysh.transform = Affine { m: [4.1667, 0.0, 0.0, 4.1667, -32375.0, -32887.5] };
let ink = [8050.0, 8160.0, 8220.0, 8300.0];
let rect = |class| {
D::Descriptor(
Descriptor::new(class)
.with("Left", D::UnitFloat { unit: *b"#Pnt", value: ink[0] })
.with("Top ", D::UnitFloat { unit: *b"#Pnt", value: ink[1] })
.with("Rght", D::UnitFloat { unit: *b"#Pnt", value: ink[2] })
.with("Btom", D::UnitFloat { unit: *b"#Pnt", value: ink[3] }),
)
};
tysh.text.items.retain(|(key, _)| !key.is("bounds") && !key.is("boundingBox"));
tysh.text.items.push((Id::new("bounds"), rect("bounds")));
tysh.text.items.push((Id::new("boundingBox"), rect("boundingBox")));
let data = crate::psd::write_tysh(&tysh);
let imported = crate::psd::text_layer_from_tysh(&data, 72.0).unwrap();
let expected = [4.1667, 0.0, 0.0, 4.1667, 1166.935, 1112.772];
for (got, want) in imported.transform.m.iter().zip(expected) {
assert!((got - want).abs() < 0.001, "{:?}", imported.transform.m);
}
let (_, rendered) = TextEngine::new().render(&imported, 72.0, PixelFormat::RGBA8);
let placed = rendered.surface.content_bounds();
assert!(!placed.is_empty(), "the normalized layer renders");
assert!((1000..3000).contains(&placed.x0) && (500..2000).contains(&placed.y0), "{placed:?}");
let round_trip = crate::psd::text_layer_from_tysh(&crate::psd::build_tysh(&imported, 72.0, Some(ink.map(|v| v as f32))), 72.0).unwrap();
for (got, want) in round_trip.transform.m.iter().zip(expected) {
assert!((got - want).abs() < 0.001, "{:?}", round_trip.transform.m);
}
}
/// Regression: a PSD whose text engine data has no `EngineDict` (or isn't a dictionary)
/// panicked with `expect("EngineDict")` when the layer was written back (PSD export).
#[test]
fn engine_data_template_without_engine_dict() {
use crate::engine_data::{self as ed, Value as E};
let t = styled("Hi", CharStyle::default());
let no_engine_dict = ed::parse(b"<< /ResourceDict << >> >>").unwrap();
for template in [no_engine_dict, E::Dict(vec![]), E::Int(3)] {
let e = crate::psd::build_engine_data(&t, Some(template), 72.0);
let text = e.path(&["EngineDict", "Editor", "Text"]);
assert!(matches!(text, Some(E::String(s)) if s == "Hi\r"), "{text:?}");
}
}
/// #123: the caret geometry (clusters, lines) sits on the rendered glyphs, so a click on a glyph
/// lands next to it: its left part before it, its right part after it. Plain text, tracking,
/// horizontal scale, mixed sizes, and centred, wrapped paragraph text.
#[test]
fn clusters_sit_on_rendered_glyphs() {
let mut e = TextEngine::new();
let big = CharStyle { size_pt: 40.0, ..Default::default() };
let small = CharStyle { size_pt: 18.0, ..Default::default() };
let mixed =
TextLayer { text: "HOHOHO".into(), runs: vec![TextRun { len: 3, style: big.clone() }, TextRun { len: 3, style: small.clone() }], ..Default::default() };
let boxed = with_para(
TextLayer { shape: TextShape::Box { x: 0.0, y: 0.0, width: 150.0, height: 200.0 }, ..styled("HOH HOH HOH HOH", small.clone()) },
ParagraphStyle { align: TextAlign::Center, ..Default::default() },
);
let cases = [
("plain", styled("HOHOH", big.clone())),
("tracking", styled("HOHOH", CharStyle { tracking: 300.0, ..big.clone() })),
("hscale", styled("HOHOH", CharStyle { horizontal_scale: 1.6, ..big.clone() })),
("kerned", styled("HOHOH", CharStyle { kern: 250.0, kerning: photocraft_doc::text::Kerning::Optical, ..big.clone() })),
("mixed", mixed),
("box", boxed),
];
for (name, t) in cases {
let t = TextLayer { transform: Affine::translate(7.0, 60.0), ..t };
let (l, r) = e.render(&t, 72.0, PixelFormat::RGBA8);
assert!(l.lines.len() >= if name == "box" { 2 } else { 1 }, "{name}");
for c in l.clusters.iter().filter(|c| !t.text[c.range.clone()].trim().is_empty()) {
let ln = &l.lines[c.line];
// Ink under the cluster's middle, between the line's ascent and descent.
let col = photocraft_geom::Rect::new(
(c.x + c.advance * 0.3 + 7.0).floor() as i32,
(ln.baseline - ln.ascent + 60.0).floor() as i32,
(c.x + c.advance * 0.55 + 7.0).ceil() as i32,
(ln.baseline + ln.descent + 60.0).ceil() as i32,
);
assert!(alpha_sum(&r.surface, col) > 1.0, "{name}: no ink under cluster {:?} ({col:?})", c.range);
let y = ln.baseline - ln.ascent * 0.4;
assert_eq!(l.hit_test(c.x + c.advance * 0.2, y), c.range.start, "{name}: left part of {:?}", c.range);
assert_eq!(l.hit_test(c.x + c.advance * 0.8, y), c.range.end, "{name}: right part of {:?}", c.range);
let (cx, top, bottom) = l.caret(c.range.start);
assert!((cx - c.x).abs() < 1e-3 && top < y && bottom > y, "{name}: caret at {:?}", c.range);
}
}
}
fn runs_of(text: &str, styles: &[(usize, CharStyle)]) -> TextLayer {
TextLayer { text: text.into(), runs: styles.iter().map(|(len, style)| TextRun { len: *len, style: style.clone() }).collect(), ..Default::default() }
}
/// Manual kerning (1/1000 em) after a character moves everything after it by kern × size.
#[test]
fn manual_kerning_moves_the_next_glyph() {
use photocraft_doc::text::Kerning;
let mut e = TextEngine::new();
let s = CharStyle { size_pt: 100.0, ..Default::default() };
let plain = e.layout(&styled("HOH", s.clone()), 72.0);
let kerned = e.layout(&runs_of("HOH", &[(1, CharStyle { kern: 100.0, ..s.clone() }), (2, s.clone())]), 72.0);
let x = |l: &crate::TextLayout, i: usize| l.glyphs[i].x;
assert_eq!(x(&kerned, 0), x(&plain, 0));
// 100/1000 em at 100 px = 10 px, for the next glyph and everything after it.
assert!((x(&kerned, 1) - x(&plain, 1) - 10.0).abs() < 1e-3, "{} vs {}", x(&kerned, 1), x(&plain, 1));
assert!((x(&kerned, 2) - x(&plain, 2) - 10.0).abs() < 1e-3);
assert!((width(&kerned) - width(&plain) - 10.0).abs() < 1e-3);
// Carets follow: the cluster after the kerned pair starts 10 px later.
assert!((kerned.caret(1).0 - plain.caret(1).0 - 10.0).abs() < 1e-3);
// Negative kerning tightens; kerning on the last character doesn't move anything.
let tight = e.layout(&runs_of("HOH", &[(1, CharStyle { kern: -50.0, ..s.clone() }), (2, s.clone())]), 72.0);
assert!((x(&tight, 1) - x(&plain, 1) + 5.0).abs() < 1e-3);
let last = e.layout(&runs_of("HOH", &[(2, s.clone()), (1, CharStyle { kern: 500.0, ..s.clone() })]), 72.0);
assert!((width(&last) - width(&plain)).abs() < 1e-3);
// Off replaces the font's pair kerning: "AV" with Off is wider than with Metrics.
let mut av = |st: CharStyle| e.layout(&styled("AV", st), 72.0).glyphs[1].x;
let metric = av(s.clone());
let off = av(CharStyle { kerning: Kerning::Off, ..s.clone() });
assert!(off > metric + 1.0, "Inter kerns AV: {off} vs {metric}");
// Centred point text stays centred around the anchor with kerning.
let centred = with_para(
runs_of("HOH", &[(1, CharStyle { kern: 300.0, ..s.clone() }), (2, s.clone())]),
ParagraphStyle { align: TextAlign::Center, ..Default::default() },
);
let l = e.layout(&centred, 72.0);
assert!((l.lines[0].x0 + l.lines[0].x1).abs() < 0.5, "{:?}", l.lines[0]);
}
/// Optical kerning computes pair spacing from the outlines: tighter for open pairs ("AV", "To")
/// than the unkerned advance, about neutral for straight stems, and never absurd.
#[test]
fn optical_kerning_tightens_open_pairs() {
use photocraft_doc::text::Kerning;
let mut e = TextEngine::new();
let s = CharStyle { size_pt: 100.0, ..Default::default() };
let gap = |e: &mut TextEngine, text: &str, k: Kerning| {
let l = e.layout(&styled(text, CharStyle { kerning: k, ..s.clone() }), 72.0);
l.glyphs[1].x - l.glyphs[0].x
};
for pair in ["AV", "To", "LT", "Ty"] {
let off = gap(&mut e, pair, Kerning::Off);
let optical = gap(&mut e, pair, Kerning::Optical);
assert!(optical < off - 3.0, "{pair}: optical {optical} vs unkerned {off}");
}
for pair in ["HH", "nn", "oo", "HO"] {
let off = gap(&mut e, pair, Kerning::Off);
let optical = gap(&mut e, pair, Kerning::Optical);
assert!((optical - off).abs() < 6.0, "{pair}: optical {optical} vs unkerned {off}");
}
// A space breaks the pair; a manual kern replaces the automatic one (as in Photoshop).
assert_eq!(gap(&mut e, "A V", Kerning::Optical), gap(&mut e, "A V", Kerning::Off));
for mode in [Kerning::Optical, Kerning::Metrics] {
let l = e.layout(&styled("AV", CharStyle { kerning: mode, kern: 100.0, ..s.clone() }), 72.0);
let off = gap(&mut e, "AV", Kerning::Off);
assert!((l.glyphs[1].x - l.glyphs[0].x - off - 10.0).abs() < 1e-3, "{mode:?}");
}
}
/// A mode change splits shaping runs: the pairs on both sides of a manually kerned character
/// lose their automatic kerning (Photoshop renders it the same way).
#[test]
fn kerning_modes_split_pairs() {
use photocraft_doc::text::Kerning;
let mut e = TextEngine::new();
let s = CharStyle { size_pt: 100.0, ..Default::default() };
let off = CharStyle { kerning: Kerning::Off, ..s.clone() };
let xs = |e: &mut TextEngine, t: &TextLayer| e.layout(t, 72.0).glyphs.iter().map(|g| g.x).collect::<Vec<_>>();
let metric = xs(&mut e, &styled("AVAV", s.clone()));
let plain = xs(&mut e, &styled("AVAV", off.clone()));
let mixed = xs(&mut e, &runs_of("AVAV", &[(2, s.clone()), (1, off.clone()), (1, s.clone())]));
let adv = |v: &[f32], i: usize| v[i + 1] - v[i];
assert!((adv(&mixed, 0) - adv(&metric, 0)).abs() < 1e-3, "AV before stays kerned");
assert!((adv(&mixed, 1) - adv(&plain, 1)).abs() < 1e-3, "VA into the manual character");
assert!((adv(&mixed, 2) - adv(&plain, 2)).abs() < 1e-3, "AV out of it");
}
#[test]
fn japanese_dictionary_word_boundaries() {
// Use only the existing bundled Latin fonts: segmentation must not require a CJK font.
let text = "私は学生です";
let mut fonts = fonts::FontDb::new();
let mut context = parley::LayoutContext::<[u8; 4]>::new();
let mut layout = parley::Layout::new();
let mut builder = context.ranged_builder(&mut fonts.fcx, text, 1.0, false);
builder.push_default(parley::StyleProperty::FontFamily(parley::FontFamily::named("Inter")));
// Suppress CJK line-break opportunities so these flags expose word boundaries alone.
builder.push_default(parley::StyleProperty::WordBreak(parley::WordBreak::KeepAll));
builder.build_into(&mut layout, text);
layout.break_all_lines(None);
let mut boundaries = Vec::new();
for line in layout.lines() {
for run in line.runs() {
for cluster in run.clusters() {
if cluster.is_word_boundary() {
boundaries.push(cluster.text_range().start);
}
}
}
}
assert_eq!(boundaries, vec![0, 3, 6, 12]);
}
#[test]
fn japanese_box_text_preserves_wrap_boundaries() {
let mut engine = TextEngine::new();
let mut text = point("日本語の文章を折り返します。", 12.0);
text.shape = TextShape::Box { x: 0.0, y: 0.0, width: 40.0, height: 1000.0 };
let layout = engine.layout(&text, 72.0);
assert!(layout.lines.len() > 1);
let mut end = 0;
for line in &layout.lines {
assert_eq!(line.range.start, end);
let content = text.text.get(line.range.clone()).expect("UTF-8 line boundaries");
assert!(!content.starts_with('。'), "closing punctuation must stay with its preceding text");
end = line.range.end;
}
assert_eq!(end, text.text.len());
}
/// Which craft-fonts family drew the glyphs of `l` (matched by the embedded bytes).
fn craft_family_of(l: &crate::TextLayout) -> Vec<&'static str> {
let mut v = Vec::new();
for g in &l.glyphs {
let Some(face) = l.faces.get(g.face as usize) else { continue };
let data: &[u8] = face.font.data.as_ref();
if let Some(f) = crate::CRAFT_FONTS.iter().find(|f| std::ptr::eq(f.bytes.as_ptr(), data.as_ptr()) && f.bytes.len() == data.len())
&& !v.contains(&f.family)
{
v.push(f.family);
}
}
v
}
#[test]
fn craft_fonts_cover_japanese_without_system_fonts() {
if !crate::CRAFT_FONTS.iter().any(|f| f.is_japanese()) {
eprintln!("skipping: built without craft-fonts (set CRAFT_FONTS_DIR to a craft-fonts checkout)");
return;
}
let mut e = TextEngine::new();
let text = "日本語の文字、カタカナ。";
let l = e.layout(&point(text, 24.0), 72.0);
assert_eq!(l.glyphs.len(), text.chars().count());
assert!(l.glyphs.iter().all(|g| g.id != 0), "no .notdef with craft-fonts");
// Sans (Inter) runs fall back to the Gothic UI family.
assert_eq!(craft_family_of(&l), vec![crate::craft_fonts::UI_JAPANESE_FAMILY]);
// Serif runs fall back to a Mincho face when the build has one (not the web build).
if crate::CRAFT_FONTS.iter().any(|f| f.is_mincho()) {
let mut t = point(text, 24.0);
t.font_family = "Times New Roman".into();
let l = e.layout(&t, 72.0);
assert!(l.glyphs.iter().all(|g| g.id != 0));
let fams = craft_family_of(&l);
assert!(fams.len() == 1 && fams[0].contains("Mincho"), "{fams:?}");
}
// Latin keeps Inter.
let l = e.layout(&point("Layer 1", 24.0), 72.0);
assert!(craft_family_of(&l).is_empty());
}
#[test]
fn works_without_craft_fonts() {
// Whatever the build: the bundled fonts load, Latin lays out, and Japanese never panics
// (without craft-fonts or system fonts it may be .notdef).
let mut e = TextEngine::new();
assert!(e.fonts.has_family("Inter"));
let l = e.layout(&point("日本語 Latin", 12.0), 72.0);
assert_eq!(l.glyphs.len(), "日本語 Latin".chars().count());
for f in crate::CRAFT_FONTS {
assert!(e.fonts.has_family(f.family), "{} registered under its manifest name", f.family);
}
if crate::CRAFT_FONTS.is_empty() {
assert!(crate::craft_fonts::japanese_families().is_empty());
let fb = fonts::fallback_candidates(&crate::cjk::script_order(Some("ja")));
assert!(!fb.iter().any(|f| f.contains("BIZ UD")));
}
}
#[test]
fn word_and_line_navigation() {
use crate::layout::{byte_index, char_index, hit_char, line_edge, line_index, line_step, word_boundary};
assert_eq!(word_boundary("hello big world", 0, true), 5);
assert_eq!(word_boundary("hello big world", 7, false), 6);
assert_eq!(word_boundary("hello big world", 15, false), 10);
assert_eq!(word_boundary("hello", 0, false), 0);
assert_eq!(word_boundary("hello", 5, true), 5);
assert_eq!(word_boundary("", 4, true), 0);
assert_eq!(word_boundary("ab", 100, true), 2);
assert_eq!(word_boundary("ab, cd", 0, true), 2);
assert_eq!(word_boundary("ab, cd", 2, true), 6);
assert_eq!(word_boundary("Größe", 0, true), 5);
let mut e = TextEngine::new();
let text = "AäB\ncd";
for vertical in [false, true] {
let mut t = point(text, 20.0);
if vertical {
t.orientation = Orientation::Vertical;
}
let l = e.layout(&t, 72.0);
let n = text.chars().count();
for i in 0..=n {
let b = byte_index(text, i);
let [(x0, y0), (x1, y1)] = l.caret_segment(b);
let (idx, line) = hit_char(&l, text, (x0 + x1) / 2.0, (y0 + y1) / 2.0);
assert_eq!(idx, i, "vertical {vertical} index {i}");
assert_eq!(line, line_index(&l, b), "vertical {vertical} index {i}");
}
assert!(l.lines.len() >= 2, "vertical {vertical}");
let end0 = char_index(text, l.lines[0].range.end);
let start1 = char_index(text, l.lines[1].range.start);
assert_eq!(line_edge(&l, text, 1, true), end0, "vertical {vertical}");
assert_eq!(line_edge(&l, text, 1, false), 0, "vertical {vertical}");
assert_eq!(line_edge(&l, text, start1, false), start1, "vertical {vertical}");
assert_eq!(line_edge(&l, text, start1, true), n, "vertical {vertical}");
let x = l.caret(byte_index(text, 0)).0;
let next = line_step(&l, text, 0, x, 1);
assert!((start1..=n).contains(&next), "vertical {vertical} line_step -> {next}");
assert_eq!(line_step(&l, text, 0, x, -1), 0, "vertical {vertical}");
assert_eq!(line_step(&l, text, n, x, 1), n, "vertical {vertical}");
}
// A remembered column stays on the short line's start; the caret's own x falls off its end.
let text = "WWWWWW\nI";
let l = e.layout(&point(text, 30.0), 72.0);
let end0 = line_edge(&l, text, 0, true);
// end0 is the first line's end, which is not the second line. Step from there.
let kept = line_step(&l, text, end0, l.caret(0).0, 1);
let jumped = line_step(&l, text, end0, l.caret(byte_index(text, end0)).0, 1);
assert_eq!(kept, char_index(text, l.lines[1].range.start), "kept column");
assert_eq!(jumped, char_index(text, l.lines[1].range.end), "own column");
assert!(jumped > kept);
}
/// Thai text sample with above/below marks (sara i, mai ek, mai tho, mai han-akat, sara u).
const THAI_SAMPLE: &str = "ภาษาไทย สวัสดีครับ ผู้ที่น้ำ";
#[test]
fn thai_in_latin_font_falls_back_to_installed_thai_font() {
// #1909: Thai typed in a Latin-only font (a newly chosen font has no PostScript name to find
// the original Thai face) must fall back to an installed Thai-capable font, not .notdef.
let mut e = TextEngine::with_system_fonts();
let Some(thai) = fonts::THAI_FAMILIES.iter().find(|f| e.fonts.has_family(f)) else {
eprintln!("skipped: no Thai-capable font installed");
return;
};
let l = e.layout(&point(THAI_SAMPLE, 24.0), 72.0);
assert!(!l.glyphs.is_empty());
assert!(l.glyphs.iter().all(|g| g.id != 0), "Thai drawn with .notdef although {thai} is installed");
// Latin next to Thai keeps the chosen font; only the Thai clusters fall back.
let l = e.layout(&point("Thai ไทย", 24.0), 72.0);
assert!(l.glyphs.iter().all(|g| g.id != 0));
let (first, last) = (l.glyphs.first().map(|g| g.face), l.glyphs.last().map(|g| g.face));
assert_ne!(first, last, "Latin and Thai drawn with the same face");
}
#[test]
fn thai_fallback_candidates_cover_every_platform() {
// Logic-level half of #1909 (runs without Thai fonts): Windows, macOS and Linux each have a
// Thai-capable family in the fallback candidates, ahead of the broad last-resort fonts.
for order in [crate::cjk::script_order(None), crate::cjk::script_order(Some("ja"))] {
let fb = fonts::fallback_candidates(&order);
let last = fb.iter().position(|f| *f == "Arial Unicode MS").unwrap();
for fam in ["Leelawadee UI", "Tahoma", "Thonburi", "Noto Sans Thai"] {
let i = fb.iter().position(|f| *f == fam).unwrap_or_else(|| panic!("{fam} missing from {fb:?}"));
assert!(i < last, "{fam} after the last-resort fonts");
}
}
}
#[test]
fn a_served_script_fallback_joins_the_fallback_stack_once_it_arrives() {
let Some(cairo) = crate::CRAFT_FONTS.iter().find(|f| f.family == "Cairo") else {
eprintln!("skipping: built without craft-fonts (set CRAFT_FONTS_DIR to a craft-fonts checkout)");
return;
};
crate::served::add_fonts(&crate::served::parse_manifest("Cairo | Regular | fonts/cairo/Cairo.ttf | Arab,Latn\n").0);
let mut db = fonts::FontDb::new();
assert!(!db.fallback_stack().any(|f| f == "Cairo"), "not before it arrives");
db.register_font_data(cairo.bytes.to_vec());
assert!(db.fallback_stack().any(|f| f == "Cairo"), "{:?}", db.fallback_stack().collect::<Vec<_>>());
}