mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-29 12:17:14 -06:00
450 lines
25 KiB
Rust
450 lines
25 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, 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(super) 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",
|
||
},
|
||
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: "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)",
|
||
},
|
||
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: "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; images keep no see-through pixels)|1.4|1.5|1.6|1.7 (default)|2.0 (choosing a standard sets its version: 1.7 for pdfA2b, 1.6 for pdfX4, 1.3 for pdfX1a and pdfX3, which write 1.4 as 1.3 too; pdfA2b refuses 1.3), preserveEditing? (on in VectorCraft Default: the native document as an embedded file, which document.open restores; choosing a standard turns it off, a standard refuses it), thumbnails? (each page drawn as a /Thumb image, 106 px on its long side), fastWebView? (a linearised file: the first page shows while the rest downloads; it stays linearised when encrypted), viewAfterSaving? (the app opens the written file), createLayers? (each layer and sublayer but template layers as a PDF layer, an optional content group listed under its layer's: hidden layers off, non-printing ones /PrintState /OFF, locked ones locked; needs 1.5 or later; they reopen as layers; choosing pdfX1a or pdfX3 turns it off; they refuse it), includeNonPrinting? (keep layers whose Print option is off; left out by default unless createLayers), flattenerPreset?: \"\" (High Resolution) | high | medium | low | a saved preset (flattener.presets.list): what PDF 1.3 files flatten transparency with, flattener?: {object.flattenTransparency options over it}, compression?: {color?, gray?: {downsample: none|average|subsample|bicubic, ppi: 300, abovePpi: 450, compression: none|zip|jpeg|jpeg2000|auto, quality: minimum|low|medium|high|maximum}, mono?: {downsample, ppi: 1200, abovePpi: 1800, compression: none|ccittG3|ccittG4|zip|runLength} (black-and-white images), compressText?: true} (images above abovePpi are resampled to ppi; auto keeps JPEGs JPEG, the others lossless; JPEG needs opaque images; none, jpeg2000, CCITT and runLength are written as ZIP with a warning), marks?: {trim, registration, colorBars, pageInfo, kind: roman|japanese, weight: 0.25 (trim marks and targets), offset: 6 (pt from the artboard, at least the bleed)} (printer's marks in [Registration], every plate: trim marks, registration targets mid-side, CMYK/spot/black-tint colour bars on top, page information (title, artboard, date UTC) below), bleed?: {useDocument (the document's bleed, document.setup), top, bottom, left, right} (pt; art in the bleed is kept). Each page: TrimBox = artboard, BleedBox = artboard + bleed, MediaBox = that + the marks' room (art clipped to the BleedBox), output?: {conversion: none (default)|destination (every colour into the destination profile's model, CMYK or RGB, also colours of that model in another profile)|preserveNumbers (only colours of the other model and Lab; those in the destination's model keep their numbers) (grey stays grey; images are converted too, printer's marks are not), destination: profile name (edit.colorSettings lists them; default: the document's profile for its colour mode), profiles: none (device colours)|all|destination|taggedSource (documents assigned a profile: edit.assignProfile) (ICC-based colours: CMYK with the destination or document CMYK profile, RGB as sRGB, grey with the sRGB tone curve; PDF/A, pdfX3 and pdfX4 always, whatever profiles says), outputIntent: profile name (embedded as the catalog's /GTS_PDFX output intent; a name that isn't a profile is only named, which pdfX1a and pdfX3 allow only with a registry and pdfX4 refuses; pdfX1a needs a CMYK one), outputCondition, outputConditionId (default: outputIntent), registry, trapped (/Trapped /True; /False when an output intent is written)} (PDF/A files keep their own output intent), advanced?: {fontSubsetPercent: 100 (below 100 is accepted with a warning: fonts are always embedded as subsets), outlineText: false (type as real, selectable and searchable text in embedded subset fonts with a ToUnicode map; fonts whose licence forbids embedding, or allows only whole fonts, stay outlines with a warning; true: every glyph as an outline), overprint: preserve (overprinting fills and strokes set /OP /op true, /OPM 1; white ones knock out with document.setup discardWhiteOverprint)|discard (they knock out)}, security?: {openPassword, permissionsPassword, printing: none|low|high, changes: none|pages|forms|comments|any, copy, screenReader, plaintextMetadata}, acknowledgeLoss?: bool (write over the file the document was read from 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",
|
||
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…)",
|
||
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> {
|
||
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))?;
|
||
let now = vectorcraft_doc::metadata::output_time();
|
||
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)";
|
||
|
||
/// Encode `doc` as PDF with the options in `p` → (bytes, warnings). 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<String>)> {
|
||
// Keep the editable object tree independent of preview fallbacks used by the drawn pages.
|
||
let (full, mut warnings) = crate::cmd::place::document::full_documents(doc);
|
||
let (bytes, drawn_warnings) = encode_carrying(cmd, &full, p, || Ok(vectorcraft_format::save(doc, false)))?;
|
||
warnings.extend(drawn_warnings);
|
||
warnings.extend(overflow_warning(cmd, &full, p)?);
|
||
Ok((bytes, warnings))
|
||
}
|
||
|
||
/// 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).
|
||
pub(super) fn encode_carrying(cmd: &str, doc: &Document, p: &Value, native: impl FnOnce() -> Result<Vec<u8>>) -> Result<(Vec<u8>, Vec<String>)> {
|
||
let mut opts = options(cmd, doc, p)?;
|
||
if opts.settings.thumbnails {
|
||
opts.thumbnails = thumbnails(doc, &opts).map_err(|e| pdf_error(cmd, e))?;
|
||
}
|
||
let mut warnings = vec![];
|
||
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()?);
|
||
} else {
|
||
opts.settings.preserve_editing = false;
|
||
warnings.push(EDITING_NEEDS_EVERY_ARTBOARD.to_string());
|
||
}
|
||
}
|
||
let set = &opts.settings;
|
||
if set.pdf13() {
|
||
vectorcraft_doc::cancel::progress(0, 0, "Flattening transparency");
|
||
}
|
||
let flat = flatten_for(cmd, doc, set, p)?;
|
||
vectorcraft_doc::cancel::check()?;
|
||
if flat.is_some() {
|
||
let kind = if set.standard.flattens() { set.standard.label() } else { set.compatibility.label() };
|
||
warnings.push(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))?;
|
||
warnings.extend(r.warnings);
|
||
warnings.extend(crate::cmd::fonts::substitution_warning(doc));
|
||
Ok((r.bytes, warnings))
|
||
}
|
||
|
||
/// 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 count as transparency, rasterized areas are
|
||
/// clipped to their regions, and discarded overprints are dropped. `None` when nothing changes (or
|
||
/// the file keeps transparency).
|
||
fn flatten_for(cmd: &str, doc: &Document, set: &PdfSettings, p: &Value) -> Result<Option<Document>> {
|
||
if !set.pdf13() {
|
||
return Ok(None);
|
||
}
|
||
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;
|
||
flatten_document(doc, &o)
|
||
}
|
||
|
||
/// 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, warnings) = encode(C, titled.as_ref().unwrap_or(&st.doc), &q)?;
|
||
write_or_return(str_param(p, "path"), &bytes, json!({ "warnings": warnings }))
|
||
}
|
||
|
||
/// `(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) => 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()
|
||
}
|