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 <woei66@gmail.com>
Co-authored-by: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-11-11 15:26:10 -05:00
1 parent f19f131247
commit 01cbc50269
4 files changed
+55 -16

No files matched your search

+4 -1
View File
@@ -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 |
|---|---|
+1 -1
View File
@@ -63,7 +63,7 @@ pub(crate) fn parse(input: Bytes) -> Result<(Canvas, Option<Frame>), 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;
+23 -12
View File
@@ -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) });
+27 -2
View File
@@ -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]