Metafile: clip EMF and WMF drawing to rectangle regions

INTERSECTCLIPRECT, EXCLUDECLIPRECT and EXTSELECTCLIPRGN limit what later records paint, including a region made of several rectangles. A rotated map stores the bounds of the clip rectangle.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Nikita
2026-10-13 17:57:34 +03:00
co-authored by Cursor
parent 457406a7a5
commit 4fee02636a
13 changed files with 594 additions and 85 deletions
+2
View File
@@ -128,6 +128,7 @@ app's JSON control channel (`deckcraft --control PORT`).
| Date | What landed |
|---|---|
| 2026-10-13 | EMF and WMF rectangle clip regions (`INTERSECTCLIPRECT`, `EXCLUDECLIPRECT`, `EXTSELECTCLIPRGN`) limit later drawing. EMF+ records are still skipped |
| 2026-10-13 | Slide transitions like PowerPoint: every transition and option measured frame by frame against PowerPoint's video export and retuned (curves, directions, soft edges, 3-D perspective, contents moving over backgrounds); PowerPoint's own XML elements and defaults read and written (diagonal Wipe, Zoom, Shape In/Out, Glitter Hexagons, Shred In/Out, Page Curl Single); `deckcraft-cli render-transition` renders frames headless |
| 2026-10-13 | Wild text cases measured against PowerPoint (102 synthetic slides): bidi by run language, East Asian vertical text upright, mongolianVert/wordArtVert/wordArtVertRtl, underline styles and strikethrough at the font's metrics, centre/right/decimal tabs, East Asian, Thai, Hindi, Arabic and Hebrew numbering, small caps, all-caps "ß", transparent text, soft hyphens, kinsoku, rtlCol and just/dist anchoring, emoji presentation, fallback faces that no longer move the baseline |
| 2026-10-13 | Render matches PowerPoint more closely on wild shape, picture and table cases: even-odd custom paths, dashes and caps, arrowheads, radial and path gradients, tile rects, rotWithShape, gradient lines, shadows cast by what is drawn (group and perspective too), group fill, picture borders, tiles, reflection, recolour (biLevel, alphaInv, brightness/contrast), table borders and grid default, 20+ preset shapes by their ECMA formulas, default miter joins, and approximate 3-D bevels, cameras and face lighting |
@@ -150,6 +151,7 @@ app's JSON control channel (`deckcraft --control PORT`).
| Date | Change | Summary |
|---|---|---|
| 2026-10-13 | trivial | Progress log: EMF/WMF rectangle clip regions |
| 2026-10-13 | trivial | Progress log: wild text cases |
| 2026-10-12 | trivial | Progress log: File tab (Backstage, #76) |
| 2026-10-12 | trivial | Progress log: lines in scaled groups (#233) |
+119 -22
View File
@@ -4,6 +4,7 @@
use std::sync::{Arc, OnceLock};
use crate::clip::{self, MAX_CLIP_RECTS};
use crate::dib::Rgba;
use crate::geom::{Op, P};
use crate::text::{ETO_CLIPPED, ETO_OPAQUE, Font, TA_UPDATECP, Text, align};
@@ -244,19 +245,39 @@ pub(crate) enum Raw {
fill: Option<[u8; 4]>,
stroke: Option<([u8; 4], f64)>,
even_odd: bool,
/// Device-space clip region. None is the default clip.
region: Option<Vec<[f64; 4]>>,
},
/// A path filled with a repeating tile.
Pattern {
ops: Vec<Op>,
even_odd: bool,
tile: Arc<RawTile>,
region: Option<Vec<[f64; 4]>>,
},
Bitmap {
rect: [f64; 4],
img: Rgba,
region: Option<Vec<[f64; 4]>>,
},
/// Text whose transform and clip are in device units.
Text(Box<Text>),
/// Text whose transform and clip are in device units. `region` is the device clip.
Text {
text: Box<Text>,
region: Option<Vec<[f64; 4]>>,
},
}
impl Raw {
fn set_region(&mut self, region: Option<Vec<[f64; 4]>>) {
match self {
Raw::Path { region: slot, .. }
| Raw::Pattern { region: slot, .. }
| Raw::Bitmap { region: slot, .. }
| Raw::Text { region: slot, .. } => {
*slot = region;
}
}
}
}
/// Text drawing state: colours, background mode, alignment and the selected font.
@@ -277,6 +298,7 @@ struct Saved {
even_odd: bool,
rop2: u32,
text: TextState,
clip: Option<Vec<[f64; 4]>>,
}
pub(crate) struct Canvas {
@@ -304,6 +326,10 @@ pub(crate) struct Canvas {
in_path: bool,
path: Vec<Op>,
full: bool,
/// Current clip in device units. None is the default (the whole surface); Some([]) draws nothing.
clip: Option<Vec<[f64; 4]>>,
/// Device bounds of the picture, so excluding from the default clip has a surface to cut.
surface: Option<[f64; 4]>,
}
impl Canvas {
@@ -327,15 +353,26 @@ impl Canvas {
in_path: false,
path: Vec::new(),
full: false,
clip: None,
surface: None,
}
}
/// Device bounds `[x0, y0, x1, y1]` of the picture. Used when a clip record cuts the default clip.
pub fn set_clip_surface(&mut self, rect: [f64; 4]) {
self.surface = clip::normalize(rect);
}
/// True once the item cap is hit; the readers stop there and keep what they have.
pub fn full(&self) -> bool {
self.full
}
fn push(&mut self, raw: Raw) {
fn push(&mut self, mut raw: Raw) {
if self.clip.as_ref().is_some_and(Vec::is_empty) {
return;
}
raw.set_region(self.clip.clone());
let cost = match &raw {
Raw::Path { ops, .. } => ops.len() * std::mem::size_of::<Op>(),
// A tile shared with its brush is counted once (by the brush); one made for this fill
@@ -345,7 +382,7 @@ impl Canvas {
ops.len() * std::mem::size_of::<Op>() + 64 + own
}
Raw::Bitmap { img, .. } => img.px.len(),
Raw::Text(t) => t.text.len() + t.dx.len() * 4 + t.font.family.len() + 128,
Raw::Text { text: t, .. } => t.text.len() + t.dx.len() * 4 + t.font.family.len() + 128,
};
if self.items.len() >= MAX_ITEMS || self.used.saturating_add(cost) > MAX_BYTES {
self.full = true;
@@ -453,6 +490,7 @@ impl Canvas {
even_odd: self.even_odd,
rop2: self.rop2,
text: self.text.clone(),
clip: self.clip.clone(),
});
} else {
self.full = true;
@@ -471,6 +509,61 @@ impl Canvas {
self.even_odd = s.even_odd;
self.rop2 = s.rop2;
self.text = s.text;
self.clip = s.clip;
}
/// The logical rectangle as a device rectangle. A scale or translation keeps the rectangle; a
/// rotation stores the bounds of the rotated corners.
fn device_rect(&self, [l, t, r, b]: [f64; 4]) -> Option<[f64; 4]> {
let corners = [(l, t), (r, t), (l, b), (r, b)].map(|p| self.map.device(p));
if !corners.iter().all(|p| p.0.is_finite() && p.1.is_finite()) {
return None;
}
let (mut x0, mut y0, mut x1, mut y1) = (f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY);
for p in corners {
x0 = x0.min(p.0);
y0 = y0.min(p.1);
x1 = x1.max(p.0);
y1 = y1.max(p.1);
}
clip::normalize([x0, y0, x1, y1])
}
fn map_rects(&self, logical: &[[f64; 4]]) -> Vec<[f64; 4]> {
let mut out = Vec::new();
for r in logical.iter().take(MAX_CLIP_RECTS) {
if let Some(d) = self.device_rect(*r) {
out.push(d);
}
if out.len() >= MAX_CLIP_RECTS {
break;
}
}
out
}
/// INTERSECTCLIPRECT: later drawing is limited to this logical rectangle as well.
pub fn intersect_clip_rect(&mut self, logical: [f64; 4]) {
self.clip = match self.device_rect(logical) {
Some(rect) => clip::intersect(self.clip.clone(), rect),
None => Some(Vec::new()),
};
}
/// EXCLUDECLIPRECT: this logical rectangle is removed from the clip.
pub fn exclude_clip_rect(&mut self, logical: [f64; 4]) {
let Some(rect) = self.device_rect(logical) else { return };
self.clip = clip::exclude(self.clip.clone(), rect, self.surface);
}
/// EXTSELECTCLIPRGN: combine these logical rectangles with the clip in a `RGN_*` mode.
pub fn select_clip_rects(&mut self, logical: &[[f64; 4]], mode: u32) {
self.clip = clip::combine(self.clip.clone(), self.map_rects(logical), mode, self.surface);
}
/// The default clip: later records are not limited by a region.
pub fn reset_clip(&mut self) {
self.clip = None;
}
/// Stores an object in `slot` (EMF index), or in the first free slot (WMF handle table).
@@ -603,14 +696,14 @@ impl Canvas {
match fill {
Some(Paint::Tile(tile)) => {
if stroke.is_some() {
self.push(Raw::Pattern { ops: ops.clone(), even_odd, tile });
self.push(Raw::Path { ops, fill: None, stroke, even_odd });
self.push(Raw::Pattern { ops: ops.clone(), even_odd, tile, region: None });
self.push(Raw::Path { ops, fill: None, stroke, even_odd, region: None });
} else {
self.push(Raw::Pattern { ops, even_odd, tile });
self.push(Raw::Pattern { ops, even_odd, tile, region: None });
}
}
Some(Paint::Solid(c)) => self.push(Raw::Path { ops, fill: Some(c), stroke, even_odd }),
None if stroke.is_some() => self.push(Raw::Path { ops, fill: None, stroke, even_odd }),
Some(Paint::Solid(c)) => self.push(Raw::Path { ops, fill: Some(c), stroke, even_odd, region: None }),
None if stroke.is_some() => self.push(Raw::Path { ops, fill: None, stroke, even_odd, region: None }),
None => {}
}
}
@@ -755,7 +848,7 @@ impl Canvas {
/// Fills a logical rectangle `[l, t, r, b]` with a solid colour (ROP fills such as BLACKNESS).
pub fn fill_rect_with(&mut self, [l, t, r, b]: [f64; 4], color: [u8; 4]) {
let dev: Vec<Op> = crate::geom::rect(l, t, r, b).iter().map(|o| self.map_op(*o)).collect();
self.push(Raw::Path { ops: dev, fill: Some(color), stroke: None, even_odd: true });
self.push(Raw::Path { ops: dev, fill: Some(color), stroke: None, even_odd: true, region: None });
}
/// Whether a raster operation draws without a source bitmap (PATCOPY, BLACKNESS, WHITENESS).
@@ -831,17 +924,21 @@ impl Canvas {
}
_ => None,
};
self.push(Raw::Text(Box::new(Text {
transform: [sx * c, sx * s, -sy * s, sy * c, o.0, o.1],
text,
dx,
font: lf.font,
color: self.text.color,
background: self.text.bk_opaque.then_some(self.text.bk_color),
h_align,
v_align,
clip,
})));
self.push(Raw::Text {
text: Box::new(Text {
transform: [sx * c, sx * s, -sy * s, sy * c, o.0, o.1],
text,
dx,
font: lf.font,
color: self.text.color,
background: self.text.bk_opaque.then_some(self.text.bk_color),
h_align,
v_align,
clip,
region: None,
}),
region: None,
});
}
/// A bitmap stretched into a logical rectangle `[x, y, w, h]`. A negative extent mirrors the
@@ -852,7 +949,7 @@ impl Canvas {
let b = self.map.device((x + w, y + h));
crate::dib::flip(&mut img, a.0 > b.0, a.1 > b.1);
let dev = [a.0.min(b.0), a.1.min(b.1), (b.0 - a.0).abs(), (b.1 - a.1).abs()];
self.push(Raw::Bitmap { rect: dev, img });
self.push(Raw::Bitmap { rect: dev, img, region: None });
}
}
+193
View File
@@ -0,0 +1,193 @@
//! Clipping regions for EMF and WMF (MS-EMF 2.3.12, MS-WMF 2.3.5).
//!
//! A region is a set of axis-aligned rectangles in device units: `None` is the default clip (the
//! whole surface), `Some([])` draws nothing, and `Some(rects)` is their union. That is how GDI
//! stores a region (`RGNDATA` is a list of rectangles). Counts are capped so a hostile file cannot
//! grow the set without bound; past the cap, extra rectangles are dropped, which can only hide
//! drawing.
/// Rectangles kept from one region. Past this, further rectangles are dropped.
pub(crate) const MAX_CLIP_RECTS: usize = 64;
pub(crate) const RGN_AND: u32 = 1;
pub(crate) const RGN_OR: u32 = 2;
pub(crate) const RGN_XOR: u32 = 3;
pub(crate) const RGN_DIFF: u32 = 4;
pub(crate) const RGN_COPY: u32 = 5;
/// A rectangle `[x0, y0, x1, y1]` with a positive area, or none.
pub(crate) fn normalize(rect: [f64; 4]) -> Option<[f64; 4]> {
let [l, t, r, b] = rect;
if ![l, t, r, b].iter().all(|v| v.is_finite()) {
return None;
}
let (x0, x1) = (l.min(r), l.max(r));
let (y0, y1) = (t.min(b), t.max(b));
(x1 - x0 > 1e-6 && y1 - y0 > 1e-6).then_some([x0, y0, x1, y1])
}
fn intersection(a: [f64; 4], b: [f64; 4]) -> Option<[f64; 4]> {
let (x0, y0) = (a[0].max(b[0]), a[1].max(b[1]));
let (x1, y1) = (a[2].min(b[2]), a[3].min(b[3]));
(x1 - x0 > 1e-6 && y1 - y0 > 1e-6 && [x0, y0, x1, y1].iter().all(|v| v.is_finite())).then_some([x0, y0, x1, y1])
}
/// `a` with `b` removed, as up to four rectangles.
fn subtract_one(a: [f64; 4], b: [f64; 4]) -> Vec<[f64; 4]> {
let Some(i) = intersection(a, b) else { return vec![a] };
let mut out = Vec::new();
if i[1] > a[1]
&& let Some(r) = normalize([a[0], a[1], a[2], i[1]])
{
out.push(r);
}
if i[3] < a[3]
&& let Some(r) = normalize([a[0], i[3], a[2], a[3]])
{
out.push(r);
}
if i[0] > a[0]
&& let Some(r) = normalize([a[0], i[1], i[0], i[3]])
{
out.push(r);
}
if i[2] < a[2]
&& let Some(r) = normalize([i[2], i[1], a[2], i[3]])
{
out.push(r);
}
out
}
fn cap(mut rects: Vec<[f64; 4]>) -> Vec<[f64; 4]> {
rects.truncate(MAX_CLIP_RECTS);
rects
}
fn subtract_all(subject: Vec<[f64; 4]>, cuts: &[[f64; 4]]) -> Vec<[f64; 4]> {
let mut cur = subject;
for cut in cuts {
let mut next = Vec::new();
for s in cur {
for piece in subtract_one(s, *cut) {
if next.len() >= MAX_CLIP_RECTS {
return next;
}
next.push(piece);
}
}
cur = next;
}
cur
}
/// Intersects the current region with one rectangle.
pub(crate) fn intersect(current: Option<Vec<[f64; 4]>>, rect: [f64; 4]) -> Option<Vec<[f64; 4]>> {
let Some(rect) = normalize(rect) else { return Some(Vec::new()) };
Some(match current {
None => vec![rect],
Some(rects) => cap(rects.iter().filter_map(|r| intersection(*r, rect)).collect()),
})
}
/// Removes one rectangle. `surface` is the device bounds, used when the clip is still the default
/// (the whole surface) so the remainder can be named.
pub(crate) fn exclude(current: Option<Vec<[f64; 4]>>, rect: [f64; 4], surface: Option<[f64; 4]>) -> Option<Vec<[f64; 4]>> {
let Some(rect) = normalize(rect) else { return current };
match current {
Some(rects) => Some(subtract_all(rects, &[rect])),
None => surface.and_then(normalize).map(|s| subtract_all(vec![s], &[rect])),
}
}
/// Combines `incoming` with the current region in a region mode (`RGN_*`). An unknown mode leaves
/// the region as it was. `RGN_COPY` replaces it. An `RGN_OR` or `RGN_XOR` against the default clip
/// stays the default clip: the whole surface already covers the incoming rectangles.
pub(crate) fn combine(current: Option<Vec<[f64; 4]>>, incoming: Vec<[f64; 4]>, mode: u32, surface: Option<[f64; 4]>) -> Option<Vec<[f64; 4]>> {
let incoming: Vec<[f64; 4]> = cap(incoming.iter().copied().filter_map(normalize).collect());
match mode {
RGN_COPY => Some(incoming),
RGN_AND => match current {
None => Some(incoming),
Some(rects) => {
let mut out = Vec::new();
for a in &rects {
for b in &incoming {
if out.len() >= MAX_CLIP_RECTS {
return Some(out);
}
if let Some(r) = intersection(*a, *b) {
out.push(r);
}
}
}
Some(out)
}
},
RGN_OR => match current {
None => None,
Some(mut rects) => {
rects.extend(incoming);
Some(cap(rects))
}
},
RGN_DIFF => match current {
Some(rects) => Some(subtract_all(rects, &incoming)),
None => surface.and_then(normalize).map(|s| subtract_all(vec![s], &incoming)),
},
RGN_XOR => {
let base = current?;
let mut out = subtract_all(base.clone(), &incoming);
let rest = subtract_all(incoming, &base);
for r in rest {
if out.len() >= MAX_CLIP_RECTS {
break;
}
out.push(r);
}
Some(out)
}
_ => current,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn intersect_shrinks_to_the_overlap() {
let r = intersect(None, [0.0, 0.0, 100.0, 50.0]);
let r = intersect(r, [40.0, 10.0, 80.0, 40.0]);
assert_eq!(r, Some(vec![[40.0, 10.0, 80.0, 40.0]]));
assert_eq!(intersect(r, [0.0, 0.0, 10.0, 10.0]), Some(vec![]));
assert_eq!(intersect(Some(vec![[0.0, 0.0, 10.0, 20.0]]), [40.0, 0.0, 80.0, 20.0]), Some(vec![]));
}
#[test]
fn modes_copy_and_or_diff_and_xor() {
let a = Some(vec![[0.0, 0.0, 100.0, 40.0]]);
assert_eq!(combine(a.clone(), vec![[10.0, 10.0, 30.0, 20.0]], RGN_COPY, None), Some(vec![[10.0, 10.0, 30.0, 20.0]]));
let and = combine(a.clone(), vec![[50.0, 0.0, 150.0, 40.0]], RGN_AND, None);
assert_eq!(and, Some(vec![[50.0, 0.0, 100.0, 40.0]]));
let or = combine(a.clone(), vec![[200.0, 0.0, 210.0, 10.0]], RGN_OR, None).unwrap();
assert_eq!(or.len(), 2);
let diff = combine(a.clone(), vec![[20.0, 0.0, 40.0, 40.0]], RGN_DIFF, None).unwrap();
assert_eq!(diff, vec![[0.0, 0.0, 20.0, 40.0], [40.0, 0.0, 100.0, 40.0]]);
let xor = combine(a, vec![[80.0, 0.0, 120.0, 40.0]], RGN_XOR, None).unwrap();
assert!(xor.contains(&[0.0, 0.0, 80.0, 40.0]));
assert!(xor.contains(&[100.0, 0.0, 120.0, 40.0]));
}
#[test]
fn excluding_from_the_default_clip_uses_the_surface() {
let r = exclude(None, [10.0, 0.0, 20.0, 40.0], Some([0.0, 0.0, 40.0, 40.0])).unwrap();
assert_eq!(r, vec![[0.0, 0.0, 10.0, 40.0], [20.0, 0.0, 40.0, 40.0]]);
assert_eq!(exclude(None, [10.0, 0.0, 20.0, 40.0], None), None);
}
#[test]
fn a_zero_area_rectangle_clears_an_intersection() {
assert_eq!(intersect(Some(vec![[0.0, 0.0, 10.0, 10.0]]), [5.0, 5.0, 5.0, 8.0]), Some(vec![]));
}
}
+37
View File
@@ -39,6 +39,9 @@ pub(crate) fn parse(input: Bytes) -> Result<(Canvas, Option<Frame>), Error> {
let [l, t, r, b] = bounds;
let frame = frame_from_header(input).or_else(|| (r > l && b > t).then_some(Frame { rect: [l, t, r - l, b - t], size: None }));
let mut cv = Canvas::new();
if r > l && b > t {
cv.set_clip_surface([l, t, r, b]);
}
if let Some(px_mm) = device_px_per_mm(input) {
cv.set_device_mm(px_mm);
}
@@ -117,6 +120,9 @@ fn record(cv: &mut Canvas, typ: u32, r: Bytes) {
83 | 84 => ext_text_out(cv, r, typ == 84),
108 => small_text_out(cv, r),
40 => r.u32(8).map(|i| cv.delete_object(i)),
29 => rect32(r, 8).map(|rect| cv.exclude_clip_rect(rect)),
30 => rect32(r, 8).map(|rect| cv.intersect_clip_rect(rect)),
75 => ext_select_clip(cv, r),
27 => xy32(r, 8).map(|p| cv.move_to(p)),
54 => xy32(r, 8).map(|p| cv.line_to(p)),
42 => rect32(r, 8).map(|[l, t, rr, b]| cv.shape(&geom::ellipse(l, t, rr, b), true, true)),
@@ -182,6 +188,37 @@ fn record(cv: &mut Canvas, typ: u32, r: Bytes) {
};
}
/// EMR_EXTSELECTCLIPRGN: `cbRgnData` bytes of `RGNDATA` (rectangles in logical units) and a region
/// mode. No region data selects the default clip.
fn ext_select_clip(cv: &mut Canvas, r: Bytes) -> Option<()> {
let cb = r.u32(8)? as usize;
let mode = r.u32(12)?;
if cb == 0 {
cv.reset_clip();
return Some(());
}
let header = 16;
if cb < 32 {
return Some(());
}
// RDH_RECTANGLES is the only region type GDI writes.
if r.u32(header + 4)? != 1 {
return Some(());
}
let n = (r.u32(header + 8)? as usize).min(crate::clip::MAX_CLIP_RECTS);
let end = header.checked_add(cb)?;
let mut rects = Vec::new();
for i in 0..n {
let o = header + 32 + i * 16;
if o + 16 > end {
break;
}
rects.push(rect32(r, o)?);
}
cv.select_clip_rects(&rects, mode);
Some(())
}
/// Two little-endian i32 values at `off`, as a point.
fn xy32(r: Bytes, off: usize) -> Option<P> {
Some((f64::from(r.i32(off)?), f64::from(r.i32(off + 4)?)))
+46 -17
View File
@@ -9,12 +9,15 @@
//! bounds-checked and every count is capped; hostile input yields a shorter picture or an [`Error`],
//! never a panic.
//!
//! Records follow MS-WMF and MS-EMF. Records outside the supported set are skipped silently.
//! Records follow MS-WMF and MS-EMF. Rectangle clip regions (`INTERSECTCLIPRECT`,
//! `EXCLUDECLIPRECT`, `EXTSELECTCLIPRGN`) limit what later records draw. Records outside the
//! supported set are skipped silently.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
mod bytes;
mod canvas;
mod clip;
mod dib;
mod emf;
mod geom;
@@ -66,12 +69,27 @@ pub enum Item {
/// Colour and width in picture units.
stroke: Option<([u8; 4], f32)>,
even_odd: bool,
/// Clip region in picture units, each rectangle `[x0, y0, x1, y1]`. None is the default clip.
region: Option<Vec<[f32; 4]>>,
},
/// A bitmap stretched into `rect` = `[x, y, w, h]` (picture units), RGBA8 rows top-down.
Bitmap { rect: [f32; 4], width: u32, height: u32, rgba: Vec<u8> },
Bitmap {
rect: [f32; 4],
width: u32,
height: u32,
rgba: Vec<u8>,
/// Clip region in picture units. None is the default clip.
region: Option<Vec<[f32; 4]>>,
},
/// A path filled with a repeating tile (pattern and hatch brushes). Items filled with the same
/// brush share its tile.
Pattern { segs: Vec<Seg>, even_odd: bool, tile: Arc<Tile> },
Pattern {
segs: Vec<Seg>,
even_odd: bool,
tile: Arc<Tile>,
/// Clip region in picture units. None is the default clip.
region: Option<Vec<[f32; 4]>>,
},
/// A text string.
Text(Box<Text>),
}
@@ -153,20 +171,20 @@ fn build(raw: Vec<Raw>, frame: Option<Frame>) -> Picture {
// Tiles shared by several fills stay shared.
let mut tiles: HashMap<*const canvas::RawTile, (Arc<canvas::RawTile>, Arc<Tile>)> = HashMap::new();
let items = raw.into_iter().filter_map(|r| match r {
Raw::Path { ops, fill, stroke, even_odd } => {
Raw::Path { ops, fill, stroke, even_odd, region } => {
let segs = ops.iter().map(|op| seg(op, tf)).collect::<Option<Vec<Seg>>>()?;
if segs.len() < 2 {
return None;
}
let stroke = stroke.and_then(|(c, w)| Some((c, f((w * stroke_scale).min(diag))?)));
Some(Item::Path { segs, fill, stroke, even_odd })
Some(Item::Path { segs, fill, stroke, even_odd, region: map_region(region, &tf)? })
}
Raw::Bitmap { rect: r, img } => {
Raw::Bitmap { rect: r, img, region } => {
let (x, y) = tf((r[0], r[1]));
let rect = [f(x)?, f(y)?, f(r[2] * sx)?, f(r[3] * sy)?];
Some(Item::Bitmap { rect, width: img.w as u32, height: img.h as u32, rgba: img.px })
Some(Item::Bitmap { rect, width: img.w as u32, height: img.h as u32, rgba: img.px, region: map_region(region, &tf)? })
}
Raw::Pattern { ops, even_odd, tile } => {
Raw::Pattern { ops, even_odd, tile, region } => {
let segs = ops.iter().map(|op| seg(op, tf)).collect::<Option<Vec<Seg>>>()?;
if segs.len() < 2 {
return None;
@@ -188,27 +206,38 @@ fn build(raw: Vec<Raw>, frame: Option<Frame>) -> Picture {
t
}
};
Some(Item::Pattern { segs, even_odd, tile })
Some(Item::Pattern { segs, even_odd, tile, region: map_region(region, &tf)? })
}
Raw::Text(mut t) => {
Raw::Text { mut text, region } => {
// Device to picture: scale by (sx, sy) after moving the frame's corner to the origin.
let [a, b, c, d, e, g] = t.transform;
let [a, b, c, d, e, g] = text.transform;
let (e, g) = tf((e, g));
t.transform = [a * sx, b * sy, c * sx, d * sy, e, g];
if !t.transform.iter().all(|v| v.is_finite()) {
text.transform = [a * sx, b * sy, c * sx, d * sy, e, g];
if !text.transform.iter().all(|v| v.is_finite()) {
return None;
}
if let Some([x0, y0, x1, y1]) = t.clip {
if let Some([x0, y0, x1, y1]) = text.clip {
let (p, q) = (tf((f64::from(x0), f64::from(y0))), tf((f64::from(x1), f64::from(y1))));
t.clip = Some([f(p.0.min(q.0))?, f(p.1.min(q.1))?, f(p.0.max(q.0))?, f(p.1.max(q.1))?]);
text.clip = Some([f(p.0.min(q.0))?, f(p.1.min(q.1))?, f(p.0.max(q.0))?, f(p.1.max(q.1))?]);
}
Some(Item::Text(t))
text.region = map_region(region, &tf)?;
Some(Item::Text(text))
}
});
let items = items.collect();
Picture { width: f(size.0).unwrap_or(0.0), height: f(size.1).unwrap_or(0.0), items }
}
fn map_region(region: Option<Vec<[f64; 4]>>, tf: &impl Fn(P) -> P) -> Option<Option<Vec<[f32; 4]>>> {
let Some(rects) = region else { return Some(None) };
let mut out = Vec::with_capacity(rects.len());
for [x0, y0, x1, y1] in rects {
let (p, q) = (tf((x0, y0)), tf((x1, y1)));
out.push([f(p.0.min(q.0))?, f(p.1.min(q.1))?, f(p.0.max(q.0))?, f(p.1.max(q.1))?]);
}
Some(Some(out))
}
fn f(v: f64) -> Option<f32> {
let v = v as f32;
v.is_finite().then_some(v)
@@ -268,7 +297,7 @@ fn bbox(raw: &[Raw]) -> Option<[f64; 4]> {
grow((rect[0], rect[1]));
grow((rect[0] + rect[2], rect[1] + rect[3]));
}
Raw::Text(t) => grow((t.transform[4], t.transform[5])),
Raw::Text { text: t, .. } => grow((t.transform[4], t.transform[5])),
}
}
b
+30 -12
View File
@@ -16,15 +16,15 @@ const MIN_BITMAP_SIDE: f64 = 1e-6;
pub enum PlacedItem {
/// A filled and/or stroked path. The stroke width is in target units; 0 is a cosmetic hairline that
/// the renderer draws at its minimum width.
Path { path: BezPath, fill: Option<[u8; 4]>, stroke: Option<([u8; 4], f64)>, even_odd: bool },
Path { path: BezPath, fill: Option<[u8; 4]>, stroke: Option<([u8; 4], f64)>, even_odd: bool, region: Option<BezPath> },
/// `Picture::items[index]`, a bitmap, stretched into `rect`.
Bitmap { index: usize, rect: Rect },
Bitmap { index: usize, rect: Rect, region: Option<BezPath> },
/// `Picture::items[index]`, a pattern fill: `path` (target units) filled with the item's tile
/// repeated; `tile` maps tile pixels to target units.
Pattern { index: usize, path: BezPath, even_odd: bool, tile: Affine },
Pattern { index: usize, path: BezPath, even_odd: bool, tile: Affine, region: Option<BezPath> },
/// `Picture::items[index]`, a text string: `transform` maps its text space to target units;
/// `clip` (target units) bounds what it draws.
Text { index: usize, transform: Affine, clip: Option<Rect> },
/// `clip` (target units) bounds what it draws. `region` is the device-context clip.
Text { index: usize, transform: Affine, clip: Option<Rect>, region: Option<BezPath> },
}
impl Picture {
@@ -55,38 +55,42 @@ impl Picture {
let mut out = Vec::with_capacity(self.items.len());
for (index, item) in self.items.iter().enumerate() {
match item {
Item::Path { segs, fill, stroke, even_odd } => {
Item::Path { segs, fill, stroke, even_odd, region } => {
let path = tf * bez(segs);
let Some(region) = placed_region(&tf, region) else { continue };
if in_range(path.bounding_box()) {
let stroke = stroke.map(|(c, w)| (c, f64::from(w) * k));
out.push(PlacedItem::Path { path, fill: *fill, stroke, even_odd: *even_odd });
out.push(PlacedItem::Path { path, fill: *fill, stroke, even_odd: *even_odd, region });
}
}
Item::Pattern { segs, even_odd, tile: t } => {
Item::Pattern { segs, even_odd, tile: t, region } => {
let path = tf * bez(segs);
let Some(region) = placed_region(&tf, region) else { continue };
let tile = tf
* Affine::translate((f64::from(t.origin[0]), f64::from(t.origin[1])))
* Affine::scale_non_uniform(f64::from(t.cell[0]), f64::from(t.cell[1]));
let det = tile.determinant();
if in_range(path.bounding_box()) && det.is_finite() && det.abs() > 1e-12 {
out.push(PlacedItem::Pattern { index, path, even_odd: *even_odd, tile });
out.push(PlacedItem::Pattern { index, path, even_odd: *even_odd, tile, region });
}
}
Item::Text(t) => {
let transform = tf * Affine::new(t.transform);
let Some(region) = placed_region(&tf, &t.region) else { continue };
let clip =
t.clip.map(|[x0, y0, x1, y1]| tf.transform_rect_bbox(Rect::new(f64::from(x0), f64::from(y0), f64::from(x1), f64::from(y1))));
let c = transform.as_coeffs();
let origin = Rect::new(c[4], c[5], c[4], c[5]);
if in_range(origin) && c.iter().all(|v| v.is_finite()) && clip.is_none_or(in_range) {
out.push(PlacedItem::Text { index, transform, clip });
out.push(PlacedItem::Text { index, transform, clip, region });
}
}
Item::Bitmap { rect: [x, y, w, h], .. } => {
Item::Bitmap { rect: [x, y, w, h], region, .. } => {
let (x0, y0) = (f64::from(*x), f64::from(*y));
let rect = tf.transform_rect_bbox(Rect::new(x0, y0, x0 + f64::from(*w), y0 + f64::from(*h)));
let Some(region) = placed_region(&tf, region) else { continue };
if in_range(rect) && rect.width() > MIN_BITMAP_SIDE && rect.height() > MIN_BITMAP_SIDE {
out.push(PlacedItem::Bitmap { index, rect });
out.push(PlacedItem::Bitmap { index, rect, region });
}
}
}
@@ -107,6 +111,20 @@ impl Item {
}
}
/// The device-context clip in target units. `Some(None)` is the default clip. `None` means the
/// region does not meet the page, so the item draws nothing.
fn placed_region(tf: &Affine, region: &Option<Vec<[f32; 4]>>) -> Option<Option<BezPath>> {
let Some(rects) = region else { return Some(None) };
let mut path = BezPath::new();
for [x0, y0, x1, y1] in rects {
let r = tf.transform_rect_bbox(Rect::new(f64::from(*x0), f64::from(*y0), f64::from(*x1), f64::from(*y1)));
if in_range(r) && r.width() > 1e-9 && r.height() > 1e-9 {
path.extend(r.to_path(0.1));
}
}
(!path.is_empty()).then_some(Some(path))
}
/// Segments as a kurbo path. A segment after a close opens a new subpath at the last start point.
fn bez(segs: &[Seg]) -> BezPath {
let mut p = BezPath::new();
+97 -13
View File
@@ -119,7 +119,7 @@ const DIB24_RGBA: [u8; 16] = [255, 0, 0, 255, 255, 255, 255, 255, 0, 0, 255, 255
fn path_segs(item: &Item) -> (&Vec<Seg>, Option<[u8; 4]>, Option<([u8; 4], f32)>, bool) {
match item {
Item::Path { segs, fill, stroke, even_odd } => (segs, *fill, *stroke, *even_odd),
Item::Path { segs, fill, stroke, even_odd, .. } => (segs, *fill, *stroke, *even_odd),
other => panic!("expected a path: {other:?}"),
}
}
@@ -179,7 +179,7 @@ fn wmf_stretchdib_flips_bottom_up_rows() {
let params = cat(vec![h32(0x00CC_0020), h16(0), h16(2), h16(2), h16(0), h16(0), h16(10), h16(20), h16(0), h16(0), dib24_bottom_up()]);
let p = parse(&wmf(&[wrec(0x0F43, &params)], None)).unwrap();
assert_eq!((p.width, p.height), (20.0, 10.0));
let Item::Bitmap { rect, width, height, rgba } = &p.items[0] else { panic!("expected a bitmap") };
let Item::Bitmap { rect, width, height, rgba, .. } = &p.items[0] else { panic!("expected a bitmap") };
assert_eq!(rect, &[0.0, 0.0, 20.0, 10.0]);
assert_eq!((*width, *height), (2, 2));
assert_eq!(rgba.as_slice(), DIB24_RGBA);
@@ -236,7 +236,7 @@ fn emf_stretchdibits_flips_and_fits_frame() {
let p = parse(&file).unwrap();
let (w, h) = (2500.0 * 72.0 / 2540.0, 1250.0 * 72.0 / 2540.0);
assert!(close(p.width, w as f32) && close(p.height, h as f32));
let Item::Bitmap { rect, width, height, rgba } = &p.items[0] else { panic!("expected a bitmap") };
let Item::Bitmap { rect, width, height, rgba, .. } = &p.items[0] else { panic!("expected a bitmap") };
assert!(rect.iter().zip([0.0, 0.0, w, h]).all(|(a, b)| close(*a, b as f32)));
assert_eq!((*width, *height), (2, 2));
assert_eq!(rgba.as_slice(), DIB24_RGBA);
@@ -428,7 +428,7 @@ fn wmf_stretchdib_clipped_source_keeps_its_place() {
// destination and the picture keeps the original 20-unit width scale.
let params = cat(vec![h32(0x00CC_0020), h16(0), h16(2), h16(2), h16(0), h16(-1), h16(10), h16(20), h16(0), h16(0), dib24_bottom_up()]);
let p = parse(&wmf(&[wrec(0x0F43, &params)], Some([0, 0, 20, 10]))).unwrap();
let Item::Bitmap { rect, width, height, rgba } = &p.items[0] else { panic!("expected a bitmap") };
let Item::Bitmap { rect, width, height, rgba, .. } = &p.items[0] else { panic!("expected a bitmap") };
// Placeable units are points: 20 units at 1440 per inch are 1 point, so the right column is 0.5 pt wide.
assert_eq!(rect, &[0.5, 0.0, 0.5, 0.5]);
assert_eq!((*width, *height), (1, 2));
@@ -723,7 +723,7 @@ fn wmf_dibbitblt_copies_the_dib_at_its_destination() {
params.extend(dib24_bottom_up());
let recs = [wrec(0x041B, &cat(vec![h16(10), h16(10), h16(0), h16(0)])), wrec(0x0940, &params)];
let p = parse(&wmf(&recs, None)).unwrap();
let Item::Bitmap { rect, width, height, rgba } = &p.items[1] else { panic!("expected a bitmap") };
let Item::Bitmap { rect, width, height, rgba, .. } = &p.items[1] else { panic!("expected a bitmap") };
assert_eq!(rect, &[5.0, 6.0, 2.0, 2.0]);
assert_eq!((*width, *height), (2, 2));
assert_eq!(rgba.as_slice(), DIB24_RGBA);
@@ -881,8 +881,14 @@ fn place_fits_the_frame_scales_strokes_and_drops_far_paths() {
width: 100.0,
height: 50.0,
items: vec![
Item::Path { segs: vec![Seg::Move(0.0, 0.0), Seg::Line(10.0, 10.0)], fill: None, stroke: Some(([0, 0, 0, 255], 4.0)), even_odd: true },
Item::Path { segs: vec![Seg::Move(0.0, 0.0), Seg::Line(2e6, 0.0)], fill: None, stroke: None, even_odd: true },
Item::Path {
segs: vec![Seg::Move(0.0, 0.0), Seg::Line(10.0, 10.0)],
fill: None,
stroke: Some(([0, 0, 0, 255], 4.0)),
even_odd: true,
region: None,
},
Item::Path { segs: vec![Seg::Move(0.0, 0.0), Seg::Line(2e6, 0.0)], fill: None, stroke: None, even_odd: true, region: None },
],
};
let placed = pic.place(kurbo::Rect::new(0.0, 0.0, 200.0, 100.0), [0.0; 4]);
@@ -893,7 +899,7 @@ fn place_fits_the_frame_scales_strokes_and_drops_far_paths() {
#[test]
fn place_keeps_only_finite_bounded_bitmaps_with_area() {
let bitmap = |rect: [f32; 4]| Item::Bitmap { rect, width: 1, height: 1, rgba: vec![0; 4] };
let bitmap = |rect: [f32; 4]| Item::Bitmap { rect, width: 1, height: 1, rgba: vec![0; 4], region: None };
let pic = crate::Picture {
width: 100.0,
height: 50.0,
@@ -907,21 +913,21 @@ fn place_keeps_only_finite_bounded_bitmaps_with_area() {
};
let placed = pic.place(kurbo::Rect::new(0.0, 0.0, 200.0, 100.0), [0.0; 4]);
assert_eq!(placed.len(), 1);
let crate::PlacedItem::Bitmap { index, rect } = &placed[0] else { panic!("expected a bitmap") };
let crate::PlacedItem::Bitmap { index, rect, .. } = &placed[0] else { panic!("expected a bitmap") };
assert_eq!((*index, *rect), (4, kurbo::Rect::new(20.0, 10.0, 40.0, 30.0)));
}
#[test]
fn take_pixels_returns_only_drawable_bitmaps() {
let mut ok = Item::Bitmap { rect: [0.0; 4], width: 2, height: 1, rgba: vec![7; 8] };
let mut ok = Item::Bitmap { rect: [0.0; 4], width: 2, height: 1, rgba: vec![7; 8], region: None };
assert_eq!(ok.take_pixels(), Some((2, 1, vec![7; 8])));
assert!(matches!(&ok, Item::Bitmap { rgba, .. } if rgba.is_empty()), "the pixels move out");
let mut short = Item::Bitmap { rect: [0.0; 4], width: 2, height: 2, rgba: vec![0; 8] };
let mut short = Item::Bitmap { rect: [0.0; 4], width: 2, height: 2, rgba: vec![0; 8], region: None };
assert_eq!(short.take_pixels(), None);
let side = crate::MAX_BITMAP_SIDE + 1;
let mut wide = Item::Bitmap { rect: [0.0; 4], width: side, height: 1, rgba: vec![0; side as usize * 4] };
let mut wide = Item::Bitmap { rect: [0.0; 4], width: side, height: 1, rgba: vec![0; side as usize * 4], region: None };
assert_eq!(wide.take_pixels(), None);
let mut path = Item::Path { segs: Vec::new(), fill: None, stroke: None, even_odd: false };
let mut path = Item::Path { segs: Vec::new(), fill: None, stroke: None, even_odd: false, region: None };
assert_eq!(path.take_pixels(), None);
let pic = |width: f32, height: f32| crate::Picture { width, height, items: Vec::new() };
assert!(pic(1.0, 2.0).has_size());
@@ -1518,3 +1524,81 @@ fn pattern_brush_pixels_count_against_the_byte_budget() {
let p = wmf200(&recs);
assert!(p.items.is_empty(), "the parse stopped at the budget: {:?}", p.items.len());
}
fn item_region(item: &Item) -> Option<&[[f32; 4]]> {
match item {
Item::Path { region, .. } | Item::Bitmap { region, .. } | Item::Pattern { region, .. } => region.as_deref(),
Item::Text(t) => t.region.as_deref(),
}
}
#[test]
fn emf_intersect_clip_rect_limits_later_records_only() {
let full = cat(vec![e32(0), e32(0), e32(80), e32(40)]);
let clip = cat(vec![e32(10), e32(5), e32(40), e32(30)]);
let p = parse(&emf(&[erec(43, &full), erec(30, &clip), erec(43, &full)], [0, 0, 80, 40], [0; 4])).unwrap();
assert_eq!(p.items.len(), 2);
assert_eq!(item_region(&p.items[0]), None);
assert_eq!(item_region(&p.items[1]), Some(&[[10.0, 5.0, 40.0, 30.0]][..]));
}
#[test]
fn emf_clip_follows_the_world_transform_and_save_restores_it() {
let xf = cat([2.0f32, 0.0, 0.0, 2.0, 0.0, 0.0].iter().map(|v| v.to_le_bytes().to_vec()).collect());
let rect = cat(vec![e32(0), e32(0), e32(10), e32(10)]);
let clip = cat(vec![e32(0), e32(0), e32(5), e32(5)]);
let p = parse(&emf(&[erec(35, &xf), erec(30, &clip), erec(43, &rect)], [0, 0, 20, 20], [0; 4])).unwrap();
assert_eq!(item_region(&p.items[0]), Some(&[[0.0, 0.0, 10.0, 10.0]][..]));
let full = cat(vec![e32(0), e32(0), e32(40), e32(20)]);
let p =
parse(&emf(&[erec(43, &full), erec(33, &[]), erec(30, &clip), erec(43, &full), erec(34, &e32(-1)), erec(43, &full)], [0, 0, 40, 20], [0; 4]))
.unwrap();
assert_eq!(p.items.len(), 3);
assert_eq!(item_region(&p.items[0]), None);
assert_eq!(item_region(&p.items[1]), Some(&[[0.0, 0.0, 5.0, 5.0]][..]));
assert_eq!(item_region(&p.items[2]), None);
}
#[test]
fn emf_ext_select_clip_rgn_copies_and_intersects_rectangles() {
let mut data = cat(vec![h32(32), h32(1), h32(2), h32(32), e32(0), e32(0), e32(50), e32(20)]);
data.extend(cat(vec![e32(0), e32(0), e32(10), e32(20), e32(30), e32(0), e32(50), e32(20)]));
let payload = cat(vec![h32(data.len() as u32), h32(5), data]);
let rect = cat(vec![e32(0), e32(0), e32(50), e32(20)]);
let p = parse(&emf(&[erec(75, &payload), erec(43, &rect)], [0, 0, 50, 20], [0; 4])).unwrap();
assert_eq!(item_region(&p.items[0]), Some(&[[0.0, 0.0, 10.0, 20.0], [30.0, 0.0, 50.0, 20.0]][..]));
let and = cat(vec![h32(0), h32(1)]);
// A zero-length region selects the default clip, so the next rectangle is not clipped.
let p = parse(&emf(&[erec(75, &payload), erec(75, &and), erec(43, &rect)], [0, 0, 50, 20], [0; 4])).unwrap();
assert_eq!(item_region(&p.items[0]), None);
let overlap = cat(vec![e32(40), e32(0), e32(80), e32(20)]);
let p = parse(&emf(&[erec(75, &payload), erec(30, &overlap), erec(43, &rect)], [0, 0, 80, 20], [0; 4])).unwrap();
assert_eq!(item_region(&p.items[0]), Some(&[[40.0, 0.0, 50.0, 20.0]][..]));
}
#[test]
fn emf_exclude_clip_rect_cuts_the_surface() {
let hole = cat(vec![e32(20), e32(0), e32(40), e32(40)]);
let rect = cat(vec![e32(0), e32(0), e32(80), e32(40)]);
let p = parse(&emf(&[erec(29, &hole), erec(43, &rect)], [0, 0, 80, 40], [0; 4])).unwrap();
assert_eq!(item_region(&p.items[0]), Some(&[[0.0, 0.0, 20.0, 40.0], [40.0, 0.0, 80.0, 40.0]][..]));
}
#[test]
fn an_empty_clip_drops_later_drawing() {
let empty = cat(vec![e32(5), e32(5), e32(5), e32(8)]);
let rect = cat(vec![e32(0), e32(0), e32(20), e32(10)]);
let p = parse(&emf(&[erec(43, &rect), erec(30, &empty), erec(43, &rect)], [0, 0, 20, 10], [0; 4])).unwrap();
assert_eq!(p.items.len(), 1);
}
#[test]
fn wmf_intersect_clip_rect_limits_later_records() {
let clip = cat(vec![h16(30), h16(40), h16(5), h16(10)]);
let rect = cat(vec![h16(40), h16(80), h16(0), h16(0)]);
let p = parse(&wmf(&[wrec(0x0416, &clip), wrec(0x041B, &rect)], None)).unwrap();
assert_eq!(item_region(&p.items[0]), Some(&[[10.0, 5.0, 40.0, 30.0]][..]));
}
+3
View File
@@ -63,6 +63,9 @@ pub struct Text {
pub v_align: VAlign,
/// Clip rectangle `[x0, y0, x1, y1]` in picture units (ETO_CLIPPED).
pub clip: Option<[f32; 4]>,
/// Clip region from the device context, in picture units. None is the default clip. Drawn
/// together with [`Text::clip`] when both are set.
pub region: Option<Vec<[f32; 4]>>,
}
/// TA_UPDATECP: the string starts at the current position, which moves past it.
+3
View File
@@ -47,6 +47,7 @@ pub(crate) fn parse(input: Bytes) -> Result<(Canvas, Option<Frame>), Error> {
let mut cv = Canvas::new();
if let Some((c, size)) = bounds {
cv.play_into(Some([c[0], c[1], c[2] - c[0], c[3] - c[1]]), size);
cv.set_clip_surface([0.0, 0.0, size.0, size.1]);
cv.pattern_px = PT_PER_PX;
}
let mut player_viewport = bounds.is_some();
@@ -122,6 +123,8 @@ fn record(cv: &mut Canvas, func: u16, r: Bytes) {
cv.shape(&geom::round_rect(l, t, rr, b, f64::from(ew), f64::from(eh)), true, true);
})
}
0x0415 => rect16(r, 6).map(|rect| cv.exclude_clip_rect(rect)),
0x0416 => rect16(r, 6).map(|rect| cv.intersect_clip_rect(rect)),
0x0418 => rect16(r, 6).map(|[l, t, rr, b]| cv.shape(&geom::ellipse(l, t, rr, b), true, true)),
0x0817 | 0x081A | 0x0830 => arc16(r).map(|(bounds, s, e)| {
let kind = match func {
+23 -12
View File
@@ -51,7 +51,7 @@ pub fn rasterize(bytes: &[u8], side: u32) -> Option<image::RgbaImage> {
ctx.push_clip_layer(&r.to_path(0.1));
for it in pic.place(r, [0.0; 4]) {
match it {
PlacedItem::Path { path, fill, stroke, even_odd } => {
PlacedItem::Path { path, fill, stroke, even_odd, region } => clipped(&mut ctx, region.as_ref(), |ctx| {
if let Some(f) = fill {
ctx.set_paint(rgba(f));
ctx.set_fill_rule(if even_odd { peniko::Fill::EvenOdd } else { peniko::Fill::NonZero });
@@ -63,9 +63,9 @@ pub fn rasterize(bytes: &[u8], side: u32) -> Option<image::RgbaImage> {
ctx.set_stroke(kurbo::Stroke::new(sw.max(1.0)).with_caps(kurbo::Cap::Round).with_join(kurbo::Join::Round));
ctx.stroke_path(&path);
}
}
PlacedItem::Bitmap { index, rect } => {
let Some(Some(pm)) = pixmaps.get(index) else { continue };
}),
PlacedItem::Bitmap { index, rect, region } => clipped(&mut ctx, region.as_ref(), |ctx| {
let Some(Some(pm)) = pixmaps.get(index) else { return };
let (bw, bh) = (pm.width().max(1) as f64, pm.height().max(1) as f64);
ctx.set_paint(vello_cpu::Image {
image: vello_cpu::ImageSource::Pixmap(pm.clone()),
@@ -74,9 +74,9 @@ pub fn rasterize(bytes: &[u8], side: u32) -> Option<image::RgbaImage> {
ctx.set_paint_transform(Affine::translate((rect.x0, rect.y0)) * Affine::scale_non_uniform(rect.width() / bw, rect.height() / bh));
ctx.fill_rect(&rect);
ctx.reset_paint_transform();
}
PlacedItem::Pattern { index, path, even_odd, tile } => {
let Some(Item::Pattern { tile: t, .. }) = pic.items.get(index) else { continue };
}),
PlacedItem::Pattern { index, path, even_odd, tile, region } => clipped(&mut ctx, region.as_ref(), |ctx| {
let Some(Item::Pattern { tile: t, .. }) = pic.items.get(index) else { return };
let key = Arc::as_ptr(t);
let (pm, avg) = tiles.entry(key).or_insert_with(|| (premul(t.width, t.height, &t.rgba), average(&t.rgba))).clone();
ctx.set_fill_rule(if even_odd { peniko::Fill::EvenOdd } else { peniko::Fill::NonZero });
@@ -101,17 +101,17 @@ pub fn rasterize(bytes: &[u8], side: u32) -> Option<image::RgbaImage> {
ctx.fill_path(&path);
}
}
}
PlacedItem::Text { index, transform, clip } => {
let Some(Item::Text(t)) = pic.items.get(index) else { continue };
}),
PlacedItem::Text { index, transform, clip, region } => clipped(&mut ctx, region.as_ref(), |ctx| {
let Some(Item::Text(t)) = pic.items.get(index) else { return };
if let Some(c) = clip {
ctx.push_clip_layer(&c.to_path(0.1));
}
draw_text(&mut ctx, t, transform);
draw_text(ctx, t, transform);
if clip.is_some() {
ctx.pop_layer();
}
}
}),
}
}
ctx.set_fill_rule(peniko::Fill::NonZero);
@@ -133,6 +133,17 @@ pub fn rasterize(bytes: &[u8], side: u32) -> Option<image::RgbaImage> {
image::RgbaImage::from_raw(w as u32, h as u32, px)
}
/// Draws `draw` inside the device-context clip. No region is the default clip.
fn clipped(ctx: &mut RenderContext, region: Option<&BezPath>, draw: impl FnOnce(&mut RenderContext)) {
if let Some(region) = region {
ctx.push_clip_layer(region);
draw(ctx);
ctx.pop_layer();
} else {
draw(ctx);
}
}
/// One glyph placed in text space (x along the baseline from the string's start, font units at
/// `scale` text units each).
struct Glyph {
+30
View File
@@ -1711,3 +1711,33 @@ 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}");
}
#[test]
fn metafile_intersect_clip_rect_hides_the_rest_of_the_picture() {
let rec = |typ: u32, payload: &[u8]| {
let mut v = typ.to_le_bytes().to_vec();
v.extend(((8 + payload.len()) as u32).to_le_bytes());
v.extend(payload);
v
};
let rect = |l: i32, t: i32, r: i32, b: i32| [l, t, r, b].into_iter().flat_map(|v| v.to_le_bytes()).collect::<Vec<_>>();
let mut body = rec(30, &rect(0, 0, 50, 50));
body.extend(rec(43, &rect(0, 0, 100, 50)));
let eof = rec(14, &[0; 12]);
let total = 88 + body.len() + eof.len();
let mut file = vec![0u8; 88];
file[0..4].copy_from_slice(&1u32.to_le_bytes());
file[4..8].copy_from_slice(&88u32.to_le_bytes());
file[8..24].copy_from_slice(&rect(0, 0, 100, 50));
file[40..44].copy_from_slice(&0x464D_4520u32.to_le_bytes());
file[44..48].copy_from_slice(&0x0001_0000u32.to_le_bytes());
file[48..52].copy_from_slice(&(total as u32).to_le_bytes());
file[52..56].copy_from_slice(&2u32.to_le_bytes());
file[56..58].copy_from_slice(&1u16.to_le_bytes());
file.extend(body);
file.extend(eof);
let img = crate::metafile::rasterize(&file, 100).expect("raster");
let opaque = |x: u32, y: u32| img.get_pixel(x, y)[3] > 200;
assert!(opaque(20, 25), "the clipped half is drawn");
assert!(!opaque(80, 25), "past the clip the picture stays clear, pixel {:?}", img.get_pixel(80, 25));
}
+4 -3
View File
@@ -1,6 +1,6 @@
# File-format parity with PowerPoint
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** trivial (Groups with a small legacy child coordinate space draw outlines, dashes, arrowheads and ink at their own width (#233); previously major: created; formats taken from PowerPoint 16.113.4's Info.plist, PPTX coverage measured from `crates/pptx`) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (EMF/WMF rectangle clip regions; previous: trivial, groups with a small legacy child space, #233) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
Every format PowerPoint reads or writes, DeckCraft's support, fidelity and how it is tested.
Gaps are itemized in [gaps.md](gaps.md#file-formats). Dimension score: **~58% ready**
@@ -38,7 +38,7 @@ PowerPoint opens and saves; Viewer = opens only).
|---|---|---|---|
| PNG, JPEG, GIF (animated), BMP, TIFF, WebP | yes | yes | `image` 0.25; animated GIFs play since #39 |
| SVG | yes (with PNG fallback) | **no** | The PNG fallback shows; SVG files can't be inserted |
| EMF / WMF | yes | partial | Drawn as vectors (paths, pens, pattern and hatch brushes, bitmaps, text in the document's fonts with the file's character advances); clip regions and EMF+ records are not drawn. Legacy OLE objects (VML preview) and ActiveX controls show their pictures |
| EMF / WMF | yes | partial | Drawn as vectors (paths, pens, pattern and hatch brushes, bitmaps, text in the document's fonts with the file's character advances). Rectangle clip regions (`INTERSECTCLIPRECT`, `EXCLUDECLIPRECT`, `EXTSELECTCLIPRGN`) limit later drawing; EMF+ records are not drawn. Legacy OLE objects (VML preview) and ActiveX controls show their pictures |
| HEIC | yes (macOS) | no | |
| Audio: MP3, AAC/M4A, ALAC, WAV, AIFF, CAF, FLAC, Ogg Vorbis, Opus | yes (Ogg/Opus partly) | yes | Pure-Rust decoders in `crates/media`, `crates/opus` |
| WMA | yes (Windows) | embedded, not playable | |
@@ -93,12 +93,13 @@ the round trip even though DeckCraft doesn't model it.
embedded fonts, every transition and animation) in a corpus repo, with import → render → export
→ re-import checks in CI and a PowerPoint reference render for each (owner runs PowerPoint).
2. The round-trip losses above (#70, #74, #77).
3. SVG rendering, and clip regions inside EMF/WMF.
3. SVG rendering. EMF+ records inside EMF/WMF are still not drawn.
## Revision history
| Date | Change | Summary |
|---|---|---|
| 2026-10-13 | minor | EMF/WMF rectangle clip regions limit later drawing; EMF+ records remain |
| 2026-10-12 | minor | EMF/WMF text and pattern brushes drawn; OLE (VML) and ActiveX previews read |
| 2026-10-12 | minor | Text warps rendered; their adjust values read and written |
| 2026-10-12 | trivial | Groups with a small legacy child coordinate space draw outlines, dashes, arrowheads and ink at their own width (#233) |
+7 -6
View File
@@ -1,6 +1,6 @@
# Where DeckCraft falls short of PowerPoint
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (3-D bevel, camera and face lighting rendered; previous: minor, IME composition and CJK interface text landed, #101; previous: minor, core-workflow alpha gate added; stage stays alpha; before that: major, first itemized gap list; full re-measure against PowerPoint for Mac 16.113.4 and the open GitHub issues) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (EMF/WMF rectangle clip regions; previous: minor, 3-D bevel, camera and face lighting rendered) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
Every known shortfall, one entry each, ranked by how much it stops someone who presents with
PowerPoint from switching. This is the work list: pick from the top. Numbers are summarized in
@@ -20,7 +20,7 @@ revision history.
| 1 | PPTX never tested against a corpus of real decks | File format | B | 20–35 | [file-format-parity.md](file-format-parity.md) |
| 2 | Data loss and crashes on common paths (#60, #40, #59) | Stability | B | 6–12 | this file |
| 3 | PPTX round trip drops comment threads, media options, rewind (#70, #74, #77) | File format | B | 3–6 | [file-format-parity.md](file-format-parity.md) |
| 4 | SVG pictures don't render (EMF/WMF do) | File format | B | 6–12 | [file-format-parity.md](file-format-parity.md) |
| 4 | SVG pictures don't render (EMF/WMF do) | File format | B | 4–8 | [file-format-parity.md](file-format-parity.md) |
| 5 | No localization: English only | Localization | B (infra + 5 languages) | 55–90 | [localization-parity.md](localization-parity.md) |
| 6 | No printing | Feature | B | 5–8 | [file-format-parity.md](file-format-parity.md) |
| 7 | Fonts: only Regular, Bold and Italic faces (#69) | Feature | B | 3–5 | [ui-parity.md](ui-parity.md) |
@@ -78,11 +78,11 @@ Detail in [file-format-parity.md](file-format-parity.md).
### SVG pictures don't render
- **Evidence:** `crates/render/src/images.rs` decodes through `image` 0.25 (PNG, JPEG, WebP, GIF,
TIFF, BMP), and EMF/WMF through `deckcraft-metafile` (ported from WordCraft: paths, fills, pens,
pattern and hatch brushes, bitmaps and text; clip regions and EMF+ records are not drawn).
pattern and hatch brushes, bitmaps and text; rectangle clip regions are applied, EMF+ records are
not drawn).
Nothing reads SVG; PowerPoint's Insert Icons stores SVG with a PNG fallback.
- **Impact:** SVG icons show only their raster fallback, and SVG files can't be inserted. An
EMF/WMF that clips its drawing (EXTSELECTCLIPRGN, INTERSECTCLIPRECT) draws past its clip.
- **Estimate:** 4–8 h (an SVG renderer through `resvg`-style crates); EMF/WMF clip regions 2–4 h.
- **Impact:** SVG icons show only their raster fallback, and SVG files can't be inserted.
- **Estimate:** 4–8 h (an SVG renderer through `resvg`-style crates).
### Template, slide-show and macro variants are saved as plain `.pptx`
- **Evidence:** `crates/pptx/src/write/mod.rs` always writes the
@@ -256,6 +256,7 @@ Detail in [localization-parity.md](localization-parity.md).
| Date | Change | Summary |
|---|---|---|
| 2026-10-13 | minor | EMF/WMF rectangle clip regions (`INTERSECTCLIPRECT`, `EXCLUDECLIPRECT`, `EXTSELECTCLIPRGN`) limit later drawing. EMF+ records remain |
| 2026-10-13 | minor | Shape, picture and table rendering checked against PowerPoint on ~150 synthetic edge-case slides; 3-D bevels, cameras and face lighting now render approximately (gap narrowed to extrusion, rigs and the 3-D pane) |
| 2026-10-12 | minor | IME composition in slide text and notes, and CJK interface text with real glyphs (#101): the IME gap is now Korean on the web and table-cell underlines |
| 2026-10-12 | minor | EMF/WMF text, pattern and hatch brushes drawn; legacy OLE and ActiveX previews shown; the SVG gap keeps only EMF clip regions |