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.
Files changed (31) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +273 -10
  3. data/README.adoc +7 -8
  4. data/examples/readme_usage.rb +67 -0
  5. data/examples/requirements_demo.rb +4 -4
  6. data/lib/svg_conform/document.rb +7 -1
  7. data/lib/svg_conform/element_proxy.rb +101 -0
  8. data/lib/svg_conform/fast_document_analyzer.rb +82 -0
  9. data/lib/svg_conform/node_index_builder.rb +47 -0
  10. data/lib/svg_conform/requirements/allowed_elements_requirement.rb +202 -0
  11. data/lib/svg_conform/requirements/base_requirement.rb +27 -0
  12. data/lib/svg_conform/requirements/color_restrictions_requirement.rb +53 -0
  13. data/lib/svg_conform/requirements/font_family_requirement.rb +18 -0
  14. data/lib/svg_conform/requirements/forbidden_content_requirement.rb +26 -0
  15. data/lib/svg_conform/requirements/id_reference_requirement.rb +96 -0
  16. data/lib/svg_conform/requirements/invalid_id_references_requirement.rb +91 -0
  17. data/lib/svg_conform/requirements/link_validation_requirement.rb +30 -0
  18. data/lib/svg_conform/requirements/namespace_attributes_requirement.rb +59 -0
  19. data/lib/svg_conform/requirements/namespace_requirement.rb +74 -0
  20. data/lib/svg_conform/requirements/no_external_css_requirement.rb +74 -0
  21. data/lib/svg_conform/requirements/no_external_fonts_requirement.rb +58 -0
  22. data/lib/svg_conform/requirements/no_external_images_requirement.rb +40 -0
  23. data/lib/svg_conform/requirements/style_requirement.rb +12 -0
  24. data/lib/svg_conform/requirements/viewbox_required_requirement.rb +72 -0
  25. data/lib/svg_conform/sax_document.rb +46 -0
  26. data/lib/svg_conform/sax_validation_handler.rb +158 -0
  27. data/lib/svg_conform/validation_context.rb +84 -2
  28. data/lib/svg_conform/validator.rb +74 -6
  29. data/lib/svg_conform/version.rb +1 -1
  30. data/lib/svg_conform.rb +1 -0
  31. 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)