From fea8c1498f60d209c77c8bb466511bb356cb7a90 Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Wed, 14 Oct 2026 02:52:57 +0300 Subject: [PATCH] Metafile: EMF+ Clear, and the EMF+ record coverage written down EmfPlusClear paints the whole picture frame in its colour, ignoring the world transform and clip, as GDI+ does; a transparent clear draws nothing. With it every drawing record of MS-EMFPLUS 2.3.4 is played. The module docs list what is deliberately ignored (rendering hints, rendering origin, serializable image effects, terminal-server records, and StrokeFillPath, which the specification names but gives no layout for), and the parity docs no longer say EMF+ isn't drawn. Co-authored-by: Cursor --- crates/metafile/src/emf.rs | 1 + crates/metafile/src/emfplus.rs | 40 ++++++++++++++++++++++++++++++---- crates/metafile/src/tests.rs | 39 ++++++++++++++++++++++++++++----- docs/file-format-parity.md | 2 +- docs/gaps.md | 3 ++- 5 files changed, 74 insertions(+), 11 deletions(-) diff --git a/crates/metafile/src/emf.rs b/crates/metafile/src/emf.rs index 21ad9b62..6190237e 100644 --- a/crates/metafile/src/emf.rs +++ b/crates/metafile/src/emf.rs @@ -43,6 +43,7 @@ pub(crate) fn parse(input: Bytes) -> Result<(Canvas, Option), Error> { cv.set_device_mm(px_mm); } let mut plus = crate::emfplus::Plus::new(); + plus.frame = frame.as_ref().map(|f| f.rect); let mut parsed = 0usize; let mut pos = hsize.max(88); while !cv.full() { diff --git a/crates/metafile/src/emfplus.rs b/crates/metafile/src/emfplus.rs index 064ce609..ce10447e 100644 --- a/crates/metafile/src/emfplus.rs +++ b/crates/metafile/src/emfplus.rs @@ -11,10 +11,17 @@ //! - world and page transforms, save/restore and containers; //! - arcs and pies; //! - clipping to rectangles and convex clip paths and regions (others: their bounding rectangle); -//! - nested metafile images, and glyph-index driver strings in the Windows core fonts. +//! - nested metafile images, and glyph-index driver strings in the Windows core fonts; +//! - cardinal splines (DrawCurve, DrawClosedCurve, FillClosedCurve), FillRegion, and Clear +//! (over the picture frame). //! -//! Not drawn yet: image attributes, glyph indices of other fonts, and region combinations other -//! than intersect and union (XOR, exclude and complement keep the left area). +//! Every drawing record of MS-EMFPLUS §2.3.4 is played. Not drawn: image attributes, glyph +//! indices of other fonts, and region combinations other than intersect and union (XOR, exclude +//! and complement keep the left area). Deliberately ignored: rendering hints (antialias, text +//! hint, interpolation, pixel offset, compositing; the renderer has its own), SetRenderingOrigin +//! (hatch phase), SerializableObject image effects, the terminal-server records SetTSGraphics and +//! SetTSClip, and StrokeFillPath (0x4037), which the specification lists but gives no layout for +//! and GDI+ doesn't write. use std::sync::Arc; @@ -217,11 +224,23 @@ pub(crate) struct Plus { objects: Vec>, stack: Vec, partial: Option, + /// The picture frame in device units (`[x, y, w, h]`), what Clear paints. + pub frame: Option<[f64; 4]>, } impl Plus { pub fn new() -> Self { - Plus { active: false, dpi: 96.0, world: IDENTITY, page: 1.0, clip: None, objects: vec![None; MAX_OBJECTS], stack: Vec::new(), partial: None } + Plus { + active: false, + dpi: 96.0, + world: IDENTITY, + page: 1.0, + clip: None, + objects: vec![None; MAX_OBJECTS], + stack: Vec::new(), + partial: None, + frame: None, + } } /// Plays the EMF+ records of one EMR_COMMENT payload (after the identifier). @@ -351,6 +370,19 @@ impl Plus { self.painter().stroke(cv, &curve_ops(&pts, tension, false, offset, segments), pen); Some(()) } + // Clear: the whole picture in one colour, whatever the clip (a transparent clear + // leaves nothing to draw). + 0x4009 => { + let c = argb(r.u32(0)?); + let [x, y, w, h] = self.frame?; + if c[3] == 0 || !(w > 0.0 && h > 0.0) { + return Some(()); + } + let to_device = invert(mul(cv.world(), cv.device_affine()))?; + let p = Painter { xf: to_device, clip: None }; + p.fill(cv, &rect_ops([x, y, x + w, y + h]), &Fill::Solid(c)); + Some(()) + } // FillRegion: the region's area (as the clip reads it). 0x4013 => { let fill = self.brush_of(flags, r.u32(0)?)?; diff --git a/crates/metafile/src/tests.rs b/crates/metafile/src/tests.rs index 43789e3d..80d41117 100644 --- a/crates/metafile/src/tests.rs +++ b/crates/metafile/src/tests.rs @@ -1863,7 +1863,9 @@ fn curves_file() -> Vec { // A green rectangle region, filled. let region = cat(vec![h32(0xDBC0_1002), h32(0), h32(0x1000_0000), f32s(&[220.0, 160.0, 180.0, 30.0])]); let fill_region = prec(0x4013, 0x8000 | 2, &h32(0xFF30_A040)); - let mut all = vec![plus_header(false), pen, fill, outline, line, prec(0x4008, 0x0400 | 2, ®ion), fill_region]; + // A cream background first. + let clear = prec(0x4009, 0, &h32(0xFFF4_EED8)); + let mut all = vec![plus_header(false), clear, pen, fill, outline, line, prec(0x4008, 0x0400 | 2, ®ion), fill_region]; all.push(prec(0x4002, 0, &[])); emf(&[plus_comment(&all)], [0, 0, 420, 200], [0; 4]) } @@ -1875,20 +1877,21 @@ fn emfplus_cardinal_curves_and_region_fill() { let _ = std::fs::write(path, &bytes); } let p = parse(&bytes).unwrap(); - assert_eq!(p.items.len(), 4, "{:?}", p.items.len()); + assert_eq!(p.items.len(), 5, "{:?}", p.items.len()); + assert_eq!(path_segs(&p.items[0]).1, Some([0xF4, 0xEE, 0xD8, 0xFF]), "the clear comes first"); // The closed curve: four cubics through the diamond's corners, closed. - let (segs, fill, ..) = path_segs(&p.items[0]); + let (segs, fill, ..) = path_segs(&p.items[1]); assert_eq!(fill, Some([0xE0, 0x80, 0x20, 0xFF])); let cubics: Vec<(f32, f32)> = segs.iter().filter_map(|s| if let Seg::Cubic(.., x, y) = s { Some((*x, *y)) } else { None }).collect(); assert_eq!(cubics.len(), 4, "{segs:?}"); assert_eq!(segs.last(), Some(&Seg::Close)); // The open curve: four cubics and no close; its controls bulge past the straight chords. - let (segs, fill, stroke, _) = path_segs(&p.items[2]); + let (segs, fill, stroke, _) = path_segs(&p.items[3]); assert_eq!((fill, stroke.map(|s| s.0)), (None, Some([0x20, 0x60, 0xC0, 0xFF]))); assert_eq!(segs.iter().filter(|s| matches!(s, Seg::Cubic(..))).count(), 4); assert!(!segs.contains(&Seg::Close)); // The region fill is its rectangle. - let (segs, fill, ..) = path_segs(&p.items[3]); + let (segs, fill, ..) = path_segs(&p.items[4]); assert_eq!(fill, Some([0x30, 0xA0, 0x40, 0xFF])); assert_eq!(segs.iter().filter(|s| matches!(s, Seg::Line(..))).count(), 3, "{segs:?}"); } @@ -1909,3 +1912,29 @@ fn emfplus_curve_offsets_and_degenerate_input_are_safe() { let p = plus_pic(vec![pen, one, past, nan, no_region]); assert!(p.items.iter().all(|i| path_segs(i).0.iter().filter(|s| matches!(s, Seg::Cubic(..))).count() == 0)); } + +#[test] +fn emfplus_clear_paints_the_whole_frame_and_transparent_clears_nothing() { + let dot = prec(0x400A, 0x8000, &cat(vec![h32(0xFF00_0000), h32(1), f32s(&[40.0, 40.0, 10.0, 10.0])])); + let clear = |argb: u32| prec(0x4009, 0, &h32(argb)); + // 100 × 50 device units (0..2500 × 0..1250 hundredths of a millimetre at 0.04 units each). + let file = |recs: Vec>| { + let mut all = vec![plus_header(false)]; + all.extend(recs); + parse(&emf(&[plus_comment(&all)], [0, 0, 99, 49], [0, 0, 2500, 1250])).unwrap() + }; + // A translated, clipped world doesn't move or cut the clear. + let shift = prec(0x402D, 0, &f32s(&[30.0, 30.0])); + let clip = prec(0x4032, 0, &f32s(&[0.0, 0.0, 5.0, 5.0])); + let p = file(vec![shift, clip, clear(0xFFFF_FFFF), prec(0x4031, 0, &[]), dot.clone()]); + assert_eq!(p.items.len(), 2); + let (segs, fill, ..) = path_segs(&p.items[0]); + assert_eq!(fill, Some([255, 255, 255, 255])); + let xs: Vec = segs.iter().filter_map(|s| if let Seg::Move(x, _) | Seg::Line(x, _) = s { Some(*x) } else { None }).collect(); + let span = xs.iter().cloned().fold(f32::MIN, f32::max) - xs.iter().cloned().fold(f32::MAX, f32::min); + assert!((span - p.width).abs() < 0.5, "clear spans {span} of {}", p.width); + // Transparent: only the dot. + assert_eq!(file(vec![clear(0x00FF_FFFF), dot.clone()]).items.len(), 1); + // No frame: nothing to clear. + assert_eq!(plus_pic(vec![clear(0xFFFF_FFFF), dot]).items.len(), 1); +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 1e888c7a..bff4d745 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 | partial | Drawn as vectors (paths, pens, pattern and hatch brushes, bitmaps, text in the document's fonts with the file's character advances); clip regions and EMF+ records are not drawn. Legacy OLE objects (VML preview) and ActiveX controls show their pictures | +| EMF / WMF | yes | partial | Drawn as vectors (paths, pens, pattern and hatch brushes, bitmaps, text in the document's fonts with the file's character advances); clip regions; EMF+ only pictures play every EMF+ drawing record (brushes incl. gradients and textures, pens, paths, curves, images, strings, transforms, clipping; see `crates/metafile/src/emfplus.rs` for the few exceptions). Legacy OLE objects (VML preview) and ActiveX controls show their pictures | | 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 | | diff --git a/docs/gaps.md b/docs/gaps.md index a2894b42..bec31d56 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -78,7 +78,8 @@ Detail in [file-format-parity.md](file-format-parity.md). ### SVG pictures don't render - **Evidence:** `crates/render/src/images.rs` decodes through `image` 0.25 (PNG, JPEG, WebP, GIF, TIFF, BMP), and EMF/WMF through `deckcraft-metafile` (ported from WordCraft: paths, fills, pens, - pattern and hatch brushes, bitmaps and text; clip regions and EMF+ records are not drawn). + pattern and hatch brushes, bitmaps and text; EMF+ only pictures play their EMF+ records; + clip regions are not drawn). 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. An EMF/WMF that clips its drawing (EXTSELECTCLIPRGN, INTERSECTCLIPRECT) draws past its clip.