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.
Files changed (125) hide show
  1. checksums.yaml +4 -4
  2. data/.gitattributes +1 -0
  3. data/.github/workflows/rake.yml +4 -1
  4. data/Gemfile +6 -3
  5. data/Gemfile.lock +211 -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 +4 -4
  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 -4
  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/external_checkers/svgcheck/parser.rb +1 -3
  30. data/lib/svg_conform/node_helpers.rb +1 -1
  31. data/lib/svg_conform/profile.rb +11 -2
  32. data/lib/svg_conform/quality_metrics/complexity_calculator.rb +3 -3
  33. data/lib/svg_conform/quality_metrics/configuration.rb +4 -2
  34. data/lib/svg_conform/references/base_reference.rb +153 -0
  35. data/lib/svg_conform/references/reference_manifest.rb +1 -2
  36. data/lib/svg_conform/report_comparator.rb +4 -4
  37. data/lib/svg_conform/requirements/base_requirement.rb +6 -0
  38. data/lib/svg_conform/requirements/external_references_requirement.rb +234 -0
  39. data/lib/svg_conform/requirements/link_validation_requirement.rb +2 -0
  40. data/lib/svg_conform/requirements/processing_features_requirement.rb +175 -0
  41. data/lib/svg_conform/requirements/svg_1_1_document_requirement.rb +213 -0
  42. data/lib/svg_conform/requirements.rb +6 -0
  43. data/lib/svg_conform/sax_document.rb +8 -4
  44. data/lib/svg_conform/sax_validation_handler.rb +22 -1
  45. data/lib/svg_conform/validation/css2_core_grammar.rb +359 -0
  46. data/lib/svg_conform/validation/css_references.rb +122 -0
  47. data/lib/svg_conform/validation/error_tracker.rb +40 -9
  48. data/lib/svg_conform/validation/svg_1_1_document_conformance.rb +154 -0
  49. data/lib/svg_conform/validation_context.rb +25 -5
  50. data/lib/svg_conform/validation_result.rb +91 -16
  51. data/lib/svg_conform/version.rb +1 -1
  52. data/lib/svg_conform/yaml_io.rb +20 -0
  53. data/lib/svg_conform.rb +25 -0
  54. data/spec/fixtures/diagnostics/external-reference-info.svg +6 -0
  55. data/spec/fixtures/plantuml/README.adoc +11 -0
  56. data/spec/fixtures/plantuml/unit-type.svg +1 -0
  57. data/spec/fixtures/profiles/svg_1_1_full_static/invalid/animation.svg +4 -0
  58. data/spec/fixtures/profiles/svg_1_1_full_static/invalid/css-grammar.svg +4 -0
  59. data/spec/fixtures/profiles/svg_1_1_full_static/invalid/dtd-attribute.svg +4 -0
  60. data/spec/fixtures/profiles/svg_1_1_full_static/invalid/dtd-content-model.svg +4 -0
  61. data/spec/fixtures/profiles/svg_1_1_full_static/invalid/interaction.svg +4 -0
  62. data/spec/fixtures/profiles/svg_1_1_full_static/invalid/malformed-xml.svg +4 -0
  63. data/spec/fixtures/profiles/svg_1_1_full_static/invalid/script.svg +5 -0
  64. data/spec/fixtures/profiles/svg_1_1_full_static/invalid/wrong-namespace.svg +4 -0
  65. data/spec/fixtures/profiles/svg_1_1_full_static/invalid/wrong-version.svg +4 -0
  66. data/spec/fixtures/profiles/svg_1_1_full_static/invalid/xml-stylesheet.svg +5 -0
  67. data/spec/fixtures/profiles/svg_1_1_full_static/valid/css.svg +5 -0
  68. data/spec/fixtures/profiles/svg_1_1_full_static/valid/foreign-metadata.svg +6 -0
  69. data/spec/fixtures/profiles/svg_1_1_full_static/valid/minimal-full.svg +5 -0
  70. data/spec/fixtures/profiles/svg_1_1_full_static/valid/xml-stylesheet.svg +5 -0
  71. data/spec/fixtures/profiles/svg_2_secure_static/invalid/animation.svg +4 -0
  72. data/spec/fixtures/profiles/svg_2_secure_static/invalid/css-import.svg +4 -0
  73. data/spec/fixtures/profiles/svg_2_secure_static/invalid/css-url.svg +4 -0
  74. data/spec/fixtures/profiles/svg_2_secure_static/invalid/data-bad-png.svg +4 -0
  75. data/spec/fixtures/profiles/svg_2_secure_static/invalid/data-empty-png.svg +4 -0
  76. data/spec/fixtures/profiles/svg_2_secure_static/invalid/data-malformed-base64.svg +4 -0
  77. data/spec/fixtures/profiles/svg_2_secure_static/invalid/data-malformed-percent.svg +4 -0
  78. data/spec/fixtures/profiles/svg_2_secure_static/invalid/data-unsupported-type.svg +4 -0
  79. data/spec/fixtures/profiles/svg_2_secure_static/invalid/file-like-fragment.svg +4 -0
  80. data/spec/fixtures/profiles/svg_2_secure_static/invalid/http.svg +4 -0
  81. data/spec/fixtures/profiles/svg_2_secure_static/invalid/https.svg +4 -0
  82. data/spec/fixtures/profiles/svg_2_secure_static/invalid/interaction.svg +4 -0
  83. data/spec/fixtures/profiles/svg_2_secure_static/invalid/protocol-relative.svg +4 -0
  84. data/spec/fixtures/profiles/svg_2_secure_static/invalid/relative-file-fragment.svg +4 -0
  85. data/spec/fixtures/profiles/svg_2_secure_static/invalid/relative-file.svg +4 -0
  86. data/spec/fixtures/profiles/svg_2_secure_static/invalid/script.svg +4 -0
  87. data/spec/fixtures/profiles/svg_2_secure_static/invalid/svg-data-excessive-nesting.svg +4 -0
  88. data/spec/fixtures/profiles/svg_2_secure_static/invalid/svg-data-malformed-xml.svg +4 -0
  89. data/spec/fixtures/profiles/svg_2_secure_static/invalid/svg-data-nested-external.svg +4 -0
  90. data/spec/fixtures/profiles/svg_2_secure_static/invalid/urn.svg +4 -0
  91. data/spec/fixtures/profiles/svg_2_secure_static/invalid/xml-stylesheet.svg +5 -0
  92. data/spec/fixtures/profiles/svg_2_secure_static/valid/jpeg-base64.svg +4 -0
  93. data/spec/fixtures/profiles/svg_2_secure_static/valid/jpeg-percent.svg +4 -0
  94. data/spec/fixtures/profiles/svg_2_secure_static/valid/png-base64.svg +4 -0
  95. data/spec/fixtures/profiles/svg_2_secure_static/valid/png-percent.svg +4 -0
  96. data/spec/fixtures/profiles/svg_2_secure_static/valid/same-document-fragment.svg +4 -0
  97. data/spec/fixtures/profiles/svg_2_secure_static/valid/svg-base64.svg +4 -0
  98. data/spec/fixtures/profiles/svg_2_secure_static/valid/svg-percent.svg +4 -0
  99. data/spec/fixtures/quality_metrics/aliases.yml +7 -0
  100. data/spec/svg_conform/commands/check_command_spec.rb +22 -2
  101. data/spec/svg_conform/commands/profiles_command_spec.rb +18 -0
  102. data/spec/svg_conform/conformance_report_spec.rb +90 -0
  103. data/spec/svg_conform/diagnostics/lens_core_adapter_spec.rb +136 -0
  104. data/spec/svg_conform/gemspec_spec.rb +34 -0
  105. data/spec/svg_conform/profiles/metanorma_profile_spec.rb +48 -0
  106. data/spec/svg_conform/profiles/svg_1_1_full_static_profile_spec.rb +146 -0
  107. data/spec/svg_conform/profiles/svg_2_secure_static_profile_spec.rb +150 -0
  108. data/spec/svg_conform/profiles_plantuml_spec.rb +81 -0
  109. data/spec/svg_conform/quality_metrics/configuration_spec.rb +82 -0
  110. data/spec/svg_conform/references/data_uri_reference_spec.rb +97 -0
  111. data/spec/svg_conform/references/integration_spec.rb +30 -0
  112. data/spec/svg_conform/references/reference_classifier_spec.rb +17 -0
  113. data/spec/svg_conform/requirements/external_references_requirement_spec.rb +109 -0
  114. data/spec/svg_conform/requirements/link_validation_requirement_spec.rb +16 -0
  115. data/spec/svg_conform/requirements/processing_features_requirement_spec.rb +75 -0
  116. data/spec/svg_conform/requirements/svg_1_1_document_requirement_spec.rb +141 -0
  117. data/spec/svg_conform/requirements/text_as_path_requirement_spec.rb +9 -5
  118. data/spec/svg_conform/validation/css2_core_grammar_spec.rb +116 -0
  119. data/spec/svg_conform/validation/svg_1_1_document_conformance_spec.rb +144 -0
  120. data/spec/svg_conform/validation_context_source_plumbing_spec.rb +122 -0
  121. data/spec/svg_conform/validation_context_spec.rb +39 -0
  122. data/spec/svg_conform/validation_result_spec.rb +123 -0
  123. data/spec/svg_conform/yaml_io_spec.rb +41 -0
  124. data/svg_conform.gemspec +4 -2
  125. metadata +120 -6
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "base64"
5
+
6
+ RSpec.describe SvgConform::Requirements::ExternalReferencesRequirement do
7
+ let(:requirement) do
8
+ described_class.new(id: "ext", citation_uri: "https://example.test/cite")
9
+ end
10
+ let(:profile) { SvgConform::Profile.new(name: "x", requirements: [requirement]) }
11
+ let(:ns) { 'xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"' }
12
+
13
+ def svg(body, pre: "")
14
+ %(#{pre}<svg #{ns} viewBox="0 0 1 1">#{body}</svg>)
15
+ end
16
+
17
+ def dom(xml)
18
+ profile.validate(SvgConform::Document.from_content(xml))
19
+ end
20
+
21
+ def sax(xml)
22
+ SvgConform::SaxDocument.from_content(xml).validate_with_profile(profile)
23
+ end
24
+
25
+ def b64_svg(inner)
26
+ "data:image/svg+xml;base64,#{Base64.strict_encode64(inner)}"
27
+ end
28
+
29
+ {
30
+ "same-document href" => ['<use href="#a"/>', 0],
31
+ "same-document xlink:href" => ['<use xlink:href="#a"/>', 0],
32
+ "http href" => ['<image href="http://e.test/a.png"/>', 1],
33
+ "xlink http href" => ['<image xlink:href="https://e.test/a.png"/>', 1],
34
+ "anchor to web" => ['<a href="https://e.test/">x</a>', 1],
35
+ "presentation url" => ['<rect fill="url(other.svg#g)"/>', 1],
36
+ "presentation fragment" => ['<rect fill="url(#g)"/>', 0],
37
+ "style attribute url" => ['<rect style="fill:url(&quot;x.svg#g&quot;)"/>', 1],
38
+ "style element import string" => ['<style>@import "a.css";</style>', 1],
39
+ "style element import url counted once" => ["<style>@import url(a.css);</style>", 1],
40
+ "style element url" => ["<style>a{fill:url(a.svg#x)}</style>", 1],
41
+ "css comment ignored" => ["<style>/* url(a.svg) */ a{fill:red}</style>", 0],
42
+ "escaped url function" => ['<rect style="fill:u\\72l(https://e.test/x.png)"/>', 1],
43
+ "escaped url function uppercase hex" => ['<rect style="fill:\\55RL(https://e.test/x.png)"/>', 1],
44
+ "escaped url in presentation attribute" => ['<rect fill="u\\72l(https://e.test/x.png)"/>', 1],
45
+ "escaped url with quoted string" => ['<rect style="fill:u\\72l(\'https://e.test/x.png\')"/>', 1],
46
+ "escaped characters inside the url" => ['<rect style="fill:url(\\68ttps://e.test/x.png)"/>', 1],
47
+ "escaped import string" => ['<style>@\\69mport "a.css";</style>', 1],
48
+ "escaped import url" => ['<style>@imp\\6frt url(a.css);</style>', 1],
49
+ "escaped import with escaped url" => ['<style>@\\69mport u\\72l(a.css);</style>', 1],
50
+ "url text inside a css string is not a reference" => ['<style>a{content:"url(a.svg)"}</style>', 0],
51
+ "escaped url with same-document fragment" => ['<rect style="fill:u\\72l(#g)"/>', 0],
52
+ "javascript scheme" => ['<a href="javascript:x()">x</a>', 1],
53
+ }.each do |label, (body, count)|
54
+ it "#{label}: #{count} finding(s) through DOM and SAX" do
55
+ xml = svg(body)
56
+ expect(dom(xml).errors.size).to eq(count)
57
+ expect(sax(xml).errors.map(&:message)).to eq(dom(xml).errors.map(&:message))
58
+ end
59
+ end
60
+
61
+ it "reports xml-stylesheet processing instructions from the source" do
62
+ xml = svg("", pre: %(<?xml-stylesheet href="a.css"?>))
63
+ expect(dom(xml).errors.size).to eq(1)
64
+ expect(sax(xml).errors.size).to eq(1)
65
+ end
66
+
67
+ it "uses the requirement id and citation in error data" do
68
+ error = sax(svg('<image href="//e.test/a.png"/>')).errors.first
69
+ expect(error.requirement_id).to eq("ext")
70
+ expect(error.data).to include(citation: "https://example.test/cite",
71
+ reference_type: "RelativePathReference")
72
+ end
73
+
74
+ describe "nested SVG data URLs" do
75
+ let(:plain) { '<svg xmlns="http://www.w3.org/2000/svg"><rect/></svg>' }
76
+
77
+ it "accepts a self-contained SVG" do
78
+ expect(sax(svg(%(<image href="#{b64_svg(plain)}"/>))).errors).to be_empty
79
+ end
80
+
81
+ it "rejects malformed XML, non-svg roots and external entities" do
82
+ ["<svg><g></svg>", "<html/>",
83
+ %(<!DOCTYPE svg SYSTEM "http://e.test/x.dtd"><svg/>)].each do |bad|
84
+ errors = sax(svg(%(<image href="#{b64_svg(bad)}"/>))).errors
85
+ expect(errors.size).to eq(1), bad
86
+ end
87
+ end
88
+
89
+ it "reports nested external content with a JSON-safe reason" do
90
+ inner = '<svg xmlns="http://www.w3.org/2000/svg"><image href="http://e.test/a"/></svg>'
91
+ error = dom(svg(%(<image href="#{b64_svg(inner)}"/>))).errors.first
92
+ expect(error.data[:reason]).to match(/nested href 'http:\/\/e.test\/a'/)
93
+ expect { JSON.generate(error.data) }.not_to raise_error
94
+ end
95
+
96
+ it "limits nesting depth" do
97
+ nest = plain
98
+ 4.times do
99
+ nest = %(<svg xmlns="http://www.w3.org/2000/svg"><image href="#{b64_svg(nest)}"/></svg>)
100
+ end
101
+ error = sax(svg(%(<image href="#{b64_svg(nest)}"/>))).errors.first
102
+ expect(error.data[:reason]).to include("nesting exceeds 3")
103
+ end
104
+ end
105
+
106
+ it "ignores empty references like the classifier does" do
107
+ expect(sax(svg('<image href=""/>')).errors).to be_empty
108
+ end
109
+ end
@@ -74,4 +74,20 @@ RSpec.describe SvgConform::Requirements::LinkValidationRequirement do
74
74
  expect(requirement).to be_a(described_class)
75
75
  end
76
76
  end
77
+
78
+ describe "classifier sharing with ExternalReferencesRequirement" do
79
+ it "still reports a missing fragment target while external-reference allows the fragment" do
80
+ svg = '<svg xmlns="http://www.w3.org/2000/svg"><use href="#missing"/></svg>'
81
+ profile = SvgConform::Profile.new(
82
+ name: "p",
83
+ requirements: [
84
+ described_class.new(id: "link"),
85
+ SvgConform::Requirements::ExternalReferencesRequirement.new(id: "ext"),
86
+ ],
87
+ )
88
+ ids = SvgConform::SaxDocument.from_content(svg)
89
+ .validate_with_profile(profile).errors.map(&:requirement_id)
90
+ expect(ids).not_to include("ext")
91
+ end
92
+ end
77
93
  end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "yaml"
5
+
6
+ RSpec.describe SvgConform::Requirements::ProcessingFeaturesRequirement do
7
+ let(:config_path) { "config/requirements/processing_features_static.yml" }
8
+ let(:requirement) { described_class.from_yaml(File.read(config_path)) }
9
+ let(:profile) do
10
+ SvgConform::Profile.new(name: "pf", requirements: [requirement])
11
+ end
12
+ let(:svg_ns) { 'xmlns="http://www.w3.org/2000/svg"' }
13
+
14
+ def svg(body)
15
+ %(<svg #{svg_ns} version="1.1" viewBox="0 0 10 10">#{body}</svg>)
16
+ end
17
+
18
+ def dom_ids(xml)
19
+ profile.validate(SvgConform::Document.from_content(xml))
20
+ .errors.map(&:requirement_id).sort
21
+ end
22
+
23
+ def sax_ids(xml)
24
+ SvgConform::SaxDocument.from_content(xml).validate_with_profile(profile)
25
+ .errors.map(&:requirement_id).sort
26
+ end
27
+
28
+ it "loads the three categories from YAML with separate ids and citations" do
29
+ expect(requirement.categories.map(&:name))
30
+ .to eq(%w[script animation interaction])
31
+ expect(requirement.categories.map(&:id)).to eq(
32
+ %w[svg11.processing.script svg11.processing.animation
33
+ svg11.processing.interaction],
34
+ )
35
+ expect(requirement.categories.map(&:citation).uniq.size).to eq(1)
36
+ end
37
+
38
+ it "resolves through a profile YAML via the requirement class map" do
39
+ entry = File.read(config_path).lines.each_with_index.map do |l, i|
40
+ i.zero? ? " - #{l}" : " #{l}"
41
+ end.join
42
+ yaml = "name: x\nrequirements:\n#{entry}"
43
+ loaded = SvgConform::Profile.from_yaml(yaml)
44
+ expect(loaded.requirements.first).to be_a(described_class)
45
+ end
46
+
47
+ it "is registered in Requirements.all" do
48
+ expect(SvgConform::Requirements.all).to include(described_class)
49
+ end
50
+
51
+ {
52
+ "script element" => ["<script>x()</script>", %w[svg11.processing.script]],
53
+ "load handler" => ['<rect onload="x()" width="1" height="1"/>',
54
+ %w[svg11.processing.script]],
55
+ "animate element" => ['<rect width="1" height="1"><animate attributeName="x" dur="1s"/></rect>',
56
+ %w[svg11.processing.animation svg11.processing.animation]],
57
+ "click handler" => ['<rect onclick="x()" width="1" height="1"/>',
58
+ %w[svg11.processing.interaction]],
59
+ "cursor attribute" => ['<rect cursor="pointer" width="1" height="1"/>',
60
+ %w[svg11.processing.interaction]],
61
+ "clean static" => ['<rect width="1" height="1"/>', []],
62
+ }.each do |label, (body, expected)|
63
+ it "reports #{label} identically through DOM and SAX" do
64
+ xml = svg(body)
65
+ expect(dom_ids(xml)).to eq(expected)
66
+ expect(sax_ids(xml)).to eq(expected)
67
+ end
68
+ end
69
+
70
+ it "attaches the category citation to the error" do
71
+ error = SvgConform::SaxDocument.from_content(svg("<script/>"))
72
+ .validate_with_profile(profile).errors.first
73
+ expect(error.data[:citation]).to eq(requirement.categories.first.citation)
74
+ end
75
+ end
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ # Path is fixed by the card; the cop expects svg11_ instead of svg_1_1_.
6
+ RSpec.describe SvgConform::Requirements::Svg11DocumentRequirement do # rubocop:disable RSpec/SpecFilePathFormat
7
+ let(:svg_ns) { 'xmlns="http://www.w3.org/2000/svg"' }
8
+ let(:profile_path) { "config/profiles/svg_1_1_full_static.yml" }
9
+
10
+ def requirement_for(rule)
11
+ SvgConform::Profile.load_from_file(profile_path).requirements.find do |req|
12
+ req.respond_to?(:rule) && req.rule == rule
13
+ end
14
+ end
15
+
16
+ def run(rule, xml, mode: :sax, serialized: false)
17
+ requirement = requirement_for(rule)
18
+ profile = SvgConform::Profile.new(name: "t", requirements: [requirement])
19
+ if mode == :dom
20
+ profile.validate(SvgConform::Document.from_content(xml)).errors
21
+ else
22
+ SvgConform::SaxDocument.from_content(xml, source_serialized: serialized)
23
+ .validate_with_profile(profile).errors
24
+ end
25
+ end
26
+
27
+ def doc(attrs: 'version="1.1"', body: '<rect width="1" height="1"/>', prolog: "")
28
+ %(<?xml version="1.0"?>\n#{prolog}<svg #{svg_ns} #{attrs}>#{body}</svg>)
29
+ end
30
+
31
+ it "defines one entry per matrix row with its own id and citation" do
32
+ entries = SvgConform::Profile.load_from_file(profile_path).requirements
33
+ .grep(described_class)
34
+ expect(entries.map(&:rule)).to eq(described_class::RULES)
35
+ expect(entries.map(&:id)).to eq(
36
+ %w[svg11.document.xml svg11.document.root svg11.document.version
37
+ svg11.document.grammar svg11.document.css2
38
+ svg11.document.xml_stylesheet],
39
+ )
40
+ expect(entries.map(&:citation_uri)).to all(start_with("https://www.w3.org/TR/"))
41
+ end
42
+
43
+ it "is registered in Requirements.all" do
44
+ expect(SvgConform::Requirements.all).to include(described_class)
45
+ end
46
+
47
+ it "rejects an unknown rule" do
48
+ requirement = described_class.new(id: "x", rule: "nope")
49
+ context = SvgConform::ValidationContext.new(nil, nil, source: "<svg/>")
50
+ expect { requirement.validate_source("<svg/>", context) }
51
+ .to raise_error(ArgumentError, /unknown rule/)
52
+ end
53
+
54
+ %i[sax dom].each do |mode|
55
+ context "when validating in #{mode} mode" do
56
+ it "accepts a valid document in every rule" do
57
+ described_class::RULES.each do |rule|
58
+ expect(run(rule, doc, mode: mode)).to be_empty, "rule #{rule}"
59
+ end
60
+ end
61
+
62
+ it "flags a wrong root namespace" do
63
+ errors = run("root", doc.sub(svg_ns, 'xmlns="http://example.com/x"'), mode: mode)
64
+ expect(errors.map(&:requirement_id)).to eq(["svg11.document.root"])
65
+ end
66
+
67
+ it "flags a wrong or missing version" do
68
+ expect(run("version", doc(attrs: 'version="1.0"'), mode: mode).size).to eq(1)
69
+ expect(run("version", doc(attrs: ""), mode: mode).size).to eq(1)
70
+ end
71
+
72
+ it "flags DTD content-model and attribute violations" do
73
+ model = run("grammar", doc(body: "<rect><g/></rect>"), mode: mode)
74
+ attribute = run("grammar", doc(body: '<rect width="1" height="1" bogus="1"/>'), mode: mode)
75
+ expect(model.map(&:requirement_id)).to all(eq("svg11.document.grammar"))
76
+ expect(model).not_to be_empty
77
+ expect(attribute).not_to be_empty
78
+ end
79
+
80
+ it "does not follow an external entity" do
81
+ xml = "<?xml version=\"1.0\"?>\n<!DOCTYPE svg [<!ENTITY x SYSTEM \"file:///etc/passwd\">]>\n<svg #{svg_ns} version=\"1.1\"><title>&x;</title></svg>"
82
+ errors = run("grammar", xml, mode: mode)
83
+ expect(errors.map(&:message).join).to include("external")
84
+ expect(errors.map(&:message).join).not_to include("root:")
85
+ end
86
+
87
+ it "flags CSS grammar errors in style attributes and style elements" do
88
+ attr = run("css2", doc(body: '<rect width="1" height="1" style="fill: {red"/>'), mode: mode)
89
+ sheet = run("css2", doc(body: "<style>rect { fill: red </style>"), mode: mode)
90
+ expect(attr.size).to be >= 1
91
+ expect(sheet.size).to be >= 1
92
+ end
93
+
94
+ it "accepts valid CSS and skips non-CSS style types" do
95
+ css = "<style>rect { fill: red }</style><style type=\"text/x-other\">{{</style>"
96
+ expect(run("css2", doc(body: css), mode: mode)).to be_empty
97
+ end
98
+
99
+ it "treats text/css media types case-insensitively" do
100
+ upper = run("css2", doc(body: '<style type="TEXT/CSS">rect { fill: red </style>'), mode: mode)
101
+ mixed = run("css2", doc(body: '<style type="Text/Css">rect { fill: red </style>'), mode: mode)
102
+ pi = %(<?xml-stylesheet href="a.css" type="TEXT/CSS"?>\n)
103
+ expect(upper.size).to be >= 1
104
+ expect(mixed.size).to be >= 1
105
+ expect(run("xml_stylesheet", doc(prolog: pi), mode: mode)).to be_empty
106
+ end
107
+
108
+ it "validates xml-stylesheet pseudo-attributes" do
109
+ good = doc(prolog: %(<?xml-stylesheet href="a.css" type="text/css"?>\n))
110
+ no_href = doc(prolog: %(<?xml-stylesheet type="text/css"?>\n))
111
+ bad_type = doc(prolog: %(<?xml-stylesheet href="a.xsl" type="text/xsl"?>\n))
112
+ garbled = doc(prolog: %(<?xml-stylesheet href=a.css?>\n))
113
+ expect(run("xml_stylesheet", good, mode: mode)).to be_empty
114
+ [no_href, bad_type, garbled].each do |xml|
115
+ expect(run("xml_stylesheet", xml, mode: mode).map(&:requirement_id))
116
+ .to eq(["svg11.document.xml_stylesheet"])
117
+ end
118
+ end
119
+ end
120
+ end
121
+
122
+ it "reports malformed XML through SAX" do
123
+ errors = run("xml", "<svg #{svg_ns}><rect></svg>")
124
+ expect(errors.map(&:requirement_id)).to include("svg11.document.xml")
125
+ end
126
+
127
+ it "cannot prove declaration claims for a re-serialized source" do
128
+ errors = run("xml", doc, serialized: true)
129
+ expect(errors.map(&:requirement_id)).to eq(["svg11.document.xml"])
130
+ expect(errors.first.message).to include("cannot be proved")
131
+ expect(run("xml", doc, serialized: false)).to be_empty
132
+ end
133
+
134
+ it "reports the same through a DOM node and a serialized SAX source" do
135
+ node = SvgConform::Document.from_content(doc).moxml_document
136
+ dom = SvgConform::Document.from_node(node)
137
+ profile = SvgConform::Profile.new(name: "t", requirements: [requirement_for("xml")])
138
+ expect(profile.validate(dom).errors.map(&:requirement_id))
139
+ .to eq(["svg11.document.xml"])
140
+ end
141
+ end
@@ -56,12 +56,16 @@ RSpec.describe SvgConform::Requirements::TextAsPathRequirement do
56
56
  SVG
57
57
 
58
58
  result = validator.validate(svg, profile: :metanorma)
59
- text_as_path_errors = result.errors.select do |e|
60
- e.message.include?("rendered as paths")
61
- end
62
59
 
63
- expect(text_as_path_errors).not_to be_empty
64
- expect(text_as_path_errors.first.severity).to eq(:warning)
60
+ # Text-as-path is a quality advisory declared with severity: warning:
61
+ # it must surface as a warning, never as an error, so that schema-valid
62
+ # documents are not reported as invalid (metanorma-pdfa#106).
63
+ expect(result.errors).to be_empty
64
+ expect(result.warnings.map(&:message)).to include(
65
+ a_string_including("rendered as paths"),
66
+ )
67
+ expect(result.warnings.first.severity).to eq(:warning)
68
+ expect(result).to be_valid
65
69
  end
66
70
 
67
71
  it "does not flag SVG with native text elements" do
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "svg_conform/validation/css2_core_grammar"
5
+
6
+ RSpec.describe SvgConform::Validation::Css2CoreGrammar do
7
+ describe ".tokenize" do
8
+ def types(css)
9
+ described_class.tokenize(css).map(&:type)
10
+ end
11
+
12
+ it "classifies the CSS 2 token kinds" do
13
+ css = 'a:hover #id 10 50% 2em url(x.png) rgb( "s" U+0-7F ~= |= @page <!-- -->'
14
+ expect(types(css).reject { |t| t == :s }).to eq(
15
+ [:ident, ":", :ident, :hash, :number, :percentage, :dimension, :uri, :function, :string,
16
+ :unicode_range, :includes, :dashmatch, :atkeyword, :cdo, :cdc],
17
+ )
18
+ end
19
+
20
+ it "drops comments and tracks line and column" do
21
+ tokens = described_class.tokenize("a /* c\n */ b")
22
+ expect(tokens.map(&:value)).to eq(["a", " ", " ", "b"])
23
+ expect(tokens.last.line).to eq(2)
24
+ expect(tokens.last.column).to eq(5)
25
+ end
26
+
27
+ it "flags unterminated strings and comments" do
28
+ expect(types('"abc')).to eq([:bad_string])
29
+ expect(types("/* abc")).to eq([:bad_comment])
30
+ end
31
+
32
+ it "accepts escapes in identifiers" do
33
+ expect(types('\66 ill')).to eq([:ident])
34
+ end
35
+ end
36
+
37
+ describe ".parse_stylesheet" do
38
+ valid = {
39
+ "empty sheet" => "",
40
+ "comments and CDO/CDC" => "<!-- /* x */ a { fill: red } -->",
41
+ "multiple selectors" => "rect, circle.a > path[x=\"1\"]:first-child { fill: #fff }",
42
+ "empty declaration slots" => "a { ; fill: red;; stroke: blue; }",
43
+ "functions and !important" => "a { fill: rgb(1, 2, 3) !important; stroke: url(#g) }",
44
+ "empty ruleset" => "a { }",
45
+ "@import" => '@import url("x.css") screen;',
46
+ "@media with nested rules" => "@media print { a { x: y } b { x: y } }",
47
+ "@font-face" => '@font-face { font-family: "F"; src: url(f.woff) }',
48
+ "unknown at-rule block" => "@foo bar { baz { qux: 1 } }",
49
+ "selector-less ruleset" => "{ fill: red }",
50
+ }
51
+
52
+ valid.each do |name, css|
53
+ it "accepts #{name}" do
54
+ expect(described_class.parse_stylesheet(css)).to be_valid
55
+ end
56
+ end
57
+
58
+ invalid = {
59
+ "missing colon" => ["a { fill red }", /expected ':'/],
60
+ "missing value" => ["a { fill: }", /no value/],
61
+ "missing property" => ["a { : red }", /property name/],
62
+ "unclosed ruleset" => ["a { fill: red", /missing '}'/],
63
+ "unclosed string" => ["a { fill: \"red }", /unterminated string/],
64
+ "unclosed comment" => ["a { fill: red } /* x", /unterminated comment/],
65
+ "unclosed function" => ["a { fill: rgb(1,2 }", /missing '\)'/],
66
+ "selector without block" => ["a b", /missing a '\{' block/],
67
+ "unterminated at-rule" => ["@import url(x.css)", /at-rule/],
68
+ "stray closing brace" => ["a { fill: red } }", /unexpected '\}'/],
69
+ }
70
+
71
+ invalid.each do |name, (css, message)|
72
+ it "rejects #{name}" do
73
+ result = described_class.parse_stylesheet(css)
74
+ expect(result).not_to be_valid
75
+ expect(result.errors.map(&:message).join("\n")).to match(message)
76
+ end
77
+ end
78
+
79
+ it "reports the position of the first error" do
80
+ error = described_class.parse_stylesheet("a { fill: red }\nb { fill blue }")
81
+ .errors.first
82
+ expect(error.line).to eq(2)
83
+ expect(error.to_s).to include("line 2")
84
+ end
85
+ end
86
+
87
+ describe ".parse_declarations" do
88
+ it "accepts a style attribute value" do
89
+ css = "fill:red; stroke : url(#a) ; stroke-width:2px;"
90
+ expect(described_class.parse_declarations(css)).to be_valid
91
+ end
92
+
93
+ it "accepts an empty value and stray semicolons" do
94
+ expect(described_class.parse_declarations("")).to be_valid
95
+ expect(described_class.parse_declarations(" ; ;")).to be_valid
96
+ end
97
+
98
+ it "rejects a declaration without a colon" do
99
+ result = described_class.parse_declarations("fill red")
100
+ expect(result).not_to be_valid
101
+ end
102
+
103
+ it "rejects a stray closing brace in a declaration list" do
104
+ result = described_class.parse_declarations("fill:red } stroke:blue")
105
+ expect(result).not_to be_valid
106
+ end
107
+
108
+ it "rejects a ruleset where a declaration list is expected" do
109
+ expect(described_class.parse_declarations("a { fill: red }")).not_to be_valid
110
+ end
111
+
112
+ it "does not judge property names or values" do
113
+ expect(described_class.parse_declarations("bogus-prop: nonsense 12qq")).to be_valid
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require "digest"
5
+ require "yaml"
6
+ require "tmpdir"
7
+ require "svg_conform/validation/svg_1_1_document_conformance"
8
+
9
+ # Path is fixed by the card; the cop expects svg11_ instead of svg_1_1_.
10
+ RSpec.describe SvgConform::Validation::Svg11DocumentConformance do # rubocop:disable RSpec/SpecFilePathFormat
11
+ subject(:conformance) { described_class.new }
12
+
13
+ let(:ns) { %(xmlns="http://www.w3.org/2000/svg" version="1.1") }
14
+ let(:dtd_path) { described_class::DTD_PATH }
15
+
16
+ def svg(body, attrs = ns)
17
+ %(<?xml version="1.0"?>\n<svg #{attrs}>#{body}</svg>)
18
+ end
19
+
20
+ # Library selection record (card 03, "Open implementation question").
21
+ # Probes: valid document, invalid content model, external-entity attempt,
22
+ # Appendix G.2 foreign content, run offline against the vendored flat DTD.
23
+ #
24
+ # Moxml/Leptris 1.9.381 (Leptris::XML::DTD): parses the DTD but ignores
25
+ # conditional sections whose keyword is a parameter entity
26
+ # (<![%svg-structure.module;[ ... ]]>), so no SVG element is declared and
27
+ # every probe, including the invalid ones, reports valid. Rejected.
28
+ # Nokogiri 1.19 (libxml2 2.13, Ruby >= 3.1 with 1.16/1.18): all four
29
+ # probes behave correctly offline, in-process, with NONET and no entity
30
+ # loading. Selected.
31
+ describe "library probes" do
32
+ let(:flat_dtd) { File.read(dtd_path) }
33
+
34
+ it "records that Leptris cannot enforce the flat DTD" do
35
+ require "leptris"
36
+ handle = Leptris::XML::DTD.parse(flat_dtd)
37
+ invalid = Leptris::XML.parse(%(<svg #{ns}><rect width="1" height="1"><g/></rect></svg>))
38
+
39
+ expect(handle.validate(invalid)).to be_empty
40
+ end
41
+
42
+ it "probe 1: Nokogiri accepts a valid document" do
43
+ expect(conformance.validate(svg(%(<rect width="1" height="1"/>))).issues).to be_empty
44
+ end
45
+
46
+ it "probe 2: Nokogiri rejects an invalid content model" do
47
+ result = conformance.validate(svg(%(<rect width="1" height="1"><g/></rect>)))
48
+
49
+ expect(result.issues.map(&:kind)).to eq([:dtd])
50
+ expect(result.issues.first.message).to include("rect content does not follow the DTD")
51
+ end
52
+
53
+ it "probe 3: an external entity attempt is rejected without reading the file" do
54
+ Dir.mktmpdir do |dir|
55
+ secret = File.join(dir, "secret.txt")
56
+ File.write(secret, "SECRET-MARKER")
57
+ source = %(<?xml version="1.0"?><!DOCTYPE svg [<!ENTITY x SYSTEM "file://#{secret}">]>) +
58
+ %(<svg #{ns}><title>&x;</title></svg>)
59
+
60
+ result = conformance.validate(source)
61
+
62
+ expect(result.issues.map(&:kind)).to eq([:external_entity])
63
+ expect(result.transformed).to be_nil
64
+ end
65
+ end
66
+
67
+ it "probe 4: foreign content validates after the Appendix G.2 transform" do
68
+ source = svg(%(<f:thing f:a="1"><g/></f:thing><metadata><f:m/></metadata>),
69
+ %(#{ns} xmlns:f="urn:foreign"))
70
+
71
+ expect(conformance.validate(source).issues).to be_empty
72
+ end
73
+ end
74
+
75
+ describe "#validate" do
76
+ it "reports a missing required attribute" do
77
+ result = conformance.validate(svg(%(<rect height="1"/>)))
78
+
79
+ expect(result.issues.first.message).to include("Element rect does not carry attribute width")
80
+ end
81
+
82
+ it "reports an undeclared attribute" do
83
+ result = conformance.validate(svg(%(<rect width="1" height="1" bogus="x"/>)))
84
+
85
+ expect(result.issues.first.message).to include("No declaration for attribute bogus")
86
+ end
87
+
88
+ it "reports malformed XML" do
89
+ result = conformance.validate(svg(%(<rect width="1" height="1">)))
90
+
91
+ expect(result.issues.first.kind).to eq(:malformed_xml)
92
+ end
93
+
94
+ it "reports a document element outside the SVG namespace" do
95
+ result = conformance.validate(%(<svg xmlns="urn:other" version="1.1"/>))
96
+
97
+ expect(result.issues.map(&:kind)).to eq([:no_svg_root])
98
+ end
99
+
100
+ it "validates xlink attributes under any prefix" do
101
+ source = svg(%(<defs><g id="a"/></defs><use xlink:href="#a"/>),
102
+ %(#{ns} xmlns:xl="http://www.w3.org/1999/xlink"))
103
+ source = source.sub("xlink:href", "xl:href")
104
+
105
+ expect(conformance.validate(source).issues).to be_empty
106
+ end
107
+
108
+ it "validates prefixed SVG elements" do
109
+ source = %(<s:svg xmlns:s="http://www.w3.org/2000/svg" version="1.1"><s:rect width="1" height="1"/></s:svg>)
110
+
111
+ expect(conformance.validate(source).issues).to be_empty
112
+ end
113
+
114
+ it "expands internal entities before validating" do
115
+ source = %(<?xml version="1.0"?><!DOCTYPE svg [<!ENTITY w "5">]><svg #{ns}><rect width="&w;" height="1"/></svg>)
116
+
117
+ expect(conformance.validate(source).issues).to be_empty
118
+ end
119
+
120
+ it "never modifies the caller's source" do
121
+ source = svg(%(<f:x xmlns:f="urn:f"/><rect width="1" height="1"/>)).freeze
122
+
123
+ expect { conformance.validate(source) }.not_to raise_error
124
+ expect(source).to include("<f:x")
125
+ end
126
+
127
+ it "keeps the Appendix G.2 copy free of foreign content" do
128
+ copy = conformance.validate(svg(%(<f:x xmlns:f="urn:f"/>), %(#{ns} f2:a="1" xmlns:f2="urn:f"))).transformed
129
+
130
+ expect(copy).not_to include("f:x")
131
+ expect(copy).not_to include("f2:a")
132
+ expect(copy).to include(%(xmlns:xlink="http://www.w3.org/1999/xlink"))
133
+ end
134
+ end
135
+
136
+ describe "vendored DTD" do
137
+ it "matches the manifest checksum" do
138
+ entry = YAML.safe_load_file(described_class::MANIFEST_PATH, permitted_classes: [Date])["files"].first
139
+
140
+ expect(Digest::SHA256.file(dtd_path).hexdigest).to eq(entry["sha256"])
141
+ expect(entry["source_url"]).to start_with("https://www.w3.org/")
142
+ end
143
+ end
144
+ end