fix(psd): give a new Txt2 object its own style and a number past the file's objects

A new object kept the template's style runs, and PhotoCraft's reader takes
font features from Txt2, so a new type layer came back with the template
layer's features (salt, ss01). Its style runs are now generated like the
stub's; only the template's structure is copied.

New type layers were numbered past the kept TextIndex values only, so after
deleting the layer with the highest index a new layer landed on its object
and took its style. They are now numbered past the file's Txt2 objects too.

txt2_extras_survive_a_resave_of_unchanged_text now gives its layer the TySh
index a file's layer has; without one the layer is new and no longer lands
on object 0.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
NetterSilver
2026-10-20 15:57:10 +02:00
co-authored by Claude Opus 5.5
parent 304f055021
commit 760109e23c
3 changed files with 74 additions and 5 deletions
+7 -4
View File
@@ -1582,9 +1582,10 @@ fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32, c
ex.used_ids = used;
// Text objects' number in the generated `Txt2` (#1348): a preserved `TySh` keeps the
// number it already names — its `Txt2` object must sit at that slot for `apply_txt2` —
// and new layers get numbers past every kept one. Kept numbers are claimed first: a layer
// added below others must not take a number a layer above it keeps, or each of those
// would be renumbered onto its neighbour's object and take its style (#7773).
// and new layers get numbers past every kept one and every object in the file's `Txt2`.
// Kept numbers are claimed first: a layer added below others must not take a number a
// layer above it keeps, or each of those would be renumbered onto its neighbour's object
// and take its style (#7773); nor may it land on a deleted layer's object.
let mut used_txt = std::collections::HashSet::new();
ex.text_index = Default::default();
let mut fresh = Vec::new();
@@ -1599,7 +1600,9 @@ fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32, c
_ => fresh.push(l.id),
}
}
let mut next_txt = used_txt.iter().max().map_or(0, |m| m + 1);
let objects =
doc.metadata.psd_global_blocks.iter().find(|(_, key, _)| key == b"Txt2").map_or(0, |(_, _, d)| photocraft_text::psd::txt2_object_count(d));
let mut next_txt = used_txt.iter().max().map_or(0, |m| m + 1).max(i32::try_from(objects).unwrap_or(i32::MAX));
if next_txt.saturating_add(fresh.len() as i32) > photocraft_text::psd::MAX_TEXT_INDEX + 1 {
next_txt = 0;
}
+58
View File
@@ -173,6 +173,61 @@ fn new_text_layer_below_keeps_the_preserved_text_indices() {
assert_eq!(indices, [("new", Some(2)), ("AV", Some(0)), ("BC", Some(1))]);
}
/// A Photoshop-shaped `Txt2` block: one object per `(text, style keys)` pair, each with its view
/// naming its own frame.
fn photoshop_txt2(objects: &[(&str, &str)]) -> Vec<u8> {
let bom = "\u{fe}\u{ff}";
let frame = "<< /0 << /2 << /2 [ 1.0 0.0 0.0 1.0 5.0 6.0 ] >> >> >> ";
let mut frames = String::new();
let mut body = String::new();
for (i, (text, style)) in objects.iter().enumerate() {
let utf16: String = format!("{text}\r").chars().flat_map(|c| ['\0', c]).collect();
let len = text.len() + 1;
frames.push_str(frame);
body.push_str(&format!(
"<< /0 << /0 ({bom}{utf16}) /5 << /0 [ << /0 << /0 << /0 ({bom}) /5 << /7 1.2 >> >> >> /1 {len} >> ] >> /6 << /0 [ << /0 << /0 << /0 ({bom}) /6 << /0 0 /11 1 {style} >> >> >> /1 {len} >> ] >> >> /1 << /0 [ << /0 {i} >> ] /1 << /0 [ << /0 << /1 1 >> /1 {len} >> ] >> /2 [ << /99 /PC /6 [ << /99 /F /10 {i} >> ] >> ] >> >> "
));
}
format!("\n\n/98 << /0 7 >> /0 << /8 << /0 [ {frames}] >> >> /1 << /0 << /0 1 >> /1 [ {body}] /2 << /0 7 >> >>").chars().map(|c| c as u32 as u8).collect()
}
/// The preserved type layers "AV" (`TextIndex` 0) and "BC" (1) of a Photoshop file whose `Txt2`
/// holds `objects`, plus a new type layer "Hi" on top, saved and reopened: "Hi"'s index and the
/// font features it comes back with.
fn add_layer_beside(objects: &[(&str, &str)]) -> (Option<i32>, Vec<String>) {
let mut doc = Document::new("k", photocraft_geom::Size::new(64, 32), photocraft_color::ColorMode::Rgb, photocraft_color::SampleType::U8);
for (i, text) in ["AV", "BC"].into_iter().enumerate() {
let mut t = layer(text, &[(2, Kerning::Metrics, 0.0)]);
photocraft_text::TextEngine::new().render_layer(&mut t, doc.resolution_dpi, doc.pixel_format());
let raw = photocraft_text::psd::build_tysh(&t, doc.resolution_dpi, None);
t.psd_raw = Some(std::sync::Arc::new(photocraft_text::psd::set_text_index(&raw, i as i32).unwrap()));
doc.layers.push(Layer::new(text, LayerContent::Text(t)));
}
doc.metadata.psd_global_blocks = vec![(*b"8BIM", *b"Txt2", std::sync::Arc::new(photoshop_txt2(objects)))];
let mut t = layer("Hi", &[(2, Kerning::Metrics, 0.0)]);
photocraft_text::TextEngine::new().render_layer(&mut t, doc.resolution_dpi, doc.pixel_format());
doc.layers.push(Layer::new("Hi", LayerContent::Text(t)));
let bytes = photocraft_io::export(&doc, "k.psd", &Default::default()).unwrap().bytes;
let back = photocraft_io::import("k.psd", &bytes).unwrap().document;
let LayerContent::Text(t) = &back.layers[2].content else { panic!("not text") };
let features = t.char_runs().iter().flat_map(|r| r.style.features.iter().map(|f| f.tag.clone())).collect();
(t.psd_raw.as_deref().and_then(|d| photocraft_text::psd::text_index(d)), features)
}
/// A new type layer takes the structure of one of the file's objects but not its style: the
/// template's style runs carried its font features (here `salt` and `ss01`) into the new layer.
#[test]
fn new_text_layer_takes_no_style_from_the_txt2_template() {
assert_eq!(add_layer_beside(&[("AV", ""), ("BC", "/28 true /85 1")]), (Some(2), vec![]));
}
/// A new type layer is numbered past the file's `Txt2` objects, not only past the kept indices:
/// the object of a deleted type layer ("Gone", index 2) is not reused with its style.
#[test]
fn new_text_layer_skips_a_deleted_layers_txt2_object() {
assert_eq!(add_layer_beside(&[("AV", ""), ("BC", ""), ("Gone", "/28 true")]), (Some(3), vec![]));
}
/// A file-controlled `TextIndex` must not size the save: `i32::MAX` is ignored (the regenerated
/// `Txt2` is sized by real text objects, never by a file's numbers) and the document still saves
/// and reopens with its text.
@@ -215,6 +270,9 @@ fn txt2_extras_survive_a_resave_of_unchanged_text() {
let mut doc = Document::new("k", photocraft_geom::Size::new(64, 32), photocraft_color::ColorMode::Rgb, photocraft_color::SampleType::U8);
let mut r = t.clone();
photocraft_text::TextEngine::new().render_layer(&mut r, doc.resolution_dpi, doc.pixel_format());
// The file's layer: its `TySh` names object 0.
let raw = photocraft_text::psd::build_tysh(&r, doc.resolution_dpi, None);
r.psd_raw = Some(std::sync::Arc::new(photocraft_text::psd::set_text_index(&raw, 0).unwrap()));
doc.layers.push(Layer::new("kept", LayerContent::Text(r)));
doc.metadata.psd_global_blocks.push((*b"8BIM", *b"Txt2", std::sync::Arc::new(prev.clone())));
let bytes = photocraft_io::export(&doc, "k.psd", &Default::default()).unwrap().bytes;
+9 -1
View File
@@ -1002,7 +1002,8 @@ pub fn build_txt2(objects: &[(i32, &TextLayer)], previous: Option<&[u8]>) -> Vec
// 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.
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();
// A new object keeps the template's structure, not its style: its style runs are generated.
let reuse = kept.is_some() || edited.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));
@@ -1250,6 +1251,13 @@ pub fn text_index(tysh: &[u8]) -> Option<i32> {
}
}
/// How many text objects the document `Txt2` block `data` holds (`/1 /1`), named by a layer or
/// not; 0 when it doesn't parse. PSD export numbers new type layers past them, so a new layer
/// never lands on a deleted layer's object.
pub fn txt2_object_count(data: &[u8]) -> usize {
parse_txt2(data).and_then(|t| t.path(&["1", "1"]).and_then(E::as_array).map(<[E]>::len)).unwrap_or(0)
}
/// Rewrites a `TySh`'s `TextIndex`: the layer's text-object number in the document's `Txt2`
/// block ([`build_txt2`] writes them in this order). `None` when `tysh` doesn't parse.
pub fn set_text_index(tysh: &[u8], index: i32) -> Option<Vec<u8>> {