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
@@ -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(profile)
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