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,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Svgpng
4
+ # Shape/size checks only: native metadata validation follows before rendering.
5
+ class FontInputs
6
+ attr_reader :fonts, :fallback_fonts
7
+
8
+ def initialize(fonts:, fallback_fonts:)
9
+ raise InputError, "fonts must be a Hash" unless fonts.is_a?(Hash)
10
+ raise InputError, "fallback_fonts must be an Array" unless fallback_fonts.is_a?(Array)
11
+
12
+ check_count!(fonts, fallback_fonts)
13
+ @fonts = fonts.to_h { |family, faces| normalize_family(family, faces) }.freeze
14
+ @fallback_fonts = validate_faces(fallback_fonts)
15
+ check_total_bytes!
16
+
17
+ freeze
18
+ end
19
+
20
+ private
21
+
22
+ def check_count!(fonts, fallback_fonts)
23
+ count = fallback_fonts.length
24
+ fonts.each_value { |faces| count += faces.is_a?(Array) ? faces.length : 1 }
25
+ raise LimitError, "Too many font faces (maximum #{Limits::FONT_COUNT})" if count > Limits::FONT_COUNT
26
+ end
27
+
28
+ def normalize_family(family, faces)
29
+ raise InputError, "Font family keys must be nonempty Strings" unless family.is_a?(String) && !family.strip.empty?
30
+
31
+ entries = faces.is_a?(Array) ? faces : [faces]
32
+ raise InputError, "Font family must have at least one face" if entries.empty?
33
+
34
+ [family.dup.freeze, validate_faces(entries)]
35
+ end
36
+
37
+ def check_total_bytes!
38
+ total = @fonts.values.flatten.sum { |face| face.bytes.bytesize } +
39
+ @fallback_fonts.sum { |face| face.bytes.bytesize }
40
+ raise LimitError, "Total font bytes exceed #{Limits::TOTAL_FONT_BYTES}" if total > Limits::TOTAL_FONT_BYTES
41
+ end
42
+
43
+ def validate_faces(faces)
44
+ raise InputError, "Every font must be a Svgpng::FontFace" unless faces.all?(FontFace)
45
+
46
+ faces.dup.freeze
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "result"
4
+
5
+ module Svgpng
6
+ # Collects ordered substitution diagnostics emitted by a renderer.
7
+ class FontWarnings
8
+ def initialize
9
+ @warnings = []
10
+ @keys = {}
11
+ end
12
+
13
+ # rubocop:disable-next Metrics/ParameterLists -- Mirrors the native event fields.
14
+ def add(requested_family:, selected_family:, style:, weight:, stretch:, scalars:)
15
+ normalized_scalars = normalize_scalars(scalars)
16
+ warning = FontSubstitutionWarning.new(requested_family: requested_family,
17
+ selected_family: selected_family,
18
+ style: style, weight: weight,
19
+ stretch: stretch, scalars: normalized_scalars)
20
+ key = [warning.requested_family, warning.selected_family, warning.style,
21
+ warning.weight, warning.stretch, warning.scalars]
22
+ @warnings << warning unless @keys.key?(key)
23
+ @keys[key] = true
24
+ warning
25
+ end
26
+
27
+ def warnings
28
+ @warnings.dup.freeze
29
+ end
30
+
31
+ private
32
+
33
+ def normalize_scalars(scalars)
34
+ unless scalars.is_a?(Array) && scalars.all? { |scalar| valid_scalar?(scalar) }
35
+ raise ArgumentError, "scalars must be Unicode scalar values (0..0x10FFFF, excluding surrogates)"
36
+ end
37
+
38
+ scalars.uniq.sort.freeze
39
+ end
40
+
41
+ def valid_scalar?(scalar)
42
+ scalar.is_a?(Integer) && scalar.between?(0, 0x10FFFF) && !scalar.between?(0xD800, 0xDFFF)
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Svgpng
4
+ module Limits
5
+ SVG_BYTES = 16 * 1024 * 1024
6
+ FONT_COUNT = 64
7
+ FONT_BYTES = 32 * 1024 * 1024
8
+ TOTAL_FONT_BYTES = 128 * 1024 * 1024
9
+ end
10
+ end
@@ -0,0 +1,213 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "errors"
4
+ require_relative "render_profile"
5
+ require "fiddle"
6
+ require "fiddle/struct"
7
+ require_relative "native_render"
8
+
9
+ module Svgpng
10
+ # Opens only the artifact belonging to this installed gem, on explicit request.
11
+ module Native
12
+ SUPPORTED_TARGETS = %w[arm64-darwin x86_64-darwin x86_64-linux aarch64-linux
13
+ x86_64-linux-musl aarch64-linux-musl x64-mingw-ucrt].map(&:freeze).freeze
14
+ SUFFIXES = { "darwin" => "dylib", "linux" => "so", "mingw" => "dll" }.freeze
15
+
16
+ def self.load
17
+ Library.new
18
+ end
19
+
20
+ def self.packaged_library_path
21
+ platform = Gem::Platform.local
22
+ target = SUPPORTED_TARGETS.find { |candidate| candidate == platform.to_s }
23
+ target ||= compatible_target(platform)
24
+ raise UnsupportedPlatformError.new(platform: platform.to_s, supported_targets: SUPPORTED_TARGETS) unless target
25
+
26
+ suffix = SUFFIXES.fetch(platform.os)
27
+ File.join(File.realpath(__dir__), "native", target, "svgpng.#{suffix}")
28
+ end
29
+ private_class_method :packaged_library_path
30
+
31
+ def self.compatible_target(platform)
32
+ SUPPORTED_TARGETS.find do |name|
33
+ candidate = Gem::Platform.new(name)
34
+ next false unless candidate.cpu == platform.cpu && candidate.os == platform.os
35
+
36
+ case platform.os
37
+ when "darwin" then true
38
+ when "linux" then candidate.version == (platform.version == "gnu" ? nil : platform.version)
39
+ else false
40
+ end
41
+ end
42
+ end
43
+ private_class_method :compatible_target
44
+
45
+ # This binding is a pixel conversion primitive, not a rendering backend.
46
+ # Keep the two ownership-related ABI primitives and their guards in one binding.
47
+ # rubocop:disable-next Metrics/ClassLength
48
+ class Library
49
+ include RenderBinding
50
+
51
+ SIZE_MAX = (1 << (Fiddle::SIZEOF_SIZE_T * 8)) - 1
52
+ POINTER_MAX = (1 << (Fiddle::SIZEOF_VOIDP * 8)) - 1
53
+ STATUS_ERRORS = { 1 => InputError, 2 => LimitError, 3 => LimitError,
54
+ 4 => InputError, 5 => InputError }.freeze
55
+ STATUS_NAMES = { 1 => "invalid argument", 2 => "arithmetic overflow", 3 => "limit exceeded",
56
+ 4 => "buffer too small", 5 => "overlapping buffers" }.freeze
57
+ PNG_OUTPUT = Fiddle::CStructBuilder.create(Fiddle::CStruct, [Fiddle::TYPE_VOIDP, Fiddle::TYPE_SIZE_T],
58
+ %w[bytes length])
59
+ PNG_STATUS_ERRORS = { 1 => InputError, 2 => LimitError, 3 => LimitError, 4 => InputError,
60
+ 5 => EncodingError, 6 => EncodingError, 7 => NativeError }.freeze
61
+ PNG_STATUS_NAMES = { 1 => "invalid argument", 2 => "arithmetic overflow", 3 => "limit exceeded",
62
+ 4 => "buffer too small", 5 => "allocation failed", 6 => "encoding failed",
63
+ 7 => "dependency version mismatch" }.freeze
64
+
65
+ def initialize
66
+ path = Native.send(:packaged_library_path)
67
+ check_packaged_file(path)
68
+ @handle = Fiddle::Handle.new(path, Fiddle::RTLD_NOW)
69
+ arguments = [Fiddle::TYPE_VOIDP, Fiddle::TYPE_SIZE_T, Fiddle::TYPE_SIZE_T,
70
+ Fiddle::TYPE_VOIDP, Fiddle::TYPE_SIZE_T, Fiddle::TYPE_SIZE_T,
71
+ Fiddle::TYPE_SIZE_T, Fiddle::TYPE_SIZE_T]
72
+ @convert = Fiddle::Function.new(@handle["svgpng_v1_rgba_to_straight"], arguments, Fiddle::TYPE_INT)
73
+ rescue Fiddle::DLError => e
74
+ raise NativeError, "Cannot load packaged SVGPNG RGBA ABI: #{e.message}"
75
+ end
76
+
77
+ def rgba_to_straight(source, width:, height:, source_stride: nil, destination_stride: nil)
78
+ source = snapshot_source(source)
79
+ source_stride = width.is_a?(Integer) ? width * 4 : nil if source_stride.nil?
80
+ destination_stride = width.is_a?(Integer) ? width * 4 : nil if destination_stride.nil?
81
+ source_bytes, destination_bytes = validate_buffers(source, width, height, source_stride, destination_stride)
82
+ convert(source, width, height, source_stride, destination_stride, source_bytes, destination_bytes)
83
+ end
84
+
85
+ def encode_rgba(source, width:, height:, source_stride: nil)
86
+ source = snapshot_source(source)
87
+ source_stride = width.is_a?(Integer) ? width * 4 : nil if source_stride.nil?
88
+ source_bytes = validate_buffers(source, width, height, source_stride, source_stride).first
89
+ encode_png(source, source_bytes, source_stride, width, height)
90
+ end
91
+
92
+ private
93
+
94
+ def snapshot_source(source)
95
+ # Class-side checking cannot be overridden by the untrusted source object.
96
+ # rubocop:disable-next Style/CaseEquality
97
+ raise InputError, "RGBA source must be bytes" unless String === source
98
+
99
+ # Normalize subclass methods and isolate later caller mutation before checking capacity.
100
+ String.new(source).byteslice(0, RenderProfile::MAX_BUFFER_BYTES).freeze
101
+ end
102
+
103
+ def encoder_functions
104
+ @encoder_functions ||= begin
105
+ arguments = [Fiddle::TYPE_VOIDP, Fiddle::TYPE_SIZE_T, Fiddle::TYPE_SIZE_T,
106
+ Fiddle::TYPE_SIZE_T, Fiddle::TYPE_SIZE_T, Fiddle::TYPE_VOIDP]
107
+ encode = Fiddle::Function.new(@handle["svgpng_v1_png_encode"], arguments, Fiddle::TYPE_INT)
108
+ release = Fiddle::Function.new(@handle["svgpng_v1_png_free"], [Fiddle::TYPE_VOIDP], Fiddle::TYPE_VOID)
109
+ [encode, release].freeze
110
+ end
111
+ rescue Fiddle::DLError => e
112
+ raise NativeError, "Cannot load packaged SVGPNG encoder ABI: #{e.message}"
113
+ end
114
+
115
+ # Keep shim release ahead of both Ruby buffer releases on every exit path.
116
+ # rubocop:disable-next Metrics/MethodLength, Metrics/AbcSize
117
+ def encode_png(source, source_bytes, source_stride, width, height)
118
+ encode, release = encoder_functions
119
+ output = PNG_OUTPUT.malloc(Fiddle::RUBY_FREE)
120
+ output.bytes = 0
121
+ output.length = 0
122
+ input = Fiddle::Pointer.malloc(source_bytes, Fiddle::RUBY_FREE)
123
+ input[0, source_bytes] = source.byteslice(0, source_bytes)
124
+ status = encode.call(input, source_bytes, source_stride, width, height, output.to_ptr)
125
+ check_png_status(status)
126
+ copy_png(output)
127
+ ensure
128
+ begin
129
+ release.call(output.to_ptr) if output
130
+ ensure
131
+ output&.to_ptr&.call_free
132
+ input&.call_free
133
+ end
134
+ end
135
+
136
+ def check_png_status(status)
137
+ return if status.zero?
138
+
139
+ message = PNG_STATUS_NAMES.fetch(status, "unknown status #{status}")
140
+ raise PNG_STATUS_ERRORS.fetch(status, NativeError), "SVGPNG PNG encoding: #{message}"
141
+ end
142
+
143
+ def copy_png(output)
144
+ length = output.length
145
+ raise NativeError, "PNG encoder returned an empty output" unless length.positive?
146
+ raise LimitError, "PNG encoder output exceeds limits" if length > RenderProfile::MAX_BUFFER_BYTES
147
+
148
+ address = output.bytes.to_i
149
+ if !address.positive? || address > POINTER_MAX - length
150
+ raise NativeError, "PNG encoder returned an invalid output pointer"
151
+ end
152
+
153
+ Fiddle::Pointer.new(address)[0, length].b
154
+ end
155
+
156
+ def check_packaged_file(path)
157
+ unless File.file?(path) && !File.symlink?(path) && File.realpath(path) == path
158
+ raise UnsupportedPlatformError.new(platform: Gem::Platform.local.to_s, supported_targets: SUPPORTED_TARGETS)
159
+ end
160
+ rescue Errno::ENOENT, Errno::ENOTDIR, Errno::ELOOP
161
+ raise UnsupportedPlatformError.new(platform: Gem::Platform.local.to_s, supported_targets: SUPPORTED_TARGETS)
162
+ end
163
+
164
+ # Keep all pre-allocation ABI guards together for auditing against rgba.c.
165
+ # rubocop:disable-next Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
166
+ def validate_buffers(source, width, height, source_stride, destination_stride)
167
+ values = [width, height, source_stride, destination_stride]
168
+ unless source.is_a?(String) && values.all? { |value| value.is_a?(Integer) && value.positive? }
169
+ raise InputError, "RGBA source must be bytes with positive integer dimensions and strides"
170
+ end
171
+ raise LimitError, "RGBA integer exceeds size_t" if values.any? { |value| value > SIZE_MAX }
172
+ if width > RenderProfile::MAX_DIMENSION || height > RenderProfile::MAX_DIMENSION ||
173
+ width * height > RenderProfile::MAX_PIXELS
174
+ raise LimitError, "RGBA dimensions exceed limits"
175
+ end
176
+ if [source_stride, destination_stride].min < width * 4
177
+ raise InputError, "RGBA stride is smaller than a pixel row"
178
+ end
179
+
180
+ capacities = [source_stride * height, destination_stride * height]
181
+ if capacities.any? { |value| value > SIZE_MAX || value > RenderProfile::MAX_BUFFER_BYTES }
182
+ raise LimitError, "RGBA buffer exceeds limits"
183
+ end
184
+ raise InputError, "RGBA source buffer is too small" if source.bytesize < capacities.first
185
+
186
+ capacities
187
+ end
188
+
189
+ # Explicit arguments mirror the checked C ABI without a second mutable descriptor.
190
+ # rubocop:disable-next Metrics/ParameterLists, Metrics/MethodLength
191
+ def convert(source, width, height, source_stride, destination_stride, source_bytes, destination_bytes)
192
+ input = Fiddle::Pointer.malloc(source_bytes, Fiddle::RUBY_FREE)
193
+ output = Fiddle::Pointer.malloc(destination_bytes, Fiddle::RUBY_FREE)
194
+ input[0, source_bytes] = source.byteslice(0, source_bytes)
195
+ output[0, destination_bytes] = "\0".b * destination_bytes
196
+ status = @convert.call(input, source_bytes, source_stride, output, destination_bytes, destination_stride,
197
+ width, height)
198
+ check_status(status)
199
+ output[0, destination_bytes].b
200
+ ensure
201
+ output&.call_free
202
+ input&.call_free
203
+ end
204
+
205
+ def check_status(status)
206
+ return if status.zero?
207
+
208
+ message = STATUS_NAMES.fetch(status, "unknown status #{status}")
209
+ raise STATUS_ERRORS.fetch(status, NativeError), "SVGPNG RGBA conversion: #{message}"
210
+ end
211
+ end
212
+ end
213
+ end
@@ -0,0 +1,198 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Svgpng
6
+ module Native
7
+ # Owns all temporary arguments and copies renderer output before releasing it.
8
+ module RenderBinding # rubocop:disable Metrics/ModuleLength -- Keep ABI layout and ownership guards together.
9
+ SIZE = Fiddle::TYPE_SIZE_T
10
+ PTR = Fiddle::TYPE_VOIDP
11
+ RENDER_OUTPUT = Fiddle::CStructBuilder.create(Fiddle::CStruct, [PTR, SIZE, PTR, SIZE],
12
+ %w[pixels pixels_length diagnostics diagnostics_length])
13
+ FONT = Fiddle::CStructBuilder.create(Fiddle::CStruct, [PTR, SIZE, PTR, SIZE, PTR, SIZE, SIZE],
14
+ %w[bytes length requested_family family_length source_id source_id_length
15
+ fallback_order])
16
+ OPTIONS = Fiddle::CStructBuilder.create(Fiddle::CStruct,
17
+ ([Fiddle::TYPE_INT32_T] * 4) + ([Fiddle::TYPE_DOUBLE] * 5),
18
+ %w[abi_version width height background_rgba intrinsic_width
19
+ intrinsic_height fit translate_x translate_y])
20
+ ERRORS = { 1 => InputError, 2 => LimitError, 3 => FontMetadataError, 4 => DuplicateFontError,
21
+ 5 => MissingFamilyError, 6 => MissingGlyphError, 7 => ForbiddenReferenceError,
22
+ 8 => NativeError, 9 => NativeError }.freeze
23
+ DIAGNOSTIC_LIMIT = 4 * 1024 * 1024
24
+
25
+ def root_attributes(source)
26
+ renderer_call(source) do |input, output, _owned|
27
+ status = render_functions.fetch(:root).call(input, source.bytesize, output.to_ptr)
28
+ check_render_status(status, output)
29
+ attributes = copy_diagnostics(output)
30
+ unless attributes.is_a?(Hash) && attributes.keys.all?(String) && attributes.values.all?(String)
31
+ raise NativeError, "Renderer returned invalid root attributes"
32
+ end
33
+
34
+ attributes
35
+ end
36
+ end
37
+
38
+ # One call owns all buffers until both output copies complete.
39
+ # rubocop:disable-next Metrics/AbcSize, Metrics/MethodLength
40
+ def render_pixels(source, layout:, fonts:)
41
+ renderer_call(source) do |input, output, owned|
42
+ options = render_options(layout, owned)
43
+ entries, count = render_fonts(fonts, owned)
44
+ status = render_functions.fetch(:render).call(input, source.bytesize, entries, count,
45
+ options.to_ptr, output.to_ptr)
46
+ check_render_status(status, output)
47
+ unless output.pixels_length == layout.buffer_bytes
48
+ raise NativeError, "Renderer returned an invalid pixel buffer length"
49
+ end
50
+
51
+ pixels = copy_render_bytes(output.pixels, output.pixels_length, RenderProfile::MAX_BUFFER_BYTES)
52
+ diagnostics = copy_diagnostics(output)
53
+ raise NativeError, "Renderer returned invalid diagnostics" unless diagnostics.is_a?(Hash)
54
+
55
+ [pixels, diagnostics]
56
+ end
57
+ end
58
+
59
+ private
60
+
61
+ def render_functions
62
+ @render_functions ||= {
63
+ root: Fiddle::Function.new(@handle["svgpng_v1_root_attributes"], [PTR, SIZE, PTR], Fiddle::TYPE_INT),
64
+ render: Fiddle::Function.new(@handle["svgpng_v1_render"], [PTR, SIZE, PTR, SIZE, PTR, PTR], Fiddle::TYPE_INT),
65
+ free: Fiddle::Function.new(@handle["svgpng_v1_render_free"], [PTR], Fiddle::TYPE_VOID)
66
+ }.freeze
67
+ rescue Fiddle::DLError
68
+ raise NativeError, "Cannot load packaged SVGPNG renderer ABI"
69
+ end
70
+
71
+ # Release the shim descriptor before any caller-owned arguments.
72
+ # rubocop:disable-next Metrics/MethodLength
73
+ def renderer_call(source)
74
+ functions = render_functions
75
+ owned = []
76
+ input = own_bytes(source, owned)
77
+ output = RENDER_OUTPUT.new(own_buffer(RENDER_OUTPUT.size, owned))
78
+ output.to_ptr[0, RENDER_OUTPUT.size] = "\0" * RENDER_OUTPUT.size
79
+ yield input, output, owned
80
+ ensure
81
+ begin
82
+ functions.fetch(:free).call(output.to_ptr) if output
83
+ ensure
84
+ owned&.reverse_each(&:call_free)
85
+ end
86
+ end
87
+
88
+ def own_buffer(length, owned)
89
+ Fiddle::Pointer.malloc([length, 1].max, Fiddle::RUBY_FREE).tap { |pointer| owned << pointer }
90
+ end
91
+
92
+ def own_bytes(bytes, owned)
93
+ pointer = own_buffer(bytes.bytesize, owned)
94
+ pointer[0, bytes.bytesize] = bytes
95
+ pointer
96
+ end
97
+
98
+ # Mirror the C ABI fields directly for alignment auditing.
99
+ # rubocop:disable-next Metrics/AbcSize
100
+ def render_options(layout, owned)
101
+ options = OPTIONS.new(own_buffer(OPTIONS.size, owned))
102
+ options.abi_version = 1
103
+ options.width = layout.width
104
+ options.height = layout.height
105
+ options.background_rgba = layout.background.inject(0) { |value, channel| (value << 8) | channel }
106
+ %i[intrinsic_width intrinsic_height translate_x translate_y].each do |key|
107
+ options[key.to_s] = layout.public_send(key).to_f
108
+ end
109
+ options.fit = layout.scale.to_f
110
+ options
111
+ end
112
+
113
+ # Keep every borrowed font pointer beside its owned allocation.
114
+ # rubocop:disable-next Metrics/AbcSize, Metrics/MethodLength
115
+ def render_fonts(inputs, owned)
116
+ faces = inputs.fonts.flat_map { |family, entries| entries.map { |face| [face, family, Library::SIZE_MAX] } }
117
+ faces.concat(inputs.fallback_fonts.each_with_index.map { |face, order| [face, face.family || "", order] })
118
+ pointer = own_buffer(FONT.size * faces.length, owned)
119
+ faces.each_with_index do |(face, family, order), index|
120
+ entry = FONT.new(pointer + (index * FONT.size))
121
+ entry.bytes = own_bytes(face.bytes, owned)
122
+ entry.length = face.bytes.bytesize
123
+ entry.requested_family = own_bytes(family, owned)
124
+ entry.family_length = family.bytesize
125
+ entry.source_id = own_bytes(face.source_id, owned)
126
+ entry.source_id_length = face.source_id.bytesize
127
+ entry.fallback_order = order
128
+ end
129
+ [pointer, faces.length]
130
+ end
131
+
132
+ def check_render_status(status, output)
133
+ return if status.zero?
134
+
135
+ error = ERRORS.fetch(status, NativeError)
136
+ details = render_error_details(status, output)
137
+ raise error.new("SVGPNG rendering failed (status #{status})", **details)
138
+ end
139
+
140
+ # Native prose and unknown fields never cross the public error boundary.
141
+ def render_error_details(status, output)
142
+ return {} unless [5, 6, 7].include?(status)
143
+
144
+ diagnostics = copy_diagnostics(output)
145
+ detail = diagnostics["error"] if diagnostics.is_a?(Hash)
146
+ return {} unless detail.is_a?(Hash)
147
+ return reference_error_details(detail) if status == 7
148
+
149
+ fields = { requested_family: error_family(detail["requested_family"]) }
150
+ fields[:scalars] = error_scalars(detail["scalars"]) if status == 6
151
+ fields
152
+ rescue Error
153
+ {} # Optional diagnostics cannot replace the native status classification.
154
+ end
155
+
156
+ def error_family(value)
157
+ return unless value.is_a?(String) && value.bytesize.between?(1, 1024) && value.valid_encoding?
158
+ return if /[[:cntrl:]]/.match?(value)
159
+
160
+ value
161
+ end
162
+
163
+ def error_scalars(value)
164
+ return unless value.is_a?(Array) && value.length <= 256
165
+ return unless value.all? do |scalar|
166
+ scalar.is_a?(Integer) && scalar.between?(0, 0x10FFFF) && !scalar.between?(0xD800, 0xDFFF)
167
+ end
168
+
169
+ value.uniq.sort
170
+ end
171
+
172
+ def reference_error_details(detail)
173
+ reference_class = detail["reference_class"]
174
+ location = detail["location"]
175
+ classes = %w[relative absolute file network scheme css_import doctype]
176
+ locations = %w[image@href use@href feImage@href svg@href style@url style@import attribute@url document@doctype]
177
+ { reference_class: classes.include?(reference_class) ? reference_class : nil,
178
+ location: locations.include?(location) ? location : nil }
179
+ end
180
+
181
+ def copy_diagnostics(output)
182
+ bytes = copy_render_bytes(output.diagnostics, output.diagnostics_length, DIAGNOSTIC_LIMIT)
183
+ JSON.parse(bytes, max_nesting: 32)
184
+ rescue JSON::ParserError
185
+ raise NativeError, "Renderer returned malformed diagnostics"
186
+ end
187
+
188
+ def copy_render_bytes(pointer, length, limit)
189
+ raise LimitError, "Renderer output exceeds limits" if length > limit
190
+ if !length.positive? || !pointer.to_i.positive? || pointer.to_i > Library::POINTER_MAX - length
191
+ raise NativeError, "Renderer returned an invalid output pointer"
192
+ end
193
+
194
+ Fiddle::Pointer.new(pointer.to_i)[0, length].b
195
+ end
196
+ end
197
+ end
198
+ end