Merge PR #6094: Fix legacy PSD point-text placement after font resolution

This commit is contained in:
David Mann
2026-10-22 23:40:19 +02:00
7 changed files with 173 additions and 40 deletions
+1 -1
View File
@@ -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 })
} }
+2 -1
View File
@@ -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 })
} }
+11 -4
View File
@@ -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
View File
@@ -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]> {
+8
View File
@@ -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
View File
@@ -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]
+2 -1
View File
@@ -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 |