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;