Files
vectorcraft/crates/engine/src/cmd/fileio/encode.rs
T
bflatasticandClaude Opus 5.5 ac77b52ad3 M4.49: PNG-8 and GIF export with a palette quantizer and dithering
document.export {format: "png8" | "gif"} reduces the art to a palette of at most `colors`
(2–256) entries: median cut over a 15-bit histogram refined by k-means (perceptual,
selective or adaptive weighting), or the web-safe, black-and-white or grey palettes; art
with that many colours or fewer keeps them exactly. Dithering is Floyd–Steinberg diffusion,
an 8×8 ordered pattern or noise, with an amount. Pixels under half opacity become one
transparent entry; partly transparent edges blend over the matte. PNG-8 is an indexed PNG
(PLTE, tRNS, 1–8 bits per pixel, Adam7); GIF is a GIF89a (optionally interlaced). Both are in
document.formats, Export As (PNG-8 / GIF Options dialogs) and Export for Screens.
document.formats' writable list and the MCP export tool's format enum gain gif and png8
(their tests list them).

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

366 lines
17 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>,
}
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>,
colors: Option<u16>,
reduction: Option<String>,
dither: Option<String>,
dither_amount: Option<u8>,
transparency: Option<bool>,
matte: Option<Value>,
}
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 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" | "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"));
}
return Ok(enc);
}
_ => return Err(bad(C, format!("no encoder for {} yet", f.label))),
};
Ok(Encoded::one(bytes))
}