From 9dfc35965c3ea5af2963f1c5c9e60e93acd32a0e Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 11 Nov 2026 13:39:28 -0500 Subject: [PATCH] Add craft-metafile: WMF and EMF reader and writer (#830) * craft-metafile: DeckCraft's WMF, EMF and EMF+ reader with WordCraft's changes merged The base is DeckCraft's crates/metafile at cf0d7fbb. WordCraft's changes since the copy was taken (WordCraft 05679497, the ancestor) are merged three ways from WordCraft 639b8133: the cropped, depth-once DIB decoder, the bitmap side cap, the header-only size(), crop_window, the free-slot object table, capacity-based budget charging and the exact reservation in subpaths. WordCraft's own text records lose to DeckCraft's richer text model. All 120 DeckCraft tests and all 69 WordCraft tests are kept (132 distinct). * craft-metafile: line styles, clip paths and the records behind them, from VectorCraft's reader The model gains what VectorCraft's reader kept and the other two dropped: LineStyle (cap, join, miter limit, dashes) on paths, and clip paths (SELECTCLIPPATH) shared between the items they clip. The reader now plays EXTCREATEPEN style entries and the pen's cap and join bits, SETMITERLIMIT, SETARCDIRECTION, SCALEVIEWPORTEXTEX, SCALEWINDOWEXTEX, SELECTCLIPPATH and ABORTPATH; STROKEANDFILLPATH closes open figures and CLOSEFIGURE moves the current position; the header's micrometre device size is used when present; BITMAPCOREHEADER, BI_JPEG and BI_PNG bitmaps decode; records of unknown types are counted in Picture::skipped_records. Each has a test. * craft-metafile: EMF and WMF writers behind the write feature, bitmap transforms, a decode budget The writers are VectorCraft's, taking a list of drawing operations (fills, strokes with pens, images, clips) instead of its document; the document walk stays in the application. Bitmap items gain a transform, so an image drawn under a turning world transform keeps its turn (the writer places images that way). JPEG and PNG bitmaps share one per-file pixel budget. The tests cover the writers record by record, round-trip both formats through the reader, and play VectorCraft's hand-built files into the picture model. * craft-metafile: a test per limit, README, crate docs Adds tests for the limits no test held to their value (string lengths, clip region rectangles, placed elements, tile side, palette size, nesting, the EMF+ object table, save stack, point lists, gradient stops, region depth, continued objects, bitmap masking work) and a truncation and mutation sweep over the new records. Records that change what is drawn but are not played now count as skipped instead of ignored. The README has the coverage tables, the limits, what stays in the apps, the origin of each part and a migration table per app. * craft-metafile: a region node cap test, the box of a turned bitmap The region node cap gets a test of its own (a balanced tree of intersections), and a turned bitmap's rectangle is the box around its four corners, so a caller that draws rectangles still has an area to draw into. --- Cargo.lock | 34 + Cargo.toml | 1 + README.md | 4 + crates/metafile/Cargo.toml | 21 + crates/metafile/README.md | 210 +++ crates/metafile/src/bytes.rs | 59 + crates/metafile/src/canvas.rs | 1419 ++++++++++++++++ crates/metafile/src/clip.rs | 193 +++ crates/metafile/src/cliprect.rs | 371 +++++ crates/metafile/src/dib.rs | 812 +++++++++ crates/metafile/src/emf.rs | 516 ++++++ crates/metafile/src/emfplus.rs | 1543 +++++++++++++++++ crates/metafile/src/geom.rs | 128 ++ crates/metafile/src/lib.rs | 452 +++++ crates/metafile/src/place.rs | 264 +++ crates/metafile/src/size.rs | 98 ++ crates/metafile/src/tests.rs | 2405 +++++++++++++++++++++++++++ crates/metafile/src/tests/limits.rs | 219 +++ crates/metafile/src/tests/model.rs | 544 ++++++ crates/metafile/src/tests/played.rs | 220 +++ crates/metafile/src/text.rs | 205 +++ crates/metafile/src/wmf.rs | 391 +++++ crates/metafile/src/write.rs | 292 ++++ crates/metafile/src/write/dib.rs | 62 + crates/metafile/src/write/emf.rs | 498 ++++++ crates/metafile/src/write/tests.rs | 527 ++++++ crates/metafile/src/write/wmf.rs | 402 +++++ 27 files changed, 11890 insertions(+) create mode 100644 crates/metafile/Cargo.toml create mode 100644 crates/metafile/README.md create mode 100644 crates/metafile/src/bytes.rs create mode 100644 crates/metafile/src/canvas.rs create mode 100644 crates/metafile/src/clip.rs create mode 100644 crates/metafile/src/cliprect.rs create mode 100644 crates/metafile/src/dib.rs create mode 100644 crates/metafile/src/emf.rs create mode 100644 crates/metafile/src/emfplus.rs create mode 100644 crates/metafile/src/geom.rs create mode 100644 crates/metafile/src/lib.rs create mode 100644 crates/metafile/src/place.rs create mode 100644 crates/metafile/src/size.rs create mode 100644 crates/metafile/src/tests.rs create mode 100644 crates/metafile/src/tests/limits.rs create mode 100644 crates/metafile/src/tests/model.rs create mode 100644 crates/metafile/src/tests/played.rs create mode 100644 crates/metafile/src/text.rs create mode 100644 crates/metafile/src/wmf.rs create mode 100644 crates/metafile/src/write.rs create mode 100644 crates/metafile/src/write/dib.rs create mode 100644 crates/metafile/src/write/emf.rs create mode 100644 crates/metafile/src/write/tests.rs create mode 100644 crates/metafile/src/write/wmf.rs 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 + } +}