fix: Preserve CMYK solid and gradient fills through PSD round trips (#2628)

* fix(compose): Preserve native CMYK fills through PSD round trips

Share depth-quantized ink samples between rendering and PSD caches.
Use the document profile for RGB fill colours and preserve valid imported
caches. Uncached CMYK fills use CPU rendering.

Cover all sample depths, gradient styles and tiled renders. Fix the real
16-bit gradient round trip and raise the psd-tools floor from 307 to 308.

* perf(raster): Reuse exact 8-bit CMYK conversions within rows

Cache the previous ink bytes and ICC result per row, with alpha decoded
independently. Keep the 16/32-bit conversion loops unchanged.

Matched 1 MP reads improve by 77% for smooth ramps, 33% with dithering
and 10% for random colours. Bitwise regression coverage includes all
depths, alpha and tile boundaries.
This commit is contained in:
Rene Leonhardt authored and GitHub committed 2026-10-10 13:11:54 -04:00
1 parent d9a9ce5f4d
commit 5290930e4a
9 files changed
+259 -25

No files matched your search

+74 -21
View File
@@ -5,8 +5,7 @@
//! The mapping matches the Gradient tool's destructive drag (`photocraft_algo::paint`): a
//! Linear gradient runs from the start (t = 0) to the end point (t = 1); Radial, Angle,
//! Reflected and Diamond gradients are centred on the start point and reach t = 1 at the end
//! point's distance. A fill made from a drag therefore renders the same pixels as painting that
//! drag (see the tests).
//! point's distance. Fill and painted gradients share this geometry (see the tests).
use photocraft_doc::{Fill, GradientStyle};
use photocraft_geom::Rect;
@@ -19,8 +18,8 @@ const MIN_LEN: f32 = 0.5;
/// A gradient fill's colour ramp, prepared for one render (colours converted once, while the
/// document's CMYK profile is active).
#[derive(Clone, Debug)]
pub struct Ramp {
color: Vec<(f32, [f32; 4])>,
pub struct Ramp<const N: usize = 4> {
color: Vec<(f32, [f32; N])>,
mids: Vec<f32>,
opacity: Vec<(f32, f32)>,
}
@@ -28,14 +27,17 @@ pub struct Ramp {
impl Ramp {
/// The ramp of a gradient fill (`None` for other fills).
pub fn new(f: &Fill) -> Option<Ramp> {
Self::with_colors(f, |c| {
let rgb = c.to_rgb();
[rgb[0], rgb[1], rgb[2], c.alpha]
})
}
}
impl<const N: usize> Ramp<N> {
fn with_colors(f: &Fill, convert: impl Fn(photocraft_doc::Color) -> [f32; N]) -> Option<Self> {
let Fill::Gradient { stops, opacity_stops, midpoints, .. } = f else { return None };
let mut color: Vec<(f32, [f32; 4])> = stops
.iter()
.map(|(p, c)| {
let rgb = c.to_rgb();
(*p, [rgb[0], rgb[1], rgb[2], c.alpha])
})
.collect();
let mut color: Vec<_> = stops.iter().map(|(p, c)| (*p, convert(*c))).collect();
color.sort_by(|a, b| a.0.total_cmp(&b.0));
let mut opacity = opacity_stops.clone();
opacity.sort_by(|a, b| a.0.total_cmp(&b.0));
@@ -43,10 +45,10 @@ impl Ramp {
}
/// Colour and alpha at `t` (`0..=1`).
pub fn sample(&self, t: f32) -> [f32; 4] {
pub fn sample(&self, t: f32) -> [f32; N] {
let mut c = sample_color(&self.color, &self.mids, t);
if !self.opacity.is_empty() {
c[3] *= sample_opacity(&self.opacity, t);
if let Some(alpha) = c.last_mut() {
*alpha *= sample_opacity(&self.opacity, t);
}
c
}
@@ -65,8 +67,8 @@ pub fn midpoint_remap(u: f32, m: f32) -> f32 {
}
}
fn sample_color(stops: &[(f32, [f32; 4])], mids: &[f32], t: f32) -> [f32; 4] {
let (Some(first), Some(last)) = (stops.first(), stops.last()) else { return [0.0; 4] };
fn sample_color<const N: usize>(stops: &[(f32, [f32; N])], mids: &[f32], t: f32) -> [f32; N] {
let (Some(first), Some(last)) = (stops.first(), stops.last()) else { return [0.0; N] };
if t <= first.0 {
return first.1;
}
@@ -97,29 +99,57 @@ fn sample_opacity(stops: &[(f32, f32)], t: f32) -> f32 {
/// Adds one level of the shared dither noise to a colour (as the Gradient tool does).
pub fn dither(c: &mut [f32; 4], x: i32, y: i32) {
dither_channels(&mut c[..3], x, y);
}
fn dither_channels(c: &mut [f32], x: i32, y: i32) {
let n = (photocraft_color::dither_noise(x, y) - 0.5) / 255.0;
for ch in c.iter_mut().take(3) {
for ch in c {
*ch = (*ch + n).clamp(0.0, 1.0);
}
}
/// Renders a gradient fill over `rect`, laid out in `frame` (row-major RGBA).
pub fn render(f: &Fill, rect: Rect, frame: Rect) -> Vec<[f32; 4]> {
let mut px = vec![[0.0f32; 4]; rect.width() as usize * rect.height() as usize];
let (Some(ramp), Fill::Gradient { angle, scale, style, reverse, offset, dither: dith, .. }) = (Ramp::new(f), f) else { return px };
render_ramp(f, rect, frame, Ramp::new(f))
}
/// Native CMYK solid/gradient pixels at document depth; patterns return `None`.
/// Use the same samples for rendering and PSD caches to avoid a lossy RGB separation.
pub fn render_cmyk_fill(f: &Fill, rect: Rect, frame: Rect, depth: photocraft_color::SampleType) -> Option<photocraft_raster::Surface> {
let native = |c: photocraft_doc::Color| {
let c = c.in_mode(photocraft_color::ColorMode::Cmyk);
[c.c[0], c.c[1], c.c[2], c.c[3], c.alpha]
};
let mut surface = photocraft_raster::Surface::new(photocraft_color::PixelFormat::new(photocraft_color::ColorMode::Cmyk, depth, true));
match f {
Fill::Solid(c) => surface.fill_rect(rect, &native(*c)),
Fill::Gradient { .. } => {
let px = render_ramp(f, rect, frame, Ramp::with_colors(f, native));
surface.write_region(rect, px.as_flattened());
}
Fill::Pattern { .. } => return None,
}
surface.prune();
Some(surface)
}
fn render_ramp<const N: usize>(f: &Fill, rect: Rect, frame: Rect, ramp: Option<Ramp<N>>) -> Vec<[f32; N]> {
let mut px = vec![[0.0f32; N]; rect.width() as usize * rect.height() as usize];
let (Some(ramp), Fill::Gradient { angle, scale, style, reverse, offset, dither: dith, .. }) = (ramp, f) else { return px };
let w = rect.width() as usize;
if w == 0 {
return px;
}
// Linear and Reflected end points on whole pixels, as Photoshop (crate::fill_layout).
let (angle, scale, offset) = &crate::fill_layout::gradient_layout(*style, *angle, *scale, *offset, frame);
let row = |y: i32, out: &mut [[f32; 4]]| {
let row = |y: i32, out: &mut [[f32; N]]| {
for (i, p) in out.iter_mut().enumerate() {
let x = rect.x0 + i as i32;
let t = crate::effects::gradient_t(*style, *angle, *scale, *reverse, *offset, frame, x as f32 + 0.5, y as f32 + 0.5);
*p = ramp.sample(t);
if *dith {
dither(p, x, y);
dither_channels(p.get_mut(..N.saturating_sub(1)).unwrap_or_default(), x, y);
}
}
};
@@ -205,6 +235,29 @@ mod tests {
const STYLES: [GradientStyle; 5] = [GradientStyle::Linear, GradientStyle::Radial, GradientStyle::Angle, GradientStyle::Reflected, GradientStyle::Diamond];
#[test]
fn native_cmyk_ramp_interpolates_black_and_alpha_separately() {
let a = Color { mode: photocraft_color::ColorMode::Cmyk, c: [0.0, 0.2, 0.4, 0.6], alpha: 0.5 };
let b = Color { c: [0.4, 0.6, 0.8, 1.0], alpha: 1.0, ..a };
let mut f = Fill::gradient(vec![(1.0, b), (0.0, a)], 0.0, 1.0, GradientStyle::Linear, false);
if let Fill::Gradient { midpoints, opacity_stops, .. } = &mut f {
*midpoints = vec![0.25];
*opacity_stops = vec![(1.0, 0.0), (0.0, 1.0)];
}
let ramp = Ramp::with_colors(&f, |c| [c.c[0], c.c[1], c.c[2], c.c[3], c.alpha]).unwrap();
let expected = [0.2, 0.4, 0.6, 0.8, 0.5625];
for (v, expected) in ramp.sample(0.25).into_iter().zip(expected) {
assert!((v - expected).abs() < 1e-6);
}
let mut dithered = ramp.sample(0.25);
dither_channels(&mut dithered[..4], 3, 7);
assert_eq!(dithered[4], expected[4]);
let noise = (photocraft_color::dither_noise(3, 7) - 0.5) / 255.0;
for i in 0..4 {
assert!((dithered[i] - expected[i] - noise).abs() < 1e-6);
}
}
#[test]
fn handles_round_trip() {
let frame = Rect::new(10, -4, 210, 96);
+9
View File
@@ -734,6 +734,15 @@ fn render_content(layer: &Layer, rect: Rect, cx: &Ctx) -> Option<Buffer> {
LayerContent::Fill(f) => match &layer.fill_cache {
// Photoshop's own rendering, valid while the fill is unchanged.
Some(c) if c.fill == *f => surface_to_buffer(&c.surface, rect),
_ if cx.mode == photocraft_color::ColorMode::Cmyk => {
let frame = fill_frame(layer, cx.canvas);
let native_rect = if matches!(f, Fill::Solid(_)) { Rect::new(0, 0, 1, 1) } else { rect };
match gradient_fill::render_cmyk_fill(f, native_rect, frame, cx.depth) {
Some(s) if matches!(f, Fill::Solid(_)) => Buffer::filled(rect, s.rgba(0, 0)),
Some(s) => surface_to_buffer(&s, rect),
None => render_fill(f, rect, frame, cx.patterns),
}
}
_ => render_fill(f, rect, fill_frame(layer, cx.canvas), cx.patterns),
},
LayerContent::Adjustment(_) => return None,
+24
View File
@@ -821,6 +821,10 @@ impl<'a> Planner<'a> {
}
fn fill(&self, p: &mut Pass<'a>, f: &Fill, frame: photocraft_geom::Rect) -> Result<(), Unsupported> {
// The RGB shader cannot interpolate or dither native ink channels.
if self.cx.mode == photocraft_color::ColorMode::Cmyk && !matches!(f, Fill::Pattern { .. }) {
return Err(Unsupported("native CMYK fill (composited on the CPU)".into()));
}
match f {
Fill::Solid(c) => {
let rgb = c.to_rgb();
@@ -1681,6 +1685,26 @@ mod tests {
use photocraft_color::{Color, ColorMode, SampleType};
use photocraft_geom::Size;
#[test]
fn native_cmyk_fills_fall_back_only_without_a_valid_cache() {
use photocraft_doc::{FillCache, GradientStyle};
for depth in photocraft_color::SampleType::ALL {
for fill in [Fill::Solid(Color::WHITE), Fill::gradient(vec![(0.0, Color::BLACK), (1.0, Color::WHITE)], 0.0, 1.0, GradientStyle::Linear, false)] {
let mut doc = Document::new("CMYK", photocraft_geom::Size::new(3, 2), photocraft_color::ColorMode::Cmyk, depth);
doc.layers.push(Layer::new("Fill", LayerContent::Fill(fill.clone())));
assert!(plan(&doc).unwrap_err().0.contains("native CMYK fill"));
doc.layers[0].visible = false;
assert!(plan(&doc).is_ok());
doc.layers[0].visible = true;
doc.layers[0].fill_cache = Some(FillCache { fill, surface: photocraft_raster::Surface::new(doc.pixel_format()) });
assert!(plan(&doc).is_ok());
doc.layers[0].fill_cache.as_mut().unwrap().fill = Fill::Solid(Color::TRANSPARENT);
assert!(plan(&doc).is_err());
doc.mode = photocraft_color::ColorMode::Rgb;
assert!(plan(&doc).is_ok());
}
}
}
#[test]
fn outer_glow_plan_knocks_out_see_through_fill() {
use photocraft_doc::{Contour, FxCommon, GlowSource, GlowTechnique};
+8 -1
View File
@@ -891,6 +891,12 @@ impl Ex {
{
return c.surface.clone();
}
if self.cmyk
&& let Some(s) =
photocraft_compose::gradient_fill::render_cmyk_fill(f, self.canvas, photocraft_compose::fill_frame(l, self.canvas), self.fmt.sample)
{
return s;
}
// In the frame the layer's masks give it, like the compositor (masks are stored apart).
// Readers composite these pixels (ours keeps them as the fill's rendering), so a pattern
// fill must be rendered from the document's patterns, not left transparent (#1907).
@@ -1320,7 +1326,8 @@ fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32) -
ex.text_index.insert(l.id, index);
}
}
ex.emit(&doc.layers);
let space = photocraft_compose::cmyk_space(doc);
photocraft_color::convert::with_cmyk_space(space.as_ref(), || ex.emit(&doc.layers));
// Merged composite, rendered and encoded in bands (no full-size float composite). Matting
// against white only changes pixels with alpha < 1; if some are slightly translucent but all
+96
View File
@@ -83,3 +83,99 @@ fn cmyk_to_png_is_colour_managed_and_tagged_srgb() {
let back = import("l.png", &r.bytes).unwrap().document;
assert_eq!(Profile::parse(back.icc_profile.as_ref().unwrap()).unwrap().color_space, ColorSpace::Rgb);
}
#[test]
fn cmyk_fills_survive_psd_roundtrip_at_every_depth() {
use photocraft_doc::{Color, Document, Fill, GradientStyle, Layer, LayerContent};
let icc = photocraft_cms::synth::cmyk_profile(&photocraft_cms::synth::CmykParams {
tvi: [0.26, 0.26, 0.26, 0.3],
grid_a2b: 5,
grid_b2a: 9,
..Default::default()
})
.to_bytes();
let a = Color { mode: ColorMode::Cmyk, c: [0.2, 0.4, 0.6, 0.3], alpha: 0.7 };
let b = Color { mode: ColorMode::Cmyk, c: [0.6, 0.2, 0.3, 0.1], alpha: 0.4 };
for depth in SampleType::ALL {
for profile in [None, Some(icc.clone())] {
for style in [
None,
Some(GradientStyle::Linear),
Some(GradientStyle::Radial),
Some(GradientStyle::Angle),
Some(GradientStyle::Reflected),
Some(GradientStyle::Diamond),
] {
let mut fill = style.map_or(Fill::Solid(a), |s| Fill::gradient(vec![(1.0, b), (0.0, a)], 35.0, 0.8, s, true));
if let Fill::Gradient { midpoints, opacity_stops, dither, offset, .. } = &mut fill {
*midpoints = vec![0.25];
*opacity_stops = vec![(0.0, 0.6), (1.0, 0.9)];
*offset = (0.1, -0.2);
*dither = true;
}
let mut doc = Document::new("CMYK fill", photocraft_geom::Size::new(9, 6), ColorMode::Cmyk, depth);
doc.icc_profile = profile.clone();
doc.layers.push(Layer::new("Fill", LayerContent::Fill(fill)));
let expected = photocraft_compose::flatten(&doc).px;
assert!(max_diff(&expected, &photocraft_compose::render_tiled(&doc, doc.bounds(), 3).px) <= 1e-6);
let bytes = export(&doc, "x.psd", &ExportOptions::default()).unwrap().bytes;
let back = import("x.psd", &bytes).unwrap().document;
let actual = photocraft_compose::flatten(&back).px;
let diff = max_diff(&expected, &actual);
assert!(diff <= 1e-6, "{depth:?} {style:?}: round-trip error {diff}");
assert_eq!(back.icc_profile, doc.icc_profile);
if style.is_none() {
let native = photocraft_raster::Surface::with_default(doc.pixel_format(), &[a.c[0], a.c[1], a.c[2], a.c[3], a.alpha]);
let actual = back.layers[0].fill_cache.as_ref().unwrap().surface.read_region(doc.bounds());
for (actual, expected) in actual.iter().zip(native.read_region(doc.bounds())) {
let tolerance = if depth == SampleType::F32 { 1e-6 } else { 0.0 };
assert!((actual - expected).abs() <= tolerance, "{depth:?}: {actual} vs {expected}");
}
}
}
}
}
}
#[cfg(feature = "corpus")]
#[test]
fn uncached_cmyk_gradient_corpus_roundtrip() {
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/psd-tools/colormodes/4x4_16bit_cmyk.psd");
let bytes = std::fs::read(path).expect("run cargo xtask corpus --all");
let doc = import("x.psd", &bytes).unwrap().document;
assert!(doc.layers.iter().any(|l| matches!(l.content, photocraft_doc::LayerContent::Fill(_)) && l.fill_cache.is_none()));
let expected = photocraft_compose::flatten(&doc).px;
let saved = export(&doc, "x.psd", &ExportOptions::default()).unwrap().bytes;
let back = import("x.psd", &saved).unwrap().document;
let diff = max_diff(&expected, &photocraft_compose::flatten(&back).px);
eprintln!("uncached 16-bit CMYK gradient round-trip max error: {diff:.8}");
assert!(diff <= 1e-6, "round-trip error {diff}");
assert_eq!(back.icc_profile, doc.icc_profile);
}
#[test]
fn rgb_fill_colours_separate_through_the_document_cmyk_profile() {
use photocraft_cms::{Intent, Transform, synth};
use photocraft_doc::{Color, Document, Fill, Layer, LayerContent};
let profile = synth::cmyk_profile(&synth::CmykParams { tvi: [0.26, 0.26, 0.26, 0.3], grid_a2b: 5, grid_b2a: 9, ..Default::default() });
let profile = Profile::parse(&profile.to_bytes()).unwrap();
let rgb = [0.2, 0.4, 0.6];
let mut expected = [0.0; 4];
Transform::new(Builtin::Srgb.profile(), &profile, Intent::RelativeColorimetric, true).unwrap().eval_fast(&rgb, &mut expected);
for depth in SampleType::ALL {
let mut doc = Document::new("RGB fill", photocraft_geom::Size::new(1, 1), ColorMode::Cmyk, depth);
doc.icc_profile = Some(profile.to_bytes());
doc.layers.push(Layer::new("Fill", LayerContent::Fill(Fill::Solid(Color::rgb(rgb[0], rgb[1], rgb[2])))));
let bytes = export(&doc, "x.psd", &ExportOptions::default()).unwrap().bytes;
let back = import("x.psd", &bytes).unwrap().document;
let samples = back.layers[0].fill_cache.as_ref().unwrap().surface.read_region(back.bounds());
let tolerance = match depth {
SampleType::U8 => 1.0 / 255.0,
SampleType::U16 => 1.0 / 65535.0,
SampleType::F32 => 1e-6,
};
for (actual, expected) in samples.iter().zip(expected) {
assert!((actual - expected).abs() <= tolerance, "{depth:?}: {actual} vs {expected}");
}
}
}
+1 -1
View File
@@ -152,7 +152,7 @@ const MIXED: Source = Source { label: "io corpus", dir: "corpus/psd", group_dept
/// The full psd-tools test set (309 files at the pinned commit; see `xtask/psd-tools-corpus.sha256`):
/// 226 vs the merged image + 10 vs the thumbnail (Advanced Blending knockouts: +7).
const PSD_TOOLS: Source = Source { label: "psd-tools corpus", dir: "corpus/psd-tools", group_depth: 0, pass_floor: 236, roundtrip_floor: 307 };
const PSD_TOOLS: Source = Source { label: "psd-tools corpus", dir: "corpus/psd-tools", group_depth: 0, pass_floor: 236, roundtrip_floor: 308 };
/// Our Photoshop-authored oracles (256 files; https://github.com/storytold/photocraft-corpus),
/// grouped by feature (`smart-filters`, `effects`, `text`, `adjustments/<mode><bits>`).
+38
View File
@@ -292,6 +292,23 @@ impl Surface {
*d = [g, g, g, s[1] as f32 / 255.0];
}
}
(ColorMode::Cmyk, SampleType::U8, _) => {
// Identical ink bytes share the exact ICC result; alpha is independent.
let mut previous: Option<([u8; 4], [f32; 3])> = None;
let src = &t.data[base..base + dst.len() * n];
for (d, s) in dst.iter_mut().zip(src.chunks_exact(n)) {
let ink = [s[0], s[1], s[2], s[3]];
let rgb = match previous {
Some((key, rgb)) if key == ink => rgb,
_ => {
let rgb = photocraft_color::convert::cmyk_to_rgb(ink.map(|v| v as f32 / 255.0));
previous = Some((ink, rgb));
rgb
}
};
*d = [rgb[0], rgb[1], rgb[2], s.get(4).map_or(1.0, |v| *v as f32 / 255.0)];
}
}
_ => {
let mut px = [0.0f32; 8];
for (i, d) in dst.iter_mut().enumerate() {
@@ -839,6 +856,27 @@ mod tests {
assert!((v[0] - v[2]).abs() < 1e-6 && (v[3] - 1.0).abs() < 1e-6);
}
#[test]
fn repeated_cmyk_reads_match_individual_pixels_bitwise() {
let rect = Rect::new(-2, -1, 515, 2);
for depth in SampleType::ALL {
for alpha in [false, true] {
let fmt = PixelFormat::new(ColorMode::Cmyk, depth, alpha);
let mut s = Surface::with_default(fmt, &[0.2, 0.4, 0.6, 0.3, 0.7]);
for (i, x) in (-1..514).enumerate() {
let c = if i / 2 % 256 == 0 { -0.0 } else { (i / 2 % 256) as f32 / 255.0 };
s.write_pixel(x, 0, &[c, 0.4, 0.6, 0.3, (i % 9) as f32 / 8.0]);
}
let mut out = vec![[0.0; 4]; (rect.width() * rect.height()) as usize];
s.read_rgba_into(rect, &mut out);
for (i, actual) in out.iter().enumerate() {
let expected = s.rgba(rect.x0 + (i % rect.width() as usize) as i32, rect.y0 + (i / rect.width() as usize) as i32);
assert_eq!(actual.map(f32::to_bits), expected.map(f32::to_bits), "{depth:?}, alpha={alpha}, pixel={i}");
}
}
}
}
#[test]
fn translated_matches_reading_the_moved_pixels() {
// Any offset (whole tiles, negative, across tile edges), every depth, a non-zero default.
+7
View File
@@ -92,6 +92,13 @@ Confidence: moderate — the next users of 0.2.x will move these numbers either
### By dimension
2026-10-10: Native CMYK solid and gradient fills share depth-quantized samples between
rendering and PSD export. The uncached 16-bit CMYK gradient now round-trips with zero
rendered error; psd-tools round trips rise from 307 to 308. Mixed and Photoshop round
trips remain 169 and 258; rendering oracle counts remain 146, 237 and 133.
Exact 8-bit CMYK row reuse reduces 1 MP conversion-read time by 77% for smooth ramps,
33% with dithering and 10% for random colours (M4 Max, release optimization level 3).
2026-10-10: [Shape stroke controls](shape-strokes.md) expose existing Solid/Dashed/Dotted,
custom dash/gap, offset, caps, joins, alignment, miter-limit and opacity capabilities in
Shape/Pen options and Properties. Properties stages a cached canvas preview before one
+2 -2
View File
@@ -7,7 +7,7 @@ Generated by `cargo xtask scorecard` from `perf/budgets.toml`, `perf/baseline.js
| Area | Done | Partial | Missing | Key numbers |
|---|---:|---:|---:|---|
| [Performance](#performance) | 3 | 22 | 22 | 56 scenarios: 3 within budget, 22 over budget or failing, 9 tracked only, 15 not run, 7 not measurable yet (baseline 2026-10-05) |
| [File compatibility](#file-compatibility) | 5 | 11 | 11 | io corpus: oracle ≥ 146/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 236/309, round trip ≥ 307/309; photoshop oracles: oracle ≥ 133/256, round trip ≥ 258/256 |
| [File compatibility](#file-compatibility) | 5 | 11 | 11 | io corpus: oracle ≥ 146/170, round trip ≥ 169/170; psd-tools corpus: oracle ≥ 236/309, round trip ≥ 308/309; photoshop oracles: oracle ≥ 133/256, round trip ≥ 258/256 |
| [Tools](#tools) | 20 | 15 | 6 | |
| [Workspace and UI](#workspace-and-ui) | 5 | 7 | 11 | settings that do nothing: 46 of 149 (target 0) |
| [Type](#type) | 0 | 3 | 6 | |
@@ -93,7 +93,7 @@ Corpus floors (`crates/io/tests/corpus.rs`): the enforced minimums; the corpus t
| Corpus | Directory | Files | Oracle pass floor | Round-trip floor |
|---|---|---:|---:|---:|
| io corpus | `corpus/psd` | 170 | 146 | 169 |
| psd-tools corpus | `corpus/psd-tools` | 309 | 236 | 307 |
| psd-tools corpus | `corpus/psd-tools` | 309 | 236 | 308 |
| photoshop oracles | `corpus/photoshop` | 256 | 133 | 258 |
| Id | Target | Status | Issue | Note |