Merge pull request #109 from storytold/pr/m4-emf-wmf

M4.94–M4.95: EMF and WMF
This commit is contained in:
BFlat 2026-10-05 15:31:36 +07:00 committed by GitHub
commit db7164a228
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
36 changed files with 4707 additions and 25 deletions

15
Cargo.lock generated
View File

@ -4284,6 +4284,7 @@ dependencies = [
"vectorcraft-eps", "vectorcraft-eps",
"vectorcraft-format", "vectorcraft-format",
"vectorcraft-geom", "vectorcraft-geom",
"vectorcraft-metafile",
"vectorcraft-pathops", "vectorcraft-pathops",
"vectorcraft-pdf", "vectorcraft-pdf",
"vectorcraft-render", "vectorcraft-render",
@ -4355,6 +4356,20 @@ dependencies = [
"vectorcraft-tools", "vectorcraft-tools",
] ]
[[package]]
name = "vectorcraft-metafile"
version = "0.2.0"
dependencies = [
"image",
"kurbo 0.13.1",
"vectorcraft-brush",
"vectorcraft-color",
"vectorcraft-doc",
"vectorcraft-effects",
"vectorcraft-geom",
"vectorcraft-text",
]
[[package]] [[package]]
name = "vectorcraft-pathops" name = "vectorcraft-pathops"
version = "0.2.0" version = "0.2.0"

View File

@ -31,6 +31,7 @@ vectorcraft-mcp = { path = "crates/mcp" }
vectorcraft-testkit = { path = "crates/testkit" } vectorcraft-testkit = { path = "crates/testkit" }
vectorcraft-cad = { path = "crates/cad" } vectorcraft-cad = { path = "crates/cad" }
vectorcraft-eps = { path = "crates/eps" } vectorcraft-eps = { path = "crates/eps" }
vectorcraft-metafile = { path = "crates/metafile" }
# foundation # foundation
kurbo = { version = "0.13", features = ["serde"] } kurbo = { version = "0.13", features = ["serde"] }

View File

@ -23,6 +23,7 @@ vectorcraft-format = { workspace = true }
vectorcraft-pdf = { workspace = true } vectorcraft-pdf = { workspace = true }
vectorcraft-cad = { workspace = true } vectorcraft-cad = { workspace = true }
vectorcraft-eps = { workspace = true } vectorcraft-eps = { workspace = true }
vectorcraft-metafile = { workspace = true }
image = { workspace = true } image = { workspace = true }
flate2 = { workspace = true } flate2 = { workspace = true }
kurbo = { workspace = true } kurbo = { workspace = true }

View File

@ -21,7 +21,7 @@ use vectorcraft_color::Swatch;
use vectorcraft_doc::{Document, GraphicStyle, ImageBlob, Node, NodeId, PatternDef, Symbol, TextStyleDef}; use vectorcraft_doc::{Document, GraphicStyle, ImageBlob, Node, NodeId, PatternDef, Symbol, TextStyleDef};
use vectorcraft_geom::Rect; 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; pub(crate) use resources::SwatchChoices;
use super::*; use super::*;

View File

@ -1,5 +1,5 @@
//! Clipboard flavours: what Copy offers other apps besides SVG (PNG, PDF and plain text) and what //! 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 //! 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 //! 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"; pub const PNG: &str = "image/png";
/// Any bitmap (PNG, BMP…), in what Paste asks the system clipboard for. /// Any bitmap (PNG, BMP…), in what Paste asks the system clipboard for.
pub const BITMAP: &str = "image/*"; 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 /// What Paste reads from other apps, best first: vector art before text, text before bitmaps
/// (word processors offer a picture of copied text too). /// (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. /// One representation of the clipboard's contents.
#[derive(Clone, Debug, PartialEq, Eq)] #[derive(Clone, Debug, PartialEq, Eq)]
@ -105,6 +108,15 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc, has_doc,
import_pdf 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 })) 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> { fn import_pdf(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "clipboard.importPdf"; const C: &str = "clipboard.importPdf";
let bytes = data_param(p, C)?; let bytes = data_param(p, C)?;

View File

@ -303,7 +303,7 @@ pub fn encode_all(doc: &Document, format: &str, p: &Value) -> Result<Encoded> {
let doc = &*doc.without_edit_modes(); let doc = &*doc.without_edit_modes();
let n = doc.artboards.len(); let n = doc.artboards.len();
// SVG reads its own `useArtboards` (an SVG option). // SVG reads its own `useArtboards` (an SVG option).
let use_artboards = if f.raster || matches!(f.id, "pdf" | "dxf") { use_artboards(p)? } else { None }; let use_artboards = if f.raster || matches!(f.id, "pdf" | "dxf" | "emf" | "wmf") { use_artboards(p)? } else { None };
if use_artboards == Some(false) { if use_artboards == Some(false) {
let bounds = vectorcraft_render::encode::art_bounds(doc).ok_or_else(|| bad(C, "nothing to export: the document has no visible art"))?; let bounds = vectorcraft_render::encode::art_bounds(doc).ok_or_else(|| bad(C, "nothing to export: the document has no visible art"))?;
let mut q = super::export::without_artboards(p); let mut q = super::export::without_artboards(p);
@ -325,6 +325,8 @@ pub fn encode_all(doc: &Document, format: &str, p: &Value) -> Result<Encoded> {
"dxf" => return super::dxf::encode(doc, p, use_artboards), "dxf" => return super::dxf::encode(doc, p, use_artboards),
// EPS reads its own `useArtboards` (the art's bounds unless asked). // EPS reads its own `useArtboards` (the art's bounds unless asked).
"eps" => return super::eps::encode(doc, p), "eps" => return super::eps::encode(doc, p),
"emf" => return super::metafile::encode(doc, p, use_artboards, vectorcraft_metafile::Kind::Emf),
"wmf" => return super::metafile::encode(doc, p, use_artboards, vectorcraft_metafile::Kind::Wmf),
"pdf" => { "pdf" => {
let (bytes, warnings) = super::pdf::encode(C, doc, p)?; let (bytes, warnings) = super::pdf::encode(C, doc, p)?;
return Ok(Encoded { warnings, ..Encoded::one(bytes) }); return Ok(Encoded { warnings, ..Encoded::one(bytes) });

View File

@ -62,6 +62,9 @@ pub fn detect(name: &str, bytes: &[u8]) -> Option<&'static Format> {
if vectorcraft_cad::is_dxf(bytes) { if vectorcraft_cad::is_dxf(bytes) {
return format("dxf"); 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) { if let Some(f) = image::guess_format(bytes).ok().and_then(image_format) {
return Some(f); 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)?; let (doc, warnings) = super::dxfimport::import(bytes, &opts.dxf)?;
(doc, warnings, false) (doc, warnings, false)
} }
"emf" | "wmf" => {
let (doc, warnings) = super::metafile::import(bytes)?;
(doc, warnings, false)
}
_ if format.raster => (raster_doc(&title, bytes)?, vec![], false), _ if format.raster => (raster_doc(&title, bytes)?, vec![], false),
_ => return Err(err(format!("{} files can't be opened yet", format.label))), _ => return Err(err(format!("{} files can't be opened yet", format.label))),
}; };

View File

@ -0,0 +1,52 @@
//! EMF and WMF for every path that writes or reads Windows metafiles (`vectorcraft_metafile`):
//! `document.export {format: emf|wmf}` and Export As (one picture of an artboard, or one per
//! artboard with Use Artboards; raster effects as images, rendered at the document's raster
//! effects resolution), `document.open` and File → Place (one artboard, the picture's frame).
use serde_json::Value;
use vectorcraft_doc::Document;
use vectorcraft_metafile::Kind;
use super::super::*;
use super::{ArtboardPick, Encoded, FormatOption};
const C: &str = "document.export";
/// The options `document.formats` lists for EMF and WMF.
pub(super) const OPTIONS: &[FormatOption] = &[super::ARTBOARD, super::ARTBOARDS, super::RANGE, super::USE_ARTBOARDS];
/// Encode `doc` as `kind`: the chosen artboard (default the first), or with `use_artboards` one
/// picture per chosen artboard (default all).
pub(super) fn encode(doc: &Document, p: &Value, use_artboards: Option<bool>, kind: Kind) -> Result<Encoded> {
let f = super::format(kind.id()).ok_or_else(|| bad(C, "no metafile format"))?;
let pick: ArtboardPick = super::encode::options(f, p)?;
let n = doc.artboards.len();
let boards = match use_artboards {
Some(true) => pick.resolve(n).map_err(|e| bad(C, e))?.unwrap_or_else(|| (0..n).collect()),
_ => vec![pick.one(n).map_err(|e| bad(C, e))?],
};
// Raster effects (shadows, glows, blurs) as images, as PDF export does.
let flat = crate::flatten_raster_effects(doc);
let src = flat.as_ref().unwrap_or(doc);
let mut enc = Encoded::default();
for b in boards {
let region = doc.artboards.get(b).ok_or_else(|| bad(C, format!("no artboard {}", b + 1)))?.rect;
let out = vectorcraft_metafile::export(src, region, kind).map_err(|e| bad(C, e))?;
enc.files.push((Some(b), out.bytes));
for w in out.warnings {
if !enc.warnings.contains(&w) {
enc.warnings.push(w);
}
}
}
if enc.files.is_empty() {
return Err(bad(C, "the document has no artboard"));
}
Ok(enc)
}
/// A metafile's document and the import's warnings.
pub(super) fn import(bytes: &[u8]) -> Result<(Document, Vec<String>)> {
let i = vectorcraft_metafile::import(bytes).map_err(super::err)?;
Ok((i.document, i.warnings))
}

View File

@ -14,6 +14,7 @@
//! - `dxf`: DXF export options, `document.exportDxf`, and the formats that can't be written (DWG, PICT). //! - `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`. //! - `eps`: EPS export options (flattened transparency, previews, the embedded document), `document.exportEps`.
//! - `dxfimport`: the DXF options `document.open` and Place read, `document.dxfInfo`. //! - `dxfimport`: the DXF options `document.open` and Place read, `document.dxfInfo`.
//! - `metafile`: EMF and WMF export, open and place.
//! //!
//! [`FORMATS`] is the single list of formats (append-only); open dialogs use [`open_filters`], //! [`FORMATS`] is the single list of formats (append-only); open dialogs use [`open_filters`],
//! agents query `document.formats`. //! agents query `document.formats`.
@ -26,6 +27,7 @@ pub mod eps;
mod export; mod export;
mod imagemap; mod imagemap;
mod load; mod load;
mod metafile;
mod native; mod native;
pub mod pdf; pub mod pdf;
mod pdfimport; mod pdfimport;
@ -72,7 +74,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Open Document", "Open Document",
[], [],
None, 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, always,
load::open load::open
), ),
@ -81,7 +83,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Serialize Document", "Serialize Document",
[], [],
None, None,
"{format?: vectorcraft (default)|template|svg|svgz|pdf|png|jpg|webp|gif|png8|txt|dxf|eps, …the format's options (see document.formats; SVG ones also as svg: {…}), selectedOnly?: false (the selected objects alone, in their layers)} → {text, warnings} for svg (plus dataBase64, the file, when its encoding isn't UTF-8), else {dataBase64, warnings}; an SVG of several artboards also gives files: [{name, text}], linked images linked: [{name, dataBase64}]", "{format?: vectorcraft (default)|template|svg|svgz|pdf|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf, …the format's options (see document.formats; SVG ones also as svg: {…}), selectedOnly?: false (the selected objects alone, in their layers)} → {text, warnings} for svg (plus dataBase64, the file, when its encoding isn't UTF-8), else {dataBase64, warnings}; an SVG of several artboards also gives files: [{name, text}], linked images linked: [{name, dataBase64}]",
has_doc, has_doc,
export::serialize export::serialize
), ),
@ -90,7 +92,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Export Document", "Export Document",
[], [],
None, None,
"{path?, format?: svg|svgz|pdf|png|jpg|webp|gif|png8|txt|dxf|eps|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png), selectedOnly?: false (the selected objects alone, in their layers), artboard?: 0, artboards?: [i…], range?: \"1-3, 5\" | \"all\" (1-based; PDF writes one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf: every page) | false (pdf/raster: the bounds of the visible art; SVG has it as an SVG option), raster: ppi?: 72 (pixels per inch, stored in the file; wins over scale), scale?: 1 (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), jpg: quality?: 90 (0–100), colorModel?: rgb|cmyk|gray, method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as <stem>.html / <stem>.map), gif/png8: colors?: 256 (2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?, webp: lossless?: true (lossy WebP isn't available yet: written lossless, with a warning), txt: the stories in stacking order (back to front; a thread once), encoding?: utf8|utf16 (with a byte order mark), lineEndings?: lf|crlf, selectionOnly?: false; SVG options flat or as svg: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers, encoding, profile, embedFonts} (see document.formats), …the PDF options of document.exportPdf, …the DXF options of document.exportDxf (useArtboards: one drawing per artboard), …the EPS options of document.exportEps (useArtboards: one file per artboard, {stem}_{artboard}.eps; else the visible art)} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. Never changes the document's path", "{path?, format?: svg|svgz|pdf|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png), selectedOnly?: false (the selected objects alone, in their layers), artboard?: 0, artboards?: [i…], range?: \"1-3, 5\" | \"all\" (1-based; PDF writes one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf: every page) | false (pdf/raster: the bounds of the visible art; SVG has it as an SVG option), raster: ppi?: 72 (pixels per inch, stored in the file; wins over scale), scale?: 1 (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), jpg: quality?: 90 (0–100), colorModel?: rgb|cmyk|gray, method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as <stem>.html / <stem>.map), gif/png8: colors?: 256 (2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?, webp: lossless?: true (lossy WebP isn't available yet: written lossless, with a warning), txt: the stories in stacking order (back to front; a thread once), encoding?: utf8|utf16 (with a byte order mark), lineEndings?: lf|crlf, selectionOnly?: false; SVG options flat or as svg: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers, encoding, profile, embedFonts} (see document.formats), …the PDF options of document.exportPdf, …the DXF options of document.exportDxf (useArtboards: one drawing per artboard), …the EPS options of document.exportEps (useArtboards: one file per artboard, {stem}_{artboard}.eps; else the visible art), emf/wmf: one picture of the artboard (useArtboards: true one file per chosen artboard, false the bounds of the visible art; EMF keeps curves, clipping, transparent images and gradients as images clipped to their shape; WMF flattens curves into polygons behind a placeable header; what a format leaves out comes back in warnings)} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. Never changes the document's path",
has_doc, has_doc,
export::export export::export
), ),
@ -446,11 +448,32 @@ pub const FORMATS: &[Format] = &[
raster: false, raster: false,
options: eps::OPTIONS, options: eps::OPTIONS,
}, },
Format { id: "emf", label: "EMF", extensions: &["emf"], mime: "image/emf", read: true, write: true, raster: false, options: metafile::OPTIONS },
Format { id: "wmf", label: "WMF", extensions: &["wmf"], mime: "image/wmf", read: true, write: true, raster: false, options: metafile::OPTIONS },
]; ];
/// Every extension `document.open` reads (the "All readable files" filter of open dialogs). /// Every extension `document.open` reads (the "All readable files" filter of open dialogs).
pub const OPEN_EXTS: &[&str] = pub const OPEN_EXTS: &[&str] = &[
&["vectorcraft", "drawcraft", "svg", "svgz", "pdf", "ai", "ait", "png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "vctemplate", "dxf"]; "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). /// Text files: File → Place sets them as area type (Text Import Options).
pub const TEXT_EXTS: &[&str] = &["txt"]; pub const TEXT_EXTS: &[&str] = &["txt"];
@ -474,6 +497,8 @@ pub const PLACE_EXTS: &[&str] = &[
"bmp", "bmp",
"vctemplate", "vctemplate",
"dxf", "dxf",
"emf",
"wmf",
"txt", "txt",
]; ];
@ -754,3 +779,5 @@ mod tests_eps;
#[cfg(test)] #[cfg(test)]
mod tests_dxfimport; mod tests_dxfimport;
#[cfg(test)]
mod tests_metafile;

View File

@ -160,7 +160,7 @@ impl Row {
return Err(bad(C, "each format is an object {format, scale?, suffix?}")); return Err(bad(C, "each format is an object {format, scale?, suffix?}"));
} }
let format = writable(C, Some(str_param(row, "format").unwrap_or("png")), None)?; let format = writable(C, Some(str_param(row, "format").unwrap_or("png")), None)?;
if matches!(format.id, "vectorcraft" | "template" | "txt" | "dxf" | "eps") { if matches!(format.id, "vectorcraft" | "template" | "txt" | "dxf" | "eps" | "emf" | "wmf") {
return Err(bad(C, "Export for Screens writes png, png8, jpg, webp, gif, svg, svgz or pdf")); return Err(bad(C, "Export for Screens writes png, png8, jpg, webp, gif, svg, svgz or pdf"));
} }
// Vector formats have no pixel size: it doesn't apply and adds no @2x suffix (not even one // Vector formats have no pixel size: it doesn't apply and adds no @2x suffix (not even one

View File

@ -154,7 +154,10 @@ fn formats_query_lists_readers_writers_and_options() {
let mut s = Session::new(); let mut s = Session::new();
let r = s.execute("document.formats", &json!({})).unwrap(); let r = s.execute("document.formats", &json!({})).unwrap();
let ids = |k: &str| r[k].as_array().unwrap().iter().map(|v| v.as_str().unwrap().to_string()).collect::<Vec<_>>(); let ids = |k: &str| r[k].as_array().unwrap().iter().map(|v| v.as_str().unwrap().to_string()).collect::<Vec<_>>();
assert_eq!(ids("writable"), ["vectorcraft", "svg", "svgz", "pdf", "png", "jpg", "gif", "webp", "template", "png8", "txt", "dxf", "eps"]); assert_eq!(
ids("writable"),
["vectorcraft", "svg", "svgz", "pdf", "png", "jpg", "gif", "webp", "template", "png8", "txt", "dxf", "eps", "emf", "wmf"]
);
assert!(ids("readable").contains(&"tiff".to_string()) && ids("readable").contains(&"ait".to_string())); assert!(ids("readable").contains(&"tiff".to_string()) && ids("readable").contains(&"ait".to_string()));
let png = r["formats"].as_array().unwrap().iter().find(|f| f["id"] == "png").unwrap(); let png = r["formats"].as_array().unwrap().iter().find(|f| f["id"] == "png").unwrap();
assert_eq!(png["options"]["scale"]["default"], 1); assert_eq!(png["options"]["scale"]["default"], 1);

View File

@ -0,0 +1,160 @@
//! EMF and WMF through the commands: export of one artboard or each, raster effects as images,
//! open, place, paste, and the format lists.
use serde_json::{Value, json};
use vectorcraft_doc::NodeKind;
use super::*;
fn session(width: f64, height: f64, artboards: usize) -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": width, "height": height, "artboards": artboards})).unwrap();
s
}
fn b64(v: &Value) -> Vec<u8> {
vectorcraft_format::base64_decode(v["dataBase64"].as_str().expect("dataBase64")).unwrap()
}
fn u32_at(b: &[u8], at: usize) -> u32 {
u32::from_le_bytes(b[at..at + 4].try_into().unwrap())
}
fn i32_at(b: &[u8], at: usize) -> i32 {
i32::from_le_bytes(b[at..at + 4].try_into().unwrap())
}
/// The EMF's record types.
fn emf_kinds(b: &[u8]) -> Vec<u32> {
let mut out = vec![];
let mut at = 0;
while at + 8 <= b.len() {
out.push(u32_at(b, at));
at += u32_at(b, at + 4) as usize;
}
out
}
/// Two 100 pt artboards side by side, a red rectangle on each.
fn two_boards() -> Session {
let mut s = session(100.0, 100.0, 2);
let second = s.doc().unwrap().doc.artboards[1].rect.x0;
s.execute("paint.setStroke", &json!({"none": true})).unwrap();
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 30, "height": 30})).unwrap();
s.execute("shape.rectangle", &json!({"x": second + 10.0, "y": 10, "width": 30, "height": 30})).unwrap();
s
}
#[test]
fn export_writes_an_emf_or_wmf_of_one_artboard() {
let mut s = two_boards();
let r = s.execute("document.export", &json!({"format": "emf"})).unwrap();
assert_eq!(r["format"], "emf");
let emf = b64(&r);
assert_eq!(vectorcraft_metafile::sniff(&emf), Some(vectorcraft_metafile::Kind::Emf));
// The first artboard's frame in 0.01 mm.
let side = (100.0 * 2540.0 / 72.0f64).round() as i32;
assert_eq!([i32_at(&emf, 24), i32_at(&emf, 28), i32_at(&emf, 32), i32_at(&emf, 36)], [0, 0, side, side]);
assert_eq!(emf_kinds(&emf).iter().filter(|k| **k == 62).count(), 1, "one rectangle on it");
// The second artboard alone; serialize gives the same bytes.
let second = b64(&s.execute("document.export", &json!({"format": "emf", "artboard": 1})).unwrap());
assert_eq!(emf_kinds(&second).iter().filter(|k| **k == 62).count(), 1);
assert_eq!(b64(&s.execute("document.serialize", &json!({"format": "emf"})).unwrap()), emf);
let wmf = b64(&s.execute("document.export", &json!({"format": "wmf"})).unwrap());
assert_eq!(vectorcraft_metafile::sniff(&wmf), Some(vectorcraft_metafile::Kind::Wmf));
assert_eq!(writable_format(None, Some("x/pic.wmf")).unwrap().id, "wmf");
assert_eq!(writable_format(Some("EMF"), None).unwrap().id, "emf");
// One file holds one artboard.
assert!(s.execute("document.export", &json!({"format": "emf", "artboards": [0, 1]})).is_err());
}
#[test]
fn use_artboards_writes_one_picture_each_or_the_art() {
let mut s = two_boards();
let r = s.execute("document.export", &json!({"format": "wmf", "useArtboards": true})).unwrap();
let files = r["files"].as_array().unwrap();
assert_eq!(files.len(), 2);
assert!(files[0]["name"].as_str().unwrap().ends_with("-Artboard-1.wmf"), "{files:?}");
// Without artboards: the bounds of the art (both rectangles).
let r = s.execute("document.export", &json!({"format": "emf", "useArtboards": false})).unwrap();
let emf = b64(&r);
let width_pt = f64::from(i32_at(&emf, 32)) / 2540.0 * 72.0;
let second = s.doc().unwrap().doc.artboards[1].rect.x0;
assert!((width_pt - (second + 30.0)).abs() < 0.05, "{width_pt}");
}
#[test]
fn raster_effects_are_written_as_images() {
let mut s = session(200.0, 200.0, 1);
let id = s.execute("shape.rectangle", &json!({"x": 50, "y": 50, "width": 60, "height": 60})).unwrap()["id"].clone();
s.execute("effect.apply", &json!({"effect": "stylize.dropShadow", "ids": [id]})).unwrap();
let r = s.execute("document.export", &json!({"format": "emf"})).unwrap();
assert!(r["warnings"].as_array().unwrap().iter().all(|w| !w.as_str().unwrap().contains("raster effects")), "{r}");
let kinds = emf_kinds(&b64(&r));
assert!(kinds.contains(&114), "the shadow is an image with transparency");
}
#[test]
fn metafiles_open_and_place() {
let mut s = two_boards();
let emf = b64(&s.execute("document.export", &json!({"format": "emf"})).unwrap());
let wmf = b64(&s.execute("document.export", &json!({"format": "wmf"})).unwrap());
for (name, bytes) in [("pic.emf", &emf), ("pic.wmf", &wmf)] {
let r = s.execute("document.open", &json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(bytes)})).unwrap();
assert_eq!(r["format"], &name[4..]);
assert_eq!(r["warnings"], json!([]));
let st = s.doc().unwrap();
let ab = st.doc.artboards[0].rect;
assert!((ab.width() - 100.0).abs() < 0.05 && (ab.height() - 100.0).abs() < 0.05, "{ab:?}");
let fill = st.doc.layers[0].children().unwrap().iter().find(|n| matches!(n.kind, NodeKind::Path { .. })).unwrap().clone();
let b = fill.geometric_bounds().unwrap();
assert!((b.x0 - 10.0).abs() < 0.05 && (b.width() - 30.0).abs() < 0.05, "{b:?}");
// Save can't write the file back: the document has no path.
assert!(s.doc().unwrap().path.is_none());
}
// Placed: one group clipped to the picture's frame, named after the file.
let mut s = session(300.0, 300.0, 1);
let r = s.execute("file.place", &json!({"name": "pic.emf", "dataBase64": vectorcraft_format::base64_encode(&emf), "at": [150, 150]})).unwrap();
assert_eq!(r["format"], "emf");
assert!((r["width"].as_f64().unwrap() - 100.0).abs() < 0.05);
let st = s.doc().unwrap();
let placed = st.doc.layers[0].children().unwrap().last().unwrap().clone();
assert_eq!(placed.name.as_deref(), Some("pic.emf"));
assert!(matches!(placed.kind, NodeKind::Group { clip: true, .. }));
let ext = fileio::PLACE_EXTS;
assert!(ext.contains(&"emf") && ext.contains(&"wmf") && OPEN_EXTS.contains(&"emf") && OPEN_EXTS.contains(&"wmf"));
}
#[test]
fn formats_list_emf_and_wmf() {
let mut s = Session::new();
let r = s.execute("document.formats", &json!({})).unwrap();
for k in ["readable", "writable"] {
let ids: Vec<&str> = r[k].as_array().unwrap().iter().map(|v| v.as_str().unwrap()).collect();
assert!(ids.contains(&"emf") && ids.contains(&"wmf"), "{k}: {ids:?}");
}
let emf = r["formats"].as_array().unwrap().iter().find(|f| f["id"] == "emf").unwrap();
assert_eq!(emf["mime"], "image/emf");
assert!(emf["options"]["useArtboards"].is_object());
assert!(open_filters().any(|(label, ext)| label == "EMF" && ext == ["emf"]));
assert!(place_filters().any(|(label, ext)| label == "WMF" && ext == ["wmf"]));
// Export for Screens writes the screen formats only.
let mut s = two_boards();
assert!(s.execute("document.exportForScreens", &json!({"formats": [{"format": "emf"}]})).is_err());
}
#[test]
fn a_pasted_metafile_becomes_objects() {
let mut src = two_boards();
let emf = b64(&src.execute("document.export", &json!({"format": "emf"})).unwrap());
let mut s = session(300.0, 300.0, 1);
let r = s.execute("clipboard.importEmf", &json!({"dataBase64": vectorcraft_format::base64_encode(&emf), "center": [150, 150]})).unwrap();
assert_eq!(r["count"], 1);
s.execute("edit.paste", &json!({"center": [150, 150]})).unwrap();
let pasted = s.doc().unwrap().doc.layers[0].children().unwrap().clone();
assert_eq!(pasted.len(), 1);
let b = pasted[0].geometric_bounds().unwrap();
assert!((b.center().x - 150.0).abs() < 0.05 && (b.width() - 30.0).abs() < 0.05, "{b:?}");
assert!(s.execute("clipboard.importEmf", &json!({"dataBase64": vectorcraft_format::base64_encode(b"%PDF-1.7")})).is_err());
}

View File

@ -6,6 +6,8 @@
//! `link` ([`super::links`]). //! `link` ([`super::links`]).
//! - An SVG becomes one group of its art; so does a DXF drawing, read with the DXF options //! - An SVG becomes one group of its art; so does a DXF drawing, read with the DXF options
//! (`dxf`, [`fileio::dxfimport`]). //! (`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 //! - 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: "crop"`; a PDF page's `art`, `trim`, `bleed` or `media` box too) or bounded by its art
//! (`crop: "bounding"`). //! (`crop: "bounding"`).
@ -48,7 +50,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Place…", "Place…",
["File"], ["File"],
Some("Cmd+Shift+P"), 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, has_doc,
place place
), ),

View File

@ -2,7 +2,8 @@
//! mutated raster images placed with File → Place, and swatch (`.vcswatches`, `.gpl`), graphic style (`.vcstyles`) and flattener preset //! 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), //! (`.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 //! 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. //! `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). // 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)?; 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

@ -57,7 +57,7 @@ fn export_tool_lists_engine_formats_and_options() {
let props = &export["inputSchema"]["properties"]; let props = &export["inputSchema"]["properties"];
assert_eq!( assert_eq!(
props["format"]["enum"], props["format"]["enum"],
json!(["vectorcraft", "svg", "svgz", "pdf", "png", "jpg", "gif", "webp", "template", "png8", "txt", "dxf", "eps"]) json!(["vectorcraft", "svg", "svgz", "pdf", "png", "jpg", "gif", "webp", "template", "png8", "txt", "dxf", "eps", "emf", "wmf"])
); );
for k in ["artboard", "range", "options"] { for k in ["artboard", "range", "options"] {
assert!(props.get(k).is_some(), "export takes {k}"); assert!(props.get(k).is_some(), "export takes {k}");

View File

@ -0,0 +1,19 @@
[package]
name = "vectorcraft-metafile"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "VectorCraft Windows metafiles: hand-written EMF and WMF writers and readers"
[lints]
workspace = true
[dependencies]
vectorcraft-geom = { workspace = true }
vectorcraft-color = { workspace = true }
vectorcraft-doc = { workspace = true }
vectorcraft-effects = { workspace = true }
vectorcraft-brush = { workspace = true }
vectorcraft-text = { workspace = true }
kurbo = { workspace = true }
image = { workspace = true }

View File

@ -0,0 +1,96 @@
//! Little-endian values: the buffer both writers build records in, and a reader that never reads
//! past its data (every read is an `Option`).
/// Bytes being written.
#[derive(Default)]
pub(crate) struct Out(pub Vec<u8>);
impl Out {
pub fn len(&self) -> usize {
self.0.len()
}
pub fn u16(&mut self, v: u16) {
self.0.extend_from_slice(&v.to_le_bytes());
}
pub fn i16(&mut self, v: i16) {
self.0.extend_from_slice(&v.to_le_bytes());
}
pub fn u32(&mut self, v: u32) {
self.0.extend_from_slice(&v.to_le_bytes());
}
pub fn i32(&mut self, v: i32) {
self.0.extend_from_slice(&v.to_le_bytes());
}
pub fn f32(&mut self, v: f32) {
self.0.extend_from_slice(&v.to_le_bytes());
}
pub fn bytes(&mut self, b: &[u8]) {
self.0.extend_from_slice(b);
}
/// Zeros up to the next multiple of `n` bytes.
pub fn pad(&mut self, n: usize) {
while !self.0.len().is_multiple_of(n) {
self.0.push(0);
}
}
/// Overwrite the `u32` at `at` (a size or count known only later).
pub fn set_u32(&mut self, at: usize, v: u32) {
if let Some(b) = self.0.get_mut(at..at + 4) {
b.copy_from_slice(&v.to_le_bytes());
}
}
pub fn set_i32(&mut self, at: usize, v: i32) {
if let Some(b) = self.0.get_mut(at..at + 4) {
b.copy_from_slice(&v.to_le_bytes());
}
}
}
/// Sequential little-endian reads from a slice.
#[derive(Clone, Copy)]
pub(crate) struct Reader<'a> {
data: &'a [u8],
pos: usize,
}
impl<'a> Reader<'a> {
pub fn new(data: &'a [u8]) -> Self {
Self { data, pos: 0 }
}
pub fn bytes(&mut self, n: usize) -> Option<&'a [u8]> {
let b = self.data.get(self.pos..self.pos.checked_add(n)?)?;
self.pos += n;
Some(b)
}
fn array<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()?)?)
}

356
crates/metafile/src/dib.rs Normal file
View File

@ -0,0 +1,356 @@
//! Device-independent bitmaps (DIBs), the pixels inside image records: written bottom-up as 24-bit
//! (opaque, composited over white) or 32-bit premultiplied BGRA (AlphaBlend's source); read from 1,
//! 4, 8, 16, 24 and 32-bit uncompressed and bit-field bitmaps and from embedded JPEG and PNG.
use std::io::Cursor;
use crate::bytes::Reader;
/// Most pixels along a side of an image the writers store (larger ones are scaled down).
const MAX_SIDE: u32 = 8192;
/// Most pixels an image the writers store holds.
const MAX_PIXELS: u64 = 1 << 24;
/// Most pixels along a side, and in all, of a bitmap the reader decodes.
const MAX_READ_SIDE: u32 = 1 << 15;
const MAX_READ_PIXELS: u64 = 1 << 26;
const BI_RGB: u32 = 0;
const BI_BITFIELDS: u32 = 3;
const BI_JPEG: u32 = 4;
const BI_PNG: u32 = 5;
/// Pixels with straight (not premultiplied) alpha, row by row from the top.
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct Rgba {
pub width: u32,
pub height: u32,
pub pixels: Vec<u8>,
}
impl Rgba {
/// `img`, scaled down to what a metafile stores when it is larger.
pub fn fitted(img: image::RgbaImage) -> Self {
let (w, h) = img.dimensions();
let side = f64::from(w.max(h)) / f64::from(MAX_SIDE);
let area = (u64::from(w) * u64::from(h)) as f64 / MAX_PIXELS as f64;
let k = side.max(area.sqrt());
let img = if k > 1.0 {
let nw = ((f64::from(w) / k).floor() as u32).max(1);
let nh = ((f64::from(h) / k).floor() as u32).max(1);
image::imageops::resize(&img, nw, nh, image::imageops::FilterType::Triangle)
} else {
img
};
let (width, height) = img.dimensions();
Self { width, height, pixels: img.into_raw() }
}
/// Every pixel fully opaque?
pub fn opaque(&self) -> bool {
self.pixels.as_chunks::<4>().0.iter().all(|p| p[3] == 255)
}
/// As PNG bytes.
pub fn to_png(&self) -> Result<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.
fn header(width: u32, height: u32, bits: u16, size: usize) -> Vec<u8> {
let mut h = crate::bytes::Out::default();
h.u32(40);
h.i32(i32::try_from(width).unwrap_or(i32::MAX));
h.i32(i32::try_from(height).unwrap_or(i32::MAX));
h.u16(1);
h.u16(bits);
h.u32(BI_RGB);
h.u32(u32::try_from(size).unwrap_or(0));
// 2835 pixels a metre: 72 ppi (the size on the page comes from the record).
h.i32(2835);
h.i32(2835);
h.u32(0);
h.u32(0);
h.0
}
/// The bytes of a row of `width` pixels of `bits` each, padded to 4 bytes.
fn stride(width: u32, bits: u32) -> Option<usize> {
let row_bits = u64::from(width).checked_mul(u64::from(bits))?;
usize::try_from(row_bits.div_ceil(32) * 4).ok()
}
/// `img` at `opacity`, composited over white, as a 24-bit DIB → (header, bits).
pub(crate) fn rgb24(img: &Rgba, opacity: f32) -> (Vec<u8>, Vec<u8>) {
let row = stride(img.width, 24).unwrap_or(0);
let mut bits = vec![0u8; row * img.height as usize];
let over = |c: u8, a: u32| ((u32::from(c) * a + 255 * (255 - a) + 127) / 255) as u8;
for (y, src) in img.pixels.chunks_exact((img.width as usize * 4).max(1)).enumerate() {
// Bottom-up: the first stored row is the image's last.
let Some(at) = (img.height as usize).checked_sub(y + 1).map(|r| r * row) else { continue };
let Some(dst) = bits.get_mut(at..at + img.width as usize * 3) else { continue };
for (d, p) in dst.chunks_exact_mut(3).zip(src.as_chunks::<4>().0) {
let a = (f32::from(p[3]) * opacity.clamp(0.0, 1.0)).round() as u32;
d.copy_from_slice(&[over(p[2], a), over(p[1], a), over(p[0], a)]);
}
}
(header(img.width, img.height, 24, bits.len()), bits)
}
/// `img` at `opacity` as a 32-bit DIB of premultiplied BGRA → (header, bits).
pub(crate) fn bgra32(img: &Rgba, opacity: f32) -> (Vec<u8>, Vec<u8>) {
let row = img.width as usize * 4;
let mut bits = vec![0u8; row * img.height as usize];
for (y, src) in img.pixels.chunks_exact(row.max(1)).enumerate() {
let Some(at) = (img.height as usize).checked_sub(y + 1).map(|r| r * row) else { continue };
let Some(dst) = bits.get_mut(at..at + row) else { continue };
for (d, p) in dst.chunks_exact_mut(4).zip(src.as_chunks::<4>().0) {
let a = (f32::from(p[3]) * opacity.clamp(0.0, 1.0)).round() as u32;
let pre = |c: u8| ((u32::from(c) * a + 127) / 255) as u8;
d.copy_from_slice(&[pre(p[2]), pre(p[1]), pre(p[0]), a as u8]);
}
}
(header(img.width, img.height, 32, bits.len()), bits)
}
/// How the fourth byte of 32-bit pixels reads.
#[derive(Clone, Copy, PartialEq, Eq)]
pub(crate) enum Alpha {
/// Unused (StretchDIBits and BitBlt ignore it): every pixel opaque.
Ignore,
/// Premultiplied alpha (AlphaBlend with per-pixel alpha).
Premultiplied,
}
/// A colour channel of `v` under `mask`, scaled to 0–255.
fn channel(v: u32, mask: u32) -> u8 {
if mask == 0 {
return 0;
}
let shift = mask.trailing_zeros();
let max = u64::from(mask >> shift);
let x = u64::from((v & mask) >> shift);
((x * 255 + max / 2) / max.max(1)).min(255) as u8
}
/// Read a DIB from its BITMAPINFO (`bmi`: the header and colour table) and pixel `bits`.
pub(crate) fn decode(bmi: &[u8], bits: &[u8], alpha: Alpha) -> Result<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::*;
fn sample() -> Rgba {
// 3 × 2: red, green, blue / half-transparent black, transparent, white.
let pixels = vec![255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255, 0, 0, 0, 128, 0, 0, 0, 0, 255, 255, 255, 255];
Rgba { width: 3, height: 2, pixels }
}
#[test]
fn rgb24_is_bottom_up_bgr_over_white() {
let (bmi, bits) = rgb24(&sample(), 1.0);
assert_eq!(bmi.len(), 40);
assert_eq!(&bmi[14..16], &[24, 0], "24 bits a pixel");
// Rows of 9 bytes padded to 12, the last row first: half-transparent black over white is
// mid grey, transparent is white.
assert_eq!(bits, vec![127, 127, 127, 255, 255, 255, 255, 255, 255, 0, 0, 0, 0, 0, 255, 0, 255, 0, 255, 0, 0, 0, 0, 0]);
}
#[test]
fn bgra32_is_premultiplied() {
let (bmi, bits) = bgra32(&sample(), 1.0);
assert_eq!(&bmi[14..16], &[32, 0]);
assert_eq!(&bits[..12], &[0, 0, 0, 128, 0, 0, 0, 0, 255, 255, 255, 255]);
assert_eq!(&bits[12..16], &[0, 0, 255, 255], "red, as BGRA");
// At half opacity.
let (_, half) = bgra32(&sample(), 0.5);
assert_eq!(&half[12..16], &[0, 0, 128, 128]);
}
#[test]
fn rgb24_round_trips_over_white() {
let (bmi, bits) = rgb24(&sample(), 1.0);
assert_eq!(bmi.len(), 40);
// Rows of 9 bytes padded to 12.
assert_eq!(bits.len(), 24);
let back = decode(&bmi, &bits, Alpha::Ignore).unwrap();
assert_eq!((back.width, back.height), (3, 2));
assert_eq!(&back.pixels[..12], &[255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255]);
// Half-transparent black over white is mid grey; transparent is white.
assert_eq!(&back.pixels[12..16], &[127, 127, 127, 255]);
assert_eq!(&back.pixels[16..20], &[255, 255, 255, 255]);
}
#[test]
fn bgra32_round_trips_with_alpha() {
let (bmi, bits) = bgra32(&sample(), 1.0);
let back = decode(&bmi, &bits, Alpha::Premultiplied).unwrap();
assert_eq!(&back.pixels[..12], &sample().pixels[..12]);
assert_eq!(back.pixels[15], 128);
assert_eq!(back.pixels[19], 0);
// Read as StretchDIBits does, alpha is ignored.
assert!(decode(&bmi, &bits, Alpha::Ignore).unwrap().opaque());
}
#[test]
fn palette_bitmaps_and_damage() {
// A 1-bit 2 × 1 bitmap: black then white.
let mut bmi = header(2, 1, 1, 4);
bmi.extend_from_slice(&[0, 0, 0, 0, 255, 255, 255, 0]);
let back = decode(&bmi, &[0b0100_0000, 0, 0, 0], Alpha::Ignore).unwrap();
assert_eq!(back.pixels, vec![0, 0, 0, 255, 255, 255, 255, 255]);
// Too few bits, a silly size, an unknown depth: errors, no panic.
assert!(decode(&bmi, &[0], Alpha::Ignore).is_err());
assert!(decode(&header(1 << 20, 1 << 20, 24, 0), &[], Alpha::Ignore).is_err());
assert!(decode(&header(1, 1, 7, 0), &[0; 4], Alpha::Ignore).is_err());
assert!(decode(&[1, 2], &[], Alpha::Ignore).is_err());
}
#[test]
fn large_images_are_scaled_to_fit() {
let img = image::RgbaImage::new(MAX_SIDE * 2, 4);
let f = Rgba::fitted(img);
assert_eq!((f.width, f.height), (MAX_SIDE, 2));
}
}

503
crates/metafile/src/emf.rs Normal file
View File

@ -0,0 +1,503 @@
//! The EMF writer: an EMR_HEADER whose frame is the region in 0.01 mm, then the drawing in device
//! units of 0.01 mm (the reference device has 100 pixels a millimetre) and an EMR_EOF.
//!
//! Each fill and stroke is a path bracket (move-to, poly-line-to and poly-Bézier-to runs, close
//! figure) filled with a solid brush or stroked with a geometric pen; brushes and pens are made
//! when the colour or pen changes and deleted when replaced. Clips are path clips inside a saved
//! DC; images are StretchDIBits (opaque) or AlphaBlend (with transparency) under a world transform
//! that places their pixels.
use kurbo::PathEl;
use vectorcraft_doc::{LineCap, LineJoin};
use vectorcraft_geom::{Affine, BezPath, FillRule, Point, Rect};
use crate::bytes::Out;
use crate::dib::{self, Rgba};
use crate::scene::{Op, Pen};
// Record types (MS-EMF 2.1.1).
pub(crate) const HEADER: u32 = 1;
pub(crate) const POLYBEZIERTO: u32 = 5;
pub(crate) const POLYLINETO: u32 = 6;
pub(crate) const EOF: u32 = 14;
pub(crate) const SETBKMODE: u32 = 18;
pub(crate) const SETPOLYFILLMODE: u32 = 19;
pub(crate) const MOVETOEX: u32 = 27;
pub(crate) const SAVEDC: u32 = 33;
pub(crate) const RESTOREDC: u32 = 34;
pub(crate) const SETWORLDTRANSFORM: u32 = 35;
pub(crate) const MODIFYWORLDTRANSFORM: u32 = 36;
pub(crate) const SELECTOBJECT: u32 = 37;
pub(crate) const CREATEBRUSHINDIRECT: u32 = 39;
pub(crate) const DELETEOBJECT: u32 = 40;
pub(crate) const SETMITERLIMIT: u32 = 58;
pub(crate) const BEGINPATH: u32 = 59;
pub(crate) const ENDPATH: u32 = 60;
pub(crate) const CLOSEFIGURE: u32 = 61;
pub(crate) const FILLPATH: u32 = 62;
pub(crate) const STROKEPATH: u32 = 64;
pub(crate) const SELECTCLIPPATH: u32 = 67;
pub(crate) const STRETCHDIBITS: u32 = 81;
pub(crate) const EXTCREATEPEN: u32 = 95;
pub(crate) const ALPHABLEND: u32 = 114;
/// The `" EMF"` signature in the header.
pub(crate) const SIGNATURE: u32 = 0x464D_4520;
/// Stock objects (selected to release ours before deleting them).
const WHITE_BRUSH: u32 = 0x8000_0000;
const NULL_PEN: u32 = 0x8000_0008;
/// Our two object slots (0 is reserved).
const BRUSH: u32 = 1;
const PEN: u32 = 2;
pub(crate) const PS_GEOMETRIC: u32 = 0x0001_0000;
pub(crate) const PS_USERSTYLE: u32 = 7;
pub(crate) const PS_ENDCAP_SQUARE: u32 = 0x100;
pub(crate) const PS_ENDCAP_FLAT: u32 = 0x200;
pub(crate) const PS_JOIN_BEVEL: u32 = 0x1000;
pub(crate) const PS_JOIN_MITER: u32 = 0x2000;
/// Raster operation: copy the source.
pub(crate) const SRCCOPY: u32 = 0x00CC_0020;
/// Device units (0.01 mm) per point.
pub(crate) const UNITS_PER_PT: f64 = 2540.0 / 72.0;
/// Longest dash pattern a geometric pen takes (longer ones are drawn as separate dashes).
const MAX_STYLE: usize = 16;
/// Header size with both extensions.
const HEADER_SIZE: usize = 108;
/// Coordinates are kept within this (far beyond any page).
const MAX_COORD: f64 = 1e9;
/// Does `bytes` start with an EMF header?
pub(crate) fn is_emf(bytes: &[u8]) -> bool {
let u32_at = |at: usize| bytes.get(at..at + 4).and_then(|b| b.try_into().ok()).map(u32::from_le_bytes);
u32_at(0) == Some(HEADER) && u32_at(40) == Some(SIGNATURE)
}
/// A COLORREF (0x00bbggrr).
pub(crate) fn colorref(rgb: [u8; 3]) -> u32 {
u32::from_le_bytes([rgb[0], rgb[1], rgb[2], 0])
}
/// What a pen object is made of, to tell when the next stroke needs another.
#[derive(Clone, PartialEq)]
struct PenKey {
style: u32,
width: u32,
color: u32,
entries: Vec<u32>,
}
/// A device-space bounding box (inclusive).
type Bounds = [i32; 4];
fn union(a: Option<Bounds>, b: Bounds) -> Bounds {
match a {
None => b,
Some(a) => [a[0].min(b[0]), a[1].min(b[1]), a[2].max(b[2]), a[3].max(b[3])],
}
}
struct Writer {
out: Out,
records: u32,
/// Document → device units.
to_dev: Affine,
/// The frame in device units: what the region clip lets through.
frame: Bounds,
/// Everything drawn, in device units.
bounds: Option<Bounds>,
brush: Option<u32>,
pen: Option<PenKey>,
fill_mode: Option<u32>,
miter: Option<u32>,
}
/// Write `ops` as an EMF of `region` (document space), described as `title`.
pub(crate) fn write(ops: &[Op], region: Rect, title: &str) -> Vec<u8> {
let k = UNITS_PER_PT;
let frame = [0, 0, (region.width() * k).round() as i32, (region.height() * k).round() as i32];
let mut w = Writer {
out: Out::default(),
records: 0,
to_dev: Affine::scale(k) * Affine::translate(-region.origin().to_vec2()),
frame,
bounds: None,
brush: None,
pen: None,
fill_mode: None,
miter: None,
};
w.header(title);
// Dash gaps and hatches show what is below.
w.record(SETBKMODE, |o| o.u32(1));
for op in ops {
w.op(op);
}
w.release();
w.record(EOF, |o| {
o.u32(0);
o.u32(16);
o.u32(20);
});
w.finish()
}
impl Writer {
/// Append one record: its type, size and `body`, padded to 4 bytes.
fn record(&mut self, kind: u32, body: impl FnOnce(&mut Out)) {
let start = self.out.len();
self.out.u32(kind);
self.out.u32(0);
body(&mut self.out);
self.out.pad(4);
let size = u32::try_from(self.out.len() - start).unwrap_or(u32::MAX);
self.out.set_u32(start + 4, size);
self.records += 1;
}
fn header(&mut self, title: &str) {
let desc: Vec<u16> =
"VectorCraft".encode_utf16().chain([0]).chain(title.chars().take(200).collect::<String>().encode_utf16()).chain([0, 0]).collect();
let f = self.frame;
// The reference device: 100 pixels a millimetre, as large as the frame.
let mm = [(f[2] / 100).max(1) + 1, (f[3] / 100).max(1) + 1];
self.record(HEADER, |o| {
// Bounds, patched in `finish`.
o.bytes(&[0; 16]);
for v in f {
o.i32(v);
}
o.u32(SIGNATURE);
o.u32(0x0001_0000);
// Bytes, records and handles, patched in `finish`.
o.u32(0);
o.u32(0);
o.u16(3);
o.u16(0);
o.u32(desc.len() as u32);
o.u32(HEADER_SIZE as u32);
o.u32(0);
o.i32(mm[0] * 100);
o.i32(mm[1] * 100);
o.i32(mm[0]);
o.i32(mm[1]);
// No pixel format, not OpenGL; the device in micrometres.
o.u32(0);
o.u32(0);
o.u32(0);
o.u32(mm[0] as u32 * 1000);
o.u32(mm[1] as u32 * 1000);
for c in &desc {
o.u16(*c);
}
});
}
/// Patch the header's bounds, size and record count.
fn finish(mut self) -> Vec<u8> {
let b = self.bounds.map(|b| [b[0].max(self.frame[0]), b[1].max(self.frame[1]), b[2].min(self.frame[2]), b[3].min(self.frame[3])]);
// An empty picture has bounds (0, 0, -1, -1).
let b = b.filter(|b| b[0] <= b[2] && b[1] <= b[3]).unwrap_or([0, 0, -1, -1]);
for (i, v) in b.iter().enumerate() {
self.out.set_i32(8 + 4 * i, *v);
}
let size = u32::try_from(self.out.len()).unwrap_or(u32::MAX);
self.out.set_u32(48, size);
self.out.set_u32(52, self.records);
self.out.0
}
fn dev(&self, p: Point) -> [i32; 2] {
let q = self.to_dev * p;
let c = |v: f64| if v.is_finite() { v.round().clamp(-MAX_COORD, MAX_COORD) as i32 } else { 0 };
[c(q.x), c(q.y)]
}
fn grow(&mut self, b: Bounds) {
self.bounds = Some(union(self.bounds, b));
}
fn op(&mut self, op: &Op) {
match op {
Op::Fill { path, rule, rgb } => {
self.fill_mode(*rule);
self.brush(*rgb);
if let Some(b) = self.path(path) {
self.grow(b);
self.record(FILLPATH, |o| b.iter().for_each(|v| o.i32(*v)));
}
}
Op::Stroke { path, pen } => {
let dashed;
let path = if self.pen(pen) {
path
} else {
dashed = crate::scene::dashed(path, pen.dash.as_ref());
&dashed
};
if let Some(b) = self.path(path) {
let half = (pen.width * UNITS_PER_PT / 2.0).ceil() as i32;
let b = [b[0] - half, b[1] - half, b[2] + half, b[3] + half];
self.grow(b);
self.record(STROKEPATH, |o| b.iter().for_each(|v| o.i32(*v)));
}
}
Op::Image { image, xf, opacity } => self.image(image, *xf, *opacity),
Op::Clip { path, rule } => {
self.release();
self.record(SAVEDC, |_| {});
self.fill_mode(*rule);
if self.path(path).is_some() {
// RGN_AND: inside the clip already set.
self.record(SELECTCLIPPATH, |o| o.u32(1));
}
}
Op::Unclip => {
self.release();
self.record(RESTOREDC, |o| o.i32(-1));
// The DC's fill mode and miter limit are back to what they were at the save.
self.fill_mode = None;
self.miter = None;
}
}
}
fn fill_mode(&mut self, rule: FillRule) {
// ALTERNATE (even-odd) or WINDING (non-zero).
let mode = if rule == FillRule::EvenOdd { 1 } else { 2 };
if self.fill_mode != Some(mode) {
self.record(SETPOLYFILLMODE, |o| o.u32(mode));
self.fill_mode = Some(mode);
}
}
/// Select a solid brush of `rgb`, made when the colour changes.
fn brush(&mut self, rgb: [u8; 3]) {
let color = colorref(rgb);
if self.brush == Some(color) {
return;
}
self.drop_brush();
self.record(CREATEBRUSHINDIRECT, |o| {
o.u32(BRUSH);
// BS_SOLID, the colour, no hatch.
o.u32(0);
o.u32(color);
o.u32(0);
});
self.record(SELECTOBJECT, |o| o.u32(BRUSH));
self.brush = Some(color);
}
fn drop_brush(&mut self) {
if self.brush.take().is_some() {
self.record(SELECTOBJECT, |o| o.u32(WHITE_BRUSH));
self.record(DELETEOBJECT, |o| o.u32(BRUSH));
}
}
fn drop_pen(&mut self) {
if self.pen.take().is_some() {
self.record(SELECTOBJECT, |o| o.u32(NULL_PEN));
self.record(DELETEOBJECT, |o| o.u32(PEN));
}
}
/// Release our objects (before a DC is saved or restored, which would bring back deleted ones).
fn release(&mut self) {
self.drop_brush();
self.drop_pen();
}
/// Select a geometric pen for `p`, made when it changes. False when `p`'s dashes must be
/// drawn as separate lines (the pen selected is solid): a pen's dashes start at the path's
/// start, every entry at least a unit long, 16 entries at most.
fn pen(&mut self, p: &Pen) -> bool {
let k = UNITS_PER_PT;
let entries: Option<Vec<u32>> = p.dash.as_ref().map(|d| {
let mut v: Vec<f64> = d.pattern.clone();
if v.len() % 2 == 1 {
v.extend_from_within(..);
}
v.iter().map(|x| (x * k).round().clamp(0.0, f64::from(u32::MAX)) as u32).collect()
});
let native = match (&entries, &p.dash) {
(Some(e), Some(d)) => d.offset.abs() < 1e-9 && e.len() <= MAX_STYLE && e.iter().all(|x| *x >= 1),
_ => true,
};
let entries = if native { entries.unwrap_or_default() } else { vec![] };
let cap = match p.cap {
LineCap::Butt => PS_ENDCAP_FLAT,
LineCap::Round => 0,
LineCap::Square => PS_ENDCAP_SQUARE,
};
let join = match p.join {
LineJoin::Miter => PS_JOIN_MITER,
LineJoin::Round => 0,
LineJoin::Bevel => PS_JOIN_BEVEL,
};
let style = PS_GEOMETRIC | cap | join | if entries.is_empty() { 0 } else { PS_USERSTYLE };
let key = PenKey { style, width: (p.width * k).round().clamp(1.0, 1e9) as u32, color: colorref(p.rgb), entries };
if p.join == LineJoin::Miter {
let m = p.miter.round().clamp(1.0, 1000.0) as u32;
if self.miter != Some(m) {
self.record(SETMITERLIMIT, |o| o.u32(m));
self.miter = Some(m);
}
}
if self.pen.as_ref() != Some(&key) {
self.drop_pen();
let n = key.entries.len() as u32;
self.record(EXTCREATEPEN, |o| {
o.u32(PEN);
// No pattern bitmap: its offsets point past the record, its sizes are 0.
let end = 52 + 4 * n;
o.u32(end);
o.u32(0);
o.u32(end);
o.u32(0);
o.u32(key.style);
o.u32(key.width);
// BS_SOLID, the colour, no hatch.
o.u32(0);
o.u32(key.color);
o.u32(0);
o.u32(n);
key.entries.iter().for_each(|e| o.u32(*e));
});
self.record(SELECTOBJECT, |o| o.u32(PEN));
self.pen = Some(key);
}
native
}
/// `bp` as a path bracket → its device bounds (`None`: nothing to draw).
fn path(&mut self, bp: &BezPath) -> Option<Bounds> {
if !bp.elements().iter().any(|e| !matches!(e, PathEl::MoveTo(_) | PathEl::ClosePath)) {
return None;
}
self.record(BEGINPATH, |_| {});
let mut bounds: Option<Bounds> = None;
let mut run: Vec<[i32; 2]> = vec![];
let mut curves = false;
let mut last = Point::ZERO;
let mut start = Point::ZERO;
for el in bp.elements() {
let (pts, is_curve): (Vec<Point>, bool) = match *el {
PathEl::MoveTo(p) => {
self.flush_run(&mut run, curves);
let d = self.dev(p);
bounds = Some(union(bounds, [d[0], d[1], d[0], d[1]]));
self.record(MOVETOEX, |o| d.iter().for_each(|v| o.i32(*v)));
(last, start) = (p, p);
continue;
}
PathEl::ClosePath => {
self.flush_run(&mut run, curves);
self.record(CLOSEFIGURE, |_| {});
last = start;
continue;
}
PathEl::LineTo(p) => (vec![p], false),
PathEl::QuadTo(c, p) => (vec![last + (c - last) * (2.0 / 3.0), p + (c - p) * (2.0 / 3.0), p], true),
PathEl::CurveTo(a, b, p) => (vec![a, b, p], true),
};
if is_curve != curves {
self.flush_run(&mut run, curves);
curves = is_curve;
}
for p in &pts {
let d = self.dev(*p);
bounds = Some(union(bounds, [d[0], d[1], d[0], d[1]]));
run.push(d);
}
last = pts.last().copied().unwrap_or(last);
}
self.flush_run(&mut run, curves);
self.record(ENDPATH, |_| {});
bounds
}
/// Write the points of a run of lines or curves, then empty it.
fn flush_run(&mut self, run: &mut Vec<[i32; 2]>, curves: bool) {
if run.is_empty() {
return;
}
let pts = std::mem::take(run);
let b = pts.iter().fold(None, |acc, p| Some(union(acc, [p[0], p[1], p[0], p[1]]))).unwrap_or([0, 0, -1, -1]);
self.record(if curves { POLYBEZIERTO } else { POLYLINETO }, |o| {
b.iter().for_each(|v| o.i32(*v));
o.u32(pts.len() as u32);
for p in &pts {
o.i32(p[0]);
o.i32(p[1]);
}
});
}
/// An image placed by `xf` (its pixels → document): StretchDIBits when opaque, else AlphaBlend,
/// drawn at its pixel size under a world transform.
fn image(&mut self, img: &Rgba, xf: Affine, opacity: f32) {
if img.width == 0 || img.height == 0 || opacity <= 0.0 {
return;
}
let m = self.to_dev * xf;
if !m.as_coeffs().iter().all(|v| v.is_finite()) || m.determinant().abs() < 1e-12 {
return;
}
let (w, h) = (f64::from(img.width), f64::from(img.height));
let corners = [Point::ZERO, Point::new(w, 0.0), Point::new(0.0, h), Point::new(w, h)].map(|p| self.dev(xf * p));
let b = corners.iter().fold(None, |acc, p| Some(union(acc, [p[0], p[1], p[0], p[1]]))).unwrap_or([0, 0, -1, -1]);
self.grow(b);
let [a, bb, c, d, e, f] = m.as_coeffs();
self.record(SETWORLDTRANSFORM, |o| [a, bb, c, d, e, f].iter().for_each(|v| o.f32(*v as f32)));
let (iw, ih) = (img.width as i32, img.height as i32);
if img.opaque() && opacity >= 0.998 {
let (bmi, bits) = dib::rgb24(img, 1.0);
self.record(STRETCHDIBITS, |o| {
b.iter().for_each(|v| o.i32(*v));
// Destination and source rectangles.
[0, 0, 0, 0, iw, ih].iter().for_each(|v| o.i32(*v));
o.u32(80);
o.u32(bmi.len() as u32);
o.u32(80 + bmi.len() as u32);
o.u32(bits.len() as u32);
// DIB_RGB_COLORS, SRCCOPY, the destination size.
o.u32(0);
o.u32(SRCCOPY);
o.i32(iw);
o.i32(ih);
o.bytes(&bmi);
o.bytes(&bits);
});
} else {
let (bmi, bits) = dib::bgra32(img, opacity);
self.record(ALPHABLEND, |o| {
b.iter().for_each(|v| o.i32(*v));
[0, 0, iw, ih].iter().for_each(|v| o.i32(*v));
// AC_SRC_OVER, no flags, constant alpha 255, AC_SRC_ALPHA (per-pixel alpha).
o.bytes(&[0, 0, 255, 1]);
o.i32(0);
o.i32(0);
// The source's own transform: identity.
[1.0f32, 0.0, 0.0, 1.0, 0.0, 0.0].iter().for_each(|v| o.f32(*v));
o.u32(0);
o.u32(0);
o.u32(108);
o.u32(bmi.len() as u32);
o.u32(108 + bmi.len() as u32);
o.u32(bits.len() as u32);
o.i32(iw);
o.i32(ih);
o.bytes(&bmi);
o.bytes(&bits);
});
}
// MWT_IDENTITY: back to drawing in device units.
self.record(MODIFYWORLDTRANSFORM, |o| {
[1.0f32, 0.0, 0.0, 1.0, 0.0, 0.0].iter().for_each(|v| o.f32(*v));
o.u32(1);
});
}
}

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

112
crates/metafile/src/lib.rs Normal file
View File

@ -0,0 +1,112 @@
//! Windows metafiles: hand-written EMF and WMF writers and readers.
//!
//! [`export`] writes the visible art over one region of a document (an artboard) as a metafile:
//!
//! - **EMF**: paths keep their Bézier curves, fills are solid brushes, strokes geometric pens with
//! their width, caps, joins, miter limit and dashes; strokes a pen can't draw (aligned inside or
//! outside, width profiles, arrowheads) are their filled outlines and brush strokes their brush
//! art. Clipping masks clip, type is written as glyph outlines, images keep their pixels (with
//! their transparency), gradients become images clipped to what they paint and pattern fills
//! their tiles clipped to the shape. The header's frame is the region in 0.01 mm.
//! - **WMF**: the same art with curves flattened into polygons and 16-bit coordinates, behind a
//! placeable header that gives the size; WMF has no clipping paths, transparency, dashes on wide
//! lines or gradients, so clipped art is written whole, images over white, dashes as separate
//! lines and gradients and patterns as one colour (each loss comes back as a warning).
//!
//! [`import`] reads either format back into a document of one artboard (the picture's frame) and
//! one layer: paths with fills and strokes, clipping, images and text. Records it doesn't read are
//! skipped, with one warning.
//!
//! - `bytes`: little-endian writing and bounds-checked reading.
//! - `dib`: device-independent bitmaps, both ways.
//! - `scene`: the document walk both writers share.
//! - `emf`, `wmf`: the writers.
//! - `import`: the reader.
mod bytes;
mod dib;
mod emf;
mod import;
mod scene;
mod wmf;
use vectorcraft_doc::Document;
use vectorcraft_geom::Rect;
/// A Windows metafile format.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Kind {
/// Enhanced Metafile (32-bit coordinates, Bézier curves, paths and clipping).
Emf,
/// Windows Metafile with a placeable header (16-bit coordinates, polygons).
Wmf,
}
impl Kind {
/// The format id (`emf`, `wmf`).
pub fn id(self) -> &'static str {
match self {
Self::Emf => "emf",
Self::Wmf => "wmf",
}
}
}
/// The metafile format `bytes` start like, if any.
pub fn sniff(bytes: &[u8]) -> Option<Kind> {
if emf::is_emf(bytes) {
Some(Kind::Emf)
} else if wmf::is_wmf(bytes) {
Some(Kind::Wmf)
} else {
None
}
}
/// A written metafile.
#[derive(Clone, Debug, Default)]
pub struct Output {
pub bytes: Vec<u8>,
/// What was approximated or left out.
pub warnings: Vec<String>,
}
/// Write the visible art of `doc` over `region` (document space: its top-left corner is the
/// picture's origin, its size the picture's) as `kind`. Live geometry effects are applied first;
/// template layers, hidden objects and guides are left out. Raster effects should be turned into
/// images before (the engine does); the ones left are reported and left out.
pub fn export(doc: &Document, region: Rect, kind: Kind) -> Result<Output, String> {
let r = region.abs();
if ![r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite()) || r.width() <= 0.0 || r.height() <= 0.0 {
return Err("the export region is empty".into());
}
let baked = vectorcraft_effects::bake_document(doc);
let doc = baked.as_ref().unwrap_or(doc);
let (ops, mut warnings) = scene::Scene::new(doc, r, kind == Kind::Emf).run();
let bytes = match kind {
Kind::Emf => emf::write(&ops, r, &doc.title),
Kind::Wmf => wmf::write(&ops, r, &mut warnings),
};
Ok(Output { bytes, warnings })
}
/// A metafile read into a document.
#[derive(Clone, Debug)]
pub struct Imported {
pub document: Document,
pub kind: Kind,
/// What was approximated or skipped.
pub warnings: Vec<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,421 @@
//! The document walk both writers share: each visible object over the region becomes drawing
//! operations ([`Op`]) in paint order, in document space. What a format can't hold is decided
//! here once: with clipping (EMF), gradients become images clipped to what they paint and
//! patterns their tiles clipped to the shape; without (WMF), clipped art is written whole and
//! gradients and patterns as one colour.
use std::collections::HashMap;
use std::sync::Arc;
use vectorcraft_color::freeform::{painted_box, spread_scale};
use vectorcraft_color::{BlendMode, GradientKind, GradientPaint, Paint};
use vectorcraft_doc::{AppearanceItem, Dash, Document, ImageObject, LineCap, LineJoin, Node, NodeKind, StrokeLayer};
use vectorcraft_effects::stroke;
use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Rect, Shape};
use crate::dib::Rgba;
/// How deep symbols, brush art and pattern tiles may nest in one another (deeper art is left out).
const MAX_NEST: u32 = 8;
/// Most pixels along a side of a gradient's image (its colours are smooth).
const MAX_GRADIENT_PX: f64 = 512.0;
/// Opacity at or above this is written as opaque.
const OPAQUE: f32 = 0.998;
const TRANSPARENCY: &str = "transparency is left out of paths: transparent fills and strokes are written opaque";
const BLENDING: &str = "blending modes are left out";
const MASKS: &str = "opacity masks are left out";
const RASTER_FX: &str = "raster effects (shadows, glows, blurs, feathering) are left out";
const CLIPPING: &str = "clipping masks are not applied: the art they clip is written whole";
const FLAT_GRADIENTS: &str = "gradients are written as one colour, the average of their stops";
const FLAT_PATTERNS: &str = "pattern fills are written as one colour";
/// A stroke as a pen draws it.
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct Pen {
pub rgb: [u8; 3],
/// Points.
pub width: f64,
pub cap: LineCap,
pub join: LineJoin,
pub miter: f64,
/// A dash pattern that draws dashes (points).
pub dash: Option<Dash>,
}
/// One drawing operation, in document space.
#[derive(Clone, Debug)]
pub(crate) enum Op {
Fill {
path: BezPath,
rule: FillRule,
rgb: [u8; 3],
},
Stroke {
path: BezPath,
pen: Pen,
},
/// An image: its pixel space (0..w, 0..h) maps into the document by `xf`; its alpha times
/// `opacity`.
Image {
image: Arc<Rgba>,
xf: Affine,
opacity: f32,
},
/// What follows, up to the matching [`Op::Unclip`], is clipped to `path` (inside the clips
/// around it). Only formats with clipping get these.
Clip {
path: BezPath,
rule: FillRule,
},
Unclip,
}
fn overlaps(a: Rect, b: Rect) -> bool {
a.x0 <= b.x1 && b.x0 <= a.x1 && a.y0 <= b.y1 && b.y0 <= a.y1
}
fn rgb_of(c: &vectorcraft_color::Color) -> [u8; 3] {
let [r, g, b, _] = c.to_rgba8(1.0);
[r, g, b]
}
/// The first visible solid fill colour in `n` (a pattern tile's art).
fn first_solid(n: &Node) -> Option<vectorcraft_color::Color> {
let own = n.appearance.items.iter().find_map(|i| match i {
AppearanceItem::Fill(f) if f.visible => f.paint.color(),
_ => None,
});
own.or_else(|| n.children().into_iter().flatten().find_map(|c| first_solid(c)))
}
/// `bp` cut into its dashes (as it is when `dash` draws none), for pens that can't draw them.
pub(crate) fn dashed(bp: &BezPath, dash: Option<&Dash>) -> BezPath {
dash.and_then(|d| stroke::dash(bp, d)).map_or_else(|| bp.clone(), |d| d.path)
}
fn q(v: f32) -> u8 {
(v.clamp(0.0, 1.0) * 255.0).round() as u8
}
pub(crate) struct Scene<'a> {
doc: &'a Document,
region: Rect,
/// The format clips (EMF).
clips: bool,
ops: Vec<Op>,
warnings: Vec<String>,
/// Decoded images by key (`None`: can't be decoded, left out).
images: HashMap<String, Option<Arc<Rgba>>>,
brushes: Option<Vec<vectorcraft_brush::Brush>>,
/// The opacity of the containers around the current object.
opacity: f32,
/// Symbols, brush art and pattern tiles being written inside one another.
nest: u32,
}
impl<'a> Scene<'a> {
pub fn new(doc: &'a Document, region: Rect, clips: bool) -> Self {
Self { doc, region, clips, ops: vec![], warnings: vec![], images: HashMap::new(), brushes: None, opacity: 1.0, nest: 0 }
}
/// The operations (clipped to the region when the format clips) and the warnings.
pub fn run(mut self) -> (Vec<Op>, Vec<String>) {
let doc = self.doc;
if self.clips {
self.ops.push(Op::Clip { path: self.region.to_path(0.1), rule: FillRule::NonZero });
}
for l in &doc.layers {
self.node(l, false);
}
if self.clips {
self.ops.push(Op::Unclip);
}
(self.ops, self.warnings)
}
fn warn(&mut self, w: &str) {
if !self.warnings.iter().any(|x| x == w) {
self.warnings.push(w.to_string());
}
}
fn node(&mut self, n: &Node, force: bool) {
if let NodeKind::Layer { template: true, .. } = n.kind {
return;
}
if !force && !n.visible {
return;
}
if !n.is_layer() {
match n.visual_bounds() {
Some(b) if !overlaps(b, self.region) => return,
None if !n.is_container() => return,
_ => {}
}
}
self.note_losses(n);
let opacity = self.opacity;
self.opacity *= n.opacity.clamp(0.0, 1.0);
match &n.kind {
NodeKind::Layer { children, clip, .. } | NodeKind::Group { children, clip } => self.children(children, *clip),
NodeKind::Path { path, rule, guide, .. } => {
if !*guide {
self.shape(n, &path.to_bezpath(), *rule);
}
}
NodeKind::Compound { children, rule } => {
let mut bp = BezPath::new();
for p in children.iter().filter_map(|c| c.path_data()) {
bp.extend(p.to_bezpath());
}
self.shape(n, &bp, *rule);
}
NodeKind::Text(_) => {
if let Some(o) = vectorcraft_effects::outline_text(n) {
self.node(&o, true);
}
}
NodeKind::Image(im) => self.image(im),
NodeKind::SymbolInstance { symbol, xf } => {
if let Some(sym) = self.doc.symbols.iter().find(|s| &s.name == symbol)
&& self.nest < MAX_NEST
{
let mut art = (*sym.art).clone();
art.transform(*xf, false);
self.nest += 1;
self.node(&art, true);
self.nest -= 1;
}
}
// Live blends, envelopes, meshes and repeats are written as their evaluated art.
NodeKind::Blend { .. } | NodeKind::Envelope { .. } | NodeKind::Mesh(_) | NodeKind::Repeat(_) => {
let g = vectorcraft_doc::live::expand_deep(n, None);
for c in g.children().into_iter().flatten() {
self.node(c, false);
}
}
}
self.opacity = opacity;
}
/// A container's children. A clipping container's first child clips the rest (its fill paints
/// below them, its stroke over them); without clipping they are written whole.
fn children(&mut self, children: &[Arc<Node>], clip: bool) {
let Some((first, rest)) = children.split_first().filter(|_| clip) else {
children.iter().for_each(|c| self.node(c, false));
return;
};
let paint = first.clip_paint();
let outline = if self.clips {
// Nothing to clip by hides the clipped art (as on the canvas).
let Some((path, rule)) = vectorcraft_effects::clip_outline(first) else { return };
Some((path, rule))
} else {
self.warn(CLIPPING);
None
};
let clipped = outline.is_some();
if let Some((path, rule)) = outline {
self.ops.push(Op::Clip { path, rule });
}
if let Some(f) = &paint.fill {
self.node(f, false);
}
rest.iter().for_each(|c| self.node(c, false));
if clipped {
self.ops.push(Op::Unclip);
}
if let Some(s) = &paint.stroke {
self.node(s, false);
}
}
/// Warn about what a metafile can't hold in `n`'s look.
fn note_losses(&mut self, n: &Node) {
if n.blend != BlendMode::Normal {
self.warn(BLENDING);
}
if n.mask.as_ref().is_some_and(|m| !m.disabled) {
self.warn(MASKS);
}
let raster = |fx: &[vectorcraft_doc::Effect]| fx.iter().any(|e| e.visible && vectorcraft_effects::is_raster(&e.id));
if raster(&n.appearance.effects) || n.appearance.items.iter().any(|i| raster(i.effects())) {
self.warn(RASTER_FX);
}
}
/// Warn when a path paints at `opacity` (times the containers').
fn check_opacity(&mut self, opacity: f32) {
if self.opacity * opacity < OPAQUE {
self.warn(TRANSPARENCY);
}
}
/// A path's fills and strokes, in paint order.
fn shape(&mut self, n: &Node, bp: &BezPath, rule: FillRule) {
if bp.elements().is_empty() {
return;
}
let bounds = bp.bounding_box();
for item in &n.appearance.items {
match item {
AppearanceItem::Fill(f) if f.visible => {
if f.blend != BlendMode::Normal {
self.warn(BLENDING);
}
self.fill(bp, rule, &f.paint, f.opacity, bounds);
}
AppearanceItem::Stroke(st) if st.visible && st.width > 0.0 && st.width.is_finite() => self.stroke(bp, rule, st, bounds),
_ => {}
}
}
}
/// Fill `bp` with `paint` at `opacity`; `bounds` places a gradient that isn't placed.
fn fill(&mut self, bp: &BezPath, rule: FillRule, paint: &Paint, opacity: f32, bounds: Rect) {
match paint {
Paint::None => {}
Paint::Gradient(g) if self.clips => self.gradient(bp, rule, g, opacity, bounds),
Paint::Pattern { pattern, xf } if self.clips && self.nest < MAX_NEST && self.doc.pattern(pattern).is_some() => {
let area = bp.bounding_box().intersect(self.region);
let tiles = self.doc.pattern(pattern).map(|def| def.instances_in(*xf, area)).unwrap_or_default();
let outer = self.opacity;
self.opacity *= opacity.clamp(0.0, 1.0);
self.ops.push(Op::Clip { path: bp.clone(), rule });
self.nest += 1;
tiles.iter().for_each(|t| self.node(t, true));
self.nest -= 1;
self.ops.push(Op::Unclip);
self.opacity = outer;
}
p => {
let Some(rgb) = self.flat(p) else { return };
self.check_opacity(opacity);
self.ops.push(Op::Fill { path: bp.clone(), rule, rgb });
}
}
}
/// One colour for a paint: a solid colour, the average of a gradient's stops, or a pattern's
/// first solid fill.
fn flat(&mut self, p: &Paint) -> Option<[u8; 3]> {
match p {
Paint::None => None,
Paint::Solid { color, .. } => Some(rgb_of(color)),
Paint::Gradient(g) => {
self.warn(FLAT_GRADIENTS);
let stops = &g.gradient.stops;
let n = stops.len().max(1) as f64;
let sum = stops.iter().map(|s| rgb_of(&s.color)).fold([0.0; 3], |a, c| [0, 1, 2].map(|i| a[i] + f64::from(c[i])));
Some(sum.map(|v| (v / n).round().clamp(0.0, 255.0) as u8))
}
Paint::Pattern { pattern, .. } => {
self.warn(FLAT_PATTERNS);
let first = self.doc.pattern(pattern).and_then(|def| def.art.iter().find_map(|a| first_solid(a)));
Some(first.map_or([128; 3], |c| rgb_of(&c)))
}
}
}
/// A gradient fill: an image of its colours over the shape's box, clipped to the shape,
/// sampled at the document's raster effects resolution.
fn gradient(&mut self, bp: &BezPath, rule: FillRule, g: &GradientPaint, opacity: f32, bounds: Rect) {
let area = bp.bounding_box().intersect(self.region);
let long = area.width().max(area.height());
if !(long > 1e-9 && long.is_finite()) {
return;
}
let k = (self.doc.raster_effects_ppi / 72.0).min(MAX_GRADIENT_PX / long);
let along = |len: f64| (len * k).ceil().clamp(1.0, MAX_GRADIENT_PX) as u16;
let (cols, rows) = (along(area.width()), along(area.height()));
let pixels: Vec<u8> = if g.gradient.kind == GradientKind::Freeform {
let Some(b) = painted_box(bounds) else { return };
let field = g.freeform_on(b).field_with(spread_scale(b), &|c| c.to_rgb());
field.grid(area, cols, rows).flat_map(|(c, a)| [q(c[0]), q(c[1]), q(c[2]), q(a)]).collect()
} else {
let geom = g.resolve(bounds);
let (cw, ch) = (area.width() / f64::from(cols), area.height() / f64::from(rows));
(0..usize::from(rows) * usize::from(cols))
.flat_map(|i| {
let (x, y) = (i % usize::from(cols), i / usize::from(cols));
let p = Point::new(area.x0 + (x as f64 + 0.5) * cw, area.y0 + (y as f64 + 0.5) * ch);
let t = geom.param_at(g.gradient.kind, p);
let t = if t.is_finite() { t.clamp(0.0, 1.0) } else { 0.0 };
let (c, a) = g.gradient.sample(t as f32);
c.to_rgba8(a)
})
.collect()
};
let image = Rgba { width: u32::from(cols), height: u32::from(rows), pixels };
let xf =
Affine::translate(area.origin().to_vec2()) * Affine::scale_non_uniform(area.width() / f64::from(cols), area.height() / f64::from(rows));
self.ops.push(Op::Clip { path: bp.clone(), rule });
self.ops.push(Op::Image { image: Arc::new(image), xf, opacity: (self.opacity * opacity).clamp(0.0, 1.0) });
self.ops.push(Op::Unclip);
}
fn stroke(&mut self, bp: &BezPath, rule: FillRule, st: &StrokeLayer, bounds: Rect) {
if st.brush.is_some()
&& self.nest < MAX_NEST
&& let Some(art) = self.brush_art(bp, st)
{
let opacity = self.opacity;
self.opacity *= st.opacity.clamp(0.0, 1.0);
self.nest += 1;
art.iter().for_each(|piece| self.node(piece, true));
self.nest -= 1;
self.opacity = opacity;
return;
}
if st.blend != BlendMode::Normal {
self.warn(BLENDING);
}
// A pen paints one colour along the path itself: anything else is the stroke's outline,
// filled.
let flat_paint = matches!(st.paint, Paint::Solid { .. }) || !self.clips;
if !stroke::is_plain(st) || !flat_paint {
let region = stroke::outline_region(&PathData::from_bezpath(bp), rule, st).to_bezpath();
self.fill(&region, FillRule::NonZero, &st.paint, st.opacity, st.paint_bounds(bounds));
return;
}
let Some(rgb) = self.flat(&st.paint) else { return };
self.check_opacity(st.opacity);
let pen = Pen { rgb, width: st.width, cap: st.cap, join: st.join, miter: st.miter_limit, dash: st.dash.clone().filter(|d| d.is_dashed()) };
self.ops.push(Op::Stroke { path: bp.clone(), pen });
}
fn brush_art(&mut self, bp: &BezPath, st: &StrokeLayer) -> Option<Vec<Node>> {
let doc = self.doc;
let brushes = self.brushes.get_or_insert_with(|| vectorcraft_brush::library(doc));
let b = st.brush.as_deref().and_then(|name| brushes.iter().find(|b| b.name == name))?;
Some(vectorcraft_brush::stroke_pieces(b, bp, st))
}
/// A placed image, decoded once per image.
fn image(&mut self, im: &ImageObject) {
let Some(img) = self.decoded(&im.key) else { return };
let (dw, dh) = (f64::from(img.width), f64::from(img.height));
let xf = im.xf * Affine::scale_non_uniform(f64::from(im.width.max(1)) / dw, f64::from(im.height.max(1)) / dh);
self.ops.push(Op::Image { image: img, xf, opacity: self.opacity.clamp(0.0, 1.0) });
}
/// Image `key`'s pixels (scaled down to what a metafile stores), `None` with a warning when it
/// has none or can't be decoded.
fn decoded(&mut self, key: &str) -> Option<Arc<Rgba>> {
if let Some(i) = self.images.get(key) {
return i.clone();
}
let blob = self.doc.images.get(key).filter(|b| !b.bytes.is_empty());
let img = blob
.and_then(|b| image::load_from_memory(&b.bytes).ok())
.map(|i| Rgba::fitted(i.to_rgba8()))
.filter(|i| i.width > 0 && i.height > 0)
.map(Arc::new);
if img.is_none() {
self.warn(&format!("image '{key}' could not be decoded and was left out"));
}
self.images.insert(key.to_string(), img.clone());
img
}
}

View File

@ -0,0 +1,330 @@
//! EMF and WMF output read back record by record: headers, record sizes, paths, pens, brushes,
//! clips, images, and what each format leaves out.
use std::sync::Arc;
use vectorcraft_color::{Color, Gradient, GradientPaint, Paint};
use vectorcraft_doc::{Appearance, CharStyle, Dash, Document, ImageBlob, ImageObject, LineCap, LineJoin, Node, NodeId, NodeKind, TextObject};
use vectorcraft_geom::{Affine, PathData, Point, Rect, shapes};
use crate::*;
/// Add `n` (with a fresh id) on top of `d`'s first layer.
pub(crate) fn add(d: &mut Document, mut n: Node) {
n.id = d.alloc_id();
let layer = d.layers[0].id;
let at = d.layers[0].children().map_or(0, Vec::len);
d.insert(Some(layer), at, n).unwrap();
}
/// A document of one `w` × `h` artboard holding `nodes` on its layer.
pub(crate) fn doc_with(w: f64, h: f64, nodes: Vec<Node>) -> Document {
let mut d = Document::new(w, h);
nodes.into_iter().for_each(|n| add(&mut d, n));
d
}
pub(crate) fn path(p: PathData, fill: Paint, stroke: Paint, width: f64) -> Node {
Node::path(NodeId(0), p, Appearance::basic(fill, stroke, width))
}
pub(crate) fn red() -> Paint {
Paint::solid(Color::rgb8(255, 0, 0))
}
pub(crate) fn blue() -> Paint {
Paint::solid(Color::rgb8(0, 0, 255))
}
/// A `w` × `h` PNG of `rgba`.
pub(crate) fn png(w: u32, h: u32, rgba: [u8; 4]) -> Vec<u8> {
let img = image::RgbaImage::from_pixel(w, h, image::Rgba(rgba));
let mut out = vec![];
img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap();
out
}
/// An image node of `bytes` placed by `xf`, its blob added to `d`.
pub(crate) fn image_node(d: &mut Document, bytes: Vec<u8>, w: u32, h: u32, xf: Affine) -> Node {
let blob = ImageBlob::new("image/png", bytes);
let key = blob.content_key();
d.images.insert(key.clone(), blob);
Node::new(NodeId(0), NodeKind::Image(ImageObject { key, width: w, height: h, xf, link: None, placement: Default::default() }))
}
pub(crate) fn u32_at(b: &[u8], at: usize) -> u32 {
u32::from_le_bytes(b[at..at + 4].try_into().unwrap())
}
pub(crate) fn i32_at(b: &[u8], at: usize) -> i32 {
i32::from_le_bytes(b[at..at + 4].try_into().unwrap())
}
pub(crate) fn u16_at(b: &[u8], at: usize) -> u16 {
u16::from_le_bytes(b[at..at + 2].try_into().unwrap())
}
/// The EMF's records: (type, the whole record).
pub(crate) fn emf_records(b: &[u8]) -> Vec<(u32, &[u8])> {
let mut out = vec![];
let mut at = 0;
while at < b.len() {
let size = u32_at(b, at + 4) as usize;
assert!(size >= 8 && size.is_multiple_of(4), "record size {size} at {at}");
out.push((u32_at(b, at), &b[at..at + size]));
at += size;
}
assert_eq!(at, b.len(), "records end at the end of the file");
out
}
/// The WMF's records after both headers: (function, the whole record).
pub(crate) fn wmf_records(b: &[u8]) -> Vec<(u16, &[u8])> {
let mut out = vec![];
let mut at = 22 + 18;
while at < b.len() {
let words = u32_at(b, at) as usize;
assert!(words >= 3, "record of {words} words at {at}");
out.push((u16_at(b, at + 4), &b[at..at + words * 2]));
at += words * 2;
}
assert_eq!(at, b.len());
out
}
pub(crate) fn emf(d: &Document) -> Output {
export(d, d.artboards[0].rect, Kind::Emf).unwrap()
}
pub(crate) fn wmf(d: &Document) -> Output {
export(d, d.artboards[0].rect, Kind::Wmf).unwrap()
}
fn kinds(recs: &[(u32, &[u8])]) -> Vec<u32> {
recs.iter().map(|r| r.0).collect()
}
fn count(k: &[u32], kind: u32) -> usize {
k.iter().filter(|x| **x == kind).count()
}
#[test]
fn emf_header_frame_is_the_region_in_hundredths_of_a_millimetre() {
// 72 × 144 pt is 25.4 × 50.8 mm; a rectangle from (36, 36) to (54, 72) pt.
let d = doc_with(72.0, 144.0, vec![path(shapes::rectangle(Rect::new(36.0, 36.0, 54.0, 72.0)), red(), Paint::None, 0.0)]);
let b = &emf(&d).bytes;
assert_eq!(sniff(b), Some(Kind::Emf));
assert_eq!(u32_at(b, 0), 1);
assert_eq!(u32_at(b, 40), 0x464D_4520, "the \" EMF\" signature");
// rclFrame.
assert_eq!([i32_at(b, 24), i32_at(b, 28), i32_at(b, 32), i32_at(b, 36)], [0, 0, 2540, 5080]);
// rclBounds, in device units of 0.01 mm: the rectangle.
assert_eq!([i32_at(b, 8), i32_at(b, 12), i32_at(b, 16), i32_at(b, 20)], [1270, 1270, 1905, 2540]);
// The reference device has 100 pixels a millimetre.
let (dev, mm) = ((i32_at(b, 72), i32_at(b, 76)), (i32_at(b, 80), i32_at(b, 84)));
assert_eq!(dev, (mm.0 * 100, mm.1 * 100));
assert_eq!((u32_at(b, 100), u32_at(b, 104)), (mm.0 as u32 * 1000, mm.1 as u32 * 1000), "micrometres");
// The description names the app.
let n = u32_at(b, 60) as usize;
let off = u32_at(b, 64) as usize;
assert_eq!(off, 108);
let desc: Vec<u16> = (0..n).map(|i| u16_at(b, off + 2 * i)).collect();
assert!(String::from_utf16_lossy(&desc).starts_with("VectorCraft\0"));
// An empty picture has empty bounds.
let empty = emf(&doc_with(72.0, 72.0, vec![]));
assert_eq!([i32_at(&empty.bytes, 8), i32_at(&empty.bytes, 16)], [0, -1]);
}
#[test]
fn emf_record_sizes_add_up_to_the_header_totals() {
let mut d = doc_with(200.0, 100.0, vec![]);
let img = image_node(&mut d, png(3, 2, [0, 128, 0, 255]), 3, 2, Affine::translate((10.0, 10.0)) * Affine::scale(10.0));
add(&mut d, img);
add(&mut d, path(shapes::ellipse(Rect::new(50.0, 10.0, 150.0, 90.0)), red(), blue(), 2.0));
let b = emf(&d).bytes;
let recs = emf_records(&b);
assert_eq!(u32_at(&b, 48) as usize, b.len(), "nBytes");
assert_eq!(u32_at(&b, 52) as usize, recs.len(), "nRecords");
assert_eq!(u16_at(&b, 56), 3, "nHandles: the reserved slot, a brush and a pen");
let (last, eof) = recs.last().copied().unwrap();
assert_eq!((last, eof.len()), (14, 20), "EMR_EOF ends the file");
assert_eq!(u32_at(eof, 16), 20, "nSizeLast");
// Fixed-size records have their fixed sizes.
for (k, r) in &recs {
let fixed = match k {
18 | 19 | 34 | 37 | 40 | 58 | 67 => Some(12),
27 | 54 => Some(16),
33 | 59 | 60 | 61 => Some(8),
39 | 62 | 64 => Some(24),
35 => Some(32),
36 => Some(36),
_ => None,
};
if let Some(f) = fixed {
assert_eq!(r.len(), f, "record {k}");
}
}
// StretchDIBits: its bitmap where its offsets say, 3 × 2 pixels of 24 bits (rows of 12 bytes).
let sdib = recs.iter().find(|r| r.0 == 81).unwrap().1;
assert_eq!([u32_at(sdib, 48), u32_at(sdib, 52), u32_at(sdib, 56), u32_at(sdib, 60)], [80, 40, 120, 24]);
assert_eq!(sdib.len(), 144);
}
#[test]
fn emf_paths_keep_their_curves_brushes_and_pens() {
let mut e = path(shapes::ellipse(Rect::new(10.0, 10.0, 60.0, 40.0)), red(), blue(), 4.0);
if let Some(st) = e.appearance.stroke_mut() {
st.cap = LineCap::Round;
st.join = LineJoin::Bevel;
}
let b = emf(&doc_with(100.0, 100.0, vec![e])).bytes;
let recs = emf_records(&b);
let k = kinds(&recs);
assert!(k.contains(&5), "EMR_POLYBEZIERTO keeps the curves");
assert!(k.contains(&62) && k.contains(&64), "filled, then stroked");
let brush = recs.iter().find(|r| r.0 == 39).unwrap().1;
assert_eq!(u32_at(brush, 16), 0x0000_00ff, "a red COLORREF");
let pen = recs.iter().find(|r| r.0 == 95).unwrap().1;
let style = u32_at(pen, 28);
assert_eq!(style & 0x000f_0000, 0x0001_0000, "geometric");
assert_eq!(style & 0xf00, 0, "round caps");
assert_eq!(style & 0xf000, 0x1000, "bevel joins");
assert_eq!(u32_at(pen, 32), (4.0 * 2540.0 / 72.0f64).round() as u32, "4 pt in 0.01 mm");
assert_eq!(u32_at(pen, 40), 0x00ff_0000, "a blue COLORREF");
assert!(recs.iter().any(|r| r.0 == 19 && u32_at(r.1, 8) == 2), "WINDING for non-zero");
// One brush and one pen for two shapes of the same colours.
let two = doc_with(
100.0,
100.0,
vec![
path(shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), red(), blue(), 1.0),
path(shapes::rectangle(Rect::new(20.0, 0.0, 30.0, 10.0)), red(), blue(), 1.0),
],
);
let k = kinds(&emf_records(&emf(&two).bytes));
assert_eq!((count(&k, 39), count(&k, 95)), (1, 1));
}
#[test]
fn emf_dashes_are_pen_styles() {
let mut l = path(shapes::line(Point::new(10.0, 10.0), Point::new(90.0, 10.0)), Paint::None, blue(), 2.0);
l.appearance.stroke_mut().unwrap().dash = Some(Dash { pattern: vec![6.0, 3.0], offset: 0.0, align_corners: false });
let b = emf(&doc_with(100.0, 100.0, vec![l.clone()])).bytes;
let recs = emf_records(&b);
let pen = recs.iter().find(|r| r.0 == 95).unwrap().1;
assert_eq!(u32_at(pen, 28) & 0xf, 7, "PS_USERSTYLE");
assert_eq!(u32_at(pen, 48), 2);
assert_eq!([u32_at(pen, 52), u32_at(pen, 56)], [(6.0 * 2540.0 / 72.0f64).round() as u32, (3.0 * 2540.0 / 72.0f64).round() as u32]);
// With an offset, the dashes are separate lines drawn with a solid pen.
l.appearance.stroke_mut().unwrap().dash = Some(Dash { pattern: vec![6.0, 3.0], offset: 2.0, align_corners: false });
let b = emf(&doc_with(100.0, 100.0, vec![l])).bytes;
let recs = emf_records(&b);
assert_eq!(u32_at(recs.iter().find(|r| r.0 == 95).unwrap().1, 28) & 0xf, 0);
assert!(count(&kinds(&recs), 27) > 5, "a move-to for each dash");
}
#[test]
fn emf_clips_gradients_and_images() {
let g = Paint::Gradient(Box::new(GradientPaint::new(Gradient::default())));
let mut d = doc_with(100.0, 100.0, vec![path(shapes::rectangle(Rect::new(10.0, 10.0, 60.0, 30.0)), g, Paint::None, 0.0)]);
let opaque = image_node(&mut d, png(4, 4, [10, 20, 30, 255]), 4, 4, Affine::translate((0.0, 50.0)));
let clear = image_node(&mut d, png(4, 4, [10, 20, 30, 100]), 4, 4, Affine::translate((50.0, 50.0)) * Affine::rotate(0.3));
add(&mut d, opaque);
add(&mut d, clear);
let out = emf(&d);
assert!(out.warnings.is_empty(), "{:?}", out.warnings);
let recs = emf_records(&out.bytes);
let k = kinds(&recs);
// The region's clip, then the gradient's clip around its image.
assert_eq!(count(&k, 67), 2);
assert_eq!(count(&k, 33), count(&k, 34), "every save is restored");
// The (opaque) gradient's image and the opaque image are StretchDIBits; the translucent one
// AlphaBlend with per-pixel alpha.
assert_eq!(count(&k, 81), 2);
let ab = recs.iter().find(|r| r.0 == 114).unwrap().1;
assert_eq!(&ab[40..44], &[0, 0, 255, 1]);
// Images are placed by a world transform, reset after each.
assert_eq!((count(&k, 35), count(&k, 36)), (3, 3));
}
#[test]
fn type_is_written_as_outlines() {
let t = TextObject::point(Point::new(10.0, 50.0), "Hi", CharStyle { fill: red(), ..CharStyle::default() });
let d = doc_with(100.0, 100.0, vec![Node::new(NodeId(0), NodeKind::Text(Box::new(t)))]);
let k = kinds(&emf_records(&emf(&d).bytes));
assert!(k.contains(&62), "glyphs are filled paths");
assert!(!k.contains(&84) && !k.contains(&83), "no text records");
}
#[test]
fn wmf_placeable_header_checksum_and_record_sizes() {
let d = doc_with(72.0, 144.0, vec![path(shapes::ellipse(Rect::new(10.0, 10.0, 60.0, 40.0)), red(), blue(), 1.0)]);
let out = wmf(&d);
let b = &out.bytes;
assert_eq!(sniff(b), Some(Kind::Wmf));
assert_eq!(u32_at(b, 0), 0x9AC6_CDD7);
// The box in logical units at 1440 an inch: 1 × 2 inches.
assert_eq!([u16_at(b, 6), u16_at(b, 8), u16_at(b, 10), u16_at(b, 12)], [0, 0, 1440, 2880]);
assert_eq!(u16_at(b, 14), 1440);
let xor = (0..10).fold(0u16, |a, i| a ^ u16_at(b, 2 * i));
assert_eq!(u16_at(b, 20), xor, "the checksum");
assert_eq!(crate::wmf::checksum(&b[..20]), xor);
// The WMF header: a memory metafile of 9 words, its size in words, the largest record.
assert_eq!([u16_at(b, 22), u16_at(b, 24), u16_at(b, 26)], [1, 9, 0x0300]);
assert_eq!(u32_at(b, 28) as usize * 2, b.len() - 22);
let recs = wmf_records(b);
let largest = recs.iter().map(|r| r.1.len() / 2).max().unwrap();
assert_eq!(u32_at(b, 34) as usize, largest, "MaxRecord");
assert_eq!(recs.last().map(|r| (r.0, r.1.len())), Some((0, 6)), "META_EOF ends the file");
// Objects: the null pen and brush, the brush, the pen.
assert_eq!(u16_at(b, 32), 4);
// The curve is flattened into a polygon, its outline a polygon drawn with the null brush.
let poly = recs.iter().find(|r| r.0 == 0x0538).unwrap().1;
assert_eq!(u16_at(poly, 6), 1);
assert!(u16_at(poly, 8) > 16, "many points");
assert!(recs.iter().any(|r| r.0 == 0x0324));
assert!(out.warnings.is_empty(), "{:?}", out.warnings);
}
#[test]
fn wmf_sizes_fit_sixteen_bits() {
assert_eq!(crate::wmf::units_per_inch(612.0, 792.0), 1440);
let inch = crate::wmf::units_per_inch(3000.0, 1000.0);
assert!(inch < 1440 && f64::from(inch) * 3000.0 / 72.0 <= 32767.0);
let d = doc_with(3000.0, 1000.0, vec![path(shapes::rectangle(Rect::new(0.0, 0.0, 3000.0, 1000.0)), red(), Paint::None, 0.0)]);
let b = wmf(&d).bytes;
assert_eq!(u16_at(&b, 14), inch);
assert!(u16_at(&b, 10) <= 32767);
}
#[test]
fn wmf_reports_what_it_leaves_out() {
let g = Paint::Gradient(Box::new(GradientPaint::new(Gradient::default())));
let mut clip = path(shapes::rectangle(Rect::new(0.0, 0.0, 20.0, 20.0)), Paint::None, Paint::None, 0.0);
if let NodeKind::Path { clipping, .. } = &mut clip.kind {
*clipping = true;
}
let inside = path(shapes::rectangle(Rect::new(5.0, 5.0, 40.0, 40.0)), g, Paint::None, 0.0);
let group = Node::new(NodeId(0), NodeKind::Group { children: vec![Arc::new(clip), Arc::new(inside)], clip: true });
let mut d = doc_with(100.0, 100.0, vec![group]);
let img = image_node(&mut d, png(2, 2, [0, 0, 0, 10]), 2, 2, Affine::translate((50.0, 50.0)) * Affine::rotate(0.5));
add(&mut d, img);
let w = wmf(&d).warnings.join("\n");
for what in ["clipping masks", "gradients", "transparency", "rotate"] {
assert!(w.contains(what), "{what}: {w}");
}
// EMF keeps all of it.
let e = emf(&d).warnings;
assert!(e.is_empty(), "{e:?}");
}
#[test]
fn hidden_and_offboard_art_is_left_out() {
let mut hidden = path(shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), red(), Paint::None, 0.0);
hidden.visible = false;
let away = path(shapes::rectangle(Rect::new(500.0, 500.0, 510.0, 510.0)), red(), Paint::None, 0.0);
let d = doc_with(100.0, 100.0, vec![hidden, away]);
let k = kinds(&emf_records(&emf(&d).bytes));
assert!(!k.contains(&62) && !k.contains(&39));
assert!(export(&d, Rect::new(0.0, 0.0, 0.0, 10.0), Kind::Emf).is_err(), "an empty region");
}

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

441
crates/metafile/src/wmf.rs Normal file
View File

@ -0,0 +1,441 @@
//! The WMF writer: a placeable header (the picture's size and logical units an inch, with its
//! checksum), the WMF header, then the drawing in 16-bit logical units with an anisotropic window
//! the size of the picture, and the end-of-file record.
//!
//! Curves are flattened into polygons (fills) and polylines (strokes, with pens of the stroke's
//! width, caps and joins; dashes as separate lines). Objects take the lowest free slot of the
//! object table, as players number them: slots 0 and 1 hold a null pen and a null brush, our pen
//! and brush come after and are deleted when replaced. Images are StretchDIB records of 24-bit
//! bitmaps composited over white.
use kurbo::PathEl;
use vectorcraft_doc::{LineCap, LineJoin};
use vectorcraft_geom::{Affine, BezPath, FillRule, Point, Rect};
use crate::bytes::Out;
use crate::dib::{self, Rgba};
use crate::emf::{PS_ENDCAP_FLAT, PS_ENDCAP_SQUARE, PS_JOIN_BEVEL, PS_JOIN_MITER, SRCCOPY, colorref};
use crate::scene::{Op, Pen};
/// The key a placeable header starts with.
pub(crate) const PLACEABLE_KEY: u32 = 0x9AC6_CDD7;
// Record functions (MS-WMF 2.1.1.1).
pub(crate) const EOF: u16 = 0x0000;
pub(crate) const SETBKMODE: u16 = 0x0102;
pub(crate) const SETMAPMODE: u16 = 0x0103;
pub(crate) const SETPOLYFILLMODE: u16 = 0x0106;
pub(crate) const SETWINDOWORG: u16 = 0x020B;
pub(crate) const SETWINDOWEXT: u16 = 0x020C;
pub(crate) const SELECTOBJECT: u16 = 0x012D;
pub(crate) const DELETEOBJECT: u16 = 0x01F0;
pub(crate) const CREATEPENINDIRECT: u16 = 0x02FA;
pub(crate) const CREATEBRUSHINDIRECT: u16 = 0x02FC;
pub(crate) const POLYGON: u16 = 0x0324;
pub(crate) const POLYLINE: u16 = 0x0325;
pub(crate) const POLYPOLYGON: u16 = 0x0538;
pub(crate) const STRETCHDIB: u16 = 0x0F43;
/// Most logical units an inch (twips, what office apps use).
const MAX_INCH: f64 = 1440.0;
/// The largest 16-bit coordinate.
const MAX_I16: f64 = 32767.0;
/// Most points a polygon or polyline record holds (its count is 16-bit).
const MAX_POINTS: usize = 32767;
/// Curves are flattened to within this many logical units.
const FLATNESS: f64 = 0.25;
/// The null pen's and the null brush's slots.
const NULL_PEN: u16 = 0;
const NULL_BRUSH: u16 = 1;
const TRANSLUCENT_IMAGES: &str = "WMF has no transparency: images are written over white";
const TURNED_IMAGES: &str = "WMF can't rotate or skew images: they are written upright, filling their bounds";
/// Does `bytes` start like a WMF (placeable, or a plain memory or disk metafile header)?
pub(crate) fn is_wmf(bytes: &[u8]) -> bool {
let u16_at = |at: usize| bytes.get(at..at + 2).and_then(|b| b.try_into().ok()).map(u16::from_le_bytes);
let placeable = bytes.get(..4).is_some_and(|b| b == PLACEABLE_KEY.to_le_bytes());
placeable || (matches!(u16_at(0), Some(1 | 2)) && u16_at(2) == Some(9) && matches!(u16_at(4), Some(0x0100 | 0x0300)))
}
/// The logical units an inch for a picture `w` × `h` points: the most, up to 1440, that keep its
/// size within 16 bits.
pub(crate) fn units_per_inch(w: f64, h: f64) -> u16 {
let side_in = w.max(h) / 72.0;
let fit = if side_in > 0.0 { (MAX_I16 / side_in).floor() } else { MAX_INCH };
fit.clamp(1.0, MAX_INCH) as u16
}
/// The XOR of the first ten 16-bit words of a placeable header.
pub(crate) fn checksum(header: &[u8]) -> u16 {
header.chunks_exact(2).take(10).fold(0, |acc, w| acc ^ u16::from_le_bytes([w[0], w[1]]))
}
/// `bp` flattened to within `tol`: each subpath's points and whether it closes.
fn flatten(bp: &BezPath, tol: f64) -> Vec<(Vec<Point>, bool)> {
let mut out: Vec<(Vec<Point>, bool)> = vec![];
let mut cur: Vec<Point> = vec![];
kurbo::flatten(bp.iter(), tol, |el| match el {
PathEl::MoveTo(p) => {
if cur.len() > 1 {
out.push((std::mem::take(&mut cur), false));
}
cur = vec![p];
}
PathEl::LineTo(p) => cur.push(p),
PathEl::ClosePath => {
if cur.len() > 1 {
out.push((std::mem::take(&mut cur), true));
}
cur.clear();
}
_ => {}
});
if cur.len() > 1 {
out.push((cur, false));
}
out
}
/// What a pen object is made of.
#[derive(Clone, Copy, PartialEq)]
struct PenKey {
style: u16,
width: i16,
color: u32,
}
struct Writer {
/// The records after the headers.
out: Out,
max_record: u32,
to_dev: Affine,
/// Logical units per point.
k: f64,
/// Which object slots are taken.
slots: Vec<bool>,
brush: Option<(u16, u32)>,
pen: Option<(u16, PenKey)>,
sel_pen: u16,
sel_brush: u16,
fill_mode: Option<u16>,
}
/// Write `ops` (curves flattened, no clips) as a placeable WMF of `region` (document space).
pub(crate) fn write(ops: &[Op], region: Rect, warnings: &mut Vec<String>) -> Vec<u8> {
let inch = units_per_inch(region.width(), region.height());
let k = f64::from(inch) / 72.0;
let size = |v: f64| (v * k).round().clamp(1.0, MAX_I16) as i16;
let (w, h) = (size(region.width()), size(region.height()));
let mut wr = Writer {
out: Out::default(),
max_record: 0,
to_dev: Affine::scale(k) * Affine::translate(-region.origin().to_vec2()),
k,
slots: vec![],
brush: None,
pen: None,
sel_pen: NULL_PEN,
sel_brush: NULL_BRUSH,
fill_mode: None,
};
// MM_ANISOTROPIC, a window the size of the picture, transparent background.
wr.record(SETMAPMODE, |o| o.u16(8));
wr.record(SETWINDOWORG, |o| {
o.i16(0);
o.i16(0);
});
wr.record(SETWINDOWEXT, |o| {
o.i16(h);
o.i16(w);
});
wr.record(SETBKMODE, |o| o.u16(1));
// The null pen and brush (slots 0 and 1), selected while ours change.
wr.alloc();
wr.record(CREATEPENINDIRECT, |o| {
o.u16(5);
o.i16(0);
o.i16(0);
o.u32(0);
});
wr.alloc();
wr.record(CREATEBRUSHINDIRECT, |o| {
o.u16(1);
o.u32(0);
o.u16(0);
});
wr.select(NULL_PEN);
wr.select(NULL_BRUSH);
let mut translucent = false;
let mut turned = false;
for op in ops {
match op {
Op::Fill { path, rule, rgb } => wr.fill(path, *rule, *rgb),
Op::Stroke { path, pen } => wr.stroke(path, pen),
Op::Image { image, xf, opacity } => {
translucent |= !image.opaque() || *opacity < 0.998;
turned |= !wr.image(image, *xf, *opacity);
}
// The scene gives formats without clipping none.
Op::Clip { .. } | Op::Unclip => {}
}
}
if let Some((slot, _)) = wr.brush.take() {
wr.select(NULL_BRUSH);
wr.delete(slot);
}
if let Some((slot, _)) = wr.pen.take() {
wr.select(NULL_PEN);
wr.delete(slot);
}
wr.record(EOF, |_| {});
for (on, w) in [(translucent, TRANSLUCENT_IMAGES), (turned, TURNED_IMAGES)] {
if on && !warnings.iter().any(|x| x == w) {
warnings.push(w.to_string());
}
}
wr.finish(w, h, inch)
}
impl Writer {
/// Append one record: its size in 16-bit words, its function and `body`.
fn record(&mut self, function: u16, body: impl FnOnce(&mut Out)) {
let start = self.out.len();
self.out.u32(0);
self.out.u16(function);
body(&mut self.out);
self.out.pad(2);
let words = u32::try_from((self.out.len() - start) / 2).unwrap_or(u32::MAX);
self.out.set_u32(start, words);
self.max_record = self.max_record.max(words);
}
/// The placeable header, the WMF header, then the records.
fn finish(self, w: i16, h: i16, inch: u16) -> Vec<u8> {
let mut o = Out::default();
o.u32(PLACEABLE_KEY);
o.u16(0);
for v in [0, 0, w, h] {
o.i16(v);
}
o.u16(inch);
o.u32(0);
let sum = checksum(&o.0);
o.u16(sum);
// A memory metafile, 9 words of header, version 3.
o.u16(1);
o.u16(9);
o.u16(0x0300);
o.u32(u32::try_from((18 + self.out.len()) / 2).unwrap_or(u32::MAX));
o.u16(u16::try_from(self.slots.len()).unwrap_or(u16::MAX));
o.u32(self.max_record);
o.u16(0);
o.bytes(&self.out.0);
o.0
}
/// The lowest free object slot, taken.
fn alloc(&mut self) -> u16 {
let i = match self.slots.iter().position(|t| !t) {
Some(i) => i,
None => {
self.slots.push(false);
self.slots.len() - 1
}
};
if let Some(s) = self.slots.get_mut(i) {
*s = true;
}
u16::try_from(i).unwrap_or(u16::MAX)
}
fn select(&mut self, slot: u16) {
self.record(SELECTOBJECT, |o| o.u16(slot));
}
fn delete(&mut self, slot: u16) {
self.record(DELETEOBJECT, |o| o.u16(slot));
if let Some(s) = self.slots.get_mut(usize::from(slot)) {
*s = false;
}
}
fn dev(&self, p: Point) -> [i16; 2] {
let q = self.to_dev * p;
let c = |v: f64| if v.is_finite() { v.round().clamp(-MAX_I16, MAX_I16) as i16 } else { 0 };
[c(q.x), c(q.y)]
}
/// The device points of `pts`, repeats dropped, thinned to what one record holds.
fn points(&self, pts: &[Point]) -> Vec<[i16; 2]> {
let mut v: Vec<[i16; 2]> = vec![];
for p in pts {
let d = self.dev(*p);
if v.last() != Some(&d) {
v.push(d);
}
}
if v.len() > MAX_POINTS {
let step = v.len().div_ceil(MAX_POINTS - 1);
let last = v.last().copied();
v = v.into_iter().step_by(step).collect();
v.extend(last);
}
v
}
fn select_pen(&mut self, slot: u16) {
if self.sel_pen != slot {
self.select(slot);
self.sel_pen = slot;
}
}
fn select_brush(&mut self, slot: u16) {
if self.sel_brush != slot {
self.select(slot);
self.sel_brush = slot;
}
}
fn use_brush(&mut self, rgb: [u8; 3]) {
let color = colorref(rgb);
let slot = match self.brush {
Some((slot, c)) if c == color => slot,
old => {
if let Some((slot, _)) = old {
self.select_brush(NULL_BRUSH);
self.delete(slot);
}
let slot = self.alloc();
self.record(CREATEBRUSHINDIRECT, |o| {
o.u16(0);
o.u32(color);
o.u16(0);
});
self.brush = Some((slot, color));
slot
}
};
self.select_brush(slot);
}
fn use_pen(&mut self, p: &Pen) {
let cap = match p.cap {
LineCap::Butt => PS_ENDCAP_FLAT,
LineCap::Round => 0,
LineCap::Square => PS_ENDCAP_SQUARE,
};
let join = match p.join {
LineJoin::Miter => PS_JOIN_MITER,
LineJoin::Round => 0,
LineJoin::Bevel => PS_JOIN_BEVEL,
};
let key = PenKey { style: (cap | join) as u16, width: (p.width * self.k).round().clamp(1.0, MAX_I16) as i16, color: colorref(p.rgb) };
let slot = match self.pen {
Some((slot, k)) if k == key => slot,
old => {
if let Some((slot, _)) = old {
self.select_pen(NULL_PEN);
self.delete(slot);
}
let slot = self.alloc();
self.record(CREATEPENINDIRECT, |o| {
o.u16(key.style);
o.i16(key.width);
o.i16(0);
o.u32(key.color);
});
self.pen = Some((slot, key));
slot
}
};
self.select_pen(slot);
}
fn fill(&mut self, bp: &BezPath, rule: FillRule, rgb: [u8; 3]) {
let polys: Vec<Vec<[i16; 2]>> = flatten(bp, FLATNESS / self.k).iter().map(|(p, _)| self.points(p)).filter(|p| p.len() > 2).collect();
if polys.is_empty() {
return;
}
// ALTERNATE (even-odd) or WINDING (non-zero).
let mode = if rule == FillRule::EvenOdd { 1 } else { 2 };
if self.fill_mode != Some(mode) {
self.record(SETPOLYFILLMODE, |o| o.u16(mode));
self.fill_mode = Some(mode);
}
self.use_brush(rgb);
self.select_pen(NULL_PEN);
for chunk in polys.chunks(usize::from(u16::MAX)) {
self.record(POLYPOLYGON, |o| {
o.u16(chunk.len() as u16);
chunk.iter().for_each(|p| o.u16(p.len() as u16));
for p in chunk.iter().flatten() {
o.i16(p[0]);
o.i16(p[1]);
}
});
}
}
fn stroke(&mut self, bp: &BezPath, pen: &Pen) {
let path = crate::scene::dashed(bp, pen.dash.as_ref());
let lines: Vec<(Vec<[i16; 2]>, bool)> =
flatten(&path, FLATNESS / self.k).iter().map(|(p, closed)| (self.points(p), *closed)).filter(|(p, _)| p.len() > 1).collect();
if lines.is_empty() {
return;
}
self.use_pen(pen);
self.select_brush(NULL_BRUSH);
for (pts, closed) in lines {
// A closed subpath is a polygon (with the null brush, its outline), so it joins at
// its start.
self.record(if closed { POLYGON } else { POLYLINE }, |o| {
o.i16(pts.len() as i16);
for p in &pts {
o.i16(p[0]);
o.i16(p[1]);
}
});
}
}
/// An image placed by `xf`, upright: false when `xf` rotates or skews it (then it fills its
/// bounds).
fn image(&mut self, img: &Rgba, xf: Affine, opacity: f32) -> bool {
if img.width == 0 || img.height == 0 || opacity <= 0.0 {
return true;
}
let m = self.to_dev * xf;
if !m.as_coeffs().iter().all(|v| v.is_finite()) {
return true;
}
let [a, b, c, d, e, f] = m.as_coeffs();
let (w, h) = (f64::from(img.width), f64::from(img.height));
let upright = b.abs() < 1e-9 && c.abs() < 1e-9;
let (x, y, dw, dh) = if upright {
(e, f, a * w, d * h)
} else {
let r = m.transform_rect_bbox(Rect::new(0.0, 0.0, w, h));
(r.x0, r.y0, r.width(), r.height())
};
let s = |v: f64| v.round().clamp(-MAX_I16, MAX_I16) as i16;
let (bmi, bits) = dib::rgb24(img, opacity);
self.record(STRETCHDIB, |o| {
o.u32(SRCCOPY);
// DIB_RGB_COLORS; the source and destination rectangles, last field first.
o.u16(0);
o.i16(img.height as i16);
o.i16(img.width as i16);
o.i16(0);
o.i16(0);
o.i16(s(dh));
o.i16(s(dw));
o.i16(s(y));
o.i16(s(x));
o.bytes(&bmi);
o.bytes(&bits);
});
upright
}
}

View File

@ -344,3 +344,6 @@ mod tests_screens;
#[cfg(test)] #[cfg(test)]
mod tests_eps; mod tests_eps;
#[cfg(test)]
mod tests_metafile;

View File

@ -0,0 +1,37 @@
//! Export As → EMF and WMF, drawn headlessly: listed with the other formats, written straight
//! away (no options dialog), one file per artboard with Use Artboards.
use serde_json::json;
use super::tests_export::{app, frame, kind, names, set};
use super::*;
/// Does `b` carry the EMF header's signature, or start with the placeable WMF key?
fn is_emf(b: &[u8]) -> bool {
b.get(40..44) == Some(&b" EMF"[..])
}
fn is_wmf(b: &[u8]) -> bool {
b.get(..4) == Some(&[0xD7, 0xCD, 0xC6, 0x9A][..])
}
#[test]
fn export_as_lists_emf_and_wmf_and_writes_them() {
let (mut app, written) = app(2);
app.run("file.exportAs", json!({"format": "emf"})).unwrap();
assert_eq!(kind(&app), Some("exportAs"));
assert_eq!(app.ui.dialog.as_ref().unwrap().str("format"), "emf");
frame(&mut app);
let r = confirm(&mut app).unwrap();
assert!(app.ui.dialog.is_none(), "no options dialog follows");
assert_eq!(r["path"], "/out/Untitled-1.emf");
assert!(is_emf(&written.borrow()[0].1));
// WMF, one file per artboard.
app.run("file.exportAs", json!({"format": "wmf"})).unwrap();
set(&mut app, "useArtboards", json!(true));
frame(&mut app);
confirm(&mut app).unwrap();
let n = names(&written);
assert_eq!(&n[1..], ["/out/Untitled-1-Artboard-1.wmf", "/out/Untitled-1-Artboard-2.wmf"]);
assert!(written.borrow()[1..].iter().all(|(_, b)| is_wmf(b)));
}

View File

@ -69,6 +69,8 @@ mod tests_svgsave;
#[cfg(test)] #[cfg(test)]
mod tests_sysclip; mod tests_sysclip;
#[cfg(test)] #[cfg(test)]
mod tests_sysclip_emf;
#[cfg(test)]
mod tests_transparencygrid; mod tests_transparencygrid;
use std::sync::mpsc::{Receiver, Sender}; 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 //! The system clipboard's formats: Copy and Cut publish every flavour the engine makes
//! ([`Session::clipboard_flavours`](vectorcraft_engine::Session::clipboard_flavours): text, SVG, //! ([`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 //! PDF, PNG), and a Paste first turns what another app copied (SVG, PDF, an EMF, text, a bitmap)
//! internal clipboard with the `clipboard.import*` commands. The host installs the platform side //! into the internal clipboard with the `clipboard.import*` commands. The host installs the
//! as [`Services::system_clipboard`](crate::Services::system_clipboard); without it (the web) Copy //! platform side as [`Services::system_clipboard`](crate::Services::system_clipboard); without it
//! publishes SVG text through egui and Paste takes SVG text only. //! (the web) Copy publishes SVG text through egui and Paste takes SVG text only.
use serde_json::{Value, json}; 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; 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 })), SVG => ("clipboard.importSvg", json!({ "dataBase64": b64(), "center": center })),
PDF => ("clipboard.importPdf", 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 })), 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. /// The fake clipboard's contents: what the app wrote, or what "another app" copied.
#[derive(Default)] #[derive(Default)]
struct Board { pub(super) struct Board {
flavours: Vec<Flavour>, flavours: Vec<Flavour>,
ours: bool, 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). /// 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 board = Rc::new(RefCell::new(Board::default()));
let services = Services { system_clipboard: Some(Box::new(Fake(board.clone()))), ..Default::default() }; let services = Services { system_clipboard: Some(Box::new(Fake(board.clone()))), ..Default::default() };
let mut app = VectorcraftApp::new(Session::new(), services); let mut app = VectorcraftApp::new(Session::new(), services);
@ -57,12 +57,12 @@ fn app() -> (VectorcraftApp, Rc<RefCell<Board>>) {
} }
/// Another app copies `flavours`. /// 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(); let flavours = flavours.into_iter().map(|(mime, data)| Flavour { mime, data }).collect();
*board.borrow_mut() = Board { flavours, ours: false }; *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}")) 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. /// 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(); let st = app.session.active().unwrap();
st.selection.objects.iter().map(|id| st.doc.node(*id).unwrap().clone()).collect() 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. /// 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 img = image::RgbaImage::from_pixel(4, 2, image::Rgba([0, 0, 255, 255]));
let mut png = vec![]; let mut png = vec![];
img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); 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

@ -361,3 +361,7 @@ Native save options: File → Save As to a native or `.ai` file opens the `saveO
artboard; false: `range`, such as "1-3, 5"), `includeLinked`, `embedProfiles`, `pdfCompatible`, `compress`, and for artboard; false: `range`, such as "1-3, 5"), `includeLinked`, `embedProfiles`, `pdfCompatible`, `compress`, and for
native files `version` and `preview`. A bad range keeps the dialog open; `ui.dialog.confirm` writes the file and the native files `version` and `preview`. A bad range keeps the dialog open; `ui.dialog.confirm` writes the file and the
artboards' files. artboards' files.
EMF and WMF: Export As… lists both formats (`file.exportAs {format: "emf"}` or `"wmf"`); they have no options
dialog, so `ui.dialog.confirm` asks for the path and writes the file (one per artboard with Use Artboards). Open and
Place read `.emf` and `.wmf`; a paste of `image/emf` from a system clipboard service runs `clipboard.importEmf`.

View File

@ -1338,3 +1338,28 @@ and Save as Template reuse the options as last saved (or Use Compression).
{"name":"run_command","arguments":{"command":"file.saveAs","params":{"path":"/tmp/set.vectorcraft","separateArtboards":true,"range":"2-3","includeLinked":true}}} {"name":"run_command","arguments":{"command":"file.saveAs","params":{"path":"/tmp/set.vectorcraft","separateArtboards":true,"range":"2-3","includeLinked":true}}}
{"name":"run_command","arguments":{"command":"file.saveAs","params":{"path":"/tmp/set.ai","pdfCompatible":false}}} {"name":"run_command","arguments":{"command":"file.saveAs","params":{"path":"/tmp/set.ai","pdfCompatible":false}}}
``` ```
## EMF and WMF
`document.export` (and `export`) with format `emf` or `wmf` writes a Windows metafile of the first artboard (or
`artboard`); `useArtboards: true` writes one file per chosen artboard (`{stem}-{artboard}.emf`), `false` the bounds of
the visible art. EMF keeps Bézier curves, solid fills (brushes), strokes as geometric pens with their width, caps,
joins, miter limit and dashes, clipping masks (path clips), images with their transparency (AlphaBlend) and type as
outlines; gradients become images clipped to their shape, pattern fills their tiles clipped to the shape, and raster
effects images rendered at the document's raster effects resolution. The header's frame is the artboard in 0.01 mm.
WMF flattens curves into polygons in 16-bit units behind a placeable header (1440 units an inch, fewer for pictures
over about 22 inches); it has no clipping, transparency or gradients, so clipped art is written whole, images over
white and gradients and patterns as one colour. What a format leaves out comes back in `warnings`.
`document.open` and `file.place` read `.emf` and `.wmf` (placeable or not) into one artboard, the picture's frame, and
one layer: paths with their fills and strokes (pens become strokes with caps, joins and dashes), clipping groups,
images and point type in the font the file names. Records VectorCraft doesn't read (EMF+ drawing among them) are
skipped with one warning. `clipboard.importEmf {dataBase64, center?}` loads an EMF or WMF picture into the clipboard
for `edit.paste`; the app pastes `image/emf` from the system clipboard ahead of text and bitmaps when the platform's
clipboard service offers it (the Windows desktop service doesn't read metafiles yet).
```json
{"name":"export","arguments":{"path":"/tmp/logo.emf"}}
{"name":"run_command","arguments":{"command":"document.export","params":{"format":"wmf","useArtboards":true,"path":"/tmp/icons.wmf"}}}
{"name":"run_command","arguments":{"command":"file.place","params":{"path":"/tmp/chart.emf","at":[300,200]}}}
```

View File

@ -58,6 +58,7 @@ pub const TABLE: &[(&str, Class)] = &[
// Crates added later (append-only). // Crates added later (append-only).
("cad", Class::Layer(3)), ("cad", Class::Layer(3)),
("eps", Class::Layer(3)), ("eps", Class::Layer(3)),
("metafile", Class::Layer(3)),
]; ];
/// Explicit orderings *within* a layer (earlier may be used by later). /// Explicit orderings *within* a layer (earlier may be used by later).