M4.69: Targa export at 16, 24 or 32 bits per pixel
document.export {format: "tga"} writes uncompressed true-colour Targa files
through the raster export path (resolution, background, anti-aliasing,
artboard ranges): depth 16 (5-5-5 with a one-bit alpha), 24 (flattened on
white) or 32 (8-bit alpha), rows bottom-up, with the version 2 footer.
Export As offers it with the tgaOptions dialog (Depth).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
f2fc8782c7
commit
b578c32f4d
|
|
@ -12,7 +12,7 @@ use vectorcraft_render::AntiAlias;
|
|||
use vectorcraft_render::encode::jpeg::{self, JpegOptions};
|
||||
use vectorcraft_render::encode::quantize::{Dither, PaletteOptions, Reduction};
|
||||
use vectorcraft_render::encode::tiff::{ByteOrder, TiffOptions};
|
||||
use vectorcraft_render::encode::{RasterExportOptions, RasterFormat, bmp};
|
||||
use vectorcraft_render::encode::{RasterExportOptions, RasterFormat, bmp, tga};
|
||||
|
||||
use super::super::*;
|
||||
use super::Format;
|
||||
|
|
@ -165,7 +165,7 @@ struct RasterOptions {
|
|||
lossless: Option<bool>,
|
||||
lzw: Option<bool>,
|
||||
byte_order: Option<String>,
|
||||
/// BMP bits per pixel.
|
||||
/// BMP and Targa bits per pixel.
|
||||
depth: Option<u8>,
|
||||
/// BMP layout: `windows` or `os2`.
|
||||
file_format: Option<String>,
|
||||
|
|
@ -227,6 +227,7 @@ impl RasterOptions {
|
|||
top_down: self.flip_rows.unwrap_or(false),
|
||||
gray: color_model == jpeg::ColorModel::Gray,
|
||||
},
|
||||
tga: tga::TgaOptions { depth: self.depth.unwrap_or(24) },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -240,13 +241,15 @@ fn bmp_layout(s: &str) -> Option<bool> {
|
|||
}
|
||||
}
|
||||
|
||||
/// Refuse options `format` can't write together: BMP depths and layouts, and colour models (BMP
|
||||
/// is RGB or grey).
|
||||
/// Refuse options `format` can't write together: BMP and Targa depths and layouts, and colour
|
||||
/// models (BMP is RGB or grey, Targa RGB).
|
||||
fn check_options(format: RasterFormat, o: &RasterExportOptions) -> Result<()> {
|
||||
let model = o.jpeg.color_model;
|
||||
let r = match format {
|
||||
RasterFormat::Bmp if model == jpeg::ColorModel::Cmyk => Err("BMP files are RGB or grayscale: colorModel rgb or gray".to_string()),
|
||||
RasterFormat::Tga if model != jpeg::ColorModel::Rgb => Err("Targa files are RGB: colorModel rgb".to_string()),
|
||||
RasterFormat::Bmp => o.bmp.check(),
|
||||
RasterFormat::Tga => o.tga.check(),
|
||||
_ => Ok(()),
|
||||
};
|
||||
r.map_err(|e| bad(C, e))
|
||||
|
|
@ -376,7 +379,7 @@ pub fn encode_all(doc: &Document, format: &str, p: &Value) -> Result<Encoded> {
|
|||
let (bytes, warnings) = super::pdf::encode(C, doc, p)?;
|
||||
return Ok(Encoded { warnings, ..Encoded::one(bytes) });
|
||||
}
|
||||
"png" | "jpg" | "webp" | "gif" | "png8" | "tiff" | "bmp" => {
|
||||
"png" | "jpg" | "webp" | "gif" | "png8" | "tiff" | "bmp" | "tga" => {
|
||||
let o: RasterOptions = options(f, p)?;
|
||||
// New Document → Background Contents: White makes the export opaque, unless `background`
|
||||
// says otherwise (JPEG has no alpha: white either way).
|
||||
|
|
@ -390,6 +393,7 @@ pub fn encode_all(doc: &Document, format: &str, p: &Value) -> Result<Encoded> {
|
|||
"png8" => RasterFormat::Png8,
|
||||
"tiff" => RasterFormat::Tiff,
|
||||
"bmp" => RasterFormat::Bmp,
|
||||
"tga" => RasterFormat::Tga,
|
||||
_ => RasterFormat::WebP,
|
||||
};
|
||||
check_options(format, &settings)?;
|
||||
|
|
|
|||
|
|
@ -83,7 +83,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"Serialize Document",
|
||||
[],
|
||||
None,
|
||||
"{format?: vectorcraft (default)|template|svg|svgz|pdf|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|tiff|bmp, …the format's options (see document.formats; SVG ones also as svg: {…}), selectedOnly?: false (the selected objects alone, in their layers)} → {text, warnings} for svg (plus dataBase64, the file, when its encoding isn't UTF-8), else {dataBase64, warnings}; an SVG of several artboards also gives files: [{name, text}], linked images linked: [{name, dataBase64}]",
|
||||
"{format?: vectorcraft (default)|template|svg|svgz|pdf|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|tiff|bmp|tga, …the format's options (see document.formats; SVG ones also as svg: {…}), selectedOnly?: false (the selected objects alone, in their layers)} → {text, warnings} for svg (plus dataBase64, the file, when its encoding isn't UTF-8), else {dataBase64, warnings}; an SVG of several artboards also gives files: [{name, text}], linked images linked: [{name, dataBase64}]",
|
||||
has_doc,
|
||||
export::serialize
|
||||
),
|
||||
|
|
@ -92,7 +92,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"Export Document",
|
||||
[],
|
||||
None,
|
||||
"{path?, format?: svg|svgz|pdf|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|tiff|bmp|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png), selectedOnly?: false (the selected objects alone, in their layers), artboard?: 0, artboards?: [i…], range?: \"1-3, 5\" | \"all\" (1-based; PDF writes one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf: every page) | false (pdf/raster: the bounds of the visible art; SVG has it as an SVG option), raster: ppi?: 72 (pixels per inch, stored in the file; wins over scale), scale?: 1 (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), jpg: quality?: 90 (0–100), colorModel?: rgb|cmyk|gray, method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as <stem>.html / <stem>.map), gif/png8: colors?: 256 (2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?, webp: lossless?: true (lossy WebP isn't available yet: written lossless, with a warning), txt: the stories in stacking order (back to front; a thread once), encoding?: utf8|utf16 (with a byte order mark), lineEndings?: lf|crlf, selectionOnly?: false; SVG options flat or as svg: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers, encoding, profile, embedFonts} (see document.formats), …the PDF options of document.exportPdf, …the DXF options of document.exportDxf (useArtboards: one drawing per artboard), …the EPS options of document.exportEps (useArtboards: one file per artboard, {stem}_{artboard}.eps; else the visible art), emf/wmf: one picture of the artboard (useArtboards: true one file per chosen artboard, false the bounds of the visible art; EMF keeps curves, clipping, transparent images and gradients as images clipped to their shape; WMF flattens curves into polygons behind a placeable header; what a format leaves out comes back in warnings), tiff: colorModel?: rgb|cmyk|gray (rgb keeps transparency as an alpha channel), lzw?: true, byteOrder?: little|big, embedIcc?: true, bmp: colorModel?: rgb|gray, depth?: 24 (1 black and white, 4|8 a palette by reduction and dither, 16, 24, 32 with alpha), fileFormat?: windows|os2 (1, 4, 8 or 24 bits), rle?: false (4 and 8 bits, windows), flipRows?: false (top-down rows); formats without alpha are flattened on white} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. Never changes the document's path",
|
||||
"{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
|
||||
),
|
||||
|
|
@ -385,6 +385,13 @@ const BMP_OPTIONS: &[FormatOption] = &[
|
|||
DITHER,
|
||||
DITHER_AMOUNT,
|
||||
];
|
||||
const TGA_DEPTH: FormatOption = FormatOption {
|
||||
name: "depth",
|
||||
ty: "integer",
|
||||
default: "24",
|
||||
description: "bits per pixel: 16 (one-bit alpha), 24 (flattened on white) or 32 (keeps transparency)",
|
||||
};
|
||||
const TGA_OPTIONS: &[FormatOption] = &[ARTBOARD, ARTBOARDS, RANGE, USE_ARTBOARDS, PPI, SCALE, BACKGROUND, ANTI_ALIAS, TGA_DEPTH];
|
||||
|
||||
/// A format `document.open` reads but nothing writes yet.
|
||||
const fn reader(id: &'static str, label: &'static str, extensions: &'static [&'static str], mime: &'static str, raster: bool) -> Format {
|
||||
|
|
@ -509,6 +516,7 @@ pub const FORMATS: &[Format] = &[
|
|||
},
|
||||
Format { id: "emf", label: "EMF", extensions: &["emf"], mime: "image/emf", read: true, write: true, raster: false, options: metafile::OPTIONS },
|
||||
Format { id: "wmf", label: "WMF", extensions: &["wmf"], mime: "image/wmf", read: true, write: true, raster: false, options: metafile::OPTIONS },
|
||||
Format { id: "tga", label: "Targa", extensions: &["tga"], mime: "image/x-tga", read: false, write: true, raster: true, options: TGA_OPTIONS },
|
||||
];
|
||||
|
||||
/// Every extension `document.open` reads (the "All readable files" filter of open dialogs).
|
||||
|
|
|
|||
|
|
@ -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", "tiff", "bmp", "template", "png8", "txt", "dxf", "eps", "emf", "wmf"]
|
||||
[
|
||||
"vectorcraft",
|
||||
"svg",
|
||||
"svgz",
|
||||
"pdf",
|
||||
"png",
|
||||
"jpg",
|
||||
"gif",
|
||||
"webp",
|
||||
"tiff",
|
||||
"bmp",
|
||||
"template",
|
||||
"png8",
|
||||
"txt",
|
||||
"dxf",
|
||||
"eps",
|
||||
"emf",
|
||||
"wmf",
|
||||
"tga"
|
||||
]
|
||||
);
|
||||
assert!(ids("readable").contains(&"tiff".to_string()) && ids("readable").contains(&"ait".to_string()));
|
||||
let png = r["formats"].as_array().unwrap().iter().find(|f| f["id"] == "png").unwrap();
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
//! TIFF and BMP export through `document.export`: the options reach the files, impossible
|
||||
//! TIFF, BMP and Targa export through `document.export`: the options reach the files, impossible
|
||||
//! ones are refused, and the files open again.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
|
@ -120,23 +120,40 @@ fn bmp_options_reach_the_file() {
|
|||
assert_eq!(r["format"], "bmp");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn targa_depth_reaches_the_file() {
|
||||
let mut s = session();
|
||||
for (depth, alpha) in [(16, 1), (24, 0), (32, 8)] {
|
||||
let tga = export(&mut s, json!({"format": "tga", "depth": depth}));
|
||||
assert_eq!((tga[16], tga[17]), (depth, alpha));
|
||||
assert_eq!(u16::from_le_bytes([tga[12], tga[13]]), 60);
|
||||
}
|
||||
let tga = export(&mut s, json!({"format": "tga"}));
|
||||
assert_eq!(tga[16], 24, "24 bits by default");
|
||||
for bad in [json!({"depth": 8}), json!({"colorModel": "gray"})] {
|
||||
assert!(s.execute("document.export", &merge(json!({"format": "tga"}), bad.clone())).is_err(), "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_formats_are_listed_and_write_one_file_per_artboard() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 30, "height": 20, "artboards": 2})).unwrap();
|
||||
let r = s.execute("document.formats", &json!({})).unwrap();
|
||||
let find = |id: &str| r["formats"].as_array().unwrap().iter().find(|f| f["id"] == id).unwrap().clone();
|
||||
let (tiff, bmp) = (find("tiff"), find("bmp"));
|
||||
let (tiff, bmp, tga) = (find("tiff"), find("bmp"), find("tga"));
|
||||
assert_eq!((tiff["write"].as_bool(), tiff["read"].as_bool()), (Some(true), Some(true)));
|
||||
assert_eq!((bmp["write"].as_bool(), bmp["read"].as_bool()), (Some(true), Some(true)));
|
||||
assert_eq!((bmp["write"].as_bool(), tga["write"].as_bool(), tga["read"].as_bool()), (Some(true), Some(true), Some(false)));
|
||||
for (f, keys) in [
|
||||
(&tiff, &["colorModel", "lzw", "byteOrder", "embedIcc", "ppi", "antiAlias"][..]),
|
||||
(&bmp, &["colorModel", "depth", "fileFormat", "rle", "flipRows", "reduction", "dither"]),
|
||||
(&tga, &["depth", "ppi", "antiAlias", "background"]),
|
||||
] {
|
||||
for k in keys {
|
||||
assert!(f["options"].get(*k).is_some(), "{} takes {k}", f["id"]);
|
||||
}
|
||||
}
|
||||
assert_eq!(format_for_name("a.tga").map(|f| f.id), Some("tga"));
|
||||
let r = s.execute("document.export", &json!({"format": "tiff", "useArtboards": true})).unwrap();
|
||||
let files: Vec<&str> = r["files"].as_array().unwrap().iter().map(|f| f["name"].as_str().unwrap()).collect();
|
||||
assert_eq!(files, ["Untitled-1-Artboard-1.tif", "Untitled-1-Artboard-2.tif"]);
|
||||
|
|
|
|||
|
|
@ -74,7 +74,8 @@ fn export_tool_lists_engine_formats_and_options() {
|
|||
"dxf",
|
||||
"eps",
|
||||
"emf",
|
||||
"wmf"
|
||||
"wmf",
|
||||
"tga"
|
||||
])
|
||||
);
|
||||
for k in ["artboard", "range", "options"] {
|
||||
|
|
|
|||
|
|
@ -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/tiff/bmp (one rendered artboard; png8 is an indexed .png), txt (the stories, back to front) or vectorcraft (a native copy; the document keeps its path). `selection: true` exports only the selected objects, cropped to their bounds. Template layers are left out; live effects are kept (geometry baked, SVG filters for shadows/glows/blur). Without `path` the bytes come back as dataBase64.",
|
||||
"Export the active document with the engine's document.export (the same bytes in the app and headless): svg (artboard viewBox), pdf (one page per artboard: all, or `artboard` / `range`), png/jpg/webp/gif/png8/tiff/bmp/tga (one rendered artboard; png8 is an indexed .png), txt (the stories, back to front) or vectorcraft (a native copy; the document keeps its path). `selection: true` exports only the selected objects, cropped to their bounds. Template layers are left out; live effects are kept (geometry baked, SVG filters for shadows/glows/blur). Without `path` the bytes come back as dataBase64.",
|
||||
obj(
|
||||
json!({
|
||||
"format": {"type": "string", "enum": FORMATS.iter().filter(|f| f.write).map(|f| f.id).collect::<Vec<_>>(), "description": "Default: from the path's extension"},
|
||||
|
|
|
|||
|
|
@ -31,6 +31,8 @@ vello_cpu = { workspace = true, features = ["multithreading"] }
|
|||
|
||||
[dev-dependencies]
|
||||
vectorcraft-testkit = { workspace = true }
|
||||
# Decoders the export tests read their files back with.
|
||||
image = { workspace = true, features = ["tga"] }
|
||||
tiff = "0.11"
|
||||
proptest = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ pub mod gif;
|
|||
pub mod jpeg;
|
||||
pub mod png;
|
||||
pub mod quantize;
|
||||
pub mod tga;
|
||||
pub mod tiff;
|
||||
|
||||
use vectorcraft_doc::{Document, Node, NodeKind};
|
||||
|
|
@ -27,6 +28,7 @@ pub enum RasterFormat {
|
|||
Gif,
|
||||
Tiff,
|
||||
Bmp,
|
||||
Tga,
|
||||
}
|
||||
|
||||
/// How a raster export renders and encodes.
|
||||
|
|
@ -49,6 +51,8 @@ pub struct RasterExportOptions {
|
|||
pub tiff: tiff::TiffOptions,
|
||||
/// BMP layout, depth, compression and row order.
|
||||
pub bmp: bmp::BmpOptions,
|
||||
/// Targa depth.
|
||||
pub tga: tga::TgaOptions,
|
||||
}
|
||||
|
||||
impl Default for RasterExportOptions {
|
||||
|
|
@ -63,6 +67,7 @@ impl Default for RasterExportOptions {
|
|||
palette: quantize::PaletteOptions::default(),
|
||||
tiff: tiff::TiffOptions::default(),
|
||||
bmp: bmp::BmpOptions::default(),
|
||||
tga: tga::TgaOptions::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -109,6 +114,7 @@ impl RasterExportOptions {
|
|||
}
|
||||
RasterFormat::Tiff => tiff::encode(img, self.ppi, &self.tiff),
|
||||
RasterFormat::Bmp => bmp::encode(&img.to_straight(), img.width, img.height, self.ppi, &self.bmp, &self.palette),
|
||||
RasterFormat::Tga => tga::encode(&img.to_straight(), img.width, img.height, &self.tga),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -191,4 +197,6 @@ mod tests_jpeg;
|
|||
#[cfg(test)]
|
||||
mod tests_palette;
|
||||
#[cfg(test)]
|
||||
mod tests_tga;
|
||||
#[cfg(test)]
|
||||
mod tests_tiff;
|
||||
|
|
|
|||
|
|
@ -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,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)
|
||||
}
|
||||
|
|
@ -228,7 +228,7 @@ registry! {
|
|||
EpsOptions: [eps_options::KIND] => eps_options::SPEC,
|
||||
Recovery: [crate::recovery::KIND] => recovery::SPEC,
|
||||
DxfImport: [dxf_import::KIND] => dxf_import::SPEC,
|
||||
RasterFormatOptions: ["tiffOptions", "bmpOptions"] => png_options::SPEC,
|
||||
RasterFormatOptions: ["tiffOptions", "bmpOptions", "tgaOptions"] => png_options::SPEC,
|
||||
}
|
||||
|
||||
/// The spec for a `Dialog::kind` ([`DialogSpec::FORM`] when unregistered).
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
//! The raster export options: PNG Options (resolution, background, anti-aliasing, interlaced),
|
||||
//! JPEG Options (no transparency; colour model, quality 0–10, method and scans, profile, image
|
||||
//! map), WebP Options, and PNG-8 and GIF Options (the palette: colour reduction, colours, dither,
|
||||
//! transparency and matte), and TIFF and BMP Options ([`super::tiff_bmp_tga`]). Fields
|
||||
//! transparency and matte), and TIFF, BMP and Targa Options ([`super::tiff_bmp_tga`]). Fields
|
||||
//! are `document.export` params (format, path, artboard choice…); `__`-prefixed ones are the
|
||||
//! dialog's.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
//! TIFF and BMP Options through Export As, drawn headlessly.
|
||||
//! TIFF, BMP and Targa Options through Export As, drawn headlessly.
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
|
|
@ -61,3 +61,21 @@ fn bmp_options_keep_their_choices_writable() {
|
|||
assert_eq!(path, "/out/Untitled-1.bmp");
|
||||
assert_eq!((&bmp[..2], bmp[28], bmp[30]), (&b"BM"[..], 8, 1), "8-bit RLE8");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn targa_options_pick_the_depth() {
|
||||
let (mut app, written) = app(1);
|
||||
app.run("file.exportAs", json!({"format": "tga"})).unwrap();
|
||||
confirm(&mut app).unwrap();
|
||||
assert_eq!(kind(&app), Some("tgaOptions"));
|
||||
let d = app.ui.dialog.as_ref().unwrap();
|
||||
assert_eq!(((spec(&d.kind).heading)(d), d.f64("depth", 0.0)), ("Targa Options".into(), 24.0));
|
||||
assert!(!tiff_bmp_tga::keeps_alpha("tga", d), "24 bits are flattened on white");
|
||||
frame(&mut app);
|
||||
set(&mut app, "depth", json!(32));
|
||||
frame(&mut app);
|
||||
assert!(tiff_bmp_tga::keeps_alpha("tga", app.ui.dialog.as_ref().unwrap()));
|
||||
confirm(&mut app).unwrap();
|
||||
let (path, tga) = written.borrow()[0].clone();
|
||||
assert_eq!((path.as_str(), tga[16], tga[17]), ("/out/Untitled-1.tga", 32, 8));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
//! The options of TIFF (colour model, byte order, LZW, profile), BMP (colour model, layout, depth,
|
||||
//! palette, RLE, row order), after the raster export rows they share with PNG Options
|
||||
//! ([`super::png_options`]): the `tiffOptions` and `bmpOptions` dialogs.
|
||||
//! palette, RLE, row order) and Targa (depth), after the raster export rows they share with PNG
|
||||
//! Options ([`super::png_options`]): the `tiffOptions`, `bmpOptions` and `tgaOptions` dialogs.
|
||||
|
||||
use serde_json::{Map, Value, json};
|
||||
use vectorcraft_render::encode::bmp::{self, BmpOptions};
|
||||
use vectorcraft_render::encode::jpeg::ColorModel;
|
||||
use vectorcraft_render::encode::quantize::{Dither, PaletteOptions, Reduction};
|
||||
use vectorcraft_render::encode::tga::{self, TgaOptions};
|
||||
use vectorcraft_render::encode::tiff::{ByteOrder, TiffOptions};
|
||||
|
||||
use super::form;
|
||||
|
|
@ -18,8 +19,9 @@ const BMP_MODELS: [&str; 2] = ["rgb", "gray"];
|
|||
const BMP_MODEL_LABELS: [&str; 2] = ["RGB", "Grayscale"];
|
||||
const LAYOUTS: [&str; 2] = ["windows", "os2"];
|
||||
const LAYOUT_LABELS: [&str; 2] = ["Windows", "OS/2"];
|
||||
/// Labels of [`bmp::DEPTHS`].
|
||||
/// Labels of [`bmp::DEPTHS`] and [`tga::DEPTHS`].
|
||||
const BMP_DEPTH_LABELS: [&str; 6] = ["1 bit", "4 bit", "8 bit", "16 bit", "24 bit", "32 bit"];
|
||||
const TGA_DEPTH_LABELS: [&str; 3] = ["16 bits/pixel", "24 bits/pixel", "32 bits/pixel"];
|
||||
|
||||
type Label<'a> = &'a dyn Fn(&mut egui::Ui, &str) -> egui::Response;
|
||||
|
||||
|
|
@ -48,6 +50,9 @@ pub(super) fn defaults(id: &str, cmyk: bool, o: &mut Map<String, Value>) {
|
|||
("dither".into(), json!(p.dither.id())),
|
||||
]);
|
||||
}
|
||||
"tga" => {
|
||||
o.insert("depth".into(), json!(TgaOptions::default().depth));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
|
@ -58,6 +63,7 @@ pub(super) fn keeps_alpha(id: &str, d: &Dialog) -> bool {
|
|||
"jpg" => false,
|
||||
"tiff" => ColorModel::from_id(&d.str("colorModel")).unwrap_or_default() == ColorModel::Rgb,
|
||||
"bmp" => d.f64("depth", 24.0) == 32.0,
|
||||
"tga" => d.f64("depth", 24.0) != 24.0,
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
|
@ -67,6 +73,11 @@ pub(super) fn rows(ui: &mut egui::Ui, d: &mut Dialog, id: &str, label: Label) {
|
|||
match id {
|
||||
"tiff" => tiff_rows(ui, d, label),
|
||||
"bmp" => bmp_rows(ui, d, label),
|
||||
"tga" => {
|
||||
label(ui, "Depth:");
|
||||
depth_row(ui, d, "tga-depth", &tga::DEPTHS, &TGA_DEPTH_LABELS, |_| true);
|
||||
ui.end_row();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -366,10 +366,11 @@ EMF and WMF: Export As… lists both formats (`file.exportAs {format: "emf"}` or
|
|||
dialog, so `ui.dialog.confirm` asks for the path and writes the file (one per artboard with Use Artboards). Open and
|
||||
Place read `.emf` and `.wmf`; a paste of `image/emf` from a system clipboard service runs `clipboard.importEmf`.
|
||||
|
||||
TIFF and BMP Options: Export As… with the TIFF or BMP format (`file.exportAs {format: "tiff"}`, then
|
||||
the save dialog) opens `tiffOptions` or `bmpOptions`: the raster rows of PNG Options (`ppi`,
|
||||
TIFF, BMP and Targa Options: Export As… with the TIFF, BMP or Targa format (`file.exportAs {format: "tiff"}`, then
|
||||
the save dialog) opens `tiffOptions`, `bmpOptions` or `tgaOptions`: the raster rows of PNG Options (`ppi`,
|
||||
`background`, `antiAlias`), then TIFF's `colorModel` (`rgb`, `cmyk`, `gray`; a CMYK document starts on `cmyk`),
|
||||
`byteOrder` (`little`, `big`), `lzw` and `embedIcc`; BMP's `colorModel` (`rgb`, `gray`), `fileFormat` (`windows`,
|
||||
`os2`), `depth` (1, 4, 8, 16, 24, 32), `reduction` and `dither` (4 and 8 bits), `rle` and `flipRows`. The BMP dialog keeps its choices writable together (OS/2 falls back to 24 bits and turns RLE and flipped
|
||||
`os2`), `depth` (1, 4, 8, 16, 24, 32), `reduction` and `dither` (4 and 8 bits), `rle` and `flipRows`; Targa's `depth`
|
||||
(16, 24, 32). The BMP dialog keeps its choices writable together (OS/2 falls back to 24 bits and turns RLE and flipped
|
||||
rows off; RLE turns flipped rows off). `ui.dialog.set` the fields and `ui.dialog.confirm` writes the file(s) with
|
||||
`document.export`.
|
||||
|
|
|
|||
10
docs/mcp.md
10
docs/mcp.md
|
|
@ -64,7 +64,7 @@ objects' fills or strokes differ (`fillMixed` / `strokeMixed`, drawn as a "?" pr
|
|||
| `screenshot` | `{path?, scale?, artboard?, window?}` | Returns MCP image content (`image/png`, base64) plus a text block. Renders the artboard; `window:true` captures the app window (remote only). |
|
||||
| `open_file` | `{path}` | Opens any readable file as a new active document: `.vectorcraft`/`.drawcraft`, `.svg`/`.svgz`, `.pdf`/`.ai`, `.ait`, PNG, JPEG, GIF, WebP, TIFF, BMP (an image opens as a document of its pixel size). Templates open as a new untitled document. PDF, `.ai` and SVG files saved with Preserve Editing reopen as the document they carry. `run_command document.formats` lists the formats. |
|
||||
| `save_file` | `{path?}` | Runs `document.save`: the document's own file in its own format (native `.vectorcraft` unless it was opened from or saved as SVG, PDF or a restorable `.ai`; then `warnings` say what that format loses). A path's extension picks the format (`.vectorcraft`, `.vctemplate`, `.pdf`, `.svg`, `.svgz`, `.ai`: a PDF carrying the native document, which reopens editable). |
|
||||
| `export` | `{path?, format?, scale?, artboard?, range?, selection?, outlineText?, options?}` | `svg`, `pdf`, `png`, `jpg`, `webp`, `gif`, `png8` (an indexed `.png`), `tiff`, `bmp`, `txt` (the document's text) or `vectorcraft` (the list comes from `document.formats`). When `format` is omitted, it comes from the path's extension. PDF writes one page per artboard: all of them, or `artboard` (0-based) / `range` (`"1-3, 5"`, 1-based); the other formats write one artboard. `options` carries more format options (e.g. `{"quality": 80}` for JPEG). `selection: true` exports the selected objects cropped to their bounds; `outlineText: true` writes SVG text as paths. Template layers are left out, live effects are kept, and exporting `vectorcraft` never changes the document's path. Without `path` the bytes come back as `dataBase64`. Both backends run the same `document.export` call. |
|
||||
| `export` | `{path?, format?, scale?, artboard?, range?, selection?, outlineText?, options?}` | `svg`, `pdf`, `png`, `jpg`, `webp`, `gif`, `png8` (an indexed `.png`), `tiff`, `bmp`, `tga`, `txt` (the document's text) or `vectorcraft` (the list comes from `document.formats`). When `format` is omitted, it comes from the path's extension. PDF writes one page per artboard: all of them, or `artboard` (0-based) / `range` (`"1-3, 5"`, 1-based); the other formats write one artboard. `options` carries more format options (e.g. `{"quality": 80}` for JPEG). `selection: true` exports the selected objects cropped to their bounds; `outlineText: true` writes SVG text as paths. Template layers are left out, live effects are kept, and exporting `vectorcraft` never changes the document's path. Without `path` the bytes come back as `dataBase64`. Both backends run the same `document.export` call. |
|
||||
| `add_text` | `{text, x?, y?, width?, height?, path?, mode?, pathEffect?, size?, font?, color?}` | Point type at (x, y); area type with `width`/`height`; or `path` + `mode` (`area`/`onPath`) to flow text in or along a path, with `pathEffect` (`rainbow`, `skew`, `3dRibbon`, `stairStep`, `gravity`). |
|
||||
| `apply_effect` | `{effect?, params?, ids?}` | Appends a live effect. Without `effect`, returns the effect catalogue with parameters and defaults. |
|
||||
| `pathfinder` | `{operation, ids?}` | `unite`, `minusFront`, `intersect`, `exclude`, `divide`, `trim`, `merge`, `crop`, `outline`, `minusBack`. For a live version, apply the `pathfinder.*` effect to a group. |
|
||||
|
|
@ -1364,7 +1364,7 @@ clipboard service offers it (the Windows desktop service doesn't read metafiles
|
|||
{"name":"run_command","arguments":{"command":"file.place","params":{"path":"/tmp/chart.emf","at":[300,200]}}}
|
||||
```
|
||||
|
||||
## TIFF and BMP export
|
||||
## TIFF, BMP and Targa export
|
||||
|
||||
`document.export` (and `export`) writes them like the other raster formats: one artboard (or `useArtboards`, one
|
||||
file each), at `ppi`, over `background`, with `antiAlias`. TIFF takes `colorModel` (`rgb` keeps transparency as an
|
||||
|
|
@ -1373,10 +1373,12 @@ unassociated alpha channel, `cmyk` writes ink amounts in the working CMYK space
|
|||
and `embedIcc` (sRGB, the working CMYK profile or grey). BMP takes `depth` (1 is black and white; 4 and 8 use a
|
||||
palette chosen by `reduction` and `dither`; 16, 24, or 32, which keeps transparency), `colorModel` (`rgb` or `gray`),
|
||||
`fileFormat` (`windows` or `os2`: 1, 4, 8 or 24 bits only), `rle` (RLE4/RLE8 for 4- and 8-bit Windows bitmaps) and
|
||||
`flipRows` (rows top-down, a negative height; not with `rle` or `os2`); impossible combinations are refused. Formats and depths
|
||||
without alpha are flattened on white. TIFF and BMP files open again; `document.formats` lists every option.
|
||||
`flipRows` (rows top-down, a negative height; not with `rle` or `os2`); impossible combinations are refused. Targa
|
||||
takes `depth`: 16 (a one-bit alpha), 24 (default) or 32 (with alpha). Formats and depths without alpha are flattened
|
||||
on white. TIFF and BMP files open again; `document.formats` lists every option.
|
||||
|
||||
```json
|
||||
{"name":"export","arguments":{"path":"/tmp/print.tif","options":{"ppi":300,"colorModel":"cmyk","byteOrder":"big"}}}
|
||||
{"name":"run_command","arguments":{"command":"document.export","params":{"format":"bmp","depth":8,"rle":true}}}
|
||||
{"name":"export","arguments":{"path":"/tmp/sprite.tga","options":{"depth":32}}}
|
||||
```
|
||||
|
|
|
|||
Loading…
Reference in New Issue