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.
- checksums.yaml +4 -4
- data/.github/workflows/rake.yml +4 -1
- data/.github/workflows/release.yml +6 -2
- data/.rubocop_todo.yml +273 -10
- data/Gemfile +1 -0
- data/README.adoc +54 -37
- data/config/profiles/metanorma.yml +4 -4
- data/docs/remediation.adoc +541 -542
- data/docs/requirements.adoc +800 -357
- 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/remediations/no_external_css_remediation.rb +4 -4
- 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
- data/spec/fixtures/namespace/repair/basic_violations.svg +3 -3
- data/spec/fixtures/namespace_attributes/repair/basic_violations.svg +2 -2
- data/spec/fixtures/no_external_css/repair/basic_violations.svg +2 -2
- data/spec/fixtures/style_promotion/repair/basic_test.svg +2 -2
- data/svg_conform.gemspec +1 -1
- 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::
|
|
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::
|
|
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::
|
|
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::
|
|
207
|
+
profile = SvgConform::Profiles.get(:lucid_fix)
|
|
208
208
|
|
|
209
209
|
# Process the SVG
|
|
210
210
|
document = SvgConform::Document.new(lucid_svg)
|
data/lib/svg_conform/document.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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)
|