document.export {format: "jpg"} takes colorModel (rgb|cmyk|gray), quality 0–100, method
(baseline|optimized|progressive), scans (3–5), embedIcc and imageMap (none|client|server).
JPEGs are written with jpeg-encoder. CMYK files hold ink amounts drawn as the renderer's ink
planes, so CMYK colours keep their inks (black type stays black ink alone). Profiles are made
in code from the CMS (cms::icc_bytes): the RGB spaces from their primaries, the CMYK spaces as
Lab lookup tables sampled from their model, a grey with sRGB's tone curve, and loaded
profiles as loaded. Image maps write <stem>.html or <stem>.map beside the image for objects
with a URL and an Image Map shape. The JPEG Options dialog gains Color Model, Quality 0–10,
Method and Scans, Image Map and Embed ICC Profile; CMYK documents default to CMYK.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
341 lines
16 KiB
Rust
341 lines
16 KiB
Rust
//! One encoder per writable format. Each format's options are parsed, typed, from the export params
|
||
//! (unknown keys are ignored, so one params object can carry the options of several formats).
|
||
|
||
use std::borrow::Cow;
|
||
|
||
use serde::Deserialize;
|
||
use serde::de::DeserializeOwned;
|
||
use serde_json::Value;
|
||
use vectorcraft_doc::{Artboard, Document};
|
||
use vectorcraft_geom::Rect;
|
||
use vectorcraft_render::AntiAlias;
|
||
use vectorcraft_render::encode::jpeg::{self, JpegOptions};
|
||
use vectorcraft_render::encode::{RasterExportOptions, RasterFormat};
|
||
|
||
use super::super::*;
|
||
use super::Format;
|
||
use super::imagemap::{self, MapKind};
|
||
|
||
const C: &str = "document.export";
|
||
|
||
/// The params that pick artboards (the fields of [`ArtboardPick`]).
|
||
pub const ARTBOARD_PARAMS: [&str; 3] = ["artboard", "artboards", "range"];
|
||
|
||
/// Which artboards an export covers: `range` (`"1-3, 5"`, 1-based, or `"all"`) wins over
|
||
/// `artboards` (0-based), which wins over `artboard`.
|
||
#[derive(Debug, Default, Deserialize)]
|
||
#[serde(default)]
|
||
pub struct ArtboardPick {
|
||
pub artboard: Option<usize>,
|
||
pub artboards: Option<Vec<usize>>,
|
||
pub range: Option<String>,
|
||
}
|
||
|
||
impl ArtboardPick {
|
||
/// The named artboards (`None`: none named), checked against the document's `count`.
|
||
pub fn resolve(&self, count: usize) -> std::result::Result<Option<Vec<usize>>, String> {
|
||
let v = match (&self.range, &self.artboards, self.artboard) {
|
||
(Some(r), _, _) if r.trim().eq_ignore_ascii_case("all") => (0..count).collect(),
|
||
(Some(r), _, _) => parse_range(r, count)?,
|
||
(None, Some(a), _) => a.clone(),
|
||
(None, None, Some(a)) => vec![a],
|
||
(None, None, None) => return Ok(None),
|
||
};
|
||
if v.is_empty() {
|
||
return Err("no artboards named".into());
|
||
}
|
||
match v.iter().find(|i| **i >= count) {
|
||
Some(i) => Err(format!("no artboard {i} (0-based; the document has {count})")),
|
||
None => Ok(Some(v)),
|
||
}
|
||
}
|
||
|
||
/// The one artboard a single-image format writes (default: the first).
|
||
pub fn one(&self, count: usize) -> std::result::Result<usize, String> {
|
||
match self.resolve(count)?.as_deref() {
|
||
None if count > 0 => Ok(0),
|
||
None => Err("the document has no artboard".into()),
|
||
Some([i]) => Ok(*i),
|
||
Some(_) => Err("this format holds one artboard: export several as PDF or with document.exportForScreens".into()),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// What an export writes: its files (one, or one per artboard for an SVG of several artboards)
|
||
/// and the image files an SVG links to.
|
||
#[derive(Default)]
|
||
pub struct Encoded {
|
||
/// `(artboard, bytes)`; the artboard names the file when there are several.
|
||
pub files: Vec<(Option<usize>, Vec<u8>)>,
|
||
/// Images to write next to the file(s) (SVG `images: "link"`).
|
||
pub linked: Vec<vectorcraft_svg::LinkedImage>,
|
||
/// Image maps, each written beside its image under the image's name (raster `imageMap`).
|
||
pub maps: Vec<imagemap::Map>,
|
||
/// The encoder's warnings (features approximated or left out, options not applied yet).
|
||
pub warnings: Vec<String>,
|
||
}
|
||
|
||
impl Encoded {
|
||
pub(super) fn one(bytes: Vec<u8>) -> Self {
|
||
Self { files: vec![(None, bytes)], ..Self::default() }
|
||
}
|
||
|
||
/// Every file to write for the destination `path` (a path or a file name): the file itself
|
||
/// when there is one, else `{stem}-{artboard name}.{ext}` beside it, then the linked images
|
||
/// beside it under their own names, then the image maps as `{image stem}.html` or `.map`.
|
||
pub fn named<'a>(&'a self, doc: &Document, path: &str) -> Vec<(String, Cow<'a, [u8]>)> {
|
||
// Siblings keep the path's own separators (this runs on every platform and on the web).
|
||
let dir = &path[..path.rfind(['/', '\\']).map_or(0, |i| i + 1)];
|
||
let sibling = |name: &str| format!("{dir}{name}");
|
||
let mut out: Vec<(String, Cow<[u8]>)> = match self.files.as_slice() {
|
||
[(_, bytes)] => vec![(path.to_string(), Cow::Borrowed(bytes.as_slice()))],
|
||
files => {
|
||
let boards: Vec<usize> = files.iter().map(|(b, _)| b.unwrap_or(0)).collect();
|
||
let file = std::path::Path::new(&path[dir.len()..]);
|
||
let stem = file.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default();
|
||
let ext = file.extension().map(|e| format!(".{}", e.to_string_lossy())).unwrap_or_default();
|
||
super::artboard_file_names(doc, &boards)
|
||
.into_iter()
|
||
.zip(files)
|
||
.map(|(board, (_, bytes))| (sibling(&format!("{stem}-{board}{ext}")), Cow::Borrowed(bytes.as_slice())))
|
||
.collect()
|
||
}
|
||
};
|
||
let maps: Vec<(String, Cow<[u8]>)> = self
|
||
.maps
|
||
.iter()
|
||
.filter_map(|m| {
|
||
let image = std::path::Path::new(&out.get(m.file)?.0);
|
||
let name = image.file_name()?.to_string_lossy();
|
||
Some((image.with_extension(m.kind.ext()).to_string_lossy().into_owned(), Cow::Owned(m.text(&name).into_bytes())))
|
||
})
|
||
.collect();
|
||
out.extend(self.linked.iter().map(|l| (sibling(&l.name), Cow::Borrowed(l.bytes.as_slice()))));
|
||
out.extend(maps);
|
||
out
|
||
}
|
||
|
||
/// The one file and the warnings, when the export wrote nothing else.
|
||
fn single(self, f: &Format) -> Result<(Vec<u8>, Vec<String>)> {
|
||
if !self.linked.is_empty() || !self.maps.is_empty() {
|
||
return Err(bad(C, format!("{} with linked images or an image map writes several files: use document.export", f.label)));
|
||
}
|
||
match <[_; 1]>::try_from(self.files) {
|
||
Ok([(_, bytes)]) => Ok((bytes, self.warnings)),
|
||
Err(_) => Err(bad(C, format!("{} writes one file per artboard here: use document.export, or name one artboard", f.label))),
|
||
}
|
||
}
|
||
}
|
||
|
||
#[derive(Default, Deserialize)]
|
||
#[serde(default, rename_all = "camelCase")]
|
||
struct RasterOptions {
|
||
#[serde(flatten)]
|
||
boards: ArtboardPick,
|
||
scale: Option<f64>,
|
||
/// Pixels per inch (wins over `scale`).
|
||
ppi: Option<f64>,
|
||
quality: Option<u8>,
|
||
background: Option<Value>,
|
||
anti_alias: Option<String>,
|
||
interlaced: Option<bool>,
|
||
color_model: Option<String>,
|
||
method: Option<String>,
|
||
scans: Option<u8>,
|
||
embed_icc: Option<bool>,
|
||
image_map: Option<String>,
|
||
}
|
||
|
||
impl RasterOptions {
|
||
/// The render and encoder settings: `ppi`, else 72 × `scale`, within 0.01–64 pixels per point;
|
||
/// `background`, else `page` (the document's background).
|
||
fn settings(&self, page: Option<[u8; 3]>) -> Result<RasterExportOptions> {
|
||
if let Some(ppi) = self.ppi.filter(|p| !(p.is_finite() && *p > 0.0)) {
|
||
return Err(bad(C, format!("ppi must be a positive number, not {ppi}")));
|
||
}
|
||
let ppi = self.ppi.unwrap_or(72.0 * self.scale.unwrap_or(1.0));
|
||
Ok(RasterExportOptions {
|
||
ppi: (ppi / 72.0).clamp(0.01, 64.0) * 72.0,
|
||
background: match &self.background {
|
||
Some(v) => background(v).map_err(|e| bad(C, e))?,
|
||
None => page,
|
||
},
|
||
anti_alias: self.anti_alias.as_deref().map(anti_alias).transpose().map_err(|e| bad(C, e))?.unwrap_or_default(),
|
||
interlaced: self.interlaced.unwrap_or(false),
|
||
quality: self.quality.unwrap_or(90).min(100),
|
||
jpeg: JpegOptions {
|
||
color_model: parse(self.color_model.as_deref(), jpeg::ColorModel::from_id, "colorModel", "rgb, cmyk or gray")?,
|
||
method: parse(self.method.as_deref(), jpeg::Method::from_id, "method", "baseline, optimized or progressive")?,
|
||
scans: self.scans.unwrap_or(3).clamp(*jpeg::SCANS.start(), *jpeg::SCANS.end()),
|
||
embed_icc: self.embed_icc.unwrap_or(true),
|
||
},
|
||
})
|
||
}
|
||
}
|
||
|
||
/// An enum option by id (its default when absent).
|
||
fn parse<T: Default>(v: Option<&str>, from_id: fn(&str) -> Option<T>, name: &str, ids: &str) -> Result<T> {
|
||
v.map(|s| from_id(s).ok_or_else(|| bad(C, format!("{name} `{s}`: {ids}")))).transpose().map(Option::unwrap_or_default)
|
||
}
|
||
|
||
/// A raster background: `transparent` (also `none` or null), `white`, `black` or a colour
|
||
/// (`"#rrggbb"`, `[r, g, b]` 0–1, `{c, m, y, k}`, `{gray}`) → opaque RGB, `None` = transparent.
|
||
pub(crate) fn background(v: &Value) -> std::result::Result<Option<[u8; 3]>, String> {
|
||
match v {
|
||
Value::Null => Ok(None),
|
||
Value::String(s) if s.eq_ignore_ascii_case("transparent") || s.eq_ignore_ascii_case("none") => Ok(None),
|
||
Value::String(s) if s.eq_ignore_ascii_case("white") => Ok(Some([255; 3])),
|
||
Value::String(s) if s.eq_ignore_ascii_case("black") => Ok(Some([0; 3])),
|
||
other => color_value(other)
|
||
.map(|c| {
|
||
let [r, g, b, _] = c.to_rgba8(1.0);
|
||
Some([r, g, b])
|
||
})
|
||
.ok_or_else(|| format!("background {other}: transparent, white, black or a colour such as \"#ff8800\"")),
|
||
}
|
||
}
|
||
|
||
/// An anti-aliasing mode by id: `none`, `art` or `type`.
|
||
pub(crate) fn anti_alias(id: &str) -> std::result::Result<AntiAlias, String> {
|
||
AntiAlias::from_id(id).ok_or_else(|| format!("antiAlias `{id}`: none, art or type"))
|
||
}
|
||
|
||
/// The `useArtboards` param of a PDF or raster export (SVG has its own): `true` writes every
|
||
/// chosen artboard (default all), `false` the bounds of the visible art instead, absent one
|
||
/// artboard (raster) or the chosen pages (PDF).
|
||
fn use_artboards(p: &Value) -> Result<Option<bool>> {
|
||
match p.get("useArtboards") {
|
||
None | Some(Value::Null) => Ok(None),
|
||
Some(Value::Bool(b)) => Ok(Some(*b)),
|
||
Some(v) => Err(bad(C, format!("useArtboards must be true or false, not {v}"))),
|
||
}
|
||
}
|
||
|
||
/// A copy of `doc` with one artboard, `rect` (exports of the art's or the selection's bounds).
|
||
pub(super) fn single_artboard(doc: &Document, rect: Rect, name: &str) -> Document {
|
||
with_single_artboard(doc.clone(), rect, name)
|
||
}
|
||
|
||
/// `d` with one artboard, `rect`.
|
||
pub(crate) fn with_single_artboard(mut d: Document, rect: Rect, name: &str) -> Document {
|
||
d.artboards = vec![Artboard { id: 1, name: name.into(), rect, show_center_mark: false, show_cross_hairs: false }];
|
||
d
|
||
}
|
||
|
||
fn boards<T>(r: std::result::Result<T, String>) -> Result<T> {
|
||
r.map_err(|e| bad(C, e))
|
||
}
|
||
|
||
fn options<T: DeserializeOwned + Default>(f: &Format, p: &Value) -> Result<T> {
|
||
if !p.is_object() {
|
||
return Ok(T::default());
|
||
}
|
||
T::deserialize(p).map_err(|e| bad(C, format!("{} options: {e}", f.label)))
|
||
}
|
||
|
||
/// Most pixels a side of a WebP image can have (its sizes are stored in 14 bits).
|
||
const WEBP_SIDE: f64 = 16383.0;
|
||
|
||
/// Refuse a raster export its format can't store (instead of writing an empty file). The
|
||
/// renderer's own size limits are [`vectorcraft_render::raster_size`]'s.
|
||
fn check_format_size(f: &Format, w: f64, h: f64) -> Result<()> {
|
||
if f.id == "webp" && (w.round() > WEBP_SIDE || h.round() > WEBP_SIDE) {
|
||
return Err(bad(
|
||
C,
|
||
format!("{} × {} pixels is too large for {} (at most {WEBP_SIDE} pixels a side): lower the scale", w.round(), h.round(), f.label),
|
||
));
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// Encode `doc` as `format` (an id or extension from [`super::FORMATS`]) with that format's
|
||
/// options from `p` (see `document.formats`) into one file. Raster formats leave template layers
|
||
/// out, and no format writes the opacity-mask editing layer. Exports that write several files (an
|
||
/// SVG per artboard, linked images) go through [`encode_all`].
|
||
pub fn encode(doc: &Document, format: &str, p: &Value) -> Result<Vec<u8>> {
|
||
encode_with_warnings(doc, format, p).map(|(bytes, _)| bytes)
|
||
}
|
||
|
||
/// Like [`encode`], also returning the encoder's warnings (PDF and SVG: options not applied yet,
|
||
/// features approximated or left out; the other formats have none).
|
||
pub fn encode_with_warnings(doc: &Document, format: &str, p: &Value) -> Result<(Vec<u8>, Vec<String>)> {
|
||
let f = super::writable(C, Some(format), None)?;
|
||
encode_all(doc, f.id, p)?.single(f)
|
||
}
|
||
|
||
/// [`encode`], with every file the export writes.
|
||
pub fn encode_all(doc: &Document, format: &str, p: &Value) -> Result<Encoded> {
|
||
let f = super::writable(C, Some(format), None)?;
|
||
let doc = &*doc.without_edit_modes();
|
||
let n = doc.artboards.len();
|
||
// SVG reads its own `useArtboards` (an SVG option).
|
||
let use_artboards = if f.raster || f.id == "pdf" { use_artboards(p)? } else { None };
|
||
if use_artboards == Some(false) {
|
||
let bounds = vectorcraft_render::encode::art_bounds(doc).ok_or_else(|| bad(C, "nothing to export: the document has no visible art"))?;
|
||
let mut q = super::export::without_artboards(p);
|
||
if let Some(o) = q.as_object_mut() {
|
||
o.remove("useArtboards");
|
||
}
|
||
return encode_all(&single_artboard(doc, bounds, "Art"), f.id, &q);
|
||
}
|
||
let bytes = match f.id {
|
||
"vectorcraft" => vectorcraft_format::save_file(doc),
|
||
// A native file flagged so opening it starts a new untitled document.
|
||
"template" => {
|
||
let mut d = doc.clone();
|
||
d.template = true;
|
||
vectorcraft_format::save_file(&d)
|
||
}
|
||
"svg" | "svgz" => return super::svg::encode(doc, p, f.id == "svgz").map_err(|e| bad(C, e)),
|
||
"pdf" => {
|
||
let (bytes, warnings) = super::pdf::encode(C, doc, p)?;
|
||
return Ok(Encoded { warnings, ..Encoded::one(bytes) });
|
||
}
|
||
"png" | "jpg" | "webp" => {
|
||
let o: RasterOptions = options(f, p)?;
|
||
// New Document → Background Contents: White makes the export opaque, unless `background`
|
||
// says otherwise (JPEG has no alpha: white either way).
|
||
let page = (doc.setup.background == vectorcraft_doc::Background::White).then_some([255; 3]);
|
||
let settings = o.settings(page)?;
|
||
let scale = settings.scale();
|
||
let format = match f.id {
|
||
"png" => RasterFormat::Png,
|
||
"jpg" => RasterFormat::Jpeg,
|
||
_ => RasterFormat::WebP,
|
||
};
|
||
// Use Artboards: one file per chosen artboard (default all); else the one chosen.
|
||
let chosen = match use_artboards {
|
||
Some(true) => boards(o.boards.resolve(n))?.unwrap_or_else(|| (0..n).collect()),
|
||
_ => vec![boards(o.boards.one(n))?],
|
||
};
|
||
// JPEG Options → Image Map.
|
||
let map = match (format, o.image_map.as_deref()) {
|
||
(RasterFormat::Jpeg, Some(m)) => MapKind::from_id(m).ok_or_else(|| bad(C, format!("imageMap `{m}`: none, client or server")))?,
|
||
_ => MapKind::None,
|
||
};
|
||
let mut enc = Encoded::default();
|
||
let mut renderer = vectorcraft_render::Renderer::new();
|
||
for b in chosen {
|
||
let region = doc.artboards.get(b).ok_or_else(|| bad(C, format!("no artboard {}", b + 1)))?.rect;
|
||
check_format_size(f, region.width() * scale, region.height() * scale)?;
|
||
vectorcraft_render::raster_size(region, scale).map_err(|e| bad(C, e))?;
|
||
enc.maps.extend(imagemap::build(doc, region, scale, map, enc.files.len()));
|
||
let bytes = renderer.export_region(doc, region, format, &settings).map_err(EngineError::Other)?;
|
||
// File Info as PNG text chunks.
|
||
let bytes = if format == RasterFormat::Png {
|
||
vectorcraft_render::encode::png::with_text(bytes, &doc.metadata.png_text(&doc.title))
|
||
} else {
|
||
bytes
|
||
};
|
||
enc.files.push((Some(b), bytes));
|
||
}
|
||
if enc.files.is_empty() {
|
||
return Err(bad(C, "the document has no artboard"));
|
||
}
|
||
return Ok(enc);
|
||
}
|
||
_ => return Err(bad(C, format!("no encoder for {} yet", f.label))),
|
||
};
|
||
Ok(Encoded::one(bytes))
|
||
}
|