Metafile: EMF+ cardinal curves and region fills

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 <cursoragent@cursor.com>
This commit is contained in:
Nikita Rakitin
2026-10-14 02:47:47 +03:00
co-authored by Cursor
parent 8f935a8697
commit 5fc8aaee7c
3 changed files with 140 additions and 0 deletions
+65
View File
@@ -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<Op> {
crate::geom::rect(l, t, r, b)
}
fn finite(v: f32) -> Option<f64> {
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<Op> {
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<Op> {
let mut ops: Vec<Op> = pts.iter().enumerate().map(|(i, p)| if i == 0 { Op::M(*p) } else { Op::L(*p) }).collect();
if close {
+60
View File
@@ -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<u8> {
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, &region), 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));
}
+15
View File
@@ -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());
}