Files
vectorcraft/crates/engine/src/cmd/fileio/encode.rs
T
bflatasticandClaude Opus 5.5 bf4614669e M4.38: native and .ai save options: each artboard to its own file, linked files, ICC profiles, PDF content
- document.save / Save As / Save a Copy to a native or .ai file take
  separateArtboards + range (also write <name>-<artboard>.<ext> per artboard
  beside the master file, holding that artboard and the art touching it; the
  result lists every file), includeLinked (linked images keep their files' own
  pixels), embedProfiles (ICC profiles loaded from files that the document is
  tagged with; opening installs them where missing) and pdfCompatible (native:
  also carry a PDF of every artboard; .ai: false writes blank pages around the
  native document). .ai files also take compress (compression.compressText).
- File > Save As to a native or .ai file shows the options dialog (VectorCraft
  Options) after the save panel: Save each artboard to a separate file (All or a
  range), Include Linked Files, Embed ICC Profiles, Create PDF-Compatible File,
  Use Compression, Preview; the web's Save As dialog shows them too. Save, Save a
  Copy and Save as Template reuse the options as last saved.
- Format: optional "profiles" and "pdf" members (load_file, pdf_content),
  SaveOptions.include_linked/profiles/pdf; files without them are unchanged.
- Changed test: tests_aisave::save_as_ai_writes_a_pdf_that_reopens_editable now
  confirms the options dialog Save As .ai shows before writing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 15:24:19 +07:00

384 lines
18 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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::quantize::{Dither, PaletteOptions, Reduction};
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>,
/// What joins the file's stem and the artboard name when there is a file per artboard
/// (default `-`; EPS uses `_`, as the reference app names them).
pub joiner: Option<&'static str>,
}
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 (see `joiner`; a file of no artboard
/// among several, as Save's master file, is the file itself), 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().filter_map(|(b, _)| *b).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();
let mut names = super::artboard_file_names(doc, &boards).into_iter();
files
.iter()
.map(|(b, bytes)| {
let name = match b {
None => path.to_string(),
Some(_) => sibling(&format!("{stem}{}{}{ext}", self.joiner.unwrap_or("-"), names.next().unwrap_or_default())),
};
(name, 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>,
colors: Option<u16>,
reduction: Option<String>,
dither: Option<String>,
dither_amount: Option<u8>,
transparency: Option<bool>,
matte: Option<Value>,
lossless: Option<bool>,
}
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),
},
palette: PaletteOptions {
colors: self.colors.unwrap_or(256).clamp(2, 256),
reduction: parse(
self.reduction.as_deref(),
Reduction::from_id,
"reduction",
"perceptual, selective, adaptive, web, blackWhite or gray",
)?,
dither: parse(self.dither.as_deref(), Dither::from_id, "dither", "none, diffusion, pattern or noise")?,
dither_amount: self.dither_amount.unwrap_or(100).min(100),
transparency: self.transparency.unwrap_or(true),
matte: match &self.matte {
Some(v) => background(v).map_err(|e| bad(C, format!("matte: {e}")))?,
None => Some([255; 3]),
},
},
})
}
}
/// 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, DXF 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, DXF) 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))
}
pub(super) 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 || matches!(f.id, "pdf" | "dxf") { 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" => super::native::encode(C, f, doc, p)?,
// A native file flagged so opening it starts a new untitled document.
"template" => {
let mut d = doc.clone();
d.template = true;
super::native::encode(C, f, &d, p)?
}
"txt" => super::text::encode(doc, p)?,
"svg" | "svgz" => return super::svg::encode(doc, p, f.id == "svgz").map_err(|e| bad(C, e)),
"dxf" => return super::dxf::encode(doc, p, use_artboards),
// EPS reads its own `useArtboards` (the art's bounds unless asked).
"eps" => return super::eps::encode(doc, p),
"pdf" => {
let (bytes, warnings) = super::pdf::encode(C, doc, p)?;
return Ok(Encoded { warnings, ..Encoded::one(bytes) });
}
"png" | "jpg" | "webp" | "gif" | "png8" => {
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,
"gif" => RasterFormat::Gif,
"png8" => RasterFormat::Png8,
_ => 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 matches!(format, RasterFormat::Png | RasterFormat::Png8) {
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"));
}
if format == RasterFormat::WebP && o.lossless == Some(false) {
enc.warnings.push("lossy WebP isn't available yet: the file is lossless (exact, but larger)".into());
}
return Ok(enc);
}
_ => return Err(bad(C, format!("no encoder for {} yet", f.label))),
};
Ok(Encoded::one(bytes))
}