diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..60bae32 --- /dev/null +++ b/.github/workflows/release.yml @@ -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 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..5e0c572 --- /dev/null +++ b/.github/workflows/test.yml @@ -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 diff --git a/.rubocop.yml b/.rubocop.yml index 29863dd..bd6efa0 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -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" @@ -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 diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc new file mode 100644 index 0000000..3e14229 --- /dev/null +++ b/CHANGELOG.adoc @@ -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. diff --git a/CONTRIBUTING.adoc b/CONTRIBUTING.adoc new file mode 100644 index 0000000..6e24673 --- /dev/null +++ b/CONTRIBUTING.adoc @@ -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/.rb` with the field layout + per MS-EMF 2.3. +3. Add an `autoload :ClassName, "emf/emr/binary/records/"` + 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/_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. diff --git a/README.adoc b/README.adoc index 354c029..3655a81 100644 --- a/README.adoc +++ b/README.adoc @@ -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//`. 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). diff --git a/TODO.impl/PROGRESS.md b/TODO.impl/PROGRESS.md index e9ab8f2..a7301ee 100644 --- a/TODO.impl/PROGRESS.md +++ b/TODO.impl/PROGRESS.md @@ -1,61 +1,116 @@ -# TODO Progress — 2026-07-24 +# TODO Progress — 2026-07-24 (second pass) This file summarises what's been implemented vs what remains. Each TODO file in this directory has its own detailed scope; this is the roll-up. -## Done (foundation + working EMF MVP) +## Done -| # | Title | Status | -|---|---|---| -| 01 | Bootstrap gem skeleton | done — gem builds, specs run, rubocop clean | -| 02 | Convert MS spec .docx → GFM markdown | done — script + 4 specs, 21 chapter files generated for MS-EMF, MS-WMF, MS-EMFPLUS | -| 03 | Binary primitives & codec | done — 11 bindata primitives + codec, all round-trip specced | -| 04 | Geometry value types | done — 8 value types (Point, PointF, PointS, Size, Rect, RectS, Color, Matrix) | -| 05 | Metafile container, Record base, Visitor base | done — Metafile enumerable, Record abstract, Visitor with register_visit | -| 06 | Format detector | done — APM WMF, standard WMF, EMF, garbage, short input | -| 09 | EMF wire layer | partial — 61 of ~122 record types declared, header fully parsed, registry + Raw fallback | -| 10 | EMF domain layer | partial — Header + WireAdapter; per-record domain classes land in TODO 10 follow-up | -| 11 | EMF parser | MVP done — single-pass, per-record error trapping, EMF+ payload extraction stub; 2-pass path association lands in TODO 11 follow-up | -| 15 | Public API surface | MVP done — Emf.parse / .parse_file / .serialize / .serialize_file / .detect_format | +### Foundation (TODOs 01–06) — fully complete +- Gem scaffold, error hierarchy, bindata primitives, geometry value + types, Metafile container, Record base, Visitor base, format detector. + +### TODO 02 — Reference doc converter — fully complete +- `scripts/convert_docs.rb` (rubyzip + nokogiri). +- 21 chapter files generated across MS-WMF, MS-EMF, MS-EMFPLUS. +- 4 specs including a synthetic-docx end-to-end test. + +### TODO 09 — EMF wire layer — substantially complete +- 117 of ~122 EMR_* types declared as bindata wire classes. +- Header wire class supports 88/100/108-byte variants + trailing bytes. +- Registry + autoload + Raw fallback for the few remaining types. +- TypeCodes module documents the canonical numeric constants. + +### TODO 10 — EMF domain layer — MVP complete +- `Emf::Model::Emr::Header` preserves all 18 wire fields + trailing. +- `Emf::Model::Emr::Records::WireAdapter` wraps every other wire class. +- Per-record semantic classes deferred (WireAdapter carries type_id and + the underlying wire for emfsvg to dispatch on). + +### TODO 11 — EMF parser — MVP complete +- Reads variable-length header, walks records, dispatches via registry. +- Per-record error trapping via `Emf::ParseError`. +- Variable-array sanity check (prevents pathological hangs). +- EMF+ payload extraction (raw bytes on `Metafile#emf_plus`). +- Post-EOF trailing bytes captured on `Metafile#trailing`. +- 2-pass path association deferred (TODO 11 follow-up). + +### TODO 15 — Public API — fully complete +- `Emf.parse`, `parse_file`, `serialize`, `serialize_file`, + `detect_format`. Auto-dispatches WMF vs EMF. + +### TODO 16 — Round-trip harness — fully complete +- `spec/emf/round_trip_spec.rb` walks every non-corrupted fixture + (186 + 21 + 1 = 208) and asserts byte-identical round-trip. + +### TODO 17 — Corrupted-resilience spec — fully complete +- `spec/emf/corrupted_resilience_spec.rb` walks all 21 `emf-corrupted/` + files. Asserts no crash, no hang (5s timeout). All 21 pass. + +### TODO 18 — Visitors — fully complete +- `Emf::Visitors::Stats` produces record-class histogram. +- `Emf::Visitors::Dump` produces human-readable dump. +- Both override `visit_emr_wire_record` for the catch-all adapter. + +### TODO 19 — CLI — fully complete +- `exe/emf` with subcommands: `version`, `info`, `dump`, `validate`, + `stats`, `round-trip`, `help`. +- Tested end-to-end against `spec/fixtures/simple/image1.emf`. + +### TODO 20 — Documentation — fully complete +- `README.adoc` rewritten with synopsis, CLI usage, architecture, + constraints, roadmap. +- `docs/architecture.adoc` — three-layer design + MECE table. +- `docs/format_notes.adoc` — per-format quirks (header variants, + records without rclBounds, SelectClipPath nSize=12, trailing data, + EMF+ container). +- `CONTRIBUTING.adoc` — branch policy, code style, constraints, + how-to-add-a-record. +- `CHANGELOG.adoc` — Keep-a-Changelog format starting at 0.1.0. + +### TODO 21 — CI — fully complete +- `.github/workflows/test.yml`: matrix on Ruby 3.1/3.2/3.3/3.4 across + Ubuntu and macOS. Runs specs, rubocop, gem build. +- `.github/workflows/release.yml`: tag-triggered (`v*`) release to + rubygems.org + GitHub release. Gated on tag push, not on main commits. +- No AI attribution anywhere. ## Verification (against spec/fixtures/) -- 75 specs, 0 failures. -- Rubocop clean (122 files, 0 offenses). -- Byte-identical round-trip on 137/186 EMF fixtures (74%). -- All 186 EMF fixtures parse without raising. -- Simple fixture (image1.emf): 3538 records, 0 errors, byte-identical round-trip. -- Converter output verified against all three MS spec .docx. +- **306 specs, 0 failures** (up from 75 in the first pass). +- Rubocop clean (179 files). +- Gem builds. +- **Byte-identical round-trip on 100% of EMF fixtures**: 186/186 in + `emf/`, 21/21 in `emf-ea/`, 1/1 in `simple/`. +- All 21 `emf-corrupted/` files parse without crashing or hanging. ## Remaining work -### P0 — landing the full EMF MVP +### P0 — semantic refinements | # | Title | Why | |---|---|---| -| 09 | EMF wire layer — finish remaining EMR_* types | ~60 more record types needed for 100% round-trip on all fixtures | -| 10 | EMF domain layer — replace WireAdapter with semantic classes | emfsvg needs typed records, not raw bindata wrappers | -| 11 | EMF parser — 2-pass path association | emfsvg needs path→renderer back-references | -| 16 | Round-trip harness — every non-corrupted fixture | strongest correctness signal | -| 17 | Corrupted-resilience spec — emf-corrupted/ corpus | proves no crashes/hangs | +| 10 | Replace `WireAdapter` with semantic per-record domain classes | emfsvg gets `Polygon#points` instead of `wire.aptl` | +| 11 | 2-pass path association (BEGINPATH ↔ FILLPATH/STROKEPATH) | emfsvg's path rendering needs the back-reference | -### P1 — completing the formats and tooling +### P1 — additional formats and tooling | # | Title | Why | |---|---|---| | 07 | WMF wire + domain + parser | second format | | 08 | Source WMF fixtures | can't validate WMF without | -| 12 | EMF+ wire layer (~60 records) | third format | +| 12 | EMF+ wire layer (~58 records) | third format | | 13 | EMF+ domain layer | mirror | -| 14 | EMF+ parser (consumes EMR_COMMENT payload) | wires EMF+ into Metafile#emf_plus | -| 18 | Visitors: Dump and Stats | debugging + spec assertions | -| 19 | exe/emf CLI | user-facing tool | -| 20 | Documentation | README + architecture + format notes | -| 21 | CI | GitHub Actions matrix | - -## Known issues to address in TODO 10/11 follow-ups - -1. **49 EMF fixtures don't byte-round-trip yet** (21 EOFError on reparse, 28 record-count drift). Most are EMF+ carriers and records with field layouts we stubbed (`rest :body`). Fix: complete the wire classes for the remaining ~60 EMR_* types in TODO 09. -2. **EMF+ extraction stashes raw bytes**, not a parsed `Metafile`. The structured extraction lands in TODO 14. -3. **Path association (BEGINPATH..ENDPATH ↔ FILLPATH/STROKEPATH) is not built**. emfsvg's rendering of EMF paths will need this; lands in TODO 11. -4. **WireAdapter wraps the bindata record**, not a true semantic class. emfsvg gets type_id + raw fields; proper `Emf::Model::Emr::Records::Polygon` etc. land in TODO 10. +| 14 | EMF+ parser (consumes EMR_COMMENT payload) | wires EMF+ into `Metafile#emf_plus` | + +## Known improvements for a future PR + +1. **SelectClipPath wire class** is currently spec-form (28 bytes) but + real-world files often have nSize=12 (header + regionMode only, no + rclBounds). Non-conforming files fall through to Raw. Fix: make the + rclBounds field conditional on nSize, or split into two classes. +2. **`Stats#visit_emr_wire_record`** tallies by underlying wire class + (e.g. `WireAdapter(Comment)`). Once TODO 10 lands semantic classes, + the histogram will show `Polygon`, `Polyline`, etc. directly. +3. **Header to_wire** preserves `trailing` bytes verbatim but doesn't + semantically model the optional description string or pixel format + data. A future refactor could parse them properly. diff --git a/docs/architecture.adoc b/docs/architecture.adoc new file mode 100644 index 0000000..f29c50e --- /dev/null +++ b/docs/architecture.adoc @@ -0,0 +1,106 @@ += Architecture + +== Layers (MECE) + +``` +binary bytes ──[bindata]──▶ Emr::Binary::*Record (byte-faithful wire) + │ + Record.from_wire + ▼ + Model::*Record (semantic OOP, immutable) + │ + Record#to_wire + ▼ + Emr::Binary::*Record ──[bindata]──▶ binary bytes +``` + +== Module map + +|=== +| Concern | Lives in | Notes + +| Binary read/write primitives +| `Emf::Binary::Types` +| 11 shared bindata primitives (`PointL`, `PointS`, `RectL`, `ColorRef`, + `XForm`, …) registered for reuse across formats. + +| Per-record wire layouts +| `Emf::Emr::Binary::Records::*` +| One file per EMR_* type. ~117 of ~122 record types implemented; the + rest fall through to `Raw` which preserves bytes verbatim. + +| Geometric value objects +| `Emf::Model::Geometry` +| `Point`, `PointF`, `PointS`, `Size`, `Rect`, `RectS`, `Color`, `Matrix`. + Immutable, value-equal, hashable, `from_wire`/`to_wire` translators. + +| Per-record semantic classes +| `Emf::Model::Emr::*` +| Currently `Header` and `Records::WireAdapter`. TODO 10 adds a typed + domain class per record. + +| Metafile container + base record + base visitor +| `Emf::Model::{Metafile,Record,Visitor}` +| Format-agnostic. Metafile is Enumerable. + +| Per-format parser +| `Emf::Emr::Parser` +| Reads header, walks records, dispatches via the registry, traps per- + record errors, extracts EMF+ payload bytes, captures post-EOF + trailing bytes. + +| Public entrypoints +| `Emf.parse`, `Emf.parse_file`, `Emf.serialize`, `Emf.serialize_file`, + `Emf.detect_format` +| Auto-detects WMF vs EMF. +|=== + +== Open/closed principle + +Each format owns a `Binary::Records` module that declares: + +1. `autoload` one entry per record file. +2. A `TYPE_TO_NAME` frozen hash mapping the integer code to the constant. +3. A `lookup(code)` class method that does `const_get(name)` (which + triggers autoload) or falls back to `Raw`. + +Adding a record = one new file under `records/` + three lines added to +the format's `records.rb`. `emf` itself is closed: no central switch +statement to edit. + +== Visitor pattern + +Each domain record's `accept(visitor)` calls a specific `visit_*` method +on the visitor. The base `Visitor` class auto-registers a no-op default +via `register_visit(name, record_class)`. Subclasses override only what +they need. + +`Emf::Visitors::Stats` and `Emf::Visitors::Dump` are shipped with the +gem; `emfsvg` will provide its own visitor for SVG emission. + +== Resilience policy + +Per-record errors are caught and stored on `Metafile#errors` (an array +of `Emf::ParseError` with `offset`, `record_code`, `message`). Parsing +continues to the next record wherever possible. Truly invalid inputs +(bad magic, truncated header) raise `Emf::FormatError` immediately. + +For records with variable-length arrays, the parser sanity-checks the +declared count against `nSize` before letting bindata allocate. This +prevents pathological inputs (e.g., the `emf-corrupted/` corpus) from +hanging the parser on giant allocations. + +== Trailing data + +After the EMR_EOF record, some EMF writers append extra bytes (signing, +padding, embedded metadata). The parser captures these as +`Metafile#trailing` and the serializer re-emits them, preserving +byte-for-byte fidelity. + +== Y-coordinate repair (out of scope) + +Sparx Enterprise Architect under Wine produces EMFs whose complex +sub-objects have only the Y anchor negated. The detection heuristic +(`rclBounds.top * rclBounds.bottom < 0`) and the per-coordinate repair +are emfsvg's concern (they affect rendering, not parsing). The parser +exposes `header.bounds` faithfully. diff --git a/docs/format_notes.adoc b/docs/format_notes.adoc new file mode 100644 index 0000000..b84ab28 --- /dev/null +++ b/docs/format_notes.adoc @@ -0,0 +1,75 @@ += Format notes + +Quirks of each format that future maintainers must know. + +== EMF + +=== Variable-length header + +The `ENHMETAHEADER` record's `nSize` is variable: + +* **88 bytes** — fixed portion only (iType through szlMillimeters). +* **100 bytes** — adds win95 fields cbPixelFormat, offPixelFormat, + bOpenGL. +* **108 bytes** — adds win98 field szlMicrometers. +* **More** — optional UTF-16LE description string at offDescription. + +The wire class `Emf::Emr::Binary::Header` uses bindata's `onlyif:` to +declare the win95/win98 fields conditionally on `n_size`, then captures +any remaining bytes as `trailing` for byte-faithful round-trip. + +=== Records without rclBounds + +Per MS-EMF 2.3.5, the closed-shape records (`EMR_RECTANGLE`, +`EMR_ELLIPSE`, `EMR_ROUNDRECT`, `EMR_ARC`, `EMR_CHORD`, `EMR_PIE`, +`EMR_ARCTO`) carry `rclBox` only — no `rclBounds`. The wire classes +inherit from `Emf::Emr::Binary::Record` (header only), not from +`WithBounds`. + +The path-rendering records (`EMR_FILLPATH`, `EMR_STROKEPATH`, +`EMR_STROKEANDFILLPATH`) DO have `rclBounds` per spec but some EMF +writers omit it (nSize=8 in those files). Our wire classes inherit from +`WithBounds` per spec; if the source has fewer bytes, the parser falls +through to `Raw` to preserve the bytes verbatim. + +=== SelectClipPath nSize=12 + +`EMR_SELECTCLIPPATH` per spec is `emr + rclBounds + regionMode` (28 +bytes) but real-world files often have nSize=12 (just `emr + +regionMode`). Our wire class declares the spec form; non-conforming +files fall through to `Raw`. + +=== Trailing data after EMR_EOF + +Some EMF writers append bytes after the `EMR_EOF` record. The parser +captures these as `Metafile#trailing` and the serializer re-emits them. + +=== EMF+ container + +EMF+ records travel inside `EMR_COMMENT` records with identifier +`0x2B464D45` ("EMF+" little-endian). The parser extracts the payload +bytes and stashes them on `Metafile#emf_plus`. Structured parsing is +TODO 14. + +== WMF + +WMF is detected by either: + +* APM (Aldus Placeable Metafile) header magic `0x9AC6CDD7` (uint32 LE + of bytes `0xD7 0xCD 0xC6 0x9A`). +* Standard METAHEADER (`metafile_type` of 1 or 2, `header_size` of 9 + words). + +Parser is TODO 07. + +== EMF+ + +EMF+ uses its own 12-byte common record header +(`uint16 type, uint16 flags, uint32 size, uint32 data_size`). The high +bit (`0x4000`) is the EMF+ record flag; the actual type code is +`type & 0x3FFF`. The first record in an EMF+ stream is always the EMF+ +Header (type 0x4001). + +EMF+ Object records can be split across multiple EMF Comment payloads; +the parser must accumulate chunks until the Object is complete. This is +TODO 14's `ObjectAccumulator`. diff --git a/exe/emf b/exe/emf index 5bd1376..879e2bb 100755 --- a/exe/emf +++ b/exe/emf @@ -1,6 +1,8 @@ #! /usr/bin/env ruby # frozen_string_literal: true +require "optparse" + $LOAD_PATH.unshift(File.expand_path("../lib", __dir__)) require "emf" @@ -12,26 +14,97 @@ module Emf command = argv.shift case command when "version", "-v", "--version" then puts "emf #{Emf::VERSION}" + when "info" then info(argv) + when "dump" then dump(argv) + when "validate" then validate(argv) + when "stats" then stats(argv) + when "round-trip" then round_trip(argv) when "help", "-h", "--help", nil then print_help else - warn "emf: '#{command}' is not yet implemented (TODO.impl/19-cli.md)." - warn "Run `emf help` for the list of planned subcommands." + warn "emf: unknown command '#{command}' (see `emf help`)" + exit 1 + end + end + + def parse_path(argv) + path = argv.shift + return path if path && !path.empty? + + warn "emf: missing FILE argument" + exit 2 + end + + def info(argv) + path = parse_path(argv) + mf = Emf.parse_file(path) + puts "Format: #{mf.format}" + puts "Records: #{mf.records.size}" + puts "Errors: #{mf.errors.size}" + puts "EMF+: #{mf.emf_plus ? "present (#{mf.emf_plus.bytesize} bytes)" : 'absent'}" + puts "Trailing: #{mf.trailing.bytesize} bytes" + return unless mf.header + + h = mf.header + puts "Bounds: left=#{h.bounds.left} top=#{h.bounds.top} right=#{h.bounds.right} bottom=#{h.bounds.bottom}" + puts "Frame: left=#{h.frame.left} top=#{h.frame.top} right=#{h.frame.right} bottom=#{h.frame.bottom}" + puts "Device: #{h.device_pixels.cx}x#{h.device_pixels.cy} px (#{h.device_mm.cx / 100.0}x#{h.device_mm.cy / 100.0} mm)" + puts "nRecords: #{h.n_records} nHandles: #{h.n_handles} nBytes: #{h.n_bytes}" + end + + def dump(argv) + path = parse_path(argv) + mf = Emf.parse_file(path) + d = Emf::Visitors::Dump.new + d.visit_all(mf) + puts d + end + + def validate(argv) + path = parse_path(argv) + mf = Emf.parse_file(path) + if mf.errors.empty? + puts "OK: #{mf.records.size} records, 0 errors" + exit 0 + else + puts "INVALID: #{mf.records.size} records, #{mf.errors.size} errors" + mf.errors.first(10).each { |e| puts " #{e.message}" } exit 1 end end + def stats(argv) + path = parse_path(argv) + mf = Emf.parse_file(path) + s = Emf::Visitors::Stats.new + s.visit_all(mf) + puts s + end + + def round_trip(argv) + path = parse_path(argv) + original = File.binread(path) + reborn = Emf.serialize(Emf.parse(original)) + out = argv.shift + if out + File.binwrite(out, reborn) + puts "Wrote #{out} (#{reborn.bytesize} bytes)" + else + puts original == reborn ? "OK: byte-identical round-trip" : "DRIFT: original=#{original.bytesize} reborn=#{reborn.bytesize}" + end + end + def print_help puts <<~HELP emf #{Emf::VERSION} — pure-Ruby WMF/EMF/EMF+ parser Usage: - emf version Print the version - emf info FILE Print metafile header summary (TODO 19) - emf dump FILE Print a record-by-record dump (TODO 19) - emf validate FILE Parse and report errors (TODO 19) - emf stats FILE Print record-type histogram (TODO 19) - emf round-trip FILE Parse then serialize (TODO 19) - emf help Show this message + emf version Print the version + emf info FILE Print metafile header and record counts + emf dump FILE Print a record-by-record dump + emf validate FILE Parse and report errors (exit 0 ok, 1 invalid) + emf stats FILE Print record-type histogram + emf round-trip FILE [--out] Serialize after parse, compare or write + emf help Show this message HELP end end diff --git a/lib/emf/emr/binary/header.rb b/lib/emf/emr/binary/header.rb index dd89d94..9021013 100644 --- a/lib/emf/emr/binary/header.rb +++ b/lib/emf/emr/binary/header.rb @@ -5,10 +5,11 @@ module Emf module Emr module Binary - # EMF metafile header record. The fixed portion is 100 bytes ending at - # szlMicrometers. An optional UTF-16LE description string follows at - # offset offDescription for nDescription Unicode chars. nSize declares - # the total record size (including the description string). + # EMF metafile header record. The fixed portion is 88 bytes ending at + # szlMillimeters. The remaining fields (cbPixelFormat, offPixelFormat, + # bOpenGL, szlMicrometers) are optional — present only when nSize is + # 100 or 108. An optional UTF-16LE description string follows at + # offDescription for nDescription Unicode chars. class Header < BinData::Record endian :little uint32 :i_type # 1 @@ -26,11 +27,14 @@ class Header < BinData::Record uint32 :n_pal_entries # palette entries (used by EMR_EOF) size_l :szl_device # reference device size in pixels size_l :szl_millimeters # reference device size in 0.01 mm - uint32 :cb_pixel_format # PixelFormatDescriptor size, 0 if none - uint32 :off_pixel_format # offset to PixelFormatDescriptor - uint32 :b_open_gl # nonzero if OpenGL records present - size_l :szl_micrometers # device size in micrometers (EMF+ variant) - rest :trailing # captures any extension bytes beyond the fixed header + # Win95-and-later fields (absent in 88-byte headers). + uint32 :cb_pixel_format, onlyif: -> { n_size > 88 } # PixelFormatDescriptor size, 0 if none + uint32 :off_pixel_format, onlyif: -> { n_size > 88 } # offset to PixelFormatDescriptor + uint32 :b_open_gl, onlyif: -> { n_size > 88 } # nonzero if OpenGL records present + # Win98-and-later field (absent in 100-byte headers). + size_l :szl_micrometers, onlyif: -> { n_size > 100 } # device size in micrometers + # Capture any trailing bytes beyond the declared optional fields. + rest :trailing end end end diff --git a/lib/emf/emr/binary/records.rb b/lib/emf/emr/binary/records.rb index 2dcfcba..61039bc 100644 --- a/lib/emf/emr/binary/records.rb +++ b/lib/emf/emr/binary/records.rb @@ -29,6 +29,7 @@ module Records autoload :SetWindowExtEx, "emf/emr/binary/records/set_window_ext_ex" autoload :SetViewportOrgEx, "emf/emr/binary/records/set_viewport_org_ex" autoload :SetViewportExtEx, "emf/emr/binary/records/set_viewport_ext_ex" + autoload :SetBrushOrgEx, "emf/emr/binary/records/set_brush_org_ex" autoload :MoveToEx, "emf/emr/binary/records/move_to_ex" autoload :LineTo, "emf/emr/binary/records/line_to" autoload :Rectangle, "emf/emr/binary/records/rectangle" @@ -38,6 +39,8 @@ module Records autoload :ArcTo, "emf/emr/binary/records/arc_to" autoload :Chord, "emf/emr/binary/records/chord" autoload :Pie, "emf/emr/binary/records/pie" + autoload :AngleArc, "emf/emr/binary/records/angle_arc" + autoload :SetArcDirection, "emf/emr/binary/records/set_arc_direction" autoload :Polygon, "emf/emr/binary/records/polygon" autoload :Polyline, "emf/emr/binary/records/polyline" autoload :PolyBezier, "emf/emr/binary/records/poly_bezier" @@ -48,6 +51,8 @@ module Records autoload :PolyBezierTo16, "emf/emr/binary/records/poly_bezier_to16" autoload :PolylineTo, "emf/emr/binary/records/polyline_to" autoload :PolylineTo16, "emf/emr/binary/records/polyline_to16" + autoload :PolyDraw, "emf/emr/binary/records/poly_draw" + autoload :PolyDraw16, "emf/emr/binary/records/poly_draw16" autoload :PolyPolyline, "emf/emr/binary/records/poly_polyline" autoload :PolyPolygon, "emf/emr/binary/records/poly_polygon" autoload :PolyPolyline16, "emf/emr/binary/records/poly_polyline16" @@ -71,11 +76,58 @@ module Records autoload :CreateFontIndirectW, "emf/emr/binary/records/create_font_indirect_w" autoload :ExtCreatePen, "emf/emr/binary/records/ext_create_pen" autoload :StretchDIBits, "emf/emr/binary/records/stretch_dibits" + autoload :SetPixelV, "emf/emr/binary/records/set_pixel_v" + autoload :SetMetArgn, "emf/emr/binary/records/set_metargn" + autoload :OffsetClipRgn, "emf/emr/binary/records/offset_clip_rgn" + autoload :ExcludeClipRect, "emf/emr/binary/records/exclude_clip_rect" + autoload :IntersectClipRect, "emf/emr/binary/records/intersect_clip_rect" + autoload :ExtSelectClipRgn, "emf/emr/binary/records/ext_select_clip_rgn" + autoload :ScaleViewportExtEx, "emf/emr/binary/records/scale_viewport_ext_ex" + autoload :ScaleWindowExtEx, "emf/emr/binary/records/scale_window_ext_ex" + autoload :SetMapperFlags, "emf/emr/binary/records/set_mapper_flags" + autoload :RealizePalette, "emf/emr/binary/records/realize_palette" + autoload :SelectPalette, "emf/emr/binary/records/select_palette" + autoload :CreatePalette, "emf/emr/binary/records/create_palette" + autoload :ResizePalette, "emf/emr/binary/records/resize_palette" + autoload :SetPaletteEntries, "emf/emr/binary/records/set_palette_entries" + autoload :ExtFloodFill, "emf/emr/binary/records/ext_flood_fill" + autoload :FillRgn, "emf/emr/binary/records/fill_rgn" + autoload :FrameRgn, "emf/emr/binary/records/frame_rgn" + autoload :InvertRgn, "emf/emr/binary/records/invert_rgn" + autoload :PaintRgn, "emf/emr/binary/records/paint_rgn" + autoload :BitBlt, "emf/emr/binary/records/bit_blt" + autoload :StretchBlt, "emf/emr/binary/records/stretch_blt" + autoload :MaskBlt, "emf/emr/binary/records/mask_blt" + autoload :PlgBlt, "emf/emr/binary/records/plg_blt" + autoload :SetDIBitsToDevice, "emf/emr/binary/records/set_dibits_to_device" + autoload :AlphaBlend, "emf/emr/binary/records/alpha_blend" + autoload :TransparentBlt, "emf/emr/binary/records/transparent_blt" + autoload :SetLayout, "emf/emr/binary/records/set_layout" + autoload :SetIcmMode, "emf/emr/binary/records/set_icm_mode" + autoload :CreateMonoBrush, "emf/emr/binary/records/create_mono_brush" + autoload :CreateDibPatternBrushPt, "emf/emr/binary/records/create_dib_pattern_brush_pt" + autoload :GradientFill, "emf/emr/binary/records/gradient_fill" + autoload :SmallTextOut, "emf/emr/binary/records/small_text_out" + autoload :DrawEscape, "emf/emr/binary/records/draw_escape" + autoload :ExtEscape, "emf/emr/binary/records/ext_escape" + autoload :NamedEscape, "emf/emr/binary/records/named_escape" + autoload :ColorCorrectPalette, "emf/emr/binary/records/color_correct_palette" + autoload :SetIcmProfileA, "emf/emr/binary/records/set_icm_profile_a" + autoload :SetIcmProfileW, "emf/emr/binary/records/set_icm_profile_w" + autoload :ForceUfiMapping, "emf/emr/binary/records/force_ufi_mapping" + autoload :SetLinkedUfis, "emf/emr/binary/records/set_linked_ufis" + autoload :ColorMatchToTargetW, "emf/emr/binary/records/colormatch_to_target_w" + autoload :CreateColorspaceW, "emf/emr/binary/records/create_colorspace_w" + autoload :CreateColorspace, "emf/emr/binary/records/create_colorspace" + autoload :SetColorspace, "emf/emr/binary/records/set_colorspace" + autoload :DeleteColorspace, "emf/emr/binary/records/delete_colorspace" + autoload :GlsRecord, "emf/emr/binary/records/gls_record" + autoload :GlsBoundedRecord, "emf/emr/binary/records/gls_bounded_record" + autoload :PolyTextOutA, "emf/emr/binary/records/poly_textout_a" + autoload :PolyTextOutW, "emf/emr/binary/records/poly_textout_w" + autoload :PixelFormat, "emf/emr/binary/records/pixel_format" + autoload :SetTextJustification, "emf/emr/binary/records/set_text_justification" - # Maps EMR_* integer type codes to wire class names. Adding a record: - # 1. Add a wire class file under records/. - # 2. Add an autoload line above. - # 3. Add an entry here. TYPE_TO_NAME = { Binary::TypeCodes::EOF => :Eof, Binary::TypeCodes::SETTEXTCOLOR => :SetTextColor, @@ -96,6 +148,7 @@ module Records Binary::TypeCodes::SETWINDOWEXTEX => :SetWindowExtEx, Binary::TypeCodes::SETVIEWPORTORGEX => :SetViewportOrgEx, Binary::TypeCodes::SETVIEWPORTEXTEX => :SetViewportExtEx, + Binary::TypeCodes::SETBRUSHORGEX => :SetBrushOrgEx, Binary::TypeCodes::MOVETOEX => :MoveToEx, Binary::TypeCodes::LINETO => :LineTo, Binary::TypeCodes::RECTANGLE => :Rectangle, @@ -105,6 +158,8 @@ module Records Binary::TypeCodes::ARCTO => :ArcTo, Binary::TypeCodes::CHORD => :Chord, Binary::TypeCodes::PIE => :Pie, + Binary::TypeCodes::ANGLEARC => :AngleArc, + Binary::TypeCodes::SETARCDIRECTION => :SetArcDirection, Binary::TypeCodes::POLYGON => :Polygon, Binary::TypeCodes::POLYLINE => :Polyline, Binary::TypeCodes::POLYBEZIER => :PolyBezier, @@ -115,6 +170,8 @@ module Records Binary::TypeCodes::POLYBEZIERTO16 => :PolyBezierTo16, Binary::TypeCodes::POLYLINETO => :PolylineTo, Binary::TypeCodes::POLYLINETO16 => :PolylineTo16, + Binary::TypeCodes::POLYDRAW => :PolyDraw, + Binary::TypeCodes::POLYDRAW16 => :PolyDraw16, Binary::TypeCodes::POLYPOLYLINE => :PolyPolyline, Binary::TypeCodes::POLYPOLYGON => :PolyPolygon, Binary::TypeCodes::POLYPOLYLINE16 => :PolyPolyline16, @@ -137,7 +194,58 @@ module Records Binary::TypeCodes::EXTTEXTOUTA => :ExtTextOutA, Binary::TypeCodes::EXTCREATEFONTINDIRECTW => :CreateFontIndirectW, Binary::TypeCodes::EXTCREATEPEN => :ExtCreatePen, - Binary::TypeCodes::STRETCHDIBITS => :StretchDIBits + Binary::TypeCodes::STRETCHDIBITS => :StretchDIBits, + Binary::TypeCodes::SETPIXELV => :SetPixelV, + Binary::TypeCodes::OFFSETCLIPRGN => :OffsetClipRgn, + Binary::TypeCodes::SETMETARGN => :SetMetArgn, + Binary::TypeCodes::EXCLUDECLIPRECT => :ExcludeClipRect, + Binary::TypeCodes::INTERSECTCLIPRECT => :IntersectClipRect, + Binary::TypeCodes::SCALEVIEWPORTEXTEX => :ScaleViewportExtEx, + Binary::TypeCodes::SCALEWINDOWEXTEX => :ScaleWindowExtEx, + Binary::TypeCodes::SETMAPPERFLAGS => :SetMapperFlags, + Binary::TypeCodes::REALIZEPALETTE => :RealizePalette, + Binary::TypeCodes::SELECTPALETTE => :SelectPalette, + Binary::TypeCodes::CREATEPALETTE => :CreatePalette, + Binary::TypeCodes::RESIZEPALETTE => :ResizePalette, + Binary::TypeCodes::SETPALETTEENTRIES => :SetPaletteEntries, + Binary::TypeCodes::EXTFLOODFILL => :ExtFloodFill, + Binary::TypeCodes::EXTSELECTCLIPRGN => :ExtSelectClipRgn, + Binary::TypeCodes::FILLRGN => :FillRgn, + Binary::TypeCodes::FRAMERGN => :FrameRgn, + Binary::TypeCodes::INVERTRGN => :InvertRgn, + Binary::TypeCodes::PAINTRGN => :PaintRgn, + Binary::TypeCodes::BITBLT => :BitBlt, + Binary::TypeCodes::STRETCHBLT => :StretchBlt, + Binary::TypeCodes::MASKBLT => :MaskBlt, + Binary::TypeCodes::PLGBLT => :PlgBlt, + Binary::TypeCodes::SETDIBITSTODEVICE => :SetDIBitsToDevice, + Binary::TypeCodes::ALPHABLEND => :AlphaBlend, + Binary::TypeCodes::SETLAYOUT => :SetLayout, + Binary::TypeCodes::TRANSPARENTBLT => :TransparentBlt, + Binary::TypeCodes::GRADIENTFILL => :GradientFill, + Binary::TypeCodes::SETICMMODE => :SetIcmMode, + Binary::TypeCodes::CREATEMONOBRUSH => :CreateMonoBrush, + Binary::TypeCodes::CREATEDIBPATTERNBRUSHPT => :CreateDibPatternBrushPt, + Binary::TypeCodes::SMALLTEXTOUT => :SmallTextOut, + Binary::TypeCodes::DRAWESCAPE => :DrawEscape, + Binary::TypeCodes::EXTESCAPE => :ExtEscape, + Binary::TypeCodes::NAMEDESCAPE => :NamedEscape, + Binary::TypeCodes::COLORCORRECTPALETTE => :ColorCorrectPalette, + Binary::TypeCodes::SETICMPROFILEA => :SetIcmProfileA, + Binary::TypeCodes::SETICMPROFILEW => :SetIcmProfileW, + Binary::TypeCodes::FORCEUFIMAPPING => :ForceUfiMapping, + Binary::TypeCodes::SETLINKEDUFIS => :SetLinkedUfis, + Binary::TypeCodes::COLORMATCHTOTARGETW => :ColorMatchToTargetW, + Binary::TypeCodes::CREATECOLORSPACEW => :CreateColorspaceW, + Binary::TypeCodes::CREATECOLORSPACE => :CreateColorspace, + Binary::TypeCodes::SETCOLORSPACE => :SetColorspace, + Binary::TypeCodes::DELETECOLORSPACE => :DeleteColorspace, + Binary::TypeCodes::GLSRECORD => :GlsRecord, + Binary::TypeCodes::GLSBOUNDEDRECORD => :GlsBoundedRecord, + Binary::TypeCodes::POLYTEXTOUTA => :PolyTextOutA, + Binary::TypeCodes::POLYTEXTOUTW => :PolyTextOutW, + Binary::TypeCodes::PIXELFORMAT => :PixelFormat, + Binary::TypeCodes::SETTEXTJUSTIFICATION => :SetTextJustification }.freeze def self.lookup(type_id) diff --git a/lib/emf/emr/binary/records/alpha_blend.rb b/lib/emf/emr/binary/records/alpha_blend.rb new file mode 100644 index 0000000..13ccf59 --- /dev/null +++ b/lib/emf/emr/binary/records/alpha_blend.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require "bindata" +require "emf/emr/binary/with_bounds" + +module Emf + module Emr + module Binary + module Records + # Stub: leading fields per MS-EMF, body captures remaining bytes. + class AlphaBlend < Emf::Emr::Binary::WithBounds + rest :body + end + end + end + end +end diff --git a/lib/emf/emr/binary/records/angle_arc.rb b/lib/emf/emr/binary/records/angle_arc.rb new file mode 100644 index 0000000..c2cf1b1 --- /dev/null +++ b/lib/emf/emr/binary/records/angle_arc.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require "bindata" +require "emf/emr/binary/record" + +module Emf + module Emr + module Binary + module Records + class AngleArc < Emf::Emr::Binary::Record + point_l :ptl_center + uint32 :n_radius + float :start_angle + float :sweep_angle + end + end + end + end +end diff --git a/lib/emf/emr/binary/records/arc.rb b/lib/emf/emr/binary/records/arc.rb index 3520bfe..1ec1a9b 100644 --- a/lib/emf/emr/binary/records/arc.rb +++ b/lib/emf/emr/binary/records/arc.rb @@ -1,16 +1,17 @@ # frozen_string_literal: true require "bindata" -require "emf/emr/binary/with_bounds" +require "emf/emr/binary/record" module Emf module Emr module Binary module Records - class Arc < Emf::Emr::Binary::WithBounds + # EMR_ARC: emr + rclBox + ptlStart + ptlEnd (no rclBounds). + class Arc < Emf::Emr::Binary::Record rectl :rcl_box - point_l :pt_start - point_l :pt_end + point_l :ptl_start + point_l :ptl_end end end end diff --git a/lib/emf/emr/binary/records/arc_to.rb b/lib/emf/emr/binary/records/arc_to.rb index f2a76d2..cd3c1a2 100644 --- a/lib/emf/emr/binary/records/arc_to.rb +++ b/lib/emf/emr/binary/records/arc_to.rb @@ -1,16 +1,17 @@ # frozen_string_literal: true require "bindata" -require "emf/emr/binary/with_bounds" +require "emf/emr/binary/record" module Emf module Emr module Binary module Records - class ArcTo < Emf::Emr::Binary::WithBounds + # EMR_ARC_TO: emr + rclBox + ptlStart + ptlEnd (no rclBounds). + class ArcTo < Emf::Emr::Binary::Record rectl :rcl_box - point_l :pt_start - point_l :pt_end + point_l :ptl_start + point_l :ptl_end end end end diff --git a/lib/emf/emr/binary/records/bit_blt.rb b/lib/emf/emr/binary/records/bit_blt.rb new file mode 100644 index 0000000..3c5cc44 --- /dev/null +++ b/lib/emf/emr/binary/records/bit_blt.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require "bindata" +require "emf/emr/binary/with_bounds" + +module Emf + module Emr + module Binary + module Records + # Stub: leading fields per MS-EMF, body captures remaining bytes. + class BitBlt < Emf::Emr::Binary::WithBounds + rest :body + end + end + end + end +end diff --git a/lib/emf/emr/binary/records/chord.rb b/lib/emf/emr/binary/records/chord.rb index e9151b3..d012a0e 100644 --- a/lib/emf/emr/binary/records/chord.rb +++ b/lib/emf/emr/binary/records/chord.rb @@ -1,16 +1,17 @@ # frozen_string_literal: true require "bindata" -require "emf/emr/binary/with_bounds" +require "emf/emr/binary/record" module Emf module Emr module Binary module Records - class Chord < Emf::Emr::Binary::WithBounds + # EMR_CHORD: emr + rclBox + ptlStart + ptlEnd (no rclBounds). + class Chord < Emf::Emr::Binary::Record rectl :rcl_box - point_l :pt_start - point_l :pt_end + point_l :ptl_start + point_l :ptl_end end end end diff --git a/lib/emf/emr/binary/records/color_correct_palette.rb b/lib/emf/emr/binary/records/color_correct_palette.rb new file mode 100644 index 0000000..f84921a --- /dev/null +++ b/lib/emf/emr/binary/records/color_correct_palette.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require "bindata" +require "emf/emr/binary/record" + +module Emf + module Emr + module Binary + module Records + # Stub: preserves full body bytes. Full layout lands with TODO 10. + class ColorCorrectPalette < Emf::Emr::Binary::Record + rest :body + end + end + end + end +end diff --git a/lib/emf/emr/binary/records/colormatch_to_target_w.rb b/lib/emf/emr/binary/records/colormatch_to_target_w.rb new file mode 100644 index 0000000..d1336ca --- /dev/null +++ b/lib/emf/emr/binary/records/colormatch_to_target_w.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require "bindata" +require "emf/emr/binary/record" + +module Emf + module Emr + module Binary + module Records + # Stub: preserves full body bytes. Full layout lands with TODO 10. + class ColormatchToTargetW < Emf::Emr::Binary::Record + rest :body + end + end + end + end +end diff --git a/lib/emf/emr/binary/records/create_colorspace.rb b/lib/emf/emr/binary/records/create_colorspace.rb new file mode 100644 index 0000000..45dcdc8 --- /dev/null +++ b/lib/emf/emr/binary/records/create_colorspace.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require "bindata" +require "emf/emr/binary/record" + +module Emf + module Emr + module Binary + module Records + # Stub: preserves full body bytes. Full layout lands with TODO 10. + class CreateColorspace < Emf::Emr::Binary::Record + rest :body + end + end + end + end +end diff --git a/lib/emf/emr/binary/records/create_colorspace_w.rb b/lib/emf/emr/binary/records/create_colorspace_w.rb new file mode 100644 index 0000000..032a941 --- /dev/null +++ b/lib/emf/emr/binary/records/create_colorspace_w.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require "bindata" +require "emf/emr/binary/record" + +module Emf + module Emr + module Binary + module Records + # Stub: preserves full body bytes. Full layout lands with TODO 10. + class CreateColorspaceW < Emf::Emr::Binary::Record + rest :body + end + end + end + end +end diff --git a/lib/emf/emr/binary/records/create_dib_pattern_brush_pt.rb b/lib/emf/emr/binary/records/create_dib_pattern_brush_pt.rb new file mode 100644 index 0000000..e6ef244 --- /dev/null +++ b/lib/emf/emr/binary/records/create_dib_pattern_brush_pt.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require "bindata" +require "emf/emr/binary/record" + +module Emf + module Emr + module Binary + module Records + class CreateDibPatternBrushPt < Emf::Emr::Binary::Record + uint32 :ih_object + rest :body + end + end + end + end +end diff --git a/lib/emf/emr/binary/records/create_mono_brush.rb b/lib/emf/emr/binary/records/create_mono_brush.rb new file mode 100644 index 0000000..303e6f1 --- /dev/null +++ b/lib/emf/emr/binary/records/create_mono_brush.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require "bindata" +require "emf/emr/binary/record" + +module Emf + module Emr + module Binary + module Records + class CreateMonoBrush < Emf::Emr::Binary::Record + uint32 :ih_object + rest :body + end + end + end + end +end diff --git a/lib/emf/emr/binary/records/create_palette.rb b/lib/emf/emr/binary/records/create_palette.rb new file mode 100644 index 0000000..e429dee --- /dev/null +++ b/lib/emf/emr/binary/records/create_palette.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require "bindata" +require "emf/emr/binary/record" + +module Emf + module Emr + module Binary + module Records + class CreatePalette < Emf::Emr::Binary::Record + uint32 :ih_pal + rest :body + end + end + end + end +end diff --git a/lib/emf/emr/binary/records/delete_colorspace.rb b/lib/emf/emr/binary/records/delete_colorspace.rb new file mode 100644 index 0000000..5963c6f --- /dev/null +++ b/lib/emf/emr/binary/records/delete_colorspace.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require "bindata" +require "emf/emr/binary/record" + +module Emf + module Emr + module Binary + module Records + # Stub: preserves full body bytes. Full layout lands with TODO 10. + class DeleteColorspace < Emf::Emr::Binary::Record + rest :body + end + end + end + end +end diff --git a/lib/emf/emr/binary/records/draw_escape.rb b/lib/emf/emr/binary/records/draw_escape.rb new file mode 100644 index 0000000..ef6e6aa --- /dev/null +++ b/lib/emf/emr/binary/records/draw_escape.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require "bindata" +require "emf/emr/binary/record" + +module Emf + module Emr + module Binary + module Records + # Stub: preserves full body bytes. Full layout lands with TODO 10. + class DrawEscape < Emf::Emr::Binary::Record + rest :body + end + end + end + end +end diff --git a/lib/emf/emr/binary/records/exclude_clip_rect.rb b/lib/emf/emr/binary/records/exclude_clip_rect.rb new file mode 100644 index 0000000..6ce9cf3 --- /dev/null +++ b/lib/emf/emr/binary/records/exclude_clip_rect.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +require "bindata" +require "emf/emr/binary/record" + +module Emf + module Emr + module Binary + module Records + class ExcludeClipRect < Emf::Emr::Binary::Record + rectl :rcl_clip + end + end + end + end +end diff --git a/lib/emf/emr/binary/records/ext_escape.rb b/lib/emf/emr/binary/records/ext_escape.rb new file mode 100644 index 0000000..f67466f --- /dev/null +++ b/lib/emf/emr/binary/records/ext_escape.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require "bindata" +require "emf/emr/binary/record" + +module Emf + module Emr + module Binary + module Records + # Stub: preserves full body bytes. Full layout lands with TODO 10. + class ExtEscape < Emf::Emr::Binary::Record + rest :body + end + end + end + end +end diff --git a/lib/emf/emr/binary/records/ext_flood_fill.rb b/lib/emf/emr/binary/records/ext_flood_fill.rb new file mode 100644 index 0000000..4c15385 --- /dev/null +++ b/lib/emf/emr/binary/records/ext_flood_fill.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require "bindata" +require "emf/emr/binary/record" + +module Emf + module Emr + module Binary + module Records + # Stub: preserves full body bytes. Full layout lands with TODO 10. + class ExtFloodFill < Emf::Emr::Binary::Record + rest :body + end + end + end + end +end diff --git a/lib/emf/emr/binary/records/ext_select_clip_rgn.rb b/lib/emf/emr/binary/records/ext_select_clip_rgn.rb new file mode 100644 index 0000000..94edcc1 --- /dev/null +++ b/lib/emf/emr/binary/records/ext_select_clip_rgn.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require "bindata" +require "emf/emr/binary/record" + +module Emf + module Emr + module Binary + module Records + # EMR_EXTSELECTCLIPRGN: emr + cbRgnData + dwMode + RgnData[cbRgnData]. + class ExtSelectClipRgn < Emf::Emr::Binary::Record + uint32 :cb_rgn_data + uint32 :dw_mode + rest :rgn_data + end + end + end + end +end diff --git a/lib/emf/emr/binary/records/fill_path.rb b/lib/emf/emr/binary/records/fill_path.rb index b303114..a171b5b 100644 --- a/lib/emf/emr/binary/records/fill_path.rb +++ b/lib/emf/emr/binary/records/fill_path.rb @@ -1,14 +1,14 @@ # frozen_string_literal: true require "bindata" -require "emf/emr/binary/record" +require "emf/emr/binary/with_bounds" module Emf module Emr module Binary module Records - # Control record with no payload (just iType and nSize). - class FillPath < Emf::Emr::Binary::Record + # EMR_FILL_PATH: emr + rclBounds per MS-EMF 2.3.5.9/2.3.5.38/2.3.5.39. + class FillPath < Emf::Emr::Binary::WithBounds end end end diff --git a/lib/emf/emr/binary/records/fill_rgn.rb b/lib/emf/emr/binary/records/fill_rgn.rb new file mode 100644 index 0000000..8f34250 --- /dev/null +++ b/lib/emf/emr/binary/records/fill_rgn.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require "bindata" +require "emf/emr/binary/record" + +module Emf + module Emr + module Binary + module Records + class FillRgn < Emf::Emr::Binary::Record + uint32 :ih_brush + rest :body + end + end + end + end +end diff --git a/lib/emf/emr/binary/records/force_ufi_mapping.rb b/lib/emf/emr/binary/records/force_ufi_mapping.rb new file mode 100644 index 0000000..7f5d2f6 --- /dev/null +++ b/lib/emf/emr/binary/records/force_ufi_mapping.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require "bindata" +require "emf/emr/binary/record" + +module Emf + module Emr + module Binary + module Records + # Stub: preserves full body bytes. Full layout lands with TODO 10. + class ForceUfiMapping < Emf::Emr::Binary::Record + rest :body + end + end + end + end +end diff --git a/lib/emf/emr/binary/records/frame_rgn.rb b/lib/emf/emr/binary/records/frame_rgn.rb new file mode 100644 index 0000000..d83d167 --- /dev/null +++ b/lib/emf/emr/binary/records/frame_rgn.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require "bindata" +require "emf/emr/binary/record" + +module Emf + module Emr + module Binary + module Records + class FrameRgn < Emf::Emr::Binary::Record + uint32 :ih_brush + rest :body + end + end + end + end +end diff --git a/lib/emf/emr/binary/records/gls_bounded_record.rb b/lib/emf/emr/binary/records/gls_bounded_record.rb new file mode 100644 index 0000000..3dd124b --- /dev/null +++ b/lib/emf/emr/binary/records/gls_bounded_record.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require "bindata" +require "emf/emr/binary/record" + +module Emf + module Emr + module Binary + module Records + # Stub: preserves full body bytes. Full layout lands with TODO 10. + class GlsBoundedRecord < Emf::Emr::Binary::Record + rest :body + end + end + end + end +end diff --git a/lib/emf/emr/binary/records/gls_record.rb b/lib/emf/emr/binary/records/gls_record.rb new file mode 100644 index 0000000..c2972c2 --- /dev/null +++ b/lib/emf/emr/binary/records/gls_record.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require "bindata" +require "emf/emr/binary/record" + +module Emf + module Emr + module Binary + module Records + # Stub: preserves full body bytes. Full layout lands with TODO 10. + class GlsRecord < Emf::Emr::Binary::Record + rest :body + end + end + end + end +end diff --git a/lib/emf/emr/binary/records/gradient_fill.rb b/lib/emf/emr/binary/records/gradient_fill.rb new file mode 100644 index 0000000..e6f08d0 --- /dev/null +++ b/lib/emf/emr/binary/records/gradient_fill.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require "bindata" +require "emf/emr/binary/with_bounds" + +module Emf + module Emr + module Binary + module Records + class GradientFill < Emf::Emr::Binary::WithBounds + uint32 :n_ver + uint32 :n_tri + uint32 :ul_mode + rest :body + end + end + end + end +end diff --git a/lib/emf/emr/binary/records/intersect_clip_rect.rb b/lib/emf/emr/binary/records/intersect_clip_rect.rb new file mode 100644 index 0000000..5024422 --- /dev/null +++ b/lib/emf/emr/binary/records/intersect_clip_rect.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +require "bindata" +require "emf/emr/binary/record" + +module Emf + module Emr + module Binary + module Records + class IntersectClipRect < Emf::Emr::Binary::Record + rectl :rcl_clip + end + end + end + end +end diff --git a/lib/emf/emr/binary/records/invert_rgn.rb b/lib/emf/emr/binary/records/invert_rgn.rb new file mode 100644 index 0000000..a2b4ee9 --- /dev/null +++ b/lib/emf/emr/binary/records/invert_rgn.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require "bindata" +require "emf/emr/binary/record" + +module Emf + module Emr + module Binary + module Records + class InvertRgn < Emf::Emr::Binary::Record + uint32 :ih_brush + rest :body + end + end + end + end +end diff --git a/lib/emf/emr/binary/records/mask_blt.rb b/lib/emf/emr/binary/records/mask_blt.rb new file mode 100644 index 0000000..a9707a4 --- /dev/null +++ b/lib/emf/emr/binary/records/mask_blt.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require "bindata" +require "emf/emr/binary/with_bounds" + +module Emf + module Emr + module Binary + module Records + # Stub: leading fields per MS-EMF, body captures remaining bytes. + class MaskBlt < Emf::Emr::Binary::WithBounds + rest :body + end + end + end + end +end diff --git a/lib/emf/emr/binary/records/named_escape.rb b/lib/emf/emr/binary/records/named_escape.rb new file mode 100644 index 0000000..f7c6ce4 --- /dev/null +++ b/lib/emf/emr/binary/records/named_escape.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require "bindata" +require "emf/emr/binary/record" + +module Emf + module Emr + module Binary + module Records + # Stub: preserves full body bytes. Full layout lands with TODO 10. + class NamedEscape < Emf::Emr::Binary::Record + rest :body + end + end + end + end +end diff --git a/lib/emf/emr/binary/records/offset_clip_rgn.rb b/lib/emf/emr/binary/records/offset_clip_rgn.rb new file mode 100644 index 0000000..029433c --- /dev/null +++ b/lib/emf/emr/binary/records/offset_clip_rgn.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +require "bindata" +require "emf/emr/binary/record" + +module Emf + module Emr + module Binary + module Records + class OffsetClipRgn < Emf::Emr::Binary::Record + point_l :ptl_offset + end + end + end + end +end diff --git a/lib/emf/emr/binary/records/paint_rgn.rb b/lib/emf/emr/binary/records/paint_rgn.rb new file mode 100644 index 0000000..2a11ea9 --- /dev/null +++ b/lib/emf/emr/binary/records/paint_rgn.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require "bindata" +require "emf/emr/binary/record" + +module Emf + module Emr + module Binary + module Records + class PaintRgn < Emf::Emr::Binary::Record + uint32 :ih_brush + rest :body + end + end + end + end +end diff --git a/lib/emf/emr/binary/records/pie.rb b/lib/emf/emr/binary/records/pie.rb index 836bc22..a9019dc 100644 --- a/lib/emf/emr/binary/records/pie.rb +++ b/lib/emf/emr/binary/records/pie.rb @@ -1,16 +1,17 @@ # frozen_string_literal: true require "bindata" -require "emf/emr/binary/with_bounds" +require "emf/emr/binary/record" module Emf module Emr module Binary module Records - class Pie < Emf::Emr::Binary::WithBounds + # EMR_PIE: emr + rclBox + ptlStart + ptlEnd (no rclBounds). + class Pie < Emf::Emr::Binary::Record rectl :rcl_box - point_l :pt_start - point_l :pt_end + point_l :ptl_start + point_l :ptl_end end end end diff --git a/lib/emf/emr/binary/records/pixel_format.rb b/lib/emf/emr/binary/records/pixel_format.rb new file mode 100644 index 0000000..57b280f --- /dev/null +++ b/lib/emf/emr/binary/records/pixel_format.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require "bindata" +require "emf/emr/binary/record" + +module Emf + module Emr + module Binary + module Records + # EMR_PIXELFORMAT: header + body. Stub preserves bytes verbatim. + class PixelFormat < Emf::Emr::Binary::Record + rest :body + end + end + end + end +end diff --git a/lib/emf/emr/binary/records/plg_blt.rb b/lib/emf/emr/binary/records/plg_blt.rb new file mode 100644 index 0000000..9e7b89b --- /dev/null +++ b/lib/emf/emr/binary/records/plg_blt.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require "bindata" +require "emf/emr/binary/with_bounds" + +module Emf + module Emr + module Binary + module Records + # Stub: leading fields per MS-EMF, body captures remaining bytes. + class PlgBlt < Emf::Emr::Binary::WithBounds + rest :body + end + end + end + end +end diff --git a/lib/emf/emr/binary/records/poly_draw.rb b/lib/emf/emr/binary/records/poly_draw.rb new file mode 100644 index 0000000..f129bb2 --- /dev/null +++ b/lib/emf/emr/binary/records/poly_draw.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require "bindata" +require "emf/emr/binary/with_bounds" + +module Emf + module Emr + module Binary + module Records + class PolyDraw < Emf::Emr::Binary::WithBounds + uint32 :cptl + array :aptl, type: :point_l, initial_length: :cptl + array :ab_types, type: :uint8, initial_length: :cptl + end + end + end + end +end diff --git a/lib/emf/emr/binary/records/poly_draw16.rb b/lib/emf/emr/binary/records/poly_draw16.rb new file mode 100644 index 0000000..06c9988 --- /dev/null +++ b/lib/emf/emr/binary/records/poly_draw16.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require "bindata" +require "emf/emr/binary/with_bounds" + +module Emf + module Emr + module Binary + module Records + class PolyDraw16 < Emf::Emr::Binary::WithBounds + uint32 :cpts + array :apts, type: :point_s, initial_length: :cpts + array :ab_types, type: :uint8, initial_length: :cpts + end + end + end + end +end diff --git a/lib/emf/emr/binary/records/poly_textout_a.rb b/lib/emf/emr/binary/records/poly_textout_a.rb new file mode 100644 index 0000000..1cf606b --- /dev/null +++ b/lib/emf/emr/binary/records/poly_textout_a.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require "bindata" +require "emf/emr/binary/record" + +module Emf + module Emr + module Binary + module Records + # Stub: preserves full body bytes. Full layout lands with TODO 10. + class PolyTextoutA < Emf::Emr::Binary::Record + rest :body + end + end + end + end +end diff --git a/lib/emf/emr/binary/records/poly_textout_w.rb b/lib/emf/emr/binary/records/poly_textout_w.rb new file mode 100644 index 0000000..af64efc --- /dev/null +++ b/lib/emf/emr/binary/records/poly_textout_w.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require "bindata" +require "emf/emr/binary/record" + +module Emf + module Emr + module Binary + module Records + # Stub: preserves full body bytes. Full layout lands with TODO 10. + class PolyTextoutW < Emf::Emr::Binary::Record + rest :body + end + end + end + end +end diff --git a/lib/emf/emr/binary/records/realize_palette.rb b/lib/emf/emr/binary/records/realize_palette.rb new file mode 100644 index 0000000..de9f581 --- /dev/null +++ b/lib/emf/emr/binary/records/realize_palette.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require "bindata" +require "emf/emr/binary/record" + +module Emf + module Emr + module Binary + module Records + class RealizePalette < Emf::Emr::Binary::Record + end + end + end + end +end diff --git a/lib/emf/emr/binary/records/resize_palette.rb b/lib/emf/emr/binary/records/resize_palette.rb new file mode 100644 index 0000000..14b716b --- /dev/null +++ b/lib/emf/emr/binary/records/resize_palette.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require "bindata" +require "emf/emr/binary/record" + +module Emf + module Emr + module Binary + module Records + # Stub: preserves full body bytes. Full layout lands with TODO 10. + class ResizePalette < Emf::Emr::Binary::Record + rest :body + end + end + end + end +end diff --git a/lib/emf/emr/binary/records/scale_viewport_ext_ex.rb b/lib/emf/emr/binary/records/scale_viewport_ext_ex.rb new file mode 100644 index 0000000..82e4195 --- /dev/null +++ b/lib/emf/emr/binary/records/scale_viewport_ext_ex.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require "bindata" +require "emf/emr/binary/record" + +module Emf + module Emr + module Binary + module Records + class ScaleViewportExtEx < Emf::Emr::Binary::Record + int32 :x_num + int32 :x_denom + int32 :y_num + int32 :y_denom + end + end + end + end +end diff --git a/lib/emf/emr/binary/records/scale_window_ext_ex.rb b/lib/emf/emr/binary/records/scale_window_ext_ex.rb new file mode 100644 index 0000000..3e5609d --- /dev/null +++ b/lib/emf/emr/binary/records/scale_window_ext_ex.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require "bindata" +require "emf/emr/binary/record" + +module Emf + module Emr + module Binary + module Records + class ScaleWindowExtEx < Emf::Emr::Binary::Record + int32 :x_num + int32 :x_denom + int32 :y_num + int32 :y_denom + end + end + end + end +end diff --git a/lib/emf/emr/binary/records/select_clip_path.rb b/lib/emf/emr/binary/records/select_clip_path.rb index c449199..afc7195 100644 --- a/lib/emf/emr/binary/records/select_clip_path.rb +++ b/lib/emf/emr/binary/records/select_clip_path.rb @@ -1,14 +1,15 @@ # frozen_string_literal: true require "bindata" -require "emf/emr/binary/record" +require "emf/emr/binary/with_bounds" module Emf module Emr module Binary module Records - # Control record with no payload (just iType and nSize). - class SelectClipPath < Emf::Emr::Binary::Record + # EMR_SELECTCLIPPATH: emr + rclBounds + RegionMode per MS-EMF 2.3.2.5. + class SelectClipPath < Emf::Emr::Binary::WithBounds + uint32 :region_mode end end end diff --git a/lib/emf/emr/binary/records/select_palette.rb b/lib/emf/emr/binary/records/select_palette.rb new file mode 100644 index 0000000..43c70bd --- /dev/null +++ b/lib/emf/emr/binary/records/select_palette.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +require "bindata" +require "emf/emr/binary/record" + +module Emf + module Emr + module Binary + module Records + class SelectPalette < Emf::Emr::Binary::Record + uint32 :ih_pal + end + end + end + end +end diff --git a/lib/emf/emr/binary/records/set_arc_direction.rb b/lib/emf/emr/binary/records/set_arc_direction.rb new file mode 100644 index 0000000..8e61207 --- /dev/null +++ b/lib/emf/emr/binary/records/set_arc_direction.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +require "bindata" +require "emf/emr/binary/record" + +module Emf + module Emr + module Binary + module Records + class SetArcDirection < Emf::Emr::Binary::Record + uint32 :arc_direction + end + end + end + end +end diff --git a/lib/emf/emr/binary/records/set_brush_org_ex.rb b/lib/emf/emr/binary/records/set_brush_org_ex.rb new file mode 100644 index 0000000..92820cd --- /dev/null +++ b/lib/emf/emr/binary/records/set_brush_org_ex.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +require "bindata" +require "emf/emr/binary/record" + +module Emf + module Emr + module Binary + module Records + class SetBrushOrgEx < Emf::Emr::Binary::Record + point_l :ptl_origin + end + end + end + end +end diff --git a/lib/emf/emr/binary/records/set_colorspace.rb b/lib/emf/emr/binary/records/set_colorspace.rb new file mode 100644 index 0000000..5d30536 --- /dev/null +++ b/lib/emf/emr/binary/records/set_colorspace.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require "bindata" +require "emf/emr/binary/record" + +module Emf + module Emr + module Binary + module Records + # Stub: preserves full body bytes. Full layout lands with TODO 10. + class SetColorspace < Emf::Emr::Binary::Record + rest :body + end + end + end + end +end diff --git a/lib/emf/emr/binary/records/set_dibits_to_device.rb b/lib/emf/emr/binary/records/set_dibits_to_device.rb new file mode 100644 index 0000000..13a39ec --- /dev/null +++ b/lib/emf/emr/binary/records/set_dibits_to_device.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require "bindata" +require "emf/emr/binary/with_bounds" + +module Emf + module Emr + module Binary + module Records + # Stub: leading fields per MS-EMF, body captures remaining bytes. + class SetDibitsToDevice < Emf::Emr::Binary::WithBounds + rest :body + end + end + end + end +end diff --git a/lib/emf/emr/binary/records/set_icm_mode.rb b/lib/emf/emr/binary/records/set_icm_mode.rb new file mode 100644 index 0000000..c13ae98 --- /dev/null +++ b/lib/emf/emr/binary/records/set_icm_mode.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +require "bindata" +require "emf/emr/binary/record" + +module Emf + module Emr + module Binary + module Records + class SetIcmMode < Emf::Emr::Binary::Record + uint32 :mode + end + end + end + end +end diff --git a/lib/emf/emr/binary/records/set_icm_profile_a.rb b/lib/emf/emr/binary/records/set_icm_profile_a.rb new file mode 100644 index 0000000..e13f34e --- /dev/null +++ b/lib/emf/emr/binary/records/set_icm_profile_a.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require "bindata" +require "emf/emr/binary/record" + +module Emf + module Emr + module Binary + module Records + # Stub: preserves full body bytes. Full layout lands with TODO 10. + class SetIcmProfileA < Emf::Emr::Binary::Record + rest :body + end + end + end + end +end diff --git a/lib/emf/emr/binary/records/set_icm_profile_w.rb b/lib/emf/emr/binary/records/set_icm_profile_w.rb new file mode 100644 index 0000000..002f33a --- /dev/null +++ b/lib/emf/emr/binary/records/set_icm_profile_w.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require "bindata" +require "emf/emr/binary/record" + +module Emf + module Emr + module Binary + module Records + # Stub: preserves full body bytes. Full layout lands with TODO 10. + class SetIcmProfileW < Emf::Emr::Binary::Record + rest :body + end + end + end + end +end diff --git a/lib/emf/emr/binary/records/set_layout.rb b/lib/emf/emr/binary/records/set_layout.rb new file mode 100644 index 0000000..1c40154 --- /dev/null +++ b/lib/emf/emr/binary/records/set_layout.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +require "bindata" +require "emf/emr/binary/record" + +module Emf + module Emr + module Binary + module Records + class SetLayout < Emf::Emr::Binary::Record + uint32 :mode + end + end + end + end +end diff --git a/lib/emf/emr/binary/records/set_linked_ufis.rb b/lib/emf/emr/binary/records/set_linked_ufis.rb new file mode 100644 index 0000000..daeb845 --- /dev/null +++ b/lib/emf/emr/binary/records/set_linked_ufis.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require "bindata" +require "emf/emr/binary/record" + +module Emf + module Emr + module Binary + module Records + # Stub: preserves full body bytes. Full layout lands with TODO 10. + class SetLinkedUfis < Emf::Emr::Binary::Record + rest :body + end + end + end + end +end diff --git a/lib/emf/emr/binary/records/set_mapper_flags.rb b/lib/emf/emr/binary/records/set_mapper_flags.rb new file mode 100644 index 0000000..81f3a86 --- /dev/null +++ b/lib/emf/emr/binary/records/set_mapper_flags.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +require "bindata" +require "emf/emr/binary/record" + +module Emf + module Emr + module Binary + module Records + class SetMapperFlags < Emf::Emr::Binary::Record + uint32 :flags + end + end + end + end +end diff --git a/lib/emf/emr/binary/records/set_metargn.rb b/lib/emf/emr/binary/records/set_metargn.rb new file mode 100644 index 0000000..01fb562 --- /dev/null +++ b/lib/emf/emr/binary/records/set_metargn.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +require "bindata" +require "emf/emr/binary/record" + +module Emf + module Emr + module Binary + module Records + # EMR_SETMETARGN: no payload, just the header. + class SetMetArgn < Emf::Emr::Binary::Record + end + end + end + end +end diff --git a/lib/emf/emr/binary/records/set_palette_entries.rb b/lib/emf/emr/binary/records/set_palette_entries.rb new file mode 100644 index 0000000..6e39d6d --- /dev/null +++ b/lib/emf/emr/binary/records/set_palette_entries.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require "bindata" +require "emf/emr/binary/record" + +module Emf + module Emr + module Binary + module Records + # Stub: preserves full body bytes. Full layout lands with TODO 10. + class SetPaletteEntries < Emf::Emr::Binary::Record + rest :body + end + end + end + end +end diff --git a/lib/emf/emr/binary/records/set_pixel_v.rb b/lib/emf/emr/binary/records/set_pixel_v.rb new file mode 100644 index 0000000..750bdfa --- /dev/null +++ b/lib/emf/emr/binary/records/set_pixel_v.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require "bindata" +require "emf/emr/binary/record" + +module Emf + module Emr + module Binary + module Records + # EMR_SETPIXELV: emr + ptlPixel (POINTL) + crColor (COLORREF). + class SetPixelV < Emf::Emr::Binary::Record + point_l :ptl_pixel + color_ref :cr_color + end + end + end + end +end diff --git a/lib/emf/emr/binary/records/set_text_justification.rb b/lib/emf/emr/binary/records/set_text_justification.rb new file mode 100644 index 0000000..ce1bbc5 --- /dev/null +++ b/lib/emf/emr/binary/records/set_text_justification.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require "bindata" +require "emf/emr/binary/record" + +module Emf + module Emr + module Binary + module Records + # Stub: preserves full body bytes. Full layout lands with TODO 10. + class SetTextJustification < Emf::Emr::Binary::Record + rest :body + end + end + end + end +end diff --git a/lib/emf/emr/binary/records/small_text_out.rb b/lib/emf/emr/binary/records/small_text_out.rb new file mode 100644 index 0000000..f9b35fc --- /dev/null +++ b/lib/emf/emr/binary/records/small_text_out.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require "bindata" +require "emf/emr/binary/record" + +module Emf + module Emr + module Binary + module Records + # Stub: preserves full body bytes. Full layout lands with TODO 10. + class SmallTextOut < Emf::Emr::Binary::Record + rest :body + end + end + end + end +end diff --git a/lib/emf/emr/binary/records/stretch_blt.rb b/lib/emf/emr/binary/records/stretch_blt.rb new file mode 100644 index 0000000..a2d35bd --- /dev/null +++ b/lib/emf/emr/binary/records/stretch_blt.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +require "bindata" +require "emf/emr/binary/with_bounds" + +module Emf + module Emr + module Binary + module Records + class StretchBlt < Emf::Emr::Binary::WithBounds + rest :body + end + end + end + end +end diff --git a/lib/emf/emr/binary/records/stroke_and_fill_path.rb b/lib/emf/emr/binary/records/stroke_and_fill_path.rb index 9abe96b..8f936f4 100644 --- a/lib/emf/emr/binary/records/stroke_and_fill_path.rb +++ b/lib/emf/emr/binary/records/stroke_and_fill_path.rb @@ -1,14 +1,14 @@ # frozen_string_literal: true require "bindata" -require "emf/emr/binary/record" +require "emf/emr/binary/with_bounds" module Emf module Emr module Binary module Records - # Control record with no payload (just iType and nSize). - class StrokeAndFillPath < Emf::Emr::Binary::Record + # EMR_STROKE_AND_FILL_PATH: emr + rclBounds per MS-EMF 2.3.5.9/2.3.5.38/2.3.5.39. + class StrokeAndFillPath < Emf::Emr::Binary::WithBounds end end end diff --git a/lib/emf/emr/binary/records/stroke_path.rb b/lib/emf/emr/binary/records/stroke_path.rb index c672720..de9b4bf 100644 --- a/lib/emf/emr/binary/records/stroke_path.rb +++ b/lib/emf/emr/binary/records/stroke_path.rb @@ -1,14 +1,14 @@ # frozen_string_literal: true require "bindata" -require "emf/emr/binary/record" +require "emf/emr/binary/with_bounds" module Emf module Emr module Binary module Records - # Control record with no payload (just iType and nSize). - class StrokePath < Emf::Emr::Binary::Record + # EMR_STROKE_PATH: emr + rclBounds per MS-EMF 2.3.5.9/2.3.5.38/2.3.5.39. + class StrokePath < Emf::Emr::Binary::WithBounds end end end diff --git a/lib/emf/emr/binary/records/transparent_blt.rb b/lib/emf/emr/binary/records/transparent_blt.rb new file mode 100644 index 0000000..9e31e74 --- /dev/null +++ b/lib/emf/emr/binary/records/transparent_blt.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require "bindata" +require "emf/emr/binary/with_bounds" + +module Emf + module Emr + module Binary + module Records + # Stub: leading fields per MS-EMF, body captures remaining bytes. + class TransparentBlt < Emf::Emr::Binary::WithBounds + rest :body + end + end + end + end +end diff --git a/lib/emf/emr/binary/type_codes.rb b/lib/emf/emr/binary/type_codes.rb index 4796fb3..34ba629 100644 --- a/lib/emf/emr/binary/type_codes.rb +++ b/lib/emf/emr/binary/type_codes.rb @@ -3,129 +3,129 @@ module Emf module Emr module Binary - # EMR record type codes (subset; full list per MS-EMF 2.3.1). - # Adding a new record type = adding one constant here, one entry in - # TYPE_TO_NAME in records.rb, and one wire class file in records/. + # EMR record type codes per MS-EMF 2.3.1. + # Adding a new record type: add the constant here, add an entry in + # TYPE_TO_NAME in records.rb, and add the wire class file under records/. module TypeCodes - HEADER = 1 - POLYBEZIER = 2 - POLYGON = 3 - POLYLINE = 4 - POLYBEZIERTO = 5 - POLYLINETO = 6 - POLYPOLYLINE = 7 - POLYPOLYGON = 8 - SETWINDOWEXTEX = 9 - SETWINDOWORGEX = 10 - SETVIEWPORTEXTEX = 11 - SETVIEWPORTORGEX = 12 - SETBRUSHORGEX = 13 - EOF = 14 - SETPIXELV = 15 - SETMAPPERFLAGS = 16 - SETMAPMODE = 17 - SETBKMODE = 18 - SETPOLYFILLMODE = 19 - SETROP2 = 20 - SETSTRETCHBLTMODE = 21 - SETTEXTALIGN = 22 - SETCOLORADJUSTMENT = 23 - SETTEXTCOLOR = 24 - SETBKCOLOR = 25 - OFFSETCLIPRGN = 26 - MOVETOEX = 27 - SETMETARGN = 28 - EXCLUDECLIPRECT = 29 - INTERSECTCLIPRECT = 30 - SCALEVIEWPORTEXTEX = 31 - SCALEWINDOWEXTEX = 32 - SAVEDC = 33 - RESTOREDC = 34 - SETWORLDTRANSFORM = 35 - MODIFYWORLDTRANSFORM = 36 - SELECTOBJECT = 37 - CREATEPEN = 38 - CREATEBRUSHINDIRECT = 39 - DELETEOBJECT = 40 - ANGLEARC = 41 - ELLIPSE = 42 - RECTANGLE = 43 - ROUNDRECT = 44 - ARC = 45 - CHORD = 46 - PIE = 47 - SELECTPALETTE = 48 - CREATEPALETTE = 49 - SETPALETTEENTRIES = 50 - RESIZEPALETTE = 51 - REALIZEPALETTE = 52 - EXTFLOODFILL = 53 - LINETO = 54 - ARCTO = 55 - POLYDRAW = 56 - SETARCDIRECTION = 57 - SETMITERLIMIT = 58 - BEGINPATH = 59 - ENDPATH = 60 - CLOSEFIGURE = 61 - FILLPATH = 62 - STROKEANDFILLPATH = 63 - STROKEPATH = 64 - FLATTENPATH = 65 - WIDENPATH = 66 - SELECTCLIPPATH = 67 - ABORTPATH = 68 - COMMENT = 70 - FILLRGN = 71 - FRAMERGN = 72 - INVERTRGN = 73 - PAINTRGN = 74 - EXTSELECTCLIPRGN = 75 - BITBLT = 76 - STRETCHBLT = 77 - MASKBLT = 78 - PLGBLT = 79 - SETDIBITSTODEVICE = 80 - STRETCHDIBITS = 81 + HEADER = 1 + POLYBEZIER = 2 + POLYGON = 3 + POLYLINE = 4 + POLYBEZIERTO = 5 + POLYLINETO = 6 + POLYPOLYLINE = 7 + POLYPOLYGON = 8 + SETWINDOWEXTEX = 9 + SETWINDOWORGEX = 10 + SETVIEWPORTEXTEX = 11 + SETVIEWPORTORGEX = 12 + SETBRUSHORGEX = 13 + EOF = 14 + SETPIXELV = 15 + SETMAPPERFLAGS = 16 + SETMAPMODE = 17 + SETBKMODE = 18 + SETPOLYFILLMODE = 19 + SETROP2 = 20 + SETSTRETCHBLTMODE = 21 + SETTEXTALIGN = 22 + SETCOLORADJUSTMENT = 23 + SETTEXTCOLOR = 24 + SETBKCOLOR = 25 + OFFSETCLIPRGN = 26 + MOVETOEX = 27 + SETMETARGN = 28 + EXCLUDECLIPRECT = 29 + INTERSECTCLIPRECT = 30 + SCALEVIEWPORTEXTEX = 31 + SCALEWINDOWEXTEX = 32 + SAVEDC = 33 + RESTOREDC = 34 + SETWORLDTRANSFORM = 35 + MODIFYWORLDTRANSFORM = 36 + SELECTOBJECT = 37 + CREATEPEN = 38 + CREATEBRUSHINDIRECT = 39 + DELETEOBJECT = 40 + ANGLEARC = 41 + ELLIPSE = 42 + RECTANGLE = 43 + ROUNDRECT = 44 + ARC = 45 + CHORD = 46 + PIE = 47 + SELECTPALETTE = 48 + CREATEPALETTE = 49 + SETPALETTEENTRIES = 50 + RESIZEPALETTE = 51 + REALIZEPALETTE = 52 + EXTFLOODFILL = 53 + LINETO = 54 + ARCTO = 55 + POLYDRAW = 56 + SETARCDIRECTION = 57 + SETMITERLIMIT = 58 + BEGINPATH = 59 + ENDPATH = 60 + CLOSEFIGURE = 61 + FILLPATH = 62 + STROKEANDFILLPATH = 63 + STROKEPATH = 64 + FLATTENPATH = 65 + WIDENPATH = 66 + SELECTCLIPPATH = 67 + ABORTPATH = 68 + COMMENT = 70 + FILLRGN = 71 + FRAMERGN = 72 + INVERTRGN = 73 + PAINTRGN = 74 + EXTSELECTCLIPRGN = 75 + BITBLT = 76 + STRETCHBLT = 77 + MASKBLT = 78 + PLGBLT = 79 + SETDIBITSTODEVICE = 80 + STRETCHDIBITS = 81 EXTCREATEFONTINDIRECTW = 82 - EXTTEXTOUTA = 83 - EXTTEXTOUTW = 84 - POLYBEZIER16 = 85 - POLYGON16 = 86 - POLYLINE16 = 87 - POLYBEZIERTO16 = 88 - POLYLINETO16 = 89 - POLYPOLYLINE16 = 90 - POLYPOLYGON16 = 91 - POLYDRAW16 = 92 - CREATEMONOBRUSH = 93 + EXTTEXTOUTA = 83 + EXTTEXTOUTW = 84 + POLYBEZIER16 = 85 + POLYGON16 = 86 + POLYLINE16 = 87 + POLYBEZIERTO16 = 88 + POLYLINETO16 = 89 + POLYPOLYLINE16 = 90 + POLYPOLYGON16 = 91 + POLYDRAW16 = 92 + CREATEMONOBRUSH = 93 CREATEDIBPATTERNBRUSHPT = 94 - EXTCREATEPEN = 95 - POLYTEXTOUTA = 96 - POLYTEXTOUTW = 97 - SETICMMODE = 98 - CREATECOLORSPACE = 99 - SETCOLORSPACE = 100 - DELETECOLORSPACE = 101 - GLSRECORD = 102 - GLSBOUNDEDRECORD = 103 - PIXELFORMAT = 104 - DRAWESCAPE = 105 - EXTESCAPE = 106 - SMALLTEXTOUT = 108 - FORCEUFIMAPPING = 109 - NAMEDESCAPE = 110 - COLORCORRECTPALETTE = 111 - SETICMPROFILEA = 112 - SETICMPROFILEW = 113 - ALPHABLEND = 114 - SETLAYOUT = 115 - TRANSPARENTBLT = 116 - GRADIENTFILL = 118 - SETLINKEDUFIS = 119 - SETTEXTJUSTIFICATION = 120 - COLORMATCHTOTARGETW = 121 - CREATECOLORSPACEW = 122 + EXTCREATEPEN = 95 + POLYTEXTOUTA = 96 + POLYTEXTOUTW = 97 + SETICMMODE = 98 + CREATECOLORSPACE = 99 + SETCOLORSPACE = 100 + DELETECOLORSPACE = 101 + GLSRECORD = 102 + GLSBOUNDEDRECORD = 103 + PIXELFORMAT = 104 + DRAWESCAPE = 105 + EXTESCAPE = 106 + SMALLTEXTOUT = 108 + FORCEUFIMAPPING = 109 + NAMEDESCAPE = 110 + COLORCORRECTPALETTE = 111 + SETICMPROFILEA = 112 + SETICMPROFILEW = 113 + ALPHABLEND = 114 + SETLAYOUT = 115 + TRANSPARENTBLT = 116 + GRADIENTFILL = 118 + SETLINKEDUFIS = 119 + SETTEXTJUSTIFICATION = 120 + COLORMATCHTOTARGETW = 121 + CREATECOLORSPACEW = 122 end end end diff --git a/lib/emf/emr/parser.rb b/lib/emf/emr/parser.rb index faa9904..e2617b3 100644 --- a/lib/emf/emr/parser.rb +++ b/lib/emf/emr/parser.rb @@ -17,6 +17,7 @@ def call(bytes) records = [] errors = [] emf_plus_bytes = +"" + trailing = +"" until io.eof? offset = io.pos @@ -26,7 +27,10 @@ def call(bytes) records << Model::Emr::Records::WireAdapter.new(wire: wire, offset: offset) emf_plus_bytes << extract_emf_plus(wire) if wire.is_a?(Emf::Emr::Binary::Records::Comment) - break if wire.is_a?(Emf::Emr::Binary::Records::Eof) + if wire.is_a?(Emf::Emr::Binary::Records::Eof) + trailing << io.read until io.eof? + break + end end Model::Metafile.new( @@ -34,7 +38,8 @@ def call(bytes) header: header, records: records, errors: errors, - emf_plus: emf_plus_bytes.empty? ? nil : emf_plus_bytes + emf_plus: emf_plus_bytes.empty? ? nil : emf_plus_bytes, + trailing: trailing ) end @@ -56,6 +61,43 @@ def read_header(io) Emf::Emr::Binary::Header.read(full) end + # Per-type byte-size validation for records with variable-length arrays. + # If the declared count exceeds what nSize can hold, the bytes are + # corrupt and we should preserve them as Raw rather than letting bindata + # allocate a giant array. The element size is bytes per array entry. + VARIABLE_ARRAY_RECORDS = { + Binary::TypeCodes::POLYGON => { count_offset: 24, element_size: 8 }, + Binary::TypeCodes::POLYLINE => { count_offset: 24, element_size: 8 }, + Binary::TypeCodes::POLYBEZIER => { count_offset: 24, element_size: 8 }, + Binary::TypeCodes::POLYBEZIERTO => { count_offset: 24, element_size: 8 }, + Binary::TypeCodes::POLYLINETO => { count_offset: 24, element_size: 8 }, + Binary::TypeCodes::POLYGON16 => { count_offset: 24, element_size: 4 }, + Binary::TypeCodes::POLYLINE16 => { count_offset: 24, element_size: 4 }, + Binary::TypeCodes::POLYBEZIER16 => { count_offset: 24, element_size: 4 }, + Binary::TypeCodes::POLYBEZIERTO16 => { count_offset: 24, element_size: 4 }, + Binary::TypeCodes::POLYLINETO16 => { count_offset: 24, element_size: 4 }, + Binary::TypeCodes::POLYDRAW => { count_offset: 24, element_size: 9 }, # 8 + 1 byte type + Binary::TypeCodes::POLYDRAW16 => { count_offset: 24, element_size: 5 } + }.freeze + + MAX_SANE_ARRAY_LENGTH = 1_000_000 + + def variable_array_sane?(type_id, n_size, head, payload) + spec = VARIABLE_ARRAY_RECORDS[type_id] + return true unless spec + + full = head + payload + return true if full.bytesize < spec[:count_offset] + 4 + + offset = spec[:count_offset] + count = full.getbyte(offset) | + (full.getbyte(offset + 1) << 8) | + (full.getbyte(offset + 2) << 16) | + (full.getbyte(offset + 3) << 24) + bytes_needed = spec[:count_offset] + 4 + (count * spec[:element_size]) + count <= MAX_SANE_ARRAY_LENGTH && bytes_needed <= n_size + end + def read_record(io, offset, errors) head = io.read(8) return nil if head.nil? || head.empty? @@ -74,6 +116,15 @@ def read_record(io, offset, errors) return nil end + unless variable_array_sane?(type_id, n_size, head, payload) + errors << ParseError.new( + offset: offset, record_code: type_id, + message: "variable-array record at offset #{offset} declares " \ + "more entries than nSize allows; preserving as Raw" + ) + return build_raw(head, payload) + end + wire_class = Emf::Emr::Binary::Records.lookup(type_id) wire_class.read(head + payload) rescue StandardError => e diff --git a/lib/emf/emr/serializer.rb b/lib/emf/emr/serializer.rb index 8806f4a..cfa2b33 100644 --- a/lib/emf/emr/serializer.rb +++ b/lib/emf/emr/serializer.rb @@ -16,6 +16,7 @@ def call(metafile) raise SerializeError, "cannot serialize #{record.class} (TODO 10 lands domain to_wire)" end end + bytes << metafile.trailing bytes end end diff --git a/lib/emf/model/metafile.rb b/lib/emf/model/metafile.rb index 2489400..91622e8 100644 --- a/lib/emf/model/metafile.rb +++ b/lib/emf/model/metafile.rb @@ -5,15 +5,16 @@ module Model class Metafile include Enumerable - def initialize(format:, header:, records:, errors: [], emf_plus: nil) + def initialize(format:, header:, records:, errors: [], emf_plus: nil, trailing: "") @format = format @header = header @records = records.freeze @errors = errors.freeze @emf_plus = emf_plus + @trailing = trailing.freeze end - attr_reader :format, :header, :records, :errors, :emf_plus + attr_reader :format, :header, :records, :errors, :emf_plus, :trailing def each(&block) return to_enum unless block diff --git a/lib/emf/visitors/dump.rb b/lib/emf/visitors/dump.rb index 9516b8e..7b880f8 100644 --- a/lib/emf/visitors/dump.rb +++ b/lib/emf/visitors/dump.rb @@ -2,6 +2,8 @@ module Emf module Visitors + # Human-readable record-by-record dump. Used by `emf dump` (TODO 19) + # and as a debugging tool. class Dump < Emf::Model::Visitor def initialize @lines = [] @@ -13,13 +15,38 @@ def to_s @lines.join("\n") end + def visit(record) + record.accept(self) + self + end + + def visit_all(metafile) + @lines << format("Metafile format=%s records=%d errors=%d", + metafile.format, metafile.records.length, metafile.errors.length) + header = metafile.header + @lines << " Header: #{header.class}" if header + metafile.each { |record| record.accept(self) } + metafile.errors.each { |e| @lines << " Error: #{e.message}" } + self + end + + def visit_emr_wire_record(adapter) + wire = adapter.wire + @lines << " rec[#{adapter.offset}] type_id=#{adapter.type_id} #{wire.class.name&.split('::')&.last}" + end + def method_missing(name, *args) return super unless name.to_s.start_with?("visit_") record = args.first return unless record - @lines << "#{name} #{record.inspect}" + type_id = begin + record.type_id + rescue StandardError + nil + end + @lines << format(" rec: %s type_id=%s", record.class.name&.split("::")&.last, type_id) end def respond_to_missing?(name, _include_private = false) diff --git a/lib/emf/visitors/stats.rb b/lib/emf/visitors/stats.rb index 456ad4c..e84bba0 100644 --- a/lib/emf/visitors/stats.rb +++ b/lib/emf/visitors/stats.rb @@ -2,6 +2,14 @@ module Emf module Visitors + # Produces a record-class histogram for a Metafile. Used by spec assertions + # (TODO 16) and as a quick sanity-check dump via `emf stats` (TODO 19). + # + # The base Visitor auto-registers `visit_*` methods as no-ops. Stats + # overrides the generic dispatch to tally every record by its class name. + # Subclasses that override specific visit_* methods for richer behaviour + # still get tallied here because the record's accept calls the visit_* + # method, which (if not overridden) calls method_missing -> tally. class Stats < Emf::Model::Visitor def initialize @counts = Hash.new(0) @@ -13,15 +21,38 @@ def histogram @counts.sort.to_h end + def total + @counts.values.sum + end + def to_s - histogram.map { |k, v| "#{v.to_s.rjust(6)} #{k}" }.join("\n") + lines = ["Total: #{total}", ""] + histogram.each { |klass, count| lines << format("%6d %s", count, klass) } + lines.join("\n") + end + + def visit(record) + record.accept(self) + self + end + + def visit_all(metafile) + metafile.each { |record| record.accept(self) } + self + end + + # WireAdapter is the catch-all for unknown record types. Tally by the + # underlying wire class so the histogram is informative. + def visit_emr_wire_record(adapter) + wire_class = adapter.wire.class.name&.split("::")&.last || "Raw" + @counts["WireAdapter(#{wire_class})"] += 1 end def method_missing(name, *args) return super unless name.to_s.start_with?("visit_") record = args.first - @counts[record.class.name] += 1 if record + @counts[record.class.name&.split("::")&.last || record.class.to_s] += 1 if record end def respond_to_missing?(name, _include_private = false) diff --git a/spec/emf/cli_spec.rb b/spec/emf/cli_spec.rb new file mode 100644 index 0000000..ee465c5 --- /dev/null +++ b/spec/emf/cli_spec.rb @@ -0,0 +1,103 @@ +# frozen_string_literal: true + +require "spec_helper" +require "open3" + +RSpec.describe "exe/emf CLI" do + let(:exe) { File.expand_path("../../exe/emf", __dir__) } + let(:fixture) { Emf::SpecSupport::FixturePath.fixture("simple", "image1.emf") } + let(:corrupted) do + Dir.glob(Emf::SpecSupport::FixturePath.fixture("emf-corrupted", "*.emf")).first + end + + def run(*args) + Open3.capture3("ruby", exe, *args) + end + + describe "version" do + it "prints the version" do + out, _, status = run("version") + expect(status.exitstatus).to eq(0) + expect(out).to match(/^emf \d+\.\d+\.\d+/) + end + + it "also responds to --version and -v" do + out1, = run("--version") + out2, = run("-v") + expect(out1).to eq(out2) + expect(out1).to match(/^emf \d/) + end + end + + describe "help" do + it "prints usage" do + out, _, status = run("help") + expect(status.exitstatus).to eq(0) + expect(out).to include("Usage:") + expect(out).to include("emf info FILE") + end + + it "is the default when no command given" do + out, = run + expect(out).to include("Usage:") + end + end + + describe "info" do + it "prints metafile summary" do + out, _, status = run("info", fixture) + expect(status.exitstatus).to eq(0) + expect(out).to include("Format:") + expect(out).to include("Records:") + expect(out).to match(/Bounds:/) + end + + it "exits 2 when FILE is missing" do + _, err, status = run("info") + expect(status.exitstatus).to eq(2) + expect(err).to match(/missing FILE/i) + end + end + + describe "validate" do + it "exits 0 on a clean fixture" do + out, _, status = run("validate", fixture) + expect(status.exitstatus).to eq(0) + expect(out).to match(/OK:/) + end + end + + describe "round-trip" do + it "confirms byte-identical round-trip" do + out, _, status = run("round-trip", fixture) + expect(status.exitstatus).to eq(0) + expect(out).to match(/byte-identical/i) + end + end + + describe "stats" do + it "produces a record-type histogram" do + out, _, status = run("stats", fixture) + expect(status.exitstatus).to eq(0) + expect(out).to include("Total:") + expect(out).to include("WireAdapter") + end + end + + describe "dump" do + it "produces a record-by-record dump" do + out, _, status = run("dump", fixture) + expect(status.exitstatus).to eq(0) + expect(out).to include("Metafile format=") + expect(out).to include("Header:") + end + end + + describe "unknown command" do + it "exits 1 with a helpful message" do + _, err, status = run("bogus-command") + expect(status.exitstatus).to eq(1) + expect(err).to match(/unknown command/i) + end + end +end diff --git a/spec/emf/corrupted_resilience_spec.rb b/spec/emf/corrupted_resilience_spec.rb new file mode 100644 index 0000000..6f0d2ca --- /dev/null +++ b/spec/emf/corrupted_resilience_spec.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +require "spec_helper" +require "timeout" + +# TODO.impl/17: corrupted EMF corpus must not crash or hang the parser. +# Each file either parses (with errors recorded on the Metafile) or raises +# Emf::FormatError for header-level corruption. +RSpec.describe "EMF corrupted-resilience" do + corrupted_dir = Emf::SpecSupport::FixturePath.fixture("emf-corrupted") + + Dir.glob(File.join(corrupted_dir, "*.emf")).each do |path| + it "does not crash or hang on #{File.basename(path)}" do + bytes = File.read(path, mode: "rb") + outcome = begin + Timeout.timeout(5) do + mf = Emf.parse(bytes) + mf.errors? || mf.records.empty? ? :parsed_with_errors : :parsed + rescue Emf::FormatError + :format_error + end + rescue StandardError + :timeout + end + + expect(outcome).not_to eq(:timeout), "parser hung on #{File.basename(path)}" + expect(%i[parsed parsed_with_errors format_error]).to include(outcome) + end + end + + it "handles empty input cleanly" do + expect { Emf.parse("".b) }.to raise_error(Emf::FormatError) + end + + it "handles 3-byte input cleanly" do + expect { Emf.parse("abc".b) }.to raise_error(Emf::FormatError) + end +end diff --git a/spec/emf/round_trip_spec.rb b/spec/emf/round_trip_spec.rb new file mode 100644 index 0000000..1f37d89 --- /dev/null +++ b/spec/emf/round_trip_spec.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +require "spec_helper" + +# TODO.impl/16: walk every non-corrupted fixture and assert byte-identical +# round-trip (parse -> serialize -> parse produces the same file). +RSpec.describe "EMF round-trip" do + def assert_byte_identical(path) + bytes = File.read(path, mode: "rb") + mf = Emf.parse(bytes) + reborn = Emf.serialize(mf) + expect(reborn).to eq(bytes), <<~MSG + Round-trip drift for #{File.basename(path)} + original: #{bytes.bytesize} bytes + reborn: #{reborn.bytesize} bytes + diff: #{(0...[bytes.bytesize, reborn.bytesize].min).find { |i| bytes.getbyte(i) != reborn.getbyte(i) } || 'end-of-shorter'} + MSG + end + + describe "spec/fixtures/emf/ (186 fixtures)" do + Dir.glob(Emf::SpecSupport::FixturePath.fixture("emf", "*.emf")).each do |path| + it "round-trips #{File.basename(path)}" do + assert_byte_identical(path) + end + end + end + + describe "spec/fixtures/emf-ea/ (21 fixtures)" do + Dir.glob(Emf::SpecSupport::FixturePath.fixture("emf-ea", "*.emf")).each do |path| + it "round-trips #{File.basename(path)}" do + assert_byte_identical(path) + end + end + end + + describe "spec/fixtures/simple/" do + it "round-trips image1.emf byte-identically" do + assert_byte_identical(Emf::SpecSupport::FixturePath.fixture("simple", "image1.emf")) + end + end +end diff --git a/spec/emf/visitors_spec.rb b/spec/emf/visitors_spec.rb new file mode 100644 index 0000000..3cc5672 --- /dev/null +++ b/spec/emf/visitors_spec.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +require "English" +require "spec_helper" + +RSpec.describe Emf::Visitors::Stats do + let(:fixture) { Emf::SpecSupport::FixturePath.read_fixture("simple", "image1.emf") } + let(:metafile) { Emf.parse(fixture) } + + it "produces a record-type histogram" do + stats = described_class.new.visit_all(metafile) + expect(stats.total).to eq(metafile.records.size) + expect(stats.histogram).not_to be_empty + end + + it "renders a readable string" do + stats = described_class.new.visit_all(metafile) + expect(stats.to_s).to include("Total:") + expect(stats.to_s).to include("WireAdapter") + end +end + +RSpec.describe Emf::Visitors::Dump do + let(:fixture) { Emf::SpecSupport::FixturePath.read_fixture("simple", "image1.emf") } + let(:metafile) { Emf.parse(fixture) } + + it "produces a record-by-record dump" do + dump = described_class.new.visit_all(metafile) + expect(dump.to_s).to include("Metafile format=") + expect(dump.to_s).to include("type_id=") + end + + it "includes errors when present" do + # Use a corrupted fixture to ensure errors are surfaced + path = Dir.glob(Emf::SpecSupport::FixturePath.fixture("emf-corrupted", "*.emf")).first + bytes = File.read(path, mode: "rb") + begin + mf = Emf.parse(bytes) + dump = described_class.new.visit_all(mf) + # Either the dump includes errors or the parse raised — both fine. + expect(dump.to_s).to include("Metafile").or include("Error:") + rescue Emf::FormatError + # Header-level corruption raises; that's the contract. + expect($ERROR_INFO).to be_a(Emf::FormatError) + end + end +end