svg_conform 0.1.1 → 0.1.3

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 (34) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +437 -14
  3. data/README.adoc +62 -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 +48 -0
  10. data/lib/svg_conform/requirements/allowed_elements_requirement.rb +222 -0
  11. data/lib/svg_conform/requirements/base_requirement.rb +27 -0
  12. data/lib/svg_conform/requirements/color_restrictions_requirement.rb +55 -0
  13. data/lib/svg_conform/requirements/font_family_requirement.rb +22 -0
  14. data/lib/svg_conform/requirements/forbidden_content_requirement.rb +26 -0
  15. data/lib/svg_conform/requirements/id_reference_requirement.rb +99 -0
  16. data/lib/svg_conform/requirements/invalid_id_references_requirement.rb +92 -0
  17. data/lib/svg_conform/requirements/link_validation_requirement.rb +30 -0
  18. data/lib/svg_conform/requirements/namespace_attributes_requirement.rb +65 -0
  19. data/lib/svg_conform/requirements/namespace_requirement.rb +75 -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_promotion_requirement.rb +7 -0
  24. data/lib/svg_conform/requirements/style_requirement.rb +12 -0
  25. data/lib/svg_conform/requirements/viewbox_required_requirement.rb +72 -0
  26. data/lib/svg_conform/sax_document.rb +46 -0
  27. data/lib/svg_conform/sax_validation_handler.rb +162 -0
  28. data/lib/svg_conform/validation_context.rb +87 -3
  29. data/lib/svg_conform/validator.rb +73 -5
  30. data/lib/svg_conform/version.rb +1 -1
  31. data/lib/svg_conform.rb +1 -0
  32. data/spec/spec_helper.rb +3 -0
  33. data/spec/support/shared_examples_for_validation_modes.rb +71 -0
  34. metadata +9 -2
@@ -0,0 +1,67 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Working example based on README.adoc Ruby API usage section
5
+ require_relative "../lib/svg_conform"
6
+
7
+ puts "=" * 60
8
+ puts "README Ruby API Usage Example"
9
+ puts "=" * 60
10
+ puts
11
+
12
+ # Sample SVG content with some issues
13
+ svg_content = <<~SVG
14
+ <svg xmlns="http://www.w3.org/2000/svg" width="100" height="100">
15
+ <rect fill="red" width="50" height="50"/>
16
+ <circle fill="blue" cx="75" cy="75" r="20"/>
17
+ </svg>
18
+ SVG
19
+
20
+ puts "Test SVG content:"
21
+ puts svg_content
22
+ puts
23
+
24
+ # Load a profile and validate
25
+ profile = SvgConform::Profiles.get(:svg_1_2_rfc)
26
+ document = SvgConform::Document.new(svg_content)
27
+ result = profile.validate(document)
28
+
29
+ puts "Validation Results:"
30
+ puts "Valid: #{result.valid?}"
31
+ puts "Errors: #{result.errors.count}"
32
+ puts "Warnings: #{result.warnings.count}"
33
+ puts
34
+
35
+ if result.errors.any?
36
+ puts "Error details:"
37
+ result.errors.each_with_index do |error, i|
38
+ puts " #{i + 1}. [#{error.requirement_id}] #{error.message}"
39
+ end
40
+ puts
41
+ end
42
+
43
+ # Apply remediations to fix issues
44
+ if !result.valid? && profile.remediation_count.positive?
45
+ puts "Applying remediations (profile has #{profile.remediation_count} remediations)..."
46
+ changes = profile.apply_remediations(document)
47
+
48
+ puts "Applied #{changes.length} remediations"
49
+ puts
50
+
51
+ puts "Fixed SVG:"
52
+ puts document.to_xml
53
+ puts
54
+
55
+ # Re-validate to confirm fixes
56
+ result_after = profile.validate(document)
57
+ puts "Re-validation after fixes:"
58
+ puts "Valid: #{result_after.valid?}"
59
+ puts "Errors: #{result_after.errors.count}"
60
+ else
61
+ puts "Document is already valid or no remediations available"
62
+ end
63
+
64
+ puts
65
+ puts "=" * 60
66
+ puts "Example complete!"
67
+ puts "=" * 60
@@ -30,7 +30,7 @@ class RequirementsDemo
30
30
 
31
31
  begin
32
32
  # Load IETF profile from YAML
33
- svg_1_2_rfc_profile = SvgConform::ProfileRegistry.load_profile("svg_1_2_rfc")
33
+ svg_1_2_rfc_profile = SvgConform::Profiles.get(:svg_1_2_rfc)
34
34
  puts "✓ Loaded IETF profile: #{svg_1_2_rfc_profile.name}"
35
35
  puts " Description: #{svg_1_2_rfc_profile.description}"
36
36
  puts " Requirements: #{svg_1_2_rfc_profile.requirement_count}"
@@ -38,7 +38,7 @@ class RequirementsDemo
38
38
  puts
39
39
 
40
40
  # Load Lucid fix profile
41
- lucid_profile = SvgConform::ProfileRegistry.load_profile("lucid_fix")
41
+ lucid_profile = SvgConform::Profiles.get(:lucid_fix)
42
42
  puts "✓ Loaded Lucid Fix profile: #{lucid_profile.name}"
43
43
  puts " Description: #{lucid_profile.description}"
44
44
  puts " Requirements: #{lucid_profile.requirement_count}"
@@ -46,7 +46,7 @@ class RequirementsDemo
46
46
  puts
47
47
 
48
48
  # List available profiles
49
- available = SvgConform::ProfileRegistry.available_profiles
49
+ available = SvgConform::Profiles.available_profiles
50
50
  puts "Available profiles: #{available.join(', ')}"
51
51
  puts
52
52
  rescue StandardError => e
@@ -204,7 +204,7 @@ class RequirementsDemo
204
204
 
205
205
  begin
206
206
  # Load the Lucid fix profile
207
- profile = SvgConform::ProfileRegistry.load_profile("lucid_fix")
207
+ profile = SvgConform::Profiles.get(:lucid_fix)
208
208
 
209
209
  # Process the SVG
210
210
  document = SvgConform::Document.new(lucid_svg)
@@ -16,6 +16,7 @@ module SvgConform
16
16
  @content = content_or_path
17
17
  end
18
18
 
19
+ @xpath_cache = {}
19
20
  parse_document
20
21
  end
21
22
 
@@ -36,7 +37,8 @@ module SvgConform
36
37
  end
37
38
 
38
39
  def xpath(path, namespaces = {})
39
- @moxml_document.xpath(path, namespaces)
40
+ cache_key = [path, namespaces].hash
41
+ @xpath_cache[cache_key] ||= @moxml_document.xpath(path, namespaces)
40
42
  end
41
43
 
42
44
  def traverse(&)
@@ -60,6 +62,10 @@ module SvgConform
60
62
  Document.from_content(to_xml)
61
63
  end
62
64
 
65
+ def clear_cache
66
+ @xpath_cache.clear
67
+ end
68
+
63
69
  def valid_xml?
64
70
  !@moxml_document.nil?
65
71
  rescue StandardError
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SvgConform
4
+ # Lightweight attribute representation for SAX parsing
5
+ class SaxAttribute
6
+ attr_reader :name, :value
7
+
8
+ def initialize(name, value)
9
+ @name = name
10
+ @value = value
11
+ end
12
+
13
+ def namespace
14
+ nil # DOM handles namespaces via XPath; SAX checks attribute names directly
15
+ end
16
+ end
17
+
18
+ # Lightweight element representation during SAX parsing
19
+ # Provides a node-like interface for validation requirements
20
+ # without the overhead of full DOM tree
21
+ class ElementProxy
22
+ attr_reader :name, :position, :path, :parent, :raw_attributes
23
+ attr_accessor :text_content, :child_counters
24
+
25
+ def initialize(name:, attributes:, position:, path:, parent:)
26
+ @name = name
27
+ @raw_attributes = attributes # Hash of attribute name => value
28
+ @position = position
29
+ @path = path # Array of parent path parts
30
+ @parent = parent
31
+ @text_content = +"" # Mutable string
32
+ @child_counters = {} # Track child element positions
33
+ end
34
+
35
+ # Build full path ID for this element
36
+ def path_id
37
+ parts = @path + ["#{@name}[#{@position}]"]
38
+ "/#{parts.join('/')}"
39
+ end
40
+
41
+ # Return attributes as array of SaxAttribute objects (for compatibility)
42
+ def attributes
43
+ @raw_attributes.map { |name, value| SaxAttribute.new(name, value) }
44
+ end
45
+
46
+ # Check if this element has a specific attribute
47
+ def attribute(name)
48
+ value = @raw_attributes[name] || @raw_attributes[name.to_s]
49
+ value ? SaxAttribute.new(name, value) : nil
50
+ end
51
+
52
+ # Get attribute value (alias for compatibility)
53
+ def [](name)
54
+ @raw_attributes[name] || @raw_attributes[name.to_s]
55
+ end
56
+
57
+ # Check if attribute exists
58
+ def has_attribute?(name)
59
+ @raw_attributes.key?(name) || @raw_attributes.key?(name.to_s)
60
+ end
61
+
62
+ # Get namespace from attributes or parent
63
+ def namespace
64
+ @raw_attributes["xmlns"] || @parent&.namespace
65
+ end
66
+
67
+ # Check if this is a text node (always false for ElementProxy)
68
+ def text?
69
+ false
70
+ end
71
+
72
+ # Support dynamic attribute access
73
+ def method_missing(method, *_args)
74
+ if method.to_s.end_with?("?")
75
+ # Boolean check
76
+ has_attribute?(method.to_s.chomp("?"))
77
+ else
78
+ # Attribute access
79
+ @attributes[method.to_s] || @attributes[method.to_sym]
80
+ end
81
+ end
82
+
83
+ def respond_to_missing?(method, include_private = false)
84
+ @raw_attributes.key?(method.to_s) || @raw_attributes.key?(method.to_sym) || super
85
+ end
86
+
87
+ # For compatibility with validation context
88
+ def line
89
+ nil # SAX doesn't provide line numbers easily
90
+ end
91
+
92
+ def column
93
+ nil
94
+ end
95
+
96
+ # Provide a stable identifier for this element
97
+ def element_id
98
+ path_id
99
+ end
100
+ end
101
+ end
@@ -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
+ compute_path_forward(node)
25
+ end
26
+
27
+ private
28
+
29
+ def analyze_document
30
+ # Pre-populate cache is not needed since we compute on demand
31
+ # The optimization comes from forward-counting siblings instead of backward
32
+ end
33
+
34
+ def compute_path_forward(node)
35
+ path_parts = []
36
+ current = node
37
+
38
+ while current
39
+ if current.respond_to?(:name) && current.name
40
+ # Count position among siblings by iterating forward from parent
41
+ position = calculate_position_fast(current)
42
+ path_parts.unshift("#{current.name}[#{position}]")
43
+ end
44
+
45
+ break unless current.respond_to?(:parent)
46
+
47
+ begin
48
+ current = current.parent
49
+ rescue NoMethodError
50
+ break
51
+ end
52
+
53
+ break unless current
54
+ end
55
+
56
+ "/#{path_parts.join('/')}"
57
+ end
58
+
59
+ def calculate_position_fast(node)
60
+ return 1 unless node.respond_to?(:parent)
61
+
62
+ parent = begin
63
+ node.parent
64
+ rescue NoMethodError
65
+ nil
66
+ end
67
+
68
+ return 1 unless parent.respond_to?(:children)
69
+
70
+ # Count this node's position among siblings with same name
71
+ position = 0
72
+ parent.children.each do |child|
73
+ next unless child.respond_to?(:name) && child.name == node.name
74
+
75
+ position += 1
76
+ break if child.equal?(node)
77
+ end
78
+
79
+ position
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,48 @@
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
+
43
+ build_index(child, child_counter)
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -120,6 +120,82 @@ module SvgConform
120
120
  end
121
121
  end
122
122
 
123
+ def validate_sax_element(element, context)
124
+ # Skip if parent is structurally invalid (matches DOM behavior)
125
+ if element.parent && context.node_structurally_invalid?(element.parent)
126
+ # Mark this element as invalid too since it won't be in final document
127
+ context.mark_node_structurally_invalid(element)
128
+ return
129
+ end
130
+
131
+ # Skip foreign namespace elements if configured (let NamespaceRequirement handle them)
132
+ if skip_foreign_namespaces && foreign_namespace_sax?(element)
133
+ return
134
+ end
135
+
136
+ element_name = element.name
137
+
138
+ # Check if element is explicitly disallowed
139
+ if disallowed_element?(element_name)
140
+ context.add_error(
141
+ requirement_id: id,
142
+ message: "Element '#{element_name}' is not allowed in this profile",
143
+ node: element,
144
+ severity: :error,
145
+ data: { element: element_name },
146
+ )
147
+ return
148
+ end
149
+
150
+ # Check parent-child relationships
151
+ if check_parent_child && element.parent
152
+ parent_name = element.parent.name
153
+ if invalid_parent_child?(parent_name, element_name)
154
+ context.add_error(
155
+ requirement_id: id,
156
+ message: "The element '#{element_name}' is not allowed as a child of '#{parent_name}'",
157
+ node: element,
158
+ severity: :error,
159
+ data: { element: element_name, parent: parent_name },
160
+ )
161
+ # Mark node as structurally invalid
162
+ context.mark_node_structurally_invalid(element)
163
+ return
164
+ end
165
+ end
166
+
167
+ # Check if element is in allowed list
168
+ if element_configs&.any?
169
+ allowed_elements = element_configs.map(&:tag)
170
+ unless allowed_elements.include?(element_name)
171
+ context.add_error(
172
+ requirement_id: id,
173
+ message: "Element '#{element_name}' is not allowed in this profile",
174
+ node: element,
175
+ severity: :error,
176
+ data: { element: element_name },
177
+ )
178
+ # Mark as structurally invalid
179
+ context.mark_node_structurally_invalid(element)
180
+ return
181
+ end
182
+ end
183
+
184
+ # Validate attributes
185
+ potential_errors = collect_attribute_errors_sax(element)
186
+ prioritized_errors = prioritize_errors(potential_errors)
187
+
188
+ # Add the prioritized errors to the context
189
+ prioritized_errors.each do |error|
190
+ context.add_error(
191
+ requirement_id: id,
192
+ message: error[:message],
193
+ node: element,
194
+ severity: :error,
195
+ )
196
+ end
197
+ end
198
+
123
199
  private
124
200
 
125
201
  def disallowed_element?(element_name)
@@ -267,6 +343,152 @@ module SvgConform
267
343
  errors
268
344
  end
269
345
 
346
+ def foreign_namespace_sax?(element)
347
+ return false unless skip_foreign_namespaces
348
+
349
+ # Check if element name has a namespace prefix (e.g., rdf:RDF, cc:Work)
350
+ if element.name.include?(":")
351
+ # Element has prefix, it's in a foreign namespace
352
+ return true
353
+ end
354
+
355
+ # Also check xmlns attribute
356
+ element_namespace = element.namespace
357
+
358
+ # No namespace or empty namespace means SVG namespace (default)
359
+ return false if element_namespace.nil? || element_namespace.empty?
360
+
361
+ # Check if namespace is in allowed list
362
+ effective_allowed_namespaces = allowed_namespaces
363
+ if allow_rdf_metadata
364
+ effective_allowed_namespaces = allowed_namespaces + RDF_NAMESPACES
365
+ end
366
+
367
+ return false if effective_allowed_namespaces.empty?
368
+
369
+ !effective_allowed_namespaces.include?(element_namespace)
370
+ end
371
+
372
+ def collect_attribute_errors_sax(element)
373
+ errors = []
374
+
375
+ # Always collect global disallowed attributes first
376
+ errors.concat(collect_global_disallowed_errors_sax(element))
377
+
378
+ # Collect element-specific attribute errors if enabled
379
+ errors.concat(collect_element_attribute_errors_sax(element)) if check_attributes
380
+
381
+ errors
382
+ end
383
+
384
+ def collect_element_attribute_errors_sax(element)
385
+ errors = []
386
+ element_name = element.name
387
+
388
+ return errors unless element_configs&.any?
389
+
390
+ element_config = element_configs.find do |config|
391
+ config.tag == element_name
392
+ end
393
+ return errors unless element_config&.attr
394
+
395
+ allowed_attrs = []
396
+ disallowed_attrs = []
397
+
398
+ # Parse attributes, separating allowed from disallowed (prefixed with !)
399
+ element_config.attr.each do |attribute|
400
+ if attribute.start_with?("!")
401
+ disallowed_attrs << attribute[1..].downcase
402
+ else
403
+ allowed_attrs << attribute.downcase
404
+ end
405
+ end
406
+
407
+ # Add common attributes that are allowed on all elements
408
+ common_attrs = %w[id class style xmlns]
409
+ allowed_attrs = (allowed_attrs + common_attrs).uniq
410
+
411
+ # Add global properties
412
+ global_properties = %w[
413
+ about base baseprofile d break class content cx cy datatype height href
414
+ label lang pathlength points preserveaspectratio property r rel resource
415
+ rev role rotate rx ry space snapshottime transform typeof version width
416
+ viewbox x x1 x2 y y1 y2 stroke stroke-width stroke-linecap stroke-linejoin
417
+ stroke-miterlimit stroke-dasharray stroke-dashoffset stroke-opacity
418
+ vector-effect viewport-fill display viewport-fill-opacity visibility
419
+ image-rendering color-rendering shape-rendering text-rendering
420
+ buffered-rendering solid-opacity solid-color color stop-color stop-opacity
421
+ line-increment text-align display-align font-size font-family font-weight
422
+ font-style font-variant direction unicode-bidi text-anchor fill fill-rule
423
+ fill-opacity requiredfeatures requiredformats requiredextensions
424
+ requiredfonts systemlanguage
425
+ ]
426
+ allowed_attrs = (allowed_attrs + global_properties).uniq
427
+
428
+ element.attributes.each do |attr|
429
+ attr_name = attr.name.downcase
430
+ next if attr_name.start_with?("xmlns:")
431
+ next if attr_name.start_with?("xml:")
432
+
433
+ # Skip namespaced attributes (those with colon prefix like inkscape:label)
434
+ # This matches DOM behavior where XPath skips attributes with namespaces
435
+ next if attr_name.include?(":") && !attr_name.start_with?("xmlns:")
436
+
437
+ next if attr.namespace
438
+ next if attr_name.start_with?("data-")
439
+
440
+ # Check if explicitly disallowed
441
+ if disallowed_attrs.include?(attr_name)
442
+ errors << {
443
+ type: :explicitly_disallowed,
444
+ attribute: attr_name,
445
+ message: "Attribute '#{attr_name}' is explicitly disallowed on element '#{element_name}'",
446
+ }
447
+ next
448
+ end
449
+
450
+ # Check if not in allowed list
451
+ next if allowed_attrs.include?(attr_name)
452
+
453
+ errors << {
454
+ type: :not_allowed,
455
+ attribute: attr_name,
456
+ message: "Attribute '#{attr_name}' is not allowed on element '#{element_name}'",
457
+ }
458
+ end
459
+
460
+ errors
461
+ end
462
+
463
+ def collect_global_disallowed_errors_sax(element)
464
+ errors = []
465
+
466
+ return errors unless element_configs&.any?
467
+
468
+ global_config = element_configs.find { |config| config.tag == "*" }
469
+ return errors unless global_config&.attr
470
+
471
+ global_disallowed = []
472
+ global_config.attr.each do |attribute|
473
+ global_disallowed << attribute[1..].downcase if attribute.start_with?("!")
474
+ end
475
+
476
+ return errors if global_disallowed.empty?
477
+
478
+ element.attributes.each do |attr|
479
+ attr_name = attr.name.downcase
480
+ next unless global_disallowed.include?(attr_name)
481
+
482
+ errors << {
483
+ type: :globally_disallowed,
484
+ attribute: attr_name,
485
+ message: "Attribute '#{attr_name}' is globally disallowed in this profile",
486
+ }
487
+ end
488
+
489
+ errors
490
+ end
491
+
270
492
  def prioritize_errors(errors)
271
493
  # Group errors by attribute name
272
494
  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,61 @@ 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
89
+ lighting-color]
90
+
91
+ color_attributes.each do |attr_name|
92
+ value = element.raw_attributes[attr_name]
93
+ next if value.nil? || value.empty?
94
+
95
+ next if valid_color?(value)
96
+
97
+ context.add_error(
98
+ requirement_id: id,
99
+ message: "Color '#{value}' in attribute '#{attr_name}' is not allowed in this profile",
100
+ node: element,
101
+ severity: :error,
102
+ data: {
103
+ attribute: attr_name,
104
+ value: value,
105
+ element: element.name,
106
+ },
107
+ )
108
+ end
109
+
110
+ # Check style attribute for color properties
111
+ style_value = element.raw_attributes["style"]
112
+ return unless style_value
113
+
114
+ styles = parse_style(style_value)
115
+ color_properties = %w[fill stroke color stop-color flood-color
116
+ lighting-color]
117
+
118
+ color_properties.each do |prop|
119
+ value = styles[prop]
120
+ next if value.nil? || value.empty?
121
+
122
+ next if valid_color?(value)
123
+
124
+ context.add_error(
125
+ requirement_id: id,
126
+ message: "Color '#{value}' in style property '#{prop}' is not allowed in this profile",
127
+ node: element,
128
+ severity: :error,
129
+ data: {
130
+ attribute: prop,
131
+ value: value,
132
+ element: element.name,
133
+ },
134
+ )
135
+ end
136
+ end
137
+
83
138
  private
84
139
 
85
140
  def valid_color?(color)