svg_conform 0.1.10 → 0.1.11

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.
@@ -8,21 +8,39 @@ require_relative "validation/structural_invalidity_tracker"
8
8
  require_relative "validation/node_id_manager"
9
9
  require_relative "validation_result"
10
10
  require_relative "references"
11
+ require_relative "classification_cache"
11
12
 
12
13
  module SvgConform
13
14
  # SAX event handler for streaming SVG validation
14
15
  # Processes XML events and dispatches to requirements
16
+ #
17
+ # == Requirement classification
18
+ #
19
+ # The handler classifies requirements into two categories:
20
+ #
21
+ # 1. Immediate validation requirements (14) - validate during parsing
22
+ # - These requirements implement +validate_sax_element+ and validate
23
+ # each element as it is encountered
24
+ # - No state is maintained between elements
25
+ #
26
+ # 2. Deferred validation requirements (3) - collect data, validate at end
27
+ # - These requirements implement +collect_sax_data+ and +validate_sax_complete+
28
+ # - State is stored in requirement-specific State classes via +context.state_for+
29
+ # - Validation happens after parsing is complete, when all data is available
30
+ #
31
+ # == State management
32
+ #
33
+ # Each handler instance has its own cache and fresh requirement instances,
34
+ # ensuring no state leakage between validations.
35
+ #
36
+ # Profile reuse for multiple validations:
37
+ # - Profile is created once with requirement instances (configuration from YAML)
38
+ # - Each validation creates a fresh SaxValidationHandler with fresh ValidationContext
39
+ # - ValidationContext creates fresh State instances for deferred requirements
40
+ # - No state pollution occurs across validations
15
41
  class SaxValidationHandler < Nokogiri::XML::SAX::Document
16
42
  attr_reader :result, :context
17
43
 
18
- # Class-level cache for requirement classifications by profile class
19
- @classification_cache = {}
20
- @classification_cache_mutex = Mutex.new
21
-
22
- class << self
23
- attr_reader :classification_cache, :classification_cache_mutex
24
- end
25
-
26
44
  def initialize(profile)
27
45
  @profile = profile
28
46
  @element_stack = [] # Track parent-child hierarchy
@@ -31,18 +49,23 @@ module SvgConform
31
49
  @parse_errors = []
32
50
  @result = nil # Will be set in end_document
33
51
 
52
+ # Create instance-level cache (no shared state between handlers)
53
+ @classification_cache = ClassificationCache.new
54
+
55
+ # Use profile requirements directly (config is stateless)
56
+ # Validation state lives in context, not in requirements
57
+ @requirements = @profile.requirements || []
58
+
34
59
  # Create validation context (without document reference for SAX)
35
60
  @context = create_sax_context
36
61
 
37
- # Classify requirements into immediate vs deferred (use cache if available)
62
+ # Classify requirements into immediate vs deferred (use instance cache)
38
63
  @immediate_requirements, @deferred_requirements = classify_requirements_with_cache
39
64
  end
40
65
 
41
66
  # SAX Event: Document start
42
67
  def start_document
43
- # Reset state in requirements that maintain state across validations
44
- reset_stateful_requirements
45
-
68
+ # No need to reset state - each validation has fresh requirement instances
46
69
  # Initialize root level counters
47
70
  @position_counters.push({})
48
71
  end
@@ -158,37 +181,21 @@ module SvgConform
158
181
  context.instance_variable_set(:@reference_manifest,
159
182
  trackers[:reference_manifest])
160
183
  context.instance_variable_set(:@fixes, [])
161
- context.instance_variable_set(:@data, {})
184
+ context.instance_variable_set(:@state_registry, {})
162
185
  context
163
186
  end
164
187
 
165
- # Classify requirements based on validation needs (with caching)
188
+ # Classify requirements based on validation needs (with instance-level caching)
166
189
  def classify_requirements_with_cache
167
- profile_key = @profile.class.name
168
- profile_requirements = @profile.requirements
169
-
170
- # Check cache first (thread-safe)
171
- @immediate_requirements = []
172
- @deferred_requirements = []
190
+ profile_key = @profile.name || @profile.object_id.to_s
173
191
 
174
- classified = self.class.classification_cache_mutex.synchronize do
175
- self.class.classification_cache[profile_key]
176
- end
177
-
178
- if classified
179
- @immediate_requirements = classified[:immediate].map do |req_class|
180
- # Find the actual instance from profile requirements
181
- profile_requirements.find { |r| r.is_a?(req_class) }
182
- end.compact
183
- @deferred_requirements = classified[:deferred].map do |req_class|
184
- profile_requirements.find { |r| r.is_a?(req_class) }
185
- end.compact
186
- else
187
- # Classify and cache
192
+ # Use instance-level cache (no mutexes needed - per-handler isolation)
193
+ @classification_cache.fetch(profile_key) do
194
+ # Classify by requirement classes
188
195
  immediate_classes = []
189
196
  deferred_classes = []
190
197
 
191
- profile_requirements.each do |req|
198
+ @requirements.each do |req|
192
199
  if req.respond_to?(:needs_deferred_validation?) && req.needs_deferred_validation?
193
200
  deferred_classes << req.class
194
201
  else
@@ -196,45 +203,22 @@ module SvgConform
196
203
  end
197
204
  end
198
205
 
199
- # Store in cache
200
- self.class.classification_cache_mutex.synchronize do
201
- self.class.classification_cache[profile_key] = {
202
- immediate: immediate_classes,
203
- deferred: deferred_classes,
204
- }
205
- end
206
-
207
- @immediate_requirements = profile_requirements.select do |req|
208
- immediate_classes.include?(req.class)
209
- end
210
- @deferred_requirements = profile_requirements.select do |req|
211
- deferred_classes.include?(req.class)
212
- end
206
+ # Cache the classification by class
207
+ { immediate: immediate_classes, deferred: deferred_classes }
213
208
  end
214
209
 
215
- [@immediate_requirements, @deferred_requirements]
216
- end
217
-
218
- # Classify requirements based on validation needs
219
- def classify_requirements
220
- return unless @profile&.requirements
210
+ # Map back to actual requirement instances from @requirements
211
+ classified = @classification_cache.fetch(profile_key) { {} }
221
212
 
222
- @profile.requirements.each do |req|
223
- if req.respond_to?(:needs_deferred_validation?) && req.needs_deferred_validation?
224
- @deferred_requirements << req
225
- else
226
- @immediate_requirements << req
227
- end
213
+ immediate = classified[:immediate].flat_map do |req_class|
214
+ @requirements.select { |r| r.is_a?(req_class) }
228
215
  end
229
- end
230
216
 
231
- # Reset state in requirements that maintain state
232
- def reset_stateful_requirements
233
- return unless @profile&.requirements
234
-
235
- @profile.requirements.each do |req|
236
- req.reset_state if req.respond_to?(:reset_state)
217
+ deferred = classified[:deferred].flat_map do |req_class|
218
+ @requirements.select { |r| r.is_a?(req_class) }
237
219
  end
220
+
221
+ [immediate, deferred]
238
222
  end
239
223
  end
240
224
  end
@@ -23,18 +23,56 @@ module SvgConform
23
23
  # - StructuralInvalidityTracker: tracks structurally invalid nodes
24
24
  # - NodeIdManager: handles node ID generation for both SAX and DOM modes
25
25
  # - ReferenceManifest: tracks ID definitions and references
26
+ # - State Registry: manages requirement-specific state for deferred validation
26
27
  #
27
- # == Usage
28
+ # == Validation modes
29
+ #
30
+ # === DOM validation (remediation mode)
28
31
  #
29
- # In DOM validation (remediation mode):
30
32
  # context = ValidationContext.new(document, profile)
31
33
  # requirement.validate_document(document, context)
32
34
  #
33
- # In SAX validation (streaming mode):
35
+ # === SAX validation (streaming mode)
36
+ #
34
37
  # context = ValidationContext.new(nil, profile)
35
38
  # requirement.validate_sax_element(element, context)
36
39
  #
37
- # == Error Reporting
40
+ # == State management for deferred validation
41
+ #
42
+ # Requirements that need deferred validation (collecting data during SAX parsing
43
+ # and validating at document end) use requirement-specific State classes:
44
+ #
45
+ # class MyRequirement < BaseRequirement
46
+ # class State
47
+ # attr_accessor :collected_items
48
+ #
49
+ # def initialize
50
+ # @collected_items = []
51
+ # end
52
+ # end
53
+ #
54
+ # def collect_sax_data(element, context)
55
+ # state = context.state_for(self)
56
+ # state.collected_items << extract_from(element)
57
+ # end
58
+ #
59
+ # def validate_sax_complete(context)
60
+ # state = context.state_for(self)
61
+ # # Validate state.collected_items
62
+ # end
63
+ # end
64
+ #
65
+ # The +state_for+ method:
66
+ # - Returns a State instance specific to the requirement class
67
+ # - Creates the State instance on first access (lazy initialization)
68
+ # - Maintains one State instance per requirement class per validation
69
+ # - Prevents state pollution when reusing profiles across validations
70
+ #
71
+ # Each validation gets a fresh ValidationContext with a fresh state registry,
72
+ # ensuring that reusing the same Profile for multiple validations doesn't leak
73
+ # state between validations.
74
+ #
75
+ # == Error reporting
38
76
  #
39
77
  # context.add_error(
40
78
  # requirement_id: id,
@@ -44,13 +82,13 @@ module SvgConform
44
82
  # fix: -> { remove_element(node) }
45
83
  # )
46
84
  #
47
- # == Node ID Tracking
85
+ # == Node ID tracking
48
86
  #
49
87
  # The context generates unique node IDs for tracking:
50
88
  # - SAX mode: uses pre-computed path_id from ElementProxy
51
89
  # - DOM mode: uses DocumentAnalyzer with forward-counting algorithm
52
90
  #
53
- # == Structural Invalidity
91
+ # == Structural invalidity
54
92
  #
55
93
  # Requirements can mark nodes as structurally invalid (e.g., invalid
56
94
  # parent-child relationships). Other requirements should skip
@@ -58,7 +96,6 @@ module SvgConform
58
96
  #
59
97
  # context.mark_node_structurally_invalid(node)
60
98
  # context.node_structurally_invalid?(node) # => true
61
- #
62
99
  class ValidationContext
63
100
  # The document being validated (nil in SAX mode)
64
101
  attr_reader :document
@@ -101,7 +138,13 @@ module SvgConform
101
138
  @reference_manifest = trackers[:reference_manifest]
102
139
 
103
140
  @fixes = []
104
- @data = {}
141
+ @state_registry = {} # Maps requirement class => state instance
142
+ end
143
+
144
+ # Get or create state for a requirement
145
+ # Each requirement gets its own state instance, preventing state pollution
146
+ def state_for(requirement)
147
+ @state_registry[requirement.class] ||= requirement.class::State.new
105
148
  end
106
149
 
107
150
  # Mark a node as structurally invalid (e.g., invalid parent-child relationship)
@@ -164,14 +207,6 @@ requirement_id: nil, severity: nil, fix: nil, data: {})
164
207
  (@error_tracker.errors + @error_tracker.warnings).count(&:fixable?)
165
208
  end
166
209
 
167
- def set_data(key, value)
168
- @data[key] = value
169
- end
170
-
171
- def get_data(key)
172
- @data[key]
173
- end
174
-
175
210
  # Register an ID definition
176
211
  def register_id(id_value, element_name:, line_number: nil,
177
212
  column_number: nil)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SvgConform
4
- VERSION = "0.1.10"
4
+ VERSION = "0.1.11"
5
5
  end
@@ -0,0 +1,47 @@
1
+ # Expected errors for IETF-test.svg validation against svg_1_2_rfc profile
2
+ # Based on diagnostic results from script/diagnose_dom_mode.rb
3
+
4
+ description: "IETF-test.svg from metanorma-standoc - contains rgb(0,0,0) colors and style properties that should be promoted"
5
+
6
+ # Total errors expected: 18
7
+ total_errors: 18
8
+
9
+ # Errors by requirement
10
+ requirements:
11
+ color_restrictions:
12
+ total: 3
13
+ errors:
14
+ - message: "Color 'rgb(0,0,0)' in style property 'stroke' is not allowed in this profile"
15
+ count: 2
16
+ - message: "Color 'rgb(0,0,0)' in style property 'fill' is not allowed in this profile"
17
+ count: 1
18
+
19
+ style_promotion:
20
+ total: 15
21
+ errors:
22
+ - message: "Style property 'stroke' can be promoted to attribute"
23
+ count: 4
24
+ - message: "Style property 'stroke-width' can be promoted to attribute"
25
+ count: 2
26
+ - message: "Style property 'fill' can be promoted to attribute"
27
+ count: 6
28
+ - message: "Style property 'font-family' can be promoted to attribute"
29
+ count: 1
30
+ - message: "Style property 'font-size' can be promoted to attribute"
31
+ count: 1
32
+
33
+ # Remediation expectations
34
+ # Note: Remediation promotes styles to attributes (e.g., stroke="black") rather than
35
+ # modifying the style attribute content (e.g., style="stroke:black")
36
+ remediation:
37
+ before_errors: 18
38
+ after_errors: 1
39
+ expected_changes: 18
40
+ expected_remediations:
41
+ - color_conversion
42
+ - style_promotion
43
+ # After remediation, expect attributes like stroke="black" instead of style="stroke:rgb(0,0,0)"
44
+ expected_attributes:
45
+ - stroke: 4 # Promoted from style
46
+ - stroke-width: 2 # Promoted from style
47
+ - fill: 6 # Promoted from style (some were already fill="none")
@@ -0,0 +1,28 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" fill-rule="evenodd" preserveAspectRatio="xMidYMid" version="1.1" viewBox="0 0 28000 21000">
2
+ <g class="Drawing" id="Straight_Connector_42">
3
+ <g>
4
+ <g style="stroke:rgb(0,0,0);stroke-width:88;fill:none">
5
+ <path d="M 4264,13886 L 4264,17273" style="fill:none" />
6
+ </g></g></g>
7
+ <g class="Drawing" id="Straight_Connector_33">
8
+ <g>
9
+ <g style="stroke:rgb(0,0,0);stroke-width:88;fill:none">
10
+ <path d="M 20355,10711 L 20351,13886" style="fill:none" />
11
+ </g></g></g>
12
+ <g class="Drawing">
13
+ <g>
14
+ <g style="stroke:none;fill:none">
15
+ <rect height="3490" width="25184" x="1512" y="340" />
16
+ </g>
17
+ <g style="font-family:Arial embedded;font-size:1552px;font-weight:400">
18
+ <g style="stroke:none;fill:rgb(0,0,0)">
19
+ <text>
20
+ <tspan x="4733 5855 6718 7582 8446 8877 9741 10605 11036 12074 12853 13716 14580 15443 15960 16307 17171 17602 18724 19071 19935 20799 21315 22179 " y="2482">
21
+ Updated Scenario Diagram
22
+ </tspan>
23
+ </text>
24
+ </g>
25
+ </g>
26
+ </g>
27
+ </g>
28
+ </svg>
data/spec/spec_helper.rb CHANGED
@@ -24,11 +24,6 @@ RSpec.configure do |config|
24
24
  SvgConform::Requirements::AllowedElementsRequirement.configuration_validation_cache.clear
25
25
  end
26
26
 
27
- # Clear SaxValidationHandler classification cache
28
- if defined?(SvgConform::SaxValidationHandler)
29
- SvgConform::SaxValidationHandler.classification_cache.clear
30
- end
31
-
32
27
  # Clear ProfileCompiler compile cache
33
28
  if defined?(SvgConform::ProfileCompiler)
34
29
  SvgConform::ProfileCompiler.compile_cache.clear
@@ -8,6 +8,26 @@ RSpec.describe "SVG 1.2 RFC Profile" do
8
8
  let(:svg_1_2_rfc_profile_path) { "config/profiles/svg_1_2_rfc.yml" }
9
9
  let(:fixtures_base_dir) { "spec/fixtures" }
10
10
 
11
+ # Helper module for grouping and analyzing errors (namespaced to avoid global pollution)
12
+ module SpecHelpers
13
+ def self.group_errors_by_requirement(validation_result)
14
+ validation_result.errors.each_with_object({}) do |error, hash|
15
+ req_id = error.requirement_id || "unknown"
16
+ hash[req_id] ||= []
17
+ hash[req_id] << error
18
+ end
19
+ end
20
+
21
+ def self.count_style_properties(style_errors)
22
+ style_errors.each_with_object({}) do |error, hash|
23
+ if error.message =~ /Style property '([-\w]+)' can be promoted/
24
+ hash[$1] ||= 0
25
+ hash[$1] += 1
26
+ end
27
+ end
28
+ end
29
+ end
30
+
11
31
  describe "profile loading" do
12
32
  it "loads the SVG 1.2 RFC profile successfully" do
13
33
  profile = SvgConform::Profile.load_from_file(svg_1_2_rfc_profile_path)
@@ -197,4 +217,134 @@ RSpec.describe "SVG 1.2 RFC Profile" do
197
217
  puts "Comprehensive test: #{initial_result.errors.count} → #{final_result.errors.count} violations"
198
218
  end
199
219
  end
220
+
221
+ describe "IETF-test.svg (issue #75)" do
222
+ let(:ietf_test_file) do
223
+ "spec/fixtures/svg_1_2_rfc/inputs/ietf_test_violations.svg"
224
+ end
225
+
226
+ before do
227
+ skip "IETF-test fixture not found" unless File.exist?(ietf_test_file)
228
+ end
229
+
230
+ it "validates IETF-test.svg and detects expected violations" do
231
+ document = SvgConform::Document.from_file(ietf_test_file)
232
+ profile = SvgConform::Profile.load_from_file(svg_1_2_rfc_profile_path)
233
+ validation_result = profile.validate(document)
234
+ errors_by_requirement = SpecHelpers.group_errors_by_requirement(validation_result)
235
+
236
+ # Should detect violations
237
+ expect(validation_result.valid?).to be false
238
+
239
+ # Should have exactly 18 errors (3 color + 15 style promotion)
240
+ expect(validation_result.errors.count).to eq(18)
241
+
242
+ # Check color_restrictions errors
243
+ color_errors = errors_by_requirement["color_restrictions"] || []
244
+ expect(color_errors.count).to eq(3)
245
+
246
+ # Check style_promotion errors
247
+ style_errors = errors_by_requirement["style_promotion"] || []
248
+ expect(style_errors.count).to eq(15)
249
+
250
+ puts "IETF-test.svg validation: #{validation_result.errors.count} errors detected"
251
+ puts " Color errors: #{color_errors.count}"
252
+ puts " Style promotion errors: #{style_errors.count}"
253
+ end
254
+
255
+ it "validates IETF-test.svg color violations in detail" do
256
+ document = SvgConform::Document.from_file(ietf_test_file)
257
+ profile = SvgConform::Profile.load_from_file(svg_1_2_rfc_profile_path)
258
+ validation_result = profile.validate(document)
259
+ errors_by_requirement = SpecHelpers.group_errors_by_requirement(validation_result)
260
+ color_errors = errors_by_requirement["color_restrictions"] || []
261
+
262
+ color_stroke_errors = color_errors.select do |e|
263
+ e.message.include?("stroke")
264
+ end
265
+ color_fill_errors = color_errors.select { |e| e.message.include?("fill") }
266
+
267
+ expect(color_stroke_errors.count).to eq(2)
268
+ expect(color_fill_errors.count).to eq(1)
269
+ end
270
+
271
+ it "validates IETF-test.svg style promotion violations in detail" do
272
+ document = SvgConform::Document.from_file(ietf_test_file)
273
+ profile = SvgConform::Profile.load_from_file(svg_1_2_rfc_profile_path)
274
+ validation_result = profile.validate(document)
275
+ errors_by_requirement = SpecHelpers.group_errors_by_requirement(validation_result)
276
+ style_errors = errors_by_requirement["style_promotion"] || []
277
+ style_props = SpecHelpers.count_style_properties(style_errors)
278
+
279
+ expect(style_props["stroke"]).to eq(4)
280
+ expect(style_props["stroke-width"]).to eq(2)
281
+ expect(style_props["fill"]).to eq(6)
282
+ expect(style_props["font-family"]).to eq(1)
283
+ expect(style_props["font-size"]).to eq(1)
284
+ end
285
+
286
+ it "applies remediations to IETF-test.svg and reduces errors" do
287
+ document = SvgConform::Document.from_file(ietf_test_file)
288
+ profile = SvgConform::Profile.load_from_file(svg_1_2_rfc_profile_path)
289
+ initial_result = profile.validate(document)
290
+ initial_error_count = initial_result.errors.count
291
+
292
+ # Apply all remediations
293
+ changes = profile.apply_remediations(document)
294
+
295
+ # Get final error count
296
+ final_result = profile.validate(document)
297
+ final_error_count = final_result.errors.count
298
+
299
+ # Verify remediation was applied
300
+ expect(changes.count).to be > 0
301
+
302
+ # Verify errors were reduced
303
+ expect(final_error_count).to be < initial_error_count
304
+
305
+ # Verify specific error reduction
306
+ expect(initial_error_count).to eq(18)
307
+ expect(final_error_count).to eq(1)
308
+
309
+ # Verify specific remediations were applied
310
+ color_changes = changes.select do |c|
311
+ c[:type] == :attribute_modified || c[:message]&.include?("color")
312
+ end
313
+ style_promotion_changes = changes.select do |c|
314
+ c[:type] == :style_promotion || c[:message]&.include?("promoted")
315
+ end
316
+
317
+ expect(color_changes.count).to be > 0
318
+ expect(style_promotion_changes.count).to be > 0
319
+
320
+ puts "IETF-test.svg remediation: #{initial_error_count} → #{final_error_count} errors"
321
+ puts " Total changes: #{changes.count}"
322
+ puts " - Color conversion: #{color_changes.count}"
323
+ puts " - Style promotion: #{style_promotion_changes.count}"
324
+ end
325
+
326
+ it "produces valid XML after remediation" do
327
+ document = SvgConform::Document.from_file(ietf_test_file)
328
+ profile = SvgConform::Profile.load_from_file(svg_1_2_rfc_profile_path)
329
+
330
+ # Apply remediations
331
+ profile.apply_remediations(document)
332
+
333
+ # Should still be valid XML
334
+ xml = document.to_xml
335
+ expect(xml).to match(/<svg\s+[^>]*>.*<\/svg>/m)
336
+
337
+ # Should maintain root element structure
338
+ expect(document.root.name).to eq("svg")
339
+
340
+ # Check for promoted stroke attributes (from style promotion)
341
+ expect(xml).to include('stroke="black"')
342
+
343
+ # Check for stroke-width attributes (from style promotion)
344
+ expect(xml).to include('stroke-width="88"')
345
+
346
+ # Check for fill attributes (from both color conversion and style promotion)
347
+ expect(xml).to include('fill="black"').or(include('fill="none"'))
348
+ end
349
+ end
200
350
  end