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.
Files changed (34) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +437 -14
  3. data/README.adoc +62 -8
  4. data/examples/readme_usage.rb +67 -0
  5. data/examples/requirements_demo.rb +4 -4
  6. data/lib/svg_conform/document.rb +7 -1
  7. data/lib/svg_conform/element_proxy.rb +101 -0
  8. data/lib/svg_conform/fast_document_analyzer.rb +82 -0
  9. data/lib/svg_conform/node_index_builder.rb +48 -0
  10. data/lib/svg_conform/requirements/allowed_elements_requirement.rb +222 -0
  11. data/lib/svg_conform/requirements/base_requirement.rb +27 -0
  12. data/lib/svg_conform/requirements/color_restrictions_requirement.rb +55 -0
  13. data/lib/svg_conform/requirements/font_family_requirement.rb +22 -0
  14. data/lib/svg_conform/requirements/forbidden_content_requirement.rb +26 -0
  15. data/lib/svg_conform/requirements/id_reference_requirement.rb +99 -0
  16. data/lib/svg_conform/requirements/invalid_id_references_requirement.rb +92 -0
  17. data/lib/svg_conform/requirements/link_validation_requirement.rb +30 -0
  18. data/lib/svg_conform/requirements/namespace_attributes_requirement.rb +65 -0
  19. data/lib/svg_conform/requirements/namespace_requirement.rb +75 -0
  20. data/lib/svg_conform/requirements/no_external_css_requirement.rb +74 -0
  21. data/lib/svg_conform/requirements/no_external_fonts_requirement.rb +58 -0
  22. data/lib/svg_conform/requirements/no_external_images_requirement.rb +40 -0
  23. data/lib/svg_conform/requirements/style_promotion_requirement.rb +7 -0
  24. data/lib/svg_conform/requirements/style_requirement.rb +12 -0
  25. data/lib/svg_conform/requirements/viewbox_required_requirement.rb +72 -0
  26. data/lib/svg_conform/sax_document.rb +46 -0
  27. data/lib/svg_conform/sax_validation_handler.rb +162 -0
  28. data/lib/svg_conform/validation_context.rb +87 -3
  29. data/lib/svg_conform/validator.rb +73 -5
  30. data/lib/svg_conform/version.rb +1 -1
  31. data/lib/svg_conform.rb +1 -0
  32. data/spec/spec_helper.rb +3 -0
  33. data/spec/support/shared_examples_for_validation_modes.rb +71 -0
  34. 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
- return unless node.respond_to?(:children)
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
- # Build path by walking up parent chain
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
- document = Document.from_file(file_path)
23
- validate_document(document, profile: profile, **options)
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
- document = Document.from_content(svg_content)
29
- validate_document(document, profile: profile, **options)
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SvgConform
4
- VERSION = "0.1.1"
4
+ VERSION = "0.1.3"
5
5
  end
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