mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-10-28 20:18:06 -06:00
M2: a Separation /All colour paints every colourant, gray 1 − tint on screen (ISO 32000-2 8.6.6.4) (#4439)
The special colourant name All refers to every colourant of the output device at once, and on an additive device its subtractive tint is complemented before it is applied (ISO 32000-2 §8.6.6.4). The vendored colour code treated All as an ordinary spot colourant and drew it through its tint transform, so a /DeviceCMYK transform giving cyan drew 0.5 scn as light cyan instead of a 50% gray. Vendored hayro-interpret patch 66: Separation::convert_f32 writes the gray 1 − t for each tint of an All space without evaluating the tint transform. Fills, strokes, images and shadings in that space all convert through it, and so does the RGB print raster. /None still paints nothing, other names still use their alternate, and a DeviceN space naming All keeps its alternate (the clause defines the name for Separation spaces). Tests: crates/render/tests/it/separation_all.rs (synthetic). Claude-Session: https://claude.ai/code/session_0175FWj4xFhMhSH3UpMU8DSC Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
Brandon Thomas
parent
6718f1f34e
commit
0dd871f772
@@ -5,6 +5,7 @@ mod inline_image_ei;
|
||||
mod jbig2_invariants;
|
||||
mod named_form_properties;
|
||||
mod search;
|
||||
mod separation_all;
|
||||
mod substitute_widths;
|
||||
mod type3_text_clip;
|
||||
mod tiling_cell_placement;
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
//! Contributor-original synthetic pages for the special colourant names of a Separation colour
|
||||
//! space (ISO 32000-2 §8.6.6.4): `/All` paints every colourant of the output device at once, so
|
||||
//! on screen a tint t is the gray 1 − t whatever the alternate space and tint transform say;
|
||||
//! `/None` paints nothing; any other name goes through its alternate space.
|
||||
use pdfcraft_render::{PageRenderer, RenderConfig, RenderRequest, RenderedPage};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// A tint transform into DeviceCMYK that gives cyan: tint t → (t, 0, 0, 0). Through it a tint
|
||||
/// draws light or full cyan, never a neutral gray, so a gray result proves it was not used.
|
||||
const CYAN: &str = "<< /FunctionType 2 /Domain [0 1] /C0 [0 0 0 0] /C1 [1 0 0 0] /N 1 >>";
|
||||
|
||||
/// A PDF of the given objects (object 1 the catalog), with a cross-reference table. Each object
|
||||
/// is its bytes: a stream's data need not be text.
|
||||
fn pdf(objects: &[Vec<u8>]) -> Vec<u8> {
|
||||
let mut bytes = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, object) in objects.iter().enumerate() {
|
||||
offsets.push(bytes.len());
|
||||
bytes.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
|
||||
bytes.extend_from_slice(object);
|
||||
bytes.extend_from_slice(b"\nendobj\n");
|
||||
}
|
||||
let xref = bytes.len();
|
||||
bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes());
|
||||
for offset in offsets {
|
||||
bytes.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
bytes.extend_from_slice(format!("trailer << /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objects.len() + 1).as_bytes());
|
||||
bytes
|
||||
}
|
||||
|
||||
fn stream(dict: &str, data: &[u8]) -> Vec<u8> {
|
||||
let mut bytes = format!("<< {dict} /Length {} >>\nstream\n", data.len()).into_bytes();
|
||||
bytes.extend_from_slice(data);
|
||||
bytes.extend_from_slice(b"\nendstream");
|
||||
bytes
|
||||
}
|
||||
|
||||
/// A 300 × 100 pt page drawing `content` with `resources`, then `extra` as objects 5 onwards.
|
||||
fn page(content: &str, resources: &str, extra: &[Vec<u8>]) -> Vec<u8> {
|
||||
let mut objects = vec![
|
||||
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
|
||||
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
|
||||
format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 100] /Contents 4 0 R /Resources {resources} >>").into_bytes(),
|
||||
stream("", content.as_bytes()),
|
||||
];
|
||||
objects.extend_from_slice(extra);
|
||||
pdf(&objects)
|
||||
}
|
||||
|
||||
fn render(pdf: Vec<u8>) -> RenderedPage {
|
||||
let mut renderer = PageRenderer::new(Arc::new(pdf), RenderConfig::default());
|
||||
let out = renderer.render(RenderRequest { page: 0, scale: 1.0, ..Default::default() });
|
||||
assert!(out.error.is_none(), "{:?}", out.error);
|
||||
assert_eq!((out.width, out.height), (300, 100));
|
||||
out
|
||||
}
|
||||
|
||||
/// The RGB of the pixel at (`x`, `y`) pt from the page's top left.
|
||||
fn rgb(out: &RenderedPage, x: usize, y: usize) -> [u8; 3] {
|
||||
let at = (y * out.width as usize + x) * 4;
|
||||
let pixel = out.rgba.get(at..at + 3).expect("the pixel lies on the page");
|
||||
[pixel[0], pixel[1], pixel[2]]
|
||||
}
|
||||
|
||||
/// `got` is the neutral gray `want`, give or take one level of rounding.
|
||||
fn assert_gray(got: [u8; 3], want: u8, what: &str) {
|
||||
assert!(got.iter().all(|c| c.abs_diff(want) <= 1), "{what}: got {got:?}, want gray {want}");
|
||||
}
|
||||
|
||||
/// Three boxes filled with `cs` at tints 0, 0.5 and 1, left to right.
|
||||
fn three_tints(space: &str) -> RenderedPage {
|
||||
let content = "/CS0 cs 0 scn 0 0 100 100 re f 0.5 scn 100 0 100 100 re f 1 scn 200 0 100 100 re f";
|
||||
render(page(content, "<< /ColorSpace << /CS0 5 0 R >> >>", &[space.as_bytes().to_vec(), CYAN.as_bytes().to_vec()]))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn separation_all_fills_paint_the_complemented_tint_as_gray() {
|
||||
let out = three_tints("[/Separation /All /DeviceCMYK 6 0 R]");
|
||||
assert_gray(rgb(&out, 50, 50), 255, "tint 0 paints no colourant: white");
|
||||
assert_gray(rgb(&out, 150, 50), 128, "tint 0.5 paints half of every colourant: 50% gray");
|
||||
assert_gray(rgb(&out, 250, 50), 0, "tint 1 paints every colourant: black");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn separation_all_strokes_paint_the_complemented_tint_as_gray() {
|
||||
let content = "/CS0 CS 0.25 SCN 40 w 0 50 m 300 50 l S";
|
||||
let out =
|
||||
render(page(content, "<< /ColorSpace << /CS0 5 0 R >> >>", &[b"[/Separation /All /DeviceCMYK 6 0 R]".to_vec(), CYAN.as_bytes().to_vec()]));
|
||||
assert_gray(rgb(&out, 150, 50), 191, "a stroke at tint 0.25: 75% gray");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn separation_none_paints_nothing() {
|
||||
// A red background, so "nothing" is told apart from white.
|
||||
let content = "1 0 0 rg 0 0 300 100 re f /CS0 cs 0 scn 0 0 100 100 re f 0.5 scn 100 0 100 100 re f 1 scn 200 0 100 100 re f";
|
||||
let out =
|
||||
render(page(content, "<< /ColorSpace << /CS0 5 0 R >> >>", &[b"[/Separation /None /DeviceCMYK 6 0 R]".to_vec(), CYAN.as_bytes().to_vec()]));
|
||||
for x in [50, 150, 250] {
|
||||
assert_eq!(rgb(&out, x, 50), [255, 0, 0], "a /None fill at x = {x} leaves the background");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_named_separation_still_paints_through_its_alternate() {
|
||||
let out = three_tints("[/Separation /Cyan /DeviceCMYK 6 0 R]");
|
||||
let [white, half, full] = [rgb(&out, 50, 50), rgb(&out, 150, 50), rgb(&out, 250, 50)];
|
||||
assert_gray(white, 255, "tint 0 of a named colourant");
|
||||
for (what, colour) in [("tint 0.5", half), ("tint 1", full)] {
|
||||
// Cyan: red well below green and blue.
|
||||
assert!(colour[0] < colour[1] && colour[0] < colour[2] && colour[2] > 200, "{what} of /Cyan is cyan: {colour:?}");
|
||||
}
|
||||
assert!(full[0] < half[0], "full cyan is deeper than half: {full:?} against {half:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_device_n_space_naming_all_still_paints_through_its_alternate() {
|
||||
// §8.6.6.4 gives All its meaning in a Separation space; a DeviceN space keeps its alternate.
|
||||
let out = three_tints("[/DeviceN [/All] /DeviceCMYK 6 0 R]");
|
||||
let half = rgb(&out, 150, 50);
|
||||
assert!(half[0] < half[1] && half[0] < half[2], "DeviceN [/All] at 0.5 draws its tint transform's cyan: {half:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn separation_all_images_paint_each_sample_as_the_complemented_gray() {
|
||||
// Three 8-bit samples: tints 0, 128/255 and 1.
|
||||
let data = [0u8, 128, 255];
|
||||
let image =
|
||||
stream("/Type /XObject /Subtype /Image /Width 3 /Height 1 /ColorSpace [/Separation /All /DeviceCMYK 6 0 R] /BitsPerComponent 8", &data);
|
||||
let out = render(page("q 300 0 0 100 0 0 cm /Im Do Q", "<< /XObject << /Im 5 0 R >> >>", &[image, CYAN.as_bytes().to_vec()]));
|
||||
assert_gray(rgb(&out, 50, 50), 255, "sample 0");
|
||||
assert_gray(rgb(&out, 150, 50), 127, "sample 128");
|
||||
assert_gray(rgb(&out, 250, 50), 0, "sample 255");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn separation_all_shadings_paint_the_complemented_gray() {
|
||||
// An axial shading from tint 0 at x = 0 to tint 1 at x = 300, painted with `sh`.
|
||||
let shading = "<< /ShadingType 2 /ColorSpace [/Separation /All /DeviceCMYK 6 0 R] /Coords [0 0 300 0] /Function << /FunctionType 2 /Domain [0 1] /C0 [0] /C1 [1] /N 1 >> /Extend [true true] >>";
|
||||
let out = render(page("/Sh sh", "<< /Shading << /Sh 5 0 R >> >>", &[shading.as_bytes().to_vec(), CYAN.as_bytes().to_vec()]));
|
||||
for (x, want) in [(1usize, 254u8), (150, 128), (298, 1)] {
|
||||
let got = rgb(&out, x, 50);
|
||||
assert!(got.iter().all(|c| c.abs_diff(want) <= 3), "shading at x = {x}: got {got:?}, want gray near {want}");
|
||||
}
|
||||
}
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+17
-2
@@ -1050,6 +1050,8 @@ pub(crate) struct Separation {
|
||||
/// `eval_tint_transform`).
|
||||
tint_transform: Option<Function>,
|
||||
is_none_separation: bool,
|
||||
/// PdfCraft patch (66): the colourant is the special name `All`.
|
||||
is_all: bool,
|
||||
}
|
||||
|
||||
/// PdfCraft patch (55): `tint_transform` run on the tints of one colour, or, when it cannot be
|
||||
@@ -1080,20 +1082,33 @@ impl Separation {
|
||||
// PdfCraft patch (55): an unreadable tint transform keeps the space (see
|
||||
// `eval_tint_transform`).
|
||||
let tint_transform = iter.next::<Object<'_>>().and_then(|f| Function::new(&f));
|
||||
// Either I did something wrong, or no other viewers properly handles
|
||||
// `All`, so let's just ignore it as well.
|
||||
let is_none_separation = name.as_str() == "None";
|
||||
// PdfCraft patch (66): `All` names every colourant of the output device at once
|
||||
// (ISO 32000-2 §8.6.6.4), so its tints never go through the alternate space.
|
||||
let is_all = name.as_str() == "All";
|
||||
|
||||
Some(Self {
|
||||
alternate_space,
|
||||
tint_transform,
|
||||
is_none_separation,
|
||||
is_all,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl ToRgb for Separation {
|
||||
fn convert_f32(&self, input: &[f32], output: &mut [u8], _: bool) -> Option<()> {
|
||||
if self.is_all {
|
||||
// PdfCraft patch (66): §8.6.6.4: the tint applies to every colourant, and on an
|
||||
// additive device it is complemented first, so tint t is the gray 1 − t on each of
|
||||
// R, G and B whatever the alternate space and tint transform say.
|
||||
for (tint, rgb) in input.iter().zip(output.chunks_exact_mut(3)) {
|
||||
// `clamp` keeps NaN, and `f32_to_u8`'s cast saturates it to 0.
|
||||
let gray = f32_to_u8(1.0 - tint.clamp(0.0, 1.0));
|
||||
rgb.copy_from_slice(&[gray, gray, gray]);
|
||||
}
|
||||
return Some(());
|
||||
}
|
||||
count_tint_transform_runs(input.len());
|
||||
// PdfCraft patch (55): see `eval_tint_transform`.
|
||||
let evaluated = input
|
||||
|
||||
Reference in New Issue
Block a user