From 05679497ea2669c8d038ed895fb8909f2c7d321b Mon Sep 17 00:00:00 2001 From: naxvog Date: Mon, 12 Oct 2026 06:45:53 +0200 Subject: [PATCH] Pictures: WMF drawings fill their frame like Word plays them (#739) Word plays a WMF into the picture's box the way a GDI player does: the placeable header's bounds are only the first window, mapped onto a viewport of the picture's size, and the file's own SETWINDOWORG and SETWINDOWEXT records replace that window. WordCraft mapped the header's bounds through the file's final mapping instead, which broke three pictures in docxide-pdf's fixtures: - bulgarian_farmland_allocation_order: the coat of arms draws in a window centred on the origin, nowhere near the header's bounds, so the lion was blown up into a solid black box. - ukrainian_municipal_heating_resolution: a portrait window replaced the landscape bounds, so the emblem was drawn at a fifth of its size. - A plain WMF in anisotropic mode that sets a window but no viewport kept the 1 x 1 default viewport and shrank to a point; its first window is now its target. Files that set their own viewport, or use the fixed-scale map modes, are read as before. SETROP2 is now read too: with R2_NOP, shapes and paths draw nothing (the coat of arms first draws its bounding rectangle that way, which showed as an outline). The mode is saved and restored with the DC. Co-authored-by: Naxvog <4963183+naxvog@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- crates/metafile/src/canvas.rs | 53 ++++++++++++++++++++- crates/metafile/src/emf.rs | 1 + crates/metafile/src/tests.rs | 89 +++++++++++++++++++++++++++++++++++ crates/metafile/src/wmf.rs | 21 ++++++++- 4 files changed, 162 insertions(+), 2 deletions(-) diff --git a/crates/metafile/src/canvas.rs b/crates/metafile/src/canvas.rs index d553c81..fa8bfdc 100644 --- a/crates/metafile/src/canvas.rs +++ b/crates/metafile/src/canvas.rs @@ -8,6 +8,7 @@ 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. @@ -15,6 +16,8 @@ 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; @@ -135,6 +138,19 @@ impl Mapping { 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] { @@ -160,6 +176,7 @@ struct Saved { pen: Pen, brush: Brush, even_odd: bool, + nop: bool, } pub(crate) struct Canvas { @@ -167,6 +184,8 @@ pub(crate) struct Canvas { 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, @@ -174,6 +193,8 @@ pub(crate) struct Canvas { 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, @@ -186,12 +207,14 @@ impl 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(), @@ -232,6 +255,22 @@ impl Canvas { 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) { @@ -249,6 +288,11 @@ impl Canvas { 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) { @@ -265,7 +309,7 @@ impl Canvas { /// 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 }); + 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; } @@ -281,6 +325,7 @@ impl Canvas { 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). @@ -381,6 +426,9 @@ impl Canvas { 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 { @@ -460,6 +508,9 @@ impl Canvas { /// 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()) { diff --git a/crates/metafile/src/emf.rs b/crates/metafile/src/emf.rs index 6f32486..027cfae 100644 --- a/crates/metafile/src/emf.rs +++ b/crates/metafile/src/emf.rs @@ -88,6 +88,7 @@ fn record(cv: &mut Canvas, typ: u32, r: Bytes) { 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(()) diff --git a/crates/metafile/src/tests.rs b/crates/metafile/src/tests.rs index 9fab9be..4ee3257 100644 --- a/crates/metafile/src/tests.rs +++ b/crates/metafile/src/tests.rs @@ -927,3 +927,92 @@ fn take_pixels_returns_only_drawable_bitmaps() { 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 index e051471..d221a76 100644 --- a/crates/metafile/src/wmf.rs +++ b/crates/metafile/src/wmf.rs @@ -37,7 +37,13 @@ pub(crate) fn parse(input: Bytes) -> Result<(Canvas, Option), Error> { 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 }; @@ -47,6 +53,11 @@ pub(crate) fn parse(input: Bytes) -> Result<(Canvas, Option), Error> { 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; @@ -55,12 +66,19 @@ pub(crate) fn parse(input: Bytes) -> Result<(Canvas, Option), Error> { return Err(Error::NoRecords); } let frame = match bounds { - // The drawing is in device units, so the logical bounds go through the final mapping. + // 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, }; @@ -74,6 +92,7 @@ fn record(cv: &mut Canvas, func: u16, r: Bytes) { 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)),