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/clipboard.rs b/crates/engine/src/cmd/clipboard.rs index 21bfcb80..a180bde5 100644 --- a/crates/engine/src/cmd/clipboard.rs +++ b/crates/engine/src/cmd/clipboard.rs @@ -21,7 +21,7 @@ use vectorcraft_color::Swatch; use vectorcraft_doc::{Document, GraphicStyle, ImageBlob, Node, NodeId, PatternDef, Symbol, TextStyleDef}; use vectorcraft_geom::Rect; -pub use flavours::{BITMAP, Flavour, PASTE_ORDER, PDF, PNG, SVG, TEXT}; +pub use flavours::{BITMAP, EMF, Flavour, PASTE_ORDER, PDF, PNG, SVG, TEXT}; pub(crate) use resources::SwatchChoices; use super::*; diff --git a/crates/engine/src/cmd/clipboard/flavours.rs b/crates/engine/src/cmd/clipboard/flavours.rs index 083470a0..aeda0aeb 100644 --- a/crates/engine/src/cmd/clipboard/flavours.rs +++ b/crates/engine/src/cmd/clipboard/flavours.rs @@ -1,5 +1,5 @@ //! Clipboard flavours: what Copy offers other apps besides SVG (PNG, PDF and plain text) and what -//! Paste takes from them (bitmaps, plain text and PDF). +//! Paste takes from them (bitmaps, plain text, PDF and Windows metafiles). //! //! The UI owns the platform clipboard (`Services::system_clipboard` in the egui frontend): on Copy //! it publishes [`Session::clipboard_flavours`]; before a Paste it turns what another app put on @@ -24,9 +24,12 @@ pub const PDF: &str = "application/pdf"; pub const PNG: &str = "image/png"; /// Any bitmap (PNG, BMP…), in what Paste asks the system clipboard for. pub const BITMAP: &str = "image/*"; +/// An EMF (or WMF) picture: what Windows apps copy as vector art (the system clipboard reads it +/// where the platform offers it). +pub const EMF: &str = "image/emf"; /// What Paste reads from other apps, best first: vector art before text, text before bitmaps /// (word processors offer a picture of copied text too). -pub const PASTE_ORDER: [&str; 4] = [SVG, PDF, TEXT, BITMAP]; +pub const PASTE_ORDER: [&str; 5] = [SVG, PDF, EMF, TEXT, BITMAP]; /// One representation of the clipboard's contents. #[derive(Clone, Debug, PartialEq, Eq)] @@ -105,6 +108,15 @@ pub fn specs() -> Vec { has_doc, import_pdf ), + cmd!( + query "clipboard.importEmf", + "Load Metafile into Clipboard", + [], + None, + "{dataBase64 (EMF or WMF bytes), center?: [x, y]} replace the clipboard with the picture's objects (paths, clipping groups, images, point type; the images they use), centred on `center` (default: the first artboard) → {count, warnings}; records VectorCraft doesn't read are skipped with one warning; then run edit.paste", + has_doc, + import_emf + ), ] } @@ -277,6 +289,21 @@ fn import_text(s: &mut Session, p: &Value) -> Result { Ok(json!({ "count": count })) } +fn import_emf(s: &mut Session, p: &Value) -> Result { + const C: &str = "clipboard.importEmf"; + let bytes = data_param(p, C)?; + if vectorcraft_metafile::sniff(&bytes).is_none() { + return Err(bad(C, "the data isn't an EMF or WMF picture")); + } + let loaded = fileio::load("Clipboard.emf", &bytes).map_err(|e| bad(C, e.to_string()))?; + let clip = Clipboard::from_document(&loaded.doc); + if clip.is_empty() { + return Err(bad(C, "the picture has no objects")); + } + let count = s.load_clipboard(clip, p)?; + Ok(json!({ "count": count, "warnings": loaded.warnings })) +} + fn import_pdf(s: &mut Session, p: &Value) -> Result { const C: &str = "clipboard.importPdf"; let bytes = data_param(p, C)?; 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/load.rs b/crates/engine/src/cmd/fileio/load.rs index de624ab4..b02ac1b4 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -62,6 +62,9 @@ pub fn detect(name: &str, bytes: &[u8]) -> Option<&'static Format> { if vectorcraft_cad::is_dxf(bytes) { return format("dxf"); } + if let Some(kind) = vectorcraft_metafile::sniff(bytes) { + return format(kind.id()); + } if let Some(f) = image::guess_format(bytes).ok().and_then(image_format) { return Some(f); } @@ -159,6 +162,10 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result let (doc, warnings) = super::dxfimport::import(bytes, &opts.dxf)?; (doc, warnings, false) } + "emf" | "wmf" => { + let (doc, warnings) = super::metafile::import(bytes)?; + (doc, warnings, false) + } _ if format.raster => (raster_doc(&title, bytes)?, vec![], false), _ => return Err(err(format!("{} files can't be opened yet", format.label))), }; diff --git a/crates/engine/src/cmd/fileio/metafile.rs b/crates/engine/src/cmd/fileio/metafile.rs new file mode 100644 index 00000000..abff8357 --- /dev/null +++ b/crates/engine/src/cmd/fileio/metafile.rs @@ -0,0 +1,52 @@ +//! EMF and WMF for every path that writes or reads Windows metafiles (`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), `document.open` and File → Place (one artboard, the picture's frame). + +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) +} + +/// A metafile's document and the import's warnings. +pub(super) fn import(bytes: &[u8]) -> Result<(Document, Vec)> { + let i = vectorcraft_metafile::import(bytes).map_err(super::err)?; + Ok((i.document, i.warnings)) +} diff --git a/crates/engine/src/cmd/fileio/mod.rs b/crates/engine/src/cmd/fileio/mod.rs index 35fe3e5d..816b2843 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, open and place. //! //! [`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; @@ -72,7 +74,7 @@ pub fn specs() -> Vec { "Open Document", [], None, - "{path} or {name, dataBase64}, PDF/.ai: pages?: \"2-3, 5\" (1-based, default all; one artboard each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds), password? (encrypted PDFs; see document.pdfInfo), textAs?: text (default: point type per line, in the file's fonts by name; missing ones are listed in warnings) | outlines (glyph paths), layers?: true (default: optional content groups become layers with their visibility, print state and lock — art that is off comes in as a hidden layer; other art goes to a layer per page) | false (one layer per page, without the art that is off; Save then asks for a name), colorMode?: rgb|cmyk (the mode the document opens in, its colours converted as file.documentColorMode does; default: the file's — a PDF keeps CMYK, Gray and spot inks (spot swatches at a tint) and opens in CMYK when painted mostly in CMYK), DXF: dxf?: {layout?: \"Model\" (default) | a paper layout's name (document.dxfInfo lists them), unit?, scale? (the ratio: 1 unit of the art = scale drawing units; default: the drawing at 1:1 in its own unit), fit?: false (scale to fit an artboard of fitTo?: [612, 792], turned to the art's orientation), scaleLineweights?: false (lineweights scale with the art), center?: true (false: the drawing's origin on the artboard's bottom-left corner; fitted art's bottom-left corner), mergeLayers?: false (all art on one layer)} → {index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks: [{name, path, ids}]}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, ASCII .dxf (each DXF layer with art a layer; blocks symbols; what can't come in is listed in warnings), PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its pixel size). A PDF/.ai/.ait or SVG saved with Preserve Editing restores the native document it carries (restored: true; a PDF only when no pages are picked), unless the file was changed elsewhere since or the data can't be read: then its artwork is imported and the first warning says why. Templates (native templates, .ait) open as a new untitled document; a restored .ai keeps its path (Save writes .ai again). Linked images are read from their files (looked for at their path, then relative to the document): missing ones show their saved preview (links.relink), modified ones are read again only with Preferences › Update Links: Automatically (else links.update). An SVG's files (relative links from the SVG's folder) stay linked, SVG files become art, missing ones a placeholder in their box (a warning and missingLinks)", + "{path} or {name, dataBase64}, PDF/.ai: pages?: \"2-3, 5\" (1-based, default all; one artboard each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds), password? (encrypted PDFs; see document.pdfInfo), textAs?: text (default: point type per line, in the file's fonts by name; missing ones are listed in warnings) | outlines (glyph paths), layers?: true (default: optional content groups become layers with their visibility, print state and lock — art that is off comes in as a hidden layer; other art goes to a layer per page) | false (one layer per page, without the art that is off; Save then asks for a name), colorMode?: rgb|cmyk (the mode the document opens in, its colours converted as file.documentColorMode does; default: the file's — a PDF keeps CMYK, Gray and spot inks (spot swatches at a tint) and opens in CMYK when painted mostly in CMYK), DXF: dxf?: {layout?: \"Model\" (default) | a paper layout's name (document.dxfInfo lists them), unit?, scale? (the ratio: 1 unit of the art = scale drawing units; default: the drawing at 1:1 in its own unit), fit?: false (scale to fit an artboard of fitTo?: [612, 792], turned to the art's orientation), scaleLineweights?: false (lineweights scale with the art), center?: true (false: the drawing's origin on the artboard's bottom-left corner; fitted art's bottom-left corner), mergeLayers?: false (all art on one layer)} → {index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks: [{name, path, ids}]}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, ASCII .dxf (each DXF layer with art a layer; blocks symbols; what can't come in is listed in warnings), PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its pixel size), .emf/.wmf (one artboard, the picture's frame; records VectorCraft doesn't read are skipped with one warning). A PDF/.ai/.ait or SVG saved with Preserve Editing restores the native document it carries (restored: true; a PDF only when no pages are picked), unless the file was changed elsewhere since or the data can't be read: then its artwork is imported and the first warning says why. Templates (native templates, .ait) open as a new untitled document; a restored .ai keeps its path (Save writes .ai again). Linked images are read from their files (looked for at their path, then relative to the document): missing ones show their saved preview (links.relink), modified ones are read again only with Preferences › Update Links: Automatically (else links.update). An SVG's files (relative links from the SVG's folder) stay linked, SVG files become art, missing ones a placeholder in their box (a warning and missingLinks)", always, load::open ), @@ -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,11 +448,32 @@ pub const FORMATS: &[Format] = &[ raster: false, options: eps::OPTIONS, }, + Format { id: "emf", label: "EMF", extensions: &["emf"], mime: "image/emf", read: true, write: true, raster: false, options: metafile::OPTIONS }, + Format { id: "wmf", label: "WMF", extensions: &["wmf"], mime: "image/wmf", read: true, write: true, raster: false, options: metafile::OPTIONS }, ]; /// Every extension `document.open` reads (the "All readable files" filter of open dialogs). -pub const OPEN_EXTS: &[&str] = - &["vectorcraft", "drawcraft", "svg", "svgz", "pdf", "ai", "ait", "png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "vctemplate", "dxf"]; +pub const OPEN_EXTS: &[&str] = &[ + "vectorcraft", + "drawcraft", + "svg", + "svgz", + "pdf", + "ai", + "ait", + "png", + "jpg", + "jpeg", + "gif", + "webp", + "tif", + "tiff", + "bmp", + "vctemplate", + "dxf", + "emf", + "wmf", +]; /// Text files: File → Place sets them as area type (Text Import Options). pub const TEXT_EXTS: &[&str] = &["txt"]; @@ -474,6 +497,8 @@ pub const PLACE_EXTS: &[&str] = &[ "bmp", "vctemplate", "dxf", + "emf", + "wmf", "txt", ]; @@ -754,3 +779,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..9d21907a --- /dev/null +++ b/crates/engine/src/cmd/fileio/tests_metafile.rs @@ -0,0 +1,160 @@ +//! EMF and WMF through the commands: export of one artboard or each, raster effects as images, +//! open, place, paste, and the format lists. + +use serde_json::{Value, json}; +use vectorcraft_doc::NodeKind; + +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 metafiles_open_and_place() { + let mut s = two_boards(); + let emf = b64(&s.execute("document.export", &json!({"format": "emf"})).unwrap()); + let wmf = b64(&s.execute("document.export", &json!({"format": "wmf"})).unwrap()); + for (name, bytes) in [("pic.emf", &emf), ("pic.wmf", &wmf)] { + let r = s.execute("document.open", &json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(bytes)})).unwrap(); + assert_eq!(r["format"], &name[4..]); + assert_eq!(r["warnings"], json!([])); + let st = s.doc().unwrap(); + let ab = st.doc.artboards[0].rect; + assert!((ab.width() - 100.0).abs() < 0.05 && (ab.height() - 100.0).abs() < 0.05, "{ab:?}"); + let fill = st.doc.layers[0].children().unwrap().iter().find(|n| matches!(n.kind, NodeKind::Path { .. })).unwrap().clone(); + let b = fill.geometric_bounds().unwrap(); + assert!((b.x0 - 10.0).abs() < 0.05 && (b.width() - 30.0).abs() < 0.05, "{b:?}"); + // Save can't write the file back: the document has no path. + assert!(s.doc().unwrap().path.is_none()); + } + // Placed: one group clipped to the picture's frame, named after the file. + let mut s = session(300.0, 300.0, 1); + let r = s.execute("file.place", &json!({"name": "pic.emf", "dataBase64": vectorcraft_format::base64_encode(&emf), "at": [150, 150]})).unwrap(); + assert_eq!(r["format"], "emf"); + assert!((r["width"].as_f64().unwrap() - 100.0).abs() < 0.05); + let st = s.doc().unwrap(); + let placed = st.doc.layers[0].children().unwrap().last().unwrap().clone(); + assert_eq!(placed.name.as_deref(), Some("pic.emf")); + assert!(matches!(placed.kind, NodeKind::Group { clip: true, .. })); + let ext = fileio::PLACE_EXTS; + assert!(ext.contains(&"emf") && ext.contains(&"wmf") && OPEN_EXTS.contains(&"emf") && OPEN_EXTS.contains(&"wmf")); +} + +#[test] +fn formats_list_emf_and_wmf() { + let mut s = Session::new(); + let r = s.execute("document.formats", &json!({})).unwrap(); + for k in ["readable", "writable"] { + let ids: Vec<&str> = r[k].as_array().unwrap().iter().map(|v| v.as_str().unwrap()).collect(); + assert!(ids.contains(&"emf") && ids.contains(&"wmf"), "{k}: {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()); + assert!(open_filters().any(|(label, ext)| label == "EMF" && ext == ["emf"])); + assert!(place_filters().any(|(label, ext)| label == "WMF" && ext == ["wmf"])); + // Export for Screens writes the screen formats only. + let mut s = two_boards(); + assert!(s.execute("document.exportForScreens", &json!({"formats": [{"format": "emf"}]})).is_err()); +} + +#[test] +fn a_pasted_metafile_becomes_objects() { + let mut src = two_boards(); + let emf = b64(&src.execute("document.export", &json!({"format": "emf"})).unwrap()); + let mut s = session(300.0, 300.0, 1); + let r = s.execute("clipboard.importEmf", &json!({"dataBase64": vectorcraft_format::base64_encode(&emf), "center": [150, 150]})).unwrap(); + assert_eq!(r["count"], 1); + s.execute("edit.paste", &json!({"center": [150, 150]})).unwrap(); + let pasted = s.doc().unwrap().doc.layers[0].children().unwrap().clone(); + assert_eq!(pasted.len(), 1); + let b = pasted[0].geometric_bounds().unwrap(); + assert!((b.center().x - 150.0).abs() < 0.05 && (b.width() - 30.0).abs() < 0.05, "{b:?}"); + assert!(s.execute("clipboard.importEmf", &json!({"dataBase64": vectorcraft_format::base64_encode(b"%PDF-1.7")})).is_err()); +} diff --git a/crates/engine/src/cmd/place/mod.rs b/crates/engine/src/cmd/place/mod.rs index 13d8ec07..54839c8b 100644 --- a/crates/engine/src/cmd/place/mod.rs +++ b/crates/engine/src/cmd/place/mod.rs @@ -6,6 +6,8 @@ //! `link` ([`super::links`]). //! - An SVG becomes one group of its art; so does a DXF drawing, read with the DXF options //! (`dxf`, [`fileio::dxfimport`]). +//! - An EMF or WMF picture becomes one group, clipped to its frame (`crop: "crop"`) or bounded by +//! its art (`crop: "bounding"`). //! - A PDF/.ai/.ait page or a native document's artboard becomes one group, clipped to the page //! (`crop: "crop"`; a PDF page's `art`, `trim`, `bleed` or `media` box too) or bounded by its art //! (`crop: "bounding"`). @@ -48,7 +50,7 @@ pub fn specs() -> Vec { "Place…", ["File"], Some("Cmd+Shift+P"), - "{path | name+dataBase64, link?: true (a raster image keeps its file's path; other files are embedded), text?: {characterSet?: \"unicode\" (UTF-8, or UTF-16 with a byte-order mark; other bytes as the platform's 8-bit set) | \"ansi\" (the platform's 8-bit set), platform?: \"windows\" (Windows-1252) | \"mac\" (Mac Roman), removeLineReturns?: false (each block of lines becomes one paragraph; blank lines end paragraphs), removeParagraphReturns?: false (drop blank lines), replaceSpaces?: n (runs of n ≥ 2 spaces become a tab)} (a .txt file, placed as area type filling rect, the replaced object's bounds, or else the artboard less a 36 pt margin), template?: false (onto a new locked template layer below the current layer), replace?: false (swap the one selected object, keeping its stacking place and transform; no at/rect), at?: [x, y] centre (default: the first artboard's centre), rect?: [x, y, width, height] fit inside, aspect kept (wins over at), page?: 1 (PDF/.ai page, or a native document's artboard), crop?: \"crop\" (clipped to that page or artboard, default) | \"bounding\" (the art's bounds) | \"art\" | \"trim\" | \"bleed\" | \"media\" (a PDF page's boxes), password? (an encrypted PDF), dxf?: {…the DXF options of document.open} (a .dxf drawing: fit fills the artboard under at, else the first; fitted or uncentred art lands with its drawing's artboard on that artboard, else it is centred on at)} → {ids, name, format, linked, width, height, warnings}. Raster images come in at 100% of their physical size (the file's ppi, else 72); SVG, DXF, PDF/.ai and native documents as one group, with the images, symbols, patterns and swatches they use. One undo step; selects what it placed (unless on a template layer); never touches the clipboard", + "{path | name+dataBase64, link?: true (a raster image keeps its file's path; other files are embedded), text?: {characterSet?: \"unicode\" (UTF-8, or UTF-16 with a byte-order mark; other bytes as the platform's 8-bit set) | \"ansi\" (the platform's 8-bit set), platform?: \"windows\" (Windows-1252) | \"mac\" (Mac Roman), removeLineReturns?: false (each block of lines becomes one paragraph; blank lines end paragraphs), removeParagraphReturns?: false (drop blank lines), replaceSpaces?: n (runs of n ≥ 2 spaces become a tab)} (a .txt file, placed as area type filling rect, the replaced object's bounds, or else the artboard less a 36 pt margin), template?: false (onto a new locked template layer below the current layer), replace?: false (swap the one selected object, keeping its stacking place and transform; no at/rect), at?: [x, y] centre (default: the first artboard's centre), rect?: [x, y, width, height] fit inside, aspect kept (wins over at), page?: 1 (PDF/.ai page, or a native document's artboard), crop?: \"crop\" (clipped to that page or artboard, default) | \"bounding\" (the art's bounds) | \"art\" | \"trim\" | \"bleed\" | \"media\" (a PDF page's boxes), password? (an encrypted PDF), dxf?: {…the DXF options of document.open} (a .dxf drawing: fit fills the artboard under at, else the first; fitted or uncentred art lands with its drawing's artboard on that artboard, else it is centred on at)} → {ids, name, format, linked, width, height, warnings}. Raster images come in at 100% of their physical size (the file's ppi, else 72); SVG, DXF, PDF/.ai, EMF/WMF (clipped to the picture's frame) and native documents as one group, with the images, symbols, patterns and swatches they use. One undo step; selects what it placed (unless on a template layer); never touches the clipboard", has_doc, place ), diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index 768342c6..80482de5 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -2,7 +2,8 @@ //! mutated raster images placed with File → Place, and swatch (`.vcswatches`, `.gpl`), graphic style (`.vcstyles`) and flattener preset //! (`.vcflattener`) libraries, and native files (compressed, damaged, saved for older versions), //! must load as an error or as a document that then renders and exports, without a panic; nor may -//! bitmaps, PDF and text pasted from other apps. +//! bitmaps, PDF and text pasted from other apps, nor EMF and WMF pictures (damaged files, records +//! of every kind with random contents) opened, placed or pasted. //! //! `PROPTEST_CASES=20000 cargo test -p vectorcraft-engine --test import_fuzz` runs a deeper search. // Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness). @@ -991,3 +992,116 @@ proptest! { survive_dxf("mutated dxf", &bytes, cut % 2 == 0)?; } } + +// ---------- Windows metafiles: opened, placed and pasted ---------- + +/// The rich document as EMF and as WMF. +fn metafile_samples() -> &'static [Vec] { + static SAMPLES: std::sync::OnceLock>> = std::sync::OnceLock::new(); + SAMPLES.get_or_init(|| { + let mut s = rich_session(); + ["emf", "wmf"] + .iter() + .map(|f| { + let r = s.execute("document.export", &json!({ "format": f })).unwrap(); + vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap() + }) + .collect() + }) +} + +/// An EMF of a 100 × 100 mm frame on a device of 10 pixels a millimetre holding `records`. +fn emf_of(records: &[(u32, Vec)]) -> Vec { + let mut b: Vec = vec![]; + let mut head = vec![0u8; 108]; + head[..4].copy_from_slice(&1u32.to_le_bytes()); + head[4..8].copy_from_slice(&108u32.to_le_bytes()); + for (at, v) in [(32, 10_000i32), (36, 10_000), (72, 1000), (76, 1000), (80, 100), (84, 100), (100, 100_000), (104, 100_000)] { + head[at..at + 4].copy_from_slice(&v.to_le_bytes()); + } + head[40..44].copy_from_slice(b" EMF"); + b.extend(head); + for (kind, body) in records { + let mut body = body.clone(); + body.resize(body.len().div_ceil(4) * 4, 0); + b.extend(kind.to_le_bytes()); + b.extend((8 + body.len() as u32).to_le_bytes()); + b.extend(body); + } + b.extend([14, 0, 0, 0, 20, 0, 0, 0, 0, 0, 0, 0, 16, 0, 0, 0, 20, 0, 0, 0]); + b +} + +/// A placeable WMF of a 1 × 1 inch box holding `records`. +fn wmf_of(records: &[(u16, Vec)]) -> Vec { + let mut b: Vec = vec![0xD7, 0xCD, 0xC6, 0x9A, 0, 0, 0, 0, 0, 0, 0xA0, 0x05, 0xA0, 0x05, 0xA0, 0x05, 0, 0, 0, 0]; + let sum = b.chunks_exact(2).fold(0u16, |a, w| a ^ u16::from_le_bytes([w[0], w[1]])); + b.extend(sum.to_le_bytes()); + b.extend([1, 0, 9, 0, 0, 3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]); + for (function, body) in records { + let mut body = body.clone(); + body.resize(body.len().div_ceil(2) * 2, 0); + b.extend((3 + body.len() as u32 / 2).to_le_bytes()); + b.extend(function.to_le_bytes()); + b.extend(body); + } + b.extend([3, 0, 0, 0, 0, 0]); + b +} + +/// Open the bytes as `name`, and place them: neither may panic. +fn survive_metafile(what: &str, name: &str, bytes: &[u8]) -> Result<(), TestCaseError> { + survive(what, || vectorcraft_engine::cmd::fileio::load(name, bytes).ok().map(|l| l.doc))?; + let r = catch_quiet(|| { + let mut s = vectorcraft_engine::Session::new(); + s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + let _ = s.execute("file.place", &json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(bytes)})); + }); + prop_assert!(r.is_ok(), "{what}: placing panicked: {:?}", r.err()); + Ok(()) +} + +/// A record body: random bytes, or small numbers (coordinates, counts and offsets that land). +fn arb_body() -> impl Strategy> { + prop_oneof![ + prop::collection::vec(any::(), 0..96), + prop::collection::vec(prop_oneof![Just(0i32), Just(1), Just(-1), 0i32..200, Just(i32::MAX), Just(i32::MIN)], 0..24) + .prop_map(|v| v.iter().flat_map(|x| x.to_le_bytes()).collect()), + ] +} + +/// WMF record functions VectorCraft reads, and one it doesn't. +const WMF_FUNCTIONS: [u16; 33] = [ + 0x0324, 0x0325, 0x0538, 0x041B, 0x0418, 0x061C, 0x0817, 0x081A, 0x0830, 0x0416, 0x0415, 0x02FA, 0x02FC, 0x02FB, 0x012D, 0x01F0, 0x0521, 0x0A32, + 0x0F43, 0x0B41, 0x0940, 0x061D, 0x0213, 0x0214, 0x001E, 0x0127, 0x0106, 0x0209, 0x012E, 0x00F7, 0x01F9, 0x0142, 0x0999, +]; + +proptest! { + #![proptest_config(config())] + + #[test] + fn mutated_metafiles_never_panic(which in 0usize..2, cut in 0usize..200_000, edits in prop::collection::vec((0usize..200_000, any::()), 0..16)) { + let mut bytes = metafile_samples()[which].clone(); + let n = bytes.len(); + for &(at, b) in &edits { + bytes[at % n] = b; + } + bytes.truncate(cut.max(8)); + survive_metafile("mutated metafile", ["x.emf", "x.wmf"][which], &bytes)?; + } + + #[test] + fn random_emf_records_never_panic(records in prop::collection::vec((0u32..124, arb_body()), 0..32)) { + survive_metafile("EMF records", "x.emf", &emf_of(&records))?; + } + + #[test] + fn random_wmf_records_never_panic(records in prop::collection::vec((prop::sample::select(WMF_FUNCTIONS.to_vec()), arb_body()), 0..32)) { + survive_metafile("WMF records", "x.wmf", &wmf_of(&records))?; + } + + #[test] + fn pasted_metafiles_never_panic(which in 0usize..2, cut in 0usize..200_000, edits in prop::collection::vec((0usize..200_000, any::()), 0..12)) { + paste_from_elsewhere("clipboard.importEmf", json!({"dataBase64": mutated_b64(metafile_samples()[which].clone(), cut, &edits)}))?; + } +} 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..ddf534b7 --- /dev/null +++ b/crates/metafile/src/bytes.rs @@ -0,0 +1,96 @@ +//! Little-endian values: the buffer both writers build records in, and a reader that never reads +//! past its data (every read is an `Option`). + +/// 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()); + } + } +} + +/// Sequential little-endian reads from a slice. +#[derive(Clone, Copy)] +pub(crate) struct Reader<'a> { + data: &'a [u8], + pos: usize, +} + +impl<'a> Reader<'a> { + pub fn new(data: &'a [u8]) -> Self { + Self { data, pos: 0 } + } + pub fn bytes(&mut self, n: usize) -> Option<&'a [u8]> { + let b = self.data.get(self.pos..self.pos.checked_add(n)?)?; + self.pos += n; + Some(b) + } + fn array(&mut self) -> Option<[u8; N]> { + self.bytes(N)?.try_into().ok() + } + pub fn u16(&mut self) -> Option { + self.array().map(u16::from_le_bytes) + } + pub fn i16(&mut self) -> Option { + self.array().map(i16::from_le_bytes) + } + pub fn u32(&mut self) -> Option { + self.array().map(u32::from_le_bytes) + } + pub fn i32(&mut self) -> Option { + self.array().map(i32::from_le_bytes) + } + pub fn f32(&mut self) -> Option { + self.array().map(f32::from_le_bytes) + } + pub fn skip(&mut self, n: usize) -> Option<()> { + self.bytes(n).map(drop) + } + /// The bytes not read yet. + pub fn rest(&self) -> &'a [u8] { + self.data.get(self.pos..).unwrap_or_default() + } +} + +/// `len` bytes of `data` from `offset` (record-relative offsets read from untrusted files). +pub(crate) fn slice(data: &[u8], offset: u32, len: u32) -> Option<&[u8]> { + let start = usize::try_from(offset).ok()?; + data.get(start..start.checked_add(usize::try_from(len).ok()?)?) +} diff --git a/crates/metafile/src/dib.rs b/crates/metafile/src/dib.rs new file mode 100644 index 00000000..d63e5837 --- /dev/null +++ b/crates/metafile/src/dib.rs @@ -0,0 +1,356 @@ +//! 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); read from 1, +//! 4, 8, 16, 24 and 32-bit uncompressed and bit-field bitmaps and from embedded JPEG and PNG. + +use std::io::Cursor; + +use crate::bytes::Reader; + +/// 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; +/// Most pixels along a side, and in all, of a bitmap the reader decodes. +const MAX_READ_SIDE: u32 = 1 << 15; +const MAX_READ_PIXELS: u64 = 1 << 26; + +const BI_RGB: u32 = 0; +const BI_BITFIELDS: u32 = 3; +const BI_JPEG: u32 = 4; +const BI_PNG: u32 = 5; + +/// 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) + } + + /// As PNG bytes. + pub fn to_png(&self) -> Result, String> { + let img = image::RgbaImage::from_raw(self.width, self.height, self.pixels.clone()).ok_or("the image's pixels don't match its size")?; + let mut png = vec![]; + img.write_to(&mut Cursor::new(&mut png), image::ImageFormat::Png).map_err(|e| e.to_string())?; + Ok(png) + } +} + +/// 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) +} + +/// How the fourth byte of 32-bit pixels reads. +#[derive(Clone, Copy, PartialEq, Eq)] +pub(crate) enum Alpha { + /// Unused (StretchDIBits and BitBlt ignore it): every pixel opaque. + Ignore, + /// Premultiplied alpha (AlphaBlend with per-pixel alpha). + Premultiplied, +} + +/// A colour channel of `v` under `mask`, scaled to 0–255. +fn channel(v: u32, mask: u32) -> u8 { + if mask == 0 { + return 0; + } + let shift = mask.trailing_zeros(); + let max = u64::from(mask >> shift); + let x = u64::from((v & mask) >> shift); + ((x * 255 + max / 2) / max.max(1)).min(255) as u8 +} + +/// Read a DIB from its BITMAPINFO (`bmi`: the header and colour table) and pixel `bits`. +pub(crate) fn decode(bmi: &[u8], bits: &[u8], alpha: Alpha) -> Result { + let bad = || "a bitmap is damaged".to_string(); + let mut r = Reader::new(bmi); + let size = r.u32().ok_or_else(bad)?; + let (width, height, bpp, compression, colors, palette_entry, masks_at) = if size == 12 { + // BITMAPCOREHEADER: 16-bit sizes, RGB triples. + let w = i32::from(r.u16().ok_or_else(bad)?); + let h = i32::from(r.u16().ok_or_else(bad)?); + r.skip(2).ok_or_else(bad)?; + let bpp = r.u16().ok_or_else(bad)?; + (w, h, bpp, BI_RGB, 0, 3, 0) + } else if size >= 40 { + let w = r.i32().ok_or_else(bad)?; + let h = r.i32().ok_or_else(bad)?; + r.skip(2).ok_or_else(bad)?; + let bpp = r.u16().ok_or_else(bad)?; + let c = r.u32().ok_or_else(bad)?; + r.skip(12).ok_or_else(bad)?; + let used = r.u32().ok_or_else(bad)?; + (w, h, bpp, c, used, 4, 40) + } else { + return Err(bad()); + }; + if compression == BI_JPEG || compression == BI_PNG { + let img = image::load_from_memory(bits).map_err(|_| bad())?.to_rgba8(); + let (w, h) = img.dimensions(); + if w > MAX_READ_SIDE || h > MAX_READ_SIDE || u64::from(w) * u64::from(h) > MAX_READ_PIXELS { + return Err("a bitmap is too large to read".into()); + } + return Ok(Rgba { width: w, height: h, pixels: img.into_raw() }); + } + if compression != BI_RGB && compression != BI_BITFIELDS { + return Err("compressed (RLE) bitmaps can't be read yet".into()); + } + let top_down = height < 0; + let (w, h) = (width.unsigned_abs(), height.unsigned_abs()); + if w == 0 || h == 0 { + return Err("a bitmap is empty".into()); + } + if w > MAX_READ_SIDE || h > MAX_READ_SIDE || u64::from(w) * u64::from(h) > MAX_READ_PIXELS { + return Err("a bitmap is too large to read".into()); + } + let header_len = size as usize; + // Bit-field masks follow a plain 40-byte header, or sit inside the larger ones. + let fields = compression == BI_BITFIELDS; + let masks: Option<[u32; 4]> = fields.then(|| { + let mut m = Reader::new(bmi.get(masks_at..).unwrap_or_default()); + let rgb = [m.u32().unwrap_or(0), m.u32().unwrap_or(0), m.u32().unwrap_or(0)]; + // An alpha mask comes with BITMAPV3INFOHEADER and later. + let a = if header_len >= 56 { m.u32().unwrap_or(0) } else { 0 }; + [rgb[0], rgb[1], rgb[2], a] + }); + let palette_at = header_len + if fields && header_len == 40 { 12 } else { 0 }; + let bpp_u = u32::from(bpp); + let palette: Vec<[u8; 3]> = if bpp_u <= 8 { + let n = if colors == 0 { 1usize << bpp_u } else { (colors as usize).min(256) }; + bmi.get(palette_at..) + .unwrap_or_default() + .chunks_exact(palette_entry) + .take(n) + .map(|e| [e.get(2).copied().unwrap_or(0), e.get(1).copied().unwrap_or(0), e.first().copied().unwrap_or(0)]) + .collect() + } else { + vec![] + }; + if !matches!(bpp, 1 | 4 | 8 | 16 | 24 | 32) { + return Err(format!("{bpp}-bit bitmaps can't be read")); + } + let row = stride(w, bpp_u).ok_or_else(bad)?; + if bits.len() < row.checked_mul(h as usize).ok_or_else(bad)? { + return Err(bad()); + } + let (mr, mg, mb, ma) = match (masks, bpp) { + (Some([r, g, b, a]), _) => (r, g, b, a), + (None, 16) => (0x7c00, 0x03e0, 0x001f, 0), + _ => (0x00ff_0000, 0x0000_ff00, 0x0000_00ff, 0xff00_0000), + }; + let mut pixels = Vec::with_capacity(w as usize * h as usize * 4); + for y in 0..h as usize { + let src_row = if top_down { y } else { h as usize - 1 - y }; + let line = bits.get(src_row * row..src_row * row + row).ok_or_else(bad)?; + for x in 0..w as usize { + let px: [u8; 4] = match bpp { + 1 | 4 | 8 => { + let bit = x * bpp_u as usize; + let byte = line.get(bit / 8).copied().unwrap_or(0); + let idx = (byte >> (8 - bpp_u as usize - bit % 8)) & ((1u16 << bpp_u) - 1) as u8; + let [r, g, b] = palette.get(idx as usize).copied().unwrap_or([0, 0, 0]); + [r, g, b, 255] + } + 16 => { + let v = u32::from(u16::from_le_bytes([line.get(x * 2).copied().unwrap_or(0), line.get(x * 2 + 1).copied().unwrap_or(0)])); + [channel(v, mr), channel(v, mg), channel(v, mb), 255] + } + 24 => { + let p = line.get(x * 3..x * 3 + 3).unwrap_or(&[0, 0, 0]); + [p[2], p[1], p[0], 255] + } + _ => { + let p = line.get(x * 4..x * 4 + 4).unwrap_or(&[0, 0, 0, 0]); + let v = u32::from_le_bytes([p[0], p[1], p[2], p[3]]); + let a = if alpha == Alpha::Premultiplied && ma != 0 { channel(v, ma) } else { 255 }; + let un = + |c: u8| if alpha == Alpha::Premultiplied && a < 255 { (u32::from(c) * 255 / u32::from(a.max(1))).min(255) as u8 } else { c }; + [un(channel(v, mr)), un(channel(v, mg)), un(channel(v, mb)), if alpha == Alpha::Premultiplied { a } else { 255 }] + } + }; + pixels.extend_from_slice(&px); + } + } + Ok(Rgba { width: w, height: h, pixels }) +} + +/// A packed DIB (a BITMAPINFO and its bits, one after the other, as WMF records hold them) split +/// into the two. +pub(crate) fn split(packed: &[u8]) -> Option<(&[u8], &[u8])> { + let mut r = Reader::new(packed); + let size = r.u32()? as usize; + let (bpp, compression, used, entry) = if size == 12 { + r.skip(6)?; + (r.u16()?, BI_RGB, 0, 3) + } else { + r.skip(10)?; + let bpp = r.u16()?; + let c = r.u32()?; + r.skip(12)?; + (bpp, c, r.u32()? as usize, 4) + }; + let masks = if compression == BI_BITFIELDS && size == 40 { 12 } else { 0 }; + let colors = if used > 0 { + used.min(256) + } else if bpp <= 8 { + 1usize << bpp + } else { + 0 + }; + let len = size.checked_add(masks)?.checked_add(colors * entry)?; + Some((packed.get(..len)?, packed.get(len..)?)) +} + +#[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 rgb24_round_trips_over_white() { + let (bmi, bits) = rgb24(&sample(), 1.0); + assert_eq!(bmi.len(), 40); + // Rows of 9 bytes padded to 12. + assert_eq!(bits.len(), 24); + let back = decode(&bmi, &bits, Alpha::Ignore).unwrap(); + assert_eq!((back.width, back.height), (3, 2)); + assert_eq!(&back.pixels[..12], &[255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255]); + // Half-transparent black over white is mid grey; transparent is white. + assert_eq!(&back.pixels[12..16], &[127, 127, 127, 255]); + assert_eq!(&back.pixels[16..20], &[255, 255, 255, 255]); + } + + #[test] + fn bgra32_round_trips_with_alpha() { + let (bmi, bits) = bgra32(&sample(), 1.0); + let back = decode(&bmi, &bits, Alpha::Premultiplied).unwrap(); + assert_eq!(&back.pixels[..12], &sample().pixels[..12]); + assert_eq!(back.pixels[15], 128); + assert_eq!(back.pixels[19], 0); + // Read as StretchDIBits does, alpha is ignored. + assert!(decode(&bmi, &bits, Alpha::Ignore).unwrap().opaque()); + } + + #[test] + fn palette_bitmaps_and_damage() { + // A 1-bit 2 × 1 bitmap: black then white. + let mut bmi = header(2, 1, 1, 4); + bmi.extend_from_slice(&[0, 0, 0, 0, 255, 255, 255, 0]); + let back = decode(&bmi, &[0b0100_0000, 0, 0, 0], Alpha::Ignore).unwrap(); + assert_eq!(back.pixels, vec![0, 0, 0, 255, 255, 255, 255, 255]); + // Too few bits, a silly size, an unknown depth: errors, no panic. + assert!(decode(&bmi, &[0], Alpha::Ignore).is_err()); + assert!(decode(&header(1 << 20, 1 << 20, 24, 0), &[], Alpha::Ignore).is_err()); + assert!(decode(&header(1, 1, 7, 0), &[0; 4], Alpha::Ignore).is_err()); + assert!(decode(&[1, 2], &[], Alpha::Ignore).is_err()); + } + + #[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/import.rs b/crates/metafile/src/import.rs new file mode 100644 index 00000000..ded12e5b --- /dev/null +++ b/crates/metafile/src/import.rs @@ -0,0 +1,938 @@ +//! The metafile reader: the format parsers (`emf`, `wmf`) play their records on a [`Player`], a +//! small model of the device context they draw into (objects, transforms, the current position, +//! the path bracket, the clip), which turns what they draw into document objects. +//! +//! Fills and strokes become paths with a fill and a stroke; lines drawn one after another with the +//! same pen join into one path. Clips become clipping groups around what they clip. Images keep +//! their pixels (as PNG), text becomes point type in the font the file names. Records the reader +//! doesn't know are counted and skipped, with one warning. + +mod emf; +mod wmf; + +use std::sync::Arc; + +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::{ + Appearance, AppearanceItem, CharStyle, Dash, Document, FillLayer, ImageBlob, ImageObject, Justify, LayerColor, LineCap, LineJoin, Node, NodeKind, + StrokeLayer, TextObject, +}; +use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, PathEl, Point, Rect, Shape, Vec2, shapes}; + +use crate::dib::Rgba; +use crate::{Imported, Kind}; + +/// Most objects an import makes (a hostile file can't exhaust memory with them). +const MAX_NODES: usize = 1 << 20; +/// Objects and clips reaching further than this from the origin (points, well inside the canvas) +/// are left out: they come from damaged or hostile coordinates. +const LIMIT: f64 = 1e6; +/// Most nested saved device contexts. +const MAX_SAVED: usize = 1024; +/// Width of a cosmetic pen (one pixel of a 96 ppi screen), points. +const HAIRLINE: f64 = 0.75; +/// Curves made for arcs and ellipses are this close to the true curve (logical units are +/// transformed after, so it is relative). +const ARC_TOLERANCE: f64 = 0.01; + +/// Raster operations. +pub(crate) const SRCCOPY: u32 = 0x00CC_0020; +const PATCOPY: u32 = 0x00F0_0021; +const WHITENESS: u32 = 0x00FF_0062; +const BLACKNESS: u32 = 0x0000_0042; + +const SKIPPED_ROPS: &str = "images drawn with raster operations other than a plain copy (masks, inversions) were left out"; +const HATCHES: &str = "hatched brushes are filled with their colour"; +const PATTERN_BRUSHES: &str = "pattern brushes are filled grey"; +const EXCLUDED_CLIPS: &str = "clip areas cut out of the clip (excluded rectangles, differences) are ignored"; +const BAD_IMAGE: &str = "an image that couldn't be read was left out"; +const FAR_AWAY: &str = "objects far outside the picture were left out"; + +/// Is `r` finite and within [`LIMIT`]? +fn sane(r: Rect) -> bool { + [r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite() && v.abs() <= LIMIT) +} + +/// A pen: its style flags (`PS_*`), width in logical units, colour and dash entries. +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct PenObj { + pub style: u32, + pub width: f64, + pub color: [u8; 3], + /// PS_USERSTYLE entries, logical units. + pub entries: Vec, +} + +impl PenObj { + pub const NULL: PenObj = PenObj { style: PS_NULL, width: 0.0, color: [0; 3], entries: vec![] }; + pub fn solid(color: [u8; 3]) -> Self { + Self { style: 0, width: 0.0, color, entries: vec![] } + } +} + +pub(crate) const PS_NULL: u32 = 5; +const PS_GEOMETRIC: u32 = 0x0001_0000; + +/// A brush. +#[derive(Clone, Debug, PartialEq)] +pub(crate) enum BrushObj { + Null, + Solid([u8; 3]), + Hatched([u8; 3]), + /// A bitmap pattern (written grey). + Pattern, +} + +impl BrushObj { + /// From a LOGBRUSH's style and colour. + pub fn from_log(style: u32, color: [u8; 3]) -> Self { + match style { + 0 => Self::Solid(color), + 1 => Self::Null, + 2 => Self::Hatched(color), + _ => Self::Pattern, + } + } +} + +/// A font: its height in logical units (negative: the character height, positive: the cell +/// height), escapement (tenths of a degree), weight, italics and face. +#[derive(Clone, Debug, Default, PartialEq)] +pub(crate) struct FontObj { + pub height: f64, + pub escapement: f64, + pub weight: i32, + pub italic: bool, + pub face: String, +} + +/// A GDI object. +#[derive(Clone, Debug)] +pub(crate) enum Obj { + Pen(PenObj), + Brush(BrushObj), + Font(FontObj), + /// Palettes, regions, colour spaces: they take a slot but don't change the drawing. + Other, +} + +/// A clip: a region in document space and its rule. Clips nest; each has an id so what one clips +/// can be grouped. +#[derive(Debug)] +struct Clip { + id: u32, + path: BezPath, + rule: FillRule, +} + +/// The state records change, saved and restored as a whole. +#[derive(Clone, Debug)] +struct Dc { + pen: PenObj, + brush: BrushObj, + font: FontObj, + text_color: [u8; 3], + text_align: u32, + fill_rule: FillRule, + miter: f64, + /// Arcs run counterclockwise (the default). + arc_ccw: bool, + /// Logical → page (EMF world transform). + world: Affine, + map_mode: u32, + win_org: Vec2, + win_ext: Vec2, + vp_org: Vec2, + vp_ext: Vec2, + clips: Vec>, +} + +impl Default for Dc { + fn default() -> Self { + Self { + pen: PenObj::solid([0; 3]), + brush: BrushObj::Solid([255; 3]), + font: FontObj::default(), + text_color: [0; 3], + text_align: 0, + fill_rule: FillRule::EvenOdd, + miter: 10.0, + arc_ccw: true, + world: Affine::IDENTITY, + map_mode: 1, + win_org: Vec2::ZERO, + win_ext: Vec2::new(1.0, 1.0), + vp_org: Vec2::ZERO, + vp_ext: Vec2::new(1.0, 1.0), + clips: vec![], + } + } +} + +/// How an arc closes. +#[derive(Clone, Copy, PartialEq, Eq)] +pub(crate) enum ArcKind { + Open, + Chord, + Pie, +} + +pub(crate) struct Player { + kind: Kind, + /// Device units (EMF) or logical units (WMF) → document points. + dev_to_doc: Affine, + /// Millimetres per device pixel (EMF's fixed mapping modes). + mm_per_px: f64, + dc: Dc, + saved: Vec, + objects: Vec>, + /// The lowest object slot that may be free (WMF numbering). + free_hint: usize, + /// The path being recorded (document space), whether a figure is open in it and where that + /// figure started (logical). + path: Option, + figure: bool, + figure_start: Point, + /// The artboard (document space): clips holding all of it clip nothing worth keeping. + frame: Rect, + /// The current position (logical). + pos: Point, + /// Lines drawn one after another outside a path bracket, not drawn yet. + pending: BezPath, + out: Drawn, + doc: Document, + warnings: Vec, + skipped: usize, + next_clip: u32, + /// The file holds EMF+ records (which aren't read). + pub emf_plus: bool, +} + +fn rgb(c: [u8; 3]) -> Paint { + Paint::solid(Color::rgb8(c[0], c[1], c[2])) +} + +/// The arc of the ellipse in `r` from the ray through `start` to the ray through `end`. +pub(crate) fn arc_path(r: Rect, start: Point, end: Point, ccw: bool, kind: ArcKind) -> BezPath { + let r = r.abs(); + let c = r.center(); + let radii = Vec2::new(r.width() / 2.0, r.height() / 2.0); + let mut bp = BezPath::new(); + if radii.x <= 0.0 || radii.y <= 0.0 { + return bp; + } + let angle = |p: Point| ((p.y - c.y) / radii.y).atan2((p.x - c.x) / radii.x); + let (a0, a1) = (angle(start), angle(end)); + let tau = std::f64::consts::TAU; + // Counterclockwise on a y-down device is towards smaller angles. + let sweep = if ccw { + let s = (a0 - a1).rem_euclid(tau); + -(if s == 0.0 { tau } else { s }) + } else { + let s = (a1 - a0).rem_euclid(tau); + if s == 0.0 { tau } else { s } + }; + let arc = kurbo::Arc { center: c, radii, start_angle: a0, sweep_angle: sweep, x_rotation: 0.0 }; + let tol = ARC_TOLERANCE * radii.x.max(radii.y).max(1e-9); + if kind == ArcKind::Pie { + bp.move_to(c); + let first = c + Vec2::new(radii.x * a0.cos(), radii.y * a0.sin()); + bp.line_to(first); + arc.append_iter(tol).for_each(|el| bp.push(el)); + } else { + kurbo::Shape::path_elements(&arc, tol).for_each(|el| bp.push(el)); + } + if kind != ArcKind::Open { + bp.close_path(); + } + bp +} + +/// A rectangle with elliptical corners `rx` × `ry`. +pub(crate) fn round_rect(r: Rect, rx: f64, ry: f64) -> BezPath { + let r = r.abs(); + let (rx, ry) = ((rx / 2.0).abs().min(r.width() / 2.0), (ry / 2.0).abs().min(r.height() / 2.0)); + if rx <= 0.0 || ry <= 0.0 { + return shapes::rectangle(r).to_bezpath(); + } + // A rectangle with round corners of radius `ry`, squeezed across. + let s = ry / rx; + let squeezed = Rect::new(r.x0 * s, r.y0, r.x1 * s, r.y1); + let mut bp = shapes::rounded_rectangle(squeezed, ry).to_bezpath(); + bp.apply_affine(Affine::scale_non_uniform(1.0 / s, 1.0)); + bp +} + +impl Player { + fn new(kind: Kind, dev_to_doc: Affine, mm_per_px: f64, artboard: Rect) -> Self { + let side = |v: f64| if v.is_finite() { v.clamp(1.0, LIMIT) } else { 1.0 }; + let (w, h) = (side(artboard.width()), side(artboard.height())); + let mut doc = Document::new(w, h); + doc.layers.clear(); + Self { + kind, + dev_to_doc, + mm_per_px, + dc: Dc::default(), + saved: vec![], + objects: vec![], + free_hint: 0, + path: None, + figure: false, + figure_start: Point::ZERO, + frame: artboard, + pos: Point::ZERO, + pending: BezPath::new(), + out: vec![], + doc, + warnings: vec![], + skipped: 0, + next_clip: 0, + emf_plus: false, + } + } + + pub fn warn(&mut self, w: &str) { + if !self.warnings.iter().any(|x| x == w) { + self.warnings.push(w.to_string()); + } + } + + /// A record of a kind the reader doesn't know. + pub fn skip(&mut self) { + self.skipped += 1; + } + + // ---------- mapping ---------- + + /// Logical → document. + fn xf(&self) -> Affine { + if self.kind == Kind::Wmf { + return self.dev_to_doc; + } + let d = &self.dc; + let page_to_dev = match d.map_mode { + // Fixed modes: millimetres a unit, y up. + 2..=6 => { + let mm = [0.1, 0.01, 0.254, 0.0254, 25.4 / 1440.0][(d.map_mode - 2) as usize]; + let s = mm / self.mm_per_px.max(1e-12); + Affine::translate(d.vp_org) * Affine::scale_non_uniform(s, -s) * Affine::translate(-d.win_org) + } + 7 | 8 => { + let ratio = |v: f64, w: f64| if w.abs() > 1e-12 && v.is_finite() { v / w } else { 1.0 }; + let (mut sx, mut sy) = (ratio(d.vp_ext.x, d.win_ext.x), ratio(d.vp_ext.y, d.win_ext.y)); + if d.map_mode == 7 { + // Isotropic: the same scale both ways, the smaller one. + let s = sx.abs().min(sy.abs()); + (sx, sy) = (s.copysign(sx), s.copysign(sy)); + } + Affine::translate(d.vp_org) * Affine::scale_non_uniform(sx, sy) * Affine::translate(-d.win_org) + } + _ => Affine::translate(d.vp_org - d.win_org), + }; + self.dev_to_doc * page_to_dev * d.world + } + + /// How much logical lengths grow in the document. + fn scale(&self) -> f64 { + let s = self.xf().determinant().abs().sqrt(); + if s.is_finite() { s } else { 0.0 } + } + + pub fn set_map_mode(&mut self, m: u32) { + self.dc.map_mode = m; + } + pub fn set_window_org(&mut self, x: f64, y: f64) { + self.dc.win_org = Vec2::new(x, y); + } + pub fn set_window_ext(&mut self, x: f64, y: f64) { + self.dc.win_ext = Vec2::new(x, y); + } + pub fn set_viewport_org(&mut self, x: f64, y: f64) { + self.dc.vp_org = Vec2::new(x, y); + } + pub fn set_viewport_ext(&mut self, x: f64, y: f64) { + self.dc.vp_ext = Vec2::new(x, y); + } + pub fn scale_ext(&mut self, window: bool, x: (f64, f64), y: (f64, f64)) { + let e = if window { &mut self.dc.win_ext } else { &mut self.dc.vp_ext }; + if x.1 != 0.0 && y.1 != 0.0 { + *e = Vec2::new(e.x * x.0 / x.1, e.y * y.0 / y.1); + } + } + /// `SetWorldTransform` / `ModifyWorldTransform` (`mode`: 1 identity, 2 left, 3 right, 4 set). + pub fn world(&mut self, x: Affine, mode: u32) { + if !x.as_coeffs().iter().all(|v| v.is_finite()) { + return; + } + self.dc.world = match mode { + 1 => Affine::IDENTITY, + 2 => self.dc.world * x, + 3 => x * self.dc.world, + _ => x, + }; + } + + // ---------- state ---------- + + pub fn set_fill_mode(&mut self, alternate: bool) { + self.dc.fill_rule = if alternate { FillRule::EvenOdd } else { FillRule::NonZero }; + } + pub fn set_miter(&mut self, m: f64) { + if m.is_finite() && m >= 1.0 { + self.dc.miter = m; + } + } + pub fn set_arc_ccw(&mut self, ccw: bool) { + self.dc.arc_ccw = ccw; + } + pub fn set_text_color(&mut self, c: [u8; 3]) { + self.dc.text_color = c; + } + pub fn set_text_align(&mut self, a: u32) { + self.dc.text_align = a; + } + + pub fn save(&mut self) { + if self.saved.len() < MAX_SAVED { + self.saved.push(self.dc.clone()); + } + } + + /// `RestoreDC(n)`: a negative `n` counts back from the latest save, a positive one is a save's + /// number. + pub fn restore(&mut self, n: i32) { + let len = self.saved.len(); + let keep = if n < 0 { len.checked_sub(n.unsigned_abs() as usize) } else { (n as usize).checked_sub(1).filter(|k| *k < len) }; + if let Some(k) = keep { + self.saved.truncate(k + 1); + if let Some(dc) = self.saved.pop() { + self.dc = dc; + } + } + } + + // ---------- objects ---------- + + /// Put `obj` in slot `index` (EMF numbers its objects). + pub fn create_at(&mut self, index: u32, obj: Obj) { + let i = index as usize; + if i == 0 || i > u16::MAX as usize { + return; + } + if self.objects.len() <= i { + self.objects.resize(i + 1, None); + } + if let Some(s) = self.objects.get_mut(i) { + *s = Some(obj); + } + } + + /// Put `obj` in the lowest free slot (WMF). + pub fn create(&mut self, obj: Obj) { + let start = self.free_hint.min(self.objects.len()); + let i = self.objects.iter().skip(start).position(Option::is_none).map_or(self.objects.len(), |p| p + start); + if i > u16::MAX as usize { + return; + } + if i == self.objects.len() { + self.objects.push(None); + } + if let Some(s) = self.objects.get_mut(i) { + *s = Some(obj); + } + self.free_hint = i + 1; + } + + pub fn delete(&mut self, index: u32) { + if let Some(s) = self.objects.get_mut(index as usize) { + *s = None; + self.free_hint = self.free_hint.min(index as usize); + } + } + + /// Select object `index`, or a stock object (EMF: the high bit set). + pub fn select(&mut self, index: u32) { + if self.kind == Kind::Emf && index & 0x8000_0000 != 0 { + match index & 0x7fff_ffff { + 0 | 18 => self.dc.brush = BrushObj::Solid([255; 3]), + 1 => self.dc.brush = BrushObj::Solid([0xc0; 3]), + 2 => self.dc.brush = BrushObj::Solid([0x80; 3]), + 3 => self.dc.brush = BrushObj::Solid([0x40; 3]), + 4 => self.dc.brush = BrushObj::Solid([0; 3]), + 5 => self.dc.brush = BrushObj::Null, + 6 => self.dc.pen = PenObj::solid([255; 3]), + 7 | 19 => self.dc.pen = PenObj::solid([0; 3]), + 8 => self.dc.pen = PenObj::NULL, + 10..=17 => self.dc.font = FontObj::default(), + _ => {} + } + return; + } + match self.objects.get(index as usize).cloned().flatten() { + Some(Obj::Pen(p)) => self.dc.pen = p, + Some(Obj::Brush(b)) => self.dc.brush = b, + Some(Obj::Font(f)) => self.dc.font = f, + _ => {} + } + } + + // ---------- output ---------- + + fn push(&mut self, mut node: Node) { + if self.out.len() >= MAX_NODES { + return; + } + if !node.visual_bounds().is_none_or(sane) { + return self.warn(FAR_AWAY); + } + node.id = self.doc.alloc_id(); + self.out.push((self.dc.clips.clone(), node)); + } + + fn fill_paint(&mut self) -> Option { + match self.dc.brush.clone() { + BrushObj::Null => None, + BrushObj::Solid(c) => Some(rgb(c)), + BrushObj::Hatched(c) => { + self.warn(HATCHES); + Some(rgb(c)) + } + BrushObj::Pattern => { + self.warn(PATTERN_BRUSHES); + Some(rgb([0x80; 3])) + } + } + } + + fn stroke_layer(&self) -> Option { + let p = &self.dc.pen; + let kind = p.style & 0xf; + if kind == PS_NULL { + return None; + } + // Geometric pens, and other pens wider than 1, have a width in logical units; the others + // are one pixel wide. + let w = p.width * self.scale(); + let width = if w > 0.0 && w.is_finite() && (p.style & PS_GEOMETRIC != 0 || p.width > 1.0) { w } else { HAIRLINE }; + let mut st = StrokeLayer::new(rgb(p.color), width); + // Round caps and joins unless the style asks for others. + st.cap = match p.style & 0xf00 { + 0x100 => LineCap::Square, + 0x200 => LineCap::Butt, + _ => LineCap::Round, + }; + st.join = match p.style & 0xf000 { + 0x1000 => LineJoin::Bevel, + 0x2000 => LineJoin::Miter, + _ => LineJoin::Round, + }; + st.miter_limit = self.dc.miter; + let unit = width.max(HAIRLINE); + let pattern: Vec = match kind { + 1 => vec![3.0 * unit, unit], + 2 => vec![unit, unit], + 3 => vec![3.0 * unit, unit, unit, unit], + 4 => vec![3.0 * unit, unit, unit, unit, unit, unit], + 7 => p.entries.iter().map(|e| e * self.scale()).collect(), + _ => vec![], + }; + let dash = Dash { pattern, offset: 0.0, align_corners: false }; + st.dash = dash.is_dashed().then_some(dash); + Some(st) + } + + /// Paint `bp` (document space) with the brush and the pen. + fn draw(&mut self, bp: BezPath, fill: bool, stroke: bool) { + if bp.elements().is_empty() { + return; + } + let mut items = vec![]; + if fill && let Some(p) = self.fill_paint() { + items.push(AppearanceItem::Fill(FillLayer::new(p))); + } + if stroke && let Some(st) = self.stroke_layer() { + items.push(AppearanceItem::Stroke(st)); + } + if items.is_empty() { + return; + } + let mut n = Node::path(vectorcraft_doc::NodeId(0), PathData::from_bezpath(&bp), Appearance { items, ..Appearance::default() }); + if let NodeKind::Path { rule, .. } = &mut n.kind { + *rule = self.dc.fill_rule; + } + self.push(n); + } + + /// Draw the lines drawn one after another so far. + pub fn flush(&mut self) { + if !self.pending.elements().is_empty() { + let bp = std::mem::take(&mut self.pending); + self.draw(bp, false, true); + } + } + + /// A figure in logical units: added to the path being recorded, else drawn (filled when it is + /// an area). + pub fn figure(&mut self, mut bp: BezPath, area: bool) { + bp.apply_affine(self.xf()); + match &mut self.path { + Some(path) => { + path.extend(bp); + self.figure = false; + } + None => self.draw(bp, area, true), + } + } + + /// Polygon / Polyline (and Bézier ones): `pts` in logical units. + pub fn poly(&mut self, pts: &[Point], closed: bool, bezier: bool) { + let Some((first, rest)) = pts.split_first() else { return }; + let mut bp = BezPath::new(); + bp.move_to(*first); + if bezier { + for c in rest.chunks_exact(3) { + bp.curve_to(c[0], c[1], c[2]); + } + } else { + rest.iter().for_each(|p| bp.line_to(*p)); + } + if closed { + bp.close_path(); + } + self.figure(bp, closed); + } + + /// Several polygons (one area) or polylines. + pub fn poly_poly(&mut self, polys: &[Vec], closed: bool) { + let mut bp = BezPath::new(); + for p in polys { + let Some((first, rest)) = p.split_first() else { continue }; + bp.move_to(*first); + rest.iter().for_each(|q| bp.line_to(*q)); + if closed { + bp.close_path(); + } + } + self.figure(bp, closed); + } + + pub fn move_to(&mut self, p: Point) { + self.pos = p; + self.figure = false; + } + + /// LineTo / PolylineTo / PolyBezierTo: from the current position through `pts` (logical). + pub fn poly_to(&mut self, pts: &[Point], bezier: bool) { + let Some(last) = pts.last().copied() else { return }; + let xf = self.xf(); + let start = xf * self.pos; + let target = match &mut self.path { + Some(path) => { + if !self.figure { + path.move_to(start); + self.figure = true; + self.figure_start = self.pos; + } + path + } + None => { + if self.pending.elements().last().and_then(|e| e.end_point()) != Some(start) { + self.pending.move_to(start); + } + &mut self.pending + } + }; + if bezier { + for c in pts.chunks_exact(3) { + target.curve_to(xf * c[0], xf * c[1], xf * c[2]); + } + } else { + pts.iter().for_each(|p| target.line_to(xf * *p)); + } + self.pos = last; + } + + pub fn arc(&mut self, r: Rect, start: Point, end: Point, kind: ArcKind) { + let bp = arc_path(r, start, end, self.dc.arc_ccw, kind); + self.figure(bp, kind != ArcKind::Open); + } + + // ---------- paths and clips ---------- + + pub fn begin_path(&mut self) { + self.path = Some(BezPath::new()); + self.figure = false; + } + pub fn end_path(&mut self) { + // The path stays until it is filled, stroked or made the clip. + } + pub fn close_figure(&mut self) { + if let Some(p) = &mut self.path + && self.figure + { + p.close_path(); + self.figure = false; + self.pos = self.figure_start; + } + } + pub fn abort_path(&mut self) { + self.path = None; + } + + /// FillPath / StrokePath / StrokeAndFillPath. + pub fn paint_path(&mut self, fill: bool, stroke: bool) { + if let Some(mut bp) = self.path.take() { + // Filling closes the open figure. + if fill && self.figure { + bp.close_path(); + } + self.draw(bp, fill, stroke); + } + } + + fn push_clip(&mut self, path: BezPath, rule: FillRule, replace: bool) { + if replace { + self.dc.clips.clear(); + } + if !sane(path.bounding_box()) { + return self.warn(FAR_AWAY); + } + // A rectangle around the whole picture (what writers clip to first) clips nothing in it. + let lines = path.elements().iter().all(|e| !matches!(e, PathEl::QuadTo(..) | PathEl::CurveTo(..))); + let around = path.bounding_box().inflate(0.5, 0.5); + if lines && path.elements().len() <= 6 && around.contains(self.frame.origin()) && around.contains(Point::new(self.frame.x1, self.frame.y1)) { + return; + } + self.next_clip += 1; + self.dc.clips.push(Arc::new(Clip { id: self.next_clip, path, rule })); + } + + /// SelectClipPath (`mode`: 1 and, 5 copy; the others cut out, which isn't read). + pub fn clip_path(&mut self, mode: u32) { + let Some(bp) = self.path.take() else { return }; + match mode { + 1 => self.push_clip(bp, self.dc.fill_rule, false), + 5 => self.push_clip(bp, self.dc.fill_rule, true), + _ => self.warn(EXCLUDED_CLIPS), + } + } + + /// IntersectClipRect (logical units). + pub fn clip_rect(&mut self, r: Rect) { + let mut bp = shapes::rectangle(r.abs()).to_bezpath(); + bp.apply_affine(self.xf()); + self.push_clip(bp, FillRule::NonZero, false); + } + + /// A clip region of `rects` in device units (`mode` as [`Self::clip_path`]); none with mode + /// copy removes the clip. + pub fn clip_region(&mut self, rects: &[Rect], mode: u32) { + if rects.is_empty() { + if mode == 5 { + self.dc.clips.clear(); + } + return; + } + let mut bp = BezPath::new(); + for r in rects { + bp.extend(shapes::rectangle(r.abs()).to_bezpath()); + } + bp.apply_affine(self.dev_to_doc); + match mode { + 1 => self.push_clip(bp, FillRule::NonZero, false), + 5 => self.push_clip(bp, FillRule::NonZero, true), + _ => self.warn(EXCLUDED_CLIPS), + } + } + + pub fn exclude_clip(&mut self) { + self.warn(EXCLUDED_CLIPS); + } + + // ---------- blits, images and text ---------- + + /// A raster operation without a source over `dest` (logical): the brush, white or black. + pub fn pattern_blit(&mut self, dest: Rect, rop: u32) { + let brush = match rop { + PATCOPY => self.dc.brush.clone(), + WHITENESS => BrushObj::Solid([255; 3]), + BLACKNESS => BrushObj::Solid([0; 3]), + _ => return self.skip(), + }; + let pen = std::mem::replace(&mut self.dc.pen, PenObj::NULL); + let outer = std::mem::replace(&mut self.dc.brush, brush); + let mut bp = shapes::rectangle(dest.abs()).to_bezpath(); + bp.apply_affine(self.xf()); + self.draw(bp, true, false); + self.dc.pen = pen; + self.dc.brush = outer; + } + + /// An image whose source rectangle `src` (pixels, from the top-left) is drawn into `dest` + /// (logical: origin and signed size) with raster operation `rop`, at `opacity`. + pub fn image(&mut self, img: Result, src: Rect, dest: Rect, rop: u32, opacity: f64) { + if rop != SRCCOPY { + self.warn(SKIPPED_ROPS); + return; + } + let img = match img { + Ok(i) => i, + Err(_) => return self.warn(BAD_IMAGE), + }; + let img = crop(img, src); + if img.width == 0 || img.height == 0 { + return; + } + let Ok(png) = img.to_png() else { return self.warn(BAD_IMAGE) }; + let blob = ImageBlob::new("image/png", png); + let key = blob.content_key(); + self.doc.images.entry(key.clone()).or_insert(blob); + let (w, h) = (f64::from(img.width), f64::from(img.height)); + let xf = self.xf() * Affine::translate((dest.x0, dest.y0)) * Affine::scale_non_uniform(dest.width() / w, dest.height() / h); + if !xf.as_coeffs().iter().all(|v| v.is_finite()) || xf.determinant().abs() < 1e-12 { + return; + } + let im = ImageObject { key, width: img.width, height: img.height, xf, link: None, placement: Default::default() }; + let mut n = Node::new(vectorcraft_doc::NodeId(0), NodeKind::Image(im)); + n.opacity = opacity.clamp(0.0, 1.0) as f32; + self.push(n); + } + + /// Text at `at` (logical; the current position with TA_UPDATECP) in the current font. + pub fn text(&mut self, at: Point, s: &str) { + let s: String = s.chars().filter(|c| *c != '\0').collect(); + if s.trim().is_empty() { + return; + } + let align = self.dc.text_align; + let at = if align & 1 != 0 { self.pos } else { at }; + let f = self.dc.font.clone(); + let k = self.scale(); + // A negative height is the em; a positive one the cell (the em and the internal leading). + let em = if f.height < 0.0 { -f.height } else { f.height * 0.85 }; + let size = if em > 0.0 && (em * k).is_finite() { (em * k).clamp(0.5, 5000.0) } else { 12.0 }; + let mut style = CharStyle { size, fill: rgb(self.dc.text_color), stroke: Paint::None, ..CharStyle::default() }; + if !f.face.trim().is_empty() { + style.font_family = f.face.trim().to_string(); + } + style.font_style = match (f.weight >= 600, f.italic) { + (true, true) => "Bold Italic", + (true, false) => "Bold", + (false, true) => "Italic", + (false, false) => "Regular", + } + .into(); + let angle = -(f.escapement / 10.0).to_radians(); + let angle = if angle.is_finite() { angle } else { 0.0 }; + // TA_BASELINE, TA_BOTTOM or TA_TOP (the default): where the baseline is. + let drop = match align & 0x18 { + 0x18 => 0.0, + 0x08 => -0.2 * size, + _ => 0.8 * size, + }; + let origin = self.xf() * at; + let mut t = TextObject::point(Point::ZERO, &s, style); + t.xf = Affine::translate(origin.to_vec2()) * Affine::rotate(angle) * Affine::translate((0.0, drop)); + t.para.justify = match align & 0x06 { + 0x06 => Justify::Center, + 0x02 => Justify::Right, + _ => Justify::Left, + }; + t.cached_bounds = Some(vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).bounds); + self.push(Node::new(vectorcraft_doc::NodeId(0), NodeKind::Text(Box::new(t)))); + } + + // ---------- the document ---------- + + /// The document: what was drawn on one layer, clipped art in clipping groups. + fn finish(mut self) -> Imported { + self.flush(); + if self.skipped > 0 { + let n = self.skipped; + self.warnings.push(format!( + "{n} record{} of kinds VectorCraft doesn't read {} skipped", + if n == 1 { "" } else { "s" }, + if n == 1 { "was" } else { "were" } + )); + } + let out = std::mem::take(&mut self.out); + if out.is_empty() && self.emf_plus { + self.warnings.push("the picture is drawn with EMF+ records, which aren't read yet: nothing was imported".into()); + } + let children = nest(&mut self.doc, out, 0); + let mut layer = Node::layer(self.doc.alloc_id(), "Layer 1", LayerColor::Preset(0)); + if let Some(c) = layer.children_mut() { + *c = children; + } + self.doc.layers = vec![Arc::new(layer)]; + Imported { document: self.doc, kind: self.kind, warnings: self.warnings } + } +} + +/// `img` cut to `src` (pixels from the top-left, within the image). +fn crop(img: Rgba, src: Rect) -> Rgba { + let (w, h) = (img.width, img.height); + let s = src.abs(); + let clamp = |v: f64, max: u32| if v.is_finite() { v.round().clamp(0.0, f64::from(max)) as u32 } else { 0 }; + let (x0, y0, x1, y1) = (clamp(s.x0, w), clamp(s.y0, h), clamp(s.x1, w), clamp(s.y1, h)); + if (x0, y0, x1, y1) == (0, 0, w, h) || x1 <= x0 || y1 <= y0 { + return img; + } + let mut pixels = Vec::with_capacity(((x1 - x0) * (y1 - y0) * 4) as usize); + for y in y0..y1 { + let row = (y * w) as usize * 4; + if let Some(p) = img.pixels.get(row + x0 as usize * 4..row + x1 as usize * 4) { + pixels.extend_from_slice(p); + } + } + Rgba { width: x1 - x0, height: y1 - y0, pixels } +} + +type Drawn = Vec<(Vec>, Node)>; + +/// The nodes of `items`, those under clips `depth` and deeper grouped into clipping groups, in +/// order. +fn nest(doc: &mut Document, items: Drawn, depth: usize) -> Vec> { + let mut out: Vec> = vec![]; + let mut group: Drawn = vec![]; + let mut clip: Option> = None; + for (chain, node) in items { + let here = chain.get(depth).cloned(); + if here.as_ref().map(|c| c.id) != clip.as_ref().map(|c| c.id) { + if let Some(c) = clip.take() { + out.push(clip_group(doc, &c, std::mem::take(&mut group), depth)); + } + clip = here.clone(); + } + match here { + Some(_) => group.push((chain, node)), + None => out.push(Arc::new(node)), + } + } + if let Some(c) = clip { + out.push(clip_group(doc, &c, group, depth)); + } + out +} + +/// A clipping group of `items`, clipped by `c` (their clip at `depth`). +fn clip_group(doc: &mut Document, c: &Clip, items: Drawn, depth: usize) -> Arc { + let mut path = Node::path(doc.alloc_id(), PathData::from_bezpath(&c.path), Appearance::default()); + if let NodeKind::Path { clipping, rule, .. } = &mut path.kind { + *clipping = true; + *rule = c.rule; + } + let mut children = vec![Arc::new(path)]; + children.extend(nest(doc, items, depth + 1)); + Arc::new(Node::new(doc.alloc_id(), NodeKind::Group { children, clip: true })) +} + +/// Read a metafile. +pub(crate) fn import(bytes: &[u8]) -> Result { + match crate::sniff(bytes) { + Some(Kind::Emf) => emf::play(bytes), + Some(Kind::Wmf) => wmf::play(bytes), + None => Err("not an EMF or WMF file".into()), + } +} diff --git a/crates/metafile/src/import/emf.rs b/crates/metafile/src/import/emf.rs new file mode 100644 index 00000000..e0eedd01 --- /dev/null +++ b/crates/metafile/src/import/emf.rs @@ -0,0 +1,356 @@ +//! EMF records (MS-EMF) played on a [`Player`]: the header gives the frame (the artboard) and the +//! reference device (device pixels → millimetres), then every record in turn until EMR_EOF. + +use vectorcraft_geom::{Affine, Point, Rect}; + +use super::{ArcKind, BrushObj, FontObj, Obj, PenObj, Player}; +use crate::bytes::{Reader, slice}; +use crate::dib::{self, Alpha}; +use crate::{Imported, Kind}; + +/// Points per millimetre. +const PT_PER_MM: f64 = 72.0 / 25.4; +/// Most points one record may give (a hostile count can't make a huge allocation). +const MAX_POINTS: usize = 1 << 22; +/// Records that change nothing VectorCraft keeps (background mode and colour, raster modes, +/// palettes, colour management, comments…). +const IGNORED: &[u32] = + &[1, 13, 15, 16, 18, 20, 21, 23, 25, 28, 48, 50, 51, 52, 65, 66, 70, 98, 100, 101, 104, 110, 111, 112, 113, 115, 119, 120, 121]; + +fn rect(r: &mut Reader) -> Option { + Some(Rect::new(f64::from(r.i32()?), f64::from(r.i32()?), f64::from(r.i32()?), f64::from(r.i32()?))) +} + +fn point32(r: &mut Reader) -> Option { + Some(Point::new(f64::from(r.i32()?), f64::from(r.i32()?))) +} + +fn point16(r: &mut Reader) -> Option { + Some(Point::new(f64::from(r.i16()?), f64::from(r.i16()?))) +} + +fn color(r: &mut Reader) -> Option<[u8; 3]> { + let [red, g, b, _] = r.u32()?.to_le_bytes(); + Some([red, g, b]) +} + +fn xform(r: &mut Reader) -> Option { + let mut v = [0.0; 6]; + for x in &mut v { + *x = f64::from(r.f32()?); + } + Some(Affine::new(v)) +} + +/// `n` points (32-bit or 16-bit), `None` when the record is shorter. +fn points(r: &mut Reader, n: u32, wide: bool) -> Option> { + let n = n as usize; + if n > MAX_POINTS || r.rest().len() < n * if wide { 8 } else { 4 } { + return None; + } + (0..n).map(|_| if wide { point32(r) } else { point16(r) }).collect() +} + +/// The polygons of a PolyPolygon / PolyPolyline record. +fn polys(r: &mut Reader, wide: bool) -> Option>> { + r.skip(16)?; + let n = r.u32()? as usize; + let total = r.u32()?; + if n > MAX_POINTS || r.rest().len() < n * 4 { + return None; + } + let counts: Vec = (0..n).map(|_| r.u32()).collect::>()?; + let mut all = points(r, total, wide)?.into_iter(); + Some(counts.iter().map(|c| all.by_ref().take(*c as usize).collect()).collect()) +} + +/// A LOGFONTW → the font. +fn font(r: &mut Reader) -> Option { + let height = f64::from(r.i32()?); + r.skip(4)?; + let escapement = f64::from(r.i32()?); + r.skip(4)?; + let weight = r.i32()?; + let italic = r.bytes(1)?.first().is_some_and(|b| *b != 0); + r.skip(7)?; + let units: Vec = (0..32).map_while(|_| r.u16()).take_while(|c| *c != 0).collect(); + Some(FontObj { height, escapement, weight, italic, face: String::from_utf16_lossy(&units) }) +} + +/// The bitmap of an image record: its BITMAPINFO and bits at the offsets the record gives. +fn bitmap(rec: &[u8], off_bmi: u32, cb_bmi: u32, off_bits: u32, cb_bits: u32, alpha: Alpha) -> Result { + let bmi = slice(rec, off_bmi, cb_bmi).ok_or("damaged")?; + let bits = slice(rec, off_bits, cb_bits).ok_or("damaged")?; + dib::decode(bmi, bits, alpha) +} + +/// The rows a DIB's source rectangle counts from the bottom in (StretchDIBits on a bottom-up DIB). +fn bottom_up(rec: &[u8], off_bmi: u32) -> bool { + let at = off_bmi as usize; + let size = rec.get(at..at + 4).and_then(|b| b.try_into().ok()).map(u32::from_le_bytes); + let height = rec.get(at + 8..at + 12).and_then(|b| b.try_into().ok()).map(i32::from_le_bytes); + size.is_some_and(|s| s >= 40) && height.is_some_and(|h| h > 0) +} + +/// The source rectangle `(x, y, w, h)` of an image of `img`'s height, counted from the top. +fn source(x: i32, y: i32, w: i32, h: i32, img: &Result, from_bottom: bool) -> Rect { + let ih = img.as_ref().map_or(0.0, |i| f64::from(i.height)); + let (x, y, w, h) = (f64::from(x), f64::from(y), f64::from(w), f64::from(h)); + let top = if from_bottom { ih - y - h } else { y }; + Rect::new(x, top, x + w, top + h) +} + +/// The size and placement of the picture: (device → document, millimetres a pixel, the artboard). +fn frame(bytes: &[u8]) -> Option<(Affine, f64, Rect)> { + let mut r = Reader::new(bytes); + r.skip(4)?; + let header_size = r.u32()?; + let bounds = rect(&mut r)?; + let frame = rect(&mut r)?; + // Signature, version, bytes, records, handles, description and palette size. + r.skip(32)?; + let device = (f64::from(r.i32()?), f64::from(r.i32()?)); + let mm = (f64::from(r.i32()?), f64::from(r.i32()?)); + // The device in micrometres (more precise), when the header has it. + let micro = if header_size >= 108 { + r.skip(12)?; + Some((f64::from(r.u32()?), f64::from(r.u32()?))) + } else { + None + }; + let ok = |v: f64| v.is_finite() && v > 0.0; + let mm_per_px = match micro { + Some((mx, _)) if ok(mx) && ok(device.0) => mx / 1000.0 / device.0, + _ if ok(mm.0) && ok(device.0) => mm.0 / device.0, + _ => 25.4 / 96.0, + }; + let mm_per_px = if mm_per_px.is_finite() && mm_per_px > 1e-9 { mm_per_px } else { 25.4 / 96.0 }; + let k = mm_per_px * PT_PER_MM; + // The frame (0.01 mm) in device pixels; an empty frame falls back to the bounds. + let f = frame.abs(); + let (origin, size) = if f.width() > 0.0 && f.height() > 0.0 { + (Point::new(f.x0 / 100.0 / mm_per_px, f.y0 / 100.0 / mm_per_px), (f.width() / 100.0 * PT_PER_MM, f.height() / 100.0 * PT_PER_MM)) + } else { + let b = bounds.abs(); + (b.origin(), ((b.width() + 1.0) * k, (b.height() + 1.0) * k)) + }; + let dev_to_doc = Affine::scale(k) * Affine::translate(-origin.to_vec2()); + let side = |v: f64| if v.is_finite() { v.clamp(1.0, 1e6) } else { 1.0 }; + Some((dev_to_doc, mm_per_px, Rect::new(0.0, 0.0, side(size.0), side(size.1)))) +} + +pub(super) fn play(bytes: &[u8]) -> Result { + let (dev_to_doc, mm_per_px, artboard) = frame(bytes).ok_or("the EMF header is damaged")?; + let mut p = Player::new(Kind::Emf, dev_to_doc, mm_per_px, artboard); + let mut at = 0usize; + let mut ended = false; + while let Some(head) = bytes.get(at..at + 8) { + let kind = u32::from_le_bytes([head[0], head[1], head[2], head[3]]); + let size = u32::from_le_bytes([head[4], head[5], head[6], head[7]]) as usize; + let Some(rec) = bytes.get(at..at.saturating_add(size)).filter(|_| size >= 8) else { break }; + if kind == 14 { + ended = true; + break; + } + if !matches!(kind, 27 | 54 | 5 | 6 | 88 | 89) { + p.flush(); + } + if record(&mut p, kind, rec).is_none() { + p.skip(); + } + at += size; + } + if !ended { + p.warn("the file ends early: what it held was imported"); + } + Ok(p.finish()) +} + +/// Play one record (`rec`: all of it, with its type and size). `None`: unknown or damaged. +fn record(p: &mut Player, kind: u32, rec: &[u8]) -> Option<()> { + let mut r = Reader::new(rec.get(8..)?); + match kind { + // PolyBezier, Polygon, Polyline, PolyBezierTo, PolylineTo and their 16-bit forms. + 2..=6 | 85..=89 => { + let wide = kind < 85; + r.skip(16)?; + let n = r.u32()?; + let pts = points(&mut r, n, wide)?; + match if wide { kind } else { kind - 83 } { + 2 => p.poly(&pts, false, true), + 3 => p.poly(&pts, true, false), + 4 => p.poly(&pts, false, false), + 5 => p.poly_to(&pts, true), + _ => p.poly_to(&pts, false), + } + } + 7 | 90 => p.poly_poly(&polys(&mut r, kind == 7)?, false), + 8 | 91 => p.poly_poly(&polys(&mut r, kind == 8)?, true), + 9 => p.set_window_ext(f64::from(r.i32()?), f64::from(r.i32()?)), + 10 => p.set_window_org(f64::from(r.i32()?), f64::from(r.i32()?)), + 11 => p.set_viewport_ext(f64::from(r.i32()?), f64::from(r.i32()?)), + 12 => p.set_viewport_org(f64::from(r.i32()?), f64::from(r.i32()?)), + 17 => p.set_map_mode(r.u32()?), + 19 => p.set_fill_mode(r.u32()? == 1), + 22 => p.set_text_align(r.u32()?), + 24 => p.set_text_color(color(&mut r)?), + 27 => p.move_to(point32(&mut r)?), + 29 => p.exclude_clip(), + 30 => p.clip_rect(rect(&mut r)?), + 31 | 32 => { + let (xn, xd, yn, yd) = (r.i32()?, r.i32()?, r.i32()?, r.i32()?); + p.scale_ext(kind == 32, (f64::from(xn), f64::from(xd)), (f64::from(yn), f64::from(yd))); + } + 33 => p.save(), + 34 => p.restore(r.i32()?), + 35 => p.world(xform(&mut r)?, 4), + 36 => { + let x = xform(&mut r)?; + p.world(x, r.u32()?); + } + 37 => p.select(r.u32()?), + 38 => { + let ih = r.u32()?; + let style = r.u32()?; + let width = f64::from(r.i32()?); + r.skip(4)?; + p.create_at(ih, Obj::Pen(PenObj { style, width, color: color(&mut r)?, entries: vec![] })); + } + 39 => { + let ih = r.u32()?; + let style = r.u32()?; + p.create_at(ih, Obj::Brush(BrushObj::from_log(style, color(&mut r)?))); + } + 40 => p.delete(r.u32()?), + 42 => p.figure(vectorcraft_geom::shapes::ellipse(rect(&mut r)?.abs()).to_bezpath(), true), + 43 => p.figure(vectorcraft_geom::shapes::rectangle(rect(&mut r)?.abs()).to_bezpath(), true), + 44 => { + let b = rect(&mut r)?; + let (cx, cy) = (f64::from(r.i32()?), f64::from(r.i32()?)); + p.figure(super::round_rect(b, cx, cy), true); + } + 45..=47 => { + let b = rect(&mut r)?; + let (s, e) = (point32(&mut r)?, point32(&mut r)?); + p.arc(b, s, e, [ArcKind::Open, ArcKind::Chord, ArcKind::Pie][(kind - 45) as usize]); + } + 49 | 99 | 122 => p.create_at(r.u32()?, Obj::Other), + 54 => { + let pt = point32(&mut r)?; + p.poly_to(&[pt], false); + } + 57 => p.set_arc_ccw(r.u32()? != 2), + 58 => p.set_miter(f64::from(r.u32()?)), + 59 => p.begin_path(), + 60 => p.end_path(), + 61 => p.close_figure(), + 62 => p.paint_path(true, false), + 63 => p.paint_path(true, true), + 64 => p.paint_path(false, true), + 67 => p.clip_path(r.u32()?), + 68 => p.abort_path(), + 70 => { + // EMF+ records ride in comments starting "EMF+". + r.skip(4)?; + if r.u32()? == 0x2B46_4D45 { + p.emf_plus = true; + } + } + 75 => { + let cb = r.u32()?; + let mode = r.u32()?; + let data = r.bytes(cb as usize)?; + let mut d = Reader::new(data); + let rects = if cb >= 32 { + d.skip(8)?; + let n = d.u32()? as usize; + d.skip(20)?; + if n > MAX_POINTS || d.rest().len() < n * 16 { + return None; + } + (0..n).map(|_| rect(&mut d)).collect::>>()? + } else { + vec![] + }; + p.clip_region(&rects, mode); + } + 76 | 77 => { + r.skip(16)?; + let dest = (r.i32()?, r.i32()?, r.i32()?, r.i32()?); + let rop = r.u32()?; + let (xs, ys) = (r.i32()?, r.i32()?); + r.skip(24 + 4 + 4)?; + let (off_bmi, cb_bmi, off_bits, cb_bits) = (r.u32()?, r.u32()?, r.u32()?, r.u32()?); + let (ws, hs) = if kind == 77 { (r.i32()?, r.i32()?) } else { (dest.2, dest.3) }; + let d = Rect::new(f64::from(dest.0), f64::from(dest.1), f64::from(dest.0) + f64::from(dest.2), f64::from(dest.1) + f64::from(dest.3)); + if cb_bmi == 0 { + p.pattern_blit(d, rop); + } else { + let img = bitmap(rec, off_bmi, cb_bmi, off_bits, cb_bits, Alpha::Ignore); + let src = source(xs, ys, ws, hs, &img, false); + p.image(img, src, d, rop, 1.0); + } + } + 81 => { + r.skip(16)?; + let (xd, yd, xs, ys, ws, hs) = (r.i32()?, r.i32()?, r.i32()?, r.i32()?, r.i32()?, r.i32()?); + let (off_bmi, cb_bmi, off_bits, cb_bits) = (r.u32()?, r.u32()?, r.u32()?, r.u32()?); + r.skip(4)?; + let rop = r.u32()?; + let (wd, hd) = (r.i32()?, r.i32()?); + let img = bitmap(rec, off_bmi, cb_bmi, off_bits, cb_bits, Alpha::Ignore); + let src = source(xs, ys, ws, hs, &img, bottom_up(rec, off_bmi)); + let d = Rect::new(f64::from(xd), f64::from(yd), f64::from(xd) + f64::from(wd), f64::from(yd) + f64::from(hd)); + p.image(img, src, d, rop, 1.0); + } + 82 => { + let ih = r.u32()?; + p.create_at(ih, Obj::Font(font(&mut r)?)); + } + 83 | 84 => { + r.skip(16 + 4 + 8)?; + let at = point32(&mut r)?; + let n = r.u32()? as usize; + let off = r.u32()?; + let s = if kind == 84 { + let raw = slice(rec, off, u32::try_from(n.checked_mul(2)?).ok()?)?; + String::from_utf16_lossy(&raw.chunks_exact(2).map(|c| u16::from_le_bytes([c[0], c[1]])).collect::>()) + } else { + slice(rec, off, u32::try_from(n).ok()?)?.iter().map(|b| char::from(*b)).collect() + }; + p.text(at, &s); + } + 93 | 94 => p.create_at(r.u32()?, Obj::Brush(BrushObj::Pattern)), + 95 => { + let ih = r.u32()?; + r.skip(16)?; + let style = r.u32()?; + let width = f64::from(r.u32()?); + let brush = r.u32()?; + let c = color(&mut r)?; + r.skip(4)?; + let n = (r.u32()? as usize).min(64); + let entries: Vec = (0..n).map_while(|_| r.u32()).map(f64::from).collect(); + // A pen painted with the null brush draws nothing. + let style = if brush == 1 { super::PS_NULL } else { style }; + p.create_at(ih, Obj::Pen(PenObj { style, width, color: c, entries })); + } + 114 => { + r.skip(16)?; + let (xd, yd, wd, hd) = (r.i32()?, r.i32()?, r.i32()?, r.i32()?); + let blend = r.bytes(4)?; + let (constant, per_pixel) = (blend.get(2).copied().unwrap_or(255), blend.get(3).is_some_and(|f| *f & 1 != 0)); + let (xs, ys) = (r.i32()?, r.i32()?); + r.skip(24 + 4 + 4)?; + let (off_bmi, cb_bmi, off_bits, cb_bits) = (r.u32()?, r.u32()?, r.u32()?, r.u32()?); + let (ws, hs) = (r.i32()?, r.i32()?); + let img = bitmap(rec, off_bmi, cb_bmi, off_bits, cb_bits, if per_pixel { Alpha::Premultiplied } else { Alpha::Ignore }); + let src = source(xs, ys, ws, hs, &img, false); + let d = Rect::new(f64::from(xd), f64::from(yd), f64::from(xd) + f64::from(wd), f64::from(yd) + f64::from(hd)); + p.image(img, src, d, crate::import::SRCCOPY, f64::from(constant) / 255.0); + } + k if IGNORED.contains(&k) => {} + _ => return None, + } + Some(()) +} diff --git a/crates/metafile/src/import/wmf.rs b/crates/metafile/src/import/wmf.rs new file mode 100644 index 00000000..1a811746 --- /dev/null +++ b/crates/metafile/src/import/wmf.rs @@ -0,0 +1,287 @@ +//! WMF records (MS-WMF) played on a [`Player`]. A placeable header gives the picture's box in +//! logical units and the units an inch; without one, the first window origin and extent are the +//! box, at 1440 units an inch. + +use vectorcraft_geom::{Affine, Point, Rect, shapes}; + +use super::{ArcKind, BrushObj, FontObj, Obj, PenObj, Player}; +use crate::bytes::Reader; +use crate::dib::{self, Alpha}; +use crate::wmf::PLACEABLE_KEY; +use crate::{Imported, Kind}; + +/// Units an inch when the file doesn't say (twips). +const DEFAULT_INCH: f64 = 1440.0; +/// Records that change nothing VectorCraft keeps (background, raster and mapping details, palettes, +/// escapes). +const IGNORED: &[u16] = &[ + 0x0102, 0x0103, 0x0104, 0x0105, 0x0107, 0x0108, 0x0201, 0x020A, 0x020B, 0x020C, 0x020D, 0x020E, 0x020F, 0x0211, 0x0410, 0x0412, 0x0231, 0x0234, + 0x0035, 0x0037, 0x0139, 0x0436, 0x0626, +]; + +fn point(r: &mut Reader) -> Option { + Some(Point::new(f64::from(r.i16()?), f64::from(r.i16()?))) +} + +/// A point stored y first. +fn point_yx(r: &mut Reader) -> Option { + let y = f64::from(r.i16()?); + Some(Point::new(f64::from(r.i16()?), y)) +} + +/// A rectangle stored bottom, right, top, left. +fn rect_brtl(r: &mut Reader) -> Option { + let (b, rt, t, l) = (r.i16()?, r.i16()?, r.i16()?, r.i16()?); + Some(Rect::new(f64::from(l), f64::from(t), f64::from(rt), f64::from(b))) +} + +fn color(r: &mut Reader) -> Option<[u8; 3]> { + let [red, g, b, _] = r.u32()?.to_le_bytes(); + Some([red, g, b]) +} + +fn points(r: &mut Reader, n: usize) -> Option> { + if r.rest().len() < n * 4 { + return None; + } + (0..n).map(|_| point(r)).collect() +} + +/// ANSI text (read as Latin-1). +fn ansi(b: &[u8]) -> String { + b.iter().map(|c| char::from(*c)).collect() +} + +/// The records' start, and the box and units an inch of a placeable header. +fn header(bytes: &[u8]) -> Option<(usize, Option<(Rect, f64)>)> { + let mut r = Reader::new(bytes); + let placeable = if r.u32()? == PLACEABLE_KEY { + r.skip(2)?; + let (l, t, rt, b) = (r.i16()?, r.i16()?, r.i16()?, r.i16()?); + let inch = r.u16()?; + let inch = if inch == 0 { DEFAULT_INCH } else { f64::from(inch) }; + Some((Rect::new(f64::from(l), f64::from(t), f64::from(rt), f64::from(b)).abs(), inch)) + } else { + None + }; + let start = if placeable.is_some() { 22 } else { 0 }; + let mut h = Reader::new(bytes.get(start..)?); + h.skip(2)?; + let words = usize::from(h.u16()?); + if words < 9 { + return None; + } + Some((start + words * 2, placeable)) +} + +/// The first window origin and extent, as the picture's box when there is no placeable header. +fn window(bytes: &[u8], mut at: usize) -> Option { + let (mut org, mut ext) = (None, None); + while let Some(head) = bytes.get(at..at + 6) { + let words = u32::from_le_bytes([head[0], head[1], head[2], head[3]]) as usize; + let function = u16::from_le_bytes([head[4], head[5]]); + if words < 3 || function == 0 { + break; + } + let mut r = Reader::new(bytes.get(at + 6..).unwrap_or_default()); + match function { + 0x020B if org.is_none() => org = point_yx(&mut r), + 0x020C if ext.is_none() => ext = point_yx(&mut r), + _ => {} + } + if org.is_some() && ext.is_some() { + break; + } + at = at.saturating_add(words.saturating_mul(2)); + } + let o = org.unwrap_or(Point::ZERO); + let e = ext?; + Some(Rect::new(o.x, o.y, o.x + e.x, o.y + e.y).abs()) +} + +pub(super) fn play(bytes: &[u8]) -> Result { + let (start, placeable) = header(bytes).ok_or("the WMF header is damaged")?; + let (frame, inch) = match placeable { + Some((b, inch)) if b.width() > 0.0 && b.height() > 0.0 => (Some(b), inch), + _ => (window(bytes, start).filter(|w| w.width() > 0.0 && w.height() > 0.0), DEFAULT_INCH), + }; + let k = 72.0 / inch; + let origin = frame.map_or(Point::ZERO, |f| f.origin()); + let artboard = frame.map_or(Rect::new(0.0, 0.0, 1.0, 1.0), |f| Rect::new(0.0, 0.0, f.width() * k, f.height() * k)); + let mut p = Player::new(Kind::Wmf, Affine::scale(k) * Affine::translate(-origin.to_vec2()), 25.4 / inch, artboard); + let mut at = start; + let mut ended = false; + while let Some(head) = bytes.get(at..at + 6) { + let words = u32::from_le_bytes([head[0], head[1], head[2], head[3]]) as usize; + let function = u16::from_le_bytes([head[4], head[5]]); + if function == 0 { + ended = true; + break; + } + let Some(rec) = words.checked_mul(2).and_then(|n| bytes.get(at..at.checked_add(n)?)).filter(|_| words >= 3) else { break }; + if !matches!(function, 0x0213 | 0x0214) { + p.flush(); + } + if record(&mut p, function, rec.get(6..).unwrap_or_default()).is_none() { + p.skip(); + } + at += rec.len(); + } + if !ended { + p.warn("the file ends early: what it held was imported"); + } + let mut out = p.finish(); + // No box given: the art's bounds. + if frame.is_none() + && let Some(b) = out.document.layers.first().and_then(|l| vectorcraft_doc::live::nodes_bounds(l.children().map_or(&[][..], |c| c.as_slice()))) + && let Some(a) = out.document.artboards.first_mut() + { + a.rect = Rect::from_origin_size(b.origin(), (b.width().max(1.0), b.height().max(1.0))); + } + Ok(out) +} + +/// An image record's source rectangle (from the bottom for a bottom-up DIB) and destination. +fn blit(p: &mut Player, rop: u32, src: (i16, i16, i16, i16), dest: (i16, i16, i16, i16), packed: Option<&[u8]>) { + let (xd, yd, wd, hd) = (f64::from(dest.0), f64::from(dest.1), f64::from(dest.2), f64::from(dest.3)); + let d = Rect::new(xd, yd, xd + wd, yd + hd); + let Some(packed) = packed else { return p.pattern_blit(d, rop) }; + let parts = dib::split(packed); + let img = parts.ok_or_else(|| "damaged".to_string()).and_then(|(bmi, bits)| dib::decode(bmi, bits, Alpha::Ignore)); + let bottom_up = parts.and_then(|(bmi, _)| bmi.get(8..12)).is_some_and(|h| i32::from_le_bytes([h[0], h[1], h[2], h[3]]) > 0); + let ih = img.as_ref().map_or(0.0, |i| f64::from(i.height)); + let (xs, ys, ws, hs) = (f64::from(src.0), f64::from(src.1), f64::from(src.2), f64::from(src.3)); + let top = if bottom_up { ih - ys - hs } else { ys }; + p.image(img, Rect::new(xs, top, xs + ws, top + hs), d, rop, 1.0); +} + +/// Play one record (`r`: its parameters). `None`: unknown or damaged. +fn record(p: &mut Player, function: u16, body: &[u8]) -> Option<()> { + let mut r = Reader::new(body); + match function { + 0x001E => p.save(), + 0x0127 => p.restore(i32::from(r.i16()?)), + 0x0106 => p.set_fill_mode(r.u16()? == 1), + 0x0209 => p.set_text_color(color(&mut r)?), + 0x012E => p.set_text_align(u32::from(r.u16()?)), + 0x0214 => p.move_to(point_yx(&mut r)?), + 0x0213 => { + let pt = point_yx(&mut r)?; + p.poly_to(&[pt], false); + } + 0x0324 | 0x0325 => { + let n = usize::try_from(r.i16()?).ok()?; + let pts = points(&mut r, n)?; + p.poly(&pts, function == 0x0324, false); + } + 0x0538 => { + let n = usize::from(r.u16()?); + if r.rest().len() < n * 2 { + return None; + } + let counts: Vec = (0..n).map(|_| r.u16().map(usize::from)).collect::>()?; + let mut polys = Vec::with_capacity(n); + for c in counts { + polys.push(points(&mut r, c)?); + } + p.poly_poly(&polys, true); + } + 0x041B => p.figure(shapes::rectangle(rect_brtl(&mut r)?.abs()).to_bezpath(), true), + 0x0418 => p.figure(shapes::ellipse(rect_brtl(&mut r)?.abs()).to_bezpath(), true), + 0x061C => { + let (h, w) = (f64::from(r.i16()?), f64::from(r.i16()?)); + p.figure(super::round_rect(rect_brtl(&mut r)?, w, h), true); + } + 0x0817 | 0x081A | 0x0830 => { + let (end, start) = (point_yx(&mut r)?, point_yx(&mut r)?); + let b = rect_brtl(&mut r)?; + let kind = match function { + 0x0817 => ArcKind::Open, + 0x081A => ArcKind::Pie, + _ => ArcKind::Chord, + }; + p.arc(b, start, end, kind); + } + 0x0416 => p.clip_rect(rect_brtl(&mut r)?), + 0x0415 => p.exclude_clip(), + 0x012D => p.select(u32::from(r.u16()?)), + 0x01F0 => p.delete(u32::from(r.u16()?)), + 0x02FA => { + let style = u32::from(r.u16()?); + let width = f64::from(r.i16()?); + r.skip(2)?; + p.create(Obj::Pen(PenObj { style, width, color: color(&mut r)?, entries: vec![] })); + } + 0x02FC => { + let style = u32::from(r.u16()?); + p.create(Obj::Brush(BrushObj::from_log(style, color(&mut r)?))); + } + 0x02FB => { + let height = f64::from(r.i16()?); + r.skip(2)?; + let escapement = f64::from(r.i16()?); + r.skip(2)?; + let weight = i32::from(r.i16()?); + let italic = r.bytes(1)?.first().is_some_and(|b| *b != 0); + r.skip(7)?; + let face: Vec = r.rest().iter().take(32).take_while(|b| **b != 0).copied().collect(); + p.create(Obj::Font(FontObj { height, escapement, weight, italic, face: ansi(&face) })); + } + 0x00F7 | 0x06FF => p.create(Obj::Other), + 0x01F9 | 0x0142 => p.create(Obj::Brush(BrushObj::Pattern)), + 0x0521 => { + let n = usize::try_from(r.i16()?).ok()?; + let s = ansi(r.bytes(n)?); + r.skip(n % 2)?; + let at = point_yx(&mut r)?; + p.text(at, &s); + } + 0x0A32 => { + let at = point_yx(&mut r)?; + let n = usize::try_from(r.i16()?).ok()?; + let opts = r.u16()?; + // ETO_OPAQUE or ETO_CLIPPED: a rectangle comes first. + if opts & 0x6 != 0 { + r.skip(8)?; + } + let s = ansi(r.bytes(n)?); + p.text(at, &s); + } + 0x0F43 => { + let rop = r.u32()?; + r.skip(2)?; + let (hs, ws, ys, xs) = (r.i16()?, r.i16()?, r.i16()?, r.i16()?); + let (hd, wd, yd, xd) = (r.i16()?, r.i16()?, r.i16()?, r.i16()?); + blit(p, rop, (xs, ys, ws, hs), (xd, yd, wd, hd), Some(r.rest())); + } + 0x0B41 => { + let rop = r.u32()?; + let (hs, ws, ys, xs) = (r.i16()?, r.i16()?, r.i16()?, r.i16()?); + // Without a bitmap, a reserved word comes before the destination. + let with_bitmap = body.len() > 22; + if !with_bitmap { + r.skip(2)?; + } + let (hd, wd, yd, xd) = (r.i16()?, r.i16()?, r.i16()?, r.i16()?); + blit(p, rop, (xs, ys, ws, hs), (xd, yd, wd, hd), with_bitmap.then(|| r.rest())); + } + 0x0940 => { + let rop = r.u32()?; + let (ys, xs) = (r.i16()?, r.i16()?); + let with_bitmap = body.len() > 20; + if !with_bitmap { + r.skip(2)?; + } + let (h, w, yd, xd) = (r.i16()?, r.i16()?, r.i16()?, r.i16()?); + blit(p, rop, (xs, ys, w, h), (xd, yd, w, h), with_bitmap.then(|| r.rest())); + } + 0x061D => { + let rop = r.u32()?; + let (h, w, y, x) = (f64::from(r.i16()?), f64::from(r.i16()?), f64::from(r.i16()?), f64::from(r.i16()?)); + p.pattern_blit(Rect::new(x, y, x + w, y + h), rop); + } + f if IGNORED.contains(&f) => {} + _ => return None, + } + Some(()) +} diff --git a/crates/metafile/src/lib.rs b/crates/metafile/src/lib.rs new file mode 100644 index 00000000..b1ab24fc --- /dev/null +++ b/crates/metafile/src/lib.rs @@ -0,0 +1,112 @@ +//! Windows metafiles: hand-written EMF and WMF writers and readers. +//! +//! [`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). +//! +//! [`import`] reads either format back into a document of one artboard (the picture's frame) and +//! one layer: paths with fills and strokes, clipping, images and text. Records it doesn't read are +//! skipped, with one warning. +//! +//! - `bytes`: little-endian writing and bounds-checked reading. +//! - `dib`: device-independent bitmaps, both ways. +//! - `scene`: the document walk both writers share. +//! - `emf`, `wmf`: the writers. +//! - `import`: the reader. + +mod bytes; +mod dib; +mod emf; +mod import; +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 }) +} + +/// A metafile read into a document. +#[derive(Clone, Debug)] +pub struct Imported { + pub document: Document, + pub kind: Kind, + /// What was approximated or skipped. + pub warnings: Vec, +} + +/// Read an EMF or WMF file (with or without a placeable header) into a document whose one +/// artboard is the picture's frame. +pub fn import(bytes: &[u8]) -> Result { + import::import(bytes) +} + +#[cfg(test)] +mod tests; + +#[cfg(test)] +mod tests_import; 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/tests_import.rs b/crates/metafile/src/tests_import.rs new file mode 100644 index 00000000..a91a764a --- /dev/null +++ b/crates/metafile/src/tests_import.rs @@ -0,0 +1,311 @@ +//! Metafiles read back: VectorCraft's own EMF and WMF round-trip, hand-built records of other +//! writers play as they draw, and damaged files fail or import partly without a panic. + +use vectorcraft_color::{Gradient, GradientPaint, Paint}; +use vectorcraft_doc::{AppearanceItem, Dash, Document, Node, NodeKind}; +use vectorcraft_geom::{Affine, Rect, shapes}; + +use crate::bytes::Out; +use crate::tests::*; +use crate::*; + +/// Every object of the imported document, depth first. +fn all(d: &Document) -> Vec { + let mut v = vec![]; + for l in &d.layers { + for c in l.children().into_iter().flatten() { + c.walk(&mut |n| v.push(n.clone())); + } + } + v +} + +fn paths(d: &Document) -> Vec { + all(d).into_iter().filter(|n| matches!(n.kind, NodeKind::Path { clipping: false, .. })).collect() +} + +fn near(a: Rect, b: Rect, tol: f64) -> bool { + [(a.x0, b.x0), (a.y0, b.y0), (a.x1, b.x1), (a.y1, b.y1)].iter().all(|(x, y)| (x - y).abs() <= tol) +} + +fn fill_rgb(n: &Node) -> Option<[u8; 3]> { + n.appearance.items.iter().find_map(|i| match i { + AppearanceItem::Fill(f) => f.paint.color().map(|c| { + let [r, g, b, _] = c.to_rgba8(1.0); + [r, g, b] + }), + _ => None, + }) +} + +fn sample() -> Document { + let mut d = doc_with(200.0, 100.0, vec![]); + add(&mut d, path(shapes::rectangle(Rect::new(10.0, 10.0, 90.0, 60.0)), red(), blue(), 4.0)); + add(&mut d, path(shapes::ellipse(Rect::new(100.0, 20.0, 180.0, 80.0)), blue(), Paint::None, 0.0)); + d +} + +#[test] +fn emf_output_round_trips() { + let d = sample(); + let out = emf(&d); + let back = import(&out.bytes).unwrap(); + assert_eq!(back.kind, Kind::Emf); + assert!(back.warnings.is_empty(), "{:?}", back.warnings); + let ab = back.document.artboards[0].rect; + assert!(near(ab, Rect::new(0.0, 0.0, 200.0, 100.0), 0.02), "{ab:?}"); + let p = paths(&back.document); + // The rectangle's fill (its stroke is drawn after it, as another path) and the ellipse. + let rect: Vec<&Node> = p.iter().filter(|n| fill_rgb(n) == Some([255, 0, 0])).collect(); + assert_eq!(rect.len(), 1); + assert!(near(rect[0].geometric_bounds().unwrap(), Rect::new(10.0, 10.0, 90.0, 60.0), 0.03)); + let stroke = p.iter().find_map(|n| n.appearance.stroke().cloned()).unwrap(); + assert!((stroke.width - 4.0).abs() < 0.02, "{}", stroke.width); + assert_eq!(stroke.paint.color().map(|c| c.to_rgba8(1.0)), Some([0, 0, 255, 255])); + let ellipse = p.iter().find(|n| fill_rgb(n) == Some([0, 0, 255])).unwrap(); + assert!(near(ellipse.geometric_bounds().unwrap(), Rect::new(100.0, 20.0, 180.0, 80.0), 0.03)); + assert!( + matches!(&ellipse.kind, NodeKind::Path { path, .. } if path.to_bezpath().elements().iter().any(|e| matches!(e, kurbo::PathEl::CurveTo(..)))) + ); +} + +#[test] +fn wmf_output_round_trips() { + let d = sample(); + let back = import(&wmf(&d).bytes).unwrap(); + assert_eq!(back.kind, Kind::Wmf); + assert!(back.warnings.is_empty(), "{:?}", back.warnings); + assert!(near(back.document.artboards[0].rect, Rect::new(0.0, 0.0, 200.0, 100.0), 0.05)); + let p = paths(&back.document); + let rect = p.iter().find(|n| fill_rgb(n) == Some([255, 0, 0])).unwrap(); + // 1440 units an inch: within a twentieth of a point. + assert!(near(rect.geometric_bounds().unwrap(), Rect::new(10.0, 10.0, 90.0, 60.0), 0.05)); + let stroke = p.iter().find_map(|n| n.appearance.stroke().cloned()).unwrap(); + assert!((stroke.width - 4.0).abs() < 0.05); + let ellipse = p.iter().find(|n| fill_rgb(n) == Some([0, 0, 255])).unwrap(); + assert!(near(ellipse.geometric_bounds().unwrap(), Rect::new(100.0, 20.0, 180.0, 80.0), 0.1)); +} + +#[test] +fn dashes_gradients_and_images_round_trip_through_emf() { + let mut line = path(shapes::line((10.0, 90.0).into(), (190.0, 90.0).into()), Paint::None, red(), 2.0); + line.appearance.stroke_mut().unwrap().dash = Some(Dash { pattern: vec![6.0, 3.0], offset: 0.0, align_corners: false }); + let g = Paint::Gradient(Box::new(GradientPaint::new(Gradient::default()))); + let mut d = doc_with(200.0, 100.0, vec![line, path(shapes::rectangle(Rect::new(10.0, 10.0, 60.0, 30.0)), g, Paint::None, 0.0)]); + let img = image_node(&mut d, png(4, 2, [0, 200, 0, 255]), 4, 2, Affine::translate((100.0, 10.0)) * Affine::scale(5.0)); + add(&mut d, img); + let back = import(&emf(&d).bytes).unwrap(); + assert!(back.warnings.is_empty(), "{:?}", back.warnings); + let nodes = all(&back.document); + let dash = nodes.iter().find_map(|n| n.appearance.stroke().and_then(|s| s.dash.clone())).unwrap(); + assert!((dash.pattern[0] - 6.0).abs() < 0.02 && (dash.pattern[1] - 3.0).abs() < 0.02, "{dash:?}"); + // The gradient: an image clipped to its rectangle (the picture's own clip is dropped); the + // image: its pixels where they were. + let groups: Vec<&Node> = nodes.iter().filter(|n| matches!(n.kind, NodeKind::Group { clip: true, .. })).collect(); + assert_eq!(groups.len(), 1); + let clipped = groups[0].children().unwrap(); + assert!(near(clipped[0].geometric_bounds().unwrap(), Rect::new(10.0, 10.0, 60.0, 30.0), 0.03)); + assert!(matches!(clipped[1].kind, NodeKind::Image(_))); + let im = nodes + .iter() + .find_map(|n| match &n.kind { + NodeKind::Image(im) if im.width == 4 => Some(im.clone()), + _ => None, + }) + .unwrap(); + assert_eq!(im.height, 2); + let placed = im.xf.transform_rect_bbox(Rect::new(0.0, 0.0, 4.0, 2.0)); + assert!(near(placed, Rect::new(100.0, 10.0, 120.0, 20.0), 0.05), "{placed:?}"); + let px = image::load_from_memory(&back.document.images[&im.key].bytes).unwrap().to_rgba8(); + assert_eq!(px.get_pixel(0, 0).0, [0, 200, 0, 255]); +} + +// ---------- hand-built records ---------- + +/// An EMF of `records` (type, body) with a frame of `w` × `h` hundredths of a millimetre on a +/// device of 100 pixels a millimetre. +fn hand_emf(w: i32, h: i32, records: &[(u32, Vec)]) -> Vec { + let mut o = Out::default(); + let rec = |o: &mut Out, kind: u32, body: &[u8]| { + o.u32(kind); + o.u32(8 + body.len() as u32); + o.bytes(body); + }; + let mut head = Out::default(); + head.bytes(&[0; 16]); + for v in [0, 0, w, h] { + head.i32(v); + } + head.u32(0x464D_4520); + head.u32(0x10000); + head.bytes(&[0; 8]); + head.u16(1); + head.u16(0); + head.bytes(&[0; 12]); + for v in [10000, 10000, 100, 100] { + head.i32(v); + } + rec(&mut o, 1, &head.0); + for (k, body) in records { + rec(&mut o, *k, body); + } + rec(&mut o, 14, &[0, 0, 0, 0, 16, 0, 0, 0, 20, 0, 0, 0]); + o.0 +} + +fn le(values: &[i32]) -> Vec { + values.iter().flat_map(|v| v.to_le_bytes()).collect() +} + +#[test] +fn hand_built_emf_plays_like_gdi() { + let mut text = vec![0; 16]; + text.extend(le(&[1, 0, 0])); + // At (1000, 1500), 2 characters at offset 76 (from the record start). + text.extend(le(&[1000, 1500, 2, 76, 0, 0, 0, 0, 0, 0])); + text.extend("Hi".encode_utf16().flat_map(u16::to_le_bytes)); + let mut font = le(&[2, -1000, 0, 0, 0, 700]); + font.extend([0u8; 8]); + font.extend("Arial".encode_utf16().chain(std::iter::repeat_n(0, 27)).flat_map(u16::to_le_bytes)); + let records = vec![ + // A green brush in slot 1, selected; a null pen. + (39, le(&[1, 0, 0x00_ff_00, 0])), + (37, le(&[1])), + (37, le(&[0x8000_0008u32 as i32])), + // A rectangle of 10 × 5 mm at (10, 10) mm. + (43, le(&[1000, 1000, 2000, 1500])), + // A record nobody knows, and one of a kind not read. + (9999, le(&[1, 2])), + (41, le(&[0, 0, 10, 0, 0])), + // Clip to a rectangle; a black pen draws two lines one after the other. + (30, le(&[0, 0, 1500, 1500])), + (37, le(&[0x8000_0007u32 as i32])), + (27, le(&[0, 0])), + (54, le(&[500, 0])), + (54, le(&[500, 500])), + // A 10 mm bold font and red text. + (82, font), + (37, le(&[2])), + (24, le(&[0xff])), + (84, text), + ]; + let back = import(&hand_emf(5000, 3000, &records)).unwrap(); + let mm = 72.0 / 25.4; + assert!(near(back.document.artboards[0].rect, Rect::new(0.0, 0.0, 50.0 * mm, 30.0 * mm), 0.01)); + assert_eq!(back.warnings, vec!["2 records of kinds VectorCraft doesn't read were skipped".to_string()]); + let layer = back.document.layers[0].children().unwrap().clone(); + // The rectangle outside the clip; the lines and the text in a clipping group. + assert_eq!(layer.len(), 2); + assert_eq!(fill_rgb(&layer[0]), Some([0, 255, 0])); + assert!(near(layer[0].geometric_bounds().unwrap(), Rect::new(10.0 * mm, 10.0 * mm, 20.0 * mm, 15.0 * mm), 0.01)); + assert!(layer[0].appearance.stroke().is_none(), "the null pen"); + let NodeKind::Group { children, clip: true } = &layer[1].kind else { panic!("a clipping group") }; + assert!(matches!(children[0].kind, NodeKind::Path { clipping: true, .. })); + assert!(near(children[0].geometric_bounds().unwrap(), Rect::new(0.0, 0.0, 15.0 * mm, 15.0 * mm), 0.01)); + // The two lines drawn one after the other are one path. + let lines = &children[1]; + assert_eq!(lines.appearance.items.len(), 1); + assert!(near(lines.geometric_bounds().unwrap(), Rect::new(0.0, 0.0, 5.0 * mm, 5.0 * mm), 0.01)); + let NodeKind::Text(t) = &children[2].kind else { panic!("text") }; + assert_eq!(t.plain_text(), "Hi"); + let st = &t.runs[0].style; + assert_eq!((st.font_family.as_str(), st.font_style.as_str()), ("Arial", "Bold")); + assert!((st.size - 10.0 * mm).abs() < 0.01); + assert_eq!(st.fill.color().map(|c| c.to_rgba8(1.0)), Some([255, 0, 0, 255])); +} + +#[test] +fn emf_mapping_modes_and_world_transforms() { + let records = vec![ + // MM_ANISOTROPIC: a 100 × 100 window on a 1000 × 2000 viewport. + (17, le(&[8])), + (9, le(&[100, 100])), + (11, le(&[1000, 2000])), + (43, le(&[0, 0, 10, 10])), + // Then a world transform moving by (5, 0) logical units. + (35, [1.0f32, 0.0, 0.0, 1.0, 5.0, 0.0].iter().flat_map(|v| v.to_le_bytes()).collect()), + (43, le(&[0, 0, 10, 10])), + ]; + let back = import(&hand_emf(10000, 10000, &records)).unwrap(); + let p = paths(&back.document); + let mm = 72.0 / 25.4; + // 10 units → 100 × 200 pixels → 1 × 2 mm. + assert!(near(p[0].geometric_bounds().unwrap(), Rect::new(0.0, 0.0, mm, 2.0 * mm), 0.01)); + assert!(near(p[1].geometric_bounds().unwrap(), Rect::new(0.5 * mm, 0.0, 1.5 * mm, 2.0 * mm), 0.01)); +} + +/// A placeable WMF of `records` (function, parameters) in a `w` × `h` box at 1440 units an inch. +fn hand_wmf(w: i16, h: i16, records: &[(u16, Vec)]) -> Vec { + let mut o = Out::default(); + o.u32(0x9AC6_CDD7); + o.u16(0); + for v in [0, 0, w, h] { + o.i16(v); + } + o.u16(1440); + o.u32(0); + let sum = crate::wmf::checksum(&o.0); + o.u16(sum); + o.bytes(&[1, 0, 9, 0, 0, 3]); + o.bytes(&[0; 12]); + for (f, body) in records.iter().chain(&[(0, vec![])]) { + o.u32(3 + body.len() as u32 / 2); + o.u16(*f); + o.bytes(body); + } + o.0 +} + +fn le16(values: &[i16]) -> Vec { + values.iter().flat_map(|v| v.to_le_bytes()).collect() +} + +#[test] +fn hand_built_wmf_plays_like_gdi() { + let records = vec![ + // A blue brush (slot 0), a 20-unit red pen with square caps (slot 1), selected. + (0x02FC, [le16(&[0]), 0x00ff_0000u32.to_le_bytes().to_vec(), le16(&[0])].concat()), + (0x02FA, [le16(&[0x100, 20, 0]), 0x0000_00ffu32.to_le_bytes().to_vec()].concat()), + (0x012D, le16(&[0])), + (0x012D, le16(&[1])), + // A rectangle (bottom, right, top, left): 1 × 0.5 inches at the origin. + (0x041B, le16(&[720, 1440, 0, 0])), + (0x0999, le16(&[1, 2])), + // Delete the brush: the next object takes slot 0 again. + (0x01F0, le16(&[0])), + (0x02FC, [le16(&[0]), 0x0000_ff00u32.to_le_bytes().to_vec(), le16(&[0])].concat()), + (0x012D, le16(&[0])), + (0x0418, le16(&[1440, 1440, 720, 0])), + ]; + let back = import(&hand_wmf(1440, 1440, &records)).unwrap(); + assert!(near(back.document.artboards[0].rect, Rect::new(0.0, 0.0, 72.0, 72.0), 1e-9)); + assert_eq!(back.warnings, vec!["1 record of kinds VectorCraft doesn't read was skipped".to_string()]); + let p = paths(&back.document); + assert_eq!(p.len(), 2); + assert_eq!(fill_rgb(&p[0]), Some([0, 0, 255])); + assert!(near(p[0].geometric_bounds().unwrap(), Rect::new(0.0, 0.0, 72.0, 36.0), 1e-9)); + let st = p[0].appearance.stroke().unwrap(); + assert!((st.width - 1.0).abs() < 1e-9 && st.cap == vectorcraft_doc::LineCap::Square); + assert_eq!(fill_rgb(&p[1]), Some([0, 255, 0]), "the new brush took the freed slot"); +} + +#[test] +fn damaged_files_fail_or_import_partly() { + let good = emf(&sample()).bytes; + for cut in [0, 8, 50, 107, 108, 120, good.len() / 2, good.len() - 4] { + let _ = import(&good[..cut]); + } + let cut = import(&good[..good.len() / 2]).unwrap(); + assert!(cut.warnings.iter().any(|w| w.contains("ends early"))); + let good = wmf(&sample()).bytes; + for cut in [0, 4, 22, 30, 40, good.len() / 2] { + let _ = import(&good[..cut]); + } + assert!(import(b"not a metafile").is_err()); + // Huge counts, sizes and offsets are refused, not allocated. + let huge = hand_emf(100, 100, &[(3, le(&[0, 0, 0, 0, i32::MAX])), (81, le(&[0; 18])), (8, le(&[0, 0, 0, 0, i32::MAX, i32::MAX]))]); + let back = import(&huge).unwrap(); + assert!(paths(&back.document).is_empty()); + let huge = hand_wmf(100, 100, &[(0x0324, le16(&[i16::MAX])), (0x0538, le16(&[-1]))]); + assert!(import(&huge).is_ok()); +} 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/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 4afbbe19..ff4f3596 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -69,6 +69,8 @@ mod tests_svgsave; #[cfg(test)] mod tests_sysclip; #[cfg(test)] +mod tests_sysclip_emf; +#[cfg(test)] mod tests_transparencygrid; use std::sync::mpsc::{Receiver, Sender}; diff --git a/crates/ui-egui/src/sysclip.rs b/crates/ui-egui/src/sysclip.rs index f346095e..eb7ab738 100644 --- a/crates/ui-egui/src/sysclip.rs +++ b/crates/ui-egui/src/sysclip.rs @@ -1,12 +1,12 @@ //! The system clipboard's formats: Copy and Cut publish every flavour the engine makes //! ([`Session::clipboard_flavours`](vectorcraft_engine::Session::clipboard_flavours): text, SVG, -//! PDF, PNG), and a Paste first turns what another app copied (SVG, PDF, text, a bitmap) into the -//! internal clipboard with the `clipboard.import*` commands. The host installs the platform side -//! as [`Services::system_clipboard`](crate::Services::system_clipboard); without it (the web) Copy -//! publishes SVG text through egui and Paste takes SVG text only. +//! PDF, PNG), and a Paste first turns what another app copied (SVG, PDF, an EMF, text, a bitmap) +//! into the internal clipboard with the `clipboard.import*` commands. The host installs the +//! platform side as [`Services::system_clipboard`](crate::Services::system_clipboard); without it +//! (the web) Copy publishes SVG text through egui and Paste takes SVG text only. use serde_json::{Value, json}; -use vectorcraft_engine::cmd::clipboard::{Flavour, PASTE_ORDER, PDF, SVG, TEXT, looks_like_svg}; +use vectorcraft_engine::cmd::clipboard::{EMF, Flavour, PASTE_ORDER, PDF, SVG, TEXT, looks_like_svg}; use crate::VectorcraftApp; @@ -36,6 +36,7 @@ pub(crate) fn import_command(f: &Flavour, center: Option<[f64; 2]>) -> (&'static } SVG => ("clipboard.importSvg", json!({ "dataBase64": b64(), "center": center })), PDF => ("clipboard.importPdf", json!({ "dataBase64": b64(), "center": center })), + EMF => ("clipboard.importEmf", json!({ "dataBase64": b64(), "center": center })), mime => ("clipboard.importImage", json!({ "dataBase64": b64(), "mime": mime, "center": center })), } } diff --git a/crates/ui-egui/src/tests_sysclip.rs b/crates/ui-egui/src/tests_sysclip.rs index ae149257..0cbd2b20 100644 --- a/crates/ui-egui/src/tests_sysclip.rs +++ b/crates/ui-egui/src/tests_sysclip.rs @@ -14,7 +14,7 @@ use crate::{Services, SystemClipboard, VectorcraftApp, menus}; /// The fake clipboard's contents: what the app wrote, or what "another app" copied. #[derive(Default)] -struct Board { +pub(super) struct Board { flavours: Vec, ours: bool, } @@ -46,7 +46,7 @@ impl SystemClipboard for Fake { } /// An app with a document and a fake system clipboard, the view centred on (250, 180). -fn app() -> (VectorcraftApp, Rc>) { +pub(super) fn app() -> (VectorcraftApp, Rc>) { let board = Rc::new(RefCell::new(Board::default())); let services = Services { system_clipboard: Some(Box::new(Fake(board.clone()))), ..Default::default() }; let mut app = VectorcraftApp::new(Session::new(), services); @@ -57,12 +57,12 @@ fn app() -> (VectorcraftApp, Rc>) { } /// Another app copies `flavours`. -fn copy_elsewhere(board: &Rc>, flavours: Vec<(&'static str, Vec)>) { +pub(super) fn copy_elsewhere(board: &Rc>, flavours: Vec<(&'static str, Vec)>) { let flavours = flavours.into_iter().map(|(mime, data)| Flavour { mime, data }).collect(); *board.borrow_mut() = Board { flavours, ours: false }; } -fn run(app: &mut VectorcraftApp, id: &str, p: Value) -> Value { +pub(super) fn run(app: &mut VectorcraftApp, id: &str, p: Value) -> Value { app.run(id, p).unwrap_or_else(|e| panic!("{id}: {e}")) } @@ -78,7 +78,7 @@ fn written(board: &Rc>) -> Vec<&'static str> { } /// The objects the last paste selected. -fn pasted(app: &VectorcraftApp) -> Vec { +pub(super) fn pasted(app: &VectorcraftApp) -> Vec { let st = app.session.active().unwrap(); st.selection.objects.iter().map(|id| st.doc.node(*id).unwrap().clone()).collect() } @@ -88,7 +88,7 @@ fn count(app: &VectorcraftApp) -> usize { } /// A 4 × 2 px blue PNG. -fn blue_png() -> Vec { +pub(super) fn blue_png() -> Vec { let img = image::RgbaImage::from_pixel(4, 2, image::Rgba([0, 0, 255, 255])); let mut png = vec![]; img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); diff --git a/crates/ui-egui/src/tests_sysclip_emf.rs b/crates/ui-egui/src/tests_sysclip_emf.rs new file mode 100644 index 00000000..852e1353 --- /dev/null +++ b/crates/ui-egui/src/tests_sysclip_emf.rs @@ -0,0 +1,28 @@ +//! Windows metafiles another app copied paste as vector art (M4.95, through the system clipboard +//! service of M4.57). + +use serde_json::json; +use vectorcraft_doc::NodeKind; +use vectorcraft_engine::Session; +use vectorcraft_engine::cmd::clipboard::{EMF, PNG}; + +use crate::sysclip::import_command; +use crate::tests_sysclip::{app, blue_png, copy_elsewhere, pasted, run}; + +#[test] +fn an_emf_from_another_app_pastes_as_vectors_ahead_of_its_bitmap() { + let (mut app, board) = app(); + let mut other = Session::new(); + other.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + other.execute("shape.ellipse", &json!({"x": 10, "y": 10, "width": 30, "height": 30})).unwrap(); + let emf = other.execute("document.export", &json!({"format": "emf"})).unwrap(); + let emf = vectorcraft_format::base64_decode(emf["dataBase64"].as_str().unwrap()).unwrap(); + let flavour = vectorcraft_engine::cmd::clipboard::Flavour { mime: EMF, data: emf.clone() }; + assert_eq!(import_command(&flavour, None).0, "clipboard.importEmf"); + copy_elsewhere(&board, vec![(PNG, blue_png()), (EMF, emf)]); + run(&mut app, "edit.paste", json!({})); + let p = pasted(&app); + assert!(!p.is_empty() && p.iter().all(|n| !matches!(n.kind, NodeKind::Image(_))), "{p:?}"); + let b = p[0].geometric_bounds().unwrap(); + assert!((b.width() - 30.0).abs() < 0.05, "{b:?}"); +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 477a7020..c0304f9d 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -361,3 +361,7 @@ 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). Open and +Place read `.emf` and `.wmf`; a paste of `image/emf` from a system clipboard service runs `clipboard.importEmf`. diff --git a/docs/mcp.md b/docs/mcp.md index 90e04dd6..111b4585 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1338,3 +1338,28 @@ 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`. + +`document.open` and `file.place` read `.emf` and `.wmf` (placeable or not) into one artboard, the picture's frame, and +one layer: paths with their fills and strokes (pens become strokes with caps, joins and dashes), clipping groups, +images and point type in the font the file names. Records VectorCraft doesn't read (EMF+ drawing among them) are +skipped with one warning. `clipboard.importEmf {dataBase64, center?}` loads an EMF or WMF picture into the clipboard +for `edit.paste`; the app pastes `image/emf` from the system clipboard ahead of text and bitmaps when the platform's +clipboard service offers it (the Windows desktop service doesn't read metafiles yet). + +```json +{"name":"export","arguments":{"path":"/tmp/logo.emf"}} +{"name":"run_command","arguments":{"command":"document.export","params":{"format":"wmf","useArtboards":true,"path":"/tmp/icons.wmf"}}} +{"name":"run_command","arguments":{"command":"file.place","params":{"path":"/tmp/chart.emf","at":[300,200]}}} +``` 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).