svg_conform 0.1.0 → 0.1.2

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 (43) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/rake.yml +4 -1
  3. data/.github/workflows/release.yml +6 -2
  4. data/.rubocop_todo.yml +273 -10
  5. data/Gemfile +1 -0
  6. data/README.adoc +54 -37
  7. data/config/profiles/metanorma.yml +4 -4
  8. data/docs/remediation.adoc +541 -542
  9. data/docs/requirements.adoc +800 -357
  10. data/examples/readme_usage.rb +67 -0
  11. data/examples/requirements_demo.rb +4 -4
  12. data/lib/svg_conform/document.rb +7 -1
  13. data/lib/svg_conform/element_proxy.rb +101 -0
  14. data/lib/svg_conform/fast_document_analyzer.rb +82 -0
  15. data/lib/svg_conform/node_index_builder.rb +47 -0
  16. data/lib/svg_conform/remediations/no_external_css_remediation.rb +4 -4
  17. data/lib/svg_conform/requirements/allowed_elements_requirement.rb +202 -0
  18. data/lib/svg_conform/requirements/base_requirement.rb +27 -0
  19. data/lib/svg_conform/requirements/color_restrictions_requirement.rb +53 -0
  20. data/lib/svg_conform/requirements/font_family_requirement.rb +18 -0
  21. data/lib/svg_conform/requirements/forbidden_content_requirement.rb +26 -0
  22. data/lib/svg_conform/requirements/id_reference_requirement.rb +96 -0
  23. data/lib/svg_conform/requirements/invalid_id_references_requirement.rb +91 -0
  24. data/lib/svg_conform/requirements/link_validation_requirement.rb +30 -0
  25. data/lib/svg_conform/requirements/namespace_attributes_requirement.rb +59 -0
  26. data/lib/svg_conform/requirements/namespace_requirement.rb +74 -0
  27. data/lib/svg_conform/requirements/no_external_css_requirement.rb +74 -0
  28. data/lib/svg_conform/requirements/no_external_fonts_requirement.rb +58 -0
  29. data/lib/svg_conform/requirements/no_external_images_requirement.rb +40 -0
  30. data/lib/svg_conform/requirements/style_requirement.rb +12 -0
  31. data/lib/svg_conform/requirements/viewbox_required_requirement.rb +72 -0
  32. data/lib/svg_conform/sax_document.rb +46 -0
  33. data/lib/svg_conform/sax_validation_handler.rb +158 -0
  34. data/lib/svg_conform/validation_context.rb +84 -2
  35. data/lib/svg_conform/validator.rb +74 -6
  36. data/lib/svg_conform/version.rb +1 -1
  37. data/lib/svg_conform.rb +1 -0
  38. data/spec/fixtures/namespace/repair/basic_violations.svg +3 -3
  39. data/spec/fixtures/namespace_attributes/repair/basic_violations.svg +2 -2
  40. data/spec/fixtures/no_external_css/repair/basic_violations.svg +2 -2
  41. data/spec/fixtures/style_promotion/repair/basic_test.svg +2 -2
  42. data/svg_conform.gemspec +1 -1
  43. metadata +12 -6
@@ -46,6 +46,24 @@ module SvgConform
46
46
  end
47
47
  end
48
48
 
49
+ def validate_sax_element(element, context)
50
+ # Check font-family attribute only
51
+ font_family = element.raw_attributes["font-family"]
52
+ return unless font_family
53
+
54
+ if svgcheck_compatibility
55
+ check_font_family_svgcheck_mode(element, context, font_family, "font-family")
56
+ elsif !valid_font_family?(font_family)
57
+ context.add_error(
58
+ requirement_id: id,
59
+ message: "Font family '#{font_family}' is not allowed in this profile",
60
+ node: element,
61
+ severity: :error,
62
+ data: { attribute: "font-family", value: font_family }
63
+ )
64
+ end
65
+ end
66
+
49
67
  private
50
68
 
51
69
  def check_font_family_svgcheck_mode(node, context, font_family_value,
@@ -55,6 +55,32 @@ module SvgConform
55
55
  end
56
56
  end
57
57
  end
58
+
59
+ def validate_sax_element(element, context)
60
+ # Check if this is a forbidden element
61
+ if forbidden_elements.include?(element.name)
62
+ context.add_error(
63
+ requirement_id: id,
64
+ message: "Forbidden element '#{element.name}' is not allowed",
65
+ node: element,
66
+ severity: :error
67
+ )
68
+ end
69
+
70
+ # Check for forbidden attributes
71
+ element.attributes.each do |attr|
72
+ attr_name = attr.name
73
+
74
+ if forbidden_attributes.include?(attr_name)
75
+ context.add_error(
76
+ requirement_id: id,
77
+ message: "Forbidden attribute '#{attr_name}' is not allowed",
78
+ node: element,
79
+ severity: :error
80
+ )
81
+ end
82
+ end
83
+ end
58
84
  end
59
85
  end
60
86
  end
@@ -6,6 +6,102 @@ require "set"
6
6
  module SvgConform
7
7
  module Requirements
8
8
  class IdReferenceRequirement < BaseRequirement
9
+ def needs_deferred_validation?
10
+ true
11
+ end
12
+
13
+ def collect_sax_data(element, context)
14
+ # Initialize collections on first call
15
+ @collected_ids ||= Set.new
16
+ @collected_url_refs ||= []
17
+ @collected_href_refs ||= []
18
+ @collected_other_refs ||= []
19
+
20
+ # Collect IDs
21
+ id_value = element.raw_attributes["id"]
22
+ @collected_ids.add(id_value) if id_value && !id_value.empty?
23
+
24
+ # Collect url() references
25
+ url_attributes = %w[fill stroke marker-start marker-mid marker-end clip-path mask filter]
26
+ url_attributes.each do |attr_name|
27
+ attr_value = element.raw_attributes[attr_name]
28
+ next unless attr_value
29
+
30
+ url_refs = extract_url_references(attr_value)
31
+ url_refs.each do |ref_id|
32
+ @collected_url_refs << [element, ref_id, attr_name]
33
+ end
34
+ end
35
+
36
+ # Check style attribute for url() references
37
+ style_attr = element.raw_attributes["style"]
38
+ if style_attr
39
+ url_refs = extract_url_references(style_attr)
40
+ url_refs.each do |ref_id|
41
+ @collected_url_refs << [element, ref_id, "style"]
42
+ end
43
+ end
44
+
45
+ # Collect href references
46
+ href_value = element.raw_attributes["href"] || element.raw_attributes["xlink:href"]
47
+ if href_value&.start_with?("#")
48
+ ref_id = href_value[1..] # Remove #
49
+ @collected_href_refs << [element, ref_id]
50
+ end
51
+
52
+ # Collect other ID references
53
+ id_ref_attributes = %w[for aria-labelledby aria-describedby aria-controls aria-owns]
54
+ id_ref_attributes.each do |attr_name|
55
+ attr_value = element.raw_attributes[attr_name]
56
+ next unless attr_value
57
+
58
+ ref_ids = attr_value.split(/\s+/)
59
+ ref_ids.each do |ref_id|
60
+ next if ref_id.empty?
61
+ @collected_other_refs << [element, ref_id, attr_name]
62
+ end
63
+ end
64
+ end
65
+
66
+ def validate_sax_complete(context)
67
+ # Validate all collected references
68
+ @collected_url_refs.each do |element, ref_id, attr_name|
69
+ next if @collected_ids.include?(ref_id)
70
+
71
+ message = if attr_name == "style"
72
+ "Reference to undefined ID '#{ref_id}' in style attribute"
73
+ else
74
+ "Reference to undefined ID '#{ref_id}' in attribute '#{attr_name}'"
75
+ end
76
+
77
+ context.add_error(
78
+ node: element,
79
+ message: message,
80
+ requirement_id: id
81
+ )
82
+ end
83
+
84
+ @collected_href_refs.each do |element, ref_id|
85
+ next if @collected_ids.include?(ref_id)
86
+
87
+ context.add_error(
88
+ node: element,
89
+ message: "Reference to undefined ID '#{ref_id}' in href attribute",
90
+ requirement_id: id
91
+ )
92
+ end
93
+
94
+ @collected_other_refs.each do |element, ref_id, attr_name|
95
+ next if @collected_ids.include?(ref_id)
96
+
97
+ context.add_error(
98
+ node: element,
99
+ message: "Reference to undefined ID '#{ref_id}' in #{attr_name} attribute",
100
+ requirement_id: id
101
+ )
102
+ end
103
+ end
104
+
9
105
  def validate_document(document, context)
10
106
  # Collect all IDs in the document
11
107
  ids = Set.new
@@ -21,6 +21,97 @@ module SvgConform
21
21
  map "strict_mode", to: :strict_mode
22
22
  end
23
23
 
24
+ def initialize(*args)
25
+ super
26
+ @collected_ids = Set.new
27
+ @use_element_refs = [] # [element, ref_id, href]
28
+ @other_refs = [] # [element, ref_id, attr_name, value]
29
+ end
30
+
31
+ def needs_deferred_validation?
32
+ true
33
+ end
34
+
35
+ def collect_sax_data(element, context)
36
+ # Initialize collections on first call
37
+ @collected_ids ||= Set.new
38
+ @use_element_refs ||= []
39
+ @other_refs ||= []
40
+
41
+ # Collect IDs
42
+ id_attr = element.raw_attributes["id"]
43
+ @collected_ids.add(id_attr) if id_attr && !id_attr.empty?
44
+
45
+ # Collect use element references
46
+ if check_use_elements && element.name == "use"
47
+ href = element.raw_attributes["xlink:href"] || element.raw_attributes["href"]
48
+ if href&.start_with?("#")
49
+ ref_id = href[1..]
50
+ @use_element_refs << [element, ref_id, href] unless ref_id.empty?
51
+ end
52
+ end
53
+
54
+ # Collect other ID references if enabled
55
+ if check_other_references
56
+ id_reference_attributes = %w[clip-path mask filter marker-start marker-mid marker-end fill stroke]
57
+
58
+ id_reference_attributes.each do |attr_name|
59
+ attr_value = element.raw_attributes[attr_name]
60
+ next unless attr_value&.match?(/^url\(#(.+)\)$/)
61
+
62
+ ref_id = Regexp.last_match(1)
63
+ @other_refs << [element, ref_id, attr_name, attr_value]
64
+ end
65
+
66
+ # Check style attribute
67
+ style_value = element.raw_attributes["style"]
68
+ if style_value
69
+ styles = parse_style(style_value)
70
+ styles.each do |property, value|
71
+ next unless value&.match?(/^url\(#(.+)\)$/)
72
+
73
+ ref_id = Regexp.last_match(1)
74
+ @other_refs << [element, ref_id, "style:#{property}", value]
75
+ end
76
+ end
77
+ end
78
+ end
79
+
80
+ def validate_sax_complete(context)
81
+ # Validate use element references
82
+ @use_element_refs.each do |element, ref_id, href|
83
+ next if @collected_ids.include?(ref_id)
84
+
85
+ context.add_error(
86
+ requirement_id: id,
87
+ node: element,
88
+ message: "use element references non-existent ID: #{ref_id}",
89
+ severity: :error,
90
+ data: { invalid_id: ref_id, href: href }
91
+ )
92
+ end
93
+
94
+ # Validate other references if enabled
95
+ @other_refs.each do |element, ref_id, attr_name, value|
96
+ next if @collected_ids.include?(ref_id)
97
+
98
+ message = if attr_name.start_with?("style:")
99
+ property = attr_name.split(":", 2)[1]
100
+ "style property #{property} references non-existent ID: #{ref_id}"
101
+ else
102
+ "#{attr_name} references non-existent ID: #{ref_id}"
103
+ end
104
+
105
+ context.add_error(
106
+ requirement_id: id,
107
+ node: element,
108
+ message: message,
109
+ severity: :error,
110
+ data: { invalid_id: ref_id, attribute: attr_name, value: value }
111
+ )
112
+ end
113
+ end
114
+
24
115
  def validate_document(document, context)
25
116
  # Collect all existing IDs in the document
26
117
  existing_ids = collect_existing_ids(document)
@@ -45,6 +45,36 @@ module SvgConform
45
45
  end
46
46
  end
47
47
 
48
+ def validate_sax_element(element, context)
49
+ # Check href attributes
50
+ href_value = element.raw_attributes["href"] || element.raw_attributes["xlink:href"]
51
+
52
+ if href_value && !ascii_only?(href_value)
53
+ context.add_error(
54
+ requirement_id: id,
55
+ message: "Link href '#{href_value}' contains non-ASCII characters",
56
+ node: element,
57
+ severity: :error
58
+ )
59
+ end
60
+
61
+ # Check other IRI attributes
62
+ iri_attributes = %w[src action formaction cite longdesc usemap]
63
+ iri_attributes.each do |attr_name|
64
+ iri_value = element.raw_attributes[attr_name]
65
+ next unless iri_value
66
+
67
+ next if ascii_only?(iri_value)
68
+
69
+ context.add_error(
70
+ requirement_id: id,
71
+ message: "IRI attribute '#{attr_name}' value '#{iri_value}' contains non-ASCII characters",
72
+ node: element,
73
+ severity: :error
74
+ )
75
+ end
76
+ end
77
+
48
78
  private
49
79
 
50
80
  def ascii_only?(string)
@@ -41,6 +41,65 @@ module SvgConform
41
41
  end
42
42
  end
43
43
 
44
+ def validate_sax_element(element, context)
45
+ # Skip validation for exempt elements (e.g., RDF metadata elements)
46
+ return if exempt_elements.include?(element.name)
47
+
48
+ # Check all attributes for namespace violations
49
+ element.attributes.each do |attr|
50
+ check_sax_attribute(attr, element, context)
51
+ end
52
+ end
53
+
54
+ private
55
+
56
+ def check_sax_attribute(attr, element, context)
57
+ attr_name = attr.name
58
+
59
+ # Check if this is a namespaced attribute by looking for colon in name
60
+ return unless attr_name.include?(":")
61
+
62
+ prefix, = attr_name.split(":", 2)
63
+
64
+ # Find the namespace URI for this prefix by walking up parent chain
65
+ namespace_uri = find_namespace_uri_sax(element, prefix)
66
+ return unless namespace_uri
67
+
68
+ # Determine if this namespace is invalid based on configuration
69
+ invalid_namespace = if allowed_namespaces.empty?
70
+ # Blacklist mode: disallowed namespaces are forbidden
71
+ disallowed_namespaces.include?(namespace_uri)
72
+ else
73
+ # Whitelist mode: only allowed namespaces are permitted
74
+ !allowed_namespaces.include?(namespace_uri)
75
+ end
76
+
77
+ return unless invalid_namespace
78
+
79
+ context.add_error(
80
+ requirement_id: id,
81
+ message: "Element '#{element.name}' does not allow attributes with namespace '#{namespace_uri}'",
82
+ node: element,
83
+ severity: :error,
84
+ data: { attribute: attr_name, namespace: namespace_uri }
85
+ )
86
+ end
87
+
88
+ def find_namespace_uri_sax(element, prefix)
89
+ # Check current element and ancestors for xmlns:prefix declarations
90
+ current = element
91
+ while current
92
+ # Check for xmlns:prefix attribute in raw_attributes
93
+ xmlns_value = current.raw_attributes["xmlns:#{prefix}"]
94
+ return xmlns_value if xmlns_value
95
+
96
+ # Move to parent
97
+ current = current.parent
98
+ end
99
+
100
+ nil
101
+ end
102
+
44
103
  private
45
104
 
46
105
  def check_attribute_nodes(node, context)
@@ -190,8 +190,82 @@ module SvgConform
190
190
  )
191
191
  end
192
192
 
193
+ def validate_sax_element(element, context)
194
+ # Check if this element has a namespace
195
+ element_namespace = get_element_namespace_sax(element)
196
+
197
+ # Skip if no namespace (default SVG namespace)
198
+ return if element_namespace.nil? || element_namespace.empty?
199
+
200
+ # Check against allowed namespaces if configured
201
+ # If allow_rdf_metadata is enabled, also allow RDF namespaces
202
+ effective_allowed_namespaces = allowed_namespaces
203
+ if allow_rdf_metadata
204
+ effective_allowed_namespaces = allowed_namespaces + RDF_NAMESPACES
205
+ end
206
+
207
+ if effective_allowed_namespaces && !effective_allowed_namespaces.empty? && !effective_allowed_namespaces.include?(element_namespace)
208
+ context.add_error(
209
+ requirement_id: id,
210
+ message: "The namespace #{element_namespace} is not permitted for svg elements.",
211
+ node: element,
212
+ severity: :error,
213
+ data: {
214
+ element_name: element.name,
215
+ namespace: element_namespace,
216
+ allowed_namespaces: effective_allowed_namespaces
217
+ }
218
+ )
219
+ return
220
+ end
221
+
222
+ # Check against disallowed namespaces if configured
223
+ return unless disallowed_namespaces && !disallowed_namespaces.empty? && disallowed_namespaces.include?(element_namespace)
224
+
225
+ context.add_error(
226
+ requirement_id: id,
227
+ message: "The namespace #{element_namespace} is not permitted for svg elements.",
228
+ node: element,
229
+ severity: :error,
230
+ data: {
231
+ element_name: element.name,
232
+ namespace: element_namespace,
233
+ disallowed_namespaces: disallowed_namespaces
234
+ }
235
+ )
236
+ end
237
+
193
238
  private
194
239
 
240
+ def get_element_namespace_sax(element)
241
+ # Try to get namespace from the element
242
+ namespace = element.namespace
243
+ return namespace if namespace && !namespace.empty?
244
+
245
+ # If no namespace found, check if element has a prefix (indicating it's namespaced)
246
+ if element.name.include?(":")
247
+ prefix = element.name.split(":").first
248
+ return find_namespace_uri_for_prefix_sax(element, prefix)
249
+ end
250
+
251
+ nil
252
+ end
253
+
254
+ def find_namespace_uri_for_prefix_sax(element, prefix)
255
+ # Check current element and ancestors for namespace declarations
256
+ current = element
257
+ while current
258
+ # Check for xmlns:prefix attribute
259
+ xmlns_attr = "xmlns:#{prefix}"
260
+ return current.raw_attributes[xmlns_attr] if current.raw_attributes[xmlns_attr]
261
+
262
+ # Move to parent
263
+ current = current.parent
264
+ end
265
+
266
+ nil
267
+ end
268
+
195
269
  def check_all_elements(document, context)
196
270
  # Recursively check all elements in the document
197
271
  traverse_elements(document.root, context)
@@ -44,6 +44,17 @@ module SvgConform
44
44
  has_style_attribute?(node)
45
45
  end
46
46
 
47
+ def validate_sax_element(element, context)
48
+ case element.name
49
+ when "style"
50
+ check_style_element_sax(element, context) if check_style_elements
51
+ when "link"
52
+ check_link_element_sax(element, context) if check_link_elements
53
+ else
54
+ check_style_attribute_sax(element, context) if check_style_attributes
55
+ end
56
+ end
57
+
47
58
  private
48
59
 
49
60
  def check_style_element(node, context)
@@ -109,6 +120,69 @@ module SvgConform
109
120
  )
110
121
  end
111
122
 
123
+ def check_style_element_sax(element, context)
124
+ # Check for @import rules in style elements
125
+ content = element.text_content
126
+
127
+ if content =~ /@import\s+url\s*\(\s*['"]?([^'")\s]+)['"]?\s*\)/i
128
+ url = ::Regexp.last_match(1)
129
+ unless allowed_url?(url)
130
+ context.add_error(
131
+ requirement_id: id,
132
+ message: "External CSS import not allowed: #{url}",
133
+ node: element,
134
+ severity: :error
135
+ )
136
+ end
137
+ end
138
+
139
+ return unless content =~ /@import\s+['"]([^'"]+)['"]/i
140
+
141
+ url = ::Regexp.last_match(1)
142
+ return if allowed_url?(url)
143
+
144
+ context.add_error(
145
+ requirement_id: id,
146
+ message: "External CSS import not allowed: #{url}",
147
+ node: element,
148
+ severity: :error
149
+ )
150
+ end
151
+
152
+ def check_link_element_sax(element, context)
153
+ rel = element.raw_attributes["rel"]
154
+ href = element.raw_attributes["href"]
155
+
156
+ return unless rel&.downcase == "stylesheet" && href
157
+
158
+ return if allowed_url?(href)
159
+
160
+ context.add_error(
161
+ requirement_id: id,
162
+ message: "External CSS link not allowed: #{href}",
163
+ node: element,
164
+ severity: :error
165
+ )
166
+ end
167
+
168
+ def check_style_attribute_sax(element, context)
169
+ style_value = element.raw_attributes["style"]
170
+ return unless style_value
171
+
172
+ # Check for url() references in style attributes
173
+ return unless style_value =~ /url\s*\(\s*['"]?([^'")\s]+)['"]?\s*\)/i
174
+
175
+ url = ::Regexp.last_match(1)
176
+ return if allowed_url?(url)
177
+
178
+ context.add_error(
179
+ requirement_id: id,
180
+ message: "External URL reference in style attribute not allowed: #{url}",
181
+ node: element,
182
+ severity: :error
183
+ )
184
+ end
185
+
112
186
  def has_style_attribute?(node)
113
187
  !get_attribute(node, "style").nil?
114
188
  end
@@ -41,6 +41,17 @@ module SvgConform
41
41
  has_style_attribute?(node)
42
42
  end
43
43
 
44
+ def validate_sax_element(element, context)
45
+ case element.name
46
+ when "style"
47
+ check_style_element_sax(element, context) if check_style_fonts
48
+ when "font-face"
49
+ check_font_face_element_sax(element, context) if check_font_face
50
+ else
51
+ check_style_attribute_sax(element, context) if check_style_fonts
52
+ end
53
+ end
54
+
44
55
  private
45
56
 
46
57
  def check_style_element(node, context)
@@ -100,6 +111,53 @@ module SvgConform
100
111
  )
101
112
  end
102
113
 
114
+ def check_style_element_sax(element, context)
115
+ # Check for @font-face with external src in style elements
116
+ content = element.text_content
117
+
118
+ # Match @font-face blocks
119
+ content.scan(/@font-face\s*\{([^}]+)\}/m) do |match|
120
+ font_face_content = match[0]
121
+
122
+ # Check for src with url() that is not data: URI
123
+ if font_face_content =~ /src\s*:\s*url\s*\(\s*['"]?([^'")\s]+)['"]?\s*\)/i
124
+ url = ::Regexp.last_match(1)
125
+ unless embedded_font?(url)
126
+ context.add_error(
127
+ requirement_id: id,
128
+ message: "External font reference not allowed: #{url}. Fonts must be embedded as data URIs.",
129
+ node: element,
130
+ severity: :error
131
+ )
132
+ end
133
+ end
134
+ end
135
+ end
136
+
137
+ def check_font_face_element_sax(element, context)
138
+ # Note: For SAX, we can't traverse children yet
139
+ # This would need to be handled differently or deferred
140
+ # For now, skip XPath-based checking in SAX mode
141
+ end
142
+
143
+ def check_style_attribute_sax(element, context)
144
+ style_value = element.raw_attributes["style"]
145
+ return unless style_value
146
+
147
+ # Check for font-family with url() references
148
+ return unless style_value =~ /font-family\s*:\s*.*url\s*\(\s*['"]?([^'")\s]+)['"]?\s*\)/i
149
+
150
+ url = ::Regexp.last_match(1)
151
+ return if embedded_font?(url)
152
+
153
+ context.add_error(
154
+ requirement_id: id,
155
+ message: "External font URL in style attribute not allowed: #{url}. Fonts must be embedded as data URIs.",
156
+ node: element,
157
+ severity: :error
158
+ )
159
+ end
160
+
103
161
  def has_style_attribute?(node)
104
162
  !get_attribute(node, "style").nil?
105
163
  end
@@ -37,6 +37,15 @@ module SvgConform
37
37
  node.name == "image" || has_style_attribute?(node)
38
38
  end
39
39
 
40
+ def validate_sax_element(element, context)
41
+ case element.name
42
+ when "image"
43
+ check_image_element_sax(element, context) if check_image_elements
44
+ else
45
+ check_style_attribute_sax(element, context) if check_style_images
46
+ end
47
+ end
48
+
40
49
  private
41
50
 
42
51
  def check_image_element(node, context)
@@ -70,6 +79,37 @@ module SvgConform
70
79
  end
71
80
  end
72
81
 
82
+ def check_image_element_sax(element, context)
83
+ # Check href and xlink:href attributes
84
+ href = element.raw_attributes["href"] || element.raw_attributes["xlink:href"]
85
+ return unless href && !embedded_image?(href)
86
+
87
+ context.add_error(
88
+ requirement_id: id,
89
+ message: "External image reference not allowed: #{href}. Images must be embedded as data URIs.",
90
+ node: element,
91
+ severity: :error
92
+ )
93
+ end
94
+
95
+ def check_style_attribute_sax(element, context)
96
+ style_value = element.raw_attributes["style"]
97
+ return unless style_value
98
+
99
+ # Check for url() references to images in background, background-image, etc.
100
+ style_value.scan(/url\s*\(\s*['"]?([^'")\s]+)['"]?\s*\)/i) do
101
+ url = ::Regexp.last_match(1)
102
+ next if embedded_image?(url)
103
+
104
+ context.add_error(
105
+ requirement_id: id,
106
+ message: "External image URL in style attribute not allowed: #{url}. Images must be embedded as data URIs.",
107
+ node: element,
108
+ severity: :error
109
+ )
110
+ end
111
+ end
112
+
73
113
  def has_style_attribute?(node)
74
114
  !get_attribute(node, "style").nil?
75
115
  end
@@ -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)