use photocraft_color::{Color, PixelFormat, SampleType}; use photocraft_doc::TextLayer; use photocraft_doc::text::{AntiAlias, 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() } } #[test] fn glyph_source_ranges_follow_shaped_clusters() { let mut engine = TextEngine::new(); for text in ["office", "Aé B", "First\nSecond"] { let layer = point(text, 24.0); let layout = engine.layout(&layer, 72.0); assert!(!layout.glyphs.is_empty()); assert_eq!(layout.glyphs.len(), layout.glyph_ranges.len()); for range in &layout.glyph_ranges { assert!(range.start < range.end, "empty range in {text:?}"); assert!(text.get(range.clone()).is_some(), "invalid range {range:?} in {text:?}"); assert!(layout.clusters.iter().any(|cluster| cluster.range == *range), "missing cluster for {range:?}"); } } } /// The rasteriser's pixel stages — composing one colour's coverage, unpremultiplying it, and /// converting the tiles — are per pixel: no pixel reads a slot another pixel wrote. Spreading them /// over threads therefore has to leave every byte as it was, which a one-thread pool checks. #[cfg(not(target_arch = "wasm32"))] #[test] fn parallel_raster_matches_a_single_thread() { let one = rayon::ThreadPoolBuilder::new().num_threads(1).build().expect("one-thread pool"); let mut e = TextEngine::new(); let boxed = TextShape::Box { x: 0.0, y: 0.0, width: 220.0, height: 90.0 }; let paragraph = TextLayer { text: "A paragraph box that wraps over several lines and covers many tiles, so the whole raster is exercised.".into(), font_family: "Inter".into(), size_pt: 18.0, shape: boxed, ..Default::default() }; let no_aa = TextLayer { text: "No AA edge".into(), font_family: "Inter".into(), size_pt: 30.0, antialias: AntiAlias::None, ..Default::default() }; let cases: Vec<(TextLayer, f32, PixelFormat)> = vec![ (point("Parallel", 48.0), 72.0, PixelFormat::RGBA8), (styled("Bold italic", CharStyle { size_pt: 40.0, faux_bold: true, faux_italic: true, ..Default::default() }), 300.0, PixelFormat::RGBA8), (no_aa, 150.0, PixelFormat::RGBA16), (styled("Wide gamut", CharStyle { size_pt: 36.0, color: Color::rgba(2.5, -0.25, 0.5, 0.4), ..Default::default() }), 96.0, PixelFormat::RGBA32F), (with_para(paragraph, ParagraphStyle::default()), 144.0, PixelFormat::RGBA8), ]; for (layer, dpi, format) in cases { let (_, spread) = e.render(&layer, dpi, format); let (_, single) = one.install(|| e.render(&layer, dpi, format)); assert_eq!(spread.rect, single.rect, "{}", layer.text); assert_eq!(spread.surface, single.surface, "{}", layer.text); } } #[test] fn raster_preserves_hdr_and_signed_float_text_colors() { let mut e = TextEngine::new(); for alpha in [1.0, 0.4] { let color = Color::rgba(2.5, -0.25, 0.5, alpha); let style = CharStyle { size_pt: 48.0, color, ..Default::default() }; let (_, rendered) = e.render(&styled("HDR", style), 72.0, PixelFormat::RGBA32F); let pixels = rendered.surface.read_region(rendered.rect); let mut covered = 0; for pixel in pixels.as_chunks::<4>().0.iter().filter(|p| p[3] > 0.01) { covered += 1; for channel in 0..3 { assert!((pixel[channel] - color.c[channel]).abs() < 1e-5, "alpha={alpha}, pixel={pixel:?}"); } assert!(pixel[3] <= alpha + 1e-6); } assert!(covered > 100); } } #[test] fn raster_integer_text_colors_stay_bounded() { let mut e = TextEngine::new(); for format in [PixelFormat::RGBA8, PixelFormat::RGBA16] { let style = CharStyle { size_pt: 48.0, color: Color::rgba(2.5, -0.25, 0.5, 0.4), ..Default::default() }; let (_, rendered) = e.render(&styled("HDR", style), 72.0, format); let pixels = rendered.surface.read_region(rendered.rect); let mut covered = 0; for pixel in pixels.as_chunks::<4>().0.iter().filter(|p| p[3] > 0.01) { covered += 1; assert_eq!(&pixel[..2], &[1.0, 0.0]); assert!((pixel[2] - 0.5).abs() < 1.0 / 255.0); assert!(pixel[3] <= 0.4 + 1.0 / 255.0); } assert!(covered > 100); } } 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); } /// Font menus cache on the shared engine's generation: other engines registering fonts (a test's /// throwaway one, in parallel) must not invalidate them, or the menus re-render every frame. #[test] fn only_the_shared_database_moves_the_shared_generation() { let _ = crate::shared(); let g = fonts::shared_generation(); TextEngine::new().fonts.register_font_data(fonts::INTER_REGULAR.to_vec()); assert_eq!(fonts::shared_generation(), g, "an unrelated engine leaves it alone"); let mut e = TextEngine::new(); e.fonts.mark_shared(); let g = fonts::shared_generation(); e.fonts.register_font_data(fonts::INTER_REGULAR.to_vec()); assert!(fonts::shared_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"); } /// Pen position of the cluster starting at byte `at`. fn cluster_x(l: &crate::TextLayout, at: usize) -> f32 { l.clusters.iter().find(|c| c.range.start == at).map(|c| c.x).unwrap() } /// #1881: a tab advances to Photoshop's next default tab stop (every 36 pt, from the anchor or /// the box's left edge), not one space width. #[test] fn tabs_advance_to_the_next_default_tab_stop() { let mut e = TextEngine::new(); let near = |a: f32, b: f32| (a - b).abs() < 0.05; let l = e.layout(&point("a\tb", 20.0), 72.0); assert!(l.glyphs.iter().all(|g| g.id != 0), "no .notdef"); assert!(near(cluster_x(&l, 2), 36.0), "{}", cluster_x(&l, 2)); assert!(near(l.caret(2).0, 36.0), "the caret after the tab sits on the stop"); // Stops are in points: twice the pixels at 144 dpi. let l = e.layout(&point("a\tb", 20.0), 144.0); assert!(near(cluster_x(&l, 2), 72.0), "{}", cluster_x(&l, 2)); // Each tab moves to the following stop; text past a stop skips to the next one. let l = e.layout(&point("a\t\tb", 20.0), 72.0); assert!(near(cluster_x(&l, 3), 72.0), "{}", cluster_x(&l, 3)); let l = e.layout(&point("MMM\tb", 20.0), 72.0); assert!(cluster_x(&l, 3) > 36.0); assert!(near(cluster_x(&l, 4), 72.0), "{}", cluster_x(&l, 4)); // A tab at the start of a line goes to the first stop; the line extent includes it. let l = e.layout(&point("\tb", 20.0), 72.0); assert!(near(cluster_x(&l, 1), 36.0), "{}", cluster_x(&l, 1)); assert!(l.lines[0].x1 > 36.0); // Paragraph text measures from the box's left edge. let mut t = point("a\tb", 20.0); t.shape = TextShape::Box { x: 10.0, y: 0.0, width: 300.0, height: 100.0 }; let l = e.layout(&t, 72.0); assert!(near(cluster_x(&l, 2), 46.0), "{}", cluster_x(&l, 2)); } /// The tab stays a tab in the document: PSD type round-trips it unchanged. #[test] fn psd_round_trips_tabs() { for shape in [TextShape::Point, TextShape::Box { x: 0.0, y: 0.0, width: 300.0, height: 100.0 }] { let t = TextLayer { shape: shape.clone(), ..point("Name\tValue\n\tIndented", 20.0) }; let back = crate::psd::text_layer_from_tysh(&crate::psd::build_tysh(&t, 72.0, None), 72.0).unwrap(); assert_eq!(back.text, t.text, "{shape:?}"); } } /// Expanded tabs never push paragraph text past the box's right edge. #[test] fn tabs_wrap_inside_the_box() { let mut e = TextEngine::new(); let mut t = point("a\tb\tc\td\te\tf\tg", 20.0); t.shape = TextShape::Box { x: 0.0, y: 0.0, width: 100.0, height: 1000.0 }; let l = e.layout(&t, 72.0); assert!(l.lines.len() > 1, "{}", l.lines.len()); // Lines break at the real tab widths: "c" (stop 72 px) still fits, "d" (stop 108 px) wraps. let line_of = |at: usize| l.clusters.iter().find(|c| c.range.start == at).map(|c| c.line).unwrap(); assert_eq!((line_of(4), line_of(6)), (0, 1)); assert!((cluster_x(&l, 6) - 0.0).abs() < 0.05, "a wrapped line starts at the box edge"); for c in &l.clusters { let ch = &t.text[c.range.clone()]; if ch != "\t" { assert!(c.x + c.advance <= 100.0 + 1e-3, "{ch:?} at {} overflows", c.x); } } } /// Right-aligned and centred point text keep their alignment around the expanded tab. #[test] fn tabs_keep_point_text_alignment() { let mut e = TextEngine::new(); let r = e.layout(&with_para(point("a\tb", 20.0), ParagraphStyle { align: TextAlign::Right, ..Default::default() }), 72.0); assert!(r.lines[0].x1.abs() < 0.05, "{:?}", r.lines[0]); let b = r.clusters.iter().find(|c| c.range.start == 2).unwrap(); assert!((b.x + b.advance).abs() < 0.05, "b ends at the anchor: {b:?}"); assert!(r.lines[0].x0 < -36.0); // Paragraph text: the line still ends at the box's right edge; a trailing tab is // whitespace outside the line's extent. for text in ["a\tb", "a\tb\t"] { let t = with_para( TextLayer { shape: TextShape::Box { x: 0.0, y: 0.0, width: 200.0, height: 100.0 }, ..point(text, 20.0) }, ParagraphStyle { align: TextAlign::Right, ..Default::default() }, ); let l = e.layout(&t, 72.0); let b = l.clusters.iter().find(|c| c.range.start == 2).unwrap(); assert!((b.x + b.advance - 200.0).abs() < 0.5, "{text:?}: {b:?}"); assert!((l.lines[0].x1 - 200.0).abs() < 0.5, "{text:?}: {:?}", l.lines[0]); } } #[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); } /// #5565: paragraph text breaks at its horizontally scaled width. At 60% it breaks like 100% /// text in a box 1/0.6 as wide, and the lines fill the box instead of wrapping early. #[test] fn horizontal_scale_breaks_paragraph_lines_at_the_scaled_width() { let mut e = TextEngine::new(); let text = "The quick brown fox jumps over the lazy dog again and again until the paragraph wraps"; let boxed = |scale: f32, width: f32, align: TextAlign| { let style = CharStyle { font_family: "Inter".into(), size_pt: 12.0, horizontal_scale: scale, ..Default::default() }; let t = TextLayer { shape: TextShape::Box { x: 10.0, y: 0.0, width, height: 1000.0 }, ..styled(text, style) }; with_para(t, ParagraphStyle { align, ..Default::default() }) }; let ranges = |l: &crate::TextLayout| l.lines.iter().map(|ln| ln.range.clone()).collect::>(); for scale in [0.6f32, 1.5] { let scaled = e.layout(&boxed(scale, 120.0, TextAlign::Left), 72.0); let plain = e.layout(&boxed(1.0, 120.0 / scale, TextAlign::Left), 72.0); assert!(scaled.lines.len() > 1, "{scale}"); assert_eq!(ranges(&scaled), ranges(&plain), "{scale}: same breaks as unscaled text in the widened box"); for (li, ln) in scaled.lines.iter().enumerate() { assert!(ln.x0 >= 10.0 - 1e-3 && ln.x1 <= 130.0 + 1e-3, "{scale}: {ln:?}"); // The extent ends at the last glyph, not after the scaled trailing space. let ink = scaled.clusters.iter().filter(|c| c.line == li && !text[c.range.clone()].trim().is_empty()); assert!((ink.map(|c| c.x + c.advance).fold(0.0, f32::max) - ln.x1).abs() < 0.01, "{scale}: {ln:?}"); } } // At 60% the lines use the box: the first would not fit at 100%. let scaled = e.layout(&boxed(0.6, 120.0, TextAlign::Left), 72.0); assert!(scaled.lines[0].x1 > 110.0, "{:?}", scaled.lines[0]); // Centred and right-aligned lines are placed by their scaled width. let c = e.layout(&boxed(0.6, 120.0, TextAlign::Center), 72.0); for ln in &c.lines { assert!(((ln.x0 + ln.x1) * 0.5 - 70.0).abs() < 0.5, "{ln:?}"); } let r = e.layout(&boxed(0.6, 120.0, TextAlign::Right), 72.0); for ln in &r.lines { assert!((ln.x1 - 130.0).abs() < 0.5, "{ln:?}"); } } #[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 { 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 = 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, 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::>() }; 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); } /// #5645: superscript and subscript runs are drawn at 58.3% of their size, raised or lowered by /// 33.3% of it, as Photoshop draws them; they used to be drawn as plain text. #[test] fn superscript_and_subscript_shrink_and_shift_the_run() { use photocraft_doc::text::FontBaseline; let mut e = TextEngine::new(); let base = CharStyle { size_pt: 60.0, ..Default::default() }; let rect = |e: &mut TextEngine, baseline: FontBaseline| { let (_, r) = e.render(&TextLayer { transform: Affine::translate(5.0, 100.0), ..styled("H", CharStyle { baseline, ..base.clone() }) }, 72.0, PixelFormat::RGBA8); r.rect }; let plain = rect(&mut e, FontBaseline::Normal); let sup = rect(&mut e, FontBaseline::Superscript); let sub = rect(&mut e, FontBaseline::Subscript); // Scripts render at 58.3% of the size. The pixel-snapped bounds of the antialiased glyph land // within 2 px of the exact scaled height (measured: +2.2 / +1.2 px at 46 px plain height). let expect = plain.height() as f32 * 0.583; for r in [&sup, &sub] { assert!((r.height() as f32 - expect).abs() <= 2.5, "plain {plain:?}, scripted {r:?}"); } // The cap's foot sits on the shifted baseline: 20 px (33.3% of 60) above or below. assert!((plain.y1 - sup.y1 - 20).abs() <= 2, "plain {plain:?}, superscript {sup:?}"); assert!((sub.y1 - plain.y1 - 20).abs() <= 2, "plain {plain:?}, subscript {sub:?}"); } /// #4130: Faux bold at 72 dpi looked pixelated because the offset copies were summed into one /// coverage buffer, saturating every antialiased edge pixel. Edges must stay antialiased and the /// ink must grow only by the dilation (about perimeter x radius), not by edge saturation. #[test] fn faux_bold_keeps_antialiased_edges_at_small_sizes() { let mut e = TextEngine::new(); let base = CharStyle { size_pt: 12.0, ..Default::default() }; let stats = |e: &mut TextEngine, s: CharStyle| { let (_, r) = e.render(&TextLayer { transform: Affine::translate(5.0, 20.0), ..styled("Bold Test", s) }, 72.0, PixelFormat::RGBA8); let (w, h) = (r.rect.width() as usize, r.rect.height() as usize); let mut partial = 0usize; for y in 0..h { for x in 0..w { let a = alpha_sum( &r.surface, photocraft_geom::Rect::new(r.rect.x0 + x as i32, r.rect.y0 + y as i32, r.rect.x0 + x as i32 + 1, r.rect.y0 + y as i32 + 1), ); if a > 0.05 && a < 0.95 { partial += 1; } } } (alpha_sum(&r.surface, r.rect), partial) }; let (plain_ink, plain_partial) = stats(&mut e, base.clone()); let (bold_ink, bold_partial) = stats(&mut e, CharStyle { faux_bold: true, ..base }); assert!(bold_ink > plain_ink, "bold must add ink"); // Weight: the old summed-coverage faux bold (before #4130) measured 282.7 total ink for this // text at 12 px (plain 138.0). The antialiased version must stay close to that weight. const MAIN_BOLD_INK: f64 = 282.7; assert!(bold_ink >= MAIN_BOLD_INK * 0.85, "stems too thin: {bold_ink} vs old {MAIN_BOLD_INK}"); assert!(bold_ink <= MAIN_BOLD_INK * 1.15, "stems too heavy: {bold_ink} vs old {MAIN_BOLD_INK}"); assert!(bold_partial * 10 >= plain_partial * 8, "edges stay antialiased: {plain_partial} -> {bold_partial}"); } /// #3705: Faux bold dilation must scale with horizontal and vertical glyph scaling. #[test] fn faux_bold_scales_with_glyph_scaling() { let mut e = TextEngine::new(); let base = CharStyle { size_pt: 40.0, ..Default::default() }; let ink = |e: &mut TextEngine, s: CharStyle| { let (_, r) = e.render(&TextLayer { transform: Affine::translate(5.0, 50.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, brect) = ink(&mut e, CharStyle { faux_bold: true, ..base.clone() }); let unscaled_extra_w = brect.width() - prect.width(); let unscaled_extra_h = brect.height() - prect.height(); // With 2x horizontal scale, the horizontal dilation scales with the glyph: let hscaled = CharStyle { size_pt: 40.0, horizontal_scale: 2.0, ..Default::default() }; let (_, phrect) = ink(&mut e, hscaled.clone()); let (_, bhrect) = ink(&mut e, CharStyle { faux_bold: true, ..hscaled }); let hscaled_extra_w = bhrect.width() - phrect.width(); assert!( hscaled_extra_w >= unscaled_extra_w * 2 - 1 && hscaled_extra_w <= unscaled_extra_w * 2 + 1, "hscaled extra width {hscaled_extra_w} vs unscaled {unscaled_extra_w}" ); // With 2x vertical scale, the vertical dilation scales with the glyph: let vscaled = CharStyle { size_pt: 40.0, vertical_scale: 2.0, ..Default::default() }; let (_, pvrect) = ink(&mut e, vscaled.clone()); let (_, bvrect) = ink(&mut e, CharStyle { faux_bold: true, ..vscaled }); let vscaled_extra_h = bvrect.height() - pvrect.height(); assert!( vscaled_extra_h >= unscaled_extra_h * 2 - 1 && vscaled_extra_h <= unscaled_extra_h * 2 + 1, "vscaled extra height {vscaled_extra_h} vs unscaled {unscaled_extra_h}" ); } #[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 wrapped_and_forced_line_starts_use_the_following_line() { use crate::layout::{byte_index, line_edge, line_index}; let mut engine = TextEngine::new(); let mut wrapped = point("ab cd", 12.0); wrapped.shape = TextShape::Box { x: 0.0, y: 0.0, width: 20.0, height: 70.0 }; let layout = engine.layout(&wrapped, 72.0); assert!(layout.lines.len() >= 2, "paragraph must wrap: {:?}", layout.lines); let boundary = layout.lines[1].range.start; assert_eq!(layout.lines[0].range.end, boundary); let index = wrapped.text[..boundary].chars().count(); assert_eq!(line_index(&layout, byte_index(&wrapped.text, index)), 1); assert_eq!(line_edge(&layout, &wrapped.text, index, false), index); assert_eq!(line_edge(&layout, &wrapped.text, index, true), wrapped.text[..layout.lines[1].range.end].chars().count()); // The previous line's right edge shares the caret index, but a click there // must still report the previous visual line. let first = &layout.lines[0]; let (x, y) = layout.to_text(first.x1, first.baseline); assert_eq!(crate::layout::hit_char(&layout, &wrapped.text, x, y).1, 0); let text = "a\u{3}b"; let forced = engine.layout(&point(text, 12.0), 72.0); assert_eq!(forced.lines.len(), 2); let start = forced.lines[1].range.start; let index = text[..start].chars().count(); assert_eq!(line_index(&forced, start), 1); assert_eq!(line_edge(&forced, text, index, false), index); assert_eq!(line_edge(&forced, text, index, true), text.chars().count()); // A normal paragraph break has distinct boundaries and retains its line. let text = "a\nb"; let paragraph = engine.layout(&point(text, 12.0), 72.0); assert_eq!(line_index(¶graph, paragraph.lines[0].range.end), 0); assert_eq!(line_index(¶graph, paragraph.lines[1].range.start), 1); assert_eq!(line_index(¶graph, text.len()), 1); } #[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 hyphenated_postscript_names_preserve_family_prefixes() { for (ps, family, weight, italic) in [("FOT-NewRodinPro-Regular", "FOT-New Rodin Pro", 400, false), ("Vendor-FamilyPro-BoldIt", "Vendor-Family Pro", 700, true)] { let guessed = fonts::guess_from_postscript(ps); assert_eq!((guessed.family.as_str(), guessed.weight, guessed.italic), (family, weight, italic)); } } #[test] fn hyphenated_postscript_style_suffix_keeps_weight_and_slant() { for (ps, family, weight, italic) in [ ("MyriadPro-Bold-It", "Myriad Pro", 700, true), ("FOT-NewRodinPro-Bold-It", "FOT-New Rodin Pro", 700, true), ("Vendor-Bold-Regular", "Vendor-Bold", 400, false), ] { let guessed = fonts::guess_from_postscript(ps); assert_eq!((guessed.family.as_str(), guessed.weight, guessed.italic), (family, weight, italic)); } } #[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}"); } /// #8797: a style that carries the PostScript name of a variable font's named condensed light /// instance lays out as wide as that instance's axis values, not at the default instance. Uses /// Bahnschrift (Windows) or Ubuntu Sans (Ubuntu) when installed, and skips otherwise; the /// synthesized-font tests in `font_names_tests` cover the instance lookup and its layout width /// everywhere. #[test] fn postscript_name_of_a_variable_instance_lays_out_at_that_instance() { use photocraft_doc::text::FontVariation; let candidates = [ ("C:\\Windows\\Fonts\\bahnschrift.ttf", "Bahnschrift-LightCondensed"), ("/usr/share/fonts/truetype/ubuntu/UbuntuSans[wdth,wght].ttf", "UbuntuSans-CondensedLight"), ]; let Some((bytes, ps)) = candidates.iter().find_map(|(p, ps)| std::fs::read(p).ok().map(|b| (b, *ps))) else { return; }; let mut e = TextEngine::new(); let Some(fam) = e.fonts.register_font_data(bytes).first().cloned() else { return; }; let width = |e: &mut TextEngine, style: CharStyle| { let l = e.layout(&styled("Hamburgefonstiv 0123", style), 96.0); l.lines[0].x1 - l.lines[0].x0 }; let base = CharStyle { font_family: fam.clone(), size_pt: 72.0, weight: 300, ..Default::default() }; let axes = vec![FontVariation { axis: "wght".into(), value: 300.0 }, FontVariation { axis: "wdth".into(), value: 75.0 }]; let family = width(&mut e, base.clone()); let instance = width(&mut e, CharStyle { postscript_name: Some(ps.into()), ..base.clone() }); let explicit = width(&mut e, CharStyle { variations: axes, ..base }); assert!(explicit < family * 0.9, "the condensed light axes are narrower: {explicit} vs {family}"); assert!((instance - explicit).abs() < 0.5, "{ps}: {instance} px, its axes: {explicit} px, default instance: {family} px"); } #[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| { 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 { txt2_with_styles(text, &modes.iter().map(|(len, m)| (*len, format!("/11 {m}"))).collect::>()) } /// [`txt2_with_modes`] with each style run's keys given as EngineData text (`"/11 1 /85 2"`). fn txt2_with_styles(text: &str, styles: &[(usize, String)]) -> Vec { 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, keys) in styles { v.extend_from_slice(format!("<< /0 << /0 << /0 (\u{fe}\u{ff}) /6 << /0 0 {keys} >> >> >> /1 {len} >> ").as_bytes()); } v.extend_from_slice(b"] >> >> >> ] >>"); v } fn with_text_index(tysh: &[u8], index: i32) -> Vec { 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::>() ); } // 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)); } /// #3617: Photoshop keeps Stylistic Sets, Contextual Alternates, Swash and the other OpenType /// settings besides (discretionary) ligatures only in `Txt2`. Import reads them, so the layout /// our engine draws when the type is edited keeps Photoshop's glyphs instead of falling back to /// the default ones. #[test] fn txt2_carries_opentype_features() { let feats = |l: &TextLayer| l.char_runs().iter().map(|r| r.style.features.iter().map(|f| (f.tag.clone(), f.value)).collect::>()).collect::>(); let f = |tag: &str, value: u16| (tag.to_string(), value); // "Il->36" plus the engine's closing break: Stylistic Set 2 (mask 2), Contextual Alternates // and Swash on "Il"; Contextual Alternates off (Photoshop's default), Stylistic Set 1 and // Fractions on the rest. let text = "Il->36"; let plain = styled(text, CharStyle { font_family: "Inter".into(), ..Default::default() }); let tysh = with_text_index(&crate::psd::build_tysh(&plain, 72.0, None), 0); let styles = [(2, "/11 1 /18 true /19 false /20 true /25 true /85 2".to_string()), (5, "/11 1 /20 false /23 true /85 1".to_string())]; let txt2 = crate::psd::parse_txt2(&txt2_with_styles(text, &styles)).unwrap(); let mut back = crate::psd::text_layer_from_tysh(&tysh, 72.0).unwrap(); assert_eq!(feats(&back), vec![Vec::<(String, u16)>::new()], "EngineData alone doesn't have them"); crate::psd::apply_txt2(&mut back, &tysh, &txt2); assert_eq!(feats(&back), vec![vec![f("calt", 1), f("swsh", 1), f("ss02", 1)], vec![f("calt", 0), f("frac", 1), f("ss01", 1)]]); assert_eq!(back.char_runs().iter().map(|r| r.len).collect::>(), vec![2, 4]); // What editing shows (our layout) has Photoshop's glyphs: Inter's `ss02` draws another I and // l, `ss01` another 3 and 6, and `->` stays two glyphs without `calt` (with it, an arrow). let mut e = TextEngine::new(); let ids = |e: &mut TextEngine, t: &TextLayer| e.layout(t, 72.0).glyphs.iter().map(|g| g.id).collect::>(); let with = |len: usize, tags: &[(&str, u16)]| TextRun { len, style: CharStyle { features: tags.iter().map(|(tag, value)| FontFeature { tag: (*tag).into(), value: *value }).collect(), ..back.char_runs()[0].style.clone() }, }; let mut expected = back.clone(); expected.runs = vec![with(2, &[("ss02", 1)]), with(4, &[("calt", 0), ("ss01", 1)])]; let shown = ids(&mut e, &back); assert_eq!(shown, ids(&mut e, &expected)); let defaults = ids(&mut e, &crate::psd::text_layer_from_tysh(&tysh, 72.0).unwrap()); assert_eq!((shown.len(), defaults.len()), (6, 5), "`->` is two glyphs, as in Photoshop, not an arrow"); assert!(shown.first() != defaults.first() && shown.last() != defaults.last(), "{shown:?} vs {defaults:?}"); // A feature the style already sets (from EngineData, read first) keeps its value; masks and // flags of the wrong type, masks beyond the 20 sets and unknown keys change nothing. let mut t = crate::psd::text_layer_from_tysh(&tysh, 72.0).unwrap(); t.runs = vec![TextRun { len: text.len(), style: CharStyle { features: vec![FontFeature { tag: "ss01".into(), value: 0 }], ..Default::default() } }]; let odd = [(1, "/85 1".to_string()), (1, "/85 (x) /20 (x) /25 (true)".to_string()), (1, "/85 -1".to_string()), (4, "/85 99999999999 /999 true".to_string())]; crate::psd::apply_txt2(&mut t, &tysh, &crate::psd::parse_txt2(&txt2_with_styles(text, &odd)).unwrap()); assert_eq!(feats(&t), vec![vec![f("ss01", 0)]]); } /// 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::>() ); 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)]); } /// #3514 / #10308: the document `Txt2` is needed for Optical kerning and for OpenType settings /// EngineData can't carry (stylistic sets, swashes, …). A fresh document of ordinary Metrics text /// must not write Photoshop's empty stub, which some readers (Photopea) reject. #[test] fn needs_txt2_for_optical_and_txt2_only_opentype() { use photocraft_doc::text::{FontFeature, Kerning}; let style = |kerning: Kerning| CharStyle { kerning, ..Default::default() }; let metrics = runs_of("AV", &[(2, style(Kerning::Metrics))]); assert!(!crate::psd::needs_txt2(&[(0, &metrics)])); let off = runs_of("AV", &[(2, style(Kerning::Off))]); assert!(!crate::psd::needs_txt2(&[(0, &off)]), "Off lives in EngineData, not Txt2"); let optical = runs_of("AV", &[(1, style(Kerning::Optical)), (1, CharStyle::default())]); assert!(crate::psd::needs_txt2(&[(0, &optical)])); assert!(!crate::psd::needs_txt2(&[])); let with_ss = |tag: &str, value: u16| { let mut s = style(Kerning::Metrics); s.features = vec![FontFeature { tag: tag.into(), value }]; runs_of("AV", &[(2, s)]) }; assert!(crate::psd::needs_txt2(&[(0, &with_ss("ss01", 1))]), "stylistic set 1 lives only in Txt2"); assert!(crate::psd::needs_txt2(&[(0, &with_ss("ss05", 1))]), "stylistic set 5 lives only in Txt2"); assert!(!crate::psd::needs_txt2(&[(0, &with_ss("ss01", 0))]), "an off set is the default"); assert!(crate::psd::needs_txt2(&[(0, &with_ss("swsh", 1))]), "swash lives only in Txt2"); assert!(crate::psd::needs_txt2(&[(0, &with_ss("calt", 0))]), "calt off is Txt2-only"); assert!(!crate::psd::needs_txt2(&[(0, &with_ss("calt", 1))]), "calt on is the default"); } /// #10308: the fresh objects, minimal style runs, seeded font list and frame list are only for a /// PhotoCraft-native block. A file's own block — here the older Photoshop's, with an empty /// numbered `/0` or none at all — gets no `/0` key, frame list or font list it didn't have, and /// a layer without a template object gets the stub it always got. #[test] fn photoshop_blocks_get_no_native_txt2_scaffolding() { use crate::engine_data::Value as E; use photocraft_doc::text::FontFeature; let style = CharStyle { font_family: "Inter".into(), features: vec![FontFeature { tag: "ss01".into(), value: 1 }], ..Default::default() }; let t = styled("Hi", style); let with_doc = photoshop_era_txt2(&["Line 1"]); let without_doc = crate::engine_data::write_bare(&[ ("98".into(), E::Dict(vec![("0".into(), E::Int(14))])), ("DocumentObjects".into(), photoshop_era_objects(&["Line 1"])), ]); for (label, prev, doc) in [("empty /0", with_doc, Some(E::dict())), ("no /0", without_doc, None)] { let out = crate::psd::parse_txt2(&crate::psd::build_txt2(&[(0, &t)], Some(&prev))).unwrap(); assert_eq!(out.get("0").cloned(), doc, "{label}: the file's /0 is left as it was"); let object = out.path(&["1", "1"]).and_then(E::as_array).and_then(|a| a.first()).expect("the numbered object"); assert!(object.get("1").is_none(), "{label}: no fresh view naming a frame the file has no list for"); assert!(object.path(&["0", "5"]).is_none(), "{label}: no fresh paragraph runs"); } } /// #10308: a fresh native object has one paragraph run per paragraph, each its UTF-16 length. #[test] fn fresh_native_txt2_object_has_a_paragraph_run_per_paragraph() { use crate::engine_data::Value as E; use photocraft_doc::text::FontFeature; let style = CharStyle { features: vec![FontFeature { tag: "ss02".into(), value: 1 }], ..Default::default() }; let t = styled("Hi\nBy\u{e9}e", style); let out = crate::psd::parse_txt2(&crate::psd::build_txt2(&[(0, &t)], None)).unwrap(); let object = out.path(&["1", "1"]).and_then(E::as_array).and_then(|a| a.first()).expect("the object"); assert_eq!(object.path(&["0", "0"]).and_then(E::as_str), Some("Hi\rBy\u{e9}e\r")); let lens: Vec = object.path(&["0", "5", "0"]).and_then(E::as_array).expect("paragraph runs").iter().filter_map(|r| r.get("1").and_then(E::as_i64)).collect(); assert_eq!(lens, vec![3, 5]); assert_eq!(out.path(&["0", "8", "0"]).and_then(E::as_array).map(<[E]>::len), Some(1), "a native block names one frame"); } /// 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"); } /// The `DocumentObjects` value older Photoshop writes beside the numbered objects /// (`/DocumentObjects /TextObjects`, named keys, run lengths under `/Length` in UTF-16 units, /// #6971): one entry per layer, its `\r`-terminated text in `Model /Text`, one paragraph run per /// paragraph, one style and kern run covering the whole text. fn photoshop_era_objects(texts: &[&str]) -> crate::engine_data::Value { use crate::engine_data::Value as E; let run = |len: i64| E::Dict(vec![("RunData".into(), E::Dict(vec![("0".into(), E::String(String::new()))])), ("Length".into(), E::Int(len))]); let entries = texts .iter() .map(|t| { let text = format!("{t}\r"); let paragraphs: Vec = text.split_inclusive('\r').map(|p| p.encode_utf16().count() as i64).collect(); let total = text.encode_utf16().count() as i64; E::Dict(vec![ ( "Model".into(), E::Dict(vec![ ("Text".into(), E::String(text)), ("ParagraphRun".into(), E::Dict(vec![("RunArray".into(), E::Array(paragraphs.iter().map(|&n| run(n)).collect()))])), ("StyleRun".into(), E::Dict(vec![("RunArray".into(), E::Array(vec![run(total)]))])), ("KernRun".into(), E::Dict(vec![("RunArray".into(), E::Array(vec![run(total)]))])), ]), ), ("View".into(), E::Dict(vec![("0".into(), E::Array(vec![E::Dict(vec![("0".into(), E::Int(0))])]))])), ]) }) .collect(); E::Dict(vec![("TextObjects".into(), E::Array(entries))]) } /// A whole `Txt2` block shaped like the older Photoshop's: document resources and the objects at /// `/DocumentObjects /TextObjects`, no numbered `/1 /1` list at all. fn photoshop_era_txt2(texts: &[&str]) -> Vec { use crate::engine_data::Value as E; crate::engine_data::write_bare(&[ ("98".into(), E::Dict(vec![("0".into(), E::Int(14))])), ("0".into(), E::dict()), ("DocumentObjects".into(), photoshop_era_objects(texts)), ]) } /// #6971: Photoshop re-renders a type layer from the objects older Photoshop keeps at /// `/DocumentObjects /TextObjects` when the file has them, so an edit that left the old text /// there showed the old text back on reopen. The save refits that copy the way it refits the /// numbered ones — the new text, one paragraph run per paragraph of it, the other runs to its /// total — and keeps the entries' view and the rest. #[test] fn edited_text_updates_the_photoshop_era_objects() { use crate::engine_data::Value as E; use photocraft_doc::text::Kerning; let prev = photoshop_era_txt2(&["Line 1\rLine 2"]); let before = crate::psd::parse_txt2(&prev).unwrap(); let t = styled("Hi\nBye", CharStyle { kerning: Kerning::Optical, ..Default::default() }); let out = crate::psd::parse_txt2(&crate::psd::build_txt2(&[(0, &t)], Some(&prev))).unwrap(); let entries = out.path(&["DocumentObjects", "TextObjects"]).and_then(E::as_array).expect("the objects"); assert_eq!(entries.len(), 1, "no entry is added"); let entry = entries.first().expect("the entry"); let model = entry.get("Model").expect("the model"); assert_eq!(model.get("Text").and_then(E::as_str), Some("Hi\rBye\r"), "the stale text is replaced"); let lens = |key: &str| -> Vec { model .path(&[key, "RunArray"]) .and_then(E::as_array) .map(|rs| rs.iter().map(|r| r.get("Length").and_then(E::as_i64).unwrap_or(-1)).collect()) .unwrap_or_default() }; assert_eq!(lens("ParagraphRun"), vec![3, 4], "one paragraph run per paragraph of the new text"); assert_eq!(lens("StyleRun"), vec![7], "the style runs cover the new text's UTF-16 units"); assert_eq!(lens("KernRun"), vec![7]); assert_eq!( entry.get("View"), before.path(&["DocumentObjects", "TextObjects"]).and_then(E::as_array).and_then(|e| e.first()).and_then(|e| e.get("View")), "the view is kept" ); // The numbered list is written alongside, as it was before this fix. let slots = out.path(&["1", "1"]).and_then(E::as_array).expect("the numbered objects"); assert_eq!(slots.len(), 1); assert_eq!(slots.first().and_then(|o| o.path(&["0", "0"])).and_then(E::as_str), Some("Hi\rBye\r")); } /// Only the entries of layers the save holds change: another layer's object — one no layer names /// — is written back as the file has it. #[test] fn only_the_edited_photoshop_era_object_changes() { use crate::engine_data::Value as E; use photocraft_doc::text::Kerning; let prev = photoshop_era_txt2(&["One", "Two"]); let before = crate::psd::parse_txt2(&prev).unwrap(); let style = |kerning: Kerning| CharStyle { kerning, ..Default::default() }; let t0 = runs_of("One", &[(3, style(Kerning::Metrics))]); let t1 = runs_of("Changed", &[(7, style(Kerning::Optical))]); let out = crate::psd::parse_txt2(&crate::psd::build_txt2(&[(0, &t0), (1, &t1)], Some(&prev))).unwrap(); let entries = out.path(&["DocumentObjects", "TextObjects"]).and_then(E::as_array).expect("the objects"); let was = before.path(&["DocumentObjects", "TextObjects"]).and_then(E::as_array).expect("the objects before"); assert_eq!(entries.len(), 2, "no entry is added"); assert_eq!(entries.first(), was.first(), "the unchanged entry is written back as it was"); assert_eq!(entries.get(1).and_then(|e| e.path(&["Model", "Text"])).and_then(E::as_str), Some("Changed\r"), "the edited entry takes the new text"); } /// Files saved by the old code have both lists: the numbered object current, the older copy still /// holding the text of an earlier edit. Re-saving repairs the stale copy even when the numbered /// object's text matches the layer (nothing else about the file changes). #[test] fn legacy_file_with_both_object_lists_repairs_the_stale_one() { use crate::engine_data::Value as E; use photocraft_doc::text::Kerning; let mut prev = photoshop_txt2(&[(4, 1)]); prev.extend_from_slice(&crate::engine_data::write_bare(&[("DocumentObjects".into(), photoshop_era_objects(&["Old stuff"]))])); let before = crate::psd::parse_txt2(&prev).unwrap(); let t = styled("AVA", CharStyle { kerning: Kerning::Metrics, ..Default::default() }); let out = crate::psd::parse_txt2(&crate::psd::build_txt2(&[(0, &t)], Some(&prev))).unwrap(); let entries = out.path(&["DocumentObjects", "TextObjects"]).and_then(E::as_array).expect("the objects"); assert_eq!(entries.len(), 1); let model = entries.first().expect("the entry").get("Model").expect("the model"); assert_eq!(model.get("Text").and_then(E::as_str), Some("AVA\r"), "the stale copy is repaired"); let lens = model .path(&["StyleRun", "RunArray"]) .and_then(E::as_array) .map(|rs| rs.iter().map(|r| r.get("Length").and_then(E::as_i64).unwrap_or(-1)).collect::>()); assert_eq!(lens, Some(vec![4]), "its runs cover the layer's text"); let slots = out.path(&["1", "1"]).and_then(E::as_array).expect("the numbered objects"); assert_eq!(slots.first().and_then(|o| o.path(&["0", "0"])).and_then(E::as_str), Some("AVA\r"), "the numbered object is kept as it was"); assert_eq!(before.path(&["1", "1"]).and_then(E::as_array).and_then(|s| s.first()), slots.first(), "the numbered object changes not at all"); } /// When both lists hold the layer's text, the block is written back byte for byte — #2374 holds /// for the older objects too: re-encoding them would only risk Photoshop refusing the file. #[test] fn unchanged_block_with_both_object_lists_is_byte_identical() { use photocraft_doc::text::Kerning; let mut prev = photoshop_txt2(&[(4, 1)]); prev.extend_from_slice(&crate::engine_data::write_bare(&[("DocumentObjects".into(), photoshop_era_objects(&["AVA"]))])); let t = styled("AVA", CharStyle { kerning: Kerning::Metrics, ..Default::default() }); assert_eq!(crate::psd::build_txt2(&[(0, &t)], Some(&prev)), prev); } /// An entry shaped unlike Photoshop writes it — a run without its `/Length`, a `RunArray` that /// isn't an array, an empty one, an object with no text to replace — is left exactly as the file /// has it: a half-refitted object (new text over old runs) is what made Photoshop quit (#6968), /// so the save never writes one. #[test] fn odd_photoshop_era_entries_are_left_as_the_file_has_them() { use crate::engine_data::Value as E; use photocraft_doc::text::Kerning; let run = |len: i64| E::Dict(vec![("RunData".into(), E::dict()), ("Length".into(), E::Int(len))]); let entry = |model: Vec<(&str, E)>| { E::Dict(vec![("Model".into(), E::Dict(model.into_iter().map(|(k, v)| (k.to_string(), v)).collect())), ("View".into(), E::dict())]) }; let with_text = |text: E, style: E| { entry(vec![ ("Text", text), ("ParagraphRun", E::Dict(vec![("RunArray".into(), E::Array(vec![run(4)]))])), ("StyleRun", E::Dict(vec![("RunArray".into(), style)])), ]) }; let odd = [ with_text(E::String("Old\r".into()), E::Array(vec![E::Dict(vec![("RunData".into(), E::dict())])])), with_text(E::String("Old\r".into()), E::Int(3)), with_text(E::String("Old\r".into()), E::Array(vec![])), E::Int(3), entry(vec![("ParagraphRun", E::Dict(vec![("RunArray".into(), E::Array(vec![run(4)]))]))]), with_text(E::Int(9), E::Array(vec![run(4)])), ]; for (i, object) in odd.iter().enumerate() { let prev = crate::engine_data::write_bare(&[ ("98".into(), E::Dict(vec![("0".into(), E::Int(14))])), ("0".into(), E::dict()), ("DocumentObjects".into(), E::Dict(vec![("TextObjects".into(), E::Array(vec![object.clone()]))])), ]); let was = crate::psd::parse_txt2(&prev).unwrap(); let t = styled("New", CharStyle { kerning: Kerning::Optical, ..Default::default() }); let out = crate::psd::parse_txt2(&crate::psd::build_txt2(&[(0, &t)], Some(&prev))).unwrap(); let entries = out.path(&["DocumentObjects", "TextObjects"]).and_then(E::as_array).expect("the objects"); assert_eq!( entries.first(), was.path(&["DocumentObjects", "TextObjects"]).and_then(E::as_array).and_then(|e| e.first()), "entry {i} is left as the file has it" ); } } /// A `TextIndex` past the entries the block holds has no stale copy to fix: nothing is grown to /// the layer's number (the slot list grows as it always did), and the entries stay the file's. #[test] fn a_text_index_beyond_the_photoshop_era_entries_grows_nothing() { use crate::engine_data::Value as E; use photocraft_doc::text::Kerning; let prev = photoshop_era_txt2(&["Only one"]); let was = crate::psd::parse_txt2(&prev).unwrap(); let t = styled("Whatever", CharStyle { kerning: Kerning::Optical, ..Default::default() }); let out = crate::psd::parse_txt2(&crate::psd::build_txt2(&[(crate::psd::MAX_TEXT_INDEX, &t)], Some(&prev))).unwrap(); let entries = out.path(&["DocumentObjects", "TextObjects"]).and_then(E::as_array).expect("the objects"); assert_eq!(entries.len(), 1, "no entry is grown to the layer's number"); assert_eq!(entries.first(), was.path(&["DocumentObjects", "TextObjects"]).and_then(E::as_array).and_then(|e| e.first())); } /// 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 { 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)]); } /// #6968: editing the text of a Photoshop type layer keeps its `Txt2` object — paragraph runs, /// the view naming its frame, the model's other run arrays and its style keys — refitted to the /// new text. It was replaced by a bare `<< /0 << /0 text /6 runs >> >>` stub, and Photoshop quit /// while opening the file. #[test] fn edited_text_keeps_the_files_txt2_object() { use crate::engine_data::Value as E; let run = |extra: &str, len: usize| format!("<< /0 << /0 << /0 (\u{fe}\u{ff}) /5 << /7 1.2 >> /6 << /0 0 /1 12.0 {extra} >> >> >> /1 {len} >> "); // "AB\rCD\r": two paragraphs, two style runs, a kern run, a view and pen positions. let prev = format!( "\n\n/98 << /0 7 >> /0 << /8 << /0 [ << /0 << /2 << /2 [ 1.0 0.0 0.0 1.0 5.0 6.0 ] >> >> >> ] >> >> /1 << /0 << /0 1 >> /1 [ << /0 << /0 (\u{fe}\u{ff}\0A\0B\0\r\0C\0D\0\r) /5 << /0 [ {}{}] >> /6 << /0 [ {}{}] >> /7 << /0 [ << /0 << >> /1 6 >> ] >> >> /1 << /0 [ << /0 0 >> ] /2 [ << /99 /PC >> ] >> /21 << /1 [ 1.0 ] >> >> ] /2 << /0 7 >> >>", run("", 3), run("", 3), run("/11 1", 2), run("/11 1 /53 (red)", 4) ); // Every character above is below U+0100: one raw byte each (the BOM `FE FF` included). let prev: Vec = prev.chars().map(|c| c as u32 as u8).collect(); let before = crate::psd::parse_txt2(&prev).unwrap(); let before_object = before.path(&["1", "1"]).and_then(E::as_array).unwrap()[0].clone(); assert_eq!(before_object.path(&["0", "0"]).and_then(E::as_str), Some("AB\rCD\r")); let lens = |o: &E, key: &str| -> Vec { o.path(&["0", key, "0"]).and_then(E::as_array).unwrap().iter().map(|r| r.get("1").and_then(E::as_i64).unwrap()).collect() }; for (text, paragraphs, styles) in [("Bevex\nCD", vec![6, 3], vec![9]), ("AB\nCD\nEFG", vec![3, 3, 4], vec![10]), ("X", vec![2], vec![2]), ("AB\nCZ", vec![3, 3], vec![6])] { let t = runs_of(text, &[(text.len(), CharStyle::default())]); let out = crate::psd::parse_txt2(&crate::psd::build_txt2(&[(0, &t)], Some(&prev))).unwrap(); for key in [&["0"][..], &["98"], &["1", "0"], &["1", "2"]] { assert_eq!(out.path(key), before.path(key), "{text:?}: /{} survives", key.join(" /")); } let object = &out.path(&["1", "1"]).and_then(E::as_array).unwrap()[0]; let engine = format!("{}\r", text.replace('\n', "\r")); let total = engine.encode_utf16().count() as i64; assert_eq!(object.path(&["0", "0"]).and_then(E::as_str), Some(engine.as_str())); assert_eq!(object.get("1"), before_object.get("1"), "{text:?}: the view (its frame) is kept"); assert!(object.get("21").is_none(), "{text:?}: pen positions of the old text are dropped"); assert_eq!(lens(object, "5"), paragraphs, "{text:?}: one paragraph run per paragraph"); // Photoshop reads the character style from these runs: they follow the layer's own runs // (one here), built on the file's first run. assert_eq!(lens(object, "6"), styles, "{text:?}: style runs cover the new text"); assert_eq!(lens(object, "7"), vec![total], "{text:?}: other run arrays cover the new text"); for r in object.path(&["0", "5", "0"]).and_then(E::as_array).unwrap() { assert_eq!(r.path(&["0", "0", "5", "7"]).and_then(E::as_f64), Some(1.2), "{text:?}: paragraph style survives"); } for r in object.path(&["0", "6", "0"]).and_then(E::as_array).unwrap() { assert_eq!(r.path(&["0", "0", "6", "1"]).and_then(E::as_f64), Some(f64::from(CharStyle::default().size_pt)), "{text:?}: the layer's font size"); assert_eq!(r.path(&["0", "0", "5", "7"]).and_then(E::as_f64), Some(1.2), "{text:?}: the file's run keeps its other keys"); } } } /// #8415: a style-only edit (`type.setStyle` with no text change) reaches the file's `Txt2`. /// Photoshop reads the character style from the object's style runs, and an object whose text /// still matched kept the file's runs, so Photoshop reopened the layer in its old font with faux /// bold still on. A layer whose style agrees with the runs still gets the block back byte for /// byte (#2374); one whose font or faux bold changed is restyled like an edited object, and the /// glyph pen positions laid out in the old style are dropped. #[test] fn style_only_edit_restyles_the_kept_txt2_object() { use crate::engine_data::Value as E; // Strings in the block are UTF-16 with a BOM; every character here is below U+0100. let utf16 = |s: &str| format!("(\u{fe}\u{ff}{})", s.chars().map(|c| format!("\0{c}")).collect::()); let font = |name: &str| format!("<< /0 << /99 /CoolTypeFont /0 << /0 {} /2 1 >> >> >> ", utf16(name)); // One object as Photoshop writes it: paragraph runs, style runs, a view and pen positions. let prev = format!( "\n\n/98 << /0 14 >> /0 << /1 << /0 [ {}{}] >> >> /1 << /1 [ << /0 << /0 {} /5 << /0 [ << /0 << /0 << /0 (\u{fe}\u{ff}) /5 << /7 1.2 >> >> >> /1 3 >> ] >> /6 << /0 [ << /0 << /0 << /0 (\u{fe}\u{ff}) /6 << /0 0 /1 12.0 /2 true /3 false /11 1 >> >> >> /1 3 >> ] >> >> /1 << /0 [ << /0 0 >> ] >> /21 << /1 [ 1.25 2.5 ] >> >> ] >>", font("Arial-Black"), font("ArialMT"), utf16("AV\r"), ); let prev: Vec = prev.chars().map(|c| c as u32 as u8).collect(); let style = CharStyle { postscript_name: Some("Arial-Black".into()), size_pt: 12.0, faux_bold: true, ..Default::default() }; let t = styled("AV", style.clone()); assert_eq!(crate::psd::build_txt2(&[(0, &t)], Some(&prev)), prev, "an unedited style keeps the file's block"); let font_name = |out: &E, run: &E| { let i = run.path(&["0", "0", "6", "0"]).and_then(E::as_i64).and_then(|i| usize::try_from(i).ok()).expect("a font index"); out.path(&["0", "1", "0"]).and_then(E::as_array).and_then(|f| f.get(i)).and_then(|f| f.path(&["0", "0", "0"])).and_then(E::as_str).map(str::to_owned) }; for (what, edited, want_font) in [ ("faux bold off", CharStyle { faux_bold: false, ..style.clone() }, "Arial-Black"), ( "font and faux bold", CharStyle { postscript_name: Some("AppleSDGothicNeo-Heavy".into()), faux_bold: false, ..style.clone() }, "AppleSDGothicNeo-Heavy", ), ] { let t = styled("AV", edited); let out = crate::psd::parse_txt2(&crate::psd::build_txt2(&[(0, &t)], Some(&prev))).unwrap(); let object = &out.path(&["1", "1"]).and_then(E::as_array).unwrap()[0]; assert_eq!(object.path(&["0", "0"]).and_then(E::as_str), Some("AV\r"), "{what}: the text is unchanged"); let runs = object.path(&["0", "6", "0"]).and_then(E::as_array).unwrap(); assert!(!runs.is_empty(), "{what}: the object has style runs"); for run in runs { assert_eq!(run.path(&["0", "0", "6", "2"]).and_then(E::as_bool), Some(false), "{what}: faux bold is off in Txt2"); assert_eq!(font_name(&out, run).as_deref(), Some(want_font), "{what}: Txt2 names the layer's font"); } assert!(object.get("21").is_none(), "{what}: pen positions of the old style are dropped"); } } /// A type layer the file's `Txt2` has no object for (one added before the save, #7698) gets an /// object shaped like the file's own, with a view naming its own frame and a frame in `/0 /8 /0`, /// instead of a bare stub: Photoshop and Photopea refused the file when the frames ran out. #[test] fn new_text_layer_gets_a_txt2_object_and_frame() { use crate::engine_data::Value as E; let run = |len: usize| format!("<< /0 << /0 << /0 (\u{fe}\u{ff}) /5 << /7 1.2 >> /6 << /0 0 /1 12.0 >> >> >> /1 {len} >> "); let prev = format!( "\n\n/98 << /0 7 >> /0 << /8 << /0 [ << /0 << /2 << /2 [ 1.0 0.0 0.0 1.0 5.0 6.0 ] >> >> >> ] >> >> /1 << /0 << /0 1 >> /1 [ << /0 << /0 (\u{fe}\u{ff}\0A\0B\0\r\0C\0D\0\r) /5 << /0 [ {}{}] >> /6 << /0 [ {}] >> >> /1 << /0 [ << /0 0 >> ] /1 << /0 [ << /0 << /1 1 >> /1 3 >> << /0 << /1 1 >> /1 3 >> ] >> /2 [ << /99 /PC /6 [ << /99 /F /10 0 >> ] >> ] >> /21 << /1 [ 1.0 ] >> >> ] /2 << /0 7 >> >>", run(3), run(3), run(6) ); // The same block after an earlier save wrote a bare stub for "v1.0" (the file in #7698). let stub = "<< /0 << /0 (\u{fe}\u{ff}\0v\x001\0.\x000\0\r) /6 << /0 [ << /0 << /0 << /0 () /6 << /0 0 /11 1 >> >> >> /1 5 >> ] >> >> >> "; let broken = prev.replace(" >> ] /2 << /0 7 >>", &format!(" >> {stub}] /2 << /0 7 >>")); let broken: Vec = broken.chars().map(|c| c as u32 as u8).collect(); let prev: Vec = prev.chars().map(|c| c as u32 as u8).collect(); let before = crate::psd::parse_txt2(&prev).unwrap(); let old = runs_of("AB\nCD", &[(5, CharStyle::default())]); let new = runs_of("v1.0", &[(4, CharStyle::default())]); let out = crate::psd::parse_txt2(&crate::psd::build_txt2(&[(0, &old), (1, &new)], Some(&prev))).unwrap(); let objects = out.path(&["1", "1"]).and_then(E::as_array).unwrap(); assert_eq!(objects.len(), 2); let kept = &before.path(&["1", "1"]).and_then(E::as_array).unwrap()[0]; assert_eq!(objects[0].get("1"), kept.get("1"), "the file's object keeps its view"); assert_eq!(objects[0].path(&["0", "5"]), kept.path(&["0", "5"]), "the file's object keeps its paragraphs"); let frames = out.path(&["0", "8", "0"]).and_then(E::as_array).unwrap(); assert_eq!(frames.len(), 2, "one frame per object"); let object = &objects[1]; assert_eq!(object.path(&["0", "0"]).and_then(E::as_str), Some("v1.0\r")); assert_eq!(object.path(&["1", "0"]), Some(&E::Array(vec![E::Dict(vec![("0".into(), E::Int(1))])])), "the view names its own frame"); assert!(object.get("21").is_none()); assert_eq!( object.path(&["1", "2"]).and_then(E::as_array).and_then(|s| s[0].get("6")).and_then(E::as_array).and_then(|f| f[0].get("10")), Some(&E::Int(1)), "the frame strike names its own frame" ); let lens = |path: &[&str]| -> Vec { object.path(path).and_then(E::as_array).unwrap().iter().map(|r| r.get("1").and_then(E::as_i64).unwrap()).collect() }; assert_eq!(lens(&["0", "5", "0"]), vec![5]); assert_eq!(lens(&["0", "6", "0"]), vec![5]); assert_eq!(lens(&["1", "1", "0"]), vec![5], "view runs follow the new paragraphs"); // Re-saving the file with the stub repairs it: the stub is replaced like a missing object. assert_eq!(crate::psd::parse_txt2(&broken).unwrap().path(&["1", "1"]).and_then(E::as_array).unwrap().len(), 2); let repaired = crate::psd::parse_txt2(&crate::psd::build_txt2(&[(0, &old), (1, &new)], Some(&broken))).unwrap(); assert_eq!(repaired, out, "a stub from an earlier save is replaced"); // Also when the stub's layer is edited before the re-save. let edited = runs_of("v2.0", &[(4, CharStyle::default())]); let repaired = crate::psd::parse_txt2(&crate::psd::build_txt2(&[(0, &old), (1, &edited)], Some(&broken))).unwrap(); let object = &repaired.path(&["1", "1"]).and_then(E::as_array).unwrap()[1]; assert_eq!(object.path(&["0", "0"]).and_then(E::as_str), Some("v2.0\r")); assert_eq!(object.path(&["1", "0"]), Some(&E::Array(vec![E::Dict(vec![("0".into(), E::Int(1))])])), "an edited stub is replaced too"); // No object to take the shape from: the fresh object as before, with the frames still padded. let bare = b"\n\n/98 << /0 7 >> /0 << /8 << /0 [ << /0 1 >> ] >> >> /1 << /1 [ << >> ] >> "; let out = crate::psd::parse_txt2(&crate::psd::build_txt2(&[(1, &new)], Some(bare))).unwrap(); assert_eq!(out.path(&["1", "1"]).and_then(E::as_array).unwrap().len(), 2); assert_eq!(out.path(&["0", "8", "0"]).and_then(E::as_array).unwrap().len(), 2); // An oversized object is never the template (it would be copied once per new layer). let filler = "0 ".repeat(5000); let big = prev.iter().map(|&b| b as char).collect::().replace("/2 [ << /99 /PC", &format!("/9 [ {filler}] /2 [ << /99 /PC")); let big: Vec = big.chars().map(|c| c as u32 as u8).collect(); let out = crate::psd::parse_txt2(&crate::psd::build_txt2(&[(0, &old), (1, &new)], Some(&big))).unwrap(); let object = &out.path(&["1", "1"]).and_then(E::as_array).unwrap()[1]; assert!(object.get("1").is_none(), "no copy of an oversized template"); assert_eq!(out.path(&["0", "8", "0"]).and_then(E::as_array).unwrap().len(), 2); } /// The frame padding is bounded like the slots a layer can name: a crafted block of many empty /// objects and no frames must not make the save write a frame for each of them. #[test] fn txt2_frame_padding_is_bounded() { use crate::engine_data::Value as E; let objects = "<< >> ".repeat(70_000); let prev = format!("\n\n/98 << /0 7 >> /0 << /8 << /0 [ ] >> >> /1 << /1 [ {objects}] >> "); let t = runs_of("AV", &[(2, CharStyle::default())]); let out = crate::psd::parse_txt2(&crate::psd::build_txt2(&[(0, &t)], Some(prev.as_bytes()))).unwrap(); let frames = out.path(&["0", "8", "0"]).and_then(E::as_array).unwrap().len(); assert_eq!(frames, crate::psd::MAX_TEXT_INDEX as usize + 1); } /// 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![ 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::>()); 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::>(); 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"]); } } /// EngineData only maps Photoshop's `OldStyle` bool to `onum`. Other Numerals tags (`lnum` / /// `pnum` / `tnum`) stay in `.pcraft` until a Photoshop-authored PSD oracle exists — we do not /// invent EngineData keys or copy figure-style tables from GPL editors (AGENTS.md clean-room). #[test] fn psd_engine_data_keeps_only_oldstyle_among_numeral_tags() { let style = CharStyle { font_family: "Inter".into(), size_pt: 20.0, features: ["lnum", "onum", "pnum", "tnum"].iter().map(|t| FontFeature { tag: (*t).into(), value: 1 }).collect(), ..Default::default() }; let t = styled("0123456789", style); let bytes = crate::psd::build_tysh(&t, 72.0, None); let back = crate::psd::text_layer_from_tysh(&bytes, 72.0).expect("tysh"); let runs = back.char_runs(); let mut got: Vec<&str> = runs[0].style.features.iter().map(|f| f.tag.as_str()).collect(); got.sort_unstable(); assert_eq!(got, ["onum"]); } /// A legacy-signature `TySh` (#1469): zero PointBase, `bounds` (the layout box) and `boundingBox` /// (the ink box, as Photoshop writes it) both around `anchor` in a remote local space, and a /// `transform` that cancels the remote origin. fn legacy_tysh(source: &TextLayer, transform: Affine, anchor: [f64; 2], engine: &mut TextEngine) -> Vec { use photocraft_psd::descriptor::{Descriptor, Id, Value as D}; let layout = engine.layout(source, 72.0); let logical = layout.bounds().unwrap().map(f64::from); let ink = crate::render::ink_bounds(&layout).unwrap(); let rect = |class, r: [f64; 4]| { D::Descriptor( Descriptor::new(class) .with("Left", D::UnitFloat { unit: *b"#Pnt", value: anchor[0] + r[0] }) .with("Top ", D::UnitFloat { unit: *b"#Pnt", value: anchor[1] + r[1] }) .with("Rght", D::UnitFloat { unit: *b"#Pnt", value: anchor[0] + r[2] }) .with("Btom", D::UnitFloat { unit: *b"#Pnt", value: anchor[1] + r[3] }), ) }; let mut tysh = crate::psd::parse_tysh(&crate::psd::build_tysh(source, 72.0, None)).unwrap(); tysh.transform = transform; tysh.text.items.retain(|(key, _)| !key.is("bounds") && !key.is("boundingBox")); tysh.text.items.push((Id::new("bounds"), rect("bounds", logical))); tysh.text.items.push((Id::new("boundingBox"), rect("boundingBox", ink))); crate::psd::write_tysh(&tysh) } fn assert_transform_near(got: Affine, want: Affine, tolerance: f64, what: &str) { assert!(got.m.iter().zip(want.m).all(|(g, w)| (g - w).abs() < tolerance), "{what}: {:?} vs {:?}", got.m, want.m); } /// #1469: Some older PSD writers combine a zero PointBase with an enormous local ink origin. /// The layout's own ink box is lined up with the saved ink box, so point alignment anchors stay /// fixed for left, centered and right text through import and a TySh round trip. #[test] fn legacy_tysh_point_origin_imports_and_round_trips_on_canvas() { use photocraft_psd::descriptor::Value as D; let original = Affine { m: [4.1667, 0.0, 0.0, 4.1667, -32375.0, -32887.5] }; // The file's layout origin (the anchor on the first baseline) in its local space. let anchor = [8050.0, 8160.0]; let expected = original.mul(&Affine::translate(anchor[0], anchor[1])); for align in [TextAlign::Left, TextAlign::Center, TextAlign::Right] { let mut source = styled("Name", CharStyle { font_family: "Inter".into(), size_pt: 120.0, ..Default::default() }); source.paragraphs = vec![ParagraphRun { len: source.text.len(), style: ParagraphStyle { align, ..Default::default() } }]; let mut engine = TextEngine::new(); let data = legacy_tysh(&source, original, anchor, &mut engine); let mut imported = crate::psd::text_layer_from_tysh(&data, 72.0).unwrap(); imported.psd_raw = Some(std::sync::Arc::new(data.clone())); crate::psd::refine_legacy_point_origin(&mut imported, 72.0, &mut engine, None); assert_eq!(imported.paragraph_runs()[0].style.align, align); assert_transform_near(imported.transform, expected, 1e-6, &format!("{align:?}: anchor")); let (_, rendered) = engine.render(&imported, 72.0, PixelFormat::RGBA8); let placed = rendered.surface.content_bounds(); assert!(!placed.is_empty(), "{align:?}: no rendered pixels"); let mut want = source.clone(); want.transform = expected; let (_, expected_pixels) = engine.render(&want, 72.0, PixelFormat::RGBA8); assert_eq!(placed, expected_pixels.surface.content_bounds(), "{align:?}: raster placement"); let native = engine.layout(&imported, 72.0).bounds(); let round_trip = crate::psd::text_layer_from_tysh(&crate::psd::build_tysh(&imported, 72.0, native), 72.0).unwrap(); assert_transform_near(round_trip.transform, expected, 0.01, &format!("{align:?}: round trip")); // A valid frame may equal the old ink-corner estimate. It must still win over refinement. let t = crate::psd::parse_tysh(&data).unwrap(); let Some(D::Descriptor(ink_box)) = t.text.get("boundingBox") else { panic!("boundingBox") }; let corner = |k| match ink_box.get(k) { Some(D::UnitFloat { value, .. }) => *value, _ => panic!("{k}"), }; let framed = with_text_index(&data, 0); let txt2 = crate::psd::parse_txt2(&txt2_with_frame("Name", Some([corner("Left"), corner("Top ")]), [1.0, 0.0, 0.0, 1.0, 0.0, 0.0])).unwrap(); let mut with_frame = crate::psd::text_layer_from_tysh(&framed, 72.0).unwrap(); with_frame.psd_raw = Some(std::sync::Arc::new(framed.clone())); crate::psd::apply_txt2_frame(&mut with_frame, &framed, &txt2); let authoritative = with_frame.transform; crate::psd::refine_legacy_point_origin(&mut with_frame, 72.0, &mut engine, Some(&txt2)); assert_eq!(with_frame.transform, authoritative, "{align:?}: exact frame wins"); } } /// #6094 review: `boundingBox` is the tight ink box (a Photoshop-saved Arial 120 pt "..." has /// `bounds` top -102.98 but `boundingBox` top -11). Text whose ink starts well below the ascent /// line keeps its baseline: lining the layout box up with the ink box put "..." and "_" about /// 116 pt too low. #[test] fn legacy_origin_lines_up_ink_with_the_ink_bounding_box() { let original = Affine { m: [1.0, 0.0, 0.0, 1.0, -7900.0, -7550.0] }; for text in ["Name", "...", "_"] { let source = styled(text, CharStyle { font_family: "Inter".into(), size_pt: 120.0, ..Default::default() }); let mut engine = TextEngine::new(); let data = legacy_tysh(&source, original, [8000.0, 8000.0], &mut engine); let mut imported = crate::psd::text_layer_from_tysh(&data, 72.0).unwrap(); imported.psd_raw = Some(std::sync::Arc::new(data)); crate::psd::refine_legacy_point_origin(&mut imported, 72.0, &mut engine, None); let baseline = imported.transform.apply(photocraft_geom::Point::new(0.0, 0.0)); assert!((baseline.x - 100.0).abs() < 1e-6 && (baseline.y - 450.0).abs() < 1e-6, "{text:?}: {baseline:?}"); } } #[test] fn legacy_origin_uses_the_rendering_engines_registered_face() { let mut engine = TextEngine::new(); let mut style = CharStyle { font_family: "JetBrains Mono".into(), font_style: "Regular".into(), size_pt: 100.0, ..Default::default() }; // This exact-face alias exists only in this engine, like a user-registered font. engine.fonts.select_named_face(&mut style); assert!(style.font_family.starts_with(".PhotoCraft-face-")); let source = with_para(styled("Name", style), ParagraphStyle { align: TextAlign::Right, ..Default::default() }); let raw = legacy_tysh(&source, Affine::translate(-8000.0, -8000.0), [8100.0, 8200.0], &mut engine); let mut imported = crate::psd::text_layer_from_tysh(&raw, 72.0).unwrap(); // Import preparation has resolved the face before refining placement. imported.runs = source.runs.clone(); imported.sync_summary(); imported.psd_raw = Some(std::sync::Arc::new(raw)); crate::psd::refine_legacy_point_origin(&mut imported, 72.0, &mut engine, None); let placed = imported.transform.apply(photocraft_geom::Point::new(0.0, 0.0)); assert!((placed.x - 100.0).abs() < 1e-6 && (placed.y - 200.0).abs() < 1e-6, "{placed:?}"); } /// 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(¢red, 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::>(); 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()); } /// #5959: Korean box text broke between Hangul syllables, mid-word ("설 / 정이나"). Lines break at /// the spaces between words, as Photoshop sets Korean; a word longer than the box still breaks. #[test] fn korean_box_text_wraps_between_words() { let mut engine = TextEngine::new(); let mut text = point("여기서는 각종 설정이나 캐릭터 제작을 진행합니다.", 12.0); text.shape = TextShape::Box { x: 0.0, y: 0.0, width: 60.0, height: 1000.0 }; let layout = engine.layout(&text, 72.0); assert!(layout.lines.len() > 2, "{}", layout.lines.len()); let words: Vec<&str> = text.text.split(' ').collect(); for line in &layout.lines { let content = text.text.get(line.range.clone()).expect("UTF-8 line boundaries").trim(); assert!(content.split(' ').all(|w| words.contains(&w)), "a line holds whole words only: {content:?}"); } assert_eq!(super::layout::hangul_runs("a 한국어 b"), vec![2..11]); } /// 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 first CJK craft family in the UI locale's script order: // the Gothic UI family when Japanese leads that order or the build has no other CJK craft font. let cjk = |f: &crate::CraftFont| f.scripts.iter().any(|s| matches!(*s, "Jpan" | "Hans" | "Hant" | "Kore")); let first = e.fonts.fallback_stack().find_map(|fam| crate::CRAFT_FONTS.iter().find(|f| f.family == fam && cjk(f))).map(|f| f.family); if !crate::CRAFT_FONTS.iter().any(|f| cjk(f) && !f.is_japanese()) || crate::cjk::ui_script_order()[0] == crate::cjk::CjkScript::Japanese { assert_eq!(first, Some(crate::craft_fonts::UI_JAPANESE_FAMILY)); } assert_eq!(craft_family_of(&l), first.into_iter().collect::>()); // Serif runs fall back to a Mincho face when the build has one (not the web build) and the // Japanese craft fonts come first. if first == Some(crate::craft_fonts::UI_JAPANESE_FAMILY) && 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()); } /// Chinese, Arabic and Thai set in Inter fall back to the craft font for their script when the /// build has one (Noto Sans CJK SC, Noto Sans Arabic and Noto Sans Thai in newer craft-fonts). #[test] fn craft_fonts_cover_their_other_scripts_without_system_fonts() { let mut e = TextEngine::new(); for (script, text) in [("Hans", "中文图层"), ("Arab", "مرحبا"), ("Thai", "ภาษาไทย")] { if !crate::CRAFT_FONTS.iter().any(|f| f.scripts.contains(&script)) { eprintln!("skipping {script}: built without a craft font for it (set CRAFT_FONTS_DIR to a craft-fonts checkout)"); continue; } let l = e.layout(&point(text, 24.0), 72.0); assert!(!l.glyphs.is_empty() && l.glyphs.iter().all(|g| g.id != 0), "{script}: no .notdef with craft-fonts"); let fams = craft_family_of(&l); assert!(fams.iter().any(|fam| crate::CRAFT_FONTS.iter().any(|f| f.family == *fam && f.scripts.contains(&script))), "{script}: drawn with {fams:?}"); } } #[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); } #[test] fn line_navigation_reaches_adjacent_line_with_large_leading() { use crate::layout::{byte_index, line_step}; let mut e = TextEngine::new(); let text = "ab\ncd"; let mut t = point(text, 12.0); t.runs = vec![TextRun { len: text.len(), style: CharStyle { font_family: "Inter".into(), size_pt: 12.0, leading_pt: Some(40.0), ..Default::default() } }]; let l = e.layout(&t, 72.0); assert_eq!(l.lines.len(), 2); assert!((l.lines[1].baseline - l.lines[0].baseline - 40.0).abs() < 0.01); let next = line_step(&l, text, 1, l.caret(byte_index(text, 1)).0, 1); assert_eq!(next, 4, "lineNext must reach the second line"); let prev = line_step(&l, text, 4, l.caret(byte_index(text, 4)).0, -1); assert_eq!(prev, 1, "linePrev must reach the first line"); } /// 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"); } } } /// The Southeast Asian scripts with their hard cases: Khmer coeng subscripts, the ah-sat mark and /// numerals; Lao vowel and tone signs; Myanmar medials, stacks and dependent vowels (#218). const SOUTHEAST_ASIAN: &[(&str, &str, &[&str])] = &[ ("Khmer", "ភាសាខ្មែរ កម្ពុជា សួស្ដី រដ្ឋធម្ណុសាស្តៗ ១២៣", fonts::KHMER_FAMILIES), ("Lao", "ພາສາລາວ ສະບາຍດີ ຄ ໂລກ ຂອບໃຈ", fonts::LAO_FAMILIES), ("Myanmar", "မြန်မာဘာသာ မင်္ဂလာပါ ကျေးဇူးတင်ပါတယ်", fonts::MYANMAR_FAMILIES), ]; #[test] fn southeast_asian_scripts_fall_back_to_installed_fonts() { // Detection and selection are tested apart, so a failure names its layer. for (name, sample, families) in SOUTHEAST_ASIAN { let mut e = TextEngine::with_system_fonts(); let Some(hit) = families.iter().find(|f| e.fonts.has_family(f)) else { eprintln!("skipped: no {name}-capable font installed"); continue; }; // Layer 1, detection + loading + shaping: the installed family, asked for by name. let direct = TextLayer { text: (*sample).into(), font_family: hit.to_string(), size_pt: 24.0, ..Default::default() }; let l = e.layout(&direct, 72.0); assert!(!l.glyphs.is_empty() && l.glyphs.iter().all(|g| g.id != 0), "{hit} is installed but shapes .notdef for {name}"); // Layer 2, selection: the same text in a Latin-only font falls back instead of drawing // tofu, and the Latin around it keeps the chosen face. let mixed = format!("Latin {sample}"); let l = e.layout(&point(&mixed, 24.0), 72.0); assert!(l.glyphs.iter().all(|g| g.id != 0), "{name} in a Latin font drawn with .notdef although {hit} is installed"); let latin = l.glyphs.first().map(|g| g.face); assert!(l.glyphs.iter().any(|g| Some(g.face) != latin), "Latin and {name} drawn with the same face"); } } #[test] fn southeast_asian_fallback_candidates_cover_every_platform() { // Logic-level half of #218 (runs without these fonts): each script has a family for Windows, // macOS and Linux 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 [ "Khmer UI", "Khmer Sangam MN", "Noto Sans Khmer", "Lao UI", "Lao Sangam MN", "Noto Sans Lao", "Myanmar Text", "Myanmar Sangam MN", "Noto Sans Myanmar", ] { 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"); } } } /// The Indic scripts of document text, with conjuncts, matras and viramas (#218). const INDIC: &[(&str, &str, &[&str])] = &[ ("Devanagari", "हिन्दी भाषा क्षत्रिय श्री", fonts::DEVANAGARI_FAMILIES), ("Bengali", "বাংলা ভাষা ক্ষত্রিয় শ্রী", fonts::BENGALI_FAMILIES), ("Gurmukhi", "ਪੰਜਾਬੀ ਭਾਸ਼ਾ ਸਤਿ ਸ੍ਰੀ", fonts::GURMUKHI_FAMILIES), ("Gujarati", "ગુજરાતી ભાષા ક્ષત્રિય શ્રી", fonts::GUJARATI_FAMILIES), ("Tamil", "தமிழ் மொழி ஸ்ரீ கொடை", fonts::TAMIL_FAMILIES), ("Telugu", "తెలుగు భాష క్షత్రియ శ్రీ", fonts::TELUGU_FAMILIES), ("Kannada", "ಕನ್ನಡ ಭಾಷೆ ಕ್ಷತ್ರಿಯ ಶ್ರೀ", fonts::KANNADA_FAMILIES), ("Malayalam", "മലയാളം ഭാഷ ക്ഷത്രിയ ശ്രീ", fonts::MALAYALAM_FAMILIES), ("Sinhala", "සිංහල භාෂාව ක්‍ෂත්‍රිය ශ්‍රී", fonts::SINHALA_FAMILIES), ]; #[test] fn indic_scripts_fall_back_to_installed_fonts() { for (name, sample, families) in INDIC { let mut e = TextEngine::with_system_fonts(); let Some(hit) = families.iter().find(|f| e.fonts.has_family(f)) else { eprintln!("skipped: no {name}-capable font installed"); continue; }; let direct = TextLayer { text: (*sample).into(), font_family: hit.to_string(), size_pt: 24.0, ..Default::default() }; let l = e.layout(&direct, 72.0); assert!(!l.glyphs.is_empty() && l.glyphs.iter().all(|g| g.id != 0), "{hit} is installed but shapes .notdef for {name}"); let mixed = format!("Latin {sample}"); let l = e.layout(&point(&mixed, 24.0), 72.0); assert!(l.glyphs.iter().all(|g| g.id != 0), "{name} in a Latin font drawn with .notdef although {hit} is installed"); let latin = l.glyphs.first().map(|g| g.face); assert!(l.glyphs.iter().any(|g| Some(g.face) != latin), "Latin and {name} drawn with the same face"); } } #[test] fn indic_fallback_candidates_cover_every_platform() { // Logic-level half (runs without these fonts): every script has a Windows, macOS and Linux // family in the candidates, ahead of the broad last-resort fonts; the shared Nirmala UI is // probed once. 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(); assert_eq!(fb.iter().filter(|f| **f == "Nirmala UI").count(), 1, "{fb:?}"); for (name, _, families) in INDIC { assert!(families.contains(&"Nirmala UI"), "{name}"); assert!(families.iter().any(|f| f.ends_with("Sangam MN") || f.starts_with("Kohinoor")), "{name}: no macOS family"); assert!(families.iter().any(|f| f.starts_with("Noto Sans ")), "{name}: no Linux family"); for fam in *families { 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::>()); } /// #1317: Photoshop keeps the last line of a text box whose baseline is inside the box even /// when its descenders hang below the bottom edge (three 35 pt lines of 25 pt type in a /// 91.3 pt box). A line whose baseline falls outside the box is still dropped. #[test] fn box_lines_whose_descent_hangs_below_the_box_are_kept() { let mut e = TextEngine::new(); let mut t = styled("one\ntwo\nthree", CharStyle { size_pt: 25.0, leading_pt: Some(35.0), ..Default::default() }); t.shape = TextShape::Box { x: 0.0, y: 0.0, width: 300.0, height: 91.27 }; let l = e.layout(&t, 72.0); assert_eq!(l.lines.len(), 3, "{:?}", l.lines.iter().map(|l| l.baseline).collect::>()); assert!(l.lines[2].baseline <= 91.27 + 0.5); t.shape = TextShape::Box { x: 0.0, y: 0.0, width: 300.0, height: 80.0 }; assert_eq!(e.layout(&t, 72.0).lines.len(), 2, "the third baseline (about 88) is below an 80 pt box"); } /// `Txt2` with one text object for `text` whose rendered frame is frame 0, and that frame: /// for point text an anchor `point`, and a frame-to-text matrix `matrix` (recent Photoshop, /// #1317). fn txt2_with_frame(text: &str, point: Option<[f64; 2]>, matrix: [f64; 6]) -> Vec { let mut v = txt2_with_modes(text, &[(text.encode_utf16().count() + 1, 1)]); // Close the object's model early: `… >> >> ] >>` → `… >> /1 << /2 [ frame ] >> >> ] >>`. let tail = b"] >> >> >> ] >>"; assert!(v.ends_with(tail)); v.truncate(v.len() - tail.len()); v.extend_from_slice(b"] >> >> /1 << /2 [ << /6 [ << /10 0 >> ] >> ] >> >> ] >>"); let pt = point.map(|[x, y]| format!("/0 [ {x:.5} {y:.5} ] ")).unwrap_or_default(); let m = matrix.map(|x| format!("{x:.5}")).join(" "); let frames = format!("/0 << /8 << /0 [ << /0 << {pt}/2 << /2 [ {m} ] >> >> >> ] >> >>"); let at = v.windows(9).position(|w| w == b"/0 << >> ").unwrap(); v.splice(at..at + 9, frames.into_bytes().into_iter().chain(*b" ")); v } /// #1317: point text whose anchor Photoshop keeps only in its `Txt2` frame (EngineData's /// `PointBase` at the origin, the transform far away) is placed at that anchor, not at the /// ink's top left. A rewritten `TySh` (bounds near the origin) is left alone. #[test] fn txt2_frame_places_point_text_at_its_anchor() { let mut t = point("2", 100.0); t.transform = Affine::translate(-1000.0, -2000.0); // Photoshop's text space: anchor at (1100, 2050), ink above its baseline. let tysh = with_text_index(&crate::psd::build_tysh(&t, 72.0, Some([1100.0, 1980.0, 1160.0, 2050.0])), 0); let txt2 = crate::psd::parse_txt2(&txt2_with_frame("2", Some([1000.0, 2100.0]), [1.0, 0.0, 0.0, 1.0, 100.0, -50.0])).unwrap(); let mut l = crate::psd::text_layer_from_tysh(&tysh, 72.0).unwrap(); crate::psd::apply_txt2_frame(&mut l, &tysh, &txt2); let o = l.transform.apply(photocraft_geom::Point::new(0.0, 0.0)); assert!((o.x - 100.0).abs() < 1e-3 && (o.y - 50.0).abs() < 1e-3, "anchor at {o:?}"); // Already folded in (bounds near the origin): unchanged; another text: unchanged. let tysh = with_text_index(&crate::psd::build_tysh(&t, 72.0, Some([0.0, -70.0, 60.0, 0.0])), 0); let mut l = crate::psd::text_layer_from_tysh(&tysh, 72.0).unwrap(); let before = l.transform; crate::psd::apply_txt2_frame(&mut l, &tysh, &txt2); assert_eq!(l.transform, before); let tysh = with_text_index(&crate::psd::build_tysh(&t, 72.0, Some([1100.0, 1980.0, 1160.0, 2050.0])), 0); let other = crate::psd::parse_txt2(&txt2_with_frame("3", Some([1000.0, 2100.0]), [1.0, 0.0, 0.0, 1.0, 100.0, -50.0])).unwrap(); let mut l = crate::psd::text_layer_from_tysh(&tysh, 72.0).unwrap(); let before = l.transform; crate::psd::apply_txt2_frame(&mut l, &tysh, &other); assert_eq!(l.transform, before); } /// `tysh` with its text-space `bounds` set to `r` (Photoshop's, where it drew the text). fn with_bounds(tysh: &[u8], r: [f64; 4]) -> Vec { use photocraft_psd::descriptor::{Descriptor, Id, Value}; let mut t = crate::psd::parse_tysh(tysh).unwrap(); let mut d = Descriptor::default(); for (k, v) in ["Left", "Top ", "Rght", "Btom"].iter().zip(r) { d.items.push((Id::new(k), Value::Double(v))); } t.text.items.retain(|(k, _)| !k.is("bounds")); t.text.items.push((Id::new("bounds"), Value::Descriptor(d))); crate::psd::write_tysh(&t) } /// #1317: a text box whose `BoxBounds` are in its `Txt2` frame's space is moved by the frame's /// matrix onto the `bounds` Photoshop drew it at (it used to land off the canvas, so its text /// "disappeared" when an edit started). #[test] fn txt2_frame_moves_box_text_onto_its_bounds() { let mut t = styled("Casemiro 45", CharStyle { size_pt: 25.0, ..Default::default() }); t.shape = TextShape::Box { x: 500.0, y: 600.0, width: 300.0, height: 90.0 }; t.transform = Affine::translate(-700.0, -300.0); let built = with_text_index(&crate::psd::build_tysh(&t, 72.0, None), 0); let tysh = with_bounds(&built, [960.0, 300.0, 1260.0, 390.0]); let txt2 = crate::psd::parse_txt2(&txt2_with_frame("Casemiro 45", None, [1.0, 0.0, 0.0, 1.0, 460.0, -300.0])).unwrap(); let mut l = crate::psd::text_layer_from_tysh(&tysh, 72.0).unwrap(); assert!(matches!(l.shape, TextShape::Box { .. })); crate::psd::apply_txt2_frame(&mut l, &tysh, &txt2); let TextShape::Box { x, y, .. } = l.shape else { panic!("not a box") }; let o = l.transform.apply(photocraft_geom::Point::new(f64::from(x), f64::from(y))); assert!((o.x - 260.0).abs() < 1e-3 && (o.y - 0.0).abs() < 1e-3, "box top left at {o:?}"); // PhotoCraft's own `TySh` (bounds = the box, frame already folded in): unchanged. let mut l = crate::psd::text_layer_from_tysh(&built, 72.0).unwrap(); let before = l.transform; crate::psd::apply_txt2_frame(&mut l, &built, &txt2); assert_eq!(l.transform, before); } /// Preferences ▸ Type ▸ Missing Glyph Protection: off, only the default family is in the /// fallback stack, so a face without the glyph shows the missing-glyph box instead of a /// fallback's glyph (#1909). #[test] fn missing_glyph_protection_can_turn_the_fallback_stack_off() { 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; }; let mut db = fonts::FontDb::new(); db.register_font_data(cairo.bytes.to_vec()); assert!(db.missing_glyph_protection(), "on by default, as in Photoshop"); assert!(db.fallback_stack().any(|f| f == "Cairo")); db.set_missing_glyph_protection(false); assert_eq!(db.fallback_stack().collect::>(), [fonts::DEFAULT_FAMILY], "only the default family remains"); db.set_missing_glyph_protection(true); assert!(db.fallback_stack().any(|f| f == "Cairo"), "turning it back on restores the coverage fonts"); } /// The shared engine's missing-glyph protection follows the setting without waiting for the /// system font scan (#4519). Under test the shared engine has no scan, so it applies at once; /// the newest setting wins. #[test] fn shared_missing_glyph_protection_follows_the_setting() { let read = || crate::shared().lock().unwrap_or_else(std::sync::PoisonError::into_inner).fonts.missing_glyph_protection(); crate::set_shared_missing_glyph_protection(false); assert!(!read()); crate::set_shared_missing_glyph_protection(true); crate::set_shared_missing_glyph_protection(false); assert!(!read(), "the newest setting wins"); crate::set_shared_missing_glyph_protection(true); assert!(read()); } #[test] fn translate_tysh_moves_only_the_translation() { let mut t = point("Hello!", 26.0); t.transform = Affine { m: [1.5, 0.0, 0.0, 1.5, 80.0, 340.0] }; let tysh = crate::psd::build_tysh(&t, 72.0, Some([0.0, -20.0, 300.0, 4.0])); let moved = crate::psd::translate_tysh(&tysh, -8.0, -279.0).unwrap(); let back = crate::psd::parse_tysh(&moved).unwrap(); assert_eq!(back.transform.m, [1.5, 0.0, 0.0, 1.5, 72.0, 61.0]); // Everything but the 16 translation bytes is unchanged. assert_eq!(moved.len(), tysh.len()); assert_eq!((&moved[..34], &moved[50..]), (&tysh[..34], &tysh[50..])); assert_eq!(crate::psd::parse_tysh(&tysh).unwrap().bounds, back.bounds, "bounds are in text space"); assert!(crate::psd::translate_tysh(&tysh[..40], 1.0, 1.0).is_none(), "a truncated block is refused"); } /// The four values after the `TySh` warp descriptor are big-endian f32, as Photoshop writes them: /// zeros for unwarped type, the text-space bounds for warped type. #[test] fn tysh_trailing_bounds_are_f32() { use photocraft_doc::text::TextWarp; let tail = |b: &[u8]| -> [f32; 4] { let t = b.get(b.len() - 16..).unwrap(); std::array::from_fn(|i| f32::from_be_bytes(t[i * 4..i * 4 + 4].try_into().unwrap())) }; let ink = [0.0, -34.316406, 288.90625, 15.058594]; let plain = crate::psd::build_tysh(&point("Type", 20.0), 72.0, Some(ink)); assert_eq!(tail(&plain), [0.0; 4]); let warped = TextLayer { warp: Some(TextWarp { style: "warpArc".into(), value: 50.0, horizontal_distortion: 0.0, vertical_distortion: 0.0, horizontal: true }), ..point("Type", 20.0) }; let bytes = crate::psd::build_tysh(&warped, 72.0, Some(ink)); assert_eq!(tail(&bytes), ink); let parsed = crate::psd::parse_tysh(&bytes).unwrap(); assert_eq!(parsed.bounds, ink); assert_eq!(crate::psd::write_tysh(&parsed), bytes); } /// EngineData's `AntiAlias` numbers as Photoshop writes them, beside the `AntA` descriptor: /// 0 None, 1 Crisp, 2 Strong, 3 Smooth, 4 Sharp. Read back from EngineData alone (no `AntA`), /// every mode survives. #[test] fn engine_data_antialias_numbers_match_photoshop() { use photocraft_doc::text::AntiAlias; for (mode, n) in [(AntiAlias::None, 0), (AntiAlias::Crisp, 1), (AntiAlias::Strong, 2), (AntiAlias::Smooth, 3), (AntiAlias::Sharp, 4)] { let t = TextLayer { antialias: mode, ..point("Type", 20.0) }; let mut tysh = crate::psd::parse_tysh(&crate::psd::build_tysh(&t, 72.0, None)).unwrap(); let e = crate::psd::engine_data(&tysh.text).unwrap(); assert_eq!(e.path(&["EngineDict", "AntiAlias"]).and_then(crate::engine_data::Value::as_f64), Some(f64::from(n)), "{mode:?}"); tysh.text.items.retain(|(k, _)| !k.is("AntA")); let back = crate::psd::text_layer_from_tysh(&crate::psd::write_tysh(&tysh), 72.0).unwrap(); assert_eq!(back.antialias, mode, "{n}"); } } /// Anti-alias modes must change edge coverage (storytold/photocraft#5902). #[test] fn antialias_modes_change_edge_fringe() { use photocraft_doc::text::AntiAlias; let fringe_mass = |mode: AntiAlias| { let mut e = TextEngine::new(); let layer = TextLayer { antialias: mode, ..point("O", 48.0) }; let (_, r) = e.render(&layer, 72.0, PixelFormat::RGBA8); let n = r.surface.channels(); r.surface.read_region(r.rect).chunks_exact(n).map(|p| f64::from(p[n - 1])).filter(|&a| a > 0.02 && a < 0.98).sum::() }; let none = fringe_mass(AntiAlias::None); let sharp = fringe_mass(AntiAlias::Sharp); let smooth = fringe_mass(AntiAlias::Smooth); let strong = fringe_mass(AntiAlias::Strong); assert_eq!(none, 0.0, "aliased-off edges are hard"); assert!(strong > smooth + 1.0, "strong smooths more than smooth: {strong} vs {smooth}"); assert!(smooth > sharp + 0.5, "sharp thins the fringe vs smooth: {sharp} vs {smooth}"); assert!(sharp > 0.0); } /// Type on a path (#218): along a vertical line the type runs top to bottom with its glyphs /// turned, along a curve it follows the curve, and glyphs beyond the end are not drawn. #[test] fn type_on_a_path_follows_the_curve() { use photocraft_doc::vector::{Knot, Subpath}; let fmt = PixelFormat::RGBA8; let mut eng = TextEngine::new(); let ink = |eng: &mut TextEngine, t: &TextLayer| { let (_, r) = eng.render(t, 72.0, fmt); r.rect }; let straight = point("Path text", 24.0); let flat = ink(&mut eng, &straight); assert!(flat.width() > flat.height() * 3, "{flat:?}"); let mut down = straight.clone(); down.shape = TextShape::Path { path: Subpath::polyline(&[(0.0, 0.0), (0.0, 400.0)]), start: 0.0, flip: false }; let r = ink(&mut eng, &down); assert!(r.height() > r.width() * 3, "vertical path gives tall ink: {r:?}"); assert!(r.height() >= flat.width() - 8, "arc length is preserved: {r:?} vs {flat:?}"); // A half circle bends the text through a larger area than the straight line. let k = 0.5523 * 60.0; let arc = Subpath { closed: false, knots: vec![ Knot { anchor: photocraft_geom::Point::new(-60.0, 0.0), in_ctrl: photocraft_geom::Point::new(-60.0, 0.0), out_ctrl: photocraft_geom::Point::new(-60.0, -k), smooth: true, }, Knot { anchor: photocraft_geom::Point::new(0.0, -60.0), in_ctrl: photocraft_geom::Point::new(-k, -60.0), out_ctrl: photocraft_geom::Point::new(k, -60.0), smooth: true, }, Knot { anchor: photocraft_geom::Point::new(60.0, 0.0), in_ctrl: photocraft_geom::Point::new(60.0, -k), out_ctrl: photocraft_geom::Point::new(60.0, 0.0), smooth: true, }, ], ..Default::default() }; let mut curved = straight.clone(); curved.shape = TextShape::Path { path: arc.clone(), start: 0.0, flip: false }; let c = ink(&mut eng, &curved); assert!(c.height() > flat.height() * 2, "curved ink is taller: {c:?} vs {flat:?}"); // A path too short for the text hides the glyphs past its end. let mut short = straight.clone(); short.shape = TextShape::Path { path: Subpath::polyline(&[(0.0, 0.0), (30.0, 0.0)]), start: 0.0, flip: false }; assert!(ink(&mut eng, &short).width() < flat.width() / 2); // A degenerate path falls back to straight point text instead of failing. let mut dot = straight.clone(); dot.shape = TextShape::Path { path: Subpath::polyline(&[(5.0, 5.0), (5.0, 5.0)]), start: 0.0, flip: false }; assert_eq!(ink(&mut eng, &dot), flat); } #[test] fn hyphenless_postscript_names_split_trailing_styles() { for (ps, family, weight, italic) in [ ("GmarketSansBold", "Gmarket Sans", 700, false), ("NanumGothicBold", "Nanum Gothic", 700, false), ("GmarketSansTTFBold", "Gmarket Sans TTF", 700, false), ("ExampleSansSemiBoldItalic", "Example Sans", 600, true), ("ExampleSansExtraLight", "Example Sans", 200, false), // A style word after a vendor tag belongs to a legacy family name; lowercase is no style. ("ArialRoundedMTBold", "Arial Rounded MT Bold", 400, false), ("Daylight", "Daylight", 400, false), ] { let r = fonts::guess_from_postscript(ps); assert_eq!((r.family.as_str(), r.weight, r.italic), (family, weight, italic), "{ps}"); } } /// Thickening moves each outline point along its bisector; this guards that curves stay as smooth /// as the regular glyph's at a large size (the bend of a bold "O"'s edge between rows matches the /// regular one's). See also `faux_bold_keeps_square_corners_square`. #[test] fn faux_bold_curves_stay_smooth_at_large_sizes() { let mut e = TextEngine::new(); // The largest bend of the left edge between consecutive rows, in pixels (second difference // of its sub-pixel position), over the middle of the glyph's height. let bumpiness = |e: &mut TextEngine, bold: bool| -> f64 { let style = CharStyle { size_pt: 600.0, faux_bold: bold, ..Default::default() }; let (_, r) = e.render(&TextLayer { transform: Affine::translate(60.0, 640.0), ..styled("O", style) }, 72.0, PixelFormat::RGBA8); let alpha = |x: i32, y: i32| alpha_sum(&r.surface, photocraft_geom::Rect::new(x, y, x + 1, y + 1)); let h = r.rect.height() as i32; let mut edges = Vec::new(); for y in (r.rect.y0 + h * 15 / 100)..(r.rect.y0 + h * 85 / 100) { if let Some(x) = (r.rect.x0..r.rect.x1).find(|&x| alpha(x, y) > 0.01) { edges.push(f64::from(x) + 1.0 - alpha(x, y)); } } edges.windows(3).map(|w| (w[2] - 2.0 * w[1] + w[0]).abs()).fold(0.0, f64::max) }; let plain = bumpiness(&mut e, false); let bold = bumpiness(&mut e, true); eprintln!("bumpiness plain {plain} bold {bold}"); assert!(bold < plain + 0.5, "faux bold's edge is notched: bends {bold} px vs {plain} px regular"); } /// The notches showed most at corners and stroke ends: eight stamped copies leave a stepped bite /// out of a square corner. Thickened outlines keep corners square, as a bold "l" shows. #[test] fn faux_bold_keeps_square_corners_square() { let mut e = TextEngine::new(); for bold in [false, true] { let style = CharStyle { size_pt: 600.0, faux_bold: bold, ..Default::default() }; let (_, r) = e.render(&TextLayer { transform: Affine::translate(60.0, 640.0), ..styled("l", style) }, 72.0, PixelFormat::RGBA8); let alpha = |x: i32, y: i32| alpha_sum(&r.surface, photocraft_geom::Rect::new(x, y, x + 1, y + 1)); // The ink's own top-left and top-right corners (the raster rect pads the ink by a pixel). let top = (r.rect.y0..r.rect.y1).find(|&y| (r.rect.x0..r.rect.x1).any(|x| alpha(x, y) > 0.5)).unwrap(); let left = (r.rect.x0..r.rect.x1).find(|&x| (r.rect.y0..r.rect.y1).any(|y| alpha(x, y) > 0.5)).unwrap(); let right = (r.rect.x0..r.rect.x1).rev().find(|&x| (r.rect.y0..r.rect.y1).any(|y| alpha(x, y) > 0.5)).unwrap(); for (x, corner) in [(left + 1, "top-left"), (right - 1, "top-right")] { let a = alpha(x, top + 1); assert!(a > 0.9, "bold {bold}: the {corner} corner is cut away (alpha {a})"); } } } #[test] fn text_rebuild_preserves_imported_font_metadata() { use crate::engine_data::Value as E; let layer = point("Original", 24.0); let mut template = crate::psd::build_engine_data(&layer, None, 72.0); for key in ["ResourceDict", "DocumentResources"] { let fonts = template.get_mut(key).unwrap().get_mut("FontSet").unwrap(); let E::Array(fonts) = fonts else { panic!("font array") }; for font in fonts.iter_mut() { font.set("Script", E::Int(4)); font.set("FontType", E::Int(2)); font.set("Synthetic", E::Int(3)); font.set("PrivateFontFlag", E::Int(17)); } } let expected = template.path(&["ResourceDict", "FontSet"]).unwrap().clone(); let mut edited = layer; edited.text = "Edited".into(); let rebuilt = crate::psd::build_engine_data(&edited, Some(template), 72.0); for key in ["ResourceDict", "DocumentResources"] { assert_eq!(rebuilt.path(&[key, "FontSet"]), Some(&expected), "existing font metadata must survive a text edit"); } } #[test] fn font_metadata_survives_tysh_and_named_style_rewrites() { use crate::engine_data::Value as E; let source = point("Original", 24.0); let mut tysh = crate::psd::parse_tysh(&crate::psd::build_tysh(&source, 72.0, None)).unwrap(); let mut data = crate::psd::engine_data(&tysh.text).unwrap(); for (key, marker) in [("ResourceDict", 17), ("DocumentResources", 29)] { let E::Array(fonts) = data.get_mut(key).unwrap().get_mut("FontSet").unwrap() else { panic!("font array") }; for font in fonts { font.set("PrivateFontFlag", E::Int(marker)); font.set("Script", E::Int(4)); } } for (key, value) in &mut tysh.text.items { if key.as_bytes() == b"EngineData" { *value = photocraft_psd::descriptor::Value::RawData(crate::engine_data::write(&data)); } } let raw = crate::psd::write_tysh(&tysh); let mut imported = crate::psd::text_layer_from_tysh(&raw, 72.0).unwrap(); // The IO importer retains the raw block separately from the parsed text model. imported.psd_raw = Some(raw.into()); imported.text = "Edited".into(); let rebuilt = crate::psd::build_tysh(&imported, 72.0, None); let styled = crate::psd_styles::write_style_sheets(&rebuilt, &imported, &photocraft_doc::TextStyles::default(), 72.0).unwrap(); for bytes in [&rebuilt, &styled] { let parsed = crate::psd::parse_tysh(bytes).unwrap(); let actual = crate::psd::engine_data(&parsed.text).unwrap(); for key in ["ResourceDict", "DocumentResources"] { assert_eq!(actual.path(&[key, "FontSet"]), data.path(&[key, "FontSet"])); } assert_eq!(crate::psd::text_layer_from_tysh(bytes, 72.0).unwrap().text, "Edited"); } } #[test] fn font_metadata_append_reorder_and_duplicate_indices() { use crate::engine_data::Value as E; let record = |name: &str, marker| E::Dict(vec![("Name".into(), E::String(name.into())), ("PrivateFontFlag".into(), E::Int(marker))]); let entries = vec![record("A", 1), record("A", 2), record("B", 3)]; let original = E::Array(entries.clone()); let names = ["A", "A", "B", "New"].map(str::to_string); let result = crate::psd::font_set(&names, Some(&original)); let fonts = result.as_array().unwrap(); assert_eq!(&fonts[..3], entries.as_slice()); assert_eq!(fonts[3], crate::psd::font_entry("New")); let reordered = crate::psd::font_set(&["B".into(), "A".into()], Some(&original)); assert_eq!(reordered, E::Array(vec![entries[2].clone(), entries[1].clone()])); // A non-positional duplicate uses the first matching record, not the last map insertion. let fallback = crate::psd::font_set(&["New".into(), "B".into(), "A".into()], Some(&original)); assert_eq!(fallback, E::Array(vec![crate::psd::font_entry("New"), entries[2].clone(), entries[0].clone()])); let empty = record("", 9); assert_eq!(crate::psd::font_set(&["".into()], Some(&E::Array(vec![E::Int(0), empty.clone()]))), E::Array(vec![empty])); for malformed in [None, Some(&E::Int(0))] { assert_eq!(crate::psd::font_set(&["New".into()], malformed), E::Array(vec![crate::psd::font_entry("New")])); } } #[test] fn font_metadata_large_nameless_lists_rebuild_after_edit() { use crate::engine_data::Value as E; let mut layer = point("Original", 24.0); let mut template = crate::psd::build_engine_data(&layer, None, 72.0); for key in ["ResourceDict", "DocumentResources"] { template.get_mut(key).unwrap().set("FontSet", E::Array(vec![E::Int(0); 10_000])); } layer.text = "Edited".into(); let rebuilt = crate::psd::build_engine_data(&layer, Some(template), 72.0); for key in ["ResourceDict", "DocumentResources"] { let fonts = rebuilt.path(&[key, "FontSet"]).unwrap().as_array().unwrap(); assert!(fonts.len() >= 10_000); assert!(fonts[..10_000].iter().all(|font| *font == crate::psd::font_entry(""))); } } #[test] fn font_metadata_reordered_duplicates_keep_distinct_dictionary_records() { use crate::engine_data::Value as E; let record = |name: &str, marker| E::Dict(vec![("Name".into(), E::String(name.into())), ("PrivateFontFlag".into(), E::Int(marker))]); let mut layer = point("Original", 24.0); let mut template = crate::psd::build_engine_data(&layer, None, 72.0); let resource = vec![record("A", 1), record("A", 2), record("B", 3)]; let document = vec![record("B", 30), record("A", 10), record("A", 20)]; template.get_mut("ResourceDict").unwrap().set("FontSet", E::Array(resource.clone())); template.get_mut("DocumentResources").unwrap().set("FontSet", E::Array(document.clone())); layer.text = "Edited".into(); let rebuilt = crate::psd::build_engine_data(&layer, Some(template), 72.0); assert_eq!(&rebuilt.path(&["ResourceDict", "FontSet"]).unwrap().as_array().unwrap()[..3], resource.as_slice()); let fonts = rebuilt.path(&["DocumentResources", "FontSet"]).unwrap().as_array().unwrap(); assert_eq!(&fonts[..3], &[document[1].clone(), document[2].clone(), document[0].clone()], "reordered duplicates must keep distinct metadata"); let repeated = crate::psd::font_set(&["A", "A", "A", "A"].map(str::to_string), Some(&E::Array(resource.clone()))); assert_eq!(repeated, E::Array(vec![resource[0].clone(), resource[1].clone(), resource[0].clone(), resource[0].clone()])); } /// Same-runner release probe: compare the original and repaired source, not a wall-clock CI limit. #[test] #[ignore = "release performance measurement for large imported FontSets"] fn font_metadata_large_lists_timing() { use crate::engine_data::Value as E; for reordered in [false, true] { let mut layer = point("Original", 24.0); let mut template = crate::psd::build_engine_data(&layer, None, 72.0); let n = 100_000; for key in ["ResourceDict", "DocumentResources"] { let entries = if reordered { (0..n).map(|i| crate::psd::font_entry(&format!("Font{}", if key == "DocumentResources" { n - 1 - i } else { i }))).collect() } else { vec![E::Int(0); n] }; template.get_mut(key).unwrap().set("FontSet", E::Array(entries)); } layer.text = "Edited".into(); let mut times = Vec::new(); for _ in 0..3 { let input = template.clone(); let start = std::time::Instant::now(); let result = crate::psd::build_engine_data(&layer, Some(input), 72.0); std::hint::black_box(&result); times.push(start.elapsed().as_secs_f64()); } times.sort_by(f64::total_cmp); println!("FONT_LOOKUP reordered={reordered} n={n} median_seconds={}", times[1]); } } /// A word wider than its paragraph box wraps at the box edge instead of running past it (#6694); /// a normal break opportunity still wins, and a box that fits the word lays out exactly as before. #[test] fn a_word_wider_than_the_paragraph_box_breaks_at_the_box_edge() { let mut e = TextEngine::new(); let word = "BLOQUEADO?!"; let alone = e.layout(&point(word, 12.0), 72.0); assert_eq!(alone.lines.len(), 1, "{:?}", alone.lines); let word_width = width(&alone); let narrow = 30.0; assert!(word_width > narrow * 2.0, "{word_width}"); let mut t = point(word, 12.0); t.shape = TextShape::Box { x: 0.0, y: 0.0, width: narrow, height: 200.0 }; let l = e.layout(&t, 72.0); assert!(l.lines.len() >= 2, "{:?}", l.lines); for line in &l.lines { assert!(line.x1 <= narrow + 0.5, "{line:?}"); } let joined: String = l.lines.iter().map(|l| &word[l.range.clone()]).collect(); assert_eq!(joined, word); t.shape = TextShape::Box { x: 0.0, y: 0.0, width: word_width + 20.0, height: 200.0 }; let fits = e.layout(&t, 72.0); assert_eq!(fits.lines.len(), 1, "{:?}", fits.lines); assert!((width(&fits) - word_width).abs() < 0.01, "{} vs {word_width}", width(&fits)); let mut spaced = point("ab cd", 12.0); spaced.shape = TextShape::Box { x: 0.0, y: 0.0, width: narrow, height: 200.0 }; let l = e.layout(&spaced, 72.0); assert_eq!(l.lines.len(), 2, "{:?}", l.lines); assert_eq!(l.lines[1].range.start, 3, "{:?}", l.lines); } /// [`TextLayout::overflows`] reads dropped lines off the layout: text that fits covers its end /// through trailing empty paragraphs; a taller text's lines stop short of the text's end; an /// empty uncovered tail of pure trailing breaks that fit is not an overflow. #[test] fn overflows_says_whether_a_box_holds_its_text() { let mut e = TextEngine::new(); let tall_box = TextShape::Box { x: 0.0, y: 0.0, width: 220.0, height: 900.0 }; // Everything fits, trailing breaks included: the trailing empty paragraphs cover the // text's end. for text in ["a", "a\n", "a\n\n", "", "Lorem ipsum dolor sit amet"] { let l = e.layout(&TextLayer { shape: tall_box.clone(), ..point(text, 24.0) }, 72.0); assert!(!l.overflows(text), "{text:?}: {} lines", l.lines.len()); } // The box holds one line of 24 pt type — additional lines are dropped, and the last line // stops short of the text. let short_box = TextShape::Box { x: 0.0, y: 0.0, width: 220.0, height: 34.0 }; let text = "ab\ncd\nef"; let l = e.layout(&TextLayer { shape: short_box.clone(), ..point(text, 24.0) }, 72.0); assert!(l.overflows(text), "{:?}", l.lines.last().map(|ln| ln.range.clone())); // A trailing break whose empty paragraph doesn't fit is an overflow as well. let l = e.layout(&TextLayer { shape: short_box.clone(), ..point("ab\n\n", 24.0) }, 72.0); assert!(l.overflows("ab\n\n"), "{:?}", l.lines.last().map(|ln| ln.range.clone())); // A box holding one full line plus exactly one more empty paragraph is not. let exact = point("ab\n", 24.0); let l = e.layout(&TextLayer { shape: tall_box.clone(), ..exact }, 72.0); assert!(!l.overflows("ab\n"), "{:?}", l.lines.last().map(|ln| ln.range.clone())); // Empty text never overflows: even a box too short for its paragraph (no lines laid out) // has nothing to lose, so no overflow mark. let l = e.layout(&TextLayer { shape: short_box.clone(), ..point("", 24.0) }, 72.0); assert!(!l.overflows(""), "{} lines", l.lines.len()); let _ = &mut e; } /// Closed polygon in text space. fn area_poly(pts: &[(f64, f64)]) -> photocraft_doc::vector::Path { photocraft_doc::vector::Path::new(vec![photocraft_doc::vector::Subpath::polygon(pts)]) } /// An ellipse from four kappa curves inside `[x, y, w, h]`. fn area_ellipse(x: f64, y: f64, w: f64, h: f64) -> photocraft_doc::vector::Path { use photocraft_doc::vector::{Knot, Subpath}; use photocraft_geom::Point; let (rx, ry, cx, cy) = (w / 2.0, h / 2.0, x + w / 2.0, y + h / 2.0); let (kx, ky) = (0.552_284_75 * rx, 0.552_284_75 * ry); let p = Point::new; let knots = vec![ Knot::smooth(p(cx, y), p(cx - kx, y), p(cx + kx, y)), Knot::smooth(p(x + w, cy), p(x + w, cy - ky), p(x + w, cy + ky)), Knot::smooth(p(cx, y + h), p(cx + kx, y + h), p(cx - kx, y + h)), Knot::smooth(p(x, cy), p(x, cy + ky), p(x, cy - ky)), ]; photocraft_doc::vector::Path::new(vec![Subpath { closed: true, knots, op: Default::default() }]) } const BUBBLE_TEXT: &str = "Well, that went better than expected. Now where did the cat hide the keys this time?"; /// Every laid-out line sits inside a span of the shape across its band. fn assert_lines_inside(l: &crate::TextLayout, path: &photocraft_doc::vector::Path) { let a = crate::area::AreaShape::new(path, false).unwrap(); for line in &l.lines { let spans = a.spans(line.baseline - line.ascent, line.baseline + line.descent, 0.0); assert!(spans.iter().any(|&(s0, s1)| line.x0 >= s0 - 0.5 && line.x1 <= s1 + 0.5), "{line:?} not inside {spans:?}"); } } #[test] fn area_text_stays_inside_an_ellipse() { let mut e = TextEngine::new(); let mut t = point(BUBBLE_TEXT, 18.0); let path = area_ellipse(0.0, 0.0, 260.0, 160.0); t.shape = TextShape::Area { path: path.clone() }; let l = e.layout(&t, 72.0); assert!(l.lines.len() >= 4, "{}", l.lines.len()); assert_lines_inside(&l, &path); // The top of an ellipse is too narrow for a line at its very edge: the first line starts lower. assert!(l.lines[0].baseline - l.lines[0].ascent > 1.0, "{:?}", l.lines[0]); // Lines go down the shape in text order. for w in l.lines.windows(2) { assert!(w[1].baseline >= w[0].baseline && w[1].range.start >= w[0].range.end, "{w:?}"); } // Middle lines are wider than the first (the ellipse is widest in the middle). let width = |i: usize| l.lines[i].x1 - l.lines[i].x0; assert!(width(l.lines.len() / 2) > width(0), "{l:?}"); } #[test] fn area_overflow_is_hidden() { let mut e = TextEngine::new(); let mut t = point(&BUBBLE_TEXT.repeat(10), 14.0); let path = area_ellipse(0.0, 0.0, 200.0, 80.0); t.shape = TextShape::Area { path: path.clone() }; let l = e.layout(&t, 72.0); assert!(!l.lines.is_empty()); assert!(l.lines.last().unwrap().range.end < t.text.len(), "the rest is hidden"); assert_lines_inside(&l, &path); } /// A long word that reaches the narrow bottom of a full bubble is hidden with the rest, not /// drawn past the outline: the wide middle rows would hold it, so the shape just ran out. #[test] fn area_overflow_at_the_narrow_bottom_is_hidden() { let mut e = TextEngine::new(); let words = "Unfortunately the misunderstanding between representatives continued indefinitely. "; for (w, h, size) in [(260.0, 160.0, 12.0), (180.0, 140.0, 14.0), (150.0, 220.0, 12.0)] { let mut t = point(&words.repeat(8), size); let path = area_ellipse(0.0, 0.0, w, h); t.shape = TextShape::Area { path: path.clone() }; let l = e.layout(&t, 72.0); assert!(!l.lines.is_empty()); assert!(l.lines.last().unwrap().range.end < t.text.len(), "the rest is hidden"); assert_lines_inside(&l, &path); } } /// The same in a later paragraph: its own rows start below the wide top of the shape, but the /// shape would still hold the word there, so it is hidden rather than drawn past the outline. #[test] fn area_overflow_in_a_later_paragraph_is_hidden() { let mut e = TextEngine::new(); // A funnel: 300 px wide at the top, a point at the bottom. let path = area_poly(&[(0.0, 0.0), (300.0, 0.0), (150.0, 300.0)]); for n in (10..120).step_by(5) { let text = format!("{}\nmisunderstanding and more", "cat ".repeat(n)); let mut t = point(&text, 12.0); t.shape = TextShape::Area { path: path.clone() }; let l = e.layout(&t, 72.0); assert_lines_inside(&l, &path); } } #[test] fn area_lines_center_in_their_span() { let mut e = TextEngine::new(); let mut t = point(BUBBLE_TEXT, 12.0); t.paragraphs = vec![ParagraphRun { len: t.text.len(), style: ParagraphStyle { align: TextAlign::Center, ..Default::default() } }]; // A diamond: every row's span is centred on x = 100. t.shape = TextShape::Area { path: area_poly(&[(100.0, 0.0), (200.0, 100.0), (100.0, 200.0), (0.0, 100.0)]) }; let l = e.layout(&t, 72.0); assert!(l.lines.len() >= 3); for line in &l.lines { assert!(((line.x0 + line.x1) / 2.0 - 100.0).abs() < 1.5, "{line:?}"); } } /// Horizontal scale applies after shaping, so area lines break at the span width divided by it: /// widened text stays inside the shape and centred lines stay centred (#5565). #[test] fn horizontally_scaled_area_text_stays_inside_its_spans() { let mut e = TextEngine::new(); let style = CharStyle { font_family: "Inter".into(), size_pt: 12.0, horizontal_scale: 1.5, ..Default::default() }; let mut t = styled(BUBBLE_TEXT, style); let path = area_ellipse(0.0, 0.0, 240.0, 200.0); t.shape = TextShape::Area { path: path.clone() }; let l = e.layout(&t, 72.0); assert!(l.lines.len() >= 3, "{:?}", l.lines); assert_lines_inside(&l, &path); t.paragraphs = vec![ParagraphRun { len: t.text.len(), style: ParagraphStyle { align: TextAlign::Center, ..Default::default() } }]; t.shape = TextShape::Area { path: area_poly(&[(100.0, 0.0), (200.0, 100.0), (100.0, 200.0), (0.0, 100.0)]) }; let l = e.layout(&t, 72.0); assert!(l.lines.len() >= 2, "{:?}", l.lines); for line in &l.lines { assert!(((line.x0 + line.x1) / 2.0 - 100.0).abs() < 1.5, "{line:?}"); } } #[test] fn area_rows_split_around_a_hole() { use photocraft_doc::vector::{PathOp, Subpath}; let mut e = TextEngine::new(); let mut t = point(&BUBBLE_TEXT.repeat(6), 12.0); let path = photocraft_doc::vector::Path::new(vec![ Subpath::polygon(&[(0.0, 0.0), (300.0, 0.0), (300.0, 300.0), (0.0, 300.0)]), Subpath::polygon(&[(120.0, 100.0), (180.0, 100.0), (180.0, 200.0), (120.0, 200.0)]).with_op(PathOp::Subtract), ]); t.shape = TextShape::Area { path: path.clone() }; let l = e.layout(&t, 72.0); assert_lines_inside(&l, &path); // Some baseline carries two lines: one left of the hole, one right of it, left first. let w = l.lines.windows(2).find(|w| (w[0].baseline - w[1].baseline).abs() < 1e-3).expect("a row split around the hole"); let (left, right) = (&w[0], &w[1]); assert!(left.x1 <= 120.5 && right.x0 >= 179.5, "{left:?} {right:?}"); } #[test] fn vertical_area_text_stays_inside() { use photocraft_doc::text::Orientation; let mut e = TextEngine::new(); let mut t = point(BUBBLE_TEXT, 12.0); t.orientation = Orientation::Vertical; t.shape = TextShape::Area { path: area_poly(&[(0.0, 0.0), (120.0, 0.0), (120.0, 200.0), (0.0, 200.0)]) }; let l = e.layout(&t, 72.0); assert!(l.lines.len() >= 2, "several columns"); for g in &l.glyphs { assert!(g.x > -1.0 && g.x < 121.0 && g.y > -1.0 && g.y < 201.0, "{g:?}"); } } #[test] fn area_without_room_lays_out_nothing_and_a_bad_path_lays_out_as_point_text() { let mut e = TextEngine::new(); let mut t = point("Hello", 40.0); // 10 px tall: no 40 pt line fits. t.shape = TextShape::Area { path: area_poly(&[(0.0, 0.0), (300.0, 0.0), (300.0, 10.0), (0.0, 10.0)]) }; assert!(e.layout(&t, 72.0).lines.is_empty()); // An open path encloses nothing (only reachable from a hostile file): point text, no panic. t.shape = TextShape::Area { path: photocraft_doc::vector::Path::new(vec![photocraft_doc::vector::Subpath::polyline(&[(0.0, 0.0), (9.0, 9.0)])]) }; let point_layout = e.layout(&point("Hello", 40.0), 72.0); assert_eq!(e.layout(&t, 72.0).lines.len(), point_layout.lines.len()); } #[test] fn area_word_wider_than_every_span_overflows_instead_of_hiding_the_text() { use photocraft_doc::vector::Subpath; let mut e = TextEngine::new(); // Two 40 px columns 120 px apart: their bounding box is 200 px wide, but no span is wider // than 40 px, so no row can ever hold the long word. let mut t = point("Wonderfulness is a long word", 12.0); let path = photocraft_doc::vector::Path::new(vec![ Subpath::polygon(&[(0.0, 0.0), (40.0, 0.0), (40.0, 200.0), (0.0, 200.0)]), Subpath::polygon(&[(160.0, 0.0), (200.0, 0.0), (200.0, 200.0), (160.0, 200.0)]), ]); t.shape = TextShape::Area { path }; let l = e.layout(&t, 72.0); let first = l.lines.first().expect("the text is shown"); // The word overflows its 40 px span on the first row (box text would break it, #6694). assert!(first.x1 - first.x0 > 40.0 && first.x1 - first.x0 < 200.0, "{first:?}"); assert!(first.x0 >= -0.5, "{first:?}"); // Nothing is hidden. assert_eq!(l.lines.last().map(|line| line.range.end), Some(t.text.len()), "{:?}", l.lines); } #[test] fn single_size_area_paragraph_breaks_in_one_pass() { let mut e = TextEngine::new(); let mut t = point(BUBBLE_TEXT, 12.0); t.shape = TextShape::Area { path: area_poly(&[(0.0, 0.0), (300.0, 0.0), (300.0, 300.0), (0.0, 300.0)]) }; crate::layout::AREA_PASSES.with(|c| c.set(0)); let l = e.layout(&t, 72.0); assert!(l.lines.len() >= 2); // Rows are planned with the paragraph's real metrics: no re-plan and no skipped spans. assert_eq!(crate::layout::AREA_PASSES.with(|c| c.get()), 1); } #[test] fn area_word_skips_a_narrow_span_for_a_wider_one_on_the_same_row() { let mut e = TextEngine::new(); let mut t = point("Wonderful things happen in speech bubbles every single day.", 12.0); // A U: a 24 px left arm and a wide right arm above y = 150, joined below it. let path = area_poly(&[(0.0, 0.0), (24.0, 0.0), (24.0, 150.0), (80.0, 150.0), (80.0, 0.0), (300.0, 0.0), (300.0, 200.0), (0.0, 200.0)]); t.shape = TextShape::Area { path: path.clone() }; let l = e.layout(&t, 72.0); assert_lines_inside(&l, &path); let first = l.lines.first().expect("the text is shown"); // "Wonderful" is wider than the left arm: it starts the text in the right arm, on the first // row, and nothing is put in the left arm's span on that row. assert_eq!(first.range.start, 0); assert!(first.x0 >= 79.5, "{first:?}"); assert!(first.baseline < 30.0, "first row: {first:?}"); assert_eq!(l.lines.iter().filter(|line| (line.baseline - first.baseline).abs() < 1e-3).count(), 1, "{:?}", l.lines); assert_eq!(l.lines.last().map(|line| line.range.end), Some(t.text.len()), "nothing hidden"); } #[test] fn area_stacks_paragraphs_with_leading_and_space_before() { let mut e = TextEngine::new(); let text = "First paragraph of the bubble text.\nSecond paragraph goes right after it."; let mut t = point(text, 12.0); let style = ParagraphStyle { space_before_pt: 6.0, ..Default::default() }; let leading = style.auto_leading * 12.0; t.paragraphs = vec![ParagraphRun { len: text.len(), style }]; let path = area_poly(&[(0.0, 0.0), (120.0, 0.0), (120.0, 300.0), (0.0, 300.0)]); t.shape = TextShape::Area { path: path.clone() }; let l = e.layout(&t, 72.0); assert_lines_inside(&l, &path); let (p0, p1): (Vec<_>, Vec<_>) = l.lines.iter().partition(|line| line.paragraph == 0); assert!(p0.len() >= 2 && p1.len() >= 2, "both paragraphs wrap: {:?}", l.lines); assert!(p1.iter().all(|line| line.paragraph == 1)); // The second paragraph starts right after the break, one leading plus space before lower. let second = text.find('\n').unwrap() + 1; assert_eq!(p1[0].range.start, second); let gap = p1[0].baseline - p0.last().unwrap().baseline; assert!((gap - (leading + 6.0)).abs() < 1.0, "gap {gap}, want {}", leading + 6.0); // Lines within a paragraph are one leading apart. for w in p0.windows(2) { assert!((w[1].baseline - w[0].baseline - leading).abs() < 1.0, "{w:?}"); } assert_eq!(l.lines.last().map(|line| line.range.end), Some(text.len())); } #[test] fn area_justify_fills_each_span_but_the_last_line() { let mut e = TextEngine::new(); let mut t = point(&BUBBLE_TEXT.repeat(2), 12.0); t.paragraphs = vec![ParagraphRun { len: t.text.len(), style: ParagraphStyle { align: TextAlign::JustifyLeft, ..Default::default() } }]; let path = area_ellipse(0.0, 0.0, 260.0, 160.0); t.shape = TextShape::Area { path: path.clone() }; let l = e.layout(&t, 72.0); assert!(l.lines.len() >= 4, "{}", l.lines.len()); assert_lines_inside(&l, &path); let a = crate::area::AreaShape::new(&path, false).unwrap(); let span_of = |line: &crate::LineInfo| { a.spans(line.baseline - line.ascent, line.baseline + line.descent, 0.0) .into_iter() .find(|&(s0, s1)| line.x0 >= s0 - 0.5 && line.x1 <= s1 + 0.5) .unwrap() }; let (last, rest) = l.lines.split_last().unwrap(); for line in rest { // Justified: the glyphs run from one edge of the line's span to the other. let (s0, s1) = span_of(line); let ink = l.glyphs.iter().filter(|g| (g.y - line.baseline).abs() < 1e-3).map(|g| g.x).fold(f32::MIN, f32::max); assert!((line.x0 - s0).abs() < 1.0, "{line:?} in {s0}..{s1}"); assert!(ink > s1 - 12.0, "last glyph at {ink}, span ends {s1}: {line:?}"); } // The last line is set flush left, not stretched. let (s0, s1) = span_of(last); assert!((last.x0 - s0).abs() < 1.0 && last.x1 < s1 - 12.0, "{last:?} in {s0}..{s1}"); } /// #8602: each colour is rasterised only over its own glyphs instead of the whole text, and the /// pixels match a full-size pass per colour (plain, faux bold, underlined, rotated and warped /// text; RGB and CMYK), up to float rounding in the coverage sums. #[test] fn per_colour_passes_match_full_size_passes() { use photocraft_doc::text::TextWarp; let mut e = TextEngine::new(); let text = "Many coloured runs of text, one colour a letter, across two lines\nof a point text layer"; let colours = [Color::rgb(0.9, 0.1, 0.1), Color::rgb(0.1, 0.7, 0.2), Color::rgb(0.1, 0.2, 0.9), Color::rgb(0.6, 0.6, 0.1)]; let runs = |bold: bool| { text.char_indices() .map(|(i, ch)| { let colour = colours[i % colours.len()]; let style = CharStyle { size_pt: 28.0, color: colour, faux_bold: bold && i % 3 == 0, underline: i % 5 == 0, ..Default::default() }; TextRun { len: ch.len_utf8(), style } }) .collect::>() }; let cases = [ TextLayer { text: text.into(), runs: runs(false), transform: Affine::translate(10.0, 40.0), ..Default::default() }, TextLayer { text: text.into(), runs: runs(true), transform: Affine::rotate(0.3).then(&Affine::translate(30.0, 90.0)), ..Default::default() }, TextLayer { text: text.into(), runs: runs(true), transform: Affine::translate(10.0, 60.0), warp: Some(TextWarp { style: "warpArc".into(), value: 40.0, horizontal_distortion: 0.0, vertical_distortion: 0.0, horizontal: true }), ..Default::default() }, ]; for (k, t) in cases.iter().enumerate() { let l = e.layout(t, 72.0); let warp = crate::render::layout_deform(&l, t); for format in [PixelFormat::RGBA8, PixelFormat::CMYKA8] { let fast = crate::render::rasterize_passes(&l, &t.transform, format, t.antialias, warp.as_ref(), true); let full = crate::render::rasterize_passes(&l, &t.transform, format, t.antialias, warp.as_ref(), false); assert_eq!(fast.rect, full.rect, "case {k}"); let (a, b) = (fast.surface.read_region(fast.rect), full.surface.read_region(full.rect)); assert!(!a.is_empty()); // Compare what shows: colour weighted by alpha (a fully transparent pixel's colour is moot). let ch = format.channels(); let shown = |px: &[f32]| { px.chunks_exact(ch).flat_map(|p| p[..ch - 1].iter().map(|c| c * p[ch - 1]).chain([p[ch - 1]]).collect::>()).collect::>() }; let worst = shown(&a).iter().zip(&shown(&b)).map(|(x, y)| (x - y).abs()).fold(0.0f32, f32::max); assert!(worst <= 1.0 / 255.0, "case {k} {format:?}: largest difference {worst}"); } } } /// A legacy (Photoshop-era) `Txt2` block shaped like a real one (psd-tools' `text.psd`): the /// fonts in `DocumentResources /FontSet /Resources`, and each object's `Model /StyleRun` runs /// holding their style under `RunData /StyleSheet /Features` (named keys) beside `Length`. fn styled_era_txt2(text: &str, font: &str, size: f64, auto_kern: i64) -> Vec { use crate::engine_data::Value as E; let font_entry = |name: &str| { E::Dict(vec![( "Resource".into(), E::Dict(vec![ ("StreamTag".into(), E::Name("CoolTypeFont".into())), ("Identifier".into(), E::Dict(vec![("Name".into(), E::String(name.into())), ("Type".into(), E::Int(1))])), ]), )]) }; let features = E::Dict(vec![ ("Font".into(), E::Int(0)), ("FontSize".into(), E::Real(size)), ("FauxBold".into(), E::Bool(false)), ("FauxItalic".into(), E::Bool(false)), ("AutoLeading".into(), E::Bool(true)), ("Leading".into(), E::Real(0.01)), ("HorizontalScale".into(), E::Real(1.0)), ("VerticalScale".into(), E::Real(1.0)), ("Tracking".into(), E::Int(0)), ("AutoKern".into(), E::Int(auto_kern)), ("BaselineShift".into(), E::Real(0.0)), ("FontCaps".into(), E::Int(0)), ("FontBaseline".into(), E::Int(0)), ("UnderlinePosition".into(), E::Int(0)), ("StrikethroughPosition".into(), E::Int(0)), ("Ligatures".into(), E::Bool(true)), ("DiscretionaryLigatures".into(), E::Bool(false)), ("ContextualLigatures".into(), E::Bool(true)), ("StylisticSets".into(), E::Int(0)), ( "FillColor".into(), E::Dict(vec![ ("StreamTag".into(), E::Name("SimplePaint".into())), ( "Color".into(), E::Dict(vec![("Type".into(), E::Int(1)), ("Values".into(), E::Array(vec![E::Real(1.0), E::Real(0.0), E::Real(0.0), E::Real(0.0)]))]), ), ]), ), ]); let total = format!("{text}\r").encode_utf16().count() as i64; let run = |len: i64, data: E| E::Dict(vec![("RunData".into(), data), ("Length".into(), E::Int(len))]); let placeholder = || E::Dict(vec![("0".into(), E::String(String::new()))]); let style_data = E::Dict(vec![( "StyleSheet".into(), E::Dict(vec![("Name".into(), E::String(String::new())), ("Parent".into(), E::Int(0)), ("Features".into(), features)]), )]); let entry = E::Dict(vec![ ( "Model".into(), E::Dict(vec![ ("Text".into(), E::String(format!("{text}\r"))), ("ParagraphRun".into(), E::Dict(vec![("RunArray".into(), E::Array(vec![run(total, placeholder())]))])), ("StyleRun".into(), E::Dict(vec![("RunArray".into(), E::Array(vec![run(total, style_data)]))])), ("KernRun".into(), E::Dict(vec![("RunArray".into(), E::Array(vec![run(total, placeholder())]))])), ]), ), ("View".into(), E::Dict(vec![("0".into(), E::Array(vec![E::Dict(vec![("0".into(), E::Int(0))])]))])), ]); crate::engine_data::write_bare(&[ ("DocumentResources".into(), E::Dict(vec![("FontSet".into(), E::Dict(vec![("Resources".into(), E::Array(vec![font_entry(font)]))]))])), ("DocumentObjects".into(), E::Dict(vec![("TextObjects".into(), E::Array(vec![entry]))])), ]) } /// The length and `Features` of each style run of the first legacy object. fn era_runs(block: &crate::engine_data::Value) -> Vec<(i64, crate::engine_data::Value)> { use crate::engine_data::Value as E; block .path(&["DocumentObjects", "TextObjects"]) .and_then(E::as_array) .and_then(<[E]>::first) .and_then(|e| e.path(&["Model", "StyleRun", "RunArray"])) .and_then(E::as_array) .map(|rs| { rs.iter() .map(|r| (r.get("Length").and_then(E::as_i64).unwrap_or(-1), r.path(&["RunData", "StyleSheet", "Features"]).cloned().unwrap_or(E::Int(0)))) .collect() }) .unwrap_or_default() } /// Review of #8888 (CodeRabbit, psd.rs): a style-only edit (no text change) restyles the older /// Photoshop's copy too, which Photoshop re-renders from. Its font list gains the new font, the /// run keeps every key Photoshop wrote, and the lengths still cover the text. #[test] fn a_style_only_edit_restyles_the_photoshop_era_objects() { use crate::engine_data::Value as E; let prev = styled_era_txt2("Line 1", "ArialMT", 13.0, 1); let style = CharStyle { postscript_name: Some("Courier".into()), size_pt: 20.0, faux_bold: true, color: Color::rgb(0.0, 0.5, 1.0), tracking: 25.0, ..Default::default() }; let out = crate::psd::parse_txt2(&crate::psd::build_txt2(&[(0, &styled("Line 1", style))], Some(&prev))).unwrap(); let runs = era_runs(&out); assert_eq!(runs.len(), 1); let (length, f) = &runs[0]; assert_eq!(*length, 7, "the run covers the text and its trailing return"); assert_eq!(f.get("FontSize").and_then(E::as_f64), Some(20.0)); assert_eq!(f.get("FauxBold").and_then(E::as_bool), Some(true)); assert_eq!(f.get("Tracking").and_then(E::as_i64), Some(25)); assert_eq!(f.get("Leading").and_then(E::as_f64), Some(0.01), "an automatic leading keeps Photoshop's own value"); assert_eq!(f.path(&["FillColor", "Color", "Type"]).and_then(E::as_i64), Some(1)); assert_eq!(f.path(&["FillColor", "Color", "Values"]).and_then(E::as_array).map(<[E]>::len), Some(4)); assert_eq!(f.path(&["FillColor", "StreamTag"]).and_then(E::as_str), Some("SimplePaint"), "a key the file held is kept"); // The font went into the block's list and the run names it. let fonts = out.path(&["DocumentResources", "FontSet", "Resources"]).and_then(E::as_array).expect("the font list"); let index = f.get("Font").and_then(E::as_i64).and_then(|i| usize::try_from(i).ok()).expect("the font index"); let name = |i: usize| fonts.get(i).and_then(|r| r.path(&["Resource", "Identifier", "Name"])).and_then(E::as_str); assert_eq!(name(index), Some("Courier")); assert_eq!(name(0), Some("ArialMT"), "the file's font stays first"); // The text did not change. let text = out.path(&["DocumentObjects", "TextObjects"]).and_then(E::as_array).and_then(<[E]>::first).and_then(|e| e.path(&["Model", "Text"])).and_then(E::as_str); assert_eq!(text, Some("Line 1\r")); } /// A layer whose style already agrees with the file's run leaves the legacy objects as they are. #[test] fn an_unedited_style_leaves_the_photoshop_era_objects_alone() { let prev = styled_era_txt2("Line 1", "ArialMT", 13.0, 1); let same = CharStyle { postscript_name: Some("ArialMT".into()), size_pt: 13.0, ..Default::default() }; let out = crate::psd::build_txt2(&[(0, &styled("Line 1", same))], Some(&prev)); assert_eq!(era_runs(&crate::psd::parse_txt2(&prev).unwrap()), era_runs(&crate::psd::parse_txt2(&out).unwrap())); } /// Both object lists, as CodeRabbit asked: the numbered object and the older copy are restyled by /// the same style-only edit. #[test] fn a_style_only_edit_restyles_both_object_lists() { use crate::engine_data::Value as E; let mut prev = photoshop_txt2(&[(4, 1)]); prev.extend_from_slice(&styled_era_txt2("AVA", "ArialMT", 13.0, 1)); let style = CharStyle { postscript_name: Some("ArialMT".into()), size_pt: 31.0, faux_italic: true, ..Default::default() }; let out = crate::psd::parse_txt2(&crate::psd::build_txt2(&[(0, &styled("AVA", style))], Some(&prev))).unwrap(); let legacy = era_runs(&out); let first = legacy.first().map(|(l, f)| (*l, f.get("FontSize").and_then(E::as_f64), f.get("FauxItalic").and_then(E::as_bool))); assert_eq!(first, Some((4, Some(31.0), Some(true)))); let numbered = out .path(&["1", "1"]) .and_then(E::as_array) .and_then(<[E]>::first) .and_then(|o| o.path(&["0", "6", "0"])) .and_then(E::as_array) .and_then(<[E]>::first) .and_then(|r| r.path(&["0", "0", "6"])); assert_eq!(numbered.and_then(|s| s.get("1")).and_then(E::as_f64), Some(31.0), "the numbered object is restyled too"); } /// Two character runs give two style runs in the legacy copy, with the lengths of the runs. #[test] fn a_two_run_layer_gives_the_photoshop_era_copy_two_style_runs() { use crate::engine_data::Value as E; let prev = styled_era_txt2("Hello", "ArialMT", 13.0, 1); let small = CharStyle { postscript_name: Some("ArialMT".into()), size_pt: 10.0, ..Default::default() }; let big = CharStyle { postscript_name: Some("ArialMT".into()), size_pt: 40.0, ..Default::default() }; let t = runs_of("Hello", &[(2, small), (3, big)]); let runs = era_runs(&crate::psd::parse_txt2(&crate::psd::build_txt2(&[(0, &t)], Some(&prev))).unwrap()); let seen: Vec<(i64, Option)> = runs.iter().map(|(l, f)| (*l, f.get("FontSize").and_then(E::as_f64))).collect(); assert_eq!(seen, vec![(2, Some(10.0)), (4, Some(40.0))], "the last run takes the trailing return"); } /// Review of #8888: every key the legacy restyle writes is written per run, so a multi-run edit /// does not copy the first run's baseline shift, case, underline or strikethrough to the others. #[test] fn each_photoshop_era_style_run_gets_its_own_values() { use crate::engine_data::Value as E; use photocraft_doc::text::{Caps, FontBaseline}; let prev = styled_era_txt2("Hello", "ArialMT", 13.0, 1); let base = CharStyle { postscript_name: Some("ArialMT".into()), size_pt: 13.0, ..Default::default() }; let decorated = CharStyle { baseline_shift_pt: 3.0, caps: Caps::SmallCaps, baseline: FontBaseline::Superscript, underline: true, ..base.clone() }; let struck = CharStyle { strikethrough: true, caps: Caps::AllCaps, ligatures: false, discretionary_ligatures: true, ..base.clone() }; let t = runs_of("Hello", &[(2, decorated), (2, base.clone()), (1, struck)]); let runs = era_runs(&crate::psd::parse_txt2(&crate::psd::build_txt2(&[(0, &t)], Some(&prev))).unwrap()); let key = |i: usize, k: &str| runs.get(i).and_then(|(_, f)| f.get(k)).cloned(); assert_eq!(runs.iter().map(|r| r.0).collect::>(), vec![2, 2, 2]); assert_eq!(key(0, "BaselineShift").and_then(|v| v.as_f64()), Some(3.0)); assert_eq!(key(1, "BaselineShift").and_then(|v| v.as_f64()), Some(0.0), "the second run is not the first"); assert_eq!([0, 1, 2].map(|i| key(i, "FontCaps").and_then(|v| v.as_i64())), [Some(1), Some(0), Some(2)]); assert_eq!([0, 1, 2].map(|i| key(i, "FontBaseline").and_then(|v| v.as_i64())), [Some(1), Some(0), Some(0)]); assert_eq!([0, 1, 2].map(|i| key(i, "UnderlinePosition").and_then(|v| v.as_i64()).map(|v| v != 0)), [Some(true), Some(false), Some(false)]); assert_eq!([0, 1, 2].map(|i| key(i, "StrikethroughPosition").and_then(|v| v.as_i64()).map(|v| v != 0)), [Some(false), Some(false), Some(true)]); assert_eq!([0, 1, 2].map(|i| key(i, "Ligatures").and_then(|v| v.as_bool())), [Some(true), Some(true), Some(false)]); assert_eq!([0, 1, 2].map(|i| key(i, "DiscretionaryLigatures").and_then(|v| v.as_bool())), [Some(false), Some(false), Some(true)]); let _ = E::Int(0); } /// Each of those keys alone is enough to restyle the legacy copy (an edit of only that key), and /// a layer that agrees on all of them leaves the block's bytes alone. #[test] fn every_legacy_style_key_is_compared() { use photocraft_doc::text::{Caps, FontBaseline, Kerning}; let prev = styled_era_txt2("Hello", "ArialMT", 13.0, 1); let base = CharStyle { postscript_name: Some("ArialMT".into()), size_pt: 13.0, ..Default::default() }; let unedited = crate::psd::build_txt2(&[(0, &styled("Hello", base.clone()))], Some(&prev)); assert!( era_runs(&crate::psd::parse_txt2(&unedited).unwrap()) == era_runs(&crate::psd::parse_txt2(&prev).unwrap()), "an agreeing layer leaves the legacy copy as it is" ); let edits = [ CharStyle { baseline_shift_pt: 2.0, ..base.clone() }, CharStyle { caps: Caps::AllCaps, ..base.clone() }, CharStyle { baseline: FontBaseline::Subscript, ..base.clone() }, CharStyle { underline: true, ..base.clone() }, CharStyle { strikethrough: true, ..base.clone() }, CharStyle { ligatures: false, ..base.clone() }, CharStyle { discretionary_ligatures: true, ..base.clone() }, CharStyle { kerning: Kerning::Optical, ..base.clone() }, ]; for (i, edit) in edits.into_iter().enumerate() { let out = crate::psd::build_txt2(&[(0, &styled("Hello", edit))], Some(&prev)); assert!(era_runs(&crate::psd::parse_txt2(&out).unwrap()) != era_runs(&crate::psd::parse_txt2(&prev).unwrap()), "edit {i} restyles the legacy copy"); } } /// Review of #8888: the legacy `AutoKern` mode is read on import, as `/11` is for the numbered /// objects, so the layer a legacy file opens as agrees with its legacy copy. #[test] fn legacy_auto_kern_is_read_on_import() { 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); for (mode, want) in [(0, Kerning::Off), (1, Kerning::Metrics), (2, Kerning::Optical)] { let txt2 = crate::psd::parse_txt2(&styled_era_txt2("AVA", "ArialMT", 13.0, mode)).unwrap(); let mut back = crate::psd::text_layer_from_tysh(&tysh, 72.0).unwrap(); crate::psd::apply_txt2(&mut back, &tysh, &txt2); assert!( back.char_runs().iter().all(|r| r.style.kerning == want), "AutoKern {mode}: {:?}", back.char_runs().iter().map(|r| r.style.kerning).collect::>() ); } // Other text, or another object, changes nothing. let txt2 = crate::psd::parse_txt2(&styled_era_txt2("AVX", "ArialMT", 13.0, 2)).unwrap(); let mut back = crate::psd::text_layer_from_tysh(&tysh, 72.0).unwrap(); crate::psd::apply_txt2(&mut back, &tysh, &txt2); assert!(back.char_runs().iter().all(|r| r.style.kerning == Kerning::Metrics)); } /// Review of the batch (FredApps, with #9104): a style-only save rewrites `Txt2`, and the raw /// (non-BOM) strings Photoshop wrote in it, `/2 (0100)` and `/3 (01110)` under `/98`, must come /// back as the same bytes: re-encoded as `FE FF` UTF-16 they make Photoshop refuse the file. #[test] fn a_style_only_save_keeps_the_raw_strings_of_the_file() { let mut prev = photoshop_txt2(&[(4, 1)]); let version = b"/98 << /0 14 >>"; let at = prev.windows(version.len()).position(|w| w == version).expect("the version key"); prev.splice(at..at + version.len(), b"/98 << /0 14 /2 (0100) /3 (01110) >>".iter().copied()); // A size the file's runs do not have: the block is rewritten. let style = CharStyle { size_pt: 31.0, faux_bold: false, ..Default::default() }; let out = crate::psd::build_txt2(&[(0, &styled("AVA", style))], Some(&prev)); assert_ne!(out, prev, "the style edit rewrites the block"); let has = |needle: &[u8]| out.windows(needle.len()).any(|w| w == needle); assert!(has(b"(0100)") && has(b"(01110)"), "the raw strings come back as the same bytes: {:?}", String::from_utf8_lossy(&out)); assert!(!has(b"(\xfe\xff\x000\x001\x000\x000)"), "and not as UTF-16"); } /// The same for the older Photoshop's objects: restyling the legacy copy leaves a raw string /// elsewhere in the block as the file wrote it. #[test] fn restyling_the_photoshop_era_objects_keeps_the_raw_strings_of_the_file() { use crate::engine_data::Value as E; let era = crate::psd::parse_txt2(&styled_era_txt2("Line 1", "ArialMT", 13.0, 1)).unwrap(); let E::Dict(mut items) = era else { panic!("a dictionary") }; items.push(("98".into(), E::Dict(vec![("0".into(), E::Int(14)), ("2".into(), E::RawString("0100".into()))]))); let prev = crate::engine_data::write_bare(&items); let style = CharStyle { postscript_name: Some("ArialMT".into()), size_pt: 24.0, ..Default::default() }; let out = crate::psd::build_txt2(&[(0, &styled("Line 1", style))], Some(&prev)); assert!(out.windows(6).any(|w| w == b"(0100)"), "{:?}", String::from_utf8_lossy(&out)); let runs = era_runs(&crate::psd::parse_txt2(&out).unwrap()); assert_eq!(runs.first().and_then(|(_, f)| f.get("FontSize")).and_then(E::as_f64), Some(24.0), "and the legacy copy was restyled"); } #[test] fn glyph_outlines_preserves_style_indices() { let mut e = TextEngine::new(); let t = TextLayer { text: "AB".into(), transform: Affine::translate(10.0, 30.0), runs: vec![ photocraft_doc::text::TextRun { len: 1, style: CharStyle { color: Color::rgb(1.0, 0.0, 0.0), ..Default::default() } }, photocraft_doc::text::TextRun { len: 1, style: CharStyle { color: Color::rgb(0.0, 1.0, 0.0), ..Default::default() } }, ], ..Default::default() }; let layout = e.layout(&t, 72.0); let glyphs = crate::render::glyph_outlines(&layout, &t.transform, None); assert!(glyphs.len() >= 2); assert_eq!(glyphs[0].style, 0); assert_eq!(glyphs[1].style, 1); } /// Colour passes are grouped in one walk of the glyphs: with a colour per letter, every placed /// glyph lands in exactly one pass and each pass holds only its own glyphs. #[test] fn colour_passes_hold_each_glyph_once() { let mut e = TextEngine::new(); let text = "abcdeghjkm".repeat(200); let runs = text .char_indices() .map(|(i, ch)| TextRun { len: ch.len_utf8(), style: CharStyle { size_pt: 12.0, color: Color::rgb(i as f32 / 2000.0, 0.5, 0.5), ..Default::default() } }) .collect(); let t = TextLayer { text, runs, ..Default::default() }; let l = e.layout(&t, 72.0); let passes = crate::render::visible_color_passes(&l, None); assert!(passes.len() > 1000, "{} passes", passes.len()); let mut seen: Vec = passes.iter().flat_map(|(_, _, p)| p.glyphs.iter().copied()).collect(); seen.sort_unstable(); assert_eq!(seen, (0..l.glyphs.len()).collect::>()); for (c, _, p) in &passes { assert!(p.glyphs.iter().all(|&i| l.styles[l.glyphs[i].style as usize].color == *c)); } }