emf: parser for WMF, EMF, and EMF+ metafiles
emf is a clean-room Ruby parser with an optional Rust native backend for the three Microsoft Windows
metafile formats:
-
WMF — Windows Metafile (see MS-WMF, kaitai
wmf.ksy) -
EMF — Enhanced Metafile (see MS-EMF)
-
EMF+ — Enhanced Metafile Plus (see MS-EMFPLUS; embedded in EMF via
EMR_COMMENTrecords)
It reads binary input into an OOP domain model and serialises the model
back to binary. It is the parsing half of the upcoming emfsvg gem
(EMF <→ SVG transformation). It replaces the FFI-based emf2svg-ruby
wrapper around the GPLv2 libemf2svg C library.
Status
-
EMF: parsing, mutable wire records and binary serialization are implemented.
-
EMF+: raw carrier payloads (
Metafile#emf_plus) and a typed stream view (Metafile#emf_plus_stream) are implemented. -
WMF: standard and placeable headers, records and binary serialization are implemented.
-
Native backend: Rust decoding and a Ruby adapter are implemented and match the Ruby backend on the valid and corrupt fixture corpora, every EMF record type and mutated WMF input; installed platform qualification remains open.
-
Qualification: implementation does not imply complete format coverage or release readiness. See delivery progress and format extension acceptance.
-
License: BSD-2-Clause. No GPL obligation.
Installation
Add to your Gemfile:
gem "emf", "~> 0.1"
Or install directly:
gem install emf
Requires Ruby >= 3.1.
Backend selection
The public Emf.parse, Emf.parse_file and Emf.serialize APIs use
EMF_BACKEND at call time:
| Value | Behavior |
|---|---|
|
Use the native backend when available; fall back to Ruby if native setup raises |
|
Use Ruby directly, without loading the native library. |
|
Require the native backend; unavailable setup raises |
Emf.backend resolves the backend at call time and returns :ruby or :native.
It uses the same selection as parsing and serialization, including the auto
availability fallback. It returns a symbol rather than a backend adapter.
Explicit native selection raises Emf::Native::Unavailable if unavailable;
unexpected setup errors propagate. This query may load the native library, and
its result describes the current settings rather than pinning future operations.
Other values raise ArgumentError. A parsing or serialization error after
backend selection propagates to the caller; it is not retried using Ruby.
Both backends return the existing Ruby model and use the Ruby binary writers
for serialization, including mutable wire records.
For example, to explicitly use Ruby:
EMF_BACKEND=ruby ruby -remf -e 'p Emf.parse_file(ARGV.fetch(0)).format' image.emf
Native loading uses a packaged platform manifest when present. For development
with an existing compatible library, EMF_NATIVE_LIBRARY overrides discovery:
EMF_BACKEND=native EMF_NATIVE_LIBRARY=/absolute/path/to/libemf_ffi.dylib ruby -remf -e 'p Emf.parse_file(ARGV.fetch(0)).format' image.emf
Use the appropriate shared-library filename for the platform. An empty override makes native loading unavailable. Native installation and platform support must be qualified using the delivery checks; the source implementation alone does not establish an available platform gem.
Synopsis
require "emf"
# Parse from bytes or a file path
metafile = Emf.parse_file("image.emf")
metafile.format # => :emf or :emf_plus
metafile.records.length # => 3538
metafile.errors.empty? # => true (no parse errors)
# Walk records with a visitor stats = Emf::Visitors::Stats.new.visit_all(metafile) puts stats.to_s
# Serialise the model back to binary bytes = Emf.serialize(metafile)
Reading field values without BinData
record.values returns a record’s field values as a frozen, plain Ruby object
(a Data with the wire’s member names; nested structs as nested Data; a counted
point array as an Array of Data points with x and y) without making
BinData field objects. It is nil for records without a fixed layout or a
counted point array (for example ExtTextOut, bitmaps, fonts, Comment, Raw); use
record.wire for those. The values are equal on the Ruby and native backends and
always reflect edits made through record.wire.
record = metafile.records.find { |r| r.type_id == 86 } # POLYGON16
record.values.rcl_bounds.left
record.values.apts.map { |point| [point.x, point.y] }
Command-line tool
emf version Print version emf info FILE Header summary + record counts emf dump FILE Record-by-record dump emf stats FILE Record-type histogram emf validate FILE Parse + report errors (exit 0 ok, 1 invalid) emf round-trip FILE [--out] Re-serialize after parse, compare or write
Conformance lens
Emf::Conformance checks a metafile against the Microsoft specifications
(MS-EMF, MS-EMFPLUS and MS-WMF) and reports what it finds. It does not repair
files and it does not change what the parser returns. The report uses the key
layout of svg_conform, so tools that read one can read the other.
require "emf"
report = Emf::Conformance.check("image.emf", profile: :tolerant)
report.valid? # => true when there are no errors (warnings do not count)
report.to_h # => Hash with string keys
report.to_yaml
The lens reads only the public model, so the Ruby and the native backend give
the same report. It adds no runtime dependency: bindata is still the only one.
Input
check accepts four forms:
| Input | Behavior |
|---|---|
path |
Read with |
|
Same as a path |
bytes |
Parsed once. |
|
Evaluated without parsing again. |
A parsed Metafile carries no input bytes. Rules that compare a declared
length with the real input length are skipped for it. One example is
emf.header.bytes. The WMF size check against the input is another. Pass the
path or the bytes when you want those rules to run.
Profiles
profile: is :strict (the default) or :tolerant. Any other value raises
ArgumentError.
-
:strictreports every spec violation as an error. -
:tolerantaccepts what real producers write. A rule that carries atolerant_reasonbecomes a warning instead of an error. The warning keeps itsrequirement_id,kind, citation and offset. The reason appears ascontext.waiver.
A waiver belongs to the rule, not to a file. Every input that triggers that
rule gets the downgrade. There is no per-file allowlist. A rule without a
tolerant_reason is an error under both profiles.
Two files in spec/fixtures/emf-corrupted/ cannot be parsed into a model. One
has a truncated header and one has an unrecognised first word. They report
valid: false under both profiles, with the error emf.parse.unparseable.
The other 19 corrupted fixtures are valid: true under :tolerant.
The report
Report#to_h has these keys: filename, profile, tool, version,
timestamp, valid, errors and warnings. errors and warnings each hold
total_count, by_requirement and the ordered issues. Issues are ordered by
offset, then requirement id. Each issue has type, requirement_id,
message, element, attribute, value, offset, severity, kind,
category, spec_citation, remediation_available and context.
offset is the absolute byte offset in the file. kind is a stable
machine-readable name. category repeats kind and severity repeats type,
for svg_conform compatibility.
This is the strict report for a repo fixture. It comes from
Emf::Conformance.check("spec/fixtures/emf/test-000.emf").to_h(timestamp: false).to_yaml.
The timestamp line is left out by timestamp: false.
filename: spec/fixtures/emf/test-000.emf
profile: strict
tool: emf
version: 0.1.2
valid: false
errors:
total_count: 1
by_requirement:
emfplus.property.enum: 1
issues:
- type: error
requirement_id: emfplus.property.enum
message: SetCompositingQuality CompositingQuality value 0 is outside 1..5 (EMF+
record 0x4024 at offset 0 of EMR_COMMENT_EMFPLUS record 50)
element: emf_plus:0x4024
attribute:
value:
offset: 52152
severity: error
kind: property_enum
category: property_enum
spec_citation: MS-EMFPLUS 2.3.6
remediation_available: false
context:
spec_citation: MS-EMFPLUS 2.3.6
format: emf_plus
record_index: 50
record_type: 16420
warnings:
total_count: 0
by_requirement: {}
issues: []
With profile: :tolerant the same file is valid: true. The finding moves to
warnings, and context.waiver carries the written reason.
Comparing backends
The report holds a creation timestamp, so two runs never compare equal as they
are. To compare two reports, drop the timestamp or pin it:
Emf::Conformance.check(path).to_h(timestamp: false) Emf::Conformance.check(path, now: Time.utc(2026, 1, 1)).to_h # "timestamp" => "2026-01-01T00:00:00Z"
Run once with EMF_BACKEND=ruby and once with EMF_BACKEND=native (see
Backend selection) and compare the two results. They must be equal. The
native value raises Emf::Native::Unavailable when no native library loads.
The rule manifest
Every rule the lens implements, and every check it does not yet make, is listed
in lib/emf/conformance/manifest/*.yml. A row has an id, a format, the
record_types it covers, a citation, a status and a reason. The reason is
required for every status except implemented.
| Status | Meaning |
|---|---|
|
A registered rule with this id exists. |
|
The data is readable from the public model today, but no rule is written yet. |
|
The check needs a public model field that does not exist yet. The reason names the field. |
|
No input can violate it, or the bytes are ignored on receipt. |
Read the manifest to see what a clean report does not prove. A deferred or
blocked:model row is a check the lens does not make. Every record type of the
three specifications appears in at least one row.
Architecture
Three layers, kept strictly separate (MECE):
binary bytes --[bindata]--> Emr::Binary::*Record (byte-faithful)
|
Record.from_wire
v
Model::*Record (semantic OOP, immutable)
|
Record#to_wire
v
Emr::Binary::*Record --[bindata]--> binary bytes
-
Wire layer (
Emf::Emr::Binary::*): one bindata class per EMR record type, declared by spec. Pure data, no behaviour. Symmetric read/write. -
Domain layer (
Emf::Model::*): immutable value objects withattr_reader, value equality,accept(visitor),from_wire,to_wire. -
Visitor pattern (
Emf::Model::Visitor): consumers subclass the base and override only thevisit_*methods they care about. Adding a record type touches the record’s own file plus three lines inrecords.rb(autoload + TYPE_TO_NAME + the file itself) — no central switch.
Constraints honoured throughout
-
No
require_relativeand norequireof internal paths inlib/— everything autoloads via the parent-namespace file pattern. -
No
double()in specs — real instances only. -
No
sendto private methods, noinstance_variable_set/get, norespond_to?for type checks. -
No AI attribution in any commit. All changes go through PRs.
See docs/architecture.adoc and docs/format_notes.adoc for the deep
dive, and delivery progress for implementation status.
Reference documentation
The Microsoft specifications are checked in at reference-docs/ as both
the original .docx files and per-chapter GFM markdown under
reference-docs/<spec>/. The conversion script is
scripts/convert_docs.rb (rubyzip + nokogiri). Re-run when the spec
revision bumps.
Development
git clone https://github.com/claricle/emf cd emf bundle install bundle exec rspec # fixture and unit checks bundle exec rubocop # clean
The round-trip harness in spec/emf/round_trip_spec.rb is the strongest
correctness signal: it walks every non-corrupted fixture and asserts
byte-identical parse → serialise → parse.
Roadmap
The execution plan for issue #15 starts at the shared roadmap. Implementation is in progress; remaining acceptance and qualification work is tracked in the linked sections.
-
Ruby implementation: existing API dispatch, model compatibility, mutable wire serialization, fallback and CLI behavior.
-
Rust core and C ABI: typed decoding, serialization and native ownership.
-
Format extensions: WMF and structured EMF+.
Other roadmap items include semantic domain records beyond WireAdapter and
two-pass path association (BEGINPATH with FILLPATH/STROKEPATH). These remain
separate from the issue #15 delivery criteria.
License
BSD-2-Clause. See LICENSE.txt.
The gem is a clean-room reimplementation derived from the public
Microsoft Open Specifications (MS-WMF, MS-EMF, MS-EMFPLUS). No code was
copied from libemf2svg (GPLv2) or libUEMF (GPLv2).