Merge PR #3018: fix(render): draw device colour in the resources' /DefaultGray, /DefaultRGB and /DefaultCMYK (vendored hayro-interpret patch 38)

vendor/README.md: main's single hayro-interpret row plus the PR's patch 38 and its tests, placed before 41
This commit is contained in:
aiongg
2026-10-22 01:45:41 +00:00
9 changed files with 667 additions and 48 deletions
+3
View File
@@ -7238,4 +7238,7 @@ trailer << /Root 1 0 R >>
assert!(render(glyph, 3).as_chunks::<4>().0.iter().all(|p| p[..3] == [255, 255, 255]), "{glyph}: 3 Tr hides the glyph");
}
}
// #1201: /DefaultGray, /DefaultRGB and /DefaultCMYK (vendored hayro-interpret patch 38).
mod default_colour_spaces;
}
@@ -0,0 +1,397 @@
//! #1201: the `/DefaultGray`, `/DefaultRGB` and `/DefaultCMYK` colour spaces of a resource
//! dictionary stand in for the device spaces its content selects, with colour operators, with
//! images and with shadings (ISO 32000-2 §8.6.5.6, vendored hayro-interpret patch 38). hayro
//! drew device colour as it was, so pages that calibrate their device colour this way (common
//! in PDF/A) came out in the wrong colours.
//!
//! The defaults are synthetic ICC profiles built here, each sending device colour somewhere it
//! cannot go uncalibrated: red to green (or blue), black to mid grey, black ink to blue.
use super::{ONE_PIXEL_RGB, image_cache_pdf, render_first_page, rgb_at};
/// The width in pixels of the 130 × 70 pt page `image_cache_pdf` makes, rendered at 72 dpi.
const WIDTH: usize = 130;
const RED: [u8; 3] = [255, 0, 0];
const GREEN: [u8; 3] = [0, 255, 0];
const BLUE: [u8; 3] = [0, 0, 255];
const BLACK: [u8; 3] = [0, 0, 0];
/// Device black through `gray_profile`: half the luminance of white, sRGB-encoded.
const MID_GREY: [u8; 3] = [188, 188, 188];
/// Full black ink through `cmyk_profile`: CIELAB (40, 20, -60) relative to the D50 white of the
/// profile connection space, which is XYZ (0.1378, 0.1125, 0.3956) and a mid blue in sRGB.
const INK_BLUE: [u8; 3] = [67, 86, 194];
/// An s15Fixed16Number (ICC.1 §4.6).
fn s15f16(value: f64) -> [u8; 4] {
((value * 65536.0).round() as i32).to_be_bytes()
}
/// An ICC v2 profile (ICC.1:2001-04 §6) of device class `class` for the colour space `space`,
/// with `tags` (signature, data) in that order. The device model is left blank: hayro takes a
/// profile whose model reads `sRGB` for standard sRGB and skips its transform.
fn icc(class: &[u8; 4], space: &[u8; 4], pcs: &[u8; 4], tags: &[([u8; 4], Vec<u8>)]) -> Vec<u8> {
let start = 128 + 4 + 12 * tags.len();
let (mut table, mut data) = (Vec::new(), Vec::new());
for (signature, tag) in tags {
table.extend_from_slice(signature);
table.extend_from_slice(&u32::try_from(start + data.len()).unwrap().to_be_bytes());
table.extend_from_slice(&u32::try_from(tag.len()).unwrap().to_be_bytes());
data.extend_from_slice(tag);
data.resize(data.len().next_multiple_of(4), 0);
}
let mut profile = vec![0; 128];
profile[0..4].copy_from_slice(&u32::try_from(start + data.len()).unwrap().to_be_bytes());
profile[8..12].copy_from_slice(&[2, 0x10, 0, 0]);
profile[12..16].copy_from_slice(class);
profile[16..20].copy_from_slice(space);
profile[20..24].copy_from_slice(pcs);
profile[36..40].copy_from_slice(b"acsp");
// The illuminant of the profile connection space, D50.
for (i, value) in [0.9642, 1.0, 0.8249].into_iter().enumerate() {
profile[68 + 4 * i..72 + 4 * i].copy_from_slice(&s15f16(value));
}
profile.extend_from_slice(&u32::try_from(tags.len()).unwrap().to_be_bytes());
profile.extend(table);
profile.extend(data);
profile
}
/// An `XYZType` tag.
fn xyz([x, y, z]: [f64; 3]) -> Vec<u8> {
[b"XYZ ".as_slice(), &[0; 4], &s15f16(x), &s15f16(y), &s15f16(z)].concat()
}
/// A `curveType` tag: one entry is a gamma (u8Fixed8), more are points spread evenly over 0..1.
fn curve(points: &[u16]) -> Vec<u8> {
let mut tag = [b"curv".as_slice(), &[0; 4], &u32::try_from(points.len()).unwrap().to_be_bytes()].concat();
for point in points {
tag.extend_from_slice(&point.to_be_bytes());
}
tag
}
/// sRGB's red, green and blue colorants, adapted to D50 as sRGB profiles record them.
const SRGB_COLORANTS: [[f64; 3]; 3] = [[0.4361, 0.2225, 0.0139], [0.3851, 0.7169, 0.0971], [0.1431, 0.0606, 0.7141]];
/// A linear RGB profile whose red, green and blue take the sRGB colorants `primaries` names:
/// with `[1, 2, 0]` red is drawn as sRGB green, green as blue and blue as red.
fn rgb_profile(primaries: [usize; 3]) -> Vec<u8> {
let mut tags = Vec::new();
for (signature, primary) in [*b"rXYZ", *b"gXYZ", *b"bXYZ"].into_iter().zip(primaries) {
tags.push((signature, xyz(SRGB_COLORANTS[primary])));
}
for signature in [*b"rTRC", *b"gTRC", *b"bTRC"] {
tags.push((signature, curve(&[0x0100])));
}
icc(b"mntr", b"RGB ", b"XYZ ", &tags)
}
/// A gray profile whose tone curve rises from half the luminance of white to all of it, so
/// device black (`0 g`) is drawn mid grey.
fn gray_profile() -> Vec<u8> {
icc(b"mntr", b"GRAY", b"XYZ ", &[(*b"kTRC", curve(&[0x8000, 0xffff]))])
}
/// A CMYK profile with an 8-bit lookup table into CIELAB (`lut8Type`, ICC.1 §6.5.7) that draws
/// full black ink as L*a*b* (40, 20, -60) and no black ink as white, whatever the other inks.
fn cmyk_profile() -> Vec<u8> {
let mut lut = [b"mft1".as_slice(), &[0; 4], &[4, 3, 2, 0]].concat();
// The matrix, only used for XYZ input: identity.
for value in [1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0] {
lut.extend_from_slice(&s15f16(value));
}
// An identity input table per ink.
for _ in 0..4 {
lut.extend(0..=255_u8);
}
// The grid, two points per ink, black ink varying fastest. Lab is encoded as L* · 2.55,
// a* + 128 and b* + 128.
for point in 0..16 {
lut.extend_from_slice(if point % 2 == 1 { &[102, 148, 68] } else { &[255, 128, 128] });
}
// An identity output table per component.
for _ in 0..3 {
lut.extend(0..=255_u8);
}
icc(b"prtr", b"CMYK", b"Lab ", &[(*b"A2B0", lut)])
}
/// An `/ICCBased` profile stream of `n` components.
fn profile_stream(n: u8, profile: Vec<u8>) -> (String, Vec<u8>) {
(format!("<< /N {n} /Length {} >>", profile.len()), profile)
}
/// A stream with the entries `dict` and the content `content`.
fn stream(dict: &str, content: &str) -> (String, Vec<u8>) {
(format!("<< {dict} /Length {} >>", content.len()), content.as_bytes().to_vec())
}
/// A form XObject covering the page, with the dictionary entries `extra`.
fn form(extra: &str, content: &str) -> (String, Vec<u8>) {
stream(&format!("/Type /XObject /Subtype /Form /BBox [0 0 130 70] {extra}"), content)
}
/// Draw `content` with `resources` on `image_cache_pdf`'s page, whose objects 5, 6, … are the
/// streams `objects`, and give the colour at each of `points` (from the top left).
fn colours(content: &str, resources: &str, objects: &[(String, Vec<u8>)], points: &[(usize, usize)]) -> Vec<[u8; 3]> {
let objects = objects.iter().map(|(dict, data)| (dict.as_str(), data.as_slice())).collect::<Vec<_>>();
let page = render_first_page(image_cache_pdf(content, resources, &objects));
assert_eq!((page.width as usize, page.height), (WIDTH, 70));
points.iter().map(|&(x, y)| rgb_at(&page.rgba, WIDTH, x, y)).collect()
}
#[track_caller]
fn assert_near(got: [u8; 3], want: [u8; 3], what: &str) {
assert!(got.iter().zip(want).all(|(got, want)| got.abs_diff(want) <= 6), "{what}: got {got:?}, expected about {want:?}");
}
/// Device CMYK black ink through hayro's own CMYK profile: near black.
#[track_caller]
fn assert_dark(got: [u8; 3], what: &str) {
assert!(got.iter().all(|c| *c < 90), "{what}: got {got:?}, expected near black");
}
/// `rg`, `RG`, `/DeviceRGB cs` and a `/ColorSpace` resource naming DeviceRGB all select
/// DeviceRGB, so all draw in the page's DefaultRGB. Without one they draw device red as before.
#[test]
fn device_rgb_operators_draw_in_default_rgb() {
let content = "1 0 0 rg 0 0 30 70 re f /DeviceRGB cs 1 0 0 sc 32 0 30 70 re f /CS0 cs 1 0 0 sc 64 0 30 70 re f \
1 0 0 RG 20 w 111 0 m 111 70 l S";
let points = [(15, 35), (47, 35), (79, 35), (111, 35)];
let what = ["rg", "/DeviceRGB cs", "/CS0 cs (/DeviceRGB)", "RG"];
let objects = [profile_stream(3, rgb_profile([1, 2, 0]))];
let calibrated = colours(content, "<< /ColorSpace << /DefaultRGB [/ICCBased 5 0 R] /CS0 /DeviceRGB >> >>", &objects, &points);
for (got, what) in calibrated.into_iter().zip(what) {
assert_near(got, GREEN, &format!("{what} with DefaultRGB"));
}
let device = colours(content, "<< /ColorSpace << /CS0 /DeviceRGB >> >>", &objects, &points);
for (got, what) in device.into_iter().zip(what) {
assert_eq!(got, RED, "{what} without DefaultRGB");
}
}
/// `k`, `K` and `/DeviceCMYK cs` draw in the page's DefaultCMYK.
#[test]
fn device_cmyk_operators_draw_in_default_cmyk() {
let content = "0 0 0 1 k 0 0 40 70 re f /DeviceCMYK cs 0 0 0 1 sc 45 0 40 70 re f 0 0 0 1 K 20 w 110 0 m 110 70 l S";
let points = [(20, 35), (65, 35), (110, 35)];
let what = ["k", "/DeviceCMYK cs", "K"];
let objects = [profile_stream(4, cmyk_profile())];
let calibrated = colours(content, "<< /ColorSpace << /DefaultCMYK [/ICCBased 5 0 R] >> >>", &objects, &points);
for (got, what) in calibrated.into_iter().zip(what) {
assert_near(got, INK_BLUE, &format!("{what} with DefaultCMYK"));
}
for (got, what) in colours(content, "<< >>", &objects, &points).into_iter().zip(what) {
assert_dark(got, &format!("{what} without DefaultCMYK"));
}
}
/// `g`, `G` and `/DeviceGray cs` draw in the page's DefaultGray.
#[test]
fn device_gray_operators_draw_in_default_gray() {
let content = "0 g 0 0 40 70 re f /DeviceGray cs 0 sc 45 0 40 70 re f 0 G 20 w 110 0 m 110 70 l S";
let points = [(20, 35), (65, 35), (110, 35)];
let what = ["g", "/DeviceGray cs", "G"];
let objects = [profile_stream(1, gray_profile())];
let calibrated = colours(content, "<< /ColorSpace << /DefaultGray [/ICCBased 5 0 R] >> >>", &objects, &points);
for (got, what) in calibrated.into_iter().zip(what) {
assert_near(got, MID_GREY, &format!("{what} with DefaultGray"));
}
for (got, what) in colours(content, "<< >>", &objects, &points).into_iter().zip(what) {
assert_eq!(got, BLACK, "{what} without DefaultGray");
}
}
/// Images whose `/ColorSpace` is a device space are drawn in the default for it too: image
/// XObjects, inline images (whose `/RGB` and `/G` abbreviate the device spaces) and an inline
/// image naming a `/ColorSpace` resource that is DeviceRGB.
#[test]
fn device_images_draw_in_the_default_colour_spaces() {
let content = "q 20 0 0 70 0 0 cm /R Do Q q 20 0 0 70 22 0 cm /G Do Q q 20 0 0 70 44 0 cm /K Do Q \
q 20 0 0 70 66 0 cm BI /W 1 /H 1 /CS /RGB /BPC 8 /F /AHx ID ff0000> EI Q \
q 20 0 0 70 88 0 cm BI /W 1 /H 1 /CS /CS0 /BPC 8 /F /AHx ID ff0000> EI Q \
q 20 0 0 70 110 0 cm BI /W 1 /H 1 /CS /G /BPC 8 /F /AHx ID 00> EI Q";
let points = [(10, 35), (32, 35), (54, 35), (76, 35), (98, 35), (120, 35)];
let image = |space: &str, data: &[u8]| {
let dict = format!("<< /Type /XObject /Subtype /Image /Width 1 /Height 1 /ColorSpace /{space} /BitsPerComponent 8 /Length {} >>", data.len());
(dict, data.to_vec())
};
let objects = [
profile_stream(3, rgb_profile([1, 2, 0])),
profile_stream(1, gray_profile()),
profile_stream(4, cmyk_profile()),
image("DeviceRGB", &[255, 0, 0]),
image("DeviceGray", &[0]),
image("DeviceCMYK", &[0, 0, 0, 255]),
];
let x_objects = "/XObject << /R 8 0 R /G 9 0 R /K 10 0 R >>";
let calibrated = colours(
content,
&format!(
"<< /ColorSpace << /DefaultRGB [/ICCBased 5 0 R] /DefaultGray [/ICCBased 6 0 R] /DefaultCMYK [/ICCBased 7 0 R] /CS0 /DeviceRGB >> {x_objects} >>"
),
&objects,
&points,
);
assert_near(calibrated[0], GREEN, "DeviceRGB image");
assert_near(calibrated[1], MID_GREY, "DeviceGray image");
assert_near(calibrated[2], INK_BLUE, "DeviceCMYK image");
assert_near(calibrated[3], GREEN, "inline /RGB image");
assert_near(calibrated[4], GREEN, "inline image in /CS0 (/DeviceRGB)");
assert_near(calibrated[5], MID_GREY, "inline /G image");
let device = colours(content, &format!("<< /ColorSpace << /CS0 /DeviceRGB >> {x_objects} >>"), &objects, &points);
assert_eq!(device[0], RED, "DeviceRGB image without DefaultRGB");
assert_eq!(device[1], BLACK, "DeviceGray image without DefaultGray");
assert_dark(device[2], "DeviceCMYK image without DefaultCMYK");
assert_eq!(device[3], RED, "inline /RGB image without DefaultRGB");
assert_eq!(device[4], RED, "inline /CS0 image without DefaultRGB");
assert_eq!(device[5], BLACK, "inline /G image without DefaultGray");
}
/// Shadings whose `/ColorSpace` is a device space are drawn in the default for it too: painted
/// by `sh` or as a shading pattern, with their colours from a function (axial) or from their
/// vertices (a Type 4 mesh).
#[test]
fn device_shadings_draw_in_the_default_colour_spaces() {
let axial = |space: &str, colour: &str| {
format!(
"<< /ShadingType 2 /ColorSpace /{space} /Coords [0 0 130 0] /Extend [true true] \
/Function << /FunctionType 2 /Domain [0 1] /C0 [{colour}] /C1 [{colour}] /N 1 >> >>"
)
};
// Two red triangles over the page: a flag, x, y and red, green and blue, a byte each.
let mut mesh: Vec<u8> = Vec::new();
for (x, y) in [(0, 0), (255, 0), (0, 255), (255, 0), (255, 255), (0, 255)] {
mesh.extend_from_slice(&[0, x, y, 255, 0, 0]);
}
let objects = [
profile_stream(3, rgb_profile([1, 2, 0])),
profile_stream(1, gray_profile()),
(
format!(
"<< /ShadingType 4 /ColorSpace /DeviceRGB /BitsPerCoordinate 8 /BitsPerComponent 8 /BitsPerFlag 8 \
/Decode [0 255 0 255 0 1 0 1 0 1] /Length {} >>",
mesh.len()
),
mesh,
),
];
let content = "q 0 0 30 70 re W n /A sh Q /Pattern cs /P scn 32 0 30 70 re f \
q 64 0 30 70 re W n /M sh Q q 96 0 30 70 re W n /G sh Q";
let points = [(15, 35), (47, 35), (79, 35), (111, 35)];
let shadings = format!(
"/Shading << /A {} /M 7 0 R /G {} >> /Pattern << /P << /PatternType 2 /Shading {} >> >>",
axial("DeviceRGB", "1 0 0"),
axial("DeviceGray", "0"),
axial("DeviceRGB", "1 0 0")
);
let calibrated = colours(
content,
&format!("<< /ColorSpace << /DefaultRGB [/ICCBased 5 0 R] /DefaultGray [/ICCBased 6 0 R] >> {shadings} >>"),
&objects,
&points,
);
assert_near(calibrated[0], GREEN, "sh, an axial shading in DeviceRGB");
assert_near(calibrated[1], GREEN, "a shading pattern in DeviceRGB");
assert_near(calibrated[2], GREEN, "sh, a Type 4 mesh in DeviceRGB");
assert_near(calibrated[3], MID_GREY, "sh, an axial shading in DeviceGray");
let device = colours(content, &format!("<< {shadings} >>"), &objects, &points);
assert_near(device[0], RED, "sh, an axial shading without DefaultRGB");
assert_near(device[1], RED, "a shading pattern without DefaultRGB");
assert_near(device[2], RED, "sh, a Type 4 mesh without DefaultRGB");
assert_near(device[3], BLACK, "sh, an axial shading without DefaultGray");
}
/// One image drawn by a page with DefaultRGB and by a form that switches it off is decoded
/// for each: the decoded-image cache keeps the two apart (hayro-interpret patch 32 shares a
/// decode between every draw of an image).
#[test]
fn an_image_drawn_with_and_without_a_default_keeps_both_colours() {
let objects = [
profile_stream(3, rgb_profile([1, 2, 0])),
(ONE_PIXEL_RGB.to_owned(), vec![255, 0, 0]),
form("/Resources << /ColorSpace << /DefaultRGB /DeviceRGB >> >>", "q 40 0 0 70 70 0 cm /R Do Q"),
];
let got = colours(
"q 40 0 0 70 0 0 cm /R Do Q /F Do",
"<< /ColorSpace << /DefaultRGB [/ICCBased 5 0 R] >> /XObject << /R 6 0 R /F 7 0 R >> >>",
&objects,
&[(20, 35), (90, 35)],
);
assert_near(got[0], GREEN, "the image on the page, in DefaultRGB");
assert_eq!(got[1], RED, "the same image in the form without DefaultRGB");
}
/// A form XObject draws device colour in the default of the resources it is drawn with: its
/// own when it has one, else the page's (with or without a `/Resources` of its own, with a
/// `/ColorSpace` of its own that has no default, and through a form nested in it). Poppler and
/// MuPDF draw these forms in the page's default too, though §8.6.5.6 names only the current
/// resource dictionary. A form whose own default is a device space or not a usable default
/// draws device colour, not the page's default.
#[test]
fn forms_inherit_the_default_colour_spaces_they_do_not_override() {
let blue = rgb_profile([2, 0, 1]);
let objects = [
profile_stream(3, rgb_profile([1, 2, 0])),
// hayro caches a profile by its stream's dictionary, which here would be byte for byte
// the first profile's, so this one says more.
(format!("<< /N 3 /Alternate /DeviceRGB /Length {} >>", blue.len()), blue),
form("", "1 0 0 rg 22 37 20 33 re f"),
form("/Resources << /ProcSet [/PDF] >>", "1 0 0 rg 44 37 20 33 re f"),
form("/Resources << /ColorSpace << /DefaultRGB [/ICCBased 6 0 R] >> /XObject << /N 12 0 R >> >>", "1 0 0 rg 66 37 20 33 re f /N Do"),
form("/Resources << /ColorSpace << /DefaultRGB /DeviceRGB >> >>", "1 0 0 rg 88 37 20 33 re f"),
form("/Resources << /ColorSpace << /DefaultRGB [/Lab << /WhitePoint [0.9642 1 0.8249] >>] >> >>", "1 0 0 rg 110 37 20 33 re f"),
form("", "1 0 0 rg 0 0 20 33 re f"),
form("/Resources << /ColorSpace << /CS1 /DeviceGray >> >>", "1 0 0 rg 22 0 20 33 re f"),
];
let got = colours(
"1 0 0 rg 0 37 20 33 re f /A Do /B Do /C Do /D Do /E Do /F Do",
"<< /ColorSpace << /DefaultRGB [/ICCBased 5 0 R] >> /XObject << /A 7 0 R /B 8 0 R /C 9 0 R /D 10 0 R /E 11 0 R /F 13 0 R >> >>",
&objects,
&[(10, 17), (32, 17), (54, 17), (76, 17), (98, 17), (120, 17), (10, 53), (32, 53)],
);
assert_near(got[0], GREEN, "the page");
assert_near(got[1], GREEN, "a form without /Resources");
assert_near(got[2], GREEN, "a form whose /Resources has no /ColorSpace");
assert_near(got[3], BLUE, "a form with a DefaultRGB of its own");
assert_eq!(got[4], RED, "a form whose DefaultRGB is /DeviceRGB");
assert_eq!(got[5], RED, "a form whose DefaultRGB is a Lab space");
assert_near(got[6], BLUE, "a form nested in the one with its own DefaultRGB");
assert_near(got[7], GREEN, "a form whose /ColorSpace has no DefaultRGB");
}
/// A default that cannot stand in for its device space (ISO 32000-2 §8.6.5.6: a Lab, Indexed
/// or Pattern space, another device space, one with the wrong number of components, or no
/// colour space at all) leaves device colour as it is, and the dictionary's other defaults
/// still apply.
#[test]
fn unusable_defaults_leave_device_colour_alone() {
let objects =
[profile_stream(1, gray_profile()), profile_stream(4, cmyk_profile()), (String::from("<< /N 3 /Length 13 >>"), b"not a profile".to_vec())];
for default in [
"[/Lab << /WhitePoint [0.9642 1 0.8249] >>]",
"[/CalGray << /WhitePoint [0.9642 1 0.8249] >>]",
"[/Indexed /DeviceRGB 0 <00FF00>]",
"/Pattern",
"[/Pattern /DeviceRGB]",
"/DeviceCMYK",
"/DeviceGray",
"[/ICCBased 6 0 R]",
"[/ICCBased 7 0 R]",
"42",
"[/NoSuchSpace]",
"/DefaultRGB",
] {
let got = colours(
"1 0 0 rg 0 0 60 70 re f 0 g 70 0 60 70 re f",
&format!("<< /ColorSpace << /DefaultRGB {default} /DefaultGray [/ICCBased 5 0 R] >> >>"),
&objects,
&[(30, 35), (100, 35)],
);
assert_eq!(got[0], RED, "DefaultRGB {default}: device RGB is drawn as it is");
assert_near(got[1], MID_GREY, &format!("DefaultRGB {default}: DefaultGray still applies"));
}
}
+1 -1
View File
File diff suppressed because one or more lines are too long
+5 -1
View File
@@ -73,7 +73,10 @@ pub(crate) struct DecodedImageCache {
/// `/BitsPerComponent`, filters, …) and its object (`obj`: the decryption key depends on it);
/// - whether it is decoded as a mask, a stencil mask, raster or mask output, interpolation,
/// the decode target size (only for an image a JPX stream decodes, which is the only
/// decode that reads it: PdfCraft patch 32), and preview or final quality.
/// decode that reads it: PdfCraft patch 32), and preview or final quality;
/// - the default colour space drawn in place of the image's device space, if any (the cache
/// key of its object, PdfCraft patch 38): the same image can be drawn by resources with a
/// `/DefaultRGB` and by resources without one.
///
/// A transfer function (graphics state `/TR`) also changes the pixels and has no cheap key, so
/// an image drawn under one is not cached; neither is an inline image, whose dictionary lives in
@@ -88,6 +91,7 @@ pub(crate) struct DecodedImageKey {
pub(crate) is_mask: bool,
pub(crate) is_stencil_mask: bool,
pub(crate) interpolate: bool,
pub(crate) default_space: Option<u128>,
pub(crate) target: Option<(u32, u32)>,
pub(crate) preview: bool,
}
+135
View File
@@ -9,12 +9,14 @@ use hayro_syntax::object::Name;
use hayro_syntax::object::Object;
use hayro_syntax::object::Stream;
use hayro_syntax::object::dict::keys::*;
use hayro_syntax::page::Resources;
use moxcms::{
ColorProfile, DataColorSpace, Layout, Transform8BitExecutor, TransformF32Executor,
TransformOptions,
};
use smallvec::{SmallVec, ToSmallVec, smallvec};
use std::borrow::Cow;
use std::cell::OnceCell;
use std::fmt::{Debug, Formatter};
use std::ops::Deref;
use std::sync::{Arc, LazyLock, OnceLock};
@@ -350,6 +352,139 @@ impl ColorSpace {
self.to_alpha_color(c, opacity, manual_scale)
.unwrap_or(AlphaColor::BLACK)
}
/// PdfCraft patch (38): whether this space can be the default colour space of `family`
/// (ISO 32000-2 §8.6.5.6): it has as many components as the device space and is not a
/// Lab, Indexed or Pattern space. Nor is a device space: a default that names one is
/// ignored, so substitution never recurses.
fn can_stand_in_for(&self, family: DeviceFamily) -> bool {
!matches!(
self.0.as_ref(),
ColorSpaceType::DeviceGray
| ColorSpaceType::DeviceRgb
| ColorSpaceType::DeviceCmyk
| ColorSpaceType::Lab(_)
| ColorSpaceType::Indexed(_)
| ColorSpaceType::Pattern(_)
) && self.num_components() == family.num_components()
}
}
/// PdfCraft patch (38): a device colour space (ISO 32000-2 §8.6.4), which the resources'
/// default colour space for it replaces wherever content selects it (§8.6.5.6).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum DeviceFamily {
Gray,
Rgb,
Cmyk,
}
impl DeviceFamily {
/// The device space a colour space operand names: `/DeviceGray`, `/DeviceRGB` or
/// `/DeviceCMYK` (or an abbreviation read as one), alone or at the head of an array. `None`
/// for any other space, including the ICC-based and calibrated ones drawn as device colour
/// here (an sRGB profile, `/CalCMYK`): they are not device spaces.
pub(crate) fn of_object(object: &Object<'_>) -> Option<Self> {
match object {
Object::Name(name) => Self::of_name(name),
Object::Array(array) => array
.flex_iter()
.next::<Name<'_>>()
.and_then(|name| Self::of_name(&name)),
_ => None,
}
}
/// The device space `name` names, as [`DeviceFamily::of_object`].
pub(crate) fn of_name(name: &Name<'_>) -> Option<Self> {
match name.deref() {
DEVICE_GRAY | G => Some(Self::Gray),
DEVICE_RGB | RGB => Some(Self::Rgb),
DEVICE_CMYK | CMYK => Some(Self::Cmyk),
_ => None,
}
}
/// The device space itself.
fn space(self) -> ColorSpace {
match self {
Self::Gray => ColorSpace::device_gray(),
Self::Rgb => ColorSpace::device_rgb(),
Self::Cmyk => ColorSpace::device_cmyk(),
}
}
fn num_components(self) -> u8 {
match self {
Self::Gray => 1,
Self::Rgb => 3,
Self::Cmyk => 4,
}
}
/// The `/ColorSpace` resource that holds the default for this space.
fn default_key(self) -> &'static [u8] {
match self {
Self::Gray => DEFAULT_GRAY,
Self::Rgb => DEFAULT_RGB,
Self::Cmyk => DEFAULT_CMYK,
}
}
}
/// PdfCraft patch (38): the default colour spaces (`/DefaultGray`, `/DefaultRGB` and
/// `/DefaultCMYK`, ISO 32000-2 §8.6.5.6) of the resources content is drawn with, each read the
/// first time the content selects its device space. Like any named resource, a default comes
/// from those resources' `/ColorSpace` or from the resources they inherit: a form XObject
/// without one of its own draws in its page's.
pub(crate) struct DefaultColorSpaces<'r, 'a> {
resources: &'r Resources<'a>,
cache: Cache,
gray: OnceCell<Option<(ColorSpace, u128)>>,
rgb: OnceCell<Option<(ColorSpace, u128)>>,
cmyk: OnceCell<Option<(ColorSpace, u128)>>,
}
impl<'r, 'a> DefaultColorSpaces<'r, 'a> {
pub(crate) fn new(resources: &'r Resources<'a>, cache: &Cache) -> Self {
Self {
resources,
cache: cache.clone(),
gray: OnceCell::new(),
rgb: OnceCell::new(),
cmyk: OnceCell::new(),
}
}
/// The default for `family`, with the cache key of the object it was read from. `None`
/// when the resources have none, or theirs is not a colour space that can stand in for
/// the device space: device colour is then drawn as it is.
pub(crate) fn get(&self, family: DeviceFamily) -> Option<(ColorSpace, u128)> {
let found = match family {
DeviceFamily::Gray => &self.gray,
DeviceFamily::Rgb => &self.rgb,
DeviceFamily::Cmyk => &self.cmyk,
};
found
.get_or_init(|| {
let object = self
.resources
.get_color_space(&Name::new_unescaped(family.default_key()))?;
let key = object.cache_key();
let space = self
.cache
.get_or_insert_with(key, || ColorSpace::new(object.clone(), &self.cache))?;
space.can_stand_in_for(family).then_some((space, key))
})
.clone()
}
/// The colour space content that selects `family` draws in: its default, else the device
/// space itself.
pub(crate) fn device(&self, family: DeviceFamily) -> ColorSpace {
self.get(family)
.map_or_else(|| family.space(), |(space, _)| space)
}
}
impl ToRgb for ColorSpace {
+61 -25
View File
@@ -1,5 +1,5 @@
use crate::{BlendMode, FillRule};
use crate::color::ColorSpace;
use crate::color::{ColorSpace, DefaultColorSpaces, DeviceFamily};
use crate::context::Context;
use crate::convert::{convert_line_cap, convert_line_join};
use crate::device::Device;
@@ -336,6 +336,30 @@ fn draw_highlight_without_appearance<'a>(
context.restore_state(device);
}
/// PdfCraft patch (38): the colour space `cs` or `CS` selects by `name`: a device space, else
/// the `/ColorSpace` resource `name`. A device space, named directly or by a resource, is
/// replaced by its default from `defaults` (ISO 32000-2 §8.6.5.6).
fn named_color_space<'a>(
name: &hayro_syntax::object::Name<'_>,
resources: &Resources<'a>,
defaults: &DefaultColorSpaces<'_, '_>,
context: &mut Context<'a>,
) -> Option<ColorSpace> {
if let Some(named) = ColorSpace::new_from_name(name) {
let default = DeviceFamily::of_name(name).and_then(|family| defaults.get(family));
return Some(default.map_or(named, |(space, _)| space));
}
let resource = resources.get_color_space(name);
if let Some((space, _)) = resource
.as_ref()
.and_then(DeviceFamily::of_object)
.and_then(|family| defaults.get(family))
{
return Some(space);
}
context.get_color_space(resources, name)
}
/// Interprets the instructions that `ops` yields, until they run out or the caller cancels. It runs
/// once per window of a content stream, between one `save_state` and its restore (see
/// `interpret_content`).
@@ -345,6 +369,9 @@ fn interpret_window<'a>(
context: &mut Context<'a>,
device: &mut impl Device<'a>,
) {
// PdfCraft patch (38): the resources' DefaultGray, DefaultRGB and DefaultCMYK replace the
// device spaces that colour operators and images select (ISO 32000-2 §8.6.5.6).
let defaults = DefaultColorSpaces::new(resources, &context.interpreter_cache.object_cache);
while let Some(op) = ops.next() {
// PdfCraft patch: stop when the caller cancelled (see `InterpreterSettings::cancelled`).
if context.settings.cancelled.as_ref().is_some_and(|c| c.load(Ordering::Relaxed)) {
@@ -353,18 +380,21 @@ fn interpret_window<'a>(
match op {
TypedInstruction::SaveState(_) => context.save_state(),
TypedInstruction::StrokeColorDeviceRgb(s) => {
context.get_mut().graphics_state.stroke_cs = ColorSpace::device_rgb();
// PdfCraft patch (38): DeviceRGB, or the DefaultRGB that replaces it.
context.get_mut().graphics_state.stroke_cs = defaults.device(DeviceFamily::Rgb);
context.get_mut().graphics_state.stroke_color =
smallvec![s.0.as_f32(), s.1.as_f32(), s.2.as_f32()];
context.get_mut().graphics_state.stroke_pattern = None;
}
TypedInstruction::StrokeColorDeviceGray(s) => {
context.get_mut().graphics_state.stroke_cs = ColorSpace::device_gray();
// PdfCraft patch (38): DeviceGray, or the DefaultGray that replaces it.
context.get_mut().graphics_state.stroke_cs = defaults.device(DeviceFamily::Gray);
context.get_mut().graphics_state.stroke_color = smallvec![s.0.as_f32()];
context.get_mut().graphics_state.stroke_pattern = None;
}
TypedInstruction::StrokeColorCmyk(s) => {
context.get_mut().graphics_state.stroke_cs = ColorSpace::device_cmyk();
// PdfCraft patch (38): DeviceCMYK, or the DefaultCMYK that replaces it.
context.get_mut().graphics_state.stroke_cs = defaults.device(DeviceFamily::Cmyk);
context.get_mut().graphics_state.stroke_color =
smallvec![s.0.as_f32(), s.1.as_f32(), s.2.as_f32(), s.3.as_f32()];
context.get_mut().graphics_state.stroke_pattern = None;
@@ -428,18 +458,21 @@ fn interpret_window<'a>(
fill_stroke_path(context, device, FillRule::NonZero);
}
TypedInstruction::NonStrokeColorDeviceGray(s) => {
context.get_mut().graphics_state.none_stroke_cs = ColorSpace::device_gray();
// PdfCraft patch (38): DeviceGray, or the DefaultGray that replaces it.
context.get_mut().graphics_state.none_stroke_cs = defaults.device(DeviceFamily::Gray);
context.get_mut().graphics_state.non_stroke_color = smallvec![s.0.as_f32()];
context.get_mut().graphics_state.non_stroke_pattern = None;
}
TypedInstruction::NonStrokeColorDeviceRgb(s) => {
context.get_mut().graphics_state.none_stroke_cs = ColorSpace::device_rgb();
// PdfCraft patch (38): DeviceRGB, or the DefaultRGB that replaces it.
context.get_mut().graphics_state.none_stroke_cs = defaults.device(DeviceFamily::Rgb);
context.get_mut().graphics_state.non_stroke_color =
smallvec![s.0.as_f32(), s.1.as_f32(), s.2.as_f32()];
context.get_mut().graphics_state.non_stroke_pattern = None;
}
TypedInstruction::NonStrokeColorCmyk(s) => {
context.get_mut().graphics_state.none_stroke_cs = ColorSpace::device_cmyk();
// PdfCraft patch (38): DeviceCMYK, or the DefaultCMYK that replaces it.
context.get_mut().graphics_state.none_stroke_cs = defaults.device(DeviceFamily::Cmyk);
context.get_mut().graphics_state.non_stroke_color =
smallvec![s.0.as_f32(), s.1.as_f32(), s.2.as_f32(), s.3.as_f32()];
context.get_mut().graphics_state.non_stroke_pattern = None;
@@ -528,13 +561,9 @@ fn interpret_window<'a>(
// Ignore for now.
}
TypedInstruction::ColorSpaceStroke(c) => {
let cs = if let Some(named) = ColorSpace::new_from_name(c.0) {
named
} else {
context
.get_color_space(resources, c.0)
.unwrap_or(ColorSpace::device_gray())
};
// PdfCraft patch (38): see `named_color_space`.
let cs = named_color_space(c.0, resources, &defaults, context)
.unwrap_or(ColorSpace::device_gray());
if !cs.is_pattern() {
context.get_mut().graphics_state.stroke_pattern = None;
@@ -543,13 +572,9 @@ fn interpret_window<'a>(
context.get_mut().graphics_state.stroke_cs = cs;
}
TypedInstruction::ColorSpaceNonStroke(c) => {
let cs = if let Some(named) = ColorSpace::new_from_name(c.0) {
named
} else {
context
.get_color_space(resources, c.0)
.unwrap_or(ColorSpace::device_gray())
};
// PdfCraft patch (38): see `named_color_space`.
let cs = named_color_space(c.0, resources, &defaults, context)
.unwrap_or(ColorSpace::device_gray());
if !cs.is_pattern() {
context.get_mut().graphics_state.non_stroke_pattern = None;
@@ -574,7 +599,7 @@ fn interpret_window<'a>(
context.get_mut().graphics_state.non_stroke_pattern = n.1.and_then(|name| {
resources
.get_pattern(name)
.and_then(|d| Pattern::new(d, context, resources))
.and_then(|d| Pattern::new(d, context, resources, &defaults))
});
}
TypedInstruction::StrokeColorNamed(n) => {
@@ -583,7 +608,7 @@ fn interpret_window<'a>(
context.get_mut().graphics_state.stroke_pattern = n.1.and_then(|name| {
resources
.get_pattern(name)
.and_then(|d| Pattern::new(d, context, resources))
.and_then(|d| Pattern::new(d, context, resources, &defaults))
});
}
TypedInstruction::BeginMarkedContentWithProperties(bdc) => {
@@ -794,6 +819,8 @@ fn interpret_window<'a>(
&cache,
Some(&decoded_cache),
transfer_function.clone(),
// PdfCraft patch (38): an image in a device space draws in its default.
&defaults,
)
}) {
draw_xobject(&x_object, resources, context, device);
@@ -804,14 +831,17 @@ fn interpret_window<'a>(
let transfer_function = context.get().graphics_state.transfer_function.clone();
let cache = context.interpreter_cache.object_cache.clone();
// PdfCraft patch: inline images are not cached (see `DecodedImageKey`).
// PdfCraft patch (38): an inline image in a device space, named directly or by a
// resource, draws in its default.
if let Some(x_object) = ImageXObject::new(
i.0,
|name| context.get_color_space(resources, name),
|name| named_color_space(name, resources, &defaults, context),
&warning_sink,
&cache,
None,
false,
transfer_function,
Some(&defaults),
) {
draw_image_xobject(&x_object, context, device);
}
@@ -830,7 +860,13 @@ fn interpret_window<'a>(
.get_shading(s.0)
.and_then(|o| {
let (dict, stream) = dict_or_stream(&o)?;
Shading::new(dict, stream, &context.interpreter_cache.object_cache)
// PdfCraft patch (38): a shading in a device space draws in its default.
Shading::new(
dict,
stream,
&context.interpreter_cache.object_cache,
&defaults,
)
})
.map(|s| {
Pattern::Shading(ShadingPattern {
+12 -3
View File
@@ -1,7 +1,7 @@
//! PDF patterns.
use crate::cache::Cache;
use crate::color::{Color, ColorSpace};
use crate::color::{Color, ColorSpace, DefaultColorSpaces};
use crate::context::{Context, InterpreterCache};
use crate::device::Device;
use crate::font::Glyph;
@@ -34,16 +34,20 @@ pub enum Pattern<'a> {
}
impl<'a> Pattern<'a> {
/// PdfCraft patch (38): `defaults` are those of the resources the pattern is selected with,
/// for a shading pattern's shading in a device space.
pub(crate) fn new(
object: Object<'a>,
ctx: &Context<'a>,
resources: &Resources<'a>,
defaults: &DefaultColorSpaces<'_, '_>,
) -> Option<Self> {
match object {
Object::Dict(dict) => Some(Self::Shading(ShadingPattern::new(
&dict,
&ctx.interpreter_cache.object_cache,
ctx.get().graphics_state.non_stroke_alpha,
defaults,
)?)),
Object::Stream(stream) => Some(Self::Tiling(Box::new(TilingPattern::new(
stream, ctx, resources,
@@ -93,11 +97,16 @@ pub struct ShadingPattern {
}
impl ShadingPattern {
pub(crate) fn new(dict: &Dict<'_>, cache: &Cache, opacity: f32) -> Option<Self> {
pub(crate) fn new(
dict: &Dict<'_>,
cache: &Cache,
opacity: f32,
defaults: &DefaultColorSpaces<'_, '_>,
) -> Option<Self> {
let shading = dict.get::<Object<'_>>(SHADING).and_then(|o| {
let (dict, stream) = dict_or_stream(&o)?;
Shading::new(dict, stream, cache)
Shading::new(dict, stream, cache, defaults)
})?;
let matrix = dict
.get::<[f64; 6]>(MATRIX)
+18 -6
View File
@@ -4,9 +4,9 @@
use crate::CacheKey;
use crate::cache::Cache;
use crate::color::{ColorComponents, ColorSpace};
use crate::color::{ColorComponents, ColorSpace, DefaultColorSpaces, DeviceFamily};
use crate::function::{Function, StitchingBounds, Values, interpolate};
use crate::util::{Float32Ext, PointExt, RectExt};
use crate::util::{Float32Ext, PointExt, RectExt, hash128};
use hayro_syntax::bit_reader::BitReader;
use hayro_syntax::object::Array;
use hayro_syntax::object::Dict;
@@ -139,12 +139,24 @@ pub struct Shading {
}
impl Shading {
pub(crate) fn new(dict: &Dict<'_>, stream: Option<&Stream<'_>>, cache: &Cache) -> Option<Self> {
let cache_key = dict.cache_key();
/// PdfCraft patch (38): a shading in a device space is drawn in its default colour space
/// from `defaults`, the resources it is painted with (ISO 32000-2 §8.6.5.6), and keyed by
/// it as an image is.
pub(crate) fn new(
dict: &Dict<'_>,
stream: Option<&Stream<'_>>,
cache: &Cache,
defaults: &DefaultColorSpaces<'_, '_>,
) -> Option<Self> {
let shading_num = dict.get::<u8>(SHADING_TYPE)?;
let color_space = ColorSpace::new(dict.get(COLORSPACE)?, cache)?;
let color_space_obj = dict.get::<Object<'_>>(COLORSPACE)?;
let default =
DeviceFamily::of_object(&color_space_obj).and_then(|family| defaults.get(family));
let (color_space, cache_key) = match default {
Some((space, key)) => (space, hash128(&(dict.cache_key(), key))),
None => (ColorSpace::new(color_space_obj, cache)?, dict.cache_key()),
};
let shading_type = match shading_num {
1 => {
+35 -12
View File
@@ -1,5 +1,5 @@
use crate::cache::{Cache, DecodedImageCache, DecodedImageKey};
use crate::color::{ColorComponents, ColorSpace, ToRgb};
use crate::color::{ColorComponents, ColorSpace, DefaultColorSpaces, DeviceFamily, ToRgb};
use crate::context::Context;
use crate::device::Device;
use crate::function::{Function, interpolate};
@@ -36,6 +36,7 @@ impl<'a> XObject<'a> {
cache: &Cache,
decoded_cache: Option<&DecodedImageCache>,
transfer_function: Option<ActiveTransferFunction>,
defaults: &DefaultColorSpaces<'_, '_>,
) -> Option<Self> {
let dict = stream.dict();
match dict.get::<Name<'_>>(SUBTYPE)?.deref() {
@@ -47,6 +48,7 @@ impl<'a> XObject<'a> {
decoded_cache,
false,
transfer_function,
Some(defaults),
)?)),
FORM => Some(Self::FormXObject(FormXObject::new(stream)?)),
_ => None,
@@ -308,9 +310,15 @@ pub(crate) struct ImageXObject<'a> {
stream: Stream<'a>,
transfer_function: Option<ActiveTransferFunction>,
warning_sink: WarningSinkFn,
/// PdfCraft patch (38): the cache key of the default colour space drawn in place of the
/// image's device space, if one is.
default_space: Option<u128>,
}
impl<'a> ImageXObject<'a> {
/// PdfCraft patch (38): an image in a device space is drawn in its default colour space
/// from `defaults`. Masks have none to replace, and `resolve_cs` (an inline image's named
/// colour space) substitutes its own.
pub(crate) fn new(
stream: &Stream<'a>,
resolve_cs: impl FnOnce(&Name<'_>) -> Option<ColorSpace>,
@@ -319,6 +327,7 @@ impl<'a> ImageXObject<'a> {
decoded_cache: Option<&DecodedImageCache>,
mut is_mask: bool,
transfer_function: Option<ActiveTransferFunction>,
defaults: Option<&DefaultColorSpaces<'_, '_>>,
) -> Option<Self> {
let dict = stream.dict();
@@ -328,6 +337,7 @@ impl<'a> ImageXObject<'a> {
.unwrap_or(false);
is_mask |= is_stencil_mask;
let mut default_space = None;
let image_cs = if is_mask {
// Masks are always single-channel.
Some(ColorSpace::device_gray())
@@ -336,15 +346,25 @@ impl<'a> ImageXObject<'a> {
.get::<Object<'_>>(CS)
.or_else(|| dict.get::<Object<'_>>(COLORSPACE));
cs_obj
.clone()
.and_then(|c| ColorSpace::new(c, cache))
// Inline images can also refer to color spaces by name.
.or_else(|| {
cs_obj
.and_then(|c| c.into_name())
.and_then(|n| resolve_cs(&n))
})
let default = cs_obj
.as_ref()
.and_then(DeviceFamily::of_object)
.zip(defaults)
.and_then(|(family, defaults)| defaults.get(family));
if let Some((space, key)) = default {
default_space = Some(key);
Some(space)
} else {
cs_obj
.clone()
.and_then(|c| ColorSpace::new(c, cache))
// Inline images can also refer to color spaces by name.
.or_else(|| {
cs_obj
.and_then(|c| c.into_name())
.and_then(|n| resolve_cs(&n))
})
}
};
let interpolate = dict
@@ -371,6 +391,7 @@ impl<'a> ImageXObject<'a> {
stream: stream.clone(),
is_mask,
is_stencil_mask,
default_space,
})
}
@@ -434,6 +455,8 @@ impl<'a> ImageXObject<'a> {
is_mask: self.is_mask,
is_stencil_mask: self.is_stencil_mask,
interpolate: self.interpolate,
// PdfCraft patch (38): the same image drawn in a default colour space or without one.
default_space: self.default_space,
// PdfCraft patch (32): see `decode_reads_target`.
target: if self.decode_reads_target() {
target_dimension
@@ -905,7 +928,7 @@ fn resolve_alpha(
.get::<Stream<'_>>(SMASK)
.or_else(|| dict.get::<Stream<'_>>(MASK))
{
let obj = ImageXObject::new(&s_mask, |_| None, &obj.warning_sink, &obj.cache, obj.decoded_cache.as_ref(), true, None)?;
let obj = ImageXObject::new(&s_mask, |_| None, &obj.warning_sink, &obj.cache, obj.decoded_cache.as_ref(), true, None, None)?;
decode_mask(&obj, target_dimension).map(|decoded| decoded.luma)
} else if let Some(color_key_mask) = dict.get::<SmallVec<[u16; 4]>>(MASK) {
@@ -965,7 +988,7 @@ fn resolve_matte(
let mut matte_rgb = [0_u8; 3];
color_space.convert_f32(&matte, &mut matte_rgb, false);
let mask_obj = ImageXObject::new(&s_mask, |_| None, &obj.warning_sink, &obj.cache, obj.decoded_cache.as_ref(), true, None)?;
let mask_obj = ImageXObject::new(&s_mask, |_| None, &obj.warning_sink, &obj.cache, obj.decoded_cache.as_ref(), true, None, None)?;
let alpha = decode_mask(&mask_obj, target_dimension)?.luma;
Some((alpha, matte_rgb))