mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-25 02:07:09 -06:00
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>
110 lines
5.1 KiB
Rust
110 lines
5.1 KiB
Rust
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);
|
||
}
|