From d01b2fbd8ae831b4ecff40ee5e3b001d10456234 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 5 Oct 2026 14:52:25 +0700 Subject: [PATCH] M4.67: TIFF export in RGB, CMYK or grayscale, with LZW, either byte order and the profile document.export {format: "tiff"} writes baseline TIFF through the raster export path (resolution, background, anti-aliasing, artboard ranges): colorModel rgb (transparency as unassociated alpha), cmyk (ink amounts, CMYK colours keep their inks) or gray, lzw (weezl), byteOrder little (II) or big (MM), embedIcc, and the resolution tags. Export As offers it with the tiffOptions dialog; TIFF moves from read-only to read and write. The TIFF writer is shared: EPS previews now use it (and its PackBits) instead of their own copy, so eps depends on render (layers allow it). Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 3 + crates/engine/src/cmd/fileio/encode.rs | 18 +- crates/engine/src/cmd/fileio/mod.rs | 28 +- crates/engine/src/cmd/fileio/tests.rs | 2 +- crates/engine/src/cmd/fileio/tests_tiffbmp.rs | 106 +++++ crates/eps/Cargo.toml | 2 + crates/eps/src/ps.rs | 30 +- crates/eps/src/tiff.rs | 142 ++----- crates/mcp/src/tests_fileio.rs | 2 +- crates/mcp/src/tools.rs | 2 +- crates/render/Cargo.toml | 3 + crates/render/src/encode/jpeg.rs | 14 +- crates/render/src/encode/mod.rs | 37 +- crates/render/src/encode/tests_tiff.rs | 184 +++++++++ crates/render/src/encode/tiff.rs | 365 ++++++++++++++++++ crates/ui-egui/src/dialogs/mod.rs | 5 + crates/ui-egui/src/dialogs/png_options.rs | 22 +- .../ui-egui/src/dialogs/tests_tiff_bmp_tga.rs | 33 ++ crates/ui-egui/src/dialogs/tiff_bmp_tga.rs | 59 +++ docs/control-protocol.md | 5 + docs/mcp.md | 14 +- xtask/src/layers.rs | 6 +- 22 files changed, 904 insertions(+), 178 deletions(-) create mode 100644 crates/engine/src/cmd/fileio/tests_tiffbmp.rs create mode 100644 crates/render/src/encode/tests_tiff.rs create mode 100644 crates/render/src/encode/tiff.rs create mode 100644 crates/ui-egui/src/dialogs/tests_tiff_bmp_tga.rs create mode 100644 crates/ui-egui/src/dialogs/tiff_bmp_tga.rs diff --git a/Cargo.lock b/Cargo.lock index 42f7ec97..d220f9a2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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]] diff --git a/crates/engine/src/cmd/fileio/encode.rs b/crates/engine/src/cmd/fileio/encode.rs index 1b04af26..974e81f0 100644 --- a/crates/engine/src/cmd/fileio/encode.rs +++ b/crates/engine/src/cmd/fileio/encode.rs @@ -11,6 +11,7 @@ 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::tiff::{ByteOrder, TiffOptions}; use vectorcraft_render::encode::{RasterExportOptions, RasterFormat}; use super::super::*; @@ -162,6 +163,8 @@ struct RasterOptions { transparency: Option, matte: Option, lossless: Option, + lzw: Option, + byte_order: Option, } impl RasterOptions { @@ -172,6 +175,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 +187,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,6 +208,12 @@ 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, + }, }) } } @@ -331,7 +342,7 @@ pub fn encode_all(doc: &Document, format: &str, p: &Value) -> Result { let (bytes, warnings) = super::pdf::encode(C, doc, p)?; return Ok(Encoded { warnings, ..Encoded::one(bytes) }); } - "png" | "jpg" | "webp" | "gif" | "png8" => { + "png" | "jpg" | "webp" | "gif" | "png8" | "tiff" => { 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,6 +354,7 @@ pub fn encode_all(doc: &Document, format: &str, p: &Value) -> Result { "jpg" => RasterFormat::Jpeg, "gif" => RasterFormat::Gif, "png8" => RasterFormat::Png8, + "tiff" => RasterFormat::Tiff, _ => RasterFormat::WebP, }; // Use Artboards: one file per chosen artboard (default all); else the one chosen. diff --git a/crates/engine/src/cmd/fileio/mod.rs b/crates/engine/src/cmd/fileio/mod.rs index 816b2843..103de1c7 100644 --- a/crates/engine/src/cmd/fileio/mod.rs +++ b/crates/engine/src/cmd/fileio/mod.rs @@ -83,7 +83,7 @@ pub fn specs() -> Vec { "Serialize Document", [], None, - "{format?: vectorcraft (default)|template|svg|svgz|pdf|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf, …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, …the format's options (see document.formats; SVG ones also as svg: {…}), selectedOnly?: false (the selected objects alone, in their layers)} → {text, warnings} for svg (plus dataBase64, the file, when its encoding isn't UTF-8), else {dataBase64, warnings}; an SVG of several artboards also gives files: [{name, text}], linked images linked: [{name, dataBase64}]", has_doc, export::serialize ), @@ -92,7 +92,7 @@ pub fn specs() -> Vec { "Export Document", [], None, - "{path?, format?: svg|svgz|pdf|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png), selectedOnly?: false (the selected objects alone, in their layers), artboard?: 0, artboards?: [i…], range?: \"1-3, 5\" | \"all\" (1-based; PDF writes one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf: every page) | false (pdf/raster: the bounds of the visible art; SVG has it as an SVG option), raster: ppi?: 72 (pixels per inch, stored in the file; wins over scale), scale?: 1 (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), jpg: quality?: 90 (0–100), colorModel?: rgb|cmyk|gray, method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as .html / .map), gif/png8: colors?: 256 (2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?, webp: lossless?: true (lossy WebP isn't available yet: written lossless, with a warning), txt: the stories in stacking order (back to front; a thread once), encoding?: utf8|utf16 (with a byte order mark), lineEndings?: lf|crlf, selectionOnly?: false; SVG options flat or as svg: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers, encoding, profile, embedFonts} (see document.formats), …the PDF options of document.exportPdf, …the DXF options of document.exportDxf (useArtboards: one drawing per artboard), …the EPS options of document.exportEps (useArtboards: one file per artboard, {stem}_{artboard}.eps; else the visible art), emf/wmf: one picture of the artboard (useArtboards: true one file per chosen artboard, false the bounds of the visible art; EMF keeps curves, clipping, transparent images and gradients as images clipped to their shape; WMF flattens curves into polygons behind a placeable header; what a format leaves out comes back in warnings)} → {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|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png), selectedOnly?: false (the selected objects alone, in their layers), artboard?: 0, artboards?: [i…], range?: \"1-3, 5\" | \"all\" (1-based; PDF writes one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf: every page) | false (pdf/raster: the bounds of the visible art; SVG has it as an SVG option), raster: ppi?: 72 (pixels per inch, stored in the file; wins over scale), scale?: 1 (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), jpg: quality?: 90 (0–100), colorModel?: rgb|cmyk|gray, method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as .html / .map), gif/png8: colors?: 256 (2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?, webp: lossless?: true (lossy WebP isn't available yet: written lossless, with a warning), txt: the stories in stacking order (back to front; a thread once), encoding?: utf8|utf16 (with a byte order mark), lineEndings?: lf|crlf, selectionOnly?: false; SVG options flat or as svg: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers, encoding, profile, embedFonts} (see document.formats), …the PDF options of document.exportPdf, …the DXF options of document.exportDxf (useArtboards: one drawing per artboard), …the EPS options of document.exportEps (useArtboards: one file per artboard, {stem}_{artboard}.eps; else the visible art), emf/wmf: one picture of the artboard (useArtboards: true one file per chosen artboard, false the bounds of the visible art; EMF keeps curves, clipping, transparent images and gradients as images clipped to their shape; WMF flattens curves into polygons behind a placeable header; what a format leaves out comes back in warnings), tiff: colorModel?: rgb|cmyk|gray (rgb keeps transparency as an alpha channel; cmyk and gray are flattened on white), lzw?: true, byteOrder?: little|big, embedIcc?: true} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. Never changes the document's path", has_doc, export::export ), @@ -336,6 +336,16 @@ 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]; + /// 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,7 +433,16 @@ 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), + Format { + id: "tiff", + label: "TIFF", + extensions: &["tif", "tiff"], + mime: "image/tiff", + read: true, + write: true, + raster: true, + options: TIFF_OPTIONS, + }, reader("bmp", "BMP", &["bmp"], "image/bmp", true), Format { id: "template", @@ -781,3 +800,6 @@ mod tests_eps; mod tests_dxfimport; #[cfg(test)] mod tests_metafile; + +#[cfg(test)] +mod tests_tiffbmp; diff --git a/crates/engine/src/cmd/fileio/tests.rs b/crates/engine/src/cmd/fileio/tests.rs index 84448d15..af83b755 100644 --- a/crates/engine/src/cmd/fileio/tests.rs +++ b/crates/engine/src/cmd/fileio/tests.rs @@ -156,7 +156,7 @@ fn formats_query_lists_readers_writers_and_options() { let ids = |k: &str| r[k].as_array().unwrap().iter().map(|v| v.as_str().unwrap().to_string()).collect::>(); assert_eq!( ids("writable"), - ["vectorcraft", "svg", "svgz", "pdf", "png", "jpg", "gif", "webp", "template", "png8", "txt", "dxf", "eps", "emf", "wmf"] + ["vectorcraft", "svg", "svgz", "pdf", "png", "jpg", "gif", "webp", "tiff", "template", "png8", "txt", "dxf", "eps", "emf", "wmf"] ); assert!(ids("readable").contains(&"tiff".to_string()) && ids("readable").contains(&"ait".to_string())); let png = r["formats"].as_array().unwrap().iter().find(|f| f["id"] == "png").unwrap(); diff --git a/crates/engine/src/cmd/fileio/tests_tiffbmp.rs b/crates/engine/src/cmd/fileio/tests_tiffbmp.rs new file mode 100644 index 00000000..ec908215 --- /dev/null +++ b/crates/engine/src/cmd/fileio/tests_tiffbmp.rs @@ -0,0 +1,106 @@ +//! TIFF 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 { + 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 { + 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 the_formats_are_listed_and_write_one_file_per_artboard() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 30, "height": 20, "artboards": 2})).unwrap(); + let r = s.execute("document.formats", &json!({})).unwrap(); + let find = |id: &str| r["formats"].as_array().unwrap().iter().find(|f| f["id"] == id).unwrap().clone(); + let tiff = find("tiff"); + assert_eq!((tiff["write"].as_bool(), tiff["read"].as_bool()), (Some(true), Some(true))); + for (f, keys) in [(&tiff, &["colorModel", "lzw", "byteOrder", "embedIcc", "ppi", "antiAlias"][..])] { + for k in keys { + assert!(f["options"].get(*k).is_some(), "{} takes {k}", f["id"]); + } + } + 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"]); +} diff --git a/crates/eps/Cargo.toml b/crates/eps/Cargo.toml index 3f7ae721..e59704f7 100644 --- a/crates/eps/Cargo.toml +++ b/crates/eps/Cargo.toml @@ -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 } diff --git a/crates/eps/src/ps.rs b/crates/eps/src/ps.rs index f92727fe..e00d813a 100644 --- a/crates/eps/src/ps.rs +++ b/crates/eps/src/ps.rs @@ -223,34 +223,8 @@ fn value85(digits: &[u8]) -> Option { 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) { - 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 { diff --git a/crates/eps/src/tiff.rs b/crates/eps/src/tiff.rs index 2a618275..ed7c6d23 100644 --- a/crates/eps/src/tiff.rs +++ b/crates/eps/src/tiff.rs @@ -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, -} - -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, 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 = 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. diff --git a/crates/mcp/src/tests_fileio.rs b/crates/mcp/src/tests_fileio.rs index 681ca550..4985d4af 100644 --- a/crates/mcp/src/tests_fileio.rs +++ b/crates/mcp/src/tests_fileio.rs @@ -57,7 +57,7 @@ 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", "template", "png8", "txt", "dxf", "eps", "emf", "wmf"]) ); for k in ["artboard", "range", "options"] { assert!(props.get(k).is_some(), "export takes {k}"); diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index f954a2d4..0bae9a93 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -264,7 +264,7 @@ pub fn tool_definitions() -> Vec { tool( "export", "Export", - "Export the active document with the engine's document.export (the same bytes in the app and headless): svg (artboard viewBox), pdf (one page per artboard: all, or `artboard` / `range`), png/jpg/webp/gif/png8 (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 (one rendered artboard; png8 is an indexed .png), txt (the stories, back to front) or vectorcraft (a native copy; the document keeps its path). `selection: true` exports only the selected objects, cropped to their bounds. Template layers are left out; live effects are kept (geometry baked, SVG filters for shadows/glows/blur). Without `path` the bytes come back as dataBase64.", obj( json!({ "format": {"type": "string", "enum": FORMATS.iter().filter(|f| f.write).map(|f| f.id).collect::>(), "description": "Default: from the path's extension"}, diff --git a/crates/render/Cargo.toml b/crates/render/Cargo.toml index 0c06f79b..152076db 100644 --- a/crates/render/Cargo.toml +++ b/crates/render/Cargo.toml @@ -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,6 @@ vello_cpu = { workspace = true, features = ["multithreading"] } [dev-dependencies] vectorcraft-testkit = { workspace = true } +tiff = "0.11" proptest = { workspace = true } serde_json = { workspace = true } diff --git a/crates/render/src/encode/jpeg.rs b/crates/render/src/encode/jpeg.rs index 388a47eb..c6f74cb5 100644 --- a/crates/render/src/encode/jpeg.rs +++ b/crates/render/src/encode/jpeg.rs @@ -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 { 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). diff --git a/crates/render/src/encode/mod.rs b/crates/render/src/encode/mod.rs index d886e76e..afd44118 100644 --- a/crates/render/src/encode/mod.rs +++ b/crates/render/src/encode/mod.rs @@ -1,12 +1,14 @@ //! 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`], or [`tiff`] (RGB, CMYK or grey, LZW, either +//! byte order, the profile). pub mod gif; pub mod jpeg; pub mod png; pub mod quantize; +pub mod tiff; use vectorcraft_doc::{Document, Node, NodeKind}; use vectorcraft_geom::Rect; @@ -22,6 +24,7 @@ pub enum RasterFormat { /// Indexed PNG: a palette of up to 256 colours. Png8, Gif, + Tiff, } /// How a raster export renders and encodes. @@ -40,6 +43,8 @@ pub struct RasterExportOptions { pub jpeg: jpeg::JpegOptions, /// PNG-8 and GIF: the colour reduction. pub palette: quantize::PaletteOptions, + /// TIFF colour model, compression, byte order and profile. + pub tiff: tiff::TiffOptions, } impl Default for RasterExportOptions { @@ -52,6 +57,7 @@ impl Default for RasterExportOptions { quality: 90, jpeg: jpeg::JpegOptions::default(), palette: quantize::PaletteOptions::default(), + tiff: tiff::TiffOptions::default(), } } } @@ -96,19 +102,32 @@ impl RasterExportOptions { _ => png::encode_indexed(&ix, &png::PngOptions { ppi: Some(self.ppi), interlaced: self.interlaced }), } } + RasterFormat::Tiff => tiff::encode(img, self.ppi, &self.tiff), + } + } + + /// 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, 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 +141,12 @@ pub(crate) fn jpeg(img: &Rendered, quality: u8, ppi: Option) -> Result [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, String> { let mut buf = Vec::new(); @@ -157,3 +182,5 @@ mod tests; mod tests_jpeg; #[cfg(test)] mod tests_palette; +#[cfg(test)] +mod tests_tiff; diff --git a/crates/render/src/encode/tests_tiff.rs b/crates/render/src/encode/tests_tiff.rs new file mode 100644 index 00000000..c70eacd2 --- /dev/null +++ b/crates/render/src/encode/tests_tiff.rs @@ -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> { + Decoder::new(Cursor::new(file)).expect("a TIFF") +} + +fn pixels(file: &[u8]) -> Vec { + 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 { + (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 = 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::(Tag::Compression).unwrap(), 5); + assert_eq!(decoder(&plain).get_tag_unsigned::(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::(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 = (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::(Tag::SamplesPerPixel).unwrap(), 4); + assert_eq!(d.get_tag_unsigned::(Tag::PhotometricInterpretation).unwrap(), 5, "separated"); + assert_eq!(d.get_tag_unsigned::(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::(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::(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); + } +} diff --git a/crates/render/src/encode/tiff.rs b/crates/render/src/encode/tiff.rs new file mode 100644 index 00000000..da8577c9 --- /dev/null +++ b/crates/render/src/encode/tiff.rs @@ -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 { + 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), + Short(Vec), + Long(Vec), + Rational(u32, u32), + Undefined(Vec), +} + +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 { + 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, 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 = 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 = 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) { + 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, 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, 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() }) +} diff --git a/crates/ui-egui/src/dialogs/mod.rs b/crates/ui-egui/src/dialogs/mod.rs index 7048602b..bfff8bb8 100644 --- a/crates/ui-egui/src/dialogs/mod.rs +++ b/crates/ui-egui/src/dialogs/mod.rs @@ -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"] => 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; diff --git a/crates/ui-egui/src/dialogs/png_options.rs b/crates/ui-egui/src/dialogs/png_options.rs index edae47aa..6b3ec394 100644 --- a/crates/ui-egui/src/dialogs/png_options.rs +++ b/crates/ui-egui/src/dialogs/png_options.rs @@ -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 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 { 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 { ("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), } } diff --git a/crates/ui-egui/src/dialogs/tests_tiff_bmp_tga.rs b/crates/ui-egui/src/dialogs/tests_tiff_bmp_tga.rs new file mode 100644 index 00000000..b823b746 --- /dev/null +++ b/crates/ui-egui/src/dialogs/tests_tiff_bmp_tga.rs @@ -0,0 +1,33 @@ +//! TIFF 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"); +} diff --git a/crates/ui-egui/src/dialogs/tiff_bmp_tga.rs b/crates/ui-egui/src/dialogs/tiff_bmp_tga.rs new file mode 100644 index 00000000..711712ec --- /dev/null +++ b/crates/ui-egui/src/dialogs/tiff_bmp_tga.rs @@ -0,0 +1,59 @@ +//! The options of TIFF (colour model, byte order, LZW, profile), after the raster export rows it +//! shares with PNG Options ([`super::png_options`]): the `tiffOptions` dialog. + +use serde_json::{Map, Value, json}; +use vectorcraft_render::encode::jpeg::ColorModel; +use vectorcraft_render::encode::tiff::{ByteOrder, TiffOptions}; + +use super::form; +use super::png_options::choice; +use crate::state::Dialog; + +type Label<'a> = &'a dyn Fn(&mut egui::Ui, &str) -> egui::Response; + +/// The options format `id` starts with (`cmyk`: a CMYK document exports CMYK TIFFs). +pub(super) fn defaults(id: &str, cmyk: bool, o: &mut Map) { + if id == "tiff" { + let t = TiffOptions::default(); + let model = if cmyk { ColorModel::Cmyk } else { t.color_model }; + o.extend([ + ("colorModel".into(), json!(model.id())), + ("byteOrder".into(), json!(t.byte_order.id())), + ("lzw".into(), json!(t.lzw)), + ("embedIcc".into(), json!(t.embed_icc)), + ]); + } +} + +/// 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, + _ => true, + } +} + +/// The grid rows of format `id`'s own options. +pub(super) fn rows(ui: &mut egui::Ui, d: &mut Dialog, id: &str, label: Label) { + if id == "tiff" { + tiff_rows(ui, d, label); + } +} + +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(); +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index c0304f9d..d6d588d4 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -365,3 +365,8 @@ 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 Options: Export As… with the TIFF format (`file.exportAs {format: "tiff"}`, then the save dialog) opens +`tiffOptions`: the raster rows of PNG Options (`ppi`, `background`, `antiAlias`), then `colorModel` (`rgb`, `cmyk`, +`gray`; a CMYK document starts on `cmyk`), `byteOrder` (`little`, `big`), `lzw` and `embedIcc`. `ui.dialog.set` the +fields and `ui.dialog.confirm` writes the file(s) with `document.export`. diff --git a/docs/mcp.md b/docs/mcp.md index 111b4585..d4f00350 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -64,7 +64,7 @@ objects' fills or strokes differ (`fillMixed` / `strokeMixed`, drawn as a "?" pr | `screenshot` | `{path?, scale?, artboard?, window?}` | Returns MCP image content (`image/png`, base64) plus a text block. Renders the artboard; `window:true` captures the app window (remote only). | | `open_file` | `{path}` | Opens any readable file as a new active document: `.vectorcraft`/`.drawcraft`, `.svg`/`.svgz`, `.pdf`/`.ai`, `.ait`, PNG, JPEG, GIF, WebP, TIFF, BMP (an image opens as a document of its pixel size). Templates open as a new untitled document. PDF, `.ai` and SVG files saved with Preserve Editing reopen as the document they carry. `run_command document.formats` lists the formats. | | `save_file` | `{path?}` | Runs `document.save`: the document's own file in its own format (native `.vectorcraft` unless it was opened from or saved as SVG, PDF or a restorable `.ai`; then `warnings` say what that format loses). A path's extension picks the format (`.vectorcraft`, `.vctemplate`, `.pdf`, `.svg`, `.svgz`, `.ai`: a PDF carrying the native document, which reopens editable). | -| `export` | `{path?, format?, scale?, artboard?, range?, selection?, outlineText?, options?}` | `svg`, `pdf`, `png`, `jpg`, `webp`, `gif`, `png8` (an indexed `.png`), `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`, `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,15 @@ 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 export + +`document.export` (and `export`) writes TIFF like the other raster formats: one artboard (or `useArtboards`, one file +each), at `ppi`, over `background`, with `antiAlias`. It takes `colorModel` (`rgb` keeps transparency as an +unassociated alpha channel, `cmyk` writes ink amounts in the working CMYK space with CMYK colours keeping their inks, +`gray` is flattened on white), `lzw` (default true), `byteOrder` (`little`, the `II` files PCs write, or `big`, `MM`) +and `embedIcc` (sRGB, the working CMYK profile or grey). TIFF files open again; `document.formats` lists every option. + +```json +{"name":"export","arguments":{"path":"/tmp/print.tif","options":{"ppi":300,"colorModel":"cmyk","byteOrder":"big"}}} +``` diff --git a/xtask/src/layers.rs b/xtask/src/layers.rs index 66956324..7f935905 100644 --- a/xtask/src/layers.rs +++ b/xtask/src/layers.rs @@ -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));