emf-converter 3.3.0 → 3.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,9 +4,9 @@
4
4
  [![CI](https://github.com/ChristopherVR/emf-converter/actions/workflows/ci.yml/badge.svg)](https://github.com/ChristopherVR/emf-converter/actions/workflows/ci.yml)
5
5
  [![license](https://img.shields.io/npm/l/emf-converter.svg)](LICENSE)
6
6
 
7
- A zero-dependency TypeScript library that converts **EMF** (Enhanced Metafile) and **WMF** (Windows Metafile) binary buffers into **PNG data URLs** by parsing their record streams and replaying the drawing commands onto an HTML Canvas.
7
+ A zero-dependency TypeScript library that converts **EMF** (Enhanced Metafile, including embedded **EMF+** / GDI+ records) and **WMF** (Windows Metafile) files into **PNG** or **SVG** (markup, a base64 data URL, React elements, or a generated JSX/TSX component).
8
8
 
9
- Windows Metafiles store a sequence of GDI drawing commands and are commonly embedded inside Office documents (Word, PowerPoint) and Windows clipboard data. This converter reads the raw binary, interprets each record, and replays the drawing operations onto a Canvas to produce a rasterised PNG. It handles three formats:
9
+ Windows metafiles are recorded GDI and GDI+ drawing calls, commonly embedded in Office documents and on the Windows clipboard. This library replays those calls the way Windows does: the PNG output is checked pixel for pixel against images painted by Windows itself (hundreds of ground-truth fixtures under `src/__fixtures__/gdi`, generated by `scripts/gdi-fixtures`), and the SVG output keeps vectors, text and gradients resolution-independent.
10
10
 
11
11
  | Format | Description | Coordinate system |
12
12
  | -------- | ------------------------------ | ----------------------- |
@@ -18,24 +18,18 @@ Windows Metafiles store a sequence of GDI drawing commands and are commonly embe
18
18
 
19
19
  ---
20
20
 
21
- ## Breaking change: `convertEmfToDataUrl` / `convertWmfToDataUrl` removed
21
+ ## What's new in 4.0
22
22
 
23
- Versions before 3.0.0 exported two functions, `convertEmfToDataUrl(buffer, options?)` and `convertWmfToDataUrl(buffer, options?)`. They have been replaced by a single auto-detecting function:
24
-
25
- ```diff
26
- -import { convertEmfToDataUrl, convertWmfToDataUrl } from 'emf-converter';
27
- -const emfPng = await convertEmfToDataUrl(emfBuffer);
28
- -const wmfPng = await convertWmfToDataUrl(wmfBuffer);
29
- +import { convertMetafileToDataUrl } from 'emf-converter';
30
- +const emfPng = await convertMetafileToDataUrl(emfBuffer);
31
- +const wmfPng = await convertMetafileToDataUrl(wmfBuffer);
32
- ```
33
-
34
- `convertMetafileToDataUrl` detects the format from the buffer itself, so the same call works for either. See "Quick start" below.
23
+ - **SVG output**: `convertMetafileToSvg`, `convertMetafileToSvgDataUrl`, `convertMetafileToSvgTree` + `svgTreeToReact` / `svgTreeToJsx` for JSX/TSX.
24
+ - **Windows-exact PNG by default**: GDI shapes are drawn by a rasteriser fitted to Windows GDI (28.4 fixed-point geometry, GDI's fill rule, line algorithm, ellipse and Bezier construction, wide pens, dash styles), and EMF+ drawing follows the file's recorded GDI+ `SmoothingMode` with GDI+'s own rasteriser. **Breaking:** default PNG output is no longer Canvas-antialiased; pass `gdiAntialias: true` for the previous smooth edges.
25
+ - **Exact text with the `fonts` option**: a built-in TrueType engine (hinting interpreter, dropout control, GDI font mapping and metrics, grayscale and ClearType) plus Windows raster `.fon` fonts. `loadSystemFonts()` reads the installed fonts in Node.js.
26
+ - **No canvas required**: a built-in pure-JavaScript rasteriser renders SVG anywhere and PNG for drawings without text; `@napi-rs/canvas` is only needed for PNG output with text in plain Node.js.
27
+ - **Complete WMF playback**: bitmaps, clipping, regions, mapping modes, palettes, flood fills, and embedded EMF comments, played as Windows' `PlayMetaFile` plays them.
28
+ - Many correctness fixes found by the new fixtures (see the changelog).
35
29
 
36
30
  ## Demo
37
31
 
38
- Try it right in your browser: drop in an `.emf` or `.wmf` file and see the rendered PNG, conversion time, and output size:
32
+ Drop an `.emf` or `.wmf` file into the browser demo to see the PNG or SVG output, download it, or copy it as a TSX component:
39
33
 
40
34
  **https://christophervr.github.io/emf-converter/**
41
35
 
@@ -45,91 +39,192 @@ Try it right in your browser: drop in an `.emf` or `.wmf` file and see the rende
45
39
  npm install emf-converter
46
40
  ```
47
41
 
48
- No required dependencies. Requires a Canvas API at runtime:
42
+ No required dependencies:
49
43
 
50
- - **Browser / Web Worker**: nothing else to install, `OffscreenCanvas` or `HTMLCanvasElement` is used automatically.
51
- - **Node.js** (no DOM, no Worker): install the optional [`@napi-rs/canvas`](https://www.npmjs.com/package/@napi-rs/canvas) package as well:
44
+ - **Browser / Web Worker**: `OffscreenCanvas` or `HTMLCanvasElement` is used automatically.
45
+ - **Node.js**: SVG output, and PNG output for drawings without text, work out of the box through the built-in rasteriser. For PNG output of drawings with text, either pass `fonts` (see [Exact text](#exact-text)) or install the optional [`@napi-rs/canvas`](https://www.npmjs.com/package/@napi-rs/canvas) (prebuilt, no `node-gyp`):
52
46
 
53
47
  ```bash
54
48
  npm install @napi-rs/canvas
55
49
  ```
56
50
 
57
- It's a prebuilt, Skia-based native module (no `node-gyp` required). When it's not installed, conversion in plain Node.js returns `null` instead of throwing.
51
+ Without either, PNG conversion of a drawing that contains text returns `null` rather than an image missing its text.
58
52
 
59
53
  ## Quick start
60
54
 
61
55
  ```typescript
62
56
  import { convertMetafileToDataUrl } from 'emf-converter';
63
57
 
64
- const emfBuffer: ArrayBuffer = /* loaded from file or network */;
65
- const pngDataUrl = await convertMetafileToDataUrl(emfBuffer);
66
- // => "data:image/png;base64,iVBORw0KGgo..."
58
+ const buffer: ArrayBuffer = /* an .emf or .wmf file */;
59
+ const png = await convertMetafileToDataUrl(buffer);
60
+ // => "data:image/png;base64,iVBORw0KGgo..." (the format is auto-detected)
61
+
62
+ // Limit the output size (aspect ratio preserved), or render at 2x.
63
+ const thumb = await convertMetafileToDataUrl(buffer, { maxWidth: 1024, maxHeight: 768 });
64
+ const hiDpi = await convertMetafileToDataUrl(buffer, { dpiScale: 2 });
65
+
66
+ // Smooth (Canvas-antialiased) edges instead of Windows' own rasterisation.
67
+ const smooth = await convertMetafileToDataUrl(buffer, { gdiAntialias: true });
68
+ ```
69
+
70
+ Returns `Promise<string | null>`; `null` when the buffer is not a valid metafile (or, in plain Node.js, when it has text and neither `fonts` nor `@napi-rs/canvas` is available).
71
+
72
+ ### SVG output
73
+
74
+ ```typescript
75
+ import { convertMetafileToSvg, convertMetafileToSvgDataUrl } from 'emf-converter';
76
+
77
+ const markup = await convertMetafileToSvg(buffer);
78
+ // => '<svg xmlns="http://www.w3.org/2000/svg" width="..." height="..." viewBox="...">...</svg>'
79
+
80
+ const svgUrl = await convertMetafileToSvgDataUrl(buffer);
81
+ // => "data:image/svg+xml;base64,PHN2ZyB4bWxucz0i..." (drop straight into <img src>)
82
+ ```
83
+
84
+ Paths, text, gradients, clipping and pattern brushes stay vectors; bitmaps are embedded as `<image>` elements (PNG/JPEG/GIF/WebP bytes verbatim, never re-encoded). Raster operations that read the destination (all 256 ROP3 codes, bitwise ROP2, pattern brushes through ROP2) are evaluated exactly against a hidden raster mirror and embedded as image patches holding only the pixels they change, so the SVG is the same with or without a canvas backend.
85
+
86
+ ### Rendering in React (JSX / TSX)
87
+
88
+ `convertMetafileToSvgTree` returns a plain `SvgNode` tree. Turn it into live elements with any `createElement`-style factory (React, Preact, ...), so the SVG is part of your component tree and can be styled, sized and given props like any other element:
89
+
90
+ ```tsx
91
+ import { createElement, useEffect, useState, type ReactNode } from 'react';
92
+ import { convertMetafileToSvgTree, svgTreeToReact } from 'emf-converter';
93
+
94
+ export function Metafile({ buffer }: { buffer: ArrayBuffer }) {
95
+ const [svg, setSvg] = useState<ReactNode>(null);
96
+ useEffect(() => {
97
+ let live = true;
98
+ convertMetafileToSvgTree(buffer).then((tree) => {
99
+ if (live && tree) {
100
+ // Extra props land on the root <svg>: override size, add a class, aria, ...
101
+ setSvg(svgTreeToReact(tree, createElement, { width: '100%', height: 'auto', role: 'img' }));
102
+ }
103
+ });
104
+ return () => {
105
+ live = false;
106
+ };
107
+ }, [buffer]);
108
+ return svg;
109
+ }
110
+ ```
111
+
112
+ Or generate a component at build time (the SVGR approach):
113
+
114
+ ```typescript
115
+ import { writeFileSync } from 'node:fs';
116
+ import { convertMetafileToSvgTree, svgTreeToJsx } from 'emf-converter';
117
+
118
+ const tree = await convertMetafileToSvgTree(buffer, { idPrefix: 'logo-' });
119
+ writeFileSync('Logo.tsx', svgTreeToJsx(tree!, { componentName: 'Logo' }));
120
+ // export function Logo(props: SVGProps<SVGSVGElement>) { return (<svg ... {...props}> ... </svg>); }
121
+ ```
122
+
123
+ Attribute names are converted to React's spelling (`stroke-width` → `strokeWidth`, `clip-path` → `clipPath`, `style` strings → style objects). Strings that come from the metafile (font names, text) are always emitted as escaped JavaScript string literals in generated source, never spliced into JSX raw. When several converted SVGs are inlined in one page, give each its own `idPrefix` so their clip-path and gradient ids cannot collide (a unique prefix per conversion is the default).
67
124
 
68
- // Works the same for WMF, the format is auto-detected from the bytes.
69
- const wmfPng = await convertMetafileToDataUrl(wmfBuffer);
125
+ ### Exact text
70
126
 
71
- // Optional: limit output dimensions (aspect ratio preserved)
72
- const scaled = await convertMetafileToDataUrl(emfBuffer, { maxWidth: 1024, maxHeight: 768 });
127
+ Text is only as exact as the fonts it is drawn with. Pass the font files the metafile uses as `fonts` (TrueType `.ttf`/`.ttc` and Windows raster `.fon`/`.fnt`), and text is drawn the way Windows GDI draws it:
128
+
129
+ ```typescript
130
+ import { convertMetafileToDataUrl, loadSystemFonts } from 'emf-converter';
131
+
132
+ const fonts = await loadSystemFonts(); // Node.js only; reuse the array across conversions
133
+ const png = await convertMetafileToDataUrl(buffer, { fonts });
73
134
  ```
74
135
 
75
- Returns `Promise<string | null>`, `null` if the buffer is invalid or no Canvas API is available.
136
+ - Fonts are realised the way GDI's font mapper does it (face substitutes, pitch/family fallback, weight choice, cell vs em height, `lfWidth` stretching), with GDI's metrics, advances, underline and strike-out.
137
+ - Glyphs are grid-fitted by the font's own TrueType instructions (including Windows' ClearType rules), scan-converted with dropout control, and placed on GDI's integer grid honouring Dx arrays, `ETO_*` flags and every `TA_*` alignment.
138
+ - Non-antialiased, grayscale or ClearType rendering is chosen from the font's quality; `fontSmoothing` sets what `DEFAULT_QUALITY` means (Windows' default is ClearType).
139
+ - Raster faces (MS Sans Serif, MS Serif, Courier, Small Fonts, System, Terminal, Fixedsys, Helv, Tms Rmn) are drawn from their bitmaps with GDI's size choice and stretching.
140
+ - Rotated text uses GDI's rounded font matrix; EMF+ `DrawString` honours the text rendering hint, string-format tracking and margins, and texture/gradient brushes.
141
+
142
+ Without `fonts`, text is drawn by the host's canvas font engine (supply `fontFamilyMap` to remap Windows face names). SVG output always keeps text as `<text>`; with `fonts` it carries GDI's exact per-glyph positions.
76
143
 
77
144
  ## API
78
145
 
79
146
  ### `convertMetafileToDataUrl(buffer, options?)`
80
147
 
81
- | Parameter | Type | Description |
82
- | ----------- | ----------------------------- | ---------------------------------------------------- |
83
- | `buffer` | `ArrayBuffer` | Raw EMF or WMF file bytes (format is auto-detected) |
84
- | `options` | `EmfConvertOptions` (optional)| Output size, DPI scale, record limits, font mapping |
85
- | **Returns** | `Promise<string \| null>` | PNG data URL or `null` on failure |
148
+ | Parameter | Type | Description |
149
+ | ----------- | ------------------------------ | --------------------------------------------------- |
150
+ | `buffer` | `ArrayBuffer` | Raw EMF or WMF file bytes (format is auto-detected) |
151
+ | `options` | `EmfConvertOptions` (optional) | See below |
152
+ | **Returns** | `Promise<string \| null>` | PNG data URL, or `null` on failure |
86
153
 
87
154
  #### `EmfConvertOptions`
88
155
 
89
- | Field | Type | Default | Description |
90
- | -------------------- | -------------------------- | -------------- | --------------------------------------------------------------------------- |
91
- | `maxWidth` | `number` | None | Maximum output width in pixels (aspect ratio preserved) |
92
- | `maxHeight` | `number` | None | Maximum output height in pixels |
93
- | `dpiScale` | `number` | `1` | Resolution multiplier for sharper output; clamped to `4` |
94
- | `maxCanvasDimension` | `number` | `8192` | Hard cap on canvas width/height in pixels |
95
- | `maxRecords` | `number` | `200000`/`500000` | Cap on records processed per stream before replay stops (EMF+ uses the higher default unless overridden) |
96
- | `fontFamilyMap` | `Record<string, string>` | None | Maps Windows face names (case-insensitive) to fonts available locally, e.g. `{ calibri: 'Carlito' }` |
97
-
98
- ```ts
99
- const png = await convertMetafileToDataUrl(buffer, {
100
- dpiScale: 2,
101
- fontFamilyMap: { calibri: 'Carlito', 'ms shell dlg': 'Tahoma' },
102
- });
103
- ```
156
+ | Field | Type | Default | Description |
157
+ | -------------------- | ------------------------------------- | ----------------- | ----------- |
158
+ | `maxWidth` | `number` | None | Maximum output width in pixels (aspect ratio preserved) |
159
+ | `maxHeight` | `number` | None | Maximum output height in pixels |
160
+ | `dpiScale` | `number` | `1` | Resolution multiplier; clamped to `4` |
161
+ | `maxCanvasDimension` | `number` | `8192` | Hard cap on output width/height in pixels |
162
+ | `maxRecords` | `number` | `200000`/`500000` | Records processed per stream before replay stops (EMF+ uses the higher default unless overridden) |
163
+ | `gdiAntialias` | `boolean` | `false` (PNG) | `true` smooths every shape edge with Canvas antialiasing instead of reproducing Windows' own GDI/GDI+ rasterisation |
164
+ | `fonts` | `Array<ArrayBuffer \| ArrayBufferView>` | None | TrueType (`.ttf`/`.ttc`) and raster (`.fon`/`.fnt`) font files for exact GDI text |
165
+ | `fontSmoothing` | `'cleartype' \| 'gray' \| 'mono'` | `'cleartype'` | What `DEFAULT_QUALITY` / `DRAFT_QUALITY` / `PROOF_QUALITY` fonts render as (Windows' system setting) |
166
+ | `fontFamilyMap` | `Record<string, string>` | None | Without `fonts`: maps Windows face names (case-insensitive) to locally available fonts, e.g. `{ calibri: 'Carlito' }` |
167
+
168
+ ### SVG functions
169
+
170
+ | Function | Returns |
171
+ | --- | --- |
172
+ | `convertMetafileToSvg(buffer, options?)` | `Promise<string \| null>`, standalone SVG markup |
173
+ | `convertMetafileToSvgDataUrl(buffer, options?)` | `Promise<string \| null>`, a `data:image/svg+xml;base64,...` URL |
174
+ | `convertMetafileToSvgTree(buffer, options?)` | `Promise<SvgNode \| null>`, the tree the helpers below consume |
175
+ | `svgTreeToString(tree)` / `svgTreeToDataUrl(tree)` | Markup / base64 data URL for an existing tree |
176
+ | `svgTreeToReact(tree, createElement, rootProps?)` | Live elements via `React.createElement` (or any compatible factory) |
177
+ | `svgTreeToJsx(tree, { componentName?, typescript?, spreadProps? })` | JSX/TSX component source code |
178
+
179
+ #### `SvgConvertOptions` (extends `EmfConvertOptions`)
180
+
181
+ | Field | Type | Default | Description |
182
+ | --- | --- | --- | --- |
183
+ | `gdiAntialias` | `boolean` | `true` (SVG) | `false` embeds Windows' aliased GDI shape pixels as image patches instead of smooth vector edges |
184
+ | `exactRasterOps` | `boolean` | `true` | `false` skips the raster mirror and expresses destination-reading raster ops with SVG `mix-blend-mode` equivalents |
185
+ | `imageResampling` | `'renderer' \| 'exact'` | `'renderer'` | `'exact'` bakes EMF+ `DrawImage` at device resolution with GDI+'s resampling kernel instead of letting the SVG renderer scale the original image |
186
+ | `includeSize` | `boolean` | `true` | Emit `width`/`height` on the root `<svg>` (`viewBox` is always emitted); `false` gives a fluid SVG |
187
+ | `idPrefix` | `string` | `emf1-`, `emf2-`, ... | Prefix for generated element ids; keep it unique per inlined SVG |
188
+
189
+ ### `loadSystemFonts(options?)`
190
+
191
+ Node.js only (returns `[]` elsewhere; the package stays browser-safe). Reads the installed `.ttf`, `.ttc`, `.fon` and `.fnt` files from the platform font folders (Windows, Linux, macOS, and per-user folders) for the `fonts` option. Options: `dirs` (scan these instead), `filter(path, name)`, `maxDepth`.
104
192
 
105
193
  ## How it works
106
194
 
107
- A three-phase pipeline: **parse → replay → export**. The header parser extracts the drawing bounds, a Canvas is created and clamped to 8192×8192 (configurable via `maxCanvasDimension`), then records are scanned sequentially and dispatched to GDI, EMF+, or WMF handlers that drive the Canvas 2D context. Embedded bitmaps (DIB and GDI+ pixel formats) and recursively embedded metafiles are resolved asynchronously after the synchronous replay completes.
195
+ A three-phase pipeline: **parse → replay → export**. The header parser reads the drawing bounds (a placeable WMF is sized from its header's units per inch), the output surface is created (clamped to `maxCanvasDimension`), and the records are replayed in order by the GDI, EMF+ or WMF handlers. PNG output draws onto a Canvas (OffscreenCanvas, HTMLCanvasElement, `@napi-rs/canvas`, or the built-in pure-JavaScript rasteriser); SVG output draws onto `SvgContext`, a recorder implementing the part of the Canvas 2D API the replay uses, mirrored onto a hidden raster wherever a raster operation must read the destination.
196
+
197
+ Everything below is verified against output painted by Windows itself; `src/gdi-parity.fixture.test.ts` holds the per-fixture bounds.
198
+
199
+ - **GDI shapes** (`gdi-raster.ts`, `gdi-raster-widen.ts`): 28.4 fixed-point geometry; GDI's fill rule (ALTERNATE/WINDING); one-pixel lines by GDI's diamond rule with its tie-breaks; GDI's own Bezier flattener, ellipse, rounded-rectangle, arc (GDI's trigonometry table and `SetArcDirection`), chord and pie construction; cosmetic dash styles (dash 18/6, dot 3/3, ...) and geometric dashes; wide pens widened from GDI's own pen polygons with every cap and join and the miter limit; rotated and skewed world transforms. Pixel-exact on the shape fixtures.
200
+ - **Raster operations**: all 256 ROP3 codes for `BitBlt`/`StretchBlt`/`StretchDIBits`/`PatBlt`, exact per bit against the destination, brush and source, with GDI's stretch modes, mirrored rectangles and rotated destinations (each device pixel mapped back to one source texel). Every `SetROP2` mode, including the bitwise AND/OR/XOR family, for shapes, paths and pattern-brush fills.
201
+ - **Brushes**: hatch, monochrome and DIB pattern brushes anchored to the brush origin, with the background mode; GDI+ solid, hatch, texture (bilinear, WrapMode-aware, as GDI+ samples them), linear gradients (GDI+'s own interpolation table: preset colours, blend shapes, gamma correction, every WrapMode) and path gradients (true boundary-shaped falloff, every WrapMode).
202
+ - **Clipping**: every GDI and GDI+ region combine mode exact for every clip (vector where possible, otherwise scan-converted to pixel regions, which is how GDI stores them), path clips with their fill mode, and region offsets.
203
+ - **EMF+**: fills, pens and clips follow the recorded `SmoothingMode` with GDI+'s own fill rasteriser (8 x 4-sample antialiasing, blend arithmetic) and pen widener (joins, caps, dash caps, compound lines, inset alignment); `DrawImage` with every InterpolationMode/PixelOffsetMode kernel, ImageAttributes wrap modes, drawn in record order under the live clip; embedded metafiles replayed as vectors; continuation records reassembled; compressed textures and images decoded before replay.
204
+ - **Text**: see [Exact text](#exact-text).
205
+ - **WMF**: played as `PlayMetaFile` plays it (GM_COMPATIBLE whole-pixel rules, mapping modes, bitmaps, pattern brushes, clipping and regions, palettes, flood fills, text spacing and justification, right-to-left layout); an EMF embedded in `MFCOMMENT` escapes is played instead, as Windows does.
108
206
 
109
- It supports 300+ EMF GDI record types, the EMF+ (GDI+) record set, and legacy WMF records, including state, transforms, objects, shapes, poly/path operations, text, bitmaps, gradients, raster operations, and clipping:
207
+ ### Supported records
110
208
 
111
- - **Clip regions with full boolean combine modes**: the converter tracks the active clip as a list of path shapes, so `Intersect`, `Union`, `Xor`, `Exclude`, `Complement`, and `Replace` combine modes work for `EMR_INTERSECTCLIPRECT` / `EMR_EXCLUDECLIPRECT` / `EMR_EXTSELECTCLIPRGN` (all `RGN_*` modes), the EMF+ `SetClipRect` / `SetClipPath` / `SetClipRegion` records (all `CombineMode` values, including nested region-node trees), and clip translation via `EMR_OFFSETCLIPRGN` / EMF+ `OffsetClip`. Subtraction and symmetric difference are expressed through even-odd fill-rule clipping, which Canvas 2D cannot do with plain `clip()` stacking.
112
- - **Gradient brushes**: GDI+ linear gradients render as Canvas linear gradients with their full colour-stop list (preset blend colours and blend factors are expanded into stops, and the optional brush transform rotates or shears the gradient axis). `WrapMode` tiling (`Tile` / `TileFlipX` / `TileFlipY` / `TileFlipXY`) is supported for a linear gradient at any angle, by unrolling one period into a single hard-stopped Canvas gradient spanning the drawing surface. Path gradients render the true boundary-shaped falloff (fanned into triangles from the centre point, with per-vertex surround colours, blend curves, preset colours, and focus scales), not a radial approximation, rasterised into a tiled `CanvasPattern` for all five `WrapMode` values. Verified against real GDI+ fixtures under `src/__fixtures__/gdi/`; see [Limitations](#limitations) for the residual, measured mismatch in flip-mode tiling.
113
- - **Raster operations (ROP3)**: `EMR_BITBLT`, `EMR_STRETCHBLT`, and `EMR_STRETCHDIBITS` (and `PatBlt`-style brush-only fills) evaluate all 256 ROP3 codes exactly, per pixel and per bit, against the real destination, brush, and (when present) source pixels, matching Windows GDI bit-for-bit rather than approximating with Canvas composite modes.
114
- - **Raster operations (ROP2)**: every `SetROP2` mode is mapped. `R2_BLACK`, `R2_WHITE`, `R2_NOP`, `R2_COPYPEN`, `R2_NOTCOPYPEN`, and `R2_NOT` are emulated exactly via colour inversion and `difference` compositing. The bitwise AND/OR/XOR family (`R2_MASKPEN`, `R2_MERGEPEN`, `R2_XORPEN`, `R2_NOTXORPEN`, `R2_MASKPENNOT`, `R2_MERGEPENNOT`, `R2_MASKNOTPEN`, `R2_MERGENOTPEN`, `R2_NOTMASKPEN`, `R2_NOTMERGEPEN`) is evaluated exactly too, bit-for-bit against the true destination pixel, for a filled and/or stroked Rectangle, RoundRect, Ellipse, Arc/Chord/Pie, Polygon, Polyline, or PolyPolygon, whether drawn immediately or built up across an `EMR_BEGINPATH`/`EMR_ENDPATH` bracket (`EMR_FILLPATH`/`EMR_STROKEANDFILLPATH`/`EMR_STROKEPATH`): the shape's geometry is replayed onto an isolated scratch canvas (recorded during the bracket for the path case; see `emf-gdi-path-record.ts`) to get its exact per-pixel coverage and raw paint colour (unaffected by anti-aliasing, since compositing onto a fully transparent destination cannot blend), then combined into the destination with the same truth-table evaluator the ROP3 blit path uses. See [Limitations](#limitations) for the measured anti-aliasing residual at a shape's own boundary.
115
- - **GDI world transforms**: `EMR_SETWORLDTRANSFORM` / `EMR_MODIFYWORLDTRANSFORM`'s full affine, including rotation and skew, is applied to Rectangle, RoundRect, Ellipse, Arc/Chord/Pie, Polygon, Polyline, PolyPolygon, path (`BeginPath`/`MoveTo`/`LineTo`/`Poly*To`/`EndPath`) drawing, bitmap blits (`EMR_BITBLT`/`EMR_STRETCHBLT`/`EMR_STRETCHDIBITS`, including the exact ROP3 path), and raster text placement (`EMR_EXTTEXTOUTW`): an affine map always carries an ellipse to another ellipse, so a rotated/skewed Ellipse (and the corresponding Arc/Chord/Pie) is rendered by decomposing the mapped shape's exact semi-axes and rotation angle (via the eigendecomposition of the linear part), not by scaling the original radii independently. A rounded rectangle's straight edges rotate/skew correctly; its corner radius stays axis-aligned even then (see [Limitations](#limitations)). A rotated bitmap blit is evaluated exactly (ROP3 truth table, brush pattern, source stretch-mode sampling) on an unrotated local raster sized from the transform's true per-axis magnitude, then placed onto the canvas with the same device-space basis vectors, so only the unavoidable final placement resamples (measured against real GDI: see [Limitations](#limitations)); this was verified against real GDI output, which does rotate a `BitBlt`/`TextOut` destination under a rotated world transform (`src/__fixtures__/gdi/rotate-bitblt-25deg`, `rotate-text-25deg`), contrary to the frequent assumption that GDI raster ops ignore world-transform rotation entirely. This full-affine handling is also required for GDI+-exported EMF files even without rotation, since they record coordinates at 16× sub-pixel precision with a compensating world-transform scale. EMF+ records already supported the full affine transform set.
116
- - **GDI pattern-brush fills**: a hatch, monochrome (`EMR_CREATEMONOBRUSH`), or DIB (`EMR_CREATEDIBPATTERNBRUSHPT`) pattern brush selected for a Rectangle/RoundRect/Ellipse/Arc/Polygon/Polyline/PolyPolygon fill (or a bracketed path's `EMR_FILLPATH`/`EMR_STROKEANDFILLPATH`) paints the real tiled pattern, anchored to the brush origin (`EMR_SETBRUSHORGEX`), instead of a flat placeholder colour. This is filled per pixel (via the shape's already-built path and the same `sampleTile` sampler the exact ROP3 blit evaluator uses) rather than through a Canvas `CanvasPattern`: every canvas backend this package targets was measured to filter a `CanvasPattern` regardless of `imageSmoothingEnabled` (which only ever affects `drawImage`), smearing a hard-edged pattern tile's texels across several device pixels even at a 1:1 pattern transform. `EMR_CREATEDIBPATTERNBRUSHPT`/`EMR_CREATEMONOBRUSH` pattern brushes used as the *source* of a `BitBlt`/`StretchBlt`/`PatBlt` ROP3 blit were already exact before this (see Raster operations (ROP3) above); this closes the same brushes used for a vector shape fill.
117
- - **EMF+ TextureFill brushes**: an EMF+ TextureFill brush (brush type 2, `EmfPlusTextureBrushData`) whose embedded image is an uncompressed pixel-format bitmap decodes and paints exactly, tiled per `WrapMode` and placed by the brush transform, the same way the gradient brushes' tiled `CanvasPattern` works. A compressed (PNG/JPEG) embedded image, the common real-world case, is now decoded too, via an async pre-decode pass (`emf-plus-texture-predecode.ts`) that walks the metafile for compressed texture-brush images and decodes them before the (otherwise fully synchronous) replay begins, so the brush's synchronous parse can consult the cached pixels; see [Limitations](#limitations) for the `CanvasPattern`-tiling residual this shares with the pattern-brush fills above, and the one case (a brush object split across `EMFPLUS_OBJECT` continuation records) still not pre-decoded.
118
- - **EMF+ Image objects**: `BitmapDataType` ([MS-EMFPLUS] 2.1.1.2: `Pixel = 0`, `Compressed = 1`) is read correctly for a standalone Image object (used by `DrawImage`/`DrawImagePoints`), verified against a real GDI+-recorded `DrawImage` of a PNG-backed `Bitmap` (`src/__fixtures__/gdi/image-draw-png`). An earlier version of this parser had the two values swapped, which fed a compressed image's PNG/JPEG bytes into the raw-pixel decoder as if they were uncompressed pixels (corrupting the image) and silently dropped a genuine uncompressed pixel bitmap (`BitmapDataType = 0`) entirely, since neither of its two branches matched that value.
209
+ - **EMF**: 115 of the 119 record types defined in MS-EMF, including logical palettes (`PALETTEINDEX`, `DIBPALETTEINDEX`, `PALETTERGB`, `DIB_PAL_COLORS`), `EMR_ALPHABLEND` (Windows' exact integer blend), `EMR_TRANSPARENTBLT`, `EMR_MASKBLT`, `EMR_PLGBLT`, `EMR_SETDIBITSTODEVICE`, `EMR_GRADIENTFILL` (rectangles and triangles), `EMR_FILLRGN` / `EMR_FRAMERGN` / `EMR_INVERTRGN` / `EMR_PAINTRGN`, `EMR_EXTFLOODFILL`, `EMR_ANGLEARC`, `EMR_POLYDRAW(16)`, `EMR_FLATTENPATH` / `EMR_WIDENPATH` / `EMR_ABORTPATH`, alongside shapes, paths, `EMR_EXTTEXTOUTW`, blits, clipping and transforms. Colour space, ICM, OpenGL, escape and font-driver records are consumed without effect, as on a Windows display.
210
+ - **EMF+**: every record in MS-EMFPLUS (Beziers, cardinal curves, regions, containers, save/restore, compositing mode, rendering origin, text contrast, `StrokeFillPath`, the terminal-server `SetTSGraphics` / `SetTSClip`, `MultiFormat*` played as GDI+ plays them), with 32-bit, compressed 16-bit and relative point data, and every object type (solid, hatch, texture and gradient brushes; pens with caps, joins, dash styles, dash caps, compound lines and custom line caps; paths, regions, bitmap and metafile images, fonts, string formats, image attributes). Several encodings follow what GDI+ actually does where it differs from MS-EMFPLUS (relative points, `StrokeFillPath`, `SetTSGraphics`, `SetTSClip`).
211
+ - **WMF**: META_ANIMATEPALETTE, META_ARC, META_BITBLT, META_CHORD, META_CREATEBITMAP, META_CREATEBITMAPINDIRECT, META_CREATEBRUSH, META_CREATEBRUSHINDIRECT, META_CREATEFONTINDIRECT, META_CREATEPALETTE, META_CREATEPATTERNBRUSH, META_CREATEPENINDIRECT, META_CREATEREGION, META_DELETEOBJECT, META_DIBBITBLT, META_DIBCREATEPATTERNBRUSH, META_DIBSTRETCHBLT, META_ELLIPSE, META_EOF, META_ESCAPE, META_EXCLUDECLIPRECT, META_EXTFLOODFILL, META_EXTTEXTOUT, META_FILLREGION, META_FLOODFILL, META_FRAMEREGION, META_INTERSECTCLIPRECT, META_INVERTREGION, META_LINETO, META_MOVETO, META_OFFSETCLIPRGN, META_OFFSETVIEWPORTORG, META_OFFSETWINDOWORG, META_PAINTREGION, META_PATBLT, META_PIE, META_POLYGON, META_POLYLINE, META_POLYPOLYGON, META_REALIZEPALETTE, META_RECTANGLE, META_RESIZEPALETTE, META_RESTOREDC, META_ROUNDRECT, META_SAVEDC, META_SCALEVIEWPORTEXT, META_SCALEWINDOWEXT, META_SELECTCLIPREGION, META_SELECTOBJECT, META_SELECTPALETTE, META_SETBKCOLOR, META_SETBKMODE, META_SETDIBTODEV, META_SETLAYOUT, META_SETMAPMODE, META_SETMAPPERFLAGS, META_SETPALENTRIES, META_SETPIXEL, META_SETPOLYFILLMODE, META_SETRELABS, META_SETROP2, META_SETSTRETCHBLTMODE, META_SETTEXTALIGN, META_SETTEXTCHAREXTRA, META_SETTEXTCOLOR, META_SETTEXTJUSTIFICATION, META_SETVIEWPORTEXT, META_SETVIEWPORTORG, META_SETWINDOWEXT, META_SETWINDOWORG, META_STRETCHBLT, META_STRETCHDIB, META_TEXTOUT. Where Windows no longer plays a record the way MS-WMF describes it (Win16 device bitmaps in META_BITBLT / META_STRETCHBLT, META_CREATEPATTERNBRUSH, banded META_SETDIBTODEV), the converter follows Windows.
119
212
 
120
213
  ## Limitations
121
214
 
122
- - **Approximated edge cases in region ops**: all six combine modes are exact while the tracked clip is at most one composable shape (the overwhelmingly common case). When the clip is already an intersection of several shapes, or was set from a live path bracket (`EMR_SELECTCLIPPATH`), `Union` / `Xor` / `Complement` degrade to the nearest conservative approximation (a console log notes when this happens).
123
- - **A shape's own boundary is still Canvas-anti-aliased**: the exact bitwise-ROP2 and pattern-brush-fill techniques above are pixel-exact in a shape's interior, but GDI rasterises a shape's edge (and a rotated/skewed one especially) without anti-aliasing, while Canvas's `fill()`/`stroke()` always anti-aliases; measured against real GDI fixtures, this residual is under 4% of pixels for an axis-aligned pattern fill or ROP2 grid (`src/__fixtures__/gdi/pattern-fill-*`, `rop2-bitwise-grid`), around 7.5% for a bitwise-ROP2 shape built as a `BeginPath`/`EndPath` bracket rather than an immediate shape (`src/__fixtures__/gdi/rop2-bitwise-path-bracket`; a pentagon has more boundary length per unit area than the grid's rectangles), and under 3% for a rotated Ellipse/Polygon/RoundRect or a skewed Rectangle, worse (up to 10%) for a rotated Rectangle specifically, whose four long diagonal edges each carry the residual (`src/__fixtures__/gdi/rotate-*`, `skew-rect`). See `src/gdi-parity.fixture.test.ts` for the exact, per-fixture tolerances this is regression-tested against.
124
- - **A rounded rectangle's corner radius stays axis-aligned under GDI world-transform rotation/skew**: its straight edges rotate/skew exactly (mapped through the full affine), but `arcTo`, which the corner arcs are built from, assumes a locally right-angled corner that does not survive a skew, so a rotated/skewed RoundRect's corners are approximated with straight tangent lines instead of true rotated/sheared elliptical arcs.
125
- - **A rotated bitmap blit's destination inevitably resamples once at placement**: the ROP3 combine itself (source stretch sampling, brush pattern, destination read) is exact on an unrotated local raster, but painting that raster into a rotated position on the canvas is a `drawImage` call under a near-1:1 (but not exactly axis-aligned) transform, which cannot stay nearest-neighbour-exact at every output pixel the way the axis-aligned blit path can; measured against real GDI output, this residual is under 1% of pixels for a 25-degree rotated `BitBlt` (`src/__fixtures__/gdi/rotate-bitblt-25deg`). A skewed (non-similarity) world transform decomposes the transform's per-axis magnitude via `Math.hypot`, which is exact for rotation + uniform scale but an approximation under a general skew.
126
- - **Rotated raster text placement uses a single decomposed rotation angle**: `EMR_EXTTEXTOUTW` under a rotated `EMR_SETWORLDTRANSFORM` maps its reference point through the full affine and rotates the glyph run by `atan2` of the transform's linear part, matching a pure rotation (optionally combined with the font's own escapement) exactly; a general skew has no single rotation angle that reproduces it exactly, so glyph shapes are not additionally sheared. Measured against real GDI output for a 25-degree rotation, the residual (glyph rasterisation differences, present even without rotation) is under 3% of pixels (`src/__fixtures__/gdi/rotate-text-25deg`).
127
- - **Bitwise ROP2/pattern-brush-fill combination is untested and approximated**: a pattern brush combined with a bitwise `SetROP2` mode on the same shape fill is not implemented as an exact combination; the fill uses the flat-colour ROP2 approximation instead of the exact pattern fill in that specific combination (rare in practice).
128
- - **Gradient tiling has a small, measured residual, worst in flip modes**: linear-gradient tiling is pixel-exact except for float colour rounding at period seams (measured against real GDI+ output, worst case 1.46% of pixels differing by up to 11 levels on one channel, for `InterpolationColors` preset stops; a handful of individual seam pixels can differ by more where a hard colour step falls exactly on a device pixel boundary). Path-gradient tiling carries a larger residual in `TileFlipX/Y/XY` modes (measured 1.7-6.5% of pixels for the fixtures in `src/__fixtures__/gdi/grad-path-*`, worse for a boundary whose gradient centre is off-centre in its own bounding box, such as an explicit `CenterPoint`): the renderer supersamples a device-resolution raster tile and mirrors it geometrically, while GDI+ mirrors its own already-rasterised tile with a small, undocumented sub-pixel lag at the seam. `Clamp` mode (no tiling) is closest to exact, typically under 1.2% (edge anti-aliasing only). See `src/gdi-parity.fixture.test.ts` for the exact, per-fixture tolerances this is regression-tested against.
129
- - **A compressed EMF+ TextureFill brush shares the pattern-brush `CanvasPattern` tiling residual**: the async pre-decode pass (see above) now paints the real decoded image instead of falling back to black, but the tile is still placed through a Canvas `CanvasPattern`, which (per the pattern-brush-fill note above) every tested canvas backend filters at tile seams regardless of `imageSmoothingEnabled`; measured against a real GDI+ fixture with a coarse (8px-block) test texture, this residual is around 25% of pixels at the default comparison tolerance (`src/__fixtures__/gdi/texture-fill-compressed`), worse for a finer/higher-frequency texture. A texture brush object split across `EMFPLUS_OBJECT` continuation records (large enough to exceed one record) is not pre-decoded at all and keeps the solid-colour fallback, since the assembled continuation buffer's byte offsets do not correspond to the pre-scan pass's offsets into the original file.
130
- - **`DrawImage`'s own resampling filter is not reproduced exactly**: decoding a standalone EMF+ Image object now correctly distinguishes `BitmapDataType` Pixel from Compressed (see above), but the actual resample GDI+'s `DrawImage` performs when scaling (bilinear by default) is not itself reproduced; Canvas's own `drawImage` resampling is used instead. Measured against a real GDI+ fixture with a 2x and a non-integer scale (`src/__fixtures__/gdi/image-draw-png`), this residual is around 18-20% of pixels at the default comparison tolerance for a high-contrast test image; a photographic or low-contrast source image would show a much smaller residual, since the differences are concentrated at hard colour transitions.
131
- - **Safety limits**: output is clamped to 8192×8192 and replay stops after 200,000 records (EMF/WMF) or 500,000 (EMF+). All three are overridable via `maxCanvasDimension` / `maxRecords`.
132
- - **Font rendering** uses the host Canvas font engine, so glyph metrics may differ from Windows GDI. Weight, italic, underline, and strike-out are honoured; supply `fontFamilyMap` to remap Windows face names to fonts available in your environment.
215
+ Everything is measured against output painted by Windows itself; `src/gdi-parity.fixture.test.ts` holds the exact per-fixture bounds.
216
+
217
+ - **Unhandled records**: `EMR_EXTTEXTOUTA`, `EMR_POLYTEXTOUTA`, `EMR_POLYTEXTOUTW` and `EMR_SMALLTEXTOUT` (ANSI, multi-string and small-glyph text records, rarely written by modern recorders) are skipped with a console warning. `EMR_SETTEXTJUSTIFICATION` and `EMR_SETCOLORADJUSTMENT` are read but not yet applied (Windows' own recorder bakes justification into `EMR_EXTTEXTOUTW` spacing arrays, so only other writers emit the former), and the `HALFTONE` stretch mode is not bit-exact. EMF+ image effects (`SerializableObject`: blur, sharpen, colour matrix and the like) are not applied, so the image is drawn without the effect, and an EMF+ pen's own transform is ignored.
218
+ - **Text without fonts**: without the `fonts` option, glyphs come from the host font engine, so text differs from Windows on a few percent of pixels (and depends on which fonts the host has). SVG text is `<text>`, rendered with the viewer's fonts.
219
+ - **Text with fonts**: non-antialiased text is within 0.03% of Windows' pixels (about 93% of glyphs bit-exact; the rest differ by a pixel on some diagonal stems, where GDI's hinting arithmetic is undocumented) and grayscale text within 0.13%. ClearType text, which is what `DEFAULT_QUALITY` fonts get on a default Windows install, is within about 4-5% (compatible-width glyph placement is not fully reproduced). Non-grid-fitted GDI+ AntiAlias and ClearType text differ by 11-13% of text pixels (GDI+'s own glyph placement and blend), rotated right/centre-aligned text can start a pixel off, and a few unusual raster-font sizes pick a different bitmap size than Windows.
220
+ - **Wide pens and paths**: flat-capped GDI pens 7 px and wider can differ by a few pixels at round joins, and dashed wide Bezier curves follow `WidenPath` (which Windows' direct drawing does not quite match); at most 0.2% of pixels on the fixtures. `EMR_WIDENPATH` does not reproduce the extra inner join triangles GDI's own `WidenPath` emits (visible only when the widened outline is itself stroked). EMF+ 1-pixel antialiased lines can differ by one antialiasing sample at their ends, some closed widened outlines by one sample along an edge, and Inset or compound pens on closed figures are approximate.
221
+ - **GM_COMPATIBLE recordings**: EMF files do not record the graphics mode, and Windows plays RoundRect, Arc, Chord, Pie and null-pen Ellipse records back differently from how a GM_COMPATIBLE application drew them on screen; the converter follows Windows' playback.
222
+ - **Small EMF+ residuals**: rotated `HighQualityBicubic` `DrawImage` edge pixels (0.14%), one-level differences at exact half-level `Blend` knots, and a few pixels of a metafile nested in `DrawImage` under a scale.
223
+ - **WMF**: `PS_INSIDEFRAME` boxes can come out a pixel short at non-integer scales, right-to-left (`LAYOUT_RTL`) layouts differ by single pixels on mirrored diagonals, and metric map modes assume a 96 dpi reference device (Windows derives them from the physical display, so its own output varies per machine).
224
+ - **Smooth mode** (`gdiAntialias: true`, and SVG's default vector edges): only shape edges differ from Windows, by design; a flood fill reads the antialiased pixels it is given.
225
+ - **SVG renderers**: output is renderer-dependent in the usual ways: angled hard-stop gradient seams fall within each renderer's own precision, and a raster operation that reads pixels under text is exact wherever its result does not depend on the glyphs, otherwise expressed as a blend layer the SVG renderer applies to its own text.
226
+ - **PNG in plain Node.js** without `@napi-rs/canvas` and without `fonts` returns `null` for drawings that contain text, rather than an image missing its text.
227
+ - **Safety limits**: output is clamped to 8192×8192 and replay stops after 200,000 records (EMF/WMF) or 500,000 (EMF+); both are overridable via `maxCanvasDimension` / `maxRecords`.
133
228
 
134
229
  ## License
135
230