Files
vectorcraft/crates/metafile/src/wmf.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

442 lines
15 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.
//! 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
}
}