From 01cbc50269a5350f0c178756e042d822ad368efc Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 11 Nov 2026 15:26:10 -0500 Subject: [PATCH] metafile: dual EMF+ files draw their EMF+ records (stacked on #843) (#845) * metafile: hold a placed pen width to what covers the frame Picture::place scaled a pen width by the fit with no upper bound, so a pen of 1e30 units made a renderer flatten round caps of that size. A placed pen is now at most twice the diagonal of the frame and the path together, which already covers the whole frame. Ported from storytold/wordcraft#5427. * metafile: one byte budget for items, EMF+ objects and clips; count clip regions when placing - Canvas::shade_shape returns when the canvas is full or the clip is empty, instead of sampling a tile that is then dropped. - EMF+ objects, the pieces of a continued object and the current and saved EMF+ clips count against the canvas's 256 MiB budget, in both directions: an object is not stored past what the canvas holds, and the canvas stops at what the objects hold. - A nested metafile held by an EMF+ image object is charged for what its items own (segments, pixels, text, clip rectangles; a shared tile or clip path once) rather than 128 bytes an item. - Picture::place charges each clip region it expands to the 500,000 element budget, for every kind of item, and expands a region once for the consecutive items that share it. Ported from storytold/deckcraft#1777, with its review comments addressed (the canvas checks include the held bytes; text and bitmap items are checked too; regions are matched by content, not by address). * metafile: a negative crop side pads the picture crop_window took each side as 0..1, so a negative side (a negative a:srcRect side in an Office file, or a picture fitted whole into a frame of another shape) was dropped and the picture was stretched over its frame. A side is now taken down to -100: the window starts before the source or ends past it, and Picture::fit places the source inside the frame at its own proportions. Ported from storytold/wordcraft#2027 (the -100 bound and the invariant test) and storytold/wordcraft#5206 (the padding test). * metafile: decode 8-bit text in the font's code page 8-bit text (WMF TEXTOUT and EXTTEXTOUT, EMF EXTTEXTOUTA and SMALLTEXTOUT) was decoded in Windows-1250, 1251 or 1252 only, and a double-byte character became a blank. - Greek, Turkish, Vietnamese, Hebrew, Arabic, Baltic and Thai character sets decode in their code pages (encoding_rs, already a workspace dependency). - Shift_JIS, UHC, GBK and Big5 pairs decode to their character, with the lead and trail ranges of each set; the character keeps the advances of both bytes. A lead byte without a trail byte is one undefined byte. - A WMF face name is read in the font's code page. - The Symbol font selected with DEFAULT_CHARSET keeps its bytes as symbol codes (U+F000 + code), as SYMBOL_CHARSET fonts do. Ported from storytold/wordcraft#5187 (the code pages, the double-byte ranges, the face name and the Symbol font) and storytold/deckcraft#792 (the Hebrew and Arabic tests). * metafile: dual EMF+ files draw their EMF+ records, as GDI+ does For a dual EMF+ file the reader played the plain EMF records and ignored the EMF+ ones. Office draws metafiles with GDI+, which does the opposite, and a file's GDI fallback can be incomplete (the EMF+ part draws a nested metafile the fallback lacks). EMF+ records now always play. In a dual file the plain EMF records are skipped, except between an EmfPlusGetDC record and the next EMF+ record. EMF+ only files and plain EMF files are read as before. Ported from storytold/deckcraft#1464. --------- Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com> Co-authored-by: David Lin Co-authored-by: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> --- crates/metafile/README.md | 5 ++++- crates/metafile/src/emf.rs | 2 +- crates/metafile/src/emfplus.rs | 35 ++++++++++++++++++++++------------ crates/metafile/src/tests.rs | 29 ++++++++++++++++++++++++++-- 4 files changed, 55 insertions(+), 16 deletions(-) diff --git a/crates/metafile/README.md b/crates/metafile/README.md index edd9750bf..ba255c36d 100644 --- a/crates/metafile/README.md +++ b/crates/metafile/README.md @@ -79,7 +79,10 @@ decoder is where the size, pixel and work limits have to be enforced. ### EMF+ (MS-EMFPLUS) -An EMF+ only file is played from its EMF+ records. A dual file is played from its EMF records. +A file with EMF+ records is played from them, as GDI+ plays it. In a dual file (one that also +draws the picture with plain EMF records, as a fallback for GDI) the plain records are skipped, +except those between an `EmfPlusGetDC` record and the next EMF+ record. What the EMF+ reader does +not read (the table's last row) is therefore lost in a dual file even where its EMF records carry it. | Feature | Read | |---|---| diff --git a/crates/metafile/src/emf.rs b/crates/metafile/src/emf.rs index cf3bc7a90..81a8dd7fe 100644 --- a/crates/metafile/src/emf.rs +++ b/crates/metafile/src/emf.rs @@ -63,7 +63,7 @@ pub(crate) fn parse(input: Bytes) -> Result<(Canvas, Option), Error> { if let Some(data) = Bytes(rec).tail(16) { plus.comment(&mut cv, data); } - } else { + } else if plus.plays_gdi() { record(&mut cv, typ, Bytes(rec)); } parsed += 1; diff --git a/crates/metafile/src/emfplus.rs b/crates/metafile/src/emfplus.rs index 1e145ffd1..5c45721a7 100644 --- a/crates/metafile/src/emfplus.rs +++ b/crates/metafile/src/emfplus.rs @@ -1,8 +1,9 @@ //! EMF+ (MS-EMFPLUS): GDI+ drawing records carried in EMF comment records. //! -//! Most EMF+ files are *dual*: the same picture is also drawn with plain EMF records, which -//! [`crate::emf`] already plays, so this reader ignores their EMF+ part. An *EMF+ only* file has -//! nothing else to draw; for those this reader plays the records: +//! Most EMF+ files are *dual*: the same picture is also drawn with plain EMF records as a +//! fallback for GDI. Office draws metafiles with GDI+, which plays the EMF+ records and skips +//! the plain ones (except after EmfPlusGetDC), and a fallback can be incomplete (a nested +//! metafile it lacks), so this reader does the same. It plays the records: //! - brushes (solid, hatch, texture, linear and path gradients), pens, paths, images, fonts, //! string formats and regions, including objects split across several records; //! - rectangles, polygons, lines, ellipses, paths and Béziers; @@ -216,8 +217,10 @@ struct Saved { } pub(crate) struct Plus { - /// An EMF+ header said the file is EMF+ only, so these records are the picture. - active: bool, + /// An EMF+ header said the file is dual: its plain EMF records are only a GDI fallback. + dual: bool, + /// An EmfPlusGetDC record opened a window of plain EMF records, until the next EMF+ record. + gdi: bool, dpi: f64, world: Xform, page: f64, @@ -234,7 +237,8 @@ pub(crate) struct Plus { impl Plus { pub fn new() -> Self { Plus { - active: false, + dual: false, + gdi: false, dpi: 96.0, world: IDENTITY, page: 1.0, @@ -247,8 +251,15 @@ impl Plus { } } + /// Whether the plain EMF record that follows is drawn: always, except in a dual file outside + /// an EmfPlusGetDC window. + pub fn plays_gdi(&self) -> bool { + !self.dual || self.gdi + } + /// Plays the EMF+ records of one EMR_COMMENT payload (after the identifier). pub fn comment(&mut self, cv: &mut Canvas, data: Bytes) { + self.gdi = false; let mut pos = 0usize; while !cv.full() { let Some(tail) = data.0.get(pos..) else { break }; @@ -261,6 +272,8 @@ impl Plus { break; } let Some(rec) = tail.slice(12, dsize) else { break }; + // Any EMF+ record ends a GetDC window; EmfPlusGetDC opens it again for its own. + self.gdi = false; let _ = self.record(cv, typ, flags, Bytes(rec)); let Some(next) = pos.checked_add(size) else { break }; pos = next; @@ -278,15 +291,15 @@ impl Plus { fn record(&mut self, cv: &mut Canvas, typ: u16, flags: u16, r: Bytes) -> Option<()> { if typ == 0x4001 { - // Header: dual files also draw with EMF records, which win. - self.active = flags & 1 == 0; + self.dual = flags & 1 != 0; if let Some(d) = r.u32(8).map(f64::from).filter(|d| *d > 0.0 && *d < 100_000.0) { self.dpi = d; } return Some(()); } - if !self.active { - return None; + if typ == 0x4004 { + self.gdi = true; + return Some(()); } let done = match typ { 0x4008 => self.object(flags, r, cv.used().saturating_add(self.clip_bytes())), @@ -1629,7 +1642,6 @@ mod cost_tests { #[test] fn pieces_of_a_continued_object_count_against_the_shared_budget() { let (mut plus, mut cv) = (Plus::new(), Canvas::new()); - plus.active = true; // Image object (kind 5), id 0, continued: 1000 bytes announced, 100 delivered. let mut rec = 1000u32.to_le_bytes().to_vec(); rec.extend([0u8; 100]); @@ -1640,7 +1652,6 @@ mod cost_tests { #[test] fn current_and_saved_clips_count_against_the_shared_budget() { let (mut plus, mut cv) = (Plus::new(), Canvas::new()); - plus.active = true; let pts = vec![(0.0, 0.0); 1000]; plus.clip = Some(pts.clone()); plus.stack.push(Saved { id: 1, world: IDENTITY, page: 1.0, clip: Some(pts) }); diff --git a/crates/metafile/src/tests.rs b/crates/metafile/src/tests.rs index 67634f1fd..806c95662 100644 --- a/crates/metafile/src/tests.rs +++ b/crates/metafile/src/tests.rs @@ -2041,12 +2041,37 @@ fn emfplus_only_fill_rects_with_argb_colour() { } #[test] -fn emfplus_dual_files_draw_only_the_emf_records() { +fn emfplus_dual_files_draw_the_emfplus_records_like_gdiplus() { let fill = prec(0x400A, 0x8000, &cat(vec![h32(0xFFFF_0000), h32(1), f32s(&[0.0, 0.0, 5.0, 5.0])])); let gdi = erec(43, &cat(vec![e32(0), e32(0), e32(9), e32(9)])); let p = parse(&emf(&[plus_comment(&[plus_header(true), fill]), gdi], [0; 4], [0; 4])).unwrap(); assert_eq!(p.items.len(), 1); - assert_eq!(path_segs(&p.items[0]).1, Some([255, 255, 255, 255])); + assert_eq!(path_segs(&p.items[0]).1, Some([255, 0, 0, 255])); +} + +#[test] +fn emfplus_dual_files_play_plain_records_after_get_dc() { + let gdi = erec(43, &cat(vec![e32(0), e32(0), e32(9), e32(9)])); + let get_dc = prec(0x4004, 0, &[]); + let p = parse(&emf(&[plus_comment(&[plus_header(true)]), plus_comment(&[get_dc]), gdi], [0; 4], [0; 4])).unwrap(); + assert_eq!(p.items.len(), 1); +} + +#[test] +fn emfplus_record_after_get_dc_in_one_comment_closes_the_plain_window() { + let gdi = erec(43, &cat(vec![e32(0), e32(0), e32(9), e32(9)])); + let get_dc = prec(0x4004, 0, &[]); + let fill = prec(0x400A, 0x8000, &cat(vec![h32(0xFFFF_0000), h32(1), f32s(&[0.0, 0.0, 5.0, 5.0])])); + let p = parse(&emf(&[plus_comment(&[plus_header(true), get_dc, fill]), gdi], [0; 4], [0; 4])).unwrap(); + assert_eq!(p.items.len(), 1); + assert_eq!(path_segs(&p.items[0]).1, Some([255, 0, 0, 255])); +} + +#[test] +fn an_emf_without_an_emfplus_header_plays_its_plain_records() { + let gdi = erec(43, &cat(vec![e32(0), e32(0), e32(9), e32(9)])); + let p = parse(&emf(&[gdi], [0; 4], [0; 4])).unwrap(); + assert_eq!(p.items.len(), 1); } #[test]