svg_conform 0.2.4 → 0.2.7

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 (125) hide show
  1. checksums.yaml +4 -4
  2. data/.gitattributes +1 -0
  3. data/.github/workflows/rake.yml +4 -1
  4. data/Gemfile +6 -3
  5. data/Gemfile.lock +211 -0
  6. data/TODO.issue-100/00-overview.md +135 -0
  7. data/TODO.issue-100/01-build-lens-core-v1.md +92 -0
  8. data/TODO.issue-100/02-adopt-lens-core-in-svg.md +123 -0
  9. data/TODO.issue-100/03-ship-svg-1-1-full-static.md +138 -0
  10. data/TODO.issue-100/04-ship-svg-2-secure-static.md +131 -0
  11. data/TODO.issue-100/05-freeze-candidate-and-handoff.md +108 -0
  12. data/TODO.issue-100/90-progress.md +65 -0
  13. data/config/profiles/metanorma.yml +4 -4
  14. data/config/profiles/svg_1_1_full_static.yml +69 -0
  15. data/config/profiles/svg_2_secure_static.yml +55 -0
  16. data/config/requirements/processing_features_static.yml +29 -0
  17. data/data/svg_1_1/manifest.yml +7 -0
  18. data/data/svg_1_1/svg11-flat-20110816.dtd +5664 -0
  19. data/docs/api_reference.adoc +19 -1
  20. data/docs/cli_guide.adoc +35 -45
  21. data/docs/diagnostics.adoc +73 -0
  22. data/docs/lens_core_handoff.adoc +84 -0
  23. data/docs/profiles.adoc +94 -1
  24. data/lib/svg_conform/cli.rb +3 -4
  25. data/lib/svg_conform/commands/check.rb +11 -60
  26. data/lib/svg_conform/conformance_report.rb +79 -1
  27. data/lib/svg_conform/diagnostics/lens_core_adapter.rb +200 -0
  28. data/lib/svg_conform/document.rb +12 -1
  29. data/lib/svg_conform/external_checkers/svgcheck/parser.rb +1 -3
  30. data/lib/svg_conform/node_helpers.rb +1 -1
  31. data/lib/svg_conform/profile.rb +11 -2
  32. data/lib/svg_conform/quality_metrics/complexity_calculator.rb +3 -3
  33. data/lib/svg_conform/quality_metrics/configuration.rb +4 -2
  34. data/lib/svg_conform/references/base_reference.rb +153 -0
  35. data/lib/svg_conform/references/reference_manifest.rb +1 -2
  36. data/lib/svg_conform/report_comparator.rb +4 -4
  37. data/lib/svg_conform/requirements/base_requirement.rb +6 -0
  38. data/lib/svg_conform/requirements/external_references_requirement.rb +234 -0
  39. data/lib/svg_conform/requirements/link_validation_requirement.rb +2 -0
  40. data/lib/svg_conform/requirements/processing_features_requirement.rb +175 -0
  41. data/lib/svg_conform/requirements/svg_1_1_document_requirement.rb +213 -0
  42. data/lib/svg_conform/requirements.rb +6 -0
  43. data/lib/svg_conform/sax_document.rb +8 -4
  44. data/lib/svg_conform/sax_validation_handler.rb +22 -1
  45. data/lib/svg_conform/validation/css2_core_grammar.rb +359 -0
  46. data/lib/svg_conform/validation/css_references.rb +122 -0
  47. data/lib/svg_conform/validation/error_tracker.rb +40 -9
  48. data/lib/svg_conform/validation/svg_1_1_document_conformance.rb +154 -0
  49. data/lib/svg_conform/validation_context.rb +25 -5
  50. data/lib/svg_conform/validation_result.rb +91 -16
  51. data/lib/svg_conform/version.rb +1 -1
  52. data/lib/svg_conform/yaml_io.rb +20 -0
  53. data/lib/svg_conform.rb +25 -0
  54. data/spec/fixtures/diagnostics/external-reference-info.svg +6 -0
  55. data/spec/fixtures/plantuml/README.adoc +11 -0
  56. data/spec/fixtures/plantuml/unit-type.svg +1 -0
  57. data/spec/fixtures/profiles/svg_1_1_full_static/invalid/animation.svg +4 -0
  58. data/spec/fixtures/profiles/svg_1_1_full_static/invalid/css-grammar.svg +4 -0
  59. data/spec/fixtures/profiles/svg_1_1_full_static/invalid/dtd-attribute.svg +4 -0
  60. data/spec/fixtures/profiles/svg_1_1_full_static/invalid/dtd-content-model.svg +4 -0
  61. data/spec/fixtures/profiles/svg_1_1_full_static/invalid/interaction.svg +4 -0
  62. data/spec/fixtures/profiles/svg_1_1_full_static/invalid/malformed-xml.svg +4 -0
  63. data/spec/fixtures/profiles/svg_1_1_full_static/invalid/script.svg +5 -0
  64. data/spec/fixtures/profiles/svg_1_1_full_static/invalid/wrong-namespace.svg +4 -0
  65. data/spec/fixtures/profiles/svg_1_1_full_static/invalid/wrong-version.svg +4 -0
  66. data/spec/fixtures/profiles/svg_1_1_full_static/invalid/xml-stylesheet.svg +5 -0
  67. data/spec/fixtures/profiles/svg_1_1_full_static/valid/css.svg +5 -0
  68. data/spec/fixtures/profiles/svg_1_1_full_static/valid/foreign-metadata.svg +6 -0
  69. data/spec/fixtures/profiles/svg_1_1_full_static/valid/minimal-full.svg +5 -0
  70. data/spec/fixtures/profiles/svg_1_1_full_static/valid/xml-stylesheet.svg +5 -0
  71. data/spec/fixtures/profiles/svg_2_secure_static/invalid/animation.svg +4 -0
  72. data/spec/fixtures/profiles/svg_2_secure_static/invalid/css-import.svg +4 -0
  73. data/spec/fixtures/profiles/svg_2_secure_static/invalid/css-url.svg +4 -0
  74. data/spec/fixtures/profiles/svg_2_secure_static/invalid/data-bad-png.svg +4 -0
  75. data/spec/fixtures/profiles/svg_2_secure_static/invalid/data-empty-png.svg +4 -0
  76. data/spec/fixtures/profiles/svg_2_secure_static/invalid/data-malformed-base64.svg +4 -0
  77. data/spec/fixtures/profiles/svg_2_secure_static/invalid/data-malformed-percent.svg +4 -0
  78. data/spec/fixtures/profiles/svg_2_secure_static/invalid/data-unsupported-type.svg +4 -0
  79. data/spec/fixtures/profiles/svg_2_secure_static/invalid/file-like-fragment.svg +4 -0
  80. data/spec/fixtures/profiles/svg_2_secure_static/invalid/http.svg +4 -0
  81. data/spec/fixtures/profiles/svg_2_secure_static/invalid/https.svg +4 -0
  82. data/spec/fixtures/profiles/svg_2_secure_static/invalid/interaction.svg +4 -0
  83. data/spec/fixtures/profiles/svg_2_secure_static/invalid/protocol-relative.svg +4 -0
  84. data/spec/fixtures/profiles/svg_2_secure_static/invalid/relative-file-fragment.svg +4 -0
  85. data/spec/fixtures/profiles/svg_2_secure_static/invalid/relative-file.svg +4 -0
  86. data/spec/fixtures/profiles/svg_2_secure_static/invalid/script.svg +4 -0
  87. data/spec/fixtures/profiles/svg_2_secure_static/invalid/svg-data-excessive-nesting.svg +4 -0
  88. data/spec/fixtures/profiles/svg_2_secure_static/invalid/svg-data-malformed-xml.svg +4 -0
  89. data/spec/fixtures/profiles/svg_2_secure_static/invalid/svg-data-nested-external.svg +4 -0
  90. data/spec/fixtures/profiles/svg_2_secure_static/invalid/urn.svg +4 -0
  91. data/spec/fixtures/profiles/svg_2_secure_static/invalid/xml-stylesheet.svg +5 -0
  92. data/spec/fixtures/profiles/svg_2_secure_static/valid/jpeg-base64.svg +4 -0
  93. data/spec/fixtures/profiles/svg_2_secure_static/valid/jpeg-percent.svg +4 -0
  94. data/spec/fixtures/profiles/svg_2_secure_static/valid/png-base64.svg +4 -0
  95. data/spec/fixtures/profiles/svg_2_secure_static/valid/png-percent.svg +4 -0
  96. data/spec/fixtures/profiles/svg_2_secure_static/valid/same-document-fragment.svg +4 -0
  97. data/spec/fixtures/profiles/svg_2_secure_static/valid/svg-base64.svg +4 -0
  98. data/spec/fixtures/profiles/svg_2_secure_static/valid/svg-percent.svg +4 -0
  99. data/spec/fixtures/quality_metrics/aliases.yml +7 -0
  100. data/spec/svg_conform/commands/check_command_spec.rb +22 -2
  101. data/spec/svg_conform/commands/profiles_command_spec.rb +18 -0
  102. data/spec/svg_conform/conformance_report_spec.rb +90 -0
  103. data/spec/svg_conform/diagnostics/lens_core_adapter_spec.rb +136 -0
  104. data/spec/svg_conform/gemspec_spec.rb +34 -0
  105. data/spec/svg_conform/profiles/metanorma_profile_spec.rb +48 -0
  106. data/spec/svg_conform/profiles/svg_1_1_full_static_profile_spec.rb +146 -0
  107. data/spec/svg_conform/profiles/svg_2_secure_static_profile_spec.rb +150 -0
  108. data/spec/svg_conform/profiles_plantuml_spec.rb +81 -0
  109. data/spec/svg_conform/quality_metrics/configuration_spec.rb +82 -0
  110. data/spec/svg_conform/references/data_uri_reference_spec.rb +97 -0
  111. data/spec/svg_conform/references/integration_spec.rb +30 -0
  112. data/spec/svg_conform/references/reference_classifier_spec.rb +17 -0
  113. data/spec/svg_conform/requirements/external_references_requirement_spec.rb +109 -0
  114. data/spec/svg_conform/requirements/link_validation_requirement_spec.rb +16 -0
  115. data/spec/svg_conform/requirements/processing_features_requirement_spec.rb +75 -0
  116. data/spec/svg_conform/requirements/svg_1_1_document_requirement_spec.rb +141 -0
  117. data/spec/svg_conform/requirements/text_as_path_requirement_spec.rb +9 -5
  118. data/spec/svg_conform/validation/css2_core_grammar_spec.rb +116 -0
  119. data/spec/svg_conform/validation/svg_1_1_document_conformance_spec.rb +144 -0
  120. data/spec/svg_conform/validation_context_source_plumbing_spec.rb +122 -0
  121. data/spec/svg_conform/validation_context_spec.rb +39 -0
  122. data/spec/svg_conform/validation_result_spec.rb +123 -0
  123. data/spec/svg_conform/yaml_io_spec.rb +41 -0
  124. data/svg_conform.gemspec +4 -2
  125. metadata +120 -6
@@ -41,8 +41,13 @@ module SvgConform
41
41
  class SaxValidationHandler < Leptris::XML::SAX::Document
42
42
  attr_reader :result, :context
43
43
 
44
- def initialize(profile)
44
+ def initialize(profile, source: nil, source_path: nil,
45
+ source_serialized: false)
45
46
  @profile = profile
47
+ @source = source
48
+ @source_path = source_path
49
+ @source_serialized = source_serialized
50
+ @source_validated = false
46
51
  @element_stack = [] # Track parent-child hierarchy
47
52
  @path_stack = [] # Current element path
48
53
  @position_counters = [] # Stack of sibling counters per level
@@ -65,6 +70,7 @@ module SvgConform
65
70
 
66
71
  # SAX Event: Document start
67
72
  def start_document
73
+ run_source_validation
68
74
  # No need to reset state - each validation has fresh requirement instances
69
75
  # Initialize root level counters
70
76
  @position_counters.push({})
@@ -124,6 +130,7 @@ module SvgConform
124
130
 
125
131
  # SAX Event: Document complete
126
132
  def end_document
133
+ run_source_validation
127
134
  # Run deferred validation
128
135
  @deferred_requirements.each do |req|
129
136
  req.validate_sax_complete(@context)
@@ -145,6 +152,7 @@ module SvgConform
145
152
 
146
153
  # Handle parse errors
147
154
  def add_parse_error(error)
155
+ run_source_validation
148
156
  @context.add_error(
149
157
  node: nil,
150
158
  message: "Parse error: #{error.message}",
@@ -160,6 +168,16 @@ module SvgConform
160
168
 
161
169
  private
162
170
 
171
+ # Source hooks run once, before any element dispatch
172
+ def run_source_validation
173
+ return if @source_validated
174
+
175
+ @source_validated = true
176
+ @requirements.each do |req|
177
+ req.validate_source(@source, @context)
178
+ end
179
+ end
180
+
163
181
  def create_incomplete_result
164
182
  # Return result even if parsing incomplete
165
183
  ValidationResult.new(nil, @profile, @context)
@@ -180,6 +198,9 @@ module SvgConform
180
198
  trackers[:structural_invalidity_tracker])
181
199
  context.instance_variable_set(:@reference_manifest,
182
200
  trackers[:reference_manifest])
201
+ context.instance_variable_set(:@source, @source)
202
+ context.instance_variable_set(:@source_path, @source_path)
203
+ context.instance_variable_set(:@source_serialized, @source_serialized)
183
204
  context.instance_variable_set(:@fixes, [])
184
205
  context.instance_variable_set(:@state_registry, {})
185
206
  context
@@ -0,0 +1,359 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "strscan"
4
+
5
+ module SvgConform
6
+ module Validation
7
+ # Syntax-only tokenizer and parser for the CSS 2 core grammar that SVG 1.1
8
+ # requires (https://www.w3.org/TR/2008/REC-CSS2-20080411/grammar.html).
9
+ #
10
+ # It checks tokens, block nesting, rulesets and declarations. It does not
11
+ # know property names, value semantics or rendering. Pure Ruby; no I/O.
12
+ module Css2CoreGrammar
13
+ Token = Struct.new(:type, :value, :line, :column)
14
+ Error = Struct.new(:message, :line, :column) do
15
+ def to_s
16
+ "#{message} (line #{line}, column #{column})"
17
+ end
18
+ end
19
+
20
+ # Outcome of a parse: +errors+ is empty when the input is valid.
21
+ class Result
22
+ attr_reader :errors
23
+
24
+ def initialize(errors)
25
+ @errors = errors.freeze
26
+ end
27
+
28
+ def valid?
29
+ errors.empty?
30
+ end
31
+ end
32
+
33
+ NONASCII = '[^\x00-\x7F]'
34
+ UNICODE = '\\\\[0-9a-fA-F]{1,6}(?:\r\n|[ \n\r\t\f])?'
35
+ ESCAPE = "(?:#{UNICODE}|\\\\[^\\n\\r\\f0-9a-fA-F])".freeze
36
+ NMSTART = "(?:[_a-zA-Z]|#{NONASCII}|#{ESCAPE})".freeze
37
+ NMCHAR = "(?:[_a-zA-Z0-9-]|#{NONASCII}|#{ESCAPE})".freeze
38
+ IDENT_SRC = "-?#{NMSTART}#{NMCHAR}*".freeze
39
+ NUM_SRC = '(?:[0-9]+|[0-9]*\.[0-9]+)'
40
+ NL = '(?:\n|\r\n|\r|\f)'
41
+ STRING_BODY = lambda do |quote|
42
+ "#{quote}(?:[^\\n\\r\\f\\\\#{quote}]|\\\\#{NL}|#{ESCAPE})*"
43
+ end
44
+
45
+ IDENT = /#{IDENT_SRC}/o
46
+ PATTERNS = [
47
+ [:s, /[ \t\r\n\f]+/],
48
+ [:comment, %r{/\*[^*]*\*+(?:[^/*][^*]*\*+)*/}],
49
+ [:cdo, /<!--/],
50
+ [:cdc, /-->/],
51
+ [:includes, /~=/],
52
+ [:dashmatch, /\|=/],
53
+ [:string, /#{STRING_BODY.call('"')}"|#{STRING_BODY.call("'")}'/],
54
+ [:bad_string, /#{STRING_BODY.call('"')}|#{STRING_BODY.call("'")}/],
55
+ [:hash, /\##{NMCHAR}+/o],
56
+ [:atkeyword, /@#{IDENT_SRC}/o],
57
+ [:uri, /url\([ \t\r\n\f]*(?:#{STRING_BODY.call('"')}"|#{STRING_BODY.call("'")}'|(?:[!#$%&*-~]|#{NONASCII}|#{ESCAPE})*)[ \t\r\n\f]*\)/io],
58
+ [:unicode_range, /u\+[0-9a-f?]{1,6}(?:-[0-9a-f]{1,6})?/i],
59
+ [:function, /#{IDENT_SRC}\(/o],
60
+ [:dimension, /#{NUM_SRC}#{IDENT_SRC}/o],
61
+ [:percentage, /#{NUM_SRC}%/],
62
+ [:number, /#{NUM_SRC}/],
63
+ [:ident, /#{IDENT_SRC}/o],
64
+ ].freeze
65
+
66
+ # Characters that are their own token types.
67
+ PUNCTUATION = %w[; { } ( ) [ ] :].freeze
68
+ ANY_TYPES = %i[ident number percentage dimension string delim uri hash
69
+ unicode_range includes dashmatch].freeze
70
+ SKIPPED_TOP_LEVEL = %i[s cdo cdc].freeze
71
+ CLOSERS = { "(" => ")", "[" => "]" }.freeze
72
+
73
+ module_function
74
+
75
+ # Tokenize CSS text. Comments are dropped; unterminated comments and
76
+ # strings surface as :bad_comment / :bad_string tokens.
77
+ # @return [Array<Token>]
78
+ def tokenize(text)
79
+ scanner = StringScanner.new(text.to_s)
80
+ tokens = []
81
+ line = 1
82
+ line_start = 0
83
+ until scanner.eos?
84
+ pos = scanner.pos
85
+ type, value = next_token(scanner)
86
+ column = pos - line_start + 1
87
+ tokens << Token.new(type, value, line, column) unless type == :comment
88
+ value.scan(/\r\n|[\n\r\f]/) { line += 1 }
89
+ nl = value.rindex(/[\n\r\f]/)
90
+ line_start = pos + nl + 1 if nl
91
+ end
92
+ tokens
93
+ end
94
+
95
+ # Parse a style sheet (style element content or external sheet).
96
+ # @return [Result]
97
+ def parse_stylesheet(text)
98
+ Parser.new(tokenize(text)).stylesheet
99
+ end
100
+
101
+ # Parse the content of a style attribute: a declaration list without
102
+ # braces.
103
+ # @return [Result]
104
+ def parse_declarations(text)
105
+ Parser.new(tokenize(text)).declaration_list
106
+ end
107
+
108
+ def next_token(scanner)
109
+ PATTERNS.each do |type, regex|
110
+ value = scanner.scan(regex)
111
+ return [type, value] if value
112
+ end
113
+ return [:bad_comment, scanner.scan(%r{/\*.*}m)] if scanner.check(%r{/\*})
114
+
115
+ char = scanner.getch
116
+ [PUNCTUATION.include?(char) ? char : :delim, char]
117
+ end
118
+ private_class_method :next_token
119
+
120
+ # Recursive-descent parser over the token list (CSS 2.1 Appendix G.1).
121
+ class Parser
122
+ def initialize(tokens)
123
+ @tokens = tokens
124
+ @pos = 0
125
+ @errors = []
126
+ end
127
+
128
+ def stylesheet
129
+ until eof?
130
+ token = peek
131
+ if SKIPPED_TOP_LEVEL.include?(token.type)
132
+ advance
133
+ else
134
+ statement
135
+ end
136
+ end
137
+ Result.new(@errors)
138
+ end
139
+
140
+ def declaration_list
141
+ skip_space
142
+ declarations_until(nil)
143
+ Result.new(@errors)
144
+ end
145
+
146
+ private
147
+
148
+ def eof?
149
+ @pos >= @tokens.length
150
+ end
151
+
152
+ def peek
153
+ @tokens[@pos]
154
+ end
155
+
156
+ def advance
157
+ token = @tokens[@pos]
158
+ @pos += 1
159
+ token
160
+ end
161
+
162
+ def skip_space
163
+ advance while !eof? && peek.type == :s
164
+ end
165
+
166
+ def type?(type)
167
+ !eof? && peek.type == type
168
+ end
169
+
170
+ def error(message, token = peek || @tokens.last)
171
+ @errors << Error.new(message, token&.line || 1, token&.column || 1)
172
+ end
173
+
174
+ def statement
175
+ if type?(:atkeyword)
176
+ at_rule
177
+ else
178
+ ruleset
179
+ end
180
+ end
181
+
182
+ def at_rule
183
+ advance
184
+ skip_space
185
+ until eof? || type?("{") || type?(";")
186
+ break unless any?
187
+ end
188
+ if type?("{")
189
+ block
190
+ elsif type?(";")
191
+ advance
192
+ else
193
+ error("at-rule is missing ';' or a block")
194
+ end
195
+ skip_space
196
+ end
197
+
198
+ # selector? '{' declarations '}'
199
+ def ruleset
200
+ start = peek
201
+ until eof? || type?("{")
202
+ unless any?
203
+ error("unexpected #{describe(peek)} in selector")
204
+ advance
205
+ end
206
+ end
207
+ return error("selector is missing a '{' block", start) if eof?
208
+
209
+ advance
210
+ skip_space
211
+ declarations_until("}")
212
+ if type?("}")
213
+ advance
214
+ else
215
+ error("ruleset is missing '}'", start)
216
+ end
217
+ skip_space
218
+ end
219
+
220
+ def declarations_until(closer)
221
+ loop do
222
+ skip_space
223
+ break if eof? || (closer && type?(closer))
224
+
225
+ if type?(";")
226
+ advance
227
+ next
228
+ end
229
+ declaration(closer)
230
+ skip_space
231
+ break if eof? || (closer && type?(closer))
232
+
233
+ if type?(";")
234
+ advance
235
+ else
236
+ error("expected ';' after declaration, found #{describe(peek)}")
237
+ recover(closer)
238
+ end
239
+ end
240
+ end
241
+
242
+ # property ':' S* value
243
+ def declaration(closer)
244
+ unless type?(:ident)
245
+ error("expected a property name, found #{describe(peek)}")
246
+ return recover(closer)
247
+ end
248
+ advance
249
+ skip_space
250
+ unless type?(":")
251
+ error("expected ':' after property name")
252
+ return recover(closer)
253
+ end
254
+ advance
255
+ skip_space
256
+ value(closer)
257
+ end
258
+
259
+ def value(closer)
260
+ first = peek
261
+ count = 0
262
+ until eof? || type?(";") || (closer && type?(closer))
263
+ if type?("{")
264
+ block
265
+ elsif type?(:atkeyword)
266
+ advance
267
+ skip_space
268
+ elsif !any?
269
+ error("unexpected #{describe(peek)} in declaration value")
270
+ advance
271
+ end
272
+ count += 1
273
+ end
274
+ error("declaration has no value", first) if count.zero?
275
+ end
276
+
277
+ # Skip to the next ';' or the closing delimiter.
278
+ def recover(closer)
279
+ advance until eof? || type?(";") || (closer && type?(closer))
280
+ end
281
+
282
+ # '{' S* [any | block | ATKEYWORD S* | ';' S*]* '}' S*
283
+ def block
284
+ open = advance
285
+ skip_space
286
+ loop do
287
+ if eof?
288
+ error("block opened here is missing '}'", open)
289
+ return
290
+ end
291
+ break if type?("}")
292
+
293
+ if type?("{")
294
+ block
295
+ elsif type?(:atkeyword) || type?(";")
296
+ advance
297
+ skip_space
298
+ elsif !any?
299
+ error("unexpected #{describe(peek)} in block")
300
+ advance
301
+ end
302
+ end
303
+ advance
304
+ skip_space
305
+ end
306
+
307
+ # Consumes one `any` production (plus trailing space). Returns false
308
+ # without consuming when the next token is not an `any` start.
309
+ def any?
310
+ return false if eof?
311
+
312
+ token = peek
313
+ if ANY_TYPES.include?(token.type) || token.type == ":"
314
+ advance
315
+ elsif token.type == :function
316
+ advance
317
+ skip_space
318
+ group(")", token)
319
+ elsif CLOSERS.key?(token.type)
320
+ advance
321
+ skip_space
322
+ group(CLOSERS[token.type], token)
323
+ elsif token.type == :bad_string
324
+ error("unterminated string", token)
325
+ advance
326
+ elsif token.type == :bad_comment
327
+ error("unterminated comment", token)
328
+ advance
329
+ else
330
+ return false
331
+ end
332
+ skip_space
333
+ true
334
+ end
335
+
336
+ def group(closer, opener)
337
+ loop do
338
+ if eof?
339
+ error("'#{opener.value}' is missing '#{closer}'", opener)
340
+ return
341
+ end
342
+ break if type?(closer)
343
+ next if any?
344
+
345
+ error("unexpected #{describe(peek)} inside '#{opener.value}'")
346
+ advance
347
+ end
348
+ advance
349
+ end
350
+
351
+ def describe(token)
352
+ return "end of input" unless token
353
+
354
+ "'#{token.value.to_s.strip[0, 20]}'"
355
+ end
356
+ end
357
+ end
358
+ end
359
+ end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "css2_core_grammar"
4
+
5
+ module SvgConform
6
+ module Validation
7
+ # Finds the url() and @import references in CSS by tokenizing it, so that
8
+ # identifier escapes (+u\72l(+, +@\69mport+) are decoded before the name is
9
+ # compared and url-like text inside strings or comments is ignored.
10
+ module CssReferences
11
+ Reference = Struct.new(:url, :import)
12
+ # A declared property (name) or a @keyframes rule (keyframes: true)
13
+ Feature = Struct.new(:name, :keyframes) do
14
+ def description
15
+ keyframes ? "CSS rule '@#{name}'" : "CSS property '#{name}'"
16
+ end
17
+ end
18
+
19
+ ESCAPE = /\\(?:(?<hex>\h{1,6})(?:\r\n|[ \t\r\n\f])?|(?<char>[^\n\r\f]))/m
20
+ LINE_CONTINUATION = /\\(?:\r\n|[\n\r\f])/
21
+
22
+ module_function
23
+
24
+ # @return [Array<Reference>] in source order; +import+ is true for
25
+ # references that are the target of an @import rule
26
+ def extract(css)
27
+ tokens = Css2CoreGrammar.tokenize(css)
28
+ references = []
29
+ import_pending = false
30
+ index = 0
31
+ while index < tokens.size
32
+ token = tokens[index]
33
+ case token.type
34
+ when :s then nil
35
+ when :atkeyword
36
+ import_pending = decode(token.value).casecmp?("@import")
37
+ when :string
38
+ references << Reference.new(unquote(token.value), true) if import_pending
39
+ import_pending = false
40
+ when :uri
41
+ references << Reference.new(uri_value(token.value), import_pending)
42
+ import_pending = false
43
+ when :function
44
+ if decode(token.value.chomp("(")).casecmp?("url")
45
+ url, index = function_url(tokens, index + 1)
46
+ references << Reference.new(url, import_pending)
47
+ end
48
+ import_pending = false
49
+ else
50
+ import_pending = false
51
+ end
52
+ index += 1
53
+ end
54
+ references.reject { |reference| reference.url.nil? || reference.url.empty? }
55
+ end
56
+
57
+ # Property names declared in +css+ (a style attribute or style sheet),
58
+ # plus @keyframes rules, with escapes decoded.
59
+ # @return [Array<Feature>]
60
+ def declarations(css)
61
+ tokens = Css2CoreGrammar.tokenize(css).reject { |token| token.type == :s }
62
+ tokens.each_with_index.filter_map do |token, index|
63
+ if token.type == :atkeyword
64
+ keyframes_feature(token)
65
+ elsif property_at?(tokens, index)
66
+ Feature.new(decode(token.value), false)
67
+ end
68
+ end
69
+ end
70
+
71
+ # An identifier followed by ":" that starts a declaration
72
+ def property_at?(tokens, index)
73
+ return false unless tokens[index].type == :ident && tokens[index + 1]&.type == ":"
74
+
75
+ index.zero? || ["{", ";"].include?(tokens[index - 1].value)
76
+ end
77
+
78
+ def keyframes_feature(token)
79
+ name = decode(token.value).delete_prefix("@")
80
+ return unless name.downcase.sub(/\A-[a-z]+-/, "") == "keyframes"
81
+
82
+ Feature.new(name, true)
83
+ end
84
+
85
+ def decode(text)
86
+ text.gsub(LINE_CONTINUATION, "").gsub(ESCAPE) do
87
+ match = Regexp.last_match
88
+ match[:hex] ? codepoint(match[:hex].hex) : match[:char]
89
+ end
90
+ end
91
+
92
+ def codepoint(number)
93
+ return "�" if number.zero? || number > 0x10FFFF || number.between?(0xD800, 0xDFFF)
94
+
95
+ [number].pack("U")
96
+ end
97
+
98
+ def unquote(string)
99
+ decode(string[1..].chomp(string[0]))
100
+ end
101
+
102
+ def uri_value(token)
103
+ inner = token.sub(/\Aurl\(/i, "").delete_suffix(")").strip
104
+ inner.start_with?('"', "'") ? unquote(inner) : decode(inner)
105
+ end
106
+
107
+ # Tokens after an escaped "url(" up to the closing parenthesis.
108
+ # @return [Array(String, Integer)] the url and the index of its last token
109
+ def function_url(tokens, index)
110
+ index += 1 while tokens[index]&.type == :s
111
+ return [unquote(tokens[index].value), index] if tokens[index]&.type == :string
112
+
113
+ parts = []
114
+ while (token = tokens[index]) && token.value != ")"
115
+ parts << token.value unless token.type == :s
116
+ index += 1
117
+ end
118
+ [decode(parts.join), index]
119
+ end
120
+ end
121
+ end
122
+ end
@@ -18,13 +18,38 @@ module SvgConform
18
18
  end
19
19
 
20
20
  # Add an error to the context
21
+ #
22
+ # A declared severity of :warning is a quality advisory (see
23
+ # TextAsPathRequirement) and must surface as a warning, not an error:
24
+ # otherwise schema-valid documents are reported as corrupt by consumers
25
+ # that log result.errors (metanorma, metanorma-pdfa#106). Other
26
+ # severities keep their existing routing: :validity_error stays out of
27
+ # the main error bucket, and :info remains in errors because the style
28
+ # remediation flow consumes it there.
21
29
  def add_error(node:, message:, rule: nil, requirement: nil,
22
30
  requirement_id: nil, severity: nil, fix: nil, data: {})
23
31
  # Support both old rule system and new requirements system
24
32
  rule_or_requirement = requirement || rule
25
33
 
26
- error = ::SvgConform::Errors::ValidationIssue.new(
27
- type: :error,
34
+ if severity == :warning
35
+ warning = ::SvgConform::Errors::ValidationIssue.new(
36
+ type: :warning,
37
+ rule: rule_or_requirement,
38
+ node: node,
39
+ message: message,
40
+ fix: fix,
41
+ data: data,
42
+ requirement_id: requirement_id,
43
+ severity: severity,
44
+ )
45
+ @warnings << warning
46
+ return warning
47
+ end
48
+
49
+ type, bucket = severity_bucket(severity)
50
+
51
+ issue = ::SvgConform::Errors::ValidationIssue.new(
52
+ type: type,
28
53
  rule: rule_or_requirement,
29
54
  node: node,
30
55
  message: message,
@@ -34,14 +59,18 @@ module SvgConform
34
59
  severity: severity,
35
60
  )
36
61
 
37
- # Handle special severity types
38
- if severity == :validity_error
39
- @validity_errors << error
62
+ bucket << issue
63
+ issue
64
+ end
65
+
66
+ # Map a declared severity to the issue type and tracker bucket
67
+ def severity_bucket(severity)
68
+ case severity
69
+ when :validity_error
70
+ [:error, @validity_errors]
40
71
  else
41
- @errors << error
72
+ [:error, @errors]
42
73
  end
43
-
44
- error
45
74
  end
46
75
 
47
76
  # Add a warning to the context
@@ -58,7 +87,8 @@ module SvgConform
58
87
  end
59
88
 
60
89
  # Add an informational notice to the context
61
- def add_notice(rule:, node:, message:, fix: nil, data: {}, type: :info)
90
+ def add_notice(rule:, node:, message:, fix: nil, data: {}, type: :info,
91
+ requirement_id: nil)
62
92
  notice = ::SvgConform::Errors::ValidationIssue.new(
63
93
  type: type,
64
94
  rule: rule,
@@ -66,6 +96,7 @@ module SvgConform
66
96
  message: message,
67
97
  fix: fix,
68
98
  data: data,
99
+ requirement_id: requirement_id,
69
100
  )
70
101
  @infos << notice
71
102
  notice