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
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SvgConform
|
|
4
|
+
# Fast document analyzer that pre-computes node relationships and IDs
|
|
5
|
+
# in a single pass to avoid repeated expensive traversals
|
|
6
|
+
#
|
|
7
|
+
# IMPORTANT: Stores paths indexed by the path itself (not object_id)
|
|
8
|
+
# since node object_ids change between traversals
|
|
9
|
+
class FastDocumentAnalyzer
|
|
10
|
+
attr_reader :path_cache
|
|
11
|
+
|
|
12
|
+
def initialize(document)
|
|
13
|
+
@document = document
|
|
14
|
+
@path_cache = {}
|
|
15
|
+
|
|
16
|
+
analyze_document
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Get cached path-based ID for a node by computing its path once
|
|
20
|
+
def get_node_id(node)
|
|
21
|
+
return nil unless node.respond_to?(:name) && node.name
|
|
22
|
+
|
|
23
|
+
# Compute path for this node (fast with forward traversal counting)
|
|
24
|
+
path = compute_path_forward(node)
|
|
25
|
+
path
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def analyze_document
|
|
31
|
+
# Pre-populate cache is not needed since we compute on demand
|
|
32
|
+
# The optimization comes from forward-counting siblings instead of backward
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def compute_path_forward(node)
|
|
36
|
+
path_parts = []
|
|
37
|
+
current = node
|
|
38
|
+
|
|
39
|
+
while current
|
|
40
|
+
if current.respond_to?(:name) && current.name
|
|
41
|
+
# Count position among siblings by iterating forward from parent
|
|
42
|
+
position = calculate_position_fast(current)
|
|
43
|
+
path_parts.unshift("#{current.name}[#{position}]")
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
break unless current.respond_to?(:parent)
|
|
47
|
+
|
|
48
|
+
begin
|
|
49
|
+
current = current.parent
|
|
50
|
+
rescue NoMethodError
|
|
51
|
+
break
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
break unless current
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
"/#{path_parts.join('/')}"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def calculate_position_fast(node)
|
|
61
|
+
return 1 unless node.respond_to?(:parent)
|
|
62
|
+
|
|
63
|
+
parent = begin
|
|
64
|
+
node.parent
|
|
65
|
+
rescue NoMethodError
|
|
66
|
+
nil
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
return 1 unless parent && parent.respond_to?(:children)
|
|
70
|
+
|
|
71
|
+
# Count this node's position among siblings with same name
|
|
72
|
+
position = 0
|
|
73
|
+
parent.children.each do |child|
|
|
74
|
+
next unless child.respond_to?(:name) && child.name == node.name
|
|
75
|
+
position += 1
|
|
76
|
+
break if child.object_id == node.object_id
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
position
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SvgConform
|
|
4
|
+
# Builds an index of node positions in single forward pass
|
|
5
|
+
# Replicates the exact logic of backward sibling traversal but computed forward
|
|
6
|
+
class NodeIndexBuilder
|
|
7
|
+
def initialize(document)
|
|
8
|
+
@node_positions = {}
|
|
9
|
+
build_index(document.root) if document.root
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# Get position of node among its siblings with same name
|
|
13
|
+
def get_position(node)
|
|
14
|
+
@node_positions[node]
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
# Build position index by traversing forward but tracking as if counting backward
|
|
20
|
+
# This matches the original logic: position = count of (previous siblings with same name) + 1
|
|
21
|
+
def build_index(node, parent_child_counter = nil)
|
|
22
|
+
return unless node.respond_to?(:name) && node.name
|
|
23
|
+
|
|
24
|
+
# If this is root or we don't have parent's counter, create new counter
|
|
25
|
+
if parent_child_counter.nil?
|
|
26
|
+
# This is the root - position is always 1
|
|
27
|
+
@node_positions[node] = 1
|
|
28
|
+
else
|
|
29
|
+
# Increment counter for this node name
|
|
30
|
+
parent_child_counter[node.name] ||= 0
|
|
31
|
+
parent_child_counter[node.name] += 1
|
|
32
|
+
@node_positions[node] = parent_child_counter[node.name]
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Process children with a fresh counter for this parent's children
|
|
36
|
+
if node.respond_to?(:children)
|
|
37
|
+
# Create new counter for children of this node
|
|
38
|
+
child_counter = {}
|
|
39
|
+
node.children.each do |child|
|
|
40
|
+
# Only process nodes with name (skip text nodes, etc.)
|
|
41
|
+
next unless child.respond_to?(:name) && child.name
|
|
42
|
+
build_index(child, child_counter)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -120,6 +120,75 @@ module SvgConform
|
|
|
120
120
|
end
|
|
121
121
|
end
|
|
122
122
|
|
|
123
|
+
def validate_sax_element(element, context)
|
|
124
|
+
# Skip foreign namespace elements if configured (let NamespaceRequirement handle them)
|
|
125
|
+
if skip_foreign_namespaces && foreign_namespace_sax?(element)
|
|
126
|
+
return
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
element_name = element.name
|
|
130
|
+
|
|
131
|
+
# Check if element is explicitly disallowed
|
|
132
|
+
if disallowed_element?(element_name)
|
|
133
|
+
context.add_error(
|
|
134
|
+
requirement_id: id,
|
|
135
|
+
message: "Element '#{element_name}' is not allowed in this profile",
|
|
136
|
+
node: element,
|
|
137
|
+
severity: :error,
|
|
138
|
+
data: { element: element_name }
|
|
139
|
+
)
|
|
140
|
+
return
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Check parent-child relationships
|
|
144
|
+
if check_parent_child && element.parent
|
|
145
|
+
parent_name = element.parent.name
|
|
146
|
+
if invalid_parent_child?(parent_name, element_name)
|
|
147
|
+
context.add_error(
|
|
148
|
+
requirement_id: id,
|
|
149
|
+
message: "The element '#{element_name}' is not allowed as a child of '#{parent_name}'",
|
|
150
|
+
node: element,
|
|
151
|
+
severity: :error,
|
|
152
|
+
data: { element: element_name, parent: parent_name }
|
|
153
|
+
)
|
|
154
|
+
# Mark node as structurally invalid
|
|
155
|
+
context.mark_node_structurally_invalid(element)
|
|
156
|
+
return
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Check if element is in allowed list
|
|
161
|
+
if element_configs&.any?
|
|
162
|
+
allowed_elements = element_configs.map(&:tag)
|
|
163
|
+
unless allowed_elements.include?(element_name)
|
|
164
|
+
context.add_error(
|
|
165
|
+
requirement_id: id,
|
|
166
|
+
message: "Element '#{element_name}' is not allowed in this profile",
|
|
167
|
+
node: element,
|
|
168
|
+
severity: :error,
|
|
169
|
+
data: { element: element_name }
|
|
170
|
+
)
|
|
171
|
+
# Mark as structurally invalid
|
|
172
|
+
context.mark_node_structurally_invalid(element)
|
|
173
|
+
return
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Validate attributes
|
|
178
|
+
potential_errors = collect_attribute_errors_sax(element)
|
|
179
|
+
prioritized_errors = prioritize_errors(potential_errors)
|
|
180
|
+
|
|
181
|
+
# Add the prioritized errors to the context
|
|
182
|
+
prioritized_errors.each do |error|
|
|
183
|
+
context.add_error(
|
|
184
|
+
requirement_id: id,
|
|
185
|
+
message: error[:message],
|
|
186
|
+
node: element,
|
|
187
|
+
severity: :error
|
|
188
|
+
)
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
123
192
|
private
|
|
124
193
|
|
|
125
194
|
def disallowed_element?(element_name)
|
|
@@ -267,6 +336,139 @@ module SvgConform
|
|
|
267
336
|
errors
|
|
268
337
|
end
|
|
269
338
|
|
|
339
|
+
def foreign_namespace_sax?(element)
|
|
340
|
+
return false unless skip_foreign_namespaces
|
|
341
|
+
|
|
342
|
+
# Check if element has a namespace
|
|
343
|
+
element_namespace = element.namespace
|
|
344
|
+
|
|
345
|
+
# No namespace or empty namespace means SVG namespace (default)
|
|
346
|
+
return false if element_namespace.nil? || element_namespace.empty?
|
|
347
|
+
|
|
348
|
+
# Check if namespace is in allowed list
|
|
349
|
+
effective_allowed_namespaces = allowed_namespaces
|
|
350
|
+
if allow_rdf_metadata
|
|
351
|
+
effective_allowed_namespaces = allowed_namespaces + RDF_NAMESPACES
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
return false if effective_allowed_namespaces.empty?
|
|
355
|
+
|
|
356
|
+
!effective_allowed_namespaces.include?(element_namespace)
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
def collect_attribute_errors_sax(element)
|
|
360
|
+
errors = []
|
|
361
|
+
|
|
362
|
+
# Always collect global disallowed attributes first
|
|
363
|
+
errors.concat(collect_global_disallowed_errors_sax(element))
|
|
364
|
+
|
|
365
|
+
# Collect element-specific attribute errors if enabled
|
|
366
|
+
errors.concat(collect_element_attribute_errors_sax(element)) if check_attributes
|
|
367
|
+
|
|
368
|
+
errors
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
def collect_element_attribute_errors_sax(element)
|
|
372
|
+
errors = []
|
|
373
|
+
element_name = element.name
|
|
374
|
+
|
|
375
|
+
return errors unless element_configs&.any?
|
|
376
|
+
|
|
377
|
+
element_config = element_configs.find { |config| config.tag == element_name }
|
|
378
|
+
return errors unless element_config&.attr
|
|
379
|
+
|
|
380
|
+
allowed_attrs = []
|
|
381
|
+
disallowed_attrs = []
|
|
382
|
+
|
|
383
|
+
# Parse attributes, separating allowed from disallowed (prefixed with !)
|
|
384
|
+
element_config.attr.each do |attribute|
|
|
385
|
+
if attribute.start_with?("!")
|
|
386
|
+
disallowed_attrs << attribute[1..].downcase
|
|
387
|
+
else
|
|
388
|
+
allowed_attrs << attribute.downcase
|
|
389
|
+
end
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
# Add common attributes that are allowed on all elements
|
|
393
|
+
common_attrs = %w[id class style xmlns]
|
|
394
|
+
allowed_attrs = (allowed_attrs + common_attrs).uniq
|
|
395
|
+
|
|
396
|
+
# Add global properties
|
|
397
|
+
global_properties = %w[
|
|
398
|
+
about base baseprofile d break class content cx cy datatype height href
|
|
399
|
+
label lang pathlength points preserveaspectratio property r rel resource
|
|
400
|
+
rev role rotate rx ry space snapshottime transform typeof version width
|
|
401
|
+
viewbox x x1 x2 y y1 y2 stroke stroke-width stroke-linecap stroke-linejoin
|
|
402
|
+
stroke-miterlimit stroke-dasharray stroke-dashoffset stroke-opacity
|
|
403
|
+
vector-effect viewport-fill display viewport-fill-opacity visibility
|
|
404
|
+
image-rendering color-rendering shape-rendering text-rendering
|
|
405
|
+
buffered-rendering solid-opacity solid-color color stop-color stop-opacity
|
|
406
|
+
line-increment text-align display-align font-size font-family font-weight
|
|
407
|
+
font-style font-variant direction unicode-bidi text-anchor fill fill-rule
|
|
408
|
+
fill-opacity requiredfeatures requiredformats requiredextensions
|
|
409
|
+
requiredfonts systemlanguage
|
|
410
|
+
]
|
|
411
|
+
allowed_attrs = (allowed_attrs + global_properties).uniq
|
|
412
|
+
|
|
413
|
+
element.attributes.each do |attr|
|
|
414
|
+
attr_name = attr.name.downcase
|
|
415
|
+
next if attr_name.start_with?("xmlns:")
|
|
416
|
+
next if attr_name.start_with?("xml:")
|
|
417
|
+
next if attr.namespace
|
|
418
|
+
next if attr_name.start_with?("data-")
|
|
419
|
+
|
|
420
|
+
# Check if explicitly disallowed
|
|
421
|
+
if disallowed_attrs.include?(attr_name)
|
|
422
|
+
errors << {
|
|
423
|
+
type: :explicitly_disallowed,
|
|
424
|
+
attribute: attr_name,
|
|
425
|
+
message: "Attribute '#{attr_name}' is explicitly disallowed on element '#{element_name}'"
|
|
426
|
+
}
|
|
427
|
+
next
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
# Check if not in allowed list
|
|
431
|
+
next if allowed_attrs.include?(attr_name)
|
|
432
|
+
|
|
433
|
+
errors << {
|
|
434
|
+
type: :not_allowed,
|
|
435
|
+
attribute: attr_name,
|
|
436
|
+
message: "Attribute '#{attr_name}' is not allowed on element '#{element_name}'"
|
|
437
|
+
}
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
errors
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
def collect_global_disallowed_errors_sax(element)
|
|
444
|
+
errors = []
|
|
445
|
+
|
|
446
|
+
return errors unless element_configs&.any?
|
|
447
|
+
|
|
448
|
+
global_config = element_configs.find { |config| config.tag == "*" }
|
|
449
|
+
return errors unless global_config&.attr
|
|
450
|
+
|
|
451
|
+
global_disallowed = []
|
|
452
|
+
global_config.attr.each do |attribute|
|
|
453
|
+
global_disallowed << attribute[1..].downcase if attribute.start_with?("!")
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
return errors if global_disallowed.empty?
|
|
457
|
+
|
|
458
|
+
element.attributes.each do |attr|
|
|
459
|
+
attr_name = attr.name.downcase
|
|
460
|
+
next unless global_disallowed.include?(attr_name)
|
|
461
|
+
|
|
462
|
+
errors << {
|
|
463
|
+
type: :globally_disallowed,
|
|
464
|
+
attribute: attr_name,
|
|
465
|
+
message: "Attribute '#{attr_name}' is globally disallowed in this profile"
|
|
466
|
+
}
|
|
467
|
+
end
|
|
468
|
+
|
|
469
|
+
errors
|
|
470
|
+
end
|
|
471
|
+
|
|
270
472
|
def prioritize_errors(errors)
|
|
271
473
|
# Group errors by attribute name
|
|
272
474
|
errors_by_attr = errors.group_by { |error| error[:attribute] }
|
|
@@ -28,6 +28,33 @@ module SvgConform
|
|
|
28
28
|
end
|
|
29
29
|
end
|
|
30
30
|
|
|
31
|
+
# SAX-based validation methods (NEW for streaming validation)
|
|
32
|
+
|
|
33
|
+
# Called for each element during SAX parsing
|
|
34
|
+
# Override in subclasses for immediate validation
|
|
35
|
+
def validate_sax_element(element, context)
|
|
36
|
+
# Default: Empty - subclasses must override for SAX support
|
|
37
|
+
# Cannot call check() here as it's abstract
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Called for each element to collect data for deferred validation
|
|
41
|
+
# Override in subclasses that need to collect data
|
|
42
|
+
def collect_sax_data(element, context)
|
|
43
|
+
# Default: no data collection
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Called at end of document for deferred validation
|
|
47
|
+
# Override in subclasses that need full document data
|
|
48
|
+
def validate_sax_complete(context)
|
|
49
|
+
# Default: no deferred validation
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Indicates if requirement needs deferred validation
|
|
53
|
+
# Override to return true for requirements that need forward references
|
|
54
|
+
def needs_deferred_validation?
|
|
55
|
+
false
|
|
56
|
+
end
|
|
57
|
+
|
|
31
58
|
# Determine if this requirement should check a specific node
|
|
32
59
|
def should_check_node?(node, context = nil)
|
|
33
60
|
return false unless node.respond_to?(:name) && node.respond_to?(:attributes)
|
|
@@ -80,6 +80,59 @@ module SvgConform
|
|
|
80
80
|
end
|
|
81
81
|
end
|
|
82
82
|
|
|
83
|
+
def validate_sax_element(element, context)
|
|
84
|
+
# Skip attribute validation for structurally invalid nodes
|
|
85
|
+
return if context.node_structurally_invalid?(element)
|
|
86
|
+
|
|
87
|
+
# Check color-related attributes
|
|
88
|
+
color_attributes = %w[fill stroke color stop-color flood-color lighting-color]
|
|
89
|
+
|
|
90
|
+
color_attributes.each do |attr_name|
|
|
91
|
+
value = element.raw_attributes[attr_name]
|
|
92
|
+
next if value.nil? || value.empty?
|
|
93
|
+
|
|
94
|
+
next if valid_color?(value)
|
|
95
|
+
|
|
96
|
+
context.add_error(
|
|
97
|
+
requirement_id: id,
|
|
98
|
+
message: "Color '#{value}' in attribute '#{attr_name}' is not allowed in this profile",
|
|
99
|
+
node: element,
|
|
100
|
+
severity: :error,
|
|
101
|
+
data: {
|
|
102
|
+
attribute: attr_name,
|
|
103
|
+
value: value,
|
|
104
|
+
element: element.name
|
|
105
|
+
}
|
|
106
|
+
)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Check style attribute for color properties
|
|
110
|
+
style_value = element.raw_attributes["style"]
|
|
111
|
+
return unless style_value
|
|
112
|
+
|
|
113
|
+
styles = parse_style(style_value)
|
|
114
|
+
color_properties = %w[fill stroke color stop-color flood-color lighting-color]
|
|
115
|
+
|
|
116
|
+
color_properties.each do |prop|
|
|
117
|
+
value = styles[prop]
|
|
118
|
+
next if value.nil? || value.empty?
|
|
119
|
+
|
|
120
|
+
next if valid_color?(value)
|
|
121
|
+
|
|
122
|
+
context.add_error(
|
|
123
|
+
requirement_id: id,
|
|
124
|
+
message: "Color '#{value}' in style property '#{prop}' is not allowed in this profile",
|
|
125
|
+
node: element,
|
|
126
|
+
severity: :error,
|
|
127
|
+
data: {
|
|
128
|
+
attribute: prop,
|
|
129
|
+
value: value,
|
|
130
|
+
element: element.name
|
|
131
|
+
}
|
|
132
|
+
)
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
83
136
|
private
|
|
84
137
|
|
|
85
138
|
def valid_color?(color)
|
|
@@ -46,6 +46,24 @@ module SvgConform
|
|
|
46
46
|
end
|
|
47
47
|
end
|
|
48
48
|
|
|
49
|
+
def validate_sax_element(element, context)
|
|
50
|
+
# Check font-family attribute only
|
|
51
|
+
font_family = element.raw_attributes["font-family"]
|
|
52
|
+
return unless font_family
|
|
53
|
+
|
|
54
|
+
if svgcheck_compatibility
|
|
55
|
+
check_font_family_svgcheck_mode(element, context, font_family, "font-family")
|
|
56
|
+
elsif !valid_font_family?(font_family)
|
|
57
|
+
context.add_error(
|
|
58
|
+
requirement_id: id,
|
|
59
|
+
message: "Font family '#{font_family}' is not allowed in this profile",
|
|
60
|
+
node: element,
|
|
61
|
+
severity: :error,
|
|
62
|
+
data: { attribute: "font-family", value: font_family }
|
|
63
|
+
)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
49
67
|
private
|
|
50
68
|
|
|
51
69
|
def check_font_family_svgcheck_mode(node, context, font_family_value,
|
|
@@ -55,6 +55,32 @@ module SvgConform
|
|
|
55
55
|
end
|
|
56
56
|
end
|
|
57
57
|
end
|
|
58
|
+
|
|
59
|
+
def validate_sax_element(element, context)
|
|
60
|
+
# Check if this is a forbidden element
|
|
61
|
+
if forbidden_elements.include?(element.name)
|
|
62
|
+
context.add_error(
|
|
63
|
+
requirement_id: id,
|
|
64
|
+
message: "Forbidden element '#{element.name}' is not allowed",
|
|
65
|
+
node: element,
|
|
66
|
+
severity: :error
|
|
67
|
+
)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Check for forbidden attributes
|
|
71
|
+
element.attributes.each do |attr|
|
|
72
|
+
attr_name = attr.name
|
|
73
|
+
|
|
74
|
+
if forbidden_attributes.include?(attr_name)
|
|
75
|
+
context.add_error(
|
|
76
|
+
requirement_id: id,
|
|
77
|
+
message: "Forbidden attribute '#{attr_name}' is not allowed",
|
|
78
|
+
node: element,
|
|
79
|
+
severity: :error
|
|
80
|
+
)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
58
84
|
end
|
|
59
85
|
end
|
|
60
86
|
end
|
|
@@ -6,6 +6,102 @@ require "set"
|
|
|
6
6
|
module SvgConform
|
|
7
7
|
module Requirements
|
|
8
8
|
class IdReferenceRequirement < BaseRequirement
|
|
9
|
+
def needs_deferred_validation?
|
|
10
|
+
true
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def collect_sax_data(element, context)
|
|
14
|
+
# Initialize collections on first call
|
|
15
|
+
@collected_ids ||= Set.new
|
|
16
|
+
@collected_url_refs ||= []
|
|
17
|
+
@collected_href_refs ||= []
|
|
18
|
+
@collected_other_refs ||= []
|
|
19
|
+
|
|
20
|
+
# Collect IDs
|
|
21
|
+
id_value = element.raw_attributes["id"]
|
|
22
|
+
@collected_ids.add(id_value) if id_value && !id_value.empty?
|
|
23
|
+
|
|
24
|
+
# Collect url() references
|
|
25
|
+
url_attributes = %w[fill stroke marker-start marker-mid marker-end clip-path mask filter]
|
|
26
|
+
url_attributes.each do |attr_name|
|
|
27
|
+
attr_value = element.raw_attributes[attr_name]
|
|
28
|
+
next unless attr_value
|
|
29
|
+
|
|
30
|
+
url_refs = extract_url_references(attr_value)
|
|
31
|
+
url_refs.each do |ref_id|
|
|
32
|
+
@collected_url_refs << [element, ref_id, attr_name]
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Check style attribute for url() references
|
|
37
|
+
style_attr = element.raw_attributes["style"]
|
|
38
|
+
if style_attr
|
|
39
|
+
url_refs = extract_url_references(style_attr)
|
|
40
|
+
url_refs.each do |ref_id|
|
|
41
|
+
@collected_url_refs << [element, ref_id, "style"]
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Collect href references
|
|
46
|
+
href_value = element.raw_attributes["href"] || element.raw_attributes["xlink:href"]
|
|
47
|
+
if href_value&.start_with?("#")
|
|
48
|
+
ref_id = href_value[1..] # Remove #
|
|
49
|
+
@collected_href_refs << [element, ref_id]
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Collect other ID references
|
|
53
|
+
id_ref_attributes = %w[for aria-labelledby aria-describedby aria-controls aria-owns]
|
|
54
|
+
id_ref_attributes.each do |attr_name|
|
|
55
|
+
attr_value = element.raw_attributes[attr_name]
|
|
56
|
+
next unless attr_value
|
|
57
|
+
|
|
58
|
+
ref_ids = attr_value.split(/\s+/)
|
|
59
|
+
ref_ids.each do |ref_id|
|
|
60
|
+
next if ref_id.empty?
|
|
61
|
+
@collected_other_refs << [element, ref_id, attr_name]
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def validate_sax_complete(context)
|
|
67
|
+
# Validate all collected references
|
|
68
|
+
@collected_url_refs.each do |element, ref_id, attr_name|
|
|
69
|
+
next if @collected_ids.include?(ref_id)
|
|
70
|
+
|
|
71
|
+
message = if attr_name == "style"
|
|
72
|
+
"Reference to undefined ID '#{ref_id}' in style attribute"
|
|
73
|
+
else
|
|
74
|
+
"Reference to undefined ID '#{ref_id}' in attribute '#{attr_name}'"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
context.add_error(
|
|
78
|
+
node: element,
|
|
79
|
+
message: message,
|
|
80
|
+
requirement_id: id
|
|
81
|
+
)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
@collected_href_refs.each do |element, ref_id|
|
|
85
|
+
next if @collected_ids.include?(ref_id)
|
|
86
|
+
|
|
87
|
+
context.add_error(
|
|
88
|
+
node: element,
|
|
89
|
+
message: "Reference to undefined ID '#{ref_id}' in href attribute",
|
|
90
|
+
requirement_id: id
|
|
91
|
+
)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
@collected_other_refs.each do |element, ref_id, attr_name|
|
|
95
|
+
next if @collected_ids.include?(ref_id)
|
|
96
|
+
|
|
97
|
+
context.add_error(
|
|
98
|
+
node: element,
|
|
99
|
+
message: "Reference to undefined ID '#{ref_id}' in #{attr_name} attribute",
|
|
100
|
+
requirement_id: id
|
|
101
|
+
)
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
9
105
|
def validate_document(document, context)
|
|
10
106
|
# Collect all IDs in the document
|
|
11
107
|
ids = Set.new
|
|
@@ -21,6 +21,97 @@ module SvgConform
|
|
|
21
21
|
map "strict_mode", to: :strict_mode
|
|
22
22
|
end
|
|
23
23
|
|
|
24
|
+
def initialize(*args)
|
|
25
|
+
super
|
|
26
|
+
@collected_ids = Set.new
|
|
27
|
+
@use_element_refs = [] # [element, ref_id, href]
|
|
28
|
+
@other_refs = [] # [element, ref_id, attr_name, value]
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def needs_deferred_validation?
|
|
32
|
+
true
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def collect_sax_data(element, context)
|
|
36
|
+
# Initialize collections on first call
|
|
37
|
+
@collected_ids ||= Set.new
|
|
38
|
+
@use_element_refs ||= []
|
|
39
|
+
@other_refs ||= []
|
|
40
|
+
|
|
41
|
+
# Collect IDs
|
|
42
|
+
id_attr = element.raw_attributes["id"]
|
|
43
|
+
@collected_ids.add(id_attr) if id_attr && !id_attr.empty?
|
|
44
|
+
|
|
45
|
+
# Collect use element references
|
|
46
|
+
if check_use_elements && element.name == "use"
|
|
47
|
+
href = element.raw_attributes["xlink:href"] || element.raw_attributes["href"]
|
|
48
|
+
if href&.start_with?("#")
|
|
49
|
+
ref_id = href[1..]
|
|
50
|
+
@use_element_refs << [element, ref_id, href] unless ref_id.empty?
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Collect other ID references if enabled
|
|
55
|
+
if check_other_references
|
|
56
|
+
id_reference_attributes = %w[clip-path mask filter marker-start marker-mid marker-end fill stroke]
|
|
57
|
+
|
|
58
|
+
id_reference_attributes.each do |attr_name|
|
|
59
|
+
attr_value = element.raw_attributes[attr_name]
|
|
60
|
+
next unless attr_value&.match?(/^url\(#(.+)\)$/)
|
|
61
|
+
|
|
62
|
+
ref_id = Regexp.last_match(1)
|
|
63
|
+
@other_refs << [element, ref_id, attr_name, attr_value]
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Check style attribute
|
|
67
|
+
style_value = element.raw_attributes["style"]
|
|
68
|
+
if style_value
|
|
69
|
+
styles = parse_style(style_value)
|
|
70
|
+
styles.each do |property, value|
|
|
71
|
+
next unless value&.match?(/^url\(#(.+)\)$/)
|
|
72
|
+
|
|
73
|
+
ref_id = Regexp.last_match(1)
|
|
74
|
+
@other_refs << [element, ref_id, "style:#{property}", value]
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def validate_sax_complete(context)
|
|
81
|
+
# Validate use element references
|
|
82
|
+
@use_element_refs.each do |element, ref_id, href|
|
|
83
|
+
next if @collected_ids.include?(ref_id)
|
|
84
|
+
|
|
85
|
+
context.add_error(
|
|
86
|
+
requirement_id: id,
|
|
87
|
+
node: element,
|
|
88
|
+
message: "use element references non-existent ID: #{ref_id}",
|
|
89
|
+
severity: :error,
|
|
90
|
+
data: { invalid_id: ref_id, href: href }
|
|
91
|
+
)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Validate other references if enabled
|
|
95
|
+
@other_refs.each do |element, ref_id, attr_name, value|
|
|
96
|
+
next if @collected_ids.include?(ref_id)
|
|
97
|
+
|
|
98
|
+
message = if attr_name.start_with?("style:")
|
|
99
|
+
property = attr_name.split(":", 2)[1]
|
|
100
|
+
"style property #{property} references non-existent ID: #{ref_id}"
|
|
101
|
+
else
|
|
102
|
+
"#{attr_name} references non-existent ID: #{ref_id}"
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
context.add_error(
|
|
106
|
+
requirement_id: id,
|
|
107
|
+
node: element,
|
|
108
|
+
message: message,
|
|
109
|
+
severity: :error,
|
|
110
|
+
data: { invalid_id: ref_id, attribute: attr_name, value: value }
|
|
111
|
+
)
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
24
115
|
def validate_document(document, context)
|
|
25
116
|
# Collect all existing IDs in the document
|
|
26
117
|
existing_ids = collect_existing_ids(document)
|