mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-25 10:17:02 -06:00
compatibility "1.3" writes a %PDF-1.3 file (its XMP metadata says 1.3 too) without transparency: a copy of the document is flattened first with flattenerPreset (stored with PDF presets; empty is High Resolution) and the flattener options over it, see-through image pixels counting as transparency and rasterized areas clipped to their regions so no soft mask is needed. Page groups are left out and the writer refuses a file that would still have transparency. PDF/X-1a and PDF/X-3 share this path (their version is now PDF 1.3; 1.4 is still accepted and written as 1.3), and with it the preset, the header and metadata code and the transparency check. Passwords at PDF 1.3 encrypt with 40-bit RC4 (revision 2). The Save PDF dialog lists PDF 1.3 and picks the flattener preset under Advanced. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
273 lines
13 KiB
Rust
273 lines
13 KiB
Rust
//! PDF/X files (print exchange): what each standard asks of a file, and the entries that say which
|
|
//! one it is.
|
|
//!
|
|
//! - PDF/X-1a:2001: every colour is CMYK, grey or a spot ink with a CMYK alternate (colours and
|
|
//! images are converted to a CMYK destination and written untagged, see [`crate::output`]), no
|
|
//! transparency (the app flattens it before writing), no layers, PDF 1.3.
|
|
//! - PDF/X-3:2002: colours are ICC-based (always tagged), no transparency, no layers, PDF 1.3.
|
|
//! - PDF/X-4:2010: transparency and layers are kept, colours are tagged, PDF 1.6 at most, and the
|
|
//! XMP metadata names the standard too.
|
|
//! - All of them: a `/GTS_PDFX` output intent (blank, the CMYK profile in effect, embedded), a
|
|
//! TrimBox on every page (the artboard), `/Trapped` True or False, `/GTS_PDFXVersion` in the
|
|
//! document information, a title and dates, embedded fonts (or text as outlines), no editing
|
|
//! data and no encryption (both refused by the settings checks).
|
|
//!
|
|
//! PDF/X-1a and PDF/X-3 files are PDF 1.3 files ([`crate::PdfSettings::pdf13`]): flat, written with
|
|
//! the PDF 1.4 settings, their header and metadata saying 1.3. The written file is checked ([`verify`]): what the
|
|
//! standard forbids that is still there refuses the export, rather than a file claiming a
|
|
//! standard it breaks.
|
|
|
|
use vectorcraft_color::cms::{self, Cms, ProfileKind};
|
|
|
|
use crate::lab_spot::find;
|
|
use crate::patch::{Patch, Xref};
|
|
use crate::{PdfError, PdfSettings, Standard};
|
|
|
|
impl Standard {
|
|
/// A PDF/X standard.
|
|
pub fn is_pdfx(self) -> bool {
|
|
matches!(self, Self::PdfX1a | Self::PdfX3 | Self::PdfX4)
|
|
}
|
|
|
|
/// Files of this standard have no transparency (PDF/X-1a and PDF/X-3): it is flattened.
|
|
pub fn flattens(self) -> bool {
|
|
matches!(self, Self::PdfX1a | Self::PdfX3)
|
|
}
|
|
|
|
/// Every colour is CMYK, grey or a spot ink (PDF/X-1a).
|
|
pub fn cmyk_only(self) -> bool {
|
|
self == Self::PdfX1a
|
|
}
|
|
|
|
/// Files of this standard may have PDF layers (PDF/X-1a and PDF/X-3 are PDF 1.3, which has
|
|
/// none).
|
|
pub fn allows_layers(self) -> bool {
|
|
!self.flattens()
|
|
}
|
|
|
|
/// `/GTS_PDFXVersion` and `/GTS_PDFXConformance` (when the standard has one).
|
|
fn pdfx_ids(self) -> Option<(&'static str, Option<&'static str>)> {
|
|
match self {
|
|
Self::PdfX1a => Some(("PDF/X-1:2001", Some("PDF/X-1a:2001"))),
|
|
Self::PdfX3 => Some(("PDF/X-3:2002", None)),
|
|
Self::PdfX4 => Some(("PDF/X-4", None)),
|
|
Self::None | Self::PdfA2b => None,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Refuse a PDF/X output intent the file can't have: a grey profile, an RGB one in PDF/X-1a, or a
|
|
/// name that isn't a profile, unless PDF/X-1a and PDF/X-3 name a registered printing condition
|
|
/// (with a registry) without embedding a profile; PDF/X-4 always embeds one.
|
|
pub(crate) fn check(set: &PdfSettings) -> Result<(), PdfError> {
|
|
let o = &set.output;
|
|
let name = o.output_intent.trim();
|
|
if !set.standard.is_pdfx() || name.is_empty() {
|
|
return Ok(());
|
|
}
|
|
let std = set.standard.label();
|
|
match cms::profile(name).map(|p| p.kind) {
|
|
Some(ProfileKind::Cmyk) => Ok(()),
|
|
Some(ProfileKind::Rgb) if !set.standard.cmyk_only() => Ok(()),
|
|
Some(ProfileKind::Rgb) => {
|
|
Err(PdfError::BadSetting(format!("output.outputIntent: {std} files print in CMYK, and “{name}” is an RGB profile")))
|
|
}
|
|
Some(ProfileKind::Gray) => {
|
|
Err(PdfError::BadSetting(format!("output.outputIntent: “{name}” is a grey profile, which can't be a {std} output intent")))
|
|
}
|
|
None if !o.registry.trim().is_empty() && set.standard != Standard::PdfX4 => Ok(()),
|
|
None if set.standard == Standard::PdfX4 => {
|
|
Err(PdfError::BadSetting(format!("output.outputIntent: no profile is called “{name}”, and a {std} output intent embeds its profile")))
|
|
}
|
|
None => Err(PdfError::BadSetting(format!(
|
|
"output.outputIntent: no profile is called “{name}”: a {std} output intent embeds a profile, or names a registered printing condition (outputConditionId and registry)"
|
|
))),
|
|
}
|
|
}
|
|
|
|
/// The CMYK profile PDF/X-1a colours are converted to when no destination is named: the output
|
|
/// intent's profile when it is a CMYK one, else the colour settings' CMYK profile.
|
|
pub(crate) fn cmyk_destination(set: &PdfSettings, source: &Cms) -> String {
|
|
match cms::profile(set.output.output_intent.trim()) {
|
|
Some(p) if p.kind == ProfileKind::Cmyk => p.name,
|
|
_ => source.settings().cmyk.clone(),
|
|
}
|
|
}
|
|
|
|
/// The title a file of `standard` gets: PDF/X files need one.
|
|
pub(crate) fn title(standard: Standard, title: String) -> String {
|
|
if standard.is_pdfx() && title.trim().is_empty() { "Untitled".into() } else { title }
|
|
}
|
|
|
|
/// Refuse a PDF/X file without a creation date (its dates are required).
|
|
pub(crate) fn require_date(standard: Standard, created: Option<i64>) -> Result<(), PdfError> {
|
|
match created {
|
|
None if standard.is_pdfx() => {
|
|
Err(PdfError::Unsupported(format!("{} files need a creation date, and this platform has no clock to give one", standard.label())))
|
|
}
|
|
_ => Ok(()),
|
|
}
|
|
}
|
|
|
|
/// The document information entries naming `standard` (empty for other files).
|
|
pub(crate) fn info_entries(standard: Standard) -> String {
|
|
let Some((version, conformance)) = standard.pdfx_ids() else { return String::new() };
|
|
let mut out = format!("/GTS_PDFXVersion({version})");
|
|
if let Some(c) = conformance {
|
|
out.push_str(&format!("/GTS_PDFXConformance({c})"));
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Edit the XMP metadata of a PDF/X file (the catalog's `/Metadata` stream; `root` is the
|
|
/// catalog dictionary's contents) into `patch`: PDF/X-4 adds the standard, Trapped and a version
|
|
/// id (PDF 1.3 files say so in [`finish`]). Files without metadata are left as they are.
|
|
pub(crate) fn xmp(pdf: &[u8], xref: &Xref, root: (usize, usize), patch: &mut Patch, standard: Standard, trapped: bool) -> Result<(), PdfError> {
|
|
if !standard.is_pdfx() {
|
|
return Ok(());
|
|
}
|
|
let bad = || PdfError::Write("the written PDF's metadata can't be edited".into());
|
|
let Some(at) = pdf.get(root.0..root.1).and_then(|d| find(d, b"/Metadata", 0)) else { return Ok(()) };
|
|
let n = crate::patch::number(pdf, root.0 + at + b"/Metadata".len()).and_then(|(n, _)| u32::try_from(n).ok()).ok_or_else(bad)?;
|
|
let (start, end) = xref.dict(pdf, n).ok_or_else(bad)?;
|
|
let obj = pdf.get(start..end).ok_or_else(bad)?;
|
|
let length_at = find(obj, b"/Length", 0).ok_or_else(bad)? + b"/Length".len();
|
|
let (length, length_end) = crate::patch::number(obj, length_at).ok_or_else(bad)?;
|
|
let data = find(obj, b"stream", length_end).map(|i| i + b"stream".len()).ok_or_else(bad)?;
|
|
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 xml = data.checked_add(length).and_then(|e| obj.get(data..e)).ok_or_else(bad)?;
|
|
let mut edits: Vec<(usize, usize, Vec<u8>)> = vec![];
|
|
if let Some((version, _)) = standard.pdfx_ids().filter(|_| standard == Standard::PdfX4) {
|
|
// Into the writer's description, whose namespaces declare the `pdf` and `xmpMM` prefixes.
|
|
let close = crate::lab_spot::rfind(xml, b"</rdf:Description>").ok_or_else(bad)?;
|
|
let trapped = if trapped { "True" } else { "False" };
|
|
let props = format!(
|
|
"<pdfxid:GTS_PDFXVersion xmlns:pdfxid=\"http://www.npes.org/pdfx/ns/id/\">{version}</pdfxid:GTS_PDFXVersion><pdf:Trapped>{trapped}</pdf:Trapped><xmpMM:VersionID>1</xmpMM:VersionID>"
|
|
);
|
|
edits.push((close, close, props.into_bytes()));
|
|
}
|
|
// Every edit grows the stream (or keeps its length).
|
|
let grown: usize = edits.iter().map(|(s, e, new)| new.len().saturating_sub(e - s)).sum();
|
|
if grown > 0 {
|
|
patch.replace(start + length_at, start + length_end, format!(" {}", length + grown).into_bytes());
|
|
}
|
|
for (s, e, new) in edits {
|
|
patch.replace(start + data + s, start + data + e, new);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// The finished file of `standard`: a PDF 1.3 file (`pdf13`: PDF/X-1a, PDF/X-3, or asked for) says
|
|
/// so ([`crate::post::declare_pdf13`]) and must be flat, then the file is checked against its
|
|
/// standard ([`verify`]).
|
|
pub(crate) fn finish(mut pdf: Vec<u8>, standard: Standard, pdf13: bool) -> Result<Vec<u8>, PdfError> {
|
|
if pdf13 {
|
|
crate::post::declare_pdf13(&mut pdf);
|
|
if !standard.flattens() {
|
|
flat(&without_streams(&pdf), "PDF 1.3")?;
|
|
}
|
|
}
|
|
verify(&pdf, standard)?;
|
|
Ok(pdf)
|
|
}
|
|
|
|
/// Refuse a file (`objects`, without its stream data) with transparency: `kind` files have none.
|
|
fn flat(objects: &[u8], kind: &str) -> Result<(), PdfError> {
|
|
match transparency(objects) {
|
|
Some(what) => Err(PdfError::Unsupported(format!(
|
|
"{kind} files have no transparency, and the file would have {what}: flatten it first (object.flattenTransparency)"
|
|
))),
|
|
None => Ok(()),
|
|
}
|
|
}
|
|
|
|
/// Refuse a written file that breaks `standard`: transparency in PDF/X-1a and PDF/X-3 (soft
|
|
/// masks, transparency groups, constant opacity, blend modes), colour spaces other than CMYK,
|
|
/// grey and spot inks in PDF/X-1a, and fonts that aren't embedded.
|
|
pub(crate) fn verify(pdf: &[u8], standard: Standard) -> Result<(), PdfError> {
|
|
if !standard.is_pdfx() {
|
|
return Ok(());
|
|
}
|
|
let objects = without_streams(pdf);
|
|
let std = standard.label();
|
|
if standard.flattens() {
|
|
flat(&objects, std)?;
|
|
}
|
|
if standard.cmyk_only()
|
|
&& let Some(space) =
|
|
["/DeviceRGB", "/CalRGB", "/CalGray", "/Lab", "/ICCBased"].into_iter().find(|n| !names(&objects, n.as_bytes()).is_empty())
|
|
{
|
|
return Err(PdfError::Unsupported(format!("{std} files are CMYK, and the file would have colours in {}", &space[1..])));
|
|
}
|
|
let mut from = 0;
|
|
while let Some(at) = find(&objects, b"/Type/FontDescriptor", from) {
|
|
let end = find(&objects, b"endobj", at).unwrap_or(objects.len());
|
|
let dict = objects.get(at..end).unwrap_or_default();
|
|
if find(dict, b"/FontFile", 0).is_none() {
|
|
return Err(PdfError::Unsupported(format!("{std} files embed their fonts, and the file would have one that isn't")));
|
|
}
|
|
from = end;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// `pdf` without its streams' data (dictionaries and the rest of the syntax), so that what is
|
|
/// looked for there isn't found in image or font bytes.
|
|
fn without_streams(pdf: &[u8]) -> Vec<u8> {
|
|
let mut out = Vec::with_capacity(pdf.len().min(1 << 20));
|
|
let mut at = 0;
|
|
while let Some(s) = find(pdf, b"stream", at) {
|
|
let data = s + b"stream".len();
|
|
// `endstream` itself, or a stream keyword that isn't one (inside a name or string).
|
|
if (s >= 3 && pdf.get(s - 3..s) == Some(b"end")) || !pdf.get(data).is_some_and(|b| matches!(b, b'\r' | b'\n')) {
|
|
out.extend_from_slice(pdf.get(at..data).unwrap_or_default());
|
|
at = data;
|
|
continue;
|
|
}
|
|
out.extend_from_slice(pdf.get(at..data).unwrap_or_default());
|
|
at = find(pdf, b"endstream", data).unwrap_or(pdf.len());
|
|
}
|
|
out.extend_from_slice(pdf.get(at..).unwrap_or_default());
|
|
out
|
|
}
|
|
|
|
/// Where name `name` (`/SMask`) occurs in `objects` as a whole name: the index just after it.
|
|
fn names(objects: &[u8], name: &[u8]) -> Vec<usize> {
|
|
let mut out = vec![];
|
|
let mut from = 0;
|
|
while let Some(at) = find(objects, name, from) {
|
|
let end = at + name.len();
|
|
if !objects.get(end).is_some_and(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'.' | b'#')) {
|
|
out.push(end);
|
|
}
|
|
from = end;
|
|
}
|
|
out
|
|
}
|
|
|
|
/// The token after `at` in `objects` (after white space): a name with its slash, or a number.
|
|
fn token(objects: &[u8], at: usize) -> &[u8] {
|
|
let rest = objects.get(at..).unwrap_or_default();
|
|
let rest = rest.get(rest.iter().take_while(|b| b.is_ascii_whitespace()).count()..).unwrap_or_default();
|
|
let len = rest.iter().enumerate().take_while(|(i, b)| *i == 0 || !(b.is_ascii_whitespace() || b"/[]<>()".contains(b))).count();
|
|
rest.get(..len).unwrap_or_default()
|
|
}
|
|
|
|
/// The first kind of transparency `objects` has, if any.
|
|
fn transparency(objects: &[u8]) -> Option<&'static str> {
|
|
if names(objects, b"/SMask").into_iter().any(|at| token(objects, at) != b"/None") {
|
|
return Some("soft masks");
|
|
}
|
|
if !names(objects, b"/Transparency").is_empty() {
|
|
return Some("transparency groups");
|
|
}
|
|
let below_one = |t: &[u8]| std::str::from_utf8(t).ok().and_then(|s| s.parse::<f64>().ok()).is_some_and(|v| v < 1.0);
|
|
if names(objects, b"/CA").into_iter().chain(names(objects, b"/ca")).any(|at| below_one(token(objects, at))) {
|
|
return Some("opacity");
|
|
}
|
|
if names(objects, b"/BM").into_iter().any(|at| !matches!(token(objects, at), b"/Normal" | b"/Compatible")) {
|
|
return Some("blend modes");
|
|
}
|
|
None
|
|
}
|