mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-14 19:26:56 -04:00
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:
1 parent
2e6585d7ea
commit
ab1c6b0516
23 files changed
+2234
-6
No files matched your search
Generated
+15
@@ -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"
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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) });
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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}");
|
||||
|
||||
@@ -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 }
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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(®ion, FillRule::NonZero, &st.paint, st.opacity, st.paint_bounds(bounds));
|
||||
return;
|
||||
}
|
||||
let Some(rgb) = self.flat(&st.paint) else { return };
|
||||
self.check_opacity(st.opacity);
|
||||
let pen = Pen { rgb, width: st.width, cap: st.cap, join: st.join, miter: st.miter_limit, dash: st.dash.clone().filter(|d| d.is_dashed()) };
|
||||
self.ops.push(Op::Stroke { path: bp.clone(), pen });
|
||||
}
|
||||
|
||||
fn brush_art(&mut self, bp: &BezPath, st: &StrokeLayer) -> Option<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
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
@@ -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
@@ -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"}}}
|
||||
```
|
||||
@@ -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).
|
||||
|
||||
Reference in new issue
Block a user