mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-26 18:56:55 -06:00
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>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
ba4ed2ff93
commit
ac77b52ad3
@@ -10,6 +10,7 @@ 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::*;
|
||||
@@ -144,6 +145,12 @@ struct RasterOptions {
|
||||
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 {
|
||||
@@ -169,6 +176,22 @@ impl RasterOptions {
|
||||
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]),
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -291,7 +314,7 @@ pub fn encode_all(doc: &Document, format: &str, p: &Value) -> Result<Encoded> {
|
||||
let (bytes, warnings) = super::pdf::encode(C, doc, p)?;
|
||||
return Ok(Encoded { warnings, ..Encoded::one(bytes) });
|
||||
}
|
||||
"png" | "jpg" | "webp" => {
|
||||
"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).
|
||||
@@ -301,6 +324,8 @@ pub fn encode_all(doc: &Document, format: &str, p: &Value) -> Result<Encoded> {
|
||||
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.
|
||||
@@ -322,7 +347,7 @@ pub fn encode_all(doc: &Document, format: &str, p: &Value) -> Result<Encoded> {
|
||||
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 {
|
||||
let bytes = if matches!(format, RasterFormat::Png | RasterFormat::Png8) {
|
||||
vectorcraft_render::encode::png::with_text(bytes, &doc.metadata.png_text(&doc.title))
|
||||
} else {
|
||||
bytes
|
||||
|
||||
@@ -101,7 +101,7 @@ fn screen_format(row: &Value) -> Result<ScreenFormat> {
|
||||
}
|
||||
let format = writable(C, Some(str_param(row, "format").unwrap_or("png")), None)?;
|
||||
if matches!(format.id, "vectorcraft" | "template") {
|
||||
return Err(bad(C, "Export for Screens writes png, jpg, webp, svg, svgz or pdf"));
|
||||
return Err(bad(C, "Export for Screens writes png, jpg, webp, gif, png8, svg, svgz or pdf"));
|
||||
}
|
||||
// Vector formats have no pixel size: scale doesn't apply and adds no @Nx suffix (not even one
|
||||
// left over from a raster row switched to SVG or PDF).
|
||||
|
||||
@@ -55,7 +55,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Serialize Document",
|
||||
[],
|
||||
None,
|
||||
"{format?: vectorcraft (default)|template|svg|svgz|pdf|png|jpg|webp, …the format's options (see document.formats; SVG ones also as svg: {…})} → {text, warnings} for svg, else {dataBase64, warnings}; an SVG of several artboards also gives files: [{name, text}], linked images linked: [{name, dataBase64}]",
|
||||
"{format?: vectorcraft (default)|template|svg|svgz|pdf|png|jpg|webp|gif|png8, …the format's options (see document.formats; SVG ones also as svg: {…})} → {text, warnings} for svg, else {dataBase64, warnings}; an SVG of several artboards also gives files: [{name, text}], linked images linked: [{name, dataBase64}]",
|
||||
has_doc,
|
||||
export::serialize
|
||||
),
|
||||
@@ -64,7 +64,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Export Document",
|
||||
[],
|
||||
None,
|
||||
"{path?, format?: svg|svgz|pdf|png|jpg|webp|vectorcraft|template (default: from the path's extension, else png), artboard?: 0, artboards?: [i…], range?: \"1-3, 5\" | \"all\" (1-based; PDF writes one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf: every page) | false (pdf/raster: the bounds of the visible art; SVG has it as an SVG option), raster: ppi?: 72 (pixels per inch, stored in the file; wins over scale), scale?: 1 (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), jpg: quality?: 90 (0–100), colorModel?: rgb|cmyk|gray, method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as <stem>.html / <stem>.map); SVG options flat or as svg: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers} (see document.formats), …the PDF options of document.exportPdf} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. Never changes the document's path",
|
||||
"{path?, format?: svg|svgz|pdf|png|jpg|webp|gif|png8|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png), artboard?: 0, artboards?: [i…], range?: \"1-3, 5\" | \"all\" (1-based; PDF writes one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf: every page) | false (pdf/raster: the bounds of the visible art; SVG has it as an SVG option), raster: ppi?: 72 (pixels per inch, stored in the file; wins over scale), scale?: 1 (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), jpg: quality?: 90 (0–100), colorModel?: rgb|cmyk|gray, method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as <stem>.html / <stem>.map), gif/png8: colors?: 256 (2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?; SVG options flat or as svg: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers} (see document.formats), …the PDF options of document.exportPdf} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. Never changes the document's path",
|
||||
has_doc,
|
||||
export::export
|
||||
),
|
||||
@@ -82,7 +82,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Export for Screens",
|
||||
["File", "Export"],
|
||||
None,
|
||||
"{folder?, artboards?: [index…] | range?: \"1-3\" (default all), formats?: [{format: png|jpg|webp|svg|svgz|pdf, scale?: 1 (raster only), ppi?: (raster: wins over scale, scale = ppi / 72), suffix?: \"@2x\" (raster default: @{scale}x when scale ≠ 1; vector formats drop @Nx suffixes), …the format's options (antiAlias, background…)}], prefix?, antiAlias?: none|art|type (raster rows without their own)} one file per artboard and format (a PDF holds its artboard alone; artboards with the same name, in any case, get -2, -3…; an unnamed one is Artboard-N) → {files: [path…]}; no folder → {files: [{name, dataBase64}]}",
|
||||
"{folder?, artboards?: [index…] | range?: \"1-3\" (default all), formats?: [{format: png|jpg|webp|gif|png8|svg|svgz|pdf, scale?: 1 (raster only), ppi?: (raster: wins over scale, scale = ppi / 72), suffix?: \"@2x\" (raster default: @{scale}x when scale ≠ 1; vector formats drop @Nx suffixes), …the format's options (antiAlias, background…)}], prefix?, antiAlias?: none|art|type (raster rows without their own)} one file per artboard and format (a PDF holds its artboard alone; artboards with the same name, in any case, get -2, -3…; an unnamed one is Artboard-N) → {files: [path…]}; no folder → {files: [{name, dataBase64}]}",
|
||||
has_doc,
|
||||
export::export_for_screens
|
||||
),
|
||||
@@ -236,6 +236,52 @@ const IMAGE_MAP: FormatOption = FormatOption {
|
||||
description: "none, client (an HTML page with <map>, <stem>.html) or server (an NCSA <stem>.map): the areas of objects with a URL and an Image Map shape (attributes.set)",
|
||||
};
|
||||
|
||||
const COLORS: FormatOption =
|
||||
FormatOption { name: "colors", ty: "integer", default: "256", description: "most palette entries, 2–256 (the transparent one included)" };
|
||||
const REDUCTION: FormatOption = FormatOption {
|
||||
name: "reduction",
|
||||
ty: "string",
|
||||
default: "\"selective\"",
|
||||
description: "the palette: perceptual, selective (also keeps rarer colours, snaps near web colours), adaptive (most used), web (web-safe), blackWhite or gray; art with that many colours or fewer keeps them exactly",
|
||||
};
|
||||
const DITHER: FormatOption = FormatOption {
|
||||
name: "dither",
|
||||
ty: "string",
|
||||
default: "\"diffusion\"",
|
||||
description: "none, diffusion (Floyd–Steinberg), pattern (8×8 ordered) or noise",
|
||||
};
|
||||
const DITHER_AMOUNT: FormatOption = FormatOption { name: "ditherAmount", ty: "integer", default: "100", description: "dither strength 0–100" };
|
||||
const TRANSPARENCY: FormatOption = FormatOption {
|
||||
name: "transparency",
|
||||
ty: "boolean",
|
||||
default: "true",
|
||||
description: "pixels under half opacity become one transparent entry (false: everything is blended over the matte)",
|
||||
};
|
||||
const MATTE: FormatOption = FormatOption {
|
||||
name: "matte",
|
||||
ty: "string",
|
||||
default: "\"white\"",
|
||||
description: "the colour partly transparent edges are blended over (\"#rrggbb\", white, black), or none (they keep their colour)",
|
||||
};
|
||||
/// The options of the palette formats (PNG-8, GIF).
|
||||
const PALETTE_OPTIONS: &[FormatOption] = &[
|
||||
ARTBOARD,
|
||||
ARTBOARDS,
|
||||
RANGE,
|
||||
USE_ARTBOARDS,
|
||||
PPI,
|
||||
SCALE,
|
||||
BACKGROUND,
|
||||
ANTI_ALIAS,
|
||||
INTERLACED,
|
||||
COLORS,
|
||||
REDUCTION,
|
||||
DITHER,
|
||||
DITHER_AMOUNT,
|
||||
TRANSPARENCY,
|
||||
MATTE,
|
||||
];
|
||||
|
||||
/// A format `document.open` reads but nothing writes yet.
|
||||
const fn reader(id: &'static str, label: &'static str, extensions: &'static [&'static str], mime: &'static str, raster: bool) -> Format {
|
||||
Format { id, label, extensions, mime, read: true, write: false, raster, options: &[] }
|
||||
@@ -302,7 +348,7 @@ pub const FORMATS: &[Format] = &[
|
||||
IMAGE_MAP,
|
||||
],
|
||||
},
|
||||
reader("gif", "GIF", &["gif"], "image/gif", true),
|
||||
Format { id: "gif", label: "GIF", extensions: &["gif"], mime: "image/gif", read: true, write: true, raster: true, options: PALETTE_OPTIONS },
|
||||
Format {
|
||||
id: "webp",
|
||||
label: "WebP",
|
||||
@@ -325,6 +371,7 @@ pub const FORMATS: &[Format] = &[
|
||||
raster: false,
|
||||
options: &[],
|
||||
},
|
||||
Format { id: "png8", label: "PNG-8", extensions: &["png"], mime: "image/png", read: false, write: true, raster: true, options: PALETTE_OPTIONS },
|
||||
];
|
||||
|
||||
/// Every extension `document.open` reads (the "All readable files" filter of open dialogs).
|
||||
@@ -573,3 +620,6 @@ mod tests_exportas;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests_jpeg;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests_palette;
|
||||
|
||||
@@ -154,7 +154,7 @@ fn formats_query_lists_readers_writers_and_options() {
|
||||
let mut s = Session::new();
|
||||
let r = s.execute("document.formats", &json!({})).unwrap();
|
||||
let ids = |k: &str| r[k].as_array().unwrap().iter().map(|v| v.as_str().unwrap().to_string()).collect::<Vec<_>>();
|
||||
assert_eq!(ids("writable"), ["vectorcraft", "svg", "svgz", "pdf", "png", "jpg", "webp", "template"]);
|
||||
assert_eq!(ids("writable"), ["vectorcraft", "svg", "svgz", "pdf", "png", "jpg", "gif", "webp", "template", "png8"]);
|
||||
assert!(ids("readable").contains(&"tiff".to_string()) && ids("readable").contains(&"ait".to_string()));
|
||||
let png = r["formats"].as_array().unwrap().iter().find(|f| f["id"] == "png").unwrap();
|
||||
assert_eq!(png["options"]["scale"]["default"], 1);
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
//! PNG-8 and GIF: palette exports through document.export and Export for Screens.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn b64(v: &Value) -> Vec<u8> {
|
||||
vectorcraft_format::base64_decode(v["dataBase64"].as_str().expect("dataBase64")).unwrap()
|
||||
}
|
||||
|
||||
/// A 40×30 pt document with a red rectangle and a blue ellipse, no strokes.
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 40, "height": 30})).unwrap();
|
||||
s.execute("paint.setStroke", &json!({"none": true})).unwrap();
|
||||
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 20, "height": 30})).unwrap();
|
||||
s.execute("shape.ellipse", &json!({"x": 22, "y": 5, "width": 15, "height": 20})).unwrap();
|
||||
// Fills the new ellipse (selected).
|
||||
s.execute("paint.setFill", &json!({"color": "#0000ff"})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
/// PNG `PLTE` entries.
|
||||
fn plte_len(file: &[u8]) -> usize {
|
||||
let i = file.windows(4).position(|w| w == b"PLTE").expect("PLTE");
|
||||
u32::from_be_bytes(file[i - 4..i].try_into().unwrap()) as usize / 3
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn png8_and_gif_export_palettes() {
|
||||
let mut s = session();
|
||||
let png8 = b64(&s.execute("document.export", &json!({"format": "png8", "colors": 16})).unwrap());
|
||||
assert_eq!(png8[25], 3, "colour type 3");
|
||||
assert!(plte_len(&png8) <= 16);
|
||||
let img = image::load_from_memory(&png8).unwrap().to_rgba8();
|
||||
assert_eq!(img.dimensions(), (40, 30));
|
||||
assert_eq!(img.get_pixel(5, 15).0, [255, 0, 0, 255]);
|
||||
assert_eq!(img.get_pixel(39, 0)[3], 0, "transparent where there is no art");
|
||||
|
||||
let gif = b64(&s.execute("document.export", &json!({"format": "gif", "background": "white", "dither": "none"})).unwrap());
|
||||
assert_eq!(&gif[..6], b"GIF89a");
|
||||
let img = image::load_from_memory_with_format(&gif, image::ImageFormat::Gif).unwrap().to_rgba8();
|
||||
assert_eq!(img.get_pixel(5, 15).0, [255, 0, 0, 255]);
|
||||
assert_eq!(img.get_pixel(39, 0).0, [255, 255, 255, 255], "the white background");
|
||||
|
||||
// Black & White: two entries whatever the art.
|
||||
let bw = b64(&s.execute("document.export", &json!({"format": "png8", "reduction": "blackWhite", "transparency": false})).unwrap());
|
||||
assert!(plte_len(&bw) <= 2);
|
||||
for bad in [json!({"reduction": "octree"}), json!({"dither": "lots"}), json!({"matte": "plaid"})] {
|
||||
assert!(s.execute("document.export", &merge(json!({"format": "gif"}), bad.clone())).is_err(), "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn palette_formats_reach_export_as_and_screens() {
|
||||
let mut s = session();
|
||||
// A .png path stays PNG; png8 is asked for by format.
|
||||
let r = s.execute("document.formats", &json!({})).unwrap();
|
||||
let f = |id: &str| r["formats"].as_array().unwrap().iter().find(|f| f["id"] == id).unwrap().clone();
|
||||
assert_eq!((f("png8")["extensions"][0].as_str(), f("png8")["read"].as_bool()), (Some("png"), Some(false)));
|
||||
assert_eq!(f("gif")["write"], true);
|
||||
for k in ["colors", "reduction", "dither", "ditherAmount", "transparency", "matte", "interlaced"] {
|
||||
assert!(f("gif")["options"].get(k).is_some() && f("png8")["options"].get(k).is_some(), "{k}");
|
||||
}
|
||||
assert_eq!(format_for_name("a.png").unwrap().id, "png");
|
||||
let r = s.execute("document.exportForScreens", &json!({"formats": [{"format": "gif", "scale": 2}, {"format": "png8"}]})).unwrap();
|
||||
let names: Vec<&str> = r["files"].as_array().unwrap().iter().map(|f| f["name"].as_str().unwrap()).collect();
|
||||
assert_eq!(names, ["Artboard-1@2x.gif", "Artboard-1.png"]);
|
||||
let gif = vectorcraft_format::base64_decode(r["files"][0]["dataBase64"].as_str().unwrap()).unwrap();
|
||||
assert_eq!(image::load_from_memory(&gif).unwrap().width(), 80);
|
||||
}
|
||||
Reference in New Issue
Block a user