PSD import: a pattern-filled shape opens as a pattern fill layer (#2059)

Since #1917 the exporter writes pattern fill pixels; a vector-masked pattern fill then reopened as a
shape layer covering the canvas, failing the corpus round-trip floor (adjustment-fillers.psd).
Pattern-filled plain shapes now import as pattern fill layers with their vector mask.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Brandon ThomasandClaude Opus 5.5 authored and GitHub committed 2026-10-09 15:59:48 -04:00
1 parent b8fe7dd5ee
commit 9b0c6c4d74
2 files changed
+37 -8

No files matched your search

+14 -8
View File
@@ -84,8 +84,8 @@ fn selected_real_mask(rec: &LayerRecord) -> Option<photocraft_psd::RealMask> {
/// The fill of a plain shape layer (fill block + vector path, no stroke) to import as a fill layer
/// with a vector mask: when its mask parameters give the vector mask a density below 100 % or a
/// feather, or when it stores no pixels and fills with a pattern.
fn soft_shape_fill(rec: &LayerRecord, has_vector: bool, fill_key: Option<&[u8; 4]>) -> Option<photocraft_doc::Fill> {
/// feather (`.1` true), or when it fills with a pattern.
fn soft_shape_fill(rec: &LayerRecord, has_vector: bool, fill_key: Option<&[u8; 4]>) -> Option<(photocraft_doc::Fill, bool)> {
if !has_vector || rec.block(b"vstk").is_some() || rec.block(b"vscg").is_some() {
return None;
}
@@ -93,10 +93,12 @@ fn soft_shape_fill(rec: &LayerRecord, has_vector: bool, fill_key: Option<&[u8; 4
let fill = rec.block(k).and_then(|b| blocks::parse_fill(k, &b.data))?;
let p = rec.layer_mask().and_then(|m| m.parameters);
let soft = p.is_some_and(|p| p.vector_density.is_some_and(|d| d < 255) || p.vector_feather.is_some_and(|f| f > 0.0));
// Without stored pixels a pattern-filled shape cannot be rasterized on its own (the
// compositor resolves the document's patterns for fill layers).
let unrendered_pattern = (rec.rect.is_empty() || rec.rect.size().is_err()) && matches!(fill, photocraft_doc::Fill::Pattern { .. });
(soft || unrendered_pattern).then_some(fill)
// A pattern-filled shape can't be rasterized on its own (the compositor resolves the
// document's patterns for fill layers), and its stored pixels may be the unclipped fill
// (our own export writes fill layers so, #1907): it is a pattern fill layer seen through
// its vector mask.
let pattern = matches!(fill, photocraft_doc::Fill::Pattern { .. });
(soft || pattern).then_some((fill, soft))
}
impl Ctx<'_> {
@@ -335,10 +337,14 @@ impl Ctx<'_> {
// Distort / Perspective: the fourth corner (the affine `transform` drops it).
perspective: rec.block(k).and_then(|b| blocks::parse_smart_perspective(k, &b.data)),
})
} else if let Some(f) = soft_shape_fill(rec, vector_key.is_some(), fill_key) {
} else if let Some((f, soft)) = soft_shape_fill(rec, vector_key.is_some(), fill_key) {
// A shape whose vector mask has a density or feather is a fill layer seen through a
// soft vector mask: the fill shows beyond the path, which the stored pixels (the
// shape alone) lack. Import it as exactly that.
// shape alone) lack. Import it as exactly that. A pattern fill keeps the stored
// pixels as its rendering (a soft mask's pixels lack the fill beyond the path).
if !soft && matches!(f, photocraft_doc::Fill::Pattern { .. }) && !rec.rect.is_empty() {
fill_cache = Some(FillCache { fill: f.clone(), surface: self.record_surface(rec, &name) });
}
LayerContent::Fill(f)
} else if (vector_key.is_some() && (fill_key.is_some() || rec.block(b"vstk").is_some())) || rec.block(b"vscg").is_some() {
let fill = fill_key.and_then(|k| rec.block(k).and_then(|b| blocks::parse_fill(k, &b.data)));
+23
View File
@@ -59,3 +59,26 @@ fn pattern_fill_survives_psd_and_layered_tiff_in_every_mode() {
}
}
}
/// A pattern fill seen through a vector mask (Photoshop's pattern-filled shape) stays a pattern
/// fill layer after a save and reopen: its stored pixels are the unclipped fill, which must not
/// turn it into a shape layer drawing the pattern over the whole canvas (psd-tools
/// adjustment-fillers.psd).
#[test]
fn vector_masked_pattern_fill_survives_psd() {
for mode in [ColorMode::Rgb, ColorMode::Cmyk] {
let mut d = doc(mode, SampleType::U8);
let fmt = d.pixel_format();
let mut white = Layer::raster("Background", fmt);
white.surface_mut().expect("raster").fill_rect(Rect::new(0, 0, 40, 24), &photocraft_raster::from_rgba(&fmt, [1.0, 1.0, 1.0, 1.0]));
d.layers.insert(0, white);
d.layers[1].vector_mask = Some(photocraft_doc::VectorMask::new(photocraft_vector::shapes::rect(8.0, 4.0, 16.0, 12.0)));
let r = export(&d, "x.psd", &ExportOptions::default()).expect("export");
let back = import("x.psd", &r.bytes).expect("import").document;
let l = back.layers.iter().find(|l| l.name == "Pattern Fill 1").expect("layer");
assert!(matches!(l.content, LayerContent::Fill(Fill::Pattern { .. })), "{mode:?}: reopened as a pattern fill");
assert!(l.vector_mask.is_some(), "{mode:?}: keeps its vector mask");
let diff = max_diff(&d, &back);
assert!(diff < 0.02, "{mode:?}: the reopened layer differs by {diff}");
}
}