diff --git a/Cargo.lock b/Cargo.lock index f87398f6..42f7ec97 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4284,6 +4284,7 @@ dependencies = [ "vectorcraft-eps", "vectorcraft-format", "vectorcraft-geom", + "vectorcraft-metafile", "vectorcraft-pathops", "vectorcraft-pdf", "vectorcraft-render", @@ -4355,6 +4356,20 @@ dependencies = [ "vectorcraft-tools", ] +[[package]] +name = "vectorcraft-metafile" +version = "0.2.0" +dependencies = [ + "image", + "kurbo 0.13.1", + "vectorcraft-brush", + "vectorcraft-color", + "vectorcraft-doc", + "vectorcraft-effects", + "vectorcraft-geom", + "vectorcraft-text", +] + [[package]] name = "vectorcraft-pathops" version = "0.2.0" diff --git a/Cargo.toml b/Cargo.toml index f4252d96..6e1e6c2e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,6 +31,7 @@ vectorcraft-mcp = { path = "crates/mcp" } vectorcraft-testkit = { path = "crates/testkit" } vectorcraft-cad = { path = "crates/cad" } vectorcraft-eps = { path = "crates/eps" } +vectorcraft-metafile = { path = "crates/metafile" } # foundation kurbo = { version = "0.13", features = ["serde"] } diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 0222560f..a8ae1adf 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -23,6 +23,7 @@ vectorcraft-format = { workspace = true } vectorcraft-pdf = { workspace = true } vectorcraft-cad = { workspace = true } vectorcraft-eps = { workspace = true } +vectorcraft-metafile = { workspace = true } image = { workspace = true } flate2 = { workspace = true } kurbo = { workspace = true } diff --git a/crates/engine/src/cmd/fileio/encode.rs b/crates/engine/src/cmd/fileio/encode.rs index fa0af535..1b04af26 100644 --- a/crates/engine/src/cmd/fileio/encode.rs +++ b/crates/engine/src/cmd/fileio/encode.rs @@ -303,7 +303,7 @@ pub fn encode_all(doc: &Document, format: &str, p: &Value) -> Result { let doc = &*doc.without_edit_modes(); let n = doc.artboards.len(); // SVG reads its own `useArtboards` (an SVG option). - let use_artboards = if f.raster || matches!(f.id, "pdf" | "dxf") { use_artboards(p)? } else { None }; + let use_artboards = if f.raster || matches!(f.id, "pdf" | "dxf" | "emf" | "wmf") { use_artboards(p)? } else { None }; if use_artboards == Some(false) { let bounds = vectorcraft_render::encode::art_bounds(doc).ok_or_else(|| bad(C, "nothing to export: the document has no visible art"))?; let mut q = super::export::without_artboards(p); @@ -325,6 +325,8 @@ pub fn encode_all(doc: &Document, format: &str, p: &Value) -> Result { "dxf" => return super::dxf::encode(doc, p, use_artboards), // EPS reads its own `useArtboards` (the art's bounds unless asked). "eps" => return super::eps::encode(doc, p), + "emf" => return super::metafile::encode(doc, p, use_artboards, vectorcraft_metafile::Kind::Emf), + "wmf" => return super::metafile::encode(doc, p, use_artboards, vectorcraft_metafile::Kind::Wmf), "pdf" => { let (bytes, warnings) = super::pdf::encode(C, doc, p)?; return Ok(Encoded { warnings, ..Encoded::one(bytes) }); diff --git a/crates/engine/src/cmd/fileio/metafile.rs b/crates/engine/src/cmd/fileio/metafile.rs new file mode 100644 index 00000000..1cc20999 --- /dev/null +++ b/crates/engine/src/cmd/fileio/metafile.rs @@ -0,0 +1,45 @@ +//! EMF and WMF export (`vectorcraft_metafile`): `document.export {format: emf|wmf}` and Export As +//! (one picture of an artboard, or one per artboard with Use Artboards; raster effects as images, +//! rendered at the document's raster effects resolution). + +use serde_json::Value; +use vectorcraft_doc::Document; +use vectorcraft_metafile::Kind; + +use super::super::*; +use super::{ArtboardPick, Encoded, FormatOption}; + +const C: &str = "document.export"; + +/// The options `document.formats` lists for EMF and WMF. +pub(super) const OPTIONS: &[FormatOption] = &[super::ARTBOARD, super::ARTBOARDS, super::RANGE, super::USE_ARTBOARDS]; + +/// Encode `doc` as `kind`: the chosen artboard (default the first), or with `use_artboards` one +/// picture per chosen artboard (default all). +pub(super) fn encode(doc: &Document, p: &Value, use_artboards: Option, kind: Kind) -> Result { + let f = super::format(kind.id()).ok_or_else(|| bad(C, "no metafile format"))?; + let pick: ArtboardPick = super::encode::options(f, p)?; + let n = doc.artboards.len(); + let boards = match use_artboards { + Some(true) => pick.resolve(n).map_err(|e| bad(C, e))?.unwrap_or_else(|| (0..n).collect()), + _ => vec![pick.one(n).map_err(|e| bad(C, e))?], + }; + // Raster effects (shadows, glows, blurs) as images, as PDF export does. + let flat = crate::flatten_raster_effects(doc); + let src = flat.as_ref().unwrap_or(doc); + let mut enc = Encoded::default(); + for b in boards { + let region = doc.artboards.get(b).ok_or_else(|| bad(C, format!("no artboard {}", b + 1)))?.rect; + let out = vectorcraft_metafile::export(src, region, kind).map_err(|e| bad(C, e))?; + enc.files.push((Some(b), out.bytes)); + for w in out.warnings { + if !enc.warnings.contains(&w) { + enc.warnings.push(w); + } + } + } + if enc.files.is_empty() { + return Err(bad(C, "the document has no artboard")); + } + Ok(enc) +} diff --git a/crates/engine/src/cmd/fileio/mod.rs b/crates/engine/src/cmd/fileio/mod.rs index 35fe3e5d..fea44395 100644 --- a/crates/engine/src/cmd/fileio/mod.rs +++ b/crates/engine/src/cmd/fileio/mod.rs @@ -14,6 +14,7 @@ //! - `dxf`: DXF export options, `document.exportDxf`, and the formats that can't be written (DWG, PICT). //! - `eps`: EPS export options (flattened transparency, previews, the embedded document), `document.exportEps`. //! - `dxfimport`: the DXF options `document.open` and Place read, `document.dxfInfo`. +//! - `metafile`: EMF and WMF export. //! //! [`FORMATS`] is the single list of formats (append-only); open dialogs use [`open_filters`], //! agents query `document.formats`. @@ -26,6 +27,7 @@ pub mod eps; mod export; mod imagemap; mod load; +mod metafile; mod native; pub mod pdf; mod pdfimport; @@ -81,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, …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, …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 ), @@ -90,7 +92,7 @@ pub fn specs() -> Vec { "Export Document", [], None, - "{path?, format?: svg|svgz|pdf|png|jpg|webp|gif|png8|txt|dxf|eps|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)} → {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|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", has_doc, export::export ), @@ -446,6 +448,8 @@ pub const FORMATS: &[Format] = &[ raster: false, options: eps::OPTIONS, }, + Format { id: "emf", label: "EMF", extensions: &["emf"], mime: "image/emf", read: false, write: true, raster: false, options: metafile::OPTIONS }, + Format { id: "wmf", label: "WMF", extensions: &["wmf"], mime: "image/wmf", read: false, write: true, raster: false, options: metafile::OPTIONS }, ]; /// Every extension `document.open` reads (the "All readable files" filter of open dialogs). @@ -754,3 +758,5 @@ mod tests_eps; #[cfg(test)] mod tests_dxfimport; +#[cfg(test)] +mod tests_metafile; diff --git a/crates/engine/src/cmd/fileio/screens.rs b/crates/engine/src/cmd/fileio/screens.rs index 11597a8d..dedd5f9b 100644 --- a/crates/engine/src/cmd/fileio/screens.rs +++ b/crates/engine/src/cmd/fileio/screens.rs @@ -160,7 +160,7 @@ impl Row { return Err(bad(C, "each format is an object {format, scale?, suffix?}")); } let format = writable(C, Some(str_param(row, "format").unwrap_or("png")), None)?; - if matches!(format.id, "vectorcraft" | "template" | "txt" | "dxf" | "eps") { + if matches!(format.id, "vectorcraft" | "template" | "txt" | "dxf" | "eps" | "emf" | "wmf") { return Err(bad(C, "Export for Screens writes png, png8, jpg, webp, gif, svg, svgz or pdf")); } // Vector formats have no pixel size: it doesn't apply and adds no @2x suffix (not even one diff --git a/crates/engine/src/cmd/fileio/tests.rs b/crates/engine/src/cmd/fileio/tests.rs index fefb621c..84448d15 100644 --- a/crates/engine/src/cmd/fileio/tests.rs +++ b/crates/engine/src/cmd/fileio/tests.rs @@ -154,7 +154,10 @@ fn formats_query_lists_readers_writers_and_options() { let mut s = Session::new(); let r = s.execute("document.formats", &json!({})).unwrap(); 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"]); + assert_eq!( + ids("writable"), + ["vectorcraft", "svg", "svgz", "pdf", "png", "jpg", "gif", "webp", "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(); assert_eq!(png["options"]["scale"]["default"], 1); diff --git a/crates/engine/src/cmd/fileio/tests_metafile.rs b/crates/engine/src/cmd/fileio/tests_metafile.rs new file mode 100644 index 00000000..bdfab03b --- /dev/null +++ b/crates/engine/src/cmd/fileio/tests_metafile.rs @@ -0,0 +1,109 @@ +//! EMF and WMF export through the commands: one artboard or each, raster effects as images, and +//! the format lists. + +use serde_json::{Value, json}; + +use super::*; + +fn session(width: f64, height: f64, artboards: usize) -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": width, "height": height, "artboards": artboards})).unwrap(); + s +} + +fn b64(v: &Value) -> Vec { + vectorcraft_format::base64_decode(v["dataBase64"].as_str().expect("dataBase64")).unwrap() +} + +fn u32_at(b: &[u8], at: usize) -> u32 { + u32::from_le_bytes(b[at..at + 4].try_into().unwrap()) +} + +fn i32_at(b: &[u8], at: usize) -> i32 { + i32::from_le_bytes(b[at..at + 4].try_into().unwrap()) +} + +/// The EMF's record types. +fn emf_kinds(b: &[u8]) -> Vec { + let mut out = vec![]; + let mut at = 0; + while at + 8 <= b.len() { + out.push(u32_at(b, at)); + at += u32_at(b, at + 4) as usize; + } + out +} + +/// Two 100 pt artboards side by side, a red rectangle on each. +fn two_boards() -> Session { + let mut s = session(100.0, 100.0, 2); + let second = s.doc().unwrap().doc.artboards[1].rect.x0; + s.execute("paint.setStroke", &json!({"none": true})).unwrap(); + s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 30, "height": 30})).unwrap(); + s.execute("shape.rectangle", &json!({"x": second + 10.0, "y": 10, "width": 30, "height": 30})).unwrap(); + s +} + +#[test] +fn export_writes_an_emf_or_wmf_of_one_artboard() { + let mut s = two_boards(); + let r = s.execute("document.export", &json!({"format": "emf"})).unwrap(); + assert_eq!(r["format"], "emf"); + let emf = b64(&r); + assert_eq!(vectorcraft_metafile::sniff(&emf), Some(vectorcraft_metafile::Kind::Emf)); + // The first artboard's frame in 0.01 mm. + let side = (100.0 * 2540.0 / 72.0f64).round() as i32; + assert_eq!([i32_at(&emf, 24), i32_at(&emf, 28), i32_at(&emf, 32), i32_at(&emf, 36)], [0, 0, side, side]); + assert_eq!(emf_kinds(&emf).iter().filter(|k| **k == 62).count(), 1, "one rectangle on it"); + // The second artboard alone; serialize gives the same bytes. + let second = b64(&s.execute("document.export", &json!({"format": "emf", "artboard": 1})).unwrap()); + assert_eq!(emf_kinds(&second).iter().filter(|k| **k == 62).count(), 1); + assert_eq!(b64(&s.execute("document.serialize", &json!({"format": "emf"})).unwrap()), emf); + let wmf = b64(&s.execute("document.export", &json!({"format": "wmf"})).unwrap()); + assert_eq!(vectorcraft_metafile::sniff(&wmf), Some(vectorcraft_metafile::Kind::Wmf)); + assert_eq!(writable_format(None, Some("x/pic.wmf")).unwrap().id, "wmf"); + assert_eq!(writable_format(Some("EMF"), None).unwrap().id, "emf"); + // One file holds one artboard. + assert!(s.execute("document.export", &json!({"format": "emf", "artboards": [0, 1]})).is_err()); +} + +#[test] +fn use_artboards_writes_one_picture_each_or_the_art() { + let mut s = two_boards(); + let r = s.execute("document.export", &json!({"format": "wmf", "useArtboards": true})).unwrap(); + let files = r["files"].as_array().unwrap(); + assert_eq!(files.len(), 2); + assert!(files[0]["name"].as_str().unwrap().ends_with("-Artboard-1.wmf"), "{files:?}"); + // Without artboards: the bounds of the art (both rectangles). + let r = s.execute("document.export", &json!({"format": "emf", "useArtboards": false})).unwrap(); + let emf = b64(&r); + let width_pt = f64::from(i32_at(&emf, 32)) / 2540.0 * 72.0; + let second = s.doc().unwrap().doc.artboards[1].rect.x0; + assert!((width_pt - (second + 30.0)).abs() < 0.05, "{width_pt}"); +} + +#[test] +fn raster_effects_are_written_as_images() { + let mut s = session(200.0, 200.0, 1); + let id = s.execute("shape.rectangle", &json!({"x": 50, "y": 50, "width": 60, "height": 60})).unwrap()["id"].clone(); + s.execute("effect.apply", &json!({"effect": "stylize.dropShadow", "ids": [id]})).unwrap(); + let r = s.execute("document.export", &json!({"format": "emf"})).unwrap(); + assert!(r["warnings"].as_array().unwrap().iter().all(|w| !w.as_str().unwrap().contains("raster effects")), "{r}"); + let kinds = emf_kinds(&b64(&r)); + assert!(kinds.contains(&114), "the shadow is an image with transparency"); +} + +#[test] +fn formats_list_emf_and_wmf() { + let mut s = Session::new(); + let r = s.execute("document.formats", &json!({})).unwrap(); + let ids: Vec<&str> = r["writable"].as_array().unwrap().iter().map(|v| v.as_str().unwrap()).collect(); + assert!(ids.contains(&"emf") && ids.contains(&"wmf"), "{ids:?}"); + let emf = r["formats"].as_array().unwrap().iter().find(|f| f["id"] == "emf").unwrap(); + assert_eq!(emf["mime"], "image/emf"); + assert!(emf["options"]["useArtboards"].is_object()); + // Export for Screens writes the screen formats only. + let mut s = two_boards(); + assert!(s.execute("document.exportForScreens", &json!({"formats": [{"format": "emf"}]})).is_err()); +} diff --git a/crates/mcp/src/tests_fileio.rs b/crates/mcp/src/tests_fileio.rs index adebc83c..681ca550 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"]) + json!(["vectorcraft", "svg", "svgz", "pdf", "png", "jpg", "gif", "webp", "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/metafile/Cargo.toml b/crates/metafile/Cargo.toml new file mode 100644 index 00000000..86ade96d --- /dev/null +++ b/crates/metafile/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "vectorcraft-metafile" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "VectorCraft Windows metafiles: hand-written EMF and WMF writers and readers" + +[lints] +workspace = true + +[dependencies] +vectorcraft-geom = { workspace = true } +vectorcraft-color = { workspace = true } +vectorcraft-doc = { workspace = true } +vectorcraft-effects = { workspace = true } +vectorcraft-brush = { workspace = true } +vectorcraft-text = { workspace = true } +kurbo = { workspace = true } +image = { workspace = true } diff --git a/crates/metafile/src/bytes.rs b/crates/metafile/src/bytes.rs new file mode 100644 index 00000000..133a4768 --- /dev/null +++ b/crates/metafile/src/bytes.rs @@ -0,0 +1,46 @@ +//! Little-endian values: the buffer both writers build records in. + +/// Bytes being written. +#[derive(Default)] +pub(crate) struct Out(pub Vec); + +impl Out { + pub fn len(&self) -> usize { + self.0.len() + } + pub fn u16(&mut self, v: u16) { + self.0.extend_from_slice(&v.to_le_bytes()); + } + pub fn i16(&mut self, v: i16) { + self.0.extend_from_slice(&v.to_le_bytes()); + } + pub fn u32(&mut self, v: u32) { + self.0.extend_from_slice(&v.to_le_bytes()); + } + pub fn i32(&mut self, v: i32) { + self.0.extend_from_slice(&v.to_le_bytes()); + } + pub fn f32(&mut self, v: f32) { + self.0.extend_from_slice(&v.to_le_bytes()); + } + pub fn bytes(&mut self, b: &[u8]) { + self.0.extend_from_slice(b); + } + /// Zeros up to the next multiple of `n` bytes. + pub fn pad(&mut self, n: usize) { + while !self.0.len().is_multiple_of(n) { + self.0.push(0); + } + } + /// Overwrite the `u32` at `at` (a size or count known only later). + pub fn set_u32(&mut self, at: usize, v: u32) { + if let Some(b) = self.0.get_mut(at..at + 4) { + b.copy_from_slice(&v.to_le_bytes()); + } + } + pub fn set_i32(&mut self, at: usize, v: i32) { + if let Some(b) = self.0.get_mut(at..at + 4) { + b.copy_from_slice(&v.to_le_bytes()); + } + } +} diff --git a/crates/metafile/src/dib.rs b/crates/metafile/src/dib.rs new file mode 100644 index 00000000..49be6c04 --- /dev/null +++ b/crates/metafile/src/dib.rs @@ -0,0 +1,137 @@ +//! Device-independent bitmaps (DIBs), the pixels inside image records: written bottom-up as 24-bit +//! (opaque, composited over white) or 32-bit premultiplied BGRA (AlphaBlend's source). + +/// Most pixels along a side of an image the writers store (larger ones are scaled down). +const MAX_SIDE: u32 = 8192; +/// Most pixels an image the writers store holds. +const MAX_PIXELS: u64 = 1 << 24; + +const BI_RGB: u32 = 0; + +/// Pixels with straight (not premultiplied) alpha, row by row from the top. +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct Rgba { + pub width: u32, + pub height: u32, + pub pixels: Vec, +} + +impl Rgba { + /// `img`, scaled down to what a metafile stores when it is larger. + pub fn fitted(img: image::RgbaImage) -> Self { + let (w, h) = img.dimensions(); + let side = f64::from(w.max(h)) / f64::from(MAX_SIDE); + let area = (u64::from(w) * u64::from(h)) as f64 / MAX_PIXELS as f64; + let k = side.max(area.sqrt()); + let img = if k > 1.0 { + let nw = ((f64::from(w) / k).floor() as u32).max(1); + let nh = ((f64::from(h) / k).floor() as u32).max(1); + image::imageops::resize(&img, nw, nh, image::imageops::FilterType::Triangle) + } else { + img + }; + let (width, height) = img.dimensions(); + Self { width, height, pixels: img.into_raw() } + } + + /// Every pixel fully opaque? + pub fn opaque(&self) -> bool { + self.pixels.as_chunks::<4>().0.iter().all(|p| p[3] == 255) + } +} + +/// A BITMAPINFOHEADER of a bottom-up, uncompressed `width` × `height` bitmap of `bits` per pixel. +fn header(width: u32, height: u32, bits: u16, size: usize) -> Vec { + let mut h = crate::bytes::Out::default(); + h.u32(40); + h.i32(i32::try_from(width).unwrap_or(i32::MAX)); + h.i32(i32::try_from(height).unwrap_or(i32::MAX)); + h.u16(1); + h.u16(bits); + h.u32(BI_RGB); + h.u32(u32::try_from(size).unwrap_or(0)); + // 2835 pixels a metre: 72 ppi (the size on the page comes from the record). + h.i32(2835); + h.i32(2835); + h.u32(0); + h.u32(0); + h.0 +} + +/// The bytes of a row of `width` pixels of `bits` each, padded to 4 bytes. +fn stride(width: u32, bits: u32) -> Option { + let row_bits = u64::from(width).checked_mul(u64::from(bits))?; + usize::try_from(row_bits.div_ceil(32) * 4).ok() +} + +/// `img` at `opacity`, composited over white, as a 24-bit DIB → (header, bits). +pub(crate) fn rgb24(img: &Rgba, opacity: f32) -> (Vec, Vec) { + let row = stride(img.width, 24).unwrap_or(0); + let mut bits = vec![0u8; row * img.height as usize]; + let over = |c: u8, a: u32| ((u32::from(c) * a + 255 * (255 - a) + 127) / 255) as u8; + for (y, src) in img.pixels.chunks_exact((img.width as usize * 4).max(1)).enumerate() { + // Bottom-up: the first stored row is the image's last. + let Some(at) = (img.height as usize).checked_sub(y + 1).map(|r| r * row) else { continue }; + let Some(dst) = bits.get_mut(at..at + img.width as usize * 3) else { continue }; + for (d, p) in dst.chunks_exact_mut(3).zip(src.as_chunks::<4>().0) { + let a = (f32::from(p[3]) * opacity.clamp(0.0, 1.0)).round() as u32; + d.copy_from_slice(&[over(p[2], a), over(p[1], a), over(p[0], a)]); + } + } + (header(img.width, img.height, 24, bits.len()), bits) +} + +/// `img` at `opacity` as a 32-bit DIB of premultiplied BGRA → (header, bits). +pub(crate) fn bgra32(img: &Rgba, opacity: f32) -> (Vec, Vec) { + let row = img.width as usize * 4; + let mut bits = vec![0u8; row * img.height as usize]; + for (y, src) in img.pixels.chunks_exact(row.max(1)).enumerate() { + let Some(at) = (img.height as usize).checked_sub(y + 1).map(|r| r * row) else { continue }; + let Some(dst) = bits.get_mut(at..at + row) else { continue }; + for (d, p) in dst.chunks_exact_mut(4).zip(src.as_chunks::<4>().0) { + let a = (f32::from(p[3]) * opacity.clamp(0.0, 1.0)).round() as u32; + let pre = |c: u8| ((u32::from(c) * a + 127) / 255) as u8; + d.copy_from_slice(&[pre(p[2]), pre(p[1]), pre(p[0]), a as u8]); + } + } + (header(img.width, img.height, 32, bits.len()), bits) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample() -> Rgba { + // 3 × 2: red, green, blue / half-transparent black, transparent, white. + let pixels = vec![255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255, 0, 0, 0, 128, 0, 0, 0, 0, 255, 255, 255, 255]; + Rgba { width: 3, height: 2, pixels } + } + + #[test] + fn rgb24_is_bottom_up_bgr_over_white() { + let (bmi, bits) = rgb24(&sample(), 1.0); + assert_eq!(bmi.len(), 40); + assert_eq!(&bmi[14..16], &[24, 0], "24 bits a pixel"); + // Rows of 9 bytes padded to 12, the last row first: half-transparent black over white is + // mid grey, transparent is white. + assert_eq!(bits, vec![127, 127, 127, 255, 255, 255, 255, 255, 255, 0, 0, 0, 0, 0, 255, 0, 255, 0, 255, 0, 0, 0, 0, 0]); + } + + #[test] + fn bgra32_is_premultiplied() { + let (bmi, bits) = bgra32(&sample(), 1.0); + assert_eq!(&bmi[14..16], &[32, 0]); + assert_eq!(&bits[..12], &[0, 0, 0, 128, 0, 0, 0, 0, 255, 255, 255, 255]); + assert_eq!(&bits[12..16], &[0, 0, 255, 255], "red, as BGRA"); + // At half opacity. + let (_, half) = bgra32(&sample(), 0.5); + assert_eq!(&half[12..16], &[0, 0, 128, 128]); + } + + #[test] + fn large_images_are_scaled_to_fit() { + let img = image::RgbaImage::new(MAX_SIDE * 2, 4); + let f = Rgba::fitted(img); + assert_eq!((f.width, f.height), (MAX_SIDE, 2)); + } +} diff --git a/crates/metafile/src/emf.rs b/crates/metafile/src/emf.rs new file mode 100644 index 00000000..dccfb432 --- /dev/null +++ b/crates/metafile/src/emf.rs @@ -0,0 +1,503 @@ +//! The EMF writer: an EMR_HEADER whose frame is the region in 0.01 mm, then the drawing in device +//! units of 0.01 mm (the reference device has 100 pixels a millimetre) and an EMR_EOF. +//! +//! Each fill and stroke is a path bracket (move-to, poly-line-to and poly-Bézier-to runs, close +//! figure) filled with a solid brush or stroked with a geometric pen; brushes and pens are made +//! when the colour or pen changes and deleted when replaced. Clips are path clips inside a saved +//! DC; images are StretchDIBits (opaque) or AlphaBlend (with transparency) under a world transform +//! that places their pixels. + +use kurbo::PathEl; +use vectorcraft_doc::{LineCap, LineJoin}; +use vectorcraft_geom::{Affine, BezPath, FillRule, Point, Rect}; + +use crate::bytes::Out; +use crate::dib::{self, Rgba}; +use crate::scene::{Op, Pen}; + +// Record types (MS-EMF 2.1.1). +pub(crate) const HEADER: u32 = 1; +pub(crate) const POLYBEZIERTO: u32 = 5; +pub(crate) const POLYLINETO: u32 = 6; +pub(crate) const EOF: u32 = 14; +pub(crate) const SETBKMODE: u32 = 18; +pub(crate) const SETPOLYFILLMODE: u32 = 19; +pub(crate) const MOVETOEX: u32 = 27; +pub(crate) const SAVEDC: u32 = 33; +pub(crate) const RESTOREDC: u32 = 34; +pub(crate) const SETWORLDTRANSFORM: u32 = 35; +pub(crate) const MODIFYWORLDTRANSFORM: u32 = 36; +pub(crate) const SELECTOBJECT: u32 = 37; +pub(crate) const CREATEBRUSHINDIRECT: u32 = 39; +pub(crate) const DELETEOBJECT: u32 = 40; +pub(crate) const SETMITERLIMIT: u32 = 58; +pub(crate) const BEGINPATH: u32 = 59; +pub(crate) const ENDPATH: u32 = 60; +pub(crate) const CLOSEFIGURE: u32 = 61; +pub(crate) const FILLPATH: u32 = 62; +pub(crate) const STROKEPATH: u32 = 64; +pub(crate) const SELECTCLIPPATH: u32 = 67; +pub(crate) const STRETCHDIBITS: u32 = 81; +pub(crate) const EXTCREATEPEN: u32 = 95; +pub(crate) const ALPHABLEND: u32 = 114; + +/// The `" EMF"` signature in the header. +pub(crate) const SIGNATURE: u32 = 0x464D_4520; +/// Stock objects (selected to release ours before deleting them). +const WHITE_BRUSH: u32 = 0x8000_0000; +const NULL_PEN: u32 = 0x8000_0008; +/// Our two object slots (0 is reserved). +const BRUSH: u32 = 1; +const PEN: u32 = 2; + +pub(crate) const PS_GEOMETRIC: u32 = 0x0001_0000; +pub(crate) const PS_USERSTYLE: u32 = 7; +pub(crate) const PS_ENDCAP_SQUARE: u32 = 0x100; +pub(crate) const PS_ENDCAP_FLAT: u32 = 0x200; +pub(crate) const PS_JOIN_BEVEL: u32 = 0x1000; +pub(crate) const PS_JOIN_MITER: u32 = 0x2000; +/// Raster operation: copy the source. +pub(crate) const SRCCOPY: u32 = 0x00CC_0020; + +/// Device units (0.01 mm) per point. +pub(crate) const UNITS_PER_PT: f64 = 2540.0 / 72.0; +/// Longest dash pattern a geometric pen takes (longer ones are drawn as separate dashes). +const MAX_STYLE: usize = 16; +/// Header size with both extensions. +const HEADER_SIZE: usize = 108; +/// Coordinates are kept within this (far beyond any page). +const MAX_COORD: f64 = 1e9; + +/// Does `bytes` start with an EMF header? +pub(crate) fn is_emf(bytes: &[u8]) -> bool { + let u32_at = |at: usize| bytes.get(at..at + 4).and_then(|b| b.try_into().ok()).map(u32::from_le_bytes); + u32_at(0) == Some(HEADER) && u32_at(40) == Some(SIGNATURE) +} + +/// A COLORREF (0x00bbggrr). +pub(crate) fn colorref(rgb: [u8; 3]) -> u32 { + u32::from_le_bytes([rgb[0], rgb[1], rgb[2], 0]) +} + +/// What a pen object is made of, to tell when the next stroke needs another. +#[derive(Clone, PartialEq)] +struct PenKey { + style: u32, + width: u32, + color: u32, + entries: Vec, +} + +/// A device-space bounding box (inclusive). +type Bounds = [i32; 4]; + +fn union(a: Option, b: Bounds) -> Bounds { + match a { + None => b, + Some(a) => [a[0].min(b[0]), a[1].min(b[1]), a[2].max(b[2]), a[3].max(b[3])], + } +} + +struct Writer { + out: Out, + records: u32, + /// Document → device units. + to_dev: Affine, + /// The frame in device units: what the region clip lets through. + frame: Bounds, + /// Everything drawn, in device units. + bounds: Option, + brush: Option, + pen: Option, + fill_mode: Option, + miter: Option, +} + +/// Write `ops` as an EMF of `region` (document space), described as `title`. +pub(crate) fn write(ops: &[Op], region: Rect, title: &str) -> Vec { + let k = UNITS_PER_PT; + let frame = [0, 0, (region.width() * k).round() as i32, (region.height() * k).round() as i32]; + let mut w = Writer { + out: Out::default(), + records: 0, + to_dev: Affine::scale(k) * Affine::translate(-region.origin().to_vec2()), + frame, + bounds: None, + brush: None, + pen: None, + fill_mode: None, + miter: None, + }; + w.header(title); + // Dash gaps and hatches show what is below. + w.record(SETBKMODE, |o| o.u32(1)); + for op in ops { + w.op(op); + } + w.release(); + w.record(EOF, |o| { + o.u32(0); + o.u32(16); + o.u32(20); + }); + w.finish() +} + +impl Writer { + /// Append one record: its type, size and `body`, padded to 4 bytes. + fn record(&mut self, kind: u32, body: impl FnOnce(&mut Out)) { + let start = self.out.len(); + self.out.u32(kind); + self.out.u32(0); + body(&mut self.out); + self.out.pad(4); + let size = u32::try_from(self.out.len() - start).unwrap_or(u32::MAX); + self.out.set_u32(start + 4, size); + self.records += 1; + } + + fn header(&mut self, title: &str) { + let desc: Vec = + "VectorCraft".encode_utf16().chain([0]).chain(title.chars().take(200).collect::().encode_utf16()).chain([0, 0]).collect(); + let f = self.frame; + // The reference device: 100 pixels a millimetre, as large as the frame. + let mm = [(f[2] / 100).max(1) + 1, (f[3] / 100).max(1) + 1]; + self.record(HEADER, |o| { + // Bounds, patched in `finish`. + o.bytes(&[0; 16]); + for v in f { + o.i32(v); + } + o.u32(SIGNATURE); + o.u32(0x0001_0000); + // Bytes, records and handles, patched in `finish`. + o.u32(0); + o.u32(0); + o.u16(3); + o.u16(0); + o.u32(desc.len() as u32); + o.u32(HEADER_SIZE as u32); + o.u32(0); + o.i32(mm[0] * 100); + o.i32(mm[1] * 100); + o.i32(mm[0]); + o.i32(mm[1]); + // No pixel format, not OpenGL; the device in micrometres. + o.u32(0); + o.u32(0); + o.u32(0); + o.u32(mm[0] as u32 * 1000); + o.u32(mm[1] as u32 * 1000); + for c in &desc { + o.u16(*c); + } + }); + } + + /// Patch the header's bounds, size and record count. + fn finish(mut self) -> Vec { + let b = self.bounds.map(|b| [b[0].max(self.frame[0]), b[1].max(self.frame[1]), b[2].min(self.frame[2]), b[3].min(self.frame[3])]); + // An empty picture has bounds (0, 0, -1, -1). + let b = b.filter(|b| b[0] <= b[2] && b[1] <= b[3]).unwrap_or([0, 0, -1, -1]); + for (i, v) in b.iter().enumerate() { + self.out.set_i32(8 + 4 * i, *v); + } + let size = u32::try_from(self.out.len()).unwrap_or(u32::MAX); + self.out.set_u32(48, size); + self.out.set_u32(52, self.records); + self.out.0 + } + + fn dev(&self, p: Point) -> [i32; 2] { + let q = self.to_dev * p; + let c = |v: f64| if v.is_finite() { v.round().clamp(-MAX_COORD, MAX_COORD) as i32 } else { 0 }; + [c(q.x), c(q.y)] + } + + fn grow(&mut self, b: Bounds) { + self.bounds = Some(union(self.bounds, b)); + } + + fn op(&mut self, op: &Op) { + match op { + Op::Fill { path, rule, rgb } => { + self.fill_mode(*rule); + self.brush(*rgb); + if let Some(b) = self.path(path) { + self.grow(b); + self.record(FILLPATH, |o| b.iter().for_each(|v| o.i32(*v))); + } + } + Op::Stroke { path, pen } => { + let dashed; + let path = if self.pen(pen) { + path + } else { + dashed = crate::scene::dashed(path, pen.dash.as_ref()); + &dashed + }; + if let Some(b) = self.path(path) { + let half = (pen.width * UNITS_PER_PT / 2.0).ceil() as i32; + let b = [b[0] - half, b[1] - half, b[2] + half, b[3] + half]; + self.grow(b); + self.record(STROKEPATH, |o| b.iter().for_each(|v| o.i32(*v))); + } + } + Op::Image { image, xf, opacity } => self.image(image, *xf, *opacity), + Op::Clip { path, rule } => { + self.release(); + self.record(SAVEDC, |_| {}); + self.fill_mode(*rule); + if self.path(path).is_some() { + // RGN_AND: inside the clip already set. + self.record(SELECTCLIPPATH, |o| o.u32(1)); + } + } + Op::Unclip => { + self.release(); + self.record(RESTOREDC, |o| o.i32(-1)); + // The DC's fill mode and miter limit are back to what they were at the save. + self.fill_mode = None; + self.miter = None; + } + } + } + + fn fill_mode(&mut self, rule: FillRule) { + // ALTERNATE (even-odd) or WINDING (non-zero). + let mode = if rule == FillRule::EvenOdd { 1 } else { 2 }; + if self.fill_mode != Some(mode) { + self.record(SETPOLYFILLMODE, |o| o.u32(mode)); + self.fill_mode = Some(mode); + } + } + + /// Select a solid brush of `rgb`, made when the colour changes. + fn brush(&mut self, rgb: [u8; 3]) { + let color = colorref(rgb); + if self.brush == Some(color) { + return; + } + self.drop_brush(); + self.record(CREATEBRUSHINDIRECT, |o| { + o.u32(BRUSH); + // BS_SOLID, the colour, no hatch. + o.u32(0); + o.u32(color); + o.u32(0); + }); + self.record(SELECTOBJECT, |o| o.u32(BRUSH)); + self.brush = Some(color); + } + + fn drop_brush(&mut self) { + if self.brush.take().is_some() { + self.record(SELECTOBJECT, |o| o.u32(WHITE_BRUSH)); + self.record(DELETEOBJECT, |o| o.u32(BRUSH)); + } + } + + fn drop_pen(&mut self) { + if self.pen.take().is_some() { + self.record(SELECTOBJECT, |o| o.u32(NULL_PEN)); + self.record(DELETEOBJECT, |o| o.u32(PEN)); + } + } + + /// Release our objects (before a DC is saved or restored, which would bring back deleted ones). + fn release(&mut self) { + self.drop_brush(); + self.drop_pen(); + } + + /// Select a geometric pen for `p`, made when it changes. False when `p`'s dashes must be + /// drawn as separate lines (the pen selected is solid): a pen's dashes start at the path's + /// start, every entry at least a unit long, 16 entries at most. + fn pen(&mut self, p: &Pen) -> bool { + let k = UNITS_PER_PT; + let entries: Option> = p.dash.as_ref().map(|d| { + let mut v: Vec = d.pattern.clone(); + if v.len() % 2 == 1 { + v.extend_from_within(..); + } + v.iter().map(|x| (x * k).round().clamp(0.0, f64::from(u32::MAX)) as u32).collect() + }); + let native = match (&entries, &p.dash) { + (Some(e), Some(d)) => d.offset.abs() < 1e-9 && e.len() <= MAX_STYLE && e.iter().all(|x| *x >= 1), + _ => true, + }; + let entries = if native { entries.unwrap_or_default() } else { vec![] }; + let cap = match p.cap { + LineCap::Butt => PS_ENDCAP_FLAT, + LineCap::Round => 0, + LineCap::Square => PS_ENDCAP_SQUARE, + }; + let join = match p.join { + LineJoin::Miter => PS_JOIN_MITER, + LineJoin::Round => 0, + LineJoin::Bevel => PS_JOIN_BEVEL, + }; + let style = PS_GEOMETRIC | cap | join | if entries.is_empty() { 0 } else { PS_USERSTYLE }; + let key = PenKey { style, width: (p.width * k).round().clamp(1.0, 1e9) as u32, color: colorref(p.rgb), entries }; + if p.join == LineJoin::Miter { + let m = p.miter.round().clamp(1.0, 1000.0) as u32; + if self.miter != Some(m) { + self.record(SETMITERLIMIT, |o| o.u32(m)); + self.miter = Some(m); + } + } + if self.pen.as_ref() != Some(&key) { + self.drop_pen(); + let n = key.entries.len() as u32; + self.record(EXTCREATEPEN, |o| { + o.u32(PEN); + // No pattern bitmap: its offsets point past the record, its sizes are 0. + let end = 52 + 4 * n; + o.u32(end); + o.u32(0); + o.u32(end); + o.u32(0); + o.u32(key.style); + o.u32(key.width); + // BS_SOLID, the colour, no hatch. + o.u32(0); + o.u32(key.color); + o.u32(0); + o.u32(n); + key.entries.iter().for_each(|e| o.u32(*e)); + }); + self.record(SELECTOBJECT, |o| o.u32(PEN)); + self.pen = Some(key); + } + native + } + + /// `bp` as a path bracket → its device bounds (`None`: nothing to draw). + fn path(&mut self, bp: &BezPath) -> Option { + if !bp.elements().iter().any(|e| !matches!(e, PathEl::MoveTo(_) | PathEl::ClosePath)) { + return None; + } + self.record(BEGINPATH, |_| {}); + let mut bounds: Option = None; + let mut run: Vec<[i32; 2]> = vec![]; + let mut curves = false; + let mut last = Point::ZERO; + let mut start = Point::ZERO; + for el in bp.elements() { + let (pts, is_curve): (Vec, bool) = match *el { + PathEl::MoveTo(p) => { + self.flush_run(&mut run, curves); + let d = self.dev(p); + bounds = Some(union(bounds, [d[0], d[1], d[0], d[1]])); + self.record(MOVETOEX, |o| d.iter().for_each(|v| o.i32(*v))); + (last, start) = (p, p); + continue; + } + PathEl::ClosePath => { + self.flush_run(&mut run, curves); + self.record(CLOSEFIGURE, |_| {}); + last = start; + continue; + } + PathEl::LineTo(p) => (vec![p], false), + PathEl::QuadTo(c, p) => (vec![last + (c - last) * (2.0 / 3.0), p + (c - p) * (2.0 / 3.0), p], true), + PathEl::CurveTo(a, b, p) => (vec![a, b, p], true), + }; + if is_curve != curves { + self.flush_run(&mut run, curves); + curves = is_curve; + } + for p in &pts { + let d = self.dev(*p); + bounds = Some(union(bounds, [d[0], d[1], d[0], d[1]])); + run.push(d); + } + last = pts.last().copied().unwrap_or(last); + } + self.flush_run(&mut run, curves); + self.record(ENDPATH, |_| {}); + bounds + } + + /// Write the points of a run of lines or curves, then empty it. + fn flush_run(&mut self, run: &mut Vec<[i32; 2]>, curves: bool) { + if run.is_empty() { + return; + } + let pts = std::mem::take(run); + let b = pts.iter().fold(None, |acc, p| Some(union(acc, [p[0], p[1], p[0], p[1]]))).unwrap_or([0, 0, -1, -1]); + self.record(if curves { POLYBEZIERTO } else { POLYLINETO }, |o| { + b.iter().for_each(|v| o.i32(*v)); + o.u32(pts.len() as u32); + for p in &pts { + o.i32(p[0]); + o.i32(p[1]); + } + }); + } + + /// An image placed by `xf` (its pixels → document): StretchDIBits when opaque, else AlphaBlend, + /// drawn at its pixel size under a world transform. + fn image(&mut self, img: &Rgba, xf: Affine, opacity: f32) { + if img.width == 0 || img.height == 0 || opacity <= 0.0 { + return; + } + let m = self.to_dev * xf; + if !m.as_coeffs().iter().all(|v| v.is_finite()) || m.determinant().abs() < 1e-12 { + return; + } + let (w, h) = (f64::from(img.width), f64::from(img.height)); + let corners = [Point::ZERO, Point::new(w, 0.0), Point::new(0.0, h), Point::new(w, h)].map(|p| self.dev(xf * p)); + let b = corners.iter().fold(None, |acc, p| Some(union(acc, [p[0], p[1], p[0], p[1]]))).unwrap_or([0, 0, -1, -1]); + self.grow(b); + let [a, bb, c, d, e, f] = m.as_coeffs(); + self.record(SETWORLDTRANSFORM, |o| [a, bb, c, d, e, f].iter().for_each(|v| o.f32(*v as f32))); + let (iw, ih) = (img.width as i32, img.height as i32); + if img.opaque() && opacity >= 0.998 { + let (bmi, bits) = dib::rgb24(img, 1.0); + self.record(STRETCHDIBITS, |o| { + b.iter().for_each(|v| o.i32(*v)); + // Destination and source rectangles. + [0, 0, 0, 0, iw, ih].iter().for_each(|v| o.i32(*v)); + o.u32(80); + o.u32(bmi.len() as u32); + o.u32(80 + bmi.len() as u32); + o.u32(bits.len() as u32); + // DIB_RGB_COLORS, SRCCOPY, the destination size. + o.u32(0); + o.u32(SRCCOPY); + o.i32(iw); + o.i32(ih); + o.bytes(&bmi); + o.bytes(&bits); + }); + } else { + let (bmi, bits) = dib::bgra32(img, opacity); + self.record(ALPHABLEND, |o| { + b.iter().for_each(|v| o.i32(*v)); + [0, 0, iw, ih].iter().for_each(|v| o.i32(*v)); + // AC_SRC_OVER, no flags, constant alpha 255, AC_SRC_ALPHA (per-pixel alpha). + o.bytes(&[0, 0, 255, 1]); + o.i32(0); + o.i32(0); + // The source's own transform: identity. + [1.0f32, 0.0, 0.0, 1.0, 0.0, 0.0].iter().for_each(|v| o.f32(*v)); + o.u32(0); + o.u32(0); + o.u32(108); + o.u32(bmi.len() as u32); + o.u32(108 + bmi.len() as u32); + o.u32(bits.len() as u32); + o.i32(iw); + o.i32(ih); + o.bytes(&bmi); + o.bytes(&bits); + }); + } + // MWT_IDENTITY: back to drawing in device units. + self.record(MODIFYWORLDTRANSFORM, |o| { + [1.0f32, 0.0, 0.0, 1.0, 0.0, 0.0].iter().for_each(|v| o.f32(*v)); + o.u32(1); + }); + } +} diff --git a/crates/metafile/src/lib.rs b/crates/metafile/src/lib.rs new file mode 100644 index 00000000..b7f72d7f --- /dev/null +++ b/crates/metafile/src/lib.rs @@ -0,0 +1,88 @@ +//! Windows metafiles: hand-written EMF and WMF writers. +//! +//! [`export`] writes the visible art over one region of a document (an artboard) as a metafile: +//! +//! - **EMF**: paths keep their Bézier curves, fills are solid brushes, strokes geometric pens with +//! their width, caps, joins, miter limit and dashes; strokes a pen can't draw (aligned inside or +//! outside, width profiles, arrowheads) are their filled outlines and brush strokes their brush +//! art. Clipping masks clip, type is written as glyph outlines, images keep their pixels (with +//! their transparency), gradients become images clipped to what they paint and pattern fills +//! their tiles clipped to the shape. The header's frame is the region in 0.01 mm. +//! - **WMF**: the same art with curves flattened into polygons and 16-bit coordinates, behind a +//! placeable header that gives the size; WMF has no clipping paths, transparency, dashes on wide +//! lines or gradients, so clipped art is written whole, images over white, dashes as separate +//! lines and gradients and patterns as one colour (each loss comes back as a warning). +//! +//! - `bytes`: little-endian writing. +//! - `dib`: device-independent bitmaps. +//! - `scene`: the document walk both writers share. +//! - `emf`, `wmf`: the writers. + +mod bytes; +mod dib; +mod emf; +mod scene; +mod wmf; + +use vectorcraft_doc::Document; +use vectorcraft_geom::Rect; + +/// A Windows metafile format. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Kind { + /// Enhanced Metafile (32-bit coordinates, Bézier curves, paths and clipping). + Emf, + /// Windows Metafile with a placeable header (16-bit coordinates, polygons). + Wmf, +} + +impl Kind { + /// The format id (`emf`, `wmf`). + pub fn id(self) -> &'static str { + match self { + Self::Emf => "emf", + Self::Wmf => "wmf", + } + } +} + +/// The metafile format `bytes` start like, if any. +pub fn sniff(bytes: &[u8]) -> Option { + if emf::is_emf(bytes) { + Some(Kind::Emf) + } else if wmf::is_wmf(bytes) { + Some(Kind::Wmf) + } else { + None + } +} + +/// A written metafile. +#[derive(Clone, Debug, Default)] +pub struct Output { + pub bytes: Vec, + /// What was approximated or left out. + pub warnings: Vec, +} + +/// Write the visible art of `doc` over `region` (document space: its top-left corner is the +/// picture's origin, its size the picture's) as `kind`. Live geometry effects are applied first; +/// template layers, hidden objects and guides are left out. Raster effects should be turned into +/// images before (the engine does); the ones left are reported and left out. +pub fn export(doc: &Document, region: Rect, kind: Kind) -> Result { + let r = region.abs(); + if ![r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite()) || r.width() <= 0.0 || r.height() <= 0.0 { + return Err("the export region is empty".into()); + } + let baked = vectorcraft_effects::bake_document(doc); + let doc = baked.as_ref().unwrap_or(doc); + let (ops, mut warnings) = scene::Scene::new(doc, r, kind == Kind::Emf).run(); + let bytes = match kind { + Kind::Emf => emf::write(&ops, r, &doc.title), + Kind::Wmf => wmf::write(&ops, r, &mut warnings), + }; + Ok(Output { bytes, warnings }) +} + +#[cfg(test)] +mod tests; diff --git a/crates/metafile/src/scene.rs b/crates/metafile/src/scene.rs new file mode 100644 index 00000000..10077ad0 --- /dev/null +++ b/crates/metafile/src/scene.rs @@ -0,0 +1,421 @@ +//! The document walk both writers share: each visible object over the region becomes drawing +//! operations ([`Op`]) in paint order, in document space. What a format can't hold is decided +//! here once: with clipping (EMF), gradients become images clipped to what they paint and +//! patterns their tiles clipped to the shape; without (WMF), clipped art is written whole and +//! gradients and patterns as one colour. + +use std::collections::HashMap; +use std::sync::Arc; + +use vectorcraft_color::freeform::{painted_box, spread_scale}; +use vectorcraft_color::{BlendMode, GradientKind, GradientPaint, Paint}; +use vectorcraft_doc::{AppearanceItem, Dash, Document, ImageObject, LineCap, LineJoin, Node, NodeKind, StrokeLayer}; +use vectorcraft_effects::stroke; +use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Rect, Shape}; + +use crate::dib::Rgba; + +/// How deep symbols, brush art and pattern tiles may nest in one another (deeper art is left out). +const MAX_NEST: u32 = 8; +/// Most pixels along a side of a gradient's image (its colours are smooth). +const MAX_GRADIENT_PX: f64 = 512.0; +/// Opacity at or above this is written as opaque. +const OPAQUE: f32 = 0.998; + +const TRANSPARENCY: &str = "transparency is left out of paths: transparent fills and strokes are written opaque"; +const BLENDING: &str = "blending modes are left out"; +const MASKS: &str = "opacity masks are left out"; +const RASTER_FX: &str = "raster effects (shadows, glows, blurs, feathering) are left out"; +const CLIPPING: &str = "clipping masks are not applied: the art they clip is written whole"; +const FLAT_GRADIENTS: &str = "gradients are written as one colour, the average of their stops"; +const FLAT_PATTERNS: &str = "pattern fills are written as one colour"; + +/// A stroke as a pen draws it. +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct Pen { + pub rgb: [u8; 3], + /// Points. + pub width: f64, + pub cap: LineCap, + pub join: LineJoin, + pub miter: f64, + /// A dash pattern that draws dashes (points). + pub dash: Option, +} + +/// One drawing operation, in document space. +#[derive(Clone, Debug)] +pub(crate) enum Op { + Fill { + path: BezPath, + rule: FillRule, + rgb: [u8; 3], + }, + Stroke { + path: BezPath, + pen: Pen, + }, + /// An image: its pixel space (0..w, 0..h) maps into the document by `xf`; its alpha times + /// `opacity`. + Image { + image: Arc, + xf: Affine, + opacity: f32, + }, + /// What follows, up to the matching [`Op::Unclip`], is clipped to `path` (inside the clips + /// around it). Only formats with clipping get these. + Clip { + path: BezPath, + rule: FillRule, + }, + Unclip, +} + +fn overlaps(a: Rect, b: Rect) -> bool { + a.x0 <= b.x1 && b.x0 <= a.x1 && a.y0 <= b.y1 && b.y0 <= a.y1 +} + +fn rgb_of(c: &vectorcraft_color::Color) -> [u8; 3] { + let [r, g, b, _] = c.to_rgba8(1.0); + [r, g, b] +} + +/// The first visible solid fill colour in `n` (a pattern tile's art). +fn first_solid(n: &Node) -> Option { + let own = n.appearance.items.iter().find_map(|i| match i { + AppearanceItem::Fill(f) if f.visible => f.paint.color(), + _ => None, + }); + own.or_else(|| n.children().into_iter().flatten().find_map(|c| first_solid(c))) +} + +/// `bp` cut into its dashes (as it is when `dash` draws none), for pens that can't draw them. +pub(crate) fn dashed(bp: &BezPath, dash: Option<&Dash>) -> BezPath { + dash.and_then(|d| stroke::dash(bp, d)).map_or_else(|| bp.clone(), |d| d.path) +} + +fn q(v: f32) -> u8 { + (v.clamp(0.0, 1.0) * 255.0).round() as u8 +} + +pub(crate) struct Scene<'a> { + doc: &'a Document, + region: Rect, + /// The format clips (EMF). + clips: bool, + ops: Vec, + warnings: Vec, + /// Decoded images by key (`None`: can't be decoded, left out). + images: HashMap>>, + brushes: Option>, + /// The opacity of the containers around the current object. + opacity: f32, + /// Symbols, brush art and pattern tiles being written inside one another. + nest: u32, +} + +impl<'a> Scene<'a> { + pub fn new(doc: &'a Document, region: Rect, clips: bool) -> Self { + Self { doc, region, clips, ops: vec![], warnings: vec![], images: HashMap::new(), brushes: None, opacity: 1.0, nest: 0 } + } + + /// The operations (clipped to the region when the format clips) and the warnings. + pub fn run(mut self) -> (Vec, Vec) { + let doc = self.doc; + if self.clips { + self.ops.push(Op::Clip { path: self.region.to_path(0.1), rule: FillRule::NonZero }); + } + for l in &doc.layers { + self.node(l, false); + } + if self.clips { + self.ops.push(Op::Unclip); + } + (self.ops, self.warnings) + } + + fn warn(&mut self, w: &str) { + if !self.warnings.iter().any(|x| x == w) { + self.warnings.push(w.to_string()); + } + } + + fn node(&mut self, n: &Node, force: bool) { + if let NodeKind::Layer { template: true, .. } = n.kind { + return; + } + if !force && !n.visible { + return; + } + if !n.is_layer() { + match n.visual_bounds() { + Some(b) if !overlaps(b, self.region) => return, + None if !n.is_container() => return, + _ => {} + } + } + self.note_losses(n); + let opacity = self.opacity; + self.opacity *= n.opacity.clamp(0.0, 1.0); + match &n.kind { + NodeKind::Layer { children, clip, .. } | NodeKind::Group { children, clip } => self.children(children, *clip), + NodeKind::Path { path, rule, guide, .. } => { + if !*guide { + self.shape(n, &path.to_bezpath(), *rule); + } + } + NodeKind::Compound { children, rule } => { + let mut bp = BezPath::new(); + for p in children.iter().filter_map(|c| c.path_data()) { + bp.extend(p.to_bezpath()); + } + self.shape(n, &bp, *rule); + } + NodeKind::Text(_) => { + if let Some(o) = vectorcraft_effects::outline_text(n) { + self.node(&o, true); + } + } + NodeKind::Image(im) => self.image(im), + NodeKind::SymbolInstance { symbol, xf } => { + if let Some(sym) = self.doc.symbols.iter().find(|s| &s.name == symbol) + && self.nest < MAX_NEST + { + let mut art = (*sym.art).clone(); + art.transform(*xf, false); + self.nest += 1; + self.node(&art, true); + self.nest -= 1; + } + } + // Live blends, envelopes, meshes and repeats are written as their evaluated art. + NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) => { + let g = vectorcraft_doc::live::expand_deep(n, None); + for c in g.children().into_iter().flatten() { + self.node(c, false); + } + } + } + self.opacity = opacity; + } + + /// A container's children. A clipping container's first child clips the rest (its fill paints + /// below them, its stroke over them); without clipping they are written whole. + fn children(&mut self, children: &[Arc], clip: bool) { + let Some((first, rest)) = children.split_first().filter(|_| clip) else { + children.iter().for_each(|c| self.node(c, false)); + return; + }; + let paint = first.clip_paint(); + let outline = if self.clips { + // Nothing to clip by hides the clipped art (as on the canvas). + let Some((path, rule)) = vectorcraft_effects::clip_outline(first) else { return }; + Some((path, rule)) + } else { + self.warn(CLIPPING); + None + }; + let clipped = outline.is_some(); + if let Some((path, rule)) = outline { + self.ops.push(Op::Clip { path, rule }); + } + if let Some(f) = &paint.fill { + self.node(f, false); + } + rest.iter().for_each(|c| self.node(c, false)); + if clipped { + self.ops.push(Op::Unclip); + } + if let Some(s) = &paint.stroke { + self.node(s, false); + } + } + + /// Warn about what a metafile can't hold in `n`'s look. + fn note_losses(&mut self, n: &Node) { + if n.blend != BlendMode::Normal { + self.warn(BLENDING); + } + if n.mask.as_ref().is_some_and(|m| !m.disabled) { + self.warn(MASKS); + } + let raster = |fx: &[vectorcraft_doc::Effect]| fx.iter().any(|e| e.visible && vectorcraft_effects::is_raster(&e.id)); + if raster(&n.appearance.effects) || n.appearance.items.iter().any(|i| raster(i.effects())) { + self.warn(RASTER_FX); + } + } + + /// Warn when a path paints at `opacity` (times the containers'). + fn check_opacity(&mut self, opacity: f32) { + if self.opacity * opacity < OPAQUE { + self.warn(TRANSPARENCY); + } + } + + /// A path's fills and strokes, in paint order. + fn shape(&mut self, n: &Node, bp: &BezPath, rule: FillRule) { + if bp.elements().is_empty() { + return; + } + let bounds = bp.bounding_box(); + for item in &n.appearance.items { + match item { + AppearanceItem::Fill(f) if f.visible => { + if f.blend != BlendMode::Normal { + self.warn(BLENDING); + } + self.fill(bp, rule, &f.paint, f.opacity, bounds); + } + AppearanceItem::Stroke(st) if st.visible && st.width > 0.0 && st.width.is_finite() => self.stroke(bp, rule, st, bounds), + _ => {} + } + } + } + + /// Fill `bp` with `paint` at `opacity`; `bounds` places a gradient that isn't placed. + fn fill(&mut self, bp: &BezPath, rule: FillRule, paint: &Paint, opacity: f32, bounds: Rect) { + match paint { + Paint::None => {} + Paint::Gradient(g) if self.clips => self.gradient(bp, rule, g, opacity, bounds), + Paint::Pattern { pattern, xf } if self.clips && self.nest < MAX_NEST && self.doc.pattern(pattern).is_some() => { + let area = bp.bounding_box().intersect(self.region); + let tiles = self.doc.pattern(pattern).map(|def| def.instances_in(*xf, area)).unwrap_or_default(); + let outer = self.opacity; + self.opacity *= opacity.clamp(0.0, 1.0); + self.ops.push(Op::Clip { path: bp.clone(), rule }); + self.nest += 1; + tiles.iter().for_each(|t| self.node(t, true)); + self.nest -= 1; + self.ops.push(Op::Unclip); + self.opacity = outer; + } + p => { + let Some(rgb) = self.flat(p) else { return }; + self.check_opacity(opacity); + self.ops.push(Op::Fill { path: bp.clone(), rule, rgb }); + } + } + } + + /// One colour for a paint: a solid colour, the average of a gradient's stops, or a pattern's + /// first solid fill. + fn flat(&mut self, p: &Paint) -> Option<[u8; 3]> { + match p { + Paint::None => None, + Paint::Solid { color, .. } => Some(rgb_of(color)), + Paint::Gradient(g) => { + self.warn(FLAT_GRADIENTS); + let stops = &g.gradient.stops; + let n = stops.len().max(1) as f64; + let sum = stops.iter().map(|s| rgb_of(&s.color)).fold([0.0; 3], |a, c| [0, 1, 2].map(|i| a[i] + f64::from(c[i]))); + Some(sum.map(|v| (v / n).round().clamp(0.0, 255.0) as u8)) + } + Paint::Pattern { pattern, .. } => { + self.warn(FLAT_PATTERNS); + let first = self.doc.pattern(pattern).and_then(|def| def.art.iter().find_map(|a| first_solid(a))); + Some(first.map_or([128; 3], |c| rgb_of(&c))) + } + } + } + + /// A gradient fill: an image of its colours over the shape's box, clipped to the shape, + /// sampled at the document's raster effects resolution. + fn gradient(&mut self, bp: &BezPath, rule: FillRule, g: &GradientPaint, opacity: f32, bounds: Rect) { + let area = bp.bounding_box().intersect(self.region); + let long = area.width().max(area.height()); + if !(long > 1e-9 && long.is_finite()) { + return; + } + let k = (self.doc.raster_effects_ppi / 72.0).min(MAX_GRADIENT_PX / long); + let along = |len: f64| (len * k).ceil().clamp(1.0, MAX_GRADIENT_PX) as u16; + let (cols, rows) = (along(area.width()), along(area.height())); + let pixels: Vec = if g.gradient.kind == GradientKind::Freeform { + let Some(b) = painted_box(bounds) else { return }; + let field = g.freeform_on(b).field_with(spread_scale(b), &|c| c.to_rgb()); + field.grid(area, cols, rows).flat_map(|(c, a)| [q(c[0]), q(c[1]), q(c[2]), q(a)]).collect() + } else { + let geom = g.resolve(bounds); + let (cw, ch) = (area.width() / f64::from(cols), area.height() / f64::from(rows)); + (0..usize::from(rows) * usize::from(cols)) + .flat_map(|i| { + let (x, y) = (i % usize::from(cols), i / usize::from(cols)); + let p = Point::new(area.x0 + (x as f64 + 0.5) * cw, area.y0 + (y as f64 + 0.5) * ch); + let t = geom.param_at(g.gradient.kind, p); + let t = if t.is_finite() { t.clamp(0.0, 1.0) } else { 0.0 }; + let (c, a) = g.gradient.sample(t as f32); + c.to_rgba8(a) + }) + .collect() + }; + let image = Rgba { width: u32::from(cols), height: u32::from(rows), pixels }; + let xf = + Affine::translate(area.origin().to_vec2()) * Affine::scale_non_uniform(area.width() / f64::from(cols), area.height() / f64::from(rows)); + self.ops.push(Op::Clip { path: bp.clone(), rule }); + self.ops.push(Op::Image { image: Arc::new(image), xf, opacity: (self.opacity * opacity).clamp(0.0, 1.0) }); + self.ops.push(Op::Unclip); + } + + fn stroke(&mut self, bp: &BezPath, rule: FillRule, st: &StrokeLayer, bounds: Rect) { + if st.brush.is_some() + && self.nest < MAX_NEST + && let Some(art) = self.brush_art(bp, st) + { + let opacity = self.opacity; + self.opacity *= st.opacity.clamp(0.0, 1.0); + self.nest += 1; + art.iter().for_each(|piece| self.node(piece, true)); + self.nest -= 1; + self.opacity = opacity; + return; + } + if st.blend != BlendMode::Normal { + self.warn(BLENDING); + } + // A pen paints one colour along the path itself: anything else is the stroke's outline, + // filled. + let flat_paint = matches!(st.paint, Paint::Solid { .. }) || !self.clips; + if !stroke::is_plain(st) || !flat_paint { + let region = stroke::outline_region(&PathData::from_bezpath(bp), rule, st).to_bezpath(); + self.fill(®ion, FillRule::NonZero, &st.paint, st.opacity, st.paint_bounds(bounds)); + return; + } + let Some(rgb) = self.flat(&st.paint) else { return }; + self.check_opacity(st.opacity); + let pen = Pen { rgb, width: st.width, cap: st.cap, join: st.join, miter: st.miter_limit, dash: st.dash.clone().filter(|d| d.is_dashed()) }; + self.ops.push(Op::Stroke { path: bp.clone(), pen }); + } + + fn brush_art(&mut self, bp: &BezPath, st: &StrokeLayer) -> Option> { + let doc = self.doc; + let brushes = self.brushes.get_or_insert_with(|| vectorcraft_brush::library(doc)); + let b = st.brush.as_deref().and_then(|name| brushes.iter().find(|b| b.name == name))?; + Some(vectorcraft_brush::stroke_pieces(b, bp, st)) + } + + /// A placed image, decoded once per image. + fn image(&mut self, im: &ImageObject) { + let Some(img) = self.decoded(&im.key) else { return }; + let (dw, dh) = (f64::from(img.width), f64::from(img.height)); + let xf = im.xf * Affine::scale_non_uniform(f64::from(im.width.max(1)) / dw, f64::from(im.height.max(1)) / dh); + self.ops.push(Op::Image { image: img, xf, opacity: self.opacity.clamp(0.0, 1.0) }); + } + + /// Image `key`'s pixels (scaled down to what a metafile stores), `None` with a warning when it + /// has none or can't be decoded. + fn decoded(&mut self, key: &str) -> Option> { + if let Some(i) = self.images.get(key) { + return i.clone(); + } + let blob = self.doc.images.get(key).filter(|b| !b.bytes.is_empty()); + let img = blob + .and_then(|b| image::load_from_memory(&b.bytes).ok()) + .map(|i| Rgba::fitted(i.to_rgba8())) + .filter(|i| i.width > 0 && i.height > 0) + .map(Arc::new); + if img.is_none() { + self.warn(&format!("image '{key}' could not be decoded and was left out")); + } + self.images.insert(key.to_string(), img.clone()); + img + } +} diff --git a/crates/metafile/src/tests.rs b/crates/metafile/src/tests.rs new file mode 100644 index 00000000..878906ad --- /dev/null +++ b/crates/metafile/src/tests.rs @@ -0,0 +1,330 @@ +//! EMF and WMF output read back record by record: headers, record sizes, paths, pens, brushes, +//! clips, images, and what each format leaves out. + +use std::sync::Arc; + +use vectorcraft_color::{Color, Gradient, GradientPaint, Paint}; +use vectorcraft_doc::{Appearance, CharStyle, Dash, Document, ImageBlob, ImageObject, LineCap, LineJoin, Node, NodeId, NodeKind, TextObject}; +use vectorcraft_geom::{Affine, PathData, Point, Rect, shapes}; + +use crate::*; + +/// Add `n` (with a fresh id) on top of `d`'s first layer. +pub(crate) fn add(d: &mut Document, mut n: Node) { + n.id = d.alloc_id(); + let layer = d.layers[0].id; + let at = d.layers[0].children().map_or(0, Vec::len); + d.insert(Some(layer), at, n).unwrap(); +} + +/// A document of one `w` × `h` artboard holding `nodes` on its layer. +pub(crate) fn doc_with(w: f64, h: f64, nodes: Vec) -> Document { + let mut d = Document::new(w, h); + nodes.into_iter().for_each(|n| add(&mut d, n)); + d +} + +pub(crate) fn path(p: PathData, fill: Paint, stroke: Paint, width: f64) -> Node { + Node::path(NodeId(0), p, Appearance::basic(fill, stroke, width)) +} + +pub(crate) fn red() -> Paint { + Paint::solid(Color::rgb8(255, 0, 0)) +} + +pub(crate) fn blue() -> Paint { + Paint::solid(Color::rgb8(0, 0, 255)) +} + +/// A `w` × `h` PNG of `rgba`. +pub(crate) fn png(w: u32, h: u32, rgba: [u8; 4]) -> Vec { + let img = image::RgbaImage::from_pixel(w, h, image::Rgba(rgba)); + let mut out = vec![]; + img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap(); + out +} + +/// An image node of `bytes` placed by `xf`, its blob added to `d`. +pub(crate) fn image_node(d: &mut Document, bytes: Vec, w: u32, h: u32, xf: Affine) -> Node { + let blob = ImageBlob::new("image/png", bytes); + let key = blob.content_key(); + d.images.insert(key.clone(), blob); + Node::new(NodeId(0), NodeKind::Image(ImageObject { key, width: w, height: h, xf, link: None, placement: Default::default() })) +} + +pub(crate) fn u32_at(b: &[u8], at: usize) -> u32 { + u32::from_le_bytes(b[at..at + 4].try_into().unwrap()) +} + +pub(crate) fn i32_at(b: &[u8], at: usize) -> i32 { + i32::from_le_bytes(b[at..at + 4].try_into().unwrap()) +} + +pub(crate) fn u16_at(b: &[u8], at: usize) -> u16 { + u16::from_le_bytes(b[at..at + 2].try_into().unwrap()) +} + +/// The EMF's records: (type, the whole record). +pub(crate) fn emf_records(b: &[u8]) -> Vec<(u32, &[u8])> { + let mut out = vec![]; + let mut at = 0; + while at < b.len() { + let size = u32_at(b, at + 4) as usize; + assert!(size >= 8 && size.is_multiple_of(4), "record size {size} at {at}"); + out.push((u32_at(b, at), &b[at..at + size])); + at += size; + } + assert_eq!(at, b.len(), "records end at the end of the file"); + out +} + +/// The WMF's records after both headers: (function, the whole record). +pub(crate) fn wmf_records(b: &[u8]) -> Vec<(u16, &[u8])> { + let mut out = vec![]; + let mut at = 22 + 18; + while at < b.len() { + let words = u32_at(b, at) as usize; + assert!(words >= 3, "record of {words} words at {at}"); + out.push((u16_at(b, at + 4), &b[at..at + words * 2])); + at += words * 2; + } + assert_eq!(at, b.len()); + out +} + +pub(crate) fn emf(d: &Document) -> Output { + export(d, d.artboards[0].rect, Kind::Emf).unwrap() +} + +pub(crate) fn wmf(d: &Document) -> Output { + export(d, d.artboards[0].rect, Kind::Wmf).unwrap() +} + +fn kinds(recs: &[(u32, &[u8])]) -> Vec { + recs.iter().map(|r| r.0).collect() +} + +fn count(k: &[u32], kind: u32) -> usize { + k.iter().filter(|x| **x == kind).count() +} + +#[test] +fn emf_header_frame_is_the_region_in_hundredths_of_a_millimetre() { + // 72 × 144 pt is 25.4 × 50.8 mm; a rectangle from (36, 36) to (54, 72) pt. + let d = doc_with(72.0, 144.0, vec![path(shapes::rectangle(Rect::new(36.0, 36.0, 54.0, 72.0)), red(), Paint::None, 0.0)]); + let b = &emf(&d).bytes; + assert_eq!(sniff(b), Some(Kind::Emf)); + assert_eq!(u32_at(b, 0), 1); + assert_eq!(u32_at(b, 40), 0x464D_4520, "the \" EMF\" signature"); + // rclFrame. + assert_eq!([i32_at(b, 24), i32_at(b, 28), i32_at(b, 32), i32_at(b, 36)], [0, 0, 2540, 5080]); + // rclBounds, in device units of 0.01 mm: the rectangle. + assert_eq!([i32_at(b, 8), i32_at(b, 12), i32_at(b, 16), i32_at(b, 20)], [1270, 1270, 1905, 2540]); + // The reference device has 100 pixels a millimetre. + let (dev, mm) = ((i32_at(b, 72), i32_at(b, 76)), (i32_at(b, 80), i32_at(b, 84))); + assert_eq!(dev, (mm.0 * 100, mm.1 * 100)); + assert_eq!((u32_at(b, 100), u32_at(b, 104)), (mm.0 as u32 * 1000, mm.1 as u32 * 1000), "micrometres"); + // The description names the app. + let n = u32_at(b, 60) as usize; + let off = u32_at(b, 64) as usize; + assert_eq!(off, 108); + let desc: Vec = (0..n).map(|i| u16_at(b, off + 2 * i)).collect(); + assert!(String::from_utf16_lossy(&desc).starts_with("VectorCraft\0")); + // An empty picture has empty bounds. + let empty = emf(&doc_with(72.0, 72.0, vec![])); + assert_eq!([i32_at(&empty.bytes, 8), i32_at(&empty.bytes, 16)], [0, -1]); +} + +#[test] +fn emf_record_sizes_add_up_to_the_header_totals() { + let mut d = doc_with(200.0, 100.0, vec![]); + let img = image_node(&mut d, png(3, 2, [0, 128, 0, 255]), 3, 2, Affine::translate((10.0, 10.0)) * Affine::scale(10.0)); + add(&mut d, img); + add(&mut d, path(shapes::ellipse(Rect::new(50.0, 10.0, 150.0, 90.0)), red(), blue(), 2.0)); + let b = emf(&d).bytes; + let recs = emf_records(&b); + assert_eq!(u32_at(&b, 48) as usize, b.len(), "nBytes"); + assert_eq!(u32_at(&b, 52) as usize, recs.len(), "nRecords"); + assert_eq!(u16_at(&b, 56), 3, "nHandles: the reserved slot, a brush and a pen"); + let (last, eof) = recs.last().copied().unwrap(); + assert_eq!((last, eof.len()), (14, 20), "EMR_EOF ends the file"); + assert_eq!(u32_at(eof, 16), 20, "nSizeLast"); + // Fixed-size records have their fixed sizes. + for (k, r) in &recs { + let fixed = match k { + 18 | 19 | 34 | 37 | 40 | 58 | 67 => Some(12), + 27 | 54 => Some(16), + 33 | 59 | 60 | 61 => Some(8), + 39 | 62 | 64 => Some(24), + 35 => Some(32), + 36 => Some(36), + _ => None, + }; + if let Some(f) = fixed { + assert_eq!(r.len(), f, "record {k}"); + } + } + // StretchDIBits: its bitmap where its offsets say, 3 × 2 pixels of 24 bits (rows of 12 bytes). + let sdib = recs.iter().find(|r| r.0 == 81).unwrap().1; + assert_eq!([u32_at(sdib, 48), u32_at(sdib, 52), u32_at(sdib, 56), u32_at(sdib, 60)], [80, 40, 120, 24]); + assert_eq!(sdib.len(), 144); +} + +#[test] +fn emf_paths_keep_their_curves_brushes_and_pens() { + let mut e = path(shapes::ellipse(Rect::new(10.0, 10.0, 60.0, 40.0)), red(), blue(), 4.0); + if let Some(st) = e.appearance.stroke_mut() { + st.cap = LineCap::Round; + st.join = LineJoin::Bevel; + } + let b = emf(&doc_with(100.0, 100.0, vec![e])).bytes; + let recs = emf_records(&b); + let k = kinds(&recs); + assert!(k.contains(&5), "EMR_POLYBEZIERTO keeps the curves"); + assert!(k.contains(&62) && k.contains(&64), "filled, then stroked"); + let brush = recs.iter().find(|r| r.0 == 39).unwrap().1; + assert_eq!(u32_at(brush, 16), 0x0000_00ff, "a red COLORREF"); + let pen = recs.iter().find(|r| r.0 == 95).unwrap().1; + let style = u32_at(pen, 28); + assert_eq!(style & 0x000f_0000, 0x0001_0000, "geometric"); + assert_eq!(style & 0xf00, 0, "round caps"); + assert_eq!(style & 0xf000, 0x1000, "bevel joins"); + assert_eq!(u32_at(pen, 32), (4.0 * 2540.0 / 72.0f64).round() as u32, "4 pt in 0.01 mm"); + assert_eq!(u32_at(pen, 40), 0x00ff_0000, "a blue COLORREF"); + assert!(recs.iter().any(|r| r.0 == 19 && u32_at(r.1, 8) == 2), "WINDING for non-zero"); + // One brush and one pen for two shapes of the same colours. + let two = doc_with( + 100.0, + 100.0, + vec![ + path(shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), red(), blue(), 1.0), + path(shapes::rectangle(Rect::new(20.0, 0.0, 30.0, 10.0)), red(), blue(), 1.0), + ], + ); + let k = kinds(&emf_records(&emf(&two).bytes)); + assert_eq!((count(&k, 39), count(&k, 95)), (1, 1)); +} + +#[test] +fn emf_dashes_are_pen_styles() { + let mut l = path(shapes::line(Point::new(10.0, 10.0), Point::new(90.0, 10.0)), Paint::None, blue(), 2.0); + l.appearance.stroke_mut().unwrap().dash = Some(Dash { pattern: vec![6.0, 3.0], offset: 0.0, align_corners: false }); + let b = emf(&doc_with(100.0, 100.0, vec![l.clone()])).bytes; + let recs = emf_records(&b); + let pen = recs.iter().find(|r| r.0 == 95).unwrap().1; + assert_eq!(u32_at(pen, 28) & 0xf, 7, "PS_USERSTYLE"); + assert_eq!(u32_at(pen, 48), 2); + assert_eq!([u32_at(pen, 52), u32_at(pen, 56)], [(6.0 * 2540.0 / 72.0f64).round() as u32, (3.0 * 2540.0 / 72.0f64).round() as u32]); + // With an offset, the dashes are separate lines drawn with a solid pen. + l.appearance.stroke_mut().unwrap().dash = Some(Dash { pattern: vec![6.0, 3.0], offset: 2.0, align_corners: false }); + let b = emf(&doc_with(100.0, 100.0, vec![l])).bytes; + let recs = emf_records(&b); + assert_eq!(u32_at(recs.iter().find(|r| r.0 == 95).unwrap().1, 28) & 0xf, 0); + assert!(count(&kinds(&recs), 27) > 5, "a move-to for each dash"); +} + +#[test] +fn emf_clips_gradients_and_images() { + let g = Paint::Gradient(Box::new(GradientPaint::new(Gradient::default()))); + let mut d = doc_with(100.0, 100.0, vec![path(shapes::rectangle(Rect::new(10.0, 10.0, 60.0, 30.0)), g, Paint::None, 0.0)]); + let opaque = image_node(&mut d, png(4, 4, [10, 20, 30, 255]), 4, 4, Affine::translate((0.0, 50.0))); + let clear = image_node(&mut d, png(4, 4, [10, 20, 30, 100]), 4, 4, Affine::translate((50.0, 50.0)) * Affine::rotate(0.3)); + add(&mut d, opaque); + add(&mut d, clear); + let out = emf(&d); + assert!(out.warnings.is_empty(), "{:?}", out.warnings); + let recs = emf_records(&out.bytes); + let k = kinds(&recs); + // The region's clip, then the gradient's clip around its image. + assert_eq!(count(&k, 67), 2); + assert_eq!(count(&k, 33), count(&k, 34), "every save is restored"); + // The (opaque) gradient's image and the opaque image are StretchDIBits; the translucent one + // AlphaBlend with per-pixel alpha. + assert_eq!(count(&k, 81), 2); + let ab = recs.iter().find(|r| r.0 == 114).unwrap().1; + assert_eq!(&ab[40..44], &[0, 0, 255, 1]); + // Images are placed by a world transform, reset after each. + assert_eq!((count(&k, 35), count(&k, 36)), (3, 3)); +} + +#[test] +fn type_is_written_as_outlines() { + let t = TextObject::point(Point::new(10.0, 50.0), "Hi", CharStyle { fill: red(), ..CharStyle::default() }); + let d = doc_with(100.0, 100.0, vec![Node::new(NodeId(0), NodeKind::Text(Box::new(t)))]); + let k = kinds(&emf_records(&emf(&d).bytes)); + assert!(k.contains(&62), "glyphs are filled paths"); + assert!(!k.contains(&84) && !k.contains(&83), "no text records"); +} + +#[test] +fn wmf_placeable_header_checksum_and_record_sizes() { + let d = doc_with(72.0, 144.0, vec![path(shapes::ellipse(Rect::new(10.0, 10.0, 60.0, 40.0)), red(), blue(), 1.0)]); + let out = wmf(&d); + let b = &out.bytes; + assert_eq!(sniff(b), Some(Kind::Wmf)); + assert_eq!(u32_at(b, 0), 0x9AC6_CDD7); + // The box in logical units at 1440 an inch: 1 × 2 inches. + assert_eq!([u16_at(b, 6), u16_at(b, 8), u16_at(b, 10), u16_at(b, 12)], [0, 0, 1440, 2880]); + assert_eq!(u16_at(b, 14), 1440); + let xor = (0..10).fold(0u16, |a, i| a ^ u16_at(b, 2 * i)); + assert_eq!(u16_at(b, 20), xor, "the checksum"); + assert_eq!(crate::wmf::checksum(&b[..20]), xor); + // The WMF header: a memory metafile of 9 words, its size in words, the largest record. + assert_eq!([u16_at(b, 22), u16_at(b, 24), u16_at(b, 26)], [1, 9, 0x0300]); + assert_eq!(u32_at(b, 28) as usize * 2, b.len() - 22); + let recs = wmf_records(b); + let largest = recs.iter().map(|r| r.1.len() / 2).max().unwrap(); + assert_eq!(u32_at(b, 34) as usize, largest, "MaxRecord"); + assert_eq!(recs.last().map(|r| (r.0, r.1.len())), Some((0, 6)), "META_EOF ends the file"); + // Objects: the null pen and brush, the brush, the pen. + assert_eq!(u16_at(b, 32), 4); + // The curve is flattened into a polygon, its outline a polygon drawn with the null brush. + let poly = recs.iter().find(|r| r.0 == 0x0538).unwrap().1; + assert_eq!(u16_at(poly, 6), 1); + assert!(u16_at(poly, 8) > 16, "many points"); + assert!(recs.iter().any(|r| r.0 == 0x0324)); + assert!(out.warnings.is_empty(), "{:?}", out.warnings); +} + +#[test] +fn wmf_sizes_fit_sixteen_bits() { + assert_eq!(crate::wmf::units_per_inch(612.0, 792.0), 1440); + let inch = crate::wmf::units_per_inch(3000.0, 1000.0); + assert!(inch < 1440 && f64::from(inch) * 3000.0 / 72.0 <= 32767.0); + let d = doc_with(3000.0, 1000.0, vec![path(shapes::rectangle(Rect::new(0.0, 0.0, 3000.0, 1000.0)), red(), Paint::None, 0.0)]); + let b = wmf(&d).bytes; + assert_eq!(u16_at(&b, 14), inch); + assert!(u16_at(&b, 10) <= 32767); +} + +#[test] +fn wmf_reports_what_it_leaves_out() { + let g = Paint::Gradient(Box::new(GradientPaint::new(Gradient::default()))); + let mut clip = path(shapes::rectangle(Rect::new(0.0, 0.0, 20.0, 20.0)), Paint::None, Paint::None, 0.0); + if let NodeKind::Path { clipping, .. } = &mut clip.kind { + *clipping = true; + } + let inside = path(shapes::rectangle(Rect::new(5.0, 5.0, 40.0, 40.0)), g, Paint::None, 0.0); + let group = Node::new(NodeId(0), NodeKind::Group { children: vec![Arc::new(clip), Arc::new(inside)], clip: true }); + let mut d = doc_with(100.0, 100.0, vec![group]); + let img = image_node(&mut d, png(2, 2, [0, 0, 0, 10]), 2, 2, Affine::translate((50.0, 50.0)) * Affine::rotate(0.5)); + add(&mut d, img); + let w = wmf(&d).warnings.join("\n"); + for what in ["clipping masks", "gradients", "transparency", "rotate"] { + assert!(w.contains(what), "{what}: {w}"); + } + // EMF keeps all of it. + let e = emf(&d).warnings; + assert!(e.is_empty(), "{e:?}"); +} + +#[test] +fn hidden_and_offboard_art_is_left_out() { + let mut hidden = path(shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), red(), Paint::None, 0.0); + hidden.visible = false; + let away = path(shapes::rectangle(Rect::new(500.0, 500.0, 510.0, 510.0)), red(), Paint::None, 0.0); + let d = doc_with(100.0, 100.0, vec![hidden, away]); + let k = kinds(&emf_records(&emf(&d).bytes)); + assert!(!k.contains(&62) && !k.contains(&39)); + assert!(export(&d, Rect::new(0.0, 0.0, 0.0, 10.0), Kind::Emf).is_err(), "an empty region"); +} diff --git a/crates/metafile/src/wmf.rs b/crates/metafile/src/wmf.rs new file mode 100644 index 00000000..5ef36bf0 --- /dev/null +++ b/crates/metafile/src/wmf.rs @@ -0,0 +1,441 @@ +//! The WMF writer: a placeable header (the picture's size and logical units an inch, with its +//! checksum), the WMF header, then the drawing in 16-bit logical units with an anisotropic window +//! the size of the picture, and the end-of-file record. +//! +//! Curves are flattened into polygons (fills) and polylines (strokes, with pens of the stroke's +//! width, caps and joins; dashes as separate lines). Objects take the lowest free slot of the +//! object table, as players number them: slots 0 and 1 hold a null pen and a null brush, our pen +//! and brush come after and are deleted when replaced. Images are StretchDIB records of 24-bit +//! bitmaps composited over white. + +use kurbo::PathEl; +use vectorcraft_doc::{LineCap, LineJoin}; +use vectorcraft_geom::{Affine, BezPath, FillRule, Point, Rect}; + +use crate::bytes::Out; +use crate::dib::{self, Rgba}; +use crate::emf::{PS_ENDCAP_FLAT, PS_ENDCAP_SQUARE, PS_JOIN_BEVEL, PS_JOIN_MITER, SRCCOPY, colorref}; +use crate::scene::{Op, Pen}; + +/// The key a placeable header starts with. +pub(crate) const PLACEABLE_KEY: u32 = 0x9AC6_CDD7; + +// Record functions (MS-WMF 2.1.1.1). +pub(crate) const EOF: u16 = 0x0000; +pub(crate) const SETBKMODE: u16 = 0x0102; +pub(crate) const SETMAPMODE: u16 = 0x0103; +pub(crate) const SETPOLYFILLMODE: u16 = 0x0106; +pub(crate) const SETWINDOWORG: u16 = 0x020B; +pub(crate) const SETWINDOWEXT: u16 = 0x020C; +pub(crate) const SELECTOBJECT: u16 = 0x012D; +pub(crate) const DELETEOBJECT: u16 = 0x01F0; +pub(crate) const CREATEPENINDIRECT: u16 = 0x02FA; +pub(crate) const CREATEBRUSHINDIRECT: u16 = 0x02FC; +pub(crate) const POLYGON: u16 = 0x0324; +pub(crate) const POLYLINE: u16 = 0x0325; +pub(crate) const POLYPOLYGON: u16 = 0x0538; +pub(crate) const STRETCHDIB: u16 = 0x0F43; + +/// Most logical units an inch (twips, what office apps use). +const MAX_INCH: f64 = 1440.0; +/// The largest 16-bit coordinate. +const MAX_I16: f64 = 32767.0; +/// Most points a polygon or polyline record holds (its count is 16-bit). +const MAX_POINTS: usize = 32767; +/// Curves are flattened to within this many logical units. +const FLATNESS: f64 = 0.25; +/// The null pen's and the null brush's slots. +const NULL_PEN: u16 = 0; +const NULL_BRUSH: u16 = 1; + +const TRANSLUCENT_IMAGES: &str = "WMF has no transparency: images are written over white"; +const TURNED_IMAGES: &str = "WMF can't rotate or skew images: they are written upright, filling their bounds"; + +/// Does `bytes` start like a WMF (placeable, or a plain memory or disk metafile header)? +pub(crate) fn is_wmf(bytes: &[u8]) -> bool { + let u16_at = |at: usize| bytes.get(at..at + 2).and_then(|b| b.try_into().ok()).map(u16::from_le_bytes); + let placeable = bytes.get(..4).is_some_and(|b| b == PLACEABLE_KEY.to_le_bytes()); + placeable || (matches!(u16_at(0), Some(1 | 2)) && u16_at(2) == Some(9) && matches!(u16_at(4), Some(0x0100 | 0x0300))) +} + +/// The logical units an inch for a picture `w` × `h` points: the most, up to 1440, that keep its +/// size within 16 bits. +pub(crate) fn units_per_inch(w: f64, h: f64) -> u16 { + let side_in = w.max(h) / 72.0; + let fit = if side_in > 0.0 { (MAX_I16 / side_in).floor() } else { MAX_INCH }; + fit.clamp(1.0, MAX_INCH) as u16 +} + +/// The XOR of the first ten 16-bit words of a placeable header. +pub(crate) fn checksum(header: &[u8]) -> u16 { + header.chunks_exact(2).take(10).fold(0, |acc, w| acc ^ u16::from_le_bytes([w[0], w[1]])) +} + +/// `bp` flattened to within `tol`: each subpath's points and whether it closes. +fn flatten(bp: &BezPath, tol: f64) -> Vec<(Vec, bool)> { + let mut out: Vec<(Vec, bool)> = vec![]; + let mut cur: Vec = vec![]; + kurbo::flatten(bp.iter(), tol, |el| match el { + PathEl::MoveTo(p) => { + if cur.len() > 1 { + out.push((std::mem::take(&mut cur), false)); + } + cur = vec![p]; + } + PathEl::LineTo(p) => cur.push(p), + PathEl::ClosePath => { + if cur.len() > 1 { + out.push((std::mem::take(&mut cur), true)); + } + cur.clear(); + } + _ => {} + }); + if cur.len() > 1 { + out.push((cur, false)); + } + out +} + +/// What a pen object is made of. +#[derive(Clone, Copy, PartialEq)] +struct PenKey { + style: u16, + width: i16, + color: u32, +} + +struct Writer { + /// The records after the headers. + out: Out, + max_record: u32, + to_dev: Affine, + /// Logical units per point. + k: f64, + /// Which object slots are taken. + slots: Vec, + brush: Option<(u16, u32)>, + pen: Option<(u16, PenKey)>, + sel_pen: u16, + sel_brush: u16, + fill_mode: Option, +} + +/// Write `ops` (curves flattened, no clips) as a placeable WMF of `region` (document space). +pub(crate) fn write(ops: &[Op], region: Rect, warnings: &mut Vec) -> Vec { + let inch = units_per_inch(region.width(), region.height()); + let k = f64::from(inch) / 72.0; + let size = |v: f64| (v * k).round().clamp(1.0, MAX_I16) as i16; + let (w, h) = (size(region.width()), size(region.height())); + let mut wr = Writer { + out: Out::default(), + max_record: 0, + to_dev: Affine::scale(k) * Affine::translate(-region.origin().to_vec2()), + k, + slots: vec![], + brush: None, + pen: None, + sel_pen: NULL_PEN, + sel_brush: NULL_BRUSH, + fill_mode: None, + }; + // MM_ANISOTROPIC, a window the size of the picture, transparent background. + wr.record(SETMAPMODE, |o| o.u16(8)); + wr.record(SETWINDOWORG, |o| { + o.i16(0); + o.i16(0); + }); + wr.record(SETWINDOWEXT, |o| { + o.i16(h); + o.i16(w); + }); + wr.record(SETBKMODE, |o| o.u16(1)); + // The null pen and brush (slots 0 and 1), selected while ours change. + wr.alloc(); + wr.record(CREATEPENINDIRECT, |o| { + o.u16(5); + o.i16(0); + o.i16(0); + o.u32(0); + }); + wr.alloc(); + wr.record(CREATEBRUSHINDIRECT, |o| { + o.u16(1); + o.u32(0); + o.u16(0); + }); + wr.select(NULL_PEN); + wr.select(NULL_BRUSH); + let mut translucent = false; + let mut turned = false; + for op in ops { + match op { + Op::Fill { path, rule, rgb } => wr.fill(path, *rule, *rgb), + Op::Stroke { path, pen } => wr.stroke(path, pen), + Op::Image { image, xf, opacity } => { + translucent |= !image.opaque() || *opacity < 0.998; + turned |= !wr.image(image, *xf, *opacity); + } + // The scene gives formats without clipping none. + Op::Clip { .. } | Op::Unclip => {} + } + } + if let Some((slot, _)) = wr.brush.take() { + wr.select(NULL_BRUSH); + wr.delete(slot); + } + if let Some((slot, _)) = wr.pen.take() { + wr.select(NULL_PEN); + wr.delete(slot); + } + wr.record(EOF, |_| {}); + for (on, w) in [(translucent, TRANSLUCENT_IMAGES), (turned, TURNED_IMAGES)] { + if on && !warnings.iter().any(|x| x == w) { + warnings.push(w.to_string()); + } + } + wr.finish(w, h, inch) +} + +impl Writer { + /// Append one record: its size in 16-bit words, its function and `body`. + fn record(&mut self, function: u16, body: impl FnOnce(&mut Out)) { + let start = self.out.len(); + self.out.u32(0); + self.out.u16(function); + body(&mut self.out); + self.out.pad(2); + let words = u32::try_from((self.out.len() - start) / 2).unwrap_or(u32::MAX); + self.out.set_u32(start, words); + self.max_record = self.max_record.max(words); + } + + /// The placeable header, the WMF header, then the records. + fn finish(self, w: i16, h: i16, inch: u16) -> Vec { + let mut o = Out::default(); + o.u32(PLACEABLE_KEY); + o.u16(0); + for v in [0, 0, w, h] { + o.i16(v); + } + o.u16(inch); + o.u32(0); + let sum = checksum(&o.0); + o.u16(sum); + // A memory metafile, 9 words of header, version 3. + o.u16(1); + o.u16(9); + o.u16(0x0300); + o.u32(u32::try_from((18 + self.out.len()) / 2).unwrap_or(u32::MAX)); + o.u16(u16::try_from(self.slots.len()).unwrap_or(u16::MAX)); + o.u32(self.max_record); + o.u16(0); + o.bytes(&self.out.0); + o.0 + } + + /// The lowest free object slot, taken. + fn alloc(&mut self) -> u16 { + let i = match self.slots.iter().position(|t| !t) { + Some(i) => i, + None => { + self.slots.push(false); + self.slots.len() - 1 + } + }; + if let Some(s) = self.slots.get_mut(i) { + *s = true; + } + u16::try_from(i).unwrap_or(u16::MAX) + } + + fn select(&mut self, slot: u16) { + self.record(SELECTOBJECT, |o| o.u16(slot)); + } + + fn delete(&mut self, slot: u16) { + self.record(DELETEOBJECT, |o| o.u16(slot)); + if let Some(s) = self.slots.get_mut(usize::from(slot)) { + *s = false; + } + } + + fn dev(&self, p: Point) -> [i16; 2] { + let q = self.to_dev * p; + let c = |v: f64| if v.is_finite() { v.round().clamp(-MAX_I16, MAX_I16) as i16 } else { 0 }; + [c(q.x), c(q.y)] + } + + /// The device points of `pts`, repeats dropped, thinned to what one record holds. + fn points(&self, pts: &[Point]) -> Vec<[i16; 2]> { + let mut v: Vec<[i16; 2]> = vec![]; + for p in pts { + let d = self.dev(*p); + if v.last() != Some(&d) { + v.push(d); + } + } + if v.len() > MAX_POINTS { + let step = v.len().div_ceil(MAX_POINTS - 1); + let last = v.last().copied(); + v = v.into_iter().step_by(step).collect(); + v.extend(last); + } + v + } + + fn select_pen(&mut self, slot: u16) { + if self.sel_pen != slot { + self.select(slot); + self.sel_pen = slot; + } + } + + fn select_brush(&mut self, slot: u16) { + if self.sel_brush != slot { + self.select(slot); + self.sel_brush = slot; + } + } + + fn use_brush(&mut self, rgb: [u8; 3]) { + let color = colorref(rgb); + let slot = match self.brush { + Some((slot, c)) if c == color => slot, + old => { + if let Some((slot, _)) = old { + self.select_brush(NULL_BRUSH); + self.delete(slot); + } + let slot = self.alloc(); + self.record(CREATEBRUSHINDIRECT, |o| { + o.u16(0); + o.u32(color); + o.u16(0); + }); + self.brush = Some((slot, color)); + slot + } + }; + self.select_brush(slot); + } + + fn use_pen(&mut self, p: &Pen) { + let cap = match p.cap { + LineCap::Butt => PS_ENDCAP_FLAT, + LineCap::Round => 0, + LineCap::Square => PS_ENDCAP_SQUARE, + }; + let join = match p.join { + LineJoin::Miter => PS_JOIN_MITER, + LineJoin::Round => 0, + LineJoin::Bevel => PS_JOIN_BEVEL, + }; + let key = PenKey { style: (cap | join) as u16, width: (p.width * self.k).round().clamp(1.0, MAX_I16) as i16, color: colorref(p.rgb) }; + let slot = match self.pen { + Some((slot, k)) if k == key => slot, + old => { + if let Some((slot, _)) = old { + self.select_pen(NULL_PEN); + self.delete(slot); + } + let slot = self.alloc(); + self.record(CREATEPENINDIRECT, |o| { + o.u16(key.style); + o.i16(key.width); + o.i16(0); + o.u32(key.color); + }); + self.pen = Some((slot, key)); + slot + } + }; + self.select_pen(slot); + } + + fn fill(&mut self, bp: &BezPath, rule: FillRule, rgb: [u8; 3]) { + let polys: Vec> = flatten(bp, FLATNESS / self.k).iter().map(|(p, _)| self.points(p)).filter(|p| p.len() > 2).collect(); + if polys.is_empty() { + return; + } + // ALTERNATE (even-odd) or WINDING (non-zero). + let mode = if rule == FillRule::EvenOdd { 1 } else { 2 }; + if self.fill_mode != Some(mode) { + self.record(SETPOLYFILLMODE, |o| o.u16(mode)); + self.fill_mode = Some(mode); + } + self.use_brush(rgb); + self.select_pen(NULL_PEN); + for chunk in polys.chunks(usize::from(u16::MAX)) { + self.record(POLYPOLYGON, |o| { + o.u16(chunk.len() as u16); + chunk.iter().for_each(|p| o.u16(p.len() as u16)); + for p in chunk.iter().flatten() { + o.i16(p[0]); + o.i16(p[1]); + } + }); + } + } + + fn stroke(&mut self, bp: &BezPath, pen: &Pen) { + let path = crate::scene::dashed(bp, pen.dash.as_ref()); + let lines: Vec<(Vec<[i16; 2]>, bool)> = + flatten(&path, FLATNESS / self.k).iter().map(|(p, closed)| (self.points(p), *closed)).filter(|(p, _)| p.len() > 1).collect(); + if lines.is_empty() { + return; + } + self.use_pen(pen); + self.select_brush(NULL_BRUSH); + for (pts, closed) in lines { + // A closed subpath is a polygon (with the null brush, its outline), so it joins at + // its start. + self.record(if closed { POLYGON } else { POLYLINE }, |o| { + o.i16(pts.len() as i16); + for p in &pts { + o.i16(p[0]); + o.i16(p[1]); + } + }); + } + } + + /// An image placed by `xf`, upright: false when `xf` rotates or skews it (then it fills its + /// bounds). + fn image(&mut self, img: &Rgba, xf: Affine, opacity: f32) -> bool { + if img.width == 0 || img.height == 0 || opacity <= 0.0 { + return true; + } + let m = self.to_dev * xf; + if !m.as_coeffs().iter().all(|v| v.is_finite()) { + return true; + } + let [a, b, c, d, e, f] = m.as_coeffs(); + let (w, h) = (f64::from(img.width), f64::from(img.height)); + let upright = b.abs() < 1e-9 && c.abs() < 1e-9; + let (x, y, dw, dh) = if upright { + (e, f, a * w, d * h) + } else { + let r = m.transform_rect_bbox(Rect::new(0.0, 0.0, w, h)); + (r.x0, r.y0, r.width(), r.height()) + }; + let s = |v: f64| v.round().clamp(-MAX_I16, MAX_I16) as i16; + let (bmi, bits) = dib::rgb24(img, opacity); + self.record(STRETCHDIB, |o| { + o.u32(SRCCOPY); + // DIB_RGB_COLORS; the source and destination rectangles, last field first. + o.u16(0); + o.i16(img.height as i16); + o.i16(img.width as i16); + o.i16(0); + o.i16(0); + o.i16(s(dh)); + o.i16(s(dw)); + o.i16(s(y)); + o.i16(s(x)); + o.bytes(&bmi); + o.bytes(&bits); + }); + upright + } +} diff --git a/crates/ui-egui/src/dialogs/mod.rs b/crates/ui-egui/src/dialogs/mod.rs index 9639e5fd..7048602b 100644 --- a/crates/ui-egui/src/dialogs/mod.rs +++ b/crates/ui-egui/src/dialogs/mod.rs @@ -344,3 +344,6 @@ mod tests_screens; #[cfg(test)] mod tests_eps; + +#[cfg(test)] +mod tests_metafile; diff --git a/crates/ui-egui/src/dialogs/tests_metafile.rs b/crates/ui-egui/src/dialogs/tests_metafile.rs new file mode 100644 index 00000000..6147aa6c --- /dev/null +++ b/crates/ui-egui/src/dialogs/tests_metafile.rs @@ -0,0 +1,37 @@ +//! Export As → EMF and WMF, drawn headlessly: listed with the other formats, written straight +//! away (no options dialog), one file per artboard with Use Artboards. + +use serde_json::json; + +use super::tests_export::{app, frame, kind, names, set}; +use super::*; + +/// Does `b` carry the EMF header's signature, or start with the placeable WMF key? +fn is_emf(b: &[u8]) -> bool { + b.get(40..44) == Some(&b" EMF"[..]) +} + +fn is_wmf(b: &[u8]) -> bool { + b.get(..4) == Some(&[0xD7, 0xCD, 0xC6, 0x9A][..]) +} + +#[test] +fn export_as_lists_emf_and_wmf_and_writes_them() { + let (mut app, written) = app(2); + app.run("file.exportAs", json!({"format": "emf"})).unwrap(); + assert_eq!(kind(&app), Some("exportAs")); + assert_eq!(app.ui.dialog.as_ref().unwrap().str("format"), "emf"); + frame(&mut app); + let r = confirm(&mut app).unwrap(); + assert!(app.ui.dialog.is_none(), "no options dialog follows"); + assert_eq!(r["path"], "/out/Untitled-1.emf"); + assert!(is_emf(&written.borrow()[0].1)); + // WMF, one file per artboard. + app.run("file.exportAs", json!({"format": "wmf"})).unwrap(); + set(&mut app, "useArtboards", json!(true)); + frame(&mut app); + confirm(&mut app).unwrap(); + let n = names(&written); + assert_eq!(&n[1..], ["/out/Untitled-1-Artboard-1.wmf", "/out/Untitled-1-Artboard-2.wmf"]); + assert!(written.borrow()[1..].iter().all(|(_, b)| is_wmf(b))); +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 477a7020..916833d2 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -361,3 +361,6 @@ Native save options: File → Save As to a native or `.ai` file opens the `saveO artboard; false: `range`, such as "1-3, 5"), `includeLinked`, `embedProfiles`, `pdfCompatible`, `compress`, and for native files `version` and `preview`. A bad range keeps the dialog open; `ui.dialog.confirm` writes the file and the 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). diff --git a/docs/mcp.md b/docs/mcp.md index 90e04dd6..a57cb931 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1338,3 +1338,20 @@ and Save as Template reuse the options as last saved (or Use Compression). {"name":"run_command","arguments":{"command":"file.saveAs","params":{"path":"/tmp/set.vectorcraft","separateArtboards":true,"range":"2-3","includeLinked":true}}} {"name":"run_command","arguments":{"command":"file.saveAs","params":{"path":"/tmp/set.ai","pdfCompatible":false}}} ``` + +## EMF and WMF + +`document.export` (and `export`) with format `emf` or `wmf` writes a Windows metafile of the first artboard (or +`artboard`); `useArtboards: true` writes one file per chosen artboard (`{stem}-{artboard}.emf`), `false` the bounds of +the visible art. EMF keeps Bézier curves, solid fills (brushes), strokes as geometric pens with their width, caps, +joins, miter limit and dashes, clipping masks (path clips), images with their transparency (AlphaBlend) and type as +outlines; gradients become images clipped to their shape, pattern fills their tiles clipped to the shape, and raster +effects images rendered at the document's raster effects resolution. The header's frame is the artboard in 0.01 mm. +WMF flattens curves into polygons in 16-bit units behind a placeable header (1440 units an inch, fewer for pictures +over about 22 inches); it has no clipping, transparency or gradients, so clipped art is written whole, images over +white and gradients and patterns as one colour. What a format leaves out comes back in `warnings`. + +```json +{"name":"export","arguments":{"path":"/tmp/logo.emf"}} +{"name":"run_command","arguments":{"command":"document.export","params":{"format":"wmf","useArtboards":true,"path":"/tmp/icons.wmf"}}} +``` diff --git a/xtask/src/layers.rs b/xtask/src/layers.rs index f11edc97..66956324 100644 --- a/xtask/src/layers.rs +++ b/xtask/src/layers.rs @@ -58,6 +58,7 @@ pub const TABLE: &[(&str, Class)] = &[ // Crates added later (append-only). ("cad", Class::Layer(3)), ("eps", Class::Layer(3)), + ("metafile", Class::Layer(3)), ]; /// Explicit orderings *within* a layer (earlier may be used by later).