diff --git a/crates/io/src/psd_export.rs b/crates/io/src/psd_export.rs index dd6446148a..4350ffd06a 100644 --- a/crates/io/src/psd_export.rs +++ b/crates/io/src/psd_export.rs @@ -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; } diff --git a/crates/io/tests/psd_kerning.rs b/crates/io/tests/psd_kerning.rs index 99b8535f4c..9c5e3dd8cd 100644 --- a/crates/io/tests/psd_kerning.rs +++ b/crates/io/tests/psd_kerning.rs @@ -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 { + 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, Vec) { + 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; diff --git a/crates/text/src/psd.rs b/crates/text/src/psd.rs index 3221f9c80d..828a50a0a2 100644 --- a/crates/text/src/psd.rs +++ b/crates/text/src/psd.rs @@ -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 { } } +/// 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> {