mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-26 10:47:11 -06:00
M4.90: Save for Web (Legacy) previews and saves optimised GIF, JPEG, PNG-8 and PNG-24 images, slices and HTML
File > Export > Save for Web (Legacy) (Ctrl/Cmd+Alt+Shift+S) opens a dialog with Original, Optimized, 2-Up and 4-Up views showing each file's size and download time, and settings for colour reduction, colours, dither, transparency and matte, interlacing, web snap, lossy GIF, JPEG quality, the colour table (lock, map to transparent, web shift, sort), image size and anti-aliasing, Clip to Artboard, sRGB, metadata, slices (all or selected), HTML output and presets. Preview in Browser opens the HTML page. Engine: document.exportForWeb, document.exportForWeb.preview (its byte count is the size of the file Save writes), webExport.settings and webExport.presets.list/save/delete. Saved presets and the remembered settings are stored in Prefs. Render: quantize_locked, and encode::web for web snap, colour table edits, lossy GIF and GIF/JPEG comments. PNG files get an sRGB chunk. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
b578c32f4d
commit
c6450f6f70
@@ -2,7 +2,8 @@
|
||||
//! anti-aliasing) and encode it as PNG ([`png`]: resolution in `pHYs`, Adam7 interlacing), JPEG
|
||||
//! ([`jpeg`]: RGB, CMYK or grey, progressive, resolution and colour profile), lossless WebP, or
|
||||
//! a palette image ([`quantize`]) as PNG-8 or [`gif`], [`tiff`] (RGB, CMYK or grey, LZW, either byte
|
||||
//! order, the profile), [`bmp`] (1–32 bits, RLE) or [`tga`].
|
||||
//! order, the profile), [`bmp`] (1–32 bits, RLE) or [`tga`]; [`web`] adds what Save for Web optimises
|
||||
//! further (web snap, colour table edits, lossy GIF, comments).
|
||||
|
||||
pub mod bmp;
|
||||
pub mod gif;
|
||||
@@ -11,6 +12,7 @@ pub mod png;
|
||||
pub mod quantize;
|
||||
pub mod tga;
|
||||
pub mod tiff;
|
||||
pub mod web;
|
||||
|
||||
use vectorcraft_doc::{Document, Node, NodeKind};
|
||||
use vectorcraft_geom::Rect;
|
||||
@@ -200,3 +202,5 @@ mod tests_palette;
|
||||
mod tests_tga;
|
||||
#[cfg(test)]
|
||||
mod tests_tiff;
|
||||
#[cfg(test)]
|
||||
mod tests_web;
|
||||
|
||||
@@ -135,38 +135,54 @@ fn write(pixels: &[u8], width: u32, height: u32, layout: Layout, extra: &[(&[u8;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The signature and the IHDR chunk (length, type, 13 data bytes, CRC), which comes first.
|
||||
const HEADER: usize = 8 + 12 + 13;
|
||||
|
||||
/// `png` (a PNG file) with a text chunk per `(keyword, text)` right after its header: `tEXt`, or
|
||||
/// uncompressed `iTXt` (UTF-8) for text beyond Latin-1. Keywords are 1–79 printable ASCII
|
||||
/// characters (others are skipped). Unchanged when it doesn't start with a PNG header.
|
||||
pub fn with_text(png: Vec<u8>, entries: &[(&str, Cow<str>)]) -> Vec<u8> {
|
||||
// The signature and the IHDR chunk (length, type, 13 data bytes, CRC), which comes first.
|
||||
const HEADER: usize = 8 + 12 + 13;
|
||||
if entries.is_empty() || !png.starts_with(SIGNATURE) || png.get(12..16) != Some(b"IHDR".as_slice()) || png.len() < HEADER {
|
||||
let chunks: Vec<([u8; 4], Vec<u8>)> = entries
|
||||
.iter()
|
||||
.filter(|(keyword, _)| !keyword.is_empty() && keyword.len() <= 79 && keyword.bytes().all(|b| (32..=126).contains(&b)))
|
||||
.map(|(keyword, text)| {
|
||||
let mut data = keyword.as_bytes().to_vec();
|
||||
data.push(0);
|
||||
let latin1: Option<Vec<u8>> = text.chars().map(|c| u8::try_from(u32::from(c)).ok()).collect();
|
||||
match latin1 {
|
||||
Some(bytes) => {
|
||||
data.extend(bytes);
|
||||
(*b"tEXt", data)
|
||||
}
|
||||
None => {
|
||||
// Uncompressed, no language tag, no translated keyword.
|
||||
data.extend_from_slice(&[0, 0, 0, 0]);
|
||||
data.extend_from_slice(text.as_bytes());
|
||||
(*b"iTXt", data)
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
with_chunks(png, &chunks)
|
||||
}
|
||||
|
||||
/// `png` marked as sRGB (an `sRGB` chunk, perceptual intent) right after its header, so browsers
|
||||
/// show its colours as they are. Unchanged when it doesn't start with a PNG header.
|
||||
pub fn with_srgb(png: Vec<u8>) -> Vec<u8> {
|
||||
with_chunks(png, &[(*b"sRGB", vec![0])])
|
||||
}
|
||||
|
||||
/// `png` with `chunks` (type, data) right after its header; chunks too large for a PNG are
|
||||
/// skipped. Unchanged when there are none or it doesn't start with a PNG header.
|
||||
fn with_chunks(png: Vec<u8>, chunks: &[([u8; 4], Vec<u8>)]) -> Vec<u8> {
|
||||
if chunks.is_empty() || !png.starts_with(SIGNATURE) || png.get(12..16) != Some(b"IHDR".as_slice()) || png.len() < HEADER {
|
||||
return png;
|
||||
}
|
||||
let mut out = Vec::with_capacity(png.len() + entries.iter().map(|(k, v)| k.len() + v.len() + 20).sum::<usize>());
|
||||
let mut out = Vec::with_capacity(png.len() + chunks.iter().map(|(_, d)| d.len() + 12).sum::<usize>());
|
||||
out.extend_from_slice(&png[..HEADER]);
|
||||
for (keyword, text) in entries {
|
||||
if keyword.is_empty() || keyword.len() > 79 || !keyword.bytes().all(|b| (32..=126).contains(&b)) {
|
||||
continue;
|
||||
}
|
||||
let mut data = keyword.as_bytes().to_vec();
|
||||
data.push(0);
|
||||
let latin1: Option<Vec<u8>> = text.chars().map(|c| u8::try_from(u32::from(c)).ok()).collect();
|
||||
let ty = match latin1 {
|
||||
Some(bytes) => {
|
||||
data.extend(bytes);
|
||||
b"tEXt"
|
||||
}
|
||||
None => {
|
||||
// Uncompressed, no language tag, no translated keyword.
|
||||
data.extend_from_slice(&[0, 0, 0, 0]);
|
||||
data.extend_from_slice(text.as_bytes());
|
||||
b"iTXt"
|
||||
}
|
||||
};
|
||||
for (ty, data) in chunks {
|
||||
if u32::try_from(data.len()).is_ok() {
|
||||
chunk(&mut out, ty, &data);
|
||||
chunk(&mut out, ty, data);
|
||||
}
|
||||
}
|
||||
out.extend_from_slice(&png[HEADER..]);
|
||||
|
||||
@@ -145,6 +145,13 @@ pub struct Indexed {
|
||||
|
||||
/// Reduce straight-alpha RGBA pixels (`width`×`height`) to a palette.
|
||||
pub fn quantize(rgba: &[u8], width: u32, height: u32, o: &PaletteOptions) -> Indexed {
|
||||
quantize_locked(rgba, width, height, o, &[])
|
||||
}
|
||||
|
||||
/// [`quantize`], keeping the `locked` colours in a reduced palette (the colour table's locked
|
||||
/// colours: reducing the colours further never drops them). Art with few enough colours keeps
|
||||
/// them all anyway; locked colours no pixel ends up using are left out.
|
||||
pub fn quantize_locked(rgba: &[u8], width: u32, height: u32, o: &PaletteOptions, locked: &[[u8; 3]]) -> 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);
|
||||
@@ -162,15 +169,29 @@ pub fn quantize(rgba: &[u8], width: u32, height: u32, o: &PaletteOptions) -> Ind
|
||||
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);
|
||||
// Locked colours take their slots first (at least one is left for the reduction).
|
||||
let mut lock: Vec<[u8; 3]> = Vec::with_capacity(locked.len().min(k));
|
||||
for c in locked {
|
||||
if lock.len() + 1 < k && !lock.contains(c) {
|
||||
lock.push(*c);
|
||||
}
|
||||
}
|
||||
let free = k - lock.len();
|
||||
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),
|
||||
Reduction::Gray => ((0..free).map(|i| [(i * 255 / (free - 1).max(1)) as u8; 3]).collect(), false),
|
||||
Reduction::Web => (web(opaque(), free), false),
|
||||
r => match distinct(opaque(), k) {
|
||||
Some(exact) => (exact, true),
|
||||
None => (median_cut(opaque(), k, r), false),
|
||||
None => (median_cut(opaque(), free, r), false),
|
||||
},
|
||||
};
|
||||
if !exact && !lock.is_empty() {
|
||||
palette.retain(|c| !lock.contains(c));
|
||||
lock.append(&mut palette);
|
||||
lock.truncate(k);
|
||||
palette = lock;
|
||||
}
|
||||
if palette.is_empty() {
|
||||
palette.push(matte);
|
||||
}
|
||||
@@ -221,7 +242,7 @@ fn distinct(colors: impl Iterator<Item = [u8; 3]>, k: usize) -> Option<Vec<[u8;
|
||||
}
|
||||
|
||||
/// A web-safe colour: each channel a multiple of 51.
|
||||
fn web_safe(c: [u8; 3]) -> [u8; 3] {
|
||||
pub fn web_safe(c: [u8; 3]) -> [u8; 3] {
|
||||
c.map(|v| ((v as u32 + 25) / 51 * 51) as u8)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
use super::quantize::{Dither, Indexed, PaletteOptions, Reduction, quantize, quantize_locked};
|
||||
use super::web::{ColorSort, gif_comment, jpeg_comment, lossy, snap, sort, to_transparent};
|
||||
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)| [f(x, y)[0], f(x, y)[1], f(x, y)[2], 255]).collect()
|
||||
}
|
||||
|
||||
fn colours(ix: &Indexed) -> Vec<[u8; 3]> {
|
||||
ix.indices.iter().map(|&i| ix.palette[i as usize]).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locked_colours_survive_a_reduction() {
|
||||
let px = image(64, 16, |x, y| [(x * 4) as u8, (y * 16) as u8, 90]);
|
||||
let rare = [7, 200, 13];
|
||||
let mut px = px;
|
||||
px[..4].copy_from_slice(&[rare[0], rare[1], rare[2], 255]);
|
||||
let o = PaletteOptions { colors: 4, dither: Dither::None, reduction: Reduction::Adaptive, ..Default::default() };
|
||||
assert!(!quantize(&px, 64, 16, &o).palette.contains(&rare), "a lone pixel loses its colour");
|
||||
let ix = quantize_locked(&px, 64, 16, &o, &[rare]);
|
||||
assert!(ix.palette.contains(&rare) && ix.palette.len() <= 4, "{:?}", ix.palette);
|
||||
assert_eq!(colours(&ix)[0], rare);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapping_to_web_colours() {
|
||||
assert_eq!(snap([50, 103, 0], 0), None);
|
||||
assert_eq!(snap([50, 103, 0], 10), Some([51, 102, 0]));
|
||||
assert_eq!(snap([60, 103, 0], 10), None, "9 levels off is beyond 10%");
|
||||
assert_eq!(snap([60, 103, 0], 100), Some([51, 102, 0]));
|
||||
assert_eq!(snap([25, 25, 25], 100), Some([0, 0, 0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mapped_colours_become_the_transparent_entry() {
|
||||
let px = image(4, 1, |x, _| [[255, 0, 0], [0, 0, 255], [0, 255, 0], [255, 0, 0]][x as usize]);
|
||||
let mut ix = quantize(&px, 4, 1, &PaletteOptions::default());
|
||||
assert_eq!(ix.transparent, None);
|
||||
let red = ix.palette.iter().position(|c| *c == [255, 0, 0]).unwrap();
|
||||
let map = to_transparent(&mut ix, |i| i == red);
|
||||
assert_eq!(map[red], None);
|
||||
assert_eq!(ix.transparent, Some(0));
|
||||
assert_eq!(ix.palette.len(), 3, "the transparent entry and blue and green");
|
||||
assert_eq!((ix.indices[0], ix.indices[3]), (0, 0));
|
||||
assert_eq!(ix.palette[ix.indices[1] as usize], [0, 0, 255]);
|
||||
// Mapping nothing changes nothing.
|
||||
let before = ix.clone();
|
||||
to_transparent(&mut ix, |_| false);
|
||||
assert_eq!(ix, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sorting_keeps_every_pixel_colour() {
|
||||
let px: Vec<u8> = image(8, 8, |x, y| [(x * 30) as u8, 255 - (y * 30) as u8, ((x + y) * 15) as u8])
|
||||
.chunks(4)
|
||||
.enumerate()
|
||||
.flat_map(|(i, p)| [p[0], p[1], p[2], if i == 5 { 0 } else { 255 }])
|
||||
.collect();
|
||||
let ix = quantize(&px, 8, 8, &PaletteOptions { colors: 16, ..Default::default() });
|
||||
for order in ColorSort::ALL {
|
||||
let mut s = ix.clone();
|
||||
let new = sort(&mut s, order);
|
||||
assert_eq!(s.transparent, Some(0), "{order:?}: the transparent entry stays first");
|
||||
assert_eq!(new[0], 0);
|
||||
for (i, (&a, &b)) in ix.indices.iter().zip(&s.indices).enumerate() {
|
||||
assert_eq!(ix.palette[a as usize], s.palette[b as usize], "{order:?} pixel {i}");
|
||||
}
|
||||
}
|
||||
let mut s = ix.clone();
|
||||
sort(&mut s, ColorSort::Luminance);
|
||||
let luma = |c: &[u8; 3]| 299 * c[0] as u32 + 587 * c[1] as u32 + 114 * c[2] as u32;
|
||||
assert!(s.palette[1..].windows(2).all(|w| luma(&w[0]) <= luma(&w[1])));
|
||||
assert_eq!(ColorSort::from_id("popularity"), Some(ColorSort::Popularity));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lossy_makes_runs_and_smaller_gifs() {
|
||||
let px = image(96, 64, |x, y| [(x * 2 + y) as u8, (y * 3) as u8, ((x * y) % 255) as u8]);
|
||||
let ix = quantize(&px, 96, 64, &PaletteOptions { colors: 64, dither: Dither::Diffusion, ..Default::default() });
|
||||
let mut lossy_ix = ix.clone();
|
||||
lossy(&mut lossy_ix, &px, 80);
|
||||
let runs = |ix: &Indexed| ix.indices.windows(2).filter(|w| w[0] != w[1]).count();
|
||||
assert!(runs(&lossy_ix) < runs(&ix));
|
||||
let size = |ix: &Indexed| gif::encode(ix, false).unwrap().len();
|
||||
assert!(size(&lossy_ix) < size(&ix), "{} vs {}", size(&lossy_ix), size(&ix));
|
||||
let mut none = ix.clone();
|
||||
lossy(&mut none, &px, 0);
|
||||
assert_eq!(none, ix);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comments_are_valid_files() {
|
||||
let ix = quantize(&image(5, 4, |x, _| [x as u8 * 40, 0, 0]), 5, 4, &PaletteOptions::default());
|
||||
let file = gif_comment(gif::encode(&ix, false).unwrap(), &"Copyright: Someone ".repeat(20));
|
||||
assert!(file.windows(2).any(|w| w == [0x21, 0xFE]));
|
||||
assert_eq!(image::load_from_memory(&file).unwrap().width(), 5);
|
||||
let px = vec![200u8; 5 * 4 * 3];
|
||||
let jpg = jpeg::encode(&px, 5, 4, 80, Some(72.0), &jpeg::JpegOptions::default()).unwrap();
|
||||
let with = jpeg_comment(jpg.clone(), "Copyright: Someone");
|
||||
assert_eq!(with.len(), jpg.len() + 4 + 18);
|
||||
assert!(with.windows(2).any(|w| w == [0xFF, 0xFE]));
|
||||
assert_eq!(image::load_from_memory(&with).unwrap().height(), 4);
|
||||
assert_eq!(jpeg_comment(vec![1, 2, 3], "x"), vec![1, 2, 3], "not a JPEG");
|
||||
let png = png::with_srgb(png::encode(&[0, 0, 0, 255], 1, 1, &png::PngOptions::default()).unwrap());
|
||||
assert!(png.windows(4).any(|w| w == b"sRGB"));
|
||||
assert_eq!(image::load_from_memory(&png).unwrap().width(), 1);
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
//! Web image optimisation on top of [`super::quantize`] (Save for Web): web snap, the colour
|
||||
//! table's edits (map to transparent, sort), lossy GIF compression, and comments in GIF and JPEG
|
||||
//! files.
|
||||
|
||||
use super::quantize::{Indexed, web_safe};
|
||||
|
||||
/// How the colour table is ordered.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum ColorSort {
|
||||
/// As the reduction made it.
|
||||
#[default]
|
||||
None,
|
||||
Hue,
|
||||
Luminance,
|
||||
/// Most used first.
|
||||
Popularity,
|
||||
}
|
||||
|
||||
impl ColorSort {
|
||||
pub const ALL: [ColorSort; 4] = [ColorSort::None, ColorSort::Hue, ColorSort::Luminance, ColorSort::Popularity];
|
||||
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
ColorSort::None => "none",
|
||||
ColorSort::Hue => "hue",
|
||||
ColorSort::Luminance => "luminance",
|
||||
ColorSort::Popularity => "popularity",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
ColorSort::None => "Unsorted",
|
||||
ColorSort::Hue => "Sort by Hue",
|
||||
ColorSort::Luminance => "Sort by Luminance",
|
||||
ColorSort::Popularity => "Sort by Popularity",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_id(s: &str) -> Option<Self> {
|
||||
Self::ALL.into_iter().find(|v| v.id().eq_ignore_ascii_case(s) || v.label().eq_ignore_ascii_case(s))
|
||||
}
|
||||
}
|
||||
|
||||
/// `c` as its nearest web-safe colour when every channel is within `tolerance` percent (0–100) of
|
||||
/// it: 100 snaps every colour, 0 none.
|
||||
pub fn snap(c: [u8; 3], tolerance: u8) -> Option<[u8; 3]> {
|
||||
// The farthest a channel can be from a web-safe value is 25 (half the step of 51).
|
||||
let max = u32::from(tolerance.min(100)) * 26 / 100;
|
||||
let s = web_safe(c);
|
||||
(tolerance > 0 && (0..3).all(|i| u32::from(c[i].abs_diff(s[i])) <= max)).then_some(s)
|
||||
}
|
||||
|
||||
/// Make the palette entries `mapped` names (by index) transparent: their pixels use the
|
||||
/// transparent entry (added at index 0 when there is none) and the entries go. Returns the old
|
||||
/// index → new index map (`None`: the entry went).
|
||||
pub fn to_transparent(ix: &mut Indexed, mapped: impl Fn(usize) -> bool) -> Vec<Option<u8>> {
|
||||
let n = ix.palette.len();
|
||||
let gone: Vec<bool> = (0..n).map(|i| Some(i) != ix.transparent.map(usize::from) && mapped(i)).collect();
|
||||
if !gone.contains(&true) {
|
||||
return (0..n).map(|i| u8::try_from(i).ok()).collect();
|
||||
}
|
||||
let clear = ix.transparent.map(usize::from);
|
||||
let mut palette = Vec::with_capacity(n);
|
||||
// The transparent entry first: the old one, or a new one (the first entry mapped's colour).
|
||||
let first_gone = gone.iter().position(|g| *g).unwrap_or(0);
|
||||
palette.push(ix.palette.get(clear.unwrap_or(first_gone)).copied().unwrap_or_default());
|
||||
let mut map = vec![Some(0u8); n];
|
||||
for (i, c) in ix.palette.iter().enumerate() {
|
||||
if gone[i] || Some(i) == clear {
|
||||
continue;
|
||||
}
|
||||
map[i] = u8::try_from(palette.len()).ok();
|
||||
palette.push(*c);
|
||||
}
|
||||
for v in &mut ix.indices {
|
||||
*v = map.get(*v as usize).copied().flatten().unwrap_or(0);
|
||||
}
|
||||
ix.palette = palette;
|
||||
ix.transparent = Some(0);
|
||||
map.into_iter().enumerate().map(|(i, m)| if gone[i] { None } else { m }).collect()
|
||||
}
|
||||
|
||||
/// Lossy GIF compression: a pixel takes the entry of the pixel left of it when that entry is
|
||||
/// within `amount` (0–100) of its own colour in `rgba` (the straight pixels it was reduced from).
|
||||
/// Longer runs of one index compress better in a GIF, at the cost of some noise.
|
||||
pub fn lossy(ix: &mut Indexed, rgba: &[u8], amount: u8) {
|
||||
let w = ix.width as usize;
|
||||
if amount == 0 || w == 0 || rgba.len() != ix.indices.len() * 4 {
|
||||
return;
|
||||
}
|
||||
// Up to 64 levels apart in each channel at 100.
|
||||
let limit = (f32::from(amount.min(100)) / 100.0 * 64.0).powi(2) * 3.0;
|
||||
let px = rgba.as_chunks::<4>().0;
|
||||
for (row, src) in ix.indices.chunks_mut(w).zip(px.chunks(w)) {
|
||||
for x in 1..row.len() {
|
||||
let (prev, cur) = (row[x - 1], row[x]);
|
||||
if prev == cur || ix.transparent.is_some_and(|t| t == prev || t == cur) {
|
||||
continue;
|
||||
}
|
||||
let (Some(c), Some(p)) = (src.get(x), ix.palette.get(prev as usize)) else { continue };
|
||||
let d: f32 = (0..3).map(|i| (f32::from(c[i]) - f32::from(p[i])).powi(2)).sum();
|
||||
if d <= limit {
|
||||
row[x] = prev;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reorder the palette by `order`, the transparent entry staying first. Returns the new order (the
|
||||
/// old index of each entry).
|
||||
pub fn sort(ix: &mut Indexed, order: ColorSort) -> Vec<usize> {
|
||||
let n = ix.palette.len();
|
||||
let identity: Vec<usize> = (0..n).collect();
|
||||
if order == ColorSort::None || n < 2 {
|
||||
return identity;
|
||||
}
|
||||
let mut count = vec![0u64; n];
|
||||
for &v in &ix.indices {
|
||||
if let Some(c) = count.get_mut(v as usize) {
|
||||
*c += 1;
|
||||
}
|
||||
}
|
||||
let fixed = ix.transparent.map(usize::from);
|
||||
let mut rest: Vec<usize> = identity.into_iter().filter(|i| Some(*i) != fixed).collect();
|
||||
let key = |i: usize| -> (f32, f32) {
|
||||
let [r, g, b] = ix.palette.get(i).copied().unwrap_or_default().map(|v| f32::from(v) / 255.0);
|
||||
let luma = 0.299 * r + 0.587 * g + 0.114 * b;
|
||||
match order {
|
||||
ColorSort::Hue => (hue(r, g, b), luma),
|
||||
ColorSort::Luminance => (luma, 0.0),
|
||||
_ => (-(count.get(i).copied().unwrap_or(0) as f32), luma),
|
||||
}
|
||||
};
|
||||
rest.sort_by(|a, b| {
|
||||
let (x, y) = (key(*a), key(*b));
|
||||
x.0.total_cmp(&y.0).then(x.1.total_cmp(&y.1)).then(a.cmp(b))
|
||||
});
|
||||
let order: Vec<usize> = fixed.into_iter().chain(rest).collect();
|
||||
let mut remap = vec![0u8; n];
|
||||
for (new, &old) in order.iter().enumerate() {
|
||||
if let (Some(r), Ok(v)) = (remap.get_mut(old), u8::try_from(new)) {
|
||||
*r = v;
|
||||
}
|
||||
}
|
||||
ix.palette = order.iter().filter_map(|&i| ix.palette.get(i).copied()).collect();
|
||||
for v in &mut ix.indices {
|
||||
*v = remap.get(*v as usize).copied().unwrap_or(0);
|
||||
}
|
||||
if fixed.is_some() {
|
||||
ix.transparent = Some(0);
|
||||
}
|
||||
order
|
||||
}
|
||||
|
||||
/// Hue in 0..1 (greys first, at -1).
|
||||
fn hue(r: f32, g: f32, b: f32) -> f32 {
|
||||
let (max, min) = (r.max(g).max(b), r.min(g).min(b));
|
||||
let d = max - min;
|
||||
if d <= f32::EPSILON {
|
||||
return -1.0;
|
||||
}
|
||||
let h = if max == r {
|
||||
((g - b) / d).rem_euclid(6.0)
|
||||
} else if max == g {
|
||||
(b - r) / d + 2.0
|
||||
} else {
|
||||
(r - g) / d + 4.0
|
||||
};
|
||||
h / 6.0
|
||||
}
|
||||
|
||||
/// `gif` with a comment extension holding `text` before its trailer (unchanged when it isn't a
|
||||
/// GIF or `text` is empty).
|
||||
pub fn gif_comment(mut gif: Vec<u8>, text: &str) -> Vec<u8> {
|
||||
if text.is_empty() || !gif.starts_with(b"GIF8") || gif.last() != Some(&0x3B) {
|
||||
return gif;
|
||||
}
|
||||
gif.pop();
|
||||
gif.extend_from_slice(&[0x21, 0xFE]);
|
||||
for block in text.as_bytes().chunks(255) {
|
||||
gif.push(block.len() as u8);
|
||||
gif.extend_from_slice(block);
|
||||
}
|
||||
gif.extend_from_slice(&[0x00, 0x3B]);
|
||||
gif
|
||||
}
|
||||
|
||||
/// `jpeg` with a comment segment (`COM`) holding `text` after its JFIF header (unchanged when it
|
||||
/// isn't a JPEG or `text` is empty; text past a segment's 65 533 bytes is cut at a character).
|
||||
pub fn jpeg_comment(jpeg: Vec<u8>, text: &str) -> Vec<u8> {
|
||||
if text.is_empty() || !jpeg.starts_with(&[0xFF, 0xD8]) {
|
||||
return jpeg;
|
||||
}
|
||||
// After SOI, and after the APP0 (JFIF) segment when it comes first.
|
||||
let at = match jpeg.get(2..6) {
|
||||
Some([0xFF, 0xE0, hi, lo]) => 4 + usize::from(u16::from_be_bytes([*hi, *lo])),
|
||||
_ => 2,
|
||||
};
|
||||
if at > jpeg.len() {
|
||||
return jpeg;
|
||||
}
|
||||
let mut end = text.len().min(65_533);
|
||||
while !text.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
let data = &text.as_bytes()[..end];
|
||||
let mut out = Vec::with_capacity(jpeg.len() + data.len() + 4);
|
||||
out.extend_from_slice(&jpeg[..at]);
|
||||
out.extend_from_slice(&[0xFF, 0xFE]);
|
||||
out.extend_from_slice(&((data.len() + 2) as u16).to_be_bytes());
|
||||
out.extend_from_slice(data);
|
||||
out.extend_from_slice(&jpeg[at..]);
|
||||
out
|
||||
}
|
||||
Reference in New Issue
Block a user