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.
- checksums.yaml +4 -4
- data/BENCHMARKS.md +56 -0
- data/CHANGELOG.md +12 -0
- data/Cargo.lock +550 -0
- data/Cargo.toml +12 -0
- data/Gemfile +10 -0
- data/README.adoc +52 -0
- data/Rakefile +5 -1
- data/benchmark/baseline.json +5 -0
- data/benchmark/ci_gate.rb +217 -0
- data/benchmark/corpus/harness.rb +175 -0
- data/benchmark/corpus/ops.rb +150 -0
- data/benchmark/corpus/run.rb +182 -0
- data/benchmark/corpus/scan_child.rb +8 -0
- data/benchmark/corpus/tiers.rb +176 -0
- data/benchmark/tier_baseline.json +12 -0
- data/docs/.vitepress/config.ts +2 -1
- data/docs/guide/engines.md +117 -0
- data/docs/reference-facade.md +130 -0
- data/ext/fontisan_cdylib/Cargo.lock +313 -0
- data/ext/fontisan_cdylib/Cargo.toml +18 -0
- data/ext/fontisan_cdylib/build.rs +22 -0
- data/ext/fontisan_cdylib/extconf.rb +36 -0
- data/ext/fontisan_cdylib/src/lib.rs +13 -0
- data/ext/fontisan_native/Cargo.toml +21 -0
- data/ext/fontisan_native/build.rs +22 -0
- data/ext/fontisan_native/clippy.toml +10 -0
- data/ext/fontisan_native/extconf.rb +59 -0
- data/ext/fontisan_native/src/lib.rs +463 -0
- data/lib/fontisan/audit/checks/cmap_check.rb +7 -0
- data/lib/fontisan/audit/checks/hinting_check.rb +1 -1
- data/lib/fontisan/audit/checks/maxp_check.rb +1 -1
- data/lib/fontisan/audit/checks/opentype_conformance_check.rb +5 -2
- data/lib/fontisan/base_collection.rb +30 -0
- data/lib/fontisan/byte_ops.rb +219 -0
- data/lib/fontisan/cli.rb +5 -5
- data/lib/fontisan/commands/audit_command.rb +38 -43
- data/lib/fontisan/commands/convert_command.rb +53 -0
- data/lib/fontisan/commands/instance_command.rb +75 -0
- data/lib/fontisan/commands/subset_command.rb +43 -0
- data/lib/fontisan/commands/unpack_command.rb +56 -0
- data/lib/fontisan/converters/cff_table_builder.rb +1 -1
- data/lib/fontisan/converters/collection_converter.rb +13 -5
- data/lib/fontisan/converters/format_converter.rb +1 -1
- data/lib/fontisan/converters/woff2_encoder.rb +8 -6
- data/lib/fontisan/converters/woff_writer.rb +6 -5
- data/lib/fontisan/engine.rb +350 -0
- data/lib/fontisan/ffi/client.rb +121 -0
- data/lib/fontisan/ffi.rb +343 -0
- data/lib/fontisan/ffi_platform.rb +24 -0
- data/lib/fontisan/font_loader.rb +5 -1
- data/lib/fontisan/font_writer.rb +13 -1
- data/lib/fontisan/native.rb +231 -0
- data/lib/fontisan/reference/table_digests.rb +251 -0
- data/lib/fontisan/reference.rb +619 -0
- data/lib/fontisan/scan.rb +372 -0
- data/lib/fontisan/sfnt_directory.rb +41 -0
- data/lib/fontisan/sfnt_font.rb +16 -6
- data/lib/fontisan/subset/builder.rb +2 -1
- data/lib/fontisan/tables/cff/charstring.rb +16 -0
- data/lib/fontisan/tables/cff.rb +15 -5
- data/lib/fontisan/tables/cmap.rb +11 -1
- data/lib/fontisan/tables/gpos.rb +7 -0
- data/lib/fontisan/tables/gsub.rb +7 -0
- data/lib/fontisan/tables/layout_common.rb +172 -0
- data/lib/fontisan/tables/name.rb +10 -4
- data/lib/fontisan/utilities/brotli_wrapper.rb +39 -4
- data/lib/fontisan/validators/opentype_validator.rb +2 -0
- data/lib/fontisan/variable/instancer.rb +1 -0
- data/lib/fontisan/variable/static_font_builder.rb +4 -1
- data/lib/fontisan/variable/table_updater.rb +4 -2
- data/lib/fontisan/variation/instancing/true_type_instancer.rb +14 -6
- data/lib/fontisan/version.rb +1 -1
- data/lib/fontisan/woff2/collection_decoder.rb +15 -1
- data/lib/fontisan/woff2/collection_encoder.rb +29 -6
- data/lib/fontisan/woff2/encoder_rules.rb +12 -4
- data/lib/fontisan/woff2/glyf_loca_reconstruct.rb +1 -1
- data/lib/fontisan/woff2_collection.rb +7 -5
- data/lib/fontisan/woff2_font.rb +12 -3
- data/lib/fontisan/woff_container.rb +83 -0
- data/lib/fontisan/woff_font.rb +24 -20
- data/lib/fontisan.rb +24 -1
- data/rakelib/bench.rake +9 -0
- data/rakelib/cdylib.rake +39 -0
- data/rakelib/corpus.rake +272 -0
- data/rakelib/corpus_convert.rake +43 -0
- data/rakelib/corpus_goldens.rake +22 -0
- data/rakelib/corpus_malformed.rake +20 -0
- data/rakelib/corpus_scan.rake +24 -0
- data/rakelib/java.rake +61 -0
- data/rakelib/native.rake +43 -0
- data/scripts/build-woff2.sh +57 -0
- data/scripts/corpus/generate_convert_goldens.rb +478 -0
- data/scripts/corpus/generate_goldens.rb +282 -0
- data/scripts/corpus/generate_malformed_goldens.rb +254 -0
- data/scripts/corpus/generate_scan_goldens.rb +98 -0
- data/scripts/tier_fallback.rb +219 -0
- data/scripts/tier_smoke.rb +64 -0
- 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
|
data/lib/fontisan/woff_font.rb
CHANGED
|
@@ -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
|
-
|
|
222
|
-
Zlib::Inflate.inflate(compressed_data)
|
|
224
|
+
inflate_table(compressed_data, entry.orig_length)
|
|
223
225
|
end
|
|
224
226
|
|
|
225
|
-
|
|
226
|
-
|
|
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
|
-
"
|
|
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
|
-
|
|
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
|
-
|
|
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
|
data/rakelib/bench.rake
ADDED
|
@@ -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
|
data/rakelib/cdylib.rake
ADDED
|
@@ -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
|
data/rakelib/corpus.rake
ADDED
|
@@ -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
|