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.
- checksums.yaml +4 -4
- data/.rubocop.yml +10 -1
- data/LICENSE +25 -0
- data/README.adoc +439 -157
- data/TODO.plan/00-overview.md +502 -0
- data/TODO.plan/01-core.md +296 -0
- data/TODO.plan/02-inspect.md +163 -0
- data/TODO.plan/03-conform.md +208 -0
- data/TODO.plan/04-convert.md +216 -0
- data/exe/claricle +1 -1
- data/lib/claricle/batch.rb +240 -0
- data/lib/claricle/cli.rb +514 -40
- data/lib/claricle/conformance_options.rb +49 -0
- data/lib/claricle/conversion_engine.rb +21 -0
- data/lib/claricle/convert.rb +21 -0
- data/lib/claricle/detector.rb +696 -0
- data/lib/claricle/emf_header.rb +33 -0
- data/lib/claricle/errors.rb +73 -0
- data/lib/claricle/fault.rb +69 -0
- data/lib/claricle/handlers/base.rb +169 -0
- data/lib/claricle/handlers/extension_points.rb +124 -0
- data/lib/claricle/handlers/metafile.rb +1011 -0
- data/lib/claricle/handlers/pdf.rb +961 -0
- data/lib/claricle/handlers/pdf_arlington/definitions.rb +54 -0
- data/lib/claricle/handlers/pdf_arlington/kind.rb +62 -0
- data/lib/claricle/handlers/pdf_arlington/predicate_gate.rb +39 -0
- data/lib/claricle/handlers/pdf_arlington/rules.rb +86 -0
- data/lib/claricle/handlers/pdf_arlington/walker.rb +131 -0
- data/lib/claricle/handlers/pdf_arlington.rb +38 -0
- data/lib/claricle/handlers/pdf_profiles.rb +66 -0
- data/lib/claricle/handlers/png.rb +1107 -0
- data/lib/claricle/handlers/postscript.rb +1357 -0
- data/lib/claricle/handlers/svg.rb +787 -0
- data/lib/claricle/image.rb +272 -0
- data/lib/claricle/lossiness/levels.rb +13 -0
- data/lib/claricle/lossiness.rb +864 -0
- data/lib/claricle/models/base.rb +460 -0
- data/lib/claricle/models/batch_item.rb +127 -0
- data/lib/claricle/models/conversion.rb +167 -0
- data/lib/claricle/models/format_capability.rb +40 -0
- data/lib/claricle/models/free_form_hash.rb +77 -0
- data/lib/claricle/models/inspection.rb +188 -0
- data/lib/claricle/models/issue.rb +23 -0
- data/lib/claricle/models/location.rb +51 -0
- data/lib/claricle/models/report.rb +54 -0
- data/lib/claricle/models/uncoerced_integer.rb +38 -0
- data/lib/claricle/postscript_fidelity/verdict.rb +37 -0
- data/lib/claricle/postscript_fidelity/view_box.rb +82 -0
- data/lib/claricle/postscript_fidelity.rb +160 -0
- data/lib/claricle/registry.rb +125 -0
- data/lib/claricle/version.rb +1 -1
- data/lib/claricle/writer.rb +468 -0
- data/lib/claricle.rb +244 -7
- metadata +201 -19
- data/claricle.gemspec +0 -42
- 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).
|