Merge pull request #119 from storytold/pr/m4-pdfx

M4.84: PDF/X-1a, PDF/X-3 and PDF/X-4
This commit is contained in:
BFlat authored and GitHub committed 2026-10-05 17:19:55 +07:00
commit 9779d21962
21 files changed
+896 -105

No files matched your search

+2 -2
View File
@@ -140,7 +140,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
| M1 | Selection, transform, layers, MCP | ✅ mostly done (transform reference point snaps to anchors/centres; rotated persistent bbox pending) | 4–6 |
| M2 | Drawing tools + smart guides | ✅ mostly done (Flare, Reshape, Live Corners widget dragging landed; Shaper, Pen modifier nuances) | 15–20 |
| M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | ✅ done (M3.7–M3.98): swatches, groups, libraries, tints, Lab spots and Registration; Color Picker, Color panel, Color Guide, Color Themes, Edit Colors and Recolor Artwork; gradients that follow every transform, the annotator, linked stops, freeform gradients, focal points, gradients on strokes and Expand; stroke geometry, arrowheads, dashes fitted to corners, stroke on type, width profiles and the Width tool, Scale Strokes and preview bounds; Appearance targeting, container appearance, target circles, Expand Appearance; knockout, isolation, blend accuracy, CMYK blending, overprint, Attributes, masks and clipping, Flatten Transparency; graphic styles, links and libraries. Left: freeform and mixed spot/process gradients export as stops or process colours to SVG/PDF; flattener presets not yet used by PDF/print; confirm the Scale Strokes & Effects default | 4–8 |
| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | ✅ mostly done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts), Place and Links, Package, EPS and DXF in and out, EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, Asset Export, clipboard flavours, File Info. Left: DWG (use DXF), PSD placement as layers, polish | 6–10 |
| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | ✅ mostly done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4), Place and Links, Package, EPS and DXF in and out, EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, Asset Export, clipboard flavours, File Info. Left: DWG (use DXF), PSD placement as layers, polish | 6–10 |
| M5 | Performance | 🟡 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); file format v2 opens 3× faster (50k paths: 722 → 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 15–25 |
| M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (Boolean precision on almost-horizontal edges fixed and the property tests made deterministic; Shape Builder edge erase, large-offset bug open) | 3–6 |
| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟡 Character/Paragraph Styles, Area Type Options, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font, Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects, tab stops + Tabs panel done; tab leaders, spell check, vertical type pending | 45–60 |
@@ -185,7 +185,7 @@ about 1–1.5 agent-hours each, so the other rows (estimated on the older scale)
| Layers, artboards, document setup | 5 | 80% | Layers panel options depth, artboard presets/rearrange polish (Document Setup and New Document are done) | 5–8 |
| View & navigation | 3 | 70% | New View/Edit Views, multiple windows/arrange, print tiling, Snap to Pixel/Glyph | 10–15 |
| Guides, grids, smart guides, snapping, rulers | 3 | 75% | global/video rulers, smart-guide preference depth | 4–8 |
| File formats | 6 | 85% | DWG (no open spec: DXF instead), PSD placement as layers, the remaining fidelity polish; EPS, DXF, EMF/WMF, TIFF, BMP, Targa, PSD export, SVGZ, PDF security and presets are done | 6–10 |
| File formats | 6 | 85% | DWG (no open spec: DXF instead), PSD placement as layers, the remaining fidelity polish; EPS, DXF, EMF/WMF, TIFF, BMP, Targa, PSD export, SVGZ, PDF security, PDF/X and presets are done | 6–10 |
| Export for Screens, Asset Export, slices, Save for Web | 3 | 85% | polish only (all four are done) | 2–4 |
| Print, colour management, separations, flattener | 3 | 70% | Print Tiling tool and Show Print Tiling, flattener presets in print/PDF, print preview fidelity (Print dialog, presets, PostScript, marks and separations are done) | 5–8 |
| Automation | 3 | 60% | Variables (data merge), scripting surface, batch | 8–12 |
+2
View File
@@ -901,6 +901,8 @@ mod tests_pdfoutput;
#[cfg(test)]
mod tests_tiffbmp;
#[cfg(test)]
mod tests_pdfx;
#[cfg(test)]
mod tests_psd;
+43 -8
View File
@@ -9,6 +9,7 @@ use serde_json::{Value, json};
use vectorcraft_doc::Document;
use vectorcraft_pdf::{Overprint, PdfError, PdfOptions, PdfPreset, PdfSettings, Standard};
use super::super::flatten::{FlattenOptions, flatten_document, see_through_images};
use super::super::*;
use super::{ArtboardPick, FormatOption, write_or_return};
use crate::EngineError;
@@ -30,7 +31,12 @@ pub(super) const OPTIONS: &[FormatOption] = &[
default: "\"VectorCraft Default\"",
description: "the PDF preset the other options apply over (built-in or saved: pdf.preset.list)",
},
FormatOption { name: "standard", ty: "string", default: "\"none\"", description: "none | pdfA2b (PDF/X is not supported yet)" },
FormatOption {
name: "standard",
ty: "string",
default: "\"none\"",
description: "none | pdfA2b | pdfX1a | pdfX3 | pdfX4 (choosing one sets the compatibility it allows; see document.exportPdf)",
},
FormatOption { name: "compatibility", ty: "string", default: "\"1.7\"", description: "PDF version: 1.4 | 1.5 | 1.6 | 1.7 | 2.0" },
FormatOption {
name: "preserveEditing",
@@ -79,7 +85,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|pdfX3|pdfX4, compatibility?: 1.4|1.5|1.6|1.7 (default)|2.0, preserveEditing? (on in VectorCraft Default: the native document as an embedded file, which document.open restores; choosing a standard turns it off, PDF/A refuses it), thumbnails?, fastWebView?, viewAfterSaving? (the app opens the written file), createLayers?, includeNonPrinting? (keep layers whose Print option is off; left out by default unless createLayers), 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), 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: true (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), overprint: preserve|discard}, security?: {openPassword, permissionsPassword, printing: none|low|high, changes: none|pages|forms|comments|any, copy, screenReader, plaintextMetadata}} → {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; PDF/X, PDF/A-2b at 2.0 and a password with a PDF/A or PDF/X standard are refused. 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 the High Resolution flattener preset; 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.4|1.5|1.6|1.7 (default)|2.0 (choosing a standard sets the latest it allows: 1.7 for pdfA2b, 1.6 for pdfX4, 1.4 for pdfX1a and pdfX3, written as PDF 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?, fastWebView?, viewAfterSaving? (the app opens the written file), createLayers? (choosing pdfX1a or pdfX3 turns it off; they refuse it), includeNonPrinting? (keep layers whose Print option is off; left out by default unless createLayers), 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: true (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), overprint: preserve|discard}, security?: {openPassword, permissionsPassword, printing: none|low|high, changes: none|pages|forms|comments|any, copy, screenReader, plaintextMetadata}} → {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). document.export {format: pdf} takes the same options",
has_doc,
export_pdf
),
@@ -145,9 +151,10 @@ pub(crate) fn pdf_error(cmd: &str, e: PdfError) -> EngineError {
/// The settings `p` asks for: its preset (built-in or one of `saved`; default
/// [`DEFAULT_PRESET`]) with `p`'s options applied over it. Choosing another standard turns
/// Preserve Editing off unless `p` asks for it. Checked as a preset is
/// ([`PdfSettings::check_values`]): a standard the writer doesn't produce yet passes. Keys that
/// aren't PDF options (path, format…) are ignored.
/// Preserve Editing off, the layers off where it has none, and sets the latest compatibility it
/// allows, unless `p` asks for those. Checked as a preset is
/// ([`PdfSettings::check_values`]): profiles that aren't there pass. Keys that aren't PDF options
/// (path, format…) are ignored.
pub fn resolve(cmd: &str, p: &Value, saved: &[PdfPreset]) -> Result<PdfSettings> {
let base = preset_settings(cmd, p, saved)?;
if !p.is_object() {
@@ -156,8 +163,17 @@ pub fn resolve(cmd: &str, p: &Value, saved: &[PdfPreset]) -> Result<PdfSettings>
let mut v = serde_json::to_value(&base).map_err(|e| EngineError::Other(e.to_string()))?;
merge(&mut v, p);
let mut s = PdfSettings::deserialize(&v).map_err(|e| bad(cmd, format!("PDF options: {e}")))?;
if s.standard != Standard::None && s.standard != base.standard && p.get("preserveEditing").is_none_or(Value::is_null) {
s.preserve_editing = false;
let unasked = |key: &str| p.get(key).is_none_or(Value::is_null);
if s.standard != base.standard {
if s.standard != Standard::None && unasked("preserveEditing") {
s.preserve_editing = false;
}
if !s.standard.allows_layers() && unasked("createLayers") {
s.create_layers = false;
}
if !s.standard.allows(s.compatibility) && unasked("compatibility") {
s.compatibility = s.standard.version();
}
}
s.check_values().map_err(|e| pdf_error(cmd, e))?;
Ok(s)
@@ -223,7 +239,14 @@ pub(super) fn encode_carrying(cmd: &str, doc: &Document, p: &Value, native: impl
warnings.push(EDITING_NEEDS_EVERY_ARTBOARD.to_string());
}
}
let r = super::super::rasterfx::export_pdf_with_report(doc, &opts).map_err(|e| pdf_error(cmd, e))?;
let flat = flatten_for(doc, &opts.settings)?;
if flat.is_some() {
warnings.push(format!(
"transparency was flattened with the High Resolution flattener preset: {} files have none",
opts.settings.standard.label()
));
}
let r = super::super::rasterfx::export_pdf_with_report(flat.as_ref().unwrap_or(doc), &opts).map_err(|e| pdf_error(cmd, e))?;
warnings.extend(r.warnings);
if opts.settings.advanced.overprint == Overprint::Preserve && doc.layers.iter().any(|l| l.has_overprint()) {
warnings.push("overprinting objects are written without overprint: overprint is not written to PDF yet".into());
@@ -231,6 +254,18 @@ pub(super) fn encode_carrying(cmd: &str, doc: &Document, p: &Value, native: impl
Ok((r.bytes, warnings))
}
/// `doc` with its transparency flattened for a standard whose files have none (PDF/X-1a, PDF/X-3),
/// with the High Resolution flattener preset; images with see-through pixels count as
/// transparency. `None` when nothing changes (or the standard keeps transparency).
fn flatten_for(doc: &Document, set: &PdfSettings) -> Result<Option<Document>> {
if !set.standard.flattens() {
return Ok(None);
}
let mut o = FlattenOptions::preset("high").unwrap_or_default();
o.no_transparency = Some(see_through_images(doc));
flatten_document(doc, &o)
}
fn export_pdf(s: &mut Session, p: &Value) -> Result<Value> {
let expanded = expand_preset(s, C, p)?;
let p = &*expanded;
+1 -1
View File
@@ -66,7 +66,7 @@ fn bad_options_are_refused() {
json!({"range": "0"}),
json!({"artboards": []}),
json!({"preset": "Nope"}),
json!({"standard": "pdfX4"}),
json!({"standard": "pdfX4", "compatibility": "1.7"}),
json!({"standard": "pdfA2b", "compatibility": "2.0"}),
json!({"compression": {"color": {"ppi": 0}}}),
json!({"compression": "zip"}),
@@ -0,0 +1,91 @@
//! `document.exportPdf {standard}` with the PDF/X standards: choosing one sets its version and
//! turns off what it forbids, PDF/X-1a and PDF/X-3 flatten transparency (images with see-through
//! pixels too) before writing, PDF/X-4 keeps it, and passwords are refused.
use serde_json::{Value, json};
use super::*;
/// A document with a CMYK rectangle under a half-opaque RGB one, and a PNG with see-through
/// pixels over both.
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 200, "height": 100})).unwrap();
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 60, "height": 60})).unwrap();
s.execute("paint.setFill", &json!({"color": {"model": "cmyk", "c": 1.0, "m": 0.0, "y": 0.0, "k": 0.0}})).unwrap();
s.execute("shape.rectangle", &json!({"x": 40, "y": 30, "width": 60, "height": 60})).unwrap();
s.execute("paint.setFill", &json!({"color": {"model": "rgb", "r": 1.0, "g": 0.2, "b": 0.0}})).unwrap();
s.execute("transparency.set", &json!({"opacity": 50})).unwrap();
let mut png = vec![];
image::RgbaImage::from_fn(8, 8, |x, _| image::Rgba([0, 120, 255, if x < 4 { 0 } else { 255 }]))
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
.unwrap();
s.execute("file.place", &json!({"name": "dots.png", "dataBase64": vectorcraft_format::base64_encode(&png), "at": [150, 50]})).unwrap();
s
}
/// `document.exportPdf` with `p` (content streams readable) → (the file's text, warnings).
fn pdf(s: &mut Session, mut p: Value) -> (String, Vec<String>) {
p["compression"] = json!({"compressText": false});
let v = s.execute("document.exportPdf", &p).unwrap();
let bytes = vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap();
let warnings = v["warnings"].as_array().unwrap().iter().map(|w| w.as_str().unwrap().to_string()).collect();
(String::from_utf8_lossy(&bytes).into_owned(), warnings)
}
#[test]
fn pdfx_1a_and_x3_flatten_transparency_and_x4_keeps_it() {
let mut s = session();
let before = s.doc().unwrap().doc.clone();
for (standard, header) in [("pdfX1a", "%PDF-1.3"), ("pdfX3", "%PDF-1.3")] {
let (text, warnings) = pdf(&mut s, json!({"standard": standard}));
assert!(text.starts_with(header), "{standard}");
assert!(warnings.iter().any(|w| w.contains("flattened")), "{standard}: {warnings:?}");
for no in ["/SMask", "/S/Transparency", "/ca 0.5"] {
assert!(!text.contains(no), "{standard}: no {no}");
}
assert!(text.contains("/S/GTS_PDFX") && text.contains("/GTS_PDFXVersion"), "{standard}");
}
// PDF/X-1a: CMYK only, the flattened colours too.
let (text, _) = pdf(&mut s, json!({"standard": "pdfX1a"}));
assert!(!text.contains("/DeviceRGB") && !text.contains("/ICCBased"));
assert!(!text.split_whitespace().any(|t| t == "rg"), "no RGB fills");
// The document itself is untouched.
assert_eq!(s.doc().unwrap().doc, before);
// PDF/X-4 keeps transparency (and the image's soft mask).
let (text, warnings) = pdf(&mut s, json!({"preset": "PDF/X-4:2010"}));
assert!(text.starts_with("%PDF-1.6"));
assert!(text.contains("/SMask") && !warnings.iter().any(|w| w.contains("flattened")), "{warnings:?}");
assert!(text.contains("<pdfxid:GTS_PDFXVersion"));
}
#[test]
fn choosing_a_pdfx_standard_sets_its_version_and_turns_off_what_it_forbids() {
let mut s = session();
let settings = |s: &mut Session, p: Value| s.execute("document.pdfSettings", &p).unwrap()["settings"].clone();
// The default preset is PDF 1.7 and keeps editing data.
let v = settings(&mut s, json!({"standard": "pdfX4"}));
assert_eq!((&v["compatibility"], &v["preserveEditing"]), (&json!("1.6"), &json!(false)));
let v = settings(&mut s, json!({"standard": "pdfX1a", "createLayers": null, "preset": "Press Quality"}));
assert_eq!((&v["compatibility"], &v["createLayers"]), (&json!("1.4"), &json!(false)));
let v = settings(&mut s, json!({"standard": "pdfX4", "compatibility": "1.5", "createLayers": true}));
assert_eq!((&v["compatibility"], &v["createLayers"]), (&json!("1.5"), &json!(true)));
// What is asked for explicitly is checked, not changed.
for p in [
json!({"standard": "pdfX4", "compatibility": "1.7"}),
json!({"standard": "pdfX3", "compatibility": "1.6"}),
json!({"standard": "pdfX1a", "createLayers": true}),
json!({"standard": "pdfX3", "preserveEditing": true}),
json!({"standard": "pdfX4", "security": {"openPassword": "x"}}),
json!({"standard": "pdfX1a", "security": {"permissionsPassword": "x"}}),
json!({"standard": "pdfX1a", "output": {"outputIntent": vectorcraft_color::cms::SRGB}}),
json!({"standard": "pdfX4", "output": {"outputIntent": "No Such Press"}}),
] {
let e = s.execute("document.exportPdf", &p).unwrap_err();
assert!(matches!(e, crate::EngineError::BadParams { .. }), "{p}: {e}");
}
// The built-in PDF/X presets export as they are.
for preset in ["PDF/X-1a:2001", "PDF/X-3:2002", "PDF/X-4:2010"] {
assert!(s.execute("document.exportPdf", &json!({"preset": preset})).is_ok(), "{preset}");
}
}
+41 -3
View File
@@ -20,6 +20,7 @@
//! Window → Flattener Preview: the same plan, worked out without rendering, reports what flattening
//! the document would touch ([`FlattenReport`], `flattener.preview`).
use std::collections::BTreeSet;
use std::sync::Arc;
use serde::{Deserialize, Serialize};
@@ -129,6 +130,12 @@ pub struct FlattenOptions {
/// Areas showing a single paint keep it (spot and process colours, swatches) with its
/// overprint; off, the flattened objects lose their overprints.
pub preserve_overprints: bool,
/// Flattening for files without any transparency (PDF/X-1a, PDF/X-3): images with
/// see-through pixels (these keys, [`see_through_images`]) count as transparency, and
/// rasterized areas clipped to their regions are opaque. `None` for ordinary flattening. Not a
/// preset option.
#[serde(skip)]
pub no_transparency: Option<BTreeSet<String>>,
}
impl Default for FlattenOptions {
@@ -175,6 +182,7 @@ impl FlattenOptions {
anti_alias,
preserve_alpha: false,
preserve_overprints: true,
no_transparency: None,
}
}
@@ -222,6 +230,11 @@ impl FlattenOptions {
Ok(o)
}
/// `n` shows an image whose see-through pixels count as transparency.
fn draws_see_through_image(&self, n: &Node) -> bool {
self.no_transparency.as_ref().is_some_and(|keys| draws_image(n, keys))
}
/// Whether a group that splits into `regions` atomic regions is rasterized whole.
fn rasterize_all(&self, regions: usize) -> bool {
self.balance <= 0.0 || (self.balance < 100.0 && regions as f64 > 10f64.powf(self.balance / 20.0))
@@ -792,7 +805,7 @@ fn plan(src: &Document, roots: &[NodeId], o: &FlattenOptions, render: bool) -> R
let mut sc = Scratch { brushes: vectorcraft_brush::library(&baked), doc: baked.clone() };
let plain: Vec<Node> = art.iter().map(|n| sc.plain(n)).collect();
let reaches: Vec<Option<Rect>> = plain.iter().map(reach).collect();
let see_through: Vec<bool> = plain.iter().map(Node::shows_transparency).collect();
let see_through: Vec<bool> = plain.iter().map(|n| n.shows_transparency() || o.draws_see_through_image(n)).collect();
let cmyk = src.color_mode == ColorMode::Cmyk;
let mut out = Plan { flat: vec![], kept: vec![], transparent: roots.iter().zip(&see_through).filter(|(_, t)| **t).map(|(id, _)| *id).collect() };
for g in overlapping(&reaches) {
@@ -927,7 +940,13 @@ fn render_raster(doc: &Document, art: &[Node], rect: Rect, ppi: f64, clip: Optio
);
let mut r = vectorcraft_render::Renderer::new();
let mut img = r.render_region(&isolated_doc(doc, art.to_vec()), region, scale, !o.preserve_alpha);
if !o.preserve_alpha || !o.anti_alias {
if o.no_transparency.is_some() && clip.is_some() && !o.preserve_alpha {
// Without transparency the clip alone shapes the image: it is opaque, over white (its
// edge pixels too).
for p in img.pixels.as_chunks_mut::<4>().0 {
p[3] = 255;
}
} else if !o.preserve_alpha || !o.anti_alias {
// The art's coverage: drawn opaque, so only its own edges (and soft effects) are partial.
let cover: Vec<Node> = art
.iter()
@@ -988,7 +1007,7 @@ fn opaque(n: &mut Node) {
/// clipping path stays). `None` when that changes nothing.
pub(crate) fn flatten_document(doc: &Document, o: &FlattenOptions) -> Result<Option<Document>> {
let outlining = o.text_to_outlines || o.strokes_to_outlines || !o.preserve_overprints;
if !outlining && !doc.layers.iter().any(|l| l.shows_transparency()) {
if !outlining && !doc.layers.iter().any(|l| l.shows_transparency() || o.draws_see_through_image(l)) {
return Ok(None);
}
let mut roots = vec![];
@@ -1197,6 +1216,25 @@ fn carry(from: &Node, mut to: Node) -> Node {
to
}
/// The keys of `doc`'s images that have see-through pixels (an alpha channel that isn't opaque
/// everywhere).
pub(crate) fn see_through_images(doc: &Document) -> BTreeSet<String> {
let see_through = |b: &ImageBlob| {
b.mime != "image/jpeg" && image::load_from_memory(&b.bytes).is_ok_and(|i| i.color().has_alpha() && i.to_rgba8().pixels().any(|p| p[3] < 255))
};
doc.images.iter().filter(|(_, b)| see_through(b)).map(|(k, _)| k.clone()).collect()
}
/// `n` shows an image of `keys` (itself or inside).
fn draws_image(n: &Node, keys: &BTreeSet<String>) -> bool {
!keys.is_empty()
&& n.visible
&& match &n.kind {
NodeKind::Image(im) => keys.contains(&im.key),
_ => n.children().is_some_and(|ch| ch.iter().any(|c| draws_image(c, keys))),
}
}
fn visible_fx(fx: &[Effect]) -> bool {
fx.iter().any(|e| e.visible)
}
+3 -2
View File
@@ -37,7 +37,7 @@ fn built_in_presets_are_listed_and_read_only() {
for n in ["High Quality Print", "Press Quality", "Smallest File Size", "PDF/X-1a:2001", "PDF/X-3:2002", "PDF/X-4:2010"] {
assert_eq!(row(n)["builtIn"], true);
}
assert_eq!(row("PDF/X-4:2010")["supported"], false, "PDF/X is not written yet");
assert!(rows.iter().all(|r| r["supported"] == true), "every built-in standard is written");
assert_eq!(row(DEFAULT_PRESET)["settings"]["preserveEditing"], true);
// Read-only: neither changed nor deleted, in any case.
assert!(is_bad_params(s.execute("pdf.preset.save", &json!({"name": "press quality", "compatibility": "1.4"}))));
@@ -47,7 +47,8 @@ fn built_in_presets_are_listed_and_read_only() {
// Built-in presets apply as named.
let v = s.execute("document.pdfSettings", &json!({"preset": "Smallest File Size"})).unwrap();
assert_eq!((&v["settings"]["fastWebView"], &v["changed"]), (&json!(true), &json!([])), "{v}");
assert!(is_bad_params(s.execute("document.exportPdf", &json!({"preset": "PDF/X-4:2010"}))), "PDF/X is refused on export");
let pdf = s.execute("document.exportPdf", &json!({"preset": "PDF/X-4:2010"})).unwrap();
assert!(pdf["dataBase64"].is_string(), "PDF/X is written");
}
#[test]
+8 -5
View File
@@ -55,8 +55,9 @@ pub fn export_with_report(doc: &Document, opts: &PdfOptions) -> Result<ExportRep
return Err(PdfError::BadArtboard(*bad));
}
let title = opts.title.clone().unwrap_or_else(|| doc.title.clone());
let title = crate::pdfx::title(set.standard, opts.title.clone().unwrap_or_else(|| doc.title.clone()));
let created_at = opts.created.or_else(vectorcraft_doc::metadata::now_unix);
crate::pdfx::require_date(set.standard, created_at)?;
let mut w = Writer::new(doc, set, &title, created_at, doc.color_mode == vectorcraft_doc::ColorMode::Cmyk)?;
// Preserve Editing: the native document as an embedded file (`check` refused PDF/A with it).
let mut warnings = set.warnings();
@@ -284,17 +285,19 @@ impl Writer {
}
}
/// The finished file (with the output intent and Trapped entries) and the warnings of its
/// drawing.
/// The finished file (with the output intent and Trapped entries, and checked against its
/// standard) and the warnings of its drawing. PDF/X-1a spot colours keep their CMYK
/// alternates.
pub(crate) fn finish(mut self) -> Result<(Vec<u8>, Vec<String>), PdfError> {
let mut bytes = self.pdf.finish().map_err(|e| PdfError::Write(format!("{e:?}")))?;
if self.cmyk {
cmyk_blending(&mut bytes, self.out.tagged);
}
let bytes = if self.lab_spots.is_empty() { bytes } else { crate::lab_spot::lab_alternates(bytes, &self.lab_spots) };
let lab = !self.lab_spots.is_empty() && !self.out.standard.cmyk_only();
let bytes = if lab { crate::lab_spot::lab_alternates(bytes, &self.lab_spots) } else { bytes };
let (bytes, more) = self.out.write_catalog(bytes)?;
self.warnings.extend(more);
Ok((bytes, self.warnings))
Ok((crate::pdfx::finish(bytes, self.out.standard)?, self.warnings))
}
}
+3
View File
@@ -50,6 +50,7 @@ mod marks;
mod output;
mod pages;
mod patch;
mod pdfx;
mod presets;
mod print;
mod settings;
@@ -220,6 +221,8 @@ mod tests_marks;
#[cfg(test)]
mod tests_output;
#[cfg(test)]
mod tests_pdfx;
#[cfg(test)]
mod tests_presets;
#[cfg(test)]
mod tests_print;
+68 -33
View File
@@ -12,11 +12,14 @@
//! document that was assigned profiles, colours are written in ICC-based colour spaces: CMYK
//! 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 always tags them.
//! 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`]).
//! - 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.
//! own output intent: neither is written there. PDF/X files always have one: blank, the CMYK
//! profile in effect.
use std::sync::{Arc, OnceLock};
@@ -58,6 +61,8 @@ pub(crate) struct ColorOut {
intent: Intent,
/// The output intent and Trapped entries to write.
catalog: Option<Catalog>,
/// The standard the file conforms to.
pub standard: Standard,
}
/// An output intent dictionary's entries.
@@ -85,20 +90,34 @@ impl Default for ColorOut {
let source = cms::active();
let cmyk_profile = source.settings().cmyk.clone();
let intent = source.settings().intent;
Self { source, dest: None, tagged: false, cmyk_profile, srgb_note: None, pixels: OnceLock::new(), intent, catalog: None }
Self {
source,
dest: None,
tagged: false,
cmyk_profile,
srgb_note: None,
pixels: OnceLock::new(),
intent,
catalog: None,
standard: Standard::None,
}
}
}
/// The profile of `set`'s destination and its colour model; `None` without conversion. A blank
/// destination is the profile of a document in colour mode `mode`.
/// destination is the profile of a document in colour mode `mode`. PDF/X-1a files always convert,
/// to a CMYK destination: blank, their output intent's profile ([`crate::pdfx::cmyk_destination`]).
fn destination(set: &PdfSettings, mode: ColorMode, source: &Cms) -> Result<Option<(String, Model)>, PdfError> {
let o = &set.output;
if o.conversion == ColorConversion::None {
let cmyk_only = set.standard.cmyk_only();
if o.conversion == ColorConversion::None && !cmyk_only {
return Ok(None);
}
let name = o.destination.trim();
let fallback = if cmyk_only { crate::pdfx::cmyk_destination(set, source) } else { String::new() };
let name = match (name.is_empty(), mode) {
(false, _) => name,
(true, _) if cmyk_only => fallback.as_str(),
(true, ColorMode::Cmyk) => source.settings().cmyk.as_str(),
(true, ColorMode::Rgb) => source.settings().rgb.as_str(),
};
@@ -109,6 +128,9 @@ fn destination(set: &PdfSettings, mode: ColorMode, source: &Cms) -> Result<Optio
ProfileKind::Cmyk => Model::Cmyk,
ProfileKind::Gray => return Err(unknown()),
};
if cmyk_only && model != Model::Cmyk {
return Err(PdfError::BadSetting(format!("output.destination: {} files are CMYK, and “{name}” is an RGB profile", set.standard.label())));
}
Ok(Some((info.name, model)))
}
@@ -136,7 +158,8 @@ pub(crate) fn warnings(set: &PdfSettings) -> Vec<String> {
if !name.is_empty() && cms::profile(name).is_none() {
out.push(format!("no profile is called “{name}”: the output intent names it without embedding it"));
}
if !asks && (!o.output_condition.trim().is_empty() || !o.registry.trim().is_empty()) {
// PDF/X files always have an output intent.
if !asks && !set.standard.is_pdfx() && (!o.output_condition.trim().is_empty() || !o.registry.trim().is_empty()) {
out.push("an output intent needs a profile or a condition identifier: the output condition and registry are left out".into());
}
out
@@ -155,18 +178,20 @@ impl ColorOut {
_ => std::mem::replace(&mut settings.rgb, name.clone()),
};
let cms = Cms::new(&settings).map_err(|e| PdfError::BadSetting(format!("output.destination: {e}")))?;
let keep = set.output.conversion == ColorConversion::PreserveNumbers || cms::canonical_name(&own) == name;
// PDF/X-1a converts without being asked: as Preserve Numbers does.
let keep = set.output.conversion != ColorConversion::Destination || cms::canonical_name(&own) == name;
if model == Model::Cmyk {
out.cmyk_profile = name.clone();
}
out.dest = Some(Dest { srgb: name == cms::SRGB, cms, model, keep });
}
out.tagged = set.standard == Standard::PdfA2b
|| match set.output.profiles {
ProfileInclusion::None => false,
ProfileInclusion::All | ProfileInclusion::Destination => true,
ProfileInclusion::TaggedSource => !doc.color_profiles.is_empty(),
};
out.tagged = matches!(set.standard, Standard::PdfA2b | Standard::PdfX3 | Standard::PdfX4)
|| (!set.standard.cmyk_only()
&& match set.output.profiles {
ProfileInclusion::None => false,
ProfileInclusion::All | ProfileInclusion::Destination => true,
ProfileInclusion::TaggedSource => !doc.color_profiles.is_empty(),
});
let rgb = match &out.dest {
Some(d) if d.model == Model::Rgb => (d.srgb, d.cms.settings().rgb.clone()),
_ => (source.rgb_is_srgb(), st.rgb.clone()),
@@ -174,7 +199,8 @@ impl ColorOut {
if out.tagged && !rgb.0 {
out.srgb_note = Some(format!("RGB colours are tagged with the sRGB profile: those of {} are written as their sRGB equivalents", rgb.1));
}
out.catalog = catalog(set);
out.catalog = catalog(set, &out.cmyk_profile);
out.standard = set.standard;
Ok(out)
}
@@ -250,16 +276,17 @@ impl ColorOut {
.as_ref()
}
/// `pdf` with the output intent and Trapped entries → (the file, warnings).
/// `pdf` with the output intent and Trapped entries, and a PDF/X file's identification
/// ([`crate::pdfx`]) → (the file, warnings).
pub(crate) fn write_catalog(&self, pdf: Vec<u8>) -> Result<(Vec<u8>, Vec<String>), PdfError> {
let Some(cat) = &self.catalog else { return Ok((pdf, vec![])) };
let xref = Xref::read(&pdf).ok_or_else(|| PdfError::Write("the written PDF has no cross-reference table".into()))?;
let mut patch = Patch::new(&xref);
let mut warnings = vec![];
let root = xref.trailer_ref(&pdf, b"/Root").and_then(|n| xref.dict(&pdf, n));
let Some(root) = root else { return Err(PdfError::Write("the written PDF has no catalog".into())) };
if let Some(OutputIntent { id, condition, registry, info, profile }) = &cat.intent {
let root = xref.trailer_ref(&pdf, b"/Root").and_then(|n| xref.dict(&pdf, n));
let Some((root, root_end)) = root else { return Err(PdfError::Write("the written PDF has no catalog".into())) };
if crate::lab_spot::find(pdf.get(root..root_end).unwrap_or_default(), b"/OutputIntents", 0).is_some() {
if crate::lab_spot::find(pdf.get(root.0..root.1).unwrap_or_default(), b"/OutputIntents", 0).is_some() {
warnings.push("the file has an output intent already: the one asked for is left out".into());
} else {
let mut dict = format!("<</Type/OutputIntent/S/GTS_PDFX/OutputConditionIdentifier{}", text_string(id));
@@ -278,22 +305,26 @@ impl ColorOut {
}
dict.push_str(">>");
let oi = patch.add_object(dict.into_bytes());
patch.replace(root, root, format!("/OutputIntents[{oi} 0 R]").into_bytes());
patch.replace(root.0, root.0, format!("/OutputIntents[{oi} 0 R]").into_bytes());
}
}
if let Some(trapped) = cat.trapped {
let entry = if trapped { "/Trapped/True" } else { "/Trapped/False" };
match xref.trailer_ref(&pdf, b"/Info").and_then(|n| xref.dict(&pdf, n)) {
Some((info, end)) if crate::lab_spot::find(pdf.get(info..end).unwrap_or_default(), b"/Trapped", 0).is_none() => {
patch.replace(info, info, entry.as_bytes().to_vec());
}
Some(_) => {}
None => {
let n = patch.add_object(format!("<<{entry}>>").into_bytes());
patch.trailer_entry(format!("/Info {n} 0 R").as_bytes());
}
let info = xref.trailer_ref(&pdf, b"/Info").and_then(|n| xref.dict(&pdf, n));
let mut entries = String::new();
if let Some(trapped) = cat.trapped
&& info.is_none_or(|(s, e)| crate::lab_spot::find(pdf.get(s..e).unwrap_or_default(), b"/Trapped", 0).is_none())
{
entries.push_str(if trapped { "/Trapped/True" } else { "/Trapped/False" });
}
entries.push_str(&crate::pdfx::info_entries(self.standard));
match info {
_ if entries.is_empty() => {}
Some((at, _)) => patch.replace(at, at, entries.into_bytes()),
None => {
let n = patch.add_object(format!("<<{entries}>>").into_bytes());
patch.trailer_entry(format!("/Info {n} 0 R").as_bytes());
}
}
crate::pdfx::xmp(&pdf, &xref, root, &mut patch, self.standard, cat.trapped == Some(true))?;
Ok((patch.apply(&pdf, &xref)?, warnings))
}
}
@@ -323,13 +354,17 @@ impl Dest {
}
}
/// The catalog entries `set` asks for (none in PDF/A files, see [`warnings`]).
fn catalog(set: &PdfSettings) -> Option<Catalog> {
/// The catalog entries `set` asks for (none in PDF/A files, see [`warnings`]); a PDF/X file's
/// output intent profile is `cmyk_profile` (the CMYK profile in effect) unless `set` names one.
fn catalog(set: &PdfSettings, cmyk_profile: &str) -> Option<Catalog> {
let o = &set.output;
if set.standard == Standard::PdfA2b {
return None;
}
let (name, id) = (o.output_intent.trim(), o.output_condition_id.trim());
let (mut name, id) = (o.output_intent.trim(), o.output_condition_id.trim());
if name.is_empty() && set.standard.is_pdfx() {
name = cmyk_profile;
}
let intent = (!name.is_empty() || !id.is_empty()).then(|| {
let profile = cms::profile(name).and_then(|p| match p.kind {
ProfileKind::Rgb => Some((p.name, 3)),
+1 -1
View File
@@ -21,7 +21,7 @@ pub(crate) struct Xref {
const MAX_DIGITS: usize = 10;
/// The unsigned number `pdf` holds at `at` (after white space), and where it ends.
fn number(pdf: &[u8], mut at: usize) -> Option<(usize, usize)> {
pub(crate) fn number(pdf: &[u8], mut at: usize) -> Option<(usize, usize)> {
while pdf.get(at).is_some_and(u8::is_ascii_whitespace) {
at += 1;
}
+270
View File
@@ -0,0 +1,270 @@
//! 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 1.3 files are written with the PDF 1.4 settings (nothing above 1.3 is used once the art is
//! flat) and their header and metadata say 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. 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 standard.flattens()
&& let Some(v) = find(xml, b"<pdf:PDFVersion>1.4<", 0)
{
let digit = v + b"<pdf:PDFVersion>1.".len();
edits.push((digit, digit + 1, b"3".to_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`: its header version (PDF 1.3 for PDF/X-1a and PDF/X-3), then
/// checked ([`verify`]).
pub(crate) fn finish(mut pdf: Vec<u8>, standard: Standard) -> Result<Vec<u8>, PdfError> {
if standard.flattens()
&& let Some(v) = pdf.get_mut(..8).filter(|h| h.starts_with(b"%PDF-1.4"))
{
v.copy_from_slice(b"%PDF-1.3");
}
verify(&pdf, standard)?;
Ok(pdf)
}
/// 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()
&& let Some(what) = transparency(&objects)
{
return Err(PdfError::Unsupported(format!(
"{std} files have no transparency, and the file would have {what}: flatten it first (object.flattenTransparency)"
)));
}
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
}
+3 -3
View File
@@ -79,17 +79,17 @@ pub fn builtin_presets() -> Vec<PdfPreset> {
),
preset(
"PDF/X-1a:2001",
"Print exchange with CMYK and spot colours only (PDF/X output is not supported yet).",
"Print exchange with CMYK and spot colours only: transparency flattened, fonts embedded.",
pdf_x(Standard::PdfX1a, Compatibility::Pdf14),
),
preset(
"PDF/X-3:2002",
"Print exchange allowing colour-managed colours (PDF/X output is not supported yet).",
"Print exchange allowing colour-managed colours: transparency flattened, fonts embedded.",
pdf_x(Standard::PdfX3, Compatibility::Pdf14),
),
preset(
"PDF/X-4:2010",
"Print exchange keeping live transparency and layers (PDF/X output is not supported yet).",
"Print exchange keeping live transparency and layers, colour-managed colours and embedded fonts.",
pdf_x(Standard::PdfX4, Compatibility::Pdf16),
),
]
+28 -16
View File
@@ -69,15 +69,26 @@ choice! {
}
impl Standard {
/// The writer can produce this standard (PDF/X is not supported yet).
pub fn supported(self) -> bool {
matches!(self, Self::None | Self::PdfA2b)
/// A file of this standard can declare PDF version `c`: PDF/A-2 is based on PDF 1.7 (so it
/// can't be PDF 2.0), PDF/X-4 on PDF 1.6, and PDF/X-1a and PDF/X-3 on PDF 1.3, which the
/// writer writes as 1.4 (the file says 1.3, see [`crate::pdfx`]).
pub fn allows(self, c: Compatibility) -> bool {
use Compatibility::*;
match self {
Self::None => true,
Self::PdfA2b => c != Pdf20,
Self::PdfX1a | Self::PdfX3 => c == Pdf14,
Self::PdfX4 => matches!(c, Pdf14 | Pdf15 | Pdf16),
}
}
/// A file of this standard can declare PDF version `c` (PDF/A-2 is based on PDF 1.7, so it
/// can't be PDF 2.0).
pub fn allows(self, c: Compatibility) -> bool {
!(self == Self::PdfA2b && c == Compatibility::Pdf20)
/// The latest PDF version a file of this standard can declare (what choosing it sets).
pub fn version(self) -> Compatibility {
match self {
Self::None | Self::PdfA2b => Compatibility::Pdf17,
Self::PdfX1a | Self::PdfX3 => Compatibility::Pdf14,
Self::PdfX4 => Compatibility::Pdf16,
}
}
}
@@ -392,27 +403,28 @@ pub(crate) fn within(name: &str, v: f64, lo: f64, hi: f64, unit: &str) -> Result
}
impl PdfSettings {
/// Refuse settings the writer can't honour: an unsupported standard, or what
/// [`Self::check_values`] refuses.
/// Refuse settings the writer can't honour: what [`Self::check_values`] refuses, a destination
/// profile that isn't there, or a PDF/X output intent it can't write ([`crate::pdfx`]).
pub fn check(&self) -> Result<(), PdfError> {
if !self.standard.supported() {
return Err(PdfError::Unsupported(format!("{} output is not supported yet", self.standard.label())));
}
crate::output::check(self)?;
crate::pdfx::check(self)?;
self.check_values()
}
/// The part of [`Self::check`] a preset must pass (it may name a standard the writer can't
/// produce yet): a standard with a PDF version it doesn't allow, PDF/A with editing data, or
/// an out-of-range value.
/// The part of [`Self::check`] a preset must pass (without the colour settings' profiles): a
/// standard with a PDF version it doesn't allow, a standard with editing data, PDF/X-1a or
/// PDF/X-3 with PDF layers, or an out-of-range value.
pub fn check_values(&self) -> Result<(), PdfError> {
self.security.check(self.standard, self.compatibility)?;
if !self.standard.allows(self.compatibility) {
return Err(PdfError::BadSetting(format!("{} files can't be {}", self.standard.label(), self.compatibility.label())));
}
if self.preserve_editing && self.standard == Standard::PdfA2b {
if self.preserve_editing && self.standard != Standard::None {
return Err(PdfError::BadSetting(format!("{} files can't carry editing data: turn off preserveEditing", self.standard.label())));
}
if self.create_layers && !self.standard.allows_layers() {
return Err(PdfError::BadSetting(format!("{} files can't have PDF layers: turn off createLayers", self.standard.label())));
}
let c = &self.compression;
for (name, img) in
[("color", (c.color.ppi, c.color.above_ppi)), ("gray", (c.gray.ppi, c.gray.above_ppi)), ("mono", (c.mono.ppi, c.mono.above_ppi))]
+5 -2
View File
@@ -522,8 +522,11 @@ fn compatibility_levels() {
}
let a = export(&d, &with(|s| s.standard = Standard::PdfA2b)).unwrap();
assert!(String::from_utf8_lossy(&a).contains("pdfaid"), "PDF/A identification in XMP");
for x in [Standard::PdfX1a, Standard::PdfX3, Standard::PdfX4] {
assert!(matches!(export(&d, &with(|s| s.standard = x)), Err(PdfError::Unsupported(_))), "{x:?}");
// PDF/X files are PDF 1.3 (PDF/X-1a, PDF/X-3) or at most 1.6 (PDF/X-4): not the default 1.7.
for (x, header) in [(Standard::PdfX1a, "%PDF-1.3"), (Standard::PdfX3, "%PDF-1.3"), (Standard::PdfX4, "%PDF-1.6")] {
assert!(matches!(export(&d, &with(|s| s.standard = x)), Err(PdfError::BadSetting(_))), "{x:?}");
let b = export(&d, &with(|s| (s.standard, s.compatibility) = (x, x.version()))).unwrap();
assert!(b.starts_with(header.as_bytes()), "{x:?}");
}
// PDF/A-2b is a PDF 1.7 standard.
let e = export(&d, &with(|s| (s.standard, s.compatibility) = (Standard::PdfA2b, Compatibility::Pdf20)));
+3 -3
View File
@@ -148,11 +148,11 @@ fn permission_bits_follow_the_options() {
fn standards_and_bad_passwords_are_refused() {
let set = |standard, compatibility, sec| PdfSettings { standard, compatibility, security: sec, ..Default::default() };
for standard in [Standard::PdfA2b, Standard::PdfX1a, Standard::PdfX3, Standard::PdfX4] {
let e = set(standard, Compatibility::Pdf17, security("pw", "")).check_values().unwrap_err();
let e = set(standard, standard.version(), security("pw", "")).check_values().unwrap_err();
assert!(matches!(&e, PdfError::BadSetting(m) if m.contains("password-protected")), "{standard:?}: {e}");
let e = set(standard, Compatibility::Pdf17, security("", "pw")).check_values().unwrap_err();
let e = set(standard, standard.version(), security("", "pw")).check_values().unwrap_err();
assert!(e.to_string().contains("password-protected"), "{standard:?}: {e}");
assert!(set(standard, Compatibility::Pdf17, security("", "")).check_values().is_ok(), "{standard:?} without passwords");
assert!(set(standard, standard.version(), security("", "")).check_values().is_ok(), "{standard:?} without passwords");
}
let bad = |c, sec| set(Standard::None, c, sec).check_values().unwrap_err().to_string();
assert!(bad(Compatibility::Pdf17, security("same", "same")).contains("must differ"));
+222
View File
@@ -0,0 +1,222 @@
//! PDF/X-1a, PDF/X-3 and PDF/X-4: each standard's header, output intent, boxes and identification,
//! and what each refuses (RGB in PDF/X-1a, transparency in PDF/X-1a and PDF/X-3, passwords).
use serde_json::json;
use vectorcraft_color::cms::{self, GENERIC_CMYK, SRGB};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{Appearance, Document, ImageBlob, ImageObject, Node, NodeKind};
use vectorcraft_geom::{Affine, Rect, shapes};
use crate::*;
const X: [Standard; 3] = [Standard::PdfX1a, Standard::PdfX3, Standard::PdfX4];
/// A document with a rectangle filled with each of `fills`.
fn doc(fills: &[Color]) -> Document {
let mut d = Document::new(200.0, 100.0);
let l = d.layers[0].id;
for (i, c) in fills.iter().enumerate() {
let x = 10.0 + 30.0 * i as f64;
let n =
Node::path(d.alloc_id(), shapes::rectangle(Rect::new(x, 10.0, x + 20.0, 30.0)), Appearance::basic(Paint::solid(*c), Paint::None, 0.0));
d.insert(Some(l), i, n).unwrap();
}
d
}
/// Options for `standard` at its version, content streams readable, a fixed date, changed by `f`.
fn opts(standard: Standard, f: impl FnOnce(&mut PdfSettings)) -> PdfOptions {
let mut o = PdfOptions::uncompressed();
(o.settings.standard, o.settings.compatibility) = (standard, standard.version());
o.created = Some(1_700_000_000);
f(&mut o.settings);
o
}
fn settings(v: serde_json::Value) -> PdfSettings {
serde_json::from_value(v).unwrap()
}
/// The file's text with its stream data left out (what dictionaries say).
fn text(pdf: &[u8]) -> String {
String::from_utf8_lossy(pdf).into_owned()
}
#[test]
fn each_standard_writes_its_header_output_intent_boxes_and_identification() {
let mut d = doc(&[Color::cmyk(0.0, 1.0, 1.0, 0.0), Color::rgb(0.2, 0.4, 0.9)]);
// PDF/X files have a title: an untitled document gets one.
d.title.clear();
// As a PDF string (parentheses escaped).
let profile = cms::active().settings().cmyk.replace('(', r"\(").replace(')', r"\)");
for (standard, header) in [(Standard::PdfX1a, "%PDF-1.3"), (Standard::PdfX3, "%PDF-1.3"), (Standard::PdfX4, "%PDF-1.6")] {
let r = export_with_report(&d, &opts(standard, |_| {})).unwrap();
let t = text(&r.bytes);
assert!(r.bytes.starts_with(header.as_bytes()), "{standard:?}: {}", &t[..8]);
// The output intent: the CMYK profile in effect, embedded.
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");
// 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}");
}
let (version, conformance) = match standard {
Standard::PdfX1a => ("PDF/X-1:2001", Some("PDF/X-1a:2001")),
Standard::PdfX3 => ("PDF/X-3:2002", None),
_ => ("PDF/X-4", None),
};
assert!(t.contains(&format!("/GTS_PDFXVersion({version})")), "{standard:?}");
assert_eq!(conformance.is_some_and(|c| t.contains(&format!("/GTS_PDFXConformance({c})"))), conformance.is_some(), "{standard:?}");
// The metadata agrees with the header; PDF/X-4 names its standard there too.
let xmp_version = if standard == Standard::PdfX4 { "1.6" } else { "1.3" };
assert!(t.contains(&format!("<pdf:PDFVersion>{xmp_version}</pdf:PDFVersion>")), "{standard:?}");
assert_eq!(t.contains("<pdfxid:GTS_PDFXVersion xmlns:pdfxid=\"http://www.npes.org/pdfx/ns/id/\">PDF/X-4<"), standard == Standard::PdfX4);
assert_eq!(t.contains("<pdf:Trapped>False</pdf:Trapped>"), standard == Standard::PdfX4);
assert!(!t.contains("/Encrypt"), "{standard:?}");
// The file reads back.
let back = import(&r.bytes).unwrap();
assert_eq!(back.artboards.len(), 1, "{standard:?}");
assert!(r.warnings.is_empty(), "{standard:?}: {:?}", r.warnings);
}
// Trapped, a named output intent with its condition and registry.
let o = opts(Standard::PdfX4, |s| {
s.output.trapped = true;
s.output.output_intent = GENERIC_CMYK.into();
s.output.output_condition_id = "CGATS TR 001".into();
s.output.registry = "http://www.color.org".into();
});
let t = text(&export(&d, &o).unwrap());
for want in ["/Trapped/True", "<pdf:Trapped>True<", "/OutputConditionIdentifier(CGATS TR 001)", "/RegistryName(http://www.color.org)"] {
assert!(t.contains(want), "{want}");
}
// A registered condition that isn't a profile is named without one.
let o = opts(Standard::PdfX1a, |s| {
s.output.output_intent = "FOGRA39".into();
s.output.registry = "http://www.color.org".into();
});
let t = text(&export(&d, &o).unwrap());
assert!(t.contains("/OutputConditionIdentifier(FOGRA39)") && !t.contains("/DestOutputProfile"), "{t}");
// PDF/X-4 always embeds its output intent's profile.
let e = export(&d, &opts(Standard::PdfX4, |s| (s.output.output_intent, s.output.registry) = ("FOGRA39".into(), "http://www.color.org".into())));
assert!(matches!(e, Err(PdfError::BadSetting(m)) if m.contains("embeds")));
}
#[test]
fn pdfx_1a_files_have_no_rgb() {
let mut d = doc(&[Color::rgb(0.9, 0.1, 0.1), Color::Lab { l: 50.0, a: 40.0, b: -20.0 }, Color::gray(0.5), Color::cmyk(0.1, 0.2, 0.3, 0.4)]);
// An RGB image too.
let png = {
let img = image::RgbaImage::from_pixel(4, 4, image::Rgba([10, 200, 30, 255]));
let mut out = std::io::Cursor::new(vec![]);
img.write_to(&mut out, image::ImageFormat::Png).unwrap();
out.into_inner()
};
d.images.insert("img".into(), ImageBlob::new("image/png", png));
let l = d.layers[0].id;
let im = ImageObject {
key: "img".into(),
width: 4,
height: 4,
xf: Affine::translate((150.0, 50.0)) * Affine::scale(5.0),
link: None,
placement: Default::default(),
};
let n = Node::new(d.alloc_id(), NodeKind::Image(im));
d.insert(Some(l), 0, n).unwrap();
let t = text(&export(&d, &opts(Standard::PdfX1a, |_| {})).unwrap());
for no in ["/DeviceRGB", "/ICCBased", "/Lab", "/CalRGB"] {
assert!(!t.contains(no), "PDF/X-1a has no {no}");
}
let ops: Vec<&str> = t.split_whitespace().collect();
assert!(!ops.contains(&"rg") && !ops.contains(&"RG"), "no RGB fills or strokes");
assert!(ops.iter().filter(|o| **o == "k").count() >= 3, "the RGB, Lab and CMYK fills are CMYK");
assert!(ops.contains(&"g"), "grey stays grey");
assert!(t.contains("/ColorSpace/DeviceCMYK"), "the image is CMYK");
// An RGB destination or output intent is refused; PDF/X-3 tags colours instead.
let refused = |f: fn(&mut PdfSettings)| export(&d, &opts(Standard::PdfX1a, f)).unwrap_err();
assert!(matches!(refused(|s| s.output.destination = SRGB.into()), PdfError::BadSetting(m) if m.contains("output.destination")));
assert!(matches!(refused(|s| s.output.output_intent = SRGB.into()), PdfError::BadSetting(m) if m.contains("output.outputIntent")));
let x3 = text(&export(&d, &opts(Standard::PdfX3, |_| {})).unwrap());
assert!(x3.contains("/ICCBased") && !x3.contains("/DeviceRGB"), "PDF/X-3 colours are ICC-based");
assert!(export(&d, &opts(Standard::PdfX3, |s| s.output.output_intent = SRGB.into())).is_ok(), "PDF/X-3 may print in RGB");
}
#[test]
fn pdfx_1a_and_x3_files_have_no_transparency() {
let mut d = doc(&[Color::cmyk(1.0, 0.0, 0.0, 0.0), Color::cmyk(0.0, 1.0, 0.0, 0.0)]);
let half = d.layers[0].children().unwrap()[1].id;
d.node_mut(half).unwrap().opacity = 0.5;
for standard in [Standard::PdfX1a, Standard::PdfX3] {
// The writer can't flatten (the app does, before writing): it refuses.
let e = export(&d, &opts(standard, |_| {})).unwrap_err();
assert!(matches!(&e, PdfError::Unsupported(m) if m.contains("transparency") && m.contains("flatten")), "{standard:?}: {e}");
}
// Opaque art passes; PDF/X-4 keeps the transparency.
d.node_mut(half).unwrap().opacity = 1.0;
let t = text(&export(&d, &opts(Standard::PdfX3, |_| {})).unwrap());
assert!(!t.contains("/S/Transparency") && !t.contains("/SMask"));
d.node_mut(half).unwrap().opacity = 0.5;
let t = text(&export(&d, &opts(Standard::PdfX4, |_| {})).unwrap());
assert!(t.contains("/ca 0.5") || t.contains("/S/Transparency"), "PDF/X-4 keeps transparency");
}
#[test]
fn pdfx_settings_rules() {
for standard in X {
// A password is refused, and so are editing data.
let e = opts(standard, |s| s.security.open_password = "x".into()).settings.check().unwrap_err();
assert!(matches!(e, PdfError::BadSetting(_)), "{standard:?}: {e}");
assert!(export(&doc(&[]), &opts(standard, |s| s.security.permissions_password = "x".into())).is_err());
assert!(
matches!(opts(standard, |s| s.preserve_editing = true).settings.check(), Err(PdfError::BadSetting(m)) if m.contains("preserveEditing"))
);
// Its own version, never PDF 1.7 or 2.0.
assert!(standard.allows(standard.version()), "{standard:?}");
assert!(!standard.allows(Compatibility::Pdf17) && !standard.allows(Compatibility::Pdf20), "{standard:?}");
let e = opts(standard, |s| s.compatibility = Compatibility::Pdf17).settings.check().unwrap_err();
assert!(matches!(e, PdfError::BadSetting(ref m) if m.contains(standard.label())), "{standard:?}: {e}");
// A name that is no profile, without a registry.
let e = opts(standard, |s| s.output.output_intent = "No Such Press".into()).settings.check().unwrap_err();
assert!(matches!(e, PdfError::BadSetting(ref m) if m.contains("No Such Press")), "{standard:?}: {e}");
}
assert_eq!(Standard::PdfX4.version(), Compatibility::Pdf16);
assert!(Standard::PdfX4.allows(Compatibility::Pdf15) && !Standard::PdfX1a.allows(Compatibility::Pdf15));
// Layers: PDF/X-4 only.
assert!(
matches!(settings(json!({"standard": "pdfX1a", "compatibility": "1.4", "createLayers": true})).check(), Err(PdfError::BadSetting(m)) if m.contains("createLayers"))
);
assert!(settings(json!({"standard": "pdfX4", "compatibility": "1.6", "createLayers": true})).check_values().is_ok());
// A date is required.
assert!(matches!(crate::pdfx::require_date(Standard::PdfX4, None), Err(PdfError::Unsupported(_))));
assert!(crate::pdfx::require_date(Standard::None, None).is_ok());
// PDF/X settings don't warn about the output condition: the output intent is always written.
assert!(settings(json!({"standard": "pdfX4", "compatibility": "1.6", "output": {"outputCondition": "Coated"}})).warnings().is_empty());
}
#[test]
fn verify_finds_what_each_standard_forbids() {
use crate::pdfx::verify;
let file = |body: &str| format!("%PDF-1.4\n1 0 obj\n{body}\nendobj\n").into_bytes();
for (body, what) in [
("<</Type/XObject/SMask 5 0 R>>", "soft masks"),
("<</Group<</S/Transparency/CS/DeviceCMYK>>>>", "transparency groups"),
("<</Type/ExtGState/ca 0.5>>", "opacity"),
("<</Type/ExtGState/BM/Multiply>>", "blend modes"),
] {
for standard in [Standard::PdfX1a, Standard::PdfX3] {
assert!(matches!(verify(&file(body), standard), Err(PdfError::Unsupported(m)) if m.contains(what)), "{standard:?}: {body}");
}
assert!(verify(&file(body), Standard::PdfX4).is_ok(), "PDF/X-4 keeps {what}");
}
for ok in ["<</Type/ExtGState/SMask/None/ca 1/CA 1.0/BM/Normal>>", "<</Length 9>>\nstream\n/SMask 1 /DeviceRGB\nendstream"] {
assert!(verify(&file(ok), Standard::PdfX1a).is_ok(), "{ok}");
}
assert!(matches!(verify(&file("[/ICCBased 3 0 R]"), Standard::PdfX1a), Err(PdfError::Unsupported(m)) if m.contains("ICCBased")));
assert!(verify(&file("[/ICCBased 3 0 R]"), Standard::PdfX3).is_ok());
assert!(verify(&file("<</PageLabels 3 0 R>>"), Standard::PdfX1a).is_ok(), "whole names only");
let font = "<</Type/FontDescriptor/FontName/A>>";
assert!(matches!(verify(&file(font), Standard::PdfX4), Err(PdfError::Unsupported(m)) if m.contains("fonts")));
assert!(verify(&file("<</Type/FontDescriptor/FontFile2 4 0 R>>"), Standard::PdfX4).is_ok());
}
+5 -3
View File
@@ -14,9 +14,11 @@ fn built_in_presets() {
let back: PdfPreset = serde_json::from_value(serde_json::to_value(p).unwrap()).unwrap();
assert_eq!(&back, p, "round-trips");
}
// PDF/X presets name standards the writer doesn't produce yet.
let x4 = builtin_preset("pdf/x-4:2010").unwrap();
assert!(matches!(x4.settings.check(), Err(PdfError::Unsupported(_))));
// The PDF/X presets are written as their standards say.
for name in ["pdf/x-1a:2001", "PDF/X-3:2002", "pdf/x-4:2010"] {
let x = builtin_preset(name).unwrap();
assert!(x.settings.standard.is_pdfx() && x.settings.check().is_ok(), "{name}");
}
assert_eq!(builtin_preset("default").unwrap().name, DEFAULT_PRESET);
assert!(builtin_preset("Nope").is_none());
}
+4 -1
View File
@@ -53,7 +53,9 @@ fn passwords_are_never_serialized() {
fn checks_refuse_what_the_writer_cant_honour() {
assert_eq!(PdfSettings::default().check(), Ok(()));
let refused = |v: serde_json::Value| settings(v).check().unwrap_err();
assert!(matches!(refused(json!({"standard": "pdfX4"})), PdfError::Unsupported(_)));
// PDF/X-4 is a PDF 1.6 standard: the default 1.7 is refused.
assert!(matches!(refused(json!({"standard": "pdfX4"})), PdfError::BadSetting(m) if m.contains("PDF 1.7")));
assert_eq!(settings(json!({"standard": "pdfX4", "compatibility": "1.6"})).check(), Ok(()));
// Passwords are written (the file is encrypted); a password with a standard is not.
assert_eq!(settings(json!({"security": {"openPassword": "x"}})).check(), Ok(()));
assert_eq!(settings(json!({"security": {"permissionsPassword": "x"}})).check(), Ok(()));
@@ -67,6 +69,7 @@ fn checks_refuse_what_the_writer_cant_honour() {
assert_eq!(settings(json!({"standard": "pdfA2b", "compatibility": "1.4"})).check(), Ok(()));
assert!(Compatibility::ALL.iter().all(|c| Standard::None.allows(*c)));
assert!(export(&doc(), &PdfOptions { settings: settings(json!({"standard": "pdfX1a"})), ..Default::default() }).is_err());
assert!(export(&doc(), &PdfOptions { settings: settings(json!({"standard": "pdfX1a", "compatibility": "1.4"})), ..Default::default() }).is_ok());
}
#[test]
+62 -19
View File
@@ -57,6 +57,8 @@ const WEIGHT_LABELS: [&str; 3] = ["0.125 pt", "0.25 pt", "0.5 pt"];
const DOCUMENT_PROFILE: &str = "Document profile";
/// The Output Intent Profile entry for no output intent (stored as "").
const NO_PROFILE: &str = "None";
/// That entry with a PDF/X standard, whose files always have an output intent.
const PDFX_INTENT: &str = "CMYK profile in effect";
pub(super) const LABEL_WIDTH: f32 = 150.0;
/// Width of the section list (its frame adds 6 px a side).
@@ -308,18 +310,10 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
preset_rows(app, ui, d);
}
row_with(ui, "Standard:", TOP_LABEL_WIDTH, |ui| {
let picked = pick::<Standard>(ui, d, "standard", 170.0, Standard::supported);
let picked = pick::<Standard>(ui, d, "standard", 170.0, |_| true);
let standard = choice::<Standard>(d, "standard").unwrap_or_default();
if picked && choice::<Compatibility>(d, "compatibility").is_some_and(|c| !standard.allows(c)) {
// The standard's own version (PDF/A-2b is a PDF 1.7 standard).
set(d, "compatibility", json!(Compatibility::Pdf17.id()));
}
if picked && standard != Standard::None {
// Files of a standard don't carry the editing data, nor passwords.
set(d, "preserveEditing", json!(false));
for p in [OPEN_PASSWORD, PERMISSIONS_PASSWORD] {
set(d, p, json!(""));
}
if picked {
standard_chosen(d, standard);
}
ui.add_space(16.0);
ui.label(egui::RichText::new("Compatibility:").color(t.text));
@@ -375,6 +369,23 @@ pub(super) fn section_list(ui: &mut egui::Ui, d: &mut Dialog, sections: &[&str],
}
}
/// What choosing `standard` changes: the compatibility, to the latest version it allows, and off
/// what files of a standard can't have (editing data, passwords; layers in PDF/X-1a and PDF/X-3).
fn standard_chosen(d: &mut Dialog, standard: Standard) {
if choice::<Compatibility>(d, "compatibility").is_some_and(|c| !standard.allows(c)) {
set(d, "compatibility", json!(standard.version().id()));
}
if standard != Standard::None {
set(d, "preserveEditing", json!(false));
for p in [OPEN_PASSWORD, PERMISSIONS_PASSWORD] {
set(d, p, json!(""));
}
}
if !standard.allows_layers() {
set(d, "createLayers", json!(false));
}
}
/// The Preset row (the presets, built-in and saved, and Save Preset…) and, while Save Preset…
/// asks for a name, the row taking it.
fn preset_rows(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) {
@@ -424,12 +435,12 @@ fn apply_preset(app: &mut VectorcraftApp, d: &mut Dialog, name: &str) {
/// General: the options, then the artboards (Save PDF) or the description (preset editor).
fn general(ui: &mut egui::Ui, d: &mut Dialog, editor: bool) {
heading(ui, "Options");
let plain = choice::<Standard>(d, "standard").unwrap_or_default() == Standard::None;
flag(ui, d, "preserveEditing", "Preserve editing capabilities", plain);
let standard = choice::<Standard>(d, "standard").unwrap_or_default();
flag(ui, d, "preserveEditing", "Preserve editing capabilities", standard == Standard::None);
flag(ui, d, "thumbnails", "Embed page thumbnails", true);
flag(ui, d, "fastWebView", "Optimize for fast web view", true);
flag(ui, d, "viewAfterSaving", "View PDF after saving", true);
flag(ui, d, "createLayers", "Create PDF layers from top-level layers", true);
flag(ui, d, "createLayers", "Create PDF layers from top-level layers", standard.allows_layers());
flag(ui, d, "includeNonPrinting", "Include non-printing layers", true);
if editor {
heading(ui, "Description");
@@ -556,19 +567,28 @@ fn profile_pick(ui: &mut egui::Ui, d: &mut Dialog, path: &str, names: &[&str], e
fn output(ui: &mut egui::Ui, d: &mut Dialog) {
let profiles = vectorcraft_color::cms::profiles();
let names = |blank: &'static str| -> Vec<&str> { std::iter::once(blank).chain(profiles.iter().map(|p| p.name.as_str())).collect() };
let standard = choice::<Standard>(d, "standard").unwrap_or_default();
heading(ui, "Color");
row(ui, "Color conversion:", |ui| {
pick::<ColorConversion>(ui, d, "output.conversion", 300.0, |_| true);
});
let converting = get(d, "output.conversion") != ColorConversion::None.id();
// PDF/X-1a converts to CMYK even without a conversion.
let converting = get(d, "output.conversion") != ColorConversion::None.id() || standard.cmyk_only();
row(ui, "Destination:", |ui| profile_pick(ui, d, "output.destination", &names(DOCUMENT_PROFILE), converting));
row(ui, "Profile inclusion:", |ui| {
pick::<ProfileInclusion>(ui, d, "output.profiles", 300.0, |_| true);
// A standard decides whether colours are tagged.
ui.add_enabled_ui(standard == Standard::None, |ui| pick::<ProfileInclusion>(ui, d, "output.profiles", 300.0, |_| true));
});
if standard.cmyk_only() {
note(ui, "PDF/X-1a files are CMYK: every colour is converted to the destination (blank: the output intent's CMYK profile), untagged.");
} else if standard != Standard::None {
note(ui, "Files of this standard tag their colours with ICC profiles.");
}
heading(ui, "Output Intent");
// PDF/A files carry their own output intent.
let own = choice::<Standard>(d, "standard") != Some(Standard::PdfA2b);
row(ui, "Output intent profile:", |ui| profile_pick(ui, d, "output.outputIntent", &names(NO_PROFILE), own));
// PDF/A files carry their own output intent; PDF/X files always have one.
let own = standard != Standard::PdfA2b;
let blank = if standard.is_pdfx() { PDFX_INTENT } else { NO_PROFILE };
row(ui, "Output intent profile:", |ui| profile_pick(ui, d, "output.outputIntent", &names(blank), own));
for (p, label) in [
("output.outputCondition", "Output condition:"),
("output.outputConditionId", "Condition identifier:"),
@@ -932,4 +952,27 @@ mod tests {
assert_eq!(get(&d, OPEN_PASSWORD), &Value::Null, "presets carry no passwords");
assert_eq!(get(&d, "security.copy"), true);
}
#[test]
fn choosing_a_pdfx_standard_sets_its_version_and_writes_the_standard() {
let (mut app, written, _) = app();
app.run("ui.savePdfDialog", json!({"path": "/tmp/x.pdf", "createLayers": true})).unwrap();
let d = app.ui.dialog.as_mut().unwrap();
set(d, "standard", json!(Standard::PdfX1a.id()));
standard_chosen(d, Standard::PdfX1a);
assert_eq!((get(d, "compatibility"), get(d, "createLayers"), get(d, "preserveEditing")), (&json!("1.4"), &json!(false), &json!(false)));
for s in SECTIONS {
set_field(&mut app, "__section", json!(s));
frame(&mut app);
}
super::super::confirm(&mut app).unwrap();
let pdf = String::from_utf8_lossy(&written.borrow()[0].1).into_owned();
assert!(pdf.starts_with("%PDF-1.3") && pdf.contains("/GTS_PDFXConformance(PDF/X-1a:2001)") && pdf.contains("/S/GTS_PDFX"));
// PDF/X-4 keeps layers and is PDF 1.6.
app.run("ui.savePdfDialog", json!({"compatibility": "2.0", "createLayers": true})).unwrap();
let d = app.ui.dialog.as_mut().unwrap();
set(d, "standard", json!(Standard::PdfX4.id()));
standard_chosen(d, Standard::PdfX4);
assert_eq!((get(d, "compatibility"), get(d, "createLayers")), (&json!("1.6"), &json!(true)));
}
}
+31 -3
View File
@@ -215,7 +215,9 @@ Long-tail commands:
PDF files take the Save PDF dialog's options: `preset`, `standard`, `compatibility`, the General toggles and the
`compression`, `marks`, `bleed`, `output`, `advanced` and `security` sections (`list_commands` with filter `exportPdf`
documents every field). `document.exportPdf` and `export` (format `pdf`, the options in `options`) return `warnings`:
options accepted but not applied yet, and features approximated or left out. PDF/X and PDF/A-2b at 2.0 are refused.
options accepted but not applied yet, and features approximated or left out. A standard with a PDF version it doesn't
allow is refused (PDF/A-2b at 2.0, PDF/X-4 above 1.6, PDF/X-1a and PDF/X-3 at anything but 1.4); choosing a standard
without giving `compatibility` sets the latest it allows.
A `security` password encrypts the file (RC4 128-bit at PDF 1.4–1.5, AES-128 at 1.6, AES-256 at 1.7 and 2.0) with its
permissions; either password opens it (`document.open {password}`), and a password with PDF/A or PDF/X is refused.
Pattern fills and strokes are written as their tiles clipped to the area they paint (a stroke's outline, with its
@@ -1057,8 +1059,8 @@ bounds, or the artboard less a 36 pt margin.
## PDF presets
`pdf.preset.list` lists the built-in presets (`VectorCraft Default`, which preserves editing, then High Quality Print,
Press Quality, Smallest File Size and the PDF/X presets, `builtIn: true`; `supported: false` where the writer can't
produce the standard yet) and the saved ones, each with its `description` and `settings`. `pdf.preset.save {name?,
Press Quality, Smallest File Size and the PDF/X presets, `builtIn: true`; `supported` says the writer produces the
preset's standard) and the saved ones, each with its `description` and `settings`. `pdf.preset.save {name?,
newName?, description?, preset?, …document.exportPdf options}` creates or changes a saved preset (a change starts from
the preset's own settings; `newName` renames it; built-in presets are read-only; passwords are never stored);
`pdf.preset.delete {name}` deletes one. `pdf.preset.export {names?, path?}` writes them as a `.vcpdfpresets` JSON file
@@ -1556,3 +1558,29 @@ file with none is refused with a message saying why.
{"name":"run_command","arguments":{"command":"document.open","params":{"path":"/tmp/logo.eps"}}}
{"name":"run_command","arguments":{"command":"file.place","params":{"path":"/tmp/logo.eps","at":[300,300]}}}
```
## PDF/X
`document.exportPdf {standard: "pdfX1a" | "pdfX3" | "pdfX4"}` (or the built-in PDF/X presets) writes print exchange
files as their standards say. Every PDF/X file gets a `/GTS_PDFX` output intent (`output.outputIntent`; blank, the
CMYK profile in effect: the destination when converting to CMYK, else the working one, embedded; in PDF/X-1a and
PDF/X-3 a name that isn't a profile needs `output.registry`, a registered printing condition named without a profile;
PDF/X-4 always embeds one), a TrimBox on every page (the artboard), `/Trapped` True or False, `/GTS_PDFXVersion`, a title (`Untitled` when the
document has none) and dates; type is outlined or embedded; editing data, passwords and, in PDF/X-1a and PDF/X-3, PDF
layers are refused.
- **pdfX1a** (PDF/X-1a:2001, written as PDF 1.3): CMYK, grey and spot colours only. Every colour and image is converted
to `output.destination` (blank: the output intent's CMYK profile, else the working CMYK profile), untagged; an RGB
destination or output intent is refused. Transparency is flattened before writing.
- **pdfX3** (PDF/X-3:2002, written as PDF 1.3): colours are tagged with ICC profiles; transparency is flattened.
- **pdfX4** (PDF/X-4:2010, PDF 1.6 at most): transparency and layers are kept, colours are tagged, and the XMP metadata
names the standard.
Flattening uses the High Resolution flattener preset on a copy (the document is untouched; a warning says so), and
images with see-through pixels count as transparency. The written file is checked against its standard: what it still
breaks (transparency, RGB in PDF/X-1a, a font not embedded) fails the export instead of writing a file that only
claims the standard.
```json
{"name":"run_command","arguments":{"command":"document.exportPdf","params":{"path":"/tmp/press.pdf","preset":"PDF/X-1a:2001","output":{"outputIntent":"VectorCraft Generic CMYK (SWOP-like)","outputCondition":"Coated","trapped":false}}}}
```