mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-03 06:30:00 -06:00
M4.49: PNG-8 and GIF export with a palette quantizer and dithering
document.export {format: "png8" | "gif"} reduces the art to a palette of at most `colors`
(2–256) entries: median cut over a 15-bit histogram refined by k-means (perceptual,
selective or adaptive weighting), or the web-safe, black-and-white or grey palettes; art
with that many colours or fewer keeps them exactly. Dithering is Floyd–Steinberg diffusion,
an 8×8 ordered pattern or noise, with an amount. Pixels under half opacity become one
transparent entry; partly transparent edges blend over the matte. PNG-8 is an indexed PNG
(PLTE, tRNS, 1–8 bits per pixel, Adam7); GIF is a GIF89a (optionally interlaced). Both are in
document.formats, Export As (PNG-8 / GIF Options dialogs) and Export for Screens.
document.formats' writable list and the MCP export tool's format enum gain gif and png8
(their tests list them).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
ba4ed2ff93
commit
ac77b52ad3
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user