From d01b2fbd8ae831b4ecff40ee5e3b001d10456234 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 5 Oct 2026 14:52:25 +0700 Subject: [PATCH 1/3] M4.67: TIFF export in RGB, CMYK or grayscale, with LZW, either byte order and the profile document.export {format: "tiff"} writes baseline TIFF through the raster export path (resolution, background, anti-aliasing, artboard ranges): colorModel rgb (transparency as unassociated alpha), cmyk (ink amounts, CMYK colours keep their inks) or gray, lzw (weezl), byteOrder little (II) or big (MM), embedIcc, and the resolution tags. Export As offers it with the tiffOptions dialog; TIFF moves from read-only to read and write. The TIFF writer is shared: EPS previews now use it (and its PackBits) instead of their own copy, so eps depends on render (layers allow it). Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 3 + crates/engine/src/cmd/fileio/encode.rs | 18 +- crates/engine/src/cmd/fileio/mod.rs | 28 +- crates/engine/src/cmd/fileio/tests.rs | 2 +- crates/engine/src/cmd/fileio/tests_tiffbmp.rs | 106 +++++ crates/eps/Cargo.toml | 2 + crates/eps/src/ps.rs | 30 +- crates/eps/src/tiff.rs | 142 ++----- crates/mcp/src/tests_fileio.rs | 2 +- crates/mcp/src/tools.rs | 2 +- crates/render/Cargo.toml | 3 + crates/render/src/encode/jpeg.rs | 14 +- crates/render/src/encode/mod.rs | 37 +- crates/render/src/encode/tests_tiff.rs | 184 +++++++++ crates/render/src/encode/tiff.rs | 365 ++++++++++++++++++ crates/ui-egui/src/dialogs/mod.rs | 5 + crates/ui-egui/src/dialogs/png_options.rs | 22 +- .../ui-egui/src/dialogs/tests_tiff_bmp_tga.rs | 33 ++ crates/ui-egui/src/dialogs/tiff_bmp_tga.rs | 59 +++ docs/control-protocol.md | 5 + docs/mcp.md | 14 +- xtask/src/layers.rs | 6 +- 22 files changed, 904 insertions(+), 178 deletions(-) create mode 100644 crates/engine/src/cmd/fileio/tests_tiffbmp.rs create mode 100644 crates/render/src/encode/tests_tiff.rs create mode 100644 crates/render/src/encode/tiff.rs create mode 100644 crates/ui-egui/src/dialogs/tests_tiff_bmp_tga.rs create mode 100644 crates/ui-egui/src/dialogs/tiff_bmp_tga.rs diff --git a/Cargo.lock b/Cargo.lock index 42f7ec97..d220f9a2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4307,6 +4307,7 @@ dependencies = [ "vectorcraft-doc", "vectorcraft-effects", "vectorcraft-geom", + "vectorcraft-render", "vectorcraft-testkit", "vectorcraft-text", ] @@ -4419,6 +4420,7 @@ dependencies = [ "log", "proptest", "serde_json", + "tiff", "vectorcraft-brush", "vectorcraft-color", "vectorcraft-doc", @@ -4428,6 +4430,7 @@ dependencies = [ "vectorcraft-text", "vello_common 0.2.0", "vello_cpu 0.2.0", + "weezl", ] [[package]] diff --git a/crates/engine/src/cmd/fileio/encode.rs b/crates/engine/src/cmd/fileio/encode.rs index 1b04af26..974e81f0 100644 --- a/crates/engine/src/cmd/fileio/encode.rs +++ b/crates/engine/src/cmd/fileio/encode.rs @@ -11,6 +11,7 @@ use vectorcraft_geom::Rect; use vectorcraft_render::AntiAlias; use vectorcraft_render::encode::jpeg::{self, JpegOptions}; use vectorcraft_render::encode::quantize::{Dither, PaletteOptions, Reduction}; +use vectorcraft_render::encode::tiff::{ByteOrder, TiffOptions}; use vectorcraft_render::encode::{RasterExportOptions, RasterFormat}; use super::super::*; @@ -162,6 +163,8 @@ struct RasterOptions { transparency: Option, matte: Option, lossless: Option, + lzw: Option, + byte_order: Option, } impl RasterOptions { @@ -172,6 +175,8 @@ impl RasterOptions { 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)); + let color_model = parse(self.color_model.as_deref(), jpeg::ColorModel::from_id, "colorModel", "rgb, cmyk or gray")?; + let embed_icc = self.embed_icc.unwrap_or(true); Ok(RasterExportOptions { ppi: (ppi / 72.0).clamp(0.01, 64.0) * 72.0, background: match &self.background { @@ -182,10 +187,10 @@ impl RasterOptions { interlaced: self.interlaced.unwrap_or(false), quality: self.quality.unwrap_or(90).min(100), jpeg: JpegOptions { - color_model: parse(self.color_model.as_deref(), jpeg::ColorModel::from_id, "colorModel", "rgb, cmyk or gray")?, + color_model, method: parse(self.method.as_deref(), jpeg::Method::from_id, "method", "baseline, optimized or progressive")?, scans: self.scans.unwrap_or(3).clamp(*jpeg::SCANS.start(), *jpeg::SCANS.end()), - embed_icc: self.embed_icc.unwrap_or(true), + embed_icc, }, palette: PaletteOptions { colors: self.colors.unwrap_or(256).clamp(2, 256), @@ -203,6 +208,12 @@ impl RasterOptions { None => Some([255; 3]), }, }, + tiff: TiffOptions { + color_model, + lzw: self.lzw.unwrap_or(true), + byte_order: parse(self.byte_order.as_deref(), ByteOrder::from_id, "byteOrder", "little or big")?, + embed_icc, + }, }) } } @@ -331,7 +342,7 @@ pub fn encode_all(doc: &Document, format: &str, p: &Value) -> Result { let (bytes, warnings) = super::pdf::encode(C, doc, p)?; return Ok(Encoded { warnings, ..Encoded::one(bytes) }); } - "png" | "jpg" | "webp" | "gif" | "png8" => { + "png" | "jpg" | "webp" | "gif" | "png8" | "tiff" => { let o: RasterOptions = options(f, p)?; // New Document → Background Contents: White makes the export opaque, unless `background` // says otherwise (JPEG has no alpha: white either way). @@ -343,6 +354,7 @@ pub fn encode_all(doc: &Document, format: &str, p: &Value) -> Result { "jpg" => RasterFormat::Jpeg, "gif" => RasterFormat::Gif, "png8" => RasterFormat::Png8, + "tiff" => RasterFormat::Tiff, _ => RasterFormat::WebP, }; // Use Artboards: one file per chosen artboard (default all); else the one chosen. diff --git a/crates/engine/src/cmd/fileio/mod.rs b/crates/engine/src/cmd/fileio/mod.rs index 816b2843..103de1c7 100644 --- a/crates/engine/src/cmd/fileio/mod.rs +++ b/crates/engine/src/cmd/fileio/mod.rs @@ -83,7 +83,7 @@ pub fn specs() -> Vec { "Serialize Document", [], None, - "{format?: vectorcraft (default)|template|svg|svgz|pdf|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf, …the format's options (see document.formats; SVG ones also as svg: {…}), selectedOnly?: false (the selected objects alone, in their layers)} → {text, warnings} for svg (plus dataBase64, the file, when its encoding isn't UTF-8), else {dataBase64, warnings}; an SVG of several artboards also gives files: [{name, text}], linked images linked: [{name, dataBase64}]", + "{format?: vectorcraft (default)|template|svg|svgz|pdf|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|tiff, …the format's options (see document.formats; SVG ones also as svg: {…}), selectedOnly?: false (the selected objects alone, in their layers)} → {text, warnings} for svg (plus dataBase64, the file, when its encoding isn't UTF-8), else {dataBase64, warnings}; an SVG of several artboards also gives files: [{name, text}], linked images linked: [{name, dataBase64}]", has_doc, export::serialize ), @@ -92,7 +92,7 @@ pub fn specs() -> Vec { "Export Document", [], None, - "{path?, format?: svg|svgz|pdf|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png), selectedOnly?: false (the selected objects alone, in their layers), 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), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf: every page) | false (pdf/raster: the bounds of the visible art; SVG has it as an SVG option), 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), jpg: quality?: 90 (0–100), colorModel?: rgb|cmyk|gray, method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as .html / .map), gif/png8: colors?: 256 (2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?, webp: lossless?: true (lossy WebP isn't available yet: written lossless, with a warning), txt: the stories in stacking order (back to front; a thread once), encoding?: utf8|utf16 (with a byte order mark), lineEndings?: lf|crlf, selectionOnly?: false; SVG options flat or as svg: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers, encoding, profile, embedFonts} (see document.formats), …the PDF options of document.exportPdf, …the DXF options of document.exportDxf (useArtboards: one drawing per artboard), …the EPS options of document.exportEps (useArtboards: one file per artboard, {stem}_{artboard}.eps; else the visible art), emf/wmf: one picture of the artboard (useArtboards: true one file per chosen artboard, false the bounds of the visible art; EMF keeps curves, clipping, transparent images and gradients as images clipped to their shape; WMF flattens curves into polygons behind a placeable header; what a format leaves out comes back in warnings)} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; 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|gif|png8|txt|dxf|eps|emf|wmf|tiff|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png), selectedOnly?: false (the selected objects alone, in their layers), 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), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf: every page) | false (pdf/raster: the bounds of the visible art; SVG has it as an SVG option), 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), jpg: quality?: 90 (0–100), colorModel?: rgb|cmyk|gray, method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as .html / .map), gif/png8: colors?: 256 (2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?, webp: lossless?: true (lossy WebP isn't available yet: written lossless, with a warning), txt: the stories in stacking order (back to front; a thread once), encoding?: utf8|utf16 (with a byte order mark), lineEndings?: lf|crlf, selectionOnly?: false; SVG options flat or as svg: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers, encoding, profile, embedFonts} (see document.formats), …the PDF options of document.exportPdf, …the DXF options of document.exportDxf (useArtboards: one drawing per artboard), …the EPS options of document.exportEps (useArtboards: one file per artboard, {stem}_{artboard}.eps; else the visible art), emf/wmf: one picture of the artboard (useArtboards: true one file per chosen artboard, false the bounds of the visible art; EMF keeps curves, clipping, transparent images and gradients as images clipped to their shape; WMF flattens curves into polygons behind a placeable header; what a format leaves out comes back in warnings), tiff: colorModel?: rgb|cmyk|gray (rgb keeps transparency as an alpha channel; cmyk and gray are flattened on white), lzw?: true, byteOrder?: little|big, embedIcc?: true} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. Never changes the document's path", has_doc, export::export ), @@ -336,6 +336,16 @@ const PALETTE_OPTIONS: &[FormatOption] = &[ MATTE, ]; +const LZW: FormatOption = FormatOption { name: "lzw", ty: "boolean", default: "true", description: "LZW compression (lossless, smaller files)" }; +const BYTE_ORDER: FormatOption = FormatOption { + name: "byteOrder", + ty: "string", + default: "\"little\"", + description: "the order of the bytes in the file's numbers: little (II, as on PCs) or big (MM)", +}; +const TIFF_OPTIONS: &[FormatOption] = + &[ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, PPI, SCALE, BACKGROUND, ANTI_ALIAS, COLOR_MODEL, LZW, BYTE_ORDER, EMBED_ICC]; + /// 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 { Format { id, label, extensions, mime, read: true, write: false, raster, options: &[] } @@ -423,7 +433,16 @@ pub const FORMATS: &[Format] = &[ raster: true, options: &[ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, PPI, SCALE, BACKGROUND, ANTI_ALIAS, LOSSLESS, WEBP_QUALITY], }, - reader("tiff", "TIFF", &["tif", "tiff"], "image/tiff", true), + Format { + id: "tiff", + label: "TIFF", + extensions: &["tif", "tiff"], + mime: "image/tiff", + read: true, + write: true, + raster: true, + options: TIFF_OPTIONS, + }, reader("bmp", "BMP", &["bmp"], "image/bmp", true), Format { id: "template", @@ -781,3 +800,6 @@ mod tests_eps; mod tests_dxfimport; #[cfg(test)] mod tests_metafile; + +#[cfg(test)] +mod tests_tiffbmp; diff --git a/crates/engine/src/cmd/fileio/tests.rs b/crates/engine/src/cmd/fileio/tests.rs index 84448d15..af83b755 100644 --- a/crates/engine/src/cmd/fileio/tests.rs +++ b/crates/engine/src/cmd/fileio/tests.rs @@ -156,7 +156,7 @@ fn formats_query_lists_readers_writers_and_options() { let ids = |k: &str| r[k].as_array().unwrap().iter().map(|v| v.as_str().unwrap().to_string()).collect::>(); assert_eq!( ids("writable"), - ["vectorcraft", "svg", "svgz", "pdf", "png", "jpg", "gif", "webp", "template", "png8", "txt", "dxf", "eps", "emf", "wmf"] + ["vectorcraft", "svg", "svgz", "pdf", "png", "jpg", "gif", "webp", "tiff", "template", "png8", "txt", "dxf", "eps", "emf", "wmf"] ); assert!(ids("readable").contains(&"tiff".to_string()) && ids("readable").contains(&"ait".to_string())); let png = r["formats"].as_array().unwrap().iter().find(|f| f["id"] == "png").unwrap(); diff --git a/crates/engine/src/cmd/fileio/tests_tiffbmp.rs b/crates/engine/src/cmd/fileio/tests_tiffbmp.rs new file mode 100644 index 00000000..ec908215 --- /dev/null +++ b/crates/engine/src/cmd/fileio/tests_tiffbmp.rs @@ -0,0 +1,106 @@ +//! TIFF export through `document.export`: the options reach the files, impossible +//! ones are refused, and the files open again. + +use serde_json::{Value, json}; + +use super::*; + +/// A 60 × 40 pt document with a black (100 % K) rectangle on a transparent corner. +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 60, "height": 40})).unwrap(); + s.execute("paint.setStroke", &json!({"none": true})).unwrap(); + s.execute("paint.setFill", &json!({"color": {"c": 0, "m": 0, "y": 0, "k": 1}})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 20, "y": 0, "width": 40, "height": 40})).unwrap(); + s +} + +fn export(s: &mut Session, p: Value) -> Vec { + let r = s.execute("document.export", &p).unwrap(); + vectorcraft_format::base64_decode(r["dataBase64"].as_str().expect("dataBase64")).unwrap() +} + +/// A TIFF's first directory: tag → (type, count, value or offset), in the file's byte order. +fn tags(f: &[u8]) -> std::collections::HashMap { + let big = &f[..2] == b"MM"; + let u16_at = |at: usize| { + let b = [f[at], f[at + 1]]; + if big { u16::from_be_bytes(b) } else { u16::from_le_bytes(b) } + }; + let u32_at = |at: usize| { + let b = f[at..at + 4].try_into().unwrap(); + if big { u32::from_be_bytes(b) } else { u32::from_le_bytes(b) } + }; + let dir = u32_at(4) as usize; + (0..u16_at(dir) as usize) + .map(|i| { + let e = dir + 2 + i * 12; + let (ty, count) = (u16_at(e + 2), u32_at(e + 4)); + // A lone SHORT sits in the first half of the value field. + let value = if ty == 3 && count == 1 { u32::from(u16_at(e + 8)) } else { u32_at(e + 8) }; + (u16_at(e), (ty, count, value)) + }) + .collect() +} + +#[test] +fn tiff_options_reach_the_file() { + let mut s = session(); + let tiff = |s: &mut Session, p: Value| export(s, merge(json!({"format": "tiff"}), p)); + let rgb = tiff(&mut s, json!({})); + assert_eq!(&rgb[..4], b"II*\0", "little-endian by default"); + let t = tags(&rgb); + assert_eq!((t[&259].2, t[&262].2, t[&277].2), (5, 2, 4), "LZW, RGB, an alpha channel for the transparent corner"); + assert!(t.contains_key(&34675), "the sRGB profile"); + let img = image::load_from_memory_with_format(&rgb, image::ImageFormat::Tiff).unwrap().to_rgba8(); + assert_eq!(img.dimensions(), (60, 40)); + assert_eq!(img.get_pixel(5, 5).0[3], 0, "transparent where nothing is drawn"); + let black = img.get_pixel(40, 20).0; + assert!(black[3] == 255 && black[0] < 80, "{black:?}"); + + let mm = tiff(&mut s, json!({"byteOrder": "big", "lzw": false, "embedIcc": false, "background": "white", "ppi": 300})); + assert_eq!(&mm[..4], b"MM\0*"); + let t = tags(&mm); + assert_eq!((t[&259].2, t[&277].2, t[&296].2), (1, 3, 2), "uncompressed, opaque RGB, inches"); + assert!(!t.contains_key(&34675)); + let x_res = t[&282].2 as usize; + assert_eq!(&mm[x_res..x_res + 8], [300u32.to_be_bytes(), 1u32.to_be_bytes()].concat(), "300/1 ppi"); + assert!(tiff(&mut s, json!({"background": "white"})).len() < mm.len() / 3 * 4, "LZW is smaller"); + + // CMYK: four samples of ink; the black rectangle is black ink alone, the rest no ink. + let cmyk = tiff(&mut s, json!({"colorModel": "cmyk", "lzw": false})); + let t = tags(&cmyk); + assert_eq!((t[&262].2, t[&277].2, t[&332].2), (5, 4, 1)); + let strip = t[&273]; + let first = if strip.1 == 1 { strip.2 } else { u32::from_le_bytes(cmyk[strip.2 as usize..][..4].try_into().unwrap()) } as usize; + let px = |x: usize, y: usize| &cmyk[first + (y * 60 + x) * 4..][..4]; + assert_eq!((px(40, 0), px(5, 0)), (&[0, 0, 0, 255][..], &[0, 0, 0, 0][..])); + + let gray = tiff(&mut s, json!({"colorModel": "gray"})); + assert_eq!((tags(&gray)[&262].2, tags(&gray)[&277].2), (1, 1), "grey, no alpha"); + assert!(s.execute("document.export", &json!({"format": "tiff", "byteOrder": "middle"})).is_err()); + // The file opens again. + let r = s.execute("document.open", &json!({"name": "back.tif", "dataBase64": vectorcraft_format::base64_encode(&mm)})).unwrap(); + assert_eq!(r["format"], "tiff"); + for file in [&cmyk, &gray, &rgb] { + s.execute("document.open", &json!({"name": "back.tif", "dataBase64": vectorcraft_format::base64_encode(file)})).unwrap(); + } +} + +#[test] +fn the_formats_are_listed_and_write_one_file_per_artboard() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 30, "height": 20, "artboards": 2})).unwrap(); + let r = s.execute("document.formats", &json!({})).unwrap(); + let find = |id: &str| r["formats"].as_array().unwrap().iter().find(|f| f["id"] == id).unwrap().clone(); + let tiff = find("tiff"); + assert_eq!((tiff["write"].as_bool(), tiff["read"].as_bool()), (Some(true), Some(true))); + for (f, keys) in [(&tiff, &["colorModel", "lzw", "byteOrder", "embedIcc", "ppi", "antiAlias"][..])] { + for k in keys { + assert!(f["options"].get(*k).is_some(), "{} takes {k}", f["id"]); + } + } + let r = s.execute("document.export", &json!({"format": "tiff", "useArtboards": true})).unwrap(); + let files: Vec<&str> = r["files"].as_array().unwrap().iter().map(|f| f["name"].as_str().unwrap()).collect(); + assert_eq!(files, ["Untitled-1-Artboard-1.tif", "Untitled-1-Artboard-2.tif"]); +} diff --git a/crates/eps/Cargo.toml b/crates/eps/Cargo.toml index 3f7ae721..e59704f7 100644 --- a/crates/eps/Cargo.toml +++ b/crates/eps/Cargo.toml @@ -15,6 +15,8 @@ vectorcraft-doc = { workspace = true } vectorcraft-effects = { workspace = true } vectorcraft-brush = { workspace = true } vectorcraft-text = { workspace = true } +# The TIFF writer (previews) and PackBits, shared with raster export. +vectorcraft-render = { workspace = true } kurbo = { workspace = true } image = { workspace = true } flate2 = { workspace = true } diff --git a/crates/eps/src/ps.rs b/crates/eps/src/ps.rs index f92727fe..e00d813a 100644 --- a/crates/eps/src/ps.rs +++ b/crates/eps/src/ps.rs @@ -223,34 +223,8 @@ fn value85(digits: &[u8]) -> Option { digits.iter().try_fold(0u32, |acc, d| acc.checked_mul(85)?.checked_add(u32::from(d.checked_sub(b'!')?))) } -/// `data` run-length encoded (PackBits: what TIFF's compression 32773 and PostScript's -/// `RunLengthDecode` read), without an end marker. -pub(crate) fn packbits(data: &[u8], out: &mut Vec) { - let mut i = 0; - while i < data.len() { - let Some(&b) = data.get(i) else { break }; - let run = data.get(i..).map_or(1, |rest| rest.iter().take(128).take_while(|x| **x == b).count()); - if run >= 3 { - out.push((257 - run) as u8); - out.push(b); - i += run; - continue; - } - // Literal bytes up to the next run of three. - let mut j = i; - while j < data.len() && j - i < 128 { - let three = data.get(j..j + 3).is_some_and(|w| w[0] == w[1] && w[1] == w[2]); - if three { - break; - } - j += 1; - } - let j = j.max(i + 1).min(data.len()); - out.push((j - i - 1) as u8); - out.extend(data.get(i..j).unwrap_or_default()); - i = j; - } -} +/// Run-length encoding (PackBits: what PostScript's `RunLengthDecode` reads), shared with TIFF. +pub(crate) use vectorcraft_render::encode::tiff::packbits; /// `data` compressed for `FlateDecode` (zlib). pub(crate) fn deflate(data: &[u8]) -> Vec { diff --git a/crates/eps/src/tiff.rs b/crates/eps/src/tiff.rs index 2a618275..ed7c6d23 100644 --- a/crates/eps/src/tiff.rs +++ b/crates/eps/src/tiff.rs @@ -1,140 +1,46 @@ -//! The TIFF preview (baseline TIFF, little-endian, one PackBits strip): 1-bit black and white, or -//! 8-bit RGB with or without an alpha channel. Also the PNG thumbnail. +//! The TIFF preview (little-endian, PackBits strips, written by the raster exports' TIFF writer): +//! 1-bit black and white, or 8-bit RGB with or without an alpha channel. Also the PNG thumbnail. + +use std::borrow::Cow; + +use vectorcraft_render::encode::on_white; +use vectorcraft_render::encode::tiff::{self, ByteOrder, Compression, Image, Layout, Photometric}; use crate::Raster; -use crate::ps::packbits; - -/// TIFF field types. -const SHORT: u16 = 3; -const LONG: u16 = 4; -const RATIONAL: u16 = 5; - -/// One directory entry: tag, type, count and its value (or, when longer than four bytes, the -/// bytes placed after the directory). -struct Entry { - tag: u16, - ty: u16, - count: u32, - data: Vec, -} - -impl Entry { - fn short(tag: u16, v: &[u16]) -> Self { - Self { tag, ty: SHORT, count: v.len() as u32, data: v.iter().flat_map(|x| x.to_le_bytes()).collect() } - } - fn long(tag: u16, v: u32) -> Self { - Self { tag, ty: LONG, count: 1, data: v.to_le_bytes().to_vec() } - } - fn rational(tag: u16, num: u32, den: u32) -> Self { - Self { tag, ty: RATIONAL, count: 1, data: [num.to_le_bytes(), den.to_le_bytes()].concat() } - } -} - -/// Pixel `px` composited over white: RGB. -fn on_white(px: &[u8; 4]) -> [u8; 3] { - let a = u32::from(px[3]); - [0, 1, 2].map(|i| ((u32::from(px[i]) * a + 255 * (255 - a) + 127) / 255) as u8) -} /// `img` as a TIFF: one bit a pixel (`bw`: black where darker than mid-grey on white), else RGB /// over white or, with `alpha`, RGBA (unassociated alpha). pub(crate) fn encode(img: &Raster, bw: bool, alpha: bool) -> Result, String> { - let (w, h) = (img.width as usize, img.height as usize); - if w == 0 || h == 0 || w.checked_mul(h).and_then(|n| n.checked_mul(4)) != Some(img.rgba.len()) { + let w = img.width as usize; + if w == 0 || img.height == 0 || w.checked_mul(img.height as usize).and_then(|n| n.checked_mul(4)) != Some(img.rgba.len()) { return Err("the preview image is empty".into()); } let px = img.rgba.as_chunks::<4>().0; - let spp: usize = if bw { - 1 - } else if alpha { - 4 - } else { - 3 - }; - // Each row compressed on its own, as TIFF readers expect. - let mut strip = Vec::with_capacity(w * h * spp / 4); - let mut row = Vec::with_capacity(w * spp); - for y in 0..h { - row.clear(); - let line = px.get(y * w..(y + 1) * w).unwrap_or_default(); - if bw { - row.resize(w.div_ceil(8), 0); + let (data, photometric, samples, bits): (Cow<[u8]>, _, u16, u16) = if bw { + let mut data = Vec::with_capacity(w.div_ceil(8) * img.height as usize); + for line in px.chunks(w) { + let at = data.len(); + data.resize(at + w.div_ceil(8), 0); for (x, p) in line.iter().enumerate() { let [r, g, b] = on_white(p); let luma = 299 * u32::from(r) + 587 * u32::from(g) + 114 * u32::from(b); // WhiteIsZero: a set bit is black. if luma < 128_000 - && let Some(byte) = row.get_mut(x / 8) + && let Some(byte) = data.get_mut(at + x / 8) { *byte |= 0x80 >> (x % 8); } } - } else if alpha { - row.extend(line.iter().flatten()); - } else { - row.extend(line.iter().flat_map(on_white)); } - packbits(&row, &mut strip); - } - let strip_len = u32::try_from(strip.len()).map_err(|_| "the preview image is too large".to_string())?; - let bits: Vec = vec![if bw { 1 } else { 8 }; spp]; - let mut entries = vec![ - Entry::long(256, img.width), - Entry::long(257, img.height), - Entry::short(258, &bits), - // PackBits. - Entry::short(259, &[32773]), - // WhiteIsZero, or RGB. - Entry::short(262, &[if bw { 0 } else { 2 }]), - // StripOffsets, filled in below. - Entry::long(273, 0), - Entry::short(277, &[spp as u16]), - Entry::long(278, img.height), - Entry::long(279, strip_len), - Entry::rational(282, 72, 1), - Entry::rational(283, 72, 1), - // Inches. - Entry::short(296, &[2]), - ]; - if alpha && !bw { - // Unassociated alpha. - entries.push(Entry::short(338, &[2])); - } - // Header, directory (count, entries, next = 0), the longer values, then the strip. - let dir_len = 2 + entries.len() * 12 + 4; - let mut extra_at = 8 + dir_len; - let extra_len: usize = entries.iter().filter(|e| e.data.len() > 4).map(|e| e.data.len().next_multiple_of(2)).sum(); - let strip_at = u32::try_from(8 + dir_len + extra_len).map_err(|_| "the preview image is too large".to_string())?; - if let Some(e) = entries.iter_mut().find(|e| e.tag == 273) { - e.data = strip_at.to_le_bytes().to_vec(); - } - let mut out = Vec::with_capacity(strip_at as usize + strip.len()); - out.extend(b"II"); - out.extend(42u16.to_le_bytes()); - out.extend(8u32.to_le_bytes()); - out.extend((entries.len() as u16).to_le_bytes()); - let mut extra = vec![]; - for e in &entries { - out.extend(e.tag.to_le_bytes()); - out.extend(e.ty.to_le_bytes()); - out.extend(e.count.to_le_bytes()); - if e.data.len() <= 4 { - let mut v = e.data.clone(); - v.resize(4, 0); - out.extend(v); - } else { - out.extend((extra_at as u32).to_le_bytes()); - extra.extend(&e.data); - if e.data.len() % 2 == 1 { - extra.push(0); - } - extra_at += e.data.len().next_multiple_of(2); - } - } - out.extend(0u32.to_le_bytes()); - out.extend(extra); - out.extend(strip); - Ok(out) + (Cow::Owned(data), Photometric::WhiteIsZero, 1, 1) + } else if alpha { + (Cow::Borrowed(&img.rgba), Photometric::Rgb, 4, 8) + } else { + (px.iter().flat_map(on_white).collect(), Photometric::Rgb, 3, 8) + }; + let image = Image { width: img.width, height: img.height, data: &data, photometric, samples, bits, alpha: alpha && !bw }; + tiff::write(&image, &Layout { byte_order: ByteOrder::Little, compression: Compression::PackBits, ppi: 72.0, icc: None }) + .map_err(|e| format!("the preview: {e}")) } /// `img` as a PNG. diff --git a/crates/mcp/src/tests_fileio.rs b/crates/mcp/src/tests_fileio.rs index 681ca550..4985d4af 100644 --- a/crates/mcp/src/tests_fileio.rs +++ b/crates/mcp/src/tests_fileio.rs @@ -57,7 +57,7 @@ fn export_tool_lists_engine_formats_and_options() { let props = &export["inputSchema"]["properties"]; assert_eq!( props["format"]["enum"], - json!(["vectorcraft", "svg", "svgz", "pdf", "png", "jpg", "gif", "webp", "template", "png8", "txt", "dxf", "eps", "emf", "wmf"]) + json!(["vectorcraft", "svg", "svgz", "pdf", "png", "jpg", "gif", "webp", "tiff", "template", "png8", "txt", "dxf", "eps", "emf", "wmf"]) ); for k in ["artboard", "range", "options"] { assert!(props.get(k).is_some(), "export takes {k}"); diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index f954a2d4..0bae9a93 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -264,7 +264,7 @@ pub fn tool_definitions() -> Vec { tool( "export", "Export", - "Export the active document with the engine's document.export (the same bytes in the app and headless): svg (artboard viewBox), pdf (one page per artboard: all, or `artboard` / `range`), png/jpg/webp/gif/png8 (one rendered artboard; png8 is an indexed .png), txt (the stories, back to front) or vectorcraft (a native copy; the document keeps its path). `selection: true` exports only the selected objects, cropped to their bounds. Template layers are left out; live effects are kept (geometry baked, SVG filters for shadows/glows/blur). Without `path` the bytes come back as dataBase64.", + "Export the active document with the engine's document.export (the same bytes in the app and headless): svg (artboard viewBox), pdf (one page per artboard: all, or `artboard` / `range`), png/jpg/webp/gif/png8/tiff (one rendered artboard; png8 is an indexed .png), txt (the stories, back to front) or vectorcraft (a native copy; the document keeps its path). `selection: true` exports only the selected objects, cropped to their bounds. Template layers are left out; live effects are kept (geometry baked, SVG filters for shadows/glows/blur). Without `path` the bytes come back as dataBase64.", obj( json!({ "format": {"type": "string", "enum": FORMATS.iter().filter(|f| f.write).map(|f| f.id).collect::>(), "description": "Default: from the path's extension"}, diff --git a/crates/render/Cargo.toml b/crates/render/Cargo.toml index 0c06f79b..152076db 100644 --- a/crates/render/Cargo.toml +++ b/crates/render/Cargo.toml @@ -22,6 +22,8 @@ image = { workspace = true } flate2 = { workspace = true } jpeg-encoder = { workspace = true } gif = { workspace = true } +# TIFF LZW compression (MIT OR Apache-2.0; already used by the image crate's TIFF reader). +weezl = "0.1" log = { workspace = true } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] @@ -29,5 +31,6 @@ vello_cpu = { workspace = true, features = ["multithreading"] } [dev-dependencies] vectorcraft-testkit = { workspace = true } +tiff = "0.11" proptest = { workspace = true } serde_json = { workspace = true } diff --git a/crates/render/src/encode/jpeg.rs b/crates/render/src/encode/jpeg.rs index 388a47eb..c6f74cb5 100644 --- a/crates/render/src/encode/jpeg.rs +++ b/crates/render/src/encode/jpeg.rs @@ -56,7 +56,7 @@ impl ColorModel { } /// The profile that describes these pixels. - fn profile(self) -> String { + pub(crate) fn profile(self) -> String { match self { ColorModel::Rgb => cms::SRGB.into(), ColorModel::Cmyk => cms::active_settings().cmyk, @@ -169,13 +169,17 @@ pub(crate) fn screen_pixels(img: &Rendered, gray: bool) -> Vec { if !gray { return rgb.flatten().collect(); } + rgb.map(to_gray()).collect() +} + +/// A colour's grey: its relative luminance (sRGB primaries) encoded with sRGB's curve, so a grey +/// keeps its value. +pub(crate) fn to_gray() -> impl Fn([u8; 3]) -> u8 { let linear: [f32; 256] = std::array::from_fn(|v| lab::srgb_to_linear(v as f32 / 255.0)); - // Relative luminance (sRGB primaries), encoded with sRGB's curve: a grey keeps its value. - rgb.map(|[r, g, b]| { + move |[r, g, b]| { let y = 0.2126 * linear[r as usize] + 0.7152 * linear[g as usize] + 0.0722 * linear[b as usize]; (lab::linear_to_srgb(y).clamp(0.0, 1.0) * 255.0).round() as u8 - }) - .collect() + } } /// `img` flattened on white with each colour separated into the working CMYK space (ink amounts). diff --git a/crates/render/src/encode/mod.rs b/crates/render/src/encode/mod.rs index d886e76e..afd44118 100644 --- a/crates/render/src/encode/mod.rs +++ b/crates/render/src/encode/mod.rs @@ -1,12 +1,14 @@ //! Raster export: render a document region with [`RasterExportOptions`] (resolution, background, //! anti-aliasing) and encode it as PNG ([`png`]: resolution in `pHYs`, Adam7 interlacing), JPEG //! ([`jpeg`]: RGB, CMYK or grey, progressive, resolution and colour profile), lossless WebP, or -//! a palette image ([`quantize`]) as PNG-8 or [`gif`]. +//! a palette image ([`quantize`]) as PNG-8 or [`gif`], or [`tiff`] (RGB, CMYK or grey, LZW, either +//! byte order, the profile). pub mod gif; pub mod jpeg; pub mod png; pub mod quantize; +pub mod tiff; use vectorcraft_doc::{Document, Node, NodeKind}; use vectorcraft_geom::Rect; @@ -22,6 +24,7 @@ pub enum RasterFormat { /// Indexed PNG: a palette of up to 256 colours. Png8, Gif, + Tiff, } /// How a raster export renders and encodes. @@ -40,6 +43,8 @@ pub struct RasterExportOptions { pub jpeg: jpeg::JpegOptions, /// PNG-8 and GIF: the colour reduction. pub palette: quantize::PaletteOptions, + /// TIFF colour model, compression, byte order and profile. + pub tiff: tiff::TiffOptions, } impl Default for RasterExportOptions { @@ -52,6 +57,7 @@ impl Default for RasterExportOptions { quality: 90, jpeg: jpeg::JpegOptions::default(), palette: quantize::PaletteOptions::default(), + tiff: tiff::TiffOptions::default(), } } } @@ -96,19 +102,32 @@ impl RasterExportOptions { _ => png::encode_indexed(&ix, &png::PngOptions { ppi: Some(self.ppi), interlaced: self.interlaced }), } } + RasterFormat::Tiff => tiff::encode(img, self.ppi, &self.tiff), + } + } + + /// Does `format` write ink amounts (a CMYK JPEG or TIFF)? + fn cmyk(&self, format: RasterFormat) -> bool { + match format { + RasterFormat::Jpeg => self.jpeg.color_model == jpeg::ColorModel::Cmyk, + RasterFormat::Tiff => self.tiff.color_model == jpeg::ColorModel::Cmyk, + _ => false, } } } 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`]). A CMYK JPEG is drawn as ink amounts - /// ([`Renderer::render_region_inks`]). + /// Callers check the size first ([`crate::raster_size`]). A CMYK JPEG or TIFF is drawn as ink + /// amounts ([`Renderer::render_region_inks`]). pub fn export_region(&mut self, doc: &Document, region: Rect, format: RasterFormat, opts: &RasterExportOptions) -> Result, String> { - if format == RasterFormat::Jpeg && opts.jpeg.color_model == jpeg::ColorModel::Cmyk { + if opts.cmyk(format) { let (w, h) = crate::region_pixels(region, opts.scale()); let inks = self.render_region_inks(doc, region, opts.scale(), &opts.render_options(format)); - return jpeg::encode(&inks, w, h, opts.quality, Some(opts.ppi), &opts.jpeg); + return match format { + RasterFormat::Tiff => tiff::encode_samples(&inks, w, h, opts.ppi, &opts.tiff), + _ => jpeg::encode(&inks, w, h, opts.quality, Some(opts.ppi), &opts.jpeg), + }; } let img = self.render_region_with(doc, region, opts.scale(), &opts.render_options(format)); opts.encode(&img, format) @@ -122,6 +141,12 @@ pub(crate) fn jpeg(img: &Rendered, quality: u8, ppi: Option) -> Result [u8; 3] { + let a = u32::from(p[3]); + [0, 1, 2].map(|i| ((u32::from(p[i]) * a + 255 * (255 - a) + 127) / 255) as u8) +} + /// Lossless WebP. pub(crate) fn webp(img: &Rendered) -> Result, String> { let mut buf = Vec::new(); @@ -157,3 +182,5 @@ mod tests; mod tests_jpeg; #[cfg(test)] mod tests_palette; +#[cfg(test)] +mod tests_tiff; diff --git a/crates/render/src/encode/tests_tiff.rs b/crates/render/src/encode/tests_tiff.rs new file mode 100644 index 00000000..c70eacd2 --- /dev/null +++ b/crates/render/src/encode/tests_tiff.rs @@ -0,0 +1,184 @@ +//! TIFF export: byte order, LZW, colour models and samples, resolution, profile, strips. + +use std::io::Cursor; + +use tiff::decoder::{Decoder, DecodingResult}; +use tiff::tags::Tag; + +use super::jpeg::ColorModel; +use super::tiff::{self as t, ByteOrder, Compression, Image, Layout, Photometric, TiffOptions}; + +fn decoder(file: &[u8]) -> Decoder> { + Decoder::new(Cursor::new(file)).expect("a TIFF") +} + +fn pixels(file: &[u8]) -> Vec { + match decoder(file).read_image().expect("decodes") { + DecodingResult::U8(v) => v, + _ => panic!("8-bit samples"), + } +} + +/// Straight RGBA: flat areas (as art is) with a transparent corner when `clear`. +fn art(w: u32, h: u32, clear: bool) -> Vec { + (0..w * h) + .flat_map(|i| { + let (x, y) = (i % w, i / w); + match (x * 3 / w, y * 2 / h) { + (0, 0) if clear => [0, 0, 0, 0], + (0, _) => [200, 30, 40, 255], + (1, _) => [20, 120, 220, 255], + _ => [255, 255, 255, 255], + } + }) + .collect() +} + +fn opts(model: ColorModel) -> TiffOptions { + TiffOptions { color_model: model, embed_icc: false, ..TiffOptions::default() } +} + +#[test] +fn byte_order_writes_an_ii_or_mm_header() { + let px = art(20, 10, false); + for (order, magic) in [(ByteOrder::Little, b"II*\0"), (ByteOrder::Big, b"MM\0*")] { + let file = t::encode_samples(&px, 20, 10, 72.0, &TiffOptions { byte_order: order, ..opts(ColorModel::Rgb) }).unwrap(); + assert_eq!(&file[..4], magic); + let rgb: Vec = px.chunks(4).flat_map(|p| &p[..3]).copied().collect(); + assert_eq!(pixels(&file), rgb, "{order:?}"); + assert_eq!(decoder(&file).get_tag_ascii_string(Tag::Software).unwrap(), "VectorCraft"); + } + for (s, o) in [("little", ByteOrder::Little), ("MM", ByteOrder::Big), ("mac", ByteOrder::Big), ("ii", ByteOrder::Little), ("big", ByteOrder::Big)] + { + assert_eq!(ByteOrder::from_id(s), Some(o), "{s}"); + } + assert_eq!(ByteOrder::from_id("middle"), None); +} + +#[test] +fn lzw_is_smaller_and_decodes_the_same() { + let px = art(120, 90, true); + let plain = t::encode_samples(&px, 120, 90, 72.0, &TiffOptions { lzw: false, ..opts(ColorModel::Rgb) }).unwrap(); + let lzw = t::encode_samples(&px, 120, 90, 72.0, &opts(ColorModel::Rgb)).unwrap(); + assert!(lzw.len() * 4 < plain.len(), "{} vs {}", lzw.len(), plain.len()); + assert_eq!(decoder(&lzw).get_tag_unsigned::(Tag::Compression).unwrap(), 5); + assert_eq!(decoder(&plain).get_tag_unsigned::(Tag::Compression).unwrap(), 1); + assert_eq!(pixels(&lzw), px, "transparency kept as an alpha channel"); + assert_eq!(pixels(&plain), px); + // The image crate reads it too. + let img = image::load_from_memory_with_format(&lzw, image::ImageFormat::Tiff).unwrap().to_rgba8(); + assert_eq!(img.into_raw(), px); +} + +#[test] +fn colour_models_write_their_samples() { + let px = art(9, 6, true); + let rgba = t::encode_samples(&px, 9, 6, 72.0, &opts(ColorModel::Rgb)).unwrap(); + assert_eq!(decoder(&rgba).colortype().unwrap(), tiff::ColorType::RGBA(8)); + assert_eq!(decoder(&rgba).get_tag_u16_vec(Tag::ExtraSamples).unwrap(), [2], "unassociated alpha"); + let opaque = art(9, 6, false); + let rgb = t::encode_samples(&opaque, 9, 6, 72.0, &opts(ColorModel::Rgb)).unwrap(); + assert_eq!(decoder(&rgb).colortype().unwrap(), tiff::ColorType::RGB(8)); + assert!(decoder(&rgb).find_tag(Tag::ExtraSamples).unwrap().is_none()); + + let gray = t::encode_samples(&opaque, 9, 6, 72.0, &opts(ColorModel::Gray)).unwrap(); + assert_eq!(decoder(&gray).colortype().unwrap(), tiff::ColorType::Gray(8)); + assert_eq!(decoder(&gray).get_tag_unsigned::(Tag::PhotometricInterpretation).unwrap(), 1, "black is zero"); + let g = pixels(&gray); + assert_eq!((g[0], g[8]), (super::jpeg::to_gray()([200, 30, 40]), 255), "lightness; white stays white"); + let on_white = t::encode_samples(&px, 9, 6, 72.0, &opts(ColorModel::Gray)).unwrap(); + assert_eq!(decoder(&on_white).colortype().unwrap(), tiff::ColorType::Gray(8), "grey has no alpha: few readers take it"); + assert_eq!(pixels(&on_white)[0], 255, "the transparent corner is white"); + + // CMYK: four samples a pixel, ink amounts as given. + let inks: Vec = (0..9 * 6 * 4).map(|i| (i * 7 % 256) as u8).collect(); + let cmyk = t::encode_samples(&inks, 9, 6, 72.0, &opts(ColorModel::Cmyk)).unwrap(); + let mut d = decoder(&cmyk); + assert_eq!(d.colortype().unwrap(), tiff::ColorType::CMYK(8)); + assert_eq!(d.get_tag_unsigned::(Tag::SamplesPerPixel).unwrap(), 4); + assert_eq!(d.get_tag_unsigned::(Tag::PhotometricInterpretation).unwrap(), 5, "separated"); + assert_eq!(d.get_tag_unsigned::(Tag::Unknown(332)).unwrap(), 1, "CMYK inks"); + assert_eq!(pixels(&cmyk), inks); +} + +#[test] +fn resolution_tags_and_the_profile() { + let px = art(4, 4, false); + for (ppi, rational) in [(300.0, (300, 1)), (72.5, (7250, 100))] { + let file = t::encode_samples(&px, 4, 4, ppi, &opts(ColorModel::Rgb)).unwrap(); + let mut d = decoder(&file); + for tag in [Tag::XResolution, Tag::YResolution] { + assert_eq!(d.get_tag(tag).unwrap(), tiff::decoder::ifd::Value::Rational(rational.0, rational.1)); + } + assert_eq!(d.get_tag_unsigned::(Tag::ResolutionUnit).unwrap(), 2, "inches"); + } + for model in ColorModel::ALL { + let src = if model == ColorModel::Cmyk { vec![0; 64] } else { px.clone() }; + let file = t::encode_samples(&src, 4, 4, 72.0, &TiffOptions { embed_icc: true, ..opts(model) }).unwrap(); + let icc = decoder(&file).get_tag_u8_vec(Tag::IccProfile).unwrap(); + assert_eq!(&icc[36..40], b"acsp", "{model:?}: an ICC profile"); + let space: &[u8; 4] = match model { + ColorModel::Rgb => b"RGB ", + ColorModel::Cmyk => b"CMYK", + ColorModel::Gray => b"GRAY", + }; + assert_eq!(&icc[16..20], space, "{model:?}"); + } + let file = t::encode_samples(&px, 4, 4, 72.0, &opts(ColorModel::Rgb)).unwrap(); + assert!(decoder(&file).find_tag(Tag::IccProfile).unwrap().is_none()); +} + +#[test] +fn big_images_take_several_strips_in_either_order() { + let (w, h) = (300, 100); + let px = art(w, h, false); + for order in ByteOrder::ALL { + for lzw in [false, true] { + let file = t::encode_samples(&px, w, h, 72.0, &TiffOptions { lzw, byte_order: order, ..opts(ColorModel::Gray) }).unwrap(); + let mut d = decoder(&file); + let offsets = d.get_tag_u32_vec(Tag::StripOffsets).unwrap(); + // 300 bytes a row: 27 rows a strip. + assert_eq!(d.get_tag_u32(Tag::RowsPerStrip).unwrap(), 27); + assert_eq!(offsets.len(), 4); + assert!(offsets.iter().all(|o| o % 2 == 0), "strips start on a word"); + assert_eq!(pixels(&file).len(), (w * h) as usize, "{order:?} lzw {lzw}"); + } + } +} + +#[test] +fn packbits_and_bilevel_images_decode() { + // 10 × 3 at one bit a pixel: two bytes a row, WhiteIsZero. + let rows = [0b1010_1010u8, 0b1100_0000, 0xFF, 0xC0, 0, 0]; + let image = Image { width: 10, height: 3, data: &rows, photometric: Photometric::WhiteIsZero, samples: 1, bits: 1, alpha: false }; + let file = t::write(&image, &Layout { byte_order: ByteOrder::Big, compression: Compression::PackBits, ppi: 72.0, icc: None }).unwrap(); + let mut d = decoder(&file); + assert_eq!(d.colortype().unwrap(), tiff::ColorType::Gray(1)); + assert_eq!(d.get_tag_unsigned::(Tag::Compression).unwrap(), 32773); + // The decoder turns WhiteIsZero into black-is-zero. + assert_eq!(pixels(&file), rows.map(|b| !b)); + // A buffer that doesn't match the size is refused, not written. + let short = Image { data: &rows[..5], ..image }; + assert!(t::write(&short, &Layout { byte_order: ByteOrder::Little, compression: Compression::None, ppi: 72.0, icc: None }).is_err()); +} + +#[test] +fn packbits_round_trips() { + for data in [vec![], vec![7], vec![1, 1, 1, 1, 2, 3, 3, 4, 4, 4], (0..400).map(|i| (i / 3 % 7) as u8).collect(), vec![9; 300]] { + let mut packed = vec![]; + t::packbits(&data, &mut packed); + let mut out = vec![]; + let mut i = 0; + while i < packed.len() { + let n = packed[i] as i8; + if n >= 0 { + out.extend(&packed[i + 1..i + 2 + n as usize]); + i += 2 + n as usize; + } else { + out.extend(std::iter::repeat_n(packed[i + 1], (1 - n as i32) as usize)); + i += 2; + } + } + assert_eq!(out, data); + } +} diff --git a/crates/render/src/encode/tiff.rs b/crates/render/src/encode/tiff.rs new file mode 100644 index 00000000..da8577c9 --- /dev/null +++ b/crates/render/src/encode/tiff.rs @@ -0,0 +1,365 @@ +//! TIFF export (baseline TIFF 6.0): little-endian (`II`) or big-endian (`MM`), strips of about +//! 8 KiB stored as they are, LZW-compressed (weezl, with TIFF's early code-size switch) or +//! PackBits; black and white, grey, RGB or CMYK (separated, ink amounts) samples with an optional +//! unassociated alpha channel, the resolution and an embedded colour profile. [`write`] is the one +//! TIFF writer: raster exports ([`encode`]) and EPS previews both use it. + +use std::borrow::Cow; + +use vectorcraft_color::cms; + +use super::jpeg::{self, ColorModel}; +use super::on_white; +use crate::Rendered; + +/// The order of the bytes in the file's numbers (the samples are bytes either way). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum ByteOrder { + /// Least significant byte first (`II`). + #[default] + Little, + /// Most significant byte first (`MM`). + Big, +} + +impl ByteOrder { + pub const ALL: [ByteOrder; 2] = [ByteOrder::Little, ByteOrder::Big]; + + pub fn id(self) -> &'static str { + match self { + ByteOrder::Little => "little", + ByteOrder::Big => "big", + } + } + + pub fn label(self) -> &'static str { + match self { + ByteOrder::Little => "Little-endian (II)", + ByteOrder::Big => "Big-endian (MM)", + } + } + + /// By id or label, ignoring case; also by the file's marker (`ii`, `mm`) and the platforms the + /// orders are known by (`ibm`, `mac`). + pub fn from_id(s: &str) -> Option { + let s = s.to_ascii_lowercase(); + match s.as_str() { + "ii" | "ibm" | "pc" | "intel" => Some(ByteOrder::Little), + "mm" | "mac" | "motorola" => Some(ByteOrder::Big), + _ => Self::ALL.into_iter().find(|o| o.id() == s || o.label().eq_ignore_ascii_case(&s)), + } + } + + fn u16(self, v: u16) -> [u8; 2] { + match self { + ByteOrder::Little => v.to_le_bytes(), + ByteOrder::Big => v.to_be_bytes(), + } + } + + fn u32(self, v: u32) -> [u8; 4] { + match self { + ByteOrder::Little => v.to_le_bytes(), + ByteOrder::Big => v.to_be_bytes(), + } + } +} + +/// How the strips are compressed. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Compression { + None, + Lzw, + /// Run lengths, each row on its own. + PackBits, +} + +impl Compression { + fn code(self) -> u16 { + match self { + Compression::None => 1, + Compression::Lzw => 5, + Compression::PackBits => 32773, + } + } +} + +/// What the samples mean. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Photometric { + /// Grey or bilevel, 0 is white (a set bit is black). + WhiteIsZero, + /// Grey, 0 is black. + BlackIsZero, + Rgb, + /// Ink amounts, 0 is no ink (CMYK). + Separated, +} + +impl Photometric { + fn code(self) -> u16 { + match self { + Photometric::WhiteIsZero => 0, + Photometric::BlackIsZero => 1, + Photometric::Rgb => 2, + Photometric::Separated => 5, + } + } +} + +/// The pixels [`write`] stores. +pub struct Image<'a> { + pub width: u32, + pub height: u32, + /// Rows of samples, `bits` per sample, each row starting on a byte. + pub data: &'a [u8], + pub photometric: Photometric, + /// Samples per pixel, the alpha one included. + pub samples: u16, + /// Bits per sample: 1 or 8. + pub bits: u16, + /// The last sample is unassociated alpha. + pub alpha: bool, +} + +/// How [`write`] stores the pixels and what else goes in the file. +pub struct Layout<'a> { + pub byte_order: ByteOrder, + pub compression: Compression, + /// Pixels per inch. + pub ppi: f64, + /// A colour profile to embed. + pub icc: Option<&'a [u8]>, +} + +/// Uncompressed bytes per strip (the size the TIFF specification recommends). +const STRIP_BYTES: usize = 8 << 10; + +/// A field's values. +enum Values { + Ascii(Vec), + Short(Vec), + Long(Vec), + Rational(u32, u32), + Undefined(Vec), +} + +impl Values { + /// Field type and count. + fn kind(&self) -> (u16, usize) { + match self { + Values::Ascii(v) => (2, v.len()), + Values::Short(v) => (3, v.len()), + Values::Long(v) => (4, v.len()), + Values::Rational(..) => (5, 1), + Values::Undefined(v) => (7, v.len()), + } + } + + /// Size in bytes. + fn len(&self) -> usize { + match self { + Values::Ascii(v) | Values::Undefined(v) => v.len(), + Values::Short(v) => v.len() * 2, + Values::Long(v) => v.len() * 4, + Values::Rational(..) => 8, + } + } + + fn bytes(&self, o: ByteOrder) -> Vec { + match self { + Values::Ascii(v) | Values::Undefined(v) => v.clone(), + Values::Short(v) => v.iter().flat_map(|x| o.u16(*x)).collect(), + Values::Long(v) => v.iter().flat_map(|x| o.u32(*x)).collect(), + Values::Rational(n, d) => [o.u32(*n), o.u32(*d)].concat(), + } + } +} + +/// Write `img` as a TIFF file: header, the image file directory, the values too long for it, then +/// the strips. +pub fn write(img: &Image, l: &Layout) -> Result, String> { + let too_large = || "the image is too large for TIFF (at most 4 GB): lower the resolution".to_string(); + let (w, h) = (img.width as usize, img.height as usize); + let row = (w * usize::from(img.samples) * usize::from(img.bits)).div_ceil(8); + if w == 0 || h == 0 || !matches!(img.bits, 1 | 8) || img.samples == 0 || row.checked_mul(h) != Some(img.data.len()) { + return Err("TIFF encoding failed: the pixel buffer doesn't match the image size".into()); + } + let rows_per_strip = (STRIP_BYTES / row).clamp(1, h); + let mut strips: Vec = Vec::with_capacity(img.data.len() / 2); + let mut offsets = vec![]; + for strip in img.data.chunks(rows_per_strip * row) { + offsets.push(strips.len()); + match l.compression { + Compression::None => strips.extend_from_slice(strip), + Compression::Lzw => { + let lzw = weezl::encode::Encoder::with_tiff_size_switch(weezl::BitOrder::Msb, 8) + .encode(strip) + .map_err(|e| format!("TIFF encoding failed: {e}"))?; + strips.extend(lzw); + } + Compression::PackBits => strip.chunks(row).for_each(|r| packbits(r, &mut strips)), + } + // Strips start on a word boundary. + if strips.len() % 2 == 1 { + strips.push(0); + } + } + let counts: Vec = offsets.iter().zip(offsets.iter().skip(1).chain([&strips.len()])).map(|(a, b)| b - a).collect(); + let mut fields: Vec<(u16, Values)> = vec![ + (256, Values::Long(vec![img.width])), + (257, Values::Long(vec![img.height])), + (258, Values::Short(vec![img.bits; usize::from(img.samples)])), + (259, Values::Short(vec![l.compression.code()])), + (262, Values::Short(vec![img.photometric.code()])), + // StripOffsets: filled in once the directory's size is known. + (273, Values::Long(vec![0; offsets.len()])), + (277, Values::Short(vec![img.samples])), + (278, Values::Long(vec![rows_per_strip as u32])), + (279, Values::Long(counts.iter().map(|c| u32::try_from(*c).unwrap_or(u32::MAX)).collect())), + (282, resolution(l.ppi)), + (283, resolution(l.ppi)), + // Chunky: the samples of a pixel together. + (284, Values::Short(vec![1])), + // Inches. + (296, Values::Short(vec![2])), + (305, Values::Ascii(b"VectorCraft\0".to_vec())), + ]; + if img.photometric == Photometric::Separated { + // InkSet: CMYK. + fields.push((332, Values::Short(vec![1]))); + } + if img.alpha { + // Unassociated alpha. + fields.push((338, Values::Short(vec![2]))); + } + if let Some(icc) = l.icc { + fields.push((34675, Values::Undefined(icc.to_vec()))); + } + // Header, directory (count, 12-byte entries, next directory), longer values, strips. + let dir_len = 2 + fields.len() * 12 + 4; + let extra_len: usize = fields.iter().map(|(_, v)| v.len()).filter(|n| *n > 4).map(|n| n.next_multiple_of(2)).sum(); + let strip_at = 8 + dir_len + extra_len; + let end = strip_at.checked_add(strips.len()).filter(|n| u32::try_from(*n).is_ok()).ok_or_else(too_large)?; + if let Some((_, Values::Long(v))) = fields.iter_mut().find(|(tag, _)| *tag == 273) { + *v = offsets.iter().map(|o| (strip_at + o) as u32).collect(); + } + let o = l.byte_order; + let mut out = Vec::with_capacity(end); + out.extend_from_slice(if o == ByteOrder::Little { b"II" } else { b"MM" }); + out.extend(o.u16(42)); + out.extend(o.u32(8)); + out.extend(o.u16(fields.len() as u16)); + let mut extra = Vec::with_capacity(extra_len); + for (tag, v) in &fields { + let (ty, count) = v.kind(); + let mut bytes = v.bytes(o); + out.extend(o.u16(*tag)); + out.extend(o.u16(ty)); + out.extend(o.u32(u32::try_from(count).map_err(|_| too_large())?)); + if bytes.len() <= 4 { + // Left-justified in the value field. + bytes.resize(4, 0); + out.extend(bytes); + } else { + out.extend(o.u32((8 + dir_len + extra.len()) as u32)); + extra.extend(&bytes); + if bytes.len() % 2 == 1 { + extra.push(0); + } + } + } + out.extend(o.u32(0)); + out.extend(extra); + out.extend(strips); + Ok(out) +} + +/// A resolution as a rational, to a hundredth of a pixel per inch. +fn resolution(ppi: f64) -> Values { + let ppi = if ppi.is_finite() && ppi > 0.0 { ppi } else { 72.0 }; + if ppi.fract() == 0.0 && ppi <= u32::MAX as f64 { + return Values::Rational(ppi as u32, 1); + } + Values::Rational((ppi * 100.0).round().clamp(1.0, u32::MAX as f64) as u32, 100) +} + +/// `data` run-length encoded (PackBits: what TIFF's compression 32773 and PostScript's +/// `RunLengthDecode` read), without an end marker. +pub fn packbits(data: &[u8], out: &mut Vec) { + let mut i = 0; + while i < data.len() { + let Some(&b) = data.get(i) else { break }; + let run = data.get(i..).map_or(1, |rest| rest.iter().take(128).take_while(|x| **x == b).count()); + if run >= 3 { + out.push((257 - run) as u8); + out.push(b); + i += run; + continue; + } + // Literal bytes up to the next run of three. + let mut j = i; + while j < data.len() && j - i < 128 { + let three = data.get(j..j + 3).is_some_and(|w| w[0] == w[1] && w[1] == w[2]); + if three { + break; + } + j += 1; + } + let j = j.max(i + 1).min(data.len()); + out.push((j - i - 1) as u8); + out.extend(data.get(i..j).unwrap_or_default()); + i = j; + } +} + +/// The TIFF options of a raster export. +#[derive(Clone, Debug, PartialEq)] +pub struct TiffOptions { + pub color_model: ColorModel, + pub lzw: bool, + pub byte_order: ByteOrder, + /// Embed the colour profile of the colour model. + pub embed_icc: bool, +} + +impl Default for TiffOptions { + fn default() -> Self { + Self { color_model: ColorModel::Rgb, lzw: true, byte_order: ByteOrder::Little, embed_icc: true } + } +} + +/// `img` as a TIFF in `o.color_model`: RGB, with an alpha channel when some pixel isn't opaque, +/// grey on white (few readers take grey with alpha), or CMYK (its colours separated on white; +/// [`crate::Renderer::export_region`] draws inks). +pub fn encode(img: &Rendered, ppi: f64, o: &TiffOptions) -> Result, String> { + let px = match o.color_model { + ColorModel::Cmyk => jpeg::separated(img), + ColorModel::Rgb | ColorModel::Gray => img.to_straight(), + }; + encode_samples(&px, img.width, img.height, ppi, o) +} + +/// Pixels as a TIFF in `o.color_model` (see [`encode`]): straight RGBA for RGB and grey, ink +/// amounts for CMYK. +pub(crate) fn encode_samples(px: &[u8], width: u32, height: u32, ppi: f64, o: &TiffOptions) -> Result, String> { + let model = o.color_model; + let (data, photometric, alpha) = match model { + ColorModel::Cmyk => (Cow::Borrowed(px), Photometric::Separated, false), + ColorModel::Rgb => { + let rgba = px.as_chunks::<4>().0; + let alpha = rgba.iter().any(|p| p[3] < 255); + let data = if alpha { Cow::Borrowed(px) } else { rgba.iter().flat_map(|p| [p[0], p[1], p[2]]).collect() }; + (data, Photometric::Rgb, alpha) + } + ColorModel::Gray => { + let gray = jpeg::to_gray(); + (px.as_chunks::<4>().0.iter().map(|p| gray(on_white(p))).collect(), Photometric::BlackIsZero, false) + } + }; + let icc = if o.embed_icc { Some(cms::icc_bytes(&model.profile()).map_err(|e| format!("TIFF colour profile: {e}"))?) } else { None }; + let samples = (model.channels() + usize::from(alpha)) as u16; + let image = Image { width, height, data: &data, photometric, samples, bits: 8, alpha }; + let compression = if o.lzw { Compression::Lzw } else { Compression::None }; + write(&image, &Layout { byte_order: o.byte_order, compression, ppi, icc: icc.as_deref() }) +} diff --git a/crates/ui-egui/src/dialogs/mod.rs b/crates/ui-egui/src/dialogs/mod.rs index 7048602b..bfff8bb8 100644 --- a/crates/ui-egui/src/dialogs/mod.rs +++ b/crates/ui-egui/src/dialogs/mod.rs @@ -58,6 +58,7 @@ pub mod swatch_conflict; pub mod swatch_options; mod text_export; pub mod text_import; +mod tiff_bmp_tga; pub mod tile_edge_color; mod tools; mod transform; @@ -227,6 +228,7 @@ registry! { EpsOptions: [eps_options::KIND] => eps_options::SPEC, Recovery: [crate::recovery::KIND] => recovery::SPEC, DxfImport: [dxf_import::KIND] => dxf_import::SPEC, + RasterFormatOptions: ["tiffOptions"] => png_options::SPEC, } /// The spec for a `Dialog::kind` ([`DialogSpec::FORM`] when unregistered). @@ -347,3 +349,6 @@ mod tests_eps; #[cfg(test)] mod tests_metafile; + +#[cfg(test)] +mod tests_tiff_bmp_tga; diff --git a/crates/ui-egui/src/dialogs/png_options.rs b/crates/ui-egui/src/dialogs/png_options.rs index edae47aa..6b3ec394 100644 --- a/crates/ui-egui/src/dialogs/png_options.rs +++ b/crates/ui-egui/src/dialogs/png_options.rs @@ -1,8 +1,9 @@ //! The raster export options: PNG Options (resolution, background, anti-aliasing, interlaced), //! JPEG Options (no transparency; colour model, quality 0–10, method and scans, profile, image //! map), WebP Options, and PNG-8 and GIF Options (the palette: colour reduction, colours, dither, -//! transparency and matte). Fields are `document.export` params (format, path, artboard -//! choice…); `__`-prefixed ones are the dialog's. +//! transparency and matte), and TIFF Options ([`super::tiff_bmp_tga`]). Fields +//! are `document.export` params (format, path, artboard choice…); `__`-prefixed ones are the +//! dialog's. use serde::Deserialize; use serde_json::{Map, Value, json}; @@ -11,7 +12,7 @@ use vectorcraft_render::AntiAlias; use vectorcraft_render::encode::jpeg::{self, ColorModel, Method}; use vectorcraft_render::encode::quantize::{Dither, PaletteOptions, Reduction}; -use super::{DialogSpec, form}; +use super::{DialogSpec, form, tiff_bmp_tga}; use crate::state::Dialog; use crate::theme::Tokens; use crate::{VectorcraftApp, io, widgets}; @@ -68,13 +69,13 @@ pub(super) fn defaults(app: &VectorcraftApp, f: &Format) -> Map { let mut o = Map::new(); o.insert("background".into(), json!(if white { "white" } else { "transparent" })); o.insert("antiAlias".into(), json!(AntiAlias::default().id())); + // A CMYK document exports CMYK JPEGs and TIFFs by default. + let cmyk = app.session.active().is_some_and(|st| st.doc.color_mode == vectorcraft_doc::ColorMode::Cmyk); match f.id { "png" => { o.insert("interlaced".into(), json!(false)); } "jpg" => { - // A CMYK document exports CMYK by default. - let cmyk = app.session.active().is_some_and(|st| st.doc.color_mode == vectorcraft_doc::ColorMode::Cmyk); let model = if cmyk { ColorModel::Cmyk } else { ColorModel::Rgb }; let defaults = jpeg::JpegOptions::default(); o.extend([ @@ -98,7 +99,7 @@ pub(super) fn defaults(app: &VectorcraftApp, f: &Format) -> Map { ("interlaced".into(), json!(false)), ]); } - _ => {} + id => tiff_bmp_tga::defaults(id, cmyk, &mut o), } o } @@ -170,10 +171,11 @@ pub(super) fn option_rows(ui: &mut egui::Ui, d: &mut Dialog, id: &str, screens: 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"); + // JPEG has no transparency, nor have some TIFF, BMP and Targa options: their list starts at + // White, which is what transparent becomes. + let first = usize::from(!tiff_bmp_tga::keeps_alpha(id, d)); ui.horizontal(|ui| { - if let Some(i) = widgets::dropdown(ui, "ro-bg", BACKGROUND_LABELS[choice], &BACKGROUND_LABELS[first..], 150.0) { + if let Some(i) = widgets::dropdown(ui, "ro-bg", BACKGROUND_LABELS[choice.max(first)], &BACKGROUND_LABELS[first..], 150.0) { let value = BACKGROUNDS.get(i + first).copied().unwrap_or("#808080"); d.fields.insert("background".into(), json!(value)); } @@ -203,7 +205,7 @@ pub(super) fn option_rows(ui: &mut egui::Ui, d: &mut Dialog, id: &str, screens: label(ui, "Lossless (lossy WebP isn't available yet)"); ui.end_row(); } - _ => {} + id => tiff_bmp_tga::rows(ui, d, id, &label), } } diff --git a/crates/ui-egui/src/dialogs/tests_tiff_bmp_tga.rs b/crates/ui-egui/src/dialogs/tests_tiff_bmp_tga.rs new file mode 100644 index 00000000..b823b746 --- /dev/null +++ b/crates/ui-egui/src/dialogs/tests_tiff_bmp_tga.rs @@ -0,0 +1,33 @@ +//! TIFF Options through Export As, drawn headlessly. + +use serde_json::json; + +use super::tests_export::{app, frame, kind, set}; +use super::*; + +#[test] +fn tiff_options_pick_the_model_byte_order_and_compression() { + let (mut app, written) = app(1); + app.run("file.exportAs", json!({"format": "tiff"})).unwrap(); + confirm(&mut app).unwrap(); + assert_eq!(kind(&app), Some("tiffOptions")); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!((spec(&d.kind).heading)(d), "TIFF Options"); + assert_eq!((d.str("colorModel"), d.str("byteOrder"), d.bool("lzw"), d.bool("embedIcc")), ("rgb".into(), "little".into(), true, true)); + frame(&mut app); + set(&mut app, "byteOrder", json!("big")); + set(&mut app, "colorModel", json!("cmyk")); + set(&mut app, "lzw", json!(false)); + frame(&mut app); + confirm(&mut app).unwrap(); + let (path, tiff) = written.borrow()[0].clone(); + assert_eq!(path, "/out/Untitled-1.tif"); + assert_eq!(&tiff[..4], b"MM\0*"); + assert!(app.ui.dialog.is_none()); + + // A CMYK document exports CMYK by default. + app.run("object.convertDocumentColorMode", json!({"mode": "cmyk"})).unwrap(); + app.run("file.exportAs", json!({"format": "tiff"})).unwrap(); + confirm(&mut app).unwrap(); + assert_eq!(app.ui.dialog.as_ref().unwrap().str("colorModel"), "cmyk"); +} diff --git a/crates/ui-egui/src/dialogs/tiff_bmp_tga.rs b/crates/ui-egui/src/dialogs/tiff_bmp_tga.rs new file mode 100644 index 00000000..711712ec --- /dev/null +++ b/crates/ui-egui/src/dialogs/tiff_bmp_tga.rs @@ -0,0 +1,59 @@ +//! The options of TIFF (colour model, byte order, LZW, profile), after the raster export rows it +//! shares with PNG Options ([`super::png_options`]): the `tiffOptions` dialog. + +use serde_json::{Map, Value, json}; +use vectorcraft_render::encode::jpeg::ColorModel; +use vectorcraft_render::encode::tiff::{ByteOrder, TiffOptions}; + +use super::form; +use super::png_options::choice; +use crate::state::Dialog; + +type Label<'a> = &'a dyn Fn(&mut egui::Ui, &str) -> egui::Response; + +/// The options format `id` starts with (`cmyk`: a CMYK document exports CMYK TIFFs). +pub(super) fn defaults(id: &str, cmyk: bool, o: &mut Map) { + if id == "tiff" { + let t = TiffOptions::default(); + let model = if cmyk { ColorModel::Cmyk } else { t.color_model }; + o.extend([ + ("colorModel".into(), json!(model.id())), + ("byteOrder".into(), json!(t.byte_order.id())), + ("lzw".into(), json!(t.lzw)), + ("embedIcc".into(), json!(t.embed_icc)), + ]); + } +} + +/// Does format `id` keep transparency with the dialog's options (else it is flattened on white)? +pub(super) fn keeps_alpha(id: &str, d: &Dialog) -> bool { + match id { + "jpg" => false, + "tiff" => ColorModel::from_id(&d.str("colorModel")).unwrap_or_default() == ColorModel::Rgb, + _ => true, + } +} + +/// The grid rows of format `id`'s own options. +pub(super) fn rows(ui: &mut egui::Ui, d: &mut Dialog, id: &str, label: Label) { + if id == "tiff" { + tiff_rows(ui, d, label); + } +} + +fn tiff_rows(ui: &mut egui::Ui, d: &mut Dialog, label: Label) { + label(ui, "Color Model:"); + choice(ui, d, "colorModel", &ColorModel::ALL.map(ColorModel::id), &ColorModel::ALL.map(ColorModel::label)); + ui.end_row(); + + label(ui, "Byte Order:"); + choice(ui, d, "byteOrder", &ByteOrder::ALL.map(ByteOrder::id), &ByteOrder::ALL.map(ByteOrder::label)); + ui.end_row(); + + ui.label(""); + ui.horizontal(|ui| { + form::check(ui, d, "lzw", "LZW Compression"); + form::check(ui, d, "embedIcc", "Embed ICC Profile"); + }); + ui.end_row(); +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index c0304f9d..d6d588d4 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -365,3 +365,8 @@ artboards' files. EMF and WMF: Export As… lists both formats (`file.exportAs {format: "emf"}` or `"wmf"`); they have no options dialog, so `ui.dialog.confirm` asks for the path and writes the file (one per artboard with Use Artboards). Open and Place read `.emf` and `.wmf`; a paste of `image/emf` from a system clipboard service runs `clipboard.importEmf`. + +TIFF Options: Export As… with the TIFF format (`file.exportAs {format: "tiff"}`, then the save dialog) opens +`tiffOptions`: the raster rows of PNG Options (`ppi`, `background`, `antiAlias`), then `colorModel` (`rgb`, `cmyk`, +`gray`; a CMYK document starts on `cmyk`), `byteOrder` (`little`, `big`), `lzw` and `embedIcc`. `ui.dialog.set` the +fields and `ui.dialog.confirm` writes the file(s) with `document.export`. diff --git a/docs/mcp.md b/docs/mcp.md index 111b4585..d4f00350 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -64,7 +64,7 @@ objects' fills or strokes differ (`fillMixed` / `strokeMixed`, drawn as a "?" pr | `screenshot` | `{path?, scale?, artboard?, window?}` | Returns MCP image content (`image/png`, base64) plus a text block. Renders the artboard; `window:true` captures the app window (remote only). | | `open_file` | `{path}` | Opens any readable file as a new active document: `.vectorcraft`/`.drawcraft`, `.svg`/`.svgz`, `.pdf`/`.ai`, `.ait`, PNG, JPEG, GIF, WebP, TIFF, BMP (an image opens as a document of its pixel size). Templates open as a new untitled document. PDF, `.ai` and SVG files saved with Preserve Editing reopen as the document they carry. `run_command document.formats` lists the formats. | | `save_file` | `{path?}` | Runs `document.save`: the document's own file in its own format (native `.vectorcraft` unless it was opened from or saved as SVG, PDF or a restorable `.ai`; then `warnings` say what that format loses). A path's extension picks the format (`.vectorcraft`, `.vctemplate`, `.pdf`, `.svg`, `.svgz`, `.ai`: a PDF carrying the native document, which reopens editable). | -| `export` | `{path?, format?, scale?, artboard?, range?, selection?, outlineText?, options?}` | `svg`, `pdf`, `png`, `jpg`, `webp`, `gif`, `png8` (an indexed `.png`), `txt` (the document's text) or `vectorcraft` (the list comes from `document.formats`). When `format` is omitted, it comes from the path's extension. PDF writes one page per artboard: all of them, or `artboard` (0-based) / `range` (`"1-3, 5"`, 1-based); the other formats write one artboard. `options` carries more format options (e.g. `{"quality": 80}` for JPEG). `selection: true` exports the selected objects cropped to their bounds; `outlineText: true` writes SVG text as paths. Template layers are left out, live effects are kept, and exporting `vectorcraft` never changes the document's path. Without `path` the bytes come back as `dataBase64`. Both backends run the same `document.export` call. | +| `export` | `{path?, format?, scale?, artboard?, range?, selection?, outlineText?, options?}` | `svg`, `pdf`, `png`, `jpg`, `webp`, `gif`, `png8` (an indexed `.png`), `tiff`, `txt` (the document's text) or `vectorcraft` (the list comes from `document.formats`). When `format` is omitted, it comes from the path's extension. PDF writes one page per artboard: all of them, or `artboard` (0-based) / `range` (`"1-3, 5"`, 1-based); the other formats write one artboard. `options` carries more format options (e.g. `{"quality": 80}` for JPEG). `selection: true` exports the selected objects cropped to their bounds; `outlineText: true` writes SVG text as paths. Template layers are left out, live effects are kept, and exporting `vectorcraft` never changes the document's path. Without `path` the bytes come back as `dataBase64`. Both backends run the same `document.export` call. | | `add_text` | `{text, x?, y?, width?, height?, path?, mode?, pathEffect?, size?, font?, color?}` | Point type at (x, y); area type with `width`/`height`; or `path` + `mode` (`area`/`onPath`) to flow text in or along a path, with `pathEffect` (`rainbow`, `skew`, `3dRibbon`, `stairStep`, `gravity`). | | `apply_effect` | `{effect?, params?, ids?}` | Appends a live effect. Without `effect`, returns the effect catalogue with parameters and defaults. | | `pathfinder` | `{operation, ids?}` | `unite`, `minusFront`, `intersect`, `exclude`, `divide`, `trim`, `merge`, `crop`, `outline`, `minusBack`. For a live version, apply the `pathfinder.*` effect to a group. | @@ -1363,3 +1363,15 @@ clipboard service offers it (the Windows desktop service doesn't read metafiles {"name":"run_command","arguments":{"command":"document.export","params":{"format":"wmf","useArtboards":true,"path":"/tmp/icons.wmf"}}} {"name":"run_command","arguments":{"command":"file.place","params":{"path":"/tmp/chart.emf","at":[300,200]}}} ``` + +## TIFF export + +`document.export` (and `export`) writes TIFF like the other raster formats: one artboard (or `useArtboards`, one file +each), at `ppi`, over `background`, with `antiAlias`. It takes `colorModel` (`rgb` keeps transparency as an +unassociated alpha channel, `cmyk` writes ink amounts in the working CMYK space with CMYK colours keeping their inks, +`gray` is flattened on white), `lzw` (default true), `byteOrder` (`little`, the `II` files PCs write, or `big`, `MM`) +and `embedIcc` (sRGB, the working CMYK profile or grey). TIFF files open again; `document.formats` lists every option. + +```json +{"name":"export","arguments":{"path":"/tmp/print.tif","options":{"ppi":300,"colorModel":"cmyk","byteOrder":"big"}}} +``` diff --git a/xtask/src/layers.rs b/xtask/src/layers.rs index 66956324..7f935905 100644 --- a/xtask/src/layers.rs +++ b/xtask/src/layers.rs @@ -64,8 +64,10 @@ pub const TABLE: &[(&str, Class)] = &[ /// Explicit orderings *within* a layer (earlier may be used by later). /// The L0 foundation is a small chain: `raster` builds on `color` and /// `geom`, which the §3 diagram draws on one line. The GPU backend (`gpu`) -/// reuses the CPU reference (`compose`) for LUTs and parity tests. -pub const INTRA_LAYER_ORDER: &[&[&str]] = &[&["geom", "color"], &["pathops", "effects"], &["pathops", "trace"], &["text", "effects"]]; +/// reuses the CPU reference (`compose`) for LUTs and parity tests. EPS previews use the renderer's +/// TIFF writer. +pub const INTRA_LAYER_ORDER: &[&[&str]] = + &[&["geom", "color"], &["pathops", "effects"], &["pathops", "trace"], &["text", "effects"], &["render", "eps"]]; fn intra_layer_allowed(from: &str, to: &str) -> bool { let (from, to) = (short_name(from), short_name(to)); From f2fc8782c7576b406458254c53ea2625da16fe00 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 5 Oct 2026 14:55:42 +0700 Subject: [PATCH 2/3] M4.68: BMP export at 1 to 32 bits, Windows or OS/2, with RLE and flipped rows document.export {format: "bmp"} writes BMP through the raster export path: depth 1 (black and white), 4 or 8 (a palette by reduction and dither, optionally RLE4/RLE8), 16 (5-5-5), 24 or 32 (with alpha, a version 4 header); colorModel rgb or gray; fileFormat windows or os2 (a core header); flipRows writes top-down rows (a negative height). Impossible combinations are refused as bad params. Export As offers it with the bmpOptions dialog, which keeps its choices writable together; BMP moves from read-only to read and write (tests that used .bmp as the unwritable format now use .dwg). Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/fileio/encode.rs | 40 +++- crates/engine/src/cmd/fileio/mod.rs | 46 +++- crates/engine/src/cmd/fileio/tests.rs | 4 +- crates/engine/src/cmd/fileio/tests_tiffbmp.rs | 43 +++- crates/mcp/src/tests.rs | 2 +- crates/mcp/src/tests_fileio.rs | 20 +- crates/mcp/src/tools.rs | 2 +- crates/render/src/encode/bmp.rs | 221 ++++++++++++++++++ crates/render/src/encode/mod.rs | 14 +- crates/render/src/encode/png.rs | 2 +- crates/render/src/encode/quantize.rs | 2 +- crates/render/src/encode/tests_bmp.rs | 133 +++++++++++ crates/ui-egui/src/dialogs/mod.rs | 2 +- crates/ui-egui/src/dialogs/png_options.rs | 2 +- .../ui-egui/src/dialogs/tests_tiff_bmp_tga.rs | 32 ++- crates/ui-egui/src/dialogs/tiff_bmp_tga.rs | 119 ++++++++-- crates/ui-egui/src/io.rs | 2 +- docs/control-protocol.md | 11 +- docs/mcp.md | 15 +- 19 files changed, 668 insertions(+), 44 deletions(-) create mode 100644 crates/render/src/encode/bmp.rs create mode 100644 crates/render/src/encode/tests_bmp.rs diff --git a/crates/engine/src/cmd/fileio/encode.rs b/crates/engine/src/cmd/fileio/encode.rs index 974e81f0..dca31adc 100644 --- a/crates/engine/src/cmd/fileio/encode.rs +++ b/crates/engine/src/cmd/fileio/encode.rs @@ -12,7 +12,7 @@ use vectorcraft_render::AntiAlias; use vectorcraft_render::encode::jpeg::{self, JpegOptions}; use vectorcraft_render::encode::quantize::{Dither, PaletteOptions, Reduction}; use vectorcraft_render::encode::tiff::{ByteOrder, TiffOptions}; -use vectorcraft_render::encode::{RasterExportOptions, RasterFormat}; +use vectorcraft_render::encode::{RasterExportOptions, RasterFormat, bmp}; use super::super::*; use super::Format; @@ -165,6 +165,12 @@ struct RasterOptions { lossless: Option, lzw: Option, byte_order: Option, + /// BMP bits per pixel. + depth: Option, + /// BMP layout: `windows` or `os2`. + file_format: Option, + rle: Option, + flip_rows: Option, } impl RasterOptions { @@ -214,10 +220,38 @@ impl RasterOptions { byte_order: parse(self.byte_order.as_deref(), ByteOrder::from_id, "byteOrder", "little or big")?, embed_icc, }, + bmp: bmp::BmpOptions { + os2: parse(self.file_format.as_deref(), bmp_layout, "fileFormat", "windows or os2")?, + depth: self.depth.unwrap_or(24), + rle: self.rle.unwrap_or(false), + top_down: self.flip_rows.unwrap_or(false), + gray: color_model == jpeg::ColorModel::Gray, + }, }) } } +/// The BMP layout by name: `true` for OS/2. +fn bmp_layout(s: &str) -> Option { + match s.to_ascii_lowercase().as_str() { + "windows" => Some(false), + "os2" | "os/2" => Some(true), + _ => None, + } +} + +/// Refuse options `format` can't write together: BMP depths and layouts, and colour models (BMP +/// is RGB or grey). +fn check_options(format: RasterFormat, o: &RasterExportOptions) -> Result<()> { + let model = o.jpeg.color_model; + let r = match format { + RasterFormat::Bmp if model == jpeg::ColorModel::Cmyk => Err("BMP files are RGB or grayscale: colorModel rgb or gray".to_string()), + RasterFormat::Bmp => o.bmp.check(), + _ => Ok(()), + }; + r.map_err(|e| bad(C, e)) +} + /// An enum option by id (its default when absent). fn parse(v: Option<&str>, from_id: fn(&str) -> Option, name: &str, ids: &str) -> Result { v.map(|s| from_id(s).ok_or_else(|| bad(C, format!("{name} `{s}`: {ids}")))).transpose().map(Option::unwrap_or_default) @@ -342,7 +376,7 @@ pub fn encode_all(doc: &Document, format: &str, p: &Value) -> Result { let (bytes, warnings) = super::pdf::encode(C, doc, p)?; return Ok(Encoded { warnings, ..Encoded::one(bytes) }); } - "png" | "jpg" | "webp" | "gif" | "png8" | "tiff" => { + "png" | "jpg" | "webp" | "gif" | "png8" | "tiff" | "bmp" => { let o: RasterOptions = options(f, p)?; // New Document → Background Contents: White makes the export opaque, unless `background` // says otherwise (JPEG has no alpha: white either way). @@ -355,8 +389,10 @@ pub fn encode_all(doc: &Document, format: &str, p: &Value) -> Result { "gif" => RasterFormat::Gif, "png8" => RasterFormat::Png8, "tiff" => RasterFormat::Tiff, + "bmp" => RasterFormat::Bmp, _ => RasterFormat::WebP, }; + check_options(format, &settings)?; // Use Artboards: one file per chosen artboard (default all); else the one chosen. let chosen = match use_artboards { Some(true) => boards(o.boards.resolve(n))?.unwrap_or_else(|| (0..n).collect()), diff --git a/crates/engine/src/cmd/fileio/mod.rs b/crates/engine/src/cmd/fileio/mod.rs index 103de1c7..1ddbc0ca 100644 --- a/crates/engine/src/cmd/fileio/mod.rs +++ b/crates/engine/src/cmd/fileio/mod.rs @@ -83,7 +83,7 @@ pub fn specs() -> Vec { "Serialize Document", [], None, - "{format?: vectorcraft (default)|template|svg|svgz|pdf|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|tiff, …the format's options (see document.formats; SVG ones also as svg: {…}), selectedOnly?: false (the selected objects alone, in their layers)} → {text, warnings} for svg (plus dataBase64, the file, when its encoding isn't UTF-8), else {dataBase64, warnings}; an SVG of several artboards also gives files: [{name, text}], linked images linked: [{name, dataBase64}]", + "{format?: vectorcraft (default)|template|svg|svgz|pdf|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|tiff|bmp, …the format's options (see document.formats; SVG ones also as svg: {…}), selectedOnly?: false (the selected objects alone, in their layers)} → {text, warnings} for svg (plus dataBase64, the file, when its encoding isn't UTF-8), else {dataBase64, warnings}; an SVG of several artboards also gives files: [{name, text}], linked images linked: [{name, dataBase64}]", has_doc, export::serialize ), @@ -92,7 +92,7 @@ pub fn specs() -> Vec { "Export Document", [], None, - "{path?, format?: svg|svgz|pdf|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|tiff|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png), selectedOnly?: false (the selected objects alone, in their layers), 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), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf: every page) | false (pdf/raster: the bounds of the visible art; SVG has it as an SVG option), 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), jpg: quality?: 90 (0–100), colorModel?: rgb|cmyk|gray, method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as .html / .map), gif/png8: colors?: 256 (2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?, webp: lossless?: true (lossy WebP isn't available yet: written lossless, with a warning), txt: the stories in stacking order (back to front; a thread once), encoding?: utf8|utf16 (with a byte order mark), lineEndings?: lf|crlf, selectionOnly?: false; SVG options flat or as svg: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers, encoding, profile, embedFonts} (see document.formats), …the PDF options of document.exportPdf, …the DXF options of document.exportDxf (useArtboards: one drawing per artboard), …the EPS options of document.exportEps (useArtboards: one file per artboard, {stem}_{artboard}.eps; else the visible art), emf/wmf: one picture of the artboard (useArtboards: true one file per chosen artboard, false the bounds of the visible art; EMF keeps curves, clipping, transparent images and gradients as images clipped to their shape; WMF flattens curves into polygons behind a placeable header; what a format leaves out comes back in warnings), tiff: colorModel?: rgb|cmyk|gray (rgb keeps transparency as an alpha channel; cmyk and gray are flattened on white), lzw?: true, byteOrder?: little|big, embedIcc?: true} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; 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|gif|png8|txt|dxf|eps|emf|wmf|tiff|bmp|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png), selectedOnly?: false (the selected objects alone, in their layers), 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), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf: every page) | false (pdf/raster: the bounds of the visible art; SVG has it as an SVG option), 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), jpg: quality?: 90 (0–100), colorModel?: rgb|cmyk|gray, method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as .html / .map), gif/png8: colors?: 256 (2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?, webp: lossless?: true (lossy WebP isn't available yet: written lossless, with a warning), txt: the stories in stacking order (back to front; a thread once), encoding?: utf8|utf16 (with a byte order mark), lineEndings?: lf|crlf, selectionOnly?: false; SVG options flat or as svg: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers, encoding, profile, embedFonts} (see document.formats), …the PDF options of document.exportPdf, …the DXF options of document.exportDxf (useArtboards: one drawing per artboard), …the EPS options of document.exportEps (useArtboards: one file per artboard, {stem}_{artboard}.eps; else the visible art), emf/wmf: one picture of the artboard (useArtboards: true one file per chosen artboard, false the bounds of the visible art; EMF keeps curves, clipping, transparent images and gradients as images clipped to their shape; WMF flattens curves into polygons behind a placeable header; what a format leaves out comes back in warnings), tiff: colorModel?: rgb|cmyk|gray (rgb keeps transparency as an alpha channel), lzw?: true, byteOrder?: little|big, embedIcc?: true, bmp: colorModel?: rgb|gray, depth?: 24 (1 black and white, 4|8 a palette by reduction and dither, 16, 24, 32 with alpha), fileFormat?: windows|os2 (1, 4, 8 or 24 bits), rle?: false (4 and 8 bits, windows), flipRows?: false (top-down rows); formats without alpha are flattened on white} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. Never changes the document's path", has_doc, export::export ), @@ -345,6 +345,46 @@ const BYTE_ORDER: FormatOption = FormatOption { }; const TIFF_OPTIONS: &[FormatOption] = &[ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, PPI, SCALE, BACKGROUND, ANTI_ALIAS, COLOR_MODEL, LZW, BYTE_ORDER, EMBED_ICC]; +const BMP_COLOR_MODEL: FormatOption = + FormatOption { name: "colorModel", ty: "string", default: "\"rgb\"", description: "rgb or gray (greys at every depth)" }; +const BMP_DEPTH: FormatOption = FormatOption { + name: "depth", + ty: "integer", + default: "24", + description: "bits per pixel: 1 (black and white), 4 or 8 (a palette by reduction and dither), 16, 24, or 32 (keeps transparency); the others are flattened on white", +}; +const FILE_FORMAT: FormatOption = FormatOption { + name: "fileFormat", + ty: "string", + default: "\"windows\"", + description: "windows, or os2 (1, 4, 8 or 24 bits, uncompressed, rows bottom-up)", +}; +const RLE: FormatOption = + FormatOption { name: "rle", ty: "boolean", default: "false", description: "run-length encode a 4- or 8-bit windows bitmap (RLE4, RLE8)" }; +const FLIP_ROWS: FormatOption = FormatOption { + name: "flipRows", + ty: "boolean", + default: "false", + description: "rows top-down (a negative height) instead of bottom-up; not with rle or os2", +}; +const BMP_OPTIONS: &[FormatOption] = &[ + ARTBOARD, + ARTBOARDS, + RANGE, + USE_ARTBOARDS, + PPI, + SCALE, + BACKGROUND, + ANTI_ALIAS, + BMP_COLOR_MODEL, + BMP_DEPTH, + FILE_FORMAT, + RLE, + FLIP_ROWS, + REDUCTION, + DITHER, + DITHER_AMOUNT, +]; /// 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 { @@ -443,7 +483,7 @@ pub const FORMATS: &[Format] = &[ raster: true, options: TIFF_OPTIONS, }, - reader("bmp", "BMP", &["bmp"], "image/bmp", true), + Format { id: "bmp", label: "BMP", extensions: &["bmp"], mime: "image/bmp", read: true, write: true, raster: true, options: BMP_OPTIONS }, Format { id: "template", label: "VectorCraft Template", diff --git a/crates/engine/src/cmd/fileio/tests.rs b/crates/engine/src/cmd/fileio/tests.rs index af83b755..bab03836 100644 --- a/crates/engine/src/cmd/fileio/tests.rs +++ b/crates/engine/src/cmd/fileio/tests.rs @@ -156,7 +156,7 @@ fn formats_query_lists_readers_writers_and_options() { let ids = |k: &str| r[k].as_array().unwrap().iter().map(|v| v.as_str().unwrap().to_string()).collect::>(); assert_eq!( ids("writable"), - ["vectorcraft", "svg", "svgz", "pdf", "png", "jpg", "gif", "webp", "tiff", "template", "png8", "txt", "dxf", "eps", "emf", "wmf"] + ["vectorcraft", "svg", "svgz", "pdf", "png", "jpg", "gif", "webp", "tiff", "bmp", "template", "png8", "txt", "dxf", "eps", "emf", "wmf"] ); assert!(ids("readable").contains(&"tiff".to_string()) && ids("readable").contains(&"ait".to_string())); let png = r["formats"].as_array().unwrap().iter().find(|f| f["id"] == "png").unwrap(); @@ -212,7 +212,7 @@ fn export_without_path_returns_bytes_and_never_retargets() { let r = s.execute("document.export", &json!({"format": "jpg"})).unwrap(); assert_eq!(&b64(&r)[..2], [0xFF, 0xD8]); assert!(r.get("path").is_none()); - assert!(s.execute("document.export", &json!({"path": dir.join("x.bmp").to_string_lossy()})).is_err(), "BMP is read-only"); + assert!(s.execute("document.export", &json!({"path": dir.join("x.dwg").to_string_lossy()})).is_err(), "DWG can't be written"); // A never-saved document hands its native bytes back. let mut s = session(10.0, 10.0, 1); let r = s.execute("document.save", &json!({})).unwrap(); diff --git a/crates/engine/src/cmd/fileio/tests_tiffbmp.rs b/crates/engine/src/cmd/fileio/tests_tiffbmp.rs index ec908215..8d7f6eb1 100644 --- a/crates/engine/src/cmd/fileio/tests_tiffbmp.rs +++ b/crates/engine/src/cmd/fileio/tests_tiffbmp.rs @@ -1,4 +1,4 @@ -//! TIFF export through `document.export`: the options reach the files, impossible +//! TIFF and BMP export through `document.export`: the options reach the files, impossible //! ones are refused, and the files open again. use serde_json::{Value, json}; @@ -87,15 +87,52 @@ fn tiff_options_reach_the_file() { } } +#[test] +fn bmp_options_reach_the_file() { + let mut s = session(); + let bmp = |s: &mut Session, p: Value| export(s, merge(json!({"format": "bmp"}), p)); + let bits = |f: &[u8]| u16::from_le_bytes([f[28], f[29]]); + let plain = bmp(&mut s, json!({})); + assert_eq!((&plain[..2], bits(&plain)), (&b"BM"[..], 24)); + for depth in [1, 4, 8, 16, 32] { + assert_eq!(bits(&bmp(&mut s, json!({"depth": depth}))), depth); + } + let rle = bmp(&mut s, json!({"depth": 8, "rle": true})); + assert_eq!(u32::from_le_bytes(rle[30..34].try_into().unwrap()), 1, "RLE8"); + let flipped = bmp(&mut s, json!({"flipRows": true})); + assert_eq!(i32::from_le_bytes(flipped[22..26].try_into().unwrap()), -40, "top-down rows"); + let os2 = bmp(&mut s, json!({"fileFormat": "os2", "depth": 4})); + assert_eq!(u32::from_le_bytes(os2[14..18].try_into().unwrap()), 12, "OS/2 core header"); + let gray = image::load_from_memory_with_format(&bmp(&mut s, json!({"colorModel": "gray", "depth": 8})), image::ImageFormat::Bmp).unwrap(); + assert!(gray.to_rgb8().pixels().all(|p| p[0] == p[1] && p[1] == p[2])); + for bad in [ + json!({"depth": 2}), + json!({"depth": 24, "rle": true}), + json!({"depth": 8, "rle": true, "flipRows": true}), + json!({"fileFormat": "os2", "depth": 32}), + json!({"fileFormat": "amiga"}), + json!({"colorModel": "cmyk"}), + ] { + let e = s.execute("document.export", &merge(json!({"format": "bmp"}), bad.clone())).unwrap_err(); + assert!(matches!(e, EngineError::BadParams { .. }), "{bad}: {e}"); + } + let r = s.execute("document.open", &json!({"name": "back.bmp", "dataBase64": vectorcraft_format::base64_encode(&rle)})).unwrap(); + assert_eq!(r["format"], "bmp"); +} + #[test] fn the_formats_are_listed_and_write_one_file_per_artboard() { let mut s = Session::new(); s.execute("file.new", &json!({"width": 30, "height": 20, "artboards": 2})).unwrap(); let r = s.execute("document.formats", &json!({})).unwrap(); let find = |id: &str| r["formats"].as_array().unwrap().iter().find(|f| f["id"] == id).unwrap().clone(); - let tiff = find("tiff"); + let (tiff, bmp) = (find("tiff"), find("bmp")); assert_eq!((tiff["write"].as_bool(), tiff["read"].as_bool()), (Some(true), Some(true))); - for (f, keys) in [(&tiff, &["colorModel", "lzw", "byteOrder", "embedIcc", "ppi", "antiAlias"][..])] { + assert_eq!((bmp["write"].as_bool(), bmp["read"].as_bool()), (Some(true), Some(true))); + for (f, keys) in [ + (&tiff, &["colorModel", "lzw", "byteOrder", "embedIcc", "ppi", "antiAlias"][..]), + (&bmp, &["colorModel", "depth", "fileFormat", "rle", "flipRows", "reduction", "dither"]), + ] { for k in keys { assert!(f["options"].get(*k).is_some(), "{} takes {k}", f["id"]); } diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index 37c091d9..5037ba9a 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -307,7 +307,7 @@ fn errors_are_tool_results_not_crashes() { ("type_text", json!({"text": "hi"})), ("screenshot", json!({"window": true})), ("open_file", json!({"path": "/definitely/not/here.svg"})), - ("export", json!({"path": "/tmp/out.bmp"})), + ("export", json!({"path": "/tmp/out.dwg"})), ("redo", json!({})), ("press_key", json!({"key": "F13"})), ]; diff --git a/crates/mcp/src/tests_fileio.rs b/crates/mcp/src/tests_fileio.rs index 4985d4af..a2ce38b1 100644 --- a/crates/mcp/src/tests_fileio.rs +++ b/crates/mcp/src/tests_fileio.rs @@ -57,7 +57,25 @@ fn export_tool_lists_engine_formats_and_options() { let props = &export["inputSchema"]["properties"]; assert_eq!( props["format"]["enum"], - json!(["vectorcraft", "svg", "svgz", "pdf", "png", "jpg", "gif", "webp", "tiff", "template", "png8", "txt", "dxf", "eps", "emf", "wmf"]) + json!([ + "vectorcraft", + "svg", + "svgz", + "pdf", + "png", + "jpg", + "gif", + "webp", + "tiff", + "bmp", + "template", + "png8", + "txt", + "dxf", + "eps", + "emf", + "wmf" + ]) ); for k in ["artboard", "range", "options"] { assert!(props.get(k).is_some(), "export takes {k}"); diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 0bae9a93..d2c86c2f 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -264,7 +264,7 @@ pub fn tool_definitions() -> Vec { tool( "export", "Export", - "Export the active document with the engine's document.export (the same bytes in the app and headless): svg (artboard viewBox), pdf (one page per artboard: all, or `artboard` / `range`), png/jpg/webp/gif/png8/tiff (one rendered artboard; png8 is an indexed .png), txt (the stories, back to front) or vectorcraft (a native copy; the document keeps its path). `selection: true` exports only the selected objects, cropped to their bounds. Template layers are left out; live effects are kept (geometry baked, SVG filters for shadows/glows/blur). Without `path` the bytes come back as dataBase64.", + "Export the active document with the engine's document.export (the same bytes in the app and headless): svg (artboard viewBox), pdf (one page per artboard: all, or `artboard` / `range`), png/jpg/webp/gif/png8/tiff/bmp (one rendered artboard; png8 is an indexed .png), txt (the stories, back to front) or vectorcraft (a native copy; the document keeps its path). `selection: true` exports only the selected objects, cropped to their bounds. Template layers are left out; live effects are kept (geometry baked, SVG filters for shadows/glows/blur). Without `path` the bytes come back as dataBase64.", obj( json!({ "format": {"type": "string", "enum": FORMATS.iter().filter(|f| f.write).map(|f| f.id).collect::>(), "description": "Default: from the path's extension"}, diff --git a/crates/render/src/encode/bmp.rs b/crates/render/src/encode/bmp.rs new file mode 100644 index 00000000..735e1479 --- /dev/null +++ b/crates/render/src/encode/bmp.rs @@ -0,0 +1,221 @@ +//! BMP export: the Windows layout (a version 3 header; version 4 with an alpha mask at 32 bits) +//! or the OS/2 one (a core header); 1, 4 or 8 bits per pixel through a palette ([`quantize`]), +//! optionally run-length encoded (RLE4, RLE8), or 16 (5-5-5), 24 or 32 bits (with alpha); rows +//! bottom-up, or top-down (a negative height). + +use super::on_white; +use super::png::{pack, pixels_per_metre}; +use super::quantize::{self, PaletteOptions, Reduction}; + +/// Bits per pixel a BMP can have. +pub const DEPTHS: [u8; 6] = [1, 4, 8, 16, 24, 32]; +/// Those of the OS/2 layout. +pub const OS2_DEPTHS: [u8; 4] = [1, 4, 8, 24]; + +/// The BMP options of a raster export. +#[derive(Clone, Debug, PartialEq)] +pub struct BmpOptions { + /// The OS/2 layout: 1, 4, 8 or 24 bits, uncompressed, rows bottom-up. + pub os2: bool, + /// Bits per pixel ([`DEPTHS`]). + pub depth: u8, + /// Run-length encode a 4- or 8-bit image. + pub rle: bool, + /// Rows top-down (flipped row order: a negative height) instead of bottom-up. + pub top_down: bool, + /// Greys instead of colours. + pub gray: bool, +} + +impl Default for BmpOptions { + fn default() -> Self { + Self { os2: false, depth: 24, rle: false, top_down: false, gray: false } + } +} + +impl BmpOptions { + /// Why these options can't be written together, if they can't. + pub fn check(&self) -> Result<(), String> { + let err = |s: &str| Err(s.to_string()); + if !DEPTHS.contains(&self.depth) { + return Err(format!("BMP depth {}: 1, 4, 8, 16, 24 or 32 bits per pixel", self.depth)); + } + if self.os2 && !OS2_DEPTHS.contains(&self.depth) { + return err("OS/2 bitmaps have 1, 4, 8 or 24 bits per pixel"); + } + if self.rle && (self.os2 || !matches!(self.depth, 4 | 8)) { + return err("RLE compression is for 4- and 8-bit Windows bitmaps"); + } + if self.top_down && (self.rle || self.os2) { + return err("compressed and OS/2 bitmaps store their rows bottom-up: turn off flipped rows"); + } + Ok(()) + } + + /// The colours of a 1-, 4- or 8-bit image: black and white at 1 bit, greys, or `palette`. + fn palette(&self, palette: &PaletteOptions) -> PaletteOptions { + let reduction = match self.depth { + 1 => Reduction::BlackWhite, + _ if self.gray => Reduction::Gray, + _ => palette.reduction, + }; + PaletteOptions { colors: 1 << self.depth, reduction, transparency: false, matte: Some([255; 3]), ..palette.clone() } + } +} + +/// Straight-alpha RGBA pixels as a BMP. At 1, 4 and 8 bits `palette` picks the colours (its +/// reduction and dither); every depth but 32 bits blends partly transparent pixels over white. +pub fn encode(rgba: &[u8], width: u32, height: u32, ppi: f64, o: &BmpOptions, palette: &PaletteOptions) -> Result, String> { + o.check()?; + let (w, h) = (width as usize, height as usize); + let side = if o.os2 { u32::from(u16::MAX) } else { i32::MAX as u32 }; + if w == 0 || h == 0 || width > side || height > side || w.checked_mul(h).and_then(|n| n.checked_mul(4)) != Some(rgba.len()) { + return Err(format!("BMP encoding failed: {width} × {height} pixels doesn't fit the format or the pixel buffer")); + } + let px = rgba.as_chunks::<4>().0; + let gray = |c: [u8; 3]| if o.gray { [quantize::luma(c); 3] } else { c }; + // Bottom-up unless flipped. + let row_at = |y: usize| if o.top_down { y } else { h - 1 - y }; + // Rows are padded to 4 bytes. + let mut data = Vec::with_capacity((w * usize::from(o.depth)).div_ceil(32) * 4 * h); + let mut colors = vec![]; + if o.depth <= 8 { + let ix = quantize::quantize(rgba, width, height, &o.palette(palette)); + for y in 0..h { + let row = ix.indices.get(row_at(y) * w..).and_then(|r| r.get(..w)).unwrap_or_default(); + if o.rle { + rle_row(row, o.depth, &mut data); + // End of line, or of the bitmap after the last. + data.extend([0, u8::from(y + 1 == h)]); + } else { + data.extend_from_slice(&pack(row, width, 1, o.depth)); + data.resize(data.len().next_multiple_of(4), 0); + } + } + colors = ix.palette; + } else { + for y in 0..h { + for p in px.get(row_at(y) * w..).and_then(|r| r.get(..w)).unwrap_or_default() { + match o.depth { + 16 => { + let [r, g, b] = gray(on_white(p)).map(|v| u16::from(v >> 3)); + data.extend((r << 10 | g << 5 | b).to_le_bytes()); + } + 24 => { + let [r, g, b] = gray(on_white(p)); + data.extend([b, g, r]); + } + _ => { + let [r, g, b] = gray([p[0], p[1], p[2]]); + data.extend([b, g, r, p[3]]); + } + } + } + data.resize(data.len().next_multiple_of(4), 0); + } + } + header(&data, width, height, ppi, o, &colors) +} + +/// The file: file header, info header, palette, then the pixel data. +fn header(data: &[u8], width: u32, height: u32, ppi: f64, o: &BmpOptions, colors: &[[u8; 3]]) -> Result, String> { + let too_large = || "the image is too large for BMP: lower the resolution".to_string(); + let indexed = o.depth <= 8; + let (info_len, entry) = match (o.os2, o.depth) { + (true, _) => (12, 3), + (false, 32) => (108, 4), + (false, _) => (40, 4), + }; + // OS/2 palettes have an entry for every index. + let entries = match (indexed, o.os2) { + (false, _) => 0, + (true, true) => 1 << o.depth, + (true, false) => colors.len(), + }; + let offset = 14 + info_len + entries * entry; + let size = u32::try_from(offset + data.len()).map_err(|_| too_large())?; + let data_len = u32::try_from(data.len()).map_err(|_| too_large())?; + let mut out = Vec::with_capacity(offset + data.len()); + out.extend(b"BM"); + out.extend(size.to_le_bytes()); + out.extend([0; 4]); + out.extend((offset as u32).to_le_bytes()); + if o.os2 { + out.extend(12u32.to_le_bytes()); + // Sides checked against u16 by `encode`. + out.extend((width as u16).to_le_bytes()); + out.extend((height as u16).to_le_bytes()); + out.extend(1u16.to_le_bytes()); + out.extend(u16::from(o.depth).to_le_bytes()); + } else { + let compression: u32 = match (o.rle, o.depth) { + (true, 8) => 1, + (true, _) => 2, + (false, 32) => 3, + (false, _) => 0, + }; + let ppm = pixels_per_metre(ppi).min(i32::MAX as u32); + let h = if o.top_down { -(height as i32) } else { height as i32 }; + out.extend((info_len as u32).to_le_bytes()); + out.extend(width.to_le_bytes()); + out.extend(h.to_le_bytes()); + out.extend(1u16.to_le_bytes()); + out.extend(u16::from(o.depth).to_le_bytes()); + out.extend(compression.to_le_bytes()); + out.extend(data_len.to_le_bytes()); + out.extend(ppm.to_le_bytes()); + out.extend(ppm.to_le_bytes()); + out.extend((entries as u32).to_le_bytes()); + out.extend(0u32.to_le_bytes()); + if info_len == 108 { + // Red, green, blue and alpha masks, sRGB, then unused end points and gammas. + for mask in [0x00FF_0000u32, 0x0000_FF00, 0x0000_00FF, 0xFF00_0000, u32::from_be_bytes(*b"sRGB")] { + out.extend(mask.to_le_bytes()); + } + out.extend([0; 48]); + } + } + for i in 0..entries { + let [r, g, b] = colors.get(i).copied().unwrap_or_default(); + out.extend(&[b, g, r, 0][..entry]); + } + out.extend_from_slice(data); + Ok(out) +} + +/// One row of palette indices run-length encoded (RLE8 at 8 bits, RLE4 at 4): runs of one index +/// as (count, index), other stretches of three pixels or more in absolute mode (0, count, the +/// pixels packed, padded to a word). +fn rle_row(row: &[u8], bits: u8, out: &mut Vec) { + let run_byte = |v: u8| if bits == 4 { (v & 15) << 4 | (v & 15) } else { v }; + let mut i = 0; + while let Some(&v) = row.get(i) { + let run = row.get(i..).map_or(1, |r| r.iter().take(255).take_while(|x| **x == v).count()); + if run >= 2 { + out.extend([run as u8, run_byte(v)]); + i += run; + continue; + } + // Up to the next pair of equal pixels. + let mut j = i + 1; + while j < row.len() && j - i < 255 && row.get(j..j + 2).is_none_or(|p| p[0] != p[1]) { + j += 1; + } + let lit = row.get(i..j).unwrap_or_default(); + if lit.len() < 3 { + // Absolute mode needs three pixels: runs of one. + lit.iter().for_each(|x| out.extend([1, run_byte(*x)])); + } else { + out.extend([0, lit.len() as u8]); + let start = out.len(); + match bits { + 4 => out.extend(lit.chunks(2).map(|p| (p[0] & 15) << 4 | p.get(1).map_or(0, |x| x & 15))), + _ => out.extend_from_slice(lit), + } + if (out.len() - start) % 2 == 1 { + out.push(0); + } + } + i = j; + } +} diff --git a/crates/render/src/encode/mod.rs b/crates/render/src/encode/mod.rs index afd44118..a4f96114 100644 --- a/crates/render/src/encode/mod.rs +++ b/crates/render/src/encode/mod.rs @@ -1,9 +1,10 @@ //! Raster export: render a document region with [`RasterExportOptions`] (resolution, background, //! anti-aliasing) and encode it as PNG ([`png`]: resolution in `pHYs`, Adam7 interlacing), JPEG //! ([`jpeg`]: RGB, CMYK or grey, progressive, resolution and colour profile), lossless WebP, or -//! a palette image ([`quantize`]) as PNG-8 or [`gif`], or [`tiff`] (RGB, CMYK or grey, LZW, either -//! byte order, the profile). +//! a palette image ([`quantize`]) as PNG-8 or [`gif`], [`tiff`] (RGB, CMYK or grey, LZW, either byte +//! order, the profile), [`bmp`] (1–32 bits, RLE) or [`tga`]. +pub mod bmp; pub mod gif; pub mod jpeg; pub mod png; @@ -25,6 +26,7 @@ pub enum RasterFormat { Png8, Gif, Tiff, + Bmp, } /// How a raster export renders and encodes. @@ -41,10 +43,12 @@ pub struct RasterExportOptions { pub quality: u8, /// JPEG colour model, coding and profile. pub jpeg: jpeg::JpegOptions, - /// PNG-8 and GIF: the colour reduction. + /// PNG-8 and GIF: the colour reduction (also BMP's at 1, 4 and 8 bits). pub palette: quantize::PaletteOptions, /// TIFF colour model, compression, byte order and profile. pub tiff: tiff::TiffOptions, + /// BMP layout, depth, compression and row order. + pub bmp: bmp::BmpOptions, } impl Default for RasterExportOptions { @@ -58,6 +62,7 @@ impl Default for RasterExportOptions { jpeg: jpeg::JpegOptions::default(), palette: quantize::PaletteOptions::default(), tiff: tiff::TiffOptions::default(), + bmp: bmp::BmpOptions::default(), } } } @@ -103,6 +108,7 @@ impl RasterExportOptions { } } RasterFormat::Tiff => tiff::encode(img, self.ppi, &self.tiff), + RasterFormat::Bmp => bmp::encode(&img.to_straight(), img.width, img.height, self.ppi, &self.bmp, &self.palette), } } @@ -179,6 +185,8 @@ fn drawn_bounds(n: &Node) -> Option { #[cfg(test)] mod tests; #[cfg(test)] +mod tests_bmp; +#[cfg(test)] mod tests_jpeg; #[cfg(test)] mod tests_palette; diff --git a/crates/render/src/encode/png.rs b/crates/render/src/encode/png.rs index 1fb04571..7e7e12af 100644 --- a/crates/render/src/encode/png.rs +++ b/crates/render/src/encode/png.rs @@ -175,7 +175,7 @@ pub fn with_text(png: Vec, entries: &[(&str, Cow)]) -> Vec { /// Rows of one-byte pixels packed `bits` per pixel, most significant first (each row starts on a /// byte); at 8 bits the pixels as they are. -fn pack(px: &[u8], w: u32, h: u32, bits: u8) -> Cow<'_, [u8]> { +pub(super) fn pack(px: &[u8], w: u32, h: u32, bits: u8) -> Cow<'_, [u8]> { if bits >= 8 { return Cow::Borrowed(px); } diff --git a/crates/render/src/encode/quantize.rs b/crates/render/src/encode/quantize.rs index e51c2b0f..e2015f5b 100644 --- a/crates/render/src/encode/quantize.rs +++ b/crates/render/src/encode/quantize.rs @@ -202,7 +202,7 @@ pub fn quantize(rgba: &[u8], width: u32, height: u32, o: &PaletteOptions) -> Ind } /// Lightness as a grey value (Rec. 601 luma). -fn luma([r, g, b]: [u8; 3]) -> u8 { +pub(super) fn luma([r, g, b]: [u8; 3]) -> u8 { ((r as u32 * 299 + g as u32 * 587 + b as u32 * 114 + 500) / 1000) as u8 } diff --git a/crates/render/src/encode/tests_bmp.rs b/crates/render/src/encode/tests_bmp.rs new file mode 100644 index 00000000..659c00d1 --- /dev/null +++ b/crates/render/src/encode/tests_bmp.rs @@ -0,0 +1,133 @@ +//! BMP export: depths, palettes and RLE, row order, layouts, alpha. + +use super::bmp::{self, BmpOptions, DEPTHS}; +use super::on_white; +use super::png::pixels_per_metre; +use super::quantize::PaletteOptions; + +pub(super) fn u16_at(f: &[u8], at: usize) -> u16 { + u16::from_le_bytes(f[at..at + 2].try_into().unwrap()) +} + +fn u32_at(f: &[u8], at: usize) -> u32 { + u32::from_le_bytes(f[at..at + 4].try_into().unwrap()) +} + +/// Straight RGBA: bands of a few colours with runs (as art is), a transparent and a half +/// transparent pixel in the first row. +pub(super) fn art(w: u32, h: u32) -> Vec { + let colors = [[230, 40, 30, 255], [30, 90, 200, 255], [250, 250, 250, 255], [20, 160, 60, 255]]; + let mut px: Vec = (0..w * h).flat_map(|i| colors[((i % w) * 4 / w + (i / w) % 2) as usize % 4]).collect(); + px[..4].copy_from_slice(&[0, 0, 0, 0]); + px[4..8].copy_from_slice(&[100, 0, 0, 128]); + px +} + +pub(super) fn decode(file: &[u8], format: image::ImageFormat) -> image::RgbaImage { + image::load_from_memory_with_format(file, format).expect("decodes").to_rgba8() +} + +fn bmp(px: &[u8], w: u32, h: u32, o: &BmpOptions) -> Vec { + bmp::encode(px, w, h, 150.0, o, &PaletteOptions::default()).unwrap() +} + +#[test] +fn every_depth_writes_its_bit_count_and_decodes() { + let (w, h) = (13, 7); + let px = art(w, h); + for depth in DEPTHS { + let file = bmp(&px, w, h, &BmpOptions { depth, ..Default::default() }); + assert_eq!(&file[..2], b"BM"); + assert_eq!(u32_at(&file, 2) as usize, file.len(), "file size"); + assert_eq!(u16_at(&file, 28), u16::from(depth), "bit count"); + assert_eq!(u32_at(&file, 38), pixels_per_metre(150.0), "resolution"); + let img = decode(&file, image::ImageFormat::Bmp); + assert_eq!(img.dimensions(), (w, h)); + let got = img.get_pixel(5, 3).0; + let want = on_white(px[(3 * w as usize + 5) * 4..][..4].try_into().unwrap()); + match depth { + 1 => assert!(got[..3].iter().all(|c| *c == 0 || *c == 255), "black and white: {got:?}"), + 16 => assert!(got[..3].iter().zip(want).all(|(a, b)| a.abs_diff(b) <= 8), "5 bits a channel: {got:?} vs {want:?}"), + 24 | 4 | 8 => assert_eq!(got[..3], want, "depth {depth}: few colours keep theirs exactly"), + _ => {} + } + if depth == 32 { + assert_eq!(u32_at(&file, 14), 108, "a version 4 header with an alpha mask"); + assert_eq!(img.get_pixel(0, 0).0[3], 0, "transparency kept"); + assert_eq!(img.get_pixel(1, 0).0[3], 128); + } else { + assert_eq!(img.get_pixel(0, 0).0, [255; 4], "flattened on white"); + } + } +} + +#[test] +fn rle8_and_rle4_decode_like_uncompressed() { + let (w, h) = (61, 9); + let px = art(w, h); + for depth in [4, 8] { + let plain = bmp(&px, w, h, &BmpOptions { depth, ..Default::default() }); + let rle = bmp(&px, w, h, &BmpOptions { depth, rle: true, ..Default::default() }); + assert_eq!(u32_at(&rle, 30), if depth == 8 { 1 } else { 2 }, "BI_RLE8 / BI_RLE4"); + assert_eq!(u32_at(&rle, 34) as usize, rle.len() - u32_at(&rle, 10) as usize, "the compressed size"); + assert!(rle.len() < plain.len(), "{} vs {}", rle.len(), plain.len()); + assert_eq!(decode(&rle, image::ImageFormat::Bmp), decode(&plain, image::ImageFormat::Bmp), "depth {depth}"); + } + // Rows of many different pixels go in absolute mode. + let noisy: Vec = (0..40u32 * 4).flat_map(|i| [(i * 37 % 256) as u8, (i * 91 % 256) as u8, (i * 13 % 256) as u8, 255]).collect(); + let plain = bmp(&noisy, 40, 4, &BmpOptions { depth: 8, ..Default::default() }); + let rle = bmp(&noisy, 40, 4, &BmpOptions { depth: 8, rle: true, ..Default::default() }); + assert_eq!(decode(&rle, image::ImageFormat::Bmp), decode(&plain, image::ImageFormat::Bmp)); +} + +#[test] +fn flipped_rows_have_a_negative_height() { + let (w, h) = (6, 5); + let px = art(w, h); + let up = bmp(&px, w, h, &BmpOptions::default()); + let down = bmp(&px, w, h, &BmpOptions { top_down: true, ..Default::default() }); + assert_eq!(u32_at(&up, 22) as i32, 5); + assert_eq!(u32_at(&down, 22) as i32, -5); + let offset = u32_at(&down, 10) as usize; + // Top-down: the first row stored is the image's first (its transparent pixel, on white). + assert_eq!(&down[offset..offset + 3], &[255, 255, 255]); + assert_eq!(decode(&up, image::ImageFormat::Bmp), decode(&down, image::ImageFormat::Bmp)); +} + +#[test] +fn os2_bitmaps_have_a_core_header() { + let (w, h) = (9, 4); + let px = art(w, h); + for depth in bmp::OS2_DEPTHS { + let file = bmp(&px, w, h, &BmpOptions { os2: true, depth, ..Default::default() }); + assert_eq!(u32_at(&file, 14), 12, "core header"); + assert_eq!((u16_at(&file, 18), u16_at(&file, 20), u16_at(&file, 24)), (9, 4, u16::from(depth))); + // A three-byte entry for every index. + let entries = if depth <= 8 { 1 << depth } else { 0 }; + assert_eq!(u32_at(&file, 10), 14 + 12 + entries * 3); + let windows = bmp(&px, w, h, &BmpOptions { depth, ..Default::default() }); + assert_eq!(decode(&file, image::ImageFormat::Bmp), decode(&windows, image::ImageFormat::Bmp), "depth {depth}"); + } +} + +#[test] +fn greys_and_impossible_combinations() { + let (w, h) = (8, 2); + let px = art(w, h); + for depth in [8, 24] { + let img = decode(&bmp(&px, w, h, &BmpOptions { depth, gray: true, ..Default::default() }), image::ImageFormat::Bmp); + assert!(img.pixels().all(|p| p[0] == p[1] && p[1] == p[2]), "depth {depth}: greys"); + } + let bad = [ + BmpOptions { depth: 3, ..Default::default() }, + BmpOptions { os2: true, depth: 32, ..Default::default() }, + BmpOptions { rle: true, ..Default::default() }, + BmpOptions { rle: true, depth: 8, os2: true, ..Default::default() }, + BmpOptions { rle: true, depth: 8, top_down: true, ..Default::default() }, + BmpOptions { os2: true, top_down: true, ..Default::default() }, + ]; + for o in bad { + assert!(o.check().is_err() && bmp::encode(&px, w, h, 72.0, &o, &PaletteOptions::default()).is_err(), "{o:?}"); + } + assert!(bmp::encode(&px[..8], w, h, 72.0, &BmpOptions::default(), &PaletteOptions::default()).is_err(), "short buffer"); +} diff --git a/crates/ui-egui/src/dialogs/mod.rs b/crates/ui-egui/src/dialogs/mod.rs index bfff8bb8..17f4e88d 100644 --- a/crates/ui-egui/src/dialogs/mod.rs +++ b/crates/ui-egui/src/dialogs/mod.rs @@ -228,7 +228,7 @@ registry! { EpsOptions: [eps_options::KIND] => eps_options::SPEC, Recovery: [crate::recovery::KIND] => recovery::SPEC, DxfImport: [dxf_import::KIND] => dxf_import::SPEC, - RasterFormatOptions: ["tiffOptions"] => png_options::SPEC, + RasterFormatOptions: ["tiffOptions", "bmpOptions"] => png_options::SPEC, } /// The spec for a `Dialog::kind` ([`DialogSpec::FORM`] when unregistered). diff --git a/crates/ui-egui/src/dialogs/png_options.rs b/crates/ui-egui/src/dialogs/png_options.rs index 6b3ec394..72b8cb4d 100644 --- a/crates/ui-egui/src/dialogs/png_options.rs +++ b/crates/ui-egui/src/dialogs/png_options.rs @@ -1,7 +1,7 @@ //! The raster export options: PNG Options (resolution, background, anti-aliasing, interlaced), //! JPEG Options (no transparency; colour model, quality 0–10, method and scans, profile, image //! map), WebP Options, and PNG-8 and GIF Options (the palette: colour reduction, colours, dither, -//! transparency and matte), and TIFF Options ([`super::tiff_bmp_tga`]). Fields +//! transparency and matte), and TIFF and BMP Options ([`super::tiff_bmp_tga`]). Fields //! are `document.export` params (format, path, artboard choice…); `__`-prefixed ones are the //! dialog's. diff --git a/crates/ui-egui/src/dialogs/tests_tiff_bmp_tga.rs b/crates/ui-egui/src/dialogs/tests_tiff_bmp_tga.rs index b823b746..96317a17 100644 --- a/crates/ui-egui/src/dialogs/tests_tiff_bmp_tga.rs +++ b/crates/ui-egui/src/dialogs/tests_tiff_bmp_tga.rs @@ -1,4 +1,4 @@ -//! TIFF Options through Export As, drawn headlessly. +//! TIFF and BMP Options through Export As, drawn headlessly. use serde_json::json; @@ -31,3 +31,33 @@ fn tiff_options_pick_the_model_byte_order_and_compression() { confirm(&mut app).unwrap(); assert_eq!(app.ui.dialog.as_ref().unwrap().str("colorModel"), "cmyk"); } + +#[test] +fn bmp_options_keep_their_choices_writable() { + let (mut app, written) = app(1); + app.run("file.exportAs", json!({"format": "bmp"})).unwrap(); + confirm(&mut app).unwrap(); + assert_eq!(kind(&app), Some("bmpOptions")); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!( + (d.str("colorModel"), d.str("fileFormat"), d.f64("depth", 0.0), d.bool("rle"), d.bool("flipRows")), + ("rgb".into(), "windows".into(), 24.0, false, false) + ); + frame(&mut app); + // OS/2 has no 32 bits: the depth falls back to 24. + set(&mut app, "fileFormat", json!("os2")); + set(&mut app, "depth", json!(32)); + frame(&mut app); + assert_eq!(app.ui.dialog.as_ref().unwrap().f64("depth", 0.0), 24.0); + // Compressed rows are bottom-up: RLE turns flipped rows off. + set(&mut app, "fileFormat", json!("windows")); + set(&mut app, "depth", json!(8)); + set(&mut app, "flipRows", json!(true)); + set(&mut app, "rle", json!(true)); + frame(&mut app); + assert!(!app.ui.dialog.as_ref().unwrap().bool("flipRows")); + confirm(&mut app).unwrap(); + let (path, bmp) = written.borrow()[0].clone(); + assert_eq!(path, "/out/Untitled-1.bmp"); + assert_eq!((&bmp[..2], bmp[28], bmp[30]), (&b"BM"[..], 8, 1), "8-bit RLE8"); +} diff --git a/crates/ui-egui/src/dialogs/tiff_bmp_tga.rs b/crates/ui-egui/src/dialogs/tiff_bmp_tga.rs index 711712ec..776b6f26 100644 --- a/crates/ui-egui/src/dialogs/tiff_bmp_tga.rs +++ b/crates/ui-egui/src/dialogs/tiff_bmp_tga.rs @@ -1,27 +1,54 @@ -//! The options of TIFF (colour model, byte order, LZW, profile), after the raster export rows it -//! shares with PNG Options ([`super::png_options`]): the `tiffOptions` dialog. +//! The options of TIFF (colour model, byte order, LZW, profile), BMP (colour model, layout, depth, +//! palette, RLE, row order), after the raster export rows they share with PNG Options +//! ([`super::png_options`]): the `tiffOptions` and `bmpOptions` dialogs. use serde_json::{Map, Value, json}; +use vectorcraft_render::encode::bmp::{self, BmpOptions}; use vectorcraft_render::encode::jpeg::ColorModel; +use vectorcraft_render::encode::quantize::{Dither, PaletteOptions, Reduction}; use vectorcraft_render::encode::tiff::{ByteOrder, TiffOptions}; use super::form; use super::png_options::choice; use crate::state::Dialog; +use crate::widgets; + +/// BMP colour models and layouts: param values and labels. +const BMP_MODELS: [&str; 2] = ["rgb", "gray"]; +const BMP_MODEL_LABELS: [&str; 2] = ["RGB", "Grayscale"]; +const LAYOUTS: [&str; 2] = ["windows", "os2"]; +const LAYOUT_LABELS: [&str; 2] = ["Windows", "OS/2"]; +/// Labels of [`bmp::DEPTHS`]. +const BMP_DEPTH_LABELS: [&str; 6] = ["1 bit", "4 bit", "8 bit", "16 bit", "24 bit", "32 bit"]; type Label<'a> = &'a dyn Fn(&mut egui::Ui, &str) -> egui::Response; /// The options format `id` starts with (`cmyk`: a CMYK document exports CMYK TIFFs). pub(super) fn defaults(id: &str, cmyk: bool, o: &mut Map) { - if id == "tiff" { - let t = TiffOptions::default(); - let model = if cmyk { ColorModel::Cmyk } else { t.color_model }; - o.extend([ - ("colorModel".into(), json!(model.id())), - ("byteOrder".into(), json!(t.byte_order.id())), - ("lzw".into(), json!(t.lzw)), - ("embedIcc".into(), json!(t.embed_icc)), - ]); + match id { + "tiff" => { + let t = TiffOptions::default(); + let model = if cmyk { ColorModel::Cmyk } else { t.color_model }; + o.extend([ + ("colorModel".into(), json!(model.id())), + ("byteOrder".into(), json!(t.byte_order.id())), + ("lzw".into(), json!(t.lzw)), + ("embedIcc".into(), json!(t.embed_icc)), + ]); + } + "bmp" => { + let (b, p) = (BmpOptions::default(), PaletteOptions::default()); + o.extend([ + ("colorModel".into(), json!(BMP_MODELS[usize::from(b.gray)])), + ("fileFormat".into(), json!(LAYOUTS[usize::from(b.os2)])), + ("depth".into(), json!(b.depth)), + ("rle".into(), json!(b.rle)), + ("flipRows".into(), json!(b.top_down)), + ("reduction".into(), json!(p.reduction.id())), + ("dither".into(), json!(p.dither.id())), + ]); + } + _ => {} } } @@ -30,14 +57,17 @@ pub(super) fn keeps_alpha(id: &str, d: &Dialog) -> bool { match id { "jpg" => false, "tiff" => ColorModel::from_id(&d.str("colorModel")).unwrap_or_default() == ColorModel::Rgb, + "bmp" => d.f64("depth", 24.0) == 32.0, _ => true, } } /// The grid rows of format `id`'s own options. pub(super) fn rows(ui: &mut egui::Ui, d: &mut Dialog, id: &str, label: Label) { - if id == "tiff" { - tiff_rows(ui, d, label); + match id { + "tiff" => tiff_rows(ui, d, label), + "bmp" => bmp_rows(ui, d, label), + _ => {} } } @@ -57,3 +87,66 @@ fn tiff_rows(ui: &mut egui::Ui, d: &mut Dialog, label: Label) { }); ui.end_row(); } + +fn bmp_rows(ui: &mut egui::Ui, d: &mut Dialog, label: Label) { + label(ui, "Color Model:"); + choice(ui, d, "colorModel", &BMP_MODELS, &BMP_MODEL_LABELS); + ui.end_row(); + + label(ui, "File Format:"); + choice(ui, d, "fileFormat", &LAYOUTS, &LAYOUT_LABELS); + ui.end_row(); + + let os2 = d.str("fileFormat") == LAYOUTS[1]; + label(ui, "Depth:"); + depth_row(ui, d, "bmp-depth", &bmp::DEPTHS, &BMP_DEPTH_LABELS, |depth| !os2 || bmp::OS2_DEPTHS.contains(&depth)); + ui.end_row(); + + // The palette of a colour image at 4 or 8 bits (1 bit is black and white; greys are fixed). + let depth = d.f64("depth", 24.0) as u8; + if matches!(depth, 4 | 8) && d.str("colorModel") != BMP_MODELS[1] { + label(ui, "Color Reduction:"); + choice(ui, d, "reduction", &Reduction::ALL.map(Reduction::id), &Reduction::ALL.map(Reduction::label)); + ui.end_row(); + label(ui, "Dither:"); + choice(ui, d, "dither", &Dither::ALL.map(Dither::id), &Dither::ALL.map(Dither::label)); + ui.end_row(); + } + + ui.label(""); + ui.horizontal(|ui| { + ui.add_enabled_ui(!os2 && matches!(depth, 4 | 8), |ui| form::check(ui, d, "rle", "Compress (RLE)")); + ui.add_enabled_ui(!os2 && !d.bool("rle"), |ui| form::check(ui, d, "flipRows", "Flip Row Order")); + }); + ui.end_row(); + normalize_bmp(d); +} + +/// Keep the BMP options writable together: OS/2 has no 16 or 32 bits, compression or flipped +/// rows; only 4- and 8-bit images are compressed, and those store their rows bottom-up. +fn normalize_bmp(d: &mut Dialog) { + let os2 = d.str("fileFormat") == LAYOUTS[1]; + let mut depth = d.f64("depth", 24.0) as u8; + if os2 && !bmp::OS2_DEPTHS.contains(&depth) { + depth = 24; + d.fields.insert("depth".into(), json!(depth)); + } + let rle = d.bool("rle") && !os2 && matches!(depth, 4 | 8); + for (key, on) in [("rle", rle), ("flipRows", d.bool("flipRows") && !os2 && !rle)] { + if d.bool(key) != on { + d.fields.insert(key.into(), json!(on)); + } + } +} + +/// A bits-per-pixel dropdown bound to `d.fields["depth"]` (`depths` shown as `labels`); `enabled` +/// greys out depths. +fn depth_row(ui: &mut egui::Ui, d: &mut Dialog, id: &str, depths: &[u8], labels: &[&str], enabled: impl Fn(u8) -> bool) { + let cur = d.f64("depth", 24.0); + let shown = depths.iter().position(|b| f64::from(*b) == cur).and_then(|i| labels.get(i)).copied().unwrap_or_default(); + if let Some(depth) = + widgets::dropdown_with(ui, id, shown, labels, 150.0, |i| depths.get(i).is_some_and(|b| enabled(*b))).and_then(|i| depths.get(i)) + { + d.fields.insert("depth".into(), json!(depth)); + } +} diff --git a/crates/ui-egui/src/io.rs b/crates/ui-egui/src/io.rs index fdd51ee5..7b24eba1 100644 --- a/crates/ui-egui/src/io.rs +++ b/crates/ui-egui/src/io.rs @@ -644,7 +644,7 @@ mod tests { assert!(vectorcraft_format::sniff(&w[1].1)); assert_eq!(app.session.active().unwrap().path.as_deref(), Some("/tmp/doc.vectorcraft")); drop(w); - assert!(export(&mut app, None, Some("/tmp/x.bmp".into()), &Value::Null).is_err(), "BMP is read-only"); + assert!(export(&mut app, None, Some("/tmp/x.dwg".into()), &Value::Null).is_err(), "DWG can't be written"); } #[test] diff --git a/docs/control-protocol.md b/docs/control-protocol.md index d6d588d4..15625e6f 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -366,7 +366,10 @@ EMF and WMF: Export As… lists both formats (`file.exportAs {format: "emf"}` or dialog, so `ui.dialog.confirm` asks for the path and writes the file (one per artboard with Use Artboards). Open and Place read `.emf` and `.wmf`; a paste of `image/emf` from a system clipboard service runs `clipboard.importEmf`. -TIFF Options: Export As… with the TIFF format (`file.exportAs {format: "tiff"}`, then the save dialog) opens -`tiffOptions`: the raster rows of PNG Options (`ppi`, `background`, `antiAlias`), then `colorModel` (`rgb`, `cmyk`, -`gray`; a CMYK document starts on `cmyk`), `byteOrder` (`little`, `big`), `lzw` and `embedIcc`. `ui.dialog.set` the -fields and `ui.dialog.confirm` writes the file(s) with `document.export`. +TIFF and BMP Options: Export As… with the TIFF or BMP format (`file.exportAs {format: "tiff"}`, then +the save dialog) opens `tiffOptions` or `bmpOptions`: the raster rows of PNG Options (`ppi`, +`background`, `antiAlias`), then TIFF's `colorModel` (`rgb`, `cmyk`, `gray`; a CMYK document starts on `cmyk`), +`byteOrder` (`little`, `big`), `lzw` and `embedIcc`; BMP's `colorModel` (`rgb`, `gray`), `fileFormat` (`windows`, +`os2`), `depth` (1, 4, 8, 16, 24, 32), `reduction` and `dither` (4 and 8 bits), `rle` and `flipRows`. The BMP dialog keeps its choices writable together (OS/2 falls back to 24 bits and turns RLE and flipped +rows off; RLE turns flipped rows off). `ui.dialog.set` the fields and `ui.dialog.confirm` writes the file(s) with +`document.export`. diff --git a/docs/mcp.md b/docs/mcp.md index d4f00350..01cd59c8 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -64,7 +64,7 @@ objects' fills or strokes differ (`fillMixed` / `strokeMixed`, drawn as a "?" pr | `screenshot` | `{path?, scale?, artboard?, window?}` | Returns MCP image content (`image/png`, base64) plus a text block. Renders the artboard; `window:true` captures the app window (remote only). | | `open_file` | `{path}` | Opens any readable file as a new active document: `.vectorcraft`/`.drawcraft`, `.svg`/`.svgz`, `.pdf`/`.ai`, `.ait`, PNG, JPEG, GIF, WebP, TIFF, BMP (an image opens as a document of its pixel size). Templates open as a new untitled document. PDF, `.ai` and SVG files saved with Preserve Editing reopen as the document they carry. `run_command document.formats` lists the formats. | | `save_file` | `{path?}` | Runs `document.save`: the document's own file in its own format (native `.vectorcraft` unless it was opened from or saved as SVG, PDF or a restorable `.ai`; then `warnings` say what that format loses). A path's extension picks the format (`.vectorcraft`, `.vctemplate`, `.pdf`, `.svg`, `.svgz`, `.ai`: a PDF carrying the native document, which reopens editable). | -| `export` | `{path?, format?, scale?, artboard?, range?, selection?, outlineText?, options?}` | `svg`, `pdf`, `png`, `jpg`, `webp`, `gif`, `png8` (an indexed `.png`), `tiff`, `txt` (the document's text) or `vectorcraft` (the list comes from `document.formats`). When `format` is omitted, it comes from the path's extension. PDF writes one page per artboard: all of them, or `artboard` (0-based) / `range` (`"1-3, 5"`, 1-based); the other formats write one artboard. `options` carries more format options (e.g. `{"quality": 80}` for JPEG). `selection: true` exports the selected objects cropped to their bounds; `outlineText: true` writes SVG text as paths. Template layers are left out, live effects are kept, and exporting `vectorcraft` never changes the document's path. Without `path` the bytes come back as `dataBase64`. Both backends run the same `document.export` call. | +| `export` | `{path?, format?, scale?, artboard?, range?, selection?, outlineText?, options?}` | `svg`, `pdf`, `png`, `jpg`, `webp`, `gif`, `png8` (an indexed `.png`), `tiff`, `bmp`, `txt` (the document's text) or `vectorcraft` (the list comes from `document.formats`). When `format` is omitted, it comes from the path's extension. PDF writes one page per artboard: all of them, or `artboard` (0-based) / `range` (`"1-3, 5"`, 1-based); the other formats write one artboard. `options` carries more format options (e.g. `{"quality": 80}` for JPEG). `selection: true` exports the selected objects cropped to their bounds; `outlineText: true` writes SVG text as paths. Template layers are left out, live effects are kept, and exporting `vectorcraft` never changes the document's path. Without `path` the bytes come back as `dataBase64`. Both backends run the same `document.export` call. | | `add_text` | `{text, x?, y?, width?, height?, path?, mode?, pathEffect?, size?, font?, color?}` | Point type at (x, y); area type with `width`/`height`; or `path` + `mode` (`area`/`onPath`) to flow text in or along a path, with `pathEffect` (`rainbow`, `skew`, `3dRibbon`, `stairStep`, `gravity`). | | `apply_effect` | `{effect?, params?, ids?}` | Appends a live effect. Without `effect`, returns the effect catalogue with parameters and defaults. | | `pathfinder` | `{operation, ids?}` | `unite`, `minusFront`, `intersect`, `exclude`, `divide`, `trim`, `merge`, `crop`, `outline`, `minusBack`. For a live version, apply the `pathfinder.*` effect to a group. | @@ -1364,14 +1364,19 @@ clipboard service offers it (the Windows desktop service doesn't read metafiles {"name":"run_command","arguments":{"command":"file.place","params":{"path":"/tmp/chart.emf","at":[300,200]}}} ``` -## TIFF export +## TIFF and BMP export -`document.export` (and `export`) writes TIFF like the other raster formats: one artboard (or `useArtboards`, one file -each), at `ppi`, over `background`, with `antiAlias`. It takes `colorModel` (`rgb` keeps transparency as an +`document.export` (and `export`) writes them like the other raster formats: one artboard (or `useArtboards`, one +file each), at `ppi`, over `background`, with `antiAlias`. TIFF takes `colorModel` (`rgb` keeps transparency as an unassociated alpha channel, `cmyk` writes ink amounts in the working CMYK space with CMYK colours keeping their inks, `gray` is flattened on white), `lzw` (default true), `byteOrder` (`little`, the `II` files PCs write, or `big`, `MM`) -and `embedIcc` (sRGB, the working CMYK profile or grey). TIFF files open again; `document.formats` lists every option. +and `embedIcc` (sRGB, the working CMYK profile or grey). BMP takes `depth` (1 is black and white; 4 and 8 use a +palette chosen by `reduction` and `dither`; 16, 24, or 32, which keeps transparency), `colorModel` (`rgb` or `gray`), +`fileFormat` (`windows` or `os2`: 1, 4, 8 or 24 bits only), `rle` (RLE4/RLE8 for 4- and 8-bit Windows bitmaps) and +`flipRows` (rows top-down, a negative height; not with `rle` or `os2`); impossible combinations are refused. Formats and depths +without alpha are flattened on white. TIFF and BMP files open again; `document.formats` lists every option. ```json {"name":"export","arguments":{"path":"/tmp/print.tif","options":{"ppi":300,"colorModel":"cmyk","byteOrder":"big"}}} +{"name":"run_command","arguments":{"command":"document.export","params":{"format":"bmp","depth":8,"rle":true}}} ``` From b578c32f4d33e806afa82a4a8e7e5ed59178e0d3 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 5 Oct 2026 14:58:30 +0700 Subject: [PATCH 3/3] M4.69: Targa export at 16, 24 or 32 bits per pixel document.export {format: "tga"} writes uncompressed true-colour Targa files through the raster export path (resolution, background, anti-aliasing, artboard ranges): depth 16 (5-5-5 with a one-bit alpha), 24 (flattened on white) or 32 (8-bit alpha), rows bottom-up, with the version 2 footer. Export As offers it with the tgaOptions dialog (Depth). Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/fileio/encode.rs | 14 ++-- crates/engine/src/cmd/fileio/mod.rs | 12 ++- crates/engine/src/cmd/fileio/tests.rs | 21 ++++- crates/engine/src/cmd/fileio/tests_tiffbmp.rs | 23 +++++- crates/mcp/src/tests_fileio.rs | 3 +- crates/mcp/src/tools.rs | 2 +- crates/render/Cargo.toml | 2 + crates/render/src/encode/mod.rs | 8 ++ crates/render/src/encode/tests_tga.rs | 30 ++++++++ crates/render/src/encode/tga.rs | 77 +++++++++++++++++++ crates/ui-egui/src/dialogs/mod.rs | 2 +- crates/ui-egui/src/dialogs/png_options.rs | 2 +- .../ui-egui/src/dialogs/tests_tiff_bmp_tga.rs | 20 ++++- crates/ui-egui/src/dialogs/tiff_bmp_tga.rs | 17 +++- docs/control-protocol.md | 7 +- docs/mcp.md | 10 ++- 16 files changed, 224 insertions(+), 26 deletions(-) create mode 100644 crates/render/src/encode/tests_tga.rs create mode 100644 crates/render/src/encode/tga.rs diff --git a/crates/engine/src/cmd/fileio/encode.rs b/crates/engine/src/cmd/fileio/encode.rs index dca31adc..7a3c6d58 100644 --- a/crates/engine/src/cmd/fileio/encode.rs +++ b/crates/engine/src/cmd/fileio/encode.rs @@ -12,7 +12,7 @@ use vectorcraft_render::AntiAlias; use vectorcraft_render::encode::jpeg::{self, JpegOptions}; use vectorcraft_render::encode::quantize::{Dither, PaletteOptions, Reduction}; use vectorcraft_render::encode::tiff::{ByteOrder, TiffOptions}; -use vectorcraft_render::encode::{RasterExportOptions, RasterFormat, bmp}; +use vectorcraft_render::encode::{RasterExportOptions, RasterFormat, bmp, tga}; use super::super::*; use super::Format; @@ -165,7 +165,7 @@ struct RasterOptions { lossless: Option, lzw: Option, byte_order: Option, - /// BMP bits per pixel. + /// BMP and Targa bits per pixel. depth: Option, /// BMP layout: `windows` or `os2`. file_format: Option, @@ -227,6 +227,7 @@ impl RasterOptions { top_down: self.flip_rows.unwrap_or(false), gray: color_model == jpeg::ColorModel::Gray, }, + tga: tga::TgaOptions { depth: self.depth.unwrap_or(24) }, }) } } @@ -240,13 +241,15 @@ fn bmp_layout(s: &str) -> Option { } } -/// Refuse options `format` can't write together: BMP depths and layouts, and colour models (BMP -/// is RGB or grey). +/// Refuse options `format` can't write together: BMP and Targa depths and layouts, and colour +/// models (BMP is RGB or grey, Targa RGB). fn check_options(format: RasterFormat, o: &RasterExportOptions) -> Result<()> { let model = o.jpeg.color_model; let r = match format { RasterFormat::Bmp if model == jpeg::ColorModel::Cmyk => Err("BMP files are RGB or grayscale: colorModel rgb or gray".to_string()), + RasterFormat::Tga if model != jpeg::ColorModel::Rgb => Err("Targa files are RGB: colorModel rgb".to_string()), RasterFormat::Bmp => o.bmp.check(), + RasterFormat::Tga => o.tga.check(), _ => Ok(()), }; r.map_err(|e| bad(C, e)) @@ -376,7 +379,7 @@ pub fn encode_all(doc: &Document, format: &str, p: &Value) -> Result { let (bytes, warnings) = super::pdf::encode(C, doc, p)?; return Ok(Encoded { warnings, ..Encoded::one(bytes) }); } - "png" | "jpg" | "webp" | "gif" | "png8" | "tiff" | "bmp" => { + "png" | "jpg" | "webp" | "gif" | "png8" | "tiff" | "bmp" | "tga" => { let o: RasterOptions = options(f, p)?; // New Document → Background Contents: White makes the export opaque, unless `background` // says otherwise (JPEG has no alpha: white either way). @@ -390,6 +393,7 @@ pub fn encode_all(doc: &Document, format: &str, p: &Value) -> Result { "png8" => RasterFormat::Png8, "tiff" => RasterFormat::Tiff, "bmp" => RasterFormat::Bmp, + "tga" => RasterFormat::Tga, _ => RasterFormat::WebP, }; check_options(format, &settings)?; diff --git a/crates/engine/src/cmd/fileio/mod.rs b/crates/engine/src/cmd/fileio/mod.rs index 1ddbc0ca..06ef828c 100644 --- a/crates/engine/src/cmd/fileio/mod.rs +++ b/crates/engine/src/cmd/fileio/mod.rs @@ -83,7 +83,7 @@ pub fn specs() -> Vec { "Serialize Document", [], None, - "{format?: vectorcraft (default)|template|svg|svgz|pdf|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|tiff|bmp, …the format's options (see document.formats; SVG ones also as svg: {…}), selectedOnly?: false (the selected objects alone, in their layers)} → {text, warnings} for svg (plus dataBase64, the file, when its encoding isn't UTF-8), else {dataBase64, warnings}; an SVG of several artboards also gives files: [{name, text}], linked images linked: [{name, dataBase64}]", + "{format?: vectorcraft (default)|template|svg|svgz|pdf|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|tiff|bmp|tga, …the format's options (see document.formats; SVG ones also as svg: {…}), selectedOnly?: false (the selected objects alone, in their layers)} → {text, warnings} for svg (plus dataBase64, the file, when its encoding isn't UTF-8), else {dataBase64, warnings}; an SVG of several artboards also gives files: [{name, text}], linked images linked: [{name, dataBase64}]", has_doc, export::serialize ), @@ -92,7 +92,7 @@ pub fn specs() -> Vec { "Export Document", [], None, - "{path?, format?: svg|svgz|pdf|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|tiff|bmp|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png), selectedOnly?: false (the selected objects alone, in their layers), 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), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf: every page) | false (pdf/raster: the bounds of the visible art; SVG has it as an SVG option), 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), jpg: quality?: 90 (0–100), colorModel?: rgb|cmyk|gray, method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as .html / .map), gif/png8: colors?: 256 (2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?, webp: lossless?: true (lossy WebP isn't available yet: written lossless, with a warning), txt: the stories in stacking order (back to front; a thread once), encoding?: utf8|utf16 (with a byte order mark), lineEndings?: lf|crlf, selectionOnly?: false; SVG options flat or as svg: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers, encoding, profile, embedFonts} (see document.formats), …the PDF options of document.exportPdf, …the DXF options of document.exportDxf (useArtboards: one drawing per artboard), …the EPS options of document.exportEps (useArtboards: one file per artboard, {stem}_{artboard}.eps; else the visible art), emf/wmf: one picture of the artboard (useArtboards: true one file per chosen artboard, false the bounds of the visible art; EMF keeps curves, clipping, transparent images and gradients as images clipped to their shape; WMF flattens curves into polygons behind a placeable header; what a format leaves out comes back in warnings), tiff: colorModel?: rgb|cmyk|gray (rgb keeps transparency as an alpha channel), lzw?: true, byteOrder?: little|big, embedIcc?: true, bmp: colorModel?: rgb|gray, depth?: 24 (1 black and white, 4|8 a palette by reduction and dither, 16, 24, 32 with alpha), fileFormat?: windows|os2 (1, 4, 8 or 24 bits), rle?: false (4 and 8 bits, windows), flipRows?: false (top-down rows); formats without alpha are flattened on white} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; 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|gif|png8|txt|dxf|eps|emf|wmf|tiff|bmp|tga|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png), selectedOnly?: false (the selected objects alone, in their layers), 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), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf: every page) | false (pdf/raster: the bounds of the visible art; SVG has it as an SVG option), 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), jpg: quality?: 90 (0–100), colorModel?: rgb|cmyk|gray, method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as .html / .map), gif/png8: colors?: 256 (2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?, webp: lossless?: true (lossy WebP isn't available yet: written lossless, with a warning), txt: the stories in stacking order (back to front; a thread once), encoding?: utf8|utf16 (with a byte order mark), lineEndings?: lf|crlf, selectionOnly?: false; SVG options flat or as svg: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers, encoding, profile, embedFonts} (see document.formats), …the PDF options of document.exportPdf, …the DXF options of document.exportDxf (useArtboards: one drawing per artboard), …the EPS options of document.exportEps (useArtboards: one file per artboard, {stem}_{artboard}.eps; else the visible art), emf/wmf: one picture of the artboard (useArtboards: true one file per chosen artboard, false the bounds of the visible art; EMF keeps curves, clipping, transparent images and gradients as images clipped to their shape; WMF flattens curves into polygons behind a placeable header; what a format leaves out comes back in warnings), tiff: colorModel?: rgb|cmyk|gray (rgb keeps transparency as an alpha channel), lzw?: true, byteOrder?: little|big, embedIcc?: true, bmp: colorModel?: rgb|gray, depth?: 24 (1 black and white, 4|8 a palette by reduction and dither, 16, 24, 32 with alpha), fileFormat?: windows|os2 (1, 4, 8 or 24 bits), rle?: false (4 and 8 bits, windows), flipRows?: false (top-down rows), tga: depth?: 24 (16 one-bit alpha, 24, 32 with alpha); formats without alpha are flattened on white} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. Never changes the document's path", has_doc, export::export ), @@ -385,6 +385,13 @@ const BMP_OPTIONS: &[FormatOption] = &[ DITHER, DITHER_AMOUNT, ]; +const TGA_DEPTH: FormatOption = FormatOption { + name: "depth", + ty: "integer", + default: "24", + description: "bits per pixel: 16 (one-bit alpha), 24 (flattened on white) or 32 (keeps transparency)", +}; +const TGA_OPTIONS: &[FormatOption] = &[ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, PPI, SCALE, BACKGROUND, ANTI_ALIAS, TGA_DEPTH]; /// 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 { @@ -509,6 +516,7 @@ pub const FORMATS: &[Format] = &[ }, Format { id: "emf", label: "EMF", extensions: &["emf"], mime: "image/emf", read: true, write: true, raster: false, options: metafile::OPTIONS }, Format { id: "wmf", label: "WMF", extensions: &["wmf"], mime: "image/wmf", read: true, write: true, raster: false, options: metafile::OPTIONS }, + Format { id: "tga", label: "Targa", extensions: &["tga"], mime: "image/x-tga", read: false, write: true, raster: true, options: TGA_OPTIONS }, ]; /// Every extension `document.open` reads (the "All readable files" filter of open dialogs). diff --git a/crates/engine/src/cmd/fileio/tests.rs b/crates/engine/src/cmd/fileio/tests.rs index bab03836..4eab7048 100644 --- a/crates/engine/src/cmd/fileio/tests.rs +++ b/crates/engine/src/cmd/fileio/tests.rs @@ -156,7 +156,26 @@ fn formats_query_lists_readers_writers_and_options() { let ids = |k: &str| r[k].as_array().unwrap().iter().map(|v| v.as_str().unwrap().to_string()).collect::>(); assert_eq!( ids("writable"), - ["vectorcraft", "svg", "svgz", "pdf", "png", "jpg", "gif", "webp", "tiff", "bmp", "template", "png8", "txt", "dxf", "eps", "emf", "wmf"] + [ + "vectorcraft", + "svg", + "svgz", + "pdf", + "png", + "jpg", + "gif", + "webp", + "tiff", + "bmp", + "template", + "png8", + "txt", + "dxf", + "eps", + "emf", + "wmf", + "tga" + ] ); assert!(ids("readable").contains(&"tiff".to_string()) && ids("readable").contains(&"ait".to_string())); let png = r["formats"].as_array().unwrap().iter().find(|f| f["id"] == "png").unwrap(); diff --git a/crates/engine/src/cmd/fileio/tests_tiffbmp.rs b/crates/engine/src/cmd/fileio/tests_tiffbmp.rs index 8d7f6eb1..0410b188 100644 --- a/crates/engine/src/cmd/fileio/tests_tiffbmp.rs +++ b/crates/engine/src/cmd/fileio/tests_tiffbmp.rs @@ -1,4 +1,4 @@ -//! TIFF and BMP export through `document.export`: the options reach the files, impossible +//! TIFF, BMP and Targa export through `document.export`: the options reach the files, impossible //! ones are refused, and the files open again. use serde_json::{Value, json}; @@ -120,23 +120,40 @@ fn bmp_options_reach_the_file() { assert_eq!(r["format"], "bmp"); } +#[test] +fn targa_depth_reaches_the_file() { + let mut s = session(); + for (depth, alpha) in [(16, 1), (24, 0), (32, 8)] { + let tga = export(&mut s, json!({"format": "tga", "depth": depth})); + assert_eq!((tga[16], tga[17]), (depth, alpha)); + assert_eq!(u16::from_le_bytes([tga[12], tga[13]]), 60); + } + let tga = export(&mut s, json!({"format": "tga"})); + assert_eq!(tga[16], 24, "24 bits by default"); + for bad in [json!({"depth": 8}), json!({"colorModel": "gray"})] { + assert!(s.execute("document.export", &merge(json!({"format": "tga"}), bad.clone())).is_err(), "{bad}"); + } +} + #[test] fn the_formats_are_listed_and_write_one_file_per_artboard() { let mut s = Session::new(); s.execute("file.new", &json!({"width": 30, "height": 20, "artboards": 2})).unwrap(); let r = s.execute("document.formats", &json!({})).unwrap(); let find = |id: &str| r["formats"].as_array().unwrap().iter().find(|f| f["id"] == id).unwrap().clone(); - let (tiff, bmp) = (find("tiff"), find("bmp")); + let (tiff, bmp, tga) = (find("tiff"), find("bmp"), find("tga")); assert_eq!((tiff["write"].as_bool(), tiff["read"].as_bool()), (Some(true), Some(true))); - assert_eq!((bmp["write"].as_bool(), bmp["read"].as_bool()), (Some(true), Some(true))); + assert_eq!((bmp["write"].as_bool(), tga["write"].as_bool(), tga["read"].as_bool()), (Some(true), Some(true), Some(false))); for (f, keys) in [ (&tiff, &["colorModel", "lzw", "byteOrder", "embedIcc", "ppi", "antiAlias"][..]), (&bmp, &["colorModel", "depth", "fileFormat", "rle", "flipRows", "reduction", "dither"]), + (&tga, &["depth", "ppi", "antiAlias", "background"]), ] { for k in keys { assert!(f["options"].get(*k).is_some(), "{} takes {k}", f["id"]); } } + assert_eq!(format_for_name("a.tga").map(|f| f.id), Some("tga")); let r = s.execute("document.export", &json!({"format": "tiff", "useArtboards": true})).unwrap(); let files: Vec<&str> = r["files"].as_array().unwrap().iter().map(|f| f["name"].as_str().unwrap()).collect(); assert_eq!(files, ["Untitled-1-Artboard-1.tif", "Untitled-1-Artboard-2.tif"]); diff --git a/crates/mcp/src/tests_fileio.rs b/crates/mcp/src/tests_fileio.rs index a2ce38b1..13916831 100644 --- a/crates/mcp/src/tests_fileio.rs +++ b/crates/mcp/src/tests_fileio.rs @@ -74,7 +74,8 @@ fn export_tool_lists_engine_formats_and_options() { "dxf", "eps", "emf", - "wmf" + "wmf", + "tga" ]) ); for k in ["artboard", "range", "options"] { diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index d2c86c2f..5d9995b3 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -264,7 +264,7 @@ pub fn tool_definitions() -> Vec { tool( "export", "Export", - "Export the active document with the engine's document.export (the same bytes in the app and headless): svg (artboard viewBox), pdf (one page per artboard: all, or `artboard` / `range`), png/jpg/webp/gif/png8/tiff/bmp (one rendered artboard; png8 is an indexed .png), txt (the stories, back to front) or vectorcraft (a native copy; the document keeps its path). `selection: true` exports only the selected objects, cropped to their bounds. Template layers are left out; live effects are kept (geometry baked, SVG filters for shadows/glows/blur). Without `path` the bytes come back as dataBase64.", + "Export the active document with the engine's document.export (the same bytes in the app and headless): svg (artboard viewBox), pdf (one page per artboard: all, or `artboard` / `range`), png/jpg/webp/gif/png8/tiff/bmp/tga (one rendered artboard; png8 is an indexed .png), txt (the stories, back to front) or vectorcraft (a native copy; the document keeps its path). `selection: true` exports only the selected objects, cropped to their bounds. Template layers are left out; live effects are kept (geometry baked, SVG filters for shadows/glows/blur). Without `path` the bytes come back as dataBase64.", obj( json!({ "format": {"type": "string", "enum": FORMATS.iter().filter(|f| f.write).map(|f| f.id).collect::>(), "description": "Default: from the path's extension"}, diff --git a/crates/render/Cargo.toml b/crates/render/Cargo.toml index 152076db..a0ae310e 100644 --- a/crates/render/Cargo.toml +++ b/crates/render/Cargo.toml @@ -31,6 +31,8 @@ vello_cpu = { workspace = true, features = ["multithreading"] } [dev-dependencies] vectorcraft-testkit = { workspace = true } +# Decoders the export tests read their files back with. +image = { workspace = true, features = ["tga"] } tiff = "0.11" proptest = { workspace = true } serde_json = { workspace = true } diff --git a/crates/render/src/encode/mod.rs b/crates/render/src/encode/mod.rs index a4f96114..4140a560 100644 --- a/crates/render/src/encode/mod.rs +++ b/crates/render/src/encode/mod.rs @@ -9,6 +9,7 @@ pub mod gif; pub mod jpeg; pub mod png; pub mod quantize; +pub mod tga; pub mod tiff; use vectorcraft_doc::{Document, Node, NodeKind}; @@ -27,6 +28,7 @@ pub enum RasterFormat { Gif, Tiff, Bmp, + Tga, } /// How a raster export renders and encodes. @@ -49,6 +51,8 @@ pub struct RasterExportOptions { pub tiff: tiff::TiffOptions, /// BMP layout, depth, compression and row order. pub bmp: bmp::BmpOptions, + /// Targa depth. + pub tga: tga::TgaOptions, } impl Default for RasterExportOptions { @@ -63,6 +67,7 @@ impl Default for RasterExportOptions { palette: quantize::PaletteOptions::default(), tiff: tiff::TiffOptions::default(), bmp: bmp::BmpOptions::default(), + tga: tga::TgaOptions::default(), } } } @@ -109,6 +114,7 @@ impl RasterExportOptions { } RasterFormat::Tiff => tiff::encode(img, self.ppi, &self.tiff), RasterFormat::Bmp => bmp::encode(&img.to_straight(), img.width, img.height, self.ppi, &self.bmp, &self.palette), + RasterFormat::Tga => tga::encode(&img.to_straight(), img.width, img.height, &self.tga), } } @@ -191,4 +197,6 @@ mod tests_jpeg; #[cfg(test)] mod tests_palette; #[cfg(test)] +mod tests_tga; +#[cfg(test)] mod tests_tiff; diff --git a/crates/render/src/encode/tests_tga.rs b/crates/render/src/encode/tests_tga.rs new file mode 100644 index 00000000..3f720443 --- /dev/null +++ b/crates/render/src/encode/tests_tga.rs @@ -0,0 +1,30 @@ +//! Targa export: depths, alpha and the footer. + +use super::on_white; +use super::tests_bmp::{art, decode, u16_at}; +use super::tga::{self, TgaOptions}; + +#[test] +fn targa_depths_alpha_and_footer() { + let (w, h) = (7, 3); + let px = art(w, h); + for (depth, alpha_bits) in [(16, 1), (24, 0), (32, 8)] { + let file = tga::encode(&px, w, h, &TgaOptions { depth }).unwrap(); + assert_eq!((file[2], file[16], file[17]), (2, depth, alpha_bits), "true colour, the depth byte, alpha bits"); + assert_eq!((u16_at(&file, 12), u16_at(&file, 14)), (7, 3)); + assert_eq!(file.len(), 18 + (w * h) as usize * usize::from(depth / 8) + 26); + assert!(file.ends_with(b"TRUEVISION-XFILE.\0")); + let img = decode(&file, image::ImageFormat::Tga); + assert_eq!(img.dimensions(), (w, h)); + let want = on_white(px[(2 * w as usize + 3) * 4..][..4].try_into().unwrap()); + let got = img.get_pixel(3, 2).0; + assert!(got[..3].iter().zip(want).all(|(a, b)| a.abs_diff(b) <= 8), "depth {depth}: {got:?} vs {want:?}"); + match depth { + 32 => assert_eq!((img.get_pixel(0, 0).0[3], img.get_pixel(1, 0).0[3]), (0, 128), "8-bit alpha"), + 24 => assert_eq!(img.get_pixel(0, 0).0, [255; 4], "flattened on white"), + _ => {} + } + } + assert!(tga::encode(&px, w, h, &TgaOptions { depth: 8 }).is_err()); + assert!(tga::encode(&px, 70_000, 1, &TgaOptions::default()).is_err(), "too wide"); +} diff --git a/crates/render/src/encode/tga.rs b/crates/render/src/encode/tga.rs new file mode 100644 index 00000000..884276ee --- /dev/null +++ b/crates/render/src/encode/tga.rs @@ -0,0 +1,77 @@ +//! Targa export: uncompressed true colour at 16 bits per pixel (5-5-5 and a one-bit alpha), 24 or +//! 32 (8-bit alpha), rows bottom-up, with the version 2 footer. + +use super::on_white; + +/// Bits per pixel a Targa export can have. +pub const DEPTHS: [u8; 3] = [16, 24, 32]; + +/// The Targa options of a raster export. +#[derive(Clone, Debug, PartialEq)] +pub struct TgaOptions { + /// Bits per pixel ([`DEPTHS`]). + pub depth: u8, +} + +impl Default for TgaOptions { + fn default() -> Self { + Self { depth: 24 } + } +} + +impl TgaOptions { + /// Why these options can't be written, if they can't. + pub fn check(&self) -> Result<(), String> { + if DEPTHS.contains(&self.depth) { Ok(()) } else { Err(format!("Targa depth {}: 16, 24 or 32 bits per pixel", self.depth)) } + } +} + +/// Straight-alpha RGBA pixels as a Targa file. 24 bits blend partly transparent pixels over white; +/// at 16 bits a pixel is opaque from half opacity up, else transparent. +pub fn encode(rgba: &[u8], width: u32, height: u32, o: &TgaOptions) -> Result, String> { + o.check()?; + let (Ok(w16), Ok(h16)) = (u16::try_from(width), u16::try_from(height)) else { + return Err(format!("{width} × {height} pixels is too large for Targa (at most 65535 pixels a side): lower the resolution")); + }; + let (w, h) = (usize::from(w16), usize::from(h16)); + if w == 0 || h == 0 || w * h * 4 != rgba.len() { + return Err("Targa encoding failed: the pixel buffer doesn't match the image size".into()); + } + let px = rgba.as_chunks::<4>().0; + let mut out = Vec::with_capacity(18 + w * h * usize::from(o.depth / 8) + 26); + // No image id or colour map, uncompressed true colour, origin (0, 0). + out.extend([0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 0, 0]); + out.extend(w16.to_le_bytes()); + out.extend(h16.to_le_bytes()); + // Bits per pixel, then the alpha bits (rows bottom-up, left to right). + let alpha_bits = match o.depth { + 16 => 1, + 32 => 8, + _ => 0, + }; + out.extend([o.depth, alpha_bits]); + for row in px.chunks(w).rev() { + for p in row { + match o.depth { + 16 => { + let v = if p[3] >= 128 { + let [r, g, b] = [p[0], p[1], p[2]].map(|c| u16::from(c >> 3)); + 0x8000 | r << 10 | g << 5 | b + } else { + 0 + }; + out.extend(v.to_le_bytes()); + } + 24 => { + let [r, g, b] = on_white(p); + out.extend([b, g, r]); + } + _ => out.extend([p[2], p[1], p[0], p[3]]), + } + } + } + // No extension area or developer directory. + out.extend([0; 8]); + out.extend(b"TRUEVISION-XFILE.\0"); + Ok(out) +} diff --git a/crates/ui-egui/src/dialogs/mod.rs b/crates/ui-egui/src/dialogs/mod.rs index 17f4e88d..31c453b7 100644 --- a/crates/ui-egui/src/dialogs/mod.rs +++ b/crates/ui-egui/src/dialogs/mod.rs @@ -228,7 +228,7 @@ registry! { EpsOptions: [eps_options::KIND] => eps_options::SPEC, Recovery: [crate::recovery::KIND] => recovery::SPEC, DxfImport: [dxf_import::KIND] => dxf_import::SPEC, - RasterFormatOptions: ["tiffOptions", "bmpOptions"] => png_options::SPEC, + RasterFormatOptions: ["tiffOptions", "bmpOptions", "tgaOptions"] => png_options::SPEC, } /// The spec for a `Dialog::kind` ([`DialogSpec::FORM`] when unregistered). diff --git a/crates/ui-egui/src/dialogs/png_options.rs b/crates/ui-egui/src/dialogs/png_options.rs index 72b8cb4d..221885d9 100644 --- a/crates/ui-egui/src/dialogs/png_options.rs +++ b/crates/ui-egui/src/dialogs/png_options.rs @@ -1,7 +1,7 @@ //! The raster export options: PNG Options (resolution, background, anti-aliasing, interlaced), //! JPEG Options (no transparency; colour model, quality 0–10, method and scans, profile, image //! map), WebP Options, and PNG-8 and GIF Options (the palette: colour reduction, colours, dither, -//! transparency and matte), and TIFF and BMP Options ([`super::tiff_bmp_tga`]). Fields +//! transparency and matte), and TIFF, BMP and Targa Options ([`super::tiff_bmp_tga`]). Fields //! are `document.export` params (format, path, artboard choice…); `__`-prefixed ones are the //! dialog's. diff --git a/crates/ui-egui/src/dialogs/tests_tiff_bmp_tga.rs b/crates/ui-egui/src/dialogs/tests_tiff_bmp_tga.rs index 96317a17..34a3e751 100644 --- a/crates/ui-egui/src/dialogs/tests_tiff_bmp_tga.rs +++ b/crates/ui-egui/src/dialogs/tests_tiff_bmp_tga.rs @@ -1,4 +1,4 @@ -//! TIFF and BMP Options through Export As, drawn headlessly. +//! TIFF, BMP and Targa Options through Export As, drawn headlessly. use serde_json::json; @@ -61,3 +61,21 @@ fn bmp_options_keep_their_choices_writable() { assert_eq!(path, "/out/Untitled-1.bmp"); assert_eq!((&bmp[..2], bmp[28], bmp[30]), (&b"BM"[..], 8, 1), "8-bit RLE8"); } + +#[test] +fn targa_options_pick_the_depth() { + let (mut app, written) = app(1); + app.run("file.exportAs", json!({"format": "tga"})).unwrap(); + confirm(&mut app).unwrap(); + assert_eq!(kind(&app), Some("tgaOptions")); + let d = app.ui.dialog.as_ref().unwrap(); + assert_eq!(((spec(&d.kind).heading)(d), d.f64("depth", 0.0)), ("Targa Options".into(), 24.0)); + assert!(!tiff_bmp_tga::keeps_alpha("tga", d), "24 bits are flattened on white"); + frame(&mut app); + set(&mut app, "depth", json!(32)); + frame(&mut app); + assert!(tiff_bmp_tga::keeps_alpha("tga", app.ui.dialog.as_ref().unwrap())); + confirm(&mut app).unwrap(); + let (path, tga) = written.borrow()[0].clone(); + assert_eq!((path.as_str(), tga[16], tga[17]), ("/out/Untitled-1.tga", 32, 8)); +} diff --git a/crates/ui-egui/src/dialogs/tiff_bmp_tga.rs b/crates/ui-egui/src/dialogs/tiff_bmp_tga.rs index 776b6f26..3253bf8d 100644 --- a/crates/ui-egui/src/dialogs/tiff_bmp_tga.rs +++ b/crates/ui-egui/src/dialogs/tiff_bmp_tga.rs @@ -1,11 +1,12 @@ //! The options of TIFF (colour model, byte order, LZW, profile), BMP (colour model, layout, depth, -//! palette, RLE, row order), after the raster export rows they share with PNG Options -//! ([`super::png_options`]): the `tiffOptions` and `bmpOptions` dialogs. +//! palette, RLE, row order) and Targa (depth), after the raster export rows they share with PNG +//! Options ([`super::png_options`]): the `tiffOptions`, `bmpOptions` and `tgaOptions` dialogs. use serde_json::{Map, Value, json}; use vectorcraft_render::encode::bmp::{self, BmpOptions}; use vectorcraft_render::encode::jpeg::ColorModel; use vectorcraft_render::encode::quantize::{Dither, PaletteOptions, Reduction}; +use vectorcraft_render::encode::tga::{self, TgaOptions}; use vectorcraft_render::encode::tiff::{ByteOrder, TiffOptions}; use super::form; @@ -18,8 +19,9 @@ const BMP_MODELS: [&str; 2] = ["rgb", "gray"]; const BMP_MODEL_LABELS: [&str; 2] = ["RGB", "Grayscale"]; const LAYOUTS: [&str; 2] = ["windows", "os2"]; const LAYOUT_LABELS: [&str; 2] = ["Windows", "OS/2"]; -/// Labels of [`bmp::DEPTHS`]. +/// Labels of [`bmp::DEPTHS`] and [`tga::DEPTHS`]. const BMP_DEPTH_LABELS: [&str; 6] = ["1 bit", "4 bit", "8 bit", "16 bit", "24 bit", "32 bit"]; +const TGA_DEPTH_LABELS: [&str; 3] = ["16 bits/pixel", "24 bits/pixel", "32 bits/pixel"]; type Label<'a> = &'a dyn Fn(&mut egui::Ui, &str) -> egui::Response; @@ -48,6 +50,9 @@ pub(super) fn defaults(id: &str, cmyk: bool, o: &mut Map) { ("dither".into(), json!(p.dither.id())), ]); } + "tga" => { + o.insert("depth".into(), json!(TgaOptions::default().depth)); + } _ => {} } } @@ -58,6 +63,7 @@ pub(super) fn keeps_alpha(id: &str, d: &Dialog) -> bool { "jpg" => false, "tiff" => ColorModel::from_id(&d.str("colorModel")).unwrap_or_default() == ColorModel::Rgb, "bmp" => d.f64("depth", 24.0) == 32.0, + "tga" => d.f64("depth", 24.0) != 24.0, _ => true, } } @@ -67,6 +73,11 @@ pub(super) fn rows(ui: &mut egui::Ui, d: &mut Dialog, id: &str, label: Label) { match id { "tiff" => tiff_rows(ui, d, label), "bmp" => bmp_rows(ui, d, label), + "tga" => { + label(ui, "Depth:"); + depth_row(ui, d, "tga-depth", &tga::DEPTHS, &TGA_DEPTH_LABELS, |_| true); + ui.end_row(); + } _ => {} } } diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 15625e6f..47904e46 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -366,10 +366,11 @@ EMF and WMF: Export As… lists both formats (`file.exportAs {format: "emf"}` or dialog, so `ui.dialog.confirm` asks for the path and writes the file (one per artboard with Use Artboards). Open and Place read `.emf` and `.wmf`; a paste of `image/emf` from a system clipboard service runs `clipboard.importEmf`. -TIFF and BMP Options: Export As… with the TIFF or BMP format (`file.exportAs {format: "tiff"}`, then -the save dialog) opens `tiffOptions` or `bmpOptions`: the raster rows of PNG Options (`ppi`, +TIFF, BMP and Targa Options: Export As… with the TIFF, BMP or Targa format (`file.exportAs {format: "tiff"}`, then +the save dialog) opens `tiffOptions`, `bmpOptions` or `tgaOptions`: the raster rows of PNG Options (`ppi`, `background`, `antiAlias`), then TIFF's `colorModel` (`rgb`, `cmyk`, `gray`; a CMYK document starts on `cmyk`), `byteOrder` (`little`, `big`), `lzw` and `embedIcc`; BMP's `colorModel` (`rgb`, `gray`), `fileFormat` (`windows`, -`os2`), `depth` (1, 4, 8, 16, 24, 32), `reduction` and `dither` (4 and 8 bits), `rle` and `flipRows`. The BMP dialog keeps its choices writable together (OS/2 falls back to 24 bits and turns RLE and flipped +`os2`), `depth` (1, 4, 8, 16, 24, 32), `reduction` and `dither` (4 and 8 bits), `rle` and `flipRows`; Targa's `depth` +(16, 24, 32). The BMP dialog keeps its choices writable together (OS/2 falls back to 24 bits and turns RLE and flipped rows off; RLE turns flipped rows off). `ui.dialog.set` the fields and `ui.dialog.confirm` writes the file(s) with `document.export`. diff --git a/docs/mcp.md b/docs/mcp.md index 01cd59c8..2719366c 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -64,7 +64,7 @@ objects' fills or strokes differ (`fillMixed` / `strokeMixed`, drawn as a "?" pr | `screenshot` | `{path?, scale?, artboard?, window?}` | Returns MCP image content (`image/png`, base64) plus a text block. Renders the artboard; `window:true` captures the app window (remote only). | | `open_file` | `{path}` | Opens any readable file as a new active document: `.vectorcraft`/`.drawcraft`, `.svg`/`.svgz`, `.pdf`/`.ai`, `.ait`, PNG, JPEG, GIF, WebP, TIFF, BMP (an image opens as a document of its pixel size). Templates open as a new untitled document. PDF, `.ai` and SVG files saved with Preserve Editing reopen as the document they carry. `run_command document.formats` lists the formats. | | `save_file` | `{path?}` | Runs `document.save`: the document's own file in its own format (native `.vectorcraft` unless it was opened from or saved as SVG, PDF or a restorable `.ai`; then `warnings` say what that format loses). A path's extension picks the format (`.vectorcraft`, `.vctemplate`, `.pdf`, `.svg`, `.svgz`, `.ai`: a PDF carrying the native document, which reopens editable). | -| `export` | `{path?, format?, scale?, artboard?, range?, selection?, outlineText?, options?}` | `svg`, `pdf`, `png`, `jpg`, `webp`, `gif`, `png8` (an indexed `.png`), `tiff`, `bmp`, `txt` (the document's text) or `vectorcraft` (the list comes from `document.formats`). When `format` is omitted, it comes from the path's extension. PDF writes one page per artboard: all of them, or `artboard` (0-based) / `range` (`"1-3, 5"`, 1-based); the other formats write one artboard. `options` carries more format options (e.g. `{"quality": 80}` for JPEG). `selection: true` exports the selected objects cropped to their bounds; `outlineText: true` writes SVG text as paths. Template layers are left out, live effects are kept, and exporting `vectorcraft` never changes the document's path. Without `path` the bytes come back as `dataBase64`. Both backends run the same `document.export` call. | +| `export` | `{path?, format?, scale?, artboard?, range?, selection?, outlineText?, options?}` | `svg`, `pdf`, `png`, `jpg`, `webp`, `gif`, `png8` (an indexed `.png`), `tiff`, `bmp`, `tga`, `txt` (the document's text) or `vectorcraft` (the list comes from `document.formats`). When `format` is omitted, it comes from the path's extension. PDF writes one page per artboard: all of them, or `artboard` (0-based) / `range` (`"1-3, 5"`, 1-based); the other formats write one artboard. `options` carries more format options (e.g. `{"quality": 80}` for JPEG). `selection: true` exports the selected objects cropped to their bounds; `outlineText: true` writes SVG text as paths. Template layers are left out, live effects are kept, and exporting `vectorcraft` never changes the document's path. Without `path` the bytes come back as `dataBase64`. Both backends run the same `document.export` call. | | `add_text` | `{text, x?, y?, width?, height?, path?, mode?, pathEffect?, size?, font?, color?}` | Point type at (x, y); area type with `width`/`height`; or `path` + `mode` (`area`/`onPath`) to flow text in or along a path, with `pathEffect` (`rainbow`, `skew`, `3dRibbon`, `stairStep`, `gravity`). | | `apply_effect` | `{effect?, params?, ids?}` | Appends a live effect. Without `effect`, returns the effect catalogue with parameters and defaults. | | `pathfinder` | `{operation, ids?}` | `unite`, `minusFront`, `intersect`, `exclude`, `divide`, `trim`, `merge`, `crop`, `outline`, `minusBack`. For a live version, apply the `pathfinder.*` effect to a group. | @@ -1364,7 +1364,7 @@ clipboard service offers it (the Windows desktop service doesn't read metafiles {"name":"run_command","arguments":{"command":"file.place","params":{"path":"/tmp/chart.emf","at":[300,200]}}} ``` -## TIFF and BMP export +## TIFF, BMP and Targa export `document.export` (and `export`) writes them like the other raster formats: one artboard (or `useArtboards`, one file each), at `ppi`, over `background`, with `antiAlias`. TIFF takes `colorModel` (`rgb` keeps transparency as an @@ -1373,10 +1373,12 @@ unassociated alpha channel, `cmyk` writes ink amounts in the working CMYK space and `embedIcc` (sRGB, the working CMYK profile or grey). BMP takes `depth` (1 is black and white; 4 and 8 use a palette chosen by `reduction` and `dither`; 16, 24, or 32, which keeps transparency), `colorModel` (`rgb` or `gray`), `fileFormat` (`windows` or `os2`: 1, 4, 8 or 24 bits only), `rle` (RLE4/RLE8 for 4- and 8-bit Windows bitmaps) and -`flipRows` (rows top-down, a negative height; not with `rle` or `os2`); impossible combinations are refused. Formats and depths -without alpha are flattened on white. TIFF and BMP files open again; `document.formats` lists every option. +`flipRows` (rows top-down, a negative height; not with `rle` or `os2`); impossible combinations are refused. Targa +takes `depth`: 16 (a one-bit alpha), 24 (default) or 32 (with alpha). Formats and depths without alpha are flattened +on white. TIFF and BMP files open again; `document.formats` lists every option. ```json {"name":"export","arguments":{"path":"/tmp/print.tif","options":{"ppi":300,"colorModel":"cmyk","byteOrder":"big"}}} {"name":"run_command","arguments":{"command":"document.export","params":{"format":"bmp","depth":8,"rle":true}}} +{"name":"export","arguments":{"path":"/tmp/sprite.tga","options":{"depth":32}}} ```