Merge pull request #1962 from marcoarnulfo/fix/pdfx-icc-once

PDF export: the output intent shares the stream of the profile that tags the colours
This commit is contained in:
BFlat
2026-10-16 05:09:12 +07:00
committed by GitHub
5 changed files with 99 additions and 11 deletions
File diff suppressed because one or more lines are too long
+46 -8
View File
@@ -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!("<</N {n}/Length {}/Filter/FlateDecode>>\nstream\n", data.len()).into_bytes();
stream.extend_from_slice(&data);
stream.extend_from_slice(b"\nendstream");
dict.push_str(&format!("/DestOutputProfile {} 0 R", patch.add_object(stream)));
// 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!("<</N {n}/Length {}/Filter/FlateDecode>>\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<Catalog> {
(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<u32> {
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 {
+46
View File
@@ -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<Vec<u8>> {
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::<Stream<'_>>(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::<Stream<'_>>(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::<Stream<'_>>(b"DestOutputProfile").unwrap().decoded().unwrap().as_ref(), &*cms::icc_bytes(DEVICE_CMYK).unwrap())
});
}
+4 -1
View File
@@ -57,7 +57,10 @@ 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 (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}");
+2 -1
View File
@@ -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: