svg_conform 0.1.1 → 0.1.3
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/.rubocop_todo.yml +437 -14
- data/README.adoc +62 -8
- 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 +48 -0
- data/lib/svg_conform/requirements/allowed_elements_requirement.rb +222 -0
- data/lib/svg_conform/requirements/base_requirement.rb +27 -0
- data/lib/svg_conform/requirements/color_restrictions_requirement.rb +55 -0
- data/lib/svg_conform/requirements/font_family_requirement.rb +22 -0
- data/lib/svg_conform/requirements/forbidden_content_requirement.rb +26 -0
- data/lib/svg_conform/requirements/id_reference_requirement.rb +99 -0
- data/lib/svg_conform/requirements/invalid_id_references_requirement.rb +92 -0
- data/lib/svg_conform/requirements/link_validation_requirement.rb +30 -0
- data/lib/svg_conform/requirements/namespace_attributes_requirement.rb +65 -0
- data/lib/svg_conform/requirements/namespace_requirement.rb +75 -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_promotion_requirement.rb +7 -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 +162 -0
- data/lib/svg_conform/validation_context.rb +87 -3
- data/lib/svg_conform/validator.rb +73 -5
- data/lib/svg_conform/version.rb +1 -1
- data/lib/svg_conform.rb +1 -0
- data/spec/spec_helper.rb +3 -0
- data/spec/support/shared_examples_for_validation_modes.rb +71 -0
- metadata +9 -2
|
@@ -43,6 +43,18 @@ module SvgConform
|
|
|
43
43
|
check_style_properties(style_value, node, context)
|
|
44
44
|
end
|
|
45
45
|
|
|
46
|
+
def validate_sax_element(element, context)
|
|
47
|
+
style_value = element.raw_attributes["style"]
|
|
48
|
+
return unless style_value
|
|
49
|
+
return if style_value.strip.empty?
|
|
50
|
+
|
|
51
|
+
# 1. Check for malformed style syntax
|
|
52
|
+
check_malformed_syntax(style_value, element, context)
|
|
53
|
+
|
|
54
|
+
# 2. Check for allowed/disallowed properties and validate their values
|
|
55
|
+
check_style_properties(style_value, element, context)
|
|
56
|
+
end
|
|
57
|
+
|
|
46
58
|
private
|
|
47
59
|
|
|
48
60
|
def check_malformed_syntax(style_value, node, context)
|
|
@@ -83,6 +83,78 @@ module SvgConform
|
|
|
83
83
|
)
|
|
84
84
|
end
|
|
85
85
|
|
|
86
|
+
def validate_sax_element(element, context)
|
|
87
|
+
# Only check the root SVG element
|
|
88
|
+
return unless element.name == "svg" && element.parent.nil?
|
|
89
|
+
|
|
90
|
+
viewbox = element.raw_attributes["viewBox"]
|
|
91
|
+
|
|
92
|
+
if viewbox.nil? || viewbox.empty?
|
|
93
|
+
context.add_error(
|
|
94
|
+
requirement_id: id,
|
|
95
|
+
message: "SVG root element must have a viewBox attribute",
|
|
96
|
+
node: element,
|
|
97
|
+
severity: :error,
|
|
98
|
+
data: { missing_attribute: "viewBox" },
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
# Add informational message about calculated viewBox if width/height are present
|
|
102
|
+
width = element.raw_attributes["width"]
|
|
103
|
+
height = element.raw_attributes["height"]
|
|
104
|
+
|
|
105
|
+
if width && height && valid_number?(width) && valid_number?(height)
|
|
106
|
+
calculated_viewbox = "0 0 #{width.to_f} #{height.to_f}"
|
|
107
|
+
context.add_error(
|
|
108
|
+
requirement_id: id,
|
|
109
|
+
message: "Trying to put in the attribute with value '#{calculated_viewbox}'",
|
|
110
|
+
node: element,
|
|
111
|
+
severity: :error,
|
|
112
|
+
data: {
|
|
113
|
+
calculated_viewbox: calculated_viewbox,
|
|
114
|
+
source_width: width,
|
|
115
|
+
source_height: height,
|
|
116
|
+
},
|
|
117
|
+
)
|
|
118
|
+
end
|
|
119
|
+
return
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Validate viewBox format (should be "min-x min-y width height")
|
|
123
|
+
normalized_viewbox = viewbox.strip.gsub(/[(),]/, " ").squeeze(" ")
|
|
124
|
+
parts = normalized_viewbox.strip.split(/\s+/)
|
|
125
|
+
unless parts.length == 4 && parts.all? { |part| valid_number?(part) }
|
|
126
|
+
context.add_error(
|
|
127
|
+
requirement_id: id,
|
|
128
|
+
message: "viewBox attribute must contain four numeric values (min-x min-y width height)",
|
|
129
|
+
node: element,
|
|
130
|
+
severity: :error,
|
|
131
|
+
data: {
|
|
132
|
+
viewbox_value: viewbox,
|
|
133
|
+
parsed_parts: parts,
|
|
134
|
+
},
|
|
135
|
+
)
|
|
136
|
+
return
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Check that width and height are positive
|
|
140
|
+
width = parts[2].to_f
|
|
141
|
+
height = parts[3].to_f
|
|
142
|
+
|
|
143
|
+
return unless width <= 0 || height <= 0
|
|
144
|
+
|
|
145
|
+
context.add_error(
|
|
146
|
+
requirement_id: id,
|
|
147
|
+
message: "viewBox width and height must be positive values",
|
|
148
|
+
node: element,
|
|
149
|
+
severity: :error,
|
|
150
|
+
data: {
|
|
151
|
+
viewbox_value: viewbox,
|
|
152
|
+
width: width,
|
|
153
|
+
height: height,
|
|
154
|
+
},
|
|
155
|
+
)
|
|
156
|
+
end
|
|
157
|
+
|
|
86
158
|
private
|
|
87
159
|
|
|
88
160
|
def valid_number?(str)
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "nokogiri"
|
|
4
|
+
require_relative "sax_validation_handler"
|
|
5
|
+
|
|
6
|
+
module SvgConform
|
|
7
|
+
# SAX-based document for streaming validation
|
|
8
|
+
# Provides high-performance validation for large SVG files
|
|
9
|
+
# without loading entire DOM tree into memory
|
|
10
|
+
class SaxDocument
|
|
11
|
+
attr_reader :file_path, :content
|
|
12
|
+
|
|
13
|
+
def self.from_file(file_path)
|
|
14
|
+
new(File.read(file_path), file_path)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def self.from_content(content)
|
|
18
|
+
new(content, nil)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def initialize(content, file_path = nil)
|
|
22
|
+
@content = content
|
|
23
|
+
@file_path = file_path
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Validate using SAX streaming parser
|
|
27
|
+
def validate_with_profile(profile)
|
|
28
|
+
handler = SaxValidationHandler.new(profile)
|
|
29
|
+
parser = Nokogiri::XML::SAX::Parser.new(handler)
|
|
30
|
+
|
|
31
|
+
begin
|
|
32
|
+
parser.parse(@content)
|
|
33
|
+
rescue StandardError => e
|
|
34
|
+
# Handle parse errors
|
|
35
|
+
handler.add_parse_error(e)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
handler.result
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# For compatibility - convert to DOM when needed
|
|
42
|
+
def to_dom
|
|
43
|
+
Document.from_content(@content)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "nokogiri"
|
|
4
|
+
require_relative "element_proxy"
|
|
5
|
+
require_relative "validation_context"
|
|
6
|
+
require_relative "validation_result"
|
|
7
|
+
|
|
8
|
+
module SvgConform
|
|
9
|
+
# SAX event handler for streaming SVG validation
|
|
10
|
+
# Processes XML events and dispatches to requirements
|
|
11
|
+
class SaxValidationHandler < Nokogiri::XML::SAX::Document
|
|
12
|
+
attr_reader :result, :context
|
|
13
|
+
|
|
14
|
+
def initialize(profile)
|
|
15
|
+
@profile = profile
|
|
16
|
+
@element_stack = [] # Track parent-child hierarchy
|
|
17
|
+
@path_stack = [] # Current element path
|
|
18
|
+
@position_counters = [] # Stack of sibling counters per level
|
|
19
|
+
@parse_errors = []
|
|
20
|
+
@result = nil # Will be set in end_document
|
|
21
|
+
|
|
22
|
+
# Create validation context (without document reference for SAX)
|
|
23
|
+
@context = create_sax_context
|
|
24
|
+
|
|
25
|
+
# Classify requirements into immediate vs deferred
|
|
26
|
+
@immediate_requirements = []
|
|
27
|
+
@deferred_requirements = []
|
|
28
|
+
classify_requirements
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# SAX Event: Document start
|
|
32
|
+
def start_document
|
|
33
|
+
# Initialize root level counters
|
|
34
|
+
@position_counters.push({})
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# SAX Event: Element start tag
|
|
38
|
+
def start_element(name, attributes = [])
|
|
39
|
+
attrs = Hash[attributes]
|
|
40
|
+
|
|
41
|
+
# Calculate position among siblings at current level
|
|
42
|
+
current_counters = @position_counters.last || {}
|
|
43
|
+
current_counters[name] ||= 0
|
|
44
|
+
current_counters[name] += 1
|
|
45
|
+
position = current_counters[name]
|
|
46
|
+
|
|
47
|
+
# Build element proxy
|
|
48
|
+
element = ElementProxy.new(
|
|
49
|
+
name: name,
|
|
50
|
+
attributes: attrs,
|
|
51
|
+
position: position,
|
|
52
|
+
path: @path_stack.dup,
|
|
53
|
+
parent: @element_stack.last,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
# Push to stacks
|
|
57
|
+
@element_stack.push(element)
|
|
58
|
+
@path_stack.push("#{name}[#{position}]")
|
|
59
|
+
@position_counters.push({}) # New level for this element's children
|
|
60
|
+
|
|
61
|
+
# Validate with immediate requirements
|
|
62
|
+
@immediate_requirements.each do |req|
|
|
63
|
+
req.validate_sax_element(element, @context)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Deferred requirements may need to collect data
|
|
67
|
+
@deferred_requirements.each do |req|
|
|
68
|
+
if req.respond_to?(:collect_sax_data)
|
|
69
|
+
req.collect_sax_data(element,
|
|
70
|
+
@context)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# SAX Event: Element end tag
|
|
76
|
+
def end_element(_name)
|
|
77
|
+
@element_stack.pop
|
|
78
|
+
@path_stack.pop
|
|
79
|
+
@position_counters.pop
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# SAX Event: Text content
|
|
83
|
+
def characters(string)
|
|
84
|
+
return if @element_stack.empty?
|
|
85
|
+
|
|
86
|
+
@element_stack.last.text_content << string
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# SAX Event: Document complete
|
|
90
|
+
def end_document
|
|
91
|
+
# Run deferred validation
|
|
92
|
+
@deferred_requirements.each do |req|
|
|
93
|
+
req.validate_sax_complete(@context)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Create result
|
|
97
|
+
@result = ValidationResult.new(nil, @profile, @context)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# SAX Event: Parse error
|
|
101
|
+
def error(error_message)
|
|
102
|
+
@parse_errors << error_message
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# SAX Event: Warning
|
|
106
|
+
def warning(warning_message)
|
|
107
|
+
# Can log warnings if needed
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Handle parse errors
|
|
111
|
+
def add_parse_error(error)
|
|
112
|
+
@context.add_error(
|
|
113
|
+
node: nil,
|
|
114
|
+
message: "Parse error: #{error.message}",
|
|
115
|
+
requirement_id: "parse_error",
|
|
116
|
+
severity: :error,
|
|
117
|
+
)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Get result (will be nil until end_document called)
|
|
121
|
+
def result
|
|
122
|
+
@result || create_incomplete_result
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
private
|
|
126
|
+
|
|
127
|
+
def create_incomplete_result
|
|
128
|
+
# Return result even if parsing incomplete
|
|
129
|
+
ValidationResult.new(nil, @profile, @context)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Create a SAX-compatible validation context
|
|
133
|
+
def create_sax_context
|
|
134
|
+
# Create context without triggering DOM operations
|
|
135
|
+
context = ValidationContext.allocate
|
|
136
|
+
context.instance_variable_set(:@document, nil)
|
|
137
|
+
context.instance_variable_set(:@profile, @profile)
|
|
138
|
+
context.instance_variable_set(:@errors, [])
|
|
139
|
+
context.instance_variable_set(:@warnings, [])
|
|
140
|
+
context.instance_variable_set(:@validity_errors, [])
|
|
141
|
+
context.instance_variable_set(:@infos, [])
|
|
142
|
+
context.instance_variable_set(:@data, {})
|
|
143
|
+
context.instance_variable_set(:@structurally_invalid_node_ids, Set.new)
|
|
144
|
+
context.instance_variable_set(:@node_id_cache, {})
|
|
145
|
+
context.instance_variable_set(:@cache_populated, true) # Skip population for SAX
|
|
146
|
+
context
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Classify requirements based on validation needs
|
|
150
|
+
def classify_requirements
|
|
151
|
+
return unless @profile.requirements
|
|
152
|
+
|
|
153
|
+
@profile.requirements.each do |req|
|
|
154
|
+
if req.respond_to?(:needs_deferred_validation?) && req.needs_deferred_validation?
|
|
155
|
+
@deferred_requirements << req
|
|
156
|
+
else
|
|
157
|
+
@immediate_requirements << req
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
@@ -17,6 +17,8 @@ module SvgConform
|
|
|
17
17
|
@infos = []
|
|
18
18
|
@data = {}
|
|
19
19
|
@structurally_invalid_node_ids = Set.new
|
|
20
|
+
@node_id_cache = {}
|
|
21
|
+
@cache_populated = false
|
|
20
22
|
end
|
|
21
23
|
|
|
22
24
|
# Mark a node as structurally invalid (e.g., invalid parent-child relationship)
|
|
@@ -24,6 +26,8 @@ module SvgConform
|
|
|
24
26
|
# Also marks all descendants as invalid since they'll be removed with the parent
|
|
25
27
|
def mark_node_structurally_invalid(node)
|
|
26
28
|
node_id = generate_node_id(node)
|
|
29
|
+
return if node_id.nil? # Safety check
|
|
30
|
+
|
|
27
31
|
@structurally_invalid_node_ids.add(node_id)
|
|
28
32
|
|
|
29
33
|
# Mark all descendants as invalid too
|
|
@@ -32,10 +36,14 @@ module SvgConform
|
|
|
32
36
|
|
|
33
37
|
# Mark all descendants of a node as structurally invalid
|
|
34
38
|
def mark_descendants_invalid(node)
|
|
35
|
-
|
|
39
|
+
# In SAX mode, ElementProxy doesn't have children yet
|
|
40
|
+
# Children will be validated individually and will check parent validity
|
|
41
|
+
return unless node.respond_to?(:children) && node.children
|
|
36
42
|
|
|
37
43
|
node.children.each do |child|
|
|
38
44
|
child_id = generate_node_id(child)
|
|
45
|
+
next if child_id.nil? # Skip if can't generate ID
|
|
46
|
+
|
|
39
47
|
@structurally_invalid_node_ids.add(child_id)
|
|
40
48
|
# Recursively mark descendants
|
|
41
49
|
mark_descendants_invalid(child)
|
|
@@ -45,6 +53,8 @@ module SvgConform
|
|
|
45
53
|
# Check if a node is structurally invalid
|
|
46
54
|
def node_structurally_invalid?(node)
|
|
47
55
|
node_id = generate_node_id(node)
|
|
56
|
+
return false if node_id.nil? # Safety check
|
|
57
|
+
|
|
48
58
|
@structurally_invalid_node_ids.include?(node_id)
|
|
49
59
|
end
|
|
50
60
|
|
|
@@ -120,16 +130,90 @@ requirement_id: nil, severity: nil, fix: nil, data: {})
|
|
|
120
130
|
|
|
121
131
|
# Generate a unique identifier for a node based on its path
|
|
122
132
|
# Builds a stable path by walking up the parent chain
|
|
133
|
+
# OPTIMIZED: Lazy cache population - populate entire cache on first call
|
|
123
134
|
def generate_node_id(node)
|
|
124
135
|
return nil unless node.respond_to?(:name)
|
|
125
136
|
|
|
126
|
-
#
|
|
137
|
+
# Populate cache for ALL nodes on first access
|
|
138
|
+
unless @cache_populated
|
|
139
|
+
populate_node_id_cache
|
|
140
|
+
@cache_populated = true
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Try cache lookup first
|
|
144
|
+
cached_id = @node_id_cache[node]
|
|
145
|
+
return cached_id if cached_id
|
|
146
|
+
|
|
147
|
+
# Fall back to building path if node not in cache
|
|
148
|
+
# (happens when different traversals create different wrapper objects)
|
|
149
|
+
build_node_path(node)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
private
|
|
153
|
+
|
|
154
|
+
# Populate cache for all nodes using document.traverse with parent tracking
|
|
155
|
+
def populate_node_id_cache
|
|
156
|
+
parent_stack = []
|
|
157
|
+
counter_stack = [{}] # Stack of {element_name => count} hashes
|
|
158
|
+
|
|
159
|
+
@document.traverse do |node|
|
|
160
|
+
next unless node.respond_to?(:name) && node.name
|
|
161
|
+
|
|
162
|
+
# Detect parent changes by checking node.parent
|
|
163
|
+
current_parent = node.respond_to?(:parent) ? node.parent : nil
|
|
164
|
+
|
|
165
|
+
# Adjust stack based on actual parent
|
|
166
|
+
while parent_stack.size.positive? && !parent_stack.last.equal?(current_parent)
|
|
167
|
+
parent_stack.pop
|
|
168
|
+
counter_stack.pop
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# If we have a new parent level, push it
|
|
172
|
+
if current_parent && (parent_stack.empty? || !parent_stack.last.equal?(current_parent))
|
|
173
|
+
parent_stack.push(current_parent)
|
|
174
|
+
counter_stack.push({})
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Increment counter at current level
|
|
178
|
+
current_counters = counter_stack.last || {}
|
|
179
|
+
current_counters[node.name] ||= 0
|
|
180
|
+
current_counters[node.name] += 1
|
|
181
|
+
|
|
182
|
+
# Build path using original backward logic (for correctness)
|
|
183
|
+
@node_id_cache[node] = build_node_path(node)
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Traverse tree, building paths with forward position counters
|
|
188
|
+
def traverse_with_forward_counting(node, path_parts, sibling_counters)
|
|
189
|
+
return unless node.respond_to?(:name) && node.name
|
|
190
|
+
|
|
191
|
+
# Increment counter for this node name at current level
|
|
192
|
+
sibling_counters[node.name] ||= 0
|
|
193
|
+
sibling_counters[node.name] += 1
|
|
194
|
+
position = sibling_counters[node.name]
|
|
195
|
+
|
|
196
|
+
# Build and cache path
|
|
197
|
+
current_path = path_parts + ["#{node.name}[#{position}]"]
|
|
198
|
+
@node_id_cache[node] = "/#{current_path.join('/')}"
|
|
199
|
+
|
|
200
|
+
# Traverse children with fresh counters
|
|
201
|
+
if node.respond_to?(:children)
|
|
202
|
+
child_counters = {}
|
|
203
|
+
node.children.each do |child|
|
|
204
|
+
traverse_with_forward_counting(child, current_path, child_counters)
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# Build path-based ID for a node (original logic, unchanged)
|
|
210
|
+
def build_node_path(node)
|
|
127
211
|
path_parts = []
|
|
128
212
|
current = node
|
|
129
213
|
|
|
130
214
|
while current
|
|
131
215
|
if current.respond_to?(:name) && current.name
|
|
132
|
-
# Count previous siblings of the same type for position
|
|
216
|
+
# Count previous siblings of the same type for position (ORIGINAL LOGIC)
|
|
133
217
|
position = 1
|
|
134
218
|
if current.respond_to?(:previous_sibling)
|
|
135
219
|
sibling = current.previous_sibling
|
|
@@ -9,6 +9,7 @@ module SvgConform
|
|
|
9
9
|
@options = {
|
|
10
10
|
fix: false,
|
|
11
11
|
strict: false,
|
|
12
|
+
mode: :sax, # Testing SAX mode
|
|
12
13
|
}.merge(options)
|
|
13
14
|
end
|
|
14
15
|
|
|
@@ -19,17 +20,31 @@ module SvgConform
|
|
|
19
20
|
"File not found: #{file_path}"
|
|
20
21
|
end
|
|
21
22
|
|
|
22
|
-
|
|
23
|
-
|
|
23
|
+
merged_options = @options.merge(options)
|
|
24
|
+
mode = determine_mode(file_path, merged_options[:mode])
|
|
25
|
+
|
|
26
|
+
case mode
|
|
27
|
+
when :sax
|
|
28
|
+
validate_file_sax(file_path, profile: profile, **merged_options)
|
|
29
|
+
when :dom
|
|
30
|
+
validate_file_dom(file_path, profile: profile, **merged_options)
|
|
31
|
+
end
|
|
24
32
|
end
|
|
25
33
|
|
|
26
34
|
# Validate SVG content string
|
|
27
35
|
def validate(svg_content, profile: :svg_1_2_rfc, **options)
|
|
28
|
-
|
|
29
|
-
|
|
36
|
+
merged_options = @options.merge(options)
|
|
37
|
+
mode = merged_options[:mode] == :sax ? :sax : :dom
|
|
38
|
+
|
|
39
|
+
case mode
|
|
40
|
+
when :sax
|
|
41
|
+
validate_content_sax(svg_content, profile: profile, **merged_options)
|
|
42
|
+
when :dom
|
|
43
|
+
validate_content_dom(svg_content, profile: profile, **merged_options)
|
|
44
|
+
end
|
|
30
45
|
end
|
|
31
46
|
|
|
32
|
-
# Validate a Document object
|
|
47
|
+
# Validate a Document object (DOM only)
|
|
33
48
|
def validate_document(document, profile: :svg_1_2_rfc, **options)
|
|
34
49
|
merged_options = @options.merge(options)
|
|
35
50
|
profile_obj = resolve_profile(profile)
|
|
@@ -64,6 +79,59 @@ module SvgConform
|
|
|
64
79
|
|
|
65
80
|
private
|
|
66
81
|
|
|
82
|
+
def determine_mode(file_path, requested_mode)
|
|
83
|
+
case requested_mode
|
|
84
|
+
when :sax
|
|
85
|
+
:sax
|
|
86
|
+
when :dom
|
|
87
|
+
:dom
|
|
88
|
+
when :auto
|
|
89
|
+
# Use SAX for files larger than 1MB
|
|
90
|
+
file_size = File.size(file_path)
|
|
91
|
+
file_size > 1_000_000 ? :sax : :dom
|
|
92
|
+
else
|
|
93
|
+
:dom
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def validate_file_sax(file_path, profile:, **options)
|
|
98
|
+
profile_obj = resolve_profile(profile)
|
|
99
|
+
sax_doc = SvgConform::SaxDocument.from_file(file_path)
|
|
100
|
+
result = sax_doc.validate_with_profile(profile_obj)
|
|
101
|
+
|
|
102
|
+
# If fixing is requested, convert to DOM and apply fixes
|
|
103
|
+
if options[:fix] && result.has_errors?
|
|
104
|
+
dom_doc = SvgConform::Document.from_file(file_path)
|
|
105
|
+
result = validate_document(dom_doc, profile: profile_obj, **options)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
result
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def validate_file_dom(file_path, profile:, **options)
|
|
112
|
+
document = SvgConform::Document.from_file(file_path)
|
|
113
|
+
validate_document(document, profile: profile, **options)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def validate_content_sax(content, profile:, **options)
|
|
117
|
+
profile_obj = resolve_profile(profile)
|
|
118
|
+
sax_doc = SvgConform::SaxDocument.from_content(content)
|
|
119
|
+
result = sax_doc.validate_with_profile(profile_obj)
|
|
120
|
+
|
|
121
|
+
# If fixing is requested, convert to DOM and apply fixes
|
|
122
|
+
if options[:fix] && result.has_errors?
|
|
123
|
+
dom_doc = SvgConform::Document.from_content(content)
|
|
124
|
+
result = validate_document(dom_doc, profile: profile_obj, **options)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
result
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def validate_content_dom(content, profile:, **options)
|
|
131
|
+
document = SvgConform::Document.from_content(content)
|
|
132
|
+
validate_document(document, profile: profile, **options)
|
|
133
|
+
end
|
|
134
|
+
|
|
67
135
|
def resolve_profile(profile)
|
|
68
136
|
case profile
|
|
69
137
|
when Symbol, String
|
data/lib/svg_conform/version.rb
CHANGED
data/lib/svg_conform.rb
CHANGED
|
@@ -11,6 +11,7 @@ end
|
|
|
11
11
|
|
|
12
12
|
require_relative "svg_conform/version"
|
|
13
13
|
require_relative "svg_conform/document"
|
|
14
|
+
require_relative "svg_conform/sax_document"
|
|
14
15
|
require_relative "svg_conform/validation_context"
|
|
15
16
|
require_relative "svg_conform/validation_result"
|
|
16
17
|
require_relative "svg_conform/profiles"
|
data/spec/spec_helper.rb
CHANGED
|
@@ -3,6 +3,9 @@
|
|
|
3
3
|
require "svg_conform"
|
|
4
4
|
require "canon"
|
|
5
5
|
|
|
6
|
+
# Load shared examples
|
|
7
|
+
Dir[File.expand_path("support/**/*.rb", __dir__)].each { |f| require f }
|
|
8
|
+
|
|
6
9
|
RSpec.configure do |config|
|
|
7
10
|
# Enable flags like --only-failures and --next-failure
|
|
8
11
|
config.example_status_persistence_file_path = ".rspec_status"
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Shared examples for testing both DOM and SAX validation modes
|
|
4
|
+
RSpec.shared_examples "validates in both DOM and SAX modes" do |file_path, profile_name|
|
|
5
|
+
let(:file_path) { file_path }
|
|
6
|
+
let(:profile) { profile_name }
|
|
7
|
+
|
|
8
|
+
it "produces identical results in DOM and SAX modes" do
|
|
9
|
+
dom_result = SvgConform::Validator.new(mode: :dom).validate_file(file_path,
|
|
10
|
+
profile: profile)
|
|
11
|
+
sax_result = SvgConform::Validator.new(mode: :sax).validate_file(file_path,
|
|
12
|
+
profile: profile)
|
|
13
|
+
|
|
14
|
+
expect(sax_result.errors.size).to eq(dom_result.errors.size),
|
|
15
|
+
"SAX mode should produce same error count as DOM mode (DOM: #{dom_result.errors.size}, SAX: #{sax_result.errors.size})"
|
|
16
|
+
|
|
17
|
+
expect(sax_result.warnings.size).to eq(dom_result.warnings.size),
|
|
18
|
+
"SAX mode should produce same warning count as DOM mode"
|
|
19
|
+
|
|
20
|
+
expect(sax_result.valid?).to eq(dom_result.valid?),
|
|
21
|
+
"SAX mode should have same validity as DOM mode"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
it "DOM mode validates correctly" do
|
|
25
|
+
result = SvgConform::Validator.new(mode: :dom).validate_file(file_path,
|
|
26
|
+
profile: profile)
|
|
27
|
+
expect(result).to be_a(SvgConform::ValidationResult)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
it "SAX mode validates correctly" do
|
|
31
|
+
result = SvgConform::Validator.new(mode: :sax).validate_file(file_path,
|
|
32
|
+
profile: profile)
|
|
33
|
+
expect(result).to be_a(SvgConform::ValidationResult)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
describe "performance comparison" do
|
|
37
|
+
it "SAX mode is faster than or equal to DOM mode" do
|
|
38
|
+
skip "Performance test - run manually for large files"
|
|
39
|
+
|
|
40
|
+
dom_time = Benchmark.realtime do
|
|
41
|
+
SvgConform::Validator.new(mode: :dom).validate_file(file_path,
|
|
42
|
+
profile: profile)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
sax_time = Benchmark.realtime do
|
|
46
|
+
SvgConform::Validator.new(mode: :sax).validate_file(file_path,
|
|
47
|
+
profile: profile)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
expect(sax_time).to be <= dom_time
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
RSpec.shared_examples "validates content in both modes" do |svg_content, profile_name|
|
|
56
|
+
let(:content) { svg_content }
|
|
57
|
+
let(:profile) { profile_name }
|
|
58
|
+
|
|
59
|
+
it "produces identical results for content validation" do
|
|
60
|
+
dom_result = SvgConform::Validator.new(mode: :dom).validate(content,
|
|
61
|
+
profile: profile)
|
|
62
|
+
sax_result = SvgConform::Validator.new(mode: :sax).validate(content,
|
|
63
|
+
profile: profile)
|
|
64
|
+
|
|
65
|
+
expect(sax_result.errors.size).to eq(dom_result.errors.size),
|
|
66
|
+
"SAX mode should produce same error count as DOM mode (DOM: #{dom_result.errors.size}, SAX: #{sax_result.errors.size})"
|
|
67
|
+
|
|
68
|
+
expect(sax_result.valid?).to eq(dom_result.valid?),
|
|
69
|
+
"SAX mode should have same validity as DOM mode"
|
|
70
|
+
end
|
|
71
|
+
end
|