svg_conform 0.2.4 → 0.2.6

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 (116) hide show
  1. checksums.yaml +4 -4
  2. data/.gitattributes +1 -0
  3. data/.github/workflows/rake.yml +3 -0
  4. data/Gemfile +7 -3
  5. data/Gemfile.lock +272 -0
  6. data/TODO.issue-100/00-overview.md +135 -0
  7. data/TODO.issue-100/01-build-lens-core-v1.md +92 -0
  8. data/TODO.issue-100/02-adopt-lens-core-in-svg.md +123 -0
  9. data/TODO.issue-100/03-ship-svg-1-1-full-static.md +138 -0
  10. data/TODO.issue-100/04-ship-svg-2-secure-static.md +131 -0
  11. data/TODO.issue-100/05-freeze-candidate-and-handoff.md +108 -0
  12. data/TODO.issue-100/90-progress.md +65 -0
  13. data/config/profiles/metanorma.yml +3 -3
  14. data/config/profiles/svg_1_1_full_static.yml +69 -0
  15. data/config/profiles/svg_2_secure_static.yml +55 -0
  16. data/config/requirements/processing_features_static.yml +29 -0
  17. data/data/svg_1_1/manifest.yml +7 -0
  18. data/data/svg_1_1/svg11-flat-20110816.dtd +5664 -0
  19. data/docs/api_reference.adoc +19 -1
  20. data/docs/cli_guide.adoc +35 -45
  21. data/docs/diagnostics.adoc +73 -0
  22. data/docs/lens_core_handoff.adoc +84 -0
  23. data/docs/profiles.adoc +94 -1
  24. data/lib/svg_conform/cli.rb +3 -3
  25. data/lib/svg_conform/commands/check.rb +11 -60
  26. data/lib/svg_conform/conformance_report.rb +79 -1
  27. data/lib/svg_conform/diagnostics/lens_core_adapter.rb +200 -0
  28. data/lib/svg_conform/document.rb +12 -1
  29. data/lib/svg_conform/node_helpers.rb +1 -1
  30. data/lib/svg_conform/profile.rb +11 -2
  31. data/lib/svg_conform/quality_metrics/complexity_calculator.rb +3 -3
  32. data/lib/svg_conform/references/base_reference.rb +153 -0
  33. data/lib/svg_conform/report_comparator.rb +4 -4
  34. data/lib/svg_conform/requirements/base_requirement.rb +6 -0
  35. data/lib/svg_conform/requirements/external_references_requirement.rb +234 -0
  36. data/lib/svg_conform/requirements/link_validation_requirement.rb +2 -0
  37. data/lib/svg_conform/requirements/processing_features_requirement.rb +175 -0
  38. data/lib/svg_conform/requirements/svg_1_1_document_requirement.rb +213 -0
  39. data/lib/svg_conform/requirements.rb +6 -0
  40. data/lib/svg_conform/sax_document.rb +8 -4
  41. data/lib/svg_conform/sax_validation_handler.rb +22 -1
  42. data/lib/svg_conform/validation/css2_core_grammar.rb +359 -0
  43. data/lib/svg_conform/validation/css_references.rb +122 -0
  44. data/lib/svg_conform/validation/error_tracker.rb +3 -1
  45. data/lib/svg_conform/validation/svg_1_1_document_conformance.rb +155 -0
  46. data/lib/svg_conform/validation_context.rb +25 -5
  47. data/lib/svg_conform/validation_result.rb +91 -16
  48. data/lib/svg_conform/version.rb +1 -1
  49. data/lib/svg_conform.rb +24 -0
  50. data/spec/fixtures/diagnostics/external-reference-info.svg +6 -0
  51. data/spec/fixtures/plantuml/README.adoc +11 -0
  52. data/spec/fixtures/plantuml/unit-type.svg +1 -0
  53. data/spec/fixtures/profiles/svg_1_1_full_static/invalid/animation.svg +4 -0
  54. data/spec/fixtures/profiles/svg_1_1_full_static/invalid/css-grammar.svg +4 -0
  55. data/spec/fixtures/profiles/svg_1_1_full_static/invalid/dtd-attribute.svg +4 -0
  56. data/spec/fixtures/profiles/svg_1_1_full_static/invalid/dtd-content-model.svg +4 -0
  57. data/spec/fixtures/profiles/svg_1_1_full_static/invalid/interaction.svg +4 -0
  58. data/spec/fixtures/profiles/svg_1_1_full_static/invalid/malformed-xml.svg +4 -0
  59. data/spec/fixtures/profiles/svg_1_1_full_static/invalid/script.svg +5 -0
  60. data/spec/fixtures/profiles/svg_1_1_full_static/invalid/wrong-namespace.svg +4 -0
  61. data/spec/fixtures/profiles/svg_1_1_full_static/invalid/wrong-version.svg +4 -0
  62. data/spec/fixtures/profiles/svg_1_1_full_static/invalid/xml-stylesheet.svg +5 -0
  63. data/spec/fixtures/profiles/svg_1_1_full_static/valid/css.svg +5 -0
  64. data/spec/fixtures/profiles/svg_1_1_full_static/valid/foreign-metadata.svg +6 -0
  65. data/spec/fixtures/profiles/svg_1_1_full_static/valid/minimal-full.svg +5 -0
  66. data/spec/fixtures/profiles/svg_1_1_full_static/valid/xml-stylesheet.svg +5 -0
  67. data/spec/fixtures/profiles/svg_2_secure_static/invalid/animation.svg +4 -0
  68. data/spec/fixtures/profiles/svg_2_secure_static/invalid/css-import.svg +4 -0
  69. data/spec/fixtures/profiles/svg_2_secure_static/invalid/css-url.svg +4 -0
  70. data/spec/fixtures/profiles/svg_2_secure_static/invalid/data-bad-png.svg +4 -0
  71. data/spec/fixtures/profiles/svg_2_secure_static/invalid/data-empty-png.svg +4 -0
  72. data/spec/fixtures/profiles/svg_2_secure_static/invalid/data-malformed-base64.svg +4 -0
  73. data/spec/fixtures/profiles/svg_2_secure_static/invalid/data-malformed-percent.svg +4 -0
  74. data/spec/fixtures/profiles/svg_2_secure_static/invalid/data-unsupported-type.svg +4 -0
  75. data/spec/fixtures/profiles/svg_2_secure_static/invalid/file-like-fragment.svg +4 -0
  76. data/spec/fixtures/profiles/svg_2_secure_static/invalid/http.svg +4 -0
  77. data/spec/fixtures/profiles/svg_2_secure_static/invalid/https.svg +4 -0
  78. data/spec/fixtures/profiles/svg_2_secure_static/invalid/interaction.svg +4 -0
  79. data/spec/fixtures/profiles/svg_2_secure_static/invalid/protocol-relative.svg +4 -0
  80. data/spec/fixtures/profiles/svg_2_secure_static/invalid/relative-file-fragment.svg +4 -0
  81. data/spec/fixtures/profiles/svg_2_secure_static/invalid/relative-file.svg +4 -0
  82. data/spec/fixtures/profiles/svg_2_secure_static/invalid/script.svg +4 -0
  83. data/spec/fixtures/profiles/svg_2_secure_static/invalid/svg-data-excessive-nesting.svg +4 -0
  84. data/spec/fixtures/profiles/svg_2_secure_static/invalid/svg-data-malformed-xml.svg +4 -0
  85. data/spec/fixtures/profiles/svg_2_secure_static/invalid/svg-data-nested-external.svg +4 -0
  86. data/spec/fixtures/profiles/svg_2_secure_static/invalid/urn.svg +4 -0
  87. data/spec/fixtures/profiles/svg_2_secure_static/invalid/xml-stylesheet.svg +5 -0
  88. data/spec/fixtures/profiles/svg_2_secure_static/valid/jpeg-base64.svg +4 -0
  89. data/spec/fixtures/profiles/svg_2_secure_static/valid/jpeg-percent.svg +4 -0
  90. data/spec/fixtures/profiles/svg_2_secure_static/valid/png-base64.svg +4 -0
  91. data/spec/fixtures/profiles/svg_2_secure_static/valid/png-percent.svg +4 -0
  92. data/spec/fixtures/profiles/svg_2_secure_static/valid/same-document-fragment.svg +4 -0
  93. data/spec/fixtures/profiles/svg_2_secure_static/valid/svg-base64.svg +4 -0
  94. data/spec/fixtures/profiles/svg_2_secure_static/valid/svg-percent.svg +4 -0
  95. data/spec/svg_conform/commands/check_command_spec.rb +22 -2
  96. data/spec/svg_conform/commands/profiles_command_spec.rb +18 -0
  97. data/spec/svg_conform/conformance_report_spec.rb +90 -0
  98. data/spec/svg_conform/diagnostics/lens_core_adapter_spec.rb +136 -0
  99. data/spec/svg_conform/gemspec_spec.rb +21 -0
  100. data/spec/svg_conform/profiles/svg_1_1_full_static_profile_spec.rb +146 -0
  101. data/spec/svg_conform/profiles/svg_2_secure_static_profile_spec.rb +150 -0
  102. data/spec/svg_conform/profiles_plantuml_spec.rb +81 -0
  103. data/spec/svg_conform/references/data_uri_reference_spec.rb +97 -0
  104. data/spec/svg_conform/references/integration_spec.rb +30 -0
  105. data/spec/svg_conform/references/reference_classifier_spec.rb +17 -0
  106. data/spec/svg_conform/requirements/external_references_requirement_spec.rb +109 -0
  107. data/spec/svg_conform/requirements/link_validation_requirement_spec.rb +16 -0
  108. data/spec/svg_conform/requirements/processing_features_requirement_spec.rb +75 -0
  109. data/spec/svg_conform/requirements/svg_1_1_document_requirement_spec.rb +141 -0
  110. data/spec/svg_conform/validation/css2_core_grammar_spec.rb +116 -0
  111. data/spec/svg_conform/validation/svg_1_1_document_conformance_spec.rb +144 -0
  112. data/spec/svg_conform/validation_context_source_plumbing_spec.rb +122 -0
  113. data/spec/svg_conform/validation_context_spec.rb +39 -0
  114. data/spec/svg_conform/validation_result_spec.rb +123 -0
  115. data/svg_conform.gemspec +3 -2
  116. metadata +102 -6
@@ -0,0 +1,200 @@
1
+ # frozen_string_literal: true
2
+
3
+ begin
4
+ require "lens_core"
5
+ rescue LoadError
6
+ nil
7
+ end
8
+
9
+ module SvgConform
10
+ module Diagnostics
11
+ # Raised when an issue's data holds a value that cannot be written as
12
+ # JSON (for example an arbitrary Ruby object).
13
+ class UnsupportedContextError < SvgConform::Error; end
14
+
15
+ # Maps a ValidationResult onto one LensCore::Report.
16
+ #
17
+ # errors -> error, kind: violation type, else "validation"
18
+ # validity_errors -> error, kind: "validity"
19
+ # warnings -> warning, kind: violation type, else "validation"
20
+ # infos -> info, kind: violation type, else "notice"
21
+ #
22
+ # Order within each channel follows the source collections; channels are
23
+ # emitted in the order above.
24
+ module LensCoreAdapter
25
+ PRODUCER = "svg_conform"
26
+ REMEDIATION_FIELDS = %i[fixable remediable remediation_type
27
+ remediation_confidence affects_content].freeze
28
+
29
+ module_function
30
+
31
+ # @param validation_result [SvgConform::ValidationResult]
32
+ # @param filename [String, nil] overrides the document's file path
33
+ # @param timestamp [Time, String, nil] caller supplied, never defaulted
34
+ # @return [LensCore::Report]
35
+ # @raise [UnsupportedContextError] for non-JSON issue data
36
+ def call(validation_result, filename: nil, timestamp: nil)
37
+ profile = validation_result.profile
38
+ LensCore::Report.new(
39
+ producer: LensCore::Producer.new(name: PRODUCER,
40
+ version: SvgConform::VERSION),
41
+ profile: LensCore::Profile.new(
42
+ id: profile.name.to_s,
43
+ citations: citations(profile.specification_uri,
44
+ profile.specification_label),
45
+ ),
46
+ valid: validation_result.valid?,
47
+ diagnostics: diagnostics(validation_result, profile),
48
+ timestamp: timestamp,
49
+ extensions: file_extension(validation_result, filename),
50
+ )
51
+ end
52
+
53
+ def file_extension(result, filename)
54
+ file = filename || result.document&.file_path
55
+ file ? { "file" => file.to_s } : {}
56
+ end
57
+
58
+ def diagnostics(result, profile)
59
+ [
60
+ *convert(result.errors, "error", "validation", profile),
61
+ *convert(result.validity_errors, "error", "validity", profile,
62
+ force_kind: true),
63
+ *convert(result.warnings, "warning", "validation", profile),
64
+ *convert(result.infos, "info", "notice", profile, remediation: false),
65
+ ]
66
+ end
67
+
68
+ def convert(issues, severity, fallback_kind, profile, force_kind: false,
69
+ remediation: true)
70
+ issues.map do |issue|
71
+ kind = force_kind ? fallback_kind : kind_for(issue, fallback_kind)
72
+ build(issue, severity, kind, profile, remediation)
73
+ end
74
+ end
75
+
76
+ def build(issue, severity, kind, profile, remediation)
77
+ requirement_id = issue.requirement_id
78
+ LensCore::Diagnostic.new(
79
+ severity: severity, kind: kind, requirement_id: requirement_id,
80
+ message: issue.message.to_s,
81
+ citations: issue_citations(issue, profile, requirement_id),
82
+ remediation: (issue.suggested_action if remediation && issue.remediable?),
83
+ context: context(issue, remediation),
84
+ location: location(issue)
85
+ )
86
+ end
87
+
88
+ def kind_for(issue, fallback)
89
+ type = issue.violation_type
90
+ return fallback if type.nil? || type.to_s.empty? ||
91
+ type.to_s == "unknown_violation"
92
+
93
+ type.to_s
94
+ end
95
+
96
+ # A requirement's own citation, else the one an issue carries in its
97
+ # data (a requirement reporting several IDs, such as the processing
98
+ # categories, cites per issue).
99
+ def issue_citations(issue, profile, requirement_id)
100
+ found = requirement_citations(profile, requirement_id)
101
+ return found unless found.empty?
102
+
103
+ data = issue.data
104
+ uri = data.is_a?(Hash) ? (data[:citation] || data["citation"]) : nil
105
+ citations(uri, nil)
106
+ end
107
+
108
+ def requirement_citations(profile, requirement_id)
109
+ requirement = profile.get_requirement(requirement_id)
110
+ return [] unless requirement
111
+
112
+ citations(requirement.citation_uri, requirement.citation_label)
113
+ end
114
+
115
+ def citations(uri, label)
116
+ return [] if uri.nil? || uri.to_s.empty?
117
+
118
+ [LensCore::Citation.new(uri: uri.to_s, label: label&.to_s)]
119
+ end
120
+
121
+ def context(issue, remediation)
122
+ context = {}
123
+ element = issue.element_name
124
+ context["element"] = element.to_s if element
125
+ if remediation
126
+ REMEDIATION_FIELDS.each do |field|
127
+ context[field.to_s] = json_value(remediation_value(issue, field),
128
+ field.to_s)
129
+ end
130
+ end
131
+ data = issue.data
132
+ unless data.nil? || (data.respond_to?(:empty?) && data.empty?)
133
+ context["data"] = json_value(data, "data")
134
+ end
135
+ context
136
+ end
137
+
138
+ def remediation_value(issue, field)
139
+ case field
140
+ when :fixable then issue.fixable?
141
+ when :remediable then issue.remediable?
142
+ when :affects_content then issue.affects_content?
143
+ else issue.public_send(field)
144
+ end
145
+ end
146
+
147
+ def location(issue)
148
+ node = issue.node
149
+ line = issue.line || data_coordinate(issue, :line)
150
+ column = issue.column || data_coordinate(issue, :column)
151
+ path = node.respond_to?(:path_id) ? node.path_id : nil
152
+ line = nil unless line.is_a?(Integer) && line >= 1
153
+ column = nil unless column.is_a?(Integer) && column >= 1
154
+ return nil if line.nil? && column.nil? && path.nil?
155
+
156
+ LensCore::Location.xml(line: line, column: column, path: path)
157
+ end
158
+
159
+ # Source-level requirements have no node and keep their position in data
160
+ def data_coordinate(issue, key)
161
+ data = issue.data
162
+ data.is_a?(Hash) ? data[key] : nil
163
+ end
164
+
165
+ # Deep copy into JSON primitives. Symbols become strings; anything else
166
+ # that JSON cannot carry fails with a stable error naming the path.
167
+ def json_value(value, path)
168
+ case value
169
+ when nil, true, false, Integer, String then value
170
+ when Float then json_float(value, path)
171
+ when Symbol then value.to_s
172
+ when Array
173
+ value.each_with_index.map { |v, i| json_value(v, "#{path}[#{i}]") }
174
+ when Hash then json_hash(value, path)
175
+ else
176
+ raise UnsupportedContextError,
177
+ "unsupported value of type #{value.class} at #{path}; " \
178
+ "issue data must be JSON-safe"
179
+ end
180
+ end
181
+
182
+ def json_float(value, path)
183
+ return value if value.finite?
184
+
185
+ raise UnsupportedContextError,
186
+ "unsupported non-finite number at #{path}"
187
+ end
188
+
189
+ def json_hash(hash, path)
190
+ hash.to_h do |key, v|
191
+ unless key.is_a?(String) || key.is_a?(Symbol)
192
+ raise UnsupportedContextError,
193
+ "unsupported key of type #{key.class} at #{path}"
194
+ end
195
+ [key.to_s, json_value(v, "#{path}.#{key}")]
196
+ end
197
+ end
198
+ end
199
+ end
200
+ end
@@ -8,6 +8,16 @@ module SvgConform
8
8
  class Document
9
9
  attr_reader :file_path, :moxml_document
10
10
 
11
+ # Original input text; a serialization when built via from_node
12
+ def source
13
+ @content
14
+ end
15
+
16
+ # True when #source was serialized from a node, not the original input
17
+ def source_serialized?
18
+ @source_serialized ? true : false
19
+ end
20
+
11
21
  def initialize(content_or_path)
12
22
  if File.exist?(content_or_path.to_s)
13
23
  @file_path = content_or_path
@@ -33,13 +43,14 @@ module SvgConform
33
43
  # Avoids re-parsing when input is already a DOM object
34
44
  def self.from_node(node)
35
45
  doc = allocate
46
+ doc.instance_variable_set(:@source_serialized, true)
36
47
  doc.instance_variable_set(:@file_path, nil)
37
48
  doc.instance_variable_set(:@xpath_cache, {})
38
49
 
39
50
  # Convert node to Moxml document
40
51
  if node.respond_to?(:to_xml) && node.class.name.start_with?("Moxml")
41
52
  # Already a Moxml node, wrap it
42
- doc.instance_variable_set(:@content, nil) # Don't store XML string
53
+ doc.instance_variable_set(:@content, node.to_xml)
43
54
  doc.instance_variable_set(:@moxml_document,
44
55
  node.is_a?(Moxml::Document) ? node : wrap_moxml_element(node))
45
56
  elsif defined?(Leptris) && node.is_a?(Leptris::XML::Node)
@@ -66,7 +66,7 @@ module SvgConform
66
66
  # the wrapper's #remove works on every adapter.
67
67
  local_name = name.include?(":") ? name.split(":", 2).last : name
68
68
  attr = Array(node.attributes).find do |a|
69
- a.respond_to?(:name) && (a.name == name || a.name == local_name) &&
69
+ a.respond_to?(:name) && [name, local_name].include?(a.name) &&
70
70
  a.respond_to?(:remove)
71
71
  end
72
72
  return false unless attr
@@ -15,6 +15,8 @@ module SvgConform
15
15
  attribute :name, :string
16
16
  attribute :description, :string
17
17
  attribute :import, :string
18
+ attribute :specification_uri, :string
19
+ attribute :specification_label, :string
18
20
  attribute :requirements, SvgConform::Requirements::BaseRequirement,
19
21
  collection: true, polymorphic: true
20
22
  attribute :remediations, SvgConform::Remediations::BaseRemediation,
@@ -48,6 +50,8 @@ module SvgConform
48
50
  map "name", to: :name
49
51
  map "description", to: :description
50
52
  map "import", to: :import
53
+ map "specification_uri", to: :specification_uri
54
+ map "specification_label", to: :specification_label
51
55
  map "requirements", to: :requirements, polymorphic: {
52
56
  attribute: "type",
53
57
  class_map: REQUIREMENTS_CLASS_MAP,
@@ -122,10 +126,15 @@ module SvgConform
122
126
  # If it's a Document object, it's already loaded into DOM
123
127
  # Use DOM validation (safe, already in memory)
124
128
  if document.is_a?(Document)
125
- context = ValidationContext.new(document, self)
129
+ context = ValidationContext.new(
130
+ document, self,
131
+ source: document.source, source_path: document.file_path,
132
+ source_serialized: document.source_serialized?
133
+ )
126
134
 
127
135
  # Validate using requirements system
128
136
  requirements&.each do |requirement|
137
+ requirement.validate_source(document.source, context)
129
138
  requirement.validate_document(document, context)
130
139
  end
131
140
 
@@ -134,7 +143,7 @@ module SvgConform
134
143
  # For other document objects, serialize and use SAX for safety
135
144
  # (This handles leptris/Moxml documents that aren't wrapped in Document)
136
145
  content = document.to_xml
137
- sax_doc = SaxDocument.from_content(content)
146
+ sax_doc = SaxDocument.from_content(content, source_serialized: true)
138
147
  sax_doc.validate_with_profile(self)
139
148
  else
140
149
  # Fallback to DOM mode for non-serializable objects
@@ -63,12 +63,12 @@ module SvgConform
63
63
  # Track opening and closing tags to estimate nesting
64
64
  depth = 0
65
65
  max_depth = 0
66
- in_tag = false
66
+ false
67
67
 
68
68
  content.each_char do |char|
69
69
  case char
70
70
  when "<"
71
- in_tag = true
71
+ true
72
72
  # Check if it's a closing tag
73
73
  if content[content.index(char) + 1] == "/"
74
74
  depth -= 1
@@ -77,7 +77,7 @@ module SvgConform
77
77
  max_depth = [max_depth, depth].max
78
78
  end
79
79
  when ">"
80
- in_tag = false
80
+ false
81
81
  end
82
82
  end
83
83
 
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "lutaml/model"
4
+ require "zlib"
4
5
 
5
6
  module SvgConform
6
7
  module References
@@ -116,6 +117,9 @@ module SvgConform
116
117
 
117
118
  # Data URI reference (data:*)
118
119
  class DataUriReference < BaseReference
120
+ PARSE = /\Adata:(?<head>[^,]*),(?<body>.*)\z/mi
121
+ PNG_SIGNATURE = "\x89PNG\r\n\x1A\n".b.freeze
122
+
119
123
  def validation_scope
120
124
  :internal
121
125
  end
@@ -125,6 +129,155 @@ module SvgConform
125
129
  match = value.match(%r{^data:([^;,]+)})
126
130
  match ? match[1] : nil
127
131
  end
132
+
133
+ # Lowercased media type, defaulting per RFC 2397 when omitted
134
+ def normalized_media_type
135
+ (media_type || "text/plain").strip.downcase
136
+ end
137
+
138
+ def base64?
139
+ match = PARSE.match(value)
140
+ return false unless match
141
+
142
+ match[:head].split(";").map { |part| part.strip.downcase }
143
+ .include?("base64")
144
+ end
145
+
146
+ # Raw payload bytes (binary string), or nil when #decode_error is set
147
+ def payload
148
+ decode unless defined?(@payload)
149
+ @payload
150
+ end
151
+
152
+ # Why the payload cannot be decoded, or nil
153
+ def decode_error
154
+ decode unless defined?(@payload)
155
+ @decode_error
156
+ end
157
+
158
+ # Why the decoded raster payload is not a PNG/JPEG file, or nil.
159
+ # Only meaningful for image/png and image/jpeg media types.
160
+ def raster_error
161
+ return decode_error if decode_error
162
+ return "empty payload" if payload.empty?
163
+
164
+ case normalized_media_type
165
+ when "image/png" then png_error(payload)
166
+ when "image/jpeg" then jpeg_error(payload)
167
+ end
168
+ end
169
+
170
+ private
171
+
172
+ def decode
173
+ @payload = nil
174
+ @decode_error = nil
175
+ match = PARSE.match(value)
176
+ unless match
177
+ @decode_error = "missing ',' separator"
178
+ return
179
+ end
180
+
181
+ body = match[:body]
182
+ if base64?
183
+ decode_base64(body)
184
+ else
185
+ decode_percent(body)
186
+ end
187
+ end
188
+
189
+ def decode_base64(body)
190
+ require "base64"
191
+ @payload = Base64.strict_decode64(percent_unescape(body).delete("\s"))
192
+ rescue ArgumentError
193
+ @decode_error = "malformed base64 payload"
194
+ end
195
+
196
+ def decode_percent(body)
197
+ @payload = percent_unescape(body)
198
+ rescue ArgumentError
199
+ @decode_error = "malformed percent-encoding"
200
+ end
201
+
202
+ def percent_unescape(text)
203
+ raise ArgumentError, "bad escape" if text.match?(/%(?!\h\h)/)
204
+
205
+ text.b.gsub(/%(\h\h)/) { Regexp.last_match(1).hex.chr }
206
+ end
207
+
208
+ def png_error(bytes)
209
+ return "not a PNG file" unless bytes.start_with?(PNG_SIGNATURE)
210
+
211
+ offset = PNG_SIGNATURE.bytesize
212
+ first = true
213
+ while offset + 12 <= bytes.bytesize
214
+ length = bytes.byteslice(offset, 4).unpack1("N")
215
+ type = bytes.byteslice(offset + 4, 4)
216
+ return "PNG must start with IHDR" if first && type != "IHDR"
217
+ return "truncated PNG chunk" if offset + 12 + length > bytes.bytesize
218
+
219
+ data = bytes.byteslice(offset + 4, 4 + length)
220
+ crc = bytes.byteslice(offset + 8 + length, 4).unpack1("N")
221
+ return "PNG chunk checksum mismatch" unless Zlib.crc32(data) == crc
222
+ return nil if type == "IEND"
223
+
224
+ first = false
225
+ offset += 12 + length
226
+ end
227
+ "PNG is missing IEND"
228
+ end
229
+
230
+ # Walks the marker segments: every segment with a length must fit in the
231
+ # payload, entropy-coded scan data is skipped to the next marker, and the
232
+ # file must end with EOI. This proves the structure, not that pixels decode.
233
+ def jpeg_error(bytes)
234
+ return "not a JPEG file" unless bytes.start_with?("\xFF\xD8\xFF".b)
235
+
236
+ offset = 2
237
+ while offset < bytes.bytesize
238
+ return "malformed JPEG marker" unless bytes.getbyte(offset) == 0xFF
239
+
240
+ offset += 1 while bytes.getbyte(offset + 1) == 0xFF
241
+ marker = bytes.getbyte(offset + 1)
242
+ return "truncated JPEG" if marker.nil?
243
+ return jpeg_trailer_error(bytes, offset + 2) if marker == 0xD9
244
+
245
+ offset = jpeg_next_offset(bytes, offset + 2, marker)
246
+ return offset if offset.is_a?(String)
247
+ end
248
+ "truncated JPEG"
249
+ end
250
+
251
+ def jpeg_trailer_error(bytes, offset)
252
+ offset == bytes.bytesize ? nil : "data after JPEG end marker"
253
+ end
254
+
255
+ # Offset of the marker after the segment (and scan data) at +offset+,
256
+ # or an error String
257
+ def jpeg_next_offset(bytes, offset, marker)
258
+ return offset if marker == 0x01 || marker.between?(0xD0, 0xD8)
259
+
260
+ length = bytes.byteslice(offset, 2)&.unpack1("n")
261
+ return "truncated JPEG segment" if length.nil?
262
+ return "invalid JPEG segment length" if length < 2
263
+ return "truncated JPEG segment" if offset + length > bytes.bytesize
264
+
265
+ offset += length
266
+ marker == 0xDA ? jpeg_skip_scan(bytes, offset) : offset
267
+ end
268
+
269
+ def jpeg_skip_scan(bytes, offset)
270
+ while offset < bytes.bytesize
271
+ if bytes.getbyte(offset) == 0xFF
272
+ following = bytes.getbyte(offset + 1)
273
+ return offset unless following.nil? || following.zero? || following.between?(0xD0, 0xD7)
274
+
275
+ offset += 1 if following
276
+ end
277
+ offset += 1
278
+ end
279
+ "truncated JPEG"
280
+ end
128
281
  end
129
282
  end
130
283
  end
@@ -752,10 +752,10 @@ filename)
752
752
  render_gh_style_table(headers, rows)
753
753
 
754
754
  puts "\n#{Paint['📊 COMPATIBILITY METRICS:', :blue, :bold]}"
755
- puts " 🗂️ Total files processed: #{Paint[total_files.to_s, :cyan,
756
- :bold]}"
757
- puts " ✅ Files with matching error counts: #{Paint[matching_files.to_s,
758
- :green, :bold]}"
755
+ total_label = Paint[total_files.to_s, :cyan, :bold]
756
+ matching_label = Paint[matching_files.to_s, :green, :bold]
757
+ puts " 🗂️ Total files processed: #{total_label}"
758
+ puts " ✅ Files with matching error counts: #{matching_label}"
759
759
 
760
760
  # Color-code the percentage based on value
761
761
  percentage_color = if match_percentage >= 90
@@ -95,6 +95,8 @@ module SvgConform
95
95
 
96
96
  attribute :id, :string
97
97
  attribute :description, :string
98
+ attribute :citation_uri, :string
99
+ attribute :citation_label, :string
98
100
  attribute :type, :string, polymorphic_class: true, default: -> {
99
101
  self.class.name&.split("::")&.last
100
102
  }
@@ -102,6 +104,8 @@ module SvgConform
102
104
  yaml do
103
105
  map "id", to: :id
104
106
  map "description", to: :description
107
+ map "citation_uri", to: :citation_uri
108
+ map "citation_label", to: :citation_label
105
109
  map "type", to: :type
106
110
  end
107
111
 
@@ -121,6 +125,8 @@ module SvgConform
121
125
 
122
126
  # Called for each element during SAX parsing
123
127
  # Override in subclasses for immediate validation
128
+ def validate_source(source, context); end
129
+
124
130
  def validate_sax_element(element, context)
125
131
  # Default: Empty - subclasses must override for SAX support
126
132
  # Cannot call check() here as it's abstract