fontisan 0.4.47 → 0.4.48

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 (99) hide show
  1. checksums.yaml +4 -4
  2. data/BENCHMARKS.md +56 -0
  3. data/CHANGELOG.md +12 -0
  4. data/Cargo.lock +550 -0
  5. data/Cargo.toml +12 -0
  6. data/Gemfile +10 -0
  7. data/README.adoc +52 -0
  8. data/Rakefile +5 -1
  9. data/benchmark/baseline.json +5 -0
  10. data/benchmark/ci_gate.rb +217 -0
  11. data/benchmark/corpus/harness.rb +175 -0
  12. data/benchmark/corpus/ops.rb +150 -0
  13. data/benchmark/corpus/run.rb +182 -0
  14. data/benchmark/corpus/scan_child.rb +8 -0
  15. data/benchmark/corpus/tiers.rb +176 -0
  16. data/benchmark/tier_baseline.json +12 -0
  17. data/docs/.vitepress/config.ts +2 -1
  18. data/docs/guide/engines.md +117 -0
  19. data/docs/reference-facade.md +130 -0
  20. data/ext/fontisan_cdylib/Cargo.lock +313 -0
  21. data/ext/fontisan_cdylib/Cargo.toml +18 -0
  22. data/ext/fontisan_cdylib/build.rs +22 -0
  23. data/ext/fontisan_cdylib/extconf.rb +36 -0
  24. data/ext/fontisan_cdylib/src/lib.rs +13 -0
  25. data/ext/fontisan_native/Cargo.toml +21 -0
  26. data/ext/fontisan_native/build.rs +22 -0
  27. data/ext/fontisan_native/clippy.toml +10 -0
  28. data/ext/fontisan_native/extconf.rb +59 -0
  29. data/ext/fontisan_native/src/lib.rs +463 -0
  30. data/lib/fontisan/audit/checks/cmap_check.rb +7 -0
  31. data/lib/fontisan/audit/checks/hinting_check.rb +1 -1
  32. data/lib/fontisan/audit/checks/maxp_check.rb +1 -1
  33. data/lib/fontisan/audit/checks/opentype_conformance_check.rb +5 -2
  34. data/lib/fontisan/base_collection.rb +30 -0
  35. data/lib/fontisan/byte_ops.rb +219 -0
  36. data/lib/fontisan/cli.rb +5 -5
  37. data/lib/fontisan/commands/audit_command.rb +38 -43
  38. data/lib/fontisan/commands/convert_command.rb +53 -0
  39. data/lib/fontisan/commands/instance_command.rb +75 -0
  40. data/lib/fontisan/commands/subset_command.rb +43 -0
  41. data/lib/fontisan/commands/unpack_command.rb +56 -0
  42. data/lib/fontisan/converters/cff_table_builder.rb +1 -1
  43. data/lib/fontisan/converters/collection_converter.rb +13 -5
  44. data/lib/fontisan/converters/format_converter.rb +1 -1
  45. data/lib/fontisan/converters/woff2_encoder.rb +8 -6
  46. data/lib/fontisan/converters/woff_writer.rb +6 -5
  47. data/lib/fontisan/engine.rb +350 -0
  48. data/lib/fontisan/ffi/client.rb +121 -0
  49. data/lib/fontisan/ffi.rb +343 -0
  50. data/lib/fontisan/ffi_platform.rb +24 -0
  51. data/lib/fontisan/font_loader.rb +5 -1
  52. data/lib/fontisan/font_writer.rb +13 -1
  53. data/lib/fontisan/native.rb +231 -0
  54. data/lib/fontisan/reference/table_digests.rb +251 -0
  55. data/lib/fontisan/reference.rb +619 -0
  56. data/lib/fontisan/scan.rb +372 -0
  57. data/lib/fontisan/sfnt_directory.rb +41 -0
  58. data/lib/fontisan/sfnt_font.rb +16 -6
  59. data/lib/fontisan/subset/builder.rb +2 -1
  60. data/lib/fontisan/tables/cff/charstring.rb +16 -0
  61. data/lib/fontisan/tables/cff.rb +15 -5
  62. data/lib/fontisan/tables/cmap.rb +11 -1
  63. data/lib/fontisan/tables/gpos.rb +7 -0
  64. data/lib/fontisan/tables/gsub.rb +7 -0
  65. data/lib/fontisan/tables/layout_common.rb +172 -0
  66. data/lib/fontisan/tables/name.rb +10 -4
  67. data/lib/fontisan/utilities/brotli_wrapper.rb +39 -4
  68. data/lib/fontisan/validators/opentype_validator.rb +2 -0
  69. data/lib/fontisan/variable/instancer.rb +1 -0
  70. data/lib/fontisan/variable/static_font_builder.rb +4 -1
  71. data/lib/fontisan/variable/table_updater.rb +4 -2
  72. data/lib/fontisan/variation/instancing/true_type_instancer.rb +14 -6
  73. data/lib/fontisan/version.rb +1 -1
  74. data/lib/fontisan/woff2/collection_decoder.rb +15 -1
  75. data/lib/fontisan/woff2/collection_encoder.rb +29 -6
  76. data/lib/fontisan/woff2/encoder_rules.rb +12 -4
  77. data/lib/fontisan/woff2/glyf_loca_reconstruct.rb +1 -1
  78. data/lib/fontisan/woff2_collection.rb +7 -5
  79. data/lib/fontisan/woff2_font.rb +12 -3
  80. data/lib/fontisan/woff_container.rb +83 -0
  81. data/lib/fontisan/woff_font.rb +24 -20
  82. data/lib/fontisan.rb +24 -1
  83. data/rakelib/bench.rake +9 -0
  84. data/rakelib/cdylib.rake +39 -0
  85. data/rakelib/corpus.rake +272 -0
  86. data/rakelib/corpus_convert.rake +43 -0
  87. data/rakelib/corpus_goldens.rake +22 -0
  88. data/rakelib/corpus_malformed.rake +20 -0
  89. data/rakelib/corpus_scan.rake +24 -0
  90. data/rakelib/java.rake +61 -0
  91. data/rakelib/native.rake +43 -0
  92. data/scripts/build-woff2.sh +57 -0
  93. data/scripts/corpus/generate_convert_goldens.rb +478 -0
  94. data/scripts/corpus/generate_goldens.rb +282 -0
  95. data/scripts/corpus/generate_malformed_goldens.rb +254 -0
  96. data/scripts/corpus/generate_scan_goldens.rb +98 -0
  97. data/scripts/tier_fallback.rb +219 -0
  98. data/scripts/tier_smoke.rb +64 -0
  99. metadata +68 -3
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ # Structural checks on a WOFF 1.0 container, run before the BinData parse.
5
+ #
6
+ # The native tiers decode a WOFF through fontisan-rs, which rejects a bad
7
+ # container with InvalidFontError "woff: <reason>" (no cause). This applies the
8
+ # same checks in the same order with the same messages, so every tier fails
9
+ # the same way on the same input (fontist/fontisan#159) rather than leaking
10
+ # whatever BinData or Zlib raise.
11
+ module WoffContainer
12
+ SIGNATURE = 0x774F4646 # 'wOFF'
13
+ MAX_OUTPUT = 1 << 30
14
+ PREFIX = "woff: "
15
+
16
+ module_function
17
+
18
+ # @param data [String] the whole file, binary
19
+ # @raise [InvalidFontError] with a "woff: ..." message
20
+ # @return [void]
21
+ def check!(data)
22
+ cur = Cursor.new(data)
23
+ reject("not a WOFF file (bad signature)") unless cur.u32 == SIGNATURE
24
+ cur.u32 # flavor
25
+ reject("header length does not match file length") unless cur.u32 == data.bytesize
26
+ num_tables = cur.u16
27
+ reject("no tables") if num_tables.zero?
28
+ cur.skip(2 + 4 + 4)
29
+ meta_offset = cur.u32
30
+ meta_length = cur.u32
31
+ cur.skip(4)
32
+ priv_offset = cur.u32
33
+ priv_length = cur.u32
34
+ [[meta_offset, meta_length], [priv_offset, priv_length]].each do |off, len|
35
+ reject("metadata or private block out of range") if off != 0 && off + len > data.bytesize
36
+ end
37
+ check_directory!(cur, num_tables, data.bytesize)
38
+ end
39
+
40
+ def check_directory!(cur, num_tables, size)
41
+ total = 12 + (16 * num_tables)
42
+ tags = Array.new(num_tables) do
43
+ tag, offset, comp, orig, = cur.u32, cur.u32, cur.u32, cur.u32, cur.u32
44
+ reject("table compLength exceeds origLength") if comp > orig
45
+ reject("table data out of range") if offset + comp > size
46
+ total += (orig + 3) & ~3
47
+ reject("decoded size too large") if total > MAX_OUTPUT
48
+ tag
49
+ end
50
+ reject("table directory not sorted by unique tag") if tags.each_cons(2).any? { |a, b| a >= b }
51
+ end
52
+
53
+ def reject(message)
54
+ raise InvalidFontError, "#{PREFIX}#{message}", cause: nil
55
+ end
56
+
57
+ # Bounds-checked big-endian reader.
58
+ class Cursor
59
+ def initialize(data)
60
+ @data = data
61
+ @pos = 0
62
+ end
63
+
64
+ def skip(count)
65
+ take(count)
66
+ nil
67
+ end
68
+
69
+ def u16 = take(2).unpack1("n")
70
+ def u32 = take(4).unpack1("N")
71
+
72
+ private
73
+
74
+ def take(count)
75
+ raise InvalidFontError.new("#{PREFIX}unexpected end of data"), cause: nil if count > @data.bytesize - @pos
76
+
77
+ chunk = @data.byteslice(@pos, count)
78
+ @pos += count
79
+ chunk
80
+ end
81
+ end
82
+ end
83
+ end
@@ -97,12 +97,15 @@ module Fontisan
97
97
  end
98
98
  raise Errno::ENOENT, "File not found: #{path}" unless File.exist?(path)
99
99
 
100
+ WoffContainer.check!(File.binread(path))
101
+
100
102
  File.open(path, "rb") do |io|
101
103
  font = read(io)
102
104
  font.validate_signature!
103
105
  font.initialize_storage
104
106
  font.io_source = io
105
107
  font.read_compressed_table_data(io)
108
+ font.table_entries.each { |entry| font.table_data(entry.tag.dup.force_encoding("UTF-8")) }
106
109
  font
107
110
  rescue BinData::ValidityError, EOFError => e
108
111
  Kernel.raise(::Fontisan::InvalidFontError,
@@ -218,18 +221,31 @@ module Fontisan
218
221
  # Table is not compressed
219
222
  compressed_data
220
223
  else
221
- # Decompress using zlib
222
- Zlib::Inflate.inflate(compressed_data)
224
+ inflate_table(compressed_data, entry.orig_length)
223
225
  end
224
226
 
225
- # Verify decompressed size matches expected
226
- if @decompressed_tables[tag].bytesize != entry.orig_length
227
+ @decompressed_tables[tag]
228
+ end
229
+
230
+ # Inflate one table. Same failures and messages as the native tiers (see
231
+ # WoffContainer): a bad stream, or one longer than origLength, is "zlib
232
+ # stream is corrupt or too long"; a shorter one is a size mismatch.
233
+ def inflate_table(data, orig_length)
234
+ plain = begin
235
+ Zlib::Inflate.inflate(data)
236
+ rescue Zlib::Error
237
+ nil
238
+ end
239
+ if plain.nil? || plain.bytesize > orig_length
240
+ Kernel.raise(::Fontisan::InvalidFontError,
241
+ "#{WoffContainer::PREFIX}zlib stream is corrupt or too long", cause: nil)
242
+ end
243
+ if plain.bytesize != orig_length
227
244
  Kernel.raise(::Fontisan::InvalidFontError,
228
- "Decompressed table '#{tag}' size mismatch: " \
229
- "expected #{entry.orig_length}, got #{@decompressed_tables[tag].bytesize}")
245
+ "#{WoffContainer::PREFIX}inflated size does not match origLength", cause: nil)
230
246
  end
231
247
 
232
- @decompressed_tables[tag]
248
+ plain
233
249
  end
234
250
 
235
251
  # Check if font has a specific table
@@ -388,19 +404,7 @@ module Fontisan
388
404
  # @param tag [String] The table tag
389
405
  # @return [Class, nil] Table parser class or nil
390
406
  def table_class_for(tag)
391
- {
392
- Constants::HEAD_TAG => Tables::Head,
393
- Constants::HHEA_TAG => Tables::Hhea,
394
- Constants::HMTX_TAG => Tables::Hmtx,
395
- Constants::MAXP_TAG => Tables::Maxp,
396
- Constants::NAME_TAG => Tables::Name,
397
- Constants::OS2_TAG => Tables::Os2,
398
- Constants::POST_TAG => Tables::Post,
399
- Constants::CMAP_TAG => Tables::Cmap,
400
- Constants::FVAR_TAG => Tables::Fvar,
401
- Constants::GSUB_TAG => Tables::Gsub,
402
- Constants::GPOS_TAG => Tables::Gpos,
403
- }[tag]
407
+ SfntFont::TABLE_CLASS_MAP[tag] || (Tables::Cff if tag == "CFF ")
404
408
  end
405
409
 
406
410
  # Build an SFNT font file (TTF or OTF) from decompressed WOFF data
data/lib/fontisan.rb CHANGED
@@ -119,6 +119,13 @@ module Fontisan
119
119
  autoload :SfntSource, "fontisan/sfnt_source"
120
120
  autoload :SfntTable, "fontisan/sfnt_table"
121
121
  autoload :SfntBuilder, "fontisan/sfnt_builder"
122
+ autoload :Reference, "fontisan/reference"
123
+ autoload :Engine, "fontisan/engine"
124
+ autoload :ByteOps, "fontisan/byte_ops"
125
+ autoload :Native, "fontisan/native"
126
+ autoload :Ffi, "fontisan/ffi"
127
+ autoload :FfiPlatform, "fontisan/ffi_platform"
128
+ autoload :Scan, "fontisan/scan"
122
129
  autoload :Stitcher, "fontisan/stitcher"
123
130
  autoload :StitcherCli, "fontisan/stitcher_cli"
124
131
  autoload :TrueTypeCollection, "fontisan/true_type_collection"
@@ -127,6 +134,8 @@ module Fontisan
127
134
  autoload :Type1Font, "fontisan/type1_font"
128
135
  autoload :Woff2Collection, "fontisan/woff2_collection"
129
136
  autoload :Woff2Font, "fontisan/woff2_font"
137
+ autoload :SfntDirectory, "fontisan/sfnt_directory"
138
+ autoload :WoffContainer, "fontisan/woff_container"
130
139
  autoload :WoffFont, "fontisan/woff_font"
131
140
 
132
141
  # SFNT offset table and table directory (defined in sfnt_font.rb)
@@ -212,7 +221,21 @@ module Fontisan
212
221
  # @example WOFF with no compression (legal per WOFF 1.0 §5.1)
213
222
  # Fontisan.convert("f.ttf", to: :woff, output: "f.woff", uncompressed: true)
214
223
  def self.convert(path, to:, output:, **)
215
- Commands::ConvertCommand.new(path, { to: to, output: output, ** }).run
224
+ # Through the Reference gate on every tier: the gate picks the tier, records
225
+ # who served the call, and never prints (the Ruby tier used to print progress
226
+ # here while the native tiers stayed silent).
227
+ # TODO(2f): UnpackCommand and the Thor CLI still call their commands directly.
228
+ Reference.convert(path, to: to, output: output, **)
229
+ end
230
+
231
+ # Scan font files into one row per font face, with the names, indexability
232
+ # result and variable instances an indexer needs. Delegates to
233
+ # {Reference.scan}; never raises for a bad font (the failure is in the row).
234
+ #
235
+ # @param paths [Array<String>] font file paths
236
+ # @return [Array<Hash>] rows (see docs/reference-facade.md)
237
+ def self.scan(paths)
238
+ Reference.scan(paths)
216
239
  end
217
240
 
218
241
  # Validate a font file using specified profile
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ desc "Run the reference corpus benches (ci tier), print the report and write BENCHMARKS.md"
4
+ task bench: "corpus:fetch" do
5
+ root = File.expand_path("..", __dir__)
6
+ ok = system(RbConfig.ruby, File.join(root, "benchmark/ci_gate.rb"), "--mode", "report",
7
+ "--write", File.join(root, "BENCHMARKS.md"))
8
+ abort "bench failed" unless ok
9
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The :ffi tier's shared library (fontisan#156 card 2d).
4
+ namespace :cdylib do
5
+ desc "Build ext/fontisan_cdylib with cargo and install it under lib/fontisan/ffi/"
6
+ task build: :vendor
7
+
8
+ desc "Build the cdylib for the active cross target (CARGO_BUILD_TARGET / RUST_TARGET) and vendor it into lib/fontisan/ffi/"
9
+ task :vendor do
10
+ require "rbconfig"
11
+ require "fileutils"
12
+ ext = File.expand_path("../ext/fontisan_cdylib", __dir__)
13
+ triple = ENV["CARGO_BUILD_TARGET"] || ENV.fetch("RUST_TARGET", nil)
14
+ if triple.nil? && ENV.key?("RUBY_TARGET") # a cross build outside rb-sys-dock
15
+ require "rb_sys/toolchain_info"
16
+ triple = RbSys::ToolchainInfo.new(ENV.fetch("RUBY_TARGET")).rust_target
17
+ end
18
+ args = ["cargo", "build", "--release", "--locked", "--manifest-path", File.join(ext, "Cargo.toml")]
19
+ args += ["--target", triple] if triple
20
+ sh(*args)
21
+ release = File.join(ENV.fetch("CARGO_TARGET_DIR", File.join(ext, "target")), triple.to_s, "release")
22
+ built = Dir[File.join(release, "{lib,}fontisan_cdylib.{so,dylib,dll}")].first
23
+ abort "cdylib not found under #{release}" unless built
24
+
25
+ dest = File.expand_path("../lib/fontisan/ffi", __dir__)
26
+ FileUtils.mkdir_p(dest)
27
+ FileUtils.cp(built, dest)
28
+ end
29
+
30
+ desc "List the fontisan_* symbols the built library exports (nm -gU / dumpbin); fails if any is missing"
31
+ task :exports do
32
+ lib = Dir[File.expand_path("../lib/fontisan/ffi/*fontisan_cdylib.*", __dir__)].first
33
+ abort "no built library under lib/fontisan/ffi (rake cdylib:build)" unless lib
34
+ listing = if Gem.win_platform? then `dumpbin /exports "#{lib}"` else `nm -gU "#{lib}"` end
35
+ missing = %w[fontisan_abi_version fontisan_call fontisan_result_free fontisan_cdylib_engine_rev].reject { |s| listing.include?(s) }
36
+ abort "missing exports: #{missing.join(', ')}" unless missing.empty?
37
+ puts listing.lines.grep(/fontisan_/)
38
+ end
39
+ end
@@ -0,0 +1,272 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "fileutils"
5
+ require "json"
6
+ require "net/http"
7
+ require "open3"
8
+ require "tmpdir"
9
+ require "uri"
10
+
11
+ # Reference corpus (fontist/fontisan#157). The manifest is corpus/manifest.json:
12
+ # source - a font fetched from a pinned URL; sha256 is of the EXTRACTED font
13
+ # (archives are not byte-stable, so the zip itself is never hashed)
14
+ # derived - a small output committed under corpus/derived/, sha256 verified
15
+ module Corpus
16
+ ROOT = File.expand_path("..", __dir__)
17
+ MANIFEST = File.join(ROOT, "corpus", "manifest.json")
18
+ FONTS_DIR = File.join(ROOT, "corpus", "fonts")
19
+ DERIVED_DIR = File.join(ROOT, "corpus", "derived")
20
+ MAX_REDIRECTS = 8
21
+ ATTEMPTS = 3
22
+
23
+ class Error < StandardError; end
24
+
25
+ module_function
26
+
27
+ def manifest
28
+ JSON.parse(File.read(MANIFEST))
29
+ end
30
+
31
+ def tiers_for(selector)
32
+ case selector.to_s
33
+ when "", "ci" then %w[ci]
34
+ when "large" then %w[large]
35
+ when "all" then %w[ci large]
36
+ else raise Error, "TIER must be ci, large or all (got #{selector.inspect})"
37
+ end
38
+ end
39
+
40
+ def sha256(path)
41
+ Digest::SHA256.file(path).hexdigest
42
+ end
43
+
44
+ def source_path(entry)
45
+ File.join(FONTS_DIR, entry.fetch("id"), entry.fetch("file"))
46
+ end
47
+
48
+ def derived_path(entry)
49
+ File.join(DERIVED_DIR, entry.fetch("file"))
50
+ end
51
+
52
+ def verify!(entry, path)
53
+ raise Error, "#{entry['id']}: #{path} is missing" unless File.exist?(path)
54
+
55
+ actual = sha256(path)
56
+ return if actual == entry.fetch("sha256")
57
+
58
+ raise Error, "#{entry['id']}: sha256 mismatch for #{File.basename(path)}\n " \
59
+ "expected #{entry['sha256']}\n actual #{actual}"
60
+ end
61
+
62
+ # Streams url into dest, following redirects, retrying transient failures.
63
+ def download(url, dest)
64
+ attempt = 0
65
+ begin
66
+ attempt += 1
67
+ stream(URI(url), dest, MAX_REDIRECTS)
68
+ rescue StandardError => e
69
+ FileUtils.rm_f(dest)
70
+ raise Error, "download failed for #{url}: #{e.message}" if attempt >= ATTEMPTS
71
+
72
+ warn "[corpus:fetch] retry #{attempt}/#{ATTEMPTS - 1} for #{url}: #{e.message}"
73
+ sleep(attempt * 2)
74
+ retry
75
+ end
76
+ end
77
+
78
+ def stream(uri, dest, redirects)
79
+ raise "too many redirects" if redirects.negative?
80
+
81
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
82
+ read_timeout: 120) do |http|
83
+ http.request_get(uri.request_uri, "User-Agent" => "fontisan-corpus") do |res|
84
+ case res
85
+ when Net::HTTPSuccess
86
+ File.open(dest, "wb") { |f| res.read_body { |chunk| f.write(chunk) } }
87
+ when Net::HTTPRedirection
88
+ return stream(URI.join(uri.to_s, res["location"]), dest, redirects - 1)
89
+ else
90
+ raise "HTTP #{res.code}"
91
+ end
92
+ end
93
+ end
94
+ end
95
+
96
+ def extract_member(zip_path, member, dest)
97
+ require "zip"
98
+ Zip::File.open(zip_path) do |zip|
99
+ found = zip.find_entry(member) or raise Error, "#{member} not in archive"
100
+ File.open(dest, "wb") { |out| IO.copy_stream(found.get_input_stream, out) }
101
+ end
102
+ end
103
+
104
+ def fetch_source(entry)
105
+ dest = source_path(entry)
106
+ if File.exist?(dest) && sha256(dest) == entry["sha256"]
107
+ puts "[corpus:fetch] ok #{entry['id']}"
108
+ return
109
+ end
110
+
111
+ FileUtils.mkdir_p(File.dirname(dest))
112
+ Dir.mktmpdir("corpus-fetch") do |tmp|
113
+ src = entry.fetch("source")
114
+ if src["archive_member"]
115
+ zip = File.join(tmp, "archive.zip")
116
+ download(src.fetch("url"), zip)
117
+ extracted = File.join(tmp, "member")
118
+ extract_member(zip, src["archive_member"], extracted)
119
+ FileUtils.mv(extracted, dest)
120
+ else
121
+ download(src.fetch("url"), dest)
122
+ end
123
+ end
124
+ begin
125
+ verify!(entry, dest)
126
+ rescue Error
127
+ FileUtils.rm_f(dest)
128
+ raise
129
+ end
130
+ puts "[corpus:fetch] fetched #{entry['id']} (#{File.size(dest)} bytes)"
131
+ end
132
+
133
+ def entries(selector)
134
+ tiers = tiers_for(selector)
135
+ manifest.fetch("fonts").select { |f| tiers.include?(f["tier"]) }
136
+ end
137
+
138
+ def fetch(selector)
139
+ list = entries(selector)
140
+ list.select { |f| f["type"] == "source" }.each { |e| fetch_source(e) }
141
+ list.select { |f| f["type"] == "derived" }.each do |e|
142
+ verify!(e, derived_path(e))
143
+ puts "[corpus:fetch] derived #{e['id']} verified"
144
+ end
145
+ sources = list.count { |f| f["type"] == "source" }
146
+ puts "[corpus:fetch] #{sources} sources and #{list.size - sources} derived entries verified"
147
+ end
148
+
149
+ # --- WOFF2 cross-check with the pinned Google woff2_decompress ----------
150
+
151
+ def woff2_decompress
152
+ out, status = Open3.capture2(File.join(ROOT, "scripts", "build-woff2.sh"))
153
+ raise Error, "scripts/build-woff2.sh failed" unless status.success?
154
+
155
+ out.lines.last.strip
156
+ end
157
+
158
+ def path_for(entry)
159
+ entry["type"] == "derived" ? derived_path(entry) : source_path(entry)
160
+ end
161
+
162
+ def tables_of(bytes)
163
+ require_relative "../corpus/derive/sfnt"
164
+ if bytes.byteslice(0, 4) == "ttcf"
165
+ CorpusSfnt.parse_ttc(bytes).map { |_v, t| t.to_h }
166
+ else
167
+ [CorpusSfnt.parse(bytes).last.to_h]
168
+ end
169
+ end
170
+
171
+ # WOFF2 drops DSIG, re-serialises glyf/loca (padding) and sets head
172
+ # flags bit 11, checkSumAdjustment and modified. Everything else must be
173
+ # byte-equal after decoding by the pinned Google decoder.
174
+ HEAD_VOLATILE = [8..11, 16..16, 28..35].flat_map(&:to_a).freeze
175
+
176
+ def glyph_slices(tables)
177
+ loca_fmt = tables["head"].byteslice(50, 2).unpack1("n")
178
+ offs = if loca_fmt.zero?
179
+ tables["loca"].unpack("n*").map { |o| o * 2 }
180
+ else
181
+ tables["loca"].unpack("N*")
182
+ end
183
+ offs.each_cons(2).map { |a, b| tables["glyf"].byteslice(a, b - a).sub(/\0+\z/n, "") }
184
+ end
185
+
186
+ def compare_decoded(id, want, got)
187
+ problems = []
188
+ unless want.size == got.size
189
+ return ["#{id}: #{got.size} members decoded, expected #{want.size}"]
190
+ end
191
+
192
+ want.zip(got).each_with_index do |(w, g), i|
193
+ where = "#{id} member #{i}"
194
+ expected_tags = w.keys - ["DSIG"]
195
+ problems << "#{where}: table tags differ" unless expected_tags.sort == g.keys.sort
196
+ (expected_tags & g.keys).each do |tag|
197
+ case tag
198
+ when "glyf", "loca" then next
199
+ when "head"
200
+ diff = (0...w[tag].bytesize).reject { |j| w[tag].getbyte(j) == g[tag].getbyte(j) }
201
+ problems << "#{where}: head differs outside volatile fields: #{diff}" unless (diff - HEAD_VOLATILE).empty?
202
+ else
203
+ problems << "#{where}: table #{tag} differs after decode" unless w[tag] == g[tag]
204
+ end
205
+ end
206
+ if w["glyf"] && g["glyf"] && glyph_slices(w) != glyph_slices(g)
207
+ problems << "#{where}: glyph outlines differ after decode"
208
+ end
209
+ end
210
+ problems
211
+ end
212
+
213
+ def check_woff2(selector)
214
+ exe = woff2_decompress
215
+ all = manifest.fetch("fonts")
216
+ failures = []
217
+ entries(selector).select { |e| e["woff2_decode"] }.each do |entry|
218
+ Dir.mktmpdir("corpus-woff2") do |tmp|
219
+ input = File.join(tmp, "in.woff2")
220
+ FileUtils.cp(path_for(entry), input)
221
+ _out, err, status = Open3.capture3(exe, input)
222
+ # woff2_decompress writes <stem>.ttf even for collections: detect the
223
+ # format by magic bytes, never by extension.
224
+ out_path = File.join(tmp, "in.ttf")
225
+ unless status.success? && File.exist?(out_path)
226
+ failures << "#{entry['id']}: woff2_decompress failed: #{err.strip}"
227
+ next
228
+ end
229
+ decoded = File.binread(out_path)
230
+ magic = decoded.byteslice(0, 4)
231
+ unless ["\x00\x01\x00\x00".b, "OTTO", "true", "ttcf"].include?(magic)
232
+ failures << "#{entry['id']}: decoded output has magic #{magic.inspect}"
233
+ next
234
+ end
235
+ if (ref_id = entry["woff2_decoded_equals"])
236
+ ref = all.find { |f| f["id"] == ref_id }
237
+ want = tables_of(File.binread(path_for(ref)))
238
+ got = tables_of(decoded)
239
+ problems = compare_decoded(entry["id"], want, got)
240
+ failures.concat(problems)
241
+ next unless problems.empty?
242
+ end
243
+ puts "[corpus:check_woff2] ok #{entry['id']} (#{magic == 'ttcf' ? 'collection' : 'single font'})"
244
+ end
245
+ end
246
+ raise Error, "WOFF2 cross-check failed:\n #{failures.join("\n ")}" unless failures.empty?
247
+ end
248
+ end
249
+
250
+ namespace :corpus do
251
+ desc "Download and verify the corpus (TIER=ci|large|all, default ci)"
252
+ task :fetch do
253
+ Corpus.fetch(ENV.fetch("TIER", "ci"))
254
+ rescue Corpus::Error => e
255
+ abort "[corpus:fetch] #{e.message}"
256
+ end
257
+
258
+ desc "Cross-check WOFF2 entries with the pinned Google woff2_decompress"
259
+ task check_woff2: :fetch do
260
+ Corpus.check_woff2(ENV.fetch("TIER", "ci"))
261
+ rescue Corpus::Error => e
262
+ abort "[corpus:check_woff2] #{e.message}"
263
+ end
264
+
265
+ desc "Regenerate corpus/derived from corpus/derive scripts (provenance; run after fetch)"
266
+ task derive: :fetch do
267
+ ruby = %w[quirks malformed fuzz ttc_multi woff2_collection]
268
+ ruby.each do |name|
269
+ sh "bundle", "exec", "ruby", File.join(Corpus::ROOT, "corpus", "derive", "#{name}.rb")
270
+ end
271
+ end
272
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../scripts/corpus/generate_convert_goldens"
4
+
5
+ # Convert goldens (card 0c). `corpus:convert:verify` only compares;
6
+ # `corpus:convert:regenerate` is the one task that writes. TIER=ci|large|all,
7
+ # ONLY=<font-id>[,<font-id>] narrows verify/regenerate. Needs `opentype-sanitizer`
8
+ # (OTS_PYTHON=<python with it>) and the pinned woff2_decompress.
9
+ module ConvertTasks
10
+ module_function
11
+
12
+ def tiers
13
+ Corpus.tiers_for(ENV.fetch("TIER", "ci"))
14
+ end
15
+
16
+ def only
17
+ ENV["ONLY"]&.split(",")
18
+ end
19
+ end
20
+
21
+ namespace :corpus do
22
+ namespace :convert do
23
+ desc "Regenerate convert goldens from the reference (TIER, ONLY)"
24
+ task regenerate: "corpus:fetch" do
25
+ files = ConvertGoldens.generate(ConvertTasks.tiers, only: ConvertTasks.only)
26
+ ConvertGoldens.write(files)
27
+ puts "[corpus:convert:regenerate] wrote #{files.size} goldens"
28
+ rescue ConvertGoldens::ToolMissing, Corpus::Error => e
29
+ abort "[corpus:convert:regenerate] #{e.message}"
30
+ end
31
+
32
+ desc "Compare regenerated convert output with the committed goldens; never writes (TIER, ONLY)"
33
+ task verify: "corpus:fetch" do
34
+ problems = ConvertGoldens.verify(ConvertTasks.tiers, only: ConvertTasks.only)
35
+ problems.concat(ConvertGoldens.ots_failures.reject { |f| ConvertGoldens.listed_exceptions.include?(f) }
36
+ .map { |f| "#{f}: OTS failure not listed in corpus/TRIAGE_CONVERT.md" })
37
+ abort "[corpus:convert:verify] #{problems.size} problem(s):\n #{problems.join("\n ")}" unless problems.empty?
38
+ puts "[corpus:convert:verify] ok"
39
+ rescue ConvertGoldens::ToolMissing => e
40
+ abort "[corpus:convert:verify] #{e.message}"
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Read goldens (fontist/fontisan#156 card 0b). `corpus:verify` compares only
4
+ # and never writes; `corpus:regenerate` rewrites the goldens and is explicit.
5
+ namespace :corpus do
6
+ desc "Compare the read goldens with fresh output (TIER=ci|large, default ci); never writes"
7
+ task verify: :fetch do
8
+ require_relative "../scripts/corpus/generate_goldens"
9
+ tier = ENV.fetch("TIER", "ci")
10
+ problems = CorpusGoldens.verify(tier)
11
+ abort "[corpus:verify] #{problems.size} problem(s):\n #{problems.join("\n ")}" unless problems.empty?
12
+
13
+ puts "[corpus:verify] tier #{tier}: #{CorpusGoldens.entries(tier).size} read goldens match"
14
+ end
15
+
16
+ desc "Rewrite the read goldens from the corpus (TIER=ci|large); review the diff"
17
+ task regenerate: :fetch do
18
+ require_relative "../scripts/corpus/generate_goldens"
19
+ tier = ENV.fetch("TIER", "ci")
20
+ puts "[corpus:regenerate] wrote #{CorpusGoldens.write(tier)} goldens (tier #{tier})"
21
+ end
22
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Malformed-input goldens (fontist/fontisan#156, card 0e).
4
+ namespace :corpus do
5
+ namespace :malformed do
6
+ desc "Check corpus/goldens/malformed against current Ruby behaviour"
7
+ task :verify do
8
+ require_relative "../scripts/corpus/generate_malformed_goldens"
9
+ problems = MalformedGoldens.verify
10
+ abort "[corpus:malformed:verify]\n #{problems.join("\n ")}" unless problems.empty?
11
+ puts "[corpus:malformed:verify] ok (#{MalformedGoldens.entries.size} kinds)"
12
+ end
13
+
14
+ desc "Regenerate corpus/goldens/malformed (only in a PR that shows the golden diff)"
15
+ task :regenerate do
16
+ require_relative "../scripts/corpus/generate_malformed_goldens"
17
+ puts "[corpus:malformed:regenerate] wrote #{MalformedGoldens.regenerate} goldens"
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Scan goldens (fontist/fontisan#156 card 0f). `corpus:scan:verify` compares
4
+ # only and never writes; `corpus:scan:regenerate` rewrites and is explicit.
5
+ namespace :corpus do
6
+ namespace :scan do
7
+ desc "Compare corpus/goldens/scan with fresh Reference.scan output (TIER=ci|large); never writes"
8
+ task verify: "corpus:fetch" do
9
+ require_relative "../scripts/corpus/generate_scan_goldens"
10
+ tier = ENV.fetch("TIER", "ci")
11
+ problems = ScanGoldens.verify(tier)
12
+ abort "[corpus:scan:verify] #{problems.size} problem(s):\n #{problems.join("\n ")}" unless problems.empty?
13
+
14
+ puts "[corpus:scan:verify] tier #{tier}: #{ScanGoldens.entries(tier).size} scan goldens match"
15
+ end
16
+
17
+ desc "Rewrite the scan goldens (TIER=ci|large); review the diff"
18
+ task regenerate: "corpus:fetch" do
19
+ require_relative "../scripts/corpus/generate_scan_goldens"
20
+ tier = ENV.fetch("TIER", "ci")
21
+ puts "[corpus:scan:regenerate] wrote #{ScanGoldens.write(tier)} goldens (tier #{tier})"
22
+ end
23
+ end
24
+ end