diff --git a/Cargo.lock b/Cargo.lock index 4fce31e5..cf4ab07e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4244,6 +4244,7 @@ name = "vectorcraft-render" version = "0.1.1" dependencies = [ "flate2", + "gif", "image", "jpeg-encoder", "log", diff --git a/Cargo.toml b/Cargo.toml index db45e12a..9f086091 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/crates/engine/src/cmd/fileio/encode.rs b/crates/engine/src/cmd/fileio/encode.rs index 9ddf104e..5ada012c 100644 --- a/crates/engine/src/cmd/fileio/encode.rs +++ b/crates/engine/src/cmd/fileio/encode.rs @@ -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, embed_icc: Option, image_map: Option, + colors: Option, + reduction: Option, + dither: Option, + dither_amount: Option, + transparency: Option, + matte: Option, } 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 { 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 { 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 { 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 diff --git a/crates/engine/src/cmd/fileio/export.rs b/crates/engine/src/cmd/fileio/export.rs index 9ddd9e88..7974df5b 100644 --- a/crates/engine/src/cmd/fileio/export.rs +++ b/crates/engine/src/cmd/fileio/export.rs @@ -101,7 +101,7 @@ fn screen_format(row: &Value) -> Result { } 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). diff --git a/crates/engine/src/cmd/fileio/mod.rs b/crates/engine/src/cmd/fileio/mod.rs index 2e854264..79aab41c 100644 --- a/crates/engine/src/cmd/fileio/mod.rs +++ b/crates/engine/src/cmd/fileio/mod.rs @@ -55,7 +55,7 @@ pub fn specs() -> Vec { "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 { "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 .html / .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 .html / .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 { "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 , .html) or server (an NCSA .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; diff --git a/crates/engine/src/cmd/fileio/tests.rs b/crates/engine/src/cmd/fileio/tests.rs index 32cafb00..013179dc 100644 --- a/crates/engine/src/cmd/fileio/tests.rs +++ b/crates/engine/src/cmd/fileio/tests.rs @@ -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::>(); - 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); diff --git a/crates/engine/src/cmd/fileio/tests_palette.rs b/crates/engine/src/cmd/fileio/tests_palette.rs new file mode 100644 index 00000000..703ea41c --- /dev/null +++ b/crates/engine/src/cmd/fileio/tests_palette.rs @@ -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 { + 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); +} diff --git a/crates/mcp/src/tests_fileio.rs b/crates/mcp/src/tests_fileio.rs index a0ecfa7f..26f5297b 100644 --- a/crates/mcp/src/tests_fileio.rs +++ b/crates/mcp/src/tests_fileio.rs @@ -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}"); } diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 3749bb3a..04daf393 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -264,7 +264,7 @@ pub fn tool_definitions() -> Vec { 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::>(), "description": "Default: from the path's extension"}, diff --git a/crates/render/Cargo.toml b/crates/render/Cargo.toml index dcd6dc03..0c06f79b 100644 --- a/crates/render/Cargo.toml +++ b/crates/render/Cargo.toml @@ -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] diff --git a/crates/render/src/encode/gif.rs b/crates/render/src/encode/gif.rs new file mode 100644 index 00000000..3d976089 --- /dev/null +++ b/crates/render/src/encode/gif.rs @@ -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, 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 { + 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() +} diff --git a/crates/render/src/encode/mod.rs b/crates/render/src/encode/mod.rs index 0d90ff81..d886e76e 100644 --- a/crates/render/src/encode/mod.rs +++ b/crates/render/src/encode/mod.rs @@ -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 { mod tests; #[cfg(test)] mod tests_jpeg; +#[cfg(test)] +mod tests_palette; diff --git a/crates/render/src/encode/png.rs b/crates/render/src/encode/png.rs index 7b703e32..1fb04571 100644 --- a/crates/render/src/encode/png.rs +++ b/crates/render/src/encode/png.rs @@ -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 { /// 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, 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 = 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, 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 = 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, 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, entries: &[(&str, Cow)]) -> Vec { 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, 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) { - 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) { let zero = vec![0u8; stride]; let mut cand: [Vec; 5] = std::array::from_fn(|_| vec![0u8; stride]); for y in 0..h as usize { diff --git a/crates/render/src/encode/quantize.rs b/crates/render/src/encode/quantize.rs new file mode 100644 index 00000000..e51c2b0f --- /dev/null +++ b/crates/render/src/encode/quantize.rs @@ -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::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::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, + /// One index per pixel, row-major. + pub indices: Vec, +} + +/// 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, k: usize) -> Option> { + 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, 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, 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 = 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::() / 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 { + 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 +} diff --git a/crates/render/src/encode/tests_palette.rs b/crates/render/src/encode/tests_palette.rs new file mode 100644 index 00000000..53a139f3 --- /dev/null +++ b/crates/render/src/encode/tests_palette.rs @@ -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 { + (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 { + 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::() / 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::>(); + (0..8).map(|b| (mean(&band(&got, b)) - mean(&band(&want, b))).abs()).sum::() + }; + 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 = (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 = 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 = 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)); + } +} diff --git a/crates/ui-egui/src/dialogs/export_for_screens.rs b/crates/ui-egui/src/dialogs/export_for_screens.rs index b66fc583..a670db46 100644 --- a/crates/ui-egui/src/dialogs/export_for_screens.rs +++ b/crates/ui-egui/src/dialogs/export_for_screens.rs @@ -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); } diff --git a/crates/ui-egui/src/dialogs/mod.rs b/crates/ui-egui/src/dialogs/mod.rs index b6f73006..50d224d2 100644 --- a/crates/ui-egui/src/dialogs/mod.rs +++ b/crates/ui-egui/src/dialogs/mod.rs @@ -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, diff --git a/crates/ui-egui/src/dialogs/png_options.rs b/crates/ui-egui/src/dialogs/png_options.rs index 7e60f65a..0b28e0ad 100644 --- a/crates/ui-egui/src/dialogs/png_options.rs +++ b/crates/ui-egui/src/dialogs/png_options.rs @@ -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); diff --git a/crates/ui-egui/src/dialogs/tests_raster_formats.rs b/crates/ui-egui/src/dialogs/tests_raster_formats.rs index 389105de..e4a73adc 100644 --- a/crates/ui-egui/src/dialogs/tests_raster_formats.rs +++ b/crates/ui-egui/src/dialogs/tests_raster_formats.rs @@ -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"); +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 5417527d..ac7184e5 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -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 `.html` (client-side ``) or `.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 |