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,864 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rexml/parsers/pullparser"
|
|
4
|
+
require "rexml/xmltokens"
|
|
5
|
+
|
|
6
|
+
require_relative "errors"
|
|
7
|
+
# `CharacterRules#unaccounted_text?` calls `AttributeReferences`, which lives
|
|
8
|
+
# in `detector.rb`. `lib/claricle.rb` loads the detector later in its own list,
|
|
9
|
+
# so the whole gem works whether or not this line is here -- but requiring this
|
|
10
|
+
# file on its own left the call site raising `NameError: uninitialized constant
|
|
11
|
+
# Claricle::Lossiness::CharacterRules::AttributeReferences`. Measured, not
|
|
12
|
+
# assumed. No cycle: `detector.rb` never names `Lossiness`.
|
|
13
|
+
require_relative "detector"
|
|
14
|
+
|
|
15
|
+
module Claricle
|
|
16
|
+
# Decides whether one conversion loses anything, from what the SOURCE
|
|
17
|
+
# document contains rather than from a table of edges.
|
|
18
|
+
#
|
|
19
|
+
# The governing rule, and every guard below follows from it: a `lossless`
|
|
20
|
+
# verdict is a positive claim and needs positive evidence. `lossy` and
|
|
21
|
+
# `unknown` both tell the caller to look; only `lossless` tells them not to,
|
|
22
|
+
# so only `lossless` can cause silent data loss. Wherever the evidence runs
|
|
23
|
+
# out the answer is `unknown`.
|
|
24
|
+
#
|
|
25
|
+
# No conversion is performed here and no delegate is loaded. The losses this
|
|
26
|
+
# classifies were measured on vectory 0.12.0 during design; this module
|
|
27
|
+
# never reaches it, even though the gemspec now depends on it for the EMF
|
|
28
|
+
# convert path (metafile.rb).
|
|
29
|
+
module Lossiness
|
|
30
|
+
# `LEVELS` lives in lossiness/levels.rb, not here -- nothing in this file
|
|
31
|
+
# reads it; only `Models::Conversion` does, and requires that file
|
|
32
|
+
# directly rather than this whole classifier. See levels.rb for why.
|
|
33
|
+
|
|
34
|
+
# A source may be a bare BasicObject exposing only reader methods, so a
|
|
35
|
+
# type test must not dispatch a method to it. Same idiom, and the same
|
|
36
|
+
# reason, as models/free_form_hash.rb:28.
|
|
37
|
+
CORE_INSTANCE = ::Object.instance_method(:is_a?)
|
|
38
|
+
|
|
39
|
+
# The exact set REXML::SourceFactory.create_from tests for its IO branch
|
|
40
|
+
# (rexml-3.4.4/lib/rexml/source.rb:42-56). `read` alone is not what it
|
|
41
|
+
# tests -- an object answering `read` but missing any of the other three
|
|
42
|
+
# falls through every branch there and hits REXML's own bare
|
|
43
|
+
# `RuntimeError: ... is not a valid input stream.`, raised from inside
|
|
44
|
+
# `PullParser.new`, which sits outside every rescue in `Scanner#run`. See
|
|
45
|
+
# `refuse_unreadable`.
|
|
46
|
+
REXML_IO_METHODS = %i[read readline nil? eof?].freeze
|
|
47
|
+
|
|
48
|
+
SVG_NAMESPACE = "http://www.w3.org/2000/svg"
|
|
49
|
+
|
|
50
|
+
# postsvg-0.3.0's `to_eps` is `to_ps(eps: true)`, one rendering pipeline
|
|
51
|
+
# where `eps:` affects only the DSC header line -- so :eps and :ps share
|
|
52
|
+
# this same rule object rather than a second hand-copied literal, which
|
|
53
|
+
# would drift silently if either target's loss list is ever revised
|
|
54
|
+
# without the other.
|
|
55
|
+
POSTSCRIPT_RULE = { lost: %i[gradient clip_path embedded_raster linked_raster], kept: %i[basic_shape] }.freeze
|
|
56
|
+
|
|
57
|
+
# Per target: features measured LOST, and features measured KEPT. A feature
|
|
58
|
+
# in neither is unmeasured, so it yields `unknown`. A single "discards"
|
|
59
|
+
# list would treat "not on the list" as "proven safe" -- measured, that
|
|
60
|
+
# calls an <image> going to EMF lossless, which nobody has tested.
|
|
61
|
+
# `basic_shape`'s `kept:` claim for eps/ps was measured against vectory's
|
|
62
|
+
# postsvg-backed writer specifically. vectory's EMF writer is a different
|
|
63
|
+
# pipeline and does NOT honour the same SVG-default semantics: measured
|
|
64
|
+
# (Codex, feat/convert-eps-ps-source-edges, the PR that first made
|
|
65
|
+
# svg -> emf reachable), a `<rect>` with no `fill` attribute -- SVG's
|
|
66
|
+
# default fill is black -- writes `fill="none"` to EMF, and a `<line>`
|
|
67
|
+
# with no `stroke-linecap` -- SVG's default is `butt` -- writes `round`.
|
|
68
|
+
# Neither loss is on any list above, so both would otherwise read
|
|
69
|
+
# `lossless`. `kept: []` closes that gap the same way this module closes
|
|
70
|
+
# every other one: unmeasured stays `unknown`, never a guessed `lossless`.
|
|
71
|
+
RULES = {
|
|
72
|
+
eps: POSTSCRIPT_RULE,
|
|
73
|
+
ps: POSTSCRIPT_RULE,
|
|
74
|
+
emf: { lost: %i[gradient clip_path linked_raster], kept: [] }
|
|
75
|
+
}.freeze
|
|
76
|
+
|
|
77
|
+
# Derived, so a feature added to any `kept:` list is covered by the prefix
|
|
78
|
+
# guard automatically rather than needing a second list kept in step.
|
|
79
|
+
KEPT_FEATURES = RULES.values.flat_map { |rule| rule[:kept] }.uniq.freeze
|
|
80
|
+
|
|
81
|
+
# The root carries no mark of its own, and `defs` is a container whose
|
|
82
|
+
# children are scanned anyway. Nothing else is ignored: waving through an
|
|
83
|
+
# element that IS lossy costs a false `lossless`, the one direction that
|
|
84
|
+
# matters.
|
|
85
|
+
IGNORED = %w[svg defs].freeze
|
|
86
|
+
|
|
87
|
+
ELEMENTS = {
|
|
88
|
+
"linearGradient" => :gradient, "radialGradient" => :gradient,
|
|
89
|
+
"clipPath" => :clip_path, "image" => :linked_raster,
|
|
90
|
+
"rect" => :basic_shape, "line" => :basic_shape
|
|
91
|
+
}.freeze
|
|
92
|
+
|
|
93
|
+
# An `<image>` is `:embedded_raster` when its href is a `data:` URI and
|
|
94
|
+
# `:linked_raster` otherwise. Measured on the EMF path: a `data:` PNG is
|
|
95
|
+
# written as a DIB, pixel-exact, so it is NOT lost there (and not proven
|
|
96
|
+
# kept either, hence `unknown`); a linked image is dropped, and the output
|
|
97
|
+
# is byte-identical to the same document without the `<image>`.
|
|
98
|
+
DATA_URI = /\A\s*data:/i
|
|
99
|
+
|
|
100
|
+
ATTR_FEATURES = { "clip-path" => :clip_path }.freeze
|
|
101
|
+
|
|
102
|
+
# The complement of XML 1.0's `Char` production. PullParser does not
|
|
103
|
+
# enforce it -- measured, a U+001C in character data or in an `id` value
|
|
104
|
+
# reaches `lossless` while REXML::Document refuses the same bytes -- so a
|
|
105
|
+
# file no conformant parser will read was getting the one verdict that
|
|
106
|
+
# tells the caller not to look.
|
|
107
|
+
#
|
|
108
|
+
# Matched against the characters a value DENOTES, never the bytes it
|
|
109
|
+
# spells -- CharacterRules resolves references first, because REXML hands
|
|
110
|
+
# attribute values back raw. And never against SOURCE bytes:
|
|
111
|
+
# utf16_gradient.svg holds 193 bytes below 0x20 that are ordinary UTF-16
|
|
112
|
+
# code units, and a byte-level guard condemns that legal document. REXML
|
|
113
|
+
# hands every payload back as valid UTF-8 or raises trying -- measured over
|
|
114
|
+
# every convert fixture in four source shapes, and over 14 hostile encoding
|
|
115
|
+
# cases with declared-vs-actual mismatches and all five BOMs among them.
|
|
116
|
+
ILLEGAL_CHAR = /[^\u0009\u000A\u000D\u0020-\uD7FF\uE000-\uFFFD\u{10000}-\u{10FFFF}]/
|
|
117
|
+
|
|
118
|
+
# Names admitted whatever their value: structural and identity only.
|
|
119
|
+
ATTR_HARMLESS = %w[version id].freeze
|
|
120
|
+
|
|
121
|
+
# `xml:space` and `xml:lang` affect character-data handling only, and no
|
|
122
|
+
# element ELEMENTS proves KEPT renders character data: `rect` and `line`
|
|
123
|
+
# draw a shape, and SVG does not paint text placed inside either. So the
|
|
124
|
+
# allowance cannot change what a proven-kept document renders.
|
|
125
|
+
#
|
|
126
|
+
# NOT the stronger claim this comment used to make. "Every
|
|
127
|
+
# character-data-bearing element is already unclassified" is false --
|
|
128
|
+
# measured, `<rect xml:space="preserve">` in a document carrying text
|
|
129
|
+
# classifies `lossless`, because a `rect` can BEAR character data even
|
|
130
|
+
# though it never renders it. The conclusion holds; that reason did not.
|
|
131
|
+
# No other prefix is allowed.
|
|
132
|
+
ATTR_ALLOWED_PREFIXED = { "xml" => %w[space lang] }.freeze
|
|
133
|
+
|
|
134
|
+
# An internal subset can make a document mean more than its markup shows:
|
|
135
|
+
# an entity carrying elements, or an ATTLIST default that appears in no
|
|
136
|
+
# start tag. REXML applies neither.
|
|
137
|
+
SUBSET_DECLARATIONS = %i[entitydecl attlistdecl elementdecl notationdecl].freeze
|
|
138
|
+
|
|
139
|
+
# Every EMITTED event type this scanner accounts for. Anything else is
|
|
140
|
+
# unmeasured and yields `unclassified`, which closes the class rather than
|
|
141
|
+
# an instance: `:externalentity` is emitted by `<!DOCTYPE svg [%missing;]>`
|
|
142
|
+
# and is handled by that fallback, not by this list.
|
|
143
|
+
#
|
|
144
|
+
# NOT the same set as PullEvent's predicate methods, and the matching
|
|
145
|
+
# counts are a coincidence. Measured, the two overlap in 10 of 14:
|
|
146
|
+
# predicates not here : [:doctype, :entity, :error, :instruction]
|
|
147
|
+
# here, not predicates: [:start_doctype, :end_doctype,
|
|
148
|
+
# :processing_instruction, :end_document]
|
|
149
|
+
# `doctype?`/`instruction?` are predicate names for the emitted
|
|
150
|
+
# `:start_doctype`/`:processing_instruction`; `entity?` and `error?` are
|
|
151
|
+
# never emitted at all. Compare the two sets, never their sizes:
|
|
152
|
+
# ruby -rrexml/parsers/pullparser -e 'p REXML::Parsers::PullEvent
|
|
153
|
+
# .instance_methods(false).grep(/\?\z/).map { |m| m.to_s.chomp("?").to_sym }'
|
|
154
|
+
#
|
|
155
|
+
# The four declaration types come from SUBSET_DECLARATIONS rather than
|
|
156
|
+
# being spelled a second time, and the direction is why. Listed twice, the
|
|
157
|
+
# two could drift, and that drift FAILS OPEN: dropping `:entitydecl` from
|
|
158
|
+
# SUBSET_DECLARATIONS while this list still carried it turned an ENTITY
|
|
159
|
+
# document from `unknown` into `lossless`. Derived, the same edit drops the
|
|
160
|
+
# name from both, the event stops being recognised, and it falls to the
|
|
161
|
+
# `:unclassified` catch-all -- the safe direction. Same route KEPT_FEATURES
|
|
162
|
+
# takes above.
|
|
163
|
+
KNOWN_EVENTS = (%i[
|
|
164
|
+
start_element end_element text cdata comment xmldecl
|
|
165
|
+
start_doctype end_doctype processing_instruction end_document
|
|
166
|
+
] + SUBSET_DECLARATIONS).freeze
|
|
167
|
+
|
|
168
|
+
# Opaque solid colour only. ARGUED from the card's measurement that SVG->EPS
|
|
169
|
+
# renders a gradient as solid black, so an opaque solid colour is
|
|
170
|
+
# representable; these are the closed-grammar forms that can denote only
|
|
171
|
+
# that. Never a "looks like a colour" pattern -- such a pattern admits
|
|
172
|
+
# `transparent`, which implies an alpha channel EPS cannot represent.
|
|
173
|
+
HEX_COLOUR = /\A#(?:\h{3}|\h{6})\z/
|
|
174
|
+
RGB_COLOUR = /\Argb\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*\)\z/
|
|
175
|
+
# Self-contained lengths. A percentage resolves against a viewport, `em`
|
|
176
|
+
# against a font, `mm` through a DPI setting -- all context the scanner
|
|
177
|
+
# cannot see. Units are unsettled anyway (D15).
|
|
178
|
+
NUMBER = /[+-]?(?:\d+\.\d+|\.\d+|\d+)/
|
|
179
|
+
SELF_CONTAINED = /\A#{NUMBER}(?:px)?\z/
|
|
180
|
+
# `width` and `height` are EXTENTS and SVG forbids a negative one, so
|
|
181
|
+
# `width="-1"` describes no renderable document and must never earn a
|
|
182
|
+
# positive `lossless` claim. `x`, `y` and the line endpoints are signed
|
|
183
|
+
# COORDINATES and keep SELF_CONTAINED -- one pattern served both, which is
|
|
184
|
+
# how a negative extent passed.
|
|
185
|
+
NON_NEGATIVE = /\A\+?(?:\d+\.\d+|\.\d+|\d+)(?:px)?\z/
|
|
186
|
+
# Four NUMBERS. The old `[\d.]+` was a run of digits and dots, not a number
|
|
187
|
+
# grammar, so `. . . .` and `1.2.3 0 10 10` both matched.
|
|
188
|
+
VIEWBOX = /\A\s*#{NUMBER}(?:[\s,]+#{NUMBER}){3}\s*\z/
|
|
189
|
+
SOLID_COLOUR = Regexp.union(HEX_COLOUR, RGB_COLOUR)
|
|
190
|
+
|
|
191
|
+
# Attribute name -> the pattern its value must match. One table rather than
|
|
192
|
+
# two name lists plus a special case, following ELEMENTS and ATTR_FEATURES
|
|
193
|
+
# above. A name absent here carries no value we have evidence for.
|
|
194
|
+
VALUE_RULES = {
|
|
195
|
+
"fill" => SOLID_COLOUR, "stroke" => SOLID_COLOUR,
|
|
196
|
+
"width" => NON_NEGATIVE, "height" => NON_NEGATIVE,
|
|
197
|
+
"x" => SELF_CONTAINED, "y" => SELF_CONTAINED,
|
|
198
|
+
"x1" => SELF_CONTAINED, "y1" => SELF_CONTAINED,
|
|
199
|
+
"x2" => SELF_CONTAINED, "y2" => SELF_CONTAINED,
|
|
200
|
+
"viewBox" => VIEWBOX
|
|
201
|
+
}.freeze
|
|
202
|
+
|
|
203
|
+
class << self
|
|
204
|
+
# `source` is a String of bytes or an open IO positioned at byte 0.
|
|
205
|
+
#
|
|
206
|
+
# CALLER CONTRACT, not a checked precondition: a String `source` must be
|
|
207
|
+
# the caller's WHOLE document. This cannot be verified here and is never
|
|
208
|
+
# attempted, because it cannot be -- measured (`spec/claricle/models/
|
|
209
|
+
# conversion_spec.rb`, "cannot distinguish a truncated String..."): a
|
|
210
|
+
# two-root document sliced at `doc[0...cut_point]`, where `cut_point`
|
|
211
|
+
# lands exactly where the first root's closing tag ends, is BYTE-FOR-BYTE
|
|
212
|
+
# IDENTICAL to a genuinely complete, well-formed single-root document
|
|
213
|
+
# ending at that same byte. `classify` sees only the bytes it is handed;
|
|
214
|
+
# there is no third signal available to tell "truncated prefix" from
|
|
215
|
+
# "the whole thing", because in this one shape they are the same String.
|
|
216
|
+
# Forcing `unknown` on that shape would have to fire on every complete
|
|
217
|
+
# single-root SVG too -- the identical bytes cannot classify two ways --
|
|
218
|
+
# which trades a rare, caller-caused false `lossless` for a false
|
|
219
|
+
# `unknown` on the overwhelming majority of real, correct documents.
|
|
220
|
+
# That is a worse contract, not a safer one, so it is refused rather than
|
|
221
|
+
# attempted.
|
|
222
|
+
#
|
|
223
|
+
# Every claim below about "a String" means an exact, core String, the
|
|
224
|
+
# same scope `image.rb`'s "fresh core String" comments use -- not a
|
|
225
|
+
# subclass overriding its own `pos`/`to_str`/etc, and not an object
|
|
226
|
+
# mutated by something else while `classify` is mid-parse. Both are
|
|
227
|
+
# separate, pre-existing hazards (a lying subclass, or a source another
|
|
228
|
+
# thread rewrites underneath a read) that apply to any Ruby API taking
|
|
229
|
+
# an object by reference, not something specific to this gap.
|
|
230
|
+
#
|
|
231
|
+
# This is why `refuse_unpositioned` below can refuse an IO but never a
|
|
232
|
+
# core String, even though it runs unconditionally on both (measured
|
|
233
|
+
# with `TracePoint`: a String source does receive its
|
|
234
|
+
# `respond_to?(:closed?)` and `respond_to?(:pos)` probes, at the exact
|
|
235
|
+
# call sites below): an IO's position, closedness and (via
|
|
236
|
+
# `TaggedSource`) read/readline faults are all independently OBSERVABLE,
|
|
237
|
+
# so those probes return true and `classify` can and does refuse an IO
|
|
238
|
+
# it cannot trust. A core String answers every one of those same probes
|
|
239
|
+
# false, so the check always no-ops for it -- not because the probe is
|
|
240
|
+
# skipped, but because a core String never has an answer worth
|
|
241
|
+
# refusing. What none of those probes ever does is dispatch a call that
|
|
242
|
+
# reads the string's bytes -- `respond_to?`/`is_a?`/`to_str` are the
|
|
243
|
+
# only methods ever called on it (measured, see the TaggedSource
|
|
244
|
+
# comment below), so there is no additional call left to hang a length
|
|
245
|
+
# or position check on. The `readline`-fault route below (`scan`,
|
|
246
|
+
# `TaggedSource`) closes the IO-shaped version of this same defect,
|
|
247
|
+
# where the fault itself is the missing signal; nothing dispatched on a
|
|
248
|
+
# core String ever faults.
|
|
249
|
+
#
|
|
250
|
+
# `rule:` is the loss rule of the TARGET. The built-in targets are in
|
|
251
|
+
# RULES; a handler added later supplies its own with `loss_rules`,
|
|
252
|
+
# which is why it can be passed in. nil means unmeasured: `unknown`.
|
|
253
|
+
def classify(source_format:, target_format:, source:, rule: RULES[target_format])
|
|
254
|
+
return "unknown" unless source_format == :svg
|
|
255
|
+
return "unknown" if rule.nil?
|
|
256
|
+
|
|
257
|
+
refuse_unreadable(source)
|
|
258
|
+
refuse_unpositioned(source)
|
|
259
|
+
root_ok, present = scan(source, rule)
|
|
260
|
+
verdict(rule, root_ok, present)
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
private
|
|
264
|
+
|
|
265
|
+
# The caller's own fault reaches them as their own exception, not as
|
|
266
|
+
# a verdict and not wrapped in one of ours.
|
|
267
|
+
def scan(source, rule)
|
|
268
|
+
tagged = TaggedSource.new(source)
|
|
269
|
+
found = Scanner.new(tagged, kept: KEPT_FEATURES | rule[:kept]).run
|
|
270
|
+
# Checked AFTER the scan, not raised through it. A `read`, `eof?` or
|
|
271
|
+
# `pos` fault propagates out of `Scanner#run` on its own (REXML never
|
|
272
|
+
# wraps those calls in a rescue -- see `TaggedSource`), so this only
|
|
273
|
+
# ever fires for the rare case that somehow reaches here anyway; the
|
|
274
|
+
# rescue below carries it out unwrapped either way.
|
|
275
|
+
raise tagged.fault if tagged.fault
|
|
276
|
+
|
|
277
|
+
# A `readline` fault is deliberately never escalated (see
|
|
278
|
+
# `TaggedSource`), so REXML can absorb it exactly as it would for a
|
|
279
|
+
# String -- but "absorbed" means the parse stopped wherever the fault
|
|
280
|
+
# landed, not that it reached the document's real end. Measured: two
|
|
281
|
+
# SVG roots, the second carrying a `lost` feature, with a `readline`
|
|
282
|
+
# fault landing right as the first root closes (depth back to 0, so
|
|
283
|
+
# `note_truncation` sees nothing wrong) -- the scan completes on the
|
|
284
|
+
# first root ALONE and calls it `lossless`, while the real, untruncated
|
|
285
|
+
# document is `unknown`. This is not specific to a hostile caller: the
|
|
286
|
+
# identical result comes from literally truncating a plain String at
|
|
287
|
+
# the same byte, with no IO or fault involved at all -- this fix must
|
|
288
|
+
# not make that route newly reachable. The String case itself is a
|
|
289
|
+
# separate, unclosable gap: see the CALLER CONTRACT note on `classify`
|
|
290
|
+
# for why -- the truncated String and a genuinely complete one are the
|
|
291
|
+
# same bytes, so no fix here can tell them apart without also breaking
|
|
292
|
+
# every complete document. So ANY absorbed `readline` fault
|
|
293
|
+
# forces the same `[root_ok, present]` shape `verdict` already reads
|
|
294
|
+
# as `unknown` for a bad root (`false`, empty), never whatever
|
|
295
|
+
# `Scanner` computed from the partial content it saw before the fault
|
|
296
|
+
# -- the governing rule at the top of this file: wherever the
|
|
297
|
+
# evidence runs out, including RIGHT here, the answer is `unknown`.
|
|
298
|
+
return [false, []] if tagged.readline_faulted
|
|
299
|
+
|
|
300
|
+
found
|
|
301
|
+
rescue SourceFault => e
|
|
302
|
+
raise e.original
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def verdict(rule, root_ok, present)
|
|
306
|
+
return "unknown" unless root_ok
|
|
307
|
+
return "unknown" if present.empty?
|
|
308
|
+
return "lossy" if present.intersect?(rule[:lost])
|
|
309
|
+
|
|
310
|
+
(present - rule[:kept]).empty? ? "lossless" : "unknown"
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
# REXML answers a source it cannot read at all with a bare
|
|
314
|
+
# `RuntimeError: NilClass is not a valid input stream.` -- measured for
|
|
315
|
+
# nil, Integer, Array and Hash. That is a caller error exactly like the
|
|
316
|
+
# two below, and this module already converts those, so letting a third
|
|
317
|
+
# leak raw contradicted the contract these comments state. It is raised
|
|
318
|
+
# here, outside the parse, for the same reason they are.
|
|
319
|
+
#
|
|
320
|
+
# `respond_to?(:read)` alone used to be the check, and it was WEAKER
|
|
321
|
+
# than the contract this comment claims: REXML_IO_METHODS is what
|
|
322
|
+
# actually keeps `PullParser.new` from raising the same bare
|
|
323
|
+
# RuntimeError for a different reason. Measured, an object exposing
|
|
324
|
+
# only `read` (and `closed?`) reached `PullParser.new` and raised
|
|
325
|
+
# `RuntimeError: Claricle::Lossiness::TaggedSource is not a valid
|
|
326
|
+
# input stream.` unwrapped, because `run`'s own rescue names
|
|
327
|
+
# `Unreadable, ArgumentError` and deliberately not `RuntimeError` (see
|
|
328
|
+
# `next_event`'s comment on why constructing the parser sits outside
|
|
329
|
+
# that boundary).
|
|
330
|
+
def refuse_unreadable(source)
|
|
331
|
+
# The ORIGINAL `Object#is_a?` bound and called via `bind_call` first,
|
|
332
|
+
# never `source.is_a?`: a caller may hand over a bare BasicObject
|
|
333
|
+
# exposing only the reader methods, and dispatching `is_a?` straight
|
|
334
|
+
# to it is itself a forbidden call -- measured, it broke the
|
|
335
|
+
# bounded-IO example. `bind_call` runs the real method without
|
|
336
|
+
# depending on `source` still having it.
|
|
337
|
+
#
|
|
338
|
+
# `respond_to?` itself IS dispatched straight to `source` below,
|
|
339
|
+
# unlike `is_a?` -- deliberately, not an oversight: a bounded-IO
|
|
340
|
+
# caller is expected to define its OWN `respond_to?` (the existing
|
|
341
|
+
# spec's "restricted" double does exactly this) so it governs what
|
|
342
|
+
# this guard is permitted to probe. Routing it through `bind_call`
|
|
343
|
+
# instead would bypass that override and defeat the restriction it
|
|
344
|
+
# exists to enforce.
|
|
345
|
+
return if CORE_INSTANCE.bind_call(source, ::String)
|
|
346
|
+
return if REXML_IO_METHODS.all? { |method| source.respond_to?(method) }
|
|
347
|
+
|
|
348
|
+
raise InvocationError, "source must be a String or a readable IO"
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
# A partially consumed IO hides everything already read -- measured, an
|
|
352
|
+
# internal ATTLIST seeked past classifies `lossless`. A pipe cannot
|
|
353
|
+
# answer at all (`IO.pipe#pos` raises Errno::ESPIPE). Both are caller
|
|
354
|
+
# errors, never verdicts, so this is raised outside the parse rescue.
|
|
355
|
+
def refuse_unpositioned(source)
|
|
356
|
+
refuse_closed(source)
|
|
357
|
+
return unless source.respond_to?(:pos)
|
|
358
|
+
|
|
359
|
+
position = begin
|
|
360
|
+
source.pos
|
|
361
|
+
rescue Errno::ESPIPE
|
|
362
|
+
raise InvocationError, "source position is not observable"
|
|
363
|
+
end
|
|
364
|
+
return if position.zero?
|
|
365
|
+
|
|
366
|
+
raise InvocationError, "source must be positioned at byte 0"
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
# Asked BEFORE `pos`, because `pos` does not agree across IO classes on a
|
|
370
|
+
# closed stream -- measured: `File#pos` raises IOError, while
|
|
371
|
+
# `StringIO#pos` returns 0 and sails through a position check, so the
|
|
372
|
+
# failure surfaced later as a raw IOError from inside the scan. `closed?`
|
|
373
|
+
# answers the same for both. A pipe is not closed, so this leaves the
|
|
374
|
+
# ESPIPE case above reachable.
|
|
375
|
+
def refuse_closed(source)
|
|
376
|
+
return unless source.respond_to?(:closed?)
|
|
377
|
+
return unless source.closed?
|
|
378
|
+
|
|
379
|
+
raise InvocationError, "source is not readable: closed stream"
|
|
380
|
+
end
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
# Attribute rules, extracted so Scanner stays inside Metrics/ClassLength
|
|
384
|
+
# without an exemption -- the same route detector.rb takes.
|
|
385
|
+
#
|
|
386
|
+
# Every rule here answers by VALUE where a value can carry meaning. A name
|
|
387
|
+
# proves nothing about what it holds: `fill` admitted `url(...)` and
|
|
388
|
+
# `width` admitted `50%` while both were whitelisted by name.
|
|
389
|
+
module AttributeRules
|
|
390
|
+
module_function
|
|
391
|
+
|
|
392
|
+
# The prefix decides FIRST, and the ordering is the rule. A namespace
|
|
393
|
+
# declaration is `xmlns=` with no prefix, or `xmlns:foo=`. `foo:xmlns` is
|
|
394
|
+
# neither -- it is an ordinary attribute in the foo namespace whose local
|
|
395
|
+
# name merely happens to be `xmlns`. Testing the local name first let it
|
|
396
|
+
# skip `prefixed` and, at the SVG namespace value, come back harmless:
|
|
397
|
+
# measured, a `lossless` verdict for a document carrying an attribute
|
|
398
|
+
# from a namespace nobody has measured.
|
|
399
|
+
def feature_for(prefix, name, value)
|
|
400
|
+
return :unclassified if CharacterRules.ill_formed?(value)
|
|
401
|
+
return nil if prefix == "xmlns"
|
|
402
|
+
return prefixed(prefix, name) if prefix
|
|
403
|
+
return namespace(value) if name == "xmlns"
|
|
404
|
+
|
|
405
|
+
ATTR_FEATURES[name] || valued(name, value)
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
# A default namespace can be rebound on any descendant, so this is
|
|
409
|
+
# checked on every element rather than only on the root. Value-sensitive:
|
|
410
|
+
# a redundant redeclaration of the SVG namespace is harmless.
|
|
411
|
+
# SVG 2 `href` wins over `xlink:href` when both are present.
|
|
412
|
+
def raster_feature(feature, attributes)
|
|
413
|
+
return feature unless feature == :linked_raster
|
|
414
|
+
|
|
415
|
+
DATA_URI.match?(attributes["href"] || attributes["xlink:href"] || "") ? :embedded_raster : feature
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
def namespace(value)
|
|
419
|
+
value == SVG_NAMESPACE ? nil : :unclassified
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
def prefixed(prefix, name)
|
|
423
|
+
ATTR_ALLOWED_PREFIXED[prefix]&.include?(name) ? nil : :unclassified
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
def valued(name, value)
|
|
427
|
+
return nil if ATTR_HARMLESS.include?(name)
|
|
428
|
+
|
|
429
|
+
rule = VALUE_RULES[name]
|
|
430
|
+
return :unclassified if rule.nil?
|
|
431
|
+
|
|
432
|
+
rule.match?(value) ? nil : :unclassified
|
|
433
|
+
end
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
# Extracted for the reason AttributeRules is: Scanner stays inside
|
|
437
|
+
# Metrics/ClassLength without an exemption.
|
|
438
|
+
#
|
|
439
|
+
# "Unaccounted for" rather than "illegal", because a value can fail to
|
|
440
|
+
# establish its characters two ways and both are the same answer here: it
|
|
441
|
+
# denotes something XML forbids, or it denotes something this scanner
|
|
442
|
+
# cannot resolve at all.
|
|
443
|
+
module CharacterRules
|
|
444
|
+
module_function
|
|
445
|
+
|
|
446
|
+
# Attribute values arrive as a Hash, and its KEYS are swept too: the
|
|
447
|
+
# names REXML accepts are not the same set as the names XML 1.0 allows.
|
|
448
|
+
# Only three classes reach here -- measured across every convert fixture
|
|
449
|
+
# plus a document exercising all 14 event types: String, Hash, and nil
|
|
450
|
+
# for an absent optional field such as an xmldecl's encoding.
|
|
451
|
+
def unaccounted?(field)
|
|
452
|
+
case field
|
|
453
|
+
when String then unaccounted_text?(field)
|
|
454
|
+
when Hash then field.any? { |name, value| unaccounted?(name) || unaccounted?(value) }
|
|
455
|
+
else false
|
|
456
|
+
end
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
# What REXML hands back is RAW for an attribute value: `id="ab"`
|
|
460
|
+
# DENOTES five characters and SPELLS eight. Only character data gets a
|
|
461
|
+
# decoded second field, so checking the bytes as given caught text by
|
|
462
|
+
# accident of the event shape and let every name-admitted attribute
|
|
463
|
+
# through -- `id`, `version`, `xml:space`, `xml:lang` and `xmlns:*` all
|
|
464
|
+
# reached `lossless`. detector.rb:423-424 states the same fact about the
|
|
465
|
+
# same parser, and this reuses the resolver it states it beside.
|
|
466
|
+
#
|
|
467
|
+
# The reference test runs on the RAW and the character test on the
|
|
468
|
+
# RESOLVED. That order is the rule, not a convenience: `&nope;`
|
|
469
|
+
# denotes the literal text "&nope;", which is legal, and after
|
|
470
|
+
# resolution it is indistinguishable from a genuinely unresolved
|
|
471
|
+
# `&nope;`. AttributeReferences documents the same asymmetry.
|
|
472
|
+
# A complete reference, so an INCOMPLETE one can be told apart from a
|
|
473
|
+
# merely unresolvable one.
|
|
474
|
+
VALID_REFERENCE = /&(?:\#\d+;|\#x\h+;|#{REXML::XMLTokens::NAME};)/
|
|
475
|
+
|
|
476
|
+
# Well-formedness, which is narrower than "every character is legal".
|
|
477
|
+
# `&` and `&#xZZ;` match no reference at all, so the unresolved test
|
|
478
|
+
# never sees them, and the characters they spell are perfectly legal --
|
|
479
|
+
# three guards in a row passed them while `id` supplied positive evidence
|
|
480
|
+
# by name. A raw `<` is never permitted here either. REXML::Document
|
|
481
|
+
# refuses every such document; PullParser reads it.
|
|
482
|
+
#
|
|
483
|
+
# PARSED character data ONLY -- attribute values and text. CDATA and
|
|
484
|
+
# comments legally carry a raw `<` and a bare `&`, so a rule applied to
|
|
485
|
+
# every field would condemn two shapes this suite pins as `lossless`.
|
|
486
|
+
def ill_formed?(raw)
|
|
487
|
+
raw.include?("<") || raw.gsub(VALID_REFERENCE, "").include?("&")
|
|
488
|
+
end
|
|
489
|
+
|
|
490
|
+
def unaccounted_text?(raw)
|
|
491
|
+
return true if AttributeReferences.unresolved?(raw)
|
|
492
|
+
|
|
493
|
+
resolved = AttributeReferences.resolve(raw)
|
|
494
|
+
# nil is RangeError -- a reference outside Unicode. Invalid UTF-8 is a
|
|
495
|
+
# surrogate or a codepoint above U+10FFFF, which `resolve` produces
|
|
496
|
+
# WITHOUT raising. Both mean the denoted characters are unestablished,
|
|
497
|
+
# and `match?` would raise rather than answer about either.
|
|
498
|
+
return true if resolved.nil? || !resolved.valid_encoding?
|
|
499
|
+
|
|
500
|
+
ILLEGAL_CHAR.match?(resolved)
|
|
501
|
+
end
|
|
502
|
+
end
|
|
503
|
+
|
|
504
|
+
# One small method per surface, dispatched from the pull loop. Decomposed
|
|
505
|
+
# rather than exempted: only Metrics/BlockLength is configured in
|
|
506
|
+
# .rubocop.yml, so every default limit is live, and every cop exemption in
|
|
507
|
+
# lib/ is a Style one -- there is no Metrics exemption anywhere.
|
|
508
|
+
# detector.rb is 686 lines and clean by this same route.
|
|
509
|
+
#
|
|
510
|
+
# (Spelling that directive out in full here would BE one: RuboCop reads the
|
|
511
|
+
# token in a comment, not the sentence around it. Measured -- it reported
|
|
512
|
+
# Lint/RedundantCopDisableDirective on the first draft of this paragraph.)
|
|
513
|
+
# Carries a fault raised by the CALLER's own source out past both parse
|
|
514
|
+
# rescues. REXML invokes the reader INSIDE them, so without this an IO
|
|
515
|
+
# whose `readline` raises came back as `unknown` -- a verdict about the
|
|
516
|
+
# caller's document, when the fault was in their object.
|
|
517
|
+
#
|
|
518
|
+
# This is why the fault is tagged at its ORIGIN rather than inferred from
|
|
519
|
+
# where a rescue sits. `PullParser.new` being outside the rescue proves
|
|
520
|
+
# only that CONSTRUCTION is safe; it says nothing about where REXML READS.
|
|
521
|
+
class SourceFault < StandardError
|
|
522
|
+
def initialize(cause)
|
|
523
|
+
@original = cause
|
|
524
|
+
super(cause.message)
|
|
525
|
+
end
|
|
526
|
+
|
|
527
|
+
attr_reader :original
|
|
528
|
+
end
|
|
529
|
+
private_constant :SourceFault
|
|
530
|
+
|
|
531
|
+
# Forwards to the caller's source and tags anything it raises, for every
|
|
532
|
+
# delegated call EXCEPT `readline` -- the canonical explanation for that
|
|
533
|
+
# carve-out lives on the `rescue StandardError` below, where the decision
|
|
534
|
+
# is actually made; every other mention here is a pointer to it, not a
|
|
535
|
+
# restatement, so there is exactly one place to correct if REXML's own
|
|
536
|
+
# behaviour ever changes. Every source is wrapped, including a String: a
|
|
537
|
+
# String cannot raise while being read, so the special case that skipped
|
|
538
|
+
# it was a branch nothing could distinguish -- measured, always wrapping
|
|
539
|
+
# passes the whole suite. REXML calls exactly ONE `to_str` on a String,
|
|
540
|
+
# then reads its OWN internal StringIO -- measured with `TracePoint`,
|
|
541
|
+
# which records every method Ruby DISPATCHES on the object rather than
|
|
542
|
+
# only the ones a spy remembered to name: `classify`'s own boundary
|
|
543
|
+
# checks (`refuse_unreadable`, `refuse_unpositioned`) call
|
|
544
|
+
# `respond_to?`/`is_a?` on the raw source before it is ever wrapped here,
|
|
545
|
+
# and REXML's own `SourceFactory` asks this wrapper a few more
|
|
546
|
+
# `respond_to?` questions (forwarded to the raw source via
|
|
547
|
+
# `respond_to_missing?` below) before it settles on the one `to_str`
|
|
548
|
+
# call. `respond_to?`/`is_a?`/`to_str` are the only three methods ever
|
|
549
|
+
# DISPATCHED on a core String source, in any call to `classify` --
|
|
550
|
+
# nothing else is ever called on it. This is a claim about method
|
|
551
|
+
# dispatch, not about the bytes: REXML's internal `StringIO` may hold a
|
|
552
|
+
# live reference to the same object rather than an independent copy, so
|
|
553
|
+
# a caller who mutates the very String they handed to `classify` while
|
|
554
|
+
# it is still running is a separate, out-of-scope hazard -- the same one
|
|
555
|
+
# that exists for any Ruby API given a mutable object by reference, and
|
|
556
|
+
# no different from an IO whose underlying file changes mid-read. See
|
|
557
|
+
# the CALLER CONTRACT comment on `classify` for what the dispatch limit
|
|
558
|
+
# implies for the truncated-String gap specifically.
|
|
559
|
+
class TaggedSource
|
|
560
|
+
def initialize(source) = @source = source
|
|
561
|
+
|
|
562
|
+
def respond_to_missing?(name, include_private = false)
|
|
563
|
+
@source.respond_to?(name, include_private)
|
|
564
|
+
end
|
|
565
|
+
|
|
566
|
+
# Recorded as well as raised, for the calls that reach here at all --
|
|
567
|
+
# `readline` never sets this, it sets `readline_faulted` instead. See
|
|
568
|
+
# the `rescue StandardError` below for why, and for the REXML citation
|
|
569
|
+
# both share.
|
|
570
|
+
attr_reader :fault
|
|
571
|
+
|
|
572
|
+
# Set (never unset) the moment a `readline` fault is deliberately let
|
|
573
|
+
# through unescalated -- `scan`, above, reads this to force `unknown`
|
|
574
|
+
# rather than trust a verdict computed from a parse that stopped
|
|
575
|
+
# early. See the `rescue StandardError` below for why a `readline`
|
|
576
|
+
# fault is left through in the first place.
|
|
577
|
+
attr_reader :readline_faulted
|
|
578
|
+
|
|
579
|
+
def method_missing(name, *, &)
|
|
580
|
+
# `__send__`, never `public_send`: a bounded source may be a bare
|
|
581
|
+
# BasicObject, which does not define `public_send`, so that call
|
|
582
|
+
# lands in its own method_missing and reads as a forbidden access.
|
|
583
|
+
# `__send__` is a real BasicObject method and dispatches cleanly.
|
|
584
|
+
@source.__send__(name, *, &)
|
|
585
|
+
rescue SourceFault
|
|
586
|
+
raise
|
|
587
|
+
rescue StandardError => e
|
|
588
|
+
# CANONICAL explanation for the `readline` carve-out -- every other
|
|
589
|
+
# comment in this class and its specs points here rather than
|
|
590
|
+
# restating it.
|
|
591
|
+
#
|
|
592
|
+
# `readline` is a delegated call REXML absorbs rather than lets
|
|
593
|
+
# escape, on every source shape, String included -- that absorption
|
|
594
|
+
# is what already makes a malformed String classify `unknown`
|
|
595
|
+
# instead of raising. Two mechanisms do it, not one: most of the
|
|
596
|
+
# time it's `IOSource#read`'s own `rescue Exception, NameError`
|
|
597
|
+
# (rexml source.rb:245-264, the rescue at :260-262); when `readline`
|
|
598
|
+
# is instead called from `read_until` (source.rb:266-282, reachable
|
|
599
|
+
# when a comment/attribute/text run holds a literal `>` before its
|
|
600
|
+
# real terminator), THAT call is unguarded and it is
|
|
601
|
+
# `BaseParser#pull_event`'s bare `rescue` (baseparser.rb:527-529)
|
|
602
|
+
# that converts it to a `REXML::ParseException` instead, which
|
|
603
|
+
# `Scanner#next_event`'s own rescue then catches. Either way REXML
|
|
604
|
+
# ends the parse rather than raising past its own boundary, so a
|
|
605
|
+
# caller's `readline` that always fails is indistinguishable, from
|
|
606
|
+
# outside REXML, from REXML's OWN read strategy hitting one of these
|
|
607
|
+
# on a perfectly ordinary File: measured, `readline` fails on 7 real,
|
|
608
|
+
# already-shipped fixtures (DOCTYPE internal subsets, UTF-16) only
|
|
609
|
+
# after 6-10 PRIOR successful `readline` calls on the very same
|
|
610
|
+
# object -- tagging this raised `EOFError`/`ArgumentError` as a
|
|
611
|
+
# caller fault and re-reporting it after the scan (see `scan` above)
|
|
612
|
+
# turned those ordinary fixtures into a raw exception instead of the
|
|
613
|
+
# `unknown` their String form correctly gets.
|
|
614
|
+
#
|
|
615
|
+
# Left through unescalated, REXML does with this exactly what it
|
|
616
|
+
# does for a String. That is not, on its own, enough to trust
|
|
617
|
+
# whatever verdict the scan then computes -- see `readline_faulted`
|
|
618
|
+
# and `scan`'s use of it -- but it is enough to guarantee the scan
|
|
619
|
+
# completes instead of crashing, which is this whole carve-out's job.
|
|
620
|
+
if name == :readline
|
|
621
|
+
@readline_faulted = true
|
|
622
|
+
raise e
|
|
623
|
+
end
|
|
624
|
+
|
|
625
|
+
@fault ||= e
|
|
626
|
+
raise SourceFault, e
|
|
627
|
+
end
|
|
628
|
+
end
|
|
629
|
+
private_constant :TaggedSource
|
|
630
|
+
|
|
631
|
+
class Scanner
|
|
632
|
+
# Raised by `next_event` and caught by `run`, so the two failures that
|
|
633
|
+
# both mean "REXML refused this document" converge on one verdict without
|
|
634
|
+
# a flag whose only job is to skip the lines after the loop. Never
|
|
635
|
+
# escapes this class.
|
|
636
|
+
class Unreadable < StandardError; end
|
|
637
|
+
private_constant :Unreadable
|
|
638
|
+
|
|
639
|
+
# `kept:` is every feature some target keeps. A name with a namespace
|
|
640
|
+
# prefix is not trusted to mean the feature it spells for any of them,
|
|
641
|
+
# so a target declared after this file was written must be in the set.
|
|
642
|
+
def initialize(source, kept: KEPT_FEATURES)
|
|
643
|
+
@source = source
|
|
644
|
+
@kept = kept
|
|
645
|
+
@found = []
|
|
646
|
+
@root_ok = false
|
|
647
|
+
@depth = @roots = 0
|
|
648
|
+
@phase = :prolog
|
|
649
|
+
end
|
|
650
|
+
|
|
651
|
+
# `next_event` reports "REXML refused this document" as `Unreadable`.
|
|
652
|
+
# What is left for `ArgumentError` is `consume` and AttributeRules,
|
|
653
|
+
# which genuinely raise it on invalid-UTF-8 input -- measured, always
|
|
654
|
+
# with this exact message. `consume` is OUR code too, though, and a
|
|
655
|
+
# real bug there (wrong arity, a typo) raises the same class: measured,
|
|
656
|
+
# injecting one was silently reported as `"unknown"` before `run`
|
|
657
|
+
# started matching by MESSAGE, not class alone, so a bug in our own
|
|
658
|
+
# code is never mistaken for a bad document.
|
|
659
|
+
def run
|
|
660
|
+
parser = REXML::Parsers::PullParser.new(@source)
|
|
661
|
+
while (event = next_event(parser))
|
|
662
|
+
consume(event)
|
|
663
|
+
end
|
|
664
|
+
note_truncation
|
|
665
|
+
[@root_ok, @found.uniq]
|
|
666
|
+
rescue Unreadable, ArgumentError => e
|
|
667
|
+
raise if e.is_a?(ArgumentError) && e.message != "invalid byte sequence in UTF-8"
|
|
668
|
+
|
|
669
|
+
[false, [:unclassified]]
|
|
670
|
+
end
|
|
671
|
+
|
|
672
|
+
private
|
|
673
|
+
|
|
674
|
+
# The rescue wraps the parser ALONE, the route detector.rb:417-421 takes,
|
|
675
|
+
# and that is what makes catching RuntimeError here safe. REXML defends
|
|
676
|
+
# itself against entity bombs with two BARE `raise "..."` sites reachable
|
|
677
|
+
# from this loop -- baseparser.rb:642 "number of entity expansions
|
|
678
|
+
# exceeded" and :600 "entity expansion has grown too large" -- and both
|
|
679
|
+
# escaped `classify`, which is documented to return one of three levels.
|
|
680
|
+
#
|
|
681
|
+
# Catching RuntimeError around the WHOLE scan instead would report a bug
|
|
682
|
+
# in `consume` or AttributeRules as an unreadable document. Here nothing
|
|
683
|
+
# of ours runs inside, so there is no fault of ours to swallow.
|
|
684
|
+
#
|
|
685
|
+
# It is also a NARROWING, not a widening: REXML::ParseException is itself
|
|
686
|
+
# a RuntimeError -- measured, `[ParseException, RuntimeError, StandardError,
|
|
687
|
+
# Exception]` -- so the class this used to catch is a subset of the one it
|
|
688
|
+
# catches now. Listing both would be Lint/ShadowedException. ArgumentError
|
|
689
|
+
# is separate: it is REXML's answer to an unusable encoding name.
|
|
690
|
+
#
|
|
691
|
+
# `PullParser.new` sits OUTSIDE, deliberately. A source that is neither
|
|
692
|
+
# String nor IO raises `RuntimeError: NilClass is not a valid input
|
|
693
|
+
# stream.` there -- measured for nil, Integer, Array and Hash -- and that
|
|
694
|
+
# is a caller error, never a verdict.
|
|
695
|
+
def next_event(parser)
|
|
696
|
+
parser.pull if parser.has_next?
|
|
697
|
+
rescue RuntimeError, ArgumentError
|
|
698
|
+
raise Unreadable
|
|
699
|
+
end
|
|
700
|
+
|
|
701
|
+
# PullParser does NOT report an unclosed element stack at EOF; only
|
|
702
|
+
# REXML::Document does. So "REXML raised" is not the whole of
|
|
703
|
+
# malformedness -- measured, `malformed.svg` is a MISMATCHED END TAG,
|
|
704
|
+
# which raises, while EOF-with-an-open-stack does not. Truncating
|
|
705
|
+
# large_trailing_gradient.svg by 30 bytes turned `lossy` into
|
|
706
|
+
# `lossless`: losing evidence made the verdict MORE confident, the exact
|
|
707
|
+
# inversion the rule at the top of this file forbids.
|
|
708
|
+
#
|
|
709
|
+
# The depth was already tracked and simply never consulted. A bare
|
|
710
|
+
# self-closing root (`<svg .../>`) also ends at depth 1, because REXML
|
|
711
|
+
# emits `[:start_element]` alone for it -- that document always has an
|
|
712
|
+
# empty feature set, so the ladder answers `unknown` either way and this
|
|
713
|
+
# guard costs it nothing. Both are pinned by fixture.
|
|
714
|
+
def note_truncation
|
|
715
|
+
note(:unclassified) unless @depth.zero?
|
|
716
|
+
end
|
|
717
|
+
|
|
718
|
+
# Deduplicated at INSERTION, not just once at the end of `run`. `note`
|
|
719
|
+
# fires once per matched event, and a document can repeat the same
|
|
720
|
+
# feature (e.g. a `gradient` on every one of a thousand shapes) --
|
|
721
|
+
# measured, that grew `@found` by one entry per match instead of staying
|
|
722
|
+
# bounded by the fixed vocabulary of features this module knows about
|
|
723
|
+
# (`RULES`/`ATTR_FEATURES`/`KEPT_FEATURES`). `include?` here is checked
|
|
724
|
+
# against that same small, fixed set, so this stays cheap regardless of
|
|
725
|
+
# document size.
|
|
726
|
+
def note(feature)
|
|
727
|
+
@found << feature unless @found.include?(feature)
|
|
728
|
+
end
|
|
729
|
+
|
|
730
|
+
# EVERY field of every event, not the character-data ones alone. The
|
|
731
|
+
# obvious guard checks `:text`, `:cdata` and `:comment`; `id` is exactly
|
|
732
|
+
# the route that list misses, because ATTR_HARMLESS admits it by NAME and
|
|
733
|
+
# its value never reaches a value rule -- measured, `id="a\u001Cb"`
|
|
734
|
+
# classified `lossless`. A list of the routes we thought of is what let
|
|
735
|
+
# this through in the first place.
|
|
736
|
+
#
|
|
737
|
+
# `event[0..]` is PullEvent's Range form, `@contents.slice(1..nil)`, so it
|
|
738
|
+
# yields the whole tail whatever the event's arity -- measured 0 to 5
|
|
739
|
+
# across all 14 emitted types, the widest being an entity declaration
|
|
740
|
+
# with a PUBLIC id and an NDATA notation. No bound to keep in step.
|
|
741
|
+
# A `:text` event carries the raw source AND REXML's own decoding of
|
|
742
|
+
# it; only the first is source. The decoded copy answers a question
|
|
743
|
+
# this file answers for itself, and reading it made an ESCAPED
|
|
744
|
+
# reference indistinguishable from a genuine one -- measured, the
|
|
745
|
+
# decoded forms of `a&nope;b` and `a&nope;b` are byte-identical
|
|
746
|
+
# while the raw forms are not. The attribute path always read the raw,
|
|
747
|
+
# which is why attributes were right when text was not.
|
|
748
|
+
def note_unaccounted_characters(event)
|
|
749
|
+
text = event.event_type == :text
|
|
750
|
+
fields = text ? [event[0]] : event[0..]
|
|
751
|
+
note(:unclassified) if fields.any? { |f| CharacterRules.unaccounted?(f) }
|
|
752
|
+
note(:unclassified) if text && CharacterRules.ill_formed?(event[0])
|
|
753
|
+
end
|
|
754
|
+
|
|
755
|
+
def consume(event)
|
|
756
|
+
note(:unclassified) unless KNOWN_EVENTS.include?(event.event_type)
|
|
757
|
+
note_unaccounted_characters(event)
|
|
758
|
+
note_declarations(event)
|
|
759
|
+
return close_element if event.event_type == :end_element
|
|
760
|
+
|
|
761
|
+
note_epilog(event)
|
|
762
|
+
visit_element(event) if event.start_element?
|
|
763
|
+
end
|
|
764
|
+
|
|
765
|
+
# An external subset is never fetched and an internal one is never
|
|
766
|
+
# applied, so either can change what the document means unobserved.
|
|
767
|
+
def note_declarations(event)
|
|
768
|
+
note(:unclassified) if SUBSET_DECLARATIONS.include?(event.event_type)
|
|
769
|
+
note(:unclassified) if event.event_type == :processing_instruction
|
|
770
|
+
return unless event.event_type == :start_doctype
|
|
771
|
+
|
|
772
|
+
note(:unclassified) if %w[SYSTEM PUBLIC].include?(event[1].to_s)
|
|
773
|
+
end
|
|
774
|
+
|
|
775
|
+
def close_element
|
|
776
|
+
@depth -= 1
|
|
777
|
+
@phase = :epilog if @depth.zero?
|
|
778
|
+
end
|
|
779
|
+
|
|
780
|
+
# REXML accepts a second root and post-root CDATA without raising --
|
|
781
|
+
# measured, [svg, rect, line, rect] for a document with two roots.
|
|
782
|
+
#
|
|
783
|
+
# Two omissions from this list are deliberate, both measured over every
|
|
784
|
+
# post-root shape. `:text` is DEAD: post-root text raises ParseException
|
|
785
|
+
# before reaching here, and post-root whitespace emits no text event at
|
|
786
|
+
# all, so it never fires -- removed rather than kept as a fail-safe,
|
|
787
|
+
# because an unreachable branch is indistinguishable from an untested
|
|
788
|
+
# one to the next reader. `:comment` is absent because a comment after
|
|
789
|
+
# the root is legal XML, not evidence of anything.
|
|
790
|
+
def note_epilog(event)
|
|
791
|
+
return unless @phase == :epilog
|
|
792
|
+
return unless %i[start_element cdata].include?(event.event_type)
|
|
793
|
+
|
|
794
|
+
note(:unclassified)
|
|
795
|
+
end
|
|
796
|
+
|
|
797
|
+
def visit_element(event)
|
|
798
|
+
prefix, name = split_name(event[0])
|
|
799
|
+
root = @depth.zero?
|
|
800
|
+
visit_root(prefix, name, event[1]) if root
|
|
801
|
+
@depth += 1
|
|
802
|
+
visit_attributes(event[1])
|
|
803
|
+
feature = element_feature(prefix, name, root, event[1])
|
|
804
|
+
note(feature) if feature
|
|
805
|
+
end
|
|
806
|
+
|
|
807
|
+
# A local name alone is not proof: `<svg xmlns="...notsvg">` and a bare
|
|
808
|
+
# `<svg>` both pass it. The namespace must be declared POSITIVELY --
|
|
809
|
+
# "not foreign" is satisfied vacuously by an absent declaration.
|
|
810
|
+
def visit_root(prefix, name, attributes)
|
|
811
|
+
@roots += 1
|
|
812
|
+
return note(:unclassified) if @roots > 1
|
|
813
|
+
|
|
814
|
+
@root_ok = name == "svg" && prefix.nil? &&
|
|
815
|
+
attributes["xmlns"] == SVG_NAMESPACE
|
|
816
|
+
# Absent dimensions depend on the same unseen viewport context as a
|
|
817
|
+
# percentage; handlers/svg.rb:116-119 takes the same position.
|
|
818
|
+
note(:unclassified) unless attributes["width"] && attributes["height"]
|
|
819
|
+
end
|
|
820
|
+
|
|
821
|
+
def visit_attributes(attributes)
|
|
822
|
+
attributes.each do |qualified, value|
|
|
823
|
+
feature = AttributeRules.feature_for(*split_name(qualified), value)
|
|
824
|
+
note(feature) if feature
|
|
825
|
+
end
|
|
826
|
+
end
|
|
827
|
+
|
|
828
|
+
# Two orderings matter here.
|
|
829
|
+
#
|
|
830
|
+
# The prefix test runs BEFORE the IGNORED skip: `<foo:defs/>` strips to
|
|
831
|
+
# "defs", and being skipped as structural is how a foreign-namespace
|
|
832
|
+
# element reached a `lossless` verdict.
|
|
833
|
+
#
|
|
834
|
+
# And only the CONFIRMED ROOT `<svg>` is structural. A nested `<svg>` is
|
|
835
|
+
# a new viewport with its own coordinate system, and is unmeasured --
|
|
836
|
+
# measured, ignoring it by name classified such a document `lossless`
|
|
837
|
+
# while everything inside the inner viewport went uncounted. So IGNORED
|
|
838
|
+
# membership is positional, not nominal.
|
|
839
|
+
def element_feature(prefix, name, root, attributes)
|
|
840
|
+
return :unclassified if prefix && IGNORED.include?(name)
|
|
841
|
+
return structural_feature(name, root) if IGNORED.include?(name)
|
|
842
|
+
|
|
843
|
+
content_feature(prefix, name, attributes)
|
|
844
|
+
end
|
|
845
|
+
|
|
846
|
+
def structural_feature(name, root)
|
|
847
|
+
return nil if name == "defs" || root
|
|
848
|
+
|
|
849
|
+
:unclassified
|
|
850
|
+
end
|
|
851
|
+
|
|
852
|
+
def content_feature(prefix, name, attributes)
|
|
853
|
+
feature = AttributeRules.raster_feature(ELEMENTS.fetch(name, :unclassified), attributes)
|
|
854
|
+
prefix && @kept.include?(feature) ? :unclassified : feature
|
|
855
|
+
end
|
|
856
|
+
|
|
857
|
+
def split_name(qualified)
|
|
858
|
+
qualified.include?(":") ? qualified.split(":", 2) : [nil, qualified]
|
|
859
|
+
end
|
|
860
|
+
end
|
|
861
|
+
end
|
|
862
|
+
|
|
863
|
+
private_constant :Lossiness
|
|
864
|
+
end
|