AI Decision System
Replayable, offline-verifiable records for AI-assisted go/no-go decisions: experiment promotions, typed model answers and human-approved actions.
翻譯準備中
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.
翻譯準備中
取自儲存庫 README
正在載入完整 README。 以下為已儲存的摘錄。
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).檢視完整 README ↗A zero-dependency TypeScript library that converts EMF (Enhanced Metafile, including embedded EMF+ / GDI+ records) and WMF (Windows Metafile) files into PNG, JPEG, WebP, SVG (markup, a base64 data URL, React elements, or a generated JSX/TSX component) or PDF. The input can be an ArrayBuffer, a typed array or Node.js Buffer, or a Blob, plain or gzip-compressed (.emz, .wmz).
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-converter
No required dependencies:
Browser / Web Worker: OffscreenCanvas or HTMLCanvasElement is used automatically for PNG, JPEG and WebP. 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 and PDF output, and PNG/JPEG/WebP output for drawings without text, work out of the box through the built-in rasteriser. For PNG/JPEG/WebP 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/WebP conversion of a drawing that contains text returns null rather 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).
For the bytes themselves, use convertMetafile. It takes a format ('png' by default, 'jpeg', 'webp' or 'svg') and returns the encoded file, which is what you write to disk in Node.js:
import { readFile, writeFile } from 'node:fs/promises';
import { convertMetafile } from 'emf-converter';
const image = await convertMetafile(await readFile('drawing.emf'), { format: 'webp' });
if (image) {
await writeFile('drawing.webp', image.data); // image.data is a Uint8Array
}
The data-URL functions are wrappers around convertMetafile, so both give the same image.
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 { convertMetafileToWebpDataUrl } from 'emf-converter';
const webp = await convertMetafileToWebpDataUrl(buffer);
// => "data:image/webp;base64,UklGR..."
// Lossy WebP at quality 0-1 (default 0.8), plus every PNG option (maxWidth, dpiScale, fonts, ...).
const small = await convertMetafileToWebpDataUrl(buffer, { lossless: false, quality: 0.8, maxWidth: 800 });
The same rendering as the PNG, encoded by the bundled WebP encoder, so the output is identical in browsers and Node.js. The default is lossless: the pixels and the transparent background are kept exactly, and the file is usually smaller than the PNG. Lossy WebP (lossless: false) comes from a simple encoder. For line art and text it is usually much larger than lossless, so use it only for smooth, photo-like content. Images larger than 16383 pixels on a side return null.
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 });
lfWidth stretching), with GDI's metrics, advances, underline and strike-out.ETO_* flags and every TA_* alignment.fontSmoothing sets what DEFAULT_QUALITY means (Windows' default is ClearType).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.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.
import { convertMetafileToPdf, loadSystemFonts } from 'emf-converter';
const fonts = await loadSystemFonts(); // Node.js only
const pdf = await convertMetafileToPdf(buffer, { fonts, title: 'Drawing' });
// => Uint8Array holding a PDF file, or null
The output is a one-page vector PDF 1.7. It uses the same replay as the SVG output, so paths, clipping, gradients, patterns, opacity, blend modes and images are kept as vectors or embedded (JPEG bytes are copied unchanged). The page is the drawing itself by default (1 pixel = 0.75 pt); pageSize: { width, height } in points fits it to a page instead.
Text is real text. With fonts, the matching TrueType face is embedded as a subset, so the text is selectable and searchable. Without fonts, text uses the standard PDF fonts (Helvetica, Times or Courier) and looks different from the original font. The output does not include PDF/A or tagged PDF. It has been checked with pdf.js.
import { convertMetafileToImageData } from 'emf-converter';
const image = await convertMetafileToImageData(buffer);
if (image) {
const { data, width, height } = image; // straight (non-premultiplied) RGBA, row-major, top-down
// e.g. sharp(data, { raw: { width, height, channels: 4 } }).png().toFile('drawing.png');
}
This is the same rendering the PNG output encodes, without the encoding step, for code that needs the pixels themselves.
getMetafileInfo reads the header and records without rendering anything, so you can see what a file contains before converting it:
import { getMetafileInfo } from 'emf-converter';
const info = await getMetafileInfo(buffer);
// info.format: 'emf' | 'emf+' | 'emf+dual' | 'wmf'
// info.width, info.height: the PNG size at default options
// info.fonts: [{ name: 'Segoe UI', gdi: true, emfPlus: false }, ...]
The main use is finding out which font files to pass as fonts: info.fonts lists the face names the file declares, so you know which fonts to supply for exact text. The list is of declared fonts, and not every one is necessarily drawn with. Fonts inside nested WMFs are not collected. The width and height ignore maxWidth and dpiScale. See the API reference for every field.
Every conversion function accepts signal (an AbortSignal) and timeoutMs:
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 5000);
try {
const png = await convertMetafileToDataUrl(buffer, { signal: controller.signal });
} finally {
clearTimeout(timer);
}
// Or reject after a time limit, with a DOMException named TimeoutError.
const quick = await convertMetafileToDataUrl(buffer, { timeoutMs: 5000 });
When the signal aborts, the promise rejects with signal.reason (an AbortError when there is none). A null result still means the buffer is not a valid metafile. Conversion checks the signal at every record, so it stops within about one record's work. One very large record, such as a single giant blit, can overrun the limit. With a signal, conversion yields to the event loop about every 16 ms.
The package installs an emf-converter command, which npx emf-converter runs:
Usage: emf-converter <files...> [options]
Convert EMF, EMF+, WMF and gzip-compressed (.emz, .wmz) metafiles to PNG,
JPEG, WebP, SVG or PDF.
Inputs:
<files...> Files to convert. "-" reads one file from stdin.
Wildcards (* and ?) are expanded by the CLI itself.
Options:
-f, --format <fmt> Output format: png, jpeg, webp, svg or pdf. Default:
from the --out extension, else png.
-o, --out <path> Output file for one input, or a directory (created if
missing) for several. Default: next to each input,
with the new extension. "-" writes to stdout.
Stdin input without --out also writes to stdout.
--width <px> Maximum output width in pixels.
--height <px> Maximum output height in pixels.
--scale <n> DPI scale factor (default 1; the library clamps to 4).
--quality <0-1> JPEG quality (default 0.92), or lossy WebP quality
(default 0.8).
--lossy Lossy WebP instead of the default lossless WebP.
--background <c> JPEG colour under transparent areas, #rgb or #rrggbb
(default #ffffff). JPEG only.
--antialias Smooth shape edges instead of reproducing Windows'
own rasterisation.
--fonts <value> "system" (default): the installed fonts, so text
matches Windows; "none": no font files; or a
directory of .ttf, .ttc, .fon or .fnt files.
-q, --quiet Print only errors.
-h, --help Show this help.
-v, --version Print the version.
Exit codes: 0 all files converted, 1 one or more files failed (the rest are
still converted), 2 usage error.
Examples:
emf-converter drawing.emf
emf-converter drawing.emf --width 1200 -o drawing.jpg
emf-converter "charts/*.wmf" -f svg -o out/
cat drawing.emz | emf-converter - -f png > drawing.png
The --fonts system default loads the installed fonts, so text matches Windows. Wildcards are expanded by the CLI itself (Node.js 22 or later), and a file named explicitly is converted whatever its extension.
MetafileInputtype MetafileInput = ArrayBuffer | ArrayBufferView | Blob;
Every conversion function accepts this. Typed arrays, DataViews and Node.js Buffers are read from their viewed bytes only, so pass the Buffer itself. Gzip-compressed .emz and .wmz files are inflated automatically (needs DecompressionStream: browsers and Node.js 18+; inflated size is capped at 256 MiB).
convertMetafile(input, options?)| Parameter | Type | Description |
|---|---|---|
input | MetafileInput | Raw EMF or WMF file (format is auto-detected) |
options | ConvertOptions (optional) | format ('png' default, 'jpeg', 'webp', 'svg') plus that format's options |
| Returns | Promise<ConvertedImage | null> | The encoded file, or null on failure |
ConvertedImage| Field | Type | Description |
|---|---|---|
format | 'png' | 'jpeg' | 'webp' | 'svg' | The output format |
mimeType | string | image/png, image/jpeg, image/webp or image/svg+xml |
data | Uint8Array | The file bytes |
width | number | Pixel width, or the SVG's width |
height | number | Pixel height, or the SVG's height |
convertMetafileToDataUrl(buffer, options?)| Parameter | Type | Description |
|---|---|---|
buffer | MetafileInput | Raw EMF or WMF file (format is auto-detected) |
options | EmfConvertOptions (optional) | See below |
| Returns | Promise<string | null> | PNG data URL, or null on failure |
EmfConvertOptionsansiCodePage 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. |
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 |
| 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 |
convertMetafileToWebpDataUrl(buffer, options?)Returns Promise<string | null>, a data:image/webp;base64,... URL (null in the same cases as the PNG function, and for images larger than 16383 pixels on a side).
WebpConvertOptions (extends EmfConvertOptions)| Field | Type | Default | Description |
|---|---|---|---|
lossless | boolean | true | true writes lossless WebP: exact pixels, transparency kept. false writes lossy WebP at quality (transparency is still kept). |
quality | number | 0.8 | Lossy quality from 0 to 1. Ignored when lossless is true. |
convertMetafileToPdf(buffer, options?)function convertMetafileToPdf(buffer: MetafileInput, options?: PdfConvertOptions): Promise<Uint8Array | null>;
Returns the bytes of a one-page vector PDF, or null for an invalid or empty metafile. svgTreeToPdf(tree, options?) makes the same PDF from an SvgNode tree.
PdfConvertOptions (extends SvgConvertOptions, plus PdfOptions)| Field | Type | Default | Description |
|---|---|---|---|
pageSize | 'drawing' | { width: number; height: number } | 'drawing' | 'drawing' makes the page the drawing itself (1 pixel = 0.75 pt). { width, height } in points fits the drawing to a page of that size, centred. |
fonts | Array<ArrayBuffer | ArrayBufferView> | None | TrueType (.ttf, .ttc) files. Text in a matching font is embedded as a subset of that face. Other text uses the standard PDF fonts. |
compress | boolean | true | Deflate the content streams and image data. |
title | string | None | Document title in the PDF information dictionary. |
decodeImage | (bytes: Uint8Array, mime: string) => PdfDecodedImage | null (may be async) | None | Decodes an embedded image format that is neither JPEG nor handled by the bundled decoders (PNG, BMP, GIF, TIFF, WebP). Returns straight RGBA, or null to skip the image. |
convertMetafileToImageData(buffer, options?)function convertMetafileToImageData(buffer: MetafileInput, options?: EmfConvertOptions): Promise<RgbaImage | null>;
Returns { data: Uint8ClampedArray, width, height }: straight (non-premultiplied) RGBA, row-major and top-down, like ImageData. It is the rendering the PNG output encodes, without the encoding step.
getMetafileInfo(buffer, options?)function getMetafileInfo(buffer: MetafileInput, options?: MetafileInfoOptions): Promise<MetafileInfo | null>;
Reads the header and records without rendering, and returns null for an invalid metafile.
MetafileInfoOptions| Option | Type | Default | Description |
|---|---|---|---|
maxRecords | number | As for the converters | Record limit per stream. |
ansiCodePage | number | 1252 | ANSI code page for WMF face names, as for the converters. |
MetafileInfo| Field | Type | Description |
|---|---|---|
format | 'emf' | 'emf+' | 'emf+dual' | 'wmf' | 'emf+' is EMF+ only (the GDI records are a stub); 'emf+dual' has a GDI fallback. |
bounds | MetafileRect | EMF: the header's device-pixel bounds (inclusive). WMF: the bounding box in logical units. |
frame | MetafileRect | null | EMF: the picture frame in .01 mm. Placeable WMF: the bounding box in .01 mm. |
device | { width, height } | null | EMF: the reference device size in pixels. |
dpi | { x, y } | Reference device resolution (96 when unknown). |
width / height | number | Pixel size convertMetafileToDataUrl produces at default options. |
recordCount | number | Records read from the stream. |
emfPlusRecordCount | number | Records read from the EMF+ streams inside EMR_COMMENT records. |
recordTypes | Record<string, number> | Count by record name (EMR_*, META_*, EMFPLUS_*). |
fonts | MetafileFont[] | Declared font faces: { name, gdi, emfPlus }. Not every one is necessarily drawn with. |
hasText | boolean | true when any text-drawing record is present. |
images | { count, mimeTypes } | Embedded images, including DIBs, EMF+ images and nested metafiles. |
embeddedMetafiles | number | Nested metafiles among images. |
description | string | null | The EMF header's application and document names. |
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.
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, JPEG and WebP, or onto a recording context for SVG and PDF. 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.
Output is tested pixel for pixel against what Windows itself paints: 899 of 910 test pictures match exactly. The rest differ by a few pixels, all in ClearType and EMF+ text. Text matches Windows only when the metafile's fonts are passed in fonts.
See Limitations for the list, Parity notes for the measurements and Outstanding work for what is still open.
Apache-2.0, free for commercial and closed-source use, with an explicit patent grant.
{ calibri: 'Carlito' }signal | AbortSignal | None | Cancels the conversion: the promise rejects with signal.reason when it aborts (see Cancellation) |
timeoutMs | number | None | Rejects with a DOMException named TimeoutError after this many milliseconds |
truncated | boolean | true when the record limit was hit or the stream ends in a damaged record. |