emf-converter 3.3.0 → 3.4.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 +95 -19
- package/dist/index.d.mts +175 -25
- package/dist/index.d.ts +175 -25
- package/dist/index.js +4159 -656
- package/dist/index.mjs +4153 -657
- package/package.json +8 -2
package/README.md
CHANGED
|
@@ -4,9 +4,9 @@
|
|
|
4
4
|
[](https://github.com/ChristopherVR/emf-converter/actions/workflows/ci.yml)
|
|
5
5
|
[](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
|
|
7
|
+
A zero-dependency TypeScript library that converts **EMF** (Enhanced Metafile) and **WMF** (Windows Metafile) binary buffers into **PNG data URLs** or **SVG** (markup, a base64 data URL, React elements, or a generated JSX/TSX component) by parsing their record streams and replaying the drawing commands.
|
|
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 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 either onto a Canvas to produce a rasterised PNG, or onto an SVG recorder that keeps vectors, text, and gradients resolution-independent. It handles three formats:
|
|
10
10
|
|
|
11
11
|
| Format | Description | Coordinate system |
|
|
12
12
|
| -------- | ------------------------------ | ----------------------- |
|
|
@@ -45,7 +45,7 @@ Try it right in your browser: drop in an `.emf` or `.wmf` file and see the rende
|
|
|
45
45
|
npm install emf-converter
|
|
46
46
|
```
|
|
47
47
|
|
|
48
|
-
No required dependencies.
|
|
48
|
+
No required dependencies. PNG output needs a Canvas API at runtime; SVG output needs none (it works anywhere JavaScript runs, and uses a canvas only when one is available, for exact destination-reading raster ops and text measurement):
|
|
49
49
|
|
|
50
50
|
- **Browser / Web Worker**: nothing else to install, `OffscreenCanvas` or `HTMLCanvasElement` is used automatically.
|
|
51
51
|
- **Node.js** (no DOM, no Worker): install the optional [`@napi-rs/canvas`](https://www.npmjs.com/package/@napi-rs/canvas) package as well:
|
|
@@ -54,7 +54,7 @@ No required dependencies. Requires a Canvas API at runtime:
|
|
|
54
54
|
npm install @napi-rs/canvas
|
|
55
55
|
```
|
|
56
56
|
|
|
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.
|
|
57
|
+
It's a prebuilt, Skia-based native module (no `node-gyp` required). When it's not installed, PNG conversion in plain Node.js returns `null` instead of throwing; SVG conversion still works.
|
|
58
58
|
|
|
59
59
|
## Quick start
|
|
60
60
|
|
|
@@ -74,6 +74,59 @@ const scaled = await convertMetafileToDataUrl(emfBuffer, { maxWidth: 1024, maxHe
|
|
|
74
74
|
|
|
75
75
|
Returns `Promise<string | null>`, `null` if the buffer is invalid or no Canvas API is available.
|
|
76
76
|
|
|
77
|
+
### SVG output
|
|
78
|
+
|
|
79
|
+
```typescript
|
|
80
|
+
import { convertMetafileToSvg, convertMetafileToSvgDataUrl } from 'emf-converter';
|
|
81
|
+
|
|
82
|
+
const markup = await convertMetafileToSvg(buffer);
|
|
83
|
+
// => '<svg xmlns="http://www.w3.org/2000/svg" width="..." height="..." viewBox="...">...</svg>'
|
|
84
|
+
|
|
85
|
+
const svgUrl = await convertMetafileToSvgDataUrl(buffer);
|
|
86
|
+
// => "data:image/svg+xml;base64,PHN2ZyB4bWxucz0i..." (drop straight into <img src>)
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
SVG output needs **no canvas at all**, so it also works in plain Node.js without `@napi-rs/canvas`. Paths, text, gradients, clipping, and pattern brushes stay vectors; bitmaps are embedded as PNG/JPEG `<image>` elements (browser-native formats are embedded as-is, never re-encoded).
|
|
90
|
+
|
|
91
|
+
### Rendering in React (JSX / TSX)
|
|
92
|
+
|
|
93
|
+
`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:
|
|
94
|
+
|
|
95
|
+
```tsx
|
|
96
|
+
import { createElement, useEffect, useState, type ReactNode } from 'react';
|
|
97
|
+
import { convertMetafileToSvgTree, svgTreeToReact } from 'emf-converter';
|
|
98
|
+
|
|
99
|
+
export function Metafile({ buffer }: { buffer: ArrayBuffer }) {
|
|
100
|
+
const [svg, setSvg] = useState<ReactNode>(null);
|
|
101
|
+
useEffect(() => {
|
|
102
|
+
let live = true;
|
|
103
|
+
convertMetafileToSvgTree(buffer).then((tree) => {
|
|
104
|
+
if (live && tree) {
|
|
105
|
+
// Extra props land on the root <svg>: override size, add a class, aria, ...
|
|
106
|
+
setSvg(svgTreeToReact(tree, createElement, { width: '100%', height: 'auto', role: 'img' }));
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
return () => {
|
|
110
|
+
live = false;
|
|
111
|
+
};
|
|
112
|
+
}, [buffer]);
|
|
113
|
+
return svg;
|
|
114
|
+
}
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Or generate a component at build time (the SVGR approach) and commit or import the `.tsx` file:
|
|
118
|
+
|
|
119
|
+
```typescript
|
|
120
|
+
import { writeFileSync } from 'node:fs';
|
|
121
|
+
import { convertMetafileToSvgTree, svgTreeToJsx } from 'emf-converter';
|
|
122
|
+
|
|
123
|
+
const tree = await convertMetafileToSvgTree(buffer, { idPrefix: 'logo-' });
|
|
124
|
+
writeFileSync('Logo.tsx', svgTreeToJsx(tree!, { componentName: 'Logo' }));
|
|
125
|
+
// export function Logo(props: SVGProps<SVGSVGElement>) { return (<svg ... {...props}> ... </svg>); }
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
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 the same page, give each its own `idPrefix` so their clip-path/gradient ids cannot collide (a unique prefix per conversion is the default).
|
|
129
|
+
|
|
77
130
|
## API
|
|
78
131
|
|
|
79
132
|
### `convertMetafileToDataUrl(buffer, options?)`
|
|
@@ -94,6 +147,7 @@ Returns `Promise<string | null>`, `null` if the buffer is invalid or no Canvas A
|
|
|
94
147
|
| `maxCanvasDimension` | `number` | `8192` | Hard cap on canvas width/height in pixels |
|
|
95
148
|
| `maxRecords` | `number` | `200000`/`500000` | Cap on records processed per stream before replay stops (EMF+ uses the higher default unless overridden) |
|
|
96
149
|
| `fontFamilyMap` | `Record<string, string>` | None | Maps Windows face names (case-insensitive) to fonts available locally, e.g. `{ calibri: 'Carlito' }` |
|
|
150
|
+
| `gdiAntialias` | `boolean` | `true` | `false` rasterises GDI vector shapes without antialiasing on GDI's pixel grid, matching Windows output pixel for pixel (slower on shape-heavy files) |
|
|
97
151
|
|
|
98
152
|
```ts
|
|
99
153
|
const png = await convertMetafileToDataUrl(buffer, {
|
|
@@ -102,32 +156,54 @@ const png = await convertMetafileToDataUrl(buffer, {
|
|
|
102
156
|
});
|
|
103
157
|
```
|
|
104
158
|
|
|
159
|
+
### SVG functions
|
|
160
|
+
|
|
161
|
+
| Function | Returns |
|
|
162
|
+
| --- | --- |
|
|
163
|
+
| `convertMetafileToSvg(buffer, options?)` | `Promise<string \| null>`, standalone SVG markup |
|
|
164
|
+
| `convertMetafileToSvgDataUrl(buffer, options?)` | `Promise<string \| null>`, a `data:image/svg+xml;base64,...` URL |
|
|
165
|
+
| `convertMetafileToSvgTree(buffer, options?)` | `Promise<SvgNode \| null>`, the tree the helpers below consume |
|
|
166
|
+
| `svgTreeToString(tree)` / `svgTreeToDataUrl(tree)` | Markup / base64 data URL for an existing tree |
|
|
167
|
+
| `svgTreeToReact(tree, createElement, rootProps?)` | Live elements via `React.createElement` (or any compatible factory) |
|
|
168
|
+
| `svgTreeToJsx(tree, { componentName?, typescript?, spreadProps? })` | JSX/TSX component source code |
|
|
169
|
+
|
|
170
|
+
#### `SvgConvertOptions` (extends `EmfConvertOptions`)
|
|
171
|
+
|
|
172
|
+
| Field | Type | Default | Description |
|
|
173
|
+
| --- | --- | --- | --- |
|
|
174
|
+
| `exactRasterOps` | `boolean` | `true` | Evaluate destination-reading raster ops (exact ROP3 blits, exact bitwise ROP2) against a hidden raster mirror when a canvas backend exists, embedding only the pixels they change. `false` (or no canvas) uses SVG `mix-blend-mode` equivalents instead |
|
|
175
|
+
| `includeSize` | `boolean` | `true` | Emit `width`/`height` on the root `<svg>` (the `viewBox` is always emitted). `false` gives a fluid SVG that fills its container |
|
|
176
|
+
| `idPrefix` | `string` | `emf1-`, `emf2-`, ... | Prefix for generated element ids; keep it unique per inlined SVG |
|
|
177
|
+
|
|
178
|
+
`maxWidth`/`maxHeight`/`dpiScale`/`maxCanvasDimension` define the SVG's coordinate space (`viewBox`) and the resolution of any embedded raster content.
|
|
179
|
+
|
|
105
180
|
## How it works
|
|
106
181
|
|
|
107
182
|
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.
|
|
108
183
|
|
|
184
|
+
For SVG output the same handlers drive `SvgContext` (`svg-context.ts`), a recorder implementing the part of the Canvas 2D API the replay uses: paths are accumulated in device space exactly as Canvas does (arcs and ellipses as cubic Beziers, which affine maps carry exactly), fills become device-space `<path>`s, strokes are mapped back into their stroke-time user space so line widths and dashes scale like Canvas's, successive clips become a chain of `<clipPath>`s, gradients and pattern brushes become paint servers, text becomes `<text>`, and raster content becomes PNG `<image>`s encoded by a small dependency-free PNG encoder (`png-encoder.ts`). With no canvas implementation at all, bitmap scratch work runs on a pure-JavaScript raster (`software-raster.ts`), so SVG conversion works in plain Node.js without `@napi-rs/canvas`.
|
|
185
|
+
|
|
109
186
|
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:
|
|
110
187
|
|
|
111
|
-
- **Clip regions with
|
|
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
|
|
188
|
+
- **Clip regions with exact boolean combine modes**: `Intersect`, `Union`, `Xor`, `Exclude`, `Complement`, and `Replace` are exact for every clip, for `EMR_INTERSECTCLIPRECT` / `EMR_EXCLUDECLIPRECT` / `EMR_EXTSELECTCLIPRGN` (all `RGN_*` modes), path-bracket clips (`EMR_SELECTCLIPPATH`, combined with any region mode), the EMF+ `SetClipRect` / `SetClipPath` / `SetClipRegion` records (all `CombineMode` values, including nested region-node trees), and clip translation via `EMR_OFFSETCLIPRGN` / EMF+ `OffsetClip`. Simple cases keep their vector form: subtraction and symmetric difference are expressed through even-odd fill-rule clipping, which Canvas 2D cannot do with plain `clip()` stacking. Everything else (a clip that is already an intersection of several shapes, self-intersecting or curved operands) is evaluated exactly by scan-converting the operands at device pixel centres with their own fill rules (`emf-clip-scanline.ts`) and storing the result as disjoint pixel-aligned rectangles, which is how GDI itself stores regions.
|
|
189
|
+
- **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 gradient spanning the drawing surface. Path gradients render the true boundary-shaped falloff (per-vertex surround colours, blend curves, preset colours, focus scales, curved boundaries flattened at GDI+'s own 0.25 flatness), computed per device pixel for all five `WrapMode` values, with each device point folded analytically into the base tile (`emf-plus-exact-fill.ts`). Verified against real GDI+ fixtures under `src/__fixtures__/gdi/`: path gradients match within 0.15% of pixels in every wrap mode.
|
|
113
190
|
- **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
|
|
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
|
|
191
|
+
- **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: the shape's coverage is computed on/off per pixel (GDI does not antialias) within the shape's bounding box, combined with the exact pen/brush colour by the same truth-table evaluator the ROP3 blit path uses, and composited through the active clip region. A pattern (hatch, monochrome, or DIB) brush fill combines exactly with every `SetROP2` mode, bitwise ones included.
|
|
192
|
+
- **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 (`EMR_EXTTEXTOUTW`). A rotated/skewed Ellipse (and Arc/Chord/Pie) is rendered from the mapped shape's exact semi-axes and rotation angle (the eigendecomposition of the linear part). RoundRect corners are exact elliptical arcs honouring unequal corner width and height, built as Beziers in logical space and mapped through the transform, so they rotate and skew exactly. Text under a skewed transform is sheared as well as rotated, matching GDI. Bitmap blits under a rotated or skewed transform are evaluated exactly (ROP3 truth table, brush pattern, source stretch-mode sampling) on a local raster whose axes are the exact transformed basis vectors, sampled at least once per device pixel, and then placed; real GDI does rotate a `BitBlt`/`TextOut` destination (`src/__fixtures__/gdi/rotate-bitblt-25deg`, `rotate-text-25deg`). 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.
|
|
116
193
|
- **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
|
-
- **
|
|
118
|
-
- **EMF+
|
|
194
|
+
- **GDI pixel grid**: thin (odd-width) pens paint the pixels their coordinates name, as GDI does, rather than two half-intensity rows straddling a pixel boundary. The `gdiAntialias: false` option goes further and rasterises every GDI vector fill and stroke without antialiasing on GDI's own pixel grid, matching Windows output to within 0.25% of pixels on the real-GDI shape fixtures, at a 3-4x cost on shape-heavy files.
|
|
195
|
+
- **EMF+ TextureFill brushes**: a TextureFill brush (brush type 2) paints its real image, tiled per `WrapMode` and placed by the brush and world transforms. Shape fills (FillRects, FillEllipse, FillPie, FillPolygon, FillPath) are computed one device pixel at a time and match a real GDI+ fixture pixel-exactly. Compressed (PNG/JPEG) embedded images, the common real-world case, are decoded by an async pre-decode pass (`emf-plus-texture-predecode.ts`) before the synchronous replay starts. EMF+ objects split across `EMFPLUS_OBJECT` continuation records, including ones spread over several `EMR_COMMENT` records, are reassembled by one routine shared by the replay and the pre-decode pass (`emf-plus-continuation.ts`), so a large split texture brush decodes like any other.
|
|
196
|
+
- **EMF+ Image objects and DrawImage**: `BitmapDataType` ([MS-EMFPLUS] 2.1.1.2: `Pixel = 0`, `Compressed = 1`) is read correctly for a standalone Image object. `DrawImage` / `DrawImagePoints` honour the source rectangle and the full destination parallelogram (so rotation and shear are kept), and resample the way GDI+ does for `InterpolationMode` Default / LowQuality / Bilinear / NearestNeighbor under `PixelOffsetMode` None or Half (`emf-plus-image-resample.ts`), matching a real GDI+ `DrawImage` fixture (`src/__fixtures__/gdi/image-draw-png`) within 8 levels per channel on every pixel.
|
|
119
197
|
|
|
120
198
|
## Limitations
|
|
121
199
|
|
|
122
|
-
- **
|
|
123
|
-
- **A
|
|
124
|
-
- **
|
|
125
|
-
- **
|
|
126
|
-
- **
|
|
127
|
-
-
|
|
128
|
-
- **
|
|
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.
|
|
200
|
+
- **Curved and diagonal GDI edges are antialiased by default**: GDI rasterises shape edges without antialiasing, while Canvas (and SVG renderers) smooth them, so by default about 1-3% of pixels differ on the rotated/curved real-GDI fixtures (`src/__fixtures__/gdi/rotate-*`, `skew-rect`, `pattern-fill-ellipse-color`, `rop2-bitwise-path-bracket`); axis-aligned shapes are exact or nearly so. With `gdiAntialias: false` the residual drops to occasional one-pixel stepping differences on steep lines and curves (at most 0.25%). See `src/gdi-parity.fixture.test.ts` for the per-fixture tolerances this is regression-tested against.
|
|
201
|
+
- **A rotated bitmap blit resamples once at placement**: the ROP3 combine itself is exact on the local raster, but painting that raster into a rotated position is a resample that cannot stay nearest-neighbour-exact at every output pixel; measured against real GDI output this is under 1% of pixels for a 25-degree rotated `BitBlt` (`src/__fixtures__/gdi/rotate-bitblt-25deg`).
|
|
202
|
+
- **Glyph rasterisation differs from GDI's font engine**: text placement, rotation, and shear match GDI, but glyphs are drawn by the host font engine, so rotated text still differs on about 2.4% of pixels (`src/__fixtures__/gdi/rotate-text-25deg`), mostly along glyph edges.
|
|
203
|
+
- **Preset-colour linear gradients differ slightly from GDI+**: linear gradients with `InterpolationColors` preset stops differ from GDI+ on about 1.5% of pixels, along the preset stops. GDI+ appears to blend preset colours through a coarse lookup table that never quite reaches a peak stop colour, while this library interpolates them exactly; without the table's exact size there is nothing exact to model. All other linear gradients, including every `WrapMode`, are exact up to float colour rounding at period seams. Path gradients match within 0.15% of pixels (single pixels right on a curved or diagonal boundary).
|
|
204
|
+
- **Texture and path-gradient brushes on strokes and text**: shape fills with these brushes are exact (see above), but strokes, pens, and text painted with a texture or path-gradient brush still go through a Canvas `CanvasPattern`, which every canvas backend filters, so their edges come out slightly soft. Texture brushes are sampled nearest-neighbour, so a texture scaled or rotated by its brush or world transform does not reproduce GDI+'s interpolated texture sampling.
|
|
205
|
+
- **`DrawImage` Bicubic/HighQuality modes and clipping**: GDI+ resampling is modelled for the Default, LowQuality, Bilinear, and NearestNeighbor interpolation modes; Bicubic and the HighQuality modes, and embedded metafile images, fall back to the canvas's own scaling. In PNG output, deferred image draws (decoded asynchronously after replay) are painted on top without the clip region that was active when they were recorded; SVG output keeps each image at its recorded z-order and clip.
|
|
206
|
+
- **SVG output**: SVG cannot read back what is already painted, so raster operations that depend on the destination (exact ROP3 blits, exact bitwise `SetROP2`) are evaluated against a hidden raster mirror when a canvas backend is available and embedded as image patches holding only the pixels they change; without a canvas backend (or with `exactRasterOps: false`) they fall back to `mix-blend-mode` equivalents (exact for the usual SRCAND/SRCPAINT/SRCINVERT mask idioms on black-and-white masks, approximate otherwise; ROP3 codes with no blend-mode equivalent are skipped). Embedded bitmaps are scaled by the SVG renderer's own image smoothing (they stay sharp when the SVG is scaled up) rather than baked at device resolution with GDI+'s resampler. Text is emitted as `<text>` and rendered with whatever fonts the viewer has, and rendering varies slightly between SVG renderers (measured in Chromium, SVG output matches the PNG output's parity on the real-GDI fixtures).
|
|
131
207
|
- **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
208
|
- **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.
|
|
133
209
|
|
package/dist/index.d.mts
CHANGED
|
@@ -1,20 +1,109 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
* the application.
|
|
2
|
+
* A tiny, framework-agnostic SVG document model plus its serialisers.
|
|
4
3
|
*
|
|
5
|
-
* The
|
|
6
|
-
*
|
|
4
|
+
* The SVG backend (`svg-context.ts`) records a metafile's drawing into an
|
|
5
|
+
* {@link SvgNode} tree rather than straight into markup, so the same result
|
|
6
|
+
* can be handed to whichever consumer needs it:
|
|
7
|
+
*
|
|
8
|
+
* - {@link svgTreeToString}: standalone SVG markup (`<svg xmlns=...>...</svg>`)
|
|
9
|
+
* - {@link svgTreeToDataUrl}: a `data:image/svg+xml;base64,...` URL, usable
|
|
10
|
+
* anywhere an image URL is (`<img src>`, CSS `background-image`, ...)
|
|
11
|
+
* - {@link svgTreeToReact}: live elements for React (or Preact, Solid's
|
|
12
|
+
* `h`, Vue's `h`, ...), built through a caller-supplied `createElement`,
|
|
13
|
+
* so this package never depends on a UI framework
|
|
14
|
+
* - {@link svgTreeToJsx}: JSX/TSX component source code, for build-time code
|
|
15
|
+
* generation in the style of SVGR
|
|
16
|
+
*
|
|
17
|
+
* Attribute names are stored in their SVG (kebab-case) spelling; the React
|
|
18
|
+
* and JSX serialisers convert them to the camelCase property names React
|
|
19
|
+
* expects (`stroke-width` → `strokeWidth`, `clip-path` → `clipPath`, and a
|
|
20
|
+
* `style` string becomes a style object).
|
|
21
|
+
*
|
|
22
|
+
* @module svg-tree
|
|
23
|
+
*/
|
|
24
|
+
/** One SVG element. Text content (for `<text>`) lives in {@link SvgNode.text}. */
|
|
25
|
+
interface SvgNode {
|
|
26
|
+
/** Element name, e.g. `path`, `g`, `linearGradient`. */
|
|
27
|
+
tag: string;
|
|
28
|
+
/** Attributes in SVG spelling (`stroke-width`, `clip-path`, `style`). */
|
|
29
|
+
attrs: Record<string, string | number>;
|
|
30
|
+
/** Child elements, in paint order. */
|
|
31
|
+
children?: SvgNode[];
|
|
32
|
+
/** Character data for text-bearing elements. Mutually exclusive with children. */
|
|
33
|
+
text?: string;
|
|
34
|
+
}
|
|
35
|
+
/** Serialises a tree to SVG markup. The root should be an `<svg>` element. */
|
|
36
|
+
declare function svgTreeToString(node: SvgNode): string;
|
|
37
|
+
/** Serialises a tree to a base64 `data:image/svg+xml` URL. */
|
|
38
|
+
declare function svgTreeToDataUrl(node: SvgNode): string;
|
|
39
|
+
/**
|
|
40
|
+
* A `createElement`-compatible factory: `React.createElement`, Preact's `h`,
|
|
41
|
+
* and most hyperscript-style factories fit this shape.
|
|
42
|
+
*/
|
|
43
|
+
type CreateElement<E> = (type: string, props: Record<string, unknown> | null, ...children: unknown[]) => E;
|
|
44
|
+
/**
|
|
45
|
+
* Builds live framework elements from a tree via `createElement`.
|
|
46
|
+
*
|
|
47
|
+
* ```tsx
|
|
48
|
+
* import { createElement } from 'react';
|
|
49
|
+
* const tree = await convertMetafileToSvgTree(buffer);
|
|
50
|
+
* return tree ? svgTreeToReact(tree, createElement, { className: 'figure', width: '100%' }) : null;
|
|
51
|
+
* ```
|
|
52
|
+
*
|
|
53
|
+
* @param node - The tree (normally the root `<svg>`).
|
|
54
|
+
* @param createElement - `React.createElement` or a compatible factory.
|
|
55
|
+
* @param rootProps - Extra props merged onto the root element (override
|
|
56
|
+
* `width`/`height`, add `className`, `role`, ...).
|
|
57
|
+
*/
|
|
58
|
+
declare function svgTreeToReact<E>(node: SvgNode, createElement: CreateElement<E>, rootProps?: Record<string, unknown>): E;
|
|
59
|
+
/** Options for {@link svgTreeToJsx}. */
|
|
60
|
+
interface SvgJsxOptions {
|
|
61
|
+
/** Component name. Default `Metafile`. */
|
|
62
|
+
componentName?: string;
|
|
63
|
+
/** Emit TypeScript (`SVGProps<SVGSVGElement>` typing). Default `true`. */
|
|
64
|
+
typescript?: boolean;
|
|
65
|
+
/**
|
|
66
|
+
* Spread the component's props onto the root `<svg>` (after the recorded
|
|
67
|
+
* attributes, so callers can override `width`/`height`). Default `true`.
|
|
68
|
+
*/
|
|
69
|
+
spreadProps?: boolean;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Generates JSX/TSX source for a React component rendering the tree, for
|
|
73
|
+
* build-time code generation (write it to a `.tsx` file and import it).
|
|
74
|
+
*
|
|
75
|
+
* ```ts
|
|
76
|
+
* const tree = await convertMetafileToSvgTree(buffer, { idPrefix: 'logo-' });
|
|
77
|
+
* writeFileSync('Logo.tsx', svgTreeToJsx(tree!, { componentName: 'Logo' }));
|
|
78
|
+
* ```
|
|
79
|
+
*
|
|
80
|
+
* The output uses the automatic JSX runtime (no `import React` needed); with
|
|
81
|
+
* `typescript: true` it imports only the `SVGProps` type from `react`.
|
|
82
|
+
*/
|
|
83
|
+
declare function svgTreeToJsx(node: SvgNode, options?: SvgJsxOptions): string;
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Public API: auto-detecting EMF/WMF conversion to PNG or SVG.
|
|
87
|
+
*
|
|
88
|
+
* The conversion pipeline for both formats and both outputs follows the same
|
|
89
|
+
* high-level steps:
|
|
90
|
+
* 1. Parse the file header to determine logical bounds and output dimensions
|
|
7
91
|
* (tried as EMF first, then WMF, to auto-detect the format).
|
|
8
|
-
* 2. Create an in-memory canvas (OffscreenCanvas
|
|
9
|
-
* fallback, or the optional `@napi-rs/canvas`
|
|
10
|
-
*
|
|
92
|
+
* 2. Create a drawing surface: an in-memory canvas (OffscreenCanvas
|
|
93
|
+
* preferred, HTMLCanvasElement fallback, or the optional `@napi-rs/canvas`
|
|
94
|
+
* Node.js backend) for PNG output, or an {@link SvgContext} recorder for
|
|
95
|
+
* SVG output.
|
|
96
|
+
* 3. Replay every metafile record onto the surface in order.
|
|
11
97
|
* 4. Resolve "deferred images", bitmap / embedded-metafile draws that require
|
|
12
98
|
* async image decoding (via {@link createImageBitmap} or, in Node.js, the
|
|
13
|
-
* `@napi-rs/canvas` `loadImage` helper).
|
|
14
|
-
*
|
|
99
|
+
* `@napi-rs/canvas` `loadImage` helper). SVG output embeds PNG/JPEG/GIF/
|
|
100
|
+
* WebP bytes verbatim and nested metafiles as nested SVG, with no decode.
|
|
101
|
+
* 5. Export: a `data:image/png;base64,...` URL, or an SVG tree that can be
|
|
102
|
+
* serialised to markup, a data URL, React elements, or JSX source.
|
|
15
103
|
*
|
|
16
104
|
* @module emf-converter
|
|
17
105
|
*/
|
|
106
|
+
|
|
18
107
|
/**
|
|
19
108
|
* Configuration options for EMF/WMF conversion.
|
|
20
109
|
*/
|
|
@@ -25,8 +114,8 @@ interface EmfConvertOptions {
|
|
|
25
114
|
maxHeight?: number;
|
|
26
115
|
/**
|
|
27
116
|
* DPI scale factor for higher-resolution output.
|
|
28
|
-
* Default is
|
|
29
|
-
*
|
|
117
|
+
* Default is 1 (1:1 pixel mapping). Values above 4 are clamped to 4 to
|
|
118
|
+
* prevent excessive memory usage.
|
|
30
119
|
*/
|
|
31
120
|
dpiScale?: number;
|
|
32
121
|
/**
|
|
@@ -46,6 +135,48 @@ interface EmfConvertOptions {
|
|
|
46
135
|
* 'ms shell dlg': 'Tahoma' }`. Applied to GDI, WMF, and EMF+ text.
|
|
47
136
|
*/
|
|
48
137
|
fontFamilyMap?: Record<string, string>;
|
|
138
|
+
/**
|
|
139
|
+
* Antialias plain-GDI (EMF) vector shapes: Rectangle, Ellipse, RoundRect,
|
|
140
|
+
* Polygon, Polyline, arcs, and bracketed paths. Default `true`, Canvas's
|
|
141
|
+
* own smooth edges. `false` rasterises their fills and strokes the way
|
|
142
|
+
* Windows GDI does, without antialiasing and on GDI's own pixel grid, for
|
|
143
|
+
* output that matches what Windows paints pixel for pixel. It reads back
|
|
144
|
+
* and rewrites each shape's bounding box, so it is noticeably slower on
|
|
145
|
+
* shape-heavy files. EMF+ drawing and text are unaffected.
|
|
146
|
+
*/
|
|
147
|
+
gdiAntialias?: boolean;
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Options for the SVG outputs ({@link convertMetafileToSvg} and friends).
|
|
151
|
+
* `maxWidth`/`maxHeight`/`dpiScale`/`maxCanvasDimension` set the SVG's
|
|
152
|
+
* coordinate space (its `viewBox` and default `width`/`height`) and the
|
|
153
|
+
* resolution of any embedded raster content; vector content stays sharp at
|
|
154
|
+
* any display size.
|
|
155
|
+
*/
|
|
156
|
+
interface SvgConvertOptions extends EmfConvertOptions {
|
|
157
|
+
/**
|
|
158
|
+
* Evaluate raster operations that read the destination (exact ROP3 blits,
|
|
159
|
+
* exact bitwise ROP2) against a hidden raster mirror of the drawing, and
|
|
160
|
+
* embed the pixels they change as image patches. Needs a canvas backend
|
|
161
|
+
* (browser/worker canvas, or `@napi-rs/canvas` in Node.js); without one,
|
|
162
|
+
* or when set to `false`, those operations fall back to SVG blend modes
|
|
163
|
+
* (`mix-blend-mode`), which are exact for the common mask ROPs on
|
|
164
|
+
* black/white masks and approximate otherwise. Default `true`.
|
|
165
|
+
*/
|
|
166
|
+
exactRasterOps?: boolean;
|
|
167
|
+
/**
|
|
168
|
+
* Emit `width`/`height` attributes on the root `<svg>` (the `viewBox` is
|
|
169
|
+
* always emitted). Set `false` for a fluid SVG that fills its container.
|
|
170
|
+
* Default `true`.
|
|
171
|
+
*/
|
|
172
|
+
includeSize?: boolean;
|
|
173
|
+
/**
|
|
174
|
+
* Prefix for every generated element id (clip paths, gradients,
|
|
175
|
+
* patterns). Ids must be unique within an HTML document, so give each
|
|
176
|
+
* inlined SVG its own prefix. Defaults to a per-process counter
|
|
177
|
+
* (`emf1-`, `emf2-`, ...).
|
|
178
|
+
*/
|
|
179
|
+
idPrefix?: string;
|
|
49
180
|
}
|
|
50
181
|
/**
|
|
51
182
|
* Converts an EMF (Enhanced Metafile) or WMF (Windows Metafile) binary buffer
|
|
@@ -56,23 +187,12 @@ interface EmfConvertOptions {
|
|
|
56
187
|
* starts with a valid `EMR_HEADER` record, so it doubles as a safe format
|
|
57
188
|
* sniff. When that probe fails, the buffer is parsed as WMF instead.
|
|
58
189
|
*
|
|
59
|
-
* For EMF: parses the EMF header, iterates over all EMR records, and replays
|
|
60
|
-
* them onto an in-memory canvas. Embedded EMF+ (GDI+) records found inside
|
|
61
|
-
* EMR_COMMENT payloads are handled transparently.
|
|
62
|
-
*
|
|
63
|
-
* For WMF: parses the optional Aldus placeable header and the standard WMF
|
|
64
|
-
* header, then replays all META_* records onto a canvas.
|
|
65
|
-
*
|
|
66
|
-
* The canvas is rendered at a configurable DPI scale (default 1x, 1:1 pixel
|
|
67
|
-
* mapping) via {@link EmfConvertOptions.dpiScale}. On the first call in a
|
|
68
|
-
* given process, the optional Node.js canvas backend (`@napi-rs/canvas`) is
|
|
69
|
-
* loaded once and cached; see {@link ensureNodeCanvasModule}.
|
|
70
|
-
*
|
|
71
190
|
* Returns `null` when:
|
|
72
191
|
* - The buffer matches neither a valid EMF header nor a valid WMF header.
|
|
73
192
|
* - The logical bounds are zero-sized or negative.
|
|
74
193
|
* - No canvas API is available (browser/worker canvas, or the optional
|
|
75
|
-
* `@napi-rs/canvas` package in plain Node.js).
|
|
194
|
+
* `@napi-rs/canvas` package in plain Node.js). SVG output
|
|
195
|
+
* ({@link convertMetafileToSvg}) needs no canvas at all.
|
|
76
196
|
*
|
|
77
197
|
* @param buffer - The raw EMF or WMF file bytes.
|
|
78
198
|
* @param options - Optional {@link EmfConvertOptions} controlling output size,
|
|
@@ -81,6 +201,36 @@ interface EmfConvertOptions {
|
|
|
81
201
|
* @returns A `data:image/png;base64,...` string, or `null` on failure.
|
|
82
202
|
*/
|
|
83
203
|
declare function convertMetafileToDataUrl(buffer: ArrayBuffer, options?: EmfConvertOptions, recursionDepth?: number): Promise<string | null>;
|
|
204
|
+
/**
|
|
205
|
+
* Converts an EMF or WMF buffer (format auto-detected) into an SVG document
|
|
206
|
+
* tree, the common source for every SVG output form: serialise it with
|
|
207
|
+
* {@link svgTreeToString} / {@link svgTreeToDataUrl}, render it in React
|
|
208
|
+
* with {@link svgTreeToReact}, or generate component source with
|
|
209
|
+
* {@link svgTreeToJsx}.
|
|
210
|
+
*
|
|
211
|
+
* Unlike PNG output this needs no canvas implementation: vectors, text,
|
|
212
|
+
* gradients, clipping, and bitmaps are all recorded in pure JavaScript. A
|
|
213
|
+
* canvas backend, when present, is used only to evaluate destination-
|
|
214
|
+
* reading raster operations exactly (see {@link SvgConvertOptions.exactRasterOps})
|
|
215
|
+
* and to measure text.
|
|
216
|
+
*
|
|
217
|
+
* @returns The root `<svg>` node, or `null` for an invalid/empty metafile.
|
|
218
|
+
*/
|
|
219
|
+
declare function convertMetafileToSvgTree(buffer: ArrayBuffer, options?: SvgConvertOptions, recursionDepth?: number): Promise<SvgNode | null>;
|
|
220
|
+
/**
|
|
221
|
+
* Converts an EMF or WMF buffer into standalone SVG markup
|
|
222
|
+
* (`<svg xmlns="http://www.w3.org/2000/svg" ...>...</svg>`).
|
|
223
|
+
*
|
|
224
|
+
* @returns The SVG markup, or `null` for an invalid/empty metafile.
|
|
225
|
+
*/
|
|
226
|
+
declare function convertMetafileToSvg(buffer: ArrayBuffer, options?: SvgConvertOptions): Promise<string | null>;
|
|
227
|
+
/**
|
|
228
|
+
* Converts an EMF or WMF buffer into a base64 SVG data URL
|
|
229
|
+
* (`data:image/svg+xml;base64,...`), usable directly as an `<img src>`.
|
|
230
|
+
*
|
|
231
|
+
* @returns The data URL, or `null` for an invalid/empty metafile.
|
|
232
|
+
*/
|
|
233
|
+
declare function convertMetafileToSvgDataUrl(buffer: ArrayBuffer, options?: SvgConvertOptions): Promise<string | null>;
|
|
84
234
|
|
|
85
235
|
/**
|
|
86
236
|
* The default DPI scale factor used for EMF/WMF rendering.
|
|
@@ -89,4 +239,4 @@ declare function convertMetafileToDataUrl(buffer: ArrayBuffer, options?: EmfConv
|
|
|
89
239
|
*/
|
|
90
240
|
declare const DEFAULT_DPI_SCALE = 1;
|
|
91
241
|
|
|
92
|
-
export { DEFAULT_DPI_SCALE, type EmfConvertOptions, convertMetafileToDataUrl };
|
|
242
|
+
export { type CreateElement, DEFAULT_DPI_SCALE, type EmfConvertOptions, type SvgConvertOptions, type SvgJsxOptions, type SvgNode, convertMetafileToDataUrl, convertMetafileToSvg, convertMetafileToSvgDataUrl, convertMetafileToSvgTree, svgTreeToDataUrl, svgTreeToJsx, svgTreeToReact, svgTreeToString };
|