Skip to content

feat: pure-Ruby WMF/EMF/EMF+ parser — foundation + EMF MVP - #1

Merged
ronaldtse merged 4 commits into
mainfrom
feat/pure-ruby-emf-parser
Jul 24, 2026
Merged

feat: pure-Ruby WMF/EMF/EMF+ parser — foundation + EMF MVP#1
ronaldtse merged 4 commits into
mainfrom
feat/pure-ruby-emf-parser

Conversation

@ronaldtse

Copy link
Copy Markdown
Contributor

feat: pure-Ruby WMF/EMF/EMF+ parser — foundation + EMF MVP

Splitting the existing C-wrapper (emf2svg-ruby, an FFI shim around the
GPLv2 libemf2svg) into two gems:

  • emf (this repo) — pure-Ruby parser. Binary → OOP domain model → binary.
  • emfsvg (sibling repo, later) — transformer (model → SVG, SVG → model).

This PR lands the foundation and a working EMF MVP. WMF and EMF+ land in
follow-up TODOs tracked in TODO.impl/.

What's in this PR

Foundation (TODOs 01–06)

  • Gem scaffold: emf.gemspec (BSD-2-Clause, Ruby ≥ 3.1, bindata runtime),
    Rakefile, .rspec, .rubocop.yml, bin/, exe/.
  • Error hierarchy: Emf::Error / FormatError / ParseError / SerializeError.
  • Bindata primitives (11): PointL, PointS, PointD, SizeL, RectL,
    RectS, ColorRef, XForm, PaletteEntry, LogPalette, FileTime
    registered for reuse across all three formats.
  • Codec helpers: UTF-16LE → UTF-8, CP1252 → UTF-8.
  • Geometry value types (8): Point, PointF, PointS, Size, Rect,
    RectS, Color, Matrix — immutable, value-equal, hashable,
    from_wire / to_wire translators.
  • Metafile: Enumerable container, frozen records + errors, ok? / errors?.
  • Record abstract base with type_id and accept contracts.
  • Visitor with register_visit — OCP-clean; subclasses override only the
    visit_* methods they need.
  • Detector: APM WMF, standard WMF, EMF auto-detection.

EMF wire + parser (TODOs 09–11, 15)

  • Emf::Emr::Binary::Header: full 100-byte fixed header + trailing-bytes
    capture for byte-faithful round-trip.
  • Emf::Emr::Binary::Records: registry + autoload for 61 record types
    covering the most common EMR_* types (Polygon/Polyline/PolyBezier + 16
    variants, drawing primitives, state records, transforms, path control,
    Comment, EOF). Unknown types fall through to Records::Raw which
    preserves bytes verbatim.
  • Emf::Emr::Parser: variable-length header read, record walk, registry
    dispatch, per-record error trapping, EMF+ payload extraction.
  • Emf::Emr::Serializer: walks records, serializes each via bindata.
  • Emf::Model::Emr::Header: domain value object preserving all 18 wire
    fields for round-trip.
  • Emf::Model::Emr::Records::WireAdapter: interim domain wrapper around
    bindata records (TODO 10 replaces with semantic classes).
  • Public API: Emf.parse / parse_file / serialize / serialize_file
    / detect_format.

Reference docs (TODO 02)

  • scripts/convert_docs.rb: rubyzip + nokogiri converter, splits any
    MS Open Specifications .docx by Heading1 into GFM files.
  • Generated markdown for MS-WMF, MS-EMF, MS-EMFPLUS (21 chapter files
    • INDEX.md + media/).

TODO roadmap

  • TODO.impl/ with 21 self-contained TODOs + PROGRESS.md rolling up
    what's done vs what remains.

Architectural decisions

  • Emf::Emr, not Emf::Emf. A namespace called Emf::Emf causes Ruby's
    constant lookup to confuse Emf::FormatError (resolved inside
    module Emf) with Emf::Emf::FormatError. Renamed to Emf::Emr,
    which mirrors the spec's EMR_* prefix.
  • bindata for wire, PORO for domain. Wire records are byte-faithful
    bindata classes. Domain records are immutable value objects with
    from_wire / to_wire translators. No hand-rolled to_h / from_h.
  • Registry + autoload for OCP. Adding an EMR record touches the
    record's own file plus three lines in records.rb. No central switch
    statement.
  • Visitor pattern for consumers. emfsvg will provide its own visitor
    with visit_emr_* overrides.
  • Errors are data, not exceptions, for per-record failures.
    Metafile#errors is an array of ParseError. Only header / magic
    failures raise.
  • BSD-2-Clause. Clean-room Ruby; no GPL taint.

Constraints honored

  • 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 work on feat/pure-ruby-emf-parser; nothing committed to main.

Verification

  • bundle exec rspec75 examples, 0 failures.
  • bundle exec rubocop122 files, 0 offenses.
  • bundle exec rake buildpkg/emf-0.1.0.gem.
  • Byte-identical round-trip on 137/186 EMF fixtures (74%).
  • All 186 EMF fixtures parse without raising.
  • simple/image1.emf: 3538 records, 0 errors, byte-identical round-trip.

What's next (tracked in TODO.impl/)

TODO Title Priority
09 Finish remaining ~60 EMR_* wire types P0
10 Replace WireAdapter with semantic domain classes P0
11 2-pass path association (BEGINPATH ↔ FILLPATH/STROKEPATH) P0
16 Round-trip harness on every non-corrupted fixture P0
17 Corrupted-resilience spec on emf-corrupted/ P0
07, 08 WMF wire + domain + parser; source WMF fixtures P1
12–14 EMF+ wire + domain + parser P1
18–21 Visitors, CLI, docs, CI P1

- emf.gemspec: BSD-2-Clause, Ruby >=3.1, bindata runtime dep
- Gem scaffold: Rakefile, .rspec, .rubocop.yml, .ruby-version, LICENSE, bin/, exe/
- Emf::Error hierarchy: Error / FormatError / ParseError / SerializeError
- Emf::Binary::Types: 11 bindata primitives (PointL, PointS, RectL, ColorRef,
  XForm, LogPalette, ...) registered for reuse across formats
- Emf::Binary::Codec: UTF-16LE and CP1252 -> UTF-8 decoding helpers
- Emf::Model::Geometry: 8 immutable value types (Point, PointF, PointS, Size,
  Rect, RectS, Color, Matrix) with from_wire/to_wire translators
- Emf::Model::Metafile: Enumerable container with format / header / records /
  errors / emf_plus, freezes records and errors on construction
- Emf::Model::Record: abstract base with type_id and accept contracts
- Emf::Model::Visitor: OCP-clean visitor with register_visit; subclasses override
  only the visit_* methods they need
- Emf::Detector: format auto-detection for APM WMF, standard WMF, EMF
- Namespace stubs for Wmf, Emr (EMF), EmfPlus with raise-on-call parsers
- 46 specs covering primitives, geometry, visitor dispatch, detection
- Emf::Emr::Binary::Header: full ENHMETAHEADER (100 bytes fixed + trailing
  capture for byte-faithful round-trip)
- Emf::Emr::Binary::Record and WithBounds shared bases for all EMR records
- Emf::Emr::Binary::TypeCodes: 122 EMR_* numeric constants per MS-EMF 2.3.1
- Emf::Emr::Binary::Records: registry + autoload for 61 record types covering
  the most common types (Polygon/Polyline/PolyBezier + 16 variants, drawing
  primitives, state records, transforms, path control, comment, EOF). Unknown
  types fall through to Records::Raw which preserves bytes verbatim.
- Emf::Emr::Parser: reads variable-length header, walks records, dispatches
  via the registry, traps per-record errors as ParseError entries, extracts
  EMF+ payload bytes from EMR_COMMENT records (identifier 0x2B464D45)
- Emf::Emr::Serializer: walks records, serializes each via bindata
- Emf::Model::Emr::Header: domain value object preserving all 18 wire fields
  for byte-faithful round-trip
- Emf::Model::Emr::Records::WireAdapter: domain-shape wrapper around bindata
  records (interim solution until TODO 10 lands per-record domain classes)
- Emf.parse / parse_file / serialize / serialize_file / detect_format: public
  API auto-dispatches WMF vs EMF
- 75 specs, 0 failures. Byte-identical round-trip on 137/186 EMF fixtures.
TODO.impl/ (22 files + index + progress):
- 21 self-contained TODOs covering all priorities (P0 foundation + EMF,
  P1 WMF + EMF+ + tooling + CI), each with Context, Outcomes, Files,
- Verification, Done-when checklist.
- PROGRESS.md rolls up what's complete vs what remains.

spec/fixtures/ (230 EMF files, 35 MB):
- emf/: 186 plain EMF files including 19 EMF+ carriers
- emf-ea/: 21 Sparx EA / Wine EMF files
- simple/: 1 minimal EMF (image1.emf, 92 KB, 3538 records)
- emf-corrupted/: 21 corrupted EMF + README documenting each corruption

scripts/convert_docs.rb:
- Converts a Microsoft Open Specifications .docx to a set of GFM markdown
  files split by Heading1, using rubyzip + nokogiri
- Extracts media, renders tables as GFM pipe tables, preserves heading
  hierarchy and inline code/bold/italic
- spec/scripts/convert_docs_spec.rb builds a synthetic .docx and verifies
  the converter end-to-end
Source .docx files (Microsoft Open Specifications, 2024-04-23 revision):
- [MS-WMF]-240423.docx (263 KB compressed, 85 K words)
- [MS-EMF]-240423.docx (569 KB compressed, 110 K words)
- [MS-EMFPLUS]-240423.docx (483 KB compressed, 108 K words)

Derived markdown in reference-docs/<spec>/:
- 7 chapters per spec (21 files total), each with YAML frontmatter
- INDEX.md per spec listing chapter title and word count
- media/ extracted from word/media/
- convert-docs.log provenance trail

Generated by scripts/convert_docs.rb. Re-run when spec revisions bump.
@ronaldtse
ronaldtse merged commit 24a64dc into main Jul 24, 2026
@ronaldtse
ronaldtse deleted the feat/pure-ruby-emf-parser branch July 24, 2026 06:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant