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,502 @@
|
|
|
1
|
+
# Claricle Unified Library Plan
|
|
2
|
+
|
|
3
|
+
**Date**: 2026-08-11. Restructured from the reviewed single-file plan
|
|
4
|
+
(git history: `docs/plans/issue-1-unified-library.md`, removed in this
|
|
5
|
+
restructure — these files are self-contained).
|
|
6
|
+
**Status**: complete as of 2026-10-09. Every acceptance item below is
|
|
7
|
+
implemented or covered by a signed-off scope decision.
|
|
8
|
+
**Goal**: turn `claricle` from a placeholder shim into the real umbrella
|
|
9
|
+
library for issue #1 — one Ruby API and one CLI providing **inspect**,
|
|
10
|
+
**conformance check**, and **conversion** over PNG, SVG, EMF/WMF, PS/EPS,
|
|
11
|
+
and PDF, delegating to the org's specialist gems.
|
|
12
|
+
|
|
13
|
+
> Local working docs under `docs/plans/*` live only on the maintainer's
|
|
14
|
+
> machine and are never committed. The plan files here are the
|
|
15
|
+
> authoritative, self-contained copy.
|
|
16
|
+
|
|
17
|
+
## Architecture
|
|
18
|
+
|
|
19
|
+
```mermaid
|
|
20
|
+
flowchart TD
|
|
21
|
+
API["Claricle.conform? / Claricle.convert / Claricle::Image"]
|
|
22
|
+
DET["Detector<br/>magic-byte sniffer"]
|
|
23
|
+
REG["Registry<br/>frozen format→handler map"]
|
|
24
|
+
API --> DET
|
|
25
|
+
DET --> REG
|
|
26
|
+
REG --> HPNG[Handlers::Png]
|
|
27
|
+
REG --> HSVG[Handlers::Svg]
|
|
28
|
+
REG --> HMET["Handlers::Metafile<br/>(:emf only — D14)"]
|
|
29
|
+
REG --> HPS["Handlers::Postscript<br/>(:eps, :ps)"]
|
|
30
|
+
REG --> HPDF[Handlers::Pdf]
|
|
31
|
+
HPNG --> png_conform
|
|
32
|
+
HSVG --> svg_conform
|
|
33
|
+
HSVG --> vectory
|
|
34
|
+
HMET --> emf
|
|
35
|
+
HMET --> vectory
|
|
36
|
+
HPS --> postscript
|
|
37
|
+
HPS --> vectory
|
|
38
|
+
HPDF --> pdfrb
|
|
39
|
+
HPNG -. maps results into .-> MOD["Models::Inspection / Report /<br/>Issue / Location / Conversion<br/>(lutaml-model)"]
|
|
40
|
+
HSVG -.-> MOD
|
|
41
|
+
HMET -.-> MOD
|
|
42
|
+
HPS -.-> MOD
|
|
43
|
+
HPDF -.-> MOD
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Every handler is a plain subclass declaring its formats once. As built,
|
|
47
|
+
`registry.rb` loads every file in `handlers/` and derives
|
|
48
|
+
`HANDLER_CLASSES` from `Base.subclasses`, so adding a format costs ONE
|
|
49
|
+
file: a handler class that declares its formats, its `detect` block and,
|
|
50
|
+
for an inbound conversion, its target list and feature-loss rules. (The
|
|
51
|
+
original design here — a `require_relative`, a list entry and a detector
|
|
52
|
+
probe per format — was replaced; `spec/claricle/one_class_per_format_spec.rb`
|
|
53
|
+
proves the one-file claim in a fresh process.)
|
|
54
|
+
|
|
55
|
+
## Item topology
|
|
56
|
+
|
|
57
|
+
Strictly sequential — each item builds on the previous one, and an item
|
|
58
|
+
may span several stacked PRs. Every PR is independently green and
|
|
59
|
+
shippable. 04 depends on 03 because the CLI batch helper is built in 03
|
|
60
|
+
and reused verbatim in 04.
|
|
61
|
+
|
|
62
|
+
```mermaid
|
|
63
|
+
flowchart LR
|
|
64
|
+
SO["issue #1 sign-off"] --> I01[01 core]
|
|
65
|
+
I01 --> I02[02 inspect] --> I03[03 conform] --> I04[04 convert]
|
|
66
|
+
SO -. D14 D18 .-> I02
|
|
67
|
+
SO -. D16 D22 D23 .-> I03
|
|
68
|
+
SO -. D11 .-> I04
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Decision record
|
|
72
|
+
|
|
73
|
+
No decision blocks implementation — all twenty-four are settled. Items
|
|
74
|
+
run in sequence for the ordinary reason that each builds on the last.
|
|
75
|
+
|
|
76
|
+
The six reported narrowings are posted to issue #1 for visibility. If
|
|
77
|
+
the author objects to one, the affected item is revisited then; we do
|
|
78
|
+
not hold work waiting for a response to a report.
|
|
79
|
+
|
|
80
|
+
## Items
|
|
81
|
+
|
|
82
|
+
| # | Item | Delivers | Can start |
|
|
83
|
+
|---|------|----------|-----------|
|
|
84
|
+
| 01 | Core | errors, models, detector, registry, `Image`, CLI runner + exit codes, stub removal, Ruby 3.3 floor, honesty baseline | now |
|
|
85
|
+
| 02 | Inspect | five handlers' `inspection`, `claricle inspect`, `claricle formats`, `--json` | after 01 |
|
|
86
|
+
| 03 | Conform | conformance mappings for png/svg/emf/pdf, `--profile`, `Claricle.conform?`, `claricle conform`, batch helper, `--strict` | after 02 |
|
|
87
|
+
| 04 | Convert | vectory-backed conversion, lossiness classification, `claricle convert`, README rewrite | after 03 |
|
|
88
|
+
|
|
89
|
+
## Contracts falsified by execution (2026-08-12)
|
|
90
|
+
|
|
91
|
+
Adversarial execution rounds ran the delegates instead of reading
|
|
92
|
+
them. Ten "verified" facts in earlier drafts were false. They are
|
|
93
|
+
recorded because the plan asserted each one confidently, and the same
|
|
94
|
+
mistake is easy to repeat. The pattern is always the same: reading
|
|
95
|
+
agreed with reading, and only running the thing disagreed.
|
|
96
|
+
|
|
97
|
+
| Claim the plan made | What running it showed |
|
|
98
|
+
|---|---|
|
|
99
|
+
| `Emf.detect_format` returns `:emf`/`:wmf`/nil, never raises | Raises `Emf::FormatError` on **every** unrecognised input — empty, short, plain text, XML, PNG bytes. Never returned nil in five trials |
|
|
100
|
+
| WMF inspect + conform work through `emf` | `Emf.parse` on WMF bytes raises `WMF parser not yet implemented` in released 0.1.0 |
|
|
101
|
+
| png_conform gives `error_type`, chunk and offset | `validate_file` → `result_builder#add_context_messages` passes only `e[:message]`. Type, chunk and offset are all dropped |
|
|
102
|
+
| svg_conform's `type` carries severity | `ErrorTracker#add_error` hardcodes `type: :error` and keeps real severity in a separate field. Mapping from `type` flattens info and warning into error |
|
|
103
|
+
| A 4096-byte binary `<svg` regex is XML-tolerant | vectory accepts UTF-16LE+BOM, `<s:svg>` prefixed roots, internal DTD subsets, and a root at byte 5028. The regex matched none of the last three |
|
|
104
|
+
| SVG→EMF is the lossy direction (emfsvg "lossy matcher") | That phrase describes a 0.1px **comparison tolerance**, not a lossy direction. Issue #1 names EMF→SVG as the one that drops semantics |
|
|
105
|
+
| `postscript` and `pdfrb` are not installable here | Both install cleanly (`pdfrb` 0.7.10, `postscript` 0.2.0). The earlier draft confused "absent from every local gemset" with "unavailable" and treated it as blocking |
|
|
106
|
+
| `libpng` is unused, no v1 operation needs it | `vectory → emfsvg → libpng ~> 1.6`, and emfsvg uses it for embedded images in SVG→EMF. It is a live transitive dependency. Only Claricle's **direct** dependency is redundant |
|
|
107
|
+
| vectory's own round-trip specs are semantic | vectory 0.12.0 has no A→B→A suite at all — only one-way conversion and reference tests, some checking little more than a format signature. The claim was invented |
|
|
108
|
+
| Ruby floor 3.2 comes from pdfrb (asserted, unverified) | Only the delegate fact was true: confirmed 2026-08-13, `pdfrb` 0.7.10 requires `>= 3.2.0`; every other delegate tops out at 3.1 (`emf`, `svg_conform`, `vectory`, `emfsvg` 3.1; `lutaml-model`, `png_conform`, `postscript` 3.0). That establishes the highest delegate minimum, not Claricle's deliberate 3.3 project floor |
|
|
109
|
+
|
|
110
|
+
## Measured delegate contracts (2026-08-13)
|
|
111
|
+
|
|
112
|
+
Everything below was **executed** against the installed gems using
|
|
113
|
+
generated fixtures, not read from source. Where an item file marks a
|
|
114
|
+
call ⚙ it points here. If you change a delegate version, re-run these
|
|
115
|
+
before trusting the plan.
|
|
116
|
+
|
|
117
|
+
**Reproducing these measurements.** The harness that produced them is
|
|
118
|
+
maintainer-local and deliberately not committed, so this section must
|
|
119
|
+
carry enough to rebuild it. Fixtures, all generated rather than vendored:
|
|
120
|
+
|
|
121
|
+
- a 100×50 SVG with a `rect` and a **red** stroke — the red matters, it
|
|
122
|
+
is what trips svg_conform's default profile;
|
|
123
|
+
- real EMF, EPS and PS produced from that SVG through vectory, so they
|
|
124
|
+
are genuine files rather than hand-forged headers;
|
|
125
|
+
- that EMF truncated to a third, and separately with only its final 20
|
|
126
|
+
bytes (the EOF record) removed;
|
|
127
|
+
- a hand-built 1×1 RGBA PNG plus variants: CRC-corrupt IDAT, duplicate
|
|
128
|
+
`IHDR`, duplicate `IEND`, trailing bytes after `IEND`, no `IDAT`;
|
|
129
|
+
- both WMF headers — standard `0100 0900` and placeable `d7cdc69a`;
|
|
130
|
+
- a `Pdfrb::Composer`-built PDF and a catalog-less broken one;
|
|
131
|
+
- malformed SVG variants: unclosed element, two roots, trailing
|
|
132
|
+
garbage, raw binary, and a *valid* SVG with no width/height/viewBox.
|
|
133
|
+
|
|
134
|
+
Run each delegate call in the tables below against those and compare.
|
|
135
|
+
A delegate version bump invalidates this section until it is re-run.
|
|
136
|
+
|
|
137
|
+
**Detection**
|
|
138
|
+
|
|
139
|
+
| Probe | Measured |
|
|
140
|
+
|---|---|
|
|
141
|
+
| PNG / PDF / EPS / PS signatures | `89504e470d0a1a0a`, `%PDF-1.4`, `%!PS-Adobe-3.0 EPSF-3.0`, `%!PS-Adobe-3.0` — the EPSF token on line 1 is the only eps/ps discriminator |
|
|
142
|
+
| `Emf.detect_format` | `:emf` for valid **and truncated** EMF; `:wmf` for both the standard (`0100 0900`) and placeable (`d7cdc69a`) headers; **raises `Emf::FormatError` for all six other fixtures** |
|
|
143
|
+
|
|
144
|
+
**Models — lutaml-model `~> 0.8.32` (currently resolved to 0.8.97)**
|
|
145
|
+
|
|
146
|
+
| Contract | Measured |
|
|
147
|
+
|---|---|
|
|
148
|
+
| Invalid enum value | Constructs **and serializes** to `{"severity":"bogus"}`. Only an explicit `validate!` raises |
|
|
149
|
+
| Empty collection | **Omitted from JSON entirely** — `Report.new(issues: [])` emits `{"source_path":"x.png"}` with no `issues` key. Force it in the mapping |
|
|
150
|
+
| Nested round trip | `byte_offset` and `chunk` survive `to_json`/`from_json` |
|
|
151
|
+
| Post-build mutation | `report.issues << ...` is accepted, so a stored verdict goes stale. `valid` must derive on read |
|
|
152
|
+
| `Tempfile` | `defined?(Tempfile)` is nil without a require |
|
|
153
|
+
|
|
154
|
+
**Inspection**
|
|
155
|
+
|
|
156
|
+
| Delegate | Measured |
|
|
157
|
+
|---|---|
|
|
158
|
+
| png_conform | `Readers::FullLoadReader` exposes `each_chunk`, `signature`, `png`, `file_size` — the metadata path, separate from validation |
|
|
159
|
+
| vectory SVG | `100x50` with width/height, `10x10` from viewBox alone. `#width` raises `Vectory::NotImplementedError` when **no** dimension source exists — including on a perfectly valid SVG — while a malformed SVG carrying `width="7"` returns 7. The error means "no dimensions", not "parse failed" |
|
|
160
|
+
| emf | `Emf.parse` → `Model::Metafile` with `ok?`, `errors`, `errors?`, `emf_plus`, `header`, `records`. Truncation does not have one exception contract: the current fixture matrix raises `Emf::FormatError`, `IOError`, or `EOFError` at different cut points |
|
|
161
|
+
| vectory EPS/PS | `100.0x50.0` — floats, not integers |
|
|
162
|
+
| pdfrb | `Document.open` → version `1.4`, 1 page. **Opens the broken PDF without complaint** — `open` does not validate |
|
|
163
|
+
|
|
164
|
+
**Conformance**
|
|
165
|
+
|
|
166
|
+
| Delegate | Measured |
|
|
167
|
+
|---|---|
|
|
168
|
+
| emf | `ok? == true`, `errors == []` on a clean parse — **but also on a file with its EOF record removed** (15 records vs 16). Not sufficient alone; see the coverage table |
|
|
169
|
+
| svg_conform | `validate(content, profile:)` → `ValidationResult`. **The default profile is the strict RFC one and rejects an ordinary red stroke** (`color_restrictions`). Under `base` the same file is clean. **`available_profiles` is cache-dependent** — it returns all six initially, then only the one most recently loaded until `Profiles.clear_cache!`, so never enumerate it lazily for the `formats` output. **UTF-32 input is silently certified clean**: a UTF-32LE document with a wrong namespace, no viewBox and a red stroke returned `valid? == true, errors == []`, while UTF-16 and ISO-8859-1 versions of the same document caught all three. vectory rejects the same bytes with `Nokogiri::XML::SyntaxError: Document is empty`. Profiles: `base`, `lucid_fix`, `metanorma`, `no_external_css`, `svg_1_2_rfc`, `svg_1_2_rfc_with_rdf`. `line` and `column` came back **nil** on every issue seen |
|
|
170
|
+
| png_conform | Location survives on **exactly one** path: `ValidationService.new(reader, path).validate` then `context.all_errors` → `{chunk_type: "IDAT", message: "CRC error in IDAT chunk", severity: :error, offset: 33}`. Both `validate_file` **and** `result.validation_result.errors` return `chunk_type: nil, chunk_offset: nil` for the same input, so it is the context object specifically, not the service generally |
|
|
171
|
+
| postscript | **No conformance basis.** `Postscript.parse` accepted an unmatched `}`, an undefined operator, a missing operand, and raw binary. Only an unterminated string raised |
|
|
172
|
+
| pdfrb structural | `Validator.validate` → `[]` on a valid doc. Failure modes are **not uniform**: a catalog-less document raises `Pdfrb::Error`, a `/Pages` pointing at a missing object raises `NoMethodError: undefined method '[]' for nil`, and a dangling unrelated reference returns an error *string*. So the allowlist cannot be a single class |
|
|
173
|
+
| vectory 0.12.0 re-verified | Re-measured 2026-08-18 on the installed line. All edges tried work (`svg→emf/eps/ps`, `emf→svg`, `eps→svg`); same-format conversion raises `NoMethodError`; `Emf.from_content` accepts SVG bytes and constructs happily. **D23's evidence reproduces exactly**: a linear gradient raises `Emfsvg::FormatError: unsupported SVG color: "url(#g)"` on `svg→emf`, and on `svg→eps` succeeds with the gradient silently dropped (the fill is emitted with no colour set, so it renders solid black, as in the table below) — the same feature, loud on one edge and silent on the other. That is why lossiness is per-conversion-from-content, not a per-edge label |
|
|
174
|
+
| svg_conform 0.2.2 result API | `SvgConform.validate_file(path, profile:)` returns a `ValidationResult` exposing `valid?`, `errors`, `warnings`, `error_count`, `warning_count`, `issue_count` and `profile`. There is **no `issues` method** — reading one raises `NoMethodError`. Each entry is a `SvgConform::Errors::ValidationIssue` with `message`, `element_name`, `line`, `column`, `fixable?`, `remediation`. Measured: `line` and `column` are **nil** on every issue, so SVG issues carry no position |
|
|
175
|
+
| svg_conform default profile (D21) | Re-confirmed on **0.2.2**: an ordinary red stroke fails the default profile with `"Color 'red' in attribute 'stroke' is not allowed in this profile"`, and passes under `base`. The same document with no stroke colour passes both |
|
|
176
|
+
| svg_conform wants a viewBox | Measured on 0.2.2: a valid SVG with `width`/`height` but no `viewBox` fails **both** the default and `base` profiles with `"SVG root element must have a viewBox attribute"`. Worth stating in the README — plenty of legitimate SVGs omit it, and `conform` will call them nonconformant |
|
|
177
|
+
| png_conform readers | `Services::ValidationService.new(reader, path)` needs a **`Readers::FullLoadReader.new(path)`**. Measured on 0.1.4: `FullLoadReader.new` takes a filepath **or IO**, so handing it raw bytes raises `ArgumentError: path name contains null byte`. `StreamingReader` fails on every input shape tried — `Errno::EINVAL` for a File in `"rb"`, `IOError` for a `StringIO`, `NoMethodError` for a path |
|
|
178
|
+
| **png_conform leaves a false verdict behind after a failed `validate`** | In all three `StreamingReader` cases above, `validate` raised **and** `context.all_errors` was left holding `{chunk_type: "SIGNATURE", message: "Invalid PNG signature"}` for a byte-perfect valid PNG. A handler that rescues the exception and reads the context gets a confident wrong answer rather than an error. Rescue must not fall through to reading the context |
|
|
179
|
+
| pdfrb profiles | `Conformance::PdfA.validate(doc, level: :a1b)` → `ValidationResult`. Read violations through **`.violations`** (or `.errors`/`.warnings`/`.infos`/`.violation_count`/`.passed?`), never by enumerating the result — see the trap below. Each is a `Violation` struct `{rule_id, message, object, severity, spec_clause}` plus `error?`/`warning?`, e.g. `6.1-2 "PDF/A requires /Catalog/Metadata XMP stream"`, severity `:error`. `PdfUA.validate(doc)` takes no level. Fully usable |
|
|
180
|
+
| **`ValidationResult` is Enumerable over its struct members, not its violations** | Measured on the original 0.7.10 line and the current 0.7.49 resolution: it is a keyword-init `Struct` including `Enumerable` with members `profile` and `violations`, so `result.size` is **2**, `result.first` is the String `"PDF/A-1"`, and `result.map(&:class)` is `[String, Array]`. On the fixture used, `size` happened to equal `violation_count`, so a naive `.size` looks right and is wrong everywhere else. Use `.violations` |
|
|
181
|
+
| pdfrb version drift | The contracts above were first measured on **0.7.10**; the gemspec now constrains `~> 0.7.23` and the current bundle resolves **0.7.49**. The focused PDF specs exercise `Document.open`, structural validation, mapped `Violation` objects, named profiles and their levels against that resolution. `VeraPdfBridge.validate` takes `(pdf_bytes, profile:)` — bytes, not a document. Ruby floor is still `>= 3.2.0` |
|
|
182
|
+
| pdfrb Arlington | `Arlington::Loader` offers only `list_object_names`, `object_definition`, `clear_cache!`. No upstream document runner or `Conformance` profile references it; Claricle now supplies the document walk described by D16 |
|
|
183
|
+
|
|
184
|
+
**What the delegates' conformance checks actually catch**
|
|
185
|
+
|
|
186
|
+
This is the most consequential measurement in the plan. Every delegate
|
|
187
|
+
was fed deliberately invalid files. **None of them is a complete
|
|
188
|
+
structural validator**, and three of them certify plainly broken input
|
|
189
|
+
as conformant.
|
|
190
|
+
|
|
191
|
+
| Format | Delegate check | Catches | **Misses — certifies as conformant** |
|
|
192
|
+
|---|---|---|---|
|
|
193
|
+
| PNG | png_conform | CRC errors (with chunk + offset), missing IDAT | **duplicate `IHDR`, duplicate `IEND`, trailing bytes after `IEND`** — all reported 0 errors, 0 warnings |
|
|
194
|
+
| SVG | svg_conform `base` | profile requirements on a well-formed document | **everything structural.** Unclosed elements, two root elements, trailing garbage, and raw binary (`\x00\x01\x02\xFF not xml`) each returned **0 errors** |
|
|
195
|
+
| EMF | `Emf#ok?` | some truncation points (raises `Emf::FormatError` at 77 B) | **almost everything else.** All measured `ok? == true, errors == []`: final EOF record removed; truncated to 116 B (1 record surviving against 16); declared record count rewritten to 0, 1, 16 or 4294967295; junk appended after the end. Note the intact file reports `header.n_records == 17` against 16 parsed records: the header counts itself, so the declared count is a usable cross-check as parsed records + 1, and Claricle makes it (`lib/claricle/handlers/metafile.rb:543-544`) |
|
|
196
|
+
| EPS/PS | `Postscript.parse` | unterminated strings only | unmatched `}`, undefined operators, missing operands, raw binary (D22) |
|
|
197
|
+
| PDF | `Validator.validate` | catalog/pages/MediaBox/references | it is a pre-write integrity check, not ISO 32000 conformance |
|
|
198
|
+
|
|
199
|
+
The pattern: these are **requirements checkers layered on an assumed-valid
|
|
200
|
+
parse**, not validators. They answer "does this document satisfy rule
|
|
201
|
+
set X", having already assumed it is a document. Claricle promised
|
|
202
|
+
users "is this file conformant", which is a different and stronger
|
|
203
|
+
question.
|
|
204
|
+
|
|
205
|
+
So `conform` could not be a thin delegation. Claricle now owns a
|
|
206
|
+
structural well-formedness layer per format ahead of the delegate and
|
|
207
|
+
documents precisely what each check covers. Decision **D23** settled
|
|
208
|
+
that design, and the SVG, PNG and EMF structural pre-passes are delivered.
|
|
209
|
+
|
|
210
|
+
**Lossiness is per-feature, not per-edge — D10 as written cannot work**
|
|
211
|
+
|
|
212
|
+
The same edge is lossless for one document and destructive for another,
|
|
213
|
+
so a static per-edge enum cannot express the truth. Measured on SVG→EMF
|
|
214
|
+
and SVG→EPS:
|
|
215
|
+
|
|
216
|
+
| SVG content | SVG→EMF | SVG→EPS |
|
|
217
|
+
|---|---|---|
|
|
218
|
+
| rect + line | clean, stable | clean, stable |
|
|
219
|
+
| linear gradient | **raises** `Emfsvg::FormatError: unsupported SVG color: "url(#grad)"` | succeeds — gradient **silently becomes solid black** |
|
|
220
|
+
| clip path | succeeds — clipped object **silently becomes invisible** | succeeds — clip **silently removed** |
|
|
221
|
+
| embedded raster | preserved but re-encoded | succeeds — image **silently deleted**. On a cold process it first raises `NameError: uninitialized constant Postsvg::Model::UnknownOperator`, then succeeds after an unrelated conversion warms it — a load-order bug that will look intermittent |
|
|
222
|
+
| text | succeeds; cycles are byte-stable after the first | succeeds, but geometry drifts on later cycles |
|
|
223
|
+
|
|
224
|
+
Issue #1 requires "no silent lossy conversions". Three of these lose
|
|
225
|
+
content with no error and no warning, which is exactly what it forbids.
|
|
226
|
+
A per-edge table would label SVG→EPS `:lossless` on the strength of a
|
|
227
|
+
rect and then silently discard a gradient. So lossiness has to be
|
|
228
|
+
decided per conversion by inspecting what the source actually contains,
|
|
229
|
+
or the edge label has to be pessimistic enough to be useless. **D23**
|
|
230
|
+
settled this with per-content feature-loss classification.
|
|
231
|
+
|
|
232
|
+
**Round-trip idempotence does not generalise**
|
|
233
|
+
|
|
234
|
+
The earlier claim — "every cycle after the first is byte-identical" —
|
|
235
|
+
was measured on one rect and one line. A repeated probe with a single
|
|
236
|
+
`<text>` element separates the two chains:
|
|
237
|
+
|
|
238
|
+
```
|
|
239
|
+
via emf: 9768016f 9768016f 9768016f c1==c2: true c2==c3: true
|
|
240
|
+
via eps: 82e95046 c1895d8a 26b657e3 c1==c2: false c2==c3: false
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
The EMF route stabilises after its first rewrite, including the text
|
|
244
|
+
geometry. The EPS route does not: its viewBox grows `0 0 100 50` →
|
|
245
|
+
`0 0 100 90` → `0 0 100 180`, while the text y coordinate moves
|
|
246
|
+
`-40` → `-90` → `-180`. One stable route does not establish a
|
|
247
|
+
general round-trip invariant when another supported route keeps
|
|
248
|
+
drifting, so idempotence still cannot serve as the correctness backbone
|
|
249
|
+
D11 proposed.
|
|
250
|
+
|
|
251
|
+
**Conversion from non-SVG sources, and two traps**
|
|
252
|
+
|
|
253
|
+
Measured with EMF, EPS and PS as sources (files produced by vectory
|
|
254
|
+
itself, so genuine):
|
|
255
|
+
|
|
256
|
+
- All nine cross-format edges from those three sources succeed on the
|
|
257
|
+
rect fixture, and `emf→svg→emf`, `eps→svg→eps`, `ps→svg→ps` are each
|
|
258
|
+
byte-stable across three cycles. The text probe above shows that this
|
|
259
|
+
does not generalise: its EMF route stabilises while its EPS route
|
|
260
|
+
drifts. Idempotence depends on both the content and the conversion
|
|
261
|
+
route.
|
|
262
|
+
- **Converting a file to the format it already is raises
|
|
263
|
+
`NoMethodError`** — `Vectory::Svg` has no `to_svg`, `Vectory::Emf` no
|
|
264
|
+
`to_emf`, and so on. `claricle convert x.svg --to svg` would surface
|
|
265
|
+
as an internal error and exit 4. It must be caught as an
|
|
266
|
+
`InvocationError` → exit 2, or defined as an explicit no-op copy.
|
|
267
|
+
- **`from_content` does not validate the content type.** Handing EPS
|
|
268
|
+
bytes to `Vectory::Emf`, or EMF bytes to `Vectory::Eps`, or SVG text
|
|
269
|
+
to `Vectory::Emf`, all construct successfully; the failure only
|
|
270
|
+
surfaces later at conversion. So a handler must never treat a
|
|
271
|
+
successful `from_content` as evidence the bytes are that format —
|
|
272
|
+
detection stays the sole authority.
|
|
273
|
+
|
|
274
|
+
**Conversion — all twelve vectory edges succeed, on a thin fixture**
|
|
275
|
+
|
|
276
|
+
| | → svg | → emf | → eps | → ps |
|
|
277
|
+
|---|---|---|---|---|
|
|
278
|
+
| **svg** | — | ok | ok | ok |
|
|
279
|
+
| **emf** | ok | — | ok | ok |
|
|
280
|
+
| **eps** | ok | ok | — | ok |
|
|
281
|
+
| **ps** | ok | ok | ok | — |
|
|
282
|
+
|
|
283
|
+
Round-trip behaviour over three cycles on both chains: conversion is
|
|
284
|
+
**deterministic** (same input, identical bytes) and the first pass
|
|
285
|
+
**rewrites** the file (213 B → 496 B). On the rect-and-line fixture
|
|
286
|
+
every later cycle was byte-identical to its predecessor — but that
|
|
287
|
+
property is fixture-specific and collapses on real content, as the
|
|
288
|
+
idempotence section above shows. Determinism is the only round-trip
|
|
289
|
+
property measured to hold generally.
|
|
290
|
+
|
|
291
|
+
## Delegate verification record — RUN 2026-08-13
|
|
292
|
+
|
|
293
|
+
Both previously-unverified delegates were
|
|
294
|
+
installed and inspected. Findings:
|
|
295
|
+
|
|
296
|
+
- **`postscript` 0.2.0**, Ruby `>= 3.0`. Entry points `Postscript.parse`
|
|
297
|
+
/ `.serialize` / `.tokenize`, plus `Postscript::Source.parse`. All
|
|
298
|
+
errors descend from `Postscript::Error` — `ParseError`, `LexError`,
|
|
299
|
+
`SyntaxError`, `UndefinedOperatorError`, `StackUnderflowError`,
|
|
300
|
+
`RecursionLimitError`, `SizeLimitError`. The exception-only reporting
|
|
301
|
+
the plan assumed is real.
|
|
302
|
+
- **`pdfrb` 0.7.10** was the originally verified release; the gemspec now
|
|
303
|
+
constrains `~> 0.7.23` and the current bundle resolves **0.7.49**. Its
|
|
304
|
+
Ruby floor remains `>= 3.2`, the highest delegate minimum; Claricle's
|
|
305
|
+
deliberate project floor is 3.3.
|
|
306
|
+
`Pdfrb::Document.open` exists as assumed. `Validator.validate` /
|
|
307
|
+
`validate!` are class methods and perform structural checks.
|
|
308
|
+
`Conformance` ships named standards: `PdfA` (A1–A4), `PdfUA`, `PdfX`,
|
|
309
|
+
`PdfVT`, `Pades`, `Ltv`, `Pdf2AF`, `TaggedPdf`, plus `Rule`,
|
|
310
|
+
`RuleSet`, `ValidationResult`, `Violation` and a `VeraPdfBridge`.
|
|
311
|
+
- **No upstream Arlington document runner exists.** `Pdfrb::Arlington`
|
|
312
|
+
ships `Loader`, `Predicate`, `ObjectDefinition`, `FieldDefinition`,
|
|
313
|
+
`Type`, `PdfVersion`, but `Arlington::Loader` offers only
|
|
314
|
+
`list_object_names` and `object_definition` — it loads the grammar,
|
|
315
|
+
it does not validate a document against it. No `Conformance` profile
|
|
316
|
+
references Arlington. Claricle therefore supplies that document walk
|
|
317
|
+
itself; D16 records the delivered scope.
|
|
318
|
+
|
|
319
|
+
Still true and still binding: **every delegate contract in the item
|
|
320
|
+
files is an assumption until executed.** Mark it ⚙ and run it before
|
|
321
|
+
writing a spec against it. Ten entries in the table above are what
|
|
322
|
+
happens otherwise.
|
|
323
|
+
|
|
324
|
+
## Decisions of record (2026-08-10, revised 2026-10-09)
|
|
325
|
+
|
|
326
|
+
Twenty-four decisions, **all settled**. Issue #1 specified the goal in
|
|
327
|
+
detail and we have measured the ground it stands on; choosing scope,
|
|
328
|
+
design and validation strategy inside that goal is our job, not the
|
|
329
|
+
author's.
|
|
330
|
+
|
|
331
|
+
Six of them narrow something the issue explicitly named, so they are
|
|
332
|
+
**reported** in the issue rather than decided silently — the author can
|
|
333
|
+
object to any of them:
|
|
334
|
+
|
|
335
|
+
| Decision | What narrowed |
|
|
336
|
+
|---|---|
|
|
337
|
+
| D2 | `Image#inspect` renamed to `#inspection` (Ruby owns `inspect`) |
|
|
338
|
+
| D11 | Byte-identical round trips are unreachable; idempotence is route- and content-specific, not general |
|
|
339
|
+
| D14 | WMF unsupported — no upstream parser |
|
|
340
|
+
| D17 | `Inspection#valid` becomes `parse_status` |
|
|
341
|
+
| D18 | EMF+ payload never validated — no upstream parser |
|
|
342
|
+
| D22 | EPS/PS conform unsupported — the parser certifies raw binary |
|
|
343
|
+
|
|
344
|
+
Everything else is settled and needs no discussion:
|
|
345
|
+
D5 (pdfrb's 3.2 minimum and libpng transitivity were measured; Claricle's 3.3 floor is deliberate), D15 (dimensions
|
|
346
|
+
follow the shape the issue itself sketched), D19 (positional-or-glob
|
|
347
|
+
covers both the issue's examples and ambiguous filenames), D20 (PNG
|
|
348
|
+
locations turned out available), D21 (svg_conform's own default rejects
|
|
349
|
+
ordinary SVGs), D23 (the issue asks for canonical conformance, so we
|
|
350
|
+
own the structural pre-pass; v1 scope is engineering sequencing), and
|
|
351
|
+
D10 folded into D23.
|
|
352
|
+
|
|
353
|
+
| # | Decision | Status |
|
|
354
|
+
|---|----------|--------|
|
|
355
|
+
| D1 | Four vertical-slice items; every item documents the commands and API it ships in README.adoc. **Amended 2026-08-18: an item may be split across several stacked PRs** — item 01 reached ~3,100 lines, which reviews as a skim rather than a review, so the author chose reviewability over one-PR-per-item. The constraint that survives: any PR that removes a documented command must carry the README correction with it, so no PR lands describing something it just deleted. 04 still does the full rewrite | settled — amended |
|
|
356
|
+
| D2 | `Image#inspect` → `Image#inspection` (`Object#inspect` stays Ruby's debugging protocol; no module-level facade — it would shadow `Module#inspect`) | settled — report: renames an API the issue named |
|
|
357
|
+
| D3 | Plain handler subclasses + `formats` declaration macro; frozen derived registry, no runtime mutation, no self-registration | settled |
|
|
358
|
+
| D4 | All unified models are lutaml-model classes. The three-segment constraint is **`~> 0.8.32`**, currently resolved to **0.8.97** — `~> 0.8` would admit 0.9 through 0.99 and contradict D13. Model invariants (severity enum, non-nil message) are enforced at construction **and** deserialization. `Report#valid` is derived, not stored, so appending an issue can't leave it stale | settled |
|
|
359
|
+
| D5 | Hard deps on all delegates; Claricle's deliberate Ruby floor is **3.3**. The current pdfrb resolution is **0.7.49** and retains the highest delegate minimum at 3.2 (everything else tops out at 3.1). No **direct** `libpng` dependency — it is already in the tree via `vectory → emfsvg` and emfsvg uses it for embedded images, so a direct dep would be redundant, not an exclusion. Heavy gems required lazily inside handlers; `emf` (bindata-only, powers the detector) is the sole eager require | settled — no sign-off needed |
|
|
360
|
+
| D6 | Keep Thor at `>= 1.2, < 2`; 1.0 and 1.1 fail to load on supported Ruby 3.4 because they reference the removed `DidYouMean::SPELL_CHECKERS`. `Runner` dispatches directly so operation errors reach the exit-code map. The CLI treats EPIPE as success only around actual `help` and `version` output | settled |
|
|
361
|
+
| D7 | Hand-rolled detector (no marcel): PNG signature, `%PDF-`, `%!PS` + `EPSF` first-line split, `Emf.detect_format` **wrapped in a `rescue Emf::FormatError`** so an unrecognised file continues to the next probe, and encoding-aware XML root detection for SVG — decode the BOM/declaration, resolve the root QName, require the SVG namespace, and disable external entity and DTD expansion (XXE). The 4096-byte binary regex is dropped as proven insufficient | settled |
|
|
362
|
+
| D8 | Tri-state `valid`, decided in order: any `error` → `no`; else any `warning` → `suspicious`; else (`info` only, or no issues at all) → `yes`. `info` never downgrades validity. Non-strict `conform?` passes `yes` AND `suspicious`; `--strict`/`strict:` requires `yes` | settled |
|
|
363
|
+
| D9 | Conversion via vectory. **All twelve edges between svg/emf/eps/ps were measured working**, so the earlier "only these are verified" hedge is retired — v1 exposes the full matrix rather than an arbitrary subset. PNG/PDF stay inspect+conform only (vectory has no class for them). EMF+ handling is **not settled here** — see D18. What is measured is only that `Emf::EmfPlus::Parser.call` raises "EMF+ parser not yet implemented", which constrains the options without choosing among them | settled |
|
|
364
|
+
| D10 | **Static per-edge lossiness cannot work — measured.** The same edge is clean for a rect and destructive for a gradient, a clip path or an embedded image, and three of those lose content with no error at all — precisely the "silent lossy conversion" issue #1 forbids. Lossiness must be decided per conversion from what the source document actually contains, with a per-edge table serving only as a pessimistic floor. The `:lossless`/`:lossy`/`:unknown` vocabulary stands; the static-table mechanism does not. Folded into D23's scope | **superseded — see D23** |
|
|
365
|
+
| D11 | Round-trip: **byte identity is unreachable and idempotence is not a general invariant.** Byte identity against the original never held — the first pass always rewrites. The rect-and-line fixture stabilises on the measured routes. With a `<text>` element, the EMF route also stabilises after its first rewrite, but the EPS route changes on every cycle and its geometry drifts. One stable route cannot establish the issue's proposed correctness backbone across the supported matrix. What remains: determinism (same input → identical bytes, measured true), same-format parse→serialize identity for EMF, and per-feature semantic assertions over a fixture corpus. The author chooses what replaces the backbone | settled — report: the issue's stated general correctness backbone is unreachable |
|
|
366
|
+
| D12 | Batch argument handling is defined once, by D19 — a positional is a literal path when it names an existing file and a glob otherwise, with `--pattern` forcing glob interpretation. `--pattern` **adds to** any positionals rather than replacing them; the combined set is deduplicated by realpath and processed in sorted order. `Dir.glob(pattern).sort`; zero matches → 2; failures don't stop the batch; exit = highest code. Every batch-capable JSON output is **always an array**, including a single result. Each slot is one `Models::BatchItem{path, status, exit_code, result, error}` envelope — never a mixed `Report`/failure array, so `jq '.[].result.valid'` can't silently return null for an operational failure. `--output` single-source only; derived names, `--force` to overwrite; `--output -` = bytes-only stdout, rejects `--json`. ONE batch helper (built in 03, reused in 04) | settled |
|
|
367
|
+
| D13 | Constrain against released delegate versions and **stop calling `~>` a pin** — `~> 0.7` admits every 0.x below 1.0, and this gem commits no lockfile, so a clean build can install an unreviewed API. Use three-segment constraints on the reviewed line. Version floors are stated once, in the item that adds the dependency; D4 does not restate them | settled |
|
|
368
|
+
| D13a | **The measured conversion graph is not reproducible without pinning the engines.** vectory 0.12.0 permits any pre-1.0 `emfsvg` and `postsvg`, and this gem commits no lockfile, so a clean install can silently swap the code that produced every conversion measurement in this plan. Either constrain the measured engine versions directly or add a CI check that fails when resolved versions drift from the ones recorded here | settled |
|
|
369
|
+
| D14 | **WMF leaves v1 entirely — proposed, not forced.** Missing upstream support does not by itself decide the question; implementing a WMF parser, or waiting for one, are defensible alternatives, exactly as D22 acknowledges for PostScript. What is forced is that it cannot ship *through `emf` 0.1.0*. Released `emf` 0.1.0 raises `WMF parser not yet implemented`, so no WMF operation can ship through the chosen delegate. The detector still recognises `:wmf`; no handler registers it, so it raises `UnsupportedFormat` → exit 3, which is the honest answer. Note the issue *body* does ask for WMF conformance even though the acceptance checklist omits it | settled — report: narrows the format list, though the issue already prescribes UnsupportedFormat here |
|
|
370
|
+
| D15 | **Dimensions follow the shape issue #1 already sketched** — `#<Inspection format=:emf, width=800, height=600, dpi=96, meta={...}>`. So `width`/`height` are plain numbers in the format's own device or user units, `dpi` is a separate nullable field carrying physical resolution where the format records it, and everything format-native goes in `meta`. Sources measured: EMF `header.device_pixels` with `device_mm` (100×50 px against 26×13 mm, so dpi derives); PNG `ImageInfo` from IHDR plus pHYs; SVG declared width/height with the viewBox in `meta`; EPS/PS BoundingBox. Normalize to a consistent numeric type — vectory returns Integer for SVG and Float for EPS. **Cross-format dimension equality is never asserted**, because `3×1` as SVG and `96×48` as EPS are both correct answers about different things | settled |
|
|
371
|
+
| D16 | PDF conformance combines `Pdfrb::Validator.validate` structural findings with Claricle's Arlington document walk. `Handlers::PdfArlington` drives pdfrb's Arlington tables from the Catalog down, checking required keys, value types and enumerated names; this fills the upstream gap where pdfrb provides the grammar but no runner. Named standards remain opt-in via `--profile NAME`, mapping to `Pdfrb::Conformance::{PdfA,PdfUA,PdfX,PdfVT,Pades,Ltv,Pdf2AF,TaggedPdf}` and their measured `Violation{rule_id, message, object, severity, spec_clause}` results | settled — Arlington delivered in PR #55 |
|
|
372
|
+
| D17 | `Inspection#valid` becomes `parse_status` (`"ok"`/`"failed"`), and inspection stops making any validity claim — it would otherwise mean five different things across five delegates, and vectory parses SVG in Nokogiri RECOVER mode so a repaired file would read as valid. Issue #1 names a `valid` field on inspection, so this changes the public model | settled — report: renames a field the issue named |
|
|
373
|
+
| D18 | EMF+ is surfaced as inspection metadata only — **proposed, same reasoning as D14**: the released parser raises "not yet implemented", but writing one is a defensible alternative the author may prefer. its payload is never validated, because the released EMF+ parser is unimplemented. Issue #1 includes EMF+ conformance | settled — report: narrows conformance coverage |
|
|
374
|
+
| D19 | CLI batch accepts `FILE...` positionals **and** honours the issue's `PATTERN` examples: a positional is a literal path when it names an existing file, and a glob otherwise. `--pattern` forces glob interpretation for the ambiguous case (a filename legitimately containing glob characters). This satisfies the issue's CLI as written without the unquoted-glob trap | settled — supports both, so nothing is deviated from |
|
|
375
|
+
| D20 | PNG issues carry full `location` — `{chunk, byte_offset}` — via `ValidationService.new(reader, path)` then `context.all_errors`, measured returning `{chunk_type: "IDAT", severity: :error, offset: 33}`. Only the `validate_file` convenience wrapper discards them, so the handler must not use it. Issue #1's location contract is met for PNG | settled — an earlier draft wrongly escalated this |
|
|
376
|
+
| D21 | **SVG conform defaults to the `base` profile, not svg_conform's own default.** Measured: svg_conform's default rejects an ordinary red stroke under `color_restrictions`; the same file is clean under `base`. Shipping the delegate's default would tell users their valid SVG is broken on their first run. Other profiles (`metanorma`, `svg_1_2_rfc`, `svg_1_2_rfc_with_rdf`, `no_external_css`, `lucid_fix`) are opt-in through the same `--profile` flag as PDF. `Report#profile` records which ran | settled — the delegate's default is simply wrong for a generic tool |
|
|
377
|
+
| D22 | **PostScript/EPS conformance ships as unsupported in v1.** Measured: `Postscript.parse` accepted an unmatched `}`, an undefined operator, a missing operand and raw binary; only an unterminated string raised. There is no conformance check to delegate to, so "clean parse = conformant" would certify garbage. `conform` on `:eps`/`:ps` raises `UnsupportedFormat` → exit 3 until either upstream adds real checking or we write DSC structural checks ourselves. Inspection and conversion are unaffected | settled — report: removes an operation the issue listed |
|
|
378
|
+
| D23 | **Claricle owns a structural well-formedness pre-pass.** No delegate is a complete validator (see the coverage table) and svg_conform certifies raw binary as a conformant SVG. Issue #1 asks us to "validate that a file conforms to its format's canonical specification", so canonical conformance is the target and the delegates cannot reach it alone. **v1 covers the measured holes** — encoding and XML well-formedness for SVG, chunk-sequence rules for PNG, EOF and trailing-byte checks for EMF — and `conform` documents per format exactly what it checks. Fuller per-format validators are follow-up work. Sequencing is an engineering call, not a product one; what the author needs to know is the coverage gap, which the README will state | settled |
|
|
379
|
+
|
|
380
|
+
## Exit codes (all commands)
|
|
381
|
+
|
|
382
|
+
| Code | Meaning |
|
|
383
|
+
|------|---------|
|
|
384
|
+
| 0 | success / conformance passed |
|
|
385
|
+
| 1 | conformance failed (issues found) |
|
|
386
|
+
| 2 | invocation error (bad args, file not found) |
|
|
387
|
+
| 3 | unsupported format or conversion |
|
|
388
|
+
| 4 | internal error / parser crash |
|
|
389
|
+
|
|
390
|
+
Mapping rules the runner must honor: a dedicated `InvocationError` maps
|
|
391
|
+
to 2 — do not blanket-map `ArgumentError`, since a delegate's accidental
|
|
392
|
+
`ArgumentError` is an internal defect, not bad user input. `LoadError`
|
|
393
|
+
maps to 4 and must be caught explicitly, because `ScriptError` is not a
|
|
394
|
+
`StandardError` and would otherwise escape as process status 1 — the
|
|
395
|
+
status reserved for nonconformance. Each delegate gets an allowlist of
|
|
396
|
+
expected malformed-input exceptions, per operation and per stage.
|
|
397
|
+
**What an allowlisted failure means depends on the operation**: under
|
|
398
|
+
`conform` it is nonconformance and exits 1; under `inspect` it is
|
|
399
|
+
`parse_status: "failed"` and exits 0, because reporting that a file
|
|
400
|
+
doesn't parse is a successful inspection. Anything off the allowlist is
|
|
401
|
+
an unexpected fault and exits 4 under either. Without that list, 1
|
|
402
|
+
versus 4 is decided by which delegate happens to report via a return
|
|
403
|
+
value and which via an exception. `Runner.run` returns an integer; only
|
|
404
|
+
`exe/claricle` calls `exit`. Nothing raises `SystemExit` through the
|
|
405
|
+
runner.
|
|
406
|
+
|
|
407
|
+
## Format × operation target matrix
|
|
408
|
+
|
|
409
|
+
| Format | Inspect | Conform | Convert to |
|
|
410
|
+
|--------|---------|---------|------------|
|
|
411
|
+
| png | 02 | 03 | — |
|
|
412
|
+
| svg | 02 | 03 (`base` profile, D21) | emf, eps, ps (04) |
|
|
413
|
+
| emf | 02 | 03 | svg, eps, ps (04) |
|
|
414
|
+
| wmf | — | — | — (D14: recognised, never handled, exit 3) |
|
|
415
|
+
| eps / ps | 02 | — (D22: no basis exists) | svg, emf, and each other (04) |
|
|
416
|
+
| pdf | 02 | 03 (structural + Arlington; profiles via `--profile`, D16) | — |
|
|
417
|
+
|
|
418
|
+
Every cell above was executed against a real fixture on 2026-08-13 —
|
|
419
|
+
see the measured-contracts section. Two deliberate holes, both with a
|
|
420
|
+
decision behind them: WMF entirely (D14, no upstream parser) and
|
|
421
|
+
EPS/PS conform (D22, no conformance basis exists).
|
|
422
|
+
|
|
423
|
+
All twelve conversion edges ship, because all twelve were measured
|
|
424
|
+
working. The earlier subset was caution, not evidence.
|
|
425
|
+
|
|
426
|
+
**`capabilities` tracks what has shipped, never what is planned —
|
|
427
|
+
because it is derived, not declared.** A handler's capabilities are
|
|
428
|
+
read off the operations it overrides on `Handlers::Base`, so
|
|
429
|
+
`claricle formats` is truthful at every commit, including the commits
|
|
430
|
+
inside an item. Amended 2026-08-18: an earlier draft used a
|
|
431
|
+
`capabilities` class macro. A declaration is a second place to state
|
|
432
|
+
what the code already says, and it can advertise an operation that is
|
|
433
|
+
still `Base`'s raising stub — the exact lie the macro existed to
|
|
434
|
+
prevent. Deriving makes that unrepresentable.
|
|
435
|
+
|
|
436
|
+
So 02 gets `inspect` by implementing `inspection`; 03 gets `conform`
|
|
437
|
+
the same way, handler by handler; 04 gets `convert`. Item 04's
|
|
438
|
+
per-handler **target list stays a real declaration**, because a list of
|
|
439
|
+
targets is data rather than a boolean. The `formats` output spec still
|
|
440
|
+
moves with the handlers, since the command's expected output changes as
|
|
441
|
+
operations land.
|
|
442
|
+
|
|
443
|
+
## Acceptance criteria → items
|
|
444
|
+
|
|
445
|
+
- [x] `Claricle::Image.from_path(...).inspection` for PNG, SVG, EMF, PS/EPS, PDF → 02 (WMF removed per D14)
|
|
446
|
+
- [x] `Claricle.conform?` delegates for png, svg, emf, pdf → 03; eps/ps refused per D22
|
|
447
|
+
- [x] `Claricle.convert` covers EMF↔SVG, PS/EPS↔SVG, SVG→EPS → 04
|
|
448
|
+
- [x] CLI inspect/conform/convert, human + JSON → 02/03/04
|
|
449
|
+
- [x] `claricle formats` support matrix → 02 (command, inspect only), grows in 03 and 04, complete at 04 (`spec/claricle/cli_spec.rb`, examples "prints what png can actually do", "claims conform and convert only where a handler implements them", and "emits a fixed row shape under --json")
|
|
450
|
+
- [x] Handler registry documented; adding a format costs ONE handler class in `handlers/` (`lib/claricle/registry.rb` loads that directory and derives `HANDLER_CLASSES`; README "Adding a format"; `spec/claricle/one_class_per_format_spec.rb`, example "picks up a dropped-in handler class for detect, dispatch, formats and convert"). → 01 (code), 01 step 7b (README)
|
|
451
|
+
- [x] Exit codes match the matrix → 01 (runner, all rows incl. 4), verified per command in 02/03/04; conform and convert each reach 4 through `spec/fixtures/faulting_handler/boom.rb` (`spec/claricle/cli_spec.rb`, examples "exits 4 when the handler raises outside its allowlist, and says what"), #63
|
|
452
|
+
- [x] Conformance specs on canonical fixtures → 03 (`spec/fixtures/canonical/**`, `canonical_fixtures_spec.rb`, #64); round-trip specs per D11 → 04 are done (`determinism_spec.rb`, `emf_identity_spec.rb`)
|
|
453
|
+
- [x] `compress` stub removed → 01
|
|
454
|
+
- [x] README.adoc and gemspec truthful → 01 (full baseline), extended per item, final rewrite 04 (`documentation_spec.rb`)
|
|
455
|
+
|
|
456
|
+
Two promises the plan must own rather than escalate:
|
|
457
|
+
|
|
458
|
+
- Issue #1 names `Claricle.detect(io)`, but 01 defines only
|
|
459
|
+
`Detector.detect(bytes)` / `detect_path`. **01 adds the public
|
|
460
|
+
`Claricle.detect` facade accepting an IO or a String.** No sign-off
|
|
461
|
+
needed — it is a missing method, not a design question.
|
|
462
|
+
- The byte **range** the issue asks for is delivered by `Location`'s
|
|
463
|
+
`byte_offset + byte_length` half-open pair (01). Done: measured on
|
|
464
|
+
main, `lib/claricle/models/location.rb` carries both, each refused if
|
|
465
|
+
negative or non-integer (`spec/claricle/models_spec.rb`, examples
|
|
466
|
+
"refuses a negative byte range at both doors" and "refuses a non-Integer range").
|
|
467
|
+
|
|
468
|
+
Promises narrowed by a signed-off decision, and traceable to it: WMF
|
|
469
|
+
support (D14), EMF+ payload conformance (D18), EPS/PS conformance (D22)
|
|
470
|
+
and cross-format round trips (D11). Arlington is delivered by D16, and
|
|
471
|
+
PNG locations are **not** in this list
|
|
472
|
+
— D20 restored them, so that promise is met rather than narrowed.
|
|
473
|
+
|
|
474
|
+
## Global constraints (every item)
|
|
475
|
+
|
|
476
|
+
- Ruby >= 3.3; CI matrix `['3.3', '3.4', '4.0']`; RuboCop `TargetRubyVersion: 3.3`.
|
|
477
|
+
The gemspec admits Ruby 3.4+, so CI should cover the newest stable line
|
|
478
|
+
it actually allows.
|
|
479
|
+
- Claricle's own code is pure Ruby; delegates are not (vectory pulls in
|
|
480
|
+
Nokogiri, a native extension). The issue's "to the extent possible"
|
|
481
|
+
governs. Measured 2026-10-09 on be34132: 186 fixtures through conform and
|
|
482
|
+
convert made 0 subprocess calls (positive control 3/3).
|
|
483
|
+
- lutaml-model for every serialized model and CLI result shape — no
|
|
484
|
+
hand-rolled `to_h`/`from_h`. `formats --json` uses
|
|
485
|
+
`Models::FormatCapability` (#72).
|
|
486
|
+
- `frozen_string_literal: true` in every file. GHA runs the default task
|
|
487
|
+
on Ruby 3.3/3.4/4.0 before merge; local work runs only the specs changed
|
|
488
|
+
by that PR.
|
|
489
|
+
- Real specs, no `double()`; fixtures from the delegates' canonical corpora.
|
|
490
|
+
Met: `grep -rn "double(\|instance_double\|class_double" spec/` finds only a
|
|
491
|
+
comment (#63); canonical fixtures landed in #64. `allow(...).to receive`
|
|
492
|
+
partial stubs remain in `cli_spec.rb` (the EPIPE examples such as
|
|
493
|
+
"maps an inspect operation's broken pipe to 4"); they
|
|
494
|
+
are not `double()`.
|
|
495
|
+
- Commit subjects: one line, under 10 words. A forward rule only: 38 of
|
|
496
|
+
268 commits already on main break it, and pushed history is not rewritten.
|
|
497
|
+
- ~~Every item passes the full Pre-Push Review Chain before push
|
|
498
|
+
(thermo-nuclear → dependency-contract-check → execution-diff → Codex → copilot-review).~~
|
|
499
|
+
Superseded by Hassan 2026-10-09: use TDD, run only changed specs, open
|
|
500
|
+
the PR, and merge after GHA is green. No extra review chain.
|
|
501
|
+
- Out of scope entirely: compression, rendering/rasterization, editing,
|
|
502
|
+
`arroolio`, WMF conversion, `libpng`, gem release (maintainer-driven, post-04).
|