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,272 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tempfile"
4
+
5
+ require_relative "detector"
6
+ require_relative "registry"
7
+
8
+ module Claricle
9
+ # What every operation takes and the registry dispatches on. Built from a
10
+ # path or from content; either way it knows its format.
11
+ #
12
+ # A path-born image is a handle to a file, not a snapshot of it.
13
+ # `format` is the verdict `from_path` reached from the bytes that were
14
+ # there at construction, and `content` and `with_path` go back to that
15
+ # name afterwards. So the file must not change while an image is in
16
+ # use -- measured: a detected PNG overwritten with a valid SVG kept
17
+ # reporting :png, and every later read saw the SVG.
18
+ #
19
+ # Stated rather than enforced, deliberately. Binding the format to one
20
+ # generation of bytes means holding that generation, which costs a
21
+ # file-sized allocation retained for the image's whole lifetime -- the
22
+ # reason `content` is lazy here at all, and the reason a handler that
23
+ # only needs a header should never reach for it. A `stat` taken at
24
+ # construction and rechecked on every read would not close the race
25
+ # either: `with_path` hands out a name, and whatever opens that name
26
+ # does so after the check. It would narrow the window in exchange for
27
+ # a guarantee this cannot keep.
28
+ #
29
+ # What a file replaced mid-flight costs is a wrong label on an honest
30
+ # failure -- the handler for the format detected first reports that it
31
+ # could not parse the bytes that are there now. A content-born image
32
+ # has no such window: its bytes and its format are settled together
33
+ # and it owns both.
34
+ class Image
35
+ attr_reader :format, :path
36
+
37
+ KIND_OF = ::Object.instance_method(:is_a?)
38
+ CLASS_OF = ::Object.instance_method(:class)
39
+ private_constant :KIND_OF, :CLASS_OF
40
+
41
+ class << self
42
+ def from_path(path)
43
+ # Checked before anything opens it, for the same reason `binary`
44
+ # runs first in `from_content`. `Detector.detect_path` opens
45
+ # whatever it is handed and `File.open` takes a descriptor as
46
+ # happily as a name -- measured: `Image.from_path(io.fileno)`
47
+ # detected :png, and by the time `initialize` refused the Integer
48
+ # the caller's IO read `Errno::EBADF` while `io.closed?` still
49
+ # said false.
50
+ name = checked_path(path)
51
+ raise InvocationError, "#{name.inspect} is a directory, not a file" if File.directory?(name)
52
+
53
+ new(format: Detector.detect_path(name), path: name)
54
+ end
55
+
56
+ def from_content(content, format: nil)
57
+ # Bytes settled first: detection reads them, so a non-String would
58
+ # die inside the detector with a NoMethodError before the
59
+ # constructor could name what was actually wrong.
60
+ content = binary(content)
61
+ # nil means "detect"; anything else is the caller's answer,
62
+ # including a false one, which the Symbol guard then rejects.
63
+ new(format: format.nil? ? Detector.detect(content) : format, content: content)
64
+ end
65
+
66
+ private
67
+
68
+ def checked_format(format)
69
+ return format if KIND_OF.bind_call(format, ::Symbol)
70
+
71
+ raise ArgumentError, "format must be a Symbol, got #{CLASS_OF.bind_call(format)}"
72
+ end
73
+
74
+ def checked_source(path, content)
75
+ return if path.nil? ^ content.nil?
76
+
77
+ raise ArgumentError, "give exactly one of path: or content:"
78
+ end
79
+
80
+ # A path is read and joined, so it has to be a name -- and one this
81
+ # image owns, for the same reason it owns its bytes. Measured:
82
+ # `path.replace(other_file)` after `from_path(path)` left the image
83
+ # reading the other file while still reporting the format detected
84
+ # from the first, and `image.path << "x"` rewrote it through the
85
+ # reader. A fresh core String makes that ownership independent of
86
+ # a subclass's virtual `frozen?` and `dup`.
87
+ #
88
+ # Refused rather than assumed, because `false` would clear the
89
+ # exactly-one check in `initialize` and then read as "no path" in
90
+ # `with_path`, so the image took the temporary-file branch and died
91
+ # on `File.binread(false)`.
92
+ # A Pathname is converted, not refused. The PNG reader takes one and
93
+ # callers pass one, so refusing it made PNG the odd handler out --
94
+ # it surfaced as NoMethodError on `rewind`, which is off the
95
+ # allowlist and so exited 4, the defect code. Converting keeps every
96
+ # guarantee above: what is stored is still a frozen String the image
97
+ # owns, and `false` still has no `to_path` and is still refused.
98
+ def checked_path(path)
99
+ # `KIND_OF.bind_call`, not `is_a?`: a subclass that overrides
100
+ # `is_a?` must not talk its way past this.
101
+ path = path.to_path if !KIND_OF.bind_call(path, ::String) && path.respond_to?(:to_path)
102
+
103
+ unless KIND_OF.bind_call(path, ::String)
104
+ raise ArgumentError,
105
+ "path must be a String or Pathname, got #{CLASS_OF.bind_call(path)}"
106
+ end
107
+
108
+ ::String.new(path).freeze
109
+ end
110
+
111
+ # Bytes this image owns, whatever the caller tagged them.
112
+ # `File.binread` already gives binary; detection binary-copies its
113
+ # own view (`Detector.detect`) and never the String we keep, so a
114
+ # content-born image was the one place `content` depended on how the
115
+ # caller happened to read the file. Measured: the same 70-byte PNG
116
+ # through `File.read` arrives UTF-8, where `length` is 69 and
117
+ # `valid_encoding?` is false -- a handler indexing or scanning it
118
+ # would disagree with the path-born image.
119
+ #
120
+ # Copied and frozen, not merely re-tagged. An Image is a value, and
121
+ # a caller who kept their String could otherwise rewrite the bytes
122
+ # out from under the format we detected -- measured:
123
+ # `bytes.replace("not a PNG")` after `from_content(bytes)` left the
124
+ # image reporting :png over nine bytes of text. Every input is
125
+ # normalized through a fresh core String: a subclass can lie about
126
+ # `b`, `dup`, `frozen?` or `encoding`, and even an exact frozen
127
+ # String can give `b` singleton behavior that makes detection see
128
+ # different bytes from the ones the image stores.
129
+ #
130
+ # Refused rather than assumed, because `false` clears the
131
+ # exactly-one check in `initialize` and is still falsy -- measured:
132
+ # `Image.new(format: :png, content: false)` built an image, and its
133
+ # `content` then read the nil path and raised "no implicit
134
+ # conversion of nil into String".
135
+ def binary(bytes)
136
+ unless KIND_OF.bind_call(bytes, ::String)
137
+ raise ArgumentError, "content must be a String, got #{CLASS_OF.bind_call(bytes)}"
138
+ end
139
+
140
+ ::String.new(bytes).force_encoding(Encoding::BINARY).freeze
141
+ end
142
+ end
143
+
144
+ def initialize(format:, path: nil, content: nil)
145
+ self.class.send(:checked_format, format)
146
+
147
+ path = self.class.send(:checked_path, path) unless path.nil?
148
+ # Exactly one source. Neither leaves #content reading from a nil
149
+ # path; both is contradictory and would silently prefer one.
150
+ self.class.send(:checked_source, path, content)
151
+ content = self.class.send(:binary, content) unless content.nil?
152
+
153
+ @format = format
154
+ @path = path
155
+ @content = content
156
+ end
157
+
158
+ # Read lazily and then remembered. Detection already streamed the file;
159
+ # slurping it at construction would waste that. Frozen for the same
160
+ # reason a content-born image keeps a copy: a handler that mutated
161
+ # these bytes in place would change what every later handler sees.
162
+ #
163
+ # Remembered, so this and `with_path` can disagree: whichever ran
164
+ # first fixes what it saw, and the other reads the file again. They
165
+ # agree exactly as long as the file does not change, which is the
166
+ # contract stated on the class.
167
+ def content
168
+ @content ||= File.binread(path).freeze
169
+ end
170
+
171
+ # The stream length, without paying #content's full read to learn it.
172
+ # Once #content is cached -- content-born from construction, or
173
+ # path-born and already read -- the cost is already paid and this
174
+ # returns the same number #content.bytesize would. A path-born image
175
+ # that has never been read asks the filesystem for its size instead
176
+ # of reading the whole file to measure it.
177
+ def bytesize
178
+ @content ? @content.bytesize : File.size(path)
179
+ end
180
+
181
+ # Most delegates want a path. A content-born image gets a temporary one
182
+ # that exists only for the block -- Tempfile.create's block form removes
183
+ # it on exit, where Tempfile.new would leave it behind.
184
+ def with_path
185
+ raise ArgumentError, "with_path requires a block" unless block_given?
186
+ return yield(path) if path
187
+
188
+ Tempfile.create(["claricle", ".#{format}"]) do |file|
189
+ file.binmode
190
+ file.write(content)
191
+ file.flush
192
+ yield(file.path)
193
+ end
194
+ end
195
+
196
+ # For a reader that only needs a prefix. A path-born image hands over
197
+ # the open file instead of its bytes, so nothing is read that the
198
+ # reader does not ask for and nothing is retained afterwards.
199
+ # Measured on a 64.0 MiB SVG, whose reader wants 8192 bytes: through
200
+ # #content it cost +64.5 MiB RSS and left the whole file in @content,
201
+ # through this it costs +0.8 MiB and retains nothing.
202
+ #
203
+ # Bytes already in hand are handed straight over, whichever way the
204
+ # image got them. Re-opening the file for an image that has already
205
+ # read it would take a SECOND snapshot of something that may have
206
+ # changed since -- and would fail outright for an image whose file
207
+ # has since gone, which the bytes it is holding do not need.
208
+ #
209
+ # `@content` and not `#content`: the reader would SLURP the file for
210
+ # a path-born image that has never read it, which is the whole cost
211
+ # this method exists to avoid.
212
+ def with_source(&)
213
+ raise ArgumentError, "with_source requires a block" unless block_given?
214
+ return yield(@content) if @content
215
+
216
+ File.open(path, "rb", &)
217
+ end
218
+
219
+ def inspection
220
+ handler.inspection(self)
221
+ end
222
+
223
+ # The keyword reaches the handler ONLY when a profile was asked for.
224
+ # A handler that declares no profiles keeps its one-argument
225
+ # `conformance_report`, and the guard above means it can never be
226
+ # called with a keyword it does not take -- so adding profiles to one
227
+ # format does not change the signature every other handler implements.
228
+ def conformance_report(profile: nil, level: nil)
229
+ profile, level = ConformanceOptions.normalize(profile, level, format: format)
230
+ return handler.conformance_report(self) unless profile
231
+
232
+ options = { profile: profile }
233
+ options[:level] = level if level
234
+ handler.conformance_report(self, **options)
235
+ end
236
+
237
+ # A target the source's handler does not list may still be one whose
238
+ # own handler declared it accepts this format (`Base.convert_from`).
239
+ def convert(to:)
240
+ inbound = Registry.inbound_handler(from: format, to: to)
241
+ return inbound.new.convert_inbound(self, to: to) if inbound
242
+
243
+ handler.convert(self, to: to)
244
+ end
245
+
246
+ private
247
+
248
+ # Per call: handlers are stateless, and a cache would buy nothing.
249
+ def handler
250
+ Registry.handler_for(format).new
251
+ end
252
+
253
+ # Refused, rather than supported. Ruby's default Marshal writes the
254
+ # ivars into a fresh object and runs neither the constructor nor any
255
+ # of its guards -- and it does not carry a String's freeze, so the
256
+ # copy is writable through its own readers. Measured:
257
+ # `copy.content.replace("NOT A PNG AT ALL")` left the copy reporting
258
+ # :png over sixteen bytes of text, and `copy.path.replace("/etc/passwd")`
259
+ # pointed a path-born image at another file while it still reported
260
+ # the format detected from the first.
261
+ #
262
+ # Nothing in this gem forks, caches or crosses a process boundary, so
263
+ # an image is not dumped at all rather than dumped through hooks of
264
+ # its own. The dump side only: `Marshal.load` of bytes from elsewhere
265
+ # is as unsafe here as it is anywhere. Private, because Marshal
266
+ # reaches a private `_dump` -- measured -- and `_dump` rather than
267
+ # `marshal_dump` because Ruby prefers `marshal_dump` where both exist.
268
+ def _dump(_depth)
269
+ raise TypeError, "cannot marshal #{self.class}: a Claricle image is not marshalable"
270
+ end
271
+ end
272
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Claricle
4
+ module Lossiness
5
+ # Split out of `lossiness.rb` so a caller that only wants the three-string
6
+ # vocabulary (`Models::Conversion` reads `LEVELS` for one attribute's
7
+ # `values:` list) does not also load the classifier and, through it,
8
+ # `detector.rb` and REXML. Measured: requiring `lossiness.rb` alone pulled
9
+ # in `REXML` and `Emf` as a side effect of a single 3-element Array
10
+ # constant. This file requires nothing and defines nothing but `LEVELS`.
11
+ LEVELS = %w[lossless lossy unknown].freeze
12
+ end
13
+ end