mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 09:46:59 -06:00
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:
File diff suppressed because one or more lines are too long
@@ -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 {
|
||||
|
||||
@@ -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())
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user