A zero-dependency TypeScript library that converts EMF (Enhanced Metafile, including embedded EMF+ / GDI+ records) and WMF (Windows Metafile) files into PNG, JPEG or SVG (markup, a base64 data URL, React elements, or a generated JSX/TSX component).
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.
| Format | Description | Coordinate system |
|---|---|---|
| WMF | Windows Metafile (16-bit) | Window/viewport mapping |
| EMF | Enhanced Metafile (32-bit GDI) | Bounds-based scaling |
| EMF+ | GDI+ extension embedded in EMF | World transform matrix |
Documentation and live demo · npm
The documentation site at https://christophervr.github.io/emf-converter/ includes a live demo: drop an .emf or .wmf file to see the PNG, JPEG or SVG output, download it, or copy it as a TSX component.
The site is built with VitePress from the docs/ directory. Run it locally with bun run docs:dev.
npm install emf-converterNo required dependencies:
-
Browser / Web Worker:
OffscreenCanvasorHTMLCanvasElementis used automatically for PNG and JPEG. Bundlers select the dedicated browser entry through conditional exports; it contains no native canvas or Node filesystem imports. Installing@napi-rs/canvasis only needed for the optional Node backend. -
Node.js: SVG output, and PNG/JPEG output for drawings without text, work out of the box through the built-in rasteriser. For PNG/JPEG output of drawings with text, either pass
fonts(see Exact text) or install the optional@napi-rs/canvas(prebuilt, nonode-gyp):npm install @napi-rs/canvas
Without either, PNG/JPEG conversion of a drawing that contains text returns
nullrather than an image missing its text.
import { convertMetafileToDataUrl } from 'emf-converter';
const buffer: ArrayBuffer = /* an .emf or .wmf file */;
const png = await convertMetafileToDataUrl(buffer);
// => "data:image/png;base64,iVBORw0KGgo..." (the format is auto-detected)
// Limit the output size (aspect ratio preserved), or render at 2x.
const thumb = await convertMetafileToDataUrl(buffer, { maxWidth: 1024, maxHeight: 768 });
const hiDpi = await convertMetafileToDataUrl(buffer, { dpiScale: 2 });
// Smooth (Canvas-antialiased) edges instead of Windows' own rasterisation.
const smooth = await convertMetafileToDataUrl(buffer, { gdiAntialias: true });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).
import { convertMetafileToJpegDataUrl } from 'emf-converter';
const jpeg = await convertMetafileToJpegDataUrl(buffer);
// => "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQ..."
// Quality 0-1 (default 0.92), the colour under transparent areas (default white),
// plus every PNG option (maxWidth, dpiScale, fonts, ...).
const preview = await convertMetafileToJpegDataUrl(buffer, { quality: 0.7, background: '#f4f4f4', maxWidth: 800 });The same rendering as the PNG, encoded by the bundled baseline JPEG encoder, so the output is identical in browsers and Node.js. JPEG has no transparency: the metafile's transparent background is filled with background. Colour is kept at full resolution (4:4:4) so coloured lines and text stay crisp; chromaSubsampling: true halves it (4:2:0) for a smaller file.
import { convertMetafileToSvg, convertMetafileToSvgDataUrl } from 'emf-converter';
const markup = await convertMetafileToSvg(buffer);
// => '<svg xmlns="http://www.w3.org/2000/svg" width="..." height="..." viewBox="...">...</svg>'
const svgUrl = await convertMetafileToSvgDataUrl(buffer);
// => "data:image/svg+xml;base64,PHN2ZyB4bWxucz0i..." (drop straight into <img src>)Paths, text, gradients, clipping and pattern brushes stay vectors; bitmaps are embedded as <image> elements (PNG/JPEG/GIF/WebP bytes verbatim, never re-encoded; arithmetic-coded and CMYK/YCCK JPEG are decoded by the bundled Windows-matching decoder and embedded as pixels, and a 12-bit JPEG draws nothing, as in Windows). 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.
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:
import { createElement, useEffect, useState, type ReactNode } from 'react';
import { convertMetafileToSvgTree, svgTreeToReact } from 'emf-converter';
export function Metafile({ buffer }: { buffer: ArrayBuffer }) {
const [svg, setSvg] = useState<ReactNode>(null);
useEffect(() => {
let live = true;
convertMetafileToSvgTree(buffer).then((tree) => {
if (live && tree) {
// Extra props land on the root <svg>: override size, add a class, aria, ...
setSvg(svgTreeToReact(tree, createElement, { width: '100%', height: 'auto', role: 'img' }));
}
});
return () => {
live = false;
};
}, [buffer]);
return svg;
}Or generate a component at build time (the SVGR approach):
import { writeFileSync } from 'node:fs';
import { convertMetafileToSvgTree, svgTreeToJsx } from 'emf-converter';
const tree = await convertMetafileToSvgTree(buffer, { idPrefix: 'logo-' });
writeFileSync('Logo.tsx', svgTreeToJsx(tree!, { componentName: 'Logo' }));
// export function Logo(props: SVGProps<SVGSVGElement>) { return (<svg ... {...props}> ... </svg>); }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).
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:
import { convertMetafileToDataUrl, loadSystemFonts } from 'emf-converter';
const fonts = await loadSystemFonts(); // Node.js only; reuse the array across conversions
const png = await convertMetafileToDataUrl(buffer, { fonts });- Fonts are realised the way GDI's font mapper does it (face substitutes, pitch/family fallback, weight choice, cell vs em height,
lfWidthstretching), with GDI's metrics, advances, underline and strike-out. - 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 everyTA_*alignment. - Non-antialiased, grayscale or ClearType rendering is chosen from the font's quality;
fontSmoothingsets whatDEFAULT_QUALITYmeans (Windows' default is ClearType). - 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 (up to 8 vertically, 5 horizontally). The size depends on the faces supplied: pass
8514sys.fonand8514fix.fonalong withvgasys.fonandvgafix.fonto get the System and Fixedsys sizes a 120 dpi or larger session draws. - Rotated text uses GDI's rounded font matrix. Text from a compatible-mode metafile replays the recorded Dx array but not the slight horizontal stretch (about 1.0006) Windows playback applies, which re-grid-fits a few Segoe UI heights. EMF+
DrawStringhonours the text rendering hint, string-format tracking and margins, and texture/gradient brushes.
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.
| Parameter | Type | Description |
|---|---|---|
buffer |
ArrayBuffer |
Raw EMF or WMF file bytes (format is auto-detected) |
options |
EmfConvertOptions (optional) |
See below |
| Returns | Promise<string | null> |
PNG data URL, or null on failure |
ansiCodePage and oemCodePage set the known code pages of the playback
device (for example, 936 from Windows GetACP() and 866 from GetOEMCP()).
Windows decodes ANSI text records (EMF and WMF) in ANSI/DEFAULT fonts with the
ANSI code page, WMF font face names too, and text in OEM fonts with the OEM code
page. The defaults are 1252 and 437; explicit text charsets are unchanged.
wmfAnsiCodePage overrides ansiCodePage for WMF input only. See the
API reference for supported values.
| Field | Type | Default | Description |
|---|---|---|---|
maxWidth |
number |
None | Maximum output width in pixels (aspect ratio preserved) |
ansiCodePage |
number |
1252 |
Playback-device ANSI code page (GetACP()) for ANSI/DEFAULT text |
oemCodePage |
number |
437 |
Playback-device OEM code page (GetOEMCP()) for OEM text |
maxHeight |
number |
None | Maximum output height in pixels |
dpiScale |
number |
1 |
Resolution multiplier; clamped to 4 |
maxCanvasDimension |
number |
8192 |
Hard cap on output width/height in pixels |
maxRecords |
number |
200000/500000 |
Records processed per stream before replay stops (EMF+ uses the higher default unless overridden) |
gdiAntialias |
boolean |
false (PNG) |
true smooths every shape edge with Canvas antialiasing instead of reproducing Windows' own GDI/GDI+ rasterisation |
fonts |
Array<ArrayBuffer | ArrayBufferView> |
None | TrueType (.ttf/.ttc) and raster (.fon/.fnt) font files for exact GDI text |
fontSmoothing |
'cleartype' | 'gray' | 'mono' |
'cleartype' |
What DEFAULT_QUALITY / DRAFT_QUALITY / PROOF_QUALITY fonts render as (Windows' system setting) |
fontFamilyMap |
Record<string, string> |
None | Without fonts: maps Windows face names (case-insensitive) to locally available fonts, e.g. { calibri: 'Carlito' } |
Returns Promise<string | null>, a data:image/jpeg;base64,... URL (null in the same cases as the PNG function).
| Field | Type | Default | Description |
|---|---|---|---|
quality |
number |
0.92 |
JPEG quality from 0 to 1, as for canvas.toDataURL('image/jpeg', quality) |
background |
string |
'#ffffff' |
Colour under transparent pixels, #rgb or #rrggbb (other values fall back to white) |
chromaSubsampling |
boolean |
false |
true stores colour at half resolution (4:2:0) for a smaller file |
| Function | Returns |
|---|---|
convertMetafileToSvg(buffer, options?) |
Promise<string | null>, standalone SVG markup |
convertMetafileToSvgDataUrl(buffer, options?) |
Promise<string | null>, a data:image/svg+xml;base64,... URL |
convertMetafileToSvgTree(buffer, options?) |
Promise<SvgNode | null>, the tree the helpers below consume |
svgTreeToString(tree) / svgTreeToDataUrl(tree) |
Markup / base64 data URL for an existing tree |
svgTreeToReact(tree, createElement, rootProps?) |
Live elements via React.createElement (or any compatible factory) |
svgTreeToJsx(tree, { componentName?, typescript?, spreadProps? }) |
JSX/TSX component source code |
| Field | Type | Default | Description |
|---|---|---|---|
gdiAntialias |
boolean |
true (SVG) |
false embeds Windows' aliased GDI shape pixels as image patches instead of smooth vector edges |
exactRasterOps |
boolean |
true |
false skips the raster mirror and expresses destination-reading raster ops with SVG mix-blend-mode equivalents |
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 |
includeSize |
boolean |
true |
Emit width/height on the root <svg> (viewBox is always emitted); false gives a fluid SVG |
idPrefix |
string |
emf1-, emf2-, ... |
Prefix for generated element ids; keep it unique per inlined SVG |
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.
A three-phase pipeline: parse → replay → export. Records are replayed by the GDI, EMF+ or WMF handlers onto a Canvas (or the built-in pure-JavaScript rasteriser) for PNG and JPEG, or onto a recording context for SVG. GDI and GDI+ shapes, raster operations, brushes, clipping, text and WMF playback are all fitted to output painted by Windows itself.
See How it works for the pipeline, the Windows-parity details and the full list of supported records.
Everything is measured against output painted by Windows itself, and a few areas still differ from it: some image effects, text with ClearType and EMF+ antialiasing, diagonal and curved glyph hinting, the last 0.26% of CMYK JPEG values, and rotated high-quality DrawImage sampling (within one level). Metric WMF map modes assume a 96 dpi device unless you pass wmfReferenceDpi.
See Limitations for the details and Outstanding work for what is still open.
Apache-2.0, free for commercial and closed-source use, with an explicit patent grant.