mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-26 02:37:01 -06:00
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>
331 lines
14 KiB
Rust
331 lines
14 KiB
Rust
//! 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");
|
||
}
|