diff --git a/Cargo.lock b/Cargo.lock index 3184a0be..48f66ba1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4397,6 +4397,7 @@ name = "vectorcraft-pdf" version = "0.2.0" dependencies = [ "aes", + "flate2", "hayro-interpret", "hayro-syntax", "image", diff --git a/crates/engine/src/cmd/fileio/mod.rs b/crates/engine/src/cmd/fileio/mod.rs index d73629cc..5cda41e5 100644 --- a/crates/engine/src/cmd/fileio/mod.rs +++ b/crates/engine/src/cmd/fileio/mod.rs @@ -893,6 +893,8 @@ mod tests_dxfimport; #[cfg(test)] mod tests_metafile; +#[cfg(test)] +mod tests_pdfoutput; #[cfg(test)] mod tests_tiffbmp; diff --git a/crates/engine/src/cmd/fileio/pdf.rs b/crates/engine/src/cmd/fileio/pdf.rs index 6aaf8739..e0759667 100644 --- a/crates/engine/src/cmd/fileio/pdf.rs +++ b/crates/engine/src/cmd/fileio/pdf.rs @@ -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 { "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 ), diff --git a/crates/engine/src/cmd/fileio/tests_pdfoutput.rs b/crates/engine/src/cmd/fileio/tests_pdfoutput.rs new file mode 100644 index 00000000..ac0937b7 --- /dev/null +++ b/crates/engine/src/cmd/fileio/tests_pdfoutput.rs @@ -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:?}"); +} diff --git a/crates/pdf/Cargo.toml b/crates/pdf/Cargo.toml index ebf9c123..f4fd2da9 100644 --- a/crates/pdf/Cargo.toml +++ b/crates/pdf/Cargo.toml @@ -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" diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index d38e043b..4b97812e 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -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 Result, + /// 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, cmyk: bool) -> Result { + 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, Vec), 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, Vec), 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, + /// How colours are written (the Output settings; print writes them as they are). + pub out: Arc, + /// 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 = 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); } diff --git a/crates/pdf/src/images.rs b/crates/pdf/src/images.rs index 43a309d9..9f22ad9d 100644 --- a/crates/pdf/src/images.rs +++ b/crates/pdf/src/images.rs @@ -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>, alpha: Option>>, 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 { + 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 { 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, width: u32, height: u32, interpolate: bool, pixels: Option<&Pixels>) -> Option { + 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 { - 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 { + 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() diff --git a/crates/pdf/src/lab_spot.rs b/crates/pdf/src/lab_spot.rs index 40f36fb4..e21ea5da 100644 --- a/crates/pdf/src/lab_spot.rs +++ b/crates/pdf/src/lab_spot.rs @@ -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 { let mut out = vec![b'/']; @@ -53,8 +55,9 @@ pub(crate) fn find(hay: &[u8], needle: &[u8], from: usize) -> Option { /// 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, spots: &[(String, Lab)]) -> Vec { - // (start, end, replacement) of each rewritten array, in file order. - let mut edits: Vec<(usize, usize, Vec)> = 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, spots: &[(String, Lab)]) -> Vec { 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::().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::().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 { diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index e040e721..a3b23c13 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -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; diff --git a/crates/pdf/src/output.rs b/crates/pdf/src/output.rs new file mode 100644 index 00000000..1b2df47c --- /dev/null +++ b/crates/pdf/src/output.rs @@ -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, + dest: Option, + /// 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, + /// The image transform, built when the first image needs it. + pixels: OnceLock>, + intent: Intent, + /// The output intent and Trapped entries to write. + catalog: Option, +} + +/// 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, + /// `/Trapped`, when it is written. + trapped: Option, +} + +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, 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 { + 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 { + 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::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) -> Result<(Vec, Vec), 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!("<>\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 { + 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::(); + 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("'); + out + } +} + +fn deflate(data: &[u8]) -> std::io::Result> { + 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), + /// 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> { + 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(), + ) + } +} diff --git a/crates/pdf/src/patch.rs b/crates/pdf/src/patch.rs new file mode 100644 index 00000000..1ce7b67d --- /dev/null +++ b/crates/pdf/src/patch.rs @@ -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 { + 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 { + 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 { + 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); + +/// The edits of one PDF, applied together by [`Self::apply`]. +pub(crate) struct Patch { + edits: Vec, + /// New objects' bodies, numbered from the table's size on. + objects: Vec>, + /// Entries added to the trailer dictionary. + trailer: Vec, + 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) { + 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) -> 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, 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::() + self.objects.iter().map(|o| o.len() + 32).sum::(); + 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) + } +} diff --git a/crates/pdf/src/settings.rs b/crates/pdf/src/settings.rs index fcaae398..2c445e8e 100644 --- a/crates/pdf/src/settings.rs +++ b/crates/pdf/src/settings.rs @@ -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 { 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() } } diff --git a/crates/pdf/src/tests_output.rs b/crates/pdf/src/tests_output.rs new file mode 100644 index 00000000..5613e2be --- /dev/null +++ b/crates/pdf/src/tests_output.rs @@ -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)> { + 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::() { + 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 { + 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)> { + 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::>(ObjectIdentifier::new(n, 0)).unwrap(); + let entry = (s.dict().get::(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 = 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::>(ObjectIdentifier::new(stream, 0)).unwrap().dict().get::(b"N"), Some(4), "{group}"); +} + +/// A 4 × 4 PNG of one colour. +fn png(rgb: [u8; 3]) -> Vec { + 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) -> 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 = 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::>(xref.root_id()).unwrap(); + let intents = catalog.get::>(b"OutputIntents").unwrap(); + f(&intents.iter::>().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::>(b"S").unwrap().as_ref(), b"GTS_PDFX"); + let text = |k: &[u8]| oi.get::>(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 = [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::>(b"DestOutputProfile").unwrap(); + assert_eq!(profile.dict().get::(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::>(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)); +} diff --git a/crates/pdf/src/tests_settings.rs b/crates/pdf/src/tests_settings.rs index 84ef0e9e..cd444522 100644 --- a/crates/pdf/src/tests_settings.rs +++ b/crates/pdf/src/tests_settings.rs @@ -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}), diff --git a/crates/ui-egui/src/dialogs/save_pdf.rs b/crates/ui-egui/src/dialogs/save_pdf.rs index 1088d03c..2db8a3d1 100644 --- a/crates/ui-egui/src/dialogs/save_pdf.rs +++ b/crates/ui-egui/src/dialogs/save_pdf.rs @@ -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, ¤t, 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::(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", ¤t, &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::(ui, d, "output.profiles", 300.0, |_| true); }); - heading(ui, "PDF/X"); - let pdfx = matches!(choice::(d, "standard"), Some(Standard::PdfX1a | Standard::PdfX3 | Standard::PdfX4)); + heading(ui, "Output Intent"); + // PDF/A files carry their own output intent. + let own = choice::(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."); } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index ff4f3596..38d21fe1 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -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; diff --git a/crates/ui-egui/src/tests_pdfoutput.rs b/crates/ui-egui/src/tests_pdfoutput.rs new file mode 100644 index 00000000..8dbba3ef --- /dev/null +++ b/crates/ui-egui/src/tests_pdfoutput.rs @@ -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")); +} diff --git a/docs/mcp.md b/docs/mcp.md index b06277f3..dbab8e2e 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -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