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.
- checksums.yaml +7 -0
- data/README.adoc +81 -0
- data/docs/decisions/svg-to-png.md +368 -0
- data/docs/goldens.md +74 -0
- data/lib/svgpng/errors.rb +57 -0
- data/lib/svgpng/font_face.rb +36 -0
- data/lib/svgpng/font_inputs.rb +49 -0
- data/lib/svgpng/font_warnings.rb +45 -0
- data/lib/svgpng/limits.rb +10 -0
- data/lib/svgpng/native.rb +213 -0
- data/lib/svgpng/native_render.rb +198 -0
- data/lib/svgpng/package_resolution.rb +174 -0
- data/lib/svgpng/raw_input.rb +51 -0
- data/lib/svgpng/release_set.rb +273 -0
- data/lib/svgpng/render_profile.rb +183 -0
- data/lib/svgpng/renderer.rb +27 -0
- data/lib/svgpng/result.rb +50 -0
- data/lib/svgpng/version.rb +5 -0
- data/lib/svgpng.rb +19 -0
- data/vendor/licenses/README.txt +23 -0
- data/vendor/licenses/fonts/COPYRIGHT.txt +13 -0
- data/vendor/licenses/fonts/NotoSans-Condensed-OFL-1.1.txt +94 -0
- data/vendor/licenses/fonts/OFL-1.1.txt +92 -0
- data/vendor/licenses/libpng/LICENSE +134 -0
- data/vendor/licenses/libpng/LICENSES/MIT-0.txt +16 -0
- data/vendor/licenses/libpng/LICENSES/MIT.txt +19 -0
- data/vendor/licenses/resvg/LICENSE-APACHE +176 -0
- data/vendor/licenses/resvg/LICENSE-MIT +25 -0
- data/vendor/licenses/rust/adler2-2.0.1/LICENSE-0BSD +12 -0
- data/vendor/licenses/rust/adler2-2.0.1/LICENSE-APACHE +201 -0
- data/vendor/licenses/rust/adler2-2.0.1/LICENSE-MIT +23 -0
- data/vendor/licenses/rust/arrayref-0.3.9/LICENSE +26 -0
- data/vendor/licenses/rust/arrayvec-0.7.8/LICENSE-APACHE +201 -0
- data/vendor/licenses/rust/arrayvec-0.7.8/LICENSE-MIT +25 -0
- data/vendor/licenses/rust/autocfg-1.5.1/LICENSE-APACHE +201 -0
- data/vendor/licenses/rust/autocfg-1.5.1/LICENSE-MIT +25 -0
- data/vendor/licenses/rust/base64-0.23.1/LICENSE-APACHE +201 -0
- data/vendor/licenses/rust/base64-0.23.1/LICENSE-MIT +21 -0
- data/vendor/licenses/rust/bitflags-2.13.2/LICENSE-APACHE +201 -0
- data/vendor/licenses/rust/bitflags-2.13.2/LICENSE-MIT +25 -0
- data/vendor/licenses/rust/block-buffer-0.10.4/LICENSE-APACHE +201 -0
- data/vendor/licenses/rust/block-buffer-0.10.4/LICENSE-MIT +25 -0
- data/vendor/licenses/rust/bytemuck-1.25.2/LICENSE-APACHE +61 -0
- data/vendor/licenses/rust/bytemuck-1.25.2/LICENSE-MIT +9 -0
- data/vendor/licenses/rust/bytemuck-1.25.2/LICENSE-ZLIB +11 -0
- data/vendor/licenses/rust/bytemuck_derive-1.12.1/LICENSE-APACHE +61 -0
- data/vendor/licenses/rust/bytemuck_derive-1.12.1/LICENSE-MIT +9 -0
- data/vendor/licenses/rust/bytemuck_derive-1.12.1/LICENSE-ZLIB +11 -0
- data/vendor/licenses/rust/byteorder-lite-0.1.0/LICENSE-MIT +21 -0
- data/vendor/licenses/rust/byteorder-lite-0.1.0/UNLICENSE +24 -0
- data/vendor/licenses/rust/cfg-if-1.0.5/LICENSE-APACHE +201 -0
- data/vendor/licenses/rust/cfg-if-1.0.5/LICENSE-MIT +25 -0
- data/vendor/licenses/rust/color_quant-1.1.0/LICENSE +21 -0
- data/vendor/licenses/rust/cpufeatures-0.2.17/LICENSE-APACHE +201 -0
- data/vendor/licenses/rust/cpufeatures-0.2.17/LICENSE-MIT +25 -0
- data/vendor/licenses/rust/crc32fast-1.5.3/LICENSE-APACHE +202 -0
- data/vendor/licenses/rust/crc32fast-1.5.3/LICENSE-MIT +21 -0
- data/vendor/licenses/rust/crypto-common-0.1.7/LICENSE-APACHE +201 -0
- data/vendor/licenses/rust/crypto-common-0.1.7/LICENSE-MIT +25 -0
- data/vendor/licenses/rust/cssparser-0.35.0/LICENSE +373 -0
- data/vendor/licenses/rust/cssparser-macros-0.6.1/LICENSE +373 -0
- data/vendor/licenses/rust/data-url-0.3.2/LICENSE-APACHE +201 -0
- data/vendor/licenses/rust/data-url-0.3.2/LICENSE-MIT +25 -0
- data/vendor/licenses/rust/digest-0.10.7/LICENSE-APACHE +201 -0
- data/vendor/licenses/rust/digest-0.10.7/LICENSE-MIT +25 -0
- data/vendor/licenses/rust/dtoa-1.0.11/LICENSE-APACHE +176 -0
- data/vendor/licenses/rust/dtoa-1.0.11/LICENSE-MIT +23 -0
- data/vendor/licenses/rust/dtoa-short-0.3.5/LICENSE +373 -0
- data/vendor/licenses/rust/euclid-0.22.14/COPYRIGHT +5 -0
- data/vendor/licenses/rust/euclid-0.22.14/LICENSE-APACHE +201 -0
- data/vendor/licenses/rust/euclid-0.22.14/LICENSE-MIT +25 -0
- data/vendor/licenses/rust/fdeflate-0.3.7/LICENSE-APACHE +177 -0
- data/vendor/licenses/rust/fdeflate-0.3.7/LICENSE-MIT +25 -0
- data/vendor/licenses/rust/flate2-1.1.10/LICENSE-APACHE +201 -0
- data/vendor/licenses/rust/flate2-1.1.10/LICENSE-MIT +25 -0
- data/vendor/licenses/rust/float-cmp-0.9.0/LICENSE +19 -0
- data/vendor/licenses/rust/font-types-0.12.7/LICENSE-APACHE +67 -0
- data/vendor/licenses/rust/font-types-0.12.7/LICENSE-MIT +25 -0
- data/vendor/licenses/rust/fontconfig-parser-0.5.8/LICENSE +21 -0
- data/vendor/licenses/rust/fontdb-0.24.0/LICENSE +21 -0
- data/vendor/licenses/rust/generic-array-0.14.7/LICENSE +21 -0
- data/vendor/licenses/rust/gif-0.14.2/LICENSE-APACHE +201 -0
- data/vendor/licenses/rust/gif-0.14.2/LICENSE-MIT +21 -0
- data/vendor/licenses/rust/harfrust-0.12.0/LICENSE +22 -0
- data/vendor/licenses/rust/image-webp-0.2.4/LICENSE-APACHE +177 -0
- data/vendor/licenses/rust/image-webp-0.2.4/LICENSE-MIT +25 -0
- data/vendor/licenses/rust/imagesize-0.15.0/LICENSE +21 -0
- data/vendor/licenses/rust/itoa-1.0.18/LICENSE-APACHE +176 -0
- data/vendor/licenses/rust/itoa-1.0.18/LICENSE-MIT +23 -0
- data/vendor/licenses/rust/kurbo-0.13.1/LICENSE-APACHE +202 -0
- data/vendor/licenses/rust/kurbo-0.13.1/LICENSE-MIT +25 -0
- data/vendor/licenses/rust/libc-0.2.191/LICENSE-APACHE +176 -0
- data/vendor/licenses/rust/libc-0.2.191/LICENSE-MIT +25 -0
- data/vendor/licenses/rust/log-0.4.34/LICENSE-APACHE +201 -0
- data/vendor/licenses/rust/log-0.4.34/LICENSE-MIT +25 -0
- data/vendor/licenses/rust/memchr-2.8.3/COPYING +3 -0
- data/vendor/licenses/rust/memchr-2.8.3/LICENSE-MIT +21 -0
- data/vendor/licenses/rust/memchr-2.8.3/UNLICENSE +24 -0
- data/vendor/licenses/rust/memmap2-0.9.12/LICENSE-APACHE +201 -0
- data/vendor/licenses/rust/memmap2-0.9.12/LICENSE-MIT +26 -0
- data/vendor/licenses/rust/miniz_oxide-0.8.9/LICENSE +25 -0
- data/vendor/licenses/rust/miniz_oxide-0.8.9/LICENSE-APACHE.md +177 -0
- data/vendor/licenses/rust/miniz_oxide-0.8.9/LICENSE-MIT.md +24 -0
- data/vendor/licenses/rust/miniz_oxide-0.8.9/LICENSE-ZLIB.md +14 -0
- data/vendor/licenses/rust/miniz_oxide-0.9.1/LICENSE +25 -0
- data/vendor/licenses/rust/miniz_oxide-0.9.1/LICENSE-APACHE.md +177 -0
- data/vendor/licenses/rust/miniz_oxide-0.9.1/LICENSE-MIT.md +24 -0
- data/vendor/licenses/rust/miniz_oxide-0.9.1/LICENSE-ZLIB.md +14 -0
- data/vendor/licenses/rust/num-traits-0.2.19/LICENSE-APACHE +201 -0
- data/vendor/licenses/rust/num-traits-0.2.19/LICENSE-MIT +25 -0
- data/vendor/licenses/rust/once_cell-1.21.4/LICENSE-APACHE +201 -0
- data/vendor/licenses/rust/once_cell-1.21.4/LICENSE-MIT +23 -0
- data/vendor/licenses/rust/phf-0.11.3/LICENSE +20 -0
- data/vendor/licenses/rust/phf_generator-0.11.3/LICENSE +20 -0
- data/vendor/licenses/rust/phf_macros-0.11.3/LICENSE +20 -0
- data/vendor/licenses/rust/phf_shared-0.11.3/LICENSE +20 -0
- data/vendor/licenses/rust/pico-args-0.5.0/LICENSE +20 -0
- data/vendor/licenses/rust/png-0.18.1/LICENSE-APACHE +201 -0
- data/vendor/licenses/rust/png-0.18.1/LICENSE-MIT +25 -0
- data/vendor/licenses/rust/polycool-0.4.0/LICENSE-APACHE +202 -0
- data/vendor/licenses/rust/polycool-0.4.0/LICENSE-MIT +25 -0
- data/vendor/licenses/rust/proc-macro2-1.0.107/LICENSE-APACHE +176 -0
- data/vendor/licenses/rust/proc-macro2-1.0.107/LICENSE-MIT +23 -0
- data/vendor/licenses/rust/quick-error-2.0.1/LICENSE-APACHE +202 -0
- data/vendor/licenses/rust/quick-error-2.0.1/LICENSE-MIT +19 -0
- data/vendor/licenses/rust/quote-1.0.47/LICENSE-APACHE +176 -0
- data/vendor/licenses/rust/quote-1.0.47/LICENSE-MIT +23 -0
- data/vendor/licenses/rust/rand-0.8.8/COPYRIGHT +12 -0
- data/vendor/licenses/rust/rand-0.8.8/LICENSE-APACHE +176 -0
- data/vendor/licenses/rust/rand-0.8.8/LICENSE-MIT +26 -0
- data/vendor/licenses/rust/rand_core-0.6.4/COPYRIGHT +12 -0
- data/vendor/licenses/rust/rand_core-0.6.4/LICENSE-APACHE +187 -0
- data/vendor/licenses/rust/rand_core-0.6.4/LICENSE-MIT +26 -0
- data/vendor/licenses/rust/read-fonts-0.41.0/LICENSE-APACHE +67 -0
- data/vendor/licenses/rust/read-fonts-0.41.0/LICENSE-MIT +25 -0
- data/vendor/licenses/rust/rgb-0.8.53/LICENSE +21 -0
- data/vendor/licenses/rust/roxmltree-0.20.0/LICENSE-APACHE +201 -0
- data/vendor/licenses/rust/roxmltree-0.20.0/LICENSE-MIT +21 -0
- data/vendor/licenses/rust/roxmltree-0.21.1/LICENSE-APACHE +201 -0
- data/vendor/licenses/rust/roxmltree-0.21.1/LICENSE-MIT +21 -0
- data/vendor/licenses/rust/ryu-1.0.23/LICENSE-APACHE +176 -0
- data/vendor/licenses/rust/ryu-1.0.23/LICENSE-BOOST +23 -0
- data/vendor/licenses/rust/serde-1.0.229/LICENSE-APACHE +176 -0
- data/vendor/licenses/rust/serde-1.0.229/LICENSE-MIT +23 -0
- data/vendor/licenses/rust/serde_core-1.0.229/LICENSE-APACHE +176 -0
- data/vendor/licenses/rust/serde_core-1.0.229/LICENSE-MIT +23 -0
- data/vendor/licenses/rust/serde_derive-1.0.229/LICENSE-APACHE +176 -0
- data/vendor/licenses/rust/serde_derive-1.0.229/LICENSE-MIT +23 -0
- data/vendor/licenses/rust/serde_json-1.0.145/LICENSE-APACHE +176 -0
- data/vendor/licenses/rust/serde_json-1.0.145/LICENSE-MIT +23 -0
- data/vendor/licenses/rust/sha2-0.10.9/LICENSE-APACHE +201 -0
- data/vendor/licenses/rust/sha2-0.10.9/LICENSE-MIT +27 -0
- data/vendor/licenses/rust/simd-adler32-0.3.10/LICENSE.md +21 -0
- data/vendor/licenses/rust/simplecss-0.2.2/LICENSE-APACHE +201 -0
- data/vendor/licenses/rust/simplecss-0.2.2/LICENSE-MIT +21 -0
- data/vendor/licenses/rust/siphasher-1.0.4/COPYING +7 -0
- data/vendor/licenses/rust/siphasher-1.0.4/LICENSE-APACHE +202 -0
- data/vendor/licenses/rust/siphasher-1.0.4/LICENSE-MIT +17 -0
- data/vendor/licenses/rust/skrifa-0.44.0/LICENSE-APACHE +67 -0
- data/vendor/licenses/rust/skrifa-0.44.0/LICENSE-MIT +25 -0
- data/vendor/licenses/rust/slotmap-1.1.1/LICENSE +19 -0
- data/vendor/licenses/rust/smallvec-1.16.3/LICENSE-APACHE +201 -0
- data/vendor/licenses/rust/smallvec-1.16.3/LICENSE-MIT +25 -0
- data/vendor/licenses/rust/strict-num-0.1.1/LICENSE +19 -0
- data/vendor/licenses/rust/svgtypes-0.16.1/LICENSE-APACHE +201 -0
- data/vendor/licenses/rust/svgtypes-0.16.1/LICENSE-MIT +20 -0
- data/vendor/licenses/rust/syn-2.0.119/LICENSE-APACHE +176 -0
- data/vendor/licenses/rust/syn-2.0.119/LICENSE-MIT +23 -0
- data/vendor/licenses/rust/syn-3.0.9/LICENSE-APACHE +176 -0
- data/vendor/licenses/rust/syn-3.0.9/LICENSE-MIT +23 -0
- data/vendor/licenses/rust/tiny-skia-0.12.0/LICENSE +30 -0
- data/vendor/licenses/rust/tiny-skia-path-0.12.0/LICENSE +30 -0
- data/vendor/licenses/rust/tinyvec-1.13.3/LICENSE-APACHE.md +202 -0
- data/vendor/licenses/rust/tinyvec-1.13.3/LICENSE-MIT.md +5 -0
- data/vendor/licenses/rust/tinyvec-1.13.3/LICENSE-ZLIB.md +11 -0
- data/vendor/licenses/rust/typenum-1.20.1/LICENSE +1 -0
- data/vendor/licenses/rust/typenum-1.20.1/LICENSE-APACHE +201 -0
- data/vendor/licenses/rust/typenum-1.20.1/LICENSE-MIT +21 -0
- data/vendor/licenses/rust/unicode-bidi-0.3.18/COPYRIGHT +8 -0
- data/vendor/licenses/rust/unicode-bidi-0.3.18/LICENSE-APACHE +201 -0
- data/vendor/licenses/rust/unicode-bidi-0.3.18/LICENSE-MIT +25 -0
- data/vendor/licenses/rust/unicode-ident-1.0.27/LICENSE-APACHE +176 -0
- data/vendor/licenses/rust/unicode-ident-1.0.27/LICENSE-MIT +23 -0
- data/vendor/licenses/rust/unicode-ident-1.0.27/LICENSE-UNICODE +39 -0
- data/vendor/licenses/rust/unicode-normalization-0.1.24/COPYRIGHT +7 -0
- data/vendor/licenses/rust/unicode-normalization-0.1.24/LICENSE-APACHE +201 -0
- data/vendor/licenses/rust/unicode-normalization-0.1.24/LICENSE-MIT +25 -0
- data/vendor/licenses/rust/unicode-script-0.5.8/LICENSE-APACHE +201 -0
- data/vendor/licenses/rust/unicode-script-0.5.8/LICENSE-MIT +27 -0
- data/vendor/licenses/rust/unicode-vo-0.1.0/LICENSE-APACHE +201 -0
- data/vendor/licenses/rust/unicode-vo-0.1.0/LICENSE-MIT +21 -0
- data/vendor/licenses/rust/version_check-0.9.5/LICENSE-APACHE +201 -0
- data/vendor/licenses/rust/version_check-0.9.5/LICENSE-MIT +19 -0
- data/vendor/licenses/rust/weezl-0.1.12/LICENSE-APACHE +201 -0
- data/vendor/licenses/rust/weezl-0.1.12/LICENSE-MIT +22 -0
- data/vendor/licenses/rust/xmlwriter-0.1.0/LICENSE +21 -0
- data/vendor/licenses/rust/zlib-rs-0.6.8/LICENSE +19 -0
- data/vendor/licenses/rust/zune-core-0.5.3/LICENSE-APACHE +201 -0
- data/vendor/licenses/rust/zune-core-0.5.3/LICENSE-MIT +21 -0
- data/vendor/licenses/rust/zune-core-0.5.3/LICENSE-ZLIB +19 -0
- data/vendor/licenses/rust/zune-jpeg-0.5.15/LICENSE-APACHE +201 -0
- data/vendor/licenses/rust/zune-jpeg-0.5.15/LICENSE-MIT +21 -0
- data/vendor/licenses/rust/zune-jpeg-0.5.15/LICENSE-ZLIB +19 -0
- data/vendor/licenses/zlib/LICENSE +22 -0
- data/vendor/manifest.yml +102 -0
- data/vendor/rust-dependencies.json +1696 -0
- 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
|