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.
@@ -1,26 +1,105 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "set"
4
+ require_relative "document_analyzer"
5
+ require_relative "errors/validation_issue"
6
+ require_relative "errors/validation_notice"
7
+ require_relative "validation/error_tracker"
8
+ require_relative "validation/structural_invalidity_tracker"
9
+ require_relative "validation/node_id_manager"
4
10
 
5
11
  module SvgConform
6
- # Context object passed to rules during validation
12
+ # Context object passed to requirements during validation
13
+ #
14
+ # The ValidationContext serves as the central state management object
15
+ # during SVG validation. It tracks errors, warnings, structural issues,
16
+ # and provides helper methods for requirements to report issues.
17
+ #
18
+ # == Architecture
19
+ #
20
+ # ValidationContext delegates to specialized tracker classes:
21
+ # - ErrorTracker: tracks errors, warnings, and notices
22
+ # - StructuralInvalidityTracker: tracks structurally invalid nodes
23
+ # - NodeIdManager: handles node ID generation for both SAX and DOM modes
24
+ # - ReferenceManifest: tracks ID definitions and references
25
+ #
26
+ # == Usage
27
+ #
28
+ # In DOM validation (remediation mode):
29
+ # context = ValidationContext.new(document, profile)
30
+ # requirement.validate_document(document, context)
31
+ #
32
+ # In SAX validation (streaming mode):
33
+ # context = ValidationContext.new(nil, profile)
34
+ # requirement.validate_sax_element(element, context)
35
+ #
36
+ # == Error Reporting
37
+ #
38
+ # context.add_error(
39
+ # requirement_id: id,
40
+ # message: "Element not allowed",
41
+ # node: node,
42
+ # severity: :error,
43
+ # fix: -> { remove_element(node) }
44
+ # )
45
+ #
46
+ # == Node ID Tracking
47
+ #
48
+ # The context generates unique node IDs for tracking:
49
+ # - SAX mode: uses pre-computed path_id from ElementProxy
50
+ # - DOM mode: uses DocumentAnalyzer with forward-counting algorithm
51
+ #
52
+ # == Structural Invalidity
53
+ #
54
+ # Requirements can mark nodes as structurally invalid (e.g., invalid
55
+ # parent-child relationships). Other requirements should skip
56
+ # validation on these nodes since they will be removed during remediation.
57
+ #
58
+ # context.mark_node_structurally_invalid(node)
59
+ # context.node_structurally_invalid?(node) # => true
60
+ #
7
61
  class ValidationContext
8
- attr_reader :document, :profile, :errors, :warnings, :fixes,
9
- :validity_errors, :reference_manifest
62
+ # The document being validated (nil in SAX mode)
63
+ attr_reader :document
64
+
65
+ # The profile containing requirements to validate against
66
+ attr_reader :profile
67
+
68
+ # Array of fixes that can be applied
69
+ attr_reader :fixes
70
+
71
+ # Reference manifest tracking IDs and references
72
+ attr_reader :reference_manifest
73
+
74
+ # Delegate error tracking methods to ErrorTracker
75
+ def errors
76
+ @error_tracker.errors
77
+ end
78
+
79
+ def warnings
80
+ @error_tracker.warnings
81
+ end
82
+
83
+ def validity_errors
84
+ @error_tracker.validity_errors
85
+ end
86
+
87
+ def infos
88
+ @error_tracker.infos
89
+ end
10
90
 
11
91
  def initialize(document, profile)
12
92
  @document = document
13
93
  @profile = profile
14
- @errors = []
15
- @warnings = []
16
- @validity_errors = []
17
- @infos = []
94
+ @error_tracker = Validation::ErrorTracker.new
95
+ @fixes = []
18
96
  @data = {}
19
- @structurally_invalid_node_ids = Set.new
20
- @node_id_cache = {}
21
- @cache_populated = false
97
+ @node_id_manager = Validation::NodeIdManager.new(document)
98
+ @structural_invalidity_tracker = Validation::StructuralInvalidityTracker.new(
99
+ node_id_generator: ->(node) { @node_id_manager.generate_node_id(node) },
100
+ )
22
101
  @reference_manifest = References::ReferenceManifest.new(
23
- source_document: document.file_path,
102
+ source_document: document&.file_path,
24
103
  )
25
104
  end
26
105
 
@@ -28,75 +107,36 @@ module SvgConform
28
107
  # Other requirements should skip attribute validation on these nodes
29
108
  # Also marks all descendants as invalid since they'll be removed with the parent
30
109
  def mark_node_structurally_invalid(node)
31
- node_id = generate_node_id(node)
32
- return if node_id.nil? # Safety check
33
-
34
- @structurally_invalid_node_ids.add(node_id)
35
-
36
- # Mark all descendants as invalid too
37
- mark_descendants_invalid(node)
110
+ @structural_invalidity_tracker.mark_node_structurally_invalid(node)
38
111
  end
39
112
 
40
113
  # Mark all descendants of a node as structurally invalid
41
114
  def mark_descendants_invalid(node)
42
- # In SAX mode, ElementProxy doesn't have children yet
43
- # Children will be validated individually and will check parent validity
44
- return unless node.respond_to?(:children) && node.children
45
-
46
- node.children.each do |child|
47
- child_id = generate_node_id(child)
48
- next if child_id.nil? # Skip if can't generate ID
49
-
50
- @structurally_invalid_node_ids.add(child_id)
51
- # Recursively mark descendants
52
- mark_descendants_invalid(child)
53
- end
115
+ @structural_invalidity_tracker.mark_descendants_invalid(node)
54
116
  end
55
117
 
56
118
  # Check if a node is structurally invalid
57
119
  def node_structurally_invalid?(node)
58
- node_id = generate_node_id(node)
59
- return false if node_id.nil? # Safety check
60
-
61
- @structurally_invalid_node_ids.include?(node_id)
120
+ @structural_invalidity_tracker.node_structurally_invalid?(node)
62
121
  end
63
122
 
64
123
  def add_error(node:, message:, rule: nil, requirement: nil,
65
124
  requirement_id: nil, severity: nil, fix: nil, data: {})
66
- # Support both old rule system and new requirements system
67
- rule_or_requirement = requirement || rule
68
-
69
- error = ValidationIssue.new(
70
- type: :error,
71
- rule: rule_or_requirement,
72
- node: node,
73
- message: message,
74
- fix: fix,
75
- data: data,
76
- requirement_id: requirement_id,
77
- severity: severity,
125
+ @error_tracker.add_error(
126
+ node: node, message: message, rule: rule, requirement: requirement,
127
+ requirement_id: requirement_id, severity: severity, fix: fix, data: data
78
128
  )
79
-
80
- # Handle special severity types
81
- if severity == :validity_error
82
- @validity_errors << error
83
- else
84
- @errors << error
85
- end
86
-
87
- error
88
129
  end
89
130
 
90
131
  def add_warning(rule:, node:, message:, fix: nil)
91
- warning = ValidationIssue.new(
92
- type: :warning,
93
- rule: rule,
94
- node: node,
95
- message: message,
96
- fix: fix,
97
- )
98
- @warnings << warning
99
- warning
132
+ @error_tracker.add_warning(rule: rule, node: node, message: message,
133
+ fix: fix)
134
+ end
135
+
136
+ # Add an informational notice (delegates to ErrorTracker)
137
+ def add_notice(rule:, node:, message:, fix: nil, data: {})
138
+ @error_tracker.add_notice(rule: rule, node: node, message: message,
139
+ fix: fix, data: data)
100
140
  end
101
141
 
102
142
  def add_fix(fix)
@@ -104,11 +144,11 @@ requirement_id: nil, severity: nil, fix: nil, data: {})
104
144
  end
105
145
 
106
146
  def has_errors?
107
- !@errors.empty?
147
+ @error_tracker.has_errors?
108
148
  end
109
149
 
110
150
  def has_warnings?
111
- !@warnings.empty?
151
+ @error_tracker.has_warnings?
112
152
  end
113
153
 
114
154
  def has_fixes?
@@ -116,11 +156,11 @@ requirement_id: nil, severity: nil, fix: nil, data: {})
116
156
  end
117
157
 
118
158
  def issue_count
119
- @errors.size + @warnings.size
159
+ @error_tracker.total_error_count + warnings.size
120
160
  end
121
161
 
122
162
  def fixable_count
123
- (@errors + @warnings).count(&:fixable?)
163
+ (@error_tracker.errors + @error_tracker.warnings).count(&:fixable?)
124
164
  end
125
165
 
126
166
  def set_data(key, value)
@@ -154,405 +194,18 @@ column_number: nil)
154
194
 
155
195
  # Add notice for external references (not errors)
156
196
  def add_external_reference_notice(node:, reference:, message: nil)
157
- notice = ValidationNotice.new(
158
- type: :external_reference,
197
+ @error_tracker.add_notice(
198
+ rule: nil,
159
199
  node: node,
160
200
  message: message || "External reference: #{reference.value}",
161
201
  data: { reference: reference },
162
202
  )
163
- @infos << notice
164
- notice
165
203
  end
166
204
 
167
205
  # Generate a unique identifier for a node based on its path
168
- # Builds a stable path by walking up the parent chain
169
- # OPTIMIZED: Lazy cache population - populate entire cache on first call
206
+ # Delegates to NodeIdManager which handles both SAX and DOM modes
170
207
  def generate_node_id(node)
171
- return nil unless node.respond_to?(:name)
172
-
173
- # Populate cache for ALL nodes on first access
174
- unless @cache_populated
175
- populate_node_id_cache
176
- @cache_populated = true
177
- end
178
-
179
- # Try cache lookup first
180
- cached_id = @node_id_cache[node]
181
- return cached_id if cached_id
182
-
183
- # Fall back to building path if node not in cache
184
- # (happens when different traversals create different wrapper objects)
185
- build_node_path(node)
186
- end
187
-
188
- private
189
-
190
- # Populate cache for all nodes using document.traverse with parent tracking
191
- def populate_node_id_cache
192
- parent_stack = []
193
- counter_stack = [{}] # Stack of {element_name => count} hashes
194
-
195
- @document.traverse do |node|
196
- next unless node.respond_to?(:name) && node.name
197
-
198
- # Detect parent changes by checking node.parent
199
- current_parent = node.respond_to?(:parent) ? node.parent : nil
200
-
201
- # Adjust stack based on actual parent
202
- while parent_stack.size.positive? && !parent_stack.last.equal?(current_parent)
203
- parent_stack.pop
204
- counter_stack.pop
205
- end
206
-
207
- # If we have a new parent level, push it
208
- if current_parent && (parent_stack.empty? || !parent_stack.last.equal?(current_parent))
209
- parent_stack.push(current_parent)
210
- counter_stack.push({})
211
- end
212
-
213
- # Increment counter at current level
214
- current_counters = counter_stack.last || {}
215
- current_counters[node.name] ||= 0
216
- current_counters[node.name] += 1
217
-
218
- # Build path using original backward logic (for correctness)
219
- @node_id_cache[node] = build_node_path(node)
220
- end
221
- end
222
-
223
- # Traverse tree, building paths with forward position counters
224
- def traverse_with_forward_counting(node, path_parts, sibling_counters)
225
- return unless node.respond_to?(:name) && node.name
226
-
227
- # Increment counter for this node name at current level
228
- sibling_counters[node.name] ||= 0
229
- sibling_counters[node.name] += 1
230
- position = sibling_counters[node.name]
231
-
232
- # Build and cache path
233
- current_path = path_parts + ["#{node.name}[#{position}]"]
234
- @node_id_cache[node] = "/#{current_path.join('/')}"
235
-
236
- # Traverse children with fresh counters
237
- if node.respond_to?(:children)
238
- child_counters = {}
239
- node.children.each do |child|
240
- traverse_with_forward_counting(child, current_path, child_counters)
241
- end
242
- end
243
- end
244
-
245
- # Build path-based ID for a node (original logic, unchanged)
246
- def build_node_path(node)
247
- path_parts = []
248
- current = node
249
-
250
- while current
251
- if current.respond_to?(:name) && current.name
252
- # Count previous siblings of the same type for position (ORIGINAL LOGIC)
253
- position = 1
254
- if current.respond_to?(:previous_sibling)
255
- sibling = current.previous_sibling
256
- while sibling
257
- position += 1 if sibling.respond_to?(:name) && sibling.name == current.name
258
- sibling = sibling.previous_sibling if sibling.respond_to?(:previous_sibling)
259
- end
260
- end
261
-
262
- path_parts.unshift("#{current.name}[#{position}]")
263
- end
264
-
265
- # Stop if we reach the document root (doesn't have parent)
266
- break unless current.respond_to?(:parent)
267
-
268
- begin
269
- current = current.parent
270
- rescue NoMethodError
271
- # Parent method failed, we're at root
272
- break
273
- end
274
-
275
- break unless current
276
- end
277
-
278
- "/#{path_parts.join('/')}"
279
- end
280
- end
281
-
282
- # Base class for validation issues
283
- class ValidationIssue
284
- attr_reader :type, :rule, :node, :message, :fix, :data,
285
- :requirement_id_override, :severity
286
-
287
- def initialize(type:, rule:, node:, message:, fix: nil, data: {},
288
- requirement_id: nil, severity: nil, violation_type: nil)
289
- @type = type
290
- @rule = rule
291
- @node = node
292
- @message = message
293
- @fix = fix
294
- @data = data
295
- @requirement_id_override = requirement_id
296
- @severity = severity
297
- @violation_type = violation_type || detect_violation_type
298
- end
299
-
300
- def requirement_id
301
- return @requirement_id_override.to_s if @requirement_id_override
302
-
303
- if @rule.respond_to?(:id)
304
- @rule.id.to_s
305
- elsif @rule.respond_to?(:class) && @rule.class.respond_to?(:name)
306
- # Extract ID from class name for requirements
307
- class_name = @rule.class.name.split("::").last
308
- class_name.gsub(/Requirement$/, "").downcase.gsub(/([a-z])([A-Z])/,
309
- '\1_\2').downcase
310
- else
311
- "unknown"
312
- end
313
- end
314
-
315
- def error?
316
- @type == :error
317
- end
318
-
319
- def warning?
320
- @type == :warning
321
- end
322
-
323
- def fixable?
324
- !@fix.nil?
325
- end
326
-
327
- def line
328
- @node.respond_to?(:line) ? @node.line : nil
329
- end
330
-
331
- def column
332
- @node.respond_to?(:column) ? @node.column : nil
333
- end
334
-
335
- def element_name
336
- @node.respond_to?(:name) ? @node.name : nil
337
- end
338
-
339
- def apply_fix
340
- return false unless fixable?
341
-
342
- begin
343
- if @fix.respond_to?(:call)
344
- @fix.call
345
- else
346
- @fix.apply
347
- end
348
- true
349
- rescue StandardError
350
- false
351
- end
352
- end
353
-
354
- def to_h
355
- {
356
- type: @type,
357
- rule: rule_id,
358
- message: @message,
359
- line: line,
360
- column: column,
361
- element: element_name,
362
- fixable: fixable?,
363
- }
364
- end
365
-
366
- def violation_type
367
- @violation_type
368
- end
369
-
370
- def remediable?
371
- case @violation_type
372
- when :color_violation, :font_violation, :content_violation, :reference_violation,
373
- :namespace_violation, :viewbox_violation, :style_violation
374
- true
375
- when :structural_violation
376
- false
377
- else
378
- # Default to remediable for backward compatibility
379
- true
380
- end
381
- end
382
-
383
- def remediation_type
384
- case @violation_type
385
- when :color_violation, :font_violation
386
- :convert
387
- when :content_violation, :reference_violation, :namespace_violation
388
- :remove
389
- when :viewbox_violation
390
- :add
391
- when :style_violation
392
- :promote
393
- else
394
- :unknown
395
- end
396
- end
397
-
398
- def remediation_confidence
399
- case @violation_type
400
- when :color_violation, :font_violation, :style_violation
401
- :automatic
402
- when :viewbox_violation, :namespace_violation
403
- :safe_structural
404
- when :content_violation, :reference_violation
405
- :safe_removal
406
- else
407
- :manual_review
408
- end
409
- end
410
-
411
- def suggested_action
412
- case @violation_type
413
- when :color_violation
414
- "Convert color to allowed equivalent using CssColor"
415
- when :font_violation
416
- "Map font family to generic equivalent"
417
- when :content_violation
418
- "Remove forbidden element or attribute"
419
- when :reference_violation
420
- "Remove broken reference or containing element"
421
- when :namespace_violation
422
- "Fix namespace declarations and remove invalid elements/attributes"
423
- when :viewbox_violation
424
- "Add missing viewBox attribute"
425
- when :style_violation
426
- "Promote style properties to attributes"
427
- when :structural_violation
428
- "Manual fix required - document structure issue"
429
- else
430
- "Apply available remediation"
431
- end
432
- end
433
-
434
- def affects_content?
435
- case @violation_type
436
- when :content_violation, :reference_violation
437
- true
438
- when :color_violation, :font_violation, :style_violation, :viewbox_violation, :namespace_violation
439
- false
440
- else
441
- false
442
- end
443
- end
444
-
445
- def to_s
446
- location = line ? " at line #{line}" : ""
447
- location += ":#{column}" if column
448
- rule_info = rule_id ? " (#{rule_id})" : ""
449
- remediation_info = remediable? ? " [#{remediation_type}]" : " [NOT REMEDIABLE]"
450
- "#{@message}#{location}#{rule_info}#{remediation_info}"
451
- end
452
-
453
- def to_h
454
- {
455
- type: @type,
456
- rule: rule_id,
457
- message: @message,
458
- line: line,
459
- column: column,
460
- element: element_name,
461
- fixable: fixable?,
462
- remediable: remediable?,
463
- violation_type: @violation_type,
464
- remediation_type: remediation_type,
465
- remediation_confidence: remediation_confidence,
466
- suggested_action: suggested_action,
467
- affects_content: affects_content?,
468
- }
469
- end
470
-
471
- private
472
-
473
- def detect_violation_type
474
- req_id = requirement_id.downcase
475
- msg = @message.downcase
476
-
477
- # First check for structural violations (non-remediable)
478
- return :structural_violation if msg.include?("root element must be") ||
479
- msg.include?("malformed") ||
480
- msg.include?("invalid document") ||
481
- msg.include?("required element missing") ||
482
- msg.include?("invalid hierarchy")
483
-
484
- # Then check requirement-based violations (remediable)
485
- case req_id
486
- when /color/
487
- :color_violation
488
- when /font/
489
- :font_violation
490
- when /forbidden|content/
491
- :content_violation
492
- when /reference|id/
493
- :reference_violation
494
- when /namespace/
495
- :namespace_violation
496
- when /viewbox/
497
- :viewbox_violation
498
- when /style/
499
- :style_violation
500
- else
501
- # Check message content for clues
502
- return :color_violation if msg.include?("color")
503
- return :font_violation if msg.include?("font")
504
- return :content_violation if msg.include?("forbidden")
505
- return :reference_violation if msg.include?("reference") || msg.include?("href")
506
- return :namespace_violation if msg.include?("namespace")
507
- return :viewbox_violation if msg.include?("viewbox")
508
- return :style_violation if msg.include?("style")
509
-
510
- :unknown_violation
511
- end
512
- end
513
-
514
- def rule_id
515
- return @requirement_id_override if @requirement_id_override
516
- return @rule.id if @rule.respond_to?(:id)
517
-
518
- if @rule.respond_to?(:class) && @rule.class.respond_to?(:name)
519
- # Extract ID from class name for requirements
520
- class_name = @rule.class.name.split("::").last
521
- class_name.gsub(/Requirement$/, "").downcase.gsub(/([a-z])([A-Z])/,
522
- '\1_\2').downcase
523
- else
524
- "unknown"
525
- end
526
- end
527
- end
528
-
529
- # New class for non-error notifications
530
- class ValidationNotice
531
- attr_reader :type, :node, :message, :data
532
-
533
- def initialize(type:, node:, message:, data: {})
534
- @type = type
535
- @node = node
536
- @message = message
537
- @data = data
538
- end
539
-
540
- def line
541
- @node.respond_to?(:line) ? @node.line : nil
542
- end
543
-
544
- def column
545
- @node.respond_to?(:column) ? @node.column : nil
546
- end
547
-
548
- def to_h
549
- {
550
- type: @type,
551
- message: @message,
552
- line: line,
553
- column: column,
554
- data: @data,
555
- }
208
+ @node_id_manager.generate_node_id(node)
556
209
  end
557
210
  end
558
211
  end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "errors/validation_issue"
4
+ require_relative "errors/validation_notice"
5
+
6
+ # Backward compatibility: re-export error classes at top level
7
+ module SvgConform
8
+ # Aliases for backward compatibility
9
+ # These classes are now defined in SvgConform::Errors
10
+ ValidationIssue = Errors::ValidationIssue
11
+ ValidationNotice = Errors::ValidationNotice
12
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SvgConform
4
- VERSION = "0.1.6"
4
+ VERSION = "0.1.8"
5
5
  end
data/lib/svg_conform.rb CHANGED
@@ -16,6 +16,8 @@ require_relative "svg_conform/profiles"
16
16
  require_relative "svg_conform/document"
17
17
  require_relative "svg_conform/sax_document"
18
18
  require_relative "svg_conform/element_proxy"
19
+ require_relative "svg_conform/errors/validation_issue"
20
+ require_relative "svg_conform/errors/validation_notice"
19
21
  require_relative "svg_conform/validation_context"
20
22
  require_relative "svg_conform/validation_result"
21
23
  require_relative "svg_conform/validator"