svgpng 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 (207) hide show
  1. checksums.yaml +7 -0
  2. data/README.adoc +81 -0
  3. data/docs/decisions/svg-to-png.md +368 -0
  4. data/docs/goldens.md +74 -0
  5. data/lib/svgpng/errors.rb +57 -0
  6. data/lib/svgpng/font_face.rb +36 -0
  7. data/lib/svgpng/font_inputs.rb +49 -0
  8. data/lib/svgpng/font_warnings.rb +45 -0
  9. data/lib/svgpng/limits.rb +10 -0
  10. data/lib/svgpng/native.rb +213 -0
  11. data/lib/svgpng/native_render.rb +198 -0
  12. data/lib/svgpng/package_resolution.rb +174 -0
  13. data/lib/svgpng/raw_input.rb +51 -0
  14. data/lib/svgpng/release_set.rb +273 -0
  15. data/lib/svgpng/render_profile.rb +183 -0
  16. data/lib/svgpng/renderer.rb +27 -0
  17. data/lib/svgpng/result.rb +50 -0
  18. data/lib/svgpng/version.rb +5 -0
  19. data/lib/svgpng.rb +19 -0
  20. data/vendor/licenses/README.txt +23 -0
  21. data/vendor/licenses/fonts/COPYRIGHT.txt +13 -0
  22. data/vendor/licenses/fonts/NotoSans-Condensed-OFL-1.1.txt +94 -0
  23. data/vendor/licenses/fonts/OFL-1.1.txt +92 -0
  24. data/vendor/licenses/libpng/LICENSE +134 -0
  25. data/vendor/licenses/libpng/LICENSES/MIT-0.txt +16 -0
  26. data/vendor/licenses/libpng/LICENSES/MIT.txt +19 -0
  27. data/vendor/licenses/resvg/LICENSE-APACHE +176 -0
  28. data/vendor/licenses/resvg/LICENSE-MIT +25 -0
  29. data/vendor/licenses/rust/adler2-2.0.1/LICENSE-0BSD +12 -0
  30. data/vendor/licenses/rust/adler2-2.0.1/LICENSE-APACHE +201 -0
  31. data/vendor/licenses/rust/adler2-2.0.1/LICENSE-MIT +23 -0
  32. data/vendor/licenses/rust/arrayref-0.3.9/LICENSE +26 -0
  33. data/vendor/licenses/rust/arrayvec-0.7.8/LICENSE-APACHE +201 -0
  34. data/vendor/licenses/rust/arrayvec-0.7.8/LICENSE-MIT +25 -0
  35. data/vendor/licenses/rust/autocfg-1.5.1/LICENSE-APACHE +201 -0
  36. data/vendor/licenses/rust/autocfg-1.5.1/LICENSE-MIT +25 -0
  37. data/vendor/licenses/rust/base64-0.23.1/LICENSE-APACHE +201 -0
  38. data/vendor/licenses/rust/base64-0.23.1/LICENSE-MIT +21 -0
  39. data/vendor/licenses/rust/bitflags-2.13.2/LICENSE-APACHE +201 -0
  40. data/vendor/licenses/rust/bitflags-2.13.2/LICENSE-MIT +25 -0
  41. data/vendor/licenses/rust/block-buffer-0.10.4/LICENSE-APACHE +201 -0
  42. data/vendor/licenses/rust/block-buffer-0.10.4/LICENSE-MIT +25 -0
  43. data/vendor/licenses/rust/bytemuck-1.25.2/LICENSE-APACHE +61 -0
  44. data/vendor/licenses/rust/bytemuck-1.25.2/LICENSE-MIT +9 -0
  45. data/vendor/licenses/rust/bytemuck-1.25.2/LICENSE-ZLIB +11 -0
  46. data/vendor/licenses/rust/bytemuck_derive-1.12.1/LICENSE-APACHE +61 -0
  47. data/vendor/licenses/rust/bytemuck_derive-1.12.1/LICENSE-MIT +9 -0
  48. data/vendor/licenses/rust/bytemuck_derive-1.12.1/LICENSE-ZLIB +11 -0
  49. data/vendor/licenses/rust/byteorder-lite-0.1.0/LICENSE-MIT +21 -0
  50. data/vendor/licenses/rust/byteorder-lite-0.1.0/UNLICENSE +24 -0
  51. data/vendor/licenses/rust/cfg-if-1.0.5/LICENSE-APACHE +201 -0
  52. data/vendor/licenses/rust/cfg-if-1.0.5/LICENSE-MIT +25 -0
  53. data/vendor/licenses/rust/color_quant-1.1.0/LICENSE +21 -0
  54. data/vendor/licenses/rust/cpufeatures-0.2.17/LICENSE-APACHE +201 -0
  55. data/vendor/licenses/rust/cpufeatures-0.2.17/LICENSE-MIT +25 -0
  56. data/vendor/licenses/rust/crc32fast-1.5.3/LICENSE-APACHE +202 -0
  57. data/vendor/licenses/rust/crc32fast-1.5.3/LICENSE-MIT +21 -0
  58. data/vendor/licenses/rust/crypto-common-0.1.7/LICENSE-APACHE +201 -0
  59. data/vendor/licenses/rust/crypto-common-0.1.7/LICENSE-MIT +25 -0
  60. data/vendor/licenses/rust/cssparser-0.35.0/LICENSE +373 -0
  61. data/vendor/licenses/rust/cssparser-macros-0.6.1/LICENSE +373 -0
  62. data/vendor/licenses/rust/data-url-0.3.2/LICENSE-APACHE +201 -0
  63. data/vendor/licenses/rust/data-url-0.3.2/LICENSE-MIT +25 -0
  64. data/vendor/licenses/rust/digest-0.10.7/LICENSE-APACHE +201 -0
  65. data/vendor/licenses/rust/digest-0.10.7/LICENSE-MIT +25 -0
  66. data/vendor/licenses/rust/dtoa-1.0.11/LICENSE-APACHE +176 -0
  67. data/vendor/licenses/rust/dtoa-1.0.11/LICENSE-MIT +23 -0
  68. data/vendor/licenses/rust/dtoa-short-0.3.5/LICENSE +373 -0
  69. data/vendor/licenses/rust/euclid-0.22.14/COPYRIGHT +5 -0
  70. data/vendor/licenses/rust/euclid-0.22.14/LICENSE-APACHE +201 -0
  71. data/vendor/licenses/rust/euclid-0.22.14/LICENSE-MIT +25 -0
  72. data/vendor/licenses/rust/fdeflate-0.3.7/LICENSE-APACHE +177 -0
  73. data/vendor/licenses/rust/fdeflate-0.3.7/LICENSE-MIT +25 -0
  74. data/vendor/licenses/rust/flate2-1.1.10/LICENSE-APACHE +201 -0
  75. data/vendor/licenses/rust/flate2-1.1.10/LICENSE-MIT +25 -0
  76. data/vendor/licenses/rust/float-cmp-0.9.0/LICENSE +19 -0
  77. data/vendor/licenses/rust/font-types-0.12.7/LICENSE-APACHE +67 -0
  78. data/vendor/licenses/rust/font-types-0.12.7/LICENSE-MIT +25 -0
  79. data/vendor/licenses/rust/fontconfig-parser-0.5.8/LICENSE +21 -0
  80. data/vendor/licenses/rust/fontdb-0.24.0/LICENSE +21 -0
  81. data/vendor/licenses/rust/generic-array-0.14.7/LICENSE +21 -0
  82. data/vendor/licenses/rust/gif-0.14.2/LICENSE-APACHE +201 -0
  83. data/vendor/licenses/rust/gif-0.14.2/LICENSE-MIT +21 -0
  84. data/vendor/licenses/rust/harfrust-0.12.0/LICENSE +22 -0
  85. data/vendor/licenses/rust/image-webp-0.2.4/LICENSE-APACHE +177 -0
  86. data/vendor/licenses/rust/image-webp-0.2.4/LICENSE-MIT +25 -0
  87. data/vendor/licenses/rust/imagesize-0.15.0/LICENSE +21 -0
  88. data/vendor/licenses/rust/itoa-1.0.18/LICENSE-APACHE +176 -0
  89. data/vendor/licenses/rust/itoa-1.0.18/LICENSE-MIT +23 -0
  90. data/vendor/licenses/rust/kurbo-0.13.1/LICENSE-APACHE +202 -0
  91. data/vendor/licenses/rust/kurbo-0.13.1/LICENSE-MIT +25 -0
  92. data/vendor/licenses/rust/libc-0.2.191/LICENSE-APACHE +176 -0
  93. data/vendor/licenses/rust/libc-0.2.191/LICENSE-MIT +25 -0
  94. data/vendor/licenses/rust/log-0.4.34/LICENSE-APACHE +201 -0
  95. data/vendor/licenses/rust/log-0.4.34/LICENSE-MIT +25 -0
  96. data/vendor/licenses/rust/memchr-2.8.3/COPYING +3 -0
  97. data/vendor/licenses/rust/memchr-2.8.3/LICENSE-MIT +21 -0
  98. data/vendor/licenses/rust/memchr-2.8.3/UNLICENSE +24 -0
  99. data/vendor/licenses/rust/memmap2-0.9.12/LICENSE-APACHE +201 -0
  100. data/vendor/licenses/rust/memmap2-0.9.12/LICENSE-MIT +26 -0
  101. data/vendor/licenses/rust/miniz_oxide-0.8.9/LICENSE +25 -0
  102. data/vendor/licenses/rust/miniz_oxide-0.8.9/LICENSE-APACHE.md +177 -0
  103. data/vendor/licenses/rust/miniz_oxide-0.8.9/LICENSE-MIT.md +24 -0
  104. data/vendor/licenses/rust/miniz_oxide-0.8.9/LICENSE-ZLIB.md +14 -0
  105. data/vendor/licenses/rust/miniz_oxide-0.9.1/LICENSE +25 -0
  106. data/vendor/licenses/rust/miniz_oxide-0.9.1/LICENSE-APACHE.md +177 -0
  107. data/vendor/licenses/rust/miniz_oxide-0.9.1/LICENSE-MIT.md +24 -0
  108. data/vendor/licenses/rust/miniz_oxide-0.9.1/LICENSE-ZLIB.md +14 -0
  109. data/vendor/licenses/rust/num-traits-0.2.19/LICENSE-APACHE +201 -0
  110. data/vendor/licenses/rust/num-traits-0.2.19/LICENSE-MIT +25 -0
  111. data/vendor/licenses/rust/once_cell-1.21.4/LICENSE-APACHE +201 -0
  112. data/vendor/licenses/rust/once_cell-1.21.4/LICENSE-MIT +23 -0
  113. data/vendor/licenses/rust/phf-0.11.3/LICENSE +20 -0
  114. data/vendor/licenses/rust/phf_generator-0.11.3/LICENSE +20 -0
  115. data/vendor/licenses/rust/phf_macros-0.11.3/LICENSE +20 -0
  116. data/vendor/licenses/rust/phf_shared-0.11.3/LICENSE +20 -0
  117. data/vendor/licenses/rust/pico-args-0.5.0/LICENSE +20 -0
  118. data/vendor/licenses/rust/png-0.18.1/LICENSE-APACHE +201 -0
  119. data/vendor/licenses/rust/png-0.18.1/LICENSE-MIT +25 -0
  120. data/vendor/licenses/rust/polycool-0.4.0/LICENSE-APACHE +202 -0
  121. data/vendor/licenses/rust/polycool-0.4.0/LICENSE-MIT +25 -0
  122. data/vendor/licenses/rust/proc-macro2-1.0.107/LICENSE-APACHE +176 -0
  123. data/vendor/licenses/rust/proc-macro2-1.0.107/LICENSE-MIT +23 -0
  124. data/vendor/licenses/rust/quick-error-2.0.1/LICENSE-APACHE +202 -0
  125. data/vendor/licenses/rust/quick-error-2.0.1/LICENSE-MIT +19 -0
  126. data/vendor/licenses/rust/quote-1.0.47/LICENSE-APACHE +176 -0
  127. data/vendor/licenses/rust/quote-1.0.47/LICENSE-MIT +23 -0
  128. data/vendor/licenses/rust/rand-0.8.8/COPYRIGHT +12 -0
  129. data/vendor/licenses/rust/rand-0.8.8/LICENSE-APACHE +176 -0
  130. data/vendor/licenses/rust/rand-0.8.8/LICENSE-MIT +26 -0
  131. data/vendor/licenses/rust/rand_core-0.6.4/COPYRIGHT +12 -0
  132. data/vendor/licenses/rust/rand_core-0.6.4/LICENSE-APACHE +187 -0
  133. data/vendor/licenses/rust/rand_core-0.6.4/LICENSE-MIT +26 -0
  134. data/vendor/licenses/rust/read-fonts-0.41.0/LICENSE-APACHE +67 -0
  135. data/vendor/licenses/rust/read-fonts-0.41.0/LICENSE-MIT +25 -0
  136. data/vendor/licenses/rust/rgb-0.8.53/LICENSE +21 -0
  137. data/vendor/licenses/rust/roxmltree-0.20.0/LICENSE-APACHE +201 -0
  138. data/vendor/licenses/rust/roxmltree-0.20.0/LICENSE-MIT +21 -0
  139. data/vendor/licenses/rust/roxmltree-0.21.1/LICENSE-APACHE +201 -0
  140. data/vendor/licenses/rust/roxmltree-0.21.1/LICENSE-MIT +21 -0
  141. data/vendor/licenses/rust/ryu-1.0.23/LICENSE-APACHE +176 -0
  142. data/vendor/licenses/rust/ryu-1.0.23/LICENSE-BOOST +23 -0
  143. data/vendor/licenses/rust/serde-1.0.229/LICENSE-APACHE +176 -0
  144. data/vendor/licenses/rust/serde-1.0.229/LICENSE-MIT +23 -0
  145. data/vendor/licenses/rust/serde_core-1.0.229/LICENSE-APACHE +176 -0
  146. data/vendor/licenses/rust/serde_core-1.0.229/LICENSE-MIT +23 -0
  147. data/vendor/licenses/rust/serde_derive-1.0.229/LICENSE-APACHE +176 -0
  148. data/vendor/licenses/rust/serde_derive-1.0.229/LICENSE-MIT +23 -0
  149. data/vendor/licenses/rust/serde_json-1.0.145/LICENSE-APACHE +176 -0
  150. data/vendor/licenses/rust/serde_json-1.0.145/LICENSE-MIT +23 -0
  151. data/vendor/licenses/rust/sha2-0.10.9/LICENSE-APACHE +201 -0
  152. data/vendor/licenses/rust/sha2-0.10.9/LICENSE-MIT +27 -0
  153. data/vendor/licenses/rust/simd-adler32-0.3.10/LICENSE.md +21 -0
  154. data/vendor/licenses/rust/simplecss-0.2.2/LICENSE-APACHE +201 -0
  155. data/vendor/licenses/rust/simplecss-0.2.2/LICENSE-MIT +21 -0
  156. data/vendor/licenses/rust/siphasher-1.0.4/COPYING +7 -0
  157. data/vendor/licenses/rust/siphasher-1.0.4/LICENSE-APACHE +202 -0
  158. data/vendor/licenses/rust/siphasher-1.0.4/LICENSE-MIT +17 -0
  159. data/vendor/licenses/rust/skrifa-0.44.0/LICENSE-APACHE +67 -0
  160. data/vendor/licenses/rust/skrifa-0.44.0/LICENSE-MIT +25 -0
  161. data/vendor/licenses/rust/slotmap-1.1.1/LICENSE +19 -0
  162. data/vendor/licenses/rust/smallvec-1.16.3/LICENSE-APACHE +201 -0
  163. data/vendor/licenses/rust/smallvec-1.16.3/LICENSE-MIT +25 -0
  164. data/vendor/licenses/rust/strict-num-0.1.1/LICENSE +19 -0
  165. data/vendor/licenses/rust/svgtypes-0.16.1/LICENSE-APACHE +201 -0
  166. data/vendor/licenses/rust/svgtypes-0.16.1/LICENSE-MIT +20 -0
  167. data/vendor/licenses/rust/syn-2.0.119/LICENSE-APACHE +176 -0
  168. data/vendor/licenses/rust/syn-2.0.119/LICENSE-MIT +23 -0
  169. data/vendor/licenses/rust/syn-3.0.9/LICENSE-APACHE +176 -0
  170. data/vendor/licenses/rust/syn-3.0.9/LICENSE-MIT +23 -0
  171. data/vendor/licenses/rust/tiny-skia-0.12.0/LICENSE +30 -0
  172. data/vendor/licenses/rust/tiny-skia-path-0.12.0/LICENSE +30 -0
  173. data/vendor/licenses/rust/tinyvec-1.13.3/LICENSE-APACHE.md +202 -0
  174. data/vendor/licenses/rust/tinyvec-1.13.3/LICENSE-MIT.md +5 -0
  175. data/vendor/licenses/rust/tinyvec-1.13.3/LICENSE-ZLIB.md +11 -0
  176. data/vendor/licenses/rust/typenum-1.20.1/LICENSE +1 -0
  177. data/vendor/licenses/rust/typenum-1.20.1/LICENSE-APACHE +201 -0
  178. data/vendor/licenses/rust/typenum-1.20.1/LICENSE-MIT +21 -0
  179. data/vendor/licenses/rust/unicode-bidi-0.3.18/COPYRIGHT +8 -0
  180. data/vendor/licenses/rust/unicode-bidi-0.3.18/LICENSE-APACHE +201 -0
  181. data/vendor/licenses/rust/unicode-bidi-0.3.18/LICENSE-MIT +25 -0
  182. data/vendor/licenses/rust/unicode-ident-1.0.27/LICENSE-APACHE +176 -0
  183. data/vendor/licenses/rust/unicode-ident-1.0.27/LICENSE-MIT +23 -0
  184. data/vendor/licenses/rust/unicode-ident-1.0.27/LICENSE-UNICODE +39 -0
  185. data/vendor/licenses/rust/unicode-normalization-0.1.24/COPYRIGHT +7 -0
  186. data/vendor/licenses/rust/unicode-normalization-0.1.24/LICENSE-APACHE +201 -0
  187. data/vendor/licenses/rust/unicode-normalization-0.1.24/LICENSE-MIT +25 -0
  188. data/vendor/licenses/rust/unicode-script-0.5.8/LICENSE-APACHE +201 -0
  189. data/vendor/licenses/rust/unicode-script-0.5.8/LICENSE-MIT +27 -0
  190. data/vendor/licenses/rust/unicode-vo-0.1.0/LICENSE-APACHE +201 -0
  191. data/vendor/licenses/rust/unicode-vo-0.1.0/LICENSE-MIT +21 -0
  192. data/vendor/licenses/rust/version_check-0.9.5/LICENSE-APACHE +201 -0
  193. data/vendor/licenses/rust/version_check-0.9.5/LICENSE-MIT +19 -0
  194. data/vendor/licenses/rust/weezl-0.1.12/LICENSE-APACHE +201 -0
  195. data/vendor/licenses/rust/weezl-0.1.12/LICENSE-MIT +22 -0
  196. data/vendor/licenses/rust/xmlwriter-0.1.0/LICENSE +21 -0
  197. data/vendor/licenses/rust/zlib-rs-0.6.8/LICENSE +19 -0
  198. data/vendor/licenses/rust/zune-core-0.5.3/LICENSE-APACHE +201 -0
  199. data/vendor/licenses/rust/zune-core-0.5.3/LICENSE-MIT +21 -0
  200. data/vendor/licenses/rust/zune-core-0.5.3/LICENSE-ZLIB +19 -0
  201. data/vendor/licenses/rust/zune-jpeg-0.5.15/LICENSE-APACHE +201 -0
  202. data/vendor/licenses/rust/zune-jpeg-0.5.15/LICENSE-MIT +21 -0
  203. data/vendor/licenses/rust/zune-jpeg-0.5.15/LICENSE-ZLIB +19 -0
  204. data/vendor/licenses/zlib/LICENSE +22 -0
  205. data/vendor/manifest.yml +102 -0
  206. data/vendor/rust-dependencies.json +1696 -0
  207. metadata +256 -0
@@ -0,0 +1,174 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "rubygems/package"
5
+ require "rubygems/resolver"
6
+ require "rubygems/source"
7
+ require "tmpdir"
8
+ require "zlib"
9
+ require_relative "../svgpng"
10
+ require_relative "native"
11
+ require_relative "release_set"
12
+
13
+ module Svgpng
14
+ # Exercises RubyGems resolution against the verified staged set and local index.
15
+ # rubocop:disable Metrics/ModuleLength -- cohesive private pipeline helpers keep the public surface to verify!.
16
+ module PackageResolution
17
+ class VerificationError < StandardError; end
18
+
19
+ # Resolve dependencies from the installed bundle, but always select SVGPNG
20
+ # from the verified eight-package index rather than the source checkout.
21
+ class InstalledDependencies < Gem::Resolver::CurrentSet
22
+ def find_all(request)
23
+ request.name == "svgpng" ? [] : super
24
+ end
25
+ end
26
+
27
+ def self.verify!(directory:, manifest_path:, destination:)
28
+ verify_destination!(destination)
29
+
30
+ paths = ReleaseSet.verify!(directory: directory, manifest_path: manifest_path)
31
+ Dir.mktmpdir("svgpng-resolution-") do |workspace|
32
+ resolve_staged(paths, workspace, destination)
33
+ end
34
+ rescue ReleaseSet::VerificationError => e
35
+ raise VerificationError, "staged release-set validation failed: #{e.message}"
36
+ end
37
+
38
+ def self.verify_destination!(destination)
39
+ return unless File.exist?(destination) && (!File.directory?(destination) || !Dir.empty?(destination))
40
+
41
+ raise VerificationError, "install destination must be absent or an empty directory"
42
+ end
43
+ private_class_method :verify_destination!
44
+
45
+ def self.resolve_staged(paths, workspace, destination)
46
+ repository = stage_repository(paths, workspace)
47
+ generate_index(repository)
48
+ install_local(repository, destination)
49
+ end
50
+ private_class_method :resolve_staged
51
+
52
+ def self.generate_index(repository)
53
+ specs = package_specs(repository)
54
+ quick = File.join(repository, "quick", "Marshal.4.8")
55
+ FileUtils.mkdir_p(quick)
56
+ write_quick_specs(specs, quick)
57
+ tuples = specs.map { |spec| [spec.name, spec.version, spec.platform] }
58
+ write_index_files(repository, tuples)
59
+ end
60
+ private_class_method :generate_index
61
+
62
+ def self.package_specs(repository)
63
+ Dir.glob(File.join(repository, "gems", "*.gem")).map { |path| Gem::Package.new(path).spec }
64
+ end
65
+ private_class_method :package_specs
66
+
67
+ def self.write_quick_specs(specs, quick)
68
+ specs.each do |spec|
69
+ path = File.join(quick, "#{spec.full_name}.gemspec.rz")
70
+ File.binwrite(path, Zlib::Deflate.deflate(Marshal.dump(spec)))
71
+ end
72
+ end
73
+ private_class_method :write_quick_specs
74
+
75
+ def self.write_index_files(repository, tuples)
76
+ %w[specs.4.8 latest_specs.4.8].each { |name| write_gzip_index(repository, name, tuples) }
77
+ end
78
+ private_class_method :write_index_files
79
+
80
+ def self.write_gzip_index(repository, name, tuples)
81
+ File.open(File.join(repository, "#{name}.gz"), "wb") do |file|
82
+ Zlib::GzipWriter.wrap(file) { |gzip| gzip.write(Marshal.dump(tuples)) }
83
+ end
84
+ end
85
+ private_class_method :write_gzip_index
86
+
87
+ def self.stage_repository(paths, workspace)
88
+ repository = File.join(workspace, "repository")
89
+ gem_directory = File.join(repository, "gems")
90
+ FileUtils.mkdir_p(gem_directory)
91
+ paths.each { |path| FileUtils.cp(path, File.join(gem_directory, File.basename(path))) }
92
+ repository
93
+ end
94
+ private_class_method :stage_repository
95
+
96
+ def self.install_local(repository, destination)
97
+ target = target_for(Gem::Platform.local)
98
+ source = Gem::Source.new("file://#{File.realpath(repository)}")
99
+ install_resolved(source, repository, destination, target)
100
+ rescue Gem::Exception, IOError, SystemCallError => e
101
+ raise VerificationError, "local gem resolution/install failed: #{e.message}"
102
+ end
103
+ private_class_method :install_local
104
+
105
+ def self.install_resolved(source, repository, destination, target)
106
+ request = resolve_full_set(source)
107
+ raise VerificationError, "local full-set index did not resolve svgpng 0.1.0" unless request
108
+
109
+ verify_selected_target!(request.spec.platform, target)
110
+ installed = install_selected_package(repository, request.spec, destination, target)
111
+ { name: installed.name, version: installed.version.to_s, platform: installed.platform.to_s }.freeze
112
+ end
113
+ private_class_method :install_resolved
114
+
115
+ def self.install_selected_package(repository, resolver_spec, destination, target)
116
+ package = package_path(repository, resolver_spec.spec)
117
+ installed = install_package(package, destination)
118
+ verify_installed_target!(installed.platform, target)
119
+ installed
120
+ end
121
+ private_class_method :install_selected_package
122
+
123
+ def self.verify_selected_target!(platform, target)
124
+ return if target.nil? && platform == Gem::Platform::RUBY
125
+ return if target && platform.to_s == target
126
+
127
+ expected = target || "Ruby shell for unsupported host"
128
+ raise VerificationError, "RubyGems selected #{platform}; expected #{expected}"
129
+ end
130
+ private_class_method :verify_selected_target!
131
+
132
+ def self.verify_installed_target!(platform, target)
133
+ return if target.nil? && platform == Gem::Platform::RUBY
134
+ return if target && platform.to_s == target
135
+
136
+ expected = target || "Ruby shell for unsupported host"
137
+ raise VerificationError, "installed platform #{platform} does not match expected #{expected}"
138
+ end
139
+ private_class_method :verify_installed_target!
140
+
141
+ def self.target_for(platform)
142
+ exact = ReleaseSet::TARGETS.find do |candidate|
143
+ next false if candidate == "ruby"
144
+
145
+ declared = Gem::Platform.new(candidate)
146
+ declared.cpu == platform.cpu && declared.os == platform.os && declared.version == platform.version
147
+ end
148
+ exact || Native.send(:compatible_target, platform)
149
+ end
150
+ private_class_method :target_for
151
+
152
+ def self.resolve_full_set(source)
153
+ activations = Gem::Resolver.new([Gem::Dependency.new("svgpng", "= 0.1.0")],
154
+ Gem::Resolver::ComposedSet.new(Gem::Resolver::IndexSet.new(source),
155
+ InstalledDependencies.new)).resolve
156
+ activations.find { |activation| activation.spec.name == "svgpng" }
157
+ end
158
+ private_class_method :resolve_full_set
159
+
160
+ def self.package_path(repository, spec)
161
+ package = File.join(repository, "gems", "#{spec.full_name}.gem")
162
+ raise VerificationError, "resolved package is missing from verified local set" unless File.file?(package)
163
+
164
+ package
165
+ end
166
+ private_class_method :package_path
167
+
168
+ def self.install_package(package, destination)
169
+ Gem::Installer.at(package, install_dir: destination, wrappers: false, document: []).install
170
+ end
171
+ private_class_method :install_package
172
+ # rubocop:enable Metrics/ModuleLength
173
+ end
174
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Svgpng
4
+ # Runs before any XML parser. This is not the native URI/CSS policy.
5
+ module RawInput
6
+ def self.validate!(bytes)
7
+ # Class-side checking and a plain snapshot prevent caller overrides/mutation.
8
+ # rubocop:disable-next Style/CaseEquality
9
+ raise InputError, "SVG input must be a String" unless String === bytes
10
+
11
+ bytes = String.new(bytes)
12
+ raise LimitError, "SVG exceeds #{Limits::SVG_BYTES} bytes" if bytes.bytesize > Limits::SVG_BYTES
13
+
14
+ text = bytes.dup.force_encoding(Encoding::UTF_8)
15
+ raise InputError, "SVG must contain UTF-8 XML bytes" unless text.valid_encoding? && !text.include?("\0")
16
+
17
+ # Scan byte offsets so repeated delimiters after multibyte text stay linear.
18
+ scan_declarations!(text.force_encoding(Encoding::BINARY))
19
+ text.force_encoding(Encoding::UTF_8).freeze
20
+ end
21
+
22
+ def self.scan_declarations!(text)
23
+ cursor = 0
24
+ while (opening = text.index("<", cursor))
25
+ cursor = token_end(text, opening)
26
+ end
27
+ end
28
+
29
+ def self.token_end(text, opening)
30
+ return closing(text, "-->", opening + 4) if text[opening, 4] == "<!--"
31
+ return closing(text, "]]>", opening + 9) if text[opening, 9] == "<![CDATA["
32
+ return closing(text, "?>", opening + 2) if text[opening, 2] == "<?"
33
+ if text[opening, 10].match?(/\A<!DOCTYPE(?:\s|\[|>)/)
34
+ raise ForbiddenReferenceError.new(
35
+ "XML DOCTYPE declarations are forbidden",
36
+ reference_class: "doctype", location: "document@doctype"
37
+ )
38
+ end
39
+
40
+ opening + 1
41
+ end
42
+
43
+ def self.closing(text, delimiter, start)
44
+ index = text.index(delimiter, start)
45
+ raise InputError, "Unterminated XML comment, CDATA or instruction" unless index
46
+
47
+ index + delimiter.length
48
+ end
49
+ private_class_method :scan_declarations!, :token_end, :closing
50
+ end
51
+ end
@@ -0,0 +1,273 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "rubygems/package"
5
+ require "tmpdir"
6
+ require "yaml"
7
+
8
+ module Svgpng
9
+ # Validates the staged local 0.1.0 package set without mutating it.
10
+ module ReleaseSet
11
+ VERSION = "0.1.0"
12
+ TARGETS = %w[
13
+ arm64-darwin x86_64-darwin x86_64-linux aarch64-linux
14
+ x86_64-linux-musl aarch64-linux-musl x64-mingw-ucrt ruby
15
+ ].freeze
16
+ TARGET_PATTERN = TARGETS.reject { |target| target == "ruby" }.join("|").freeze
17
+ GEM_NAME = /\Asvgpng-0\.1\.0(?:-(?:#{TARGET_PATTERN}))?\.gem\z/
18
+ SHA256 = /\A[a-f0-9]{64}\z/
19
+ MANIFEST_KEYS = %w[schema_version version artifacts].freeze
20
+ ARTIFACT_KEYS = %w[target path sha256].freeze
21
+ CHUNK_SIZE = 1024 * 1024
22
+
23
+ class VerificationError < StandardError; end
24
+
25
+ def self.verify!(directory:, manifest_path:)
26
+ raise VerificationError, "release directory must not be a symlink" if File.symlink?(directory)
27
+
28
+ release_dir = File.expand_path(directory)
29
+ raise VerificationError, "release directory does not exist: #{release_dir}" unless File.directory?(release_dir)
30
+
31
+ artifacts = Manifest.new(manifest_path).artifacts
32
+ Directory.new(release_dir).verify!(artifacts)
33
+ end
34
+
35
+ # Reads the versioned manifest and validates its schema and artifact names.
36
+ class Manifest
37
+ attr_reader :artifacts
38
+
39
+ def initialize(path)
40
+ @path = File.expand_path(path)
41
+ @artifacts = read_artifacts
42
+ end
43
+
44
+ private
45
+
46
+ def read_artifacts
47
+ raise VerificationError, "release manifest is missing: #{@path}" unless File.file?(@path)
48
+ raise VerificationError, "release manifest must not be a symlink: #{@path}" if File.symlink?(@path)
49
+
50
+ data = load_yaml
51
+ validate_header(data)
52
+ build_artifacts(manifest_entries(data))
53
+ end
54
+
55
+ def manifest_entries(data)
56
+ entries = data.fetch("artifacts")
57
+ raise VerificationError, "manifest artifacts must be an array" unless entries.is_a?(Array)
58
+
59
+ entries.map { |entry| validate_entry(entry) }
60
+ end
61
+
62
+ def build_artifacts(parsed)
63
+ validate_targets(parsed.map(&:first))
64
+ paths = parsed.map { |row| row[1] }
65
+ raise VerificationError, "manifest artifact paths must be unique" unless paths.uniq.length == paths.length
66
+
67
+ parsed.to_h { |target, path, digest| [path, [target, digest]] }
68
+ end
69
+
70
+ def load_yaml
71
+ YAML.safe_load_file(@path, permitted_classes: [], permitted_symbols: [], aliases: false)
72
+ rescue Psych::Exception => e
73
+ raise VerificationError, "invalid YAML manifest #{@path}: #{e.message}"
74
+ end
75
+
76
+ def validate_header(data)
77
+ unless data.is_a?(Hash) && data.keys.sort == MANIFEST_KEYS.sort
78
+ raise VerificationError, "manifest must contain exactly: #{MANIFEST_KEYS.join(", ")}"
79
+ end
80
+ raise VerificationError, "manifest schema_version must be 1" unless data["schema_version"] == 1
81
+ raise VerificationError, "manifest version must be #{VERSION}" unless data["version"] == VERSION
82
+ end
83
+
84
+ def validate_targets(targets)
85
+ return if targets.sort == TARGETS.sort
86
+
87
+ raise VerificationError, "manifest must list exactly eight targets (#{target_problems(targets)})"
88
+ end
89
+
90
+ def target_problems(targets)
91
+ missing = TARGETS - targets
92
+ unexpected = targets - TARGETS
93
+ duplicates = targets.tally.select { |_target, count| count > 1 }.keys
94
+ issues = [target_issue("missing", missing), target_issue("unexpected", unexpected),
95
+ target_issue("duplicate", duplicates)]
96
+ issues
97
+ .compact
98
+ .join("; ")
99
+ end
100
+
101
+ def target_issue(kind, values)
102
+ "#{kind} targets: #{values.join(", ")}" unless values.empty?
103
+ end
104
+
105
+ def validate_entry(entry)
106
+ unless entry.is_a?(Hash) && entry.keys.sort == ARTIFACT_KEYS.sort
107
+ raise VerificationError, "each artifact must contain exactly: #{ARTIFACT_KEYS.join(", ")}"
108
+ end
109
+
110
+ target = entry["target"]
111
+ path = entry["path"]
112
+ digest = entry["sha256"]
113
+ validate_path(target, path)
114
+ validate_digest(path, digest)
115
+ [target, path, digest]
116
+ end
117
+
118
+ def validate_digest(path, digest)
119
+ return if digest.is_a?(String) && SHA256.match?(digest)
120
+
121
+ raise VerificationError, "#{path}: sha256 must be 64 lowercase hexadecimal characters"
122
+ end
123
+
124
+ def validate_path(target, path)
125
+ raise VerificationError, "artifact target must be a string" unless target.is_a?(String)
126
+
127
+ validate_basename(path)
128
+ raise VerificationError, "invalid release gem filename: #{path}" unless GEM_NAME.match?(path)
129
+
130
+ expected = target == "ruby" ? "svgpng-#{VERSION}.gem" : "svgpng-#{VERSION}-#{target}.gem"
131
+ raise VerificationError, "#{target}: expected filename #{expected}" unless path == expected
132
+ end
133
+
134
+ def validate_basename(path)
135
+ return if path.is_a?(String) && File.basename(path) == path && path != "." && path != ".."
136
+
137
+ raise VerificationError, "artifact path must be a simple basename"
138
+ end
139
+ end
140
+
141
+ # Compares the manifest against regular files in the local candidate dir.
142
+ class Directory
143
+ def initialize(path)
144
+ @path = path
145
+ end
146
+
147
+ def verify!(artifacts)
148
+ paths = actual_paths
149
+ expected = artifacts.keys.sort
150
+ validate_set(paths, expected)
151
+ expected.each { |name| verify_artifact(name, artifacts.fetch(name)) }
152
+ expected.map { |name| File.join(@path, name) }.freeze
153
+ end
154
+
155
+ private
156
+
157
+ def actual_paths
158
+ Dir.children(@path).select { |name| name.end_with?(".gem") }.sort
159
+ end
160
+
161
+ def validate_set(actual, expected)
162
+ missing = expected - actual
163
+ extra = actual - expected
164
+ raise VerificationError, "release set mismatch; missing: #{missing.join(", ")}" unless missing.empty?
165
+ raise VerificationError, "release set mismatch; unexpected: #{extra.join(", ")}" unless extra.empty?
166
+ end
167
+
168
+ def verify_artifact(name, artifact)
169
+ target, expected_digest = artifact
170
+ path = File.join(@path, name)
171
+ raise VerificationError, "release artifact must not be a symlink: #{path}" if File.symlink?(path)
172
+ raise VerificationError, "release artifact is not a regular file: #{path}" unless File.file?(path)
173
+
174
+ actual_digest = digest(path)
175
+ unless actual_digest == expected_digest
176
+ raise VerificationError, "SHA-256 mismatch for #{path}: expected #{expected_digest}, got #{actual_digest}"
177
+ end
178
+
179
+ Package.new(path, target).verify!
180
+ end
181
+
182
+ def digest(path)
183
+ result = Digest::SHA256.new
184
+ File.open(path, "rb") do |file|
185
+ while (chunk = file.read(CHUNK_SIZE))
186
+ result.update(chunk)
187
+ end
188
+ end
189
+ result.hexdigest
190
+ end
191
+ end
192
+
193
+ # Inspects gem metadata and unpacked paths without installing the package.
194
+ class Package
195
+ def initialize(path, target)
196
+ @path = path
197
+ @target = target
198
+ end
199
+
200
+ def verify!
201
+ package = Gem::Package.new(@path)
202
+ validate_gemspec(package.spec)
203
+ contents = package.contents
204
+ validate_contents(contents)
205
+ verify_payload(package)
206
+ rescue Gem::Package::Error, Gem::Security::Exception, Zlib::Error => e
207
+ raise VerificationError, "invalid gem package #{@path}: #{e.message}"
208
+ end
209
+
210
+ private
211
+
212
+ def validate_gemspec(spec)
213
+ platform = @target == "ruby" ? "ruby" : @target
214
+ unless spec.name == "svgpng" && spec.version.to_s == VERSION && spec.platform.to_s == platform
215
+ raise VerificationError, "#{@path}: gem metadata must be svgpng #{VERSION} for #{platform}"
216
+ end
217
+ return if spec.extensions.empty? && spec.executables.empty?
218
+
219
+ raise VerificationError, "#{@path}: gem must not declare extensions or executables"
220
+ end
221
+
222
+ def validate_contents(contents)
223
+ contents.each { |entry| validate_entry(entry) }
224
+ libraries = contents.select { |entry| shared_library?(entry) }
225
+ validate_shared_libraries(libraries)
226
+ end
227
+
228
+ def validate_entry(entry)
229
+ if entry.start_with?("/") || entry.split("/").include?("..") || entry.include?(92.chr)
230
+ raise VerificationError, "#{@path}: unsafe package path #{entry.inspect}"
231
+ end
232
+ return unless entry.start_with?("bin/") || entry.match?(/\.(?:sh|bat|ps1)\z/i)
233
+
234
+ raise VerificationError, "#{@path}: packaged scripts are not allowed (#{entry})"
235
+ end
236
+
237
+ def validate_shared_libraries(libraries)
238
+ if @target == "ruby" && !libraries.empty?
239
+ raise VerificationError, "#{@path}: ruby shell gem must not contain shared libraries"
240
+ end
241
+ return if @target == "ruby" || libraries == [shared_library_path]
242
+
243
+ raise VerificationError,
244
+ "#{@path}: expected exactly #{shared_library_path}; found shared libraries: #{libraries.join(", ")}"
245
+ end
246
+
247
+ def verify_payload(package)
248
+ return if @target == "ruby"
249
+
250
+ Dir.mktmpdir("svgpng-gem-inspect-") do |destination|
251
+ package.extract_files(destination)
252
+ extracted = File.join(destination, shared_library_path)
253
+ unless File.file?(extracted) && !File.symlink?(extracted)
254
+ raise VerificationError, "#{@path}: expected packaged shared library #{shared_library_path}"
255
+ end
256
+ end
257
+ end
258
+
259
+ def shared_library?(entry)
260
+ entry.match?(/\.(?:so|dylib|dll)\z/)
261
+ end
262
+
263
+ def shared_library_path
264
+ extension = case @target
265
+ when /darwin/ then "dylib"
266
+ when /mingw/ then "dll"
267
+ else "so"
268
+ end
269
+ "lib/svgpng/native/#{@target}/svgpng.#{extension}"
270
+ end
271
+ end
272
+ end
273
+ end
@@ -0,0 +1,183 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Svgpng
4
+ # Resolves parser-provided root attributes into a bounded raster canvas.
5
+ # Keep viewport resolution, output fitting, and their shared limits together.
6
+ class RenderProfile # rubocop:disable Metrics/ClassLength
7
+ MAX_DIMENSION = 32_768
8
+ MAX_PIXELS = 64_000_000
9
+ MAX_STRIDE = MAX_DIMENSION * 4
10
+ MAX_BUFFER_BYTES = 256 * 1024 * 1024
11
+ MAX_ROOT_NUMBER_BYTES = 1024
12
+ MAX_ROOT_EXPONENT = 4096
13
+ KEYS = %i[width height scale background].freeze
14
+ DECIMAL = /\A[+-]?(?:\d+(?:\.\d*)?|\.\d+)\z/
15
+ SVG_NUMBER = /[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?/
16
+ LENGTH = /\A(#{SVG_NUMBER})(px|in|cm|mm|q|pt|pc|%)?\z/
17
+ UNITS = { "px" => 1, "in" => 96, "cm" => Rational(4800, 127),
18
+ "mm" => Rational(480, 127), "q" => Rational(120, 127),
19
+ "pt" => Rational(4, 3), "pc" => 16 }.freeze
20
+
21
+ Layout = Struct.new(:width, :height, :intrinsic_width, :intrinsic_height,
22
+ :scale, :translate_x, :translate_y, :background,
23
+ keyword_init: true) do
24
+ def transform
25
+ [scale, 0, 0, scale, translate_x, translate_y].freeze
26
+ end
27
+
28
+ def stride
29
+ width * 4
30
+ end
31
+
32
+ def buffer_bytes
33
+ stride * height
34
+ end
35
+ end
36
+
37
+ # Validation must precede capturing the immutable profile values.
38
+ def initialize(profile = {}) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
39
+ unless profile.is_a?(Hash) && (profile.keys - KEYS).empty?
40
+ raise ProfileError, "profile must be a hash with symbol width, height, scale, background keys"
41
+ end
42
+ if profile.key?(:scale) && (profile.key?(:width) || profile.key?(:height))
43
+ raise ProfileError, "scale cannot be combined with width or height"
44
+ end
45
+
46
+ @width = pixel_dimension(profile, :width)
47
+ @height = pixel_dimension(profile, :height)
48
+ @scale = decimal_scale(profile[:scale]) if profile.key?(:scale)
49
+ @background = parse_background(profile.fetch(:background, "transparent"))
50
+ freeze
51
+ end
52
+
53
+ # Build one immutable layout only after all allocation bounds pass.
54
+ def resolve(root_attributes = {}) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
55
+ unless root_attributes.is_a?(Hash) && root_attributes.keys.all?(String)
56
+ raise ProfileError, "root attributes must be a hash with string keys"
57
+ end
58
+
59
+ source_width, source_height = intrinsic_size(root_attributes)
60
+ width, height = output_size(source_width, source_height)
61
+ check_bounds(width, height)
62
+ scale = [Rational(width, 1) / source_width, Rational(height, 1) / source_height].min
63
+ Layout.new(width: width, height: height, intrinsic_width: source_width,
64
+ intrinsic_height: source_height, scale: scale,
65
+ translate_x: (width - (source_width * scale)) / 2,
66
+ translate_y: (height - (source_height * scale)) / 2,
67
+ background: @background).freeze
68
+ end
69
+
70
+ private
71
+
72
+ def pixel_dimension(profile, key)
73
+ return unless profile.key?(key)
74
+
75
+ value = profile[key]
76
+ raise ProfileError, "#{key} must be a positive integer" unless value.is_a?(Integer) && value.positive?
77
+ raise LimitError, "#{key} exceeds #{MAX_DIMENSION}" if value > MAX_DIMENSION
78
+
79
+ value
80
+ end
81
+
82
+ def decimal_scale(value)
83
+ unless value.is_a?(String) && DECIMAL.match?(value)
84
+ raise ProfileError, "scale must be a positive finite base-10 decimal string"
85
+ end
86
+
87
+ number = Rational(value)
88
+ raise ProfileError, "scale must be positive" unless number.positive?
89
+
90
+ number
91
+ end
92
+
93
+ def parse_background(value)
94
+ return [0, 0, 0, 0].freeze if value == "transparent"
95
+ unless value.is_a?(String) && /\A#[0-9a-fA-F]{6}(?:[0-9a-fA-F]{2})?\z/.match?(value)
96
+ raise ProfileError, "background must be transparent, #RRGGBB, or #RRGGBBAA"
97
+ end
98
+
99
+ channels = value.delete_prefix("#").scan(/../).map { |channel| channel.to_i(16) }
100
+ alpha = channels.fetch(3, 255)
101
+ [*channels.first(3).map { |channel| ((channel * alpha) + 127) / 255 }, alpha].freeze
102
+ end
103
+
104
+ # These branches mirror the public root-dimension precedence contract.
105
+ def intrinsic_size(attributes) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
106
+ width = root_length(attributes["width"], 300)
107
+ height = root_length(attributes["height"], 150)
108
+ view_box = parse_view_box(attributes["viewBox"])
109
+ if width && height
110
+ [width, height]
111
+ elsif width
112
+ [width, view_box ? width * view_box[3] / view_box[2] : Rational(150)]
113
+ elsif height
114
+ [view_box ? height * view_box[2] / view_box[3] : Rational(300), height]
115
+ elsif view_box
116
+ view_box.last(2)
117
+ else
118
+ [Rational(300), Rational(150)]
119
+ end
120
+ end
121
+
122
+ def root_length(value, percentage_base)
123
+ return if value.nil?
124
+
125
+ match = LENGTH.match(value.to_s.strip)
126
+ raise ProfileError, "unsupported root length: #{value.inspect}" unless match
127
+
128
+ number = root_number(match[1])
129
+ raise ProfileError, "root dimensions must be positive" unless number.positive?
130
+
131
+ unit = match[2]
132
+ number * (unit == "%" ? Rational(percentage_base, 100) : UNITS.fetch(unit, 1))
133
+ end
134
+
135
+ def parse_view_box(value)
136
+ return if value.nil?
137
+
138
+ text = value.to_s.strip
139
+ # Enforce separators so malformed tokens cannot become a different viewport.
140
+ unless /\A#{SVG_NUMBER}(?:(?:\s*,\s*|\s+)#{SVG_NUMBER}){3}\z/.match?(text)
141
+ raise ProfileError, "viewBox must contain four finite numbers"
142
+ end
143
+
144
+ values = text.split(/[\s,]+/).map { |number| root_number(number) }
145
+ values if values[2].positive? && values[3].positive?
146
+ end
147
+
148
+ def root_number(value)
149
+ if value.bytesize > MAX_ROOT_NUMBER_BYTES || value.split(/[eE]/).fetch(1, "0").to_i.abs > MAX_ROOT_EXPONENT
150
+ raise LimitError, "root numeric representation exceeds limits"
151
+ end
152
+ raise ProfileError, "root numbers must be finite" unless Float(value).finite?
153
+
154
+ Rational(value)
155
+ end
156
+
157
+ def output_size(width, height)
158
+ if @width && @height
159
+ [@width, @height]
160
+ elsif @width
161
+ [@width, round_dimension(@width * height / width)]
162
+ elsif @height
163
+ [round_dimension(@height * width / height), @height]
164
+ else
165
+ factor = @scale || 1
166
+ [round_dimension(width * factor), round_dimension(height * factor)]
167
+ end
168
+ end
169
+
170
+ def round_dimension(value)
171
+ [(value + Rational(1, 2)).floor, 1].max
172
+ end
173
+
174
+ def check_bounds(width, height)
175
+ raise LimitError, "output dimension exceeds #{MAX_DIMENSION}" if [width, height].max > MAX_DIMENSION
176
+ raise LimitError, "output pixel count exceeds #{MAX_PIXELS}" if width * height > MAX_PIXELS
177
+ raise LimitError, "output row stride exceeds #{MAX_STRIDE}" if width * 4 > MAX_STRIDE
178
+ return if width * height * 4 <= MAX_BUFFER_BYTES
179
+
180
+ raise LimitError, "output buffer exceeds #{MAX_BUFFER_BYTES} bytes"
181
+ end
182
+ end
183
+ end