From f8d54d7af4a1e830eda2bf74cf9903010daabd2d Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 12 Oct 2026 18:27:19 -0400 Subject: [PATCH] Pictures: WMF and EMF draw, with the metafile reader ported from WordCraft (#317) Co-authored-by: Claude Opus 5.5 (1M context) --- Cargo.lock | 9 + Cargo.toml | 1 + crates/metafile/Cargo.toml | 13 + crates/metafile/src/bytes.rs | 59 ++ crates/metafile/src/canvas.rs | 574 +++++++++++++++++++ crates/metafile/src/dib.rs | 259 +++++++++ crates/metafile/src/emf.rs | 315 ++++++++++ crates/metafile/src/geom.rs | 118 ++++ crates/metafile/src/lib.rs | 208 +++++++ crates/metafile/src/place.rs | 124 ++++ crates/metafile/src/tests.rs | 1018 +++++++++++++++++++++++++++++++++ crates/metafile/src/wmf.rs | 273 +++++++++ crates/render/Cargo.toml | 1 + crates/render/src/images.rs | 15 +- crates/render/src/lib.rs | 1 + crates/render/src/metafile.rs | 105 ++++ crates/render/src/tests.rs | 70 +++ docs/file-format-parity.md | 4 +- docs/gaps.md | 16 +- docs/roadmap.md | 2 +- xtask/src/layers.rs | 2 + 21 files changed, 3173 insertions(+), 14 deletions(-) create mode 100644 crates/metafile/Cargo.toml create mode 100644 crates/metafile/src/bytes.rs create mode 100644 crates/metafile/src/canvas.rs create mode 100644 crates/metafile/src/dib.rs create mode 100644 crates/metafile/src/emf.rs create mode 100644 crates/metafile/src/geom.rs create mode 100644 crates/metafile/src/lib.rs create mode 100644 crates/metafile/src/place.rs create mode 100644 crates/metafile/src/tests.rs create mode 100644 crates/metafile/src/wmf.rs create mode 100644 crates/render/src/metafile.rs diff --git a/Cargo.lock b/Cargo.lock index 65028e5..e8c9398 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1046,6 +1046,14 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "deckcraft-metafile" +version = "0.6.1" +dependencies = [ + "kurbo", + "thiserror 2.0.21", +] + [[package]] name = "deckcraft-model" version = "0.6.1" @@ -1103,6 +1111,7 @@ dependencies = [ "deckcraft-color", "deckcraft-fonts", "deckcraft-geom", + "deckcraft-metafile", "deckcraft-model", "deckcraft-text", "image", diff --git a/Cargo.toml b/Cargo.toml index 90715e5..b859615 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,7 @@ repository = "https://github.com/storytold/deckcraft" # internal deckcraft-geom = { path = "crates/geom" } deckcraft-color = { path = "crates/color" } +deckcraft-metafile = { path = "crates/metafile" } deckcraft-model = { path = "crates/model" } deckcraft-fonts = { path = "crates/fonts" } deckcraft-text = { path = "crates/text" } diff --git a/crates/metafile/Cargo.toml b/crates/metafile/Cargo.toml new file mode 100644 index 0000000..63f7ee7 --- /dev/null +++ b/crates/metafile/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "deckcraft-metafile" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "DeckCraft metafiles (ported from WordCraft): parses Windows WMF and EMF records into a neutral vector model (paths and bitmaps)" + +[lints] +workspace = true + +[dependencies] +kurbo = { workspace = true } +thiserror = { workspace = true } diff --git a/crates/metafile/src/bytes.rs b/crates/metafile/src/bytes.rs new file mode 100644 index 0000000..5584d6d --- /dev/null +++ b/crates/metafile/src/bytes.rs @@ -0,0 +1,59 @@ +//! Bounds-checked little-endian access to hostile input. Every read returns `None` past the end, so +//! a truncated or lying size field stops the parse instead of panicking. + +#[derive(Clone, Copy)] +pub(crate) struct Bytes<'a>(pub(crate) &'a [u8]); + +impl<'a> Bytes<'a> { + pub fn slice(&self, off: usize, len: usize) -> Option<&'a [u8]> { + self.0.get(off..off.checked_add(len)?) + } + + pub fn tail(&self, off: usize) -> Option> { + self.0.get(off..).map(Bytes) + } + + fn arr(&self, off: usize) -> Option<[u8; N]> { + self.slice(off, N)?.try_into().ok() + } + + pub fn u16(&self, off: usize) -> Option { + Some(u16::from_le_bytes(self.arr(off)?)) + } + + pub fn i16(&self, off: usize) -> Option { + Some(i16::from_le_bytes(self.arr(off)?)) + } + + pub fn u32(&self, off: usize) -> Option { + Some(u32::from_le_bytes(self.arr(off)?)) + } + + pub fn i32(&self, off: usize) -> Option { + Some(i32::from_le_bytes(self.arr(off)?)) + } + + pub fn f32(&self, off: usize) -> Option { + Some(f32::from_le_bytes(self.arr(off)?)) + } + + /// `n` consecutive (x, y) pairs of 16- or 32-bit signed integers starting at `off`. + pub fn points(&self, off: usize, n: usize, wide: bool) -> Option> { + let sz = if wide { 8 } else { 4 }; + let raw = self.slice(off, n.checked_mul(sz)?)?; + let mut out = Vec::with_capacity(n); + for c in raw.chunks_exact(sz) { + let p = if wide { + let x = i32::from_le_bytes(c.get(0..4)?.try_into().ok()?); + let y = i32::from_le_bytes(c.get(4..8)?.try_into().ok()?); + (f64::from(x), f64::from(y)) + } else { + let x = i16::from_le_bytes(c.get(0..2)?.try_into().ok()?); + let y = i16::from_le_bytes(c.get(2..4)?.try_into().ok()?); + (f64::from(x), f64::from(y)) + }; + out.push(p); + } + Some(out) + } +} diff --git a/crates/metafile/src/canvas.rs b/crates/metafile/src/canvas.rs new file mode 100644 index 0000000..fa8bfdc --- /dev/null +++ b/crates/metafile/src/canvas.rs @@ -0,0 +1,574 @@ +//! Drawing state shared by the WMF and EMF readers: window/viewport mapping and the EMF world +//! transform, pen and brush selection through an object table, the DC save stack and path brackets. +//! Output is device-space items; `lib.rs` maps them into the picture frame. + +use crate::dib::Rgba; +use crate::geom::{Op, P}; +use crate::{MAX_BYTES, MAX_ITEMS, MAX_OBJECTS, MAX_PATH_OPS, MAX_STACK}; + +pub(crate) const MM_TEXT: u32 = 1; +const MM_ISOTROPIC: u32 = 7; +const MM_ANISOTROPIC: u32 = 8; +const STOCK: u32 = 0x8000_0000; +pub(crate) const PS_NULL: u32 = 5; +/// Pen style bits; the rest of the style word holds end-cap, join and type flags. +pub(crate) const PS_STYLE_MASK: u32 = 0x0F; +const BS_SOLID: u32 = 0; +pub(crate) const BS_NULL: u32 = 1; +const WINDING: u32 = 2; +/// SETROP2 mix mode that leaves the destination alone: outlines and fills draw nothing. +const R2_NOP: u32 = 11; +const PATCOPY: u32 = 0x00F0_0021; +const BLACKNESS: u32 = 0x0000_0042; +const WHITENESS: u32 = 0x00FF_0062; +/// Device pixels per millimetre assumed for WMF (96 dpi), and for EMF files without a device size. +const DEFAULT_PX_PER_MM: f64 = 96.0 / 25.4; + +/// Affine transform `[m11, m12, m21, m22, dx, dy]` in EMF XFORM order: `x' = x·m11 + y·m21 + dx`. +pub(crate) type Xform = [f64; 6]; +const IDENTITY: Xform = [1.0, 0.0, 0.0, 1.0, 0.0, 0.0]; + +/// `a` then `b` in EMF row-vector convention: the matrix product `a × b`. +pub(crate) fn mul(a: Xform, b: Xform) -> Xform { + [ + a[0] * b[0] + a[1] * b[2], + a[0] * b[1] + a[1] * b[3], + a[2] * b[0] + a[3] * b[2], + a[2] * b[1] + a[3] * b[3], + a[4] * b[0] + a[5] * b[2] + b[4], + a[4] * b[1] + a[5] * b[3] + b[5], + ] +} + +#[derive(Clone, Copy, PartialEq, Debug)] +pub(crate) struct Pen { + pub style: u32, + /// Logical width; 0 means a cosmetic one-pixel hairline. + pub width: f64, + pub color: [u8; 4], +} + +#[derive(Clone, Copy, PartialEq, Debug)] +pub(crate) struct Brush { + pub style: u32, + pub color: [u8; 4], +} + +#[derive(Clone, Copy, Debug)] +pub(crate) enum Obj { + Pen(Pen), + Brush(Brush), + /// Fonts, palettes, pattern brushes and regions: they take a table slot but draw nothing. + Other, +} + +/// Converts a COLORREF (0x00BBGGRR) to opaque RGBA. +pub(crate) fn colorref(c: u32) -> [u8; 4] { + [(c & 0xFF) as u8, ((c >> 8) & 0xFF) as u8, ((c >> 16) & 0xFF) as u8, 255] +} + +/// Millimetres per logical unit of the metric map modes (LOMETRIC, HIMETRIC, LOENGLISH, HIENGLISH, TWIPS). +fn metric_mm(mode: u32) -> Option { + match mode { + 2 => Some(0.1), + 3 => Some(0.01), + 4 => Some(25.4 / 100.0), + 5 => Some(25.4 / 1000.0), + 6 => Some(25.4 / 1440.0), + _ => None, + } +} + +#[derive(Clone, Copy)] +pub(crate) struct Mapping { + wo: P, + we: P, + vo: P, + ve: P, + mode: u32, + world: Xform, + /// Device pixels per millimetre on each axis (EMF: from szlDevice and szlMillimeters). + px_mm: P, +} + +impl Mapping { + fn identity() -> Self { + Mapping { + wo: (0.0, 0.0), + we: (1.0, 1.0), + vo: (0.0, 0.0), + ve: (1.0, 1.0), + mode: MM_TEXT, + world: IDENTITY, + px_mm: (DEFAULT_PX_PER_MM, DEFAULT_PX_PER_MM), + } + } + + /// Logical point to device point: world transform, then window to viewport. + pub fn device(&self, p: P) -> P { + let w = self.world; + let (x, y) = (p.0 * w[0] + p.1 * w[2] + w[4], p.0 * w[1] + p.1 * w[3] + w[5]); + let (sx, sy) = self.ratios(); + ((x - self.wo.0) * sx + self.vo.0, (y - self.wo.1) * sy + self.vo.1) + } + + /// Window-to-viewport scale per axis. MM_TEXT fixes the scale at 1; the metric modes use a fixed + /// scale with y up; MM_ISOTROPIC uses one scale on both axes (the smaller magnitude, each axis's + /// sign kept); anisotropic and unknown modes use the extents as given. + fn ratios(&self) -> (f64, f64) { + if self.mode == MM_TEXT { + return (1.0, 1.0); + } + if let Some(mm) = metric_mm(self.mode) { + return (mm * self.px_mm.0, -mm * self.px_mm.1); + } + let (rx, ry) = (ratio(self.ve.0, self.we.0), ratio(self.ve.1, self.we.1)); + if self.mode == MM_ISOTROPIC { + let s = rx.abs().min(ry.abs()); + return (if rx < 0.0 { -s } else { s }, if ry < 0.0 { -s } else { s }); + } + (rx, ry) + } + + /// Approximate device length per logical length, for pen widths. + fn scale(&self) -> f64 { + let w = self.world; + let det = (w[0] * w[3] - w[1] * w[2]).abs().sqrt(); + let (sx, sy) = self.ratios(); + det * (sx.abs() + sy.abs()) / 2.0 + } + + /// True when the window-to-viewport extents set the scale (the isotropic and anisotropic modes). + pub fn uses_extents(&self) -> bool { + matches!(self.mode, MM_ISOTROPIC | MM_ANISOTROPIC) + } + + pub fn window_ext(&self) -> P { + self.we + } + + pub fn viewport_ext(&self) -> P { + self.ve + } + + /// The window in device units as `[x, y, w, h]` with positive extents, used as the picture frame + /// of a plain WMF. + pub fn window_rect(&self) -> [f64; 4] { + let a = self.device(self.wo); + let b = self.device((self.wo.0 + self.we.0, self.wo.1 + self.we.1)); + [a.0.min(b.0), a.1.min(b.1), (b.0 - a.0).abs(), (b.1 - a.1).abs()] + } +} + +fn ratio(num: f64, den: f64) -> f64 { + if den == 0.0 { 1.0 } else { num / den } +} + +/// Output of drawing, in device units. Path points are already mapped; `lib.rs` normalises them. +pub(crate) enum Raw { + Path { ops: Vec, fill: Option<[u8; 4]>, stroke: Option<([u8; 4], f64)>, even_odd: bool }, + Bitmap { rect: [f64; 4], img: Rgba }, +} + +#[derive(Clone)] +struct Saved { + map: Mapping, + pen: Pen, + brush: Brush, + even_odd: bool, + nop: bool, +} + +pub(crate) struct Canvas { + pub items: Vec, + pub map: Mapping, + /// True once SETWINDOWEXT was seen, so a plain WMF has a frame to use. + pub window_set: bool, + /// True once the file set the viewport extent itself (not the player, see [`Canvas::play_into`]). + pub viewport_set: bool, + saved: Vec, + /// Bytes held by the items so far (path ops and pixels), capped at [`MAX_BYTES`]. + used: usize, + objects: Vec>, + pen: Pen, + brush: Brush, + even_odd: bool, + /// The mix mode is R2_NOP: shapes draw nothing. + nop: bool, + pos: P, + in_path: bool, + path: Vec, + full: bool, +} + +impl Canvas { + pub fn new() -> Self { + Canvas { + items: Vec::new(), + map: Mapping::identity(), + window_set: false, + viewport_set: false, + saved: Vec::new(), + used: 0, + objects: Vec::new(), + pen: Pen { style: 0, width: 0.0, color: [0, 0, 0, 255] }, + brush: Brush { style: BS_SOLID, color: [255, 255, 255, 255] }, + even_odd: true, + nop: false, + pos: (0.0, 0.0), + in_path: false, + path: Vec::new(), + full: false, + } + } + + /// True once the item cap is hit; the readers stop there and keep what they have. + pub fn full(&self) -> bool { + self.full + } + + fn push(&mut self, raw: Raw) { + let cost = match &raw { + Raw::Path { ops, .. } => ops.len() * std::mem::size_of::(), + Raw::Bitmap { img, .. } => img.px.len(), + }; + if self.items.len() >= MAX_ITEMS || self.used.saturating_add(cost) > MAX_BYTES { + self.full = true; + } else { + self.used += cost; + self.items.push(raw); + } + } + + pub fn set_window_org(&mut self, p: P) { + self.map.wo = p; + } + + pub fn set_window_ext(&mut self, e: P) { + self.map.we = e; + self.window_set = true; + } + + pub fn set_viewport_org(&mut self, p: P) { + self.map.vo = p; + } + + pub fn set_viewport_ext(&mut self, e: P) { + self.map.ve = e; + self.viewport_set = true; + } + + /// Sets up the mapping the way a player does before it plays a WMF into a target: anisotropic, + /// `window` (`[x, y, w, h]` in logical units) onto a viewport of `size` at the origin. The file's + /// own window records then replace the window; the viewport stays unless the file sets one. + /// Without a window (a plain WMF) only the viewport extent is set: the map mode and viewport + /// origin stay the file's. + pub fn play_into(&mut self, window: Option<[f64; 4]>, size: P) { + if let Some([x, y, w, h]) = window { + self.map.mode = MM_ANISOTROPIC; + self.map.wo = (x, y); + self.map.we = (w, h); + self.map.vo = (0.0, 0.0); + } + self.map.ve = size; + } + + pub fn set_map_mode(&mut self, mode: u32) { + self.map.mode = mode; + } + + /// Device pixels per millimetre (x, y) for the metric map modes. Ignored unless both are positive. + pub fn set_device_mm(&mut self, px_mm: P) { + if px_mm.0.is_finite() && px_mm.1.is_finite() && px_mm.0 > 0.0 && px_mm.1 > 0.0 { + self.map.px_mm = px_mm; + } + } + + pub fn set_polyfill(&mut self, mode: u32) { + self.even_odd = mode != WINDING; + } + + /// SETROP2: only R2_NOP changes what is drawn (nothing); the other mix modes draw as copy. + pub fn set_rop2(&mut self, mode: u32) { + self.nop = mode == R2_NOP; + } + + /// EMF SETWORLDTRANSFORM (`mode` 4) and MODIFYWORLDTRANSFORM (1 identity, 2 left, 3 right, 4 set). + /// An unknown mode leaves the world transform as it was. + pub fn modify_world(&mut self, x: Xform, mode: u32) { + match mode { + 1 => self.map.world = IDENTITY, + 2 => self.map.world = mul(x, self.map.world), + 3 => self.map.world = mul(self.map.world, x), + 4 => self.map.world = x, + _ => {} + } + } + + /// Pushes the drawing state. Past [`MAX_STACK`] levels the parse stops, since later restores + /// could no longer match the file. + pub fn save_dc(&mut self) { + if self.saved.len() < MAX_STACK { + self.saved.push(Saved { map: self.map, pen: self.pen, brush: self.brush, even_odd: self.even_odd, nop: self.nop }); + } else { + self.full = true; + } + } + + /// Negative `n` pops `-n` levels; positive `n` restores absolute level `n`. + pub fn restore_dc(&mut self, n: i32) { + let idx = if n < 0 { self.saved.len().checked_sub(n.unsigned_abs() as usize) } else { (n as usize).checked_sub(1) }; + let Some(idx) = idx else { return }; + let Some(s) = self.saved.get(idx).cloned() else { return }; + self.saved.truncate(idx); + self.map = s.map; + self.pen = s.pen; + self.brush = s.brush; + self.even_odd = s.even_odd; + self.nop = s.nop; + } + + /// Stores an object in `slot` (EMF index), or in the first free slot (WMF handle table). + pub fn add_object(&mut self, slot: Option, obj: Obj) { + match slot { + Some(i) if i < MAX_OBJECTS => { + if self.objects.len() <= i { + self.objects.resize(i + 1, None); + } + if let Some(s) = self.objects.get_mut(i) { + *s = Some(obj); + } + } + Some(_) => {} + None => match self.objects.iter().position(Option::is_none) { + Some(i) => { + if let Some(s) = self.objects.get_mut(i) { + *s = Some(obj); + } + } + None if self.objects.len() < MAX_OBJECTS => self.objects.push(Some(obj)), + None => {} + }, + } + } + + pub fn delete_object(&mut self, i: u32) { + if let Some(s) = self.objects.get_mut(i as usize) { + *s = None; + } + } + + /// Selects an object by index; indices with the high bit set are stock objects. + pub fn select(&mut self, i: u32) { + if i & STOCK != 0 { + return self.select_stock(i & !STOCK); + } + match self.objects.get(i as usize).copied().flatten() { + Some(Obj::Pen(p)) => self.pen = p, + Some(Obj::Brush(b)) => self.brush = b, + Some(Obj::Other) | None => {} + } + } + + fn select_stock(&mut self, i: u32) { + let white = [255, 255, 255, 255]; + let black = [0, 0, 0, 255]; + let gray = |v: u8| Brush { style: BS_SOLID, color: [v, v, v, 255] }; + match i { + 0 | 18 => self.brush = Brush { style: BS_SOLID, color: white }, + 1 => self.brush = gray(192), + 2 => self.brush = gray(128), + 3 => self.brush = gray(64), + 4 => self.brush = Brush { style: BS_SOLID, color: black }, + 5 => self.brush = Brush { style: BS_NULL, color: black }, + 6 => self.pen = Pen { style: 0, width: 0.0, color: white }, + 7 | 19 => self.pen = Pen { style: 0, width: 0.0, color: black }, + 8 => self.pen = Pen { style: PS_NULL, width: 0.0, color: black }, + _ => {} + } + } + + fn map_op(&self, op: Op) -> Op { + match op { + Op::M(p) => Op::M(self.map.device(p)), + Op::L(p) => Op::L(self.map.device(p)), + Op::C(a, b, c) => Op::C(self.map.device(a), self.map.device(b), self.map.device(c)), + Op::Z => Op::Z, + } + } + + fn fill_color(&self) -> Option<[u8; 4]> { + (self.brush.style == BS_SOLID).then_some(self.brush.color) + } + + fn stroke_pen(&self) -> Option<([u8; 4], f64)> { + if (self.pen.style & PS_STYLE_MASK) == PS_NULL { + return None; + } + // A cosmetic pen (width 0) stays 0: the renderers draw that as a hairline. + let w = if self.pen.width == 0.0 { 0.0 } else { self.pen.width.abs() * self.map.scale() }; + Some((self.pen.color, w)) + } + + fn push_path_ops(&mut self, ops: impl IntoIterator) { + for op in ops { + if self.path.len() >= MAX_PATH_OPS { + return; + } + self.path.push(op); + } + } + + /// Draws logical-space ops with the current pen (`stroke`) and brush (`fill`), or appends them to + /// the open path bracket, where fill and stroke wait for FILLPATH/STROKEPATH. + pub fn shape(&mut self, ops: &[Op], fill: bool, stroke: bool) { + let dev: Vec = ops.iter().map(|o| self.map_op(*o)).collect(); + if self.in_path { + return self.push_path_ops(dev); + } + if self.nop { + return; + } + let fill = if fill { self.fill_color() } else { None }; + let stroke = if stroke { self.stroke_pen() } else { None }; + if (fill.is_none() && stroke.is_none()) || dev.len() < 2 { + return; + } + let even_odd = self.even_odd; + self.push(Raw::Path { ops: dev, fill, stroke, even_odd }); + } + + pub fn move_to(&mut self, p: P) { + if self.in_path { + self.push_path_ops([Op::M(self.map.device(p))]); + } + self.pos = p; + } + + /// LINETO from the current point, then moves the current point to `p`. + pub fn line_to(&mut self, p: P) { + self.line_chain(&[p], false, true); + } + + /// Lines from the current point through `pts` (POLYLINETO), or a polyline from the current point. + pub fn line_chain(&mut self, pts: &[P], fill: bool, stroke: bool) { + // Inside an open bracket the path already ends at the current point, so no MoveTo is needed. + let mut ops = if self.in_path && !self.path.is_empty() { Vec::new() } else { vec![Op::M(self.pos)] }; + ops.extend(pts.iter().map(|p| Op::L(*p))); + if let Some(last) = pts.last() { + self.pos = *last; + } + self.shape(&ops, fill, stroke); + } + + /// POLYBEZIER: cubic segments that start a new figure at `from`. The current point is unchanged. + pub fn bezier(&mut self, from: P, pts: &[P]) { + let mut ops = vec![Op::M(from)]; + ops.extend(cubics(pts)); + self.shape(&ops, false, true); + } + + /// POLYBEZIERTO: cubic segments from the current point through groups of three control or end + /// points; the current point moves to the last point. Inside an open bracket the path already + /// ends at the current point, so no MoveTo is needed. + pub fn bezier_to(&mut self, pts: &[P]) { + let mut ops = if self.in_path && !self.path.is_empty() { Vec::new() } else { vec![Op::M(self.pos)] }; + ops.extend(cubics(pts)); + if let Some(last) = pts.last() { + self.pos = *last; + } + self.shape(&ops, false, true); + } + + pub fn poly(&mut self, pts: &[P], close: bool, fill: bool, stroke: bool) { + let Some(first) = pts.first() else { return }; + let mut ops = vec![Op::M(*first)]; + ops.extend(pts.iter().skip(1).map(|p| Op::L(*p))); + if close { + ops.push(Op::Z); + } + self.shape(&ops, fill, stroke); + } + + pub fn begin_path(&mut self) { + self.in_path = true; + self.path.clear(); + } + + pub fn end_path(&mut self) { + self.in_path = false; + } + + pub fn close_figure(&mut self) { + if self.in_path { + self.push_path_ops([Op::Z]); + } + } + + /// FILLPATH, STROKEPATH and STROKEANDFILLPATH: paints the bracketed path and empties it. + pub fn paint_path(&mut self, fill: bool, stroke: bool) { + let ops = std::mem::take(&mut self.path); + if self.nop { + return; + } + let fill = if fill { self.fill_color() } else { None }; + let stroke = if stroke { self.stroke_pen() } else { None }; + if ops.len() < 2 || (fill.is_none() && stroke.is_none()) { + return; + } + let even_odd = self.even_odd; + self.push(Raw::Path { ops, fill, stroke, even_odd }); + } + + /// Fills a logical rectangle `[l, t, r, b]` with the current brush (nothing when it is not solid). + pub fn fill_brush_rect(&mut self, rect: [f64; 4]) { + if let Some(color) = self.fill_color() { + self.fill_rect_with(rect, color); + } + } + + /// Fills a logical rectangle `[l, t, r, b]` with a solid colour (ROP fills such as BLACKNESS). + pub fn fill_rect_with(&mut self, [l, t, r, b]: [f64; 4], color: [u8; 4]) { + let dev: Vec = crate::geom::rect(l, t, r, b).iter().map(|o| self.map_op(*o)).collect(); + self.push(Raw::Path { ops: dev, fill: Some(color), stroke: None, even_odd: true }); + } + + /// Whether a raster operation draws without a source bitmap (PATCOPY, BLACKNESS, WHITENESS). + pub fn sourceless(rop: u32) -> bool { + matches!(rop, PATCOPY | BLACKNESS | WHITENESS) + } + + /// Fills a logical rectangle `[x, y, w, h]` for a source-less raster operation. PATCOPY uses the + /// brush (when solid); the others fill black or white. Other operations are not handled here. + pub fn fill_rop(&mut self, [x, y, w, h]: [f64; 4], rop: u32) { + let rect = [x, y, x + w, y + h]; + match rop { + PATCOPY => self.fill_brush_rect(rect), + BLACKNESS => self.fill_rect_with(rect, [0, 0, 0, 255]), + WHITENESS => self.fill_rect_with(rect, [255, 255, 255, 255]), + _ => {} + } + } + + /// A bitmap stretched into a logical rectangle `[x, y, w, h]`. A negative extent mirrors the + /// bitmap, as the mapping does. + pub fn bitmap(&mut self, rect: [f64; 4], mut img: Rgba) { + let [x, y, w, h] = rect; + let a = self.map.device((x, y)); + let b = self.map.device((x + w, y + h)); + crate::dib::flip(&mut img, a.0 > b.0, a.1 > b.1); + let dev = [a.0.min(b.0), a.1.min(b.1), (b.0 - a.0).abs(), (b.1 - a.1).abs()]; + self.push(Raw::Bitmap { rect: dev, img }); + } +} + +/// Cubic segments from groups of three points (control, control, end). A trailing partial group is +/// dropped. +fn cubics(pts: &[P]) -> Vec { + let mut ops = Vec::with_capacity(pts.len() / 3); + let mut it = pts.iter().copied(); + while let (Some(a), Some(b), Some(e)) = (it.next(), it.next(), it.next()) { + ops.push(Op::C(a, b, e)); + } + ops +} diff --git a/crates/metafile/src/dib.rs b/crates/metafile/src/dib.rs new file mode 100644 index 0000000..55cf091 --- /dev/null +++ b/crates/metafile/src/dib.rs @@ -0,0 +1,259 @@ +//! Device-independent bitmaps. BITMAPINFOHEADER DIBs at 1/4/8/16/24/32 bpp with BI_RGB or BI_BITFIELDS +//! decode to RGBA8 rows, top-down. Compressed (RLE, JPEG, PNG) and V4/V5-only features are not decoded. + +use crate::{MAX_PIXELS, bytes::Bytes}; + +const BI_RGB: u32 = 0; +const BI_BITFIELDS: u32 = 3; +/// Most palette entries a DIB can carry (the colour table never exceeds 2^8). +const MAX_PALETTE: usize = 256; +const ROP_SRCAND: u32 = 0x0088_00C6; +const ROP_SRCPAINT: u32 = 0x00EE_0086; +const ROP_SRCINVERT: u32 = 0x0066_0046; + +/// A decoded picture. `bottom_up` records the DIB's native row order, which source rectangles use. +pub(crate) struct Rgba { + pub w: usize, + pub h: usize, + pub bottom_up: bool, + pub px: Vec, +} + +/// How a decoded bitmap combines with the destination. +#[derive(Clone, Copy)] +pub(crate) enum Blend { + /// A raster operation (BITBLT, STRETCHDIB and friends). + Rop(u32), + /// EMR_ALPHABLEND: a constant alpha (0..=255), times the per-pixel alpha when `per_pixel` is set + /// (AC_SRC_ALPHA: the source colours are premultiplied by it). + Alpha { constant: u8, per_pixel: bool }, +} + +impl Blend { + /// Whether the decoder must keep the 32-bit alpha byte of each pixel. + pub fn per_pixel(self) -> bool { + matches!(self, Blend::Alpha { per_pixel: true, .. }) + } +} + +struct Header { + w: usize, + h: usize, + top_down: bool, + bpp: usize, + masks: [u32; 3], + pal_off: usize, + pal_n: usize, +} + +impl Header { + fn read(info: Bytes) -> Option
{ + let size = info.u32(0)? as usize; + let w = info.i32(4)?; + let h = info.i32(8)?; + let bpp = info.u16(14)? as usize; + let comp = info.u32(16)?; + let clr_used = info.u32(32)? as usize; + if size < 40 || w <= 0 || h == 0 || !matches!(bpp, 1 | 4 | 8 | 16 | 24 | 32) || !matches!(comp, BI_RGB | BI_BITFIELDS) { + return None; + } + let masks = if comp == BI_BITFIELDS { + [info.u32(40)?, info.u32(44)?, info.u32(48)?] + } else if bpp == 16 { + [0x7C00, 0x03E0, 0x001F] + } else { + [0xFF_0000, 0xFF00, 0xFF] + }; + let masks_len = if comp == BI_BITFIELDS && size == 40 { 12 } else { 0 }; + let pal_off = size.checked_add(masks_len)?; + // Indexed formats take 2^bpp entries when clrUsed is 0. Deeper formats carry only the + // entries clrUsed names (WMF DIBs can have them), still capped. + let pal_n = match (bpp <= 8, clr_used) { + (true, 0) => 1usize << bpp, + (true, n) => n.min(1usize << bpp), + (false, n) => n.min(MAX_PALETTE), + }; + Some(Header { w: w as usize, h: h.unsigned_abs() as usize, top_down: h < 0, bpp, masks, pal_off, pal_n }) + } +} + +/// Byte length of the info part (header, masks, palette) of a DIB, so the pixel bits follow it. +pub(crate) fn info_len(dib: Bytes) -> Option { + let h = Header::read(dib)?; + h.pal_off.checked_add(h.pal_n.checked_mul(4)?) +} + +/// Decodes a DIB given as its info part (header plus masks and palette) and its pixel bits. The +/// 32-bit alpha byte is kept only when `keep_alpha` is set (per-pixel alpha blends); otherwise pixels +/// are opaque. +pub(crate) fn decode(info: Bytes, bits: Bytes, keep_alpha: bool) -> Option { + let hd = Header::read(info)?; + let pixels = hd.w.checked_mul(hd.h)?; + if pixels == 0 || pixels > MAX_PIXELS { + return None; + } + let stride = hd.w.checked_mul(hd.bpp)?.checked_add(31)? / 32 * 4; + let data = bits.slice(0, stride.checked_mul(hd.h)?)?; + let pal = info.slice(hd.pal_off, hd.pal_n * 4)?; + let mut px = vec![0u8; pixels * 4]; + for y in 0..hd.h { + let src_row = if hd.top_down { y } else { hd.h - 1 - y }; + let row = src_row * stride; + for x in 0..hd.w { + let p = pixel(data, row, x, &hd, pal, keep_alpha)?; + let o = (y * hd.w + x) * 4; + px.get_mut(o..o + 4)?.copy_from_slice(&p); + } + } + Some(Rgba { w: hd.w, h: hd.h, bottom_up: !hd.top_down, px }) +} + +fn pixel(data: &[u8], row: usize, x: usize, hd: &Header, pal: &[u8], keep_alpha: bool) -> Option<[u8; 4]> { + let [m_r, m_g, m_b] = hd.masks; + let [r, g, b] = match hd.bpp { + 1 => { + let b = *data.get(row + x / 8)?; + palette(pal, usize::from((b >> (7 - x % 8)) & 1)) + } + 4 => { + let b = *data.get(row + x / 2)?; + palette(pal, usize::from(if x.is_multiple_of(2) { b >> 4 } else { b & 0x0F })) + } + 8 => palette(pal, usize::from(*data.get(row + x)?)), + 24 => { + let [b, g, r] = data.get(row + x * 3..row + x * 3 + 3)?.try_into().ok()?; + [r, g, b] + } + 16 => { + let o = row + x * 2; + let v = u32::from(u16::from_le_bytes(data.get(o..o + 2)?.try_into().ok()?)); + [field(v, m_r), field(v, m_g), field(v, m_b)] + } + _ => { + let o = row + x * 4; + let v = u32::from_le_bytes(data.get(o..o + 4)?.try_into().ok()?); + let a = if keep_alpha { (v >> 24) as u8 } else { 255 }; + return Some([field(v, m_r), field(v, m_g), field(v, m_b), a]); + } + }; + Some([r, g, b, 255]) +} + +/// Palette entries are BGRX quads; an index past the table reads as black rather than failing the image. +fn palette(pal: &[u8], i: usize) -> [u8; 3] { + pal.get(i * 4..i * 4 + 4).and_then(|c| <[u8; 4]>::try_from(c).ok()).map_or([0, 0, 0], |c| [c[2], c[1], c[0]]) +} + +/// Extracts one channel from a masked pixel value and scales it to 8 bits. +fn field(v: u32, mask: u32) -> u8 { + if mask == 0 { + return 0; + } + let shift = mask.trailing_zeros(); + let max = u64::from(mask >> shift); + let val = u64::from((v & mask) >> shift); + ((val * 255 + max / 2) / max) as u8 +} + +/// Clips a source rectangle `src` = `[x, y, w, h]` to the bitmap and returns the cropped pixels (top-down) +/// with the part of the destination `dst` that they fill. `src` is in DIB pixel coordinates (`y` counted +/// from the DIB's origin, so bottom-up images count from the bottom); a non-positive source size means the +/// whole bitmap. Clipping shrinks the destination by the same ratio and shifts it by the clipped-off part, +/// so the rest of the image keeps its place. A negative destination extent is kept (the caller mirrors). +pub(crate) fn clip_blit(img: &Rgba, src: [i64; 4], dst: [f64; 4]) -> Option<(Rgba, [f64; 4])> { + let (iw, ih) = (img.w as i64, img.h as i64); + let [sx, sy, sw, sh] = if src[2] > 0 && src[3] > 0 { src } else { [0, 0, iw, ih] }; + // The request's top row in top-down order. + let top = if img.bottom_up { ih.saturating_sub(sy).saturating_sub(sh) } else { sy }; + let (x0, x1) = (sx.max(0), sx.saturating_add(sw).min(iw)); + let (r0, r1) = (top.max(0), top.saturating_add(sh).min(ih)); + if x1 <= x0 || r1 <= r0 { + return None; + } + let (kx, ky) = (dst[2] / sw as f64, dst[3] / sh as f64); + let out = [dst[0] + (x0 - sx) as f64 * kx, dst[1] + (r0 - top) as f64 * ky, (x1 - x0) as f64 * kx, (r1 - r0) as f64 * ky]; + let (x0, x1, r0, r1) = (x0 as usize, x1 as usize, r0 as usize, r1 as usize); + let (ow, oh) = (x1 - x0, r1 - r0); + let mut px = Vec::with_capacity(ow * oh * 4); + for row in r0..r1 { + let s = (row * img.w + x0) * 4; + px.extend_from_slice(img.px.get(s..s + ow * 4)?); + } + Some((Rgba { w: ow, h: oh, bottom_up: false, px }, out)) +} + +/// Applies a blend to decoded pixels before they are drawn. The picture has no destination pixels, so +/// raster operations are drawn as they look over white paper: SRCAND (destination AND source) leaves +/// white source pixels out and draws the rest; SRCPAINT and SRCINVERT leave black ones out. Other +/// operations draw the source as copied. Constant alpha scales every pixel's alpha, after a +/// per-pixel (premultiplied) source is made straight. +pub(crate) fn prepare(img: &mut Rgba, blend: Blend) { + match blend { + Blend::Rop(ROP_SRCAND) => clear_where(img, [255, 255, 255]), + Blend::Rop(ROP_SRCPAINT | ROP_SRCINVERT) => clear_where(img, [0, 0, 0]), + Blend::Rop(_) => {} + Blend::Alpha { constant, per_pixel } => { + for p in img.px.as_chunks_mut::<4>().0 { + if per_pixel { + unpremultiply(p); + } + p[3] = (u16::from(p[3]) * u16::from(constant) / 255) as u8; + } + } + } +} + +/// Makes the pixels of colour `rgb` transparent: where a raster operation keeps the destination. +fn clear_where(img: &mut Rgba, rgb: [u8; 3]) { + for p in img.px.as_chunks_mut::<4>().0 { + if p[..3] == rgb { + p[3] = 0; + } + } +} + +/// AC_SRC_ALPHA sources are premultiplied; the model holds straight RGBA, so divide the colour by the +/// alpha (rounded, capped at 255). A transparent pixel keeps its colour bytes. +fn unpremultiply(p: &mut [u8; 4]) { + let a = u32::from(p[3]); + if a == 0 || a == 255 { + return; + } + for c in &mut p[..3] { + *c = ((u32::from(*c) * 255 + a / 2) / a).min(255) as u8; + } +} + +/// Mirrors a decoded image left-right and/or top-bottom. Rows come from the buffer as chunks, so a +/// short buffer flips only the rows it has. +pub(crate) fn flip(img: &mut Rgba, horizontal: bool, vertical: bool) { + let (w, h) = (img.w, img.h); + if w == 0 || h == 0 { + return; + } + let row = w * 4; + if horizontal { + for line in img.px.chunks_exact_mut(row) { + for x in 0..w / 2 { + for c in 0..4 { + line.swap(x * 4 + c, (w - 1 - x) * 4 + c); + } + } + } + } + if vertical { + // Rows the buffer actually holds: a short buffer flips only those. + let n = h.min(img.px.len() / row); + for y in 0..n / 2 { + swap_rows(&mut img.px, y * row, (n - 1 - y) * row, row); + } + } +} + +/// Swaps the `len` bytes at `a` with those at `b` (`a < b`); does nothing when either is out of range. +fn swap_rows(px: &mut [u8], a: usize, b: usize, len: usize) { + let Some((head, tail)) = px.split_at_mut_checked(b) else { return }; + if let (Some(top), Some(bottom)) = (head.get_mut(a..a.saturating_add(len)), tail.get_mut(..len)) { + top.swap_with_slice(bottom); + } +} diff --git a/crates/metafile/src/emf.rs b/crates/metafile/src/emf.rs new file mode 100644 index 0000000..027cfae --- /dev/null +++ b/crates/metafile/src/emf.rs @@ -0,0 +1,315 @@ +//! EMF reader (MS-EMF). Records are (type, size in bytes) followed by a payload; offsets inside a +//! record (bitmap info and bits) are relative to the record start, so each handler reads the record slice. + +use crate::bytes::Bytes; +use crate::canvas::{BS_NULL, Brush, Canvas, Obj, PS_NULL, PS_STYLE_MASK, Pen, Xform, colorref}; +use crate::dib::{self, Blend}; +use crate::geom::{self, ArcKind, P}; +use crate::{Error, Frame, MAX_PATH_OPS}; + +const EMF_SIGNATURE: u32 = 0x464D_4520; +/// Hundredths of a millimetre to points. +const HMM_TO_PT: f64 = 72.0 / 2540.0; +/// SRCCOPY, the plain copy of a bitmap. +const SRCCOPY: u32 = 0x00CC_0020; +/// LogPenEx pen type bits; PS_COSMETIC pens are one device pixel wide. +const PS_TYPE_MASK: u32 = 0x000F_0000; +const PS_COSMETIC: u32 = 0; + +pub(crate) fn is_emf(b: Bytes) -> bool { + b.u32(0) == Some(1) && b.u32(40) == Some(EMF_SIGNATURE) +} + +/// The picture frame: `rclFrame` (0.01 mm) mapped to device units through `szlDevice` and +/// `szlMillimeters`, sized in points. Without a usable frame, the device bounds stand in (1 unit = 1 point). +pub(crate) fn parse(input: Bytes) -> Result<(Canvas, Option), Error> { + if !is_emf(input) { + return Err(Error::BadHeader); + } + let hsize = input.u32(4).ok_or(Error::BadHeader)? as usize; + let bounds = rect32(input, 8).ok_or(Error::BadHeader)?; + let [l, t, r, b] = bounds; + let frame = frame_from_header(input).or_else(|| (r > l && b > t).then_some(Frame { rect: [l, t, r - l, b - t], size: None })); + let mut cv = Canvas::new(); + if let Some(px_mm) = device_px_per_mm(input) { + cv.set_device_mm(px_mm); + } + let mut parsed = 0usize; + let mut pos = hsize.max(88); + while !cv.full() { + let (Some(typ), Some(size)) = (input.u32(pos), pos.checked_add(4).and_then(|o| input.u32(o))) else { break }; + let size = size as usize; + if size < 8 || !size.is_multiple_of(4) { + break; + } + let Some(rec) = input.slice(pos, size) else { break }; + if typ == 14 { + break; + } + record(&mut cv, typ, Bytes(rec)); + parsed += 1; + let Some(next) = pos.checked_add(size) else { break }; + pos = next; + } + if parsed == 0 { + return Err(Error::NoRecords); + } + Ok((cv, frame)) +} + +/// Device pixels per millimetre on each axis, from `szlDevice` (72) and `szlMillimeters` (80). +fn device_px_per_mm(input: Bytes) -> Option<(f64, f64)> { + let (dw, dh) = (input.i32(72)?, input.i32(76)?); + let (mw, mh) = (input.i32(80)?, input.i32(84)?); + (dw > 0 && dh > 0 && mw > 0 && mh > 0).then(|| (f64::from(dw) / f64::from(mw), f64::from(dh) / f64::from(mh))) +} + +/// The frame from `rclFrame` (offset 24), `szlDevice` (72) and `szlMillimeters` (80) in the header. +fn frame_from_header(input: Bytes) -> Option { + let [fl, ft, fr, fb] = rect32(input, 24)?; + if fr <= fl || fb <= ft { + return None; + } + // Device units per hundredth of a millimetre. + let (px_mm_x, px_mm_y) = device_px_per_mm(input)?; + let (sx, sy) = (px_mm_x / 100.0, px_mm_y / 100.0); + let rect = [fl * sx, ft * sy, (fr - fl) * sx, (fb - ft) * sy]; + Some(Frame { rect, size: Some(((fr - fl) * HMM_TO_PT, (fb - ft) * HMM_TO_PT)) }) +} + +fn record(cv: &mut Canvas, typ: u32, r: Bytes) { + if cv.full() { + return; + } + let _ = match typ { + 9 => xy32(r, 8).map(|p| cv.set_window_ext(p)), + 10 => xy32(r, 8).map(|p| cv.set_window_org(p)), + 11 => xy32(r, 8).map(|p| cv.set_viewport_ext(p)), + 12 => xy32(r, 8).map(|p| cv.set_viewport_org(p)), + 17 => r.u32(8).map(|m| cv.set_map_mode(m)), + 19 => r.u32(8).map(|m| cv.set_polyfill(m)), + 20 => r.u32(8).map(|m| cv.set_rop2(m)), + 33 => { + cv.save_dc(); + Some(()) + } + 34 => r.i32(8).map(|n| cv.restore_dc(n)), + 35 => xform(r, 8).map(|x| cv.modify_world(x, 4)), + 36 => xform(r, 8).zip(r.u32(32)).map(|(x, m)| cv.modify_world(x, m)), + 37 => r.u32(8).map(|i| cv.select(i)), + 38 => create_pen(cv, r), + 95 => create_ext_pen(cv, r), + 39 => create_brush(cv, r), + 40 => r.u32(8).map(|i| cv.delete_object(i)), + 27 => xy32(r, 8).map(|p| cv.move_to(p)), + 54 => xy32(r, 8).map(|p| cv.line_to(p)), + 42 => rect32(r, 8).map(|[l, t, rr, b]| cv.shape(&geom::ellipse(l, t, rr, b), true, true)), + 43 => rect32(r, 8).map(|[l, t, rr, b]| cv.shape(&geom::rect(l, t, rr, b), true, true)), + 44 => { + let (cw, ch) = (r.i32(24), r.i32(28)); + let (Some(cw), Some(ch)) = (cw, ch) else { return }; + rect32(r, 8).map(|[l, t, rr, b]| { + cv.shape(&geom::round_rect(l, t, rr, b, f64::from(cw), f64::from(ch)), true, true); + }) + } + 45..=47 => arc32(r).map(|(bounds, s, e)| { + let kind = match typ { + 45 => ArcKind::Arc, + 46 => ArcKind::Chord, + _ => ArcKind::Pie, + }; + let [l, t, rr, b] = bounds; + cv.shape(&geom::arc_shape(l, t, rr, b, s, e, kind), kind != ArcKind::Arc, true); + }), + 4 | 87 => counted(r, typ == 4).map(|pts| cv.poly(&pts, false, false, true)), + 3 | 86 => counted(r, typ == 3).map(|pts| cv.poly(&pts, true, true, true)), + 6 | 89 => counted(r, typ == 6).map(|pts| cv.line_chain(&pts, false, true)), + // POLYBEZIER starts its own figure at the first point and leaves the current point alone; + // POLYBEZIERTO continues from the current point. + 2 | 85 => counted(r, typ == 2).and_then(|pts| { + let from = *pts.first()?; + cv.bezier(from, pts.get(1..)?); + Some(()) + }), + 5 | 88 => counted(r, typ == 5).map(|pts| cv.bezier_to(&pts)), + 7 | 90 => poly_groups(r, typ == 7).map(|(counts, pts)| cv.shape(&geom::subpaths(&pts, &counts, false), false, true)), + 8 | 91 => poly_groups(r, typ == 8).map(|(counts, pts)| cv.shape(&geom::subpaths(&pts, &counts, true), true, true)), + 59 => { + cv.begin_path(); + Some(()) + } + 60 => { + cv.end_path(); + Some(()) + } + 61 => { + cv.close_figure(); + Some(()) + } + 62 => { + cv.paint_path(true, false); + Some(()) + } + 63 => { + cv.paint_path(true, true); + Some(()) + } + 64 => { + cv.paint_path(false, true); + Some(()) + } + 76 | 77 => bitblt(cv, r, typ == 77), + 80 => set_dibits(cv, r), + 81 => stretch_dibits(cv, r), + 114 => alphablend(cv, r), + _ => Some(()), + }; +} + +/// Two little-endian i32 values at `off`, as a point. +fn xy32(r: Bytes, off: usize) -> Option

{ + Some((f64::from(r.i32(off)?), f64::from(r.i32(off + 4)?))) +} + +/// RECTL (left, top, right, bottom) as i32 values widened to f64. +fn rect32(r: Bytes, off: usize) -> Option<[f64; 4]> { + Some([f64::from(r.i32(off)?), f64::from(r.i32(off + 4)?), f64::from(r.i32(off + 8)?), f64::from(r.i32(off + 12)?)]) +} + +/// An i32 field as f64; 0 past the end. +fn i32s(r: Bytes, off: usize) -> f64 { + r.i32(off).map_or(0.0, f64::from) +} + +/// XFORM: six f32 values in M11, M12, M21, M22, Dx, Dy order. +fn xform(r: Bytes, off: usize) -> Option { + let mut x = [0.0f64; 6]; + for (i, v) in x.iter_mut().enumerate() { + *v = f64::from(r.f32(off + i * 4)?); + } + Some(x) +} + +/// ARC, CHORD, PIE: bounds at 8, start point at 24, end point at 32. +fn arc32(r: Bytes) -> Option<([f64; 4], P, P)> { + Some((rect32(r, 8)?, xy32(r, 24)?, xy32(r, 32)?)) +} + +/// A point count from a record, rejected when it exceeds the path budget (before any allocation). +fn budget(n: u32) -> Option { + let n = n as usize; + (n <= MAX_PATH_OPS).then_some(n) +} + +/// Point count at 24, then the points (32-bit or 16-bit) at 28. +fn counted(r: Bytes, wide: bool) -> Option> { + let n = budget(r.u32(24)?)?; + r.tail(28)?.points(0, n, wide) +} + +/// Subpath count at 24, total point count at 28, per-subpath counts at 32, then the points. +fn poly_groups(r: Bytes, wide: bool) -> Option<(Vec, Vec

)> { + let n = budget(r.u32(24)?)?; + // The record must hold the n counts before anything is allocated for them. + let raw = r.slice(32, n.checked_mul(4)?)?; + let counts: Vec = raw.as_chunks::<4>().0.iter().map(|c| u32::from_le_bytes(*c) as usize).collect(); + let total = counts.iter().try_fold(0usize, |a, c| a.checked_add(*c))?; + let total = budget(u32::try_from(total).ok()?)?; + let pts = r.tail(n.checked_mul(4)?.checked_add(32)?)?.points(0, total, wide)?; + Some((counts, pts)) +} + +/// EMR_CREATEPEN: handle, LogPen (style, width x/y, colour). +fn create_pen(cv: &mut Canvas, r: Bytes) -> Option<()> { + let slot = r.u32(8)? as usize; + let style = r.u32(12)?; + let width = r.i32(16)?; + let color = r.u32(24)?; + cv.add_object(Some(slot), Obj::Pen(Pen { style, width: f64::from(width), color: colorref(color) })); + Some(()) +} + +/// EMR_EXTCREATEPEN: handle, bitmap offsets (unused), LogPenEx (style, width, brush style, colour). +/// A cosmetic pen's width is in device pixels (always 1), so it is a hairline; a BS_NULL brush draws +/// no line at all. +fn create_ext_pen(cv: &mut Canvas, r: Bytes) -> Option<()> { + let slot = r.u32(8)? as usize; + let (style, width, brush, color) = (r.u32(28)?, r.u32(32)?, r.u32(36)?, r.u32(40)?); + let width = if style & PS_TYPE_MASK == PS_COSMETIC { 0.0 } else { f64::from(width) }; + let style = if brush == BS_NULL { PS_NULL } else { style & PS_STYLE_MASK }; + cv.add_object(Some(slot), Obj::Pen(Pen { style, width, color: colorref(color) })); + Some(()) +} + +/// EMR_CREATEBRUSHINDIRECT: handle, LogBrush32 (style, colour, hatch). +fn create_brush(cv: &mut Canvas, r: Bytes) -> Option<()> { + let slot = r.u32(8)? as usize; + let style = r.u32(12)?; + let color = r.u32(16)?; + cv.add_object(Some(slot), Obj::Brush(Brush { style, color: colorref(color) })); + Some(()) +} + +/// BITBLT (76) and STRETCHBLT (77): dest at 24..40, rop at 40, source x/y at 44, bmi at 84, bits at +/// 92; STRETCHBLT adds the source w/h at 100. A source-less rop fills the destination instead. +fn bitblt(cv: &mut Canvas, r: Bytes, stretch: bool) -> Option<()> { + let dst = [i32s(r, 24), i32s(r, 28), i32s(r, 32), i32s(r, 36)]; + let rop = r.u32(40)?; + if Canvas::sourceless(rop) { + cv.fill_rop(dst, rop); + return Some(()); + } + let (sw, sh) = if stretch { (i32s(r, 100), i32s(r, 104)) } else { (dst[2], dst[3]) }; + let src = [i32s(r, 44), i32s(r, 48), sw, sh]; + blit(cv, r, dst, src, (84, 92), Blend::Rop(rop)) +} + +/// ALPHABLEND: laid out like STRETCHBLT, with a BLENDFUNCTION at 40. Constant alpha at 42 applies to +/// every pixel; AlphaFormat at 43 with AC_SRC_ALPHA (1) also uses the per-pixel alpha. +fn alphablend(cv: &mut Canvas, r: Bytes) -> Option<()> { + let dst = [i32s(r, 24), i32s(r, 28), i32s(r, 32), i32s(r, 36)]; + let constant = *r.0.get(42)?; + let per_pixel = (*r.0.get(43)? & 0x01) != 0; + let src = [i32s(r, 44), i32s(r, 48), i32s(r, 100), i32s(r, 104)]; + blit(cv, r, dst, src, (84, 92), Blend::Alpha { constant, per_pixel }) +} + +/// STRETCHDIBITS: dest x/y at 24, src x/y/w/h at 32..48, bmi offset/size at 48, bits at 56, rop at 68, +/// dest w/h at 72. +fn stretch_dibits(cv: &mut Canvas, r: Bytes) -> Option<()> { + let dst = [i32s(r, 24), i32s(r, 28), i32s(r, 72), i32s(r, 76)]; + let src = [i32s(r, 32), i32s(r, 36), i32s(r, 40), i32s(r, 44)]; + let rop = r.u32(68)?; + blit(cv, r, dst, src, (48, 56), Blend::Rop(rop)) +} + +/// SETDIBITSTODEVICE (MS-EMF 2.3.1.5): dest x/y at 24, src x/y at 32, size (cxSrc, cySrc) at 40, bmi +/// at 48, bits at 56, start scan at 68. The image keeps its own size; the bits begin at scan line +/// `iStartScan`, so the source y counts from there. +fn set_dibits(cv: &mut Canvas, r: Bytes) -> Option<()> { + let (cx, cy) = (i32s(r, 40), i32s(r, 44)); + if cx <= 0.0 || cy <= 0.0 { + return None; + } + let dst = [i32s(r, 24), i32s(r, 28), cx, cy]; + let src = [i32s(r, 32), i32s(r, 36) - f64::from(r.u32(68)?), cx, cy]; + blit(cv, r, dst, src, (48, 56), Blend::Rop(SRCCOPY)) +} + +/// Decodes the bitmap at `at` = (offset of the bmi offset/length pair, offset of the bits pair), both +/// inside the record, and stretches the source rectangle `src` into `dst`. Both are `[x, y, w, h]`; a +/// non-positive source size means the whole bitmap. `blend` decides how it combines. +fn blit(cv: &mut Canvas, r: Bytes, dst: [f64; 4], src: [f64; 4], at: (usize, usize), blend: Blend) -> Option<()> { + let (bo, bl) = (r.u32(at.0)? as usize, r.u32(at.0 + 4)? as usize); + let (po, pl) = (r.u32(at.1)? as usize, r.u32(at.1 + 4)? as usize); + if pl == 0 { + return None; + } + let info = Bytes(r.slice(bo, bl)?); + let pixels = Bytes(r.slice(po, pl)?); + let mut img = dib::decode(info, pixels, blend.per_pixel())?; + dib::prepare(&mut img, blend); + let (img, dst) = dib::clip_blit(&img, src.map(|v| v as i64), dst)?; + cv.bitmap(dst, img); + Some(()) +} diff --git a/crates/metafile/src/geom.rs b/crates/metafile/src/geom.rs new file mode 100644 index 0000000..8063c73 --- /dev/null +++ b/crates/metafile/src/geom.rs @@ -0,0 +1,118 @@ +//! Path operations in `f64` and the shape builders that turn WMF/EMF rectangles, ellipses and arcs +//! into cubic Béziers (the classic 4/3·tan(Δ/4) approximation, at most 90° per piece). + +use std::f64::consts::{FRAC_PI_2, PI, TAU}; + +pub(crate) type P = (f64, f64); + +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) enum Op { + M(P), + L(P), + C(P, P, P), + Z, +} + +pub(crate) fn rect(l: f64, t: f64, r: f64, b: f64) -> Vec { + vec![Op::M((l, t)), Op::L((r, t)), Op::L((r, b)), Op::L((l, b)), Op::Z] +} + +/// Corner radii are half the EMF/WMF corner ellipse size, clamped so the corners never overlap. +pub(crate) fn round_rect(l: f64, t: f64, r: f64, b: f64, ew: f64, eh: f64) -> Vec { + // Reversed corners (right < left, bottom < top) describe the same rectangle. + let (l, r) = (l.min(r), l.max(r)); + let (t, b) = (t.min(b), t.max(b)); + let rx = (ew / 2.0).abs().min((r - l) / 2.0); + let ry = (eh / 2.0).abs().min((b - t) / 2.0); + let mut ops = vec![Op::M((l + rx, t)), Op::L((r - rx, t))]; + ops.extend(arc(r - rx, t + ry, rx, ry, -FRAC_PI_2, FRAC_PI_2)); + ops.push(Op::L((r, b - ry))); + ops.extend(arc(r - rx, b - ry, rx, ry, 0.0, FRAC_PI_2)); + ops.push(Op::L((l + rx, b))); + ops.extend(arc(l + rx, b - ry, rx, ry, FRAC_PI_2, FRAC_PI_2)); + ops.push(Op::L((l, t + ry))); + ops.extend(arc(l + rx, t + ry, rx, ry, PI, FRAC_PI_2)); + ops.push(Op::Z); + ops +} + +pub(crate) fn ellipse(l: f64, t: f64, r: f64, b: f64) -> Vec { + let (cx, cy, rx, ry) = center(l, t, r, b); + let mut ops = vec![Op::M((cx + rx, cy))]; + ops.extend(arc(cx, cy, rx, ry, 0.0, TAU)); + ops.push(Op::Z); + ops +} + +/// Which closed or open shape an ARC/CHORD/PIE record draws. +#[derive(Clone, Copy, PartialEq, Eq)] +pub(crate) enum ArcKind { + Arc, + Chord, + Pie, +} + +/// ARC, CHORD and PIE: the arc runs counter-clockwise on screen from the start point to the end point. +pub(crate) fn arc_shape(l: f64, t: f64, r: f64, b: f64, s: P, e: P, kind: ArcKind) -> Vec { + let (cx, cy, rx, ry) = center(l, t, r, b); + let ang = |p: P| ((p.1 - cy) / ry.max(f64::MIN_POSITIVE)).atan2((p.0 - cx) / rx.max(f64::MIN_POSITIVE)); + let (a0, a1) = (ang(s), ang(e)); + let mut d = (a0 - a1).rem_euclid(TAU); + if d < 1e-12 { + d = TAU; + } + let sweep = -d; + let start = (cx + rx * a0.cos(), cy + ry * a0.sin()); + let mut ops = if kind == ArcKind::Pie { vec![Op::M((cx, cy)), Op::L(start)] } else { vec![Op::M(start)] }; + ops.extend(arc(cx, cy, rx, ry, a0, sweep)); + match kind { + ArcKind::Arc => {} + ArcKind::Chord => ops.push(Op::Z), + ArcKind::Pie => ops.extend([Op::L((cx, cy)), Op::Z]), + } + ops +} + +fn center(l: f64, t: f64, r: f64, b: f64) -> (f64, f64, f64, f64) { + ((l + r) / 2.0, (t + b) / 2.0, (r - l).abs() / 2.0, (b - t).abs() / 2.0) +} + +/// Cubic pieces along an ellipse from parameter `a0` over signed `sweep` radians. The current point +/// must already be at `(cx + rx cos a0, cy + ry sin a0)`. +fn arc(cx: f64, cy: f64, rx: f64, ry: f64, a0: f64, sweep: f64) -> Vec { + let n = ((sweep.abs() / FRAC_PI_2).ceil() as usize).clamp(1, 8); + let step = sweep / n as f64; + let k = 4.0 / 3.0 * (step / 4.0).tan(); + let pt = |t: f64| (cx + rx * t.cos(), cy + ry * t.sin()); + let mut ops = Vec::with_capacity(n); + for i in 0..n { + let t0 = a0 + step * i as f64; + let t1 = t0 + step; + let (p0, p3) = (pt(t0), pt(t1)); + let c1 = (p0.0 - k * rx * t0.sin(), p0.1 + k * ry * t0.cos()); + let c2 = (p3.0 + k * rx * t1.sin(), p3.1 - k * ry * t1.cos()); + ops.push(Op::C(c1, c2, p3)); + } + ops +} + +/// Consecutive subpaths: `counts[i]` points each, taken in order from `pts`. Counts that run past the +/// point list are cut short rather than failing the whole record. +pub(crate) fn subpaths(pts: &[P], counts: &[usize], close: bool) -> Vec { + let mut ops = Vec::with_capacity(pts.len() + counts.len()); + let mut at = 0usize; + for &c in counts { + let end = at.saturating_add(c).min(pts.len()); + let Some((first, rest)) = pts.get(at..end).and_then(|s| s.split_first()) else { + at = end; + continue; + }; + ops.push(Op::M(*first)); + ops.extend(rest.iter().map(|p| Op::L(*p))); + if close { + ops.push(Op::Z); + } + at = end; + } + ops +} diff --git a/crates/metafile/src/lib.rs b/crates/metafile/src/lib.rs new file mode 100644 index 0000000..2200235 --- /dev/null +++ b/crates/metafile/src/lib.rs @@ -0,0 +1,208 @@ +//! DeckCraft metafiles (ported from WordCraft's `crates/metafile`): reads Windows Metafiles (WMF) +//! and Enhanced Metafiles (EMF) into a neutral vector model of filled and stroked paths and RGBA +//! bitmaps, in a picture frame with y down. +//! +//! The reader is pure Rust over byte slices (no file system), so it builds for wasm. Every read is +//! bounds-checked and every count is capped; hostile input yields a shorter picture or an [`Error`], +//! never a panic. +//! +//! Records follow MS-WMF and MS-EMF. Records outside the supported set are skipped silently. +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +#![forbid(unsafe_code)] + +mod bytes; +mod canvas; +mod dib; +mod emf; +mod geom; +mod place; +mod wmf; + +#[cfg(test)] +mod tests; + +use bytes::Bytes; +use canvas::Raw; +use geom::{Op, P}; +pub use place::{MAX_BITMAP_SIDE, PlacedItem}; + +/// Items kept from one file. Past the cap the parse stops and keeps what it has. +pub(crate) const MAX_ITEMS: usize = 200_000; +/// Object table slots (WMF handles, EMF object indices). +pub(crate) const MAX_OBJECTS: usize = 4096; +/// Largest bitmap in pixels (about 40 megapixels, 160 MB of RGBA). +pub(crate) const MAX_PIXELS: usize = 40_000_000; +/// DC save-stack depth. +pub(crate) const MAX_STACK: usize = 1024; +/// Points in one open path bracket. +pub(crate) const MAX_PATH_OPS: usize = 2_000_000; +/// Bytes of path ops and pixels kept from one file (about 256 MB). +pub(crate) const MAX_BYTES: usize = 256 * 1024 * 1024; + +/// A parsed metafile. Items use coordinates in `0..width` and `0..height`, y down. +#[derive(Debug, Clone, PartialEq)] +pub struct Picture { + /// Size of the drawing in its own units: points for placeable WMF and EMF (from the frame), device + /// or logical units otherwise. + pub width: f32, + pub height: f32, + pub items: Vec, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum Item { + /// A filled and/or stroked path, in picture coordinates. + Path { + segs: Vec, + fill: Option<[u8; 4]>, + /// Colour and width in picture units. + stroke: Option<([u8; 4], f32)>, + even_odd: bool, + }, + /// A bitmap stretched into `rect` = `[x, y, w, h]` (picture units), RGBA8 rows top-down. + Bitmap { rect: [f32; 4], width: u32, height: u32, rgba: Vec }, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum Seg { + Move(f32, f32), + Line(f32, f32), + Cubic(f32, f32, f32, f32, f32, f32), + Close, +} + +#[derive(Debug, thiserror::Error, Clone, PartialEq, Eq)] +pub enum Error { + #[error("not a WMF or EMF file")] + NotMetafile, + #[error("metafile header is truncated or invalid")] + BadHeader, + #[error("metafile has no readable records")] + NoRecords, +} + +/// The picture frame in device units (`rect` = `[x, y, w, h]`) and, when the file states one, its +/// size in picture units. +pub(crate) struct Frame { + pub rect: [f64; 4], + pub size: Option<(f64, f64)>, +} + +/// Parses a WMF (placeable or plain) or EMF file. +pub fn parse(bytes: &[u8]) -> Result { + let b = Bytes(bytes); + let (cv, frame) = if emf::is_emf(b) { + emf::parse(b)? + } else if wmf::is_wmf(b) { + wmf::parse(b)? + } else { + return Err(Error::NotMetafile); + }; + Ok(build(cv.items, frame)) +} + +/// True when the bytes start like a WMF (placeable or plain header) or an EMF. +pub fn is_metafile(bytes: &[u8]) -> bool { + let b = Bytes(bytes); + emf::is_emf(b) || wmf::is_wmf(b) +} + +/// Maps device-space output into the picture frame. Without a usable frame the frame is the bounding +/// box of the drawing, so the origin is still the top-left of what was drawn. +fn build(raw: Vec, frame: Option) -> Picture { + let frame = frame.filter(|f| f.rect.iter().all(|v| v.is_finite()) && f.rect[2] > 0.0 && f.rect[3] > 0.0); + let (rect, size) = match frame { + Some(f) => (f.rect, f.size.unwrap_or((f.rect[2], f.rect[3]))), + None => match bbox(&raw) { + Some([x0, y0, x1, y1]) => ([x0, y0, x1 - x0, y1 - y0], (x1 - x0, y1 - y0)), + None => return Picture { width: 0.0, height: 0.0, items: Vec::new() }, + }, + }; + let sx = if rect[2] > 0.0 { size.0 / rect[2] } else { 1.0 }; + let sy = if rect[3] > 0.0 { size.1 / rect[3] } else { 1.0 }; + let tf = move |p: P| ((p.0 - rect[0]) * sx, (p.1 - rect[1]) * sy); + let stroke_scale = (sx + sy) / 2.0; + // No stroke is wider than the picture's diagonal: anything wider is a mapping artefact. + let diag = size.0.hypot(size.1); + let items = raw.into_iter().filter_map(|r| match r { + Raw::Path { ops, fill, stroke, even_odd } => { + let segs = ops.iter().map(|op| seg(op, tf)).collect::>>()?; + if segs.len() < 2 { + return None; + } + let stroke = stroke.and_then(|(c, w)| Some((c, f((w * stroke_scale).min(diag))?))); + Some(Item::Path { segs, fill, stroke, even_odd }) + } + Raw::Bitmap { rect: r, img } => { + let (x, y) = tf((r[0], r[1])); + let rect = [f(x)?, f(y)?, f(r[2] * sx)?, f(r[3] * sy)?]; + Some(Item::Bitmap { rect, width: img.w as u32, height: img.h as u32, rgba: img.px }) + } + }); + let items = items.collect(); + Picture { width: f(size.0).unwrap_or(0.0), height: f(size.1).unwrap_or(0.0), items } +} + +fn f(v: f64) -> Option { + let v = v as f32; + v.is_finite().then_some(v) +} + +fn seg(op: &Op, tf: impl Fn(P) -> P) -> Option { + let pt = |p: P| { + let (x, y) = tf(p); + Some((f(x)?, f(y)?)) + }; + Some(match *op { + Op::M(p) => { + let (x, y) = pt(p)?; + Seg::Move(x, y) + } + Op::L(p) => { + let (x, y) = pt(p)?; + Seg::Line(x, y) + } + Op::C(a, b, c) => { + let (x1, y1) = pt(a)?; + let (x2, y2) = pt(b)?; + let (x3, y3) = pt(c)?; + Seg::Cubic(x1, y1, x2, y2, x3, y3) + } + Op::Z => Seg::Close, + }) +} + +/// Bounding box `[x0, y0, x1, y1]` over the finite points of the output. +fn bbox(raw: &[Raw]) -> Option<[f64; 4]> { + let mut b: Option<[f64; 4]> = None; + let mut grow = |p: P| { + if p.0.is_finite() && p.1.is_finite() { + b = Some(match b { + None => [p.0, p.1, p.0, p.1], + Some([x0, y0, x1, y1]) => [x0.min(p.0), y0.min(p.1), x1.max(p.0), y1.max(p.1)], + }); + } + }; + for r in raw { + match r { + Raw::Path { ops, .. } => { + for op in ops { + match *op { + Op::M(p) | Op::L(p) => grow(p), + Op::C(a, c, d) => { + grow(a); + grow(c); + grow(d); + } + Op::Z => {} + } + } + } + Raw::Bitmap { rect, .. } => { + grow((rect[0], rect[1])); + grow((rect[0] + rect[2], rect[1] + rect[3])); + } + } + } + b +} diff --git a/crates/metafile/src/place.rs b/crates/metafile/src/place.rs new file mode 100644 index 0000000..68f0795 --- /dev/null +++ b/crates/metafile/src/place.rs @@ -0,0 +1,124 @@ +//! Placing a picture on a target (a page or a device): the fitted transform, paths as kurbo paths in +//! target units and bitmaps as target rectangles. The rasteriser and the PDF writer only paint the result. + +use kurbo::{Affine, BezPath, Rect, Shape}; + +use crate::{Item, Picture, Seg}; + +/// Largest bitmap side the renderers draw: within the PDF image limit and the rasteriser's 16-bit sides. +pub const MAX_BITMAP_SIDE: u32 = 30_000; +/// Placed coordinates past this (target units) are far off any page; such items are left out. +const MAX_COORD: f64 = 1e6; +/// Smallest placed bitmap side (target units), so the stretch onto it is never singular. +const MIN_BITMAP_SIDE: f64 = 1e-6; + +/// One item of a placed picture, in target units. +pub enum PlacedItem { + /// A filled and/or stroked path. The stroke width is in target units; 0 is a cosmetic hairline that + /// the renderer draws at its minimum width. + Path { path: BezPath, fill: Option<[u8; 4]>, stroke: Option<([u8; 4], f64)>, even_odd: bool }, + /// `Picture::items[index]`, a bitmap, stretched into `rect`. + Bitmap { index: usize, rect: Rect }, +} + +impl Picture { + /// Whether the picture has a finite, positive size, so it can be fitted into a frame. + pub fn has_size(&self) -> bool { + self.width.is_finite() && self.height.is_finite() && self.width > 0.0 && self.height > 0.0 + } + + /// The transform that fits the picture into `r` with the crop fractions `crop` (left, top, right, + /// bottom) removed first: the visible part fills `r`. None for a degenerate size. + pub fn fit(&self, r: Rect, crop: [f32; 4]) -> Option { + let [cl, ct, cr, cb] = crop.map(|v| if v.is_finite() { f64::from(v.clamp(0.0, 0.95)) } else { 0.0 }); + let (w, h) = (f64::from(self.width), f64::from(self.height)); + let sx = r.width() / (w * (1.0 - cl - cr).max(0.05)); + let sy = r.height() / (h * (1.0 - ct - cb).max(0.05)); + (sx.is_finite() && sy.is_finite() && sx > 0.0 && sy > 0.0) + .then(|| Affine::translate((r.x0 - cl * w * sx, r.y0 - ct * h * sy)) * Affine::scale_non_uniform(sx, sy)) + } + + /// The items placed into `r` with `crop` applied. Items that are not finite or reach past ±1e6 + /// target units are left out (they are far off any page), as are bitmaps with no area. Stroke + /// widths are scaled to target units (along the mean axis scale, so a non-uniform fit keeps + /// strokes even). + pub fn place(&self, r: Rect, crop: [f32; 4]) -> Vec { + let Some(tf) = self.fit(r, crop) else { return Vec::new() }; + let c = tf.as_coeffs(); + let k = (c[0].abs() + c[3].abs()) / 2.0; + let mut out = Vec::with_capacity(self.items.len()); + for (index, item) in self.items.iter().enumerate() { + match item { + Item::Path { segs, fill, stroke, even_odd } => { + let path = tf * bez(segs); + if in_range(path.bounding_box()) { + let stroke = stroke.map(|(c, w)| (c, f64::from(w) * k)); + out.push(PlacedItem::Path { path, fill: *fill, stroke, even_odd: *even_odd }); + } + } + Item::Bitmap { rect: [x, y, w, h], .. } => { + let (x0, y0) = (f64::from(*x), f64::from(*y)); + let rect = tf.transform_rect_bbox(Rect::new(x0, y0, x0 + f64::from(*w), y0 + f64::from(*h))); + if in_range(rect) && rect.width() > MIN_BITMAP_SIDE && rect.height() > MIN_BITMAP_SIDE { + out.push(PlacedItem::Bitmap { index, rect }); + } + } + } + } + out + } +} + +impl Item { + /// Takes a bitmap's pixels out of the item (its geometry stays) and returns them as (width, height, + /// RGBA) when a renderer can draw them: both sides in 1..=[`MAX_BITMAP_SIDE`] and exactly that many + /// pixels. None for paths and for bitmaps that cannot be drawn. + pub fn take_pixels(&mut self) -> Option<(u32, u32, Vec)> { + let Item::Bitmap { width, height, rgba, .. } = self else { return None }; + let (w, h, px) = (*width, *height, std::mem::take(rgba)); + let sides = (1..=MAX_BITMAP_SIDE).contains(&w) && (1..=MAX_BITMAP_SIDE).contains(&h); + (sides && px.len() == (w as usize).saturating_mul(h as usize).saturating_mul(4)).then_some((w, h, px)) + } +} + +/// Segments as a kurbo path. A segment after a close opens a new subpath at the last start point. +fn bez(segs: &[Seg]) -> BezPath { + let mut p = BezPath::new(); + let mut start = (0.0, 0.0); + let mut open = false; + for s in segs { + match *s { + Seg::Move(x, y) => { + start = (f64::from(x), f64::from(y)); + p.move_to(start); + open = true; + } + Seg::Line(x, y) => { + if !open { + p.move_to(start); + } + p.line_to((f64::from(x), f64::from(y))); + open = true; + } + Seg::Cubic(a, b, c, d, e, f) => { + if !open { + p.move_to(start); + } + p.curve_to((f64::from(a), f64::from(b)), (f64::from(c), f64::from(d)), (f64::from(e), f64::from(f))); + open = true; + } + Seg::Close => { + if open { + p.close_path(); + } + open = false; + } + } + } + p +} + +/// Whether a placed box is finite and within ±[`MAX_COORD`]. +fn in_range(b: Rect) -> bool { + [b.x0, b.y0, b.x1, b.y1].iter().all(|v| v.is_finite() && v.abs() < MAX_COORD) +} diff --git a/crates/metafile/src/tests.rs b/crates/metafile/src/tests.rs new file mode 100644 index 0000000..4ee3257 --- /dev/null +++ b/crates/metafile/src/tests.rs @@ -0,0 +1,1018 @@ +//! Hand-built WMF and EMF buffers: each test writes the records it needs and checks the parsed model. + +use crate::canvas::Canvas; +use crate::dib::Rgba; +use crate::{Error, Item, MAX_BYTES, MAX_ITEMS, Seg, is_metafile, parse}; + +fn h16(v: i32) -> Vec { + (v as i16).to_le_bytes().to_vec() +} + +fn h32(v: u32) -> Vec { + v.to_le_bytes().to_vec() +} + +fn e32(v: i32) -> Vec { + v.to_le_bytes().to_vec() +} + +fn cat(parts: Vec>) -> Vec { + parts.concat() +} + +/// One WMF record: size in words, function, parameters (padded to a word boundary). +fn wrec(func: u16, params: &[u8]) -> Vec { + let mut p = params.to_vec(); + if !p.len().is_multiple_of(2) { + p.push(0); + } + let words = ((6 + p.len()) / 2) as u32; + let mut v = words.to_le_bytes().to_vec(); + v.extend(func.to_le_bytes()); + v.extend(p); + v +} + +/// A WMF file: optional placeable header (bbox left, top, right, bottom in logical units, 1440 per inch), +/// the standard header, the records and an end-of-file record. +fn wmf(records: &[Vec], placeable: Option<[i32; 4]>) -> Vec { + let body: Vec = records.concat(); + let eof = wrec(0, &[]); + let mut f = Vec::new(); + if let Some(bb) = placeable { + f.extend(0x9AC6_CDD7u32.to_le_bytes()); + f.extend(0u16.to_le_bytes()); + for v in bb { + f.extend((v as i16).to_le_bytes()); + } + f.extend(1440u16.to_le_bytes()); + f.extend(0u32.to_le_bytes()); + f.extend(0u16.to_le_bytes()); + } + let total = (f.len() + 18 + body.len() + eof.len()) / 2; + f.extend(1u16.to_le_bytes()); + f.extend(9u16.to_le_bytes()); + f.extend(0x300u16.to_le_bytes()); + f.extend((total as u32).to_le_bytes()); + f.extend(16u16.to_le_bytes()); + f.extend(0u32.to_le_bytes()); + f.extend(0u16.to_le_bytes()); + f.extend(body); + f.extend(eof); + f +} + +/// One EMF record: type, size in bytes (payload padded to 4), payload. +fn erec(typ: u32, payload: &[u8]) -> Vec { + let mut p = payload.to_vec(); + while !p.len().is_multiple_of(4) { + p.push(0); + } + let mut v = typ.to_le_bytes().to_vec(); + v.extend(((8 + p.len()) as u32).to_le_bytes()); + v.extend(p); + v +} + +/// An EMF file with an 88-byte header (bounds and frame in 0.01 mm), the records and an EOF record. +fn emf(records: &[Vec], bounds: [i32; 4], frame: [i32; 4]) -> Vec { + let body: Vec = records.concat(); + let eof = erec(14, &[0; 12]); + let total = 88 + body.len() + eof.len(); + let mut h = vec![0u8; 88]; + h[0..4].copy_from_slice(&1u32.to_le_bytes()); + h[4..8].copy_from_slice(&88u32.to_le_bytes()); + h[8..24].copy_from_slice(&bounds.iter().flat_map(|v| v.to_le_bytes()).collect::>()); + h[24..40].copy_from_slice(&frame.iter().flat_map(|v| v.to_le_bytes()).collect::>()); + h[40..44].copy_from_slice(&0x464D_4520u32.to_le_bytes()); + h[44..48].copy_from_slice(&0x0001_0000u32.to_le_bytes()); + h[48..52].copy_from_slice(&(total as u32).to_le_bytes()); + h[52..56].copy_from_slice(&((records.len() + 1) as u32).to_le_bytes()); + h[56..58].copy_from_slice(&1u16.to_le_bytes()); + // 1000 x 500 device pixels for 250 x 125 mm: 0.04 device units per hundredth of a millimetre. + h[72..76].copy_from_slice(&1000i32.to_le_bytes()); + h[76..80].copy_from_slice(&500i32.to_le_bytes()); + h[80..84].copy_from_slice(&250i32.to_le_bytes()); + h[84..88].copy_from_slice(&125i32.to_le_bytes()); + let mut out = h; + out.extend(body); + out.extend(eof); + out +} + +/// A BITMAPINFOHEADER (40 bytes). +fn info_header(w: i32, h: i32, bpp: u16, size_image: u32) -> Vec { + cat(vec![h32(40), e32(w), e32(h), h16(1), h16(i32::from(bpp)), h32(0), h32(size_image), e32(0), e32(0), h32(0), h32(0)]) +} + +/// A 2x2 24-bit bottom-up DIB. File rows are stored bottom first: the bottom image row is blue, green; +/// the top row is red, white. Rows are padded to 4 bytes (8 bytes per row). +fn dib24_bottom_up() -> Vec { + let mut v = info_header(2, 2, 24, 16); + v.extend([255, 0, 0, 0, 255, 0, 0, 0]); + v.extend([0, 0, 255, 255, 255, 255, 0, 0]); + v +} + +/// Top-down RGBA of [`dib24_bottom_up`]: top-left red, top-right white, bottom-left blue, bottom-right green. +const DIB24_RGBA: [u8; 16] = [255, 0, 0, 255, 255, 255, 255, 255, 0, 0, 255, 255, 0, 255, 0, 255]; + +fn path_segs(item: &Item) -> (&Vec, Option<[u8; 4]>, Option<([u8; 4], f32)>, bool) { + match item { + Item::Path { segs, fill, stroke, even_odd } => (segs, *fill, *stroke, *even_odd), + Item::Bitmap { .. } => panic!("expected a path"), + } +} + +fn close(a: f32, b: f32) -> bool { + (a - b).abs() < 1e-3 +} + +#[test] +fn wmf_rectangle_with_solid_brush() { + let recs = [ + wrec(0x02FC, &cat(vec![h16(0), h32(0x0000_00FF), h16(0)])), + wrec(0x012D, &h16(0)), + wrec(0x041B, &cat(vec![h16(70), h16(110), h16(20), h16(10)])), + ]; + let p = parse(&wmf(&recs, None)).unwrap(); + assert_eq!((p.width, p.height), (100.0, 50.0)); + assert_eq!(p.items.len(), 1); + let (segs, fill, stroke, even_odd) = path_segs(&p.items[0]); + assert_eq!(segs, &vec![Seg::Move(0.0, 0.0), Seg::Line(100.0, 0.0), Seg::Line(100.0, 50.0), Seg::Line(0.0, 50.0), Seg::Close]); + assert_eq!(fill, Some([255, 0, 0, 255])); + assert_eq!(stroke, Some(([0, 0, 0, 255], 0.0))); + assert!(even_odd); +} + +#[test] +fn wmf_polyline_with_pen() { + let recs = [ + wrec(0x02FA, &cat(vec![h16(0), h16(4), h16(0), h32(0x0000_FF00)])), + wrec(0x012D, &h16(0)), + wrec(0x0325, &cat(vec![h16(3), h16(0), h16(0), h16(50), h16(0), h16(50), h16(40)])), + ]; + let p = parse(&wmf(&recs, None)).unwrap(); + assert_eq!((p.width, p.height), (50.0, 40.0)); + let (segs, fill, stroke, _) = path_segs(&p.items[0]); + assert_eq!(segs, &vec![Seg::Move(0.0, 0.0), Seg::Line(50.0, 0.0), Seg::Line(50.0, 40.0)]); + assert_eq!(fill, None); + assert_eq!(stroke, Some(([0, 255, 0, 255], 4.0))); +} + +#[test] +fn wmf_polygon_fill_rule_follows_polyfillmode() { + let square = cat(vec![h16(4), h16(0), h16(0), h16(10), h16(0), h16(10), h16(10), h16(0), h16(10)]); + let winding = [wrec(0x0106, &h16(2)), wrec(0x0324, &square)]; + let p = parse(&wmf(&winding, None)).unwrap(); + assert!(!path_segs(&p.items[0]).3, "WINDING fills use the nonzero rule"); + let alternate = [wrec(0x0324, &square)]; + let p = parse(&wmf(&alternate, None)).unwrap(); + let (segs, fill, _, even_odd) = path_segs(&p.items[0]); + assert!(even_odd, "default ALTERNATE is even-odd"); + assert_eq!(fill, Some([255, 255, 255, 255])); + assert_eq!(segs.last(), Some(&Seg::Close)); +} + +#[test] +fn wmf_stretchdib_flips_bottom_up_rows() { + let params = cat(vec![h32(0x00CC_0020), h16(0), h16(2), h16(2), h16(0), h16(0), h16(10), h16(20), h16(0), h16(0), dib24_bottom_up()]); + let p = parse(&wmf(&[wrec(0x0F43, ¶ms)], None)).unwrap(); + assert_eq!((p.width, p.height), (20.0, 10.0)); + let Item::Bitmap { rect, width, height, rgba } = &p.items[0] else { panic!("expected a bitmap") }; + assert_eq!(rect, &[0.0, 0.0, 20.0, 10.0]); + assert_eq!((*width, *height), (2, 2)); + assert_eq!(rgba.as_slice(), DIB24_RGBA); +} + +#[test] +fn wmf_mapping_window_and_viewport() { + let recs = [ + wrec(0x0103, &h16(8)), + wrec(0x020B, &cat(vec![h16(100), h16(100)])), + wrec(0x020C, &cat(vec![h16(50), h16(50)])), + wrec(0x020E, &cat(vec![h16(100), h16(100)])), + wrec(0x0214, &cat(vec![h16(100), h16(100)])), + wrec(0x0213, &cat(vec![h16(150), h16(150)])), + ]; + let p = parse(&wmf(&recs, None)).unwrap(); + assert_eq!((p.width, p.height), (100.0, 100.0)); + let (segs, fill, stroke, _) = path_segs(&p.items[0]); + assert_eq!(segs, &vec![Seg::Move(0.0, 0.0), Seg::Line(100.0, 100.0)]); + assert_eq!(fill, None); + assert_eq!(stroke, Some(([0, 0, 0, 255], 0.0))); +} + +#[test] +fn wmf_placeable_header_sets_frame_in_points() { + let p = parse(&wmf(&[wrec(0x041B, &cat(vec![h16(1440), h16(2880), h16(0), h16(0)]))], Some([0, 0, 2880, 1440]))).unwrap(); + assert_eq!((p.width, p.height), (144.0, 72.0)); +} + +#[test] +fn emf_stretchdibits_flips_and_fits_frame() { + let mut payload = vec![0u8; 16]; + payload.extend(cat(vec![ + e32(0), + e32(0), + e32(0), + e32(0), + e32(2), + e32(2), + e32(80), + e32(40), + e32(120), + e32(16), + e32(0), + e32(0x00CC_0020), + e32(100), + e32(50), + ])); + payload.extend(info_header(2, 2, 24, 16)); + payload.extend(dib24_bottom_up().get(40..).unwrap_or_default()); + let rec = erec(81, &payload); + // A 25 x 12.5 mm frame is 70.87 x 35.43 points; the bitmap fills the whole frame. + let file = emf(&[rec], [0, 0, 100, 50], [0, 0, 2500, 1250]); + let p = parse(&file).unwrap(); + let (w, h) = (2500.0 * 72.0 / 2540.0, 1250.0 * 72.0 / 2540.0); + assert!(close(p.width, w as f32) && close(p.height, h as f32)); + let Item::Bitmap { rect, width, height, rgba } = &p.items[0] else { panic!("expected a bitmap") }; + assert!(rect.iter().zip([0.0, 0.0, w, h]).all(|(a, b)| close(*a, b as f32))); + assert_eq!((*width, *height), (2, 2)); + assert_eq!(rgba.as_slice(), DIB24_RGBA); +} + +#[test] +fn emf_world_transform_scales_geometry() { + let xf = cat([2.0f32, 0.0, 0.0, 2.0, 0.0, 0.0].iter().map(|v| v.to_le_bytes().to_vec()).collect()); + let recs = [erec(35, &xf), erec(27, &cat(vec![e32(0), e32(0)])), erec(54, &cat(vec![e32(10), e32(5)]))]; + let p = parse(&emf(&recs, [0; 4], [0; 4])).unwrap(); + assert_eq!((p.width, p.height), (20.0, 10.0)); + let (segs, fill, stroke, _) = path_segs(&p.items[0]); + assert_eq!(segs, &vec![Seg::Move(0.0, 0.0), Seg::Line(20.0, 10.0)]); + assert_eq!(fill, None); + assert_eq!(stroke, Some(([0, 0, 0, 255], 0.0))); +} + +#[test] +fn emf_ellipse_is_four_cubics_back_to_start() { + let rect = cat(vec![e32(0), e32(0), e32(40), e32(20)]); + let p = parse(&emf(&[erec(42, &rect)], [0; 4], [0; 4])).unwrap(); + let (segs, fill, _, _) = path_segs(&p.items[0]); + assert_eq!(segs.len(), 6); + assert_eq!(segs[0], Seg::Move(40.0, 10.0)); + assert!(matches!(segs[4], Seg::Cubic(_, _, _, _, x, y) if close(x, 40.0) && close(y, 10.0))); + assert_eq!(segs[5], Seg::Close); + assert_eq!(fill, Some([255, 255, 255, 255])); +} + +#[test] +fn emf_path_bracket_strokeandfill_uses_stock_black_brush() { + let bounds = vec![0u8; 16]; + let recs = [ + erec(59, &[]), + erec(27, &cat(vec![e32(0), e32(0)])), + erec(54, &cat(vec![e32(10), e32(0)])), + erec(54, &cat(vec![e32(10), e32(10)])), + erec(61, &[]), + erec(60, &[]), + erec(37, &h32(0x8000_0004)), + erec(63, &bounds), + ]; + let p = parse(&emf(&recs, [0; 4], [0; 4])).unwrap(); + assert_eq!(p.items.len(), 1); + let (segs, fill, stroke, _) = path_segs(&p.items[0]); + assert_eq!(segs, &vec![Seg::Move(0.0, 0.0), Seg::Line(10.0, 0.0), Seg::Line(10.0, 10.0), Seg::Close]); + assert_eq!(fill, Some([0, 0, 0, 255])); + assert_eq!(stroke, Some(([0, 0, 0, 255], 0.0))); +} + +#[test] +fn item_cap_stops_the_parse_and_keeps_the_rest() { + let rect = wrec(0x041B, &cat(vec![h16(10), h16(10), h16(0), h16(0)])); + let recs: Vec> = (0..MAX_ITEMS + 10).map(|_| rect.clone()).collect(); + let p = parse(&wmf(&recs, None)).unwrap(); + assert_eq!(p.items.len(), MAX_ITEMS); +} + +#[test] +fn detection_and_garbage() { + let w = wmf(&[wrec(0x041B, &cat(vec![h16(1), h16(1), h16(0), h16(0)]))], None); + let e = emf(&[erec(42, &cat(vec![e32(0), e32(0), e32(4), e32(4)]))], [0; 4], [0; 4]); + assert!(is_metafile(&w)); + assert!(is_metafile(&e)); + assert!(!is_metafile(b"hello world, not a metafile")); + assert_eq!(parse(b"hello world, not a metafile").unwrap_err(), Error::NotMetafile); + assert!(parse(&[]).is_err()); +} + +/// Fixed-seed LCG (Knuth's constants): the fuzz inputs are reproducible. +struct Lcg(u64); + +impl Lcg { + fn rnd(&mut self) -> u32 { + self.0 = self.0.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1_442_695_040_888_963_407); + (self.0 >> 33) as u32 + } +} + +#[test] +fn truncated_random_and_mutated_inputs_never_panic() { + let rect = wrec(0x041B, &cat(vec![h16(10), h16(10), h16(0), h16(0)])); + let wfile = wmf( + &[ + wrec(0x02FC, &cat(vec![h16(0), h32(0xFF), h16(0)])), + wrec(0x012D, &h16(0)), + rect, + wrec(0x0325, &cat(vec![h16(2), h16(0), h16(0), h16(5), h16(5)])), + ], + Some([0, 0, 20, 10]), + ); + let mut dib_rec = cat(vec![h32(0x00CC_0020), h16(0), h16(2), h16(2), h16(0), h16(0), h16(10), h16(20), h16(0), h16(0)]); + dib_rec.extend(dib24_bottom_up()); + let mut payload = vec![0u8; 16]; + payload.extend(cat(vec![e32(0), e32(0), e32(0), e32(0), e32(2), e32(2), e32(80), e32(40), e32(120), e32(16), e32(0), e32(0), e32(20), e32(10)])); + payload.extend(info_header(2, 2, 24, 16)); + payload.extend(dib24_bottom_up().get(40..).unwrap_or_default()); + let efile = emf( + &[ + erec(59, &[]), + erec(27, &cat(vec![e32(0), e32(0)])), + erec(54, &cat(vec![e32(9), e32(9)])), + erec(61, &[]), + erec(60, &[]), + erec(63, &[0; 16]), + erec(81, &payload), + erec(86, &cat(vec![h32(4), h16(0), h16(0), h16(9), h16(9), h16(0), h16(9)])), + ], + [0, 0, 20, 10], + [0, 0, 2540, 1270], + ); + for file in [&wfile, &efile] { + for n in 0..=file.len() { + let prefix = &file[..n]; + let _ = parse(prefix); + let _ = is_metafile(prefix); + } + } + let mut rng = Lcg(0x2545_F491_4F6C_DD1D); + for _ in 0..3000 { + let len = (rng.rnd() % 400) as usize; + let buf: Vec = (0..len).map(|_| rng.rnd() as u8).collect(); + let _ = parse(&buf); + } + for i in 0..1500usize { + let mut buf = if i.is_multiple_of(2) { efile.clone() } else { wfile.clone() }; + for _ in 0..8 { + let at = rng.rnd() as usize % buf.len(); + buf[at] = rng.rnd() as u8; + } + let _ = parse(&buf); + } + for i in 0..500usize { + let mut buf = if i.is_multiple_of(2) { efile.clone() } else { wfile.clone() }; + let at = rng.rnd() as usize % buf.len(); + buf[at..].iter_mut().for_each(|b| *b = 0xFF); + let _ = parse(&buf); + } +} + +#[test] +fn wmf_stock_null_brush_by_16_bit_handle_has_no_fill() { + // SELECTOBJECT with 0x8005 is the NULL_BRUSH stock object (0x8000 | 5), so the rectangle is outline only. + let recs = [wrec(0x012D, &h16(0x8005u16 as i32)), wrec(0x041B, &cat(vec![h16(70), h16(110), h16(20), h16(10)]))]; + let p = parse(&wmf(&recs, None)).unwrap(); + let (_, fill, stroke, _) = path_segs(&p.items[0]); + assert_eq!(fill, None); + assert_eq!(stroke, Some(([0, 0, 0, 255], 0.0))); +} + +#[test] +fn emf_frame_comes_from_rclframe_not_device_bounds() { + // Bounds cover only the left half of the 25 x 12.5 mm frame (device units are 0.04 per 0.01 mm), so a + // rectangle from x = 0 to 50 px ends at half the picture width. + let rec = erec(43, &cat(vec![e32(0), e32(0), e32(50), e32(25)])); + let p = parse(&emf(&[rec], [0, 0, 50, 25], [0, 0, 2500, 1250])).unwrap(); + let w = 2500.0 * 72.0 / 2540.0; + assert!(close(p.width, w as f32) && close(p.height, (1250.0 * 72.0 / 2540.0) as f32)); + let (segs, ..) = path_segs(&p.items[0]); + assert!(matches!(segs[1], Seg::Line(x, _) if close(x, (w / 2.0) as f32)), "{segs:?}"); +} + +#[test] +fn emf_bounds_stand_in_when_the_frame_is_empty() { + let rec = erec(43, &cat(vec![e32(0), e32(0), e32(50), e32(25)])); + let p = parse(&emf(&[rec], [0, 0, 50, 25], [0; 4])).unwrap(); + assert_eq!((p.width, p.height), (50.0, 25.0)); +} + +#[test] +fn wmf_placeable_bounds_go_through_the_window_mapping() { + // Logical bounds 200 x 100 at 1440 per inch are 10 x 5 points; the window is 2:1 onto the viewport, so + // the frame is 100 x 50 device units and the rectangle fills it. + let recs = [ + wrec(0x0103, &h16(8)), + wrec(0x020C, &cat(vec![h16(100), h16(200)])), + wrec(0x020E, &cat(vec![h16(50), h16(100)])), + wrec(0x041B, &cat(vec![h16(100), h16(200), h16(0), h16(0)])), + ]; + let p = parse(&wmf(&recs, Some([0, 0, 200, 100]))).unwrap(); + assert_eq!((p.width, p.height), (10.0, 5.0)); + let (segs, ..) = path_segs(&p.items[0]); + assert!(segs.iter().any(|s| matches!(s, Seg::Line(x, y) if close(*x, 10.0) && close(*y, 5.0))), "{segs:?}"); +} + +#[test] +fn wmf_stretchdib_clipped_source_keeps_its_place() { + // Source x from -1 for 2 columns: only column 0 is in the bitmap, so it fills the right half of the + // destination and the picture keeps the original 20-unit width scale. + let params = cat(vec![h32(0x00CC_0020), h16(0), h16(2), h16(2), h16(0), h16(-1), h16(10), h16(20), h16(0), h16(0), dib24_bottom_up()]); + let p = parse(&wmf(&[wrec(0x0F43, ¶ms)], Some([0, 0, 20, 10]))).unwrap(); + let Item::Bitmap { rect, width, height, rgba } = &p.items[0] else { panic!("expected a bitmap") }; + // Placeable units are points: 20 units at 1440 per inch are 1 point, so the right column is 0.5 pt wide. + assert_eq!(rect, &[0.5, 0.0, 0.5, 0.5]); + assert_eq!((*width, *height), (1, 2)); + assert_eq!(rgba.as_slice(), [255, 0, 0, 255, 0, 0, 255, 255]); +} + +#[test] +fn wmf_negative_destination_width_mirrors_the_bitmap() { + let params = cat(vec![h32(0x00CC_0020), h16(0), h16(2), h16(2), h16(0), h16(0), h16(10), h16(-20), h16(0), h16(0), dib24_bottom_up()]); + // Frame from the placeable bounds (-20, 0)-(0, 10): the mirrored bitmap fills it. + let p = parse(&wmf(&[wrec(0x0F43, ¶ms)], Some([-20, 0, 0, 10]))).unwrap(); + let Item::Bitmap { rect, rgba, .. } = &p.items[0] else { panic!("expected a bitmap") }; + assert_eq!(rect, &[0.0, 0.0, 1.0, 0.5]); + assert_eq!(&rgba[..8], [255, 255, 255, 255, 255, 0, 0, 255]); +} + +#[test] +fn emf_blackness_bitblt_fills_black_without_a_source() { + let mut payload = vec![0u8; 16]; + payload.extend(cat(vec![e32(10), e32(20), e32(30), e32(40), e32(0x0000_0042)])); + let p = parse(&emf(&[erec(76, &payload)], [0; 4], [0; 4])).unwrap(); + let (segs, fill, stroke, _) = path_segs(&p.items[0]); + assert_eq!(fill, Some([0, 0, 0, 255])); + assert_eq!(stroke, None); + assert_eq!(segs.len(), 5); +} + +#[test] +fn emf_savedc_past_the_stack_cap_stops_the_parse() { + let mut recs: Vec> = (0..=crate::MAX_STACK).map(|_| erec(33, &[])).collect(); + recs.push(erec(43, &cat(vec![e32(0), e32(0), e32(10), e32(10)]))); + let p = parse(&emf(&recs, [0; 4], [0; 4])).unwrap(); + assert!(p.items.is_empty(), "records after the cap must not be drawn"); +} + +#[test] +fn canvas_stops_at_the_byte_budget() { + // Two 128 MB bitmaps fit the budget; the third would exceed it and stops the canvas. + let mut cv = Canvas::new(); + let big = || Rgba { w: 1, h: 1, bottom_up: false, px: vec![0u8; MAX_BYTES / 2] }; + cv.bitmap([0.0, 0.0, 1.0, 1.0], big()); + cv.bitmap([0.0, 0.0, 1.0, 1.0], big()); + assert!(!cv.full()); + cv.bitmap([0.0, 0.0, 1.0, 1.0], big()); + assert!(cv.full()); + assert_eq!(cv.items.len(), 2); +} + +#[test] +fn oversized_dib_header_size_is_rejected_without_overflow() { + let mut info = info_header(2, 2, 24, 16); + info[0..4].copy_from_slice(&0xFFFF_FFF0u32.to_le_bytes()); + let params = cat(vec![h32(0x00CC_0020), h16(0), h16(2), h16(2), h16(0), h16(0), h16(10), h16(20), h16(0), h16(0), info]); + let p = parse(&wmf(&[wrec(0x0F43, ¶ms)], None)).unwrap(); + assert!(p.items.is_empty()); +} + +/// An EMF record of `len` bytes (header included) with u32 fields and byte blobs written at absolute +/// record offsets, so each test places a field where the spec puts it. +fn erec_at(typ: u32, len: usize, fields: &[(usize, u32)], blobs: &[(usize, &[u8])]) -> Vec { + let mut v = vec![0u8; len]; + v[0..4].copy_from_slice(&typ.to_le_bytes()); + v[4..8].copy_from_slice(&(len as u32).to_le_bytes()); + for (off, val) in fields { + v[*off..*off + 4].copy_from_slice(&val.to_le_bytes()); + } + for (off, b) in blobs { + v[*off..*off + b.len()].copy_from_slice(b); + } + v +} + +/// A BITBLT-style record (BITBLT, STRETCHBLT, ALPHABLEND layout) with its DIB at 112: the bmi and bits +/// offsets and lengths go at 84 and 92. `fields` and `blobs` add the other fields. +fn dib_rec(typ: u32, fields: &[(usize, u32)], blobs: &[(usize, &[u8])], info: &[u8], bits: &[u8]) -> Vec { + let at = 112; + let bits_at = at + info.len(); + let mut f = fields.to_vec(); + f.extend([(84, at as u32), (88, info.len() as u32), (92, bits_at as u32), (96, bits.len() as u32)]); + let mut b: Vec<(usize, &[u8])> = blobs.to_vec(); + b.extend([(at, info), (bits_at, bits)]); + erec_at(typ, bits_at + bits.len(), &f, &b) +} + +/// BITBLT (76) with destination `dst` = [x, y, w, h] and a 40-byte header DIB. +fn bitblt_rec(rop: u32, dst: [u32; 4], info: &[u8], bits: &[u8]) -> Vec { + let fields = [(24, dst[0]), (28, dst[1]), (32, dst[2]), (36, dst[3]), (40, rop)]; + dib_rec(76, &fields, &[], info, bits) +} + +#[test] +fn wmf_font_takes_an_object_slot_so_handles_line_up() { + // The font is handle 0, so the pen created after it is handle 1. + let recs = [ + wrec(0x02FB, &[0u8; 18]), + wrec(0x02FA, &cat(vec![h16(0), h16(4), h16(0), h32(0x0000_FF00)])), + wrec(0x012D, &h16(1)), + wrec(0x0325, &cat(vec![h16(2), h16(0), h16(0), h16(50), h16(0), h16(50)])), + ]; + let p = parse(&wmf(&recs, None)).unwrap(); + let (_, _, stroke, _) = path_segs(&p.items[0]); + assert_eq!(stroke, Some(([0, 255, 0, 255], 4.0))); +} + +#[test] +fn emf_polybezierto16_continues_the_open_path() { + // BEGINPATH, MOVETO a, LINETO b, POLYBEZIERTO16, CLOSEFIGURE, FILLPATH: one closed figure from a. + let mut bezier = vec![0u8; 16]; + bezier.extend(cat(vec![e32(3), h16(10), h16(10), h16(20), h16(10), h16(20), h16(0)])); + let recs = [ + erec(59, &[]), + erec(27, &cat(vec![e32(0), e32(0)])), + erec(54, &cat(vec![e32(10), e32(0)])), + erec(88, &bezier), + erec(61, &[]), + erec(62, &[0; 16]), + ]; + let p = parse(&emf(&recs, [0; 4], [0; 4])).unwrap(); + assert_eq!(p.items.len(), 1); + let (segs, fill, _, _) = path_segs(&p.items[0]); + assert_eq!(segs, &vec![Seg::Move(0.0, 0.0), Seg::Line(10.0, 0.0), Seg::Cubic(10.0, 10.0, 20.0, 10.0, 20.0, 0.0), Seg::Close]); + assert_eq!(fill, Some([255, 255, 255, 255])); +} + +#[test] +fn emf_polybezier_starts_its_own_figure_and_keeps_the_current_point() { + // POLYBEZIER (2) starts at its first point; the LINETO after it still starts at the MOVETO point. + let mut bezier = vec![0u8; 16]; + bezier.extend(cat(vec![e32(4), e32(0), e32(0), e32(5), e32(5), e32(10), e32(5), e32(10), e32(0)])); + let recs = [erec(27, &cat(vec![e32(3), e32(3)])), erec(2, &bezier), erec(54, &cat(vec![e32(50), e32(50)]))]; + let p = parse(&emf(&recs, [0; 4], [0; 4])).unwrap(); + assert_eq!(p.items.len(), 2); + let (segs, ..) = path_segs(&p.items[0]); + assert_eq!(segs[0], Seg::Move(0.0, 0.0)); + let (segs, ..) = path_segs(&p.items[1]); + assert_eq!(segs, &vec![Seg::Move(3.0, 3.0), Seg::Line(50.0, 50.0)]); +} + +#[test] +fn hairline_pen_stays_zero_under_an_anisotropic_window() { + // Window 2000 x 2000 onto a 1 x 1 viewport: a cosmetic pen must not become one device unit wide. + let recs = [ + wrec(0x0103, &h16(8)), + wrec(0x020C, &cat(vec![h16(2000), h16(2000)])), + wrec(0x020E, &cat(vec![h16(1), h16(1)])), + wrec(0x0214, &cat(vec![h16(0), h16(0)])), + wrec(0x0213, &cat(vec![h16(2000), h16(2000)])), + ]; + let p = parse(&wmf(&recs, None)).unwrap(); + assert_eq!((p.width, p.height), (1.0, 1.0)); + let (segs, _, stroke, _) = path_segs(&p.items[0]); + assert_eq!(segs.last(), Some(&Seg::Line(1.0, 1.0))); + assert_eq!(stroke, Some(([0, 0, 0, 255], 0.0))); +} + +#[test] +fn null_pen_is_recognised_under_extra_style_bits() { + // Pen style 0x105: PS_NULL (5) with a flag above the style nibble still draws no outline. + let pen = erec_at(38, 28, &[(8, 0), (12, 0x105), (16, 1), (24, 0)], &[]); + let recs = [pen, erec(37, &h32(0)), erec(43, &cat(vec![e32(0), e32(0), e32(10), e32(10)]))]; + let p = parse(&emf(&recs, [0; 4], [0; 4])).unwrap(); + let (_, fill, stroke, _) = path_segs(&p.items[0]); + assert_eq!(fill, Some([255, 255, 255, 255])); + assert_eq!(stroke, None); +} + +#[test] +fn metric_map_mode_flips_y_and_scales_to_device_pixels_emf() { + // LOMETRIC is 0.1 mm per unit; the header maps 4 device pixels to 1 mm. Points go y-up, so the + // drawing lies below its origin and the picture frame moves up to its top. + let recs = [erec(17, &h32(2)), erec(27, &cat(vec![e32(0), e32(0)])), erec(54, &cat(vec![e32(100), e32(50)]))]; + let p = parse(&emf(&recs, [0; 4], [0; 4])).unwrap(); + let (segs, ..) = path_segs(&p.items[0]); + assert!(close_seg(&segs[0], Seg::Move(0.0, 20.0)) && close_seg(&segs[1], Seg::Line(40.0, 0.0)), "{segs:?}"); +} + +#[test] +fn metric_map_mode_uses_96_dpi_for_wmf() { + let recs = [wrec(0x0103, &h16(2)), wrec(0x0214, &cat(vec![h16(0), h16(0)])), wrec(0x0213, &cat(vec![h16(50), h16(100)]))]; + let p = parse(&wmf(&recs, None)).unwrap(); + let px_mm = 96.0 / 25.4; + let (segs, ..) = path_segs(&p.items[0]); + let (x, y) = (100.0 * 0.1 * px_mm, 50.0 * 0.1 * px_mm); + assert!(close_seg(&segs[0], Seg::Move(0.0, y as f32)) && close_seg(&segs[1], Seg::Line(x as f32, 0.0)), "{segs:?}"); +} + +/// Segment equality within [`close`] tolerance. +fn close_seg(a: &Seg, b: Seg) -> bool { + match (*a, b) { + (Seg::Move(x, y), Seg::Move(u, v)) | (Seg::Line(x, y), Seg::Line(u, v)) => close(x, u) && close(y, v), + _ => false, + } +} + +#[test] +fn isotropic_mode_uses_one_scale_and_keeps_axis_signs() { + // Window 100 x 50; viewport 400 x -100: the scale is min(4, 2) = 2 on both axes, y flipped. + let recs = [ + wrec(0x0103, &h16(7)), + wrec(0x020C, &cat(vec![h16(50), h16(100)])), + wrec(0x020E, &cat(vec![h16(-100), h16(400)])), + wrec(0x0214, &cat(vec![h16(0), h16(0)])), + wrec(0x0213, &cat(vec![h16(25), h16(50)])), + ]; + let p = parse(&wmf(&recs, None)).unwrap(); + let (segs, ..) = path_segs(&p.items[0]); + assert!(close_seg(&segs[0], Seg::Move(0.0, 100.0)) && close_seg(&segs[1], Seg::Line(100.0, 50.0)), "{segs:?}"); +} + +#[test] +fn modify_world_with_an_unknown_mode_keeps_the_transform() { + let scale2 = cat([2.0f32, 0.0, 0.0, 2.0, 0.0, 0.0].iter().map(|v| v.to_le_bytes().to_vec()).collect()); + let identity = cat([1.0f32, 0.0, 0.0, 1.0, 0.0, 0.0].iter().map(|v| v.to_le_bytes().to_vec()).collect()); + let recs = + [erec(35, &scale2), erec(36, &cat(vec![identity, h32(99)])), erec(27, &cat(vec![e32(0), e32(0)])), erec(54, &cat(vec![e32(10), e32(5)]))]; + let p = parse(&emf(&recs, [0; 4], [0; 4])).unwrap(); + let (segs, ..) = path_segs(&p.items[0]); + assert_eq!(segs, &vec![Seg::Move(0.0, 0.0), Seg::Line(20.0, 10.0)]); +} + +#[test] +fn round_rect_accepts_reversed_corners() { + let normal = crate::geom::round_rect(0.0, 0.0, 10.0, 20.0, 4.0, 4.0); + assert_eq!(crate::geom::round_rect(10.0, 20.0, 0.0, 0.0, 4.0, 4.0), normal); + assert_eq!(crate::geom::round_rect(10.0, 0.0, 0.0, 20.0, 4.0, 4.0), normal); +} + +#[test] +fn stroke_width_is_clamped_to_the_picture_diagonal() { + // A 1440-unit placeable picture is 72 points square (diagonal 101.8); a 5000-unit pen would be 250. + let recs = [ + wrec(0x02FA, &cat(vec![h16(0), h16(5000), h16(0), h32(0)])), + wrec(0x012D, &h16(0)), + wrec(0x041B, &cat(vec![h16(1440), h16(1440), h16(0), h16(0)])), + ]; + let p = parse(&wmf(&recs, Some([0, 0, 1440, 1440]))).unwrap(); + assert_eq!((p.width, p.height), (72.0, 72.0)); + let (_, _, stroke, _) = path_segs(&p.items[0]); + assert!(close(stroke.unwrap_or(([0; 4], 0.0)).1, 72.0 * 2.0f32.sqrt()), "{stroke:?}"); +} + +/// A WMF rectangle from (0, 0) to (1, 1), so a picture's frame starts at the origin. +fn origin_rect() -> Vec { + wrec(0x041B, &cat(vec![h16(1), h16(1), h16(0), h16(0)])) +} + +#[test] +fn wmf_dibbitblt_without_a_dib_is_a_pattern_fill() { + // The 12-word form: rop(4), ySrc, xSrc, reserved, height, width, yDest, xDest; PATCOPY fills with the brush. + let params = cat(vec![h32(0x00F0_0021), h16(0), h16(0), h16(0), h16(10), h16(20), h16(5), h16(7)]); + let p = parse(&wmf(&[origin_rect(), wrec(0x0940, ¶ms)], None)).unwrap(); + let (segs, fill, _, _) = path_segs(&p.items[1]); + assert_eq!(fill, Some([255, 255, 255, 255])); + assert_eq!(segs, &vec![Seg::Move(7.0, 5.0), Seg::Line(27.0, 5.0), Seg::Line(27.0, 15.0), Seg::Line(7.0, 15.0), Seg::Close]); +} + +#[test] +fn wmf_dibstretchblt_without_a_dib_is_a_rop_fill() { + // The 14-word form: rop(4), srcH, srcW, ySrc, xSrc, reserved, destH, destW, yDest, xDest; BLACKNESS fills black. + let params = cat(vec![h32(0x0000_0042), h16(2), h16(2), h16(0), h16(0), h16(0), h16(10), h16(20), h16(5), h16(7)]); + let p = parse(&wmf(&[origin_rect(), wrec(0x0B41, ¶ms)], None)).unwrap(); + let (segs, fill, stroke, _) = path_segs(&p.items[1]); + assert_eq!((fill, stroke), (Some([0, 0, 0, 255]), None)); + assert_eq!(segs, &vec![Seg::Move(7.0, 5.0), Seg::Line(27.0, 5.0), Seg::Line(27.0, 15.0), Seg::Line(7.0, 15.0), Seg::Close]); +} + +#[test] +fn wmf_dibstretchblt_stretches_the_dib_into_its_destination() { + // rop(4), srcH, srcW, ySrc, xSrc, destH, destW, yDest, xDest, then the DIB (no reserved word). + let mut params = cat(vec![h32(0x00CC_0020), h16(2), h16(2), h16(0), h16(0), h16(10), h16(20), h16(6), h16(5)]); + params.extend(dib24_bottom_up()); + let p = parse(&wmf(&[origin_rect(), wrec(0x0B41, ¶ms)], None)).unwrap(); + let Item::Bitmap { rect, rgba, .. } = &p.items[1] else { panic!("expected a bitmap") }; + assert_eq!(rect, &[5.0, 6.0, 20.0, 10.0]); + assert_eq!(rgba.as_slice(), DIB24_RGBA); +} + +#[test] +fn wmf_negative_destination_height_mirrors_the_rows() { + let params = cat(vec![h32(0x00CC_0020), h16(0), h16(2), h16(2), h16(0), h16(0), h16(-10), h16(20), h16(0), h16(0), dib24_bottom_up()]); + let p = parse(&wmf(&[wrec(0x0F43, ¶ms)], None)).unwrap(); + let Item::Bitmap { rect, rgba, .. } = &p.items[0] else { panic!("expected a bitmap") }; + assert_eq!(rect, &[0.0, 0.0, 20.0, 10.0]); + // Blue, green on top; red, white below. + assert_eq!(rgba.as_slice(), [0, 0, 255, 255, 0, 255, 0, 255, 255, 0, 0, 255, 255, 255, 255, 255]); +} + +#[test] +fn wmf_dibbitblt_copies_the_dib_at_its_destination() { + // The rectangle fixes the picture origin at (0, 0); the DIB lands at x = 5, y = 6. + let mut params = cat(vec![h32(0x00CC_0020), h16(0), h16(0), h16(2), h16(2), h16(6), h16(5)]); + params.extend(dib24_bottom_up()); + let recs = [wrec(0x041B, &cat(vec![h16(10), h16(10), h16(0), h16(0)])), wrec(0x0940, ¶ms)]; + let p = parse(&wmf(&recs, None)).unwrap(); + let Item::Bitmap { rect, width, height, rgba } = &p.items[1] else { panic!("expected a bitmap") }; + assert_eq!(rect, &[5.0, 6.0, 2.0, 2.0]); + assert_eq!((*width, *height), (2, 2)); + assert_eq!(rgba.as_slice(), DIB24_RGBA); +} + +#[test] +fn emf_setdibitstodevice_draws_the_image_at_its_own_size() { + let dib = dib24_bottom_up(); + let (info, bits) = (&dib[..40], &dib[40..]); + // Destination x/y at 24, src x/y at 32, size at 40, bmi at 48 (offset, length), bits at 56, scan start + // 68 and scan count 72. + let fields = [(24, 10), (28, 20), (40, 2), (44, 2), (48, 112), (52, 40), (56, 152), (60, 16), (72, 2)]; + let rec = erec_at(80, 168, &fields, &[(112, info), (152, bits)]); + let p = parse(&emf(&[rec], [0; 4], [0; 4])).unwrap(); + let Item::Bitmap { rect, rgba, .. } = &p.items[0] else { panic!("expected a bitmap") }; + assert_eq!(rect, &[0.0, 0.0, 2.0, 2.0]); + assert_eq!(rgba.as_slice(), DIB24_RGBA); +} + +#[test] +fn emf_setdibitstodevice_source_counts_from_the_start_scan() { + // ySrc 1 with the bits starting at scan 1 is source row 0 of the bits: the bottom row (blue, green), + // drawn cxSrc x cySrc = 2 x 1 at the destination. + let dib = dib24_bottom_up(); + let (info, bits) = (&dib[..40], &dib[40..]); + let fields = [(24, 10), (28, 20), (36, 1), (40, 2), (44, 1), (48, 112), (52, 40), (56, 152), (60, 16), (68, 1), (72, 2)]; + let rec = erec_at(80, 168, &fields, &[(112, info), (152, bits)]); + let r = erec(43, &cat(vec![e32(0), e32(0), e32(1), e32(1)])); + let p = parse(&emf(&[r, rec], [0; 4], [0; 4])).unwrap(); + let Item::Bitmap { rect, rgba, .. } = &p.items[1] else { panic!("expected a bitmap") }; + assert_eq!(rect, &[10.0, 20.0, 2.0, 1.0]); + assert_eq!(rgba.as_slice(), [0, 0, 255, 255, 0, 255, 0, 255]); +} + +#[test] +fn emf_alphablend_uses_constant_and_per_pixel_alpha() { + // Constant alpha 128 (BLENDFUNCTION bytes at 40: op, flags, constant, format) over a 24-bit DIB. + let blend = [0u8, 0, 128, 0]; + let dib = dib24_bottom_up(); + let rec = dib_rec(114, &[(24, 0), (28, 0), (32, 2), (36, 2), (44, 0), (48, 0), (100, 2), (104, 2)], &[(40, &blend[..])], &dib[..40], &dib[40..]); + let p = parse(&emf(&[rec], [0; 4], [0; 4])).unwrap(); + let Item::Bitmap { rgba, .. } = &p.items[0] else { panic!("expected a bitmap") }; + let alpha: Vec = rgba.as_chunks::<4>().0.iter().map(|px| px[3]).collect(); + assert_eq!(alpha, vec![128; 4]); + // Per-pixel alpha (AC_SRC_ALPHA) over a 32-bit premultiplied DIB: BGRA 0, 0, 128, 128 is straight red at + // half alpha, and the constant 128 halves the alpha again. + let one_px = |constant: u8, bgra: [u8; 4]| { + let blend = [0u8, 0, constant, 1]; + let info = info_header(1, 1, 32, 4); + let rec = dib_rec(114, &[(24, 0), (28, 0), (32, 1), (36, 1), (44, 0), (48, 0), (100, 1), (104, 1)], &[(40, &blend[..])], &info, &bgra); + let p = parse(&emf(&[rec], [0; 4], [0; 4])).unwrap(); + let Item::Bitmap { rgba, .. } = &p.items[0] else { panic!("expected a bitmap") }; + rgba.clone() + }; + assert_eq!(one_px(255, [0, 0, 128, 128]), [255, 0, 0, 128]); + assert_eq!(one_px(128, [0, 0, 128, 128]), [255, 0, 0, 64]); + assert_eq!(one_px(255, [10, 20, 64, 128]), [128, 40, 20, 128], "rounded to the nearest value"); +} + +#[test] +fn emf_srcand_mask_draws_all_but_its_white_pixels() { + // SRCAND over white paper is the source itself; white keeps the destination, so it is transparent. + let dib = dib24_bottom_up(); + let rec = bitblt_rec(0x0088_00C6, [0, 0, 2, 2], &dib[..40], &dib[40..]); + let p = parse(&emf(&[rec], [0; 4], [0; 4])).unwrap(); + let Item::Bitmap { rgba, .. } = &p.items[0] else { panic!("expected a bitmap") }; + assert_eq!(rgba.as_slice(), [255, 0, 0, 255, 255, 255, 255, 0, 0, 0, 255, 255, 0, 255, 0, 255]); +} + +#[test] +fn emf_srcpaint_makes_black_pixels_transparent() { + // A 2 x 1 DIB: black, then red. SRCPAINT leaves black transparent; SRCCOPY keeps it opaque. + let info = info_header(2, 1, 24, 8); + let bits = [0u8, 0, 0, 0, 0, 255, 0, 0]; + let p = parse(&emf(&[bitblt_rec(0x00EE_0086, [0, 0, 2, 1], &info, &bits)], [0; 4], [0; 4])).unwrap(); + let Item::Bitmap { rgba, .. } = &p.items[0] else { panic!("expected a bitmap") }; + assert_eq!(rgba.as_slice(), [0, 0, 0, 0, 255, 0, 0, 255]); + let p = parse(&emf(&[bitblt_rec(0x00CC_0020, [0, 0, 2, 1], &info, &bits)], [0; 4], [0; 4])).unwrap(); + let Item::Bitmap { rgba, .. } = &p.items[0] else { panic!("expected a bitmap") }; + assert_eq!(rgba.as_slice(), [0, 0, 0, 255, 255, 0, 0, 255]); +} + +#[test] +fn wmf_dib_palette_entries_are_skipped_before_the_bits() { + // A 24-bit DIB with biClrUsed = 2 carries two RGBQUADs (8 bytes) before its pixel bits. + let mut info = info_header(2, 2, 24, 16); + info[32..36].copy_from_slice(&2u32.to_le_bytes()); + let mut dib = info; + dib.extend([9u8; 8]); + dib.extend(&dib24_bottom_up()[40..]); + let params = cat(vec![h32(0x00CC_0020), h16(0), h16(2), h16(2), h16(0), h16(0), h16(10), h16(20), h16(0), h16(0), dib]); + let p = parse(&wmf(&[wrec(0x0F43, ¶ms)], None)).unwrap(); + let Item::Bitmap { rgba, .. } = &p.items[0] else { panic!("expected a bitmap") }; + assert_eq!(rgba.as_slice(), DIB24_RGBA); +} + +#[test] +fn emf_point_counts_past_the_budget_are_rejected() { + // A count of 4 billion points in a record that cannot hold them: nothing is drawn and nothing is allocated. + let mut poly = vec![0u8; 16]; + poly.extend(cat(vec![h32(u32::MAX), h32(0), h32(0)])); + let mut groups = vec![0u8; 16]; + groups.extend(cat(vec![h32(u32::MAX), h32(u32::MAX), h32(1)])); + let p = parse(&emf(&[erec(4, &poly), erec(7, &groups)], [0; 4], [0; 4])).unwrap(); + assert!(p.items.is_empty()); +} + +#[test] +fn emf_polypolygon_counts_must_fit_the_record() { + // A polygon count within the point budget in a record too short to hold the counts draws nothing. + let mut short = vec![0u8; 16]; + short.extend(cat(vec![h32(crate::MAX_PATH_OPS as u32), h32(3), h32(3)])); + // POLYPOLYGON16: two triangles of three points each. + let mut two = vec![0u8; 16]; + two.extend(cat(vec![h32(2), h32(6), h32(3), h32(3)])); + two.extend(cat([0, 0, 10, 0, 0, 10, 20, 0, 30, 0, 20, 10].iter().map(|v| h16(*v)).collect())); + let p = parse(&emf(&[erec(91, &short), erec(91, &two)], [0; 4], [0; 4])).unwrap(); + assert_eq!(p.items.len(), 1); + let (segs, ..) = path_segs(&p.items[0]); + assert_eq!(segs.iter().filter(|s| matches!(s, Seg::Move(..))).count(), 2, "{segs:?}"); +} + +/// EMR_EXTCREATEPEN into slot 1 with LogPenEx style, width and brush style, then a selected rectangle. +fn ext_pen_rect(style: u32, width: u32, brush: u32) -> Option<([u8; 4], f32)> { + let pen = erec_at(95, 52, &[(8, 1), (28, style), (32, width), (36, brush), (40, 0x0000_FF00)], &[]); + let recs = [pen, erec(37, &h32(1)), erec(43, &cat(vec![e32(0), e32(0), e32(10), e32(10)]))]; + let p = parse(&emf(&recs, [0; 4], [0; 4])).unwrap(); + path_segs(&p.items[0]).2 +} + +#[test] +fn emf_ext_pen_cosmetic_is_a_hairline_and_null_brush_draws_nothing() { + // PS_COSMETIC widths are device pixels: a hairline, not one logical unit. + assert_eq!(ext_pen_rect(0, 1, 0), Some(([0, 255, 0, 255], 0.0))); + // PS_GEOMETRIC (0x10000) keeps its logical width. + assert_eq!(ext_pen_rect(0x0001_0000, 4, 0), Some(([0, 255, 0, 255], 4.0))); + // A BS_NULL brush draws no line. + assert_eq!(ext_pen_rect(0x0001_0000, 4, 1), None); +} + +#[test] +fn flip_tolerates_a_short_pixel_buffer() { + let mut img = Rgba { w: 2, h: 2, bottom_up: false, px: vec![1u8; 7] }; + crate::dib::flip(&mut img, true, true); + assert_eq!(img.px.len(), 7); + // Three rows held of four: only the rows present swap (0 with 2). + let mut img = Rgba { w: 1, h: 4, bottom_up: false, px: (0u8..12).collect() }; + crate::dib::flip(&mut img, false, true); + assert_eq!(img.px, [8, 9, 10, 11, 4, 5, 6, 7, 0, 1, 2, 3]); +} + +#[test] +fn place_fits_the_frame_scales_strokes_and_drops_far_paths() { + let pic = crate::Picture { + width: 100.0, + height: 50.0, + items: vec![ + Item::Path { segs: vec![Seg::Move(0.0, 0.0), Seg::Line(10.0, 10.0)], fill: None, stroke: Some(([0, 0, 0, 255], 4.0)), even_odd: true }, + Item::Path { segs: vec![Seg::Move(0.0, 0.0), Seg::Line(2e6, 0.0)], fill: None, stroke: None, even_odd: true }, + ], + }; + let placed = pic.place(kurbo::Rect::new(0.0, 0.0, 200.0, 100.0), [0.0; 4]); + assert_eq!(placed.len(), 1); + let crate::PlacedItem::Path { stroke, .. } = &placed[0] else { panic!("expected a path") }; + assert_eq!(stroke.map(|s| s.1), Some(8.0)); +} + +#[test] +fn place_keeps_only_finite_bounded_bitmaps_with_area() { + let bitmap = |rect: [f32; 4]| Item::Bitmap { rect, width: 1, height: 1, rgba: vec![0; 4] }; + let pic = crate::Picture { + width: 100.0, + height: 50.0, + items: vec![ + bitmap([f32::NAN, 0.0, 10.0, 10.0]), + bitmap([0.0, 0.0, 0.0, 10.0]), + bitmap([0.0, 0.0, 2e6, 10.0]), + bitmap([0.0, 0.0, f32::INFINITY, 10.0]), + bitmap([10.0, 5.0, 10.0, 10.0]), + ], + }; + let placed = pic.place(kurbo::Rect::new(0.0, 0.0, 200.0, 100.0), [0.0; 4]); + assert_eq!(placed.len(), 1); + let crate::PlacedItem::Bitmap { index, rect } = &placed[0] else { panic!("expected a bitmap") }; + assert_eq!((*index, *rect), (4, kurbo::Rect::new(20.0, 10.0, 40.0, 30.0))); +} + +#[test] +fn take_pixels_returns_only_drawable_bitmaps() { + let mut ok = Item::Bitmap { rect: [0.0; 4], width: 2, height: 1, rgba: vec![7; 8] }; + assert_eq!(ok.take_pixels(), Some((2, 1, vec![7; 8]))); + assert!(matches!(&ok, Item::Bitmap { rgba, .. } if rgba.is_empty()), "the pixels move out"); + let mut short = Item::Bitmap { rect: [0.0; 4], width: 2, height: 2, rgba: vec![0; 8] }; + assert_eq!(short.take_pixels(), None); + let side = crate::MAX_BITMAP_SIDE + 1; + let mut wide = Item::Bitmap { rect: [0.0; 4], width: side, height: 1, rgba: vec![0; side as usize * 4] }; + assert_eq!(wide.take_pixels(), None); + let mut path = Item::Path { segs: Vec::new(), fill: None, stroke: None, even_odd: false }; + assert_eq!(path.take_pixels(), None); + let pic = |width: f32, height: f32| crate::Picture { width, height, items: Vec::new() }; + assert!(pic(1.0, 2.0).has_size()); + assert!(!pic(0.0, 2.0).has_size() && !pic(f32::NAN, 2.0).has_size() && !pic(1.0, f32::INFINITY).has_size()); +} + +/// The rectangle a WMF RECTANGLE record covers, from its path's corners. +fn rect_extent(item: &Item) -> (f32, f32, f32, f32) { + let (segs, ..) = path_segs(item); + let pts: Vec<(f32, f32)> = segs + .iter() + .filter_map(|s| match s { + Seg::Move(x, y) | Seg::Line(x, y) => Some((*x, *y)), + _ => None, + }) + .collect(); + let (xs, ys): (Vec, Vec) = pts.into_iter().unzip(); + let lo = |v: &[f32]| v.iter().copied().fold(f32::MAX, f32::min); + let hi = |v: &[f32]| v.iter().copied().fold(f32::MIN, f32::max); + (lo(&xs), lo(&ys), hi(&xs), hi(&ys)) +} + +#[test] +fn plain_anisotropic_wmf_without_a_viewport_fills_its_window() { + // Anisotropic, window 200 x 100, no viewport (a logo strip saved without a placeable header): the + // window is the target, not a 1 x 1 viewport that squeezes the picture to a point. + let recs = [ + wrec(0x0103, &h16(8)), + wrec(0x020B, &cat(vec![h16(0), h16(0)])), + wrec(0x020C, &cat(vec![h16(100), h16(200)])), + wrec(0x041B, &cat(vec![h16(100), h16(200), h16(0), h16(0)])), + ]; + let p = parse(&wmf(&recs, None)).unwrap(); + assert_eq!((p.width, p.height), (200.0, 100.0)); + let (x0, y0, x1, y1) = rect_extent(&p.items[0]); + assert!(close(x0, 0.0) && close(y0, 0.0) && close(x1, 200.0) && close(y1, 100.0), "{x0} {y0} {x1} {y1}"); +} + +#[test] +fn placeable_wmf_window_records_replace_the_bounds() { + // The placeable bounds say 1440 square (72 points); the file then draws in a window centred on the + // origin, as a coat of arms saved by a drawing program does. The window maps onto the 72-point + // target, so the drawing fills it instead of being blown up around the bounds. + let recs = [ + wrec(0x020C, &cat(vec![h16(2000), h16(2000)])), + wrec(0x020B, &cat(vec![h16(-1000), h16(-1000)])), + wrec(0x041B, &cat(vec![h16(1000), h16(1000), h16(-1000), h16(-1000)])), + ]; + let p = parse(&wmf(&recs, Some([238, 539, 1678, 1979]))).unwrap(); + assert_eq!((p.width, p.height), (72.0, 72.0)); + let (x0, y0, x1, y1) = rect_extent(&p.items[0]); + assert!(close(x0, 0.0) && close(y0, 0.0) && close(x1, 72.0) && close(y1, 72.0), "{x0} {y0} {x1} {y1}"); +} + +#[test] +fn placeable_wmf_keeps_its_target_when_the_window_changes_shape() { + // Bounds 600 x 300 (30 x 15 points), then a portrait window 100 x 200 for a bitmap-sized drawing: + // the drawing still fills the target (Word stretches it to the picture's box). + let recs = [wrec(0x0103, &h16(8)), wrec(0x020C, &cat(vec![h16(200), h16(100)])), wrec(0x041B, &cat(vec![h16(200), h16(100), h16(0), h16(0)]))]; + let p = parse(&wmf(&recs, Some([0, 0, 600, 300]))).unwrap(); + assert_eq!((p.width, p.height), (30.0, 15.0)); + let (x0, y0, x1, y1) = rect_extent(&p.items[0]); + assert!(close(x0, 0.0) && close(y0, 0.0) && close(x1, 30.0) && close(y1, 15.0), "{x0} {y0} {x1} {y1}"); +} + +#[test] +fn rop2_nop_draws_nothing_until_the_mode_changes() { + // A coat of arms that first draws its bounding rectangle with the R2_NOP mix mode (no output), + // then its shapes with R2_COPYPEN. Restoring a saved state brings R2_NOP back. + let rect = |n: i32| wrec(0x041B, &cat(vec![h16(n), h16(n), h16(0), h16(0)])); + let recs = [wrec(0x0104, &h16(11)), rect(100), wrec(0x001E, &[]), wrec(0x0104, &h16(13)), rect(50), wrec(0x0127, &h16(-1)), rect(20)]; + let p = parse(&wmf(&recs, Some([0, 0, 100, 100]))).unwrap(); + assert_eq!(p.items.len(), 1, "{:?}", p.items); + let (_, _, x1, y1) = rect_extent(&p.items[0]); + // 50 of 100 units in a 5-point picture. + assert!(close(x1, 2.5) && close(y1, 2.5), "{x1} {y1}"); +} + +#[test] +fn plain_wmf_target_comes_from_a_window_that_was_read() { + // A window record too short to read, then the real one: the target is the real window, not the + // 1 x 1 default the short record left in place. A viewport origin set before it is kept. + let recs = [ + wrec(0x0103, &h16(8)), + wrec(0x020D, &cat(vec![h16(0), h16(0)])), + wrec(0x020C, &[]), + wrec(0x020C, &cat(vec![h16(100), h16(200)])), + wrec(0x041B, &cat(vec![h16(100), h16(200), h16(0), h16(0)])), + ]; + let p = parse(&wmf(&recs, None)).unwrap(); + assert_eq!((p.width, p.height), (200.0, 100.0)); + let (x0, y0, x1, y1) = rect_extent(&p.items[0]); + assert!(close(x0, 0.0) && close(y0, 0.0) && close(x1, 200.0) && close(y1, 100.0), "{x0} {y0} {x1} {y1}"); +} diff --git a/crates/metafile/src/wmf.rs b/crates/metafile/src/wmf.rs new file mode 100644 index 0000000..d221a76 --- /dev/null +++ b/crates/metafile/src/wmf.rs @@ -0,0 +1,273 @@ +//! WMF reader (MS-WMF). A file is an optional 22-byte placeable header, a 9-word standard header, then +//! records of (size in 16-bit words, function, parameters). Parameters are read at byte 6 of each record. + +use crate::bytes::Bytes; +use crate::canvas::{Brush, Canvas, Obj, Pen, colorref}; +use crate::dib; +use crate::geom::{self, ArcKind, P}; +use crate::{Error, Frame, MAX_PATH_OPS}; + +const PLACEABLE_KEY: u32 = 0x9AC6_CDD7; +const DEFAULT_INCH: f64 = 1440.0; + +pub(crate) fn is_wmf(b: Bytes) -> bool { + if b.u32(0) == Some(PLACEABLE_KEY) { + return true; + } + matches!(b.u16(0), Some(1 | 2)) && b.u16(2) == Some(9) && matches!(b.u16(4), Some(0x100 | 0x300)) +} + +/// Reads all records. Returns the drawing state and the picture frame, or an error when no record +/// could be read at all. +pub(crate) fn parse(input: Bytes) -> Result<(Canvas, Option), Error> { + let placeable = input.u32(0) == Some(PLACEABLE_KEY); + // Placeable bounds: logical corners (left, top, right, bottom) and their size in points. + let (hdr, bounds) = if placeable { + let [l, t, r, b] = [6, 8, 10, 12].map(|o| input.i16(o).map(f64::from)); + let (Some(l), Some(t), Some(r), Some(b)) = (l, t, r, b) else { return Err(Error::BadHeader) }; + let inch = input.u16(14).filter(|v| *v > 0).map_or(DEFAULT_INCH, f64::from); + let (w, h) = (r - l, b - t); + let bounds = (w > 0.0 && h > 0.0).then_some(([l, t, r, b], (w * 72.0 / inch, h * 72.0 / inch))); + (22, bounds) + } else { + (0, None) + }; + let hsize = input.u16(hdr + 2).ok_or(Error::BadHeader)? as usize; + if hsize < 9 { + return Err(Error::BadHeader); + } + let mut pos = hdr.checked_add(hsize.checked_mul(2).ok_or(Error::BadHeader)?).ok_or(Error::BadHeader)?; + // Played into a target like Word does: the placeable bounds are the first window, the target (the + // picture size) the viewport. A plain WMF's target is its first window. + let mut cv = Canvas::new(); + if let Some((c, size)) = bounds { + cv.play_into(Some([c[0], c[1], c[2] - c[0], c[3] - c[1]]), size); + } + let mut player_viewport = bounds.is_some(); + let mut parsed = 0usize; + while !cv.full() { + let (Some(words), Some(func)) = (input.u32(pos), pos.checked_add(4).and_then(|o| input.u16(o))) else { break }; + let Some(len) = (words as usize).checked_mul(2).filter(|l| *l >= 6) else { break }; + let Some(rec) = input.slice(pos, len) else { break }; + if func == 0 { + break; + } + record(&mut cv, func, Bytes(rec)); + if func == 0x020C && cv.window_set && !player_viewport && !cv.viewport_set { + let we = cv.map.window_ext(); + cv.play_into(None, (we.0.abs(), we.1.abs())); + player_viewport = true; + } + parsed += 1; + let Some(next) = pos.checked_add(len) else { break }; + pos = next; + } + if parsed == 0 { + return Err(Error::NoRecords); + } + let frame = match bounds { + // Played into its target: the frame is the target. + Some((_, size)) if !cv.viewport_set && cv.map.uses_extents() => Some(Frame { rect: [0.0, 0.0, size.0, size.1], size: Some(size) }), + // The file set its own viewport, so the drawing is in its device units and the logical bounds + // go through the final mapping. + Some((c, size)) => { + let (a, b) = (cv.map.device((c[0], c[1])), cv.map.device((c[2], c[3]))); + let rect = [a.0.min(b.0), a.1.min(b.1), (b.0 - a.0).abs(), (b.1 - a.1).abs()]; + (rect[2] > 0.0 && rect[3] > 0.0).then_some(Frame { rect, size: Some(size) }) + } + None if player_viewport && !cv.viewport_set && cv.map.uses_extents() => { + let ve = cv.map.viewport_ext(); + Some(Frame { rect: [0.0, 0.0, ve.0, ve.1], size: None }) + } + None if cv.window_set => Some(Frame { rect: cv.map.window_rect(), size: None }), + None => None, + }; + Ok((cv, frame)) +} + +fn record(cv: &mut Canvas, func: u16, r: Bytes) { + if cv.full() { + return; + } + let _ = match func { + 0x0103 => r.u16(6).map(|m| cv.set_map_mode(u32::from(m))), + 0x0106 => r.u16(6).map(|m| cv.set_polyfill(u32::from(m))), + 0x0104 => r.u16(6).map(|m| cv.set_rop2(u32::from(m))), + // Y is stored before X in these records. + 0x020B => xy16(r, 6).map(|p| cv.set_window_org(p)), + 0x020C => xy16(r, 6).map(|p| cv.set_window_ext(p)), + 0x020D => xy16(r, 6).map(|p| cv.set_viewport_org(p)), + 0x020E => xy16(r, 6).map(|p| cv.set_viewport_ext(p)), + 0x001E => { + cv.save_dc(); + Some(()) + } + 0x0127 => r.i16(6).map(|n| cv.restore_dc(i32::from(n))), + 0x0214 => xy16(r, 6).map(|p| cv.move_to(p)), + 0x0213 => xy16(r, 6).map(|p| cv.line_to(p)), + 0x0325 => counted_points(r).map(|pts| cv.poly(&pts, false, false, true)), + 0x0324 => counted_points(r).map(|pts| cv.poly(&pts, true, true, true)), + 0x0538 => polypolygon(r).map(|ops| cv.shape(&ops, true, true)), + 0x041B => rect16(r, 6).map(|[l, t, rr, b]| cv.shape(&geom::rect(l, t, rr, b), true, true)), + 0x061C => { + let [ew, eh] = [r.i16(8), r.i16(6)]; + let (Some(ew), Some(eh)) = (ew, eh) else { return }; + rect16(r, 10).map(|[l, t, rr, b]| { + cv.shape(&geom::round_rect(l, t, rr, b, f64::from(ew), f64::from(eh)), true, true); + }) + } + 0x0418 => rect16(r, 6).map(|[l, t, rr, b]| cv.shape(&geom::ellipse(l, t, rr, b), true, true)), + 0x0817 | 0x081A | 0x0830 => arc16(r).map(|(bounds, s, e)| { + let kind = match func { + 0x0817 => ArcKind::Arc, + 0x081A => ArcKind::Pie, + _ => ArcKind::Chord, + }; + let [l, t, rr, b] = bounds; + cv.shape(&geom::arc_shape(l, t, rr, b, s, e, kind), kind != ArcKind::Arc, true); + }), + 0x02FA => create_pen(cv, r), + 0x02FC => create_brush(cv, r), + // Fonts, palettes, pattern brushes and regions take a handle slot too, so later SELECTOBJECT + // handles count them; selecting one draws nothing. + 0x02FB | 0x00F7 | 0x01F9 | 0x0142 | 0x06FF => { + cv.add_object(None, Obj::Other); + Some(()) + } + 0x012D => r.u16(6).map(|h| cv.select(wmf_handle(h))), + 0x01F0 => r.u16(6).map(|h| cv.delete_object(u32::from(h))), + // STRETCHDIB: rop(4) usage(2) srcH srcW ySrc xSrc destH destW yDest xDest, then the DIB. + 0x0F43 => { + let rop = r.u32(6).unwrap_or(0); + dib_blit(cv, r, 28, rop, [i16s(r, 26), i16s(r, 24), i16s(r, 22), i16s(r, 20)], [i16s(r, 18), i16s(r, 16), i16s(r, 14), i16s(r, 12)]) + } + 0x0B41 => dib_stretchblt(cv, r), + 0x0940 => dib_bitblt(cv, r), + _ => Some(()), + }; +} + +/// A 16-bit WMF handle as a canvas object index: stock objects are `0x8000 | index`, which the +/// canvas reads with the 32-bit stock flag. +fn wmf_handle(h: u16) -> u32 { + let h = u32::from(h); + if h & 0x8000 != 0 { (h & 0x7FFF) | 0x8000_0000 } else { h } +} + +/// An i16 field as f64; 0 past the end. +fn i16s(r: Bytes, off: usize) -> f64 { + r.i16(off).map_or(0.0, f64::from) +} + +/// CREATEPENINDIRECT: style, width (x, y), colour. Only the width's x component is used. +fn create_pen(cv: &mut Canvas, r: Bytes) -> Option<()> { + let style = r.u16(6)?; + let width = r.i16(8)?; + let color = r.u32(12)?; + cv.add_object(None, Obj::Pen(Pen { style: u32::from(style), width: f64::from(width), color: colorref(color) })); + Some(()) +} + +/// CREATEBRUSHINDIRECT: style, colour, hatch (hatch is not drawn). +fn create_brush(cv: &mut Canvas, r: Bytes) -> Option<()> { + let style = r.u16(6)?; + let color = r.u32(8)?; + cv.add_object(None, Obj::Brush(Brush { style: u32::from(style), color: colorref(color) })); + Some(()) +} + +/// (x, y) from a Y-then-X pair stored at `off`. +fn xy16(r: Bytes, off: usize) -> Option

{ + Some((f64::from(r.i16(off + 2)?), f64::from(r.i16(off)?))) +} + +/// Rectangle stored as bottom, right, top, left (the order MS-WMF uses for RECTANGLE and ELLIPSE). +fn rect16(r: Bytes, off: usize) -> Option<[f64; 4]> { + let (b, rr, t, l) = (r.i16(off)?, r.i16(off + 2)?, r.i16(off + 4)?, r.i16(off + 6)?); + Some([f64::from(l), f64::from(t), f64::from(rr), f64::from(b)]) +} + +/// ARC, PIE and CHORD: end Y, end X, start Y, start X, then the bounding rectangle (bottom, right, top, left). +fn arc16(r: Bytes) -> Option<([f64; 4], P, P)> { + let end = xy16(r, 6)?; + let start = xy16(r, 10)?; + let bounds = rect16(r, 14)?; + Some((bounds, start, end)) +} + +/// A count of 16-bit points, then the points (POLYLINE, POLYGON). +fn counted_points(r: Bytes) -> Option> { + let n = r.u16(6)? as usize; + r.tail(8)?.points(0, n, false) +} + +/// POLYPOLYGON: polygon count, one 16-bit point count per polygon, then all points. +fn polypolygon(r: Bytes) -> Option> { + let n = r.u16(6)? as usize; + let mut counts = Vec::with_capacity(n); + for i in 0..n { + counts.push(r.u16(i.checked_mul(2)?.checked_add(8)?)? as usize); + } + let total = counts.iter().try_fold(0usize, |a, c| a.checked_add(*c))?; + if total > MAX_PATH_OPS { + return None; + } + let pts = r.tail(n.checked_mul(2)?.checked_add(8)?)?.points(0, total, false)?; + Some(geom::subpaths(&pts, &counts, true)) +} + +/// Whether a DIBBITBLT or DIBSTRETCHBLT record carries a bitmap: without one it is exactly +/// `(function >> 8) + 3` words long (MS-WMF 2.3.1.2, 2.3.1.3). +fn has_bitmap(func: u16, r: Bytes) -> bool { + r.0.len() / 2 > usize::from(func >> 8) + 3 +} + +/// META_DIBBITBLT: rop(4), ySrc, xSrc, then height, width, yDest, xDest and the DIB at 22. The +/// bitmap-less form has a reserved word before the height, so its fields sit 2 bytes later; it, or +/// a source-less raster operation, fills the destination. +fn dib_bitblt(cv: &mut Canvas, r: Bytes) -> Option<()> { + let rop = r.u32(6)?; + if !has_bitmap(0x0940, r) { + cv.fill_rop([i16s(r, 22), i16s(r, 20), i16s(r, 18), i16s(r, 16)], rop); + return Some(()); + } + let dst = [i16s(r, 20), i16s(r, 18), i16s(r, 16), i16s(r, 14)]; + if Canvas::sourceless(rop) { + cv.fill_rop(dst, rop); + return Some(()); + } + let src = [i16s(r, 12), i16s(r, 10), dst[2], dst[3]]; + dib_blit(cv, r, 22, rop, dst, src) +} + +/// META_DIBSTRETCHBLT: rop(4), srcH, srcW, ySrc, xSrc, then destH, destW, yDest, xDest and the DIB at +/// 26. The bitmap-less form has a reserved word after xSrc, so the destination sits 2 bytes later; +/// it, or a source-less raster operation, fills the destination. +fn dib_stretchblt(cv: &mut Canvas, r: Bytes) -> Option<()> { + let rop = r.u32(6)?; + if !has_bitmap(0x0B41, r) { + cv.fill_rop([i16s(r, 26), i16s(r, 24), i16s(r, 22), i16s(r, 20)], rop); + return Some(()); + } + let dst = [i16s(r, 24), i16s(r, 22), i16s(r, 20), i16s(r, 18)]; + if Canvas::sourceless(rop) { + cv.fill_rop(dst, rop); + return Some(()); + } + let src = [i16s(r, 16), i16s(r, 14), i16s(r, 12), i16s(r, 10)]; + dib_blit(cv, r, 26, rop, dst, src) +} + +/// A DIB at `at`, stretched from source rectangle `src` into `dst` (both in logical units, pixels for `src`). +/// The raster operation `rop` decides how the pixels combine (see [`dib::prepare`]). +fn dib_blit(cv: &mut Canvas, r: Bytes, at: usize, rop: u32, dst: [f64; 4], src: [f64; 4]) -> Option<()> { + let dib = r.tail(at)?; + let n = dib::info_len(dib)?; + let info = dib.slice(0, n)?; + let bits = dib.tail(n)?; + let mut img = dib::decode(Bytes(info), bits, false)?; + dib::prepare(&mut img, dib::Blend::Rop(rop)); + let (img, dst) = dib::clip_blit(&img, src.map(|v| v as i64), dst)?; + cv.bitmap(dst, img); + Some(()) +} diff --git a/crates/render/Cargo.toml b/crates/render/Cargo.toml index ccb98ea..6b66288 100644 --- a/crates/render/Cargo.toml +++ b/crates/render/Cargo.toml @@ -11,6 +11,7 @@ workspace = true [dependencies] deckcraft-geom = { workspace = true } deckcraft-color = { workspace = true } +deckcraft-metafile = { workspace = true } deckcraft-model = { workspace = true } deckcraft-fonts = { workspace = true } deckcraft-text = { workspace = true } diff --git a/crates/render/src/images.rs b/crates/render/src/images.rs index 09e1baa..3071e94 100644 --- a/crates/render/src/images.rs +++ b/crates/render/src/images.rs @@ -99,11 +99,20 @@ fn duotone_rgb(a: &PictureAdjust) -> Option<(deckcraft_color::Rgba, deckcraft_co Some((rgb(d, deckcraft_color::Rgba::BLACK), rgb(l, deckcraft_color::Rgba::WHITE))) } -/// Decode encoded bytes (PNG, JPEG, GIF, WebP, BMP, TIFF) to straight RGBA. +/// Decode encoded bytes (PNG, JPEG, GIF, WebP, BMP, TIFF; WMF and EMF drawn at +/// [`crate::metafile::DEFAULT_SIDE`]) to straight RGBA. pub fn decode(bytes: &[u8]) -> Option { + decode_at(bytes, crate::metafile::DEFAULT_SIDE) +} + +/// [`decode`], with a metafile drawn with its longer side `side` px. +fn decode_at(bytes: &[u8], side: u32) -> Option { if bytes.len() > 512 << 20 { return None; } + if crate::metafile::is_metafile(bytes) { + return crate::metafile::rasterize(bytes, side); + } let img = image::load_from_memory(bytes).ok()?; let (w, h) = (img.width(), img.height()); if w == 0 || h == 0 || (w as u64) * (h as u64) > 400_000_000 { @@ -131,13 +140,13 @@ pub fn decode_for(bytes: &Arc>, want: f64, adj: &PictureAdjust) -> Optio }) { return Some(pm); } - let Some(mut img) = decode(bytes) else { + let target = 1u32 << bucket; + let Some(mut img) = decode_at(bytes, target) else { with(|c| { c.bad.insert(addr, bytes.clone()); }); return None; }; - let target = 1u32 << bucket; let (w, h) = img.dimensions(); if w.max(h) > target.max(16) && w.max(h) > 4096.min(target * 2) { let k = target as f64 / w.max(h) as f64; diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 4e8058a..bc457b6 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -12,6 +12,7 @@ mod colr; mod compound; mod images; +mod metafile; mod morph_path; mod morph_text; mod paint; diff --git a/crates/render/src/metafile.rs b/crates/render/src/metafile.rs new file mode 100644 index 0000000..8afcfb2 --- /dev/null +++ b/crates/render/src/metafile.rs @@ -0,0 +1,105 @@ +//! Windows Metafile and Enhanced Metafile pictures (`image/x-wmf`, `image/x-emf`): parsed by +//! `deckcraft-metafile` and drawn as vectors into a bitmap of the size the slide needs, so they go +//! through the same crop, tile and recolour path as any other picture. + +use std::sync::Arc; + +use deckcraft_metafile::PlacedItem; +use kurbo::{Affine, Rect, Shape as _}; +use vello_cpu::{Pixmap, RenderContext, Resources, peniko}; + +/// Longest side of a metafile drawn without a target size (export, thumbnails of the picture). +pub const DEFAULT_SIDE: u32 = 1024; +/// Largest side drawn. +const MAX_SIDE: u32 = 8192; + +/// Is `bytes` a WMF or EMF file? +pub fn is_metafile(bytes: &[u8]) -> bool { + deckcraft_metafile::is_metafile(bytes) +} + +/// Draw the metafile `bytes` into straight RGBA with its longer side `side` px (transparent where +/// it draws nothing). `None` when it doesn't parse or has no size. +pub fn rasterize(bytes: &[u8], side: u32) -> Option { + let mut pic = deckcraft_metafile::parse(bytes).ok()?; + if !pic.has_size() { + return None; + } + let side = side.clamp(16, MAX_SIDE) as f64; + let (pw, ph) = (pic.width as f64, pic.height as f64); + let k = side / pw.max(ph); + let w = (pw * k).round().clamp(1.0, MAX_SIDE as f64) as u16; + let h = (ph * k).round().clamp(1.0, MAX_SIDE as f64) as u16; + let pixmaps: Vec>> = pic.items.iter_mut().map(|it| it.take_pixels().and_then(|(w, h, px)| premul(w, h, &px))).collect(); + let mut ctx = RenderContext::new_with(w, h, vello_cpu::RenderSettings { num_threads: 0, ..Default::default() }); + let r = Rect::new(0.0, 0.0, w as f64, h as f64); + ctx.push_clip_layer(&r.to_path(0.1)); + for it in pic.place(r, [0.0; 4]) { + match it { + PlacedItem::Path { path, fill, stroke, even_odd } => { + if let Some(f) = fill { + ctx.set_paint(rgba(f)); + ctx.set_fill_rule(if even_odd { peniko::Fill::EvenOdd } else { peniko::Fill::NonZero }); + ctx.fill_path(&path); + } + // GDI pens: round caps and joins; a cosmetic (zero-width) pen is one pixel. + if let Some((s, sw)) = stroke { + ctx.set_paint(rgba(s)); + ctx.set_stroke(kurbo::Stroke::new(sw.max(1.0)).with_caps(kurbo::Cap::Round).with_join(kurbo::Join::Round)); + ctx.stroke_path(&path); + } + } + PlacedItem::Bitmap { index, rect } => { + let Some(Some(pm)) = pixmaps.get(index) else { continue }; + let (bw, bh) = (pm.width().max(1) as f64, pm.height().max(1) as f64); + ctx.set_paint(vello_cpu::Image { + image: vello_cpu::ImageSource::Pixmap(pm.clone()), + sampler: peniko::ImageSampler::default().with_quality(peniko::ImageQuality::Medium), + }); + ctx.set_paint_transform(Affine::translate((rect.x0, rect.y0)) * Affine::scale_non_uniform(rect.width() / bw, rect.height() / bh)); + ctx.fill_rect(&rect); + ctx.reset_paint_transform(); + } + } + } + ctx.set_fill_rule(peniko::Fill::NonZero); + ctx.pop_layer(); + ctx.flush(); + let mut px = vec![0u8; w as usize * h as usize * 4]; + if let Some(pm) = vello_cpu::PixmapMut::new(w, h, &mut px) { + ctx.render(pm, &mut Resources::new()); + } + // Premultiplied to straight. + for p in px.as_chunks_mut::<4>().0 { + let a = p[3] as u32; + if a != 0 && a != 255 { + for c in p.iter_mut().take(3) { + *c = ((*c as u32 * 255 + a / 2) / a).min(255) as u8; + } + } + } + image::RgbaImage::from_raw(w as u32, h as u32, px) +} + +fn rgba(c: [u8; 4]) -> peniko::Color { + peniko::Color::from_rgba8(c[0], c[1], c[2], c[3]) +} + +/// Straight RGBA rows to a premultiplied pixmap. +fn premul(w: u32, h: u32, px: &[u8]) -> Option> { + if w == 0 || h == 0 || w > u16::MAX as u32 || h > u16::MAX as u32 || px.len() < w as usize * h as usize * 4 { + return None; + } + let data = px + .as_chunks::<4>() + .0 + .iter() + .take(w as usize * h as usize) + .map(|p| { + let a = p[3] as u16; + let m = |c: u8| ((c as u16 * a + 127) / 255) as u8; + vello_cpu::color::PremulRgba8 { r: m(p[0]), g: m(p[1]), b: m(p[2]), a: p[3] } + }) + .collect(); + Some(Arc::new(Pixmap::from_parts(data, w as u16, h as u16))) +} diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index adcc449..3ee10e6 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -1075,3 +1075,73 @@ fn every_warp_preset_renders_text_inside_the_slide() { assert!(x0 <= x1 && y0 <= y1, "{name}: no ink"); } } + +/// A placeable WMF, 2 × 1 inch (2880 × 1440 twips): a solid red brush and a rectangle over the +/// left half, nothing on the right. +fn half_red_wmf() -> Vec { + fn rec(func: u16, params: &[i16]) -> Vec { + let mut v = ((3 + params.len()) as u32).to_le_bytes().to_vec(); + v.extend(func.to_le_bytes()); + for p in params { + v.extend(p.to_le_bytes()); + } + v + } + // CREATEBRUSHINDIRECT (solid, COLORREF 0x000000FF = red), SELECTOBJECT 0, RECTANGLE (bottom, + // right, top, left). + let body = [rec(0x02FC, &[0, 0xFF, 0, 0]), rec(0x012D, &[0]), rec(0x041B, &[1440, 1440, 0, 0]), rec(0, &[])].concat(); + let mut f = 0x9AC6_CDD7u32.to_le_bytes().to_vec(); + f.extend(0u16.to_le_bytes()); + for v in [0i16, 0, 2880, 1440] { + f.extend(v.to_le_bytes()); + } + f.extend(1440u16.to_le_bytes()); + f.extend(0u32.to_le_bytes()); + f.extend(0u16.to_le_bytes()); + let total = (f.len() + 18 + body.len()) / 2; + for v in [1u16, 9, 0x300] { + f.extend(v.to_le_bytes()); + } + f.extend((total as u32).to_le_bytes()); + f.extend(16u16.to_le_bytes()); + f.extend(0u32.to_le_bytes()); + f.extend(0u16.to_le_bytes()); + f.extend(body); + f +} + +#[test] +fn metafile_pictures_draw_their_vectors() { + let mut p = Presentation::default(); + let media = p.add_media("image1.wmf", "image/x-wmf", half_red_wmf()); + let fill = deckcraft_model::style::PictureFill { media, ..Default::default() }; + let sh = Shape { id: ShapeId(600), xfrm: Some(Xfrm::new(100.0, 100.0, 400.0, 200.0)), kind: ShapeKind::Picture { fill }, ..Default::default() }; + std::sync::Arc::make_mut(&mut p.slides[0]).shapes = vec![sh]; + for scale in [0.5, 1.0, 3.0] { + let img = render_slide(&p, 0, &RenderOpts { scale, ..Default::default() }); + let at = |x: f64, y: f64| img.pixel((x * scale) as u32, (y * scale) as u32); + let left = at(200.0, 200.0); + assert!(left[0] > 200 && left[1] < 60 && left[2] < 60, "left half red at {scale}: {left:?}"); + let right = at(400.0, 200.0); + assert!(right[0] > 200 && right[1] > 200 && right[2] > 200, "right half empty at {scale}: {right:?}"); + } +} + +#[test] +fn broken_metafiles_draw_nothing_and_never_panic() { + let wmf = half_red_wmf(); + let mut cases: Vec> = (0..wmf.len()).step_by(3).map(|n| wmf[..n].to_vec()).collect(); + cases.push(vec![1, 0, 0, 0, 0xFF, 0xFF, 0xFF, 0xFF]); + let mut huge = wmf.clone(); + huge[10] = 0xFF; + huge[11] = 0x7F; + cases.push(huge); + for bytes in cases { + let mut p = Presentation::default(); + let media = p.add_media("image1.emf", "image/x-emf", bytes); + let fill = deckcraft_model::style::PictureFill { media, ..Default::default() }; + let sh = Shape { id: ShapeId(600), xfrm: Some(Xfrm::new(0.0, 0.0, 400.0, 200.0)), kind: ShapeKind::Picture { fill }, ..Default::default() }; + std::sync::Arc::make_mut(&mut p.slides[0]).shapes = vec![sh]; + let _ = render_slide(&p, 0, &RenderOpts { scale: 0.25, ..Default::default() }); + } +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 2bf1d28..519a118 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -38,7 +38,7 @@ PowerPoint opens and saves; Viewer = opens only). |---|---|---|---| | PNG, JPEG, GIF (animated), BMP, TIFF, WebP | yes | yes | `image` 0.25; animated GIFs play since #39 | | SVG | yes (with PNG fallback) | **no** | The PNG fallback shows; SVG files can't be inserted | -| EMF / WMF | yes | **no** | Common in decks made on Windows: blank picture | +| EMF / WMF | yes | partial | Drawn as vectors (paths, pens, bitmaps); text records inside are skipped | | HEIC | yes (macOS) | no | | | Audio: MP3, AAC/M4A, ALAC, WAV, AIFF, CAF, FLAC, Ogg Vorbis, Opus | yes (Ogg/Opus partly) | yes | Pure-Rust decoders in `crates/media`, `crates/opus` | | WMA | yes (Windows) | embedded, not playable | | @@ -93,7 +93,7 @@ the round trip even though DeckCraft doesn't model it. embedded fonts, every transition and animation) in a corpus repo, with import → render → export → re-import checks in CI and a PowerPoint reference render for each (owner runs PowerPoint). 2. The round-trip losses above (#70, #74, #77). -3. EMF/WMF and SVG rendering. +3. SVG rendering, and text inside EMF/WMF. ## Revision history diff --git a/docs/gaps.md b/docs/gaps.md index a13383a..4ab640a 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -20,7 +20,7 @@ revision history. | 1 | PPTX never tested against a corpus of real decks | File format | B | 20–35 | [file-format-parity.md](file-format-parity.md) | | 2 | Data loss and crashes on common paths (#60, #40, #59) | Stability | B | 6–12 | this file | | 3 | PPTX round trip drops comment threads, media options, rewind (#70, #74, #77) | File format | B | 3–6 | [file-format-parity.md](file-format-parity.md) | -| 4 | EMF/WMF and SVG pictures don't render | File format | B | 8–14 | [file-format-parity.md](file-format-parity.md) | +| 4 | SVG pictures don't render (EMF/WMF do) | File format | B | 8–14 | [file-format-parity.md](file-format-parity.md) | | 5 | No localization: English only | Localization | B (infra + 5 languages) | 55–90 | [localization-parity.md](localization-parity.md) | | 6 | No printing | Feature | B | 5–8 | [file-format-parity.md](file-format-parity.md) | | 7 | Fonts: only Regular, Bold and Italic faces (#69) | Feature | B | 3–5 | [ui-parity.md](ui-parity.md) | @@ -75,14 +75,14 @@ Detail in [file-format-parity.md](file-format-parity.md). - **Impact:** a deck opened and saved silently loses review state and show behaviour. - **Estimate:** 3–6 h. **Beta blocker.** -### EMF/WMF and SVG pictures don't render +### SVG pictures don't render - **Evidence:** `crates/render/src/images.rs` decodes through `image` 0.25 (PNG, JPEG, WebP, GIF, - TIFF, BMP); nothing reads EMF, WMF or SVG. Decks from Windows often hold EMF (pasted charts, - Visio, logos); PowerPoint's Insert Icons stores SVG with a PNG fallback. -- **Impact:** blank pictures in real decks; SVG icons show only their raster fallback, and SVG files - can't be inserted. -- **Estimate:** 8–14 h (an SVG renderer through `resvg`-style crates, an EMF/WMF record player for - the common records). **Beta blocker** for EMF. + TIFF, BMP), and EMF/WMF through `deckcraft-metafile` (ported from WordCraft: paths, fills, pens + and bitmaps; text records are skipped). Nothing reads SVG; PowerPoint's Insert Icons stores SVG + with a PNG fallback. +- **Impact:** SVG icons show only their raster fallback, and SVG files can't be inserted. Text + drawn inside an EMF/WMF is missing. +- **Estimate:** 4–8 h (an SVG renderer through `resvg`-style crates); EMF text records 2–4 h. ### Template, slide-show and macro variants are saved as plain `.pptx` - **Evidence:** `crates/pptx/src/write/mod.rs` always writes the diff --git a/docs/roadmap.md b/docs/roadmap.md index da27573..9795303 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -16,7 +16,7 @@ points and lead the beta plan. | Core workflow | Works end to end? | Evidence | Hours to full pass | |---|---|---|---| | Build a deck from scratch (theme, layouts, titles and bullets, shapes, pictures from file), save, close and reopen it | yes | Commands for all of it; `.deckcraft` and `.pptx` round-trip tests (`crates/pptx/tests/roundtrip.rs`, `crates/format`); AutoRecover. Close discards edits without asking (#60, PR #75); image *paste* fails on Windows/Linux (#53), though insert-from-file works | 1–3 | -| Open a colleague's `.pptx`, edit it, save it back as `.pptx` that PowerPoint opens | partial (not blocking) | The reader is lenient and keeps unknown objects. Files open in PowerPoint without repair (checked 2026-10-06). But only generated decks have been tested; EMF/WMF pictures render blank; comment threads, media options and rewind are lost (#70, #74, #77) | 31–55 | +| Open a colleague's `.pptx`, edit it, save it back as `.pptx` that PowerPoint opens | partial (not blocking) | The reader is lenient and keeps unknown objects. Files open in PowerPoint without repair (checked 2026-10-06). But only generated decks have been tested; text inside EMF/WMF pictures is missing; comment threads, media options and rewind are lost (#70, #74, #77) | 31–55 | | Present: full-screen show with transitions, animations, media, presenter view with notes, next slide and timer | yes | 48/48 transitions, 64 effects, triggers, builds, media playback, presenter window. Presenter view isn't placed on the second display automatically (drag it there) | 3–5 | | Add business content: tables, charts with their data, simple diagrams | partial (not blocking) | Tables with styles, merge/split, all D or P. Charts insert with the common types and edit data in the Chart Data dialog (P0 partial); SmartArt is basic (pre-drawn shapes) | 12–20 | | Share the deck: PDF of slides, notes or handouts; images | yes | `file.export` PDF with text layer, links, bookmarks, handouts 1–9; PNG/JPEG. Artwork is raster in the PDF; no direct print (print the PDF) | 10–16 | diff --git a/xtask/src/layers.rs b/xtask/src/layers.rs index eb0227d..b9bb409 100644 --- a/xtask/src/layers.rs +++ b/xtask/src/layers.rs @@ -47,6 +47,8 @@ pub const TABLE: &[(&str, Class)] = &[ ("isobmff", Class::Layer(1)), ("media", Class::Layer(2)), ("color", Class::Layer(0)), + // WMF/EMF reader (ported from WordCraft). + ("metafile", Class::Layer(0)), ("model", Class::Layer(1)), ("fonts", Class::Layer(1)), ("text", Class::Layer(2)),