From 5fc8aaee7c1abf5edc2b07ea481af33864d63a06 Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Wed, 14 Oct 2026 02:47:47 +0300 Subject: [PATCH] Metafile: EMF+ cardinal curves and region fills MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DrawCurve, DrawClosedCurve and FillClosedCurve (GDI+ cardinal splines, the smoothed lines of charts and freehand shapes) draw as cubic Béziers with the record's tension, offset and segment count; FillRegion fills the region object's area. These records were skipped, so the shapes were missing. Co-authored-by: Cursor --- crates/metafile/src/emfplus.rs | 65 ++++++++++++++++++++++++++++++++++ crates/metafile/src/tests.rs | 60 +++++++++++++++++++++++++++++++ crates/render/src/tests.rs | 15 ++++++++ 3 files changed, 140 insertions(+) diff --git a/crates/metafile/src/emfplus.rs b/crates/metafile/src/emfplus.rs index 48d1af89..064ce609 100644 --- a/crates/metafile/src/emfplus.rs +++ b/crates/metafile/src/emfplus.rs @@ -328,6 +328,39 @@ impl Plus { self.painter().stroke(cv, &ops, pen); Some(()) } + // Cardinal splines: FillClosedCurve, DrawClosedCurve, DrawCurve. + 0x4016 => { + let fill = self.brush_of(flags, r.u32(0)?)?; + let tension = finite(r.f32(4)?)?; + let pts = points(r, 12, r.u32(8)? as usize, compressed)?; + self.painter().fill(cv, &curve_ops(&pts, tension, true, 0, usize::MAX), &fill); + Some(()) + } + 0x4017 => { + let pen = self.pen(id)?; + let tension = finite(r.f32(0)?)?; + let pts = points(r, 8, r.u32(4)? as usize, compressed)?; + self.painter().stroke(cv, &curve_ops(&pts, tension, true, 0, usize::MAX), pen); + Some(()) + } + 0x4018 => { + let pen = self.pen(id)?; + let tension = finite(r.f32(0)?)?; + let (offset, segments) = (r.u32(4)? as usize, r.u32(8)? as usize); + let pts = points(r, 16, r.u32(12)? as usize, compressed)?; + self.painter().stroke(cv, &curve_ops(&pts, tension, false, offset, segments), pen); + Some(()) + } + // FillRegion: the region's area (as the clip reads it). + 0x4013 => { + let fill = self.brush_of(flags, r.u32(0)?)?; + let Some(Some(Obj::Region(Some(area)))) = self.objects.get(id).cloned() else { return None }; + if area.len() < 3 { + return None; + } + self.painter().fill(cv, &poly_ops(&area, true), &fill); + Some(()) + } 0x401A => { let img = self.image(id)?; let dst = *rects(r, 24, 1, compressed)?.first()?; @@ -1246,6 +1279,38 @@ fn rect_ops([l, t, r, b]: [f64; 4]) -> Vec { crate::geom::rect(l, t, r, b) } +fn finite(v: f32) -> Option { + v.is_finite().then_some(f64::from(v)) +} + +/// A GDI+ cardinal spline through `pts` as cubic Béziers: each segment's control points lie a +/// third of `tension` (GDI+'s default 0.5) along the neighbouring chords. `closed` joins the +/// last point back to the first; an open curve draws `segments` segments from point `offset`. +fn curve_ops(pts: &[P], tension: f64, closed: bool, offset: usize, segments: usize) -> Vec { + let n = pts.len(); + if n < 2 { + return Vec::new(); + } + let k = tension.clamp(0.0, 10.0) / 3.0; + let at = |i: isize| -> P { + let i = if closed { i.rem_euclid(n as isize) as usize } else { i.clamp(0, n as isize - 1) as usize }; + pts.get(i).copied().unwrap_or((0.0, 0.0)) + }; + let (first, count) = if closed { (0, n) } else { (offset.min(n - 1), segments.min(n - 1 - offset.min(n - 1))) }; + let mut ops = vec![Op::M(at(first as isize))]; + for s in first..first + count { + let i = s as isize; + let (p0, p1, p2, p3) = (at(i - 1), at(i), at(i + 1), at(i + 2)); + let c1 = (p1.0 + (p2.0 - p0.0) * k, p1.1 + (p2.1 - p0.1) * k); + let c2 = (p2.0 - (p3.0 - p1.0) * k, p2.1 - (p3.1 - p1.1) * k); + ops.push(Op::C(c1, c2, p2)); + } + if closed { + ops.push(Op::Z); + } + ops +} + fn poly_ops(pts: &[P], close: bool) -> Vec { let mut ops: Vec = pts.iter().enumerate().map(|(i, p)| if i == 0 { Op::M(*p) } else { Op::L(*p) }).collect(); if close { diff --git a/crates/metafile/src/tests.rs b/crates/metafile/src/tests.rs index 7ad5071b..43789e3d 100644 --- a/crates/metafile/src/tests.rs +++ b/crates/metafile/src/tests.rs @@ -1849,3 +1849,63 @@ fn emfplus_nested_metafile_image_draws_its_items_into_the_destination() { assert_eq!(fill, Some([255, 255, 255, 255])); assert!(segs.contains(&Seg::Line(40.0, 20.0)), "{segs:?}"); } + +/// Curves and a region fill, as an EMF+ only file (also written to `$DECKCRAFT_EMF_OUT` to look at). +fn curves_file() -> Vec { + let pen = prec(0x4008, 0x0200 | 1, &solid_pen(0xFF20_60C0, 3.0)); + // A closed curve through a diamond's corners, filled orange. + let diamond = f32s(&[100.0, 10.0, 190.0, 100.0, 100.0, 190.0, 10.0, 100.0]); + let fill = prec(0x4016, 0x8000, &cat(vec![h32(0xFFE0_8020), f32s(&[0.5]), h32(4), diamond.clone()])); + let outline = prec(0x4017, 1, &cat(vec![f32s(&[0.5]), h32(4), diamond])); + // An open curve (a smoothed line chart): all four segments of five points. + let line = + prec(0x4018, 1, &cat(vec![f32s(&[0.5]), h32(0), h32(4), h32(5), f32s(&[210.0, 150.0, 260.0, 60.0, 310.0, 120.0, 360.0, 30.0, 410.0, 90.0])])); + // 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]; + all.push(prec(0x4002, 0, &[])); + emf(&[plus_comment(&all)], [0, 0, 420, 200], [0; 4]) +} + +#[test] +fn emfplus_cardinal_curves_and_region_fill() { + let bytes = curves_file(); + if let Ok(path) = std::env::var("DECKCRAFT_EMF_OUT") { + let _ = std::fs::write(path, &bytes); + } + let p = parse(&bytes).unwrap(); + assert_eq!(p.items.len(), 4, "{:?}", p.items.len()); + // The closed curve: four cubics through the diamond's corners, closed. + let (segs, fill, ..) = path_segs(&p.items[0]); + 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]); + 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]); + assert_eq!(fill, Some([0x30, 0xA0, 0x40, 0xFF])); + assert_eq!(segs.iter().filter(|s| matches!(s, Seg::Line(..))).count(), 3, "{segs:?}"); +} + +#[test] +fn emfplus_curve_offsets_and_degenerate_input_are_safe() { + let pen = prec(0x4008, 0x0200 | 1, &solid_pen(0xFF00_0000, 1.0)); + // Segments from point 2 of 5, asking for more than there are: two segments. + let line = prec(0x4018, 1, &cat(vec![f32s(&[0.5]), h32(2), h32(99), h32(5), f32s(&[0.0, 0.0, 10.0, 5.0, 20.0, 0.0, 30.0, 5.0, 40.0, 0.0])])); + let p = plus_pic(vec![pen.clone(), line]); + let (segs, ..) = path_segs(&p.items[0]); + assert_eq!(segs.iter().filter(|s| matches!(s, Seg::Cubic(..))).count(), 2, "{segs:?}"); + // One point, an offset past the end, a NaN tension, a missing region: nothing, no panic. + let one = prec(0x4018, 1, &cat(vec![f32s(&[0.5]), h32(0), h32(1), h32(1), f32s(&[1.0, 1.0])])); + let past = prec(0x4018, 1, &cat(vec![f32s(&[0.5]), h32(9), h32(9), h32(2), f32s(&[0.0, 0.0, 9.0, 9.0])])); + let nan = prec(0x4017, 1, &cat(vec![f32s(&[f32::NAN]), h32(3), f32s(&[0.0, 0.0, 9.0, 9.0, 0.0, 9.0])])); + let no_region = prec(0x4013, 0x8000 | 7, &h32(0xFF00_0000)); + 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)); +} diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 0de3d695..40bb76c1 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -1711,3 +1711,18 @@ fn east_asian_vertical_text_stands_ideographs_upright() { let (w, h) = ink(deckcraft_model::text::TextDir::Vertical); assert!(h > w * 3, "vert: stroke on its side {w}x{h}"); } + +/// Renders the EMF at `$DECKCRAFT_EMF_IN` to `$DECKCRAFT_EMF_PNG` (a look-at helper; does nothing +/// without them). +#[test] +fn render_an_emf_file_to_look_at() { + let (Ok(src), Ok(out)) = (std::env::var("DECKCRAFT_EMF_IN"), std::env::var("DECKCRAFT_EMF_PNG")) else { return }; + let Ok(bytes) = std::fs::read(&src) else { return }; + let mut p = Presentation::default(); + let media = p.add_media("image1.emf", "image/x-emf", bytes); + let fill = deckcraft_model::style::PictureFill { media, ..Default::default() }; + let sh = Shape { id: ShapeId(600), xfrm: Some(Xfrm::new(40.0, 40.0, 840.0, 400.0)), kind: ShapeKind::Picture { fill }, ..Default::default() }; + std::sync::Arc::make_mut(&mut p.slides[0]).shapes = vec![sh]; + let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() }); + let _ = std::fs::write(out, img.to_png()); +}