svg_conform 0.1.7 → 0.1.8

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.
@@ -27,49 +27,39 @@ module SvgConform
27
27
  def check(node, context)
28
28
  return unless element?(node)
29
29
 
30
- # Check font-family attribute only (not style properties)
31
- # Style properties are handled by StylePromotionRequirement to avoid duplication
32
- font_family = get_attribute(node, "font-family")
33
- return unless font_family
34
-
35
- if svgcheck_compatibility
36
- check_font_family_svgcheck_mode(node, context, font_family,
37
- "font-family")
38
- elsif !valid_font_family?(font_family)
39
- context.add_error(
40
- requirement_id: id,
41
- message: "Font family '#{font_family}' is not allowed in this profile",
42
- node: node,
43
- severity: :error,
44
- data: { attribute: "font-family", value: font_family },
45
- )
46
- end
30
+ validate_font_family(node, context)
47
31
  end
48
32
 
49
33
  def validate_sax_element(element, context)
50
- # Skip if parent is structurally invalid
34
+ # Skip if parent is structurally invalid (SAX-specific: DOM handles this via should_check_node?)
51
35
  return if element.parent && context.node_structurally_invalid?(element.parent)
52
36
 
53
- # Check font-family attribute only
54
- font_family = element.raw_attributes["font-family"]
37
+ validate_font_family(element, context)
38
+ end
39
+
40
+ private
41
+
42
+ # Shared validation logic for both DOM and SAX modes
43
+ def validate_font_family(node_or_element, context)
44
+ # Check font-family attribute only (not style properties)
45
+ # Style properties are handled by StylePromotionRequirement to avoid duplication
46
+ font_family = get_attribute(node_or_element, "font-family")
55
47
  return unless font_family
56
48
 
57
49
  if svgcheck_compatibility
58
- check_font_family_svgcheck_mode(element, context, font_family,
50
+ check_font_family_svgcheck_mode(node_or_element, context, font_family,
59
51
  "font-family")
60
52
  elsif !valid_font_family?(font_family)
61
53
  context.add_error(
62
54
  requirement_id: id,
63
55
  message: "Font family '#{font_family}' is not allowed in this profile",
64
- node: element,
56
+ node: node_or_element,
65
57
  severity: :error,
66
58
  data: { attribute: "font-family", value: font_family },
67
59
  )
68
60
  end
69
61
  end
70
62
 
71
- private
72
-
73
63
  def check_font_family_svgcheck_mode(node, context, font_family_value,
74
64
  attribute_name)
75
65
  # Check if the font family value is valid according to svgcheck
@@ -57,7 +57,7 @@ module SvgConform
57
57
 
58
58
  # Validate collected style elements
59
59
  @collected_style_elements.each do |element|
60
- check_style_element_sax(element, context)
60
+ check_style_element(element, context)
61
61
  end
62
62
 
63
63
  # Reset for next validation
@@ -79,17 +79,22 @@ module SvgConform
79
79
  # Style elements handled in deferred validation (need text content)
80
80
  # Already collected in collect_sax_data
81
81
  when "link"
82
- check_link_element_sax(element, context) if check_link_elements
82
+ check_link_element(element, context) if check_link_elements
83
83
  else
84
- check_style_attribute_sax(element, context) if check_style_attributes
84
+ check_style_attribute(element, context) if check_style_attributes
85
85
  end
86
86
  end
87
87
 
88
88
  private
89
89
 
90
- def check_style_element(node, context)
90
+ def check_style_element(node_or_element, context)
91
91
  # Check for @import rules in style elements
92
- content = node.text || ""
92
+ # Handle both DOM nodes (node.text) and SAX elements (element.text_content)
93
+ content = if node_or_element.respond_to?(:text_content)
94
+ node_or_element.text_content
95
+ else
96
+ node_or_element.text || ""
97
+ end
93
98
 
94
99
  if content =~ /@import\s+url\s*\(\s*['"]?([^'")\s]+)['"]?\s*\)/i
95
100
  url = ::Regexp.last_match(1)
@@ -97,7 +102,7 @@ module SvgConform
97
102
  context.add_error(
98
103
  requirement_id: id,
99
104
  message: "External CSS import not allowed: #{url}",
100
- node: node,
105
+ node: node_or_element,
101
106
  severity: :error,
102
107
  )
103
108
  end
@@ -111,14 +116,14 @@ module SvgConform
111
116
  context.add_error(
112
117
  requirement_id: id,
113
118
  message: "External CSS import not allowed: #{url}",
114
- node: node,
119
+ node: node_or_element,
115
120
  severity: :error,
116
121
  )
117
122
  end
118
123
 
119
- def check_link_element(node, context)
120
- rel = get_attribute(node, "rel")
121
- href = get_attribute(node, "href")
124
+ def check_link_element(node_or_element, context)
125
+ rel = get_attribute(node_or_element, "rel")
126
+ href = get_attribute(node_or_element, "href")
122
127
 
123
128
  return unless rel&.downcase == "stylesheet" && href
124
129
 
@@ -127,13 +132,13 @@ module SvgConform
127
132
  context.add_error(
128
133
  requirement_id: id,
129
134
  message: "External CSS link not allowed: #{href}",
130
- node: node,
135
+ node: node_or_element,
131
136
  severity: :error,
132
137
  )
133
138
  end
134
139
 
135
- def check_style_attribute(node, context)
136
- style_value = get_attribute(node, "style")
140
+ def check_style_attribute(node_or_element, context)
141
+ style_value = get_attribute(node_or_element, "style")
137
142
  return unless style_value
138
143
 
139
144
  # Check for url() references in style attributes
@@ -145,70 +150,7 @@ module SvgConform
145
150
  context.add_error(
146
151
  requirement_id: id,
147
152
  message: "External URL reference in style attribute not allowed: #{url}",
148
- node: node,
149
- severity: :error,
150
- )
151
- end
152
-
153
- def check_style_element_sax(element, context)
154
- # Check for @import rules in style elements
155
- content = element.text_content
156
-
157
- if content =~ /@import\s+url\s*\(\s*['"]?([^'")\s]+)['"]?\s*\)/i
158
- url = ::Regexp.last_match(1)
159
- unless allowed_url?(url)
160
- context.add_error(
161
- requirement_id: id,
162
- message: "External CSS import not allowed: #{url}",
163
- node: element,
164
- severity: :error,
165
- )
166
- end
167
- end
168
-
169
- return unless content =~ /@import\s+['"]([^'"]+)['"]/i
170
-
171
- url = ::Regexp.last_match(1)
172
- return if allowed_url?(url)
173
-
174
- context.add_error(
175
- requirement_id: id,
176
- message: "External CSS import not allowed: #{url}",
177
- node: element,
178
- severity: :error,
179
- )
180
- end
181
-
182
- def check_link_element_sax(element, context)
183
- rel = element.raw_attributes["rel"]
184
- href = element.raw_attributes["href"]
185
-
186
- return unless rel&.downcase == "stylesheet" && href
187
-
188
- return if allowed_url?(href)
189
-
190
- context.add_error(
191
- requirement_id: id,
192
- message: "External CSS link not allowed: #{href}",
193
- node: element,
194
- severity: :error,
195
- )
196
- end
197
-
198
- def check_style_attribute_sax(element, context)
199
- style_value = element.raw_attributes["style"]
200
- return unless style_value
201
-
202
- # Check for url() references in style attributes
203
- return unless style_value =~ /url\s*\(\s*['"]?([^'")\s]+)['"]?\s*\)/i
204
-
205
- url = ::Regexp.last_match(1)
206
- return if allowed_url?(url)
207
-
208
- context.add_error(
209
- requirement_id: id,
210
- message: "External URL reference in style attribute not allowed: #{url}",
211
- node: element,
153
+ node: node_or_element,
212
154
  severity: :error,
213
155
  )
214
156
  end
@@ -18,96 +18,41 @@ module SvgConform
18
18
  root = document.root
19
19
  return unless root&.name == "svg"
20
20
 
21
- viewbox = get_attribute(root, "viewBox")
22
-
23
- if viewbox.nil? || viewbox.empty?
24
- context.add_error(
25
- requirement: self,
26
- node: root,
27
- message: "SVG root element must have a viewBox attribute",
28
- data: { missing_attribute: "viewBox" },
29
- )
30
-
31
- # Add informational message about calculated viewBox if width/height are present
32
- width = get_attribute(root, "width")
33
- height = get_attribute(root, "height")
34
-
35
- if width && height && valid_number?(width) && valid_number?(height)
36
- calculated_viewbox = "0 0 #{width.to_f} #{height.to_f}"
37
- context.add_error(
38
- requirement: self,
39
- node: root,
40
- message: "Trying to put in the attribute with value '#{calculated_viewbox}'",
41
- data: {
42
- calculated_viewbox: calculated_viewbox,
43
- source_width: width,
44
- source_height: height,
45
- },
46
- )
47
- end
48
- return
49
- end
50
-
51
- # Validate viewBox format (should be "min-x min-y width height")
52
- # Also accept comma-separated with parentheses like "(0, 0, 100, 100)" (svgcheck is lenient)
53
- normalized_viewbox = viewbox.strip.gsub(/[(),]/, " ").squeeze(" ")
54
- parts = normalized_viewbox.strip.split(/\s+/)
55
- unless parts.length == 4 && parts.all? { |part| valid_number?(part) }
56
- context.add_error(
57
- requirement: self,
58
- node: root,
59
- message: "viewBox attribute must contain four numeric values (min-x min-y width height)",
60
- data: {
61
- viewbox_value: viewbox,
62
- parsed_parts: parts,
63
- },
64
- )
65
- return
66
- end
67
-
68
- # Check that width and height are positive
69
- width = parts[2].to_f
70
- height = parts[3].to_f
71
-
72
- return unless width <= 0 || height <= 0
73
-
74
- context.add_error(
75
- requirement: self,
76
- node: root,
77
- message: "viewBox width and height must be positive values",
78
- data: {
79
- viewbox_value: viewbox,
80
- width: width,
81
- height: height,
82
- },
83
- )
21
+ validate_viewbox(root, context)
84
22
  end
85
23
 
86
24
  def validate_sax_element(element, context)
87
25
  # Only check the root SVG element
88
26
  return unless element.name == "svg" && element.parent.nil?
89
27
 
90
- viewbox = element.raw_attributes["viewBox"]
28
+ validate_viewbox(element, context)
29
+ end
30
+
31
+ private
32
+
33
+ # Shared validation logic for both DOM and SAX modes
34
+ def validate_viewbox(svg_root, context)
35
+ viewbox = get_attribute(svg_root, "viewBox")
91
36
 
92
37
  if viewbox.nil? || viewbox.empty?
93
38
  context.add_error(
94
39
  requirement_id: id,
95
40
  message: "SVG root element must have a viewBox attribute",
96
- node: element,
41
+ node: svg_root,
97
42
  severity: :error,
98
43
  data: { missing_attribute: "viewBox" },
99
44
  )
100
45
 
101
46
  # Add informational message about calculated viewBox if width/height are present
102
- width = element.raw_attributes["width"]
103
- height = element.raw_attributes["height"]
47
+ width = get_attribute(svg_root, "width")
48
+ height = get_attribute(svg_root, "height")
104
49
 
105
50
  if width && height && valid_number?(width) && valid_number?(height)
106
51
  calculated_viewbox = "0 0 #{width.to_f} #{height.to_f}"
107
52
  context.add_error(
108
53
  requirement_id: id,
109
54
  message: "Trying to put in the attribute with value '#{calculated_viewbox}'",
110
- node: element,
55
+ node: svg_root,
111
56
  severity: :error,
112
57
  data: {
113
58
  calculated_viewbox: calculated_viewbox,
@@ -120,13 +65,14 @@ module SvgConform
120
65
  end
121
66
 
122
67
  # Validate viewBox format (should be "min-x min-y width height")
68
+ # Also accept comma-separated with parentheses like "(0, 0, 100, 100)" (svgcheck is lenient)
123
69
  normalized_viewbox = viewbox.strip.gsub(/[(),]/, " ").squeeze(" ")
124
70
  parts = normalized_viewbox.strip.split(/\s+/)
125
71
  unless parts.length == 4 && parts.all? { |part| valid_number?(part) }
126
72
  context.add_error(
127
73
  requirement_id: id,
128
74
  message: "viewBox attribute must contain four numeric values (min-x min-y width height)",
129
- node: element,
75
+ node: svg_root,
130
76
  severity: :error,
131
77
  data: {
132
78
  viewbox_value: viewbox,
@@ -145,7 +91,7 @@ module SvgConform
145
91
  context.add_error(
146
92
  requirement_id: id,
147
93
  message: "viewBox width and height must be positive values",
148
- node: element,
94
+ node: svg_root,
149
95
  severity: :error,
150
96
  data: {
151
97
  viewbox_value: viewbox,
@@ -155,8 +101,6 @@ module SvgConform
155
101
  )
156
102
  end
157
103
 
158
- private
159
-
160
104
  def valid_number?(str)
161
105
  Float(str)
162
106
  true
@@ -3,6 +3,9 @@
3
3
  require "nokogiri"
4
4
  require_relative "element_proxy"
5
5
  require_relative "validation_context"
6
+ require_relative "validation/error_tracker"
7
+ require_relative "validation/structural_invalidity_tracker"
8
+ require_relative "validation/node_id_manager"
6
9
  require_relative "validation_result"
7
10
  require_relative "references"
8
11
 
@@ -139,14 +142,19 @@ module SvgConform
139
142
  context = ValidationContext.allocate
140
143
  context.instance_variable_set(:@document, nil)
141
144
  context.instance_variable_set(:@profile, @profile)
142
- context.instance_variable_set(:@errors, [])
143
- context.instance_variable_set(:@warnings, [])
144
- context.instance_variable_set(:@validity_errors, [])
145
- context.instance_variable_set(:@infos, [])
145
+ context.instance_variable_set(:@error_tracker, Validation::ErrorTracker.new)
146
+ context.instance_variable_set(:@fixes, [])
146
147
  context.instance_variable_set(:@data, {})
147
- context.instance_variable_set(:@structurally_invalid_node_ids, Set.new)
148
- context.instance_variable_set(:@node_id_cache, {})
149
- context.instance_variable_set(:@cache_populated, true) # Skip population for SAX
148
+ # Create NodeIdManager without a document (SAX mode)
149
+ node_id_manager = Validation::NodeIdManager.new(nil)
150
+ context.instance_variable_set(:@node_id_manager, node_id_manager)
151
+ # Create StructuralInvalidityTracker with a node ID generator
152
+ context.instance_variable_set(:@structural_invalidity_tracker,
153
+ Validation::StructuralInvalidityTracker.new(
154
+ node_id_generator: ->(node) {
155
+ node_id_manager.generate_node_id(node)
156
+ },
157
+ ))
150
158
  context.instance_variable_set(:@reference_manifest,
151
159
  References::ReferenceManifest.new(source_document: nil))
152
160
  context
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SvgConform
4
+ module Validation
5
+ # Tracks validation errors, warnings, and informational messages
6
+ # Separated from ValidationContext for better separation of concerns
7
+ #
8
+ # Note: This class references ValidationContext::ValidationIssue which is
9
+ # defined in ValidationContext. ValidationContext must be loaded first.
10
+ class ErrorTracker
11
+ attr_reader :errors, :warnings, :validity_errors, :infos
12
+
13
+ def initialize
14
+ @errors = []
15
+ @warnings = []
16
+ @validity_errors = []
17
+ @infos = []
18
+ end
19
+
20
+ # Add an error to the context
21
+ def add_error(node:, message:, rule: nil, requirement: nil,
22
+ requirement_id: nil, severity: nil, fix: nil, data: {})
23
+ # Support both old rule system and new requirements system
24
+ rule_or_requirement = requirement || rule
25
+
26
+ error = ::SvgConform::Errors::ValidationIssue.new(
27
+ type: :error,
28
+ rule: rule_or_requirement,
29
+ node: node,
30
+ message: message,
31
+ fix: fix,
32
+ data: data,
33
+ requirement_id: requirement_id,
34
+ severity: severity,
35
+ )
36
+
37
+ # Handle special severity types
38
+ if severity == :validity_error
39
+ @validity_errors << error
40
+ else
41
+ @errors << error
42
+ end
43
+
44
+ error
45
+ end
46
+
47
+ # Add a warning to the context
48
+ def add_warning(rule:, node:, message:, fix: nil)
49
+ warning = ::SvgConform::Errors::ValidationIssue.new(
50
+ type: :warning,
51
+ rule: rule,
52
+ node: node,
53
+ message: message,
54
+ fix: fix,
55
+ )
56
+ @warnings << warning
57
+ warning
58
+ end
59
+
60
+ # Add an informational notice to the context
61
+ def add_notice(rule:, node:, message:, fix: nil, data: {}, type: :info)
62
+ notice = ::SvgConform::Errors::ValidationIssue.new(
63
+ type: type,
64
+ rule: rule,
65
+ node: node,
66
+ message: message,
67
+ fix: fix,
68
+ data: data,
69
+ )
70
+ @infos << notice
71
+ notice
72
+ end
73
+
74
+ # Check if there are any errors
75
+ def has_errors?
76
+ @errors.any?
77
+ end
78
+
79
+ # Check if there are any warnings
80
+ def has_warnings?
81
+ @warnings.any?
82
+ end
83
+
84
+ # Check if there are any validity errors
85
+ def has_validity_errors?
86
+ @validity_errors.any?
87
+ end
88
+
89
+ # Get total error count (including validity errors)
90
+ def total_error_count
91
+ @errors.length + @validity_errors.length
92
+ end
93
+
94
+ # Clear all tracked issues
95
+ def clear
96
+ @errors.clear
97
+ @warnings.clear
98
+ @validity_errors.clear
99
+ @infos.clear
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../document_analyzer"
4
+
5
+ module SvgConform
6
+ module Validation
7
+ # Manages node ID generation for validation contexts
8
+ # Wraps DocumentAnalyzer for DOM mode and handles ElementProxy for SAX mode
9
+ class NodeIdManager
10
+ def initialize(document = nil)
11
+ @document = document
12
+ @analyzer = nil # Lazy-loaded, only needed for DOM/remediation mode
13
+ end
14
+
15
+ # Generate a unique identifier for a node based on its path
16
+ # Uses DocumentAnalyzer for efficient forward-counting algorithm (DOM mode)
17
+ # For SAX mode, uses the pre-computed path from ElementProxy
18
+ def generate_node_id(node)
19
+ # In SAX mode, node may be an ElementProxy with pre-computed path
20
+ return node.path_id if node.respond_to?(:path_id)
21
+
22
+ # Lazy-load the analyzer only when needed (DOM/remediation mode)
23
+ @analyzer ||= DocumentAnalyzer.new(@document) if @document
24
+ return nil unless @analyzer
25
+
26
+ @analyzer.get_node_id(node)
27
+ end
28
+
29
+ # Check if the manager has a document for DOM-based ID generation
30
+ def dom_mode?
31
+ !@document.nil?
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+
5
+ module SvgConform
6
+ module Validation
7
+ # Tracks structurally invalid nodes during validation
8
+ # Separated from ValidationContext for better separation of concerns
9
+ class StructuralInvalidityTracker
10
+ def initialize(node_id_generator:)
11
+ @structurally_invalid_node_ids = Set.new
12
+ @node_id_generator = node_id_generator
13
+ end
14
+
15
+ # Mark a node as structurally invalid (e.g., invalid parent-child relationship)
16
+ # Other requirements should skip attribute validation on these nodes
17
+ # Also marks all descendants as invalid since they'll be removed with the parent
18
+ def mark_node_structurally_invalid(node)
19
+ node_id = @node_id_generator.call(node)
20
+ return if node_id.nil? # Safety check
21
+
22
+ @structurally_invalid_node_ids.add(node_id)
23
+
24
+ # Mark all descendants as invalid too
25
+ mark_descendants_invalid(node)
26
+ end
27
+
28
+ # Mark all descendants of a node as structurally invalid
29
+ def mark_descendants_invalid(node)
30
+ # In SAX mode, ElementProxy doesn't have children yet
31
+ # Children will be validated individually and will check parent validity
32
+ return unless node.respond_to?(:children) && node.children
33
+
34
+ node.children.each do |child|
35
+ child_id = @node_id_generator.call(child)
36
+ next if child_id.nil? # Skip if can't generate ID
37
+
38
+ @structurally_invalid_node_ids.add(child_id)
39
+ # Recursively mark descendants
40
+ mark_descendants_invalid(child)
41
+ end
42
+ end
43
+
44
+ # Check if a node is structurally invalid
45
+ def node_structurally_invalid?(node)
46
+ node_id = @node_id_generator.call(node)
47
+ return false if node_id.nil? # Safety check
48
+
49
+ @structurally_invalid_node_ids.include?(node_id)
50
+ end
51
+
52
+ # Clear all tracked invalid nodes
53
+ def clear
54
+ @structurally_invalid_node_ids.clear
55
+ end
56
+
57
+ # Get the count of structurally invalid nodes
58
+ def count
59
+ @structurally_invalid_node_ids.size
60
+ end
61
+ end
62
+ end
63
+ end