mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-30 20:58:00 -06:00
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
d01b2fbd8a
commit
f2fc8782c7
@@ -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<bool>,
|
||||
lzw: Option<bool>,
|
||||
byte_order: Option<String>,
|
||||
/// BMP bits per pixel.
|
||||
depth: Option<u8>,
|
||||
/// BMP layout: `windows` or `os2`.
|
||||
file_format: Option<String>,
|
||||
rle: Option<bool>,
|
||||
flip_rows: Option<bool>,
|
||||
}
|
||||
|
||||
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<bool> {
|
||||
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<T: Default>(v: Option<&str>, from_id: fn(&str) -> Option<T>, name: &str, ids: &str) -> Result<T> {
|
||||
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<Encoded> {
|
||||
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<Encoded> {
|
||||
"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()),
|
||||
|
||||
@@ -83,7 +83,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"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<CommandSpec> {
|
||||
"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 <stem>.html / <stem>.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 <stem>.html / <stem>.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",
|
||||
|
||||
@@ -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::<Vec<_>>();
|
||||
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();
|
||||
|
||||
@@ -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"]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user