diff --git a/crates/io/src/psd_import.rs b/crates/io/src/psd_import.rs index ffe2d34eaf..af19a0d76e 100644 --- a/crates/io/src/psd_import.rs +++ b/crates/io/src/psd_import.rs @@ -307,6 +307,7 @@ impl Ctx<'_> { }); if let Some(txt2) = &self.txt2 { photocraft_text::psd::apply_txt2(&mut t, &data, txt2); + photocraft_text::psd::apply_txt2_frame(&mut t, &data, txt2); } // Photoshop's pixels are the cache only when the file has some: ag-psd, GIMP and // other writers leave type layers without image data (Photoshop re-renders them on diff --git a/crates/io/tests/psd_style_sheets.rs b/crates/io/tests/psd_style_sheets.rs new file mode 100644 index 0000000000..0634dd3f9c --- /dev/null +++ b/crates/io/tests/psd_style_sheets.rs @@ -0,0 +1,225 @@ +//! #3795: a PSD type layer's alignment and character attributes can come from its style sheets +//! rather than its runs. A run's `Properties` / `StyleSheetData` hold only its overrides; the rest +//! comes from the paragraph style its `Name` names in `ParagraphSheetSet`, the character style in +//! `StyleSheetSet` (by name, or the paragraph's `DefaultStyleSheet`), then the Normal sheets. +//! Reading only the Normal sheets left a centred paragraph style read as Left, so starting an +//! edit re-laid the text out half a line to the side. + +use photocraft_doc::text::TextAlign; +use photocraft_geom::Affine; +use photocraft_psd::descriptor::{Descriptor, Id, UnicodeString, Value as D}; +use photocraft_text::engine_data::Value as E; +use photocraft_text::psd::{TySh, text_layer_from_tysh, write_tysh}; + +fn real(v: f64) -> E { + E::Real(v) +} + +fn d(kv: Vec<(&str, E)>) -> E { + E::Dict(kv.into_iter().map(|(k, v)| (k.to_string(), v)).collect()) +} + +/// Photoshop-shaped EngineData for one paragraph of point text. `para` is the paragraph run's +/// `ParagraphSheet`, `style` the character run's `StyleSheet`; the sheet sets are the +/// `ResourceDict`'s, with Normal at index 0. +fn engine(text: &str, para: E, style: E, para_set: Vec, style_set: Vec) -> E { + let ed_text = format!("{text}\r"); + let n = ed_text.encode_utf16().count() as i64; + let res = d(vec![ + ( + "FontSet", + E::Array(vec![d(vec![("Name", E::String("Inter-Regular".into())), ("Script", E::Int(0)), ("FontType", E::Int(1)), ("Synthetic", E::Int(0))])]), + ), + ("StyleSheetSet", E::Array(style_set)), + ("ParagraphSheetSet", E::Array(para_set)), + ("TheNormalStyleSheet", E::Int(0)), + ("TheNormalParagraphSheet", E::Int(0)), + ]); + d(vec![ + ( + "EngineDict", + d(vec![ + ("Editor", d(vec![("Text", E::String(ed_text))])), + ("ParagraphRun", d(vec![("RunArray", E::Array(vec![d(vec![("ParagraphSheet", para)])])), ("RunLengthArray", E::Array(vec![E::Int(n)]))])), + ("StyleRun", d(vec![("RunArray", E::Array(vec![d(vec![("StyleSheet", style)])])), ("RunLengthArray", E::Array(vec![E::Int(n)]))])), + ( + "Rendered", + d(vec![( + "Shapes", + d(vec![( + "Children", + E::Array(vec![d(vec![ + ("ShapeType", E::Int(0)), + ("Cookie", d(vec![("Photoshop", d(vec![("ShapeType", E::Int(0)), ("PointBase", E::Array(vec![real(0.0), real(0.0)]))]))])), + ])]), + )]), + )]), + ), + ]), + ), + ("ResourceDict", res), + ]) +} + +fn normal_char(size: f64) -> E { + d(vec![ + ("Name", E::String("Normal RGB".into())), + ("StyleSheetData", d(vec![("Font", E::Int(0)), ("FontSize", real(size)), ("AutoLeading", E::Bool(true))])), + ]) +} + +fn para_sheet(name: &str, justification: i64, default_style: i64) -> E { + d(vec![("Name", E::String(name.into())), ("DefaultStyleSheet", E::Int(default_style)), ("Properties", d(vec![("Justification", E::Int(justification))]))]) +} + +fn layer(text: &str, ed: &E) -> photocraft_doc::TextLayer { + let desc = Descriptor::new("TxLr") + .with("Txt ", D::Text(UnicodeString::new_nul(text))) + .with("Ornt", D::Enumerated { type_id: Id::new("Ornt"), value: Id::new("Hrzn") }) + .with("TextIndex", D::Integer(0)) + .with("EngineData", D::RawData(photocraft_text::engine_data::write(ed))); + let data = write_tysh(&TySh { transform: Affine::translate(450.0, 300.0), text: desc, warp: None, bounds: [0.0; 4] }); + let mut t = text_layer_from_tysh(&data, 72.0).expect("parses"); + // Import keeps the block, as for a layer read from a file. + t.psd_raw = Some(std::sync::Arc::new(data)); + t +} + +/// The paragraph run names a centred (or right-aligned) paragraph style and overrides nothing. +#[test] +fn alignment_from_the_paragraph_style_the_run_names() { + for (j, want) in [(2, TextAlign::Center), (1, TextAlign::Right)] { + let ed = engine( + "LOREM IPSUM", + d(vec![("Name", E::String("Title".into())), ("DefaultStyleSheet", E::Int(0)), ("Properties", d(vec![]))]), + d(vec![("StyleSheetData", d(vec![]))]), + vec![para_sheet("Normal RGB", 0, 0), para_sheet("Title", j, 0)], + vec![normal_char(48.0)], + ); + let t = layer("LOREM IPSUM", &ed); + assert_eq!(t.paragraphs[0].style.align, want, "Justification {j} from the paragraph style"); + // Laid out around the anchor like Photoshop draws it. + let b = photocraft_text::TextEngine::new().layout(&t, 72.0).bounds().unwrap(); + match want { + TextAlign::Center => assert!((b[0] + b[2]).abs() < 1.0, "centred on the anchor: {b:?}"), + _ => assert!(b[2].abs() < 1.0, "ends at the anchor: {b:?}"), + } + } +} + +/// A run's own value still wins over its paragraph style, and an unknown style name or an +/// out-of-range sheet index falls back to Normal. +#[test] +fn run_overrides_win_and_bad_references_fall_back_to_normal() { + let ed = engine( + "Text", + d(vec![("Name", E::String("Title".into())), ("DefaultStyleSheet", E::Int(0)), ("Properties", d(vec![("Justification", E::Int(1))]))]), + d(vec![("StyleSheetData", d(vec![]))]), + vec![para_sheet("Normal RGB", 0, 0), para_sheet("Title", 2, 0)], + vec![normal_char(48.0)], + ); + assert_eq!(layer("Text", &ed).paragraphs[0].style.align, TextAlign::Right); + let ed = engine( + "Text", + d(vec![("Name", E::String("Missing".into())), ("DefaultStyleSheet", E::Int(9)), ("Properties", d(vec![]))]), + d(vec![("Name", E::String("Missing".into())), ("StyleSheetData", d(vec![]))]), + vec![para_sheet("Normal RGB", 2, 0)], + vec![normal_char(30.0)], + ); + let t = layer("Text", &ed); + assert_eq!(t.paragraphs[0].style.align, TextAlign::Center, "Normal's alignment"); + assert!((t.runs[0].style.size_pt - 30.0).abs() < 0.01, "Normal's size: {}", t.runs[0].style.size_pt); +} + +/// Character attributes from the paragraph's default character style (`DefaultStyleSheet`) and +/// from a character style the run names. +#[test] +fn character_attributes_from_the_style_sheets() { + let big = d(vec![("Name", E::String("Big".into())), ("StyleSheetData", d(vec![("FontSize", real(72.0))]))]); + let ed = engine( + "Text", + d(vec![("DefaultStyleSheet", E::Int(1)), ("Properties", d(vec![]))]), + d(vec![("StyleSheetData", d(vec![]))]), + vec![para_sheet("Normal RGB", 0, 0)], + vec![normal_char(12.0), big.clone()], + ); + assert!((layer("Text", &ed).runs[0].style.size_pt - 72.0).abs() < 0.01, "the paragraph's default character style"); + let ed = engine( + "Text", + d(vec![("DefaultStyleSheet", E::Int(0)), ("Properties", d(vec![]))]), + d(vec![("Name", E::String("Big".into())), ("StyleSheetData", d(vec![]))]), + vec![para_sheet("Normal RGB", 0, 0)], + vec![normal_char(12.0), big], + ); + let t = layer("Text", &ed); + assert!((t.runs[0].style.size_pt - 72.0).abs() < 0.01, "the named character style: {}", t.runs[0].style.size_pt); + // Other attributes still come from Normal. + assert_eq!( + t.runs[0].style.font_family, + layer( + "Text", + &engine( + "Text", + d(vec![("Properties", d(vec![]))]), + d(vec![("StyleSheetData", d(vec![]))]), + vec![para_sheet("Normal RGB", 0, 0)], + vec![normal_char(12.0)] + ) + ) + .runs[0] + .style + .font_family + ); +} + +/// Export writes every run fully resolved and keeps the sheet sets: a round trip reads the same +/// alignment and size, with one entry per sheet. +#[test] +fn styles_from_sheets_survive_a_round_trip() { + use photocraft_color::{ColorMode, SampleType}; + use photocraft_doc::{Document, Layer, LayerContent, Size}; + let ed = engine( + "LOREM IPSUM", + d(vec![("Name", E::String("Title".into())), ("DefaultStyleSheet", E::Int(1)), ("Properties", d(vec![]))]), + d(vec![("StyleSheetData", d(vec![]))]), + vec![para_sheet("Normal RGB", 0, 0), para_sheet("Title", 2, 1)], + vec![normal_char(12.0), d(vec![("Name", E::String("Big".into())), ("StyleSheetData", d(vec![("FontSize", real(48.0))]))])], + ); + let mut t = layer("LOREM IPSUM", &ed); + let mut doc = Document::new("t", Size::new(900, 600), ColorMode::Rgb, SampleType::U8); + doc.resolution_dpi = 72.0; + photocraft_text::TextEngine::new().render_layer(&mut t, 72.0, doc.pixel_format()); + doc.layers.push(Layer::new("t", LayerContent::Text(t))); + let bytes = photocraft_io::export(&doc, "t.psd", &Default::default()).unwrap().bytes; + let back = photocraft_io::import("t.psd", &bytes).unwrap().document; + let LayerContent::Text(bt) = &back.layers[0].content else { panic!("not a type layer") }; + assert_eq!(bt.paragraphs[0].style.align, TextAlign::Center); + assert!((bt.runs[0].style.size_pt - 48.0).abs() < 0.01, "{}", bt.runs[0].style.size_pt); +} + +/// A layer that inherits its alignment and size from sheets it no longer links to (a document +/// without those styles) keeps them through export: runs are written with what they inherited. +#[test] +fn unlinked_runs_keep_their_inherited_style_on_export() { + use photocraft_color::{ColorMode, SampleType}; + use photocraft_doc::{Document, Layer, LayerContent, Size}; + let ed = engine( + "LOREM IPSUM", + d(vec![("Name", E::String("Title".into())), ("DefaultStyleSheet", E::Int(1)), ("Properties", d(vec![]))]), + d(vec![("StyleSheetData", d(vec![]))]), + vec![para_sheet("Normal RGB", 0, 0), para_sheet("Title", 2, 1)], + vec![normal_char(12.0), d(vec![("Name", E::String("Big".into())), ("StyleSheetData", d(vec![("FontSize", real(48.0))]))])], + ); + let mut t = layer("LOREM IPSUM", &ed); + assert!(t.psd_raw.is_some()); + let mut doc = Document::new("t", Size::new(900, 600), ColorMode::Rgb, SampleType::U8); + doc.resolution_dpi = 72.0; + photocraft_text::TextEngine::new().render_layer(&mut t, 72.0, doc.pixel_format()); + doc.layers.push(Layer::new("t", LayerContent::Text(t))); + // No `text_styles_map::import`: the document has no styles and the runs link to none. + let bytes = photocraft_io::export(&doc, "t.psd", &Default::default()).unwrap().bytes; + let back = photocraft_io::import("t.psd", &bytes).unwrap().document; + let LayerContent::Text(bt) = &back.layers[0].content else { panic!("not a type layer") }; + assert_eq!(bt.paragraphs[0].style.align, TextAlign::Center); + assert!((bt.runs[0].style.size_pt - 48.0).abs() < 0.01, "{}", bt.runs[0].style.size_pt); +} diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index f7494bd992..7fdbb8a01f 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -702,7 +702,11 @@ impl Layouter { Some(b) => b + leading + pending_space, }; pending_space = 0.0; - let overflow = if vertical { baseline + descent > -box_rect.0 + 0.5 } else { baseline + descent > box_rect.1 + box_rect.3 + 0.5 }; + // Photoshop keeps a horizontal line whose baseline is inside the box and lets + // its descenders hang below the bottom edge (#1317: three 35 pt lines in a + // 91.3 pt box, the last baseline at 88). Requiring the descent to fit dropped + // that line. + let overflow = if vertical { baseline + descent > -box_rect.0 + 0.5 } else { baseline > box_rect.1 + box_rect.3 + 0.5 }; if is_box && overflow { stop = true; break; diff --git a/crates/text/src/psd.rs b/crates/text/src/psd.rs index b1359f31bb..27b1909510 100644 --- a/crates/text/src/psd.rs +++ b/crates/text/src/psd.rs @@ -12,7 +12,7 @@ use photocraft_color::{Color, ColorMode}; use photocraft_doc::TextLayer; use photocraft_doc::text::{AntiAlias, Caps, CharStyle, Kerning, Orientation, ParagraphRun, ParagraphStyle, TextAlign, TextRun, TextShape, TextWarp}; -use photocraft_geom::Affine; +use photocraft_geom::{Affine, Point}; use photocraft_psd::descriptor::{Descriptor, Id, UnicodeString, Value as D, VersionedDescriptor}; use crate::engine_data::{self as ed, Value as E}; @@ -212,6 +212,83 @@ pub fn apply_txt2(layer: &mut TextLayer, tysh: &[u8], txt2: &E) { layer.sync_summary(); } +/// The frame (`Txt2 /0 /8 /0` entry) of text object `index`: its rendered frame's `/10` names it. +fn txt2_frame(txt2: &E, index: usize) -> Option<&E> { + let object = txt2.path(&["1", "1"]).and_then(E::as_array)?.get(index)?; + let frame = + object.path(&["1", "2"]).and_then(E::as_array)?.first()?.get("6").and_then(E::as_array)?.iter().find_map(|f| f.get("10").and_then(E::as_i64))?; + txt2.path(&["0", "8", "0"]).and_then(E::as_array)?.get(usize::try_from(frame).ok()?)?.get("0") +} + +/// Places `layer` where Photoshop draws it when the text object's frame in `Txt2` is moved +/// (#1317). Recent Photoshop keeps a type layer's position partly in its `Txt2` frame: a matrix +/// (`/2 /2`) from the frame's space to the `TySh` text space and, for point text, the anchor +/// (`/0`), while EngineData's `PointBase` stays at the origin and `BoxBounds` stays in the +/// frame's space. Ignoring them put the text far from Photoshop's pixels (off the canvas, so +/// it "disappeared" as soon as an edit started), and point text a line too high and off its +/// alignment anchor. Only when the layer's `TextIndex` entry still holds the layer's text, as +/// for [`apply_txt2`], and the frame agrees with the `TySh` `bounds`. +pub fn apply_txt2_frame(layer: &mut TextLayer, tysh: &[u8], txt2: &E) { + let Some(t) = parse_tysh(tysh) else { return }; + let Some(index) = (match t.text.get("TextIndex") { + Some(D::Integer(i)) => usize::try_from(*i).ok(), + _ => None, + }) else { + return; + }; + let text = txt2.path(&["1", "1"]).and_then(E::as_array).and_then(|a| a.get(index)).and_then(|o| o.path(&["0", "0"])).and_then(E::as_str); + if text.is_none_or(|t| t.strip_suffix('\r').unwrap_or(t).replace('\r', "\n") != layer.text) { + return; + } + let Some(frame) = txt2_frame(txt2, index) else { return }; + let m = arr_f(frame.path(&["2", "2"])); + let matrix = if m.len() == 6 && m.iter().all(|v| v.is_finite()) { Affine { m: [m[0], m[1], m[2], m[3], m[4], m[5]] } } else { Affine::IDENTITY }; + let at = match layer.shape { + TextShape::Point => { + let p = arr_f(frame.get("0")); + if p.len() == 2 && p.iter().all(|v| v.is_finite()) { Affine::translate(p[0], p[1]) } else { Affine::IDENTITY } + } + TextShape::Box { .. } => Affine::IDENTITY, + }; + let to_text = matrix.mul(&at); + if to_text == Affine::IDENTITY || to_text.determinant().abs() <= 1e-9 { + return; + } + // The frame's origin (point text: the anchor; box text: the box's top left) must land in + // the text-space `bounds` Photoshop wrote beside it, and not already be there without it. A `TySh` PhotoCraft rewrote (an edited + // or moved layer whose text is unchanged keeps its old `Txt2` object and frame) already has + // the frame folded into its transform and its `bounds` near the origin: applying the frame + // again would move it twice. + let origin = match layer.shape { + TextShape::Point => Point::new(0.0, 0.0), + TextShape::Box { .. } => { + let b = engine_data(&t.text) + .map(|e| { + arr_f( + e.path(&["EngineDict", "Rendered", "Shapes", "Children"]) + .and_then(E::as_array) + .and_then(|c| c.first()) + .and_then(|s| s.path(&["Cookie", "Photoshop", "BoxBounds"])), + ) + }) + .unwrap_or_default(); + let [x, y, ..] = b[..] else { return }; + Point::new(x, y) + } + }; + let Some([l, top, r, bottom]) = descriptor_rect(&t.text, "bounds") else { return }; + let inside = |p: Point| p.x >= l - 1.0 && p.x <= r + 1.0 && p.y >= top - 1.0 && p.y <= bottom + 1.0; + if inside(origin) || !inside(to_text.apply(origin)) { + return; + } + { + // From the file's own transform: the frame's anchor is exact, so it replaces the + // ink-box estimate `legacy_point_origin` made for such point text (#1469), which put + // the baseline at the ink's top and the anchor at the ink's left whatever the alignment. + layer.transform = t.transform.mul(&to_text); + } +} + /// OpenType settings of a `Txt2` style that are on/off flags, by style key, as Photoshop /// writes them (each turned on alone in the Character panel's OpenType settings and read back): /// `/20` Contextual Alternates, `/23` Fractions, `/24` Ordinals, `/25` Swash, `/26` Titling @@ -380,46 +457,97 @@ fn apply_engine_data(layer: &mut TextLayer, e: &E, txt: Option<&str>, k: f32) { .and_then(E::as_array) .map(|a| a.iter().map(|f| f.get("Name").and_then(E::as_str).unwrap_or("").to_string()).collect()) .unwrap_or_default(); - let default_sheet = res.and_then(|r| r.get("TheNormalStyleSheet")).and_then(E::as_i64).unwrap_or(0) as usize; - let base_style = - res.and_then(|r| r.get("StyleSheetSet")).and_then(E::as_array).and_then(|a| a.get(default_sheet).or(a.first())).and_then(|s| s.get("StyleSheetData")); - let default_para_sheet = res.and_then(|r| r.get("TheNormalParagraphSheet")).and_then(E::as_i64).unwrap_or(0) as usize; - let base_para = res - .and_then(|r| r.get("ParagraphSheetSet")) - .and_then(E::as_array) - .and_then(|a| a.get(default_para_sheet).or(a.first())) - .and_then(|s| s.get("Properties")); + // Style resolution (#3795). A run's own `StyleSheetData` / `Properties` hold only what it + // overrides; the rest comes from its sheets, as in Photoshop: + // - a paragraph run: its `Properties` → the `ParagraphSheetSet` entry its `Name` names (a + // paragraph style; or its `Parent` index) → `TheNormalParagraphSheet`; + // - a character run: its `StyleSheetData` → the `StyleSheetSet` entry its `Parent` or `Name` + // names (a character style) → the default character style of its paragraph (the paragraph run's + // `DefaultStyleSheet`, else its paragraph style's) → `TheNormalStyleSheet`. + // Missing or out-of-range indices fall through to the next level, then to the defaults. + let style_set: &[E] = res.and_then(|r| r.get("StyleSheetSet")).and_then(E::as_array).unwrap_or(&[]); + let para_set: &[E] = res.and_then(|r| r.get("ParagraphSheetSet")).and_then(E::as_array).unwrap_or(&[]); + let index = |key: &str| res.and_then(|r| r.get(key)).and_then(E::as_i64).and_then(|i| usize::try_from(i).ok()); + let normal_style = index("TheNormalStyleSheet").and_then(|i| style_set.get(i)).or(style_set.first()); + let normal_para = index("TheNormalParagraphSheet").and_then(|i| para_set.get(i)).or(para_set.first()); + // A run names its sheet by a `Parent` index or by `Name` (see `psd_styles::reference`). + let named = |set: &'_ [E], sheet: &E| -> Option { + if let Some(i) = sheet.get("Parent").and_then(E::as_i64).and_then(|i| usize::try_from(i).ok()).filter(|i| *i < set.len()) { + return Some(i); + } + let name = sheet.get("Name").and_then(E::as_str)?; + set.iter().position(|s| s.get("Name").and_then(E::as_str) == Some(name)) + }; + + // Paragraph runs first: a character run's defaults depend on its paragraph. + let prun = e.path(&["EngineDict", "ParagraphRun"]); + let psheets: Vec<&E> = + prun.and_then(|r| r.get("RunArray")).and_then(E::as_array).map(|a| a.iter().filter_map(|x| x.get("ParagraphSheet")).collect()).unwrap_or_default(); + let plens16 = arr_f(prun.and_then(|r| r.get("RunLengthArray"))); + let plens = utf16_to_byte_lengths(&ed_text, &plens16); + let empty = E::Dict(Vec::new()); + // Per paragraph run: its merged base properties and its default character style. + let para_bases: Vec<(E, Option<&E>)> = psheets + .iter() + .map(|sheet| { + let style = named(para_set, sheet).and_then(|i| para_set.get(i)); + let base = merged(&[style.and_then(|s| s.get("Properties")), normal_para.and_then(|s| s.get("Properties"))]); + let char_default = [Some(*sheet), style] + .into_iter() + .flatten() + .find_map(|s| s.get("DefaultStyleSheet").and_then(E::as_i64).and_then(|i| usize::try_from(i).ok()).and_then(|i| style_set.get(i))); + (base, char_default) + }) + .collect(); + layer.paragraphs = psheets + .iter() + .zip(¶_bases) + .zip(plens) + .map(|((sheet, (base, _)), len)| ParagraphRun { len, style: para_style(Some(base), sheet.get("Properties").unwrap_or(&empty), k) }) + .collect(); // Character runs. let srun = e.path(&["EngineDict", "StyleRun"]); - let sdata: Vec<&E> = srun - .and_then(|r| r.get("RunArray")) - .and_then(E::as_array) - .map(|a| a.iter().filter_map(|x| x.path(&["StyleSheet", "StyleSheetData"])).collect()) - .unwrap_or_default(); - let slens = utf16_to_byte_lengths(&ed_text, &arr_f(srun.and_then(|r| r.get("RunLengthArray")))); + let ssheets: Vec<&E> = + srun.and_then(|r| r.get("RunArray")).and_then(E::as_array).map(|a| a.iter().filter_map(|x| x.get("StyleSheet")).collect()).unwrap_or_default(); + let slens16 = arr_f(srun.and_then(|r| r.get("RunLengthArray"))); + let slens = utf16_to_byte_lengths(&ed_text, &slens16); + // The paragraph run each character run starts in (UTF-16 offsets). + let para_of = |at: f64| -> Option { + let mut end = 0.0; + for (i, n) in plens16.iter().enumerate() { + end += n; + if at < end { + return Some(i); + } + } + plens16.len().checked_sub(1) + }; + let mut at16 = 0.0; + let bases: Vec = ssheets + .iter() + .enumerate() + .map(|(i, sheet)| { + let own = named(style_set, sheet).and_then(|i| style_set.get(i)); + let para_default = para_of(at16).and_then(|p| para_bases.get(p)).and_then(|(_, c)| *c); + at16 += slens16.get(i).copied().unwrap_or(0.0); + merged(&[own, para_default, normal_style].map(|s| s.and_then(|s| s.get("StyleSheetData")))) + }) + .collect(); + let sdata: Vec<&E> = ssheets.iter().map(|s| s.get("StyleSheetData").unwrap_or(&empty)).collect(); let incoming: Vec<(usize, bool, f32)> = sdata .iter() + .zip(&bases) .zip(&slens) - .map(|(d, len)| { - let auto = lookup(d, base_style, "AutoKerning").and_then(E::as_bool) != Some(false); - let kern = lookup(d, base_style, "Kerning").and_then(E::as_f64).filter(|v| v.is_finite()).unwrap_or(0.0) as f32; + .map(|((d, base), len)| { + let auto = lookup(d, Some(base), "AutoKerning").and_then(E::as_bool) != Some(false); + let kern = lookup(d, Some(base), "Kerning").and_then(E::as_f64).filter(|v| v.is_finite()).unwrap_or(0.0) as f32; (*len, auto, kern) }) .collect(); - layer.runs = sdata.iter().zip(slens).map(|(d, len)| TextRun { len, style: char_style(base_style, d, &fonts, k) }).collect(); + layer.runs = sdata.iter().zip(&bases).zip(slens).map(|((d, base), len)| TextRun { len, style: char_style(Some(base), d, &fonts, k) }).collect(); layer.runs = kerning_from_pairs(&layer.text, layer.char_runs(), &incoming); - // Paragraph runs. - let prun = e.path(&["EngineDict", "ParagraphRun"]); - let pdata: Vec<&E> = prun - .and_then(|r| r.get("RunArray")) - .and_then(E::as_array) - .map(|a| a.iter().filter_map(|x| x.path(&["ParagraphSheet", "Properties"])).collect()) - .unwrap_or_default(); - let plens = utf16_to_byte_lengths(&ed_text, &arr_f(prun.and_then(|r| r.get("RunLengthArray")))); - layer.paragraphs = pdata.iter().zip(plens).map(|(d, len)| ParagraphRun { len, style: para_style(base_para, d, k) }).collect(); - // Shape. let shape = e.path(&["EngineDict", "Rendered", "Shapes", "Children"]).and_then(E::as_array).and_then(|a| a.first()); if let Some(sh) = shape @@ -432,6 +560,21 @@ fn apply_engine_data(layer: &mut TextLayer, e: &E, txt: Option<&str>, k: f32) { } } +/// The dictionaries in `levels` as one: a key comes from the first level that has it. +fn merged(levels: &[Option<&E>]) -> E { + let mut out: Vec<(String, E)> = Vec::new(); + for level in levels.iter().flatten() { + if let E::Dict(kv) = level { + for (k, v) in kv { + if !out.iter().any(|(o, _)| o == k) { + out.push((k.clone(), v.clone())); + } + } + } + } + E::Dict(out) +} + fn lookup<'a>(run: &'a E, base: Option<&'a E>, key: &str) -> Option<&'a E> { run.get(key).or_else(|| base.and_then(|b| b.get(key))) } diff --git a/crates/text/src/psd_styles.rs b/crates/text/src/psd_styles.rs index fb3b297fdc..4caba32115 100644 --- a/crates/text/src/psd_styles.rs +++ b/crates/text/src/psd_styles.rs @@ -55,6 +55,20 @@ fn reference(sheet: Option<&E>, set_names: &[String], normal: usize) -> Option Option { let t = parse_tysh(tysh)?; @@ -170,6 +184,7 @@ pub fn write_style_sheets(tysh: &[u8], layer: &TextLayer, styles: &TextStyles, d rd.set("TheNormalParagraphSheet", E::Int(0)); rd.set("FontSet", E::Array(fonts.iter().map(|f| crate::psd::font_entry(f)).collect())); } + let char_fonts = e.path(&["ResourceDict", "FontSet"]).or_else(|| e.path(&["DocumentResources", "FontSet"])).map(|f| names(Some(f))).unwrap_or_default(); let char_ix = |id: Option| id.and_then(|id| styles.character.iter().position(|d| d.id == id)).map(|i| i as i64 + 1); let para_ix = |id: Option| id.and_then(|id| styles.paragraph.iter().position(|d| d.id == id)).map(|i| i as i64 + 1); let runs = layer.char_runs(); @@ -193,9 +208,16 @@ pub fn write_style_sheets(tysh: &[u8], layer: &TextLayer, styles: &TextStyles, d if let Some(sheet) = r.get_mut("StyleSheet") { remove_key(sheet, "Parent"); remove_key(sheet, "Name"); - if let Some(ix) = char_ix(run_at(start).and_then(|r| r.style.style_sheet)) { + let run = run_at(start); + if let Some(ix) = char_ix(run.and_then(|r| r.style.style_sheet)) { sheet.set("Parent", E::Int(ix)); } + // The run's reference may be dropped or renumbered: write what it inherited + // too, so it keeps its look whatever sheet it now points to (#3795). + if let Some(run) = run { + let fi = char_fonts.iter().position(|f| *f == postscript_for(&run.style)).unwrap_or(0); + fill_missing(sheet, "StyleSheetData", style_sheet_data(&run.style, fi, k)); + } } } } @@ -208,6 +230,9 @@ pub fn write_style_sheets(tysh: &[u8], layer: &TextLayer, styles: &TextStyles, d if let Some(ix) = para_ix(paras.get(i).and_then(|p| p.style.style_sheet)) { sheet.set("Parent", E::Int(ix)); } + if let Some(p) = paras.get(i) { + fill_missing(sheet, "Properties", paragraph_properties(&p.style, k)); + } } } } diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index dc8df9e5e3..ae3092ca46 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -1580,6 +1580,104 @@ fn a_served_script_fallback_joins_the_fallback_stack_once_it_arrives() { assert!(db.fallback_stack().any(|f| f == "Cairo"), "{:?}", db.fallback_stack().collect::>()); } +/// #1317: Photoshop keeps the last line of a text box whose baseline is inside the box even +/// when its descenders hang below the bottom edge (three 35 pt lines of 25 pt type in a +/// 91.3 pt box). A line whose baseline falls outside the box is still dropped. +#[test] +fn box_lines_whose_descent_hangs_below_the_box_are_kept() { + let mut e = TextEngine::new(); + let mut t = styled("one\ntwo\nthree", CharStyle { size_pt: 25.0, leading_pt: Some(35.0), ..Default::default() }); + t.shape = TextShape::Box { x: 0.0, y: 0.0, width: 300.0, height: 91.27 }; + let l = e.layout(&t, 72.0); + assert_eq!(l.lines.len(), 3, "{:?}", l.lines.iter().map(|l| l.baseline).collect::>()); + assert!(l.lines[2].baseline <= 91.27 + 0.5); + t.shape = TextShape::Box { x: 0.0, y: 0.0, width: 300.0, height: 80.0 }; + assert_eq!(e.layout(&t, 72.0).lines.len(), 2, "the third baseline (about 88) is below an 80 pt box"); +} + +/// `Txt2` with one text object for `text` whose rendered frame is frame 0, and that frame: +/// for point text an anchor `point`, and a frame-to-text matrix `matrix` (recent Photoshop, +/// #1317). +fn txt2_with_frame(text: &str, point: Option<[f64; 2]>, matrix: [f64; 6]) -> Vec { + let mut v = txt2_with_modes(text, &[(text.encode_utf16().count() + 1, 1)]); + // Close the object's model early: `… >> >> ] >>` → `… >> /1 << /2 [ frame ] >> >> ] >>`. + let tail = b"] >> >> >> ] >>"; + assert!(v.ends_with(tail)); + v.truncate(v.len() - tail.len()); + v.extend_from_slice(b"] >> >> /1 << /2 [ << /6 [ << /10 0 >> ] >> ] >> >> ] >>"); + let pt = point.map(|[x, y]| format!("/0 [ {x:.5} {y:.5} ] ")).unwrap_or_default(); + let m = matrix.map(|x| format!("{x:.5}")).join(" "); + let frames = format!("/0 << /8 << /0 [ << /0 << {pt}/2 << /2 [ {m} ] >> >> >> ] >> >>"); + let at = v.windows(9).position(|w| w == b"/0 << >> ").unwrap(); + v.splice(at..at + 9, frames.into_bytes().into_iter().chain(*b" ")); + v +} + +/// #1317: point text whose anchor Photoshop keeps only in its `Txt2` frame (EngineData's +/// `PointBase` at the origin, the transform far away) is placed at that anchor, not at the +/// ink's top left. A rewritten `TySh` (bounds near the origin) is left alone. +#[test] +fn txt2_frame_places_point_text_at_its_anchor() { + let mut t = point("2", 100.0); + t.transform = Affine::translate(-1000.0, -2000.0); + // Photoshop's text space: anchor at (1100, 2050), ink above its baseline. + let tysh = with_text_index(&crate::psd::build_tysh(&t, 72.0, Some([1100.0, 1980.0, 1160.0, 2050.0])), 0); + let txt2 = crate::psd::parse_txt2(&txt2_with_frame("2", Some([1000.0, 2100.0]), [1.0, 0.0, 0.0, 1.0, 100.0, -50.0])).unwrap(); + let mut l = crate::psd::text_layer_from_tysh(&tysh, 72.0).unwrap(); + crate::psd::apply_txt2_frame(&mut l, &tysh, &txt2); + let o = l.transform.apply(photocraft_geom::Point::new(0.0, 0.0)); + assert!((o.x - 100.0).abs() < 1e-3 && (o.y - 50.0).abs() < 1e-3, "anchor at {o:?}"); + // Already folded in (bounds near the origin): unchanged; another text: unchanged. + let tysh = with_text_index(&crate::psd::build_tysh(&t, 72.0, Some([0.0, -70.0, 60.0, 0.0])), 0); + let mut l = crate::psd::text_layer_from_tysh(&tysh, 72.0).unwrap(); + let before = l.transform; + crate::psd::apply_txt2_frame(&mut l, &tysh, &txt2); + assert_eq!(l.transform, before); + let tysh = with_text_index(&crate::psd::build_tysh(&t, 72.0, Some([1100.0, 1980.0, 1160.0, 2050.0])), 0); + let other = crate::psd::parse_txt2(&txt2_with_frame("3", Some([1000.0, 2100.0]), [1.0, 0.0, 0.0, 1.0, 100.0, -50.0])).unwrap(); + let mut l = crate::psd::text_layer_from_tysh(&tysh, 72.0).unwrap(); + let before = l.transform; + crate::psd::apply_txt2_frame(&mut l, &tysh, &other); + assert_eq!(l.transform, before); +} + +/// `tysh` with its text-space `bounds` set to `r` (Photoshop's, where it drew the text). +fn with_bounds(tysh: &[u8], r: [f64; 4]) -> Vec { + use photocraft_psd::descriptor::{Descriptor, Id, Value}; + let mut t = crate::psd::parse_tysh(tysh).unwrap(); + let mut d = Descriptor::default(); + for (k, v) in ["Left", "Top ", "Rght", "Btom"].iter().zip(r) { + d.items.push((Id::new(k), Value::Double(v))); + } + t.text.items.retain(|(k, _)| !k.is("bounds")); + t.text.items.push((Id::new("bounds"), Value::Descriptor(d))); + crate::psd::write_tysh(&t) +} + +/// #1317: a text box whose `BoxBounds` are in its `Txt2` frame's space is moved by the frame's +/// matrix onto the `bounds` Photoshop drew it at (it used to land off the canvas, so its text +/// "disappeared" when an edit started). +#[test] +fn txt2_frame_moves_box_text_onto_its_bounds() { + let mut t = styled("Casemiro 45", CharStyle { size_pt: 25.0, ..Default::default() }); + t.shape = TextShape::Box { x: 500.0, y: 600.0, width: 300.0, height: 90.0 }; + t.transform = Affine::translate(-700.0, -300.0); + let built = with_text_index(&crate::psd::build_tysh(&t, 72.0, None), 0); + let tysh = with_bounds(&built, [960.0, 300.0, 1260.0, 390.0]); + let txt2 = crate::psd::parse_txt2(&txt2_with_frame("Casemiro 45", None, [1.0, 0.0, 0.0, 1.0, 460.0, -300.0])).unwrap(); + let mut l = crate::psd::text_layer_from_tysh(&tysh, 72.0).unwrap(); + assert!(matches!(l.shape, TextShape::Box { .. })); + crate::psd::apply_txt2_frame(&mut l, &tysh, &txt2); + let TextShape::Box { x, y, .. } = l.shape else { panic!("not a box") }; + let o = l.transform.apply(photocraft_geom::Point::new(f64::from(x), f64::from(y))); + assert!((o.x - 260.0).abs() < 1e-3 && (o.y - 0.0).abs() < 1e-3, "box top left at {o:?}"); + // PhotoCraft's own `TySh` (bounds = the box, frame already folded in): unchanged. + let mut l = crate::psd::text_layer_from_tysh(&built, 72.0).unwrap(); + let before = l.transform; + crate::psd::apply_txt2_frame(&mut l, &built, &txt2); + assert_eq!(l.transform, before); +} + /// Preferences ▸ Type ▸ Missing Glyph Protection: off, only the default family is in the /// fallback stack, so a face without the glyph shows the missing-glyph box instead of a /// fallback's glyph (#1909).