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
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: `
` 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
error: forged"` produced an "error:"
|
|
265
|
+
# line of Claricle's own shape, and `
` 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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
20
|
-
|
|
406
|
+
arguments[1] = command_text(arguments[1])
|
|
407
|
+
end
|
|
21
408
|
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
36
|
-
long_desc <<~LONGDESC
|
|
37
|
-
Convert images between different formats.
|
|
463
|
+
private
|
|
38
464
|
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
56
|
-
|
|
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
|
-
|
|
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
|