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));