From ec4a1ac25dfd570bd7336fc01667dafb2f3e380a Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 15 Nov 2026 07:27:08 -0800 Subject: [PATCH] PDF export: a set Don't Include Profiles removes image color profiles When Profile inclusion is set to Don't Include Profiles (`profiles: "none"`), gray, RGB and CMYK images are written in device color spaces without their ICC profiles, and a JPEG that is kept as is loses its APP2 ICC_PROFILE segments. PDF/A-2b, PDF/X-3 and PDF/X-4 keep tagging their images. Leaving the setting out keeps the earlier output. `profiles` now serializes as null when not set, `"default"` clears a preset's value, and Save PDF > Output lists Default first. Presets and stored settings saved earlier hold `"profiles": "none"`, so exports from them now drop image profiles. --- crates/doc/src/cmyk.rs | 20 ++++- crates/engine/src/cmd/fileio/pdf.rs | 2 +- .../engine/src/cmd/fileio/tests_pdfoutput.rs | 16 ++++ crates/pdf/src/export/images.rs | 48 ++++++---- crates/pdf/src/export/text.rs | 2 +- crates/pdf/src/images.rs | 90 +++++++++++++++---- crates/pdf/src/output.rs | 50 +++++++++-- crates/pdf/src/settings.rs | 20 ++++- crates/pdf/src/tests/cmykimages.rs | 29 ++++++ crates/pdf/src/tests/compression.rs | 81 ++++++++++++++++- crates/pdf/src/tests/settings.rs | 18 ++++ crates/ui-egui/src/dialogs/save_pdf.rs | 18 +++- docs/mcp.md | 6 +- docs/target-app-parity.md | 1 + 14 files changed, 348 insertions(+), 53 deletions(-) diff --git a/crates/doc/src/cmyk.rs b/crates/doc/src/cmyk.rs index 6339f0a4ca..60a6db4074 100644 --- a/crates/doc/src/cmyk.rs +++ b/crates/doc/src/cmyk.rs @@ -664,9 +664,6 @@ pub fn jpeg_with_icc(bytes: &[u8], icc: &[u8]) -> Option> { const CHUNK: usize = 0xFFFF - 2 - ICC_SEGMENT.len() - 2; let chunks: Vec<&[u8]> = icc.chunks(CHUNK).collect(); let count = u8::try_from(chunks.len()).ok().filter(|n| *n > 0)?; - if !bytes.starts_with(&[0xFF, 0xD8]) { - return None; - } let mut segments = Vec::with_capacity(icc.len() + 18 * chunks.len()); for (i, chunk) in chunks.iter().enumerate() { let len = u16::try_from(chunk.len() + 2 + ICC_SEGMENT.len() + 2).ok()?; @@ -676,6 +673,23 @@ pub fn jpeg_with_icc(bytes: &[u8], icc: &[u8]) -> Option> { segments.extend_from_slice(&[u8::try_from(i + 1).ok()?, count]); segments.extend_from_slice(chunk); } + with_icc_segments(bytes, segments) +} + +/// JPEG `bytes` without the ICC profile they carry: every `ICC_PROFILE` APP2 segment before the +/// scan taken out, and the other segments and the scan as they are. `None` for bytes whose +/// segments before the scan can't be read. +pub fn jpeg_without_icc(bytes: &[u8]) -> Option> { + with_icc_segments(bytes, vec![]) +} + +/// JPEG `bytes` with every `ICC_PROFILE` APP2 segment before the scan taken out and `segments` +/// written after the leading application segments; `None` when the bytes don't start a JPEG or +/// their segments before the scan can't be read. +fn with_icc_segments(bytes: &[u8], segments: Vec) -> Option> { + if !bytes.starts_with(&[0xFF, 0xD8]) { + return None; + } let mut out = Vec::with_capacity(bytes.len() + segments.len()); out.extend_from_slice(&[0xFF, 0xD8]); let mut icc_segments = Some(segments); diff --git a/crates/engine/src/cmd/fileio/pdf.rs b/crates/engine/src/cmd/fileio/pdf.rs index 77902b4a15..6738f0727c 100644 --- a/crates/engine/src/cmd/fileio/pdf.rs +++ b/crates/engine/src/cmd/fileio/pdf.rs @@ -128,7 +128,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), useArtboards?: true (false: one page, the bounds of the visible art the page draws (non-printing layers left out unless includeNonPrinting or createLayers keeps them), as document.export writes it; artboard, artboards and range are then ignored, Preserve Editing is left out (with a warning when it is on), and a document with no visible art is refused), standard?: none|pdfA2b|pdfX1a (CMYK, grey and spot colours only: colours and images converted to output.destination or the CMYK output intent, untagged; transparency flattened with flattenerPreset; PDF 1.3)|pdfX3 (colours ICC-tagged; transparency flattened; PDF 1.3)|pdfX4 (transparency and layers kept; colours ICC-tagged; PDF 1.6 at most) (PDF/X: a /GTS_PDFX output intent, by default the CMYK profile in effect, embedded; TrimBox, Trapped, GTS_PDFXVersion, title and dates; fonts embedded or outlined), compatibility?: 1.3 (no transparency: it is flattened with flattenerPreset, without the layers the file leaves out; images keep no see-through pixels)|1.4|1.5|1.6|1.7 (default)|2.0 (choosing a standard sets its version: 1.7 for pdfA2b, 1.6 for pdfX4, 1.3 for pdfX1a and pdfX3, which write 1.4 as 1.3 too; pdfA2b refuses 1.3; in pdfA2b files, and with longPagesInPoints: false, a page with a side longer than 14,400 pt keeps its size with a UserUnit from 1.6 on: 1.5 is then written as 1.6, with 1.5's encryption, and so is 1.4 in pdfA2b files, with a warning; PDF/X files and other 1.3 and 1.4 files write it in points, and 1.3 and 1.4 (pdfX1a and pdfX3 too) refuse a page past 32,767 pt whose size or art needs a real number past 32,767; a page whose short side is 3 pt or longer but would be under 3 units at that UserUnit gets a smaller one that keeps the short side at 3 units; a warning gives each such page's size), longPagesInPoints?: true (such a page is written at its size in points, without a UserUnit, at every version, and 1.5 stays 1.5, in files without a standard (PDF/A files keep the UserUnit); apps that keep to the limit cut it to 200 in; false: the UserUnit as above, with a warning that names this option), preserveEditing? (on in VectorCraft Default: the native document as an embedded file, which document.open restores; choosing a standard turns it off, a standard refuses it), thumbnails? (each page drawn as a /Thumb image, 106 px on its long side), fastWebView? (a linearised file: the first page shows while the rest downloads; it stays linearised when encrypted), viewAfterSaving? (the app opens the written file), createLayers? (each layer and sublayer but template layers as a PDF layer, an optional content group listed under its layer's: hidden layers off, non-printing ones /PrintState /OFF, locked ones locked; needs 1.5 or later; they reopen as layers; choosing pdfX1a or pdfX3 turns it off; they refuse it), includeNonPrinting? (keep layers whose Print option is off; left out by default unless createLayers), flattenerPreset?: \"\" (High Resolution) | high | medium | low | a saved preset (flattener.presets.list): what PDF 1.3 files flatten transparency with, flattener?: {object.flattenTransparency options over it}, 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; a PDF/X file with no bleed on a side where the document's bleed has one comes back with a warning). 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, and with destination and an sRGB destination a gray or RGB image tagged with an ICC profile is converted from it and written untagged; with destination and a CMYK destination, or another RGB one images are converted to (not when colours are ICC-tagged), an RGB image tagged with one is converted from it through sRGB; 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, pdfX3 and pdfX4 always, whatever profiles says; a CMYK image that keeps its numbers carries the CMYK profile it embeds with all and taggedSource, and in PDF/A, pdfX3 and pdfX4 unless destination, never in pdfX1a), outputIntent: profile name (embedded as the catalog's /GTS_PDFX output intent; a name that isn't a profile is only named, which pdfX1a and pdfX3 allow only with a registry and pdfX4 refuses; pdfX1a needs a CMYK one), 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 (below 100 is accepted with a warning: fonts are always embedded as subsets), outlineText: false (type as real, selectable and searchable text in embedded subset fonts with a ToUnicode map; fonts whose licence forbids embedding, or allows only whole fonts, stay outlines with a warning; true: every glyph as an outline), overprint: preserve (overprinting fills and strokes set /OP /op true, /OPM 1; white ones knock out with document.setup discardWhiteOverprint)|discard (they knock out)}, security?: {openPassword, permissionsPassword, printing: none|low|high, changes: none|pages|forms|comments|any, copy, screenReader, plaintextMetadata}, acknowledgeLoss?: bool (write over the file the document was read from although reading it left things out), format?: \"pdf\"} → {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; a standard with a PDF version it doesn't allow and a password with a standard are refused, and so is a PDF/X file that would still break its standard (transparency the flattener left, RGB in pdfX1a, a font not embedded) or a PDF 1.3 file with transparency left. document.export {format: pdf} takes the same options. A key PDF doesn't take, and an unknown key inside one of the option objects, are refused", + "{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), useArtboards?: true (false: one page, the bounds of the visible art the page draws (non-printing layers left out unless includeNonPrinting or createLayers keeps them), as document.export writes it; artboard, artboards and range are then ignored, Preserve Editing is left out (with a warning when it is on), and a document with no visible art is refused), standard?: none|pdfA2b|pdfX1a (CMYK, grey and spot colours only: colours and images converted to output.destination or the CMYK output intent, untagged; transparency flattened with flattenerPreset; PDF 1.3)|pdfX3 (colours ICC-tagged; transparency flattened; PDF 1.3)|pdfX4 (transparency and layers kept; colours ICC-tagged; PDF 1.6 at most) (PDF/X: a /GTS_PDFX output intent, by default the CMYK profile in effect, embedded; TrimBox, Trapped, GTS_PDFXVersion, title and dates; fonts embedded or outlined), compatibility?: 1.3 (no transparency: it is flattened with flattenerPreset, without the layers the file leaves out; images keep no see-through pixels)|1.4|1.5|1.6|1.7 (default)|2.0 (choosing a standard sets its version: 1.7 for pdfA2b, 1.6 for pdfX4, 1.3 for pdfX1a and pdfX3, which write 1.4 as 1.3 too; pdfA2b refuses 1.3; in pdfA2b files, and with longPagesInPoints: false, a page with a side longer than 14,400 pt keeps its size with a UserUnit from 1.6 on: 1.5 is then written as 1.6, with 1.5's encryption, and so is 1.4 in pdfA2b files, with a warning; PDF/X files and other 1.3 and 1.4 files write it in points, and 1.3 and 1.4 (pdfX1a and pdfX3 too) refuse a page past 32,767 pt whose size or art needs a real number past 32,767; a page whose short side is 3 pt or longer but would be under 3 units at that UserUnit gets a smaller one that keeps the short side at 3 units; a warning gives each such page's size), longPagesInPoints?: true (such a page is written at its size in points, without a UserUnit, at every version, and 1.5 stays 1.5, in files without a standard (PDF/A files keep the UserUnit); apps that keep to the limit cut it to 200 in; false: the UserUnit as above, with a warning that names this option), preserveEditing? (on in VectorCraft Default: the native document as an embedded file, which document.open restores; choosing a standard turns it off, a standard refuses it), thumbnails? (each page drawn as a /Thumb image, 106 px on its long side), fastWebView? (a linearised file: the first page shows while the rest downloads; it stays linearised when encrypted), viewAfterSaving? (the app opens the written file), createLayers? (each layer and sublayer but template layers as a PDF layer, an optional content group listed under its layer's: hidden layers off, non-printing ones /PrintState /OFF, locked ones locked; needs 1.5 or later; they reopen as layers; choosing pdfX1a or pdfX3 turns it off; they refuse it), includeNonPrinting? (keep layers whose Print option is off; left out by default unless createLayers), flattenerPreset?: \"\" (High Resolution) | high | medium | low | a saved preset (flattener.presets.list): what PDF 1.3 files flatten transparency with, flattener?: {object.flattenTransparency options over it}, 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; a PDF/X file with no bleed on a side where the document's bleed has one comes back with a warning). 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, and with destination and an sRGB destination a gray or RGB image tagged with an ICC profile is converted from it and written untagged; with destination and a CMYK destination, or another RGB one images are converted to (not when colours are ICC-tagged), an RGB image tagged with one is converted from it through sRGB; 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; images are written without the ICC profiles they carry, a JPEG without its ICC_PROFILE segments, but in PDF/A, pdfX3 and pdfX4)|all|destination|taggedSource (documents assigned a profile: edit.assignProfile)|default (not set, the default; \"default\" unsets a preset's value, null keeps it: device colours, and gray and RGB images keep the profiles they carry, but in pdfX1a) (ICC-based colours: CMYK with the destination or document CMYK profile, RGB as sRGB, grey with the sRGB tone curve; PDF/A, pdfX3 and pdfX4 always, whatever profiles says; a CMYK image that keeps its numbers carries the CMYK profile it embeds with all and taggedSource, and in PDF/A, pdfX3 and pdfX4 unless destination, never in pdfX1a), outputIntent: profile name (embedded as the catalog's /GTS_PDFX output intent; a name that isn't a profile is only named, which pdfX1a and pdfX3 allow only with a registry and pdfX4 refuses; pdfX1a needs a CMYK one), 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 (below 100 is accepted with a warning: fonts are always embedded as subsets), outlineText: false (type as real, selectable and searchable text in embedded subset fonts with a ToUnicode map; fonts whose licence forbids embedding, or allows only whole fonts, stay outlines with a warning; true: every glyph as an outline), overprint: preserve (overprinting fills and strokes set /OP /op true, /OPM 1; white ones knock out with document.setup discardWhiteOverprint)|discard (they knock out)}, security?: {openPassword, permissionsPassword, printing: none|low|high, changes: none|pages|forms|comments|any, copy, screenReader, plaintextMetadata}, acknowledgeLoss?: bool (write over the file the document was read from although reading it left things out), format?: \"pdf\"} → {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; a standard with a PDF version it doesn't allow and a password with a standard are refused, and so is a PDF/X file that would still break its standard (transparency the flattener left, RGB in pdfX1a, a font not embedded) or a PDF 1.3 file with transparency left. document.export {format: pdf} takes the same options. A key PDF doesn't take, and an unknown key inside one of the option objects, are refused", has_doc, export_pdf ), diff --git a/crates/engine/src/cmd/fileio/tests_pdfoutput.rs b/crates/engine/src/cmd/fileio/tests_pdfoutput.rs index 04726fee46..c98a121ded 100644 --- a/crates/engine/src/cmd/fileio/tests_pdfoutput.rs +++ b/crates/engine/src/cmd/fileio/tests_pdfoutput.rs @@ -38,3 +38,19 @@ fn export_pdf_converts_colours_writes_the_output_intent_and_real_text() { let bad = s.execute("document.exportPdf", &json!({"output": {"conversion": "destination", "destination": "Missing"}})); assert!(matches!(bad, Err(crate::EngineError::BadParams { .. })), "{bad:?}"); } + +/// Over a saved preset whose Profiles setting is `none`, `"default"` leaves the setting unset, +/// while `null` keeps the preset's value; an unknown value is a bad parameter. +#[test] +fn profiles_default_unsets_a_presets_profiles_setting() { + use vectorcraft_pdf::{PdfPreset, PdfSettings, ProfileInclusion}; + let mut settings = PdfSettings::default(); + settings.output.profiles = Some(ProfileInclusion::None); + let saved = [PdfPreset { name: "No profiles".into(), description: String::new(), settings }]; + let resolve = |output: Value| pdf::resolve("document.exportPdf", &json!({"preset": "No profiles", "output": output}), &saved); + assert_eq!(resolve(json!({"profiles": "default"})).unwrap().output.profiles, None); + assert_eq!(resolve(json!({"profiles": null})).unwrap().output.profiles, Some(ProfileInclusion::None)); + assert_eq!(resolve(json!({"profiles": "all"})).unwrap().output.profiles, Some(ProfileInclusion::All)); + let bad = resolve(json!({"profiles": "sometimes"})); + assert!(matches!(bad, Err(crate::EngineError::BadParams { .. })), "{bad:?}"); +} diff --git a/crates/pdf/src/export/images.rs b/crates/pdf/src/export/images.rs index 04a3a811f3..32878eb2bc 100644 --- a/crates/pdf/src/export/images.rs +++ b/crates/pdf/src/export/images.rs @@ -3,6 +3,8 @@ use super::{Arc, BlendMode, Color, Exporter, Image, Node, NodeKind, Paint, Rect, use krilla::color::device_n::{Colorant, DeviceNSpace, Process}; use krilla::image::{BitsPerComponent, CustomImage, ImageColorspace}; +use crate::images::SourceProfiles; + /// The byte count of a repeated color channel, within [`vectorcraft_doc::MAX_RASTER_ALLOC`]. fn tinted_color_len(samples: usize, pixels: usize) -> Option { let bytes = samples.checked_mul(pixels)?; @@ -138,7 +140,7 @@ impl Exporter<'_> { } let doc = self.doc; let out = self.out.clone(); - let profiles = !out.standard.cmyk_only(); + let profiles = out.source_profiles(); let to_srgb = out.image_srgb_intent().map(|intent| crate::images::ToSrgb { intent, sixteen_bit: self.sixteen_bit }); let img = doc.image_blob(im).filter(|_| !self.left_out.contains(key)).and_then(|blob| { let cmyk_image = blob.is_cmyk(); @@ -147,7 +149,7 @@ impl Exporter<'_> { } let r = if cmyk_image { let icc = out.cmyk_image_icc(blob); - crate::images::recode_cmyk(blob, size, c, interpolate, out.cmyk_pixels(), None, icc.as_deref()) + crate::images::recode_cmyk(blob, size, c, interpolate, out.cmyk_pixels(), None, out.cmyk_tag(icc.as_deref())) } else { crate::images::recode(&blob.bytes, size, c, interpolate, out.pixels(), profiles, to_srgb) }; @@ -197,7 +199,8 @@ impl Exporter<'_> { let img = doc.image_blob(im).filter(|b| b.is_cmyk()).and_then(|blob| { let alpha = crate::images::mask_alpha(doc.image_blob(mi)?, im.width, im.height, m.clip)?; let icc = self.out.cmyk_image_icc(blob); - let r = crate::images::recode_cmyk(blob, size, self.compression, interpolate, self.out.cmyk_pixels(), Some(alpha), icc.as_deref()); + let tag = self.out.cmyk_tag(icc.as_deref()); + let r = crate::images::recode_cmyk(blob, size, self.compression, interpolate, self.out.cmyk_pixels(), Some(alpha), tag); if let (Some(w), Some(_)) = (r.warning, &r.image) { self.warn(w); } @@ -320,13 +323,15 @@ pub(super) fn png_8_bit(bytes: &[u8]) -> Option> { } /// Image `blob` as it is: PNG, JPEG, GIF and WebP through the PDF writer (JPEG data unchanged), -/// other formats (a TIFF) decoded with their 16-bit samples and, when `profiles` allows it (not in -/// PDF/X-1a), the ICC profile they carry ([`crate::images::decoded_image`]). Without `profiles`, an -/// image that carries a gray or RGB profile is written without it, and so is one whose gray or RGB -/// profile is malformed ([`crate::images::valid_icc`]): a JPEG with its data unchanged, an image of -/// another format decoded. Without `sixteen_bit` (a file before PDF 1.5), a PNG or another image of -/// 16 bits a sample is written at 8 bits ([`png_8_bit`]). -pub(super) fn embed(blob: &vectorcraft_doc::ImageBlob, interpolate: bool, sixteen_bit: bool, profiles: bool) -> Option { +/// other formats (a TIFF) decoded with their 16-bit samples and, when `profiles` keeps it (not in +/// PDF/X-1a), the ICC profile they carry ([`crate::images::decoded_image`]). When `profiles` +/// doesn't keep it, an image that carries a gray or RGB profile is written without it, and so is +/// one whose gray or RGB profile is malformed ([`crate::images::valid_icc`]): a JPEG with its data +/// unchanged, an image of another format decoded. With [`SourceProfiles::Remove`], an image that +/// carries any profile is written without it, a JPEG without its `ICC_PROFILE` segments (decoded +/// when those can't be taken out). Without `sixteen_bit` (a file before PDF 1.5), a PNG or another +/// image of 16 bits a sample is written at 8 bits ([`png_8_bit`]). +pub(super) fn embed(blob: &vectorcraft_doc::ImageBlob, interpolate: bool, sixteen_bit: bool, profiles: SourceProfiles) -> Option { // The header's size first: the writer's decoders allocate what it declares, and the encoded // bytes of an image that is far larger than any the app reads may be a few kilobytes. let size = image::ImageReader::new(std::io::Cursor::new(blob.bytes.as_slice())).with_guessed_format().ok()?.into_dimensions(); @@ -334,16 +339,25 @@ pub(super) fn embed(blob: &vectorcraft_doc::ImageBlob, interpolate: bool, sixtee return None; } let bytes = blob.bytes.as_ref().clone(); - // The PDF writer keeps a gray or RGB image's ICC profile: without `profiles`, or when the - // profile is malformed, such a JPEG is written without its profile, and an image of another + // The PDF writer keeps a gray or RGB image's ICC profile: unless `profiles` keeps it, or when + // the profile is malformed, such a JPEG is written without its profile, and an image of another // format is decoded and written without it. The color space is read from the header's bytes - // 16..20 even in a profile cut short, as the PDF writer reads it. - let strip = crate::images::source_icc(&bytes).is_some_and(|icc| match icc.get(16..20) { - Some(space @ (b"GRAY" | b"RGB ")) => !profiles || <[u8; 4]>::try_from(space).is_ok_and(|s| !crate::images::valid_icc(&icc, s)), + // 16..20 even in a profile cut short, as the PDF writer reads it. With `Remove`, a JPEG's + // `ICC_PROFILE` segments are taken out also when they don't make up a profile the decoder reads. + let remove = profiles == SourceProfiles::Remove; + let jpeg = matches!(blob.mime.as_str(), "image/jpeg" | "image/jpg"); + let unkept = crate::images::source_icc(&bytes).is_some_and(|icc| match icc.get(16..20) { + _ if remove => true, + Some(space @ (b"GRAY" | b"RGB ")) => { + profiles != SourceProfiles::Keep || <[u8; 4]>::try_from(space).is_ok_and(|s| !crate::images::valid_icc(&icc, s)) + } _ => false, }); + let strip = (remove && jpeg) || unkept; let direct = match blob.mime.as_str() { - "image/jpeg" | "image/jpg" if strip => Image::from_jpeg_with_icc(bytes.clone().into(), None, interpolate).ok(), + "image/jpeg" | "image/jpg" if strip => { + crate::images::jpeg_data(&bytes, remove).and_then(|data| Image::from_jpeg_with_icc(data.into(), None, interpolate).ok()) + } _ if strip => None, "image/png" if !sixteen_bit && png_16_bit(&bytes) => png_8_bit(&bytes).and_then(|png| Image::from_png(png.into(), interpolate).ok()), "image/png" => Image::from_png(bytes.clone().into(), interpolate).ok(), @@ -371,7 +385,7 @@ mod tests { let crc = !png[12..29].iter().fold(!0u32, |c, b| (0..8).fold(c ^ u32::from(*b), |c, _| if c & 1 == 1 { 0xEDB8_8320 ^ (c >> 1) } else { c >> 1 })); png[29..33].copy_from_slice(&crc.to_be_bytes()); - assert!(super::embed(&ImageBlob::png(png), false, true, true).is_none()); + assert!(super::embed(&ImageBlob::png(png), false, true, super::SourceProfiles::Keep).is_none()); } #[test] diff --git a/crates/pdf/src/export/text.rs b/crates/pdf/src/export/text.rs index c6946f5416..ea29167f1b 100644 --- a/crates/pdf/src/export/text.rs +++ b/crates/pdf/src/export/text.rs @@ -326,7 +326,7 @@ impl Exporter<'_> { let img = match self.images.get(&cache) { Some(img) => img.clone(), None => { - let img = embed(&image.blob, self.interpolate, self.sixteen_bit, !self.out.standard.cmyk_only()); + let img = embed(&image.blob, self.interpolate, self.sixteen_bit, self.out.source_profiles()); self.images.insert(cache, img.clone()); img } diff --git a/crates/pdf/src/images.rs b/crates/pdf/src/images.rs index ac1d77f372..0b7024e731 100644 --- a/crates/pdf/src/images.rs +++ b/crates/pdf/src/images.rs @@ -20,6 +20,9 @@ //! inverted samples, which the PDF writer's Decode array undoes. A CMYK image whose numbers are //! kept carries the CMYK profile it is given ([`cmyk_icc`]), whether it is embedded as it is or //! written again; without one it is untagged, a JPEG's own `ICC_PROFILE` segments included. +//! With Don't Include Profiles set ([`SourceProfiles::Remove`], [`CmykTag::Removed`]), a JPEG +//! embedded as it is loses its `ICC_PROFILE` segments, its other segments and compressed samples +//! unchanged. use std::io::Cursor; use std::sync::Arc; @@ -152,10 +155,10 @@ pub(crate) fn from_decoded(img: DynamicImage, icc: Option>, interpolate: } /// Image `bytes` decoded within [`limits`], with the ICC profile it carries ([`source_icc`]) when -/// `profiles` allows it (not in PDF/X-1a, which allows no ICC-based color spaces), as -/// [`from_decoded`] writes it. `None` when it can't be decoded. -pub(crate) fn decoded_image(bytes: &[u8], interpolate: bool, sixteen_bit: bool, profiles: bool) -> Option { - from_decoded(decoded(bytes)?, profiles.then(|| source_icc(bytes)).flatten(), interpolate, sixteen_bit) +/// `profiles` keeps it ([`SourceProfiles::Keep`]), as [`from_decoded`] writes it. `None` when it +/// can't be decoded. +pub(crate) fn decoded_image(bytes: &[u8], interpolate: bool, sixteen_bit: bool, profiles: SourceProfiles) -> Option { + from_decoded(decoded(bytes)?, (profiles == SourceProfiles::Keep).then(|| source_icc(bytes)).flatten(), interpolate, sixteen_bit) } /// Are all of the RGBA pixels `rgba` grey? @@ -330,6 +333,47 @@ pub(crate) struct ToSrgb { pub sixteen_bit: bool, } +/// What happens to the ICC profile a gray or RGB image carries +/// ([`crate::output::ColorOut::source_profiles`]). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum SourceProfiles { + /// The image keeps a well-formed gray or RGB profile ([`valid_icc`]). + Keep, + /// The image is untagged; a JPEG embedded as it is keeps its data unchanged (PDF/X-1a). + Untag, + /// The image is untagged and a JPEG embedded as it is loses its `ICC_PROFILE` segments, its + /// other segments and compressed samples unchanged (Don't Include Profiles). + Remove, +} + +/// The CMYK profile [`recode_cmyk`] tags a CMYK image that keeps its numbers with. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum CmykTag<'a> { + /// This profile ([`cmyk_icc`]). + Profile(&'a [u8]), + /// None; a JPEG embedded as it is keeps its data unchanged. + Untagged, + /// None, and a JPEG embedded as it is loses its `ICC_PROFILE` segments, its other segments + /// and compressed samples unchanged (Don't Include Profiles). + Removed, +} + +impl<'a> CmykTag<'a> { + /// The profile the image is tagged with. + fn icc(self) -> Option<&'a [u8]> { + match self { + Self::Profile(icc) => Some(icc), + Self::Untagged | Self::Removed => None, + } + } +} + +/// JPEG `bytes` as they are embedded: without their `ICC_PROFILE` segments with `remove` +/// ([`vectorcraft_doc::cmyk::jpeg_without_icc`]). `None` when those can't be taken out. +pub(crate) fn jpeg_data(bytes: &[u8], remove: bool) -> Option> { + if remove { vectorcraft_doc::cmyk::jpeg_without_icc(bytes) } else { Some(bytes.to_vec()) } +} + /// The ICC profile image `bytes` carries ([`source_icc`]), when it is a well-formed profile of /// colour space `space` (`GRAY` or `RGB `, [`valid_icc`]): `None` without one, `Some(None)` when /// the CMS can't read it. @@ -474,8 +518,7 @@ fn resample_channels(img: DynamicImage, r: &Rule, (w, h): (u32, u32)) -> Dynamic /// 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, its colours /// converted by `convert` (colour images). Pixels that keep their colours keep the ICC profile -/// they carry when `profiles` allows it (not in PDF/X-1a, which allows no ICC-based color spaces); -/// with `to_srgb`, pixels in a gray or RGB profile other than sRGB are converted from it to sRGB +/// they carry when `profiles` keeps it ([`SourceProfiles::Keep`]); with `to_srgb`, pixels in a gray or RGB profile other than sRGB are converted from it to sRGB /// instead ([`ToSrgb`]), and colour ones then by `convert`; grey ones that `convert` leaves as /// they are keep their profile. `interpolate` lets viewers smooth it. An image that can't be /// decoded is kept as it is, with a warning when its pixels were to be converted to sRGB. @@ -485,7 +528,7 @@ pub(crate) fn recode( c: &CompressionSettings, interpolate: bool, convert: Option<&Pixels>, - profiles: bool, + profiles: SourceProfiles, to_srgb: Option, ) -> Recoded { let keep = Recoded { image: None, warning: None, note: None }; @@ -553,9 +596,14 @@ pub(crate) fn recode( // Written as it is only when nothing converts it further. let as_is = kept(&rule, dims, size, jpeg) && convert.is_none(); // A JPEG kept as JPEG whose samples the conversion leaves unchanged (an sRGB - // profile): its JPEG data, without the profile. - if !changed && as_is && jpeg { - let image = Image::from_jpeg_with_icc(bytes.to_vec().into(), None, interpolate).ok(); + // profile): its JPEG data, without the profile (and, with `Remove`, without its + // segments: encoded again when those can't be taken out). + if !changed + && as_is + && jpeg + && let Some(data) = jpeg_data(bytes, profiles == SourceProfiles::Remove) + { + let image = Image::from_jpeg_with_icc(data.into(), None, interpolate).ok(); return Recoded { image, warning: rule.instead, note: None }; } // Not resampled and lossless: written at its own depth. @@ -573,7 +621,8 @@ pub(crate) fn recode( // Pixels that keep their colours keep the profile they are in; converted ones are in the // destination's. let want = if kind == Kind::Color { *b"RGB " } else { *b"GRAY" }; - let icc = (profiles && convert.is_none() && source.is_none()).then(|| source_icc(bytes)).flatten().filter(|icc| valid_icc(icc, want)); + let keeps = profiles == SourceProfiles::Keep && convert.is_none() && source.is_none(); + let icc = keeps.then(|| source_icc(bytes)).flatten().filter(|icc| valid_icc(icc, want)); let rgb_icc = icc.is_some() && kind == Kind::Color; let mut rgba = resample(img, &rule, sized(&rule, dims, size)).into_rgba8(); if kind == Kind::Mono { @@ -630,8 +679,8 @@ pub(crate) fn coverage_sized(w: u32, h: u32, cover: Vec, size: (f64, f64), c /// is when its numbers are (`convert` is `None`); otherwise its ink amounts are resampled, /// converted by `convert` and compressed again. With `alpha` (one sample a pixel, see /// [`mask_alpha`]) the image is lossless and carries it as its soft mask; `None` then when its -/// inks can't be read. When its numbers are kept, the image is tagged with CMYK profile `icc` -/// (see [`cmyk_icc`]), and is untagged without one; converted pixels are never tagged with it. +/// inks can't be read. When its numbers are kept, the image is tagged as `tag` says, and is +/// untagged without a profile; converted pixels are never tagged with it. pub(crate) fn recode_cmyk( blob: &ImageBlob, size: (f64, f64), @@ -639,18 +688,21 @@ pub(crate) fn recode_cmyk( interpolate: bool, convert: Option<&CmykPixels>, alpha: Option>, - icc: Option<&[u8]>, + tag: CmykTag<'_>, ) -> Recoded { let rule = rule(c, Kind::Color); let jpeg = jpeg_components(&blob.bytes).is_some(); - let icc = icc.filter(|_| convert.is_none()); + let icc = tag.icc().filter(|_| convert.is_none()); if jpeg && convert.is_none() && alpha.is_none() { let dims = ImageReader::new(Cursor::new(blob.bytes.as_slice())).with_guessed_format().ok().and_then(|r| r.into_dimensions().ok()); - if let Some((w, h)) = dims.filter(|d| kept(&rule, *d, size, true)) { - // The JPEG data as it is, tagged with `icc` rather than with its own segments; one - // larger than [`MAX_EMBED_PIXELS`] is not written. + // The JPEG data as it is (without its `ICC_PROFILE` segments with `CmykTag::Removed`: its + // inks are encoded again when those can't be taken out), tagged with `icc` rather than + // with its own segments; one larger than [`MAX_EMBED_PIXELS`] is not written. + if let Some((w, h)) = dims.filter(|d| kept(&rule, *d, size, true)) + && let Some(data) = jpeg_data(&blob.bytes, tag == CmykTag::Removed) + { let image = (u64::from(w) * u64::from(h) <= MAX_EMBED_PIXELS) - .then(|| Image::from_jpeg_with_icc(blob.bytes.as_ref().clone().into(), icc.map(|i| i.to_vec().into()), interpolate).ok()) + .then(|| Image::from_jpeg_with_icc(data.into(), icc.map(|i| i.to_vec().into()), interpolate).ok()) .flatten(); return Recoded { image, warning: rule.instead, note: None }; } diff --git a/crates/pdf/src/output.rs b/crates/pdf/src/output.rs index 92381e1628..454e41c5ac 100644 --- a/crates/pdf/src/output.rs +++ b/crates/pdf/src/output.rs @@ -24,7 +24,11 @@ //! always tag them, whatever `profiles` says; PDF/X-1a never does, and always converts to a //! CMYK destination (see [`crate::pdfx`]). A CMYK image that keeps its numbers is tagged with //! the CMYK profile it carries, if any, with `all` and `taggedSource`, and in PDF/A, PDF/X-3 -//! and PDF/X-4 unless `profiles` is `destination` ([`ColorOut::cmyk_image_icc`]). +//! and PDF/X-4 unless `profiles` is `destination` ([`ColorOut::cmyk_image_icc`]). When +//! `profiles` isn't set, colors are written as with `none` and gray and RGB images keep the +//! profiles they carry (but in PDF/X-1a); when it is set to `none`, no image keeps its own +//! profile, a JPEG's `ICC_PROFILE` segments included, except in PDF/A, PDF/X-3 and PDF/X-4 +//! ([`ColorOut::source_profiles`]). //! - 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 @@ -70,6 +74,9 @@ pub(crate) struct ColorOut { /// CMYK images that keep their numbers are tagged with the CMYK profile they carry /// ([`Self::cmyk_image_icc`]). image_icc: bool, + /// Don't Include Profiles was set, in a file that doesn't tag every color: no image keeps its + /// own ICC profile, a JPEG's `ICC_PROFILE` segments included ([`Self::source_profiles`]). + no_profiles: 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 @@ -117,6 +124,7 @@ impl Default for ColorOut { dest: None, tagged: false, image_icc: false, + no_profiles: false, cmyk_profile, srgb_note: None, pixels: OnceLock::new(), @@ -246,9 +254,11 @@ impl ColorOut { } out.dest = Some(Dest { srgb: name == cms::SRGB, cms, model, keep, destination }); } - out.tagged = matches!(set.standard, Standard::PdfA2b | Standard::PdfX3 | Standard::PdfX4) + let profiles = set.output.profiles.unwrap_or_default(); + let tags_all = matches!(set.standard, Standard::PdfA2b | Standard::PdfX3 | Standard::PdfX4); + out.tagged = tags_all || (!set.standard.cmyk_only() - && match set.output.profiles { + && match profiles { ProfileInclusion::None => false, ProfileInclusion::All | ProfileInclusion::Destination => true, ProfileInclusion::TaggedSource => !doc.color_profiles.is_empty(), @@ -256,11 +266,14 @@ impl ColorOut { // Source profiles: with `all` and `taggedSource`, and in the files that tag every colour // unless only the destination profile is asked for; never in PDF/X-1a. out.image_icc = !set.standard.cmyk_only() - && match set.output.profiles { + && match profiles { ProfileInclusion::All | ProfileInclusion::TaggedSource => true, - ProfileInclusion::None => matches!(set.standard, Standard::PdfA2b | Standard::PdfX3 | Standard::PdfX4), + ProfileInclusion::None => tags_all, ProfileInclusion::Destination => false, }; + // A set Don't Include Profiles leaves out every image's own profile, outside the files + // that tag every color. + out.no_profiles = set.output.profiles == Some(ProfileInclusion::None) && !tags_all; 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()), @@ -367,6 +380,33 @@ impl ColorOut { crate::images::cmyk_icc(blob) } + /// How CMYK image `blob` is tagged ([`crate::images::recode_cmyk`]): with the profile + /// [`Self::cmyk_image_icc`] gives (`icc`), else untagged, without a JPEG's own `ICC_PROFILE` + /// segments when Don't Include Profiles was set. + pub(crate) fn cmyk_tag<'a>(&self, icc: Option<&'a [u8]>) -> crate::images::CmykTag<'a> { + use crate::images::CmykTag; + match icc { + Some(icc) => CmykTag::Profile(icc), + None if self.no_profiles => CmykTag::Removed, + None => CmykTag::Untagged, + } + } + + /// What happens to the ICC profile a gray or RGB image carries ([`crate::images::recode`]): + /// left out with its JPEG segments when Don't Include Profiles was set (not in PDF/A, PDF/X-3 + /// and PDF/X-4), left out of the color space in PDF/X-1a, which allows no ICC-based color + /// spaces, and kept otherwise, also when the Profiles setting isn't set. + pub(crate) fn source_profiles(&self) -> crate::images::SourceProfiles { + use crate::images::SourceProfiles; + if self.no_profiles { + SourceProfiles::Remove + } else if self.standard.cmyk_only() { + SourceProfiles::Untag + } else { + SourceProfiles::Keep + } + } + /// How CMYK images' ink amounts are converted; `None` when they keep their numbers (no /// conversion, or to a CMYK destination that keeps them). pub(crate) fn cmyk_pixels(&self) -> Option<&CmykPixels> { diff --git a/crates/pdf/src/settings.rs b/crates/pdf/src/settings.rs index adaaa30c9c..c2f5487cbb 100644 --- a/crates/pdf/src/settings.rs +++ b/crates/pdf/src/settings.rs @@ -387,7 +387,12 @@ pub struct OutputSettings { /// Destination profile name (empty: the document's profile; [`DOCUMENT_CMYK`]: the CMYK /// profile in effect, whatever the document's colour mode). pub destination: String, - pub profiles: ProfileInclusion, + /// The Profile Inclusion setting; `None` (left out, `null` or `"default"`) when it isn't set: + /// colors are then written as with [`ProfileInclusion::None`], while gray and RGB images keep + /// the ICC profiles they carry. A set [`ProfileInclusion::None`] also leaves those out (see + /// [`crate::output`]). + #[serde(deserialize_with = "unset_or_choice")] + pub profiles: Option, /// PDF/X output intent profile name. pub output_intent: String, pub output_condition: String, @@ -397,6 +402,19 @@ pub struct OutputSettings { pub trapped: bool, } +/// The JSON id of a setting that isn't set ([`OutputSettings::profiles`]), as `null` is. +pub const UNSET_CHOICE: &str = "default"; + +/// A choice that may be left unset: `null` and [`UNSET_CHOICE`] are `None`, a choice's id is that +/// choice. +fn unset_or_choice<'de, D: serde::Deserializer<'de>, T: Deserialize<'de>>(d: D) -> Result, D::Error> { + match Option::::deserialize(d)? { + None => Ok(None), + Some(id) if id == UNSET_CHOICE => Ok(None), + Some(id) => T::deserialize(serde::de::value::StringDeserializer::::new(id)).map(Some), + } +} + /// The Advanced section. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] diff --git a/crates/pdf/src/tests/cmykimages.rs b/crates/pdf/src/tests/cmykimages.rs index 590afa3df9..c9746d0fc4 100644 --- a/crates/pdf/src/tests/cmykimages.rs +++ b/crates/pdf/src/tests/cmykimages.rs @@ -573,6 +573,35 @@ fn a_cmyk_image_that_keeps_its_numbers_carries_its_cmyk_profile() { assert!(!String::from_utf8_lossy(&bytes).contains("/S/Luminosity"), "no luminosity soft mask"); } +/// With Don't Include Profiles set, a CMYK JPEG kept as it is is DeviceCMYK without its own +/// `ICC_PROFILE` segments, its compressed inks unchanged; one resampled and compressed again is +/// written without a profile too. Left unset, the JPEG data stays as it is. +#[test] +fn a_set_dont_include_profiles_takes_a_cmyk_jpegs_profile_out() { + let ink = [0u8, 102, 255, 0]; + let icc = cmyk_profile(|_| {}); + let jpg = cmyk_jpeg_with(&flat(ink, 64 * 64), 64, 64, &icc); + let d = image_doc(ImageBlob::new("image/jpeg", jpg.clone()), 64, 64); + let export = |s: serde_json::Value| { + let out = export_with_report(&d, &opts(s.clone())).unwrap_or_else(|e| panic!("{s}: {e}")); + let w: Vec = written(&out.bytes).into_iter().filter(|w| w.space != "DeviceGray").collect(); + assert_eq!(w.len(), 1, "{s}: {w:?}"); + (w.into_iter().next().unwrap(), out.bytes) + }; + let segment = |data: &[u8]| data.windows(12).any(|w| w == b"ICC_PROFILE\0"); + let (w, _) = export(json!({})); + assert!(w.data == jpg && segment(&w.data), "unset: the JPEG data unchanged"); + let s = json!({"output": {"profiles": "none"}}); + let (w, bytes) = export(s.clone()); + assert_eq!((w.space.as_str(), w.filter.as_deref()), ("DeviceCMYK", Some("DCTDecode")), "{s}"); + assert!(!segment(&w.data) && w.data.len() < jpg.len(), "{s}: no ICC_PROFILE segment is left"); + assert!(w.data == vectorcraft_doc::cmyk::jpeg_without_icc(&jpg).unwrap(), "{s}: the other segments and the scan unchanged"); + assert_eq!(blobs(&import(&bytes).unwrap())[0].cmyk().unwrap().data, ImageBlob::new("image/jpeg", jpg.clone()).cmyk().unwrap().data); + let s = json!({"output": {"profiles": "none"}, "compression": {"color": {"downsample": "bicubic", "ppi": 72.0, "abovePpi": 100.0}}}); + let (w, _) = export(s.clone()); + assert!(w.icc.is_none() && !segment(&w.data), "{s}: {w:?}"); +} + /// A CMYK JPEG kept as it is whose header declares far more pixels than the app reads is left out /// with a warning, as other images that large are. #[test] diff --git a/crates/pdf/src/tests/compression.rs b/crates/pdf/src/tests/compression.rs index f36cb88e4b..26c6826723 100644 --- a/crates/pdf/src/tests/compression.rs +++ b/crates/pdf/src/tests/compression.rs @@ -266,7 +266,7 @@ fn images_declaring_more_than_the_allowance_are_not_decoded() { assert_eq!(crate::images::mask_alpha(&blob, 60_000, 60_000, false), None); assert_eq!(crate::images::mask_alpha(&blob, 2, 2, false), None, "another size is not decoded at all"); let c: CompressionSettings = serde_json::from_value(json!({"gray": {"downsample": "bicubic", "ppi": 72, "abovePpi": 100}})).unwrap(); - let r = crate::images::recode(&png, (10.0, 10.0), &c, false, None, true, None); + let r = crate::images::recode(&png, (10.0, 10.0), &c, false, None, crate::images::SourceProfiles::Keep, None); assert!(r.image.is_none(), "kept as it is"); } @@ -425,6 +425,80 @@ fn pdf_x1a_keeps_a_tagged_gray_jpeg_as_jpeg() { } } +/// With Don't Include Profiles set, a gray or RGB image is written without the ICC profile it +/// carries: a PNG, JPEG or TIFF embedded as it is, resampled or compressed with JPEG, with alpha and +/// at 16 bits. A JPEG kept as it is loses its `ICC_PROFILE` segments, its compressed samples +/// unchanged. Left unset, or `null`, the setting keeps the profiles. PDF/X-4 files keep them +/// whatever the setting says. +#[test] +fn a_set_dont_include_profiles_removes_image_profiles() { + let (rgb, gray) = (icc_profile(b"RGB ", 600), icc_profile(b"GRAY", 300)); + let mut clear = colour().to_rgba8(); + clear.put_pixel(0, 0, image::Rgba([10, 20, 30, 0])); + let deep = DynamicImage::ImageRgb16(colour().to_rgb16()); + let export = |d: &Document, settings: Value| { + let mut settings: PdfSettings = serde_json::from_value(settings).unwrap(); + settings.compression.compress_text = false; + export_with_report(d, &PdfOptions { settings, ..Default::default() }).unwrap() + }; + let resampled = + json!({"color": {"downsample": "bicubic", "ppi": 150, "abovePpi": 225}, "gray": {"downsample": "bicubic", "ppi": 150, "abovePpi": 225}}); + let jpeg = json!({"color": {"compression": "jpeg"}, "gray": {"compression": "jpeg"}}); + let cases = [ + ("RGB PNG", tagged(&colour(), &rgb, ImageFormat::Png), "image/png", json!({}), "DeviceRGB"), + ("gray PNG", tagged(&colour().grayscale(), &gray, ImageFormat::Png), "image/png", json!({}), "DeviceGray"), + ("RGB JPEG", tagged(&colour(), &rgb, ImageFormat::Jpeg), "image/jpeg", json!({}), "DeviceRGB"), + ("gray JPEG", tagged(&colour().grayscale(), &gray, ImageFormat::Jpeg), "image/jpeg", json!({}), "DeviceGray"), + ("RGB TIFF", tagged(&colour(), &rgb, ImageFormat::Tiff), "image/tiff", json!({}), "DeviceRGB"), + ("gray TIFF", tagged(&colour().grayscale(), &gray, ImageFormat::Tiff), "image/tiff", json!({}), "DeviceGray"), + ("RGBA TIFF", tagged(&clear.into(), &rgb, ImageFormat::Tiff), "image/tiff", json!({}), "DeviceRGB"), + ("16-bit TIFF", tagged(&deep, &rgb, ImageFormat::Tiff), "image/tiff", json!({}), "DeviceRGB"), + ("RGB PNG, resampled", tagged(&colour(), &rgb, ImageFormat::Png), "image/png", resampled.clone(), "DeviceRGB"), + ("gray JPEG, resampled", tagged(&colour().grayscale(), &gray, ImageFormat::Jpeg), "image/jpeg", resampled, "DeviceGray"), + ("RGB TIFF as JPEG", tagged(&colour(), &rgb, ImageFormat::Tiff), "image/tiff", jpeg.clone(), "DeviceRGB"), + ("gray TIFF as JPEG", tagged(&colour().grayscale(), &gray, ImageFormat::Tiff), "image/tiff", jpeg, "DeviceGray"), + ]; + for (what, bytes, mime, compression, device) in cases { + let d = doc(bytes.clone(), mime); + // Left unset, or null, the image keeps its profile. + for profiles in [json!({}), json!({"profiles": null})] { + let r = export(&d, json!({"compression": compression.clone(), "output": profiles})); + let spaces = image_spaces(&r.bytes); + assert!(spaces.len() == 1 && spaces[0].space == "ICCBased" && spaces[0].icc.is_some(), "{what}, {profiles}: {spaces:?}"); + } + let none = export(&d, json!({"compression": compression.clone(), "output": {"profiles": "none"}})); + assert!(none.warnings.is_empty(), "{what}: {:?}", none.warnings); + let spaces = image_spaces(&none.bytes); + assert!(spaces.len() == 1 && spaces[0].space == device && spaces[0].icc.is_none(), "{what}: {spaces:?}"); + let data = image_data(&none.bytes); + assert!(data.iter().all(|d| !d.windows(12).any(|w| w == b"ICC_PROFILE\0")), "{what}: no ICC_PROFILE segment is left"); + if mime == "image/jpeg" && compression == json!({}) { + // The JPEG data without its profile segments: the image decoder reads the same pixels + // from it, and no profile. + assert!(data.len() == 1 && data[0].len() < bytes.len(), "{what}: the JPEG data kept"); + let decode = |b: &[u8]| image::load_from_memory_with_format(b, ImageFormat::Jpeg).unwrap(); + assert!(decode(&data[0]) == decode(&bytes), "{what}: the same pixels"); + assert!(crate::images::source_icc(&data[0]).is_none() && crate::images::source_icc(&bytes).is_some(), "{what}"); + } + // PDF/X-4 tags every colour: the image keeps its profile. + let x4 = export(&d, json!({"standard": "pdfX4", "compatibility": "1.6", "compression": compression, "output": {"profiles": "none"}})); + let spaces = image_spaces(&x4.bytes); + assert!(spaces.len() == 1 && spaces[0].space == "ICCBased", "{what}, PDF/X-4: {spaces:?}"); + } + // A JPEG whose `ICC_PROFILE` segments don't make up a profile (the first of two, the second + // missing) loses them too. + let plain = encoded(colour(), ImageFormat::Jpeg); + let mut part = vec![0xFF, 0xD8, 0xFF, 0xE2, 0, 30]; + part.extend_from_slice(b"ICC_PROFILE\0"); + part.extend_from_slice(&[1, 2]); + part.extend_from_slice(&rgb[..14]); + part.extend_from_slice(&plain[2..]); + assert!(crate::images::source_icc(&part).is_none(), "the decoder reads no profile"); + let none = export(&doc(part, "image/jpeg"), json!({"output": {"profiles": "none"}})); + let data = image_data(&none.bytes); + assert!(data == [plain], "an unreadable profile: the JPEG data without its segments"); +} + /// A gray or RGB TIFF that carries an ICC profile of its colour space is written in an ICC-based /// colour space holding the same profile, at 16 bits a sample from PDF 1.5 on, with its alpha as /// a soft mask; and so is a tagged TIFF or PNG that is resampled or compressed with JPEG. Pixels @@ -778,9 +852,10 @@ fn convert_to_srgb_converts_tagged_images_from_their_profile() { let c: CompressionSettings = serde_json::from_value(json!({})).unwrap(); let srgb_out = Some(crate::images::ToSrgb { intent: Intent::RelativeColorimetric, sixteen_bit: true }); for (what, png) in [("RGB", declared(&colour(), &p3)), ("gray", declared(&colour().grayscale(), &gray))] { - let r = crate::images::recode(&png, (100.0, 100.0), &c, false, None, true, srgb_out); + let keep = crate::images::SourceProfiles::Keep; + let r = crate::images::recode(&png, (100.0, 100.0), &c, false, None, keep, srgb_out); assert!(r.image.is_none() && r.warning.is_some_and(|w| w.contains("not converted to sRGB")), "{what}: {:?}", r.warning); - let r = crate::images::recode(&png, (100.0, 100.0), &c, false, None, true, None); + let r = crate::images::recode(&png, (100.0, 100.0), &c, false, None, keep, None); assert!(r.image.is_none() && r.warning.is_none(), "{what}, no conversion: {:?}", r.warning); // Too large to embed as it is too: the export leaves it out, and says only that. let mut settings: PdfSettings = serde_json::from_value(to_srgb(json!({}))).unwrap(); diff --git a/crates/pdf/src/tests/settings.rs b/crates/pdf/src/tests/settings.rs index e159b7b162..d4dd5a63ce 100644 --- a/crates/pdf/src/tests/settings.rs +++ b/crates/pdf/src/tests/settings.rs @@ -41,6 +41,24 @@ fn json_is_camel_case_and_partial_objects_keep_defaults() { } } +/// The Profiles setting is unset when left out, `null` or `"default"` (written as `null`), and set +/// by a choice's id; another value is refused. +#[test] +fn the_profiles_setting_can_be_left_unset() { + assert_eq!(serde_json::to_value(PdfSettings::default()).unwrap()["output"]["profiles"], serde_json::Value::Null); + for unset in [json!({}), json!({"profiles": null}), json!({"profiles": "default"})] { + assert_eq!(settings(json!({"output": unset.clone()})).output.profiles, None, "{unset}"); + } + for (id, want) in [("none", ProfileInclusion::None), ("all", ProfileInclusion::All), ("taggedSource", ProfileInclusion::TaggedSource)] { + let s = settings(json!({"output": {"profiles": id}})); + assert_eq!(s.output.profiles, Some(want), "{id}"); + assert_eq!(serde_json::from_value::(serde_json::to_value(&s).unwrap()).unwrap(), s, "{id} round-trips"); + } + for bad in [json!("Default"), json!("never"), json!(1)] { + assert!(serde_json::from_value::(json!({"output": {"profiles": bad.clone()}})).is_err(), "{bad}"); + } +} + #[test] fn passwords_are_never_serialized() { let s = settings(json!({"security": {"openPassword": "secret", "permissionsPassword": "owner"}})); diff --git a/crates/ui-egui/src/dialogs/save_pdf.rs b/crates/ui-egui/src/dialogs/save_pdf.rs index 225c2cee52..4f8bdf7667 100644 --- a/crates/ui-egui/src/dialogs/save_pdf.rs +++ b/crates/ui-egui/src/dialogs/save_pdf.rs @@ -325,6 +325,19 @@ pub(super) fn pick(ui: &mut egui::Ui, d: &mut Dialog, path: &str, wid true } +/// [`pick`] with a first entry, `unset`, for a setting that isn't set: shown while the value at +/// `path` isn't one of the choices, and stored as [`vectorcraft_pdf::UNSET_CHOICE`] when chosen. +fn pick_or_unset(ui: &mut egui::Ui, d: &mut Dialog, path: &str, width: f32, unset: &str) -> bool { + let current = choice_index::(d, path).map_or(0, |i| i + 1); + let lang = crate::i18n::current(); + let labels: Vec<&str> = std::iter::once(unset).chain(T::LABELS.iter().map(|label| choice_label(lang, path, label))).collect(); + let label = labels.get(current).copied().unwrap_or(unset); + let Some(i) = widgets::dropdown_with(ui, path, label, &labels, width, |_| true) else { return false }; + let id = i.checked_sub(1).and_then(|i| T::IDS.get(i)).copied().unwrap_or(vectorcraft_pdf::UNSET_CHOICE); + set(d, path, json!(id)); + true +} + /// Printer mark styles use adjectives, separately from interface language names. pub(super) fn choice_label<'a>(lang: crate::i18n::Lang, path: &str, label: &'a str) -> &'a str { if path == "marks.kind" { crate::i18n::tr_ctx(lang, "mark-style", label) } else { crate::i18n::tr(lang, label) } @@ -663,8 +676,9 @@ fn output(ui: &mut egui::Ui, d: &mut Dialog) { .collect(); row(ui, tl!("Destination:"), |ui| profile_pick(ui, d, "output.destination", &destinations, 2, converting)); row(ui, tl!("Profile inclusion:"), |ui| { - // A standard decides whether colours are tagged. - ui.add_enabled_ui(standard == Standard::None, |ui| pick::(ui, d, "output.profiles", 300.0, |_| true)); + // A standard decides whether colours are tagged. Not set, colors are untagged and gray and + // RGB images keep their profiles (shown as Default). + ui.add_enabled_ui(standard == Standard::None, |ui| pick_or_unset::(ui, d, "output.profiles", 300.0, tl!("Default"))); }); if standard.cmyk_only() { note(ui, tl!("PDF/X-1a files are CMYK: every colour is converted to the destination (blank: the output intent's CMYK profile), untagged.")); diff --git a/docs/mcp.md b/docs/mcp.md index c38d6fc7a2..f805e6a40d 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -5917,7 +5917,11 @@ for its colour mode). `profiles` (`all`, `destination`, or `taggedSource` for a the document's CMYK profile, RGB as sRGB, grey with the sRGB tone curve (PDF/A, PDF/X-3 and PDF/X-4 files are always tagged, whatever `profiles` says). A CMYK image that keeps its numbers carries the CMYK profile it embeds with `all` and `taggedSource`, and in PDF/A, PDF/X-3 and PDF/X-4 unless `profiles` is `destination`; PDF/X-1a -images are always untagged CMYK. `outputIntent` embeds a profile as the file's +images are always untagged CMYK. Left unset (the default; `"default"` unsets it over a preset, while `null` keeps the +preset's value), `profiles` writes device colours and gray and RGB images keep the profiles they carry (not in +PDF/X-1a). Set to `none`, it also writes every image without the profile it carries, and a JPEG kept as it is without +its `ICC_PROFILE` segments, its other segments and compressed samples unchanged; PDF/A, PDF/X-3 and PDF/X-4 files keep +the profiles they tag images with. The Save PDF dialog lists the unset value as Default. `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). Type is written as selectable, searchable text in embedded subset fonts with a ToUnicode map (fonts whose licence forbids embedding stay outlines, with a warning); `advanced: {outlineText: diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 4c8b2d43f8..0b6bb3ac08 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1930,6 +1930,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - The pixels of a PNG, JPEG, GIF or WebP file that Place decodes to check them are kept in the image store the renderers share, so the canvas's first frame with the placed image draws them without decoding the file again on the UI thread: File › Place, a file dropped on the canvas, a click or drag of the place cursor, `file.place`, and Paste of such an image. A CMYK JPEG is still decoded when it is first drawn (`crates/ui-egui/src/placing.rs`, `crates/render/src/tests.rs`). - A photo whose EXIF orientation turns or flips it (a camera held upright or upside down) is placed and opened upright, as it was taken; the image is stored as it is seen (#4284). A quarter turn swaps the resolution's axes, so the image keeps its physical size. TIFFs read without the image decoder (grayscale with extra channels, CCITT Group 3, CMYK with one plane per ink) follow their Orientation tag too, and a turned CMYK JPEG or TIFF keeps its inks, at the 8 or 16 bits the file has, as a CMYK TIFF. - PDF export writes a CMYK image that keeps its numbers in an `ICCBased` color space holding, byte for byte, the CMYK profile the image carries (a CMYK TIFF's tag 34675, or a CMYK JPEG's `ICC_PROFILE` APP2 segments) when `output.profiles` is `all` or `taggedSource`, and in PDF/A-2b, PDF/X-3 and PDF/X-4 unless it is `destination` ([the help page](https://helpx.adobe.com/illustrator/desktop/save-and-export/export-files-to-different-formats/pdf-marks-bleeds-and-color-output-options.html)). This covers a CMYK JPEG embedded as it is, one the Compression settings resample or compress again, a TIFF written again, and an image written with its mask as its soft mask. With `none`, and in PDF/X-1a, the image is DeviceCMYK and a JPEG's own profile is left out, so PDF/X-1a export writes a CMYK JPEG that carries a profile. Pixels the Output settings convert are never tagged with the profile of their old numbers. A profile tags an image only when its size, signature, class, CMYK color space and tag table are well formed and it is at most 16 MiB (VectorCraft's own limit). Transparency in a tagged CMYK document blends in the document's CMYK profile; when the file holds no space of that profile it blends in DeviceCMYK, or keeps its RGB space in PDF/A, and never in an image's own profile (`crates/pdf/src/tests/cmykimages.rs`, `export::writer::tests`). + - PDF export with `output.profiles` set to `none` (Don't Include Profiles) writes no image with the ICC profile it carries, as [the help page](https://helpx.adobe.com/illustrator/desktop/save-and-export/export-files-to-different-formats/pdf-marks-bleeds-and-color-output-options.html) describes for that setting: gray and RGB PNG, JPEG and TIFF images embedded as they are, resampled or compressed again, and CMYK JPEG images. A JPEG kept as it is loses its `ICC_PROFILE` APP2 segments, its other segments and compressed samples unchanged. PDF/A-2b, PDF/X-3 and PDF/X-4 files keep the profiles they tag images with. Left unset (the default, `"default"`, listed as Default in Save PDF), the setting writes device colors while gray and RGB images keep their profiles, as before; that default is VectorCraft's own rule (`a_set_dont_include_profiles_removes_image_profiles`, `a_set_dont_include_profiles_takes_a_cmyk_jpegs_profile_out`). - PDF import keeps the CMYK profile of an image in a four-component `ICCBased` color space, whose stream holds the profile ([ISO 32000-1, §8.6.5.5](https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf#page=157)), byte for byte with the image's ink amounts, so PDF export can write the image in it again. A CMYK TIFF carries it as tag 34675, whatever the image's Decode array and bit depth ([ICC.1:2010, Annex B](https://www.color.org/specification/ICC1v43_2010-12.pdf)). A CMYK JPEG kept as it is carries it in `ICC_PROFILE` APP2 segments in place of a profile of its own, with its compressed samples unchanged, and one whose segments can't hold it is stored as a CMYK TIFF carrying it. A profile that isn't well formed, isn't of CMYK colors or is over 16 MiB (VectorCraft's own limit) leaves the image's inks as they are, untagged. DeviceCMYK and DeviceN images carry no profile, and a CMYK TIFF's tag 34675 is read up to 16 MiB. EPS and PostScript import does not keep one: PostScript defines no `ICCBased` profile stream (`crates/pdf/src/tests/cmykimages.rs`, `cmyk::tests`). - An image that Open and Place store again keeps the ICC profile its file embeds and its 16-bit samples. A turned PNG, JPEG, WebP or TIFF stored as PNG, and a TIFF the image decoder reads, keep their gray or RGB samples at 8 or 16 bits with their gray or RGB profile in the PNG's `iCCP` chunk ([PNG specification, iCCP](https://www.w3.org/TR/png-3/#11iCCP)). A CMYK TIFF stored with one plane per ink and a turned CMYK JPEG or TIFF keep their CMYK profile as TIFF tag 34675, a JPEG's put together from all its APP2 segments ([ICC image-format guide](https://github.com/InternationalColorConsortium/iccDEV/blob/master/iccdev-mcp/docs/icc-format-guide.md)). A profile whose color space differs from the stored samples' is left out. The profile is kept in the stored image; how CMYK inks are drawn and exported to PDF does not change (`crates/engine/src/cmd/fileio/tests.rs`). - PDF export writes a stored gray or RGB TIFF in an `ICCBased` color space that holds the gray or RGB profile of its tag 34675, at 16 bits a sample from PDF 1.5 on and 8 before, with its alpha as a soft mask only when some pixel is not opaque ([ISO 32000-1, §8.6.5.5](https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf#page=157)). A gray or RGB image, a PNG included, that the Compression settings resample or compress with JPEG keeps its profile too, and gray pixels in an RGB profile stay RGB. Pixels the Output settings convert, a profile whose color space differs from the samples', and a malformed profile, a PNG's or JPEG's kept as it is included, are written without it; such a JPEG keeps its JPEG data. PDF/X-1a allows no ICC-based color spaces, so there a gray image with a gray profile, kept as it is, resampled or compressed with JPEG, is written in DeviceGray without the profile, a JPEG kept as JPEG with its JPEG data unchanged, and RGB images are converted to CMYK as before. CMYK images are unchanged (`crates/pdf/src/tests/compression.rs`).