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:
bflatastic
2026-10-05 09:14:11 +07:00
co-authored by Claude Opus 5.5
parent bba9d2ceb8
commit 157724279f
23 changed files with 1166 additions and 78 deletions
+113
View File
@@ -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;
+160
View File
@@ -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
}
}
+173
View File
@@ -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}");
}