Project

emf

0.0
The project is in a healthy, maintained state
Parser for Windows Metafile (WMF), Enhanced Metafile (EMF), and EMF+ binary formats, in Ruby with an optional native backend. Produces an OOP domain model that downstream gems (such as emfsvg) consume.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies

Runtime

~> 2.4
 Project Readme

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_COMMENT records)

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

auto (default)

Use the native backend when available; fall back to Ruby if native setup raises Emf::Native::Unavailable.

ruby

Use Ruby directly, without loading the native library.

native

Require the native backend; unavailable setup raises Emf::Native::Unavailable.

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 String

Read with File.binread. The path becomes the report’s filename.

Pathname

Same as a path String.

bytes String

Parsed once. filename is nil. A String is taken as a path only when it names an existing file, has no NUL byte and is under 4096 bytes long.

Emf::Model::Metafile

Evaluated without parsing again. filename is nil.

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.

  • :strict reports every spec violation as an error.

  • :tolerant accepts what real producers write. A rule that carries a tolerant_reason becomes a warning instead of an error. The warning keeps its requirement_id, kind, citation and offset. The reason appears as context.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

implemented

A registered rule with this id exists.

deferred

The data is readable from the public model today, but no rule is written yet.

blocked:model

The check needs a public model field that does not exist yet. The reason names the field.

not_checkable

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 with attr_reader, value equality, accept(visitor), from_wire, to_wire.

  • Visitor pattern (Emf::Model::Visitor): consumers subclass the base and override only the visit_* methods they care about. Adding a record type touches the record’s own file plus three lines in records.rb (autoload + TYPE_TO_NAME + the file itself) — no central switch.

Constraints honoured throughout

  • No require_relative and no require of internal paths in lib/ — everything autoloads via the parent-namespace file pattern.

  • No double() in specs — real instances only.

  • No send to private methods, no instance_variable_set/get, no respond_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.

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