mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-14 19:26:51 -04:00
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:
1 parent
d9a9ce5f4d
commit
5290930e4a
9 files changed
+259
-25
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>`).
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
Reference in new issue
Block a user