From c61d27b825f0eb38ec7a9f74a674551f1167ddf1 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sat, 14 Nov 2026 13:21:29 -0800 Subject: [PATCH] Metafile import: hidden WMF records leave the scale alone, and a far stroke keeps the strokes before it A WMF without a placeable box picks its scale from the map mode its drawing records are played in. Lines and shapes drawn under R2_NOP or R2_XORPEN, and PATBLT records the reader does not fill, no longer count, with the mix mode saved and restored with SAVEDC and RESTOREDC. An open stroke that reaches far outside the picture is left out before it joins the strokes waiting to be joined, so the strokes drawn before it are still added. --- crates/engine/tests/import_fuzz.rs | 10 ++++- crates/metafile/src/import/picture.rs | 61 +++++++++++++++++++++------ crates/metafile/src/tests/import.rs | 51 ++++++++++++++++++++++ 3 files changed, 107 insertions(+), 15 deletions(-) diff --git a/crates/engine/tests/import_fuzz.rs b/crates/engine/tests/import_fuzz.rs index e22f8ac5cd..a768785abf 100644 --- a/crates/engine/tests/import_fuzz.rs +++ b/crates/engine/tests/import_fuzz.rs @@ -2904,9 +2904,9 @@ fn arb_body() -> impl Strategy> { } /// WMF record functions VectorCraft reads, and one it doesn't. -const WMF_FUNCTIONS: [u16; 33] = [ +const WMF_FUNCTIONS: [u16; 35] = [ 0x0324, 0x0325, 0x0538, 0x041B, 0x0418, 0x061C, 0x0817, 0x081A, 0x0830, 0x0416, 0x0415, 0x02FA, 0x02FC, 0x02FB, 0x012D, 0x01F0, 0x0521, 0x0A32, - 0x0F43, 0x0B41, 0x0940, 0x061D, 0x0213, 0x0214, 0x001E, 0x0127, 0x0106, 0x0209, 0x012E, 0x00F7, 0x01F9, 0x0142, 0x0999, + 0x0F43, 0x0B41, 0x0940, 0x061D, 0x0213, 0x0214, 0x001E, 0x0127, 0x0106, 0x0209, 0x012E, 0x00F7, 0x01F9, 0x0142, 0x0103, 0x0104, 0x0999, ]; proptest! { @@ -2933,6 +2933,12 @@ proptest! { survive_metafile("WMF records", "x.wmf", &wmf_of(&records))?; } + #[test] + fn random_headerless_wmf_records_never_panic(records in prop::collection::vec((prop::sample::select(WMF_FUNCTIONS.to_vec()), arb_body()), 0..32)) { + // Without the placeable header, the map and mix modes the records set decide the scale. + survive_metafile("headerless WMF records", "x.wmf", &wmf_of(&records)[22..])?; + } + #[test] fn random_emf_plus_records_never_panic(records in prop::collection::vec((0x4001u16..0x403B, any::(), arb_body()), 0..32)) { // One comment of EMF+ records (type, flags, data) after an EmfPlusHeader. diff --git a/crates/metafile/src/import/picture.rs b/crates/metafile/src/import/picture.rs index 5385d94153..03a2e5628e 100644 --- a/crates/metafile/src/import/picture.rs +++ b/crates/metafile/src/import/picture.rs @@ -56,25 +56,39 @@ const PT_PER_PX: f64 = 72.0 / 96.0; /// Points a millimetre. const PT_PER_MM: f64 = 72.0 / 25.4; -/// WMF records that draw and that `craft_metafile` plays: lines, shapes, text, PATBLT and bitmaps. -const WMF_DRAWS: [u16; 16] = - [0x0213, 0x0324, 0x0325, 0x0418, 0x041B, 0x0521, 0x0538, 0x061C, 0x061D, 0x0817, 0x081A, 0x0830, 0x0940, 0x0A32, 0x0B41, 0x0F43]; +/// WMF records that draw lines and shapes, which `craft_metafile` plays only when the mix mode +/// lets them draw. +const WMF_SHAPES: [u16; 10] = [0x0213, 0x0324, 0x0325, 0x0418, 0x041B, 0x0538, 0x061C, 0x0817, 0x081A, 0x0830]; +/// WMF records that draw text and bitmaps, which `craft_metafile` plays whatever the mix mode. +const WMF_DRAWS: [u16; 5] = [0x0521, 0x0940, 0x0A32, 0x0B41, 0x0F43]; +/// SETROP2 mix modes under which `craft_metafile` draws no lines, shapes or brush fills: R2_XORPEN +/// and R2_NOP. +const WMF_MIX_SKIPS: [u16; 2] = [7, 11]; +/// The mix mode a WMF starts in (R2_COPYPEN). +const WMF_MIX_START: u16 = 13; +/// PATBLT raster operations `craft_metafile` draws: PATCOPY (with the brush, as the mix mode +/// allows), BLACKNESS and WHITENESS. +const PATCOPY: u32 = 0x00F0_0021; +const BLACKNESS: u32 = 0x0000_0042; +const WHITENESS: u32 = 0x00FF_0062; /// Does a WMF draw in a metric map mode (MM_LOMETRIC to MM_TWIPS)? `craft_metafile` plays those /// at 96 pixels an inch, whatever the window. The mode that more drawing records are played in -/// decides; a file that draws nothing goes by its last mode. `start` is where the standard header -/// begins. +/// decides, counting only records the reader draws (not lines and shapes under R2_NOP or +/// R2_XORPEN, nor a PATBLT it does not fill); a file that draws nothing goes by its last mode. +/// `start` is where the standard header begins. fn wmf_metric(bytes: &[u8], start: usize) -> bool { let u16_at = |at: usize| bytes.get(at..at.checked_add(2)?).map(|b| u16::from_le_bytes([b[0], b[1]])); let u32_at = |at: usize| bytes.get(at..at.checked_add(4)?).map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]])); let Some(words) = start.checked_add(2).and_then(u16_at) else { return false }; let mut at = start.saturating_add(usize::from(words) * 2); let mut mode = 1; + let mut mix = WMF_MIX_START; let metric = |mode: u16| (2..=6).contains(&mode); // Drawing records played in a metric mode, and in any other. let (mut in_metric, mut in_other) = (0usize, 0usize); - // The modes SAVEDC kept, which RESTOREDC brings back as the reader does. - let mut saved: Vec = Vec::new(); + // The map and mix modes SAVEDC kept, which RESTOREDC brings back as the reader does. + let mut saved: Vec<(u16, u16)> = Vec::new(); // The records as the reader walks them: it stops at the end record, a record shorter than its // header, one that runs past the file and a SAVEDC past its 1,024 levels. while let (Some(words), Some(function)) = (u32_at(at), u16_at(at.saturating_add(4))) { @@ -86,12 +100,14 @@ fn wmf_metric(bytes: &[u8], start: usize) -> bool { match function { // SETMAPMODE, when the record holds its mode. 0x0103 if size >= 8 => mode = u16_at(at + 6).unwrap_or(mode), + // SETROP2, when the record holds its mix mode. + 0x0104 if size >= 8 => mix = u16_at(at + 6).unwrap_or(mix), // SAVEDC. 0x001E => { if saved.len() >= SAVED_LEVELS { break; } - saved.push(mode); + saved.push((mode, mix)); } // RESTOREDC: a negative count pops that many levels, a positive one goes back to that // level. One that names no saved level changes nothing. @@ -99,20 +115,31 @@ fn wmf_metric(bytes: &[u8], start: usize) -> bool { let n = u16_at(at + 6).map_or(0, |v| i32::from(i16::from_le_bytes(v.to_le_bytes()))); let level = if n < 0 { saved.len().checked_sub(n.unsigned_abs() as usize) } else { (n as usize).checked_sub(1) }; if let Some(level) = level - && let Some(m) = saved.get(level).copied() + && let Some((m, x)) = saved.get(level).copied() { mode = m; + mix = x; saved.truncate(level); } } - f if WMF_DRAWS.contains(&f) => { - if metric(mode) { + f => { + let draws = match f { + _ if WMF_DRAWS.contains(&f) => true, + _ if WMF_SHAPES.contains(&f) => !WMF_MIX_SKIPS.contains(&mix), + // PATBLT: its raster operation, when the record holds one. + 0x061D => match u32_at(at + 6).filter(|_| size >= 10) { + Some(PATCOPY) => !WMF_MIX_SKIPS.contains(&mix), + Some(rop) => rop == BLACKNESS || rop == WHITENESS, + None => false, + }, + _ => false, + }; + if draws && metric(mode) { in_metric += 1; - } else { + } else if draws { in_other += 1; } } - _ => {} } at = next; } @@ -401,6 +428,14 @@ impl Mapper { if let (None, Some((c, w))) = (fill, stroke) && !segs.iter().any(|s| matches!(s, Seg::Close)) { + // A stroke that reaches too far is left out here, before it joins the strokes + // waiting, so they are still added. + let half = f64::from(w) * self.k; + let half = if half > 0.0 && half.is_finite() { half / 2.0 } else { HAIRLINE / 2.0 }; + if !sane(bp.bounding_box().inflate(half, half)) { + self.flush(); + return self.warn(FAR_AWAY); + } let pen = Pen { color: c, width: w, line, even_odd, chain: chain.iter().map(|c| c.id).collect() }; let start = bp.elements().first().and_then(|e| match e { PathEl::MoveTo(p) => Some(*p), diff --git a/crates/metafile/src/tests/import.rs b/crates/metafile/src/tests/import.rs index a193997c4b..f5870c5f95 100644 --- a/crates/metafile/src/tests/import.rs +++ b/crates/metafile/src/tests/import.rs @@ -409,6 +409,57 @@ fn wmf_that_draws_in_a_metric_map_mode_and_restores_is_read_at_96_pixels_an_inch assert!((b.width() - 72.0).abs() < 1e-4 && (b.height() - 72.0).abs() < 1e-4, "{b:?}"); } +#[test] +fn wmf_records_the_mix_mode_keeps_from_drawing_do_not_set_its_scale() { + // A rectangle of 1440 units in MM_TEXT; then SAVEDC, MM_TWIPS, a mix mode and two records that + // draw nothing under it, and RESTOREDC. + let rect = (0x041B, le16(&[1440, 1440, 0, 0])); + let patblt = |rop: u32| (0x061D, [rop.to_le_bytes().to_vec(), le16(&[100, 100, 0, 0])].concat()); + let cases = [ + // R2_NOP and R2_XORPEN: rectangles draw nothing. + (11i16, [rect.clone(), rect.clone()]), + (7, [rect.clone(), rect.clone()]), + // R2_NOP: PATBLT with PATCOPY fills nothing. + (11, [patblt(0x00F0_0021), patblt(0x00F0_0021)]), + // R2_COPYPEN: PATBLT with DSTINVERT is not drawn. + (13, [patblt(0x0055_0009), patblt(0x0055_0009)]), + ]; + for (mix, hidden) in cases { + let mut records = vec![(0x020B, le16(&[0, 0])), (0x020C, le16(&[1440, 1440])), rect.clone()]; + records.extend([(0x001E, Vec::new()), (0x0103, le16(&[6])), (0x0104, le16(&[mix]))]); + records.extend(hidden); + records.push((0x0127, le16(&[-1]))); + let plain = hand_wmf(1440, 1440, &records)[22..].to_vec(); + let back = import(&plain).unwrap(); + let p = paths(&back.document); + assert_eq!(p.len(), 1, "mix {mix}: {p:?}"); + let b = p[0].geometric_bounds().unwrap(); + assert!((b.width() - 72.0).abs() < 1e-4 && (b.height() - 72.0).abs() < 1e-4, "mix {mix}: {b:?}"); + } + // R2_NOP, then SAVEDC, R2_COPYPEN and RESTOREDC bring R2_NOP back: the MM_TWIPS rectangle after + // them draws nothing. + let mut records = vec![(0x020B, le16(&[0, 0])), (0x020C, le16(&[1440, 1440])), rect.clone(), (0x0104, le16(&[11]))]; + records.extend([(0x001E, Vec::new()), (0x0104, le16(&[13])), (0x0127, le16(&[-1])), (0x0103, le16(&[6])), rect]); + let back = import(&hand_wmf(1440, 1440, &records)[22..]).unwrap(); + let p = paths(&back.document); + assert_eq!(p.len(), 1, "{p:?}"); + let b = p[0].geometric_bounds().unwrap(); + assert!((b.width() - 72.0).abs() < 1e-4 && (b.height() - 72.0).abs() < 1e-4, "restored: {b:?}"); +} + +#[test] +fn a_stroke_that_reaches_too_far_leaves_the_strokes_before_it() { + // The black pen draws from (0, 500) to (100, 500), then on to a point far outside the picture. + let records = vec![(37, le(&[0x8000_0007u32 as i32])), (27, le(&[0, 500])), (54, le(&[100, 500])), (54, le(&[100_000_000, 500]))]; + let back = import(&hand_emf(5000, 3000, &records)).unwrap(); + let p = paths(&back.document); + assert_eq!(p.len(), 1, "{p:?}"); + let mm = 72.0 / 25.4; + let b = p[0].geometric_bounds().unwrap(); + assert!((b.x0).abs() < 0.01 && (b.x1 - mm).abs() < 0.01 && (b.y0 - 5.0 * mm).abs() < 0.01, "{b:?}"); + assert!(back.warnings.iter().any(|w| w.contains("far outside")), "{:?}", back.warnings); +} + #[test] fn damaged_files_fail_or_import_partly() { let good = emf(&sample()).bytes;