mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-22 19:08:39 +00: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:
5 files changed
+99
-11
No files matched your search
@@ -109,7 +109,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"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), form
Line truncated
|
||||
"{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 a
Line truncated
|
||||
has_doc,
|
||||
export_pdf
|
||||
),
|
||||
|
||||
@@ -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