From 32dbc4b641e692c5d83e53c774432757c9d9064c Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Wed, 14 Oct 2026 16:44:47 +0200 Subject: [PATCH 1/2] PDF export: the output intent shares the stream of the profile that tags the colours (fixes #1954) --- crates/engine/src/cmd/fileio/pdf.rs | 2 +- crates/pdf/src/output.rs | 54 ++++++++++++++++++++++++----- crates/pdf/src/tests_output.rs | 46 ++++++++++++++++++++++++ crates/pdf/src/tests_pdfx.rs | 6 +++- docs/mcp.md | 3 +- 5 files changed, 100 insertions(+), 11 deletions(-) diff --git a/crates/engine/src/cmd/fileio/pdf.rs b/crates/engine/src/cmd/fileio/pdf.rs index fdbf6e083..1703bece0 100644 --- a/crates/engine/src/cmd/fileio/pdf.rs +++ b/crates/engine/src/cmd/fileio/pdf.rs @@ -109,7 +109,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 (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; 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), 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). 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, 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", + "{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 (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; 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), 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). 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, pdfX3 and pdfX4 always, whatever profiles says), 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", has_doc, export_pdf ), diff --git a/crates/pdf/src/output.rs b/crates/pdf/src/output.rs index 7013f7899..30a32c5e3 100644 --- a/crates/pdf/src/output.rs +++ b/crates/pdf/src/output.rs @@ -14,13 +14,13 @@ //! 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, PDF/X-3 and PDF/X-4 -//! always tag them; PDF/X-1a never does, and always converts to a CMYK destination (see -//! [`crate::pdfx`]). +//! always tag them, whatever `profiles` says; PDF/X-1a never does, and always converts to a +//! CMYK destination (see [`crate::pdfx`]). //! - 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. PDF/X files always have one: blank, the CMYK -//! profile in effect. +//! profile in effect. A profile that also tags colours is embedded once, shared by both. use std::sync::{Arc, OnceLock}; @@ -327,11 +327,19 @@ impl ColorOut { } if let Some((name, n)) = profile { let icc = cms::icc_bytes(name).map_err(|e| PdfError::Write(format!("output intent profile: {e}")))?; - let data = deflate(&icc).map_err(|e| PdfError::Write(format!("output intent profile: {e}")))?; - let mut stream = format!("<>\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))); + // The profile tagging the colours is often this one: both share its stream. + let shared = if self.tagged { tagging_stream(&pdf, &xref, &icc) } else { None }; + let stream = match shared { + Some(s) => s, + None => { + let data = deflate(&icc).map_err(|e| PdfError::Write(format!("output intent profile: {e}")))?; + let mut stream = format!("<>\nstream\n", data.len()).into_bytes(); + stream.extend_from_slice(&data); + stream.extend_from_slice(b"\nendstream"); + patch.add_object(stream) + } + }; + dict.push_str(&format!("/DestOutputProfile {stream} 0 R")); } dict.push_str(">>"); let oi = patch.add_object(dict.into_bytes()); @@ -419,6 +427,36 @@ fn catalog(set: &PdfSettings, cmyk_profile: &str) -> Option { (intent.is_some() || trapped.is_some()).then_some(Catalog { intent, trapped }) } +/// The object number of the stream of an ICC-based colour space of `pdf` that holds profile `icc` +/// (its data as it is, or Flate-compressed); `None` when no colour space uses that profile. +fn tagging_stream(pdf: &[u8], xref: &Xref, icc: &[u8]) -> Option { + use std::io::Read; + const ICC: &[u8] = b"[/ICCBased "; + let mut from = 0; + while let Some(at) = crate::lab_spot::find(pdf, ICC, from) { + from = at + ICC.len(); + let Some(n) = crate::patch::number(pdf, from).and_then(|(n, _)| u32::try_from(n).ok()) else { continue }; + let Some(obj) = xref.dict(pdf, n).and_then(|(s, e)| pdf.get(s..e)) else { continue }; + let Some(length) = crate::lab_spot::find(obj, b"/Length", 0).and_then(|i| crate::patch::number(obj, i + b"/Length".len())) else { + continue; + }; + let Some(data) = crate::lab_spot::find(obj, b"stream", length.1).map(|i| i + b"stream".len()) else { continue }; + let data = data + obj.get(data..).map_or(0, |r| r.iter().take(2).take_while(|b| matches!(b, b'\r' | b'\n')).count()); + let Some(raw) = data.checked_add(length.0).and_then(|e| obj.get(data..e)) else { continue }; + let same = if crate::lab_spot::find(obj.get(..data).unwrap_or_default(), b"/FlateDecode", 0).is_some() { + let mut out = vec![]; + // One byte more than the profile is enough to tell a longer one apart. + flate2::read::ZlibDecoder::new(raw).take(icc.len() as u64 + 1).read_to_end(&mut out).is_ok() && out == icc + } else { + raw == icc + }; + if same { + return Some(n); + } + } + None +} + /// `s` as a PDF text string: a literal string when it is printable ASCII, else UTF-16BE (with its /// byte order mark) in hex. pub(crate) fn text_string(s: &str) -> String { diff --git a/crates/pdf/src/tests_output.rs b/crates/pdf/src/tests_output.rs index 43ecd79e2..009bd3d78 100644 --- a/crates/pdf/src/tests_output.rs +++ b/crates/pdf/src/tests_output.rs @@ -313,3 +313,49 @@ fn pdf_a_tags_cmyk_colours_with_the_cmyk_profile() { let r = pdf(&d, json!({"standard": "pdfA2b"})); assert!(icc_profiles(&r.bytes).iter().any(|p| p.0 == 4)); } + +/// The streams of `bytes` that hold an ICC profile (`acsp` at offset 36), decoded. +fn icc_streams(bytes: &[u8]) -> Vec> { + let file = Pdf::new(Arc::new(bytes.to_vec())).unwrap(); + let count = crate::patch::Xref::read(bytes).unwrap().count() as i32; + (1..count) + .filter_map(|n| file.xref().get::>(ObjectIdentifier::new(n, 0))) + .map(|s| s.decoded().unwrap().into_owned()) + .filter(|data| data.get(36..40) == Some(b"acsp")) + .collect() +} + +/// [`pdf`] with the version and the creation date the standard of `settings` asks for. +fn standard_pdf(d: &Document, settings: Value) -> ExportReport { + let mut settings: PdfSettings = serde_json::from_value(settings).unwrap(); + (settings.compatibility, settings.compression.compress_text) = (settings.standard.version(), false); + export_with_report(d, &PdfOptions { settings, created: Some(1_700_000_000), ..Default::default() }).unwrap() +} + +#[test] +fn a_profile_that_tags_colours_and_is_the_output_intent_is_embedded_once() { + let d = doc(ColorMode::Cmyk, &[Color::cmyk(0.1, 0.2, 0.3, 0.4)]); + let working = cms::active_settings().cmyk; + let icc = cms::icc_bytes(&working).unwrap(); + for set in [ + json!({"standard": "pdfX4"}), + json!({"standard": "pdfX4", "output": {"outputIntent": working}}), + // PDF/X-3 and PDF/X-4 always tag their colours: `profiles` doesn't apply. + json!({"standard": "pdfX4", "output": {"profiles": "none", "outputIntent": working}}), + json!({"standard": "pdfX3"}), + json!({"output": {"profiles": "all", "outputIntent": working}}), + ] { + let r = standard_pdf(&d, set.clone()); + let streams = icc_streams(&r.bytes); + assert_eq!(streams.len(), 1, "{set}: ICC profile streams"); + assert!(streams[0] == *icc, "{set}: the working CMYK profile"); + assert!(colours(&r.bytes).iter().all(|c| c.0 == "scn"), "{set}: tagged colours"); + output_intent(&r.bytes, None, |oi| assert_eq!(oi.get::>(b"DestOutputProfile").unwrap().decoded().unwrap().as_ref(), &*icc)); + } + // Another output intent profile is its own stream. + let r = standard_pdf(&d, json!({"standard": "pdfX4", "output": {"outputIntent": DEVICE_CMYK}})); + assert_eq!(icc_streams(&r.bytes).len(), 2); + output_intent(&r.bytes, None, |oi| { + assert_eq!(oi.get::>(b"DestOutputProfile").unwrap().decoded().unwrap().as_ref(), &*cms::icc_bytes(DEVICE_CMYK).unwrap()) + }); +} diff --git a/crates/pdf/src/tests_pdfx.rs b/crates/pdf/src/tests_pdfx.rs index d7dbee693..8149839c6 100644 --- a/crates/pdf/src/tests_pdfx.rs +++ b/crates/pdf/src/tests_pdfx.rs @@ -57,7 +57,11 @@ fn each_standard_writes_its_header_output_intent_boxes_and_identification() { for want in ["/OutputIntents[", "/S/GTS_PDFX", "/DestOutputProfile ", &format!("/OutputConditionIdentifier({profile})")] { assert!(t.contains(want), "{standard:?}: {want}"); } - assert!(t.contains("/N 4/Length"), "{standard:?}: a CMYK profile"); + // (PDF/X-3 and PDF/X-4 share the stream of the profile that tags the colours.) + let oi: u32 = t.split("/DestOutputProfile ").nth(1).unwrap().split(' ').next().unwrap().parse().unwrap(); + let file = hayro_syntax::Pdf::new(std::sync::Arc::new(r.bytes.clone())).unwrap(); + let profile = file.xref().get::>(hayro_syntax::object::ObjectIdentifier::new(oi as i32, 0)).unwrap(); + assert_eq!(profile.dict().get::(b"N"), Some(4), "{standard:?}: a CMYK profile"); // Every page is trimmed to its artboard; the file is untrapped, titled and dated. for want in ["/TrimBox[0 0 200 100]", "/Trapped/False", "/Title(Untitled)", "/CreationDate", "/ModDate"] { assert!(t.contains(want), "{standard:?}: {want}"); diff --git a/docs/mcp.md b/docs/mcp.md index b3edd9f3b..ac706ade3 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1676,7 +1676,8 @@ the other model (`preserveNumbers`: colours already in it keep their numbers); g 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 +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). `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: From e6b28f0e802824d5dc9f585c196a4f3406203786 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 16 Oct 2026 04:31:29 +0700 Subject: [PATCH 2/2] Tests: read the PDF/X output intent's profile with the crate's own xref reader (#1962) --- crates/pdf/src/tests_pdfx.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/crates/pdf/src/tests_pdfx.rs b/crates/pdf/src/tests_pdfx.rs index 8149839c6..400662b76 100644 --- a/crates/pdf/src/tests_pdfx.rs +++ b/crates/pdf/src/tests_pdfx.rs @@ -59,9 +59,8 @@ fn each_standard_writes_its_header_output_intent_boxes_and_identification() { } // (PDF/X-3 and PDF/X-4 share the stream of the profile that tags the colours.) let oi: u32 = t.split("/DestOutputProfile ").nth(1).unwrap().split(' ').next().unwrap().parse().unwrap(); - let file = hayro_syntax::Pdf::new(std::sync::Arc::new(r.bytes.clone())).unwrap(); - let profile = file.xref().get::>(hayro_syntax::object::ObjectIdentifier::new(oi as i32, 0)).unwrap(); - assert_eq!(profile.dict().get::(b"N"), Some(4), "{standard:?}: a CMYK profile"); + let (start, end) = crate::patch::Xref::read(&r.bytes).unwrap().dict(&r.bytes, oi).unwrap(); + assert!(text(&r.bytes[start..end]).contains("/N 4"), "{standard:?}: a CMYK profile"); // Every page is trimmed to its artboard; the file is untrapped, titled and dated. for want in ["/TrimBox[0 0 200 100]", "/Trapped/False", "/Title(Untitled)", "/CreationDate", "/ModDate"] { assert!(t.contains(want), "{standard:?}: {want}");