svg_conform 0.2.4 → 0.2.7
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.
- checksums.yaml +4 -4
- data/.gitattributes +1 -0
- data/.github/workflows/rake.yml +4 -1
- data/Gemfile +6 -3
- data/Gemfile.lock +211 -0
- data/TODO.issue-100/00-overview.md +135 -0
- data/TODO.issue-100/01-build-lens-core-v1.md +92 -0
- data/TODO.issue-100/02-adopt-lens-core-in-svg.md +123 -0
- data/TODO.issue-100/03-ship-svg-1-1-full-static.md +138 -0
- data/TODO.issue-100/04-ship-svg-2-secure-static.md +131 -0
- data/TODO.issue-100/05-freeze-candidate-and-handoff.md +108 -0
- data/TODO.issue-100/90-progress.md +65 -0
- data/config/profiles/metanorma.yml +4 -4
- data/config/profiles/svg_1_1_full_static.yml +69 -0
- data/config/profiles/svg_2_secure_static.yml +55 -0
- data/config/requirements/processing_features_static.yml +29 -0
- data/data/svg_1_1/manifest.yml +7 -0
- data/data/svg_1_1/svg11-flat-20110816.dtd +5664 -0
- data/docs/api_reference.adoc +19 -1
- data/docs/cli_guide.adoc +35 -45
- data/docs/diagnostics.adoc +73 -0
- data/docs/lens_core_handoff.adoc +84 -0
- data/docs/profiles.adoc +94 -1
- data/lib/svg_conform/cli.rb +3 -4
- data/lib/svg_conform/commands/check.rb +11 -60
- data/lib/svg_conform/conformance_report.rb +79 -1
- data/lib/svg_conform/diagnostics/lens_core_adapter.rb +200 -0
- data/lib/svg_conform/document.rb +12 -1
- data/lib/svg_conform/external_checkers/svgcheck/parser.rb +1 -3
- data/lib/svg_conform/node_helpers.rb +1 -1
- data/lib/svg_conform/profile.rb +11 -2
- data/lib/svg_conform/quality_metrics/complexity_calculator.rb +3 -3
- data/lib/svg_conform/quality_metrics/configuration.rb +4 -2
- data/lib/svg_conform/references/base_reference.rb +153 -0
- data/lib/svg_conform/references/reference_manifest.rb +1 -2
- data/lib/svg_conform/report_comparator.rb +4 -4
- data/lib/svg_conform/requirements/base_requirement.rb +6 -0
- data/lib/svg_conform/requirements/external_references_requirement.rb +234 -0
- data/lib/svg_conform/requirements/link_validation_requirement.rb +2 -0
- data/lib/svg_conform/requirements/processing_features_requirement.rb +175 -0
- data/lib/svg_conform/requirements/svg_1_1_document_requirement.rb +213 -0
- data/lib/svg_conform/requirements.rb +6 -0
- data/lib/svg_conform/sax_document.rb +8 -4
- data/lib/svg_conform/sax_validation_handler.rb +22 -1
- data/lib/svg_conform/validation/css2_core_grammar.rb +359 -0
- data/lib/svg_conform/validation/css_references.rb +122 -0
- data/lib/svg_conform/validation/error_tracker.rb +40 -9
- data/lib/svg_conform/validation/svg_1_1_document_conformance.rb +154 -0
- data/lib/svg_conform/validation_context.rb +25 -5
- data/lib/svg_conform/validation_result.rb +91 -16
- data/lib/svg_conform/version.rb +1 -1
- data/lib/svg_conform/yaml_io.rb +20 -0
- data/lib/svg_conform.rb +25 -0
- data/spec/fixtures/diagnostics/external-reference-info.svg +6 -0
- data/spec/fixtures/plantuml/README.adoc +11 -0
- data/spec/fixtures/plantuml/unit-type.svg +1 -0
- data/spec/fixtures/profiles/svg_1_1_full_static/invalid/animation.svg +4 -0
- data/spec/fixtures/profiles/svg_1_1_full_static/invalid/css-grammar.svg +4 -0
- data/spec/fixtures/profiles/svg_1_1_full_static/invalid/dtd-attribute.svg +4 -0
- data/spec/fixtures/profiles/svg_1_1_full_static/invalid/dtd-content-model.svg +4 -0
- data/spec/fixtures/profiles/svg_1_1_full_static/invalid/interaction.svg +4 -0
- data/spec/fixtures/profiles/svg_1_1_full_static/invalid/malformed-xml.svg +4 -0
- data/spec/fixtures/profiles/svg_1_1_full_static/invalid/script.svg +5 -0
- data/spec/fixtures/profiles/svg_1_1_full_static/invalid/wrong-namespace.svg +4 -0
- data/spec/fixtures/profiles/svg_1_1_full_static/invalid/wrong-version.svg +4 -0
- data/spec/fixtures/profiles/svg_1_1_full_static/invalid/xml-stylesheet.svg +5 -0
- data/spec/fixtures/profiles/svg_1_1_full_static/valid/css.svg +5 -0
- data/spec/fixtures/profiles/svg_1_1_full_static/valid/foreign-metadata.svg +6 -0
- data/spec/fixtures/profiles/svg_1_1_full_static/valid/minimal-full.svg +5 -0
- data/spec/fixtures/profiles/svg_1_1_full_static/valid/xml-stylesheet.svg +5 -0
- data/spec/fixtures/profiles/svg_2_secure_static/invalid/animation.svg +4 -0
- data/spec/fixtures/profiles/svg_2_secure_static/invalid/css-import.svg +4 -0
- data/spec/fixtures/profiles/svg_2_secure_static/invalid/css-url.svg +4 -0
- data/spec/fixtures/profiles/svg_2_secure_static/invalid/data-bad-png.svg +4 -0
- data/spec/fixtures/profiles/svg_2_secure_static/invalid/data-empty-png.svg +4 -0
- data/spec/fixtures/profiles/svg_2_secure_static/invalid/data-malformed-base64.svg +4 -0
- data/spec/fixtures/profiles/svg_2_secure_static/invalid/data-malformed-percent.svg +4 -0
- data/spec/fixtures/profiles/svg_2_secure_static/invalid/data-unsupported-type.svg +4 -0
- data/spec/fixtures/profiles/svg_2_secure_static/invalid/file-like-fragment.svg +4 -0
- data/spec/fixtures/profiles/svg_2_secure_static/invalid/http.svg +4 -0
- data/spec/fixtures/profiles/svg_2_secure_static/invalid/https.svg +4 -0
- data/spec/fixtures/profiles/svg_2_secure_static/invalid/interaction.svg +4 -0
- data/spec/fixtures/profiles/svg_2_secure_static/invalid/protocol-relative.svg +4 -0
- data/spec/fixtures/profiles/svg_2_secure_static/invalid/relative-file-fragment.svg +4 -0
- data/spec/fixtures/profiles/svg_2_secure_static/invalid/relative-file.svg +4 -0
- data/spec/fixtures/profiles/svg_2_secure_static/invalid/script.svg +4 -0
- data/spec/fixtures/profiles/svg_2_secure_static/invalid/svg-data-excessive-nesting.svg +4 -0
- data/spec/fixtures/profiles/svg_2_secure_static/invalid/svg-data-malformed-xml.svg +4 -0
- data/spec/fixtures/profiles/svg_2_secure_static/invalid/svg-data-nested-external.svg +4 -0
- data/spec/fixtures/profiles/svg_2_secure_static/invalid/urn.svg +4 -0
- data/spec/fixtures/profiles/svg_2_secure_static/invalid/xml-stylesheet.svg +5 -0
- data/spec/fixtures/profiles/svg_2_secure_static/valid/jpeg-base64.svg +4 -0
- data/spec/fixtures/profiles/svg_2_secure_static/valid/jpeg-percent.svg +4 -0
- data/spec/fixtures/profiles/svg_2_secure_static/valid/png-base64.svg +4 -0
- data/spec/fixtures/profiles/svg_2_secure_static/valid/png-percent.svg +4 -0
- data/spec/fixtures/profiles/svg_2_secure_static/valid/same-document-fragment.svg +4 -0
- data/spec/fixtures/profiles/svg_2_secure_static/valid/svg-base64.svg +4 -0
- data/spec/fixtures/profiles/svg_2_secure_static/valid/svg-percent.svg +4 -0
- data/spec/fixtures/quality_metrics/aliases.yml +7 -0
- data/spec/svg_conform/commands/check_command_spec.rb +22 -2
- data/spec/svg_conform/commands/profiles_command_spec.rb +18 -0
- data/spec/svg_conform/conformance_report_spec.rb +90 -0
- data/spec/svg_conform/diagnostics/lens_core_adapter_spec.rb +136 -0
- data/spec/svg_conform/gemspec_spec.rb +34 -0
- data/spec/svg_conform/profiles/metanorma_profile_spec.rb +48 -0
- data/spec/svg_conform/profiles/svg_1_1_full_static_profile_spec.rb +146 -0
- data/spec/svg_conform/profiles/svg_2_secure_static_profile_spec.rb +150 -0
- data/spec/svg_conform/profiles_plantuml_spec.rb +81 -0
- data/spec/svg_conform/quality_metrics/configuration_spec.rb +82 -0
- data/spec/svg_conform/references/data_uri_reference_spec.rb +97 -0
- data/spec/svg_conform/references/integration_spec.rb +30 -0
- data/spec/svg_conform/references/reference_classifier_spec.rb +17 -0
- data/spec/svg_conform/requirements/external_references_requirement_spec.rb +109 -0
- data/spec/svg_conform/requirements/link_validation_requirement_spec.rb +16 -0
- data/spec/svg_conform/requirements/processing_features_requirement_spec.rb +75 -0
- data/spec/svg_conform/requirements/svg_1_1_document_requirement_spec.rb +141 -0
- data/spec/svg_conform/requirements/text_as_path_requirement_spec.rb +9 -5
- data/spec/svg_conform/validation/css2_core_grammar_spec.rb +116 -0
- data/spec/svg_conform/validation/svg_1_1_document_conformance_spec.rb +144 -0
- data/spec/svg_conform/validation_context_source_plumbing_spec.rb +122 -0
- data/spec/svg_conform/validation_context_spec.rb +39 -0
- data/spec/svg_conform/validation_result_spec.rb +123 -0
- data/spec/svg_conform/yaml_io_spec.rb +41 -0
- data/svg_conform.gemspec +4 -2
- metadata +120 -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
|
data/lib/svg_conform/document.rb
CHANGED
|
@@ -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,
|
|
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)
|
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "yaml"
|
|
4
|
-
|
|
5
3
|
module SvgConform
|
|
6
4
|
module ExternalCheckers
|
|
7
5
|
module Svgcheck
|
|
@@ -26,7 +24,7 @@ module SvgConform
|
|
|
26
24
|
|
|
27
25
|
def load_mapping_config
|
|
28
26
|
@mapping_config = if File.exist?(@mapping_config_path)
|
|
29
|
-
|
|
27
|
+
SvgConform::YamlIO.load_file(@mapping_config_path)
|
|
30
28
|
else
|
|
31
29
|
# Fallback to empty config if file doesn't exist
|
|
32
30
|
{
|
|
@@ -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) &&
|
|
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
|
data/lib/svg_conform/profile.rb
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
66
|
+
false
|
|
67
67
|
|
|
68
68
|
content.each_char do |char|
|
|
69
69
|
case char
|
|
70
70
|
when "<"
|
|
71
|
-
|
|
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
|
-
|
|
80
|
+
false
|
|
81
81
|
end
|
|
82
82
|
end
|
|
83
83
|
|
|
@@ -39,8 +39,10 @@ module SvgConform
|
|
|
39
39
|
raise ArgumentError, "Configuration file not found: #{path}"
|
|
40
40
|
end
|
|
41
41
|
|
|
42
|
-
hash =
|
|
43
|
-
|
|
42
|
+
hash = SvgConform::YamlIO.safe_load_file(
|
|
43
|
+
path, permitted_classes: [Symbol], permitted_symbols: [],
|
|
44
|
+
aliases: true
|
|
45
|
+
)
|
|
44
46
|
new(hash)
|
|
45
47
|
end
|
|
46
48
|
|
|
@@ -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
|
-
|
|
756
|
-
|
|
757
|
-
puts "
|
|
758
|
-
|
|
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
|