svg_conform 0.1.6 → 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
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SvgConform
4
+ # Shared helper methods for working with XML nodes
5
+ #
6
+ # This module provides common utilities used by both requirements
7
+ # and remediations to avoid code duplication and ensure consistency.
8
+ module NodeHelpers
9
+ # Check if a node is an element
10
+ # @param node [Object] the node to check
11
+ # @return [Boolean] true if the node is an element
12
+ def element?(node)
13
+ node.respond_to?(:name) && !node.name.nil?
14
+ end
15
+
16
+ # Check if a node is text
17
+ # @param node [Object] the node to check
18
+ # @return [Boolean] true if the node is text
19
+ def text?(node)
20
+ node.respond_to?(:text?) && node.text?
21
+ end
22
+
23
+ # Get attribute value from a node
24
+ # @param node [Object] the node
25
+ # @param name [String] attribute name
26
+ # @return [String, nil] attribute value or nil
27
+ def get_attribute(node, name)
28
+ return nil unless node.respond_to?(:[])
29
+
30
+ node[name]
31
+ end
32
+
33
+ # Set attribute value on a node
34
+ # @param node [Object] the node
35
+ # @param name [String] attribute name
36
+ # @param value [String] attribute value
37
+ # @return [Boolean] true if successful
38
+ def set_attribute(node, name, value)
39
+ return unless node.respond_to?(:[]=)
40
+
41
+ node[name] = value
42
+ true
43
+ end
44
+
45
+ # Check if node has an attribute
46
+ # @param node [Object] the node
47
+ # @param name [String] attribute name
48
+ # @return [Boolean] true if attribute exists
49
+ def has_attribute?(node, name)
50
+ return false unless node.respond_to?(:[])
51
+
52
+ !node[name].nil?
53
+ end
54
+
55
+ # Remove attribute from a node
56
+ # @param node [Object] the node
57
+ # @param name [String] attribute name
58
+ # @return [Boolean] true if successful
59
+ def remove_attribute(node, name)
60
+ if node.respond_to?(:remove_attribute)
61
+ node.remove_attribute(name)
62
+ true
63
+ elsif node.respond_to?(:[]=) && node.respond_to?(:[])
64
+ # Fallback for bracket notation
65
+ node[name] = nil
66
+ true
67
+ else
68
+ false
69
+ end
70
+ end
71
+ end
72
+ end
@@ -2,11 +2,14 @@
2
2
 
3
3
  require "lutaml/model"
4
4
  require_relative "../remediation_result"
5
+ require_relative "../node_helpers"
5
6
 
6
7
  module SvgConform
7
8
  module Remediations
8
9
  # Base class for all remediations using lutaml-model serialization
9
10
  class BaseRemediation < Lutaml::Model::Serializable
11
+ include SvgConform::NodeHelpers
12
+
10
13
  attribute :id, :string
11
14
  attribute :description, :string
12
15
  attribute :targets, :string, collection: true
@@ -72,27 +75,8 @@ module SvgConform
72
75
 
73
76
  protected
74
77
 
75
- def element?(node)
76
- node.respond_to?(:name) && node.name
77
- end
78
-
79
- def get_attribute(node, attr_name)
80
- return nil unless node.respond_to?(:[])
81
-
82
- node[attr_name]
83
- end
84
-
85
- def set_attribute(node, attr_name, value)
86
- return unless node.respond_to?(:[]=)
87
-
88
- node[attr_name] = value
89
- end
90
-
91
- def has_attribute?(node, attr_name)
92
- return false unless node.respond_to?(:[])
93
-
94
- !node[attr_name].nil?
95
- end
78
+ # Helper methods for node manipulation included via NodeHelpers module:
79
+ # element?, text?, get_attribute, set_attribute, has_attribute?, remove_attribute
96
80
 
97
81
  def find_nodes(document, &)
98
82
  nodes = []
@@ -100,20 +84,6 @@ module SvgConform
100
84
  nodes
101
85
  end
102
86
 
103
- # Helper method to remove attribute
104
- def remove_attribute(node, name)
105
- if node.respond_to?(:remove_attribute)
106
- node.remove_attribute(name)
107
- true
108
- elsif node.respond_to?(:[]=) && node.respond_to?(:[])
109
- # Fallback for different implementations
110
- node[name] = nil
111
- true
112
- else
113
- false
114
- end
115
- end
116
-
117
87
  # Helper method to remove node
118
88
  def remove_node(node)
119
89
  return false unless node.respond_to?(:remove)