mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-24 01:47: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
|
||||
// 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 })
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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]> {
|
||||
|
||||
@@ -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
@@ -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]
|
||||
|
||||
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user