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