diff --git a/crates/engine/src/cmd/fileio/pdf.rs b/crates/engine/src/cmd/fileio/pdf.rs index 9cd51fae..abd5fcc6 100644 --- a/crates/engine/src/cmd/fileio/pdf.rs +++ b/crates/engine/src/cmd/fileio/pdf.rs @@ -53,7 +53,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?, 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}, compressText?: true}, marks?: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25, offset: 6}, bleed?: {useDocument, top, bottom, left, right} (pt), 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, passwords and PDF/A-2b at 2.0 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?, 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, offset: 6}, bleed?: {useDocument, top, bottom, left, right} (pt), 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, passwords and PDF/A-2b at 2.0 are refused. document.export {format: pdf} takes the same options", has_doc, export_pdf ), diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index 5f519edf..8f1b4e04 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -103,6 +103,7 @@ pub fn export_with_report(doc: &Document, opts: &PdfOptions) -> Result { knockout: bool, /// Spot colours written with a Lab alternate ([`crate::lab_spot`]): colorant name, Lab values. lab_spots: Vec<(String, vectorcraft_color::cms::Lab)>, - /// Images the writer samples itself may ask viewers to smooth them (not in PDF/A). + /// Images may ask viewers to smooth them (not in PDF/A). interpolate: bool, + /// How images are resampled and compressed. + compression: &'a crate::CompressionSettings, } /// Most pixels along a side of a freeform gradient's image (its colour field is smooth). @@ -1017,37 +1020,58 @@ impl Exporter<'_> { s.set_stroke(None); } - fn load_image(&mut self, key: &str) -> Option { - if let Some(i) = self.images.get(key) { + /// Image `key` placed `size` points wide and high, resampled and compressed as the Compression + /// settings say ([`crate::images::recode`]); cached by key (and size when images are + /// resampled). + fn load_image(&mut self, key: &str, size: (f64, f64)) -> Option { + let c = self.compression; + let sized = [c.color.downsample, c.gray.downsample, c.mono.downsample].iter().any(|d| *d != crate::Downsample::None); + let cache = if sized { format!("{key}\u{0}{:.3}x{:.3}", size.0, size.1) } else { key.to_string() }; + if let Some(i) = self.images.get(&cache) { return i.clone(); } - let img = self.doc.images.get(key).and_then(|blob| { - let bytes = blob.bytes.as_ref().clone(); - let direct = match blob.mime.as_str() { - "image/png" => Image::from_png(bytes.clone().into(), true).ok(), - "image/jpeg" | "image/jpg" => Image::from_jpeg(bytes.clone().into(), true).ok(), - "image/gif" => Image::from_gif(bytes.clone().into(), true).ok(), - "image/webp" => Image::from_webp(bytes.clone().into(), true).ok(), - _ => None, - }; - direct.or_else(|| { - let rgba = image::load_from_memory(&bytes).ok()?.to_rgba8(); - let (w, h) = rgba.dimensions(); - Some(Image::from_rgba8(rgba.into_raw(), w, h)) - }) + let interpolate = self.interpolate; + let doc = self.doc; + let img = doc.images.get(key).and_then(|blob| { + let r = crate::images::recode(&blob.bytes, size, c, interpolate); + if let Some(w) = r.warning { + self.warn(w); + } + r.image.or_else(|| embed(blob, interpolate)) }); if img.is_none() { self.warn(format!("image '{key}' could not be decoded and was skipped")); } - self.images.insert(key.to_string(), img.clone()); + self.images.insert(cache, img.clone()); img } fn image(&mut self, s: &mut Surface, im: &vectorcraft_doc::ImageObject) { - let Some(img) = self.load_image(&im.key) else { return }; + // The size it is placed at: its pixel grid's sides through its transform. + let [a, b, c, d, ..] = im.xf.as_coeffs(); + let size = (im.width as f64 * a.hypot(b), im.height as f64 * c.hypot(d)); + let Some(img) = self.load_image(&im.key, size) else { return }; let Some(size) = Size::from_wh(im.width.max(1) as f32, im.height.max(1) as f32) else { return }; s.push_transform(&xf(im.xf)); s.draw_image(img, size); s.pop(); } } + +/// Image `blob` as it is: PNG, JPEG, GIF and WebP through the PDF writer (JPEG data unchanged), +/// other formats decoded. +fn embed(blob: &vectorcraft_doc::ImageBlob, interpolate: bool) -> Option { + let bytes = blob.bytes.as_ref().clone(); + let direct = match blob.mime.as_str() { + "image/png" => Image::from_png(bytes.clone().into(), interpolate).ok(), + "image/jpeg" | "image/jpg" => Image::from_jpeg(bytes.clone().into(), interpolate).ok(), + "image/gif" => Image::from_gif(bytes.clone().into(), interpolate).ok(), + "image/webp" => Image::from_webp(bytes.clone().into(), interpolate).ok(), + _ => None, + }; + direct.or_else(|| { + let rgba = image::load_from_memory(&bytes).ok()?.to_rgba8(); + let (w, h) = rgba.dimensions(); + Some(Image::from_rgba8(rgba.into_raw(), w, h)) + }) +} diff --git a/crates/pdf/src/images.rs b/crates/pdf/src/images.rs index 385bdbfb..43a309d9 100644 --- a/crates/pdf/src/images.rs +++ b/crates/pdf/src/images.rs @@ -1,10 +1,21 @@ -//! Images the writer makes itself: pixels it samples (freeform gradients) as PDF image -//! XObjects. +//! 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). +//! +//! 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, +//! a JPEG kept as JPEG or another kept lossless) is embedded as it is; the others are decoded, +//! resampled and encoded again. JPEG needs opaque pixels: images with transparency stay lossless. +use std::io::Cursor; use std::sync::Arc; +use image::imageops::FilterType; +use image::{DynamicImage, ExtendedColorType, ImageDecoder, ImageFormat, ImageReader}; use krilla::image::{BitsPerComponent, CustomImage, Image, ImageColorspace}; +use crate::{CompressionSettings, Downsample, ImageCodec, JpegQuality, MonoCodec}; + /// Raw 8-bit pixels: colour samples (RGB, or grey with `luma`) and an optional alpha channel. #[derive(Clone, Hash)] struct Raw { @@ -55,3 +66,159 @@ pub(crate) fn from_rgba(rgba: &[u8], width: u32, height: u32, interpolate: bool) 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() } + +/// The kinds of images the Compression section has settings for. +#[derive(Clone, Copy, Debug, PartialEq)] +enum Kind { + Color, + Gray, + Mono, +} + +/// How an image's pixels are compressed in the file. +#[derive(Clone, Copy, Debug, PartialEq)] +enum Codec { + /// JPEG images stay JPEG (at this quality when they are resampled); the others are lossless. + Auto(u8), + Zip, + Jpeg(u8), +} + +/// The settings for one kind of image. +struct Rule { + downsample: Downsample, + /// Target resolution, and the resolution above which images are resampled (ppi). + ppi: f64, + above: f64, + codec: Codec, + /// Why the codec differs from the one asked for (that one isn't available). + instead: Option<&'static str>, +} + +const UNCOMPRESSED: &str = "uncompressed images are not written: images are compressed with ZIP"; + +/// JPEG quality (1–100) of a quality setting. +fn quality(q: JpegQuality) -> u8 { + match q { + JpegQuality::Minimum => 10, + JpegQuality::Low => 30, + JpegQuality::Medium => 55, + JpegQuality::High => 80, + JpegQuality::Maximum => 95, + } +} + +fn rule(c: &CompressionSettings, kind: Kind) -> Rule { + if kind == Kind::Mono { + let m = &c.mono; + let instead = match m.compression { + MonoCodec::Zip => None, + MonoCodec::None => Some(UNCOMPRESSED), + MonoCodec::CcittG3 | MonoCodec::CcittG4 => Some("CCITT compression is not supported: monochrome images are compressed with ZIP"), + MonoCodec::RunLength => Some("Run Length compression is not supported: monochrome images are compressed with ZIP"), + }; + return Rule { downsample: m.downsample, ppi: m.ppi, above: m.above_ppi, codec: Codec::Zip, instead }; + } + let i = if kind == Kind::Color { &c.color } else { &c.gray }; + let (codec, instead) = match i.compression { + ImageCodec::Auto => (Codec::Auto(quality(i.quality)), None), + ImageCodec::Zip => (Codec::Zip, None), + ImageCodec::Jpeg => (Codec::Jpeg(quality(i.quality)), None), + ImageCodec::None => (Codec::Zip, Some(UNCOMPRESSED)), + ImageCodec::Jpeg2000 => (Codec::Zip, Some("JPEG 2000 compression is not supported: those images are compressed with ZIP")), + }; + Rule { downsample: i.downsample, ppi: i.ppi, above: i.above_ppi, codec, instead } +} + +/// What [`recode`] makes of an image. +pub(crate) struct Recoded { + /// The image to write; `None` keeps the source image as it is. + pub image: Option, + /// Set when the image isn't compressed as asked. + pub warning: Option<&'static str>, +} + +/// Is `img` (a grey image) only black and white? +fn bilevel(img: &DynamicImage) -> bool { + img.to_luma_alpha8().pixels().all(|p| p[0] == 0 || p[0] == 255) +} + +/// 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 { + 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); + let Ok(decoder) = reader.into_decoder() else { return keep }; + let (w, h) = decoder.dimensions(); + // Resolution along each side as placed; above the threshold, a side goes down to the target. + let ppi = |px: u32, pt: f64| px as f64 * 72.0 / pt.abs(); + let (px, py) = (ppi(w, size.0), ppi(h, size.1)); + let sized = |r: &Rule| { + let resample = r.downsample != Downsample::None && [px, py].iter().any(|p| p.is_finite() && *p > r.above); + let side = |n: u32, p: f64| if resample && p.is_finite() && p > r.ppi { ((n as f64 * r.ppi / p).round() as u32).clamp(1, n) } else { n }; + (side(w, px), side(h, py)) + }; + // Already as asked: not resampled, and lossless or a JPEG kept as JPEG. + let kept = |r: &Rule| { + sized(r) == (w, h) + && match r.codec { + Codec::Auto(_) => true, + Codec::Zip => !jpeg, + Codec::Jpeg(_) => jpeg, + } + }; + use ExtendedColorType as T; + let grey = matches!(decoder.original_color_type(), T::L1 | T::La1 | T::L2 | T::La2 | T::L4 | T::La4 | T::L8 | T::La8 | T::L16 | T::La16); + let (kind, pixels) = if grey { + // Only the pixels tell monochrome images from greyscale ones: decoded unless both keep it. + let (g, m) = (rule(c, Kind::Gray), rule(c, Kind::Mono)); + if kept(&g) && kept(&m) && g.instead == m.instead { + return Recoded { image: None, warning: g.instead }; + } + let Ok(img) = DynamicImage::from_decoder(decoder) else { return keep }; + (if bilevel(&img) { Kind::Mono } else { Kind::Gray }, Some(img)) + } else { + (Kind::Color, None) + }; + let rule = rule(c, kind); + if kept(&rule) { + return Recoded { image: None, warning: rule.instead }; + } + let Some(img) = pixels.or_else(|| image::load_from_memory(bytes).ok()) else { return keep }; + let (nw, nh) = sized(&rule); + let img = if (nw, nh) == (w, h) { + img + } else { + match rule.downsample { + Downsample::Average => img.thumbnail_exact(nw, nh), + Downsample::Subsample => img.resize_exact(nw, nh, FilterType::Nearest), + Downsample::Bicubic | Downsample::None => img.resize_exact(nw, nh, FilterType::CatmullRom), + } + }; + let mut rgba = img.to_rgba8(); + if kind == Kind::Mono { + // Resampling greys the edges: back to black and white. + for p in rgba.pixels_mut() { + let v = if p[0] >= 128 { 255 } else { 0 }; + (p[0], p[1], p[2]) = (v, v, v); + } + } + let quality = match rule.codec { + Codec::Jpeg(q) => Some(q), + 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)); + let (width, height) = rgba.dimensions(); + Recoded { image: image.or_else(|| from_rgba(rgba.as_raw(), width, height, interpolate)), warning: rule.instead } +} + +/// 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()) }; + 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/lib.rs b/crates/pdf/src/lib.rs index 6bb3692f..614e5862 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -5,8 +5,8 @@ //! width profiles, fitted or dotted dashes as the canvas's filled outlines and brushed strokes //! 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 and text as -//! outlined glyph paths. Hidden objects, guides and template layers are skipped. [`PdfSettings`] is the Save PDF +//! 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, guides and template layers are skipped. [`PdfSettings`] is the Save PDF //! dialog's model (standard, compatibility, General, Compression, Marks and Bleeds, Output, //! Advanced, Security); options the writer doesn't apply yet come back as warnings. With Preserve //! Editing the native document rides along as an embedded file ([`editing()`]). @@ -156,6 +156,8 @@ mod tests_charstroke; #[cfg(test)] mod tests_cmykblend; #[cfg(test)] +mod tests_compression; +#[cfg(test)] mod tests_dashalign; #[cfg(test)] mod tests_editing; diff --git a/crates/pdf/src/settings.rs b/crates/pdf/src/settings.rs index 6745c9e9..b472646b 100644 --- a/crates/pdf/src/settings.rs +++ b/crates/pdf/src/settings.rs @@ -433,17 +433,12 @@ 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 c = &self.compression; 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"), - ( - c.color != d.compression.color || c.gray != d.compression.gray || c.mono != d.compression.mono, - "image downsampling and compression settings are not applied yet: images are embedded unchanged", - ), (self.marks.any(), "printer's marks are not drawn yet"), (!self.bleed.use_document && self.bleed.values().iter().any(|v| *v > 0.0), "bleed is not added yet: each page is its artboard"), (o.conversion != ColorConversion::None, "colour conversion is not applied yet: colours are written as they are"), diff --git a/crates/pdf/src/tests_compression.rs b/crates/pdf/src/tests_compression.rs new file mode 100644 index 00000000..c65742ac --- /dev/null +++ b/crates/pdf/src/tests_compression.rs @@ -0,0 +1,95 @@ +//! Image compression and downsampling: images above the threshold resolution are resampled to the +//! target, compressed with JPEG or ZIP as their kind (colour, greyscale, monochrome) asks, and the +//! codecs the writer lacks are written as ZIP with a warning. + +use std::io::Cursor; + +use image::{DynamicImage, ImageFormat}; +use serde_json::{Value, json}; +use vectorcraft_doc::{Document, ImageBlob, ImageObject, Node, NodeKind}; +use vectorcraft_geom::Affine; + +use crate::*; + +fn encoded(img: DynamicImage, format: ImageFormat) -> Vec { + let mut out = Vec::new(); + img.write_to(&mut Cursor::new(&mut out), format).unwrap(); + out +} + +/// A 600 × 600 colour photo-like image. +fn colour() -> DynamicImage { + image::RgbImage::from_fn(600, 600, |x, y| image::Rgb([(x % 256) as u8, (y % 256) as u8, ((x * y) % 251) as u8])).into() +} + +/// A document with image `bytes` (600 × 600 pixels) placed one inch wide: 600 ppi. +fn doc(bytes: Vec, mime: &str) -> Document { + let mut d = Document::new(100.0, 100.0); + d.images.insert("img".into(), ImageBlob::new(mime, bytes)); + let xf = Affine::scale(72.0 / 600.0); + let n = Node::new(d.alloc_id(), NodeKind::Image(ImageObject { key: "img".into(), width: 600, height: 600, xf, link: None })); + let l = d.layers[0].id; + d.insert(Some(l), 0, n).unwrap(); + d +} + +/// The PDF of `d` with Compression options `compression` (content streams left readable) and its +/// warnings. +fn pdf(d: &Document, compression: Value) -> (String, Vec) { + let mut settings: PdfSettings = serde_json::from_value(json!({ "compression": compression })).unwrap(); + settings.compression.compress_text = false; + let r = export_with_report(d, &PdfOptions { settings, ..Default::default() }).unwrap(); + (String::from_utf8_lossy(&r.bytes).into_owned(), r.warnings) +} + +#[test] +fn images_above_the_threshold_are_resampled_to_the_target_resolution() { + let d = doc(encoded(colour(), ImageFormat::Png), "image/png"); + for method in ["average", "subsample", "bicubic"] { + let (text, warnings) = pdf(&d, json!({"color": {"downsample": method, "ppi": 150, "abovePpi": 225}})); + assert!(text.contains("/Width 150") && text.contains("/Height 150"), "{method}"); + assert!(warnings.is_empty(), "{warnings:?}"); + } + // Below the threshold, or without downsampling, the image keeps its pixels. + for c in [json!({"color": {"downsample": "bicubic", "ppi": 150, "abovePpi": 700}}), json!({})] { + assert!(pdf(&d, c.clone()).0.contains("/Width 600"), "{c}"); + } + // Greyscale images take the greyscale settings. + let grey = doc(encoded(colour().grayscale(), ImageFormat::Png), "image/png"); + let (text, _) = pdf(&grey, json!({"color": {"downsample": "bicubic", "ppi": 150}, "gray": {"downsample": "bicubic", "ppi": 100}})); + assert!(text.contains("/Width 100") && text.contains("/DeviceGray"), "grey at 100 ppi"); +} + +#[test] +fn jpeg_zip_and_automatic_compression() { + let png = doc(encoded(colour(), ImageFormat::Png), "image/png"); + let jpeg = doc(encoded(colour(), ImageFormat::Jpeg), "image/jpeg"); + let dct = |d: &Document, c: Value| pdf(d, c).0.contains("/DCTDecode"); + assert!(dct(&png, json!({"color": {"compression": "jpeg", "quality": "low"}})), "JPEG compression"); + assert!(!dct(&png, json!({"color": {"compression": "zip"}})) && pdf(&png, json!({})).0.contains("/FlateDecode"), "ZIP"); + assert!(!dct(&png, json!({})), "automatic: lossless for a PNG"); + assert!(dct(&jpeg, json!({})) && dct(&jpeg, json!({"color": {"downsample": "average", "ppi": 150}})), "automatic: a JPEG stays JPEG"); + assert!(!dct(&jpeg, json!({"color": {"compression": "zip"}})), "ZIP re-encodes a JPEG losslessly"); + // JPEG has no transparency: an image with some stays lossless. + let mut clear = colour().to_rgba8(); + clear.put_pixel(0, 0, image::Rgba([0, 0, 0, 0])); + let clear = doc(encoded(clear.into(), ImageFormat::Png), "image/png"); + let (text, _) = pdf(&clear, json!({"color": {"compression": "jpeg"}})); + assert!(!text.contains("/DCTDecode") && text.contains("/SMask")); +} + +#[test] +fn codecs_the_writer_lacks_are_zip_with_a_warning() { + let png = doc(encoded(colour(), ImageFormat::Png), "image/png"); + let (text, warnings) = pdf(&png, json!({"color": {"compression": "jpeg2000"}})); + assert!(!text.contains("/JPXDecode") && text.contains("/FlateDecode")); + assert!(warnings.len() == 1 && warnings[0].contains("JPEG 2000"), "{warnings:?}"); + // Black-and-white images are monochrome: their settings apply, CCITT becomes ZIP. + let bw = image::GrayImage::from_fn(600, 600, |x, y| image::Luma([if (x / 7 + y / 5) % 2 == 0 { 0 } else { 255 }])); + let bw = doc(encoded(bw.into(), ImageFormat::Png), "image/png"); + let (text, warnings) = pdf(&bw, json!({"mono": {"downsample": "bicubic", "ppi": 300, "abovePpi": 450, "compression": "ccittG4"}})); + assert!(text.contains("/Width 300") && !text.contains("/CCITTFaxDecode")); + assert!(warnings.len() == 1 && warnings[0].contains("CCITT"), "{warnings:?}"); + // The codecs of other kinds of images say nothing. + assert!(pdf(&png, json!({"mono": {"compression": "ccittG4"}})).1.is_empty()); +} diff --git a/crates/pdf/src/tests_settings.rs b/crates/pdf/src/tests_settings.rs index 237b5f70..cf4deca6 100644 --- a/crates/pdf/src/tests_settings.rs +++ b/crates/pdf/src/tests_settings.rs @@ -74,7 +74,6 @@ fn options_not_applied_yet_come_back_as_warnings() { (json!({"thumbnails": true}), "thumbnails"), (json!({"fastWebView": true}), "fast web view"), (json!({"createLayers": true}), "layers"), - (json!({"compression": {"color": {"compression": "jpeg"}}}), "image"), (json!({"marks": {"registration": true}}), "marks"), (json!({"bleed": {"top": 9}}), "bleed"), (json!({"output": {"conversion": "destination"}}), "conversion"), @@ -86,8 +85,14 @@ fn options_not_applied_yet_come_back_as_warnings() { let w = settings(v.clone()).warnings(); assert!(w.len() == 1 && w[0].contains(word), "{v}: {w:?}"); } - // A document bleed, the default view/overprint choices and applied options warn about nothing. - for v in [json!({"bleed": {"useDocument": true, "top": 9}}), json!({"viewAfterSaving": true}), json!({"compression": {"compressText": false}})] { + // A document bleed, 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. + for v in [ + json!({"bleed": {"useDocument": true, "top": 9}}), + json!({"viewAfterSaving": true}), + json!({"compression": {"compressText": false}}), + json!({"compression": {"color": {"compression": "jpeg"}, "mono": {"compression": "ccittG4"}}}), + ] { assert!(settings(v.clone()).warnings().is_empty(), "{v}"); } let r = diff --git a/docs/mcp.md b/docs/mcp.md index f2aedc17..0a801504 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -196,6 +196,10 @@ options accepted but not applied yet, and features approximated or left out. PDF Pattern fills and strokes are written as their tiles clipped to the area they paint (a stroke's outline, with its dashes, caps, profile, arrowheads and alignment), and freeform gradients as an image of their colour field at the document's raster effects resolution, clipped the same way. +Images follow the `compression` settings of their kind (`color`, `gray`, or `mono` for black-and-white images): above +`abovePpi` as placed they are resampled (`downsample`: `average`, `subsample` or `bicubic`) to `ppi`, and compressed +with `zip`, `jpeg` (at `quality`; images with transparency stay lossless) or `auto` (JPEGs stay JPEG, the others +lossless). `none`, `jpeg2000`, CCITT and `runLength` are written as ZIP, with a warning when an image needs them. `document.pdfSettings` lists the options that differ from the preset and the warnings without writing a file. Opening a PDF (or `.ai`) imports every page as an artboard and layer; `document.open` takes `pages` ("2-3, 5", 1-based),