Merge pull request #109 from storytold/pr/m4-emf-wmf
M4.94–M4.95: EMF and WMF
This commit is contained in:
commit
db7164a228
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ use vectorcraft_color::Swatch;
|
|||
use vectorcraft_doc::{Document, GraphicStyle, ImageBlob, Node, NodeId, PatternDef, Symbol, TextStyleDef};
|
||||
use vectorcraft_geom::Rect;
|
||||
|
||||
pub use flavours::{BITMAP, Flavour, PASTE_ORDER, PDF, PNG, SVG, TEXT};
|
||||
pub use flavours::{BITMAP, EMF, Flavour, PASTE_ORDER, PDF, PNG, SVG, TEXT};
|
||||
pub(crate) use resources::SwatchChoices;
|
||||
|
||||
use super::*;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
//! Clipboard flavours: what Copy offers other apps besides SVG (PNG, PDF and plain text) and what
|
||||
//! Paste takes from them (bitmaps, plain text and PDF).
|
||||
//! Paste takes from them (bitmaps, plain text, PDF and Windows metafiles).
|
||||
//!
|
||||
//! The UI owns the platform clipboard (`Services::system_clipboard` in the egui frontend): on Copy
|
||||
//! it publishes [`Session::clipboard_flavours`]; before a Paste it turns what another app put on
|
||||
|
|
@ -24,9 +24,12 @@ pub const PDF: &str = "application/pdf";
|
|||
pub const PNG: &str = "image/png";
|
||||
/// Any bitmap (PNG, BMP…), in what Paste asks the system clipboard for.
|
||||
pub const BITMAP: &str = "image/*";
|
||||
/// An EMF (or WMF) picture: what Windows apps copy as vector art (the system clipboard reads it
|
||||
/// where the platform offers it).
|
||||
pub const EMF: &str = "image/emf";
|
||||
/// What Paste reads from other apps, best first: vector art before text, text before bitmaps
|
||||
/// (word processors offer a picture of copied text too).
|
||||
pub const PASTE_ORDER: [&str; 4] = [SVG, PDF, TEXT, BITMAP];
|
||||
pub const PASTE_ORDER: [&str; 5] = [SVG, PDF, EMF, TEXT, BITMAP];
|
||||
|
||||
/// One representation of the clipboard's contents.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
|
|
@ -105,6 +108,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
has_doc,
|
||||
import_pdf
|
||||
),
|
||||
cmd!(
|
||||
query "clipboard.importEmf",
|
||||
"Load Metafile into Clipboard",
|
||||
[],
|
||||
None,
|
||||
"{dataBase64 (EMF or WMF bytes), center?: [x, y]} replace the clipboard with the picture's objects (paths, clipping groups, images, point type; the images they use), centred on `center` (default: the first artboard) → {count, warnings}; records VectorCraft doesn't read are skipped with one warning; then run edit.paste",
|
||||
has_doc,
|
||||
import_emf
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
|
|
@ -277,6 +289,21 @@ fn import_text(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
Ok(json!({ "count": count }))
|
||||
}
|
||||
|
||||
fn import_emf(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "clipboard.importEmf";
|
||||
let bytes = data_param(p, C)?;
|
||||
if vectorcraft_metafile::sniff(&bytes).is_none() {
|
||||
return Err(bad(C, "the data isn't an EMF or WMF picture"));
|
||||
}
|
||||
let loaded = fileio::load("Clipboard.emf", &bytes).map_err(|e| bad(C, e.to_string()))?;
|
||||
let clip = Clipboard::from_document(&loaded.doc);
|
||||
if clip.is_empty() {
|
||||
return Err(bad(C, "the picture has no objects"));
|
||||
}
|
||||
let count = s.load_clipboard(clip, p)?;
|
||||
Ok(json!({ "count": count, "warnings": loaded.warnings }))
|
||||
}
|
||||
|
||||
fn import_pdf(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "clipboard.importPdf";
|
||||
let bytes = data_param(p, C)?;
|
||||
|
|
|
|||
|
|
@ -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) });
|
||||
|
|
|
|||
|
|
@ -62,6 +62,9 @@ pub fn detect(name: &str, bytes: &[u8]) -> Option<&'static Format> {
|
|||
if vectorcraft_cad::is_dxf(bytes) {
|
||||
return format("dxf");
|
||||
}
|
||||
if let Some(kind) = vectorcraft_metafile::sniff(bytes) {
|
||||
return format(kind.id());
|
||||
}
|
||||
if let Some(f) = image::guess_format(bytes).ok().and_then(image_format) {
|
||||
return Some(f);
|
||||
}
|
||||
|
|
@ -159,6 +162,10 @@ pub fn load_with(name: &str, bytes: &[u8], opts: &LoadOptions) -> Result<Loaded>
|
|||
let (doc, warnings) = super::dxfimport::import(bytes, &opts.dxf)?;
|
||||
(doc, warnings, false)
|
||||
}
|
||||
"emf" | "wmf" => {
|
||||
let (doc, warnings) = super::metafile::import(bytes)?;
|
||||
(doc, warnings, false)
|
||||
}
|
||||
_ if format.raster => (raster_doc(&title, bytes)?, vec![], false),
|
||||
_ => return Err(err(format!("{} files can't be opened yet", format.label))),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,52 @@
|
|||
//! EMF and WMF for every path that writes or reads Windows metafiles (`vectorcraft_metafile`):
|
||||
//! `document.export {format: emf|wmf}` and Export As (one picture of an artboard, or one per
|
||||
//! artboard with Use Artboards; raster effects as images, rendered at the document's raster
|
||||
//! effects resolution), `document.open` and File → Place (one artboard, the picture's frame).
|
||||
|
||||
use serde_json::Value;
|
||||
use vectorcraft_doc::Document;
|
||||
use vectorcraft_metafile::Kind;
|
||||
|
||||
use super::super::*;
|
||||
use super::{ArtboardPick, Encoded, FormatOption};
|
||||
|
||||
const C: &str = "document.export";
|
||||
|
||||
/// The options `document.formats` lists for EMF and WMF.
|
||||
pub(super) const OPTIONS: &[FormatOption] = &[super::ARTBOARD, super::ARTBOARDS, super::RANGE, super::USE_ARTBOARDS];
|
||||
|
||||
/// Encode `doc` as `kind`: the chosen artboard (default the first), or with `use_artboards` one
|
||||
/// picture per chosen artboard (default all).
|
||||
pub(super) fn encode(doc: &Document, p: &Value, use_artboards: Option<bool>, kind: Kind) -> Result<Encoded> {
|
||||
let f = super::format(kind.id()).ok_or_else(|| bad(C, "no metafile format"))?;
|
||||
let pick: ArtboardPick = super::encode::options(f, p)?;
|
||||
let n = doc.artboards.len();
|
||||
let boards = match use_artboards {
|
||||
Some(true) => pick.resolve(n).map_err(|e| bad(C, e))?.unwrap_or_else(|| (0..n).collect()),
|
||||
_ => vec![pick.one(n).map_err(|e| bad(C, e))?],
|
||||
};
|
||||
// Raster effects (shadows, glows, blurs) as images, as PDF export does.
|
||||
let flat = crate::flatten_raster_effects(doc);
|
||||
let src = flat.as_ref().unwrap_or(doc);
|
||||
let mut enc = Encoded::default();
|
||||
for b in boards {
|
||||
let region = doc.artboards.get(b).ok_or_else(|| bad(C, format!("no artboard {}", b + 1)))?.rect;
|
||||
let out = vectorcraft_metafile::export(src, region, kind).map_err(|e| bad(C, e))?;
|
||||
enc.files.push((Some(b), out.bytes));
|
||||
for w in out.warnings {
|
||||
if !enc.warnings.contains(&w) {
|
||||
enc.warnings.push(w);
|
||||
}
|
||||
}
|
||||
}
|
||||
if enc.files.is_empty() {
|
||||
return Err(bad(C, "the document has no artboard"));
|
||||
}
|
||||
Ok(enc)
|
||||
}
|
||||
|
||||
/// A metafile's document and the import's warnings.
|
||||
pub(super) fn import(bytes: &[u8]) -> Result<(Document, Vec<String>)> {
|
||||
let i = vectorcraft_metafile::import(bytes).map_err(super::err)?;
|
||||
Ok((i.document, i.warnings))
|
||||
}
|
||||
|
|
@ -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, open and place.
|
||||
//!
|
||||
//! [`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;
|
||||
|
|
@ -72,7 +74,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"Open Document",
|
||||
[],
|
||||
None,
|
||||
"{path} or {name, dataBase64}, PDF/.ai: pages?: \"2-3, 5\" (1-based, default all; one artboard each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds), password? (encrypted PDFs; see document.pdfInfo), textAs?: text (default: point type per line, in the file's fonts by name; missing ones are listed in warnings) | outlines (glyph paths), layers?: true (default: optional content groups become layers with their visibility, print state and lock — art that is off comes in as a hidden layer; other art goes to a layer per page) | false (one layer per page, without the art that is off; Save then asks for a name), colorMode?: rgb|cmyk (the mode the document opens in, its colours converted as file.documentColorMode does; default: the file's — a PDF keeps CMYK, Gray and spot inks (spot swatches at a tint) and opens in CMYK when painted mostly in CMYK), DXF: dxf?: {layout?: \"Model\" (default) | a paper layout's name (document.dxfInfo lists them), unit?, scale? (the ratio: 1 unit of the art = scale drawing units; default: the drawing at 1:1 in its own unit), fit?: false (scale to fit an artboard of fitTo?: [612, 792], turned to the art's orientation), scaleLineweights?: false (lineweights scale with the art), center?: true (false: the drawing's origin on the artboard's bottom-left corner; fitted art's bottom-left corner), mergeLayers?: false (all art on one layer)} → {index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks: [{name, path, ids}]}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, ASCII .dxf (each DXF layer with art a layer; blocks symbols; what can't come in is listed in warnings), PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its pixel size). A PDF/.ai/.ait or SVG saved with Preserve Editing restores the native document it carries (restored: true; a PDF only when no pages are picked), unless the file was changed elsewhere since or the data can't be read: then its artwork is imported and the first warning says why. Templates (native templates, .ait) open as a new untitled document; a restored .ai keeps its path (Save writes .ai again). Linked images are read from their files (looked for at their path, then relative to the document): missing ones show their saved preview (links.relink), modified ones are read again only with Preferences › Update Links: Automatically (else links.update). An SVG's <image> files (relative links from the SVG's folder) stay linked, SVG files become art, missing ones a placeholder in their box (a warning and missingLinks)",
|
||||
"{path} or {name, dataBase64}, PDF/.ai: pages?: \"2-3, 5\" (1-based, default all; one artboard each) | page?: n, cropTo?: bounding|art|crop (default)|trim|bleed|media (the box each artboard gets; bounding: the art's bounds), password? (encrypted PDFs; see document.pdfInfo), textAs?: text (default: point type per line, in the file's fonts by name; missing ones are listed in warnings) | outlines (glyph paths), layers?: true (default: optional content groups become layers with their visibility, print state and lock — art that is off comes in as a hidden layer; other art goes to a layer per page) | false (one layer per page, without the art that is off; Save then asks for a name), colorMode?: rgb|cmyk (the mode the document opens in, its colours converted as file.documentColorMode does; default: the file's — a PDF keeps CMYK, Gray and spot inks (spot swatches at a tint) and opens in CMYK when painted mostly in CMYK), DXF: dxf?: {layout?: \"Model\" (default) | a paper layout's name (document.dxfInfo lists them), unit?, scale? (the ratio: 1 unit of the art = scale drawing units; default: the drawing at 1:1 in its own unit), fit?: false (scale to fit an artboard of fitTo?: [612, 792], turned to the art's orientation), scaleLineweights?: false (lineweights scale with the art), center?: true (false: the drawing's origin on the artboard's bottom-left corner; fitted art's bottom-left corner), mergeLayers?: false (all art on one layer)} → {index, title, format, warnings, restored, missingLinks, modifiedLinks, updatedLinks: [{name, path, ids}]}; any readable format (see document.formats): .vectorcraft/.drawcraft, .svg/.svgz, .pdf/.ai, .ait, ASCII .dxf (each DXF layer with art a layer; blocks symbols; what can't come in is listed in warnings), PNG/JPEG/GIF/WebP/TIFF/BMP (an image opens as a document of its pixel size), .emf/.wmf (one artboard, the picture's frame; records VectorCraft doesn't read are skipped with one warning). A PDF/.ai/.ait or SVG saved with Preserve Editing restores the native document it carries (restored: true; a PDF only when no pages are picked), unless the file was changed elsewhere since or the data can't be read: then its artwork is imported and the first warning says why. Templates (native templates, .ait) open as a new untitled document; a restored .ai keeps its path (Save writes .ai again). Linked images are read from their files (looked for at their path, then relative to the document): missing ones show their saved preview (links.relink), modified ones are read again only with Preferences › Update Links: Automatically (else links.update). An SVG's <image> files (relative links from the SVG's folder) stay linked, SVG files become art, missing ones a placeholder in their box (a warning and missingLinks)",
|
||||
always,
|
||||
load::open
|
||||
),
|
||||
|
|
@ -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,11 +448,32 @@ pub const FORMATS: &[Format] = &[
|
|||
raster: false,
|
||||
options: eps::OPTIONS,
|
||||
},
|
||||
Format { id: "emf", label: "EMF", extensions: &["emf"], mime: "image/emf", read: true, write: true, raster: false, options: metafile::OPTIONS },
|
||||
Format { id: "wmf", label: "WMF", extensions: &["wmf"], mime: "image/wmf", read: true, write: true, raster: false, options: metafile::OPTIONS },
|
||||
];
|
||||
|
||||
/// Every extension `document.open` reads (the "All readable files" filter of open dialogs).
|
||||
pub const OPEN_EXTS: &[&str] =
|
||||
&["vectorcraft", "drawcraft", "svg", "svgz", "pdf", "ai", "ait", "png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "vctemplate", "dxf"];
|
||||
pub const OPEN_EXTS: &[&str] = &[
|
||||
"vectorcraft",
|
||||
"drawcraft",
|
||||
"svg",
|
||||
"svgz",
|
||||
"pdf",
|
||||
"ai",
|
||||
"ait",
|
||||
"png",
|
||||
"jpg",
|
||||
"jpeg",
|
||||
"gif",
|
||||
"webp",
|
||||
"tif",
|
||||
"tiff",
|
||||
"bmp",
|
||||
"vctemplate",
|
||||
"dxf",
|
||||
"emf",
|
||||
"wmf",
|
||||
];
|
||||
|
||||
/// Text files: File → Place sets them as area type (Text Import Options).
|
||||
pub const TEXT_EXTS: &[&str] = &["txt"];
|
||||
|
|
@ -474,6 +497,8 @@ pub const PLACE_EXTS: &[&str] = &[
|
|||
"bmp",
|
||||
"vctemplate",
|
||||
"dxf",
|
||||
"emf",
|
||||
"wmf",
|
||||
"txt",
|
||||
];
|
||||
|
||||
|
|
@ -754,3 +779,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,160 @@
|
|||
//! EMF and WMF through the commands: export of one artboard or each, raster effects as images,
|
||||
//! open, place, paste, and the format lists.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::NodeKind;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn session(width: f64, height: f64, artboards: usize) -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": width, "height": height, "artboards": artboards})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn b64(v: &Value) -> Vec<u8> {
|
||||
vectorcraft_format::base64_decode(v["dataBase64"].as_str().expect("dataBase64")).unwrap()
|
||||
}
|
||||
|
||||
fn u32_at(b: &[u8], at: usize) -> u32 {
|
||||
u32::from_le_bytes(b[at..at + 4].try_into().unwrap())
|
||||
}
|
||||
|
||||
fn i32_at(b: &[u8], at: usize) -> i32 {
|
||||
i32::from_le_bytes(b[at..at + 4].try_into().unwrap())
|
||||
}
|
||||
|
||||
/// The EMF's record types.
|
||||
fn emf_kinds(b: &[u8]) -> Vec<u32> {
|
||||
let mut out = vec![];
|
||||
let mut at = 0;
|
||||
while at + 8 <= b.len() {
|
||||
out.push(u32_at(b, at));
|
||||
at += u32_at(b, at + 4) as usize;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Two 100 pt artboards side by side, a red rectangle on each.
|
||||
fn two_boards() -> Session {
|
||||
let mut s = session(100.0, 100.0, 2);
|
||||
let second = s.doc().unwrap().doc.artboards[1].rect.x0;
|
||||
s.execute("paint.setStroke", &json!({"none": true})).unwrap();
|
||||
s.execute("paint.setFill", &json!({"color": "#ff0000"})).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 30, "height": 30})).unwrap();
|
||||
s.execute("shape.rectangle", &json!({"x": second + 10.0, "y": 10, "width": 30, "height": 30})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_writes_an_emf_or_wmf_of_one_artboard() {
|
||||
let mut s = two_boards();
|
||||
let r = s.execute("document.export", &json!({"format": "emf"})).unwrap();
|
||||
assert_eq!(r["format"], "emf");
|
||||
let emf = b64(&r);
|
||||
assert_eq!(vectorcraft_metafile::sniff(&emf), Some(vectorcraft_metafile::Kind::Emf));
|
||||
// The first artboard's frame in 0.01 mm.
|
||||
let side = (100.0 * 2540.0 / 72.0f64).round() as i32;
|
||||
assert_eq!([i32_at(&emf, 24), i32_at(&emf, 28), i32_at(&emf, 32), i32_at(&emf, 36)], [0, 0, side, side]);
|
||||
assert_eq!(emf_kinds(&emf).iter().filter(|k| **k == 62).count(), 1, "one rectangle on it");
|
||||
// The second artboard alone; serialize gives the same bytes.
|
||||
let second = b64(&s.execute("document.export", &json!({"format": "emf", "artboard": 1})).unwrap());
|
||||
assert_eq!(emf_kinds(&second).iter().filter(|k| **k == 62).count(), 1);
|
||||
assert_eq!(b64(&s.execute("document.serialize", &json!({"format": "emf"})).unwrap()), emf);
|
||||
let wmf = b64(&s.execute("document.export", &json!({"format": "wmf"})).unwrap());
|
||||
assert_eq!(vectorcraft_metafile::sniff(&wmf), Some(vectorcraft_metafile::Kind::Wmf));
|
||||
assert_eq!(writable_format(None, Some("x/pic.wmf")).unwrap().id, "wmf");
|
||||
assert_eq!(writable_format(Some("EMF"), None).unwrap().id, "emf");
|
||||
// One file holds one artboard.
|
||||
assert!(s.execute("document.export", &json!({"format": "emf", "artboards": [0, 1]})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn use_artboards_writes_one_picture_each_or_the_art() {
|
||||
let mut s = two_boards();
|
||||
let r = s.execute("document.export", &json!({"format": "wmf", "useArtboards": true})).unwrap();
|
||||
let files = r["files"].as_array().unwrap();
|
||||
assert_eq!(files.len(), 2);
|
||||
assert!(files[0]["name"].as_str().unwrap().ends_with("-Artboard-1.wmf"), "{files:?}");
|
||||
// Without artboards: the bounds of the art (both rectangles).
|
||||
let r = s.execute("document.export", &json!({"format": "emf", "useArtboards": false})).unwrap();
|
||||
let emf = b64(&r);
|
||||
let width_pt = f64::from(i32_at(&emf, 32)) / 2540.0 * 72.0;
|
||||
let second = s.doc().unwrap().doc.artboards[1].rect.x0;
|
||||
assert!((width_pt - (second + 30.0)).abs() < 0.05, "{width_pt}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raster_effects_are_written_as_images() {
|
||||
let mut s = session(200.0, 200.0, 1);
|
||||
let id = s.execute("shape.rectangle", &json!({"x": 50, "y": 50, "width": 60, "height": 60})).unwrap()["id"].clone();
|
||||
s.execute("effect.apply", &json!({"effect": "stylize.dropShadow", "ids": [id]})).unwrap();
|
||||
let r = s.execute("document.export", &json!({"format": "emf"})).unwrap();
|
||||
assert!(r["warnings"].as_array().unwrap().iter().all(|w| !w.as_str().unwrap().contains("raster effects")), "{r}");
|
||||
let kinds = emf_kinds(&b64(&r));
|
||||
assert!(kinds.contains(&114), "the shadow is an image with transparency");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metafiles_open_and_place() {
|
||||
let mut s = two_boards();
|
||||
let emf = b64(&s.execute("document.export", &json!({"format": "emf"})).unwrap());
|
||||
let wmf = b64(&s.execute("document.export", &json!({"format": "wmf"})).unwrap());
|
||||
for (name, bytes) in [("pic.emf", &emf), ("pic.wmf", &wmf)] {
|
||||
let r = s.execute("document.open", &json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(bytes)})).unwrap();
|
||||
assert_eq!(r["format"], &name[4..]);
|
||||
assert_eq!(r["warnings"], json!([]));
|
||||
let st = s.doc().unwrap();
|
||||
let ab = st.doc.artboards[0].rect;
|
||||
assert!((ab.width() - 100.0).abs() < 0.05 && (ab.height() - 100.0).abs() < 0.05, "{ab:?}");
|
||||
let fill = st.doc.layers[0].children().unwrap().iter().find(|n| matches!(n.kind, NodeKind::Path { .. })).unwrap().clone();
|
||||
let b = fill.geometric_bounds().unwrap();
|
||||
assert!((b.x0 - 10.0).abs() < 0.05 && (b.width() - 30.0).abs() < 0.05, "{b:?}");
|
||||
// Save can't write the file back: the document has no path.
|
||||
assert!(s.doc().unwrap().path.is_none());
|
||||
}
|
||||
// Placed: one group clipped to the picture's frame, named after the file.
|
||||
let mut s = session(300.0, 300.0, 1);
|
||||
let r = s.execute("file.place", &json!({"name": "pic.emf", "dataBase64": vectorcraft_format::base64_encode(&emf), "at": [150, 150]})).unwrap();
|
||||
assert_eq!(r["format"], "emf");
|
||||
assert!((r["width"].as_f64().unwrap() - 100.0).abs() < 0.05);
|
||||
let st = s.doc().unwrap();
|
||||
let placed = st.doc.layers[0].children().unwrap().last().unwrap().clone();
|
||||
assert_eq!(placed.name.as_deref(), Some("pic.emf"));
|
||||
assert!(matches!(placed.kind, NodeKind::Group { clip: true, .. }));
|
||||
let ext = fileio::PLACE_EXTS;
|
||||
assert!(ext.contains(&"emf") && ext.contains(&"wmf") && OPEN_EXTS.contains(&"emf") && OPEN_EXTS.contains(&"wmf"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formats_list_emf_and_wmf() {
|
||||
let mut s = Session::new();
|
||||
let r = s.execute("document.formats", &json!({})).unwrap();
|
||||
for k in ["readable", "writable"] {
|
||||
let ids: Vec<&str> = r[k].as_array().unwrap().iter().map(|v| v.as_str().unwrap()).collect();
|
||||
assert!(ids.contains(&"emf") && ids.contains(&"wmf"), "{k}: {ids:?}");
|
||||
}
|
||||
let emf = r["formats"].as_array().unwrap().iter().find(|f| f["id"] == "emf").unwrap();
|
||||
assert_eq!(emf["mime"], "image/emf");
|
||||
assert!(emf["options"]["useArtboards"].is_object());
|
||||
assert!(open_filters().any(|(label, ext)| label == "EMF" && ext == ["emf"]));
|
||||
assert!(place_filters().any(|(label, ext)| label == "WMF" && ext == ["wmf"]));
|
||||
// Export for Screens writes the screen formats only.
|
||||
let mut s = two_boards();
|
||||
assert!(s.execute("document.exportForScreens", &json!({"formats": [{"format": "emf"}]})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pasted_metafile_becomes_objects() {
|
||||
let mut src = two_boards();
|
||||
let emf = b64(&src.execute("document.export", &json!({"format": "emf"})).unwrap());
|
||||
let mut s = session(300.0, 300.0, 1);
|
||||
let r = s.execute("clipboard.importEmf", &json!({"dataBase64": vectorcraft_format::base64_encode(&emf), "center": [150, 150]})).unwrap();
|
||||
assert_eq!(r["count"], 1);
|
||||
s.execute("edit.paste", &json!({"center": [150, 150]})).unwrap();
|
||||
let pasted = s.doc().unwrap().doc.layers[0].children().unwrap().clone();
|
||||
assert_eq!(pasted.len(), 1);
|
||||
let b = pasted[0].geometric_bounds().unwrap();
|
||||
assert!((b.center().x - 150.0).abs() < 0.05 && (b.width() - 30.0).abs() < 0.05, "{b:?}");
|
||||
assert!(s.execute("clipboard.importEmf", &json!({"dataBase64": vectorcraft_format::base64_encode(b"%PDF-1.7")})).is_err());
|
||||
}
|
||||
|
|
@ -6,6 +6,8 @@
|
|||
//! `link` ([`super::links`]).
|
||||
//! - An SVG becomes one group of its art; so does a DXF drawing, read with the DXF options
|
||||
//! (`dxf`, [`fileio::dxfimport`]).
|
||||
//! - An EMF or WMF picture becomes one group, clipped to its frame (`crop: "crop"`) or bounded by
|
||||
//! its art (`crop: "bounding"`).
|
||||
//! - A PDF/.ai/.ait page or a native document's artboard becomes one group, clipped to the page
|
||||
//! (`crop: "crop"`; a PDF page's `art`, `trim`, `bleed` or `media` box too) or bounded by its art
|
||||
//! (`crop: "bounding"`).
|
||||
|
|
@ -48,7 +50,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"Place…",
|
||||
["File"],
|
||||
Some("Cmd+Shift+P"),
|
||||
"{path | name+dataBase64, link?: true (a raster image keeps its file's path; other files are embedded), text?: {characterSet?: \"unicode\" (UTF-8, or UTF-16 with a byte-order mark; other bytes as the platform's 8-bit set) | \"ansi\" (the platform's 8-bit set), platform?: \"windows\" (Windows-1252) | \"mac\" (Mac Roman), removeLineReturns?: false (each block of lines becomes one paragraph; blank lines end paragraphs), removeParagraphReturns?: false (drop blank lines), replaceSpaces?: n (runs of n ≥ 2 spaces become a tab)} (a .txt file, placed as area type filling rect, the replaced object's bounds, or else the artboard less a 36 pt margin), template?: false (onto a new locked template layer below the current layer), replace?: false (swap the one selected object, keeping its stacking place and transform; no at/rect), at?: [x, y] centre (default: the first artboard's centre), rect?: [x, y, width, height] fit inside, aspect kept (wins over at), page?: 1 (PDF/.ai page, or a native document's artboard), crop?: \"crop\" (clipped to that page or artboard, default) | \"bounding\" (the art's bounds) | \"art\" | \"trim\" | \"bleed\" | \"media\" (a PDF page's boxes), password? (an encrypted PDF), dxf?: {…the DXF options of document.open} (a .dxf drawing: fit fills the artboard under at, else the first; fitted or uncentred art lands with its drawing's artboard on that artboard, else it is centred on at)} → {ids, name, format, linked, width, height, warnings}. Raster images come in at 100% of their physical size (the file's ppi, else 72); SVG, DXF, PDF/.ai and native documents as one group, with the images, symbols, patterns and swatches they use. One undo step; selects what it placed (unless on a template layer); never touches the clipboard",
|
||||
"{path | name+dataBase64, link?: true (a raster image keeps its file's path; other files are embedded), text?: {characterSet?: \"unicode\" (UTF-8, or UTF-16 with a byte-order mark; other bytes as the platform's 8-bit set) | \"ansi\" (the platform's 8-bit set), platform?: \"windows\" (Windows-1252) | \"mac\" (Mac Roman), removeLineReturns?: false (each block of lines becomes one paragraph; blank lines end paragraphs), removeParagraphReturns?: false (drop blank lines), replaceSpaces?: n (runs of n ≥ 2 spaces become a tab)} (a .txt file, placed as area type filling rect, the replaced object's bounds, or else the artboard less a 36 pt margin), template?: false (onto a new locked template layer below the current layer), replace?: false (swap the one selected object, keeping its stacking place and transform; no at/rect), at?: [x, y] centre (default: the first artboard's centre), rect?: [x, y, width, height] fit inside, aspect kept (wins over at), page?: 1 (PDF/.ai page, or a native document's artboard), crop?: \"crop\" (clipped to that page or artboard, default) | \"bounding\" (the art's bounds) | \"art\" | \"trim\" | \"bleed\" | \"media\" (a PDF page's boxes), password? (an encrypted PDF), dxf?: {…the DXF options of document.open} (a .dxf drawing: fit fills the artboard under at, else the first; fitted or uncentred art lands with its drawing's artboard on that artboard, else it is centred on at)} → {ids, name, format, linked, width, height, warnings}. Raster images come in at 100% of their physical size (the file's ppi, else 72); SVG, DXF, PDF/.ai, EMF/WMF (clipped to the picture's frame) and native documents as one group, with the images, symbols, patterns and swatches they use. One undo step; selects what it placed (unless on a template layer); never touches the clipboard",
|
||||
has_doc,
|
||||
place
|
||||
),
|
||||
|
|
|
|||
|
|
@ -2,7 +2,8 @@
|
|||
//! mutated raster images placed with File → Place, and swatch (`.vcswatches`, `.gpl`), graphic style (`.vcstyles`) and flattener preset
|
||||
//! (`.vcflattener`) libraries, and native files (compressed, damaged, saved for older versions),
|
||||
//! must load as an error or as a document that then renders and exports, without a panic; nor may
|
||||
//! bitmaps, PDF and text pasted from other apps.
|
||||
//! bitmaps, PDF and text pasted from other apps, nor EMF and WMF pictures (damaged files, records
|
||||
//! of every kind with random contents) opened, placed or pasted.
|
||||
//!
|
||||
//! `PROPTEST_CASES=20000 cargo test -p vectorcraft-engine --test import_fuzz` runs a deeper search.
|
||||
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness).
|
||||
|
|
@ -991,3 +992,116 @@ proptest! {
|
|||
survive_dxf("mutated dxf", &bytes, cut % 2 == 0)?;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Windows metafiles: opened, placed and pasted ----------
|
||||
|
||||
/// The rich document as EMF and as WMF.
|
||||
fn metafile_samples() -> &'static [Vec<u8>] {
|
||||
static SAMPLES: std::sync::OnceLock<Vec<Vec<u8>>> = std::sync::OnceLock::new();
|
||||
SAMPLES.get_or_init(|| {
|
||||
let mut s = rich_session();
|
||||
["emf", "wmf"]
|
||||
.iter()
|
||||
.map(|f| {
|
||||
let r = s.execute("document.export", &json!({ "format": f })).unwrap();
|
||||
vectorcraft_format::base64_decode(r["dataBase64"].as_str().unwrap()).unwrap()
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
}
|
||||
|
||||
/// An EMF of a 100 × 100 mm frame on a device of 10 pixels a millimetre holding `records`.
|
||||
fn emf_of(records: &[(u32, Vec<u8>)]) -> Vec<u8> {
|
||||
let mut b: Vec<u8> = vec![];
|
||||
let mut head = vec![0u8; 108];
|
||||
head[..4].copy_from_slice(&1u32.to_le_bytes());
|
||||
head[4..8].copy_from_slice(&108u32.to_le_bytes());
|
||||
for (at, v) in [(32, 10_000i32), (36, 10_000), (72, 1000), (76, 1000), (80, 100), (84, 100), (100, 100_000), (104, 100_000)] {
|
||||
head[at..at + 4].copy_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
head[40..44].copy_from_slice(b" EMF");
|
||||
b.extend(head);
|
||||
for (kind, body) in records {
|
||||
let mut body = body.clone();
|
||||
body.resize(body.len().div_ceil(4) * 4, 0);
|
||||
b.extend(kind.to_le_bytes());
|
||||
b.extend((8 + body.len() as u32).to_le_bytes());
|
||||
b.extend(body);
|
||||
}
|
||||
b.extend([14, 0, 0, 0, 20, 0, 0, 0, 0, 0, 0, 0, 16, 0, 0, 0, 20, 0, 0, 0]);
|
||||
b
|
||||
}
|
||||
|
||||
/// A placeable WMF of a 1 × 1 inch box holding `records`.
|
||||
fn wmf_of(records: &[(u16, Vec<u8>)]) -> Vec<u8> {
|
||||
let mut b: Vec<u8> = vec![0xD7, 0xCD, 0xC6, 0x9A, 0, 0, 0, 0, 0, 0, 0xA0, 0x05, 0xA0, 0x05, 0xA0, 0x05, 0, 0, 0, 0];
|
||||
let sum = b.chunks_exact(2).fold(0u16, |a, w| a ^ u16::from_le_bytes([w[0], w[1]]));
|
||||
b.extend(sum.to_le_bytes());
|
||||
b.extend([1, 0, 9, 0, 0, 3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]);
|
||||
for (function, body) in records {
|
||||
let mut body = body.clone();
|
||||
body.resize(body.len().div_ceil(2) * 2, 0);
|
||||
b.extend((3 + body.len() as u32 / 2).to_le_bytes());
|
||||
b.extend(function.to_le_bytes());
|
||||
b.extend(body);
|
||||
}
|
||||
b.extend([3, 0, 0, 0, 0, 0]);
|
||||
b
|
||||
}
|
||||
|
||||
/// Open the bytes as `name`, and place them: neither may panic.
|
||||
fn survive_metafile(what: &str, name: &str, bytes: &[u8]) -> Result<(), TestCaseError> {
|
||||
survive(what, || vectorcraft_engine::cmd::fileio::load(name, bytes).ok().map(|l| l.doc))?;
|
||||
let r = catch_quiet(|| {
|
||||
let mut s = vectorcraft_engine::Session::new();
|
||||
s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
|
||||
let _ = s.execute("file.place", &json!({"name": name, "dataBase64": vectorcraft_format::base64_encode(bytes)}));
|
||||
});
|
||||
prop_assert!(r.is_ok(), "{what}: placing panicked: {:?}", r.err());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A record body: random bytes, or small numbers (coordinates, counts and offsets that land).
|
||||
fn arb_body() -> impl Strategy<Value = Vec<u8>> {
|
||||
prop_oneof![
|
||||
prop::collection::vec(any::<u8>(), 0..96),
|
||||
prop::collection::vec(prop_oneof![Just(0i32), Just(1), Just(-1), 0i32..200, Just(i32::MAX), Just(i32::MIN)], 0..24)
|
||||
.prop_map(|v| v.iter().flat_map(|x| x.to_le_bytes()).collect()),
|
||||
]
|
||||
}
|
||||
|
||||
/// WMF record functions VectorCraft reads, and one it doesn't.
|
||||
const WMF_FUNCTIONS: [u16; 33] = [
|
||||
0x0324, 0x0325, 0x0538, 0x041B, 0x0418, 0x061C, 0x0817, 0x081A, 0x0830, 0x0416, 0x0415, 0x02FA, 0x02FC, 0x02FB, 0x012D, 0x01F0, 0x0521, 0x0A32,
|
||||
0x0F43, 0x0B41, 0x0940, 0x061D, 0x0213, 0x0214, 0x001E, 0x0127, 0x0106, 0x0209, 0x012E, 0x00F7, 0x01F9, 0x0142, 0x0999,
|
||||
];
|
||||
|
||||
proptest! {
|
||||
#![proptest_config(config())]
|
||||
|
||||
#[test]
|
||||
fn mutated_metafiles_never_panic(which in 0usize..2, cut in 0usize..200_000, edits in prop::collection::vec((0usize..200_000, any::<u8>()), 0..16)) {
|
||||
let mut bytes = metafile_samples()[which].clone();
|
||||
let n = bytes.len();
|
||||
for &(at, b) in &edits {
|
||||
bytes[at % n] = b;
|
||||
}
|
||||
bytes.truncate(cut.max(8));
|
||||
survive_metafile("mutated metafile", ["x.emf", "x.wmf"][which], &bytes)?;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn random_emf_records_never_panic(records in prop::collection::vec((0u32..124, arb_body()), 0..32)) {
|
||||
survive_metafile("EMF records", "x.emf", &emf_of(&records))?;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn random_wmf_records_never_panic(records in prop::collection::vec((prop::sample::select(WMF_FUNCTIONS.to_vec()), arb_body()), 0..32)) {
|
||||
survive_metafile("WMF records", "x.wmf", &wmf_of(&records))?;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pasted_metafiles_never_panic(which in 0usize..2, cut in 0usize..200_000, edits in prop::collection::vec((0usize..200_000, any::<u8>()), 0..12)) {
|
||||
paste_from_elsewhere("clipboard.importEmf", json!({"dataBase64": mutated_b64(metafile_samples()[which].clone(), cut, &edits)}))?;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,96 @@
|
|||
//! Little-endian values: the buffer both writers build records in, and a reader that never reads
|
||||
//! past its data (every read is an `Option`).
|
||||
|
||||
/// Bytes being written.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Out(pub Vec<u8>);
|
||||
|
||||
impl Out {
|
||||
pub fn len(&self) -> usize {
|
||||
self.0.len()
|
||||
}
|
||||
pub fn u16(&mut self, v: u16) {
|
||||
self.0.extend_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
pub fn i16(&mut self, v: i16) {
|
||||
self.0.extend_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
pub fn u32(&mut self, v: u32) {
|
||||
self.0.extend_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
pub fn i32(&mut self, v: i32) {
|
||||
self.0.extend_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
pub fn f32(&mut self, v: f32) {
|
||||
self.0.extend_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
pub fn bytes(&mut self, b: &[u8]) {
|
||||
self.0.extend_from_slice(b);
|
||||
}
|
||||
/// Zeros up to the next multiple of `n` bytes.
|
||||
pub fn pad(&mut self, n: usize) {
|
||||
while !self.0.len().is_multiple_of(n) {
|
||||
self.0.push(0);
|
||||
}
|
||||
}
|
||||
/// Overwrite the `u32` at `at` (a size or count known only later).
|
||||
pub fn set_u32(&mut self, at: usize, v: u32) {
|
||||
if let Some(b) = self.0.get_mut(at..at + 4) {
|
||||
b.copy_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
}
|
||||
pub fn set_i32(&mut self, at: usize, v: i32) {
|
||||
if let Some(b) = self.0.get_mut(at..at + 4) {
|
||||
b.copy_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Sequential little-endian reads from a slice.
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) struct Reader<'a> {
|
||||
data: &'a [u8],
|
||||
pos: usize,
|
||||
}
|
||||
|
||||
impl<'a> Reader<'a> {
|
||||
pub fn new(data: &'a [u8]) -> Self {
|
||||
Self { data, pos: 0 }
|
||||
}
|
||||
pub fn bytes(&mut self, n: usize) -> Option<&'a [u8]> {
|
||||
let b = self.data.get(self.pos..self.pos.checked_add(n)?)?;
|
||||
self.pos += n;
|
||||
Some(b)
|
||||
}
|
||||
fn array<const N: usize>(&mut self) -> Option<[u8; N]> {
|
||||
self.bytes(N)?.try_into().ok()
|
||||
}
|
||||
pub fn u16(&mut self) -> Option<u16> {
|
||||
self.array().map(u16::from_le_bytes)
|
||||
}
|
||||
pub fn i16(&mut self) -> Option<i16> {
|
||||
self.array().map(i16::from_le_bytes)
|
||||
}
|
||||
pub fn u32(&mut self) -> Option<u32> {
|
||||
self.array().map(u32::from_le_bytes)
|
||||
}
|
||||
pub fn i32(&mut self) -> Option<i32> {
|
||||
self.array().map(i32::from_le_bytes)
|
||||
}
|
||||
pub fn f32(&mut self) -> Option<f32> {
|
||||
self.array().map(f32::from_le_bytes)
|
||||
}
|
||||
pub fn skip(&mut self, n: usize) -> Option<()> {
|
||||
self.bytes(n).map(drop)
|
||||
}
|
||||
/// The bytes not read yet.
|
||||
pub fn rest(&self) -> &'a [u8] {
|
||||
self.data.get(self.pos..).unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
/// `len` bytes of `data` from `offset` (record-relative offsets read from untrusted files).
|
||||
pub(crate) fn slice(data: &[u8], offset: u32, len: u32) -> Option<&[u8]> {
|
||||
let start = usize::try_from(offset).ok()?;
|
||||
data.get(start..start.checked_add(usize::try_from(len).ok()?)?)
|
||||
}
|
||||
|
|
@ -0,0 +1,356 @@
|
|||
//! Device-independent bitmaps (DIBs), the pixels inside image records: written bottom-up as 24-bit
|
||||
//! (opaque, composited over white) or 32-bit premultiplied BGRA (AlphaBlend's source); read from 1,
|
||||
//! 4, 8, 16, 24 and 32-bit uncompressed and bit-field bitmaps and from embedded JPEG and PNG.
|
||||
|
||||
use std::io::Cursor;
|
||||
|
||||
use crate::bytes::Reader;
|
||||
|
||||
/// Most pixels along a side of an image the writers store (larger ones are scaled down).
|
||||
const MAX_SIDE: u32 = 8192;
|
||||
/// Most pixels an image the writers store holds.
|
||||
const MAX_PIXELS: u64 = 1 << 24;
|
||||
/// Most pixels along a side, and in all, of a bitmap the reader decodes.
|
||||
const MAX_READ_SIDE: u32 = 1 << 15;
|
||||
const MAX_READ_PIXELS: u64 = 1 << 26;
|
||||
|
||||
const BI_RGB: u32 = 0;
|
||||
const BI_BITFIELDS: u32 = 3;
|
||||
const BI_JPEG: u32 = 4;
|
||||
const BI_PNG: u32 = 5;
|
||||
|
||||
/// Pixels with straight (not premultiplied) alpha, row by row from the top.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) struct Rgba {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub pixels: Vec<u8>,
|
||||
}
|
||||
|
||||
impl Rgba {
|
||||
/// `img`, scaled down to what a metafile stores when it is larger.
|
||||
pub fn fitted(img: image::RgbaImage) -> Self {
|
||||
let (w, h) = img.dimensions();
|
||||
let side = f64::from(w.max(h)) / f64::from(MAX_SIDE);
|
||||
let area = (u64::from(w) * u64::from(h)) as f64 / MAX_PIXELS as f64;
|
||||
let k = side.max(area.sqrt());
|
||||
let img = if k > 1.0 {
|
||||
let nw = ((f64::from(w) / k).floor() as u32).max(1);
|
||||
let nh = ((f64::from(h) / k).floor() as u32).max(1);
|
||||
image::imageops::resize(&img, nw, nh, image::imageops::FilterType::Triangle)
|
||||
} else {
|
||||
img
|
||||
};
|
||||
let (width, height) = img.dimensions();
|
||||
Self { width, height, pixels: img.into_raw() }
|
||||
}
|
||||
|
||||
/// Every pixel fully opaque?
|
||||
pub fn opaque(&self) -> bool {
|
||||
self.pixels.as_chunks::<4>().0.iter().all(|p| p[3] == 255)
|
||||
}
|
||||
|
||||
/// As PNG bytes.
|
||||
pub fn to_png(&self) -> Result<Vec<u8>, String> {
|
||||
let img = image::RgbaImage::from_raw(self.width, self.height, self.pixels.clone()).ok_or("the image's pixels don't match its size")?;
|
||||
let mut png = vec![];
|
||||
img.write_to(&mut Cursor::new(&mut png), image::ImageFormat::Png).map_err(|e| e.to_string())?;
|
||||
Ok(png)
|
||||
}
|
||||
}
|
||||
|
||||
/// A BITMAPINFOHEADER of a bottom-up, uncompressed `width` × `height` bitmap of `bits` per pixel.
|
||||
fn header(width: u32, height: u32, bits: u16, size: usize) -> Vec<u8> {
|
||||
let mut h = crate::bytes::Out::default();
|
||||
h.u32(40);
|
||||
h.i32(i32::try_from(width).unwrap_or(i32::MAX));
|
||||
h.i32(i32::try_from(height).unwrap_or(i32::MAX));
|
||||
h.u16(1);
|
||||
h.u16(bits);
|
||||
h.u32(BI_RGB);
|
||||
h.u32(u32::try_from(size).unwrap_or(0));
|
||||
// 2835 pixels a metre: 72 ppi (the size on the page comes from the record).
|
||||
h.i32(2835);
|
||||
h.i32(2835);
|
||||
h.u32(0);
|
||||
h.u32(0);
|
||||
h.0
|
||||
}
|
||||
|
||||
/// The bytes of a row of `width` pixels of `bits` each, padded to 4 bytes.
|
||||
fn stride(width: u32, bits: u32) -> Option<usize> {
|
||||
let row_bits = u64::from(width).checked_mul(u64::from(bits))?;
|
||||
usize::try_from(row_bits.div_ceil(32) * 4).ok()
|
||||
}
|
||||
|
||||
/// `img` at `opacity`, composited over white, as a 24-bit DIB → (header, bits).
|
||||
pub(crate) fn rgb24(img: &Rgba, opacity: f32) -> (Vec<u8>, Vec<u8>) {
|
||||
let row = stride(img.width, 24).unwrap_or(0);
|
||||
let mut bits = vec![0u8; row * img.height as usize];
|
||||
let over = |c: u8, a: u32| ((u32::from(c) * a + 255 * (255 - a) + 127) / 255) as u8;
|
||||
for (y, src) in img.pixels.chunks_exact((img.width as usize * 4).max(1)).enumerate() {
|
||||
// Bottom-up: the first stored row is the image's last.
|
||||
let Some(at) = (img.height as usize).checked_sub(y + 1).map(|r| r * row) else { continue };
|
||||
let Some(dst) = bits.get_mut(at..at + img.width as usize * 3) else { continue };
|
||||
for (d, p) in dst.chunks_exact_mut(3).zip(src.as_chunks::<4>().0) {
|
||||
let a = (f32::from(p[3]) * opacity.clamp(0.0, 1.0)).round() as u32;
|
||||
d.copy_from_slice(&[over(p[2], a), over(p[1], a), over(p[0], a)]);
|
||||
}
|
||||
}
|
||||
(header(img.width, img.height, 24, bits.len()), bits)
|
||||
}
|
||||
|
||||
/// `img` at `opacity` as a 32-bit DIB of premultiplied BGRA → (header, bits).
|
||||
pub(crate) fn bgra32(img: &Rgba, opacity: f32) -> (Vec<u8>, Vec<u8>) {
|
||||
let row = img.width as usize * 4;
|
||||
let mut bits = vec![0u8; row * img.height as usize];
|
||||
for (y, src) in img.pixels.chunks_exact(row.max(1)).enumerate() {
|
||||
let Some(at) = (img.height as usize).checked_sub(y + 1).map(|r| r * row) else { continue };
|
||||
let Some(dst) = bits.get_mut(at..at + row) else { continue };
|
||||
for (d, p) in dst.chunks_exact_mut(4).zip(src.as_chunks::<4>().0) {
|
||||
let a = (f32::from(p[3]) * opacity.clamp(0.0, 1.0)).round() as u32;
|
||||
let pre = |c: u8| ((u32::from(c) * a + 127) / 255) as u8;
|
||||
d.copy_from_slice(&[pre(p[2]), pre(p[1]), pre(p[0]), a as u8]);
|
||||
}
|
||||
}
|
||||
(header(img.width, img.height, 32, bits.len()), bits)
|
||||
}
|
||||
|
||||
/// How the fourth byte of 32-bit pixels reads.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum Alpha {
|
||||
/// Unused (StretchDIBits and BitBlt ignore it): every pixel opaque.
|
||||
Ignore,
|
||||
/// Premultiplied alpha (AlphaBlend with per-pixel alpha).
|
||||
Premultiplied,
|
||||
}
|
||||
|
||||
/// A colour channel of `v` under `mask`, scaled to 0–255.
|
||||
fn channel(v: u32, mask: u32) -> u8 {
|
||||
if mask == 0 {
|
||||
return 0;
|
||||
}
|
||||
let shift = mask.trailing_zeros();
|
||||
let max = u64::from(mask >> shift);
|
||||
let x = u64::from((v & mask) >> shift);
|
||||
((x * 255 + max / 2) / max.max(1)).min(255) as u8
|
||||
}
|
||||
|
||||
/// Read a DIB from its BITMAPINFO (`bmi`: the header and colour table) and pixel `bits`.
|
||||
pub(crate) fn decode(bmi: &[u8], bits: &[u8], alpha: Alpha) -> Result<Rgba, String> {
|
||||
let bad = || "a bitmap is damaged".to_string();
|
||||
let mut r = Reader::new(bmi);
|
||||
let size = r.u32().ok_or_else(bad)?;
|
||||
let (width, height, bpp, compression, colors, palette_entry, masks_at) = if size == 12 {
|
||||
// BITMAPCOREHEADER: 16-bit sizes, RGB triples.
|
||||
let w = i32::from(r.u16().ok_or_else(bad)?);
|
||||
let h = i32::from(r.u16().ok_or_else(bad)?);
|
||||
r.skip(2).ok_or_else(bad)?;
|
||||
let bpp = r.u16().ok_or_else(bad)?;
|
||||
(w, h, bpp, BI_RGB, 0, 3, 0)
|
||||
} else if size >= 40 {
|
||||
let w = r.i32().ok_or_else(bad)?;
|
||||
let h = r.i32().ok_or_else(bad)?;
|
||||
r.skip(2).ok_or_else(bad)?;
|
||||
let bpp = r.u16().ok_or_else(bad)?;
|
||||
let c = r.u32().ok_or_else(bad)?;
|
||||
r.skip(12).ok_or_else(bad)?;
|
||||
let used = r.u32().ok_or_else(bad)?;
|
||||
(w, h, bpp, c, used, 4, 40)
|
||||
} else {
|
||||
return Err(bad());
|
||||
};
|
||||
if compression == BI_JPEG || compression == BI_PNG {
|
||||
let img = image::load_from_memory(bits).map_err(|_| bad())?.to_rgba8();
|
||||
let (w, h) = img.dimensions();
|
||||
if w > MAX_READ_SIDE || h > MAX_READ_SIDE || u64::from(w) * u64::from(h) > MAX_READ_PIXELS {
|
||||
return Err("a bitmap is too large to read".into());
|
||||
}
|
||||
return Ok(Rgba { width: w, height: h, pixels: img.into_raw() });
|
||||
}
|
||||
if compression != BI_RGB && compression != BI_BITFIELDS {
|
||||
return Err("compressed (RLE) bitmaps can't be read yet".into());
|
||||
}
|
||||
let top_down = height < 0;
|
||||
let (w, h) = (width.unsigned_abs(), height.unsigned_abs());
|
||||
if w == 0 || h == 0 {
|
||||
return Err("a bitmap is empty".into());
|
||||
}
|
||||
if w > MAX_READ_SIDE || h > MAX_READ_SIDE || u64::from(w) * u64::from(h) > MAX_READ_PIXELS {
|
||||
return Err("a bitmap is too large to read".into());
|
||||
}
|
||||
let header_len = size as usize;
|
||||
// Bit-field masks follow a plain 40-byte header, or sit inside the larger ones.
|
||||
let fields = compression == BI_BITFIELDS;
|
||||
let masks: Option<[u32; 4]> = fields.then(|| {
|
||||
let mut m = Reader::new(bmi.get(masks_at..).unwrap_or_default());
|
||||
let rgb = [m.u32().unwrap_or(0), m.u32().unwrap_or(0), m.u32().unwrap_or(0)];
|
||||
// An alpha mask comes with BITMAPV3INFOHEADER and later.
|
||||
let a = if header_len >= 56 { m.u32().unwrap_or(0) } else { 0 };
|
||||
[rgb[0], rgb[1], rgb[2], a]
|
||||
});
|
||||
let palette_at = header_len + if fields && header_len == 40 { 12 } else { 0 };
|
||||
let bpp_u = u32::from(bpp);
|
||||
let palette: Vec<[u8; 3]> = if bpp_u <= 8 {
|
||||
let n = if colors == 0 { 1usize << bpp_u } else { (colors as usize).min(256) };
|
||||
bmi.get(palette_at..)
|
||||
.unwrap_or_default()
|
||||
.chunks_exact(palette_entry)
|
||||
.take(n)
|
||||
.map(|e| [e.get(2).copied().unwrap_or(0), e.get(1).copied().unwrap_or(0), e.first().copied().unwrap_or(0)])
|
||||
.collect()
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
if !matches!(bpp, 1 | 4 | 8 | 16 | 24 | 32) {
|
||||
return Err(format!("{bpp}-bit bitmaps can't be read"));
|
||||
}
|
||||
let row = stride(w, bpp_u).ok_or_else(bad)?;
|
||||
if bits.len() < row.checked_mul(h as usize).ok_or_else(bad)? {
|
||||
return Err(bad());
|
||||
}
|
||||
let (mr, mg, mb, ma) = match (masks, bpp) {
|
||||
(Some([r, g, b, a]), _) => (r, g, b, a),
|
||||
(None, 16) => (0x7c00, 0x03e0, 0x001f, 0),
|
||||
_ => (0x00ff_0000, 0x0000_ff00, 0x0000_00ff, 0xff00_0000),
|
||||
};
|
||||
let mut pixels = Vec::with_capacity(w as usize * h as usize * 4);
|
||||
for y in 0..h as usize {
|
||||
let src_row = if top_down { y } else { h as usize - 1 - y };
|
||||
let line = bits.get(src_row * row..src_row * row + row).ok_or_else(bad)?;
|
||||
for x in 0..w as usize {
|
||||
let px: [u8; 4] = match bpp {
|
||||
1 | 4 | 8 => {
|
||||
let bit = x * bpp_u as usize;
|
||||
let byte = line.get(bit / 8).copied().unwrap_or(0);
|
||||
let idx = (byte >> (8 - bpp_u as usize - bit % 8)) & ((1u16 << bpp_u) - 1) as u8;
|
||||
let [r, g, b] = palette.get(idx as usize).copied().unwrap_or([0, 0, 0]);
|
||||
[r, g, b, 255]
|
||||
}
|
||||
16 => {
|
||||
let v = u32::from(u16::from_le_bytes([line.get(x * 2).copied().unwrap_or(0), line.get(x * 2 + 1).copied().unwrap_or(0)]));
|
||||
[channel(v, mr), channel(v, mg), channel(v, mb), 255]
|
||||
}
|
||||
24 => {
|
||||
let p = line.get(x * 3..x * 3 + 3).unwrap_or(&[0, 0, 0]);
|
||||
[p[2], p[1], p[0], 255]
|
||||
}
|
||||
_ => {
|
||||
let p = line.get(x * 4..x * 4 + 4).unwrap_or(&[0, 0, 0, 0]);
|
||||
let v = u32::from_le_bytes([p[0], p[1], p[2], p[3]]);
|
||||
let a = if alpha == Alpha::Premultiplied && ma != 0 { channel(v, ma) } else { 255 };
|
||||
let un =
|
||||
|c: u8| if alpha == Alpha::Premultiplied && a < 255 { (u32::from(c) * 255 / u32::from(a.max(1))).min(255) as u8 } else { c };
|
||||
[un(channel(v, mr)), un(channel(v, mg)), un(channel(v, mb)), if alpha == Alpha::Premultiplied { a } else { 255 }]
|
||||
}
|
||||
};
|
||||
pixels.extend_from_slice(&px);
|
||||
}
|
||||
}
|
||||
Ok(Rgba { width: w, height: h, pixels })
|
||||
}
|
||||
|
||||
/// A packed DIB (a BITMAPINFO and its bits, one after the other, as WMF records hold them) split
|
||||
/// into the two.
|
||||
pub(crate) fn split(packed: &[u8]) -> Option<(&[u8], &[u8])> {
|
||||
let mut r = Reader::new(packed);
|
||||
let size = r.u32()? as usize;
|
||||
let (bpp, compression, used, entry) = if size == 12 {
|
||||
r.skip(6)?;
|
||||
(r.u16()?, BI_RGB, 0, 3)
|
||||
} else {
|
||||
r.skip(10)?;
|
||||
let bpp = r.u16()?;
|
||||
let c = r.u32()?;
|
||||
r.skip(12)?;
|
||||
(bpp, c, r.u32()? as usize, 4)
|
||||
};
|
||||
let masks = if compression == BI_BITFIELDS && size == 40 { 12 } else { 0 };
|
||||
let colors = if used > 0 {
|
||||
used.min(256)
|
||||
} else if bpp <= 8 {
|
||||
1usize << bpp
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let len = size.checked_add(masks)?.checked_add(colors * entry)?;
|
||||
Some((packed.get(..len)?, packed.get(len..)?))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn sample() -> Rgba {
|
||||
// 3 × 2: red, green, blue / half-transparent black, transparent, white.
|
||||
let pixels = vec![255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255, 0, 0, 0, 128, 0, 0, 0, 0, 255, 255, 255, 255];
|
||||
Rgba { width: 3, height: 2, pixels }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rgb24_is_bottom_up_bgr_over_white() {
|
||||
let (bmi, bits) = rgb24(&sample(), 1.0);
|
||||
assert_eq!(bmi.len(), 40);
|
||||
assert_eq!(&bmi[14..16], &[24, 0], "24 bits a pixel");
|
||||
// Rows of 9 bytes padded to 12, the last row first: half-transparent black over white is
|
||||
// mid grey, transparent is white.
|
||||
assert_eq!(bits, vec![127, 127, 127, 255, 255, 255, 255, 255, 255, 0, 0, 0, 0, 0, 255, 0, 255, 0, 255, 0, 0, 0, 0, 0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bgra32_is_premultiplied() {
|
||||
let (bmi, bits) = bgra32(&sample(), 1.0);
|
||||
assert_eq!(&bmi[14..16], &[32, 0]);
|
||||
assert_eq!(&bits[..12], &[0, 0, 0, 128, 0, 0, 0, 0, 255, 255, 255, 255]);
|
||||
assert_eq!(&bits[12..16], &[0, 0, 255, 255], "red, as BGRA");
|
||||
// At half opacity.
|
||||
let (_, half) = bgra32(&sample(), 0.5);
|
||||
assert_eq!(&half[12..16], &[0, 0, 128, 128]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rgb24_round_trips_over_white() {
|
||||
let (bmi, bits) = rgb24(&sample(), 1.0);
|
||||
assert_eq!(bmi.len(), 40);
|
||||
// Rows of 9 bytes padded to 12.
|
||||
assert_eq!(bits.len(), 24);
|
||||
let back = decode(&bmi, &bits, Alpha::Ignore).unwrap();
|
||||
assert_eq!((back.width, back.height), (3, 2));
|
||||
assert_eq!(&back.pixels[..12], &[255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255]);
|
||||
// Half-transparent black over white is mid grey; transparent is white.
|
||||
assert_eq!(&back.pixels[12..16], &[127, 127, 127, 255]);
|
||||
assert_eq!(&back.pixels[16..20], &[255, 255, 255, 255]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bgra32_round_trips_with_alpha() {
|
||||
let (bmi, bits) = bgra32(&sample(), 1.0);
|
||||
let back = decode(&bmi, &bits, Alpha::Premultiplied).unwrap();
|
||||
assert_eq!(&back.pixels[..12], &sample().pixels[..12]);
|
||||
assert_eq!(back.pixels[15], 128);
|
||||
assert_eq!(back.pixels[19], 0);
|
||||
// Read as StretchDIBits does, alpha is ignored.
|
||||
assert!(decode(&bmi, &bits, Alpha::Ignore).unwrap().opaque());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn palette_bitmaps_and_damage() {
|
||||
// A 1-bit 2 × 1 bitmap: black then white.
|
||||
let mut bmi = header(2, 1, 1, 4);
|
||||
bmi.extend_from_slice(&[0, 0, 0, 0, 255, 255, 255, 0]);
|
||||
let back = decode(&bmi, &[0b0100_0000, 0, 0, 0], Alpha::Ignore).unwrap();
|
||||
assert_eq!(back.pixels, vec![0, 0, 0, 255, 255, 255, 255, 255]);
|
||||
// Too few bits, a silly size, an unknown depth: errors, no panic.
|
||||
assert!(decode(&bmi, &[0], Alpha::Ignore).is_err());
|
||||
assert!(decode(&header(1 << 20, 1 << 20, 24, 0), &[], Alpha::Ignore).is_err());
|
||||
assert!(decode(&header(1, 1, 7, 0), &[0; 4], Alpha::Ignore).is_err());
|
||||
assert!(decode(&[1, 2], &[], Alpha::Ignore).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn large_images_are_scaled_to_fit() {
|
||||
let img = image::RgbaImage::new(MAX_SIDE * 2, 4);
|
||||
let f = Rgba::fitted(img);
|
||||
assert_eq!((f.width, f.height), (MAX_SIDE, 2));
|
||||
}
|
||||
}
|
||||
|
|
@ -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,938 @@
|
|||
//! The metafile reader: the format parsers (`emf`, `wmf`) play their records on a [`Player`], a
|
||||
//! small model of the device context they draw into (objects, transforms, the current position,
|
||||
//! the path bracket, the clip), which turns what they draw into document objects.
|
||||
//!
|
||||
//! Fills and strokes become paths with a fill and a stroke; lines drawn one after another with the
|
||||
//! same pen join into one path. Clips become clipping groups around what they clip. Images keep
|
||||
//! their pixels (as PNG), text becomes point type in the font the file names. Records the reader
|
||||
//! doesn't know are counted and skipped, with one warning.
|
||||
|
||||
mod emf;
|
||||
mod wmf;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::{
|
||||
Appearance, AppearanceItem, CharStyle, Dash, Document, FillLayer, ImageBlob, ImageObject, Justify, LayerColor, LineCap, LineJoin, Node, NodeKind,
|
||||
StrokeLayer, TextObject,
|
||||
};
|
||||
use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, PathEl, Point, Rect, Shape, Vec2, shapes};
|
||||
|
||||
use crate::dib::Rgba;
|
||||
use crate::{Imported, Kind};
|
||||
|
||||
/// Most objects an import makes (a hostile file can't exhaust memory with them).
|
||||
const MAX_NODES: usize = 1 << 20;
|
||||
/// Objects and clips reaching further than this from the origin (points, well inside the canvas)
|
||||
/// are left out: they come from damaged or hostile coordinates.
|
||||
const LIMIT: f64 = 1e6;
|
||||
/// Most nested saved device contexts.
|
||||
const MAX_SAVED: usize = 1024;
|
||||
/// Width of a cosmetic pen (one pixel of a 96 ppi screen), points.
|
||||
const HAIRLINE: f64 = 0.75;
|
||||
/// Curves made for arcs and ellipses are this close to the true curve (logical units are
|
||||
/// transformed after, so it is relative).
|
||||
const ARC_TOLERANCE: f64 = 0.01;
|
||||
|
||||
/// Raster operations.
|
||||
pub(crate) const SRCCOPY: u32 = 0x00CC_0020;
|
||||
const PATCOPY: u32 = 0x00F0_0021;
|
||||
const WHITENESS: u32 = 0x00FF_0062;
|
||||
const BLACKNESS: u32 = 0x0000_0042;
|
||||
|
||||
const SKIPPED_ROPS: &str = "images drawn with raster operations other than a plain copy (masks, inversions) were left out";
|
||||
const HATCHES: &str = "hatched brushes are filled with their colour";
|
||||
const PATTERN_BRUSHES: &str = "pattern brushes are filled grey";
|
||||
const EXCLUDED_CLIPS: &str = "clip areas cut out of the clip (excluded rectangles, differences) are ignored";
|
||||
const BAD_IMAGE: &str = "an image that couldn't be read was left out";
|
||||
const FAR_AWAY: &str = "objects far outside the picture were left out";
|
||||
|
||||
/// Is `r` finite and within [`LIMIT`]?
|
||||
fn sane(r: Rect) -> bool {
|
||||
[r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite() && v.abs() <= LIMIT)
|
||||
}
|
||||
|
||||
/// A pen: its style flags (`PS_*`), width in logical units, colour and dash entries.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) struct PenObj {
|
||||
pub style: u32,
|
||||
pub width: f64,
|
||||
pub color: [u8; 3],
|
||||
/// PS_USERSTYLE entries, logical units.
|
||||
pub entries: Vec<f64>,
|
||||
}
|
||||
|
||||
impl PenObj {
|
||||
pub const NULL: PenObj = PenObj { style: PS_NULL, width: 0.0, color: [0; 3], entries: vec![] };
|
||||
pub fn solid(color: [u8; 3]) -> Self {
|
||||
Self { style: 0, width: 0.0, color, entries: vec![] }
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) const PS_NULL: u32 = 5;
|
||||
const PS_GEOMETRIC: u32 = 0x0001_0000;
|
||||
|
||||
/// A brush.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) enum BrushObj {
|
||||
Null,
|
||||
Solid([u8; 3]),
|
||||
Hatched([u8; 3]),
|
||||
/// A bitmap pattern (written grey).
|
||||
Pattern,
|
||||
}
|
||||
|
||||
impl BrushObj {
|
||||
/// From a LOGBRUSH's style and colour.
|
||||
pub fn from_log(style: u32, color: [u8; 3]) -> Self {
|
||||
match style {
|
||||
0 => Self::Solid(color),
|
||||
1 => Self::Null,
|
||||
2 => Self::Hatched(color),
|
||||
_ => Self::Pattern,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A font: its height in logical units (negative: the character height, positive: the cell
|
||||
/// height), escapement (tenths of a degree), weight, italics and face.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub(crate) struct FontObj {
|
||||
pub height: f64,
|
||||
pub escapement: f64,
|
||||
pub weight: i32,
|
||||
pub italic: bool,
|
||||
pub face: String,
|
||||
}
|
||||
|
||||
/// A GDI object.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) enum Obj {
|
||||
Pen(PenObj),
|
||||
Brush(BrushObj),
|
||||
Font(FontObj),
|
||||
/// Palettes, regions, colour spaces: they take a slot but don't change the drawing.
|
||||
Other,
|
||||
}
|
||||
|
||||
/// A clip: a region in document space and its rule. Clips nest; each has an id so what one clips
|
||||
/// can be grouped.
|
||||
#[derive(Debug)]
|
||||
struct Clip {
|
||||
id: u32,
|
||||
path: BezPath,
|
||||
rule: FillRule,
|
||||
}
|
||||
|
||||
/// The state records change, saved and restored as a whole.
|
||||
#[derive(Clone, Debug)]
|
||||
struct Dc {
|
||||
pen: PenObj,
|
||||
brush: BrushObj,
|
||||
font: FontObj,
|
||||
text_color: [u8; 3],
|
||||
text_align: u32,
|
||||
fill_rule: FillRule,
|
||||
miter: f64,
|
||||
/// Arcs run counterclockwise (the default).
|
||||
arc_ccw: bool,
|
||||
/// Logical → page (EMF world transform).
|
||||
world: Affine,
|
||||
map_mode: u32,
|
||||
win_org: Vec2,
|
||||
win_ext: Vec2,
|
||||
vp_org: Vec2,
|
||||
vp_ext: Vec2,
|
||||
clips: Vec<Arc<Clip>>,
|
||||
}
|
||||
|
||||
impl Default for Dc {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
pen: PenObj::solid([0; 3]),
|
||||
brush: BrushObj::Solid([255; 3]),
|
||||
font: FontObj::default(),
|
||||
text_color: [0; 3],
|
||||
text_align: 0,
|
||||
fill_rule: FillRule::EvenOdd,
|
||||
miter: 10.0,
|
||||
arc_ccw: true,
|
||||
world: Affine::IDENTITY,
|
||||
map_mode: 1,
|
||||
win_org: Vec2::ZERO,
|
||||
win_ext: Vec2::new(1.0, 1.0),
|
||||
vp_org: Vec2::ZERO,
|
||||
vp_ext: Vec2::new(1.0, 1.0),
|
||||
clips: vec![],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How an arc closes.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum ArcKind {
|
||||
Open,
|
||||
Chord,
|
||||
Pie,
|
||||
}
|
||||
|
||||
pub(crate) struct Player {
|
||||
kind: Kind,
|
||||
/// Device units (EMF) or logical units (WMF) → document points.
|
||||
dev_to_doc: Affine,
|
||||
/// Millimetres per device pixel (EMF's fixed mapping modes).
|
||||
mm_per_px: f64,
|
||||
dc: Dc,
|
||||
saved: Vec<Dc>,
|
||||
objects: Vec<Option<Obj>>,
|
||||
/// The lowest object slot that may be free (WMF numbering).
|
||||
free_hint: usize,
|
||||
/// The path being recorded (document space), whether a figure is open in it and where that
|
||||
/// figure started (logical).
|
||||
path: Option<BezPath>,
|
||||
figure: bool,
|
||||
figure_start: Point,
|
||||
/// The artboard (document space): clips holding all of it clip nothing worth keeping.
|
||||
frame: Rect,
|
||||
/// The current position (logical).
|
||||
pos: Point,
|
||||
/// Lines drawn one after another outside a path bracket, not drawn yet.
|
||||
pending: BezPath,
|
||||
out: Drawn,
|
||||
doc: Document,
|
||||
warnings: Vec<String>,
|
||||
skipped: usize,
|
||||
next_clip: u32,
|
||||
/// The file holds EMF+ records (which aren't read).
|
||||
pub emf_plus: bool,
|
||||
}
|
||||
|
||||
fn rgb(c: [u8; 3]) -> Paint {
|
||||
Paint::solid(Color::rgb8(c[0], c[1], c[2]))
|
||||
}
|
||||
|
||||
/// The arc of the ellipse in `r` from the ray through `start` to the ray through `end`.
|
||||
pub(crate) fn arc_path(r: Rect, start: Point, end: Point, ccw: bool, kind: ArcKind) -> BezPath {
|
||||
let r = r.abs();
|
||||
let c = r.center();
|
||||
let radii = Vec2::new(r.width() / 2.0, r.height() / 2.0);
|
||||
let mut bp = BezPath::new();
|
||||
if radii.x <= 0.0 || radii.y <= 0.0 {
|
||||
return bp;
|
||||
}
|
||||
let angle = |p: Point| ((p.y - c.y) / radii.y).atan2((p.x - c.x) / radii.x);
|
||||
let (a0, a1) = (angle(start), angle(end));
|
||||
let tau = std::f64::consts::TAU;
|
||||
// Counterclockwise on a y-down device is towards smaller angles.
|
||||
let sweep = if ccw {
|
||||
let s = (a0 - a1).rem_euclid(tau);
|
||||
-(if s == 0.0 { tau } else { s })
|
||||
} else {
|
||||
let s = (a1 - a0).rem_euclid(tau);
|
||||
if s == 0.0 { tau } else { s }
|
||||
};
|
||||
let arc = kurbo::Arc { center: c, radii, start_angle: a0, sweep_angle: sweep, x_rotation: 0.0 };
|
||||
let tol = ARC_TOLERANCE * radii.x.max(radii.y).max(1e-9);
|
||||
if kind == ArcKind::Pie {
|
||||
bp.move_to(c);
|
||||
let first = c + Vec2::new(radii.x * a0.cos(), radii.y * a0.sin());
|
||||
bp.line_to(first);
|
||||
arc.append_iter(tol).for_each(|el| bp.push(el));
|
||||
} else {
|
||||
kurbo::Shape::path_elements(&arc, tol).for_each(|el| bp.push(el));
|
||||
}
|
||||
if kind != ArcKind::Open {
|
||||
bp.close_path();
|
||||
}
|
||||
bp
|
||||
}
|
||||
|
||||
/// A rectangle with elliptical corners `rx` × `ry`.
|
||||
pub(crate) fn round_rect(r: Rect, rx: f64, ry: f64) -> BezPath {
|
||||
let r = r.abs();
|
||||
let (rx, ry) = ((rx / 2.0).abs().min(r.width() / 2.0), (ry / 2.0).abs().min(r.height() / 2.0));
|
||||
if rx <= 0.0 || ry <= 0.0 {
|
||||
return shapes::rectangle(r).to_bezpath();
|
||||
}
|
||||
// A rectangle with round corners of radius `ry`, squeezed across.
|
||||
let s = ry / rx;
|
||||
let squeezed = Rect::new(r.x0 * s, r.y0, r.x1 * s, r.y1);
|
||||
let mut bp = shapes::rounded_rectangle(squeezed, ry).to_bezpath();
|
||||
bp.apply_affine(Affine::scale_non_uniform(1.0 / s, 1.0));
|
||||
bp
|
||||
}
|
||||
|
||||
impl Player {
|
||||
fn new(kind: Kind, dev_to_doc: Affine, mm_per_px: f64, artboard: Rect) -> Self {
|
||||
let side = |v: f64| if v.is_finite() { v.clamp(1.0, LIMIT) } else { 1.0 };
|
||||
let (w, h) = (side(artboard.width()), side(artboard.height()));
|
||||
let mut doc = Document::new(w, h);
|
||||
doc.layers.clear();
|
||||
Self {
|
||||
kind,
|
||||
dev_to_doc,
|
||||
mm_per_px,
|
||||
dc: Dc::default(),
|
||||
saved: vec![],
|
||||
objects: vec![],
|
||||
free_hint: 0,
|
||||
path: None,
|
||||
figure: false,
|
||||
figure_start: Point::ZERO,
|
||||
frame: artboard,
|
||||
pos: Point::ZERO,
|
||||
pending: BezPath::new(),
|
||||
out: vec![],
|
||||
doc,
|
||||
warnings: vec![],
|
||||
skipped: 0,
|
||||
next_clip: 0,
|
||||
emf_plus: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn warn(&mut self, w: &str) {
|
||||
if !self.warnings.iter().any(|x| x == w) {
|
||||
self.warnings.push(w.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
/// A record of a kind the reader doesn't know.
|
||||
pub fn skip(&mut self) {
|
||||
self.skipped += 1;
|
||||
}
|
||||
|
||||
// ---------- mapping ----------
|
||||
|
||||
/// Logical → document.
|
||||
fn xf(&self) -> Affine {
|
||||
if self.kind == Kind::Wmf {
|
||||
return self.dev_to_doc;
|
||||
}
|
||||
let d = &self.dc;
|
||||
let page_to_dev = match d.map_mode {
|
||||
// Fixed modes: millimetres a unit, y up.
|
||||
2..=6 => {
|
||||
let mm = [0.1, 0.01, 0.254, 0.0254, 25.4 / 1440.0][(d.map_mode - 2) as usize];
|
||||
let s = mm / self.mm_per_px.max(1e-12);
|
||||
Affine::translate(d.vp_org) * Affine::scale_non_uniform(s, -s) * Affine::translate(-d.win_org)
|
||||
}
|
||||
7 | 8 => {
|
||||
let ratio = |v: f64, w: f64| if w.abs() > 1e-12 && v.is_finite() { v / w } else { 1.0 };
|
||||
let (mut sx, mut sy) = (ratio(d.vp_ext.x, d.win_ext.x), ratio(d.vp_ext.y, d.win_ext.y));
|
||||
if d.map_mode == 7 {
|
||||
// Isotropic: the same scale both ways, the smaller one.
|
||||
let s = sx.abs().min(sy.abs());
|
||||
(sx, sy) = (s.copysign(sx), s.copysign(sy));
|
||||
}
|
||||
Affine::translate(d.vp_org) * Affine::scale_non_uniform(sx, sy) * Affine::translate(-d.win_org)
|
||||
}
|
||||
_ => Affine::translate(d.vp_org - d.win_org),
|
||||
};
|
||||
self.dev_to_doc * page_to_dev * d.world
|
||||
}
|
||||
|
||||
/// How much logical lengths grow in the document.
|
||||
fn scale(&self) -> f64 {
|
||||
let s = self.xf().determinant().abs().sqrt();
|
||||
if s.is_finite() { s } else { 0.0 }
|
||||
}
|
||||
|
||||
pub fn set_map_mode(&mut self, m: u32) {
|
||||
self.dc.map_mode = m;
|
||||
}
|
||||
pub fn set_window_org(&mut self, x: f64, y: f64) {
|
||||
self.dc.win_org = Vec2::new(x, y);
|
||||
}
|
||||
pub fn set_window_ext(&mut self, x: f64, y: f64) {
|
||||
self.dc.win_ext = Vec2::new(x, y);
|
||||
}
|
||||
pub fn set_viewport_org(&mut self, x: f64, y: f64) {
|
||||
self.dc.vp_org = Vec2::new(x, y);
|
||||
}
|
||||
pub fn set_viewport_ext(&mut self, x: f64, y: f64) {
|
||||
self.dc.vp_ext = Vec2::new(x, y);
|
||||
}
|
||||
pub fn scale_ext(&mut self, window: bool, x: (f64, f64), y: (f64, f64)) {
|
||||
let e = if window { &mut self.dc.win_ext } else { &mut self.dc.vp_ext };
|
||||
if x.1 != 0.0 && y.1 != 0.0 {
|
||||
*e = Vec2::new(e.x * x.0 / x.1, e.y * y.0 / y.1);
|
||||
}
|
||||
}
|
||||
/// `SetWorldTransform` / `ModifyWorldTransform` (`mode`: 1 identity, 2 left, 3 right, 4 set).
|
||||
pub fn world(&mut self, x: Affine, mode: u32) {
|
||||
if !x.as_coeffs().iter().all(|v| v.is_finite()) {
|
||||
return;
|
||||
}
|
||||
self.dc.world = match mode {
|
||||
1 => Affine::IDENTITY,
|
||||
2 => self.dc.world * x,
|
||||
3 => x * self.dc.world,
|
||||
_ => x,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------- state ----------
|
||||
|
||||
pub fn set_fill_mode(&mut self, alternate: bool) {
|
||||
self.dc.fill_rule = if alternate { FillRule::EvenOdd } else { FillRule::NonZero };
|
||||
}
|
||||
pub fn set_miter(&mut self, m: f64) {
|
||||
if m.is_finite() && m >= 1.0 {
|
||||
self.dc.miter = m;
|
||||
}
|
||||
}
|
||||
pub fn set_arc_ccw(&mut self, ccw: bool) {
|
||||
self.dc.arc_ccw = ccw;
|
||||
}
|
||||
pub fn set_text_color(&mut self, c: [u8; 3]) {
|
||||
self.dc.text_color = c;
|
||||
}
|
||||
pub fn set_text_align(&mut self, a: u32) {
|
||||
self.dc.text_align = a;
|
||||
}
|
||||
|
||||
pub fn save(&mut self) {
|
||||
if self.saved.len() < MAX_SAVED {
|
||||
self.saved.push(self.dc.clone());
|
||||
}
|
||||
}
|
||||
|
||||
/// `RestoreDC(n)`: a negative `n` counts back from the latest save, a positive one is a save's
|
||||
/// number.
|
||||
pub fn restore(&mut self, n: i32) {
|
||||
let len = self.saved.len();
|
||||
let keep = if n < 0 { len.checked_sub(n.unsigned_abs() as usize) } else { (n as usize).checked_sub(1).filter(|k| *k < len) };
|
||||
if let Some(k) = keep {
|
||||
self.saved.truncate(k + 1);
|
||||
if let Some(dc) = self.saved.pop() {
|
||||
self.dc = dc;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- objects ----------
|
||||
|
||||
/// Put `obj` in slot `index` (EMF numbers its objects).
|
||||
pub fn create_at(&mut self, index: u32, obj: Obj) {
|
||||
let i = index as usize;
|
||||
if i == 0 || i > u16::MAX as usize {
|
||||
return;
|
||||
}
|
||||
if self.objects.len() <= i {
|
||||
self.objects.resize(i + 1, None);
|
||||
}
|
||||
if let Some(s) = self.objects.get_mut(i) {
|
||||
*s = Some(obj);
|
||||
}
|
||||
}
|
||||
|
||||
/// Put `obj` in the lowest free slot (WMF).
|
||||
pub fn create(&mut self, obj: Obj) {
|
||||
let start = self.free_hint.min(self.objects.len());
|
||||
let i = self.objects.iter().skip(start).position(Option::is_none).map_or(self.objects.len(), |p| p + start);
|
||||
if i > u16::MAX as usize {
|
||||
return;
|
||||
}
|
||||
if i == self.objects.len() {
|
||||
self.objects.push(None);
|
||||
}
|
||||
if let Some(s) = self.objects.get_mut(i) {
|
||||
*s = Some(obj);
|
||||
}
|
||||
self.free_hint = i + 1;
|
||||
}
|
||||
|
||||
pub fn delete(&mut self, index: u32) {
|
||||
if let Some(s) = self.objects.get_mut(index as usize) {
|
||||
*s = None;
|
||||
self.free_hint = self.free_hint.min(index as usize);
|
||||
}
|
||||
}
|
||||
|
||||
/// Select object `index`, or a stock object (EMF: the high bit set).
|
||||
pub fn select(&mut self, index: u32) {
|
||||
if self.kind == Kind::Emf && index & 0x8000_0000 != 0 {
|
||||
match index & 0x7fff_ffff {
|
||||
0 | 18 => self.dc.brush = BrushObj::Solid([255; 3]),
|
||||
1 => self.dc.brush = BrushObj::Solid([0xc0; 3]),
|
||||
2 => self.dc.brush = BrushObj::Solid([0x80; 3]),
|
||||
3 => self.dc.brush = BrushObj::Solid([0x40; 3]),
|
||||
4 => self.dc.brush = BrushObj::Solid([0; 3]),
|
||||
5 => self.dc.brush = BrushObj::Null,
|
||||
6 => self.dc.pen = PenObj::solid([255; 3]),
|
||||
7 | 19 => self.dc.pen = PenObj::solid([0; 3]),
|
||||
8 => self.dc.pen = PenObj::NULL,
|
||||
10..=17 => self.dc.font = FontObj::default(),
|
||||
_ => {}
|
||||
}
|
||||
return;
|
||||
}
|
||||
match self.objects.get(index as usize).cloned().flatten() {
|
||||
Some(Obj::Pen(p)) => self.dc.pen = p,
|
||||
Some(Obj::Brush(b)) => self.dc.brush = b,
|
||||
Some(Obj::Font(f)) => self.dc.font = f,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- output ----------
|
||||
|
||||
fn push(&mut self, mut node: Node) {
|
||||
if self.out.len() >= MAX_NODES {
|
||||
return;
|
||||
}
|
||||
if !node.visual_bounds().is_none_or(sane) {
|
||||
return self.warn(FAR_AWAY);
|
||||
}
|
||||
node.id = self.doc.alloc_id();
|
||||
self.out.push((self.dc.clips.clone(), node));
|
||||
}
|
||||
|
||||
fn fill_paint(&mut self) -> Option<Paint> {
|
||||
match self.dc.brush.clone() {
|
||||
BrushObj::Null => None,
|
||||
BrushObj::Solid(c) => Some(rgb(c)),
|
||||
BrushObj::Hatched(c) => {
|
||||
self.warn(HATCHES);
|
||||
Some(rgb(c))
|
||||
}
|
||||
BrushObj::Pattern => {
|
||||
self.warn(PATTERN_BRUSHES);
|
||||
Some(rgb([0x80; 3]))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn stroke_layer(&self) -> Option<StrokeLayer> {
|
||||
let p = &self.dc.pen;
|
||||
let kind = p.style & 0xf;
|
||||
if kind == PS_NULL {
|
||||
return None;
|
||||
}
|
||||
// Geometric pens, and other pens wider than 1, have a width in logical units; the others
|
||||
// are one pixel wide.
|
||||
let w = p.width * self.scale();
|
||||
let width = if w > 0.0 && w.is_finite() && (p.style & PS_GEOMETRIC != 0 || p.width > 1.0) { w } else { HAIRLINE };
|
||||
let mut st = StrokeLayer::new(rgb(p.color), width);
|
||||
// Round caps and joins unless the style asks for others.
|
||||
st.cap = match p.style & 0xf00 {
|
||||
0x100 => LineCap::Square,
|
||||
0x200 => LineCap::Butt,
|
||||
_ => LineCap::Round,
|
||||
};
|
||||
st.join = match p.style & 0xf000 {
|
||||
0x1000 => LineJoin::Bevel,
|
||||
0x2000 => LineJoin::Miter,
|
||||
_ => LineJoin::Round,
|
||||
};
|
||||
st.miter_limit = self.dc.miter;
|
||||
let unit = width.max(HAIRLINE);
|
||||
let pattern: Vec<f64> = match kind {
|
||||
1 => vec![3.0 * unit, unit],
|
||||
2 => vec![unit, unit],
|
||||
3 => vec![3.0 * unit, unit, unit, unit],
|
||||
4 => vec![3.0 * unit, unit, unit, unit, unit, unit],
|
||||
7 => p.entries.iter().map(|e| e * self.scale()).collect(),
|
||||
_ => vec![],
|
||||
};
|
||||
let dash = Dash { pattern, offset: 0.0, align_corners: false };
|
||||
st.dash = dash.is_dashed().then_some(dash);
|
||||
Some(st)
|
||||
}
|
||||
|
||||
/// Paint `bp` (document space) with the brush and the pen.
|
||||
fn draw(&mut self, bp: BezPath, fill: bool, stroke: bool) {
|
||||
if bp.elements().is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut items = vec![];
|
||||
if fill && let Some(p) = self.fill_paint() {
|
||||
items.push(AppearanceItem::Fill(FillLayer::new(p)));
|
||||
}
|
||||
if stroke && let Some(st) = self.stroke_layer() {
|
||||
items.push(AppearanceItem::Stroke(st));
|
||||
}
|
||||
if items.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut n = Node::path(vectorcraft_doc::NodeId(0), PathData::from_bezpath(&bp), Appearance { items, ..Appearance::default() });
|
||||
if let NodeKind::Path { rule, .. } = &mut n.kind {
|
||||
*rule = self.dc.fill_rule;
|
||||
}
|
||||
self.push(n);
|
||||
}
|
||||
|
||||
/// Draw the lines drawn one after another so far.
|
||||
pub fn flush(&mut self) {
|
||||
if !self.pending.elements().is_empty() {
|
||||
let bp = std::mem::take(&mut self.pending);
|
||||
self.draw(bp, false, true);
|
||||
}
|
||||
}
|
||||
|
||||
/// A figure in logical units: added to the path being recorded, else drawn (filled when it is
|
||||
/// an area).
|
||||
pub fn figure(&mut self, mut bp: BezPath, area: bool) {
|
||||
bp.apply_affine(self.xf());
|
||||
match &mut self.path {
|
||||
Some(path) => {
|
||||
path.extend(bp);
|
||||
self.figure = false;
|
||||
}
|
||||
None => self.draw(bp, area, true),
|
||||
}
|
||||
}
|
||||
|
||||
/// Polygon / Polyline (and Bézier ones): `pts` in logical units.
|
||||
pub fn poly(&mut self, pts: &[Point], closed: bool, bezier: bool) {
|
||||
let Some((first, rest)) = pts.split_first() else { return };
|
||||
let mut bp = BezPath::new();
|
||||
bp.move_to(*first);
|
||||
if bezier {
|
||||
for c in rest.chunks_exact(3) {
|
||||
bp.curve_to(c[0], c[1], c[2]);
|
||||
}
|
||||
} else {
|
||||
rest.iter().for_each(|p| bp.line_to(*p));
|
||||
}
|
||||
if closed {
|
||||
bp.close_path();
|
||||
}
|
||||
self.figure(bp, closed);
|
||||
}
|
||||
|
||||
/// Several polygons (one area) or polylines.
|
||||
pub fn poly_poly(&mut self, polys: &[Vec<Point>], closed: bool) {
|
||||
let mut bp = BezPath::new();
|
||||
for p in polys {
|
||||
let Some((first, rest)) = p.split_first() else { continue };
|
||||
bp.move_to(*first);
|
||||
rest.iter().for_each(|q| bp.line_to(*q));
|
||||
if closed {
|
||||
bp.close_path();
|
||||
}
|
||||
}
|
||||
self.figure(bp, closed);
|
||||
}
|
||||
|
||||
pub fn move_to(&mut self, p: Point) {
|
||||
self.pos = p;
|
||||
self.figure = false;
|
||||
}
|
||||
|
||||
/// LineTo / PolylineTo / PolyBezierTo: from the current position through `pts` (logical).
|
||||
pub fn poly_to(&mut self, pts: &[Point], bezier: bool) {
|
||||
let Some(last) = pts.last().copied() else { return };
|
||||
let xf = self.xf();
|
||||
let start = xf * self.pos;
|
||||
let target = match &mut self.path {
|
||||
Some(path) => {
|
||||
if !self.figure {
|
||||
path.move_to(start);
|
||||
self.figure = true;
|
||||
self.figure_start = self.pos;
|
||||
}
|
||||
path
|
||||
}
|
||||
None => {
|
||||
if self.pending.elements().last().and_then(|e| e.end_point()) != Some(start) {
|
||||
self.pending.move_to(start);
|
||||
}
|
||||
&mut self.pending
|
||||
}
|
||||
};
|
||||
if bezier {
|
||||
for c in pts.chunks_exact(3) {
|
||||
target.curve_to(xf * c[0], xf * c[1], xf * c[2]);
|
||||
}
|
||||
} else {
|
||||
pts.iter().for_each(|p| target.line_to(xf * *p));
|
||||
}
|
||||
self.pos = last;
|
||||
}
|
||||
|
||||
pub fn arc(&mut self, r: Rect, start: Point, end: Point, kind: ArcKind) {
|
||||
let bp = arc_path(r, start, end, self.dc.arc_ccw, kind);
|
||||
self.figure(bp, kind != ArcKind::Open);
|
||||
}
|
||||
|
||||
// ---------- paths and clips ----------
|
||||
|
||||
pub fn begin_path(&mut self) {
|
||||
self.path = Some(BezPath::new());
|
||||
self.figure = false;
|
||||
}
|
||||
pub fn end_path(&mut self) {
|
||||
// The path stays until it is filled, stroked or made the clip.
|
||||
}
|
||||
pub fn close_figure(&mut self) {
|
||||
if let Some(p) = &mut self.path
|
||||
&& self.figure
|
||||
{
|
||||
p.close_path();
|
||||
self.figure = false;
|
||||
self.pos = self.figure_start;
|
||||
}
|
||||
}
|
||||
pub fn abort_path(&mut self) {
|
||||
self.path = None;
|
||||
}
|
||||
|
||||
/// FillPath / StrokePath / StrokeAndFillPath.
|
||||
pub fn paint_path(&mut self, fill: bool, stroke: bool) {
|
||||
if let Some(mut bp) = self.path.take() {
|
||||
// Filling closes the open figure.
|
||||
if fill && self.figure {
|
||||
bp.close_path();
|
||||
}
|
||||
self.draw(bp, fill, stroke);
|
||||
}
|
||||
}
|
||||
|
||||
fn push_clip(&mut self, path: BezPath, rule: FillRule, replace: bool) {
|
||||
if replace {
|
||||
self.dc.clips.clear();
|
||||
}
|
||||
if !sane(path.bounding_box()) {
|
||||
return self.warn(FAR_AWAY);
|
||||
}
|
||||
// A rectangle around the whole picture (what writers clip to first) clips nothing in it.
|
||||
let lines = path.elements().iter().all(|e| !matches!(e, PathEl::QuadTo(..) | PathEl::CurveTo(..)));
|
||||
let around = path.bounding_box().inflate(0.5, 0.5);
|
||||
if lines && path.elements().len() <= 6 && around.contains(self.frame.origin()) && around.contains(Point::new(self.frame.x1, self.frame.y1)) {
|
||||
return;
|
||||
}
|
||||
self.next_clip += 1;
|
||||
self.dc.clips.push(Arc::new(Clip { id: self.next_clip, path, rule }));
|
||||
}
|
||||
|
||||
/// SelectClipPath (`mode`: 1 and, 5 copy; the others cut out, which isn't read).
|
||||
pub fn clip_path(&mut self, mode: u32) {
|
||||
let Some(bp) = self.path.take() else { return };
|
||||
match mode {
|
||||
1 => self.push_clip(bp, self.dc.fill_rule, false),
|
||||
5 => self.push_clip(bp, self.dc.fill_rule, true),
|
||||
_ => self.warn(EXCLUDED_CLIPS),
|
||||
}
|
||||
}
|
||||
|
||||
/// IntersectClipRect (logical units).
|
||||
pub fn clip_rect(&mut self, r: Rect) {
|
||||
let mut bp = shapes::rectangle(r.abs()).to_bezpath();
|
||||
bp.apply_affine(self.xf());
|
||||
self.push_clip(bp, FillRule::NonZero, false);
|
||||
}
|
||||
|
||||
/// A clip region of `rects` in device units (`mode` as [`Self::clip_path`]); none with mode
|
||||
/// copy removes the clip.
|
||||
pub fn clip_region(&mut self, rects: &[Rect], mode: u32) {
|
||||
if rects.is_empty() {
|
||||
if mode == 5 {
|
||||
self.dc.clips.clear();
|
||||
}
|
||||
return;
|
||||
}
|
||||
let mut bp = BezPath::new();
|
||||
for r in rects {
|
||||
bp.extend(shapes::rectangle(r.abs()).to_bezpath());
|
||||
}
|
||||
bp.apply_affine(self.dev_to_doc);
|
||||
match mode {
|
||||
1 => self.push_clip(bp, FillRule::NonZero, false),
|
||||
5 => self.push_clip(bp, FillRule::NonZero, true),
|
||||
_ => self.warn(EXCLUDED_CLIPS),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn exclude_clip(&mut self) {
|
||||
self.warn(EXCLUDED_CLIPS);
|
||||
}
|
||||
|
||||
// ---------- blits, images and text ----------
|
||||
|
||||
/// A raster operation without a source over `dest` (logical): the brush, white or black.
|
||||
pub fn pattern_blit(&mut self, dest: Rect, rop: u32) {
|
||||
let brush = match rop {
|
||||
PATCOPY => self.dc.brush.clone(),
|
||||
WHITENESS => BrushObj::Solid([255; 3]),
|
||||
BLACKNESS => BrushObj::Solid([0; 3]),
|
||||
_ => return self.skip(),
|
||||
};
|
||||
let pen = std::mem::replace(&mut self.dc.pen, PenObj::NULL);
|
||||
let outer = std::mem::replace(&mut self.dc.brush, brush);
|
||||
let mut bp = shapes::rectangle(dest.abs()).to_bezpath();
|
||||
bp.apply_affine(self.xf());
|
||||
self.draw(bp, true, false);
|
||||
self.dc.pen = pen;
|
||||
self.dc.brush = outer;
|
||||
}
|
||||
|
||||
/// An image whose source rectangle `src` (pixels, from the top-left) is drawn into `dest`
|
||||
/// (logical: origin and signed size) with raster operation `rop`, at `opacity`.
|
||||
pub fn image(&mut self, img: Result<Rgba, String>, src: Rect, dest: Rect, rop: u32, opacity: f64) {
|
||||
if rop != SRCCOPY {
|
||||
self.warn(SKIPPED_ROPS);
|
||||
return;
|
||||
}
|
||||
let img = match img {
|
||||
Ok(i) => i,
|
||||
Err(_) => return self.warn(BAD_IMAGE),
|
||||
};
|
||||
let img = crop(img, src);
|
||||
if img.width == 0 || img.height == 0 {
|
||||
return;
|
||||
}
|
||||
let Ok(png) = img.to_png() else { return self.warn(BAD_IMAGE) };
|
||||
let blob = ImageBlob::new("image/png", png);
|
||||
let key = blob.content_key();
|
||||
self.doc.images.entry(key.clone()).or_insert(blob);
|
||||
let (w, h) = (f64::from(img.width), f64::from(img.height));
|
||||
let xf = self.xf() * Affine::translate((dest.x0, dest.y0)) * Affine::scale_non_uniform(dest.width() / w, dest.height() / h);
|
||||
if !xf.as_coeffs().iter().all(|v| v.is_finite()) || xf.determinant().abs() < 1e-12 {
|
||||
return;
|
||||
}
|
||||
let im = ImageObject { key, width: img.width, height: img.height, xf, link: None, placement: Default::default() };
|
||||
let mut n = Node::new(vectorcraft_doc::NodeId(0), NodeKind::Image(im));
|
||||
n.opacity = opacity.clamp(0.0, 1.0) as f32;
|
||||
self.push(n);
|
||||
}
|
||||
|
||||
/// Text at `at` (logical; the current position with TA_UPDATECP) in the current font.
|
||||
pub fn text(&mut self, at: Point, s: &str) {
|
||||
let s: String = s.chars().filter(|c| *c != '\0').collect();
|
||||
if s.trim().is_empty() {
|
||||
return;
|
||||
}
|
||||
let align = self.dc.text_align;
|
||||
let at = if align & 1 != 0 { self.pos } else { at };
|
||||
let f = self.dc.font.clone();
|
||||
let k = self.scale();
|
||||
// A negative height is the em; a positive one the cell (the em and the internal leading).
|
||||
let em = if f.height < 0.0 { -f.height } else { f.height * 0.85 };
|
||||
let size = if em > 0.0 && (em * k).is_finite() { (em * k).clamp(0.5, 5000.0) } else { 12.0 };
|
||||
let mut style = CharStyle { size, fill: rgb(self.dc.text_color), stroke: Paint::None, ..CharStyle::default() };
|
||||
if !f.face.trim().is_empty() {
|
||||
style.font_family = f.face.trim().to_string();
|
||||
}
|
||||
style.font_style = match (f.weight >= 600, f.italic) {
|
||||
(true, true) => "Bold Italic",
|
||||
(true, false) => "Bold",
|
||||
(false, true) => "Italic",
|
||||
(false, false) => "Regular",
|
||||
}
|
||||
.into();
|
||||
let angle = -(f.escapement / 10.0).to_radians();
|
||||
let angle = if angle.is_finite() { angle } else { 0.0 };
|
||||
// TA_BASELINE, TA_BOTTOM or TA_TOP (the default): where the baseline is.
|
||||
let drop = match align & 0x18 {
|
||||
0x18 => 0.0,
|
||||
0x08 => -0.2 * size,
|
||||
_ => 0.8 * size,
|
||||
};
|
||||
let origin = self.xf() * at;
|
||||
let mut t = TextObject::point(Point::ZERO, &s, style);
|
||||
t.xf = Affine::translate(origin.to_vec2()) * Affine::rotate(angle) * Affine::translate((0.0, drop));
|
||||
t.para.justify = match align & 0x06 {
|
||||
0x06 => Justify::Center,
|
||||
0x02 => Justify::Right,
|
||||
_ => Justify::Left,
|
||||
};
|
||||
t.cached_bounds = Some(vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &t).bounds);
|
||||
self.push(Node::new(vectorcraft_doc::NodeId(0), NodeKind::Text(Box::new(t))));
|
||||
}
|
||||
|
||||
// ---------- the document ----------
|
||||
|
||||
/// The document: what was drawn on one layer, clipped art in clipping groups.
|
||||
fn finish(mut self) -> Imported {
|
||||
self.flush();
|
||||
if self.skipped > 0 {
|
||||
let n = self.skipped;
|
||||
self.warnings.push(format!(
|
||||
"{n} record{} of kinds VectorCraft doesn't read {} skipped",
|
||||
if n == 1 { "" } else { "s" },
|
||||
if n == 1 { "was" } else { "were" }
|
||||
));
|
||||
}
|
||||
let out = std::mem::take(&mut self.out);
|
||||
if out.is_empty() && self.emf_plus {
|
||||
self.warnings.push("the picture is drawn with EMF+ records, which aren't read yet: nothing was imported".into());
|
||||
}
|
||||
let children = nest(&mut self.doc, out, 0);
|
||||
let mut layer = Node::layer(self.doc.alloc_id(), "Layer 1", LayerColor::Preset(0));
|
||||
if let Some(c) = layer.children_mut() {
|
||||
*c = children;
|
||||
}
|
||||
self.doc.layers = vec![Arc::new(layer)];
|
||||
Imported { document: self.doc, kind: self.kind, warnings: self.warnings }
|
||||
}
|
||||
}
|
||||
|
||||
/// `img` cut to `src` (pixels from the top-left, within the image).
|
||||
fn crop(img: Rgba, src: Rect) -> Rgba {
|
||||
let (w, h) = (img.width, img.height);
|
||||
let s = src.abs();
|
||||
let clamp = |v: f64, max: u32| if v.is_finite() { v.round().clamp(0.0, f64::from(max)) as u32 } else { 0 };
|
||||
let (x0, y0, x1, y1) = (clamp(s.x0, w), clamp(s.y0, h), clamp(s.x1, w), clamp(s.y1, h));
|
||||
if (x0, y0, x1, y1) == (0, 0, w, h) || x1 <= x0 || y1 <= y0 {
|
||||
return img;
|
||||
}
|
||||
let mut pixels = Vec::with_capacity(((x1 - x0) * (y1 - y0) * 4) as usize);
|
||||
for y in y0..y1 {
|
||||
let row = (y * w) as usize * 4;
|
||||
if let Some(p) = img.pixels.get(row + x0 as usize * 4..row + x1 as usize * 4) {
|
||||
pixels.extend_from_slice(p);
|
||||
}
|
||||
}
|
||||
Rgba { width: x1 - x0, height: y1 - y0, pixels }
|
||||
}
|
||||
|
||||
type Drawn = Vec<(Vec<Arc<Clip>>, Node)>;
|
||||
|
||||
/// The nodes of `items`, those under clips `depth` and deeper grouped into clipping groups, in
|
||||
/// order.
|
||||
fn nest(doc: &mut Document, items: Drawn, depth: usize) -> Vec<Arc<Node>> {
|
||||
let mut out: Vec<Arc<Node>> = vec![];
|
||||
let mut group: Drawn = vec![];
|
||||
let mut clip: Option<Arc<Clip>> = None;
|
||||
for (chain, node) in items {
|
||||
let here = chain.get(depth).cloned();
|
||||
if here.as_ref().map(|c| c.id) != clip.as_ref().map(|c| c.id) {
|
||||
if let Some(c) = clip.take() {
|
||||
out.push(clip_group(doc, &c, std::mem::take(&mut group), depth));
|
||||
}
|
||||
clip = here.clone();
|
||||
}
|
||||
match here {
|
||||
Some(_) => group.push((chain, node)),
|
||||
None => out.push(Arc::new(node)),
|
||||
}
|
||||
}
|
||||
if let Some(c) = clip {
|
||||
out.push(clip_group(doc, &c, group, depth));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A clipping group of `items`, clipped by `c` (their clip at `depth`).
|
||||
fn clip_group(doc: &mut Document, c: &Clip, items: Drawn, depth: usize) -> Arc<Node> {
|
||||
let mut path = Node::path(doc.alloc_id(), PathData::from_bezpath(&c.path), Appearance::default());
|
||||
if let NodeKind::Path { clipping, rule, .. } = &mut path.kind {
|
||||
*clipping = true;
|
||||
*rule = c.rule;
|
||||
}
|
||||
let mut children = vec![Arc::new(path)];
|
||||
children.extend(nest(doc, items, depth + 1));
|
||||
Arc::new(Node::new(doc.alloc_id(), NodeKind::Group { children, clip: true }))
|
||||
}
|
||||
|
||||
/// Read a metafile.
|
||||
pub(crate) fn import(bytes: &[u8]) -> Result<Imported, String> {
|
||||
match crate::sniff(bytes) {
|
||||
Some(Kind::Emf) => emf::play(bytes),
|
||||
Some(Kind::Wmf) => wmf::play(bytes),
|
||||
None => Err("not an EMF or WMF file".into()),
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,356 @@
|
|||
//! EMF records (MS-EMF) played on a [`Player`]: the header gives the frame (the artboard) and the
|
||||
//! reference device (device pixels → millimetres), then every record in turn until EMR_EOF.
|
||||
|
||||
use vectorcraft_geom::{Affine, Point, Rect};
|
||||
|
||||
use super::{ArcKind, BrushObj, FontObj, Obj, PenObj, Player};
|
||||
use crate::bytes::{Reader, slice};
|
||||
use crate::dib::{self, Alpha};
|
||||
use crate::{Imported, Kind};
|
||||
|
||||
/// Points per millimetre.
|
||||
const PT_PER_MM: f64 = 72.0 / 25.4;
|
||||
/// Most points one record may give (a hostile count can't make a huge allocation).
|
||||
const MAX_POINTS: usize = 1 << 22;
|
||||
/// Records that change nothing VectorCraft keeps (background mode and colour, raster modes,
|
||||
/// palettes, colour management, comments…).
|
||||
const IGNORED: &[u32] =
|
||||
&[1, 13, 15, 16, 18, 20, 21, 23, 25, 28, 48, 50, 51, 52, 65, 66, 70, 98, 100, 101, 104, 110, 111, 112, 113, 115, 119, 120, 121];
|
||||
|
||||
fn rect(r: &mut Reader) -> Option<Rect> {
|
||||
Some(Rect::new(f64::from(r.i32()?), f64::from(r.i32()?), f64::from(r.i32()?), f64::from(r.i32()?)))
|
||||
}
|
||||
|
||||
fn point32(r: &mut Reader) -> Option<Point> {
|
||||
Some(Point::new(f64::from(r.i32()?), f64::from(r.i32()?)))
|
||||
}
|
||||
|
||||
fn point16(r: &mut Reader) -> Option<Point> {
|
||||
Some(Point::new(f64::from(r.i16()?), f64::from(r.i16()?)))
|
||||
}
|
||||
|
||||
fn color(r: &mut Reader) -> Option<[u8; 3]> {
|
||||
let [red, g, b, _] = r.u32()?.to_le_bytes();
|
||||
Some([red, g, b])
|
||||
}
|
||||
|
||||
fn xform(r: &mut Reader) -> Option<Affine> {
|
||||
let mut v = [0.0; 6];
|
||||
for x in &mut v {
|
||||
*x = f64::from(r.f32()?);
|
||||
}
|
||||
Some(Affine::new(v))
|
||||
}
|
||||
|
||||
/// `n` points (32-bit or 16-bit), `None` when the record is shorter.
|
||||
fn points(r: &mut Reader, n: u32, wide: bool) -> Option<Vec<Point>> {
|
||||
let n = n as usize;
|
||||
if n > MAX_POINTS || r.rest().len() < n * if wide { 8 } else { 4 } {
|
||||
return None;
|
||||
}
|
||||
(0..n).map(|_| if wide { point32(r) } else { point16(r) }).collect()
|
||||
}
|
||||
|
||||
/// The polygons of a PolyPolygon / PolyPolyline record.
|
||||
fn polys(r: &mut Reader, wide: bool) -> Option<Vec<Vec<Point>>> {
|
||||
r.skip(16)?;
|
||||
let n = r.u32()? as usize;
|
||||
let total = r.u32()?;
|
||||
if n > MAX_POINTS || r.rest().len() < n * 4 {
|
||||
return None;
|
||||
}
|
||||
let counts: Vec<u32> = (0..n).map(|_| r.u32()).collect::<Option<_>>()?;
|
||||
let mut all = points(r, total, wide)?.into_iter();
|
||||
Some(counts.iter().map(|c| all.by_ref().take(*c as usize).collect()).collect())
|
||||
}
|
||||
|
||||
/// A LOGFONTW → the font.
|
||||
fn font(r: &mut Reader) -> Option<FontObj> {
|
||||
let height = f64::from(r.i32()?);
|
||||
r.skip(4)?;
|
||||
let escapement = f64::from(r.i32()?);
|
||||
r.skip(4)?;
|
||||
let weight = r.i32()?;
|
||||
let italic = r.bytes(1)?.first().is_some_and(|b| *b != 0);
|
||||
r.skip(7)?;
|
||||
let units: Vec<u16> = (0..32).map_while(|_| r.u16()).take_while(|c| *c != 0).collect();
|
||||
Some(FontObj { height, escapement, weight, italic, face: String::from_utf16_lossy(&units) })
|
||||
}
|
||||
|
||||
/// The bitmap of an image record: its BITMAPINFO and bits at the offsets the record gives.
|
||||
fn bitmap(rec: &[u8], off_bmi: u32, cb_bmi: u32, off_bits: u32, cb_bits: u32, alpha: Alpha) -> Result<dib::Rgba, String> {
|
||||
let bmi = slice(rec, off_bmi, cb_bmi).ok_or("damaged")?;
|
||||
let bits = slice(rec, off_bits, cb_bits).ok_or("damaged")?;
|
||||
dib::decode(bmi, bits, alpha)
|
||||
}
|
||||
|
||||
/// The rows a DIB's source rectangle counts from the bottom in (StretchDIBits on a bottom-up DIB).
|
||||
fn bottom_up(rec: &[u8], off_bmi: u32) -> bool {
|
||||
let at = off_bmi as usize;
|
||||
let size = rec.get(at..at + 4).and_then(|b| b.try_into().ok()).map(u32::from_le_bytes);
|
||||
let height = rec.get(at + 8..at + 12).and_then(|b| b.try_into().ok()).map(i32::from_le_bytes);
|
||||
size.is_some_and(|s| s >= 40) && height.is_some_and(|h| h > 0)
|
||||
}
|
||||
|
||||
/// The source rectangle `(x, y, w, h)` of an image of `img`'s height, counted from the top.
|
||||
fn source(x: i32, y: i32, w: i32, h: i32, img: &Result<dib::Rgba, String>, from_bottom: bool) -> Rect {
|
||||
let ih = img.as_ref().map_or(0.0, |i| f64::from(i.height));
|
||||
let (x, y, w, h) = (f64::from(x), f64::from(y), f64::from(w), f64::from(h));
|
||||
let top = if from_bottom { ih - y - h } else { y };
|
||||
Rect::new(x, top, x + w, top + h)
|
||||
}
|
||||
|
||||
/// The size and placement of the picture: (device → document, millimetres a pixel, the artboard).
|
||||
fn frame(bytes: &[u8]) -> Option<(Affine, f64, Rect)> {
|
||||
let mut r = Reader::new(bytes);
|
||||
r.skip(4)?;
|
||||
let header_size = r.u32()?;
|
||||
let bounds = rect(&mut r)?;
|
||||
let frame = rect(&mut r)?;
|
||||
// Signature, version, bytes, records, handles, description and palette size.
|
||||
r.skip(32)?;
|
||||
let device = (f64::from(r.i32()?), f64::from(r.i32()?));
|
||||
let mm = (f64::from(r.i32()?), f64::from(r.i32()?));
|
||||
// The device in micrometres (more precise), when the header has it.
|
||||
let micro = if header_size >= 108 {
|
||||
r.skip(12)?;
|
||||
Some((f64::from(r.u32()?), f64::from(r.u32()?)))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let ok = |v: f64| v.is_finite() && v > 0.0;
|
||||
let mm_per_px = match micro {
|
||||
Some((mx, _)) if ok(mx) && ok(device.0) => mx / 1000.0 / device.0,
|
||||
_ if ok(mm.0) && ok(device.0) => mm.0 / device.0,
|
||||
_ => 25.4 / 96.0,
|
||||
};
|
||||
let mm_per_px = if mm_per_px.is_finite() && mm_per_px > 1e-9 { mm_per_px } else { 25.4 / 96.0 };
|
||||
let k = mm_per_px * PT_PER_MM;
|
||||
// The frame (0.01 mm) in device pixels; an empty frame falls back to the bounds.
|
||||
let f = frame.abs();
|
||||
let (origin, size) = if f.width() > 0.0 && f.height() > 0.0 {
|
||||
(Point::new(f.x0 / 100.0 / mm_per_px, f.y0 / 100.0 / mm_per_px), (f.width() / 100.0 * PT_PER_MM, f.height() / 100.0 * PT_PER_MM))
|
||||
} else {
|
||||
let b = bounds.abs();
|
||||
(b.origin(), ((b.width() + 1.0) * k, (b.height() + 1.0) * k))
|
||||
};
|
||||
let dev_to_doc = Affine::scale(k) * Affine::translate(-origin.to_vec2());
|
||||
let side = |v: f64| if v.is_finite() { v.clamp(1.0, 1e6) } else { 1.0 };
|
||||
Some((dev_to_doc, mm_per_px, Rect::new(0.0, 0.0, side(size.0), side(size.1))))
|
||||
}
|
||||
|
||||
pub(super) fn play(bytes: &[u8]) -> Result<Imported, String> {
|
||||
let (dev_to_doc, mm_per_px, artboard) = frame(bytes).ok_or("the EMF header is damaged")?;
|
||||
let mut p = Player::new(Kind::Emf, dev_to_doc, mm_per_px, artboard);
|
||||
let mut at = 0usize;
|
||||
let mut ended = false;
|
||||
while let Some(head) = bytes.get(at..at + 8) {
|
||||
let kind = u32::from_le_bytes([head[0], head[1], head[2], head[3]]);
|
||||
let size = u32::from_le_bytes([head[4], head[5], head[6], head[7]]) as usize;
|
||||
let Some(rec) = bytes.get(at..at.saturating_add(size)).filter(|_| size >= 8) else { break };
|
||||
if kind == 14 {
|
||||
ended = true;
|
||||
break;
|
||||
}
|
||||
if !matches!(kind, 27 | 54 | 5 | 6 | 88 | 89) {
|
||||
p.flush();
|
||||
}
|
||||
if record(&mut p, kind, rec).is_none() {
|
||||
p.skip();
|
||||
}
|
||||
at += size;
|
||||
}
|
||||
if !ended {
|
||||
p.warn("the file ends early: what it held was imported");
|
||||
}
|
||||
Ok(p.finish())
|
||||
}
|
||||
|
||||
/// Play one record (`rec`: all of it, with its type and size). `None`: unknown or damaged.
|
||||
fn record(p: &mut Player, kind: u32, rec: &[u8]) -> Option<()> {
|
||||
let mut r = Reader::new(rec.get(8..)?);
|
||||
match kind {
|
||||
// PolyBezier, Polygon, Polyline, PolyBezierTo, PolylineTo and their 16-bit forms.
|
||||
2..=6 | 85..=89 => {
|
||||
let wide = kind < 85;
|
||||
r.skip(16)?;
|
||||
let n = r.u32()?;
|
||||
let pts = points(&mut r, n, wide)?;
|
||||
match if wide { kind } else { kind - 83 } {
|
||||
2 => p.poly(&pts, false, true),
|
||||
3 => p.poly(&pts, true, false),
|
||||
4 => p.poly(&pts, false, false),
|
||||
5 => p.poly_to(&pts, true),
|
||||
_ => p.poly_to(&pts, false),
|
||||
}
|
||||
}
|
||||
7 | 90 => p.poly_poly(&polys(&mut r, kind == 7)?, false),
|
||||
8 | 91 => p.poly_poly(&polys(&mut r, kind == 8)?, true),
|
||||
9 => p.set_window_ext(f64::from(r.i32()?), f64::from(r.i32()?)),
|
||||
10 => p.set_window_org(f64::from(r.i32()?), f64::from(r.i32()?)),
|
||||
11 => p.set_viewport_ext(f64::from(r.i32()?), f64::from(r.i32()?)),
|
||||
12 => p.set_viewport_org(f64::from(r.i32()?), f64::from(r.i32()?)),
|
||||
17 => p.set_map_mode(r.u32()?),
|
||||
19 => p.set_fill_mode(r.u32()? == 1),
|
||||
22 => p.set_text_align(r.u32()?),
|
||||
24 => p.set_text_color(color(&mut r)?),
|
||||
27 => p.move_to(point32(&mut r)?),
|
||||
29 => p.exclude_clip(),
|
||||
30 => p.clip_rect(rect(&mut r)?),
|
||||
31 | 32 => {
|
||||
let (xn, xd, yn, yd) = (r.i32()?, r.i32()?, r.i32()?, r.i32()?);
|
||||
p.scale_ext(kind == 32, (f64::from(xn), f64::from(xd)), (f64::from(yn), f64::from(yd)));
|
||||
}
|
||||
33 => p.save(),
|
||||
34 => p.restore(r.i32()?),
|
||||
35 => p.world(xform(&mut r)?, 4),
|
||||
36 => {
|
||||
let x = xform(&mut r)?;
|
||||
p.world(x, r.u32()?);
|
||||
}
|
||||
37 => p.select(r.u32()?),
|
||||
38 => {
|
||||
let ih = r.u32()?;
|
||||
let style = r.u32()?;
|
||||
let width = f64::from(r.i32()?);
|
||||
r.skip(4)?;
|
||||
p.create_at(ih, Obj::Pen(PenObj { style, width, color: color(&mut r)?, entries: vec![] }));
|
||||
}
|
||||
39 => {
|
||||
let ih = r.u32()?;
|
||||
let style = r.u32()?;
|
||||
p.create_at(ih, Obj::Brush(BrushObj::from_log(style, color(&mut r)?)));
|
||||
}
|
||||
40 => p.delete(r.u32()?),
|
||||
42 => p.figure(vectorcraft_geom::shapes::ellipse(rect(&mut r)?.abs()).to_bezpath(), true),
|
||||
43 => p.figure(vectorcraft_geom::shapes::rectangle(rect(&mut r)?.abs()).to_bezpath(), true),
|
||||
44 => {
|
||||
let b = rect(&mut r)?;
|
||||
let (cx, cy) = (f64::from(r.i32()?), f64::from(r.i32()?));
|
||||
p.figure(super::round_rect(b, cx, cy), true);
|
||||
}
|
||||
45..=47 => {
|
||||
let b = rect(&mut r)?;
|
||||
let (s, e) = (point32(&mut r)?, point32(&mut r)?);
|
||||
p.arc(b, s, e, [ArcKind::Open, ArcKind::Chord, ArcKind::Pie][(kind - 45) as usize]);
|
||||
}
|
||||
49 | 99 | 122 => p.create_at(r.u32()?, Obj::Other),
|
||||
54 => {
|
||||
let pt = point32(&mut r)?;
|
||||
p.poly_to(&[pt], false);
|
||||
}
|
||||
57 => p.set_arc_ccw(r.u32()? != 2),
|
||||
58 => p.set_miter(f64::from(r.u32()?)),
|
||||
59 => p.begin_path(),
|
||||
60 => p.end_path(),
|
||||
61 => p.close_figure(),
|
||||
62 => p.paint_path(true, false),
|
||||
63 => p.paint_path(true, true),
|
||||
64 => p.paint_path(false, true),
|
||||
67 => p.clip_path(r.u32()?),
|
||||
68 => p.abort_path(),
|
||||
70 => {
|
||||
// EMF+ records ride in comments starting "EMF+".
|
||||
r.skip(4)?;
|
||||
if r.u32()? == 0x2B46_4D45 {
|
||||
p.emf_plus = true;
|
||||
}
|
||||
}
|
||||
75 => {
|
||||
let cb = r.u32()?;
|
||||
let mode = r.u32()?;
|
||||
let data = r.bytes(cb as usize)?;
|
||||
let mut d = Reader::new(data);
|
||||
let rects = if cb >= 32 {
|
||||
d.skip(8)?;
|
||||
let n = d.u32()? as usize;
|
||||
d.skip(20)?;
|
||||
if n > MAX_POINTS || d.rest().len() < n * 16 {
|
||||
return None;
|
||||
}
|
||||
(0..n).map(|_| rect(&mut d)).collect::<Option<Vec<_>>>()?
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
p.clip_region(&rects, mode);
|
||||
}
|
||||
76 | 77 => {
|
||||
r.skip(16)?;
|
||||
let dest = (r.i32()?, r.i32()?, r.i32()?, r.i32()?);
|
||||
let rop = r.u32()?;
|
||||
let (xs, ys) = (r.i32()?, r.i32()?);
|
||||
r.skip(24 + 4 + 4)?;
|
||||
let (off_bmi, cb_bmi, off_bits, cb_bits) = (r.u32()?, r.u32()?, r.u32()?, r.u32()?);
|
||||
let (ws, hs) = if kind == 77 { (r.i32()?, r.i32()?) } else { (dest.2, dest.3) };
|
||||
let d = Rect::new(f64::from(dest.0), f64::from(dest.1), f64::from(dest.0) + f64::from(dest.2), f64::from(dest.1) + f64::from(dest.3));
|
||||
if cb_bmi == 0 {
|
||||
p.pattern_blit(d, rop);
|
||||
} else {
|
||||
let img = bitmap(rec, off_bmi, cb_bmi, off_bits, cb_bits, Alpha::Ignore);
|
||||
let src = source(xs, ys, ws, hs, &img, false);
|
||||
p.image(img, src, d, rop, 1.0);
|
||||
}
|
||||
}
|
||||
81 => {
|
||||
r.skip(16)?;
|
||||
let (xd, yd, xs, ys, ws, hs) = (r.i32()?, r.i32()?, r.i32()?, r.i32()?, r.i32()?, r.i32()?);
|
||||
let (off_bmi, cb_bmi, off_bits, cb_bits) = (r.u32()?, r.u32()?, r.u32()?, r.u32()?);
|
||||
r.skip(4)?;
|
||||
let rop = r.u32()?;
|
||||
let (wd, hd) = (r.i32()?, r.i32()?);
|
||||
let img = bitmap(rec, off_bmi, cb_bmi, off_bits, cb_bits, Alpha::Ignore);
|
||||
let src = source(xs, ys, ws, hs, &img, bottom_up(rec, off_bmi));
|
||||
let d = Rect::new(f64::from(xd), f64::from(yd), f64::from(xd) + f64::from(wd), f64::from(yd) + f64::from(hd));
|
||||
p.image(img, src, d, rop, 1.0);
|
||||
}
|
||||
82 => {
|
||||
let ih = r.u32()?;
|
||||
p.create_at(ih, Obj::Font(font(&mut r)?));
|
||||
}
|
||||
83 | 84 => {
|
||||
r.skip(16 + 4 + 8)?;
|
||||
let at = point32(&mut r)?;
|
||||
let n = r.u32()? as usize;
|
||||
let off = r.u32()?;
|
||||
let s = if kind == 84 {
|
||||
let raw = slice(rec, off, u32::try_from(n.checked_mul(2)?).ok()?)?;
|
||||
String::from_utf16_lossy(&raw.chunks_exact(2).map(|c| u16::from_le_bytes([c[0], c[1]])).collect::<Vec<_>>())
|
||||
} else {
|
||||
slice(rec, off, u32::try_from(n).ok()?)?.iter().map(|b| char::from(*b)).collect()
|
||||
};
|
||||
p.text(at, &s);
|
||||
}
|
||||
93 | 94 => p.create_at(r.u32()?, Obj::Brush(BrushObj::Pattern)),
|
||||
95 => {
|
||||
let ih = r.u32()?;
|
||||
r.skip(16)?;
|
||||
let style = r.u32()?;
|
||||
let width = f64::from(r.u32()?);
|
||||
let brush = r.u32()?;
|
||||
let c = color(&mut r)?;
|
||||
r.skip(4)?;
|
||||
let n = (r.u32()? as usize).min(64);
|
||||
let entries: Vec<f64> = (0..n).map_while(|_| r.u32()).map(f64::from).collect();
|
||||
// A pen painted with the null brush draws nothing.
|
||||
let style = if brush == 1 { super::PS_NULL } else { style };
|
||||
p.create_at(ih, Obj::Pen(PenObj { style, width, color: c, entries }));
|
||||
}
|
||||
114 => {
|
||||
r.skip(16)?;
|
||||
let (xd, yd, wd, hd) = (r.i32()?, r.i32()?, r.i32()?, r.i32()?);
|
||||
let blend = r.bytes(4)?;
|
||||
let (constant, per_pixel) = (blend.get(2).copied().unwrap_or(255), blend.get(3).is_some_and(|f| *f & 1 != 0));
|
||||
let (xs, ys) = (r.i32()?, r.i32()?);
|
||||
r.skip(24 + 4 + 4)?;
|
||||
let (off_bmi, cb_bmi, off_bits, cb_bits) = (r.u32()?, r.u32()?, r.u32()?, r.u32()?);
|
||||
let (ws, hs) = (r.i32()?, r.i32()?);
|
||||
let img = bitmap(rec, off_bmi, cb_bmi, off_bits, cb_bits, if per_pixel { Alpha::Premultiplied } else { Alpha::Ignore });
|
||||
let src = source(xs, ys, ws, hs, &img, false);
|
||||
let d = Rect::new(f64::from(xd), f64::from(yd), f64::from(xd) + f64::from(wd), f64::from(yd) + f64::from(hd));
|
||||
p.image(img, src, d, crate::import::SRCCOPY, f64::from(constant) / 255.0);
|
||||
}
|
||||
k if IGNORED.contains(&k) => {}
|
||||
_ => return None,
|
||||
}
|
||||
Some(())
|
||||
}
|
||||
|
|
@ -0,0 +1,287 @@
|
|||
//! WMF records (MS-WMF) played on a [`Player`]. A placeable header gives the picture's box in
|
||||
//! logical units and the units an inch; without one, the first window origin and extent are the
|
||||
//! box, at 1440 units an inch.
|
||||
|
||||
use vectorcraft_geom::{Affine, Point, Rect, shapes};
|
||||
|
||||
use super::{ArcKind, BrushObj, FontObj, Obj, PenObj, Player};
|
||||
use crate::bytes::Reader;
|
||||
use crate::dib::{self, Alpha};
|
||||
use crate::wmf::PLACEABLE_KEY;
|
||||
use crate::{Imported, Kind};
|
||||
|
||||
/// Units an inch when the file doesn't say (twips).
|
||||
const DEFAULT_INCH: f64 = 1440.0;
|
||||
/// Records that change nothing VectorCraft keeps (background, raster and mapping details, palettes,
|
||||
/// escapes).
|
||||
const IGNORED: &[u16] = &[
|
||||
0x0102, 0x0103, 0x0104, 0x0105, 0x0107, 0x0108, 0x0201, 0x020A, 0x020B, 0x020C, 0x020D, 0x020E, 0x020F, 0x0211, 0x0410, 0x0412, 0x0231, 0x0234,
|
||||
0x0035, 0x0037, 0x0139, 0x0436, 0x0626,
|
||||
];
|
||||
|
||||
fn point(r: &mut Reader) -> Option<Point> {
|
||||
Some(Point::new(f64::from(r.i16()?), f64::from(r.i16()?)))
|
||||
}
|
||||
|
||||
/// A point stored y first.
|
||||
fn point_yx(r: &mut Reader) -> Option<Point> {
|
||||
let y = f64::from(r.i16()?);
|
||||
Some(Point::new(f64::from(r.i16()?), y))
|
||||
}
|
||||
|
||||
/// A rectangle stored bottom, right, top, left.
|
||||
fn rect_brtl(r: &mut Reader) -> Option<Rect> {
|
||||
let (b, rt, t, l) = (r.i16()?, r.i16()?, r.i16()?, r.i16()?);
|
||||
Some(Rect::new(f64::from(l), f64::from(t), f64::from(rt), f64::from(b)))
|
||||
}
|
||||
|
||||
fn color(r: &mut Reader) -> Option<[u8; 3]> {
|
||||
let [red, g, b, _] = r.u32()?.to_le_bytes();
|
||||
Some([red, g, b])
|
||||
}
|
||||
|
||||
fn points(r: &mut Reader, n: usize) -> Option<Vec<Point>> {
|
||||
if r.rest().len() < n * 4 {
|
||||
return None;
|
||||
}
|
||||
(0..n).map(|_| point(r)).collect()
|
||||
}
|
||||
|
||||
/// ANSI text (read as Latin-1).
|
||||
fn ansi(b: &[u8]) -> String {
|
||||
b.iter().map(|c| char::from(*c)).collect()
|
||||
}
|
||||
|
||||
/// The records' start, and the box and units an inch of a placeable header.
|
||||
fn header(bytes: &[u8]) -> Option<(usize, Option<(Rect, f64)>)> {
|
||||
let mut r = Reader::new(bytes);
|
||||
let placeable = if r.u32()? == PLACEABLE_KEY {
|
||||
r.skip(2)?;
|
||||
let (l, t, rt, b) = (r.i16()?, r.i16()?, r.i16()?, r.i16()?);
|
||||
let inch = r.u16()?;
|
||||
let inch = if inch == 0 { DEFAULT_INCH } else { f64::from(inch) };
|
||||
Some((Rect::new(f64::from(l), f64::from(t), f64::from(rt), f64::from(b)).abs(), inch))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let start = if placeable.is_some() { 22 } else { 0 };
|
||||
let mut h = Reader::new(bytes.get(start..)?);
|
||||
h.skip(2)?;
|
||||
let words = usize::from(h.u16()?);
|
||||
if words < 9 {
|
||||
return None;
|
||||
}
|
||||
Some((start + words * 2, placeable))
|
||||
}
|
||||
|
||||
/// The first window origin and extent, as the picture's box when there is no placeable header.
|
||||
fn window(bytes: &[u8], mut at: usize) -> Option<Rect> {
|
||||
let (mut org, mut ext) = (None, None);
|
||||
while let Some(head) = bytes.get(at..at + 6) {
|
||||
let words = u32::from_le_bytes([head[0], head[1], head[2], head[3]]) as usize;
|
||||
let function = u16::from_le_bytes([head[4], head[5]]);
|
||||
if words < 3 || function == 0 {
|
||||
break;
|
||||
}
|
||||
let mut r = Reader::new(bytes.get(at + 6..).unwrap_or_default());
|
||||
match function {
|
||||
0x020B if org.is_none() => org = point_yx(&mut r),
|
||||
0x020C if ext.is_none() => ext = point_yx(&mut r),
|
||||
_ => {}
|
||||
}
|
||||
if org.is_some() && ext.is_some() {
|
||||
break;
|
||||
}
|
||||
at = at.saturating_add(words.saturating_mul(2));
|
||||
}
|
||||
let o = org.unwrap_or(Point::ZERO);
|
||||
let e = ext?;
|
||||
Some(Rect::new(o.x, o.y, o.x + e.x, o.y + e.y).abs())
|
||||
}
|
||||
|
||||
pub(super) fn play(bytes: &[u8]) -> Result<Imported, String> {
|
||||
let (start, placeable) = header(bytes).ok_or("the WMF header is damaged")?;
|
||||
let (frame, inch) = match placeable {
|
||||
Some((b, inch)) if b.width() > 0.0 && b.height() > 0.0 => (Some(b), inch),
|
||||
_ => (window(bytes, start).filter(|w| w.width() > 0.0 && w.height() > 0.0), DEFAULT_INCH),
|
||||
};
|
||||
let k = 72.0 / inch;
|
||||
let origin = frame.map_or(Point::ZERO, |f| f.origin());
|
||||
let artboard = frame.map_or(Rect::new(0.0, 0.0, 1.0, 1.0), |f| Rect::new(0.0, 0.0, f.width() * k, f.height() * k));
|
||||
let mut p = Player::new(Kind::Wmf, Affine::scale(k) * Affine::translate(-origin.to_vec2()), 25.4 / inch, artboard);
|
||||
let mut at = start;
|
||||
let mut ended = false;
|
||||
while let Some(head) = bytes.get(at..at + 6) {
|
||||
let words = u32::from_le_bytes([head[0], head[1], head[2], head[3]]) as usize;
|
||||
let function = u16::from_le_bytes([head[4], head[5]]);
|
||||
if function == 0 {
|
||||
ended = true;
|
||||
break;
|
||||
}
|
||||
let Some(rec) = words.checked_mul(2).and_then(|n| bytes.get(at..at.checked_add(n)?)).filter(|_| words >= 3) else { break };
|
||||
if !matches!(function, 0x0213 | 0x0214) {
|
||||
p.flush();
|
||||
}
|
||||
if record(&mut p, function, rec.get(6..).unwrap_or_default()).is_none() {
|
||||
p.skip();
|
||||
}
|
||||
at += rec.len();
|
||||
}
|
||||
if !ended {
|
||||
p.warn("the file ends early: what it held was imported");
|
||||
}
|
||||
let mut out = p.finish();
|
||||
// No box given: the art's bounds.
|
||||
if frame.is_none()
|
||||
&& let Some(b) = out.document.layers.first().and_then(|l| vectorcraft_doc::live::nodes_bounds(l.children().map_or(&[][..], |c| c.as_slice())))
|
||||
&& let Some(a) = out.document.artboards.first_mut()
|
||||
{
|
||||
a.rect = Rect::from_origin_size(b.origin(), (b.width().max(1.0), b.height().max(1.0)));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// An image record's source rectangle (from the bottom for a bottom-up DIB) and destination.
|
||||
fn blit(p: &mut Player, rop: u32, src: (i16, i16, i16, i16), dest: (i16, i16, i16, i16), packed: Option<&[u8]>) {
|
||||
let (xd, yd, wd, hd) = (f64::from(dest.0), f64::from(dest.1), f64::from(dest.2), f64::from(dest.3));
|
||||
let d = Rect::new(xd, yd, xd + wd, yd + hd);
|
||||
let Some(packed) = packed else { return p.pattern_blit(d, rop) };
|
||||
let parts = dib::split(packed);
|
||||
let img = parts.ok_or_else(|| "damaged".to_string()).and_then(|(bmi, bits)| dib::decode(bmi, bits, Alpha::Ignore));
|
||||
let bottom_up = parts.and_then(|(bmi, _)| bmi.get(8..12)).is_some_and(|h| i32::from_le_bytes([h[0], h[1], h[2], h[3]]) > 0);
|
||||
let ih = img.as_ref().map_or(0.0, |i| f64::from(i.height));
|
||||
let (xs, ys, ws, hs) = (f64::from(src.0), f64::from(src.1), f64::from(src.2), f64::from(src.3));
|
||||
let top = if bottom_up { ih - ys - hs } else { ys };
|
||||
p.image(img, Rect::new(xs, top, xs + ws, top + hs), d, rop, 1.0);
|
||||
}
|
||||
|
||||
/// Play one record (`r`: its parameters). `None`: unknown or damaged.
|
||||
fn record(p: &mut Player, function: u16, body: &[u8]) -> Option<()> {
|
||||
let mut r = Reader::new(body);
|
||||
match function {
|
||||
0x001E => p.save(),
|
||||
0x0127 => p.restore(i32::from(r.i16()?)),
|
||||
0x0106 => p.set_fill_mode(r.u16()? == 1),
|
||||
0x0209 => p.set_text_color(color(&mut r)?),
|
||||
0x012E => p.set_text_align(u32::from(r.u16()?)),
|
||||
0x0214 => p.move_to(point_yx(&mut r)?),
|
||||
0x0213 => {
|
||||
let pt = point_yx(&mut r)?;
|
||||
p.poly_to(&[pt], false);
|
||||
}
|
||||
0x0324 | 0x0325 => {
|
||||
let n = usize::try_from(r.i16()?).ok()?;
|
||||
let pts = points(&mut r, n)?;
|
||||
p.poly(&pts, function == 0x0324, false);
|
||||
}
|
||||
0x0538 => {
|
||||
let n = usize::from(r.u16()?);
|
||||
if r.rest().len() < n * 2 {
|
||||
return None;
|
||||
}
|
||||
let counts: Vec<usize> = (0..n).map(|_| r.u16().map(usize::from)).collect::<Option<_>>()?;
|
||||
let mut polys = Vec::with_capacity(n);
|
||||
for c in counts {
|
||||
polys.push(points(&mut r, c)?);
|
||||
}
|
||||
p.poly_poly(&polys, true);
|
||||
}
|
||||
0x041B => p.figure(shapes::rectangle(rect_brtl(&mut r)?.abs()).to_bezpath(), true),
|
||||
0x0418 => p.figure(shapes::ellipse(rect_brtl(&mut r)?.abs()).to_bezpath(), true),
|
||||
0x061C => {
|
||||
let (h, w) = (f64::from(r.i16()?), f64::from(r.i16()?));
|
||||
p.figure(super::round_rect(rect_brtl(&mut r)?, w, h), true);
|
||||
}
|
||||
0x0817 | 0x081A | 0x0830 => {
|
||||
let (end, start) = (point_yx(&mut r)?, point_yx(&mut r)?);
|
||||
let b = rect_brtl(&mut r)?;
|
||||
let kind = match function {
|
||||
0x0817 => ArcKind::Open,
|
||||
0x081A => ArcKind::Pie,
|
||||
_ => ArcKind::Chord,
|
||||
};
|
||||
p.arc(b, start, end, kind);
|
||||
}
|
||||
0x0416 => p.clip_rect(rect_brtl(&mut r)?),
|
||||
0x0415 => p.exclude_clip(),
|
||||
0x012D => p.select(u32::from(r.u16()?)),
|
||||
0x01F0 => p.delete(u32::from(r.u16()?)),
|
||||
0x02FA => {
|
||||
let style = u32::from(r.u16()?);
|
||||
let width = f64::from(r.i16()?);
|
||||
r.skip(2)?;
|
||||
p.create(Obj::Pen(PenObj { style, width, color: color(&mut r)?, entries: vec![] }));
|
||||
}
|
||||
0x02FC => {
|
||||
let style = u32::from(r.u16()?);
|
||||
p.create(Obj::Brush(BrushObj::from_log(style, color(&mut r)?)));
|
||||
}
|
||||
0x02FB => {
|
||||
let height = f64::from(r.i16()?);
|
||||
r.skip(2)?;
|
||||
let escapement = f64::from(r.i16()?);
|
||||
r.skip(2)?;
|
||||
let weight = i32::from(r.i16()?);
|
||||
let italic = r.bytes(1)?.first().is_some_and(|b| *b != 0);
|
||||
r.skip(7)?;
|
||||
let face: Vec<u8> = r.rest().iter().take(32).take_while(|b| **b != 0).copied().collect();
|
||||
p.create(Obj::Font(FontObj { height, escapement, weight, italic, face: ansi(&face) }));
|
||||
}
|
||||
0x00F7 | 0x06FF => p.create(Obj::Other),
|
||||
0x01F9 | 0x0142 => p.create(Obj::Brush(BrushObj::Pattern)),
|
||||
0x0521 => {
|
||||
let n = usize::try_from(r.i16()?).ok()?;
|
||||
let s = ansi(r.bytes(n)?);
|
||||
r.skip(n % 2)?;
|
||||
let at = point_yx(&mut r)?;
|
||||
p.text(at, &s);
|
||||
}
|
||||
0x0A32 => {
|
||||
let at = point_yx(&mut r)?;
|
||||
let n = usize::try_from(r.i16()?).ok()?;
|
||||
let opts = r.u16()?;
|
||||
// ETO_OPAQUE or ETO_CLIPPED: a rectangle comes first.
|
||||
if opts & 0x6 != 0 {
|
||||
r.skip(8)?;
|
||||
}
|
||||
let s = ansi(r.bytes(n)?);
|
||||
p.text(at, &s);
|
||||
}
|
||||
0x0F43 => {
|
||||
let rop = r.u32()?;
|
||||
r.skip(2)?;
|
||||
let (hs, ws, ys, xs) = (r.i16()?, r.i16()?, r.i16()?, r.i16()?);
|
||||
let (hd, wd, yd, xd) = (r.i16()?, r.i16()?, r.i16()?, r.i16()?);
|
||||
blit(p, rop, (xs, ys, ws, hs), (xd, yd, wd, hd), Some(r.rest()));
|
||||
}
|
||||
0x0B41 => {
|
||||
let rop = r.u32()?;
|
||||
let (hs, ws, ys, xs) = (r.i16()?, r.i16()?, r.i16()?, r.i16()?);
|
||||
// Without a bitmap, a reserved word comes before the destination.
|
||||
let with_bitmap = body.len() > 22;
|
||||
if !with_bitmap {
|
||||
r.skip(2)?;
|
||||
}
|
||||
let (hd, wd, yd, xd) = (r.i16()?, r.i16()?, r.i16()?, r.i16()?);
|
||||
blit(p, rop, (xs, ys, ws, hs), (xd, yd, wd, hd), with_bitmap.then(|| r.rest()));
|
||||
}
|
||||
0x0940 => {
|
||||
let rop = r.u32()?;
|
||||
let (ys, xs) = (r.i16()?, r.i16()?);
|
||||
let with_bitmap = body.len() > 20;
|
||||
if !with_bitmap {
|
||||
r.skip(2)?;
|
||||
}
|
||||
let (h, w, yd, xd) = (r.i16()?, r.i16()?, r.i16()?, r.i16()?);
|
||||
blit(p, rop, (xs, ys, w, h), (xd, yd, w, h), with_bitmap.then(|| r.rest()));
|
||||
}
|
||||
0x061D => {
|
||||
let rop = r.u32()?;
|
||||
let (h, w, y, x) = (f64::from(r.i16()?), f64::from(r.i16()?), f64::from(r.i16()?), f64::from(r.i16()?));
|
||||
p.pattern_blit(Rect::new(x, y, x + w, y + h), rop);
|
||||
}
|
||||
f if IGNORED.contains(&f) => {}
|
||||
_ => return None,
|
||||
}
|
||||
Some(())
|
||||
}
|
||||
|
|
@ -0,0 +1,112 @@
|
|||
//! Windows metafiles: hand-written EMF and WMF writers and readers.
|
||||
//!
|
||||
//! [`export`] writes the visible art over one region of a document (an artboard) as a metafile:
|
||||
//!
|
||||
//! - **EMF**: paths keep their Bézier curves, fills are solid brushes, strokes geometric pens with
|
||||
//! their width, caps, joins, miter limit and dashes; strokes a pen can't draw (aligned inside or
|
||||
//! outside, width profiles, arrowheads) are their filled outlines and brush strokes their brush
|
||||
//! art. Clipping masks clip, type is written as glyph outlines, images keep their pixels (with
|
||||
//! their transparency), gradients become images clipped to what they paint and pattern fills
|
||||
//! their tiles clipped to the shape. The header's frame is the region in 0.01 mm.
|
||||
//! - **WMF**: the same art with curves flattened into polygons and 16-bit coordinates, behind a
|
||||
//! placeable header that gives the size; WMF has no clipping paths, transparency, dashes on wide
|
||||
//! lines or gradients, so clipped art is written whole, images over white, dashes as separate
|
||||
//! lines and gradients and patterns as one colour (each loss comes back as a warning).
|
||||
//!
|
||||
//! [`import`] reads either format back into a document of one artboard (the picture's frame) and
|
||||
//! one layer: paths with fills and strokes, clipping, images and text. Records it doesn't read are
|
||||
//! skipped, with one warning.
|
||||
//!
|
||||
//! - `bytes`: little-endian writing and bounds-checked reading.
|
||||
//! - `dib`: device-independent bitmaps, both ways.
|
||||
//! - `scene`: the document walk both writers share.
|
||||
//! - `emf`, `wmf`: the writers.
|
||||
//! - `import`: the reader.
|
||||
|
||||
mod bytes;
|
||||
mod dib;
|
||||
mod emf;
|
||||
mod import;
|
||||
mod scene;
|
||||
mod wmf;
|
||||
|
||||
use vectorcraft_doc::Document;
|
||||
use vectorcraft_geom::Rect;
|
||||
|
||||
/// A Windows metafile format.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Kind {
|
||||
/// Enhanced Metafile (32-bit coordinates, Bézier curves, paths and clipping).
|
||||
Emf,
|
||||
/// Windows Metafile with a placeable header (16-bit coordinates, polygons).
|
||||
Wmf,
|
||||
}
|
||||
|
||||
impl Kind {
|
||||
/// The format id (`emf`, `wmf`).
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
Self::Emf => "emf",
|
||||
Self::Wmf => "wmf",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The metafile format `bytes` start like, if any.
|
||||
pub fn sniff(bytes: &[u8]) -> Option<Kind> {
|
||||
if emf::is_emf(bytes) {
|
||||
Some(Kind::Emf)
|
||||
} else if wmf::is_wmf(bytes) {
|
||||
Some(Kind::Wmf)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// A written metafile.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Output {
|
||||
pub bytes: Vec<u8>,
|
||||
/// What was approximated or left out.
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// Write the visible art of `doc` over `region` (document space: its top-left corner is the
|
||||
/// picture's origin, its size the picture's) as `kind`. Live geometry effects are applied first;
|
||||
/// template layers, hidden objects and guides are left out. Raster effects should be turned into
|
||||
/// images before (the engine does); the ones left are reported and left out.
|
||||
pub fn export(doc: &Document, region: Rect, kind: Kind) -> Result<Output, String> {
|
||||
let r = region.abs();
|
||||
if ![r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite()) || r.width() <= 0.0 || r.height() <= 0.0 {
|
||||
return Err("the export region is empty".into());
|
||||
}
|
||||
let baked = vectorcraft_effects::bake_document(doc);
|
||||
let doc = baked.as_ref().unwrap_or(doc);
|
||||
let (ops, mut warnings) = scene::Scene::new(doc, r, kind == Kind::Emf).run();
|
||||
let bytes = match kind {
|
||||
Kind::Emf => emf::write(&ops, r, &doc.title),
|
||||
Kind::Wmf => wmf::write(&ops, r, &mut warnings),
|
||||
};
|
||||
Ok(Output { bytes, warnings })
|
||||
}
|
||||
|
||||
/// A metafile read into a document.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Imported {
|
||||
pub document: Document,
|
||||
pub kind: Kind,
|
||||
/// What was approximated or skipped.
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// Read an EMF or WMF file (with or without a placeable header) into a document whose one
|
||||
/// artboard is the picture's frame.
|
||||
pub fn import(bytes: &[u8]) -> Result<Imported, String> {
|
||||
import::import(bytes)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests_import;
|
||||
|
|
@ -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,311 @@
|
|||
//! Metafiles read back: VectorCraft's own EMF and WMF round-trip, hand-built records of other
|
||||
//! writers play as they draw, and damaged files fail or import partly without a panic.
|
||||
|
||||
use vectorcraft_color::{Gradient, GradientPaint, Paint};
|
||||
use vectorcraft_doc::{AppearanceItem, Dash, Document, Node, NodeKind};
|
||||
use vectorcraft_geom::{Affine, Rect, shapes};
|
||||
|
||||
use crate::bytes::Out;
|
||||
use crate::tests::*;
|
||||
use crate::*;
|
||||
|
||||
/// Every object of the imported document, depth first.
|
||||
fn all(d: &Document) -> Vec<Node> {
|
||||
let mut v = vec![];
|
||||
for l in &d.layers {
|
||||
for c in l.children().into_iter().flatten() {
|
||||
c.walk(&mut |n| v.push(n.clone()));
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
fn paths(d: &Document) -> Vec<Node> {
|
||||
all(d).into_iter().filter(|n| matches!(n.kind, NodeKind::Path { clipping: false, .. })).collect()
|
||||
}
|
||||
|
||||
fn near(a: Rect, b: Rect, tol: f64) -> bool {
|
||||
[(a.x0, b.x0), (a.y0, b.y0), (a.x1, b.x1), (a.y1, b.y1)].iter().all(|(x, y)| (x - y).abs() <= tol)
|
||||
}
|
||||
|
||||
fn fill_rgb(n: &Node) -> Option<[u8; 3]> {
|
||||
n.appearance.items.iter().find_map(|i| match i {
|
||||
AppearanceItem::Fill(f) => f.paint.color().map(|c| {
|
||||
let [r, g, b, _] = c.to_rgba8(1.0);
|
||||
[r, g, b]
|
||||
}),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
fn sample() -> Document {
|
||||
let mut d = doc_with(200.0, 100.0, vec![]);
|
||||
add(&mut d, path(shapes::rectangle(Rect::new(10.0, 10.0, 90.0, 60.0)), red(), blue(), 4.0));
|
||||
add(&mut d, path(shapes::ellipse(Rect::new(100.0, 20.0, 180.0, 80.0)), blue(), Paint::None, 0.0));
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emf_output_round_trips() {
|
||||
let d = sample();
|
||||
let out = emf(&d);
|
||||
let back = import(&out.bytes).unwrap();
|
||||
assert_eq!(back.kind, Kind::Emf);
|
||||
assert!(back.warnings.is_empty(), "{:?}", back.warnings);
|
||||
let ab = back.document.artboards[0].rect;
|
||||
assert!(near(ab, Rect::new(0.0, 0.0, 200.0, 100.0), 0.02), "{ab:?}");
|
||||
let p = paths(&back.document);
|
||||
// The rectangle's fill (its stroke is drawn after it, as another path) and the ellipse.
|
||||
let rect: Vec<&Node> = p.iter().filter(|n| fill_rgb(n) == Some([255, 0, 0])).collect();
|
||||
assert_eq!(rect.len(), 1);
|
||||
assert!(near(rect[0].geometric_bounds().unwrap(), Rect::new(10.0, 10.0, 90.0, 60.0), 0.03));
|
||||
let stroke = p.iter().find_map(|n| n.appearance.stroke().cloned()).unwrap();
|
||||
assert!((stroke.width - 4.0).abs() < 0.02, "{}", stroke.width);
|
||||
assert_eq!(stroke.paint.color().map(|c| c.to_rgba8(1.0)), Some([0, 0, 255, 255]));
|
||||
let ellipse = p.iter().find(|n| fill_rgb(n) == Some([0, 0, 255])).unwrap();
|
||||
assert!(near(ellipse.geometric_bounds().unwrap(), Rect::new(100.0, 20.0, 180.0, 80.0), 0.03));
|
||||
assert!(
|
||||
matches!(&ellipse.kind, NodeKind::Path { path, .. } if path.to_bezpath().elements().iter().any(|e| matches!(e, kurbo::PathEl::CurveTo(..))))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wmf_output_round_trips() {
|
||||
let d = sample();
|
||||
let back = import(&wmf(&d).bytes).unwrap();
|
||||
assert_eq!(back.kind, Kind::Wmf);
|
||||
assert!(back.warnings.is_empty(), "{:?}", back.warnings);
|
||||
assert!(near(back.document.artboards[0].rect, Rect::new(0.0, 0.0, 200.0, 100.0), 0.05));
|
||||
let p = paths(&back.document);
|
||||
let rect = p.iter().find(|n| fill_rgb(n) == Some([255, 0, 0])).unwrap();
|
||||
// 1440 units an inch: within a twentieth of a point.
|
||||
assert!(near(rect.geometric_bounds().unwrap(), Rect::new(10.0, 10.0, 90.0, 60.0), 0.05));
|
||||
let stroke = p.iter().find_map(|n| n.appearance.stroke().cloned()).unwrap();
|
||||
assert!((stroke.width - 4.0).abs() < 0.05);
|
||||
let ellipse = p.iter().find(|n| fill_rgb(n) == Some([0, 0, 255])).unwrap();
|
||||
assert!(near(ellipse.geometric_bounds().unwrap(), Rect::new(100.0, 20.0, 180.0, 80.0), 0.1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dashes_gradients_and_images_round_trip_through_emf() {
|
||||
let mut line = path(shapes::line((10.0, 90.0).into(), (190.0, 90.0).into()), Paint::None, red(), 2.0);
|
||||
line.appearance.stroke_mut().unwrap().dash = Some(Dash { pattern: vec![6.0, 3.0], offset: 0.0, align_corners: false });
|
||||
let g = Paint::Gradient(Box::new(GradientPaint::new(Gradient::default())));
|
||||
let mut d = doc_with(200.0, 100.0, vec![line, path(shapes::rectangle(Rect::new(10.0, 10.0, 60.0, 30.0)), g, Paint::None, 0.0)]);
|
||||
let img = image_node(&mut d, png(4, 2, [0, 200, 0, 255]), 4, 2, Affine::translate((100.0, 10.0)) * Affine::scale(5.0));
|
||||
add(&mut d, img);
|
||||
let back = import(&emf(&d).bytes).unwrap();
|
||||
assert!(back.warnings.is_empty(), "{:?}", back.warnings);
|
||||
let nodes = all(&back.document);
|
||||
let dash = nodes.iter().find_map(|n| n.appearance.stroke().and_then(|s| s.dash.clone())).unwrap();
|
||||
assert!((dash.pattern[0] - 6.0).abs() < 0.02 && (dash.pattern[1] - 3.0).abs() < 0.02, "{dash:?}");
|
||||
// The gradient: an image clipped to its rectangle (the picture's own clip is dropped); the
|
||||
// image: its pixels where they were.
|
||||
let groups: Vec<&Node> = nodes.iter().filter(|n| matches!(n.kind, NodeKind::Group { clip: true, .. })).collect();
|
||||
assert_eq!(groups.len(), 1);
|
||||
let clipped = groups[0].children().unwrap();
|
||||
assert!(near(clipped[0].geometric_bounds().unwrap(), Rect::new(10.0, 10.0, 60.0, 30.0), 0.03));
|
||||
assert!(matches!(clipped[1].kind, NodeKind::Image(_)));
|
||||
let im = nodes
|
||||
.iter()
|
||||
.find_map(|n| match &n.kind {
|
||||
NodeKind::Image(im) if im.width == 4 => Some(im.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(im.height, 2);
|
||||
let placed = im.xf.transform_rect_bbox(Rect::new(0.0, 0.0, 4.0, 2.0));
|
||||
assert!(near(placed, Rect::new(100.0, 10.0, 120.0, 20.0), 0.05), "{placed:?}");
|
||||
let px = image::load_from_memory(&back.document.images[&im.key].bytes).unwrap().to_rgba8();
|
||||
assert_eq!(px.get_pixel(0, 0).0, [0, 200, 0, 255]);
|
||||
}
|
||||
|
||||
// ---------- hand-built records ----------
|
||||
|
||||
/// An EMF of `records` (type, body) with a frame of `w` × `h` hundredths of a millimetre on a
|
||||
/// device of 100 pixels a millimetre.
|
||||
fn hand_emf(w: i32, h: i32, records: &[(u32, Vec<u8>)]) -> Vec<u8> {
|
||||
let mut o = Out::default();
|
||||
let rec = |o: &mut Out, kind: u32, body: &[u8]| {
|
||||
o.u32(kind);
|
||||
o.u32(8 + body.len() as u32);
|
||||
o.bytes(body);
|
||||
};
|
||||
let mut head = Out::default();
|
||||
head.bytes(&[0; 16]);
|
||||
for v in [0, 0, w, h] {
|
||||
head.i32(v);
|
||||
}
|
||||
head.u32(0x464D_4520);
|
||||
head.u32(0x10000);
|
||||
head.bytes(&[0; 8]);
|
||||
head.u16(1);
|
||||
head.u16(0);
|
||||
head.bytes(&[0; 12]);
|
||||
for v in [10000, 10000, 100, 100] {
|
||||
head.i32(v);
|
||||
}
|
||||
rec(&mut o, 1, &head.0);
|
||||
for (k, body) in records {
|
||||
rec(&mut o, *k, body);
|
||||
}
|
||||
rec(&mut o, 14, &[0, 0, 0, 0, 16, 0, 0, 0, 20, 0, 0, 0]);
|
||||
o.0
|
||||
}
|
||||
|
||||
fn le(values: &[i32]) -> Vec<u8> {
|
||||
values.iter().flat_map(|v| v.to_le_bytes()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hand_built_emf_plays_like_gdi() {
|
||||
let mut text = vec![0; 16];
|
||||
text.extend(le(&[1, 0, 0]));
|
||||
// At (1000, 1500), 2 characters at offset 76 (from the record start).
|
||||
text.extend(le(&[1000, 1500, 2, 76, 0, 0, 0, 0, 0, 0]));
|
||||
text.extend("Hi".encode_utf16().flat_map(u16::to_le_bytes));
|
||||
let mut font = le(&[2, -1000, 0, 0, 0, 700]);
|
||||
font.extend([0u8; 8]);
|
||||
font.extend("Arial".encode_utf16().chain(std::iter::repeat_n(0, 27)).flat_map(u16::to_le_bytes));
|
||||
let records = vec![
|
||||
// A green brush in slot 1, selected; a null pen.
|
||||
(39, le(&[1, 0, 0x00_ff_00, 0])),
|
||||
(37, le(&[1])),
|
||||
(37, le(&[0x8000_0008u32 as i32])),
|
||||
// A rectangle of 10 × 5 mm at (10, 10) mm.
|
||||
(43, le(&[1000, 1000, 2000, 1500])),
|
||||
// A record nobody knows, and one of a kind not read.
|
||||
(9999, le(&[1, 2])),
|
||||
(41, le(&[0, 0, 10, 0, 0])),
|
||||
// Clip to a rectangle; a black pen draws two lines one after the other.
|
||||
(30, le(&[0, 0, 1500, 1500])),
|
||||
(37, le(&[0x8000_0007u32 as i32])),
|
||||
(27, le(&[0, 0])),
|
||||
(54, le(&[500, 0])),
|
||||
(54, le(&[500, 500])),
|
||||
// A 10 mm bold font and red text.
|
||||
(82, font),
|
||||
(37, le(&[2])),
|
||||
(24, le(&[0xff])),
|
||||
(84, text),
|
||||
];
|
||||
let back = import(&hand_emf(5000, 3000, &records)).unwrap();
|
||||
let mm = 72.0 / 25.4;
|
||||
assert!(near(back.document.artboards[0].rect, Rect::new(0.0, 0.0, 50.0 * mm, 30.0 * mm), 0.01));
|
||||
assert_eq!(back.warnings, vec!["2 records of kinds VectorCraft doesn't read were skipped".to_string()]);
|
||||
let layer = back.document.layers[0].children().unwrap().clone();
|
||||
// The rectangle outside the clip; the lines and the text in a clipping group.
|
||||
assert_eq!(layer.len(), 2);
|
||||
assert_eq!(fill_rgb(&layer[0]), Some([0, 255, 0]));
|
||||
assert!(near(layer[0].geometric_bounds().unwrap(), Rect::new(10.0 * mm, 10.0 * mm, 20.0 * mm, 15.0 * mm), 0.01));
|
||||
assert!(layer[0].appearance.stroke().is_none(), "the null pen");
|
||||
let NodeKind::Group { children, clip: true } = &layer[1].kind else { panic!("a clipping group") };
|
||||
assert!(matches!(children[0].kind, NodeKind::Path { clipping: true, .. }));
|
||||
assert!(near(children[0].geometric_bounds().unwrap(), Rect::new(0.0, 0.0, 15.0 * mm, 15.0 * mm), 0.01));
|
||||
// The two lines drawn one after the other are one path.
|
||||
let lines = &children[1];
|
||||
assert_eq!(lines.appearance.items.len(), 1);
|
||||
assert!(near(lines.geometric_bounds().unwrap(), Rect::new(0.0, 0.0, 5.0 * mm, 5.0 * mm), 0.01));
|
||||
let NodeKind::Text(t) = &children[2].kind else { panic!("text") };
|
||||
assert_eq!(t.plain_text(), "Hi");
|
||||
let st = &t.runs[0].style;
|
||||
assert_eq!((st.font_family.as_str(), st.font_style.as_str()), ("Arial", "Bold"));
|
||||
assert!((st.size - 10.0 * mm).abs() < 0.01);
|
||||
assert_eq!(st.fill.color().map(|c| c.to_rgba8(1.0)), Some([255, 0, 0, 255]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emf_mapping_modes_and_world_transforms() {
|
||||
let records = vec![
|
||||
// MM_ANISOTROPIC: a 100 × 100 window on a 1000 × 2000 viewport.
|
||||
(17, le(&[8])),
|
||||
(9, le(&[100, 100])),
|
||||
(11, le(&[1000, 2000])),
|
||||
(43, le(&[0, 0, 10, 10])),
|
||||
// Then a world transform moving by (5, 0) logical units.
|
||||
(35, [1.0f32, 0.0, 0.0, 1.0, 5.0, 0.0].iter().flat_map(|v| v.to_le_bytes()).collect()),
|
||||
(43, le(&[0, 0, 10, 10])),
|
||||
];
|
||||
let back = import(&hand_emf(10000, 10000, &records)).unwrap();
|
||||
let p = paths(&back.document);
|
||||
let mm = 72.0 / 25.4;
|
||||
// 10 units → 100 × 200 pixels → 1 × 2 mm.
|
||||
assert!(near(p[0].geometric_bounds().unwrap(), Rect::new(0.0, 0.0, mm, 2.0 * mm), 0.01));
|
||||
assert!(near(p[1].geometric_bounds().unwrap(), Rect::new(0.5 * mm, 0.0, 1.5 * mm, 2.0 * mm), 0.01));
|
||||
}
|
||||
|
||||
/// A placeable WMF of `records` (function, parameters) in a `w` × `h` box at 1440 units an inch.
|
||||
fn hand_wmf(w: i16, h: i16, records: &[(u16, Vec<u8>)]) -> Vec<u8> {
|
||||
let mut o = Out::default();
|
||||
o.u32(0x9AC6_CDD7);
|
||||
o.u16(0);
|
||||
for v in [0, 0, w, h] {
|
||||
o.i16(v);
|
||||
}
|
||||
o.u16(1440);
|
||||
o.u32(0);
|
||||
let sum = crate::wmf::checksum(&o.0);
|
||||
o.u16(sum);
|
||||
o.bytes(&[1, 0, 9, 0, 0, 3]);
|
||||
o.bytes(&[0; 12]);
|
||||
for (f, body) in records.iter().chain(&[(0, vec![])]) {
|
||||
o.u32(3 + body.len() as u32 / 2);
|
||||
o.u16(*f);
|
||||
o.bytes(body);
|
||||
}
|
||||
o.0
|
||||
}
|
||||
|
||||
fn le16(values: &[i16]) -> Vec<u8> {
|
||||
values.iter().flat_map(|v| v.to_le_bytes()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hand_built_wmf_plays_like_gdi() {
|
||||
let records = vec![
|
||||
// A blue brush (slot 0), a 20-unit red pen with square caps (slot 1), selected.
|
||||
(0x02FC, [le16(&[0]), 0x00ff_0000u32.to_le_bytes().to_vec(), le16(&[0])].concat()),
|
||||
(0x02FA, [le16(&[0x100, 20, 0]), 0x0000_00ffu32.to_le_bytes().to_vec()].concat()),
|
||||
(0x012D, le16(&[0])),
|
||||
(0x012D, le16(&[1])),
|
||||
// A rectangle (bottom, right, top, left): 1 × 0.5 inches at the origin.
|
||||
(0x041B, le16(&[720, 1440, 0, 0])),
|
||||
(0x0999, le16(&[1, 2])),
|
||||
// Delete the brush: the next object takes slot 0 again.
|
||||
(0x01F0, le16(&[0])),
|
||||
(0x02FC, [le16(&[0]), 0x0000_ff00u32.to_le_bytes().to_vec(), le16(&[0])].concat()),
|
||||
(0x012D, le16(&[0])),
|
||||
(0x0418, le16(&[1440, 1440, 720, 0])),
|
||||
];
|
||||
let back = import(&hand_wmf(1440, 1440, &records)).unwrap();
|
||||
assert!(near(back.document.artboards[0].rect, Rect::new(0.0, 0.0, 72.0, 72.0), 1e-9));
|
||||
assert_eq!(back.warnings, vec!["1 record of kinds VectorCraft doesn't read was skipped".to_string()]);
|
||||
let p = paths(&back.document);
|
||||
assert_eq!(p.len(), 2);
|
||||
assert_eq!(fill_rgb(&p[0]), Some([0, 0, 255]));
|
||||
assert!(near(p[0].geometric_bounds().unwrap(), Rect::new(0.0, 0.0, 72.0, 36.0), 1e-9));
|
||||
let st = p[0].appearance.stroke().unwrap();
|
||||
assert!((st.width - 1.0).abs() < 1e-9 && st.cap == vectorcraft_doc::LineCap::Square);
|
||||
assert_eq!(fill_rgb(&p[1]), Some([0, 255, 0]), "the new brush took the freed slot");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn damaged_files_fail_or_import_partly() {
|
||||
let good = emf(&sample()).bytes;
|
||||
for cut in [0, 8, 50, 107, 108, 120, good.len() / 2, good.len() - 4] {
|
||||
let _ = import(&good[..cut]);
|
||||
}
|
||||
let cut = import(&good[..good.len() / 2]).unwrap();
|
||||
assert!(cut.warnings.iter().any(|w| w.contains("ends early")));
|
||||
let good = wmf(&sample()).bytes;
|
||||
for cut in [0, 4, 22, 30, 40, good.len() / 2] {
|
||||
let _ = import(&good[..cut]);
|
||||
}
|
||||
assert!(import(b"not a metafile").is_err());
|
||||
// Huge counts, sizes and offsets are refused, not allocated.
|
||||
let huge = hand_emf(100, 100, &[(3, le(&[0, 0, 0, 0, i32::MAX])), (81, le(&[0; 18])), (8, le(&[0, 0, 0, 0, i32::MAX, i32::MAX]))]);
|
||||
let back = import(&huge).unwrap();
|
||||
assert!(paths(&back.document).is_empty());
|
||||
let huge = hand_wmf(100, 100, &[(0x0324, le16(&[i16::MAX])), (0x0538, le16(&[-1]))]);
|
||||
assert!(import(&huge).is_ok());
|
||||
}
|
||||
|
|
@ -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)));
|
||||
}
|
||||
|
|
@ -69,6 +69,8 @@ mod tests_svgsave;
|
|||
#[cfg(test)]
|
||||
mod tests_sysclip;
|
||||
#[cfg(test)]
|
||||
mod tests_sysclip_emf;
|
||||
#[cfg(test)]
|
||||
mod tests_transparencygrid;
|
||||
|
||||
use std::sync::mpsc::{Receiver, Sender};
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
//! The system clipboard's formats: Copy and Cut publish every flavour the engine makes
|
||||
//! ([`Session::clipboard_flavours`](vectorcraft_engine::Session::clipboard_flavours): text, SVG,
|
||||
//! PDF, PNG), and a Paste first turns what another app copied (SVG, PDF, text, a bitmap) into the
|
||||
//! internal clipboard with the `clipboard.import*` commands. The host installs the platform side
|
||||
//! as [`Services::system_clipboard`](crate::Services::system_clipboard); without it (the web) Copy
|
||||
//! publishes SVG text through egui and Paste takes SVG text only.
|
||||
//! PDF, PNG), and a Paste first turns what another app copied (SVG, PDF, an EMF, text, a bitmap)
|
||||
//! into the internal clipboard with the `clipboard.import*` commands. The host installs the
|
||||
//! platform side as [`Services::system_clipboard`](crate::Services::system_clipboard); without it
|
||||
//! (the web) Copy publishes SVG text through egui and Paste takes SVG text only.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_engine::cmd::clipboard::{Flavour, PASTE_ORDER, PDF, SVG, TEXT, looks_like_svg};
|
||||
use vectorcraft_engine::cmd::clipboard::{EMF, Flavour, PASTE_ORDER, PDF, SVG, TEXT, looks_like_svg};
|
||||
|
||||
use crate::VectorcraftApp;
|
||||
|
||||
|
|
@ -36,6 +36,7 @@ pub(crate) fn import_command(f: &Flavour, center: Option<[f64; 2]>) -> (&'static
|
|||
}
|
||||
SVG => ("clipboard.importSvg", json!({ "dataBase64": b64(), "center": center })),
|
||||
PDF => ("clipboard.importPdf", json!({ "dataBase64": b64(), "center": center })),
|
||||
EMF => ("clipboard.importEmf", json!({ "dataBase64": b64(), "center": center })),
|
||||
mime => ("clipboard.importImage", json!({ "dataBase64": b64(), "mime": mime, "center": center })),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ use crate::{Services, SystemClipboard, VectorcraftApp, menus};
|
|||
|
||||
/// The fake clipboard's contents: what the app wrote, or what "another app" copied.
|
||||
#[derive(Default)]
|
||||
struct Board {
|
||||
pub(super) struct Board {
|
||||
flavours: Vec<Flavour>,
|
||||
ours: bool,
|
||||
}
|
||||
|
|
@ -46,7 +46,7 @@ impl SystemClipboard for Fake {
|
|||
}
|
||||
|
||||
/// An app with a document and a fake system clipboard, the view centred on (250, 180).
|
||||
fn app() -> (VectorcraftApp, Rc<RefCell<Board>>) {
|
||||
pub(super) fn app() -> (VectorcraftApp, Rc<RefCell<Board>>) {
|
||||
let board = Rc::new(RefCell::new(Board::default()));
|
||||
let services = Services { system_clipboard: Some(Box::new(Fake(board.clone()))), ..Default::default() };
|
||||
let mut app = VectorcraftApp::new(Session::new(), services);
|
||||
|
|
@ -57,12 +57,12 @@ fn app() -> (VectorcraftApp, Rc<RefCell<Board>>) {
|
|||
}
|
||||
|
||||
/// Another app copies `flavours`.
|
||||
fn copy_elsewhere(board: &Rc<RefCell<Board>>, flavours: Vec<(&'static str, Vec<u8>)>) {
|
||||
pub(super) fn copy_elsewhere(board: &Rc<RefCell<Board>>, flavours: Vec<(&'static str, Vec<u8>)>) {
|
||||
let flavours = flavours.into_iter().map(|(mime, data)| Flavour { mime, data }).collect();
|
||||
*board.borrow_mut() = Board { flavours, ours: false };
|
||||
}
|
||||
|
||||
fn run(app: &mut VectorcraftApp, id: &str, p: Value) -> Value {
|
||||
pub(super) fn run(app: &mut VectorcraftApp, id: &str, p: Value) -> Value {
|
||||
app.run(id, p).unwrap_or_else(|e| panic!("{id}: {e}"))
|
||||
}
|
||||
|
||||
|
|
@ -78,7 +78,7 @@ fn written(board: &Rc<RefCell<Board>>) -> Vec<&'static str> {
|
|||
}
|
||||
|
||||
/// The objects the last paste selected.
|
||||
fn pasted(app: &VectorcraftApp) -> Vec<Node> {
|
||||
pub(super) fn pasted(app: &VectorcraftApp) -> Vec<Node> {
|
||||
let st = app.session.active().unwrap();
|
||||
st.selection.objects.iter().map(|id| st.doc.node(*id).unwrap().clone()).collect()
|
||||
}
|
||||
|
|
@ -88,7 +88,7 @@ fn count(app: &VectorcraftApp) -> usize {
|
|||
}
|
||||
|
||||
/// A 4 × 2 px blue PNG.
|
||||
fn blue_png() -> Vec<u8> {
|
||||
pub(super) fn blue_png() -> Vec<u8> {
|
||||
let img = image::RgbaImage::from_pixel(4, 2, image::Rgba([0, 0, 255, 255]));
|
||||
let mut png = vec![];
|
||||
img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,28 @@
|
|||
//! Windows metafiles another app copied paste as vector art (M4.95, through the system clipboard
|
||||
//! service of M4.57).
|
||||
|
||||
use serde_json::json;
|
||||
use vectorcraft_doc::NodeKind;
|
||||
use vectorcraft_engine::Session;
|
||||
use vectorcraft_engine::cmd::clipboard::{EMF, PNG};
|
||||
|
||||
use crate::sysclip::import_command;
|
||||
use crate::tests_sysclip::{app, blue_png, copy_elsewhere, pasted, run};
|
||||
|
||||
#[test]
|
||||
fn an_emf_from_another_app_pastes_as_vectors_ahead_of_its_bitmap() {
|
||||
let (mut app, board) = app();
|
||||
let mut other = Session::new();
|
||||
other.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
|
||||
other.execute("shape.ellipse", &json!({"x": 10, "y": 10, "width": 30, "height": 30})).unwrap();
|
||||
let emf = other.execute("document.export", &json!({"format": "emf"})).unwrap();
|
||||
let emf = vectorcraft_format::base64_decode(emf["dataBase64"].as_str().unwrap()).unwrap();
|
||||
let flavour = vectorcraft_engine::cmd::clipboard::Flavour { mime: EMF, data: emf.clone() };
|
||||
assert_eq!(import_command(&flavour, None).0, "clipboard.importEmf");
|
||||
copy_elsewhere(&board, vec![(PNG, blue_png()), (EMF, emf)]);
|
||||
run(&mut app, "edit.paste", json!({}));
|
||||
let p = pasted(&app);
|
||||
assert!(!p.is_empty() && p.iter().all(|n| !matches!(n.kind, NodeKind::Image(_))), "{p:?}");
|
||||
let b = p[0].geometric_bounds().unwrap();
|
||||
assert!((b.width() - 30.0).abs() < 0.05, "{b:?}");
|
||||
}
|
||||
|
|
@ -361,3 +361,7 @@ Native save options: File → Save As to a native or `.ai` file opens the `saveO
|
|||
artboard; false: `range`, such as "1-3, 5"), `includeLinked`, `embedProfiles`, `pdfCompatible`, `compress`, and for
|
||||
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). Open and
|
||||
Place read `.emf` and `.wmf`; a paste of `image/emf` from a system clipboard service runs `clipboard.importEmf`.
|
||||
|
|
|
|||
25
docs/mcp.md
25
docs/mcp.md
|
|
@ -1338,3 +1338,28 @@ and Save as Template reuse the options as last saved (or Use Compression).
|
|||
{"name":"run_command","arguments":{"command":"file.saveAs","params":{"path":"/tmp/set.vectorcraft","separateArtboards":true,"range":"2-3","includeLinked":true}}}
|
||||
{"name":"run_command","arguments":{"command":"file.saveAs","params":{"path":"/tmp/set.ai","pdfCompatible":false}}}
|
||||
```
|
||||
|
||||
## EMF and WMF
|
||||
|
||||
`document.export` (and `export`) with format `emf` or `wmf` writes a Windows metafile of the first artboard (or
|
||||
`artboard`); `useArtboards: true` writes one file per chosen artboard (`{stem}-{artboard}.emf`), `false` the bounds of
|
||||
the visible art. EMF keeps Bézier curves, solid fills (brushes), strokes as geometric pens with their width, caps,
|
||||
joins, miter limit and dashes, clipping masks (path clips), images with their transparency (AlphaBlend) and type as
|
||||
outlines; gradients become images clipped to their shape, pattern fills their tiles clipped to the shape, and raster
|
||||
effects images rendered at the document's raster effects resolution. The header's frame is the artboard in 0.01 mm.
|
||||
WMF flattens curves into polygons in 16-bit units behind a placeable header (1440 units an inch, fewer for pictures
|
||||
over about 22 inches); it has no clipping, transparency or gradients, so clipped art is written whole, images over
|
||||
white and gradients and patterns as one colour. What a format leaves out comes back in `warnings`.
|
||||
|
||||
`document.open` and `file.place` read `.emf` and `.wmf` (placeable or not) into one artboard, the picture's frame, and
|
||||
one layer: paths with their fills and strokes (pens become strokes with caps, joins and dashes), clipping groups,
|
||||
images and point type in the font the file names. Records VectorCraft doesn't read (EMF+ drawing among them) are
|
||||
skipped with one warning. `clipboard.importEmf {dataBase64, center?}` loads an EMF or WMF picture into the clipboard
|
||||
for `edit.paste`; the app pastes `image/emf` from the system clipboard ahead of text and bitmaps when the platform's
|
||||
clipboard service offers it (the Windows desktop service doesn't read metafiles yet).
|
||||
|
||||
```json
|
||||
{"name":"export","arguments":{"path":"/tmp/logo.emf"}}
|
||||
{"name":"run_command","arguments":{"command":"document.export","params":{"format":"wmf","useArtboards":true,"path":"/tmp/icons.wmf"}}}
|
||||
{"name":"run_command","arguments":{"command":"file.place","params":{"path":"/tmp/chart.emf","at":[300,200]}}}
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
Loading…
Reference in New Issue