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,208 @@
|
|
|
1
|
+
# 03 — Conform: unified reports, `Claricle.conform?`, batch helper
|
|
2
|
+
|
|
3
|
+
Can start: after 02. D16 (PDF scope), D22 (EPS/PS) and D23 (pre-pass)
|
|
4
|
+
settle what this item builds.
|
|
5
|
+
|
|
6
|
+
## Problem
|
|
7
|
+
|
|
8
|
+
Each delegate reports validity in a different shape (typed issues,
|
|
9
|
+
error strings, exceptions, or parse-error lists). The issue demands one
|
|
10
|
+
typed `Report` with uniform `Issue{severity, code, message, location}`
|
|
11
|
+
across every format that has a conformance basis, a module-level API,
|
|
12
|
+
and a batch-capable CLI
|
|
13
|
+
command that exits 1 on nonconformance.
|
|
14
|
+
|
|
15
|
+
## Design
|
|
16
|
+
|
|
17
|
+
Mapping layer per handler — `location` is nullable throughout;
|
|
18
|
+
synthetic stable codes where upstream has none; never invent
|
|
19
|
+
coordinates:
|
|
20
|
+
|
|
21
|
+
| Delegate result | → Issue mapping |
|
|
22
|
+
|---|---|
|
|
23
|
+
| png_conform, **not** via `validate_file` | Build `Readers::FullLoadReader.new(path)`, pass it to `ValidationService.new(reader, path)`, call `validate`, then read **all three buckets — `context.all_errors`, `all_warnings`, `all_info`**. Location lives on the **context object only**: both `validate_file` and `result.validation_result.errors` return `chunk_type: nil, chunk_offset: nil` for the same input. `all_errors` alone silently drops warnings and info, which would break D8's tri-state and `--strict`. Measured error shape: `{chunk_type: "IDAT", message: "CRC error in IDAT chunk", severity: :error, offset: 33}`. Map `severity` as-is, `location: {chunk: chunk_type, byte_offset: offset}` (offset may be nil, e.g. a missing IEND). **`byte_length` has no source here** — png_conform reports a start offset only, so the issue's "byte range" is delivered as offset-plus-chunk for PNG, and `byte_length` stays nil unless the pre-pass (D23) computes it from the chunk header, which it can. `code` synthesized stably since there is no upstream type. **Never use `validate_file`** — it returns a `FileAnalysis` with chunk and offset discarded. See D23: this path misses duplicate `IHDR`/`IEND` and trailing bytes entirely |
|
|
24
|
+
| svg_conform `ValidationIssue` | Severity is `severity || type` — `ErrorTracker#add_error` hardcodes `type: :error`, and `severity` was measured **nil** on some issues, so neither field alone is reliable. Normalize `:validity_error` to `error`. Collect errors, warnings **and** validity errors, not just one bucket. `code: requirement_id`. `line` and `column` were nil on every issue measured, so `location` is usually nil; populate only when present. Run under the `base` profile (D21), and call `Profiles.clear_cache!` or enumerate eagerly — `available_profiles` collapses to just the last-loaded profile after any validation. Note UTF-32 input was measured passing every profile silently, so the structural pre-pass (D23) must settle encoding before profile validation runs — but note D23: `base` returns 0 errors for raw binary, so it cannot stand alone |
|
|
25
|
+
| emf | `Emf.parse` → `Metafile` exposing `ok?` and `errors`. **`ok?` is not sufficient on its own**, and truncation is the sharpest case: of the 164 truncations of `valid.emf` that parse without raising, **16 report `ok? == true`** (measured 2026-09-08, same sweep as the allowlist figures below). `after_eof.emf` shows it without truncation too — bytes past the EOF record, measuring `ok? == true, errors == [], records == 16`, the same numbers as the intact file. (Re-measured 2026-09-08. The earlier wording cited a removed-EOF file at `records == 15`; no fixture on disk reproduces that today, and both `short_eof` fixtures measure `ok? == false` with one error. The conclusion is unchanged, the example is now one that reproduces.) Require an EOF record on top of `ok?` (D23). **Compare the declared record count to the parsed body count plus one** — `EMR_HEADER` counts itself, while `Metafile#records` contains only the records that follow it; the handler enforces this invariant both after a successful parse and through an independent structural count when parsing fails. Map `errors` (message, offset, record_code) to `severity: "error"`, `code: "EMF_PARSE"`, `location: {byte_offset: offset}` — `byte_length` stays nil unless the pre-pass computes it. On severity generally: png_conform emits a SYMBOL (`:error`, measured), `Models::Issue::SEVERITIES` is `%w[error warning info]`, and "map as-is" is safe because lutaml coerces — `Issue.new(severity: :error)` stores the String `"error"` and round-trips, while `:nonsense` is still refused with `Lutaml::Model::ValidationError`. The enum guards either way; the coercion is what makes the symbol harmless, and it is worth knowing rather than rediscovering. Truncation raises **`IOError`** at some cut points and `Emf::FormatError` at others — both belong on the conform allowlist, or a corrupt file exits 4 instead of 1. Swept 2026-09-08 across all **364** cut points of `valid.emf`: `IOError` 96, `Emf::FormatError` 88, `EOFError` 16, no exception at 164. **`EOFError` is a subclass of `IOError`**, so rescuing that pair covers every truncation — verified by rescuing exactly the pair and finding nothing escapes. **But the pair is NOT the whole allowlist**: `bad_comment.emf`, already on disk, raises **`NoMethodError`** from inside the gem, which the pair does not catch. **Do not add it to the allowlist**: it is a gem defect, not a corrupt-input signal. In `emf-0.1.0/lib/emf/emr/parser.rb`, `Emr::Parser#extract_emf_plus` guards on the DECLARED `cb_data >= 4` and then ORs together `data.getbyte(0)`, `data.getbyte(1)`, `data.getbyte(2)` and `data.getbyte(3)` without checking the actual bytesize, so each returns nil and the shifts raise. It is called from `Emr::Parser#call`, OUTSIDE the `rescue StandardError` in `#read_record` that would otherwise have reported the same fault as a `ParseError`. Cited by method name rather than by line number, deliberately: this is another gem's source, so any line reference here rots on its next release. Reachable from ordinary corruption, not just this fixture: 40,000 mutated documents produced 4 `NoMethodError` hits, every one in that function. Report it upstream; do not widen the rescue to swallow it. And note the pre-pass is still needed once it is fixed — with a bytesize guard applied, `bad_comment.emf` comes back `ok? == true, errors == []`, because the gem has no way to say "a comment declares more data than it holds" |
|
|
26
|
+
| postscript | **No mapping — EPS/PS conform is unsupported in v1 (D22).** `Postscript.parse` was measured accepting an unmatched `}`, an undefined operator, a missing operand and raw binary; only an unterminated string raised. There is nothing here that answers "does this conform?", so the handler declares no `conform` capability and the registry raises `UnsupportedFormat` → exit 3 |
|
|
27
|
+
| pdfrb `Validator.validate` | Structural, class method taking a `Document`. Measured `[]` on a valid file. **Failure reporting is not uniform** — a catalog-less document raises `Pdfrb::Error`, a `/Pages` reference to a missing object raises `NoMethodError`, and a dangling unrelated reference comes back as an error string. The allowlist must cover the raising cases including `NoMethodError` from this call path specifically, which means wrapping the call rather than allowlisting `NoMethodError` globally. When it returns strings: `severity: "error"`, `code: "PDF_STRUCTURE"`, `message` the string, `location: nil` |
|
|
28
|
+
| pdfrb `Conformance::*` `Violation` | Only via `--profile`. Measured `PdfA.validate(doc, level: :a1b)` → `ValidationResult` whose violations are `Violation{rule_id, message, object, severity, spec_clause}`, e.g. `rule_id="6.1-2", message="PDF/A requires /Catalog/Metadata XMP stream", object="Catalog", severity=:error`. So `code: rule_id`, `message` as-is (it has real prose — no composition needed), severity as-is, `location: {node_path: object}` — note `object` was the string `"Catalog"`, not a path, so treat it as an opaque label. **`PdfA`/`PdfX`/`PdfVT` take `level:`; `PdfUA` does not** — the adapter is per-profile |
|
|
29
|
+
|
|
30
|
+
- **One `--profile` flag, two formats.** PDF and SVG both have a
|
|
31
|
+
generic check and a set of named stricter standards, so they share
|
|
32
|
+
one mechanism rather than inventing two.
|
|
33
|
+
- PDF: generic `conform` runs `Validator.validate` (structural).
|
|
34
|
+
`--profile pdf_a --level a1b` reaches `Pdfrb::Conformance::*`.
|
|
35
|
+
**A profile run must pass structural validation first** — measured
|
|
36
|
+
on a catalog-less file, most profiles raise a raw `NoMethodError`
|
|
37
|
+
while `Pdf2AF` returns zero violations and "passes". Never report a
|
|
38
|
+
profile pass for a document that is not structurally sound.
|
|
39
|
+
**Claricle validates the level itself**: `PdfA`, `PdfX`, `PdfVT`
|
|
40
|
+
and `Pades` accept `level:`; `PdfUA`, `Ltv`, `Pdf2AF` and
|
|
41
|
+
`TaggedPdf` do not. An invalid level is **silently ignored**
|
|
42
|
+
upstream — `level: :nonsense` returned the same result as
|
|
43
|
+
`level: :a1b` — so an unrecognised level must be rejected here with
|
|
44
|
+
`InvocationError`, and passing `--level` to a profile that takes
|
|
45
|
+
none is equally an error. Enumerate the accepted names and levels
|
|
46
|
+
in one table; do not pass user input through unchecked.
|
|
47
|
+
- SVG: generic `conform` runs the **`base`** profile (D21).
|
|
48
|
+
`--profile metanorma`, `svg_1_2_rfc`, `svg_1_2_rfc_with_rdf`,
|
|
49
|
+
`no_external_css`, `lucid_fix` reach the stricter sets.
|
|
50
|
+
- An unknown profile name, or `--profile` on a format with no profile
|
|
51
|
+
system, is an `InvocationError` → exit 2.
|
|
52
|
+
- `Report#profile` always records what actually ran, so no result is
|
|
53
|
+
ambiguous about which standard it was judged against.
|
|
54
|
+
"Valid PDF" and "valid PDF/A" are different claims, and so are
|
|
55
|
+
"well-formed SVG" and "SVG that satisfies the RFC colour rules".
|
|
56
|
+
Neither is silently substituted for the other.
|
|
57
|
+
- **Arlington is delivered by Claricle's document walk.** Released pdfrb
|
|
58
|
+
exposes `Arlington::Loader`, predicates and definitions — the grammar,
|
|
59
|
+
not a runner. `Handlers::PdfArlington` supplies the missing walk from
|
|
60
|
+
the Catalog down and maps required-key, type and enumerated-name
|
|
61
|
+
findings into Claricle issues (#55). Generic PDF conformance combines
|
|
62
|
+
those findings with the structural validator; named profiles remain
|
|
63
|
+
opt-in.
|
|
64
|
+
- **`conform` still means different things per format, so say so.**
|
|
65
|
+
PNG gets chunk-level validation, SVG the `base` requirement set, EMF
|
|
66
|
+
a parse plus `ok?`, PDF a structural check. Those are not comparable
|
|
67
|
+
claims even after D22 removed the worst offender. `Report#profile`
|
|
68
|
+
and the validator version record what actually ran, and the README
|
|
69
|
+
states it per format rather than implying uniformity.
|
|
70
|
+
- Module API: `Claricle.conform?(path = nil, pattern: nil,
|
|
71
|
+
strict: false, profile: nil)` — exactly one of positional path or
|
|
72
|
+
`pattern:` (`InvocationError` otherwise; the issue's examples use
|
|
73
|
+
both shapes); `pattern:` is true only if every matched file conforms.
|
|
74
|
+
`Claricle.conformance_report(path, profile: nil)` → `Models::Report`.
|
|
75
|
+
`profile:` is accepted for PDF and SVG (D16, D21); on any other
|
|
76
|
+
format, or with a name that format does not define, it raises
|
|
77
|
+
`InvocationError`. `Report#profile` records which one ran, so a
|
|
78
|
+
result is never ambiguous about what it checked.
|
|
79
|
+
- **A batch predicate loses information, so give callers the full
|
|
80
|
+
result too.** `Claricle.conformance_batch(pattern:)` returns ordered
|
|
81
|
+
per-file outcomes plus an aggregate status, so Ruby callers can get
|
|
82
|
+
what the CLI gets. Without it the CLI records per-file failures and
|
|
83
|
+
keeps going while the Ruby predicate aborts on the first one, and
|
|
84
|
+
there's no way to reach the complete picture from Ruby. The batch
|
|
85
|
+
predicate must collect every outcome — no `all?` short-circuiting —
|
|
86
|
+
and then deterministically raise the highest-severity operational
|
|
87
|
+
failure, so the same input always fails the same way.
|
|
88
|
+
- **`conform?` is a predicate, so it answers about conformance and
|
|
89
|
+
raises about everything else.** A nonconformant file returns `false`.
|
|
90
|
+
An operational failure — unknown format, unsupported format, missing
|
|
91
|
+
file, delegate crash — raises, and raises out of the batch shape too;
|
|
92
|
+
it never silently becomes `false`. `pattern:` matching zero files
|
|
93
|
+
raises `InvocationError` (matching the CLI's exit 2, not a vacuous
|
|
94
|
+
`true`).
|
|
95
|
+
- **The batch helper** (owned here, reused by 04): a positional
|
|
96
|
+
argument is a literal path when it names an existing file and a glob
|
|
97
|
+
otherwise; `--pattern` forces glob interpretation for a filename that
|
|
98
|
+
legitimately contains glob characters (D19). Expand
|
|
99
|
+
`Dir.glob(pattern).sort`; zero matches → exit 2; per-file failures
|
|
100
|
+
don't stop the batch; final exit = highest code.
|
|
101
|
+
- **One envelope type, never a mixed array.** Every slot is a
|
|
102
|
+
`Models::BatchItem{path, status, exit_code, result, error}` — one
|
|
103
|
+
homogeneous element type, with `result` holding the `Report` (or in
|
|
104
|
+
04 the `Conversion`) and `error` holding code and message. A
|
|
105
|
+
heterogeneous `Array<Report | Failure>` would force consumers to
|
|
106
|
+
type-switch on a field's presence, and `jq '.[].result.valid'` would
|
|
107
|
+
return null for an operational failure, indistinguishable from a
|
|
108
|
+
genuine null. Human output prints one stderr line per failed file.
|
|
109
|
+
Because 04 needs the same helper with a different success predicate,
|
|
110
|
+
keep it operation-neutral — something like
|
|
111
|
+
`Batch.run(argument, classify:, &operation)` returning ordered
|
|
112
|
+
envelopes plus aggregate status — rather than baking conform's exit
|
|
113
|
+
0/1 rule into it and forcing 04 to work around it.
|
|
114
|
+
- CLI: `claricle conform FILE... [--pattern GLOB] [--json] [--strict]`;
|
|
115
|
+
conformance failure returns 1 as an ordinary integer from the runner,
|
|
116
|
+
never a raised `SystemExit`; `--strict` requires tri-state `yes`
|
|
117
|
+
(D8). Batch-capable JSON
|
|
118
|
+
is **always an array**, single result included; a single-file failure
|
|
119
|
+
must produce the same JSON envelope as a batch one, so CI consumers
|
|
120
|
+
never get JSON in one case and bare stderr text in the other.
|
|
121
|
+
Human output prints, per file: the path, the tri-state verdict, and
|
|
122
|
+
every mapped issue as one line carrying severity, code, message, and
|
|
123
|
+
the location fields that are populated. A conformant file prints its
|
|
124
|
+
path and verdict, nothing more.
|
|
125
|
+
- **One stable JSON cardinality**: batch-capable commands always emit
|
|
126
|
+
an array of `BatchItem`, single result included. Shape can't follow
|
|
127
|
+
invocation syntax — a filename may legally contain glob characters,
|
|
128
|
+
and an unquoted glob is expanded by the shell before Claricle sees
|
|
129
|
+
the argument, so "which form did the user type" isn't knowable. 04's
|
|
130
|
+
`convert --json` uses the same rule.
|
|
131
|
+
- `capabilities` gains `conform` per handler **in the same commit that
|
|
132
|
+
implements that handler's `conformance_report`**, alongside the
|
|
133
|
+
`formats` output spec — so `formats` never claims an operation that
|
|
134
|
+
isn't working yet, not even between commits inside this item.
|
|
135
|
+
- Fixtures: one valid + one invalid sample per format, sourced from the
|
|
136
|
+
delegates' canonical corpora (PngSuite, svg_conform fixtures, emf
|
|
137
|
+
corpus, pdfrb specs).
|
|
138
|
+
|
|
139
|
+
## Do
|
|
140
|
+
|
|
141
|
+
Same shape as 02 — command plumbing first, then one handler per commit,
|
|
142
|
+
so no commit declares a capability the CLI can't yet deliver.
|
|
143
|
+
|
|
144
|
+
1. `Models::BatchItem` + operation-neutral batch helper + module API +
|
|
145
|
+
`conform` command, with the exit codes reachable now (2 for zero
|
|
146
|
+
matches, 3 for a format no handler conforms). Extend the exact command
|
|
147
|
+
inventory with `conform` in the same commit. No capability changes.
|
|
148
|
+
2. Module API argument specs — both call shapes, `InvocationError`
|
|
149
|
+
cases, and `conformance_batch` collecting every outcome without
|
|
150
|
+
short-circuiting.
|
|
151
|
+
3. One handler per commit: `conformance_report` TDD against valid and
|
|
152
|
+
invalid fixtures, the derived `conform` capability, and the `formats`
|
|
153
|
+
expected-output update, together. Exit codes 0 and 1 get their
|
|
154
|
+
end-to-end specs with the first handler.
|
|
155
|
+
4. Per-delegate malformed-input allowlist, per operation and stage, so
|
|
156
|
+
a recognised-but-corrupt file exits 1 rather than 4 — **for the
|
|
157
|
+
formats that have conform at all**. EPS and PS are excluded by D22:
|
|
158
|
+
they exit 3 whatever their content, since there is no conformance
|
|
159
|
+
verdict to give. `Emf::FormatError`
|
|
160
|
+
raised while conforming a truncated EMF goes **on** that allowlist:
|
|
161
|
+
the delegate reports it as an exception before any `metafile.errors`
|
|
162
|
+
result exists, but the user-visible meaning is the same
|
|
163
|
+
nonconformance that malformed PostScript produces, and the exit code
|
|
164
|
+
must follow the meaning rather than the delegate's reporting style.
|
|
165
|
+
Then prove exit 4 with a deliberately faulting handler — an
|
|
166
|
+
off-allowlist exception — since every recognised-but-corrupt fixture
|
|
167
|
+
now correctly lands on 1.
|
|
168
|
+
5. `--strict` + tri-state boundary specs (warnings-only file,
|
|
169
|
+
info-only file).
|
|
170
|
+
6. README section for `conform` (D1), stating per-format what conform
|
|
171
|
+
actually checks.
|
|
172
|
+
|
|
173
|
+
## Done when
|
|
174
|
+
|
|
175
|
+
- [x] `Claricle.conform?` correct for png, svg, emf and pdf, both call
|
|
176
|
+
shapes; eps/ps raise `UnsupportedFormat` per D22
|
|
177
|
+
(`spec/claricle/conformance/module_api_spec.rb`, examples
|
|
178
|
+
`answers true for a real conformant PNG through the positional call`,
|
|
179
|
+
`answers true for a real conformant PDF through the positional call`,
|
|
180
|
+
`answers true for a real conformant EMF and a real conformant SVG`,
|
|
181
|
+
`answers true for an all-conforming mixed-format pattern`, and
|
|
182
|
+
`raises rather than answering false when EPS or PS is unsupported`).
|
|
183
|
+
- [x] Invalid fixture of each format yields a `Report` with populated,
|
|
184
|
+
correctly-mapped issues; exit codes verified end-to-end. png, svg, pdf
|
|
185
|
+
(`spec/claricle/cli_spec.rb`); invalid EMF exits 1 with its
|
|
186
|
+
`emf.record_framing` error line (`spec/claricle/cli_spec.rb`, example
|
|
187
|
+
`exits 1 for a nonconformant EMF, with an error line on stdout`; #71).
|
|
188
|
+
- [x] Batch glob over mixed formats returns highest-code exit and a
|
|
189
|
+
positionally complete JSON array of `BatchItem`, failures included.
|
|
190
|
+
- [x] A single-file failure emits the same JSON envelope as a batch one.
|
|
191
|
+
- [x] Exit code 4 is reached end-to-end. A corrupt-but-recognised fixture
|
|
192
|
+
exits 1 for png, svg, emf and pdf; eps and ps exit 3 regardless of
|
|
193
|
+
content (D22). Exit 4 comes from a real faulting handler run through
|
|
194
|
+
the real executable (`spec/claricle/cli_spec.rb`, example
|
|
195
|
+
`exits 4 when the handler raises outside its allowlist, and says what`,
|
|
196
|
+
with `spec/fixtures/faulting_handler/boom.rb`; #63).
|
|
197
|
+
- [x] `formats` now reports conform, and its spec says so.
|
|
198
|
+
- [x] PDF named profiles map all eight planned pdfrb validators; `--level`
|
|
199
|
+
validates and maps the accepted PDF/A, PDF/X, PDF/VT and PAdES levels,
|
|
200
|
+
and structural/Arlington validation runs before a named profile.
|
|
201
|
+
- ~~Full Pre-Push Review Chain passed.~~ Superseded: Hassan 2026-10-09 requires TDD, only changed specs, and green GHA; no extra review chain.
|
|
202
|
+
|
|
203
|
+
## Files
|
|
204
|
+
|
|
205
|
+
`lib/claricle.rb` (module API), `lib/claricle/handlers/*.rb`,
|
|
206
|
+
`lib/claricle/models/batch_item.rb`, `lib/claricle/cli.rb` + batch
|
|
207
|
+
helper file, `README.adoc`, `spec/claricle/conformance/*_spec.rb`,
|
|
208
|
+
`spec/fixtures/`.
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
# 04 — Convert: vectory routing, lossiness, README rewrite
|
|
2
|
+
|
|
3
|
+
Can start: after 03 (reuses its batch helper). D11 (round-trip
|
|
4
|
+
assertions) and D23 (lossiness classification) settled what it asserts.
|
|
5
|
+
|
|
6
|
+
## Problem
|
|
7
|
+
|
|
8
|
+
No conversion exists. The issue requires EMF↔SVG, PS/EPS↔SVG and
|
|
9
|
+
SVG→EPS with explicit lossiness — no silent lossy conversions — plus
|
|
10
|
+
the final README describing the real library. All twelve edges between
|
|
11
|
+
svg/emf/eps/ps were measured working, so v1 ships the full matrix.
|
|
12
|
+
|
|
13
|
+
## Design
|
|
14
|
+
|
|
15
|
+
- `Models::Conversion` (lutaml-model): `source_path`, `source_format`,
|
|
16
|
+
`target_format`, `lossiness`, `output_path`, `content` (excluded from
|
|
17
|
+
JSON serialization). `source_path` is populated whenever the image
|
|
18
|
+
came from disk, and nil for a content-born one — `Image#convert`
|
|
19
|
+
works in memory and has no path to report. It is required in every
|
|
20
|
+
path that batching can reach, since that is where a result must be
|
|
21
|
+
traceable to its input; never substitute a Tempfile path to fill it.
|
|
22
|
+
- Conversion routes through vectory: `Vectory::Emf.from_content(...)
|
|
23
|
+
.to_svg` and siblings. All twelve svg/emf/eps/ps edges succeed **on a
|
|
24
|
+
rect-and-line fixture** (D9) — that is what "the matrix works" means,
|
|
25
|
+
and no more. Richer content fails in ways the matrix cannot express:
|
|
26
|
+
gradients raise on SVG→EMF, and the SVG→EPS path raised
|
|
27
|
+
`NameError: uninitialized constant Postsvg::Model::UnknownOperator`
|
|
28
|
+
on a cold process for an embedded raster, then succeeded once an
|
|
29
|
+
unrelated conversion had run first. That load-order bug will present
|
|
30
|
+
as intermittent, so the spec suite must exercise each edge in a fresh
|
|
31
|
+
process, not only after other conversions have warmed the constants (done in #70: `spec/claricle/conversion/fresh_process_spec.rb`, one fresh
|
|
32
|
+
process per convert edge). WMF is out of v1 entirely (D14)
|
|
33
|
+
and never reaches a handler; png and pdf have no vectory class.
|
|
34
|
+
- **Lossiness is per-conversion, not per-edge (D10 superseded, D23).**
|
|
35
|
+
Measured: SVG→EPS silently turns a gradient solid black, silently
|
|
36
|
+
removes a clip path, and silently deletes an embedded raster, while
|
|
37
|
+
the same edge is perfectly clean for a rect. SVG→EMF *raises* on a
|
|
38
|
+
gradient (`Emfsvg::FormatError: unsupported SVG color`) but silently
|
|
39
|
+
makes a clipped object invisible. A static per-edge label would
|
|
40
|
+
therefore call SVG→EPS lossless on rect evidence and then destroy a
|
|
41
|
+
gradient without a word — the exact silent lossy conversion issue #1
|
|
42
|
+
forbids. So: inspect the source document for the features known to be
|
|
43
|
+
dropped on that edge, and classify **this** conversion. The
|
|
44
|
+
`:lossless` / `:lossy` / `:unknown` vocabulary stands; the static
|
|
45
|
+
table becomes a pessimistic floor, never the answer. Build the
|
|
46
|
+
feature list from the fixture corpus and grow it as more losses are
|
|
47
|
+
found. Human output discloses lossy/unknown conversions through its
|
|
48
|
+
warning or batch summary; JSON output carries the classification in
|
|
49
|
+
the result and deliberately keeps stderr clean. No consent-gate flag
|
|
50
|
+
— the issue requires disclosure, not consent.
|
|
51
|
+
- **Write lifecycle**: `Image#convert(to:)` never touches disk.
|
|
52
|
+
`Claricle.convert(src, to:, output: nil, force: false)` writes to the
|
|
53
|
+
given path; stdout for `"-"`; with `output: nil` derives the sibling
|
|
54
|
+
path (source name, target extension — the issue's
|
|
55
|
+
`convert("diagram.emf", to: :svg)` writes `diagram.svg`).
|
|
56
|
+
`Conversion#output_path` = actual written path, nil for
|
|
57
|
+
stdout/in-memory.
|
|
58
|
+
- **Same-format conversion is an invocation error, not a crash.**
|
|
59
|
+
`Vectory::Svg#to_svg` does not exist — every same-format pair raises
|
|
60
|
+
`NoMethodError`, which would exit 4 as an internal fault. Reject
|
|
61
|
+
`--to` equal to the detected source format up front with
|
|
62
|
+
`InvocationError` → exit 2, before any delegate is touched.
|
|
63
|
+
- **A successful `from_content` proves nothing about the content.**
|
|
64
|
+
Measured: EPS bytes into `Vectory::Emf`, EMF bytes into
|
|
65
|
+
`Vectory::Eps` and raw SVG into `Vectory::Emf` all construct without
|
|
66
|
+
complaint and only fail at the conversion call. Detection remains the
|
|
67
|
+
only authority on what a file is; never infer it from a delegate
|
|
68
|
+
accepting the bytes.
|
|
69
|
+
- **One overwrite rule, both layers**: an existing destination is never
|
|
70
|
+
overwritten without `force: true`/`--force`, and it makes no
|
|
71
|
+
difference whether the path was derived or given explicitly. Refusing
|
|
72
|
+
raises `InvocationError` at API level and exits 2 at CLI level. Two
|
|
73
|
+
rules here would be a trap — the derived path is the one the user
|
|
74
|
+
didn't type and so the one they're least likely to be watching.
|
|
75
|
+
- **Batch conversion must preflight the whole write set before writing
|
|
76
|
+
anything.** Derived sibling names plus batching plus `--force` is a
|
|
77
|
+
data-loss machine otherwise. Real cases: `a.emf` and `a.svg` in one
|
|
78
|
+
run targeting SVG, where converting the EMF overwrites a later input;
|
|
79
|
+
`a.eps` and `a.ps` both deriving `a.svg`, where one result destroys
|
|
80
|
+
the other; `Logo.eps` and `logo.ps` colliding on a case-insensitive
|
|
81
|
+
filesystem; and `convert logo.svg --to emf --output logo.svg --force`
|
|
82
|
+
destroying its own source. So: resolve every source/destination pair
|
|
83
|
+
up front, compare by **filesystem identity** rather than string
|
|
84
|
+
equality (symlinks, hardlinks, case folding), reject any destination
|
|
85
|
+
that is also a source, and reject non-unique destinations even under
|
|
86
|
+
`--force`. Note a **nonexistent** destination has no inode to compare
|
|
87
|
+
— `Logo.svg` and `logo.svg` collide on a case-insensitive filesystem
|
|
88
|
+
before either exists. So canonicalize planned destinations by probing
|
|
89
|
+
the target directory's case sensitivity once, and compare
|
|
90
|
+
canonicalized strings for paths that do not yet exist. A preflight
|
|
91
|
+
collision **fails the whole batch before any write**, rather than
|
|
92
|
+
producing per-item failures — half-written output is worse than none,
|
|
93
|
+
and the user can fix the input set and re-run. `--force` authorises replacing an unrelated existing file,
|
|
94
|
+
never clobbering an input.
|
|
95
|
+
- **Writes are atomic in both directions.** Without `--force`, stage to
|
|
96
|
+
a temp file in the destination's own directory and publish with a
|
|
97
|
+
no-replace atomic operation, so a concurrent writer can't slip
|
|
98
|
+
between the existence check and the write. With `--force`, stage the
|
|
99
|
+
same way and atomically rename over the target, so a serialization or
|
|
100
|
+
disk failure can't truncate a good existing file. For `--output -`,
|
|
101
|
+
generate the bytes fully before writing and use binary
|
|
102
|
+
`stdout.write` — never `puts`, which appends a newline and corrupts
|
|
103
|
+
binary output. Spec it byte-for-byte.
|
|
104
|
+
- CLI: `claricle convert SOURCE... [--pattern GLOB] [--to FORMAT]
|
|
105
|
+
[--output FILE|-] [--json] [--force]` — same invocation shape as
|
|
106
|
+
`conform` (D19): a positional source is a literal path when it names
|
|
107
|
+
an existing file and a glob otherwise, with `--pattern` forcing glob
|
|
108
|
+
interpretation. Batch runs via 03's helper, every file landing in a
|
|
109
|
+
uniform `Models::BatchItem` slot, failures included. `--output` names
|
|
110
|
+
a single destination, so it is rejected with exit 2 whenever more
|
|
111
|
+
than one source resolves; multi-source runs use derived names. `--to` may be omitted **only** when
|
|
112
|
+
`--output` names a file whose extension maps to a known format; that
|
|
113
|
+
extension then decides the target. Omitting both, or giving an
|
|
114
|
+
extension that maps to nothing, exits 2 with a message naming the
|
|
115
|
+
problem — there is no default target and no guessing from the source
|
|
116
|
+
format, drawn from the handlers' declared canonical extensions rather
|
|
117
|
+
than a central table. `--to svg --output result.emf` is a conflict,
|
|
118
|
+
not a preference — exit 2 rather than silently letting one win.
|
|
119
|
+
`--output -` puts bytes alone on stdout, and rejects `--json` and a
|
|
120
|
+
missing `--to` (exit 2, since there's no extension to infer from).
|
|
121
|
+
Everything else — the human summary, lossiness warnings, error text —
|
|
122
|
+
goes to stderr in that mode, so the stdout stream stays pipe-safe.
|
|
123
|
+
`--json` serializes `Conversion` minus content, always as an array of
|
|
124
|
+
`BatchItem` per 03's rule. Human output prints one line per file:
|
|
125
|
+
source, target format, written path (or `-`), and the lossiness
|
|
126
|
+
classification.
|
|
127
|
+
- `capabilities` gains `convert` plus that handler's target list in the
|
|
128
|
+
same commit that implements the edge (per 00's rule), with the
|
|
129
|
+
`formats` output spec moving alongside. The last such commit is what
|
|
130
|
+
finally makes `claricle formats` the full support matrix the issue
|
|
131
|
+
asks for.
|
|
132
|
+
- **There is no general round-trip invariant, and that is measured
|
|
133
|
+
(D11).** Byte identity against the original never held — the first
|
|
134
|
+
pass always rewrites. Idempotence held for a rect-and-line fixture
|
|
135
|
+
across the measured routes. With a single `<text>` element, the EMF
|
|
136
|
+
chain also stabilises after the first rewrite, but the EPS chain does
|
|
137
|
+
not: its viewBox grows `0 0 100 50` → `0 0 100 90` →
|
|
138
|
+
`0 0 100 180`, and its text y coordinate moves `-40` → `-90` →
|
|
139
|
+
`-180`. So do **not** write a general `cycle N == cycle N+1` spec:
|
|
140
|
+
one stable route does not establish the property across the supported
|
|
141
|
+
matrix.
|
|
142
|
+
What can honestly be asserted: conversion is **deterministic** (same
|
|
143
|
+
input, identical bytes — measured true); same-format parse→serialize
|
|
144
|
+
identity for EMF; and per-feature semantic properties over a fixture
|
|
145
|
+
corpus, each naming the property it checks. These are D11's replacement
|
|
146
|
+
for the issue's stated general backbone.
|
|
147
|
+
Also note "dimension preservation" is not assertable until D15
|
|
148
|
+
settles units — the same SVG reports `3×1` through vectory and
|
|
149
|
+
`96×48` as EPS, so an equality check would be testing a coincidence.
|
|
150
|
+
- Docs closure: README.adoc fully rewritten on top of 01's honesty
|
|
151
|
+
baseline — real API + CLI, the format × operation matrix, exit codes,
|
|
152
|
+
and the registry extension workflow. That workflow must describe what
|
|
153
|
+
adding a format **actually** costs: if handlers do not yet implement
|
|
154
|
+
their operations or declare sniffers, targets, extensions and lossiness,
|
|
155
|
+
then it is a handler class plus a detector edit plus a require plus a
|
|
156
|
+
registry entry plus a lossiness edge, and the README says so rather
|
|
157
|
+
than repeating the one-class claim. Gemspec description refreshed for
|
|
158
|
+
the operations that now exist; its URLs moved to the `claricle` org in
|
|
159
|
+
01 and need no further edit. `sig/` stays deleted (01's docs rule).
|
|
160
|
+
Extend the exact command inventory with `convert` in the same commit
|
|
161
|
+
as the command.
|
|
162
|
+
|
|
163
|
+
## Do
|
|
164
|
+
|
|
165
|
+
Plumbing first again, then one edge family per commit.
|
|
166
|
+
|
|
167
|
+
1. `Models::Conversion` + the per-target feature-loss rules (D23) +
|
|
168
|
+
specs. Not a flat edge table — a rule says "this target discards
|
|
169
|
+
gradients / clip paths / embedded rasters", and the classifier
|
|
170
|
+
inspects the source for those features.
|
|
171
|
+
2. Extract the write lifecycle as its own injectable service and spec
|
|
172
|
+
it directly — preflight collision set, filesystem-identity aliasing,
|
|
173
|
+
no-replace atomic create, force-mode temp-and-rename, binary stdout.
|
|
174
|
+
01 freezes the registry and bans runtime mutation and test-only
|
|
175
|
+
APIs, so there is no honest route from a detected real format to a
|
|
176
|
+
stand-in handler; testing the service directly avoids needing one.
|
|
177
|
+
3. `convert` command + batch + `--force`/`--output -`/`--json`
|
|
178
|
+
boundary specs + `--to` inference, its exit-2 cases, and the
|
|
179
|
+
`--to`/`--output` suffix conflict; `--output -` spec asserts stdout
|
|
180
|
+
carries bytes only, with no trailing newline. Extend the exact command
|
|
181
|
+
inventory with `convert` in the same commit.
|
|
182
|
+
4. One edge family per commit: handler `convert` TDD against real
|
|
183
|
+
fixtures, override `convert`, declare only that handler's target-list
|
|
184
|
+
data, and update the `formats` expected output together.
|
|
185
|
+
5. Semantic spec suite over the fixture corpus, each spec naming the
|
|
186
|
+
property it checks (D11 rejects a general invariant, so do not
|
|
187
|
+
write a general round-trip assertion). Add a feature-loss rule only
|
|
188
|
+
when a fixture demonstrates the loss.
|
|
189
|
+
6. `formats` full-matrix spec once the last edge lands.
|
|
190
|
+
7. README rewrite + gemspec metadata.
|
|
191
|
+
|
|
192
|
+
## Done when
|
|
193
|
+
|
|
194
|
+
- [x] All acceptance-matrix conversions work via API and CLI with correct
|
|
195
|
+
exit codes and output-mode-appropriate lossiness disclosure.
|
|
196
|
+
- [x] Semantic suite green; every `:lossless` classification is
|
|
197
|
+
fixture-proven for the features present in that fixture, and the
|
|
198
|
+
gradient, clip-path and embedded-raster cases each produce a
|
|
199
|
+
`:lossy` classification disclosed in human or JSON output rather than
|
|
200
|
+
silent loss.
|
|
201
|
+
- [x] A batch that would collide or overwrite an input is refused before
|
|
202
|
+
anything is written, and specs cover the four concrete cases.
|
|
203
|
+
- [x] `claricle formats` prints the complete support matrix — every cell
|
|
204
|
+
matches what actually works.
|
|
205
|
+
- [x] README describes only verified behavior; issue #1 acceptance
|
|
206
|
+
checklist fully satisfiable, or the gaps are signed off (D11, D14, D18, D22).
|
|
207
|
+
- ~~Full Pre-Push Review Chain passed.~~ Superseded: Hassan 2026-10-09 requires TDD, only changed specs, and green GHA; no extra review chain.
|
|
208
|
+
|
|
209
|
+
## Files
|
|
210
|
+
|
|
211
|
+
`lib/claricle/models/conversion.rb`, `lib/claricle/handlers/
|
|
212
|
+
{metafile,postscript,svg}.rb`, `lib/claricle/writer.rb` (write
|
|
213
|
+
lifecycle service), `lib/claricle/registry.rb`, `lib/claricle.rb`,
|
|
214
|
+
`lib/claricle/cli.rb`, `README.adoc`, `claricle.gemspec`,
|
|
215
|
+
`spec/claricle/conversion/*_spec.rb`, `spec/claricle/writer_spec.rb`,
|
|
216
|
+
`spec/claricle/cli_spec.rb`.
|
data/exe/claricle
CHANGED