Merge pull request #110 from storytold/pr/m4-tiff-bmp-tga
M4.67–M4.69: TIFF, BMP and Targa export
This commit is contained in:
commit
df4b84048e
|
|
@ -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]]
|
||||
|
|
|
|||
|
|
@ -11,7 +11,8 @@ 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::{RasterExportOptions, RasterFormat};
|
||||
use vectorcraft_render::encode::tiff::{ByteOrder, TiffOptions};
|
||||
use vectorcraft_render::encode::{RasterExportOptions, RasterFormat, bmp, tga};
|
||||
|
||||
use super::super::*;
|
||||
use super::Format;
|
||||
|
|
@ -162,6 +163,14 @@ struct RasterOptions {
|
|||
transparency: Option<bool>,
|
||||
matte: Option<Value>,
|
||||
lossless: Option<bool>,
|
||||
lzw: Option<bool>,
|
||||
byte_order: Option<String>,
|
||||
/// BMP and Targa bits per pixel.
|
||||
depth: Option<u8>,
|
||||
/// BMP layout: `windows` or `os2`.
|
||||
file_format: Option<String>,
|
||||
rle: Option<bool>,
|
||||
flip_rows: Option<bool>,
|
||||
}
|
||||
|
||||
impl RasterOptions {
|
||||
|
|
@ -172,6 +181,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 +193,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,10 +214,47 @@ 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,
|
||||
},
|
||||
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,
|
||||
},
|
||||
tga: tga::TgaOptions { depth: self.depth.unwrap_or(24) },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 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))
|
||||
}
|
||||
|
||||
/// 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)
|
||||
|
|
@ -331,7 +379,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" => {
|
||||
"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).
|
||||
|
|
@ -343,8 +391,12 @@ pub fn encode_all(doc: &Document, format: &str, p: &Value) -> Result<Encoded> {
|
|||
"jpg" => RasterFormat::Jpeg,
|
||||
"gif" => RasterFormat::Gif,
|
||||
"png8" => RasterFormat::Png8,
|
||||
"tiff" => RasterFormat::Tiff,
|
||||
"bmp" => RasterFormat::Bmp,
|
||||
"tga" => RasterFormat::Tga,
|
||||
_ => 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, …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<CommandSpec> {
|
|||
"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 <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)} → {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 <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), 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
|
||||
),
|
||||
|
|
@ -336,6 +336,63 @@ 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];
|
||||
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,
|
||||
];
|
||||
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 {
|
||||
Format { id, label, extensions, mime, read: true, write: false, raster, options: &[] }
|
||||
|
|
@ -423,8 +480,17 @@ 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),
|
||||
reader("bmp", "BMP", &["bmp"], "image/bmp", true),
|
||||
Format {
|
||||
id: "tiff",
|
||||
label: "TIFF",
|
||||
extensions: &["tif", "tiff"],
|
||||
mime: "image/tiff",
|
||||
read: true,
|
||||
write: true,
|
||||
raster: true,
|
||||
options: TIFF_OPTIONS,
|
||||
},
|
||||
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",
|
||||
|
|
@ -450,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).
|
||||
|
|
@ -781,3 +848,6 @@ mod tests_eps;
|
|||
mod tests_dxfimport;
|
||||
#[cfg(test)]
|
||||
mod tests_metafile;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests_tiffbmp;
|
||||
|
|
|
|||
|
|
@ -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::<Vec<_>>();
|
||||
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",
|
||||
"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();
|
||||
|
|
@ -212,7 +231,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();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,160 @@
|
|||
//! 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};
|
||||
|
||||
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<u8> {
|
||||
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<u16, (u16, u32, u32)> {
|
||||
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 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 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, 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(), 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"]);
|
||||
}
|
||||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -223,34 +223,8 @@ fn value85(digits: &[u8]) -> Option<u32> {
|
|||
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<u8>) {
|
||||
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<u8> {
|
||||
|
|
|
|||
|
|
@ -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<u8>,
|
||||
}
|
||||
|
||||
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<Vec<u8>, 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<u16> = 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.
|
||||
|
|
|
|||
|
|
@ -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"})),
|
||||
];
|
||||
|
|
|
|||
|
|
@ -57,7 +57,26 @@ 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",
|
||||
"bmp",
|
||||
"template",
|
||||
"png8",
|
||||
"txt",
|
||||
"dxf",
|
||||
"eps",
|
||||
"emf",
|
||||
"wmf",
|
||||
"tga"
|
||||
])
|
||||
);
|
||||
for k in ["artboard", "range", "options"] {
|
||||
assert!(props.get(k).is_some(), "export takes {k}");
|
||||
|
|
|
|||
|
|
@ -264,7 +264,7 @@ pub fn tool_definitions() -> Vec<Value> {
|
|||
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/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::<Vec<_>>(), "description": "Default: from the path's extension"},
|
||||
|
|
|
|||
|
|
@ -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,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 }
|
||||
|
|
|
|||
|
|
@ -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<Vec<u8>, 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<Vec<u8>, 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<u8>) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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<u8> {
|
|||
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).
|
||||
|
|
|
|||
|
|
@ -1,12 +1,16 @@
|
|||
//! 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`], [`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;
|
||||
pub mod quantize;
|
||||
pub mod tga;
|
||||
pub mod tiff;
|
||||
|
||||
use vectorcraft_doc::{Document, Node, NodeKind};
|
||||
use vectorcraft_geom::Rect;
|
||||
|
|
@ -22,6 +26,9 @@ pub enum RasterFormat {
|
|||
/// Indexed PNG: a palette of up to 256 colours.
|
||||
Png8,
|
||||
Gif,
|
||||
Tiff,
|
||||
Bmp,
|
||||
Tga,
|
||||
}
|
||||
|
||||
/// How a raster export renders and encodes.
|
||||
|
|
@ -38,8 +45,14 @@ 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,
|
||||
/// Targa depth.
|
||||
pub tga: tga::TgaOptions,
|
||||
}
|
||||
|
||||
impl Default for RasterExportOptions {
|
||||
|
|
@ -52,6 +65,9 @@ impl Default for RasterExportOptions {
|
|||
quality: 90,
|
||||
jpeg: jpeg::JpegOptions::default(),
|
||||
palette: quantize::PaletteOptions::default(),
|
||||
tiff: tiff::TiffOptions::default(),
|
||||
bmp: bmp::BmpOptions::default(),
|
||||
tga: tga::TgaOptions::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -96,19 +112,34 @@ impl RasterExportOptions {
|
|||
_ => png::encode_indexed(&ix, &png::PngOptions { ppi: Some(self.ppi), interlaced: self.interlaced }),
|
||||
}
|
||||
}
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<Vec<u8>, 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 +153,12 @@ pub(crate) fn jpeg(img: &Rendered, quality: u8, ppi: Option<f64>) -> Result<Vec<
|
|||
jpeg::encode(&jpeg::screen_pixels(img, false), img.width, img.height, quality, ppi, &o)
|
||||
}
|
||||
|
||||
/// A straight-alpha pixel composited over white.
|
||||
pub fn on_white(p: &[u8; 4]) -> [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<Vec<u8>, String> {
|
||||
let mut buf = Vec::new();
|
||||
|
|
@ -154,6 +191,12 @@ fn drawn_bounds(n: &Node) -> Option<Rect> {
|
|||
#[cfg(test)]
|
||||
mod tests;
|
||||
#[cfg(test)]
|
||||
mod tests_bmp;
|
||||
#[cfg(test)]
|
||||
mod tests_jpeg;
|
||||
#[cfg(test)]
|
||||
mod tests_palette;
|
||||
#[cfg(test)]
|
||||
mod tests_tga;
|
||||
#[cfg(test)]
|
||||
mod tests_tiff;
|
||||
|
|
|
|||
|
|
@ -175,7 +175,7 @@ pub fn with_text(png: Vec<u8>, entries: &[(&str, Cow<str>)]) -> Vec<u8> {
|
|||
|
||||
/// 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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<u8> {
|
||||
let colors = [[230, 40, 30, 255], [30, 90, 200, 255], [250, 250, 250, 255], [20, 160, 60, 255]];
|
||||
let mut px: Vec<u8> = (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<u8> {
|
||||
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<u8> = (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");
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
|
|
@ -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<Cursor<&[u8]>> {
|
||||
Decoder::new(Cursor::new(file)).expect("a TIFF")
|
||||
}
|
||||
|
||||
fn pixels(file: &[u8]) -> Vec<u8> {
|
||||
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<u8> {
|
||||
(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<u8> = 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::<u16>(Tag::Compression).unwrap(), 5);
|
||||
assert_eq!(decoder(&plain).get_tag_unsigned::<u16>(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::<u16>(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<u8> = (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::<u16>(Tag::SamplesPerPixel).unwrap(), 4);
|
||||
assert_eq!(d.get_tag_unsigned::<u16>(Tag::PhotometricInterpretation).unwrap(), 5, "separated");
|
||||
assert_eq!(d.get_tag_unsigned::<u16>(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::<u16>(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::<u16>(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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Vec<u8>, 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)
|
||||
}
|
||||
|
|
@ -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<Self> {
|
||||
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<u8>),
|
||||
Short(Vec<u16>),
|
||||
Long(Vec<u32>),
|
||||
Rational(u32, u32),
|
||||
Undefined(Vec<u8>),
|
||||
}
|
||||
|
||||
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<u8> {
|
||||
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<Vec<u8>, 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<u8> = 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<usize> = 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<u8>) {
|
||||
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<Vec<u8>, 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<Vec<u8>, 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() })
|
||||
}
|
||||
|
|
@ -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", "bmpOptions", "tgaOptions"] => 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;
|
||||
|
|
|
|||
|
|
@ -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, BMP and Targa 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<String, Value> {
|
|||
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<String, Value> {
|
|||
("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),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,81 @@
|
|||
//! TIFF, BMP and Targa 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");
|
||||
}
|
||||
|
||||
#[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");
|
||||
}
|
||||
|
||||
#[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));
|
||||
}
|
||||
|
|
@ -0,0 +1,163 @@
|
|||
//! The options of TIFF (colour model, byte order, LZW, profile), BMP (colour model, layout, depth,
|
||||
//! 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;
|
||||
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`] 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;
|
||||
|
||||
/// The options format `id` starts with (`cmyk`: a CMYK document exports CMYK TIFFs).
|
||||
pub(super) fn defaults(id: &str, cmyk: bool, o: &mut Map<String, Value>) {
|
||||
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())),
|
||||
]);
|
||||
}
|
||||
"tga" => {
|
||||
o.insert("depth".into(), json!(TgaOptions::default().depth));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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,
|
||||
"bmp" => d.f64("depth", 24.0) == 32.0,
|
||||
"tga" => d.f64("depth", 24.0) != 24.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) {
|
||||
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();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -365,3 +365,12 @@ 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, 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`; 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`.
|
||||
|
|
|
|||
21
docs/mcp.md
21
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`, `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. |
|
||||
|
|
@ -1363,3 +1363,22 @@ 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, 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
|
||||
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). 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. 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}}}
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
Loading…
Reference in New Issue