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 <[email protected]>
This commit is contained in:
bflatasticandClaude Opus 5.5 committed 2026-10-05 11:26:15 +07:00
1 parent ba4ed2ff93
commit ac77b52ad3
20 files changed
+1063 -46

No files matched your search

Generated
+1
View File
@@ -4244,6 +4244,7 @@ name = "vectorcraft-render"
version = "0.1.1"
dependencies = [
"flate2",
"gif",
"image",
"jpeg-encoder",
"log",
+1
View File
@@ -49,6 +49,7 @@ harfrust = "0.13"
linesweeper = "0.4"
flate2 = { version = "1", default-features = false, features = ["rust_backend"] }
jpeg-encoder = { version = "0.6", default-features = false, features = ["std"] }
gif = { version = "0.14", default-features = false, features = ["std", "raii_no_panic"] }
# ui
egui = "0.36"
+27 -2
View File
@@ -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
+1 -1
View File
@@ -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).
+54 -4
View File
@@ -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;
+1 -1
View File
@@ -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, ["[email protected]", "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);
}
+1 -1
View File
@@ -55,7 +55,7 @@ fn export_tool_lists_engine_formats_and_options() {
let tools = tool_definitions();
let export = tools.iter().find(|t| t["name"] == "export").unwrap();
let props = &export["inputSchema"]["properties"];
assert_eq!(props["format"]["enum"], json!(["vectorcraft", "svg", "svgz", "pdf", "png", "jpg", "webp", "template"]));
assert_eq!(props["format"]["enum"], json!(["vectorcraft", "svg", "svgz", "pdf", "png", "jpg", "gif", "webp", "template", "png8"]));
for k in ["artboard", "range", "options"] {
assert!(props.get(k).is_some(), "export takes {k}");
}
+1 -1
View File
@@ -264,7 +264,7 @@ pub fn tool_definitions() -> Vec<Value> {
tool(
"export",
"Export",
"Export the active document with the engine's document.export (the same bytes in the app and headless): svg (artboard viewBox), pdf (one page per artboard: all, or `artboard` / `range`), png/jpg/webp (one rendered artboard) or vectorcraft (a native copy; the document keeps its path). `selection: true` exports only the selected objects, cropped to their bounds. Template layers are left out; live effects are kept (geometry baked, SVG filters for shadows/glows/blur). Without `path` the bytes come back as dataBase64.",
"Export the active document with the engine's document.export (the same bytes in the app and headless): svg (artboard viewBox), pdf (one page per artboard: all, or `artboard` / `range`), png/jpg/webp/gif/png8 (one rendered artboard; png8 is an indexed .png) or vectorcraft (a native copy; the document keeps its path). `selection: true` exports only the selected objects, cropped to their bounds. Template layers are left out; live effects are kept (geometry baked, SVG filters for shadows/glows/blur). Without `path` the bytes come back as dataBase64.",
obj(
json!({
"format": {"type": "string", "enum": FORMATS.iter().filter(|f| f.write).map(|f| f.id).collect::<Vec<_>>(), "description": "Default: from the path's extension"},
+1
View File
@@ -21,6 +21,7 @@ vello_common = { version = "0.2", default-features = false }
image = { workspace = true }
flate2 = { workspace = true }
jpeg-encoder = { workspace = true }
gif = { workspace = true }
log = { workspace = true }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
+33
View File
@@ -0,0 +1,33 @@
//! GIF export: a palette image ([`super::quantize`]) as a one-frame GIF89a with its transparent
//! entry, optionally interlaced (rows in four passes, so the image builds up while it loads).
use std::borrow::Cow;
use super::quantize::Indexed;
/// Encode `img` as a GIF file.
pub fn encode(img: &Indexed, interlaced: bool) -> Result<Vec<u8>, String> {
let fail = |e: gif::EncodingError| format!("GIF encoding failed: {e}");
let (Ok(w), Ok(h)) = (u16::try_from(img.width), u16::try_from(img.height)) else {
return Err(format!("{} × {} pixels is too large for GIF (at most 65535 pixels a side): lower the resolution", img.width, img.height));
};
if w == 0 || h == 0 || img.indices.len() != w as usize * h as usize || img.palette.is_empty() || img.palette.len() > 256 {
return Err("GIF encoding failed: the palette image is malformed".into());
}
let buffer = if interlaced { Cow::Owned(interlace(&img.indices, w as usize)) } else { Cow::Borrowed(img.indices.as_slice()) };
let mut enc = gif::Encoder::new(Vec::new(), w, h, &img.palette.concat()).map_err(fail)?;
let frame = gif::Frame { width: w, height: h, buffer, transparent: img.transparent, interlaced, ..gif::Frame::default() };
enc.write_frame(&frame).map_err(fail)?;
enc.into_inner().map_err(fail)
}
/// The rows of a `width`-wide image in GIF's interlaced order: every 8th row from 0, every 8th
/// from 4, every 4th from 2, then every 2nd from 1.
fn interlace(px: &[u8], width: usize) -> Vec<u8> {
let rows: Vec<&[u8]> = px.chunks(width.max(1)).collect();
[(0, 8), (4, 8), (2, 4), (1, 2)]
.into_iter()
.flat_map(|(start, step)| rows.iter().skip(start).step_by(step))
.flat_map(|r| r.iter().copied())
.collect()
}
+28 -3
View File
@@ -1,9 +1,12 @@
//! Raster export: render a document region with [`RasterExportOptions`] (resolution, background,
//! anti-aliasing) and encode it as PNG ([`png`]: resolution in `pHYs`, Adam7 interlacing), JPEG
//! ([`jpeg`]: RGB, CMYK or grey, progressive, resolution and colour profile) or lossless WebP.
//! ([`jpeg`]: RGB, CMYK or grey, progressive, resolution and colour profile), lossless WebP, or
//! a palette image ([`quantize`]) as PNG-8 or [`gif`].
pub mod gif;
pub mod jpeg;
pub mod png;
pub mod quantize;
use vectorcraft_doc::{Document, Node, NodeKind};
use vectorcraft_geom::Rect;
@@ -16,6 +19,9 @@ pub enum RasterFormat {
Png,
Jpeg,
WebP,
/// Indexed PNG: a palette of up to 256 colours.
Png8,
Gif,
}
/// How a raster export renders and encodes.
@@ -26,17 +32,27 @@ pub struct RasterExportOptions {
/// Opaque background colour (RGB); `None` = transparent (JPEG has no alpha: white).
pub background: Option<[u8; 3]>,
pub anti_alias: AntiAlias,
/// PNG: Adam7 interlacing.
/// PNG, PNG-8: Adam7 interlacing; GIF: interlaced rows.
pub interlaced: bool,
/// JPEG quality 0–100.
pub quality: u8,
/// JPEG colour model, coding and profile.
pub jpeg: jpeg::JpegOptions,
/// PNG-8 and GIF: the colour reduction.
pub palette: quantize::PaletteOptions,
}
impl Default for RasterExportOptions {
fn default() -> Self {
Self { ppi: 72.0, background: None, anti_alias: AntiAlias::Art, interlaced: false, quality: 90, jpeg: jpeg::JpegOptions::default() }
Self {
ppi: 72.0,
background: None,
anti_alias: AntiAlias::Art,
interlaced: false,
quality: 90,
jpeg: jpeg::JpegOptions::default(),
palette: quantize::PaletteOptions::default(),
}
}
}
@@ -73,6 +89,13 @@ impl RasterExportOptions {
jpeg::encode(&px, img.width, img.height, self.quality, Some(self.ppi), &self.jpeg)
}
RasterFormat::WebP => webp(img),
RasterFormat::Png8 | RasterFormat::Gif => {
let ix = quantize::quantize(&img.to_straight(), img.width, img.height, &self.palette);
match format {
RasterFormat::Gif => gif::encode(&ix, self.interlaced),
_ => png::encode_indexed(&ix, &png::PngOptions { ppi: Some(self.ppi), interlaced: self.interlaced }),
}
}
}
}
}
@@ -132,3 +155,5 @@ fn drawn_bounds(n: &Node) -> Option<Rect> {
mod tests;
#[cfg(test)]
mod tests_jpeg;
#[cfg(test)]
mod tests_palette;
+89 -22
View File
@@ -1,6 +1,7 @@
//! A PNG writer for raster exports: 8-bit RGBA (RGB when every pixel is opaque), adaptive row
//! filters, the resolution as a `pHYs` chunk and optional Adam7 interlacing (the image builds up
//! progressively while it loads). Chunks are written here; flate2 compresses the image data.
//! A PNG writer for raster exports: 8-bit RGBA (RGB when every pixel is opaque) or indexed
//! (PNG-8: a palette of up to 256 colours, 1–8 bits per pixel), adaptive row filters, the
//! resolution as a `pHYs` chunk and optional Adam7 interlacing (the image builds up progressively
//! while it loads). Chunks are written here; flate2 compresses the image data.
use std::borrow::Cow;
use std::io::Write as _;
@@ -8,6 +9,8 @@ use std::io::Write as _;
use flate2::Crc;
use flate2::write::ZlibEncoder;
use super::quantize::Indexed;
/// What goes into the file besides the pixels.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct PngOptions {
@@ -39,9 +42,7 @@ pub fn phys_chunk(x: f64, y: f64) -> Vec<u8> {
/// Encode straight-alpha RGBA8 pixels (`width`×`height`, row-major) as a PNG file.
pub fn encode(rgba: &[u8], width: u32, height: u32, o: &PngOptions) -> Result<Vec<u8>, String> {
if width == 0 || height == 0 || width > i32::MAX as u32 || height > i32::MAX as u32 {
return Err(format!("PNG encoding failed: {width} × {height} pixels is not a valid image size"));
}
check_size(width, height)?;
if rgba.len() as u64 != u64::from(width) * u64::from(height) * 4 {
return Err("PNG encoding failed: pixel buffer doesn't match the image size".into());
}
@@ -49,32 +50,82 @@ pub fn encode(rgba: &[u8], width: u32, height: u32, o: &PngOptions) -> Result<Ve
let opaque = px.iter().all(|p| p[3] == 255);
let (channels, color_type) = if opaque { (3, 2) } else { (4, 6) };
let pixels: Cow<[u8]> = if opaque { Cow::Owned(px.iter().flat_map(|p| [p[0], p[1], p[2]]).collect()) } else { Cow::Borrowed(rgba) };
write(&pixels, width, height, Layout { channels, bits: 8, color_type }, &[], o)
}
/// Encode a palette image as an indexed PNG (colour type 3: `PLTE`, and `tRNS` for its
/// transparent entry) at 1, 2, 4 or 8 bits per pixel, as few as the palette needs.
pub fn encode_indexed(img: &Indexed, o: &PngOptions) -> Result<Vec<u8>, String> {
check_size(img.width, img.height)?;
if img.indices.len() as u64 != u64::from(img.width) * u64::from(img.height) || img.palette.is_empty() || img.palette.len() > 256 {
return Err("PNG encoding failed: the palette image is malformed".into());
}
let bits = match img.palette.len() {
0..=2 => 1,
3..=4 => 2,
5..=16 => 4,
_ => 8,
};
let plte = img.palette.concat();
// Alpha of the entries up to the transparent one (the rest are opaque).
let trns: Vec<u8> = img.transparent.map(|t| (0..=t).map(|i| if i == t { 0 } else { 255 }).collect()).unwrap_or_default();
let mut extra: Vec<(&[u8; 4], &[u8])> = vec![(b"PLTE", &plte)];
if !trns.is_empty() {
extra.push((b"tRNS", &trns));
}
write(&img.indices, img.width, img.height, Layout { channels: 1, bits, color_type: 3 }, &extra, o)
}
fn check_size(width: u32, height: u32) -> Result<(), String> {
if width == 0 || height == 0 || width > i32::MAX as u32 || height > i32::MAX as u32 {
return Err(format!("PNG encoding failed: {width} × {height} pixels is not a valid image size"));
}
Ok(())
}
/// How pixels are stored: bytes per pixel (one per index for palette images), bits per sample
/// in the file and the PNG colour type.
#[derive(Clone, Copy)]
struct Layout {
channels: usize,
bits: u8,
color_type: u8,
}
/// Write the file: signature, `IHDR`, `pHYs`, the `extra` chunks, the filtered and compressed
/// pixels (`layout.channels` bytes each, packed to `layout.bits` in the file) and `IEND`.
fn write(pixels: &[u8], width: u32, height: u32, layout: Layout, extra: &[(&[u8; 4], &[u8])], o: &PngOptions) -> Result<Vec<u8>, String> {
let Layout { channels, bits, color_type } = layout;
let mut out = Vec::with_capacity(pixels.len() / 2 + 64);
out.extend_from_slice(SIGNATURE);
let mut ihdr = Vec::with_capacity(13);
ihdr.extend_from_slice(&width.to_be_bytes());
ihdr.extend_from_slice(&height.to_be_bytes());
// Bit depth 8, colour type, deflate compression, adaptive filtering, interlace method.
ihdr.extend_from_slice(&[8, color_type, 0, 0, u8::from(o.interlaced)]);
// Bit depth, colour type, deflate compression, adaptive filtering, interlace method.
ihdr.extend_from_slice(&[bits, color_type, 0, 0, u8::from(o.interlaced)]);
chunk(&mut out, b"IHDR", &ihdr);
if let Some(ppi) = o.ppi {
out.extend_from_slice(&phys_chunk(ppi, ppi));
}
let raw = if o.interlaced {
let mut raw = Vec::with_capacity(pixels.len() + height as usize * 2);
for (ty, data) in extra {
chunk(&mut out, ty, data);
}
// Filters work on whole bytes: a sub-byte pixel counts as one.
let bpp = (channels * bits as usize / 8).max(1);
let mut raw = Vec::with_capacity(pixels.len() + height as usize * if o.interlaced { 2 } else { 1 });
let mut rows = |px: &[u8], w: u32, h: u32| {
let packed = pack(px, w, h, bits);
filter_rows(&packed, (w as usize * channels * bits as usize).div_ceil(8), h, bpp, &mut raw);
};
if o.interlaced {
for pass in ADAM7 {
let sub = sub_image(&pixels, width, height, channels, pass);
if let Some((w, h, px)) = sub {
filter_rows(&px, w, h, channels, &mut raw);
if let Some((w, h, px)) = sub_image(pixels, width, height, channels, pass) {
rows(&px, w, h);
}
}
raw
} else {
let mut raw = Vec::with_capacity(pixels.len() + height as usize);
filter_rows(&pixels, width, height, channels, &mut raw);
raw
};
rows(pixels, width, height);
}
let mut z = ZlibEncoder::new(Vec::with_capacity(raw.len() / 2), flate2::Compression::default());
let data = z.write_all(&raw).and_then(|()| z.finish()).map_err(|e| format!("PNG encoding failed: {e}"))?;
for part in data.chunks(IDAT_MAX) {
@@ -122,6 +173,23 @@ pub fn with_text(png: Vec<u8>, entries: &[(&str, Cow<str>)]) -> Vec<u8> {
out
}
/// Rows of one-byte pixels packed `bits` per pixel, most significant first (each row starts on a
/// byte); at 8 bits the pixels as they are.
fn pack(px: &[u8], w: u32, h: u32, bits: u8) -> Cow<'_, [u8]> {
if bits >= 8 {
return Cow::Borrowed(px);
}
let per = (8 / bits) as usize;
let mut out = Vec::with_capacity(h as usize * (w as usize).div_ceil(per));
for row in px.chunks(w.max(1) as usize) {
for group in row.chunks(per) {
let byte = group.iter().enumerate().fold(0u8, |b, (i, v)| b | (v & ((1 << bits) - 1)) << (8 - bits as usize * (i + 1)));
out.push(byte);
}
}
Cow::Owned(out)
}
/// Append one chunk: length, type, data, CRC of type and data.
fn chunk(out: &mut Vec<u8>, ty: &[u8; 4], data: &[u8]) {
out.extend_from_slice(&(data.len() as u32).to_be_bytes());
@@ -150,10 +218,9 @@ fn sub_image(px: &[u8], width: u32, height: u32, channels: usize, (x0, y0, dx, d
Some((w, h, out))
}
/// Filter every row of a `w`×`h` image into `out` (filter type byte + filtered bytes), picking
/// per row the filter with the smallest sum of absolute values (the usual heuristic).
fn filter_rows(px: &[u8], w: u32, h: u32, bpp: usize, out: &mut Vec<u8>) {
let stride = w as usize * bpp;
/// Filter every row (`stride` bytes, `h` rows) into `out` (filter type byte + filtered bytes),
/// picking per row the filter with the smallest sum of absolute values (the usual heuristic).
fn filter_rows(px: &[u8], stride: usize, h: u32, bpp: usize, out: &mut Vec<u8>) {
let zero = vec![0u8; stride];
let mut cand: [Vec<u8>; 5] = std::array::from_fn(|_| vec![0u8; stride]);
for y in 0..h as usize {
+447
View File
@@ -0,0 +1,447 @@
//! Colour reduction for palette formats (PNG-8, GIF): a palette of at most 256 colours chosen by
//! median cut over a 15-bit histogram (refined by a few k-means passes) or a fixed palette, then
//! each pixel mapped to its nearest entry with optional dithering (Floyd–Steinberg diffusion, an
//! 8×8 ordered pattern, or noise). Pixels under half opacity can become one transparent entry;
//! the others are blended over the matte colour.
use std::collections::HashMap;
/// How the palette is chosen.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Reduction {
/// Median cut weighted towards the colours the eye tells apart best.
Perceptual,
/// Like perceptual, keeping rarer distinct colours too, and snapping near web colours to them.
#[default]
Selective,
/// Median cut by how often colours occur.
Adaptive,
/// The 216 web-safe colours (the most used of them when fewer are allowed).
Web,
/// Black and white.
BlackWhite,
/// Evenly spaced greys.
Gray,
}
impl Reduction {
pub const ALL: [Reduction; 6] =
[Reduction::Perceptual, Reduction::Selective, Reduction::Adaptive, Reduction::Web, Reduction::BlackWhite, Reduction::Gray];
pub fn id(self) -> &'static str {
match self {
Reduction::Perceptual => "perceptual",
Reduction::Selective => "selective",
Reduction::Adaptive => "adaptive",
Reduction::Web => "web",
Reduction::BlackWhite => "blackWhite",
Reduction::Gray => "gray",
}
}
pub fn label(self) -> &'static str {
match self {
Reduction::Perceptual => "Perceptual",
Reduction::Selective => "Selective",
Reduction::Adaptive => "Adaptive",
Reduction::Web => "Web",
Reduction::BlackWhite => "Black & White",
Reduction::Gray => "Grayscale",
}
}
pub fn from_id(s: &str) -> Option<Self> {
Self::ALL.into_iter().find(|r| r.id().eq_ignore_ascii_case(s) || r.label().eq_ignore_ascii_case(s))
}
/// Channel weights of colour distances.
fn weights(self) -> [f32; 3] {
match self {
Reduction::Perceptual | Reduction::Selective => [0.55, 0.77, 0.34],
_ => [1.0; 3],
}
}
}
/// How colours between palette entries are approximated.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Dither {
None,
/// Floyd–Steinberg error diffusion.
#[default]
Diffusion,
/// An 8×8 ordered (Bayer) pattern.
Pattern,
/// Random noise.
Noise,
}
impl Dither {
pub const ALL: [Dither; 4] = [Dither::None, Dither::Diffusion, Dither::Pattern, Dither::Noise];
pub fn id(self) -> &'static str {
match self {
Dither::None => "none",
Dither::Diffusion => "diffusion",
Dither::Pattern => "pattern",
Dither::Noise => "noise",
}
}
pub fn label(self) -> &'static str {
match self {
Dither::None => "No Dither",
Dither::Diffusion => "Diffusion",
Dither::Pattern => "Pattern",
Dither::Noise => "Noise",
}
}
pub fn from_id(s: &str) -> Option<Self> {
Self::ALL.into_iter().find(|d| d.id().eq_ignore_ascii_case(s) || d.label().eq_ignore_ascii_case(s))
}
}
/// The palette options of a raster export (PNG-8, GIF).
#[derive(Clone, Debug, PartialEq)]
pub struct PaletteOptions {
/// Most palette entries, the transparent one included: 2–256.
pub colors: u16,
pub reduction: Reduction,
pub dither: Dither,
/// Dither strength 0–100.
pub dither_amount: u8,
/// Pixels under half opacity become a transparent entry; otherwise every pixel is opaque.
pub transparency: bool,
/// The colour partly transparent pixels are blended over. `None`: with transparency, they
/// keep their own colour (hard edges); without, white.
pub matte: Option<[u8; 3]>,
}
impl Default for PaletteOptions {
fn default() -> Self {
Self {
colors: 256,
reduction: Reduction::Selective,
dither: Dither::Diffusion,
dither_amount: 100,
transparency: true,
matte: Some([255; 3]),
}
}
}
/// An image as palette indices.
#[derive(Clone, Debug, PartialEq)]
pub struct Indexed {
pub width: u32,
pub height: u32,
pub palette: Vec<[u8; 3]>,
/// The transparent entry (always 0 when there is one).
pub transparent: Option<u8>,
/// One index per pixel, row-major.
pub indices: Vec<u8>,
}
/// Reduce straight-alpha RGBA pixels (`width`×`height`) to a palette.
pub fn quantize(rgba: &[u8], width: u32, height: u32, o: &PaletteOptions) -> Indexed {
let px = rgba.as_chunks::<4>().0;
let clear = |p: &[u8; 4]| o.transparency && p[3] < 128;
let gray = matches!(o.reduction, Reduction::Gray | Reduction::BlackWhite);
let matte = o.matte.unwrap_or([255; 3]);
let blend = o.matte.is_some() || !o.transparency;
// The opaque colours: blended over the matte, greys for the grey reductions.
let colors: Vec<[u8; 3]> = px
.iter()
.map(|p| {
let a = if blend { p[3] as u32 } else { 255 };
let c = [0, 1, 2].map(|i| ((p[i] as u32 * a + matte[i] as u32 * (255 - a) + 127) / 255) as u8);
if gray { [luma(c); 3] } else { c }
})
.collect();
let has_clear = px.iter().any(clear);
let k = (o.colors.clamp(2, 256) as usize - usize::from(has_clear)).max(1);
let opaque = || colors.iter().zip(px).filter(|(_, p)| !clear(p)).map(|(c, _)| *c);
let (mut palette, exact) = match o.reduction {
Reduction::BlackWhite => (vec![[0; 3], [255; 3]], false),
Reduction::Gray => ((0..k).map(|i| [(i * 255 / (k - 1).max(1)) as u8; 3]).collect(), false),
Reduction::Web => (web(opaque(), k), false),
r => match distinct(opaque(), k) {
Some(exact) => (exact, true),
None => (median_cut(opaque(), k, r), false),
},
};
if palette.is_empty() {
palette.push(matte);
}
let weights = o.reduction.weights();
let dither = if exact { Dither::None } else { o.dither };
let mut indices = map(&colors, width as usize, &palette, weights, dither, f32::from(o.dither_amount.min(100)) / 100.0, |i| clear(&px[i]));
// Drop the entries no pixel uses (fixed palettes), keeping the order.
let mut used = [false; 256];
for (i, &ix) in indices.iter().enumerate() {
if !clear(&px[i]) {
used[ix as usize] = true;
}
}
let mut remap = [0u8; 256];
let mut kept = Vec::with_capacity(palette.len() + 1);
if has_clear {
kept.push(matte);
}
for (i, c) in palette.iter().enumerate() {
if used[i] {
remap[i] = kept.len() as u8;
kept.push(*c);
}
}
for (i, ix) in indices.iter_mut().enumerate() {
*ix = if clear(&px[i]) { 0 } else { remap[*ix as usize] };
}
Indexed { width, height, palette: kept, transparent: has_clear.then_some(0), indices }
}
/// Lightness as a grey value (Rec. 601 luma).
fn luma([r, g, b]: [u8; 3]) -> u8 {
((r as u32 * 299 + g as u32 * 587 + b as u32 * 114 + 500) / 1000) as u8
}
/// The colours themselves, most used first, when there are at most `k`.
fn distinct(colors: impl Iterator<Item = [u8; 3]>, k: usize) -> Option<Vec<[u8; 3]>> {
let mut seen: HashMap<[u8; 3], u32> = HashMap::with_capacity(k + 1);
for c in colors {
*seen.entry(c).or_insert(0) += 1;
if seen.len() > k {
return None;
}
}
let mut v: Vec<([u8; 3], u32)> = seen.into_iter().collect();
v.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
Some(v.into_iter().map(|(c, _)| c).collect())
}
/// A web-safe colour: each channel a multiple of 51.
fn web_safe(c: [u8; 3]) -> [u8; 3] {
c.map(|v| ((v as u32 + 25) / 51 * 51) as u8)
}
/// The `k` most used web-safe colours.
fn web(colors: impl Iterator<Item = [u8; 3]>, k: usize) -> Vec<[u8; 3]> {
let mut count: HashMap<[u8; 3], u32> = HashMap::new();
for c in colors {
*count.entry(web_safe(c)).or_insert(0) += 1;
}
let mut v: Vec<([u8; 3], u32)> = count.into_iter().collect();
v.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
v.into_iter().take(k).map(|(c, _)| c).collect()
}
/// One histogram cell: its mean colour and pixel count.
#[derive(Clone, Copy)]
struct Cell {
mean: [f32; 3],
count: u32,
}
fn dist(a: [f32; 3], b: [f32; 3], w: [f32; 3]) -> f32 {
(0..3).map(|i| (w[i] * (a[i] - b[i])).powi(2)).sum()
}
/// `k` colours by median cut over a 5-bit-per-channel histogram, then k-means passes.
fn median_cut(colors: impl Iterator<Item = [u8; 3]>, k: usize, r: Reduction) -> Vec<[u8; 3]> {
let mut hist = vec![(0u32, [0u64; 3]); 1 << 15];
for c in colors {
let h = &mut hist[(c[0] as usize >> 3) << 10 | (c[1] as usize >> 3) << 5 | c[2] as usize >> 3];
h.0 += 1;
for (sum, v) in h.1.iter_mut().zip(c) {
*sum += v as u64;
}
}
let mut cells: Vec<Cell> = hist.iter().filter(|h| h.0 > 0).map(|&(n, s)| Cell { mean: s.map(|v| v as f32 / n as f32), count: n }).collect();
let w = r.weights();
// How much a box gains from a split: its widest weighted range, times its pixel count (how
// often its colours occur) or a root of it (keeping rarer colours apart too).
let score = |b: &[Cell]| -> (f32, usize) {
let (mut lo, mut hi) = ([f32::MAX; 3], [f32::MIN; 3]);
let mut n = 0u64;
for c in b {
for i in 0..3 {
lo[i] = lo[i].min(c.mean[i]);
hi[i] = hi[i].max(c.mean[i]);
}
n += c.count as u64;
}
let axis = (0..3).max_by(|&a, &b| (w[a] * (hi[a] - lo[a])).total_cmp(&(w[b] * (hi[b] - lo[b])))).unwrap_or(0);
let range = w[axis] * (hi[axis] - lo[axis]);
let weight = match r {
Reduction::Adaptive => n as f32,
Reduction::Perceptual => (n as f32).sqrt(),
_ => (n as f32).sqrt().sqrt(),
};
(if b.len() < 2 { 0.0 } else { range * weight }, axis)
};
// (start, end, score, axis) over `cells`.
let mut boxes = vec![{
let (s, axis) = score(&cells);
(0, cells.len(), s, axis)
}];
while boxes.len() < k {
let Some(at) = boxes.iter().enumerate().max_by(|x, y| x.1.2.total_cmp(&y.1.2)).map(|(i, _)| i) else { break };
let (a, b, s, axis) = boxes[at];
if s <= 0.0 {
break;
}
let part = &mut cells[a..b];
part.sort_by(|x, y| x.mean[axis].total_cmp(&y.mean[axis]));
// Split at the median pixel, leaving at least one cell on each side.
let total: u64 = part.iter().map(|c| c.count as u64).sum();
let mut acc = 0u64;
let mut mid = 1;
for (i, c) in part.iter().enumerate() {
acc += c.count as u64;
if acc * 2 >= total {
mid = (i + 1).clamp(1, part.len() - 1);
break;
}
}
for (x, y) in [(a, a + mid), (a + mid, b)] {
let (s, axis) = score(&cells[x..y]);
if x == a {
boxes[at] = (x, y, s, axis);
} else {
boxes.push((x, y, s, axis));
}
}
}
let mean = |b: &[Cell]| {
let n: f64 = b.iter().map(|c| c.count as f64).sum();
[0, 1, 2].map(|i| (b.iter().map(|c| c.mean[i] as f64 * c.count as f64).sum::<f64>() / n.max(1.0)) as f32)
};
let mut palette: Vec<[f32; 3]> = boxes.iter().map(|&(a, b, ..)| mean(&cells[a..b])).collect();
// k-means: move each entry to the mean of the cells nearest to it.
for _ in 0..3 {
let mut sums = vec![([0f64; 3], 0f64); palette.len()];
for c in &cells {
let i = nearest(&palette, c.mean, w);
for ch in 0..3 {
sums[i].0[ch] += c.mean[ch] as f64 * c.count as f64;
}
sums[i].1 += c.count as f64;
}
for (p, (s, n)) in palette.iter_mut().zip(sums) {
if n > 0.0 {
*p = s.map(|v| (v / n) as f32);
}
}
}
let mut out: Vec<[u8; 3]> = palette.iter().map(|p| p.map(|v| v.round().clamp(0.0, 255.0) as u8)).collect();
if r == Reduction::Selective {
// Colours close to a web-safe one become it (they display without dithering in a browser
// limited to those).
for c in &mut out {
let s = web_safe(*c);
if (0..3).all(|i| c[i].abs_diff(s[i]) <= 6) {
*c = s;
}
}
}
out.sort_unstable();
out.dedup();
out
}
fn nearest(palette: &[[f32; 3]], c: [f32; 3], w: [f32; 3]) -> usize {
palette.iter().enumerate().min_by(|a, b| dist(*a.1, c, w).total_cmp(&dist(*b.1, c, w))).map_or(0, |(i, _)| i)
}
/// The 8×8 ordered-dither threshold at (x, y), in 0..1.
fn bayer(x: usize, y: usize) -> f32 {
let mut v = 0;
for i in 0..3 {
let (xb, yb) = ((x >> i) & 1, (y >> i) & 1);
v |= ((xb ^ yb) << 1 | yb) << (2 * (2 - i));
}
(v as f32 + 0.5) / 64.0
}
/// A noise threshold at (x, y), in 0..1 (a hash, so exports are reproducible).
fn noise(x: usize, y: usize) -> f32 {
let mut h = (x as u32).wrapping_mul(0x9E37_79B1) ^ (y as u32).wrapping_mul(0x85EB_CA77);
h ^= h >> 15;
h = h.wrapping_mul(0x2C1B_3C6D);
h ^= h >> 12;
(h >> 8) as f32 / (1 << 24) as f32
}
/// Map each colour to its nearest palette entry, dithering by `amount` (0–1). Pixels `skip`
/// names (transparent) get index 0 and take no diffused error.
fn map(colors: &[[u8; 3]], width: usize, palette: &[[u8; 3]], w: [f32; 3], dither: Dither, amount: f32, skip: impl Fn(usize) -> bool) -> Vec<u8> {
let pal: Vec<[f32; 3]> = palette.iter().map(|c| c.map(f32::from)).collect();
// Nearest entries by colour: exact colours without dithering, a 6-bit grid with it.
let mut exact: HashMap<[u8; 3], u8> = HashMap::new();
let mut grid = vec![u16::MAX; if dither == Dither::None { 0 } else { 1 << 18 }];
let mut lookup = |c: [f32; 3]| -> u8 {
let q = c.map(|v| v.round().clamp(0.0, 255.0) as u8);
if grid.is_empty() {
return *exact.entry(q).or_insert_with(|| nearest(&pal, c, w) as u8);
}
let key = (q[0] as usize >> 2) << 12 | (q[1] as usize >> 2) << 6 | q[2] as usize >> 2;
match grid.get(key) {
Some(&v) if v != u16::MAX => v as u8,
_ => {
let i = nearest(&pal, c, w) as u8;
if let Some(g) = grid.get_mut(key) {
*g = i as u16;
}
i
}
}
};
// Ordered and noise dithering spread a pixel by about one palette step.
let step = 255.0 / (pal.len() as f32).cbrt().max(1.0) * amount;
let width = width.max(1);
let mut out = vec![0u8; colors.len()];
let mut err = vec![[0f32; 3]; 2 * (width + 2)];
for (y, row) in colors.chunks(width).enumerate() {
let (cur, next) = err.split_at_mut(width + 2);
if dither == Dither::Diffusion {
next.fill([0.0; 3]);
}
for (x, c) in row.iter().enumerate() {
let i = y * width + x;
if skip(i) {
continue;
}
let mut v = c.map(f32::from);
match dither {
Dither::None => {}
Dither::Diffusion => {
for ch in 0..3 {
v[ch] = (v[ch] + cur[x + 1][ch]).clamp(0.0, 255.0);
}
}
Dither::Pattern | Dither::Noise => {
let t = if dither == Dither::Pattern { bayer(x % 8, y % 8) } else { noise(x, y) };
v = v.map(|ch| (ch + (t - 0.5) * step).clamp(0.0, 255.0));
}
}
let ix = lookup(v);
out[i] = ix;
if dither == Dither::Diffusion {
let p = pal.get(ix as usize).copied().unwrap_or_default();
let e = [0, 1, 2].map(|ch| (v[ch] - p[ch]) * amount);
// Floyd–Steinberg: 7/16 right, 3/16 below left, 5/16 below, 1/16 below right.
for ch in 0..3 {
cur[x + 2][ch] += e[ch] * 7.0 / 16.0;
next[x][ch] += e[ch] * 3.0 / 16.0;
next[x + 1][ch] += e[ch] * 5.0 / 16.0;
next[x + 2][ch] += e[ch] / 16.0;
}
}
}
if dither == Dither::Diffusion {
cur.copy_from_slice(next);
}
}
out
}
+180
View File
@@ -0,0 +1,180 @@
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{Appearance, Node};
use vectorcraft_geom::shapes;
use super::png::{PngOptions, encode_indexed};
use super::quantize::{Dither, Indexed, PaletteOptions, Reduction, quantize};
use super::*;
/// A `w`×`h` opaque image of `f(x, y)` colours.
fn image(w: u32, h: u32, f: impl Fn(u32, u32) -> [u8; 3]) -> Vec<u8> {
(0..h)
.flat_map(|y| (0..w).map(move |x| (x, y)))
.flat_map(|(x, y)| {
let [r, g, b] = f(x, y);
[r, g, b, 255]
})
.collect()
}
/// Smooth colour ramps: thousands of colours.
fn ramps(w: u32, h: u32) -> Vec<u8> {
image(w, h, |x, y| [(x * 255 / w) as u8, (y * 255 / h) as u8, ((x + y) * 127 / (w + h)) as u8])
}
fn rgb(px: &[u8]) -> Vec<[u8; 3]> {
px.chunks(4).map(|p| [p[0], p[1], p[2]]).collect()
}
/// The colour each pixel gets back.
fn colours(ix: &Indexed) -> Vec<[u8; 3]> {
ix.indices.iter().map(|&i| ix.palette[i as usize]).collect()
}
/// The PNG chunk of type `ty` (first match).
fn chunk<'a>(file: &'a [u8], ty: &[u8; 4]) -> Option<&'a [u8]> {
let mut i = 8;
while i + 8 <= file.len() {
let len = u32::from_be_bytes(file[i..i + 4].try_into().unwrap()) as usize;
if &file[i + 4..i + 8] == ty {
return Some(&file[i + 8..i + 8 + len]);
}
i += 12 + len;
}
None
}
#[test]
fn two_colour_art_gives_two_entries() {
let px = image(9, 5, |x, _| if x < 4 { [200, 30, 40] } else { [10, 10, 90] });
for reduction in [Reduction::Perceptual, Reduction::Selective, Reduction::Adaptive] {
let ix = quantize(&px, 9, 5, &PaletteOptions { reduction, ..Default::default() });
assert_eq!(ix.palette.len(), 2, "{reduction:?}");
assert_eq!(ix.transparent, None);
assert_eq!(colours(&ix), rgb(&px), "exact, no dithering");
}
// A hard-edged circle exported on white: black and white only.
let mut d = Document::new(40.0, 30.0);
let id = d.alloc_id();
let l = d.layers[0].id;
let n = Node::path(id, shapes::ellipse(Rect::new(5.3, 4.7, 31.1, 26.2)), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0));
d.insert(Some(l), 0, n).unwrap();
let o = RasterExportOptions { background: Some([255; 3]), anti_alias: AntiAlias::None, ..Default::default() };
let file = Renderer::new().export_region(&d, d.artboards[0].rect, RasterFormat::Png8, &o).unwrap();
assert_eq!(file[25], 3, "colour type 3");
assert_eq!(file[24], 1, "two entries fit one bit per pixel");
assert_eq!(chunk(&file, b"PLTE").unwrap().len(), 6);
}
#[test]
fn colours_cap_the_palette() {
let px = ramps(64, 48);
for colors in [2, 16, 64, 256] {
for dither in Dither::ALL {
for reduction in [Reduction::Perceptual, Reduction::Selective, Reduction::Adaptive, Reduction::Web, Reduction::Gray] {
let ix = quantize(&px, 64, 48, &PaletteOptions { colors, dither, reduction, ..Default::default() });
assert!(ix.palette.len() <= colors as usize, "{colors} {dither:?} {reduction:?}: {}", ix.palette.len());
assert!(ix.indices.iter().all(|&i| (i as usize) < ix.palette.len()));
}
}
}
// Many colours use most of the palette.
assert!(quantize(&px, 64, 48, &PaletteOptions { colors: 16, ..Default::default() }).palette.len() >= 12);
}
#[test]
fn fixed_palettes() {
let px = ramps(32, 32);
let web = quantize(&px, 32, 32, &PaletteOptions { reduction: Reduction::Web, ..Default::default() });
assert!(web.palette.iter().flatten().all(|v| v % 51 == 0), "web-safe");
let gray = quantize(&px, 32, 32, &PaletteOptions { reduction: Reduction::Gray, colors: 8, ..Default::default() });
assert!(gray.palette.iter().all(|[r, g, b]| r == g && g == b) && gray.palette.len() <= 8);
let bw = quantize(&px, 32, 32, &PaletteOptions { reduction: Reduction::BlackWhite, ..Default::default() });
assert_eq!(bw.palette, [[0, 0, 0], [255, 255, 255]]);
}
#[test]
fn dithering_keeps_the_average_colour() {
let px = image(64, 16, |x, _| [(x * 4) as u8; 3]);
let mean = |c: &[[u8; 3]]| c.iter().map(|p| p[0] as f64).sum::<f64>() / c.len().max(1) as f64;
// Per 8-pixel column band, how far the result's mean is from the original's.
let err = |dither| {
let ix = quantize(&px, 64, 16, &PaletteOptions { reduction: Reduction::BlackWhite, dither, ..Default::default() });
let (got, want) = (colours(&ix), rgb(&px));
let band = |c: &[[u8; 3]], b: usize| c.iter().enumerate().filter(|(i, _)| (i % 64) / 8 == b).map(|(_, p)| *p).collect::<Vec<_>>();
(0..8).map(|b| (mean(&band(&got, b)) - mean(&band(&want, b))).abs()).sum::<f64>()
};
let none = err(Dither::None);
for d in [Dither::Diffusion, Dither::Pattern, Dither::Noise] {
assert!(err(d) < none / 2.0, "{d:?}: {} vs {none}", err(d));
}
let noisy = PaletteOptions { dither: Dither::Noise, colors: 8, ..Default::default() };
assert_eq!(quantize(&px, 64, 16, &noisy), quantize(&px, 64, 16, &noisy), "noise is reproducible");
}
#[test]
fn transparent_pixels_get_their_own_entry() {
let px: Vec<u8> = (0..16u8).flat_map(|i| if i % 2 == 0 { [255, 0, 0, 255] } else { [0, 0, 255, if i < 8 { 0 } else { 100 }] }).collect();
let ix = quantize(&px, 4, 4, &PaletteOptions::default());
assert_eq!(ix.transparent, Some(0));
assert_eq!(ix.palette.len(), 2, "red and the transparent entry: alpha 100 is under half");
assert!(ix.indices.iter().enumerate().all(|(i, &v)| (v == 0) == (i % 2 == 1)));
let opaque = quantize(&px, 4, 4, &PaletteOptions { transparency: false, matte: Some([0, 0, 0]), ..Default::default() });
assert_eq!(opaque.transparent, None);
assert!(opaque.palette.contains(&[0, 0, 0]) && opaque.palette.contains(&[0, 0, 100]), "blended over the matte: {:?}", opaque.palette);
let file = encode_indexed(&ix, &PngOptions::default()).unwrap();
assert_eq!(chunk(&file, b"tRNS").unwrap(), [0]);
let back = image::load_from_memory(&file).unwrap().to_rgba8();
assert_eq!(back.get_pixel(0, 0).0, [255, 0, 0, 255]);
assert_eq!(back.get_pixel(1, 0)[3], 0);
}
#[test]
fn png8_decodes_at_every_bit_depth_and_interlaced() {
for (colors, depth) in [(2, 1), (4, 2), (16, 4), (200, 8)] {
let px = ramps(13, 11);
let ix = quantize(&px, 13, 11, &PaletteOptions { colors, ..Default::default() });
for interlaced in [false, true] {
let file = encode_indexed(&ix, &PngOptions { ppi: Some(144.0), interlaced }).unwrap();
assert_eq!((file[24], file[25], file[28]), (depth, 3, u8::from(interlaced)));
let back = image::load_from_memory(&file).unwrap().to_rgb8();
let want: Vec<u8> = colours(&ix).into_iter().flatten().collect();
assert_eq!(back.into_raw(), want, "{colors} colours, interlaced {interlaced}");
assert!(chunk(&file, b"pHYs").is_some());
}
}
}
#[test]
fn gif_decodes() {
let px: Vec<u8> = ramps(21, 17).chunks(4).enumerate().flat_map(|(i, p)| [p[0], p[1], p[2], if i % 5 == 0 { 0 } else { 255 }]).collect();
let ix = quantize(&px, 21, 17, &PaletteOptions { colors: 32, ..Default::default() });
for interlaced in [false, true] {
let file = gif::encode(&ix, interlaced).unwrap();
assert_eq!(&file[..6], b"GIF89a");
let back = image::load_from_memory_with_format(&file, image::ImageFormat::Gif).unwrap().to_rgba8();
assert_eq!(back.dimensions(), (21, 17));
for (i, p) in back.pixels().enumerate() {
match ix.indices[i] {
0 => assert_eq!(p[3], 0, "transparent at {i}"),
v => {
let [r, g, b] = ix.palette[v as usize];
assert_eq!(p.0, [r, g, b, 255], "{i} interlaced {interlaced}");
}
}
}
}
let wide = Indexed { width: 70_000, height: 1, palette: vec![[0; 3]], transparent: None, indices: vec![0; 70_000] };
assert!(gif::encode(&wide, false).is_err());
}
#[test]
fn ids_round_trip() {
for r in Reduction::ALL {
assert_eq!(Reduction::from_id(r.id()), Some(r));
}
for d in Dither::ALL {
assert_eq!(Dither::from_id(d.id()), Some(d));
}
}
@@ -102,7 +102,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
}
let cur = f["format"].as_str().unwrap_or("png").to_string();
egui::ComboBox::from_id_salt(("efs-fmt", i)).selected_text(cur.to_uppercase()).width(70.0).show_ui(ui, |ui| {
for fm in ["png", "jpg", "webp", "svg", "pdf"] {
for fm in ["png", "jpg", "webp", "gif", "png8", "svg", "pdf"] {
if ui.selectable_label(cur == fm, fm.to_uppercase()).clicked() {
f["format"] = json!(fm);
}
+1 -1
View File
@@ -192,7 +192,7 @@ registry! {
SvgOptions: [svg_options::KIND] => svg_options::SPEC,
NewDocumentMore: [new_document::MORE] => new_document::MORE_SPEC,
Place: [place::KIND] => place::SPEC,
RasterOptions: ["pngOptions", "jpgOptions", "webpOptions"] => png_options::SPEC,
RasterOptions: ["pngOptions", "jpgOptions", "webpOptions", "gifOptions", "png8Options"] => png_options::SPEC,
ExportAs: ["exportAs"] => export_as::SPEC,
ImportPdf: [import_pdf::KIND] => import_pdf::SPEC,
SwatchConflict: [swatch_conflict::KIND] => swatch_conflict::SPEC,
+87 -8
View File
@@ -1,13 +1,15 @@
//! The raster export options: PNG Options (resolution, background, anti-aliasing, interlaced),
//! JPEG Options (no transparency; colour model, quality 0–10, method and scans, profile, image
//! map) and WebP Options. Fields are `document.export` params (format, path, artboard choice…);
//! `__`-prefixed ones are the dialog's.
//! map), WebP Options, and PNG-8 and GIF Options (the palette: colour reduction, colours, dither,
//! transparency and matte). Fields are `document.export` params (format, path, artboard
//! choice…); `__`-prefixed ones are the dialog's.
use serde::Deserialize;
use serde_json::{Value, json};
use vectorcraft_engine::cmd::fileio::{self, ArtboardPick, Format};
use vectorcraft_render::AntiAlias;
use vectorcraft_render::encode::jpeg::{self, ColorModel, Method};
use vectorcraft_render::encode::quantize::{Dither, PaletteOptions, Reduction};
use super::{DialogSpec, form};
use crate::state::Dialog;
@@ -27,10 +29,16 @@ const BACKGROUND_LABELS: [&str; 4] = ["Transparent", "White", "Black", "Other"];
/// JPEG image map param values and their labels.
const IMAGE_MAPS: [&str; 3] = ["none", "client", "server"];
const IMAGE_MAP_LABELS: [&str; 3] = ["None", "Client-side (.html)", "Server-side (.map)"];
/// Palette sizes offered (any 2–256 can be typed).
const COLOR_COUNTS: [&str; 8] = ["2", "4", "8", "16", "32", "64", "128", "256"];
/// Matte param values and their labels (`Other`: a colour).
const MATTES: [&str; 3] = ["none", "white", "black"];
const MATTE_LABELS: [&str; 4] = ["None", "White", "Black", "Other"];
/// JPEG quality on the dialog's 0–10 scale: the band each step falls in.
const QUALITY_BANDS: [&str; 11] = ["Low", "Low", "Low", "Medium", "Medium", "Medium", "High", "High", "Maximum", "Maximum", "Maximum"];
/// The dialog kind of a raster format (`pngOptions`, `jpgOptions`, `webpOptions`).
/// The dialog kind of a raster format (`pngOptions`, `jpgOptions`, `webpOptions`, `gifOptions`,
/// `png8Options`).
fn kind(f: &Format) -> String {
format!("{}Options", f.id)
}
@@ -67,6 +75,18 @@ pub fn open(app: &mut VectorcraftApp, f: &Format, mut params: Value) {
("imageMap".into(), json!(IMAGE_MAPS[0])),
]);
}
"gif" | "png8" => {
let p = PaletteOptions::default();
o.extend([
("reduction".into(), json!(p.reduction.id())),
("colors".into(), json!(p.colors)),
("dither".into(), json!(p.dither.id())),
("ditherAmount".into(), json!(p.dither_amount)),
("transparency".into(), json!(p.transparency)),
("matte".into(), json!(MATTES[1])),
("interlaced".into(), json!(false)),
]);
}
_ => {}
}
if let Some((w, h)) = size {
@@ -131,11 +151,7 @@ fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
d.fields.insert("background".into(), json!(value));
}
if choice == 3 {
let c = vectorcraft_color::Color::from_hex(&bg).map_or([128, 128, 128, 255], |c| c.to_rgba8(1.0));
let mut rgb = [c[0], c[1], c[2]];
if ui.color_edit_button_srgb(&mut rgb).changed() {
d.fields.insert("background".into(), json!(format!("#{:02x}{:02x}{:02x}", rgb[0], rgb[1], rgb[2])));
}
color_button(ui, d, "background");
}
});
ui.end_row();
@@ -154,6 +170,7 @@ fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
ui.end_row();
}
"jpg" => jpeg_rows(ui, d, &label),
"gif" | "png8" => palette_rows(ui, d, &label),
_ => {}
}
@@ -208,6 +225,68 @@ fn jpeg_rows(ui: &mut egui::Ui, d: &mut Dialog, label: &dyn Fn(&mut egui::Ui, &s
ui.end_row();
}
/// The PNG-8 and GIF Options rows: the palette (reduction, colours, dither and amount),
/// transparency and matte, interlacing.
fn palette_rows(ui: &mut egui::Ui, d: &mut Dialog, label: &dyn Fn(&mut egui::Ui, &str) -> egui::Response) {
label(ui, "Color Reduction:");
choice(ui, d, "reduction", &Reduction::ALL.map(Reduction::id), &Reduction::ALL.map(Reduction::label));
ui.end_row();
label(ui, "Colors:");
let mut n = d.f64("colors", 256.0).round().clamp(2.0, 256.0) as u16;
ui.horizontal(|ui| {
if let Some(i) = widgets::dropdown(ui, "ro-colors", &n.to_string(), &COLOR_COUNTS, 70.0) {
n = COLOR_COUNTS.get(i).and_then(|c| c.parse().ok()).unwrap_or(n);
d.fields.insert("colors".into(), json!(n));
}
if ui.add(egui::DragValue::new(&mut n).range(2..=256)).changed() {
d.fields.insert("colors".into(), json!(n));
}
});
ui.end_row();
label(ui, "Dither:");
ui.horizontal(|ui| {
choice(ui, d, "dither", &Dither::ALL.map(Dither::id), &Dither::ALL.map(Dither::label));
if Dither::from_id(&d.str("dither")).is_some_and(|x| x != Dither::None) {
let mut amount = d.f64("ditherAmount", 100.0).round().clamp(0.0, 100.0) as u8;
if ui.add(egui::DragValue::new(&mut amount).range(0..=100).suffix("%")).changed() {
d.fields.insert("ditherAmount".into(), json!(amount));
}
}
});
ui.end_row();
label(ui, "Matte:");
let matte = d.str("matte");
let at = MATTES.iter().position(|v| matte.eq_ignore_ascii_case(v)).unwrap_or(3);
ui.horizontal(|ui| {
if let Some(i) = widgets::dropdown(ui, "ro-matte", MATTE_LABELS[at], &MATTE_LABELS, 150.0) {
d.fields.insert("matte".into(), json!(MATTES.get(i).copied().unwrap_or("#808080")));
}
if at == 3 {
color_button(ui, d, "matte");
}
});
ui.end_row();
ui.label("");
ui.horizontal(|ui| {
form::check(ui, d, "transparency", "Transparency");
form::check(ui, d, "interlaced", "Interlaced");
});
ui.end_row();
}
/// A colour button bound to `d.fields[key]` (`"#rrggbb"`; grey when unset).
fn color_button(ui: &mut egui::Ui, d: &mut Dialog, key: &str) {
let c = vectorcraft_color::Color::from_hex(&d.str(key)).map_or([128, 128, 128, 255], |c| c.to_rgba8(1.0));
let mut rgb = [c[0], c[1], c[2]];
if ui.color_edit_button_srgb(&mut rgb).changed() {
d.fields.insert(key.into(), json!(format!("#{:02x}{:02x}{:02x}", rgb[0], rgb[1], rgb[2])));
}
}
/// A dropdown bound to `d.fields[key]`: one of `ids`, shown by its label (the first when unknown).
fn choice(ui: &mut egui::Ui, d: &mut Dialog, key: &str, ids: &[&str], labels: &[&str]) {
let cur = d.str(key);
@@ -1,4 +1,4 @@
//! The options of the raster formats beyond PNG, drawn headlessly: JPEG.
//! The options of the raster formats beyond PNG, drawn headlessly: JPEG, PNG-8 and GIF.
use serde_json::json;
@@ -42,3 +42,38 @@ fn jpeg_options_pick_the_colour_model_method_and_profile() {
confirm(&mut app).unwrap();
assert_eq!(app.ui.dialog.as_ref().unwrap().str("colorModel"), "cmyk");
}
#[test]
fn palette_options_reduce_the_colours() {
let (mut app, written) = app(1);
app.run("file.exportAs", json!({"format": "gif"})).unwrap();
confirm(&mut app).unwrap();
assert_eq!(kind(&app), Some("gifOptions"));
let d = app.ui.dialog.as_ref().unwrap();
assert_eq!((spec(&d.kind).heading)(d), "GIF Options");
assert_eq!(
(d.str("reduction"), d.f64("colors", 0.0), d.str("dither"), d.bool("transparency"), d.str("matte")),
("selective".into(), 256.0, "diffusion".into(), true, "white".into())
);
frame(&mut app);
set(&mut app, "colors", json!(4));
set(&mut app, "dither", json!("pattern"));
set(&mut app, "matte", json!("#336699"));
set(&mut app, "interlaced", json!(true));
frame(&mut app);
confirm(&mut app).unwrap();
let (path, gif) = written.borrow()[0].clone();
assert_eq!(path, "/out/Untitled-1.gif");
assert_eq!(&gif[..6], b"GIF89a");
assert_eq!(gif[10] & 0x07, 1, "a global colour table of 4 entries");
app.run("file.exportAs", json!({"format": "png8"})).unwrap();
confirm(&mut app).unwrap();
assert_eq!(kind(&app), Some("png8Options"));
set(&mut app, "reduction", json!("gray"));
frame(&mut app);
confirm(&mut app).unwrap();
let (path, png) = written.borrow()[1].clone();
assert_eq!(path, "/out/Untitled-1.png");
assert_eq!(png[25], 3, "indexed");
}
+1
View File
@@ -27,6 +27,7 @@
| `engine.execute` `file.place` | `{paths?}` / `{path \| name+dataBase64, …file.place params}` | with no file, picks files and opens the `place` dialog; with `paths`, opens it for them (fields `files`, `link`, `template`, `replace`; `__replace` says whether Replace applies, `__info` describes each file). `ui.dialog.confirm` places one file centred in the view (or in place of the selected object) and loads the place cursor with several (`file.place.queue`). With a file, it runs `file.place` centred in the view. Files dropped on the canvas are placed at the drop point (linked; Shift embeds), elsewhere or with no document open they open. With the cursor loaded (`ui.inspect` → `tool: "place"`, `toolOptions: {count, current, name, names}`), `ui.pointer` clicks and drags place files and `ui.key` ←/→/↑/↓ and Escape cycle and discard them |
| `app.export` raster options | `{path?, format: png \| jpg \| webp, ppi?, background?, antiAlias?, interlaced?, quality?}` | `ppi` sets the pixel size (72 = one pixel per point) and is stored in the file; `background` is `transparent`, `white`, `black` or `"#rrggbb"`; `antiAlias` is `none`, `art` or `type` (text snapped to pixels); `interlaced` writes an Adam7 PNG. The PNG Options dialog (`pngOptions`; `jpgOptions` / `webpOptions` for the other raster formats) has the same fields: fill them with `ui.dialog.set` and press OK with `ui.dialog.confirm` |
| `app.export` JPEG options | `{path?, format: jpg, quality?: 0–100, colorModel?: rgb \| cmyk \| gray, method?: baseline \| optimized \| progressive, scans?: 3–5, embedIcc?, imageMap?: none \| client \| server}` | `cmyk` writes ink amounts in the working CMYK space (CMYK colours keep their inks; RGB ones are separated with the colour settings); `embedIcc` (default true) embeds sRGB, the working CMYK profile or a grey profile, generated by the CMS; `imageMap` also writes `<stem>.html` (client-side `<map>`) or `<stem>.map` (NCSA) beside the image for the objects with a URL and an Image Map shape (`attributes.set`), returned in `linked`. The `jpgOptions` dialog has these fields too (`quality` is shown 0–10; a CMYK document defaults to `cmyk`) |
| `app.export` PNG-8 and GIF | `{path?, format: png8 \| gif, colors?: 2–256, reduction?: perceptual \| selective \| adaptive \| web \| blackWhite \| gray, dither?: none \| diffusion \| pattern \| noise, ditherAmount?: 0–100, transparency?, matte?, interlaced?, …raster options}` | a palette of at most `colors` entries (art with that many colours or fewer keeps them exactly); pixels under half opacity become one transparent entry unless `transparency` is false, partly transparent edges are blended over `matte` (`none` keeps their colour). `png8` writes an indexed `.png` (1–8 bits per pixel); Export for Screens rows take both. The `png8Options` / `gifOptions` dialogs have these fields |
| `ui.dialog.*` on `importPdf` | `{field: "page" \| "allPages" \| "range" \| "cropTo" \| "password", value}` | opening (`app.open`, `file.open`) a PDF or `.ai` with several pages or a password, or placing one (`file.place` without `page`), opens the Import PDF dialog (`mode`: `open` or `place`; `pageCount`). While `__locked` is true, set `password` and `ui.dialog.confirm` to unlock it (a one-page file then opens or is placed). Then set `allPages: false` and `range` ("2-3, 5") to open some pages, or `page` to place one, and `cropTo` (`bounding`, `art`, `crop`, `trim`, `bleed`, `media`); `ui.dialog.confirm` opens or places them (placing keeps the other `file.place` params, such as `link`, `template` and `at`, in `__place`). `document.open {pages, cropTo, password}` and `file.place {page, crop, password}` do the same without the dialog |