Render: an isolated CMYK group of any size blends in CMYK, in bands (#6885)

* Render: an isolated CMYK group of any size blends in CMYK, in bands

hayro patch (27) blended an isolated DeviceCMYK transparency group in CMYK only up to 2^24
device pixels (a 4096 x 4096 square); a larger one was blended in RGB. Print as image renders a
whole page at once, up to 8192 px on the long side, so a page enlarged to 11 x 17 in at 600 dpi
(about 43 MP) blended any group covering most of it in RGB, and a print test file's CMYK blend
patches drew their X's. The same applied to exporting a large page as an image.

A larger group is now blended in bands of whole rows, each within the old limit: both ink passes
and the tint conversion run per band, so their memory stays as before, and only the finished
group (4 bytes a pixel) is kept whole. Every band is blended before any is drawn, so a group a
pass has no tints for is still drawn as before, whole.

The regression test lowers the band to 7 rows (set_cmyk_band_pixels, hidden, per thread like
set_skip_offscreen_paths) and checks the banded group matches the whole one across an
anti-aliased edge, and is CMYK's green where magenta is under black with Difference.

* Test: CMYK green through the profile is (0, 166, 81); check green against RGB's magenta
This commit is contained in:
FredApps authored and GitHub committed 2026-11-10 18:11:33 +01:00
1 parent 8559306f77
commit e03d5a1c29
3 files changed
+126 -38

No files matched your search

@@ -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:?}");
}
}
+3
View File
@@ -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};
+78 -38
View File
@@ -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<u64> = 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<u64>) {
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<Pixmap> {
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