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:
bflatastic
2026-10-05 15:24:51 +07:00
co-authored by Claude Opus 5.5
parent f2fc8782c7
commit b578c32f4d
16 changed files with 224 additions and 26 deletions
+9 -5
View File
@@ -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)?;
+10 -2
View File
@@ -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).
+20 -1
View File
@@ -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();
+20 -3
View File
@@ -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"]);