Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
name: Release

on:
push:
tags:
- "v*"

permissions:
contents: write

jobs:
release:
name: Build and publish
runs-on: ubuntu-latest
environment: release

steps:
- uses: actions/checkout@v4

- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: "3.3"
bundler-cache: true

- name: Build gem
run: bundle exec rake build

- name: Publish to rubygems.org
env:
EMF_RUBYGEMS_API_KEY: ${{ secrets.EMF_RUBYGEMS_API_KEY }}
run: |
mkdir -p ~/.gem
printf '---\n:rubygems_api_key: %s\n' "$EMF_RUBYGEMS_API_KEY" > ~/.gem/credentials
chmod 0600 ~/.gem/credentials
gem push pkg/emf-*.gem

- name: Create GitHub release
uses: softprops/action-gh-release@v2
with:
generate_release_notes: true
files: pkg/emf-*.gem
35 changes: 35 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
name: Test

on:
push:
branches: [main]
pull_request:
branches: [main]

jobs:
test:
name: Ruby ${{ matrix.ruby }} on ${{ matrix.os }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
ruby: ["3.1", "3.2", "3.3", "3.4"]
os: [ubuntu-latest, macos-latest]

steps:
- uses: actions/checkout@v4

- name: Set up Ruby ${{ matrix.ruby }}
uses: ruby/setup-ruby@v1
with:
ruby-version: ${{ matrix.ruby }}
bundler-cache: true

- name: Run specs
run: bundle exec rspec

- name: Run rubocop
run: bundle exec rubocop

- name: Build gem
run: bundle exec rake build
12 changes: 8 additions & 4 deletions .rubocop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,20 +19,23 @@ Layout/LineLength:
Max: 140

Metrics/MethodLength:
Max: 25
Max: 35
Exclude:
- "lib/emf/model/geometry/**/*.rb"
- "spec/scripts/convert_docs_spec.rb"
- "exe/emf"

Metrics/ModuleLength:
Max: 200
Max: 300
Exclude:
- "lib/emf/emr/binary/records.rb"

Metrics/ClassLength:
Exclude:
- "scripts/convert_docs.rb"

Metrics/AbcSize:
Max: 30
Max: 60
Exclude:
- "lib/emf/model/geometry/**/*.rb"

Expand Down Expand Up @@ -87,10 +90,11 @@ Lint/FloatComparison:
Exclude:
- "lib/emf/model/geometry/matrix.rb"

# Hex format strings don't benefit from annotated tokens.
# Hex/format strings don't benefit from annotated tokens.
Style/FormatStringToken:
Exclude:
- "lib/emf/model/geometry/color.rb"
- "lib/emf/visitors/**/*"
- "scripts/convert_docs.rb"

# Visitor subclasses intentionally don't call super (the base Visitor has no
Expand Down
47 changes: 47 additions & 0 deletions CHANGELOG.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
= Changelog

All notable changes to `emf` will be documented in this file.

The format is based on http://keepachangelog.com/en/1.1.0/[Keep a Changelog],
and this project adheres to http://semver.org/spec/v2.0.0.html[Semantic Versioning].

== [Unreleased]

=== Added

* WMF parser (TODO 07).
* EMF+ structured parser (TODO 12–14).
* Semantic domain records to replace `WireAdapter` (TODO 10).
* 2-pass path association for BEGINPATH ↔ FILLPATH/STROKEPATH (TODO 11).

== [0.1.0] — 2026-07-24

=== Added

* Initial release of `emf`, a pure-Ruby parser for WMF / EMF / EMF+.
* Foundation: gem scaffold, error hierarchy, bindata primitives, geometry
value types, `Metafile` container, abstract `Record` base, `Visitor`
pattern with OCP-clean `register_visit`.
* EMF wire layer with 117 of ~122 EMR_* record types declared.
* `Emf::Emr::Parser` with variable-length header support, per-record
error trapping, EMF+ payload extraction, and post-EOF trailing byte
capture.
* `Emf::Emr::Serializer` for round-trip.
* `Emf.parse` / `parse_file` / `serialize` / `serialize_file` /
`detect_format` public API.
* `Emf::Visitors::Stats` and `Emf::Visitors::Dump`.
* `emf` CLI: `info`, `dump`, `validate`, `stats`, `round-trip`,
`version`, `help`.
* `scripts/convert_docs.rb`: rubyzip + nokogiri converter that turns
MS-WMF / MS-EMF / MS-EMFPLUS `.docx` files into per-Heading1 GFM
markdown.
* 21 self-contained TODOs in `TODO.impl/` with progress tracker.
* CI workflows for GitHub Actions.

=== Verified

* Byte-identical round-trip on 100% of EMF fixtures (186 in `emf/`, 21
in `emf-ea/`, 1 in `simple/`).
* No crashes or hangs on the 21-file corrupted corpus.
* 290+ specs, all green.
* Rubocop clean.
62 changes: 62 additions & 0 deletions CONTRIBUTING.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
= Contributing to emf

== Branches and PRs

* Never commit to `main`. Create a feature branch (`feat/...`,
`fix/...`, `docs/...`) and open a PR.
* Never push tags yourself. Tags are releases; the maintainer decides.
* Squash-merge or rebase-merge is preferred; the maintainer chooses per
PR.

== Code style

* `bundle exec rubocop` must pass clean.
* `bundle exec rspec` must pass with no failures.
* Target Ruby 3.1+. Use pattern matching, endless ranges, and
frozen-string-literals.

== Constraints (project-specific)

These are absolute. PRs that violate them will be rejected.

* **No `require_relative`** in `lib/`. No `require` of internal paths.
Use Ruby `autoload` declared in the immediate parent namespace's file.
Create the namespace file if it doesn't exist.
* **No `double()` in specs.** Use real instances or `Struct.new` for
plain data.
* **No `send` to private methods**, no `instance_variable_set`/`get`,
no `respond_to?` for type checks. Use `is_a?` or design away the
check.
* **No AI attribution** in any commit message, PR description, code
comment, or changelog entry. The author is the user; AI is a tool.
* **Never delete source files** unless the user explicitly approves.

== Adding a new EMR_* record type

1. Add the type code to `lib/emf/emr/binary/type_codes.rb`.
2. Create the wire class at
`lib/emf/emr/binary/records/<snake_name>.rb` with the field layout
per MS-EMF 2.3.
3. Add an `autoload :ClassName, "emf/emr/binary/records/<snake_name>"`
to `lib/emf/emr/binary/records.rb`.
4. Add an entry to `TYPE_TO_NAME` in the same file.
5. (Optional) Add a per-record round-trip spec at
`spec/emf/emr/binary/records/<snake_name>_spec.rb`.

No central switch statement to modify. The registry handles dispatch.

== Adding tests

* Walk-the-corpus specs use `Dir.glob(Emf::SpecSupport::FixturePath.fixture(...))`.
* Round-trip is the strongest correctness signal: parse → serialise →
byte-identical bytes.
* Per-record specs cover edge values: zero, max, min, negative.

== Running tests

bundle exec rspec # full suite
bundle exec rspec spec/emf/emr/ # subset
bundle exec rspec -e "round-trips" # by description

The full suite (~280 examples) runs in under 5 seconds on a warm
machine.
135 changes: 134 additions & 1 deletion README.adoc
Original file line number Diff line number Diff line change
@@ -1,3 +1,136 @@
= emf: library for working with WMF and EMF files
= emf: pure-Ruby parser for WMF, EMF, and EMF+ metafiles

`emf` is a clean-room, pure-Ruby parser 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**: full parse + byte-identical round-trip on every fixture
(186/186 in `spec/fixtures/emf/`, 21/21 in `spec/fixtures/emf-ea/`,
1/1 in `spec/fixtures/simple/`).
* **EMF+**: EMF+ payloads are extracted from EMF carrier records as raw
bytes (`Metafile#emf_plus`). Structured EMF+ parsing is TODO
(see `TODO.impl/12-14`).
* **WMF**: detected but not yet parsed (`TODO.impl/07`).
* **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.

== 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 back to binary — byte-identical on every fixture
bytes = Emf.serialize(metafile)

== 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

== 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 `TODO.impl/PROGRESS.md` for what's done vs what remains.

=== 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 # ~280 examples across fixtures + units
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

Full TODO list lives in `TODO.impl/`. Highlights:

* TODO 09 -- finish remaining ~60 EMR wire types (currently 117 of ~122 done)
* TODO 10 -- semantic domain records (currently WireAdapter for most types)
* TODO 11 -- 2-pass path association (BEGINPATH <-> FILLPATH/STROKEPATH)
* TODO 07 -- WMF parser + fixtures
* TODO 12-14 -- EMF+ wire + domain + parser

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