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
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base_requirement"
|
|
4
|
+
require_relative "../references/reference_classifier"
|
|
5
|
+
require_relative "../validation/css_references"
|
|
6
|
+
|
|
7
|
+
module SvgConform
|
|
8
|
+
module Requirements
|
|
9
|
+
# Rejects references that need another document, a local file or the
|
|
10
|
+
# network. Same-document fragments and self-contained image data URLs pass.
|
|
11
|
+
#
|
|
12
|
+
# Every reference goes through References::ReferenceClassifier; data URL
|
|
13
|
+
# decoding lives on References::DataUriReference. DOM and SAX validation
|
|
14
|
+
# share the same finding logic (#attribute_findings, #css_findings,
|
|
15
|
+
# #source_findings).
|
|
16
|
+
class ExternalReferencesRequirement < BaseRequirement
|
|
17
|
+
ALLOWED_DATA_TYPES = %w[image/png image/jpeg image/svg+xml].freeze
|
|
18
|
+
URL_ATTRIBUTES = %w[fill stroke filter clip-path mask marker-start
|
|
19
|
+
marker-mid marker-end cursor].freeze
|
|
20
|
+
SVG_NAMESPACE_ROOT = "svg"
|
|
21
|
+
STYLESHEET_PI = /<\?xml-stylesheet\s(?<body>.*?)\?>/mi
|
|
22
|
+
EXTERNAL_DOCTYPE = /<!ENTITY|<!DOCTYPE[^>\[]*\b(?:SYSTEM|PUBLIC)\b/i
|
|
23
|
+
|
|
24
|
+
attribute :type, :string,
|
|
25
|
+
default: -> { "ExternalReferencesRequirement" }
|
|
26
|
+
attribute :max_data_uri_depth, :integer, default: -> { 3 }
|
|
27
|
+
|
|
28
|
+
yaml do
|
|
29
|
+
map "id", to: :id
|
|
30
|
+
map "description", to: :description
|
|
31
|
+
map "type", to: :type
|
|
32
|
+
map "citation_uri", to: :citation_uri
|
|
33
|
+
map "citation_label", to: :citation_label
|
|
34
|
+
map "max_data_uri_depth", to: :max_data_uri_depth
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
class State
|
|
38
|
+
attr_accessor :style_elements
|
|
39
|
+
|
|
40
|
+
def initialize
|
|
41
|
+
@style_elements = []
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def needs_deferred_validation?
|
|
46
|
+
true
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def check(node, context)
|
|
50
|
+
return unless element?(node)
|
|
51
|
+
|
|
52
|
+
attrs = (node.attributes || []).to_h { |a| [a.name.to_s, a.value.to_s] }
|
|
53
|
+
report_all(node, context, attribute_findings(node.name, attrs))
|
|
54
|
+
return unless local_name(node.name) == "style"
|
|
55
|
+
|
|
56
|
+
report_all(node, context, css_findings(node.text.to_s))
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def collect_sax_data(element, context)
|
|
60
|
+
attrs = element.raw_attributes.to_h { |k, v| [k.to_s, v.to_s] }
|
|
61
|
+
report_all(element, context, attribute_findings(element.name, attrs))
|
|
62
|
+
return unless local_name(element.name) == "style"
|
|
63
|
+
|
|
64
|
+
context.state_for(self).style_elements << element
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def validate_sax_complete(context)
|
|
68
|
+
context.state_for(self).style_elements.each do |element|
|
|
69
|
+
report_all(element, context, css_findings(element.text_content.to_s))
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def validate_source(source, context)
|
|
74
|
+
return if source.nil?
|
|
75
|
+
|
|
76
|
+
report_all(nil, context, source_findings(source.to_s))
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Reference findings for one element's attributes: [{attr:, value:, reason:}]
|
|
80
|
+
def attribute_findings(element_name, attrs, depth: 0, seen: [])
|
|
81
|
+
findings = []
|
|
82
|
+
attrs.each do |name, value|
|
|
83
|
+
local = local_name(name)
|
|
84
|
+
if local == "href"
|
|
85
|
+
findings.concat(reference_findings(value, element_name, local,
|
|
86
|
+
depth, seen))
|
|
87
|
+
elsif local == "style"
|
|
88
|
+
findings.concat(css_findings(value, depth: depth, seen: seen,
|
|
89
|
+
element_name: element_name))
|
|
90
|
+
elsif URL_ATTRIBUTES.include?(local)
|
|
91
|
+
findings.concat(css_findings(value, depth: depth, seen: seen,
|
|
92
|
+
element_name: element_name,
|
|
93
|
+
attribute_name: name,
|
|
94
|
+
urls_only: true))
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
findings
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def css_findings(css, depth: 0, seen: [], element_name: "style",
|
|
101
|
+
attribute_name: "style", urls_only: false)
|
|
102
|
+
Validation::CssReferences.extract(css).flat_map do |reference|
|
|
103
|
+
next [] if urls_only && reference.import
|
|
104
|
+
|
|
105
|
+
attr = reference.import ? "@import" : "url()"
|
|
106
|
+
reference_findings(reference.url.strip, element_name,
|
|
107
|
+
"#{attribute_name} #{attr}", depth, seen)
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def source_findings(source)
|
|
112
|
+
source.to_enum(:scan, STYLESHEET_PI).flat_map do
|
|
113
|
+
body = Regexp.last_match[:body]
|
|
114
|
+
href = body[/\bhref\s*=\s*(?:"([^"]*)"|'([^']*)')/, 1] ||
|
|
115
|
+
body[/\bhref\s*=\s*'([^']*)'/, 1]
|
|
116
|
+
next [] if href.nil? || href.empty?
|
|
117
|
+
|
|
118
|
+
reference_findings(href, "xml-stylesheet", "href", 0, [])
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
private
|
|
123
|
+
|
|
124
|
+
def reference_findings(value, element_name, attribute_name, depth, seen)
|
|
125
|
+
reference = References::ReferenceClassifier.classify(
|
|
126
|
+
value, element_name: element_name, attribute_name: attribute_name
|
|
127
|
+
)
|
|
128
|
+
return [] unless reference
|
|
129
|
+
|
|
130
|
+
reason = rejection_reason(reference, depth, seen)
|
|
131
|
+
return [] unless reason
|
|
132
|
+
|
|
133
|
+
[{ attr: attribute_name, value: value,
|
|
134
|
+
type: reference.class.name.split("::").last, reason: reason }]
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def rejection_reason(reference, depth, seen)
|
|
138
|
+
case reference
|
|
139
|
+
when References::InternalFragmentReference then nil
|
|
140
|
+
when References::DataUriReference then data_uri_reason(reference, depth,
|
|
141
|
+
seen)
|
|
142
|
+
when References::UrnReference then "URN requires an external resource"
|
|
143
|
+
when References::ExternalUrlReference
|
|
144
|
+
"network resource is not allowed"
|
|
145
|
+
else "reference requires another document or local file"
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def data_uri_reason(reference, depth, seen)
|
|
150
|
+
type = reference.normalized_media_type
|
|
151
|
+
unless ALLOWED_DATA_TYPES.include?(type)
|
|
152
|
+
return "unsupported data URL media type '#{type}'"
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
return reference.decode_error if reference.decode_error
|
|
156
|
+
|
|
157
|
+
if type == "image/svg+xml"
|
|
158
|
+
svg_data_reason(reference.payload, depth, seen)
|
|
159
|
+
elsif (error = reference.raster_error)
|
|
160
|
+
"invalid #{type} payload: #{error}"
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def svg_data_reason(payload, depth, seen)
|
|
165
|
+
if depth >= max_data_uri_depth
|
|
166
|
+
return "SVG data URL nesting exceeds #{max_data_uri_depth}"
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
digest = payload.hash
|
|
170
|
+
return "SVG data URL contains a reference cycle" if seen.include?(digest)
|
|
171
|
+
|
|
172
|
+
text = payload.dup.force_encoding(Encoding::UTF_8)
|
|
173
|
+
return "SVG data URL is not valid UTF-8" unless text.valid_encoding?
|
|
174
|
+
if text.match?(EXTERNAL_DOCTYPE)
|
|
175
|
+
return "SVG data URL declares external entities"
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
nested = nested_findings(text, depth + 1, seen + [digest])
|
|
179
|
+
return nested if nested.is_a?(String)
|
|
180
|
+
|
|
181
|
+
first = nested.first
|
|
182
|
+
first && "nested #{first[:attr]} '#{truncate(first[:value])}': #{first[:reason]}"
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Returns findings, or a String reason when the SVG is not usable
|
|
186
|
+
def nested_findings(text, depth, seen)
|
|
187
|
+
document = Document.from_content(text)
|
|
188
|
+
root = document.root
|
|
189
|
+
unless root && local_name(root.name) == SVG_NAMESPACE_ROOT
|
|
190
|
+
return "SVG data URL does not contain an svg document"
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
findings = source_findings(text)
|
|
194
|
+
document.traverse do |node|
|
|
195
|
+
next unless element?(node)
|
|
196
|
+
|
|
197
|
+
attrs = (node.attributes || []).to_h { |a| [a.name.to_s, a.value.to_s] }
|
|
198
|
+
findings.concat(attribute_findings(node.name, attrs, depth: depth,
|
|
199
|
+
seen: seen))
|
|
200
|
+
if local_name(node.name) == "style"
|
|
201
|
+
findings.concat(css_findings(node.text.to_s, depth: depth,
|
|
202
|
+
seen: seen))
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
findings
|
|
206
|
+
rescue StandardError
|
|
207
|
+
"SVG data URL is not well-formed XML"
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def report_all(node, context, findings)
|
|
211
|
+
findings.each do |finding|
|
|
212
|
+
context.add_error(
|
|
213
|
+
requirement_id: id,
|
|
214
|
+
message: "External reference not allowed in #{finding[:attr]}: " \
|
|
215
|
+
"'#{truncate(finding[:value])}' (#{finding[:reason]})",
|
|
216
|
+
node: node,
|
|
217
|
+
severity: :error,
|
|
218
|
+
data: { citation: citation_uri, attribute: finding[:attr],
|
|
219
|
+
reference_type: finding[:type],
|
|
220
|
+
reason: finding[:reason] },
|
|
221
|
+
)
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def truncate(value)
|
|
226
|
+
value.length > 60 ? "#{value[0, 57]}..." : value
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def local_name(name)
|
|
230
|
+
name.to_s.split(":").last
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
end
|
|
@@ -30,6 +30,7 @@ module SvgConform
|
|
|
30
30
|
context.add_external_reference_notice(
|
|
31
31
|
node: node,
|
|
32
32
|
reference: reference,
|
|
33
|
+
requirement_id: id,
|
|
33
34
|
)
|
|
34
35
|
end
|
|
35
36
|
|
|
@@ -72,6 +73,7 @@ module SvgConform
|
|
|
72
73
|
context.add_external_reference_notice(
|
|
73
74
|
node: element,
|
|
74
75
|
reference: reference,
|
|
76
|
+
requirement_id: id,
|
|
75
77
|
)
|
|
76
78
|
end
|
|
77
79
|
end
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base_requirement"
|
|
4
|
+
require_relative "../validation/css_references"
|
|
5
|
+
|
|
6
|
+
module SvgConform
|
|
7
|
+
module Requirements
|
|
8
|
+
# Rejects processing features that a static profile does not allow.
|
|
9
|
+
#
|
|
10
|
+
# Each configured category (for example script, animation, interaction)
|
|
11
|
+
# carries its own requirement ID and normative citation, so a profile can
|
|
12
|
+
# report them as separate rules. DOM and SAX validation share #check_names.
|
|
13
|
+
class ProcessingFeaturesRequirement < BaseRequirement
|
|
14
|
+
# One rejected feature group
|
|
15
|
+
class Category < Lutaml::Model::Serializable
|
|
16
|
+
attribute :name, :string
|
|
17
|
+
attribute :id, :string
|
|
18
|
+
attribute :citation, :string
|
|
19
|
+
attribute :description, :string
|
|
20
|
+
attribute :elements, :string, collection: true, default: -> { [] }
|
|
21
|
+
attribute :attributes, :string, collection: true, default: -> { [] }
|
|
22
|
+
attribute :attribute_patterns, :string, collection: true,
|
|
23
|
+
default: -> { [] }
|
|
24
|
+
|
|
25
|
+
yaml do
|
|
26
|
+
map "name", to: :name
|
|
27
|
+
map "id", to: :id
|
|
28
|
+
map "citation", to: :citation
|
|
29
|
+
map "description", to: :description
|
|
30
|
+
map "elements", to: :elements
|
|
31
|
+
map "attributes", to: :attributes
|
|
32
|
+
map "attribute_patterns", to: :attribute_patterns
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def rejects_element?(local_name)
|
|
36
|
+
elements.include?(local_name)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def rejects_attribute?(local_name)
|
|
40
|
+
attributes.include?(local_name) || matches_pattern?(local_name)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# CSS property names are checked against the attribute patterns only:
|
|
44
|
+
# the listed attribute names (cursor, begin, ...) are SVG attributes,
|
|
45
|
+
# not CSS declarations. Vendor prefixes (-webkit-) are ignored.
|
|
46
|
+
def rejects_css_property?(property)
|
|
47
|
+
matches_pattern?(property.downcase.sub(/\A-[a-z]+-/, ""))
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# @keyframes only exists to drive CSS animation.
|
|
51
|
+
def rejects_keyframes?
|
|
52
|
+
rejects_css_property?("animation-name")
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def matches_pattern?(name)
|
|
58
|
+
attribute_patterns.any? { |pattern| Regexp.new(pattern).match?(name) }
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
attribute :type, :string,
|
|
63
|
+
default: -> { "ProcessingFeaturesRequirement" }
|
|
64
|
+
attribute :categories, Category, collection: true, default: -> { [] }
|
|
65
|
+
|
|
66
|
+
yaml do
|
|
67
|
+
map "id", to: :id
|
|
68
|
+
map "description", to: :description
|
|
69
|
+
map "type", to: :type
|
|
70
|
+
map "categories", to: :categories
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def needs_deferred_validation?
|
|
74
|
+
true
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def check(node, context)
|
|
78
|
+
return unless element?(node)
|
|
79
|
+
|
|
80
|
+
attrs = (node.attributes || []).to_h { |attr| [attr.name.to_s, attr.value.to_s] }
|
|
81
|
+
check_names(node, local_name(node.name), attrs.keys, context)
|
|
82
|
+
check_css(node, attrs["style"], context)
|
|
83
|
+
check_css(node, node.text.to_s, context) if style_element?(node.name)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def collect_sax_data(element, context)
|
|
87
|
+
names = element.attributes.map { |attr| attr.name.to_s }
|
|
88
|
+
check_names(element, local_name(element.name), names, context)
|
|
89
|
+
check_css(element, element.raw_attributes["style"]&.to_s, context)
|
|
90
|
+
context.state_for(self).style_elements << element if style_element?(element.name)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def validate_sax_complete(context)
|
|
94
|
+
context.state_for(self).style_elements.each do |element|
|
|
95
|
+
check_css(element, element.text_content.to_s, context)
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Style elements seen while streaming; their text is only complete at the end
|
|
100
|
+
class State
|
|
101
|
+
attr_reader :style_elements
|
|
102
|
+
|
|
103
|
+
def initialize
|
|
104
|
+
@style_elements = []
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
private
|
|
109
|
+
|
|
110
|
+
def style_element?(name)
|
|
111
|
+
local_name(name) == "style"
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Declarations in a style attribute or style element content. Tokenized,
|
|
115
|
+
# so escaped property names (\61nimation) are decoded and text inside
|
|
116
|
+
# strings or comments is ignored.
|
|
117
|
+
def check_css(node, css, context)
|
|
118
|
+
return if css.nil? || css.empty?
|
|
119
|
+
|
|
120
|
+
Validation::CssReferences.declarations(css).each do |feature|
|
|
121
|
+
categories.each do |category|
|
|
122
|
+
next unless css_rejected?(category, feature)
|
|
123
|
+
|
|
124
|
+
report(context, node, category,
|
|
125
|
+
"#{category.name} feature: #{feature.description} " \
|
|
126
|
+
"is not allowed in static processing")
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def css_rejected?(category, feature)
|
|
132
|
+
if feature.keyframes
|
|
133
|
+
category.rejects_keyframes?
|
|
134
|
+
else
|
|
135
|
+
category.rejects_css_property?(feature.name)
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def check_names(node, element_name, attribute_names, context)
|
|
140
|
+
reported = []
|
|
141
|
+
categories.each do |category|
|
|
142
|
+
if category.rejects_element?(element_name)
|
|
143
|
+
report(context, node, category,
|
|
144
|
+
"#{category.name} feature: element '#{element_name}' " \
|
|
145
|
+
"is not allowed in static processing")
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
attribute_names.each do |attr_name|
|
|
149
|
+
next if reported.include?(attr_name)
|
|
150
|
+
next unless category.rejects_attribute?(attr_name)
|
|
151
|
+
|
|
152
|
+
reported << attr_name
|
|
153
|
+
report(context, node, category,
|
|
154
|
+
"#{category.name} feature: attribute '#{attr_name}' " \
|
|
155
|
+
"is not allowed in static processing")
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def report(context, node, category, message)
|
|
161
|
+
context.add_error(
|
|
162
|
+
requirement_id: category.id || id,
|
|
163
|
+
message: message,
|
|
164
|
+
node: node,
|
|
165
|
+
severity: :error,
|
|
166
|
+
data: { category: category.name, citation: category.citation },
|
|
167
|
+
)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def local_name(name)
|
|
171
|
+
name.to_s.split(":").last
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "nokogiri"
|
|
4
|
+
require "strscan"
|
|
5
|
+
require_relative "base_requirement"
|
|
6
|
+
require_relative "../validation/svg_1_1_document_conformance"
|
|
7
|
+
require_relative "../validation/css2_core_grammar"
|
|
8
|
+
|
|
9
|
+
module SvgConform
|
|
10
|
+
module Requirements
|
|
11
|
+
# Source-level document checks for SVG 1.1 Full. One profile entry runs
|
|
12
|
+
# one +rule+ (xml, root, version, grammar, css2 or xml_stylesheet), so each
|
|
13
|
+
# carries its own ID and citation. All rules work from the original
|
|
14
|
+
# source text through #validate_source, which both DOM and SAX
|
|
15
|
+
# validation invoke exactly once; the element callbacks do nothing.
|
|
16
|
+
class Svg11DocumentRequirement < BaseRequirement
|
|
17
|
+
RULES = %w[xml root version grammar css2 xml_stylesheet].freeze
|
|
18
|
+
SVG_NS = Validation::Svg11DocumentConformance::SVG_NS
|
|
19
|
+
STYLESHEET_TARGET = "xml-stylesheet"
|
|
20
|
+
CSS_MEDIA_TYPE = "text/css" # media types are case-insensitive
|
|
21
|
+
PSEUDO_ATTRIBUTES = %w[href type title media charset alternate].freeze
|
|
22
|
+
PSEUDO_ATTRIBUTE_PAIR = /\G\s*([^\s=]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/
|
|
23
|
+
GRAMMAR_SKIPPED = %i[malformed_xml no_svg_root].freeze
|
|
24
|
+
|
|
25
|
+
# Per-validation cache shared by every rule entry of a profile
|
|
26
|
+
class State
|
|
27
|
+
attr_accessor :parsed, :conformance
|
|
28
|
+
|
|
29
|
+
def initialize
|
|
30
|
+
@parsed = nil
|
|
31
|
+
@conformance = nil
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
attribute :type, :string, default: -> { "Svg11DocumentRequirement" }
|
|
36
|
+
attribute :rule, :string
|
|
37
|
+
|
|
38
|
+
yaml do
|
|
39
|
+
map "id", to: :id
|
|
40
|
+
map "description", to: :description
|
|
41
|
+
map "citation_uri", to: :citation_uri
|
|
42
|
+
map "citation_label", to: :citation_label
|
|
43
|
+
map "type", to: :type
|
|
44
|
+
map "rule", to: :rule
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def validate_source(source, context)
|
|
48
|
+
return if source.nil?
|
|
49
|
+
unless RULES.include?(rule)
|
|
50
|
+
raise ArgumentError,
|
|
51
|
+
"Svg11DocumentRequirement #{id}: unknown rule #{rule.inspect}"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
send(:"check_#{rule}", source.to_s, context)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def validate_document(_document, _context); end
|
|
58
|
+
|
|
59
|
+
def check(_node, _context); end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def check_xml(source, context)
|
|
64
|
+
if context.source_serialized?
|
|
65
|
+
return fail_with(context, "source was re-serialized from a parsed " \
|
|
66
|
+
"tree; its XML declaration, doctype and " \
|
|
67
|
+
"processing instructions cannot be proved")
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
parsed = parse(source, context)
|
|
71
|
+
return unless parsed.is_a?(Hash) && parsed[:error]
|
|
72
|
+
|
|
73
|
+
fail_with(context, "document is not well-formed XML: #{parsed[:error].message}",
|
|
74
|
+
line: parsed[:error].line)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def check_root(source, context)
|
|
78
|
+
root = root_of(source, context)
|
|
79
|
+
return unless root
|
|
80
|
+
return if root.name == "svg" && root.namespace&.href == SVG_NS
|
|
81
|
+
|
|
82
|
+
fail_with(context, "document element must be 'svg' in the " \
|
|
83
|
+
"#{SVG_NS} namespace, found " \
|
|
84
|
+
"'#{root.name}' in '#{root.namespace&.href}'")
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def check_version(source, context)
|
|
88
|
+
root = root_of(source, context)
|
|
89
|
+
return unless root
|
|
90
|
+
|
|
91
|
+
value = root["version"]
|
|
92
|
+
return if value == "1.1"
|
|
93
|
+
|
|
94
|
+
fail_with(context, value ? "root version must be \"1.1\", found #{value.inspect}" : "root element does not declare version=\"1.1\"")
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def check_grammar(source, context)
|
|
98
|
+
result = conformance(source, context)
|
|
99
|
+
result.issues.each do |issue|
|
|
100
|
+
next if GRAMMAR_SKIPPED.include?(issue.kind)
|
|
101
|
+
|
|
102
|
+
fail_with(context, "SVG 1.1 DTD (#{issue.kind}): #{issue.message}",
|
|
103
|
+
line: issue.line)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def check_css2(source, context)
|
|
108
|
+
parsed = parse(source, context)
|
|
109
|
+
return unless parsed.is_a?(Nokogiri::XML::Document)
|
|
110
|
+
|
|
111
|
+
parsed.traverse do |node|
|
|
112
|
+
next unless node.element? && node.namespace&.href == SVG_NS
|
|
113
|
+
|
|
114
|
+
check_style_attribute(node, context)
|
|
115
|
+
check_style_element(node, context)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def check_xml_stylesheet(source, context)
|
|
120
|
+
parsed = parse(source, context)
|
|
121
|
+
return unless parsed.is_a?(Nokogiri::XML::Document)
|
|
122
|
+
|
|
123
|
+
parsed.children.each do |node|
|
|
124
|
+
next unless node.processing_instruction? && node.name == STYLESHEET_TARGET
|
|
125
|
+
|
|
126
|
+
stylesheet_problems(node.content).each do |problem|
|
|
127
|
+
fail_with(context, "xml-stylesheet: #{problem}", line: node.line)
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def check_style_attribute(node, context)
|
|
133
|
+
value = node["style"]
|
|
134
|
+
return unless value
|
|
135
|
+
|
|
136
|
+
report_css(Validation::Css2CoreGrammar.parse_declarations(value),
|
|
137
|
+
"style attribute on '#{node.name}'", node, context)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def check_style_element(node, context)
|
|
141
|
+
return unless node.name == "style"
|
|
142
|
+
return unless ["", CSS_MEDIA_TYPE].include?(node["type"].to_s.downcase)
|
|
143
|
+
|
|
144
|
+
report_css(Validation::Css2CoreGrammar.parse_stylesheet(node.content),
|
|
145
|
+
"style element", node, context)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def report_css(result, where, node, context)
|
|
149
|
+
result.errors.each do |error|
|
|
150
|
+
fail_with(context, "CSS 2 grammar in #{where}: #{error}",
|
|
151
|
+
line: node.line)
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Pseudo-attribute syntax of the xml-stylesheet Recommendation plus the
|
|
156
|
+
# SVG applicability rule that a declared type must be CSS.
|
|
157
|
+
def stylesheet_problems(content)
|
|
158
|
+
problems = []
|
|
159
|
+
pairs = []
|
|
160
|
+
scanner = StringScanner.new(content)
|
|
161
|
+
until scanner.eos?
|
|
162
|
+
unless scanner.scan(PSEUDO_ATTRIBUTE_PAIR)
|
|
163
|
+
problems << "malformed pseudo-attribute near #{scanner.rest.strip[0, 20].inspect}"
|
|
164
|
+
break
|
|
165
|
+
end
|
|
166
|
+
pairs << [scanner[1], scanner[2] || scanner[3]]
|
|
167
|
+
end
|
|
168
|
+
names = pairs.map(&:first)
|
|
169
|
+
problems << "duplicate pseudo-attribute #{names.tally.select { |_, n| n > 1 }.keys.first}" if names.uniq.size != names.size
|
|
170
|
+
names.reject { |n| PSEUDO_ATTRIBUTES.include?(n) }.each do |name|
|
|
171
|
+
problems << "unknown pseudo-attribute '#{name}'"
|
|
172
|
+
end
|
|
173
|
+
values = pairs.to_h
|
|
174
|
+
problems << "href pseudo-attribute is required" if problems.empty? && values["href"].to_s.empty?
|
|
175
|
+
if values["type"] && values["type"].downcase != CSS_MEDIA_TYPE
|
|
176
|
+
problems << "type #{values['type'].inspect} is not applicable to SVG (text/css expected)"
|
|
177
|
+
end
|
|
178
|
+
if values["alternate"] && !%w[yes no].include?(values["alternate"])
|
|
179
|
+
problems << "alternate must be \"yes\" or \"no\""
|
|
180
|
+
end
|
|
181
|
+
problems
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def conformance(source, context)
|
|
185
|
+
state = context.state_for(self)
|
|
186
|
+
state.conformance ||= Validation::Svg11DocumentConformance.new.validate(source)
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Non-validating, offline parse; a Hash with :error when malformed.
|
|
190
|
+
def parse(source, context)
|
|
191
|
+
state = context.state_for(self)
|
|
192
|
+
state.parsed ||= begin
|
|
193
|
+
doc = Nokogiri::XML::Document.parse(source) { |c| c.strict.nonet }
|
|
194
|
+
doc.root ? doc : { error: Nokogiri::XML::SyntaxError.new("document has no root element") }
|
|
195
|
+
rescue Nokogiri::XML::SyntaxError => e
|
|
196
|
+
{ error: e }
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def root_of(source, context)
|
|
201
|
+
parsed = parse(source, context)
|
|
202
|
+
parsed.is_a?(Nokogiri::XML::Document) ? parsed.root : nil
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def fail_with(context, message, line: nil)
|
|
206
|
+
data = { citation: citation_uri }
|
|
207
|
+
data[:line] = line if line.is_a?(Integer) && line.positive?
|
|
208
|
+
context.add_error(requirement_id: id, message: message, node: nil,
|
|
209
|
+
severity: :error, data: data)
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
end
|
|
@@ -19,6 +19,9 @@ require_relative "requirements/no_external_css_requirement"
|
|
|
19
19
|
require_relative "requirements/no_external_fonts_requirement"
|
|
20
20
|
require_relative "requirements/no_external_images_requirement"
|
|
21
21
|
require_relative "requirements/text_as_path_requirement"
|
|
22
|
+
require_relative "requirements/processing_features_requirement"
|
|
23
|
+
require_relative "requirements/svg_1_1_document_requirement"
|
|
24
|
+
require_relative "requirements/external_references_requirement"
|
|
22
25
|
|
|
23
26
|
module SvgConform
|
|
24
27
|
module Requirements
|
|
@@ -40,6 +43,9 @@ module SvgConform
|
|
|
40
43
|
LinkValidationRequirement,
|
|
41
44
|
IdReferenceRequirement,
|
|
42
45
|
TextAsPathRequirement,
|
|
46
|
+
ProcessingFeaturesRequirement,
|
|
47
|
+
Svg11DocumentRequirement,
|
|
48
|
+
ExternalReferencesRequirement,
|
|
43
49
|
]
|
|
44
50
|
end
|
|
45
51
|
|
|
@@ -14,18 +14,22 @@ module SvgConform
|
|
|
14
14
|
new(File.read(file_path), file_path)
|
|
15
15
|
end
|
|
16
16
|
|
|
17
|
-
def self.from_content(content)
|
|
18
|
-
new(content, nil)
|
|
17
|
+
def self.from_content(content, source_serialized: false)
|
|
18
|
+
new(content, nil, source_serialized: source_serialized)
|
|
19
19
|
end
|
|
20
20
|
|
|
21
|
-
def initialize(content, file_path = nil)
|
|
21
|
+
def initialize(content, file_path = nil, source_serialized: false)
|
|
22
22
|
@content = content
|
|
23
23
|
@file_path = file_path
|
|
24
|
+
@source_serialized = source_serialized
|
|
24
25
|
end
|
|
25
26
|
|
|
26
27
|
# Validate using SAX streaming parser
|
|
27
28
|
def validate_with_profile(profile)
|
|
28
|
-
handler = SaxValidationHandler.new(
|
|
29
|
+
handler = SaxValidationHandler.new(
|
|
30
|
+
profile, source: @content, source_path: @file_path,
|
|
31
|
+
source_serialized: @source_serialized
|
|
32
|
+
)
|
|
29
33
|
parser = Leptris::XML::SAX::Parser.new(handler)
|
|
30
34
|
|
|
31
35
|
begin
|