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.
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SvgConform
4
+ module Errors
5
+ # Base class for all SvgConform error types
6
+ #
7
+ # Provides common interface for error and notice objects
8
+ # used during validation.
9
+ class Base
10
+ attr_reader :type, :node, :message
11
+
12
+ # Initialize a new error/notice
13
+ #
14
+ # @param type [Symbol] The type (:error, :warning, :info, etc.)
15
+ # @param node [Object] The node associated with this error
16
+ # @param message [String] The error message
17
+ def initialize(type:, node:, message:)
18
+ @type = type
19
+ @node = node
20
+ @message = message
21
+ end
22
+
23
+ # Get the line number of the error
24
+ #
25
+ # @return [Integer, nil] The line number if available
26
+ def line
27
+ @node.respond_to?(:line) ? @node.line : nil
28
+ end
29
+
30
+ # Get the column number of the error
31
+ #
32
+ # @return [Integer, nil] The column number if available
33
+ def column
34
+ @node.respond_to?(:column) ? @node.column : nil
35
+ end
36
+
37
+ # Get the element name of the error
38
+ #
39
+ # @return [String, nil] The element name if available
40
+ def element_name
41
+ @node.respond_to?(:name) ? @node.name : nil
42
+ end
43
+
44
+ # Convert error to hash representation
45
+ #
46
+ # @return [Hash] Hash representation of the error
47
+ def to_h
48
+ {
49
+ type: @type,
50
+ message: @message,
51
+ line: line,
52
+ column: column,
53
+ element: element_name,
54
+ }
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,245 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module SvgConform
6
+ module Errors
7
+ # Validation issue (error, warning, or notice)
8
+ #
9
+ # Represents a single validation issue found during SVG validation.
10
+ # Contains information about the issue including type, rule, node,
11
+ # message, fix, and remediation information.
12
+ class ValidationIssue < Base
13
+ attr_reader :rule, :fix, :data,
14
+ :requirement_id_override, :severity, :violation_type
15
+
16
+ def initialize(type:, rule:, node:, message:, fix: nil, data: {},
17
+ requirement_id: nil, severity: nil, violation_type: nil)
18
+ super(type: type, node: node, message: message)
19
+ @rule = rule
20
+ @fix = fix
21
+ @data = data
22
+ @requirement_id_override = requirement_id
23
+ @severity = severity
24
+ @violation_type = violation_type || detect_violation_type
25
+ end
26
+
27
+ def requirement_id
28
+ return @requirement_id_override.to_s if @requirement_id_override
29
+
30
+ if @rule.respond_to?(:id)
31
+ @rule.id.to_s
32
+ elsif @rule.respond_to?(:class) && @rule.class.respond_to?(:name)
33
+ # Extract ID from class name for requirements
34
+ class_name = @rule.class.name.split("::").last
35
+ class_name.gsub(/Requirement$/, "").downcase.gsub(/([a-z])([A-Z])/,
36
+ '\1_\2').downcase
37
+ else
38
+ "unknown"
39
+ end
40
+ end
41
+
42
+ def error?
43
+ @type == :error
44
+ end
45
+
46
+ def warning?
47
+ @type == :warning
48
+ end
49
+
50
+ def fixable?
51
+ !@fix.nil?
52
+ end
53
+
54
+ def line
55
+ @node.respond_to?(:line) ? @node.line : nil
56
+ end
57
+
58
+ def column
59
+ @node.respond_to?(:column) ? @node.column : nil
60
+ end
61
+
62
+ def element_name
63
+ @node.respond_to?(:name) ? @node.name : nil
64
+ end
65
+
66
+ def apply_fix
67
+ return false unless fixable?
68
+
69
+ begin
70
+ if @fix.respond_to?(:call)
71
+ @fix.call
72
+ else
73
+ @fix.apply
74
+ end
75
+ true
76
+ rescue StandardError
77
+ false
78
+ end
79
+ end
80
+
81
+ # Check if this issue is remediable
82
+ def remediable?
83
+ case @violation_type
84
+ when :color_violation, :font_violation, :content_violation, :reference_violation,
85
+ :namespace_violation, :viewbox_violation, :style_violation
86
+ true
87
+ when :structural_violation
88
+ false
89
+ else
90
+ # Default to remediable for backward compatibility
91
+ true
92
+ end
93
+ end
94
+
95
+ # Get the type of remediation needed
96
+ def remediation_type
97
+ case @violation_type
98
+ when :color_violation, :font_violation
99
+ :convert
100
+ when :content_violation, :reference_violation, :namespace_violation
101
+ :remove
102
+ when :viewbox_violation
103
+ :add
104
+ when :style_violation
105
+ :promote
106
+ else
107
+ :unknown
108
+ end
109
+ end
110
+
111
+ # Get the confidence level of automated remediation
112
+ def remediation_confidence
113
+ case @violation_type
114
+ when :color_violation, :font_violation, :style_violation
115
+ :automatic
116
+ when :viewbox_violation, :namespace_violation
117
+ :safe_structural
118
+ when :content_violation, :reference_violation
119
+ :safe_removal
120
+ else
121
+ :manual_review
122
+ end
123
+ end
124
+
125
+ # Get the suggested action to fix this issue
126
+ def suggested_action
127
+ case @violation_type
128
+ when :color_violation
129
+ "Convert color to allowed equivalent using CssColor"
130
+ when :font_violation
131
+ "Map font family to generic equivalent"
132
+ when :content_violation
133
+ "Remove forbidden element or attribute"
134
+ when :reference_violation
135
+ "Remove broken reference or containing element"
136
+ when :namespace_violation
137
+ "Fix namespace declarations and remove invalid elements/attributes"
138
+ when :viewbox_violation
139
+ "Add missing viewBox attribute"
140
+ when :style_violation
141
+ "Promote style properties to attributes"
142
+ when :structural_violation
143
+ "Manual fix required - document structure issue"
144
+ else
145
+ "Apply available remediation"
146
+ end
147
+ end
148
+
149
+ # Check if this issue affects document content
150
+ def affects_content?
151
+ case @violation_type
152
+ when :content_violation, :reference_violation
153
+ true
154
+ when :color_violation, :font_violation, :style_violation, :viewbox_violation, :namespace_violation
155
+ false
156
+ else
157
+ false
158
+ end
159
+ end
160
+
161
+ def to_h
162
+ {
163
+ type: @type,
164
+ rule: rule_id,
165
+ message: @message,
166
+ line: line,
167
+ column: column,
168
+ element: element_name,
169
+ fixable: fixable?,
170
+ remediable: remediable?,
171
+ violation_type: @violation_type,
172
+ remediation_type: remediation_type,
173
+ remediation_confidence: remediation_confidence,
174
+ suggested_action: suggested_action,
175
+ affects_content: affects_content?,
176
+ }
177
+ end
178
+
179
+ def to_s
180
+ location = line ? " at line #{line}" : ""
181
+ location += ":#{column}" if column
182
+ rule_info = rule_id ? " (#{rule_id})" : ""
183
+ remediation_info = remediable? ? " [#{remediation_type}]" : " [NOT REMEDIABLE]"
184
+ "#{@message}#{location}#{rule_info}#{remediation_info}"
185
+ end
186
+
187
+ private
188
+
189
+ def detect_violation_type
190
+ req_id = requirement_id.downcase
191
+ msg = @message.downcase
192
+
193
+ # First check for structural violations (non-remediable)
194
+ return :structural_violation if msg.include?("root element must be") ||
195
+ msg.include?("malformed") ||
196
+ msg.include?("invalid document") ||
197
+ msg.include?("required element missing") ||
198
+ msg.include?("invalid hierarchy")
199
+
200
+ # Then check requirement-based violations (remediable)
201
+ case req_id
202
+ when /color/
203
+ :color_violation
204
+ when /font/
205
+ :font_violation
206
+ when /forbidden|content/
207
+ :content_violation
208
+ when /reference|id/
209
+ :reference_violation
210
+ when /namespace/
211
+ :namespace_violation
212
+ when /viewbox/
213
+ :viewbox_violation
214
+ when /style/
215
+ :style_violation
216
+ else
217
+ # Check message content for clues
218
+ return :color_violation if msg.include?("color")
219
+ return :font_violation if msg.include?("font")
220
+ return :content_violation if msg.include?("forbidden")
221
+ return :reference_violation if msg.include?("reference") || msg.include?("href")
222
+ return :namespace_violation if msg.include?("namespace")
223
+ return :viewbox_violation if msg.include?("viewbox")
224
+ return :style_violation if msg.include?("style")
225
+
226
+ :unknown_violation
227
+ end
228
+ end
229
+
230
+ def rule_id
231
+ return @requirement_id_override if @requirement_id_override
232
+ return @rule.id if @rule.respond_to?(:id)
233
+
234
+ if @rule.respond_to?(:class) && @rule.class.respond_to?(:name)
235
+ # Extract ID from class name for requirements
236
+ class_name = @rule.class.name.split("::").last
237
+ class_name.gsub(/Requirement$/, "").downcase.gsub(/([a-z])([A-Z])/,
238
+ '\1_\2').downcase
239
+ else
240
+ "unknown"
241
+ end
242
+ end
243
+ end
244
+ end
245
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+
5
+ module SvgConform
6
+ module Errors
7
+ # Validation notice (informational message, not an error)
8
+ #
9
+ # Represents informational notices during validation, such as
10
+ # external reference notices or other non-error messages.
11
+ class ValidationNotice < Base
12
+ attr_reader :data
13
+
14
+ def initialize(type:, node:, message:, data: {})
15
+ super(type: type, node: node, message: message)
16
+ @data = data
17
+ end
18
+
19
+ def to_h
20
+ {
21
+ type: @type,
22
+ message: @message,
23
+ line: line,
24
+ column: column,
25
+ data: @data,
26
+ }
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,177 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SvgConform
4
+ module Interfaces
5
+ # Defines the contract for validation requirements
6
+ #
7
+ # All requirements must implement the methods defined in this interface.
8
+ # This ensures consistent behavior between DOM and SAX validation modes.
9
+ #
10
+ # @example Implementing a requirement
11
+ # class MyRequirement < BaseRequirement
12
+ # include RequirementInterface
13
+ #
14
+ # def check(node, context)
15
+ # # DOM validation logic here
16
+ # end
17
+ #
18
+ # def validate_sax_element(element, context)
19
+ # # SAX validation logic here
20
+ # end
21
+ # end
22
+ module RequirementInterface
23
+ # Required Methods
24
+ # These methods must be implemented by all requirement classes
25
+
26
+ # Validates a single node during DOM traversal
27
+ #
28
+ # This is the main validation method called for each node that passes
29
+ # the should_check_node? filter during DOM-based validation.
30
+ #
31
+ # @param node [Moxml::Node, Nokogiri::XML::Node] The node to validate
32
+ # @param context [ValidationContext] The validation context for reporting errors
33
+ # @return [void]
34
+ # @raise [NotImplementedError] If not implemented by subclass
35
+ #
36
+ # @example Basic implementation
37
+ # def check(node, context)
38
+ # if invalid_condition?(node)
39
+ # context.add_error(
40
+ # requirement_id: id,
41
+ # message: "Node violates requirement",
42
+ # node: node,
43
+ # severity: :error,
44
+ # )
45
+ # end
46
+ # end
47
+ def check(node, context)
48
+ raise NotImplementedError, "#{self.class} must implement #check"
49
+ end
50
+
51
+ # Optional Methods
52
+ # These methods have default implementations but can be overridden
53
+
54
+ # Validates the entire document (called once per requirement)
55
+ #
56
+ # Default implementation traverses the document and calls #check
57
+ # on each node that passes should_check_node?. Override for custom
58
+ # document-level validation logic.
59
+ #
60
+ # @param document [Document] The document to validate
61
+ # @param context [ValidationContext] The validation context for reporting errors
62
+ # @return [void]
63
+ def validate_document(document, context)
64
+ document.traverse do |node|
65
+ check(node, context) if should_check_node?(node, context)
66
+ end
67
+ end
68
+
69
+ # Validates an element during SAX parsing
70
+ #
71
+ # Called for each element during streaming SAX validation.
72
+ # Override this method to implement SAX-compatible validation logic.
73
+ #
74
+ # IMPORTANT: In SAX mode, the element is an ElementProxy (not a full DOM node).
75
+ # It provides: name, attributes, parent, path, position but NOT:
76
+ # - children (not yet parsed)
77
+ # - tree traversal (impossible during streaming)
78
+ # - XPath queries (not available)
79
+ #
80
+ # Use ElementProxy methods:
81
+ # - element.name - element tag name
82
+ # - element.attributes - hash of attributes
83
+ # - element.parent - parent ElementProxy
84
+ # - element.path - array of element names representing path
85
+ # - element.position - 1-based position in document
86
+ # - element.path_id - unique node ID for tracking
87
+ #
88
+ # @param element [ElementProxy] The element being validated
89
+ # @param context [ValidationContext] The validation context for reporting errors
90
+ # @return [void]
91
+ def validate_sax_element(element, context)
92
+ # Default: Empty - subclasses must override for SAX support
93
+ end
94
+
95
+ # Collects data during SAX parsing for deferred validation
96
+ #
97
+ # Called for each element during SAX parsing to collect data
98
+ # that will be used later in validate_sax_complete.
99
+ #
100
+ # Use this for requirements that need to collect references, IDs,
101
+ # or other data before validating (e.g., ID/reference validation).
102
+ #
103
+ # @param element [ElementProxy] The element being examined
104
+ # @param context [ValidationContext] The validation context
105
+ # @return [void]
106
+ def collect_sax_data(element, context)
107
+ # Default: no data collection
108
+ end
109
+
110
+ # Performs deferred validation after document is fully parsed
111
+ #
112
+ # Called once after SAX parsing completes. Use this to validate
113
+ # relationships that require forward references (e.g., ID references,
114
+ # cross-element constraints).
115
+ #
116
+ # Override this method and return true from needs_deferred_validation?
117
+ # to enable deferred validation for this requirement.
118
+ #
119
+ # @param context [ValidationContext] The validation context containing
120
+ # collected data and for reporting errors
121
+ # @return [void]
122
+ def validate_sax_complete(context)
123
+ # Default: no deferred validation
124
+ end
125
+
126
+ # Indicates if this requirement needs deferred validation
127
+ #
128
+ # Return true if this requirement needs to validate after the full
129
+ # document is parsed (e.g., for ID/reference validation).
130
+ #
131
+ # When returning true, also implement validate_sax_complete.
132
+ #
133
+ # @return [Boolean] true if deferred validation is needed
134
+ def needs_deferred_validation?
135
+ false
136
+ end
137
+
138
+ # Determines if this requirement should check a specific node
139
+ #
140
+ # Return false to skip validation for a node. The default implementation
141
+ # skips nodes that are not elements (text nodes, comments, etc.) and
142
+ # structurally invalid nodes.
143
+ #
144
+ # Override this to add additional filtering logic.
145
+ #
146
+ # @param node [Moxml::Node, Nokogiri::XML::Node, ElementProxy] The node to check
147
+ # @param context [ValidationContext, nil] The validation context (may be nil in some cases)
148
+ # @return [Boolean] true if the node should be validated
149
+ def should_check_node?(node, context = nil)
150
+ return false unless node.respond_to?(:name) && node.respond_to?(:attributes)
151
+
152
+ # Skip structurally invalid nodes
153
+ return false if context&.node_structurally_invalid?(node)
154
+
155
+ true
156
+ end
157
+
158
+ # Resets any state between validations (for batch mode)
159
+ #
160
+ # Called between validations when validating multiple files.
161
+ # Override this method if your requirement maintains state that
162
+ # needs to be reset.
163
+ #
164
+ # @return [void]
165
+ def reset_state
166
+ # Default: no state to reset
167
+ end
168
+
169
+ # Returns a string representation of the requirement
170
+ #
171
+ # @return [String] The requirement ID and description
172
+ def to_s
173
+ "#{@id}: #{@description}"
174
+ end
175
+ end
176
+ end
177
+ end
@@ -177,7 +177,8 @@ module SvgConform
177
177
  # Store the prefixes to remove on the document for later serialization
178
178
  # Try to store on Document wrapper first, fallback to moxml_document
179
179
  target = document.respond_to?(:instance_variable_set) ? document : root.document
180
- target&.instance_variable_set(:@unused_namespace_prefixes, disallowed_prefixes)
180
+ target&.instance_variable_set(:@unused_namespace_prefixes,
181
+ disallowed_prefixes)
181
182
  end
182
183
 
183
184
  def find_used_namespace_prefixes(document)
@@ -1,12 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../node_helpers"
4
+ require_relative "../interfaces/requirement_interface"
4
5
 
5
6
  module SvgConform
6
7
  module Requirements
7
8
  # Base class for all validation requirements
8
9
  class BaseRequirement < Lutaml::Model::Serializable
9
10
  include SvgConform::NodeHelpers
11
+ include SvgConform::Interfaces::RequirementInterface
10
12
 
11
13
  attribute :id, :string
12
14
  attribute :description, :string
@@ -81,6 +83,20 @@ module SvgConform
81
83
  end
82
84
  end
83
85
 
86
+ # Shared helper for skipping attribute validation on structurally invalid nodes
87
+ # Used by both DOM and SAX validation
88
+ def skip_attribute_validation?(node_or_element, context)
89
+ # For DOM mode, also check if it's an element
90
+ return true if element?(node_or_element) && context.node_structurally_invalid?(node_or_element)
91
+
92
+ # For SAX mode (ElementProxy always has this method)
93
+ if node_or_element.respond_to?(:path_id)
94
+ return context.node_structurally_invalid?(node_or_element)
95
+ end
96
+
97
+ false
98
+ end
99
+
84
100
  def to_s
85
101
  "#{@id}: #{@description}"
86
102
  end
@@ -26,70 +26,30 @@ module SvgConform
26
26
  def check(node, context)
27
27
  return unless element?(node)
28
28
 
29
- # Skip attribute validation for structurally invalid nodes (e.g., wrong parent-child)
30
- return if context.node_structurally_invalid?(node)
31
-
32
- # Check color-related attributes
33
- color_attributes = %w[fill stroke color stop-color flood-color
34
- lighting-color]
35
-
36
- color_attributes.each do |attr_name|
37
- value = get_attribute(node, attr_name)
38
- next if value.nil? || value.empty?
39
-
40
- next if valid_color?(value)
41
-
42
- context.add_error(
43
- requirement_id: id,
44
- message: "Color '#{value}' in attribute '#{attr_name}' is not allowed in this profile",
45
- node: node,
46
- severity: :error,
47
- data: {
48
- attribute: attr_name,
49
- value: value,
50
- element: node.name,
51
- },
52
- )
53
- end
54
-
55
- # Check style attribute for color properties
56
- style_value = get_attribute(node, "style")
57
- return unless style_value
58
-
59
- styles = parse_style(style_value)
60
- color_properties = %w[fill stroke color stop-color flood-color
61
- lighting-color]
62
-
63
- color_properties.each do |prop|
64
- value = styles[prop]
65
- next if value.nil? || value.empty?
66
-
67
- next if valid_color?(value)
68
-
69
- context.add_error(
70
- requirement_id: id,
71
- message: "Color '#{value}' in style property '#{prop}' is not allowed in this profile",
72
- node: node,
73
- severity: :error,
74
- data: {
75
- attribute: prop,
76
- value: value,
77
- element: node.name,
78
- },
79
- )
80
- end
29
+ validate_colors(node, context)
81
30
  end
82
31
 
83
32
  def validate_sax_element(element, context)
33
+ # ElementProxy always represents an element, so no element? check needed
34
+ validate_colors(element, context)
35
+ end
36
+
37
+ private
38
+
39
+ # Shared validation logic for both DOM and SAX modes
40
+ def validate_colors(node_or_element, context)
84
41
  # Skip attribute validation for structurally invalid nodes
85
- return if context.node_structurally_invalid?(element)
42
+ return if skip_attribute_validation?(node_or_element, context)
43
+
44
+ # Get element name
45
+ element_name = node_or_element.name
86
46
 
87
47
  # Check color-related attributes
88
48
  color_attributes = %w[fill stroke color stop-color flood-color
89
49
  lighting-color]
90
50
 
91
51
  color_attributes.each do |attr_name|
92
- value = element.raw_attributes[attr_name]
52
+ value = get_attribute(node_or_element, attr_name)
93
53
  next if value.nil? || value.empty?
94
54
 
95
55
  next if valid_color?(value)
@@ -97,18 +57,18 @@ module SvgConform
97
57
  context.add_error(
98
58
  requirement_id: id,
99
59
  message: "Color '#{value}' in attribute '#{attr_name}' is not allowed in this profile",
100
- node: element,
60
+ node: node_or_element,
101
61
  severity: :error,
102
62
  data: {
103
63
  attribute: attr_name,
104
64
  value: value,
105
- element: element.name,
65
+ element: element_name,
106
66
  },
107
67
  )
108
68
  end
109
69
 
110
70
  # Check style attribute for color properties
111
- style_value = element.raw_attributes["style"]
71
+ style_value = get_attribute(node_or_element, "style")
112
72
  return unless style_value
113
73
 
114
74
  styles = parse_style(style_value)
@@ -124,19 +84,17 @@ module SvgConform
124
84
  context.add_error(
125
85
  requirement_id: id,
126
86
  message: "Color '#{value}' in style property '#{prop}' is not allowed in this profile",
127
- node: element,
87
+ node: node_or_element,
128
88
  severity: :error,
129
89
  data: {
130
90
  attribute: prop,
131
91
  value: value,
132
- element: element.name,
92
+ element: element_name,
133
93
  },
134
94
  )
135
95
  end
136
96
  end
137
97
 
138
- private
139
-
140
98
  def valid_color?(color)
141
99
  # First check if threshold-based validation is enabled
142
100
  if black_and_white_threshold