diff --git a/Cargo.lock b/Cargo.lock index 821f7a47..69c3ca9a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4234,6 +4234,7 @@ dependencies = [ name = "vectorcraft-render" version = "0.1.1" dependencies = [ + "flate2", "image", "log", "proptest", diff --git a/crates/engine/src/cmd/fileio/encode.rs b/crates/engine/src/cmd/fileio/encode.rs index eedd9917..5c85e291 100644 --- a/crates/engine/src/cmd/fileio/encode.rs +++ b/crates/engine/src/cmd/fileio/encode.rs @@ -5,6 +5,8 @@ use serde::Deserialize; use serde::de::DeserializeOwned; use serde_json::Value; use vectorcraft_doc::Document; +use vectorcraft_render::AntiAlias; +use vectorcraft_render::encode::{RasterExportOptions, RasterFormat}; use super::super::*; use super::Format; @@ -109,12 +111,60 @@ impl Encoded { } #[derive(Default, Deserialize)] -#[serde(default)] +#[serde(default, rename_all = "camelCase")] struct RasterOptions { #[serde(flatten)] boards: ArtboardPick, scale: Option, + /// Pixels per inch (wins over `scale`). + ppi: Option, quality: Option, + background: Option, + anti_alias: Option, + interlaced: Option, +} + +impl RasterOptions { + /// The render and encoder settings: `ppi`, else 72 × `scale`, within 0.01–64 pixels per point; + /// `background`, else `page` (the document's background). + fn settings(&self, page: Option<[u8; 3]>) -> Result { + if let Some(ppi) = self.ppi.filter(|p| !(p.is_finite() && *p > 0.0)) { + return Err(bad(C, format!("ppi must be a positive number, not {ppi}"))); + } + let ppi = self.ppi.unwrap_or(72.0 * self.scale.unwrap_or(1.0)); + Ok(RasterExportOptions { + ppi: (ppi / 72.0).clamp(0.01, 64.0) * 72.0, + background: match &self.background { + Some(v) => background(v).map_err(|e| bad(C, e))?, + None => page, + }, + anti_alias: self.anti_alias.as_deref().map(anti_alias).transpose().map_err(|e| bad(C, e))?.unwrap_or_default(), + interlaced: self.interlaced.unwrap_or(false), + quality: self.quality.unwrap_or(90), + }) + } +} + +/// A raster background: `transparent` (also `none` or null), `white`, `black` or a colour +/// (`"#rrggbb"`, `[r, g, b]` 0–1, `{c, m, y, k}`, `{gray}`) → opaque RGB, `None` = transparent. +pub(crate) fn background(v: &Value) -> std::result::Result, String> { + match v { + Value::Null => Ok(None), + Value::String(s) if s.eq_ignore_ascii_case("transparent") || s.eq_ignore_ascii_case("none") => Ok(None), + Value::String(s) if s.eq_ignore_ascii_case("white") => Ok(Some([255; 3])), + Value::String(s) if s.eq_ignore_ascii_case("black") => Ok(Some([0; 3])), + other => color_value(other) + .map(|c| { + let [r, g, b, _] = c.to_rgba8(1.0); + Some([r, g, b]) + }) + .ok_or_else(|| format!("background {other}: transparent, white, black or a colour such as \"#ff8800\"")), + } +} + +/// An anti-aliasing mode by id: `none`, `art` or `type`. +pub(crate) fn anti_alias(id: &str) -> std::result::Result { + AntiAlias::from_id(id).ok_or_else(|| format!("antiAlias `{id}`: none, art or type")) } fn boards(r: std::result::Result) -> Result { @@ -173,18 +223,19 @@ pub fn encode_all(doc: &Document, format: &str, p: &Value) -> Result { "png" | "jpg" | "webp" => { let o: RasterOptions = options(f, p)?; let region = doc.artboards[boards(o.boards.one(n))?].rect; - let scale = o.scale.unwrap_or(1.0).clamp(0.01, 64.0); + // New Document → Background Contents: White makes the export opaque, unless `background` + // says otherwise (JPEG has no alpha: white either way). + let page = (doc.setup.background == vectorcraft_doc::Background::White).then_some([255; 3]); + let settings = o.settings(page)?; + let scale = settings.scale(); check_format_size(f, region.width() * scale, region.height() * scale)?; vectorcraft_render::raster_size(region, scale).map_err(|e| bad(C, e))?; - // JPEG has no alpha; New Document → Background Contents: White fills the artboard too. - let white = f.id == "jpg" || doc.setup.background == vectorcraft_doc::Background::White; - let img = vectorcraft_render::Renderer::new().render_region(doc, region, scale, white); - match f.id { - "png" => img.to_png(), - "webp" => img.to_webp(), - _ => img.to_jpeg(o.quality.unwrap_or(90)), - } - .map_err(EngineError::Other)? + let format = match f.id { + "png" => RasterFormat::Png, + "jpg" => RasterFormat::Jpeg, + _ => RasterFormat::WebP, + }; + vectorcraft_render::Renderer::new().export_region(doc, region, format, &settings).map_err(EngineError::Other)? } _ => return Err(bad(C, format!("no encoder for {} yet", f.label))), }; diff --git a/crates/engine/src/cmd/fileio/export.rs b/crates/engine/src/cmd/fileio/export.rs index 200c6488..76d1f61f 100644 --- a/crates/engine/src/cmd/fileio/export.rs +++ b/crates/engine/src/cmd/fileio/export.rs @@ -102,7 +102,12 @@ fn screen_format(row: &Value) -> Result { } // Vector formats have no pixel size: scale doesn't apply and adds no @Nx suffix (not even one // left over from a raster row switched to SVG or PDF). - let scale = if format.raster { f64_or(row, "scale", 1.0) } else { 1.0 }; + // A raster row's `ppi` wins over its `scale` (the encoder does the same), so it names the suffix. + let scale = match row.get("ppi").and_then(Value::as_f64) { + _ if !format.raster => 1.0, + Some(ppi) => ppi / 72.0, + None => f64_or(row, "scale", 1.0), + }; let suffix = match str_param(row, "suffix") { Some(s) if !format.raster && is_scale_suffix(s) => String::new(), Some(s) => s.to_string(), @@ -129,10 +134,18 @@ pub(super) fn export_for_screens(s: &mut Session, p: &Value) -> Result { let n = doc.artboards.len(); let pick = if p.is_object() { ArtboardPick::deserialize(p).map_err(|e| bad(C, e.to_string()))? } else { ArtboardPick::default() }; let boards = pick.resolve(n).map_err(|e| bad(C, e))?.unwrap_or_else(|| (0..n).collect()); - let formats: Vec = match p.get("formats").and_then(Value::as_array) { + let mut formats: Vec = match p.get("formats").and_then(Value::as_array) { Some(rows) => rows.iter().map(screen_format).collect::>()?, None => vec![screen_format(&json!({}))?], }; + // A top-level anti-aliasing mode applies to every row that doesn't name its own. + if let Some(aa) = p.get("antiAlias") { + for sf in &mut formats { + if let Some(o) = sf.options.as_object_mut() { + o.entry("antiAlias").or_insert_with(|| aa.clone()); + } + } + } let prefix = str_param(p, "prefix").unwrap_or(""); let folder = str_param(p, "folder"); if let Some(dir) = folder { diff --git a/crates/engine/src/cmd/fileio/mod.rs b/crates/engine/src/cmd/fileio/mod.rs index 4bf58750..3edad4a2 100644 --- a/crates/engine/src/cmd/fileio/mod.rs +++ b/crates/engine/src/cmd/fileio/mod.rs @@ -23,6 +23,7 @@ mod svg; use serde_json::{Value, json}; pub use encode::{ARTBOARD_PARAMS, ArtboardPick, Encoded, encode, encode_all, encode_with_warnings}; +pub(crate) use encode::{anti_alias, background}; pub(crate) use load::source; pub use load::{Loaded, RasterImage, detect, file_name, load, open_bytes, raster_image}; pub use save::{save_encoding, save_format}; @@ -65,7 +66,7 @@ pub fn specs() -> Vec { "Export Document", [], None, - "{path?, format?: svg|svgz|pdf|png|jpg|webp|vectorcraft (default: from the path's extension, else png), artboard?: 0, artboards?: [i…], range?: \"1-3, 5\" | \"all\" (1-based; PDF writes one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), scale?: 1 (raster), quality?: 90 (jpg), SVG options flat or as svg: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers} (see document.formats), …the PDF options of document.exportPdf} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. Never changes the document's path", + "{path?, format?: svg|svgz|pdf|png|jpg|webp|vectorcraft (default: from the path's extension, else png), artboard?: 0, artboards?: [i…], range?: \"1-3, 5\" | \"all\" (1-based; PDF writes one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), raster: ppi?: 72 (pixels per inch, stored in the file; wins over scale), scale?: 1 (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), quality?: 90 (jpg); SVG options flat or as svg: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers} (see document.formats), …the PDF options of document.exportPdf} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. Never changes the document's path", has_doc, export::export ), @@ -92,7 +93,7 @@ pub fn specs() -> Vec { "Export for Screens", ["File", "Export"], None, - "{folder?, artboards?: [index…] | range?: \"1-3\" (default all), formats?: [{format: png|jpg|webp|svg|svgz|pdf, scale?: 1 (raster only), suffix?: \"@2x\" (raster default: @{scale}x when scale ≠ 1; vector formats drop @Nx suffixes)}], prefix?} one file per artboard and format (a PDF holds its artboard alone; artboards with the same name, in any case, get -2, -3…; an unnamed one is Artboard-N) → {files: [path…]}; no folder → {files: [{name, dataBase64}]}", + "{folder?, artboards?: [index…] | range?: \"1-3\" (default all), formats?: [{format: png|jpg|webp|svg|svgz|pdf, scale?: 1 (raster only), ppi?: (raster: wins over scale, scale = ppi / 72), suffix?: \"@2x\" (raster default: @{scale}x when scale ≠ 1; vector formats drop @Nx suffixes), …the format's options (antiAlias, background…)}], prefix?, antiAlias?: none|art|type (raster rows without their own)} one file per artboard and format (a PDF holds its artboard alone; artboards with the same name, in any case, get -2, -3…; an unnamed one is Artboard-N) → {files: [path…]}; no folder → {files: [{name, dataBase64}]}", has_doc, export::export_for_screens ), @@ -177,6 +178,26 @@ const RANGE: FormatOption = FormatOption { }; const SCALE: FormatOption = FormatOption { name: "scale", ty: "number", default: "1", description: "pixels per point (0.01–64)" }; const QUALITY: FormatOption = FormatOption { name: "quality", ty: "integer", default: "90", description: "JPEG quality 1–100" }; +const PPI: FormatOption = FormatOption { + name: "ppi", + ty: "number", + default: "72", + description: "resolution in pixels per inch (72 = one pixel per point; 150, 300…), stored in the file; wins over scale", +}; +const BACKGROUND: FormatOption = FormatOption { + name: "background", + ty: "string", + default: "\"transparent\"", + description: "transparent, white, black or a colour such as \"#ff8800\" (JPEG: white when transparent)", +}; +const ANTI_ALIAS: FormatOption = FormatOption { + name: "antiAlias", + ty: "string", + default: "\"art\"", + description: "none (hard pixel edges), art (smooth edges) or type (smooth, text snapped to whole pixels)", +}; +const INTERLACED: FormatOption = + FormatOption { name: "interlaced", ty: "boolean", default: "false", description: "Adam7 interlacing (the image builds up while it loads)" }; /// A format `document.open` reads but nothing writes yet. const fn reader(id: &'static str, label: &'static str, extensions: &'static [&'static str], mime: &'static str, raster: bool) -> Format { @@ -209,7 +230,16 @@ pub const FORMATS: &[Format] = &[ Format { id: "pdf", label: "PDF", extensions: &["pdf"], mime: "application/pdf", read: true, write: true, raster: false, options: pdf::OPTIONS }, reader("ai", "PDF-compatible .ai", &["ai"], "application/pdf", false), reader("ait", "PDF-compatible .ait template", &["ait"], "application/pdf", false), - Format { id: "png", label: "PNG", extensions: &["png"], mime: "image/png", read: true, write: true, raster: true, options: &[ARTBOARD, SCALE] }, + Format { + id: "png", + label: "PNG", + extensions: &["png"], + mime: "image/png", + read: true, + write: true, + raster: true, + options: &[ARTBOARD, PPI, SCALE, BACKGROUND, ANTI_ALIAS, INTERLACED], + }, Format { id: "jpg", label: "JPEG", @@ -218,7 +248,7 @@ pub const FORMATS: &[Format] = &[ read: true, write: true, raster: true, - options: &[ARTBOARD, SCALE, QUALITY], + options: &[ARTBOARD, PPI, SCALE, BACKGROUND, ANTI_ALIAS, QUALITY], }, reader("gif", "GIF", &["gif"], "image/gif", true), Format { @@ -229,7 +259,7 @@ pub const FORMATS: &[Format] = &[ read: true, write: true, raster: true, - options: &[ARTBOARD, SCALE], + options: &[ARTBOARD, PPI, SCALE, BACKGROUND, ANTI_ALIAS], }, reader("tiff", "TIFF", &["tif", "tiff"], "image/tiff", true), reader("bmp", "BMP", &["bmp"], "image/bmp", true), @@ -418,3 +448,6 @@ mod tests_pdf; mod tests_svg; #[cfg(test)] mod tests_svgedit; + +#[cfg(test)] +mod tests_raster; diff --git a/crates/engine/src/cmd/fileio/ppi.rs b/crates/engine/src/cmd/fileio/ppi.rs index 6f704b9d..de885ca5 100644 --- a/crates/engine/src/cmd/fileio/ppi.rs +++ b/crates/engine/src/cmd/fileio/ppi.rs @@ -159,16 +159,7 @@ fn bmp(b: &[u8]) -> Option<(f64, f64)> { /// `png` with a `pHYs` chunk declaring `ppi` right after the header (any existing one is replaced). pub fn with_png_resolution(png: &[u8], (x, y): (f64, f64)) -> Vec { let Some(head) = be32(png, 8).and_then(|len| slice(png, 0, 20usize.checked_add(len as usize)?)) else { return png.to_vec() }; - let ppm = |v: f64| ((v / INCH_M).round() as u32).to_be_bytes(); - let mut chunk = Vec::with_capacity(21); - chunk.extend_from_slice(&9u32.to_be_bytes()); - chunk.extend_from_slice(b"pHYs"); - chunk.extend_from_slice(&ppm(x)); - chunk.extend_from_slice(&ppm(y)); - chunk.push(1); - let mut crc = flate2::Crc::new(); - crc.update(&chunk[4..]); - chunk.extend_from_slice(&crc.sum().to_be_bytes()); + let chunk = vectorcraft_render::encode::png::phys_chunk(x, y); let mut out = Vec::with_capacity(png.len() + chunk.len()); out.extend_from_slice(head); out.extend_from_slice(&chunk); diff --git a/crates/engine/src/cmd/fileio/tests_raster.rs b/crates/engine/src/cmd/fileio/tests_raster.rs new file mode 100644 index 00000000..fa81265e --- /dev/null +++ b/crates/engine/src/cmd/fileio/tests_raster.rs @@ -0,0 +1,149 @@ +//! Raster export options: resolution (stored in the file), background, anti-aliasing and +//! interlacing, also on Export for Screens and Rasterize. + +use serde_json::{Value, json}; + +use super::*; + +fn session(width: f64, height: f64, artboards: usize) -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": width, "height": height, "artboards": artboards})).unwrap(); + s +} + +fn b64(v: &Value) -> Vec { + vectorcraft_format::base64_decode(v["dataBase64"].as_str().expect("dataBase64")).unwrap() +} + +fn rgba(bytes: &[u8]) -> image::RgbaImage { + image::load_from_memory(bytes).unwrap().to_rgba8() +} + +/// A chunk's data by type (first match) in a PNG file. +fn png_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 png_options_resolution_background_interlace_and_anti_aliasing() { + let mut s = session(40.0, 30.0, 1); + s.execute("paint.setStroke", &json!({"none": true})).unwrap(); + s.execute("shape.ellipse", &json!({"x": 5.3, "y": 4.7, "width": 25.8, "height": 21.5})).unwrap(); + let png = |s: &mut Session, p: Value| b64(&s.execute("document.export", &p).unwrap()); + + let hi = png(&mut s, json!({"format": "png", "ppi": 300})); + let ppm = (300.0f64 / 0.0254).round() as u32; + assert_eq!(&png_chunk(&hi, b"pHYs").unwrap()[..4], ppm.to_be_bytes(), "pHYs = ppi / 0.0254"); + assert_eq!(rgba(&png(&mut s, json!({"format": "png", "ppi": 144}))).dimensions(), (80, 60), "144 ppi doubles the size"); + // scale alone stores 72 × scale. + let scaled = png(&mut s, json!({"format": "png", "scale": 2})); + assert_eq!(&png_chunk(&scaled, b"pHYs").unwrap()[..4], ((144.0f64 / 0.0254).round() as u32).to_be_bytes()); + + let white = rgba(&png(&mut s, json!({"format": "png", "background": "white"}))); + assert!(white.pixels().all(|p| p[3] == 255), "a white background has no alpha below 255"); + assert_eq!(white.get_pixel(0, 0).0, [255, 255, 255, 255]); + assert_eq!(rgba(&png(&mut s, json!({"format": "png", "background": "black"}))).get_pixel(0, 0).0, [0, 0, 0, 255]); + assert_eq!(rgba(&png(&mut s, json!({"format": "png", "background": "#3366cc"}))).get_pixel(0, 0).0, [0x33, 0x66, 0xcc, 255]); + assert_eq!(rgba(&png(&mut s, json!({"format": "png"}))).get_pixel(0, 0)[3], 0, "transparent by default"); + assert_eq!(rgba(&png(&mut s, json!({"format": "jpg", "background": "black"}))).get_pixel(0, 0)[0], 0); + + let laced = png(&mut s, json!({"format": "png", "interlaced": true})); + assert_eq!(laced[28], 1, "IHDR interlace method: Adam7"); + assert_eq!(rgba(&laced), rgba(&png(&mut s, json!({"format": "png"}))), "the interlaced file decodes to the same image"); + + let hard = rgba(&png(&mut s, json!({"format": "png", "antiAlias": "none"}))); + assert!(hard.pixels().all(|p| p[3] == 0 || p[3] == 255), "no anti-aliasing: alpha is 0 or 255"); + let soft = rgba(&png(&mut s, json!({"format": "png", "antiAlias": "art"}))); + assert!(soft.pixels().any(|p| p[3] != 0 && p[3] != 255)); + for bad in [ + json!({"antiAlias": "smooth"}), + json!({"background": "plaid"}), + json!({"interlaced": "yes"}), + json!({"ppi": "high"}), + json!({"ppi": 0}), + json!({"ppi": -72}), + ] { + let mut p = bad.clone(); + p["format"] = json!("png"); + assert!(s.execute("document.export", &p).is_err(), "{bad}"); + } +} + +#[test] +fn screens_and_rasterize_take_anti_aliasing() { + let mut s = session(40.0, 30.0, 1); + s.execute("paint.setStroke", &json!({"none": true})).unwrap(); + let id = s.execute("shape.ellipse", &json!({"x": 5.3, "y": 4.7, "width": 25.8, "height": 21.5})).unwrap()["id"].as_u64().unwrap(); + let hard = |img: &image::RgbaImage| img.pixels().all(|p| p[3] == 0 || p[3] == 255); + let r = s + .execute( + "document.exportForScreens", + &json!({"antiAlias": "none", "formats": [{"format": "png"}, {"format": "png", "scale": 2, "antiAlias": "art"}]}), + ) + .unwrap(); + assert!(hard(&rgba(&b64(&r["files"][0]))), "the top-level mode applies to rows without their own"); + assert!(!hard(&rgba(&b64(&r["files"][1]))), "a row's own mode wins"); + + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + s.execute("object.rasterize", &json!({"ppi": 72, "antiAlias": "none", "background": "transparent"})).unwrap(); + let doc = &s.doc().unwrap().doc; + let vectorcraft_doc::NodeKind::Image(im) = &doc.layers[0].children().unwrap()[0].kind else { panic!("rasterized to an image") }; + assert!(hard(&rgba(&doc.images[&im.key].bytes))); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + s.execute("object.rasterize", &json!({"ppi": 72, "background": "black"})).unwrap(); + let doc = &s.doc().unwrap().doc; + let vectorcraft_doc::NodeKind::Image(im) = &doc.layers[0].children().unwrap()[0].kind else { panic!("rasterized to an image") }; + assert_eq!(rgba(&doc.images[&im.key].bytes).get_pixel(0, 0).0, [0, 0, 0, 255]); + assert!(s.execute("object.rasterize", &json!({"antiAlias": "fuzzy"})).is_err()); +} + +#[test] +fn formats_list_the_raster_options() { + let mut s = Session::new(); + let r = s.execute("document.formats", &json!({})).unwrap(); + let opts = |id: &str| r["formats"].as_array().unwrap().iter().find(|f| f["id"] == id).unwrap()["options"].clone(); + for k in ["ppi", "scale", "background", "antiAlias", "interlaced"] { + assert!(opts("png").get(k).is_some(), "png lists {k}"); + } + assert_eq!(opts("png")["antiAlias"]["default"], "art"); + assert_eq!(opts("png")["ppi"]["default"], 72); + assert!(opts("jpg").get("quality").is_some() && opts("jpg").get("interlaced").is_none()); + assert!(opts("webp").get("antiAlias").is_some() && opts("svg").get("antiAlias").is_none()); +} + +#[test] +fn screens_rows_with_a_resolution_name_their_scale() { + let mut s = session(40.0, 30.0, 1); + let r = s.execute("document.exportForScreens", &json!({"formats": [{"format": "png", "ppi": 144}, {"format": "svg", "ppi": 144}]})).unwrap(); + assert_eq!((r["files"][0]["name"].as_str(), r["files"][1]["name"].as_str()), (Some("Artboard-1@2x.png"), Some("Artboard-1.svg")), "{r}"); + assert_eq!(rgba(&b64(&r["files"][0])).dimensions(), (80, 60)); +} + +#[test] +fn a_300_ppi_export_places_back_at_its_physical_size() { + let mut s = session(72.0, 36.0, 1); + let png = b64(&s.execute("document.export", &json!({"format": "png", "ppi": 300, "background": "white"})).unwrap()); + assert_eq!(rgba(&png).dimensions(), (300, 150)); + assert_eq!(super::ppi::resolution(&png), Some((300.0, 300.0)), "the place reader reads the writer's pHYs"); + let r = s.execute("file.place", &json!({"name": "art.png", "dataBase64": vectorcraft_format::base64_encode(&png)})).unwrap(); + assert!((r["width"].as_f64().unwrap() - 72.0).abs() < 1e-6 && (r["height"].as_f64().unwrap() - 36.0).abs() < 1e-6, "100%: {r}"); +} + +#[test] +fn the_document_background_is_the_default_export_background() { + let mut s = session(40.0, 30.0, 1); + s.execute("document.setup", &json!({"backgroundContents": "white"})).unwrap(); + let png = |s: &mut Session, p: Value| rgba(&b64(&s.execute("document.export", &p).unwrap())); + assert_eq!(png(&mut s, json!({"format": "png"})).get_pixel(0, 0).0, [255, 255, 255, 255], "a white document exports opaque"); + assert_eq!(png(&mut s, json!({"format": "png", "background": "transparent"})).get_pixel(0, 0)[3], 0, "the option wins"); + assert_eq!(png(&mut s, json!({"format": "webp", "background": "black"})).get_pixel(0, 0).0, [0, 0, 0, 255]); +} diff --git a/crates/engine/src/cmd/menucmds.rs b/crates/engine/src/cmd/menucmds.rs index cd2a21cf..a271e506 100644 --- a/crates/engine/src/cmd/menucmds.rs +++ b/crates/engine/src/cmd/menucmds.rs @@ -9,6 +9,7 @@ use vectorcraft_color::{Color, Paint}; use vectorcraft_doc::marks::{MarkStyle, TrimMarks}; use vectorcraft_doc::{Appearance, ImageObject, LiveShape, Node, NodeId, NodeKind}; use vectorcraft_geom::{Affine, Anchor, PathData, Point, Rect, Vec2}; +use vectorcraft_render::encode::{RasterExportOptions, RasterFormat}; use super::edit::{duplicate_in, selected_roots}; use super::*; @@ -89,7 +90,7 @@ pub fn specs() -> Vec { "Rasterize…", ["Object"], None, - "{ppi?: (document raster effects resolution), background?: \"transparent\"|\"white\", padding?: pt} replace the selection with an embedded PNG image → {id, width, height}", + "{ppi?: (document raster effects resolution), background?: transparent|white|black|\"#rrggbb\", antiAlias?: none|art (default)|type (text snapped to pixels), padding?: pt} replace the selection with an embedded PNG image → {id, width, height}", has_selection, rasterize ), @@ -317,12 +318,12 @@ pub(crate) fn unique_key(d: &vectorcraft_doc::Document, stem: &str) -> String { } } -fn render_png(doc: &vectorcraft_doc::Document, region: Rect, scale: f64, white: bool) -> Result<(Vec, u32, u32)> { +fn render_png(doc: &vectorcraft_doc::Document, region: Rect, scale: f64, opts: &vectorcraft_render::RenderOptions) -> Result<(Vec, u32, u32)> { if region.width() * region.height() * scale * scale > MAX_PIXELS || region.width() * scale > 65535.0 || region.height() * scale > 65535.0 { return Err(EngineError::Other("image would be too large; lower the resolution".into())); } let mut r = vectorcraft_render::Renderer::new(); - let img = r.render_region(doc, region, scale, white); + let img = r.render_region_with(doc, region, scale, opts); Ok((img.to_png().map_err(EngineError::Other)?, img.width, img.height)) } @@ -335,7 +336,12 @@ fn rasterize(s: &mut Session, p: &Value) -> Result { return Err(bad(C, "ppi must be between 1 and 2400")); } let pad = f64_or(p, "padding", 0.0).max(0.0); - let white = str_param(p, "background") == Some("white"); + let raster = RasterExportOptions { + ppi, + background: fileio::background(p.get("background").unwrap_or(&Value::Null)).map_err(|e| bad(C, e))?, + anti_alias: str_param(p, "antiAlias").map(fileio::anti_alias).transpose().map_err(|e| bad(C, e))?.unwrap_or_default(), + ..Default::default() + }; let b = st.doc.bounds_of(&roots, true).ok_or_else(|| bad(C, "selection has no bounds"))?.inflate(pad, pad); let scale = ppi / 72.0; // Snap to whole pixels. @@ -349,7 +355,7 @@ fn rasterize(s: &mut Session, p: &Value) -> Result { }) .collect(); let tmp = isolated_doc(&st.doc, nodes); - let (png, w, h) = render_png(&tmp, region, scale, white)?; + let (png, w, h) = render_png(&tmp, region, scale, &raster.render_options(RasterFormat::Png))?; let top = *roots.last().ok_or_else(|| bad(C, "nothing selected"))?; let id = s.edit("Rasterize", |d, sel| { let (par, idx, _) = d.position(top).ok_or(EngineError::NoNode(top))?; @@ -499,7 +505,7 @@ fn crop_image(s: &mut Session, p: &Value) -> Result { bare.visible = true; let tmp = isolated_doc(&st.doc, vec![bare]); let region = Rect::new(r.x0, r.y0, r.x0 + (r.width() * scale).round().max(1.0) / scale, r.y0 + (r.height() * scale).round().max(1.0) / scale); - let (png, w, h) = render_png(&tmp, region, scale, false)?; + let (png, w, h) = render_png(&tmp, region, scale, &RasterExportOptions::default().render_options(RasterFormat::Png))?; s.edit("Crop Image", |d, _| { let key = unique_key(d, "crop"); d.images.insert(key.clone(), vectorcraft_doc::ImageBlob { mime: "image/png".into(), bytes: Arc::new(png) }); diff --git a/crates/render/Cargo.toml b/crates/render/Cargo.toml index e4bc8457..98eaa268 100644 --- a/crates/render/Cargo.toml +++ b/crates/render/Cargo.toml @@ -19,6 +19,7 @@ vello_cpu = { workspace = true } # Filter types (Gaussian blur, drop shadow) are not re-exported by vello_cpu 0.2. vello_common = { version = "0.2", default-features = false } image = { workspace = true } +flate2 = { workspace = true } log = { workspace = true } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] diff --git a/crates/render/src/encode/mod.rs b/crates/render/src/encode/mod.rs new file mode 100644 index 00000000..bbbb3a53 --- /dev/null +++ b/crates/render/src/encode/mod.rs @@ -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, 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, 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) -> Result, String> { + let rgb: Vec = 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, 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; diff --git a/crates/render/src/encode/png.rs b/crates/render/src/encode/png.rs new file mode 100644 index 00000000..eaa541c4 --- /dev/null +++ b/crates/render/src/encode/png.rs @@ -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, + /// 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 { + 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, 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, 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)> { + 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) { + let stride = w as usize * bpp; + let zero = vec![0u8; stride]; + let mut cand: [Vec; 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::(); + 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 + } +} diff --git a/crates/render/src/encode/tests.rs b/crates/render/src/encode/tests.rs new file mode 100644 index 00000000..0b91d3fc --- /dev/null +++ b/crates/render/src/encode/tests.rs @@ -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 { + 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 { + 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::() 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}"); +} diff --git a/crates/render/src/fx.rs b/crates/render/src/fx.rs index 8d1892ce..f82974f3 100644 --- a/crates/render/src/fx.rs +++ b/crates/render/src/fx.rs @@ -306,7 +306,7 @@ impl Renderer { } let (w, h) = (w as u16, h as u16); // Silhouette (the object's painted alpha), offset by the shadow distance. - let mut off = crate::single_threaded_context(w, h); + let mut off = f.offscreen_context(w, h); let crop = Frame { mt: false, view: Affine::translate((-x0, -y0)) * f.view, ..*f }; self.inside_layer(|r| paint(r, &mut off, &shifted(&crop, dx, dy))); off.flush(); @@ -580,7 +580,7 @@ impl Renderer { return; } let (w, h) = (w as u16, h as u16); - let mut off = crate::single_threaded_context(w, h); + let mut off = f.offscreen_context(w, h); let shifted = Frame { mt: false, view: Affine::translate((-x0, -y0)) * f.view, ..*f }; self.inside_layer(|r| draw(r, &mut off, &shifted, None)); off.flush(); diff --git a/crates/render/src/group.rs b/crates/render/src/group.rs index 7703411d..47e147fa 100644 --- a/crates/render/src/group.rs +++ b/crates/render/src/group.rs @@ -11,7 +11,7 @@ use vectorcraft_geom::{Affine, BezPath, FillRule, Rect}; use vello_cpu::peniko::{self, BlendMode, Compose, Mix}; use vello_cpu::{Pixmap, RenderContext}; -use crate::{Frame, Renderer, blend_mode, fill_rule, single_threaded_context}; +use crate::{Frame, Renderer, blend_mode, fill_rule}; /// How a group composites onto what is below it. pub(crate) struct Composite<'a> { @@ -162,7 +162,7 @@ impl Renderer { /// `draw` rendered into a new context of region `r` (frame `sub`), over `backdrop` if any. fn offscreen(&mut self, r: Region, sub: &Frame, backdrop: Option>, draw: &mut Content) -> Pixmap { - let mut ctx = single_threaded_context(r.w, r.h); + let mut ctx = sub.offscreen_context(r.w, r.h); if let Some(b) = &backdrop { draw_pixmap(&mut ctx, b.clone(), (0.0, 0.0)); } diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index d9697d6e..d71935e1 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -7,6 +7,7 @@ #![forbid(unsafe_code)] mod brush_fx; +pub mod encode; mod freeform; mod fx; mod group; @@ -48,16 +49,14 @@ pub fn raster_size(region: Rect, scale: f64) -> Result<(u32, u32), String> { if !(scale.is_finite() && scale > 0.0) { return Err(format!("invalid scale {scale}")); } - let w = (region.width() * scale).round().max(1.0); - let h = (region.height() * scale).round().max(1.0); - let max = f64::from(MAX_RASTER_SIDE); - if !(w <= max && h <= max && w * h <= MAX_RASTER_PIXELS as f64) { + let (w, h) = region_pixels(region, scale); + if !(w <= MAX_RASTER_SIDE && h <= MAX_RASTER_SIDE && u64::from(w) * u64::from(h) <= MAX_RASTER_PIXELS) { return Err(format!( - "the image would be {w:.0} × {h:.0} pixels; raster exports are limited to {MAX_RASTER_SIDE} pixels per side and {} megapixels: lower the scale or resolution", + "the image would be {w} × {h} pixels; raster exports are limited to {MAX_RASTER_SIDE} pixels per side and {} megapixels: lower the scale or resolution", MAX_RASTER_PIXELS / 1_000_000 )); } - Ok((w as u32, h as u32)) + Ok((w, h)) } /// Rendering options. @@ -89,6 +88,54 @@ pub struct RenderOptions { pub mask_view: Option, /// Screen view: highlight substituted fonts and glyphs as Document Setup asks. pub highlight_substitutions: bool, + /// Edge smoothing (raster export option). + pub anti_alias: AntiAlias, +} + +/// How edges are rasterized (raster export option). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum AntiAlias { + /// Hard edges: a pixel is painted when a path, text or image covers at least half of it. + /// Raster effects (blur, shadows, glows) and pattern tiles stay smooth. + None, + /// Smooth edges everywhere. + #[default] + Art, + /// Smooth edges with type snapped to the pixel grid (see `TextLayout::snap_to_pixels`): + /// crisper small text. + Type, +} + +impl AntiAlias { + pub const ALL: [Self; 3] = [Self::None, Self::Art, Self::Type]; + + /// The id used in command params (`none`, `art`, `type`). + pub fn id(self) -> &'static str { + match self { + Self::None => "none", + Self::Art => "art", + Self::Type => "type", + } + } + + /// The mode an id names (any case). + pub fn from_id(id: &str) -> Option { + Self::ALL.into_iter().find(|a| a.id().eq_ignore_ascii_case(id)) + } + + /// The name shown in options dialogs. + pub fn label(self) -> &'static str { + match self { + Self::None => "None", + Self::Art => "Art Optimized", + Self::Type => "Type Optimized", + } + } + + /// vello's aliasing threshold: paint a pixel only above half coverage when edges are hard. + fn threshold(self) -> Option { + (self == Self::None).then_some(127) + } } impl Default for RenderOptions { @@ -106,6 +153,7 @@ impl Default for RenderOptions { tile_edge: vectorcraft_doc::LAYER_COLORS[0].1, mask_view: None, highlight_substitutions: false, + anti_alias: AntiAlias::Art, } } } @@ -131,7 +179,8 @@ impl Rendered { } out } - /// Encode as PNG. + /// Encode as PNG without metadata (thumbnails, screenshots, embedded rasters; exports use + /// [`encode`]). pub fn to_png(&self) -> Result, String> { let mut buf = Vec::new(); let img = image::RgbaImage::from_raw(self.width, self.height, self.to_straight()) @@ -141,30 +190,11 @@ impl Rendered { } /// Encode as JPEG (flattened on white) at `quality` 1..=100. pub fn to_jpeg(&self, quality: u8) -> Result, String> { - let rgba = self.to_straight(); - let rgb: Vec = rgba - .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 enc = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut buf, quality.clamp(1, 100)); - image::ImageEncoder::write_image(enc, &rgb, self.width, self.height, image::ExtendedColorType::Rgb8) - .map_err(|e| format!("JPEG encoding failed: {e}"))?; - Ok(buf) + encode::jpeg(self, quality, None) } /// Encode as lossless WebP. pub fn to_webp(&self) -> Result, String> { - let mut buf = Vec::new(); - let enc = image::codecs::webp::WebPEncoder::new_lossless(&mut buf); - image::ImageEncoder::write_image(enc, &self.to_straight(), self.width, self.height, image::ExtendedColorType::Rgba8) - .map_err(|e| format!("WebP encoding failed: {e}"))?; - Ok(buf) + encode::webp(self) } /// Straight-alpha RGBA at (x, y). pub fn pixel(&self, x: u32, y: u32) -> [u8; 4] { @@ -318,6 +348,21 @@ struct Frame<'a> { ink: Ink, } +impl Frame<'_> { + /// Text space → device pixels when type is snapped to the pixel grid (Type anti-aliasing). + fn text_snap(&self, t: &TextObject) -> Option { + (self.opts.anti_alias == AntiAlias::Type).then(|| self.view * t.xf) + } + + /// A render context for drawing this frame's art offscreen (on the calling thread), with its + /// edge smoothing: hard edges must not depend on where the art is drawn. + fn offscreen_context(&self, w: u16, h: u16) -> RenderContext { + let mut ctx = single_threaded_context(w, h); + ctx.set_aliasing_threshold(self.opts.anti_alias.threshold()); + ctx + } +} + impl Renderer { pub fn new() -> Self { Self { @@ -412,6 +457,8 @@ impl Renderer { } _ => RenderContext::new_with(w, h, vello_cpu::RenderSettings { num_threads: threads, ..Default::default() }), }; + // `reset` keeps the threshold of the previous render: set it every time. + ctx.set_aliasing_threshold(opts.anti_alias.threshold()); if let Some(bg) = opts.background { ctx.set_transform(Affine::IDENTITY); ctx.set_paint(f.ink.fixed(bg)); @@ -478,11 +525,15 @@ impl Renderer { /// Render one artboard (or any document rect) at `scale` pixels per point, transparent or on white, /// as exported: template layers are left out. pub fn render_region(&mut self, doc: &Document, region: Rect, scale: f64, white: bool) -> Rendered { - let w = (region.width() * scale).round().max(1.0) as u32; - let h = (region.height() * scale).round().max(1.0) as u32; - let view = Affine::scale(scale) * Affine::translate((-region.x0, -region.y0)); let opts = RenderOptions { background: white.then_some([255, 255, 255, 255]), skip_templates: true, ..Default::default() }; - self.render(doc, w, h, view, &opts) + self.render_region_with(doc, region, scale, &opts) + } + + /// Render a document rect at `scale` pixels per point with `opts`. + pub fn render_region_with(&mut self, doc: &Document, region: Rect, scale: f64, opts: &RenderOptions) -> Rendered { + let (w, h) = region_pixels(region, scale); + let view = Affine::scale(scale) * Affine::translate((-region.x0, -region.y0)); + self.render(doc, w, h, view, opts) } /// Render a single node (thumbnails, previews) fitted into `size`×`size` pixels. @@ -633,7 +684,7 @@ impl Renderer { /// The coverage of opacity mask `m` per output pixel (row-major): its art rendered offscreen, /// as luminance ([`mask_value`]). fn mask_values(&mut self, f: &Frame, m: &vectorcraft_doc::OpacityMask, w: u16, h: u16) -> Vec { - let mut mctx = single_threaded_context(w, h); + let mut mctx = f.offscreen_context(w, h); // Mask art is a picture of its own: it takes no part in a knockout group around the object, // and its luminance is that of its screen colours. let outer = @@ -682,7 +733,10 @@ impl Renderer { && a.blend == vectorcraft_color::BlendMode::Normal && !fx::has_fx(a) { - let g = self.text_geom_of(a, t); + let g = match f.text_snap(t) { + Some(xf) => Arc::new(text_geom_snapped(t, Some(xf))), + None => self.text_geom_of(a, t), + }; self.draw_text_geom(ctx, f, a, t, &g); self.stats.drawn += 1; return; @@ -1054,7 +1108,7 @@ impl Renderer { } fn draw_text(&mut self, ctx: &mut RenderContext, f: &Frame, n: &Node, t: &TextObject) { - let g = text_geom(t); + let g = text_geom_snapped(t, f.text_snap(t)); self.draw_text_geom(ctx, f, n, t, &g); } @@ -1306,8 +1360,16 @@ struct TextGeom { } fn text_geom(t: &TextObject) -> TextGeom { + text_geom_snapped(t, None) +} + +/// Glyph geometry of `t`; `snap` (text space → device pixels) puts the glyphs on whole pixels. +fn text_geom_snapped(t: &TextObject, snap: Option) -> TextGeom { let db = vectorcraft_text::FontDb::global(); - let layout = vectorcraft_text::layout(db, t); + let mut layout = vectorcraft_text::layout(db, t); + if let Some(xf) = snap { + layout.snap_to_pixels(xf); + } let mut runs = vec![BezPath::new(); t.runs.len()]; let mut all = BezPath::new(); // Per run: is its family missing, and the face its glyphs should come from. @@ -1337,6 +1399,10 @@ fn text_geom(t: &TextObject) -> TextGeom { /// Document Setup's highlight behind substituted fonts and glyphs (screen only). const SUBSTITUTED: peniko::Color = peniko::Color::from_rgba8(255, 120, 190, 110); +/// The pixel size of `region` rendered at `scale` pixels per point (at least 1×1). +pub fn region_pixels(region: Rect, scale: f64) -> (u32, u32) { + ((region.width() * scale).round().max(1.0) as u32, (region.height() * scale).round().max(1.0) as u32) +} fn rects_overlap(a: Rect, b: Rect) -> bool { a.x0 <= b.x1 && b.x0 <= a.x1 && a.y0 <= b.y1 && b.y0 <= a.y1 diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index c3fb1bdc..2cfe13e3 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -20,7 +20,7 @@ pub mod thread; pub use features::OtFeatures; pub use fontdb::{FALLBACK_FAMILY, FontDb, FontFace}; -use kurbo::{BezPath, Point, Rect, Vec2}; +use kurbo::{Affine, BezPath, Point, Rect, Vec2}; pub use layout::{layout, layout_with}; pub use vectorcraft_doc::TextObject; @@ -144,6 +144,29 @@ impl TextLayout { pub fn line_of(&self, byte: usize) -> usize { self.lines.iter().rposition(|l| l.start <= byte).unwrap_or(0) } + /// Snap type to the pixel grid (type-optimized anti-aliasing): move each glyph so its pen + /// position on the baseline lands on a whole device pixel, given `to_device` (text space → + /// device pixels). Whole baselines and glyph starts keep small type crisp and evenly spaced. + /// Only text that is neither rotated nor skewed nor on a path is snapped; returns whether + /// the layout changed. + pub fn snap_to_pixels(&mut self, to_device: Affine) -> bool { + let [a, b, c, d, _, _] = to_device.as_coeffs(); + if self.on_path || b.abs() > 1e-9 || c.abs() > 1e-9 || a.abs() < 1e-12 || d.abs() < 1e-12 { + return false; + } + let mut moved = false; + for g in self.glyphs.iter_mut().filter(|g| g.angle == 0.0) { + let p = to_device * g.origin; + let shift = Vec2::new((p.x.round() - p.x) / a, (p.y.round() - p.y) / d); + if shift == Vec2::ZERO { + continue; + } + g.origin += shift; + g.outline.apply_affine(Affine::translate(shift)); + moved = true; + } + moved + } } fn dir(angle: f64) -> Vec2 { @@ -332,4 +355,6 @@ mod tests; #[cfg(test)] mod tests_scripts; #[cfg(test)] +mod tests_snap; +#[cfg(test)] mod tests_typo; diff --git a/crates/text/src/tests_snap.rs b/crates/text/src/tests_snap.rs new file mode 100644 index 00000000..a5c65dd5 --- /dev/null +++ b/crates/text/src/tests_snap.rs @@ -0,0 +1,39 @@ +//! Pixel snapping for type-optimized anti-aliasing. + +use kurbo::{Affine, Shape}; +use vectorcraft_doc::CharStyle; + +use super::*; + +fn text_at(x: f64, y: f64) -> TextObject { + TextObject::point(Point::new(x, y), "Hill", CharStyle { size: 9.0, ..CharStyle::default() }) +} + +#[test] +fn glyph_starts_land_on_whole_device_pixels() { + let t = text_at(10.3, 20.6); + let mut l = layout(FontDb::global(), &t); + let before: Vec = l.glyphs.iter().map(|g| g.origin).collect(); + let to_device = Affine::scale(2.0) * t.xf; + assert!(l.snap_to_pixels(to_device)); + for (g, was) in l.glyphs.iter().zip(before) { + let p = to_device * g.origin; + assert!((p.x - p.x.round()).abs() < 1e-9 && (p.y - p.y.round()).abs() < 1e-9, "{p:?}"); + assert!((g.origin - was).hypot() <= 0.5f64.hypot(0.5) / 2.0 + 1e-9, "moved at most half a pixel"); + // The outline moved with its pen position. + assert!(!g.outline.elements().is_empty()); + } + let ink = |l: &TextLayout| l.glyphs[0].outline.bounding_box(); + let fresh = layout(FontDb::global(), &t); + let d = ink(&l).origin() - ink(&fresh).origin(); + assert!((d - (l.glyphs[0].origin - fresh.glyphs[0].origin)).hypot() < 1e-9); +} + +#[test] +fn rotated_text_is_left_alone() { + let t = text_at(10.3, 20.6); + let mut l = layout(FontDb::global(), &t); + let before: Vec = l.glyphs.iter().map(|g| g.origin).collect(); + assert!(!l.snap_to_pixels(Affine::rotate(0.3) * t.xf)); + assert_eq!(l.glyphs.iter().map(|g| g.origin).collect::>(), before); +} diff --git a/crates/ui-egui/src/dialogs/mod.rs b/crates/ui-egui/src/dialogs/mod.rs index c3953801..56b38c24 100644 --- a/crates/ui-egui/src/dialogs/mod.rs +++ b/crates/ui-egui/src/dialogs/mod.rs @@ -29,6 +29,7 @@ mod new_document; pub mod new_swatch; mod path_ops; pub mod place; +mod png_options; pub mod recolor; pub mod saturate; mod save_changes; @@ -51,6 +52,7 @@ pub use color_picker::open as open_color_picker; pub use document_setup::open as open_document_setup; pub use effect::open as open_effect_dialog; pub use new_document::{open as open_new_document, preset_card}; +pub use png_options::open as open_raster_options; pub use save_pdf::open as open_save_pdf; pub use tools::open_tool_dialog; @@ -180,6 +182,7 @@ registry! { SvgOptions: [svg_options::KIND] => svg_options::SPEC, NewDocumentMore: [new_document::MORE] => new_document::MORE_SPEC, Place: [place::KIND] => place::SPEC, + RasterOptions: ["pngOptions", "jpgOptions", "webpOptions"] => png_options::SPEC, } /// The spec for a `Dialog::kind` ([`DialogSpec::FORM`] when unregistered). @@ -278,3 +281,5 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { #[cfg(test)] mod tests; +#[cfg(test)] +mod tests_export; diff --git a/crates/ui-egui/src/dialogs/png_options.rs b/crates/ui-egui/src/dialogs/png_options.rs new file mode 100644 index 00000000..4caf6ce3 --- /dev/null +++ b/crates/ui-egui/src/dialogs/png_options.rs @@ -0,0 +1,151 @@ +//! The raster export options: PNG Options (resolution, background, anti-aliasing, interlaced), +//! JPEG Options (quality instead of interlacing; no transparency) and WebP Options. Fields are +//! `document.export` params (format, path, artboard choice…); `__`-prefixed ones are the dialog's. + +use serde::Deserialize; +use serde_json::{Value, json}; +use vectorcraft_engine::cmd::fileio::{self, ArtboardPick, Format}; +use vectorcraft_render::AntiAlias; + +use super::{DialogSpec, form}; +use crate::state::Dialog; +use crate::theme::Tokens; +use crate::{VectorcraftApp, io, widgets}; + +pub(super) const SPEC: DialogSpec = DialogSpec { heading, body, confirm, ok: Some("Export"), min_width: 360.0, ..DialogSpec::FORM }; + +/// Resolution presets (pixels per inch). +const RESOLUTIONS: [f64; 3] = [72.0, 150.0, 300.0]; +/// Their labels, then `Other` (any resolution). +const RESOLUTION_LABELS: [&str; 4] = ["Screen (72 ppi)", "Medium (150 ppi)", "High (300 ppi)", "Other"]; +/// Background param values. +const BACKGROUNDS: [&str; 3] = ["transparent", "white", "black"]; +/// Their labels, then `Other` (a colour). +const BACKGROUND_LABELS: [&str; 4] = ["Transparent", "White", "Black", "Other"]; + +/// The dialog kind of a raster format (`pngOptions`, `jpgOptions`, `webpOptions`). +fn kind(f: &Format) -> String { + format!("{}Options", f.id) +} + +fn format_of(d: &Dialog) -> Option<&'static Format> { + fileio::format(d.kind.strip_suffix("Options")?) +} + +/// Open the options of raster format `f` for an export whose params (format, path, artboard +/// choice) are `params`. +pub fn open(app: &mut VectorcraftApp, f: &Format, mut params: Value) { + let size = app.session.active().and_then(|st| export_size(&st.doc, ¶ms)); + // The document's background (New Document → Background Contents) is the default; JPEG has no alpha. + let white = f.id == "jpg" || app.session.active().is_some_and(|st| st.doc.setup.background == vectorcraft_doc::Background::White); + if let Some(o) = params.as_object_mut() { + o.insert("ppi".into(), json!(72)); + o.insert("background".into(), json!(if white { "white" } else { "transparent" })); + o.insert("antiAlias".into(), json!(AntiAlias::default().id())); + match f.id { + "png" => o.insert("interlaced".into(), json!(false)), + "jpg" => o.insert("quality".into(), json!(90)), + _ => None, + }; + if let Some((w, h)) = size { + o.insert("__size".into(), json!([w, h])); + } + } + app.ui.dialog = Some(Dialog::new(&kind(f), params)); +} + +/// The size in points of the region an export covers: the chosen artboard. +fn export_size(doc: &vectorcraft_doc::Document, p: &Value) -> Option<(f64, f64)> { + let r = doc.artboards.get(ArtboardPick::deserialize(p).ok()?.one(doc.artboards.len()).ok()?)?.rect; + Some((r.width(), r.height())) +} + +fn heading(d: &Dialog) -> String { + format!("{} Options", format_of(d).map_or("Export", |f| f.label)) +} + +fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { + let t = Tokens::get(ui.ctx()); + let id = format_of(d).map_or("png", |f| f.id); + let label = |ui: &mut egui::Ui, text: &str| ui.label(egui::RichText::new(text).color(t.text_dim)); + egui::Grid::new("raster-options").num_columns(2).spacing([10.0, 8.0]).show(ui, |ui| { + label(ui, "Resolution:"); + let ppi = d.f64("ppi", 72.0); + let preset = RESOLUTIONS.iter().position(|p| *p == ppi).filter(|_| !d.bool("__otherPpi")); + ui.horizontal(|ui| { + if let Some(i) = widgets::dropdown(ui, "ro-ppi", RESOLUTION_LABELS[preset.unwrap_or(3)], &RESOLUTION_LABELS, 150.0) { + if let Some(p) = RESOLUTIONS.get(i) { + d.fields.insert("ppi".into(), json!(p)); + } + d.fields.insert("__otherPpi".into(), json!(i == 3)); + } + if preset.is_none() + && let Some(v) = widgets::plain_field(ui, "ro-ppi-other", ppi, " ppi", 0, 80.0) + { + d.fields.insert("ppi".into(), json!(v.round().clamp(1.0, 4608.0))); + } + }); + ui.end_row(); + + label(ui, "Background Color:"); + let bg = d.str("background"); + let choice = BACKGROUNDS.iter().position(|v| bg.eq_ignore_ascii_case(v)).unwrap_or(3); + // JPEG has no transparency: its list starts at White. + let first = usize::from(id == "jpg"); + ui.horizontal(|ui| { + if let Some(i) = widgets::dropdown(ui, "ro-bg", BACKGROUND_LABELS[choice], &BACKGROUND_LABELS[first..], 150.0) { + let value = BACKGROUNDS.get(i + first).copied().unwrap_or("#808080"); + d.fields.insert("background".into(), json!(value)); + } + if choice == 3 { + let c = vectorcraft_color::Color::from_hex(&bg).map_or([128, 128, 128, 255], |c| c.to_rgba8(1.0)); + let mut rgb = [c[0], c[1], c[2]]; + if ui.color_edit_button_srgb(&mut rgb).changed() { + d.fields.insert("background".into(), json!(format!("#{:02x}{:02x}{:02x}", rgb[0], rgb[1], rgb[2]))); + } + } + }); + ui.end_row(); + + label(ui, "Anti-aliasing:"); + let aa = AntiAlias::from_id(&d.str("antiAlias")).unwrap_or_default(); + if let Some(i) = widgets::dropdown(ui, "ro-aa", aa.label(), &AntiAlias::ALL.map(AntiAlias::label), 150.0) { + d.fields.insert("antiAlias".into(), json!(AntiAlias::ALL[i].id())); + } + ui.end_row(); + + match id { + "png" => { + ui.label(""); + form::check(ui, d, "interlaced", "Interlaced"); + ui.end_row(); + } + "jpg" => { + label(ui, "Quality:"); + let mut q = d.f64("quality", 90.0).round() as u8; + if ui.add(egui::Slider::new(&mut q, 1..=100)).changed() { + d.fields.insert("quality".into(), json!(q)); + } + ui.end_row(); + } + _ => {} + } + + if let Some([w, h]) = + d.fields.get("__size").and_then(Value::as_array).map(|a| [0, 1].map(|i| a.get(i).and_then(Value::as_f64).unwrap_or(0.0))) + { + label(ui, "Size:"); + let (pw, ph) = vectorcraft_render::region_pixels(vectorcraft_geom::Rect::new(0.0, 0.0, w, h), ppi / 72.0); + ui.label(egui::RichText::new(format!("{pw} × {ph} px")).color(t.text)); + ui.end_row(); + } + }); + false +} + +/// Write the file(s) with the chosen options. +fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { + let (format, path) = (d.str("format"), d.str("path")); + app.ui.dialog = None; + io::export(app, Some(&format), Some(path), &form::params(d)).map(|path| json!({"path": path})) +} diff --git a/crates/ui-egui/src/dialogs/tests_export.rs b/crates/ui-egui/src/dialogs/tests_export.rs new file mode 100644 index 00000000..3ec24d67 --- /dev/null +++ b/crates/ui-egui/src/dialogs/tests_export.rs @@ -0,0 +1,97 @@ +//! The raster options dialogs, drawn headlessly. + +use std::cell::RefCell; +use std::rc::Rc; + +use serde_json::json; +use vectorcraft_engine::Session; +use vectorcraft_engine::cmd::fileio; + +use super::*; +use crate::Services; + +type Written = Rc)>>>; + +/// An app with `artboards` 60×40 pt artboards whose save dialog answers `/out/` and +/// whose writer records what it is given. +fn app(artboards: usize) -> (VectorcraftApp, Written) { + let written = Written::default(); + let w = written.clone(); + let services = Services { + pick_save: Some(Box::new(|name: &str| Some(format!("/out/{name}")))), + write: Some(Box::new(move |p: &str, b: &[u8]| { + w.borrow_mut().push((p.to_string(), b.to_vec())); + Ok(()) + })), + ..Default::default() + }; + let mut app = VectorcraftApp::new(Session::new(), services); + app.run("file.new", json!({"width": 60, "height": 40, "artboards": artboards})).unwrap(); + app.run("paint.setStroke", json!({"none": true})).unwrap(); + app.run("shape.ellipse", json!({"x": 5, "y": 4, "width": 30, "height": 20})).unwrap(); + (app, written) +} + +/// One headless frame of the dialog layer. +fn frame(app: &mut VectorcraftApp) { + let ctx = egui::Context::default(); + theme::install_fonts(&ctx); + let mut out = ctx.run_ui(Default::default(), |ui| show(app, ui.ctx())); + out.textures_delta.clear(); +} + +fn kind(app: &VectorcraftApp) -> Option<&str> { + app.ui.dialog.as_ref().map(|d| d.kind.as_str()) +} + +fn set(app: &mut VectorcraftApp, field: &str, value: serde_json::Value) { + app.ui.dialog.as_mut().expect("a dialog is open").fields.insert(field.into(), value); +} + +#[test] +fn png_options_draw_and_export_with_the_chosen_options() { + let (mut app, written) = app(2); + png_options::open(&mut app, fileio::format("png").unwrap(), json!({"format": "png", "path": "/out/a.png", "artboard": 1})); + assert_eq!(kind(&app), Some("pngOptions")); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((d.f64("ppi", 0.0), d.str("background"), d.str("antiAlias"), d.bool("interlaced")), (72.0, "transparent".into(), "art".into(), false)); + assert_eq!(d.fields["__size"], json!([60.0, 40.0]), "the chosen artboard"); + frame(&mut app); + // Other resolution and an Other background colour draw their own field and colour button. + set(&mut app, "__otherPpi", json!(true)); + set(&mut app, "ppi", json!(200)); + set(&mut app, "background", json!("#204080")); + set(&mut app, "antiAlias", json!("type")); + set(&mut app, "interlaced", json!(true)); + frame(&mut app); + assert_eq!(confirm(&mut app).unwrap()["path"], "/out/a.png"); + assert!(app.ui.dialog.is_none()); + let png = written.borrow()[0].1.clone(); + assert_eq!(png[28], 1, "interlaced"); + let phys = png.windows(4).position(|w| w == b"pHYs").unwrap(); + assert_eq!(&png[phys + 4..phys + 8], ((200.0f64 / 0.0254).round() as u32).to_be_bytes()); + let img = image::load_from_memory(&png).unwrap().to_rgba8(); + assert_eq!(img.dimensions(), (167, 111), "60 × 40 pt at 200 ppi"); + assert_eq!(img.get_pixel(0, 0).0, [0x20, 0x40, 0x80, 255]); + // JPEG Options: quality, and no transparent background. + png_options::open(&mut app, fileio::format("jpg").unwrap(), json!({"format": "jpg", "path": "/out/a.jpg"})); + assert_eq!(kind(&app), Some("jpgOptions")); + assert_eq!(app.ui.dialog.as_ref().unwrap().str("background"), "white"); + frame(&mut app); + set(&mut app, "quality", json!(40)); + confirm(&mut app).unwrap(); + assert_eq!(&written.borrow()[1].1[..2], [0xFF, 0xD8]); +} + +#[test] +fn export_as_png_picks_the_file_then_shows_png_options() { + let (mut app, written) = app(1); + app.run("file.export.png", json!({})).unwrap(); + assert_eq!(kind(&app), Some("pngOptions")); + assert_eq!(app.ui.dialog.as_ref().unwrap().str("path"), "/out/Untitled-1.png"); + assert!(written.borrow().is_empty()); + app.ui.dialog = None; + let r = app.run("file.export.png", json!({"path": "/x/a.png", "ppi": 144})).unwrap(); + assert_eq!(r["path"], "/x/a.png"); + assert_eq!(&written.borrow()[0].1[16..20], 120u32.to_be_bytes(), "60 pt at 144 ppi"); +} diff --git a/crates/ui-egui/src/io.rs b/crates/ui-egui/src/io.rs index 3a259024..a50704a5 100644 --- a/crates/ui-egui/src/io.rs +++ b/crates/ui-egui/src/io.rs @@ -68,7 +68,7 @@ fn suggested(app: &VectorcraftApp, ext: &str) -> String { } /// `path`, else a suggested name (web download) or one picked in a save dialog. -fn target_path(app: &mut VectorcraftApp, path: Option, ext: &str) -> Result { +pub(crate) fn target_path(app: &mut VectorcraftApp, path: Option, ext: &str) -> Result { match path { Some(p) => Ok(p), None if app.services.download.is_some() => Ok(suggested(app, ext)), diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 95c4826e..54a3054c 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -103,7 +103,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "", "{} opens SVG Options; with params = document.export {path?, svg?: {…SVG options}, range?…} (a .svgz path writes it gzipped)", ), - ("file.export.png", "Export As PNG…", "", "{path?, scale?: 1, artboard?} (document.export options)"), + ("file.export.png", "Export As PNG…", "", "{path?, …document.export options}; no path: pick the file, then PNG Options"), ("file.exportForScreens", "Export for Screens…", "Cmd+Alt+E", "{} opens the dialog; with params = document.exportForScreens"), ("file.documentSetup", "Document Setup…", "Cmd+Alt+P", "{}"), ("file.newDialog", "New…", "Cmd+N", "{} opens the New Document dialog"), @@ -456,6 +456,14 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option app.run("document.exportForScreens", p.clone()), + "file.export.png" if s("path").is_none() => io::target_path(app, None, "png").and_then(|path| { + let f = vectorcraft_engine::cmd::fileio::format("png").ok_or("no PNG encoder")?; + let mut params = if p.is_object() { p.clone() } else { json!({}) }; + params["format"] = json!("png"); + params["path"] = json!(path); + crate::dialogs::open_raster_options(app, f, params); + Ok(Value::Null) + }), "file.export.png" => io::export(app, Some("png"), s("path"), p).map(|p| json!({"path": p})), "file.documentSetup" => crate::dialogs::open_document_setup(app), "edit.preferences" => { @@ -1112,7 +1120,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec)> { c("Expand…", "ui.expandDialog"), c("Expand Appearance", "effect.expandAppearance"), c("Crop Image", "object.cropImage"), - cp("Rasterize…", "object.rasterize", json!({"ppi": 72, "background": "transparent"})), + cp("Rasterize…", "object.rasterize", json!({"ppi": 72, "background": "transparent", "antiAlias": "art"})), cp("Create Gradient Mesh…", "object.mesh.create", json!({"rows": 4, "cols": 4, "appearance": "flat", "highlight": 100})), cp( "Create Object Mosaic…", diff --git a/docs/control-protocol.md b/docs/control-protocol.md index ad54da24..07224d39 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -23,6 +23,7 @@ | `engine.execute` `ui.savePdfDialog` | `{path?, preset?, range?, …document.exportPdf options}` | opens the Save PDF dialog (`file.export.pdf` with no params opens it too). Its fields are the `document.exportPdf` options: set `compatibility` with `ui.dialog.set {field: "compatibility", value: "1.5"}`, a whole section with `{field: "compression", value: {…}}`, the artboards with `__allArtboards: false` and `range`, the visible section with `__section` (General, Compression, Marks and Bleeds, Output, Advanced, Security, Summary). `ui.dialog.confirm` writes `path` (else asks for one), returns `{path, bytes, warnings}` and opens the file when `viewAfterSaving` is set | | SVG Options (`engine.execute` of `file.export.svg`, `file.saveAs`, `file.saveCopy`) | `{}` / `{path?, svg?: {…}}` | `file.export.svg` without params, and Save As or Save a Copy to an `.svg`/`.svgz` path without SVG options, open the `svgOptions` dialog (`ui.dialog.set` its fields: `styling`, `outlineText`, `images`, `objectIds`, `decimals`, `minify`, `responsive`, `fewerTspans`, `metadata`, `preserveEditing`, `useArtboards`, `allArtboards`, `range`, `showCode`; `ui.dialog.confirm` exports or saves and remembers the choices). With `svg: {…}` (the `document.formats` SVG options) they run directly. `app.save {path?, svg?}` takes the same options; a document saved as SVG saves as SVG again with the same options | | `engine.execute` `file.place` | `{paths?}` / `{path \| name+dataBase64, …file.place params}` | with no file, picks files and opens the `place` dialog; with `paths`, opens it for them (fields `files`, `link`, `template`, `replace`; `__replace` says whether Replace applies, `__info` describes each file). `ui.dialog.confirm` places one file centred in the view (or in place of the selected object) and loads the place cursor with several (`file.place.queue`). With a file, it runs `file.place` centred in the view. Files dropped on the canvas are placed at the drop point (linked; Shift embeds), elsewhere or with no document open they open. With the cursor loaded (`ui.inspect` → `tool: "place"`, `toolOptions: {count, current, name, names}`), `ui.pointer` clicks and drags place files and `ui.key` ←/→/↑/↓ and Escape cycle and discard them | +| `app.export` raster options | `{path?, format: png \| jpg \| webp, ppi?, background?, antiAlias?, interlaced?, quality?}` | `ppi` sets the pixel size (72 = one pixel per point) and is stored in the file; `background` is `transparent`, `white`, `black` or `"#rrggbb"`; `antiAlias` is `none`, `art` or `type` (text snapped to pixels); `interlaced` writes an Adam7 PNG. The PNG Options dialog (`pngOptions`; `jpgOptions` / `webpOptions` for the other raster formats) has the same fields: fill them with `ui.dialog.set` and press OK with `ui.dialog.confirm` | Swatch editors: `engine.execute` with `ui.swatchOptions {name}` opens the `swatchOptions` dialog for a colour swatch (fields `name`, `spot`, `global`, `mode`: `gray`/`rgb`/`hsb`/`lab`/`cmyk`/`web`, `color`: `"#rrggbb"` or a colour object, diff --git a/docs/mcp.md b/docs/mcp.md index b90d5e67..7ef5ecd9 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -156,8 +156,13 @@ Draw, look, export: {"name":"draw_path","arguments":{"d":"M72 400 C 150 300 250 500 330 400","stroke":"#e53935","strokeWidth":4,"fill":"none"}} {"name":"screenshot","arguments":{"scale":0.5}} {"name":"export","arguments":{"path":"/tmp/art.svg"}} +{"name":"export","arguments":{"path":"/tmp/art.png","options":{"ppi":144,"background":"white","antiAlias":"type"}}} ``` +Raster exports take more options in `options`: `ppi` (72, 150, 300…; stored in the file), `background` +(`transparent`, `white`, `black` or `"#rrggbb"`), `antiAlias` (`none`, `art`, `type`: text snapped to pixels) and, +for PNG, `interlaced`. + Long-tail commands: ```json