Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions crates/pdf/src/import/builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ impl<'p> Builder<'p> {
images: HashMap::new(),
image_keys: HashMap::new(),
cmyk_keys: HashSet::new(),
separation_paints: HashMap::new(),
opaque: HashSet::new(),
warnings: vec![],
fonts: HashMap::new(),
Expand Down Expand Up @@ -582,6 +583,17 @@ impl<'p> Builder<'p> {
Some(Paint::Pattern { pattern: name, xf })
}

/// Separation image `st` (`w` × `h` pixels) as the paint of its ink at 100 % (a spot swatch, a
/// process plate or Registration, found by its colour space) and its tints as gray pixels
/// ([`crate::import_image::tint_gray`]). `None` for another image, an ink whose colour can't
/// be told, or tints that can't be read (with a warning): the image opens in RGB. The ink is
/// only marked used once the tints are read.
pub(super) fn separation_image(&mut self, st: &craft_pdf::syntax::object::Stream<'_>, w: u32, h: u32) -> Option<(Paint, Vec<u8>)> {
let space = self.colors.array_space(&crate::import_image::separation_space(st)?)?;
let gray = crate::import_image::tint_gray(st, w, h).map_err(|why| self.warn(why)).ok()?;
Some((self.colors.native(space, &[1.0])?.paint(), gray))
}

pub(super) fn add_image(&mut self, key: u128, make: impl FnOnce() -> Option<(ImageBlob, u32, u32)>, xf: Affine) {
self.add_image_masked(key, make, xf, None, None);
}
Expand Down
19 changes: 19 additions & 0 deletions crates/pdf/src/import/device.rs
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,25 @@ impl<'a> Device<'a> for Builder<'_> {
let mask = if crate::import_image::has_mask(st) { self.alpha_mask(key, &r, w, h, transform) } else { None };
return self.add_image_masked(key, || cmyk.map(|blob| (blob, w, h)), transform, mask, None);
}
// A Separation image keeps its ink: its tints as a grayscale image that the ink at
// 100 % colorizes, as a mask painted in the ink comes in. Its mask becomes an
// opacity mask, as a CMYK image's does. Read once per image: the decision is kept,
// so one that opens in RGB stays RGB, and the gray pixels have their own key.
if !white {
let found = match self.separation_paints.get(&key) {
Some(paint) => paint.clone().map(|p| (p, None)),
None => {
let read = self.separation_image(st, w, h);
self.separation_paints.insert(key, read.as_ref().map(|(p, _)| p.clone()));
read.map(|(p, gray)| (p, Some(gray)))
}
};
if let Some((paint, gray)) = found {
let mask = if crate::import_image::has_mask(st) { self.alpha_mask(key, &r, w, h, transform) } else { None };
let make = || gray.and_then(|g| gray_png(g, w, h)).map(|png| (ImageBlob::new("image/png", png), w, h));
return self.add_image_masked(key ^ 0x5e9_a7e5, make, transform, mask, Some(paint));
}
}
// JPEG passthrough for plain DeviceRGB/DeviceGray DCT images.
let dict = st.dict();
let filters = st.filters();
Expand Down
3 changes: 3 additions & 0 deletions crates/pdf/src/import/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,9 @@ pub(super) struct Builder<'p> {
pub(super) image_keys: HashMap<u128, (String, u32, u32)>,
/// The images kept with their CMYK samples ([`crate::import_image`]).
pub(super) cmyk_keys: HashSet<u128>,
/// Images in a Separation space, by key: the paint of their ink, or `None` when they open in
/// RGB (an ink that can't be told, tints that can't be read).
pub(super) separation_paints: HashMap<u128, Option<Paint>>,
/// Mask keys of CMYK images whose mask turned out to hide nothing.
pub(super) opaque: HashSet<u128>,
pub(super) warnings: Vec<String>,
Expand Down
12 changes: 12 additions & 0 deletions crates/pdf/src/import_color.rs
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,18 @@ impl<'p> Colors<'p> {
}
}

/// The space of colour space array `a` (`[/Separation name alt tint]` or `[/DeviceN …]`, such
/// as an image's) among the file's: matched by its kind, its colorant names and its tint
/// transform, not by the page's walk. `None` for another array.
pub fn array_space(&mut self, a: &Array<'_>) -> Option<Space> {
self.scan();
let mut it = a.flex_iter();
let (device_n, names) = ink_names(&mut it)?;
it.next::<Object<'_>>()?;
let key = debug_text(&Function::new(&it.next::<Object<'_>>()?)?)?;
self.spaces.iter().position(|s| s.device_n == device_n && s.names == names && s.key == key).map(Space::Inks)
}

/// Whether a space of inks `names` has the kind, colorant count and tint transform of a space
/// of other inks. [`Colors::space_of`] tells such spaces apart only by the names from the
/// page's walk.
Expand Down
76 changes: 73 additions & 3 deletions crates/pdf/src/import_image.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,12 @@
//! convention JPEG readers and the PDF writer take a CMYK JPEG to follow) is kept as it is; any
//! other is decoded, its Decode array applied, and stored as a CMYK TIFF
//! ([`ImageBlob::cmyk_tiff`]). A mask (`/SMask`, `/Mask`) doesn't change that: the importer
//! carries it as the image's opacity mask ([`has_mask`]). Every other image is read as RGB by the
//! interpreter.
//! carries it as the image's opacity mask ([`has_mask`]). A Separation image keeps its ink: its
//! tints are read as a grayscale image ([`tint_gray`]) that the ink colorizes. Every other image is
//! read as RGB by the interpreter.

use craft_pdf::syntax::Filter;
use craft_pdf::syntax::object::stream::ImageDecodeParams;
use craft_pdf::syntax::object::stream::{ImageColorSpace, ImageDecodeParams};
use craft_pdf::syntax::object::{Array, Dict, Name, Stream};
use vectorcraft_doc::ImageBlob;
use vectorcraft_doc::cmyk::{Inks, MAX_CMYK_PIXELS, jpeg_components};
Expand Down Expand Up @@ -90,6 +91,75 @@ pub(crate) fn cmyk(st: &Stream<'_>, width: u32, height: u32) -> Result<Option<Im
Inks::new(width, height, inks).and_then(|i| ImageBlob::cmyk_tiff(&i)).map(Some).ok_or(UNREAD)
}

/// An image in a spot ink read as RGB: why.
pub(crate) const UNREAD_TINTS: &str = "images in a spot ink whose samples couldn't be read were imported in RGB";

/// The image's colour space when it is a Separation (`[/Separation name alt tint]`): one ink.
/// `None` for the ink `None`, which paints nothing (ISO 32000-1 §8.6.6.4): the interpreter draws it.
pub(crate) fn separation_space<'a>(st: &Stream<'a>) -> Option<Array<'a>> {
let d = st.dict();
let a = d.get::<Array<'a>>(key(d, b"ColorSpace", b"CS"))?;
let one_ink = {
let mut it = a.flex_iter();
it.next::<Name<'_>>()?.as_ref() == b"Separation" && it.next::<Name<'_>>()?.as_ref() != b"None"
};
one_ink.then_some(a)
}

/// The tints of the one-component image `st` (`width` × `height` pixels, a Separation image's)
/// as a grayscale image: white where it lays no ink, black at 100 %, its Decode array applied.
/// `Err` (a warning) when its samples can't be read.
pub(crate) fn tint_gray(st: &Stream<'_>, width: u32, height: u32) -> Result<Vec<u8>, &'static str> {
let d = st.dict();
let bpc = d.get::<u8>(key(d, b"BitsPerComponent", b"BPC")).unwrap_or(8);
if !matches!(bpc, 1 | 2 | 4 | 8 | 16) {
return Err(UNREAD_TINTS);
}
// The same cap as CMYK images'.
let n = u64::from(width) * u64::from(height);
if n == 0 || n > MAX_CMYK_PIXELS {
return Err(UNREAD_TINTS);
}
let (lo, hi) = match d.get::<Array<'_>>(key(d, b"Decode", b"D")).map(|a| a.iter::<f32>().collect::<Vec<_>>()).as_deref() {
Some(&[lo, hi]) if lo.is_finite() && hi.is_finite() => (lo, hi),
_ => (0.0, 1.0),
};
let params = ImageDecodeParams { is_indexed: false, bpc: Some(bpc), num_components: Some(1), target_dimension: None, width, height };
let decoded = st.decoded_image(&params).map_err(|_| UNREAD_TINTS)?;
// An image codec (JPEG, CCITT, JBIG2, JPEG 2000) says what it decoded to: one gray channel of
// the image's size, at its own bit depth.
let bpc = match &decoded.image_data {
None => bpc,
Some(i)
if i.alpha.is_none()
&& matches!(i.color_space, Some(ImageColorSpace::Gray))
&& (i.width, i.height) == (width, height)
&& matches!(i.bits_per_component, 1 | 2 | 4 | 8 | 16) =>
{
i.bits_per_component
}
Some(_) => return Err(UNREAD_TINTS),
};
let bits = usize::from(bpc);
let row = (width as usize * bits).div_ceil(8);
let data = decoded.data.get(..row * height as usize).ok_or(UNREAD_TINTS)?;
let max = ((1u32 << bits) - 1) as f32;
let mut out = Vec::with_capacity(n as usize);
for line in data.chunks_exact(row) {
for x in 0..width as usize {
let v = match bits {
16 => line.get(x * 2..x * 2 + 2).map_or(0, |s| s.iter().fold(0u32, |v, b| (v << 8) | u32::from(*b))),
8 => line.get(x).map_or(0, |b| u32::from(*b)),
// 1, 2 or 4 bits a sample, packed from the high bits of each byte.
_ => line.get(x * bits / 8).map_or(0, |b| (u32::from(*b) >> (8 - bits - (x * bits) % 8)) & ((1 << bits) - 1)),
};
let tint = (lo + v as f32 / max * (hi - lo)).clamp(0.0, 1.0);
out.push(((1.0 - tint) * 255.0).round() as u8);
}
}
Ok(out)
}

/// Does the image carry its own transparency (a soft mask, a stencil mask or a colour key)?
pub(crate) fn has_mask(st: &Stream<'_>) -> bool {
let d = st.dict();
Expand Down
135 changes: 135 additions & 0 deletions crates/pdf/src/tests/colorize.rs
Original file line number Diff line number Diff line change
Expand Up @@ -297,3 +297,138 @@ fn an_uncolored_pattern_drawing_an_image_mask_keeps_each_color() {
}
assert_eq!(colors, [["#ff0000"], ["#0000ff"]]);
}

const SPOT_RED: &str = "[/Separation /SpotRed /DeviceCMYK << /FunctionType 2 /Domain [0 1] /C0 [0 0 0 0] /C1 [0 1 1 0] /N 1 >>]";

/// A page drawing a `w` × 1 image in color space `space` (`bpc` bits a sample, more dictionary
/// entries `more`, samples `hex`), then the `extra` objects after it.
fn separation_page(space: &str, w: u32, bpc: u8, more: &str, hex: &str, extra: &[&str]) -> Document {
separation_report(space, w, bpc, more, hex, extra, "q 80 0 0 10 10 50 cm /Im1 Do Q").document
}

/// [`separation_page`] drawn with `content`, with the import's report. `more` may name filters
/// after ASCIIHex (`/Filter [/ASCIIHexDecode …]`).
fn separation_report(space: &str, w: u32, bpc: u8, more: &str, hex: &str, extra: &[&str], content: &str) -> ImportReport {
let filter = if more.contains("/Filter") { "" } else { "/Filter /ASCIIHexDecode" };
let image = format!(
"<< /Type /XObject /Subtype /Image /Width {w} /Height 1 /ColorSpace {space} /BitsPerComponent {bpc} {more} {filter} /Length {} >>\nstream\n{hex}>\nendstream",
hex.len() + 1
);
let resources = format!("/XObject << /Im1 {} 0 R >>", first_extra(1));
let mut objects = vec![image.as_str()];
objects.extend_from_slice(extra);
let pdf = pdf_with(&[PdfPage { resources, ..PdfPage::new(100.0, 100.0, content) }], &objects, None);
import_with_report(&pdf, &ImportOptions::default()).unwrap()
}

/// An image in a spot ink's Separation space opens as a grayscale image of its tints, colorized by
/// the ink's spot swatch at 100 %.
#[test]
fn a_separation_image_opens_colorized_by_its_ink() {
let d = separation_page(SPOT_RED, 4, 8, "", "0055AAFF", &[]);
let images = imported_images(&d);
assert_eq!(images.len(), 1);
let (paint, gray) = &images[0];
assert_eq!(gray, &[255, 170, 85, 0], "the tints 0, 1/3, 2/3 and 1");
let Some(Paint::Solid { swatch, tint, .. }) = paint else { panic!("colorized: {paint:?}") };
assert_eq!((swatch.as_deref(), *tint), (Some("SpotRed"), 1.0));
assert!(d.swatch("SpotRed").is_some_and(|s| s.spot), "a spot swatch");
}

/// Samples of 1, 4 and 16 bits are unpacked, and the Decode array applied.
#[test]
fn a_separation_image_reads_its_samples_and_decode_array() {
for (bpc, w, more, hex, want) in [
(1, 8, "/Decode [1 0]", "C3", vec![255, 255, 0, 0, 0, 0, 255, 255]),
(4, 3, "", "0F80", vec![255, 0, 119]),
(16, 2, "", "0000FFFF", vec![255, 0]),
] {
let d = separation_page(SPOT_RED, w, bpc, more, hex, &[]);
let images = imported_images(&d);
assert_eq!(images.len(), 1, "{bpc} bits");
assert_eq!(images[0].1, want, "{bpc} bits");
assert!(images[0].0.is_some(), "{bpc} bits: colorized");
}
}

/// A Separation of a process plate colorizes in that plate's CMYK color; the ink `None`, which
/// paints nothing, is left to the interpreter.
#[test]
fn a_separation_image_of_a_process_plate_or_of_no_ink() {
let black = "[/Separation /Black /DeviceCMYK << /FunctionType 2 /Domain [0 1] /C0 [0 0 0 0] /C1 [0 0 0 1] /N 1 >>]";
let d = separation_page(black, 2, 8, "", "00FF", &[]);
let images = imported_images(&d);
assert_eq!(images.len(), 1);
assert_eq!(images[0].0, Some(Paint::solid(Color::cmyk(0.0, 0.0, 0.0, 1.0))));
assert!(d.swatches.iter().all(|s| !s.spot), "no spot swatch for a process plate");
let none = "[/Separation /None /DeviceCMYK << /FunctionType 2 /Domain [0 1] /C0 [0 0 0 0] /C1 [0 1 1 0] /N 1 >>]";
let d = separation_page(none, 2, 8, "", "00FF", &[]);
let mut colorized = vec![];
for l in &d.layers {
l.walk(&mut |n| {
if matches!(n.kind, NodeKind::Image(_)) {
colorized.push(n.extras().colorize.is_some());
}
});
}
assert!(colorized.iter().all(|c| !c), "{colorized:?}");
}

/// A Separation image's soft mask becomes its opacity mask.
#[test]
fn a_separation_image_keeps_its_soft_mask() {
let smask = "<< /Type /XObject /Subtype /Image /Width 4 /Height 1 /ColorSpace /DeviceGray /BitsPerComponent 8 /Filter /ASCIIHexDecode /Length 9 >>\nstream\nFF00FF00>\nendstream";
let d = separation_page(SPOT_RED, 4, 8, &format!("/SMask {} 0 R", first_extra(1) + 1), "0055AAFF", &[smask]);
let mut found = vec![];
for l in &d.layers {
l.walk(&mut |n| {
if matches!(n.kind, NodeKind::Image(_)) && n.extras().colorize.is_some() {
found.push(n.extras().mask.is_some());
}
});
}
assert_eq!(found, [true]);
}

/// The same Separation image drawn twice is colorized twice, over the same pixels.
#[test]
fn a_separation_image_drawn_twice_is_colorized_twice() {
let d = separation_report(SPOT_RED, 4, 8, "", "0055AAFF", &[], "q 80 0 0 10 10 20 cm /Im1 Do Q q 80 0 0 10 10 60 cm /Im1 Do Q").document;
let images = imported_images(&d);
assert_eq!(images.len(), 2);
assert_eq!(images[0], images[1]);
assert!(images[0].0.is_some(), "colorized");
}

/// A one-component JPEG in a Separation space is read through its decoder.
#[test]
fn a_separation_jpeg_opens_colorized_by_its_ink() {
let mut jpeg = vec![];
jpeg_encoder::Encoder::new(&mut jpeg, 100).encode(&[0, 0, 255, 255, 0, 0, 255, 255], 8, 1, jpeg_encoder::ColorType::Luma).unwrap();
let hex: String = jpeg.iter().map(|b| format!("{b:02X}")).collect();
let d = separation_page(SPOT_RED, 8, 8, "/Filter [/ASCIIHexDecode /DCTDecode]", &hex, &[]);
let images = imported_images(&d);
assert_eq!(images.len(), 1);
assert!(images[0].0.is_some(), "colorized");
// Sample 0 lays no ink (white), 255 lays it all (black); JPEG blurs the edges a little.
let gray = &images[0].1;
assert!(gray[0] > 200 && gray[2] < 55 && gray[4] > 200 && gray[6] < 55, "{gray:?}");
}

/// A Separation image whose tints can't be read (its data cut short) opens in RGB each time it is
/// drawn, with one warning and no swatch for its ink.
#[test]
fn a_separation_image_that_cant_be_read_stays_rgb() {
let r = separation_report(SPOT_RED, 4, 8, "", "0055", &[], "q 80 0 0 10 10 20 cm /Im1 Do Q q 80 0 0 10 10 60 cm /Im1 Do Q");
let mut colorized = vec![];
for l in &r.document.layers {
l.walk(&mut |n| {
if matches!(n.kind, NodeKind::Image(_)) {
colorized.push(n.extras().colorize.is_some());
}
});
}
assert_eq!(colorized, [false, false]);
assert!(r.document.swatches.iter().all(|s| !s.spot), "no swatch for an ink nothing uses");
assert_eq!(r.warnings.iter().filter(|w| w.contains("spot ink")).count(), 1, "{:?}", r.warnings);
}
Loading
Loading