mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-28 18:22:06 +01:00
* PSD type: resolve paragraph and character styles from the run's own sheets (#3795) apply_engine_data filled a run's missing attributes only from the Normal sheets (TheNormalParagraphSheet / TheNormalStyleSheet). A run that takes its alignment or size from a named sheet (by Parent index or Name), or from its paragraph's DefaultStyleSheet, was read with Normal's values, so a centred paragraph style came back Left and starting an edit re-laid the text half a line to the side. Resolve run -> named sheet -> paragraph's default character style -> Normal, ignoring missing or out-of-range references. On export, runs whose sheet reference is rewritten also get the attributes they inherited, so they keep their look whatever they now point to. * PSD type: place text at its Txt2 frame and keep box lines whose descent overhangs (#1317) Recent Photoshop keeps part of a type layer's position in its Txt2 frame: a frame-to-text-space matrix and, for point text, the anchor, with EngineData's PointBase left at the origin and BoxBounds in the frame's space. Import ignored them, so box text landed far off the canvas once an edit started (the text 'disappeared') and point text moved up a line and off its alignment anchor. Apply the frame when its origin lands in the TySh bounds (and not already without it, so a TySh PhotoCraft rewrote is not moved twice). Photoshop also keeps a box line whose baseline is inside the box while its descenders hang below it; layout dropped such lines (and some single-line boxes rendered nothing). --------- Co-authored-by: Brandon Thomas <echelon@gmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Brandon Thomas
Claude Opus 5.5
parent
8d49ecf50d
commit
73ed08ddbe
@@ -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
|
||||
|
||||
@@ -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<E>, style_set: Vec<E>) -> 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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
+173
-30
@@ -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<usize> {
|
||||
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<usize> {
|
||||
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<E> = 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)))
|
||||
}
|
||||
|
||||
@@ -55,6 +55,20 @@ fn reference(sheet: Option<&E>, set_names: &[String], normal: usize) -> Option<u
|
||||
(i != normal && i < set_names.len()).then_some(i)
|
||||
}
|
||||
|
||||
/// Adds to `sheet[key]` (a dictionary) every entry of `resolved` it lacks; existing entries win.
|
||||
fn fill_missing(sheet: &mut E, key: &str, resolved: E) {
|
||||
let E::Dict(all) = resolved else { return };
|
||||
if sheet.get(key).is_none() {
|
||||
sheet.set(key, E::dict());
|
||||
}
|
||||
let Some(E::Dict(have)) = sheet.get_mut(key) else { return };
|
||||
for (k, v) in all {
|
||||
if !have.iter().any(|(h, _)| *h == k) {
|
||||
have.push((k, v));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads the style sheets of a `TySh` block. None when it has no engine data.
|
||||
pub fn read_style_sheets(tysh: &[u8], dpi: f32) -> Option<PsdStyleSheets> {
|
||||
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<u32>| id.and_then(|id| styles.character.iter().position(|d| d.id == id)).map(|i| i as i64 + 1);
|
||||
let para_ix = |id: Option<u32>| 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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::<Vec<_>>());
|
||||
}
|
||||
|
||||
/// #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::<Vec<_>>());
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
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).
|
||||
|
||||
Reference in New Issue
Block a user