Skip to content

About

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 drawing commands onto an HTML Canvas.

Resources

Stars

9 stars

Watchers

0 watching

Forks

Repository files navigation

emf-converter

npm version CI license

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


Documentation and demo

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.

Install

npm install emf-converter

No required dependencies:

  • Browser / Web Worker: OffscreenCanvas or HTMLCanvasElement is 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/canvas is 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, no node-gyp):

    npm install @napi-rs/canvas

    Without either, PNG/JPEG conversion of a drawing that contains text returns null rather than an image missing its text.

Quick start

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).

JPEG output

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.

SVG output

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.

Rendering in React (JSX / TSX)

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).

Exact text

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, lfWidth stretching), 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 every TA_* alignment.
  • Non-antialiased, grayscale or ClearType rendering is chosen from the font's quality; fontSmoothing sets what DEFAULT_QUALITY means (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.fon and 8514fix.fon along with vgasys.fon and vgafix.fon to 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+ DrawString honours 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.

API

convertMetafileToDataUrl(buffer, options?)

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

EmfConvertOptions

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' }

convertMetafileToJpegDataUrl(buffer, options?)

Returns Promise<string | null>, a data:image/jpeg;base64,... URL (null in the same cases as the PNG function).

JpegConvertOptions (extends EmfConvertOptions)

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

SVG functions

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

SvgConvertOptions (extends EmfConvertOptions)

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

loadSystemFonts(options?)

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.

How it works

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.

Limitations

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.

License

Apache-2.0, free for commercial and closed-source use, with an explicit patent grant.

About

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 drawing commands onto an HTML Canvas.

Resources

Stars

9 stars

Watchers

0 watching

Forks

Releases

Used by

Contributors

Languages