diff --git a/Cargo.lock b/Cargo.lock index 0d73e0f6..9d9270f1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4217,6 +4217,8 @@ dependencies = [ "krilla", "kurbo 0.13.1", "log", + "serde", + "serde_json", "smallvec", "thiserror 2.0.21", "vectorcraft-brush", diff --git a/crates/engine/src/cmd/fileio/encode.rs b/crates/engine/src/cmd/fileio/encode.rs index 15666abc..c636fb9b 100644 --- a/crates/engine/src/cmd/fileio/encode.rs +++ b/crates/engine/src/cmd/fileio/encode.rs @@ -8,7 +8,6 @@ use vectorcraft_doc::Document; use super::super::*; use super::Format; -use crate::EngineError; const C: &str = "document.export"; @@ -113,11 +112,7 @@ pub fn encode(doc: &Document, format: &str, p: &Value) -> Result> { let opts = vectorcraft_svg::ExportOptions { artboard, outline_text: o.outline_text.unwrap_or(false), ..Default::default() }; vectorcraft_svg::export(doc, &opts).into_bytes() } - "pdf" => { - let o: ArtboardPick = options(f, p)?; - let opts = vectorcraft_pdf::PdfOptions { artboards: boards(o.resolve(n))?, ..Default::default() }; - super::super::rasterfx::export_pdf(doc, &opts).map_err(|e| EngineError::Other(e.to_string()))? - } + "pdf" => super::pdf::encode(C, doc, p)?.0, "png" | "jpg" | "webp" => { let o: RasterOptions = options(f, p)?; let region = doc.artboards[boards(o.boards.one(n))?].rect; diff --git a/crates/engine/src/cmd/fileio/mod.rs b/crates/engine/src/cmd/fileio/mod.rs index 7e238c74..c8bf32df 100644 --- a/crates/engine/src/cmd/fileio/mod.rs +++ b/crates/engine/src/cmd/fileio/mod.rs @@ -5,6 +5,7 @@ //! - `encode`: one encoder per writable format, with typed options parsed from the params. //! - `export`: `document.export` / `serialize` / `exportSelection` / `exportForScreens`. //! - `save`: `document.save`, `file.saveAsTemplate`. +//! - `pdf`: PDF settings and presets for every PDF export, `document.exportPdf`. //! //! [`FORMATS`] is the single list of formats (append-only); open dialogs use [`open_filters`], //! agents query `document.formats`. @@ -13,6 +14,7 @@ mod batch; mod encode; mod export; mod load; +pub mod pdf; mod save; use serde_json::{Value, json}; @@ -57,7 +59,7 @@ pub fn specs() -> Vec { "Export Document", [], None, - "{path?, format?: svg|pdf|png|jpg|webp|vectorcraft (default: from the path's extension, else png), artboard?: 0, artboards?: [i…], range?: \"1-3, 5\" (1-based; PDF writes one page per artboard, default all; the other formats write one artboard), scale?: 1 (raster), quality?: 90 (jpg), outlineText?: bool (svg)} → {path, format, bytes}; no path → {dataBase64, format, bytes}. Never changes the document's path", + "{path?, format?: svg|pdf|png|jpg|webp|vectorcraft (default: from the path's extension, else png), artboard?: 0, artboards?: [i…], range?: \"1-3, 5\" (1-based; PDF writes one page per artboard, default all; the other formats write one artboard), scale?: 1 (raster), quality?: 90 (jpg), outlineText?: bool (svg), …the PDF options of document.exportPdf} → {path, format, bytes}; no path → {dataBase64, format, bytes}. Never changes the document's path", has_doc, export::export ), @@ -105,7 +107,7 @@ pub fn specs() -> Vec { #[derive(Clone, Copy, Debug)] pub struct FormatOption { pub name: &'static str, - /// JSON type: `number`, `integer`, `boolean`, `string` or `array`. + /// JSON type: `number`, `integer`, `boolean`, `string`, `array` or `object`. pub ty: &'static str, /// The default as a JSON literal (`"1"`, `"false"`, `"null"`). pub default: &'static str, @@ -200,16 +202,7 @@ pub const FORMATS: &[Format] = &[ options: &[ARTBOARD, OUTLINE_TEXT], }, reader("svgz", "SVG Compressed", &["svgz"], "image/svg+xml", false), - Format { - id: "pdf", - label: "PDF", - extensions: &["pdf"], - mime: "application/pdf", - read: true, - write: true, - raster: false, - options: &[ARTBOARD, ARTBOARDS, RANGE], - }, + Format { id: "pdf", label: "PDF", extensions: &["pdf"], mime: "application/pdf", read: true, write: true, raster: false, options: pdf::OPTIONS }, reader("ai", "PDF-compatible .ai", &["ai"], "application/pdf", false), reader("ait", "PDF-compatible .ait template", &["ait"], "application/pdf", false), Format { id: "png", label: "PNG", extensions: &["png"], mime: "image/png", read: true, write: true, raster: true, options: &[ARTBOARD, SCALE] }, @@ -346,3 +339,5 @@ fn write_or_return(path: Option<&str>, bytes: &[u8], extra: Value) -> Result Vec { + vec![cmd!( + "document.exportPdf", + "Export PDF", + [], + None, + "{path?, preset?: \"VectorCraft Default\", 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?, thumbnails?, fastWebView?, viewAfterSaving?, 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}, compressText?: true}, marks?: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25, offset: 6}, bleed?: {useDocument, top, bottom, left, right} (pt), output?: {conversion: none|destination|preserveNumbers, destination, profiles: none|all|destination|taggedSource, outputIntent, outputCondition, outputConditionId, registry, trapped}, advanced?: {fontSubsetPercent: 100, outlineText: true, 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, passwords and PDF/A-2b at 2.0 are refused. document.export {format: pdf} takes the same options", + has_doc, + export_pdf + )] +} + +/// Every preset name (built-in first). +pub fn presets() -> Vec { + vec![DEFAULT_PRESET.to_string()] +} + +fn preset(cmd: &str, name: &str) -> Result { + if name.eq_ignore_ascii_case(DEFAULT_PRESET) || name.eq_ignore_ascii_case("default") { + return Ok(PdfSettings::default()); + } + Err(bad(cmd, format!("unknown PDF preset `{name}` (presets: {})", presets().join(", ")))) +} + +/// Merge `over` into `base`: objects key by key (recursively), `null` keeps the base (the +/// preset's value), anything else replaces. +fn merge(base: &mut Value, over: &Value) { + match (base, over) { + (Value::Object(b), Value::Object(o)) => { + for (k, v) in o.iter().filter(|(_, v)| !v.is_null()) { + merge(b.entry(k.as_str()).or_insert(Value::Null), v); + } + } + (b, o) => *b = o.clone(), + } +} + +/// A writer error as an engine error: bad settings are bad params of `cmd`. +fn pdf_error(cmd: &str, e: PdfError) -> EngineError { + match e { + PdfError::BadSetting(_) | PdfError::Unsupported(_) => bad(cmd, e.to_string()), + e => EngineError::Other(e.to_string()), + } +} + +/// The settings `p` asks for: its preset (default: [`DEFAULT_PRESET`]) with `p`'s options applied +/// over it, checked. Keys that aren't PDF options (path, format…) are ignored. +pub fn settings(cmd: &str, p: &Value) -> Result { + let base = match str_param(p, "preset") { + Some(name) => preset(cmd, name)?, + None => PdfSettings::default(), + }; + if !p.is_object() { + return Ok(base); + } + let mut v = serde_json::to_value(&base).map_err(|e| EngineError::Other(e.to_string()))?; + merge(&mut v, p); + let s = PdfSettings::deserialize(&v).map_err(|e| bad(cmd, format!("PDF options: {e}")))?; + s.check().map_err(|e| pdf_error(cmd, e))?; + Ok(s) +} + +/// The full export options for `doc`: settings plus the artboards `p` picks (default all). +pub fn options(cmd: &str, doc: &Document, p: &Value) -> Result { + let pick = if p.is_object() { ArtboardPick::deserialize(p).map_err(|e| bad(cmd, format!("PDF options: {e}")))? } else { ArtboardPick::default() }; + let artboards = pick.resolve(doc.artboards.len()).map_err(|e| bad(cmd, e))?; + Ok(PdfOptions { settings: settings(cmd, p)?, artboards, ..Default::default() }) +} + +/// Encode `doc` as PDF with the options in `p` → (bytes, warnings). Raster effects are rendered +/// to images at the document's raster effects resolution. +pub fn encode(cmd: &str, doc: &Document, p: &Value) -> Result<(Vec, Vec)> { + let opts = options(cmd, doc, p)?; + let r = super::super::rasterfx::export_pdf_with_report(doc, &opts).map_err(|e| pdf_error(cmd, e))?; + let mut warnings = 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()); + } + Ok((r.bytes, warnings)) +} + +fn export_pdf(s: &mut Session, p: &Value) -> Result { + let (bytes, warnings) = encode(C, &s.doc()?.doc, p)?; + write_or_return(str_param(p, "path"), &bytes, json!({ "warnings": warnings })) +} diff --git a/crates/engine/src/cmd/fileio/tests_pdf.rs b/crates/engine/src/cmd/fileio/tests_pdf.rs new file mode 100644 index 00000000..3bd5abbd --- /dev/null +++ b/crates/engine/src/cmd/fileio/tests_pdf.rs @@ -0,0 +1,146 @@ +//! `document.exportPdf` and the PDF options of every PDF export. + +use serde_json::{Value, json}; + +use super::pdf::DEFAULT_PRESET; +use super::*; + +fn session(artboards: usize) -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 100, "height": 80, "artboards": artboards})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 40, "height": 30})).unwrap(); + s +} + +fn b64(v: &Value) -> Vec { + vectorcraft_format::base64_decode(v["dataBase64"].as_str().expect("dataBase64")).unwrap() +} + +fn export(s: &mut Session, p: Value) -> Value { + s.execute("document.exportPdf", &p).unwrap_or_else(|e| panic!("{p}: {e}")) +} + +fn warnings(v: &Value) -> Vec { + v["warnings"].as_array().expect("warnings").iter().map(|w| w.as_str().unwrap().to_string()).collect() +} + +fn pages(bytes: &[u8]) -> usize { + vectorcraft_pdf::import(bytes).unwrap().artboards.len() +} + +/// `p` plus `format: pdf` (the same options through `document.export`). +fn as_export(p: &Value) -> Value { + let mut o = p.as_object().cloned().unwrap_or_default(); + o.insert("format".into(), json!("pdf")); + Value::Object(o) +} + +#[test] +fn export_pdf_writes_the_compatibility_header_and_the_range() { + let mut s = session(3); + let v = export(&mut s, json!({})); + let pdf = b64(&v); + assert!(pdf.starts_with(b"%PDF-1.7")); + assert_eq!(pages(&pdf), 3); + assert_eq!(v["bytes"], pdf.len()); + assert!(warnings(&v).is_empty(), "{v}"); + assert!(b64(&export(&mut s, json!({"compatibility": "1.5"}))).starts_with(b"%PDF-1.5")); + assert!(b64(&export(&mut s, json!({"compatibility": "2.0"}))).starts_with(b"%PDF-2.0")); + assert_eq!(pages(&b64(&export(&mut s, json!({"range": "1,3"})))), 2); + assert_eq!(pages(&b64(&export(&mut s, json!({"artboards": [1]})))), 1); + // The same options through document.export and document.serialize. + let v = s.execute("document.export", &as_export(&json!({"compatibility": "1.4", "range": "2"}))).unwrap(); + assert!(b64(&v).starts_with(b"%PDF-1.4") && pages(&b64(&v)) == 1); + let v = s.execute("document.serialize", &as_export(&json!({"compatibility": "1.6"}))).unwrap(); + assert!(b64(&v).starts_with(b"%PDF-1.6")); +} + +#[test] +fn bad_options_are_refused() { + let mut s = session(2); + for p in [ + json!({"compatibility": "1.3"}), + json!({"compatibility": 1.5}), + json!({"range": "1-3"}), + json!({"range": "0"}), + json!({"artboards": []}), + json!({"preset": "Nope"}), + json!({"standard": "pdfX4"}), + json!({"standard": "pdfA2b", "compatibility": "2.0"}), + json!({"compression": {"color": {"ppi": 0}}}), + json!({"compression": "zip"}), + json!({"security": {"openPassword": "x"}}), + ] { + let e = s.execute("document.exportPdf", &p).unwrap_err(); + assert!(matches!(e, crate::EngineError::BadParams { .. }), "{p}: {e}"); + assert!(s.execute("document.export", &as_export(&p)).is_err(), "document.export {p}"); + } +} + +#[test] +fn options_apply_over_the_preset() { + let mut s = session(1); + let a = b64(&export(&mut s, json!({"preset": DEFAULT_PRESET, "compression": {"compressText": false}}))); + let b = b64(&export(&mut s, json!({"preset": "default"}))); + assert!(a.len() > b.len(), "content streams left uncompressed"); + assert!(String::from_utf8_lossy(&a).contains(" re") || String::from_utf8_lossy(&a).contains(" l")); + // `null` (the default document.formats lists) keeps the preset's value, at any depth. + let p = json!({"compatibility": null, "compression": null, "security": {"openPassword": null}, "marks": {"weight": null}}); + let v = export(&mut s, p); + assert!(b64(&v).starts_with(b"%PDF-1.7")); + assert!(warnings(&v).is_empty(), "{v}"); +} + +#[test] +fn pdf_a_refuses_pdf_2() { + let mut s = session(1); + let e = s.execute("document.exportPdf", &json!({"standard": "pdfA2b", "compatibility": "2.0"})).unwrap_err(); + assert!(matches!(e, crate::EngineError::BadParams { .. }), "{e}"); + let pdf = b64(&export(&mut s, json!({"standard": "pdfA2b", "compatibility": "1.6"}))); + assert!(pdf.starts_with(b"%PDF-1.6") && String::from_utf8_lossy(&pdf).contains("pdfaid")); +} + +#[test] +fn options_not_applied_yet_and_document_features_warn() { + let mut s = session(1); + let w = warnings(&export(&mut s, json!({"thumbnails": true, "marks": {"trim": true}}))); + assert_eq!(w.len(), 2, "{w:?}"); + assert!(w.iter().any(|w| w.contains("thumbnails")) && w.iter().any(|w| w.contains("marks"))); + // A pattern stroke is approximated: its warning comes back. + let tile = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [tile]})).unwrap(); + s.execute("object.pattern.make", &json!({"name": "Dots", "width": 20, "height": 20})).unwrap(); + s.execute("object.pattern.done", &json!({})).unwrap(); + let frame = s.execute("shape.rectangle", &json!({"x": 20, "y": 20, "width": 50, "height": 40})).unwrap()["id"].as_u64().unwrap(); + s.execute("paint.setStroke", &json!({"ids": [frame], "swatch": "Dots"})).unwrap(); + let pattern = |w: Vec| w.iter().any(|w| w.contains("pattern strokes")); + assert!(pattern(warnings(&export(&mut s, json!({}))))); +} + +#[test] +fn export_pdf_writes_a_path() { + let mut s = session(2); + let dir = std::env::temp_dir().join(format!("vc-exportpdf-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("out.pdf").to_string_lossy().to_string(); + let v = export(&mut s, json!({"path": path, "range": "2"})); + assert_eq!(v["path"], path.as_str()); + assert!(v.get("dataBase64").is_none()); + let bytes = std::fs::read(&path).unwrap(); + assert_eq!(v["bytes"], bytes.len()); + assert_eq!(pages(&bytes), 1); + assert_eq!(s.doc().unwrap().path, None, "exporting never retargets the document"); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn formats_list_the_pdf_options() { + let mut s = Session::new(); + let v = s.execute("document.formats", &json!({})).unwrap(); + let pdf = v["formats"].as_array().unwrap().iter().find(|f| f["id"] == "pdf").unwrap(); + for k in ["artboard", "range", "preset", "standard", "compatibility", "compression", "security"] { + assert!(pdf["options"].get(k).is_some(), "{k}"); + } + assert_eq!(pdf["options"]["compatibility"]["default"], "1.7"); + assert_eq!(pdf["options"]["preset"]["default"], DEFAULT_PRESET); +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 21adff72..45a2f159 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -210,6 +210,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(attributes::specs()); v.extend(newart::specs()); v.extend(colortheme::specs()); + v.extend(fileio::pdf::specs()); v }) } diff --git a/crates/engine/src/cmd/rasterfx.rs b/crates/engine/src/cmd/rasterfx.rs index d1b9533d..8233976b 100644 --- a/crates/engine/src/cmd/rasterfx.rs +++ b/crates/engine/src/cmd/rasterfx.rs @@ -181,9 +181,18 @@ pub fn flatten_raster_effects(doc: &Document) -> Option { /// PDF bytes for `doc`, with raster effects rendered as images. pub fn export_pdf(doc: &Document, opts: &vectorcraft_pdf::PdfOptions) -> std::result::Result, vectorcraft_pdf::PdfError> { + export_pdf_with_report(doc, opts).map(|r| r.bytes) +} + +/// Like [`export_pdf`], also returning the writer's warnings (options not applied yet, features +/// approximated or left out). +pub fn export_pdf_with_report( + doc: &Document, + opts: &vectorcraft_pdf::PdfOptions, +) -> std::result::Result { match flatten_raster_effects(doc) { - Some(d) => vectorcraft_pdf::export(&d, opts), - None => vectorcraft_pdf::export(doc, opts), + Some(d) => vectorcraft_pdf::export_with_report(&d, opts), + None => vectorcraft_pdf::export_with_report(doc, opts), } } diff --git a/crates/engine/src/tests_colormgmt.rs b/crates/engine/src/tests_colormgmt.rs index 0bc4e2d2..b4265791 100644 --- a/crates/engine/src/tests_colormgmt.rs +++ b/crates/engine/src/tests_colormgmt.rs @@ -318,7 +318,8 @@ fn has_cmyk_op(pdf: &str) -> bool { } fn pdf_text(doc: &Document) -> String { - let bytes = vectorcraft_pdf::export(doc, &vectorcraft_pdf::PdfOptions { compress: false, created: Some(0), ..Default::default() }).unwrap(); + let bytes = + vectorcraft_pdf::export(doc, &vectorcraft_pdf::PdfOptions { created: Some(0), ..vectorcraft_pdf::PdfOptions::uncompressed() }).unwrap(); String::from_utf8_lossy(&bytes).into_owned() } diff --git a/crates/engine/src/tests_knockout.rs b/crates/engine/src/tests_knockout.rs index 26e27cc0..972dd9f7 100644 --- a/crates/engine/src/tests_knockout.rs +++ b/crates/engine/src/tests_knockout.rs @@ -145,7 +145,7 @@ fn pdf_and_svg_write_knockout_groups_as_masks() { run(&mut s, "transparency.set", json!({"ids": [g], "knockout": "on"})); let ko = s.doc().unwrap().doc.clone(); - let pdf = |d: &Document| vectorcraft_pdf::export_with_report(d, &vectorcraft_pdf::PdfOptions { compress: false, ..Default::default() }).unwrap(); + let pdf = |d: &Document| vectorcraft_pdf::export_with_report(d, &vectorcraft_pdf::PdfOptions::uncompressed()).unwrap(); let r = pdf(&plain); assert!(!String::from_utf8_lossy(&r.bytes).contains("/SMask") && r.warnings.is_empty()); let r = pdf(&ko); diff --git a/crates/engine/src/tests_labspots.rs b/crates/engine/src/tests_labspots.rs index f405d494..24b1a5c9 100644 --- a/crates/engine/src/tests_labspots.rs +++ b/crates/engine/src/tests_labspots.rs @@ -149,7 +149,7 @@ fn spot_options_and_lab_colours_round_trip() { } fn pdf_bytes(s: &Session) -> Vec { - let opts = vectorcraft_pdf::PdfOptions { compress: false, created: Some(0), ..Default::default() }; + let opts = vectorcraft_pdf::PdfOptions { created: Some(0), ..vectorcraft_pdf::PdfOptions::uncompressed() }; vectorcraft_pdf::export(doc(s), &opts).unwrap() } diff --git a/crates/engine/src/tests_linked_stops.rs b/crates/engine/src/tests_linked_stops.rs index edd392fa..2b0cae9e 100644 --- a/crates/engine/src/tests_linked_stops.rs +++ b/crates/engine/src/tests_linked_stops.rs @@ -120,7 +120,7 @@ fn spot_stops_separate_on_their_plate() { } fn pdf_of(s: &Session) -> (String, Vec) { - let opts = vectorcraft_pdf::PdfOptions { compress: false, created: Some(0), ..Default::default() }; + let opts = vectorcraft_pdf::PdfOptions { created: Some(0), ..vectorcraft_pdf::PdfOptions::uncompressed() }; let r = vectorcraft_pdf::export_with_report(doc(s), &opts).unwrap(); (String::from_utf8_lossy(&r.bytes).into_owned(), r.warnings) } diff --git a/crates/engine/src/tests_registration.rs b/crates/engine/src/tests_registration.rs index af232531..9b539f47 100644 --- a/crates/engine/src/tests_registration.rs +++ b/crates/engine/src/tests_registration.rs @@ -103,9 +103,11 @@ fn pdf_writes_registration_as_separation_all() { let mut s = session(); run(&mut s, "shape.rectangle", json!({"x": 30, "y": 30, "width": 40, "height": 40})); run(&mut s, "object.createTrimMarks", json!({})); - let bytes = - vectorcraft_pdf::export(&s.doc().unwrap().doc, &vectorcraft_pdf::PdfOptions { compress: false, created: Some(0), ..Default::default() }) - .unwrap(); + let bytes = vectorcraft_pdf::export( + &s.doc().unwrap().doc, + &vectorcraft_pdf::PdfOptions { created: Some(0), ..vectorcraft_pdf::PdfOptions::uncompressed() }, + ) + .unwrap(); let pdf = String::from_utf8_lossy(&bytes); assert!(pdf.contains("/Separation/All/DeviceCMYK"), "Registration prints on every plate"); } diff --git a/crates/engine/src/tests_strokegradient.rs b/crates/engine/src/tests_strokegradient.rs index fce22425..eb9b0335 100644 --- a/crates/engine/src/tests_strokegradient.rs +++ b/crates/engine/src/tests_strokegradient.rs @@ -133,7 +133,7 @@ fn svg_and_pdf_write_the_same_gradient_as_slices_with_a_warning() { assert!(svg.contains(" 3, "{mode}: sliced"); let back = vectorcraft_svg::import(&svg).unwrap(); assert_same(&before, &render(&back), 0.04, &format!("{mode} SVG")); - let opts = vectorcraft_pdf::PdfOptions { compress: false, ..Default::default() }; + let opts = vectorcraft_pdf::PdfOptions::uncompressed(); let r = vectorcraft_pdf::export_with_report(doc(&s), &opts).unwrap(); assert!(r.warnings.iter().any(|w| w.contains("along or across strokes")), "{:?}", r.warnings); assert!(String::from_utf8_lossy(&r.bytes).matches("/ShadingType 2").count() > 3, "{mode}: axial shadings"); diff --git a/crates/engine/src/tests_swatches.rs b/crates/engine/src/tests_swatches.rs index 5b886278..d22bf5eb 100644 --- a/crates/engine/src/tests_swatches.rs +++ b/crates/engine/src/tests_swatches.rs @@ -57,7 +57,8 @@ fn grouped_spot_swatches_print_on_their_own_plate() { assert!(plates["plates"].as_array().unwrap().iter().any(|p| p["name"] == "Signal Orange" && p["spot"] == true), "{plates}"); let a = rect(&mut s); run(&mut s, "paint.setFill", json!({"ids": [a.0], "swatch": "Signal Orange"})); - let bytes = vectorcraft_pdf::export(doc(&s), &vectorcraft_pdf::PdfOptions { compress: false, created: Some(0), ..Default::default() }).unwrap(); + let bytes = + vectorcraft_pdf::export(doc(&s), &vectorcraft_pdf::PdfOptions { created: Some(0), ..vectorcraft_pdf::PdfOptions::uncompressed() }).unwrap(); let pdf = String::from_utf8_lossy(&bytes); assert!(pdf.contains("/Separation") && pdf.contains("Signal#20Orange"), "a grouped spot swatch is a Separation"); let info = run(&mut s, "document.info", json!({})); diff --git a/crates/engine/src/tests_tints.rs b/crates/engine/src/tests_tints.rs index f977e985..caf99043 100644 --- a/crates/engine/src/tests_tints.rs +++ b/crates/engine/src/tests_tints.rs @@ -120,7 +120,7 @@ fn spot_tints_print_their_percentage_of_the_plate() { // A process colour linked to a global swatch separates into process inks. assert!(proof::inks(d, &c, &color, Some(("Brand", 0.4)), proof::Intent::RelativeColorimetric).spot.is_none()); // PDF: the Separation value is the tint (102 / 255 = 0.4). - let bytes = vectorcraft_pdf::export(d, &vectorcraft_pdf::PdfOptions { compress: false, created: Some(0), ..Default::default() }).unwrap(); + let bytes = vectorcraft_pdf::export(d, &vectorcraft_pdf::PdfOptions { created: Some(0), ..vectorcraft_pdf::PdfOptions::uncompressed() }).unwrap(); let pdf = String::from_utf8_lossy(&bytes); assert!(pdf.contains("/Separation/Ink/DeviceCMYK"), "a spot tint is a Separation"); assert!(pdf.contains("cs 0.4 scn"), "the tint is the Separation value"); diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index aea0aa4c..738c3013 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -50,7 +50,7 @@ fn rich_svg() -> String { } fn rich_pdf() -> Vec { - vectorcraft_engine::export_pdf(&rich_doc(), &vectorcraft_pdf::PdfOptions { compress: false, ..Default::default() }).unwrap() + vectorcraft_engine::export_pdf(&rich_doc(), &vectorcraft_pdf::PdfOptions::uncompressed()).unwrap() } /// Numbers that tend to break arithmetic: zero, negatives, huge, tiny, exponents, junk. diff --git a/crates/pdf/Cargo.toml b/crates/pdf/Cargo.toml index 540e7f33..cf018bce 100644 --- a/crates/pdf/Cargo.toml +++ b/crates/pdf/Cargo.toml @@ -23,6 +23,8 @@ hayro-syntax = "0.7.2" hayro-interpret = { version = "0.7.0", default-features = false, features = ["embed-fonts"] } log = { workspace = true } smallvec = "1" +serde = { workspace = true } [dev-dependencies] vectorcraft-testkit = { workspace = true } +serde_json = { workspace = true } diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index 164e1255..9d9ac13c 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -19,15 +19,18 @@ use vectorcraft_effects::stroke::{self, WrittenShape}; use vectorcraft_geom::{Affine, BezPath, FillRule, Rect}; use crate::lab_spot::{find, rfind}; -use crate::{Compatibility, ExportReport, PdfError, PdfOptions}; +use crate::{Compatibility, ExportReport, PdfError, PdfOptions, Standard}; /// Export `doc` as PDF bytes: one page per artboard (or the artboards chosen in `opts`). pub fn export(doc: &Document, opts: &PdfOptions) -> Result, PdfError> { export_with_report(doc, opts).map(|r| r.bytes) } -/// Like [`export`], also returning warnings about approximated or dropped features. +/// Like [`export`], also returning warnings: options accepted but not applied yet (see +/// [`crate::PdfSettings::warnings`]) and features approximated or dropped. pub fn export_with_report(doc: &Document, opts: &PdfOptions) -> Result { + let set = &opts.settings; + set.check()?; // Live geometry effects export as their result; raster effects are reported below. let baked = vectorcraft_effects::bake_document(doc); let doc = baked.as_ref().unwrap_or(doc); @@ -45,21 +48,20 @@ pub fn export_with_report(doc: &Document, opts: &PdfOptions) -> Result (PdfVersion::Pdf14, None), - Compatibility::Pdf15 => (PdfVersion::Pdf15, None), - Compatibility::Pdf16 => (PdfVersion::Pdf16, None), - Compatibility::Pdf17 => (PdfVersion::Pdf17, None), - Compatibility::Pdf20 => (PdfVersion::Pdf20, None), - Compatibility::PdfA2b => (PdfVersion::Pdf17, Some(Archival::A2_B)), - Compatibility::PdfX4 => return Err(PdfError::Unsupported("PDF/X-4 output is not supported by the PDF writer yet".into())), + let version = match set.compatibility { + Compatibility::Pdf14 => PdfVersion::Pdf14, + Compatibility::Pdf15 => PdfVersion::Pdf15, + Compatibility::Pdf16 => PdfVersion::Pdf16, + Compatibility::Pdf17 => PdfVersion::Pdf17, + Compatibility::Pdf20 => PdfVersion::Pdf20, }; let mut cb = ConfigurationBuilder::new().with_version(version); - if let Some(a) = archival { - cb = cb.with_archival_validator(a); + // `check` has refused the standards (and standard/version pairs) the writer can't produce. + if set.standard == Standard::PdfA2b { + cb = cb.with_archival_validator(Archival::A2_B); } let configuration = cb.finish().map_err(|e| PdfError::Write(format!("{e:?}")))?; - let settings = krilla::SerializeSettings { compress_content_streams: opts.compress, configuration, ..Default::default() }; + let settings = krilla::SerializeSettings { compress_content_streams: set.compression.compress_text, configuration, ..Default::default() }; let mut pdf = krilla::Document::new_with(settings); let title = opts.title.clone().unwrap_or_else(|| doc.title.clone()); @@ -108,7 +110,8 @@ pub fn export_with_report(doc: &Document, opts: &PdfOptions) -> Result