svg_conform 0.1.1 → 0.1.2
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/.rubocop_todo.yml +273 -10
- data/README.adoc +7 -8
- data/examples/readme_usage.rb +67 -0
- data/examples/requirements_demo.rb +4 -4
- data/lib/svg_conform/document.rb +7 -1
- data/lib/svg_conform/element_proxy.rb +101 -0
- data/lib/svg_conform/fast_document_analyzer.rb +82 -0
- data/lib/svg_conform/node_index_builder.rb +47 -0
- data/lib/svg_conform/requirements/allowed_elements_requirement.rb +202 -0
- data/lib/svg_conform/requirements/base_requirement.rb +27 -0
- data/lib/svg_conform/requirements/color_restrictions_requirement.rb +53 -0
- data/lib/svg_conform/requirements/font_family_requirement.rb +18 -0
- data/lib/svg_conform/requirements/forbidden_content_requirement.rb +26 -0
- data/lib/svg_conform/requirements/id_reference_requirement.rb +96 -0
- data/lib/svg_conform/requirements/invalid_id_references_requirement.rb +91 -0
- data/lib/svg_conform/requirements/link_validation_requirement.rb +30 -0
- data/lib/svg_conform/requirements/namespace_attributes_requirement.rb +59 -0
- data/lib/svg_conform/requirements/namespace_requirement.rb +74 -0
- data/lib/svg_conform/requirements/no_external_css_requirement.rb +74 -0
- data/lib/svg_conform/requirements/no_external_fonts_requirement.rb +58 -0
- data/lib/svg_conform/requirements/no_external_images_requirement.rb +40 -0
- data/lib/svg_conform/requirements/style_requirement.rb +12 -0
- data/lib/svg_conform/requirements/viewbox_required_requirement.rb +72 -0
- data/lib/svg_conform/sax_document.rb +46 -0
- data/lib/svg_conform/sax_validation_handler.rb +158 -0
- data/lib/svg_conform/validation_context.rb +84 -2
- data/lib/svg_conform/validator.rb +74 -6
- data/lib/svg_conform/version.rb +1 -1
- data/lib/svg_conform.rb +1 -0
- metadata +8 -2
|
@@ -45,6 +45,36 @@ module SvgConform
|
|
|
45
45
|
end
|
|
46
46
|
end
|
|
47
47
|
|
|
48
|
+
def validate_sax_element(element, context)
|
|
49
|
+
# Check href attributes
|
|
50
|
+
href_value = element.raw_attributes["href"] || element.raw_attributes["xlink:href"]
|
|
51
|
+
|
|
52
|
+
if href_value && !ascii_only?(href_value)
|
|
53
|
+
context.add_error(
|
|
54
|
+
requirement_id: id,
|
|
55
|
+
message: "Link href '#{href_value}' contains non-ASCII characters",
|
|
56
|
+
node: element,
|
|
57
|
+
severity: :error
|
|
58
|
+
)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Check other IRI attributes
|
|
62
|
+
iri_attributes = %w[src action formaction cite longdesc usemap]
|
|
63
|
+
iri_attributes.each do |attr_name|
|
|
64
|
+
iri_value = element.raw_attributes[attr_name]
|
|
65
|
+
next unless iri_value
|
|
66
|
+
|
|
67
|
+
next if ascii_only?(iri_value)
|
|
68
|
+
|
|
69
|
+
context.add_error(
|
|
70
|
+
requirement_id: id,
|
|
71
|
+
message: "IRI attribute '#{attr_name}' value '#{iri_value}' contains non-ASCII characters",
|
|
72
|
+
node: element,
|
|
73
|
+
severity: :error
|
|
74
|
+
)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
48
78
|
private
|
|
49
79
|
|
|
50
80
|
def ascii_only?(string)
|
|
@@ -41,6 +41,65 @@ module SvgConform
|
|
|
41
41
|
end
|
|
42
42
|
end
|
|
43
43
|
|
|
44
|
+
def validate_sax_element(element, context)
|
|
45
|
+
# Skip validation for exempt elements (e.g., RDF metadata elements)
|
|
46
|
+
return if exempt_elements.include?(element.name)
|
|
47
|
+
|
|
48
|
+
# Check all attributes for namespace violations
|
|
49
|
+
element.attributes.each do |attr|
|
|
50
|
+
check_sax_attribute(attr, element, context)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def check_sax_attribute(attr, element, context)
|
|
57
|
+
attr_name = attr.name
|
|
58
|
+
|
|
59
|
+
# Check if this is a namespaced attribute by looking for colon in name
|
|
60
|
+
return unless attr_name.include?(":")
|
|
61
|
+
|
|
62
|
+
prefix, = attr_name.split(":", 2)
|
|
63
|
+
|
|
64
|
+
# Find the namespace URI for this prefix by walking up parent chain
|
|
65
|
+
namespace_uri = find_namespace_uri_sax(element, prefix)
|
|
66
|
+
return unless namespace_uri
|
|
67
|
+
|
|
68
|
+
# Determine if this namespace is invalid based on configuration
|
|
69
|
+
invalid_namespace = if allowed_namespaces.empty?
|
|
70
|
+
# Blacklist mode: disallowed namespaces are forbidden
|
|
71
|
+
disallowed_namespaces.include?(namespace_uri)
|
|
72
|
+
else
|
|
73
|
+
# Whitelist mode: only allowed namespaces are permitted
|
|
74
|
+
!allowed_namespaces.include?(namespace_uri)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
return unless invalid_namespace
|
|
78
|
+
|
|
79
|
+
context.add_error(
|
|
80
|
+
requirement_id: id,
|
|
81
|
+
message: "Element '#{element.name}' does not allow attributes with namespace '#{namespace_uri}'",
|
|
82
|
+
node: element,
|
|
83
|
+
severity: :error,
|
|
84
|
+
data: { attribute: attr_name, namespace: namespace_uri }
|
|
85
|
+
)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def find_namespace_uri_sax(element, prefix)
|
|
89
|
+
# Check current element and ancestors for xmlns:prefix declarations
|
|
90
|
+
current = element
|
|
91
|
+
while current
|
|
92
|
+
# Check for xmlns:prefix attribute in raw_attributes
|
|
93
|
+
xmlns_value = current.raw_attributes["xmlns:#{prefix}"]
|
|
94
|
+
return xmlns_value if xmlns_value
|
|
95
|
+
|
|
96
|
+
# Move to parent
|
|
97
|
+
current = current.parent
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
nil
|
|
101
|
+
end
|
|
102
|
+
|
|
44
103
|
private
|
|
45
104
|
|
|
46
105
|
def check_attribute_nodes(node, context)
|
|
@@ -190,8 +190,82 @@ module SvgConform
|
|
|
190
190
|
)
|
|
191
191
|
end
|
|
192
192
|
|
|
193
|
+
def validate_sax_element(element, context)
|
|
194
|
+
# Check if this element has a namespace
|
|
195
|
+
element_namespace = get_element_namespace_sax(element)
|
|
196
|
+
|
|
197
|
+
# Skip if no namespace (default SVG namespace)
|
|
198
|
+
return if element_namespace.nil? || element_namespace.empty?
|
|
199
|
+
|
|
200
|
+
# Check against allowed namespaces if configured
|
|
201
|
+
# If allow_rdf_metadata is enabled, also allow RDF namespaces
|
|
202
|
+
effective_allowed_namespaces = allowed_namespaces
|
|
203
|
+
if allow_rdf_metadata
|
|
204
|
+
effective_allowed_namespaces = allowed_namespaces + RDF_NAMESPACES
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
if effective_allowed_namespaces && !effective_allowed_namespaces.empty? && !effective_allowed_namespaces.include?(element_namespace)
|
|
208
|
+
context.add_error(
|
|
209
|
+
requirement_id: id,
|
|
210
|
+
message: "The namespace #{element_namespace} is not permitted for svg elements.",
|
|
211
|
+
node: element,
|
|
212
|
+
severity: :error,
|
|
213
|
+
data: {
|
|
214
|
+
element_name: element.name,
|
|
215
|
+
namespace: element_namespace,
|
|
216
|
+
allowed_namespaces: effective_allowed_namespaces
|
|
217
|
+
}
|
|
218
|
+
)
|
|
219
|
+
return
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# Check against disallowed namespaces if configured
|
|
223
|
+
return unless disallowed_namespaces && !disallowed_namespaces.empty? && disallowed_namespaces.include?(element_namespace)
|
|
224
|
+
|
|
225
|
+
context.add_error(
|
|
226
|
+
requirement_id: id,
|
|
227
|
+
message: "The namespace #{element_namespace} is not permitted for svg elements.",
|
|
228
|
+
node: element,
|
|
229
|
+
severity: :error,
|
|
230
|
+
data: {
|
|
231
|
+
element_name: element.name,
|
|
232
|
+
namespace: element_namespace,
|
|
233
|
+
disallowed_namespaces: disallowed_namespaces
|
|
234
|
+
}
|
|
235
|
+
)
|
|
236
|
+
end
|
|
237
|
+
|
|
193
238
|
private
|
|
194
239
|
|
|
240
|
+
def get_element_namespace_sax(element)
|
|
241
|
+
# Try to get namespace from the element
|
|
242
|
+
namespace = element.namespace
|
|
243
|
+
return namespace if namespace && !namespace.empty?
|
|
244
|
+
|
|
245
|
+
# If no namespace found, check if element has a prefix (indicating it's namespaced)
|
|
246
|
+
if element.name.include?(":")
|
|
247
|
+
prefix = element.name.split(":").first
|
|
248
|
+
return find_namespace_uri_for_prefix_sax(element, prefix)
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
nil
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def find_namespace_uri_for_prefix_sax(element, prefix)
|
|
255
|
+
# Check current element and ancestors for namespace declarations
|
|
256
|
+
current = element
|
|
257
|
+
while current
|
|
258
|
+
# Check for xmlns:prefix attribute
|
|
259
|
+
xmlns_attr = "xmlns:#{prefix}"
|
|
260
|
+
return current.raw_attributes[xmlns_attr] if current.raw_attributes[xmlns_attr]
|
|
261
|
+
|
|
262
|
+
# Move to parent
|
|
263
|
+
current = current.parent
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
nil
|
|
267
|
+
end
|
|
268
|
+
|
|
195
269
|
def check_all_elements(document, context)
|
|
196
270
|
# Recursively check all elements in the document
|
|
197
271
|
traverse_elements(document.root, context)
|
|
@@ -44,6 +44,17 @@ module SvgConform
|
|
|
44
44
|
has_style_attribute?(node)
|
|
45
45
|
end
|
|
46
46
|
|
|
47
|
+
def validate_sax_element(element, context)
|
|
48
|
+
case element.name
|
|
49
|
+
when "style"
|
|
50
|
+
check_style_element_sax(element, context) if check_style_elements
|
|
51
|
+
when "link"
|
|
52
|
+
check_link_element_sax(element, context) if check_link_elements
|
|
53
|
+
else
|
|
54
|
+
check_style_attribute_sax(element, context) if check_style_attributes
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
47
58
|
private
|
|
48
59
|
|
|
49
60
|
def check_style_element(node, context)
|
|
@@ -109,6 +120,69 @@ module SvgConform
|
|
|
109
120
|
)
|
|
110
121
|
end
|
|
111
122
|
|
|
123
|
+
def check_style_element_sax(element, context)
|
|
124
|
+
# Check for @import rules in style elements
|
|
125
|
+
content = element.text_content
|
|
126
|
+
|
|
127
|
+
if content =~ /@import\s+url\s*\(\s*['"]?([^'")\s]+)['"]?\s*\)/i
|
|
128
|
+
url = ::Regexp.last_match(1)
|
|
129
|
+
unless allowed_url?(url)
|
|
130
|
+
context.add_error(
|
|
131
|
+
requirement_id: id,
|
|
132
|
+
message: "External CSS import not allowed: #{url}",
|
|
133
|
+
node: element,
|
|
134
|
+
severity: :error
|
|
135
|
+
)
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
return unless content =~ /@import\s+['"]([^'"]+)['"]/i
|
|
140
|
+
|
|
141
|
+
url = ::Regexp.last_match(1)
|
|
142
|
+
return if allowed_url?(url)
|
|
143
|
+
|
|
144
|
+
context.add_error(
|
|
145
|
+
requirement_id: id,
|
|
146
|
+
message: "External CSS import not allowed: #{url}",
|
|
147
|
+
node: element,
|
|
148
|
+
severity: :error
|
|
149
|
+
)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def check_link_element_sax(element, context)
|
|
153
|
+
rel = element.raw_attributes["rel"]
|
|
154
|
+
href = element.raw_attributes["href"]
|
|
155
|
+
|
|
156
|
+
return unless rel&.downcase == "stylesheet" && href
|
|
157
|
+
|
|
158
|
+
return if allowed_url?(href)
|
|
159
|
+
|
|
160
|
+
context.add_error(
|
|
161
|
+
requirement_id: id,
|
|
162
|
+
message: "External CSS link not allowed: #{href}",
|
|
163
|
+
node: element,
|
|
164
|
+
severity: :error
|
|
165
|
+
)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def check_style_attribute_sax(element, context)
|
|
169
|
+
style_value = element.raw_attributes["style"]
|
|
170
|
+
return unless style_value
|
|
171
|
+
|
|
172
|
+
# Check for url() references in style attributes
|
|
173
|
+
return unless style_value =~ /url\s*\(\s*['"]?([^'")\s]+)['"]?\s*\)/i
|
|
174
|
+
|
|
175
|
+
url = ::Regexp.last_match(1)
|
|
176
|
+
return if allowed_url?(url)
|
|
177
|
+
|
|
178
|
+
context.add_error(
|
|
179
|
+
requirement_id: id,
|
|
180
|
+
message: "External URL reference in style attribute not allowed: #{url}",
|
|
181
|
+
node: element,
|
|
182
|
+
severity: :error
|
|
183
|
+
)
|
|
184
|
+
end
|
|
185
|
+
|
|
112
186
|
def has_style_attribute?(node)
|
|
113
187
|
!get_attribute(node, "style").nil?
|
|
114
188
|
end
|
|
@@ -41,6 +41,17 @@ module SvgConform
|
|
|
41
41
|
has_style_attribute?(node)
|
|
42
42
|
end
|
|
43
43
|
|
|
44
|
+
def validate_sax_element(element, context)
|
|
45
|
+
case element.name
|
|
46
|
+
when "style"
|
|
47
|
+
check_style_element_sax(element, context) if check_style_fonts
|
|
48
|
+
when "font-face"
|
|
49
|
+
check_font_face_element_sax(element, context) if check_font_face
|
|
50
|
+
else
|
|
51
|
+
check_style_attribute_sax(element, context) if check_style_fonts
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
44
55
|
private
|
|
45
56
|
|
|
46
57
|
def check_style_element(node, context)
|
|
@@ -100,6 +111,53 @@ module SvgConform
|
|
|
100
111
|
)
|
|
101
112
|
end
|
|
102
113
|
|
|
114
|
+
def check_style_element_sax(element, context)
|
|
115
|
+
# Check for @font-face with external src in style elements
|
|
116
|
+
content = element.text_content
|
|
117
|
+
|
|
118
|
+
# Match @font-face blocks
|
|
119
|
+
content.scan(/@font-face\s*\{([^}]+)\}/m) do |match|
|
|
120
|
+
font_face_content = match[0]
|
|
121
|
+
|
|
122
|
+
# Check for src with url() that is not data: URI
|
|
123
|
+
if font_face_content =~ /src\s*:\s*url\s*\(\s*['"]?([^'")\s]+)['"]?\s*\)/i
|
|
124
|
+
url = ::Regexp.last_match(1)
|
|
125
|
+
unless embedded_font?(url)
|
|
126
|
+
context.add_error(
|
|
127
|
+
requirement_id: id,
|
|
128
|
+
message: "External font reference not allowed: #{url}. Fonts must be embedded as data URIs.",
|
|
129
|
+
node: element,
|
|
130
|
+
severity: :error
|
|
131
|
+
)
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def check_font_face_element_sax(element, context)
|
|
138
|
+
# Note: For SAX, we can't traverse children yet
|
|
139
|
+
# This would need to be handled differently or deferred
|
|
140
|
+
# For now, skip XPath-based checking in SAX mode
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def check_style_attribute_sax(element, context)
|
|
144
|
+
style_value = element.raw_attributes["style"]
|
|
145
|
+
return unless style_value
|
|
146
|
+
|
|
147
|
+
# Check for font-family with url() references
|
|
148
|
+
return unless style_value =~ /font-family\s*:\s*.*url\s*\(\s*['"]?([^'")\s]+)['"]?\s*\)/i
|
|
149
|
+
|
|
150
|
+
url = ::Regexp.last_match(1)
|
|
151
|
+
return if embedded_font?(url)
|
|
152
|
+
|
|
153
|
+
context.add_error(
|
|
154
|
+
requirement_id: id,
|
|
155
|
+
message: "External font URL in style attribute not allowed: #{url}. Fonts must be embedded as data URIs.",
|
|
156
|
+
node: element,
|
|
157
|
+
severity: :error
|
|
158
|
+
)
|
|
159
|
+
end
|
|
160
|
+
|
|
103
161
|
def has_style_attribute?(node)
|
|
104
162
|
!get_attribute(node, "style").nil?
|
|
105
163
|
end
|
|
@@ -37,6 +37,15 @@ module SvgConform
|
|
|
37
37
|
node.name == "image" || has_style_attribute?(node)
|
|
38
38
|
end
|
|
39
39
|
|
|
40
|
+
def validate_sax_element(element, context)
|
|
41
|
+
case element.name
|
|
42
|
+
when "image"
|
|
43
|
+
check_image_element_sax(element, context) if check_image_elements
|
|
44
|
+
else
|
|
45
|
+
check_style_attribute_sax(element, context) if check_style_images
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
40
49
|
private
|
|
41
50
|
|
|
42
51
|
def check_image_element(node, context)
|
|
@@ -70,6 +79,37 @@ module SvgConform
|
|
|
70
79
|
end
|
|
71
80
|
end
|
|
72
81
|
|
|
82
|
+
def check_image_element_sax(element, context)
|
|
83
|
+
# Check href and xlink:href attributes
|
|
84
|
+
href = element.raw_attributes["href"] || element.raw_attributes["xlink:href"]
|
|
85
|
+
return unless href && !embedded_image?(href)
|
|
86
|
+
|
|
87
|
+
context.add_error(
|
|
88
|
+
requirement_id: id,
|
|
89
|
+
message: "External image reference not allowed: #{href}. Images must be embedded as data URIs.",
|
|
90
|
+
node: element,
|
|
91
|
+
severity: :error
|
|
92
|
+
)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def check_style_attribute_sax(element, context)
|
|
96
|
+
style_value = element.raw_attributes["style"]
|
|
97
|
+
return unless style_value
|
|
98
|
+
|
|
99
|
+
# Check for url() references to images in background, background-image, etc.
|
|
100
|
+
style_value.scan(/url\s*\(\s*['"]?([^'")\s]+)['"]?\s*\)/i) do
|
|
101
|
+
url = ::Regexp.last_match(1)
|
|
102
|
+
next if embedded_image?(url)
|
|
103
|
+
|
|
104
|
+
context.add_error(
|
|
105
|
+
requirement_id: id,
|
|
106
|
+
message: "External image URL in style attribute not allowed: #{url}. Images must be embedded as data URIs.",
|
|
107
|
+
node: element,
|
|
108
|
+
severity: :error
|
|
109
|
+
)
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
73
113
|
def has_style_attribute?(node)
|
|
74
114
|
!get_attribute(node, "style").nil?
|
|
75
115
|
end
|
|
@@ -43,6 +43,18 @@ module SvgConform
|
|
|
43
43
|
check_style_properties(style_value, node, context)
|
|
44
44
|
end
|
|
45
45
|
|
|
46
|
+
def validate_sax_element(element, context)
|
|
47
|
+
style_value = element.raw_attributes["style"]
|
|
48
|
+
return unless style_value
|
|
49
|
+
return if style_value.strip.empty?
|
|
50
|
+
|
|
51
|
+
# 1. Check for malformed style syntax
|
|
52
|
+
check_malformed_syntax(style_value, element, context)
|
|
53
|
+
|
|
54
|
+
# 2. Check for allowed/disallowed properties and validate their values
|
|
55
|
+
check_style_properties(style_value, element, context)
|
|
56
|
+
end
|
|
57
|
+
|
|
46
58
|
private
|
|
47
59
|
|
|
48
60
|
def check_malformed_syntax(style_value, node, context)
|
|
@@ -83,6 +83,78 @@ module SvgConform
|
|
|
83
83
|
)
|
|
84
84
|
end
|
|
85
85
|
|
|
86
|
+
def validate_sax_element(element, context)
|
|
87
|
+
# Only check the root SVG element
|
|
88
|
+
return unless element.name == "svg" && element.parent.nil?
|
|
89
|
+
|
|
90
|
+
viewbox = element.raw_attributes["viewBox"]
|
|
91
|
+
|
|
92
|
+
if viewbox.nil? || viewbox.empty?
|
|
93
|
+
context.add_error(
|
|
94
|
+
requirement_id: id,
|
|
95
|
+
message: "SVG root element must have a viewBox attribute",
|
|
96
|
+
node: element,
|
|
97
|
+
severity: :error,
|
|
98
|
+
data: { missing_attribute: "viewBox" }
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
# Add informational message about calculated viewBox if width/height are present
|
|
102
|
+
width = element.raw_attributes["width"]
|
|
103
|
+
height = element.raw_attributes["height"]
|
|
104
|
+
|
|
105
|
+
if width && height && valid_number?(width) && valid_number?(height)
|
|
106
|
+
calculated_viewbox = "0 0 #{width.to_f} #{height.to_f}"
|
|
107
|
+
context.add_error(
|
|
108
|
+
requirement_id: id,
|
|
109
|
+
message: "Trying to put in the attribute with value '#{calculated_viewbox}'",
|
|
110
|
+
node: element,
|
|
111
|
+
severity: :error,
|
|
112
|
+
data: {
|
|
113
|
+
calculated_viewbox: calculated_viewbox,
|
|
114
|
+
source_width: width,
|
|
115
|
+
source_height: height
|
|
116
|
+
}
|
|
117
|
+
)
|
|
118
|
+
end
|
|
119
|
+
return
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Validate viewBox format (should be "min-x min-y width height")
|
|
123
|
+
normalized_viewbox = viewbox.strip.gsub(/[(),]/, " ").squeeze(" ")
|
|
124
|
+
parts = normalized_viewbox.strip.split(/\s+/)
|
|
125
|
+
unless parts.length == 4 && parts.all? { |part| valid_number?(part) }
|
|
126
|
+
context.add_error(
|
|
127
|
+
requirement_id: id,
|
|
128
|
+
message: "viewBox attribute must contain four numeric values (min-x min-y width height)",
|
|
129
|
+
node: element,
|
|
130
|
+
severity: :error,
|
|
131
|
+
data: {
|
|
132
|
+
viewbox_value: viewbox,
|
|
133
|
+
parsed_parts: parts
|
|
134
|
+
}
|
|
135
|
+
)
|
|
136
|
+
return
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Check that width and height are positive
|
|
140
|
+
width = parts[2].to_f
|
|
141
|
+
height = parts[3].to_f
|
|
142
|
+
|
|
143
|
+
return unless width <= 0 || height <= 0
|
|
144
|
+
|
|
145
|
+
context.add_error(
|
|
146
|
+
requirement_id: id,
|
|
147
|
+
message: "viewBox width and height must be positive values",
|
|
148
|
+
node: element,
|
|
149
|
+
severity: :error,
|
|
150
|
+
data: {
|
|
151
|
+
viewbox_value: viewbox,
|
|
152
|
+
width: width,
|
|
153
|
+
height: height
|
|
154
|
+
}
|
|
155
|
+
)
|
|
156
|
+
end
|
|
157
|
+
|
|
86
158
|
private
|
|
87
159
|
|
|
88
160
|
def valid_number?(str)
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "nokogiri"
|
|
4
|
+
require_relative "sax_validation_handler"
|
|
5
|
+
|
|
6
|
+
module SvgConform
|
|
7
|
+
# SAX-based document for streaming validation
|
|
8
|
+
# Provides high-performance validation for large SVG files
|
|
9
|
+
# without loading entire DOM tree into memory
|
|
10
|
+
class SaxDocument
|
|
11
|
+
attr_reader :file_path, :content
|
|
12
|
+
|
|
13
|
+
def self.from_file(file_path)
|
|
14
|
+
new(File.read(file_path), file_path)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def self.from_content(content)
|
|
18
|
+
new(content, nil)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def initialize(content, file_path = nil)
|
|
22
|
+
@content = content
|
|
23
|
+
@file_path = file_path
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Validate using SAX streaming parser
|
|
27
|
+
def validate_with_profile(profile)
|
|
28
|
+
handler = SaxValidationHandler.new(profile)
|
|
29
|
+
parser = Nokogiri::XML::SAX::Parser.new(handler)
|
|
30
|
+
|
|
31
|
+
begin
|
|
32
|
+
parser.parse(@content)
|
|
33
|
+
rescue StandardError => e
|
|
34
|
+
# Handle parse errors
|
|
35
|
+
handler.add_parse_error(e)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
handler.result
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# For compatibility - convert to DOM when needed
|
|
42
|
+
def to_dom
|
|
43
|
+
Document.from_content(@content)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "nokogiri"
|
|
4
|
+
require_relative "element_proxy"
|
|
5
|
+
require_relative "validation_context"
|
|
6
|
+
require_relative "validation_result"
|
|
7
|
+
|
|
8
|
+
module SvgConform
|
|
9
|
+
# SAX event handler for streaming SVG validation
|
|
10
|
+
# Processes XML events and dispatches to requirements
|
|
11
|
+
class SaxValidationHandler < Nokogiri::XML::SAX::Document
|
|
12
|
+
attr_reader :result, :context
|
|
13
|
+
|
|
14
|
+
def initialize(profile)
|
|
15
|
+
@profile = profile
|
|
16
|
+
@element_stack = [] # Track parent-child hierarchy
|
|
17
|
+
@path_stack = [] # Current element path
|
|
18
|
+
@position_counters = [] # Stack of sibling counters per level
|
|
19
|
+
@parse_errors = []
|
|
20
|
+
@result = nil # Will be set in end_document
|
|
21
|
+
|
|
22
|
+
# Create validation context (without document reference for SAX)
|
|
23
|
+
@context = create_sax_context
|
|
24
|
+
|
|
25
|
+
# Classify requirements into immediate vs deferred
|
|
26
|
+
@immediate_requirements = []
|
|
27
|
+
@deferred_requirements = []
|
|
28
|
+
classify_requirements
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# SAX Event: Document start
|
|
32
|
+
def start_document
|
|
33
|
+
# Initialize root level counters
|
|
34
|
+
@position_counters.push({})
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# SAX Event: Element start tag
|
|
38
|
+
def start_element(name, attributes = [])
|
|
39
|
+
attrs = Hash[attributes]
|
|
40
|
+
|
|
41
|
+
# Calculate position among siblings at current level
|
|
42
|
+
current_counters = @position_counters.last || {}
|
|
43
|
+
current_counters[name] ||= 0
|
|
44
|
+
current_counters[name] += 1
|
|
45
|
+
position = current_counters[name]
|
|
46
|
+
|
|
47
|
+
# Build element proxy
|
|
48
|
+
element = ElementProxy.new(
|
|
49
|
+
name: name,
|
|
50
|
+
attributes: attrs,
|
|
51
|
+
position: position,
|
|
52
|
+
path: @path_stack.dup,
|
|
53
|
+
parent: @element_stack.last
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
# Push to stacks
|
|
57
|
+
@element_stack.push(element)
|
|
58
|
+
@path_stack.push("#{name}[#{position}]")
|
|
59
|
+
@position_counters.push({}) # New level for this element's children
|
|
60
|
+
|
|
61
|
+
# Validate with immediate requirements
|
|
62
|
+
@immediate_requirements.each do |req|
|
|
63
|
+
req.validate_sax_element(element, @context)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Deferred requirements may need to collect data
|
|
67
|
+
@deferred_requirements.each do |req|
|
|
68
|
+
req.collect_sax_data(element, @context) if req.respond_to?(:collect_sax_data)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# SAX Event: Element end tag
|
|
73
|
+
def end_element(name)
|
|
74
|
+
@element_stack.pop
|
|
75
|
+
@path_stack.pop
|
|
76
|
+
@position_counters.pop
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# SAX Event: Text content
|
|
80
|
+
def characters(string)
|
|
81
|
+
return if @element_stack.empty?
|
|
82
|
+
@element_stack.last.text_content << string
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# SAX Event: Document complete
|
|
86
|
+
def end_document
|
|
87
|
+
# Run deferred validation
|
|
88
|
+
@deferred_requirements.each do |req|
|
|
89
|
+
req.validate_sax_complete(@context)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Create result
|
|
93
|
+
@result = ValidationResult.new(nil, @profile, @context)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# SAX Event: Parse error
|
|
97
|
+
def error(error_message)
|
|
98
|
+
@parse_errors << error_message
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# SAX Event: Warning
|
|
102
|
+
def warning(warning_message)
|
|
103
|
+
# Can log warnings if needed
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Handle parse errors
|
|
107
|
+
def add_parse_error(error)
|
|
108
|
+
@context.add_error(
|
|
109
|
+
node: nil,
|
|
110
|
+
message: "Parse error: #{error.message}",
|
|
111
|
+
requirement_id: "parse_error",
|
|
112
|
+
severity: :error
|
|
113
|
+
)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Get result (will be nil until end_document called)
|
|
117
|
+
def result
|
|
118
|
+
@result || create_incomplete_result
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
private
|
|
122
|
+
|
|
123
|
+
def create_incomplete_result
|
|
124
|
+
# Return result even if parsing incomplete
|
|
125
|
+
ValidationResult.new(nil, @profile, @context)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Create a SAX-compatible validation context
|
|
129
|
+
def create_sax_context
|
|
130
|
+
# Create context without triggering DOM operations
|
|
131
|
+
context = ValidationContext.allocate
|
|
132
|
+
context.instance_variable_set(:@document, nil)
|
|
133
|
+
context.instance_variable_set(:@profile, @profile)
|
|
134
|
+
context.instance_variable_set(:@errors, [])
|
|
135
|
+
context.instance_variable_set(:@warnings, [])
|
|
136
|
+
context.instance_variable_set(:@validity_errors, [])
|
|
137
|
+
context.instance_variable_set(:@infos, [])
|
|
138
|
+
context.instance_variable_set(:@data, {})
|
|
139
|
+
context.instance_variable_set(:@structurally_invalid_node_ids, Set.new)
|
|
140
|
+
context.instance_variable_set(:@node_id_cache, {})
|
|
141
|
+
context.instance_variable_set(:@cache_populated, true) # Skip population for SAX
|
|
142
|
+
context
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Classify requirements based on validation needs
|
|
146
|
+
def classify_requirements
|
|
147
|
+
return unless @profile.requirements
|
|
148
|
+
|
|
149
|
+
@profile.requirements.each do |req|
|
|
150
|
+
if req.respond_to?(:needs_deferred_validation?) && req.needs_deferred_validation?
|
|
151
|
+
@deferred_requirements << req
|
|
152
|
+
else
|
|
153
|
+
@immediate_requirements << req
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|