svg_conform 0.1.0 → 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 (43) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/rake.yml +4 -1
  3. data/.github/workflows/release.yml +6 -2
  4. data/.rubocop_todo.yml +273 -10
  5. data/Gemfile +1 -0
  6. data/README.adoc +54 -37
  7. data/config/profiles/metanorma.yml +4 -4
  8. data/docs/remediation.adoc +541 -542
  9. data/docs/requirements.adoc +800 -357
  10. data/examples/readme_usage.rb +67 -0
  11. data/examples/requirements_demo.rb +4 -4
  12. data/lib/svg_conform/document.rb +7 -1
  13. data/lib/svg_conform/element_proxy.rb +101 -0
  14. data/lib/svg_conform/fast_document_analyzer.rb +82 -0
  15. data/lib/svg_conform/node_index_builder.rb +47 -0
  16. data/lib/svg_conform/remediations/no_external_css_remediation.rb +4 -4
  17. data/lib/svg_conform/requirements/allowed_elements_requirement.rb +202 -0
  18. data/lib/svg_conform/requirements/base_requirement.rb +27 -0
  19. data/lib/svg_conform/requirements/color_restrictions_requirement.rb +53 -0
  20. data/lib/svg_conform/requirements/font_family_requirement.rb +18 -0
  21. data/lib/svg_conform/requirements/forbidden_content_requirement.rb +26 -0
  22. data/lib/svg_conform/requirements/id_reference_requirement.rb +96 -0
  23. data/lib/svg_conform/requirements/invalid_id_references_requirement.rb +91 -0
  24. data/lib/svg_conform/requirements/link_validation_requirement.rb +30 -0
  25. data/lib/svg_conform/requirements/namespace_attributes_requirement.rb +59 -0
  26. data/lib/svg_conform/requirements/namespace_requirement.rb +74 -0
  27. data/lib/svg_conform/requirements/no_external_css_requirement.rb +74 -0
  28. data/lib/svg_conform/requirements/no_external_fonts_requirement.rb +58 -0
  29. data/lib/svg_conform/requirements/no_external_images_requirement.rb +40 -0
  30. data/lib/svg_conform/requirements/style_requirement.rb +12 -0
  31. data/lib/svg_conform/requirements/viewbox_required_requirement.rb +72 -0
  32. data/lib/svg_conform/sax_document.rb +46 -0
  33. data/lib/svg_conform/sax_validation_handler.rb +158 -0
  34. data/lib/svg_conform/validation_context.rb +84 -2
  35. data/lib/svg_conform/validator.rb +74 -6
  36. data/lib/svg_conform/version.rb +1 -1
  37. data/lib/svg_conform.rb +1 -0
  38. data/spec/fixtures/namespace/repair/basic_violations.svg +3 -3
  39. data/spec/fixtures/namespace_attributes/repair/basic_violations.svg +2 -2
  40. data/spec/fixtures/no_external_css/repair/basic_violations.svg +2 -2
  41. data/spec/fixtures/style_promotion/repair/basic_test.svg +2 -2
  42. data/svg_conform.gemspec +1 -1
  43. metadata +12 -6
@@ -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 # Simplified for now
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 = String.new # 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
+ 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
@@ -75,8 +75,8 @@ module SvgConform
75
75
  content = node.text || ""
76
76
  original_content = content.dup
77
77
 
78
- # Remove @import url() statements
79
- content.gsub!(/@import\s+url\s*\(\s*['"]?([^'")\s]+)['"]?\s*\)\s*;?/i) do |match|
78
+ # Remove @import url() statements (including the newline)
79
+ content.gsub!(/@import\s+url\s*\(\s*['"]?([^'")\s]+)['"]?\s*\)\s*;?\s*\n?/i) do |match|
80
80
  url = ::Regexp.last_match(1)
81
81
  if allowed_url?(url)
82
82
  match # Keep allowed URLs
@@ -95,8 +95,8 @@ module SvgConform
95
95
  end
96
96
  end
97
97
 
98
- # Remove @import "url" statements
99
- content.gsub!(/@import\s+['"]([^'"]+)['"]\s*;?/i) do |match|
98
+ # Remove @import "url" statements (including the newline)
99
+ content.gsub!(/@import\s+['"]([^'"]+)['"]\s*;?\s*\n?/i) do |match|
100
100
  url = ::Regexp.last_match(1)
101
101
  if allowed_url?(url)
102
102
  match # Keep allowed URLs
@@ -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)