From e28851d2f1e561decbf220e237ac2f4a1f3223bb Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 12 Oct 2026 22:40:56 -0400 Subject: [PATCH] Pictures: WMF/EMF text, pattern and hatch brushes draw; ActiveX previews show (#336) * ActiveX controls show their pictures A slide's p:controls are read: each control with a p:pic (the image PowerPoint shows for it, often a WMF) becomes a picture above the slide's shapes. Co-Authored-By: Claude Opus 5.5 (1M context) * Metafiles draw their text, pattern and hatch brushes deckcraft-metafile (still kurbo + thiserror only, so it builds for wasm): - Text: WMF EXTTEXTOUT/TEXTOUT and EMF EXTTEXTOUTW/EXTTEXTOUTA/SMALLTEXTOUT with the selected font (CREATEFONTINDIRECT, EXTCREATEFONTINDIRECTW: face, signed LOGFONT height, width, weight, italic, underline, strike-out, escapement, charset), text and background colour, background mode, TA_* alignment and TA_UPDATECP, the dx array (ETO_PDY pairs too), ETO_OPAQUE fills and ETO_CLIPPED clips. 8-bit strings decode through their charset (Windows-1252/1250/1251; symbol fonts keep their codes at U+F000; double-byte sets keep their advances). The neutral Item::Text carries a text-space-to-picture transform, so the mapping and escapement are folded in and glyphs are never mirrored. - Brushes: WMF DIBCREATEPATTERNBRUSH, EMF CREATEDIBPATTERNBRUSHPT and CREATEMONOBRUSH, and BS_HATCHED, as Item::Pattern tiles in 96-dpi pixels; WMF PATBLT. The XOR / AND-pattern / XOR see-through shadow of old PowerPoint WMFs draws its pattern in the XOR colour. - MFCOMMENT escapes with an embedded EMF are ignored: PowerPoint draws the WMF's records. - Pattern pixels and text count against the byte budget; strings are capped. deckcraft-render draws text through deckcraft-fonts with the face slide text would use (document_face), symbol fonts mapped as bullets map them (deckcraft_text::symbol_char, now public, with Webdings), per-character fallback, the file's advances, GDI cell metrics for top/bottom alignment, underline/strike-out and synthetic bold/italic. Tiles below 1.5 output pixels per cell are drawn as their average colour. On the 24 corpus slides that show a metafile, mean abs diff against PowerPoint goes from 28.31 to 5.06 (with the OLE/ActiveX preview commits). Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --- ROADMAP.md | 4 +- crates/metafile/src/canvas.rs | 399 +++++++++++++++++++++++--- crates/metafile/src/emf.rs | 122 +++++++- crates/metafile/src/lib.rs | 73 ++++- crates/metafile/src/place.rs | 26 ++ crates/metafile/src/tests.rs | 504 ++++++++++++++++++++++++++++++++- crates/metafile/src/text.rs | 200 +++++++++++++ crates/metafile/src/wmf.rs | 121 +++++++- crates/pptx/src/read/mod.rs | 4 + crates/pptx/src/read/shapes.rs | 16 ++ crates/pptx/tests/ole_vml.rs | 84 ++++++ crates/render/src/metafile.rs | 259 ++++++++++++++++- crates/render/src/tests.rs | 175 ++++++++++++ crates/text/src/lib.rs | 28 ++ crates/text/src/tests.rs | 11 + docs/file-format-parity.md | 5 +- docs/gaps.md | 15 +- 17 files changed, 1979 insertions(+), 67 deletions(-) create mode 100644 crates/metafile/src/text.rs create mode 100644 crates/pptx/tests/ole_vml.rs diff --git a/ROADMAP.md b/ROADMAP.md index c93e1fd..813474d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -129,12 +129,10 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | What landed | |---|---| | 2026-10-12 | Objects off the slide show and can be selected and moved around it in Normal view, as in PowerPoint; the show, exports and thumbnails still clip to the slide (#270) | - | 2026-10-12 | Korean, Japanese and Chinese input: IME composition shown in place and underlined, one undo step per composition, candidate window at the caret (slide text, notes, fields); interface text (title, notes, outline, sections, comments) borrows installed fonts for scripts the interface fonts lack (#101) | - | 2026-10-12 | Selection Pane like PowerPoint (#133): front-most first, groups fold, drag and drop restacks among siblings, Bring Forward / Send Backward, Show All / Hide All, eye, rename in place, keyboard; Lock (pane padlock, canvas menu) keeps objects from moving, sizing or rotating and round-trips PowerPoint's locking attributes | - | 2026-10-12 | Text keys like PowerPoint for Windows (#268): Ctrl+arrows and Ctrl+Shift+arrows by word (punctuation, CJK, emoji, RTL), Ctrl+Backspace/Delete with smart spacing, Ctrl+Up/Down by paragraph, Ctrl+Home/End, End on wrapped lines, Ctrl+]/[ and Ctrl+Shift+>/< font size, Shift+F3 case cycle, word and paragraph selection | +| 2026-10-12 | EMF/WMF pictures draw their text (fonts, alignment, dx advances, escapement, symbol fonts), pattern and hatch brushes, PATBLT and the XOR/AND see-through shadow; legacy OLE objects show their VML preview and ActiveX controls their pictures | | 2026-10-12 | WordArt text warps (`prstTxWarp`): all 40 presets drawn, curves fitted to PowerPoint by observation; warp adjusts round-trip | | 2026-10-10 | Progress docs restructured to the craftrules standard; full re-measure (breadth 79%, ready ~55%, mainstream ~53%, essentials ~62%, alpha). Landed: Animation Painter (#54), Morph geometry outlines (#27), animated GIF playback (#39), hyperlink colour and following links in the show (#37, #45), date fields in their own format (#36), flipped shapes keep readable text (#35), show transition fix (#46), start screen from the Home button (#49), MCP export lists PDF (#65), OmaStore manifest (#57) | | 2026-10-09 | v0.4.0 released. UAX #9 bidi and Arabic shaping (#4), Morph with Words and Characters (#5), tables grow to fit and PDF cell text (#14), rotating log file (#7), DX12 default on Windows (#16), H.264/HEVC overflow hardening (#2), PPTX sniffing anywhere in the zip (#19) | diff --git a/crates/metafile/src/canvas.rs b/crates/metafile/src/canvas.rs index fa8bfdc..4ffa56b 100644 --- a/crates/metafile/src/canvas.rs +++ b/crates/metafile/src/canvas.rs @@ -2,8 +2,11 @@ //! transform, pen and brush selection through an object table, the DC save stack and path brackets. //! Output is device-space items; `lib.rs` maps them into the picture frame. +use std::sync::{Arc, OnceLock}; + use crate::dib::Rgba; use crate::geom::{Op, P}; +use crate::text::{ETO_CLIPPED, ETO_OPAQUE, Font, TA_UPDATECP, Text, align}; use crate::{MAX_BYTES, MAX_ITEMS, MAX_OBJECTS, MAX_PATH_OPS, MAX_STACK}; pub(crate) const MM_TEXT: u32 = 1; @@ -15,9 +18,21 @@ pub(crate) const PS_NULL: u32 = 5; pub(crate) const PS_STYLE_MASK: u32 = 0x0F; const BS_SOLID: u32 = 0; pub(crate) const BS_NULL: u32 = 1; +pub(crate) const BS_HATCHED: u32 = 2; +/// BS_PATTERN, BS_DIBPATTERN and BS_DIBPATTERNPT: the brush paints a bitmap tile. +const BS_PATTERN: u32 = 3; +const BS_DIBPATTERN: u32 = 5; +const BS_DIBPATTERNPT: u32 = 6; const WINDING: u32 = 2; -/// SETROP2 mix mode that leaves the destination alone: outlines and fills draw nothing. +/// SETROP2 mix modes. R2_NOP leaves the destination alone: outlines and fills draw nothing. +/// R2_XORPEN and R2_MASKPEN draw the classic see-through shadow (XOR a colour, AND a pattern, XOR +/// the colour again); the others draw as R2_COPYPEN. +const R2_XORPEN: u32 = 7; +const R2_MASKPEN: u32 = 9; const R2_NOP: u32 = 11; +const R2_COPYPEN: u32 = 13; +/// SETBKMODE OPAQUE: text cells and hatch gaps are filled with the background colour. +const OPAQUE: u32 = 2; const PATCOPY: u32 = 0x00F0_0021; const BLACKNESS: u32 = 0x0000_0042; const WHITENESS: u32 = 0x00FF_0062; @@ -48,20 +63,78 @@ pub(crate) struct Pen { pub color: [u8; 4], } -#[derive(Clone, Copy, PartialEq, Debug)] +#[derive(Clone, PartialEq, Debug)] pub(crate) struct Brush { pub style: u32, pub color: [u8; 4], + /// HS_* hatch style of a BS_HATCHED brush. + pub hatch: u32, + /// The tile of a pattern brush. + pub pattern: Option>, } -#[derive(Clone, Copy, Debug)] +impl Brush { + pub fn new(style: u32, color: [u8; 4]) -> Self { + Brush { style, color, hatch: 0, pattern: None } + } + + /// A pattern brush painting `img`. A monochrome brush paints its dark pixels in the text + /// colour and its light ones in the background colour. None for an empty or oversized tile. + pub fn pattern(img: Rgba, mono: bool) -> Option { + if img.w == 0 || img.h == 0 || img.w > MAX_TILE_SIDE || img.h > MAX_TILE_SIDE || img.px.len() != img.w * img.h * 4 { + return None; + } + let pattern = Arc::new(Pattern { w: img.w, h: img.h, px: img.px, mono, plain: OnceLock::new() }); + Some(Brush { style: BS_DIBPATTERNPT, color: [0, 0, 0, 255], hatch: 0, pattern: Some(pattern) }) + } +} + +/// A pattern brush's tile, top-down RGBA. +#[derive(PartialEq, Debug)] +pub(crate) struct Pattern { + pub w: usize, + pub h: usize, + pub px: Vec, + pub mono: bool, + /// The tile as drawn when nothing recolours it, shared by every fill with this brush. + pub plain: OnceLock>, +} + +/// Largest pattern tile side kept (GDI brushes are typically 8×8). +pub(crate) const MAX_TILE_SIDE: usize = 256; + +#[derive(Clone, Debug)] pub(crate) enum Obj { Pen(Pen), Brush(Brush), - /// Fonts, palettes, pattern brushes and regions: they take a table slot but draw nothing. + Font(Arc), + /// Palettes and regions: they take a table slot but draw nothing. Other, } +/// A font object: the neutral [`Font`] and its escapement (tenths of a degree, counter-clockwise), +/// which the text transform takes. +#[derive(Debug, Clone, PartialEq, Default)] +pub(crate) struct LogFont { + pub font: Font, + pub escapement: f64, +} + +/// A brush tile in device units: `cell` device units per tile pixel, laid from the device origin. +#[derive(Debug, PartialEq)] +pub(crate) struct RawTile { + pub w: usize, + pub h: usize, + pub px: Vec, + pub cell: f64, +} + +/// What a fill paints. +enum Paint { + Solid([u8; 4]), + Tile(Arc), +} + /// Converts a COLORREF (0x00BBGGRR) to opaque RGBA. pub(crate) fn colorref(c: u32) -> [u8; 4] { [(c & 0xFF) as u8, ((c >> 8) & 0xFF) as u8, ((c >> 16) & 0xFF) as u8, 255] @@ -166,8 +239,34 @@ fn ratio(num: f64, den: f64) -> f64 { /// Output of drawing, in device units. Path points are already mapped; `lib.rs` normalises them. pub(crate) enum Raw { - Path { ops: Vec, fill: Option<[u8; 4]>, stroke: Option<([u8; 4], f64)>, even_odd: bool }, - Bitmap { rect: [f64; 4], img: Rgba }, + Path { + ops: Vec, + fill: Option<[u8; 4]>, + stroke: Option<([u8; 4], f64)>, + even_odd: bool, + }, + /// A path filled with a repeating tile. + Pattern { + ops: Vec, + even_odd: bool, + tile: Arc, + }, + Bitmap { + rect: [f64; 4], + img: Rgba, + }, + /// Text whose transform and clip are in device units. + Text(Box), +} + +/// Text drawing state: colours, background mode, alignment and the selected font. +#[derive(Clone)] +struct TextState { + color: [u8; 4], + bk_color: [u8; 4], + bk_opaque: bool, + align: u32, + font: Option>, } #[derive(Clone)] @@ -176,7 +275,8 @@ struct Saved { pen: Pen, brush: Brush, even_odd: bool, - nop: bool, + rop2: u32, + text: TextState, } pub(crate) struct Canvas { @@ -193,8 +293,13 @@ pub(crate) struct Canvas { pen: Pen, brush: Brush, even_odd: bool, - /// The mix mode is R2_NOP: shapes draw nothing. - nop: bool, + /// SETROP2 mix mode. + rop2: u32, + /// The colour of the last fill drawn with R2_XORPEN, for the see-through shadow idiom. + xor_color: Option<[u8; 4]>, + text: TextState, + /// Device units per pattern-brush pixel: brushes are laid out in 96-dpi pixels. + pub pattern_px: f64, pos: P, in_path: bool, path: Vec, @@ -212,9 +317,12 @@ impl Canvas { used: 0, objects: Vec::new(), pen: Pen { style: 0, width: 0.0, color: [0, 0, 0, 255] }, - brush: Brush { style: BS_SOLID, color: [255, 255, 255, 255] }, + brush: Brush::new(BS_SOLID, [255, 255, 255, 255]), even_odd: true, - nop: false, + rop2: R2_COPYPEN, + xor_color: None, + text: TextState { color: [0, 0, 0, 255], bk_color: [255, 255, 255, 255], bk_opaque: true, align: 0, font: None }, + pattern_px: 1.0, pos: (0.0, 0.0), in_path: false, path: Vec::new(), @@ -230,7 +338,14 @@ impl Canvas { fn push(&mut self, raw: Raw) { let cost = match &raw { Raw::Path { ops, .. } => ops.len() * std::mem::size_of::(), + // A tile shared with its brush is counted once (by the brush); one made for this fill + // counts here. + Raw::Pattern { ops, tile, .. } => { + let own = if Arc::strong_count(tile) == 1 { tile.px.len() } else { 0 }; + ops.len() * std::mem::size_of::() + 64 + own + } Raw::Bitmap { img, .. } => img.px.len(), + Raw::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; @@ -288,9 +403,31 @@ impl Canvas { self.even_odd = mode != WINDING; } - /// SETROP2: only R2_NOP changes what is drawn (nothing); the other mix modes draw as copy. + /// SETROP2. R2_NOP draws nothing; R2_XORPEN and R2_MASKPEN are read as the see-through shadow + /// idiom (see [`Canvas::fill_paint`]); the other mix modes draw as copy. pub fn set_rop2(&mut self, mode: u32) { - self.nop = mode == R2_NOP; + self.rop2 = mode; + } + + pub fn set_text_color(&mut self, c: u32) { + self.text.color = colorref(c); + } + + pub fn set_bk_color(&mut self, c: u32) { + self.text.bk_color = colorref(c); + } + + pub fn set_bk_mode(&mut self, mode: u32) { + self.text.bk_opaque = mode == OPAQUE; + } + + pub fn set_text_align(&mut self, ta: u32) { + self.text.align = ta; + } + + /// The character set of the selected font (ANSI without one). + pub fn font_charset(&self) -> u8 { + self.text.font.as_ref().map_or(0, |f| f.font.charset) } /// EMF SETWORLDTRANSFORM (`mode` 4) and MODIFYWORLDTRANSFORM (1 identity, 2 left, 3 right, 4 set). @@ -309,7 +446,14 @@ impl Canvas { /// could no longer match the file. pub fn save_dc(&mut self) { if self.saved.len() < MAX_STACK { - self.saved.push(Saved { map: self.map, pen: self.pen, brush: self.brush, even_odd: self.even_odd, nop: self.nop }); + self.saved.push(Saved { + map: self.map, + pen: self.pen, + brush: self.brush.clone(), + even_odd: self.even_odd, + rop2: self.rop2, + text: self.text.clone(), + }); } else { self.full = true; } @@ -325,11 +469,21 @@ impl Canvas { self.pen = s.pen; self.brush = s.brush; self.even_odd = s.even_odd; - self.nop = s.nop; + self.rop2 = s.rop2; + self.text = s.text; } /// Stores an object in `slot` (EMF index), or in the first free slot (WMF handle table). pub fn add_object(&mut self, slot: Option, obj: Obj) { + // A pattern brush's pixels count against the byte budget like drawn items. + if let Obj::Brush(Brush { pattern: Some(p), .. }) = &obj { + let cost = p.px.len(); + if self.used.saturating_add(cost) > MAX_BYTES { + self.full = true; + return; + } + self.used += cost; + } match slot { Some(i) if i < MAX_OBJECTS => { if self.objects.len() <= i { @@ -363,9 +517,10 @@ impl Canvas { if i & STOCK != 0 { return self.select_stock(i & !STOCK); } - match self.objects.get(i as usize).copied().flatten() { + match self.objects.get(i as usize).cloned().flatten() { Some(Obj::Pen(p)) => self.pen = p, Some(Obj::Brush(b)) => self.brush = b, + Some(Obj::Font(f)) => self.text.font = Some(f), Some(Obj::Other) | None => {} } } @@ -373,17 +528,19 @@ impl Canvas { fn select_stock(&mut self, i: u32) { let white = [255, 255, 255, 255]; let black = [0, 0, 0, 255]; - let gray = |v: u8| Brush { style: BS_SOLID, color: [v, v, v, 255] }; + let gray = |v: u8| Brush::new(BS_SOLID, [v, v, v, 255]); match i { - 0 | 18 => self.brush = Brush { style: BS_SOLID, color: white }, + 0 | 18 => self.brush = Brush::new(BS_SOLID, white), 1 => self.brush = gray(192), 2 => self.brush = gray(128), 3 => self.brush = gray(64), - 4 => self.brush = Brush { style: BS_SOLID, color: black }, - 5 => self.brush = Brush { style: BS_NULL, color: black }, + 4 => self.brush = Brush::new(BS_SOLID, black), + 5 => self.brush = Brush::new(BS_NULL, black), 6 => self.pen = Pen { style: 0, width: 0.0, color: white }, 7 | 19 => self.pen = Pen { style: 0, width: 0.0, color: black }, 8 => self.pen = Pen { style: PS_NULL, width: 0.0, color: black }, + // The stock fonts (OEM_FIXED_FONT … DEFAULT_GUI_FONT): drawn in the default face. + 10..=17 => self.text.font = None, _ => {} } } @@ -397,8 +554,81 @@ impl Canvas { } } - fn fill_color(&self) -> Option<[u8; 4]> { - (self.brush.style == BS_SOLID).then_some(self.brush.color) + /// The current brush's paint. Hatch and monochrome brushes take the background colour (when + /// the background mode is opaque) and the text colour. Under R2_MASKPEN (destination AND + /// brush) a tile's white pixels keep what is below, and its black ones take the colour of the + /// XOR fill before it: XOR, AND, XOR again leaves that colour where the tile is black. + fn fill_paint(&self) -> Option { + let b = &self.brush; + match b.style { + BS_SOLID => Some(Paint::Solid(b.color)), + BS_HATCHED => { + let bg = self.text.bk_opaque.then_some(self.text.bk_color); + let px = hatch_tile(b.hatch, b.color, bg)?; + Some(Paint::Tile(Arc::new(RawTile { w: 8, h: 8, px, cell: self.pattern_px }))) + } + BS_PATTERN | BS_DIBPATTERN | BS_DIBPATTERNPT => { + let p = b.pattern.as_ref()?; + if !p.mono && self.rop2 != R2_MASKPEN { + let cell = self.pattern_px; + return Some(Paint::Tile(p.plain.get_or_init(|| Arc::new(RawTile { w: p.w, h: p.h, px: p.px.clone(), cell })).clone())); + } + let mut px = p.px.clone(); + for c in px.as_chunks_mut::<4>().0 { + if p.mono { + let light = u16::from(c[0]) + u16::from(c[1]) + u16::from(c[2]) >= 384; + let v = if light { self.text.bk_color } else { self.text.color }; + *c = [v[0], v[1], v[2], 255]; + } + if self.rop2 == R2_MASKPEN { + if c[..3] == [255, 255, 255] { + c[3] = 0; + } else if c[..3] == [0, 0, 0] + && let Some(x) = self.xor_color + { + *c = x; + } + } + } + Some(Paint::Tile(Arc::new(RawTile { w: p.w, h: p.h, px, cell: self.pattern_px }))) + } + _ => None, + } + } + + /// Pushes device ops filled with `fill` and stroked with `stroke`: a tile fill and its outline + /// are two items. + fn push_shape(&mut self, ops: Vec, fill: Option, stroke: Option<([u8; 4], f64)>) { + let even_odd = self.even_odd; + 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 }); + } else { + self.push(Raw::Pattern { ops, even_odd, tile }); + } + } + 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 }), + None => {} + } + } + + /// Whether the mix mode suppresses drawing: R2_NOP draws nothing, and R2_XORPEN shapes only + /// remember their fill colour (an XOR pair cancels; the see-through shadow idiom draws its + /// pattern in that colour). + fn mix_skips(&mut self, fill: bool) -> bool { + match self.rop2 { + R2_NOP => true, + R2_XORPEN => { + if fill && self.brush.style == BS_SOLID { + self.xor_color = Some(self.brush.color); + } + true + } + _ => false, + } } fn stroke_pen(&self) -> Option<([u8; 4], f64)> { @@ -426,16 +656,12 @@ impl Canvas { if self.in_path { return self.push_path_ops(dev); } - if self.nop { + if self.mix_skips(fill) || dev.len() < 2 { return; } - let fill = if fill { self.fill_color() } else { None }; + let fill = if fill { self.fill_paint() } else { None }; let stroke = if stroke { self.stroke_pen() } else { None }; - if (fill.is_none() && stroke.is_none()) || dev.len() < 2 { - return; - } - let even_odd = self.even_odd; - self.push(Raw::Path { ops: dev, fill, stroke, even_odd }); + self.push_shape(dev, fill, stroke); } pub fn move_to(&mut self, p: P) { @@ -508,23 +734,22 @@ impl Canvas { /// FILLPATH, STROKEPATH and STROKEANDFILLPATH: paints the bracketed path and empties it. pub fn paint_path(&mut self, fill: bool, stroke: bool) { let ops = std::mem::take(&mut self.path); - if self.nop { + if self.mix_skips(fill) || ops.len() < 2 { return; } - let fill = if fill { self.fill_color() } else { None }; + let fill = if fill { self.fill_paint() } else { None }; let stroke = if stroke { self.stroke_pen() } else { None }; - if ops.len() < 2 || (fill.is_none() && stroke.is_none()) { - return; - } - let even_odd = self.even_odd; - self.push(Raw::Path { ops, fill, stroke, even_odd }); + self.push_shape(ops, fill, stroke); } - /// Fills a logical rectangle `[l, t, r, b]` with the current brush (nothing when it is not solid). - pub fn fill_brush_rect(&mut self, rect: [f64; 4]) { - if let Some(color) = self.fill_color() { - self.fill_rect_with(rect, color); + /// Fills a logical rectangle `[l, t, r, b]` with the current brush (PATBLT, PATCOPY blits). + pub fn fill_brush_rect(&mut self, [l, t, r, b]: [f64; 4]) { + if self.mix_skips(true) { + return; } + let fill = self.fill_paint(); + let dev: Vec = crate::geom::rect(l, t, r, b).iter().map(|o| self.map_op(*o)).collect(); + self.push_shape(dev, fill, None); } /// Fills a logical rectangle `[l, t, r, b]` with a solid colour (ROP fills such as BLACKNESS). @@ -550,6 +775,75 @@ impl Canvas { } } + /// Draws `text` (one `dx` advance per character, or none) with the current font, colours and + /// alignment. The reference point is `at`, or the current position under TA_UPDATECP (which + /// then moves past the string). `rect` (logical `[l, t, r, b]`) is filled with the background + /// colour under ETO_OPAQUE and clips the text under ETO_CLIPPED. Text ignores the mix mode, as + /// GDI's does. + pub fn text(&mut self, at: P, text: String, dx: Vec, options: u32, rect: Option<[f64; 4]>) { + if let (true, Some(r)) = (options & ETO_OPAQUE != 0, rect) { + self.fill_rect_with(r, self.text.bk_color); + } + let lf = self.text.font.as_deref().cloned().unwrap_or_default(); + let update = self.text.align & TA_UPDATECP != 0; + let origin = if update { self.pos } else { at }; + let (h_align, v_align) = align(self.text.align); + let o = self.map.device(origin); + let ux = self.map.device((origin.0 + 1.0, origin.1)); + let uy = self.map.device((origin.0, origin.1 + 1.0)); + let (ux, uy) = ((ux.0 - o.0, ux.1 - o.1), (uy.0 - o.0, uy.1 - o.1)); + if update { + // The current position moves along the baseline past the string (back past it when + // right-aligned). Without a dx array the advance is estimated from the height. + let adv: f64 = if dx.is_empty() { + f64::from(lf.font.height.abs().max(1.0)) * 0.5 * text.chars().count() as f64 + } else { + dx.iter().map(|d| f64::from(*d)).sum() + }; + let adv = match h_align { + crate::HAlign::Left => adv, + crate::HAlign::Right => -adv, + crate::HAlign::Center => 0.0, + }; + let theta = (lf.escapement / 10.0).to_radians(); + let y_sign = if uy.1 < 0.0 { 1.0 } else { -1.0 }; + let p = (origin.0 + adv * theta.cos(), origin.1 + adv * theta.sin() * y_sign); + if p.0.is_finite() && p.1.is_finite() { + self.pos = p; + } + } + let (sx, sy) = (ux.0.hypot(ux.1), uy.0.hypot(uy.1)); + let drawable = !text.is_empty() && [sx, sy, o.0, o.1].iter().all(|v| v.is_finite()) && sx > 0.0 && sy > 0.0; + if !drawable { + return; + } + // The baseline runs along the device direction of the logical x axis (rotated by a world + // transform; a mirroring mapping does not mirror glyphs), turned counter-clockwise by the + // escapement. + let det = ux.0 * uy.1 - ux.1 * uy.0; + let phi = if det > 0.0 { ux.1.atan2(ux.0) } else { ux.1.atan2(ux.0.abs()) }; + let a = phi - (lf.escapement / 10.0).to_radians(); + let (c, s) = (a.cos(), a.sin()); + let clip = match (options & ETO_CLIPPED != 0, rect) { + (true, Some([l, t, r, b])) => { + let (p, q) = (self.map.device((l, t)), self.map.device((r, b))); + Some([p.0.min(q.0) as f32, p.1.min(q.1) as f32, p.0.max(q.0) as f32, p.1.max(q.1) as f32]) + } + _ => 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, + }))); + } + /// A bitmap stretched into a logical rectangle `[x, y, w, h]`. A negative extent mirrors the /// bitmap, as the mapping does. pub fn bitmap(&mut self, rect: [f64; 4], mut img: Rgba) { @@ -562,6 +856,31 @@ impl Canvas { } } +/// An 8×8 hatch tile (HS_HORIZONTAL, HS_VERTICAL, HS_FDIAGONAL, HS_BDIAGONAL, HS_CROSS, +/// HS_DIAGCROSS): one-pixel lines in `fg` over `bg`, or over nothing when the background is +/// transparent. None for an unknown style. +pub(crate) fn hatch_tile(style: u32, fg: [u8; 4], bg: Option<[u8; 4]>) -> Option> { + if style > 5 { + return None; + } + let mut px = Vec::with_capacity(8 * 8 * 4); + for y in 0..8u32 { + for x in 0..8u32 { + let (h, v, f, b) = (y == 7, x == 7, x == y, x + y == 7); + let on = match style { + 0 => h, + 1 => v, + 2 => f, + 3 => b, + 4 => h || v, + _ => f || b, + }; + px.extend(if on { fg } else { bg.unwrap_or([0, 0, 0, 0]) }); + } + } + Some(px) +} + /// Cubic segments from groups of three points (control, control, end). A trailing partial group is /// dropped. fn cubics(pts: &[P]) -> Vec { diff --git a/crates/metafile/src/emf.rs b/crates/metafile/src/emf.rs index 027cfae..709537f 100644 --- a/crates/metafile/src/emf.rs +++ b/crates/metafile/src/emf.rs @@ -1,12 +1,20 @@ //! EMF reader (MS-EMF). Records are (type, size in bytes) followed by a payload; offsets inside a //! record (bitmap info and bits) are relative to the record start, so each handler reads the record slice. +use std::sync::Arc; + use crate::bytes::Bytes; -use crate::canvas::{BS_NULL, Brush, Canvas, Obj, PS_NULL, PS_STYLE_MASK, Pen, Xform, colorref}; +use crate::canvas::{BS_HATCHED, BS_NULL, Brush, Canvas, LogFont, Obj, PS_NULL, PS_STYLE_MASK, Pen, Xform, colorref}; use crate::dib::{self, Blend}; use crate::geom::{self, ArcKind, P}; +use crate::text::{ETO_GLYPH_INDEX, ETO_PDY, Font, MAX_CHARS, decode_bytes, decode_utf16}; use crate::{Error, Frame, MAX_PATH_OPS}; +/// ExtTextOut options: no rectangle in the record (EMR_SMALLTEXTOUT, EmrText), and 8-bit +/// characters (EMR_SMALLTEXTOUT). +const ETO_NO_RECT: u32 = 0x0100; +const ETO_SMALL_CHARS: u32 = 0x0200; + const EMF_SIGNATURE: u32 = 0x464D_4520; /// Hundredths of a millimetre to points. const HMM_TO_PT: f64 = 72.0 / 2540.0; @@ -100,6 +108,14 @@ fn record(cv: &mut Canvas, typ: u32, r: Bytes) { 38 => create_pen(cv, r), 95 => create_ext_pen(cv, r), 39 => create_brush(cv, r), + 93 | 94 => create_dib_brush(cv, r, typ == 93), + 82 => create_font(cv, r), + 18 => r.u32(8).map(|m| cv.set_bk_mode(m)), + 22 => r.u32(8).map(|a| cv.set_text_align(a)), + 24 => r.u32(8).map(|c| cv.set_text_color(c)), + 25 => r.u32(8).map(|c| cv.set_bk_color(c)), + 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)), 27 => xy32(r, 8).map(|p| cv.move_to(p)), 54 => xy32(r, 8).map(|p| cv.line_to(p)), @@ -246,7 +262,109 @@ fn create_brush(cv: &mut Canvas, r: Bytes) -> Option<()> { let slot = r.u32(8)? as usize; let style = r.u32(12)?; let color = r.u32(16)?; - cv.add_object(Some(slot), Obj::Brush(Brush { style, color: colorref(color) })); + let mut b = Brush::new(style, colorref(color)); + if style == BS_HATCHED { + b.hatch = r.u32(20).unwrap_or(0); + } + cv.add_object(Some(slot), Obj::Brush(b)); + Some(()) +} + +/// EMR_CREATEDIBPATTERNBRUSHPT (94) and EMR_CREATEMONOBRUSH (93): handle, colour usage, then the +/// bitmap info and bits as offset/size pairs. A mono brush (or one whose palette holds indices, +/// DIB_PAL_COLORS) takes the text and background colours. An undecodable bitmap leaves the slot +/// empty. +fn create_dib_brush(cv: &mut Canvas, r: Bytes, mono: bool) -> Option<()> { + let slot = r.u32(8)? as usize; + let usage = r.u32(12)?; + let (bo, bl) = (r.u32(16)? as usize, r.u32(20)? as usize); + let (po, pl) = (r.u32(24)? as usize, r.u32(28)? as usize); + let img = dib::decode(Bytes(r.slice(bo, bl)?), Bytes(r.slice(po, pl)?), false)?; + let b = Brush::pattern(img, mono || usage == 1)?; + cv.add_object(Some(slot), Obj::Brush(b)); + Some(()) +} + +/// EMR_EXTCREATEFONTINDIRECTW: handle, then a LogFont (MS-EMF 2.2.13): height, width, escapement, +/// orientation, weight (i32 each), italic, underline, strike-out, charset and four more bytes, then +/// the face name in 32 UTF-16 units (NUL-terminated). +fn create_font(cv: &mut Canvas, r: Bytes) -> Option<()> { + let slot = r.u32(8)? as usize; + let height = r.i32(12)?; + let width = r.i32(16).unwrap_or(0); + let escapement = r.i32(20).unwrap_or(0); + let weight = r.i32(28).unwrap_or(0); + let byte = |o: usize| r.0.get(o).copied().unwrap_or(0); + let units: Vec = (0..32).map_while(|i| r.u16(40 + 2 * i)).take_while(|u| *u != 0).collect(); + let (family, _) = decode_utf16(&units, &[]); + let font = Font { + family: family.trim().to_string(), + height: height as f32, + width: width as f32, + weight: weight.clamp(0, 1000) as u16, + italic: byte(32) != 0, + underline: byte(33) != 0, + strike: byte(34) != 0, + charset: byte(35), + }; + cv.add_object(Some(slot), Obj::Font(Arc::new(LogFont { font, escapement: f64::from(escapement) }))); + Some(()) +} + +/// The dx array at `off` for `n` characters: i32 advances, or (dx, dy) pairs under ETO_PDY (only +/// dx is used). Empty when absent or short. +fn dx_array(r: Bytes, off: usize, n: usize, opts: u32) -> Vec { + let step = if opts & ETO_PDY != 0 { 8 } else { 4 }; + if off == 0 { + return Vec::new(); + } + let dx: Vec = (0..n).map_while(|i| r.i32(off.checked_add(i.checked_mul(step)?)?)).map(|v| v as f32).collect(); + if dx.len() == n { dx } else { Vec::new() } +} + +/// EMR_EXTTEXTOUTA (83) and EMR_EXTTEXTOUTW (84): bounds, graphics mode and scales, then an EmrText +/// at 36: reference point, character count, string offset, options, the rectangle (absent under +/// ETO_NO_RECT) and the dx offset. Offsets count from the record start. Glyph-index strings name +/// glyphs of a font the reader cannot match, so they are left out. +fn ext_text_out(cv: &mut Canvas, r: Bytes, wide: bool) -> Option<()> { + let at = xy32(r, 36)?; + let n = (r.u32(44)? as usize).min(MAX_CHARS); + let off = r.u32(48)? as usize; + let opts = r.u32(52)?; + let (rect, dx_off) = if opts & ETO_NO_RECT != 0 { (None, r.u32(56)?) } else { (rect32(r, 56), r.u32(72)?) }; + if opts & ETO_GLYPH_INDEX != 0 { + return None; + } + let dx = dx_array(r, dx_off as usize, n, opts); + let (text, dx) = if wide { + let raw = r.slice(off, n.checked_mul(2)?)?; + let units: Vec = raw.as_chunks::<2>().0.iter().map(|c| u16::from_le_bytes(*c)).collect(); + decode_utf16(&units, &dx) + } else { + decode_bytes(r.slice(off, n)?, cv.font_charset(), &dx) + }; + cv.text(at, text, dx, opts, rect); + Some(()) +} + +/// EMR_SMALLTEXTOUT (MS-EMF 2.3.5.37): x, y, character count, options, graphics mode, scales, the +/// bounds (absent under ETO_NO_RECT), then the characters: 8-bit under ETO_SMALL_CHARS, else UTF-16. +fn small_text_out(cv: &mut Canvas, r: Bytes) -> Option<()> { + let at = xy32(r, 8)?; + let n = (r.u32(16)? as usize).min(MAX_CHARS); + let opts = r.u32(20)?; + if opts & ETO_GLYPH_INDEX != 0 { + return None; + } + let (rect, off) = if opts & ETO_NO_RECT != 0 { (None, 36) } else { (rect32(r, 36), 52) }; + let (text, _) = if opts & ETO_SMALL_CHARS != 0 { + decode_bytes(r.slice(off, n)?, cv.font_charset(), &[]) + } else { + let raw = r.slice(off, n.checked_mul(2)?)?; + let units: Vec = raw.as_chunks::<2>().0.iter().map(|c| u16::from_le_bytes(*c)).collect(); + decode_utf16(&units, &[]) + }; + cv.text(at, text, Vec::new(), opts, rect); Some(()) } diff --git a/crates/metafile/src/lib.rs b/crates/metafile/src/lib.rs index 2200235..d7d9850 100644 --- a/crates/metafile/src/lib.rs +++ b/crates/metafile/src/lib.rs @@ -1,6 +1,9 @@ //! DeckCraft metafiles (ported from WordCraft's `crates/metafile`): reads Windows Metafiles (WMF) -//! and Enhanced Metafiles (EMF) into a neutral vector model of filled and stroked paths and RGBA -//! bitmaps, in a picture frame with y down. +//! and Enhanced Metafiles (EMF) into a neutral vector model of filled and stroked paths, pattern +//! fills, RGBA bitmaps and text strings, in a picture frame with y down. +//! +//! Text stays font-agnostic: a [`Text`] item names the font the file asks for and carries the +//! string, its advances and alignment; the renderer resolves the font and draws the glyphs. //! //! The reader is pure Rust over byte slices (no file system), so it builds for wasm. Every read is //! bounds-checked and every count is capped; hostile input yields a shorter picture or an [`Error`], @@ -16,15 +19,20 @@ mod dib; mod emf; mod geom; mod place; +mod text; mod wmf; #[cfg(test)] mod tests; +use std::collections::HashMap; +use std::sync::Arc; + use bytes::Bytes; use canvas::Raw; use geom::{Op, P}; pub use place::{MAX_BITMAP_SIDE, PlacedItem}; +pub use text::{Font, HAlign, SYMBOL_CHARSET, Text, VAlign}; /// Items kept from one file. Past the cap the parse stops and keeps what it has. pub(crate) const MAX_ITEMS: usize = 200_000; @@ -61,6 +69,22 @@ pub enum Item { }, /// 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 }, + /// A path filled with a repeating tile (pattern and hatch brushes). Items filled with the same + /// brush share its tile. + Pattern { segs: Vec, even_odd: bool, tile: Arc }, + /// A text string. + Text(Box), +} + +/// A brush tile: RGBA8 rows top-down (transparent where the brush leaves the background), each +/// pixel `cell` picture units, with a tile's top-left corner at `origin`. +#[derive(Debug, Clone, PartialEq)] +pub struct Tile { + pub width: u32, + pub height: u32, + pub rgba: Vec, + pub cell: [f32; 2], + pub origin: [f32; 2], } #[derive(Debug, Clone, Copy, PartialEq)] @@ -94,6 +118,8 @@ pub fn parse(bytes: &[u8]) -> Result { let (cv, frame) = if emf::is_emf(b) { emf::parse(b)? } else if wmf::is_wmf(b) { + // A WMF converted from an EMF can carry the original in MFCOMMENT escapes; PowerPoint draws + // the WMF's own records (its dx rounding shows), so the copy is not used. wmf::parse(b)? } else { return Err(Error::NotMetafile); @@ -124,6 +150,8 @@ fn build(raw: Vec, frame: Option) -> Picture { let stroke_scale = (sx + sy) / 2.0; // No stroke is wider than the picture's diagonal: anything wider is a mapping artefact. let diag = size.0.hypot(size.1); + // Tiles shared by several fills stay shared. + let mut tiles: HashMap<*const canvas::RawTile, (Arc, Arc)> = HashMap::new(); let items = raw.into_iter().filter_map(|r| match r { Raw::Path { ops, fill, stroke, even_odd } => { let segs = ops.iter().map(|op| seg(op, tf)).collect::>>()?; @@ -138,6 +166,44 @@ fn build(raw: Vec, frame: Option) -> Picture { 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 }) } + Raw::Pattern { ops, even_odd, tile } => { + let segs = ops.iter().map(|op| seg(op, tf)).collect::>>()?; + if segs.len() < 2 { + return None; + } + let key = Arc::as_ptr(&tile); + let tile = match tiles.get(&key) { + Some((_, t)) => t.clone(), + None => { + let (ox, oy) = tf((0.0, 0.0)); + let t = Arc::new(Tile { + width: u32::try_from(tile.w).ok()?, + height: u32::try_from(tile.h).ok()?, + rgba: tile.px.clone(), + cell: [f(tile.cell * sx)?, f(tile.cell * sy)?], + origin: [f(ox)?, f(oy)?], + }); + // The raw tile is kept with its key, so the address is never reused by another. + tiles.insert(key, (tile.clone(), t.clone())); + t + } + }; + Some(Item::Pattern { segs, even_odd, tile }) + } + Raw::Text(mut t) => { + // 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 (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()) { + return None; + } + if let Some([x0, y0, x1, y1]) = t.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))?]); + } + Some(Item::Text(t)) + } }); let items = items.collect(); Picture { width: f(size.0).unwrap_or(0.0), height: f(size.1).unwrap_or(0.0), items } @@ -185,7 +251,7 @@ fn bbox(raw: &[Raw]) -> Option<[f64; 4]> { }; for r in raw { match r { - Raw::Path { ops, .. } => { + Raw::Path { ops, .. } | Raw::Pattern { ops, .. } => { for op in ops { match *op { Op::M(p) | Op::L(p) => grow(p), @@ -202,6 +268,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])), } } b diff --git a/crates/metafile/src/place.rs b/crates/metafile/src/place.rs index 68f0795..28e0b6a 100644 --- a/crates/metafile/src/place.rs +++ b/crates/metafile/src/place.rs @@ -19,6 +19,12 @@ pub enum PlacedItem { Path { path: BezPath, fill: Option<[u8; 4]>, stroke: Option<([u8; 4], f64)>, even_odd: bool }, /// `Picture::items[index]`, a bitmap, stretched into `rect`. Bitmap { index: usize, rect: Rect }, + /// `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 }, + /// `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 }, } impl Picture { @@ -56,6 +62,26 @@ impl Picture { out.push(PlacedItem::Path { path, fill: *fill, stroke, even_odd: *even_odd }); } } + Item::Pattern { segs, even_odd, tile: t } => { + let path = tf * bez(segs); + 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 }); + } + } + Item::Text(t) => { + let transform = tf * Affine::new(t.transform); + 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 }); + } + } Item::Bitmap { rect: [x, y, w, h], .. } => { 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))); diff --git a/crates/metafile/src/tests.rs b/crates/metafile/src/tests.rs index 4ee3257..271cc83 100644 --- a/crates/metafile/src/tests.rs +++ b/crates/metafile/src/tests.rs @@ -120,7 +120,7 @@ const DIB24_RGBA: [u8; 16] = [255, 0, 0, 255, 255, 255, 255, 255, 0, 0, 255, 255 fn path_segs(item: &Item) -> (&Vec, Option<[u8; 4]>, Option<([u8; 4], f32)>, bool) { match item { Item::Path { segs, fill, stroke, even_odd } => (segs, *fill, *stroke, *even_odd), - Item::Bitmap { .. } => panic!("expected a path"), + other => panic!("expected a path: {other:?}"), } } @@ -1016,3 +1016,505 @@ fn plain_wmf_target_comes_from_a_window_that_was_read() { let (x0, y0, x1, y1) = rect_extent(&p.items[0]); assert!(close(x0, 0.0) && close(y0, 0.0) && close(x1, 200.0) && close(y1, 100.0), "{x0} {y0} {x1} {y1}"); } + +// ---- Text, pattern and hatch brushes, mix-mode idioms ---- + +use crate::{HAlign, Picture, Text, VAlign}; + +/// META_CREATEFONTINDIRECT: height, width, escapement (and orientation), weight, the italic, +/// underline, strike-out and charset bytes, four precision bytes, then the NUL-terminated face. +fn wfont(height: i32, weight: i32, flags: [u8; 3], charset: u8, esc: i32, face: &[u8]) -> Vec { + let mut v = cat(vec![h16(height), h16(0), h16(esc), h16(esc), h16(weight)]); + v.extend(flags); + v.extend([charset, 0, 0, 0, 0]); + v.extend(face); + v.push(0); + wrec(0x02FB, &v) +} + +/// META_EXTTEXTOUT at (x, y): the rectangle when given, the string padded to a word, then `dx`. +fn wtext(x: i32, y: i32, s: &[u8], opts: i32, rect: Option<[i32; 4]>, dx: &[i32]) -> Vec { + let mut v = cat(vec![h16(y), h16(x), h16(s.len() as i32), h16(opts)]); + for c in rect.into_iter().flatten() { + v.extend(h16(c)); + } + v.extend(s); + if s.len() % 2 == 1 { + v.push(0); + } + for d in dx { + v.extend(h16(*d)); + } + wrec(0x0A32, &v) +} + +/// A plain WMF whose 200 × 100 window maps one logical unit to one picture unit. +fn wmf200(records: &[Vec]) -> Picture { + let mut recs = vec![wrec(0x020C, &cat(vec![h16(100), h16(200)]))]; + recs.extend(records.iter().cloned()); + parse(&wmf(&recs, None)).unwrap() +} + +fn texts(p: &Picture) -> Vec<&Text> { + p.items + .iter() + .filter_map(|i| match i { + Item::Text(t) => Some(t.as_ref()), + _ => None, + }) + .collect() +} + +fn close6(a: [f64; 6], b: [f64; 6]) -> bool { + a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-6) +} + +#[test] +fn wmf_text_reads_the_font_string_dx_colour_and_alignment() { + let recs = [ + wfont(-20, 700, [1, 1, 1], 0, 0, b"Arial"), + wrec(0x012D, &h16(0)), + wrec(0x0209, &h32(0x0000_00FF)), + wrec(0x0102, &h16(1)), + wrec(0x012E, &h16(24 | 6)), + wtext(50, 60, b"Hi!", 0, None, &[10, 8, 6]), + ]; + let p = wmf200(&recs); + let t = texts(&p); + assert_eq!(t.len(), 1, "{:?}", p.items); + let t = t[0]; + assert_eq!(t.text, "Hi!"); + assert_eq!(t.dx, vec![10.0, 8.0, 6.0], "the dx array follows the word-padded odd string"); + assert_eq!(t.font.family, "Arial"); + assert_eq!((t.font.height, t.font.weight, t.font.italic, t.font.underline, t.font.strike), (-20.0, 700, true, true, true)); + assert_eq!(t.color, [255, 0, 0, 255]); + assert_eq!(t.background, None, "TRANSPARENT background mode"); + assert_eq!((t.h_align, t.v_align), (HAlign::Center, VAlign::Baseline)); + assert!(close6(t.transform, [1.0, 0.0, 0.0, 1.0, 50.0, 60.0]), "{:?}", t.transform); + assert_eq!(t.clip, None); +} + +#[test] +fn font_height_sign_and_default_state_are_kept() { + // A positive height is the cell height; it stays positive for the renderer to resolve. Without + // SETTEXTALIGN the reference point is the top left; without SETBKMODE the cell is filled white. + let recs = [wfont(30, 400, [0, 0, 0], 0, 0, b"Times New Roman"), wrec(0x012D, &h16(0)), wtext(5, 5, b"x", 0, None, &[])]; + let p = wmf200(&recs); + let t = texts(&p)[0]; + assert_eq!(t.font.height, 30.0); + assert_eq!(t.font.family, "Times New Roman"); + assert_eq!((t.h_align, t.v_align), (HAlign::Left, VAlign::Top)); + assert_eq!(t.background, Some([255, 255, 255, 255])); + assert!(t.dx.is_empty(), "no dx array in the record"); + // Right and bottom. + let p = wmf200(&[wrec(0x012E, &h16(2 | 8)), wtext(5, 5, b"x", 0, None, &[])]); + let t = texts(&p)[0]; + assert_eq!((t.h_align, t.v_align), (HAlign::Right, VAlign::Bottom)); + assert_eq!(t.font, crate::Font::default(), "no font selected"); +} + +#[test] +fn escapement_turns_the_baseline_counter_clockwise() { + let recs = [wfont(-20, 400, [0, 0, 0], 0, 900, b"Arial"), wrec(0x012D, &h16(0)), wtext(100, 90, b"Up", 0, None, &[])]; + let p = wmf200(&recs); + let t = texts(&p)[0]; + // Text x runs up the picture (0, -1); text y (down the glyphs) runs right (1, 0). + assert!(close6(t.transform, [0.0, -1.0, 1.0, 0.0, 100.0, 90.0]), "{:?}", t.transform); +} + +#[test] +fn a_y_up_mapping_moves_text_but_does_not_mirror_it() { + // Window 200 × -100 onto a 200 × 100 viewport: y goes up. + let recs = [ + wrec(0x0103, &h16(8)), + wrec(0x020C, &cat(vec![h16(-100), h16(200)])), + wrec(0x020E, &cat(vec![h16(100), h16(200)])), + wtext(40, -30, b"A", 0, None, &[]), + ]; + let p = parse(&wmf(&recs, None)).unwrap(); + let t = texts(&p)[0]; + assert!(close6(t.transform, [1.0, 0.0, 0.0, 1.0, 40.0, 30.0]), "{:?}", t.transform); +} + +#[test] +fn update_cp_starts_at_the_current_position_and_moves_past_the_string() { + let recs = + [wrec(0x012E, &h16(1)), wrec(0x0214, &cat(vec![h16(20), h16(10)])), wtext(0, 0, b"ab", 0, None, &[5, 7]), wtext(0, 0, b"c", 0, None, &[3])]; + let p = wmf200(&recs); + let t = texts(&p); + assert_eq!(t.len(), 2); + assert!(close6(t[0].transform, [1.0, 0.0, 0.0, 1.0, 10.0, 20.0]), "{:?}", t[0].transform); + assert!(close6(t[1].transform, [1.0, 0.0, 0.0, 1.0, 22.0, 20.0]), "{:?}", t[1].transform); +} + +#[test] +fn eto_opaque_fills_the_rectangle_and_eto_clipped_clips() { + let recs = [wrec(0x0201, &h32(0x0000_FF00)), wtext(12, 30, b"ok", 2 | 4, Some([10, 10, 60, 40]), &[])]; + let p = wmf200(&recs); + assert_eq!(p.items.len(), 2, "{:?}", p.items); + let (x0, y0, x1, y1) = rect_extent(&p.items[0]); + assert!(close(x0, 10.0) && close(y0, 10.0) && close(x1, 60.0) && close(y1, 40.0)); + assert_eq!(path_segs(&p.items[0]).1, Some([0, 255, 0, 255])); + let t = texts(&p)[0]; + assert_eq!(t.clip, Some([10.0, 10.0, 60.0, 40.0])); + assert_eq!(t.background, Some([0, 255, 0, 255])); +} + +#[test] +fn character_sets_decode_bytes() { + let one = |charset: u8, s: &[u8]| { + let p = wmf200(&[wfont(-10, 400, [0, 0, 0], charset, 0, b"F"), wrec(0x012D, &h16(0)), wtext(0, 0, s, 0, None, &[])]); + texts(&p)[0].text.clone() + }; + assert_eq!(one(0, &[b'A', 0x80, 0x93, 0x94, 0xE9]), "A€“”é"); + assert_eq!(one(238, &[0xB9, 0xE8, 0x9E, 0xA3]), "ąčžŁ"); + assert_eq!(one(204, &[0xC0, 0xFF, 0xA8]), "АяЁ"); + assert_eq!(one(2, b"n\xA7"), "\u{F06E}\u{F0A7}", "symbol fonts keep their codes in the private-use area"); + assert_eq!(one(0, &[0x81]), "\u{FFFD}", "an undefined byte"); + // A double-byte character set: a lead and trail byte become one blank with both advances. + let p = + wmf200(&[wfont(-10, 400, [0, 0, 0], 128, 0, b"F"), wrec(0x012D, &h16(0)), wtext(0, 0, &[0x41, 0x82, 0xA0, 0x42], 0, None, &[1, 2, 3, 4])]); + let t = texts(&p)[0]; + assert_eq!((t.text.as_str(), t.dx.as_slice()), ("A B", [1.0, 5.0, 4.0].as_slice())); +} + +#[test] +fn wmf_textout_reads_its_string_and_position() { + let mut v = cat(vec![h16(3)]); + v.extend(b"abc\0"); + v.extend(cat(vec![h16(40), h16(30)])); + let p = wmf200(&[wrec(0x0521, &v)]); + let t = texts(&p)[0]; + assert_eq!(t.text, "abc"); + assert!(close6(t.transform, [1.0, 0.0, 0.0, 1.0, 30.0, 40.0]), "{:?}", t.transform); +} + +#[test] +fn savedc_restores_text_colour_and_font() { + let recs = [ + wfont(-10, 400, [0, 0, 0], 0, 0, b"One"), + wrec(0x012D, &h16(0)), + wrec(0x0209, &h32(0x0000_00FF)), + wrec(0x001E, &[]), + wfont(-10, 400, [0, 0, 0], 0, 0, b"Two"), + wrec(0x012D, &h16(1)), + wrec(0x0209, &h32(0x00FF_0000)), + wrec(0x0127, &h16(-1)), + wtext(0, 0, b"x", 0, None, &[]), + ]; + let p = wmf200(&recs); + let t = texts(&p)[0]; + assert_eq!((t.font.family.as_str(), t.color), ("One", [255, 0, 0, 255])); +} + +/// EMR_EXTCREATEFONTINDIRECTW in slot 1: LogFont at 12, face at 40. +fn efont(height: i32, esc: i32, weight: i32, italic: u8, charset: u8, face: &str) -> Vec { + let mut v = cat(vec![h32(1), e32(height), e32(0), e32(esc), e32(esc), e32(weight)]); + v.extend([italic, 0, 0, charset, 0, 0, 0, 0]); + let mut name: Vec = face.encode_utf16().flat_map(|u| u.to_le_bytes()).collect(); + name.resize(64, 0); + v.extend(name); + erec(82, &v) +} + +/// EMR_EXTTEXTOUTW (or A) at (x, y) with options, the rectangle (absent under ETO_NO_RECT), the +/// string at 76 and the dx array after it. +fn etext(wide: bool, x: i32, y: i32, units: &[u16], opts: u32, dx: &[i32]) -> Vec { + let no_rect = opts & 0x100 != 0; + let fixed = if no_rect { 60 } else { 76 }; + let s: Vec = if wide { units.iter().flat_map(|u| u.to_le_bytes()).collect() } else { units.iter().map(|u| *u as u8).collect() }; + let s_off = fixed; + let dx_off = (s_off + s.len()).next_multiple_of(4); + let len = dx_off + dx.len() * 4; + let mut f = vec![(24, 1u32), (36, x as u32), (40, y as u32), (44, units.len() as u32), (48, s_off as u32), (52, opts)]; + f.push((if no_rect { 56 } else { 72 }, if dx.is_empty() { 0 } else { dx_off as u32 })); + let dxb: Vec = dx.iter().flat_map(|d| d.to_le_bytes()).collect(); + erec_at(if wide { 84 } else { 83 }, len.next_multiple_of(4), &f, &[(s_off, &s), (dx_off, &dxb)]) +} + +#[test] +fn emf_exttextoutw_reads_utf16_dx_and_the_font() { + let recs = [ + efont(24, 0, 400, 1, 0, "Segoe UI"), + erec(37, &h32(1)), + erec(22, &h32(0x18 | 2)), + erec(24, &h32(0x0000_FF00)), + erec(18, &h32(1)), + etext(true, 100, 50, &[0x41, 0xD83D, 0xDE00], 0, &[10, 6, 0]), + ]; + let p = parse(&emf(&recs, [0, 0, 200, 100], [0, 0, 5000, 2500])).unwrap(); + let t = texts(&p); + assert_eq!(t.len(), 1, "{:?}", p.items); + let t = t[0]; + assert_eq!(t.text, "A\u{1F600}"); + assert_eq!(t.dx, vec![10.0, 6.0], "a surrogate pair takes both advances"); + assert_eq!((t.font.family.as_str(), t.font.height, t.font.italic), ("Segoe UI", 24.0, true)); + assert_eq!((t.h_align, t.v_align, t.color, t.background), (HAlign::Right, VAlign::Baseline, [0, 255, 0, 255], None)); + // Upright, uniformly scaled. + let [a, b, c, d, ..] = t.transform; + assert!(a > 0.0 && d > 0.0 && b.abs() < 1e-9 && c.abs() < 1e-9 && (a - d).abs() < 1e-9, "{:?}", t.transform); +} + +#[test] +fn emf_text_options_pdy_no_rect_ansi_and_glyph_indices() { + // ETO_PDY: (dx, dy) pairs, of which dx is used. ETO_NO_RECT moves the dx offset to 56. + let recs = [etext(true, 0, 0, &[0x61, 0x62], 0x2000 | 0x100, &[7, 1, 9, 2])]; + let p = parse(&emf(&recs, [0, 0, 200, 100], [0, 0, 5000, 2500])).unwrap(); + assert_eq!(texts(&p)[0].dx, vec![7.0, 9.0]); + // EXTTEXTOUTA decodes through the font's charset. + let recs = [efont(-10, 0, 400, 0, 238, "F"), erec(37, &h32(1)), etext(false, 0, 0, &[0xB9], 0, &[])]; + let p = parse(&emf(&recs, [0, 0, 200, 100], [0, 0, 5000, 2500])).unwrap(); + assert_eq!(texts(&p)[0].text, "ą"); + // Glyph indices name glyphs of a font we cannot match: left out. + let recs = [etext(true, 0, 0, &[5, 6], 0x10, &[])]; + let p = parse(&emf(&recs, [0, 0, 200, 100], [0, 0, 5000, 2500])).unwrap(); + assert!(texts(&p).is_empty()); +} + +#[test] +fn emf_smalltextout_reads_8_bit_and_16_bit_strings() { + // x, y, count, options, mode, scales, (no bounds under ETO_NO_RECT), characters. + let small = |opts: u32, chars: &[u8]| { + let mut v = cat(vec![e32(3), e32(4), h32(2), h32(opts), h32(1), h32(0), h32(0)]); + v.extend(chars); + erec(108, &v) + }; + let p = parse(&emf(&[small(0x100 | 0x200, b"hi")], [0, 0, 200, 100], [0, 0, 5000, 2500])).unwrap(); + assert_eq!(texts(&p)[0].text, "hi"); + let p = parse(&emf(&[small(0x100, &[b'o', 0, b'k', 0])], [0, 0, 200, 100], [0, 0, 5000, 2500])).unwrap(); + assert_eq!(texts(&p)[0].text, "ok"); +} + +/// An 8 × 8 one-bit DIB (palette white, black; stored bottom-up): `rows` top to bottom. +fn mono_dib(rows: [u8; 8]) -> Vec { + let mut v = info_header(8, 8, 1, 32); + v.extend([255, 255, 255, 0, 0, 0, 0, 0]); + for r in rows.iter().rev() { + v.extend([*r, 0, 0, 0]); + } + v +} + +fn pattern_of(item: &Item) -> &crate::Tile { + match item { + Item::Pattern { tile, .. } => tile, + other => panic!("expected a pattern fill: {other:?}"), + } +} + +/// Pixel (x, y) of a tile. +fn tile_px(t: &crate::Tile, x: usize, y: usize) -> [u8; 4] { + let o = (y * t.width as usize + x) * 4; + t.rgba[o..o + 4].try_into().unwrap() +} + +#[test] +fn wmf_dib_pattern_brush_paints_its_rows_top_down() { + let brush = wrec(0x0142, &cat(vec![h16(5), h16(0), mono_dib([0xFF, 0x80, 0, 0, 0, 0, 0, 0x01])])); + let recs = [ + brush, + // A pen after the brush: the brush took handle 0. + wrec(0x02FA, &cat(vec![h16(0), h16(1), h16(0), h32(0x0000_00FF)])), + wrec(0x012D, &h16(0)), + wrec(0x012D, &h16(1)), + wrec(0x041B, &cat(vec![h16(720), h16(1440), h16(0), h16(0)])), + ]; + let p = parse(&wmf(&recs, Some([0, 0, 1440, 720]))).unwrap(); + assert_eq!(p.items.len(), 2, "the tile fill and the outline: {:?}", p.items); + let t = pattern_of(&p.items[0]); + assert_eq!((t.width, t.height), (8, 8)); + for x in 0..8 { + assert_eq!(tile_px(t, x, 0), [0, 0, 0, 255], "top row is set: black"); + } + assert_eq!(tile_px(t, 0, 1), [0, 0, 0, 255]); + assert_eq!(tile_px(t, 1, 1), [255, 255, 255, 255]); + assert_eq!(tile_px(t, 7, 7), [0, 0, 0, 255]); + assert_eq!(tile_px(t, 0, 7), [255, 255, 255, 255]); + // 96-dpi pixels of a picture in points, laid from the picture's corner. + assert!(close(t.cell[0], 0.75) && close(t.cell[1], 0.75) && t.origin == [0.0, 0.0], "{:?} {:?}", t.cell, t.origin); + assert_eq!(path_segs(&p.items[1]).2.map(|s| s.0), Some([255, 0, 0, 255])); +} + +#[test] +fn hatch_brushes_draw_lines_over_the_background_mode() { + let hatch = |style: i32| wrec(0x02FC, &cat(vec![h16(2), h32(0x00FF_0000), h16(style)])); + let rect = wrec(0x041B, &cat(vec![h16(50), h16(50), h16(0), h16(0)])); + // HS_CROSS over a transparent background. + let p = wmf200(&[hatch(4), wrec(0x012D, &h16(0)), wrec(0x0102, &h16(1)), rect.clone()]); + let t = pattern_of(&p.items[0]); + let blue = [0, 0, 255, 255]; + assert_eq!((tile_px(t, 3, 7), tile_px(t, 7, 3)), (blue, blue)); + assert_eq!(tile_px(t, 3, 3)[3], 0, "gaps are transparent"); + // HS_BDIAGONAL over the opaque white background. + let p = wmf200(&[hatch(3), wrec(0x012D, &h16(0)), rect.clone()]); + let t = pattern_of(&p.items[0]); + assert_eq!((tile_px(t, 0, 7), tile_px(t, 7, 0)), (blue, blue)); + assert_eq!(tile_px(t, 0, 0), [255, 255, 255, 255]); + // An unknown hatch style draws no fill. + let p = wmf200(&[hatch(9), wrec(0x012D, &h16(0)), rect]); + assert!(p.items.iter().all(|i| !matches!(i, Item::Pattern { .. })), "{:?}", p.items); +} + +#[test] +fn xor_mask_xor_draws_the_pattern_in_the_xor_colour() { + // The see-through shadow: XOR a grey polygon, AND a checker pattern, XOR the polygon again. + let square = wrec(0x0324, &cat(vec![h16(4), h16(0), h16(0), h16(40), h16(0), h16(40), h16(40), h16(0), h16(40)])); + let recs = [ + wrec(0x0104, &h16(7)), + wrec(0x02FC, &cat(vec![h16(0), h32(0x0080_8080), h16(0)])), + wrec(0x012D, &h16(0)), + square.clone(), + wrec(0x0104, &h16(9)), + wrec(0x0142, &cat(vec![h16(5), h16(0), mono_dib([0x55, 0xAA, 0x55, 0xAA, 0x55, 0xAA, 0x55, 0xAA])])), + wrec(0x012D, &h16(1)), + wrec(0x041B, &cat(vec![h16(40), h16(40), h16(0), h16(0)])), + wrec(0x0104, &h16(7)), + wrec(0x012D, &h16(0)), + square, + ]; + let p = wmf200(&recs); + assert_eq!(p.items.len(), 2, "only the AND pattern (and its outline) draw: {:?}", p.items); + let t = pattern_of(&p.items[0]); + // 0x55 = 0101 0101: white, black, … + assert_eq!(tile_px(t, 0, 0)[3], 0, "white keeps what is below"); + assert_eq!(tile_px(t, 1, 0), [128, 128, 128, 255], "black takes the XOR colour"); +} + +#[test] +fn patblt_fills_with_the_brush() { + let patblt = |rop: u32| wrec(0x061D, &cat(vec![h32(rop), h16(30), h16(40), h16(10), h16(20)])); + let recs = [wrec(0x02FC, &cat(vec![h16(0), h32(0x00FF_0000), h16(0)])), wrec(0x012D, &h16(0)), patblt(0x00F0_0021), patblt(0x0055_0009)]; + let p = wmf200(&recs); + assert_eq!(p.items.len(), 1, "DSTINVERT draws nothing: {:?}", p.items); + let (x0, y0, x1, y1) = rect_extent(&p.items[0]); + assert!(close(x0, 20.0) && close(y0, 10.0) && close(x1, 60.0) && close(y1, 40.0), "{x0} {y0} {x1} {y1}"); + assert_eq!(path_segs(&p.items[0]).1, Some([0, 0, 255, 255])); +} + +#[test] +fn emf_dib_pattern_and_mono_brushes() { + let dib = mono_dib([0xF0; 8]); + let (info, bits) = dib.split_at(48); + let brush = |typ: u32| { + let f = [(8, 1u32), (12, 0), (16, 32), (20, info.len() as u32), (24, 32 + info.len() as u32), (28, bits.len() as u32)]; + erec_at(typ, 32 + dib.len(), &f, &[(32, info), (32 + info.len(), bits)]) + }; + let rect = erec(43, &cat(vec![e32(0), e32(0), e32(40), e32(40)])); + let null_pen = erec(37, &h32(0x8000_0008)); + let p = parse(&emf(&[brush(94), erec(37, &h32(1)), null_pen.clone(), rect.clone()], [0, 0, 200, 100], [0, 0, 5000, 2500])).unwrap(); + let t = pattern_of(&p.items[0]); + assert_eq!((tile_px(t, 0, 0), tile_px(t, 7, 0)), ([0, 0, 0, 255], [255, 255, 255, 255])); + // A mono brush paints in the text colour (red) and the background colour (green). + let recs = [erec(24, &h32(0xFF)), erec(25, &h32(0xFF00)), brush(93), erec(37, &h32(1)), null_pen, rect]; + let p = parse(&emf(&recs, [0, 0, 200, 100], [0, 0, 5000, 2500])).unwrap(); + let t = pattern_of(&p.items[0]); + assert_eq!((tile_px(t, 0, 0), tile_px(t, 7, 0)), ([255, 0, 0, 255], [0, 255, 0, 255])); +} + +#[test] +fn hostile_text_and_brush_records_never_panic() { + // A string longer than its record is cut to what the record holds. + let mut v = cat(vec![h16(0), h16(0), h16(0x7FFF), h16(0)]); + v.extend(b"abcd"); + let p = wmf200(&[wrec(0x0A32, &v)]); + assert_eq!(texts(&p)[0].text, "abcd"); + // The rectangle flag without room for the rectangle draws nothing. + let p = wmf200(&[wrec(0x0A32, &cat(vec![h16(0), h16(0), h16(1), h16(6)]))]); + assert!(texts(&p).is_empty()); + // A face name with no terminator stops at 32 bytes. + let p = wmf200(&[wfont(-10, 400, [0, 0, 0], 0, 0, &[b'A'; 40]), wrec(0x012D, &h16(0)), wtext(0, 0, b"x", 0, None, &[])]); + assert_eq!(texts(&p)[0].font.family.len(), 32); + // A pattern brush whose DIB is cut short, or too big for a tile, still takes its handle slot + // and draws no fill. + let short = wrec(0x0142, &cat(vec![h16(5), h16(0), info_header(8, 8, 1, 32)])); + let mut huge = info_header(300, 300, 1, 0); + huge.extend([255, 255, 255, 0, 0, 0, 0, 0]); + huge.extend(vec![0u8; 40 * 300]); + let huge = wrec(0x0142, &cat(vec![h16(5), h16(0), huge])); + for b in [short, huge] { + let recs = [ + b, + wrec(0x02FA, &cat(vec![h16(0), h16(1), h16(0), h32(0xFF)])), + wrec(0x012D, &h16(0)), + wrec(0x012D, &h16(1)), + wrec(0x041B, &cat(vec![h16(9), h16(9), h16(0), h16(0)])), + ]; + let p = wmf200(&recs); + assert_eq!(p.items.len(), 1, "{:?}", p.items); + let (_, fill, stroke, _) = path_segs(&p.items[0]); + assert_eq!((fill, stroke.map(|s| s.0)), (Some([255, 255, 255, 255]), Some([255, 0, 0, 255]))); + } + // EMF: the string offset past the record, a huge count, a dx offset past the record. + let bad = [ + erec_at(84, 80, &[(44, 2), (48, 4000), (52, 0)], &[]), + erec_at(84, 84, &[(44, u32::MAX), (48, 76), (52, 0)], &[(76, &[b'a', 0, b'b', 0])]), + erec_at(84, 80, &[(44, 2), (48, 76), (52, 0), (72, 0xFFFF_FFF0)], &[(76, &[b'a', 0, b'b', 0])]), + erec_at(82, 20, &[(8, 1)], &[]), + erec_at(94, 32, &[(8, 1), (16, 0xFFFF_FFFF), (20, 40), (24, 0), (28, 0xFFFF_FFFF)], &[]), + erec_at(108, 40, &[(16, u32::MAX), (20, 0x300)], &[]), + ]; + for r in bad { + let _ = parse(&emf(&[r], [0, 0, 200, 100], [0, 0, 5000, 2500])); + } + let p = parse(&emf( + &[erec_at(84, 84, &[(44, 2), (48, 76), (52, 0), (72, 0xFFFF_FFF0)], &[(76, &[b'a', 0, b'b', 0])])], + [0, 0, 200, 100], + [0, 0, 5000, 2500], + )) + .unwrap(); + assert!(texts(&p)[0].dx.is_empty(), "a dx array past the record is dropped"); + // Mutated text and brush files. + let wfile = wmf( + &[ + wfont(-20, 700, [1, 1, 1], 238, 450, b"Arial"), + wrec(0x012D, &h16(0)), + wrec(0x0142, &cat(vec![h16(5), h16(0), mono_dib([0x55; 8])])), + wrec(0x012D, &h16(1)), + wrec(0x012E, &h16(1)), + wtext(5, 5, b"Hello", 6, Some([0, 0, 50, 50]), &[1, 2, 3, 4, 5]), + wrec(0x041B, &cat(vec![h16(9), h16(9), h16(0), h16(0)])), + ], + Some([0, 0, 100, 100]), + ); + let efile = emf( + &[efont(24, 900, 400, 1, 0, "Segoe UI"), erec(37, &h32(1)), etext(true, 1, 2, &[0x41, 0xD83D, 0xDE00], 0x2000, &[1, 2, 3, 4, 5, 6])], + [0, 0, 200, 100], + [0, 0, 5000, 2500], + ); + let mut rng = Lcg(0x9E37_79B9_7F4A_7C15); + for file in [&wfile, &efile] { + for n in 0..=file.len() { + let _ = parse(&file[..n]); + } + for _ in 0..1500 { + let mut buf = file.clone(); + for _ in 0..6 { + let at = rng.rnd() as usize % buf.len(); + buf[at] = rng.rnd() as u8; + } + let _ = parse(&buf); + } + } +} + +#[test] +fn text_items_are_capped_like_any_other() { + let t = wtext(0, 0, b"x", 0, None, &[]); + let recs: Vec> = (0..MAX_ITEMS + 5).map(|_| t.clone()).collect(); + let p = wmf200(&recs); + assert_eq!(p.items.len(), MAX_ITEMS); +} + +#[test] +fn pattern_brush_pixels_count_against_the_byte_budget() { + // 256 × 256 one-bit pattern brushes (256 KiB of RGBA each): far fewer than the object table + // holds fit the budget, and the parse stops there. + let mut dib = info_header(256, 256, 1, 0); + dib.extend([255, 255, 255, 0, 0, 0, 0, 0]); + dib.extend(vec![0x55u8; 32 * 256]); + let brush = wrec(0x0142, &cat(vec![h16(5), h16(0), dib])); + let mut recs: Vec> = (0..1100).map(|_| brush.clone()).collect(); + recs.push(wrec(0x041B, &cat(vec![h16(9), h16(9), h16(0), h16(0)]))); + let p = wmf200(&recs); + assert!(p.items.is_empty(), "the parse stopped at the budget: {:?}", p.items.len()); +} diff --git a/crates/metafile/src/text.rs b/crates/metafile/src/text.rs new file mode 100644 index 0000000..86bca91 --- /dev/null +++ b/crates/metafile/src/text.rs @@ -0,0 +1,200 @@ +//! Text records: the font (LOGFONT), the text string decoded from its character set, and the +//! neutral [`Text`] item. The metafile crate knows no fonts, so measuring, alignment against the +//! font's metrics and glyph drawing are left to the renderer. + +/// A font chosen by a CREATEFONTINDIRECT record (MS-WMF 2.2.1.2 Font, MS-EMF 2.2.13 LogFont). +#[derive(Debug, Clone, PartialEq, Default)] +pub struct Font { + /// The face name the file asks for (`"Arial"`, `"Wingdings"`); may be empty. + pub family: String, + /// LOGFONT height in text units: negative is the character (em) height, positive the cell + /// height (ascent plus descent), 0 the default size. + pub height: f32, + /// Average character width in text units; 0 keeps the font's own proportions. + pub width: f32, + /// 100..=900 (400 regular, 700 bold); 0 is the default weight. + pub weight: u16, + pub italic: bool, + pub underline: bool, + pub strike: bool, + /// LOGFONT character set (0 ANSI, 2 SYMBOL, 238 EASTEUROPE…). + pub charset: u8, +} + +/// SYMBOL_CHARSET: byte codes are the font's own symbol codes. +pub const SYMBOL_CHARSET: u8 = 2; + +/// Where the reference point sits on the string horizontally (TA_LEFT, TA_CENTER, TA_RIGHT). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum HAlign { + #[default] + Left, + Center, + Right, +} + +/// Where the reference point sits vertically (TA_TOP, TA_BASELINE, TA_BOTTOM): the top of the +/// character cell, the baseline or the bottom of the cell. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum VAlign { + #[default] + Top, + Baseline, + Bottom, +} + +/// One string drawn by EXTTEXTOUT, TEXTOUT or SMALLTEXTOUT. +#[derive(Debug, Clone, PartialEq)] +pub struct Text { + /// Text space to picture space as affine coefficients `[a, b, c, d, e, f]` (`x' = a·x + c·y + e`, + /// `y' = b·x + d·y + f`, kurbo's order). Text space has x along the baseline and y down, in the + /// metafile's logical units, with the reference point at the origin. Escapement and the + /// mapping are folded in; glyphs are never mirrored. + pub transform: [f64; 6], + pub text: String, + /// Advance after each character of `text`, in text units (the dx array). Empty when the file + /// gives none: the font's own advances apply. + pub dx: Vec, + pub font: Font, + pub color: [u8; 4], + /// Colour filled behind the character cells (OPAQUE background mode); None when transparent. + pub background: Option<[u8; 4]>, + pub h_align: HAlign, + pub v_align: VAlign, + /// Clip rectangle `[x0, y0, x1, y1]` in picture units (ETO_CLIPPED). + pub clip: Option<[f32; 4]>, +} + +/// TA_UPDATECP: the string starts at the current position, which moves past it. +pub(crate) const TA_UPDATECP: u32 = 0x0001; +/// ETO_OPAQUE fills the rectangle with the background colour; ETO_CLIPPED clips the text to it. +pub(crate) const ETO_OPAQUE: u32 = 0x0002; +pub(crate) const ETO_CLIPPED: u32 = 0x0004; +/// The string holds glyph indices, not characters (drawn only when the font matches). +pub(crate) const ETO_GLYPH_INDEX: u32 = 0x0010; +/// The dx array holds (dx, dy) pairs. +pub(crate) const ETO_PDY: u32 = 0x2000; +/// Longest string kept from one record. +pub(crate) const MAX_CHARS: usize = 16_384; + +/// Horizontal and vertical alignment from a TA_* word (the vertical bits are 0 top, 8 bottom, +/// 24 baseline; the horizontal ones 0 left, 2 right, 6 center). +pub(crate) fn align(ta: u32) -> (HAlign, VAlign) { + let h = match ta & 0x06 { + 6 => HAlign::Center, + 2 => HAlign::Right, + _ => HAlign::Left, + }; + let v = match ta & 0x18 { + 0x18 => VAlign::Baseline, + 0x08 => VAlign::Bottom, + _ => VAlign::Top, + }; + (h, v) +} + +/// Decodes 8-bit text in `charset` to one character per byte, and merges `dx` to match: a +/// double-byte character set's lead and trail bytes become one blank (its glyphs are not +/// decoded) whose advance is the sum of both. `dx` may be empty. +pub(crate) fn decode_bytes(bytes: &[u8], charset: u8, dx: &[f32]) -> (String, Vec) { + let mut s = String::with_capacity(bytes.len()); + let mut out_dx = Vec::with_capacity(dx.len().min(bytes.len())); + let dbcs = matches!(charset, 128 | 129 | 130 | 134 | 136); + let mut i = 0; + while let Some(&b) = bytes.get(i) { + let n = if dbcs && b >= 0x81 && i + 1 < bytes.len() { 2 } else { 1 }; + let c = if n == 2 { ' ' } else { byte_char(b, charset) }; + s.push(c); + if !dx.is_empty() { + out_dx.push((i..i + n).filter_map(|k| dx.get(k)).sum()); + } + i += n; + } + (s, out_dx) +} + +/// UTF-16 text to a string with one dx entry per character: a surrogate pair takes the sum of +/// its two advances. Unpaired surrogates become U+FFFD. +pub(crate) fn decode_utf16(units: &[u16], dx: &[f32]) -> (String, Vec) { + let mut s = String::with_capacity(units.len()); + let mut out_dx = Vec::with_capacity(dx.len().min(units.len())); + let mut i = 0; + for r in char::decode_utf16(units.iter().copied()) { + let c = r.unwrap_or(char::REPLACEMENT_CHARACTER); + let n = c.len_utf16(); + s.push(c); + if !dx.is_empty() { + out_dx.push((i..i + n).filter_map(|k| dx.get(k)).sum()); + } + i += n; + } + (s, out_dx) +} + +/// One byte in a single-byte Windows code page. Symbol fonts keep their codes in the private-use +/// range (U+F000 + code), where symbol fonts map them. +fn byte_char(b: u8, charset: u8) -> char { + if b < 0x80 && charset != SYMBOL_CHARSET { + return char::from(b); + } + let cp = match charset { + SYMBOL_CHARSET => 0xF000 | u32::from(b), + 238 => high(b, &CP1250), + 204 => high(b, &CP1251), + _ => high(b, &CP1252), + }; + char::from_u32(cp).unwrap_or(char::REPLACEMENT_CHARACTER) +} + +/// The code point of byte `b` (0x80..=0xFF) from a table of the upper half; 0 entries are +/// undefined bytes, drawn as U+FFFD. +fn high(b: u8, table: &[u16; 128]) -> u32 { + match table.get(usize::from(b.wrapping_sub(0x80))) { + Some(0) | None => 0xFFFD, + Some(v) => u32::from(*v), + } +} + +/// Windows-1252 (Western) 0x80..=0xFF: the upper half of Latin-1 with typographic marks in 0x80..=0x9F. +const CP1252: [u16; 128] = { + let mut t = [0u16; 128]; + let low: [u16; 32] = [ + 0x20AC, 0, 0x201A, 0x0192, 0x201E, 0x2026, 0x2020, 0x2021, 0x02C6, 0x2030, 0x0160, 0x2039, 0x0152, 0, 0x017D, 0, 0, 0x2018, 0x2019, 0x201C, + 0x201D, 0x2022, 0x2013, 0x2014, 0x02DC, 0x2122, 0x0161, 0x203A, 0x0153, 0, 0x017E, 0x0178, + ]; + let mut i = 0; + while i < 128 { + t[i] = if i < 32 { low[i] } else { 0x80 + i as u16 }; + i += 1; + } + t +}; + +/// Windows-1250 (Central European) 0x80..=0xFF. +const CP1250: [u16; 128] = [ + 0x20AC, 0, 0x201A, 0, 0x201E, 0x2026, 0x2020, 0x2021, 0, 0x2030, 0x0160, 0x2039, 0x015A, 0x0164, 0x017D, 0x0179, // + 0, 0x2018, 0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0, 0x2122, 0x0161, 0x203A, 0x015B, 0x0165, 0x017E, 0x017A, // + 0x00A0, 0x02C7, 0x02D8, 0x0141, 0x00A4, 0x0104, 0x00A6, 0x00A7, 0x00A8, 0x00A9, 0x015E, 0x00AB, 0x00AC, 0x00AD, 0x00AE, 0x017B, // + 0x00B0, 0x00B1, 0x02DB, 0x0142, 0x00B4, 0x00B5, 0x00B6, 0x00B7, 0x00B8, 0x0105, 0x015F, 0x00BB, 0x013D, 0x02DD, 0x013E, 0x017C, // + 0x0154, 0x00C1, 0x00C2, 0x0102, 0x00C4, 0x0139, 0x0106, 0x00C7, 0x010C, 0x00C9, 0x0118, 0x00CB, 0x011A, 0x00CD, 0x00CE, 0x010E, // + 0x0110, 0x0143, 0x0147, 0x00D3, 0x00D4, 0x0150, 0x00D6, 0x00D7, 0x0158, 0x016E, 0x00DA, 0x0170, 0x00DC, 0x00DD, 0x0162, 0x00DF, // + 0x0155, 0x00E1, 0x00E2, 0x0103, 0x00E4, 0x013A, 0x0107, 0x00E7, 0x010D, 0x00E9, 0x0119, 0x00EB, 0x011B, 0x00ED, 0x00EE, 0x010F, // + 0x0111, 0x0144, 0x0148, 0x00F3, 0x00F4, 0x0151, 0x00F6, 0x00F7, 0x0159, 0x016F, 0x00FA, 0x0171, 0x00FC, 0x00FD, 0x0163, 0x02D9, +]; + +/// Windows-1251 (Cyrillic) 0x80..=0xFF; 0xC0..=0xFF are А..я in order. +const CP1251: [u16; 128] = { + let low: [u16; 64] = [ + 0x0402, 0x0403, 0x201A, 0x0453, 0x201E, 0x2026, 0x2020, 0x2021, 0x20AC, 0x2030, 0x0409, 0x2039, 0x040A, 0x040C, 0x040B, 0x040F, // + 0x0452, 0x2018, 0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, 0, 0x2122, 0x0459, 0x203A, 0x045A, 0x045C, 0x045B, 0x045F, // + 0x00A0, 0x040E, 0x045E, 0x0408, 0x00A4, 0x0490, 0x00A6, 0x00A7, 0x0401, 0x00A9, 0x0404, 0x00AB, 0x00AC, 0x00AD, 0x00AE, 0x0407, // + 0x00B0, 0x00B1, 0x0406, 0x0456, 0x0491, 0x00B5, 0x00B6, 0x00B7, 0x0451, 0x2116, 0x0454, 0x00BB, 0x0458, 0x0405, 0x0455, 0x0457, + ]; + let mut t = [0u16; 128]; + let mut i = 0; + while i < 128 { + t[i] = if i < 64 { low[i] } else { 0x0410 + (i as u16 - 64) }; + i += 1; + } + t +}; diff --git a/crates/metafile/src/wmf.rs b/crates/metafile/src/wmf.rs index d221a76..12733e1 100644 --- a/crates/metafile/src/wmf.rs +++ b/crates/metafile/src/wmf.rs @@ -1,14 +1,19 @@ //! WMF reader (MS-WMF). A file is an optional 22-byte placeable header, a 9-word standard header, then //! records of (size in 16-bit words, function, parameters). Parameters are read at byte 6 of each record. +use std::sync::Arc; + use crate::bytes::Bytes; -use crate::canvas::{Brush, Canvas, Obj, Pen, colorref}; +use crate::canvas::{BS_HATCHED, Brush, Canvas, LogFont, Obj, Pen, colorref}; use crate::dib; use crate::geom::{self, ArcKind, P}; +use crate::text::{ETO_CLIPPED, ETO_OPAQUE, Font, MAX_CHARS, decode_bytes}; use crate::{Error, Frame, MAX_PATH_OPS}; const PLACEABLE_KEY: u32 = 0x9AC6_CDD7; const DEFAULT_INCH: f64 = 1440.0; +/// A placeable WMF plays into a target sized in points; brushes are laid out in 96-dpi pixels. +const PT_PER_PX: f64 = 72.0 / 96.0; pub(crate) fn is_wmf(b: Bytes) -> bool { if b.u32(0) == Some(PLACEABLE_KEY) { @@ -42,6 +47,7 @@ pub(crate) fn parse(input: Bytes) -> Result<(Canvas, Option), 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.pattern_px = PT_PER_PX; } let mut player_viewport = bounds.is_some(); let mut parsed = 0usize; @@ -128,12 +134,31 @@ fn record(cv: &mut Canvas, func: u16, r: Bytes) { }), 0x02FA => create_pen(cv, r), 0x02FC => create_brush(cv, r), - // Fonts, palettes, pattern brushes and regions take a handle slot too, so later SELECTOBJECT - // handles count them; selecting one draws nothing. - 0x02FB | 0x00F7 | 0x01F9 | 0x0142 | 0x06FF => { + 0x02FB => { + let f = font(r).unwrap_or_default(); + cv.add_object(None, Obj::Font(Arc::new(f))); + Some(()) + } + 0x0142 => { + // A pattern the reader cannot decode still takes its handle slot. + let b = dib_pattern_brush(r).map_or(Obj::Other, Obj::Brush); + cv.add_object(None, b); + Some(()) + } + // Palettes, old-style pattern brushes and regions take a handle slot too, so later + // SELECTOBJECT handles count them; selecting one draws nothing. + 0x00F7 | 0x01F9 | 0x06FF => { cv.add_object(None, Obj::Other); Some(()) } + 0x0209 => r.u32(6).map(|c| cv.set_text_color(c)), + 0x0201 => r.u32(6).map(|c| cv.set_bk_color(c)), + 0x0102 => r.u16(6).map(|m| cv.set_bk_mode(u32::from(m))), + 0x012E => r.u16(6).map(|a| cv.set_text_align(u32::from(a))), + 0x0A32 => ext_text_out(cv, r), + 0x0521 => text_out(cv, r), + // PATBLT: rop(4), height, width, y, x. + 0x061D => r.u32(6).map(|rop| cv.fill_rop([i16s(r, 16), i16s(r, 14), i16s(r, 12), i16s(r, 10)], rop)), 0x012D => r.u16(6).map(|h| cv.select(wmf_handle(h))), 0x01F0 => r.u16(6).map(|h| cv.delete_object(u32::from(h))), // STRETCHDIB: rop(4) usage(2) srcH srcW ySrc xSrc destH destW yDest xDest, then the DIB. @@ -168,11 +193,93 @@ fn create_pen(cv: &mut Canvas, r: Bytes) -> Option<()> { Some(()) } -/// CREATEBRUSHINDIRECT: style, colour, hatch (hatch is not drawn). +/// CREATEBRUSHINDIRECT: style, colour, hatch. fn create_brush(cv: &mut Canvas, r: Bytes) -> Option<()> { - let style = r.u16(6)?; + let style = u32::from(r.u16(6)?); let color = r.u32(8)?; - cv.add_object(None, Obj::Brush(Brush { style: u32::from(style), color: colorref(color) })); + let mut b = Brush::new(style, colorref(color)); + if style == BS_HATCHED { + b.hatch = u32::from(r.u16(12).unwrap_or(0)); + } + cv.add_object(None, Obj::Brush(b)); + Some(()) +} + +/// DIBCREATEPATTERNBRUSH: style, colour usage, then a DIB (MS-WMF 2.3.4.8). The DIB's own palette +/// gives the colours; with DIB_PAL_COLORS (indices into a palette the reader does not keep) a +/// monochrome DIB takes the text and background colours. +fn dib_pattern_brush(r: Bytes) -> Option { + let usage = r.u16(8)?; + let dib = r.tail(10)?; + let n = dib::info_len(dib)?; + let img = dib::decode(Bytes(dib.slice(0, n)?), dib.tail(n)?, false)?; + Brush::pattern(img, usage == 1) +} + +/// Font object (MS-WMF 2.2.1.2): height, width, escapement, orientation, weight (i16 each), italic, +/// underline, strike-out and charset bytes, three precision/quality bytes, pitch and family, then the +/// face name (up to 32 bytes, NUL-terminated, in the font's character set). +fn font(r: Bytes) -> Option { + let height = r.i16(6)?; + let width = r.i16(8).unwrap_or(0); + let escapement = r.i16(10).unwrap_or(0); + let weight = r.i16(14).unwrap_or(0); + let byte = |o: usize| r.0.get(o).copied().unwrap_or(0); + let charset = byte(19); + let name = r.0.get(24..).unwrap_or(&[]); + let name = name.get(..32).unwrap_or(name); + let name = name.split(|b| *b == 0).next().unwrap_or(&[]); + // Face names are plain ASCII in practice; other bytes go through the Western code page. + let (family, _) = decode_bytes(name, 0, &[]); + Some(LogFont { + font: Font { + family: family.trim().to_string(), + height: f32::from(height), + width: f32::from(width), + weight: weight.clamp(0, 1000) as u16, + italic: byte(16) != 0, + underline: byte(17) != 0, + strike: byte(18) != 0, + charset, + }, + escapement: f64::from(escapement), + }) +} + +/// The charset of the selected font, for decoding 8-bit text. +fn charset(cv: &Canvas) -> u8 { + cv.font_charset() +} + +/// EXTTEXTOUT (MS-WMF 2.3.3.5): y, x, string length, options, the rectangle (left, top, right, +/// bottom) when ETO_OPAQUE or ETO_CLIPPED is set, the string padded to a word, then an optional dx +/// array of i16 advances. +fn ext_text_out(cv: &mut Canvas, r: Bytes) -> Option<()> { + let (y, x) = (r.i16(6)?, r.i16(8)?); + let n = usize::from(r.u16(10)?).min(MAX_CHARS); + let opts = u32::from(r.u16(12)?); + let has_rect = opts & (ETO_OPAQUE | ETO_CLIPPED) != 0; + let rect = if has_rect { Some([i16s(r, 14), i16s(r, 16), i16s(r, 18), i16s(r, 20)]) } else { None }; + let at = if has_rect { 22 } else { 14 }; + // A string running past the record is cut to what the record holds. + let avail = r.0.len().saturating_sub(at); + let n = n.min(avail); + let bytes = r.slice(at, n)?; + let dx_at = at + n.next_multiple_of(2); + let dx: Vec = if r.0.len() >= dx_at + 2 * n { (0..n).filter_map(|i| r.i16(dx_at + 2 * i)).map(f32::from).collect() } else { Vec::new() }; + let (text, dx) = decode_bytes(bytes, charset(cv), &dx); + cv.text((f64::from(x), f64::from(y)), text, dx, opts, rect); + Some(()) +} + +/// TEXTOUT (MS-WMF 2.3.3.20): string length, the string padded to a word, then y and x. +fn text_out(cv: &mut Canvas, r: Bytes) -> Option<()> { + let n = usize::from(r.u16(6)?).min(MAX_CHARS); + let bytes = r.slice(8, n)?; + let at = 8 + n.next_multiple_of(2); + let (y, x) = (r.i16(at)?, r.i16(at + 2)?); + let (text, _) = decode_bytes(bytes, charset(cv), &[]); + cv.text((f64::from(x), f64::from(y)), text, Vec::new(), 0, None); Some(()) } diff --git a/crates/pptx/src/read/mod.rs b/crates/pptx/src/read/mod.rs index 692cfe5..057614e 100644 --- a/crates/pptx/src/read/mod.rs +++ b/crates/pptx/src/read/mod.rs @@ -711,6 +711,10 @@ fn read_slide(imp: &mut Imp, part: &Part, doc: &Doc, id: SlideId, layout: Layout if let Some(tree) = csld.child("spTree") { s.shapes = shapes::sp_tree(imp, part, &mut ctx, tree, 0); } + if let Some(c) = csld.child("controls") { + let pics = shapes::controls(imp, part, &mut ctx, c); + s.shapes.extend(pics); + } } if let Some(t) = root.child("transition") { s.transition = timing::transition(imp, part, t); diff --git a/crates/pptx/src/read/shapes.rs b/crates/pptx/src/read/shapes.rs index db778de..51b2b86 100644 --- a/crates/pptx/src/read/shapes.rs +++ b/crates/pptx/src/read/shapes.rs @@ -87,6 +87,22 @@ pub fn sp_tree(imp: &mut Imp, part: &Part, ctx: &mut IdCtx, tree: &El, depth: us out } +/// ActiveX controls (`p:controls`): each control's picture (`p:pic`), the image PowerPoint shows +/// for it, as a picture above the slide's shapes. Controls without one are left out. +pub fn controls(imp: &mut Imp, part: &Part, ctx: &mut IdCtx, controls: &El) -> Vec { + let mut out = vec![]; + for c in controls.children_named("control") { + if ctx.count >= MAX_SHAPES { + break; + } + if let Some(p) = c.child("pic") { + ctx.count += 1; + out.push(pic(imp, part, ctx, p)); + } + } + out +} + fn remap_connectors(shapes: &mut [Shape], ctx: &IdCtx, depth: usize) { if depth > MAX_TREE_DEPTH { return; diff --git a/crates/pptx/tests/ole_vml.rs b/crates/pptx/tests/ole_vml.rs new file mode 100644 index 0000000..8f6c007 --- /dev/null +++ b/crates/pptx/tests/ole_vml.rs @@ -0,0 +1,84 @@ +//! Legacy OLE objects whose preview picture lives in the slide's VML drawing, and ActiveX controls +//! shown by their pictures. + +use std::io::{Cursor, Read, Write}; + +use deckcraft_model::ShapeKind; +use deckcraft_model::defaults; + +const FRAME: &str = r#""#; + +const VML: &str = r#""#; + +const VML_RELS: &str = r#""#; + +const SLIDE_RELS: &str = r#""#; + +/// An ActiveX control as PowerPoint writes it: a VML-only choice, and a fallback with its picture. +const CONTROLS: &str = r#""#; + +fn png(v: u8) -> Vec { + let mut out = Cursor::new(vec![]); + image::DynamicImage::ImageRgb8(image::RgbImage::from_pixel(2, 2, image::Rgb([v, v, v]))) + .write_to(&mut out, image::ImageFormat::Png) + .expect("png"); + out.into_inner() +} + +/// A new deck whose first slide holds [`FRAME`], with the VML drawing, its rels and two pictures. +fn deck() -> Vec { + let bytes = deckcraft_pptx::export(&defaults::new_presentation(None)).expect("export"); + let mut z = zip::ZipArchive::new(Cursor::new(bytes)).expect("zip"); + let mut out = zip::ZipWriter::new(Cursor::new(Vec::new())); + let o = zip::write::SimpleFileOptions::default(); + for i in 0..z.len() { + let mut f = z.by_index(i).expect("entry"); + let mut d = vec![]; + f.read_to_end(&mut d).expect("read"); + let name = f.name().to_string(); + if name == "ppt/slides/slide1.xml" { + let s = String::from_utf8(d).expect("utf8"); + let at = s.rfind("").expect("spTree"); + let s = format!("{}{FRAME}{}", &s[..at], &s[at..]); + let at = s.rfind("").expect("cSld"); + d = format!("{}{CONTROLS}{}", &s[..at], &s[at..]).into_bytes(); + } else if name == "ppt/slides/_rels/slide1.xml.rels" { + let s = String::from_utf8(d).expect("utf8"); + d = s.replace("", &format!("{SLIDE_RELS}")).into_bytes(); + } + out.start_file(name, o).expect("start"); + out.write_all(&d).expect("write"); + } + for (name, data) in [ + ("ppt/drawings/vmlDrawing1.vml", VML.as_bytes().to_vec()), + ("ppt/drawings/_rels/vmlDrawing1.vml.rels", VML_RELS.as_bytes().to_vec()), + ("ppt/media/other.png", png(10)), + ("ppt/media/preview.png", png(200)), + ] { + out.start_file(name, o).expect("start"); + out.write_all(&data).expect("write"); + } + out.finish().expect("finish").into_inner() +} + +#[test] +fn an_ole_object_without_a_picture_shows_its_vml_preview() { + let p = deckcraft_pptx::import(&deck()).expect("import"); + let s = p.slides[0].shapes.iter().find(|s| s.name == "Object 3").expect("frame"); + let ShapeKind::Opaque { preview: Some(m), label, .. } = &s.kind else { panic!("{:?}", s.kind) }; + assert_eq!(label, "Thing.1"); + let media = p.media.iter().find(|x| x.id == *m).expect("media"); + // The shape whose id is the object's spid, not the first one in the drawing. + assert_eq!(media.name, "preview.png"); +} + +#[test] +fn activex_controls_show_their_pictures() { + let p = deckcraft_pptx::import(&deck()).expect("import"); + let s = p.slides[0].shapes.iter().find(|s| s.name == "CheckBox1").expect("control picture"); + let ShapeKind::Picture { fill } = &s.kind else { panic!("{:?}", s.kind) }; + assert_eq!(p.media.iter().find(|m| m.id == fill.media).map(|m| m.name.as_str()), Some("preview.png")); + let x = s.xfrm.expect("xfrm"); + assert_eq!((x.x, x.y, x.w, x.h), (1.0, 2.0, 10.0, 5.0)); + assert!(!p.slides[0].shapes.iter().any(|s| s.name == "NoPicture"), "a control without a picture draws nothing"); +} diff --git a/crates/render/src/metafile.rs b/crates/render/src/metafile.rs index 8afcfb2..1235097 100644 --- a/crates/render/src/metafile.rs +++ b/crates/render/src/metafile.rs @@ -1,17 +1,30 @@ //! Windows Metafile and Enhanced Metafile pictures (`image/x-wmf`, `image/x-emf`): parsed by //! `deckcraft-metafile` and drawn as vectors into a bitmap of the size the slide needs, so they go //! through the same crop, tile and recolour path as any other picture. +//! +//! Text items name the font the file asks for; they are drawn with the face slide text would use +//! for that family (`FontDb::document_face`, so a missing font falls back the same way), symbol +//! fonts mapped as bullets map them, and the file's own advances (the dx array) placing each +//! character. +use std::collections::HashMap; use std::sync::Arc; -use deckcraft_metafile::PlacedItem; -use kurbo::{Affine, Rect, Shape as _}; +use deckcraft_fonts::{FontDb, FontFace}; +use deckcraft_metafile::{HAlign, Item, PlacedItem, Text, Tile, VAlign}; +use kurbo::{Affine, BezPath, Rect, Shape as _}; +use skrifa::MetadataProvider as _; use vello_cpu::{Pixmap, RenderContext, Resources, peniko}; /// Longest side of a metafile drawn without a target size (export, thumbnails of the picture). pub const DEFAULT_SIDE: u32 = 1024; /// Largest side drawn. const MAX_SIDE: u32 = 8192; +/// Pattern cells smaller than this (output pixels) are drawn as the tile's average colour: a +/// sub-pixel pattern would only alias. +const MIN_CELL_PX: f64 = 1.5; +/// Size of text whose font gives no height (LOGFONT height 0), in text units. +const DEFAULT_EM: f64 = 12.0; /// Is `bytes` a WMF or EMF file? pub fn is_metafile(bytes: &[u8]) -> bool { @@ -31,6 +44,8 @@ pub fn rasterize(bytes: &[u8], side: u32) -> Option { let w = (pw * k).round().clamp(1.0, MAX_SIDE as f64) as u16; let h = (ph * k).round().clamp(1.0, MAX_SIDE as f64) as u16; let pixmaps: Vec>> = pic.items.iter_mut().map(|it| it.take_pixels().and_then(|(w, h, px)| premul(w, h, &px))).collect(); + // Pattern tiles shared by several fills are converted once. + let mut tiles: HashMap<*const Tile, (Option>, peniko::Color)> = HashMap::new(); let mut ctx = RenderContext::new_with(w, h, vello_cpu::RenderSettings { num_threads: 0, ..Default::default() }); let r = Rect::new(0.0, 0.0, w as f64, h as f64); ctx.push_clip_layer(&r.to_path(0.1)); @@ -60,6 +75,43 @@ pub fn rasterize(bytes: &[u8], side: u32) -> Option { 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 }; + 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 }); + let cell = tile.determinant().abs().sqrt(); + match pm { + Some(pm) if cell >= MIN_CELL_PX => { + ctx.set_paint(vello_cpu::Image { + image: vello_cpu::ImageSource::Pixmap(pm), + sampler: peniko::ImageSampler { + x_extend: peniko::Extend::Repeat, + y_extend: peniko::Extend::Repeat, + quality: peniko::ImageQuality::Low, + alpha: 1.0, + }, + }); + ctx.set_paint_transform(tile); + ctx.fill_path(&path); + ctx.reset_paint_transform(); + } + _ => { + ctx.set_paint(avg); + ctx.fill_path(&path); + } + } + } + PlacedItem::Text { index, transform, clip } => { + let Some(Item::Text(t)) = pic.items.get(index) else { continue }; + if let Some(c) = clip { + ctx.push_clip_layer(&c.to_path(0.1)); + } + draw_text(&mut ctx, t, transform); + if clip.is_some() { + ctx.pop_layer(); + } + } } } ctx.set_fill_rule(peniko::Fill::NonZero); @@ -81,10 +133,213 @@ pub fn rasterize(bytes: &[u8], side: u32) -> Option { image::RgbaImage::from_raw(w as u32, h as u32, px) } +/// One glyph placed in text space (x along the baseline from the string's start, font units at +/// `scale` text units each). +struct Glyph { + face: Arc, + gid: u32, + x: f64, + y: f64, +} + +/// Draws a text item; `m` maps its text space to the output. +fn draw_text(ctx: &mut RenderContext, t: &Text, m: Affine) { + let db = FontDb::global(); + let font = &t.font; + let family = if font.family.trim().is_empty() { "Arial" } else { font.family.trim() }; + let (bold, italic) = (font.weight >= 600, font.italic); + let style = match (bold, italic) { + (true, true) => "Bold Italic", + (true, false) => "Bold", + (false, true) => "Italic", + (false, false) => "Regular", + }; + let face = db.document_face(family, style); + let upem = face.upem.max(1.0); + let (asc, desc) = win_metrics(&face); + // LOGFONT height: negative is the em, positive the cell (ascent plus descent). + let h = f64::from(font.height); + let em = if h < 0.0 { + -h + } else if h > 0.0 { + h * upem / (asc + desc).max(1.0) + } else { + DEFAULT_EM + }; + let k = em / upem; + if !k.is_finite() || k <= 0.0 { + return; + } + // Characters as drawn: symbol fonts (Wingdings…) map like bullets; each character takes the + // face that has it. + let chars: Vec = t.text.chars().map(|c| deckcraft_text::symbol_char(&face, family, c).unwrap_or(c)).collect(); + let faces: Vec> = chars + .iter() + .map(|c| { + if c.is_whitespace() || c.is_control() || face.covers(*c) { face.clone() } else { db.cascade(&[*c], std::slice::from_ref(&face), style) } + }) + .collect(); + // Shape runs of one face; record each glyph's pen position and its character. + let mut shaped: Vec<(Glyph, usize)> = Vec::with_capacity(chars.len()); + let mut char_pen: Vec = vec![0.0; chars.len() + 1]; + let mut pen = 0.0; + let mut i = 0; + while i < chars.len() { + let Some(f) = faces.get(i).cloned() else { break }; + let mut j = i + 1; + while faces.get(j).is_some_and(|g| g.id() == f.id()) { + j += 1; + } + let run: String = chars.get(i..j).unwrap_or(&[]).iter().collect(); + // Byte offset in the run → character index in the string. + let byte_char: Vec = run.char_indices().map(|(b, _)| b).collect(); + let ku = f.upem.max(1.0); + for g in deckcraft_fonts::shape(&f, &run, &[], |c| c) { + let ci = i + byte_char.partition_point(|b| *b < g.cluster).min(j - i - 1); + let s = em / ku; + shaped.push((Glyph { face: f.clone(), gid: g.gid, x: pen + f64::from(g.x_offset) * s, y: -f64::from(g.y_offset) * s }, ci)); + pen += f64::from(g.x_advance) * s; + if let Some(p) = char_pen.get_mut(ci + 1) { + *p = pen; + } + } + i = j; + } + // Character start pens: a character's start is the previous one's end. + for ci in 1..char_pen.len() { + let prev = char_pen.get(ci - 1).copied().unwrap_or(0.0); + if let Some(p) = char_pen.get_mut(ci) + && *p < prev + { + *p = prev; + } + } + // The file's advances place each character; glyphs keep their offset within their character. + let use_dx = !t.dx.is_empty() && t.dx.len() == chars.len(); + let mut width = pen; + let mut x_scale = 1.0; + if use_dx { + let mut starts = Vec::with_capacity(chars.len() + 1); + let mut x = 0.0; + for d in &t.dx { + starts.push(x); + x += f64::from(*d); + } + width = x; + for (g, ci) in &mut shaped { + let (start, pen_start) = (starts.get(*ci).copied().unwrap_or(0.0), char_pen.get(*ci).copied().unwrap_or(0.0)); + g.x = start + (g.x - pen_start); + } + } else if font.width > 0.0 { + // A LOGFONT width narrows or widens the font against its average character width. + let avg = avg_char_width(&face) * k; + if avg > 0.0 { + x_scale = (f64::from(font.width) / avg).clamp(0.05, 20.0); + width *= x_scale; + for (g, _) in &mut shaped { + g.x *= x_scale; + } + } + } + let x0 = match t.h_align { + HAlign::Left => 0.0, + HAlign::Center => -width / 2.0, + HAlign::Right => -width, + }; + let (a, d) = (asc * k, desc * k); + let base = match t.v_align { + VAlign::Baseline => 0.0, + VAlign::Top => a, + VAlign::Bottom => -d, + }; + let at = m * Affine::translate((x0, base)); + ctx.set_fill_rule(peniko::Fill::NonZero); + if let Some(bg) = t.background + && width > 0.0 + { + ctx.set_paint(rgba(bg)); + ctx.fill_path(&(at * Rect::new(0.0, -a, width, d).to_path(0.1))); + } + let color = rgba(t.color); + ctx.set_paint(color); + for (g, _) in &shaped { + let path = db.outline(&g.face, g.gid); + if path.elements().is_empty() { + continue; + } + let s = em / g.face.upem.max(1.0); + let skew = if italic && !g.face.italic { Affine::new([1.0, 0.0, -0.2, 1.0, 0.0, 0.0]) } else { Affine::IDENTITY }; + let gm = at * Affine::translate((g.x, g.y)) * Affine::scale_non_uniform(x_scale, 1.0) * skew * Affine::scale(s); + let p: BezPath = gm * path.as_ref().clone(); + ctx.fill_path(&p); + if bold && g.face.weight < 600.0 { + ctx.set_stroke(kurbo::Stroke::new(em * 0.025 * scale_of(at)).with_join(kurbo::Join::Round)); + ctx.stroke_path(&p); + } + } + // Underline and strike-out: the font's own positions and thickness. + let metrics = face.skrifa().map(|f| f.metrics(skrifa::instance::Size::unscaled(), face.location())); + let deco = |on: bool, d: Option, fallback: (f64, f64)| -> Option { + if !on || width <= 0.0 { + return None; + } + let (off, th) = d.map_or(fallback, |d| (f64::from(d.offset) * k, f64::from(d.thickness) * k)); + let th = if th > 0.0 { th } else { em / 20.0 }; + // Offsets are y-up from the baseline; text space is y-down. + Some(Rect::new(0.0, -off - th / 2.0, width, -off + th / 2.0)) + }; + let lines = [ + deco(font.underline, metrics.as_ref().and_then(|m| m.underline), (-em * 0.1, em / 20.0)), + deco(font.strike, metrics.as_ref().and_then(|m| m.strikeout), (em * 0.26, em / 20.0)), + ]; + for r in lines.into_iter().flatten() { + ctx.fill_path(&(at * r.to_path(0.1))); + } +} + +/// Mean scale of an affine transform's linear part. +fn scale_of(m: Affine) -> f64 { + m.determinant().abs().sqrt() +} + +/// The face's Windows ascent and descent (OS/2 `usWinAscent`/`usWinDescent`, as GDI measures the +/// cell), in font units; the typographic ones when the font has no OS/2 table. +fn win_metrics(face: &FontFace) -> (f64, f64) { + use skrifa::raw::TableProvider as _; + let os2 = face.skrifa().and_then(|f| f.os2().ok()).map(|o| (f64::from(o.us_win_ascent()), f64::from(o.us_win_descent()))); + match os2 { + Some((a, d)) if a + d > 0.0 => (a, d), + _ => (face.ascent.max(0.0), face.descent.max(0.0)), + } +} + +/// OS/2 `xAvgCharWidth` in font units (half an em without it). +fn avg_char_width(face: &FontFace) -> f64 { + use skrifa::raw::TableProvider as _; + face.skrifa().and_then(|f| f.os2().ok()).map(|o| f64::from(o.x_avg_char_width())).filter(|w| *w > 0.0).unwrap_or(face.upem * 0.5) +} + fn rgba(c: [u8; 4]) -> peniko::Color { peniko::Color::from_rgba8(c[0], c[1], c[2], c[3]) } +/// The average colour of straight RGBA pixels (alpha-weighted). +fn average(px: &[u8]) -> peniko::Color { + let (mut r, mut g, mut b, mut a, mut n) = (0u64, 0u64, 0u64, 0u64, 0u64); + for p in px.as_chunks::<4>().0 { + let w = u64::from(p[3]); + r += u64::from(p[0]) * w; + g += u64::from(p[1]) * w; + b += u64::from(p[2]) * w; + a += w; + n += 1; + } + if a == 0 || n == 0 { + return peniko::Color::from_rgba8(0, 0, 0, 0); + } + peniko::Color::from_rgba8((r / a) as u8, (g / a) as u8, (b / a) as u8, (a / n) as u8) +} + /// Straight RGBA rows to a premultiplied pixmap. fn premul(w: u32, h: u32, px: &[u8]) -> Option> { if w == 0 || h == 0 || w > u16::MAX as u32 || h > u16::MAX as u32 || px.len() < w as usize * h as usize * 4 { diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index d9a5883..ee71391 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -1268,3 +1268,178 @@ fn text_spilling_out_of_its_box_below_the_slide_is_reported() { let (_, drawn) = render_slide_drawn(&Presentation::default(), 0, &RenderOpts { scale: 0.5, edit: true, ..Default::default() }); assert_eq!(drawn, Rect::new(0.0, 0.0, 960.0, 540.0)); } + +/// A placeable WMF (1440 units per inch, bounds `w` × `h`) holding `records` (function, parameter +/// bytes). +fn wmf_with(w: i16, h: i16, records: &[(u16, Vec)]) -> Vec { + let mut body = Vec::new(); + for (func, params) in records { + let mut p = params.clone(); + if p.len() % 2 == 1 { + p.push(0); + } + body.extend(((6 + p.len()) as u32 / 2).to_le_bytes()); + body.extend(func.to_le_bytes()); + body.extend(p); + } + body.extend([3, 0, 0, 0, 0, 0]); + let mut f = 0x9AC6_CDD7u32.to_le_bytes().to_vec(); + f.extend(0u16.to_le_bytes()); + for v in [0i16, 0, w, h] { + f.extend(v.to_le_bytes()); + } + f.extend(1440u16.to_le_bytes()); + f.extend(0u32.to_le_bytes()); + f.extend(0u16.to_le_bytes()); + let total = (f.len() + 18 + body.len()) / 2; + for v in [1u16, 9, 0x300] { + f.extend(v.to_le_bytes()); + } + f.extend((total as u32).to_le_bytes()); + f.extend(16u16.to_le_bytes()); + f.extend(0u32.to_le_bytes()); + f.extend(0u16.to_le_bytes()); + f.extend(body); + f +} + +fn i16s(v: &[i32]) -> Vec { + v.iter().flat_map(|x| (*x as i16).to_le_bytes()).collect() +} + +/// CREATEFONTINDIRECT (height, escapement, face) then SELECTOBJECT of it (handle 0), transparent +/// background, and the alignment `ta`. +fn font_records(height: i32, esc: i32, face: &str, ta: i32) -> Vec<(u16, Vec)> { + let mut f = i16s(&[height, 0, esc, esc, 400]); + f.extend([0, 0, 0, if face.contains("dings") { 2 } else { 0 }, 0, 0, 0, 0]); + f.extend(face.as_bytes()); + f.push(0); + vec![(0x02FB, f), (0x012D, i16s(&[0])), (0x0102, i16s(&[1])), (0x012E, i16s(&[ta]))] +} + +/// EXTTEXTOUT at (x, y) with an optional dx array. +fn text_record(x: i32, y: i32, s: &str, dx: &[i32]) -> (u16, Vec) { + let mut v = i16s(&[y, x, s.len() as i32, 0]); + v.extend(s.as_bytes()); + if s.len() % 2 == 1 { + v.push(0); + } + v.extend(i16s(dx)); + (0x0A32, v) +} + +/// Bounding box (x0, y0, x1, y1) of the pixels at least half opaque and darker than mid-grey. +fn mf_ink(img: &image::RgbaImage) -> Option<(u32, u32, u32, u32)> { + let mut b: Option<(u32, u32, u32, u32)> = None; + for (x, y, p) in img.enumerate_pixels() { + if p[3] > 128 && (p[0] as u32 + p[1] as u32 + p[2] as u32) < 384 { + b = Some(match b { + None => (x, y, x, y), + Some((a, c, d, e)) => (a.min(x), c.min(y), d.max(x), e.max(y)), + }); + } + } + b +} + +#[test] +fn metafile_text_ink_lands_at_its_reference_point() { + // 2 × 1 inch (144 × 72 pt) drawn 288 px wide: 2 px per point. A 36-point "HH", top-left + // aligned at (72, 18) pt. + let mut recs = font_records(-720, 0, "Arial", 0); + recs.push(text_record(1440, 360, "HH", &[])); + let img = crate::metafile::rasterize(&wmf_with(2880, 1440, &recs), 288).expect("raster"); + let (x0, y0, x1, y1) = mf_ink(&img).expect("text ink"); + assert!((144..156).contains(&x0), "left edge at the reference point: {x0}"); + assert!(x1 > x0 + 40 && x1 < 288, "two capitals wide: {x1}"); + // The cell's top is at 36 px; a capital starts below the cell top and sits on the baseline. + assert!((38..72).contains(&y0), "cap top below the cell top: {y0}"); + assert!((88..112).contains(&y1), "baseline one ascent below the top: {y1}"); +} + +#[test] +fn metafile_text_centre_baseline_and_escapement() { + // TA_CENTER | TA_BASELINE at (72, 36) pt: the ink is centred on 144 px and sits on 72 px. + let mut recs = font_records(-720, 0, "Arial", 24 | 6); + recs.push(text_record(1440, 720, "HH", &[])); + let img = crate::metafile::rasterize(&wmf_with(2880, 1440, &recs), 288).expect("raster"); + let (x0, _, x1, y1) = mf_ink(&img).expect("ink"); + assert!(((x0 + x1) as i32 / 2 - 144).abs() <= 4, "centred: {x0}..{x1}"); + assert!((69..=74).contains(&y1), "on the baseline: {y1}"); + // Escapement 90°: the string runs up the picture. + let mut recs = font_records(-360, 900, "Arial", 24); + recs.push(text_record(1440, 1300, "HHHH", &[])); + let img = crate::metafile::rasterize(&wmf_with(2880, 1440, &recs), 288).expect("raster"); + let (x0, y0, x1, y1) = mf_ink(&img).expect("ink"); + assert!(y1 - y0 > 2 * (x1 - x0), "a vertical run: {x0} {y0} {x1} {y1}"); + assert!(x1 <= 146 && y1 <= 132, "drawn up and left of the baseline origin (144, 130): {x1} {y1}"); +} + +#[test] +fn metafile_text_follows_its_dx_array() { + // "HH" with the second H a full inch (144 px) after the first: two clumps of ink, a gap between. + let mut recs = font_records(-360, 0, "Arial", 24); + recs.push(text_record(360, 720, "HH", &[1440, 100])); + let img = crate::metafile::rasterize(&wmf_with(2880, 1440, &recs), 288).expect("raster"); + let col_has_ink = |x: u32| (0..img.height()).any(|y| img.get_pixel(x, y)[3] > 128); + assert!((36..56).any(col_has_ink), "first H at 36 px"); + assert!((180..200).any(col_has_ink), "second H one inch later"); + assert!(!(70..170).any(col_has_ink), "nothing between them"); +} + +#[test] +fn metafile_symbol_text_draws_a_bullet_not_a_box() { + // Webdings "n" is a filled circle (installed, or mapped to a black circle where it is not), as + // is Wingdings "l" in a symbol font that is not installed anywhere. + for (face, s) in [("Webdings", "n"), ("Wingdings Not Installed", "l")] { + let mut recs = font_records(-1080, 0, face, 0); + recs.push(text_record(360, 0, s, &[])); + let img = crate::metafile::rasterize(&wmf_with(2880, 1440, &recs), 288).expect("raster"); + let (x0, y0, x1, y1) = mf_ink(&img).expect("ink"); + assert!(x1 - x0 > 10 && y1 - y0 > 10, "{face}: a sizeable mark: {x0} {y0} {x1} {y1}"); + let (cx, cy) = ((x0 + x1) / 2, (y0 + y1) / 2); + assert!(img.get_pixel(cx, cy)[3] > 200, "{face}: filled in the middle"); + for (x, y) in [(x0, y0), (x1, y0), (x0, y1), (x1, y1)] { + assert!(img.get_pixel(x, y)[3] < 128, "{face}: round, not a box: corner ({x}, {y})"); + } + } +} + +#[test] +fn metafile_opaque_text_fills_its_cell() { + // OPAQUE background in red behind a space. + let mut recs = font_records(-720, 0, "Arial", 0); + recs.push((0x0102, i16s(&[2]))); + recs.push((0x0201, 0x0000_00FFu32.to_le_bytes().to_vec())); + recs.push(text_record(1440, 360, " ", &[])); + let img = crate::metafile::rasterize(&wmf_with(2880, 1440, &recs), 288).expect("raster"); + let p = img.get_pixel(148, 60); + assert!(p[0] > 200 && p[1] < 60 && p[3] > 200, "cell filled red: {p:?}"); + assert!(img.get_pixel(140, 60)[3] < 50, "nothing left of the cell"); +} + +#[test] +fn metafile_pattern_brush_tiles_and_averages_when_tiny() { + // A 1-bit 8 × 8 checker (palette white, black), a null pen and a one-inch square. + let mut dib: Vec = [40u32, 8, 8].iter().flat_map(|v| v.to_le_bytes()).collect(); + dib.extend(1u16.to_le_bytes()); + dib.extend(1u16.to_le_bytes()); + dib.extend([0u8; 24]); + dib.extend([255, 255, 255, 0, 0, 0, 0, 0]); + for r in 0..8 { + dib.extend([if r % 2 == 0 { 0x55 } else { 0xAA }, 0, 0, 0]); + } + let mut brush = i16s(&[5, 0]); + brush.extend(dib); + let recs = vec![(0x0142, brush), (0x012D, i16s(&[0])), (0x012D, i16s(&[0x8008])), (0x041B, i16s(&[1440, 1440, 0, 0]))]; + let bytes = wmf_with(1440, 1440, &recs); + // 4 px per point: a 0.75 pt cell is 3 px. + let img = crate::metafile::rasterize(&bytes, 288).expect("raster"); + let (a, b) = (img.get_pixel(1, 1), img.get_pixel(4, 1)); + assert!(a[3] > 200 && b[3] > 200); + assert!((a[0] as i32 - b[0] as i32).abs() > 200, "neighbouring cells alternate: {a:?} {b:?}"); + // Far below one pixel per cell: the average grey. + let img = crate::metafile::rasterize(&bytes, 32).expect("raster"); + let p = img.get_pixel(16, 16); + assert!((100..156).contains(&p[0]) && p[3] > 200, "average grey: {p:?}"); +} diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index ba58f92..cc09349 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -773,9 +773,37 @@ fn symbol_font_char(face: &FontFace, c: &str, font: Option<&str>) -> Option Option { + let code = c as u32; + if !is_symbol_font(font) || !(code < 0x100 || (0xF000..=0xF0FF).contains(&code)) { + return None; + } + let plain = char::from_u32(code & 0xFF)?; + let s = plain.to_string(); + Some(symbol_font_char(face, &s, Some(font)).unwrap_or_else(|| map_symbol_bullet(&s, Some(font)).chars().next().unwrap_or('•'))) +} + fn map_symbol_bullet(c: &str, font: Option<&str>) -> String { let f = font.unwrap_or("").to_ascii_lowercase(); let first = c.chars().next().unwrap_or('•'); + if f.starts_with("webdings") { + let m = match (first as u32) & 0xff { + 0x6e => '●', + 0x67 => '■', + 0x61 => '✓', + 0x72 => '✕', + 0x33 => '◀', + 0x34 => '▶', + 0x35 => '▲', + 0x36 => '▼', + _ => '•', + }; + return m.to_string(); + } if f.contains("wingdings") || f.contains("symbol") || ('\u{f000}'..='\u{f0ff}').contains(&first) { let code = (first as u32) & 0xff; // Wingdings 2 and 3 put different shapes at the same codes. diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index f3dd013..930789b 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -748,3 +748,14 @@ fn an_installed_symbol_font_draws_its_own_bullet() { assert_eq!(symbol_font_char(&face, "\u{ea}", Some("Wingdings 2")), Some('\u{f0ea}')); assert_eq!(symbol_font_char(&face, "\u{ea}", Some("Arial")), None); } + +#[test] +fn symbol_char_maps_symbol_font_codes() { + // Inter is not a symbol font, so the codes map to look-alikes. + let face = FontDb::global().face("Inter", "Regular"); + assert_eq!(symbol_char(&face, "Arial", 'n'), None, "not a symbol font"); + assert_eq!(symbol_char(&face, "Webdings", 'n'), Some('\u{25CF}')); + assert_eq!(symbol_char(&face, "Webdings", '\u{F06E}'), Some('\u{25CF}'), "the private-use form of the code"); + assert_eq!(symbol_char(&face, "Wingdings", '\u{F06E}'), Some('\u{25A0}')); + assert_eq!(symbol_char(&face, "Wingdings", '\u{100}'), None, "not a symbol code"); +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 519a118..44685ea 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -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, bitmaps); text records inside are skipped | +| 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 | | 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,11 +93,12 @@ 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 text inside EMF/WMF. +3. SVG rendering, and clip regions inside EMF/WMF. ## Revision history | Date | Change | Summary | |---|---|---| +| 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-10 | major | Created from PowerPoint 16.113.4's `CFBundleDocumentTypes`, `crates/pptx` reader/writer and tests, and the open round-trip issues | diff --git a/docs/gaps.md b/docs/gaps.md index a433df7..1d35223 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -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 | 8–14 | [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) | | 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) | @@ -77,12 +77,12 @@ 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 - and bitmaps; text records are skipped). 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. Text - drawn inside an EMF/WMF is missing. -- **Estimate:** 4–8 h (an SVG renderer through `resvg`-style crates); EMF text records 2–4 h. + 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). + 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. ### 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-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 | | 2026-10-12 | minor | Text warps render (all 40 presets, adjusts round-trip); the warp gap is now the gallery, command and handles | | 2026-10-10 | minor | Noted that no gap blocks alpha (core-workflow gate passes) | | 2026-10-10 | major | Created: every gap from the 2026-10-10 re-measure and the 16 open GitHub issues, ranked, with beta blockers marked |