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,787 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rexml/document"
|
|
4
|
+
require "rexml/parsers/baseparser"
|
|
5
|
+
|
|
6
|
+
require_relative "base"
|
|
7
|
+
require_relative "../conversion_engine"
|
|
8
|
+
require_relative "../detector"
|
|
9
|
+
require_relative "../emf_header"
|
|
10
|
+
require_relative "../models/inspection"
|
|
11
|
+
require_relative "../models/issue"
|
|
12
|
+
require_relative "../models/location"
|
|
13
|
+
require_relative "../models/report"
|
|
14
|
+
require_relative "../models/conversion"
|
|
15
|
+
require_relative "../lossiness"
|
|
16
|
+
require_relative "../postscript_fidelity"
|
|
17
|
+
|
|
18
|
+
module Claricle
|
|
19
|
+
module Handlers
|
|
20
|
+
# Reports what an SVG's root element declares. It reads the root and
|
|
21
|
+
# nothing else: everything inspection reports lives there, and parsing
|
|
22
|
+
# megabytes of path data to fetch the root's attributes would be work
|
|
23
|
+
# with no output.
|
|
24
|
+
#
|
|
25
|
+
# So `parse_status` is scoped to the root prefix, and says only that
|
|
26
|
+
# the root start tag was readable. Bytes past that tag are read --
|
|
27
|
+
# the bound is 8192 either way -- but nothing in them is parsed, so
|
|
28
|
+
# a document malformed only after it still reports "ok". Measured on
|
|
29
|
+
# five shapes: damage after the root, a second root element, an
|
|
30
|
+
# unclosed root, a mismatched end tag and a truncation mid-body.
|
|
31
|
+
# REXML's DOM rejects all five, this reports "ok" for all five, and
|
|
32
|
+
# a spec pins each one.
|
|
33
|
+
#
|
|
34
|
+
# That is a narrower promise than well-formedness, not a weaker
|
|
35
|
+
# check. D23 asks that the status come from Claricle's own
|
|
36
|
+
# structural check rather than from a delegate staying quiet, and
|
|
37
|
+
# this one is affirmative: the reader must hand back a root start
|
|
38
|
+
# event or the inspection fails. Judging the whole document is
|
|
39
|
+
# conformance (D16, item 03), and it would cost the bounded read --
|
|
40
|
+
# a 64.0 MiB SVG costs +0.8 MiB here and +64.5 MiB once every byte
|
|
41
|
+
# has to be parsed.
|
|
42
|
+
class Svg < Base
|
|
43
|
+
formats :svg
|
|
44
|
+
convert_to :eps, :ps, :emf
|
|
45
|
+
|
|
46
|
+
# Symbol -> the vectory method it dispatches to, matching
|
|
47
|
+
# Handlers::Metafile's own TARGET_METHODS convention for the same job.
|
|
48
|
+
CONVERT_TARGET_METHODS = { eps: :to_eps, ps: :to_ps, emf: :to_emf }.freeze
|
|
49
|
+
|
|
50
|
+
# Matches Handlers::Metafile's own MAX_CONVERT_BYTES: bound the READ
|
|
51
|
+
# itself, not the bytesize checked after the fact -- #content and
|
|
52
|
+
# #bytesize are different filesystem calls for a path-born image, so a
|
|
53
|
+
# stat-then-read check still materialises an oversized file before
|
|
54
|
+
# refusing it.
|
|
55
|
+
MAX_CONVERT_BYTES = 200 * 1024 * 1024
|
|
56
|
+
|
|
57
|
+
# svg_conform's own six, measured from `Profiles.available_profiles`
|
|
58
|
+
# on a cleared cache rather than copied from its README, and pinned
|
|
59
|
+
# by a spec so a release that adds or drops one says so here. `base`
|
|
60
|
+
# is FIRST because D21 makes it what a plain `conform` runs.
|
|
61
|
+
profiles :base, :lucid_fix, :metanorma, :no_external_css,
|
|
62
|
+
:svg_1_2_rfc, :svg_1_2_rfc_with_rdf
|
|
63
|
+
|
|
64
|
+
# CSS absolute lengths, all defined against 1in = 96px. Computed
|
|
65
|
+
# from that anchor rather than copied: 72pt, 6pc and 1in all come
|
|
66
|
+
# to 96.0. Q is listed because CSS defines it: an absolute unit
|
|
67
|
+
# missing from this table is indistinguishable from a relative one,
|
|
68
|
+
# and both come back nil -- the right answer for `%`, the wrong
|
|
69
|
+
# answer for `Q`. Relative units need no table of their own; they
|
|
70
|
+
# are simply anything absent here.
|
|
71
|
+
PX_PER_INCH = 96.0
|
|
72
|
+
ABSOLUTE_UNITS = {
|
|
73
|
+
"px" => 1.0,
|
|
74
|
+
"in" => PX_PER_INCH,
|
|
75
|
+
"pc" => PX_PER_INCH / 6.0,
|
|
76
|
+
"pt" => PX_PER_INCH / 72.0,
|
|
77
|
+
"cm" => PX_PER_INCH / 2.54,
|
|
78
|
+
"mm" => PX_PER_INCH / 25.4,
|
|
79
|
+
"q" => PX_PER_INCH / 101.6
|
|
80
|
+
}.freeze
|
|
81
|
+
|
|
82
|
+
# SVG's own number grammar, not Ruby's: `Float("1.")` is 1.0 and
|
|
83
|
+
# `Float("1.e2")` is 100.0, but SVG requires a digit after the
|
|
84
|
+
# decimal point, so those are not dimensions at all.
|
|
85
|
+
NUMBER = /[+-]?(?:\d+\.\d+|\.\d+|\d+)(?:e[+-]?\d+)?/i
|
|
86
|
+
# Surrounding whitespace survives XML attribute-value normalization
|
|
87
|
+
# for a CDATA attribute -- `width=" 100"` arrives with the space --
|
|
88
|
+
# and it is not part of the value the document means.
|
|
89
|
+
#
|
|
90
|
+
# XML's whitespace set explicitly, not `\s`: `\s` also matches
|
|
91
|
+
# vertical tab and form feed, which XML does not treat as
|
|
92
|
+
# whitespace, so `width="100"` would read as 100 when it is
|
|
93
|
+
# not a dimension at all.
|
|
94
|
+
XML_SPACE = /[ \t\r\n]*/
|
|
95
|
+
DIMENSION = /\A#{XML_SPACE}(?<number>#{NUMBER})(?<unit>[a-z%]*)#{XML_SPACE}\z/i
|
|
96
|
+
ISSUE_CODE = "svg.root_unreadable"
|
|
97
|
+
ISSUE_MESSAGE = "SVG root element could not be read"
|
|
98
|
+
|
|
99
|
+
# Builds the `Report` a conform operation returns, kept apart from
|
|
100
|
+
# root-attribute interpretation for the same reason `Png` keeps its
|
|
101
|
+
# own mapper apart: the two share a file and nothing else.
|
|
102
|
+
class ConformanceMapper
|
|
103
|
+
# D21: a generic `conform` runs the `base` requirement set, which
|
|
104
|
+
# is why `Svg.profiles` lists it first. The other five are reached
|
|
105
|
+
# through `--profile`.
|
|
106
|
+
#
|
|
107
|
+
# What matters is that it is passed AT ALL. svg_conform's own
|
|
108
|
+
# default is `:svg_1_2_rfc`, a far stricter set: measured on 0.2.2,
|
|
109
|
+
# it reports a `color_restrictions` error against
|
|
110
|
+
# `spec/fixtures/conform/valid.svg`, which `base` passes clean. So
|
|
111
|
+
# omitting the profile does not mean "no profile", it means the
|
|
112
|
+
# wrong one, quietly.
|
|
113
|
+
#
|
|
114
|
+
# Either route reaches it, and an earlier version of this comment
|
|
115
|
+
# had that backwards. `validate_file` declares
|
|
116
|
+
# `profile: :svg_1_2_rfc`, then calls on with
|
|
117
|
+
# `profile: profile, **merged_options` -- and `merged_options`
|
|
118
|
+
# carries whatever `Validator.new` was given, splatted AFTER the
|
|
119
|
+
# keyword, so a constructor option WINS over it. Measured all three
|
|
120
|
+
# ways against that fixture: constructor-only `[]`, keyword-only
|
|
121
|
+
# `[]`, neither `["color_restrictions"]`. The keyword is used here
|
|
122
|
+
# because it reads as an argument to the call it applies to.
|
|
123
|
+
# svg_conform's own three buckets. `ValidationResult` carries
|
|
124
|
+
# exactly these -- it takes `context.errors`, `context.warnings`
|
|
125
|
+
# and `context.validity_errors` in its constructor and drops
|
|
126
|
+
# `context.infos` on the floor, so a notice raised by a
|
|
127
|
+
# requirement cannot be reported however it is collected here.
|
|
128
|
+
# Measured on 0.2.2; `ValidationResult` has no `infos` reader.
|
|
129
|
+
BUCKETS = %i[errors warnings validity_errors].freeze
|
|
130
|
+
|
|
131
|
+
# An issue's own `severity` is optional and usually absent:
|
|
132
|
+
# `Errors::ValidationIssue#initialize` defaults it to nil, and
|
|
133
|
+
# `ErrorTracker#add_warning` and `#add_notice` never pass one. So
|
|
134
|
+
# `type` is the fallback, and it is the reliable field -- the
|
|
135
|
+
# tracker sets it to :error, :warning or :info at every one of the
|
|
136
|
+
# three places it builds an issue.
|
|
137
|
+
#
|
|
138
|
+
# `:validity_error` is the one value that is a severity and not a
|
|
139
|
+
# type: `add_error` reads it to file the issue under
|
|
140
|
+
# `validity_errors` while still stamping `type: :error`. It means
|
|
141
|
+
# error here, so it maps to one.
|
|
142
|
+
SEVERITIES = {
|
|
143
|
+
error: "error", validity_error: "error",
|
|
144
|
+
warning: "warning", info: "info"
|
|
145
|
+
}.freeze
|
|
146
|
+
|
|
147
|
+
# An issue whose type svg_conform grows past the three above. It
|
|
148
|
+
# is reported rather than dropped, at the severity that cannot
|
|
149
|
+
# understate it -- a report is read to decide whether a file is
|
|
150
|
+
# usable, and a silently missing error is the one outcome worth
|
|
151
|
+
# refusing.
|
|
152
|
+
DEFAULT_SEVERITY = "error"
|
|
153
|
+
|
|
154
|
+
# `requirement_id` is derived, never nil: it falls back to the
|
|
155
|
+
# rule's id, then to the rule class's name, then to the string
|
|
156
|
+
# "unknown". So unlike PNG, no code has to be slugged out of the
|
|
157
|
+
# message here.
|
|
158
|
+
# Well-formedness FIRST, and it is not belt-and-braces. svg_conform
|
|
159
|
+
# 0.2.2 collects XML parse failures in `SaxValidationHandler`'s own
|
|
160
|
+
# `@parse_errors`, which `ValidationResult` never carries -- so
|
|
161
|
+
# mapping its three buckets cannot tell a valid document from an
|
|
162
|
+
# unparseable one. Measured on three shapes, every one reported
|
|
163
|
+
# `valid: :yes`, no issues, CLI exit 0:
|
|
164
|
+
#
|
|
165
|
+
# <svg ...><g></svg> mismatched end tag
|
|
166
|
+
# <svg ...><rect wid truncated mid-attribute
|
|
167
|
+
# <svg .../><svg/> two root elements
|
|
168
|
+
#
|
|
169
|
+
# A conformance report that calls a broken file conformant is the
|
|
170
|
+
# one answer this operation must never give, so the check runs
|
|
171
|
+
# before the delegate and short-circuits it.
|
|
172
|
+
#
|
|
173
|
+
# This is where the whole document gets parsed, and that is
|
|
174
|
+
# correct HERE where it is wrong in `inspection`: D16 makes
|
|
175
|
+
# judging the whole document conformance's job, and the bounded
|
|
176
|
+
# 8192-byte read exists to keep `inspect` cheap, not to keep
|
|
177
|
+
# `conform` shallow.
|
|
178
|
+
def self.report(image, profile:)
|
|
179
|
+
image.with_path do |path|
|
|
180
|
+
malformed = not_well_formed(path)
|
|
181
|
+
next malformed if malformed
|
|
182
|
+
|
|
183
|
+
report_for(image, path, profile)
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# REXML is already a runtime dependency and raises one class for
|
|
188
|
+
# every shape above, so nothing here has to enumerate what "broken"
|
|
189
|
+
# means. Encoding failures come back as the same refusal: a
|
|
190
|
+
# document Ruby cannot decode is one no validator can judge.
|
|
191
|
+
def self.not_well_formed(path)
|
|
192
|
+
::REXML::Document.new(::File.read(path))
|
|
193
|
+
nil
|
|
194
|
+
rescue ::REXML::ParseException, ::EncodingError, ::ArgumentError => e
|
|
195
|
+
malformed_report(path, e)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# The first line of REXML's message only: it renders the whole
|
|
199
|
+
# offending source after it, which would put an arbitrary slice of
|
|
200
|
+
# someone's document into a report a caller may well print.
|
|
201
|
+
def self.malformed_report(path, error)
|
|
202
|
+
Models::Report.new(
|
|
203
|
+
source_path: path, format: "svg",
|
|
204
|
+
validator_version: ::SvgConform::VERSION,
|
|
205
|
+
issues: [Models::Issue.new(
|
|
206
|
+
severity: "error", code: "svg.not_well_formed",
|
|
207
|
+
message: "the document is not well-formed XML: #{error.message.lines.first.to_s.strip}"
|
|
208
|
+
)]
|
|
209
|
+
)
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def self.report_for(image, path, profile)
|
|
213
|
+
warm_profile_cache
|
|
214
|
+
result = ::SvgConform::Validator.new.validate_file(path, profile: profile)
|
|
215
|
+
|
|
216
|
+
Models::Report.new(
|
|
217
|
+
source_path: image.path, format: image.format.to_s,
|
|
218
|
+
profile: profile.to_s, validator_version: ::SvgConform::VERSION,
|
|
219
|
+
issues: issues_from(result, profile: profile)
|
|
220
|
+
)
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# ADDS to svg_conform's profile cache; never clears it. Validating
|
|
224
|
+
# loads only the profile it used, and `Profiles.available_profiles`
|
|
225
|
+
# answers from the cache once the cache is non-empty -- so one
|
|
226
|
+
# `base` run leaves the process believing `base` is the only
|
|
227
|
+
# profile that exists, measured six before and `[:base]` after.
|
|
228
|
+
#
|
|
229
|
+
# Clearing the cache fixes that count and breaks something worse.
|
|
230
|
+
# `Profiles` keys its cache in a CLASS VARIABLE shared with every
|
|
231
|
+
# other user of the gem in the process, so a host that customised
|
|
232
|
+
# a profile through svg_conform's own public API loses that
|
|
233
|
+
# customisation the first time Claricle conforms anything --
|
|
234
|
+
# measured: a host removing `viewbox_required` from `base` saw it
|
|
235
|
+
# come back after one Claricle call. A library inside someone
|
|
236
|
+
# else's process does not get to reset their state.
|
|
237
|
+
#
|
|
238
|
+
# Warming from OUR OWN declaration is what makes this possible.
|
|
239
|
+
# `available_profiles` cannot be the source, because once the cache
|
|
240
|
+
# has collapsed it reports the collapsed list -- the very thing
|
|
241
|
+
# being repaired. `Svg.profiles` is declared, not derived, so it
|
|
242
|
+
# says the same six whatever the cache currently holds.
|
|
243
|
+
#
|
|
244
|
+
# Idempotent and cheap when warm: every call is a cache hit.
|
|
245
|
+
# Measured cold, all six load in 54.7 ms once; a warm validation
|
|
246
|
+
# costs 0.08 ms against 0.81 ms when the cache was cleared each
|
|
247
|
+
# time, so a glob batch stops paying a reload per file.
|
|
248
|
+
def self.warm_profile_cache
|
|
249
|
+
Svg.supported_profiles.each { |name| ::SvgConform::Profiles.get(name) }
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def self.issues_from(result, profile:)
|
|
253
|
+
BUCKETS.flat_map { |bucket| result.public_send(bucket).to_a }
|
|
254
|
+
.reject { |raw| base_foreign_namespace_issue?(raw, profile) }
|
|
255
|
+
.map { |raw| issue_from(raw) }
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# SVG 1.1 permits foreign-namespace elements. svg_conform's base
|
|
259
|
+
# profile applies its SVG-only element whitelist to them; root
|
|
260
|
+
# namespace failures have no parent and remain.
|
|
261
|
+
def self.base_foreign_namespace_issue?(raw, profile)
|
|
262
|
+
data = raw.data
|
|
263
|
+
profile == :base && raw.requirement_id.to_s == "namespace" &&
|
|
264
|
+
data.key?(:element_name) && data.key?(:namespace) &&
|
|
265
|
+
raw.node.respond_to?(:parent) && !raw.node.parent.nil?
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def self.issue_from(raw)
|
|
269
|
+
Models::Issue.new(
|
|
270
|
+
severity: SEVERITIES.fetch(raw.severity || raw.type, DEFAULT_SEVERITY),
|
|
271
|
+
code: raw.requirement_id.to_s,
|
|
272
|
+
message: raw.message.to_s,
|
|
273
|
+
location: location_for(raw)
|
|
274
|
+
)
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
# `line` and `column` are read off the issue's node, and the SAX
|
|
278
|
+
# path leaves both nil on every issue measured. nil rather than an
|
|
279
|
+
# all-nil Location, for the same reason Png gives: nothing here
|
|
280
|
+
# should invent a position svg_conform did not report.
|
|
281
|
+
def self.location_for(raw)
|
|
282
|
+
line = raw.line
|
|
283
|
+
column = raw.column
|
|
284
|
+
return nil if line.nil? && column.nil?
|
|
285
|
+
|
|
286
|
+
Models::Location.new(line: line, column: column)
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
private_class_method :report_for, :issues_from, :base_foreign_namespace_issue?,
|
|
290
|
+
:issue_from, :location_for,
|
|
291
|
+
:not_well_formed, :warm_profile_cache,
|
|
292
|
+
:malformed_report
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
private_constant :ConformanceMapper
|
|
296
|
+
|
|
297
|
+
private_constant :PX_PER_INCH, :ABSOLUTE_UNITS, :NUMBER, :XML_SPACE, :DIMENSION,
|
|
298
|
+
:ISSUE_CODE, :ISSUE_MESSAGE, :CONVERT_TARGET_METHODS, :MAX_CONVERT_BYTES
|
|
299
|
+
|
|
300
|
+
def convert(image, to:)
|
|
301
|
+
raise UnsupportedFormat.new(image.format, :convert, target: to) unless self.class.convert_targets.include?(to)
|
|
302
|
+
|
|
303
|
+
content = bounded_content(image).tap { ConversionEngine.load! }
|
|
304
|
+
converted = convert_content(content, to)
|
|
305
|
+
build_conversion(image, to, content, converted)
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
# Claricle's own structural verdict on a WHOLE SVG (D23), as
|
|
309
|
+
# against `inspection` below, which is scoped to the root prefix
|
|
310
|
+
# and stays that way. No delegate is consulted: svg_conform's
|
|
311
|
+
# `base` profile returns zero errors for raw binary, and UTF-32
|
|
312
|
+
# was measured passing every profile silently, so both have to be
|
|
313
|
+
# REFUSED here before any profile validation runs.
|
|
314
|
+
#
|
|
315
|
+
# "Refused", not "diagnosed as an encoding problem". Four routes
|
|
316
|
+
# reach `svg.encoding_unusable` and they do not divide along
|
|
317
|
+
# "REXML could not decode it":
|
|
318
|
+
#
|
|
319
|
+
# * a bad encoding NAME, which decodes perfectly -- a bare
|
|
320
|
+
# ArgumentError from REXML's encoding setter;
|
|
321
|
+
# * undecodable bytes in the prolog -- a bare EncodingError;
|
|
322
|
+
# * undecodable bytes after a start tag -- the same failure
|
|
323
|
+
# WRAPPED in a ParseException;
|
|
324
|
+
# * undecodable bytes REXML reports as a wrapped ArgumentError.
|
|
325
|
+
#
|
|
326
|
+
# Everything else is `svg.not_well_formed`, and which code a given
|
|
327
|
+
# file gets is REXML's decision rather than a promise made here --
|
|
328
|
+
# measured on UTF-32, only big-endian WITH a BOM reaches the
|
|
329
|
+
# encoding code while the other three describe stray null bytes,
|
|
330
|
+
# and across nine raw-binary shapes it is seven against two. Both
|
|
331
|
+
# codes are a refusal, which is what D23 needs.
|
|
332
|
+
#
|
|
333
|
+
# It reads the entire document and holds it. The overhead on top
|
|
334
|
+
# of that tracks the largest single construct the parser holds --
|
|
335
|
+
# one attribute value, or one element's attribute set -- and NOT
|
|
336
|
+
# the file size, so there is no meaningful multiplier to quote.
|
|
337
|
+
# Measured on 64.0 MiB inputs against a 64 MiB bare read:
|
|
338
|
+
# ordinary documents (deep nesting, 40 attributes an element,
|
|
339
|
+
# many small elements) cost 1.7x the file, while one valid
|
|
340
|
+
# document carrying millions of attributes on a single element
|
|
341
|
+
# cost 12.0x, and a single 64 MiB attribute value 4.6x. The
|
|
342
|
+
# blow-up needs an adversarial document, which is exactly what a
|
|
343
|
+
# conformance checker is handed. That is conformance's cost to
|
|
344
|
+
# pay: item 02 puts the bounded 8192-byte read in `inspect` and
|
|
345
|
+
# says whole-document well-formedness belongs here.
|
|
346
|
+
#
|
|
347
|
+
# What it never does, on any input, is build a document tree --
|
|
348
|
+
# measured at zero REXML::Element objects where REXML's DOM builds
|
|
349
|
+
# one per element and peaked at 1.0-1.4 GiB on the same files.
|
|
350
|
+
# That is a structural property, not a memory bound: the
|
|
351
|
+
# many-attribute run above also built zero.
|
|
352
|
+
module Structure
|
|
353
|
+
# DIVERGES from XML's full well-formedness in BOTH directions,
|
|
354
|
+
# in a way worth stating precisely because item 03 documents per
|
|
355
|
+
# format exactly what `conform` checks, and it will quote this.
|
|
356
|
+
# It stays silent on three families a validator rejects, and it
|
|
357
|
+
# reports an error on four names a validator accepts. Neither
|
|
358
|
+
# direction implies the other, so both are stated below.
|
|
359
|
+
#
|
|
360
|
+
# `REXML::Text.check` enforces TWO rules, and it runs only when
|
|
361
|
+
# the DOM builds Text nodes. This route builds none, so BOTH are
|
|
362
|
+
# skipped:
|
|
363
|
+
#
|
|
364
|
+
# * the Char production -- `<svg>a\u0000b</svg>`, and the
|
|
365
|
+
# references `�`, `�`, ``, ``;
|
|
366
|
+
# * XML 1.0 2.4's markup delimiters -- a bare `&` or a raw `<`
|
|
367
|
+
# where a reference was required, as in the commonest
|
|
368
|
+
# malformed SVG there is, `<svg>Tom & Jerry</svg>`, and in
|
|
369
|
+
# `id="a<b"`.
|
|
370
|
+
#
|
|
371
|
+
# The three families in this section are all false NEGATIVES:
|
|
372
|
+
# input this scan calls clean that a validator rejects. The
|
|
373
|
+
# opposite direction is real too and is stated at the end of
|
|
374
|
+
# this comment.
|
|
375
|
+
#
|
|
376
|
+
# A THIRD family is unrelated to `Text.check`: a CDATA section
|
|
377
|
+
# at top level AFTER the root element. `count_roots` sees a
|
|
378
|
+
# :cdata event, which it ignores, so `<svg/><![CDATA[x]]>` scans
|
|
379
|
+
# clean while xmllint calls it "Extra content at the end of the
|
|
380
|
+
# document". The boundary is narrow and was measured: a comment,
|
|
381
|
+
# a PI or whitespace after the root are LEGAL and correctly
|
|
382
|
+
# clean; text after the root, and a CDATA section BEFORE it, are
|
|
383
|
+
# both already caught as not-well-formed.
|
|
384
|
+
#
|
|
385
|
+
# The second family is NOT a Char-production case: `&` and `<`
|
|
386
|
+
# are perfectly legal XML Chars, which is why naming only the
|
|
387
|
+
# Char production described half the gap. Every shape measured
|
|
388
|
+
# when that sentence was written happened to be a Char
|
|
389
|
+
# violation, so the corpus could not tell "the Char production
|
|
390
|
+
# is unchecked" from "Text.check never runs" -- and the sentence
|
|
391
|
+
# asserted the narrower reading. The `&` examples above separate
|
|
392
|
+
# the two; the Char examples do not.
|
|
393
|
+
#
|
|
394
|
+
# Distinct from the undefined-entity limit below: REXML's DOM
|
|
395
|
+
# ACCEPTS `&nope;`, so that limit is a place the DOM agrees with
|
|
396
|
+
# us, not a case it catches and we miss.
|
|
397
|
+
#
|
|
398
|
+
# FIXED, not disclosed: four characters are legal in an XML name
|
|
399
|
+
# and REXML's OWN published NCNAME_STR accepts them, but REXML's
|
|
400
|
+
# live grammar refused them, which would have reported a VALID
|
|
401
|
+
# document as svg.not_well_formed (U+00B7 middle dot, U+0300
|
|
402
|
+
# combining grave, U+203F undertie, U+2040 character tie --
|
|
403
|
+
# measured against xmllint, which calls all four valid). Fixed by
|
|
404
|
+
# parsing through `Detector.canonical_source` (see `count_roots`
|
|
405
|
+
# below), the same grammar patch `Detector::RootSource` already
|
|
406
|
+
# applies for the bounded root read, rather than reimplementing
|
|
407
|
+
# it here.
|
|
408
|
+
NOT_WELL_FORMED_CODE = "svg.not_well_formed"
|
|
409
|
+
ENCODING_UNUSABLE_CODE = "svg.encoding_unusable"
|
|
410
|
+
MULTIPLE_ROOTS_CODE = "svg.multiple_root_elements"
|
|
411
|
+
UNDECODABLE_MESSAGE = "SVG source is not decodable text"
|
|
412
|
+
UNDECODABLE_CAUSES = [ArgumentError, EncodingError].freeze
|
|
413
|
+
# CHARACTERS, not bytes. REXML's first message line bounds lines
|
|
414
|
+
# and not bytes -- the spec's 50,000-character token yields a
|
|
415
|
+
# 50,027-byte first line -- and an issue has to stay printable
|
|
416
|
+
# as one line. So the real
|
|
417
|
+
# bound is 200 characters, hence at most 800 bytes; the byte
|
|
418
|
+
# count is script-dependent and there is no fixed multiplier.
|
|
419
|
+
# `byteslice` is deliberately not used: it split a CJK codepoint
|
|
420
|
+
# and Models::Issue then refused the value outright.
|
|
421
|
+
MESSAGE_CHARACTER_LIMIT = 200
|
|
422
|
+
# 64 MiB matches the size class this module's own header comment
|
|
423
|
+
# measures against (1.7x-12.0x overhead depending on shape); past
|
|
424
|
+
# this, refuse to scan rather than read further into an
|
|
425
|
+
# attacker-controlled document with no bound at all.
|
|
426
|
+
MAX_SCAN_BYTES = 64 * 1024 * 1024
|
|
427
|
+
TOO_LARGE_CODE = "svg.too_large_to_scan"
|
|
428
|
+
TOO_LARGE_MESSAGE = "SVG source exceeds the #{MAX_SCAN_BYTES}-byte scan limit".freeze
|
|
429
|
+
|
|
430
|
+
class << self
|
|
431
|
+
# `tagged(source)` (the caller's own reader) runs OUTSIDE
|
|
432
|
+
# `do_scan`'s rescue, so a reader's own `ArgumentError` is no
|
|
433
|
+
# longer reported as `svg.encoding_unusable` -- measured with a
|
|
434
|
+
# reader whose `#read` itself raises.
|
|
435
|
+
def scan(source)
|
|
436
|
+
do_scan(tagged(source))
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
private
|
|
440
|
+
|
|
441
|
+
# At most one issue, which is what is currently KNOWABLE
|
|
442
|
+
# rather than a law: the parser stops at its first fatal
|
|
443
|
+
# error, and the root count is only complete when none
|
|
444
|
+
# occurred. A document with two genuine problems reports the
|
|
445
|
+
# first. The Array return keeps room for a later non-fatal
|
|
446
|
+
# check, which would coexist with the root count.
|
|
447
|
+
def do_scan(text)
|
|
448
|
+
return [issue(TOO_LARGE_CODE, TOO_LARGE_MESSAGE)] if text == :too_large
|
|
449
|
+
|
|
450
|
+
roots = count_roots(text)
|
|
451
|
+
return [] unless roots > 1
|
|
452
|
+
|
|
453
|
+
[issue(MULTIPLE_ROOTS_CODE, "document has #{roots} root elements")]
|
|
454
|
+
rescue REXML::ParseException => e
|
|
455
|
+
[parse_failure(e)]
|
|
456
|
+
# Its own clause, sitting between the ParseException rescue
|
|
457
|
+
# above and the ArgumentError rescue below. All three classes
|
|
458
|
+
# are pairwise non-subtypes in both directions -- measured --
|
|
459
|
+
# so no clause can shadow another and the order is free.
|
|
460
|
+
# REXML transcodes lazily
|
|
461
|
+
# while parsing, so a source whose bytes are truncated
|
|
462
|
+
# mid-character raises from inside the pull loop rather than
|
|
463
|
+
# arriving as a ParseException -- measured on 13 bytes, a
|
|
464
|
+
# UTF-16LE BOM followed by an odd-length PI, which escaped
|
|
465
|
+
# this method entirely on both source arms. All four of the
|
|
466
|
+
# Encoding::* errors are genuine "not decodable text", the
|
|
467
|
+
# same verdict raw binary gets.
|
|
468
|
+
rescue EncodingError
|
|
469
|
+
[issue(ENCODING_UNUSABLE_CODE, UNDECODABLE_MESSAGE)]
|
|
470
|
+
rescue ArgumentError => e
|
|
471
|
+
[issue(ENCODING_UNUSABLE_CODE, e.message)]
|
|
472
|
+
end
|
|
473
|
+
|
|
474
|
+
# Tag, never transcode: REXML still finds a BOM or a
|
|
475
|
+
# declaration and switches encodings itself. Both arms arrive
|
|
476
|
+
# binary-tagged -- `Image.from_content` normalises to
|
|
477
|
+
# ASCII-8BIT and a path-born source is opened "rb" -- and
|
|
478
|
+
# measured, a multibyte ROOT NAME reads back as "no root
|
|
479
|
+
# element" from a binary-tagged source and parses from a
|
|
480
|
+
# UTF-8-tagged one.
|
|
481
|
+
|
|
482
|
+
# Capped at MAX_SCAN_BYTES + 1: past it returns `:too_large`
|
|
483
|
+
# instead of reading further (measured: RSS tracked an
|
|
484
|
+
# uncapped read 1:1 with input size).
|
|
485
|
+
#
|
|
486
|
+
# keep the `+ 1` argument on `source.read` even though no spec
|
|
487
|
+
# can catch its removal: an over-cap input returns the same
|
|
488
|
+
# `:too_large` verdict whether `read` stopped at the cap or
|
|
489
|
+
# read the whole source, so no output-observable spec
|
|
490
|
+
# distinguishes them -- only RSS does, which is what this
|
|
491
|
+
# class is measured against, not asserted in-suite. This
|
|
492
|
+
# becomes the only check once the cap is dropped.
|
|
493
|
+
def tagged(source)
|
|
494
|
+
bytes = if source.respond_to?(:read)
|
|
495
|
+
source.read(MAX_SCAN_BYTES + 1)
|
|
496
|
+
else
|
|
497
|
+
source.byteslice(0, MAX_SCAN_BYTES + 1)
|
|
498
|
+
end
|
|
499
|
+
bytes ||= ::String.new
|
|
500
|
+
return :too_large if bytes.bytesize > MAX_SCAN_BYTES
|
|
501
|
+
|
|
502
|
+
bytes.force_encoding(Encoding::UTF_8)
|
|
503
|
+
end
|
|
504
|
+
|
|
505
|
+
# Loops to :end_document rather than on `has_next?` because
|
|
506
|
+
# the final pull is what runs REXML's end-of-input check.
|
|
507
|
+
# Parsing `<svg>` both ways:
|
|
508
|
+
#
|
|
509
|
+
# has_next? -> no raise
|
|
510
|
+
# :end_document -> REXML::ParseException
|
|
511
|
+
#
|
|
512
|
+
# Root COUNTING is not what buys this. Collecting the events
|
|
513
|
+
# of `<svg/><g/>` under `has_next?` gives
|
|
514
|
+
# [:start_element, :end_element, :start_element], which the
|
|
515
|
+
# depth walk below counts as 2 roots -- the same answer. The
|
|
516
|
+
# EOF check is the whole difference.
|
|
517
|
+
#
|
|
518
|
+
# The root count is Claricle's own. REXML accepts four of the
|
|
519
|
+
# five second-root shapes measured -- only `<svg/><g></g>`
|
|
520
|
+
# raises -- so nothing here can be delegated to it.
|
|
521
|
+
#
|
|
522
|
+
# `Detector.canonical_source`, not a bare `BaseParser.new(text)`:
|
|
523
|
+
# REXML's own live grammar refuses four characters that are
|
|
524
|
+
# legal in an XML name (U+00B7, U+0300, U+203F, U+2040) and its
|
|
525
|
+
# OWN published NCNAME_STR accepts -- `Detector::RootSource`
|
|
526
|
+
# already patches that gap for the bounded root read, and
|
|
527
|
+
# reusing it here (over the whole document rather than just the
|
|
528
|
+
# root) avoids reimplementing the same grammar fix a second
|
|
529
|
+
# time and risking disagreement with it.
|
|
530
|
+
def count_roots(text)
|
|
531
|
+
parser = REXML::Parsers::BaseParser.new(Detector.canonical_source(text))
|
|
532
|
+
roots = 0
|
|
533
|
+
depth = 0
|
|
534
|
+
while (type = parser.pull[0]) != :end_document
|
|
535
|
+
roots += 1 if type == :start_element && depth.zero?
|
|
536
|
+
depth += 1 if type == :start_element
|
|
537
|
+
depth -= 1 if type == :end_element
|
|
538
|
+
end
|
|
539
|
+
roots
|
|
540
|
+
end
|
|
541
|
+
|
|
542
|
+
# Undecodable bytes reach us wrapped in a ParseException, and
|
|
543
|
+
# the wrapped class is either an ArgumentError or an
|
|
544
|
+
# Encoding::InvalidByteSequenceError -- both are ENCODING
|
|
545
|
+
# failures rather than well-formedness ones, and
|
|
546
|
+
# UNDECODABLE_CAUSES names ArgumentError and the wider
|
|
547
|
+
# EncodingError, which InvalidByteSequenceError subclasses, so
|
|
548
|
+
# a sibling EncodingError REXML has not been measured wrapping
|
|
549
|
+
# yet is still caught. Routing on the OUTER
|
|
550
|
+
# exception class alone would file them under
|
|
551
|
+
# svg.not_well_formed.
|
|
552
|
+
#
|
|
553
|
+
# Which binary files land here is REXML's decision, not ours:
|
|
554
|
+
# measured across nine shapes, seven carry the wrapped
|
|
555
|
+
# ArgumentError and two -- a run of NULs, and every byte value
|
|
556
|
+
# 0-255 in order -- decode cleanly enough to fail as markup
|
|
557
|
+
# instead. Both outcomes refuse the file.
|
|
558
|
+
def parse_failure(error)
|
|
559
|
+
return issue(ENCODING_UNUSABLE_CODE, UNDECODABLE_MESSAGE) if undecodable?(error)
|
|
560
|
+
|
|
561
|
+
issue(NOT_WELL_FORMED_CODE, prose(error))
|
|
562
|
+
end
|
|
563
|
+
|
|
564
|
+
# Both classes, because the SAME failure reaches us as either
|
|
565
|
+
# depending only on WHERE inside REXML it was raised. REXML's
|
|
566
|
+
# `pull_event` wraps one region and the prolog sits outside it,
|
|
567
|
+
# so a BOM plus a character truncated in the PROLOG arrives
|
|
568
|
+
# bare as Encoding::InvalidByteSequenceError, while the same
|
|
569
|
+
# truncation after a start tag arrives WRAPPED in a
|
|
570
|
+
# ParseException whose prose is the useless "Exception
|
|
571
|
+
# parsing" -- measured on 13 bytes and on 9. Same defect, same
|
|
572
|
+
# verdict.
|
|
573
|
+
def undecodable?(error)
|
|
574
|
+
UNDECODABLE_CAUSES.any? { |kind| error.continued_exception.is_a?(kind) }
|
|
575
|
+
end
|
|
576
|
+
|
|
577
|
+
# RuntimeError#to_s, not `error.message`, for three measured
|
|
578
|
+
# reasons. ParseException#to_s reaches `current_line`, which
|
|
579
|
+
# re-reads the whole document at its default newline separator
|
|
580
|
+
# -- +52 MiB against +0 MiB here on a 64 MiB newline-free
|
|
581
|
+
# document. It appends REXML's own backtrace, so `e.message`
|
|
582
|
+
# on a real PNG is 674 characters carrying absolute
|
|
583
|
+
# filesystem paths where this is 17. And an issue has to stay
|
|
584
|
+
# printable as one line.
|
|
585
|
+
def prose(error)
|
|
586
|
+
RuntimeError.instance_method(:to_s).bind_call(error)
|
|
587
|
+
end
|
|
588
|
+
|
|
589
|
+
# Every issue this module builds passes through here, so the
|
|
590
|
+
# length bound is applied ONCE, at the funnel, rather than at
|
|
591
|
+
# each call site. An unusable encoding NAME is document
|
|
592
|
+
# content and so is attacker-controlled -- measured, a
|
|
593
|
+
# 100,000-character name produced a 100,018-character message
|
|
594
|
+
# when only `prose` truncated.
|
|
595
|
+
def issue(code, message)
|
|
596
|
+
Models::Issue.new(severity: "error", code: code, message: printable(message))
|
|
597
|
+
end
|
|
598
|
+
|
|
599
|
+
# Length only. Control characters are deliberately NOT stripped
|
|
600
|
+
# here: `Cli::Presenter::CONTROL` already owns that rule for
|
|
601
|
+
# every rendered row, issue messages included (`cli.rb:126`),
|
|
602
|
+
# over a wider set than `[[:cntrl:]]` -- it covers U+2028 and
|
|
603
|
+
# U+2029, which `[[:cntrl:]]` does not match. A second copy
|
|
604
|
+
# here would be the weaker of two rules for one thing, and it
|
|
605
|
+
# would DESTROY the character where the render layer escapes
|
|
606
|
+
# it, breaking the stated contract that `--json` carries the
|
|
607
|
+
# true value while only the human rendering escapes. `meta` is
|
|
608
|
+
# free document text and is handled exactly this way.
|
|
609
|
+
#
|
|
610
|
+
# Slice BEFORE scrubbing: slicing is safe on an invalid
|
|
611
|
+
# encoding and bounds the work, where scanning the whole
|
|
612
|
+
# string RAISES on one -- measured, ArgumentError -- and
|
|
613
|
+
# scrubbing 8,000,000 characters to keep 200 costs 0.0118s
|
|
614
|
+
# against 0.0000s. The slice is by CHARACTER, not byte, so it
|
|
615
|
+
# cannot itself leave anything half-formed; scrub guards
|
|
616
|
+
# against a message that was already invalid before the cut,
|
|
617
|
+
# which `Models::Issue` refuses outright.
|
|
618
|
+
def printable(message)
|
|
619
|
+
message[0, MESSAGE_CHARACTER_LIMIT].scrub
|
|
620
|
+
end
|
|
621
|
+
end
|
|
622
|
+
end
|
|
623
|
+
|
|
624
|
+
# After the module body: `private_constant` on a name that does
|
|
625
|
+
# not exist yet raises NameError.
|
|
626
|
+
private_constant :Structure
|
|
627
|
+
|
|
628
|
+
def inspection(image)
|
|
629
|
+
# Detector.read_root, not a second reader: it owns the 8192-byte
|
|
630
|
+
# bound, the ATTLIST precedence and the reference resolution, and
|
|
631
|
+
# a copy here would have to agree with all three forever.
|
|
632
|
+
#
|
|
633
|
+
# with_source, not content: read_root takes an IO as happily as a
|
|
634
|
+
# String, and asks for 8192 bytes either way. Measured on a
|
|
635
|
+
# 64.0 MiB SVG, `image.content` cost +64.5 MiB RSS and retained
|
|
636
|
+
# the file for the lifetime of the image, to reach those bytes.
|
|
637
|
+
root = image.with_source { |source| Detector.read_root(source) }
|
|
638
|
+
return unreadable(image) unless root
|
|
639
|
+
|
|
640
|
+
readable(image, root.last)
|
|
641
|
+
end
|
|
642
|
+
|
|
643
|
+
# The mapping, and every fact it rests on, are documented on
|
|
644
|
+
# `ConformanceMapper.report` below.
|
|
645
|
+
#
|
|
646
|
+
# The default comes from the DECLARATION, not from a second constant
|
|
647
|
+
# naming `base` again -- one rule in two places is one that can
|
|
648
|
+
# drift, and `profiles` already puts the default first.
|
|
649
|
+
def conformance_report(image, profile: nil)
|
|
650
|
+
# Lazily required (D5), matching `Png#read_chunks`'s own lazy
|
|
651
|
+
# `require "png_conform"`: the detector's `emf` is the only eager
|
|
652
|
+
# delegate, and svg_conform pulls in a profile loader and a SAX
|
|
653
|
+
# stack that a plain `inspect` run has no use for.
|
|
654
|
+
require "svg_conform"
|
|
655
|
+
|
|
656
|
+
ConformanceMapper.report(image, profile: profile || self.class.supported_profiles.first)
|
|
657
|
+
end
|
|
658
|
+
|
|
659
|
+
private
|
|
660
|
+
|
|
661
|
+
# Bounds the READ itself rather than trusting a stat taken on a
|
|
662
|
+
# separate filesystem call -- the same reasoning as MetafileConvert's
|
|
663
|
+
# own `bounded_content` (metafile.rb). Reading MAX_CONVERT_BYTES + 1
|
|
664
|
+
# and getting that many back is the proof the stream is over the
|
|
665
|
+
# limit whatever a stat would have said.
|
|
666
|
+
def bounded_content(image)
|
|
667
|
+
content = image.with_source { |source| bounded_read(source, MAX_CONVERT_BYTES + 1) }
|
|
668
|
+
return content if content.bytesize <= MAX_CONVERT_BYTES
|
|
669
|
+
|
|
670
|
+
raise ConversionError, "svg image exceeds the #{MAX_CONVERT_BYTES}-byte convert limit"
|
|
671
|
+
end
|
|
672
|
+
|
|
673
|
+
# Near-verbatim copy of MetafileConvert#bounded_read (metafile.rb) --
|
|
674
|
+
# kept as its own small copy rather than a shared Handlers::Base
|
|
675
|
+
# helper: metafile.rb belongs to a separate, independently-mergeable,
|
|
676
|
+
# already-approved PR, and editing it here to extract a shared helper
|
|
677
|
+
# would be cross-PR coupling into a diff this branch doesn't own.
|
|
678
|
+
def bounded_read(source, length)
|
|
679
|
+
return source.read(length) || "".b if source.respond_to?(:read)
|
|
680
|
+
|
|
681
|
+
source.byteslice(0, length)
|
|
682
|
+
end
|
|
683
|
+
|
|
684
|
+
# Scoped to only the delegate call, not `build_conversion` below --
|
|
685
|
+
# that method's own Lossiness.classify/Models::Conversion.new are
|
|
686
|
+
# this file's local logic, not the delegate chain this rescue exists
|
|
687
|
+
# to cover.
|
|
688
|
+
#
|
|
689
|
+
# NOT an exhaustive list of what the delegate chain can raise --
|
|
690
|
+
# `rescue StandardError` mirrors Handlers::Metafile's own documented
|
|
691
|
+
# deviation (metafile.rb) rather than a narrower explicit-class list.
|
|
692
|
+
# `utf16_gradient.svg` is a real, permanent input-shape failure
|
|
693
|
+
# (Vectory::ParsingError) with no equivalent fix; the cold-process
|
|
694
|
+
# `Postsvg::Model::UnknownOperator` NameError this same rescue used to
|
|
695
|
+
# exist for is fixed by ConversionEngine immediately before this call.
|
|
696
|
+
def convert_content(content, to)
|
|
697
|
+
PostscriptFidelity.convert(content, to) do |svg|
|
|
698
|
+
EmfHeader.seal(to, ::Vectory::Svg.from_content(svg).public_send(CONVERT_TARGET_METHODS.fetch(to)).content)
|
|
699
|
+
end
|
|
700
|
+
rescue StandardError => e
|
|
701
|
+
raise ConversionError, "#{e.class}: #{e.message}"
|
|
702
|
+
end
|
|
703
|
+
|
|
704
|
+
# `output_path` is deliberately absent here -- the caller
|
|
705
|
+
# (Claricle.convert_one) does not know the real written path until
|
|
706
|
+
# after Writer#write runs, and Models::Base seals (and therefore
|
|
707
|
+
# freezes) every instance at construction.
|
|
708
|
+
def build_conversion(image, to, content, converted)
|
|
709
|
+
Models::Conversion.new(
|
|
710
|
+
source_path: image.path,
|
|
711
|
+
source_format: image.format.to_s,
|
|
712
|
+
target_format: to.to_s,
|
|
713
|
+
lossiness: PostscriptFidelity::Verdict.classify(image.format, to, content),
|
|
714
|
+
content: converted
|
|
715
|
+
)
|
|
716
|
+
end
|
|
717
|
+
|
|
718
|
+
def readable(image, attributes)
|
|
719
|
+
Models::Inspection.new(
|
|
720
|
+
format: image.format.to_s,
|
|
721
|
+
width: dimension(attributes["width"]),
|
|
722
|
+
height: dimension(attributes["height"]),
|
|
723
|
+
# Passed straight through: `Models::FreeFormHash.cast` does the
|
|
724
|
+
# copy itself, explicitly, so the inspection never shares the
|
|
725
|
+
# reader's hash. That is Claricle's own type rather than
|
|
726
|
+
# generic lutaml behaviour -- lutaml's `:hash` reshapes `text`
|
|
727
|
+
# and `elements`, which is why FreeFormHash exists at all. A
|
|
728
|
+
# `.dup` here would repeat a copy that already happened --
|
|
729
|
+
# measured, and pinned by a spec.
|
|
730
|
+
meta: attributes,
|
|
731
|
+
parse_status: "ok"
|
|
732
|
+
)
|
|
733
|
+
end
|
|
734
|
+
|
|
735
|
+
def unreadable(image)
|
|
736
|
+
Models::Inspection.new(
|
|
737
|
+
format: image.format.to_s,
|
|
738
|
+
parse_status: "failed",
|
|
739
|
+
issues: [Models::Issue.new(severity: "error", code: ISSUE_CODE,
|
|
740
|
+
message: ISSUE_MESSAGE)]
|
|
741
|
+
)
|
|
742
|
+
end
|
|
743
|
+
|
|
744
|
+
# nil rather than a wrong number, in every case it cannot answer:
|
|
745
|
+
# no attribute, an unparseable one, a relative unit, a negative
|
|
746
|
+
# value, or one that overflows. The viewBox is deliberately not
|
|
747
|
+
# consulted -- it defines an aspect ratio, not an intrinsic size
|
|
748
|
+
# (D15).
|
|
749
|
+
#
|
|
750
|
+
# Negative is nulled, zero is not: SVG 1.1 5.1.2 on width/height,
|
|
751
|
+
# "A negative value is an error ... A value of zero disables
|
|
752
|
+
# rendering of the element." The spec draws the line at the sign,
|
|
753
|
+
# not at zero, so a declared zero is a real (if degenerate)
|
|
754
|
+
# measurement and only a negative one joins the unusable cases
|
|
755
|
+
# above.
|
|
756
|
+
def dimension(declared)
|
|
757
|
+
match = DIMENSION.match(declared.to_s)
|
|
758
|
+
return nil unless match
|
|
759
|
+
|
|
760
|
+
unit = match[:unit].downcase
|
|
761
|
+
factor = unit.empty? ? 1.0 : ABSOLUTE_UNITS[unit]
|
|
762
|
+
return nil unless factor
|
|
763
|
+
|
|
764
|
+
value = scale(match[:number], factor)
|
|
765
|
+
# -0.0.negative? is false (-0.0 == 0.0 under IEEE 754), so a
|
|
766
|
+
# declared "-0" falls through as a kept zero, not a nulled
|
|
767
|
+
# negative -- no separate case needed for it.
|
|
768
|
+
value&.negative? ? nil : value
|
|
769
|
+
end
|
|
770
|
+
|
|
771
|
+
# Finiteness is checked on the CONVERTED value, not the parsed one:
|
|
772
|
+
# 1e308 is finite, and 1e308 * 96 is Infinity, which an Inspection
|
|
773
|
+
# refuses to hold. Measured: building one raises
|
|
774
|
+
# Lutaml::Model::ValidationError, "width expects a finite number,
|
|
775
|
+
# got Infinity", from `validate_finite` during `initialize` -- so
|
|
776
|
+
# `inspect` on a file that parsed fine would die before any
|
|
777
|
+
# serialisation ran, not in `to_json`.
|
|
778
|
+
def scale(number, factor)
|
|
779
|
+
parsed = Float(number, exception: false)
|
|
780
|
+
return nil unless parsed
|
|
781
|
+
|
|
782
|
+
converted = parsed * factor
|
|
783
|
+
converted.finite? ? converted : nil
|
|
784
|
+
end
|
|
785
|
+
end
|
|
786
|
+
end
|
|
787
|
+
end
|