M4.94: EMF and WMF export from Export As and document.export

New crates/metafile (L3, pure Rust): hand-written EMF and WMF writers over
one shared document walk. EMF keeps Bezier paths, solid brushes, geometric
pens (width, caps, joins, miter limit, user-style dashes), clipping masks as
path clips, images (StretchDIBits, or AlphaBlend with transparency) placed by
a world transform, type as outlines; gradients become images clipped to their
shape and pattern fills their tiles clipped to it. The header's frame is the
artboard in 0.01 mm on a 100 px/mm reference device. WMF flattens curves into
polygons in 16-bit units behind a placeable header (with its checksum),
numbers objects as players do, and reports what it leaves out (clipping,
transparency, gradients, rotated images).

document.export {format: emf|wmf} (and serialize, export) writes one artboard,
or one file per artboard with useArtboards (false: the art's bounds); raster
effects are rendered to images first, as for PDF. Export As lists EMF and WMF.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
bflatasticandClaude Opus 5.5 committed 2026-10-05 15:24:24 +07:00
1 parent 2e6585d7ea
commit ab1c6b0516
23 files changed
+2234 -6

No files matched your search

Generated
+15
View File
@@ -4284,6 +4284,7 @@ dependencies = [
"vectorcraft-eps",
"vectorcraft-format",
"vectorcraft-geom",
"vectorcraft-metafile",
"vectorcraft-pathops",
"vectorcraft-pdf",
"vectorcraft-render",
@@ -4355,6 +4356,20 @@ dependencies = [
"vectorcraft-tools",
]
[[package]]
name = "vectorcraft-metafile"
version = "0.2.0"
dependencies = [
"image",
"kurbo 0.13.1",
"vectorcraft-brush",
"vectorcraft-color",
"vectorcraft-doc",
"vectorcraft-effects",
"vectorcraft-geom",
"vectorcraft-text",
]
[[package]]
name = "vectorcraft-pathops"
version = "0.2.0"
+1
View File
@@ -31,6 +31,7 @@ vectorcraft-mcp = { path = "crates/mcp" }
vectorcraft-testkit = { path = "crates/testkit" }
vectorcraft-cad = { path = "crates/cad" }
vectorcraft-eps = { path = "crates/eps" }
vectorcraft-metafile = { path = "crates/metafile" }
# foundation
kurbo = { version = "0.13", features = ["serde"] }
+1
View File
@@ -23,6 +23,7 @@ vectorcraft-format = { workspace = true }
vectorcraft-pdf = { workspace = true }
vectorcraft-cad = { workspace = true }
vectorcraft-eps = { workspace = true }
vectorcraft-metafile = { workspace = true }
image = { workspace = true }
flate2 = { workspace = true }
kurbo = { workspace = true }
+3 -1
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 n = doc.artboards.len();
// SVG reads its own `useArtboards` (an SVG option).
let use_artboards = if f.raster || matches!(f.id, "pdf" | "dxf") { use_artboards(p)? } else { None };
let use_artboards = if f.raster || matches!(f.id, "pdf" | "dxf" | "emf" | "wmf") { use_artboards(p)? } else { None };
if use_artboards == Some(false) {
let bounds = vectorcraft_render::encode::art_bounds(doc).ok_or_else(|| bad(C, "nothing to export: the document has no visible art"))?;
let mut q = super::export::without_artboards(p);
@@ -325,6 +325,8 @@ pub fn encode_all(doc: &Document, format: &str, p: &Value) -> Result<Encoded> {
"dxf" => return super::dxf::encode(doc, p, use_artboards),
// EPS reads its own `useArtboards` (the art's bounds unless asked).
"eps" => return super::eps::encode(doc, p),
"emf" => return super::metafile::encode(doc, p, use_artboards, vectorcraft_metafile::Kind::Emf),
"wmf" => return super::metafile::encode(doc, p, use_artboards, vectorcraft_metafile::Kind::Wmf),
"pdf" => {
let (bytes, warnings) = super::pdf::encode(C, doc, p)?;
return Ok(Encoded { warnings, ..Encoded::one(bytes) });
+45
View File
@@ -0,0 +1,45 @@
//! EMF and WMF export (`vectorcraft_metafile`): `document.export {format: emf|wmf}` and Export As
//! (one picture of an artboard, or one per artboard with Use Artboards; raster effects as images,
//! rendered at the document's raster effects resolution).
use serde_json::Value;
use vectorcraft_doc::Document;
use vectorcraft_metafile::Kind;
use super::super::*;
use super::{ArtboardPick, Encoded, FormatOption};
const C: &str = "document.export";
/// The options `document.formats` lists for EMF and WMF.
pub(super) const OPTIONS: &[FormatOption] = &[super::ARTBOARD, super::ARTBOARDS, super::RANGE, super::USE_ARTBOARDS];
/// Encode `doc` as `kind`: the chosen artboard (default the first), or with `use_artboards` one
/// picture per chosen artboard (default all).
pub(super) fn encode(doc: &Document, p: &Value, use_artboards: Option<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)
}
+8 -2
View File
@@ -14,6 +14,7 @@
//! - `dxf`: DXF export options, `document.exportDxf`, and the formats that can't be written (DWG, PICT).
//! - `eps`: EPS export options (flattened transparency, previews, the embedded document), `document.exportEps`.
//! - `dxfimport`: the DXF options `document.open` and Place read, `document.dxfInfo`.
//! - `metafile`: EMF and WMF export.
//!
//! [`FORMATS`] is the single list of formats (append-only); open dialogs use [`open_filters`],
//! agents query `document.formats`.
@@ -26,6 +27,7 @@ pub mod eps;
mod export;
mod imagemap;
mod load;
mod metafile;
mod native;
pub mod pdf;
mod pdfimport;
@@ -81,7 +83,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Serialize Document",
[],
None,
"{format?: vectorcraft (default)|template|svg|svgz|pdf|png|jpg|webp|gif|png8|txt|dxf|eps, …the format's options (see document.formats; SVG ones also as svg: {…}), selectedOnly?: false (the selected objects alone, in their layers)} → {text, warnings} for svg (plus dataBase64, the file, when its encoding isn't UTF-8), else {dataBase64, warnings}; an SVG of several artboards also gives files: [{name, text}], linked images linked: [{name, dataBase64}]",
"{format?: vectorcraft (default)|template|svg|svgz|pdf|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf, …the format's options (see document.formats; SVG ones also as svg: {…}), selectedOnly?: false (the selected objects alone, in their layers)} → {text, warnings} for svg (plus dataBase64, the file, when its encoding isn't UTF-8), else {dataBase64, warnings}; an SVG of several artboards also gives files: [{name, text}], linked images linked: [{name, dataBase64}]",
has_doc,
export::serialize
),
@@ -90,7 +92,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Export Document",
[],
None,
"{path?, format?: svg|svgz|pdf|png|jpg|webp|gif|png8|txt|dxf|eps|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png), selectedOnly?: false (the selected objects alone, in their layers), artboard?: 0, artboards?: [i…], range?: \"1-3, 5\" | \"all\" (1-based; PDF writes one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf: every page) | false (pdf/raster: the bounds of the visible art; SVG has it as an SVG option), raster: ppi?: 72 (pixels per inch, stored in the file; wins over scale), scale?: 1 (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), jpg: quality?: 90 (0–100), colorModel?: rgb|cmyk|gray, method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as <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,
export::export
),
@@ -446,6 +448,8 @@ pub const FORMATS: &[Format] = &[
raster: false,
options: eps::OPTIONS,
},
Format { id: "emf", label: "EMF", extensions: &["emf"], mime: "image/emf", read: false, write: true, raster: false, options: metafile::OPTIONS },
Format { id: "wmf", label: "WMF", extensions: &["wmf"], mime: "image/wmf", read: false, write: true, raster: false, options: metafile::OPTIONS },
];
/// Every extension `document.open` reads (the "All readable files" filter of open dialogs).
@@ -754,3 +758,5 @@ mod tests_eps;
#[cfg(test)]
mod tests_dxfimport;
#[cfg(test)]
mod tests_metafile;
+1 -1
View File
@@ -160,7 +160,7 @@ impl Row {
return Err(bad(C, "each format is an object {format, scale?, suffix?}"));
}
let format = writable(C, Some(str_param(row, "format").unwrap_or("png")), None)?;
if matches!(format.id, "vectorcraft" | "template" | "txt" | "dxf" | "eps") {
if matches!(format.id, "vectorcraft" | "template" | "txt" | "dxf" | "eps" | "emf" | "wmf") {
return Err(bad(C, "Export for Screens writes png, png8, jpg, webp, gif, svg, svgz or pdf"));
}
// Vector formats have no pixel size: it doesn't apply and adds no @2x suffix (not even one
+4 -1
View File
@@ -154,7 +154,10 @@ fn formats_query_lists_readers_writers_and_options() {
let mut s = Session::new();
let r = s.execute("document.formats", &json!({})).unwrap();
let ids = |k: &str| r[k].as_array().unwrap().iter().map(|v| v.as_str().unwrap().to_string()).collect::<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()));
let png = r["formats"].as_array().unwrap().iter().find(|f| f["id"] == "png").unwrap();
assert_eq!(png["options"]["scale"]["default"], 1);
@@ -0,0 +1,109 @@
//! EMF and WMF export through the commands: one artboard or each, raster effects as images, and
//! the format lists.
use serde_json::{Value, json};
use super::*;
fn session(width: f64, height: f64, artboards: usize) -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": width, "height": height, "artboards": artboards})).unwrap();
s
}
fn b64(v: &Value) -> Vec<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 formats_list_emf_and_wmf() {
let mut s = Session::new();
let r = s.execute("document.formats", &json!({})).unwrap();
let ids: Vec<&str> = r["writable"].as_array().unwrap().iter().map(|v| v.as_str().unwrap()).collect();
assert!(ids.contains(&"emf") && ids.contains(&"wmf"), "{ids:?}");
let emf = r["formats"].as_array().unwrap().iter().find(|f| f["id"] == "emf").unwrap();
assert_eq!(emf["mime"], "image/emf");
assert!(emf["options"]["useArtboards"].is_object());
// Export for Screens writes the screen formats only.
let mut s = two_boards();
assert!(s.execute("document.exportForScreens", &json!({"formats": [{"format": "emf"}]})).is_err());
}
+1 -1
View File
@@ -57,7 +57,7 @@ fn export_tool_lists_engine_formats_and_options() {
let props = &export["inputSchema"]["properties"];
assert_eq!(
props["format"]["enum"],
json!(["vectorcraft", "svg", "svgz", "pdf", "png", "jpg", "gif", "webp", "template", "png8", "txt", "dxf", "eps"])
json!(["vectorcraft", "svg", "svgz", "pdf", "png", "jpg", "gif", "webp", "template", "png8", "txt", "dxf", "eps", "emf", "wmf"])
);
for k in ["artboard", "range", "options"] {
assert!(props.get(k).is_some(), "export takes {k}");
+19
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 }
+46
View File
@@ -0,0 +1,46 @@
//! Little-endian values: the buffer both writers build records in.
/// Bytes being written.
#[derive(Default)]
pub(crate) struct Out(pub Vec<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());
}
}
}
+137
View File
@@ -0,0 +1,137 @@
//! Device-independent bitmaps (DIBs), the pixels inside image records: written bottom-up as 24-bit
//! (opaque, composited over white) or 32-bit premultiplied BGRA (AlphaBlend's source).
/// Most pixels along a side of an image the writers store (larger ones are scaled down).
const MAX_SIDE: u32 = 8192;
/// Most pixels an image the writers store holds.
const MAX_PIXELS: u64 = 1 << 24;
const BI_RGB: u32 = 0;
/// Pixels with straight (not premultiplied) alpha, row by row from the top.
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct Rgba {
pub width: u32,
pub height: u32,
pub pixels: Vec<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)
}
}
/// 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)
}
#[cfg(test)]
mod tests {
use super::*;
fn sample() -> Rgba {
// 3 × 2: red, green, blue / half-transparent black, transparent, white.
let pixels = vec![255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255, 0, 0, 0, 128, 0, 0, 0, 0, 255, 255, 255, 255];
Rgba { width: 3, height: 2, pixels }
}
#[test]
fn rgb24_is_bottom_up_bgr_over_white() {
let (bmi, bits) = rgb24(&sample(), 1.0);
assert_eq!(bmi.len(), 40);
assert_eq!(&bmi[14..16], &[24, 0], "24 bits a pixel");
// Rows of 9 bytes padded to 12, the last row first: half-transparent black over white is
// mid grey, transparent is white.
assert_eq!(bits, vec![127, 127, 127, 255, 255, 255, 255, 255, 255, 0, 0, 0, 0, 0, 255, 0, 255, 0, 255, 0, 0, 0, 0, 0]);
}
#[test]
fn bgra32_is_premultiplied() {
let (bmi, bits) = bgra32(&sample(), 1.0);
assert_eq!(&bmi[14..16], &[32, 0]);
assert_eq!(&bits[..12], &[0, 0, 0, 128, 0, 0, 0, 0, 255, 255, 255, 255]);
assert_eq!(&bits[12..16], &[0, 0, 255, 255], "red, as BGRA");
// At half opacity.
let (_, half) = bgra32(&sample(), 0.5);
assert_eq!(&half[12..16], &[0, 0, 128, 128]);
}
#[test]
fn large_images_are_scaled_to_fit() {
let img = image::RgbaImage::new(MAX_SIDE * 2, 4);
let f = Rgba::fitted(img);
assert_eq!((f.width, f.height), (MAX_SIDE, 2));
}
}
+503
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);
});
}
}
+88
View File
@@ -0,0 +1,88 @@
//! Windows metafiles: hand-written EMF and WMF writers.
//!
//! [`export`] writes the visible art over one region of a document (an artboard) as a metafile:
//!
//! - **EMF**: paths keep their Bézier curves, fills are solid brushes, strokes geometric pens with
//! their width, caps, joins, miter limit and dashes; strokes a pen can't draw (aligned inside or
//! outside, width profiles, arrowheads) are their filled outlines and brush strokes their brush
//! art. Clipping masks clip, type is written as glyph outlines, images keep their pixels (with
//! their transparency), gradients become images clipped to what they paint and pattern fills
//! their tiles clipped to the shape. The header's frame is the region in 0.01 mm.
//! - **WMF**: the same art with curves flattened into polygons and 16-bit coordinates, behind a
//! placeable header that gives the size; WMF has no clipping paths, transparency, dashes on wide
//! lines or gradients, so clipped art is written whole, images over white, dashes as separate
//! lines and gradients and patterns as one colour (each loss comes back as a warning).
//!
//! - `bytes`: little-endian writing.
//! - `dib`: device-independent bitmaps.
//! - `scene`: the document walk both writers share.
//! - `emf`, `wmf`: the writers.
mod bytes;
mod dib;
mod emf;
mod scene;
mod wmf;
use vectorcraft_doc::Document;
use vectorcraft_geom::Rect;
/// A Windows metafile format.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Kind {
/// Enhanced Metafile (32-bit coordinates, Bézier curves, paths and clipping).
Emf,
/// Windows Metafile with a placeable header (16-bit coordinates, polygons).
Wmf,
}
impl Kind {
/// The format id (`emf`, `wmf`).
pub fn id(self) -> &'static str {
match self {
Self::Emf => "emf",
Self::Wmf => "wmf",
}
}
}
/// The metafile format `bytes` start like, if any.
pub fn sniff(bytes: &[u8]) -> Option<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 })
}
#[cfg(test)]
mod tests;
+421
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
}
}
+330
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");
}
+441
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
}
}
+3
View File
@@ -344,3 +344,6 @@ mod tests_screens;
#[cfg(test)]
mod tests_eps;
#[cfg(test)]
mod tests_metafile;
@@ -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)));
}
+3
View File
@@ -361,3 +361,6 @@ Native save options: File → Save As to a native or `.ai` file opens the `saveO
artboard; false: `range`, such as "1-3, 5"), `includeLinked`, `embedProfiles`, `pdfCompatible`, `compress`, and for
native files `version` and `preview`. A bad range keeps the dialog open; `ui.dialog.confirm` writes the file and the
artboards' files.
EMF and WMF: Export As… lists both formats (`file.exportAs {format: "emf"}` or `"wmf"`); they have no options
dialog, so `ui.dialog.confirm` asks for the path and writes the file (one per artboard with Use Artboards).
+17
View File
@@ -1338,3 +1338,20 @@ and Save as Template reuse the options as last saved (or Use Compression).
{"name":"run_command","arguments":{"command":"file.saveAs","params":{"path":"/tmp/set.vectorcraft","separateArtboards":true,"range":"2-3","includeLinked":true}}}
{"name":"run_command","arguments":{"command":"file.saveAs","params":{"path":"/tmp/set.ai","pdfCompatible":false}}}
```
## EMF and WMF
`document.export` (and `export`) with format `emf` or `wmf` writes a Windows metafile of the first artboard (or
`artboard`); `useArtboards: true` writes one file per chosen artboard (`{stem}-{artboard}.emf`), `false` the bounds of
the visible art. EMF keeps Bézier curves, solid fills (brushes), strokes as geometric pens with their width, caps,
joins, miter limit and dashes, clipping masks (path clips), images with their transparency (AlphaBlend) and type as
outlines; gradients become images clipped to their shape, pattern fills their tiles clipped to the shape, and raster
effects images rendered at the document's raster effects resolution. The header's frame is the artboard in 0.01 mm.
WMF flattens curves into polygons in 16-bit units behind a placeable header (1440 units an inch, fewer for pictures
over about 22 inches); it has no clipping, transparency or gradients, so clipped art is written whole, images over
white and gradients and patterns as one colour. What a format leaves out comes back in `warnings`.
```json
{"name":"export","arguments":{"path":"/tmp/logo.emf"}}
{"name":"run_command","arguments":{"command":"document.export","params":{"format":"wmf","useArtboards":true,"path":"/tmp/icons.wmf"}}}
```
+1
View File
@@ -58,6 +58,7 @@ pub const TABLE: &[(&str, Class)] = &[
// Crates added later (append-only).
("cad", Class::Layer(3)),
("eps", Class::Layer(3)),
("metafile", Class::Layer(3)),
];
/// Explicit orderings *within* a layer (earlier may be used by later).