mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-14 19:26:51 -04:00
* fix(psd): an unedited save writes Photoshop's Txt2 back unchanged (#2374) Since #1517 every PSD save with type layers rebuilt the document's Txt2 block: it replaced everything under /1 with the text-object list, emptied objects no layer names, and cut each kept object's style runs down to the auto-kern mode alone. Photoshop then refused to open an unedited re-save ("Could not complete your request because of a program error"). build_txt2 now starts from the file's own block: the other /1 keys and unnamed objects are kept, a kept object's style runs keep every style key and only get /11 set (split where a mode changes inside a run), and a block whose content comes out unchanged is returned byte for byte. Co-Authored-By: Claude Opus 5.5 <[email protected]> * test(psd): Photoshop type files keep Txt2 byte-for-byte on an unedited save (#2374) Corpus test over corpus/photoshop/text: import, export, re-import, and compare the Txt2 global block. All 56 Photoshop-authored type files pass; the previous build_txt2 fails on the first. Co-Authored-By: Claude Opus 5.5 <[email protected]> --------- Co-authored-by: vivekprojects-GIT <[email protected]> Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
3 files changed
+196
-17
No files matched your search
@@ -144,6 +144,31 @@ fn corpus_tysh_lossless() {
|
||||
assert_eq!(find(&bytes), find(&out));
|
||||
}
|
||||
|
||||
/// An unedited save of a Photoshop-authored type file writes its `Txt2` back byte-for-byte
|
||||
/// (#2374: a regenerated `Txt2` made Photoshop refuse the re-saved file).
|
||||
#[cfg(feature = "corpus")]
|
||||
#[test]
|
||||
fn corpus_photoshop_txt2_survives_an_unedited_save() {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/photoshop/text");
|
||||
assert!(root.is_dir(), "{} is missing: run `cargo xtask corpus --photoshop`", root.display());
|
||||
let mut files = Vec::new();
|
||||
collect(&root, &mut files);
|
||||
files.sort();
|
||||
let txt2 = |doc: &photocraft_doc::Document| doc.metadata.psd_global_blocks.iter().find(|b| &b.1 == b"Txt2").map(|b| b.2.clone());
|
||||
let mut checked = 0;
|
||||
for f in &files {
|
||||
let name = f.file_name().unwrap().to_string_lossy().to_string();
|
||||
let doc = photocraft_io::import(&name, &std::fs::read(f).unwrap()).unwrap().document;
|
||||
let Some(before) = txt2(&doc) else { continue };
|
||||
let out = photocraft_io::export(&doc, &name, &Default::default()).unwrap().bytes;
|
||||
let back = photocraft_io::import(&name, &out).unwrap().document;
|
||||
assert!(txt2(&back) == Some(before), "{name}: Txt2 changed on an unedited save");
|
||||
checked += 1;
|
||||
}
|
||||
assert!(checked > 0, "no Photoshop type files with Txt2 in {}", root.display());
|
||||
println!("Txt2 kept byte-for-byte in {checked} Photoshop type files");
|
||||
}
|
||||
|
||||
/// A text layer created in Photocraft (no PSD data) exports as an editable type layer and
|
||||
/// imports back with the same model.
|
||||
#[test]
|
||||
|
||||
+85
-17
@@ -740,41 +740,64 @@ pub const MAX_TEXT_INDEX: i32 = 0xFFFF;
|
||||
/// `previous` is the block this replaces (the file's own). A kept object — one whose text is
|
||||
/// unchanged — keeps everything the file held beyond the two regenerated keys (its model's text
|
||||
/// and style runs): Photoshop stores glyph pen positions under `/21 /1` there, and a save with
|
||||
/// no text edit must not drop them. A changed text invalidates those extras along with the old
|
||||
/// runs, so they are dropped rather than left stale.
|
||||
/// no text edit must not drop them. Its own style runs keep every style key too: only their
|
||||
/// auto-kern mode is set (split where a mode changes inside a run). A changed text invalidates
|
||||
/// those extras along with the old runs, so they are dropped rather than left stale. Everything
|
||||
/// else the file's block held — the other keys beside the text objects, objects no layer names —
|
||||
/// is kept, and a block whose content comes out unchanged is returned byte for byte (#2374:
|
||||
/// Photoshop refused files whose `Txt2` an unedited save had stripped).
|
||||
pub fn build_txt2(objects: &[(i32, &TextLayer)], previous: Option<&[u8]>) -> Vec<u8> {
|
||||
let prev = previous.and_then(parse_txt2);
|
||||
let prev_objects: &[E] = prev.as_ref().and_then(|p| p.path(&["1", "1"]).and_then(E::as_array)).unwrap_or(&[]);
|
||||
let mut slots: Vec<E> = Vec::new();
|
||||
let mut slots: Vec<E> = prev_objects.to_vec();
|
||||
for (index, layer) in objects {
|
||||
if !(0..=MAX_TEXT_INDEX).contains(index) {
|
||||
continue;
|
||||
}
|
||||
let text = format!("{}\r", layer.text.replace('\n', "\r"));
|
||||
let runs: Vec<E> = txt2_mode_runs(layer)
|
||||
.into_iter()
|
||||
.map(|(mode, len)| {
|
||||
// `<< /0 << /0 << /0 () /6 << /0 0 /11 mode >> >> >> /1 len >>`.
|
||||
let style = E::Dict(vec![("0".into(), E::Int(0)), ("11".into(), E::Int(mode))]);
|
||||
let data = E::Dict(vec![("0".into(), E::String(String::new())), ("6".into(), style)]);
|
||||
E::Dict(vec![("0".into(), E::Dict(vec![("0".into(), data)])), ("1".into(), E::Int(len as i64))])
|
||||
})
|
||||
.collect();
|
||||
let modes = txt2_mode_runs(layer);
|
||||
let at = *index as usize;
|
||||
let kept = prev_objects.get(at).filter(|o| o.get("0").and_then(|m| m.get("0")).and_then(E::as_str) == Some(text.as_str()));
|
||||
let mut object = kept.cloned().unwrap_or_else(E::dict);
|
||||
let mut model = object.get("0").cloned().filter(|m| matches!(m, E::Dict(_))).unwrap_or_else(E::dict);
|
||||
let file_runs = kept.and_then(|_| model.path(&["6", "0"]).and_then(E::as_array)).and_then(|runs| with_modes(runs, &modes));
|
||||
let runs = file_runs.unwrap_or_else(|| {
|
||||
modes
|
||||
.iter()
|
||||
.map(|&(mode, len)| {
|
||||
// `<< /0 << /0 << /0 () /6 << /0 0 /11 mode >> >> >> /1 len >>`.
|
||||
let style = E::Dict(vec![("0".into(), E::Int(0)), ("11".into(), E::Int(mode))]);
|
||||
let data = E::Dict(vec![("0".into(), E::String(String::new())), ("6".into(), style)]);
|
||||
E::Dict(vec![("0".into(), E::Dict(vec![("0".into(), data)])), ("1".into(), E::Int(len as i64))])
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
model.set("0", E::String(text));
|
||||
model.set("6", E::Dict(vec![("0".into(), E::Array(runs))]));
|
||||
match model.get_mut("6") {
|
||||
Some(style_run @ E::Dict(_)) => style_run.set("0", E::Array(runs)),
|
||||
_ => model.set("6", E::Dict(vec![("0".into(), E::Array(runs))])),
|
||||
}
|
||||
object.set("0", model);
|
||||
if slots.len() <= at {
|
||||
slots.resize(at + 1, E::dict());
|
||||
}
|
||||
slots[at] = object;
|
||||
if let Some(slot) = slots.get_mut(at) {
|
||||
*slot = object;
|
||||
}
|
||||
}
|
||||
// The outer dictionary keeps whatever the block held (`/98` version, block extras, …), and
|
||||
// `/1` whatever it holds beside the text objects.
|
||||
let mut outer = prev.clone().unwrap_or_else(|| E::Dict(vec![("98".into(), E::Dict(vec![("0".into(), E::Int(14))])), ("0".into(), E::dict())]));
|
||||
match outer.get_mut("1") {
|
||||
Some(objs @ E::Dict(_)) => objs.set("1", E::Array(slots)),
|
||||
_ => outer.set("1", E::Dict(vec![("1".into(), E::Array(slots))])),
|
||||
}
|
||||
// Nothing changed: the file's own bytes (writing them back would re-encode strings and numbers).
|
||||
if let (Some(bytes), Some(p)) = (previous, &prev)
|
||||
&& *p == outer
|
||||
{
|
||||
return bytes.to_vec();
|
||||
}
|
||||
// The outer dictionary keeps whatever the block held (`/98` version, block extras, …).
|
||||
let mut outer = prev.unwrap_or_else(|| E::Dict(vec![("98".into(), E::Dict(vec![("0".into(), E::Int(14))])), ("0".into(), E::dict())]));
|
||||
outer.set("1", E::Dict(vec![("1".into(), E::Array(slots))]));
|
||||
match outer {
|
||||
E::Dict(items) => ed::write_bare(&items),
|
||||
// Unreachable: `parse_txt2` and the fallback above both yield a dictionary.
|
||||
@@ -782,6 +805,51 @@ pub fn build_txt2(objects: &[(i32, &TextLayer)], previous: Option<&[u8]>) -> Vec
|
||||
}
|
||||
}
|
||||
|
||||
/// The file's own `Txt2` style runs `runs` with the auto-kern modes `modes` (`(mode, UTF-16
|
||||
/// length)`, see [`txt2_mode_runs`]) set at `/0 /0 /6 /11`, keeping every other style key. A
|
||||
/// run a mode change falls inside is split there. `None` when the runs don't cover the text or
|
||||
/// lack a style dictionary: the caller writes its own runs instead.
|
||||
fn with_modes(runs: &[E], modes: &[(i64, usize)]) -> Option<Vec<E>> {
|
||||
let lens: Vec<usize> = runs.iter().map(|r| r.get("1").and_then(E::as_i64).and_then(|l| usize::try_from(l).ok())).collect::<Option<_>>()?;
|
||||
let total = modes.iter().try_fold(0usize, |a, m| a.checked_add(m.1))?;
|
||||
if lens.iter().try_fold(0usize, |a, l| a.checked_add(*l))? != total {
|
||||
return None;
|
||||
}
|
||||
let mut out = Vec::with_capacity(runs.len());
|
||||
let (mut mi, mut mode_end) = (0usize, modes.first()?.1);
|
||||
let mut at = 0usize;
|
||||
for (run, len) in runs.iter().zip(lens) {
|
||||
if len == 0 {
|
||||
out.push(run.clone());
|
||||
continue;
|
||||
}
|
||||
let end = at + len;
|
||||
while at < end {
|
||||
while at >= mode_end && mi + 1 < modes.len() {
|
||||
mi += 1;
|
||||
mode_end = mode_end.saturating_add(modes.get(mi)?.1);
|
||||
}
|
||||
let piece_end = end.min(mode_end);
|
||||
if piece_end <= at {
|
||||
return None;
|
||||
}
|
||||
let mut piece = run.clone();
|
||||
if piece_end - at != len {
|
||||
piece.set("1", E::Int(i64::try_from(piece_end - at).ok()?));
|
||||
}
|
||||
let style = piece.get_mut("0").and_then(|v| v.get_mut("0")).and_then(|v| v.get_mut("6")).filter(|v| matches!(v, E::Dict(_)))?;
|
||||
let mode = modes.get(mi)?.0;
|
||||
// Keep the file's value (and its number type) when the mode is the same.
|
||||
if style.get("11").and_then(E::as_i64) != Some(mode) {
|
||||
style.set("11", E::Int(mode));
|
||||
}
|
||||
out.push(piece);
|
||||
at = piece_end;
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// The layer's text-object number in the document's `Txt2` block, when its `TySh` names one
|
||||
/// within `0..=`[`MAX_TEXT_INDEX`]. PSD export keeps a preserved number — its regenerated `Txt2`
|
||||
/// object sits at that slot, and [`apply_txt2`] looks the slot up — and numbers layers without
|
||||
|
||||
@@ -648,6 +648,92 @@ fn txt2_keeps_a_kept_objects_extras_and_drops_them_with_its_text() {
|
||||
assert!(object.path(&["0", "keep"]).is_none(), "model extras of another text are dropped");
|
||||
}
|
||||
|
||||
/// A `Txt2` shaped like Photoshop's: document resources and settings beside the text objects
|
||||
/// (`/0`, `/1 /0`, `/1 /2`), style runs carrying a full style (font, size, leading) besides the
|
||||
/// auto-kern mode, a Latin-1 string without a byte-order mark, reals, and a second object no
|
||||
/// layer names. `modes` are the object's `(UTF-16 length, auto-kern mode)` runs for "AVA\r".
|
||||
fn photoshop_txt2(modes: &[(usize, i64)]) -> Vec<u8> {
|
||||
let mut v = b"\n\n/98 << /0 14 >> /0 << /1 << /0 [ << /0 << /0 (MyriadPro-Regular) /2 0 >> >> ] >> >> /1 << /0 << /0 1 /1 .5 >> /1 [ << /0 << /0 (\xfe\xff\0A\0V\0A\0\r) /6 << /0 [ ".to_vec();
|
||||
for (len, m) in modes {
|
||||
v.extend_from_slice(format!("<< /0 << /0 << /0 (\u{fe}\u{ff}) /6 << /0 0 /1 12.0 /2 14.5 /11 {m} >> >> >> /1 {len} >> ").as_bytes());
|
||||
}
|
||||
v.extend_from_slice(
|
||||
b"] >> /7 << /0 1 >> >> /21 << /1 [ 1.25 2.5 ] >> >> << /0 << /0 (\xfe\xff\0B\0\r) >> /21 << /1 [ 3.0 ] >> >> ] /2 << /0 7 >> >> /3 << /0 .25 >>",
|
||||
);
|
||||
v
|
||||
}
|
||||
|
||||
/// #2374: an unedited save hands Photoshop back its own `Txt2`, byte for byte. Rebuilding it
|
||||
/// dropped `/1 /0` and `/1 /2`, emptied unnamed objects and cut every style run down to its
|
||||
/// auto-kern mode, and Photoshop refused to open the file.
|
||||
#[test]
|
||||
fn unchanged_txt2_is_written_back_byte_for_byte() {
|
||||
use photocraft_doc::text::Kerning;
|
||||
let prev = photoshop_txt2(&[(2, 1), (2, 1)]);
|
||||
let tysh = with_text_index(&crate::psd::build_tysh(&styled("AVA", CharStyle::default()), 72.0, None), 0);
|
||||
let mut t = crate::psd::text_layer_from_tysh(&tysh, 72.0).unwrap();
|
||||
crate::psd::apply_txt2(&mut t, &tysh, &crate::psd::parse_txt2(&prev).unwrap());
|
||||
assert!(t.char_runs().iter().all(|r| r.style.kerning == Kerning::Metrics));
|
||||
assert_eq!(crate::psd::build_txt2(&[(0, &t)], Some(&prev)), prev);
|
||||
// An optical run reads back and writes back the same way.
|
||||
let prev = photoshop_txt2(&[(1, 2), (3, 1)]);
|
||||
crate::psd::apply_txt2(&mut t, &tysh, &crate::psd::parse_txt2(&prev).unwrap());
|
||||
assert_eq!(t.char_runs().first().map(|r| r.style.kerning), Some(Kerning::Optical));
|
||||
assert_eq!(crate::psd::build_txt2(&[(0, &t)], Some(&prev)), prev);
|
||||
}
|
||||
|
||||
/// An edited auto-kern mode changes only `/11` (splitting the run it falls inside); the file's
|
||||
/// style keys, the other `/1` keys and the objects no layer names all survive.
|
||||
#[test]
|
||||
fn edited_txt2_keeps_the_files_styles_and_objects() {
|
||||
use crate::engine_data::Value as E;
|
||||
use photocraft_doc::text::Kerning;
|
||||
let prev = photoshop_txt2(&[(4, 1)]);
|
||||
let style = |kerning: Kerning| CharStyle { kerning, ..Default::default() };
|
||||
let t = runs_of("AVA", &[(1, style(Kerning::Optical)), (2, style(Kerning::Metrics))]);
|
||||
let out = crate::psd::parse_txt2(&crate::psd::build_txt2(&[(0, &t)], Some(&prev))).unwrap();
|
||||
let before = crate::psd::parse_txt2(&prev).unwrap();
|
||||
for key in [&["0"][..], &["1", "0"], &["1", "2"], &["3"]] {
|
||||
assert_eq!(out.path(key), before.path(key), "/{} survives", key.join(" /"));
|
||||
}
|
||||
let objects = out.path(&["1", "1"]).and_then(E::as_array).unwrap();
|
||||
assert_eq!(objects.len(), 2, "the object no layer names is kept");
|
||||
assert_eq!(objects.get(1), before.path(&["1", "1"]).and_then(E::as_array).unwrap().get(1));
|
||||
let object = &objects[0];
|
||||
assert_eq!(object.path(&["0", "7", "0"]).and_then(E::as_i64), Some(1), "model extras survive");
|
||||
assert!(object.path(&["21", "1"]).is_some(), "pen positions survive an unchanged text");
|
||||
let runs = object.path(&["0", "6", "0"]).and_then(E::as_array).unwrap();
|
||||
let got: Vec<(i64, i64)> =
|
||||
runs.iter().map(|r| (r.get("1").and_then(E::as_i64).unwrap(), r.path(&["0", "0", "6", "11"]).and_then(E::as_i64).unwrap())).collect();
|
||||
assert_eq!(got, vec![(1, 2), (3, 1)], "the run is split where the mode changes");
|
||||
for r in runs {
|
||||
assert_eq!(r.path(&["0", "0", "6", "1"]).and_then(E::as_f64), Some(12.0), "the font size survives");
|
||||
assert_eq!(r.path(&["0", "0", "6", "2"]).and_then(E::as_f64), Some(14.5), "the leading survives");
|
||||
}
|
||||
// And it reads back with the edited modes.
|
||||
let tysh = with_text_index(&crate::psd::build_tysh(&t, 72.0, None), 0);
|
||||
let mut back = crate::psd::text_layer_from_tysh(&tysh, 72.0).unwrap();
|
||||
crate::psd::apply_txt2(&mut back, &tysh, &out);
|
||||
let modes: Vec<(usize, Kerning)> = back.char_runs().iter().map(|r| (r.len, r.style.kerning)).collect();
|
||||
assert_eq!(modes, vec![(1, Kerning::Optical), (2, Kerning::Metrics)]);
|
||||
}
|
||||
|
||||
/// The file's style runs are only reused when they cover the text: runs of another length (or
|
||||
/// hostile lengths) fall back to generated runs, without panicking.
|
||||
#[test]
|
||||
fn txt2_runs_that_dont_cover_the_text_are_regenerated() {
|
||||
use crate::engine_data::Value as E;
|
||||
let t = runs_of("AVA", &[(3, CharStyle::default())]);
|
||||
for modes in [&[(3, 1)][..], &[(9, 1)], &[(i64::MAX as usize, 1), (2, 1)], &[]] {
|
||||
let prev = photoshop_txt2(modes);
|
||||
let out = crate::psd::parse_txt2(&crate::psd::build_txt2(&[(0, &t)], Some(&prev))).unwrap();
|
||||
let runs = out.path(&["1", "1"]).and_then(E::as_array).unwrap()[0].path(&["0", "6", "0"]).and_then(E::as_array).unwrap();
|
||||
let total: i64 = runs.iter().filter_map(|r| r.get("1").and_then(E::as_i64)).sum();
|
||||
assert_eq!(total, 4, "{modes:?}");
|
||||
assert!(out.path(&["1", "2"]).is_some());
|
||||
}
|
||||
}
|
||||
|
||||
/// A file-controlled `TextIndex` must not size the save: out-of-range numbers are ignored (the
|
||||
/// slot array is sized by real text objects, never by a file's numbers) and reading one back is
|
||||
/// a no-op, so a hostile file degrades instead of allocating gigabytes on export.
|
||||
|
||||
Reference in new issue
Block a user