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.
- checksums.yaml +4 -4
- data/.gitattributes +1 -0
- data/.github/workflows/rake.yml +4 -1
- data/Gemfile +6 -3
- data/Gemfile.lock +211 -0
- data/TODO.issue-100/00-overview.md +135 -0
- data/TODO.issue-100/01-build-lens-core-v1.md +92 -0
- data/TODO.issue-100/02-adopt-lens-core-in-svg.md +123 -0
- data/TODO.issue-100/03-ship-svg-1-1-full-static.md +138 -0
- data/TODO.issue-100/04-ship-svg-2-secure-static.md +131 -0
- data/TODO.issue-100/05-freeze-candidate-and-handoff.md +108 -0
- data/TODO.issue-100/90-progress.md +65 -0
- data/config/profiles/metanorma.yml +4 -4
- data/config/profiles/svg_1_1_full_static.yml +69 -0
- data/config/profiles/svg_2_secure_static.yml +55 -0
- data/config/requirements/processing_features_static.yml +29 -0
- data/data/svg_1_1/manifest.yml +7 -0
- data/data/svg_1_1/svg11-flat-20110816.dtd +5664 -0
- data/docs/api_reference.adoc +19 -1
- data/docs/cli_guide.adoc +35 -45
- data/docs/diagnostics.adoc +73 -0
- data/docs/lens_core_handoff.adoc +84 -0
- data/docs/profiles.adoc +94 -1
- data/lib/svg_conform/cli.rb +3 -4
- data/lib/svg_conform/commands/check.rb +11 -60
- data/lib/svg_conform/conformance_report.rb +79 -1
- data/lib/svg_conform/diagnostics/lens_core_adapter.rb +200 -0
- data/lib/svg_conform/document.rb +12 -1
- data/lib/svg_conform/external_checkers/svgcheck/parser.rb +1 -3
- data/lib/svg_conform/node_helpers.rb +1 -1
- data/lib/svg_conform/profile.rb +11 -2
- data/lib/svg_conform/quality_metrics/complexity_calculator.rb +3 -3
- data/lib/svg_conform/quality_metrics/configuration.rb +4 -2
- data/lib/svg_conform/references/base_reference.rb +153 -0
- data/lib/svg_conform/references/reference_manifest.rb +1 -2
- data/lib/svg_conform/report_comparator.rb +4 -4
- data/lib/svg_conform/requirements/base_requirement.rb +6 -0
- data/lib/svg_conform/requirements/external_references_requirement.rb +234 -0
- data/lib/svg_conform/requirements/link_validation_requirement.rb +2 -0
- data/lib/svg_conform/requirements/processing_features_requirement.rb +175 -0
- data/lib/svg_conform/requirements/svg_1_1_document_requirement.rb +213 -0
- data/lib/svg_conform/requirements.rb +6 -0
- data/lib/svg_conform/sax_document.rb +8 -4
- data/lib/svg_conform/sax_validation_handler.rb +22 -1
- data/lib/svg_conform/validation/css2_core_grammar.rb +359 -0
- data/lib/svg_conform/validation/css_references.rb +122 -0
- data/lib/svg_conform/validation/error_tracker.rb +40 -9
- data/lib/svg_conform/validation/svg_1_1_document_conformance.rb +154 -0
- data/lib/svg_conform/validation_context.rb +25 -5
- data/lib/svg_conform/validation_result.rb +91 -16
- data/lib/svg_conform/version.rb +1 -1
- data/lib/svg_conform/yaml_io.rb +20 -0
- data/lib/svg_conform.rb +25 -0
- data/spec/fixtures/diagnostics/external-reference-info.svg +6 -0
- data/spec/fixtures/plantuml/README.adoc +11 -0
- data/spec/fixtures/plantuml/unit-type.svg +1 -0
- data/spec/fixtures/profiles/svg_1_1_full_static/invalid/animation.svg +4 -0
- data/spec/fixtures/profiles/svg_1_1_full_static/invalid/css-grammar.svg +4 -0
- data/spec/fixtures/profiles/svg_1_1_full_static/invalid/dtd-attribute.svg +4 -0
- data/spec/fixtures/profiles/svg_1_1_full_static/invalid/dtd-content-model.svg +4 -0
- data/spec/fixtures/profiles/svg_1_1_full_static/invalid/interaction.svg +4 -0
- data/spec/fixtures/profiles/svg_1_1_full_static/invalid/malformed-xml.svg +4 -0
- data/spec/fixtures/profiles/svg_1_1_full_static/invalid/script.svg +5 -0
- data/spec/fixtures/profiles/svg_1_1_full_static/invalid/wrong-namespace.svg +4 -0
- data/spec/fixtures/profiles/svg_1_1_full_static/invalid/wrong-version.svg +4 -0
- data/spec/fixtures/profiles/svg_1_1_full_static/invalid/xml-stylesheet.svg +5 -0
- data/spec/fixtures/profiles/svg_1_1_full_static/valid/css.svg +5 -0
- data/spec/fixtures/profiles/svg_1_1_full_static/valid/foreign-metadata.svg +6 -0
- data/spec/fixtures/profiles/svg_1_1_full_static/valid/minimal-full.svg +5 -0
- data/spec/fixtures/profiles/svg_1_1_full_static/valid/xml-stylesheet.svg +5 -0
- data/spec/fixtures/profiles/svg_2_secure_static/invalid/animation.svg +4 -0
- data/spec/fixtures/profiles/svg_2_secure_static/invalid/css-import.svg +4 -0
- data/spec/fixtures/profiles/svg_2_secure_static/invalid/css-url.svg +4 -0
- data/spec/fixtures/profiles/svg_2_secure_static/invalid/data-bad-png.svg +4 -0
- data/spec/fixtures/profiles/svg_2_secure_static/invalid/data-empty-png.svg +4 -0
- data/spec/fixtures/profiles/svg_2_secure_static/invalid/data-malformed-base64.svg +4 -0
- data/spec/fixtures/profiles/svg_2_secure_static/invalid/data-malformed-percent.svg +4 -0
- data/spec/fixtures/profiles/svg_2_secure_static/invalid/data-unsupported-type.svg +4 -0
- data/spec/fixtures/profiles/svg_2_secure_static/invalid/file-like-fragment.svg +4 -0
- data/spec/fixtures/profiles/svg_2_secure_static/invalid/http.svg +4 -0
- data/spec/fixtures/profiles/svg_2_secure_static/invalid/https.svg +4 -0
- data/spec/fixtures/profiles/svg_2_secure_static/invalid/interaction.svg +4 -0
- data/spec/fixtures/profiles/svg_2_secure_static/invalid/protocol-relative.svg +4 -0
- data/spec/fixtures/profiles/svg_2_secure_static/invalid/relative-file-fragment.svg +4 -0
- data/spec/fixtures/profiles/svg_2_secure_static/invalid/relative-file.svg +4 -0
- data/spec/fixtures/profiles/svg_2_secure_static/invalid/script.svg +4 -0
- data/spec/fixtures/profiles/svg_2_secure_static/invalid/svg-data-excessive-nesting.svg +4 -0
- data/spec/fixtures/profiles/svg_2_secure_static/invalid/svg-data-malformed-xml.svg +4 -0
- data/spec/fixtures/profiles/svg_2_secure_static/invalid/svg-data-nested-external.svg +4 -0
- data/spec/fixtures/profiles/svg_2_secure_static/invalid/urn.svg +4 -0
- data/spec/fixtures/profiles/svg_2_secure_static/invalid/xml-stylesheet.svg +5 -0
- data/spec/fixtures/profiles/svg_2_secure_static/valid/jpeg-base64.svg +4 -0
- data/spec/fixtures/profiles/svg_2_secure_static/valid/jpeg-percent.svg +4 -0
- data/spec/fixtures/profiles/svg_2_secure_static/valid/png-base64.svg +4 -0
- data/spec/fixtures/profiles/svg_2_secure_static/valid/png-percent.svg +4 -0
- data/spec/fixtures/profiles/svg_2_secure_static/valid/same-document-fragment.svg +4 -0
- data/spec/fixtures/profiles/svg_2_secure_static/valid/svg-base64.svg +4 -0
- data/spec/fixtures/profiles/svg_2_secure_static/valid/svg-percent.svg +4 -0
- data/spec/fixtures/quality_metrics/aliases.yml +7 -0
- data/spec/svg_conform/commands/check_command_spec.rb +22 -2
- data/spec/svg_conform/commands/profiles_command_spec.rb +18 -0
- data/spec/svg_conform/conformance_report_spec.rb +90 -0
- data/spec/svg_conform/diagnostics/lens_core_adapter_spec.rb +136 -0
- data/spec/svg_conform/gemspec_spec.rb +34 -0
- data/spec/svg_conform/profiles/metanorma_profile_spec.rb +48 -0
- data/spec/svg_conform/profiles/svg_1_1_full_static_profile_spec.rb +146 -0
- data/spec/svg_conform/profiles/svg_2_secure_static_profile_spec.rb +150 -0
- data/spec/svg_conform/profiles_plantuml_spec.rb +81 -0
- data/spec/svg_conform/quality_metrics/configuration_spec.rb +82 -0
- data/spec/svg_conform/references/data_uri_reference_spec.rb +97 -0
- data/spec/svg_conform/references/integration_spec.rb +30 -0
- data/spec/svg_conform/references/reference_classifier_spec.rb +17 -0
- data/spec/svg_conform/requirements/external_references_requirement_spec.rb +109 -0
- data/spec/svg_conform/requirements/link_validation_requirement_spec.rb +16 -0
- data/spec/svg_conform/requirements/processing_features_requirement_spec.rb +75 -0
- data/spec/svg_conform/requirements/svg_1_1_document_requirement_spec.rb +141 -0
- data/spec/svg_conform/requirements/text_as_path_requirement_spec.rb +9 -5
- data/spec/svg_conform/validation/css2_core_grammar_spec.rb +116 -0
- data/spec/svg_conform/validation/svg_1_1_document_conformance_spec.rb +144 -0
- data/spec/svg_conform/validation_context_source_plumbing_spec.rb +122 -0
- data/spec/svg_conform/validation_context_spec.rb +39 -0
- data/spec/svg_conform/validation_result_spec.rb +123 -0
- data/spec/svg_conform/yaml_io_spec.rb +41 -0
- data/svg_conform.gemspec +4 -2
- 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
|
-
|
|
27
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
|
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
|