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/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 index 1cc20999..abff8357 100644 --- a/crates/engine/src/cmd/fileio/metafile.rs +++ b/crates/engine/src/cmd/fileio/metafile.rs @@ -1,6 +1,7 @@ -//! EMF and WMF export (`vectorcraft_metafile`): `document.export {format: emf|wmf}` and Export As -//! (one picture of an artboard, or one per artboard with Use Artboards; raster effects as images, -//! rendered at the document's raster effects resolution). +//! 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; @@ -43,3 +44,9 @@ pub(super) fn encode(doc: &Document, p: &Value, use_artboards: Option, kin } 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 fea44395..816b2843 100644 --- a/crates/engine/src/cmd/fileio/mod.rs +++ b/crates/engine/src/cmd/fileio/mod.rs @@ -14,7 +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. +//! - `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`. @@ -74,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 ), @@ -448,13 +448,32 @@ pub const FORMATS: &[Format] = &[ raster: false, options: eps::OPTIONS, }, - Format { id: "emf", label: "EMF", extensions: &["emf"], mime: "image/emf", read: false, write: true, raster: false, options: metafile::OPTIONS }, - Format { id: "wmf", label: "WMF", extensions: &["wmf"], mime: "image/wmf", read: false, write: true, raster: false, options: metafile::OPTIONS }, + 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"]; @@ -478,6 +497,8 @@ pub const PLACE_EXTS: &[&str] = &[ "bmp", "vctemplate", "dxf", + "emf", + "wmf", "txt", ]; diff --git a/crates/engine/src/cmd/fileio/tests_metafile.rs b/crates/engine/src/cmd/fileio/tests_metafile.rs index bdfab03b..9d21907a 100644 --- a/crates/engine/src/cmd/fileio/tests_metafile.rs +++ b/crates/engine/src/cmd/fileio/tests_metafile.rs @@ -1,7 +1,8 @@ -//! EMF and WMF export through the commands: one artboard or each, raster effects as images, and -//! the format lists. +//! 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::*; @@ -94,16 +95,66 @@ fn raster_effects_are_written_as_images() { 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(); - let ids: Vec<&str> = r["writable"].as_array().unwrap().iter().map(|v| v.as_str().unwrap()).collect(); - assert!(ids.contains(&"emf") && ids.contains(&"wmf"), "{ids:?}"); + 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/metafile/src/bytes.rs b/crates/metafile/src/bytes.rs index 133a4768..ddf534b7 100644 --- a/crates/metafile/src/bytes.rs +++ b/crates/metafile/src/bytes.rs @@ -1,4 +1,5 @@ -//! Little-endian values: the buffer both writers build records in. +//! 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)] @@ -44,3 +45,52 @@ impl Out { } } } + +/// 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 index 49be6c04..d63e5837 100644 --- a/crates/metafile/src/dib.rs +++ b/crates/metafile/src/dib.rs @@ -1,12 +1,23 @@ //! 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). +//! (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)] @@ -38,6 +49,14 @@ impl Rgba { 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. @@ -97,6 +116,167 @@ pub(crate) fn bgra32(img: &Rgba, opacity: f32) -> (Vec, Vec) { (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::*; @@ -128,6 +308,45 @@ mod tests { 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); 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 index b7f72d7f..b1ab24fc 100644 --- a/crates/metafile/src/lib.rs +++ b/crates/metafile/src/lib.rs @@ -1,4 +1,4 @@ -//! Windows metafiles: hand-written EMF and WMF writers. +//! 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: //! @@ -13,14 +13,20 @@ //! lines or gradients, so clipped art is written whole, images over white, dashes as separate //! lines and gradients and patterns as one colour (each loss comes back as a warning). //! -//! - `bytes`: little-endian writing. -//! - `dib`: device-independent bitmaps. +//! [`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; @@ -84,5 +90,23 @@ pub fn export(doc: &Document, region: Rect, kind: Kind) -> Result, +} + +/// 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/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/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 916833d2..c0304f9d 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -363,4 +363,5 @@ native files `version` and `preview`. A bad range keeps the dialog open; `ui.dia 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). +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 a57cb931..111b4585 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1351,7 +1351,15 @@ WMF flattens curves into polygons in 16-bit units behind a placeable header (144 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]}}} ```