diff --git a/Cargo.lock b/Cargo.lock index 60473316c..266217a1a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1061,6 +1061,15 @@ dependencies = [ "serde_json", ] +[[package]] +name = "craft-metafile" +version = "0.1.0" +dependencies = [ + "image", + "kurbo 0.13.1", + "thiserror 2.0.21", +] + [[package]] name = "craft-mime" version = "0.1.0" @@ -1921,6 +1930,12 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" +[[package]] +name = "fax" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" + [[package]] name = "fdeflate" version = "0.3.7" @@ -2964,9 +2979,14 @@ checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" dependencies = [ "bytemuck", "byteorder-lite", + "color_quant", + "gif", "moxcms", "num-traits", "png 0.18.1", + "tiff", + "zune-core", + "zune-jpeg", ] [[package]] @@ -5935,6 +5955,20 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "tiff" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error 2.0.1", + "weezl", + "zune-jpeg", +] + [[package]] name = "time" version = "0.3.55" diff --git a/Cargo.toml b/Cargo.toml index 42a44ab74..12047dc96 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -48,6 +48,7 @@ craft-media = { path = "crates/media", default-features = false } craft-media-testkit = { path = "crates/media-testkit" } craft-mime = { path = "crates/mime" } craft-menu = { path = "crates/menu" } +craft-metafile = { path = "crates/metafile" } craft-pcraft-format = { path = "crates/pcraft-format" } craft-prepress = { path = "crates/prepress" } craft-print = { path = "crates/print" } diff --git a/README.md b/README.md index e7fd5d6c2..26e308c38 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,10 @@ craft- = { git = "https://github.com/storytold/craft-libs", rev = " Picture` plays a file's records on a model of the GDI device context and + returns what was drawn: paths, pattern fills, bitmaps and text, each with its clip. +- `size(bytes)` gives the picture's size from the header alone when the header decides it. +- `sniff(bytes) -> Option` and `is_metafile(bytes)` recognise the formats. +- `Picture::fit` and `Picture::place` fit a picture (with crop fractions) into a rectangle for + callers that draw it; `crop_window` is the crop rule they share with bitmaps. +- `write::emf` and `write::wmf` (feature `write`) write a list of drawing operations. + +The crate depends on `kurbo`, `thiserror` and `image` (PNG, JPEG, GIF, TIFF and BMP decoders, for +the image files that EMF+ and `BI_JPEG`/`BI_PNG` bitmaps carry). It knows no fonts, no file system +and no application. + +## The model + +A `Picture` has a size (points when the file states one: the EMF frame, the placeable WMF box; +device or logical units otherwise) and `items` in paint order, in `0..width` by `0..height`, y down: + +| Item | What it holds | +|---|---| +| `Item::Path` | Segments (`Seg`: move, line, cubic, close), a fill colour, a stroke colour and width (0 is a hairline), the stroke's `LineStyle` (cap, join, miter limit, dashes), the fill rule. | +| `Item::Pattern` | A path filled with a repeating RGBA `Tile`: hatch and bitmap brushes, EMF+ gradients and textures. Fills with the same brush share one tile. | +| `Item::Bitmap` | RGBA8 pixels (straight alpha, rows top-down), the rectangle they fill and a `transform` that also holds a turn or a skew (then the rectangle is the box around it). | +| `Item::Text` | The string, the font the file asks for (family, height, weight, italic, underline, strike, character set), per-character advances, colours, alignment and a transform from text space. | + +Every item carries the clip it was drawn under: `region` (the GDI clip region as rectangles) and +`clips` (clip paths from `SELECTCLIPPATH`, outermost first; items under the same clip share one +`Arc`). An item shows inside its region and inside each of its clip paths. + +`Picture::skipped_records` counts records of a type the reader does not play, so a caller can say +that part of a file was not read. + +### Why this model + +The three apps had two output models. WordCraft's and DeckCraft's readers produced a picture of +paths, bitmaps and text to draw. VectorCraft's reader built VectorCraft document nodes directly, +and kept what an editor needs and a renderer can lose: the pen's caps, joins and dashes, clip +paths as clips, and the turn of an image. The shared model is the picture, extended with exactly +those three things (`LineStyle`, `ClipPath`, `Item::Bitmap::transform`), so an editor can rebuild +objects from it and a renderer can draw it. Bitmaps are decoded RGBA rather than raw DIB bytes +because every caller needs pixels (two draw them, one re-encodes them as PNG), and because the +decoder is where the size, pixel and work limits have to be enforced. + +## Records read + +### WMF (MS-WMF) + +| Group | Records | +|---|---| +| Headers | Placeable header, memory and disk metafile headers. | +| Mapping | `SETMAPMODE`, `SETWINDOWORG`, `SETWINDOWEXT`, `SETVIEWPORTORG`, `SETVIEWPORTEXT`, all map modes (text, metric, isotropic, anisotropic). A placeable file is played into its box the way Office plays it. | +| State | `SAVEDC`, `RESTOREDC`, `SETPOLYFILLMODE`, `SETROP2`, `SETBKMODE`, `SETBKCOLOR`, `SETTEXTCOLOR`, `SETTEXTALIGN`. | +| Objects | `CREATEPENINDIRECT` (style, cap and join bits), `CREATEBRUSHINDIRECT` (solid, null, hatched), `DIBCREATEPATTERNBRUSH`, `CREATEFONTINDIRECT`, `SELECTOBJECT`, `DELETEOBJECT`, stock objects. `CREATEPALETTE`, `CREATEPATTERNBRUSH` and `CREATEREGION` take their handle slot. | +| Drawing | `MOVETO`, `LINETO`, `POLYLINE`, `POLYGON`, `POLYPOLYGON`, `RECTANGLE`, `ROUNDRECT`, `ELLIPSE`, `ARC`, `PIE`, `CHORD`, `PATBLT`. | +| Bitmaps | `STRETCHDIB`, `DIBSTRETCHBLT`, `DIBBITBLT` (with or without a source). | +| Text | `EXTTEXTOUT` (rectangle, opaque, clipped, dx), `TEXTOUT`; code pages 1250, 1251, 1252, the symbol character set, double-byte lead bytes as blanks. | +| Clipping | `INTERSECTCLIPRECT`, `EXCLUDECLIPRECT`. | +| Not read | `OFFSETWINDOWORG`, `OFFSETVIEWPORTORG`, `SCALEWINDOWEXT`, `SCALEVIEWPORTEXT`, flood fills, `SETPIXEL`, region records (`FILLREGION`, `FRAMEREGION`, `PAINTREGION`, `INVERTREGION`, `SELECTCLIPREGION`, `OFFSETCLIPRGN`), the device-dependent `BITBLT`, `STRETCHBLT` and `SETDIBTODEV`. They are counted in `skipped_records`. | + +### EMF (MS-EMF) + +| Group | Records | +|---|---| +| Header | Frame, device size in pixels, millimetres and (when present) micrometres. | +| Mapping | `SETMAPMODE`, window and viewport origin and extent, `SCALEVIEWPORTEXTEX`, `SCALEWINDOWEXTEX`, `SETWORLDTRANSFORM`, `MODIFYWORLDTRANSFORM`. | +| State | `SAVEDC`, `RESTOREDC`, `SETPOLYFILLMODE`, `SETROP2`, `SETBKMODE`, `SETBKCOLOR`, `SETTEXTCOLOR`, `SETTEXTALIGN`, `SETMITERLIMIT`, `SETARCDIRECTION`. | +| Objects | `CREATEPEN`, `EXTCREATEPEN` (cosmetic and geometric, cap, join, user-style dashes), `CREATEBRUSHINDIRECT`, `CREATEMONOBRUSH`, `CREATEDIBPATTERNBRUSHPT`, `EXTCREATEFONTINDIRECTW`, `SELECTOBJECT`, `DELETEOBJECT`, stock objects. | +| Drawing | `MOVETOEX`, `LINETO`, `POLYLINE(16)`, `POLYLINETO(16)`, `POLYGON(16)`, `POLYBEZIER(16)`, `POLYBEZIERTO(16)`, `POLYPOLYLINE(16)`, `POLYPOLYGON(16)`, `RECTANGLE`, `ROUNDRECT`, `ELLIPSE`, `ARC`, `CHORD`, `PIE`. | +| Paths | `BEGINPATH`, `ENDPATH`, `CLOSEFIGURE`, `FILLPATH`, `STROKEPATH`, `STROKEANDFILLPATH`, `ABORTPATH`, `SELECTCLIPPATH` (`RGN_AND`, `RGN_COPY`). | +| Bitmaps | `BITBLT`, `STRETCHBLT`, `STRETCHDIBITS`, `SETDIBITSTODEVICE`, `ALPHABLEND` (constant and per-pixel alpha); source-less raster operations fill with the brush, black or white; `SRCAND`, `SRCPAINT` and `SRCINVERT` are drawn as they look over white. | +| Text | `EXTTEXTOUTA`, `EXTTEXTOUTW`, `SMALLTEXTOUT`; glyph-index strings are left out. | +| Clipping | `INTERSECTCLIPRECT`, `EXCLUDECLIPRECT`, `EXTSELECTCLIPRGN` (all five modes, on rectangles), `SELECTCLIPPATH`. | +| Not read | `ANGLEARC`, `ARCTO`, `POLYDRAW(16)`, `SETPIXELV`, flood fills, region paints, `MASKBLT`, `PLGBLT`, `TRANSPARENTBLT`, `GRADIENTFILL`, `POLYTEXTOUT`, `WIDENPATH`, `SETMETARGN`, `OFFSETCLIPRGN`, `SETLAYOUT`, `SELECTCLIPPATH` with `RGN_OR`, `RGN_XOR` or `RGN_DIFF` (the clip stays as it was). Unknown types are counted in `skipped_records`. | + +### EMF+ (MS-EMFPLUS) + +An EMF+ only file is played from its EMF+ records. A dual file is played from its EMF records. + +| Feature | Read | +|---|---| +| Objects | Brushes (solid, hatch, texture, linear gradient with preset colours or blend factors, path gradient), pens (colour and width), paths, regions, images, fonts, string formats; objects continued over several records. | +| Drawing | `FillRects`, `DrawRects`, `FillPolygon`, `DrawLines`, `FillEllipse`, `DrawEllipse`, `FillPie`, `DrawPie`, `DrawArc`, `FillPath`, `DrawPath`, `DrawBeziers`, `DrawCurve`, `DrawClosedCurve`, `FillClosedCurve`, `FillRegion`, `Clear`. | +| Images | `DrawImage`, `DrawImagePoints`: raw pixel formats, PNG, JPEG, GIF, TIFF and BMP payloads, and nested metafiles (three levels). | +| Text | `DrawString` (alignment, wrapping to the layout width), `DrawDriverString` with character codes, and with glyph indices in the Windows core fonts. | +| State | World and page transforms, `Save`, `Restore`, containers. | +| Clipping | `SetClipRect`, `SetClipPath`, `SetClipRegion`, `ResetClip`, `OffsetClip`: rectangles and convex shapes cut the drawing itself; other shapes clip to their bounding rectangle; region nodes combine by intersect and union. | +| Not read | Pen caps, joins and dashes (EMF+ strokes have the default line style), image attributes and effects, glyph indices of other fonts, region XOR, exclude and complement, rendering hints, `SetRenderingOrigin`, the terminal-server records. | + +### Bitmaps (DIB) + +`BITMAPINFOHEADER` and larger headers at 1, 4, 8, 16, 24 and 32 bits with `BI_RGB` or +`BI_BITFIELDS`; `BITMAPCOREHEADER` at 1, 4, 8 and 24 bits; `BI_JPEG` and `BI_PNG`. Bottom-up and +top-down rows, source rectangles (only the rows and columns a record draws are decoded), mirrored +destinations. RLE compression is not decoded. + +## Writers (feature `write`) + +`write::emf(ops, region, app, title)` and `write::wmf(ops, region)` take `write::Op`s in points, +y down, in paint order: `Fill` (path, fill rule, colour), `Stroke` (path and `Pen`: colour, width, +cap, join, miter limit, dashes), `Image` (RGBA pixels, an affine placement, an opacity), `Clip` +and `Unclip`. `region` is the part of that space the picture covers. + +- EMF keeps curves, writes geometric pens (`EXTCREATEPEN`; dashes as `PS_USERSTYLE` when the pen + can draw them, else one line per dash), path clips inside `SAVEDC`/`RESTOREDC`, opaque images as + `STRETCHDIBITS` and translucent ones as `ALPHABLEND`, each under a world transform. +- WMF flattens curves, writes 16-bit coordinates behind a placeable header (at most 1440 units an + inch, fewer for large pictures), and returns what it could not hold in `write::Wmf` + (`translucent_images`, `turned_images`, `clips_ignored`, `oversized_images`). + +The reader reads back what the writers write; the tests round-trip both formats. + +## Limits + +Every size, count and offset from a file is checked before it is used, with `get` and checked +arithmetic. Past a limit the reader keeps what it has (a shorter picture) or leaves the item out; +it never panics and never allocates from an unchecked count. Each limit has a test. + +| Limit | Value | Past it | +|---|---|---| +| Items in a picture | 200,000 | The parse stops. | +| Bytes of path points and pixels kept (pattern brushes and clip paths included) | 256 MiB | The parse stops. | +| Points in one path bracket; points in one record | 2,000,000 | The bracket stops growing; the record is left out. | +| Object table slots | 4,096 | The object is not stored. | +| `SAVEDC` depth | 1,024 | The parse stops. | +| Clip paths nested on one device context | 32 | The parse stops. | +| Rectangles in a clip region | 64 | Further rectangles are dropped (this can only hide drawing). | +| Pen style entries | 16 | Further entries are dropped. | +| Pixels in one bitmap | 40,000,000 | The bitmap is left out. | +| Bitmap side drawn | 30,000 | The bitmap is left out before it is decoded. | +| JPEG or PNG side | 32,768 | The image is left out before it is decoded. | +| JPEG and PNG pixels decoded in one file | 2^28 | Further images are left out before they are decoded. | +| Pattern tile side | 256 | The brush is not made. | +| Characters in one string | 16,384 (EMF+: 32,768) | The string is cut. | +| EMF+ objects, save stack | 64, 64 | Ignored. | +| EMF+ points in one record; region nodes | 100,000 | The list is cut; the region is not made. | +| EMF+ region depth | 32 | The region is not made. | +| EMF+ gradient stops | 256 | The count is cut. | +| EMF+ continued object | 64 MiB | The size is cut; nothing is reserved ahead. | +| Nested metafiles | 3 levels | The inner picture is not read. | +| Clipping work per item, and per clip area | 1,000,000 and 4,000,000 steps | The item is left out; the clip keeps its previous area. | +| Masking a bitmap to a clip that is not a rectangle | 5,000,000 point tests | The bitmap is left out. | +| Placed path elements (`Picture::place`) | 500,000 | Placing stops. | +| Placed coordinates (`Picture::place`) | ±1,000,000 | The item is left out. | +| Writers: dashes cut from one stroke | 100,000 | The stroke is written solid. | +| Writers: segments from one curve | about 4,096 | The tolerance is raised. | + +The tests parse every prefix of sample files, files with each byte replaced, and random bytes. + +## What stays in the apps + +- Mapping a `Picture` onto the app's document: VectorCraft builds paths, clipping groups, images + and point type from the items (joining consecutive lines into one path, dropping a clip that + covers the whole frame, turning skipped records and hatch and pattern fills into its warnings); + WordCraft and DeckCraft keep a picture as a picture. +- Drawing: rasterising and PDF output of placed items, font lookup, shaping and glyph drawing + (`Text` only names a font), caching of parsed pictures. +- For the writers: walking the document into `write::Op`s. Gradients, pattern fills, type as + outlines, stroke alignment, width profiles, arrowheads, effects and opacity are decided there, + as is scaling an image down before it is written and the wording of any warning. + +## Origin + +| Part | From | +|---|---| +| The reader (`canvas`, `emf`, `wmf`, `geom`, `place`, `bytes`) | DeckCraft `crates/metafile` at `cf0d7fbb`, itself ported from WordCraft at `05679497`. | +| EMF+ (`emfplus`), clip regions (`clip`, `cliprect`), text (`text`), pattern and hatch brushes | DeckCraft. | +| The cropped, depth-once DIB decoder (`dib`), the bitmap side cap, `size`, `crop_window`, the free-slot object table, capacity-based budget charging, the exact reservation for poly-polygon groups | WordCraft `crates/metafile` at `639b8133`, merged three ways over the common ancestor. | +| `LineStyle`, clip paths, `Item::Bitmap::transform`, `skipped_records`, the records behind them (`EXTCREATEPEN` entries, `SETMITERLIMIT`, `SETARCDIRECTION`, `SCALEVIEWPORTEXTEX`, `SCALEWINDOWEXTEX`, `SELECTCLIPPATH`, `ABORTPATH`), closing figures in `STROKEANDFILLPATH`, the micrometre device size, core-header, JPEG and PNG bitmaps, the per-file decode budget | VectorCraft `crates/metafile` at `3deafc24` (its reader's behaviour, written anew on this model). | +| The writers (`write`) | VectorCraft `crates/metafile` (`emf.rs`, `wmf.rs`, the encoding half of `dib.rs`, `vectorcraft_geom::flatten`). | + +All three sources are MIT OR Apache-2.0. The tests build every file they read in code; the crate +bundles no sample files. + +## Migration + +### DeckCraft (`deckcraft-metafile`) + +| Before | After | +|---|---| +| `deckcraft_metafile::{parse, is_metafile, Picture, Item, Seg, Tile, Text, Font, HAlign, VAlign, SYMBOL_CHARSET, PlacedItem, MAX_BITMAP_SIDE, Error}` | The same names in `craft_metafile`. | +| `Item::Path { segs, fill, stroke, even_odd, region }` | Adds `line` and `clips`. Draw `line` when stroking; clip to each of `clips` as to `region`. | +| `Item::Bitmap { rect, .. }`, `Item::Pattern { .. }`, `Text` | Add `clips`; `Item::Bitmap` adds `transform` (equal to `rect` unless the bitmap is turned). | +| `PlacedItem::*` | Add `clips` (`PlacedClip`: a path and its fill rule); `Path` adds `line`; `Bitmap` adds `transform`. | +| `Picture { width, height, items }` | Adds `skipped_records`. | +| `Picture::fit` with crops clamped to 95% a side | `crop_window`: each axis keeps at least 0.5%. | + +### WordCraft (`wordcraft-metafile`) + +| Before | After | +|---|---| +| `parse`, `size`, `is_metafile`, `Picture`, `Seg`, `Error`, `MAX_BITMAP_SIDE`, `crop_window`, `Picture::{has_size, fit, place}`, `Item::take_pixels` | The same names in `craft_metafile`. | +| `Item::Path`, `Item::Bitmap` | Add `region`, `clips`, `line`, `transform` as above; new `Item::Pattern` (hatch and pattern brushes now draw). | +| `Text { text, x, y, size, cell, font, bold, italic, color, angle, halign, valign, dx }` | `Text { transform, text, dx, font: Font { family, height, weight, italic, .. }, color, background, h_align, v_align, clip, region, clips }`. The reference point and angle are in `transform`; `size` is `font.height.abs()` in text units and `cell` is `font.height > 0`; `bold` is `font.weight >= 600`. A height of 0 and an empty family are the file's "default font": WordCraft's 16-unit Arial default moves to WordCraft. | +| `Text::layout`, `TextLayout` | Stay in WordCraft, over the shared `Text`. | +| `PlacedItem::Text { index, tf }` | `PlacedItem::Text { index, transform, clip, region, clips }`. | +| Text from `EXTTEXTOUTW` only | Also `EXTTEXTOUTA`, `SMALLTEXTOUT`, WMF text, opaque backgrounds, EMF+ strings. | + +### VectorCraft (`vectorcraft-metafile`) + +| Before | After | +|---|---| +| `sniff`, `Kind` | `craft_metafile::{sniff, Kind}`. | +| `import(bytes) -> Imported { document, kind, warnings }` | `craft_metafile::parse(bytes)`, then VectorCraft's own mapping of `Picture` to a `Document`: `Item::Path` to a path node (`line` to cap, join, miter limit and dash), `clips` to clipping groups (`Arc::ptr_eq` groups items), `region` to a rectangle clip, `Item::Bitmap` to an image placed by `transform`, `Item::Text` to point type, `Item::Pattern` to its tile or a flat colour, `skipped_records` to the warning. | +| `export(doc, region, kind) -> Output` | VectorCraft's `scene` walk produces `craft_metafile::write::Op`s, then `write::emf(&ops, region, "VectorCraft", &doc.title)` or `write::wmf(&ops, region)`; `Wmf`'s flags become the two WMF warnings. | +| `scene::Pen { cap: LineCap, join: LineJoin, dash: Option }` | `write::Pen { cap: Cap, join: Join, dash: Option }` (`Cap::Flat` is `LineCap::Butt`). A dash with `align_corners`, or one `Dash::emf_pen_draws` refuses, is cut by VectorCraft and passed as the path with no dash, to keep its own dash cutting. | +| `dib::Rgba::fitted`, `to_png` | Stay in VectorCraft. | +| EMF+ only files: a warning and nothing imported | They are read. | diff --git a/crates/metafile/src/bytes.rs b/crates/metafile/src/bytes.rs new file mode 100644 index 000000000..5584d6dff --- /dev/null +++ b/crates/metafile/src/bytes.rs @@ -0,0 +1,59 @@ +//! Bounds-checked little-endian access to hostile input. Every read returns `None` past the end, so +//! a truncated or lying size field stops the parse instead of panicking. + +#[derive(Clone, Copy)] +pub(crate) struct Bytes<'a>(pub(crate) &'a [u8]); + +impl<'a> Bytes<'a> { + pub fn slice(&self, off: usize, len: usize) -> Option<&'a [u8]> { + self.0.get(off..off.checked_add(len)?) + } + + pub fn tail(&self, off: usize) -> Option> { + self.0.get(off..).map(Bytes) + } + + fn arr(&self, off: usize) -> Option<[u8; N]> { + self.slice(off, N)?.try_into().ok() + } + + pub fn u16(&self, off: usize) -> Option { + Some(u16::from_le_bytes(self.arr(off)?)) + } + + pub fn i16(&self, off: usize) -> Option { + Some(i16::from_le_bytes(self.arr(off)?)) + } + + pub fn u32(&self, off: usize) -> Option { + Some(u32::from_le_bytes(self.arr(off)?)) + } + + pub fn i32(&self, off: usize) -> Option { + Some(i32::from_le_bytes(self.arr(off)?)) + } + + pub fn f32(&self, off: usize) -> Option { + Some(f32::from_le_bytes(self.arr(off)?)) + } + + /// `n` consecutive (x, y) pairs of 16- or 32-bit signed integers starting at `off`. + pub fn points(&self, off: usize, n: usize, wide: bool) -> Option> { + let sz = if wide { 8 } else { 4 }; + let raw = self.slice(off, n.checked_mul(sz)?)?; + let mut out = Vec::with_capacity(n); + for c in raw.chunks_exact(sz) { + let p = if wide { + let x = i32::from_le_bytes(c.get(0..4)?.try_into().ok()?); + let y = i32::from_le_bytes(c.get(4..8)?.try_into().ok()?); + (f64::from(x), f64::from(y)) + } else { + let x = i16::from_le_bytes(c.get(0..2)?.try_into().ok()?); + let y = i16::from_le_bytes(c.get(2..4)?.try_into().ok()?); + (f64::from(x), f64::from(y)) + }; + out.push(p); + } + Some(out) + } +} diff --git a/crates/metafile/src/canvas.rs b/crates/metafile/src/canvas.rs new file mode 100644 index 000000000..2b59bb933 --- /dev/null +++ b/crates/metafile/src/canvas.rs @@ -0,0 +1,1419 @@ +//! Drawing state shared by the WMF and EMF readers: window/viewport mapping and the EMF world +//! 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::clip::{self, MAX_CLIP_RECTS}; +use crate::dib::Rgba; +use crate::geom::{Op, P}; +use crate::text::{ETO_CLIPPED, ETO_OPAQUE, Font, TA_UPDATECP, Text, align}; +use crate::{Cap, Join, LineStyle, MAX_BYTES, MAX_ITEMS, MAX_OBJECTS, MAX_PATH_OPS, MAX_STACK}; + +pub(crate) const MM_TEXT: u32 = 1; +const MM_ISOTROPIC: u32 = 7; +const MM_ANISOTROPIC: u32 = 8; +const STOCK: u32 = 0x8000_0000; +pub(crate) const PS_NULL: u32 = 5; +/// Pen style bits; the rest of the style word holds end-cap, join and type flags. +pub(crate) const PS_STYLE_MASK: u32 = 0x0F; +pub(crate) const BS_SOLID: u32 = 0; +/// Cells on the longer side of a computed (gradient or texture) fill tile. +const SHADE_RES: usize = 128; +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 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; +/// Device pixels per millimetre assumed for WMF (96 dpi), and for EMF files without a device size. +const DEFAULT_PX_PER_MM: f64 = 96.0 / 25.4; + +/// Affine transform `[m11, m12, m21, m22, dx, dy]` in EMF XFORM order: `x' = x·m11 + y·m21 + dx`. +pub(crate) type Xform = [f64; 6]; +const IDENTITY: Xform = [1.0, 0.0, 0.0, 1.0, 0.0, 0.0]; + +/// `a` then `b` in EMF row-vector convention: the matrix product `a × b`. +pub(crate) fn mul(a: Xform, b: Xform) -> Xform { + [ + a[0] * b[0] + a[1] * b[2], + a[0] * b[1] + a[1] * b[3], + a[2] * b[0] + a[3] * b[2], + a[2] * b[1] + a[3] * b[3], + a[4] * b[0] + a[5] * b[2] + b[4], + a[4] * b[1] + a[5] * b[3] + b[5], + ] +} + +#[derive(Clone, PartialEq, Debug, Default)] +pub(crate) struct Pen { + /// The whole PS_* style word: line style (low nibble), end cap (0xF00) and join (0xF000). + pub style: u32, + /// Logical width; 0 means a cosmetic one-pixel hairline. + pub width: f64, + pub color: [u8; 4], + /// PS_USERSTYLE dash and gap lengths (logical units; device pixels for a cosmetic pen). + pub entries: Vec, +} + +impl Pen { + pub fn new(style: u32, width: f64, color: [u8; 4]) -> Self { + Pen { style, width, color, entries: Vec::new() } + } +} + +/// PS_USERSTYLE entries kept from one pen (GDI allows 16). +pub(crate) const MAX_DASH_ENTRIES: usize = 16; +/// Clip paths nested by SELECTCLIPPATH (RGN_AND) on one device context. One more stops the parse: +/// what follows could no longer be clipped as the file asks. +pub(crate) const MAX_CLIP_PATHS: usize = 32; +const PS_DASH: u32 = 1; +const PS_DOT: u32 = 2; +const PS_DASHDOT: u32 = 3; +const PS_DASHDOTDOT: u32 = 4; +const PS_USERSTYLE: u32 = 7; +const PS_ENDCAP_MASK: u32 = 0x0F00; +const PS_ENDCAP_SQUARE: u32 = 0x0100; +const PS_ENDCAP_FLAT: u32 = 0x0200; +const PS_JOIN_MASK: u32 = 0xF000; +const PS_JOIN_BEVEL: u32 = 0x1000; +const PS_JOIN_MITER: u32 = 0x2000; +/// GDI's default miter limit. +const DEFAULT_MITER: f64 = 10.0; + +/// The clip an item is drawn under, in device units. +#[derive(Clone, Default)] +pub(crate) struct RawClip { + /// The clip region as rectangles. None is the default clip. + pub rects: Option>, + /// Clip paths (SELECTCLIPPATH): the item shows inside all of them. + pub paths: Vec>, +} + +/// A clip path in device units. +pub(crate) struct RawClipPath { + pub ops: Vec, + pub even_odd: bool, +} + +#[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>, +} + +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), + 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] +} + +/// Millimetres per logical unit of the metric map modes (LOMETRIC, HIMETRIC, LOENGLISH, HIENGLISH, TWIPS). +fn metric_mm(mode: u32) -> Option { + match mode { + 2 => Some(0.1), + 3 => Some(0.01), + 4 => Some(25.4 / 100.0), + 5 => Some(25.4 / 1000.0), + 6 => Some(25.4 / 1440.0), + _ => None, + } +} + +#[derive(Clone, Copy)] +pub(crate) struct Mapping { + wo: P, + we: P, + vo: P, + ve: P, + mode: u32, + world: Xform, + /// Device pixels per millimetre on each axis (EMF: from szlDevice and szlMillimeters). + px_mm: P, +} + +impl Mapping { + fn identity() -> Self { + Mapping { + wo: (0.0, 0.0), + we: (1.0, 1.0), + vo: (0.0, 0.0), + ve: (1.0, 1.0), + mode: MM_TEXT, + world: IDENTITY, + px_mm: (DEFAULT_PX_PER_MM, DEFAULT_PX_PER_MM), + } + } + + /// Logical point to device point: world transform, then window to viewport. + pub fn device(&self, p: P) -> P { + let w = self.world; + let (x, y) = (p.0 * w[0] + p.1 * w[2] + w[4], p.0 * w[1] + p.1 * w[3] + w[5]); + let (sx, sy) = self.ratios(); + ((x - self.wo.0) * sx + self.vo.0, (y - self.wo.1) * sy + self.vo.1) + } + + /// Window-to-viewport scale per axis. MM_TEXT fixes the scale at 1; the metric modes use a fixed + /// scale with y up; MM_ISOTROPIC uses one scale on both axes (the smaller magnitude, each axis's + /// sign kept); anisotropic and unknown modes use the extents as given. + fn ratios(&self) -> (f64, f64) { + if self.mode == MM_TEXT { + return (1.0, 1.0); + } + if let Some(mm) = metric_mm(self.mode) { + return (mm * self.px_mm.0, -mm * self.px_mm.1); + } + let (rx, ry) = (ratio(self.ve.0, self.we.0), ratio(self.ve.1, self.we.1)); + if self.mode == MM_ISOTROPIC { + let s = rx.abs().min(ry.abs()); + return (if rx < 0.0 { -s } else { s }, if ry < 0.0 { -s } else { s }); + } + (rx, ry) + } + + /// Approximate device length per logical length, for pen widths. + fn scale(&self) -> f64 { + let w = self.world; + let det = (w[0] * w[3] - w[1] * w[2]).abs().sqrt(); + let (sx, sy) = self.ratios(); + det * (sx.abs() + sy.abs()) / 2.0 + } + + /// True when the window-to-viewport extents set the scale (the isotropic and anisotropic modes). + pub fn uses_extents(&self) -> bool { + matches!(self.mode, MM_ISOTROPIC | MM_ANISOTROPIC) + } + + pub fn window_ext(&self) -> P { + self.we + } + + pub fn viewport_ext(&self) -> P { + self.ve + } + + /// The window in device units as `[x, y, w, h]` with positive extents, used as the picture frame + /// of a plain WMF. + pub fn window_rect(&self) -> [f64; 4] { + let a = self.device(self.wo); + let b = self.device((self.wo.0 + self.we.0, self.wo.1 + self.we.1)); + [a.0.min(b.0), a.1.min(b.1), (b.0 - a.0).abs(), (b.1 - a.1).abs()] + } +} + +fn ratio(num: f64, den: f64) -> f64 { + if den == 0.0 { 1.0 } else { num / den } +} + +/// Output of drawing, in device units. Path points are already mapped; `lib.rs` normalises them. +/// `region` is the clip the item was drawn under. +pub(crate) enum Raw { + Path { + ops: Vec, + fill: Option<[u8; 4]>, + stroke: Option<([u8; 4], f64)>, + /// Caps, joins and dashes of the stroke (dash lengths in device units). + line: LineStyle, + even_odd: bool, + region: RawClip, + }, + /// A path filled with a repeating tile. + Pattern { ops: Vec, even_odd: bool, tile: Arc, region: RawClip }, + /// `xf` maps the unit square onto the bitmap's place: `[a, b, c, d, e, f]` with + /// `x' = a·x + c·y + e`, `y' = b·x + d·y + f`. `rect` is `[x, y, w, h]` of the same place when + /// the mapping neither turns nor skews it, and the box around it when it does. + Bitmap { rect: [f64; 4], xf: [f64; 6], img: Rgba, region: RawClip }, + /// Text whose transform and clip are in device units. + Text { text: Box, region: RawClip }, +} + +impl Raw { + fn set_region(&mut self, region: RawClip) { + match self { + Raw::Path { region: slot, .. } + | Raw::Pattern { region: slot, .. } + | Raw::Bitmap { region: slot, .. } + | Raw::Text { region: slot, .. } => { + *slot = region; + } + } + } +} + +/// Text drawing state: colours, background mode, alignment and the selected font. +#[derive(Clone)] +struct TextState { + color: [u8; 4], + bk_color: [u8; 4], + bk_opaque: bool, + align: u32, + font: Option>, +} + +#[derive(Clone)] +struct Saved { + map: Mapping, + pen: Pen, + brush: Brush, + even_odd: bool, + rop2: u32, + text: TextState, + clip: Option>, + clip_paths: Vec>, + miter: f64, + arc_clockwise: bool, +} + +pub(crate) struct Canvas { + pub items: Vec, + pub map: Mapping, + /// True once SETWINDOWEXT was seen, so a plain WMF has a frame to use. + pub window_set: bool, + /// True once the file set the viewport extent itself (not the player, see [`Canvas::play_into`]). + pub viewport_set: bool, + saved: Vec, + /// Bytes held by the items so far (path ops and pixels), capped at [`MAX_BYTES`]. + used: usize, + objects: Vec>, + /// Indices below `objects.len()` that hold no object, so the WMF handle table finds its lowest + /// free slot without scanning. + free: std::collections::BTreeSet, + /// Table slots examined by `add_object(None, ..)`; the tests assert it stays constant per call. + #[cfg(test)] + pub slot_probes: usize, + pen: Pen, + brush: Brush, + even_odd: 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, + full: bool, + /// Current clip in device units. None is the default (the whole surface); Some([]) draws nothing. + clip: Option>, + /// Device bounds of the picture, so excluding from the default clip has a surface to cut. + surface: Option<[f64; 4]>, + /// EMF+ convex device clip polygon: drawing is cut to it geometrically. + plus_clip: Option>, + /// Clip paths selected with SELECTCLIPPATH, innermost last. + clip_paths: Vec>, + /// SETMITERLIMIT. + miter: f64, + /// SETARCDIRECTION is AD_CLOCKWISE. + arc_clockwise: bool, + /// Where the open figure of the path bracket started (logical), for CLOSEFIGURE. + fig_start: P, + /// Records of a type the readers do not know. + pub skipped: u32, +} + +impl Canvas { + pub fn new() -> Self { + Canvas { + items: Vec::new(), + map: Mapping::identity(), + window_set: false, + viewport_set: false, + saved: Vec::new(), + used: 0, + objects: Vec::new(), + free: std::collections::BTreeSet::new(), + #[cfg(test)] + slot_probes: 0, + pen: Pen::new(0, 0.0, [0, 0, 0, 255]), + brush: Brush::new(BS_SOLID, [255, 255, 255, 255]), + even_odd: true, + 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(), + full: false, + clip: None, + surface: None, + plus_clip: None, + clip_paths: Vec::new(), + miter: DEFAULT_MITER, + arc_clockwise: false, + fig_start: (0.0, 0.0), + skipped: 0, + } + } + + /// Device bounds `[x0, y0, x1, y1]` of the picture. Used when a clip record cuts the default clip. + pub fn set_clip_surface(&mut self, rect: [f64; 4]) { + self.surface = clip::normalize(rect); + } + + /// True once the item cap is hit; the readers stop there and keep what they have. + pub fn full(&self) -> bool { + self.full + } + + fn push(&mut self, raw: Raw) { + match self.plus_clip.take() { + None => self.push_raw(raw), + Some(c) => { + for r in crate::cliprect::clip_raw(raw, &c) { + self.push_raw(r); + } + self.plus_clip = Some(c); + } + } + } + + fn push_raw(&mut self, mut raw: Raw) { + if self.clip.as_ref().is_some_and(Vec::is_empty) { + return; + } + raw.set_region(RawClip { rects: self.clip.clone(), paths: self.clip_paths.clone() }); + // A clip path's own ops were charged when it was selected; an item holds a pointer to it. + let region_cost = self.clip.as_ref().map_or(0, |c| c.len() * std::mem::size_of::<[f64; 4]>()) + + self.clip_paths.capacity() * std::mem::size_of::>(); + let cost = match &raw { + // What is held, capacity and all. + Raw::Path { ops, line, .. } => ops.capacity().saturating_mul(std::mem::size_of::()).saturating_add(line.dash.capacity() * 4), + // 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.capacity() } else { 0 }; + ops.capacity().saturating_mul(std::mem::size_of::()).saturating_add(64).saturating_add(own) + } + Raw::Bitmap { img, .. } => img.px.capacity(), + Raw::Text { text: t, .. } => { + t.text.capacity().saturating_add(t.dx.capacity().saturating_mul(4)).saturating_add(t.font.family.capacity()).saturating_add(128) + } + } + .saturating_add(region_cost); + if self.items.len() >= MAX_ITEMS || self.used.saturating_add(cost) > MAX_BYTES { + self.full = true; + } else { + self.used += cost; + self.items.push(raw); + } + } + + pub fn set_window_org(&mut self, p: P) { + self.map.wo = p; + } + + pub fn set_window_ext(&mut self, e: P) { + self.map.we = e; + self.window_set = true; + } + + pub fn set_viewport_org(&mut self, p: P) { + self.map.vo = p; + } + + pub fn set_viewport_ext(&mut self, e: P) { + self.map.ve = e; + self.viewport_set = true; + } + + /// Sets up the mapping the way a player does before it plays a WMF into a target: anisotropic, + /// `window` (`[x, y, w, h]` in logical units) onto a viewport of `size` at the origin. The file's + /// own window records then replace the window; the viewport stays unless the file sets one. + /// Without a window (a plain WMF) only the viewport extent is set: the map mode and viewport + /// origin stay the file's. + pub fn play_into(&mut self, window: Option<[f64; 4]>, size: P) { + if let Some([x, y, w, h]) = window { + self.map.mode = MM_ANISOTROPIC; + self.map.wo = (x, y); + self.map.we = (w, h); + self.map.vo = (0.0, 0.0); + } + self.map.ve = size; + } + + pub fn set_map_mode(&mut self, mode: u32) { + self.map.mode = mode; + } + + /// Device pixels per millimetre (x, y) for the metric map modes. Ignored unless both are positive. + pub fn set_device_mm(&mut self, px_mm: P) { + if px_mm.0.is_finite() && px_mm.1.is_finite() && px_mm.0 > 0.0 && px_mm.1 > 0.0 { + self.map.px_mm = px_mm; + } + } + + pub fn set_polyfill(&mut self, mode: u32) { + self.even_odd = mode != WINDING; + } + + /// 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.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). + /// An unknown mode leaves the world transform as it was. + pub fn modify_world(&mut self, x: Xform, mode: u32) { + match mode { + 1 => self.map.world = IDENTITY, + 2 => self.map.world = mul(x, self.map.world), + 3 => self.map.world = mul(self.map.world, x), + 4 => self.map.world = x, + _ => {} + } + } + + /// The current world transform (EMF+ draws through it). + pub fn world(&self) -> Xform { + self.map.world + } + + /// Sets the pen and brush directly (EMF+ records name their own pen and brush). + pub fn set_pen(&mut self, pen: Pen) { + self.pen = pen; + } + + pub fn set_brush(&mut self, brush: Brush) { + self.brush = brush; + } + + /// Sets the EMF+ convex device clip polygon (None: unclipped). + pub fn set_clip(&mut self, clip: Option>) { + self.plus_clip = clip; + } + + /// Logical to device as one affine transform (the mapping is affine). + pub fn device_affine(&self) -> Xform { + let o = self.map.device((0.0, 0.0)); + let x = self.map.device((1.0, 0.0)); + let y = self.map.device((0.0, 1.0)); + [x.0 - o.0, x.1 - o.1, y.0 - o.0, y.1 - o.1, o.0, o.1] + } + + /// Text colour, font and alignment set directly (EMF+ strings name their own font and brush); + /// the background is transparent. + pub fn set_text_style(&mut self, color: [u8; 4], font: LogFont, align: u32) { + self.text.color = color; + self.text.bk_opaque = false; + self.text.align = align; + self.text.font = Some(Arc::new(font)); + } + + /// Pushes an item already in device units (a nested metafile's drawing). + pub fn push_device(&mut self, raw: Raw) { + self.push(raw); + } + + /// Fills logical `ops` with a ready-made tile, laid from the device origin. + pub fn tile_shape(&mut self, ops: &[Op], tile: Arc) { + let dev: Vec = ops.iter().map(|o| self.map_op(*o)).collect(); + if dev.len() >= 2 { + let even_odd = self.even_odd; + self.push(Raw::Pattern { ops: dev, even_odd, tile, region: RawClip::default() }); + } + } + + /// Fills logical `ops` with a colour computed per device point (gradients, textures): a tile + /// of at most [`SHADE_RES`] cells on the longer side, laid out so that its one repeat covers + /// the shape. + pub fn shade_shape(&mut self, ops: &[Op], shade: &dyn Fn(P) -> [u8; 4]) { + let dev: Vec = ops.iter().map(|o| self.map_op(*o)).collect(); + let Some([l, t, r, b]) = crate::cliprect::ops_bbox(&dev) else { return }; + let side = (r - l).max(b - t); + if dev.len() < 2 || !(side.is_finite() && side > 0.0) { + return; + } + let cell = side / SHADE_RES as f64; + let w = (((r - l) / cell).ceil() as usize + 1).clamp(1, SHADE_RES + 1); + let h = (((b - t) / cell).ceil() as usize + 1).clamp(1, SHADE_RES + 1); + let (pw, ph) = (w as f64 * cell, h as f64 * cell); + let mut px = Vec::with_capacity(w * h * 4); + for j in 0..h { + // The device position this tile row stands for inside the shape's box. + let y = t + (j as f64 * cell - t).rem_euclid(ph) + cell / 2.0; + for i in 0..w { + let x = l + (i as f64 * cell - l).rem_euclid(pw) + cell / 2.0; + px.extend_from_slice(&shade((x, y))); + } + } + let even_odd = self.even_odd; + self.push(Raw::Pattern { ops: dev, even_odd, tile: Arc::new(RawTile { w, h, px, cell }), region: RawClip::default() }); + } + + /// Pushes the drawing state. Past [`MAX_STACK`] levels the parse stops, since later restores + /// 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.clone(), + brush: self.brush.clone(), + even_odd: self.even_odd, + rop2: self.rop2, + text: self.text.clone(), + clip: self.clip.clone(), + clip_paths: self.clip_paths.clone(), + miter: self.miter, + arc_clockwise: self.arc_clockwise, + }); + } else { + self.full = true; + } + } + + /// Negative `n` pops `-n` levels; positive `n` restores absolute level `n`. + pub fn restore_dc(&mut self, n: i32) { + let idx = if n < 0 { self.saved.len().checked_sub(n.unsigned_abs() as usize) } else { (n as usize).checked_sub(1) }; + let Some(idx) = idx else { return }; + let Some(s) = self.saved.get(idx).cloned() else { return }; + self.saved.truncate(idx); + self.map = s.map; + self.pen = s.pen; + self.brush = s.brush; + self.even_odd = s.even_odd; + self.rop2 = s.rop2; + self.text = s.text; + self.clip = s.clip; + self.clip_paths = s.clip_paths; + self.miter = s.miter; + self.arc_clockwise = s.arc_clockwise; + } + + /// The logical rectangle as a device rectangle. A scale or translation keeps the rectangle; a + /// rotation stores the bounds of the rotated corners. + fn device_rect(&self, [l, t, r, b]: [f64; 4]) -> Option<[f64; 4]> { + let corners = [(l, t), (r, t), (l, b), (r, b)].map(|p| self.map.device(p)); + if !corners.iter().all(|p| p.0.is_finite() && p.1.is_finite()) { + return None; + } + let (mut x0, mut y0, mut x1, mut y1) = (f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY); + for p in corners { + x0 = x0.min(p.0); + y0 = y0.min(p.1); + x1 = x1.max(p.0); + y1 = y1.max(p.1); + } + clip::normalize([x0, y0, x1, y1]) + } + + fn map_rects(&self, logical: &[[f64; 4]]) -> Vec<[f64; 4]> { + let mut out = Vec::new(); + for r in logical.iter().take(MAX_CLIP_RECTS) { + if let Some(d) = self.device_rect(*r) { + out.push(d); + } + if out.len() >= MAX_CLIP_RECTS { + break; + } + } + out + } + + /// INTERSECTCLIPRECT: later drawing is limited to this logical rectangle as well. + pub fn intersect_clip_rect(&mut self, logical: [f64; 4]) { + self.clip = match self.device_rect(logical) { + Some(rect) => clip::intersect(self.clip.clone(), rect), + None => Some(Vec::new()), + }; + } + + /// EXCLUDECLIPRECT: this logical rectangle is removed from the clip. + pub fn exclude_clip_rect(&mut self, logical: [f64; 4]) { + let Some(rect) = self.device_rect(logical) else { return }; + self.clip = clip::exclude(self.clip.clone(), rect, self.surface); + } + + /// EXTSELECTCLIPRGN: combine these logical rectangles with the clip in a `RGN_*` mode. + pub fn select_clip_rects(&mut self, logical: &[[f64; 4]], mode: u32) { + // RGN_COPY replaces the whole clip, clip paths included. + if mode == clip::RGN_COPY { + self.clip_paths.clear(); + } + self.clip = clip::combine(self.clip.clone(), self.map_rects(logical), mode, self.surface); + } + + /// The default clip: later records are not limited by a region or a clip path. + pub fn reset_clip(&mut self) { + self.clip = None; + self.clip_paths.clear(); + } + + /// SELECTCLIPPATH: the bracketed path becomes part of the clip and the bracket is emptied. + /// RGN_AND clips to it inside the current clip; RGN_COPY replaces the clip with it. The modes + /// that add to or cut from the clip (RGN_OR, RGN_XOR, RGN_DIFF) leave the clip as it was. The + /// path uses the current fill mode. One clip path more than [`MAX_CLIP_PATHS`] stops the parse. + pub fn select_clip_path(&mut self, mode: u32) { + let ops = std::mem::take(&mut self.path); + self.in_path = false; + if !matches!(mode, clip::RGN_AND | clip::RGN_COPY) { + return; + } + if mode == clip::RGN_COPY { + self.clip = None; + self.clip_paths.clear(); + } + if ops.len() < 2 { + // An empty path clips everything away. + self.clip = Some(Vec::new()); + return; + } + let cost = ops.capacity().saturating_mul(std::mem::size_of::()); + if self.clip_paths.len() >= MAX_CLIP_PATHS || self.used.saturating_add(cost) > MAX_BYTES { + self.full = true; + return; + } + self.used += cost; + self.clip_paths.push(Arc::new(RawClipPath { ops, even_odd: self.even_odd })); + } + + /// ABORTPATH: closes and discards the path bracket. + pub fn abort_path(&mut self) { + self.in_path = false; + self.path.clear(); + } + + /// SETMITERLIMIT. Limits below 1 are invalid and ignored, as GDI does. + pub fn set_miter_limit(&mut self, limit: f64) { + if limit.is_finite() && limit >= 1.0 { + self.miter = limit; + } + } + + /// SETARCDIRECTION: AD_CLOCKWISE (2) or AD_COUNTERCLOCKWISE (1, the default). + pub fn set_arc_direction(&mut self, dir: u32) { + self.arc_clockwise = dir == 2; + } + + /// The start and end of an arc record in the order the counter-clockwise arc builder takes + /// them: a clockwise arc from `s` to `e` covers what a counter-clockwise one from `e` to `s` does. + pub fn arc_ends(&self, s: P, e: P) -> (P, P) { + if self.arc_clockwise { (e, s) } else { (s, e) } + } + + /// SCALEVIEWPORTEXTEX and SCALEWINDOWEXTEX: the extent times `num / den` on each axis. A zero + /// denominator leaves the extent alone. + pub fn scale_ext(&mut self, window: bool, x: (f64, f64), y: (f64, f64)) { + if x.1 == 0.0 || y.1 == 0.0 { + return; + } + let e = if window { &mut self.map.we } else { &mut self.map.ve }; + *e = (e.0 * x.0 / x.1, e.1 * y.0 / y.1); + } + + /// Stores an object in `slot` (EMF index), or in the lowest 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 => { + // Growing the table leaves the slots between the old end and `i` free. + self.free.extend(self.objects.len()..i); + if self.objects.len() <= i { + self.objects.resize(i + 1, None); + } + self.free.remove(&i); + if let Some(s) = self.objects.get_mut(i) { + *s = Some(obj); + } + } + Some(_) => {} + None => match self.free.pop_first() { + Some(i) => { + #[cfg(test)] + { + self.slot_probes += 1; + } + if let Some(s) = self.objects.get_mut(i) { + *s = Some(obj); + } + } + None if self.objects.len() < MAX_OBJECTS => self.objects.push(Some(obj)), + None => {} + }, + } + } + + pub fn delete_object(&mut self, i: u32) { + let i = i as usize; + if let Some(s) = self.objects.get_mut(i) { + *s = None; + self.free.insert(i); + } + } + + /// Selects an object by index; indices with the high bit set are stock objects. + pub fn select(&mut self, i: u32) { + if i & STOCK != 0 { + return self.select_stock(i & !STOCK); + } + 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 => {} + } + } + + fn select_stock(&mut self, i: u32) { + let white = [255, 255, 255, 255]; + let black = [0, 0, 0, 255]; + let gray = |v: u8| Brush::new(BS_SOLID, [v, v, v, 255]); + match i { + 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::new(BS_SOLID, black), + 5 => self.brush = Brush::new(BS_NULL, black), + 6 => self.pen = Pen::new(0, 0.0, white), + 7 | 19 => self.pen = Pen::new(0, 0.0, black), + 8 => self.pen = Pen::new(PS_NULL, 0.0, black), + // The stock fonts (OEM_FIXED_FONT … DEFAULT_GUI_FONT): drawn in the default face. + 10..=17 => self.text.font = None, + _ => {} + } + } + + fn map_op(&self, op: Op) -> Op { + match op { + Op::M(p) => Op::M(self.map.device(p)), + Op::L(p) => Op::L(self.map.device(p)), + Op::C(a, b, c) => Op::C(self.map.device(a), self.map.device(b), self.map.device(c)), + Op::Z => Op::Z, + } + } + + /// 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; + let region = RawClip::default(); + let line = if stroke.is_some() { self.line_style() } else { LineStyle::default() }; + match fill { + Some(Paint::Tile(tile)) => { + if stroke.is_some() { + self.push(Raw::Pattern { ops: ops.clone(), even_odd, tile, region: region.clone() }); + self.push(Raw::Path { ops, fill: None, stroke, line, even_odd, region }); + } else { + self.push(Raw::Pattern { ops, even_odd, tile, region }); + } + } + Some(Paint::Solid(c)) => self.push(Raw::Path { ops, fill: Some(c), stroke, line, even_odd, region }), + None if stroke.is_some() => self.push(Raw::Path { ops, fill: None, stroke, line, even_odd, region }), + 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)> { + if (self.pen.style & PS_STYLE_MASK) == PS_NULL { + return None; + } + // A cosmetic pen (width 0) stays 0: the renderers draw that as a hairline. + let w = if self.pen.width == 0.0 { 0.0 } else { self.pen.width.abs() * self.map.scale() }; + Some((self.pen.color, w)) + } + + /// Caps, joins, the miter limit and dashes of the current pen, dash lengths in device units. + /// PS_DASH, PS_DOT, PS_DASHDOT and PS_DASHDOTDOT are 3:1 dashes and 1:1 dots of the pen's + /// width (one device pixel for a cosmetic pen); PS_USERSTYLE gives its own lengths. + fn line_style(&self) -> LineStyle { + let s = self.pen.style; + let cap = match s & PS_ENDCAP_MASK { + PS_ENDCAP_SQUARE => Cap::Square, + PS_ENDCAP_FLAT => Cap::Flat, + _ => Cap::Round, + }; + let join = match s & PS_JOIN_MASK { + PS_JOIN_BEVEL => Join::Bevel, + PS_JOIN_MITER => Join::Miter, + _ => Join::Round, + }; + // A cosmetic pen's lengths are device pixels. + let scale = if self.pen.width == 0.0 { 1.0 } else { self.map.scale() }; + let w = self.pen.width.abs() * scale; + let u = if w.is_finite() && w > 0.0 { w } else { 1.0 }; + let dash: Vec = match s & PS_STYLE_MASK { + PS_DASH => vec![3.0 * u, u], + PS_DOT => vec![u, u], + PS_DASHDOT => vec![3.0 * u, u, u, u], + PS_DASHDOTDOT => vec![3.0 * u, u, u, u, u, u], + PS_USERSTYLE => self.pen.entries.iter().map(|e| e * scale).collect(), + _ => Vec::new(), + }; + // A pattern with a negative or non-finite length, or with no length at all, draws a solid line. + let ok = dash.iter().all(|d| d.is_finite() && *d >= 0.0 && *d <= f64::from(f32::MAX)) && dash.iter().any(|d| *d > 0.0); + LineStyle { cap, join, miter_limit: self.miter as f32, dash: if ok { dash.iter().map(|d| *d as f32).collect() } else { Vec::new() } } + } + + fn push_path_ops(&mut self, ops: impl IntoIterator) { + for op in ops { + if self.path.len() >= MAX_PATH_OPS { + return; + } + self.path.push(op); + } + } + + /// Draws logical-space ops with the current pen (`stroke`) and brush (`fill`), or appends them to + /// the open path bracket, where fill and stroke wait for FILLPATH/STROKEPATH. + pub fn shape(&mut self, mut dev: Vec, fill: bool, stroke: bool) { + // Maps in place: the caller's vector becomes the device-space path, no second copy. + for op in &mut dev { + *op = self.map_op(*op); + } + if self.in_path { + return self.push_path_ops(dev); + } + if self.mix_skips(fill) || dev.len() < 2 { + return; + } + let fill = if fill { self.fill_paint() } else { None }; + let stroke = if stroke { self.stroke_pen() } else { None }; + self.push_shape(dev, fill, stroke); + } + + pub fn move_to(&mut self, p: P) { + if self.in_path { + self.push_path_ops([Op::M(self.map.device(p))]); + } + self.pos = p; + self.fig_start = p; + } + + /// LINETO from the current point, then moves the current point to `p`. + pub fn line_to(&mut self, p: P) { + self.line_chain(&[p], false, true); + } + + /// Lines from the current point through `pts` (POLYLINETO), or a polyline from the current point. + pub fn line_chain(&mut self, pts: &[P], fill: bool, stroke: bool) { + // Inside an open bracket the path already ends at the current point, so no MoveTo is needed. + let mut ops = if self.in_path && !self.path.is_empty() { Vec::new() } else { self.start_figure() }; + ops.extend(pts.iter().map(|p| Op::L(*p))); + if let Some(last) = pts.last() { + self.pos = *last; + } + self.shape(ops, fill, stroke); + } + + /// POLYBEZIER: cubic segments that start a new figure at `from`. The current point is unchanged. + pub fn bezier(&mut self, from: P, pts: &[P]) { + let mut ops = vec![Op::M(from)]; + ops.extend(cubics(pts)); + self.shape(ops, false, true); + } + + /// POLYBEZIERTO: cubic segments from the current point through groups of three control or end + /// points; the current point moves to the last point. Inside an open bracket the path already + /// ends at the current point, so no MoveTo is needed. + pub fn bezier_to(&mut self, pts: &[P]) { + let mut ops = if self.in_path && !self.path.is_empty() { Vec::new() } else { self.start_figure() }; + ops.extend(cubics(pts)); + if let Some(last) = pts.last() { + self.pos = *last; + } + self.shape(ops, false, true); + } + + pub fn poly(&mut self, pts: &[P], close: bool, fill: bool, stroke: bool) { + let Some(first) = pts.first() else { return }; + let mut ops = vec![Op::M(*first)]; + ops.extend(pts.iter().skip(1).map(|p| Op::L(*p))); + if close { + ops.push(Op::Z); + } + self.shape(ops, fill, stroke); + } + + pub fn begin_path(&mut self) { + self.in_path = true; + self.path.clear(); + } + + pub fn end_path(&mut self) { + self.in_path = false; + } + + /// A figure that starts at the current position, which CLOSEFIGURE comes back to. + fn start_figure(&mut self) -> Vec { + self.fig_start = self.pos; + vec![Op::M(self.pos)] + } + + /// CLOSEFIGURE: closes the open figure; the current position goes back to where it started. + pub fn close_figure(&mut self) { + if self.in_path { + self.push_path_ops([Op::Z]); + self.pos = self.fig_start; + } + } + + /// FILLPATH, STROKEPATH and STROKEANDFILLPATH: paints the bracketed path and empties it. + /// STROKEANDFILLPATH closes the open figures first, as GDI does, so the outline runs all the + /// way round what is filled. + pub fn paint_path(&mut self, fill: bool, stroke: bool) { + let mut ops = std::mem::take(&mut self.path); + if fill && stroke { + ops = close_figures(ops); + } + if self.mix_skips(fill) || ops.len() < 2 { + return; + } + let fill = if fill { self.fill_paint() } else { None }; + let stroke = if stroke { self.stroke_pen() } else { None }; + self.push_shape(ops, fill, stroke); + } + + /// 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). + pub fn fill_rect_with(&mut self, [l, t, r, b]: [f64; 4], color: [u8; 4]) { + let dev: Vec = crate::geom::rect(l, t, r, b).iter().map(|o| self.map_op(*o)).collect(); + self.push(Raw::Path { ops: dev, fill: Some(color), stroke: None, line: LineStyle::default(), even_odd: true, region: RawClip::default() }); + } + + /// Whether a raster operation draws without a source bitmap (PATCOPY, BLACKNESS, WHITENESS). + pub fn sourceless(rop: u32) -> bool { + matches!(rop, PATCOPY | BLACKNESS | WHITENESS) + } + + /// Fills a logical rectangle `[x, y, w, h]` for a source-less raster operation. PATCOPY uses the + /// brush (when solid); the others fill black or white. Other operations are not handled here. + pub fn fill_rop(&mut self, [x, y, w, h]: [f64; 4], rop: u32) { + let rect = [x, y, x + w, y + h]; + match rop { + PATCOPY => self.fill_brush_rect(rect), + BLACKNESS => self.fill_rect_with(rect, [0, 0, 0, 255]), + WHITENESS => self.fill_rect_with(rect, [255, 255, 255, 255]), + _ => {} + } + } + + /// 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 { + text: Box::new(Text { + transform: [sx * c, sx * s, -sy * s, sy * c, o.0, o.1], + text, + dx, + font: lf.font, + color: self.text.color, + background: self.text.bk_opaque.then_some(self.text.bk_color), + h_align, + v_align, + clip, + region: None, + clips: Vec::new(), + }), + region: RawClip::default(), + }); + } + + /// A bitmap stretched into a logical rectangle `[x, y, w, h]`. A negative extent mirrors the + /// bitmap, as the mapping does. Under a world transform that turns or skews, the pixels stay + /// as they are and the item's transform carries the turn. + pub fn bitmap(&mut self, rect: [f64; 4], mut img: Rgba) { + let [x, y, w, h] = rect; + let a = self.map.device((x, y)); + let b = self.map.device((x + w, y + h)); + let (ux, uy) = (self.map.device((x + w, y)), self.map.device((x, y + h))); + let dev = [a.0.min(b.0), a.1.min(b.1), (b.0 - a.0).abs(), (b.1 - a.1).abs()]; + // Upright: the top-right corner is level with the top-left, the bottom-left below it. + let tol = 1e-9 * (dev[2] + dev[3]).max(1.0); + let xf = if (ux.1 - a.1).abs() <= tol && (uy.0 - a.0).abs() <= tol { + crate::dib::flip(&mut img, a.0 > b.0, a.1 > b.1); + [dev[2], 0.0, 0.0, dev[3], dev[0], dev[1]] + } else { + [ux.0 - a.0, ux.1 - a.1, uy.0 - a.0, uy.1 - a.1, a.0, a.1] + }; + // A turned bitmap's rectangle is the box around its four corners. + let xs = [a.0, b.0, ux.0, uy.0]; + let ys = [a.1, b.1, ux.1, uy.1]; + let (x0, x1) = (xs.iter().copied().fold(f64::INFINITY, f64::min), xs.iter().copied().fold(f64::NEG_INFINITY, f64::max)); + let (y0, y1) = (ys.iter().copied().fold(f64::INFINITY, f64::min), ys.iter().copied().fold(f64::NEG_INFINITY, f64::max)); + let dev = if xf[1] == 0.0 && xf[2] == 0.0 { dev } else { [x0, y0, x1 - x0, y1 - y0] }; + self.push(Raw::Bitmap { rect: dev, xf, img, region: RawClip::default() }); + } +} + +/// 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) +} + +/// `ops` with every open figure closed. +fn close_figures(ops: Vec) -> Vec { + let mut out = Vec::with_capacity(ops.len() + 1); + for op in ops { + if matches!(op, Op::M(_)) && !matches!(out.last(), None | Some(Op::Z | Op::M(_))) { + out.push(Op::Z); + } + out.push(op); + } + if !matches!(out.last(), None | Some(Op::Z | Op::M(_))) { + out.push(Op::Z); + } + out +} + +/// Cubic segments from groups of three points (control, control, end). A trailing partial group is +/// dropped. +fn cubics(pts: &[P]) -> Vec { + let mut ops = Vec::with_capacity(pts.len() / 3); + let mut it = pts.iter().copied(); + while let (Some(a), Some(b), Some(e)) = (it.next(), it.next(), it.next()) { + ops.push(Op::C(a, b, e)); + } + ops +} + +#[cfg(test)] +mod object_table_tests { + use super::*; + + fn pen(id: u32) -> Obj { + Obj::Pen(Pen::new(0, 0.0, [id as u8, (id >> 8) as u8, 0, 255])) + } + + fn id_of(o: &Option) -> Option { + match o { + Some(Obj::Pen(p)) => Some(u32::from(p.color[0]) | u32::from(p.color[1]) << 8), + _ => None, + } + } + + /// The table as it was before the free-slot set: a scan from index 0 for every handle-table create. + struct Naive(Vec>); + + impl Naive { + fn add(&mut self, slot: Option, id: u32) { + match slot { + Some(i) if i < MAX_OBJECTS => { + if self.0.len() <= i { + self.0.resize(i + 1, None); + } + self.0[i] = Some(id); + } + Some(_) => {} + None => match self.0.iter().position(Option::is_none) { + Some(i) => self.0[i] = Some(id), + None if self.0.len() < MAX_OBJECTS => self.0.push(Some(id)), + None => {} + }, + } + } + + fn delete(&mut self, i: u32) { + if let Some(s) = self.0.get_mut(i as usize) { + *s = None; + } + } + } + + #[test] + fn handle_table_picks_the_same_slots_as_the_first_free_scan() { + let mut seed = 0x9E37_79B9_7F4A_7C15u64; + let mut rnd = move |n: u64| { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + seed % n + }; + for round in 0..20 { + // Small tables exercise reuse; some rounds also run past the cap and use explicit EMF slots. + let span = if round % 2 == 0 { 12 } else { MAX_OBJECTS as u64 + 50 }; + let (mut cv, mut naive) = (Canvas::new(), Naive(Vec::new())); + for id in 0..20_000u32 { + match rnd(10) { + 0..=4 => { + cv.add_object(None, pen(id)); + naive.add(None, id); + } + 5 | 6 if round >= 10 => { + let slot = rnd(span + 20) as usize; + cv.add_object(Some(slot), pen(id)); + naive.add(Some(slot), id); + } + _ => { + let i = rnd(span + 5) as u32; + cv.delete_object(i); + naive.delete(i); + } + } + if id % 500 == 0 || id == 19_999 { + let got: Vec> = cv.objects.iter().map(id_of).collect(); + assert_eq!(got, naive.0, "round {round}, step {id}"); + } + } + } + } + + #[test] + fn creating_into_a_full_table_does_not_scan_it() { + let mut cv = Canvas::new(); + for i in 0..MAX_OBJECTS as u32 { + cv.add_object(None, pen(i)); + } + assert_eq!(cv.objects.len(), MAX_OBJECTS); + let before = cv.slot_probes; + for i in 0..10_000 { + cv.add_object(None, pen(i)); + } + // A scan from index 0 would examine 4096 slots per create; the free set examines none. + assert_eq!(cv.slot_probes, before); + cv.delete_object(7); + cv.add_object(None, pen(99)); + assert_eq!(cv.slot_probes, before + 1); + assert_eq!(cv.objects.get(7).map(id_of), Some(Some(99))); + } +} diff --git a/crates/metafile/src/clip.rs b/crates/metafile/src/clip.rs new file mode 100644 index 000000000..2fc88ca44 --- /dev/null +++ b/crates/metafile/src/clip.rs @@ -0,0 +1,193 @@ +//! Clipping regions for EMF and WMF (MS-EMF 2.3.12, MS-WMF 2.3.5). +//! +//! A region is a set of axis-aligned rectangles in device units: `None` is the default clip (the +//! whole surface), `Some([])` draws nothing, and `Some(rects)` is their union. That is how GDI +//! stores a region (`RGNDATA` is a list of rectangles). Counts are capped so a hostile file cannot +//! grow the set without bound; past the cap, extra rectangles are dropped, which can only hide +//! drawing. + +/// Rectangles kept from one region. Past this, further rectangles are dropped. +pub(crate) const MAX_CLIP_RECTS: usize = 64; + +pub(crate) const RGN_AND: u32 = 1; +pub(crate) const RGN_OR: u32 = 2; +pub(crate) const RGN_XOR: u32 = 3; +pub(crate) const RGN_DIFF: u32 = 4; +pub(crate) const RGN_COPY: u32 = 5; + +/// A rectangle `[x0, y0, x1, y1]` with a positive area, or none. +pub(crate) fn normalize(rect: [f64; 4]) -> Option<[f64; 4]> { + let [l, t, r, b] = rect; + if ![l, t, r, b].iter().all(|v| v.is_finite()) { + return None; + } + let (x0, x1) = (l.min(r), l.max(r)); + let (y0, y1) = (t.min(b), t.max(b)); + (x1 - x0 > 1e-6 && y1 - y0 > 1e-6).then_some([x0, y0, x1, y1]) +} + +fn intersection(a: [f64; 4], b: [f64; 4]) -> Option<[f64; 4]> { + let (x0, y0) = (a[0].max(b[0]), a[1].max(b[1])); + let (x1, y1) = (a[2].min(b[2]), a[3].min(b[3])); + (x1 - x0 > 1e-6 && y1 - y0 > 1e-6 && [x0, y0, x1, y1].iter().all(|v| v.is_finite())).then_some([x0, y0, x1, y1]) +} + +/// `a` with `b` removed, as up to four rectangles. +fn subtract_one(a: [f64; 4], b: [f64; 4]) -> Vec<[f64; 4]> { + let Some(i) = intersection(a, b) else { return vec![a] }; + let mut out = Vec::new(); + if i[1] > a[1] + && let Some(r) = normalize([a[0], a[1], a[2], i[1]]) + { + out.push(r); + } + if i[3] < a[3] + && let Some(r) = normalize([a[0], i[3], a[2], a[3]]) + { + out.push(r); + } + if i[0] > a[0] + && let Some(r) = normalize([a[0], i[1], i[0], i[3]]) + { + out.push(r); + } + if i[2] < a[2] + && let Some(r) = normalize([i[2], i[1], a[2], i[3]]) + { + out.push(r); + } + out +} + +fn cap(mut rects: Vec<[f64; 4]>) -> Vec<[f64; 4]> { + rects.truncate(MAX_CLIP_RECTS); + rects +} + +fn subtract_all(subject: Vec<[f64; 4]>, cuts: &[[f64; 4]]) -> Vec<[f64; 4]> { + let mut cur = subject; + for cut in cuts { + let mut next = Vec::new(); + for s in cur { + for piece in subtract_one(s, *cut) { + if next.len() >= MAX_CLIP_RECTS { + return next; + } + next.push(piece); + } + } + cur = next; + } + cur +} + +/// Intersects the current region with one rectangle. +pub(crate) fn intersect(current: Option>, rect: [f64; 4]) -> Option> { + let Some(rect) = normalize(rect) else { return Some(Vec::new()) }; + Some(match current { + None => vec![rect], + Some(rects) => cap(rects.iter().filter_map(|r| intersection(*r, rect)).collect()), + }) +} + +/// Removes one rectangle. `surface` is the device bounds, used when the clip is still the default +/// (the whole surface) so the remainder can be named. +pub(crate) fn exclude(current: Option>, rect: [f64; 4], surface: Option<[f64; 4]>) -> Option> { + let Some(rect) = normalize(rect) else { return current }; + match current { + Some(rects) => Some(subtract_all(rects, &[rect])), + None => surface.and_then(normalize).map(|s| subtract_all(vec![s], &[rect])), + } +} + +/// Combines `incoming` with the current region in a region mode (`RGN_*`). An unknown mode leaves +/// the region as it was. `RGN_COPY` replaces it. An `RGN_OR` or `RGN_XOR` against the default clip +/// stays the default clip: the whole surface already covers the incoming rectangles. +pub(crate) fn combine(current: Option>, incoming: Vec<[f64; 4]>, mode: u32, surface: Option<[f64; 4]>) -> Option> { + let incoming: Vec<[f64; 4]> = cap(incoming.iter().copied().filter_map(normalize).collect()); + match mode { + RGN_COPY => Some(incoming), + RGN_AND => match current { + None => Some(incoming), + Some(rects) => { + let mut out = Vec::new(); + for a in &rects { + for b in &incoming { + if out.len() >= MAX_CLIP_RECTS { + return Some(out); + } + if let Some(r) = intersection(*a, *b) { + out.push(r); + } + } + } + Some(out) + } + }, + RGN_OR => match current { + None => None, + Some(mut rects) => { + rects.extend(incoming); + Some(cap(rects)) + } + }, + RGN_DIFF => match current { + Some(rects) => Some(subtract_all(rects, &incoming)), + None => surface.and_then(normalize).map(|s| subtract_all(vec![s], &incoming)), + }, + RGN_XOR => { + let base = current?; + let mut out = subtract_all(base.clone(), &incoming); + let rest = subtract_all(incoming, &base); + for r in rest { + if out.len() >= MAX_CLIP_RECTS { + break; + } + out.push(r); + } + Some(out) + } + _ => current, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn intersect_shrinks_to_the_overlap() { + let r = intersect(None, [0.0, 0.0, 100.0, 50.0]); + let r = intersect(r, [40.0, 10.0, 80.0, 40.0]); + assert_eq!(r, Some(vec![[40.0, 10.0, 80.0, 40.0]])); + assert_eq!(intersect(r, [0.0, 0.0, 10.0, 10.0]), Some(vec![])); + assert_eq!(intersect(Some(vec![[0.0, 0.0, 10.0, 20.0]]), [40.0, 0.0, 80.0, 20.0]), Some(vec![])); + } + + #[test] + fn modes_copy_and_or_diff_and_xor() { + let a = Some(vec![[0.0, 0.0, 100.0, 40.0]]); + assert_eq!(combine(a.clone(), vec![[10.0, 10.0, 30.0, 20.0]], RGN_COPY, None), Some(vec![[10.0, 10.0, 30.0, 20.0]])); + let and = combine(a.clone(), vec![[50.0, 0.0, 150.0, 40.0]], RGN_AND, None); + assert_eq!(and, Some(vec![[50.0, 0.0, 100.0, 40.0]])); + let or = combine(a.clone(), vec![[200.0, 0.0, 210.0, 10.0]], RGN_OR, None).unwrap(); + assert_eq!(or.len(), 2); + let diff = combine(a.clone(), vec![[20.0, 0.0, 40.0, 40.0]], RGN_DIFF, None).unwrap(); + assert_eq!(diff, vec![[0.0, 0.0, 20.0, 40.0], [40.0, 0.0, 100.0, 40.0]]); + let xor = combine(a, vec![[80.0, 0.0, 120.0, 40.0]], RGN_XOR, None).unwrap(); + assert!(xor.contains(&[0.0, 0.0, 80.0, 40.0])); + assert!(xor.contains(&[100.0, 0.0, 120.0, 40.0])); + } + + #[test] + fn excluding_from_the_default_clip_uses_the_surface() { + let r = exclude(None, [10.0, 0.0, 20.0, 40.0], Some([0.0, 0.0, 40.0, 40.0])).unwrap(); + assert_eq!(r, vec![[0.0, 0.0, 10.0, 40.0], [20.0, 0.0, 40.0, 40.0]]); + assert_eq!(exclude(None, [10.0, 0.0, 20.0, 40.0], None), None); + } + + #[test] + fn a_zero_area_rectangle_clears_an_intersection() { + assert_eq!(intersect(Some(vec![[0.0, 0.0, 10.0, 10.0]]), [5.0, 5.0, 5.0, 8.0]), Some(vec![])); + } +} diff --git a/crates/metafile/src/cliprect.rs b/crates/metafile/src/cliprect.rs new file mode 100644 index 000000000..c19f808a1 --- /dev/null +++ b/crates/metafile/src/cliprect.rs @@ -0,0 +1,371 @@ +//! Clipping drawn output to a convex device polygon (EMF+ clip rectangles, and clip paths and +//! regions that are convex; others are reduced to their bounding rectangle). Shapes wholly inside +//! pass unchanged; shapes crossing the edge are flattened and cut: fills with Sutherland–Hodgman +//! per subpath, outlines with Cyrus–Beck per segment. Bitmaps are cropped (and masked outside a +//! non-rectangular polygon) and text gets the polygon's bounding rectangle. + +use crate::LineStyle; +use crate::canvas::Raw; +use crate::dib::Rgba; +use crate::geom::{Op, P}; + +/// Line segments per Bézier when a crossing shape is flattened. +const CURVE_STEPS: usize = 16; + +/// Point-against-edge tests one clipped item may cost (all its subpaths together); an item over +/// it is dropped, as a single polygon over it already was. +const MAX_CLIP_WORK: usize = 1_000_000; + +/// The same allowance for intersecting two clip regions, a budget of its own: two convex +/// 1,000-gons cost about 1.5 million tests once the cut polygon has grown. +pub(crate) const MAX_AREA_CLIP_WORK: usize = 4 * MAX_CLIP_WORK; + +/// The bounding box `[l, t, r, b]` of the points in `ops` (control points included). +pub(crate) fn ops_bbox(ops: &[Op]) -> Option<[f64; 4]> { + let mut b: Option<[f64; 4]> = None; + let mut add = |p: P| { + let v = b.get_or_insert([p.0, p.1, p.0, p.1]); + *v = [v[0].min(p.0), v[1].min(p.1), v[2].max(p.0), v[3].max(p.1)]; + }; + for op in ops { + match *op { + Op::M(p) | Op::L(p) => add(p), + Op::C(a, c, d) => { + add(a); + add(c); + add(d); + } + Op::Z => {} + } + } + b +} + +pub(crate) fn intersect(a: [f64; 4], b: [f64; 4]) -> [f64; 4] { + let r = [a[0].max(b[0]), a[1].max(b[1]), a[2].min(b[2]), a[3].min(b[3])]; + // An empty intersection stays empty (zero size) so it clips everything away. + if r[2] < r[0] || r[3] < r[1] { [r[0], r[1], r[0], r[1]] } else { r } +} + +/// A rectangle `[l, t, r, b]` as a polygon. +pub(crate) fn rect_poly([l, t, r, b]: [f64; 4]) -> Vec

{ + vec![(l, t), (r, t), (r, b), (l, b)] +} + +/// The bounding box of a point list. +pub(crate) fn pts_bbox(pts: &[P]) -> Option<[f64; 4]> { + let (&f, rest) = pts.split_first()?; + Some(rest.iter().fold([f.0, f.1, f.0, f.1], |v, p| [v[0].min(p.0), v[1].min(p.1), v[2].max(p.0), v[3].max(p.1)])) +} + +fn cross(a: P, b: P, p: P) -> f64 { + (b.0 - a.0) * (p.1 - a.1) - (b.1 - a.1) * (p.0 - a.0) +} + +/// A convex clip polygon with positive orientation, or None when `pts` is not convex (or +/// degenerate); duplicate and collinear points are dropped. +pub(crate) fn convex(pts: &[P]) -> Option> { + let mut v: Vec

= Vec::with_capacity(pts.len()); + for &p in pts { + if !(p.0.is_finite() && p.1.is_finite()) { + return None; + } + if v.last().is_none_or(|q: &P| (q.0 - p.0).abs() > 1e-9 || (q.1 - p.1).abs() > 1e-9) { + v.push(p); + } + } + while v.len() > 1 && v.first() == v.last() { + v.pop(); + } + let area: f64 = (0..v.len()).map(|i| cross((0.0, 0.0), v[i], v[(i + 1) % v.len()])).sum(); + if v.len() < 3 || area.abs() < 1e-12 { + return None; + } + if area < 0.0 { + v.reverse(); + } + let n = v.len(); + let mut out = Vec::with_capacity(n); + for i in 0..n { + let c = cross(v[(i + n - 1) % n], v[i], v[(i + 1) % n]); + if c < -1e-9 * (1.0 + area.abs()) { + return None; + } + if c.abs() > 1e-12 { + out.push(v[i]); + } + } + (out.len() >= 3).then_some(out) +} + +fn inside_poly(clip: &[P], p: P) -> bool { + (0..clip.len()).all(|i| cross(clip[i], clip[(i + 1) % clip.len()], p) >= -1e-9) +} + +/// Whether the clip polygon is an axis-aligned rectangle. +fn is_rect(clip: &[P]) -> bool { + pts_bbox(clip).is_some_and(|b| clip.len() == 4 && clip.iter().all(|p| (p.0 == b[0] || p.0 == b[2]) && (p.1 == b[1] || p.1 == b[3]))) +} + +/// Sutherland–Hodgman: `poly` cut by convex `clip`, charging each point visited against each clip +/// edge to `budget`: the polygon +/// can gain a vertex at every edge, so its cost isn't known from its input size alone. `None` +/// once the budget is spent. +pub(crate) fn clip_polygon_within(poly: &[P], clip: &[P], budget: &mut usize) -> Option> { + let mut poly = poly.to_vec(); + for i in 0..clip.len() { + // The work actually done: this edge visits the polygon as it now stands. + if poly.len() > *budget { + return None; + } + *budget -= poly.len(); + let (a, b) = (clip[i], clip[(i + 1) % clip.len()]); + let keep = |p: P| cross(a, b, p) >= 0.0; + let cut = |p: P, q: P| { + let (cp, cq) = (cross(a, b, p), cross(a, b, q)); + let t = cp / (cp - cq); + (p.0 + (q.0 - p.0) * t, p.1 + (q.1 - p.1) * t) + }; + let mut next = Vec::with_capacity(poly.len() + 4); + for j in 0..poly.len() { + let (p, q) = (poly[(j + poly.len() - 1) % poly.len()], poly[j]); + match (keep(p), keep(q)) { + (true, true) => next.push(q), + (true, false) => next.push(cut(p, q)), + (false, true) => { + next.push(cut(p, q)); + next.push(q); + } + (false, false) => {} + } + } + poly = next; + if poly.is_empty() { + break; + } + } + Some(poly) +} + +fn disjoint(b: [f64; 4], c: [f64; 4]) -> bool { + b[2] < c[0] || b[0] > c[2] || b[3] < c[1] || b[1] > c[3] || c[2] <= c[0] || c[3] <= c[1] +} + +fn bbox_inside(b: [f64; 4], clip: &[P]) -> bool { + rect_poly(b).into_iter().all(|p| inside_poly(clip, p)) +} + +/// Clips one drawn item to a convex device polygon; a shape that is both filled and stroked comes +/// back as two items. An empty polygon clips everything away. +pub(crate) fn clip_raw(raw: Raw, clip: &[P]) -> Vec { + let Some(cb) = pts_bbox(clip).filter(|_| clip.len() >= 3) else { return Vec::new() }; + match raw { + Raw::Path { ops, fill, stroke, line, even_odd, region } => { + let Some(b) = ops_bbox(&ops) else { return Vec::new() }; + if bbox_inside(b, clip) { + return vec![Raw::Path { ops, fill, stroke, line, even_odd, region }]; + } + if disjoint(b, cb) { + return Vec::new(); + } + let subs = flatten(&ops); + // One allowance for the fill and the stroke together; the item goes once it is spent. + let mut budget = MAX_CLIP_WORK; + let mut out = Vec::new(); + if fill.is_some() { + let Some(f) = clip_fill(&subs, clip, &mut budget) else { return Vec::new() }; + if !f.is_empty() { + out.push(Raw::Path { ops: f, fill, stroke: None, line: LineStyle::default(), even_odd, region: region.clone() }); + } + } + if stroke.is_some() { + let Some(s) = clip_stroke(&subs, clip, &mut budget) else { return Vec::new() }; + if !s.is_empty() { + out.push(Raw::Path { ops: s, fill: None, stroke, line, even_odd, region }); + } + } + out + } + Raw::Pattern { ops, even_odd, tile, region } => { + let Some(b) = ops_bbox(&ops) else { return Vec::new() }; + if bbox_inside(b, clip) { + return vec![Raw::Pattern { ops, even_odd, tile, region }]; + } + if disjoint(b, cb) { + return Vec::new(); + } + let subs = flatten(&ops); + let f = clip_fill(&subs, clip, &mut { MAX_CLIP_WORK }).unwrap_or_default(); + if f.is_empty() { Vec::new() } else { vec![Raw::Pattern { ops: f, even_odd, tile, region }] } + } + Raw::Bitmap { rect, xf, img, region } => { + // A turned or skewed bitmap is not cut: it stays whole inside the clip and goes otherwise. + if xf[1] != 0.0 || xf[2] != 0.0 { + let corners = + [(0.0, 0.0), (1.0, 0.0), (0.0, 1.0), (1.0, 1.0)].map(|(u, v)| (xf[0] * u + xf[2] * v + xf[4], xf[1] * u + xf[3] * v + xf[5])); + let whole = pts_bbox(&corners).is_some_and(|b| bbox_inside(b, clip)); + return if whole { vec![Raw::Bitmap { rect, xf, img, region }] } else { Vec::new() }; + } + crop(rect, img, clip) + .map(|(r, img)| vec![Raw::Bitmap { rect: r, xf: [r[2], 0.0, 0.0, r[3], r[0], r[1]], img, region }]) + .unwrap_or_default() + } + Raw::Text { text: mut t, region } => { + // Text clips to a rectangle: the one around the polygon. + let cur = t.clip.map_or(cb, |[a, b, d, e]| intersect(cb, [f64::from(a), f64::from(b), f64::from(d), f64::from(e)])); + t.clip = Some(cur.map(|v| v as f32)); + vec![Raw::Text { text: t, region }] + } + } +} + +/// Subpaths as point lists with their closed flag. +pub(crate) fn flatten(ops: &[Op]) -> Vec<(Vec

, bool)> { + let mut subs: Vec<(Vec

, bool)> = Vec::new(); + for op in ops { + match *op { + Op::M(p) => subs.push((vec![p], false)), + Op::L(p) => match subs.last_mut() { + Some(s) => s.0.push(p), + None => subs.push((vec![p], false)), + }, + Op::C(a, b, d) => { + let Some(s) = subs.last_mut() else { continue }; + let Some(&p0) = s.0.last() else { continue }; + for i in 1..=CURVE_STEPS { + let t = i as f64 / CURVE_STEPS as f64; + let u = 1.0 - t; + let k = [u * u * u, 3.0 * u * u * t, 3.0 * u * t * t, t * t * t]; + s.0.push((k[0] * p0.0 + k[1] * a.0 + k[2] * b.0 + k[3] * d.0, k[0] * p0.1 + k[1] * a.1 + k[2] * b.1 + k[3] * d.1)); + } + } + Op::Z => { + if let Some(s) = subs.last_mut() { + s.1 = true; + // Drawing after a close starts from the subpath's first point. + let first = s.0.first().copied(); + if let Some(f) = first { + subs.push((vec![f], false)); + } + } + } + } + } + subs.retain(|s| s.0.len() > 1); + subs +} + +/// The clipped fill of every subpath, its clipping work charged to `budget` with that of the +/// stroke: `None` (the whole item goes) once it is spent. +fn clip_fill(subs: &[(Vec

, bool)], clip: &[P], budget: &mut usize) -> Option> { + let mut ops = Vec::new(); + for (pts, _) in subs { + let poly = clip_polygon_within(pts, clip, budget)?; + if poly.len() >= 3 { + ops.push(Op::M(poly[0])); + ops.extend(poly[1..].iter().map(|p| Op::L(*p))); + ops.push(Op::Z); + } + } + Some(ops) +} + +/// The clipped outline of every subpath; `None` once `budget` is spent. +fn clip_stroke(subs: &[(Vec

, bool)], clip: &[P], budget: &mut usize) -> Option> { + let mut ops = Vec::new(); + for (pts, closed) in subs { + let mut pts = pts.clone(); + if *closed && let Some(&f) = pts.first() { + pts.push(f); + } + let mut last: Option

= None; + for w in pts.windows(2) { + let Some((a, b)) = segment(w[0], w[1], clip, budget)? else { + last = None; + continue; + }; + if last != Some(a) { + ops.push(Op::M(a)); + } + ops.push(Op::L(b)); + last = Some(b); + } + } + Some(ops) +} + +/// Cyrus–Beck: the part of segment `a`–`b` inside convex `clip`, one unit of `budget` per clip edge +/// visited; the outer `None` is a spent budget. +fn segment(a: P, b: P, clip: &[P], budget: &mut usize) -> Option> { + let d = (b.0 - a.0, b.1 - a.1); + let (mut t0, mut t1) = (0.0f64, 1.0f64); + for i in 0..clip.len() { + *budget = budget.checked_sub(1)?; + let (e0, e1) = (clip[i], clip[(i + 1) % clip.len()]); + // Inside is cross(e0, e1, p) >= 0, linear in t along the segment. + let f0 = cross(e0, e1, a); + let df = (e1.0 - e0.0) * d.1 - (e1.1 - e0.1) * d.0; + if df == 0.0 { + if f0 < 0.0 { + return Some(None); + } + } else { + let t = -f0 / df; + if df > 0.0 { + t0 = t0.max(t); + } else { + t1 = t1.min(t); + } + } + } + Some((t0 <= t1).then_some(((a.0 + d.0 * t0, a.1 + d.1 * t0), (a.0 + d.0 * t1, a.1 + d.1 * t1)))) +} + +/// Point-in-polygon tests (pixels times clip corners) spent masking one bitmap to a clip that is +/// not a rectangle. A bitmap that would take more is left out. +pub(crate) const MAX_MASK_WORK: usize = 5_000_000; + +/// Crops a bitmap drawn into `rect` = `[x, y, w, h]` (device, positive extents) to the clip +/// polygon's box; pixels outside a non-rectangular polygon turn transparent. +pub(crate) fn crop(rect: [f64; 4], img: Rgba, clip: &[P]) -> Option<([f64; 4], Rgba)> { + let [x, y, w, h] = rect; + let b = [x, y, x + w, y + h]; + if bbox_inside(b, clip) { + return Some((rect, img)); + } + let c = pts_bbox(clip)?; + if disjoint(b, c) || w <= 0.0 || h <= 0.0 || img.w == 0 || img.h == 0 { + return None; + } + let i = intersect(b, c); + let (kx, ky) = (img.w as f64 / w, img.h as f64 / h); + let x0 = (((i[0] - x) * kx).floor().max(0.0) as usize).min(img.w); + let x1 = (((i[2] - x) * kx).ceil().max(0.0) as usize).min(img.w); + let y0 = (((i[1] - y) * ky).floor().max(0.0) as usize).min(img.h); + let y1 = (((i[3] - y) * ky).ceil().max(0.0) as usize).min(img.h); + if x1 <= x0 || y1 <= y0 { + return None; + } + let mask = !is_rect(clip); + let total_pixels = (x1 - x0).saturating_mul(y1 - y0); + if mask && total_pixels.saturating_mul(clip.len()) > MAX_MASK_WORK { + return None; + } + let mut px = Vec::with_capacity((x1 - x0) * (y1 - y0) * 4); + for row in y0..y1 { + let s = (row * img.w + x0) * 4; + let start = px.len(); + px.extend_from_slice(img.px.get(s..s + (x1 - x0) * 4)?); + if mask { + let cy = y + (row as f64 + 0.5) / ky; + for (k, p) in px[start..].as_chunks_mut::<4>().0.iter_mut().enumerate() { + let cx = x + ((x0 + k) as f64 + 0.5) / kx; + if !inside_poly(clip, (cx, cy)) { + p[3] = 0; + } + } + } + } + let out = [x + x0 as f64 / kx, y + y0 as f64 / ky, (x1 - x0) as f64 / kx, (y1 - y0) as f64 / ky]; + Some((out, Rgba { w: x1 - x0, h: y1 - y0, px })) +} diff --git a/crates/metafile/src/dib.rs b/crates/metafile/src/dib.rs new file mode 100644 index 000000000..787f20eb6 --- /dev/null +++ b/crates/metafile/src/dib.rs @@ -0,0 +1,812 @@ +//! Device-independent bitmaps. BITMAPINFOHEADER DIBs at 1/4/8/16/24/32 bpp with BI_RGB or BI_BITFIELDS, +//! BITMAPCOREHEADER DIBs at 1/4/8/24 bpp, and BI_JPEG and BI_PNG DIBs decode to RGBA8 rows, top-down. +//! RLE compression and V4/V5-only features are not decoded. + +use crate::{MAX_BITMAP_SIDE, MAX_PIXELS, bytes::Bytes}; + +const BI_RGB: u32 = 0; +const BI_BITFIELDS: u32 = 3; +/// The bits are a whole JPEG or PNG file (MS-WMF 2.1.1.7). +const BI_JPEG: u32 = 4; +const BI_PNG: u32 = 5; +/// BITMAPCOREHEADER: 16-bit sizes, RGB triples in the palette, always bottom-up. +const CORE_HEADER: usize = 12; +/// Most palette entries a DIB can carry (the colour table never exceeds 2^8). +const MAX_PALETTE: usize = 256; +const ROP_SRCAND: u32 = 0x0088_00C6; +const ROP_SRCPAINT: u32 = 0x00EE_0086; +const ROP_SRCINVERT: u32 = 0x0066_0046; + +/// A decoded picture, rows top-down. +pub(crate) struct Rgba { + pub w: usize, + pub h: usize, + pub px: Vec, +} + +/// How a decoded bitmap combines with the destination. +#[derive(Clone, Copy)] +pub(crate) enum Blend { + /// A raster operation (BITBLT, STRETCHDIB and friends). + Rop(u32), + /// EMR_ALPHABLEND: a constant alpha (0..=255), times the per-pixel alpha when `per_pixel` is set + /// (AC_SRC_ALPHA: the source colours are premultiplied by it). + Alpha { constant: u8, per_pixel: bool }, +} + +impl Blend { + /// Whether the decoder must keep the 32-bit alpha byte of each pixel. + pub fn per_pixel(self) -> bool { + matches!(self, Blend::Alpha { per_pixel: true, .. }) + } +} + +struct Header { + w: usize, + h: usize, + top_down: bool, + bpp: usize, + masks: [u32; 3], + pal_off: usize, + pal_n: usize, + /// Bytes per palette entry: 4 (BGRX), or 3 (BGR) under a core header. + pal_entry: usize, +} + +impl Header { + fn read(info: Bytes) -> Option

{ + let size = info.u32(0)? as usize; + if size == CORE_HEADER { + let (w, h, bpp) = (usize::from(info.u16(4)?), usize::from(info.u16(6)?), usize::from(info.u16(10)?)); + if w == 0 || h == 0 || !matches!(bpp, 1 | 4 | 8 | 24) { + return None; + } + let pal_n = if bpp <= 8 { 1usize << bpp } else { 0 }; + return Some(Header { w, h, top_down: false, bpp, masks: [0xFF_0000, 0xFF00, 0xFF], pal_off: CORE_HEADER, pal_n, pal_entry: 3 }); + } + let w = info.i32(4)?; + let h = info.i32(8)?; + let bpp = info.u16(14)? as usize; + let comp = info.u32(16)?; + let clr_used = info.u32(32)? as usize; + if size < 40 || w <= 0 || h == 0 || !matches!(bpp, 1 | 4 | 8 | 16 | 24 | 32) || !matches!(comp, BI_RGB | BI_BITFIELDS) { + return None; + } + let masks = if comp == BI_BITFIELDS { + [info.u32(40)?, info.u32(44)?, info.u32(48)?] + } else if bpp == 16 { + [0x7C00, 0x03E0, 0x001F] + } else { + [0xFF_0000, 0xFF00, 0xFF] + }; + let masks_len = if comp == BI_BITFIELDS && size == 40 { 12 } else { 0 }; + let pal_off = size.checked_add(masks_len)?; + // Indexed formats take 2^bpp entries when clrUsed is 0. Deeper formats carry only the + // entries clrUsed names (WMF DIBs can have them), still capped. + let pal_n = match (bpp <= 8, clr_used) { + (true, 0) => 1usize << bpp, + (true, n) => n.min(1usize << bpp), + (false, n) => n.min(MAX_PALETTE), + }; + Some(Header { w: w as usize, h: h.unsigned_abs() as usize, top_down: h < 0, bpp, masks, pal_off, pal_n, pal_entry: 4 }) + } +} + +/// Byte length of the info part (header, masks, palette) of a DIB, so the pixel bits follow it. +pub(crate) fn info_len(dib: Bytes) -> Option { + if compression(dib).is_some() { + // No palette: the bits are a whole image file. + return Some(dib.u32(0)? as usize).filter(|n| *n >= 40); + } + let h = Header::read(dib)?; + h.pal_off.checked_add(h.pal_n.checked_mul(h.pal_entry)?) +} + +/// BI_JPEG or BI_PNG when the header says the bits are an image file. +fn compression(info: Bytes) -> Option { + (info.u32(0)? >= 40).then(|| info.u32(16)).flatten().filter(|c| matches!(*c, BI_JPEG | BI_PNG)) +} + +/// Decodes a JPEG or PNG file (the bits of a BI_JPEG or BI_PNG DIB, an EMF+ image) to top-down +/// RGBA. The size comes from the file's header first, so an image over the side or pixel cap is +/// refused before any pixel is decoded, and the decoder's own allocations are capped too. +pub(crate) fn decode_compressed(bytes: &[u8]) -> Option { + let reader = || { + let mut r = image::ImageReader::new(std::io::Cursor::new(bytes)).with_guessed_format().ok()?; + let mut limits = image::Limits::default(); + limits.max_image_width = Some(MAX_COMPRESSED_SIDE); + limits.max_image_height = Some(MAX_COMPRESSED_SIDE); + limits.max_alloc = Some((MAX_PIXELS * 4) as u64); + r.limits(limits); + Some(r) + }; + let (w, h) = reader()?.into_dimensions().ok()?; + let (w, h) = (w as usize, h as usize); + if w == 0 || h == 0 || w.checked_mul(h)? > MAX_PIXELS { + return None; + } + // A few bytes of JPEG or PNG decode to millions of pixels, so the pixels of one file are + // budgeted as a whole: an image past the budget is left out before it is decoded. + let left = DECODE_LEFT.with(std::cell::Cell::get).checked_sub((w * h) as u64)?; + DECODE_LEFT.with(|d| d.set(left)); + let img = reader()?.decode().ok()?.to_rgba8(); + Some(Rgba { w, h, px: img.into_raw() }) +} + +/// Pixels that the JPEG and PNG images of one file (nested metafiles included) may decode to. +pub(crate) const MAX_DECODE_PIXELS: u64 = 1 << 28; + +thread_local! { + /// What is left of [`MAX_DECODE_PIXELS`] for the file being parsed on this thread. + static DECODE_LEFT: std::cell::Cell = const { std::cell::Cell::new(MAX_DECODE_PIXELS) }; +} + +/// Starts a file's decode budget (`MAX_DECODE_PIXELS` for every parse outside the tests). +pub(crate) fn set_decode_budget(pixels: u64) { + DECODE_LEFT.with(|d| d.set(pixels)); +} + +/// Largest side of a JPEG or PNG image. +pub(crate) const MAX_COMPRESSED_SIDE: u32 = 1 << 15; + +/// Decodes the part of a DIB that the source rectangle `src` = `[x, y, w, h]` selects and returns it +/// (top-down) with the part of the destination `dst` that it fills; see [`clip_rect`] for the +/// coordinates. `info` is the info part (header plus masks and palette) and `bits` the pixel bits. The +/// 32-bit alpha byte is kept only when `keep_alpha` is set (per-pixel alpha blends); otherwise pixels +/// are opaque. Only the clipped rows and columns are decoded, so the work and the memory follow the +/// crop, not the whole bitmap; the whole bitmap is still validated (size cap, palette, bits length). +/// None as well when the clipped width or height is over [`MAX_BITMAP_SIDE`], which no renderer draws. +pub(crate) fn decode_clip(info: Bytes, bits: Bytes, keep_alpha: bool, src: [i64; 4], dst: [f64; 4]) -> Option<(Rgba, [f64; 4])> { + if compression(info).is_some() { + // An image file has no bottom-up row order: the source rectangle counts from the top. + let img = decode_compressed(bits.0)?; + let (part, out) = clip_blit(&img, src, dst)?; + let cap = MAX_BITMAP_SIDE as usize; + return (part.w <= cap && part.h <= cap).then_some((part, out)); + } + let hd = Header::read(info)?; + let pixels = hd.w.checked_mul(hd.h)?; + if pixels == 0 || pixels > MAX_PIXELS { + return None; + } + let stride = hd.w.checked_mul(hd.bpp)?.checked_add(31)? / 32 * 4; + let data = bits.slice(0, stride.checked_mul(hd.h)?)?; + let pal = info.slice(hd.pal_off, hd.pal_n * hd.pal_entry)?; + let (clip, out) = clip_rect(hd.w, hd.h, !hd.top_down, src, dst)?; + // The renderers draw no bitmap with a side over the cap (`Item::take_pixels`); decoding one + // would only fill the parse's memory budget with pixels nobody draws. + let cap = MAX_BITMAP_SIDE as usize; + if clip.x1 - clip.x0 > cap || clip.r1 - clip.r0 > cap { + return None; + } + let (ow, oh) = (clip.x1 - clip.x0, clip.r1 - clip.r0); + let mut px = vec![0u8; ow.checked_mul(oh)?.checked_mul(4)?]; + #[cfg(test)] + DECODED.with(|d| d.set(d.get() + ow * oh)); + // The depth, palette and masks are the same for every pixel: pick the row decoder once. + let kind = RowKind::new(&hd, pal, keep_alpha); + for (y, line) in (clip.r0..clip.r1).zip(px.chunks_exact_mut(ow * 4)) { + let src_row = if hd.top_down { y } else { hd.h - 1 - y }; + let row = data.get(src_row * stride..(src_row + 1) * stride)?; + kind.decode_row(row, clip.x0..clip.x1, line.as_chunks_mut::<4>().0)?; + } + Some((Rgba { w: ow, h: oh, px }, out)) +} + +/// Decodes a whole DIB (rows top-down); see [`decode_clip`]. +pub(crate) fn decode(info: Bytes, bits: Bytes, keep_alpha: bool) -> Option { + decode_clip(info, bits, keep_alpha, [0, 0, 0, 0], [0.0, 0.0, 1.0, 1.0]).map(|(img, _)| img) +} + +/// The part of a decoded (top-down) image that the source rectangle `src` = `[x, y, w, h]` selects, +/// with the part of the destination `dst` that it fills; see [`clip_rect`]. +pub(crate) fn clip_blit(img: &Rgba, src: [i64; 4], dst: [f64; 4]) -> Option<(Rgba, [f64; 4])> { + let (clip, out) = clip_rect(img.w, img.h, false, src, dst)?; + let (ow, oh) = (clip.x1 - clip.x0, clip.r1 - clip.r0); + let mut px = Vec::with_capacity(ow.checked_mul(oh)?.checked_mul(4)?); + for row in clip.r0..clip.r1 { + let s = row.checked_mul(img.w)?.checked_add(clip.x0)?.checked_mul(4)?; + px.extend_from_slice(img.px.get(s..s.checked_add(ow * 4)?)?); + } + Some((Rgba { w: ow, h: oh, px }, out)) +} + +#[cfg(test)] +thread_local! { + /// Pixels asked of the decoder on this thread, so the tests can check that the work follows the crop. + static DECODED: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// One channel of a masked 16/32 bpp pixel, with the shift and scale worked out once per bitmap. +/// Channels of up to 8 bits (every common 16 bpp layout) go through a table of their values, so a +/// pixel costs no division. +#[derive(Clone)] +struct Channel { + mask: u32, + shift: u32, + max: u64, + table: [u8; 256], +} + +impl Channel { + fn new(mask: u32) -> Channel { + let shift = if mask == 0 { 0 } else { mask.trailing_zeros() }; + let max = u64::from(mask >> shift); + let mut table = [0u8; 256]; + if mask != 0 && max <= 255 { + for (val, t) in table.iter_mut().enumerate().take(max as usize + 1) { + *t = ((val as u64 * 255 + max / 2) / max) as u8; + } + } + Channel { mask, shift, max, table } + } + + /// The channel of `v` scaled to 8 bits; 0 when the mask is empty. + fn get(&self, v: u32) -> u8 { + if self.mask == 0 { + return 0; + } + let val = u64::from((v & self.mask) >> self.shift); + if self.max <= 255 { + return self.table[usize::from(val as u8)]; + } + ((val * 255 + self.max / 2) / self.max) as u8 + } +} + +/// How the pixels of one DIB turn into RGBA, decided once from its header. +enum RowKind { + /// 1, 4 or 8 bpp: `bpp` bits per index into a table of every possible index. + Indexed { bpp: usize, table: Box<[[u8; 4]; 256]> }, + /// 24 bpp: BGR triples. + Bgr24, + /// 32 bpp with the default masks: BGRA quads, alpha kept or forced opaque. + Bgra32 { keep_alpha: bool }, + /// 16 bpp, or 32 bpp with other masks. + Masked { bpp: usize, ch: Box<[Channel; 3]>, keep_alpha: bool }, +} + +impl RowKind { + fn new(hd: &Header, pal: &[u8], keep_alpha: bool) -> RowKind { + match hd.bpp { + 1 | 4 | 8 => { + // An index past the palette reads as black, as in `palette`. + let mut table = Box::new([[0u8, 0, 0, 255]; 256]); + for (i, t) in table.iter_mut().enumerate().take(1 << hd.bpp) { + let [r, g, b] = palette(pal, i, hd.pal_entry); + *t = [r, g, b, 255]; + } + RowKind::Indexed { bpp: hd.bpp, table } + } + 24 => RowKind::Bgr24, + 32 if hd.masks == [0xFF_0000, 0xFF00, 0xFF] => RowKind::Bgra32 { keep_alpha }, + _ => RowKind::Masked { bpp: hd.bpp, ch: Box::new(hd.masks.map(Channel::new)), keep_alpha }, + } + } + + /// Decodes the columns `cols` of one source row `row` (all of its bytes) into `out`. `None` when + /// the row is shorter than the columns need. + fn decode_row(&self, row: &[u8], cols: std::ops::Range, out: &mut [[u8; 4]]) -> Option<()> { + match self { + RowKind::Indexed { bpp: 8, table } => { + for (d, s) in out.iter_mut().zip(row.get(cols)?) { + *d = table[usize::from(*s)]; + } + } + RowKind::Indexed { bpp: 4, table } => { + for (d, x) in out.iter_mut().zip(cols) { + let b = *row.get(x / 2)?; + *d = table[usize::from(if x.is_multiple_of(2) { b >> 4 } else { b & 0x0F })]; + } + } + RowKind::Indexed { table, .. } => { + for (d, x) in out.iter_mut().zip(cols) { + let b = *row.get(x / 8)?; + *d = table[usize::from((b >> (7 - x % 8)) & 1)]; + } + } + RowKind::Bgr24 => { + let src = row.get(cols.start.checked_mul(3)?..cols.end.checked_mul(3)?)?; + for (d, s) in out.iter_mut().zip(src.as_chunks::<3>().0) { + *d = [s[2], s[1], s[0], 255]; + } + } + RowKind::Bgra32 { keep_alpha } => { + let src = row.get(cols.start.checked_mul(4)?..cols.end.checked_mul(4)?)?; + for (d, s) in out.iter_mut().zip(src.as_chunks::<4>().0) { + *d = [s[2], s[1], s[0], if *keep_alpha { s[3] } else { 255 }]; + } + } + RowKind::Masked { bpp: 16, ch, .. } => { + let [r, g, b] = &**ch; + let src = row.get(cols.start.checked_mul(2)?..cols.end.checked_mul(2)?)?; + for (d, s) in out.iter_mut().zip(src.as_chunks::<2>().0) { + let v = u32::from(u16::from_le_bytes(*s)); + *d = [r.get(v), g.get(v), b.get(v), 255]; + } + } + RowKind::Masked { ch, keep_alpha, .. } => { + let [r, g, b] = &**ch; + let src = row.get(cols.start.checked_mul(4)?..cols.end.checked_mul(4)?)?; + for (d, s) in out.iter_mut().zip(src.as_chunks::<4>().0) { + let v = u32::from_le_bytes(*s); + *d = [r.get(v), g.get(v), b.get(v), if *keep_alpha { (v >> 24) as u8 } else { 255 }]; + } + } + } + Some(()) + } +} + +/// Palette entries are BGRX quads (`entry` 4) or BGR triples (3, core headers); an index past the +/// table reads as black rather than failing the image. +fn palette(pal: &[u8], i: usize, entry: usize) -> [u8; 3] { + pal.get(i * entry..i * entry + 3).map_or([0, 0, 0], |c| [c[2], c[1], c[0]]) +} + +/// The columns `x0..x1` and top-down rows `r0..r1` of a bitmap that a clipped source rectangle covers. +struct Clip { + x0: usize, + x1: usize, + r0: usize, + r1: usize, +} + +/// Clips a source rectangle `src` = `[x, y, w, h]` to a `w` x `h` bitmap and returns the covered pixels +/// with the part of the destination `dst` that they fill. `src` is in DIB pixel coordinates (`y` counted +/// from the DIB's origin, so bottom-up images count from the bottom); a non-positive source size means the +/// whole bitmap. Clipping shrinks the destination by the same ratio and shifts it by the clipped-off part, +/// so the rest of the image keeps its place. A negative destination extent is kept (the caller mirrors). +/// `None` when nothing of the bitmap is covered. +fn clip_rect(w: usize, h: usize, bottom_up: bool, src: [i64; 4], dst: [f64; 4]) -> Option<(Clip, [f64; 4])> { + let (iw, ih) = (w as i64, h as i64); + let [sx, sy, sw, sh] = if src[2] > 0 && src[3] > 0 { src } else { [0, 0, iw, ih] }; + // The request's top row in top-down order. + let top = if bottom_up { ih.saturating_sub(sy).saturating_sub(sh) } else { sy }; + let (x0, x1) = (sx.max(0), sx.saturating_add(sw).min(iw)); + let (r0, r1) = (top.max(0), top.saturating_add(sh).min(ih)); + if x1 <= x0 || r1 <= r0 { + return None; + } + let (kx, ky) = (dst[2] / sw as f64, dst[3] / sh as f64); + let out = [dst[0] + (x0 - sx) as f64 * kx, dst[1] + (r0 - top) as f64 * ky, (x1 - x0) as f64 * kx, (r1 - r0) as f64 * ky]; + Some((Clip { x0: x0 as usize, x1: x1 as usize, r0: r0 as usize, r1: r1 as usize }, out)) +} + +/// Applies a blend to decoded pixels before they are drawn. The picture has no destination pixels, so +/// raster operations are drawn as they look over white paper: SRCAND (destination AND source) leaves +/// white source pixels out and draws the rest; SRCPAINT and SRCINVERT leave black ones out. Other +/// operations draw the source as copied. Constant alpha scales every pixel's alpha, after a +/// per-pixel (premultiplied) source is made straight. +pub(crate) fn prepare(img: &mut Rgba, blend: Blend) { + match blend { + Blend::Rop(ROP_SRCAND) => clear_where(img, [255, 255, 255]), + Blend::Rop(ROP_SRCPAINT | ROP_SRCINVERT) => clear_where(img, [0, 0, 0]), + Blend::Rop(_) => {} + Blend::Alpha { constant, per_pixel } => { + for p in img.px.as_chunks_mut::<4>().0 { + if per_pixel { + unpremultiply(p); + } + p[3] = (u16::from(p[3]) * u16::from(constant) / 255) as u8; + } + } + } +} + +/// Makes the pixels of colour `rgb` transparent: where a raster operation keeps the destination. +fn clear_where(img: &mut Rgba, rgb: [u8; 3]) { + for p in img.px.as_chunks_mut::<4>().0 { + if p[..3] == rgb { + p[3] = 0; + } + } +} + +/// AC_SRC_ALPHA sources are premultiplied; the model holds straight RGBA, so divide the colour by the +/// alpha (rounded, capped at 255). A transparent pixel keeps its colour bytes. +fn unpremultiply(p: &mut [u8; 4]) { + let a = u32::from(p[3]); + if a == 0 || a == 255 { + return; + } + for c in &mut p[..3] { + *c = ((u32::from(*c) * 255 + a / 2) / a).min(255) as u8; + } +} + +/// Mirrors a decoded image left-right and/or top-bottom. Rows come from the buffer as chunks, so a +/// short buffer flips only the rows it has. +pub(crate) fn flip(img: &mut Rgba, horizontal: bool, vertical: bool) { + let (w, h) = (img.w, img.h); + if w == 0 || h == 0 { + return; + } + let row = w * 4; + if horizontal { + for line in img.px.chunks_exact_mut(row) { + for x in 0..w / 2 { + for c in 0..4 { + line.swap(x * 4 + c, (w - 1 - x) * 4 + c); + } + } + } + } + if vertical { + // Rows the buffer actually holds: a short buffer flips only those. + let n = h.min(img.px.len() / row); + for y in 0..n / 2 { + swap_rows(&mut img.px, y * row, (n - 1 - y) * row, row); + } + } +} + +/// Swaps the `len` bytes at `a` with those at `b` (`a < b`); does nothing when either is out of range. +fn swap_rows(px: &mut [u8], a: usize, b: usize, len: usize) { + let Some((head, tail)) = px.split_at_mut_checked(b) else { return }; + if let (Some(top), Some(bottom)) = (head.get_mut(a..a.saturating_add(len)), tail.get_mut(..len)) { + top.swap_with_slice(bottom); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The pipeline before `decode_clip`: decode the whole bitmap, then copy the clipped part out. + fn decode_whole(info: Bytes, bits: Bytes, keep_alpha: bool) -> Option { + let hd = Header::read(info)?; + let pixels = hd.w.checked_mul(hd.h)?; + if pixels == 0 || pixels > MAX_PIXELS { + return None; + } + let stride = hd.w.checked_mul(hd.bpp)?.checked_add(31)? / 32 * 4; + let data = bits.slice(0, stride.checked_mul(hd.h)?)?; + let pal = info.slice(hd.pal_off, hd.pal_n * 4)?; + let mut px = vec![0u8; pixels * 4]; + for y in 0..hd.h { + let src_row = if hd.top_down { y } else { hd.h - 1 - y }; + let row = src_row * stride; + for x in 0..hd.w { + let p = pixel(data, row, x, &hd, pal, keep_alpha)?; + let o = (y * hd.w + x) * 4; + px.get_mut(o..o + 4)?.copy_from_slice(&p); + } + } + Some(Rgba { w: hd.w, h: hd.h, px }) + } + + fn clip_blit_whole(img: &Rgba, bottom_up: bool, src: [i64; 4], dst: [f64; 4]) -> Option<(Rgba, [f64; 4])> { + let (iw, ih) = (img.w as i64, img.h as i64); + let [sx, sy, sw, sh] = if src[2] > 0 && src[3] > 0 { src } else { [0, 0, iw, ih] }; + let top = if bottom_up { ih.saturating_sub(sy).saturating_sub(sh) } else { sy }; + let (x0, x1) = (sx.max(0), sx.saturating_add(sw).min(iw)); + let (r0, r1) = (top.max(0), top.saturating_add(sh).min(ih)); + if x1 <= x0 || r1 <= r0 { + return None; + } + let (kx, ky) = (dst[2] / sw as f64, dst[3] / sh as f64); + let out = [dst[0] + (x0 - sx) as f64 * kx, dst[1] + (r0 - top) as f64 * ky, (x1 - x0) as f64 * kx, (r1 - r0) as f64 * ky]; + let (x0, x1, r0, r1) = (x0 as usize, x1 as usize, r0 as usize, r1 as usize); + let (ow, oh) = (x1 - x0, r1 - r0); + let mut px = Vec::with_capacity(ow * oh * 4); + for row in r0..r1 { + let s = (row * img.w + x0) * 4; + px.extend_from_slice(img.px.get(s..s + ow * 4)?); + } + Some((Rgba { w: ow, h: oh, px }, out)) + } + + /// A BITMAPINFOHEADER DIB with a palette for indexed depths; returns (info, bits). + fn make_dib(w: usize, h: i32, bpp: usize, seed: &mut u64) -> (Vec, Vec) { + let mut rnd = || { + *seed ^= *seed << 13; + *seed ^= *seed >> 7; + *seed ^= *seed << 17; + *seed + }; + let mut info = Vec::new(); + info.extend(40u32.to_le_bytes()); + info.extend((w as i32).to_le_bytes()); + info.extend(h.to_le_bytes()); + info.extend(1u16.to_le_bytes()); + info.extend((bpp as u16).to_le_bytes()); + info.extend([0u8; 24]); + if bpp <= 8 { + for _ in 0..(1usize << bpp) * 4 { + info.push(rnd() as u8); + } + } + let stride = (w * bpp).div_ceil(32) * 4; + let bits = (0..stride * h.unsigned_abs() as usize).map(|_| rnd() as u8).collect(); + (info, bits) + } + + /// The per-pixel decoder `decode_clip` used before the depth was chosen once per bitmap. Kept as + /// the reference the fast decoder must match byte for byte. + fn pixel(data: &[u8], row: usize, x: usize, hd: &Header, pal: &[u8], keep_alpha: bool) -> Option<[u8; 4]> { + DECODED.with(|d| d.set(d.get() + 1)); + let [m_r, m_g, m_b] = hd.masks; + let [r, g, b] = match hd.bpp { + 1 => { + let b = *data.get(row + x / 8)?; + palette(pal, usize::from((b >> (7 - x % 8)) & 1), 4) + } + 4 => { + let b = *data.get(row + x / 2)?; + palette(pal, usize::from(if x.is_multiple_of(2) { b >> 4 } else { b & 0x0F }), 4) + } + 8 => palette(pal, usize::from(*data.get(row + x)?), 4), + 24 => { + let [b, g, r] = data.get(row + x * 3..row + x * 3 + 3)?.try_into().ok()?; + [r, g, b] + } + 16 => { + let o = row + x * 2; + let v = u32::from(u16::from_le_bytes(data.get(o..o + 2)?.try_into().ok()?)); + [field(v, m_r), field(v, m_g), field(v, m_b)] + } + _ => { + let o = row + x * 4; + let v = u32::from_le_bytes(data.get(o..o + 4)?.try_into().ok()?); + let a = if keep_alpha { (v >> 24) as u8 } else { 255 }; + return Some([field(v, m_r), field(v, m_g), field(v, m_b), a]); + } + }; + Some([r, g, b, 255]) + } + + /// Extracts one channel from a masked pixel value and scales it to 8 bits. + fn field(v: u32, mask: u32) -> u8 { + if mask == 0 { + return 0; + } + let shift = mask.trailing_zeros(); + let max = u64::from(mask >> shift); + let val = u64::from((v & mask) >> shift); + ((val * 255 + max / 2) / max) as u8 + } + + /// `decode_clip` as it was: the same clip, then `pixel` for every output pixel. + fn decode_clip_reference(info: Bytes, bits: Bytes, keep_alpha: bool, src: [i64; 4], dst: [f64; 4]) -> Option<(Rgba, [f64; 4])> { + let hd = Header::read(info)?; + let pixels = hd.w.checked_mul(hd.h)?; + if pixels == 0 || pixels > MAX_PIXELS { + return None; + } + let stride = hd.w.checked_mul(hd.bpp)?.checked_add(31)? / 32 * 4; + let data = bits.slice(0, stride.checked_mul(hd.h)?)?; + let pal = info.slice(hd.pal_off, hd.pal_n * 4)?; + let (clip, out) = clip_rect(hd.w, hd.h, !hd.top_down, src, dst)?; + let (ow, oh) = (clip.x1 - clip.x0, clip.r1 - clip.r0); + let mut px = vec![0u8; ow.checked_mul(oh)?.checked_mul(4)?]; + for (y, line) in (clip.r0..clip.r1).zip(px.chunks_exact_mut(ow * 4)) { + let src_row = if hd.top_down { y } else { hd.h - 1 - y }; + let row = src_row * stride; + for (x, dst_px) in (clip.x0..clip.x1).zip(line.as_chunks_mut::<4>().0) { + *dst_px = pixel(data, row, x, &hd, pal, keep_alpha)?; + } + } + Some((Rgba { w: ow, h: oh, px }, out)) + } + + /// A DIB with chosen header fields: `masks` makes it BI_BITFIELDS (inline after a 40 byte + /// header, or inside a larger header), `clr_used` limits the palette, and the palette, when the + /// depth is indexed, has `pal_entries` entries (fewer than the depth names reads as black). + fn make_dib_with( + w: usize, + h: i32, + bpp: usize, + masks: Option<([u32; 3], usize)>, + clr_used: u32, + pal_entries: usize, + seed: &mut u64, + ) -> (Vec, Vec) { + let mut rnd = || { + *seed ^= *seed << 13; + *seed ^= *seed >> 7; + *seed ^= *seed << 17; + *seed + }; + let size = masks.map_or(40, |(_, s)| s); + let mut info = Vec::new(); + info.extend((size as u32).to_le_bytes()); + info.extend((w as i32).to_le_bytes()); + info.extend(h.to_le_bytes()); + info.extend(1u16.to_le_bytes()); + info.extend((bpp as u16).to_le_bytes()); + info.extend(if masks.is_some() { 3u32 } else { 0 }.to_le_bytes()); + info.extend([0u8; 12]); + info.extend(clr_used.to_le_bytes()); + info.extend([0u8; 4]); + info.resize(size.max(40), 0); + if let Some((m, _)) = masks { + for (i, v) in m.iter().enumerate() { + let at = 40 + i * 4; + if size == 40 { + info.extend(v.to_le_bytes()); + } else if let Some(dst) = info.get_mut(at..at + 4) { + dst.copy_from_slice(&v.to_le_bytes()); + } + } + } + for _ in 0..pal_entries * 4 { + info.push(rnd() as u8); + } + let stride = (w * bpp).div_ceil(32) * 4; + let bits = (0..stride * h.unsigned_abs() as usize).map(|_| rnd() as u8).collect(); + (info, bits) + } + + fn assert_same(info: &[u8], bits: &[u8], what: &str) { + for keep_alpha in [false, true] { + for src in [[0, 0, 0, 0], [3, 1, 20, 3], [-4, -4, 100, 100], [36, 4, 5, 5], [1, 0, 1, 5], [8, 2, 17, 2], [0, 3, 37, 1]] { + let dst = [1.0, 2.0, 30.0, 40.0]; + let old = decode_clip_reference(Bytes(info), Bytes(bits), keep_alpha, src, dst); + let new = decode_clip(Bytes(info), Bytes(bits), keep_alpha, src, dst); + match (old, new) { + (None, None) => {} + (Some((a, ad)), Some((b, bd))) => { + assert_eq!((a.w, a.h, &a.px), (b.w, b.h, &b.px), "{what} alpha={keep_alpha} {src:?}"); + assert_eq!(ad.map(f64::to_bits), bd.map(f64::to_bits), "{what} alpha={keep_alpha} {src:?}"); + } + _ => panic!("old and new disagree on None: {what} alpha={keep_alpha} {src:?}"), + } + } + } + } + + #[test] + fn decode_clip_matches_the_per_pixel_reference_at_every_depth_palette_and_mask() { + let mut seed = 0x9E37_79B9_7F4A_7C15u64; + // Widths that leave padding bits in the row, so a row's last byte is only partly used. + for w in [1, 9, 37] { + for h in [6, -6] { + for bpp in [1usize, 4, 8] { + let n = 1usize << bpp; + // Full palette, clrUsed shorter than the depth (the rest reads black), a + // palette cut short of what clrUsed names, and none at all. + for (clr_used, entries) in [(0, n), (3, 3), (n as u32 + 50, n), (0, 1), (0, 0)] { + let (info, bits) = make_dib_with(w, h, bpp, None, clr_used, entries, &mut seed); + assert_same(&info, &bits, &format!("{bpp} bpp w={w} h={h} clrUsed={clr_used} palette={entries}")); + } + } + let (info, bits) = make_dib_with(w, h, 24, None, 0, 0, &mut seed); + assert_same(&info, &bits, &format!("24 bpp w={w} h={h}")); + // A colour table on a deep DIB is carried but unused. + let (info, bits) = make_dib_with(w, h, 24, None, 5, 5, &mut seed); + assert_same(&info, &bits, &format!("24 bpp with palette w={w} h={h}")); + // Masks on depths that ignore them still move the palette and the bits. + for bpp in [1usize, 8, 24] { + let n = if bpp == 24 { 0 } else { 1 << bpp }; + let (info, bits) = make_dib_with(w, h, bpp, Some(([0xF800, 0x07E0, 0x001F], 40)), 0, n, &mut seed); + assert_same(&info, &bits, &format!("{bpp} bpp with masks w={w} h={h}")); + } + let masks16: [Option<[u32; 3]>; 5] = [ + None, + Some([0xF800, 0x07E0, 0x001F]), + Some([0x7C00, 0x03E0, 0x001F]), + Some([0x0F00, 0x00F0, 0x000F]), + Some([0xF800, 0x0000, 0x001F]), + ]; + let masks32: [Option<[u32; 3]>; 6] = [ + None, + Some([0xFF_0000, 0xFF00, 0xFF]), + Some([0xFF, 0xFF00, 0xFF_0000]), + Some([0x3FF0_0000, 0x000F_FC00, 0x0000_03FF]), + Some([0xFFFF_FFFF, 0x0000_FF00, 0]), + Some([0x00FF_FF00, 0x00FF_FF00, 0x0000_00FF]), + ]; + for (bpp, sets) in [(16usize, masks16.as_slice()), (32, masks32.as_slice())] { + for m in sets { + // 40: the masks follow the header; 56 and 108: they are inside it. + for hsize in [40usize, 56, 108] { + let inline = m.map(|m| (m, hsize)); + let (info, bits) = make_dib_with(w, h, bpp, inline, 0, 0, &mut seed); + assert_same(&info, &bits, &format!("{bpp} bpp masks={m:x?} header={hsize} w={w} h={h}")); + } + } + } + } + } + } + + /// Timing of the per-pixel reference against `decode_clip` on a 40 MP bitmap: + /// `cargo test -p craft-metafile --release -- --ignored --nocapture dib_decode_speed`. + #[test] + #[ignore = "timing only"] + fn dib_decode_speed() { + let mut seed = 6u64; + for bpp in [1usize, 4, 8, 16, 24, 32] { + let (info, bits) = make_dib(8000, -5000, bpp, &mut seed); + for round in 0..4 { + let t = std::time::Instant::now(); + let a = decode_clip_reference(Bytes(&info), Bytes(&bits), false, [0, 0, 8000, 5000], [0.0, 0.0, 1.0, 1.0]).map(|x| x.0.px); + let slow = t.elapsed().as_secs_f64() * 1e3; + let t = std::time::Instant::now(); + let b = decode_clip(Bytes(&info), Bytes(&bits), false, [0, 0, 8000, 5000], [0.0, 0.0, 1.0, 1.0]).map(|x| x.0.px); + let fast = t.elapsed().as_secs_f64() * 1e3; + assert!(a == b); + eprintln!("{bpp:2} bpp round {round}: reference {slow:4.0} ms, decode_clip {fast:4.0} ms"); + } + } + } + + #[test] + fn decode_clip_matches_decode_then_clip_blit() { + let mut seed = 0x2545_F491_4F6C_DD1Du64; + let mut cases = 0; + for bpp in [1, 4, 8, 16, 24, 32] { + for h in [5, -5, 1, -1] { + let w = 7; + let (info, bits) = make_dib(w, h, bpp, &mut seed); + for keep_alpha in [false, true] { + // Source x/y from outside the bitmap on both sides; sizes include zero and negative. + for sx in [-9, -2, 0, 3, 6, 8, 20] { + for sy in [-9, -2, 0, 2, 4, 5, 20] { + for (sw, sh) in [(0, 0), (-3, 4), (4, -2), (1, 1), (3, 2), (7, 5), (30, 30), (2, 9)] { + let src = [sx, sy, sw, sh]; + let dst = [10.0, 20.0, 70.0, 50.0]; + let old = decode_whole(Bytes(&info), Bytes(&bits), keep_alpha).and_then(|img| clip_blit_whole(&img, h > 0, src, dst)); + let new = decode_clip(Bytes(&info), Bytes(&bits), keep_alpha, src, dst); + match (old, new) { + (None, None) => {} + (Some((a, ad)), Some((b, bd))) => { + assert_eq!((a.w, a.h, &a.px), (b.w, b.h, &b.px), "{bpp} bpp h={h} {src:?}"); + assert_eq!(ad.map(f64::to_bits), bd.map(f64::to_bits), "{bpp} bpp h={h} {src:?}"); + } + _ => panic!("old and new disagree on None: {bpp} bpp h={h} {src:?}"), + } + cases += 1; + } + } + } + } + } + } + assert!(cases > 10_000); + } + + #[test] + fn decode_clip_rejects_what_decode_rejected() { + let mut seed = 7; + let (info, bits) = make_dib(7, 5, 24, &mut seed); + let short = &bits[..bits.len() - 1]; + // A short bits buffer fails the whole image even when the crop lies in the rows that exist. + assert!(decode_clip(Bytes(&info), Bytes(short), false, [0, 0, 1, 1], [0.0, 0.0, 1.0, 1.0]).is_none()); + assert!(decode_whole(Bytes(&info), Bytes(short), false).is_none()); + } + + #[test] + fn decode_clip_decodes_only_the_cropped_pixels() { + let mut seed = 99; + let (info, bits) = make_dib(400, -300, 24, &mut seed); + DECODED.with(|d| d.set(0)); + let (img, _) = decode_clip(Bytes(&info), Bytes(&bits), false, [10, 20, 8, 4], [0.0, 0.0, 8.0, 4.0]).unwrap(); + assert_eq!((img.w, img.h), (8, 4)); + assert_eq!(DECODED.with(|d| d.get()), 8 * 4); + // The whole-bitmap path decodes all 400 x 300. + DECODED.with(|d| d.set(0)); + decode_whole(Bytes(&info), Bytes(&bits), false).unwrap(); + assert_eq!(DECODED.with(|d| d.get()), 400 * 300); + } + + #[test] + fn decode_clip_refuses_a_side_no_renderer_draws() { + let cap = MAX_BITMAP_SIDE as usize; + let mut seed = 11; + // 1 bpp, one row longer than the cap: 5 KB of bits, 120 KB of RGBA. + let (info, bits) = make_dib(cap + 1, 1, 1, &mut seed); + assert!(decode_clip(Bytes(&info), Bytes(&bits), false, [0, 0, 0, 0], [0.0, 0.0, 1.0, 1.0]).is_none()); + // The same for the height. + let (info, bits) = make_dib(1, cap as i32 + 1, 1, &mut seed); + assert!(decode_clip(Bytes(&info), Bytes(&bits), false, [0, 0, 0, 0], [0.0, 0.0, 1.0, 1.0]).is_none()); + // A crop of the wide bitmap within the cap still decodes, and so does the cap itself. + let (info, bits) = make_dib(cap + 1, 1, 1, &mut seed); + let (img, _) = decode_clip(Bytes(&info), Bytes(&bits), false, [10, 0, 100, 1], [0.0, 0.0, 1.0, 1.0]).unwrap(); + assert_eq!((img.w, img.h), (100, 1)); + let (info, bits) = make_dib(cap, 1, 1, &mut seed); + let (img, _) = decode_clip(Bytes(&info), Bytes(&bits), false, [0, 0, 0, 0], [0.0, 0.0, 1.0, 1.0]).unwrap(); + assert_eq!((img.w, img.h), (cap, 1)); + } +} diff --git a/crates/metafile/src/emf.rs b/crates/metafile/src/emf.rs new file mode 100644 index 000000000..cf3bc7a90 --- /dev/null +++ b/crates/metafile/src/emf.rs @@ -0,0 +1,516 @@ +//! 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_HATCHED, BS_NULL, Brush, Canvas, LogFont, MAX_DASH_ENTRIES, Obj, PS_NULL, 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; +/// SRCCOPY, the plain copy of a bitmap. +const SRCCOPY: u32 = 0x00CC_0020; +/// LogPenEx pen type bits; PS_COSMETIC pens are one device pixel wide. +const PS_TYPE_MASK: u32 = 0x000F_0000; +const PS_COSMETIC: u32 = 0; + +pub(crate) fn is_emf(b: Bytes) -> bool { + b.u32(0) == Some(1) && b.u32(40) == Some(EMF_SIGNATURE) +} + +/// The picture frame: `rclFrame` (0.01 mm) mapped to device units through `szlDevice` and +/// `szlMillimeters`, sized in points. Without a usable frame, the device bounds stand in (1 unit = 1 point). +pub(crate) fn parse(input: Bytes) -> Result<(Canvas, Option), Error> { + if !is_emf(input) { + return Err(Error::BadHeader); + } + let hsize = input.u32(4).ok_or(Error::BadHeader)? as usize; + let bounds = rect32(input, 8).ok_or(Error::BadHeader)?; + let [l, t, r, b] = bounds; + let frame = frame_from_header(input).or_else(|| (r > l && b > t).then_some(Frame { rect: [l, t, r - l, b - t], size: None })); + let mut cv = Canvas::new(); + if r > l && b > t { + cv.set_clip_surface([l, t, r, b]); + } + if let Some(px_mm) = device_px_per_mm(input) { + cv.set_device_mm(px_mm); + } + let mut plus = crate::emfplus::Plus::new(); + plus.frame = frame.as_ref().map(|f| f.rect); + let mut parsed = 0usize; + let mut pos = hsize.max(88); + while !cv.full() { + let (Some(typ), Some(size)) = (input.u32(pos), pos.checked_add(4).and_then(|o| input.u32(o))) else { break }; + let size = size as usize; + if size < 8 || !size.is_multiple_of(4) { + break; + } + let Some(rec) = input.slice(pos, size) else { break }; + if typ == 14 { + break; + } + if typ == 70 && Bytes(rec).u32(12) == Some(crate::emfplus::EMF_PLUS) { + if let Some(data) = Bytes(rec).tail(16) { + plus.comment(&mut cv, data); + } + } else { + record(&mut cv, typ, Bytes(rec)); + } + parsed += 1; + let Some(next) = pos.checked_add(size) else { break }; + pos = next; + } + if parsed == 0 { + return Err(Error::NoRecords); + } + Ok((cv, frame)) +} + +/// Device pixels per millimetre on each axis, from `szlDevice` (72) and the device's size: +/// `szlMicrometers` (100) when the header has that extension (a header of 108 bytes or more) and +/// it is set, which is a thousand times finer, else `szlMillimeters` (80). +fn device_px_per_mm(input: Bytes) -> Option<(f64, f64)> { + let (dw, dh) = (input.i32(72)?, input.i32(76)?); + if dw <= 0 || dh <= 0 { + return None; + } + if input.u32(4)? >= 108 + && let (Some(uw), Some(uh)) = (input.u32(100), input.u32(104)) + && uw > 0 + && uh > 0 + { + return Some((f64::from(dw) * 1000.0 / f64::from(uw), f64::from(dh) * 1000.0 / f64::from(uh))); + } + let (mw, mh) = (input.i32(80)?, input.i32(84)?); + (mw > 0 && mh > 0).then(|| (f64::from(dw) / f64::from(mw), f64::from(dh) / f64::from(mh))) +} + +/// The frame from `rclFrame` (offset 24), `szlDevice` (72) and `szlMillimeters` (80) in the header. +pub(crate) fn frame_from_header(input: Bytes) -> Option { + let [fl, ft, fr, fb] = rect32(input, 24)?; + if fr <= fl || fb <= ft { + return None; + } + // Device units per hundredth of a millimetre. + let (px_mm_x, px_mm_y) = device_px_per_mm(input)?; + let (sx, sy) = (px_mm_x / 100.0, px_mm_y / 100.0); + let rect = [fl * sx, ft * sy, (fr - fl) * sx, (fb - ft) * sy]; + Some(Frame { rect, size: Some(((fr - fl) * HMM_TO_PT, (fb - ft) * HMM_TO_PT)) }) +} + +fn record(cv: &mut Canvas, typ: u32, r: Bytes) { + if cv.full() { + return; + } + let _ = match typ { + 9 => xy32(r, 8).map(|p| cv.set_window_ext(p)), + 10 => xy32(r, 8).map(|p| cv.set_window_org(p)), + 11 => xy32(r, 8).map(|p| cv.set_viewport_ext(p)), + 12 => xy32(r, 8).map(|p| cv.set_viewport_org(p)), + 17 => r.u32(8).map(|m| cv.set_map_mode(m)), + 19 => r.u32(8).map(|m| cv.set_polyfill(m)), + 20 => r.u32(8).map(|m| cv.set_rop2(m)), + 33 => { + cv.save_dc(); + Some(()) + } + 34 => r.i32(8).map(|n| cv.restore_dc(n)), + // SCALEVIEWPORTEXTEX (31) and SCALEWINDOWEXTEX (32): xNum, xDenom, yNum, yDenom. + 31 | 32 => (|| { + let v = [r.i32(8)?, r.i32(12)?, r.i32(16)?, r.i32(20)?].map(f64::from); + cv.scale_ext(typ == 32, (v[0], v[1]), (v[2], v[3])); + Some(()) + })(), + 57 => r.u32(8).map(|d| cv.set_arc_direction(d)), + 58 => r.u32(8).map(|m| cv.set_miter_limit(f64::from(m))), + 67 => r.u32(8).map(|m| cv.select_clip_path(m)), + 68 => { + cv.abort_path(); + Some(()) + } + 35 => xform(r, 8).map(|x| cv.modify_world(x, 4)), + 36 => xform(r, 8).zip(r.u32(32)).map(|(x, m)| cv.modify_world(x, m)), + 37 => r.u32(8).map(|i| cv.select(i)), + 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)), + 29 => rect32(r, 8).map(|rect| cv.exclude_clip_rect(rect)), + 30 => rect32(r, 8).map(|rect| cv.intersect_clip_rect(rect)), + 75 => ext_select_clip(cv, r), + 27 => xy32(r, 8).map(|p| cv.move_to(p)), + 54 => xy32(r, 8).map(|p| cv.line_to(p)), + 42 => rect32(r, 8).map(|[l, t, rr, b]| cv.shape(geom::ellipse(l, t, rr, b), true, true)), + 43 => rect32(r, 8).map(|[l, t, rr, b]| cv.shape(geom::rect(l, t, rr, b), true, true)), + 44 => { + let (cw, ch) = (r.i32(24), r.i32(28)); + let (Some(cw), Some(ch)) = (cw, ch) else { return }; + rect32(r, 8).map(|[l, t, rr, b]| { + cv.shape(geom::round_rect(l, t, rr, b, f64::from(cw), f64::from(ch)), true, true); + }) + } + 45..=47 => arc32(r).map(|(bounds, s, e)| { + let kind = match typ { + 45 => ArcKind::Arc, + 46 => ArcKind::Chord, + _ => ArcKind::Pie, + }; + let [l, t, rr, b] = bounds; + let (s, e) = cv.arc_ends(s, e); + cv.shape(geom::arc_shape(l, t, rr, b, s, e, kind), kind != ArcKind::Arc, true); + }), + 4 | 87 => counted(r, typ == 4).map(|pts| cv.poly(&pts, false, false, true)), + 3 | 86 => counted(r, typ == 3).map(|pts| cv.poly(&pts, true, true, true)), + 6 | 89 => counted(r, typ == 6).map(|pts| cv.line_chain(&pts, false, true)), + // POLYBEZIER starts its own figure at the first point and leaves the current point alone; + // POLYBEZIERTO continues from the current point. + 2 | 85 => counted(r, typ == 2).and_then(|pts| { + let from = *pts.first()?; + cv.bezier(from, pts.get(1..)?); + Some(()) + }), + 5 | 88 => counted(r, typ == 5).map(|pts| cv.bezier_to(&pts)), + 7 | 90 => poly_groups(r, typ == 7).map(|(counts, pts)| cv.shape(geom::subpaths(&pts, &counts, false), false, true)), + 8 | 91 => poly_groups(r, typ == 8).map(|(counts, pts)| cv.shape(geom::subpaths(&pts, &counts, true), true, true)), + 59 => { + cv.begin_path(); + Some(()) + } + 60 => { + cv.end_path(); + Some(()) + } + 61 => { + cv.close_figure(); + Some(()) + } + 62 => { + cv.paint_path(true, false); + Some(()) + } + 63 => { + cv.paint_path(true, true); + Some(()) + } + 64 => { + cv.paint_path(false, true); + Some(()) + } + 76 | 77 => bitblt(cv, r, typ == 77), + 80 => set_dibits(cv, r), + 81 => stretch_dibits(cv, r), + 114 => alphablend(cv, r), + t if IGNORED.contains(&t) => Some(()), + _ => { + cv.skipped = cv.skipped.saturating_add(1); + Some(()) + } + }; +} + +/// Records that change nothing the picture keeps: the header, the brush origin, mapper flags, +/// stretch mode, colour adjustment, palettes, FLATTENPATH, comments (EMF+ ones are read before +/// they get here), colour management and colour spaces, pixel formats, font linking and text +/// justification. Every other record the reader does not play is counted as skipped. +const IGNORED: &[u32] = &[1, 13, 16, 21, 23, 48, 49, 50, 51, 52, 65, 70, 98, 99, 100, 101, 104, 109, 110, 111, 112, 113, 119, 120, 121, 122]; + +/// EMR_EXTSELECTCLIPRGN: `cbRgnData` bytes of `RGNDATA` (rectangles in logical units) and a region +/// mode. No region data selects the default clip. +fn ext_select_clip(cv: &mut Canvas, r: Bytes) -> Option<()> { + let cb = r.u32(8)? as usize; + let mode = r.u32(12)?; + if cb == 0 { + cv.reset_clip(); + return Some(()); + } + let header = 16; + if cb < 32 { + return Some(()); + } + // RDH_RECTANGLES is the only region type GDI writes. + if r.u32(header + 4)? != 1 { + return Some(()); + } + let n = (r.u32(header + 8)? as usize).min(crate::clip::MAX_CLIP_RECTS); + let end = header.checked_add(cb)?; + let mut rects = Vec::new(); + for i in 0..n { + let o = header + 32 + i * 16; + if o + 16 > end { + break; + } + rects.push(rect32(r, o)?); + } + cv.select_clip_rects(&rects, mode); + Some(()) +} + +/// Two little-endian i32 values at `off`, as a point. +fn xy32(r: Bytes, off: usize) -> Option

{ + Some((f64::from(r.i32(off)?), f64::from(r.i32(off + 4)?))) +} + +/// RECTL (left, top, right, bottom) as i32 values widened to f64. +fn rect32(r: Bytes, off: usize) -> Option<[f64; 4]> { + Some([f64::from(r.i32(off)?), f64::from(r.i32(off + 4)?), f64::from(r.i32(off + 8)?), f64::from(r.i32(off + 12)?)]) +} + +/// An i32 field as f64; 0 past the end. +fn i32s(r: Bytes, off: usize) -> f64 { + r.i32(off).map_or(0.0, f64::from) +} + +/// XFORM: six f32 values in M11, M12, M21, M22, Dx, Dy order. +fn xform(r: Bytes, off: usize) -> Option { + let mut x = [0.0f64; 6]; + for (i, v) in x.iter_mut().enumerate() { + *v = f64::from(r.f32(off + i * 4)?); + } + Some(x) +} + +/// ARC, CHORD, PIE: bounds at 8, start point at 24, end point at 32. +fn arc32(r: Bytes) -> Option<([f64; 4], P, P)> { + Some((rect32(r, 8)?, xy32(r, 24)?, xy32(r, 32)?)) +} + +/// A point count from a record, rejected when it exceeds the path budget (before any allocation). +fn budget(n: u32) -> Option { + let n = n as usize; + (n <= MAX_PATH_OPS).then_some(n) +} + +/// Point count at 24, then the points (32-bit or 16-bit) at 28. +fn counted(r: Bytes, wide: bool) -> Option> { + let n = budget(r.u32(24)?)?; + r.tail(28)?.points(0, n, wide) +} + +/// Subpath count at 24, total point count at 28, per-subpath counts at 32, then the points. +fn poly_groups(r: Bytes, wide: bool) -> Option<(Vec, Vec

)> { + let n = budget(r.u32(24)?)?; + // The record must hold the n counts before anything is allocated for them. + let raw = r.slice(32, n.checked_mul(4)?)?; + let counts: Vec = raw.as_chunks::<4>().0.iter().map(|c| u32::from_le_bytes(*c) as usize).collect(); + let total = counts.iter().try_fold(0usize, |a, c| a.checked_add(*c))?; + let total = budget(u32::try_from(total).ok()?)?; + let pts = r.tail(n.checked_mul(4)?.checked_add(32)?)?.points(0, total, wide)?; + Some((counts, pts)) +} + +/// EMR_CREATEPEN: handle, LogPen (style, width x/y, colour). +fn create_pen(cv: &mut Canvas, r: Bytes) -> Option<()> { + let slot = r.u32(8)? as usize; + let style = r.u32(12)?; + let width = r.i32(16)?; + let color = r.u32(24)?; + cv.add_object(Some(slot), Obj::Pen(Pen::new(style, f64::from(width), colorref(color)))); + Some(()) +} + +/// EMR_EXTCREATEPEN: handle, bitmap offsets (unused), LogPenEx (style, width, brush style, colour, +/// hatch, the number of style entries at 48 and the entries from 52). A cosmetic pen's width is in +/// device pixels (always 1), so it is a hairline; a BS_NULL brush draws no line at all. The style +/// word keeps its end cap and join; the entries are the dashes and gaps of a PS_USERSTYLE pen. +fn create_ext_pen(cv: &mut Canvas, r: Bytes) -> Option<()> { + let slot = r.u32(8)? as usize; + let (style, width, brush, color) = (r.u32(28)?, r.u32(32)?, r.u32(36)?, r.u32(40)?); + let width = if style & PS_TYPE_MASK == PS_COSMETIC { 0.0 } else { f64::from(width) }; + let n = (r.u32(48).unwrap_or(0) as usize).min(MAX_DASH_ENTRIES); + let entries = (0..n).map_while(|i| r.u32(52 + i * 4)).map(f64::from).collect(); + let style = if brush == BS_NULL { PS_NULL } else { style }; + cv.add_object(Some(slot), Obj::Pen(Pen { style, width, color: colorref(color), entries })); + Some(()) +} + +/// EMR_CREATEBRUSHINDIRECT: handle, LogBrush32 (style, colour, hatch). +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)?; + 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(()) +} + +/// BITBLT (76) and STRETCHBLT (77): dest at 24..40, rop at 40, source x/y at 44, bmi at 84, bits at +/// 92; STRETCHBLT adds the source w/h at 100. A source-less rop fills the destination instead. +fn bitblt(cv: &mut Canvas, r: Bytes, stretch: bool) -> Option<()> { + let dst = [i32s(r, 24), i32s(r, 28), i32s(r, 32), i32s(r, 36)]; + let rop = r.u32(40)?; + if Canvas::sourceless(rop) { + cv.fill_rop(dst, rop); + return Some(()); + } + let (sw, sh) = if stretch { (i32s(r, 100), i32s(r, 104)) } else { (dst[2], dst[3]) }; + let src = [i32s(r, 44), i32s(r, 48), sw, sh]; + blit(cv, r, dst, src, (84, 92), Blend::Rop(rop)) +} + +/// ALPHABLEND: laid out like STRETCHBLT, with a BLENDFUNCTION at 40. Constant alpha at 42 applies to +/// every pixel; AlphaFormat at 43 with AC_SRC_ALPHA (1) also uses the per-pixel alpha. +fn alphablend(cv: &mut Canvas, r: Bytes) -> Option<()> { + let dst = [i32s(r, 24), i32s(r, 28), i32s(r, 32), i32s(r, 36)]; + let constant = *r.0.get(42)?; + let per_pixel = (*r.0.get(43)? & 0x01) != 0; + let src = [i32s(r, 44), i32s(r, 48), i32s(r, 100), i32s(r, 104)]; + blit(cv, r, dst, src, (84, 92), Blend::Alpha { constant, per_pixel }) +} + +/// STRETCHDIBITS: dest x/y at 24, src x/y/w/h at 32..48, bmi offset/size at 48, bits at 56, rop at 68, +/// dest w/h at 72. +fn stretch_dibits(cv: &mut Canvas, r: Bytes) -> Option<()> { + let dst = [i32s(r, 24), i32s(r, 28), i32s(r, 72), i32s(r, 76)]; + let src = [i32s(r, 32), i32s(r, 36), i32s(r, 40), i32s(r, 44)]; + let rop = r.u32(68)?; + blit(cv, r, dst, src, (48, 56), Blend::Rop(rop)) +} + +/// SETDIBITSTODEVICE (MS-EMF 2.3.1.5): dest x/y at 24, src x/y at 32, size (cxSrc, cySrc) at 40, bmi +/// at 48, bits at 56, start scan at 68. The image keeps its own size; the bits begin at scan line +/// `iStartScan`, so the source y counts from there. +fn set_dibits(cv: &mut Canvas, r: Bytes) -> Option<()> { + let (cx, cy) = (i32s(r, 40), i32s(r, 44)); + if cx <= 0.0 || cy <= 0.0 { + return None; + } + let dst = [i32s(r, 24), i32s(r, 28), cx, cy]; + let src = [i32s(r, 32), i32s(r, 36) - f64::from(r.u32(68)?), cx, cy]; + blit(cv, r, dst, src, (48, 56), Blend::Rop(SRCCOPY)) +} + +/// Decodes the bitmap at `at` = (offset of the bmi offset/length pair, offset of the bits pair), both +/// inside the record, and stretches the source rectangle `src` into `dst`. Both are `[x, y, w, h]`; a +/// non-positive source size means the whole bitmap. `blend` decides how it combines. +fn blit(cv: &mut Canvas, r: Bytes, dst: [f64; 4], src: [f64; 4], at: (usize, usize), blend: Blend) -> Option<()> { + let (bo, bl) = (r.u32(at.0)? as usize, r.u32(at.0 + 4)? as usize); + let (po, pl) = (r.u32(at.1)? as usize, r.u32(at.1 + 4)? as usize); + if pl == 0 { + return None; + } + let info = Bytes(r.slice(bo, bl)?); + let pixels = Bytes(r.slice(po, pl)?); + let (mut img, dst) = dib::decode_clip(info, pixels, blend.per_pixel(), src.map(|v| v as i64), dst)?; + dib::prepare(&mut img, blend); + cv.bitmap(dst, img); + Some(()) +} diff --git a/crates/metafile/src/emfplus.rs b/crates/metafile/src/emfplus.rs new file mode 100644 index 000000000..0c46644d2 --- /dev/null +++ b/crates/metafile/src/emfplus.rs @@ -0,0 +1,1543 @@ +//! EMF+ (MS-EMFPLUS): GDI+ drawing records carried in EMF comment records. +//! +//! Most EMF+ files are *dual*: the same picture is also drawn with plain EMF records, which +//! [`crate::emf`] already plays, so this reader ignores their EMF+ part. An *EMF+ only* file has +//! nothing else to draw; for those this reader plays the records: +//! - brushes (solid, hatch, texture, linear and path gradients), pens, paths, images, fonts, +//! string formats and regions, including objects split across several records; +//! - rectangles, polygons, lines, ellipses, paths and Béziers; +//! - bitmaps (raw pixel formats, and PNG/JPEG/GIF/TIFF/BMP payloads) and strings (DrawString, +//! DrawDriverString with character codes); +//! - world and page transforms, save/restore and containers; +//! - arcs and pies; +//! - clipping to rectangles and convex clip paths and regions (others: their bounding rectangle); +//! - nested metafile images, and glyph-index driver strings in the Windows core fonts; +//! - cardinal splines (DrawCurve, DrawClosedCurve, FillClosedCurve), FillRegion, and Clear +//! (over the picture frame). +//! +//! Every drawing record of MS-EMFPLUS §2.3.4 is played. Not drawn: image attributes, glyph +//! indices of other fonts, and region combinations other than intersect and union (XOR, exclude +//! and complement keep the left area). Deliberately ignored: rendering hints (antialias, text +//! hint, interpolation, pixel offset, compositing; the renderer has its own), SetRenderingOrigin +//! (hatch phase), SerializableObject image effects, the terminal-server records SetTSGraphics and +//! SetTSClip, and StrokeFillPath (0x4037), which the specification lists but gives no layout for +//! and GDI+ doesn't write. + +use std::sync::Arc; + +use crate::bytes::Bytes; +use crate::canvas::{BS_NULL, BS_SOLID, Brush, Canvas, LogFont, PS_NULL, Pen, RawTile, Xform, hatch_tile, mul}; +use crate::canvas::{Raw, RawClip}; +use crate::cliprect::{MAX_AREA_CLIP_WORK, clip_polygon_within, convex, flatten, ops_bbox, pts_bbox, rect_poly}; +use crate::dib::Rgba; +use crate::geom::{Op, P}; +use crate::text::{ETO_CLIPPED, Font}; +use crate::{Item, LineStyle, Picture, Seg}; + +/// "EMF+" as the comment identifier of an EMR_COMMENT holding EMF+ records. +pub(crate) const EMF_PLUS: u32 = 0x2B46_4D45; +const IDENTITY: Xform = [1.0, 0.0, 0.0, 1.0, 0.0, 0.0]; +/// GDI+ allows 64 objects; ids are a byte. +pub(crate) const MAX_OBJECTS: usize = 64; +pub(crate) const MAX_STACK: usize = 64; +/// Points per path or point list read, as the plain EMF reader caps paths. +pub(crate) const MAX_POINTS: usize = 100_000; +/// Gradient stops read from a brush. +pub(crate) const MAX_STOPS: usize = 256; +/// Characters per string. +pub(crate) const MAX_CHARS: usize = 32_768; +/// Bytes gathered for one object split across records. +pub(crate) const MAX_OBJECT_BYTES: usize = 64 * 1024 * 1024; +/// Nesting depth of region nodes. +pub(crate) const MAX_REGION_DEPTH: usize = 32; +/// Region nodes and path points parsed together, including nodes in sibling branches. +pub(crate) const MAX_REGION_WORK: usize = MAX_POINTS; + +const TA_RIGHT: u32 = 2; +const TA_CENTER: u32 = 6; +const TA_BOTTOM: u32 = 8; +const TA_BASELINE: u32 = 24; + +/// What a brush paints. Coordinates are in brush space, which `xf` maps to world space. +#[derive(Clone)] +enum Fill { + Solid([u8; 4]), + Hatch { style: u32, fg: [u8; 4], bg: [u8; 4] }, + Linear { rect: [f64; 4], stops: Vec<(f64, [u8; 4])>, wrap: u32, xf: Xform }, + Radial { center: P, radius: P, stops: Vec<(f64, [u8; 4])>, xf: Xform }, + Texture { img: Arc, wrap: u32, xf: Xform }, +} + +impl Fill { + /// One colour standing for the brush (pens and text draw solid). + fn color(&self) -> [u8; 4] { + match self { + Fill::Solid(c) | Fill::Hatch { fg: c, .. } => *c, + Fill::Linear { stops, .. } | Fill::Radial { stops, .. } => ramp(stops, 0.5), + Fill::Texture { img, .. } => img.px.get(..4).and_then(|c| c.try_into().ok()).unwrap_or([0, 0, 0, 255]), + } + } + + /// The colour at a brush-space point. + fn sample(&self, p: P) -> [u8; 4] { + match self { + Fill::Solid(c) | Fill::Hatch { fg: c, .. } => *c, + Fill::Linear { rect, stops, wrap, .. } => { + let t = if rect[2] == 0.0 { 0.0 } else { (p.0 - rect[0]) / rect[2] }; + ramp(stops, wrap_t(t, *wrap)) + } + Fill::Radial { center, radius, stops, .. } => { + let (dx, dy) = ((p.0 - center.0) / radius.0.max(1e-9), (p.1 - center.1) / radius.1.max(1e-9)); + ramp(stops, dx.hypot(dy).min(1.0)) + } + Fill::Texture { img, wrap, .. } => { + let (w, h) = (img.w as f64, img.h as f64); + if w == 0.0 || h == 0.0 { + return [0, 0, 0, 0]; + } + if *wrap == 4 && !((0.0..w).contains(&p.0) && (0.0..h).contains(&p.1)) { + return [0, 0, 0, 0]; + } + let x = (p.0.rem_euclid(w) as usize).min(img.w - 1); + let y = (p.1.rem_euclid(h) as usize).min(img.h - 1); + let i = (y * img.w + x) * 4; + img.px.get(i..i + 4).and_then(|c| c.try_into().ok()).unwrap_or([0, 0, 0, 0]) + } + } + } + + fn xf(&self) -> Xform { + match self { + Fill::Linear { xf, .. } | Fill::Radial { xf, .. } | Fill::Texture { xf, .. } => *xf, + _ => IDENTITY, + } + } +} + +/// Where `t` lands under a wrap mode: tiled, mirrored (flip modes) or clamped. +fn wrap_t(t: f64, wrap: u32) -> f64 { + if !t.is_finite() { + return 0.0; + } + match wrap { + 1 | 3 => { + let m = t.rem_euclid(2.0); + if m > 1.0 { 2.0 - m } else { m } + } + 4 => t.clamp(0.0, 1.0), + _ => t.rem_euclid(1.0), + } +} + +/// The colour at `t` along sorted stops. +fn ramp(stops: &[(f64, [u8; 4])], t: f64) -> [u8; 4] { + let (Some(first), Some(last)) = (stops.first(), stops.last()) else { return [0, 0, 0, 0] }; + if t <= first.0 { + return first.1; + } + for w in stops.windows(2) { + let ((a, ca), (b, cb)) = (w[0], w[1]); + if t <= b { + let k = if b > a { (t - a) / (b - a) } else { 0.0 }; + return lerp(ca, cb, k); + } + } + last.1 +} + +fn lerp(a: [u8; 4], b: [u8; 4], k: f64) -> [u8; 4] { + let mut c = [0u8; 4]; + for i in 0..4 { + c[i] = (f64::from(a[i]) + (f64::from(b[i]) - f64::from(a[i])) * k).round().clamp(0.0, 255.0) as u8; + } + c +} + +#[derive(Clone)] +struct FontObj { + em: f64, + unit: u32, + style: u32, + family: String, +} + +#[derive(Clone, Copy, Debug, Default)] +struct Format { + flags: u32, + align: u32, + line_align: u32, +} + +/// A clip area: a convex polygon (empty clips everything), or None for infinite. +type Area = Option>; + +enum ImageData { + Bitmap(Rgba), + /// A nested WMF or EMF (EMF+) picture. + Meta(Picture), +} + +thread_local! { + /// Nesting depth of metafile images being parsed, so a file of nested metafiles cannot run + /// the stack out. + static NESTING: std::cell::Cell = const { std::cell::Cell::new(0) }; +} +/// Metafile images inside metafile images, at most. +pub(crate) const MAX_NESTING: usize = 3; + +#[derive(Clone)] +enum Obj { + Brush(Fill), + Pen { + color: [u8; 4], + width: f64, + }, + Path(Vec), + Image(Arc), + Font(FontObj), + Format(Format), + /// A region's bounding rectangle `[l, t, r, b]` in world units; None is infinite. + Region(Area), +} + +/// An object arriving in several records: its id, type, total size and the bytes so far. +struct Partial { + id: usize, + kind: u16, + total: usize, + data: Vec, +} + +struct Saved { + id: u32, + world: Xform, + page: f64, + clip: Area, +} + +pub(crate) struct Plus { + /// An EMF+ header said the file is EMF+ only, so these records are the picture. + active: bool, + dpi: f64, + world: Xform, + page: f64, + /// Convex device clip polygon; None is unclipped. + clip: Area, + objects: Vec>, + object_bytes: usize, + stack: Vec, + partial: Option, + /// The picture frame in device units (`[x, y, w, h]`), what Clear paints. + pub frame: Option<[f64; 4]>, +} + +impl Plus { + pub fn new() -> Self { + Plus { + active: false, + dpi: 96.0, + world: IDENTITY, + page: 1.0, + clip: None, + objects: vec![None; MAX_OBJECTS], + object_bytes: 0, + stack: Vec::new(), + partial: None, + frame: None, + } + } + + /// Plays the EMF+ records of one EMR_COMMENT payload (after the identifier). + pub fn comment(&mut self, cv: &mut Canvas, data: Bytes) { + let mut pos = 0usize; + while !cv.full() { + let Some(tail) = data.0.get(pos..) else { break }; + let tail = Bytes(tail); + let (Some(typ), Some(flags), Some(size), Some(dsize)) = (tail.u16(0), tail.u16(2), tail.u32(4), tail.u32(8)) else { + break; + }; + let (size, dsize) = (size as usize, dsize as usize); + if size < 12 || dsize > size - 12 { + break; + } + let Some(rec) = tail.slice(12, dsize) else { break }; + let _ = self.record(cv, typ, flags, Bytes(rec)); + let Some(next) = pos.checked_add(size) else { break }; + pos = next; + } + } + + /// World units to device pixels before the canvas's own mapping. + fn xf(&self) -> Xform { + mul(self.world, [self.page, 0.0, 0.0, self.page, 0.0, 0.0]) + } + + fn painter(&self) -> Painter { + Painter { xf: self.xf(), clip: self.clip.clone() } + } + + fn record(&mut self, cv: &mut Canvas, typ: u16, flags: u16, r: Bytes) -> Option<()> { + if typ == 0x4001 { + // Header: dual files also draw with EMF records, which win. + self.active = flags & 1 == 0; + if let Some(d) = r.u32(8).map(f64::from).filter(|d| *d > 0.0 && *d < 100_000.0) { + self.dpi = d; + } + return Some(()); + } + if !self.active { + return None; + } + match typ { + 0x4008 => self.object(flags, r), + 0x4009 | 0x4013 | 0x401A..=0x401C | 0x4036 => self.record_image_text_region(cv, typ, flags, r), + 0x4025..=0x4035 => self.record_state(cv, typ, flags, r), + 0x4010..=0x4012 | 0x4016..=0x4019 => self.record_curve(cv, typ, flags, r), + _ => self.record_shape(cv, typ, flags, r), + } + } + + /// Rectangle, polygon, ellipse and path records. + fn record_shape(&mut self, cv: &mut Canvas, typ: u16, flags: u16, r: Bytes) -> Option<()> { + let compressed = flags & 0x4000 != 0; + let id = usize::from(flags & 0xFF); + match typ { + 0x400A => { + let fill = self.brush_of(flags, r.u32(0)?)?; + let p = self.painter(); + for rc in rects(r, 8, r.u32(4)? as usize, compressed)? { + if cv.full() { + break; + } + p.fill(cv, &rect_ops(rc), &fill); + } + Some(()) + } + 0x400B => { + let pen = self.pen(id)?; + let p = self.painter(); + for rc in rects(r, 4, r.u32(0)? as usize, compressed)? { + if cv.full() { + break; + } + p.stroke(cv, &rect_ops(rc), pen); + } + Some(()) + } + 0x400C => { + let fill = self.brush_of(flags, r.u32(0)?)?; + let pts = points(r, 8, r.u32(4)? as usize, compressed)?; + self.painter().fill(cv, &poly_ops(&pts, true), &fill); + Some(()) + } + 0x400D => { + let pen = self.pen(id)?; + let pts = points(r, 4, r.u32(0)? as usize, compressed)?; + self.painter().stroke(cv, &poly_ops(&pts, flags & 0x2000 != 0), pen); + Some(()) + } + 0x400E => { + let fill = self.brush_of(flags, r.u32(0)?)?; + let rect = *rects(r, 4, 1, compressed)?.first()?; + self.painter().fill(cv, &ellipse_ops(rect), &fill); + Some(()) + } + 0x400F => { + let pen = self.pen(id)?; + let rect = *rects(r, 0, 1, compressed)?.first()?; + self.painter().stroke(cv, &ellipse_ops(rect), pen); + Some(()) + } + 0x4014 => { + let fill = self.brush_of(flags, r.u32(0)?)?; + let ops = self.path(id)?; + self.painter().fill(cv, &ops, &fill); + Some(()) + } + 0x4015 => { + let ops = self.path(id)?; + let pen = self.pen(r.u32(0)? as usize)?; + self.painter().stroke(cv, &ops, pen); + Some(()) + } + _ => None, + } + } + + /// Curve, bezier and arc records. + fn record_curve(&mut self, cv: &mut Canvas, typ: u16, flags: u16, r: Bytes) -> Option<()> { + let compressed = flags & 0x4000 != 0; + let id = usize::from(flags & 0xFF); + match typ { + 0x4019 => { + let pen = self.pen(id)?; + let pts = points(r, 4, r.u32(0)? as usize, compressed)?; + let (first, rest) = pts.split_first()?; + let mut ops = vec![Op::M(*first)]; + ops.extend(rest.as_chunks::<3>().0.iter().map(|c| Op::C(c[0], c[1], c[2]))); + self.painter().stroke(cv, &ops, pen); + Some(()) + } + // Cardinal splines: FillClosedCurve, DrawClosedCurve, DrawCurve. + 0x4016 => { + let fill = self.brush_of(flags, r.u32(0)?)?; + let tension = finite(r.f32(4)?)?; + let pts = points(r, 12, r.u32(8)? as usize, compressed)?; + self.painter().fill(cv, &curve_ops(&pts, tension, true, 0, usize::MAX), &fill); + Some(()) + } + 0x4017 => { + let pen = self.pen(id)?; + let tension = finite(r.f32(0)?)?; + let pts = points(r, 8, r.u32(4)? as usize, compressed)?; + self.painter().stroke(cv, &curve_ops(&pts, tension, true, 0, usize::MAX), pen); + Some(()) + } + 0x4018 => { + let pen = self.pen(id)?; + let tension = finite(r.f32(0)?)?; + let (offset, segments) = (r.u32(4)? as usize, r.u32(8)? as usize); + let pts = points(r, 16, r.u32(12)? as usize, compressed)?; + self.painter().stroke(cv, &curve_ops(&pts, tension, false, offset, segments), pen); + Some(()) + } + 0x4010 => { + let fill = self.brush_of(flags, r.u32(0)?)?; + let [start, sweep] = floats::<2>(r, 4)?; + let rect = *rects(r, 12, 1, compressed)?.first()?; + self.painter().fill(cv, &arc_ops(rect, start, sweep, true), &fill); + Some(()) + } + 0x4011 | 0x4012 => { + let pen = self.pen(id)?; + let [start, sweep] = floats::<2>(r, 0)?; + let rect = *rects(r, 8, 1, compressed)?.first()?; + self.painter().stroke(cv, &arc_ops(rect, start, sweep, typ == 0x4011), pen); + Some(()) + } + _ => None, + } + } + + /// Clear, region fill, image and text records. + fn record_image_text_region(&mut self, cv: &mut Canvas, typ: u16, flags: u16, r: Bytes) -> Option<()> { + let compressed = flags & 0x4000 != 0; + let id = usize::from(flags & 0xFF); + match typ { + // Clear: the whole picture in one colour, whatever the clip (a transparent clear + // leaves nothing to draw). + 0x4009 => { + let c = argb(r.u32(0)?); + let [x, y, w, h] = self.frame?; + if c[3] == 0 || !(w > 0.0 && h > 0.0) { + return Some(()); + } + let to_device = invert(mul(cv.world(), cv.device_affine()))?; + let p = Painter { xf: to_device, clip: None }; + p.fill(cv, &rect_ops([x, y, x + w, y + h]), &Fill::Solid(c)); + Some(()) + } + // FillRegion: the region's area (as the clip reads it). + 0x4013 => { + let fill = self.brush_of(flags, r.u32(0)?)?; + let Some(Some(Obj::Region(Some(area)))) = self.objects.get(id).cloned() else { return None }; + if area.len() < 3 { + return None; + } + self.painter().fill(cv, &poly_ops(&area, true), &fill); + Some(()) + } + 0x401A => { + let img = self.image(id)?; + let dst = *rects(r, 24, 1, compressed)?.first()?; + self.draw_image(cv, &img, r, [dst[0], dst[1], dst[2] - dst[0], dst[3] - dst[1]]) + } + 0x401B => { + let img = self.image(id)?; + let pts = points(r, 28, 3, compressed)?; + let (p0, p1, p2) = (*pts.first()?, *pts.get(1)?, *pts.get(2)?); + // The destination parallelogram's top-left, top-right and bottom-left corners; a + // sheared or rotated one is drawn into its bounding rectangle's axes. + let dst = [p0.0, p0.1, p1.0 - p0.0 + (p2.0 - p0.0), p2.1 - p0.1 + (p1.1 - p0.1)]; + self.draw_image(cv, &img, r, dst) + } + 0x401C => self.draw_string(cv, flags, r), + 0x4036 => self.draw_driver_string(cv, flags, r), + _ => None, + } + } + + /// Save / restore, transform and clip records. + fn record_state(&mut self, cv: &mut Canvas, typ: u16, flags: u16, r: Bytes) -> Option<()> { + let id = usize::from(flags & 0xFF); + match typ { + 0x4025 | 0x4028 => r.u32(0).map(|i| self.push(i)), + 0x4027 => r.u32(24).map(|i| self.push(i)), + 0x4026 | 0x4029 => r.u32(0).map(|i| self.pop(i)), + 0x402A => matrix(r, 0).map(|m| self.world = m), + 0x402B => { + self.world = IDENTITY; + Some(()) + } + 0x402C => matrix(r, 0).and_then(|m| self.apply(m, flags)), + 0x402D => self.apply([1.0, 0.0, 0.0, 1.0, f64::from(r.f32(0)?), f64::from(r.f32(4)?)], flags), + 0x402E => self.apply([f64::from(r.f32(0)?), 0.0, 0.0, f64::from(r.f32(4)?), 0.0, 0.0], flags), + 0x402F => { + let a = f64::from(r.f32(0)?).to_radians(); + self.apply([a.cos(), a.sin(), -a.sin(), a.cos(), 0.0, 0.0], flags) + } + 0x4030 => { + let page = f64::from(r.f32(0)?) * unit_px(u32::from(flags & 0xFF), self.dpi); + if page.is_finite() && page > 0.0 && page < 1.0e6 { + self.page = page; + } + Some(()) + } + 0x4031 => { + self.clip = None; + Some(()) + } + 0x4032 => { + let [x, y, w, h] = floats::<4>(r, 0)?; + let area = map_area(Some(rect_poly([x, y, x + w, y + h])), mul(self.xf(), cv.device_affine())); + self.combine(flags, area) + } + 0x4033 => { + let area = map_area(Some(area_of_ops(&self.path(id)?)), mul(self.xf(), cv.device_affine())); + self.combine(flags, area) + } + 0x4034 => { + let Some(Some(Obj::Region(reg))) = self.objects.get(id).cloned() else { return None }; + let area = map_area(reg, mul(self.xf(), cv.device_affine())); + self.combine(flags, area) + } + 0x4035 => { + let [dx, dy] = floats::<2>(r, 0)?; + let a = mul(self.xf(), cv.device_affine()); + let (ox, oy) = (dx * a[0] + dy * a[2], dx * a[1] + dy * a[3]); + if let Some(c) = self.clip.as_mut() { + for p in c.iter_mut() { + *p = (p.0 + ox, p.1 + oy); + } + } + Some(()) + } + _ => None, + } + } + + /// Without the `A` flag a transform is applied before the world transform (prepended). + fn apply(&mut self, m: Xform, flags: u16) -> Option<()> { + self.world = if flags & 0x2000 != 0 { mul(self.world, m) } else { mul(m, self.world) }; + Some(()) + } + + fn push(&mut self, id: u32) { + if self.stack.len() < MAX_STACK { + self.stack.push(Saved { id, world: self.world, page: self.page, clip: self.clip.clone() }); + } + } + + fn pop(&mut self, id: u32) { + if let Some(i) = self.stack.iter().rposition(|s| s.id == id) { + let s = self.stack.swap_remove(i); + (self.world, self.page, self.clip) = (s.world, s.page, s.clip); + self.stack.truncate(i); + } + } + + /// Combines a new clip area with the current one under the record's combine mode: replace, + /// intersect and union (as the rectangle around both). XOR, exclude and complement leave the + /// clip as it was. + fn combine(&mut self, flags: u16, new: Area) -> Option<()> { + self.clip = match (flags >> 8) & 0xF { + 0 => new, + 1 => area_intersect(self.clip.take(), new), + 2 => area_union(self.clip.take(), new), + _ => self.clip.take(), + }; + Some(()) + } + + fn object(&mut self, flags: u16, r: Bytes) -> Option<()> { + let id = usize::from(flags & 0xFF); + let kind = (flags >> 8) & 0x7F; + if flags & 0x8000 != 0 { + // A continued object: the total size, then this record's share of the bytes. + let total = (r.u32(0)? as usize).min(MAX_OBJECT_BYTES); + let chunk = r.tail(4)?.0; + let p = match self.partial.take() { + Some(p) if p.id == id && p.kind == kind && p.total == total => p, + _ => Partial { id, kind, total, data: Vec::new() }, + }; + let mut p = p; + let room = p.total.saturating_sub(p.data.len()); + p.data.extend_from_slice(&chunk[..chunk.len().min(room)]); + if p.data.len() >= p.total { + let obj = parse_object(p.kind, Bytes(&p.data)); + self.store(p.id, obj); + } else { + self.partial = Some(p); + } + return Some(()); + } + // The last piece of a continued object comes without the continue flag. + if let Some(mut p) = self.partial.take() + && p.id == id + && p.kind == kind + { + let room = p.total.saturating_sub(p.data.len()); + p.data.extend_from_slice(&r.0[..r.0.len().min(room)]); + let obj = parse_object(kind, Bytes(&p.data)); + self.store(id, obj); + return Some(()); + } + let obj = parse_object(kind, r); + self.store(id, obj); + Some(()) + } + + fn store(&mut self, id: usize, obj: Option) { + let Some(slot) = self.objects.get_mut(id) else { return }; + if let Some(old) = slot.take() { + self.object_bytes = self.object_bytes.saturating_sub(Self::obj_cost(&old)); + } + if let Some(new_obj) = obj { + let cost = Self::obj_cost(&new_obj); + if self.object_bytes.saturating_add(cost) <= crate::MAX_BYTES { + self.object_bytes += cost; + *slot = Some(new_obj); + } + } + } + + fn obj_cost(obj: &Obj) -> usize { + match obj { + Obj::Image(img) => match img.as_ref() { + ImageData::Bitmap(rgba) => rgba.px.len(), + ImageData::Meta(pic) => pic.items.len().saturating_mul(128), + }, + Obj::Brush(Fill::Texture { img, .. }) => img.px.len(), + Obj::Path(ops) => ops.len().saturating_mul(std::mem::size_of::()), + _ => 0, + } + } + + fn path(&self, id: usize) -> Option> { + match self.objects.get(id)? { + Some(Obj::Path(ops)) => Some(ops.clone()), + _ => None, + } + } + + fn pen(&self, id: usize) -> Option<([u8; 4], f64)> { + match self.objects.get(id)? { + Some(Obj::Pen { color, width }) => Some((*color, *width)), + _ => None, + } + } + + fn image(&self, id: usize) -> Option> { + match self.objects.get(id)? { + Some(Obj::Image(img)) => Some(img.clone()), + _ => None, + } + } + + /// The brush of a fill record: an ARGB value with the `S` flag, else a brush object. + fn brush_of(&self, flags: u16, v: u32) -> Option { + if flags & 0x8000 != 0 { + return Some(Fill::Solid(argb(v))); + } + match self.objects.get(v as usize)? { + Some(Obj::Brush(f)) => Some(f.clone()), + _ => None, + } + } + + /// DrawImage and DrawImagePoints: the source rectangle (pixels) cut from the image, drawn + /// into world rectangle `dst` = `[x, y, w, h]`. + fn draw_image(&self, cv: &mut Canvas, img: &ImageData, r: Bytes, dst: [f64; 4]) -> Option<()> { + let img = match img { + ImageData::Bitmap(b) => b, + ImageData::Meta(pic) => { + self.painter().picture(cv, dst, pic); + return Some(()); + } + }; + let [sx, sy, sw, sh] = floats::<4>(r, 8)?; + let src = if r.u32(4)? == 2 { [sx, sy, sw, sh].map(|v| v.round().clamp(-1.0e9, 1.0e9) as i64) } else { [0; 4] }; + let (part, dst) = crate::dib::clip_blit(img, src, dst)?; + self.painter().image(cv, dst, part); + Some(()) + } + + fn font(&self, id: usize) -> Option { + match self.objects.get(id)? { + Some(Obj::Font(f)) => Some(f.clone()), + _ => None, + } + } + + /// The font as a LOGFONT whose height is in world units. + fn log_font(&self, f: &FontObj) -> LogFont { + let em = if f.unit == 0 { f.em } else { f.em * unit_px(f.unit, self.dpi) / self.page }; + let font = Font { + family: f.family.clone(), + height: -(em.clamp(0.0, 1.0e6) as f32), + weight: if f.style & 1 != 0 { 700 } else { 400 }, + italic: f.style & 2 != 0, + underline: f.style & 4 != 0, + strike: f.style & 8 != 0, + ..Font::default() + }; + LogFont { font, escapement: 0.0 } + } + + /// DrawString: lines of text laid out in a rectangle by the string format's alignments. + fn draw_string(&self, cv: &mut Canvas, flags: u16, r: Bytes) -> Option<()> { + let font = self.font(usize::from(flags & 0xFF))?; + let color = self.brush_of(flags, r.u32(0)?)?.color(); + let fmt = match self.objects.get(r.u32(4)? as usize) { + Some(Some(Obj::Format(f))) => *f, + _ => Format::default(), + }; + let len = (r.u32(8)? as usize).min(MAX_CHARS); + let [x, y, w, h] = floats::<4>(r, 12)?; + let units: Vec = (0..len).map(|i| r.u16(28 + i * 2)).collect::>()?; + let lf = self.log_font(&font); + let em = f64::from(-lf.font.height); + let wrap = w > 0.0 && fmt.flags & 0x1000 == 0; + let lines: Vec> = units + .split(|u| *u == 0x0A) + .flat_map(|l| { + let l: Vec = l.iter().copied().filter(|u| *u != 0x0D).collect(); + if wrap { wrap_line(&l, w, em) } else { vec![l] } + }) + .collect(); + let line_h = em * 1.2; + let (ax, ta_h) = match fmt.align { + 1 => (x + w / 2.0, TA_CENTER), + 2 => (x + w, TA_RIGHT), + _ => (x, 0), + }; + let block = line_h * lines.len() as f64; + let top = match fmt.line_align { + 1 => y + (h - block) / 2.0, + 2 => y + h - block, + _ => y, + }; + let clip = (w > 0.0 && h > 0.0 && fmt.flags & 0x4000 == 0).then_some([x, y, x + w, y + h]); + let p = self.painter(); + for (i, line) in lines.iter().enumerate() { + if line.is_empty() { + continue; + } + let (text, dx) = crate::text::decode_utf16(line, &[]); + let at = (ax, top + line_h * i as f64); + let (at, ta) = if fmt.line_align == 2 && lines.len() == 1 { ((ax, y + h), ta_h | TA_BOTTOM) } else { (at, ta_h) }; + p.text(cv, at, text, dx, TextStyle { color, font: lf.clone(), align: ta, clip }, IDENTITY); + } + Some(()) + } + + /// DrawDriverString with character codes (CmapLookup): positioned glyphs on a baseline. + fn draw_driver_string(&self, cv: &mut Canvas, flags: u16, r: Bytes) -> Option<()> { + let font = self.font(usize::from(flags & 0xFF))?; + let color = self.brush_of(flags, r.u32(0)?)?.color(); + let opts = r.u32(4)?; + let n = (r.u32(12)? as usize).min(MAX_CHARS); + let mut units: Vec = (0..n).map(|i| r.u16(16 + i * 2)).collect::>()?; + if opts & 1 == 0 { + // Glyph indices into the font. The crate knows no fonts, but the classic Windows core + // fonts keep the standard Macintosh glyph order for ASCII, so those map back. + units = glyphs_to_chars(&font.family, &units)?; + } + let pos = points(r, 16 + n * 2, n, false)?; + let m = if r.u32(8)? != 0 { matrix(r, 16 + n * 10)? } else { IDENTITY }; + let lf = self.log_font(&font); + let first = *pos.first()?; + let dx: Vec = if opts & 4 != 0 { + Vec::new() + } else { + let mut d: Vec = pos.windows(2).map(|w| (w[1].0 - w[0].0) as f32).collect(); + d.push(d.last().copied().unwrap_or(-lf.font.height * 0.5)); + d + }; + let (text, dx) = crate::text::decode_utf16(&units, &dx); + self.painter().text(cv, first, text, dx, TextStyle { color, font: lf, align: TA_BASELINE, clip: None }, m); + Some(()) + } +} + +/// Page unit to device pixels: world, display and pixel are 1; point, inch, document, mm. +fn unit_px(unit: u32, dpi: f64) -> f64 { + match unit { + 3 => dpi / 72.0, + 4 => dpi, + 5 => dpi / 300.0, + 6 => dpi / 25.4, + _ => 1.0, + } +} + +struct TextStyle { + color: [u8; 4], + font: LogFont, + align: u32, + /// World clip rectangle `[l, t, r, b]`. + clip: Option<[f64; 4]>, +} + +/// Draws through the canvas with EMF+'s transform, clip and its own pen or brush, then puts the +/// canvas's GDI state back. +struct Painter { + xf: Xform, + clip: Area, +} + +impl Painter { + fn begin(&self, cv: &mut Canvas, pre: Xform) { + let world = cv.world(); + cv.save_dc(); + cv.modify_world(mul(mul(pre, self.xf), world), 4); + cv.set_clip(self.clip.clone()); + } + + fn end(&self, cv: &mut Canvas) { + cv.restore_dc(-1); + cv.set_clip(None); + } + + fn no_pen(cv: &mut Canvas) { + cv.set_pen(Pen::new(PS_NULL, 0.0, [0, 0, 0, 0])); + } + + fn fill(&self, cv: &mut Canvas, ops: &[Op], fill: &Fill) { + self.begin(cv, IDENTITY); + Self::no_pen(cv); + match fill { + Fill::Solid(c) => { + cv.set_brush(Brush::new(BS_SOLID, *c)); + cv.shape(ops.to_vec(), true, false); + } + Fill::Hatch { style, fg, bg } => match hatch_tile(*style, *fg, Some(*bg)) { + Some(px) => cv.tile_shape(ops, Arc::new(RawTile { w: 8, h: 8, px, cell: cv.pattern_px })), + // GDI+ has many more hatch styles than GDI: those fill with the blend of both colours. + None => { + cv.set_brush(Brush::new(BS_SOLID, lerp(*fg, *bg, 0.5))); + cv.shape(ops.to_vec(), true, false); + } + }, + _ => { + // Device point to brush space: invert brush space → world → device. + if let Some(inv) = invert(mul(fill.xf(), cv.device_affine())) { + cv.shade_shape(ops, &|p| fill.sample(apply(inv, p))); + } + } + } + self.end(cv); + } + + fn stroke(&self, cv: &mut Canvas, ops: &[Op], (color, width): ([u8; 4], f64)) { + self.begin(cv, IDENTITY); + cv.set_pen(Pen::new(0, width, color)); + cv.set_brush(Brush::new(BS_NULL, [0, 0, 0, 0])); + cv.shape(ops.to_vec(), false, true); + self.end(cv); + } + + fn image(&self, cv: &mut Canvas, dst: [f64; 4], img: Rgba) { + self.begin(cv, IDENTITY); + cv.bitmap(dst, img); + self.end(cv); + } + + /// A nested metafile's picture stretched into world rectangle `dst` = `[x, y, w, h]`. + fn picture(&self, cv: &mut Canvas, [x, y, w, h]: [f64; 4], pic: &Picture) { + self.begin(cv, IDENTITY); + let (sx, sy) = (w / f64::from(pic.width), h / f64::from(pic.height)); + let m = mul([sx, 0.0, 0.0, sy, x, y], cv.device_affine()); + let scale = (m[0] * m[3] - m[1] * m[2]).abs().sqrt(); + let pt = |a: f32, b: f32| apply(m, (f64::from(a), f64::from(b))); + let ops = |segs: &[Seg]| -> Vec { + segs.iter() + .map(|s| match *s { + Seg::Move(a, b) => Op::M(pt(a, b)), + Seg::Line(a, b) => Op::L(pt(a, b)), + Seg::Cubic(a, b, c, d, e, f) => Op::C(pt(a, b), pt(c, d), pt(e, f)), + Seg::Close => Op::Z, + }) + .collect() + }; + // The nested picture's own clip regions are not carried over: each item takes the + // canvas's current clip. + for item in &pic.items { + if cv.full() { + break; + } + let raw = match item { + Item::Path { segs, fill, stroke, line, even_odd, .. } => Raw::Path { + ops: ops(segs), + fill: *fill, + stroke: stroke.map(|(c, w)| (c, f64::from(w) * scale)), + line: LineStyle { dash: line.dash.iter().map(|d| (f64::from(*d) * scale) as f32).collect(), ..line.clone() }, + even_odd: *even_odd, + region: RawClip::default(), + }, + Item::Pattern { segs, even_odd, tile, .. } => Raw::Pattern { + ops: ops(segs), + even_odd: *even_odd, + tile: Arc::new(RawTile { + w: tile.width as usize, + h: tile.height as usize, + px: tile.rgba.clone(), + cell: f64::from(tile.cell[0]) * scale, + }), + region: RawClip::default(), + }, + Item::Bitmap { rect, transform, width, height, rgba, .. } => { + let (a, b) = (pt(rect[0], rect[1]), pt(rect[0] + rect[2], rect[1] + rect[3])); + let mut img = Rgba { w: *width as usize, h: *height as usize, px: rgba.clone() }; + let rect = [a.0.min(b.0), a.1.min(b.1), (b.0 - a.0).abs(), (b.1 - a.1).abs()]; + let turned = mul(*transform, m); + let xf = if turned[1] == 0.0 && turned[2] == 0.0 { + crate::dib::flip(&mut img, a.0 > b.0, a.1 > b.1); + [rect[2], 0.0, 0.0, rect[3], rect[0], rect[1]] + } else { + turned + }; + Raw::Bitmap { rect, xf, img, region: RawClip::default() } + } + Item::Text(t) => { + let mut t = t.clone(); + t.transform = mul(t.transform, m); + if let Some([x0, y0, x1, y1]) = t.clip { + let (p, q) = (pt(x0, y0), pt(x1, y1)); + t.clip = 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]); + } + Raw::Text { text: t, region: RawClip::default() } + } + }; + cv.push_device(raw); + } + self.end(cv); + } + + fn text(&self, cv: &mut Canvas, at: P, text: String, dx: Vec, style: TextStyle, pre: Xform) { + self.begin(cv, pre); + cv.set_text_style(style.color, style.font, style.align); + let (opts, rect) = match style.clip { + Some(c) => (ETO_CLIPPED, Some(c)), + None => (0, None), + }; + cv.text(at, text, dx, opts, rect); + self.end(cv); + } +} + +fn apply(m: Xform, p: P) -> P { + (p.0 * m[0] + p.1 * m[2] + m[4], p.0 * m[1] + p.1 * m[3] + m[5]) +} + +fn invert(m: Xform) -> Option { + let det = m[0] * m[3] - m[1] * m[2]; + if !det.is_finite() || det.abs() < 1e-12 { + return None; + } + let (a, b, c, d) = (m[3] / det, -m[1] / det, -m[2] / det, m[0] / det); + Some([a, b, c, d, -(m[4] * a + m[5] * c), -(m[4] * b + m[5] * d)]) +} + +/// ARGB (0xAARRGGBB) to RGBA. +fn argb(v: u32) -> [u8; 4] { + [(v >> 16) as u8, (v >> 8) as u8, v as u8, (v >> 24) as u8] +} + +fn floats(r: Bytes, off: usize) -> Option<[f64; N]> { + let mut v = [0.0; N]; + for (i, x) in v.iter_mut().enumerate() { + *x = f64::from(r.f32(off + i * 4)?); + } + v.iter().all(|x| x.is_finite()).then_some(v) +} + +fn matrix(r: Bytes, off: usize) -> Option { + floats::<6>(r, off) +} + +fn parse_object(kind: u16, r: Bytes) -> Option { + match kind { + 1 => brush(r, 0).map(Obj::Brush), + 2 => pen(r), + 3 => path(r).map(Obj::Path), + 4 => region(r), + 5 => image(r).map(|i| Obj::Image(Arc::new(i))), + 6 => font(r), + 7 => Some(Obj::Format(Format { flags: r.u32(4)?, align: r.u32(12)?, line_align: r.u32(16)? })), + _ => None, + } +} + +/// A brush object at `off`: version, type, then the type's data. +fn brush(r: Bytes, off: usize) -> Option { + let d = r.tail(off + 8)?; + match r.u32(off + 4)? { + 0 => Some(Fill::Solid(argb(d.u32(0)?))), + 1 => Some(Fill::Hatch { style: d.u32(0)?, fg: argb(d.u32(4)?), bg: argb(d.u32(8)?) }), + 2 => { + let bflags = d.u32(0)?; + let wrap = d.u32(4)?; + let (xf, off) = if bflags & 0x2 != 0 { (matrix(d, 8)?, 32) } else { (IDENTITY, 8) }; + let ImageData::Bitmap(img) = image(d.tail(off)?)? else { return None }; + Some(Fill::Texture { img: Arc::new(img), wrap, xf }) + } + 3 => path_gradient(d), + 4 => linear_gradient(d), + _ => None, + } +} + +fn linear_gradient(d: Bytes) -> Option { + let bflags = d.u32(0)?; + let wrap = d.u32(4)?; + let rect = floats::<4>(d, 8)?; + let (c0, c1) = (argb(d.u32(24)?), argb(d.u32(28)?)); + let mut off = 40; + let mut xf = IDENTITY; + if bflags & 0x2 != 0 { + xf = matrix(d, off)?; + off += 24; + } + let mut stops = vec![(0.0, c0), (1.0, c1)]; + if bflags & 0x4 != 0 { + // Preset colours: positions, then one colour each. + let n = (d.u32(off)? as usize).min(MAX_STOPS); + let pos: Vec = (0..n).map(|i| d.f32(off + 4 + i * 4).map(f64::from)).collect::>()?; + let cols: Vec<[u8; 4]> = (0..n).map(|i| d.u32(off + 4 + n * 4 + i * 4).map(argb)).collect::>()?; + stops = pos.into_iter().zip(cols).collect(); + } else if bflags & 0x8 != 0 { + // Blend factors: positions, then how far from the start colour to the end colour. + let n = (d.u32(off)? as usize).min(MAX_STOPS); + let pos: Vec = (0..n).map(|i| d.f32(off + 4 + i * 4).map(f64::from)).collect::>()?; + let fac: Vec = (0..n).map(|i| d.f32(off + 4 + n * 4 + i * 4).map(f64::from)).collect::>()?; + stops = pos.into_iter().zip(fac).map(|(p, k)| (p, lerp(c0, c1, k.clamp(0.0, 1.0)))).collect(); + } + stops.retain(|s| s.0.is_finite()); + stops.sort_by(|a, b| a.0.total_cmp(&b.0)); + (!stops.is_empty()).then_some(Fill::Linear { rect, stops, wrap, xf }) +} + +/// A path gradient: from the centre colour to the (first) surrounding colour at the boundary, +/// taken as the ellipse in the boundary's bounding rectangle. +fn path_gradient(d: Bytes) -> Option { + let bflags = d.u32(0)?; + let center_c = argb(d.u32(8)?); + let [cx, cy] = floats::<2>(d, 12)?; + let nc = (d.u32(20)? as usize).min(MAX_STOPS); + let surround = if nc > 0 { argb(d.u32(24)?) } else { center_c }; + let mut off = 24 + nc * 4; + let bbox = if bflags & 0x1 != 0 { + let size = d.u32(off)? as usize; + let b = ops_bbox(&path(d.tail(off + 4)?)?)?; + off = off.checked_add(4 + size)?; + b + } else { + let n = d.u32(off)? as usize; + let pts = points(d, off + 4, n, false)?; + off = off.checked_add(4 + 8 * n.min(MAX_POINTS))?; + ops_bbox(&poly_ops(&pts, true))? + }; + let xf = if bflags & 0x2 != 0 { matrix(d, off)? } else { IDENTITY }; + let radius = ((bbox[2] - bbox[0]) / 2.0, (bbox[3] - bbox[1]) / 2.0); + Some(Fill::Radial { center: (cx, cy), radius, stops: vec![(0.0, center_c), (1.0, surround)], xf }) +} + +/// A pen object: version, type, then pen data (flags, unit, width, optional fields), then a brush. +fn pen(r: Bytes) -> Option { + let pflags = r.u32(8)?; + let width = f64::from(r.f32(16)?); + let mut off = 20usize; + for (bit, len) in [(0x1, 24), (0x2, 4), (0x4, 4), (0x8, 4), (0x10, 4), (0x20, 4), (0x40, 4), (0x80, 4)] { + if pflags & bit != 0 { + off += len; + } + } + for bit in [0x100, 0x200, 0x400] { + if pflags & bit != 0 { + off += if bit == 0x200 { 4 } else { 4 + 4 * (r.u32(off)? as usize).min(MAX_POINTS) }; + } + } + for bit in [0x800, 0x1000] { + if pflags & bit != 0 { + off += 4 + (r.u32(off)? as usize).min(1 << 20); + } + } + let color = brush(r, off)?.color(); + Some(Obj::Pen { color, width: if width.is_finite() { width.clamp(0.0, 1.0e5) } else { 0.0 } }) +} + +/// A path object: points, then one type byte per point (start, line, Bézier; 0x80 closes). +fn path(r: Bytes) -> Option> { + let n = (r.u32(4)? as usize).min(MAX_POINTS); + let pflags = r.u32(8)?; + if pflags & 0x1800 != 0 { + // Relative (0x0800) and run-length encoded (0x1000) points: not read yet. + return None; + } + let compressed = pflags & 0x4000 != 0; + let pts = points(r, 12, n, compressed)?; + let toff = 12 + n * if compressed { 4 } else { 8 }; + let types = r.slice(toff, n)?; + let mut ops = Vec::with_capacity(n + 4); + let mut i = 0; + while i < n { + let (t, p) = (types[i], pts[i]); + match t & 0x07 { + 0 => ops.push(Op::M(p)), + 3 if i + 2 < n => { + ops.push(Op::C(p, pts[i + 1], pts[i + 2])); + if types[i + 2] & 0x80 != 0 { + ops.push(Op::Z); + } + i += 3; + continue; + } + _ => ops.push(Op::L(p)), + } + if t & 0x80 != 0 { + ops.push(Op::Z); + } + i += 1; + } + Some(ops) +} + +/// A region object as a convex area in world units (non-convex parts become their bounding +/// rectangle). +fn region(r: Bytes) -> Option { + let mut work = 0; + region_node(r, 8, 0, &mut work).map(|(a, _)| Obj::Region(a)) +} + +/// One region node at `off`: its area and its length in bytes. +fn region_node(r: Bytes, off: usize, depth: usize, work: &mut usize) -> Option<(Area, usize)> { + *work = work.checked_add(1)?; + if depth > MAX_REGION_DEPTH || *work > MAX_REGION_WORK { + return None; + } + match r.u32(off)? { + 0x1000_0000 => { + let [x, y, w, h] = floats::<4>(r, off + 4)?; + Some((Some(rect_poly([x.min(x + w), y.min(y + h), x.max(x + w), y.max(y + h)])), 20)) + } + 0x1000_0001 => { + let size = r.u32(off + 4)? as usize; + if size < 12 { + return None; + } + let data = Bytes(r.slice(off + 8, size)?); + let points = (data.u32(4)? as usize).min(MAX_POINTS); + *work = work.checked_add(points)?; + if *work > MAX_REGION_WORK { + return None; + } + let area = area_of_ops(&path(data)?); + Some((Some(area), 8usize.checked_add(size)?)) + } + 0x1000_0002 => Some((Some(Vec::new()), 4)), + 0x1000_0003 => Some((None, 4)), + mode @ 0..=5 => { + let (a, la) = region_node(r, off + 4, depth + 1, work)?; + let (b, lb) = region_node(r, off.checked_add(4 + la)?, depth + 1, work)?; + let out = match mode { + 0 => b, + 1 => area_intersect(a, b), + 2 => area_union(a, b), + // XOR, exclude, complement: the left area bounds the result closely enough. + _ => a, + }; + Some((out, (4 + la).checked_add(lb)?)) + } + _ => None, + } +} + +/// The area a path encloses: its outline when that is one convex figure, else the rectangle +/// around it. +fn area_of_ops(ops: &[Op]) -> Vec

{ + let subs = flatten(ops); + if let [(pts, _)] = subs.as_slice() + && let Some(c) = convex(pts) + { + return c; + } + ops_bbox(ops).map(rect_poly).unwrap_or_default() +} + +fn area_intersect(a: Area, b: Area) -> Area { + match (a, b) { + (None, x) | (x, None) => x, + (Some(a), Some(b)) if a.len() >= 3 && b.len() >= 3 => { + // Out of work: keep the area so far rather than call the region empty. + match clip_polygon_within(&a, &b, &mut { MAX_AREA_CLIP_WORK }) { + Some(p) => Some(convex(&p).unwrap_or_default()), + None => Some(a), + } + } + _ => Some(Vec::new()), + } +} + +fn area_union(a: Area, b: Area) -> Area { + match (a, b) { + (Some(a), Some(b)) => { + if a.len() < 3 { + return Some(b); + } + if b.len() < 3 { + return Some(a); + } + let all: Vec

= a.into_iter().chain(b).collect(); + Some(pts_bbox(&all).map(rect_poly).unwrap_or_default()) + } + _ => None, + } +} + +/// An area through an affine map (which keeps it convex; the orientation is put right again). +fn map_area(a: Area, m: Xform) -> Area { + a.map(|pts| { + let mapped: Vec

= pts.iter().map(|p| apply(m, *p)).collect(); + if mapped.len() < 3 { Vec::new() } else { convex(&mapped).unwrap_or_default() } + }) +} + +/// An elliptical arc in `rect` from `start` sweeping `sweep` degrees (clockwise on screen), as +/// cubics; a pie adds the lines to and from the centre. +fn arc_ops([l, t, r, b]: [f64; 4], start: f64, sweep: f64, pie: bool) -> Vec { + let (cx, cy, rx, ry) = ((l + r) / 2.0, (t + b) / 2.0, (r - l) / 2.0, (b - t) / 2.0); + let sweep = sweep.clamp(-360.0, 360.0); + // GDI+ angles are along rays from the centre; turn them into the ellipse's parameter. + let param = |deg: f64| { + let a = deg.to_radians(); + (rx * a.sin()).atan2(ry * a.cos()) + }; + let t0 = param(start); + let mut dt = if sweep.abs() >= 360.0 { sweep.signum() * std::f64::consts::TAU } else { param(start + sweep) - t0 }; + if sweep.abs() < 360.0 { + while sweep > 0.0 && dt < 0.0 { + dt += std::f64::consts::TAU; + } + while sweep < 0.0 && dt > 0.0 { + dt -= std::f64::consts::TAU; + } + } + let at = |t: f64| (cx + rx * t.cos(), cy + ry * t.sin()); + let mut ops = Vec::with_capacity(8); + if pie { + ops.push(Op::M((cx, cy))); + ops.push(Op::L(at(t0))); + } else { + ops.push(Op::M(at(t0))); + } + let n = ((dt.abs() / std::f64::consts::FRAC_PI_2).ceil() as usize).clamp(1, 4); + let step = dt / n as f64; + let k = 4.0 / 3.0 * (step / 4.0).tan(); + for i in 0..n { + let (a, e) = (t0 + step * i as f64, t0 + step * (i + 1) as f64); + let (p0, p3) = (at(a), at(e)); + let c1 = (p0.0 - k * rx * a.sin(), p0.1 + k * ry * a.cos()); + let c2 = (p3.0 + k * rx * e.sin(), p3.1 - k * ry * e.cos()); + ops.push(Op::C(c1, c2, p3)); + } + if pie { + ops.push(Op::Z); + } + ops +} + +/// A font object: em size, unit, style flags, then the family name in UTF-16. +fn font(r: Bytes) -> Option { + let em = f64::from(r.f32(4)?); + let n = (r.u32(20)? as usize).min(64); + let units: Vec = (0..n).map(|i| r.u16(24 + i * 2)).collect::>()?; + let family = String::from_utf16_lossy(&units).trim_end_matches('\0').to_string(); + em.is_finite().then_some(Obj::Font(FontObj { em, unit: r.u32(8)?, style: r.u32(12)?, family })) +} + +/// An image object: a bitmap of raw pixels or a compressed (PNG, JPEG, GIF…) file, or a nested +/// metafile. +fn image(r: Bytes) -> Option { + match r.u32(4)? { + 1 => bitmap(r).map(ImageData::Bitmap), + 2 => metafile(r).map(ImageData::Meta), + _ => None, + } +} + +/// A metafile image: type, size, then a WMF (placeable or not) or EMF file, parsed as a picture. +fn metafile(r: Bytes) -> Option { + let size = r.u32(12)? as usize; + let data = r.slice(16, size).or_else(|| r.tail(16).map(|t| t.0))?; + let depth = NESTING.with(std::cell::Cell::get); + if depth >= MAX_NESTING { + return None; + } + NESTING.with(|n| n.set(depth + 1)); + let pic = crate::parse_nested(data).ok(); + NESTING.with(|n| n.set(depth)); + pic.filter(|p| p.width > 0.0 && p.height > 0.0) +} + +fn bitmap(r: Bytes) -> Option { + let (w, h, stride) = (r.u32(8)? as i32, r.u32(12)? as i32, r.u32(16)? as i32); + let format = r.u32(20)?; + let data = r.tail(28)?; + if r.u32(24)? == 1 { + return crate::dib::decode_compressed(data.0); + } + let (w, h) = (usize::try_from(w).ok()?, usize::try_from(h).ok()?); + if w == 0 || h == 0 || w.checked_mul(h)? > crate::MAX_PIXELS { + return None; + } + let bpp = ((format >> 8) & 0xFF) as usize; + let (pal, pixels) = if format & 0x0001_0000 != 0 { + // Indexed formats carry a palette first: flags, count, ARGB entries. + let n = (data.u32(4)? as usize).min(256); + let pal: Vec<[u8; 4]> = (0..n).map(|i| data.u32(8 + i * 4).map(argb)).collect::>()?; + (pal, data.tail(8 + n * 4)?) + } else { + (Vec::new(), data) + }; + let row = (w.checked_mul(bpp)?).div_ceil(8); + let stride_abs = (stride.unsigned_abs() as usize).max(row); + let mut px = Vec::with_capacity(w * h * 4); + for y in 0..h { + let line = pixels.slice(y.checked_mul(stride_abs)?, row)?; + for x in 0..w { + let c = match (format, bpp) { + (_, 1 | 4 | 8) if !pal.is_empty() => { + let bit = x * bpp; + let byte = *line.get(bit / 8)?; + let i = (byte >> (8 - bpp - bit % 8)) & ((1u16 << bpp) - 1) as u8; + pal.get(usize::from(i)).copied().unwrap_or([0, 0, 0, 255]) + } + (_, 24) => { + let p = line.get(x * 3..x * 3 + 3)?; + [p[2], p[1], p[0], 255] + } + (0x0002_2009, 32) => { + let p = line.get(x * 4..x * 4 + 4)?; + [p[2], p[1], p[0], 255] + } + (0x000E_200B, 32) => { + let p = line.get(x * 4..x * 4 + 4)?; + let a = u32::from(p[3]); + let un = |v: u8| (u32::from(v) * 255 + a / 2).checked_div(a).map_or(v, |c| c.min(255) as u8); + [un(p[2]), un(p[1]), un(p[0]), p[3]] + } + (_, 32) => { + let p = line.get(x * 4..x * 4 + 4)?; + [p[2], p[1], p[0], p[3]] + } + (0x0002_1006, 16) => { + let v = u16::from_le_bytes([*line.get(x * 2)?, *line.get(x * 2 + 1)?]); + let s = |v: u16, bits: u32| ((u32::from(v) * 255) / ((1 << bits) - 1)) as u8; + [s(v >> 11, 5), s((v >> 5) & 0x3F, 6), s(v & 0x1F, 5), 255] + } + (_, 16) => { + let v = u16::from_le_bytes([*line.get(x * 2)?, *line.get(x * 2 + 1)?]); + let s = |v: u16| ((u32::from(v & 0x1F) * 255) / 31) as u8; + [s(v >> 10), s(v >> 5), s(v), 255] + } + _ => return None, + }; + px.extend_from_slice(&c); + } + } + // A negative stride stores the rows bottom-up. + let mut img = Rgba { w, h, px }; + if stride < 0 { + crate::dib::flip(&mut img, false, true); + } + Some(img) +} + +/// `n` points at `off`: 16-bit integers when compressed, else 32-bit floats. +fn points(r: Bytes, off: usize, n: usize, compressed: bool) -> Option> { + let n = n.min(MAX_POINTS); + let mut v = Vec::with_capacity(n); + for i in 0..n { + v.push(if compressed { + (f64::from(r.i16(off + i * 4)?), f64::from(r.i16(off + i * 4 + 2)?)) + } else { + let (x, y) = (f64::from(r.f32(off + i * 8)?), f64::from(r.f32(off + i * 8 + 4)?)); + if !(x.is_finite() && y.is_finite()) { + return None; + } + (x, y) + }); + } + Some(v) +} + +/// `n` rectangles (x, y, w, h) at `off`, as `[l, t, r, b]`. +fn rects(r: Bytes, off: usize, n: usize, compressed: bool) -> Option> { + let pts = points(r, off, n.min(MAX_POINTS / 2).checked_mul(2)?, compressed)?; + Some(pts.as_chunks::<2>().0.iter().map(|c| [c[0].0, c[0].1, c[0].0 + c[1].0, c[0].1 + c[1].1]).collect()) +} + +fn rect_ops([l, t, r, b]: [f64; 4]) -> Vec { + crate::geom::rect(l, t, r, b) +} + +fn finite(v: f32) -> Option { + v.is_finite().then_some(f64::from(v)) +} + +/// A GDI+ cardinal spline through `pts` as cubic Béziers: each segment's control points lie a +/// third of `tension` (GDI+'s default 0.5) along the neighbouring chords. `closed` joins the +/// last point back to the first; an open curve draws `segments` segments from point `offset`. +fn curve_ops(pts: &[P], tension: f64, closed: bool, offset: usize, segments: usize) -> Vec { + let n = pts.len(); + if n < 2 { + return Vec::new(); + } + let k = tension.clamp(0.0, 10.0) / 3.0; + let at = |i: isize| -> P { + let i = if closed { i.rem_euclid(n as isize) as usize } else { i.clamp(0, n as isize - 1) as usize }; + pts.get(i).copied().unwrap_or((0.0, 0.0)) + }; + let (first, count) = if closed { (0, n) } else { (offset.min(n - 1), segments.min(n - 1 - offset.min(n - 1))) }; + let mut ops = vec![Op::M(at(first as isize))]; + for s in first..first + count { + let i = s as isize; + let (p0, p1, p2, p3) = (at(i - 1), at(i), at(i + 1), at(i + 2)); + let c1 = (p1.0 + (p2.0 - p0.0) * k, p1.1 + (p2.1 - p0.1) * k); + let c2 = (p2.0 - (p3.0 - p1.0) * k, p2.1 - (p3.1 - p1.1) * k); + ops.push(Op::C(c1, c2, p2)); + } + if closed { + ops.push(Op::Z); + } + ops +} + +fn poly_ops(pts: &[P], close: bool) -> Vec { + let mut ops: Vec = pts.iter().enumerate().map(|(i, p)| if i == 0 { Op::M(*p) } else { Op::L(*p) }).collect(); + if close { + ops.push(Op::Z); + } + ops +} + +/// An ellipse in its bounding box, as four cubic arcs. +fn ellipse_ops([l, t, r, b]: [f64; 4]) -> Vec { + let (cx, cy, rx, ry) = ((l + r) / 2.0, (t + b) / 2.0, (r - l) / 2.0, (b - t) / 2.0); + let k = 0.552_284_75; + let (ox, oy) = (rx * k, ry * k); + vec![ + Op::M((cx + rx, cy)), + Op::C((cx + rx, cy + oy), (cx + ox, cy + ry), (cx, cy + ry)), + Op::C((cx - ox, cy + ry), (cx - rx, cy + oy), (cx - rx, cy)), + Op::C((cx - rx, cy - oy), (cx - ox, cy - ry), (cx, cy - ry)), + Op::C((cx + ox, cy - ry), (cx + rx, cy - oy), (cx + rx, cy)), + Op::Z, + ] +} + +/// Core fonts whose first glyphs follow the standard Macintosh order (glyph 3 is the space, +/// glyphs 3..=97 are U+0020..=U+007E). +const MAC_ORDER_FONTS: &[&str] = + &["arial", "arial black", "times new roman", "courier new", "verdana", "georgia", "tahoma", "trebuchet ms", "comic sans ms", "impact"]; + +/// Glyph indices of a known font back to ASCII; None when the font is unknown or a glyph is +/// outside the ASCII range. +fn glyphs_to_chars(family: &str, glyphs: &[u16]) -> Option> { + let family = family.trim().to_ascii_lowercase(); + if !MAC_ORDER_FONTS.contains(&family.as_str()) { + return None; + } + glyphs.iter().map(|g| (3..=97).contains(g).then_some(g + 29)).collect() +} + +/// A rough advance for a character in ems: the metafile crate has no fonts, so line breaks for +/// wrapped strings use typical proportional widths. +fn char_em(u: u16) -> f64 { + match char::from_u32(u32::from(u)).unwrap_or('x') { + ' ' | 'i' | 'l' | 'j' | '.' | ',' | ':' | ';' | '\'' | '!' | '|' | 'I' | 'f' | 't' | 'r' => 0.3, + 'm' | 'w' | 'M' | 'W' => 0.85, + c if c.is_ascii_uppercase() || c.is_ascii_digit() => 0.62, + c if (c as u32) >= 0x2E80 => 1.0, + _ => 0.52, + } +} + +/// Greedy word wrap of one line to `width` world units at em size `em`; a word longer than the +/// line breaks between characters. +fn wrap_line(line: &[u16], width: f64, em: f64) -> Vec> { + if em <= 0.0 || !width.is_finite() { + return vec![line.to_vec()]; + } + let trimmed = |v: &[u16]| v.iter().rposition(|u| *u != 0x20).map_or(0, |i| i + 1); + let measure = |v: &[u16]| v.iter().map(|u| char_em(*u) * em).sum::(); + + let mut out: Vec> = Vec::new(); + let mut cur: Vec = Vec::new(); + let mut cur_total_w = 0.0; + + for word in line.split_inclusive(|u| *u == 0x20) { + let n_non_space = trimmed(word); + let word_non_space_w = measure(&word[..n_non_space]); + let word_total_w = measure(word); + + if cur.is_empty() { + if word_non_space_w <= width { + cur.extend_from_slice(word); + cur_total_w = word_total_w; + continue; + } + } else if cur_total_w + word_non_space_w <= width { + cur.extend_from_slice(word); + cur_total_w += word_total_w; + continue; + } + + if !cur.is_empty() { + let n = trimmed(&cur); + cur.truncate(n); + out.push(std::mem::take(&mut cur)); + } + + if word_non_space_w <= width { + cur.extend_from_slice(word); + cur_total_w = word_total_w; + continue; + } + + // A word wider than the line on its own: break it between characters. + let mut cur_w = 0.0; + for &u in word { + let u_w = char_em(u) * em; + if cur.is_empty() { + cur.push(u); + cur_w = u_w; + } else if cur_w + u_w <= width { + cur.push(u); + cur_w += u_w; + } else { + let n = trimmed(&cur); + cur.truncate(n); + out.push(std::mem::take(&mut cur)); + cur.push(u); + cur_w = u_w; + } + } + cur_total_w = cur_w; + } + let n = cur.iter().rposition(|u| *u != 0x20).map_or(0, |i| i + 1); + cur.truncate(n); + out.push(cur); + out +} diff --git a/crates/metafile/src/geom.rs b/crates/metafile/src/geom.rs new file mode 100644 index 000000000..0c2b772cf --- /dev/null +++ b/crates/metafile/src/geom.rs @@ -0,0 +1,128 @@ +//! Path operations in `f64` and the shape builders that turn WMF/EMF rectangles, ellipses and arcs +//! into cubic Béziers (the classic 4/3·tan(Δ/4) approximation, at most 90° per piece). + +use std::f64::consts::{FRAC_PI_2, PI, TAU}; + +pub(crate) type P = (f64, f64); + +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) enum Op { + M(P), + L(P), + C(P, P, P), + Z, +} + +pub(crate) fn rect(l: f64, t: f64, r: f64, b: f64) -> Vec { + vec![Op::M((l, t)), Op::L((r, t)), Op::L((r, b)), Op::L((l, b)), Op::Z] +} + +/// Corner radii are half the EMF/WMF corner ellipse size, clamped so the corners never overlap. +pub(crate) fn round_rect(l: f64, t: f64, r: f64, b: f64, ew: f64, eh: f64) -> Vec { + // Reversed corners (right < left, bottom < top) describe the same rectangle. + let (l, r) = (l.min(r), l.max(r)); + let (t, b) = (t.min(b), t.max(b)); + let rx = (ew / 2.0).abs().min((r - l) / 2.0); + let ry = (eh / 2.0).abs().min((b - t) / 2.0); + let mut ops = vec![Op::M((l + rx, t)), Op::L((r - rx, t))]; + ops.extend(arc(r - rx, t + ry, rx, ry, -FRAC_PI_2, FRAC_PI_2)); + ops.push(Op::L((r, b - ry))); + ops.extend(arc(r - rx, b - ry, rx, ry, 0.0, FRAC_PI_2)); + ops.push(Op::L((l + rx, b))); + ops.extend(arc(l + rx, b - ry, rx, ry, FRAC_PI_2, FRAC_PI_2)); + ops.push(Op::L((l, t + ry))); + ops.extend(arc(l + rx, t + ry, rx, ry, PI, FRAC_PI_2)); + ops.push(Op::Z); + ops +} + +pub(crate) fn ellipse(l: f64, t: f64, r: f64, b: f64) -> Vec { + let (cx, cy, rx, ry) = center(l, t, r, b); + let mut ops = vec![Op::M((cx + rx, cy))]; + ops.extend(arc(cx, cy, rx, ry, 0.0, TAU)); + ops.push(Op::Z); + ops +} + +/// Which closed or open shape an ARC/CHORD/PIE record draws. +#[derive(Clone, Copy, PartialEq, Eq)] +pub(crate) enum ArcKind { + Arc, + Chord, + Pie, +} + +/// ARC, CHORD and PIE: the arc runs counter-clockwise on screen from the start point to the end point. +pub(crate) fn arc_shape(l: f64, t: f64, r: f64, b: f64, s: P, e: P, kind: ArcKind) -> Vec { + let (cx, cy, rx, ry) = center(l, t, r, b); + let ang = |p: P| ((p.1 - cy) / ry.max(f64::MIN_POSITIVE)).atan2((p.0 - cx) / rx.max(f64::MIN_POSITIVE)); + let (a0, a1) = (ang(s), ang(e)); + let mut d = (a0 - a1).rem_euclid(TAU); + if d < 1e-12 { + d = TAU; + } + let sweep = -d; + let start = (cx + rx * a0.cos(), cy + ry * a0.sin()); + let mut ops = if kind == ArcKind::Pie { vec![Op::M((cx, cy)), Op::L(start)] } else { vec![Op::M(start)] }; + ops.extend(arc(cx, cy, rx, ry, a0, sweep)); + match kind { + ArcKind::Arc => {} + ArcKind::Chord => ops.push(Op::Z), + ArcKind::Pie => ops.extend([Op::L((cx, cy)), Op::Z]), + } + ops +} + +fn center(l: f64, t: f64, r: f64, b: f64) -> (f64, f64, f64, f64) { + ((l + r) / 2.0, (t + b) / 2.0, (r - l).abs() / 2.0, (b - t).abs() / 2.0) +} + +/// Cubic pieces along an ellipse from parameter `a0` over signed `sweep` radians. The current point +/// must already be at `(cx + rx cos a0, cy + ry sin a0)`. +fn arc(cx: f64, cy: f64, rx: f64, ry: f64, a0: f64, sweep: f64) -> Vec { + let n = ((sweep.abs() / FRAC_PI_2).ceil() as usize).clamp(1, 8); + let step = sweep / n as f64; + let k = 4.0 / 3.0 * (step / 4.0).tan(); + let pt = |t: f64| (cx + rx * t.cos(), cy + ry * t.sin()); + let mut ops = Vec::with_capacity(n); + for i in 0..n { + let t0 = a0 + step * i as f64; + let t1 = t0 + step; + let (p0, p3) = (pt(t0), pt(t1)); + let c1 = (p0.0 - k * rx * t0.sin(), p0.1 + k * ry * t0.cos()); + let c2 = (p3.0 + k * rx * t1.sin(), p3.1 - k * ry * t1.cos()); + ops.push(Op::C(c1, c2, p3)); + } + ops +} + +/// Consecutive subpaths: `counts[i]` points each, taken in order from `pts`. Counts that run past the +/// point list are cut short rather than failing the whole record. +pub(crate) fn subpaths(pts: &[P], counts: &[usize], close: bool) -> Vec { + // The exact size first: empty groups add no ops, so a long count list of zeros (file data) + // mustn't reserve room the canvas budget never sees. + let (mut n, mut at) = (0usize, 0usize); + for &c in counts { + let end = at.saturating_add(c).min(pts.len()); + if end > at { + n = n.saturating_add(end - at + usize::from(close)); + } + at = end; + } + let mut ops = Vec::with_capacity(n); + let mut at = 0usize; + for &c in counts { + let end = at.saturating_add(c).min(pts.len()); + let Some((first, rest)) = pts.get(at..end).and_then(<[_]>::split_first) else { + at = end; + continue; + }; + ops.push(Op::M(*first)); + ops.extend(rest.iter().map(|p| Op::L(*p))); + if close { + ops.push(Op::Z); + } + at = end; + } + ops +} diff --git a/crates/metafile/src/lib.rs b/crates/metafile/src/lib.rs new file mode 100644 index 000000000..2aa739fc6 --- /dev/null +++ b/crates/metafile/src/lib.rs @@ -0,0 +1,452 @@ +//! Windows metafiles: reads Windows Metafiles (WMF), Enhanced Metafiles (EMF) and EMF+ into a +//! neutral vector model, and (feature `write`) writes EMF and WMF. +//! +//! [`parse`] plays a file's records on a model of the device context and returns a [`Picture`]: +//! filled and stroked paths with their line styles, pattern fills, RGBA bitmaps and text strings, +//! each with the clip it was drawn under, in a picture frame with y down. Nothing in the model +//! belongs to an application: mapping it onto a document (or drawing it) is the caller's. +//! [`Picture::place`] fits a picture into a rectangle for callers that draw it. +//! +//! Text stays font-agnostic: a [`Text`] item names the font the file asks for and carries the +//! string, its advances and alignment; the caller 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`], +//! never a panic. Records follow MS-WMF, MS-EMF and MS-EMFPLUS; the README lists the records read +//! and the limits. Records of a type the reader does not know are counted in +//! [`Picture::skipped_records`]. +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +#![forbid(unsafe_code)] + +mod bytes; +mod canvas; +mod clip; +mod cliprect; +mod dib; +mod emf; +mod emfplus; +mod geom; +mod place; +mod size; +mod text; +mod wmf; + +#[cfg(feature = "write")] +pub mod write; + +#[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, PlacedClip, PlacedItem, bez_path, crop_window}; +pub use size::size; +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; +/// Object table slots (WMF handles, EMF object indices). +pub(crate) const MAX_OBJECTS: usize = 4096; +/// Largest bitmap in pixels (about 40 megapixels, 160 MB of RGBA). +pub(crate) const MAX_PIXELS: usize = 40_000_000; +/// DC save-stack depth. +pub(crate) const MAX_STACK: usize = 1024; +/// Points in one open path bracket. +pub(crate) const MAX_PATH_OPS: usize = 2_000_000; +/// Bytes of path ops and pixels kept from one file (about 256 MB). +pub(crate) const MAX_BYTES: usize = 256 * 1024 * 1024; + +/// A parsed metafile. Items use coordinates in `0..width` and `0..height`, y down. +#[derive(Debug, Clone, PartialEq, Default)] +pub struct Picture { + /// Size of the drawing in its own units: points for placeable WMF and EMF (from the frame), device + /// or logical units otherwise. + pub width: f32, + pub height: f32, + pub items: Vec, + /// Records of a type the reader does not know (neither drawn nor known to change nothing that + /// is kept). A caller can tell its user that part of the file was not read. + pub skipped_records: u32, +} + +/// How the ends of an open stroke and of its dashes are drawn (`PS_ENDCAP_*`). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum Cap { + #[default] + Round, + Square, + Flat, +} + +/// How the corners of a stroke are drawn (`PS_JOIN_*`). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum Join { + #[default] + Round, + Bevel, + Miter, +} + +/// Caps, joins and dashes of a stroke. The default is GDI's: round caps and joins, a miter limit +/// of 10, a solid line. +#[derive(Debug, Clone, PartialEq)] +pub struct LineStyle { + pub cap: Cap, + pub join: Join, + /// Longest miter as a multiple of the stroke width (for [`Join::Miter`]). + pub miter_limit: f32, + /// Dash, gap, dash, gap… lengths in the units of the stroke width, starting at the path's + /// start. Empty is a solid line. + pub dash: Vec, +} + +impl Default for LineStyle { + fn default() -> Self { + LineStyle { cap: Cap::Round, join: Join::Round, miter_limit: 10.0, dash: Vec::new() } + } +} + +impl LineStyle { + /// The same style with its dash lengths times `k`. None when a length stops being finite. + fn scaled(&self, k: f64) -> Option { + let dash = self.dash.iter().map(|d| f(f64::from(*d) * k)).collect::>>()?; + Some(LineStyle { dash, ..self.clone() }) + } +} + +/// A clip path (`SELECTCLIPPATH`), in picture units. Items clipped by the same path share it, so +/// `Arc::ptr_eq` tells which items a clip covers. +#[derive(Debug, Clone, PartialEq)] +pub struct ClipPath { + pub segs: Vec, + pub even_odd: bool, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum Item { + /// A filled and/or stroked path, in picture coordinates. + Path { + segs: Vec, + fill: Option<[u8; 4]>, + /// Colour and width in picture units. + stroke: Option<([u8; 4], f32)>, + /// Caps, joins and dashes of the stroke (dash lengths in picture units). + line: LineStyle, + even_odd: bool, + /// Clip region in picture units, each rectangle `[x0, y0, x1, y1]`. None is the default clip. + region: Option>, + /// Clip paths: the item shows inside all of them (and inside `region`). Outermost first. + clips: Vec>, + }, + /// A bitmap stretched into `rect` = `[x, y, w, h]` (picture units), RGBA8 rows top-down, straight + /// alpha. + Bitmap { + rect: [f32; 4], + /// The unit square (the whole bitmap, y down) 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). For an + /// upright bitmap this is `rect` again (`[w, 0, 0, h, x, y]`); under a world transform that + /// turns or skews the bitmap it carries the turn, and `rect` is the box around the turned bitmap. + transform: [f64; 6], + width: u32, + height: u32, + rgba: Vec, + /// Clip region in picture units. None is the default clip. + region: Option>, + /// Clip paths, as on [`Item::Path`]. + clips: 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, + /// Clip region in picture units. None is the default clip. + region: Option>, + /// Clip paths, as on [`Item::Path`]. + clips: Vec>, + }, + /// 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)] +pub enum Seg { + Move(f32, f32), + Line(f32, f32), + Cubic(f32, f32, f32, f32, f32, f32), + Close, +} + +#[derive(Debug, thiserror::Error, Clone, PartialEq, Eq)] +pub enum Error { + #[error("not a WMF or EMF file")] + NotMetafile, + #[error("metafile header is truncated or invalid")] + BadHeader, + #[error("metafile has no readable records")] + NoRecords, +} + +/// The picture frame in device units (`rect` = `[x, y, w, h]`) and, when the file states one, its +/// size in picture units. +pub(crate) struct Frame { + pub rect: [f64; 4], + pub size: Option<(f64, f64)>, +} + +/// Parses a WMF (placeable or plain) or EMF file. +pub fn parse(bytes: &[u8]) -> Result { + dib::set_decode_budget(dib::MAX_DECODE_PIXELS); + parse_nested(bytes) +} + +/// [`parse`] for a metafile inside the one being parsed: it draws on the same budgets. +pub(crate) fn parse_nested(bytes: &[u8]) -> Result { + let b = Bytes(bytes); + 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; Office draws + // the WMF's own records (its dx rounding shows), so the copy is not used. + wmf::parse(b)? + } else { + return Err(Error::NotMetafile); + }; + let skipped = cv.skipped; + let mut pic = build(cv.items, frame); + pic.skipped_records = skipped; + Ok(pic) +} + +/// True when the bytes start like a WMF (placeable or plain header) or an EMF. +pub fn is_metafile(bytes: &[u8]) -> bool { + sniff(bytes).is_some() +} + +/// A metafile format. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Kind { + /// Enhanced Metafile (32-bit coordinates, Bézier curves, paths and clipping), with or without + /// EMF+ records. + Emf, + /// Windows Metafile, with or without a placeable header (16-bit coordinates, polygons). + Wmf, +} + +/// The metafile format `bytes` start like, if any. +pub fn sniff(bytes: &[u8]) -> Option { + let b = Bytes(bytes); + if emf::is_emf(b) { + Some(Kind::Emf) + } else if wmf::is_wmf(b) { + Some(Kind::Wmf) + } else { + None + } +} + +/// Maps device-space output into the picture frame. Without a usable frame the frame is the bounding +/// box of the drawing, so the origin is still the top-left of what was drawn. +fn build(raw: Vec, frame: Option) -> Picture { + let frame = frame.filter(|f| f.rect.iter().all(|v| v.is_finite()) && f.rect[2] > 0.0 && f.rect[3] > 0.0); + let (rect, size) = match frame { + Some(f) => (f.rect, f.size.unwrap_or((f.rect[2], f.rect[3]))), + None => match bbox(&raw) { + Some([x0, y0, x1, y1]) => ([x0, y0, x1 - x0, y1 - y0], (x1 - x0, y1 - y0)), + None => return Picture::default(), + }, + }; + let sx = if rect[2] > 0.0 { size.0 / rect[2] } else { 1.0 }; + let sy = if rect[3] > 0.0 { size.1 / rect[3] } else { 1.0 }; + let tf = move |p: P| ((p.0 - rect[0]) * sx, (p.1 - rect[1]) * sy); + 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(); + // So do clip paths. None is a path with a point that is not finite: its items are left out. + let mut clip_paths: ClipCache = HashMap::new(); + let mut clip = |region: canvas::RawClip| -> Option { + let mut paths = Vec::with_capacity(region.paths.len()); + for raw in region.paths { + let key = Arc::as_ptr(&raw); + let mapped = match clip_paths.get(&key) { + Some((_, c)) => c.clone(), + None => { + let c = raw + .ops + .iter() + .map(|op| seg(op, tf)) + .collect::>>() + .map(|segs| Arc::new(ClipPath { segs, even_odd: raw.even_odd })); + // The raw path is kept with its key, so the address is never reused by another. + clip_paths.insert(key, (raw, c.clone())); + c + } + }; + paths.push(mapped?); + } + Some((map_region(region.rects, &tf)?, paths)) + }; + let items = raw.into_iter().filter_map(|r| match r { + Raw::Path { ops, fill, stroke, line, even_odd, region } => { + let segs = ops.iter().map(|op| seg(op, tf)).collect::>>()?; + if segs.len() < 2 { + return None; + } + let stroke = stroke.and_then(|(c, w)| Some((c, f((w * stroke_scale).min(diag))?))); + let (region, clips) = clip(region)?; + Some(Item::Path { segs, fill, stroke, line: line.scaled(stroke_scale)?, even_odd, region, clips }) + } + Raw::Bitmap { rect: r, xf, img, region } => { + let (x, y) = tf((r[0], r[1])); + let rect = [f(x)?, f(y)?, f(r[2] * sx)?, f(r[3] * sy)?]; + let (e, g) = tf((xf[4], xf[5])); + let transform = [xf[0] * sx, xf[1] * sy, xf[2] * sx, xf[3] * sy, e, g]; + if !transform.iter().all(|v| v.is_finite()) { + return None; + } + let (region, clips) = clip(region)?; + Some(Item::Bitmap { rect, transform, width: img.w as u32, height: img.h as u32, rgba: img.px, region, clips }) + } + Raw::Pattern { ops, even_odd, tile, region } => { + 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 + } + }; + let (region, clips) = clip(region)?; + Some(Item::Pattern { segs, even_odd, tile, region, clips }) + } + Raw::Text { mut text, region } => { + // Device to picture: scale by (sx, sy) after moving the frame's corner to the origin. + let [a, b, c, d, e, g] = text.transform; + let (e, g) = tf((e, g)); + text.transform = [a * sx, b * sy, c * sx, d * sy, e, g]; + if !text.transform.iter().all(|v| v.is_finite()) { + return None; + } + if let Some([x0, y0, x1, y1]) = text.clip { + let (p, q) = (tf((f64::from(x0), f64::from(y0))), tf((f64::from(x1), f64::from(y1)))); + text.clip = Some([f(p.0.min(q.0))?, f(p.1.min(q.1))?, f(p.0.max(q.0))?, f(p.1.max(q.1))?]); + } + (text.region, text.clips) = clip(region)?; + Some(Item::Text(text)) + } + }); + let items = items.collect(); + Picture { width: f(size.0).unwrap_or(0.0), height: f(size.1).unwrap_or(0.0), items, skipped_records: 0 } +} + +/// An item's clip in picture units: the region rectangles and the clip paths. +type ItemClip = (Option>, Vec>); + +/// Device clip paths already mapped into the picture, by address. +type ClipCache = HashMap<*const canvas::RawClipPath, (Arc, Option>)>; + +fn map_region(region: Option>, tf: &impl Fn(P) -> P) -> Option>> { + let Some(rects) = region else { return Some(None) }; + let mut out = Vec::with_capacity(rects.len()); + for [x0, y0, x1, y1] in rects { + let (p, q) = (tf((x0, y0)), tf((x1, y1))); + out.push([f(p.0.min(q.0))?, f(p.1.min(q.1))?, f(p.0.max(q.0))?, f(p.1.max(q.1))?]); + } + Some(Some(out)) +} + +fn f(v: f64) -> Option { + let v = v as f32; + v.is_finite().then_some(v) +} + +fn seg(op: &Op, tf: impl Fn(P) -> P) -> Option { + let pt = |p: P| { + let (x, y) = tf(p); + Some((f(x)?, f(y)?)) + }; + Some(match *op { + Op::M(p) => { + let (x, y) = pt(p)?; + Seg::Move(x, y) + } + Op::L(p) => { + let (x, y) = pt(p)?; + Seg::Line(x, y) + } + Op::C(a, b, c) => { + let (x1, y1) = pt(a)?; + let (x2, y2) = pt(b)?; + let (x3, y3) = pt(c)?; + Seg::Cubic(x1, y1, x2, y2, x3, y3) + } + Op::Z => Seg::Close, + }) +} + +/// Bounding box `[x0, y0, x1, y1]` over the finite points of the output. +fn bbox(raw: &[Raw]) -> Option<[f64; 4]> { + let mut b: Option<[f64; 4]> = None; + let mut grow = |p: P| { + if p.0.is_finite() && p.1.is_finite() { + b = Some(match b { + None => [p.0, p.1, p.0, p.1], + Some([x0, y0, x1, y1]) => [x0.min(p.0), y0.min(p.1), x1.max(p.0), y1.max(p.1)], + }); + } + }; + for r in raw { + match r { + Raw::Path { ops, .. } | Raw::Pattern { ops, .. } => { + for op in ops { + match *op { + Op::M(p) | Op::L(p) => grow(p), + Op::C(a, c, d) => { + grow(a); + grow(c); + grow(d); + } + Op::Z => {} + } + } + } + Raw::Bitmap { rect, .. } => { + grow((rect[0], rect[1])); + grow((rect[0] + rect[2], rect[1] + rect[3])); + } + Raw::Text { text: t, .. } => grow((t.transform[4], t.transform[5])), + } + } + b +} diff --git a/crates/metafile/src/place.rs b/crates/metafile/src/place.rs new file mode 100644 index 000000000..2e78284a5 --- /dev/null +++ b/crates/metafile/src/place.rs @@ -0,0 +1,264 @@ +//! Placing a picture on a target (a page or a device): the fitted transform, paths as kurbo paths in +//! target units and bitmaps as target rectangles. The rasteriser and the PDF writer only paint the result. + +use std::sync::Arc; + +use kurbo::{Affine, BezPath, Rect, Shape}; + +use std::collections::HashMap; + +use crate::{ClipPath, Item, LineStyle, Picture, Seg}; + +/// Largest bitmap side the renderers draw: within the PDF image limit and the rasteriser's 16-bit sides. +pub const MAX_BITMAP_SIDE: u32 = 30_000; +/// Placed coordinates past this (target units) are far off any page; such items are left out. +const MAX_COORD: f64 = 1e6; +/// Smallest placed bitmap side (target units), so the stretch onto it is never singular. +const MIN_BITMAP_SIDE: f64 = 1e-6; +/// Maximum aggregate path elements retained in placed output. +const MAX_PLACED_ELEMENTS: usize = 500_000; +/// The least of a source axis a crop keeps (0.5%). Files crop pictures to slivers (a header bar +/// that is 2.7% of a page-sized metafile). A frame of at most 4000 units a side then spans at most +/// 800,000 units for the whole source: what it covers stays inside `MAX_COORD` and is never left +/// out as far off the page. +const MIN_VISIBLE: f64 = 0.005; + +/// The part of a picture's source that `crop` (left, top, right, bottom fractions of the source) +/// keeps, as fractions of the source: [left, top, width, height], bitmap or metafile alike. Each +/// side is taken as 0–1 (junk as 0). When the two sides of an axis leave less than 0.5% of it, +/// the left (top) side stands and 0.5% shows. +pub fn crop_window(crop: [f32; 4]) -> [f64; 4] { + let [l, t, r, b] = crop.map(|v| if v.is_finite() { f64::from(v.clamp(0.0, 1.0)) } else { 0.0 }); + let axis = |lo: f64, hi: f64| { + let lo = lo.min(1.0 - MIN_VISIBLE); + (lo, (1.0 - lo - hi).max(MIN_VISIBLE)) + }; + let ((x, w), (y, h)) = (axis(l, r), axis(t, b)); + [x, y, w, h] +} + +/// One item of a placed picture, in target units. `region` is the device-context clip region and +/// `clips` the clip paths: an item shows inside the region and inside every clip path. +pub enum PlacedItem { + /// A filled and/or stroked path. The stroke width is in target units; 0 is a cosmetic hairline that + /// the renderer draws at its minimum width. `line` has its dash lengths in target units. + Path { + path: BezPath, + fill: Option<[u8; 4]>, + stroke: Option<([u8; 4], f64)>, + line: LineStyle, + even_odd: bool, + region: Option>, + clips: Vec>, + }, + /// `Picture::items[index]`, a bitmap, stretched into `rect`. `transform` maps the unit square + /// (the whole bitmap) to target units: the same place when the bitmap is upright, and the + /// turned or skewed place when it is not. + Bitmap { index: usize, rect: Rect, transform: Affine, region: Option>, clips: Vec> }, + /// `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, region: Option>, clips: Vec> }, + /// `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, region: Option>, clips: Vec> }, +} + +/// A placed clip region: `Some(None)` is the default clip, `None` a region that does not meet the page. +type PlacedRegion = Option>>; + +/// A clip path in target units. Items under the same clip share it. +pub struct PlacedClip { + pub path: BezPath, + pub even_odd: bool, +} + +impl Picture { + /// Whether the picture has a finite, positive size, so it can be fitted into a frame. + pub fn has_size(&self) -> bool { + self.width.is_finite() && self.height.is_finite() && self.width > 0.0 && self.height > 0.0 + } + + /// The transform that fits the picture into `r` with the crop fractions `crop` (left, top, right, + /// bottom) removed first: the visible part fills `r`. None for a degenerate size. + pub fn fit(&self, r: Rect, crop: [f32; 4]) -> Option { + let [cl, ct, vw, vh] = crop_window(crop); + let (w, h) = (f64::from(self.width), f64::from(self.height)); + let sx = r.width() / (w * vw); + let sy = r.height() / (h * vh); + (sx.is_finite() && sy.is_finite() && sx > 0.0 && sy > 0.0) + .then(|| Affine::translate((r.x0 - cl * w * sx, r.y0 - ct * h * sy)) * Affine::scale_non_uniform(sx, sy)) + } + + /// The items placed into `r` with `crop` applied. Items that are not finite or reach past ±1e6 + /// target units are left out (they are far off any page), as are bitmaps with no area. Stroke + /// widths are scaled to target units (along the mean axis scale, so a non-uniform fit keeps + /// strokes even). + pub fn place(&self, r: Rect, crop: [f32; 4]) -> Vec { + let Some(tf) = self.fit(r, crop) else { return Vec::new() }; + let c = tf.as_coeffs(); + let k = (c[0].abs() + c[3].abs()) / 2.0; + let mut out = Vec::with_capacity(self.items.len()); + let mut total_elements = 0usize; + let mut cached_region: Option<(*const [f32; 4], usize, PlacedRegion)> = None; + let mut get_region = |reg: &Option>| -> PlacedRegion { + let (ptr, len) = match reg { + None => (std::ptr::null(), 0), + Some(v) => (v.as_ptr(), v.len()), + }; + if let Some((c_ptr, c_len, ref res)) = cached_region + && c_ptr == ptr + && c_len == len + { + return res.clone(); + } + let res = placed_region(&tf, reg); + cached_region = Some((ptr, len, res.clone())); + res + }; + + // Clip paths by address: the picture holds them for the whole call. None is a clip that is + // not finite or reaches past the coordinate limit; what it clips is left out. + let mut placed_clips: HashMap<*const ClipPath, Option>> = HashMap::new(); + let mut clip_elements = 0usize; + let mut get_clips = |clips: &[Arc]| -> Option>> { + clips + .iter() + .map(|c| { + placed_clips + .entry(Arc::as_ptr(c)) + .or_insert_with(|| { + let path = tf * bez_path(&c.segs); + clip_elements = clip_elements.saturating_add(path.elements().len()); + (clip_elements <= MAX_PLACED_ELEMENTS && in_range(path.bounding_box())) + .then(|| Arc::new(PlacedClip { path, even_odd: c.even_odd })) + }) + .clone() + }) + .collect() + }; + + for (index, item) in self.items.iter().enumerate() { + if total_elements >= MAX_PLACED_ELEMENTS { + break; + } + match item { + Item::Path { segs, fill, stroke, line, even_odd, region, clips } => { + let path = tf * bez_path(segs); + let Some(region) = get_region(region) else { continue }; + let Some(clips) = get_clips(clips) else { continue }; + let line = LineStyle { dash: line.dash.iter().map(|d| (f64::from(*d) * k) as f32).collect(), ..line.clone() }; + if in_range(path.bounding_box()) && line.dash.iter().all(|d| d.is_finite()) { + total_elements = total_elements.saturating_add(path.elements().len()); + let stroke = stroke.map(|(c, w)| (c, f64::from(w) * k)); + out.push(PlacedItem::Path { path, fill: *fill, stroke, line, even_odd: *even_odd, region, clips }); + } + } + Item::Pattern { segs, even_odd, tile: t, region, clips } => { + let path = tf * bez_path(segs); + let Some(region) = get_region(region) else { continue }; + let Some(clips) = get_clips(clips) else { continue }; + let tile = tf + * Affine::translate((f64::from(t.origin[0]), f64::from(t.origin[1]))) + * Affine::scale_non_uniform(f64::from(t.cell[0]), f64::from(t.cell[1])); + let det = tile.determinant(); + if in_range(path.bounding_box()) && det.is_finite() && det.abs() > 1e-12 { + total_elements = total_elements.saturating_add(path.elements().len()); + out.push(PlacedItem::Pattern { index, path, even_odd: *even_odd, tile, region, clips }); + } + } + Item::Text(t) => { + let transform = tf * Affine::new(t.transform); + let Some(region) = get_region(&t.region) else { continue }; + let Some(clips) = get_clips(&t.clips) else { continue }; + let clip = + t.clip.map(|[x0, y0, x1, y1]| tf.transform_rect_bbox(Rect::new(f64::from(x0), f64::from(y0), f64::from(x1), f64::from(y1)))); + let c = transform.as_coeffs(); + let origin = Rect::new(c[4], c[5], c[4], c[5]); + if in_range(origin) && c.iter().all(|v| v.is_finite() && v.abs() <= MAX_COORD) && clip.is_none_or(in_range) { + out.push(PlacedItem::Text { index, transform, clip, region, clips }); + } + } + Item::Bitmap { rect: [x, y, w, h], transform, region, clips, .. } => { + let (x0, y0) = (f64::from(*x), f64::from(*y)); + let rect = tf.transform_rect_bbox(Rect::new(x0, y0, x0 + f64::from(*w), y0 + f64::from(*h))); + let Some(region) = get_region(region) else { continue }; + let Some(clips) = get_clips(clips) else { continue }; + let transform = tf * Affine::new(*transform); + let finite = transform.as_coeffs().iter().all(|v| v.is_finite() && v.abs() <= MAX_COORD); + if in_range(rect) && finite && rect.width() > MIN_BITMAP_SIDE && rect.height() > MIN_BITMAP_SIDE { + out.push(PlacedItem::Bitmap { index, rect, transform, region, clips }); + } + } + } + } + out + } +} + +impl Item { + /// Takes a bitmap's pixels out of the item (its geometry stays) and returns them as (width, height, + /// RGBA) when a renderer can draw them: both sides in 1..=[`MAX_BITMAP_SIDE`] and exactly that many + /// pixels. None for paths and for bitmaps that cannot be drawn. + pub fn take_pixels(&mut self) -> Option<(u32, u32, Vec)> { + let Item::Bitmap { width, height, rgba, .. } = self else { return None }; + let (w, h, px) = (*width, *height, std::mem::take(rgba)); + let sides = (1..=MAX_BITMAP_SIDE).contains(&w) && (1..=MAX_BITMAP_SIDE).contains(&h); + (sides && px.len() == (w as usize).saturating_mul(h as usize).saturating_mul(4)).then_some((w, h, px)) + } +} + +/// The device-context clip in target units. `Some(None)` is the default clip. `None` means the +/// region does not meet the page, so the item draws nothing. +fn placed_region(tf: &Affine, region: &Option>) -> PlacedRegion { + let Some(rects) = region else { return Some(None) }; + let mut path = BezPath::new(); + for [x0, y0, x1, y1] in rects { + let r = tf.transform_rect_bbox(Rect::new(f64::from(*x0), f64::from(*y0), f64::from(*x1), f64::from(*y1))); + if in_range(r) && r.width() > 1e-9 && r.height() > 1e-9 { + path.extend(r.to_path(0.1)); + } + } + (!path.is_empty()).then_some(Some(Arc::new(path))) +} + +/// Segments as a kurbo path. A segment after a close opens a new subpath at the last start point. +pub fn bez_path(segs: &[Seg]) -> BezPath { + let mut p = BezPath::new(); + let mut start = (0.0, 0.0); + let mut open = false; + for s in segs { + match *s { + Seg::Move(x, y) => { + start = (f64::from(x), f64::from(y)); + p.move_to(start); + open = true; + } + Seg::Line(x, y) => { + if !open { + p.move_to(start); + } + p.line_to((f64::from(x), f64::from(y))); + open = true; + } + Seg::Cubic(a, b, c, d, e, f) => { + if !open { + p.move_to(start); + } + p.curve_to((f64::from(a), f64::from(b)), (f64::from(c), f64::from(d)), (f64::from(e), f64::from(f))); + open = true; + } + Seg::Close => { + if open { + p.close_path(); + } + open = false; + } + } + } + p +} + +/// Whether a placed box is finite and within ±[`MAX_COORD`]. +fn in_range(b: Rect) -> bool { + [b.x0, b.y0, b.x1, b.y1].iter().all(|v| v.is_finite() && v.abs() < MAX_COORD) +} diff --git a/crates/metafile/src/size.rs b/crates/metafile/src/size.rs new file mode 100644 index 000000000..8766b9962 --- /dev/null +++ b/crates/metafile/src/size.rs @@ -0,0 +1,98 @@ +//! The size of a metafile picture without playing its records. + +use crate::bytes::Bytes; +use crate::{Error, emf, f, parse, wmf}; + +/// A placeable WMF header is 22 bytes, followed by the 9-word standard header. +const PLACEABLE_KEY: u32 = 0x9AC6_CDD7; +const PLACEABLE_LEN: usize = 22; +const DEFAULT_INCH: f64 = 1440.0; + +/// The size of a WMF or EMF picture in points (device or logical units when the file states no +/// size), the same as `parse(bytes)` would put in the picture's `width` and `height`, or the error +/// `parse` would give. +/// +/// An EMF and a placeable WMF carry their frame in the header, so the size is read from there +/// without decoding a bitmap or building a path. Any other file (a plain WMF, an EMF without a +/// usable frame) takes the full parse. +pub fn size(bytes: &[u8]) -> Result<(f32, f32), Error> { + if let Some(size) = header_size(Bytes(bytes)) { + return Ok(size); + } + let pic = parse(bytes)?; + Ok((pic.width, pic.height)) +} + +/// The size when the header decides it and the file has a first record (otherwise `parse` fails +/// with `NoRecords`, so the header alone is not enough). None sends the caller to the full parse. +pub(crate) fn header_size(b: Bytes) -> Option<(f32, f32)> { + if emf::is_emf(b) { + return emf_size(b); + } + if wmf::is_wmf(b) && b.u32(0) == Some(PLACEABLE_KEY) { + return placeable_wmf_size(b); + } + None +} + +/// `rclFrame` mapped to points. The frame must pass the checks `build` applies to it. +fn emf_size(b: Bytes) -> Option<(f32, f32)> { + let hsize = b.u32(4)? as usize; + b.slice(8, 16)?; + let frame = emf::frame_from_header(b)?; + if !frame.rect.iter().all(|v| v.is_finite()) || frame.rect[2] <= 0.0 || frame.rect[3] <= 0.0 { + return None; + } + // The records start after the header; the parse needs one that is not the end record. + let pos = hsize.max(88); + let (typ, len) = (b.u32(pos)?, b.u32(pos.checked_add(4)?)? as usize); + if len < 8 || !len.is_multiple_of(4) || b.slice(pos, len).is_none() || typ == 14 { + return None; + } + let (w, h) = frame.size?; + Some((f(w).unwrap_or(0.0), f(h).unwrap_or(0.0))) +} + +/// The placeable header's size in points, for a file whose records leave the target mapping alone +/// (see [`sets_the_mapping`]); the full parse decides the others. +fn placeable_wmf_size(b: Bytes) -> Option<(f32, f32)> { + let [l, t, r, bt] = [6, 8, 10, 12].map(|o| b.i16(o).map(f64::from)); + let (l, t, r, bt) = (l?, t?, r?, bt?); + let inch = b.u16(14).filter(|v| *v > 0).map_or(DEFAULT_INCH, f64::from); + let (w, h) = (r - l, bt - t); + if w <= 0.0 || h <= 0.0 { + return None; + } + let hsize = b.u16(PLACEABLE_LEN + 2)? as usize; + if hsize < 9 { + return None; + } + let pos = PLACEABLE_LEN.checked_add(hsize.checked_mul(2)?)?; + let (words, func) = (b.u32(pos)? as usize, b.u16(pos.checked_add(4)?)?); + let len = words.checked_mul(2).filter(|l| *l >= 6)?; + if func == 0 || b.slice(pos, len).is_none() { + return None; + } + if sets_the_mapping(b, pos) { + return None; + } + Some((f(w * 72.0 / inch)?, f(h * 72.0 / inch)?)) +} + +/// Whether a record from `pos` on is SetMapMode (0x0103) or SetViewportExt (0x020E). Those can change +/// the frame the full parse ends with (a zero-size viewport leaves none), so the header size is not +/// enough. The records are only walked, not played, with the same stops as `wmf::parse`. +fn sets_the_mapping(b: Bytes, mut pos: usize) -> bool { + while let (Some(words), Some(func)) = (b.u32(pos), pos.checked_add(4).and_then(|o| b.u16(o))) { + let Some(len) = (words as usize).checked_mul(2).filter(|l| *l >= 6) else { return false }; + if func == 0 || b.slice(pos, len).is_none() { + return false; + } + if matches!(func, 0x0103 | 0x020E) { + return true; + } + let Some(next) = pos.checked_add(len) else { return false }; + pos = next; + } + false +} diff --git a/crates/metafile/src/tests.rs b/crates/metafile/src/tests.rs new file mode 100644 index 000000000..dc7f9a26c --- /dev/null +++ b/crates/metafile/src/tests.rs @@ -0,0 +1,2405 @@ +//! Hand-built WMF and EMF buffers: each test writes the records it needs and checks the parsed model. + +use crate::canvas::Canvas; +use crate::dib::Rgba; +use crate::{Error, Item, MAX_BYTES, MAX_ITEMS, Seg, is_metafile, parse}; + +mod limits; +mod model; +mod played; + +fn h16(v: i32) -> Vec { + (v as i16).to_le_bytes().to_vec() +} + +fn h32(v: u32) -> Vec { + v.to_le_bytes().to_vec() +} + +fn e32(v: i32) -> Vec { + v.to_le_bytes().to_vec() +} + +fn cat(parts: Vec>) -> Vec { + parts.concat() +} + +/// One WMF record: size in words, function, parameters (padded to a word boundary). +fn wrec(func: u16, params: &[u8]) -> Vec { + let mut p = params.to_vec(); + if !p.len().is_multiple_of(2) { + p.push(0); + } + let words = ((6 + p.len()) / 2) as u32; + let mut v = words.to_le_bytes().to_vec(); + v.extend(func.to_le_bytes()); + v.extend(p); + v +} + +/// A WMF file: optional placeable header (bbox left, top, right, bottom in logical units, 1440 per inch), +/// the standard header, the records and an end-of-file record. +fn wmf(records: &[Vec], placeable: Option<[i32; 4]>) -> Vec { + let body: Vec = records.concat(); + let eof = wrec(0, &[]); + let mut f = Vec::new(); + if let Some(bb) = placeable { + f.extend(0x9AC6_CDD7u32.to_le_bytes()); + f.extend(0u16.to_le_bytes()); + for v in bb { + f.extend((v as i16).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() + eof.len()) / 2; + f.extend(1u16.to_le_bytes()); + f.extend(9u16.to_le_bytes()); + f.extend(0x300u16.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.extend(eof); + f +} + +/// One EMF record: type, size in bytes (payload padded to 4), payload. +fn erec(typ: u32, payload: &[u8]) -> Vec { + let mut p = payload.to_vec(); + while !p.len().is_multiple_of(4) { + p.push(0); + } + let mut v = typ.to_le_bytes().to_vec(); + v.extend(((8 + p.len()) as u32).to_le_bytes()); + v.extend(p); + v +} + +/// An EMF file with an 88-byte header (bounds and frame in 0.01 mm), the records and an EOF record. +fn emf(records: &[Vec], bounds: [i32; 4], frame: [i32; 4]) -> Vec { + let body: Vec = records.concat(); + let eof = erec(14, &[0; 12]); + let total = 88 + body.len() + eof.len(); + let mut h = vec![0u8; 88]; + h[0..4].copy_from_slice(&1u32.to_le_bytes()); + h[4..8].copy_from_slice(&88u32.to_le_bytes()); + h[8..24].copy_from_slice(&bounds.iter().flat_map(|v| v.to_le_bytes()).collect::>()); + h[24..40].copy_from_slice(&frame.iter().flat_map(|v| v.to_le_bytes()).collect::>()); + h[40..44].copy_from_slice(&0x464D_4520u32.to_le_bytes()); + h[44..48].copy_from_slice(&0x0001_0000u32.to_le_bytes()); + h[48..52].copy_from_slice(&(total as u32).to_le_bytes()); + h[52..56].copy_from_slice(&((records.len() + 1) as u32).to_le_bytes()); + h[56..58].copy_from_slice(&1u16.to_le_bytes()); + // 1000 x 500 device pixels for 250 x 125 mm: 0.04 device units per hundredth of a millimetre. + h[72..76].copy_from_slice(&1000i32.to_le_bytes()); + h[76..80].copy_from_slice(&500i32.to_le_bytes()); + h[80..84].copy_from_slice(&250i32.to_le_bytes()); + h[84..88].copy_from_slice(&125i32.to_le_bytes()); + let mut out = h; + out.extend(body); + out.extend(eof); + out +} + +/// A BITMAPINFOHEADER (40 bytes). +fn info_header(w: i32, h: i32, bpp: u16, size_image: u32) -> Vec { + cat(vec![h32(40), e32(w), e32(h), h16(1), h16(i32::from(bpp)), h32(0), h32(size_image), e32(0), e32(0), h32(0), h32(0)]) +} + +/// A 2x2 24-bit bottom-up DIB. File rows are stored bottom first: the bottom image row is blue, green; +/// the top row is red, white. Rows are padded to 4 bytes (8 bytes per row). +fn dib24_bottom_up() -> Vec { + let mut v = info_header(2, 2, 24, 16); + v.extend([255, 0, 0, 0, 255, 0, 0, 0]); + v.extend([0, 0, 255, 255, 255, 255, 0, 0]); + v +} + +/// Top-down RGBA of [`dib24_bottom_up`]: top-left red, top-right white, bottom-left blue, bottom-right green. +const DIB24_RGBA: [u8; 16] = [255, 0, 0, 255, 255, 255, 255, 255, 0, 0, 255, 255, 0, 255, 0, 255]; + +type PathParts<'a> = (&'a Vec, Option<[u8; 4]>, Option<([u8; 4], f32)>, bool); + +fn path_segs(item: &Item) -> PathParts<'_> { + match item { + Item::Path { segs, fill, stroke, even_odd, .. } => (segs, *fill, *stroke, *even_odd), + other => panic!("expected a path: {other:?}"), + } +} + +fn close(a: f32, b: f32) -> bool { + (a - b).abs() < 1e-3 +} + +#[test] +fn wmf_rectangle_with_solid_brush() { + let recs = [ + wrec(0x02FC, &cat(vec![h16(0), h32(0x0000_00FF), h16(0)])), + wrec(0x012D, &h16(0)), + wrec(0x041B, &cat(vec![h16(70), h16(110), h16(20), h16(10)])), + ]; + let p = parse(&wmf(&recs, None)).unwrap(); + assert_eq!((p.width, p.height), (100.0, 50.0)); + assert_eq!(p.items.len(), 1); + let (segs, fill, stroke, even_odd) = path_segs(&p.items[0]); + assert_eq!(segs, &vec![Seg::Move(0.0, 0.0), Seg::Line(100.0, 0.0), Seg::Line(100.0, 50.0), Seg::Line(0.0, 50.0), Seg::Close]); + assert_eq!(fill, Some([255, 0, 0, 255])); + assert_eq!(stroke, Some(([0, 0, 0, 255], 0.0))); + assert!(even_odd); +} + +#[test] +fn wmf_polyline_with_pen() { + let recs = [ + wrec(0x02FA, &cat(vec![h16(0), h16(4), h16(0), h32(0x0000_FF00)])), + wrec(0x012D, &h16(0)), + wrec(0x0325, &cat(vec![h16(3), h16(0), h16(0), h16(50), h16(0), h16(50), h16(40)])), + ]; + let p = parse(&wmf(&recs, None)).unwrap(); + assert_eq!((p.width, p.height), (50.0, 40.0)); + let (segs, fill, stroke, _) = path_segs(&p.items[0]); + assert_eq!(segs, &vec![Seg::Move(0.0, 0.0), Seg::Line(50.0, 0.0), Seg::Line(50.0, 40.0)]); + assert_eq!(fill, None); + assert_eq!(stroke, Some(([0, 255, 0, 255], 4.0))); +} + +#[test] +fn wmf_polygon_fill_rule_follows_polyfillmode() { + let square = cat(vec![h16(4), h16(0), h16(0), h16(10), h16(0), h16(10), h16(10), h16(0), h16(10)]); + let winding = [wrec(0x0106, &h16(2)), wrec(0x0324, &square)]; + let p = parse(&wmf(&winding, None)).unwrap(); + assert!(!path_segs(&p.items[0]).3, "WINDING fills use the nonzero rule"); + let alternate = [wrec(0x0324, &square)]; + let p = parse(&wmf(&alternate, None)).unwrap(); + let (segs, fill, _, even_odd) = path_segs(&p.items[0]); + assert!(even_odd, "default ALTERNATE is even-odd"); + assert_eq!(fill, Some([255, 255, 255, 255])); + assert_eq!(segs.last(), Some(&Seg::Close)); +} + +#[test] +fn wmf_stretchdib_flips_bottom_up_rows() { + let params = cat(vec![h32(0x00CC_0020), h16(0), h16(2), h16(2), h16(0), h16(0), h16(10), h16(20), h16(0), h16(0), dib24_bottom_up()]); + let p = parse(&wmf(&[wrec(0x0F43, ¶ms)], None)).unwrap(); + assert_eq!((p.width, p.height), (20.0, 10.0)); + let Item::Bitmap { rect, width, height, rgba, .. } = &p.items[0] else { panic!("expected a bitmap") }; + assert_eq!(rect, &[0.0, 0.0, 20.0, 10.0]); + assert_eq!((*width, *height), (2, 2)); + assert_eq!(rgba.as_slice(), DIB24_RGBA); +} + +#[test] +fn wmf_mapping_window_and_viewport() { + let recs = [ + wrec(0x0103, &h16(8)), + wrec(0x020B, &cat(vec![h16(100), h16(100)])), + wrec(0x020C, &cat(vec![h16(50), h16(50)])), + wrec(0x020E, &cat(vec![h16(100), h16(100)])), + wrec(0x0214, &cat(vec![h16(100), h16(100)])), + wrec(0x0213, &cat(vec![h16(150), h16(150)])), + ]; + let p = parse(&wmf(&recs, None)).unwrap(); + assert_eq!((p.width, p.height), (100.0, 100.0)); + let (segs, fill, stroke, _) = path_segs(&p.items[0]); + assert_eq!(segs, &vec![Seg::Move(0.0, 0.0), Seg::Line(100.0, 100.0)]); + assert_eq!(fill, None); + assert_eq!(stroke, Some(([0, 0, 0, 255], 0.0))); +} + +#[test] +fn wmf_placeable_header_sets_frame_in_points() { + let p = parse(&wmf(&[wrec(0x041B, &cat(vec![h16(1440), h16(2880), h16(0), h16(0)]))], Some([0, 0, 2880, 1440]))).unwrap(); + assert_eq!((p.width, p.height), (144.0, 72.0)); +} + +#[test] +fn emf_stretchdibits_flips_and_fits_frame() { + let mut payload = vec![0u8; 16]; + payload.extend(cat(vec![ + e32(0), + e32(0), + e32(0), + e32(0), + e32(2), + e32(2), + e32(80), + e32(40), + e32(120), + e32(16), + e32(0), + e32(0x00CC_0020), + e32(100), + e32(50), + ])); + payload.extend(info_header(2, 2, 24, 16)); + payload.extend(dib24_bottom_up().get(40..).unwrap_or_default()); + let rec = erec(81, &payload); + // A 25 x 12.5 mm frame is 70.87 x 35.43 points; the bitmap fills the whole frame. + let file = emf(&[rec], [0, 0, 100, 50], [0, 0, 2500, 1250]); + let p = parse(&file).unwrap(); + let (w, h) = (2500.0 * 72.0 / 2540.0, 1250.0 * 72.0 / 2540.0); + assert!(close(p.width, w as f32) && close(p.height, h as f32)); + let Item::Bitmap { rect, width, height, rgba, .. } = &p.items[0] else { panic!("expected a bitmap") }; + assert!(rect.iter().zip([0.0, 0.0, w, h]).all(|(a, b)| close(*a, b as f32))); + assert_eq!((*width, *height), (2, 2)); + assert_eq!(rgba.as_slice(), DIB24_RGBA); +} + +#[test] +fn emf_world_transform_scales_geometry() { + let xf = cat([2.0f32, 0.0, 0.0, 2.0, 0.0, 0.0].iter().map(|v| v.to_le_bytes().to_vec()).collect()); + let recs = [erec(35, &xf), erec(27, &cat(vec![e32(0), e32(0)])), erec(54, &cat(vec![e32(10), e32(5)]))]; + let p = parse(&emf(&recs, [0; 4], [0; 4])).unwrap(); + assert_eq!((p.width, p.height), (20.0, 10.0)); + let (segs, fill, stroke, _) = path_segs(&p.items[0]); + assert_eq!(segs, &vec![Seg::Move(0.0, 0.0), Seg::Line(20.0, 10.0)]); + assert_eq!(fill, None); + assert_eq!(stroke, Some(([0, 0, 0, 255], 0.0))); +} + +#[test] +fn emf_ellipse_is_four_cubics_back_to_start() { + let rect = cat(vec![e32(0), e32(0), e32(40), e32(20)]); + let p = parse(&emf(&[erec(42, &rect)], [0; 4], [0; 4])).unwrap(); + let (segs, fill, _, _) = path_segs(&p.items[0]); + assert_eq!(segs.len(), 6); + assert_eq!(segs[0], Seg::Move(40.0, 10.0)); + assert!(matches!(segs[4], Seg::Cubic(_, _, _, _, x, y) if close(x, 40.0) && close(y, 10.0))); + assert_eq!(segs[5], Seg::Close); + assert_eq!(fill, Some([255, 255, 255, 255])); +} + +#[test] +fn emf_path_bracket_strokeandfill_uses_stock_black_brush() { + let bounds = vec![0u8; 16]; + let recs = [ + erec(59, &[]), + erec(27, &cat(vec![e32(0), e32(0)])), + erec(54, &cat(vec![e32(10), e32(0)])), + erec(54, &cat(vec![e32(10), e32(10)])), + erec(61, &[]), + erec(60, &[]), + erec(37, &h32(0x8000_0004)), + erec(63, &bounds), + ]; + let p = parse(&emf(&recs, [0; 4], [0; 4])).unwrap(); + assert_eq!(p.items.len(), 1); + let (segs, fill, stroke, _) = path_segs(&p.items[0]); + assert_eq!(segs, &vec![Seg::Move(0.0, 0.0), Seg::Line(10.0, 0.0), Seg::Line(10.0, 10.0), Seg::Close]); + assert_eq!(fill, Some([0, 0, 0, 255])); + assert_eq!(stroke, Some(([0, 0, 0, 255], 0.0))); +} + +#[test] +fn item_cap_stops_the_parse_and_keeps_the_rest() { + let rect = wrec(0x041B, &cat(vec![h16(10), h16(10), h16(0), h16(0)])); + let recs: Vec> = (0..MAX_ITEMS + 10).map(|_| rect.clone()).collect(); + let p = parse(&wmf(&recs, None)).unwrap(); + assert_eq!(p.items.len(), MAX_ITEMS); +} + +#[test] +fn detection_and_garbage() { + let w = wmf(&[wrec(0x041B, &cat(vec![h16(1), h16(1), h16(0), h16(0)]))], None); + let e = emf(&[erec(42, &cat(vec![e32(0), e32(0), e32(4), e32(4)]))], [0; 4], [0; 4]); + assert!(is_metafile(&w)); + assert!(is_metafile(&e)); + assert!(!is_metafile(b"hello world, not a metafile")); + assert_eq!(parse(b"hello world, not a metafile").unwrap_err(), Error::NotMetafile); + assert!(parse(&[]).is_err()); +} + +/// Fixed-seed LCG (Knuth's constants): the fuzz inputs are reproducible. +struct Lcg(u64); + +impl Lcg { + fn rnd(&mut self) -> u32 { + self.0 = self.0.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1_442_695_040_888_963_407); + (self.0 >> 33) as u32 + } +} + +#[test] +fn truncated_random_and_mutated_inputs_never_panic() { + let rect = wrec(0x041B, &cat(vec![h16(10), h16(10), h16(0), h16(0)])); + let wfile = wmf( + &[ + wrec(0x02FC, &cat(vec![h16(0), h32(0xFF), h16(0)])), + wrec(0x012D, &h16(0)), + rect, + wrec(0x0325, &cat(vec![h16(2), h16(0), h16(0), h16(5), h16(5)])), + ], + Some([0, 0, 20, 10]), + ); + let mut dib_rec = cat(vec![h32(0x00CC_0020), h16(0), h16(2), h16(2), h16(0), h16(0), h16(10), h16(20), h16(0), h16(0)]); + dib_rec.extend(dib24_bottom_up()); + let mut payload = vec![0u8; 16]; + payload.extend(cat(vec![e32(0), e32(0), e32(0), e32(0), e32(2), e32(2), e32(80), e32(40), e32(120), e32(16), e32(0), e32(0), e32(20), e32(10)])); + payload.extend(info_header(2, 2, 24, 16)); + payload.extend(dib24_bottom_up().get(40..).unwrap_or_default()); + let efile = emf( + &[ + erec(59, &[]), + erec(27, &cat(vec![e32(0), e32(0)])), + erec(54, &cat(vec![e32(9), e32(9)])), + erec(61, &[]), + erec(60, &[]), + erec(63, &[0; 16]), + erec(81, &payload), + erec(86, &cat(vec![h32(4), h16(0), h16(0), h16(9), h16(9), h16(0), h16(9)])), + ], + [0, 0, 20, 10], + [0, 0, 2540, 1270], + ); + for file in [&wfile, &efile] { + for n in 0..=file.len() { + let prefix = &file[..n]; + let _ = parse(prefix); + let _ = is_metafile(prefix); + } + } + let mut rng = Lcg(0x2545_F491_4F6C_DD1D); + for _ in 0..3000 { + let len = (rng.rnd() % 400) as usize; + let buf: Vec = (0..len).map(|_| rng.rnd() as u8).collect(); + let _ = parse(&buf); + } + for i in 0..1500usize { + let mut buf = if i.is_multiple_of(2) { efile.clone() } else { wfile.clone() }; + for _ in 0..8 { + let at = rng.rnd() as usize % buf.len(); + buf[at] = rng.rnd() as u8; + } + let _ = parse(&buf); + } + for i in 0..500usize { + let mut buf = if i.is_multiple_of(2) { efile.clone() } else { wfile.clone() }; + let at = rng.rnd() as usize % buf.len(); + buf[at..].iter_mut().for_each(|b| *b = 0xFF); + let _ = parse(&buf); + } +} + +#[test] +fn wmf_stock_null_brush_by_16_bit_handle_has_no_fill() { + // SELECTOBJECT with 0x8005 is the NULL_BRUSH stock object (0x8000 | 5), so the rectangle is outline only. + let recs = [wrec(0x012D, &h16(0x8005u16 as i32)), wrec(0x041B, &cat(vec![h16(70), h16(110), h16(20), h16(10)]))]; + let p = parse(&wmf(&recs, None)).unwrap(); + let (_, fill, stroke, _) = path_segs(&p.items[0]); + assert_eq!(fill, None); + assert_eq!(stroke, Some(([0, 0, 0, 255], 0.0))); +} + +#[test] +fn emf_frame_comes_from_rclframe_not_device_bounds() { + // Bounds cover only the left half of the 25 x 12.5 mm frame (device units are 0.04 per 0.01 mm), so a + // rectangle from x = 0 to 50 px ends at half the picture width. + let rec = erec(43, &cat(vec![e32(0), e32(0), e32(50), e32(25)])); + let p = parse(&emf(&[rec], [0, 0, 50, 25], [0, 0, 2500, 1250])).unwrap(); + let w = 2500.0 * 72.0 / 2540.0; + assert!(close(p.width, w as f32) && close(p.height, (1250.0 * 72.0 / 2540.0) as f32)); + let (segs, ..) = path_segs(&p.items[0]); + assert!(matches!(segs[1], Seg::Line(x, _) if close(x, (w / 2.0) as f32)), "{segs:?}"); +} + +#[test] +fn emf_bounds_stand_in_when_the_frame_is_empty() { + let rec = erec(43, &cat(vec![e32(0), e32(0), e32(50), e32(25)])); + let p = parse(&emf(&[rec], [0, 0, 50, 25], [0; 4])).unwrap(); + assert_eq!((p.width, p.height), (50.0, 25.0)); +} + +#[test] +fn wmf_placeable_bounds_go_through_the_window_mapping() { + // Logical bounds 200 x 100 at 1440 per inch are 10 x 5 points; the window is 2:1 onto the viewport, so + // the frame is 100 x 50 device units and the rectangle fills it. + let recs = [ + wrec(0x0103, &h16(8)), + wrec(0x020C, &cat(vec![h16(100), h16(200)])), + wrec(0x020E, &cat(vec![h16(50), h16(100)])), + wrec(0x041B, &cat(vec![h16(100), h16(200), h16(0), h16(0)])), + ]; + let p = parse(&wmf(&recs, Some([0, 0, 200, 100]))).unwrap(); + assert_eq!((p.width, p.height), (10.0, 5.0)); + let (segs, ..) = path_segs(&p.items[0]); + assert!(segs.iter().any(|s| matches!(s, Seg::Line(x, y) if close(*x, 10.0) && close(*y, 5.0))), "{segs:?}"); +} + +#[test] +fn wmf_stretchdib_clipped_source_keeps_its_place() { + // Source x from -1 for 2 columns: only column 0 is in the bitmap, so it fills the right half of the + // destination and the picture keeps the original 20-unit width scale. + let params = cat(vec![h32(0x00CC_0020), h16(0), h16(2), h16(2), h16(0), h16(-1), h16(10), h16(20), h16(0), h16(0), dib24_bottom_up()]); + let p = parse(&wmf(&[wrec(0x0F43, ¶ms)], Some([0, 0, 20, 10]))).unwrap(); + let Item::Bitmap { rect, width, height, rgba, .. } = &p.items[0] else { panic!("expected a bitmap") }; + // Placeable units are points: 20 units at 1440 per inch are 1 point, so the right column is 0.5 pt wide. + assert_eq!(rect, &[0.5, 0.0, 0.5, 0.5]); + assert_eq!((*width, *height), (1, 2)); + assert_eq!(rgba.as_slice(), [255, 0, 0, 255, 0, 0, 255, 255]); +} + +#[test] +fn wmf_negative_destination_width_mirrors_the_bitmap() { + let params = cat(vec![h32(0x00CC_0020), h16(0), h16(2), h16(2), h16(0), h16(0), h16(10), h16(-20), h16(0), h16(0), dib24_bottom_up()]); + // Frame from the placeable bounds (-20, 0)-(0, 10): the mirrored bitmap fills it. + let p = parse(&wmf(&[wrec(0x0F43, ¶ms)], Some([-20, 0, 0, 10]))).unwrap(); + let Item::Bitmap { rect, rgba, .. } = &p.items[0] else { panic!("expected a bitmap") }; + assert_eq!(rect, &[0.0, 0.0, 1.0, 0.5]); + assert_eq!(&rgba[..8], [255, 255, 255, 255, 255, 0, 0, 255]); +} + +#[test] +fn emf_blackness_bitblt_fills_black_without_a_source() { + let mut payload = vec![0u8; 16]; + payload.extend(cat(vec![e32(10), e32(20), e32(30), e32(40), e32(0x0000_0042)])); + let p = parse(&emf(&[erec(76, &payload)], [0; 4], [0; 4])).unwrap(); + let (segs, fill, stroke, _) = path_segs(&p.items[0]); + assert_eq!(fill, Some([0, 0, 0, 255])); + assert_eq!(stroke, None); + assert_eq!(segs.len(), 5); +} + +#[test] +fn emf_savedc_past_the_stack_cap_stops_the_parse() { + let mut recs: Vec> = (0..=crate::MAX_STACK).map(|_| erec(33, &[])).collect(); + recs.push(erec(43, &cat(vec![e32(0), e32(0), e32(10), e32(10)]))); + let p = parse(&emf(&recs, [0; 4], [0; 4])).unwrap(); + assert!(p.items.is_empty(), "records after the cap must not be drawn"); +} + +#[test] +fn canvas_stops_at_the_byte_budget() { + // Two 128 MB bitmaps fit the budget; the third would exceed it and stops the canvas. + let mut cv = Canvas::new(); + let big = || Rgba { w: 1, h: 1, px: vec![0u8; MAX_BYTES / 2] }; + cv.bitmap([0.0, 0.0, 1.0, 1.0], big()); + cv.bitmap([0.0, 0.0, 1.0, 1.0], big()); + assert!(!cv.full()); + cv.bitmap([0.0, 0.0, 1.0, 1.0], big()); + assert!(cv.full()); + assert_eq!(cv.items.len(), 2); +} + +#[test] +fn oversized_dib_header_size_is_rejected_without_overflow() { + let mut info = info_header(2, 2, 24, 16); + info[0..4].copy_from_slice(&0xFFFF_FFF0u32.to_le_bytes()); + let params = cat(vec![h32(0x00CC_0020), h16(0), h16(2), h16(2), h16(0), h16(0), h16(10), h16(20), h16(0), h16(0), info]); + let p = parse(&wmf(&[wrec(0x0F43, ¶ms)], None)).unwrap(); + assert!(p.items.is_empty()); +} + +/// An EMF record of `len` bytes (header included) with u32 fields and byte blobs written at absolute +/// record offsets, so each test places a field where the spec puts it. +fn erec_at(typ: u32, len: usize, fields: &[(usize, u32)], blobs: &[(usize, &[u8])]) -> Vec { + let mut v = vec![0u8; len]; + v[0..4].copy_from_slice(&typ.to_le_bytes()); + v[4..8].copy_from_slice(&(len as u32).to_le_bytes()); + for (off, val) in fields { + v[*off..*off + 4].copy_from_slice(&val.to_le_bytes()); + } + for (off, b) in blobs { + v[*off..*off + b.len()].copy_from_slice(b); + } + v +} + +/// A BITBLT-style record (BITBLT, STRETCHBLT, ALPHABLEND layout) with its DIB at 112: the bmi and bits +/// offsets and lengths go at 84 and 92. `fields` and `blobs` add the other fields. +fn dib_rec(typ: u32, fields: &[(usize, u32)], blobs: &[(usize, &[u8])], info: &[u8], bits: &[u8]) -> Vec { + let at = 112; + let bits_at = at + info.len(); + let mut f = fields.to_vec(); + f.extend([(84, at as u32), (88, info.len() as u32), (92, bits_at as u32), (96, bits.len() as u32)]); + let mut b: Vec<(usize, &[u8])> = blobs.to_vec(); + b.extend([(at, info), (bits_at, bits)]); + erec_at(typ, bits_at + bits.len(), &f, &b) +} + +/// BITBLT (76) with destination `dst` = [x, y, w, h] and a 40-byte header DIB. +fn bitblt_rec(rop: u32, dst: [u32; 4], info: &[u8], bits: &[u8]) -> Vec { + let fields = [(24, dst[0]), (28, dst[1]), (32, dst[2]), (36, dst[3]), (40, rop)]; + dib_rec(76, &fields, &[], info, bits) +} + +#[test] +fn wmf_font_takes_an_object_slot_so_handles_line_up() { + // The font is handle 0, so the pen created after it is handle 1. + let recs = [ + wrec(0x02FB, &[0u8; 18]), + wrec(0x02FA, &cat(vec![h16(0), h16(4), h16(0), h32(0x0000_FF00)])), + wrec(0x012D, &h16(1)), + wrec(0x0325, &cat(vec![h16(2), h16(0), h16(0), h16(50), h16(0), h16(50)])), + ]; + let p = parse(&wmf(&recs, None)).unwrap(); + let (_, _, stroke, _) = path_segs(&p.items[0]); + assert_eq!(stroke, Some(([0, 255, 0, 255], 4.0))); +} + +#[test] +fn emf_polybezierto16_continues_the_open_path() { + // BEGINPATH, MOVETO a, LINETO b, POLYBEZIERTO16, CLOSEFIGURE, FILLPATH: one closed figure from a. + let mut bezier = vec![0u8; 16]; + bezier.extend(cat(vec![e32(3), h16(10), h16(10), h16(20), h16(10), h16(20), h16(0)])); + let recs = [ + erec(59, &[]), + erec(27, &cat(vec![e32(0), e32(0)])), + erec(54, &cat(vec![e32(10), e32(0)])), + erec(88, &bezier), + erec(61, &[]), + erec(62, &[0; 16]), + ]; + let p = parse(&emf(&recs, [0; 4], [0; 4])).unwrap(); + assert_eq!(p.items.len(), 1); + let (segs, fill, _, _) = path_segs(&p.items[0]); + assert_eq!(segs, &vec![Seg::Move(0.0, 0.0), Seg::Line(10.0, 0.0), Seg::Cubic(10.0, 10.0, 20.0, 10.0, 20.0, 0.0), Seg::Close]); + assert_eq!(fill, Some([255, 255, 255, 255])); +} + +#[test] +fn emf_polybezier_starts_its_own_figure_and_keeps_the_current_point() { + // POLYBEZIER (2) starts at its first point; the LINETO after it still starts at the MOVETO point. + let mut bezier = vec![0u8; 16]; + bezier.extend(cat(vec![e32(4), e32(0), e32(0), e32(5), e32(5), e32(10), e32(5), e32(10), e32(0)])); + let recs = [erec(27, &cat(vec![e32(3), e32(3)])), erec(2, &bezier), erec(54, &cat(vec![e32(50), e32(50)]))]; + let p = parse(&emf(&recs, [0; 4], [0; 4])).unwrap(); + assert_eq!(p.items.len(), 2); + let (segs, ..) = path_segs(&p.items[0]); + assert_eq!(segs[0], Seg::Move(0.0, 0.0)); + let (segs, ..) = path_segs(&p.items[1]); + assert_eq!(segs, &vec![Seg::Move(3.0, 3.0), Seg::Line(50.0, 50.0)]); +} + +#[test] +fn hairline_pen_stays_zero_under_an_anisotropic_window() { + // Window 2000 x 2000 onto a 1 x 1 viewport: a cosmetic pen must not become one device unit wide. + let recs = [ + wrec(0x0103, &h16(8)), + wrec(0x020C, &cat(vec![h16(2000), h16(2000)])), + wrec(0x020E, &cat(vec![h16(1), h16(1)])), + wrec(0x0214, &cat(vec![h16(0), h16(0)])), + wrec(0x0213, &cat(vec![h16(2000), h16(2000)])), + ]; + let p = parse(&wmf(&recs, None)).unwrap(); + assert_eq!((p.width, p.height), (1.0, 1.0)); + let (segs, _, stroke, _) = path_segs(&p.items[0]); + assert_eq!(segs.last(), Some(&Seg::Line(1.0, 1.0))); + assert_eq!(stroke, Some(([0, 0, 0, 255], 0.0))); +} + +#[test] +fn null_pen_is_recognised_under_extra_style_bits() { + // Pen style 0x105: PS_NULL (5) with a flag above the style nibble still draws no outline. + let pen = erec_at(38, 28, &[(8, 0), (12, 0x105), (16, 1), (24, 0)], &[]); + let recs = [pen, erec(37, &h32(0)), erec(43, &cat(vec![e32(0), e32(0), e32(10), e32(10)]))]; + let p = parse(&emf(&recs, [0; 4], [0; 4])).unwrap(); + let (_, fill, stroke, _) = path_segs(&p.items[0]); + assert_eq!(fill, Some([255, 255, 255, 255])); + assert_eq!(stroke, None); +} + +#[test] +fn metric_map_mode_flips_y_and_scales_to_device_pixels_emf() { + // LOMETRIC is 0.1 mm per unit; the header maps 4 device pixels to 1 mm. Points go y-up, so the + // drawing lies below its origin and the picture frame moves up to its top. + let recs = [erec(17, &h32(2)), erec(27, &cat(vec![e32(0), e32(0)])), erec(54, &cat(vec![e32(100), e32(50)]))]; + let p = parse(&emf(&recs, [0; 4], [0; 4])).unwrap(); + let (segs, ..) = path_segs(&p.items[0]); + assert!(close_seg(&segs[0], Seg::Move(0.0, 20.0)) && close_seg(&segs[1], Seg::Line(40.0, 0.0)), "{segs:?}"); +} + +#[test] +fn metric_map_mode_uses_96_dpi_for_wmf() { + let recs = [wrec(0x0103, &h16(2)), wrec(0x0214, &cat(vec![h16(0), h16(0)])), wrec(0x0213, &cat(vec![h16(50), h16(100)]))]; + let p = parse(&wmf(&recs, None)).unwrap(); + let px_mm = 96.0 / 25.4; + let (segs, ..) = path_segs(&p.items[0]); + let (x, y) = (100.0 * 0.1 * px_mm, 50.0 * 0.1 * px_mm); + assert!(close_seg(&segs[0], Seg::Move(0.0, y as f32)) && close_seg(&segs[1], Seg::Line(x as f32, 0.0)), "{segs:?}"); +} + +/// Segment equality within [`close`] tolerance. +fn close_seg(a: &Seg, b: Seg) -> bool { + match (*a, b) { + (Seg::Move(x, y), Seg::Move(u, v)) | (Seg::Line(x, y), Seg::Line(u, v)) => close(x, u) && close(y, v), + _ => false, + } +} + +#[test] +fn isotropic_mode_uses_one_scale_and_keeps_axis_signs() { + // Window 100 x 50; viewport 400 x -100: the scale is min(4, 2) = 2 on both axes, y flipped. + let recs = [ + wrec(0x0103, &h16(7)), + wrec(0x020C, &cat(vec![h16(50), h16(100)])), + wrec(0x020E, &cat(vec![h16(-100), h16(400)])), + wrec(0x0214, &cat(vec![h16(0), h16(0)])), + wrec(0x0213, &cat(vec![h16(25), h16(50)])), + ]; + let p = parse(&wmf(&recs, None)).unwrap(); + let (segs, ..) = path_segs(&p.items[0]); + assert!(close_seg(&segs[0], Seg::Move(0.0, 100.0)) && close_seg(&segs[1], Seg::Line(100.0, 50.0)), "{segs:?}"); +} + +#[test] +fn modify_world_with_an_unknown_mode_keeps_the_transform() { + let scale2 = cat([2.0f32, 0.0, 0.0, 2.0, 0.0, 0.0].iter().map(|v| v.to_le_bytes().to_vec()).collect()); + let identity = cat([1.0f32, 0.0, 0.0, 1.0, 0.0, 0.0].iter().map(|v| v.to_le_bytes().to_vec()).collect()); + let recs = + [erec(35, &scale2), erec(36, &cat(vec![identity, h32(99)])), erec(27, &cat(vec![e32(0), e32(0)])), erec(54, &cat(vec![e32(10), e32(5)]))]; + let p = parse(&emf(&recs, [0; 4], [0; 4])).unwrap(); + let (segs, ..) = path_segs(&p.items[0]); + assert_eq!(segs, &vec![Seg::Move(0.0, 0.0), Seg::Line(20.0, 10.0)]); +} + +#[test] +fn round_rect_accepts_reversed_corners() { + let normal = crate::geom::round_rect(0.0, 0.0, 10.0, 20.0, 4.0, 4.0); + assert_eq!(crate::geom::round_rect(10.0, 20.0, 0.0, 0.0, 4.0, 4.0), normal); + assert_eq!(crate::geom::round_rect(10.0, 0.0, 0.0, 20.0, 4.0, 4.0), normal); +} + +#[test] +fn stroke_width_is_clamped_to_the_picture_diagonal() { + // A 1440-unit placeable picture is 72 points square (diagonal 101.8); a 5000-unit pen would be 250. + let recs = [ + wrec(0x02FA, &cat(vec![h16(0), h16(5000), h16(0), h32(0)])), + wrec(0x012D, &h16(0)), + wrec(0x041B, &cat(vec![h16(1440), h16(1440), h16(0), h16(0)])), + ]; + let p = parse(&wmf(&recs, Some([0, 0, 1440, 1440]))).unwrap(); + assert_eq!((p.width, p.height), (72.0, 72.0)); + let (_, _, stroke, _) = path_segs(&p.items[0]); + assert!(close(stroke.unwrap_or(([0; 4], 0.0)).1, 72.0 * 2.0f32.sqrt()), "{stroke:?}"); +} + +/// A WMF rectangle from (0, 0) to (1, 1), so a picture's frame starts at the origin. +fn origin_rect() -> Vec { + wrec(0x041B, &cat(vec![h16(1), h16(1), h16(0), h16(0)])) +} + +#[test] +fn wmf_dibbitblt_without_a_dib_is_a_pattern_fill() { + // The 12-word form: rop(4), ySrc, xSrc, reserved, height, width, yDest, xDest; PATCOPY fills with the brush. + let params = cat(vec![h32(0x00F0_0021), h16(0), h16(0), h16(0), h16(10), h16(20), h16(5), h16(7)]); + let p = parse(&wmf(&[origin_rect(), wrec(0x0940, ¶ms)], None)).unwrap(); + let (segs, fill, _, _) = path_segs(&p.items[1]); + assert_eq!(fill, Some([255, 255, 255, 255])); + assert_eq!(segs, &vec![Seg::Move(7.0, 5.0), Seg::Line(27.0, 5.0), Seg::Line(27.0, 15.0), Seg::Line(7.0, 15.0), Seg::Close]); +} + +#[test] +fn wmf_dibstretchblt_without_a_dib_is_a_rop_fill() { + // The 14-word form: rop(4), srcH, srcW, ySrc, xSrc, reserved, destH, destW, yDest, xDest; BLACKNESS fills black. + let params = cat(vec![h32(0x0000_0042), h16(2), h16(2), h16(0), h16(0), h16(0), h16(10), h16(20), h16(5), h16(7)]); + let p = parse(&wmf(&[origin_rect(), wrec(0x0B41, ¶ms)], None)).unwrap(); + let (segs, fill, stroke, _) = path_segs(&p.items[1]); + assert_eq!((fill, stroke), (Some([0, 0, 0, 255]), None)); + assert_eq!(segs, &vec![Seg::Move(7.0, 5.0), Seg::Line(27.0, 5.0), Seg::Line(27.0, 15.0), Seg::Line(7.0, 15.0), Seg::Close]); +} + +#[test] +fn wmf_dibstretchblt_stretches_the_dib_into_its_destination() { + // rop(4), srcH, srcW, ySrc, xSrc, destH, destW, yDest, xDest, then the DIB (no reserved word). + let mut params = cat(vec![h32(0x00CC_0020), h16(2), h16(2), h16(0), h16(0), h16(10), h16(20), h16(6), h16(5)]); + params.extend(dib24_bottom_up()); + let p = parse(&wmf(&[origin_rect(), wrec(0x0B41, ¶ms)], None)).unwrap(); + let Item::Bitmap { rect, rgba, .. } = &p.items[1] else { panic!("expected a bitmap") }; + assert_eq!(rect, &[5.0, 6.0, 20.0, 10.0]); + assert_eq!(rgba.as_slice(), DIB24_RGBA); +} + +#[test] +fn wmf_negative_destination_height_mirrors_the_rows() { + let params = cat(vec![h32(0x00CC_0020), h16(0), h16(2), h16(2), h16(0), h16(0), h16(-10), h16(20), h16(0), h16(0), dib24_bottom_up()]); + let p = parse(&wmf(&[wrec(0x0F43, ¶ms)], None)).unwrap(); + let Item::Bitmap { rect, rgba, .. } = &p.items[0] else { panic!("expected a bitmap") }; + assert_eq!(rect, &[0.0, 0.0, 20.0, 10.0]); + // Blue, green on top; red, white below. + assert_eq!(rgba.as_slice(), [0, 0, 255, 255, 0, 255, 0, 255, 255, 0, 0, 255, 255, 255, 255, 255]); +} + +#[test] +fn wmf_dibbitblt_copies_the_dib_at_its_destination() { + // The rectangle fixes the picture origin at (0, 0); the DIB lands at x = 5, y = 6. + let mut params = cat(vec![h32(0x00CC_0020), h16(0), h16(0), h16(2), h16(2), h16(6), h16(5)]); + params.extend(dib24_bottom_up()); + let recs = [wrec(0x041B, &cat(vec![h16(10), h16(10), h16(0), h16(0)])), wrec(0x0940, ¶ms)]; + let p = parse(&wmf(&recs, None)).unwrap(); + let Item::Bitmap { rect, width, height, rgba, .. } = &p.items[1] else { panic!("expected a bitmap") }; + assert_eq!(rect, &[5.0, 6.0, 2.0, 2.0]); + assert_eq!((*width, *height), (2, 2)); + assert_eq!(rgba.as_slice(), DIB24_RGBA); +} + +#[test] +fn emf_setdibitstodevice_draws_the_image_at_its_own_size() { + let dib = dib24_bottom_up(); + let (info, bits) = (&dib[..40], &dib[40..]); + // Destination x/y at 24, src x/y at 32, size at 40, bmi at 48 (offset, length), bits at 56, scan start + // 68 and scan count 72. + let fields = [(24, 10), (28, 20), (40, 2), (44, 2), (48, 112), (52, 40), (56, 152), (60, 16), (72, 2)]; + let rec = erec_at(80, 168, &fields, &[(112, info), (152, bits)]); + let p = parse(&emf(&[rec], [0; 4], [0; 4])).unwrap(); + let Item::Bitmap { rect, rgba, .. } = &p.items[0] else { panic!("expected a bitmap") }; + assert_eq!(rect, &[0.0, 0.0, 2.0, 2.0]); + assert_eq!(rgba.as_slice(), DIB24_RGBA); +} + +#[test] +fn emf_setdibitstodevice_source_counts_from_the_start_scan() { + // ySrc 1 with the bits starting at scan 1 is source row 0 of the bits: the bottom row (blue, green), + // drawn cxSrc x cySrc = 2 x 1 at the destination. + let dib = dib24_bottom_up(); + let (info, bits) = (&dib[..40], &dib[40..]); + let fields = [(24, 10), (28, 20), (36, 1), (40, 2), (44, 1), (48, 112), (52, 40), (56, 152), (60, 16), (68, 1), (72, 2)]; + let rec = erec_at(80, 168, &fields, &[(112, info), (152, bits)]); + let r = erec(43, &cat(vec![e32(0), e32(0), e32(1), e32(1)])); + let p = parse(&emf(&[r, rec], [0; 4], [0; 4])).unwrap(); + let Item::Bitmap { rect, rgba, .. } = &p.items[1] else { panic!("expected a bitmap") }; + assert_eq!(rect, &[10.0, 20.0, 2.0, 1.0]); + assert_eq!(rgba.as_slice(), [0, 0, 255, 255, 0, 255, 0, 255]); +} + +#[test] +fn emf_alphablend_uses_constant_and_per_pixel_alpha() { + // Constant alpha 128 (BLENDFUNCTION bytes at 40: op, flags, constant, format) over a 24-bit DIB. + let blend = [0u8, 0, 128, 0]; + let dib = dib24_bottom_up(); + let rec = dib_rec(114, &[(24, 0), (28, 0), (32, 2), (36, 2), (44, 0), (48, 0), (100, 2), (104, 2)], &[(40, &blend[..])], &dib[..40], &dib[40..]); + let p = parse(&emf(&[rec], [0; 4], [0; 4])).unwrap(); + let Item::Bitmap { rgba, .. } = &p.items[0] else { panic!("expected a bitmap") }; + let alpha: Vec = rgba.as_chunks::<4>().0.iter().map(|px| px[3]).collect(); + assert_eq!(alpha, vec![128; 4]); + // Per-pixel alpha (AC_SRC_ALPHA) over a 32-bit premultiplied DIB: BGRA 0, 0, 128, 128 is straight red at + // half alpha, and the constant 128 halves the alpha again. + let one_px = |constant: u8, bgra: [u8; 4]| { + let blend = [0u8, 0, constant, 1]; + let info = info_header(1, 1, 32, 4); + let rec = dib_rec(114, &[(24, 0), (28, 0), (32, 1), (36, 1), (44, 0), (48, 0), (100, 1), (104, 1)], &[(40, &blend[..])], &info, &bgra); + let p = parse(&emf(&[rec], [0; 4], [0; 4])).unwrap(); + let Item::Bitmap { rgba, .. } = &p.items[0] else { panic!("expected a bitmap") }; + rgba.clone() + }; + assert_eq!(one_px(255, [0, 0, 128, 128]), [255, 0, 0, 128]); + assert_eq!(one_px(128, [0, 0, 128, 128]), [255, 0, 0, 64]); + assert_eq!(one_px(255, [10, 20, 64, 128]), [128, 40, 20, 128], "rounded to the nearest value"); +} + +#[test] +fn emf_srcand_mask_draws_all_but_its_white_pixels() { + // SRCAND over white paper is the source itself; white keeps the destination, so it is transparent. + let dib = dib24_bottom_up(); + let rec = bitblt_rec(0x0088_00C6, [0, 0, 2, 2], &dib[..40], &dib[40..]); + let p = parse(&emf(&[rec], [0; 4], [0; 4])).unwrap(); + let Item::Bitmap { rgba, .. } = &p.items[0] else { panic!("expected a bitmap") }; + assert_eq!(rgba.as_slice(), [255, 0, 0, 255, 255, 255, 255, 0, 0, 0, 255, 255, 0, 255, 0, 255]); +} + +#[test] +fn emf_srcpaint_makes_black_pixels_transparent() { + // A 2 x 1 DIB: black, then red. SRCPAINT leaves black transparent; SRCCOPY keeps it opaque. + let info = info_header(2, 1, 24, 8); + let bits = [0u8, 0, 0, 0, 0, 255, 0, 0]; + let p = parse(&emf(&[bitblt_rec(0x00EE_0086, [0, 0, 2, 1], &info, &bits)], [0; 4], [0; 4])).unwrap(); + let Item::Bitmap { rgba, .. } = &p.items[0] else { panic!("expected a bitmap") }; + assert_eq!(rgba.as_slice(), [0, 0, 0, 0, 255, 0, 0, 255]); + let p = parse(&emf(&[bitblt_rec(0x00CC_0020, [0, 0, 2, 1], &info, &bits)], [0; 4], [0; 4])).unwrap(); + let Item::Bitmap { rgba, .. } = &p.items[0] else { panic!("expected a bitmap") }; + assert_eq!(rgba.as_slice(), [0, 0, 0, 255, 255, 0, 0, 255]); +} + +#[test] +fn wmf_dib_palette_entries_are_skipped_before_the_bits() { + // A 24-bit DIB with biClrUsed = 2 carries two RGBQUADs (8 bytes) before its pixel bits. + let mut info = info_header(2, 2, 24, 16); + info[32..36].copy_from_slice(&2u32.to_le_bytes()); + let mut dib = info; + dib.extend([9u8; 8]); + dib.extend(&dib24_bottom_up()[40..]); + let params = cat(vec![h32(0x00CC_0020), h16(0), h16(2), h16(2), h16(0), h16(0), h16(10), h16(20), h16(0), h16(0), dib]); + let p = parse(&wmf(&[wrec(0x0F43, ¶ms)], None)).unwrap(); + let Item::Bitmap { rgba, .. } = &p.items[0] else { panic!("expected a bitmap") }; + assert_eq!(rgba.as_slice(), DIB24_RGBA); +} + +#[test] +fn emf_point_counts_past_the_budget_are_rejected() { + // A count of 4 billion points in a record that cannot hold them: nothing is drawn and nothing is allocated. + let mut poly = vec![0u8; 16]; + poly.extend(cat(vec![h32(u32::MAX), h32(0), h32(0)])); + let mut groups = vec![0u8; 16]; + groups.extend(cat(vec![h32(u32::MAX), h32(u32::MAX), h32(1)])); + let p = parse(&emf(&[erec(4, &poly), erec(7, &groups)], [0; 4], [0; 4])).unwrap(); + assert!(p.items.is_empty()); +} + +#[test] +fn emf_polypolygon_counts_must_fit_the_record() { + // A polygon count within the point budget in a record too short to hold the counts draws nothing. + let mut short = vec![0u8; 16]; + short.extend(cat(vec![h32(crate::MAX_PATH_OPS as u32), h32(3), h32(3)])); + // POLYPOLYGON16: two triangles of three points each. + let mut two = vec![0u8; 16]; + two.extend(cat(vec![h32(2), h32(6), h32(3), h32(3)])); + two.extend(cat([0, 0, 10, 0, 0, 10, 20, 0, 30, 0, 20, 10].iter().map(|v| h16(*v)).collect())); + let p = parse(&emf(&[erec(91, &short), erec(91, &two)], [0; 4], [0; 4])).unwrap(); + assert_eq!(p.items.len(), 1); + let (segs, ..) = path_segs(&p.items[0]); + assert_eq!(segs.iter().filter(|s| matches!(s, Seg::Move(..))).count(), 2, "{segs:?}"); +} + +/// EMR_EXTCREATEPEN into slot 1 with LogPenEx style, width and brush style, then a selected rectangle. +fn ext_pen_rect(style: u32, width: u32, brush: u32) -> Option<([u8; 4], f32)> { + let pen = erec_at(95, 52, &[(8, 1), (28, style), (32, width), (36, brush), (40, 0x0000_FF00)], &[]); + let recs = [pen, erec(37, &h32(1)), erec(43, &cat(vec![e32(0), e32(0), e32(10), e32(10)]))]; + let p = parse(&emf(&recs, [0; 4], [0; 4])).unwrap(); + path_segs(&p.items[0]).2 +} + +#[test] +fn emf_ext_pen_cosmetic_is_a_hairline_and_null_brush_draws_nothing() { + // PS_COSMETIC widths are device pixels: a hairline, not one logical unit. + assert_eq!(ext_pen_rect(0, 1, 0), Some(([0, 255, 0, 255], 0.0))); + // PS_GEOMETRIC (0x10000) keeps its logical width. + assert_eq!(ext_pen_rect(0x0001_0000, 4, 0), Some(([0, 255, 0, 255], 4.0))); + // A BS_NULL brush draws no line. + assert_eq!(ext_pen_rect(0x0001_0000, 4, 1), None); +} + +#[test] +fn flip_tolerates_a_short_pixel_buffer() { + let mut img = Rgba { w: 2, h: 2, px: vec![1u8; 7] }; + crate::dib::flip(&mut img, true, true); + assert_eq!(img.px.len(), 7); + // Three rows held of four: only the rows present swap (0 with 2). + let mut img = Rgba { w: 1, h: 4, px: (0u8..12).collect() }; + crate::dib::flip(&mut img, false, true); + assert_eq!(img.px, [8, 9, 10, 11, 4, 5, 6, 7, 0, 1, 2, 3]); +} + +#[test] +fn place_fits_the_frame_scales_strokes_and_drops_far_paths() { + let pic = crate::Picture { + width: 100.0, + height: 50.0, + items: vec![ + Item::Path { + segs: vec![Seg::Move(0.0, 0.0), Seg::Line(10.0, 10.0)], + fill: None, + stroke: Some(([0, 0, 0, 255], 4.0)), + line: crate::LineStyle::default(), + even_odd: true, + region: None, + clips: Vec::new(), + }, + Item::Path { + segs: vec![Seg::Move(0.0, 0.0), Seg::Line(2e6, 0.0)], + fill: None, + stroke: None, + even_odd: true, + line: crate::LineStyle::default(), + region: None, + clips: Vec::new(), + }, + ], + skipped_records: 0, + }; + let placed = pic.place(kurbo::Rect::new(0.0, 0.0, 200.0, 100.0), [0.0; 4]); + assert_eq!(placed.len(), 1); + let crate::PlacedItem::Path { stroke, .. } = &placed[0] else { panic!("expected a path") }; + assert_eq!(stroke.map(|s| s.1), Some(8.0)); +} + +/// #1899: a crop keeps what the file says down to a sliver (a header's bar is 2.7% of a page-sized +/// metafile), and junk sides give a finite part of the source with some area. +#[test] +fn crop_window_keeps_a_sliver_and_stays_finite() { + let [x, y, w, h] = crate::crop_window([0.0, 0.51771, 0.0, 0.45485]); + assert!(x == 0.0 && w == 1.0 && (y - 0.51771).abs() < 1e-6 && (h - 0.02744).abs() < 1e-5, "{y} {h}"); + let pic = crate::Picture { width: 100.0, height: 1000.0, items: Vec::new(), skipped_records: 0 }; + let tf = pic.fit(kurbo::Rect::new(0.0, 0.0, 100.0, 27.44), [0.0, 0.51771, 0.0, 0.45485]).unwrap(); + // The kept band of the source (517.71..545.15) is the frame (0..27.44). + let top = tf * kurbo::Point::new(0.0, 517.71); + let bottom = tf * kurbo::Point::new(0.0, 545.15); + assert!(top.y.abs() < 1e-3 && (bottom.y - 27.44).abs() < 1e-3, "{top:?} {bottom:?}"); + for c in [[f32::NAN, f32::INFINITY, -1.0, 2.0], [0.9; 4], [1.0, 1.0, 0.0, 0.0], [f32::MAX, f32::MIN, f32::NEG_INFINITY, 0.5]] { + let [x, y, w, h] = crate::crop_window(c); + assert!([x, y, w, h].iter().all(|v| v.is_finite()) && w > 0.0 && h > 0.0 && x + w <= 1.0 + 1e-9 && y + h <= 1.0 + 1e-9, "{c:?}"); + } +} + +/// The narrowest crop on the largest frame layout makes (4000 pt, far down a 22-inch page) still +/// places the source's content: none of it is left out as far off the page. +#[test] +fn the_narrowest_crop_on_the_largest_frame_keeps_its_content() { + let pic = crate::Picture { + width: 100.0, + height: 100.0, + items: vec![ + Item::Bitmap { + rect: [0.0, 0.0, 100.0, 100.0], + transform: [0.0; 6], + width: 1, + height: 1, + rgba: vec![255; 4], + region: None, + clips: Vec::new(), + }, + Item::Path { + segs: vec![Seg::Move(0.0, 0.0), Seg::Line(100.0, 100.0)], + fill: None, + stroke: Some(([0, 0, 0, 255], 1.0)), + even_odd: false, + line: crate::LineStyle::default(), + region: None, + clips: Vec::new(), + }, + ], + skipped_records: 0, + }; + let frame = kurbo::Rect::new(1584.0, 1584.0, 5584.0, 5584.0); + // The last sliver, the first sliver, and sides that overlap. + for crop in [[1.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 1.0], [0.9999, 0.6, 0.5, 0.9999]] { + assert_eq!(pic.place(frame, crop).len(), 2, "{crop:?}"); + } +} + +#[test] +fn place_keeps_only_finite_bounded_bitmaps_with_area() { + let bitmap = |rect: [f32; 4]| Item::Bitmap { rect, transform: [0.0; 6], width: 1, height: 1, rgba: vec![0; 4], region: None, clips: Vec::new() }; + let pic = crate::Picture { + width: 100.0, + height: 50.0, + items: vec![ + bitmap([f32::NAN, 0.0, 10.0, 10.0]), + bitmap([0.0, 0.0, 0.0, 10.0]), + bitmap([0.0, 0.0, 2e6, 10.0]), + bitmap([0.0, 0.0, f32::INFINITY, 10.0]), + bitmap([10.0, 5.0, 10.0, 10.0]), + ], + skipped_records: 0, + }; + let placed = pic.place(kurbo::Rect::new(0.0, 0.0, 200.0, 100.0), [0.0; 4]); + assert_eq!(placed.len(), 1); + let crate::PlacedItem::Bitmap { index, rect, .. } = &placed[0] else { panic!("expected a bitmap") }; + assert_eq!((*index, *rect), (4, kurbo::Rect::new(20.0, 10.0, 40.0, 30.0))); +} + +/// `size` against the full parse: same numbers or same error, and whether the header decided it. +fn assert_size_matches_parse(file: &[u8], from_header: bool, what: &str) { + let full = parse(file).map(|p| (p.width, p.height)); + assert_eq!(crate::size(file), full, "{what}"); + assert_eq!(crate::size::header_size(crate::bytes::Bytes(file)).is_some(), from_header, "{what}: header path"); + if let Some(s) = crate::size::header_size(crate::bytes::Bytes(file)) { + assert_eq!(Ok(s), full, "{what}: header size"); + } +} + +#[test] +fn size_from_the_header_equals_the_full_parse() { + let rect = || erec(43, &cat(vec![e32(0), e32(0), e32(50), e32(25)])); + let emfs = [ + ("emf with a frame", emf(&[rect()], [0, 0, 50, 25], [0, 0, 2500, 1250]), true), + ("emf with a large frame", emf(&[rect()], [0, 0, 50, 25], [-90_000, 0, 900_000, 1_250_000]), true), + ("emf with an empty frame", emf(&[rect()], [0, 0, 50, 25], [0; 4]), false), + ("emf with no records", emf(&[], [0, 0, 50, 25], [0, 0, 2500, 1250]), false), + ("emf with no frame and no bounds", emf(&[rect()], [0; 4], [0; 4]), false), + ]; + for (what, file, header) in &emfs { + assert_size_matches_parse(file, *header, what); + } + let draw = [wrec(0x041B, &cat(vec![h16(1440), h16(2880), h16(0), h16(0)]))]; + let wmfs = [ + ("placeable wmf", wmf(&draw, Some([0, 0, 2880, 1440])), true), + ( + "placeable wmf with a window mapping", + wmf( + &[wrec(0x0103, &h16(8)), wrec(0x020C, &cat(vec![h16(100), h16(200)])), wrec(0x020E, &cat(vec![h16(50), h16(100)])), draw[0].clone()], + Some([0, 0, 200, 100]), + ), + false, + ), + ( + "placeable wmf with a zero viewport", + wmf(&[wrec(0x0103, &h16(8)), wrec(0x020E, &cat(vec![h16(0), h16(0)])), draw[0].clone()], Some([0, 0, 200, 100])), + false, + ), + ("placeable wmf with a window only", wmf(&[wrec(0x020C, &cat(vec![h16(100), h16(200)])), draw[0].clone()], Some([0, 0, 200, 100])), true), + ("placeable wmf with an empty box", wmf(&draw, Some([0, 0, 0, 0])), false), + ("placeable wmf with no records", wmf(&[], Some([0, 0, 2880, 1440])), false), + ("plain wmf", wmf(&draw, None), false), + ]; + for (what, file, header) in &wmfs { + assert_size_matches_parse(file, *header, what); + } + assert_size_matches_parse(b"not a metafile at all", false, "garbage"); + assert_size_matches_parse(&[], false, "empty"); +} + +#[test] +fn size_of_a_truncated_or_mutated_file_equals_the_full_parse() { + let rect = erec(43, &cat(vec![e32(0), e32(0), e32(50), e32(25)])); + let draw = wrec(0x041B, &cat(vec![h16(1440), h16(2880), h16(0), h16(0)])); + let files = [emf(&[rect], [0, 0, 50, 25], [0, 0, 2500, 1250]), wmf(&[draw], Some([0, 0, 2880, 1440]))]; + let mut seed = 0x1234_5678_9ABC_DEF0u64; + for file in &files { + for len in 0..=file.len() { + let cut = &file[..len]; + assert_eq!(crate::size(cut), parse(cut).map(|p| (p.width, p.height)), "cut at {len}"); + } + for _ in 0..3000 { + let mut m = file.clone(); + for _ in 0..3 { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + let at = (seed % m.len() as u64) as usize; + m[at] = (seed >> 32) as u8; + } + let full = parse(&m).map(|p| (p.width, p.height)); + let fast = crate::size(&m); + assert_eq!(fast, full, "{m:?}"); + } + } +} + +/// Time of the full parse against the header-only size for an EMF with one 8000 x 5000, 1 bpp bitmap +/// (5 MB, decodes to 160 MB): `cargo test -p craft-metafile --release -- --ignored --nocapture size_speed`. +#[test] +#[ignore = "timing only"] +fn size_speed() { + let (w, h) = (8000i32, 5000i32); + let mut payload = vec![0u8; 16]; + payload.extend(cat(vec![ + e32(0), + e32(0), + e32(0), + e32(0), + e32(w), + e32(h), + e32(80), + e32(48), + e32(128), + h32((w / 8 * h) as u32), + e32(0), + e32(0x00CC_0020), + e32(100), + e32(50), + ])); + payload.extend(info_header(w, h, 1, 0)); + payload.extend([0, 0, 0, 0, 255, 255, 255, 0]); + payload.extend((0..(w / 8 * h) as usize).map(|i| (i * 31) as u8)); + let file = emf(&[erec(81, &payload)], [0, 0, 100, 50], [0, 0, 2500, 1250]); + for round in 0..4 { + let t = std::time::Instant::now(); + let full = parse(&file).map(|p| (p.width, p.height)); + let full_ms = t.elapsed().as_secs_f64() * 1e3; + let t = std::time::Instant::now(); + let fast = crate::size(&file); + let fast_ms = t.elapsed().as_secs_f64() * 1e6; + assert_eq!(full, fast); + eprintln!("round {round}: parse {full_ms:.1} ms, size {fast_ms:.1} us"); + } +} + +#[test] +fn take_pixels_returns_only_drawable_bitmaps() { + let mut ok = Item::Bitmap { rect: [0.0; 4], transform: [0.0; 6], width: 2, height: 1, rgba: vec![7; 8], region: None, clips: Vec::new() }; + assert_eq!(ok.take_pixels(), Some((2, 1, vec![7; 8]))); + assert!(matches!(&ok, Item::Bitmap { rgba, .. } if rgba.is_empty()), "the pixels move out"); + let mut short = Item::Bitmap { rect: [0.0; 4], transform: [0.0; 6], width: 2, height: 2, rgba: vec![0; 8], region: None, clips: Vec::new() }; + assert_eq!(short.take_pixels(), None); + let side = crate::MAX_BITMAP_SIDE + 1; + let mut wide = Item::Bitmap { + rect: [0.0; 4], + transform: [0.0; 6], + width: side, + height: 1, + rgba: vec![0; side as usize * 4], + region: None, + clips: Vec::new(), + }; + assert_eq!(wide.take_pixels(), None); + let mut path = Item::Path { + segs: Vec::new(), + fill: None, + stroke: None, + even_odd: false, + line: crate::LineStyle::default(), + region: None, + clips: Vec::new(), + }; + assert_eq!(path.take_pixels(), None); + let pic = |width: f32, height: f32| crate::Picture { width, height, items: Vec::new(), skipped_records: 0 }; + assert!(pic(1.0, 2.0).has_size()); + assert!(!pic(0.0, 2.0).has_size() && !pic(f32::NAN, 2.0).has_size() && !pic(1.0, f32::INFINITY).has_size()); +} + +/// The rectangle a WMF RECTANGLE record covers, from its path's corners. +fn rect_extent(item: &Item) -> (f32, f32, f32, f32) { + let (segs, ..) = path_segs(item); + let pts: Vec<(f32, f32)> = segs + .iter() + .filter_map(|s| match s { + Seg::Move(x, y) | Seg::Line(x, y) => Some((*x, *y)), + _ => None, + }) + .collect(); + let (xs, ys): (Vec, Vec) = pts.into_iter().unzip(); + let lo = |v: &[f32]| v.iter().copied().fold(f32::MAX, f32::min); + let hi = |v: &[f32]| v.iter().copied().fold(f32::MIN, f32::max); + (lo(&xs), lo(&ys), hi(&xs), hi(&ys)) +} + +#[test] +fn plain_anisotropic_wmf_without_a_viewport_fills_its_window() { + // Anisotropic, window 200 x 100, no viewport (a logo strip saved without a placeable header): the + // window is the target, not a 1 x 1 viewport that squeezes the picture to a point. + let recs = [ + wrec(0x0103, &h16(8)), + wrec(0x020B, &cat(vec![h16(0), h16(0)])), + wrec(0x020C, &cat(vec![h16(100), h16(200)])), + wrec(0x041B, &cat(vec![h16(100), h16(200), h16(0), h16(0)])), + ]; + let p = parse(&wmf(&recs, None)).unwrap(); + assert_eq!((p.width, p.height), (200.0, 100.0)); + 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}"); +} + +#[test] +fn placeable_wmf_window_records_replace_the_bounds() { + // The placeable bounds say 1440 square (72 points); the file then draws in a window centred on the + // origin, as a coat of arms saved by a drawing program does. The window maps onto the 72-point + // target, so the drawing fills it instead of being blown up around the bounds. + let recs = [ + wrec(0x020C, &cat(vec![h16(2000), h16(2000)])), + wrec(0x020B, &cat(vec![h16(-1000), h16(-1000)])), + wrec(0x041B, &cat(vec![h16(1000), h16(1000), h16(-1000), h16(-1000)])), + ]; + let p = parse(&wmf(&recs, Some([238, 539, 1678, 1979]))).unwrap(); + assert_eq!((p.width, p.height), (72.0, 72.0)); + let (x0, y0, x1, y1) = rect_extent(&p.items[0]); + assert!(close(x0, 0.0) && close(y0, 0.0) && close(x1, 72.0) && close(y1, 72.0), "{x0} {y0} {x1} {y1}"); +} + +#[test] +fn placeable_wmf_keeps_its_target_when_the_window_changes_shape() { + // Bounds 600 x 300 (30 x 15 points), then a portrait window 100 x 200 for a bitmap-sized drawing: + // the drawing still fills the target (Word stretches it to the picture's box). + let recs = [wrec(0x0103, &h16(8)), wrec(0x020C, &cat(vec![h16(200), h16(100)])), wrec(0x041B, &cat(vec![h16(200), h16(100), h16(0), h16(0)]))]; + let p = parse(&wmf(&recs, Some([0, 0, 600, 300]))).unwrap(); + assert_eq!((p.width, p.height), (30.0, 15.0)); + let (x0, y0, x1, y1) = rect_extent(&p.items[0]); + assert!(close(x0, 0.0) && close(y0, 0.0) && close(x1, 30.0) && close(y1, 15.0), "{x0} {y0} {x1} {y1}"); +} + +#[test] +fn rop2_nop_draws_nothing_until_the_mode_changes() { + // A coat of arms that first draws its bounding rectangle with the R2_NOP mix mode (no output), + // then its shapes with R2_COPYPEN. Restoring a saved state brings R2_NOP back. + let rect = |n: i32| wrec(0x041B, &cat(vec![h16(n), h16(n), h16(0), h16(0)])); + let recs = [wrec(0x0104, &h16(11)), rect(100), wrec(0x001E, &[]), wrec(0x0104, &h16(13)), rect(50), wrec(0x0127, &h16(-1)), rect(20)]; + let p = parse(&wmf(&recs, Some([0, 0, 100, 100]))).unwrap(); + assert_eq!(p.items.len(), 1, "{:?}", p.items); + let (_, _, x1, y1) = rect_extent(&p.items[0]); + // 50 of 100 units in a 5-point picture. + assert!(close(x1, 2.5) && close(y1, 2.5), "{x1} {y1}"); +} + +#[test] +fn plain_wmf_target_comes_from_a_window_that_was_read() { + // A window record too short to read, then the real one: the target is the real window, not the + // 1 x 1 default the short record left in place. A viewport origin set before it is kept. + let recs = [ + wrec(0x0103, &h16(8)), + wrec(0x020D, &cat(vec![h16(0), h16(0)])), + wrec(0x020C, &[]), + wrec(0x020C, &cat(vec![h16(100), h16(200)])), + wrec(0x041B, &cat(vec![h16(100), h16(200), h16(0), h16(0)])), + ]; + let p = parse(&wmf(&recs, None)).unwrap(); + assert_eq!((p.width, p.height), (200.0, 100.0)); + 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(u16::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()); +} + +fn item_region(item: &Item) -> Option<&[[f32; 4]]> { + match item { + Item::Path { region, .. } | Item::Bitmap { region, .. } | Item::Pattern { region, .. } => region.as_deref(), + Item::Text(t) => t.region.as_deref(), + } +} + +#[test] +fn emf_intersect_clip_rect_limits_later_records_only() { + let full = cat(vec![e32(0), e32(0), e32(80), e32(40)]); + let clip = cat(vec![e32(10), e32(5), e32(40), e32(30)]); + let p = parse(&emf(&[erec(43, &full), erec(30, &clip), erec(43, &full)], [0, 0, 80, 40], [0; 4])).unwrap(); + assert_eq!(p.items.len(), 2); + assert_eq!(item_region(&p.items[0]), None); + assert_eq!(item_region(&p.items[1]), Some(&[[10.0, 5.0, 40.0, 30.0]][..])); +} + +#[test] +fn emf_clip_follows_the_world_transform_and_save_restores_it() { + let xf = cat([2.0f32, 0.0, 0.0, 2.0, 0.0, 0.0].iter().map(|v| v.to_le_bytes().to_vec()).collect()); + let rect = cat(vec![e32(0), e32(0), e32(10), e32(10)]); + let clip = cat(vec![e32(0), e32(0), e32(5), e32(5)]); + let p = parse(&emf(&[erec(35, &xf), erec(30, &clip), erec(43, &rect)], [0, 0, 20, 20], [0; 4])).unwrap(); + assert_eq!(item_region(&p.items[0]), Some(&[[0.0, 0.0, 10.0, 10.0]][..])); + + let full = cat(vec![e32(0), e32(0), e32(40), e32(20)]); + let p = + parse(&emf(&[erec(43, &full), erec(33, &[]), erec(30, &clip), erec(43, &full), erec(34, &e32(-1)), erec(43, &full)], [0, 0, 40, 20], [0; 4])) + .unwrap(); + assert_eq!(p.items.len(), 3); + assert_eq!(item_region(&p.items[0]), None); + assert_eq!(item_region(&p.items[1]), Some(&[[0.0, 0.0, 5.0, 5.0]][..])); + assert_eq!(item_region(&p.items[2]), None); +} + +#[test] +fn emf_ext_select_clip_rgn_copies_and_intersects_rectangles() { + let mut data = cat(vec![h32(32), h32(1), h32(2), h32(32), e32(0), e32(0), e32(50), e32(20)]); + data.extend(cat(vec![e32(0), e32(0), e32(10), e32(20), e32(30), e32(0), e32(50), e32(20)])); + let payload = cat(vec![h32(data.len() as u32), h32(5), data]); + let rect = cat(vec![e32(0), e32(0), e32(50), e32(20)]); + let p = parse(&emf(&[erec(75, &payload), erec(43, &rect)], [0, 0, 50, 20], [0; 4])).unwrap(); + assert_eq!(item_region(&p.items[0]), Some(&[[0.0, 0.0, 10.0, 20.0], [30.0, 0.0, 50.0, 20.0]][..])); + + let and = cat(vec![h32(0), h32(1)]); + // A zero-length region selects the default clip, so the next rectangle is not clipped. + let p = parse(&emf(&[erec(75, &payload), erec(75, &and), erec(43, &rect)], [0, 0, 50, 20], [0; 4])).unwrap(); + assert_eq!(item_region(&p.items[0]), None); + + let overlap = cat(vec![e32(40), e32(0), e32(80), e32(20)]); + let p = parse(&emf(&[erec(75, &payload), erec(30, &overlap), erec(43, &rect)], [0, 0, 80, 20], [0; 4])).unwrap(); + assert_eq!(item_region(&p.items[0]), Some(&[[40.0, 0.0, 50.0, 20.0]][..])); +} + +#[test] +fn emf_exclude_clip_rect_cuts_the_surface() { + let hole = cat(vec![e32(20), e32(0), e32(40), e32(40)]); + let rect = cat(vec![e32(0), e32(0), e32(80), e32(40)]); + let p = parse(&emf(&[erec(29, &hole), erec(43, &rect)], [0, 0, 80, 40], [0; 4])).unwrap(); + assert_eq!(item_region(&p.items[0]), Some(&[[0.0, 0.0, 20.0, 40.0], [40.0, 0.0, 80.0, 40.0]][..])); +} + +#[test] +fn an_empty_clip_drops_later_drawing() { + let empty = cat(vec![e32(5), e32(5), e32(5), e32(8)]); + let rect = cat(vec![e32(0), e32(0), e32(20), e32(10)]); + let p = parse(&emf(&[erec(43, &rect), erec(30, &empty), erec(43, &rect)], [0, 0, 20, 10], [0; 4])).unwrap(); + assert_eq!(p.items.len(), 1); +} + +#[test] +fn wmf_intersect_clip_rect_limits_later_records() { + let clip = cat(vec![h16(30), h16(40), h16(5), h16(10)]); + let rect = cat(vec![h16(40), h16(80), h16(0), h16(0)]); + let p = parse(&wmf(&[wrec(0x0416, &clip), wrec(0x041B, &rect)], None)).unwrap(); + assert_eq!(item_region(&p.items[0]), Some(&[[10.0, 5.0, 40.0, 30.0]][..])); +} + +// ---- EMF+ ---- + +fn f32s(v: &[f32]) -> Vec { + v.iter().flat_map(|x| x.to_le_bytes()).collect() +} + +/// One EMF+ record: type, flags, size, data size, data (padded to 4). +fn prec(typ: u16, flags: u16, data: &[u8]) -> Vec { + let mut d = data.to_vec(); + while !d.len().is_multiple_of(4) { + d.push(0); + } + let mut v = typ.to_le_bytes().to_vec(); + v.extend(flags.to_le_bytes()); + v.extend(((12 + d.len()) as u32).to_le_bytes()); + v.extend((d.len() as u32).to_le_bytes()); + v.extend(d); + v +} + +/// An EMR_COMMENT carrying EMF+ records. +fn plus_comment(recs: &[Vec]) -> Vec { + let body = recs.concat(); + let mut p = ((body.len() + 4) as u32).to_le_bytes().to_vec(); + p.extend(0x2B46_4D45u32.to_le_bytes()); + p.extend(body); + erec(70, &p) +} + +/// EMF+ header: dual (bit 0) or EMF+ only, 96 dpi. +fn plus_header(dual: bool) -> Vec { + prec(0x4001, u16::from(dual), &cat(vec![h32(0xDBC0_1002), h32(0), h32(96), h32(96)])) +} + +fn solid_pen(argb: u32, width: f32) -> Vec { + cat(vec![h32(0xDBC0_1002), h32(0), h32(0), h32(0), f32s(&[width]), h32(0xDBC0_1002), h32(0), h32(argb)]) +} + +#[test] +fn emfplus_only_fill_rects_with_argb_colour() { + let fill = prec(0x400A, 0x8000, &cat(vec![h32(0xFF00_80FF), h32(1), f32s(&[10.0, 20.0, 30.0, 40.0])])); + let p = parse(&emf(&[plus_comment(&[plus_header(false), fill])], [0; 4], [0; 4])).unwrap(); + assert_eq!(p.items.len(), 1); + let (segs, fill, stroke, _) = path_segs(&p.items[0]); + assert_eq!(fill, Some([0x00, 0x80, 0xFF, 0xFF])); + assert_eq!(stroke, None); + // Geometry is placed relative to the drawing's bounding box (no frame given). + assert_eq!((p.width, p.height), (30.0, 40.0)); + assert_eq!(segs[0], Seg::Move(0.0, 0.0)); + assert!(segs.contains(&Seg::Line(30.0, 40.0))); +} + +#[test] +fn emfplus_dual_files_draw_only_the_emf_records() { + let fill = prec(0x400A, 0x8000, &cat(vec![h32(0xFFFF_0000), h32(1), f32s(&[0.0, 0.0, 5.0, 5.0])])); + let gdi = erec(43, &cat(vec![e32(0), e32(0), e32(9), e32(9)])); + let p = parse(&emf(&[plus_comment(&[plus_header(true), fill]), gdi], [0; 4], [0; 4])).unwrap(); + assert_eq!(p.items.len(), 1); + assert_eq!(path_segs(&p.items[0]).1, Some([255, 255, 255, 255])); +} + +#[test] +fn emfplus_pen_object_draws_lines_under_a_scale_transform() { + let pen = prec(0x4008, 0x0200 | 3, &solid_pen(0xFF00_FF00, 2.0)); + let scale = prec(0x402E, 0, &f32s(&[2.0, 3.0])); + let lines = prec(0x400D, 3, &cat(vec![h32(2), f32s(&[1.0, 1.0, 4.0, 2.0])])); + let p = parse(&emf(&[plus_comment(&[plus_header(false), pen, scale, lines])], [0; 4], [0; 4])).unwrap(); + let (segs, fill, stroke, _) = path_segs(&p.items[0]); + // (1,1)-(4,2) scaled by (2,3) is (2,3)-(8,6), placed relative to its bounding box. + assert_eq!(segs, &vec![Seg::Move(0.0, 0.0), Seg::Line(6.0, 3.0)]); + assert_eq!(fill, None); + let (c, w) = stroke.unwrap(); + assert_eq!(c, [0, 255, 0, 255]); + assert!(w > 0.0); +} + +#[test] +fn emfplus_path_object_fills_with_brush_object_and_restore_undoes_transform() { + let brush = prec(0x4008, 0x0100 | 1, &cat(vec![h32(0xDBC0_1002), h32(0), h32(0x8012_3456)])); + // Triangle: start, line, line|close. + let path = prec(0x4008, 0x0300 | 2, &cat(vec![h32(0xDBC0_1002), h32(3), h32(0), f32s(&[0.0, 0.0, 10.0, 0.0, 0.0, 10.0]), vec![0, 1, 0x81]])); + let save = prec(0x4025, 0, &h32(7)); + let mv = prec(0x402D, 0, &f32s(&[100.0, 0.0])); + let restore = prec(0x4026, 0, &h32(7)); + let fill = prec(0x4014, 2, &h32(1)); + let p = parse(&emf(&[plus_comment(&[plus_header(false), brush, path, save, mv, restore, fill])], [0; 4], [0; 4])).unwrap(); + let (segs, fill, _, _) = path_segs(&p.items[0]); + assert_eq!(fill, Some([0x12, 0x34, 0x56, 0x80])); + assert_eq!(segs, &vec![Seg::Move(0.0, 0.0), Seg::Line(10.0, 0.0), Seg::Line(0.0, 10.0), Seg::Close]); +} + +#[test] +fn emfplus_garbage_records_never_panic() { + let mut s = 0x1234_5678u32; + for _ in 0..300 { + let mut recs = vec![plus_header(false)]; + for _ in 0..6 { + s = s.wrapping_mul(1_103_515_245).wrapping_add(12_345); + let typ = 0x4000 + (s >> 16) as u16 % 0x40; + let data: Vec = (0..(s % 64)).map(|i| (s.rotate_left(i)) as u8).collect(); + recs.push(prec(typ, (s >> 8) as u16, &data)); + } + let _ = parse(&emf(&[plus_comment(&recs)], [0; 4], [0; 4])); + } +} + +fn plus_pic(recs: Vec>) -> Picture { + let mut all = vec![plus_header(false)]; + all.extend(recs); + parse(&emf(&[plus_comment(&all)], [0; 4], [0; 4])).unwrap() +} + +fn u16s(s: &str) -> Vec { + s.encode_utf16().flat_map(u16::to_le_bytes).collect() +} + +/// A 2 x 2 32bpp ARGB bitmap image object: red, green / blue, white. +fn bitmap_2x2() -> Vec { + let px = [0xFFFF_0000u32, 0xFF00_FF00, 0xFF00_00FF, 0xFFFF_FFFF]; + cat(vec![h32(0xDBC0_1002), h32(1), h32(2), h32(2), h32(8), h32(0x0026_200A), h32(0), px.iter().flat_map(|v| v.to_le_bytes()).collect()]) +} + +fn draw_image(id: u16, dst: [f32; 4]) -> Vec { + prec(0x401A, id, &cat(vec![h32(0), h32(2), f32s(&[0.0, 0.0, 2.0, 2.0]), f32s(&dst)])) +} + +#[test] +fn emfplus_draw_image_places_the_bitmap() { + let p = plus_pic(vec![prec(0x4008, 0x0500 | 4, &bitmap_2x2()), draw_image(4, [10.0, 10.0, 20.0, 40.0])]); + match &p.items[0] { + Item::Bitmap { rect, width, height, rgba, .. } => { + assert_eq!((*width, *height), (2, 2)); + assert_eq!(rect, &[0.0, 0.0, 20.0, 40.0]); + assert_eq!(&rgba[..8], &[255, 0, 0, 255, 0, 255, 0, 255]); + assert_eq!(&rgba[8..12], &[0, 0, 255, 255]); + } + other => panic!("expected a bitmap: {other:?}"), + } +} + +#[test] +fn emfplus_object_split_across_records_is_joined() { + let obj = bitmap_2x2(); + let (a, b) = obj.split_at(20); + let total = h32(obj.len() as u32); + let first = prec(0x4008, 0x8000 | 0x0500 | 1, &cat(vec![total.clone(), a.to_vec()])); + let second = prec(0x4008, 0x0500 | 1, b); + let p = plus_pic(vec![first, second, draw_image(1, [0.0, 0.0, 4.0, 4.0])]); + assert!(matches!(&p.items[0], Item::Bitmap { width: 2, height: 2, .. })); +} + +#[test] +fn emfplus_compressed_png_image_decodes() { + let mut png = Vec::new(); + let img = image::RgbaImage::from_pixel(3, 1, image::Rgba([1, 2, 3, 255])); + image::DynamicImage::ImageRgba8(img).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + let obj = cat(vec![h32(0xDBC0_1002), h32(1), h32(0), h32(0), h32(0), h32(0), h32(1), png]); + let draw = prec(0x401A, 2, &cat(vec![h32(0), h32(2), f32s(&[0.0, 0.0, 3.0, 1.0]), f32s(&[0.0, 0.0, 3.0, 1.0])])); + let p = plus_pic(vec![prec(0x4008, 0x0500 | 2, &obj), draw]); + assert!(matches!(&p.items[0], Item::Bitmap { width: 3, height: 1, rgba, .. } if rgba[..4] == [1, 2, 3, 255])); +} + +fn font_obj(family: &str, em: f32, unit: u32, style: u32) -> Vec { + cat(vec![h32(0xDBC0_1002), f32s(&[em]), h32(unit), h32(style), h32(0), h32(family.encode_utf16().count() as u32), u16s(family)]) +} + +#[test] +fn emfplus_draw_string_with_font_and_centred_format() { + let font = prec(0x4008, 0x0600 | 3, &font_obj("Calibri", 12.0, 2, 1)); + // String format: centre alignment, no clip. + let fmt = prec(0x4008, 0x0700 | 4, &cat(vec![h32(0xDBC0_1002), h32(0x4000), h32(0), h32(1), h32(0)])); + let s = prec(0x401C, 0x8000 | 3, &cat(vec![h32(0xFF11_2233), h32(4), h32(2), f32s(&[0.0, 0.0, 100.0, 20.0]), u16s("Hi")])); + let p = plus_pic(vec![font, fmt, prec(0x400A, 0x8000, &cat(vec![h32(0xFFFF_FFFF), h32(1), f32s(&[0.0, 0.0, 100.0, 20.0])])), s]); + let t = p.items.iter().find_map(|i| if let Item::Text(t) = i { Some(t) } else { None }).expect("a text item"); + assert_eq!(t.text, "Hi"); + assert_eq!(t.font.family, "Calibri"); + assert_eq!(t.font.weight, 700); + assert!(close(t.font.height, -12.0)); + assert_eq!(t.color, [0x11, 0x22, 0x33, 0xFF]); + assert_eq!(t.h_align, HAlign::Center); + assert!(close(t.transform[4] as f32, 50.0)); + assert_eq!(t.background, None); + assert_eq!(t.clip, None); +} + +#[test] +fn emfplus_driver_string_uses_character_codes_and_positions() { + let font = prec(0x4008, 0x0600, &font_obj("Arial", 10.0, 0, 0)); + let pos = f32s(&[5.0, 30.0, 11.0, 30.0]); + let s = prec(0x4036, 0x8000, &cat(vec![h32(0xFF00_0000), h32(1), h32(0), h32(2), u16s("Ab"), pos])); + let p = plus_pic(vec![prec(0x400A, 0x8000, &cat(vec![h32(0xFFFF_FFFF), h32(1), f32s(&[0.0, 0.0, 50.0, 50.0])])), font, s]); + let Some(Item::Text(t)) = p.items.get(1) else { panic!("expected text: {:?}", p.items) }; + assert_eq!(t.text, "Ab"); + assert_eq!(t.dx, vec![6.0, 6.0]); + assert_eq!(t.v_align, VAlign::Baseline); + assert!(close(t.transform[4] as f32, 5.0) && close(t.transform[5] as f32, 30.0)); +} + +#[test] +fn emfplus_glyph_index_driver_strings_map_back_in_core_fonts_only() { + // Glyph indices (no CmapLookup): 36, 69 are "Ab" in Arial's standard glyph order. + let gi = || prec(0x4036, 0x8000, &cat(vec![h32(0xFF00_0000), h32(0), h32(0), h32(2), h16(36), h16(69), f32s(&[0.0, 0.0, 6.0, 0.0])])); + let p = plus_pic(vec![prec(0x4008, 0x0600, &font_obj("Arial", 10.0, 0, 0)), gi()]); + assert!(matches!(&p.items[0], Item::Text(t) if t.text == "Ab"), "{:?}", p.items); + // An unknown font's glyph indices cannot be mapped: nothing is drawn. + assert!(plus_pic(vec![prec(0x4008, 0x0600, &font_obj("Calibri", 10.0, 0, 0)), gi()]).items.is_empty()); +} + +#[test] +fn emfplus_linear_gradient_fills_with_a_shaded_tile() { + // Red to blue across x = 0..100. + let brush = + cat(vec![h32(0xDBC0_1002), h32(4), h32(0), h32(0), f32s(&[0.0, 0.0, 100.0, 10.0]), h32(0xFFFF_0000), h32(0xFF00_00FF), h32(0), h32(0)]); + let fill = prec(0x400A, 0, &cat(vec![h32(5), h32(1), f32s(&[0.0, 0.0, 100.0, 10.0])])); + let p = plus_pic(vec![prec(0x4008, 0x0100 | 5, &brush), fill]); + let Item::Pattern { tile, .. } = &p.items[0] else { panic!("expected a pattern: {:?}", p.items) }; + let px = |x: usize| &tile.rgba[x * 4..x * 4 + 4]; + assert!(px(0)[0] > 240 && px(0)[2] < 15, "left is red: {:?}", px(0)); + let right = (tile.width as usize - 2).min(127); + assert!(px(right)[2] > 230 && px(right)[0] < 25, "right is blue: {:?}", px(right)); +} + +#[test] +fn emfplus_hatch_brush_fills_with_an_8x8_tile() { + let brush = cat(vec![h32(0xDBC0_1002), h32(1), h32(4), h32(0xFF00_0000), h32(0xFFFF_FFFF)]); + let fill = prec(0x400A, 0, &cat(vec![h32(0), h32(1), f32s(&[0.0, 0.0, 16.0, 16.0])])); + let p = plus_pic(vec![prec(0x4008, 0x0100, &brush), fill]); + assert!(matches!(&p.items[0], Item::Pattern { tile, .. } if tile.width == 8 && tile.height == 8)); +} + +#[test] +fn emfplus_clip_rect_cuts_fills_strokes_and_images() { + let clip = prec(0x4032, 0, &f32s(&[0.0, 0.0, 50.0, 50.0])); + let fill = prec(0x400A, 0x8000, &cat(vec![h32(0xFF00_0000), h32(1), f32s(&[25.0, 25.0, 50.0, 50.0])])); + let outside = prec(0x400A, 0x8000, &cat(vec![h32(0xFF00_0000), h32(1), f32s(&[60.0, 60.0, 5.0, 5.0])])); + let pen = prec(0x4008, 0x0200 | 1, &solid_pen(0xFF00_FF00, 1.0)); + let line = prec(0x400D, 1, &cat(vec![h32(2), f32s(&[0.0, 10.0, 100.0, 10.0])])); + let img = prec(0x4008, 0x0500 | 2, &bitmap_2x2()); + let p = plus_pic(vec![clip, fill, outside, pen, line, img, draw_image(2, [40.0, 0.0, 20.0, 20.0])]); + assert_eq!(p.items.len(), 3, "{:?}", p.items); + let (segs, ..) = path_segs(&p.items[0]); + let xs: Vec = segs.iter().filter_map(|s| if let Seg::Move(x, _) | Seg::Line(x, _) = s { Some(*x) } else { None }).collect(); + // Picture coordinates start at the drawing's left edge (0): the square is cut at x = 50. + assert!(xs.iter().all(|x| *x <= 50.0 + 1e-3), "{segs:?}"); + let (segs, ..) = path_segs(&p.items[1]); + assert!(matches!(segs[1], Seg::Line(x, _) if close(x, 50.0)), "{segs:?}"); + let Item::Bitmap { rect, width, .. } = &p.items[2] else { panic!() }; + assert_eq!(*width, 1); + assert!(close(rect[2], 10.0)); +} + +#[test] +fn emfplus_reset_clip_and_restore_bring_drawing_back() { + let clip = prec(0x4032, 0, &f32s(&[0.0, 0.0, 1.0, 1.0])); + let fill = || prec(0x400A, 0x8000, &cat(vec![h32(0xFF00_0000), h32(1), f32s(&[10.0, 10.0, 5.0, 5.0])])); + let p = plus_pic(vec![prec(0x4025, 0, &h32(1)), clip, fill(), prec(0x4026, 0, &h32(1)), fill()]); + assert_eq!(p.items.len(), 1); + let clip = prec(0x4032, 0, &f32s(&[0.0, 0.0, 1.0, 1.0])); + let p = plus_pic(vec![clip, fill(), prec(0x4031, 0, &[]), fill()]); + assert_eq!(p.items.len(), 1); +} + +#[test] +fn emfplus_region_clip_intersects_rectangles() { + // Region: intersect(rect 0,0,30,30 ; rect 10,10,100,100) = 10..30. + let rect = |r: [f32; 4]| cat(vec![h32(0x1000_0000), f32s(&r)]); + let region = cat(vec![h32(0xDBC0_1002), h32(2), h32(1), rect([0.0, 0.0, 30.0, 30.0]), rect([10.0, 10.0, 90.0, 90.0])]); + let fill = prec(0x400A, 0x8000, &cat(vec![h32(0xFF00_0000), h32(1), f32s(&[0.0, 0.0, 100.0, 100.0])])); + let p = plus_pic(vec![prec(0x4008, 0x0400 | 6, ®ion), prec(0x4034, 6, &[]), fill]); + assert_eq!((p.width, p.height), (20.0, 20.0)); +} + +#[test] +fn emfplus_region_path_cannot_read_into_its_sibling() { + let rect = |r: [f32; 4]| cat(vec![h32(0x1000_0000), f32s(&r)]); + // The path declares only its 12-byte header, but claims three points. The following + // rectangle must not supply those points or be parsed twice as the next node. + let path = cat(vec![h32(0x1000_0001), h32(12), h32(0), h32(3), h32(0)]); + let region = cat(vec![h32(0xDBC0_1002), h32(2), h32(1), path, rect([10.0, 10.0, 20.0, 20.0])]); + let fill = prec(0x400A, 0x8000, &cat(vec![h32(0xFF00_0000), h32(1), f32s(&[0.0, 0.0, 100.0, 100.0])])); + let p = plus_pic(vec![prec(0x4008, 0x0400 | 6, ®ion), prec(0x4034, 6, &[]), fill]); + assert_eq!(p.items.len(), 1); + assert_eq!((p.width, p.height), (100.0, 100.0)); +} + +#[test] +fn emfplus_region_path_keeps_its_shape() { + let mut path_data = cat(vec![h32(0), h32(4), h32(0x4000)]); + for (x, y) in [(10i16, 10i16), (30, 10), (30, 30), (10, 30)] { + path_data.extend(x.to_le_bytes()); + path_data.extend(y.to_le_bytes()); + } + path_data.extend([0, 1, 1, 0x81]); + let path_node = cat(vec![h32(0x1000_0001), h32(path_data.len() as u32), path_data]); + let region = cat(vec![h32(0xDBC0_1002), h32(1), path_node]); + let fill = prec(0x400A, 0x8000, &cat(vec![h32(0xFF00_0000), h32(1), f32s(&[0.0, 0.0, 100.0, 100.0])])); + let p = plus_pic(vec![prec(0x4008, 0x0400 | 6, ®ion), prec(0x4034, 6, &[]), fill]); + assert_eq!((p.width, p.height), (20.0, 20.0)); +} + +#[test] +fn emfplus_pie_and_arc() { + // A quarter pie from 0 to 90 degrees in a 0..100 circle: centre, then (100, 50) round to (50, 100). + let pie = prec(0x4010, 0x8000, &cat(vec![h32(0xFF00_0000), f32s(&[0.0, 90.0]), f32s(&[0.0, 0.0, 100.0, 100.0])])); + let p = plus_pic(vec![pie]); + let (segs, fill, ..) = path_segs(&p.items[0]); + assert_eq!(fill, Some([0, 0, 0, 255])); + assert_eq!(segs[0], Seg::Move(0.0, 0.0)); + assert_eq!(segs[1], Seg::Line(50.0, 0.0)); + assert!(matches!(segs[2], Seg::Cubic(.., x, y) if close(x, 0.0) && close(y, 50.0)), "{segs:?}"); + assert_eq!(segs[3], Seg::Close); + // A half-circle arc stroke is two cubics. + let pen = prec(0x4008, 0x0200, &solid_pen(0xFF00_0000, 1.0)); + let arc = prec(0x4012, 0, &cat(vec![f32s(&[180.0, -180.0]), f32s(&[0.0, 0.0, 100.0, 100.0])])); + let p = plus_pic(vec![pen, arc]); + let (segs, ..) = path_segs(&p.items[0]); + assert_eq!(segs.len(), 3, "{segs:?}"); +} + +#[test] +fn emfplus_convex_clip_path_cuts_to_the_shape_not_its_box() { + // A diamond clip path (0,50) (50,0) (100,50) (50,100): the corner of a full square fill is cut away. + let diamond = cat(vec![h32(0xDBC0_1002), h32(4), h32(0), f32s(&[0.0, 50.0, 50.0, 0.0, 100.0, 50.0, 50.0, 100.0]), vec![0, 1, 1, 0x81]]); + let fill = prec(0x400A, 0x8000, &cat(vec![h32(0xFF00_0000), h32(1), f32s(&[0.0, 0.0, 100.0, 100.0])])); + let p = plus_pic(vec![prec(0x4008, 0x0300 | 9, &diamond), prec(0x4033, 9, &[]), fill]); + let (segs, ..) = path_segs(&p.items[0]); + let pts: Vec<(f32, f32)> = segs.iter().filter_map(|s| if let Seg::Move(x, y) | Seg::Line(x, y) = s { Some((*x, *y)) } else { None }).collect(); + assert!(!pts.iter().any(|(x, y)| close(*x, 0.0) && close(*y, 0.0)), "corner kept: {pts:?}"); + assert!(pts.iter().all(|(x, y)| (x - 50.0).abs() + (y - 50.0).abs() <= 50.0 + 1e-3), "{pts:?}"); +} + +#[test] +fn emfplus_draw_string_wraps_to_the_layout_width() { + let font = prec(0x4008, 0x0600, &font_obj("Arial", 10.0, 0, 0)); + let text = "alpha beta gamma delta"; + let s = prec( + 0x401C, + 0x8000, + &cat(vec![h32(0xFF00_0000), h32(9), h32(text.encode_utf16().count() as u32), f32s(&[0.0, 0.0, 60.0, 100.0]), u16s(text)]), + ); + let fill = prec(0x400A, 0x8000, &cat(vec![h32(0xFFFF_FFFF), h32(1), f32s(&[0.0, 0.0, 60.0, 100.0])])); + let p = plus_pic(vec![fill, font, s]); + let lines: Vec<&str> = p.items.iter().filter_map(|i| if let Item::Text(t) = i { Some(t.text.as_str()) } else { None }).collect(); + assert_eq!(lines, vec!["alpha beta", "gamma delta"]); +} + +#[test] +fn emfplus_nested_metafile_image_draws_its_items_into_the_destination() { + // The nested picture: a plain EMF with a 10 x 10 rectangle (white brush, black pen). + let inner = emf(&[erec(43, &cat(vec![e32(0), e32(0), e32(10), e32(10)]))], [0; 4], [0; 4]); + let obj = cat(vec![h32(0xDBC0_1002), h32(2), h32(3), h32(inner.len() as u32), inner]); + let p = plus_pic(vec![prec(0x4008, 0x0500 | 3, &obj), draw_image(3, [0.0, 0.0, 40.0, 20.0])]); + assert_eq!(p.items.len(), 1); + assert_eq!((p.width, p.height), (40.0, 20.0)); + let (segs, fill, ..) = path_segs(&p.items[0]); + assert_eq!(fill, Some([255, 255, 255, 255])); + assert!(segs.contains(&Seg::Line(40.0, 20.0)), "{segs:?}"); +} + +/// Curves and a region fill, as an EMF+ only file (also written to `$CRAFT_METAFILE_EMF_OUT` to look at). +fn curves_file() -> Vec { + let pen = prec(0x4008, 0x0200 | 1, &solid_pen(0xFF20_60C0, 3.0)); + // A closed curve through a diamond's corners, filled orange. + let diamond = f32s(&[100.0, 10.0, 190.0, 100.0, 100.0, 190.0, 10.0, 100.0]); + let fill = prec(0x4016, 0x8000, &cat(vec![h32(0xFFE0_8020), f32s(&[0.5]), h32(4), diamond.clone()])); + let outline = prec(0x4017, 1, &cat(vec![f32s(&[0.5]), h32(4), diamond])); + // An open curve (a smoothed line chart): all four segments of five points. + let line = + prec(0x4018, 1, &cat(vec![f32s(&[0.5]), h32(0), h32(4), h32(5), f32s(&[210.0, 150.0, 260.0, 60.0, 310.0, 120.0, 360.0, 30.0, 410.0, 90.0])])); + // A green rectangle region, filled. + let region = cat(vec![h32(0xDBC0_1002), h32(0), h32(0x1000_0000), f32s(&[220.0, 160.0, 180.0, 30.0])]); + let fill_region = prec(0x4013, 0x8000 | 2, &h32(0xFF30_A040)); + // A cream background first. + let clear = prec(0x4009, 0, &h32(0xFFF4_EED8)); + let mut all = vec![plus_header(false), clear, pen, fill, outline, line, prec(0x4008, 0x0400 | 2, ®ion), fill_region]; + all.push(prec(0x4002, 0, &[])); + emf(&[plus_comment(&all)], [0, 0, 420, 200], [0; 4]) +} + +#[test] +fn emfplus_cardinal_curves_and_region_fill() { + let bytes = curves_file(); + if let Ok(path) = std::env::var("CRAFT_METAFILE_EMF_OUT") { + let _ = std::fs::write(path, &bytes); + } + let p = parse(&bytes).unwrap(); + assert_eq!(p.items.len(), 5, "{:?}", p.items.len()); + assert_eq!(path_segs(&p.items[0]).1, Some([0xF4, 0xEE, 0xD8, 0xFF]), "the clear comes first"); + // The closed curve: four cubics through the diamond's corners, closed. + let (segs, fill, ..) = path_segs(&p.items[1]); + assert_eq!(fill, Some([0xE0, 0x80, 0x20, 0xFF])); + let cubics: Vec<(f32, f32)> = segs.iter().filter_map(|s| if let Seg::Cubic(.., x, y) = s { Some((*x, *y)) } else { None }).collect(); + assert_eq!(cubics.len(), 4, "{segs:?}"); + assert_eq!(segs.last(), Some(&Seg::Close)); + // The open curve: four cubics and no close; its controls bulge past the straight chords. + let (segs, fill, stroke, _) = path_segs(&p.items[3]); + assert_eq!((fill, stroke.map(|s| s.0)), (None, Some([0x20, 0x60, 0xC0, 0xFF]))); + assert_eq!(segs.iter().filter(|s| matches!(s, Seg::Cubic(..))).count(), 4); + assert!(!segs.contains(&Seg::Close)); + // The region fill is its rectangle. + let (segs, fill, ..) = path_segs(&p.items[4]); + assert_eq!(fill, Some([0x30, 0xA0, 0x40, 0xFF])); + assert_eq!(segs.iter().filter(|s| matches!(s, Seg::Line(..))).count(), 3, "{segs:?}"); +} + +#[test] +fn emfplus_curve_offsets_and_degenerate_input_are_safe() { + let pen = prec(0x4008, 0x0200 | 1, &solid_pen(0xFF00_0000, 1.0)); + // Segments from point 2 of 5, asking for more than there are: two segments. + let line = prec(0x4018, 1, &cat(vec![f32s(&[0.5]), h32(2), h32(99), h32(5), f32s(&[0.0, 0.0, 10.0, 5.0, 20.0, 0.0, 30.0, 5.0, 40.0, 0.0])])); + let p = plus_pic(vec![pen.clone(), line]); + let (segs, ..) = path_segs(&p.items[0]); + assert_eq!(segs.iter().filter(|s| matches!(s, Seg::Cubic(..))).count(), 2, "{segs:?}"); + // One point, an offset past the end, a NaN tension, a missing region: nothing, no panic. + let one = prec(0x4018, 1, &cat(vec![f32s(&[0.5]), h32(0), h32(1), h32(1), f32s(&[1.0, 1.0])])); + let past = prec(0x4018, 1, &cat(vec![f32s(&[0.5]), h32(9), h32(9), h32(2), f32s(&[0.0, 0.0, 9.0, 9.0])])); + let nan = prec(0x4017, 1, &cat(vec![f32s(&[f32::NAN]), h32(3), f32s(&[0.0, 0.0, 9.0, 9.0, 0.0, 9.0])])); + let no_region = prec(0x4013, 0x8000 | 7, &h32(0xFF00_0000)); + let p = plus_pic(vec![pen, one, past, nan, no_region]); + assert!(p.items.iter().all(|i| path_segs(i).0.iter().filter(|s| matches!(s, Seg::Cubic(..))).count() == 0)); +} + +#[test] +fn emfplus_clear_paints_the_whole_frame_and_transparent_clears_nothing() { + let dot = prec(0x400A, 0x8000, &cat(vec![h32(0xFF00_0000), h32(1), f32s(&[40.0, 40.0, 10.0, 10.0])])); + let clear = |argb: u32| prec(0x4009, 0, &h32(argb)); + // 100 × 50 device units (0..2500 × 0..1250 hundredths of a millimetre at 0.04 units each). + let file = |recs: Vec>| { + let mut all = vec![plus_header(false)]; + all.extend(recs); + parse(&emf(&[plus_comment(&all)], [0, 0, 99, 49], [0, 0, 2500, 1250])).unwrap() + }; + // A translated, clipped world doesn't move or cut the clear. + let shift = prec(0x402D, 0, &f32s(&[30.0, 30.0])); + let clip = prec(0x4032, 0, &f32s(&[0.0, 0.0, 5.0, 5.0])); + let p = file(vec![shift, clip, clear(0xFFFF_FFFF), prec(0x4031, 0, &[]), dot.clone()]); + assert_eq!(p.items.len(), 2); + let (segs, fill, ..) = path_segs(&p.items[0]); + assert_eq!(fill, Some([255, 255, 255, 255])); + let xs: Vec = segs.iter().filter_map(|s| if let Seg::Move(x, _) | Seg::Line(x, _) = s { Some(*x) } else { None }).collect(); + let span = xs.iter().cloned().fold(f32::MIN, f32::max) - xs.iter().cloned().fold(f32::MAX, f32::min); + assert!((span - p.width).abs() < 0.5, "clear spans {span} of {}", p.width); + // Transparent: only the dot. + assert_eq!(file(vec![clear(0x00FF_FFFF), dot.clone()]).items.len(), 1); + // No frame: nothing to clear. + assert_eq!(plus_pic(vec![clear(0xFFFF_FFFF), dot]).items.len(), 1); +} + +#[test] +fn emfplus_wrap_line_linear_performance() { + let font = prec(0x4008, 0x0600, &font_obj("Arial", 10.0, 0, 0)); + // Repeated words that wrap across lines + let text = "word ".repeat(500); + let s = prec( + 0x401C, + 0x8000, + &cat(vec![h32(0xFF00_0000), h32(9), h32(text.encode_utf16().count() as u32), f32s(&[0.0, 0.0, 100.0, 1000.0]), u16s(&text)]), + ); + let fill = prec(0x400A, 0x8000, &cat(vec![h32(0xFFFF_FFFF), h32(1), f32s(&[0.0, 0.0, 100.0, 1000.0])])); + let p = plus_pic(vec![fill, font, s]); + let text_items = p.items.iter().filter(|i| matches!(i, Item::Text(_))).count(); + assert!(text_items > 10, "wrapped into multiple lines: {text_items}"); +} + +#[test] +fn clip_polygon_work_bound() { + let poly: Vec<(f64, f64)> = (0..2000).map(|i| (i as f64, i as f64)).collect(); + let clip: Vec<(f64, f64)> = (0..2000).map(|i| (i as f64, i as f64)).collect(); + assert!(crate::cliprect::clip_polygon_within(&poly, &clip, &mut 1_000_000).is_none(), "excessive polygon clip product rejected"); +} + +#[test] +fn clip_polygon_charges_actual_work_not_projected_growth() { + // Two convex 1,000-gons: the product is exactly the budget, and a cut that adds a vertex must + // not make a later step look over budget. + let gon = |cx: f64| -> Vec<(f64, f64)> { + (0..1000).map(|i| (i as f64 / 1000.0 * std::f64::consts::TAU).sin_cos()).map(|(s, c)| (cx + 100.0 * c, 100.0 * s)).collect() + }; + let clipped = crate::cliprect::clip_polygon_within(&gon(0.0), &gon(30.0), &mut 4_000_000).unwrap(); + assert!(clipped.len() > 100, "the intersection is kept: {}", clipped.len()); +} + +#[test] +fn clip_work_is_shared_by_fill_and_stroke() { + use crate::canvas::Raw; + use crate::geom::Op; + // One zigzag subpath crossing a 512-gon's edge: about 510k point-edge tests per pass, so a + // fill alone fits the allowance but a fill plus a stroke would spend it twice. + let clip: Vec<(f64, f64)> = + (0..512).map(|i| (i as f64 / 512.0 * std::f64::consts::TAU).sin_cos()).map(|(s, c)| (100.0 + 100.0 * c, 100.0 + 100.0 * s)).collect(); + let mut ops = vec![Op::M((0.0, 95.0))]; + ops.extend((1..1000).map(|i| Op::L((i as f64 * 0.19, if i % 2 == 0 { 95.0 } else { 300.0 })))); + ops.push(Op::Z); + let raw = |fill, stroke| Raw::Path { + ops: ops.clone(), + fill, + stroke, + even_odd: false, + line: crate::LineStyle::default(), + region: crate::canvas::RawClip::default(), + }; + let black = [0, 0, 0, 255]; + assert!(!crate::cliprect::clip_raw(raw(Some(black), None), &clip).is_empty(), "a fill alone is within the allowance"); + assert!(crate::cliprect::clip_raw(raw(Some(black), Some((black, 1.0))), &clip).is_empty(), "fill and stroke share one allowance"); +} + +#[test] +fn clip_work_is_bounded_across_subpaths() { + use crate::canvas::Raw; + use crate::geom::Op; + // 512-gon clip; 4,000 small triangles crossing its edge: each under the per-polygon cap, together + // far over it, for both the fill and the stroke. + let clip: Vec<(f64, f64)> = + (0..512).map(|i| (i as f64 / 512.0 * std::f64::consts::TAU).sin_cos()).map(|(s, c)| (100.0 + 100.0 * c, 100.0 + 100.0 * s)).collect(); + let tri = |x: f64| [Op::M((x, 95.0)), Op::L((x + 1.0, 95.0)), Op::L((x, 300.0)), Op::Z]; + let many: Vec = (0..4000).flat_map(|i| tri(i as f64 % 200.0)).collect(); + let raw = |ops| Raw::Path { + ops, + fill: Some([0, 0, 0, 255]), + stroke: Some(([0, 0, 0, 255], 1.0)), + even_odd: false, + line: crate::LineStyle::default(), + region: crate::canvas::RawClip::default(), + }; + assert!(crate::cliprect::clip_raw(raw(many), &clip).is_empty(), "the whole item's clipping work is bounded"); + assert_eq!(crate::cliprect::clip_raw(raw(tri(100.0).to_vec()), &clip).len(), 2, "a small crossing shape is still clipped"); +} + +/// Clipping a fill can add a vertex at every clip edge, so a polygon's work isn't its input size: +/// shapes that pass the up-front estimate but grow while clipped are charged as they grow, against +/// one budget for the whole item. +#[test] +fn growing_fill_clips_share_one_budget() { + use crate::canvas::Raw; + use crate::geom::Op; + let clip: Vec<(f64, f64)> = + (0..512).map(|i| (i as f64 / 512.0 * std::f64::consts::TAU).sin_cos()).map(|(s, c)| (100.0 + 100.0 * c, 100.0 + 100.0 * s)).collect(); + // A triangle around the whole circle: clipped, it becomes the 512-gon, about 130,000 steps. + let tri = [Op::M((-1000.0, -1000.0)), Op::L((1000.0, -1000.0)), Op::L((100.0, 1000.0)), Op::Z]; + let raw = |n: usize| Raw::Path { + ops: tri.repeat(n), + fill: Some([0, 0, 0, 255]), + stroke: None, + even_odd: false, + line: crate::LineStyle::default(), + region: crate::canvas::RawClip::default(), + }; + // 400 of them pass the estimate (400 x 4 x 512 is under a million) but cost about 52 million. + assert!(crate::cliprect::clip_raw(raw(400), &clip).is_empty(), "the growing work is charged"); + assert_eq!(crate::cliprect::clip_raw(raw(2), &clip).len(), 1, "a couple of them are still clipped"); +} + +#[test] +fn stroke_work_is_charged_and_outside_items_are_cheap() { + use crate::canvas::Raw; + use crate::geom::Op; + let clip: Vec<(f64, f64)> = + (0..512).map(|i| (i as f64 / 512.0 * std::f64::consts::TAU).sin_cos()).map(|(s, c)| (100.0 + 100.0 * c, 100.0 + 100.0 * s)).collect(); + // A stroke-only zigzag of 4,000 segments across the edge: 4,000 x 512 tests, over the allowance. + let mut ops = vec![Op::M((0.0, 95.0))]; + ops.extend((1..4000).map(|i| Op::L((i as f64 * 0.05, if i % 2 == 0 { 95.0 } else { 300.0 })))); + let raw = Raw::Path { + ops, + fill: None, + stroke: Some(([0, 0, 0, 255], 1.0)), + even_odd: false, + line: crate::LineStyle::default(), + region: crate::canvas::RawClip::default(), + }; + assert!(crate::cliprect::clip_raw(raw, &clip).is_empty(), "stroke work is bounded"); +} + +#[test] +fn region_intersection_over_budget_keeps_the_previous_area() { + let gon = |cx: f64| -> Vec<(f64, f64)> { + (0..1000).map(|i| (i as f64 / 1000.0 * std::f64::consts::TAU).sin_cos()).map(|(s, c)| (cx + 100.0 * c, 100.0 * s)).collect() + }; + assert!(crate::cliprect::clip_polygon_within(&gon(0.0), &gon(30.0), &mut 10).is_none()); + assert!(crate::cliprect::clip_polygon_within(&gon(0.0), &gon(30.0), &mut crate::cliprect::MAX_AREA_CLIP_WORK.clone()).is_some()); +} + +/// A poly-polygon's group counts are file data: a long list of empty groups adds no ops, so it +/// mustn't reserve room for them either (the canvas charges what a path holds, capacity included). +#[test] +fn empty_poly_groups_reserve_nothing() { + let pts = [(0.0, 0.0), (10.0, 0.0), (10.0, 10.0)]; + let mut counts = vec![0usize; 100_000]; + counts.push(3); + let ops = crate::geom::subpaths(&pts, &counts, true); + assert_eq!(ops.len(), 4, "M L L Z"); + assert!(ops.capacity() <= 8, "capacity {}", ops.capacity()); + // Counts past the points are cut short, and the size still matches. + let ops = crate::geom::subpaths(&pts, &[2, 5, 4], false); + assert_eq!((ops.len(), ops.capacity()), (3, 3)); +} diff --git a/crates/metafile/src/tests/limits.rs b/crates/metafile/src/tests/limits.rs new file mode 100644 index 000000000..16f995d9e --- /dev/null +++ b/crates/metafile/src/tests/limits.rs @@ -0,0 +1,219 @@ +//! One test for each cap that the other test modules do not already hold to its value. + +use super::*; +use crate::emfplus; + +#[test] +fn strings_are_cut_at_the_character_cap() { + let cap = crate::text::MAX_CHARS; + // EXTTEXTOUTW: the count says more than the cap; the string holds that many. + let units = vec![0x41u16; cap + 100]; + let p = parse(&emf(&[etext(true, 0, 0, &units, 0, &[])], [0, 0, 200, 100], [0, 0, 5000, 2500])).unwrap(); + assert_eq!(texts(&p)[0].text.chars().count(), cap); + // META_EXTTEXTOUT and META_TEXTOUT. + let bytes = vec![b'a'; cap + 100]; + let p = wmf200(&[wtext(0, 0, &bytes, 0, None, &[])]); + assert_eq!(texts(&p)[0].text.chars().count(), cap); + let mut v = h16((cap + 100) as i32 as i16 as i32); + v.extend(&bytes); + v.extend(cat(vec![h16(0), h16(0)])); + let p = wmf200(&[wrec(0x0521, &v)]); + assert!(texts(&p).iter().all(|t| t.text.chars().count() <= cap)); +} + +#[test] +fn emfplus_strings_are_cut_at_their_character_cap() { + let cap = emfplus::MAX_CHARS; + let font = prec(0x4008, 0x0600 | 3, &font_obj("Calibri", 12.0, 2, 0)); + let long: String = "a".repeat(cap + 50); + let s = prec(0x401C, 0x8000 | 3, &cat(vec![h32(0xFF00_0000), h32(9), h32((cap + 50) as u32), f32s(&[0.0, 0.0, 0.0, 0.0]), u16s(&long)])); + let p = plus_pic(vec![font, s]); + let total: usize = texts(&p).iter().map(|t| t.text.chars().count()).sum(); + assert!(total > 0 && total <= cap, "{total}"); +} + +#[test] +fn a_clip_region_keeps_at_most_its_rectangle_cap() { + let cap = crate::clip::MAX_CLIP_RECTS; + // EXTSELECTCLIPRGN (RGN_COPY) with twice the cap of separate rectangles in its RGNDATA. + let n = cap * 2; + let mut data = cat(vec![h32(32), h32(1), h32(n as u32), h32((n * 16) as u32), vec![0; 16]]); + for i in 0..n as i32 { + data.extend(cat(vec![e32(i * 10), e32(0), e32(i * 10 + 5), e32(5)])); + } + let rgn = erec(75, &cat(vec![h32(data.len() as u32), h32(5), data])); + let draw = erec(43, &cat(vec![e32(0), e32(0), e32(10_000), e32(10)])); + let p = parse(&emf(&[rgn, draw], [0; 4], [0; 4])).unwrap(); + let Item::Path { region: Some(r), .. } = &p.items[0] else { panic!("a clipped path") }; + assert_eq!(r.len(), cap); +} + +#[test] +fn placing_stops_at_the_element_cap() { + let segs: Vec = std::iter::once(Seg::Move(0.0, 0.0)).chain((0..100_000).map(|i| Seg::Line((i % 7) as f32, (i % 5) as f32))).collect(); + let path = || Item::Path { + segs: segs.clone(), + fill: Some([0, 0, 0, 255]), + stroke: None, + line: crate::LineStyle::default(), + even_odd: false, + region: None, + clips: Vec::new(), + }; + let pic = crate::Picture { width: 10.0, height: 10.0, items: (0..8).map(|_| path()).collect(), skipped_records: 0 }; + let placed = pic.place(kurbo::Rect::new(0.0, 0.0, 10.0, 10.0), [0.0; 4]); + // Five paths of 100,001 elements reach the 500,000 cap; the rest are left out. + assert_eq!(placed.len(), 5); +} + +#[test] +fn a_pattern_tile_over_the_side_cap_is_no_brush() { + let side = crate::canvas::MAX_TILE_SIDE; + let tile = |w: usize, h: usize| crate::canvas::Brush::pattern(Rgba { w, h, px: vec![0; w * h * 4] }, false); + assert!(tile(side, side).is_some()); + assert!(tile(side + 1, 1).is_none()); + assert!(tile(1, side + 1).is_none()); +} + +#[test] +fn a_dib_palette_holds_at_most_256_entries() { + // 24 bpp with a colour table of 1000 entries claimed: 256 are skipped before the bits. + let mut info = info_header(1, 1, 24, 4); + info[32..36].copy_from_slice(&1000u32.to_le_bytes()); + assert_eq!(crate::dib::info_len(crate::bytes::Bytes(&info)), Some(40 + 256 * 4)); +} + +/// `inner` as an EMF+ metafile image object drawn into (0, 0, 40, 20), in a file of its own. +fn wrap(inner: Vec) -> Vec { + let obj = cat(vec![h32(0xDBC0_1002), h32(2), h32(3), h32(inner.len() as u32), inner]); + emf(&[plus_comment(&[plus_header(false), prec(0x4008, 0x0500 | 3, &obj), draw_image(3, [0.0, 0.0, 40.0, 20.0])])], [0; 4], [0; 4]) +} + +#[test] +fn metafiles_nest_only_so_deep() { + let nested = |levels: usize| { + let mut file = emf(&[erec(43, &cat(vec![e32(0), e32(0), e32(10), e32(10)]))], [0; 4], [0; 4]); + for _ in 0..levels { + file = wrap(file); + } + parse(&file).map(|p| p.items.len()).unwrap_or(0) + }; + assert_eq!(nested(emfplus::MAX_NESTING), 1); + assert_eq!(nested(emfplus::MAX_NESTING + 1), 0, "the innermost picture is not read"); +} + +#[test] +fn emfplus_object_ids_past_the_table_are_ignored() { + let draw = |id: u16| { + let pen = prec(0x4008, 0x0200 | id, &solid_pen(0xFF00_FF00, 2.0)); + let lines = prec(0x400D, id, &cat(vec![h32(2), f32s(&[1.0, 1.0, 4.0, 2.0])])); + plus_pic(vec![pen, lines]).items.len() + }; + assert_eq!(draw(emfplus::MAX_OBJECTS as u16 - 1), 1); + assert_eq!(draw(emfplus::MAX_OBJECTS as u16), 0); +} + +#[test] +fn emfplus_saves_past_the_stack_cap_are_not_kept() { + // Saves 0..=cap, a move, then a restore of the last save: it was not kept, so the move stays. + let cap = emfplus::MAX_STACK as u32; + let fill = || prec(0x400A, 0x8000, &cat(vec![h32(0xFF00_0000), h32(1), f32s(&[0.0, 0.0, 10.0, 10.0])])); + let run = |saves: u32| { + let mut recs: Vec> = (0..saves).map(|i| prec(0x4025, 0, &h32(i))).collect(); + recs.extend([fill(), prec(0x402D, 0, &f32s(&[100.0, 0.0])), prec(0x4026, 0, &h32(saves - 1)), fill()]); + plus_pic(recs).width + }; + assert_eq!(run(cap), 10.0, "the last save was kept: the restore undoes the move"); + assert_eq!(run(cap + 1), 110.0, "one save too many: nothing to restore"); +} + +#[test] +fn emfplus_point_lists_are_cut_at_the_point_cap() { + let n = emfplus::MAX_POINTS + 10; + let pts: Vec = (0..n).flat_map(|i| [(i % 100) as f32, (i % 37) as f32]).collect(); + let pen = prec(0x4008, 0x0200 | 3, &solid_pen(0xFF00_FF00, 1.0)); + let lines = prec(0x400D, 3, &cat(vec![h32(n as u32), f32s(&pts)])); + let p = plus_pic(vec![pen, lines]); + let (segs, ..) = path_segs(&p.items[0]); + assert_eq!(segs.len(), emfplus::MAX_POINTS); +} + +#[test] +fn a_continued_emfplus_object_reserves_nothing_for_the_size_it_claims() { + // 4 GB claimed (cut to the cap), a few bytes sent: the object never completes and no memory + // is set aside for it. + let first = prec(0x4008, 0x8000 | 0x0500 | 1, &cat(vec![h32(u32::MAX), vec![1, 2, 3, 4]])); + let p = plus_pic(vec![first.clone(), first, draw_image(1, [0.0, 0.0, 4.0, 4.0])]); + assert!(p.items.is_empty()); +} + +#[test] +fn emfplus_gradients_keep_at_most_their_stop_cap() { + // Preset colours (brush flag 4) with far more stops claimed than the data holds: the count is + // cut to the cap first, so the read is bounded and the short data makes no brush. + let cap = emfplus::MAX_STOPS as u32; + let brush = |n: u32, held: u32| { + let mut b = + cat(vec![h32(0xDBC0_1002), h32(4), h32(4), h32(0), f32s(&[0.0, 0.0, 100.0, 10.0]), h32(0xFFFF_0000), h32(0xFF00_00FF), h32(0), h32(0)]); + b.extend(h32(n)); + b.extend(f32s(&(0..held).map(|i| i as f32 / held as f32).collect::>())); + b.extend((0..held).flat_map(|_| h32(0xFF00_FF00))); + b + }; + let fill = || prec(0x400A, 0, &cat(vec![h32(5), h32(1), f32s(&[0.0, 0.0, 100.0, 10.0])])); + let drawn = |n: u32, held: u32| plus_pic(vec![prec(0x4008, 0x0100 | 5, &brush(n, held)), fill()]).items.len(); + assert_eq!(drawn(cap, cap), 1); + assert_eq!(drawn(u32::MAX, cap), 1, "a count past the cap reads the cap's worth"); + assert_eq!(drawn(u32::MAX, 4), 0, "and no more than the record holds"); +} + +#[test] +fn emfplus_regions_nest_only_so_deep_and_only_so_many_nodes() { + let rect = || cat(vec![h32(0x1000_0000), f32s(&[0.0, 0.0, 30.0, 30.0])]); + // A chain of intersections `depth` deep: and(and(and(..., rect), rect), rect). + let chain = |depth: usize| { + let mut body = Vec::new(); + for _ in 0..depth { + body.extend(h32(1)); + } + for _ in 0..=depth { + body.extend(rect()); + } + cat(vec![h32(0xDBC0_1002), h32(depth as u32), body]) + }; + let clipped = |region: Vec| { + let fill = prec(0x400A, 0x8000, &cat(vec![h32(0xFF00_0000), h32(1), f32s(&[0.0, 0.0, 100.0, 100.0])])); + plus_pic(vec![prec(0x4008, 0x0400 | 6, ®ion), prec(0x4034, 6, &[]), fill]).width + }; + assert_eq!(clipped(chain(emfplus::MAX_REGION_DEPTH)), 30.0, "a region at the depth cap clips"); + assert_eq!(clipped(chain(emfplus::MAX_REGION_DEPTH + 1)), 100.0, "one deeper is no region: the fill is whole"); + // A balanced tree of intersections stays shallow and has 2^(levels + 1) - 1 nodes. + fn tree(levels: usize, out: &mut Vec) { + if levels == 0 { + out.extend(cat(vec![h32(0x1000_0000), f32s(&[0.0, 0.0, 30.0, 30.0])])); + } else { + out.extend(h32(1)); + tree(levels - 1, out); + tree(levels - 1, out); + } + } + let balanced = |levels: usize| { + let mut body = Vec::new(); + tree(levels, &mut body); + cat(vec![h32(0xDBC0_1002), h32(0), body]) + }; + let levels = (emfplus::MAX_REGION_WORK + 1).ilog2() as usize; + assert_eq!(clipped(balanced(levels - 1)), 30.0, "fewer nodes than the cap"); + assert_eq!(clipped(balanced(levels)), 100.0, "more nodes than the cap is no region"); +} + +#[test] +fn masking_a_bitmap_to_a_clip_stops_at_the_work_cap() { + // A triangle clip over a bitmap: three corners times the pixels inside the clip's box. + let tri = [(0.0, 0.0), (1000.0, 0.0), (0.0, 1000.0)]; + let side = |n: usize| crate::cliprect::crop([0.0, 0.0, 1000.0, 1000.0], Rgba { w: n, h: n, px: vec![255; n * n * 4] }, &tri).is_some(); + let cap = crate::cliprect::MAX_MASK_WORK; + let fits = ((cap / 3) as f64).sqrt() as usize; + assert!(side(fits)); + assert!(!side(fits + 2)); +} diff --git a/crates/metafile/src/tests/model.rs b/crates/metafile/src/tests/model.rs new file mode 100644 index 000000000..ab6f87ce5 --- /dev/null +++ b/crates/metafile/src/tests/model.rs @@ -0,0 +1,544 @@ +//! Line styles, clip paths and the device-context records behind them, the DIB forms and the +//! header fields that came with VectorCraft's reader. + +use std::sync::Arc; + +use super::*; +use crate::canvas::{MAX_CLIP_PATHS, MAX_DASH_ENTRIES}; +use crate::{Cap, ClipPath, Join, LineStyle, PlacedItem}; + +fn line_of(item: &Item) -> &LineStyle { + match item { + Item::Path { line, .. } => line, + _ => panic!("expected a path"), + } +} + +fn clips_of(item: &Item) -> &Vec> { + match item { + Item::Path { clips, .. } | Item::Bitmap { clips, .. } | Item::Pattern { clips, .. } => clips, + Item::Text(t) => &t.clips, + } +} + +fn rect(l: i32, t: i32, r: i32, b: i32) -> Vec { + erec(43, &cat(vec![e32(l), e32(t), e32(r), e32(b)])) +} + +fn move_to(x: i32, y: i32) -> Vec { + erec(27, &cat(vec![e32(x), e32(y)])) +} + +fn line_to(x: i32, y: i32) -> Vec { + erec(54, &cat(vec![e32(x), e32(y)])) +} + +/// EMR_EXTCREATEPEN in slot 1 with `entries` style entries. +fn ext_pen(style: u32, width: u32, entries: &[u32]) -> Vec { + let mut fields = vec![(8, 1), (28, style), (32, width), (36, 0), (40, 0x0000_00FF), (48, entries.len() as u32)]; + fields.extend(entries.iter().enumerate().map(|(i, e)| (52 + i * 4, *e))); + erec_at(95, 52 + entries.len() * 4, &fields, &[]) +} + +/// A rectangle path bracket selected as the clip in `mode`. +fn clip_to_rect(l: i32, t: i32, r: i32, b: i32, mode: u32) -> Vec> { + vec![erec(59, &[]), rect(l, t, r, b), erec(60, &[]), erec(67, &h32(mode))] +} + +fn parse_emf(recs: &[Vec]) -> crate::Picture { + parse(&emf(recs, [0; 4], [0; 4])).unwrap() +} + +const PS_GEOMETRIC: u32 = 0x0001_0000; + +#[test] +fn emf_geometric_pen_keeps_its_cap_join_miter_limit_and_dashes() { + // PS_USERSTYLE with flat caps and miter joins, 4 wide, dashes of 8 and gaps of 4. + let pen = ext_pen(PS_GEOMETRIC | 7 | 0x200 | 0x2000, 4, &[8, 4]); + let recs = [pen, erec(37, &h32(1)), erec(58, &h32(3)), move_to(0, 0), line_to(100, 0)]; + let p = parse_emf(&recs); + let (_, _, stroke, _) = path_segs(&p.items[0]); + assert_eq!(stroke, Some(([255, 0, 0, 255], 4.0))); + assert_eq!(line_of(&p.items[0]), &LineStyle { cap: Cap::Flat, join: Join::Miter, miter_limit: 3.0, dash: vec![8.0, 4.0] }); +} + +#[test] +fn dash_lengths_follow_the_mapping_like_the_width() { + // A world transform of 2: the width and the dashes double. + let xf: Vec = [2.0f32, 0.0, 0.0, 2.0, 0.0, 0.0].iter().flat_map(|v| v.to_le_bytes()).collect(); + let recs = [erec(35, &xf), ext_pen(PS_GEOMETRIC | 7, 4, &[8, 4]), erec(37, &h32(1)), move_to(0, 0), line_to(100, 0)]; + let p = parse_emf(&recs); + let (_, _, stroke, _) = path_segs(&p.items[0]); + assert_eq!(stroke, Some(([255, 0, 0, 255], 8.0))); + assert_eq!(line_of(&p.items[0]).dash, vec![16.0, 8.0]); +} + +#[test] +fn pen_styles_become_dash_patterns_of_the_pen_width() { + // CREATEPEN: slot, style, width (x, y), colour. + let pen = |style: u32, width: u32| erec_at(38, 28, &[(8, 1), (12, style), (16, width)], &[]); + let draw = |style: u32, width: u32| { + let p = parse_emf(&[pen(style, width), erec(37, &h32(1)), move_to(0, 0), line_to(100, 0)]); + line_of(&p.items[0]).clone() + }; + assert_eq!(draw(0, 2), LineStyle::default(), "PS_SOLID: round caps and joins, miter limit 10, no dashes"); + assert_eq!(draw(1, 2).dash, vec![6.0, 2.0], "PS_DASH"); + assert_eq!(draw(2, 2).dash, vec![2.0, 2.0], "PS_DOT"); + assert_eq!(draw(3, 2).dash, vec![6.0, 2.0, 2.0, 2.0], "PS_DASHDOT"); + assert_eq!(draw(4, 2).dash, vec![6.0, 2.0, 2.0, 2.0, 2.0, 2.0], "PS_DASHDOTDOT"); + // A cosmetic pen's dashes are in device pixels. + assert_eq!(draw(2, 0).dash, vec![1.0, 1.0]); + // PS_INSIDEFRAME and unknown styles are solid. + assert!(draw(6, 2).dash.is_empty()); +} + +#[test] +fn a_dash_pattern_that_draws_nothing_is_a_solid_line() { + let draw = |entries: &[u32]| { + let p = parse_emf(&[ext_pen(PS_GEOMETRIC | 7, 2, entries), erec(37, &h32(1)), move_to(0, 0), line_to(100, 0)]); + line_of(&p.items[0]).dash.clone() + }; + assert!(draw(&[0, 0]).is_empty()); + assert!(draw(&[]).is_empty()); + assert_eq!(draw(&[0, 5]), vec![0.0, 5.0], "a zero dash with round caps draws dots"); +} + +#[test] +fn a_pen_keeps_at_most_sixteen_style_entries() { + let entries: Vec = (1..=100).collect(); + let p = parse_emf(&[ext_pen(PS_GEOMETRIC | 7, 1, &entries), erec(37, &h32(1)), move_to(0, 0), line_to(100, 0)]); + assert_eq!(line_of(&p.items[0]).dash.len(), MAX_DASH_ENTRIES); + // A count that runs past the record keeps the entries the record holds. + let mut rec = ext_pen(PS_GEOMETRIC | 7, 1, &[3, 2]); + rec[48..52].copy_from_slice(&1000u32.to_le_bytes()); + let p = parse_emf(&[rec, erec(37, &h32(1)), move_to(0, 0), line_to(100, 0)]); + assert_eq!(line_of(&p.items[0]).dash, vec![3.0, 2.0]); +} + +#[test] +fn wmf_pen_style_word_carries_cap_and_join() { + // CREATEPENINDIRECT: style (PS_SOLID, flat caps, bevel joins), width (x, y), colour. + let pen = wrec(0x02FA, &cat(vec![h16(0x1200), h16(3), h16(0), h32(0)])); + let recs = [pen, wrec(0x012D, &h16(0)), wrec(0x0214, &cat(vec![h16(0), h16(0)])), wrec(0x0213, &cat(vec![h16(0), h16(50)]))]; + let p = parse(&wmf(&recs, None)).unwrap(); + let line = line_of(&p.items[0]); + assert_eq!((line.cap, line.join), (Cap::Flat, Join::Bevel)); +} + +#[test] +fn restore_dc_brings_back_the_miter_limit() { + let pen = ext_pen(PS_GEOMETRIC | 0x2000, 2, &[]); + let recs = + [pen, erec(37, &h32(1)), erec(33, &[]), erec(58, &h32(4)), move_to(0, 0), line_to(9, 0), erec(34, &e32(-1)), move_to(0, 5), line_to(9, 5)]; + let p = parse_emf(&recs); + assert_eq!(line_of(&p.items[0]).miter_limit, 4.0); + assert_eq!(line_of(&p.items[1]).miter_limit, 10.0); + // A limit below 1 is invalid: the limit stays. + let p = parse_emf(&[ext_pen(PS_GEOMETRIC | 0x2000, 2, &[]), erec(37, &h32(1)), erec(58, &h32(0)), move_to(0, 0), line_to(9, 0)]); + assert_eq!(line_of(&p.items[0]).miter_limit, 10.0); +} + +#[test] +fn select_clip_path_clips_what_follows_and_items_share_the_path() { + let mut recs = vec![rect(0, 0, 100, 100)]; + recs.extend(clip_to_rect(10, 10, 60, 60, 1)); + recs.extend([rect(0, 0, 50, 50), rect(20, 20, 80, 80)]); + let p = parse_emf(&recs); + assert_eq!(p.items.len(), 3); + assert!(clips_of(&p.items[0]).is_empty(), "drawn before the clip"); + let (a, b) = (clips_of(&p.items[1]), clips_of(&p.items[2])); + assert_eq!((a.len(), b.len()), (1, 1)); + assert!(Arc::ptr_eq(&a[0], &b[0]), "one clip, shared"); + assert_eq!(a[0].segs, vec![Seg::Move(10.0, 10.0), Seg::Line(60.0, 10.0), Seg::Line(60.0, 60.0), Seg::Line(10.0, 60.0), Seg::Close]); + assert!(a[0].even_odd, "the path takes the fill mode in force (ALTERNATE)"); + // The clip path is not painted, and the bracket is empty afterwards: FILLPATH draws nothing. + let mut recs = clip_to_rect(0, 0, 10, 10, 1); + recs.push(erec(62, &[0; 16])); + assert!(parse_emf(&recs).items.is_empty()); +} + +#[test] +fn clip_paths_nest_with_and_replace_with_copy_and_end_with_restore_dc() { + let mut recs = vec![erec(33, &[])]; + recs.extend(clip_to_rect(0, 0, 90, 90, 1)); + recs.extend(clip_to_rect(10, 10, 80, 80, 1)); + recs.push(rect(0, 0, 100, 100)); + recs.extend(clip_to_rect(20, 20, 70, 70, 5)); + recs.push(rect(0, 0, 100, 100)); + recs.push(erec(34, &e32(-1))); + recs.push(rect(0, 0, 100, 100)); + let p = parse_emf(&recs); + let n: Vec = p.items.iter().map(|i| clips_of(i).len()).collect(); + assert_eq!(n, vec![2, 1, 0], "RGN_AND nests, RGN_COPY replaces, RESTOREDC removes"); + let outer = &clips_of(&p.items[0])[0]; + assert_eq!(outer.segs.first(), Some(&Seg::Move(0.0, 0.0)), "outermost first"); + assert_eq!(clips_of(&p.items[1])[0].segs.first(), Some(&Seg::Move(20.0, 20.0))); +} + +#[test] +fn clip_modes_that_add_or_cut_leave_the_clip_and_a_region_copy_drops_clip_paths() { + for mode in [2, 3, 4, 0, 99] { + let mut recs = clip_to_rect(0, 0, 10, 10, mode); + recs.push(rect(0, 0, 100, 100)); + let p = parse_emf(&recs); + assert!(clips_of(&p.items[0]).is_empty(), "mode {mode}"); + } + // EXTSELECTCLIPRGN with no region data selects the default clip: no region, no clip paths. + let mut recs = clip_to_rect(0, 0, 10, 10, 1); + recs.push(erec(75, &cat(vec![h32(0), h32(5)]))); + recs.push(rect(0, 0, 100, 100)); + let p = parse_emf(&recs); + assert!(clips_of(&p.items[0]).is_empty()); +} + +#[test] +fn an_empty_clip_path_clips_everything_away() { + let recs = [erec(59, &[]), erec(60, &[]), erec(67, &h32(1)), rect(0, 0, 100, 100)]; + assert!(parse_emf(&recs).items.is_empty()); +} + +#[test] +fn one_clip_path_past_the_cap_stops_the_parse() { + let nested = |n: usize| { + let mut recs = Vec::new(); + for _ in 0..n { + recs.extend(clip_to_rect(0, 0, 50, 50, 1)); + } + recs.push(rect(0, 0, 100, 100)); + parse_emf(&recs) + }; + let p = nested(MAX_CLIP_PATHS); + assert_eq!(clips_of(&p.items[0]).len(), MAX_CLIP_PATHS); + assert!(nested(MAX_CLIP_PATHS + 1).items.is_empty(), "what follows can no longer be clipped as the file asks"); +} + +#[test] +fn clip_path_ops_count_against_the_byte_budget() { + // Each clip path is charged once, when it is selected, so RGN_COPY in a loop cannot hold more + // than the budget (the earlier paths stay alive while items drawn under them do). + let mut cv = Canvas::new(); + let big = crate::MAX_PATH_OPS; + let mut selected = 0; + while !cv.full() && selected < 100 { + cv.begin_path(); + cv.line_chain(&vec![(1.0, 1.0); big - 1], false, false); + cv.end_path(); + cv.select_clip_path(5); + selected += 1; + } + assert!(cv.full(), "the budget must stop a file that keeps selecting large clip paths"); + assert!(selected * big * std::mem::size_of::() <= 2 * MAX_BYTES); +} + +#[test] +fn abort_path_discards_the_bracket() { + let recs = [erec(59, &[]), rect(0, 0, 10, 10), erec(68, &[]), erec(62, &[0; 16]), rect(0, 0, 5, 5)]; + let p = parse_emf(&recs); + assert_eq!(p.items.len(), 1, "only the rectangle drawn after the bracket was aborted"); + assert_eq!((p.width, p.height), (5.0, 5.0)); +} + +#[test] +fn arc_direction_picks_the_other_part_of_the_ellipse() { + // From the right-most point to the top-most one: counter-clockwise is the quarter between + // them, clockwise the three quarters round the other way. + let arc = erec(45, &cat(vec![e32(0), e32(0), e32(100), e32(100), e32(100), e32(50), e32(50), e32(0)])); + let ccw = parse_emf(std::slice::from_ref(&arc)); + assert!(close(ccw.width, 50.0) && close(ccw.height, 50.0), "{} x {}", ccw.width, ccw.height); + let cw = parse_emf(&[erec(57, &h32(2)), arc.clone()]); + assert!(close(cw.width, 100.0) && close(cw.height, 100.0), "{} x {}", cw.width, cw.height); + // RESTOREDC brings the direction back. + let p = parse_emf(&[erec(33, &[]), erec(57, &h32(2)), erec(34, &e32(-1)), arc]); + assert!(close(p.width, 50.0)); +} + +#[test] +fn scale_viewport_and_window_ext_change_the_mapping() { + let setup = [erec(17, &h32(8)), erec(9, &cat(vec![e32(100), e32(100)])), erec(11, &cat(vec![e32(100), e32(100)]))]; + let scaled = |typ: u32, v: [i32; 4]| { + let mut recs = setup.to_vec(); + recs.push(erec(typ, &cat(v.iter().map(|x| e32(*x)).collect()))); + recs.push(rect(0, 0, 10, 10)); + let p = parse_emf(&recs); + (p.width, p.height) + }; + assert_eq!(scaled(31, [2, 1, 3, 1]), (20.0, 30.0), "SCALEVIEWPORTEXTEX"); + assert_eq!(scaled(32, [2, 1, 5, 1]), (5.0, 2.0), "SCALEWINDOWEXTEX"); + assert_eq!(scaled(31, [2, 0, 3, 1]), (10.0, 10.0), "a zero denominator leaves the extent"); +} + +#[test] +fn stroke_and_fill_path_closes_open_figures_but_stroke_path_does_not() { + let figure = |paint: u32| { + let recs = + [erec(59, &[]), move_to(0, 0), line_to(10, 0), line_to(10, 10), move_to(20, 0), line_to(30, 0), erec(60, &[]), erec(paint, &[0; 16])]; + let p = parse_emf(&recs); + path_segs(&p.items[0]).0.clone() + }; + let open = vec![Seg::Move(0.0, 0.0), Seg::Line(10.0, 0.0), Seg::Line(10.0, 10.0), Seg::Move(20.0, 0.0), Seg::Line(30.0, 0.0)]; + assert_eq!(figure(64), open, "STROKEPATH"); + assert_eq!(figure(62), open, "FILLPATH: a fill closes by itself"); + let closed = + vec![Seg::Move(0.0, 0.0), Seg::Line(10.0, 0.0), Seg::Line(10.0, 10.0), Seg::Close, Seg::Move(20.0, 0.0), Seg::Line(30.0, 0.0), Seg::Close]; + assert_eq!(figure(63), closed, "STROKEANDFILLPATH"); +} + +#[test] +fn close_figure_moves_the_current_position_to_the_figures_start() { + let recs = [erec(59, &[]), move_to(0, 0), line_to(10, 0), line_to(10, 10), erec(61, &[]), erec(60, &[]), erec(64, &[0; 16]), line_to(5, 20)]; + let p = parse_emf(&recs); + assert_eq!(path_segs(&p.items[1]).0, &vec![Seg::Move(0.0, 0.0), Seg::Line(5.0, 20.0)]); +} + +#[test] +fn records_of_unknown_types_are_counted_and_known_ones_are_not() { + // 200 is no EMF record; 21 (SETSTRETCHBLTMODE) and 70 (a plain comment) change nothing kept. + let recs = [rect(0, 0, 10, 10), erec(200, &[0; 8]), erec(21, &h32(1)), erec(70, &cat(vec![h32(4), h32(0)])), erec(201, &[])]; + assert_eq!(parse_emf(&recs).skipped_records, 2); + let rect16 = wrec(0x041B, &cat(vec![h16(10), h16(10), h16(0), h16(0)])); + let recs = [rect16, wrec(0x0999, &[0; 4]), wrec(0x0107, &h16(1))]; + assert_eq!(parse(&wmf(&recs, None)).unwrap().skipped_records, 1); +} + +#[test] +fn emf_header_micrometres_set_the_device_resolution() { + // The 88-byte header says 4 pixels a millimetre; the extension says 5 (200 mm for 1000 pixels). + // A 25.4 mm frame is then 127 pixels wide, and a 127-pixel rectangle fills its 72 points. + let short = emf(&[rect(0, 0, 127, 10)], [0, 0, 127, 10], [0, 0, 2540, 1270]); + let mut file = short[..88].to_vec(); + file.extend([0u8; 12]); + file.extend(200_000u32.to_le_bytes()); + file.extend(100_000u32.to_le_bytes()); + file.extend(&short[88..]); + file[4..8].copy_from_slice(&108u32.to_le_bytes()); + let right = |bytes: &[u8]| { + let p = parse(bytes).unwrap(); + assert!(close(p.width, 72.0)); + path_segs(&p.items[0]).0.iter().filter_map(|s| if let Seg::Line(x, _) = s { Some(*x) } else { None }).fold(0.0, f32::max) + }; + assert!(close(right(&file), 72.0), "{}", right(&file)); + assert!(close(right(&short), 90.0), "without the extension the millimetres stand"); + // A zero in the extension falls back to the millimetres. + file[100..104].copy_from_slice(&0u32.to_le_bytes()); + assert!(close(right(&file), 90.0)); + assert_eq!(crate::size(&file).unwrap(), (parse(&file).unwrap().width, parse(&file).unwrap().height)); +} + +/// STRETCHDIB of a 2 x 2 source into 20 x 10 with `dib` as the packed bitmap. +fn stretch_dib(dib: Vec) -> Vec { + wmf(&[wrec(0x0F43, &cat(vec![h32(0x00CC_0020), h16(0), h16(2), h16(2), h16(0), h16(0), h16(10), h16(20), h16(0), h16(0), dib]))], None) +} + +#[test] +fn core_header_dibs_decode() { + // BITMAPCOREHEADER: size 12, 16-bit width and height, planes, bits per pixel; rows bottom-up. + let core = |bpp: u16| cat(vec![h32(12), h16(2), h16(2), h16(1), h16(i32::from(bpp))]); + let mut dib = core(24); + dib.extend(dib24_bottom_up().get(40..).unwrap_or_default()); + let p = parse(&stretch_dib(dib)).unwrap(); + let Item::Bitmap { width, height, rgba, .. } = &p.items[0] else { panic!("expected a bitmap") }; + assert_eq!((*width, *height), (2, 2)); + assert_eq!(rgba.as_slice(), DIB24_RGBA); + // 1 bpp: two palette entries of three bytes (blue, green, red), then padded rows. + let mut dib = core(1); + dib.extend([10, 20, 30, 40, 50, 60]); + dib.extend([0b0100_0000, 0, 0, 0, 0b1000_0000, 0, 0, 0]); + let p = parse(&stretch_dib(dib)).unwrap(); + let Item::Bitmap { rgba, .. } = &p.items[0] else { panic!("expected a bitmap") }; + let (a, b) = ([30, 20, 10, 255], [60, 50, 40, 255]); + assert_eq!(rgba.as_slice(), [b, a, a, b].concat(), "the file's second row is the top one"); + // 16 bpp is not a core-header depth. + assert!(parse(&stretch_dib(core(16))).unwrap().items.is_empty()); +} + +fn png(w: u32, h: u32, px: [u8; 4]) -> Vec { + let mut out = Vec::new(); + let img = image::RgbaImage::from_pixel(w, h, image::Rgba(px)); + image::DynamicImage::ImageRgba8(img).write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap(); + out +} + +/// EMR_STRETCHDIBITS of the whole `w` x `h` source into (0, 0, 30, 10) with a header of +/// `compression` and `bits`. +fn stretch_dibits(w: i32, h: i32, compression: u32, bits: &[u8]) -> Vec { + let mut info = info_header(w, h, 32, bits.len() as u32); + info[16..20].copy_from_slice(&compression.to_le_bytes()); + let mut payload = vec![0u8; 16]; + let fields = [0, 0, 0, 0, w, h, 80, 40, 120, bits.len() as i32, 0, 0x00CC_0020, 30, 10]; + payload.extend(cat(fields.iter().map(|v| e32(*v)).collect())); + payload.extend(info); + payload.extend(bits); + erec(81, &payload) +} + +#[test] +fn png_and_jpeg_dibs_decode() { + let p = parse_emf(&[stretch_dibits(3, 1, 5, &png(3, 1, [1, 2, 3, 255]))]); + let Item::Bitmap { rect, width, height, rgba, .. } = &p.items[0] else { panic!("expected a bitmap") }; + assert_eq!((*width, *height, rect), (3, 1, &[0.0, 0.0, 30.0, 10.0])); + assert_eq!(rgba.as_slice(), [1, 2, 3, 255].repeat(3)); + let mut jpeg = Vec::new(); + let img = image::RgbImage::from_pixel(8, 8, image::Rgb([200, 200, 200])); + image::DynamicImage::ImageRgb8(img).write_to(&mut std::io::Cursor::new(&mut jpeg), image::ImageFormat::Jpeg).unwrap(); + let p = parse_emf(&[stretch_dibits(8, 8, 4, &jpeg)]); + let Item::Bitmap { width, height, rgba, .. } = &p.items[0] else { panic!("expected a bitmap") }; + assert_eq!((*width, *height), (8, 8)); + assert!(rgba.as_chunks::<4>().0.iter().all(|c| c[0].abs_diff(200) < 4 && c[3] == 255)); + // Bits that are no image, and a truncated file, draw nothing. + assert!(parse_emf(&[stretch_dibits(3, 1, 5, &[1, 2, 3, 4])]).items.is_empty()); + let whole = png(3, 1, [1, 2, 3, 255]); + assert!(parse_emf(&[stretch_dibits(3, 1, 5, &whole[..whole.len() / 2])]).items.is_empty()); +} + +#[test] +fn a_compressed_dib_over_the_side_cap_is_refused_before_decoding() { + let side = crate::dib::MAX_COMPRESSED_SIDE; + assert!(crate::dib::decode_compressed(&png(side, 1, [0; 4])).is_some()); + assert!(crate::dib::decode_compressed(&png(side + 1, 1, [0; 4])).is_none()); + // A side no renderer draws (MAX_BITMAP_SIDE) makes no item either. + let wide = png(crate::MAX_BITMAP_SIDE + 1, 1, [0; 4]); + assert!(parse_emf(&[stretch_dibits(crate::MAX_BITMAP_SIDE as i32 + 1, 1, 5, &wide)]).items.is_empty()); +} + +#[test] +fn placed_paths_carry_their_line_style_and_shared_clips() { + let mut recs = clip_to_rect(0, 0, 50, 50, 1); + recs.extend([ext_pen(PS_GEOMETRIC | 7 | 0x100, 2, &[6, 3]), erec(37, &h32(1)), rect(0, 0, 100, 100), rect(10, 10, 20, 20)]); + let p = parse_emf(&recs); + assert_eq!((p.width, p.height), (100.0, 100.0)); + let placed = p.place(kurbo::Rect::new(0.0, 0.0, 200.0, 200.0), [0.0; 4]); + let (PlacedItem::Path { line, clips: a, stroke, .. }, PlacedItem::Path { clips: b, .. }) = (&placed[0], &placed[1]) else { + panic!("expected two paths") + }; + assert_eq!(stroke.map(|s| s.1), Some(4.0)); + assert_eq!((line.cap, &line.dash), (Cap::Square, &vec![12.0, 6.0]), "dashes scale with the width"); + assert!(Arc::ptr_eq(&a[0], &b[0])); + assert_eq!(kurbo::Shape::bounding_box(&a[0].path), kurbo::Rect::new(0.0, 0.0, 100.0, 100.0)); + assert!(a[0].even_odd); +} + +#[test] +fn an_item_under_a_clip_far_off_the_page_is_left_out() { + let clip = Arc::new(ClipPath { segs: vec![Seg::Move(0.0, 0.0), Seg::Line(f32::MAX, 0.0), Seg::Line(0.0, 10.0), Seg::Close], even_odd: false }); + let bitmap = |clips| Item::Bitmap { + rect: [0.0, 0.0, 10.0, 10.0], + transform: [10.0, 0.0, 0.0, 10.0, 0.0, 0.0], + width: 1, + height: 1, + rgba: vec![0; 4], + region: None, + clips, + }; + let pic = crate::Picture { width: 10.0, height: 10.0, items: vec![bitmap(vec![clip]), bitmap(Vec::new())], skipped_records: 0 }; + let placed = pic.place(kurbo::Rect::new(0.0, 0.0, 10.0, 10.0), [0.0; 4]); + assert_eq!(placed.len(), 1); +} + +#[test] +fn an_upright_bitmap_has_its_rectangle_as_its_transform() { + let p = parse(&stretch_dib(dib24_bottom_up())).unwrap(); + let Item::Bitmap { rect, transform, .. } = &p.items[0] else { panic!("expected a bitmap") }; + assert_eq!((rect, transform), (&[0.0, 0.0, 20.0, 10.0], &[20.0, 0.0, 0.0, 10.0, 0.0, 0.0])); +} + +#[test] +fn a_turned_bitmap_keeps_its_pixels_and_carries_the_turn() { + // A quarter turn (x' = -y, y' = x) moved 10 right: the 30 x 10 destination stands 10 x 30. + let xf: Vec = [0.0f32, 1.0, -1.0, 0.0, 10.0, 0.0].iter().flat_map(|v| v.to_le_bytes()).collect(); + let pixels = png(3, 1, [9, 8, 7, 255]); + let p = parse(&emf(&[erec(35, &xf), stretch_dibits(3, 1, 5, &pixels)], [0, 0, 10, 30], [0, 0, 2540, 7620])).unwrap(); + let Item::Bitmap { transform, width, height, .. } = &p.items[0] else { panic!("expected a bitmap") }; + assert_eq!((*width, *height), (3, 1), "the pixels are not resampled"); + // 4 device pixels a millimetre and a 25.4 x 76.2 mm frame of 72 x 216 points: 0.709 points a pixel. + let k = 72.0 / 101.6; + let want = [0.0, 30.0 * k, -10.0 * k, 0.0, 10.0 * k, 0.0]; + assert!(transform.iter().zip(want).all(|(a, b)| (a - b).abs() < 1e-9), "{transform:?}"); + let placed = p.place(kurbo::Rect::new(0.0, 0.0, 72.0, 216.0), [0.0; 4]); + let PlacedItem::Bitmap { transform, .. } = &placed[0] else { panic!("expected a bitmap") }; + let corner = *transform * kurbo::Point::new(1.0, 1.0); + assert!((corner.x - 0.0).abs() < 1e-6 && (corner.y - 30.0 * k).abs() < 1e-6, "{corner:?}"); +} + +#[test] +fn compressed_bitmaps_share_one_decode_budget_per_file() { + let small = png(3, 1, [0, 0, 0, 255]); + crate::dib::set_decode_budget(7); + assert!(crate::dib::decode_compressed(&small).is_some()); + assert!(crate::dib::decode_compressed(&small).is_some()); + assert!(crate::dib::decode_compressed(&small).is_none(), "one pixel left: the third image is not decoded"); + // Every parse starts with the whole budget again. + let p = parse_emf(&[stretch_dibits(3, 1, 5, &small)]); + assert_eq!(p.items.len(), 1); +} + +#[test] +fn truncated_and_mutated_files_with_these_records_never_panic() { + let mut recs = vec![ + ext_pen(PS_GEOMETRIC | 7 | 0x200 | 0x2000, 4, &[8, 4, 2, 1]), + erec(37, &h32(1)), + erec(58, &h32(3)), + erec(57, &h32(2)), + erec(17, &h32(8)), + erec(9, &cat(vec![e32(100), e32(100)])), + erec(11, &cat(vec![e32(100), e32(100)])), + erec(31, &cat(vec![e32(2), e32(1), e32(3), e32(1)])), + erec(32, &cat(vec![e32(1), e32(2), e32(1), e32(3)])), + erec(33, &[]), + ]; + recs.extend(clip_to_rect(0, 0, 50, 50, 1)); + recs.extend(clip_to_rect(5, 5, 40, 40, 5)); + recs.extend([ + erec(45, &cat(vec![e32(0), e32(0), e32(100), e32(100), e32(100), e32(50), e32(50), e32(0)])), + erec(59, &[]), + move_to(0, 0), + line_to(10, 0), + line_to(10, 10), + erec(61, &[]), + erec(60, &[]), + erec(63, &[0; 16]), + erec(59, &[]), + rect(0, 0, 5, 5), + erec(68, &[]), + stretch_dibits(3, 1, 5, &png(3, 1, [1, 2, 3, 255])), + erec(34, &e32(-1)), + erec(200, &[0; 8]), + ]); + let efile = emf(&recs, [0, 0, 100, 100], [0, 0, 2540, 2540]); + let mut core = cat(vec![h32(12), h16(2), h16(2), h16(1), h16(24)]); + core.extend(dib24_bottom_up().get(40..).unwrap_or_default()); + let wfile = stretch_dib(core); + assert!(!parse(&efile).unwrap().items.is_empty() && !parse(&wfile).unwrap().items.is_empty()); + let mut rng = Lcg(0x9E37_79B9_7F4A_7C15); + for file in [&efile, &wfile] { + for n in 0..=file.len() { + let _ = parse(&file[..n]); + let _ = crate::size(&file[..n]); + } + for at in 0..file.len() { + for v in [0x00, 0x7F, 0x80, 0xFF] { + let mut bad = file.clone(); + bad[at] = v; + let _ = parse(&bad); + } + } + for _ in 0..2000 { + let mut bad = file.clone(); + for _ in 0..6 { + let at = rng.rnd() as usize % bad.len(); + bad[at] = rng.rnd() as u8; + } + if let Ok(p) = parse(&bad) { + let _ = p.place(kurbo::Rect::new(0.0, 0.0, 100.0, 100.0), [0.0; 4]); + } + } + } +} + +#[test] +fn a_turned_bitmaps_rectangle_is_the_box_around_it() { + let xf: Vec = [0.0f32, 1.0, -1.0, 0.0, 10.0, 0.0].iter().flat_map(|v| v.to_le_bytes()).collect(); + let p = parse(&emf(&[erec(35, &xf), stretch_dibits(3, 1, 5, &png(3, 1, [9, 8, 7, 255]))], [0, 0, 10, 30], [0, 0, 2540, 7620])).unwrap(); + let Item::Bitmap { rect, .. } = &p.items[0] else { panic!("expected a bitmap") }; + let k = 72.0 / 101.6; + assert!(rect.iter().zip([0.0, 0.0, 10.0 * k, 30.0 * k]).all(|(a, b)| (f64::from(*a) - b).abs() < 1e-4), "{rect:?}"); +} diff --git a/crates/metafile/src/tests/played.rs b/crates/metafile/src/tests/played.rs new file mode 100644 index 000000000..46eb23bca --- /dev/null +++ b/crates/metafile/src/tests/played.rs @@ -0,0 +1,220 @@ +//! Hand-built files from VectorCraft's reader tests, played into the picture model: what GDI +//! draws for them is what the model must hold. + +use super::*; +use crate::{Cap, Kind, sniff}; + +/// An EMF of `records` (type, body) with a `w` x `h` frame in 0.01 mm and a reference device of +/// 100 pixels a millimetre, so one device unit is 0.01 mm. +fn hand_emf(w: i32, h: i32, records: &[(u32, Vec)]) -> Vec { + let mut head = vec![0u8; 16]; + head.extend(le(&[0, 0, w, h])); + head.extend(0x464D_4520u32.to_le_bytes()); + head.extend(0x10000u32.to_le_bytes()); + head.extend([0; 8]); + head.extend([1, 0, 0, 0]); + head.extend([0; 12]); + head.extend(le(&[10000, 10000, 100, 100])); + let mut out = Vec::new(); + let eof = (14, vec![0, 0, 0, 0, 16, 0, 0, 0, 20, 0, 0, 0]); + for (kind, body) in [(1, head)].iter().chain(records).chain([&eof]) { + out.extend(kind.to_le_bytes()); + out.extend((8 + body.len() as u32).to_le_bytes()); + out.extend(body); + } + out +} + +fn le(values: &[i32]) -> Vec { + values.iter().flat_map(|v| v.to_le_bytes()).collect() +} + +fn le16(values: &[i16]) -> Vec { + values.iter().flat_map(|v| v.to_le_bytes()).collect() +} + +/// A placeable WMF of `records` (function, parameters) in a `w` x `h` box at 1440 units an inch. +fn hand_wmf(w: i16, h: i16, records: &[(u16, Vec)]) -> Vec { + let mut o = 0x9AC6_CDD7u32.to_le_bytes().to_vec(); + o.extend(le16(&[0, 0, 0, w, h, 1440])); + o.extend([0; 4]); + let sum = o.as_chunks::<2>().0.iter().fold(0u16, |a, w| a ^ u16::from_le_bytes(*w)); + o.extend(sum.to_le_bytes()); + o.extend([1, 0, 9, 0, 0, 3]); + o.extend([0; 12]); + for (f, body) in records.iter().chain(&[(0, vec![])]) { + o.extend((3 + body.len() as u32 / 2).to_le_bytes()); + o.extend(f.to_le_bytes()); + o.extend(body); + } + o +} + +/// The bounding box `[x0, y0, x1, y1]` of a path item's points. +fn bounds(item: &Item) -> [f32; 4] { + let (segs, ..) = path_segs(item); + let mut b = [f32::MAX, f32::MAX, f32::MIN, f32::MIN]; + let mut grow = |x: f32, y: f32| b = [b[0].min(x), b[1].min(y), b[2].max(x), b[3].max(y)]; + for s in segs { + match *s { + Seg::Move(x, y) | Seg::Line(x, y) => grow(x, y), + Seg::Cubic(x1, y1, x2, y2, x, y) => { + grow(x1, y1); + grow(x2, y2); + grow(x, y); + } + Seg::Close => {} + } + } + b +} + +fn near(a: [f32; 4], b: [f64; 4], tol: f64) -> bool { + a.iter().zip(b).all(|(a, b)| (f64::from(*a) - b).abs() <= tol) +} + +const MM: f64 = 72.0 / 25.4; + +#[test] +fn hand_built_emf_plays_like_gdi() { + let mut text = vec![0; 16]; + text.extend(le(&[1, 0, 0])); + // At (1000, 1500), 2 characters at offset 76 (from the record start). + text.extend(le(&[1000, 1500, 2, 76, 0, 0, 0, 0, 0, 0])); + text.extend("Hi".encode_utf16().flat_map(u16::to_le_bytes)); + let mut font = le(&[2, -1000, 0, 0, 0, 700]); + font.extend([0u8; 8]); + font.extend("Arial".encode_utf16().chain(std::iter::repeat_n(0, 27)).flat_map(u16::to_le_bytes)); + let records = vec![ + // A green brush in slot 1, selected; a null pen. + (39, le(&[1, 0, 0x00_ff_00, 0])), + (37, le(&[1])), + (37, le(&[0x8000_0008u32 as i32])), + // A rectangle of 10 x 5 mm at (10, 10) mm. + (43, le(&[1000, 1000, 2000, 1500])), + // A record nobody knows, and one of a kind not read (ANGLEARC). + (9999, le(&[1, 2])), + (41, le(&[0, 0, 10, 0, 0])), + // Clip to a rectangle; a black pen draws two lines one after the other. + (30, le(&[0, 0, 1500, 1500])), + (37, le(&[0x8000_0007u32 as i32])), + (27, le(&[0, 0])), + (54, le(&[500, 0])), + (54, le(&[500, 500])), + // A 10 mm bold font and red text. + (82, font), + (37, le(&[2])), + (24, le(&[0xff])), + (84, text), + ]; + let file = hand_emf(5000, 3000, &records); + assert_eq!(sniff(&file), Some(Kind::Emf)); + let p = parse(&file).unwrap(); + assert!((f64::from(p.width) - 50.0 * MM).abs() < 0.01 && (f64::from(p.height) - 30.0 * MM).abs() < 0.01); + assert_eq!(p.skipped_records, 2); + assert_eq!(p.items.len(), 4, "the rectangle, two lines and the text"); + let (_, fill, stroke, _) = path_segs(&p.items[0]); + assert_eq!((fill, stroke), (Some([0, 255, 0, 255]), None), "green, with the null pen"); + assert!(near(bounds(&p.items[0]), [10.0 * MM, 10.0 * MM, 20.0 * MM, 15.0 * MM], 0.01)); + let Item::Path { region: None, .. } = &p.items[0] else { panic!("drawn before the clip") }; + // The lines and the text are clipped to the rectangle. + for item in &p.items[1..3] { + let Item::Path { region: Some(r), fill: None, stroke: Some(([0, 0, 0, 255], _)), .. } = item else { panic!("a clipped black line") }; + assert!(r.len() == 1 && near(r[0], [0.0, 0.0, 15.0 * MM, 15.0 * MM], 0.01)); + } + assert!(near(bounds(&p.items[1]), [0.0, 0.0, 5.0 * MM, 0.0], 0.01)); + assert!(near(bounds(&p.items[2]), [5.0 * MM, 0.0, 5.0 * MM, 5.0 * MM], 0.01)); + let Item::Text(t) = &p.items[3] else { panic!("text") }; + assert_eq!((t.text.as_str(), t.font.family.as_str(), t.font.weight), ("Hi", "Arial", 700)); + assert_eq!(t.color, [255, 0, 0, 255]); + // The em is 1000 text units of 0.01 mm: 10 mm. + let em = f64::from(-t.font.height) * t.transform[3]; + assert!((em - 10.0 * MM).abs() < 0.01, "{em}"); + assert!((t.transform[4] - 10.0 * MM).abs() < 0.01 && (t.transform[5] - 15.0 * MM).abs() < 0.01); + assert!(t.region.as_ref().is_some_and(|r| r.len() == 1)); +} + +#[test] +fn emf_mapping_modes_and_world_transforms() { + let records = vec![ + // MM_ANISOTROPIC: a 100 x 100 window on a 1000 x 2000 viewport. + (17, le(&[8])), + (9, le(&[100, 100])), + (11, le(&[1000, 2000])), + (43, le(&[0, 0, 10, 10])), + // Then a world transform moving by (5, 0) logical units. + (35, [1.0f32, 0.0, 0.0, 1.0, 5.0, 0.0].iter().flat_map(|v| v.to_le_bytes()).collect()), + (43, le(&[0, 0, 10, 10])), + ]; + let p = parse(&hand_emf(10000, 10000, &records)).unwrap(); + // 10 units are 100 x 200 pixels: 1 x 2 mm. + assert!(near(bounds(&p.items[0]), [0.0, 0.0, MM, 2.0 * MM], 0.01)); + assert!(near(bounds(&p.items[1]), [0.5 * MM, 0.0, 1.5 * MM, 2.0 * MM], 0.01)); +} + +#[test] +fn hand_built_wmf_plays_like_gdi() { + let records = vec![ + // A blue brush (slot 0), a 20-unit red pen with square caps (slot 1), selected. + (0x02FC, [le16(&[0]), 0x00ff_0000u32.to_le_bytes().to_vec(), le16(&[0])].concat()), + (0x02FA, [le16(&[0x100, 20, 0]), 0x0000_00ffu32.to_le_bytes().to_vec()].concat()), + (0x012D, le16(&[0])), + (0x012D, le16(&[1])), + // A rectangle (bottom, right, top, left): 1 x 0.5 inches at the origin. + (0x041B, le16(&[720, 1440, 0, 0])), + (0x0999, le16(&[1, 2])), + // Delete the brush: the next object takes slot 0 again. + (0x01F0, le16(&[0])), + (0x02FC, [le16(&[0]), 0x0000_ff00u32.to_le_bytes().to_vec(), le16(&[0])].concat()), + (0x012D, le16(&[0])), + (0x0418, le16(&[1440, 1440, 720, 0])), + ]; + let file = hand_wmf(1440, 1440, &records); + assert_eq!(sniff(&file), Some(Kind::Wmf)); + let p = parse(&file).unwrap(); + assert_eq!((p.width, p.height), (72.0, 72.0)); + assert_eq!(p.skipped_records, 1); + assert_eq!(p.items.len(), 2); + let Item::Path { fill, stroke, line, .. } = &p.items[0] else { panic!("a path") }; + assert_eq!(*fill, Some([0, 0, 255, 255])); + assert!(near(bounds(&p.items[0]), [0.0, 0.0, 72.0, 36.0], 1e-4)); + assert_eq!((*stroke, line.cap), (Some(([255, 0, 0, 255], 1.0)), Cap::Square), "20 units of 1/1440 inch are a point"); + assert_eq!(path_segs(&p.items[1]).1, Some([0, 255, 0, 255]), "the new brush took the freed slot"); +} + +#[test] +fn wmf_window_is_mapped_onto_the_placeable_box() { + // The box is 1000 x 500 units at 1440 an inch: 50 x 25 pt. + let want = [12.5, 6.25, 37.5, 18.75]; + // Window 0,0 - 4000 x 2000: the rectangle 1000,500 - 3000,1500 is the middle half. + let ext = vec![(0x020C, le16(&[2000, 4000])), (0x041B, le16(&[1500, 3000, 500, 1000]))]; + // Window 10000,20000 - 1000 x 500 (the box): the rectangle 10250,20125 - 10750,20375. + let org = vec![(0x020B, le16(&[20000, 10000])), (0x020C, le16(&[500, 1000])), (0x041B, le16(&[20375, 10750, 20125, 10250]))]; + // Window 0,2000 - 4000 x -2000 (y up): the same rectangle, flipped onto the same place. + let flip = vec![(0x020B, le16(&[2000, 0])), (0x020C, le16(&[-2000, 4000])), (0x041B, le16(&[1500, 3000, 500, 1000]))]; + for records in [ext, org, flip] { + let p = parse(&hand_wmf(1000, 500, &records)).unwrap(); + assert_eq!((p.width, p.height), (50.0, 25.0)); + assert_eq!(p.items.len(), 1); + assert!(near(bounds(&p.items[0]), want, 1e-4), "{:?}", bounds(&p.items[0])); + } +} + +#[test] +fn huge_counts_sizes_and_offsets_are_refused_not_allocated() { + let huge = hand_emf(100, 100, &[(3, le(&[0, 0, 0, 0, i32::MAX])), (81, le(&[0; 18])), (8, le(&[0, 0, 0, 0, i32::MAX, i32::MAX]))]); + assert!(parse(&huge).unwrap().items.is_empty()); + let huge = hand_wmf(100, 100, &[(0x0324, le16(&[i16::MAX])), (0x0538, le16(&[-1]))]); + assert!(parse(&huge).unwrap().items.is_empty()); + assert!(parse(b"not a metafile").is_err()); +} + +#[test] +fn a_run_of_clip_rectangles_stays_one_region() { + // 200,000 INTERSECTCLIPRECT records nest nothing: the region is still one rectangle. + let mut records = vec![(30, le(&[0, 0, 1500, 1500])); 200_000]; + records.push((43, le(&[100, 100, 200, 200]))); + let p = parse(&hand_emf(5000, 3000, &records)).unwrap(); + let Item::Path { region: Some(r), clips, .. } = &p.items[0] else { panic!("a clipped path") }; + assert_eq!((r.len(), clips.len()), (1, 0)); +} diff --git a/crates/metafile/src/text.rs b/crates/metafile/src/text.rs new file mode 100644 index 000000000..53d13e5b4 --- /dev/null +++ b/crates/metafile/src/text.rs @@ -0,0 +1,205 @@ +//! 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]>, + /// Clip region from the device context, in picture units. None is the default clip. Drawn + /// together with [`Text::clip`] when both are set. + pub region: Option>, + /// Clip paths from the device context, as on [`crate::Item::Path`]. + pub clips: Vec>, +} + +/// 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 new file mode 100644 index 000000000..dc24fa198 --- /dev/null +++ b/crates/metafile/src/wmf.rs @@ -0,0 +1,391 @@ +//! 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::{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) { + return true; + } + matches!(b.u16(0), Some(1 | 2)) && b.u16(2) == Some(9) && matches!(b.u16(4), Some(0x100 | 0x300)) +} + +/// Reads all records. Returns the drawing state and the picture frame, or an error when no record +/// could be read at all. +pub(crate) fn parse(input: Bytes) -> Result<(Canvas, Option), Error> { + let placeable = input.u32(0) == Some(PLACEABLE_KEY); + // Placeable bounds: logical corners (left, top, right, bottom) and their size in points. + let (hdr, bounds) = if placeable { + let [l, t, r, b] = [6, 8, 10, 12].map(|o| input.i16(o).map(f64::from)); + let (Some(l), Some(t), Some(r), Some(b)) = (l, t, r, b) else { return Err(Error::BadHeader) }; + let inch = input.u16(14).filter(|v| *v > 0).map_or(DEFAULT_INCH, f64::from); + let (w, h) = (r - l, b - t); + let bounds = (w > 0.0 && h > 0.0).then_some(([l, t, r, b], (w * 72.0 / inch, h * 72.0 / inch))); + (22, bounds) + } else { + (0, None) + }; + let hsize = input.u16(hdr + 2).ok_or(Error::BadHeader)? as usize; + if hsize < 9 { + return Err(Error::BadHeader); + } + let mut pos = hdr.checked_add(hsize.checked_mul(2).ok_or(Error::BadHeader)?).ok_or(Error::BadHeader)?; + // Played into a target like Word does: the placeable bounds are the first window, the target (the + // picture size) the viewport. A plain WMF's target is its first window. + let mut cv = Canvas::new(); + if let Some((c, size)) = bounds { + cv.play_into(Some([c[0], c[1], c[2] - c[0], c[3] - c[1]]), size); + cv.set_clip_surface([0.0, 0.0, size.0, size.1]); + cv.pattern_px = PT_PER_PX; + } + let mut player_viewport = bounds.is_some(); + let mut parsed = 0usize; + while !cv.full() { + let (Some(words), Some(func)) = (input.u32(pos), pos.checked_add(4).and_then(|o| input.u16(o))) else { break }; + let Some(len) = (words as usize).checked_mul(2).filter(|l| *l >= 6) else { break }; + let Some(rec) = input.slice(pos, len) else { break }; + if func == 0 { + break; + } + record(&mut cv, func, Bytes(rec)); + if func == 0x020C && cv.window_set && !player_viewport && !cv.viewport_set { + let we = cv.map.window_ext(); + cv.play_into(None, (we.0.abs(), we.1.abs())); + player_viewport = true; + } + parsed += 1; + let Some(next) = pos.checked_add(len) else { break }; + pos = next; + } + if parsed == 0 { + return Err(Error::NoRecords); + } + let frame = match bounds { + // Played into its target: the frame is the target. + Some((_, size)) if !cv.viewport_set && cv.map.uses_extents() => Some(Frame { rect: [0.0, 0.0, size.0, size.1], size: Some(size) }), + // The file set its own viewport, so the drawing is in its device units and the logical bounds + // go through the final mapping. + Some((c, size)) => { + let (a, b) = (cv.map.device((c[0], c[1])), cv.map.device((c[2], c[3]))); + let rect = [a.0.min(b.0), a.1.min(b.1), (b.0 - a.0).abs(), (b.1 - a.1).abs()]; + (rect[2] > 0.0 && rect[3] > 0.0).then_some(Frame { rect, size: Some(size) }) + } + None if player_viewport && !cv.viewport_set && cv.map.uses_extents() => { + let ve = cv.map.viewport_ext(); + Some(Frame { rect: [0.0, 0.0, ve.0, ve.1], size: None }) + } + None if cv.window_set => Some(Frame { rect: cv.map.window_rect(), size: None }), + None => None, + }; + Ok((cv, frame)) +} + +fn record(cv: &mut Canvas, func: u16, r: Bytes) { + if cv.full() { + return; + } + let _ = match func { + 0x0103 => r.u16(6).map(|m| cv.set_map_mode(u32::from(m))), + 0x0106 => r.u16(6).map(|m| cv.set_polyfill(u32::from(m))), + 0x0104 => r.u16(6).map(|m| cv.set_rop2(u32::from(m))), + // Y is stored before X in these records. + 0x020B => xy16(r, 6).map(|p| cv.set_window_org(p)), + 0x020C => xy16(r, 6).map(|p| cv.set_window_ext(p)), + 0x020D => xy16(r, 6).map(|p| cv.set_viewport_org(p)), + 0x020E => xy16(r, 6).map(|p| cv.set_viewport_ext(p)), + 0x001E => { + cv.save_dc(); + Some(()) + } + 0x0127 => r.i16(6).map(|n| cv.restore_dc(i32::from(n))), + 0x0214 => xy16(r, 6).map(|p| cv.move_to(p)), + 0x0213 => xy16(r, 6).map(|p| cv.line_to(p)), + 0x0325 => counted_points(r).map(|pts| cv.poly(&pts, false, false, true)), + 0x0324 => counted_points(r).map(|pts| cv.poly(&pts, true, true, true)), + 0x0538 => polypolygon(r).map(|ops| cv.shape(ops, true, true)), + 0x041B => rect16(r, 6).map(|[l, t, rr, b]| cv.shape(geom::rect(l, t, rr, b), true, true)), + 0x061C => { + let [ew, eh] = [r.i16(8), r.i16(6)]; + let (Some(ew), Some(eh)) = (ew, eh) else { return }; + rect16(r, 10).map(|[l, t, rr, b]| { + cv.shape(geom::round_rect(l, t, rr, b, f64::from(ew), f64::from(eh)), true, true); + }) + } + 0x0415 => rect16(r, 6).map(|rect| cv.exclude_clip_rect(rect)), + 0x0416 => rect16(r, 6).map(|rect| cv.intersect_clip_rect(rect)), + 0x0418 => rect16(r, 6).map(|[l, t, rr, b]| cv.shape(geom::ellipse(l, t, rr, b), true, true)), + 0x0817 | 0x081A | 0x0830 => arc16(r).map(|(bounds, s, e)| { + let kind = match func { + 0x0817 => ArcKind::Arc, + 0x081A => ArcKind::Pie, + _ => ArcKind::Chord, + }; + let [l, t, rr, b] = bounds; + cv.shape(geom::arc_shape(l, t, rr, b, s, e, kind), kind != ArcKind::Arc, true); + }), + 0x02FA => create_pen(cv, r), + 0x02FC => create_brush(cv, r), + 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. + 0x0F43 => { + let rop = r.u32(6).unwrap_or(0); + dib_blit(cv, r, 28, rop, [i16s(r, 26), i16s(r, 24), i16s(r, 22), i16s(r, 20)], [i16s(r, 18), i16s(r, 16), i16s(r, 14), i16s(r, 12)]) + } + 0x0B41 => dib_stretchblt(cv, r), + 0x0940 => dib_bitblt(cv, r), + f if IGNORED.contains(&f) => Some(()), + _ => { + cv.skipped = cv.skipped.saturating_add(1); + Some(()) + } + }; +} + +/// Records that change nothing the picture keeps: relative-absolute and stretch modes, character +/// spacing and justification, mapper flags, palettes, escapes and the layout. Every other record +/// the reader does not play is counted as skipped. +const IGNORED: &[u16] = &[0x0105, 0x0107, 0x0108, 0x020A, 0x0231, 0x0234, 0x0035, 0x0037, 0x0139, 0x0436, 0x0626, 0x0149]; + +/// A 16-bit WMF handle as a canvas object index: stock objects are `0x8000 | index`, which the +/// canvas reads with the 32-bit stock flag. +fn wmf_handle(h: u16) -> u32 { + let h = u32::from(h); + if h & 0x8000 != 0 { (h & 0x7FFF) | 0x8000_0000 } else { h } +} + +/// An i16 field as f64; 0 past the end. +fn i16s(r: Bytes, off: usize) -> f64 { + r.i16(off).map_or(0.0, f64::from) +} + +/// CREATEPENINDIRECT: style, width (x, y), colour. Only the width's x component is used. +fn create_pen(cv: &mut Canvas, r: Bytes) -> Option<()> { + let style = r.u16(6)?; + let width = r.i16(8)?; + let color = r.u32(12)?; + cv.add_object(None, Obj::Pen(Pen::new(u32::from(style), f64::from(width), colorref(color)))); + Some(()) +} + +/// CREATEBRUSHINDIRECT: style, colour, hatch. +fn create_brush(cv: &mut Canvas, r: Bytes) -> Option<()> { + let style = u32::from(r.u16(6)?); + let color = r.u32(8)?; + 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(()) +} + +/// (x, y) from a Y-then-X pair stored at `off`. +fn xy16(r: Bytes, off: usize) -> Option

{ + Some((f64::from(r.i16(off + 2)?), f64::from(r.i16(off)?))) +} + +/// Rectangle stored as bottom, right, top, left (the order MS-WMF uses for RECTANGLE and ELLIPSE). +fn rect16(r: Bytes, off: usize) -> Option<[f64; 4]> { + let (b, rr, t, l) = (r.i16(off)?, r.i16(off + 2)?, r.i16(off + 4)?, r.i16(off + 6)?); + Some([f64::from(l), f64::from(t), f64::from(rr), f64::from(b)]) +} + +/// ARC, PIE and CHORD: end Y, end X, start Y, start X, then the bounding rectangle (bottom, right, top, left). +fn arc16(r: Bytes) -> Option<([f64; 4], P, P)> { + let end = xy16(r, 6)?; + let start = xy16(r, 10)?; + let bounds = rect16(r, 14)?; + Some((bounds, start, end)) +} + +/// A count of 16-bit points, then the points (POLYLINE, POLYGON). +fn counted_points(r: Bytes) -> Option> { + let n = r.u16(6)? as usize; + r.tail(8)?.points(0, n, false) +} + +/// POLYPOLYGON: polygon count, one 16-bit point count per polygon, then all points. +fn polypolygon(r: Bytes) -> Option> { + let n = r.u16(6)? as usize; + let mut counts = Vec::with_capacity(n); + for i in 0..n { + counts.push(r.u16(i.checked_mul(2)?.checked_add(8)?)? as usize); + } + let total = counts.iter().try_fold(0usize, |a, c| a.checked_add(*c))?; + if total > MAX_PATH_OPS { + return None; + } + let pts = r.tail(n.checked_mul(2)?.checked_add(8)?)?.points(0, total, false)?; + Some(geom::subpaths(&pts, &counts, true)) +} + +/// Whether a DIBBITBLT or DIBSTRETCHBLT record carries a bitmap: without one it is exactly +/// `(function >> 8) + 3` words long (MS-WMF 2.3.1.2, 2.3.1.3). +fn has_bitmap(func: u16, r: Bytes) -> bool { + r.0.len() / 2 > usize::from(func >> 8) + 3 +} + +/// META_DIBBITBLT: rop(4), ySrc, xSrc, then height, width, yDest, xDest and the DIB at 22. The +/// bitmap-less form has a reserved word before the height, so its fields sit 2 bytes later; it, or +/// a source-less raster operation, fills the destination. +fn dib_bitblt(cv: &mut Canvas, r: Bytes) -> Option<()> { + let rop = r.u32(6)?; + if !has_bitmap(0x0940, r) { + cv.fill_rop([i16s(r, 22), i16s(r, 20), i16s(r, 18), i16s(r, 16)], rop); + return Some(()); + } + let dst = [i16s(r, 20), i16s(r, 18), i16s(r, 16), i16s(r, 14)]; + if Canvas::sourceless(rop) { + cv.fill_rop(dst, rop); + return Some(()); + } + let src = [i16s(r, 12), i16s(r, 10), dst[2], dst[3]]; + dib_blit(cv, r, 22, rop, dst, src) +} + +/// META_DIBSTRETCHBLT: rop(4), srcH, srcW, ySrc, xSrc, then destH, destW, yDest, xDest and the DIB at +/// 26. The bitmap-less form has a reserved word after xSrc, so the destination sits 2 bytes later; +/// it, or a source-less raster operation, fills the destination. +fn dib_stretchblt(cv: &mut Canvas, r: Bytes) -> Option<()> { + let rop = r.u32(6)?; + if !has_bitmap(0x0B41, r) { + cv.fill_rop([i16s(r, 26), i16s(r, 24), i16s(r, 22), i16s(r, 20)], rop); + return Some(()); + } + let dst = [i16s(r, 24), i16s(r, 22), i16s(r, 20), i16s(r, 18)]; + if Canvas::sourceless(rop) { + cv.fill_rop(dst, rop); + return Some(()); + } + let src = [i16s(r, 16), i16s(r, 14), i16s(r, 12), i16s(r, 10)]; + dib_blit(cv, r, 26, rop, dst, src) +} + +/// A DIB at `at`, stretched from source rectangle `src` into `dst` (both in logical units, pixels for `src`). +/// The raster operation `rop` decides how the pixels combine (see [`dib::prepare`]). +fn dib_blit(cv: &mut Canvas, r: Bytes, at: usize, rop: u32, dst: [f64; 4], src: [f64; 4]) -> Option<()> { + let dib = r.tail(at)?; + let n = dib::info_len(dib)?; + let info = dib.slice(0, n)?; + let bits = dib.tail(n)?; + let (mut img, dst) = dib::decode_clip(Bytes(info), bits, false, src.map(|v| v as i64), dst)?; + dib::prepare(&mut img, dib::Blend::Rop(rop)); + cv.bitmap(dst, img); + Some(()) +} diff --git a/crates/metafile/src/write.rs b/crates/metafile/src/write.rs new file mode 100644 index 000000000..ab82f0e16 --- /dev/null +++ b/crates/metafile/src/write.rs @@ -0,0 +1,292 @@ +//! EMF and WMF writers (feature `write`). +//! +//! The writers take a list of drawing operations ([`Op`]) in paint order, in points with y down, +//! and the region of that space the picture covers: its top-left corner is the picture's origin +//! and its size the picture's size. What an application's document holds beyond these operations +//! (gradients, patterns, type, effects, transparency of paths) is turned into them by the +//! application. +//! +//! - **EMF** ([`emf`]): paths keep their Bézier curves; fills are solid brushes; strokes are +//! geometric pens with their width, cap, join, miter limit and dashes; clips are path clips +//! inside a saved device context; images keep their pixels and their transparency and may be +//! turned or skewed. The header's frame is the region in 0.01 mm. +//! - **WMF** ([`wmf`]): the same operations with curves flattened into polygons and 16-bit +//! coordinates, behind a placeable header that gives the size. WMF has no clip paths, no +//! transparency and no turned images; [`Wmf`] says which of these the picture lost. +//! +//! The reader reads back what the writers write: the tests round-trip both formats through +//! [`crate::parse`]. + +mod dib; +mod emf; +mod wmf; + +use std::sync::Arc; + +use kurbo::{Affine, BezPath, PathEl, Point, Rect}; + +use crate::{Cap, Join}; + +/// Most line segments one curve is flattened into, whatever its size and the tolerance. +const MAX_FLATTEN_PER_CURVE: f64 = 4096.0; +/// A curve whose control polygon is longer than this, or has a point further out, is written as +/// a line: kurbo squares and cubes coordinates when it sizes its work, which overflows there. +const MAX_FLATTEN_LENGTH: f64 = 1e60; +/// Most dashes one stroke is cut into when its pen cannot draw them (the rest of the path is +/// then left solid, so a tiny pattern on a long path cannot make an endless file). +const MAX_DASHES: usize = 100_000; + +/// One drawing operation. +#[derive(Clone, Debug)] +pub enum Op { + /// A path filled with one colour. + Fill { + path: BezPath, + even_odd: bool, + rgb: [u8; 3], + }, + /// A path stroked with a pen. + Stroke { + path: BezPath, + pen: Pen, + }, + /// An image: `xf` maps its pixel space (`0..width`, `0..height`) into the picture space; its + /// alpha is multiplied by `opacity`. + Image { + image: Arc, + xf: Affine, + opacity: f32, + }, + /// What follows, up to the matching [`Op::Unclip`], is clipped to `path` (inside the clips + /// around it). WMF has no clip paths and writes what follows whole. + Clip { + path: BezPath, + even_odd: bool, + }, + Unclip, +} + +/// A stroke as a pen draws it. +#[derive(Clone, Debug, PartialEq)] +pub struct Pen { + pub rgb: [u8; 3], + /// Points. + pub width: f64, + pub cap: Cap, + pub join: Join, + /// Longest miter as a multiple of the width (for [`Join::Miter`]). + pub miter: f64, + pub dash: Option, +} + +/// A dash pattern. +#[derive(Clone, Debug, PartialEq)] +pub struct Dash { + /// Dash, gap, dash, gap… in points. An odd count repeats to an even one. + pub pattern: Vec, + /// How far into the pattern the path starts. + pub offset: f64, +} + +impl Dash { + /// Whether the pattern draws dashes: every length finite and not negative, and not all zero. + fn is_dashed(&self) -> bool { + self.offset.is_finite() && self.pattern.iter().all(|v| v.is_finite() && *v >= 0.0) && self.pattern.iter().any(|v| *v > 0.0) + } + + /// Whether an EMF pen draws this pattern itself (`PS_USERSTYLE`): it starts at the path's + /// start, has at most 16 entries once made even, and every entry is at least one device unit + /// (0.01 mm). Other patterns are written as one line per dash, cut with [`kurbo::dash`]; a + /// caller that cuts dashes differently (aligned to corners, say) asks here and passes the + /// dashes as the path with no pattern. + pub fn emf_pen_draws(&self) -> bool { + emf::dash_entries(self).is_some() + } +} + +/// Pixels with straight (not premultiplied) alpha, RGBA8, row by row from the top. +#[derive(Clone, Debug, PartialEq)] +pub struct Image { + pub width: u32, + pub height: u32, + pub rgba: Vec, +} + +impl Image { + /// Every pixel fully opaque? + pub fn opaque(&self) -> bool { + self.rgba.as_chunks::<4>().0.iter().all(|p| p[3] == 255) + } +} + +/// A written WMF and what the format could not hold. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Wmf { + pub bytes: Vec, + /// Images with transparency were written over white. + pub translucent_images: bool, + /// Turned or skewed images were written upright, filling their bounds. + pub turned_images: bool, + /// Clips were not applied: what they cover was written whole. + pub clips_ignored: bool, + /// Images with a side over 32,767 pixels were left out. + pub oversized_images: bool, +} + +/// Writes `ops` over `region` as an EMF. The header's description names the application and the +/// picture (`app`, `title`). None when the region is empty or not finite. +pub fn emf(ops: &[Op], region: Rect, app: &str, title: &str) -> Option> { + Some(emf::write(ops, sane(region)?, app, title)) +} + +/// Writes `ops` over `region` as a placeable WMF. None when the region is empty or not finite. +pub fn wmf(ops: &[Op], region: Rect) -> Option { + Some(wmf::write(ops, sane(region)?)) +} + +/// `region` with its corners in order, when it is finite and has an area. +fn sane(region: Rect) -> Option { + let r = region.abs(); + ([r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite()) && r.width() > 0.0 && r.height() > 0.0).then_some(r) +} + +/// `bp` cut into its dashes (as it is when `dash` draws none), for pens that can't draw them. +fn dashed(bp: &BezPath, dash: Option<&Dash>) -> BezPath { + let Some(d) = dash.filter(|d| d.is_dashed()) else { return bp.clone() }; + let mut out = BezPath::new(); + let mut dashes = 0usize; + for el in kurbo::dash(bp.iter(), d.offset, &d.pattern) { + dashes += usize::from(matches!(el, PathEl::MoveTo(_))); + if dashes > MAX_DASHES { + // Too many to write one by one: the stroke stays solid. + return bp.clone(); + } + out.push(el); + } + out +} + +/// `bp` flattened to within `tol`: each subpath's points and whether it closes. +fn flatten(bp: &BezPath, tol: f64) -> Vec<(Vec, bool)> { + let mut out: Vec<(Vec, bool)> = vec![]; + let mut cur: Vec = vec![]; + flatten_capped(bp.iter(), tol, |el| match el { + PathEl::MoveTo(p) => { + if cur.len() > 1 { + out.push((std::mem::take(&mut cur), false)); + } + cur = vec![p]; + } + PathEl::LineTo(p) => cur.push(p), + PathEl::ClosePath => { + if cur.len() > 1 { + out.push((std::mem::take(&mut cur), true)); + } + cur.clear(); + } + _ => {} + }); + if cur.len() > 1 { + out.push((cur, false)); + } + out +} + +/// [`kurbo::flatten`], each curve's tolerance raised so that it makes at most about +/// [`MAX_FLATTEN_PER_CURVE`] segments. kurbo sizes its work and a buffer from the curve's +/// coordinates before producing anything, so a small curve with far-out handles asks for +/// gigabytes. The segments a curve needs grow with the square root of its control polygon's +/// length over the tolerance; that ratio is held under the square of the cap. A curve whose +/// points aren't finite is drawn as a line to its end. +fn flatten_capped(path: impl IntoIterator, tolerance: f64, mut callback: impl FnMut(PathEl)) { + let mut last = None; + let mut start = None; + for el in path { + let (from, to, length) = match el { + PathEl::MoveTo(p) => { + (last, start) = (Some(p), Some(p)); + callback(el); + continue; + } + PathEl::LineTo(p) => { + last = Some(p); + callback(el); + continue; + } + PathEl::ClosePath => { + last = start; + callback(el); + continue; + } + PathEl::QuadTo(a, b) => (last, b, last.map(|p| p.distance(a) + a.distance(b))), + PathEl::CurveTo(a, b, c) => (last, c, last.map(|p| p.distance(a) + a.distance(b) + b.distance(c))), + }; + last = Some(to); + let (Some(from), Some(length)) = (from, length) else { continue }; + // A short polygon far from the origin still overflows kurbo's `p * (mt * 2.0)` products. + let far = |p: Point| !(p.x.abs() <= MAX_FLATTEN_LENGTH && p.y.abs() <= MAX_FLATTEN_LENGTH); + let controls_far = match el { + PathEl::QuadTo(a, _) => far(a), + PathEl::CurveTo(a, b, _) => far(a) || far(b), + _ => false, + }; + if length.is_nan() || length > MAX_FLATTEN_LENGTH || far(from) || far(to) || controls_far { + callback(PathEl::LineTo(to)); + continue; + } + let tol = tolerance.max(length / (MAX_FLATTEN_PER_CURVE * MAX_FLATTEN_PER_CURVE)); + kurbo::flatten([PathEl::MoveTo(from), el], tol, |e| { + if !matches!(e, PathEl::MoveTo(_)) { + callback(e); + } + }); + } +} + +/// Little-endian bytes being written. +#[derive(Default)] +struct Out(Vec); + +impl Out { + fn len(&self) -> usize { + self.0.len() + } + fn u16(&mut self, v: u16) { + self.0.extend_from_slice(&v.to_le_bytes()); + } + fn i16(&mut self, v: i16) { + self.0.extend_from_slice(&v.to_le_bytes()); + } + fn u32(&mut self, v: u32) { + self.0.extend_from_slice(&v.to_le_bytes()); + } + fn i32(&mut self, v: i32) { + self.0.extend_from_slice(&v.to_le_bytes()); + } + fn f32(&mut self, v: f32) { + self.0.extend_from_slice(&v.to_le_bytes()); + } + fn bytes(&mut self, b: &[u8]) { + self.0.extend_from_slice(b); + } + /// Zeros up to the next multiple of `n` bytes. + fn pad(&mut self, n: usize) { + while !self.0.len().is_multiple_of(n) { + self.0.push(0); + } + } + /// Overwrite the `u32` at `at` (a size or count known only later). + fn set_u32(&mut self, at: usize, v: u32) { + if let Some(b) = self.0.get_mut(at..at + 4) { + b.copy_from_slice(&v.to_le_bytes()); + } + } + fn set_i32(&mut self, at: usize, v: i32) { + if let Some(b) = self.0.get_mut(at..at + 4) { + b.copy_from_slice(&v.to_le_bytes()); + } + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/metafile/src/write/dib.rs b/crates/metafile/src/write/dib.rs new file mode 100644 index 000000000..b9eafb7a8 --- /dev/null +++ b/crates/metafile/src/write/dib.rs @@ -0,0 +1,62 @@ +//! Bitmaps as the writers store them: bottom-up, uncompressed DIBs. + +use super::Image; + +const BI_RGB: u32 = 0; + +/// A BITMAPINFOHEADER of a bottom-up, uncompressed `width` × `height` bitmap of `bits` per pixel. +fn header(width: u32, height: u32, bits: u16, size: usize) -> Vec { + let mut h = super::Out::default(); + h.u32(40); + h.i32(i32::try_from(width).unwrap_or(i32::MAX)); + h.i32(i32::try_from(height).unwrap_or(i32::MAX)); + h.u16(1); + h.u16(bits); + h.u32(BI_RGB); + h.u32(u32::try_from(size).unwrap_or(0)); + // 2835 pixels a metre: 72 ppi (the size on the page comes from the record). + h.i32(2835); + h.i32(2835); + h.u32(0); + h.u32(0); + h.0 +} + +/// The bytes of a row of `width` pixels of `bits` each, padded to 4 bytes. +fn stride(width: u32, bits: u32) -> Option { + let row_bits = u64::from(width).checked_mul(u64::from(bits))?; + usize::try_from(row_bits.div_ceil(32) * 4).ok() +} + +/// `img` at `opacity`, composited over white, as a 24-bit DIB → (header, bits). +pub(crate) fn rgb24(img: &Image, opacity: f32) -> (Vec, Vec) { + let row = stride(img.width, 24).unwrap_or(0); + let mut bits = vec![0u8; row * img.height as usize]; + let over = |c: u8, a: u32| ((u32::from(c) * a + 255 * (255 - a) + 127) / 255) as u8; + for (y, src) in img.rgba.chunks_exact((img.width as usize * 4).max(1)).enumerate() { + // Bottom-up: the first stored row is the image's last. + let Some(at) = (img.height as usize).checked_sub(y + 1).map(|r| r * row) else { continue }; + let Some(dst) = bits.get_mut(at..at + img.width as usize * 3) else { continue }; + for (d, p) in dst.as_chunks_mut::<3>().0.iter_mut().zip(src.as_chunks::<4>().0) { + let a = (f32::from(p[3]) * opacity.clamp(0.0, 1.0)).round() as u32; + d.copy_from_slice(&[over(p[2], a), over(p[1], a), over(p[0], a)]); + } + } + (header(img.width, img.height, 24, bits.len()), bits) +} + +/// `img` at `opacity` as a 32-bit DIB of premultiplied BGRA → (header, bits). +pub(crate) fn bgra32(img: &Image, opacity: f32) -> (Vec, Vec) { + let row = img.width as usize * 4; + let mut bits = vec![0u8; row * img.height as usize]; + for (y, src) in img.rgba.chunks_exact(row.max(1)).enumerate() { + let Some(at) = (img.height as usize).checked_sub(y + 1).map(|r| r * row) else { continue }; + let Some(dst) = bits.get_mut(at..at + row) else { continue }; + for (d, p) in dst.as_chunks_mut::<4>().0.iter_mut().zip(src.as_chunks::<4>().0) { + let a = (f32::from(p[3]) * opacity.clamp(0.0, 1.0)).round() as u32; + let pre = |c: u8| ((u32::from(c) * a + 127) / 255) as u8; + d.copy_from_slice(&[pre(p[2]), pre(p[1]), pre(p[0]), a as u8]); + } + } + (header(img.width, img.height, 32, bits.len()), bits) +} diff --git a/crates/metafile/src/write/emf.rs b/crates/metafile/src/write/emf.rs new file mode 100644 index 000000000..6f8584495 --- /dev/null +++ b/crates/metafile/src/write/emf.rs @@ -0,0 +1,498 @@ +//! The EMF writer: an EMR_HEADER whose frame is the region in 0.01 mm, then the drawing in device +//! units of 0.01 mm (the reference device has 100 pixels a millimetre) and an EMR_EOF. +//! +//! Each fill and stroke is a path bracket (move-to, poly-line-to and poly-Bézier-to runs, close +//! figure) filled with a solid brush or stroked with a geometric pen; brushes and pens are made +//! when the colour or pen changes and deleted when replaced. Clips are path clips inside a saved +//! DC; images are StretchDIBits (opaque) or AlphaBlend (with transparency) under a world transform +//! that places their pixels. + +use kurbo::{Affine, BezPath, PathEl, Point, Rect}; + +use super::{Dash, Image, Op, Out, Pen, dib}; +use crate::{Cap, Join}; + +// Record types (MS-EMF 2.1.1). +pub(crate) const HEADER: u32 = 1; +pub(crate) const POLYBEZIERTO: u32 = 5; +pub(crate) const POLYLINETO: u32 = 6; +pub(crate) const EOF: u32 = 14; +pub(crate) const SETBKMODE: u32 = 18; +pub(crate) const SETPOLYFILLMODE: u32 = 19; +pub(crate) const MOVETOEX: u32 = 27; +pub(crate) const SAVEDC: u32 = 33; +pub(crate) const RESTOREDC: u32 = 34; +pub(crate) const SETWORLDTRANSFORM: u32 = 35; +pub(crate) const MODIFYWORLDTRANSFORM: u32 = 36; +pub(crate) const SELECTOBJECT: u32 = 37; +pub(crate) const CREATEBRUSHINDIRECT: u32 = 39; +pub(crate) const DELETEOBJECT: u32 = 40; +pub(crate) const SETMITERLIMIT: u32 = 58; +pub(crate) const BEGINPATH: u32 = 59; +pub(crate) const ENDPATH: u32 = 60; +pub(crate) const CLOSEFIGURE: u32 = 61; +pub(crate) const FILLPATH: u32 = 62; +pub(crate) const STROKEPATH: u32 = 64; +pub(crate) const SELECTCLIPPATH: u32 = 67; +pub(crate) const STRETCHDIBITS: u32 = 81; +pub(crate) const EXTCREATEPEN: u32 = 95; +pub(crate) const ALPHABLEND: u32 = 114; + +/// The `" EMF"` signature in the header. +pub(crate) const SIGNATURE: u32 = 0x464D_4520; +/// Stock objects (selected to release ours before deleting them). +const WHITE_BRUSH: u32 = 0x8000_0000; +const NULL_PEN: u32 = 0x8000_0008; +/// Our two object slots (0 is reserved). +const BRUSH: u32 = 1; +const PEN: u32 = 2; + +pub(crate) const PS_GEOMETRIC: u32 = 0x0001_0000; +pub(crate) const PS_USERSTYLE: u32 = 7; +pub(crate) const PS_ENDCAP_SQUARE: u32 = 0x100; +pub(crate) const PS_ENDCAP_FLAT: u32 = 0x200; +pub(crate) const PS_JOIN_BEVEL: u32 = 0x1000; +pub(crate) const PS_JOIN_MITER: u32 = 0x2000; +/// Raster operation: copy the source. +pub(crate) const SRCCOPY: u32 = 0x00CC_0020; + +/// Device units (0.01 mm) per point. +pub(crate) const UNITS_PER_PT: f64 = 2540.0 / 72.0; +/// Longest dash pattern a geometric pen takes (longer ones are drawn as separate dashes). +const MAX_STYLE: usize = 16; +/// Header size with both extensions. +const HEADER_SIZE: usize = 108; +/// Coordinates are kept within this (far beyond any page). +const MAX_COORD: f64 = 1e9; + +/// A COLORREF (0x00bbggrr). +pub(crate) fn colorref(rgb: [u8; 3]) -> u32 { + u32::from_le_bytes([rgb[0], rgb[1], rgb[2], 0]) +} + +/// The style entries (device units) of a pen that draws `d` itself; None when the dashes must be +/// written as separate lines: a pen's dashes start at the path's start, every entry is at least a +/// unit long, and there are 16 entries at most. +pub(super) fn dash_entries(d: &Dash) -> Option> { + let mut v: Vec = d.pattern.clone(); + if v.len() % 2 == 1 { + v.extend_from_within(..); + } + let e: Vec = v.iter().map(|x| (x * UNITS_PER_PT).round().clamp(0.0, f64::from(u32::MAX)) as u32).collect(); + (d.offset.abs() < 1e-9 && e.len() <= MAX_STYLE && e.iter().all(|x| *x >= 1)).then_some(e) +} + +/// What a pen object is made of, to tell when the next stroke needs another. +#[derive(Clone, PartialEq)] +struct PenKey { + style: u32, + width: u32, + color: u32, + entries: Vec, +} + +/// A device-space bounding box (inclusive). +type Bounds = [i32; 4]; + +fn union(a: Option, b: Bounds) -> Bounds { + match a { + None => b, + Some(a) => [a[0].min(b[0]), a[1].min(b[1]), a[2].max(b[2]), a[3].max(b[3])], + } +} + +struct Writer { + out: Out, + records: u32, + /// Picture space → device units. + to_dev: Affine, + /// The frame in device units: what the region clip lets through. + frame: Bounds, + /// Everything drawn, in device units. + bounds: Option, + brush: Option, + pen: Option, + fill_mode: Option, + miter: Option, +} + +/// Write `ops` as an EMF of `region`; the header's description names `app` and `title`. +pub(crate) fn write(ops: &[Op], region: Rect, app: &str, title: &str) -> Vec { + let k = UNITS_PER_PT; + let frame = [0, 0, (region.width() * k).round() as i32, (region.height() * k).round() as i32]; + let mut w = Writer { + out: Out::default(), + records: 0, + to_dev: Affine::scale(k) * Affine::translate(-region.origin().to_vec2()), + frame, + bounds: None, + brush: None, + pen: None, + fill_mode: None, + miter: None, + }; + w.header(app, title); + // Dash gaps and hatches show what is below. + w.record(SETBKMODE, |o| o.u32(1)); + for op in ops { + w.op(op); + } + w.release(); + w.record(EOF, |o| { + o.u32(0); + o.u32(16); + o.u32(20); + }); + w.finish() +} + +impl Writer { + /// Append one record: its type, size and `body`, padded to 4 bytes. + fn record(&mut self, kind: u32, body: impl FnOnce(&mut Out)) { + let start = self.out.len(); + self.out.u32(kind); + self.out.u32(0); + body(&mut self.out); + self.out.pad(4); + let size = u32::try_from(self.out.len() - start).unwrap_or(u32::MAX); + self.out.set_u32(start + 4, size); + self.records += 1; + } + + fn header(&mut self, app: &str, title: &str) { + // "Application\0Picture\0\0", each part cut to what a description may reasonably hold. + let part = |s: &str| s.chars().filter(|c| *c != '\0').take(200).collect::(); + let desc: Vec = part(app).encode_utf16().chain([0]).chain(part(title).encode_utf16()).chain([0, 0]).collect(); + let f = self.frame; + // The reference device: 100 pixels a millimetre, as large as the frame. + let mm = [(f[2] / 100).max(1) + 1, (f[3] / 100).max(1) + 1]; + self.record(HEADER, |o| { + // Bounds, patched in `finish`. + o.bytes(&[0; 16]); + for v in f { + o.i32(v); + } + o.u32(SIGNATURE); + o.u32(0x0001_0000); + // Bytes, records and handles, patched in `finish`. + o.u32(0); + o.u32(0); + o.u16(3); + o.u16(0); + o.u32(desc.len() as u32); + o.u32(HEADER_SIZE as u32); + o.u32(0); + o.i32(mm[0] * 100); + o.i32(mm[1] * 100); + o.i32(mm[0]); + o.i32(mm[1]); + // No pixel format, not OpenGL; the device in micrometres. + o.u32(0); + o.u32(0); + o.u32(0); + o.u32(mm[0] as u32 * 1000); + o.u32(mm[1] as u32 * 1000); + for c in &desc { + o.u16(*c); + } + }); + } + + /// Patch the header's bounds, size and record count. + fn finish(mut self) -> Vec { + let b = self.bounds.map(|b| [b[0].max(self.frame[0]), b[1].max(self.frame[1]), b[2].min(self.frame[2]), b[3].min(self.frame[3])]); + // An empty picture has bounds (0, 0, -1, -1). + let b = b.filter(|b| b[0] <= b[2] && b[1] <= b[3]).unwrap_or([0, 0, -1, -1]); + for (i, v) in b.iter().enumerate() { + self.out.set_i32(8 + 4 * i, *v); + } + let size = u32::try_from(self.out.len()).unwrap_or(u32::MAX); + self.out.set_u32(48, size); + self.out.set_u32(52, self.records); + self.out.0 + } + + fn dev(&self, p: Point) -> [i32; 2] { + let q = self.to_dev * p; + let c = |v: f64| if v.is_finite() { v.round().clamp(-MAX_COORD, MAX_COORD) as i32 } else { 0 }; + [c(q.x), c(q.y)] + } + + fn grow(&mut self, b: Bounds) { + self.bounds = Some(union(self.bounds, b)); + } + + fn op(&mut self, op: &Op) { + match op { + Op::Fill { path, even_odd, rgb } => { + self.fill_mode(*even_odd); + self.brush(*rgb); + if let Some(b) = self.path(path) { + self.grow(b); + self.record(FILLPATH, |o| b.iter().for_each(|v| o.i32(*v))); + } + } + Op::Stroke { path, pen } => { + let dashed; + let path = if self.pen(pen) { + path + } else { + dashed = super::dashed(path, pen.dash.as_ref()); + &dashed + }; + if let Some(b) = self.path(path) { + let half = (pen.width * UNITS_PER_PT / 2.0).ceil() as i32; + let b = [b[0] - half, b[1] - half, b[2] + half, b[3] + half]; + self.grow(b); + self.record(STROKEPATH, |o| b.iter().for_each(|v| o.i32(*v))); + } + } + Op::Image { image, xf, opacity } => self.image(image, *xf, *opacity), + Op::Clip { path, even_odd } => { + self.release(); + self.record(SAVEDC, |_| {}); + self.fill_mode(*even_odd); + if self.path(path).is_some() { + // RGN_AND: inside the clip already set. + self.record(SELECTCLIPPATH, |o| o.u32(1)); + } + } + Op::Unclip => { + self.release(); + self.record(RESTOREDC, |o| o.i32(-1)); + // The DC's fill mode and miter limit are back to what they were at the save. + self.fill_mode = None; + self.miter = None; + } + } + } + + fn fill_mode(&mut self, even_odd: bool) { + // ALTERNATE (even-odd) or WINDING (non-zero). + let mode = if even_odd { 1 } else { 2 }; + if self.fill_mode != Some(mode) { + self.record(SETPOLYFILLMODE, |o| o.u32(mode)); + self.fill_mode = Some(mode); + } + } + + /// Select a solid brush of `rgb`, made when the colour changes. + fn brush(&mut self, rgb: [u8; 3]) { + let color = colorref(rgb); + if self.brush == Some(color) { + return; + } + self.drop_brush(); + self.record(CREATEBRUSHINDIRECT, |o| { + o.u32(BRUSH); + // BS_SOLID, the colour, no hatch. + o.u32(0); + o.u32(color); + o.u32(0); + }); + self.record(SELECTOBJECT, |o| o.u32(BRUSH)); + self.brush = Some(color); + } + + fn drop_brush(&mut self) { + if self.brush.take().is_some() { + self.record(SELECTOBJECT, |o| o.u32(WHITE_BRUSH)); + self.record(DELETEOBJECT, |o| o.u32(BRUSH)); + } + } + + fn drop_pen(&mut self) { + if self.pen.take().is_some() { + self.record(SELECTOBJECT, |o| o.u32(NULL_PEN)); + self.record(DELETEOBJECT, |o| o.u32(PEN)); + } + } + + /// Release our objects (before a DC is saved or restored, which would bring back deleted ones). + fn release(&mut self) { + self.drop_brush(); + self.drop_pen(); + } + + /// Select a geometric pen for `p`, made when it changes. False when `p`'s dashes must be + /// drawn as separate lines (the pen selected is solid): a pen's dashes start at the path's + /// start, every entry at least a unit long, 16 entries at most. + fn pen(&mut self, p: &Pen) -> bool { + let k = UNITS_PER_PT; + let entries = p.dash.as_ref().map(dash_entries); + let native = !matches!(entries, Some(None)); + let entries = entries.flatten().unwrap_or_default(); + let cap = match p.cap { + Cap::Flat => PS_ENDCAP_FLAT, + Cap::Round => 0, + Cap::Square => PS_ENDCAP_SQUARE, + }; + let join = match p.join { + Join::Miter => PS_JOIN_MITER, + Join::Round => 0, + Join::Bevel => PS_JOIN_BEVEL, + }; + let style = PS_GEOMETRIC | cap | join | if entries.is_empty() { 0 } else { PS_USERSTYLE }; + let key = PenKey { style, width: (p.width * k).round().clamp(1.0, 1e9) as u32, color: colorref(p.rgb), entries }; + if p.join == Join::Miter { + let m = p.miter.round().clamp(1.0, 1000.0) as u32; + if self.miter != Some(m) { + self.record(SETMITERLIMIT, |o| o.u32(m)); + self.miter = Some(m); + } + } + if self.pen.as_ref() != Some(&key) { + self.drop_pen(); + let n = key.entries.len() as u32; + self.record(EXTCREATEPEN, |o| { + o.u32(PEN); + // No pattern bitmap: its offsets point past the record, its sizes are 0. + let end = 52 + 4 * n; + o.u32(end); + o.u32(0); + o.u32(end); + o.u32(0); + o.u32(key.style); + o.u32(key.width); + // BS_SOLID, the colour, no hatch. + o.u32(0); + o.u32(key.color); + o.u32(0); + o.u32(n); + key.entries.iter().for_each(|e| o.u32(*e)); + }); + self.record(SELECTOBJECT, |o| o.u32(PEN)); + self.pen = Some(key); + } + native + } + + /// `bp` as a path bracket → its device bounds (`None`: nothing to draw). + fn path(&mut self, bp: &BezPath) -> Option { + if !bp.elements().iter().any(|e| !matches!(e, PathEl::MoveTo(_) | PathEl::ClosePath)) { + return None; + } + self.record(BEGINPATH, |_| {}); + let mut bounds: Option = None; + let mut run: Vec<[i32; 2]> = vec![]; + let mut curves = false; + let mut last = Point::ZERO; + let mut start = Point::ZERO; + for el in bp.elements() { + let (pts, is_curve): (Vec, bool) = match *el { + PathEl::MoveTo(p) => { + self.flush_run(&mut run, curves); + let d = self.dev(p); + bounds = Some(union(bounds, [d[0], d[1], d[0], d[1]])); + self.record(MOVETOEX, |o| d.iter().for_each(|v| o.i32(*v))); + (last, start) = (p, p); + continue; + } + PathEl::ClosePath => { + self.flush_run(&mut run, curves); + self.record(CLOSEFIGURE, |_| {}); + last = start; + continue; + } + PathEl::LineTo(p) => (vec![p], false), + PathEl::QuadTo(c, p) => (vec![last + (c - last) * (2.0 / 3.0), p + (c - p) * (2.0 / 3.0), p], true), + PathEl::CurveTo(a, b, p) => (vec![a, b, p], true), + }; + if is_curve != curves { + self.flush_run(&mut run, curves); + curves = is_curve; + } + for p in &pts { + let d = self.dev(*p); + bounds = Some(union(bounds, [d[0], d[1], d[0], d[1]])); + run.push(d); + } + last = pts.last().copied().unwrap_or(last); + } + self.flush_run(&mut run, curves); + self.record(ENDPATH, |_| {}); + bounds + } + + /// Write the points of a run of lines or curves, then empty it. + fn flush_run(&mut self, run: &mut Vec<[i32; 2]>, curves: bool) { + if run.is_empty() { + return; + } + let pts = std::mem::take(run); + let b = pts.iter().fold(None, |acc, p| Some(union(acc, [p[0], p[1], p[0], p[1]]))).unwrap_or([0, 0, -1, -1]); + self.record(if curves { POLYBEZIERTO } else { POLYLINETO }, |o| { + b.iter().for_each(|v| o.i32(*v)); + o.u32(pts.len() as u32); + for p in &pts { + o.i32(p[0]); + o.i32(p[1]); + } + }); + } + + /// An image placed by `xf` (its pixels → picture space): StretchDIBits when opaque, else AlphaBlend, + /// drawn at its pixel size under a world transform. + fn image(&mut self, img: &Image, xf: Affine, opacity: f32) { + if img.width == 0 || img.height == 0 || opacity <= 0.0 { + return; + } + let m = self.to_dev * xf; + if !m.as_coeffs().iter().all(|v| v.is_finite()) || m.determinant().abs() < 1e-12 { + return; + } + let (w, h) = (f64::from(img.width), f64::from(img.height)); + let corners = [Point::ZERO, Point::new(w, 0.0), Point::new(0.0, h), Point::new(w, h)].map(|p| self.dev(xf * p)); + let b = corners.iter().fold(None, |acc, p| Some(union(acc, [p[0], p[1], p[0], p[1]]))).unwrap_or([0, 0, -1, -1]); + self.grow(b); + let [a, bb, c, d, e, f] = m.as_coeffs(); + self.record(SETWORLDTRANSFORM, |o| [a, bb, c, d, e, f].iter().for_each(|v| o.f32(*v as f32))); + let (iw, ih) = (img.width as i32, img.height as i32); + if img.opaque() && opacity >= 0.998 { + let (bmi, bits) = dib::rgb24(img, 1.0); + self.record(STRETCHDIBITS, |o| { + b.iter().for_each(|v| o.i32(*v)); + // Destination and source rectangles. + [0, 0, 0, 0, iw, ih].iter().for_each(|v| o.i32(*v)); + o.u32(80); + o.u32(bmi.len() as u32); + o.u32(80 + bmi.len() as u32); + o.u32(bits.len() as u32); + // DIB_RGB_COLORS, SRCCOPY, the destination size. + o.u32(0); + o.u32(SRCCOPY); + o.i32(iw); + o.i32(ih); + o.bytes(&bmi); + o.bytes(&bits); + }); + } else { + let (bmi, bits) = dib::bgra32(img, opacity); + self.record(ALPHABLEND, |o| { + b.iter().for_each(|v| o.i32(*v)); + [0, 0, iw, ih].iter().for_each(|v| o.i32(*v)); + // AC_SRC_OVER, no flags, constant alpha 255, AC_SRC_ALPHA (per-pixel alpha). + o.bytes(&[0, 0, 255, 1]); + o.i32(0); + o.i32(0); + // The source's own transform: identity. + [1.0f32, 0.0, 0.0, 1.0, 0.0, 0.0].iter().for_each(|v| o.f32(*v)); + o.u32(0); + o.u32(0); + o.u32(108); + o.u32(bmi.len() as u32); + o.u32(108 + bmi.len() as u32); + o.u32(bits.len() as u32); + o.i32(iw); + o.i32(ih); + o.bytes(&bmi); + o.bytes(&bits); + }); + } + // MWT_IDENTITY: back to drawing in device units. + self.record(MODIFYWORLDTRANSFORM, |o| { + [1.0f32, 0.0, 0.0, 1.0, 0.0, 0.0].iter().for_each(|v| o.f32(*v)); + o.u32(1); + }); + } +} diff --git a/crates/metafile/src/write/tests.rs b/crates/metafile/src/write/tests.rs new file mode 100644 index 000000000..6d03bda1c --- /dev/null +++ b/crates/metafile/src/write/tests.rs @@ -0,0 +1,527 @@ +//! The writers' output record by record, and round trips through the reader. + +use std::sync::Arc; + +use kurbo::{Affine, BezPath, Ellipse, Point, Rect, Shape}; + +use super::*; +use crate::{Item, Kind, Seg, parse, sniff}; + +const RED: [u8; 3] = [255, 0, 0]; +const BLUE: [u8; 3] = [0, 0, 255]; + +fn u32_at(b: &[u8], at: usize) -> u32 { + u32::from_le_bytes(b[at..at + 4].try_into().unwrap()) +} + +fn i32_at(b: &[u8], at: usize) -> i32 { + i32::from_le_bytes(b[at..at + 4].try_into().unwrap()) +} + +fn u16_at(b: &[u8], at: usize) -> u16 { + u16::from_le_bytes(b[at..at + 2].try_into().unwrap()) +} + +/// The EMF's records: (type, the whole record). +fn emf_records(b: &[u8]) -> Vec<(u32, &[u8])> { + let mut out = vec![]; + let mut at = 0; + while at < b.len() { + let size = u32_at(b, at + 4) as usize; + assert!(size >= 8 && size.is_multiple_of(4), "record size {size} at {at}"); + out.push((u32_at(b, at), &b[at..at + size])); + at += size; + } + assert_eq!(at, b.len(), "records end at the end of the file"); + out +} + +/// The WMF's records after both headers: (function, the whole record). +fn wmf_records(b: &[u8]) -> Vec<(u16, &[u8])> { + let mut out = vec![]; + let mut at = 22 + 18; + while at < b.len() { + let words = u32_at(b, at) as usize; + assert!(words >= 3, "record of {words} words at {at}"); + out.push((u16_at(b, at + 4), &b[at..at + words * 2])); + at += words * 2; + } + assert_eq!(at, b.len()); + out +} + +fn kinds(recs: &[(u32, &[u8])]) -> Vec { + recs.iter().map(|r| r.0).collect() +} + +fn count(k: &[u32], kind: u32) -> usize { + k.iter().filter(|x| **x == kind).count() +} + +fn rect(x0: f64, y0: f64, x1: f64, y1: f64) -> BezPath { + Rect::new(x0, y0, x1, y1).to_path(0.1) +} + +fn ellipse(x0: f64, y0: f64, x1: f64, y1: f64) -> BezPath { + let mut p = Ellipse::from_rect(Rect::new(x0, y0, x1, y1)).to_path(0.01); + if !matches!(p.elements().last(), Some(PathEl::ClosePath)) { + p.close_path(); + } + p +} + +fn line(a: (f64, f64), b: (f64, f64)) -> BezPath { + let mut p = BezPath::new(); + p.move_to(a); + p.line_to(b); + p +} + +fn fill(path: BezPath, rgb: [u8; 3]) -> Op { + Op::Fill { path, even_odd: false, rgb } +} + +fn pen(rgb: [u8; 3], width: f64) -> Pen { + Pen { rgb, width, cap: Cap::Round, join: Join::Round, miter: 10.0, dash: None } +} + +fn stroke(path: BezPath, pen: Pen) -> Op { + Op::Stroke { path, pen } +} + +fn image(width: u32, height: u32, px: [u8; 4]) -> Arc { + Arc::new(Image { width, height, rgba: px.repeat((width * height) as usize) }) +} + +fn page(w: f64, h: f64) -> Rect { + Rect::new(0.0, 0.0, w, h) +} + +fn write_emf(ops: &[Op], w: f64, h: f64) -> Vec { + emf(ops, page(w, h), "Craft", "Picture").unwrap() +} + +/// Red, green, blue; then half-transparent black, transparent, white. +fn sample() -> Image { + Image { width: 3, height: 2, rgba: vec![255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255, 0, 0, 0, 128, 9, 9, 9, 0, 255, 255, 255, 255] } +} + +#[test] +fn emf_header_frame_is_the_region_in_hundredths_of_a_millimetre() { + // 72 x 144 pt is 25.4 x 50.8 mm; a rectangle from (36, 36) to (54, 72) pt. + let b = &write_emf(&[fill(rect(36.0, 36.0, 54.0, 72.0), RED)], 72.0, 144.0); + assert_eq!(sniff(b), Some(Kind::Emf)); + assert_eq!(u32_at(b, 0), 1); + assert_eq!(u32_at(b, 40), 0x464D_4520, "the \" EMF\" signature"); + // rclFrame. + assert_eq!([i32_at(b, 24), i32_at(b, 28), i32_at(b, 32), i32_at(b, 36)], [0, 0, 2540, 5080]); + // rclBounds, in device units of 0.01 mm: the rectangle. + assert_eq!([i32_at(b, 8), i32_at(b, 12), i32_at(b, 16), i32_at(b, 20)], [1270, 1270, 1905, 2540]); + // The reference device has 100 pixels a millimetre. + let (dev, mm) = ((i32_at(b, 72), i32_at(b, 76)), (i32_at(b, 80), i32_at(b, 84))); + assert_eq!(dev, (mm.0 * 100, mm.1 * 100)); + assert_eq!((u32_at(b, 100), u32_at(b, 104)), (mm.0 as u32 * 1000, mm.1 as u32 * 1000), "micrometres"); + // The description names the application and the picture. + let n = u32_at(b, 60) as usize; + let off = u32_at(b, 64) as usize; + assert_eq!(off, 108); + let desc: Vec = (0..n).map(|i| u16_at(b, off + 2 * i)).collect(); + assert_eq!(String::from_utf16_lossy(&desc), "Craft\0Picture\0\0"); + // An empty picture has empty bounds. + let empty = write_emf(&[], 72.0, 72.0); + assert_eq!([i32_at(&empty, 8), i32_at(&empty, 16)], [0, -1]); + // The region's origin is the picture's. + let moved = emf(&[fill(rect(136.0, 36.0, 154.0, 72.0), RED)], Rect::new(100.0, 0.0, 172.0, 144.0), "", "").unwrap(); + assert_eq!([i32_at(&moved, 8), i32_at(&moved, 16)], [1270, 1905]); +} + +#[test] +fn emf_record_sizes_add_up_to_the_header_totals() { + let ops = [ + Op::Image { image: image(3, 2, [0, 128, 0, 255]), xf: Affine::translate((10.0, 10.0)) * Affine::scale(10.0), opacity: 1.0 }, + fill(ellipse(50.0, 10.0, 150.0, 90.0), RED), + stroke(ellipse(50.0, 10.0, 150.0, 90.0), pen(BLUE, 2.0)), + ]; + let b = write_emf(&ops, 200.0, 100.0); + let recs = emf_records(&b); + assert_eq!(u32_at(&b, 48) as usize, b.len(), "nBytes"); + assert_eq!(u32_at(&b, 52) as usize, recs.len(), "nRecords"); + assert_eq!(u16_at(&b, 56), 3, "nHandles: the reserved slot, a brush and a pen"); + let (last, eof) = recs.last().copied().unwrap(); + assert_eq!((last, eof.len()), (14, 20), "EMR_EOF ends the file"); + assert_eq!(u32_at(eof, 16), 20, "nSizeLast"); + // Fixed-size records have their fixed sizes. + for (k, r) in &recs { + let fixed = match k { + 18 | 19 | 34 | 37 | 40 | 58 | 67 => Some(12), + 27 | 54 => Some(16), + 33 | 59 | 60 | 61 => Some(8), + 39 | 62 | 64 => Some(24), + 35 => Some(32), + 36 => Some(36), + _ => None, + }; + if let Some(f) = fixed { + assert_eq!(r.len(), f, "record {k}"); + } + } + // StretchDIBits: its bitmap where its offsets say, 3 x 2 pixels of 24 bits (rows of 12 bytes). + let sdib = recs.iter().find(|r| r.0 == 81).unwrap().1; + assert_eq!([u32_at(sdib, 48), u32_at(sdib, 52), u32_at(sdib, 56), u32_at(sdib, 60)], [80, 40, 120, 24]); + assert_eq!(sdib.len(), 144); +} + +#[test] +fn emf_paths_keep_their_curves_brushes_and_pens() { + let e = ellipse(10.0, 10.0, 60.0, 40.0); + let p = Pen { join: Join::Bevel, ..pen(BLUE, 4.0) }; + let b = write_emf(&[fill(e.clone(), RED), stroke(e, p)], 100.0, 100.0); + let recs = emf_records(&b); + let k = kinds(&recs); + assert!(k.contains(&5), "EMR_POLYBEZIERTO keeps the curves"); + assert!(k.contains(&62) && k.contains(&64), "filled, then stroked"); + let brush = recs.iter().find(|r| r.0 == 39).unwrap().1; + assert_eq!(u32_at(brush, 16), 0x0000_00ff, "a red COLORREF"); + let pen_rec = recs.iter().find(|r| r.0 == 95).unwrap().1; + let style = u32_at(pen_rec, 28); + assert_eq!(style & 0x000f_0000, 0x0001_0000, "geometric"); + assert_eq!(style & 0xf00, 0, "round caps"); + assert_eq!(style & 0xf000, 0x1000, "bevel joins"); + assert_eq!(u32_at(pen_rec, 32), (4.0 * 2540.0 / 72.0f64).round() as u32, "4 pt in 0.01 mm"); + assert_eq!(u32_at(pen_rec, 40), 0x00ff_0000, "a blue COLORREF"); + assert!(recs.iter().any(|r| r.0 == 19 && u32_at(r.1, 8) == 2), "WINDING for non-zero"); + // One brush and one pen for two shapes of the same colours. + let two = [ + fill(rect(0.0, 0.0, 10.0, 10.0), RED), + stroke(rect(0.0, 0.0, 10.0, 10.0), pen(BLUE, 1.0)), + fill(rect(20.0, 0.0, 30.0, 10.0), RED), + stroke(rect(20.0, 0.0, 30.0, 10.0), pen(BLUE, 1.0)), + ]; + let k = kinds(&emf_records(&write_emf(&two, 100.0, 100.0))); + assert_eq!((count(&k, 39), count(&k, 95)), (1, 1)); + // A miter join sets the miter limit. + let miter = Pen { join: Join::Miter, miter: 4.0, ..pen(BLUE, 1.0) }; + let recs_bytes = write_emf(&[stroke(rect(0.0, 0.0, 10.0, 10.0), miter)], 100.0, 100.0); + assert!(emf_records(&recs_bytes).iter().any(|r| r.0 == 58 && u32_at(r.1, 8) == 4)); +} + +#[test] +fn emf_dashes_are_pen_styles() { + let l = line((10.0, 10.0), (90.0, 10.0)); + let dashed = |dash: Dash| write_emf(&[stroke(l.clone(), Pen { dash: Some(dash), ..pen(BLUE, 2.0) })], 100.0, 100.0); + let native = Dash { pattern: vec![6.0, 3.0], offset: 0.0 }; + assert!(native.emf_pen_draws()); + let b = dashed(native); + let recs = emf_records(&b); + let pen_rec = recs.iter().find(|r| r.0 == 95).unwrap().1; + assert_eq!(u32_at(pen_rec, 28) & 0xf, 7, "PS_USERSTYLE"); + assert_eq!(u32_at(pen_rec, 48), 2); + assert_eq!([u32_at(pen_rec, 52), u32_at(pen_rec, 56)], [(6.0 * 2540.0 / 72.0f64).round() as u32, (3.0 * 2540.0 / 72.0f64).round() as u32]); + // With an offset, the dashes are separate lines drawn with a solid pen. + let offset = Dash { pattern: vec![6.0, 3.0], offset: 2.0 }; + assert!(!offset.emf_pen_draws()); + let b = dashed(offset); + let recs = emf_records(&b); + assert_eq!(u32_at(recs.iter().find(|r| r.0 == 95).unwrap().1, 28) & 0xf, 0); + assert!(count(&kinds(&recs), 27) > 5, "a move-to for each dash"); + // An odd pattern repeats to an even one; more than 16 entries, or one under a device unit, + // are not a pen's. + assert!(Dash { pattern: vec![4.0; 8], offset: 0.0 }.emf_pen_draws()); + assert!(!Dash { pattern: vec![4.0; 9], offset: 0.0 }.emf_pen_draws()); + assert!(!Dash { pattern: vec![4.0, 0.001], offset: 0.0 }.emf_pen_draws()); +} + +#[test] +fn emf_clips_and_images() { + let ops = [ + Op::Clip { path: rect(10.0, 10.0, 60.0, 30.0), even_odd: true }, + fill(rect(0.0, 0.0, 100.0, 100.0), RED), + Op::Image { image: image(4, 4, [10, 20, 30, 255]), xf: Affine::translate((0.0, 50.0)), opacity: 1.0 }, + Op::Unclip, + Op::Image { image: image(4, 4, [10, 20, 30, 100]), xf: Affine::translate((50.0, 50.0)) * Affine::rotate(0.3), opacity: 1.0 }, + ]; + let b = write_emf(&ops, 100.0, 100.0); + let recs = emf_records(&b); + let k = kinds(&recs); + assert_eq!(count(&k, 67), 1); + assert!(recs.iter().any(|r| r.0 == 67 && u32_at(r.1, 8) == 1), "RGN_AND: inside the clips around it"); + assert_eq!(count(&k, 33), count(&k, 34), "every save is restored"); + // The opaque image is StretchDIBits; the translucent one AlphaBlend with per-pixel alpha. + assert_eq!(count(&k, 81), 1); + let ab = recs.iter().find(|r| r.0 == 114).unwrap().1; + assert_eq!(&ab[40..44], &[0, 0, 255, 1]); + // Images are placed by a world transform, reset after each. + assert_eq!((count(&k, 35), count(&k, 36)), (2, 2)); +} + +#[test] +fn wmf_placeable_header_checksum_and_record_sizes() { + let e = ellipse(10.0, 10.0, 60.0, 40.0); + let out = wmf(&[fill(e.clone(), RED), stroke(e, pen(BLUE, 1.0))], page(72.0, 144.0)).unwrap(); + let b = &out.bytes; + assert_eq!(sniff(b), Some(Kind::Wmf)); + assert_eq!(u32_at(b, 0), 0x9AC6_CDD7); + // The box in logical units at 1440 an inch: 1 x 2 inches. + assert_eq!([u16_at(b, 6), u16_at(b, 8), u16_at(b, 10), u16_at(b, 12)], [0, 0, 1440, 2880]); + assert_eq!(u16_at(b, 14), 1440); + let xor = (0..10).fold(0u16, |a, i| a ^ u16_at(b, 2 * i)); + assert_eq!(u16_at(b, 20), xor, "the checksum"); + // The WMF header: a memory metafile of 9 words, its size in words, the largest record. + assert_eq!([u16_at(b, 22), u16_at(b, 24), u16_at(b, 26)], [1, 9, 0x0300]); + assert_eq!(u32_at(b, 28) as usize * 2, b.len() - 22); + let recs = wmf_records(b); + let largest = recs.iter().map(|r| r.1.len() / 2).max().unwrap(); + assert_eq!(u32_at(b, 34) as usize, largest, "MaxRecord"); + assert_eq!(recs.last().map(|r| (r.0, r.1.len())), Some((0, 6)), "META_EOF ends the file"); + // Objects: the null pen and brush, the brush, the pen. + assert_eq!(u16_at(b, 32), 4); + // The curve is flattened into a polygon, its outline a polygon drawn with the null brush. + let poly = recs.iter().find(|r| r.0 == 0x0538).unwrap().1; + assert_eq!(u16_at(poly, 6), 1); + assert!(u16_at(poly, 8) > 16, "many points"); + assert!(recs.iter().any(|r| r.0 == 0x0324)); + assert_eq!(out, Wmf { bytes: out.bytes.clone(), ..Wmf::default() }, "nothing was lost"); +} + +#[test] +fn wmf_sizes_fit_sixteen_bits() { + assert_eq!(super::wmf::units_per_inch(612.0, 792.0), 1440); + let inch = super::wmf::units_per_inch(3000.0, 1000.0); + assert!(inch < 1440 && f64::from(inch) * 3000.0 / 72.0 <= 32767.0); + let b = wmf(&[fill(rect(0.0, 0.0, 3000.0, 1000.0), RED)], page(3000.0, 1000.0)).unwrap().bytes; + assert_eq!(u16_at(&b, 14), inch); + assert!(u16_at(&b, 10) <= 32767); +} + +#[test] +fn wmf_reports_what_it_leaves_out() { + let clipped = [Op::Clip { path: rect(0.0, 0.0, 20.0, 20.0), even_odd: false }, fill(rect(5.0, 5.0, 40.0, 40.0), RED), Op::Unclip]; + let out = wmf(&clipped, page(100.0, 100.0)).unwrap(); + assert!(out.clips_ignored && !out.translucent_images && !out.turned_images && !out.oversized_images); + // The art the clip covers is written whole. + assert!(wmf_records(&out.bytes).iter().any(|r| r.0 == 0x0538)); + let turned = Op::Image { image: image(2, 2, [0, 0, 0, 10]), xf: Affine::translate((50.0, 50.0)) * Affine::rotate(0.5), opacity: 1.0 }; + let out = wmf(&[turned], page(100.0, 100.0)).unwrap(); + assert!(out.translucent_images && out.turned_images && !out.clips_ignored); + let faded = Op::Image { image: image(2, 2, [0, 0, 0, 255]), xf: Affine::IDENTITY, opacity: 0.5 }; + assert!(wmf(&[faded], page(100.0, 100.0)).unwrap().translucent_images); + // An image whose side does not fit the record's 16-bit sizes is left out. + let wide = Op::Image { image: image(40_000, 1, [0, 0, 0, 255]), xf: Affine::IDENTITY, opacity: 1.0 }; + let out = wmf(&[wide], page(100.0, 100.0)).unwrap(); + assert!(out.oversized_images); + assert!(!wmf_records(&out.bytes).iter().any(|r| r.0 == 0x0F43)); +} + +#[test] +fn an_empty_or_unbounded_region_is_refused() { + for r in [Rect::new(0.0, 0.0, 0.0, 10.0), Rect::new(0.0, 0.0, f64::NAN, 10.0), Rect::new(0.0, 0.0, f64::INFINITY, 10.0)] { + assert!(emf(&[], r, "", "").is_none()); + assert!(wmf(&[], r).is_none()); + } + // Corners in either order are one region. + assert_eq!(emf(&[], Rect::new(72.0, 72.0, 0.0, 0.0), "", ""), emf(&[], page(72.0, 72.0), "", "")); +} + +#[test] +fn rgb24_is_bottom_up_bgr_over_white() { + let (bmi, bits) = dib::rgb24(&sample(), 1.0); + assert_eq!(bmi.len(), 40); + assert_eq!(&bmi[14..16], &[24, 0], "24 bits a pixel"); + // Rows of 9 bytes padded to 12, the last row first: half-transparent black over white is + // mid grey, transparent is white. + assert_eq!(bits, vec![127, 127, 127, 255, 255, 255, 255, 255, 255, 0, 0, 0, 0, 0, 255, 0, 255, 0, 255, 0, 0, 0, 0, 0]); +} + +#[test] +fn bgra32_is_premultiplied() { + let (bmi, bits) = dib::bgra32(&sample(), 1.0); + assert_eq!(&bmi[14..16], &[32, 0]); + assert_eq!(&bits[..12], &[0, 0, 0, 128, 0, 0, 0, 0, 255, 255, 255, 255]); + assert_eq!(&bits[12..16], &[0, 0, 255, 255], "red, as BGRA"); + // At half opacity. + let (_, half) = dib::bgra32(&sample(), 0.5); + assert_eq!(&half[12..16], &[0, 0, 128, 128]); +} + +#[test] +fn written_dibs_decode_with_the_reader() { + let decode = + |(bmi, bits): (Vec, Vec), alpha: bool| crate::dib::decode(crate::bytes::Bytes(&bmi), crate::bytes::Bytes(&bits), alpha).unwrap(); + let back = decode(dib::rgb24(&sample(), 1.0), false); + assert_eq!((back.w, back.h), (3, 2)); + assert_eq!(&back.px[..12], &[255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255]); + assert_eq!(&back.px[12..20], &[127, 127, 127, 255, 255, 255, 255, 255], "over white"); + let back = decode(dib::bgra32(&sample(), 1.0), true); + assert_eq!(&back.px[..12], &sample().rgba[..12]); + assert_eq!((back.px[15], back.px[19]), (128, 0), "the alpha is kept"); + // A short pixel buffer writes the rows it has and no more. + let short = Image { width: 3, height: 2, rgba: vec![255; 12] }; + assert_eq!(dib::rgb24(&short, 1.0).1.len(), 24); +} + +fn close(a: f64, b: f64, tol: f64) -> bool { + (a - b).abs() <= tol +} + +/// The bounding box of a path item's points. +fn bounds(segs: &[Seg]) -> Rect { + let mut pts = Vec::new(); + for s in segs { + match *s { + Seg::Move(x, y) | Seg::Line(x, y) => pts.push(Point::new(f64::from(x), f64::from(y))), + Seg::Cubic(a, b, c, d, x, y) => pts.extend([(a, b), (c, d), (x, y)].map(|(x, y)| Point::new(f64::from(x), f64::from(y)))), + Seg::Close => {} + } + } + pts.iter().skip(1).fold(Rect::from_points(pts[0], pts[0]), |r, p| r.union_pt(*p)) +} + +fn rect_near(a: Rect, b: Rect, tol: f64) -> bool { + close(a.x0, b.x0, tol) && close(a.y0, b.y0, tol) && close(a.x1, b.x1, tol) && close(a.y1, b.y1, tol) +} + +#[test] +fn emf_round_trips_through_the_reader() { + let turned = Affine::translate((120.0, 50.0)) * Affine::rotate(0.3) * Affine::scale(5.0); + let ops = [ + Op::Fill { path: ellipse(10.0, 10.0, 60.0, 40.0), even_odd: true, rgb: RED }, + stroke( + line((10.0, 60.0), (90.0, 60.0)), + Pen { rgb: BLUE, width: 2.0, cap: Cap::Flat, join: Join::Miter, miter: 4.0, dash: Some(Dash { pattern: vec![6.0, 3.0], offset: 0.0 }) }, + ), + Op::Clip { path: rect(100.0, 10.0, 150.0, 30.0), even_odd: false }, + fill(rect(90.0, 0.0, 160.0, 40.0), [0, 255, 0]), + Op::Clip { path: rect(110.0, 0.0, 140.0, 40.0), even_odd: true }, + fill(rect(90.0, 0.0, 160.0, 40.0), BLUE), + Op::Unclip, + Op::Unclip, + Op::Image { image: image(4, 2, [200, 100, 50, 128]), xf: turned, opacity: 1.0 }, + Op::Image { image: image(3, 2, [1, 2, 3, 255]), xf: Affine::translate((10.0, 70.0)) * Affine::scale(4.0), opacity: 1.0 }, + fill(rect(0.0, 90.0, 5.0, 95.0), RED), + ]; + let p = parse(&write_emf(&ops, 200.0, 100.0)).unwrap(); + assert!(close(f64::from(p.width), 200.0, 0.02) && close(f64::from(p.height), 100.0, 0.02), "{} x {}", p.width, p.height); + assert_eq!(p.skipped_records, 0, "the reader knows every record the writer writes"); + // A device unit is 0.01 mm, 0.028 pt. + let tol = 0.05; + let Item::Path { segs, fill: Some([255, 0, 0, 255]), stroke: None, even_odd: true, clips, .. } = &p.items[0] else { panic!("the ellipse") }; + assert!(segs.iter().any(|s| matches!(s, Seg::Cubic(..))), "curves stay curves"); + assert!(rect_near(bounds(segs), Rect::new(10.0, 10.0, 60.0, 40.0), tol) && clips.is_empty()); + let Item::Path { fill: None, stroke: Some(([0, 0, 255, 255], w)), line, .. } = &p.items[1] else { panic!("the dashed line") }; + assert!(close(f64::from(*w), 2.0, tol)); + assert_eq!((line.cap, line.join, line.miter_limit), (crate::Cap::Flat, crate::Join::Miter, 4.0)); + assert!(line.dash.len() == 2 && close(f64::from(line.dash[0]), 6.0, tol) && close(f64::from(line.dash[1]), 3.0, tol), "{:?}", line.dash); + // One clip, then a second inside it; both gone after the two restores. + let Item::Path { fill: Some([0, 255, 0, 255]), even_odd: false, clips: outer, .. } = &p.items[2] else { panic!("the clipped fill") }; + assert_eq!(outer.len(), 1); + assert!(rect_near(bounds(&outer[0].segs), Rect::new(100.0, 10.0, 150.0, 30.0), tol) && !outer[0].even_odd); + let Item::Path { fill: Some([0, 0, 255, 255]), clips: both, .. } = &p.items[3] else { panic!("the twice clipped fill") }; + assert_eq!(both.len(), 2); + assert!(Arc::ptr_eq(&both[0], &outer[0]) && both[1].even_odd); + // The turned image keeps its pixels, its alpha and its turn. + let Item::Bitmap { transform, width: 4, height: 2, rgba, clips, .. } = &p.items[4] else { panic!("the turned image") }; + assert!(clips.is_empty()); + assert!(rgba.as_chunks::<4>().0.iter().all(|c| c[3] == 128 && c[0].abs_diff(200) <= 2 && c[1].abs_diff(100) <= 2 && c[2].abs_diff(50) <= 2)); + let want = (turned * Affine::scale_non_uniform(4.0, 2.0)).as_coeffs(); + assert!(transform.iter().zip(want).all(|(a, b)| close(*a, b, tol)), "{transform:?} {want:?}"); + let Item::Bitmap { rect: r, width: 3, height: 2, rgba, .. } = &p.items[5] else { panic!("the upright image") }; + assert!(rect_near(Rect::new(r[0].into(), r[1].into(), (r[0] + r[2]).into(), (r[1] + r[3]).into()), Rect::new(10.0, 70.0, 22.0, 78.0), tol)); + assert_eq!(rgba.as_slice(), [1, 2, 3, 255].repeat(6)); + let Item::Path { clips, .. } = &p.items[6] else { panic!("the last fill") }; + assert!(clips.is_empty()); + assert_eq!(p.items.len(), 7); +} + +#[test] +fn wmf_round_trips_through_the_reader() { + let mut tri = BezPath::new(); + tri.move_to((10.0, 10.0)); + tri.line_to((60.0, 10.0)); + tri.line_to((10.0, 40.0)); + tri.close_path(); + let ops = [ + Op::Fill { path: tri, even_odd: true, rgb: RED }, + stroke(line((10.0, 50.0), (100.0, 50.0)), Pen { cap: Cap::Square, join: Join::Bevel, ..pen(BLUE, 2.0) }), + fill(ellipse(70.0, 10.0, 130.0, 40.0), [0, 255, 0]), + Op::Image { image: image(3, 2, [1, 2, 3, 255]), xf: Affine::translate((10.0, 55.0)) * Affine::scale(4.0), opacity: 1.0 }, + ]; + let out = wmf(&ops, page(144.0, 72.0)).unwrap(); + let p = parse(&out.bytes).unwrap(); + assert_eq!((p.width, p.height), (144.0, 72.0)); + assert_eq!(p.skipped_records, 0); + // A logical unit is 1/1440 inch, 0.05 pt. + let tol = 0.06; + let Item::Path { segs, fill: Some([255, 0, 0, 255]), stroke: None, even_odd: true, .. } = &p.items[0] else { panic!("the triangle") }; + assert!(rect_near(bounds(segs), Rect::new(10.0, 10.0, 60.0, 40.0), tol)); + let Item::Path { segs, fill: None, stroke: Some(([0, 0, 255, 255], w)), line, .. } = &p.items[1] else { panic!("the line") }; + assert!(close(f64::from(*w), 2.0, tol) && rect_near(bounds(segs), Rect::new(10.0, 50.0, 100.0, 50.0), tol)); + assert_eq!((line.cap, line.join), (crate::Cap::Square, crate::Join::Bevel)); + let Item::Path { segs, fill: Some([0, 255, 0, 255]), even_odd: false, .. } = &p.items[2] else { panic!("the ellipse") }; + assert!(rect_near(bounds(segs), Rect::new(70.0, 10.0, 130.0, 40.0), 0.3), "flattened to within a quarter unit"); + let Item::Bitmap { rect: r, width: 3, height: 2, rgba, .. } = &p.items[3] else { panic!("the image") }; + assert!(rect_near(Rect::new(r[0].into(), r[1].into(), (r[0] + r[2]).into(), (r[1] + r[3]).into()), Rect::new(10.0, 55.0, 22.0, 63.0), tol)); + assert_eq!(rgba.as_slice(), [1, 2, 3, 255].repeat(6)); +} + +#[test] +fn every_prefix_of_a_written_file_reads_without_a_panic() { + let ops = [ + Op::Clip { path: rect(0.0, 0.0, 50.0, 50.0), even_odd: false }, + fill(ellipse(10.0, 10.0, 60.0, 40.0), RED), + stroke(line((0.0, 0.0), (50.0, 50.0)), Pen { dash: Some(Dash { pattern: vec![3.0, 2.0], offset: 0.0 }), ..pen(BLUE, 1.0) }), + Op::Unclip, + Op::Image { image: image(2, 2, [9, 9, 9, 90]), xf: Affine::rotate(0.2), opacity: 0.5 }, + ]; + let e = write_emf(&ops, 100.0, 100.0); + let w = wmf(&ops, page(100.0, 100.0)).unwrap().bytes; + for file in [&e, &w] { + for n in 0..=file.len() { + let _ = parse(&file[..n]); + let _ = crate::size(&file[..n]); + } + // Each byte in turn set to 0xFF. + for at in 0..file.len() { + let mut bad = file.clone(); + bad[at] = 0xFF; + let _ = parse(&bad); + } + } +} + +#[test] +fn a_tiny_dash_on_a_long_path_is_written_solid() { + // Not a pen's pattern (an offset), and a hundred million dashes if cut: the line stays whole. + let tiny = Dash { pattern: vec![0.01, 0.01], offset: 0.005 }; + let ops = [stroke(line((0.0, 0.0), (1.0e6, 0.0)), Pen { dash: Some(tiny), ..pen(BLUE, 1.0) })]; + let b = write_emf(&ops, 100.0, 100.0); + assert!(count(&kinds(&emf_records(&b)), 27) == 1, "one move-to"); + assert!(wmf(&ops, page(100.0, 100.0)).unwrap().bytes.len() < 1000); + // Up to the cap the dashes are cut. + let some = Dash { pattern: vec![1.0, 1.0], offset: 0.5 }; + let b = write_emf(&[stroke(line((0.0, 0.0), (100.0, 0.0)), Pen { dash: Some(some), ..pen(BLUE, 1.0) })], 100.0, 100.0); + assert_eq!(count(&kinds(&emf_records(&b)), 27), 51); + // A pattern that draws nothing (all zero, negative, not finite) is a solid line. + for bad in [vec![0.0, 0.0], vec![-1.0, 2.0], vec![f64::NAN, 1.0], vec![]] { + let d = Dash { pattern: bad, offset: 1.0 }; + let b = write_emf(&[stroke(line((0.0, 0.0), (100.0, 0.0)), Pen { dash: Some(d), ..pen(BLUE, 1.0) })], 100.0, 100.0); + assert_eq!(count(&kinds(&emf_records(&b)), 27), 1); + } +} + +#[test] +fn far_out_curves_flatten_into_a_bounded_number_of_points() { + let curve = |h: f64| { + let mut p = BezPath::new(); + p.move_to((0.0, 0.0)); + p.curve_to((h, 0.0), (h, 50.0), (50.0, 50.0)); + p.close_path(); + p + }; + for h in [1.0e9, 1.0e70, f64::INFINITY, f64::NAN] { + let out = wmf(&[fill(curve(h), RED), stroke(curve(h), pen(BLUE, 1.0))], page(100.0, 100.0)).unwrap(); + assert!(out.bytes.len() < 100_000, "{h}: {} bytes", out.bytes.len()); + let _ = parse(&out.bytes).unwrap(); + let e = write_emf(&[fill(curve(h), RED)], 100.0, 100.0); + let _ = parse(&e).unwrap(); + } +} diff --git a/crates/metafile/src/write/wmf.rs b/crates/metafile/src/write/wmf.rs new file mode 100644 index 000000000..6f2633660 --- /dev/null +++ b/crates/metafile/src/write/wmf.rs @@ -0,0 +1,402 @@ +//! The WMF writer: a placeable header (the picture's size and logical units an inch, with its +//! checksum), the WMF header, then the drawing in 16-bit logical units with an anisotropic window +//! the size of the picture, and the end-of-file record. +//! +//! Curves are flattened into polygons (fills) and polylines (strokes, with pens of the stroke's +//! width, caps and joins; dashes as separate lines). Objects take the lowest free slot of the +//! object table, as players number them: slots 0 and 1 hold a null pen and a null brush, our pen +//! and brush come after and are deleted when replaced. Images are StretchDIB records of 24-bit +//! bitmaps composited over white. + +use kurbo::{Affine, BezPath, Point, Rect}; + +use super::emf::{PS_ENDCAP_FLAT, PS_ENDCAP_SQUARE, PS_JOIN_BEVEL, PS_JOIN_MITER, SRCCOPY, colorref}; +use super::{Image, Op, Out, Pen, Wmf, dib}; +use crate::{Cap, Join}; + +/// The key a placeable header starts with. +pub(crate) const PLACEABLE_KEY: u32 = 0x9AC6_CDD7; + +// Record functions (MS-WMF 2.1.1.1). +pub(crate) const EOF: u16 = 0x0000; +pub(crate) const SETBKMODE: u16 = 0x0102; +pub(crate) const SETMAPMODE: u16 = 0x0103; +pub(crate) const SETPOLYFILLMODE: u16 = 0x0106; +pub(crate) const SETWINDOWORG: u16 = 0x020B; +pub(crate) const SETWINDOWEXT: u16 = 0x020C; +pub(crate) const SELECTOBJECT: u16 = 0x012D; +pub(crate) const DELETEOBJECT: u16 = 0x01F0; +pub(crate) const CREATEPENINDIRECT: u16 = 0x02FA; +pub(crate) const CREATEBRUSHINDIRECT: u16 = 0x02FC; +pub(crate) const POLYGON: u16 = 0x0324; +pub(crate) const POLYLINE: u16 = 0x0325; +pub(crate) const POLYPOLYGON: u16 = 0x0538; +pub(crate) const STRETCHDIB: u16 = 0x0F43; + +/// Most logical units an inch (twips, what office apps use). +const MAX_INCH: f64 = 1440.0; +/// The largest 16-bit coordinate. +const MAX_I16: f64 = 32767.0; +/// Most points a polygon or polyline record holds (its count is 16-bit). +const MAX_POINTS: usize = 32767; +/// Curves are flattened to within this many logical units. +const FLATNESS: f64 = 0.25; +/// The null pen's and the null brush's slots. +const NULL_PEN: u16 = 0; +const NULL_BRUSH: u16 = 1; + +/// The logical units an inch for a picture `w` × `h` points: the most, up to 1440, that keep its +/// size within 16 bits. +pub(crate) fn units_per_inch(w: f64, h: f64) -> u16 { + let side_in = w.max(h) / 72.0; + let fit = if side_in > 0.0 { (MAX_I16 / side_in).floor() } else { MAX_INCH }; + fit.clamp(1.0, MAX_INCH) as u16 +} + +/// The XOR of the first ten 16-bit words of a placeable header. +pub(crate) fn checksum(header: &[u8]) -> u16 { + header.as_chunks::<2>().0.iter().take(10).fold(0, |acc, w| acc ^ u16::from_le_bytes(*w)) +} + +/// What a pen object is made of. +#[derive(Clone, Copy, PartialEq)] +struct PenKey { + style: u16, + width: i16, + color: u32, +} + +struct Writer { + /// The records after the headers. + out: Out, + max_record: u32, + to_dev: Affine, + /// Logical units per point. + k: f64, + /// Which object slots are taken. + slots: Vec, + brush: Option<(u16, u32)>, + pen: Option<(u16, PenKey)>, + sel_pen: u16, + sel_brush: u16, + fill_mode: Option, +} + +/// Write `ops` (curves flattened, no clips) as a placeable WMF of `region`. +pub(crate) fn write(ops: &[Op], region: Rect) -> Wmf { + let inch = units_per_inch(region.width(), region.height()); + let k = f64::from(inch) / 72.0; + let size = |v: f64| (v * k).round().clamp(1.0, MAX_I16) as i16; + let (w, h) = (size(region.width()), size(region.height())); + let mut wr = Writer { + out: Out::default(), + max_record: 0, + to_dev: Affine::scale(k) * Affine::translate(-region.origin().to_vec2()), + k, + slots: vec![], + brush: None, + pen: None, + sel_pen: NULL_PEN, + sel_brush: NULL_BRUSH, + fill_mode: None, + }; + // MM_ANISOTROPIC, a window the size of the picture, transparent background. + wr.record(SETMAPMODE, |o| o.u16(8)); + wr.record(SETWINDOWORG, |o| { + o.i16(0); + o.i16(0); + }); + wr.record(SETWINDOWEXT, |o| { + o.i16(h); + o.i16(w); + }); + wr.record(SETBKMODE, |o| o.u16(1)); + // The null pen and brush (slots 0 and 1), selected while ours change. + wr.alloc(); + wr.record(CREATEPENINDIRECT, |o| { + o.u16(5); + o.i16(0); + o.i16(0); + o.u32(0); + }); + wr.alloc(); + wr.record(CREATEBRUSHINDIRECT, |o| { + o.u16(1); + o.u32(0); + o.u16(0); + }); + wr.select(NULL_PEN); + wr.select(NULL_BRUSH); + let mut lost = Wmf::default(); + for op in ops { + match op { + Op::Fill { path, even_odd, rgb } => wr.fill(path, *even_odd, *rgb), + Op::Stroke { path, pen } => wr.stroke(path, pen), + Op::Image { image, xf, opacity } => { + // The record's sizes are 16-bit. + if image.width > MAX_POINTS as u32 || image.height > MAX_POINTS as u32 { + lost.oversized_images = true; + continue; + } + lost.translucent_images |= !image.opaque() || *opacity < 0.998; + lost.turned_images |= !wr.image(image, *xf, *opacity); + } + // WMF has no clip paths: what a clip covers is written whole. + Op::Clip { .. } => lost.clips_ignored = true, + Op::Unclip => {} + } + } + if let Some((slot, _)) = wr.brush.take() { + wr.select(NULL_BRUSH); + wr.delete(slot); + } + if let Some((slot, _)) = wr.pen.take() { + wr.select(NULL_PEN); + wr.delete(slot); + } + wr.record(EOF, |_| {}); + Wmf { bytes: wr.finish(w, h, inch), ..lost } +} + +impl Writer { + /// Append one record: its size in 16-bit words, its function and `body`. + fn record(&mut self, function: u16, body: impl FnOnce(&mut Out)) { + let start = self.out.len(); + self.out.u32(0); + self.out.u16(function); + body(&mut self.out); + self.out.pad(2); + let words = u32::try_from((self.out.len() - start) / 2).unwrap_or(u32::MAX); + self.out.set_u32(start, words); + self.max_record = self.max_record.max(words); + } + + /// The placeable header, the WMF header, then the records. + fn finish(self, w: i16, h: i16, inch: u16) -> Vec { + let mut o = Out::default(); + o.u32(PLACEABLE_KEY); + o.u16(0); + for v in [0, 0, w, h] { + o.i16(v); + } + o.u16(inch); + o.u32(0); + let sum = checksum(&o.0); + o.u16(sum); + // A memory metafile, 9 words of header, version 3. + o.u16(1); + o.u16(9); + o.u16(0x0300); + o.u32(u32::try_from((18 + self.out.len()) / 2).unwrap_or(u32::MAX)); + o.u16(u16::try_from(self.slots.len()).unwrap_or(u16::MAX)); + o.u32(self.max_record); + o.u16(0); + o.bytes(&self.out.0); + o.0 + } + + /// The lowest free object slot, taken. + fn alloc(&mut self) -> u16 { + let i = match self.slots.iter().position(|t| !t) { + Some(i) => i, + None => { + self.slots.push(false); + self.slots.len() - 1 + } + }; + if let Some(s) = self.slots.get_mut(i) { + *s = true; + } + u16::try_from(i).unwrap_or(u16::MAX) + } + + fn select(&mut self, slot: u16) { + self.record(SELECTOBJECT, |o| o.u16(slot)); + } + + fn delete(&mut self, slot: u16) { + self.record(DELETEOBJECT, |o| o.u16(slot)); + if let Some(s) = self.slots.get_mut(usize::from(slot)) { + *s = false; + } + } + + fn dev(&self, p: Point) -> [i16; 2] { + let q = self.to_dev * p; + let c = |v: f64| if v.is_finite() { v.round().clamp(-MAX_I16, MAX_I16) as i16 } else { 0 }; + [c(q.x), c(q.y)] + } + + /// The device points of `pts`, repeats dropped, thinned to what one record holds. + fn points(&self, pts: &[Point]) -> Vec<[i16; 2]> { + let mut v: Vec<[i16; 2]> = vec![]; + for p in pts { + let d = self.dev(*p); + if v.last() != Some(&d) { + v.push(d); + } + } + if v.len() > MAX_POINTS { + let step = v.len().div_ceil(MAX_POINTS - 1); + let last = v.last().copied(); + v = v.into_iter().step_by(step).collect(); + v.extend(last); + } + v + } + + fn select_pen(&mut self, slot: u16) { + if self.sel_pen != slot { + self.select(slot); + self.sel_pen = slot; + } + } + + fn select_brush(&mut self, slot: u16) { + if self.sel_brush != slot { + self.select(slot); + self.sel_brush = slot; + } + } + + fn use_brush(&mut self, rgb: [u8; 3]) { + let color = colorref(rgb); + let slot = match self.brush { + Some((slot, c)) if c == color => slot, + old => { + if let Some((slot, _)) = old { + self.select_brush(NULL_BRUSH); + self.delete(slot); + } + let slot = self.alloc(); + self.record(CREATEBRUSHINDIRECT, |o| { + o.u16(0); + o.u32(color); + o.u16(0); + }); + self.brush = Some((slot, color)); + slot + } + }; + self.select_brush(slot); + } + + fn use_pen(&mut self, p: &Pen) { + let cap = match p.cap { + Cap::Flat => PS_ENDCAP_FLAT, + Cap::Round => 0, + Cap::Square => PS_ENDCAP_SQUARE, + }; + let join = match p.join { + Join::Miter => PS_JOIN_MITER, + Join::Round => 0, + Join::Bevel => PS_JOIN_BEVEL, + }; + let key = PenKey { style: (cap | join) as u16, width: (p.width * self.k).round().clamp(1.0, MAX_I16) as i16, color: colorref(p.rgb) }; + let slot = match self.pen { + Some((slot, k)) if k == key => slot, + old => { + if let Some((slot, _)) = old { + self.select_pen(NULL_PEN); + self.delete(slot); + } + let slot = self.alloc(); + self.record(CREATEPENINDIRECT, |o| { + o.u16(key.style); + o.i16(key.width); + o.i16(0); + o.u32(key.color); + }); + self.pen = Some((slot, key)); + slot + } + }; + self.select_pen(slot); + } + + fn fill(&mut self, bp: &BezPath, even_odd: bool, rgb: [u8; 3]) { + let polys: Vec> = super::flatten(bp, FLATNESS / self.k).iter().map(|(p, _)| self.points(p)).filter(|p| p.len() > 2).collect(); + if polys.is_empty() { + return; + } + // ALTERNATE (even-odd) or WINDING (non-zero). + let mode = if even_odd { 1 } else { 2 }; + if self.fill_mode != Some(mode) { + self.record(SETPOLYFILLMODE, |o| o.u16(mode)); + self.fill_mode = Some(mode); + } + self.use_brush(rgb); + self.select_pen(NULL_PEN); + for chunk in polys.chunks(usize::from(u16::MAX)) { + self.record(POLYPOLYGON, |o| { + o.u16(chunk.len() as u16); + chunk.iter().for_each(|p| o.u16(p.len() as u16)); + for p in chunk.iter().flatten() { + o.i16(p[0]); + o.i16(p[1]); + } + }); + } + } + + fn stroke(&mut self, bp: &BezPath, pen: &Pen) { + let path = super::dashed(bp, pen.dash.as_ref()); + let lines: Vec<(Vec<[i16; 2]>, bool)> = + super::flatten(&path, FLATNESS / self.k).iter().map(|(p, closed)| (self.points(p), *closed)).filter(|(p, _)| p.len() > 1).collect(); + if lines.is_empty() { + return; + } + self.use_pen(pen); + self.select_brush(NULL_BRUSH); + for (pts, closed) in lines { + // A closed subpath is a polygon (with the null brush, its outline), so it joins at + // its start. + self.record(if closed { POLYGON } else { POLYLINE }, |o| { + o.i16(pts.len() as i16); + for p in &pts { + o.i16(p[0]); + o.i16(p[1]); + } + }); + } + } + + /// An image placed by `xf`, upright: false when `xf` rotates or skews it (then it fills its + /// bounds). + fn image(&mut self, img: &Image, xf: Affine, opacity: f32) -> bool { + if img.width == 0 || img.height == 0 || opacity <= 0.0 { + return true; + } + let m = self.to_dev * xf; + if !m.as_coeffs().iter().all(|v| v.is_finite()) { + return true; + } + let [a, b, c, d, e, f] = m.as_coeffs(); + let (w, h) = (f64::from(img.width), f64::from(img.height)); + let upright = b.abs() < 1e-9 && c.abs() < 1e-9; + let (x, y, dw, dh) = if upright { + (e, f, a * w, d * h) + } else { + let r = m.transform_rect_bbox(Rect::new(0.0, 0.0, w, h)); + (r.x0, r.y0, r.width(), r.height()) + }; + let s = |v: f64| v.round().clamp(-MAX_I16, MAX_I16) as i16; + let (bmi, bits) = dib::rgb24(img, opacity); + self.record(STRETCHDIB, |o| { + o.u32(SRCCOPY); + // DIB_RGB_COLORS; the source and destination rectangles, last field first. + o.u16(0); + o.i16(img.height as i16); + o.i16(img.width as i16); + o.i16(0); + o.i16(0); + o.i16(s(dh)); + o.i16(s(dw)); + o.i16(s(y)); + o.i16(s(x)); + o.bytes(&bmi); + o.bytes(&bits); + }); + upright + } +}