M4.73: PDF colour conversion, ICC profiles and output intent

Save PDF > Output now applies: colours convert to a destination profile
(destination, or preserveNumbers keeping the numbers of colours already in
its model; grey stays grey; images and freeform gradient images convert
too; printer's marks don't). Profile inclusion writes ICC-based colour
spaces (CMYK with the destination's or the document's profile, RGB as sRGB,
grey), and transparency in CMYK output blends in the CMYK profile's space.
The output intent profile is embedded as the catalog's /GTS_PDFX output
intent with its condition, identifier and registry, and Trapped goes in
the document information (PDF/A keeps its own). A shared patch module
edits written PDFs (Lab spot alternates use it too).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic 2026-10-05 16:24:57 +07:00
parent 51f6817a0b
commit 5b69862a08
18 changed files with 1244 additions and 129 deletions

1
Cargo.lock generated
View File

@ -4397,6 +4397,7 @@ name = "vectorcraft-pdf"
version = "0.2.0"
dependencies = [
"aes",
"flate2",
"hayro-interpret",
"hayro-syntax",
"image",

View File

@ -893,6 +893,8 @@ mod tests_dxfimport;
#[cfg(test)]
mod tests_metafile;
#[cfg(test)]
mod tests_pdfoutput;
#[cfg(test)]
mod tests_tiffbmp;

View File

@ -46,7 +46,12 @@ pub(super) const OPTIONS: &[FormatOption] = &[
default: "null",
description: "{useDocument, top, bottom, left, right} in points: the page grows by the bleed (BleedBox) around its artboard (TrimBox)",
},
FormatOption { name: "output", ty: "object", default: "null", description: "colour conversion and output intent (see document.exportPdf)" },
FormatOption {
name: "output",
ty: "object",
default: "null",
description: "colour conversion to a destination profile, ICC profiles, output intent and Trapped (see document.exportPdf)",
},
FormatOption { name: "advanced", ty: "object", default: "null", description: "{fontSubsetPercent, outlineText, overprint}" },
FormatOption {
name: "security",
@ -69,7 +74,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Export PDF",
[],
None,
"{path?, preset?: \"VectorCraft Default\" (built-in or saved: pdf.preset.list), artboard? | artboards?: [i…] | range?: \"1-3, 5\" (1-based; default all, one page each), standard?: none|pdfA2b|pdfX1a|pdfX3|pdfX4, compatibility?: 1.4|1.5|1.6|1.7 (default)|2.0, preserveEditing? (on in VectorCraft Default: the native document as an embedded file, which document.open restores; choosing a standard turns it off, PDF/A refuses it), thumbnails?, fastWebView?, viewAfterSaving? (the app opens the written file), createLayers?, includeNonPrinting? (keep layers whose Print option is off; left out by default unless createLayers), compression?: {color?, gray?: {downsample: none|average|subsample|bicubic, ppi: 300, abovePpi: 450, compression: none|zip|jpeg|jpeg2000|auto, quality: minimum|low|medium|high|maximum}, mono?: {downsample, ppi: 1200, abovePpi: 1800, compression: none|ccittG3|ccittG4|zip|runLength} (black-and-white images), compressText?: true} (images above abovePpi are resampled to ppi; auto keeps JPEGs JPEG, the others lossless; JPEG needs opaque images; none, jpeg2000, CCITT and runLength are written as ZIP with a warning), marks?: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25 (trim marks and targets), offset: 6 (pt from the artboard, at least the bleed)} (printer's marks in [Registration], every plate: trim marks, registration targets mid-side, CMYK/spot/black-tint colour bars on top, page information (title, artboard, date UTC) below), bleed?: {useDocument (the document's bleed, document.setup), top, bottom, left, right} (pt; art in the bleed is kept). Each page: TrimBox = artboard, BleedBox = artboard + bleed, MediaBox = that + the marks' room (art clipped to the BleedBox), output?: {conversion: none|destination|preserveNumbers, destination, profiles: none|all|destination|taggedSource, outputIntent, outputCondition, outputConditionId, registry, trapped}, advanced?: {fontSubsetPercent: 100, outlineText: true, overprint: preserve|discard}, security?: {openPassword, permissionsPassword, printing: none|low|high, changes: none|pages|forms|comments|any, copy, screenReader, plaintextMetadata}} → {path, bytes, warnings}; no path → {dataBase64, bytes, warnings}. The options apply over the preset (null keeps its value); options accepted but not applied yet come back as warnings; PDF/X, PDF/A-2b at 2.0 and a password with a PDF/A or PDF/X standard are refused. document.export {format: pdf} takes the same options",
"{path?, preset?: \"VectorCraft Default\" (built-in or saved: pdf.preset.list), artboard? | artboards?: [i…] | range?: \"1-3, 5\" (1-based; default all, one page each), standard?: none|pdfA2b|pdfX1a|pdfX3|pdfX4, compatibility?: 1.4|1.5|1.6|1.7 (default)|2.0, preserveEditing? (on in VectorCraft Default: the native document as an embedded file, which document.open restores; choosing a standard turns it off, PDF/A refuses it), thumbnails?, fastWebView?, viewAfterSaving? (the app opens the written file), createLayers?, includeNonPrinting? (keep layers whose Print option is off; left out by default unless createLayers), compression?: {color?, gray?: {downsample: none|average|subsample|bicubic, ppi: 300, abovePpi: 450, compression: none|zip|jpeg|jpeg2000|auto, quality: minimum|low|medium|high|maximum}, mono?: {downsample, ppi: 1200, abovePpi: 1800, compression: none|ccittG3|ccittG4|zip|runLength} (black-and-white images), compressText?: true} (images above abovePpi are resampled to ppi; auto keeps JPEGs JPEG, the others lossless; JPEG needs opaque images; none, jpeg2000, CCITT and runLength are written as ZIP with a warning), marks?: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25 (trim marks and targets), offset: 6 (pt from the artboard, at least the bleed)} (printer's marks in [Registration], every plate: trim marks, registration targets mid-side, CMYK/spot/black-tint colour bars on top, page information (title, artboard, date UTC) below), bleed?: {useDocument (the document's bleed, document.setup), top, bottom, left, right} (pt; art in the bleed is kept). Each page: TrimBox = artboard, BleedBox = artboard + bleed, MediaBox = that + the marks' room (art clipped to the BleedBox), output?: {conversion: none (default)|destination (every colour into the destination profile's model, CMYK or RGB, also colours of that model in another profile)|preserveNumbers (only colours of the other model and Lab; those in the destination's model keep their numbers) (grey stays grey; images are converted too, printer's marks are not), destination: profile name (edit.colorSettings lists them; default: the document's profile for its colour mode), profiles: none (device colours)|all|destination|taggedSource (documents assigned a profile: edit.assignProfile) (ICC-based colours: CMYK with the destination or document CMYK profile, RGB as sRGB, grey with the sRGB tone curve; PDF/A always), outputIntent: profile name (embedded as the catalog's /GTS_PDFX output intent; a name that isn't a profile is only named), outputCondition, outputConditionId (default: outputIntent), registry, trapped (/Trapped /True; /False when an output intent is written)} (PDF/A files keep their own output intent), advanced?: {fontSubsetPercent: 100, outlineText: true, overprint: preserve|discard}, security?: {openPassword, permissionsPassword, printing: none|low|high, changes: none|pages|forms|comments|any, copy, screenReader, plaintextMetadata}} → {path, bytes, warnings}; no path → {dataBase64, bytes, warnings}. The options apply over the preset (null keeps its value); options accepted but not applied yet come back as warnings; PDF/X, PDF/A-2b at 2.0 and a password with a PDF/A or PDF/X standard are refused. document.export {format: pdf} takes the same options",
has_doc,
export_pdf
),

View File

@ -0,0 +1,38 @@
//! `document.exportPdf` output options reach the writer: colours converted and tagged with
//! profiles, and the output intent written.
use serde_json::{Value, json};
use super::*;
fn pdf(s: &mut Session, p: Value) -> (String, Value) {
let v = s.execute("document.exportPdf", &p).unwrap();
let bytes = vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap();
(String::from_utf8_lossy(&bytes).into_owned(), v["warnings"].clone())
}
#[test]
fn export_pdf_converts_colours_and_writes_the_output_intent() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 200, "height": 100})).unwrap();
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 40, "height": 40})).unwrap();
s.execute("paint.setFill", &json!({"color": {"model": "rgb", "r": 1.0, "g": 0.0, "b": 0.0}})).unwrap();
s.execute("text.create", &json!({"x": 80, "y": 60, "text": "Press ready"})).unwrap();
let generic = vectorcraft_color::cms::GENERIC_CMYK;
let p = json!({
"preset": "VectorCraft Default",
"compression": {"compressText": false},
"output": {"conversion": "destination", "destination": generic, "profiles": "destination", "outputIntent": generic, "outputConditionId": "Generic press", "trapped": true},
});
let (text, warnings) = pdf(&mut s, p.clone());
assert_eq!(warnings, json!([]), "everything asked for is applied");
assert!(text.contains("/ICCBased") && text.contains("/N 4"), "CMYK colours tagged with the destination");
assert!(text.contains("/S/GTS_PDFX") && text.contains("/Trapped/True"));
// The Summary has nothing to report either.
let mut q = p;
q["includeDocument"] = json!(true);
assert_eq!(s.execute("document.pdfSettings", &q).unwrap()["warnings"], json!([]));
// Converting to a profile that isn't there is a bad parameter.
let bad = s.execute("document.exportPdf", &json!({"output": {"conversion": "destination", "destination": "Missing"}}));
assert!(matches!(bad, Err(crate::EngineError::BadParams { .. })), "{bad:?}");
}

View File

@ -17,6 +17,7 @@ vectorcraft-color = { workspace = true }
vectorcraft-text = { workspace = true }
kurbo = { workspace = true }
thiserror = { workspace = true }
flate2 = { workspace = true }
image = { workspace = true }
krilla = { workspace = true }
hayro-syntax = "0.7.2"

View File

@ -1,6 +1,7 @@
//! Document → PDF (krilla). Mirrors the tree walk of `vectorcraft-render`.
use std::collections::HashMap;
use std::sync::Arc;
use krilla::color::separation::{Color as SepColor, SeparationColorant, SeparationSpace};
use krilla::color::{cmyk, luma, rgb};
@ -20,6 +21,7 @@ use vectorcraft_geom::{Affine, BezPath, FillRule, Rect};
use crate::lab_spot::{find, rfind};
use crate::marks::PageBoxes;
use crate::output::ColorOut;
use crate::{Compatibility, ExportReport, PdfError, PdfOptions, Standard};
/// Export `doc` as PDF bytes: one page per artboard (or the artboards chosen in `opts`).
@ -63,6 +65,7 @@ pub fn export_with_report(doc: &Document, opts: &PdfOptions) -> Result<ExportRep
let mut ex = Exporter::new(doc, set);
ex.non_printing = set.include_non_printing || set.create_layers;
ex.out = w.out.clone();
// Marks and Bleeds: each page is its artboard (the trim box) grown by the bleed, and by the
// printer's marks around that.
let bleed = set.bleed_of(doc);
@ -102,7 +105,10 @@ pub fn export_with_report(doc: &Document, opts: &PdfOptions) -> Result<ExportRep
/// its pages ([`Self::page`]), each drawn by an [`Exporter`].
pub(crate) struct Writer {
pub pdf: krilla::Document,
/// Groups blend in CMYK (a CMYK document's composite, see `cmyk_blending`).
/// How colours are written (the Output settings).
pub out: Arc<ColorOut>,
/// Groups blend in CMYK (a CMYK document's composite, or converting to CMYK; see
/// `cmyk_blending`).
cmyk: bool,
/// Spot colours written with a Lab alternate, gathered from the exporters ([`Self::absorb`]).
lab_spots: Vec<(String, vectorcraft_color::cms::Lab)>,
@ -134,9 +140,17 @@ pub(crate) struct Sheet<'a> {
}
impl Writer {
/// A PDF with `set`'s version, standard and compression, and `doc`'s metadata under `title`,
/// created at `created` (Unix seconds). With `cmyk`, transparency blends in CMYK.
/// A PDF with `set`'s version, standard, compression and colour output, and `doc`'s metadata
/// under `title`, created at `created` (Unix seconds). With `cmyk`, transparency blends in
/// CMYK (unless the colours are converted to RGB).
pub(crate) fn new(doc: &Document, set: &crate::PdfSettings, title: &str, created: Option<i64>, cmyk: bool) -> Result<Self, PdfError> {
let out = ColorOut::new(doc, set)?;
let mut warnings = vec![];
// Tagged CMYK colours need the profile (PDF/A tags every colour).
let cmyk_profile = if out.tagged { out.cmyk_icc() } else { None };
if out.tagged && cmyk_profile.is_none() {
warnings.push(format!("the CMYK profile {} can't be embedded: CMYK colours are written untagged", out.cmyk_profile()));
}
let version = match set.compatibility {
Compatibility::Pdf14 => PdfVersion::Pdf14,
Compatibility::Pdf15 => PdfVersion::Pdf15,
@ -150,7 +164,13 @@ impl Writer {
cb = cb.with_archival_validator(Archival::A2_B);
}
let configuration = cb.finish().map_err(|e| PdfError::Write(format!("{e:?}")))?;
let settings = krilla::SerializeSettings { compress_content_streams: set.compression.compress_text, configuration, ..Default::default() };
let settings = krilla::SerializeSettings {
compress_content_streams: set.compression.compress_text,
no_device_cs: out.tagged,
cmyk_profile,
configuration,
..Default::default()
};
let mut pdf = krilla::Document::new_with(settings);
let mut meta = Metadata::new().creator("VectorCraft".into()).producer("VectorCraft".into());
@ -173,7 +193,7 @@ impl Writer {
meta = meta.creation_date(t);
}
pdf.set_metadata(meta);
Ok(Self { pdf, cmyk, lab_spots: vec![], warnings: vec![] })
Ok(Self { pdf, cmyk: out.blends_cmyk(cmyk), out: Arc::new(out), lab_spots: vec![], warnings })
}
/// Draw one page: the art of `ex`'s document placed as `sheet` says, then its marks.
@ -227,8 +247,10 @@ impl Writer {
// The drawing space the page shows (the marks' area).
let shown = sheet.view.inverse().transform_rect_bbox(paper);
let knockout = std::mem::replace(&mut ex.knockout, false);
// Marks keep their inks: they aren't converted to the destination.
let convert = std::mem::replace(&mut ex.convert, false);
ex.node(&mut s, art, sheet.window.map_or(shown, |r| r.intersect(shown)), true);
ex.knockout = knockout;
(ex.knockout, ex.convert) = (knockout, convert);
}
if window.is_some() {
s.pop();
@ -258,44 +280,80 @@ impl Writer {
}
}
/// The finished file and the warnings of its drawing.
pub(crate) fn finish(self) -> Result<(Vec<u8>, Vec<String>), PdfError> {
/// The finished file (with the output intent and Trapped entries) and the warnings of its
/// drawing.
pub(crate) fn finish(mut self) -> Result<(Vec<u8>, Vec<String>), PdfError> {
let mut bytes = self.pdf.finish().map_err(|e| PdfError::Write(format!("{e:?}")))?;
if self.cmyk {
cmyk_blending(&mut bytes);
cmyk_blending(&mut bytes, self.out.tagged);
}
let bytes = if self.lab_spots.is_empty() { bytes } else { crate::lab_spot::lab_alternates(bytes, &self.lab_spots) };
let (bytes, more) = self.out.write_catalog(bytes)?;
self.warnings.extend(more);
Ok((bytes, self.warnings))
}
}
/// Make the transparency groups of `pdf` blend in DeviceCMYK. The PDF writer gives every group
/// DeviceRGB as its blending space, so the group dictionaries are rewritten in place, keeping their
/// length so the cross-reference offsets stay valid. Luminosity masks' groups keep RGB: mask
/// luminance is that of screen colours, as on screen.
fn cmyk_blending(pdf: &mut [u8]) {
/// Make the transparency groups of `pdf` blend in CMYK. The PDF writer gives every group RGB as
/// its blending space (DeviceRGB, or sRGB when colours are `tagged`), so the group dictionaries
/// are rewritten in place to DeviceCMYK (or the CMYK profile's space), keeping their length so the
/// cross-reference offsets stay valid. Luminosity masks' groups keep RGB: mask luminance is that
/// of screen colours, as on screen.
fn cmyk_blending(pdf: &mut [u8], tagged: bool) {
let masks = luminosity_mask_groups(pdf);
let mut spaces = vec![("/CS/DeviceRGB".to_string(), "/CS/DeviceCMYK".to_string())];
if tagged {
spaces.extend(icc_spaces(pdf));
}
let mut from = 0;
while let Some(at) = find(pdf, b"/Group<<", from).map(|i| i + b"/Group".len()) {
let end = find(pdf, b">>", at).map_or(pdf.len(), |i| i + 2);
if !object_number(&pdf[..at]).is_some_and(|n| masks.contains(&n)) {
cmyk_group(&mut pdf[at..end]);
let mask = pdf.get(..at).and_then(object_number).is_some_and(|n| masks.contains(&n));
if let (false, Some(dict)) = (mask, pdf.get_mut(at..end)) {
cmyk_group(dict, &spaces);
}
from = end;
}
}
/// Rewrite transparency group dictionary `dict` (`<<…>>`) to blend in DeviceCMYK, at the same
/// length: leaving out the optional `/Type/Group` makes room for the longer name, spaces pad the
/// rest.
fn cmyk_group(dict: &mut [u8]) {
/// The ICC-based RGB colour spaces of `pdf`, each with the ICC-based CMYK one to blend in instead,
/// as group `/CS` entries (`/CS 3 0 R`); none without a CMYK one.
fn icc_spaces(pdf: &[u8]) -> Vec<(String, String)> {
const ICC: &[u8] = b"[/ICCBased ";
let (mut rgb, mut cmyk) = (vec![], None);
let mut from = 0;
while let Some(at) = find(pdf, ICC, from) {
from = at + ICC.len();
let digits = pdf.get(from..).map_or(0, |r| r.iter().take_while(|b| b.is_ascii_digit()).count());
let (Some(array), Some(stream)) = (pdf.get(..at).and_then(object_number), pdf.get(from..from + digits).and_then(number)) else {
continue;
};
// The profile stream's dictionary says how many components it has.
let Some(head) = find(pdf, format!("\n{stream} 0 obj").as_bytes(), 0) else { continue };
let dict = pdf.get(head..find(pdf, b"stream", head).unwrap_or(pdf.len())).unwrap_or_default();
if find(dict, b"/N 4", 0).is_some() {
cmyk.get_or_insert(format!("/CS {array} 0 R"));
} else if find(dict, b"/N 3", 0).is_some() {
rgb.push(format!("/CS {array} 0 R"));
}
}
let Some(cmyk) = cmyk else { return vec![] };
rgb.into_iter().map(|r| (r, cmyk.clone())).collect()
}
/// Rewrite transparency group dictionary `dict` (`<<…>>`) to blend in the CMYK space of the first
/// of `spaces` (RGB `/CS` entry, CMYK one) it has, at the same length: leaving out the optional
/// `/Type/Group` makes room for the longer entry, spaces pad the rest.
fn cmyk_group(dict: &mut [u8], spaces: &[(String, String)]) {
let Ok(text) = std::str::from_utf8(dict) else { return };
if !(text.contains("/S/Transparency") && text.contains("/Type/Group") && text.contains("/CS/DeviceRGB")) {
if !(text.contains("/S/Transparency") && text.contains("/Type/Group")) {
return;
}
let body = text.replacen("/Type/Group", "", 1).replacen("/CS/DeviceRGB", "/CS/DeviceCMYK", 1);
let Some((rgb, cmyk)) = spaces.iter().find(|(rgb, _)| text.contains(rgb.as_str())) else { return };
let body = text.replacen("/Type/Group", "", 1).replacen(rgb.as_str(), cmyk, 1);
let body = body.strip_suffix(">>").unwrap_or(&body);
let new = format!("{body}{}>>", " ".repeat(dict.len() - body.len() - 2));
let Some(pad) = dict.len().checked_sub(body.len() + 2) else { return };
let new = format!("{body}{}>>", " ".repeat(pad));
dict.copy_from_slice(new.as_bytes());
}
@ -361,6 +419,10 @@ pub(crate) struct Exporter<'a> {
pub intent: vectorcraft_color::cms::Intent,
/// Whether a layer shows transparency (found out on the first page that asks).
transparent: Option<bool>,
/// How colours are written (the Output settings; print writes them as they are).
pub out: Arc<ColorOut>,
/// Colours are converted to the destination (printer's marks aren't).
convert: bool,
}
impl<'a> Exporter<'a> {
@ -379,6 +441,8 @@ impl<'a> Exporter<'a> {
non_printing: false,
intent: vectorcraft_color::cms::active().settings().intent,
transparent: None,
out: Arc::default(),
convert: true,
}
}
@ -485,14 +549,17 @@ fn rects_overlap(a: Rect, b: Rect) -> bool {
}
impl Exporter<'_> {
/// A colour for the page: in CMYK documents RGB and Lab colours are separated into DeviceCMYK
/// through the active colour settings, so the file carries press values.
/// A colour for the page, as the Output settings write it ([`ColorOut::color`]): without
/// conversion, RGB and Lab colours of CMYK documents are separated into CMYK through the
/// active colour settings, so the file carries press values.
fn col(&mut self, c: &Color) -> krilla::color::Color {
if self.doc.color_mode == vectorcraft_doc::ColorMode::Cmyk && matches!(c, Color::Rgb { .. } | Color::Lab { .. }) {
let [cc, m, y, k] = vectorcraft_color::cms::active().to_cmyk(c, self.intent);
return cmyk::Color::new(q(cc), q(m), q(y), q(k)).into();
let cmyk_doc = self.doc.color_mode == vectorcraft_doc::ColorMode::Cmyk;
let c = self.out.color(c, self.intent, cmyk_doc, self.convert);
if let (Color::Rgb { .. }, Some(note)) = (c, &self.out.srgb_note) {
let note = note.clone();
self.warn(note);
}
color(c)
color(&c)
}
/// The Separation colour space of spot swatch `name` (its CMYK equivalent is the alternate
@ -507,7 +574,7 @@ impl Exporter<'_> {
{
self.lab_spots.push((name.to_string(), vectorcraft_color::cms::Lab::new(l, a, b)));
}
let full = vectorcraft_color::cms::active().to_cmyk(&color, self.intent);
let full = self.out.cmyk(&color, self.intent);
let alt = krilla::color::RegularColor::Cmyk(cmyk::Color::new(q(full[0]), q(full[1]), q(full[2]), q(full[3])));
Some(SeparationSpace::new(SeparationColorant::Custom(sw.name.clone()), alt))
}
@ -665,7 +732,7 @@ impl Exporter<'_> {
acc[i] += c[i] / n;
}
}
Some(color(&Color::rgb(acc[0], acc[1], acc[2])).into())
Some(self.col(&Color::rgb(acc[0], acc[1], acc[2])).into())
}
}
}
@ -958,9 +1025,11 @@ impl Exporter<'_> {
let (cols, rows) = (along(area.width()), along(area.height()));
let field = g.freeform_on(b).field_with(spread_scale(b), &|c| c.to_rgb());
let rgba: Vec<u8> = field.grid(area, cols, rows).flat_map(|(c, a)| [q(c[0]), q(c[1]), q(c[2]), q(a)]).collect();
let (Some(img), Some(size)) =
(crate::images::from_rgba(&rgba, cols.into(), rows.into(), self.interpolate), Size::from_wh(area.width() as f32, area.height() as f32))
else {
let pixels = if self.convert { self.out.pixels() } else { None };
let (Some(img), Some(size)) = (
crate::images::from_rgba_in(rgba, cols.into(), rows.into(), self.interpolate, pixels),
Size::from_wh(area.width() as f32, area.height() as f32),
) else {
return;
};
s.push_transform(&xf(Affine::translate(area.origin().to_vec2())));
@ -1187,8 +1256,9 @@ impl Exporter<'_> {
}
let interpolate = self.interpolate;
let doc = self.doc;
let out = self.out.clone();
let img = doc.images.get(key).and_then(|blob| {
let r = crate::images::recode(&blob.bytes, size, c, interpolate);
let r = crate::images::recode(&blob.bytes, size, c, interpolate, out.pixels());
if let Some(w) = r.warning {
self.warn(w);
}

View File

@ -1,6 +1,6 @@
//! Images in the PDF: the document's images as the Compression settings say (resampled above a
//! resolution, compressed with ZIP or JPEG), and pixels the writer makes itself (freeform
//! gradients).
//! resolution, compressed with ZIP or JPEG) and converted as the Output settings say
//! ([`Pixels`]), and pixels the writer makes itself (freeform gradients).
//!
//! Every image is classified as the settings are split: colour, greyscale (a grey colour type) or
//! monochrome (greyscale that is only black and white). An image already as asked (not resampled,
@ -10,20 +10,22 @@
use std::io::Cursor;
use std::sync::Arc;
use image::buffer::ConvertBuffer;
use image::imageops::FilterType;
use image::{DynamicImage, ExtendedColorType, ImageDecoder, ImageFormat, ImageReader};
use image::{DynamicImage, ExtendedColorType, ImageDecoder, ImageFormat, ImageReader, RgbaImage};
use krilla::image::{BitsPerComponent, CustomImage, Image, ImageColorspace};
use crate::output::Pixels;
use crate::{CompressionSettings, Downsample, ImageCodec, JpegQuality, MonoCodec};
/// Raw 8-bit pixels: colour samples (RGB, or grey with `luma`) and an optional alpha channel.
/// Raw 8-bit pixels: colour samples (RGB, grey or CMYK) and an optional alpha channel.
#[derive(Clone, Hash)]
struct Raw {
color: Arc<Vec<u8>>,
alpha: Option<Arc<Vec<u8>>>,
width: u32,
height: u32,
luma: bool,
space: ImageColorspace,
}
impl CustomImage for Raw {
@ -48,7 +50,7 @@ impl CustomImage for Raw {
}
fn color_space(&self) -> ImageColorspace {
if self.luma { ImageColorspace::Luma } else { ImageColorspace::Rgb }
self.space
}
}
@ -57,14 +59,34 @@ impl CustomImage for Raw {
/// `interpolate` asks viewers to smooth it when enlarged (PDF/A forbids it). `None` when `rgba`
/// doesn't hold that many pixels.
pub(crate) fn from_rgba(rgba: &[u8], width: u32, height: u32, interpolate: bool) -> Option<Image> {
from_rgba_as(rgba, width, height, interpolate, None)
}
/// [`from_rgba`], colour pixels written as CMYK when `pixels` converts to CMYK (grey ones stay
/// grey).
fn from_rgba_as(rgba: &[u8], width: u32, height: u32, interpolate: bool, pixels: Option<&Pixels>) -> Option<Image> {
let px = rgba.as_chunks::<4>().0;
if px.len() as u64 != width as u64 * height as u64 || px.is_empty() {
return None;
}
let luma = px.iter().all(|p| p[0] == p[1] && p[1] == p[2]);
let color = if luma { px.iter().map(|p| p[0]).collect() } else { px.iter().flat_map(|p| [p[0], p[1], p[2]]).collect() };
let (color, space) = if luma {
(px.iter().map(|p| p[0]).collect(), ImageColorspace::Luma)
} else if let Some(cmyk) = pixels.and_then(|p| p.cmyk(rgba)) {
(cmyk, ImageColorspace::Cmyk)
} else {
(px.iter().flat_map(|p| [p[0], p[1], p[2]]).collect(), ImageColorspace::Rgb)
};
let alpha = px.iter().any(|p| p[3] < 255).then(|| Arc::new(px.iter().map(|p| p[3]).collect()));
Image::from_custom(Raw { color: Arc::new(color), alpha, width, height, luma }, interpolate).ok()
Image::from_custom(Raw { color: Arc::new(color), alpha, width, height, space }, interpolate).ok()
}
/// [`from_rgba`] of pixels converted by `pixels` (when given).
pub(crate) fn from_rgba_in(mut rgba: Vec<u8>, width: u32, height: u32, interpolate: bool, pixels: Option<&Pixels>) -> Option<Image> {
if let Some(p) = pixels {
p.rgb_in_place(&mut rgba);
}
from_rgba_as(&rgba, width, height, interpolate, pixels)
}
/// The kinds of images the Compression section has settings for.
@ -96,6 +118,7 @@ struct Rule {
}
const UNCOMPRESSED: &str = "uncompressed images are not written: images are compressed with ZIP";
const CMYK_ZIP: &str = "images converted to CMYK are compressed with ZIP, not JPEG";
/// JPEG quality (1–100) of a quality setting.
fn quality(q: JpegQuality) -> u8 {
@ -144,9 +167,10 @@ fn bilevel(img: &DynamicImage) -> bool {
}
/// Encoded image `bytes` placed `size` points wide and high, as the Compression settings `c` ask:
/// resampled when its resolution is above the threshold, compressed with ZIP or JPEG.
/// `interpolate` lets viewers smooth it. An image that can't be decoded is kept as it is.
pub(crate) fn recode(bytes: &[u8], size: (f64, f64), c: &CompressionSettings, interpolate: bool) -> Recoded {
/// resampled when its resolution is above the threshold, compressed with ZIP or JPEG, its colours
/// converted by `convert` (colour images). `interpolate` lets viewers smooth it. An image that
/// can't be decoded is kept as it is.
pub(crate) fn recode(bytes: &[u8], size: (f64, f64), c: &CompressionSettings, interpolate: bool, convert: Option<&Pixels>) -> Recoded {
let keep = Recoded { image: None, warning: None };
let Ok(reader) = ImageReader::new(Cursor::new(bytes)).with_guessed_format() else { return keep };
let jpeg = reader.format() == Some(ImageFormat::Jpeg);
@ -183,7 +207,8 @@ pub(crate) fn recode(bytes: &[u8], size: (f64, f64), c: &CompressionSettings, in
(Kind::Color, None)
};
let rule = rule(c, kind);
if kept(&rule) {
let convert = convert.filter(|_| kind == Kind::Color);
if kept(&rule) && convert.is_none() {
return Recoded { image: None, warning: rule.instead };
}
let Some(img) = pixels.or_else(|| image::load_from_memory(bytes).ok()) else { return keep };
@ -210,14 +235,27 @@ pub(crate) fn recode(bytes: &[u8], size: (f64, f64), c: &CompressionSettings, in
Codec::Auto(q) if jpeg => Some(q),
_ => None,
};
let image = quality.filter(|_| rgba.pixels().all(|p| p[3] == 255)).and_then(|q| to_jpeg(&img, kind != Kind::Color, q, interpolate));
// CMYK images are written losslessly (the PDF writer's CMYK JPEGs need the reader's
// conventions).
let cmyk = convert.is_some_and(|p| p.is_cmyk());
let (width, height) = rgba.dimensions();
Recoded { image: image.or_else(|| from_rgba(rgba.as_raw(), width, height, interpolate)), warning: rule.instead }
let opaque = rgba.pixels().all(|p| p[3] == 255);
let image = match quality.filter(|_| opaque && !cmyk) {
Some(q) => {
if let Some(p) = convert {
p.rgb_in_place(&mut rgba);
}
to_jpeg(&rgba, kind != Kind::Color, q, interpolate).or_else(|| from_rgba(rgba.as_raw(), width, height, interpolate))
}
None => from_rgba_in(rgba.into_raw(), width, height, interpolate, convert),
};
let warning = rule.instead.or((cmyk && quality.is_some() && opaque).then_some(CMYK_ZIP));
Recoded { image, warning }
}
/// Opaque `img` as a JPEG image at `quality` (a greyscale JPEG with `grey`).
fn to_jpeg(img: &DynamicImage, grey: bool, quality: u8, interpolate: bool) -> Option<Image> {
let img = if grey { DynamicImage::ImageLuma8(img.to_luma8()) } else { DynamicImage::ImageRgb8(img.to_rgb8()) };
/// Opaque `rgba` as a JPEG image at `quality` (a greyscale JPEG with `grey`).
fn to_jpeg(rgba: &RgbaImage, grey: bool, quality: u8, interpolate: bool) -> Option<Image> {
let img = if grey { DynamicImage::ImageLuma8(rgba.convert()) } else { DynamicImage::ImageRgb8(rgba.convert()) };
let mut out = Vec::new();
image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, quality).encode_image(&img).ok()?;
Image::from_jpeg(out.into(), interpolate).ok()

View File

@ -9,6 +9,8 @@
use vectorcraft_color::cms::Lab;
use vectorcraft_color::cms::lab::D50;
use crate::patch::{Patch, Xref};
/// `name` as the PDF writer writes a name object (`/` then the bytes, irregular ones as `#XX`).
fn pdf_name(name: &str) -> Vec<u8> {
let mut out = vec![b'/'];
@ -53,8 +55,9 @@ pub(crate) fn find(hay: &[u8], needle: &[u8], from: usize) -> Option<usize> {
/// written by the export: uncompressed objects, a cross-reference table) to Lab alternates.
/// Spaces written another way are left as they are.
pub(crate) fn lab_alternates(pdf: Vec<u8>, spots: &[(String, Lab)]) -> Vec<u8> {
// (start, end, replacement) of each rewritten array, in file order.
let mut edits: Vec<(usize, usize, Vec<u8>)> = vec![];
let Some(xref) = Xref::read(&pdf) else { return pdf };
let mut patch = Patch::new(&xref);
let mut any = false;
for (name, lab) in spots {
let mut head = b"[/Separation".to_vec();
head.extend(pdf_name(name));
@ -62,52 +65,18 @@ pub(crate) fn lab_alternates(pdf: Vec<u8>, spots: &[(String, Lab)]) -> Vec<u8> {
let mut from = 0;
while let Some(start) = find(&pdf, &head, from) {
let Some(end) = find(&pdf, b">>]", start).map(|e| e + 3) else { break };
edits.push((start, end, lab_separation(name, *lab)));
patch.replace(start, end, lab_separation(name, *lab));
any = true;
from = end;
}
}
let Some(xref) = xref_offset(&pdf) else { return pdf };
if edits.is_empty() || edits.iter().any(|e| e.1 > xref) {
if !any {
return pdf;
}
edits.sort_by_key(|e| e.0);
// Where a byte offset of the old file lands in the new one.
let moved = |off: usize| -> usize {
edits.iter().filter(|e| e.1 <= off).fold(off as isize, |o, e| o + e.2.len() as isize - (e.1 - e.0) as isize) as usize
};
let mut out = Vec::with_capacity(pdf.len() + 256 * edits.len());
let mut at = 0;
for (start, end, new) in &edits {
out.extend_from_slice(&pdf[at..*start]);
out.extend_from_slice(new);
at = *end;
match patch.apply(&pdf, &xref) {
Ok(out) => out,
Err(_) => pdf,
}
out.extend_from_slice(&pdf[at..xref]);
// The cross-reference table: `xref\n0 N\n` then N 20-byte entries; in-use ones hold offsets.
let tail = &pdf[xref..];
let Some(entries) = find(tail, b"\n", 5).map(|i| i + 1) else { return pdf };
let Some(count) = std::str::from_utf8(&tail[5..entries - 1]).ok().and_then(|s| s.split_whitespace().nth(1)?.parse::<usize>().ok()) else {
return pdf;
};
let table_end = entries + 20 * count;
if tail.len() < table_end {
return pdf;
}
out.extend_from_slice(&tail[..entries]);
for e in tail[entries..table_end].chunks(20) {
match std::str::from_utf8(&e[..10]).ok().and_then(|s| s.parse::<usize>().ok()) {
Some(off) if e[17] == b'n' => {
out.extend(format!("{:010}", moved(off)).bytes());
out.extend_from_slice(&e[10..]);
}
_ => out.extend_from_slice(e),
}
}
let rest = &tail[table_end..];
let Some(sx) = rfind(rest, b"startxref\n") else { return pdf };
out.extend_from_slice(&rest[..sx]);
out.extend(format!("startxref\n{}\n%%EOF", moved(xref)).bytes());
out
}
pub(crate) fn rfind(hay: &[u8], needle: &[u8]) -> Option<usize> {

View File

@ -6,7 +6,9 @@
//! as their brush art), opacity and blend modes (transparency groups),
//! clip groups, linear/radial gradients (shadings), pattern fills and strokes as their tiles and
//! freeform gradients as images (clipped to what they paint), embedded images (resampled and
//! compressed as the Compression settings say) and text as outlined glyph paths. Hidden objects,
//! compressed as the Compression settings say) and text as outlined glyph paths. Colours are
//! converted, tagged with ICC profiles and given an output intent as the Output settings say.
//! Hidden objects,
//! guides and template layers are skipped, and so are non-printing layers unless asked for.
//! Each page is its artboard (the trim box) grown by the bleed (the bleed box) and by the
//! printer's marks around it (the media box), drawn in Registration. [`PdfSettings`] is the Save PDF
@ -45,7 +47,9 @@ mod import_shading;
mod import_text;
mod lab_spot;
mod marks;
mod output;
mod pages;
mod patch;
mod presets;
mod print;
mod settings;
@ -214,6 +218,8 @@ mod tests_import_options;
#[cfg(test)]
mod tests_marks;
#[cfg(test)]
mod tests_output;
#[cfg(test)]
mod tests_presets;
#[cfg(test)]
mod tests_print;

422
crates/pdf/src/output.rs Normal file
View File

@ -0,0 +1,422 @@
//! Save PDF › Output: colours converted to a destination profile, the ICC profiles that tag them,
//! and the output intent and Trapped entries.
//!
//! - Conversion ([`ColorConversion`]): `destination` converts every colour to the destination
//! profile's model (CMYK or RGB) with the colour settings' intent, also colours already in that
//! model when their profile differs from the destination; `preserveNumbers` converts only the
//! colours of the other model (and Lab ones), keeping the numbers of those already in it.
//! Grey stays grey. Images (and freeform gradients' images) are converted the same way, pixel
//! by pixel; printer's marks are not. The destination is a profile name; empty, the document's
//! profile for its colour mode.
//! - Profiles ([`ProfileInclusion`]): with `all`, with `destination`, and with `taggedSource` on a
//! document that was assigned profiles, colours are written in ICC-based colour spaces: CMYK
//! with the CMYK profile in effect (the destination when converting to CMYK, else the
//! document's), RGB with sRGB (colours of another RGB space as their sRGB equivalents) and grey
//! with the sRGB tone curve. Otherwise they are device colours. PDF/A always tags them.
//! - Output intent: the profile `outputIntent` names, embedded as the `/GTS_PDFX` output intent
//! of the catalog with the condition, identifier and registry; Trapped goes in the document
//! information (`/False` when an output intent is written untrapped). PDF/A files carry their
//! own output intent: neither is written there.
use std::sync::{Arc, OnceLock};
use vectorcraft_color::Color;
use vectorcraft_color::cms::{self, Cms, Intent, Model, ProfileKind, ProofLut};
use vectorcraft_doc::{ColorMode, Document};
use crate::patch::{Patch, Xref};
use crate::{ColorConversion, PdfError, PdfSettings, ProfileInclusion, Standard};
/// Most characters of an output intent's text entries written.
const MAX_TEXT: usize = 255;
/// The destination colours are converted to.
struct Dest {
cms: Cms,
model: Model,
/// Colours already in the destination's model keep their numbers (Preserve Numbers, or the
/// destination is their own profile).
keep: bool,
/// The destination is sRGB (what images are).
srgb: bool,
}
/// How an export writes colours (see the module documentation).
pub(crate) struct ColorOut {
/// The colour settings colours come from.
source: Arc<Cms>,
dest: Option<Dest>,
/// Colours are written in ICC-based colour spaces.
pub tagged: bool,
/// The CMYK profile that tags CMYK colours.
cmyk_profile: String,
/// Tagged RGB colours are written as their sRGB equivalents: why (a warning), when their RGB
/// space isn't sRGB.
pub srgb_note: Option<String>,
/// The image transform, built when the first image needs it.
pixels: OnceLock<Option<Pixels>>,
intent: Intent,
/// The output intent and Trapped entries to write.
catalog: Option<Catalog>,
}
/// An output intent dictionary's entries.
struct OutputIntent {
/// `/OutputConditionIdentifier`.
id: String,
/// `/OutputCondition`, `/RegistryName` and `/Info` (left out when empty).
condition: String,
registry: String,
info: String,
/// The profile to embed (`/DestOutputProfile`): its name and number of components.
profile: Option<(String, u8)>,
}
/// The output intent and Trapped entries of the catalog and document information.
struct Catalog {
intent: Option<OutputIntent>,
/// `/Trapped`, when it is written.
trapped: Option<bool>,
}
impl Default for ColorOut {
/// Colours as they are, device colours, nothing in the catalog.
fn default() -> Self {
let source = cms::active();
let cmyk_profile = source.settings().cmyk.clone();
let intent = source.settings().intent;
Self { source, dest: None, tagged: false, cmyk_profile, srgb_note: None, pixels: OnceLock::new(), intent, catalog: None }
}
}
/// The profile of `set`'s destination and its colour model; `None` without conversion. A blank
/// destination is the profile of a document in colour mode `mode`.
fn destination(set: &PdfSettings, mode: ColorMode, source: &Cms) -> Result<Option<(String, Model)>, PdfError> {
let o = &set.output;
if o.conversion == ColorConversion::None {
return Ok(None);
}
let name = o.destination.trim();
let name = match (name.is_empty(), mode) {
(false, _) => name,
(true, ColorMode::Cmyk) => source.settings().cmyk.as_str(),
(true, ColorMode::Rgb) => source.settings().rgb.as_str(),
};
let unknown = || PdfError::BadSetting(format!("output.destination: no RGB or CMYK profile is called “{name}”"));
let info = cms::profile(name).ok_or_else(unknown)?;
let model = match info.kind {
ProfileKind::Rgb => Model::Rgb,
ProfileKind::Cmyk => Model::Cmyk,
ProfileKind::Gray => return Err(unknown()),
};
Ok(Some((info.name, model)))
}
/// Refuse a destination profile that isn't there (when converting).
pub(crate) fn check(set: &PdfSettings) -> Result<(), PdfError> {
// A blank destination is the document's own profile, which is always there.
if !set.output.destination.trim().is_empty() {
destination(set, ColorMode::Rgb, &cms::active())?;
}
Ok(())
}
/// What `set`'s Output section leaves out, one warning each.
pub(crate) fn warnings(set: &PdfSettings) -> Vec<String> {
let o = &set.output;
let asks = !o.output_intent.trim().is_empty() || !o.output_condition_id.trim().is_empty();
let mut out = vec![];
if set.standard == Standard::PdfA2b {
if asks || o.trapped {
out.push("PDF/A files carry their own output intent: the output intent and Trapped entries are left out".into());
}
return out;
}
let name = o.output_intent.trim();
if !name.is_empty() && cms::profile(name).is_none() {
out.push(format!("no profile is called “{name}”: the output intent names it without embedding it"));
}
if !asks && (!o.output_condition.trim().is_empty() || !o.registry.trim().is_empty()) {
out.push("an output intent needs a profile or a condition identifier: the output condition and registry are left out".into());
}
out
}
impl ColorOut {
/// How `set` writes the colours of `doc` (the colour settings are the source).
pub(crate) fn new(doc: &Document, set: &PdfSettings) -> Result<Self, PdfError> {
let mut out = Self::default();
let source = out.source.clone();
let st = source.settings();
if let Some((name, model)) = destination(set, doc.color_mode, &source)? {
let mut settings = st.clone();
let own = match model {
Model::Cmyk => std::mem::replace(&mut settings.cmyk, name.clone()),
_ => std::mem::replace(&mut settings.rgb, name.clone()),
};
let cms = Cms::new(&settings).map_err(|e| PdfError::BadSetting(format!("output.destination: {e}")))?;
let keep = set.output.conversion == ColorConversion::PreserveNumbers || cms::canonical_name(&own) == name;
if model == Model::Cmyk {
out.cmyk_profile = name.clone();
}
out.dest = Some(Dest { srgb: name == cms::SRGB, cms, model, keep });
}
out.tagged = set.standard == Standard::PdfA2b
|| match set.output.profiles {
ProfileInclusion::None => false,
ProfileInclusion::All | ProfileInclusion::Destination => true,
ProfileInclusion::TaggedSource => !doc.color_profiles.is_empty(),
};
let rgb = match &out.dest {
Some(d) if d.model == Model::Rgb => (d.srgb, d.cms.settings().rgb.clone()),
_ => (source.rgb_is_srgb(), st.rgb.clone()),
};
if out.tagged && !rgb.0 {
out.srgb_note = Some(format!("RGB colours are tagged with the sRGB profile: those of {} are written as their sRGB equivalents", rgb.1));
}
out.catalog = catalog(set);
Ok(out)
}
/// Transparency blends in CMYK: converting to CMYK, or (without conversion) in a CMYK
/// document (`cmyk`).
pub(crate) fn blends_cmyk(&self, cmyk: bool) -> bool {
self.dest.as_ref().map_or(cmyk, |d| d.model == Model::Cmyk)
}
/// The ICC profile that tags CMYK colours (when they are tagged).
pub(crate) fn cmyk_icc(&self) -> Option<krilla::icc::ICCProfile<4>> {
krilla::icc::ICCProfile::new(&cms::icc_bytes(&self.cmyk_profile).ok()?)
}
/// The name of that profile.
pub(crate) fn cmyk_profile(&self) -> &str {
&self.cmyk_profile
}
/// Colour `c` as it is written with `intent`: converted to the destination (with `convert`;
/// printer's marks aren't), else RGB and Lab colours of a CMYK document (`cmyk_doc`)
/// separated into CMYK; tagged RGB colours as their sRGB equivalents.
pub(crate) fn color(&self, c: &Color, intent: Intent, cmyk_doc: bool, convert: bool) -> Color {
let s = &self.source;
let c = match &self.dest {
Some(d) if convert => d.convert(s, c, intent),
_ if cmyk_doc && matches!(c, Color::Rgb { .. } | Color::Lab { .. }) => {
let [c, m, y, k] = s.to_cmyk(c, intent);
Color::Cmyk { c, m, y, k }
}
_ => *c,
};
match c {
Color::Rgb { r, g, b } if self.srgb_note.is_some() => {
let space = self.dest.as_ref().filter(|d| convert && d.model == Model::Rgb).map_or(&**s, |d| &d.cms);
let [r, g, b] = space.rgb_to_srgb([r, g, b]);
Color::Rgb { r, g, b }
}
c => c,
}
}
/// Ink values of `c` in the CMYK space of the output (a spot colour's alternate).
pub(crate) fn cmyk(&self, c: &Color, intent: Intent) -> [f32; 4] {
match &self.dest {
Some(d) if d.model == Model::Cmyk => d.cms.to_cmyk(&d.convert(&self.source, c, intent), intent),
_ => self.source.to_cmyk(c, intent),
}
}
/// How colour images' pixels are converted; `None` when they are written as they are.
pub(crate) fn pixels(&self) -> Option<&Pixels> {
self.pixels
.get_or_init(|| {
let d = self.dest.as_ref()?;
let intent = self.intent;
match d.model {
Model::Cmyk => {
let plane = |k: bool| {
ProofLut::build(|rgb| {
let v = d.cms.srgb_to_cmyk(rgb, intent);
if k { [v[3], 0.0, 0.0] } else { [v[0], v[1], v[2]] }
})
};
Some(Pixels::Cmyk(Box::new([plane(false), plane(true)])))
}
// Images are sRGB: converted unless the destination is sRGB, they keep their
// numbers or RGB is tagged as sRGB.
_ if d.srgb || d.keep || self.tagged => None,
_ => Some(Pixels::Rgb(Box::new(ProofLut::build(|rgb| d.cms.srgb_to_rgb(rgb))))),
}
})
.as_ref()
}
/// `pdf` with the output intent and Trapped entries → (the file, warnings).
pub(crate) fn write_catalog(&self, pdf: Vec<u8>) -> Result<(Vec<u8>, Vec<String>), PdfError> {
let Some(cat) = &self.catalog else { return Ok((pdf, vec![])) };
let xref = Xref::read(&pdf).ok_or_else(|| PdfError::Write("the written PDF has no cross-reference table".into()))?;
let mut patch = Patch::new(&xref);
let mut warnings = vec![];
if let Some(OutputIntent { id, condition, registry, info, profile }) = &cat.intent {
let root = xref.trailer_ref(&pdf, b"/Root").and_then(|n| xref.dict(&pdf, n));
let Some((root, root_end)) = root else { return Err(PdfError::Write("the written PDF has no catalog".into())) };
if crate::lab_spot::find(pdf.get(root..root_end).unwrap_or_default(), b"/OutputIntents", 0).is_some() {
warnings.push("the file has an output intent already: the one asked for is left out".into());
} else {
let mut dict = format!("<</Type/OutputIntent/S/GTS_PDFX/OutputConditionIdentifier{}", text_string(id));
for (key, v) in [("OutputCondition", condition), ("RegistryName", registry), ("Info", info)] {
if !v.is_empty() {
dict.push_str(&format!("/{key}{}", text_string(v)));
}
}
if let Some((name, n)) = profile {
let icc = cms::icc_bytes(name).map_err(|e| PdfError::Write(format!("output intent profile: {e}")))?;
let data = deflate(&icc).map_err(|e| PdfError::Write(format!("output intent profile: {e}")))?;
let mut stream = format!("<</N {n}/Length {}/Filter/FlateDecode>>\nstream\n", data.len()).into_bytes();
stream.extend_from_slice(&data);
stream.extend_from_slice(b"\nendstream");
dict.push_str(&format!("/DestOutputProfile {} 0 R", patch.add_object(stream)));
}
dict.push_str(">>");
let oi = patch.add_object(dict.into_bytes());
patch.replace(root, root, format!("/OutputIntents[{oi} 0 R]").into_bytes());
}
}
if let Some(trapped) = cat.trapped {
let entry = if trapped { "/Trapped/True" } else { "/Trapped/False" };
match xref.trailer_ref(&pdf, b"/Info").and_then(|n| xref.dict(&pdf, n)) {
Some((info, end)) if crate::lab_spot::find(pdf.get(info..end).unwrap_or_default(), b"/Trapped", 0).is_none() => {
patch.replace(info, info, entry.as_bytes().to_vec());
}
Some(_) => {}
None => {
let n = patch.add_object(format!("<<{entry}>>").into_bytes());
patch.trailer_entry(format!("/Info {n} 0 R").as_bytes());
}
}
}
Ok((patch.apply(&pdf, &xref)?, warnings))
}
}
impl Dest {
/// `c` converted from `source` (see the module documentation).
fn convert(&self, source: &Cms, c: &Color, intent: Intent) -> Color {
match (self.model, c) {
(_, Color::Gray { .. }) | (Model::Cmyk, Color::Cmyk { .. }) | (Model::Rgb, Color::Rgb { .. })
if self.keep || matches!(c, Color::Gray { .. }) =>
{
*c
}
(Model::Cmyk, _) => {
// Through Lab (CMYK of another profile, Lab) or the display colour (RGB).
let [c, m, y, k] = match c {
Color::Rgb { .. } => self.cms.srgb_to_cmyk(source.display_rgb(c), intent),
_ => self.cms.lab_to_cmyk(source.lab(c), intent),
};
Color::Cmyk { c, m, y, k }
}
_ => {
let [r, g, b] = self.cms.srgb_to_rgb(source.display_rgb(c));
Color::Rgb { r, g, b }
}
}
}
}
/// The catalog entries `set` asks for (none in PDF/A files, see [`warnings`]).
fn catalog(set: &PdfSettings) -> Option<Catalog> {
let o = &set.output;
if set.standard == Standard::PdfA2b {
return None;
}
let (name, id) = (o.output_intent.trim(), o.output_condition_id.trim());
let intent = (!name.is_empty() || !id.is_empty()).then(|| {
let profile = cms::profile(name).and_then(|p| match p.kind {
ProfileKind::Rgb => Some((p.name, 3)),
ProfileKind::Cmyk => Some((p.name, 4)),
ProfileKind::Gray => None,
});
let text = |s: &str| s.trim().chars().take(MAX_TEXT).collect::<String>();
OutputIntent {
id: text(if id.is_empty() { name } else { id }),
condition: text(&o.output_condition),
registry: text(&o.registry),
info: text(name),
profile,
}
});
let trapped = (o.trapped || intent.is_some()).then_some(o.trapped);
(intent.is_some() || trapped.is_some()).then_some(Catalog { intent, trapped })
}
/// `s` as a PDF text string: a literal string when it is printable ASCII, else UTF-16BE (with its
/// byte order mark) in hex.
fn text_string(s: &str) -> String {
if s.bytes().all(|b| (b' '..=b'~').contains(&b)) {
let mut out = String::with_capacity(s.len() + 2);
out.push('(');
for c in s.chars() {
if matches!(c, '(' | ')' | '\\') {
out.push('\\');
}
out.push(c);
}
out.push(')');
out
} else {
let mut out = String::from("<FEFF");
for u in s.encode_utf16() {
out.push_str(&format!("{u:04X}"));
}
out.push('>');
out
}
}
fn deflate(data: &[u8]) -> std::io::Result<Vec<u8>> {
use std::io::Write;
let mut e = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default());
e.write_all(data)?;
e.finish()
}
/// How colour images' pixels are converted to the destination.
pub(crate) enum Pixels {
/// To another RGB space.
Rgb(Box<ProofLut>),
/// To CMYK: the cyan, magenta and yellow plane, and the black one.
Cmyk(Box<[ProofLut; 2]>),
}
impl Pixels {
pub(crate) fn is_cmyk(&self) -> bool {
matches!(self, Self::Cmyk(_))
}
/// Convert the RGB samples of straight RGBA pixels `rgba` in place (RGB destinations).
pub(crate) fn rgb_in_place(&self, rgba: &mut [u8]) {
if let Self::Rgb(lut) = self {
for p in rgba.as_chunks_mut::<4>().0 {
let [r, g, b] = lut.apply8([p[0], p[1], p[2]]);
(p[0], p[1], p[2]) = (r, g, b);
}
}
}
/// The CMYK samples of straight RGBA pixels `rgba` (CMYK destinations).
pub(crate) fn cmyk(&self, rgba: &[u8]) -> Option<Vec<u8>> {
let Self::Cmyk(luts) = self else { return None };
let [cmy, k] = &**luts;
Some(
rgba.as_chunks::<4>()
.0
.iter()
.flat_map(|p| {
let rgb = [p[0], p[1], p[2]];
let [c, m, y] = cmy.apply8(rgb);
[c, m, y, k.apply8(rgb)[0]]
})
.collect(),
)
}
}

183
crates/pdf/src/patch.rs Normal file
View File

@ -0,0 +1,183 @@
//! Edits to a PDF the export wrote (uncompressed objects, one cross-reference table starting at
//! object 0): bytes replaced or inserted inside objects, new objects and trailer entries. The
//! cross-reference table is written again to match.
use crate::PdfError;
use crate::lab_spot::{find, rfind, xref_offset};
/// The cross-reference table of a written PDF and its trailer.
pub(crate) struct Xref {
/// Where the table (`xref`) starts.
at: usize,
/// Where its first 20-byte entry starts.
entries: usize,
/// Number of entries (the trailer's `/Size`).
count: usize,
/// The trailer dictionary, `<<` to `>>` included.
trailer: (usize, usize),
}
/// Most bytes of a number in PDF syntax read here.
const MAX_DIGITS: usize = 10;
/// The unsigned number `pdf` holds at `at` (after white space), and where it ends.
fn number(pdf: &[u8], mut at: usize) -> Option<(usize, usize)> {
while pdf.get(at).is_some_and(u8::is_ascii_whitespace) {
at += 1;
}
let digits = pdf.get(at..)?.iter().take(MAX_DIGITS + 1).take_while(|b| b.is_ascii_digit()).count();
if digits == 0 || digits > MAX_DIGITS {
return None;
}
Some((std::str::from_utf8(pdf.get(at..at + digits)?).ok()?.parse().ok()?, at + digits))
}
impl Xref {
/// The table `startxref` points at; `None` for files written another way.
pub fn read(pdf: &[u8]) -> Option<Self> {
let at = xref_offset(pdf)?;
let (first, after) = number(pdf, at + 4)?;
let (count, after) = number(pdf, after)?;
let entries = find(pdf, b"\n", after)? + 1;
let table_end = entries.checked_add(count.checked_mul(20)?)?;
if first != 0 || count == 0 || table_end > pdf.len() {
return None;
}
let start = find(pdf, b"<<", find(pdf, b"trailer", table_end)?)?;
let end = rfind(pdf.get(start..rfind(pdf, b"startxref")?)?, b">>")? + start + 2;
Some(Self { at, entries, count, trailer: (start, end) })
}
/// Where in-use object `n` starts.
fn offset(&self, pdf: &[u8], n: u32) -> Option<usize> {
let e = self.entries + 20 * usize::try_from(n).ok().filter(|n| *n < self.count)?;
let entry = pdf.get(e..e + 20)?;
(entry.get(17) == Some(&b'n')).then_some(())?;
number(entry, 0).map(|(off, _)| off)
}
/// The object the trailer's `key` (`b"/Root"`) refers to.
pub fn trailer_ref(&self, pdf: &[u8], key: &[u8]) -> Option<u32> {
let dict = pdf.get(self.trailer.0..self.trailer.1)?;
let at = find(dict, key, 0)? + key.len();
// A key that only starts with `key` (`/Info` in `/InfoX`) isn't it.
if dict.get(at).is_some_and(|b| b.is_ascii_alphanumeric()) {
return None;
}
u32::try_from(number(dict, at)?.0).ok()
}
/// The contents of the dictionary object `n` is (just after its `<<`, up to its `endobj`).
pub fn dict(&self, pdf: &[u8], n: u32) -> Option<(usize, usize)> {
let off = self.offset(pdf, n)?;
let (num, after) = number(pdf, off)?;
let head = pdf.get(after..after + 6)?;
if num != n as usize || head != b" 0 obj" {
return None;
}
let mut at = after + 6;
while pdf.get(at).is_some_and(u8::is_ascii_whitespace) {
at += 1;
}
(pdf.get(at..at + 2)? == b"<<").then_some(())?;
Some((at + 2, find(pdf, b"endobj", at)?))
}
}
/// A replacement of bytes `start..end` of the file.
type Edit = (usize, usize, Vec<u8>);
/// The edits of one PDF, applied together by [`Self::apply`].
pub(crate) struct Patch {
edits: Vec<Edit>,
/// New objects' bodies, numbered from the table's size on.
objects: Vec<Vec<u8>>,
/// Entries added to the trailer dictionary.
trailer: Vec<u8>,
size: usize,
}
impl Patch {
pub fn new(xref: &Xref) -> Self {
Self { edits: vec![], objects: vec![], trailer: vec![], size: xref.count }
}
/// Replace bytes `start..end` (inside the objects) with `with`; `start == end` inserts.
pub fn replace(&mut self, start: usize, end: usize, with: Vec<u8>) {
self.edits.push((start, end, with));
}
/// Add an object of `body` (a dictionary, array or stream with its dictionary) → its number.
pub fn add_object(&mut self, body: Vec<u8>) -> u32 {
self.objects.push(body);
u32::try_from(self.size + self.objects.len() - 1).unwrap_or(u32::MAX)
}
/// Add `entry` (`/Info 12 0 R`) to the trailer dictionary.
pub fn trailer_entry(&mut self, entry: &[u8]) {
self.trailer.extend_from_slice(entry);
}
/// `pdf` with the edits, new objects and trailer entries, and a cross-reference table that
/// matches. Refused when an edit overlaps another or reaches past the objects.
pub fn apply(mut self, pdf: &[u8], xref: &Xref) -> Result<Vec<u8>, PdfError> {
let bad = |why: &str| PdfError::Write(format!("can't edit the written PDF: {why}"));
self.edits.sort_by_key(|e| (e.0, e.1));
if self.edits.windows(2).any(|w| w[0].1 > w[1].0) || self.edits.iter().any(|e| e.0 > e.1 || e.1 > xref.at) {
return Err(bad("edits overlap"));
}
// Where a byte offset of the old file lands in the new one.
let edits = &self.edits;
let moved = |off: usize| -> usize {
edits.iter().filter(|e| e.1 <= off).fold(off as isize, |o, e| o + e.2.len() as isize - (e.1 - e.0) as isize) as usize
};
let added: usize = self.edits.iter().map(|e| e.2.len()).sum::<usize>() + self.objects.iter().map(|o| o.len() + 32).sum::<usize>();
let mut out = Vec::with_capacity(pdf.len() + added + 64);
let mut at = 0;
for (start, end, new) in edits.iter() {
out.extend_from_slice(pdf.get(at..*start).ok_or_else(|| bad("bad edit"))?);
out.extend_from_slice(new);
at = *end;
}
out.extend_from_slice(pdf.get(at..xref.at).ok_or_else(|| bad("bad edit"))?);
let mut offsets = Vec::with_capacity(self.objects.len());
for (i, body) in self.objects.iter().enumerate() {
offsets.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n", self.size + i).as_bytes());
out.extend_from_slice(body);
out.extend_from_slice(b"\nendobj\n");
}
// The table: the old entries moved (free ones as they are), then the new objects'.
let table = out.len();
let size = self.size + self.objects.len();
out.extend_from_slice(format!("xref\n0 {size}\n").as_bytes());
let old = pdf.get(xref.entries..xref.entries + 20 * xref.count).ok_or_else(|| bad("bad cross-reference table"))?;
for e in old.chunks(20) {
match number(e, 0) {
Some((off, _)) if e.get(17) == Some(&b'n') => {
out.extend_from_slice(format!("{:010}", moved(off)).as_bytes());
out.extend_from_slice(e.get(10..).unwrap_or_default());
}
_ => out.extend_from_slice(e),
}
}
let eol = old.get(18..20).unwrap_or(b"\r\n");
for off in offsets {
out.extend_from_slice(format!("{off:010} 00000 n").as_bytes());
out.extend_from_slice(eol);
}
// The trailer, with its size and the new entries.
let (ts, te) = xref.trailer;
let dict = pdf.get(ts..te.saturating_sub(2)).ok_or_else(|| bad("bad trailer"))?;
let size_at = find(dict, b"/Size", 0).ok_or_else(|| bad("the trailer has no size"))? + 5;
let (_, size_end) = number(dict, size_at).ok_or_else(|| bad("the trailer has no size"))?;
let (Some(head), Some(tail)) = (dict.get(..size_at), dict.get(size_end..)) else { return Err(bad("bad trailer")) };
out.extend_from_slice(b"trailer\n");
out.extend_from_slice(head);
out.extend_from_slice(format!(" {size}").as_bytes());
out.extend_from_slice(tail);
out.extend_from_slice(&self.trailer);
out.extend_from_slice(format!(">>\nstartxref\n{table}\n%%EOF").as_bytes());
Ok(out)
}
}

View File

@ -397,6 +397,7 @@ impl PdfSettings {
if !self.standard.supported() {
return Err(PdfError::Unsupported(format!("{} output is not supported yet", self.standard.label())));
}
crate::output::check(self)?;
self.check_values()
}
@ -432,22 +433,11 @@ impl PdfSettings {
/// Options that are accepted but not applied by the writer yet, one warning each.
pub fn warnings(&self) -> Vec<String> {
let d = Self::default();
let o = &self.output;
let s = &self.security;
[
(self.thumbnails, "page thumbnails are not embedded yet"),
(self.fast_web_view, "fast web view (a linearised file) is not written yet"),
(self.create_layers, "PDF layers are not written yet: every layer is plain page content"),
(o.conversion != ColorConversion::None, "colour conversion is not applied yet: colours are written as they are"),
(o.profiles != ProfileInclusion::None, "ICC profiles are not embedded yet"),
(
!o.output_intent.is_empty()
|| !o.output_condition.is_empty()
|| !o.output_condition_id.is_empty()
|| !o.registry.is_empty()
|| o.trapped,
"output intent and trapped entries are not written yet",
),
(!self.advanced.outline_text, "text is exported as outlines: real, selectable text is not written yet"),
(
!s.protected()
@ -463,6 +453,7 @@ impl PdfSettings {
.filter(|(on, _)| *on)
.map(|(_, w)| w.to_string())
.chain(crate::encrypt::warnings(self))
.chain(crate::output::warnings(self))
.collect()
}
}

View File

@ -0,0 +1,314 @@
//! Save PDF › Output: colours converted to the destination (or their numbers preserved), ICC
//! profiles tagging them, images converted too, and the output intent and Trapped entries.
use std::io::Cursor;
use std::sync::Arc;
use hayro_syntax::Pdf;
use hayro_syntax::object::{Array, Dict, Name, ObjectIdentifier, Stream};
use serde_json::{Value, json};
use vectorcraft_color::cms::{self, Cms, ColorSettings, DEVICE_CMYK, GENERIC_CMYK, SRGB};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{Appearance, ColorMode, Document, ImageBlob, ImageObject, Node, NodeKind};
use vectorcraft_geom::{Affine, Rect, shapes};
use crate::*;
/// A document of `mode` with a rectangle filled with each of `fills`, side by side.
fn doc(mode: ColorMode, fills: &[Color]) -> Document {
let mut d = Document::new(200.0, 100.0);
d.color_mode = mode;
let l = d.layers[0].id;
for (i, c) in fills.iter().enumerate() {
let x = 10.0 + 30.0 * i as f64;
let n =
Node::path(d.alloc_id(), shapes::rectangle(Rect::new(x, 10.0, x + 20.0, 30.0)), Appearance::basic(Paint::solid(*c), Paint::None, 0.0));
d.insert(Some(l), i, n).unwrap();
}
d
}
/// The export of `d` with `settings` (content streams left readable).
fn pdf(d: &Document, settings: Value) -> ExportReport {
let mut settings: PdfSettings = serde_json::from_value(settings).unwrap();
settings.compression.compress_text = false;
export_with_report(d, &PdfOptions { settings, ..Default::default() }).unwrap()
}
/// The fill colours the first page's content sets: (operator, operands).
fn colours(pdf: &[u8]) -> Vec<(String, Vec<f32>)> {
let file = Pdf::new(Arc::new(pdf.to_vec())).unwrap();
let pages = file.pages();
let content = String::from_utf8_lossy(pages.iter().next().unwrap().page_stream().unwrap()).into_owned();
let (mut nums, mut out) = (vec![], vec![]);
for t in content.split_whitespace() {
if let Ok(v) = t.parse::<f32>() {
nums.push(v);
continue;
}
if ["rg", "k", "g", "scn"].contains(&t) {
out.push((t.to_string(), nums.clone()));
}
nums.clear();
}
out
}
/// `v` as the writer stores it (8 bits a component).
fn q8(v: &[f32]) -> Vec<f32> {
v.iter().map(|v| (v.clamp(0.0, 1.0) * 255.0).round() / 255.0).collect()
}
fn close(a: &[f32], b: &[f32]) -> bool {
a.len() == b.len() && a.iter().zip(b).all(|(a, b)| (a - b).abs() < 1e-3)
}
/// The colour management of a CMYK destination, as the export builds it.
fn to_cmyk(dest: &str) -> Cms {
Cms::new(&ColorSettings { cmyk: dest.into(), ..cms::active_settings() }).unwrap()
}
/// The ICC profiles the ICC-based colour spaces of `pdf` use: (components, profile bytes).
fn icc_profiles(pdf: &[u8]) -> Vec<(i32, Vec<u8>)> {
let file = Pdf::new(Arc::new(pdf.to_vec())).unwrap();
let text = String::from_utf8_lossy(pdf);
let mut out = vec![];
for (at, _) in text.match_indices("[/ICCBased ") {
let n: i32 = text[at + 11..].split(' ').next().unwrap().parse().unwrap();
let s = file.xref().get::<Stream<'_>>(ObjectIdentifier::new(n, 0)).unwrap();
let entry = (s.dict().get::<i32>(b"N").unwrap(), s.decoded().unwrap().into_owned());
if !out.contains(&entry) {
out.push(entry);
}
}
out
}
#[test]
fn converting_to_a_cmyk_destination_writes_k_instead_of_rg() {
let red = Color::rgb(1.0, 0.0, 0.0);
let d = doc(ColorMode::Rgb, &[red]);
assert_eq!(colours(&pdf(&d, json!({})).bytes), [("rg".to_string(), vec![1.0, 0.0, 0.0])], "without conversion");
let ink = q8(&to_cmyk(GENERIC_CMYK).srgb_to_cmyk([1.0, 0.0, 0.0], cms::active_settings().intent));
for conversion in ["destination", "preserveNumbers"] {
let r = pdf(&d, json!({"output": {"conversion": conversion, "destination": GENERIC_CMYK}}));
let c = colours(&r.bytes);
assert!(c.len() == 1 && c[0].0 == "k" && close(&c[0].1, &ink), "{conversion}: {c:?} vs {ink:?}");
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
}
// A blank destination is the document's profile: the RGB document's colours stay RGB.
assert_eq!(colours(&pdf(&d, json!({"output": {"conversion": "destination"}})).bytes)[0].0, "rg");
}
#[test]
fn preserve_numbers_keeps_cmyk_values_that_destination_converts() {
let ink = Color::cmyk(0.1, 0.2, 0.3, 0.4);
let d = doc(ColorMode::Rgb, &[ink, Color::Gray { k: 0.25 }]);
let numbers = q8(&[0.1, 0.2, 0.3, 0.4]);
let fills = |conversion: &str, dest: &str| colours(&pdf(&d, json!({"output": {"conversion": conversion, "destination": dest}})).bytes);
let kept = fills("preserveNumbers", DEVICE_CMYK);
assert!(kept[0].0 == "k" && close(&kept[0].1, &numbers), "{kept:?}");
// Grey stays grey.
assert!(kept[1].0 == "g" && close(&kept[1].1, &q8(&[0.75])), "{kept:?}");
// Convert to destination: CMYK of another profile goes through Lab into the destination.
let converted = fills("destination", DEVICE_CMYK);
let expected = q8(&to_cmyk(DEVICE_CMYK).lab_to_cmyk(cms::active().lab(&ink), cms::active_settings().intent));
assert!(converted[0].0 == "k" && close(&converted[0].1, &expected), "{converted:?} vs {expected:?}");
assert!(converted[0].1.iter().zip(&numbers).any(|(a, b)| (a - b).abs() > 0.02), "the numbers change: {converted:?}");
// …unless the destination is their own profile.
let own = fills("destination", &cms::active_settings().cmyk);
assert!(close(&own[0].1, &numbers), "{own:?}");
}
#[test]
fn converting_to_rgb_turns_cmyk_into_rg() {
let ink = Color::cmyk(0.0, 1.0, 1.0, 0.0);
let d = doc(ColorMode::Cmyk, &[ink]);
assert_eq!(colours(&pdf(&d, json!({})).bytes)[0].0, "k");
let c = colours(&pdf(&d, json!({"output": {"conversion": "preserveNumbers", "destination": SRGB}})).bytes);
assert!(c[0].0 == "rg" && close(&c[0].1, &q8(&cms::active().display_rgb(&ink))), "{c:?}");
}
#[test]
fn an_unknown_destination_is_refused() {
let d = doc(ColorMode::Rgb, &[Color::BLACK]);
let set: PdfSettings = serde_json::from_value(json!({"output": {"conversion": "destination", "destination": "No Such Press"}})).unwrap();
assert!(matches!(set.check(), Err(PdfError::BadSetting(m)) if m.contains("No Such Press")));
assert!(export(&d, &PdfOptions { settings: set, ..Default::default() }).is_err());
// Not converting, the destination doesn't matter.
let set: PdfSettings = serde_json::from_value(json!({"output": {"destination": "No Such Press"}})).unwrap();
assert_eq!(set.check(), Ok(()));
}
#[test]
fn profiles_tag_colours_with_icc_based_spaces() {
let d = doc(ColorMode::Rgb, &[Color::rgb(0.2, 0.4, 0.6), Color::cmyk(0.1, 0.2, 0.3, 0.4), Color::Gray { k: 0.5 }]);
let none = pdf(&d, json!({}));
assert!(icc_profiles(&none.bytes).is_empty());
assert!(colours(&none.bytes).iter().all(|c| c.0 != "scn"));
let all = pdf(&d, json!({"output": {"profiles": "all"}}));
let profiles = icc_profiles(&all.bytes);
let mut n: Vec<i32> = profiles.iter().map(|p| p.0).collect();
n.sort();
assert_eq!(n, [1, 3, 4], "grey, RGB and CMYK spaces");
assert!(profiles.iter().any(|p| p.0 == 4 && p.1 == *cms::icc_bytes(&cms::active_settings().cmyk).unwrap()), "the working CMYK profile");
assert!(colours(&all.bytes).iter().all(|c| c.0 == "scn"), "{:?}", colours(&all.bytes));
// The destination profile tags converted colours.
let dest = pdf(&d, json!({"output": {"conversion": "destination", "destination": DEVICE_CMYK, "profiles": "destination"}}));
let profiles = icc_profiles(&dest.bytes);
assert!(profiles.iter().any(|p| p.0 == 4 && p.1 == *cms::icc_bytes(DEVICE_CMYK).unwrap()), "the destination");
// Tagged source profiles: only a document that was assigned profiles is tagged.
assert!(icc_profiles(&pdf(&d, json!({"output": {"profiles": "taggedSource"}})).bytes).is_empty());
let mut tagged = d.clone();
tagged.color_profiles.cmyk = Some(GENERIC_CMYK.into());
assert_eq!(icc_profiles(&pdf(&tagged, json!({"output": {"profiles": "taggedSource"}})).bytes).len(), 3);
}
#[test]
fn tagged_cmyk_documents_blend_in_the_cmyk_profile_space() {
let mut d = doc(ColorMode::Cmyk, &[Color::cmyk(1.0, 0.0, 0.0, 0.0)]);
let mut n = Node::path(
d.alloc_id(),
shapes::rectangle(Rect::new(20.0, 20.0, 60.0, 60.0)),
Appearance::basic(Paint::solid(Color::cmyk(0.0, 1.0, 0.0, 0.0)), Paint::None, 0.0),
);
n.opacity = 0.5;
let l = d.layers[0].id;
d.insert(Some(l), 1, n).unwrap();
let r = pdf(&d, json!({"output": {"profiles": "all"}}));
let text = String::from_utf8_lossy(&r.bytes);
let group = text.split("/Group<<").nth(1).unwrap();
let cs: i32 = group.split("/CS ").nth(1).unwrap().split(' ').next().unwrap().parse().unwrap();
let space = text.split(&format!("\n{cs} 0 obj\n")).nth(1).unwrap();
let stream: i32 = space.strip_prefix("[/ICCBased ").unwrap().split(' ').next().unwrap().parse().unwrap();
let file = Pdf::new(Arc::new(r.bytes.clone())).unwrap();
assert_eq!(file.xref().get::<Stream<'_>>(ObjectIdentifier::new(stream, 0)).unwrap().dict().get::<i32>(b"N"), Some(4), "{group}");
}
/// A 4 × 4 PNG of one colour.
fn png(rgb: [u8; 3]) -> Vec<u8> {
let mut out = Vec::new();
image::DynamicImage::from(image::RgbImage::from_pixel(4, 4, image::Rgb(rgb)))
.write_to(&mut Cursor::new(&mut out), image::ImageFormat::Png)
.unwrap();
out
}
fn image_doc(bytes: Vec<u8>) -> Document {
let mut d = Document::new(100.0, 100.0);
d.images.insert("img".into(), ImageBlob::new("image/png", bytes));
let image = ImageObject { key: "img".into(), width: 4, height: 4, xf: Affine::scale(10.0), link: None, placement: Default::default() };
let n = Node::new(d.alloc_id(), NodeKind::Image(image));
let l = d.layers[0].id;
d.insert(Some(l), 0, n).unwrap();
d
}
/// The colour of the first image of the PDF `bytes` imports as, at its first pixel.
fn first_pixel(bytes: &[u8]) -> [u8; 3] {
let back = import(bytes).unwrap();
let blob = back.images.values().next().unwrap();
image::load_from_memory(&blob.bytes).unwrap().to_rgb8().get_pixel(0, 0).0
}
#[test]
fn images_are_converted_too() {
let d = image_doc(png([200, 40, 30]));
let plain = pdf(&d, json!({}));
assert!(String::from_utf8_lossy(&plain.bytes).contains("/ColorSpace/DeviceRGB"));
let r = pdf(&d, json!({"output": {"conversion": "preserveNumbers", "destination": GENERIC_CMYK}}));
assert!(String::from_utf8_lossy(&r.bytes).contains("/ColorSpace/DeviceCMYK"), "a CMYK image");
// CMYK images aren't written as JPEG.
let jpeg =
pdf(&d, json!({"compression": {"color": {"compression": "jpeg"}}, "output": {"conversion": "destination", "destination": GENERIC_CMYK}}));
assert!(!String::from_utf8_lossy(&jpeg.bytes).contains("/DCTDecode"));
assert!(jpeg.warnings.iter().any(|w| w.contains("ZIP")), "{:?}", jpeg.warnings);
// An RGB destination other than sRGB changes the pixels (unless their numbers are kept).
assert_eq!(first_pixel(&plain.bytes), [200, 40, 30]);
let p3 = |conversion: &str| first_pixel(&pdf(&d, json!({"output": {"conversion": conversion, "destination": cms::DISPLAY_P3}})).bytes);
assert_ne!(p3("destination"), [200, 40, 30]);
assert_eq!(p3("preserveNumbers"), [200, 40, 30]);
// Grey images stay grey.
let grey = image_doc(png([90, 90, 90]));
let r = pdf(&grey, json!({"output": {"conversion": "destination", "destination": GENERIC_CMYK}}));
assert!(String::from_utf8_lossy(&r.bytes).contains("/ColorSpace/DeviceGray"));
}
#[test]
fn printers_marks_keep_their_inks() {
let d = doc(ColorMode::Cmyk, &[Color::cmyk(0.0, 0.0, 1.0, 0.0)]);
let r = pdf(&d, json!({"marks": {"colorBars": true}, "output": {"conversion": "destination", "destination": SRGB}}));
let ops: Vec<String> = colours(&r.bytes).into_iter().map(|c| c.0).collect();
assert!(ops.contains(&"rg".to_string()) && ops.contains(&"k".to_string()), "art converted, colour bars kept: {ops:?}");
}
/// The catalog's output intent of `bytes` (opened with `password`).
fn output_intent(bytes: &[u8], password: Option<&str>, f: impl FnOnce(&Dict<'_>)) {
let file = crate::pages::open(bytes, password).unwrap();
let xref = file.xref();
let catalog = xref.get::<Dict<'_>>(xref.root_id()).unwrap();
let intents = catalog.get::<Array<'_>>(b"OutputIntents").unwrap();
f(&intents.iter::<Dict<'_>>().next().unwrap());
}
#[test]
fn the_output_intent_embeds_its_profile_and_trapped_is_written() {
let mut d = doc(ColorMode::Cmyk, &[Color::cmyk(0.0, 0.0, 1.0, 0.0)]);
let out = json!({"outputIntent": GENERIC_CMYK, "outputCondition": "Coated (press)", "outputConditionId": "Généric", "registry": "http://example.com", "trapped": true});
for (title, password) in [("Poster", None), ("", None), ("Poster", Some("secret"))] {
d.title = title.into();
let mut set = json!({"output": out});
if let Some(p) = password {
set["security"] = json!({"openPassword": p});
}
let r = pdf(&d, set);
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
output_intent(&r.bytes, password, |oi| {
assert_eq!(oi.get::<Name<'_>>(b"S").unwrap().as_ref(), b"GTS_PDFX");
let text = |k: &[u8]| oi.get::<hayro_syntax::object::String<'_>>(k).map(|s| s.as_bytes().to_vec());
assert_eq!(text(b"OutputCondition").unwrap(), b"Coated (press)");
assert_eq!(text(b"RegistryName").unwrap(), b"http://example.com");
// Non-ASCII text is UTF-16 with its byte order mark.
let id: Vec<u8> = [0xFE, 0xFF].into_iter().chain("Généric".encode_utf16().flat_map(u16::to_be_bytes)).collect();
assert_eq!(text(b"OutputConditionIdentifier").unwrap(), id);
let profile = oi.get::<Stream<'_>>(b"DestOutputProfile").unwrap();
assert_eq!(profile.dict().get::<i32>(b"N"), Some(4));
assert_eq!(profile.decoded().unwrap().as_ref(), &*cms::icc_bytes(GENERIC_CMYK).unwrap());
});
if password.is_none() {
assert!(String::from_utf8_lossy(&r.bytes).contains("/Trapped/True"), "title {title:?}");
}
assert_eq!(
import_with_report(&r.bytes, &ImportOptions { password: password.map(str::to_string), ..Default::default() })
.unwrap()
.document
.artboards
.len(),
1
);
}
// A condition identifier alone (a registered printing condition) names no profile; untrapped
// output says so.
let r = pdf(&d, json!({"output": {"outputConditionId": "CGATS TR 001"}}));
output_intent(&r.bytes, None, |oi| assert!(oi.get::<Stream<'_>>(b"DestOutputProfile").is_none()));
assert!(String::from_utf8_lossy(&r.bytes).contains("/Trapped/False"));
// Nothing asked, nothing written.
let plain = String::from_utf8_lossy(&pdf(&d, json!({})).bytes).into_owned();
assert!(!plain.contains("/OutputIntents") && !plain.contains("/Trapped"));
}
#[test]
fn pdf_a_keeps_its_own_output_intent() {
let d = doc(ColorMode::Rgb, &[Color::BLACK]);
let r = pdf(&d, json!({"standard": "pdfA2b", "output": {"outputIntent": GENERIC_CMYK, "trapped": true}}));
let text = String::from_utf8_lossy(&r.bytes);
assert!(text.contains("GTS_PDFA1") && !text.contains("GTS_PDFX") && !text.contains("/Trapped"));
assert!(r.warnings.iter().any(|w| w.contains("PDF/A")), "{:?}", r.warnings);
}
#[test]
fn pdf_a_tags_cmyk_colours_with_the_cmyk_profile() {
let d = doc(ColorMode::Cmyk, &[Color::cmyk(0.1, 0.2, 0.3, 0.4)]);
let r = pdf(&d, json!({"standard": "pdfA2b"}));
assert!(icc_profiles(&r.bytes).iter().any(|p| p.0 == 4));
}

View File

@ -76,18 +76,23 @@ fn options_not_applied_yet_come_back_as_warnings() {
(json!({"thumbnails": true}), "thumbnails"),
(json!({"fastWebView": true}), "fast web view"),
(json!({"createLayers": true}), "layers"),
(json!({"output": {"conversion": "destination"}}), "conversion"),
(json!({"output": {"profiles": "all"}}), "ICC"),
(json!({"output": {"trapped": true}}), "trapped"),
(json!({"advanced": {"outlineText": false}}), "outlines"),
(json!({"output": {"outputIntent": "No Such Press"}}), "without embedding"),
(json!({"output": {"registry": "http://example.com"}}), "condition identifier"),
(json!({"standard": "pdfA2b", "output": {"trapped": true}}), "PDF/A"),
(json!({"security": {"printing": "low"}}), "permissions"),
] {
let w = settings(v.clone()).warnings();
assert!(w.len() == 1 && w[0].contains(word), "{v}: {w:?}");
}
// Bleed and marks, the default view/overprint choices and applied options (image compression
// too: a codec the writer lacks is reported when an image needs it) warn about nothing.
// too: a codec the writer lacks is reported when an image needs it; colour output)
// warn about nothing.
for v in [
json!({"output": {"conversion": "destination", "destination": vectorcraft_color::cms::GENERIC_CMYK, "profiles": "all"}}),
json!({"output": {"conversion": "preserveNumbers", "profiles": "taggedSource"}}),
json!({"output": {"outputIntent": vectorcraft_color::cms::GENERIC_CMYK, "outputCondition": "Press", "registry": "r", "trapped": true}}),
json!({"output": {"outputConditionId": "CGATS TR 001"}}),
json!({"bleed": {"useDocument": true, "top": 9}}),
json!({"bleed": {"top": 9}, "marks": {"trim": true, "registration": true, "colorBars": true, "pageInfo": true}}),
json!({"includeNonPrinting": true}),

View File

@ -55,6 +55,8 @@ const WEIGHT_LABELS: [&str; 3] = ["0.125 pt", "0.25 pt", "0.5 pt"];
/// The Destination entry for the document's own profile (stored as "").
const DOCUMENT_PROFILE: &str = "Document profile";
/// The Output Intent Profile entry for no output intent (stored as "").
const NO_PROFILE: &str = "None";
const LABEL_WIDTH: f32 = 150.0;
/// Width of the section list (its frame adds 6 px a side).
@ -523,38 +525,44 @@ fn marks_and_bleeds(app: &VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
});
}
/// A dropdown of the colour profiles (`names`, the first one standing for "" at `path`), showing
/// the profile at `path` (a name that isn't one of them too).
fn profile_pick(ui: &mut egui::Ui, d: &mut Dialog, path: &str, names: &[&str], enabled: bool) {
let blank = names.first().copied().unwrap_or_default();
let current = get(d, path).as_str().filter(|s| !s.is_empty()).unwrap_or(blank).to_string();
ui.add_enabled_ui(enabled, |ui| {
if let Some(i) = widgets::dropdown(ui, path, &current, names, 300.0) {
set(d, path, json!(if i == 0 { "" } else { names.get(i).copied().unwrap_or_default() }));
}
});
}
fn output(ui: &mut egui::Ui, d: &mut Dialog) {
let profiles = vectorcraft_color::cms::profiles();
let names = |blank: &'static str| -> Vec<&str> { std::iter::once(blank).chain(profiles.iter().map(|p| p.name.as_str())).collect() };
heading(ui, "Color");
row(ui, "Color conversion:", |ui| {
pick::<ColorConversion>(ui, d, "output.conversion", 300.0, |_| true);
});
let converting = get(d, "output.conversion") != ColorConversion::None.id();
row(ui, "Destination:", |ui| {
let profiles = vectorcraft_color::cms::profiles();
let names: Vec<&str> = std::iter::once(DOCUMENT_PROFILE).chain(profiles.iter().map(|p| p.name.as_str())).collect();
let current = get(d, "output.destination").as_str().filter(|s| !s.is_empty()).unwrap_or(DOCUMENT_PROFILE).to_string();
ui.add_enabled_ui(converting, |ui| {
if let Some(i) = widgets::dropdown(ui, "output.destination", &current, &names, 300.0) {
set(d, "output.destination", json!(if i == 0 { "" } else { names.get(i).copied().unwrap_or_default() }));
}
});
});
row(ui, "Destination:", |ui| profile_pick(ui, d, "output.destination", &names(DOCUMENT_PROFILE), converting));
row(ui, "Profile inclusion:", |ui| {
pick::<ProfileInclusion>(ui, d, "output.profiles", 300.0, |_| true);
});
heading(ui, "PDF/X");
let pdfx = matches!(choice::<Standard>(d, "standard"), Some(Standard::PdfX1a | Standard::PdfX3 | Standard::PdfX4));
heading(ui, "Output Intent");
// PDF/A files carry their own output intent.
let own = choice::<Standard>(d, "standard") != Some(Standard::PdfA2b);
row(ui, "Output intent profile:", |ui| profile_pick(ui, d, "output.outputIntent", &names(NO_PROFILE), own));
for (p, label) in [
("output.outputIntent", "Output intent profile:"),
("output.outputCondition", "Output condition:"),
("output.outputConditionId", "Condition identifier:"),
("output.registry", "Registry name:"),
] {
row(ui, label, |ui| text(ui, d, p, pdfx));
row(ui, label, |ui| text(ui, d, p, own));
}
flag(ui, d, "output.trapped", "Mark as trapped", pdfx);
if !pdfx {
note(ui, "These apply with a PDF/X standard.");
flag(ui, d, "output.trapped", "Mark as trapped", own);
if !own {
note(ui, "PDF/A files carry their own output intent.");
}
}

View File

@ -53,6 +53,8 @@ mod tests_overprint;
#[cfg(test)]
mod tests_paintchips;
#[cfg(test)]
mod tests_pdfoutput;
#[cfg(test)]
mod tests_place;
#[cfg(test)]
mod tests_recolor;

View File

@ -0,0 +1,46 @@
//! Save PDF › Output: the colour conversion, profiles and output intent chosen in the dialog
//! reach the written file, with nothing reported as not applied.
use serde_json::json;
use vectorcraft_color::cms::GENERIC_CMYK;
use crate::tests_svg::app;
use crate::{dialogs, theme};
/// One headless frame of the dialog layer.
fn frame(app: &mut crate::VectorcraftApp) {
let ctx = egui::Context::default();
theme::install_fonts(&ctx);
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| dialogs::show(app, ui.ctx()));
out.textures_delta.clear();
}
#[test]
fn output_choices_reach_the_pdf() {
let (mut app, written) = app("/tmp/unused.pdf");
app.run("text.create", json!({"x": 10, "y": 70, "text": "Proof"})).unwrap();
app.run("ui.savePdfDialog", json!({"path": "/tmp/press.pdf"})).unwrap();
let fields = &mut app.ui.dialog.as_mut().unwrap().fields;
fields.insert("__section".into(), json!("Output"));
fields.insert(
"output".into(),
json!({"conversion": "destination", "destination": GENERIC_CMYK, "profiles": "destination", "outputIntent": GENERIC_CMYK, "outputConditionId": "Proofing", "trapped": true}),
);
fields.insert("compression".into(), json!({"compressText": false}));
frame(&mut app);
// PDF/A greys the output intent out (it carries its own); a name that isn't a profile shows.
for (standard, intent) in [("pdfA2b", GENERIC_CMYK), ("none", "Registered Press")] {
let d = app.ui.dialog.as_mut().unwrap();
d.fields.insert("standard".into(), json!(standard));
d.fields["output"]["outputIntent"] = json!(intent);
frame(&mut app);
}
let d = app.ui.dialog.as_mut().unwrap();
d.fields.insert("standard".into(), json!("none"));
d.fields["output"]["outputIntent"] = json!(GENERIC_CMYK);
frame(&mut app);
dialogs::confirm(&mut app).unwrap();
assert!(app.ui.status.starts_with("Saved /tmp/press.pdf") && !app.ui.status.contains("note"), "{}", app.ui.status);
let pdf = String::from_utf8_lossy(&written.borrow()[0].1).into_owned();
assert!(pdf.contains("/S/GTS_PDFX") && pdf.contains("/Trapped/True") && pdf.contains("/ICCBased"));
}

View File

@ -799,6 +799,20 @@ the colour bars are process, spot and black-tint patches. Layers whose Print opt
{"name":"run_command","arguments":{"command":"document.exportPdf","params":{"path":"/tmp/press.pdf","preset":"Press Quality","marks":{"trim":true,"registration":true,"colorBars":true,"pageInfo":true}}}}
```
The Output section converts colours. `output: {conversion, destination}` converts
every colour into the destination profile's model (`destination`; CMYK of another profile too) or only the colours of
the other model (`preserveNumbers`: colours already in it keep their numbers); grey stays grey, images are converted
too and printer's marks are not. The destination is any profile `edit.colorSettings` lists (default: the document's
for its colour mode). `profiles` (`all`, `destination`, or `taggedSource` for a document assigned profiles with
`edit.assignProfile`) writes colours in ICC-based spaces with their profiles embedded: CMYK with the destination's or
the document's CMYK profile, RGB as sRGB, grey with the sRGB tone curve. `outputIntent` embeds a profile as the file's
`/GTS_PDFX` output intent with `outputCondition`, `outputConditionId` and `registry`, and `trapped` sets `/Trapped`
(PDF/A files keep their own output intent).
```json
{"name":"run_command","arguments":{"command":"document.exportPdf","params":{"path":"/tmp/press.pdf","output":{"conversion":"preserveNumbers","destination":"VectorCraft Generic CMYK (SWOP-like)","profiles":"destination","outputIntent":"VectorCraft Generic CMYK (SWOP-like)","trapped":true}}}}
```
## Scale Strokes & Effects
`object.scale`, `object.transform` and `object.transformEach` take `strokes?: bool` (Scale Strokes & Effects) and