fix(psd): a new type layer gets a full Txt2 object and its own frame (#7698)

A type layer the file's Txt2 has no object for (one added in PhotoCraft)
was written as a bare stub with no view, no paragraph runs and no frame
in /0 /8 /0. Photopea and Photoshop refused the file once the objects
outnumbered the frames.

build_txt2 now gives such a layer an object shaped like one of the
file's own (refitted with edited_object from #7220), with its view
naming its own frame, and pads the frame list to one per object. A stub
written by an earlier save is replaced the same way, so re-saving an
affected file repairs it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
NetterSilver
2026-10-19 14:56:15 +02:00
co-authored by Claude Opus 5.5
parent e8e745b7c2
commit 198fa59c7d
2 changed files with 135 additions and 2 deletions
+75 -2
View File
@@ -975,6 +975,11 @@ pub fn build_txt2(objects: &[(i32, &TextLayer)], previous: Option<&[u8]>) -> Vec
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> = prev_objects.to_vec();
// Looked up once per save, not per layer: a crafted block with many objects must not cost
// a pass over all of them for every type layer.
let all_stubs = prev_objects.iter().all(is_stub);
// A template is copied once per new layer, so a large one is skipped (the stub is written).
let template = prev_objects.iter().rev().find(|o| names_one_frame(o) && fits(o, &mut { TEMPLATE_NODES }) && edited_object(o, "\r").is_some());
for (index, layer) in objects {
if !(0..=MAX_TEXT_INDEX).contains(index) {
continue;
@@ -988,8 +993,15 @@ pub fn build_txt2(objects: &[(i32, &TextLayer)], previous: Option<&[u8]>) -> Vec
// run array refitted to the new text. A bare `<< /0 << /0 text /6 runs >> >>` stub in
// Photoshop's block (no paragraph runs, no view) made Photoshop quit while opening the file.
let edited = kept.is_none().then(|| prev_objects.get(at).and_then(|o| edited_object(o, &text))).flatten();
let reuse = kept.is_some() || edited.is_some();
let mut object = kept.cloned().or(edited).unwrap_or_else(E::dict);
// A new object (#7698): a layer the file's block has no object for (a type layer added
// here) takes the shape of one of the file's own objects, refitted the same way, with its
// view naming its own frame. A bare stub beside Photoshop's objects, its slot without a
// frame in `/0 /8 /0`, made Photoshop and Photopea refuse the file. A stub an earlier save
// wrote is replaced the same way, so re-saving such a file repairs it.
let kept = kept.filter(|o| !is_stub(o) || all_stubs);
let fresh = (kept.is_none() && edited.is_none()).then(|| template.and_then(|t| new_object(t, at, &text))).flatten();
let reuse = kept.is_some() || edited.is_some() || fresh.is_some();
let mut object = kept.cloned().or(edited).or(fresh).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 = reuse.then(|| model.path(&["6", "0"]).and_then(E::as_array)).flatten().and_then(|runs| with_modes(runs, &modes));
let runs = file_runs.unwrap_or_else(|| {
@@ -1019,6 +1031,7 @@ pub fn build_txt2(objects: &[(i32, &TextLayer)], previous: Option<&[u8]>) -> Vec
// 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())]));
pad_frames(&mut outer, slots.len());
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))])),
@@ -1036,6 +1049,66 @@ pub fn build_txt2(objects: &[(i32, &TextLayer)], previous: Option<&[u8]>) -> Vec
}
}
/// The most values a `Txt2` object may hold to serve as the template for new objects. Photoshop's
/// own objects hold a few hundred (611 at most in the file from #7698).
const TEMPLATE_NODES: usize = 4096;
/// Whether `v` holds at most `budget` values, counting down `budget` (stops at the first one over).
fn fits(v: &E, budget: &mut usize) -> bool {
let Some(left) = budget.checked_sub(1) else { return false };
*budget = left;
match v {
E::Dict(items) => items.iter().all(|(_, x)| fits(x, budget)),
E::Array(items) => items.iter().all(|x| fits(x, budget)),
_ => true,
}
}
/// Whether the text object `object` has a view naming one frame (`/1 /0 [ << /0 n >> ]`), as
/// Photoshop writes it.
fn names_one_frame(object: &E) -> bool {
object.path(&["1", "0"]).and_then(E::as_array).is_some_and(|f| f.len() == 1 && f.first().and_then(|x| x.get("0")).is_some())
}
/// A text object for the slot `at`, which the file's block has none for: the file's object
/// `template` refitted to `text` by [`edited_object`], its view now naming frame `at` and its view
/// runs (`/1 /1 /0`, one per paragraph) remade for the new paragraphs. `None` when the template
/// can't be refitted.
fn new_object(template: &E, at: usize, text: &str) -> Option<E> {
let paragraphs: Vec<usize> = text.split_inclusive('\r').map(|p| p.encode_utf16().count()).collect();
let mut object = edited_object(template, text)?;
let view = object.get_mut("1")?;
view.set("0", E::Array(vec![E::Dict(vec![("0".into(), E::Int(i64::try_from(at).ok()?))])]));
if let Some(runs) = view.path(&["1", "0"]).and_then(E::as_array) {
let fitted = runs_per(runs, &paragraphs)?;
view.get_mut("1")?.set("0", E::Array(fitted));
}
Some(object)
}
/// Whether `object` is the bare `<< /0 << /0 text /6 runs >> >>` object [`build_txt2`] writes
/// when it has nothing to take a shape from: no view, no paragraph runs.
fn is_stub(object: &E) -> bool {
let only = |v: &E, keys: &[&str]| matches!(v, E::Dict(items) if items.iter().all(|(k, _)| keys.contains(&k.as_str())));
only(object, &["0"]) && object.get("0").is_some_and(|m| only(m, &["0", "6"]))
}
/// Gives the block `outer` a frame (`/0 /8 /0`) for each of its `count` text objects: Photoshop
/// writes one per object, and a reader indexes them by object number. Missing frames are the
/// small point-text frame Photoshop writes (`<< /0 << /2 << /6 [ -1.0 -1.0 ] /11 << /4 -1 /7
/// false >> >> >> >>`), never copies of a file's frame: the slot count reaches
/// [`MAX_TEXT_INDEX`], and a large frame copied that often would blow up the save. A block
/// without a frame list is left as it is.
fn pad_frames(outer: &mut E, count: usize) {
let Some(E::Array(frames)) = outer.get_mut("0").and_then(|v| v.get_mut("8")).and_then(|v| v.get_mut("0")) else { return };
if frames.len() < count {
let kind = E::Dict(vec![("4".into(), E::Int(-1)), ("7".into(), E::Bool(false))]);
let data = E::Dict(vec![("6".into(), E::Array(vec![E::Real(-1.0), E::Real(-1.0)])), ("11".into(), kind)]);
let frame = E::Dict(vec![("0".into(), E::Dict(vec![("2".into(), data)]))]);
frames.resize(count, frame);
}
}
/// The file's `Txt2` text object `object` (`<< /0 model /1 view … >>`) for the changed engine
/// text `text` (`\r` breaks, trailing `\r`): the model's text replaced, its paragraph runs (`/5`)
/// one per paragraph of the new text (each keeping the style of the file's paragraph at that
+60
View File
@@ -1147,6 +1147,66 @@ fn edited_text_keeps_the_files_txt2_object() {
}
}
/// A type layer the file's `Txt2` has no object for (one added before the save, #7698) gets an
/// object shaped like the file's own, with a view naming its own frame and a frame in `/0 /8 /0`,
/// instead of a bare stub: Photoshop and Photopea refused the file when the frames ran out.
#[test]
fn new_text_layer_gets_a_txt2_object_and_frame() {
use crate::engine_data::Value as E;
let run = |len: usize| format!("<< /0 << /0 << /0 (\u{fe}\u{ff}) /5 << /7 1.2 >> /6 << /0 0 /1 12.0 >> >> >> /1 {len} >> ");
let prev = format!(
"\n\n/98 << /0 7 >> /0 << /8 << /0 [ << /0 << /2 << /2 [ 1.0 0.0 0.0 1.0 5.0 6.0 ] >> >> >> ] >> >> /1 << /0 << /0 1 >> /1 [ << /0 << /0 (\u{fe}\u{ff}\0A\0B\0\r\0C\0D\0\r) /5 << /0 [ {}{}] >> /6 << /0 [ {}] >> >> /1 << /0 [ << /0 0 >> ] /1 << /0 [ << /0 << /1 1 >> /1 3 >> << /0 << /1 1 >> /1 3 >> ] >> /2 [ << /99 /PC >> ] >> /21 << /1 [ 1.0 ] >> >> ] /2 << /0 7 >> >>",
run(3),
run(3),
run(6)
);
// The same block after an earlier save wrote a bare stub for "v1.0" (the file in #7698).
let stub = "<< /0 << /0 (\u{fe}\u{ff}\0v\x001\0.\x000\0\r) /6 << /0 [ << /0 << /0 << /0 () /6 << /0 0 /11 1 >> >> >> /1 5 >> ] >> >> >> ";
let broken = prev.replace(" >> ] /2 << /0 7 >>", &format!(" >> {stub}] /2 << /0 7 >>"));
let broken: Vec<u8> = broken.chars().map(|c| c as u32 as u8).collect();
let prev: Vec<u8> = prev.chars().map(|c| c as u32 as u8).collect();
let before = crate::psd::parse_txt2(&prev).unwrap();
let old = runs_of("AB\nCD", &[(5, CharStyle::default())]);
let new = runs_of("v1.0", &[(4, CharStyle::default())]);
let out = crate::psd::parse_txt2(&crate::psd::build_txt2(&[(0, &old), (1, &new)], Some(&prev))).unwrap();
let objects = out.path(&["1", "1"]).and_then(E::as_array).unwrap();
assert_eq!(objects.len(), 2);
let kept = &before.path(&["1", "1"]).and_then(E::as_array).unwrap()[0];
assert_eq!(objects[0].get("1"), kept.get("1"), "the file's object keeps its view");
assert_eq!(objects[0].path(&["0", "5"]), kept.path(&["0", "5"]), "the file's object keeps its paragraphs");
let frames = out.path(&["0", "8", "0"]).and_then(E::as_array).unwrap();
assert_eq!(frames.len(), 2, "one frame per object");
let object = &objects[1];
assert_eq!(object.path(&["0", "0"]).and_then(E::as_str), Some("v1.0\r"));
assert_eq!(object.path(&["1", "0"]), Some(&E::Array(vec![E::Dict(vec![("0".into(), E::Int(1))])])), "the view names its own frame");
assert!(object.get("21").is_none());
let lens =
|path: &[&str]| -> Vec<i64> { object.path(path).and_then(E::as_array).unwrap().iter().map(|r| r.get("1").and_then(E::as_i64).unwrap()).collect() };
assert_eq!(lens(&["0", "5", "0"]), vec![5]);
assert_eq!(lens(&["0", "6", "0"]), vec![5]);
assert_eq!(lens(&["1", "1", "0"]), vec![5], "view runs follow the new paragraphs");
// Re-saving the file with the stub repairs it: the stub is replaced like a missing object.
assert_eq!(crate::psd::parse_txt2(&broken).unwrap().path(&["1", "1"]).and_then(E::as_array).unwrap().len(), 2);
let repaired = crate::psd::parse_txt2(&crate::psd::build_txt2(&[(0, &old), (1, &new)], Some(&broken))).unwrap();
assert_eq!(repaired, out, "a stub from an earlier save is replaced");
// No object to take the shape from: the fresh object as before, with the frames still padded.
let bare = b"\n\n/98 << /0 7 >> /0 << /8 << /0 [ << /0 1 >> ] >> >> /1 << /1 [ << >> ] >> ";
let out = crate::psd::parse_txt2(&crate::psd::build_txt2(&[(1, &new)], Some(bare))).unwrap();
assert_eq!(out.path(&["1", "1"]).and_then(E::as_array).unwrap().len(), 2);
assert_eq!(out.path(&["0", "8", "0"]).and_then(E::as_array).unwrap().len(), 2);
// An oversized object is never the template (it would be copied once per new layer).
let filler = "0 ".repeat(5000);
let big = prev.iter().map(|&b| b as char).collect::<String>().replace("/2 [ << /99 /PC >> ]", &format!("/2 [ << /99 /PC >> ] /9 [ {filler}]"));
let big: Vec<u8> = big.chars().map(|c| c as u32 as u8).collect();
let out = crate::psd::parse_txt2(&crate::psd::build_txt2(&[(0, &old), (1, &new)], Some(&big))).unwrap();
let object = &out.path(&["1", "1"]).and_then(E::as_array).unwrap()[1];
assert!(object.get("1").is_none(), "no copy of an oversized template");
assert_eq!(out.path(&["0", "8", "0"]).and_then(E::as_array).unwrap().len(), 2);
}
/// 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]