diff --git a/crates/render/src/raster/tests/isolated_cmyk_groups.rs b/crates/render/src/raster/tests/isolated_cmyk_groups.rs index c9cc9aa492..aa15019277 100644 --- a/crates/render/src/raster/tests/isolated_cmyk_groups.rs +++ b/crates/render/src/raster/tests/isolated_cmyk_groups.rs @@ -280,3 +280,48 @@ fn the_color_blend_mode_over_an_empty_cmyk_group_keeps_every_k_level() { } assert!(wrong.is_empty(), "K levels drawn wrong (level, plain, blended): {wrong:?}"); } + +/// A group larger than the renderer blends in CMYK at once (hayro patch (27): 2^24 device +/// pixels) used to be blended in RGB instead: a page enlarged to 11 x 17 in for Print as image +/// at 600 dpi drew the X's of a print test file's CMYK blend patches. It is now blended in bands +/// of whole rows. Here the band is lowered to a few rows, so the group is blended in many +/// bands, across the anti-aliased edge of a triangle as well: the result is the same as in one +/// piece, and still CMYK's (magenta under black with Difference is green). +#[test] +fn a_cmyk_group_blended_in_bands_is_the_group_blended_whole() { + let group = "0 1 0 0 k 0 0 m 200 0 l 0 200 l f /Diff gs 0 0 0 1 k 0 0 200 200 re f"; + let pdf = format!( + "%PDF-1.7 +1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj +2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj +3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 220 220] /Contents 4 0 R /Resources << /XObject << /X0 5 0 R >> >> >> endobj +4 0 obj << /Length 28 >> stream +q 1 0 0 1 10 10 cm /X0 Do Q +endstream endobj +5 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 200 200] /Group << /S /Transparency /CS /DeviceCMYK /I true >> /Resources << /ExtGState << /Diff << /BM /Difference >> >> >> /Length {} >> stream +{group} +endstream endobj +trailer << /Root 1 0 R >> +%%EOF", + group.len() + ); + let mut r = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default()); + let request = RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 2.0, tag: 0, ..Default::default() }; + let whole = r.render(request); + // 400 device pixels a row: bands of 7 rows, the last one shorter. + hayro::set_cmyk_band_pixels(Some(2_800)); + let banded = r.render(request); + hayro::set_cmyk_band_pixels(None); + assert!(whole.error.is_none() && banded.error.is_none(), "{:?} {:?}", whole.error, banded.error); + assert_eq!((whole.width, whole.height), (banded.width, banded.height)); + let worst = whole.rgba.iter().zip(banded.rgba.iter()).map(|(a, b)| a.abs_diff(*b)).max().unwrap_or(0); + assert!(worst <= 1, "bands differ from the whole group by up to {worst}"); + // Inside the triangle (device (100, 340), the group's point (40, 40)): magenta under black. + let at = |p: &RenderedPage, x: u32, y: u32| p.rgba[((y * p.width + x) * 4) as usize..][..3].to_vec(); + for page in [&whole, &banded] { + let rgb = at(page, 100, 340); + // CMYK's 1 0 1 0 through the CMYK profile is (0, 166, 81); blended in RGB it would be + // magenta, red and blue high and green low. + assert!(rgb[1] > 120 && rgb[0] < 60 && rgb[2] < 120, "blended in CMYK, green: {rgb:?}"); + } +} diff --git a/vendor/hayro/src/lib.rs b/vendor/hayro/src/lib.rs index 73730a07a7..1c13faffda 100644 --- a/vendor/hayro/src/lib.rs +++ b/vendor/hayro/src/lib.rs @@ -65,6 +65,9 @@ pub use renderer::{image_resampling_size, tiling_cell_pixels, tiling_cell_scale, /// PdfCraft patch: exported for its regression test. #[doc(hidden)] pub use renderer::set_skip_offscreen_paths; +/// PdfCraft patch (27): exported for its regression test. +#[doc(hidden)] +pub use renderer::set_cmyk_band_pixels; /// PdfCraft patch (30): exported for its regression tests. #[doc(hidden)] pub use renderer::{OffscreenDraws, offscreen_draws}; diff --git a/vendor/hayro/src/renderer.rs b/vendor/hayro/src/renderer.rs index 7a9462b63c..c8640adb14 100644 --- a/vendor/hayro/src/renderer.rs +++ b/vendor/hayro/src/renderer.rs @@ -815,11 +815,27 @@ enum PaintSetup { Nothing, } -/// PdfCraft patch (27): the largest group, in device pixels, blended in CMYK (2^24, a 4096 × -/// 4096 square). Each pixel costs about 20 bytes more than drawing the group as before (two -/// pass canvases, its tints, their RGB and the result); a larger group is drawn as before. +/// PdfCraft patch (27): the most device pixels of a group blended in CMYK at once (2^24, a 4096 × +/// 4096 square). Each costs about 16 bytes while its band is blended (two pass canvases, its +/// tints and their RGB); a larger group is blended in bands of whole rows under it, and only +/// the finished group, 4 bytes a pixel, is kept whole. Before, a larger group was blended in RGB +/// instead: a page enlarged to 11 x 17 in for Print as image at 600 dpi drew the X's of a print +/// test file's CMYK blend patches when one group covered most of it. const MAX_CMYK_GROUP_PIXELS: u64 = 1 << 24; +thread_local! { + /// PdfCraft patch (27): the band size [`MAX_CMYK_GROUP_PIXELS`] sets, on this thread. Only + /// PdfCraft's regression test lowers it, to blend a small page's groups in many bands. + static CMYK_BAND_PIXELS: std::cell::Cell = const { std::cell::Cell::new(MAX_CMYK_GROUP_PIXELS) }; +} + +/// PdfCraft patch (27): exported for its regression test (see [`CMYK_BAND_PIXELS`]); `None` +/// restores [`MAX_CMYK_GROUP_PIXELS`]. +#[doc(hidden)] +pub fn set_cmyk_band_pixels(pixels: Option) { + CMYK_BAND_PIXELS.with(|p| p.set(pixels.unwrap_or(MAX_CMYK_GROUP_PIXELS).max(1))); +} + pub(crate) struct Renderer { pub(crate) ctx: RenderContext, /// PdfCraft patch: when set, solid text and line-art paint colours are remapped @@ -1743,6 +1759,46 @@ impl Renderer { Some(pix) } + /// PdfCraft patch (27): rows `offset.1` .. `offset.1 + height` of `group` blended in CMYK, as + /// a `width` × `height` premultiplied pixmap whose corner is device point `offset`; `None` + /// when the group holds something a pass has no tints for, or its tints can't be converted. + fn cmyk_group_band( + &self, + group: &hayro_interpret::CmykGroup<'_, '_>, + offset: (f64, f64), + width: u16, + height: u16, + ) -> Option { + let cmy = self.ink_pass(group, InkPass::Cmy, offset, width, height)?; + let k = self.ink_pass(group, InkPass::K, offset, width, height)?; + + // A tint is a pass's premultiplied colour un-premultiplied and complemented. + let ink = |premul: u8, alpha: u8| { + if alpha == 0 { + return 0; + } + let straight = (u32::from(premul) * 255 + u32::from(alpha) / 2) / u32::from(alpha); + 255 - straight.min(255) as u8 + }; + let pixels = usize::from(width) * usize::from(height); + let mut tints = Vec::with_capacity(pixels * 4); + for (p, q) in cmy.data().iter().zip(k.data()) { + // Both passes draw the same shapes at the same opacities, so they cover the same + // pixels by the same amounts; each pass's own alpha un-premultiplies its colour. + tints.extend_from_slice(&[ink(p.r, p.a), ink(p.g, p.a), ink(p.b, p.a), ink(q.r, q.a)]); + } + let mut rgb = vec![0u8; pixels * 3]; + hayro_interpret::color::cmyk_to_srgb8(&tints, &mut rgb)?; + + let mut result = offscreen_pixmap(width, height, false); + for ((px, rgb), coverage) in result.data_mut().iter_mut().zip(rgb.chunks_exact(3)).zip(cmy.data()) { + let a = u32::from(coverage.a); + let premul = |v: u8| ((u32::from(v) * a + 127) / 255) as u8; + *px = PremulRgba8 { r: premul(rgb[0]), g: premul(rgb[1]), b: premul(rgb[2]), a: coverage.a }; + } + Some(result) + } + /// PdfCraft patch: remaps a solid paint colour through `color_replacement`, if one is /// set. `Paint::Pattern` (images, shadings, tiling patterns) always passes through /// untouched. @@ -2444,42 +2500,26 @@ impl<'a> Device<'a> for Renderer { if width == 0 || height == 0 { return true; } - if u64::from(width) * u64::from(height) > MAX_CMYK_GROUP_PIXELS { - return false; - } - - let Some(cmy) = self.ink_pass(group, InkPass::Cmy, (x0, y0), width, height) else { - return false; - }; - let Some(k) = self.ink_pass(group, InkPass::K, (x0, y0), width, height) else { - return false; - }; - - // A tint is a pass's premultiplied colour un-premultiplied and complemented. - let ink = |premul: u8, alpha: u8| { - if alpha == 0 { - return 0; - } - let straight = (u32::from(premul) * 255 + u32::from(alpha) / 2) / u32::from(alpha); - 255 - straight.min(255) as u8 - }; - let pixels = usize::from(width) * usize::from(height); - let mut tints = Vec::with_capacity(pixels * 4); - for (p, q) in cmy.data().iter().zip(k.data()) { - // Both passes draw the same shapes at the same opacities, so they cover the same - // pixels by the same amounts; each pass's own alpha un-premultiplies its colour. - tints.extend_from_slice(&[ink(p.r, p.a), ink(p.g, p.a), ink(p.b, p.a), ink(q.r, q.a)]); - } - let mut rgb = vec![0u8; pixels * 3]; - if hayro_interpret::color::cmyk_to_srgb8(&tints, &mut rgb).is_none() { - return false; - } - + // Bands of whole rows, each within the band size: a group of any size blends in CMYK. + // Every band is blended before any is drawn, so a group a pass has no tints for in one + // band is drawn as before, whole. + let band = (CMYK_BAND_PIXELS.with(std::cell::Cell::get) / u64::from(width)).clamp(1, u64::from(height)); + // At most `height`, so it fits a u16. + let band = band as u16; let mut result = offscreen_pixmap(width, height, false); - for ((px, rgb), coverage) in result.data_mut().iter_mut().zip(rgb.chunks_exact(3)).zip(cmy.data()) { - let a = u32::from(coverage.a); - let premul = |v: u8| ((u32::from(v) * a + 127) / 255) as u8; - *px = PremulRgba8 { r: premul(rgb[0]), g: premul(rgb[1]), b: premul(rgb[2]), a: coverage.a }; + let row = usize::from(width); + let mut top = 0u16; + while top < height { + let rows = band.min(height - top); + let Some(piece) = self.cmyk_group_band(group, (x0, y0 + f64::from(top)), width, rows) else { + return false; + }; + let start = usize::from(top) * row; + let Some(dest) = result.data_mut().get_mut(start..start + usize::from(rows) * row) else { + return false; + }; + dest.copy_from_slice(piece.data()); + top += rows; } // The group's own soft mask and blend mode are on its layer; what the contents set