fix(psd): keep preserved TextIndex values when a type layer is added below them

Text indices were assigned in one bottom-up pass, so a new layer took the
next free number before the layers above it claimed the numbers their TySh
keeps. Each of those was then renumbered onto its neighbour's Txt2 object
and took its style (70 of 74 layers on the #7698 file after adding a layer
at index 5). Kept numbers are now claimed first and new layers get numbers
past them.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
NetterSilver
2026-10-20 10:51:26 +02:00
co-authored by Claude Opus 5.5
parent 604007490b
commit 78cc36f24a
2 changed files with 49 additions and 13 deletions
+20 -13
View File
@@ -1582,26 +1582,33 @@ 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 the next free number.
// 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).
let mut used_txt = std::collections::HashSet::new();
let mut next_txt = 0i32;
ex.text_index = Default::default();
let mut fresh = Vec::new();
for (_, _, l) in &walk {
let LayerContent::Text(t) = &l.content else {
continue;
};
let kept = t.psd_raw.as_deref().and_then(|d| photocraft_text::psd::text_index(d));
let index = match kept {
Some(i) if used_txt.insert(i) => i,
_ => {
while used_txt.contains(&next_txt) {
next_txt += 1;
}
used_txt.insert(next_txt);
next_txt
match t.psd_raw.as_deref().and_then(|d| photocraft_text::psd::text_index(d)) {
Some(i) if used_txt.insert(i) => {
ex.text_index.insert(l.id, i);
}
};
ex.text_index.insert(l.id, index);
_ => fresh.push(l.id),
}
}
let mut next_txt = used_txt.iter().max().map_or(0, |m| m + 1);
if next_txt.saturating_add(fresh.len() as i32) > photocraft_text::psd::MAX_TEXT_INDEX + 1 {
next_txt = 0;
}
for id in fresh {
while used_txt.contains(&next_txt) {
next_txt += 1;
}
used_txt.insert(next_txt);
ex.text_index.insert(id, next_txt);
}
}
// Progress: one unit per layer record, then the merged composite (#3389).
+29
View File
@@ -144,6 +144,35 @@ fn duplicate_preserved_text_indices_get_distinct_txt2_objects() {
}
}
/// A type layer added below preserved ones takes a number past theirs: claiming the next free
/// number on the way up renumbered every layer above it onto its neighbour's `Txt2` object
/// (#7773, 70 of 74 layers on the #7698 file).
#[test]
fn new_text_layer_below_keeps_the_preserved_text_indices() {
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::Optical, 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)));
}
let mut t = layer("new", &[(3, Kerning::Metrics, 0.0)]);
photocraft_text::TextEngine::new().render_layer(&mut t, doc.resolution_dpi, doc.pixel_format());
doc.layers.insert(0, Layer::new("new", 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 indices: Vec<_> = back
.layers
.iter()
.map(|l| {
let LayerContent::Text(t) = &l.content else { panic!("not text") };
(l.name.as_str(), t.psd_raw.as_deref().and_then(|d| photocraft_text::psd::text_index(d)))
})
.collect();
assert_eq!(indices, [("new", Some(2)), ("AV", Some(0)), ("BC", Some(1))]);
}
/// 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.