mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-14 19:26:51 -04:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ea1268cac |
No files matched your search
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 })
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user