claricle 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +10 -1
  3. data/LICENSE +25 -0
  4. data/README.adoc +439 -157
  5. data/TODO.plan/00-overview.md +502 -0
  6. data/TODO.plan/01-core.md +296 -0
  7. data/TODO.plan/02-inspect.md +163 -0
  8. data/TODO.plan/03-conform.md +208 -0
  9. data/TODO.plan/04-convert.md +216 -0
  10. data/exe/claricle +1 -1
  11. data/lib/claricle/batch.rb +240 -0
  12. data/lib/claricle/cli.rb +514 -40
  13. data/lib/claricle/conformance_options.rb +49 -0
  14. data/lib/claricle/conversion_engine.rb +21 -0
  15. data/lib/claricle/convert.rb +21 -0
  16. data/lib/claricle/detector.rb +696 -0
  17. data/lib/claricle/emf_header.rb +33 -0
  18. data/lib/claricle/errors.rb +73 -0
  19. data/lib/claricle/fault.rb +69 -0
  20. data/lib/claricle/handlers/base.rb +169 -0
  21. data/lib/claricle/handlers/extension_points.rb +124 -0
  22. data/lib/claricle/handlers/metafile.rb +1011 -0
  23. data/lib/claricle/handlers/pdf.rb +961 -0
  24. data/lib/claricle/handlers/pdf_arlington/definitions.rb +54 -0
  25. data/lib/claricle/handlers/pdf_arlington/kind.rb +62 -0
  26. data/lib/claricle/handlers/pdf_arlington/predicate_gate.rb +39 -0
  27. data/lib/claricle/handlers/pdf_arlington/rules.rb +86 -0
  28. data/lib/claricle/handlers/pdf_arlington/walker.rb +131 -0
  29. data/lib/claricle/handlers/pdf_arlington.rb +38 -0
  30. data/lib/claricle/handlers/pdf_profiles.rb +66 -0
  31. data/lib/claricle/handlers/png.rb +1107 -0
  32. data/lib/claricle/handlers/postscript.rb +1357 -0
  33. data/lib/claricle/handlers/svg.rb +787 -0
  34. data/lib/claricle/image.rb +272 -0
  35. data/lib/claricle/lossiness/levels.rb +13 -0
  36. data/lib/claricle/lossiness.rb +864 -0
  37. data/lib/claricle/models/base.rb +460 -0
  38. data/lib/claricle/models/batch_item.rb +127 -0
  39. data/lib/claricle/models/conversion.rb +167 -0
  40. data/lib/claricle/models/format_capability.rb +40 -0
  41. data/lib/claricle/models/free_form_hash.rb +77 -0
  42. data/lib/claricle/models/inspection.rb +188 -0
  43. data/lib/claricle/models/issue.rb +23 -0
  44. data/lib/claricle/models/location.rb +51 -0
  45. data/lib/claricle/models/report.rb +54 -0
  46. data/lib/claricle/models/uncoerced_integer.rb +38 -0
  47. data/lib/claricle/postscript_fidelity/verdict.rb +37 -0
  48. data/lib/claricle/postscript_fidelity/view_box.rb +82 -0
  49. data/lib/claricle/postscript_fidelity.rb +160 -0
  50. data/lib/claricle/registry.rb +125 -0
  51. data/lib/claricle/version.rb +1 -1
  52. data/lib/claricle/writer.rb +468 -0
  53. data/lib/claricle.rb +244 -7
  54. metadata +201 -19
  55. data/claricle.gemspec +0 -42
  56. data/sig/claricle.rbs +0 -4
@@ -0,0 +1,296 @@
1
+ # 01 — Core: models, detection, registry, CLI runner
2
+
3
+ Can start: now. Everything else builds on this item; it ships with an
4
+ empty registry, so every operation raises `UnsupportedFormat` through
5
+ the real dispatch path until 02. The model shape is settled by D15
6
+ (dimensions), D17 (`parse_status`) and D23 (structural pre-pass) — read
7
+ those before writing `Handlers::Base`.
8
+
9
+ ## Problem
10
+
11
+ The gem is a placeholder: a Thor CLI whose `validate`/`convert`/
12
+ `compress` commands print "coming soon". Nothing exists to detect
13
+ formats, dispatch to handlers, model results, or map errors to exit
14
+ codes — and the README advertises the fake commands.
15
+
16
+ ## Design
17
+
18
+ Contracts an implementer must honor (each verified against the real
19
+ dependency where marked ⚙):
20
+
21
+ - **Errors** (`lib/claricle/errors.rb`): `UnknownFormat` (bytes match no
22
+ signature), `UnsupportedFormat.new(format, operation = nil)` with
23
+ message `format :wmf is not supported for convert to :svg` style,
24
+ `ConversionError`, `InvocationError` (bad arguments, conflicting
25
+ flags, refused destination — the only thing besides `Thor::Error` and
26
+ `ENOENT` that maps to exit 2) — all `< Claricle::Error` (moved here
27
+ from `claricle.rb`).
28
+ - **Models** (`lib/claricle/models/`): lutaml-model `Serializable`
29
+ classes ⚙ (construct + `to_json`/`from_json` against the installed gem
30
+ before trusting specs). `Location{byte_offset, byte_length, line,
31
+ column, chunk, node_path}` all nullable — the offset/length pair is a
32
+ zero-based half-open range, because issue #1 asks for a byte *range*
33
+ and a bare offset can't express one. `Issue{severity:
34
+ error|warning|info, code, message, location}` — `message` is always
35
+ present, never nil. `Report{source_path, format, issues, profile,
36
+ validator_version}` — provenance matters once conformance can mean
37
+ more than one thing, and `source_path` is what lets a batch result be
38
+ traced back to its input (nil for content-born images; never expose a
39
+ Tempfile path). `Report#valid` is **derived on read**, not stored, so
40
+ appending an issue or deserializing JSON can't leave a stale verdict;
41
+ it decides in D8 order error → warning → else `yes`.
42
+ `Inspection{format, width, height, dpi, color_space, meta,
43
+ parse_status, issues}` — see the inspection contract below.
44
+ - **Invariants are enforced, not just described** ⚙: the gemspec
45
+ constrains lutaml-model to `~> 0.8.32`, currently resolved to 0.8.97.
46
+ Claricle validates and normalizes on construction AND on
47
+ deserialization, then deep-freezes the issue collection. Contract
48
+ specs cover an invalid severity, a missing message, post-build
49
+ mutation, and inconsistent JSON.
50
+ - **Inspection means "did the metadata parse", nothing more.**
51
+ `parse_status` is `"ok"` or `"failed"` — Strings, because the model
52
+ declares a string enum. A Symbol is accepted on construction and reads
53
+ back as a String ⚙, so compare against `"ok"`, never `:ok`. It replaces
54
+ a `valid` field outright — `valid` would mean five different things
55
+ across five delegates, and vectory parses SVG in Nokogiri's default
56
+ RECOVER mode ⚙, so a repaired malformed SVG would read as valid.
57
+ Validity claims belong to `conform` alone. An **allowlisted** parse failure gives
58
+ `"failed"` and exits 0 — the command succeeded in reporting that the
59
+ file doesn't parse. A fault off the allowlist is not absorbed; it
60
+ propagates and exits 4. **`parse_status` comes from Claricle's own
61
+ structural check (D23), never from delegate silence** — measured, a
62
+ PNG reader yields zero chunks without raising, svg_conform's `base`
63
+ profile returns no errors for raw binary, and vectory raises on a
64
+ *valid* dimensionless SVG. "The delegate didn't complain" is not
65
+ evidence the file parsed.
66
+ - **Detector** (`lib/claricle/detector.rb`): internal --
67
+ `Claricle::Detector` is a `private_constant`, so
68
+ `Claricle::Detector` raises `NameError` from
69
+ outside the gem. `Detector.detect(bytes)` / `Detector.detect_path(path)`
70
+ are reached through the public `Claricle.detect(source)` (a String or
71
+ an IO) and `Image.from_path` / `Image.from_content`;
72
+ `Claricle.detect_path` does not exist. Result: `:png :svg :emf :wmf
73
+ :eps :ps :pdf` or raise `UnknownFormat`. Load-bearing details, each corrected by execution on
74
+ 2026-08-12 — do not trust the earlier wording, it was wrong:
75
+ - Normalize `bytes.to_s.b` first (callers hand UTF-8 strings;
76
+ comparisons must be binary).
77
+ - `%!PS` + `EPSF` in first line → `:eps` else `:ps`.
78
+ - **`::Emf.detect_format` raises `Emf::FormatError` on every
79
+ unrecognised input** ⚙ — verified against emf 0.1.0 with empty,
80
+ 2-byte, plain-text, XML and PNG input; it never returned nil. Wrap
81
+ the metafile probe in `rescue Emf::FormatError` and continue to the
82
+ next probe. Rescue that class only, never bare. Without this, every
83
+ ordinary unknown file becomes exit 4 instead of `UnknownFormat`/3.
84
+ - **SVG detection is encoding-aware XML root detection, not a
85
+ regex** ⚙. A 4096-byte binary `<svg` scan rejects input vectory
86
+ accepts: UTF-16LE+BOM, `<s:svg xmlns:s=...>` prefixed roots, and a
87
+ root sitting at byte 5028 behind a long licence comment all failed
88
+ the regex and all parsed fine in vectory 0.12.0. Decode the BOM and
89
+ XML declaration, resolve the root **QName**, and require the SVG
90
+ namespace — local name alone would accept a non-SVG `<svg>` in
91
+ another namespace. Disable external entity resolution and DTD
92
+ expansion (XXE); a detector must never fetch a URL. Any byte limit
93
+ on the preamble is a deliberate product restriction, so state it —
94
+ it is not delegate compatibility.
95
+ - `:wmf` is still detected even though nothing handles it (D14), so
96
+ the user gets `UnsupportedFormat`/3 rather than a misleading
97
+ "unknown format".
98
+ - **Registry** (`lib/claricle/registry.rb`): internal
99
+ (`private_constant :Registry`); reached through `Image` operations and
100
+ the `formats` command. It loads every file in `handlers/`, derives
101
+ `HANDLER_CLASSES` from `Base.subclasses`, and builds the frozen
102
+ `HANDLERS` map from each class's `supported_formats`;
103
+ `handler_for(format)` fetches or raises `UnsupportedFormat`;
104
+ `formats -> [Symbol]` (sorted; feeds 02's `formats` command); no runtime
105
+ mutation, no test-only APIs.
106
+ - **Handler metadata carries what the registry derives.** Adding a
107
+ format is one handler class on main: registry discovery is automatic,
108
+ and `spec/claricle/one_class_per_format_spec.rb` proves that a dropped-in
109
+ class supplies detection, dispatch, formats output and inbound
110
+ conversion without another edit. A handler declares its formats, an
111
+ optional `detect` block, its conversion targets, the **feature-loss
112
+ rules** for each of those targets (per D23, lossiness is classified per
113
+ conversion from the source's content, so a handler declares which
114
+ source features a target discards, not a flat per-edge label), and its
115
+ canonical file extensions (needed by 04's `--to` inference from an
116
+ `--output` suffix). The built-in probes remain Detector's ordered,
117
+ load-bearing prefix/delegate/XML sequence; handler-declared probes are
118
+ the extension point and run afterwards through `Registry.detect`, so
119
+ a new format cannot change an existing format's verdict. Capabilities
120
+ are derived from the operations the handler overrides, not declared.
121
+ - **Handlers::Base** (`lib/claricle/handlers/base.rb`): internal
122
+ (`private_constant :Handlers`) -- a handler is a
123
+ subclass of it, never called by name from outside the gem.
124
+ `formats(*syms)`
125
+ class macro is pure declaration; instance `inspection(image)`,
126
+ `conformance_report(image)`, `convert(image, to:)` all raise
127
+ `UnsupportedFormat` (interpolate `to` into the message — unused-arg
128
+ lint otherwise).
129
+ - **Image** (`lib/claricle/image.rb`): `from_path` (detects),
130
+ `from_content(content, format: nil)` (detects when format omitted);
131
+ `#content` lazy-reads; ops dispatch through the registry;
132
+ `#with_path { |p| }` yields the real path or a binmode Tempfile
133
+ (auto-cleaned) for content-born images — `require "tempfile"` is
134
+ load-bearing (stdlib, NOT autoloaded ⚙).
135
+ - **CLI + Runner** (`lib/claricle/cli.rb`): stubs deleted, `version`
136
+ kept, nested `Runner` module maps `run(argv)` → exit code:
137
+ dispatch Thor directly so `Thor::Error` and operation failures reach
138
+ the map. Thor's public `start` treats every `Errno::EPIPE` as success,
139
+ even one raised by a command's own work; only actual `help` and
140
+ `version` output tolerates a closed consumer. Validate argv before
141
+ dispatch so malformed or non-ASCII-compatible text is an invocation
142
+ error rather than an internal encoding failure.
143
+ `Thor::Error`/`Errno::ENOENT`/`InvocationError` → 2,
144
+ `UnknownFormat`/`UnsupportedFormat` → 3, other `StandardError` → 4,
145
+ **`LoadError` → 4 and `SystemStackError` → 4, both caught
146
+ explicitly** — neither is a `StandardError`, so a missing lazy
147
+ delegate or a deeply recursive PostScript file would otherwise escape
148
+ as process status 1, the code reserved for nonconformance. Recursive
149
+ PS through `Vectory::Ps#to_svg` was measured raising
150
+ `SystemStackError`. Catch `ScriptError` and `SystemStackError`
151
+ narrowly; never rescue `Exception`.
152
+ Never blanket-map `ArgumentError`: a delegate raising one
153
+ accidentally is an internal defect, not bad user input. Split into
154
+ `run` / `exit_code` / `error_message` to stay under
155
+ `Metrics/MethodLength`. `Runner.run` **returns** an integer and never
156
+ calls `exit`; only `exe/claricle` exits, via
157
+ `exit Claricle::Cli::Runner.run(ARGV)`. `run` does rescue `SystemExit`
158
+ and return its status, bounded to 0-255 with anything outside becoming
159
+ 4 — that replaced the original prescription's "no `SystemExit`
160
+ passthrough". Nothing is thrown through the runner either way, and
161
+ 03's nonconformant exit 1 stays a returned integer like every other
162
+ code. Normalize exception messages to UTF-8 before reporting them so
163
+ a delegate's differently encoded message cannot replace its mapped
164
+ status with an `Encoding::CompatibilityError`.
165
+ - **Tooling**: gemspec floor `>= 3.3.0`, add `emf` + `lutaml-model`
166
+ (only what 01 uses; 02 adds the rest) with three-segment constraints
167
+ on the reviewed line — `~> 0.7` is not a pin, it admits every 0.x
168
+ (D13), and this gem commits no lockfile. `.rubocop.yml`
169
+ `TargetRubyVersion: 3.3` + `Metrics/BlockLength` exclude `spec/**/*`
170
+ (rubocop-rspec isn't wired in); CI matrix `['3.3', '3.4', '4.0']`, extended
171
+ to the newest stable Ruby the gemspec actually admits.
172
+ - **Tooling outcome.** `rexml` also joined `emf` and `lutaml-model`,
173
+ because `detector.rb` requires it directly. No direct runtime
174
+ dependency forces 3.3: measured, `emf` 0.1.0 asks `>= 3.1.0`
175
+ and the current lutaml-model resolution, 0.8.97, asks `>= 3.0.0`,
176
+ while `rexml` 3.4.4 asks
177
+ `>= 2.5.0` and Thor 1.2.0 asks `>= 2.0.0`. The 3.3 project floor is a
178
+ deliberate policy, not a delegate constraint. Thor's floor is 1.2:
179
+ 1.0 and 1.1 reference the removed `DidYouMean::SPELL_CHECKERS` and
180
+ fail on supported Ruby 3.4.
181
+ - **Honesty baseline**: README.adoc and the gemspec description
182
+ describe reality as of 01 — the pre-01 README advertised validation,
183
+ conversion, compression and a fictional
184
+ `Claricle::Validator` API, and the gemspec advertises compression.
185
+ Later items extend this baseline; 04 does the final rewrite. Merged
186
+ source should never claim capabilities it lacks, release or no
187
+ release.
188
+ - **Require order is load-bearing for one pair only.** Every file under
189
+ `lib/` loads standalone except `lib/claricle/cli.rb`, which names
190
+ `Thor` at class-definition time and raises `uninitialized constant
191
+ Claricle::Thor` unless `thor` was required first — so `lib/claricle.rb`
192
+ requiring `thor` ahead of `cli` is a real dependency, not reading
193
+ order. `detector`, `image`, `registry`, `errors`, `handlers/base` and
194
+ the models each load on their own, and `lib/claricle.rb` may require
195
+ them in any order. `detector` is standalone only as far as loading
196
+ goes: it does not require `errors`, so raising `UnknownFormat` needs
197
+ `errors` already loaded. That replaced the original prescription
198
+ (thor, emf, lutaml/model, version, errors, models, detector,
199
+ handlers/base, registry, image, cli), which existed because every file
200
+ relied on the entry point to have loaded its constants first.
201
+ `registry.rb` requires `handlers/base` itself, next to the
202
+ `HANDLER_CLASSES` entries that name those classes; `detector.rb`
203
+ requires `emf` and `rexml`; `models/base.rb` requires `lutaml/model`.
204
+
205
+ ## Do
206
+
207
+ TDD each step (failing spec → implement → green → rubocop → commit,
208
+ subjects in quotes):
209
+
210
+ 1. Tooling floor + gemspec deps; `bundle install` must resolve against
211
+ released gems (D13). "chore: raise floor, add emf and lutaml-model"
212
+ 2. Error taxonomy + spec. "feat: add error taxonomy"
213
+ 3. Models + derived tri-state spec + JSON round-trip spec + invariant
214
+ specs (invalid severity, nil message, post-build mutation,
215
+ inconsistent JSON); run the lutaml-model validation contract check in
216
+ `bin/console` first against the constrained line (`~> 0.8.32`, current
217
+ resolution 0.8.97).
218
+ "feat: add unified issue and report models"
219
+ 4. Detector + spec. Fixtures: smallest real `.emf`/`.wmf` from the emf
220
+ corpus into `spec/fixtures/detector/`; inline byte strings for the
221
+ rest. Must include the cases the old regex failed — UTF-16LE+BOM,
222
+ `<s:svg>` prefixed root, internal DTD subset, root behind a
223
+ 5000-byte comment — plus rejection cases: an `<svg>` in a foreign
224
+ namespace, an `<svg` decoy inside a comment, and an entity referring
225
+ to an external file (must not be fetched). Assert that plain text
226
+ reaches `UnknownFormat` and PNG bytes detect as `:png` — neither may
227
+ leak `Emf::FormatError`, which both trigger on the metafile probe.
228
+ "feat: add format detector"
229
+ 5. Registry + Base + specs (anonymous subclasses are fine — real
230
+ classes, not doubles). "feat: add handler registry and base class"
231
+ 6. Image + spec (tempfile lifecycle both directions; the
232
+ "no handler registered" example must be replaced in 02 with an
233
+ explicitly-passed unregistered format). "feat: add image entry object"
234
+ 7. CLI rebuild + Runner + spec (version → 0, unknown command → 2,
235
+ `LoadError` → 4, `InvocationError` → 2, all three stubs gone).
236
+ "feat: rebuild cli with exit-code runner"
237
+ 7b. Honesty baseline: README.adoc and the gemspec description
238
+ describe only what exists; `sig/` is deleted rather than kept
239
+ honest, because RBS is not maintained here.
240
+ "docs: describe only shipped behaviour"
241
+ 8. Final wiring; full `bundle exec rake`; execution-diff against main —
242
+ `bundle exec exe/claricle version`, `bundle exec exe/claricle help`,
243
+ `bundle exec exe/claricle validate x.png; echo $?`, on BOTH revisions
244
+ (bare `exe/` can't resolve `lib/` from a checkout).
245
+ "chore: finalize core wiring"
246
+
247
+ ## Done when
248
+
249
+ - [x] `bundle exec rake` green on a clean install.
250
+ - [x] `bundle exec exe/claricle version` → 0; `bundle exec exe/claricle
251
+ nope; echo $?` → 2; stub commands absent from `claricle help`.
252
+ - [x] Runner spec covers every row of the exit-code matrix including 4
253
+ (a raised `StandardError` that is not a `Claricle::Error`); 03
254
+ exercises 4 end-to-end through a deliberately faulting handler
255
+ raising an off-allowlist exception. A real crashing delegate is the
256
+ wrong probe — once the allowlists exist, a corrupt fixture is
257
+ nonconformance and exits 1. `spec/claricle/cli_spec.rb` covers these with
258
+ the missing-file and directory examples (2), unknown and unhandled
259
+ format examples (3), nonconformant format examples (1), and the two
260
+ "exits 4 when the handler raises outside its allowlist" examples
261
+ (conform and convert) through `spec/fixtures/faulting_handler/boom.rb`, #63.
262
+ - [x] `Report#valid` spec covers info-only and warning-plus-info inputs,
263
+ proves the frozen issue collection refuses mutation rather than
264
+ silently accepting it, and proves the verdict is correct after a
265
+ deserialization round trip.
266
+ - [x] The detector's SVG contract is stated as what it accepts, not as
267
+ parity with vectory — vectory is not a dependency of this item, so a
268
+ parity claim cannot be measured here. It accepts a root carrying the
269
+ SVG namespace within `SVG_PROLOG_BYTES`; a foreign namespace, a decoy
270
+ and a namespace-less root are all refused, and no external entity is
271
+ ever resolved. Both bounds are real divergences from a permissive XML
272
+ reader, not formalities ⚙: REXML parses a valid SVG behind a
273
+ comment longer than `SVG_PROLOG_BYTES` (8192; pinned on both sides by
274
+ `spec/claricle/detector_spec.rb`, examples "accepts a root that ends on
275
+ the last byte of the bound" and "rejects the same root one byte beyond
276
+ the bound"), and behind no comment at all with the namespace dropped,
277
+ that `Claricle.detect` refuses in both cases.
278
+ - [x] (one-time check, now complete) Execution-diff vs main shows ONLY: stubs gone, version unchanged,
279
+ README and gemspec truthful.
280
+ - [x] (PR-time record, superseded by CI) Every ⚙ contract check ran against the installed gem and its outcome
281
+ is recorded in the PR description. `Emf.detect_format` raising, the
282
+ SVG root cases, lutaml-model's deferred validation and `tempfile`'s
283
+ non-autoloading are the known ones; treat any new delegate assumption
284
+ the same way.
285
+ - ~~Full Pre-Push Review Chain passed.~~ Superseded: Hassan 2026-10-09 requires TDD, only changed specs, and green GHA; no extra review chain.
286
+
287
+ ## Files
288
+
289
+ `claricle.gemspec`, `.rubocop.yml`, `.github/workflows/main.yml`,
290
+ `lib/claricle.rb`, `lib/claricle/errors.rb`, `lib/claricle/models/
291
+ {base,free_form_hash,location,issue,report,inspection}.rb`,
292
+ `lib/claricle/detector.rb`,
293
+ `lib/claricle/registry.rb`, `lib/claricle/handlers/base.rb`,
294
+ `lib/claricle/image.rb`, `lib/claricle/cli.rb`, `exe/claricle`,
295
+ `README.adoc`, `spec/claricle/*_spec.rb`,
296
+ `spec/fixtures/detector/`.
@@ -0,0 +1,163 @@
1
+ # 02 — Inspect: five handlers, `claricle inspect`, `claricle formats`
2
+
3
+ Can start: after 01. D14 (WMF) and D18 (EMF+) settle what this item
4
+ registers.
5
+
6
+ ## Problem
7
+
8
+ Core dispatches to an empty registry. Nothing can read metadata from
9
+ any format, and the CLI has no operation commands.
10
+
11
+ ## Design
12
+
13
+ Delegate entry points (verified against the real gems 2026-08-10; the
14
+ delegates are path- or content-oriented as noted):
15
+
16
+ | Handler (formats) | Delegate call | Metadata source |
17
+ |---|---|---|
18
+ | `Handlers::Png` (`:png`) | A chunk **reader**, not the validator — `PngConform::Readers::StreamingReader` is the entry point actually used -- `FullLoadReader` was measured first but is `read_until: :eof`, so it reads past `IEND`, owns and leaks the file it opens, and rejects a Pathname. The streaming reader stops at `IEND` and closes its own file. The handler does not call `each_chunk`: it drives `reader.io` by hand, so it can read an 8-byte header and decide before touching a payload, which is what bounds the read. Measured — `open` raises nothing on a signature followed by 0 to 4 junk bytes, it just leaves the IO at byte 8, so "no exception" cannot mean `parse_status: "ok"` — require a readable `IHDR` before declaring success. Routing inspection through `ValidationService` would make `inspect` mean conformance for PNG and metadata for everything else | `IHDR`: width/height/bit depth/colour type; `pHYs`: dpi |
19
+ | `Handlers::Svg` (`:svg`) | **Amended 2026-08-21: the slice shipped with no delegate at all.** `Detector.read_root(source)`, with the source handed over by `image.with_source` — the detector already owns the 8192-byte bound, the ATTLIST precedence and the reference resolution, so a second reader would have to agree with all three forever. vectory is not in the gemspec. The original row's `Vectory::Svg.from_content(image.content)` was measured, then not taken: a heavyweight dependency and the whole file in memory to reach the root's attributes. What that measurement settled still holds — dimensions are nullable, and `parse_status` comes from Claricle's own structural check (D23), here an affirmative root start event, never from a delegate staying quiet. **Scope is the root prefix**: nothing past the root start tag is parsed — the 8192-byte bound is read either way — so a document malformed only after that tag reports `ok`. Whole-document well-formedness is conformance (D16, item 03), and it would cost the bounded read — 64.0 MiB SVG, +0.8 MiB against +64.5 MiB. Values get XML's **CDATA** attribute normalization and only that: a literal tab, CR or LF becomes a space, a character reference keeps its character. The non-CDATA trim-and-collapse is deliberately skipped — the type lives in the DTD, SVG's DTD is external, and a bounded reader can neither fetch it nor should, so implementing the internal-subset half would be right for a locally declared `NMTOKENS` and wrong for the same attribute declared externally | width/height (nullable); root attributes into `meta` |
20
+ | `Handlers::Metafile` (`:emf` only) | `::Emf.parse(image.content)` | header `bounds` and `frame`, both `Emf::Model::Geometry::Rect` with `width`/`height` helpers, **plus `device_pixels` and `device_mm`**, both `Emf::Model::Geometry::Size` (`cx`/`cy`). Measured on our fixture: bounds 100x50, frame 2645x1322, device_pixels 100x50, device_mm 26x13. **Amended twice on 2026-08-20** — an earlier amendment claimed there was no device accessor, which was my own false contract: I probed `device`/`millimeters` and concluded from the wrong names. The original row was right. Also available: `n_records`, `n_handles`, `description` (nil on our fixture). `metafile.emf_plus` was nil on our fixture, and when present it is packed binary — putting it straight into `meta` risks `JSON::GeneratorError`, so expose presence, byte size and optionally Base64, never the raw bytes ⚙ confirm against a real EMF+ file before writing the spec |
21
+ | `Handlers::Postscript` (`:eps, :ps`) | **Amended 2026-08-20: vectory is not needed here.** `::Postscript.parse(src).header` exposes `bounding_box` directly, returning `[0.0, 0.0, 100.0, 50.0]` — the same Floats vectory gave, from a delegate already required for the DSC data. Taking vectory would add a heavyweight dependency for a number we already have, which is the reasoning the SVG slice settled. The header also carries `hires_bounding_box`, `title`, `creator`, `creation_date`, `epsf`, `language_level`, `page_count` and `custom`. D22 is untouched: a successful parse still says nothing about conformance. **Amended again 2026-08-21, after building it:** `epsf` is never populated even for a genuine EPSF header; the page fields are populated only by the two-argument `%%Pages: n m` form; and `custom` is the bucket of comments 0.2.0 could NOT read, so all three stay out of `meta`. 0.2.0 is also last-wins on repeated header comments where DSC gives the FIRST one precedence, so every published value is checked against the first declaration's own text | dims from `hires_bounding_box`, falling back to `bounding_box`; the header comments 0.2.0 populates reliably into `meta` |
22
+ | `Handlers::Pdf` (`:pdf`) | `Pdfrb::Document.open(path)` via `image.with_path`. **Amended 2026-08-20, two corrections on pdfrb 0.7.23:** `version` holds (`"1.4"`), but there is **no `page_count` method** — `pages` returns a `Pdfrb::Document::Pages` and the count is `pages.count`, so a handler written from the original row raises `NoMethodError`. **Amended again 2026-08-21: the MediaBox claim above was wrong, and it is what pulled dimensions into this row.** `page[:MediaBox]` is a `Rectangle`, but a `Rectangle` is not evidence a rectangle was declared — pdfrb coerces every element with `to_f` and pads what is missing, so `[0 0]` reads back as a 0x0 page and `[0 0 null 100]` fabricates a coordinate. `page.media_box`, the accessor that resolves inheritance, **hangs forever** on a self-referential `/Parent`; `pages.first` walks `/Kids` with no cycle guard and resolves references by object number alone; and `pages.count` reports the DECLARED `/Count` verbatim, so one leaf page declaring `/Count 999` reports 999. **Dimensions are therefore deferred** — see `docs/plans/inspect-pdf.md` for the eleven rounds of measurements and what a future pdfrb must offer before they are revisited. It **opens a structurally broken PDF without complaint**, so a successful `open` cannot by itself yield `parse_status: "ok"`; the status comes from Claricle's structural pre-pass (D23), consistent with 01 | version; **declared** page count. **Amended 2026-08-28:** `pages.count` is unusable for it -- measured, it is sometimes the declared `/Count`, sometimes computed by walking `/Kids`, and it raises `SystemStackError` on a self-cycling `/Kids` tree whose `/Count` is absent. The handler reads `/Count` RAW off the page-tree node instead, through `.value[:Count]`, because the typed subscript coerces and mutates the node in place. Dimensions deferred |
23
+
24
+ Every row above was executed against a real fixture on 2026-08-13, and
25
+ **re-measured on 2026-08-20 against emf 0.1.0, postscript 0.2.0, pdfrb
26
+ 0.7.23 and vectory 0.12.0** — three rows had drifted and are amended
27
+ above. See the measured-contracts section in 00. Re-run them if a
28
+ delegate version moves; that section exists because six
29
+ confidently-asserted contracts turned out false, and this round found
30
+ three more.
31
+
32
+ The SVG row's 2026-08-21 amendment is not one of those three. Nothing
33
+ had drifted there: the slice was built and chose to drop the delegate,
34
+ so the row now records what shipped.
35
+
36
+ The repo had no PDF fixture, so the PDF slice builds one: a minimal
37
+ PDF 1.4 with a catalog, a pages node and a single page -- sufficient for
38
+ this handler's structure gate, not structurally valid in the full sense.
39
+ **Amended 2026-08-28:** the page carries NO `/MediaBox` -- pdfrb's
40
+ own validator reports "Page 3 has no /MediaBox" -- because this slice
41
+ reports no dimensions, so there is nothing for a box to feed. The
42
+ earlier "200x100 page" described a fixture that was never built.
43
+
44
+ - **WMF is out (D14).** Released `emf` 0.1.0 raises `WMF parser not yet
45
+ implemented`, so `Handlers::Metafile` claims `:emf` alone. The
46
+ detector still returns `:wmf`; with no handler registered it raises
47
+ `UnsupportedFormat` → exit 3. That needs its own spec: a real WMF
48
+ fixture is recognised and refused, not reported unknown.
49
+
50
+ - Handlers must reference delegates with `::` — `Claricle::Handlers::X`
51
+ shadows top-level constants (`Emf`, `Postscript`).
52
+ - Heavy delegates are `require`d lazily inside each handler (D5).
53
+ - `capabilities` is **derived** from the operations a handler overrides
54
+ on `Handlers::Base`, not declared by a macro. **02 yields `inspect`
55
+ only** — nothing else is implemented, so `formats` cannot advertise
56
+ it. 03 and 04 extend it by implementing the operations (see 00's
57
+ capabilities rule).
58
+ - `Inspection#parse_status` is `"ok"` or `"failed"` — "did the metadata
59
+ parse", never a validity claim (01's inspection contract). An
60
+ **allowlisted** parse error becomes `"failed"` plus one
61
+ `Issue{severity: "error"}` and still exits 0; a fault off the
62
+ allowlist propagates and exits 4, so a genuine bug in a delegate is
63
+ never disguised as a tidy `"failed"` result. A clean parse is `"ok"`
64
+ with no issues. Handlers do NOT run a second validation pass here,
65
+ and PNG must not route inspection through the conformance validator
66
+ just because it has one — that would make `inspect` mean something
67
+ different for PNG than for everything else.
68
+ Codes reuse the scheme 03 formalizes, so 03 extends this mapping
69
+ instead of replacing it. `dpi`/`color_space` nullable where the
70
+ format has none. Note that a `"failed"` inspection still exits 0.
71
+ - New deps in gemspec as item 02's handlers land: `png_conform`,
72
+ `postscript`, `pdfrb` — three-segment constraints on the reviewed line
73
+ (D13), not a floating `~> 0.x`. Locally installed and reviewed:
74
+ png_conform 0.1.4, postscript 0.2.0 and pdfrb 0.7.23, with PDF behavior
75
+ re-verified on 0.7.49, which resolves here. The earlier plan's
76
+ `png_conform ~> 0.7` figure was wrong and contradicted this list.
77
+ `svg_conform` belongs to item 03 and `vectory` to item 04; neither is
78
+ introduced here. Item 04's `vectory → emfsvg` chain will bring
79
+ `libpng` transitively, so no direct dependency is planned (D5). Verify
80
+ Bundler resolution.
81
+ - CLI: `claricle inspect FILE [--json]`; human output prints format,
82
+ dimensions, each populated metadata field one per line, the
83
+ `parse_status`, and every issue — a delegate that failed to parse
84
+ shows up there and nowhere else, so omitting it would print a
85
+ blank-looking success. JSON uses lutaml-model `to_json`, and empty
86
+ collections must still serialize as `[]` rather than being omitted —
87
+ lutaml-model drops them by default unless the mapping says otherwise.
88
+ Dimensions follow settled D15: numeric values are normalised, and
89
+ cross-format dimension equality is never asserted. `claricle formats` prints the format ×
90
+ operation matrix (human + `--json`). `formats --json` is an array of
91
+ `{"format": "svg", "inspect": true, "conform": false, "convert":
92
+ false, "convert_to": []}` objects, sorted by format — the keys stay fixed across items, so
93
+ 03 and 04 flip booleans and fill `convert_to` without reshaping the
94
+ schema. Missing file → exit 2 (runner spec example deferred from 01
95
+ lands here).
96
+ - Registry: `registry.rb` loads every file in `handlers/` and derives
97
+ `HANDLER_CLASSES` from `Handlers::Base.subclasses`, so each of the five
98
+ handlers is introduced by its own file without a second registration
99
+ edit. A missing handler file is the silent case, and item 02 catches it
100
+ by asserting the exact expected format set rather than a
101
+ self-consistency check. Replace
102
+ 01's "no handler registered" spec example with
103
+ `from_content("x", format: :unregistered)`.
104
+
105
+ ## Do
106
+
107
+ Plumbing first, then one handler per commit. The order matters: the
108
+ `formats` command has to exist before any handler can be truthful in
109
+ it, and after that every handler arrives as a complete slice.
110
+
111
+ 1. Add deps; verify resolution.
112
+ 2. `capabilities` derivation on `Handlers::Base` + `formats` command +
113
+ spec against the still-empty registry (prints no formats).
114
+ 3. `inspect` command + `--json` + missing-file exit-2 spec, still
115
+ against the empty registry.
116
+ 4. One handler per commit, each commit carrying the whole slice: the
117
+ handler class (its capabilities are derived from the operations it
118
+ overrides, not declared — a declaration can advertise an operation
119
+ that is still `Base`'s raising stub), its `require` in
120
+ `registry.rb`, its `HANDLER_CLASSES` entry, its
121
+ spec against a real fixture, and the `formats` expected-output
122
+ update. **Amended 2026-08-28, PDF only:** its fixtures are
123
+ GENERATED by `spec/support/pdf_builder.rb` rather than committed. A
124
+ PDF's xref offsets depend on every byte before them, so a committed
125
+ corpus is 60-odd opaque blobs no reviewer can check, and one of them
126
+ would be a 120 KB file whose whole purpose is to assert a
127
+ `SystemStackError`. No commit ever advertises an operation it didn't ship.
128
+ 5. Replace 01's "no handler registered" spec example.
129
+ 6. README section for `inspect` and `formats` (D1) — real commands
130
+ only, no forward promises.
131
+
132
+ ## Done when
133
+
134
+ - [x] `Claricle::Image.from_path(f).inspection` returns a populated
135
+ `Inspection` for a real PNG, SVG, EMF, EPS, PS, and PDF fixture --
136
+ the PDF one generated rather than committed, per the amendment above.
137
+ `PdfBuilder`-generated PDFs (`spec/claricle/cli_spec.rb`, example
138
+ `inspects a PDF through the real detector`); the two committed PDFs are
139
+ gone and `spec/support/inspect_fixture.rb` builds them at spec time (#67).
140
+ - [x] A real WMF fixture is detected as `:wmf` and refused with exit 3
141
+ (D14; `spec/claricle/cli_spec.rb`, example
142
+ `exits 3 for a detected format with no handler`).
143
+ - [x] `claricle inspect`/`claricle formats` work in human and JSON modes;
144
+ exit codes verified (0 / 2 missing file / 3 unknown format).
145
+ - [x] `formats` reports each format's true operations. Originally
146
+ "`inspect` only"; once 03 and 04 shipped that was no longer true, and
147
+ `spec/claricle/cli_spec.rb`, example
148
+ `claims conform and convert only where a handler implements them`,
149
+ asserts the per-format truth (D22: eps/ps have `convert` but not
150
+ `conform`).
151
+ - ~~Full Pre-Push Review Chain passed.~~ Superseded: Hassan 2026-10-09 requires TDD, only changed specs, and green GHA; no extra review chain.
152
+
153
+ ## Files
154
+
155
+ `claricle.gemspec`,
156
+ `lib/claricle/handlers/{png,svg,metafile,postscript,pdf}.rb`,
157
+ `lib/claricle/handlers/base.rb` (capabilities),
158
+ `lib/claricle/registry.rb` (handler requires + class list),
159
+ `lib/claricle/cli.rb`,
160
+ `README.adoc`, `spec/claricle/handlers/*_spec.rb`,
161
+ `spec/claricle/cli_spec.rb`, `spec/fixtures/`,
162
+ `spec/support/pdf_builder.rb` and `spec/support/pdf_objstm_builder.rb`
163
+ (the generated PDF corpus).