mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-15 20:34:18 +00:00
Each writer marks its notes as a loss (something of the document that the file leaves out or that reaches it changed) or as a note on the options (an option it didn't apply or applied another way while the art reaches the file as it looks, or something an option turns off). The PDF, SVG, EPS, legacy .ai, DXF and HP-GL writers return their notes on the options in a list of their own (`setting_notes`) beside `warnings`, and the engine's `Encoded` does the same. The replies of the save and export commands, and of `clipboard.exportPdf` and `clipboard.exportPng`, list the losses first, each kind in the order the writers gave them, across every file that Export for Screens writes. The status bar names the first note, so an export to lossy WebP of type in a font that isn't installed now names the fallback-font note instead of the lossy WebP note. A print to a file keeps the order its notes are given in.
530 lines
31 KiB
Rust
530 lines
31 KiB
Rust
//! PDF export settings for every PDF path (`document.export {format: pdf}`, Export for Screens,
|
||
//! `document.exportPdf` and the Save PDF dialog): presets (built-in and saved), the options parsed
|
||
//! over them, the warnings that come back, and `document.pdfSettings` (the dialog's Summary).
|
||
|
||
use std::borrow::Cow;
|
||
use std::sync::Arc;
|
||
|
||
use serde::Deserialize;
|
||
use serde_json::{Value, json};
|
||
use vectorcraft_doc::{Document, Node, NodeKind};
|
||
use vectorcraft_pdf::{Overprint, PdfError, PdfOptions, PdfPreset, PdfSettings, Standard, THUMBNAIL_SIZE, Thumbnail};
|
||
|
||
use super::super::flatten::{FlattenOptions, flatten_document, see_through_images};
|
||
use super::super::{CommandSpec, Result, Session, always, bad, bool_or, cmd, has_doc, str_param};
|
||
use super::{ArtboardPick, FormatOption, Note, note_texts, write_or_return};
|
||
use crate::EngineError;
|
||
|
||
const C: &str = "document.exportPdf";
|
||
|
||
/// The built-in preset every PDF export starts from.
|
||
pub use vectorcraft_pdf::DEFAULT_PRESET;
|
||
|
||
/// The PDF options `document.formats` lists; `document.exportPdf` documents every field.
|
||
pub(crate) const OPTIONS: &[FormatOption] = &[
|
||
super::ARTBOARD,
|
||
super::ARTBOARDS,
|
||
super::RANGE,
|
||
super::USE_ARTBOARDS,
|
||
FormatOption {
|
||
name: "preset",
|
||
ty: "string",
|
||
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 | 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.3 (transparency flattened) | 1.4 | 1.5 | 1.6 | 1.7 | 2.0 (a pdfA2b file at 1.4 with a page longer than 14,400 pt is written as 1.6, with a UserUnit as at 1.6, and a warning; see document.exportPdf)",
|
||
},
|
||
FormatOption {
|
||
name: "preserveEditing",
|
||
ty: "boolean",
|
||
default: "true",
|
||
description: "embed the native document so VectorCraft reopens the PDF editable (the default preset's choice)",
|
||
},
|
||
FormatOption { name: "thumbnails", ty: "boolean", default: "false", description: "each page drawn as a /Thumb image, 106 px on its long side" },
|
||
FormatOption {
|
||
name: "fastWebView",
|
||
ty: "boolean",
|
||
default: "false",
|
||
description: "a linearized file: the first page shows while the rest downloads",
|
||
},
|
||
FormatOption {
|
||
name: "viewAfterSaving",
|
||
ty: "boolean",
|
||
default: "false",
|
||
description: "in the desktop app's document.exportPdf, the written file opens in the system viewer",
|
||
},
|
||
FormatOption { name: "compression", ty: "object", default: "null", description: "{color, gray, mono, compressText} (see document.exportPdf)" },
|
||
FormatOption { name: "marks", ty: "object", default: "null", description: "printer's marks (see document.exportPdf)" },
|
||
FormatOption {
|
||
name: "bleed",
|
||
ty: "object",
|
||
default: "null",
|
||
description: "{useDocument, top, bottom, left, right} in points: the page grows by the bleed (BleedBox) around its artboard (TrimBox); a PDF/X file with no bleed on a side where the document's bleed has one comes back with a warning",
|
||
},
|
||
FormatOption {
|
||
name: "output",
|
||
ty: "object",
|
||
default: "null",
|
||
description: "colour conversion to a destination profile, ICC profiles, output intent and Trapped (see document.exportPdf)",
|
||
},
|
||
FormatOption {
|
||
name: "advanced",
|
||
ty: "object",
|
||
default: "null",
|
||
description: "{fontSubsetPercent, outlineText (false: real text in embedded subset fonts), overprint}",
|
||
},
|
||
FormatOption {
|
||
name: "security",
|
||
ty: "object",
|
||
default: "null",
|
||
description: "passwords and permissions (the file is encrypted: RC4 40-bit at 1.3, RC4 128-bit at 1.4–1.5, AES-128 at 1.6, AES-256 at 1.7/2.0)",
|
||
},
|
||
FormatOption {
|
||
name: "includeNonPrinting",
|
||
ty: "boolean",
|
||
default: "false",
|
||
description: "keep the layers whose Print option is off (left out otherwise, unless createLayers)",
|
||
},
|
||
FormatOption {
|
||
name: "longPagesInPoints",
|
||
ty: "boolean",
|
||
default: "false",
|
||
description: "a page with a side longer than 14,400 pt (200 in) is written at its size in points, without a UserUnit, at every PDF version, in files without a standard (PDF/A files keep the UserUnit)",
|
||
},
|
||
FormatOption {
|
||
name: "createLayers",
|
||
ty: "boolean",
|
||
default: "false",
|
||
description: "layers and sublayers as PDF layers (optional content groups with their visibility, print state and lock, sublayers listed under their layers; PDF 1.5 or later)",
|
||
},
|
||
FormatOption {
|
||
name: "flattenerPreset",
|
||
ty: "string",
|
||
default: "\"\"",
|
||
description: "the flattener preset PDF 1.3 files (PDF/X-1a and PDF/X-3 too) flatten transparency with: empty (High Resolution), high, medium, low or a saved one",
|
||
},
|
||
FormatOption {
|
||
name: "flattener",
|
||
ty: "object",
|
||
default: "null",
|
||
description: "flattener options over flattenerPreset (as object.flattenTransparency)",
|
||
},
|
||
];
|
||
|
||
pub fn specs() -> Vec<CommandSpec> {
|
||
vec![
|
||
cmd!(
|
||
"document.exportPdf",
|
||
"Export PDF",
|
||
[],
|
||
None,
|
||
"{path?, preset?: \"VectorCraft Default\" (built-in or saved: pdf.preset.list), artboard? | artboards?: [i…] | range?: \"1-3, 5\" (1-based; default all, one page each), standard?: none|pdfA2b|pdfX1a (CMYK, grey and spot colours only: colours and images converted to output.destination or the CMYK output intent, untagged; transparency flattened with flattenerPreset; PDF 1.3)|pdfX3 (colours ICC-tagged; transparency flattened; PDF 1.3)|pdfX4 (transparency and layers kept; colours ICC-tagged; PDF 1.6 at most) (PDF/X: a /GTS_PDFX output intent, by default the CMYK profile in effect, embedded; TrimBox, Trapped, GTS_PDFXVersion, title and dates; fonts embedded or outlined), compatibility?: 1.3 (no transparency: it is flattened with flattenerPreset, without the layers the file leaves out; images keep no see-through pixels)|1.4|1.5|1.6|1.7 (default)|2.0 (choosing a standard sets its version: 1.7 for pdfA2b, 1.6 for pdfX4, 1.3 for pdfX1a and pdfX3, which write 1.4 as 1.3 too; pdfA2b refuses 1.3; a page with a side longer than 14,400 pt keeps its size with a UserUnit from 1.6 on: 1.5 is then written as 1.6, with 1.5's encryption, and so is 1.4 in pdfA2b files, with a warning; PDF/X files and other 1.3 and 1.4 files write it in points, and 1.3 and 1.4 (pdfX1a and pdfX3 too) refuse a page past 32,767 pt whose size or art needs a real number past 32,767; a page whose short side is 3 pt or longer but would be under 3 units at that UserUnit gets a smaller one that keeps the short side at 3 units; a warning gives each such page's size), longPagesInPoints?: false (true: such a page is written at its size in points, without a UserUnit, at every version, and 1.5 stays 1.5, in files without a standard (PDF/A files keep the UserUnit); apps that keep to the limit cut it to 200 in), preserveEditing? (on in VectorCraft Default: the native document as an embedded file, which document.open restores; choosing a standard turns it off, a standard refuses it), thumbnails? (each page drawn as a /Thumb image, 106 px on its long side), fastWebView? (a linearised file: the first page shows while the rest downloads; it stays linearised when encrypted), viewAfterSaving? (the app opens the written file), createLayers? (each layer and sublayer but template layers as a PDF layer, an optional content group listed under its layer's: hidden layers off, non-printing ones /PrintState /OFF, locked ones locked; needs 1.5 or later; they reopen as layers; choosing pdfX1a or pdfX3 turns it off; they refuse it), includeNonPrinting? (keep layers whose Print option is off; left out by default unless createLayers), flattenerPreset?: \"\" (High Resolution) | high | medium | low | a saved preset (flattener.presets.list): what PDF 1.3 files flatten transparency with, flattener?: {object.flattenTransparency options over it}, compression?: {color?, gray?: {downsample: none|average|subsample|bicubic, ppi: 300, abovePpi: 450, compression: none|zip|jpeg|jpeg2000|auto, quality: minimum|low|medium|high|maximum}, mono?: {downsample, ppi: 1200, abovePpi: 1800, compression: none|ccittG3|ccittG4|zip|runLength} (black-and-white images), compressText?: true} (images above abovePpi are resampled to ppi; auto keeps JPEGs JPEG, the others lossless; JPEG needs opaque images; none, jpeg2000, CCITT and runLength are written as ZIP with a warning), marks?: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25 (trim marks and targets), offset: 6 (pt from the artboard, at least the bleed)} (printer's marks in [Registration], every plate: trim marks, registration targets mid-side, CMYK/spot/black-tint colour bars on top, page information (title, artboard, date UTC) below), bleed?: {useDocument (the document's bleed, document.setup), top, bottom, left, right} (pt; art in the bleed is kept; a PDF/X file with no bleed on a side where the document's bleed has one comes back with a warning). Each page: TrimBox = artboard, BleedBox = artboard + bleed, MediaBox = that + the marks' room (art clipped to the BleedBox), output?: {conversion: none (default)|destination (every colour into the destination profile's model, CMYK or RGB, also colours of that model in another profile)|preserveNumbers (only colours of the other model and Lab; those in the destination's model keep their numbers) (grey stays grey; images are converted too, printer's marks are not), destination: profile name (edit.colorSettings lists them; default: the document's profile for its colour mode), profiles: none (device colours)|all|destination|taggedSource (documents assigned a profile: edit.assignProfile) (ICC-based colours: CMYK with the destination or document CMYK profile, RGB as sRGB, grey with the sRGB tone curve; PDF/A, pdfX3 and pdfX4 always, whatever profiles says), outputIntent: profile name (embedded as the catalog's /GTS_PDFX output intent; a name that isn't a profile is only named, which pdfX1a and pdfX3 allow only with a registry and pdfX4 refuses; pdfX1a needs a CMYK one), outputCondition, outputConditionId (default: outputIntent), registry, trapped (/Trapped /True; /False when an output intent is written)} (PDF/A files keep their own output intent), advanced?: {fontSubsetPercent: 100 (below 100 is accepted with a warning: fonts are always embedded as subsets), outlineText: false (type as real, selectable and searchable text in embedded subset fonts with a ToUnicode map; fonts whose licence forbids embedding, or allows only whole fonts, stay outlines with a warning; true: every glyph as an outline), overprint: preserve (overprinting fills and strokes set /OP /op true, /OPM 1; white ones knock out with document.setup discardWhiteOverprint)|discard (they knock out)}, security?: {openPassword, permissionsPassword, printing: none|low|high, changes: none|pages|forms|comments|any, copy, screenReader, plaintextMetadata}, acknowledgeLoss?: bool (write over the file the document was read from although reading it left things out), format?: \"pdf\"} → {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) or a PDF 1.3 file with transparency left. document.export {format: pdf} takes the same options. A key PDF doesn't take, and an unknown key inside one of the option objects, are refused",
|
||
has_doc,
|
||
export_pdf
|
||
),
|
||
cmd!(
|
||
query "document.pdfSettings",
|
||
"PDF Settings",
|
||
[],
|
||
None,
|
||
"{preset?, includeDocument?: false, …options of document.exportPdf} → {settings (the preset with the options applied), presets: [name…], changed: [{option: \"compression.compressText\", value}] (what differs from the preset), warnings}; includeDocument also exports the active document in memory and adds its warnings (knockout groups approximated, effects left out…). A key PDF doesn't take, and an unknown key inside one of the option objects, are refused",
|
||
always,
|
||
pdf_settings
|
||
),
|
||
]
|
||
}
|
||
|
||
/// Every preset name: the built-in ones, then the saved ones ([`crate::Prefs::pdf_presets`]).
|
||
pub fn presets(s: &Session) -> Vec<String> {
|
||
names(&s.prefs.pdf_presets)
|
||
}
|
||
|
||
/// The built-in preset names, then those of `saved`.
|
||
fn names(saved: &[PdfPreset]) -> Vec<String> {
|
||
vectorcraft_pdf::builtin_presets().into_iter().map(|p| p.name).chain(saved.iter().map(|p| p.name.clone())).collect()
|
||
}
|
||
|
||
/// The preset `name` names: a built-in one (any case; `default` is the app default) or one of
|
||
/// `saved`.
|
||
pub fn find_preset(name: &str, saved: &[PdfPreset]) -> Option<PdfPreset> {
|
||
vectorcraft_pdf::builtin_preset(name).or_else(|| saved.iter().find(|p| p.name.eq_ignore_ascii_case(name.trim())).cloned())
|
||
}
|
||
|
||
fn preset(cmd: &str, name: &str, saved: &[PdfPreset]) -> Result<PdfSettings> {
|
||
find_preset(name, saved)
|
||
.map(|p| p.settings)
|
||
.ok_or_else(|| bad(cmd, format!("unknown PDF preset `{name}` (presets: {})", names(saved).join(", "))))
|
||
}
|
||
|
||
/// The settings of the preset `p` names (default: [`DEFAULT_PRESET`]), before `p`'s options.
|
||
pub fn preset_settings(cmd: &str, p: &Value, saved: &[PdfPreset]) -> Result<PdfSettings> {
|
||
preset(cmd, str_param(p, "preset").unwrap_or(DEFAULT_PRESET), saved)
|
||
}
|
||
|
||
/// Merge `over` into `base`: objects key by key (recursively), `null` keeps the base (the
|
||
/// preset's value), anything else replaces.
|
||
pub(crate) 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`.
|
||
pub(crate) fn pdf_error(cmd: &str, e: PdfError) -> EngineError {
|
||
match e {
|
||
PdfError::BadSetting(_) | PdfError::Unsupported(_) => bad(cmd, e.to_string()),
|
||
PdfError::Cancelled => EngineError::Cancelled,
|
||
e => EngineError::Other(e.to_string()),
|
||
}
|
||
}
|
||
|
||
/// 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, 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() {
|
||
return Ok(base);
|
||
}
|
||
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}")))?;
|
||
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)
|
||
}
|
||
|
||
/// [`resolve`], refusing what the writer can't honour ([`PdfSettings::check`]).
|
||
pub fn settings_with(cmd: &str, p: &Value, saved: &[PdfPreset]) -> Result<PdfSettings> {
|
||
let s = resolve(cmd, p, saved)?;
|
||
s.check().map_err(|e| pdf_error(cmd, e))?;
|
||
Ok(s)
|
||
}
|
||
|
||
/// [`settings_with`] the built-in presets only (what the encoders know: see [`expand_preset`]).
|
||
pub fn settings(cmd: &str, p: &Value) -> Result<PdfSettings> {
|
||
settings_with(cmd, p, &[])
|
||
}
|
||
|
||
/// `p` with the saved presets it names (a PDF preset, a flattener preset: see
|
||
/// [`super::eps::with_flattener`]) written out as options, for the encoders, which know only the
|
||
/// built-in presets. Params naming none come back as they are.
|
||
pub fn expand_preset<'a>(s: &Session, cmd: &str, p: &'a Value) -> Result<Cow<'a, Value>> {
|
||
let expanded = match str_param(p, "preset") {
|
||
Some(name) if vectorcraft_pdf::builtin_preset(name).is_none() => {
|
||
let set = settings_with(cmd, p, &s.prefs.pdf_presets)?;
|
||
let mut q = p.clone();
|
||
merge(&mut q, &serde_json::to_value(set).map_err(|e| EngineError::Other(e.to_string()))?);
|
||
if let Some(o) = q.as_object_mut() {
|
||
o.remove("preset");
|
||
}
|
||
Cow::Owned(q)
|
||
}
|
||
_ => Cow::Borrowed(p),
|
||
};
|
||
super::eps::with_flattener(s, cmd, expanded)
|
||
}
|
||
|
||
/// The full export options for `doc`: settings plus the artboards `p` picks (default all), created
|
||
/// at `doc`'s File Info creation date (the time of writing when it has none) and modified at the
|
||
/// time of writing. On the web, which has no clock, the creation date is the modification date too,
|
||
/// and a document without one gets neither.
|
||
pub fn options(cmd: &str, doc: &Document, p: &Value) -> Result<PdfOptions> {
|
||
options_at(cmd, doc, p, vectorcraft_doc::metadata::output_time())
|
||
}
|
||
|
||
/// [`options`] modified at `now` (Unix seconds) instead of the time of writing.
|
||
fn options_at(cmd: &str, doc: &Document, p: &Value, now: Option<i64>) -> Result<PdfOptions> {
|
||
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,
|
||
display_doc_title: bool_or(p, "_displayDocTitle", super::has_own_title(doc)),
|
||
created: doc.metadata.created.or(now),
|
||
modified: now,
|
||
..Default::default()
|
||
})
|
||
}
|
||
|
||
/// Why a PDF of some artboards carries no editing data.
|
||
pub const EDITING_NEEDS_EVERY_ARTBOARD: &str =
|
||
"Preserve editing was left out: it needs a PDF of every artboard, in order (reopened, this one would show the others too)";
|
||
|
||
/// The warning for a PDF/X file that leaves out the document's bleed
|
||
/// ([`vectorcraft_pdf::PdfSettings::leaves_out_bleed`]).
|
||
pub const BLEED_LEFT_OUT: &str = "the document sets a bleed that this PDF/X file leaves out: turn on Use document bleed settings";
|
||
|
||
/// Encode `doc` as PDF with the options in `p` → (bytes, notes). Raster effects are rendered
|
||
/// to images at the document's raster effects resolution. Preserve Editing embeds `doc` itself
|
||
/// when the PDF has every artboard (a PDF of some reopens as just those).
|
||
pub fn encode(cmd: &str, doc: &Document, p: &Value) -> Result<(Vec<u8>, Vec<Note>)> {
|
||
encode_at(cmd, doc, p, None)
|
||
}
|
||
|
||
/// [`encode`] modified at `modified` (Unix seconds) when given, instead of the time of writing.
|
||
pub(super) fn encode_at(cmd: &str, doc: &Document, p: &Value, modified: Option<i64>) -> Result<(Vec<u8>, Vec<Note>)> {
|
||
// Keep the editable object tree independent of preview fallbacks used by the drawn pages.
|
||
let (full, placed) = crate::cmd::place::document::full_documents(doc);
|
||
let mut notes: Vec<Note> = placed.into_iter().map(Note::loss).collect();
|
||
let (bytes, drawn) = encode_carrying_at(cmd, &full, p, modified, || Ok(vectorcraft_format::save(doc, false)), || Ok(None))?;
|
||
notes.extend(drawn);
|
||
notes.extend(overflow_warning(cmd, &full, p)?.map(Note::loss));
|
||
Ok((bytes, notes))
|
||
}
|
||
|
||
/// The warning about the type that the pages of a PDF of `doc` with the options in `p` draw and
|
||
/// whose text doesn't fit (see [`crate::cmd::fonts::export_overflow_warning`]): on the pages
|
||
/// chosen, leaving out the layers that don't print unless the PDF writes them.
|
||
fn overflow_warning(cmd: &str, doc: &Document, p: &Value) -> Result<Option<String>> {
|
||
let opts = options(cmd, doc, p)?;
|
||
let areas = vectorcraft_pdf::page_areas(doc, &opts).map_err(|e| pdf_error(cmd, e))?;
|
||
let set = &opts.settings;
|
||
let printed = if set.include_non_printing || set.create_layers { None } else { without_non_printing(&doc.layers) };
|
||
Ok(crate::cmd::fonts::export_overflow_warning(doc, printed.as_deref().unwrap_or(&doc.layers), Some(&areas)))
|
||
}
|
||
|
||
/// [`encode`] the pages of `doc`, carrying `native()` as the editing data (a `.ai` file: the
|
||
/// native document with its save options, whose pages may be left blank), and `private()`, the
|
||
/// editing copy of the art for other apps with what it couldn't hold, on every page.
|
||
pub(super) fn encode_carrying(
|
||
cmd: &str,
|
||
doc: &Document,
|
||
p: &Value,
|
||
native: impl FnOnce() -> Result<Vec<u8>>,
|
||
private: impl FnOnce() -> Result<Option<(vectorcraft_pdf::PrivateData, Vec<String>)>>,
|
||
) -> Result<(Vec<u8>, Vec<Note>)> {
|
||
encode_carrying_at(cmd, doc, p, None, native, private)
|
||
}
|
||
|
||
/// [`encode_carrying`] modified at `modified` (Unix seconds) when given, instead of the time of
|
||
/// writing.
|
||
fn encode_carrying_at(
|
||
cmd: &str,
|
||
doc: &Document,
|
||
p: &Value,
|
||
modified: Option<i64>,
|
||
native: impl FnOnce() -> Result<Vec<u8>>,
|
||
private: impl FnOnce() -> Result<Option<(vectorcraft_pdf::PrivateData, Vec<String>)>>,
|
||
) -> Result<(Vec<u8>, Vec<Note>)> {
|
||
let mut opts = options_at(cmd, doc, p, modified.or_else(vectorcraft_doc::metadata::output_time))?;
|
||
// The pages' background colors, behind the art (the editing data keeps them as colors).
|
||
let pages = opts.artboards.clone().unwrap_or_else(|| (0..doc.artboards.len()).collect());
|
||
let filled = super::export::with_artboard_fills(doc, p, pages).map_err(|e| bad(cmd, e))?;
|
||
let doc = &*filled;
|
||
// A page the file can't hold fails the export before the thumbnails and the flattening.
|
||
vectorcraft_pdf::check_page_sizes(doc, &opts).map_err(|e| pdf_error(cmd, e))?;
|
||
if opts.settings.thumbnails {
|
||
opts.thumbnails = thumbnails(doc, &opts).map_err(|e| pdf_error(cmd, e))?;
|
||
}
|
||
let mut notes = vec![];
|
||
// A loss, ahead of the flattening note: the PDF/X pages leave out the bleed that Document
|
||
// Setup gives.
|
||
if opts.settings.leaves_out_bleed(doc) {
|
||
notes.push(Note::loss(BLEED_LEFT_OUT));
|
||
}
|
||
if opts.settings.preserve_editing {
|
||
if opts.artboards.as_ref().is_none_or(|v| v.iter().copied().eq(0..doc.artboards.len())) {
|
||
opts.native = Some(native()?);
|
||
if let Some((data, lost)) = private()? {
|
||
opts.private_data = Some(data);
|
||
notes.extend(lost.into_iter().map(Note::loss));
|
||
}
|
||
} else {
|
||
opts.settings.preserve_editing = false;
|
||
notes.push(Note::setting(EDITING_NEEDS_EVERY_ARTBOARD));
|
||
}
|
||
}
|
||
let set = &opts.settings;
|
||
if set.pdf13() {
|
||
vectorcraft_doc::cancel::progress(0, 0, "Flattening transparency");
|
||
}
|
||
let (flat, flattened) = flatten_for(cmd, doc, set, p)?;
|
||
vectorcraft_doc::cancel::check()?;
|
||
if flattened {
|
||
let kind = if set.standard.flattens() { set.standard.label() } else { set.compatibility.label() };
|
||
notes.push(Note::loss(format!("transparency is flattened into opaque art and images ({kind} files have none): see the flattener preset")));
|
||
}
|
||
let r = super::super::rasterfx::export_pdf_with_report(flat.as_ref().unwrap_or(doc), &opts).map_err(|e| pdf_error(cmd, e))?;
|
||
notes.extend(r.warnings.into_iter().map(Note::loss));
|
||
notes.extend(r.setting_notes.into_iter().map(Note::setting));
|
||
notes.extend(crate::cmd::fonts::substitution_warning(doc).map(Note::loss));
|
||
Ok((r.bytes, notes))
|
||
}
|
||
|
||
/// The flattener preset of PDF 1.3 files whose `flattenerPreset` is empty.
|
||
pub const DEFAULT_FLATTENER: &str = "High Resolution";
|
||
|
||
/// `doc` with its transparency flattened for a PDF 1.3 file (asked for, or PDF/X-1a and PDF/X-3,
|
||
/// whose files have none), with `flattenerPreset` (default High Resolution) and `p`'s `flattener`
|
||
/// options over it; images with see-through pixels and colorized images, including those in
|
||
/// pattern tiles, count as transparency. Rasterized areas are clipped to their regions, and
|
||
/// discarded overprints are dropped. Layers the pages leave out (non-printing ones, unless asked
|
||
/// for) are left out of the flattening, hidden. → the document to write (`None`: `doc` as it is)
|
||
/// and whether its transparency was flattened.
|
||
fn flatten_for(cmd: &str, doc: &Document, set: &PdfSettings, p: &Value) -> Result<(Option<Document>, bool)> {
|
||
if !set.pdf13() {
|
||
return Ok((None, false));
|
||
}
|
||
let printed = if set.include_non_printing || set.create_layers { None } else { without_non_printing(&doc.layers) };
|
||
let shown = printed.map(|layers| {
|
||
let mut d = doc.clone();
|
||
d.layers = layers;
|
||
d
|
||
});
|
||
let name = Some(set.flattener_preset.trim()).filter(|n| !n.is_empty()).unwrap_or(DEFAULT_FLATTENER);
|
||
let mut o = FlattenOptions::for_export(Some(name), p.get("flattener"), &[]).map_err(|e| bad(cmd, format!("PDF flattener: {e}")))?;
|
||
o.preserve_overprints &= set.advanced.overprint == Overprint::Preserve;
|
||
o.no_transparency = Some(see_through_images(doc));
|
||
// Without soft masks a rasterized area can't be a rectangle with see-through pixels around
|
||
// its art: it is clipped to its regions (opaque inside them).
|
||
o.clip_complex_regions = true;
|
||
Ok(match flatten_document(shown.as_ref().unwrap_or(doc), &o)? {
|
||
Some(flat) => (Some(flat), true),
|
||
None => (shown, false),
|
||
})
|
||
}
|
||
|
||
/// The pages `opts` export of `doc` drawn on white, [`THUMBNAIL_SIZE`] pixels on their long side:
|
||
/// the thumbnails Embed Page Thumbnails embeds. Layers the pages leave out (non-printing ones,
|
||
/// unless asked for) are left out of them too.
|
||
fn thumbnails(doc: &Document, opts: &PdfOptions) -> std::result::Result<Vec<Thumbnail>, PdfError> {
|
||
let areas = vectorcraft_pdf::page_areas(doc, opts)?;
|
||
let set = &opts.settings;
|
||
let printed = if set.include_non_printing || set.create_layers { None } else { without_non_printing(&doc.layers) };
|
||
let shown = printed.map(|layers| {
|
||
let mut d = doc.clone();
|
||
d.layers = layers;
|
||
d
|
||
});
|
||
let doc = shown.as_ref().unwrap_or(doc);
|
||
let mut r = vectorcraft_render::Renderer::new();
|
||
Ok(areas
|
||
.into_iter()
|
||
.map(|a| {
|
||
let img = r.render_region(doc, a, f64::from(THUMBNAIL_SIZE) / a.width().max(a.height()).max(1.0), true);
|
||
// Drawn on white: every pixel is opaque.
|
||
let rgb = img.pixels.as_chunks::<4>().0.iter().flat_map(|p| [p[0], p[1], p[2]]).collect();
|
||
Thumbnail { width: img.width, height: img.height, rgb }
|
||
})
|
||
.collect())
|
||
}
|
||
|
||
/// `layers` with the non-printing layers among them (at any depth) hidden; `None` when there are
|
||
/// none.
|
||
fn without_non_printing(layers: &[Arc<Node>]) -> Option<Vec<Arc<Node>>> {
|
||
let mut out: Option<Vec<Arc<Node>>> = None;
|
||
for (i, l) in layers.iter().enumerate() {
|
||
let NodeKind::Layer { printable, children, .. } = &l.kind else { continue };
|
||
let new = if !printable {
|
||
l.visible.then(|| {
|
||
let mut n = (**l).clone();
|
||
n.visible = false;
|
||
Arc::new(n)
|
||
})
|
||
} else {
|
||
without_non_printing(children).map(|c| {
|
||
let mut n = (**l).clone();
|
||
if let NodeKind::Layer { children, .. } = &mut n.kind {
|
||
*children = c;
|
||
}
|
||
Arc::new(n)
|
||
})
|
||
};
|
||
if let Some(slot) = new.and_then(|new| Some((out.get_or_insert_with(|| layers.to_vec()).get_mut(i)?, new))) {
|
||
*slot.0 = slot.1;
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
fn export_pdf(s: &mut Session, p: &Value) -> Result<Value> {
|
||
if let Some(path) = str_param(p, "path") {
|
||
super::check_not_lossy_overwrite(s.doc()?, path, p, C)?;
|
||
}
|
||
let expanded = expand_preset(s, C, p)?;
|
||
let p = &*expanded;
|
||
let st = s.doc()?;
|
||
let display_doc_title = super::has_own_title(&st.doc);
|
||
let titled = super::titled_for_export(&st.doc, st.path.as_deref(), "pdf");
|
||
let mut q = p.clone();
|
||
if let Some(o) = q.as_object_mut() {
|
||
o.insert("_displayDocTitle".into(), json!(display_doc_title));
|
||
} else {
|
||
q = json!({ "_displayDocTitle": display_doc_title });
|
||
}
|
||
let (bytes, notes) = encode(C, titled.as_ref().unwrap_or(&st.doc), &q)?;
|
||
write_or_return(str_param(p, "path"), &bytes, json!({ "warnings": note_texts(¬es) }))
|
||
}
|
||
|
||
/// `(path, value)` for every leaf of `v` that differs from `default` (`compression.color.ppi`).
|
||
pub fn changed(prefix: &str, v: &Value, default: &Value, out: &mut Vec<Value>) {
|
||
match (v, default) {
|
||
(Value::Object(o), Value::Object(d)) => {
|
||
for (k, x) in o {
|
||
let path = if prefix.is_empty() { k.clone() } else { format!("{prefix}.{k}") };
|
||
changed(&path, x, d.get(k).unwrap_or(&Value::Null), out);
|
||
}
|
||
}
|
||
_ if v != default => out.push(json!({ "option": prefix, "value": v })),
|
||
_ => {}
|
||
}
|
||
}
|
||
|
||
/// `[{option, value}]` for every setting of `set` that differs from `base`
|
||
/// (`compression.color.ppi`).
|
||
pub fn changes(set: &PdfSettings, base: &PdfSettings) -> Vec<Value> {
|
||
let (Ok(v), Ok(base)) = (serde_json::to_value(set), serde_json::to_value(base)) else { return vec![] };
|
||
let mut diff = vec![];
|
||
changed("", &v, &base, &mut diff);
|
||
diff
|
||
}
|
||
|
||
fn pdf_settings(s: &mut Session, p: &Value) -> Result<Value> {
|
||
const Q: &str = "document.pdfSettings";
|
||
let saved = &s.prefs.pdf_presets;
|
||
let set = settings_with(Q, p, saved)?;
|
||
let diff = changes(&set, &preset_settings(Q, p, saved)?);
|
||
let warnings = match s.active() {
|
||
Some(st) if bool_or(p, "includeDocument", false) => note_texts(&encode(Q, &st.doc, &*expand_preset(s, Q, p)?)?.1),
|
||
_ => set.warnings(),
|
||
};
|
||
let v = serde_json::to_value(&set).map_err(|e| EngineError::Other(e.to_string()))?;
|
||
Ok(json!({ "settings": v, "presets": presets(s), "changed": diff, "warnings": warnings }))
|
||
}
|
||
|
||
/// Is `key` a top-level PDF setting (a [`PdfSettings`] field as `document.exportPdf` names it)?
|
||
pub fn is_setting(key: &str) -> bool {
|
||
static DEFAULTS: std::sync::LazyLock<Value> = std::sync::LazyLock::new(|| serde_json::to_value(PdfSettings::default()).unwrap_or_default());
|
||
DEFAULTS.get(key).is_some()
|
||
}
|