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
// frames come from an untrusted file.
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.
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)?;
*damaged = psd_damaged;
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 })
}
+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
/// let Photoshop re-render on open) is rendered from its model, instead of staying empty
/// until it is edited.
pub(crate) fn prepare(doc: &mut Document) {
fn visit(layers: &mut [Layer], eng: &mut photocraft_text::TextEngine, dpi: f32, format: photocraft_color::PixelFormat) {
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,
txt2: Option<&photocraft_text::engine_data::Value>,
) {
for l in layers {
match &mut l.content {
LayerContent::Text(t) => {
@@ -56,11 +62,12 @@ pub(crate) fn prepare(doc: &mut Document) {
if changed {
t.sync_summary();
}
photocraft_text::psd::refine_legacy_point_origin(t, dpi, eng, txt2);
if t.cache.is_none() {
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 format = doc.pixel_format();
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).
if let Some([x, y]) = legacy_point_origin(&t) {
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();
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.
///
/// 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
// 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.
(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]> {
+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)
}
/// 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.
pub fn ink_rect_warped(layout: &TextLayout, transform: &Affine, warp: Option<&Warp>) -> Rect {
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.
/// The TySh transform cancels that origin, so importing it as a zero-based layout puts text far
/// off-canvas. Fold the descriptor origin into the imported transform and preserve it on a TySh
/// round trip so every editing and export path uses the same position.
#[test]
fn legacy_tysh_point_origin_imports_and_round_trips_on_canvas() {
/// A legacy-signature `TySh` (#1469): zero PointBase, `bounds` (the layout box) and `boundingBox`
/// (the ink box, as Photoshop writes it) both around `anchor` in a remote local space, and a
/// `transform` that cancels the remote origin.
fn legacy_tysh(source: &TextLayer, transform: Affine, anchor: [f64; 2], engine: &mut TextEngine) -> Vec<u8> {
use photocraft_psd::descriptor::{Descriptor, Id, Value as D};
let source = styled("Name", CharStyle { font_family: "Inter".into(), size_pt: 120.0, ..Default::default() });
let mut tysh = crate::psd::parse_tysh(&crate::psd::build_tysh(&source, 72.0, None)).unwrap();
tysh.transform = Affine { m: [4.1667, 0.0, 0.0, 4.1667, -32375.0, -32887.5] };
let ink = [8050.0, 8160.0, 8220.0, 8300.0];
let rect = |class| {
let layout = engine.layout(source, 72.0);
let logical = layout.bounds().unwrap().map(f64::from);
let ink = crate::render::ink_bounds(&layout).unwrap();
let rect = |class, r: [f64; 4]| {
D::Descriptor(
Descriptor::new(class)
.with("Left", D::UnitFloat { unit: *b"#Pnt", value: ink[0] })
.with("Top ", D::UnitFloat { unit: *b"#Pnt", value: ink[1] })
.with("Rght", D::UnitFloat { unit: *b"#Pnt", value: ink[2] })
.with("Btom", D::UnitFloat { unit: *b"#Pnt", value: ink[3] }),
.with("Left", D::UnitFloat { unit: *b"#Pnt", value: anchor[0] + r[0] })
.with("Top ", D::UnitFloat { unit: *b"#Pnt", value: anchor[1] + r[1] })
.with("Rght", D::UnitFloat { unit: *b"#Pnt", value: anchor[0] + r[2] })
.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.push((Id::new("bounds"), rect("bounds")));
tysh.text.items.push((Id::new("boundingBox"), rect("boundingBox")));
let data = crate::psd::write_tysh(&tysh);
tysh.text.items.push((Id::new("bounds"), rect("bounds", logical)));
tysh.text.items.push((Id::new("boundingBox"), rect("boundingBox", ink)));
crate::psd::write_tysh(&tysh)
}
let imported = crate::psd::text_layer_from_tysh(&data, 72.0).unwrap();
let expected = [4.1667, 0.0, 0.0, 4.1667, 1166.935, 1112.772];
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:?}");
fn assert_transform_near(got: Affine, want: Affine, tolerance: f64, what: &str) {
assert!(got.m.iter().zip(want.m).all(|(g, w)| (g - w).abs() < tolerance), "{what}: {:?} vs {:?}", got.m, want.m);
}
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();
for (got, want) in round_trip.transform.m.iter().zip(expected) {
assert!((got - want).abs() < 0.001, "{:?}", round_trip.transform.m);
/// #1469: Some older PSD writers combine a zero PointBase with an enormous local ink origin.
/// The layout's own ink box is lined up with the saved ink box, so point alignment anchors stay
/// 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)
/// panicked with `expect("EngineDict")` when the layer was written back (PSD export).
#[test]
+2 -1
View File
@@ -1,6 +1,6 @@
# 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
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-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-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-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 |