svg_conform 0.1.0

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 (335) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/rake.yml +15 -0
  3. data/.github/workflows/release.yml +23 -0
  4. data/.github/workflows/svgcheck-compatibility.yml +135 -0
  5. data/.gitignore +12 -0
  6. data/.rspec +3 -0
  7. data/.rubocop.yml +13 -0
  8. data/.rubocop_todo.yml +183 -0
  9. data/CODE_OF_CONDUCT.md +132 -0
  10. data/Gemfile +14 -0
  11. data/README.adoc +1384 -0
  12. data/Rakefile +15 -0
  13. data/config/profiles/base.yml +34 -0
  14. data/config/profiles/lucid_fix.yml +37 -0
  15. data/config/profiles/metanorma.yml +212 -0
  16. data/config/profiles/no_external_css.yml +20 -0
  17. data/config/profiles/svg_1_2_rfc.yml +284 -0
  18. data/config/profiles/svg_1_2_rfc_with_rdf.yml +145 -0
  19. data/config/svgcheck_mapping.yml +180 -0
  20. data/docs/profiles.adoc +547 -0
  21. data/docs/rdf_metadata_support.adoc +212 -0
  22. data/docs/remediation.adoc +732 -0
  23. data/docs/requirements.adoc +709 -0
  24. data/examples/demo.rb +116 -0
  25. data/examples/requirements_demo.rb +240 -0
  26. data/exe/svg_conform +7 -0
  27. data/lib/svg_conform/batch_report.rb +70 -0
  28. data/lib/svg_conform/cli.rb +107 -0
  29. data/lib/svg_conform/commands/check.rb +390 -0
  30. data/lib/svg_conform/commands/profiles.rb +118 -0
  31. data/lib/svg_conform/commands/svgcheck.rb +90 -0
  32. data/lib/svg_conform/commands/svgcheck_compare.rb +92 -0
  33. data/lib/svg_conform/commands/svgcheck_compatibility.rb +43 -0
  34. data/lib/svg_conform/commands/svgcheck_generate.rb +262 -0
  35. data/lib/svg_conform/compatibility/analysis_context.rb +68 -0
  36. data/lib/svg_conform/compatibility/comparison_result.rb +147 -0
  37. data/lib/svg_conform/compatibility/compatibility_analyzer.rb +85 -0
  38. data/lib/svg_conform/compatibility/file_processor.rb +109 -0
  39. data/lib/svg_conform/compatibility/pattern_discovery.rb +319 -0
  40. data/lib/svg_conform/compatibility/report_formatter.rb +359 -0
  41. data/lib/svg_conform/compatibility/svg_analysis_engine.rb +316 -0
  42. data/lib/svg_conform/compatibility/validity_analysis.rb +252 -0
  43. data/lib/svg_conform/compatibility/xml_analysis_engine.rb +198 -0
  44. data/lib/svg_conform/compatibility_analyzer.rb +285 -0
  45. data/lib/svg_conform/conformance_report.rb +267 -0
  46. data/lib/svg_conform/constants.rb +199 -0
  47. data/lib/svg_conform/css_color.rb +262 -0
  48. data/lib/svg_conform/document.rb +203 -0
  49. data/lib/svg_conform/external_checkers/svgcheck/compatibility_engine.rb +166 -0
  50. data/lib/svg_conform/external_checkers/svgcheck/output_generator.rb +101 -0
  51. data/lib/svg_conform/external_checkers/svgcheck/parser.rb +200 -0
  52. data/lib/svg_conform/external_checkers/svgcheck/report_comparator.rb +175 -0
  53. data/lib/svg_conform/external_checkers/svgcheck/report_generator.rb +82 -0
  54. data/lib/svg_conform/external_checkers/svgcheck/validation_pipeline.rb +249 -0
  55. data/lib/svg_conform/external_checkers/svgcheck.rb +56 -0
  56. data/lib/svg_conform/external_checkers.rb +34 -0
  57. data/lib/svg_conform/fixer.rb +56 -0
  58. data/lib/svg_conform/profile.rb +164 -0
  59. data/lib/svg_conform/profiles.rb +60 -0
  60. data/lib/svg_conform/remediation_engine.rb +92 -0
  61. data/lib/svg_conform/remediation_result.rb +36 -0
  62. data/lib/svg_conform/remediation_runner.rb +225 -0
  63. data/lib/svg_conform/remediations/base_remediation.rb +165 -0
  64. data/lib/svg_conform/remediations/color_remediation.rb +226 -0
  65. data/lib/svg_conform/remediations/font_embedding_remediation.rb +145 -0
  66. data/lib/svg_conform/remediations/font_remediation.rb +122 -0
  67. data/lib/svg_conform/remediations/image_embedding_remediation.rb +154 -0
  68. data/lib/svg_conform/remediations/invalid_id_references_remediation.rb +129 -0
  69. data/lib/svg_conform/remediations/namespace_attribute_remediation.rb +244 -0
  70. data/lib/svg_conform/remediations/namespace_remediation.rb +151 -0
  71. data/lib/svg_conform/remediations/no_external_css_remediation.rb +192 -0
  72. data/lib/svg_conform/remediations/style_promotion_remediation.rb +93 -0
  73. data/lib/svg_conform/remediations/viewbox_remediation.rb +127 -0
  74. data/lib/svg_conform/remediations.rb +40 -0
  75. data/lib/svg_conform/report_comparator.rb +772 -0
  76. data/lib/svg_conform/requirements/allowed_elements_requirement.rb +367 -0
  77. data/lib/svg_conform/requirements/base_requirement.rb +98 -0
  78. data/lib/svg_conform/requirements/color_restrictions_requirement.rb +126 -0
  79. data/lib/svg_conform/requirements/element_requirement_config.rb +75 -0
  80. data/lib/svg_conform/requirements/font_family_requirement.rb +133 -0
  81. data/lib/svg_conform/requirements/forbidden_content_requirement.rb +60 -0
  82. data/lib/svg_conform/requirements/id_reference_requirement.rb +133 -0
  83. data/lib/svg_conform/requirements/invalid_id_references_requirement.rb +132 -0
  84. data/lib/svg_conform/requirements/link_validation_requirement.rb +55 -0
  85. data/lib/svg_conform/requirements/namespace_attributes_requirement.rb +211 -0
  86. data/lib/svg_conform/requirements/namespace_requirement.rb +294 -0
  87. data/lib/svg_conform/requirements/no_external_css_requirement.rb +132 -0
  88. data/lib/svg_conform/requirements/no_external_fonts_requirement.rb +121 -0
  89. data/lib/svg_conform/requirements/no_external_images_requirement.rb +91 -0
  90. data/lib/svg_conform/requirements/style_promotion_requirement.rb +72 -0
  91. data/lib/svg_conform/requirements/style_requirement.rb +226 -0
  92. data/lib/svg_conform/requirements/viewbox_required_requirement.rb +96 -0
  93. data/lib/svg_conform/requirements.rb +49 -0
  94. data/lib/svg_conform/semantic_comparator.rb +829 -0
  95. data/lib/svg_conform/validation_context.rb +408 -0
  96. data/lib/svg_conform/validation_result.rb +146 -0
  97. data/lib/svg_conform/validator.rb +91 -0
  98. data/lib/svg_conform/version.rb +5 -0
  99. data/lib/svg_conform.rb +68 -0
  100. data/lib/tasks/fixtures.rake +321 -0
  101. data/lib/tasks/svgcheck.rake +111 -0
  102. data/reference-docs/SVG-1.2-RFC.rnc.txt +1676 -0
  103. data/reference-docs/Scalable Vector Graphics (SVG) 1.1 (Second Edition).html +40764 -0
  104. data/reference-docs/Scalable Vector Graphics (SVG) Tiny 1.2 Specification.html +44591 -0
  105. data/reference-docs/rfc7996.txt +2971 -0
  106. data/sig/svg_conform.rbs +4 -0
  107. data/spec/fixtures/allowed_elements/inputs/basic_violations.svg +21 -0
  108. data/spec/fixtures/allowed_elements/repair/basic_violations.svg +18 -0
  109. data/spec/fixtures/color_restrictions/inputs/basic_violations.svg +23 -0
  110. data/spec/fixtures/color_restrictions/repair/basic_violations.svg +23 -0
  111. data/spec/fixtures/comprehensive/inputs/multiple_violations.svg +21 -0
  112. data/spec/fixtures/comprehensive/repair/multiple_violations.svg +16 -0
  113. data/spec/fixtures/font_family/inputs/basic_violations.svg +17 -0
  114. data/spec/fixtures/font_family/repair/basic_violations.svg +17 -0
  115. data/spec/fixtures/forbidden_content/inputs/basic_violations.svg +27 -0
  116. data/spec/fixtures/forbidden_content/repair/basic_violations.svg +19 -0
  117. data/spec/fixtures/id_reference/inputs/basic_violations.svg +17 -0
  118. data/spec/fixtures/id_reference/repair/basic_violations.svg +15 -0
  119. data/spec/fixtures/link_validation/inputs/basic_violations.svg +20 -0
  120. data/spec/fixtures/link_validation/repair/basic_violations.svg +16 -0
  121. data/spec/fixtures/lucid/inputs/simple.svg +67 -0
  122. data/spec/fixtures/lucid/repair/simple.svg +63 -0
  123. data/spec/fixtures/namespace/inputs/basic_violations.svg +20 -0
  124. data/spec/fixtures/namespace/repair/basic_violations.svg +17 -0
  125. data/spec/fixtures/namespace_attributes/inputs/basic_violations.svg +16 -0
  126. data/spec/fixtures/namespace_attributes/repair/basic_violations.svg +15 -0
  127. data/spec/fixtures/no_external_css/inputs/basic_violations.svg +20 -0
  128. data/spec/fixtures/no_external_css/repair/basic_violations.svg +18 -0
  129. data/spec/fixtures/style/inputs/basic_violations.svg +22 -0
  130. data/spec/fixtures/style/repair/basic_violations.svg +22 -0
  131. data/spec/fixtures/style_promotion/inputs/basic_test.svg +15 -0
  132. data/spec/fixtures/style_promotion/repair/basic_test.svg +15 -0
  133. data/spec/fixtures/svg_1_2_rfc/inputs/allowed_elements_violations.svg +18 -0
  134. data/spec/fixtures/svg_1_2_rfc/inputs/color_restrictions_violations.svg +23 -0
  135. data/spec/fixtures/svgcheck/check/DrawBerry-sample-2.svg.code +1 -0
  136. data/spec/fixtures/svgcheck/check/DrawBerry-sample-2.svg.err +0 -0
  137. data/spec/fixtures/svgcheck/check/DrawBerry-sample-2.svg.out +23 -0
  138. data/spec/fixtures/svgcheck/check/IETF-test.svg.code +1 -0
  139. data/spec/fixtures/svgcheck/check/IETF-test.svg.err +0 -0
  140. data/spec/fixtures/svgcheck/check/IETF-test.svg.out +20 -0
  141. data/spec/fixtures/svgcheck/check/circle.svg.code +1 -0
  142. data/spec/fixtures/svgcheck/check/circle.svg.err +0 -0
  143. data/spec/fixtures/svgcheck/check/circle.svg.out +2 -0
  144. data/spec/fixtures/svgcheck/check/colors.svg.code +1 -0
  145. data/spec/fixtures/svgcheck/check/colors.svg.err +0 -0
  146. data/spec/fixtures/svgcheck/check/colors.svg.out +13 -0
  147. data/spec/fixtures/svgcheck/check/dia-sample-svg.svg.code +1 -0
  148. data/spec/fixtures/svgcheck/check/dia-sample-svg.svg.err +0 -0
  149. data/spec/fixtures/svgcheck/check/dia-sample-svg.svg.out +76 -0
  150. data/spec/fixtures/svgcheck/check/example-dot.svg.code +1 -0
  151. data/spec/fixtures/svgcheck/check/example-dot.svg.err +0 -0
  152. data/spec/fixtures/svgcheck/check/example-dot.svg.out +11 -0
  153. data/spec/fixtures/svgcheck/check/full-tiny.svg.code +1 -0
  154. data/spec/fixtures/svgcheck/check/full-tiny.svg.err +0 -0
  155. data/spec/fixtures/svgcheck/check/full-tiny.svg.out +5835 -0
  156. data/spec/fixtures/svgcheck/check/good.svg.code +1 -0
  157. data/spec/fixtures/svgcheck/check/good.svg.err +0 -0
  158. data/spec/fixtures/svgcheck/check/good.svg.out +1 -0
  159. data/spec/fixtures/svgcheck/check/httpbis-proxy20-fig6.svg.code +1 -0
  160. data/spec/fixtures/svgcheck/check/httpbis-proxy20-fig6.svg.err +0 -0
  161. data/spec/fixtures/svgcheck/check/httpbis-proxy20-fig6.svg.out +5 -0
  162. data/spec/fixtures/svgcheck/check/malformed.svg.code +1 -0
  163. data/spec/fixtures/svgcheck/check/malformed.svg.err +0 -0
  164. data/spec/fixtures/svgcheck/check/malformed.svg.out +8 -0
  165. data/spec/fixtures/svgcheck/check/rfc-svg.svg.code +1 -0
  166. data/spec/fixtures/svgcheck/check/rfc-svg.svg.err +0 -0
  167. data/spec/fixtures/svgcheck/check/rfc-svg.svg.out +1 -0
  168. data/spec/fixtures/svgcheck/check/rfc.xml.code +1 -0
  169. data/spec/fixtures/svgcheck/check/rfc.xml.err +0 -0
  170. data/spec/fixtures/svgcheck/check/rfc.xml.out +2 -0
  171. data/spec/fixtures/svgcheck/check/rgb.svg.code +1 -0
  172. data/spec/fixtures/svgcheck/check/rgb.svg.err +0 -0
  173. data/spec/fixtures/svgcheck/check/rgb.svg.out +9 -0
  174. data/spec/fixtures/svgcheck/check/svg-wordle.svg.code +1 -0
  175. data/spec/fixtures/svgcheck/check/svg-wordle.svg.err +0 -0
  176. data/spec/fixtures/svgcheck/check/svg-wordle.svg.out +508 -0
  177. data/spec/fixtures/svgcheck/check/threshold.svg.code +1 -0
  178. data/spec/fixtures/svgcheck/check/threshold.svg.err +0 -0
  179. data/spec/fixtures/svgcheck/check/threshold.svg.out +20 -0
  180. data/spec/fixtures/svgcheck/check/utf8.svg.code +1 -0
  181. data/spec/fixtures/svgcheck/check/utf8.svg.err +0 -0
  182. data/spec/fixtures/svgcheck/check/utf8.svg.out +162 -0
  183. data/spec/fixtures/svgcheck/check/viewBox-both.svg.code +1 -0
  184. data/spec/fixtures/svgcheck/check/viewBox-both.svg.err +0 -0
  185. data/spec/fixtures/svgcheck/check/viewBox-both.svg.out +4 -0
  186. data/spec/fixtures/svgcheck/check/viewBox-height.svg.code +1 -0
  187. data/spec/fixtures/svgcheck/check/viewBox-height.svg.err +0 -0
  188. data/spec/fixtures/svgcheck/check/viewBox-height.svg.out +3 -0
  189. data/spec/fixtures/svgcheck/check/viewBox-none.svg.code +1 -0
  190. data/spec/fixtures/svgcheck/check/viewBox-none.svg.err +0 -0
  191. data/spec/fixtures/svgcheck/check/viewBox-none.svg.out +3 -0
  192. data/spec/fixtures/svgcheck/check/viewBox-width.svg.code +1 -0
  193. data/spec/fixtures/svgcheck/check/viewBox-width.svg.err +0 -0
  194. data/spec/fixtures/svgcheck/check/viewBox-width.svg.out +3 -0
  195. data/spec/fixtures/svgcheck/inputs/DrawBerry-sample-2.svg +28 -0
  196. data/spec/fixtures/svgcheck/inputs/IETF-test.svg +28 -0
  197. data/spec/fixtures/svgcheck/inputs/circle.svg +3 -0
  198. data/spec/fixtures/svgcheck/inputs/colors.svg +18 -0
  199. data/spec/fixtures/svgcheck/inputs/dia-sample-svg.svg +47 -0
  200. data/spec/fixtures/svgcheck/inputs/example-dot.svg +75 -0
  201. data/spec/fixtures/svgcheck/inputs/full-tiny.svg +16194 -0
  202. data/spec/fixtures/svgcheck/inputs/good.svg +19 -0
  203. data/spec/fixtures/svgcheck/inputs/httpbis-proxy20-fig6.svg +2 -0
  204. data/spec/fixtures/svgcheck/inputs/malformed.svg +11 -0
  205. data/spec/fixtures/svgcheck/inputs/rfc-svg.svg +1028 -0
  206. data/spec/fixtures/svgcheck/inputs/rfc.xml +37 -0
  207. data/spec/fixtures/svgcheck/inputs/rgb.svg +9 -0
  208. data/spec/fixtures/svgcheck/inputs/svg-wordle.svg +330 -0
  209. data/spec/fixtures/svgcheck/inputs/threshold.svg +26 -0
  210. data/spec/fixtures/svgcheck/inputs/utf8.svg +448 -0
  211. data/spec/fixtures/svgcheck/inputs/viewBox-both.svg +3 -0
  212. data/spec/fixtures/svgcheck/inputs/viewBox-height.svg +3 -0
  213. data/spec/fixtures/svgcheck/inputs/viewBox-none.svg +3 -0
  214. data/spec/fixtures/svgcheck/inputs/viewBox-width.svg +3 -0
  215. data/spec/fixtures/svgcheck/repair/DrawBerry-sample-2.svg.code +1 -0
  216. data/spec/fixtures/svgcheck/repair/DrawBerry-sample-2.svg.err +0 -0
  217. data/spec/fixtures/svgcheck/repair/DrawBerry-sample-2.svg.file +0 -0
  218. data/spec/fixtures/svgcheck/repair/DrawBerry-sample-2.svg.out +23 -0
  219. data/spec/fixtures/svgcheck/repair/IETF-test.svg.code +1 -0
  220. data/spec/fixtures/svgcheck/repair/IETF-test.svg.err +0 -0
  221. data/spec/fixtures/svgcheck/repair/IETF-test.svg.file +29 -0
  222. data/spec/fixtures/svgcheck/repair/IETF-test.svg.out +20 -0
  223. data/spec/fixtures/svgcheck/repair/circle.svg.code +1 -0
  224. data/spec/fixtures/svgcheck/repair/circle.svg.err +0 -0
  225. data/spec/fixtures/svgcheck/repair/circle.svg.file +4 -0
  226. data/spec/fixtures/svgcheck/repair/circle.svg.out +2 -0
  227. data/spec/fixtures/svgcheck/repair/colors.svg.code +1 -0
  228. data/spec/fixtures/svgcheck/repair/colors.svg.err +0 -0
  229. data/spec/fixtures/svgcheck/repair/colors.svg.file +19 -0
  230. data/spec/fixtures/svgcheck/repair/colors.svg.out +13 -0
  231. data/spec/fixtures/svgcheck/repair/dia-sample-svg.svg.code +1 -0
  232. data/spec/fixtures/svgcheck/repair/dia-sample-svg.svg.err +0 -0
  233. data/spec/fixtures/svgcheck/repair/dia-sample-svg.svg.file +47 -0
  234. data/spec/fixtures/svgcheck/repair/dia-sample-svg.svg.out +76 -0
  235. data/spec/fixtures/svgcheck/repair/example-dot.svg.code +1 -0
  236. data/spec/fixtures/svgcheck/repair/example-dot.svg.err +0 -0
  237. data/spec/fixtures/svgcheck/repair/example-dot.svg.file +69 -0
  238. data/spec/fixtures/svgcheck/repair/example-dot.svg.out +11 -0
  239. data/spec/fixtures/svgcheck/repair/good.svg.code +1 -0
  240. data/spec/fixtures/svgcheck/repair/good.svg.err +0 -0
  241. data/spec/fixtures/svgcheck/repair/good.svg.file +0 -0
  242. data/spec/fixtures/svgcheck/repair/good.svg.out +1 -0
  243. data/spec/fixtures/svgcheck/repair/httpbis-proxy20-fig6.svg.code +1 -0
  244. data/spec/fixtures/svgcheck/repair/httpbis-proxy20-fig6.svg.err +0 -0
  245. data/spec/fixtures/svgcheck/repair/httpbis-proxy20-fig6.svg.file +73 -0
  246. data/spec/fixtures/svgcheck/repair/httpbis-proxy20-fig6.svg.out +5 -0
  247. data/spec/fixtures/svgcheck/repair/malformed.svg.code +1 -0
  248. data/spec/fixtures/svgcheck/repair/malformed.svg.err +0 -0
  249. data/spec/fixtures/svgcheck/repair/malformed.svg.file +9 -0
  250. data/spec/fixtures/svgcheck/repair/malformed.svg.out +8 -0
  251. data/spec/fixtures/svgcheck/repair/rfc-svg.svg.code +1 -0
  252. data/spec/fixtures/svgcheck/repair/rfc-svg.svg.err +0 -0
  253. data/spec/fixtures/svgcheck/repair/rfc-svg.svg.file +0 -0
  254. data/spec/fixtures/svgcheck/repair/rfc-svg.svg.out +1 -0
  255. data/spec/fixtures/svgcheck/repair/rfc.xml.code +1 -0
  256. data/spec/fixtures/svgcheck/repair/rfc.xml.err +0 -0
  257. data/spec/fixtures/svgcheck/repair/rfc.xml.file +37 -0
  258. data/spec/fixtures/svgcheck/repair/rfc.xml.out +2 -0
  259. data/spec/fixtures/svgcheck/repair/rgb.svg.code +1 -0
  260. data/spec/fixtures/svgcheck/repair/rgb.svg.err +0 -0
  261. data/spec/fixtures/svgcheck/repair/rgb.svg.file +10 -0
  262. data/spec/fixtures/svgcheck/repair/rgb.svg.out +9 -0
  263. data/spec/fixtures/svgcheck/repair/svg-wordle.svg.code +1 -0
  264. data/spec/fixtures/svgcheck/repair/svg-wordle.svg.err +0 -0
  265. data/spec/fixtures/svgcheck/repair/svg-wordle.svg.file +112 -0
  266. data/spec/fixtures/svgcheck/repair/svg-wordle.svg.out +508 -0
  267. data/spec/fixtures/svgcheck/repair/threshold.svg.code +1 -0
  268. data/spec/fixtures/svgcheck/repair/threshold.svg.err +0 -0
  269. data/spec/fixtures/svgcheck/repair/threshold.svg.file +27 -0
  270. data/spec/fixtures/svgcheck/repair/threshold.svg.out +20 -0
  271. data/spec/fixtures/svgcheck/repair/utf8.svg.code +1 -0
  272. data/spec/fixtures/svgcheck/repair/utf8.svg.err +0 -0
  273. data/spec/fixtures/svgcheck/repair/utf8.svg.file +381 -0
  274. data/spec/fixtures/svgcheck/repair/utf8.svg.out +162 -0
  275. data/spec/fixtures/svgcheck/repair/viewBox-both.svg.code +1 -0
  276. data/spec/fixtures/svgcheck/repair/viewBox-both.svg.err +0 -0
  277. data/spec/fixtures/svgcheck/repair/viewBox-both.svg.file +4 -0
  278. data/spec/fixtures/svgcheck/repair/viewBox-both.svg.out +4 -0
  279. data/spec/fixtures/svgcheck/repair/viewBox-height.svg.code +1 -0
  280. data/spec/fixtures/svgcheck/repair/viewBox-height.svg.err +0 -0
  281. data/spec/fixtures/svgcheck/repair/viewBox-height.svg.file +4 -0
  282. data/spec/fixtures/svgcheck/repair/viewBox-height.svg.out +3 -0
  283. data/spec/fixtures/svgcheck/repair/viewBox-none.svg.code +1 -0
  284. data/spec/fixtures/svgcheck/repair/viewBox-none.svg.err +0 -0
  285. data/spec/fixtures/svgcheck/repair/viewBox-none.svg.file +4 -0
  286. data/spec/fixtures/svgcheck/repair/viewBox-none.svg.out +3 -0
  287. data/spec/fixtures/svgcheck/repair/viewBox-width.svg.code +1 -0
  288. data/spec/fixtures/svgcheck/repair/viewBox-width.svg.err +0 -0
  289. data/spec/fixtures/svgcheck/repair/viewBox-width.svg.file +4 -0
  290. data/spec/fixtures/svgcheck/repair/viewBox-width.svg.out +3 -0
  291. data/spec/fixtures/viewbox_required/inputs/missing_viewbox.svg +10 -0
  292. data/spec/fixtures/viewbox_required/repair/missing_viewbox.svg +10 -0
  293. data/spec/spec_helper.rb +16 -0
  294. data/spec/svg_conform/batch_report_spec.rb +99 -0
  295. data/spec/svg_conform/commands/check_command_spec.rb +90 -0
  296. data/spec/svg_conform/commands/profiles_command_spec.rb +20 -0
  297. data/spec/svg_conform/commands/svgcheck_compare_command_spec.rb +13 -0
  298. data/spec/svg_conform/commands/svgcheck_compatibility_command_spec.rb +13 -0
  299. data/spec/svg_conform/commands/svgcheck_generate_command_spec.rb +14 -0
  300. data/spec/svg_conform/profiles/base_profile_spec.rb +42 -0
  301. data/spec/svg_conform/profiles/lucid_fix_profile_spec.rb +46 -0
  302. data/spec/svg_conform/profiles/lucid_profile_spec.rb +84 -0
  303. data/spec/svg_conform/profiles/metanorma_profile_spec.rb +62 -0
  304. data/spec/svg_conform/profiles/no_external_css_profile_spec.rb +66 -0
  305. data/spec/svg_conform/profiles/svg_1_2_rfc_profile_spec.rb +200 -0
  306. data/spec/svg_conform/profiles/svg_1_2_rfc_with_rdf_profile_spec.rb +81 -0
  307. data/spec/svg_conform/remediations/color_remediation_spec.rb +95 -0
  308. data/spec/svg_conform/remediations/font_embedding_remediation_spec.rb +20 -0
  309. data/spec/svg_conform/remediations/font_remediation_spec.rb +95 -0
  310. data/spec/svg_conform/remediations/image_embedding_remediation_spec.rb +20 -0
  311. data/spec/svg_conform/remediations/invalid_id_references_remediation_spec.rb +97 -0
  312. data/spec/svg_conform/remediations/namespace_attribute_remediation_spec.rb +97 -0
  313. data/spec/svg_conform/remediations/namespace_remediation_spec.rb +95 -0
  314. data/spec/svg_conform/remediations/no_external_css_remediation_spec.rb +97 -0
  315. data/spec/svg_conform/remediations/style_promotion_remediation_spec.rb +97 -0
  316. data/spec/svg_conform/remediations/viewbox_remediation_spec.rb +95 -0
  317. data/spec/svg_conform/requirements/allowed_elements_requirement_spec.rb +118 -0
  318. data/spec/svg_conform/requirements/color_restrictions_requirement_spec.rb +168 -0
  319. data/spec/svg_conform/requirements/font_family_requirement_spec.rb +188 -0
  320. data/spec/svg_conform/requirements/forbidden_content_requirement_spec.rb +195 -0
  321. data/spec/svg_conform/requirements/id_reference_requirement_spec.rb +78 -0
  322. data/spec/svg_conform/requirements/invalid_id_references_requirement_spec.rb +78 -0
  323. data/spec/svg_conform/requirements/link_validation_requirement_spec.rb +78 -0
  324. data/spec/svg_conform/requirements/namespace_attributes_requirement_spec.rb +86 -0
  325. data/spec/svg_conform/requirements/namespace_requirement_spec.rb +184 -0
  326. data/spec/svg_conform/requirements/no_external_css_requirement_spec.rb +78 -0
  327. data/spec/svg_conform/requirements/no_external_fonts_requirement_spec.rb +20 -0
  328. data/spec/svg_conform/requirements/no_external_images_requirement_spec.rb +20 -0
  329. data/spec/svg_conform/requirements/style_promotion_requirement_spec.rb +78 -0
  330. data/spec/svg_conform/requirements/style_requirement_spec.rb +76 -0
  331. data/spec/svg_conform/requirements/viewbox_required_requirement_spec.rb +165 -0
  332. data/spec/svg_conform_spec.rb +32 -0
  333. data/spec/svgcheck_compatibility_spec.rb +355 -0
  334. data/svg_conform.gemspec +35 -0
  335. metadata +436 -0
@@ -0,0 +1,829 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "nokogiri"
4
+
5
+ module SvgConform
6
+ # Semantic comparison engine for comparing validation results and remediated content
7
+ class SemanticComparator
8
+ # Compare validation results semantically
9
+ def self.compare_validation_results(svg_conform_report, svgcheck_report)
10
+ # Extract semantic features from both reports
11
+ svg_conform_features = extract_validation_features(svg_conform_report)
12
+ svgcheck_features = extract_validation_features(svgcheck_report)
13
+
14
+ # Detect repair mode (both tools report valid=true)
15
+ repair_mode = svg_conform_features[:valid] && svgcheck_features[:valid]
16
+
17
+ # Detect mixed mode (one valid, one invalid)
18
+ mixed_mode = svg_conform_features[:valid] != svgcheck_features[:valid]
19
+
20
+ # Compare features semantically
21
+ comparison = {
22
+ overall_validity: compare_validity(svg_conform_features[:valid],
23
+ svgcheck_features[:valid]),
24
+ requirement_coverage: compare_requirement_coverage(svg_conform_features[:requirements],
25
+ svgcheck_features[:requirements]),
26
+ semantic_issues: compare_semantic_issues(svg_conform_features[:issues], svgcheck_features[:issues],
27
+ repair_mode, mixed_mode),
28
+ detailed_mapping: create_detailed_mapping(svg_conform_features[:issues], svgcheck_features[:issues],
29
+ repair_mode, mixed_mode),
30
+ repair_mode: repair_mode,
31
+ mixed_mode: mixed_mode,
32
+ }
33
+
34
+ # Add validity_match for compatibility
35
+ comparison[:validity_match] = comparison[:overall_validity][:match]
36
+
37
+ # Calculate overall compatibility score
38
+ comparison[:compatibility_score] =
39
+ calculate_compatibility_score(comparison)
40
+ comparison
41
+ end
42
+
43
+ # Compare remediated SVG content semantically
44
+ def self.compare_remediated_content(svg_conform_content, svgcheck_content)
45
+ # Parse both SVG documents
46
+ svg_conform_doc = parse_svg_safely(svg_conform_content)
47
+ svgcheck_doc = parse_svg_safely(svgcheck_content)
48
+
49
+ return { error: "Failed to parse SVG content" } if svg_conform_doc.nil? || svgcheck_doc.nil?
50
+
51
+ # Extract semantic features from both documents
52
+ svg_conform_features = extract_svg_features(svg_conform_doc)
53
+ svgcheck_features = extract_svg_features(svgcheck_doc)
54
+
55
+ # Compare features
56
+ {
57
+ structure_match: compare_structure(svg_conform_features[:structure],
58
+ svgcheck_features[:structure]),
59
+ attributes_match: compare_attributes(svg_conform_features[:attributes],
60
+ svgcheck_features[:attributes]),
61
+ content_match: compare_content(svg_conform_features[:content],
62
+ svgcheck_features[:content]),
63
+ namespace_match: compare_namespaces(svg_conform_features[:namespaces],
64
+ svgcheck_features[:namespaces]),
65
+ style_match: compare_styles(svg_conform_features[:styles],
66
+ svgcheck_features[:styles]),
67
+ semantic_equivalence: calculate_semantic_equivalence(
68
+ svg_conform_features, svgcheck_features
69
+ ),
70
+ }
71
+ end
72
+
73
+ # Extract validation features for semantic comparison
74
+ def self.extract_validation_features(report)
75
+ # Extract ALL issues (errors, warnings, infos) for semantic comparison
76
+ issues = []
77
+
78
+ issues.concat(report.errors.issues) if report.respond_to?(:errors) && report.errors.respond_to?(:issues)
79
+
80
+ # For svgcheck reports, also include warnings and infos if available
81
+ issues.concat(report.warnings.issues) if report.respond_to?(:warnings) && report.warnings.respond_to?(:issues)
82
+
83
+ # If the report has a direct issues method, use that
84
+ issues = report.issues if issues.empty? && report.respond_to?(:issues)
85
+
86
+ {
87
+ valid: report.respond_to?(:valid) ? report.valid : false,
88
+ requirements: extract_requirement_types(issues),
89
+ issues: group_issues_semantically(issues),
90
+ }
91
+ end
92
+
93
+ # Extract requirement types from issues
94
+ def self.extract_requirement_types(issues)
95
+ requirement_counts = {}
96
+
97
+ issues.each do |issue|
98
+ req_id = issue.respond_to?(:requirement_id) ? issue.requirement_id : "unknown"
99
+ requirement_counts[req_id] = (requirement_counts[req_id] || 0) + 1
100
+ end
101
+
102
+ requirement_counts
103
+ end
104
+
105
+ # Group issues by semantic meaning
106
+ def self.group_issues_semantically(issues)
107
+ semantic_groups = {}
108
+
109
+ issues.each do |issue|
110
+ semantic_key = extract_semantic_key(issue)
111
+ semantic_groups[semantic_key] ||= []
112
+ semantic_groups[semantic_key] << issue
113
+ end
114
+
115
+ semantic_groups.transform_values(&:length)
116
+ end
117
+
118
+ # Extract semantic key from an issue
119
+ def self.extract_semantic_key(issue)
120
+ # Handle different issue types
121
+ message = if issue.respond_to?(:message)
122
+ issue.message
123
+ elsif issue.is_a?(Hash)
124
+ issue[:message] || issue["message"]
125
+ else
126
+ issue.to_s
127
+ end
128
+
129
+ # SECURITY: Prevent ReDoS attacks by limiting message length
130
+ # GitHub CodeQL: Regular expressions with excessive backtracking can cause DoS
131
+ # This affects svgcheck comparison commands (development use only)
132
+ # Note: Ruby 3.2+ has built-in regex caching that prevents ReDoS
133
+ if message.length > 1000
134
+ # Truncate very long messages to prevent exponential backtracking
135
+ message = "#{message[0, 997]}..."
136
+ end
137
+
138
+ # Map ONLY the exact 28 svgcheck.py log patterns to semantic keys
139
+ # SECURITY: Regex patterns use quantifier limits to prevent ReDoS
140
+ case message
141
+ # Error patterns from checksvg.py (4 patterns)
142
+ when /\AMalformed field '([^']{1,200})' in style attribute found\. Field removed\.\z/
143
+ "malformed_style_field:#{normalize_value(::Regexp.last_match(1))}"
144
+ when /\AMalformed field '(\[[^\]]{1,200}\])' in style attribute found\. Field removed\.\z/
145
+ "malformed_style_field:#{normalize_value(::Regexp.last_match(1))}"
146
+ when /\AMalformed style declaration '([^']{1,200})' found\. Declaration removed\.\z/
147
+ "malformed_style_field:#{normalize_value(::Regexp.last_match(1))}"
148
+ when /\AStyle property '([^']{1,100})' promoted to attribute\z/
149
+ "style_promotion:#{::Regexp.last_match(1)}"
150
+ when /\AStyle property '([^']{1,100})' removed\z/
151
+ "style_property_removed:#{::Regexp.last_match(1)}"
152
+ when /\AError when calculating SVG size: (.{1,500})\z/
153
+ "informative:svg_size_calculation_error"
154
+ when /\AFile does not conform to SVG requirements\z/
155
+ "informative:file_nonconformant"
156
+
157
+ # Warning patterns from checksvg.py (10 patterns)
158
+ when /\AElement '([^']{1,100})' in namespace '([^']{1,200})' is not allowed\z/
159
+ "invalid_element_namespace:#{::Regexp.last_match(1)}:#{normalize_namespace(::Regexp.last_match(2))}"
160
+ when /\AElement '([^']{1,100})' not allowed\z/
161
+ "invalid_element:#{::Regexp.last_match(1)}"
162
+ when /\AElement '([^']{1,100})' does not allow attributes with namespace '([^']{1,200})'\z/
163
+ "namespace_violation:#{::Regexp.last_match(1)}:#{normalize_namespace(::Regexp.last_match(2))}"
164
+ when /\AThe element '([^']{1,100})' does not allow the attribute '([^']{1,100})', attribute to be removed\.\z/
165
+ "invalid_attribute:#{::Regexp.last_match(1)}:#{::Regexp.last_match(2)}"
166
+ when /\AThe attribute '([^']{1,100})' does not allow the value '([^']{1,200})', replaced with '([^']{1,200})'\z/
167
+ # Normalize color values for semantic equivalence
168
+ attribute = ::Regexp.last_match(1)
169
+ value = ::Regexp.last_match(2)
170
+
171
+ # For color attributes, use normalized color values
172
+ if %w[fill stroke].include?(attribute)
173
+ "invalid_attribute_value:#{attribute}:#{normalize_color_value(value)}"
174
+ else
175
+ "invalid_attribute_value:#{attribute}:#{normalize_value(value)}"
176
+ end
177
+ when /\AThe attribute '([^']{1,100})' does not allow the value '([^']{1,200})', attribute to be removed\z/
178
+ "invalid_attribute_value:#{::Regexp.last_match(1)}:#{normalize_value(::Regexp.last_match(2))}"
179
+ when /\AThe attribute viewBox is required on the root svg element\z/
180
+ "viewbox_required"
181
+ when /\ATrying to put in the attribute with value '([^']{1,200})'\z/
182
+ "informative:viewbox_auto_added"
183
+ when /\AThe namespace ([^\s]{1,200}) is not permitted for svg elements\.\z/
184
+ "namespace_violation:element:#{normalize_namespace(::Regexp.last_match(1))}"
185
+ when /\AThe element '([^']{1,100})' is not allowed as a child of '([^']{1,100})'\z/
186
+ "invalid_child:#{::Regexp.last_match(1)}:#{::Regexp.last_match(2)}"
187
+ when /\AMalformed namespace\. Should have errored during parsing\z/
188
+ "informative:malformed_namespace"
189
+ when /\A--no-xinclude option is deprecated and has no effect\.\z/
190
+ "informative:deprecated_option"
191
+
192
+ # Note patterns from checksvg.py (13 patterns)
193
+ when /\Amodify_style check '([^']{1,100})' in '([^']{1,100})'\z/
194
+ "informative:modify_style_check"
195
+ when /\A modify_style - p=([^\s]{1,100}) v=(.{1,200})\z/
196
+ "informative:modify_style_processing"
197
+ when /\Avalue_ok look for (.{1,200}) in (.{1,200})\z/
198
+ "informative:value_validation"
199
+ when /\A legal value list (.{1,500})\z/
200
+ "informative:legal_values"
201
+ when /\A --- skip to end -- (.{1,200})\z/
202
+ "informative:validation_skip"
203
+ when /\AColor or grayscale heuristic applied to: '([^']{1,100})' yields shade: '([^']{1,100})'\z/
204
+ "informative:color_heuristic"
205
+ when /\A[^\s]{1,50} tag = (.{1,200})\z/
206
+ "informative:element_processing"
207
+ when /\A[^\s]{1,50} element [^:]{1,50}: (.{1,200})\z/
208
+ "informative:element_attributes"
209
+ when /\A[^\s]{1,50} attr ([^\s]{1,100}) = ([^\s]{1,200}) \(ns = ([^)]{0,200})\)\z/
210
+ "informative:attribute_processing"
211
+ when /\A[^\s]{1,50}child, tag = (.{1,200})\z/
212
+ "informative:child_processing"
213
+ when /\AChecking svg element at line (\d{1,10}) in file (.{1,500})\z/
214
+ "informative:svg_element_check"
215
+
216
+ # SvgConform-specific patterns that map to svgcheck semantic equivalents
217
+ when /\AColor '([^']{1,100})' in attribute '([^']{1,100})' is not allowed in this profile\z/
218
+ # Map SvgConform color restriction to svgcheck invalid_attribute_value pattern
219
+ # Normalize color values to handle different formats (WHITE -> white, etc.)
220
+ "invalid_attribute_value:#{::Regexp.last_match(2)}:#{normalize_color_value(::Regexp.last_match(1))}"
221
+ when /\AColor '([^']{1,100})' in style property '([^']{1,100})' is not allowed in this profile\z/
222
+ # Map SvgConform style property color restriction to svgcheck invalid_attribute_value pattern
223
+ # Svgcheck promotes style properties to attributes, so we map accordingly
224
+ "invalid_attribute_value:#{::Regexp.last_match(2)}:#{normalize_color_value(::Regexp.last_match(1))}"
225
+ when /\AFont family '([^']{1,200})' is not allowed in this profile\z/
226
+ # Map SvgConform font family restriction to svgcheck invalid_attribute_value pattern
227
+ "invalid_attribute_value:font-family:#{normalize_value(::Regexp.last_match(1))}"
228
+ when /\AFont family '([^']{1,200})' in style is not allowed in this profile\z/
229
+ # Map SvgConform style font family restriction to svgcheck invalid_attribute_value pattern
230
+ font_family = normalize_value(::Regexp.last_match(1))
231
+ # Embedded font restrictions are profile differences
232
+ if font_family.include?("embedded")
233
+ "informative:profile_stricter_embedded_fonts:#{font_family}"
234
+ else
235
+ "invalid_attribute_value:font-family:#{font_family}"
236
+ end
237
+ when /\AFont family '([^']{1,200})' is not allowed in this profile\z/
238
+ # Map SvgConform font family restriction to svgcheck invalid_attribute_value pattern
239
+ font_family = normalize_value(::Regexp.last_match(1))
240
+ # Embedded font restrictions are profile differences
241
+ if font_family.include?("embedded")
242
+ "informative:profile_stricter_embedded_fonts:#{font_family}"
243
+ else
244
+ "invalid_attribute_value:font-family:#{font_family}"
245
+ end
246
+ when /\Asvg root element must have a viewbox attribute\z/i
247
+ # Map SvgConform viewBox requirement to svgcheck pattern (case insensitive)
248
+ "viewbox_required"
249
+ when /\AElement '([^']{1,100})' is not allowed in this profile\z/
250
+ # Map SvgConform element restriction to svgcheck invalid_child pattern
251
+ element = ::Regexp.last_match(1)
252
+ # Font-related and clipPath elements are profile differences, not validation errors
253
+ if %w[font glyph font-face missing-glyph clipPath].include?(element)
254
+ "informative:profile_stricter_elements:#{element}"
255
+ else
256
+ "invalid_child:#{element}:svg"
257
+ end
258
+ when /\AThe element '([^']{1,100})' is not allowed as a child of '([^']{1,100})'\z/
259
+ # Map SvgConform child element restriction to svgcheck pattern
260
+ element = ::Regexp.last_match(1)
261
+ parent = ::Regexp.last_match(2)
262
+ # Font-related and clipPath elements as children are profile differences
263
+ if %w[font glyph font-face missing-glyph clipPath].include?(element)
264
+ "informative:profile_stricter_elements:#{element}:#{parent}"
265
+ else
266
+ "invalid_child:#{element}:#{parent}"
267
+ end
268
+ when /\AAttribute '([^']{1,100})' is not allowed on element '([^']{1,100})'\z/
269
+ # Map SvgConform attribute restriction to svgcheck pattern
270
+ "invalid_attribute:#{::Regexp.last_match(2)}:#{::Regexp.last_match(1)}"
271
+ when /\AThe namespace ([^\s]{1,200}) is not permitted for svg elements\.?\z/
272
+ # Map SvgConform namespace restriction to svgcheck pattern
273
+ "namespace_violation:element:#{normalize_namespace(::Regexp.last_match(1))}"
274
+ when /\AviewBox attribute must contain four numeric values/i
275
+ # Map SvgConform viewBox format validation (more strict than svgcheck)
276
+ "viewbox_format_error"
277
+
278
+ # Everything else maps to "other" for non-svgcheck messages
279
+ else
280
+ "other:#{normalize_message(message)}"
281
+ end
282
+ end
283
+
284
+ # Compare validity semantically
285
+ def self.compare_validity(svg_conform_valid, svgcheck_valid)
286
+ {
287
+ svg_conform: svg_conform_valid,
288
+ svgcheck: svgcheck_valid,
289
+ match: svg_conform_valid == svgcheck_valid,
290
+ semantic_match: true, # Validity is always semantic
291
+ }
292
+ end
293
+
294
+ # Compare requirement coverage
295
+ def self.compare_requirement_coverage(svg_conform_reqs, svgcheck_reqs)
296
+ all_requirements = (svg_conform_reqs.keys + svgcheck_reqs.keys).uniq
297
+
298
+ coverage = {}
299
+ all_requirements.each do |req|
300
+ svg_count = svg_conform_reqs[req] || 0
301
+ svgcheck_count = svgcheck_reqs[req] || 0
302
+
303
+ coverage[req] = {
304
+ svg_conform: svg_count,
305
+ svgcheck: svgcheck_count,
306
+ exact_match: svg_count == svgcheck_count,
307
+ coverage_ratio: if svgcheck_count.positive?
308
+ svg_count.to_f / svgcheck_count
309
+ else
310
+ (svg_count.positive? ? Float::INFINITY : 1.0)
311
+ end,
312
+ }
313
+ end
314
+
315
+ coverage
316
+ end
317
+
318
+ # Compare semantic issues
319
+ def self.compare_semantic_issues(svg_conform_issues, svgcheck_issues,
320
+ repair_mode = false, _mixed_mode = false)
321
+ all_semantic_keys = (svg_conform_issues.keys + svgcheck_issues.keys).uniq
322
+
323
+ comparison = {}
324
+ all_semantic_keys.each do |key|
325
+ svg_count = svg_conform_issues[key] || 0
326
+ svgcheck_count = svgcheck_issues[key] || 0
327
+
328
+ # In repair mode, treat informational messages differently
329
+ # Check if this is a repair notification by looking at the semantic key pattern
330
+ is_repair_notification = repair_mode && (
331
+ key.start_with?("style_promotion:", "informative:") ||
332
+ key.include?("replaced with") ||
333
+ key.start_with?("invalid_attribute_value:") # These are repair notifications in repair mode
334
+ )
335
+
336
+ comparison[key] = if is_repair_notification
337
+ # Repair notifications are informational, not validation errors
338
+ {
339
+ svg_conform: svg_count,
340
+ svgcheck: svgcheck_count,
341
+ semantic_match: true, # Always consider repair notifications as matching
342
+ coverage: true,
343
+ repair_notification: true,
344
+ }
345
+ else
346
+ {
347
+ svg_conform: svg_count,
348
+ svgcheck: svgcheck_count,
349
+ semantic_match: svg_count == svgcheck_count,
350
+ coverage: svgcheck_count.positive? ? (svg_count >= svgcheck_count) : svg_count.zero?,
351
+ repair_notification: false,
352
+ }
353
+ end
354
+ end
355
+
356
+ comparison
357
+ end
358
+
359
+ # Create detailed mapping between issues
360
+ def self.create_detailed_mapping(svg_conform_issues, svgcheck_issues,
361
+ repair_mode = false, _mixed_mode = false)
362
+ # In repair mode, separate validation issues from repair notifications
363
+ if repair_mode
364
+ svg_conform_validation = svg_conform_issues.reject do |k, _|
365
+ k.start_with?("style_promotion:", "informative:",
366
+ "invalid_attribute_value:")
367
+ end
368
+ svgcheck_validation = svgcheck_issues.reject do |k, _|
369
+ k.start_with?("style_promotion:",
370
+ "informative:") || k.include?("replaced with") || k.start_with?("invalid_attribute_value:")
371
+ end
372
+
373
+ svg_conform_notifications = svg_conform_issues.select do |k, _|
374
+ k.start_with?("style_promotion:", "informative:",
375
+ "invalid_attribute_value:")
376
+ end
377
+ svgcheck_notifications = svgcheck_issues.select do |k, _|
378
+ k.start_with?("style_promotion:",
379
+ "informative:") || k.include?("replaced with") || k.start_with?("invalid_attribute_value:")
380
+ end
381
+
382
+ {
383
+ svg_conform_only: svg_conform_validation.keys - svgcheck_validation.keys,
384
+ svgcheck_only: svgcheck_validation.keys - svg_conform_validation.keys,
385
+ common: svg_conform_validation.keys & svgcheck_validation.keys,
386
+ total_svg_conform: svg_conform_validation.values.sum,
387
+ total_svgcheck: svgcheck_validation.values.sum,
388
+ repair_notifications: {
389
+ svg_conform: svg_conform_notifications.values.sum,
390
+ svgcheck: svgcheck_notifications.values.sum,
391
+ },
392
+ }
393
+ else
394
+ {
395
+ svg_conform_only: svg_conform_issues.keys - svgcheck_issues.keys,
396
+ svgcheck_only: svgcheck_issues.keys - svg_conform_issues.keys,
397
+ common: svg_conform_issues.keys & svgcheck_issues.keys,
398
+ total_svg_conform: svg_conform_issues.values.sum,
399
+ total_svgcheck: svgcheck_issues.values.sum,
400
+ }
401
+ end
402
+ end
403
+
404
+ # Calculate compatibility score
405
+ def self.calculate_compatibility_score(comparison)
406
+ # Handle mixed mode scenarios differently
407
+ return calculate_mixed_mode_compatibility_score(comparison) if comparison[:mixed_mode]
408
+
409
+ validity_score = comparison[:overall_validity][:match] ? 1.0 : 0.0
410
+
411
+ # Separate validation issues from informational issues
412
+ validation_issues = {}
413
+ informational_issues = {}
414
+
415
+ comparison[:semantic_issues].each do |key, issue|
416
+ if key.start_with?("style_promotion:", "informative:")
417
+ informational_issues[key] = issue
418
+ else
419
+ validation_issues[key] = issue
420
+ end
421
+ end
422
+
423
+ # Calculate semantic issues score for validation issues only
424
+ if validation_issues.empty?
425
+ semantic_score = 1.0
426
+ else
427
+ semantic_scores = validation_issues.values.map do |issue|
428
+ issue[:semantic_match] ? 1.0 : 0.0
429
+ end
430
+ semantic_score = semantic_scores.sum / semantic_scores.length
431
+ end
432
+
433
+ # Calculate coverage score excluding informational issues
434
+ total_svg_conform = comparison[:detailed_mapping][:total_svg_conform]
435
+ total_svgcheck = comparison[:detailed_mapping][:total_svgcheck]
436
+
437
+ # Subtract informational issues from both totals
438
+ informational_svg_conform = informational_issues.values.sum do |issue|
439
+ issue[:svg_conform]
440
+ end
441
+ informational_svgcheck = informational_issues.values.sum do |issue|
442
+ issue[:svgcheck]
443
+ end
444
+
445
+ validation_svg_conform_total = total_svg_conform - informational_svg_conform
446
+ validation_svgcheck_total = total_svgcheck - informational_svgcheck
447
+
448
+ # Coverage score: how well do we cover the validation issues?
449
+ # If SvgConform finds more issues than svgcheck, that's not a penalty
450
+ coverage_score = if validation_svgcheck_total.positive?
451
+ # Standard case: measure how much of svgcheck we cover
452
+ [
453
+ validation_svg_conform_total.to_f / validation_svgcheck_total, 1.0
454
+ ].min
455
+ elsif validation_svg_conform_total.positive?
456
+ # SvgConform finds issues but svgcheck doesn't - this is fine (more strict)
457
+ 1.0
458
+ else
459
+ # Both find no validation issues - perfect
460
+ 1.0
461
+ end
462
+
463
+ # Prioritize semantic matching over requirement categorization
464
+ # Validity: 20%, Semantic Issues: 60%, Coverage: 20%
465
+ (validity_score * 0.2 + semantic_score * 0.6 + coverage_score * 0.2).round(3)
466
+ end
467
+
468
+ # Calculate compatibility score for mixed mode scenarios
469
+ def self.calculate_mixed_mode_compatibility_score(comparison)
470
+ # In mixed mode, one tool succeeded and one failed
471
+ # Focus on complementary validation rather than exact overlap
472
+
473
+ # Separate validation issues from informational issues
474
+ validation_issues = {}
475
+ informational_issues = {}
476
+
477
+ comparison[:semantic_issues].each do |key, issue|
478
+ if key.start_with?("style_promotion:", "informative:")
479
+ informational_issues[key] = issue
480
+ else
481
+ validation_issues[key] = issue
482
+ end
483
+ end
484
+
485
+ # Calculate semantic overlap score (exact matches)
486
+ if validation_issues.empty?
487
+ semantic_overlap_score = 1.0
488
+ else
489
+ semantic_matches = validation_issues.values.count do |issue|
490
+ issue[:semantic_match]
491
+ end
492
+ total_unique_issues = validation_issues.size
493
+ semantic_overlap_score = semantic_matches.to_f / total_unique_issues
494
+ end
495
+
496
+ # Calculate complementary validation score with improved logic
497
+ # Give significant credit when tools detect different but valid issues
498
+ total_svg_conform = comparison[:detailed_mapping][:total_svg_conform]
499
+ total_svgcheck = comparison[:detailed_mapping][:total_svgcheck]
500
+
501
+ # Subtract informational issues from both totals
502
+ informational_svg_conform = informational_issues.values.sum do |issue|
503
+ issue[:svg_conform]
504
+ end
505
+ informational_svgcheck = informational_issues.values.sum do |issue|
506
+ issue[:svgcheck]
507
+ end
508
+
509
+ validation_svg_conform_total = total_svg_conform - informational_svg_conform
510
+ validation_svgcheck_total = total_svgcheck - informational_svgcheck
511
+
512
+ # Count common issues (both tools detected)
513
+ common_issues = comparison[:detailed_mapping][:common].size
514
+
515
+ # Enhanced complementary validation scoring
516
+ # Give high credit when both tools find issues, even if different types
517
+ complementary_score = if validation_svg_conform_total.positive? && validation_svgcheck_total.positive?
518
+ # Both tools found validation issues - excellent complementary coverage
519
+ # Base score of 0.8, with bonus for common issues
520
+ base_score = 0.8
521
+ common_bonus = common_issues.positive? ? 0.15 : 0.0
522
+ [base_score + common_bonus, 1.0].min
523
+ elsif validation_svg_conform_total.positive? || validation_svgcheck_total.positive?
524
+ # One tool found issues - partial coverage
525
+ 0.7
526
+ else
527
+ # Neither found validation issues - perfect (but unlikely in mixed mode)
528
+ 1.0
529
+ end
530
+
531
+ # Mixed mode remediation success bonus - enhanced
532
+ # In mixed mode, successful remediation by either tool is valuable
533
+ remediation_success_bonus = if comparison[:overall_validity][:svg_conform] || comparison[:overall_validity][:svgcheck]
534
+ # One tool successfully remediated the file
535
+ # Higher bonus if both tools found issues but one succeeded
536
+ if validation_svg_conform_total.positive? && validation_svgcheck_total.positive?
537
+ 0.5 # Both found issues, one succeeded - excellent
538
+ else
539
+ 0.4 # Standard remediation success
540
+ end
541
+ else
542
+ # Neither tool achieved validity (shouldn't happen in mixed mode)
543
+ 0.0
544
+ end
545
+
546
+ # Enhanced mixed mode scoring with higher weight on complementary validation
547
+ # Semantic Overlap: 25%, Complementary Validation: 50%, Remediation Success: 25%
548
+ # This better rewards comprehensive validation coverage
549
+ (semantic_overlap_score * 0.25 + complementary_score * 0.5 + remediation_success_bonus * 0.25).round(3)
550
+ end
551
+
552
+ # Parse SVG safely
553
+ def self.parse_svg_safely(content)
554
+ return nil if content.nil? || content.strip.empty?
555
+
556
+ Nokogiri::XML(content, &:strict)
557
+ rescue Nokogiri::XML::SyntaxError
558
+ nil
559
+ end
560
+
561
+ # Extract SVG features for semantic comparison
562
+ def self.extract_svg_features(doc)
563
+ {
564
+ structure: extract_structure(doc),
565
+ attributes: extract_attributes(doc),
566
+ content: extract_content(doc),
567
+ namespaces: extract_namespaces(doc),
568
+ styles: extract_styles(doc),
569
+ }
570
+ end
571
+
572
+ # Extract document structure
573
+ def self.extract_structure(doc)
574
+ elements = doc.xpath("//*").map(&:name).uniq.sort
575
+ hierarchy = extract_hierarchy(doc.root) if doc.root
576
+
577
+ {
578
+ elements: elements,
579
+ hierarchy: hierarchy,
580
+ element_count: doc.xpath("//*").length,
581
+ }
582
+ end
583
+
584
+ # Extract element hierarchy
585
+ def self.extract_hierarchy(element, depth = 0)
586
+ return nil if depth > 10 # Prevent infinite recursion
587
+
588
+ {
589
+ name: element.name,
590
+ children: element.children.select(&:element?).map do |child|
591
+ extract_hierarchy(child, depth + 1)
592
+ end,
593
+ }
594
+ end
595
+
596
+ # Extract attributes
597
+ def self.extract_attributes(doc)
598
+ attributes = {}
599
+
600
+ doc.xpath("//*").each do |element|
601
+ element.attributes.each do |name, attr|
602
+ key = "#{element.name}@#{name}"
603
+ attributes[key] ||= []
604
+ attributes[key] << normalize_attribute_value(attr.value)
605
+ end
606
+ end
607
+
608
+ attributes.transform_values(&:uniq)
609
+ end
610
+
611
+ # Extract text content
612
+ def self.extract_content(doc)
613
+ {
614
+ text_nodes: doc.xpath("//text()").map(&:content).reject(&:empty?),
615
+ cdata_sections: doc.xpath("//comment()").map(&:content),
616
+ }
617
+ end
618
+
619
+ # Extract namespaces
620
+ def self.extract_namespaces(doc)
621
+ namespaces = {}
622
+
623
+ doc.xpath("//*").each do |element|
624
+ element.namespace_definitions.each do |ns|
625
+ namespaces[ns.prefix || "default"] = ns.href
626
+ end
627
+ end
628
+
629
+ namespaces
630
+ end
631
+
632
+ # Extract style information
633
+ def self.extract_styles(doc)
634
+ styles = {}
635
+
636
+ doc.xpath("//*[@style]").each do |element|
637
+ style_attr = element["style"]
638
+ parsed_styles = parse_style_attribute(style_attr)
639
+ styles[element.name] ||= []
640
+ styles[element.name] << parsed_styles
641
+ end
642
+
643
+ styles
644
+ end
645
+
646
+ # Parse style attribute
647
+ def self.parse_style_attribute(style_str)
648
+ return {} if style_str.nil? || style_str.strip.empty?
649
+
650
+ styles = {}
651
+ style_str.split(";").each do |declaration|
652
+ next if declaration.strip.empty?
653
+
654
+ property, value = declaration.split(":", 2)
655
+ next unless property && value
656
+
657
+ styles[property.strip] = normalize_style_value(value.strip)
658
+ end
659
+
660
+ styles
661
+ end
662
+
663
+ # Compare structure
664
+ def self.compare_structure(struct1, struct2)
665
+ {
666
+ elements_match: struct1[:elements] == struct2[:elements],
667
+ hierarchy_match: compare_hierarchy(struct1[:hierarchy],
668
+ struct2[:hierarchy]),
669
+ element_count_match: struct1[:element_count] == struct2[:element_count],
670
+ }
671
+ end
672
+
673
+ # Compare hierarchy
674
+ def self.compare_hierarchy(hier1, hier2)
675
+ return true if hier1.nil? && hier2.nil?
676
+ return false if hier1.nil? || hier2.nil?
677
+
678
+ return false unless hier1[:name] == hier2[:name]
679
+ return false unless hier1[:children].length == hier2[:children].length
680
+
681
+ hier1[:children].zip(hier2[:children]).all? do |child1, child2|
682
+ compare_hierarchy(child1, child2)
683
+ end
684
+ end
685
+
686
+ # Compare attributes
687
+ def self.compare_attributes(attrs1, attrs2)
688
+ all_keys = (attrs1.keys + attrs2.keys).uniq
689
+
690
+ matches = all_keys.map do |key|
691
+ values1 = attrs1[key] || []
692
+ values2 = attrs2[key] || []
693
+ values1.sort == values2.sort
694
+ end
695
+
696
+ {
697
+ exact_match: matches.all?,
698
+ partial_match_ratio: matches.count(true).to_f / matches.length,
699
+ differing_attributes: all_keys.select.with_index { |_, i| !matches[i] },
700
+ }
701
+ end
702
+
703
+ # Compare content
704
+ def self.compare_content(content1, content2)
705
+ {
706
+ text_match: content1[:text_nodes].sort == content2[:text_nodes].sort,
707
+ cdata_match: content1[:cdata_sections].sort == content2[:cdata_sections].sort,
708
+ }
709
+ end
710
+
711
+ # Compare namespaces
712
+ def self.compare_namespaces(ns1, ns2)
713
+ {
714
+ exact_match: ns1 == ns2,
715
+ common_namespaces: ns1.keys & ns2.keys,
716
+ svg_conform_only: ns1.keys - ns2.keys,
717
+ svgcheck_only: ns2.keys - ns1.keys,
718
+ }
719
+ end
720
+
721
+ # Compare styles
722
+ def self.compare_styles(styles1, styles2)
723
+ all_elements = (styles1.keys + styles2.keys).uniq
724
+
725
+ element_comparisons = {}
726
+ all_elements.each do |element|
727
+ elem_styles1 = styles1[element] || []
728
+ elem_styles2 = styles2[element] || []
729
+
730
+ element_comparisons[element] = {
731
+ count_match: elem_styles1.length == elem_styles2.length,
732
+ semantic_match: compare_style_semantics(elem_styles1, elem_styles2),
733
+ }
734
+ end
735
+
736
+ element_comparisons
737
+ end
738
+
739
+ # Compare style semantics
740
+ def self.compare_style_semantics(styles1, styles2)
741
+ # Flatten and normalize all styles
742
+ flat1 = styles1.flat_map(&:to_a).to_h
743
+ flat2 = styles2.flat_map(&:to_a).to_h
744
+
745
+ # Compare normalized styles
746
+ flat1 == flat2
747
+ end
748
+
749
+ # Calculate semantic equivalence
750
+ def self.calculate_semantic_equivalence(features1, features2)
751
+ structure_score = features1[:structure][:elements] == features2[:structure][:elements] ? 1.0 : 0.0
752
+ namespace_score = features1[:namespaces] == features2[:namespaces] ? 1.0 : 0.0
753
+ content_score = features1[:content] == features2[:content] ? 1.0 : 0.0
754
+
755
+ # Weighted average
756
+ (structure_score * 0.4 + namespace_score * 0.3 + content_score * 0.3).round(3)
757
+ end
758
+
759
+ # Utility methods for normalization
760
+ def self.normalize_namespace(namespace)
761
+ case namespace
762
+ when /inkscape/i
763
+ "inkscape"
764
+ when /sodipodi/i
765
+ "sodipodi"
766
+ when /w3\.org.*svg/i
767
+ "svg"
768
+ else
769
+ namespace
770
+ end
771
+ end
772
+
773
+ def self.normalize_value(value)
774
+ # Normalize common values for semantic comparison
775
+ normalized = value.downcase.strip
776
+
777
+ # Handle array-like values from svgcheck (e.g., "['malformed']" -> "malformed")
778
+ normalized = ::Regexp.last_match(1) if normalized =~ /\A\['([^']{1,200})'\]\z/
779
+
780
+ normalized
781
+ end
782
+
783
+ def self.normalize_color_value(color)
784
+ # Normalize color values to handle different formats
785
+ normalized = color.downcase.strip
786
+
787
+ # Map common color equivalents
788
+ case normalized
789
+ when "white", "#fff", "#ffffff", "rgb(255,255,255)", "rgb(255, 255, 255)", "rgb(100%,100%,100%)", "rgb(100%, 100%, 100%)"
790
+ "white"
791
+ when "black", "#000", "#000000", "rgb(0,0,0)", "rgb(0, 0, 0)", "rgb(0%,0%,0%)", "rgb(0%, 0%, 0%)"
792
+ "black"
793
+ when "red", "#f00", "#ff0000", "rgb(255,0,0)", "rgb(255, 0, 0)", "rgb(100%,0%,0%)", "rgb(100%, 0%, 0%)"
794
+ "red"
795
+ when "green", "#0f0", "#00ff00", "rgb(0,255,0)", "rgb(0, 255, 0)", "rgb(0%,100%,0%)", "rgb(0%, 100%, 0%)"
796
+ "green"
797
+ when "blue", "#00f", "#0000ff", "rgb(0,0,255)", "rgb(0, 0, 255)", "rgb(0%,0%,100%)", "rgb(0%, 0%, 100%)"
798
+ "blue"
799
+ when "grey", "gray"
800
+ "grey"
801
+ else
802
+ # For hex colors, normalize to lowercase without spaces
803
+ if /\A#[0-9a-f]{3,8}\z/i.match?(normalized)
804
+ normalized.downcase
805
+ elsif /\Argb\s*\(/i.match?(normalized)
806
+ # Normalize RGB format by removing spaces
807
+ normalized.gsub(/\s+/, "")
808
+ else
809
+ normalized
810
+ end
811
+ end
812
+ end
813
+
814
+ def self.normalize_message(message)
815
+ # Extract key semantic components from message
816
+ message.downcase.gsub(/['":]/, "").strip
817
+ end
818
+
819
+ def self.normalize_attribute_value(value)
820
+ # Normalize attribute values for comparison
821
+ value.strip.downcase
822
+ end
823
+
824
+ def self.normalize_style_value(value)
825
+ # Normalize style values for comparison
826
+ value.strip.downcase
827
+ end
828
+ end
829
+ end