M4.95: EMF and WMF open, place and paste as vector objects

crates/metafile import: EMF and WMF (placeable or not) records play on a
small device-context model (objects numbered as GDI does, saved DCs, world
transforms and mapping modes, path brackets, the current position) into one
artboard (the picture's frame) and one layer: paths with fills and strokes
(pens with caps, joins, dashes), clipping groups for clips, images from DIBs
(StretchDIBits, BitBlt, StretchBlt, AlphaBlend, StretchDIB, DIBBitBlt) and
point type in the font the file names. Lines drawn one after another join
into one path; a clip around the whole picture is dropped; objects far
outside it are left out; unknown records are skipped with one warning.

document.open and file.place read .emf and .wmf through the shared loader
(Open and Place filters list them). clipboard.importEmf loads a picture into
the clipboard, and Paste takes image/emf from a system clipboard service
ahead of text and bitmaps. The import fuzzer covers mutated and random-record
metafiles opened, placed and pasted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic 2026-10-05 14:18:50 +07:00
parent ab1c6b0516
commit 961ca75a28
21 changed files with 2489 additions and 35 deletions

View File

@ -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::*;

View File

@ -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<CommandSpec> {
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<Value> {
Ok(json!({ "count": count }))
}
fn import_emf(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
const C: &str = "clipboard.importPdf";
let bytes = data_param(p, C)?;

View File

@ -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<Loaded>
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))),
};

View File

@ -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<bool>, kin
}
Ok(enc)
}
/// A metafile's document and the import's warnings.
pub(super) fn import(bytes: &[u8]) -> Result<(Document, Vec<String>)> {
let i = vectorcraft_metafile::import(bytes).map_err(super::err)?;
Ok((i.document, i.warnings))
}

View File

@ -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<CommandSpec> {
"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 <image> 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 <image> 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",
];

View File

@ -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());
}

View File

@ -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<CommandSpec> {
"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
),

View File

@ -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<u8>] {
static SAMPLES: std::sync::OnceLock<Vec<Vec<u8>>> = 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<u8>)]) -> Vec<u8> {
let mut b: Vec<u8> = 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<u8>)]) -> Vec<u8> {
let mut b: Vec<u8> = 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<Value = Vec<u8>> {
prop_oneof![
prop::collection::vec(any::<u8>(), 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::<u8>()), 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::<u8>()), 0..12)) {
paste_from_elsewhere("clipboard.importEmf", json!({"dataBase64": mutated_b64(metafile_samples()[which].clone(), cut, &edits)}))?;
}
}

View File

@ -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<const N: usize>(&mut self) -> Option<[u8; N]> {
self.bytes(N)?.try_into().ok()
}
pub fn u16(&mut self) -> Option<u16> {
self.array().map(u16::from_le_bytes)
}
pub fn i16(&mut self) -> Option<i16> {
self.array().map(i16::from_le_bytes)
}
pub fn u32(&mut self) -> Option<u32> {
self.array().map(u32::from_le_bytes)
}
pub fn i32(&mut self) -> Option<i32> {
self.array().map(i32::from_le_bytes)
}
pub fn f32(&mut self) -> Option<f32> {
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()?)?)
}

View File

@ -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<Vec<u8>, 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<u8>, Vec<u8>) {
(header(img.width, img.height, 32, bits.len()), bits)
}
/// How the fourth byte of 32-bit pixels reads.
#[derive(Clone, Copy, PartialEq, Eq)]
pub(crate) enum Alpha {
/// Unused (StretchDIBits and BitBlt ignore it): every pixel opaque.
Ignore,
/// Premultiplied alpha (AlphaBlend with per-pixel alpha).
Premultiplied,
}
/// A colour channel of `v` under `mask`, scaled to 0–255.
fn channel(v: u32, mask: u32) -> u8 {
if mask == 0 {
return 0;
}
let shift = mask.trailing_zeros();
let max = u64::from(mask >> shift);
let x = u64::from((v & mask) >> shift);
((x * 255 + max / 2) / max.max(1)).min(255) as u8
}
/// Read a DIB from its BITMAPINFO (`bmi`: the header and colour table) and pixel `bits`.
pub(crate) fn decode(bmi: &[u8], bits: &[u8], alpha: Alpha) -> Result<Rgba, String> {
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);

View File

@ -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<f64>,
}
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<Arc<Clip>>,
}
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<Dc>,
objects: Vec<Option<Obj>>,
/// 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<BezPath>,
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<String>,
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<Paint> {
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<StrokeLayer> {
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<f64> = 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<Point>], 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<Rgba, String>, 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<Arc<Clip>>, 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<Arc<Node>> {
let mut out: Vec<Arc<Node>> = vec![];
let mut group: Drawn = vec![];
let mut clip: Option<Arc<Clip>> = 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<Node> {
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<Imported, String> {
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()),
}
}

View File

@ -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<Rect> {
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<Point> {
Some(Point::new(f64::from(r.i32()?), f64::from(r.i32()?)))
}
fn point16(r: &mut Reader) -> Option<Point> {
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<Affine> {
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<Vec<Point>> {
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<Vec<Vec<Point>>> {
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<u32> = (0..n).map(|_| r.u32()).collect::<Option<_>>()?;
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<FontObj> {
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<u16> = (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<dib::Rgba, String> {
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<dib::Rgba, String>, 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<Imported, String> {
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::<Option<Vec<_>>>()?
} 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::<Vec<_>>())
} 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<f64> = (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(())
}

View File

@ -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<Point> {
Some(Point::new(f64::from(r.i16()?), f64::from(r.i16()?)))
}
/// A point stored y first.
fn point_yx(r: &mut Reader) -> Option<Point> {
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<Rect> {
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<Vec<Point>> {
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<Rect> {
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<Imported, String> {
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<usize> = (0..n).map(|_| r.u16().map(usize::from)).collect::<Option<_>>()?;
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<u8> = 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(())
}

View File

@ -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<Output, String
Ok(Output { bytes, warnings })
}
/// A metafile read into a document.
#[derive(Clone, Debug)]
pub struct Imported {
pub document: Document,
pub kind: Kind,
/// What was approximated or skipped.
pub warnings: Vec<String>,
}
/// 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<Imported, String> {
import::import(bytes)
}
#[cfg(test)]
mod tests;
#[cfg(test)]
mod tests_import;

View File

@ -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<Node> {
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<Node> {
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<u8>)]) -> Vec<u8> {
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<u8> {
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<u8>)]) -> Vec<u8> {
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<u8> {
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());
}

View File

@ -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};

View File

@ -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 })),
}
}

View File

@ -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<Flavour>,
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<RefCell<Board>>) {
pub(super) fn app() -> (VectorcraftApp, Rc<RefCell<Board>>) {
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<RefCell<Board>>) {
}
/// Another app copies `flavours`.
fn copy_elsewhere(board: &Rc<RefCell<Board>>, flavours: Vec<(&'static str, Vec<u8>)>) {
pub(super) fn copy_elsewhere(board: &Rc<RefCell<Board>>, flavours: Vec<(&'static str, Vec<u8>)>) {
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<RefCell<Board>>) -> Vec<&'static str> {
}
/// The objects the last paste selected.
fn pasted(app: &VectorcraftApp) -> Vec<Node> {
pub(super) fn pasted(app: &VectorcraftApp) -> Vec<Node> {
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<u8> {
pub(super) fn blue_png() -> Vec<u8> {
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();

View File

@ -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:?}");
}

View File

@ -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`.

View File

@ -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]}}}
```