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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 15:24:24 +07:00

331 lines
14 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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");
}