From c71e3f94ad851384c1d1a427f9306ede2ced1557 Mon Sep 17 00:00:00 2001 From: adrdsouza <102761952+adrdsouza@users.noreply.github.com> Date: Thu, 15 Oct 2026 16:27:30 +0530 Subject: [PATCH 1/2] fix(psd): refine legacy point anchors after font resolution --- crates/io/src/affinity.rs | 2 +- crates/io/src/lib.rs | 3 +- crates/io/src/text_import.rs | 15 +++-- crates/text/src/psd.rs | 39 ++++++++++- crates/text/src/tests.rs | 123 ++++++++++++++++++++++++++--------- docs/file-format-parity.md | 3 +- 6 files changed, 144 insertions(+), 41 deletions(-) diff --git a/crates/io/src/affinity.rs b/crates/io/src/affinity.rs index efba868be0..9c4fd72b99 100644 --- a/crates/io/src/affinity.rs +++ b/crates/io/src/affinity.rs @@ -43,7 +43,7 @@ pub(crate) fn import(name: &str, bytes: &[u8]) -> Result // Type layers draw from their model, within the import's raster bound: the sizes and // frames come from an untrusted file. let mut document = b.doc; - crate::text_import::prepare(&mut document); + crate::text_import::prepare(&mut document, None); // Save never writes back over the Affinity file. Ok(ImportResult { document, warnings, source_read_only: true, preview_only: false }) } diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index a620ddcf33..5dee7d88b7 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -283,7 +283,8 @@ fn import_stages(name: &str, bytes: &[u8], ctl: &photocraft_raster::Interrupt, m ctl.progress(0.05); let (mut document, warnings) = psd_import::psd_to_document_decoded(&file, ctl, merged).ok_or(IoError::Cancelled)?; document.name = name.to_string(); - text_import::prepare(&mut document); + let txt2 = file.global_blocks.iter().find(|b| &b.key == b"Txt2").and_then(|b| photocraft_text::psd::parse_txt2(&b.data)); + text_import::prepare(&mut document, txt2.as_ref()); return Ok(ImportResult { document, warnings, source_read_only: false, preview_only: false }); } if affinity::is_affinity(bytes) || affinity::has_extension(name) { diff --git a/crates/io/src/text_import.rs b/crates/io/src/text_import.rs index 9272f0b5b7..0522f39e90 100644 --- a/crates/io/src/text_import.rs +++ b/crates/io/src/text_import.rs @@ -28,8 +28,14 @@ fn render_bounded(eng: &mut photocraft_text::TextEngine, t: &mut TextLayer, dpi: /// - a type layer without pixels (ag-psd, GIMP and other writers leave the image data out and /// let Photoshop re-render on open) is rendered from its model, instead of staying empty /// until it is edited. -pub(crate) fn prepare(doc: &mut Document) { - fn visit(layers: &mut [Layer], eng: &mut photocraft_text::TextEngine, dpi: f32, format: photocraft_color::PixelFormat) { +pub(crate) fn prepare(doc: &mut Document, txt2: Option<&photocraft_text::engine_data::Value>) { + fn visit( + layers: &mut [Layer], + eng: &mut photocraft_text::TextEngine, + dpi: f32, + format: photocraft_color::PixelFormat, + txt2: Option<&photocraft_text::engine_data::Value>, + ) { for l in layers { match &mut l.content { LayerContent::Text(t) => { @@ -56,11 +62,12 @@ pub(crate) fn prepare(doc: &mut Document) { if changed { t.sync_summary(); } + photocraft_text::psd::refine_legacy_point_origin(t, dpi, eng, txt2); if t.cache.is_none() { render_bounded(eng, t, dpi, format); } } - LayerContent::Group(g) => visit(&mut g.children, eng, dpi, format), + LayerContent::Group(g) => visit(&mut g.children, eng, dpi, format, txt2), _ => {} } } @@ -68,5 +75,5 @@ pub(crate) fn prepare(doc: &mut Document) { let dpi = doc.resolution_dpi; let format = doc.pixel_format(); let mut eng = photocraft_text::shared().lock().unwrap_or_else(|e| e.into_inner()); - visit(&mut doc.layers, &mut eng, dpi, format); + visit(&mut doc.layers, &mut eng, dpi, format, txt2); } diff --git a/crates/text/src/psd.rs b/crates/text/src/psd.rs index 0092fcf92e..1c9867eba9 100644 --- a/crates/text/src/psd.rs +++ b/crates/text/src/psd.rs @@ -376,12 +376,43 @@ pub fn text_layer_from_tysh(data: &[u8], dpi: f32) -> Option { // writing the TySh all agree on the visible position (#1469). if let Some([x, y]) = legacy_point_origin(&t) { let [a, b, c, d, e, f] = layer.transform.m; - layer.transform.m = [a, b, c, d, a * x + c * y + e, b * x + d * y + f]; + let corrected = [a, b, c, d, a * x + c * y + e, b * x + d * y + f]; + if corrected.iter().all(|v| v.is_finite()) { + layer.transform.m = corrected; + } } layer.sync_summary(); Some(layer) } +/// Refines a legacy point-text estimate after imported fonts and styles have been resolved. +/// Uses the rendering engine's font context; an accepted `Txt2` frame stays authoritative. +pub fn refine_legacy_point_origin(layer: &mut TextLayer, dpi: f32, engine: &mut crate::TextEngine, txt2: Option<&E>) { + let Some(raw) = layer.psd_raw.clone() else { return }; + let Some(t) = parse_tysh(&raw) else { return }; + let Some([x, y]) = legacy_point_origin(&t) else { return }; + if !matches!(layer.shape, TextShape::Point) { + return; + } + let guessed = t.transform.mul(&Affine::translate(x, y)); + if layer.transform != guessed { + return; + } + let Some(bounds) = engine.layout(layer, dpi).bounds() else { return }; + if !bounds.iter().all(|v| v.is_finite()) { + return; + } + let corrected = t.transform.mul(&Affine::translate(x - f64::from(bounds[0]), y - f64::from(bounds[1]))); + if corrected.m.iter().all(|v| v.is_finite()) { + layer.transform = corrected; + // A valid frame can coincide with the initial estimate; numeric equality cannot tell + // whether it was used. Reapply it after the fallback so it always wins. + if let Some(txt2) = txt2 { + apply_txt2_frame(layer, &raw, txt2); + } + } +} + /// The local origin to fold into a legacy point-text transform, if present. /// /// Photoshop normally keeps point text close to its PointBase. The affected legacy files instead @@ -412,7 +443,11 @@ fn legacy_point_origin(t: &TySh) -> Option<[f64; 2]> { // A local bounding-box origin in the thousands and a transform origin beyond a normal canvas // are the signature of this old writer. Requiring that the bounding-box origin is closer to // the document prevents applying the correction again after export/import. - (bounds[0].abs().max(bounds[1].abs()) > 1024.0 && origin_distance > 4096.0 && shifted_distance < origin_distance).then_some([bounds[0], bounds[1]]) + if !(bounds[0].abs().max(bounds[1].abs()) > 1024.0 && origin_distance > 4096.0 && shifted_distance < origin_distance) { + return None; + } + + Some([bounds[0], bounds[1]]) } fn descriptor_rect(text: &Descriptor, key: &str) -> Option<[f64; 4]> { diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 13de665ccd..9eaedb6f8a 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -1222,47 +1222,106 @@ fn psd_round_trips_antialias_opentype_and_warp() { } /// #1469: Some older PSD writers combine a zero PointBase with an enormous local ink origin. -/// The TySh transform cancels that origin, so importing it as a zero-based layout puts text far -/// off-canvas. Fold the descriptor origin into the imported transform and preserve it on a TySh -/// round trip so every editing and export path uses the same position. +/// Recover the saved ink origin relative to style-aware layout bounds, 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::{Descriptor, Id, Value as D}; - let source = styled("Name", CharStyle { font_family: "Inter".into(), size_pt: 120.0, ..Default::default() }); - let mut tysh = crate::psd::parse_tysh(&crate::psd::build_tysh(&source, 72.0, None)).unwrap(); - tysh.transform = Affine { m: [4.1667, 0.0, 0.0, 4.1667, -32375.0, -32887.5] }; - let ink = [8050.0, 8160.0, 8220.0, 8300.0]; - let rect = |class| { - D::Descriptor( - Descriptor::new(class) - .with("Left", D::UnitFloat { unit: *b"#Pnt", value: ink[0] }) - .with("Top ", D::UnitFloat { unit: *b"#Pnt", value: ink[1] }) - .with("Rght", D::UnitFloat { unit: *b"#Pnt", value: ink[2] }) - .with("Btom", D::UnitFloat { unit: *b"#Pnt", value: ink[3] }), - ) - }; - tysh.text.items.retain(|(key, _)| !key.is("bounds") && !key.is("boundingBox")); - tysh.text.items.push((Id::new("bounds"), rect("bounds"))); - tysh.text.items.push((Id::new("boundingBox"), rect("boundingBox"))); - let data = crate::psd::write_tysh(&tysh); + let original = Affine { m: [4.1667, 0.0, 0.0, 4.1667, -32375.0, -32887.5] }; + let saved_ink_origin = [8050.0, 8160.0]; + 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 native = TextEngine::new().layout(&source, 72.0).bounds().unwrap(); + let saved_ink = [ + saved_ink_origin[0], + saved_ink_origin[1], + saved_ink_origin[0] + f64::from(native[2] - native[0]), + saved_ink_origin[1] + f64::from(native[3] - native[1]), + ]; + let rect = |class| { + D::Descriptor( + Descriptor::new(class) + .with("Left", D::UnitFloat { unit: *b"#Pnt", value: saved_ink[0] }) + .with("Top ", D::UnitFloat { unit: *b"#Pnt", value: saved_ink[1] }) + .with("Rght", D::UnitFloat { unit: *b"#Pnt", value: saved_ink[2] }) + .with("Btom", D::UnitFloat { unit: *b"#Pnt", value: saved_ink[3] }), + ) + }; + let mut tysh = crate::psd::parse_tysh(&crate::psd::build_tysh(&source, 72.0, None)).unwrap(); + tysh.transform = original; + tysh.text.items.retain(|(key, _)| !key.is("bounds") && !key.is("boundingBox")); + tysh.text.items.push((Id::new("bounds"), rect("bounds"))); + tysh.text.items.push((Id::new("boundingBox"), rect("boundingBox"))); + let data = crate::psd::write_tysh(&tysh); - let imported = crate::psd::text_layer_from_tysh(&data, 72.0).unwrap(); - let expected = [4.1667, 0.0, 0.0, 4.1667, 1166.935, 1112.772]; - for (got, want) in imported.transform.m.iter().zip(expected) { - assert!((got - want).abs() < 0.001, "{:?}", imported.transform.m); - } - let (_, rendered) = TextEngine::new().render(&imported, 72.0, PixelFormat::RGBA8); - let placed = rendered.surface.content_bounds(); - assert!(!placed.is_empty(), "the normalized layer renders"); - assert!((1000..3000).contains(&placed.x0) && (500..2000).contains(&placed.y0), "{placed:?}"); + let mut imported = crate::psd::text_layer_from_tysh(&data, 72.0).unwrap(); + imported.psd_raw = Some(std::sync::Arc::new(data.clone())); + let mut engine = TextEngine::new(); + crate::psd::refine_legacy_point_origin(&mut imported, 72.0, &mut engine, None); + assert_eq!(imported.paragraph_runs()[0].style.align, align); + let expected_anchor = original.apply(photocraft_geom::Point::new(saved_ink_origin[0], saved_ink_origin[1])); + let recovered_ink = imported.transform.apply(photocraft_geom::Point::new(f64::from(native[0]), f64::from(native[1]))); + assert!((recovered_ink.x - expected_anchor.x).abs() < 0.001, "{align:?}: {recovered_ink:?} vs {expected_anchor:?}"); + assert!((recovered_ink.y - expected_anchor.y).abs() < 0.001, "{align:?}: {recovered_ink:?} vs {expected_anchor:?}"); - let round_trip = crate::psd::text_layer_from_tysh(&crate::psd::build_tysh(&imported, 72.0, Some(ink.map(|v| v as f32))), 72.0).unwrap(); - for (got, want) in round_trip.transform.m.iter().zip(expected) { - assert!((got - want).abs() < 0.001, "{:?}", round_trip.transform.m); + 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 expected = source.clone(); + expected.transform = original.mul(&Affine::translate(saved_ink_origin[0] - f64::from(native[0]), saved_ink_origin[1] - f64::from(native[1]))); + let (_, expected_pixels) = engine.render(&expected, 72.0, PixelFormat::RGBA8); + assert_eq!(placed, expected_pixels.surface.content_bounds(), "{align:?}: raster placement"); + + let round_trip_data = crate::psd::build_tysh(&imported, 72.0, Some(native)); + let round_trip = crate::psd::text_layer_from_tysh(&round_trip_data, 72.0).unwrap(); + let round_trip_ink = TextEngine::new().layout(&round_trip, 72.0).bounds().unwrap(); + let round_trip_origin = round_trip.transform.apply(photocraft_geom::Point::new(f64::from(round_trip_ink[0]), f64::from(round_trip_ink[1]))); + assert!((round_trip_origin.x - expected_anchor.x).abs() < 1.0, "{align:?}: {round_trip_origin:?}"); + assert!((round_trip_origin.y - expected_anchor.y).abs() < 1.0, "{align:?}: {round_trip_origin:?}"); + + // A valid frame may equal the old ink-corner estimate. It must still win over refinement. + let framed = with_text_index(&data, 0); + let txt2 = crate::psd::parse_txt2(&txt2_with_frame("Name", Some(saved_ink_origin), [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"); } } +#[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 native = engine.layout(&source, 72.0).bounds().unwrap(); + let mut t = crate::psd::parse_tysh(&crate::psd::build_tysh(&source, 72.0, None)).unwrap(); + t.transform = Affine::translate(-8000.0, -8000.0); + let saved = [8100.0, 8200.0, 8100.0 + f64::from(native[2] - native[0]), 8200.0 + f64::from(native[3] - native[1])]; + let raw = with_bounds(&crate::psd::write_tysh(&t), saved); + let mut t = crate::psd::parse_tysh(&raw).unwrap(); + let bounds = t.text.get("bounds").unwrap().clone(); + t.text.items.retain(|(k, _)| !k.is("boundingBox")); + t.text.items.push((photocraft_psd::descriptor::Id::new("boundingBox"), bounds)); + let raw = crate::psd::write_tysh(&t); + 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 bounds = engine.layout(&imported, 72.0).bounds().unwrap(); + let placed = imported.transform.apply(photocraft_geom::Point::new(f64::from(bounds[0]), f64::from(bounds[1]))); + assert!((placed.x - 100.0).abs() < 0.001 && (placed.y - 200.0).abs() < 0.001, "{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] diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index ba74623709..28cf1e337a 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-13 · **Change:** minor (earlier: PDF write one flattened page via Save As / Save a Copy / CLI, #3401; CMYK Invert, Posterize and Threshold on the inks; smart filter masks on PSD open and save; Photoshop-oracle floor 188/258; lossy DNG, compression 34892, decodes with its levels and LinearizationTable applied to the stored 8-bit samples, FILE-215-11; CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; earlier: CMYK Invert, Posterize and Threshold on the inks; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding) · **Target:** Adobe Photoshop 2026 +> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-15 · **Change:** minor (earlier: PDF write one flattened page via Save As / Save a Copy / CLI, #3401; CMYK Invert, Posterize and Threshold on the inks; smart filter masks on PSD open and save; Photoshop-oracle floor 188/258; lossy DNG, compression 34892, decodes with its levels and LinearizationTable applied to the stored 8-bit samples, FILE-215-11; CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; earlier: CMYK Invert, Posterize and Threshold on the inks; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding) · **Target:** Adobe Photoshop 2026 Every format Photoshop 2026 (27.11.0) reads or writes, with PhotoCraft's support, fidelity and tests. Photoshop's list comes from its `Info.plist` `CFBundleDocumentTypes`, the PiPL read/write @@ -142,6 +142,7 @@ remaining gaps in #1606 stay tracked with per-sample corpus ceilings. | Date | Change | Summary | |---|---|---| +| 2026-10-15 | minor | Legacy PSD point-text placement is refined after font resolution using the rendering engine; centered/right alignment keeps its layout offset, while valid `Txt2` frames remain authoritative. Synthetic alignment, font-context and round-trip regressions cover this fallback. | | 2026-10-14 | minor | PSD smart filter masks: Photoshop stores a smart object's filtered pixels without its filter mask and mixes the unfiltered ones (`FEid`) back in; import now mixes them and export writes them that way, so sf-filter-mask-hard, -gradient and sf-stack-blend-and-mask pass; Photoshop-oracle floor 185 → 188 of 258 | | 2026-10-13 | minor | Lossy DNG (compression 34892) decodes: 8-bit baseline JPEG tiles and strips, CFA and LinearRaw, partial edge tiles; the stored values take the normal levels and LinearizationTable (FILE-215-11, lossy DNG part) | | 2026-10-14 | minor | Corpus floors raised to the measured results of the CMYK, Lab, 32-bit, Levels, Threshold, Posterize, feather and pass-through fixes landed together: Photoshop-authored 165 → 185 of 258, io 146 → 148 of 170, psd-tools 242 → 249 of 309 | From fd8a2856d42f4f52537c0e35ede89ffcb1aeb17d Mon Sep 17 00:00:00 2001 From: David Mann Date: Thu, 22 Oct 2026 21:29:29 +0200 Subject: [PATCH 2/2] Integration: line the layout's ink box up with the TySh ink box (#6094) refine_legacy_point_origin moved the layout's logical box (line x0, baseline - ascent) onto the TySh boundingBox, but Photoshop writes boundingBox as the tight ink box (Arial 120 pt "..." has bounds top -102.98, boundingBox top -11). Legacy point text whose ink starts below the ascent line ("...", "_", lowercase) opened up to ~116 pt too low. Match like with like: use the layout's unrounded text-space ink bounds (new render::ink_bounds). The PR's tests built boundingBox from the logical box, so they now write bounds = layout box and boundingBox = ink box and pin the exact anchor; legacy_origin_lines_up_ink_with_the_ ink_bounding_box covers "Name", "..." and "_" and fails without this. Co-authored-by: bogusyogi <102761952+bogusyogi@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/text/src/psd.rs | 9 ++- crates/text/src/render.rs | 8 +++ crates/text/src/tests.rs | 127 ++++++++++++++++++++++---------------- 3 files changed, 87 insertions(+), 57 deletions(-) diff --git a/crates/text/src/psd.rs b/crates/text/src/psd.rs index ed050940ce..43b99b6d45 100644 --- a/crates/text/src/psd.rs +++ b/crates/text/src/psd.rs @@ -412,11 +412,14 @@ pub fn refine_legacy_point_origin(layer: &mut TextLayer, dpi: f32, engine: &mut if layer.transform != guessed { return; } - let Some(bounds) = engine.layout(layer, dpi).bounds() else { return }; - if !bounds.iter().all(|v| v.is_finite()) { + // `boundingBox` is the tight ink box (Photoshop writes the layout box as `bounds`), so match + // it with the layout's ink, not its logical box: text whose ink starts below the ascent + // line (`...`, `_`, lowercase) would otherwise land too low. + let Some(ink) = crate::render::ink_bounds(&engine.layout(layer, dpi)) else { return }; + if !ink.iter().all(|v| v.is_finite()) { return; } - let corrected = t.transform.mul(&Affine::translate(x - f64::from(bounds[0]), y - f64::from(bounds[1]))); + let corrected = t.transform.mul(&Affine::translate(x - ink[0], y - ink[1])); if corrected.m.iter().all(|v| v.is_finite()) { layer.transform = corrected; // A valid frame can coincide with the initial estimate; numeric equality cannot tell diff --git a/crates/text/src/render.rs b/crates/text/src/render.rs index fe32982d9d..963fe08c8e 100644 --- a/crates/text/src/render.rs +++ b/crates/text/src/render.rs @@ -287,6 +287,14 @@ fn rect_from_bounds([x0, y0, x1, y1]: [f64; 4]) -> Rect { Rect::new(x0 as i32, y0 as i32, x1 as i32, y1 as i32) } +/// Text-space bounds of the drawn glyph outlines and decorations, unrounded (`[x0, y0, x1, y1]`), +/// or `None` when nothing is drawn. +pub fn ink_bounds(layout: &TextLayout) -> Option<[f64; 4]> { + let mut b = Bounds::default(); + draw(layout, &Xform::IDENTITY, &mut b, None, None); + b.rect +} + /// [`ink_rect`] of warped text. pub fn ink_rect_warped(layout: &TextLayout, transform: &Affine, warp: Option<&Warp>) -> Rect { let mut b = Bounds::default(); diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index 30a892a675..0267199bfe 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -1650,69 +1650,79 @@ fn psd_round_trips_antialias_opentype_and_warp() { } } +/// 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. -/// Recover the saved ink origin relative to style-aware layout bounds, so point alignment anchors -/// stay fixed for left, centered and right text through import and a TySh round trip. +/// 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::{Descriptor, Id, Value as D}; + use photocraft_psd::descriptor::Value as D; let original = Affine { m: [4.1667, 0.0, 0.0, 4.1667, -32375.0, -32887.5] }; - let saved_ink_origin = [8050.0, 8160.0]; + // 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 native = TextEngine::new().layout(&source, 72.0).bounds().unwrap(); - let saved_ink = [ - saved_ink_origin[0], - saved_ink_origin[1], - saved_ink_origin[0] + f64::from(native[2] - native[0]), - saved_ink_origin[1] + f64::from(native[3] - native[1]), - ]; - let rect = |class| { - D::Descriptor( - Descriptor::new(class) - .with("Left", D::UnitFloat { unit: *b"#Pnt", value: saved_ink[0] }) - .with("Top ", D::UnitFloat { unit: *b"#Pnt", value: saved_ink[1] }) - .with("Rght", D::UnitFloat { unit: *b"#Pnt", value: saved_ink[2] }) - .with("Btom", D::UnitFloat { unit: *b"#Pnt", value: saved_ink[3] }), - ) - }; - let mut tysh = crate::psd::parse_tysh(&crate::psd::build_tysh(&source, 72.0, None)).unwrap(); - tysh.transform = original; - tysh.text.items.retain(|(key, _)| !key.is("bounds") && !key.is("boundingBox")); - tysh.text.items.push((Id::new("bounds"), rect("bounds"))); - tysh.text.items.push((Id::new("boundingBox"), rect("boundingBox"))); - let data = crate::psd::write_tysh(&tysh); + let 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())); - let mut engine = TextEngine::new(); crate::psd::refine_legacy_point_origin(&mut imported, 72.0, &mut engine, None); assert_eq!(imported.paragraph_runs()[0].style.align, align); - let expected_anchor = original.apply(photocraft_geom::Point::new(saved_ink_origin[0], saved_ink_origin[1])); - let recovered_ink = imported.transform.apply(photocraft_geom::Point::new(f64::from(native[0]), f64::from(native[1]))); - assert!((recovered_ink.x - expected_anchor.x).abs() < 0.001, "{align:?}: {recovered_ink:?} vs {expected_anchor:?}"); - assert!((recovered_ink.y - expected_anchor.y).abs() < 0.001, "{align:?}: {recovered_ink:?} vs {expected_anchor:?}"); + 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 expected = source.clone(); - expected.transform = original.mul(&Affine::translate(saved_ink_origin[0] - f64::from(native[0]), saved_ink_origin[1] - f64::from(native[1]))); - let (_, expected_pixels) = engine.render(&expected, 72.0, PixelFormat::RGBA8); + 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 round_trip_data = crate::psd::build_tysh(&imported, 72.0, Some(native)); - let round_trip = crate::psd::text_layer_from_tysh(&round_trip_data, 72.0).unwrap(); - let round_trip_ink = TextEngine::new().layout(&round_trip, 72.0).bounds().unwrap(); - let round_trip_origin = round_trip.transform.apply(photocraft_geom::Point::new(f64::from(round_trip_ink[0]), f64::from(round_trip_ink[1]))); - assert!((round_trip_origin.x - expected_anchor.x).abs() < 1.0, "{align:?}: {round_trip_origin:?}"); - assert!((round_trip_origin.y - expected_anchor.y).abs() < 1.0, "{align:?}: {round_trip_origin:?}"); + 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(saved_ink_origin), [1.0, 0.0, 0.0, 1.0, 0.0, 0.0])).unwrap(); + 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); @@ -1722,6 +1732,25 @@ fn legacy_tysh_point_origin_imports_and_round_trips_on_canvas() { } } +/// #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(); @@ -1730,25 +1759,15 @@ fn legacy_origin_uses_the_rendering_engines_registered_face() { 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 native = engine.layout(&source, 72.0).bounds().unwrap(); - let mut t = crate::psd::parse_tysh(&crate::psd::build_tysh(&source, 72.0, None)).unwrap(); - t.transform = Affine::translate(-8000.0, -8000.0); - let saved = [8100.0, 8200.0, 8100.0 + f64::from(native[2] - native[0]), 8200.0 + f64::from(native[3] - native[1])]; - let raw = with_bounds(&crate::psd::write_tysh(&t), saved); - let mut t = crate::psd::parse_tysh(&raw).unwrap(); - let bounds = t.text.get("bounds").unwrap().clone(); - t.text.items.retain(|(k, _)| !k.is("boundingBox")); - t.text.items.push((photocraft_psd::descriptor::Id::new("boundingBox"), bounds)); - let raw = crate::psd::write_tysh(&t); + 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 bounds = engine.layout(&imported, 72.0).bounds().unwrap(); - let placed = imported.transform.apply(photocraft_geom::Point::new(f64::from(bounds[0]), f64::from(bounds[1]))); - assert!((placed.x - 100.0).abs() < 0.001 && (placed.y - 200.0).abs() < 0.001, "{placed:?}"); + 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)