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
@@ -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