mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-25 02:07:09 -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
@@ -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()
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user