mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-30 04:37:29 -06:00
document.export {format: "png8" | "gif"} reduces the art to a palette of at most `colors`
(2–256) entries: median cut over a 15-bit histogram refined by k-means (perceptual,
selective or adaptive weighting), or the web-safe, black-and-white or grey palettes; art
with that many colours or fewer keeps them exactly. Dithering is Floyd–Steinberg diffusion,
an 8×8 ordered pattern or noise, with an amount. Pixels under half opacity become one
transparent entry; partly transparent edges blend over the matte. PNG-8 is an indexed PNG
(PLTE, tRNS, 1–8 bits per pixel, Adam7); GIF is a GIF89a (optionally interlaced). Both are in
document.formats, Export As (PNG-8 / GIF Options dialogs) and Export for Screens.
document.formats' writable list and the MCP export tool's format enum gain gif and png8
(their tests list them).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
73 lines
3.6 KiB
Rust
73 lines
3.6 KiB
Rust
//! PNG-8 and GIF: palette exports through document.export and Export for Screens.
|
||
|
||
use serde_json::{Value, json};
|
||
|
||
use super::*;
|
||
|
||
fn b64(v: &Value) -> Vec<u8> {
|
||
vectorcraft_format::base64_decode(v["dataBase64"].as_str().expect("dataBase64")).unwrap()
|
||
}
|
||
|
||
/// A 40×30 pt document with a red rectangle and a blue ellipse, no strokes.
|
||
fn session() -> Session {
|
||
let mut s = Session::new();
|
||
s.execute("file.new", &json!({"width": 40, "height": 30})).unwrap();
|
||
s.execute("paint.setStroke", &json!({"none": true})).unwrap();
|
||
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
|
||
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 20, "height": 30})).unwrap();
|
||
s.execute("shape.ellipse", &json!({"x": 22, "y": 5, "width": 15, "height": 20})).unwrap();
|
||
// Fills the new ellipse (selected).
|
||
s.execute("paint.setFill", &json!({"color": "#0000ff"})).unwrap();
|
||
s
|
||
}
|
||
|
||
/// PNG `PLTE` entries.
|
||
fn plte_len(file: &[u8]) -> usize {
|
||
let i = file.windows(4).position(|w| w == b"PLTE").expect("PLTE");
|
||
u32::from_be_bytes(file[i - 4..i].try_into().unwrap()) as usize / 3
|
||
}
|
||
|
||
#[test]
|
||
fn png8_and_gif_export_palettes() {
|
||
let mut s = session();
|
||
let png8 = b64(&s.execute("document.export", &json!({"format": "png8", "colors": 16})).unwrap());
|
||
assert_eq!(png8[25], 3, "colour type 3");
|
||
assert!(plte_len(&png8) <= 16);
|
||
let img = image::load_from_memory(&png8).unwrap().to_rgba8();
|
||
assert_eq!(img.dimensions(), (40, 30));
|
||
assert_eq!(img.get_pixel(5, 15).0, [255, 0, 0, 255]);
|
||
assert_eq!(img.get_pixel(39, 0)[3], 0, "transparent where there is no art");
|
||
|
||
let gif = b64(&s.execute("document.export", &json!({"format": "gif", "background": "white", "dither": "none"})).unwrap());
|
||
assert_eq!(&gif[..6], b"GIF89a");
|
||
let img = image::load_from_memory_with_format(&gif, image::ImageFormat::Gif).unwrap().to_rgba8();
|
||
assert_eq!(img.get_pixel(5, 15).0, [255, 0, 0, 255]);
|
||
assert_eq!(img.get_pixel(39, 0).0, [255, 255, 255, 255], "the white background");
|
||
|
||
// Black & White: two entries whatever the art.
|
||
let bw = b64(&s.execute("document.export", &json!({"format": "png8", "reduction": "blackWhite", "transparency": false})).unwrap());
|
||
assert!(plte_len(&bw) <= 2);
|
||
for bad in [json!({"reduction": "octree"}), json!({"dither": "lots"}), json!({"matte": "plaid"})] {
|
||
assert!(s.execute("document.export", &merge(json!({"format": "gif"}), bad.clone())).is_err(), "{bad}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn palette_formats_reach_export_as_and_screens() {
|
||
let mut s = session();
|
||
// A .png path stays PNG; png8 is asked for by format.
|
||
let r = s.execute("document.formats", &json!({})).unwrap();
|
||
let f = |id: &str| r["formats"].as_array().unwrap().iter().find(|f| f["id"] == id).unwrap().clone();
|
||
assert_eq!((f("png8")["extensions"][0].as_str(), f("png8")["read"].as_bool()), (Some("png"), Some(false)));
|
||
assert_eq!(f("gif")["write"], true);
|
||
for k in ["colors", "reduction", "dither", "ditherAmount", "transparency", "matte", "interlaced"] {
|
||
assert!(f("gif")["options"].get(k).is_some() && f("png8")["options"].get(k).is_some(), "{k}");
|
||
}
|
||
assert_eq!(format_for_name("a.png").unwrap().id, "png");
|
||
let r = s.execute("document.exportForScreens", &json!({"formats": [{"format": "gif", "scale": 2}, {"format": "png8"}]})).unwrap();
|
||
let names: Vec<&str> = r["files"].as_array().unwrap().iter().map(|f| f["name"].as_str().unwrap()).collect();
|
||
assert_eq!(names, ["Artboard-1@2x.gif", "Artboard-1.png"]);
|
||
let gif = vectorcraft_format::base64_decode(r["files"][0]["dataBase64"].as_str().unwrap()).unwrap();
|
||
assert_eq!(image::load_from_memory(&gif).unwrap().width(), 80);
|
||
}
|