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,240 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "errors"
|
|
4
|
+
require_relative "fault"
|
|
5
|
+
require_relative "models/batch_item"
|
|
6
|
+
|
|
7
|
+
module Claricle
|
|
8
|
+
# Ordered per-file outcomes plus the batch's aggregate status, so a Ruby
|
|
9
|
+
# caller reaches everything the CLI reaches. Built by Batch; read by
|
|
10
|
+
# everyone else.
|
|
11
|
+
class BatchResult
|
|
12
|
+
attr_reader :items
|
|
13
|
+
|
|
14
|
+
# `BatchResult` is public -- item 04 reuses it -- so the "never empty"
|
|
15
|
+
# invariant `exit_code` relies on has to hold here, not only in
|
|
16
|
+
# `Batch.run`'s own call. A caller building one directly with `[]` would
|
|
17
|
+
# otherwise get a silently nil `exit_code`, exactly what the comment
|
|
18
|
+
# below claims cannot happen.
|
|
19
|
+
def initialize(outcomes)
|
|
20
|
+
raise ArgumentError, "outcomes must not be empty" if outcomes.empty?
|
|
21
|
+
|
|
22
|
+
@outcomes = outcomes.freeze
|
|
23
|
+
@items = outcomes.map(&:item).freeze
|
|
24
|
+
freeze
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Never nil: enforced above, and `Batch.run` never reaches here with an
|
|
28
|
+
# empty batch either -- `expand` raises first. Two guards for one
|
|
29
|
+
# invariant because this class is public and `Batch.run` is not.
|
|
30
|
+
def exit_code
|
|
31
|
+
@items.map(&:exit_code).max
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# The operational failure a predicate should raise: the highest code,
|
|
35
|
+
# and on a tie the earliest path. `find` rather than `max_by`, so which
|
|
36
|
+
# of two equal failures wins is a property of this code rather than of
|
|
37
|
+
# an undocumented tie rule -- the same input has to fail the same way
|
|
38
|
+
# every time.
|
|
39
|
+
def highest_error
|
|
40
|
+
failed = @outcomes.reject { |outcome| outcome.error.nil? }
|
|
41
|
+
return if failed.empty?
|
|
42
|
+
|
|
43
|
+
highest = failed.map { |outcome| outcome.item.exit_code }.max
|
|
44
|
+
failed.find { |outcome| outcome.item.exit_code == highest }.error
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# One batch helper, owned here and reused by conversion. It knows how to
|
|
49
|
+
# turn arguments into files and how to keep going past a failure; it knows
|
|
50
|
+
# nothing about what the operation means. `classify` decides that, so a
|
|
51
|
+
# second operation does not have to work around the first one's rule.
|
|
52
|
+
module Batch
|
|
53
|
+
# The raised exception is kept beside its envelope rather than inside
|
|
54
|
+
# it: a predicate has to re-raise the real thing, and an envelope holds
|
|
55
|
+
# only what serializes.
|
|
56
|
+
Outcome = Data.define(:item, :error)
|
|
57
|
+
private_constant :Outcome
|
|
58
|
+
|
|
59
|
+
# `{a,b}` brace alternation is combinatorial in Ruby's Dir.glob, not
|
|
60
|
+
# linear in the pattern's length -- measured, a bare 100-character
|
|
61
|
+
# --pattern value (`"{a,b}" * 20`) took over ten seconds and a
|
|
62
|
+
# 110-character one did not return inside fifteen, entirely before any
|
|
63
|
+
# filesystem match is attempted. A CLI argument is untrusted input, so
|
|
64
|
+
# it must not be able to turn a short string into an unbounded CPU
|
|
65
|
+
# stall. Eight groups is a generous cap for a real extension list
|
|
66
|
+
# (`*.{png,svg,eps,pdf,gif,bmp,tiff,webp}` is one group of eight
|
|
67
|
+
# alternatives, not eight groups) and costs nothing measurable even at
|
|
68
|
+
# the cap.
|
|
69
|
+
MAX_BRACES = 8
|
|
70
|
+
private_constant :MAX_BRACES
|
|
71
|
+
|
|
72
|
+
# The group count alone is not enough: a FEW groups with MANY
|
|
73
|
+
# alternatives each multiply just as badly as many groups with few --
|
|
74
|
+
# measured, eight groups of eight alternatives (`{x1,...,x8}` repeated
|
|
75
|
+
# eight times, still under MAX_BRACES) never returned. Estimated as the
|
|
76
|
+
# product of (alternatives + 1) per non-nested `{...}` group -- it
|
|
77
|
+
# undercounts a nested pattern, but nesting was measured far cheaper
|
|
78
|
+
# than flat repetition for the same brace count (a 4-level-deep,
|
|
79
|
+
# 8-alternative-per-level pattern returned in under a millisecond), so
|
|
80
|
+
# MAX_BRACES is what bounds nesting and this bounds flat alternation.
|
|
81
|
+
MAX_GLOB_COMBINATIONS = 1024
|
|
82
|
+
private_constant :MAX_GLOB_COMBINATIONS
|
|
83
|
+
|
|
84
|
+
GLOB_METACHARACTERS = /[*?\[{]/
|
|
85
|
+
private_constant :GLOB_METACHARACTERS
|
|
86
|
+
|
|
87
|
+
class << self
|
|
88
|
+
def run(arguments, classify:, pattern: nil, &operation)
|
|
89
|
+
run_files(expand(arguments, pattern), classify: classify, &operation)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Runs over an ALREADY-expanded file list, skipping expansion
|
|
93
|
+
# entirely. `Claricle.convert_batch` calls `expand` itself first (to
|
|
94
|
+
# build its destination preflight) and hands the result straight
|
|
95
|
+
# here -- expanding it a second time would redo every `File.file?`/
|
|
96
|
+
# `File.realpath` call for nothing, and would silently drop a file
|
|
97
|
+
# that vanished between the two calls (TOCTOU) even though a
|
|
98
|
+
# destination had already been preflighted for it.
|
|
99
|
+
def run_files(files, classify:, &operation)
|
|
100
|
+
BatchResult.new(files.map { |path| outcome(path, classify, &operation) })
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# A positional is a literal path when it names an existing file and a
|
|
104
|
+
# glob otherwise; a pattern is always a glob, which is how a filename
|
|
105
|
+
# that legitimately contains glob characters is reached the other way.
|
|
106
|
+
# `--pattern` adds to the positionals rather than replacing them.
|
|
107
|
+
#
|
|
108
|
+
# `File.file?` decides both times, and it also drops what a glob
|
|
109
|
+
# returns that no operation can open: a directory, or a symlink whose
|
|
110
|
+
# target is gone. Either would otherwise cost exit 4 -- the code for
|
|
111
|
+
# an internal defect -- for an ordinary mismatch.
|
|
112
|
+
#
|
|
113
|
+
# Sorted before deduplicating, so which spelling of two names for one
|
|
114
|
+
# file survives does not depend on the order they were given in.
|
|
115
|
+
#
|
|
116
|
+
# Public, unlike the rest of this class: `Claricle.convert_batch`
|
|
117
|
+
# needs the expanded file list before it can preflight the whole
|
|
118
|
+
# destination set up front. `glob`/`glob_combinations`/
|
|
119
|
+
# `nothing_matched` stay private -- only this method is called from
|
|
120
|
+
# outside `Batch`.
|
|
121
|
+
def expand(arguments, pattern)
|
|
122
|
+
arguments = arguments.map { |argument| path_string(argument) }
|
|
123
|
+
pattern = path_string(pattern) if pattern
|
|
124
|
+
files = matched_files(arguments, pattern)
|
|
125
|
+
raise InvocationError, nothing_matched(arguments, pattern) if files.empty?
|
|
126
|
+
|
|
127
|
+
files
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
private
|
|
131
|
+
|
|
132
|
+
def path_string(value)
|
|
133
|
+
File.path(value)
|
|
134
|
+
rescue TypeError, ArgumentError => e
|
|
135
|
+
raise InvocationError, "invalid path: #{e.message}"
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def matched_files(arguments, pattern)
|
|
139
|
+
found = arguments.flat_map { |argument| files_for(argument) }
|
|
140
|
+
found.concat(glob(pattern)) if pattern
|
|
141
|
+
files = found.select { |path| File.file?(path) }
|
|
142
|
+
.sort.uniq { |path| File.realpath(path) }
|
|
143
|
+
reject_invalid_literals(arguments, files)
|
|
144
|
+
files
|
|
145
|
+
rescue ArgumentError => e
|
|
146
|
+
raise InvocationError, "invalid path: #{e.message}"
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def files_for(argument)
|
|
150
|
+
File.file?(argument) ? [argument] : glob(argument)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Without a glob metacharacter, a nonexistent argument is a likely
|
|
154
|
+
# literal typo rather than an intentionally unmatched pattern.
|
|
155
|
+
def missing_literal?(argument)
|
|
156
|
+
!argument.match?(GLOB_METACHARACTERS) && !File.exist?(argument)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Once something else matched, an invalid literal must not disappear.
|
|
160
|
+
# With no matches, `nothing_matched` keeps the lone-input wording.
|
|
161
|
+
def reject_invalid_literals(arguments, files)
|
|
162
|
+
return if files.empty?
|
|
163
|
+
|
|
164
|
+
reject_directory(arguments)
|
|
165
|
+
reject_missing_literal(arguments)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def reject_directory(arguments)
|
|
169
|
+
directory = arguments.find { |argument| File.directory?(argument) }
|
|
170
|
+
raise InvocationError, "#{directory.inspect} is a directory, not a file" if directory
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def reject_missing_literal(arguments)
|
|
174
|
+
missing = arguments.find { |argument| missing_literal?(argument) }
|
|
175
|
+
raise InvocationError, "No such file or directory - #{missing}" if missing
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def outcome(path, classify)
|
|
179
|
+
result = yield(path)
|
|
180
|
+
Outcome.new(
|
|
181
|
+
item: Models::BatchItem.new(path: path, exit_code: classify.call(result),
|
|
182
|
+
result: result),
|
|
183
|
+
error: nil
|
|
184
|
+
)
|
|
185
|
+
# The set the CLI runner rescues, so Interrupt still propagates and
|
|
186
|
+
# Ctrl-C does not become a row in a report.
|
|
187
|
+
rescue StandardError, ScriptError, SystemStackError => e
|
|
188
|
+
Outcome.new(item: failed_item(path, e), error: e)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# Fault owns both diagnostic values. Besides keeping this path aligned
|
|
192
|
+
# with the CLI, it reads the real exception class without trusting an
|
|
193
|
+
# overridable #class method and supplies a printable name for an
|
|
194
|
+
# anonymous class. `BatchError#code` is required, so either gap would
|
|
195
|
+
# otherwise abort from inside the rescue meant to collect the failure.
|
|
196
|
+
def failed_item(path, error)
|
|
197
|
+
Models::BatchItem.new(
|
|
198
|
+
path: path,
|
|
199
|
+
exit_code: Fault.exit_code(error),
|
|
200
|
+
error: Models::BatchError.new(code: Fault.class_name(error),
|
|
201
|
+
message: Fault.message(error))
|
|
202
|
+
)
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def glob(text)
|
|
206
|
+
if text.count("{") > MAX_BRACES
|
|
207
|
+
raise InvocationError,
|
|
208
|
+
"glob #{text.inspect} has too many { groups (max #{MAX_BRACES})"
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
if glob_combinations(text) > MAX_GLOB_COMBINATIONS
|
|
212
|
+
raise InvocationError,
|
|
213
|
+
"glob #{text.inspect} would expand to too many combinations " \
|
|
214
|
+
"(max #{MAX_GLOB_COMBINATIONS})"
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
Dir.glob(text)
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def glob_combinations(text)
|
|
221
|
+
text.scan(/\{[^{}]*\}/).reduce(1) { |total, group| total * (group.count(",") + 1) }
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# Two different mistakes, so two different sentences: naming nothing
|
|
225
|
+
# is not the same as naming something that matched nothing, and
|
|
226
|
+
# "no files matched " with an empty list tells a user neither.
|
|
227
|
+
def nothing_matched(arguments, pattern)
|
|
228
|
+
given = [*arguments, pattern].compact
|
|
229
|
+
return "no files given" if given.empty?
|
|
230
|
+
|
|
231
|
+
directory = arguments.find { |argument| File.directory?(argument) }
|
|
232
|
+
return "#{directory.inspect} is a directory, not a file" if directory
|
|
233
|
+
|
|
234
|
+
"no files matched #{given.map(&:inspect).join(", ")}"
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
private_constant :Batch
|
|
240
|
+
end
|