mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-24 09:57:08 -06:00
Merge PR #6094: Fix legacy PSD point-text placement after font resolution
This commit is contained in:
@@ -47,7 +47,7 @@ pub(crate) fn import(name: &str, bytes: &[u8]) -> Result<ImportResult, IoError>
|
|||||||
// Type layers draw from their model, within the import's raster bound: the sizes and
|
// Type layers draw from their model, within the import's raster bound: the sizes and
|
||||||
// frames come from an untrusted file.
|
// frames come from an untrusted file.
|
||||||
let mut document = b.doc;
|
let mut document = b.doc;
|
||||||
crate::text_import::prepare(&mut document);
|
crate::text_import::prepare(&mut document, None);
|
||||||
// Save never writes back over the Affinity file.
|
// Save never writes back over the Affinity file.
|
||||||
Ok(ImportResult { document, warnings, source_read_only: true, preview_only: false })
|
Ok(ImportResult { document, warnings, source_read_only: true, preview_only: false })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -384,7 +384,8 @@ fn import_psd(name: &str, bytes: &[u8], ctl: &photocraft_raster::Interrupt, dama
|
|||||||
let (mut document, warnings, psd_damaged) = psd_import::psd_to_document_decoded_integrity(&file, ctl, merged).ok_or(IoError::Cancelled)?;
|
let (mut document, warnings, psd_damaged) = psd_import::psd_to_document_decoded_integrity(&file, ctl, merged).ok_or(IoError::Cancelled)?;
|
||||||
*damaged = psd_damaged;
|
*damaged = psd_damaged;
|
||||||
document.name = name.to_string();
|
document.name = name.to_string();
|
||||||
text_import::prepare(&mut document);
|
let txt2 = file.global_blocks.iter().find(|b| &b.key == b"Txt2").and_then(|b| photocraft_text::psd::parse_txt2(&b.data));
|
||||||
|
text_import::prepare(&mut document, txt2.as_ref());
|
||||||
Ok(ImportResult { document, warnings, source_read_only: false, preview_only: false })
|
Ok(ImportResult { document, warnings, source_read_only: false, preview_only: false })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,8 +28,14 @@ fn render_bounded(eng: &mut photocraft_text::TextEngine, t: &mut TextLayer, dpi:
|
|||||||
/// - a type layer without pixels (ag-psd, GIMP and other writers leave the image data out and
|
/// - a type layer without pixels (ag-psd, GIMP and other writers leave the image data out and
|
||||||
/// let Photoshop re-render on open) is rendered from its model, instead of staying empty
|
/// let Photoshop re-render on open) is rendered from its model, instead of staying empty
|
||||||
/// until it is edited.
|
/// until it is edited.
|
||||||
pub(crate) fn prepare(doc: &mut Document) {
|
pub(crate) fn prepare(doc: &mut Document, txt2: Option<&photocraft_text::engine_data::Value>) {
|
||||||
fn visit(layers: &mut [Layer], eng: &mut photocraft_text::TextEngine, dpi: f32, format: photocraft_color::PixelFormat) {
|
fn visit(
|
||||||
|
layers: &mut [Layer],
|
||||||
|
eng: &mut photocraft_text::TextEngine,
|
||||||
|
dpi: f32,
|
||||||
|
format: photocraft_color::PixelFormat,
|
||||||
|
txt2: Option<&photocraft_text::engine_data::Value>,
|
||||||
|
) {
|
||||||
for l in layers {
|
for l in layers {
|
||||||
match &mut l.content {
|
match &mut l.content {
|
||||||
LayerContent::Text(t) => {
|
LayerContent::Text(t) => {
|
||||||
@@ -56,11 +62,12 @@ pub(crate) fn prepare(doc: &mut Document) {
|
|||||||
if changed {
|
if changed {
|
||||||
t.sync_summary();
|
t.sync_summary();
|
||||||
}
|
}
|
||||||
|
photocraft_text::psd::refine_legacy_point_origin(t, dpi, eng, txt2);
|
||||||
if t.cache.is_none() {
|
if t.cache.is_none() {
|
||||||
render_bounded(eng, t, dpi, format);
|
render_bounded(eng, t, dpi, format);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
LayerContent::Group(g) => visit(&mut g.children, eng, dpi, format),
|
LayerContent::Group(g) => visit(&mut g.children, eng, dpi, format, txt2),
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -68,5 +75,5 @@ pub(crate) fn prepare(doc: &mut Document) {
|
|||||||
let dpi = doc.resolution_dpi;
|
let dpi = doc.resolution_dpi;
|
||||||
let format = doc.pixel_format();
|
let format = doc.pixel_format();
|
||||||
let mut eng = photocraft_text::shared().lock().unwrap_or_else(|e| e.into_inner());
|
let mut eng = photocraft_text::shared().lock().unwrap_or_else(|e| e.into_inner());
|
||||||
visit(&mut doc.layers, &mut eng, dpi, format);
|
visit(&mut doc.layers, &mut eng, dpi, format, txt2);
|
||||||
}
|
}
|
||||||
|
|||||||
+40
-2
@@ -390,12 +390,46 @@ pub fn text_layer_from_tysh(data: &[u8], dpi: f32) -> Option<TextLayer> {
|
|||||||
// writing the TySh all agree on the visible position (#1469).
|
// writing the TySh all agree on the visible position (#1469).
|
||||||
if let Some([x, y]) = legacy_point_origin(&t) {
|
if let Some([x, y]) = legacy_point_origin(&t) {
|
||||||
let [a, b, c, d, e, f] = layer.transform.m;
|
let [a, b, c, d, e, f] = layer.transform.m;
|
||||||
layer.transform.m = [a, b, c, d, a * x + c * y + e, b * x + d * y + f];
|
let corrected = [a, b, c, d, a * x + c * y + e, b * x + d * y + f];
|
||||||
|
if corrected.iter().all(|v| v.is_finite()) {
|
||||||
|
layer.transform.m = corrected;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
layer.sync_summary();
|
layer.sync_summary();
|
||||||
Some(layer)
|
Some(layer)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Refines a legacy point-text estimate after imported fonts and styles have been resolved.
|
||||||
|
/// Uses the rendering engine's font context; an accepted `Txt2` frame stays authoritative.
|
||||||
|
pub fn refine_legacy_point_origin(layer: &mut TextLayer, dpi: f32, engine: &mut crate::TextEngine, txt2: Option<&E>) {
|
||||||
|
let Some(raw) = layer.psd_raw.clone() else { return };
|
||||||
|
let Some(t) = parse_tysh(&raw) else { return };
|
||||||
|
let Some([x, y]) = legacy_point_origin(&t) else { return };
|
||||||
|
if !matches!(layer.shape, TextShape::Point) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let guessed = t.transform.mul(&Affine::translate(x, y));
|
||||||
|
if layer.transform != guessed {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// `boundingBox` is the tight ink box (Photoshop writes the layout box as `bounds`), so match
|
||||||
|
// it with the layout's ink, not its logical box: text whose ink starts below the ascent
|
||||||
|
// line (`...`, `_`, lowercase) would otherwise land too low.
|
||||||
|
let Some(ink) = crate::render::ink_bounds(&engine.layout(layer, dpi)) else { return };
|
||||||
|
if !ink.iter().all(|v| v.is_finite()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let corrected = t.transform.mul(&Affine::translate(x - ink[0], y - ink[1]));
|
||||||
|
if corrected.m.iter().all(|v| v.is_finite()) {
|
||||||
|
layer.transform = corrected;
|
||||||
|
// A valid frame can coincide with the initial estimate; numeric equality cannot tell
|
||||||
|
// whether it was used. Reapply it after the fallback so it always wins.
|
||||||
|
if let Some(txt2) = txt2 {
|
||||||
|
apply_txt2_frame(layer, &raw, txt2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The local origin to fold into a legacy point-text transform, if present.
|
/// The local origin to fold into a legacy point-text transform, if present.
|
||||||
///
|
///
|
||||||
/// Photoshop normally keeps point text close to its PointBase. The affected legacy files instead
|
/// Photoshop normally keeps point text close to its PointBase. The affected legacy files instead
|
||||||
@@ -426,7 +460,11 @@ fn legacy_point_origin(t: &TySh) -> Option<[f64; 2]> {
|
|||||||
// A local bounding-box origin in the thousands and a transform origin beyond a normal canvas
|
// A local bounding-box origin in the thousands and a transform origin beyond a normal canvas
|
||||||
// are the signature of this old writer. Requiring that the bounding-box origin is closer to
|
// are the signature of this old writer. Requiring that the bounding-box origin is closer to
|
||||||
// the document prevents applying the correction again after export/import.
|
// the document prevents applying the correction again after export/import.
|
||||||
(bounds[0].abs().max(bounds[1].abs()) > 1024.0 && origin_distance > 4096.0 && shifted_distance < origin_distance).then_some([bounds[0], bounds[1]])
|
if !(bounds[0].abs().max(bounds[1].abs()) > 1024.0 && origin_distance > 4096.0 && shifted_distance < origin_distance) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
Some([bounds[0], bounds[1]])
|
||||||
}
|
}
|
||||||
|
|
||||||
fn descriptor_rect(text: &Descriptor, key: &str) -> Option<[f64; 4]> {
|
fn descriptor_rect(text: &Descriptor, key: &str) -> Option<[f64; 4]> {
|
||||||
|
|||||||
@@ -287,6 +287,14 @@ fn rect_from_bounds([x0, y0, x1, y1]: [f64; 4]) -> Rect {
|
|||||||
Rect::new(x0 as i32, y0 as i32, x1 as i32, y1 as i32)
|
Rect::new(x0 as i32, y0 as i32, x1 as i32, y1 as i32)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Text-space bounds of the drawn glyph outlines and decorations, unrounded (`[x0, y0, x1, y1]`),
|
||||||
|
/// or `None` when nothing is drawn.
|
||||||
|
pub fn ink_bounds(layout: &TextLayout) -> Option<[f64; 4]> {
|
||||||
|
let mut b = Bounds::default();
|
||||||
|
draw(layout, &Xform::IDENTITY, &mut b, None, None);
|
||||||
|
b.rect
|
||||||
|
}
|
||||||
|
|
||||||
/// [`ink_rect`] of warped text.
|
/// [`ink_rect`] of warped text.
|
||||||
pub fn ink_rect_warped(layout: &TextLayout, transform: &Affine, warp: Option<&Warp>) -> Rect {
|
pub fn ink_rect_warped(layout: &TextLayout, transform: &Affine, warp: Option<&Warp>) -> Rect {
|
||||||
let mut b = Bounds::default();
|
let mut b = Bounds::default();
|
||||||
|
|||||||
+109
-31
@@ -1650,48 +1650,126 @@ fn psd_round_trips_antialias_opentype_and_warp() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// #1469: Some older PSD writers combine a zero PointBase with an enormous local ink origin.
|
/// A legacy-signature `TySh` (#1469): zero PointBase, `bounds` (the layout box) and `boundingBox`
|
||||||
/// The TySh transform cancels that origin, so importing it as a zero-based layout puts text far
|
/// (the ink box, as Photoshop writes it) both around `anchor` in a remote local space, and a
|
||||||
/// off-canvas. Fold the descriptor origin into the imported transform and preserve it on a TySh
|
/// `transform` that cancels the remote origin.
|
||||||
/// round trip so every editing and export path uses the same position.
|
fn legacy_tysh(source: &TextLayer, transform: Affine, anchor: [f64; 2], engine: &mut TextEngine) -> Vec<u8> {
|
||||||
#[test]
|
|
||||||
fn legacy_tysh_point_origin_imports_and_round_trips_on_canvas() {
|
|
||||||
use photocraft_psd::descriptor::{Descriptor, Id, Value as D};
|
use photocraft_psd::descriptor::{Descriptor, Id, Value as D};
|
||||||
|
let layout = engine.layout(source, 72.0);
|
||||||
let source = styled("Name", CharStyle { font_family: "Inter".into(), size_pt: 120.0, ..Default::default() });
|
let logical = layout.bounds().unwrap().map(f64::from);
|
||||||
let mut tysh = crate::psd::parse_tysh(&crate::psd::build_tysh(&source, 72.0, None)).unwrap();
|
let ink = crate::render::ink_bounds(&layout).unwrap();
|
||||||
tysh.transform = Affine { m: [4.1667, 0.0, 0.0, 4.1667, -32375.0, -32887.5] };
|
let rect = |class, r: [f64; 4]| {
|
||||||
let ink = [8050.0, 8160.0, 8220.0, 8300.0];
|
|
||||||
let rect = |class| {
|
|
||||||
D::Descriptor(
|
D::Descriptor(
|
||||||
Descriptor::new(class)
|
Descriptor::new(class)
|
||||||
.with("Left", D::UnitFloat { unit: *b"#Pnt", value: ink[0] })
|
.with("Left", D::UnitFloat { unit: *b"#Pnt", value: anchor[0] + r[0] })
|
||||||
.with("Top ", D::UnitFloat { unit: *b"#Pnt", value: ink[1] })
|
.with("Top ", D::UnitFloat { unit: *b"#Pnt", value: anchor[1] + r[1] })
|
||||||
.with("Rght", D::UnitFloat { unit: *b"#Pnt", value: ink[2] })
|
.with("Rght", D::UnitFloat { unit: *b"#Pnt", value: anchor[0] + r[2] })
|
||||||
.with("Btom", D::UnitFloat { unit: *b"#Pnt", value: ink[3] }),
|
.with("Btom", D::UnitFloat { unit: *b"#Pnt", value: anchor[1] + r[3] }),
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
let mut tysh = crate::psd::parse_tysh(&crate::psd::build_tysh(source, 72.0, None)).unwrap();
|
||||||
|
tysh.transform = transform;
|
||||||
tysh.text.items.retain(|(key, _)| !key.is("bounds") && !key.is("boundingBox"));
|
tysh.text.items.retain(|(key, _)| !key.is("bounds") && !key.is("boundingBox"));
|
||||||
tysh.text.items.push((Id::new("bounds"), rect("bounds")));
|
tysh.text.items.push((Id::new("bounds"), rect("bounds", logical)));
|
||||||
tysh.text.items.push((Id::new("boundingBox"), rect("boundingBox")));
|
tysh.text.items.push((Id::new("boundingBox"), rect("boundingBox", ink)));
|
||||||
let data = crate::psd::write_tysh(&tysh);
|
crate::psd::write_tysh(&tysh)
|
||||||
|
}
|
||||||
|
|
||||||
let imported = crate::psd::text_layer_from_tysh(&data, 72.0).unwrap();
|
fn assert_transform_near(got: Affine, want: Affine, tolerance: f64, what: &str) {
|
||||||
let expected = [4.1667, 0.0, 0.0, 4.1667, 1166.935, 1112.772];
|
assert!(got.m.iter().zip(want.m).all(|(g, w)| (g - w).abs() < tolerance), "{what}: {:?} vs {:?}", got.m, want.m);
|
||||||
for (got, want) in imported.transform.m.iter().zip(expected) {
|
}
|
||||||
assert!((got - want).abs() < 0.001, "{:?}", imported.transform.m);
|
|
||||||
}
|
|
||||||
let (_, rendered) = TextEngine::new().render(&imported, 72.0, PixelFormat::RGBA8);
|
|
||||||
let placed = rendered.surface.content_bounds();
|
|
||||||
assert!(!placed.is_empty(), "the normalized layer renders");
|
|
||||||
assert!((1000..3000).contains(&placed.x0) && (500..2000).contains(&placed.y0), "{placed:?}");
|
|
||||||
|
|
||||||
let round_trip = crate::psd::text_layer_from_tysh(&crate::psd::build_tysh(&imported, 72.0, Some(ink.map(|v| v as f32))), 72.0).unwrap();
|
/// #1469: Some older PSD writers combine a zero PointBase with an enormous local ink origin.
|
||||||
for (got, want) in round_trip.transform.m.iter().zip(expected) {
|
/// The layout's own ink box is lined up with the saved ink box, so point alignment anchors stay
|
||||||
assert!((got - want).abs() < 0.001, "{:?}", round_trip.transform.m);
|
/// fixed for left, centered and right text through import and a TySh round trip.
|
||||||
|
#[test]
|
||||||
|
fn legacy_tysh_point_origin_imports_and_round_trips_on_canvas() {
|
||||||
|
use photocraft_psd::descriptor::Value as D;
|
||||||
|
|
||||||
|
let original = Affine { m: [4.1667, 0.0, 0.0, 4.1667, -32375.0, -32887.5] };
|
||||||
|
// The file's layout origin (the anchor on the first baseline) in its local space.
|
||||||
|
let anchor = [8050.0, 8160.0];
|
||||||
|
let expected = original.mul(&Affine::translate(anchor[0], anchor[1]));
|
||||||
|
for align in [TextAlign::Left, TextAlign::Center, TextAlign::Right] {
|
||||||
|
let mut source = styled("Name", CharStyle { font_family: "Inter".into(), size_pt: 120.0, ..Default::default() });
|
||||||
|
source.paragraphs = vec![ParagraphRun { len: source.text.len(), style: ParagraphStyle { align, ..Default::default() } }];
|
||||||
|
let mut engine = TextEngine::new();
|
||||||
|
let data = legacy_tysh(&source, original, anchor, &mut engine);
|
||||||
|
|
||||||
|
let mut imported = crate::psd::text_layer_from_tysh(&data, 72.0).unwrap();
|
||||||
|
imported.psd_raw = Some(std::sync::Arc::new(data.clone()));
|
||||||
|
crate::psd::refine_legacy_point_origin(&mut imported, 72.0, &mut engine, None);
|
||||||
|
assert_eq!(imported.paragraph_runs()[0].style.align, align);
|
||||||
|
assert_transform_near(imported.transform, expected, 1e-6, &format!("{align:?}: anchor"));
|
||||||
|
|
||||||
|
let (_, rendered) = engine.render(&imported, 72.0, PixelFormat::RGBA8);
|
||||||
|
let placed = rendered.surface.content_bounds();
|
||||||
|
assert!(!placed.is_empty(), "{align:?}: no rendered pixels");
|
||||||
|
let mut want = source.clone();
|
||||||
|
want.transform = expected;
|
||||||
|
let (_, expected_pixels) = engine.render(&want, 72.0, PixelFormat::RGBA8);
|
||||||
|
assert_eq!(placed, expected_pixels.surface.content_bounds(), "{align:?}: raster placement");
|
||||||
|
|
||||||
|
let native = engine.layout(&imported, 72.0).bounds();
|
||||||
|
let round_trip = crate::psd::text_layer_from_tysh(&crate::psd::build_tysh(&imported, 72.0, native), 72.0).unwrap();
|
||||||
|
assert_transform_near(round_trip.transform, expected, 0.01, &format!("{align:?}: round trip"));
|
||||||
|
|
||||||
|
// A valid frame may equal the old ink-corner estimate. It must still win over refinement.
|
||||||
|
let t = crate::psd::parse_tysh(&data).unwrap();
|
||||||
|
let Some(D::Descriptor(ink_box)) = t.text.get("boundingBox") else { panic!("boundingBox") };
|
||||||
|
let corner = |k| match ink_box.get(k) {
|
||||||
|
Some(D::UnitFloat { value, .. }) => *value,
|
||||||
|
_ => panic!("{k}"),
|
||||||
|
};
|
||||||
|
let framed = with_text_index(&data, 0);
|
||||||
|
let txt2 = crate::psd::parse_txt2(&txt2_with_frame("Name", Some([corner("Left"), corner("Top ")]), [1.0, 0.0, 0.0, 1.0, 0.0, 0.0])).unwrap();
|
||||||
|
let mut with_frame = crate::psd::text_layer_from_tysh(&framed, 72.0).unwrap();
|
||||||
|
with_frame.psd_raw = Some(std::sync::Arc::new(framed.clone()));
|
||||||
|
crate::psd::apply_txt2_frame(&mut with_frame, &framed, &txt2);
|
||||||
|
let authoritative = with_frame.transform;
|
||||||
|
crate::psd::refine_legacy_point_origin(&mut with_frame, 72.0, &mut engine, Some(&txt2));
|
||||||
|
assert_eq!(with_frame.transform, authoritative, "{align:?}: exact frame wins");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// #6094 review: `boundingBox` is the tight ink box (a Photoshop-saved Arial 120 pt "..." has
|
||||||
|
/// `bounds` top -102.98 but `boundingBox` top -11). Text whose ink starts well below the ascent
|
||||||
|
/// line keeps its baseline: lining the layout box up with the ink box put "..." and "_" about
|
||||||
|
/// 116 pt too low.
|
||||||
|
#[test]
|
||||||
|
fn legacy_origin_lines_up_ink_with_the_ink_bounding_box() {
|
||||||
|
let original = Affine { m: [1.0, 0.0, 0.0, 1.0, -7900.0, -7550.0] };
|
||||||
|
for text in ["Name", "...", "_"] {
|
||||||
|
let source = styled(text, CharStyle { font_family: "Inter".into(), size_pt: 120.0, ..Default::default() });
|
||||||
|
let mut engine = TextEngine::new();
|
||||||
|
let data = legacy_tysh(&source, original, [8000.0, 8000.0], &mut engine);
|
||||||
|
let mut imported = crate::psd::text_layer_from_tysh(&data, 72.0).unwrap();
|
||||||
|
imported.psd_raw = Some(std::sync::Arc::new(data));
|
||||||
|
crate::psd::refine_legacy_point_origin(&mut imported, 72.0, &mut engine, None);
|
||||||
|
let baseline = imported.transform.apply(photocraft_geom::Point::new(0.0, 0.0));
|
||||||
|
assert!((baseline.x - 100.0).abs() < 1e-6 && (baseline.y - 450.0).abs() < 1e-6, "{text:?}: {baseline:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn legacy_origin_uses_the_rendering_engines_registered_face() {
|
||||||
|
let mut engine = TextEngine::new();
|
||||||
|
let mut style = CharStyle { font_family: "JetBrains Mono".into(), font_style: "Regular".into(), size_pt: 100.0, ..Default::default() };
|
||||||
|
// This exact-face alias exists only in this engine, like a user-registered font.
|
||||||
|
engine.fonts.select_named_face(&mut style);
|
||||||
|
assert!(style.font_family.starts_with(".PhotoCraft-face-"));
|
||||||
|
let source = with_para(styled("Name", style), ParagraphStyle { align: TextAlign::Right, ..Default::default() });
|
||||||
|
let raw = legacy_tysh(&source, Affine::translate(-8000.0, -8000.0), [8100.0, 8200.0], &mut engine);
|
||||||
|
let mut imported = crate::psd::text_layer_from_tysh(&raw, 72.0).unwrap();
|
||||||
|
// Import preparation has resolved the face before refining placement.
|
||||||
|
imported.runs = source.runs.clone();
|
||||||
|
imported.sync_summary();
|
||||||
|
imported.psd_raw = Some(std::sync::Arc::new(raw));
|
||||||
|
crate::psd::refine_legacy_point_origin(&mut imported, 72.0, &mut engine, None);
|
||||||
|
let placed = imported.transform.apply(photocraft_geom::Point::new(0.0, 0.0));
|
||||||
|
assert!((placed.x - 100.0).abs() < 1e-6 && (placed.y - 200.0).abs() < 1e-6, "{placed:?}");
|
||||||
|
}
|
||||||
|
|
||||||
/// Regression: a PSD whose text engine data has no `EngineDict` (or isn't a dictionary)
|
/// Regression: a PSD whose text engine data has no `EngineDict` (or isn't a dictionary)
|
||||||
/// panicked with `expect("EngineDict")` when the layer was written back (PSD export).
|
/// panicked with `expect("EngineDict")` when the layer was written back (PSD export).
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# File-format parity
|
# File-format parity
|
||||||
|
|
||||||
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-22 · **Change:** minor (`.atn`: a recorded Save As imports with its format and file (`file.saveAs`, `file.saveACopy` for As a Copy) instead of as a plain Save that wrote over the played file, #7102; folder destinations stop playback until #5430; `.asl` layer style libraries read and written, #1051; JPEG Options on File › Save, Save As and Save a Copy, FILE-215-19 partial (no Matte, progressive Scans or size preview); 32-bit Hue/Saturation unclipped and Colorize as Photoshop does them, Photoshop-oracle floor 190/258, #5781; JPEG Options on Save As and Save a Copy, FILE-215-19; TIFF compression for scripted saves, `tiffCompression`, #5301; the Nikon D4 white level follows the sample depth (12 and 14 bit), #5978; Apply Layer Comp action mapping, #7101; Cineon write: 10-bit gray/RGB; animated WebP frames load into video layers, #1198; Photoshop mixed-order TIFF type geometry, #4758; DDS textures save: uncompressed or BC1–BC3, optional mip chain, DDS Options in Save As (#1327); PSD smart filters: 53 filters (39 more: Pinch, Polar Coordinates, Ripple, Spherize, Twirl, Wave, ZigZag, Invert, Blur, Surface Blur, Smart Sharpen, Diffuse, Wind, Offset, Mezzotint, Clouds, Difference Clouds, Fibers, Lens Flare and others) plus Hue/Saturation and Perspective Warp mapped both ways, #6296, #6298, #6300, #6302, #6304, #6314, #6316, #6267, #6268, FILE-215-2; large-file fixtures: a 20 MP layered PSD, a 150-layer layout, a 2 GB PSD and a 4.4 GB PSB generated at test time and round-tripped, FILE-216-2 done, #5107; earlier: smart objects keep Photoshop's Cylinder warp through re-renders and saves, #4112; PNG `sTER` stereo pairing kept on save, #6063; PDF write one flattened page via Save As / Save a Copy / CLI, #3401; CMYK Invert, Posterize and Threshold on the inks; smart filter masks on PSD open and save; Photoshop-oracle floor 188/258; lossy DNG, compression 34892, decodes with its levels and LinearizationTable applied to the stored 8-bit samples, FILE-215-11; CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding) · **Target:** Adobe Photoshop 2026
|
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-22 · **Change:** minor (legacy PSD point text placed from the layout ink box after font resolution, #6094; `.atn`: a recorded Save As imports with its format and file (`file.saveAs`, `file.saveACopy` for As a Copy) instead of as a plain Save that wrote over the played file, #7102; folder destinations stop playback until #5430; `.asl` layer style libraries read and written, #1051; JPEG Options on File › Save, Save As and Save a Copy, FILE-215-19 partial (no Matte, progressive Scans or size preview); 32-bit Hue/Saturation unclipped and Colorize as Photoshop does them, Photoshop-oracle floor 190/258, #5781; JPEG Options on Save As and Save a Copy, FILE-215-19; TIFF compression for scripted saves, `tiffCompression`, #5301; the Nikon D4 white level follows the sample depth (12 and 14 bit), #5978; Apply Layer Comp action mapping, #7101; Cineon write: 10-bit gray/RGB; animated WebP frames load into video layers, #1198; Photoshop mixed-order TIFF type geometry, #4758; DDS textures save: uncompressed or BC1–BC3, optional mip chain, DDS Options in Save As (#1327); PSD smart filters: 53 filters (39 more: Pinch, Polar Coordinates, Ripple, Spherize, Twirl, Wave, ZigZag, Invert, Blur, Surface Blur, Smart Sharpen, Diffuse, Wind, Offset, Mezzotint, Clouds, Difference Clouds, Fibers, Lens Flare and others) plus Hue/Saturation and Perspective Warp mapped both ways, #6296, #6298, #6300, #6302, #6304, #6314, #6316, #6267, #6268, FILE-215-2; large-file fixtures: a 20 MP layered PSD, a 150-layer layout, a 2 GB PSD and a 4.4 GB PSB generated at test time and round-tripped, FILE-216-2 done, #5107; earlier: smart objects keep Photoshop's Cylinder warp through re-renders and saves, #4112; PNG `sTER` stereo pairing kept on save, #6063; PDF write one flattened page via Save As / Save a Copy / CLI, #3401; CMYK Invert, Posterize and Threshold on the inks; smart filter masks on PSD open and save; Photoshop-oracle floor 188/258; lossy DNG, compression 34892, decodes with its levels and LinearizationTable applied to the stored 8-bit samples, FILE-215-11; CMYK Hue/Saturation and Color Balance keep the black ink; CMYK Posterize on the stored values, psd-tools oracle floor 242/309; CMYK adjustment layers composite on the document's inks; Photoshop-oracle floor 165/258; CMYK Brightness/Contrast on the inks, CMYK Threshold's luma; modern Brightness/Contrast's brightness curve; Photoshop-oracle floor 151/258; Lab documents composite on their stored L, a, b; Photoshop-oracle floor 152/258; Levels on whole levels at every step with Photoshop's shadow toe; Photoshop-oracle floor 146/258, psd-tools 238/309; 32-bit adjustments unclipped; Photoshop-oracle floor 149/258; mask feathers blur with their exact variance; Threshold splits on Photoshop's luminosity; a pass-through clipping base with an adjustment renders isolated; psd-tools oracle floor 240/309; 16-bit Posterize as Photoshop bins it; psd-tools oracle floor 241/309; Gradient Map luminosity; Photoshop-oracle floor 147/258; PSD: `Mt16`/`Mt32` merged transparency read; palette PNGs open in Indexed Color mode, #4804; 16-bit Lab Invert; Photoshop-oracle floor 145/258; `.atn` import maps 112 events; DDS textures open, #1327; DPX read and write, #1049; #2465 fixed: CS2 placed layers with only a `PlLd` block open their contents; Photoshop Actions `.atn` read and write; `.shc` contour files read and write; earlier the same day: indexed BMP palette mode; Save keeps a JPEG's quality and a WebP's encoding) · **Target:** Adobe Photoshop 2026
|
||||||
|
|
||||||
Every format Photoshop 2026 (27.11.0) reads or writes, with PhotoCraft's support, fidelity and
|
Every format Photoshop 2026 (27.11.0) reads or writes, with PhotoCraft's support, fidelity and
|
||||||
tests. Photoshop's list comes from its `Info.plist` `CFBundleDocumentTypes`, the PiPL read/write
|
tests. Photoshop's list comes from its `Info.plist` `CFBundleDocumentTypes`, the PiPL read/write
|
||||||
@@ -147,6 +147,7 @@ remaining gaps in #1606 stay tracked with per-sample corpus ceilings.
|
|||||||
| 2026-10-18 | minor | Scripted TIFF saves take `tiffCompression` (none, LZW, ZIP), and the CLI `--tiff-compression` (#5301) |
|
| 2026-10-18 | minor | Scripted TIFF saves take `tiffCompression` (none, LZW, ZIP), and the CLI `--tiff-compression` (#5301) |
|
||||||
| 2026-10-17 | minor | `.atn`: a recorded Save As imports with its format and file (`file.saveAs`, `file.saveACopy` for As a Copy) instead of as a plain Save that wrote over the played file, #7102; folder destinations stop playback until #5430 |
|
| 2026-10-17 | minor | `.atn`: a recorded Save As imports with its format and file (`file.saveAs`, `file.saveACopy` for As a Copy) instead of as a plain Save that wrote over the played file, #7102; folder destinations stop playback until #5430 |
|
||||||
| 2026-10-17 | minor | `.atn`: Apply Layer Comp by name maps to `layerComp.apply` and exports as `applyComp` (#7101) |
|
| 2026-10-17 | minor | `.atn`: Apply Layer Comp by name maps to `layerComp.apply` and exports as `applyComp` (#7101) |
|
||||||
|
| 2026-10-15 | minor | Legacy PSD point-text placement is refined after font resolution using the rendering engine; centered/right alignment keeps its layout offset, while valid `Txt2` frames remain authoritative. Synthetic alignment, font-context and round-trip regressions cover this fallback. |
|
||||||
| 2026-10-15 | minor | Cineon write: Save As `.cin` stores 10-bit log gray/RGB frames (Cineon 4.5 header, three samples to a 32-bit word); a Cineon frame saved back keeps its code values |
|
| 2026-10-15 | minor | Cineon write: Save As `.cin` stores 10-bit log gray/RGB frames (Cineon 4.5 header, three samples to a 32-bit word); a Cineon frame saved back keeps its code values |
|
||||||
| 2026-10-14 | minor | 32-bit Hue/Saturation as Photoshop does it: the raised saturation isn't clipped (saturated colours leave 0..1) and the slider is cut to whole 8-bit levels, and Colorize builds its colour with the saturation times 1 − \|Lightness\| (8 and 16 bits unchanged): photoshop rgb32 hue-saturation and hue-saturation-colorize pass |
|
| 2026-10-14 | minor | 32-bit Hue/Saturation as Photoshop does it: the raised saturation isn't clipped (saturated colours leave 0..1) and the slider is cut to whole 8-bit levels, and Colorize builds its colour with the saturation times 1 − \|Lightness\| (8 and 16 bits unchanged): photoshop rgb32 hue-saturation and hue-saturation-colorize pass |
|
||||||
| 2026-10-14 | minor | Lab documents round each adjustment layer's result on the stored L, a, b to whole samples, as Photoshop stores them (the native Lab path): photoshop lab8 curves.psd passes |
|
| 2026-10-14 | minor | Lab documents round each adjustment layer's result on the stored L, a, b to whole samples, as Photoshop stores them (the native Lab path): photoshop lab8 curves.psd passes |
|
||||||
|
|||||||
Reference in New Issue
Block a user