Integrate KenM76 PR #5228

# Conflicts:
#	vendor/README.md
#	vendor/hayro-interpret/src/device.rs
This commit is contained in:
FredApps
2026-10-29 02:39:15 +01:00
9 changed files with 702 additions and 26 deletions
+4
View File
@@ -7004,6 +7004,10 @@ mod default_colour_spaces;
// (vendored hayro-syntax patch 28, hayro-interpret patch 55).
mod devicen_inks;
// #3776: isolated DeviceCMYK groups blend in CMYK (vendored hayro-interpret patch 70, hayro
// patch 27).
mod isolated_cmyk_groups;
/// #3109 / #3116: clipping-only text (`7 Tr`) installs a clip for subsequent painting
/// but draws no pixels itself.
#[test]
@@ -0,0 +1,282 @@
//! Isolated transparency groups whose colour space is `DeviceCMYK` blend in CMYK (ISO 32000-2
//! §11.3.4, §11.3.5.3).
use crate::{PageRenderer, RenderConfig, RenderRequest, RenderedPage, RequestKind};
use std::sync::Arc;
/// A print test file's patch of blend modes in isolated groups whose colour space is
/// `DeviceCMYK` drew an X in nearly every cell. The elements of a group blend in the
/// group's colour space, and a subtractive space's components are complemented before the
/// blend function and back after it (ISO 32000-2 §11.3.4); of the nonseparable modes,
/// §11.3.5.3 blends C, M and Y as complemented R, G and B and takes K from the backdrop
/// (from the source for Luminosity). PdfCraft blended the colours converted to RGB, so
/// magenta under black with Difference came out magenta instead of green. Vendored
/// hayro-interpret patch (70) and hayro patch (27): an isolated group (§11.4.7: its own
/// transparent backdrop) blending in DeviceCMYK is blended in CMYK.
///
/// Each cell is a square painted in the CMYK the blend should give, with a smaller isolated
/// group on it whose two elements are tint A and then tint B with the cell's blend mode:
/// drawn right, the group is the colour of the square around it.
#[test]
fn isolated_groups_blend_in_device_cmyk() {
// (blend mode, tint A, tint B, A then B blended in DeviceCMYK, worked out by hand)
type Cell = (&'static str, [f32; 4], [f32; 4], [f32; 4]);
let cells: [Cell; 7] = [
// |(1,0,1,1) − (1,1,1,0)| = (0,1,0,1), complemented back: green.
("Difference", [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 0.0, 1.0], [1.0, 0.0, 1.0, 0.0]),
("Exclusion", [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 0.0, 1.0], [1.0, 0.0, 1.0, 0.0]),
("Multiply", [0.0, 0.0, 1.0, 0.0], [0.0, 0.5, 0.0, 0.0], [0.0, 0.5, 1.0, 0.0]),
("Screen", [0.0, 1.0, 1.0, 0.0], [0.5, 0.5, 1.0, 0.0], [0.0, 0.5, 1.0, 0.0]),
("Darken", [0.0, 0.0, 1.0, 0.0], [0.0, 0.5, 0.0, 0.0], [0.0, 0.5, 1.0, 0.0]),
// Hue of (0,1,1) at the saturation and luminosity of (1,0.5,1): (0.355,0.855,0.855);
// K is the backdrop's.
("Hue", [0.0, 0.5, 0.0, 0.3], [1.0, 0.0, 0.0, 0.0], [0.645, 0.145, 0.145, 0.3]),
// (1,0,1) at the luminosity of (0,1,0), clipped: (1,0.3051,1); K is the source's.
("Luminosity", [0.0, 1.0, 0.0, 0.3], [1.0, 0.0, 1.0, 0.0], [0.0, 0.6949, 0.0, 0.0]),
];
let tint = |t: [f32; 4]| format!("{} {} {} {} k", t[0], t[1], t[2], t[3]);
// `group`: the cell group's /Group entries; `b`: what the second element paints.
let render = |group: &str, b: &dyn Fn([f32; 4]) -> String| {
let (mut page, mut objects, mut names) = (String::new(), String::new(), String::new());
for (i, (mode, ta, tb, expected)) in cells.iter().enumerate() {
let x = 10 + i * 50;
page += &format!("{} {x} 10 40 40 re f /G{i} Do\n", tint(*expected));
let rect = format!("{} 20 20 20 re f", x + 10);
let n = 10 + i * 3;
for (k, paint) in [(n, tint(*ta)), (n + 1, b(*tb))] {
let body = format!("{paint} {rect}");
objects += &format!(
"{k} 0 obj << /Type /XObject /Subtype /Form /BBox [{x} 10 {} 50] /Group << /S /Transparency /CS /DeviceCMYK >> /Length {} >> stream\n{body}\nendstream endobj\n",
x + 40,
body.len()
);
}
let body = "/E1 Do /BM gs /E2 Do";
objects += &format!(
"{} 0 obj << /Type /XObject /Subtype /Form /BBox [{x} 10 {} 50] /Group << /S /Transparency {group} >> /Resources << /XObject << /E1 {n} 0 R /E2 {} 0 R >> /ExtGState << /BM << /BM /{mode} >> >> >> /Length {} >> stream\n{body}\nendstream endobj\n",
n + 2,
x + 40,
n + 1,
body.len()
);
names += &format!("/G{i} {} 0 R ", n + 2);
}
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 370 60] /Group << /S /Transparency /CS /DeviceCMYK >> /Contents 4 0 R /Resources << /XObject << {names}>> >> >> endobj
4 0 obj << /Length {} >> stream
{page}
endstream endobj
{objects}trailer << /Root 1 0 R >>
%%EOF",
page.len()
);
let mut r = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default());
let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() });
assert!(p.error.is_none(), "{group}: {:?}", p.error);
p
};
let px = |p: &RenderedPage, x: usize, y: u32| p.rgba[((y * p.width + x as u32) * 4) as usize..][..3].to_vec();
let distance = |a: &[u8], b: &[u8]| a.iter().zip(b).map(|(a, b)| a.abs_diff(*b)).max().unwrap_or(0);
let p = render("/CS /DeviceCMYK /I true", &tint);
for (i, (mode, ..)) in cells.iter().enumerate() {
let (group, square) = (px(&p, 30 + i * 50, 30), px(&p, 14 + i * 50, 30));
// The square is a solid CMYK fill; the group lands within rounding of it.
assert!(distance(&group, &square) <= 3, "{mode}: group {group:?}, square {square:?}");
}
// A group whose contents have no ink tints (here the second element is RGB) is
// composited in RGB exactly as before: the same as a group blending in DeviceRGB.
let rgb = |_: [f32; 4]| "1 0 0 rg".to_string();
let cmyk_group = render("/CS /DeviceCMYK /I true", &rgb);
let rgb_group = render("/CS /DeviceRGB /I true", &rgb);
assert_eq!(cmyk_group.rgba, rgb_group.rgba, "contents without tints are drawn as before");
}
/// Two cases from the review of #3777. A group wider than about two of vello_cpu's tiles lost
/// its blend mode in the tiles a paint covered completely, which drew black over magenta at
/// 300 dpi where Difference gives green; each pass now draws inside a layer. And a nested
/// isolated group that names another blending space (here DeviceRGB) blends in that space
/// (ISO 32000-2 §11.3.4), so the enclosing CMYK group is drawn as before; a non-isolated
/// one ignores its /CS and blends in the enclosing group's CMYK (Table 145).
#[test]
fn isolated_cmyk_groups_blend_at_any_size_and_respect_nested_spaces() {
let render = |inner_cs: Option<&str>, scale: f32| {
// `inner_cs`: the nested group's /CS and /I entries.
let group = "0 1 0 0 k 0 0 200 200 re f /Diff gs 0 0 0 1 k 0 0 200 200 re f";
let (outer_body, extra) = match inner_cs {
None => (group.to_string(), String::new()),
Some(cs) => (
"/In Do".to_string(),
format!(
"6 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 200 200] /Group << /S /Transparency {cs} >> /Resources << /ExtGState << /Diff << /BM /Difference >> >> >> /Length {} >> stream\n{group}\nendstream endobj\n",
group.len()
),
),
};
let page = "q 1 0 0 1 10 70 cm /X0 Do Q 1 0 1 0 k 10 10 200 50 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 280] /Contents 4 0 R /Resources << /XObject << /X0 5 0 R >> >> >> endobj
4 0 obj << /Length {} >> stream
{page}
endstream endobj
5 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 200 200] /Group << /S /Transparency /CS /DeviceCMYK /I true >> /Resources << /XObject << /In 6 0 R >> /ExtGState << /Diff << /BM /Difference >> >> >> /Length {} >> stream
{outer_body}
endstream endobj
{extra}trailer << /Root 1 0 R >>
%%EOF",
page.len(),
outer_body.len()
);
let mut r = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default());
let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale, tag: 0, ..Default::default() });
assert!(p.error.is_none(), "{:?}", p.error);
p
};
let px = |p: &RenderedPage, x: f32, y: f32, scale: f32| {
let (x, y) = ((x * scale) as u32, (y * scale) as u32);
p.rgba[((y * p.width + x) * 4) as usize..][..3].to_vec()
};
let distance = |a: &[u8], b: &[u8]| a.iter().zip(b).map(|(a, b)| a.abs_diff(*b)).max().unwrap_or(0);
// 200 pt at scale 4 is 800 px: the centre lies in tiles the paints cover completely.
for scale in [1.0, 4.0] {
let p = render(None, scale);
let strip = px(&p, 110.0, 245.0, scale);
for (x, y) in [(110.0, 110.0), (15.0, 15.0), (110.0, 205.0)] {
let group = px(&p, x, y, scale);
assert!(distance(&group, &strip) <= 3, "scale {scale} at ({x}, {y}): group {group:?}, strip {strip:?}");
}
}
// A nested DeviceRGB group: the whole CMYK group falls back, so the page is drawn exactly
// as when the enclosing group is not offered to the CMYK path at all.
let nested_rgb = render(Some("/CS /DeviceRGB /I true"), 1.0);
let green = px(&nested_rgb, 110.0, 245.0, 1.0);
assert!(distance(&px(&nested_rgb, 110.0, 110.0, 1.0), &green) > 100, "the nested group blends in RGB, not CMYK");
// A nested DeviceCMYK group stays in the CMYK path.
let nested_cmyk = render(Some("/CS /DeviceCMYK /I true"), 1.0);
assert!(distance(&px(&nested_cmyk, 110.0, 110.0, 1.0), &green) <= 3);
// A non-isolated nested group's /CS is ignored (ISO 32000-2 Table 145): it blends in the
// enclosing group's CMYK, even when it names DeviceRGB.
let non_isolated_rgb = render(Some("/CS /DeviceRGB /I false /K false"), 1.0);
assert!(distance(&px(&non_isolated_rgb, 110.0, 110.0, 1.0), &green) <= 3, "a non-isolated nested group inherits CMYK");
}
/// An element of a knockout group is first drawn in erase mode, which clears what the group
/// drew under the element's shape. An isolated `DeviceCMYK` group nested in that element must
/// clear too: when the erase pass drew it through the CMYK path, it painted over the red
/// beneath instead of clearing it. The page must match the same element with its nested group
/// blending in `DeviceRGB`, which never takes the CMYK path.
#[test]
fn an_isolated_cmyk_group_inside_a_knockout_element_is_erased_like_any_other() {
let render = |inner_cs: &str| {
let knockout = "1 0 0 rg 20 20 160 160 re f /El Do";
let element = "/Cm Do";
let inner = "/A gs 0 1 0 0 k 60 60 80 80 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 200 200] /Contents 4 0 R /Resources << /XObject << /Ko 5 0 R >> >> >> endobj
4 0 obj << /Length 7 >> stream
/Ko Do
endstream endobj
5 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 200 200] /Group << /S /Transparency /I true /K true >> /Resources << /XObject << /El 6 0 R >> >> /Length {} >> stream
{knockout}
endstream endobj
6 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 200 200] /Group << /S /Transparency >> /Resources << /XObject << /Cm 7 0 R >> >> /Length {} >> stream
{element}
endstream endobj
7 0 obj << /Type /XObject /Subtype /Form /BBox [0 0 200 200] /Group << /S /Transparency /CS {inner_cs} /I true >> /Resources << /ExtGState << /A << /ca 0.5 >> >> >> /Length {} >> stream
{inner}
endstream endobj
trailer << /Root 1 0 R >>
%%EOF",
knockout.len(),
element.len(),
inner.len()
);
let mut r = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default());
let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() });
assert!(p.error.is_none(), "{inner_cs}: {:?}", p.error);
// The centre of the inner square, (100, 100), is also the centre of the page.
p.rgba[((100 * p.width + 100) * 4) as usize..][..3].to_vec()
};
let rgb = render("/DeviceRGB");
let cmyk = render("/DeviceCMYK");
assert!(rgb[2] > 150, "the RGB control shows half-opacity magenta over white, not red: {rgb:?}");
assert!(rgb.iter().zip(&cmyk).all(|(a, b)| a.abs_diff(*b) <= 3), "CMYK {cmyk:?}, RGB {rgb:?}");
}
/// A minimal page with `page` as its content, a shading pattern `/P` and an isolated
/// `DeviceCMYK` group `/G` over `bbox` drawing `group`; the RGB of device pixel (`x`, `y`).
fn cmyk_group_pixel(media: [u32; 2], page: &str, bbox: [u32; 4], group: &str, group_resources: &str, (x, y): (u32, u32)) -> Vec<u8> {
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 {} {}] /Contents 4 0 R /Resources << /XObject << /G 5 0 R >> /Pattern << /P 6 0 R >> >> >> endobj
4 0 obj << /Length {} >> stream
{page}
endstream endobj
5 0 obj << /Type /XObject /Subtype /Form /BBox [{} {} {} {}] /Group << /S /Transparency /CS /DeviceCMYK /I true >> /Resources << {group_resources} >> /Length {} >> stream
{group}
endstream endobj
6 0 obj << /Type /Pattern /PatternType 2 /Matrix [2 0 0 2 30 40] /Shading << /ShadingType 2 /ColorSpace /DeviceRGB /Coords [0 0 10 0] /Extend [true true] /Function << /FunctionType 2 /Domain [0 1] /C0 [1 0 0] /C1 [0 0 1] /N 1 >> >> >> endobj
trailer << /Root 1 0 R >>
%%EOF",
media[0],
media[1],
page.len(),
bbox[0],
bbox[1],
bbox[2],
bbox[3],
group.len()
);
let mut r = PageRenderer::new(Arc::new(pdf.into_bytes()), RenderConfig::default());
let p = r.render(RenderRequest { page: 0, kind: RequestKind::Pixels, tile: None, scale: 1.0, tag: 0, ..Default::default() });
assert!(p.error.is_none(), "{:?}", p.error);
p.rgba[((y * p.width + x) * 4) as usize..][..3].to_vec()
}
/// From the review of #5228: a shading pattern painted before the group left its pattern
/// transform on the context, and the finished group was drawn through it, so the cyan half
/// of the group showed magenta. The group must look the same whether or not a pattern was
/// painted first.
#[test]
fn a_pattern_painted_before_an_isolated_cmyk_group_does_not_move_it() {
let group = "0 1 0 0 k 20 20 30 60 re f 1 0 0 0 k 50 20 30 60 re f";
let render = |page: &str| cmyk_group_pixel([100, 100], page, [20, 20, 80, 80], group, "", (55, 50));
let after_pattern = render("/Pattern cs /P scn 0 0 5 5 re f /G Do");
let alone = render("/G Do");
assert!(alone[2] > 150 && alone[0] < 100, "the control is cyan: {alone:?}");
assert!(alone.iter().zip(&after_pattern).all(|(a, b)| a.abs_diff(*b) <= 3), "after a pattern {after_pattern:?}, alone {alone:?}");
}
/// From the review of #5228: the Color blend mode over an isolated group's empty backdrop
/// keeps the source colour (ISO 32000-2 §11.3.5.3 with nothing beneath). The K ink pass
/// draws gray, and vello_cpu's nonseparable blend of a gray darkened some K levels to
/// near black. Every 8-bit K level is checked against a plain fill of the same ink.
#[test]
fn the_color_blend_mode_over_an_empty_cmyk_group_keeps_every_k_level() {
let mut wrong = Vec::new();
for level in 0..=255u8 {
let k = format!("0 0 0 {} k", f32::from(level) / 255.0);
let page = format!("{k} 0 0 20 20 re f /G Do");
let group = format!("/B gs {k} 20 0 20 20 re f");
let plain = cmyk_group_pixel([40, 20], &page, [20, 0, 40, 20], &group, "/ExtGState << /B << /BM /Color >> >>", (10, 10));
let blended = cmyk_group_pixel([40, 20], &page, [20, 0, 40, 20], &group, "/ExtGState << /B << /BM /Color >> >>", (30, 10));
if plain.iter().zip(&blended).any(|(a, b)| a.abs_diff(*b) > 3) {
wrong.push((level, plain, blended));
}
}
assert!(wrong.is_empty(), "K levels drawn wrong (level, plain, blended): {wrong:?}");
}
+12 -9
View File
File diff suppressed because one or more lines are too long
+100
View File
@@ -0,0 +1,100 @@
//! PdfCraft patch (70): an isolated transparency group that blends in `DeviceCMYK`.
//!
//! ISO 32000-2 §11.3.4: a group's elements blend in the group's colour space, and the
//! components of a subtractive space are complemented before the blend function and
//! complemented back after it. The renderer composites in RGB, which gives other colours
//! wherever a blend mode meets CMYK artwork: `0 1 0 0 k` (magenta) under `0 0 0 1 k` (black)
//! with `/BM /Difference` is green when blended in CMYK (`|(1,0,1,1) − (1,1,1,0)|`
//! complemented back is `1 0 1 0`) and magenta when blended in RGB.
//!
//! An *isolated* group starts from a transparent backdrop of its own (§11.4.7), so its
//! interior can be computed apart from the page and handed back as finished pixels. A device
//! that can blend in CMYK takes such a group through [`Device::draw_isolated_cmyk_group`] and
//! interprets it with [`CmykGroup::interpret`] as often as it needs; every other device, and a
//! device that declines, gets the group's contents as before.
use crate::InterpreterSettings;
use crate::context::{Context, InterpreterCache};
use crate::device::Device;
use crate::interpret::state::{ClipType, State};
use crate::x_object::{FormXObject, interpret_form_contents};
use hayro_syntax::object::dict::keys::{CS, GROUP};
use hayro_syntax::object::{Dict, Name};
use hayro_syntax::page::Resources;
use hayro_syntax::xref::XRef;
use kurbo::{Affine, Rect, Shape};
use std::ops::Deref;
/// An isolated, non-knockout transparency group whose blending colour space is `DeviceCMYK`,
/// captured where it is painted (PdfCraft patch (70); see the module documentation).
pub struct CmykGroup<'a, 'b> {
form: &'b FormXObject<'a>,
resources: &'b Resources<'a>,
/// The graphics state the group's contents start with: the state at `Do`, after the
/// form's `/Matrix`, with the group's own opacity, soft mask and blend mode already taken
/// by the layer the group is composited through.
state: State<'a>,
root_transform: Affine,
bbox: Rect,
interpreter_cache: InterpreterCache<'a>,
settings: InterpreterSettings,
xref: &'a XRef,
nesting_depth: u32,
}
impl<'a, 'b> CmykGroup<'a, 'b> {
pub(crate) fn new(form: &'b FormXObject<'a>, resources: &'b Resources<'a>, context: &Context<'a>) -> Self {
let mut state = context.get().clone();
// The clips in force belong to the device the group is composited onto; contents
// interpreted on their own push and pop only the clips they set themselves.
state.clips = Vec::<ClipType>::new();
Self {
form,
resources,
state,
root_transform: context.root_transform(),
bbox: context.bbox(),
interpreter_cache: context.interpreter_cache.clone(),
settings: context.settings.clone(),
xref: context.xref,
nesting_depth: context.nesting_depth(),
}
}
/// The device-space box the group can paint: its `/BBox` under the current transform,
/// within the area being drawn.
pub fn device_bbox(&self) -> Rect {
let [x0, y0, x1, y1] = self.form.bbox;
let user = Rect::new(x0 as f64, y0 as f64, x1 as f64, y1 as f64);
let device = (self.state.ctm * user.to_path(0.1)).bounding_box();
device.intersect(self.bbox)
}
/// Interpret the group's contents into `device`, with device space moved by `offset`
/// (so a device covering only [`Self::device_bbox`] can start at its corner).
pub fn interpret(&self, device: &mut impl Device<'a>, offset: (f64, f64)) {
let shift = Affine::translate((-offset.0, -offset.1));
let mut state = self.state.clone();
state.ctm = shift * state.ctm;
let mut context = Context::new_with(
shift * self.root_transform,
shift.transform_rect_bbox(self.bbox),
&self.interpreter_cache,
self.xref,
self.settings.clone(),
state,
self.nesting_depth,
);
interpret_form_contents(self.resources, self.form, &mut context, device);
}
}
/// Whether `form`'s transparency group blends in `DeviceCMYK`: it says so in its `/CS`.
pub(crate) fn blends_in_device_cmyk(form: &Dict<'_>) -> bool {
form.get::<Dict<'_>>(GROUP).and_then(|g| g.get::<Name<'_>>(CS)).is_some_and(|cs| matches!(cs.deref(), b"DeviceCMYK" | b"CMYK"))
}
/// Whether `form`'s transparency group names a blending colour space of its own (`/CS`).
pub(crate) fn names_a_blending_space(form: &Dict<'_>) -> bool {
form.get::<Dict<'_>>(GROUP).is_some_and(|g| g.contains_key(CS))
}
+33
View File
@@ -1399,6 +1399,29 @@ impl Color {
.to_rgba(&self.components, self.opacity, false)
}
/// PdfCraft patch (70): the colour as process ink tints `[C, M, Y, K]` (0 = no ink) and
/// its opacity, for blending in a `DeviceCMYK` transparency group (ISO 32000-2 §11.3.4).
///
/// `DeviceCMYK` gives its components. `DeviceGray` is black ink alone, `K = 1 − gray`
/// (§10.4.2.2). `None` for every other space, ICCBased CMYK included: such a colour has no
/// tints without a colour conversion this crate does not make (an ICCBased colour keeps
/// its own profile), and the caller draws it as before.
pub fn ink_tints(&self) -> Option<([f32; 4], f32)> {
let c = |i: usize| {
self.components
.get(i)
.copied()
.unwrap_or(0.0)
.clamp(0.0, 1.0)
};
let tints = match self.color_space.0.as_ref() {
ColorSpaceType::DeviceCmyk => [c(0), c(1), c(2), c(3)],
ColorSpaceType::DeviceGray => [0.0, 0.0, 0.0, 1.0 - c(0)],
_ => return None,
};
Some((tints, self.opacity.clamp(0.0, 1.0)))
}
/// Create a color from RGBA.
pub fn from_rgba(rgba: AlphaColor) -> Self {
let c = rgba.components();
@@ -1410,6 +1433,16 @@ impl Color {
}
}
/// PdfCraft patch (70): `input`, 8-bit `DeviceCMYK` pixels (four bytes each), converted to
/// 8-bit sRGB in `output` (three bytes each) through the CMYK profile pages are drawn with,
/// so a colour blended in CMYK lands where the same colour painted directly does.
pub fn cmyk_to_srgb8(input: &[u8], output: &mut [u8]) -> Option<()> {
if input.len() % 4 != 0 || output.len() != input.len() / 4 * 3 {
return None;
}
CMYK_TRANSFORM.convert_u8(input, output)
}
static CMYK_TRANSFORM: LazyLock<ICCProfile> = LazyLock::new(|| {
ICCProfile::new(include_bytes!("../assets/CGATS001Compat-v2-micro.icc"), 4).unwrap()
});
+12
View File
@@ -1,3 +1,4 @@
use crate::cmyk_group::CmykGroup;
use crate::font::Glyph;
use crate::soft_mask::SoftMask;
use crate::{BlendMode, ClipPath, Image};
@@ -120,6 +121,17 @@ pub trait Device<'a> {
fn end_knockout_pass(&mut self) -> Vec<BlendMode> {
Vec::new()
}
/// PdfCraft patch (70): draw the contents of an isolated transparency group that blends
/// in `DeviceCMYK` (ISO 32000-2 §11.3.4), already inside the layer it is composited
/// through. Return `true` when the device drew them; on `false` (the default) the
/// interpreter draws the contents into the device as for any other group.
fn draw_isolated_cmyk_group(&mut self, _group: &CmykGroup<'a, '_>) -> bool {
false
}
/// PdfCraft patch (70): an isolated transparency group that names a blending colour space
/// other than `DeviceCMYK` is about to be drawn. A device drawing an enclosing group in CMYK
/// can no longer draw that group right, since this one blends in its own space.
fn group_blends_outside_device_cmyk(&mut self) {}
}
/// A device that discards all drawing operations.
+2
View File
@@ -32,6 +32,7 @@ This crate has one optional feature:
mod log;
mod cache;
mod cmyk_group;
mod context;
mod convert;
mod device;
@@ -53,6 +54,7 @@ pub mod shading;
pub mod util;
pub use cache::CacheKey;
pub use cmyk_group::CmykGroup;
pub use context::*;
pub use device::*;
pub use function::Function;
+54 -17
View File
@@ -1,4 +1,5 @@
use crate::cache::{Cache, DecodedImageCache, DecodedImageKey};
use crate::cmyk_group::{CmykGroup, blends_in_device_cmyk, names_a_blending_space};
use crate::color::{ColorComponents, ColorSpace, DefaultColorSpaces, DeviceFamily, ToRgb};
use crate::context::Context;
use crate::device::Device;
@@ -138,8 +139,6 @@ pub(crate) fn draw_form_xobject<'a, 'b>(
return;
}
let iter = TypedIter::new(x_object.decoded.as_ref());
context.path_mut().truncate(0);
context.save_state();
context.pre_concat_affine(x_object.matrix);
@@ -231,6 +230,57 @@ pub(crate) fn draw_form_xobject<'a, 'b>(
}
}
let resources = Resources::for_form(x_object.resources.clone(), resources.clone());
// PdfCraft patch (70): an isolated group blending in DeviceCMYK starts from a backdrop of
// its own, so a device able to blend in CMYK computes its interior apart and composites the
// result through the layer pushed above (see `cmyk_group`). Knockout groups are left out:
// their elements composite with the group's initial backdrop, not with each other.
// An isolated group that names another blending space blends in that space (§11.3.4); a
// device drawing an enclosing group in CMYK is told, so it can decline that group. A
// non-isolated group's /CS is ignored: it blends in its parent's space (ISO 32000-2
// Table 145, CS), so it stays in the enclosing group's CMYK.
if x_object.is_transparency_group
&& x_object.is_isolated
&& names_a_blending_space(&x_object.dict)
&& !blends_in_device_cmyk(&x_object.dict)
{
device.group_blends_outside_device_cmyk();
}
let blended_in_cmyk = own_layer
&& x_object.is_isolated
&& !x_object.is_knockout
&& blends_in_device_cmyk(&x_object.dict)
&& device.draw_isolated_cmyk_group(&CmykGroup::new(x_object, &resources, context));
if !blended_in_cmyk {
interpret_form_contents(&resources, x_object, context, device);
}
if own_layer {
device.pop_transparency_group();
}
context.pop_root_transform();
context.restore_state(device);
if has_oc {
context.ocg_state.end_marked_content();
}
context.end_nested_interpretation();
}
/// The contents of form `x_object`, in `context` as prepared for them (the form's `/Matrix`
/// applied, its root transform pushed), clipped to its `/BBox`. PdfCraft patch (70): shared by
/// [`draw_form_xobject`] and [`CmykGroup::interpret`].
pub(crate) fn interpret_form_contents<'a>(
resources: &Resources<'a>,
x_object: &FormXObject<'a>,
context: &mut Context<'a>,
device: &mut impl Device<'a>,
) {
device.set_soft_mask(context.get().graphics_state.soft_mask.clone());
device.set_blend_mode(context.get().graphics_state.blend_mode);
@@ -247,26 +297,13 @@ pub(crate) fn draw_form_xobject<'a, 'b>(
});
interpret(
iter,
&Resources::for_form(x_object.resources.clone(), resources.clone()),
TypedIter::new(x_object.decoded.as_ref()),
resources,
context,
device,
);
device.pop_clip_path();
if own_layer {
device.pop_transparency_group();
}
context.pop_root_transform();
context.restore_state(device);
if has_oc {
context.ocg_state.end_marked_content();
}
context.end_nested_interpretation();
}
/// PdfCraft patch (67): draws a non-isolated knockout group entered at full opacity, without a
+203
View File
@@ -436,6 +436,11 @@ 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.
const MAX_CMYK_GROUP_PIXELS: u64 = 1 << 24;
pub(crate) struct Renderer {
pub(crate) ctx: RenderContext,
/// PdfCraft patch: when set, solid text and line-art paint colours are remapped
@@ -467,6 +472,50 @@ pub(crate) struct Renderer {
pub(crate) preview: bool,
// TODO: Remove this once vello_cpu bug with non-transparent images is fixed
image_transparency_stack: Vec<bool>,
/// PdfCraft patch (27): set while this renderer draws one ink pass of a group blending
/// in `DeviceCMYK` (see [`Renderer::draw_isolated_cmyk_group`]).
ink: InkState,
}
/// PdfCraft patch (27): which inks a pass of a `DeviceCMYK` group draws, and whether the
/// group asked for something a pass can't draw.
///
/// A pass paints the complement of ink tints as colour — no ink is white — so the RGB blend
/// functions compute ISO 32000-2 §11.3.4's subtractive blend, `1 − B(1 − cb, 1 − cs)`, and
/// complementing the pixels back gives the tints. The cyan, magenta and yellow pass paints
/// them as red, green and blue, which is also how §11.3.5.3 has the nonseparable modes treat
/// them. The black pass paints black ink as a grey, so a separable mode blends it like any
/// tint, and of the nonseparable modes Hue, Saturation and Color keep the backdrop's grey
/// while Luminosity takes the source's: §11.3.5.3's rule for the K component.
#[derive(Clone, Copy, Default)]
struct InkState {
pass: Option<InkPass>,
/// Something in the group has no ink tints (an RGB colour, a pattern, an image): the
/// group is drawn as before instead.
unsupported: bool,
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum InkPass {
/// Cyan, magenta and yellow as red, green and blue.
Cmy,
/// Black as grey.
K,
}
impl InkState {
/// The colour a pass paints for `color`; transparent, with the group marked unsupported,
/// when the colour has no ink tints.
fn paint(&mut self, pass: InkPass, color: &Color) -> AlphaColor<Srgb> {
let Some(([c, m, y, k], alpha)) = color.ink_tints() else {
self.unsupported = true;
return AlphaColor::TRANSPARENT;
};
match pass {
InkPass::Cmy => AlphaColor::new([1.0 - c, 1.0 - m, 1.0 - y, alpha]),
InkPass::K => AlphaColor::new([1.0 - k, 1.0 - k, 1.0 - k, alpha]),
}
}
}
#[derive(Clone, Copy)]
@@ -532,6 +581,7 @@ impl Renderer {
scaler: Scaler::new(ResamplingFunction::CatmullRom),
preview,
image_transparency_stack: Vec::new(),
ink: InkState::default(),
}
}
@@ -637,6 +687,7 @@ impl Renderer {
scaler: self.scaler,
preview: self.preview,
image_transparency_stack: Vec::new(),
ink: InkState::default(),
};
let mut mask_pix = Pixmap::new(self.ctx.width(), self.ctx.height());
let rgb_data = ImageData::Rgb(RgbData {
@@ -1106,6 +1157,53 @@ impl Renderer {
.fill_rect(&Rect::new(0.0, 0.0, width as f64, height as f64));
}
/// PdfCraft patch (27): one ink pass of `group` (see [`InkState`]) on a `width` × `height`
/// canvas whose corner is device point `offset`; `None` when the group holds something the
/// pass has no tints for.
fn ink_pass(
&self,
group: &hayro_interpret::CmykGroup<'_, '_>,
pass: InkPass,
offset: (f64, f64),
width: u16,
height: u16,
) -> Option<Pixmap> {
let mut renderer = Self {
ctx: RenderContext::new_with(width, height, derive_settings(self.ctx.render_settings())),
color_replacement: None,
in_annotations: self.in_annotations,
inside_pattern: self.inside_pattern,
soft_mask_cache: HashMap::default(),
outline_cache: self.outline_cache.clone(),
cur_mask: None,
in_type3_glyph: false,
scaler: Scaler::new(ResamplingFunction::CatmullRom),
preview: self.preview,
image_transparency_stack: Vec::new(),
ink: InkState {
pass: Some(pass),
unsupported: false,
},
knockout: Vec::new(),
erasing: false,
erase_groups: 0,
};
// The pass draws inside a layer of its own. On the bare canvas, vello_cpu fills a tile
// that a paint covers completely without the blend mode, so the middle of a group wider
// than about two tiles lost its blend (#3777).
renderer.ctx.push_layer(None, None, None, None, None);
group.interpret(&mut renderer, offset);
renderer.ctx.pop_layer();
if renderer.ink.unsupported {
return None;
}
let mut pix = Pixmap::new(width, height);
renderer.ctx.flush();
let mut resources = vello_cpu::Resources::default();
renderer.ctx.render_to_pixmap(&mut resources, &mut pix);
Some(pix)
}
/// PdfCraft patch: remaps a solid paint colour through `color_replacement`, if one is
/// set. `Paint::Pattern` (images, shadings, tiling patterns) always passes through
/// untouched.
@@ -1211,6 +1309,20 @@ impl Renderer {
// covers its triangles alone and must not repeat outside them.
let mut extend = peniko::Extend::Repeat;
// PdfCraft patch (27): an ink pass paints tints, and has none for a pattern.
if let Some(pass) = self.ink.pass {
let color = match paint {
Paint::Color(c) => self.ink.paint(pass, c),
Paint::Pattern(_) => {
self.ink.unsupported = true;
AlphaColor::TRANSPARENT
}
};
self.ctx.set_paint_transform(Affine::IDENTITY);
self.ctx.set_paint(color);
return PaintSetup::Clip(None);
}
let paint: PaintType = match paint.clone() {
Paint::Color(c) => {
let c = c.to_rgba().to_rgba8();
@@ -1344,6 +1456,7 @@ impl Renderer {
scaler: self.scaler,
preview: self.preview,
image_transparency_stack: Vec::new(),
ink: InkState::default(),
};
let initial_transform = Affine::translate((BORDER, BORDER))
* Affine::scale_non_uniform(xs as f64, ys as f64)
@@ -1407,6 +1520,7 @@ impl Renderer {
scaler: self.scaler,
preview: self.preview,
image_transparency_stack: Vec::new(),
ink: InkState::default(),
};
let mut initial_transform = Affine::scale_non_uniform(xs, ys)
* Affine::translate((-bbox.x0, -bbox.y0));
@@ -1592,6 +1706,12 @@ impl Renderer {
impl<'a> Device<'a> for Renderer {
fn draw_image(&mut self, image: hayro_interpret::Image<'a, '_>, mut transform: Affine) {
// PdfCraft patch (27): an image's pixels are converted to RGB before they reach the
// device, so an ink pass has no tints for them; the group is drawn as before.
if self.ink.pass.is_some() {
self.ink.unsupported = true;
return;
}
// PdfCraft patch (22): an erase pass paints nothing; an unmasked image's rectangle was
// cleared by `knockout_shape`, and a masked image's shape is left alone.
if self.erasing {
@@ -1706,6 +1826,7 @@ impl<'a> Device<'a> for Renderer {
scaler: self.scaler,
preview: self.preview,
image_transparency_stack: Vec::new(),
ink: InkState::default(),
};
let mut sub_pix = Pixmap::new(width, height);
sub_renderer.ctx.set_transform(transform);
@@ -1769,6 +1890,87 @@ impl<'a> Device<'a> for Renderer {
self.push_clip_path_inner(&clip_path.path, clip_path.fill);
}
/// PdfCraft patch (27): a nested group blending in another space can't be drawn in a pass.
fn group_blends_outside_device_cmyk(&mut self) {
if self.ink.pass.is_some() {
self.ink.unsupported = true;
}
}
/// PdfCraft patch (27), with hayro-interpret patch (70): an isolated group that blends in
/// `DeviceCMYK` (ISO 32000-2 §11.3.4). Its contents are drawn twice on canvases of their own,
/// as cyan-magenta-yellow and as black (see [`InkState`]), and the tints those passes leave
/// are converted to RGB through the CMYK profile solid CMYK colour is drawn with. The
/// result goes into the layer the interpreter pushed for the group, which composites it
/// with the group's opacity, soft mask and blend mode as before. Declined (and so drawn as
/// before) under reading colours, inside a pass (a nested group then blends in the pass's
/// own CMYK), while erasing for a knockout group (the contents must clear, not paint), and
/// for contents a pass has no tints for.
fn draw_isolated_cmyk_group(&mut self, group: &hayro_interpret::CmykGroup<'a, '_>) -> bool {
if self.color_replacement.is_some() || self.ink.pass.is_some() || self.erasing {
return false;
}
let canvas = Rect::new(0.0, 0.0, f64::from(self.ctx.width()), f64::from(self.ctx.height()));
let area = group.device_bbox().intersect(canvas);
if !(area.width() > 0.0 && area.height() > 0.0) {
// Nothing of the group is on the canvas (a NaN box lands here too).
return true;
}
let (x0, y0) = (area.x0.floor(), area.y0.floor());
// Within the canvas, so the sides fit its u16 ones.
let width = (area.x1.ceil() - x0) as u16;
let height = (area.y1.ceil() - y0) as u16;
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;
}
let mut result = Pixmap::new(width, height);
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 };
}
// The group's own soft mask and blend mode are on its layer; what the contents set
// must not apply to the finished pixels a second time.
self.set_soft_mask(None);
self.set_blend_mode(BlendMode::Normal);
// A pattern painted earlier leaves its paint transform on the context.
self.ctx.set_paint_transform(Affine::IDENTITY);
self.draw_pixmap(Arc::new(result), ImageQuality::Low, Affine::translate((x0, y0)));
true
}
fn push_transparency_group(
&mut self,
opacity: f32,
@@ -2141,6 +2343,7 @@ fn draw_soft_mask(mask: &SoftMask<'_>, settings: RenderSettings, width: u16, hei
scaler: Scaler::new(ResamplingFunction::CatmullRom),
preview,
image_transparency_stack: Vec::new(),
ink: InkState::default(),
};
let bg_color = mask.background_color().to_rgba();