From f2fc8782c7576b406458254c53ea2625da16fe00 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 5 Oct 2026 14:55:42 +0700 Subject: [PATCH] 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}}} ```