fix(psd): an unedited save writes Photoshop's Txt2 back unchanged (#2374) (#2427)

* 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:
authored and GitHub committed 2026-10-10 13:11:42 -04:00
1 parent 1a49942b63
commit 8fd4aa4cc2
3 files changed
+196 -17

No files matched your search

+25
View File
@@ -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
View File
@@ -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
+86
View File
@@ -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.