claricle 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +10 -1
  3. data/LICENSE +25 -0
  4. data/README.adoc +439 -157
  5. data/TODO.plan/00-overview.md +502 -0
  6. data/TODO.plan/01-core.md +296 -0
  7. data/TODO.plan/02-inspect.md +163 -0
  8. data/TODO.plan/03-conform.md +208 -0
  9. data/TODO.plan/04-convert.md +216 -0
  10. data/exe/claricle +1 -1
  11. data/lib/claricle/batch.rb +240 -0
  12. data/lib/claricle/cli.rb +514 -40
  13. data/lib/claricle/conformance_options.rb +49 -0
  14. data/lib/claricle/conversion_engine.rb +21 -0
  15. data/lib/claricle/convert.rb +21 -0
  16. data/lib/claricle/detector.rb +696 -0
  17. data/lib/claricle/emf_header.rb +33 -0
  18. data/lib/claricle/errors.rb +73 -0
  19. data/lib/claricle/fault.rb +69 -0
  20. data/lib/claricle/handlers/base.rb +169 -0
  21. data/lib/claricle/handlers/extension_points.rb +124 -0
  22. data/lib/claricle/handlers/metafile.rb +1011 -0
  23. data/lib/claricle/handlers/pdf.rb +961 -0
  24. data/lib/claricle/handlers/pdf_arlington/definitions.rb +54 -0
  25. data/lib/claricle/handlers/pdf_arlington/kind.rb +62 -0
  26. data/lib/claricle/handlers/pdf_arlington/predicate_gate.rb +39 -0
  27. data/lib/claricle/handlers/pdf_arlington/rules.rb +86 -0
  28. data/lib/claricle/handlers/pdf_arlington/walker.rb +131 -0
  29. data/lib/claricle/handlers/pdf_arlington.rb +38 -0
  30. data/lib/claricle/handlers/pdf_profiles.rb +66 -0
  31. data/lib/claricle/handlers/png.rb +1107 -0
  32. data/lib/claricle/handlers/postscript.rb +1357 -0
  33. data/lib/claricle/handlers/svg.rb +787 -0
  34. data/lib/claricle/image.rb +272 -0
  35. data/lib/claricle/lossiness/levels.rb +13 -0
  36. data/lib/claricle/lossiness.rb +864 -0
  37. data/lib/claricle/models/base.rb +460 -0
  38. data/lib/claricle/models/batch_item.rb +127 -0
  39. data/lib/claricle/models/conversion.rb +167 -0
  40. data/lib/claricle/models/format_capability.rb +40 -0
  41. data/lib/claricle/models/free_form_hash.rb +77 -0
  42. data/lib/claricle/models/inspection.rb +188 -0
  43. data/lib/claricle/models/issue.rb +23 -0
  44. data/lib/claricle/models/location.rb +51 -0
  45. data/lib/claricle/models/report.rb +54 -0
  46. data/lib/claricle/models/uncoerced_integer.rb +38 -0
  47. data/lib/claricle/postscript_fidelity/verdict.rb +37 -0
  48. data/lib/claricle/postscript_fidelity/view_box.rb +82 -0
  49. data/lib/claricle/postscript_fidelity.rb +160 -0
  50. data/lib/claricle/registry.rb +125 -0
  51. data/lib/claricle/version.rb +1 -1
  52. data/lib/claricle/writer.rb +468 -0
  53. data/lib/claricle.rb +244 -7
  54. metadata +201 -19
  55. data/claricle.gemspec +0 -42
  56. data/sig/claricle.rbs +0 -4
data/lib/claricle/cli.rb CHANGED
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "json"
4
+
3
5
  module Claricle
4
6
  # Command-line interface for Claricle
5
7
  class Cli < Thor
@@ -7,61 +9,533 @@ module Claricle
7
9
  true
8
10
  end
9
11
 
12
+ # Thor >= 1.5 contributes both `help` and `tree` to every subclass.
13
+ # Claricle keeps `help` and drops `tree`. Guarded because the gemspec
14
+ # permits Thor down to 1.2, where `tree` does not exist and
15
+ # `undefine: true` on a missing method raises NameError.
16
+ remove_command :tree, undefine: true if method_defined?(:tree)
17
+
10
18
  desc "version", "Display Claricle version"
11
19
  def version
12
- puts "Claricle version #{Claricle::VERSION}"
20
+ tolerate_closed_output { puts "Claricle version #{Claricle::VERSION}" }
21
+ end
22
+
23
+ # Thor supplies this command. The rescue is WIDE on purpose: it covers
24
+ # text generation as well as the write, so an `Errno::EPIPE` raised
25
+ # while Thor is still building the help page reports success too.
26
+ # Narrowing it to the write half needs Thor's shell calls buffered and
27
+ # replayed, and a replayed call cannot be given back the shell state it
28
+ # was made in -- see the gate record for the measured failure shapes.
29
+ # `spec/claricle/cli_help_spec.rb` pins the wide behaviour, so a future
30
+ # narrowing attempt is deliberate rather than accidental.
31
+ def help(command = nil, subcommand = false) # rubocop:disable Style/OptionalBooleanParameter
32
+ tolerate_closed_output { super(command, subcommand) }
33
+ end
34
+
35
+ desc "inspect FILE", "Report a file's format and metadata"
36
+ option :json, type: :boolean, default: false, desc: "Emit JSON"
37
+ # Named `inspect_file`, because a Thor command is an instance method
38
+ # and `def inspect(file)` would override `Object#inspect` on every
39
+ # Cli instance -- `p cli` then raises ArgumentError. This is D2's
40
+ # rule (`Image#inspect` became `#inspection`) applied one layer up.
41
+ # The command users type is still `inspect`; the re-key and the
42
+ # override below are what keep the method name from leaking out.
43
+ def inspect_file(file)
44
+ inspection = Image.from_path(file).inspection
45
+ payload = options[:json] ? inspection.to_json : Presenter.inspection(inspection)
46
+ tolerate_closed_output { puts payload }
47
+ end
48
+ # Thor registers a command under its METHOD name, so this shipped as
49
+ # the command `inspect_file` -- and `normalize_command_name`
50
+ # translates dashes to underscores, so `inspect-file` reached it too.
51
+ # `map "inspect" => :inspect_file` added a third spelling on top
52
+ # rather than replacing the first two.
53
+ #
54
+ # Re-keying the registry entry leaves exactly `inspect`. The Command
55
+ # object keeps its `inspect_file` name, so dispatch still reaches the
56
+ # method above and `Object#inspect` is never shadowed.
57
+ #
58
+ # The `map` alias is dropped rather than kept, because with the entry
59
+ # re-keyed `normalize_command_name("inspect")` would follow the alias
60
+ # back to the name that no longer exists.
61
+ commands["inspect"] = commands.delete("inspect_file")
62
+
63
+ # Thor prints a command's METHOD name in an arity error, and the
64
+ # method behind `inspect` is `inspect_file` -- so `claricle inspect`
65
+ # with no file answered `ERROR: "claricle inspect_file" was called
66
+ # with no arguments`, naming the one spelling this CLI rejects.
67
+ # Measured.
68
+ #
69
+ # The registry key is Thor's CANONICAL name for the command, which
70
+ # is not always what the user typed -- Thor resolves abbreviations
71
+ # first, so `claricle ins` arrives here with key `inspect`. That is
72
+ # the name worth printing either way. The message is built from a
73
+ # copy carrying it; a copy, because the original's name is what Thor
74
+ # dispatches on. A command whose key already matches is passed
75
+ # through untouched.
76
+ def self.handle_argument_error(command, error, args, arity)
77
+ key = commands.key(command)
78
+ command = command.dup.tap { |copy| copy.name = key } if key && key != command.name
79
+
80
+ super
81
+ end
82
+
83
+ desc "formats", "List the formats Claricle handles and what it can do with each"
84
+ option :json, type: :boolean, default: false, desc: "Emit JSON"
85
+ def formats
86
+ rows = Registry.formats.map { |format| Presenter.format_row(format) }
87
+ payload = options[:json] ? Models::FormatCapability.to_json(rows) : Presenter.format_table(rows)
88
+ tolerate_closed_output { puts payload }
89
+ end
90
+
91
+ desc "conform FILE...", "Check files against their format's conformance rules"
92
+ option :pattern, type: :string,
93
+ desc: "Read this as a glob, whatever the filename looks like"
94
+ option :json, type: :boolean, default: false, desc: "Emit JSON"
95
+ option :strict, type: :boolean, default: false,
96
+ desc: "Require a clean verdict, not merely the absence of errors"
97
+ option :profile, type: :string,
98
+ desc: "Require conformance to a named profile the format defines"
99
+ option :level, type: :string, desc: "Select a level within a PDF profile"
100
+ def conform(*files)
101
+ result = Claricle.conformance_batch(*files, pattern: options[:pattern],
102
+ strict: options[:strict],
103
+ profile: options[:profile],
104
+ level: options[:level])
105
+ # The status is settled before anything is written. `tolerate_closed_output`
106
+ # answers 0, which is right for a command that can only succeed and
107
+ # would silently turn a nonconformant batch into a passing one here.
108
+ tolerate_closed_output { write_conformance(result) }
109
+ Runner::Status.new(result.exit_code)
110
+ end
111
+
112
+ desc "convert SOURCE...", "Convert files to another format"
113
+ option :pattern, type: :string,
114
+ desc: "Read this as a glob, whatever the filename looks like"
115
+ option :to, type: :string, desc: "Target format"
116
+ option :output, type: :string, desc: "Destination file, or - for stdout"
117
+ option :json, type: :boolean, default: false, desc: "Emit JSON"
118
+ option :force, type: :boolean, default: false, desc: "Overwrite an existing destination"
119
+ def convert(*files)
120
+ refuse_stdout_json_conflict
121
+ result = Claricle.convert_batch(*files, pattern: options[:pattern], to: options[:to],
122
+ output: options[:output], force: options[:force])
123
+ # Bytes for `--output -` are written to stdout as a side effect INSIDE
124
+ # `Claricle.convert_batch`, above -- unlike every other command, whose
125
+ # write happens afterward, guarded by `tolerate_closed_output` below.
126
+ # A closed consumer there is caught by Batch's own per-file rescue
127
+ # before it ever reaches this line, and reads as an ordinary
128
+ # conversion failure (exit 4) rather than the closed-pipe exit 0
129
+ # every sibling command gives. Restored explicitly here, since
130
+ # `tolerate_closed_output` cannot see a failure that already happened.
131
+ return Runner::Status.new(0) if closed_stdout_write?(result)
132
+
133
+ tolerate_closed_output { write_convert(result) }
134
+ Runner::Status.new(result.exit_code)
135
+ end
136
+
137
+ # Rendering, kept together so the commands only choose a payload and
138
+ # write it. Nothing here touches `options` or writes output.
139
+ module Presenter
140
+ # C0, DEL, C1, and Unicode's own two line breaks. Not just C0:
141
+ # U+0085 is NEL, a next-line control, and U+009B is CSI, the
142
+ # introducer an escape sequence uses -- both reachable as
143
+ # character references, both measured arriving in the output
144
+ # intact. U+2028 and U+2029 are line and paragraph separators by
145
+ # definition. Nothing here is a character a file has a reason to
146
+ # put in its metadata.
147
+ CONTROL = /[\u0000-\u001F\u007F-\u009F\u2028\u2029]/
148
+ # Named here rather than read off the model, so the printed order is
149
+ # chosen rather than whatever order the attributes happen to sit in.
150
+ LOCATION_FIELDS = %i[chunk byte_offset byte_length line column node_path].freeze
151
+ private_constant :CONTROL, :LOCATION_FIELDS
152
+
153
+ module_function
154
+
155
+ # Per file: the path, the tri-state verdict, and every mapped issue on
156
+ # one line. A conformant file prints its path and verdict, nothing
157
+ # more. A file whose operation could not run has no verdict to print
158
+ # and is reported on stderr instead.
159
+ def conformance(items)
160
+ items.reject { |item| item.status == "error" }
161
+ .flat_map { |item| ["#{visible(item.path)}: #{item.result.valid}", *issues(item)] }
162
+ .join("\n")
163
+ end
164
+
165
+ # Generic: a `BatchItem`'s failure shape (path + error message) is the
166
+ # same whatever operation produced it, so this is what `convert`'s own
167
+ # failure rendering calls too, rather than the conform-named method
168
+ # below or a near-duplicate of it.
169
+ def batch_failures(items)
170
+ items.select { |item| item.status == "error" }
171
+ .map { |item| "claricle: #{visible(item.path)}: #{visible(item.error.message)}" }
172
+ end
173
+
174
+ def conformance_failures(items)
175
+ batch_failures(items)
176
+ end
177
+
178
+ # Per file: the path, the target format, where the bytes landed
179
+ # ("-" for stdout, since `Writer#write` returns nil there), and the
180
+ # lossiness classification -- the line shape the plan card names.
181
+ # An errored item has no `Conversion` to describe and is reported
182
+ # on stderr instead, via `batch_failures`.
183
+ def conversions(items)
184
+ items.reject { |item| item.status == "error" }
185
+ .map { |item| conversion_line(item.path, item.result) }
186
+ end
187
+
188
+ # One stderr line per conversion whose lossiness is not `lossless`:
189
+ # the plan card requires disclosure, not consent.
190
+ def conversion_warnings(items)
191
+ items.reject { |item| item.status == "error" || item.result.lossiness == "lossless" }
192
+ .map { |item| conversion_warning(item.path, item.result) }
193
+ end
194
+
195
+ def conversion_warning(path, conversion)
196
+ verdict = conversion.lossiness == "lossy" ? "is lossy" : "has unknown lossiness"
197
+ "claricle: warning: #{visible(path)} -> #{conversion.target_format} #{verdict}"
198
+ end
199
+
200
+ def conversion_line(path, conversion)
201
+ written = conversion.output_path || "-"
202
+ "#{visible(path)} -> #{conversion.target_format}: #{visible(written)} (#{conversion.lossiness})"
203
+ end
204
+
205
+ def issues(item)
206
+ item.result.issues.map do |issue|
207
+ [" #{visible(issue.severity)}", code(issue.code),
208
+ visible(issue.message), location(issue.location)].compact.join(" ")
209
+ end
210
+ end
211
+
212
+ # Absent, not `[]`: Issue#code is optional, and an empty bracket
213
+ # pair would read as a code nobody chose rather than one the
214
+ # delegate never reported.
215
+ def code(code)
216
+ return if code.nil?
217
+
218
+ "[#{visible(code)}]"
219
+ end
220
+
221
+ # Only what a delegate actually reported. Spelling out six fields
222
+ # would print four nils, and printing none would lose the ones that
223
+ # were measured.
224
+ def location(location)
225
+ return if location.nil?
226
+
227
+ populated = LOCATION_FIELDS.filter_map do |name|
228
+ value = location.public_send(name)
229
+ "#{name}: #{visible(value)}" unless value.nil?
230
+ end
231
+ "(#{populated.join(", ")})" unless populated.empty?
232
+ end
233
+
234
+ # Capabilities are derived from the handler, so this cannot
235
+ # advertise an operation that is still a raising stub.
236
+ def format_row(format)
237
+ capabilities = Registry.capabilities_for(format)
238
+
239
+ Models::FormatCapability.new(
240
+ format: format.to_s,
241
+ inspectable: capabilities.include?(:inspect),
242
+ conform: capabilities.include?(:conform),
243
+ convert: capabilities.include?(:convert),
244
+ convert_to: Registry.convert_targets_for(format).map(&:to_s)
245
+ )
246
+ end
247
+
248
+ def format_table(rows)
249
+ rows.map do |row|
250
+ operations = { inspect: row.inspectable, conform: row.conform, convert: row.convert }
251
+ "#{row.format}\t#{operations.select { |_, supported| supported }.keys.join(", ")}"
252
+ end.join("\n")
253
+ end
254
+
255
+ def inspection(inspection)
256
+ rows(inspection)
257
+ .filter_map { |label, value| "#{visible(label)}: #{visible(value)}" unless value.nil? }
258
+ .join("\n")
259
+ end
260
+
261
+ # Metadata is free text out of the file, and an SVG root attribute
262
+ # can hold a newline: `&#xA;` is a character reference, so XML
263
+ # keeps it as a real newline rather than folding it to a space.
264
+ # Printed raw, `id="a&#xA;error: forged"` produced an "error:"
265
+ # line of Claricle's own shape, and `&#xD;` overwrote the line in
266
+ # a terminal. Control characters are shown escaped instead.
267
+ #
268
+ # Only the human rendering needs this. `--json` carries the true
269
+ # value and JSON escapes it on the way out, so the two disagree on
270
+ # presentation and agree on the bytes.
271
+ def visible(text)
272
+ text.to_s.gsub(CONTROL) { |char| escaped(char.ord) }
273
+ end
274
+
275
+ # `\xNN` up to a byte, `\u{NNNN}` above it. One form for both
276
+ # would either print `\x2028` -- which reads as `\x20` followed by
277
+ # "28" -- or spell a newline `\u{000A}`.
278
+ def escaped(code)
279
+ code > 0xFF ? format("\\u{%04X}", code) : format("\\x%02X", code)
280
+ end
281
+
282
+ # Label/value pairs filtered once, rather than a conditional per
283
+ # field: a nil field is simply absent, and adding a field later does
284
+ # not add a branch. Pairs rather than a Hash, because two issues can
285
+ # share a severity and a Hash would silently drop one.
286
+ def rows(inspection)
287
+ [
288
+ ["format", inspection.format], *dimension_rows(inspection),
289
+ ["dpi", inspection.dpi], ["color space", inspection.color_space],
290
+ *meta_rows(inspection), ["parse status", inspection.parse_status],
291
+ *inspection.issues.map { |issue| [issue.severity, issue.message] }
292
+ ]
293
+ end
294
+
295
+ # Metadata keys come out of the file, and an SVG root may declare
296
+ # any attribute it likes. Unprefixed, `error="forged"` printed a
297
+ # line shaped exactly like an issue and `format="png"` printed a
298
+ # second, contradictory format line. The prefix keeps the file's
299
+ # own words in their own namespace.
300
+ #
301
+ # It settles a collision that was already there, too: an SVG
302
+ # declaring `width="10mm"` produced a `width` line for the raw
303
+ # declaration and another for the computed 37.79 px.
304
+ def meta_rows(inspection)
305
+ inspection.meta.to_a.sort.map { |key, value| ["meta.#{key}", value] }
306
+ end
307
+
308
+ # One line when both are known, separate lines when only one is.
309
+ # SVG can carry a width and no height, and "7.0x" is not a
310
+ # dimension -- but dropping the line would lose the width entirely.
311
+ def dimension_rows(inspection)
312
+ width = inspection.width
313
+ height = inspection.height
314
+ return [["dimensions", "#{width}x#{height}"]] if width && height
315
+
316
+ [["width", width], ["height", height]]
317
+ end
13
318
  end
14
319
 
15
- desc "validate FILE_PATTERN", "Validate image files (placeholder)"
16
- long_desc <<~LONGDESC
17
- Validate image files against their respective standards.
320
+ private_constant :Presenter
321
+
322
+ # Turns an exception into a process status. `run` returns an Integer
323
+ # for everything it maps, and never exits -- only exe/claricle exits,
324
+ # so every code is reachable from a spec without trapping SystemExit.
325
+ # Interrupt and other signals are deliberately not mapped and do
326
+ # propagate, so Ctrl-C behaves like Ctrl-C.
327
+ module Runner
328
+ # A command sets its status by returning one of these. A bare Integer
329
+ # is ignored, because thor passes a command's return value straight
330
+ # through and any incidental `File.write` would otherwise become the
331
+ # exit status.
332
+ class Status
333
+ RANGE = (0..255)
334
+ private_constant :RANGE
335
+
336
+ attr_reader :code
337
+
338
+ def initialize(code)
339
+ unless self.class.valid?(code)
340
+ raise ArgumentError, "status must be an Integer in #{RANGE}, got #{code.inspect}"
341
+ end
342
+
343
+ @code = code
344
+ freeze
345
+ end
346
+
347
+ def self.valid?(code)
348
+ code.is_a?(Integer) && RANGE.cover?(code)
349
+ end
350
+ end
351
+
352
+ # Errors Claricle recognises, whose own message already reads as a
353
+ # sentence. Everything else gets its class named.
354
+ MAPPED = [Error, Thor::Error, Errno::ENOENT].freeze
355
+ private_constant :MAPPED
356
+
357
+ class << self
358
+ def run(argv, output: $stderr)
359
+ arguments = argv.dup
360
+ validate_arguments(arguments)
361
+ normalize_command_arguments(arguments)
362
+ result = invoke(arguments)
363
+ result.is_a?(Status) ? result.code : 0
364
+ rescue SystemExit => e
365
+ # A command may exit deliberately. The status still has to be a
366
+ # byte: `exit 256` reports success.
367
+ bounded_status(e.status)
368
+ rescue StandardError, ScriptError, SystemStackError => e
369
+ report(error_message(e), output)
370
+ exit_code(e)
371
+ end
372
+
373
+ private
374
+
375
+ # Thor's public `start` turns every EPIPE into success, including
376
+ # one raised by a command's own operation. Dispatch directly so the
377
+ # runner can map those failures; Cli handles only its output writes.
378
+ def invoke(arguments)
379
+ Cli.send(:dispatch, nil, arguments, nil, { shell: Thor::Base.shell.new })
380
+ end
381
+
382
+ # Thor assumes argv text is valid and ASCII-compatible. Reject input
383
+ # it cannot parse as a bad invocation instead of an internal error.
384
+ def validate_arguments(arguments)
385
+ arguments.each_with_index do |argument, index|
386
+ valid = argument.is_a?(String) &&
387
+ argument.encoding.ascii_compatible? &&
388
+ argument.valid_encoding?
389
+ next if valid
390
+
391
+ raise InvocationError, "argument #{index + 1} must be valid ASCII-compatible text"
392
+ end
393
+ end
394
+
395
+ # Command names are semantic text, unlike future positional paths.
396
+ # Thor compares them with UTF-8 command keys and can choke on another
397
+ # valid ASCII-compatible encoding, so normalize only that boundary.
398
+ def normalize_command_arguments(arguments)
399
+ command_name = arguments[0]
400
+ # A leading terminator selects Thor's default command without itself
401
+ # becoming a command name; leave it in argv for Thor to consume.
402
+ command_name = nil if command_name == "--"
403
+ arguments[0] = command_name = command_text(command_name) if command_name
404
+ return unless help_command?(command_name) && arguments[1]
18
405
 
19
- This command will validate PNG, SVG, and other image formats
20
- for conformance and correctness.
406
+ arguments[1] = command_text(arguments[1])
407
+ end
21
408
 
22
- Note: This is a placeholder command. Functionality will be
23
- implemented in future versions.
409
+ def command_text(argument)
410
+ argument.encode(Encoding::UTF_8)
411
+ rescue EncodingError
412
+ raise InvocationError, "command name cannot be converted to UTF-8"
413
+ end
24
414
 
25
- Examples:
26
- claricle validate image.png
27
- claricle validate *.svg
28
- claricle validate "images/*.png"
29
- LONGDESC
30
- def validate(_pattern)
31
- puts "Image validation functionality coming soon!"
32
- puts "This gem is currently a placeholder to reserve the name."
415
+ def help_command?(name)
416
+ Cli.send(:normalize_command_name, name) == "help"
417
+ end
418
+
419
+ # Writing the diagnostic must not become the failure. `output.puts`
420
+ # sits inside a rescue body, so nothing above catches it: with
421
+ # stderr on a closed pipe the EPIPE escaped `run` entirely and the
422
+ # executable exited 1 -- the status the README reserves for a
423
+ # nonconformant file. Failing to *report* an error cannot be
424
+ # allowed to replace that error's own status.
425
+ def report(message, output)
426
+ output.puts(message)
427
+ rescue StandardError
428
+ nil
429
+ end
430
+
431
+ # Returns rather than raises: an ArgumentError from here would be
432
+ # inside the SystemExit rescue and could not reach the next clause.
433
+ def bounded_status(code)
434
+ Status.valid?(code) ? code : 4
435
+ end
436
+
437
+ # The library owns the map, because a batch has to reach the same
438
+ # answer for the same exception. Thor's own errors are the one row
439
+ # that belongs here: they cannot arise inside an operation, and
440
+ # Fault is deliberately Thor-free.
441
+ def exit_code(error)
442
+ error.is_a?(Thor::Error) ? 2 : Fault.exit_code(error)
443
+ end
444
+
445
+ # An unexpected failure names its class: "claricle: missing gem" is
446
+ # not much help when the class was LoadError. A recognised error
447
+ # does not need one -- ENOENT included, now that `inspect` takes a
448
+ # path and a typo is an ordinary user error rather than a defect.
449
+ def error_message(error)
450
+ message = Presenter.visible(Fault.message(error))
451
+ # Errno::ENOENT joins the bare-message set now that a command
452
+ # takes a path. It was already exit code 2, but its message was
453
+ # unreachable while nothing opened a file, so naming the class
454
+ # went unnoticed. A missing file is an ordinary user mistake and
455
+ # reads as one.
456
+ return "claricle: #{message}" if MAPPED.any? { |kind| Fault.matches_class?(error, kind) }
457
+
458
+ "claricle: #{Fault.class_name(error)}: #{message}"
459
+ end
460
+ end
33
461
  end
34
462
 
35
- desc "convert SOURCE DEST", "Convert image formats (placeholder)"
36
- long_desc <<~LONGDESC
37
- Convert images between different formats.
463
+ private
38
464
 
39
- Note: This is a placeholder command. Functionality will be
40
- implemented in future versions.
465
+ def tolerate_closed_output
466
+ yield
467
+ rescue Errno::EPIPE
468
+ Runner::Status.new(0)
469
+ end
470
+
471
+ # `--output -` puts converted bytes alone on stdout; `--json` wants the
472
+ # same stream for its array. Checked here, before `Claricle.convert_batch`
473
+ # runs anything, so this is a pure invocation error rather than a
474
+ # partially-converted batch discovering the conflict mid-write.
475
+ def refuse_stdout_json_conflict
476
+ return unless options[:output] == Writer::STDOUT_DESTINATION && options[:json]
477
+
478
+ raise InvocationError, "--json is not supported with --output -"
479
+ end
41
480
 
42
- Examples:
43
- claricle convert image.png image.svg
44
- claricle convert logo.svg logo.png
45
- LONGDESC
46
- def convert(_source, _dest)
47
- puts "Image conversion functionality coming soon!"
48
- puts "This gem is currently a placeholder to reserve the name."
481
+ # `--output -` always processes exactly one file: `convert_batch`
482
+ # refuses more than one source whenever `--output` is given at all, so
483
+ # `result.highest_error` unambiguously means the one write this command
484
+ # made. `BatchResult#highest_error` returns the actual raised exception
485
+ # (not the stringified `BatchError` on the item), so `Errno::EPIPE` is
486
+ # checked directly here.
487
+ def closed_stdout_write?(result)
488
+ options[:output] == Writer::STDOUT_DESTINATION && result.highest_error.is_a?(Errno::EPIPE)
49
489
  end
50
490
 
51
- desc "compress FILE_PATTERN", "Compress image files (placeholder)"
52
- long_desc <<~LONGDESC
53
- Compress image files while maintaining quality.
491
+ # JSON is always an array, a single result included: a filename may
492
+ # legally contain glob characters and a shell expands an unquoted glob
493
+ # before Claricle sees it, so which form the user typed is not knowable
494
+ # and the shape must not depend on it.
495
+ #
496
+ # Human output splits: verdicts to stdout, one line per failed file to
497
+ # stderr. Skipped entirely when every file failed, so `puts` cannot
498
+ # print a bare blank line where the JSON form prints a whole array.
499
+ def write_conformance(result)
500
+ return puts(Models::BatchItem.to_json(result.items)) if options[:json]
501
+
502
+ verdicts = Presenter.conformance(result.items)
503
+ puts verdicts unless verdicts.empty?
504
+ Presenter.conformance_failures(result.items).each { |line| warn line }
505
+ end
506
+
507
+ # One line per successfully converted file: source, target format,
508
+ # written path (or "-"), and the lossiness classification -- the
509
+ # plan card's required shape. `--output -` already put the converted
510
+ # bytes on stdout as a side effect inside `Claricle.convert_batch`, so
511
+ # the summary goes to stderr instead in that mode, keeping the stdout
512
+ # stream pipe-safe; every other mode prints it to stdout, the same
513
+ # split `write_conformance` uses for its own verdict/failure halves.
514
+ # `--json` is unreachable together with stdout mode -- `convert` above
515
+ # already refuses that combination before this ever runs -- so reading
516
+ # `options[:json]` here is safe in both modes.
517
+ def write_convert(result)
518
+ return puts(Models::BatchItem.to_json(result.items)) if options[:json]
519
+
520
+ print_conversion_summary(Presenter.conversions(result.items))
521
+ print_conversion_warnings(result.items)
522
+ Presenter.batch_failures(result.items).each { |line| warn line }
523
+ end
524
+
525
+ # `--output -` already sent the classification to stderr in its summary
526
+ # line, so a second line per file there would only repeat it.
527
+ def print_conversion_warnings(items)
528
+ return if options[:output] == Writer::STDOUT_DESTINATION
529
+
530
+ Presenter.conversion_warnings(items).each { |line| warn line }
531
+ end
54
532
 
55
- Note: This is a placeholder command. Functionality will be
56
- implemented in future versions.
533
+ # The stdout/stderr split `write_convert`'s own comment describes,
534
+ # pulled out so `write_convert` stays one guard clause plus two calls.
535
+ def print_conversion_summary(lines)
536
+ return lines.each { |line| warn line } if options[:output] == Writer::STDOUT_DESTINATION
57
537
 
58
- Examples:
59
- claricle compress image.png
60
- claricle compress *.jpg
61
- LONGDESC
62
- def compress(_pattern)
63
- puts "Image compression functionality coming soon!"
64
- puts "This gem is currently a placeholder to reserve the name."
538
+ puts lines.join("\n") unless lines.empty?
65
539
  end
66
540
  end
67
541
  end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Claricle
4
+ # Validates and normalizes the profile/level pair shared by the module
5
+ # API and Image. A format is optional because batch calls reject global
6
+ # typos before opening files, then Image checks each concrete pairing.
7
+ module ConformanceOptions
8
+ module_function
9
+
10
+ def normalize(profile, level, format: nil)
11
+ profile = profile_name(profile)
12
+ raise InvocationError, "level requires a profile" if profile.nil? && !level.nil?
13
+
14
+ check_format(profile, format) if profile && format
15
+ [profile, level_name(profile, level)]
16
+ end
17
+
18
+ def profile_name(profile)
19
+ return if profile.nil?
20
+
21
+ wanted = profile.to_sym
22
+ return wanted if Registry.profiles.include?(wanted)
23
+
24
+ raise InvocationError, "no format defines a profile named #{profile.inspect}"
25
+ end
26
+
27
+ def check_format(profile, format)
28
+ accepted = Registry.profiles_for(format)
29
+ raise UnsupportedProfile.new(format, profile, accepted) unless accepted.include?(profile)
30
+ end
31
+
32
+ def level_name(profile, level)
33
+ return if level.nil?
34
+
35
+ accepted = Registry.levels_for_profile(profile)
36
+ raise InvocationError, "profile #{profile} does not take a level" unless accepted
37
+
38
+ normalized = level.to_s.downcase.to_sym
39
+ return normalized if accepted.include?(normalized)
40
+
41
+ raise InvocationError,
42
+ "profile #{profile} does not define level #{level.inspect}; choose #{accepted.join(", ")}"
43
+ end
44
+
45
+ private_class_method :profile_name, :check_format, :level_name
46
+ end
47
+
48
+ private_constant :ConformanceOptions
49
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Claricle
4
+ # Loads the render stack only when a conversion has passed its local
5
+ # target and input-size checks. Inspection and conformance do not need it.
6
+ module ConversionEngine
7
+ module_function
8
+
9
+ def load!
10
+ require "vectory"
11
+ require "postsvg"
12
+
13
+ # postsvg 0.3.0 declares UnknownOperator through the wrong autoload
14
+ # path. Loading every operator category once prevents a cold process
15
+ # from failing only when a rendered SVG contains an embedded raster.
16
+ ::Postsvg::Model::Operators.load_all!
17
+ end
18
+ end
19
+
20
+ private_constant :ConversionEngine
21
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Claricle
4
+ # One file in, the one `Models::Conversion` out. The same code path as
5
+ # `convert_batch`, so the write lifecycle, collision and overwrite rules
6
+ # cannot differ; the only change is the failure shape. A batch collects
7
+ # a per-file failure into its result, but with one file there is nothing
8
+ # to carry it past, so the real exception is raised, as `conform?` does.
9
+ #
10
+ # `path` is one file, never a pattern: a name that matches several files
11
+ # is refused before anything is converted.
12
+ def self.convert(path, to: nil, output: nil, force: false)
13
+ files = Batch.expand([path], nil)
14
+ raise InvocationError, "convert takes a single source; #{files.length} files matched" if files.length > 1
15
+
16
+ result = convert_batch(files.first, to: to, output: output, force: force)
17
+ raise result.highest_error if result.highest_error
18
+
19
+ result.items.first.result
20
+ end
21
+ end