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,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="&#xB;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 `&#xD800;`, `&#0;`, `&#1;`, `&#xFFFE;`;
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