mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-29 12:17:14 -06:00
M4.34: raster export options and PNG Options — resolution, background, anti-aliasing, interlacing
PNG is written by an in-code chunk writer with the resolution in pHYs and optional Adam7 interlacing (JPEG stores the resolution in JFIF). document.export takes ppi (72/150/300/other, wins over scale), background (transparent, white, black or a colour) and antiAlias (none, art, type: text snapped to whole pixels); Export for Screens rows take them too (plus one top-level antiAlias, and a row's ppi names its @Nx suffix), and so does object.rasterize. Hard edges stay hard in every offscreen pass (groups, masks, filters), whatever the thread count. RenderOptions.anti_alias and render::encode::RasterExportOptions; PNG/JPEG/WebP Options dialog (pngOptions/jpgOptions/webpOptions) after File > Export As PNG. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
bba9d2ceb8
commit
157724279f
@@ -0,0 +1,113 @@
|
||||
//! Raster export: render a document region with [`RasterExportOptions`] (resolution, background,
|
||||
//! anti-aliasing) and encode it as PNG ([`png`]: resolution in `pHYs`, Adam7 interlacing), JPEG
|
||||
//! (resolution in the JFIF header) or lossless WebP.
|
||||
|
||||
pub mod png;
|
||||
|
||||
use vectorcraft_doc::Document;
|
||||
use vectorcraft_geom::Rect;
|
||||
|
||||
use crate::{AntiAlias, RenderOptions, Rendered, Renderer};
|
||||
|
||||
/// A raster file format.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum RasterFormat {
|
||||
Png,
|
||||
Jpeg,
|
||||
WebP,
|
||||
}
|
||||
|
||||
/// How a raster export renders and encodes.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct RasterExportOptions {
|
||||
/// Resolution in pixels per inch: 72 renders one pixel per point. Stored in the file.
|
||||
pub ppi: f64,
|
||||
/// Opaque background colour (RGB); `None` = transparent (JPEG has no alpha: white).
|
||||
pub background: Option<[u8; 3]>,
|
||||
pub anti_alias: AntiAlias,
|
||||
/// PNG: Adam7 interlacing.
|
||||
pub interlaced: bool,
|
||||
/// JPEG quality 1–100.
|
||||
pub quality: u8,
|
||||
}
|
||||
|
||||
impl Default for RasterExportOptions {
|
||||
fn default() -> Self {
|
||||
Self { ppi: 72.0, background: None, anti_alias: AntiAlias::Art, interlaced: false, quality: 90 }
|
||||
}
|
||||
}
|
||||
|
||||
impl RasterExportOptions {
|
||||
/// Pixels per point.
|
||||
pub fn scale(&self) -> f64 {
|
||||
self.ppi / 72.0
|
||||
}
|
||||
|
||||
/// What the renderer draws for `format`: template layers left out, over the background
|
||||
/// (white for a JPEG without one).
|
||||
pub fn render_options(&self, format: RasterFormat) -> RenderOptions {
|
||||
let background = self.background.or((format == RasterFormat::Jpeg).then_some([255; 3]));
|
||||
RenderOptions {
|
||||
background: background.map(|[r, g, b]| [r, g, b, 255]),
|
||||
skip_templates: true,
|
||||
anti_alias: self.anti_alias,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Encode a rendered image as `format`.
|
||||
pub fn encode(&self, img: &Rendered, format: RasterFormat) -> Result<Vec<u8>, String> {
|
||||
match format {
|
||||
RasterFormat::Png => {
|
||||
png::encode(&img.to_straight(), img.width, img.height, &png::PngOptions { ppi: Some(self.ppi), interlaced: self.interlaced })
|
||||
}
|
||||
RasterFormat::Jpeg => jpeg(img, self.quality, Some(self.ppi)),
|
||||
RasterFormat::WebP => webp(img),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Renderer {
|
||||
/// Render `region` of `doc` (an artboard or any rect) as exported and encode it as `format`.
|
||||
/// Callers check the size first ([`crate::raster_size`]).
|
||||
pub fn export_region(&mut self, doc: &Document, region: Rect, format: RasterFormat, opts: &RasterExportOptions) -> Result<Vec<u8>, String> {
|
||||
let img = self.render_region_with(doc, region, opts.scale(), &opts.render_options(format));
|
||||
opts.encode(&img, format)
|
||||
}
|
||||
}
|
||||
|
||||
/// JPEG at `quality` 1–100, partly transparent pixels flattened on white, with the resolution
|
||||
/// `ppi` (if any) in the JFIF header.
|
||||
pub(crate) fn jpeg(img: &Rendered, quality: u8, ppi: Option<f64>) -> Result<Vec<u8>, String> {
|
||||
let rgb: Vec<u8> = img
|
||||
.to_straight()
|
||||
.as_chunks::<4>()
|
||||
.0
|
||||
.iter()
|
||||
.flat_map(|p| {
|
||||
let a = p[3] as u32;
|
||||
let mix = |c: u8| ((c as u32 * a + 255 * (255 - a)) / 255) as u8;
|
||||
[mix(p[0]), mix(p[1]), mix(p[2])]
|
||||
})
|
||||
.collect();
|
||||
let mut buf = Vec::new();
|
||||
let mut enc = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut buf, quality.clamp(1, 100));
|
||||
if let Some(ppi) = ppi {
|
||||
enc.set_pixel_density(image::codecs::jpeg::PixelDensity::dpi(ppi.round().clamp(1.0, u16::MAX as f64) as u16));
|
||||
}
|
||||
image::ImageEncoder::write_image(enc, &rgb, img.width, img.height, image::ExtendedColorType::Rgb8)
|
||||
.map_err(|e| format!("JPEG encoding failed: {e}"))?;
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
/// Lossless WebP.
|
||||
pub(crate) fn webp(img: &Rendered) -> Result<Vec<u8>, String> {
|
||||
let mut buf = Vec::new();
|
||||
let enc = image::codecs::webp::WebPEncoder::new_lossless(&mut buf);
|
||||
image::ImageEncoder::write_image(enc, &img.to_straight(), img.width, img.height, image::ExtendedColorType::Rgba8)
|
||||
.map_err(|e| format!("WebP encoding failed: {e}"))?;
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,160 @@
|
||||
//! 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.
|
||||
|
||||
use std::borrow::Cow;
|
||||
use std::io::Write as _;
|
||||
|
||||
use flate2::Crc;
|
||||
use flate2::write::ZlibEncoder;
|
||||
|
||||
/// What goes into the file besides the pixels.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct PngOptions {
|
||||
/// Pixels per inch, stored as pixels per metre in `pHYs` (`None`: no `pHYs` chunk).
|
||||
pub ppi: Option<f64>,
|
||||
/// Adam7 interlacing.
|
||||
pub interlaced: bool,
|
||||
}
|
||||
|
||||
const SIGNATURE: &[u8; 8] = b"\x89PNG\r\n\x1a\n";
|
||||
/// Most image data bytes per `IDAT` chunk.
|
||||
const IDAT_MAX: usize = 1 << 20;
|
||||
/// Adam7 passes: (x0, y0, dx, dy).
|
||||
const ADAM7: [(u32, u32, u32, u32); 7] = [(0, 0, 8, 8), (4, 0, 8, 8), (0, 4, 4, 8), (2, 0, 4, 4), (0, 2, 2, 4), (1, 0, 2, 2), (0, 1, 1, 2)];
|
||||
|
||||
/// Pixels per metre for a resolution in pixels per inch (the unit `pHYs` stores).
|
||||
pub fn pixels_per_metre(ppi: f64) -> u32 {
|
||||
(ppi / 0.0254).round().clamp(1.0, u32::MAX as f64) as u32
|
||||
}
|
||||
|
||||
/// A whole `pHYs` chunk (length, type, data, CRC) declaring `x` × `y` pixels per inch.
|
||||
pub fn phys_chunk(x: f64, y: f64) -> Vec<u8> {
|
||||
let [x, y] = [x, y].map(|v| pixels_per_metre(v).to_be_bytes());
|
||||
let mut out = Vec::with_capacity(21);
|
||||
// x, y, unit 1 = metre.
|
||||
chunk(&mut out, b"pHYs", &[x.as_slice(), y.as_slice(), &[1]].concat());
|
||||
out
|
||||
}
|
||||
|
||||
/// 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"));
|
||||
}
|
||||
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());
|
||||
}
|
||||
let px = rgba.as_chunks::<4>().0;
|
||||
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) };
|
||||
|
||||
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)]);
|
||||
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 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);
|
||||
}
|
||||
}
|
||||
raw
|
||||
} else {
|
||||
let mut raw = Vec::with_capacity(pixels.len() + height as usize);
|
||||
filter_rows(&pixels, width, height, channels, &mut raw);
|
||||
raw
|
||||
};
|
||||
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) {
|
||||
chunk(&mut out, b"IDAT", part);
|
||||
}
|
||||
chunk(&mut out, b"IEND", &[]);
|
||||
Ok(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());
|
||||
out.extend_from_slice(ty);
|
||||
out.extend_from_slice(data);
|
||||
let mut crc = Crc::new();
|
||||
crc.update(ty);
|
||||
crc.update(data);
|
||||
out.extend_from_slice(&crc.sum().to_be_bytes());
|
||||
}
|
||||
|
||||
/// The pixels of one Adam7 pass as their own image (`None` when the pass is empty).
|
||||
fn sub_image(px: &[u8], width: u32, height: u32, channels: usize, (x0, y0, dx, dy): (u32, u32, u32, u32)) -> Option<(u32, u32, Vec<u8>)> {
|
||||
let w = width.saturating_sub(x0).div_ceil(dx);
|
||||
let h = height.saturating_sub(y0).div_ceil(dy);
|
||||
if w == 0 || h == 0 {
|
||||
return None;
|
||||
}
|
||||
let mut out = Vec::with_capacity(w as usize * h as usize * channels);
|
||||
for y in (y0..height).step_by(dy as usize) {
|
||||
let row = &px[y as usize * width as usize * channels..][..width as usize * channels];
|
||||
for x in (x0..width).step_by(dx as usize) {
|
||||
out.extend_from_slice(&row[x as usize * channels..][..channels]);
|
||||
}
|
||||
}
|
||||
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;
|
||||
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 {
|
||||
let cur = &px[y * stride..][..stride];
|
||||
let prev = if y == 0 { &zero[..] } else { &px[(y - 1) * stride..][..stride] };
|
||||
// The first pixel has no left neighbour (a = c = 0).
|
||||
let (head, tail) = (bpp.min(stride), stride.saturating_sub(bpp));
|
||||
let [none, sub, up, avg, pae] = &mut cand;
|
||||
none.copy_from_slice(cur);
|
||||
sub[..head].copy_from_slice(&cur[..head]);
|
||||
for i in 0..stride {
|
||||
up[i] = cur[i].wrapping_sub(prev[i]);
|
||||
}
|
||||
for i in 0..head {
|
||||
avg[i] = cur[i].wrapping_sub(prev[i] / 2);
|
||||
pae[i] = cur[i].wrapping_sub(paeth(0, prev[i], 0));
|
||||
}
|
||||
for j in 0..tail {
|
||||
let i = j + bpp;
|
||||
let (a, b, c) = (cur[j], prev[i], prev[j]);
|
||||
sub[i] = cur[i].wrapping_sub(a);
|
||||
avg[i] = cur[i].wrapping_sub(((a as u16 + b as u16) / 2) as u8);
|
||||
pae[i] = cur[i].wrapping_sub(paeth(a, b, c));
|
||||
}
|
||||
let cost = |c: &[u8]| c.iter().map(|&v| (v as i8).unsigned_abs() as u64).sum::<u64>();
|
||||
let best = (0..5).min_by_key(|&f| cost(&cand[f])).unwrap_or(0);
|
||||
out.push(best as u8);
|
||||
out.extend_from_slice(&cand[best]);
|
||||
}
|
||||
}
|
||||
|
||||
fn paeth(a: u8, b: u8, c: u8) -> u8 {
|
||||
let p = a as i16 + b as i16 - c as i16;
|
||||
let (pa, pb, pc) = ((p - a as i16).abs(), (p - b as i16).abs(), (p - c as i16).abs());
|
||||
if pa <= pb && pa <= pc {
|
||||
a
|
||||
} else if pb <= pc {
|
||||
b
|
||||
} else {
|
||||
c
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::{Appearance, CharStyle, Node, NodeKind, TextObject};
|
||||
use vectorcraft_geom::{Point, shapes};
|
||||
|
||||
use super::png::{PngOptions, encode, pixels_per_metre};
|
||||
use super::*;
|
||||
|
||||
/// A chunk's data by type (first match).
|
||||
fn chunk_data<'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
|
||||
}
|
||||
|
||||
/// Deterministic straight-alpha noise with some fully transparent and some opaque pixels.
|
||||
fn noise(w: u32, h: u32, opaque: bool) -> Vec<u8> {
|
||||
let mut x: u32 = 0x1234_5678;
|
||||
(0..w * h * 4)
|
||||
.map(|i| {
|
||||
x ^= x << 13;
|
||||
x ^= x >> 17;
|
||||
x ^= x << 5;
|
||||
if i % 4 == 3 && opaque { 255 } else { (x >> 24) as u8 }
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn decode(png: &[u8]) -> Vec<u8> {
|
||||
image::load_from_memory_with_format(png, image::ImageFormat::Png).expect("decodes").to_rgba8().into_raw()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn png_round_trips_plain_and_interlaced_at_odd_sizes() {
|
||||
for (w, h) in [(1, 1), (3, 5), (9, 9), (17, 2), (8, 8), (33, 31)] {
|
||||
for opaque in [false, true] {
|
||||
let px = noise(w, h, opaque);
|
||||
for interlaced in [false, true] {
|
||||
let file = encode(&px, w, h, &PngOptions { ppi: None, interlaced }).unwrap();
|
||||
assert_eq!(&file[..8], b"\x89PNG\r\n\x1a\n");
|
||||
assert_eq!(file[28], u8::from(interlaced), "IHDR interlace method");
|
||||
assert_eq!(file[25], if opaque { 2 } else { 6 }, "opaque images are written as RGB");
|
||||
assert_eq!(decode(&file), px, "{w}×{h} opaque={opaque} interlaced={interlaced}");
|
||||
assert!(chunk_data(&file, b"pHYs").is_none());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn phys_holds_pixels_per_metre() {
|
||||
let file = encode(&noise(2, 2, true), 2, 2, &PngOptions { ppi: Some(300.0), interlaced: false }).unwrap();
|
||||
let phys = chunk_data(&file, b"pHYs").expect("pHYs");
|
||||
let ppm = (300.0f64 / 0.0254).round() as u32;
|
||||
assert_eq!(ppm, pixels_per_metre(300.0));
|
||||
assert_eq!(phys, [ppm.to_be_bytes().as_slice(), ppm.to_be_bytes().as_slice(), &[1]].concat());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn png_refuses_a_buffer_of_the_wrong_size() {
|
||||
assert!(encode(&[0; 12], 2, 2, &PngOptions::default()).is_err());
|
||||
assert!(encode(&[], 0, 0, &PngOptions::default()).is_err());
|
||||
}
|
||||
|
||||
/// A 40×30 pt document with a black circle off the pixel grid.
|
||||
fn circle_doc() -> Document {
|
||||
let mut d = Document::new(40.0, 30.0);
|
||||
let id = d.alloc_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));
|
||||
let l = d.layers[0].id;
|
||||
d.insert(Some(l), 0, n).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
fn export(d: &Document, format: RasterFormat, o: &RasterExportOptions) -> image::RgbaImage {
|
||||
let bytes = Renderer::new().export_region(d, d.artboards[0].rect, format, o).unwrap();
|
||||
image::load_from_memory(&bytes).unwrap().to_rgba8()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolution_sets_the_pixel_size_and_is_stored() {
|
||||
let d = circle_doc();
|
||||
assert_eq!(export(&d, RasterFormat::Png, &RasterExportOptions::default()).dimensions(), (40, 30));
|
||||
let o = RasterExportOptions { ppi: 144.0, ..Default::default() };
|
||||
assert_eq!(export(&d, RasterFormat::Png, &o).dimensions(), (80, 60), "144 ppi doubles the size");
|
||||
let png = Renderer::new().export_region(&d, d.artboards[0].rect, RasterFormat::Png, &o).unwrap();
|
||||
assert_eq!(&chunk_data(&png, b"pHYs").unwrap()[..4], pixels_per_metre(144.0).to_be_bytes());
|
||||
let jpg = Renderer::new().export_region(&d, d.artboards[0].rect, RasterFormat::Jpeg, &o).unwrap();
|
||||
// JFIF APP0: units 1 (dots per inch), then the x and y density.
|
||||
let app0 = jpg.windows(5).position(|w| w == b"JFIF\0").unwrap();
|
||||
assert_eq!(&jpg[app0 + 7..app0 + 12], [1, 0, 144, 0, 144]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backgrounds_are_opaque_or_transparent() {
|
||||
let d = circle_doc();
|
||||
let transparent = export(&d, RasterFormat::Png, &RasterExportOptions::default());
|
||||
assert_eq!(transparent.get_pixel(0, 0)[3], 0);
|
||||
for (bg, want) in [([255, 255, 255], [255, 255, 255, 255]), ([0, 0, 0], [0, 0, 0, 255]), ([30, 120, 200], [30, 120, 200, 255])] {
|
||||
let img = export(&d, RasterFormat::Png, &RasterExportOptions { background: Some(bg), ..Default::default() });
|
||||
assert!(img.pixels().all(|p| p[3] == 255), "no alpha below 255 on {bg:?}");
|
||||
assert_eq!(img.get_pixel(0, 0).0, want);
|
||||
}
|
||||
let webp = export(&d, RasterFormat::WebP, &RasterExportOptions { background: Some([0, 0, 0]), ..Default::default() });
|
||||
assert_eq!(webp.get_pixel(0, 0).0, [0, 0, 0, 255]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interlaced_export_decodes_to_the_same_pixels() {
|
||||
let d = circle_doc();
|
||||
let plain = Renderer::new().export_region(&d, d.artboards[0].rect, RasterFormat::Png, &RasterExportOptions::default()).unwrap();
|
||||
let o = RasterExportOptions { interlaced: true, ..Default::default() };
|
||||
let laced = Renderer::new().export_region(&d, d.artboards[0].rect, RasterFormat::Png, &o).unwrap();
|
||||
assert_eq!((plain[28], laced[28]), (0, 1));
|
||||
assert_eq!(decode(&plain), decode(&laced));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anti_alias_none_paints_whole_pixels() {
|
||||
let d = circle_doc();
|
||||
let soft = export(&d, RasterFormat::Png, &RasterExportOptions::default());
|
||||
assert!(soft.pixels().any(|p| p[3] != 0 && p[3] != 255), "art anti-aliasing has partial edge pixels");
|
||||
let hard = export(&d, RasterFormat::Png, &RasterExportOptions { anti_alias: AntiAlias::None, ..Default::default() });
|
||||
assert!(hard.pixels().all(|p| p[3] == 0 || p[3] == 255), "no anti-aliasing: every alpha is 0 or 255");
|
||||
let covered = |img: &image::RgbaImage| img.pixels().filter(|p| p[3] >= 128).count() as i64;
|
||||
assert!((covered(&hard) - covered(&soft)).abs() <= 8, "the same shape, only harder edges");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_anti_aliasing_snaps_text_to_pixels() {
|
||||
let mut d = Document::new(80.0, 30.0);
|
||||
let id = d.alloc_id();
|
||||
let t = TextObject::point(Point::new(4.37, 18.61), "Hill", CharStyle { size: 12.0, ..CharStyle::default() });
|
||||
let n = Node::new(id, NodeKind::Text(Box::new(t)));
|
||||
let l = d.layers[0].id;
|
||||
d.insert(Some(l), 0, n).unwrap();
|
||||
let art = export(&d, RasterFormat::Png, &RasterExportOptions::default());
|
||||
let typ = export(&d, RasterFormat::Png, &RasterExportOptions { anti_alias: AntiAlias::Type, ..Default::default() });
|
||||
assert!(art.pixels().any(|p| p[3] > 0), "the text is drawn");
|
||||
assert_ne!(art, typ, "type moves to whole pixels");
|
||||
// Snapping moves glyphs by under a pixel: the ink stays where it was.
|
||||
let ink = |img: &image::RgbaImage| img.pixels().map(|p| p[3] as u64).sum::<u64>() as f64;
|
||||
assert!((ink(&typ) / ink(&art) - 1.0).abs() < 0.1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anti_alias_none_is_the_same_on_every_thread_count() {
|
||||
// Glows are filtered offscreen when the renderer is multithreaded: their clip must be hard too.
|
||||
let mut d = Document::new(80.0, 80.0);
|
||||
let id = d.alloc_id();
|
||||
let mut n = Node::path(id, shapes::rectangle(Rect::new(20.5, 20.3, 60.4, 59.6)), Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0));
|
||||
let glow = serde_json::json!({"color": "#ffffff", "mode": "normal", "opacity": 100, "blur": 4});
|
||||
n.appearance.effects = vec![vectorcraft_doc::Effect { id: "stylize.innerGlow".into(), params: glow, visible: true }];
|
||||
let l = d.layers[0].id;
|
||||
d.insert(Some(l), 0, n).unwrap();
|
||||
let opts = RenderOptions { anti_alias: AntiAlias::None, ..Default::default() };
|
||||
let render = |threads: u16| {
|
||||
let mut r = Renderer::new();
|
||||
r.threads = threads;
|
||||
r.render_region_with(&d, d.artboards[0].rect, 1.0, &opts)
|
||||
};
|
||||
let (st, mt) = (render(0), render(3));
|
||||
for img in [&st, &mt] {
|
||||
assert!(img.to_straight().chunks_exact(4).all(|p| p[3] == 0 || p[3] == 255), "hard edges");
|
||||
}
|
||||
let worst = st.pixels.iter().zip(&mt.pixels).map(|(a, b)| a.abs_diff(*b)).max().unwrap();
|
||||
assert!(worst <= 4, "max channel difference {worst}");
|
||||
}
|
||||
Reference in New Issue
Block a user