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,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="a&#28;b"`
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: `&amp;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
+ # `&amp` 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&amp;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