fix(psd): type layer bytes match Photoshop (TySh bounds, AntiAlias, version info)

- TySh: the four values after the warp descriptor are big-endian f32, not
  i32. Written as Photoshop writes them: zeros for unwarped type, the
  text-space bounds for warped type (corpus: warp-arc-50.psd, unwarped
  text oracles).
- EngineData AntiAlias numbers as Photoshop writes them, on write and on
  read (when there is no AntA descriptor): 0 None, 1 Crisp, 2 Strong,
  3 Smooth, 4 Sharp (corpus text/antialias-*.psd). Platform modes, which
  no corpus file confirms, write Smooth and keep their exact mode in AntA.
- Resource 1057: writer "Adobe Photoshop", reader "Adobe Photoshop 2026",
  no NUL terminator, byte for byte what all 258 Photoshop 2026 oracles
  write.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Brandon ThomasandClaude Opus 5.5 committed 2026-10-12 04:45:15 -04:00
1 parent 62f4b9d453
commit 9ea1268cac
5 files changed
+167 -17

No files matched your search

+2 -2
View File
@@ -112,8 +112,8 @@ fn tysh_with_font(text: &str, runs: &[Run], box_bounds: Option<[f64; 4]>, transf
.with("boundingBox", rect("boundingBox"))
.with("TextIndex", D::Integer(0))
.with("EngineData", D::RawData(photocraft_text::engine_data::write(&engine_data(text, runs, box_bounds, font))));
let bounds = [ink[0].floor() as i32, ink[1].floor() as i32, ink[2].ceil() as i32, ink[3].ceil() as i32];
write_tysh(&TySh { transform, text: desc, warp: None, bounds })
// Unwarped type: Photoshop writes four zero f32s after the warp descriptor.
write_tysh(&TySh { transform, text: desc, warp: None, bounds: [0.0; 4] })
}
struct Case {
@@ -0,0 +1,74 @@
//! Type-layer bytes we write match what Photoshop 2026 writes for the same settings, checked
//! against Photoshop-authored files in `corpus/photoshop` (feature `corpus`; fetched by
//! `cargo xtask corpus --all`, a missing corpus fails):
//! - EngineData `AntiAlias` numbers for every anti-aliasing mode;
//! - the four f32 values that end a `TySh` block (zeros unwarped, text-space bounds warped);
//! - the version info resource (1057).
#![cfg(feature = "corpus")]
use std::path::{Path, PathBuf};
use photocraft_psd::PsdFile;
use photocraft_psd::descriptor::Value as D;
use photocraft_text::engine_data::Value as E;
fn corpus(rel: &str) -> PsdFile {
let path: PathBuf = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/photoshop").join(rel);
let bytes = std::fs::read(&path).unwrap_or_else(|e| panic!("{}: {e} (run `cargo xtask corpus --all`)", path.display()));
PsdFile::from_bytes(&bytes).unwrap()
}
fn first_tysh(f: &PsdFile) -> Vec<u8> {
f.layers().iter().find_map(|l| l.block(b"TySh")).map(|b| b.data.clone()).unwrap()
}
fn engine_antialias(tysh: &[u8]) -> Option<f64> {
let t = photocraft_text::psd::parse_tysh(tysh)?;
photocraft_text::psd::engine_data(&t.text)?.path(&["EngineDict", "AntiAlias"]).and_then(E::as_f64)
}
fn rect(t: &photocraft_text::psd::TySh, key: &str) -> [f32; 4] {
let Some(D::Descriptor(d)) = t.text.get(key) else { panic!("no {key}") };
["Left", "Top ", "Rght", "Btom"].map(|k| match d.get(k) {
Some(D::UnitFloat { value, .. }) => *value as f32,
other => panic!("{k}: {other:?}"),
})
}
#[test]
fn engine_data_antialias_matches_photoshop() {
for name in ["none", "crisp", "strong", "smooth", "sharp"] {
let raw = first_tysh(&corpus(&format!("text/antialias-{name}.psd")));
let mut layer = photocraft_text::psd::text_layer_from_tysh(&raw, 72.0).unwrap();
layer.psd_raw = None;
let ours = photocraft_text::psd::build_tysh(&layer, 72.0, None);
assert_eq!(engine_antialias(&ours), engine_antialias(&raw), "{name}");
}
}
#[test]
fn tysh_trailing_bounds_match_photoshop() {
for (file, warped) in [("text/warp-arc-50.psd", true), ("text/antialias-smooth.psd", false)] {
let raw = first_tysh(&corpus(file));
let theirs = photocraft_text::psd::parse_tysh(&raw).unwrap();
let mut layer = photocraft_text::psd::text_layer_from_tysh(&raw, 72.0).unwrap();
assert_eq!(layer.warp.is_some(), warped, "{file}");
layer.psd_raw = None;
let ours = photocraft_text::psd::parse_tysh(&photocraft_text::psd::build_tysh(&layer, 72.0, Some(rect(&theirs, "bounds")))).unwrap();
assert_eq!(ours.bounds, theirs.bounds, "{file}");
if warped {
assert_eq!(theirs.bounds, rect(&theirs, "bounds"), "{file}");
} else {
assert_eq!(theirs.bounds, [0.0; 4], "{file}");
}
}
}
#[test]
fn version_info_matches_photoshop() {
for file in ["text/antialias-smooth.psd", "text/warp-arc-50.psd"] {
let theirs = corpus(file);
assert_eq!(photocraft_psd::resources::version_info_resource(true).data, theirs.resource(1057).unwrap().data, "{file}");
}
}
+17 -3
View File
@@ -250,15 +250,16 @@ pub(crate) fn write_section(out: &mut Vec<u8>, res: &[ImageResource]) -> Result<
out.end_len(at, false)
}
/// Builds the data for a 1057 version info resource.
/// Builds the data for a 1057 version info resource, with the writer and reader names Photoshop
/// 2026 writes ("Adobe Photoshop", "Adobe Photoshop 2026"), without a NUL terminator.
pub fn version_info_resource(has_real_merged_data: bool) -> ImageResource {
ImageResource::new(
ids::VERSION_INFO,
VersionInfo {
version: 1,
has_real_merged_data,
writer: UnicodeString::new_nul("Adobe Photoshop"),
reader: UnicodeString::new_nul("Adobe Photoshop CS6"),
writer: UnicodeString::new("Adobe Photoshop"),
reader: UnicodeString::new("Adobe Photoshop 2026"),
file_version: 1,
}
.to_bytes(),
@@ -365,4 +366,17 @@ mod tests {
other => panic!("{other:?}"),
}
}
/// Byte for byte what Photoshop 2026 writes (every Photoshop-authored oracle in the corpus):
/// u32 count + UTF-16BE, no NUL terminator.
#[test]
fn version_info_matches_photoshop_bytes() {
let mut want = vec![0, 0, 0, 1, 1];
for s in ["Adobe Photoshop", "Adobe Photoshop 2026"] {
want.extend_from_slice(&(s.len() as u32).to_be_bytes());
want.extend(s.encode_utf16().flat_map(u16::to_be_bytes));
}
want.extend_from_slice(&[0, 0, 0, 1]);
assert_eq!(version_info_resource(true).data, want);
}
}
+34 -12
View File
@@ -24,8 +24,10 @@ pub struct TySh {
pub transform: Affine,
pub text: Descriptor,
pub warp: Option<Descriptor>,
/// The four trailing i32 values (left, top, right, bottom).
pub bounds: [i32; 4],
/// The four trailing big-endian f32 values (left, top, right, bottom) after the warp
/// descriptor. Photoshop writes zeros for unwarped type and the text-space bounds (the
/// `bounds` descriptor's rectangle) for warped type.
pub bounds: [f32; 4],
}
/// Parses a `TySh` block.
@@ -42,10 +44,10 @@ pub fn parse_tysh(data: &[u8]) -> Option<TySh> {
None => (None, 0),
};
let tail = rest.get(wused..).unwrap_or(&[]);
let mut bounds = [0i32; 4];
let mut bounds = [0f32; 4];
for (i, b) in bounds.iter_mut().enumerate() {
if let Some(x) = tail.get(i * 4..i * 4 + 4) {
*b = i32::from_be_bytes(x.try_into().unwrap_or([0; 4]));
*b = f32::from_be_bytes(x.try_into().unwrap_or([0; 4]));
}
}
Some(TySh { transform: Affine { m }, text: text.descriptor, warp, bounds })
@@ -221,7 +223,11 @@ pub fn text_layer_from_tysh(data: &[u8], dpi: f32) -> Option<TextLayer> {
Some("AnSt" | "antiAliasStrong") => AntiAlias::Strong,
Some("antiAliasPlatformGray") => AntiAlias::Windows,
Some("antiAliasPlatformLCD") => AntiAlias::WindowsLcd,
_ => AntiAlias::Smooth,
Some(_) => AntiAlias::Smooth,
// No `AntA`: the engine data's mode says it.
None => {
engine_data(&t.text).and_then(|e| e.path(&["EngineDict", "AntiAlias"]).and_then(E::as_f64)).and_then(engine_antialias).unwrap_or(AntiAlias::Smooth)
}
};
if let Some(w) = &t.warp {
let style = enum_value(w, "warpStyle").unwrap_or_else(|| "warpNone".into());
@@ -979,12 +985,13 @@ pub fn build_engine_data(layer: &TextLayer, template: Option<E>, dpi: f32) -> E
"AntiAlias",
E::Int(match layer.antialias {
AntiAlias::None => 0,
AntiAlias::Sharp => 1,
AntiAlias::Crisp => 2,
AntiAlias::Strong => 3,
AntiAlias::Smooth => 4,
// EngineData has no platform modes; the `AntA` descriptor carries the exact value.
AntiAlias::Windows | AntiAlias::WindowsLcd => 1,
AntiAlias::Crisp => 1,
AntiAlias::Strong => 2,
AntiAlias::Smooth => 3,
AntiAlias::Sharp => 4,
// No Photoshop file confirms the platform modes' numbers here; the `AntA` descriptor
// carries the exact mode, and Smooth is the closest.
AntiAlias::Windows | AntiAlias::WindowsLcd => 3,
}),
);
if dict.get("UseFractionalGlyphWidths").is_none() {
@@ -1059,6 +1066,19 @@ pub fn build_engine_data(layer: &TextLayer, template: Option<E>, dpi: f32) -> E
e
}
/// The anti-aliasing mode for EngineData's `AntiAlias` number, as Photoshop writes it: 0 None,
/// 1 Crisp, 2 Strong, 3 Smooth, 4 Sharp. Other numbers (platform modes) are left to `AntA`.
fn engine_antialias(n: f64) -> Option<AntiAlias> {
Some(match n as i64 {
0 => AntiAlias::None,
1 => AntiAlias::Crisp,
2 => AntiAlias::Strong,
3 => AntiAlias::Smooth,
4 => AntiAlias::Sharp,
_ => return None,
})
}
/// Builds the `TySh` block for `layer`, patching its current `psd_raw` when present.
/// `ink` is the layer's rendered bounds in text space (for `bounds`/`boundingBox`).
pub fn build_tysh(layer: &TextLayer, dpi: f32, ink: Option<[f32; 4]>) -> Vec<u8> {
@@ -1123,6 +1143,8 @@ pub fn build_tysh(layer: &TextLayer, dpi: f32, ink: Option<[f32; 4]>) -> Vec<u8>
(None, Some(old)) if enum_value(&old, "warpStyle").as_deref() == Some("warpNone") => Some(old),
_ => None,
};
let bounds = [l.floor() as i32, t.floor() as i32, r.ceil() as i32, b.ceil() as i32];
// Photoshop's trailing bounds: zeros for unwarped type, the text-space bounds when warped.
let warped = warp.as_ref().is_some_and(|w| enum_value(w, "warpStyle").is_some_and(|s| s != "warpNone"));
let bounds = if warped { [l, t, r, b] } else { [0.0; 4] };
write_tysh(&TySh { transform: layer.transform, text, warp, bounds })
}
+40
View File
@@ -1505,3 +1505,43 @@ fn translate_tysh_moves_only_the_translation() {
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}");
}
}