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,619 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Fontisan
|
|
7
|
+
# The reference facade: one path-based entry point per engine operation.
|
|
8
|
+
#
|
|
9
|
+
# Every golden is generated through this module and every engine tier
|
|
10
|
+
# (Ruby, native, FFI) serves the same operations, so the signatures, return
|
|
11
|
+
# schemas and error surfaces here are the contract. Operations take a file
|
|
12
|
+
# path, never a loaded font object. They wrap existing classes and add no
|
|
13
|
+
# behaviour except three deliberate additions:
|
|
14
|
+
#
|
|
15
|
+
# 1. An optional `timestamp:` (Unix seconds) reaches the places Ruby stamps
|
|
16
|
+
# `head.modified` from the clock (WOFF2 encode, and StaticFontBuilder).
|
|
17
|
+
# nil keeps today's behaviour; operations that do not stamp ignore it.
|
|
18
|
+
# 2. {.subset} copies fontist's `glyph_ids` step (cmap -> uniq -> sort and
|
|
19
|
+
# its "None of the requested characters exist in <name>" error) before
|
|
20
|
+
# calling `Subset::Builder` with the web profile.
|
|
21
|
+
# 3. {.convert} runs `Fontisan.convert` quietly (ConvertCommand prints to
|
|
22
|
+
# stdout otherwise).
|
|
23
|
+
#
|
|
24
|
+
# Read-type results ({.read}, {.audit}, {.audit_dir}, {.scan}) are plain
|
|
25
|
+
# JSON-ready data: Hash with String keys sorted bytewise, Array, String,
|
|
26
|
+
# Integer, true, false, nil. They never contain a Float (Fixed 16.16 values
|
|
27
|
+
# are raw integers) or a clock value. See docs/reference-facade.md or
|
|
28
|
+
# REFERENCE-FACADE.md at the repository root.
|
|
29
|
+
#
|
|
30
|
+
# Errors: operations let the wrapped class's exception propagate unchanged
|
|
31
|
+
# (Errno::ENOENT, Fontisan::InvalidFontError, Fontisan::Error
|
|
32
|
+
# "Conversion failed: ...", ArgumentError for bad options, ...). {.scan}
|
|
33
|
+
# and {.audit_dir} never raise for a bad font; they record the failure in
|
|
34
|
+
# the row.
|
|
35
|
+
module Reference
|
|
36
|
+
FONT_EXTENSIONS = %w[.ttf .otf .ttc .otc .woff .woff2 .dfont .pfa .pfb].freeze
|
|
37
|
+
|
|
38
|
+
# Tables whose parsed header fields are included in {.read}.
|
|
39
|
+
SNAPSHOT_TABLES = {
|
|
40
|
+
"head" => :head, "hhea" => :hhea, "maxp" => :maxp, "OS/2" => :os2
|
|
41
|
+
}.freeze
|
|
42
|
+
|
|
43
|
+
FIXED_ONE = 65_536
|
|
44
|
+
|
|
45
|
+
VARIATION_DIGESTS = {
|
|
46
|
+
"gvar" => :gvar, "HVAR" => :hvar, "VVAR" => :hvar, "MVAR" => :mvar, "STAT" => :stat
|
|
47
|
+
}.freeze
|
|
48
|
+
|
|
49
|
+
POST_FIELDS = %i[version_raw italic_angle_raw underline_position underline_thickness
|
|
50
|
+
is_fixed_pitch min_mem_type42 max_mem_type42 min_mem_type1
|
|
51
|
+
max_mem_type1].freeze
|
|
52
|
+
|
|
53
|
+
autoload :TableDigests, "fontisan/reference/table_digests"
|
|
54
|
+
|
|
55
|
+
class << self
|
|
56
|
+
# Read the structure of every face in a font file.
|
|
57
|
+
#
|
|
58
|
+
# @param path [String]
|
|
59
|
+
# @param mode [Symbol] :metadata or :full (FontLoader loading mode)
|
|
60
|
+
# @param lazy [Boolean] FontLoader lazy flag
|
|
61
|
+
# @return [Array<Hash>] one Hash per face (see docs for the schema)
|
|
62
|
+
def read(path, mode: :metadata, lazy: true)
|
|
63
|
+
path = path_arg(path)
|
|
64
|
+
sha = Digest::SHA256.file(path).hexdigest
|
|
65
|
+
format = FontLoader.detect_format(path)&.to_s
|
|
66
|
+
count = face_count(path)
|
|
67
|
+
faces = Array.new(count) do |index|
|
|
68
|
+
font = FontLoader.load(path, font_index: index, mode: mode, lazy: lazy)
|
|
69
|
+
begin
|
|
70
|
+
read_face(font, index, sha, format, mode, lazy)
|
|
71
|
+
ensure
|
|
72
|
+
font.close if font.respond_to?(:close)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
normalize(faces)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Per-glyph outline hashes of one face (full mode).
|
|
79
|
+
#
|
|
80
|
+
# glyf fonts: SHA-256 of the glyph's byte range in `glyf`, located by
|
|
81
|
+
# the parsed `loca`. CFF/CFF2 fonts: SHA-256 of the glyph's
|
|
82
|
+
# CharString bytes from the CharStrings INDEX. Empty glyphs hash the
|
|
83
|
+
# empty string.
|
|
84
|
+
#
|
|
85
|
+
# @return [Hash] `{"kind"=>"glyf"|"cff"|nil, "hashes"=>[hex, ...]}`
|
|
86
|
+
def outline_hashes(path, index: 0)
|
|
87
|
+
path = path_arg(path)
|
|
88
|
+
font = FontLoader.load(path, font_index: index, mode: :full,
|
|
89
|
+
lazy: true)
|
|
90
|
+
begin
|
|
91
|
+
normalize(outline_data(font))
|
|
92
|
+
ensure
|
|
93
|
+
font.close if font.respond_to?(:close)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Structured audit of every face, without clock or version fields.
|
|
98
|
+
#
|
|
99
|
+
# @param path [String]
|
|
100
|
+
# @param validate [Boolean, Symbol] false (no checks), true (default
|
|
101
|
+
# profile) or a profile name
|
|
102
|
+
# @return [Array<Hash>] one canonical report per face
|
|
103
|
+
def audit(path, validate: false)
|
|
104
|
+
path = path_arg(path)
|
|
105
|
+
result = Commands::AuditCommand.new(
|
|
106
|
+
path, validate: validate || false, include_codepoints: false
|
|
107
|
+
).run
|
|
108
|
+
normalize(Array(result).map { |report| audit_hash(report) })
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Audit every font file below a directory.
|
|
112
|
+
#
|
|
113
|
+
# @param dir [String]
|
|
114
|
+
# @param validate [Boolean, Symbol] as for {.audit}
|
|
115
|
+
# @return [Array<Hash>] one entry per file, sorted by relative path:
|
|
116
|
+
# `{"path"=>rel, "faces"=>[...]}` or `{"path"=>rel, "error"=>{...}}`
|
|
117
|
+
def audit_dir(dir, validate: false)
|
|
118
|
+
dir = path_arg(dir)
|
|
119
|
+
files = font_files(dir)
|
|
120
|
+
entries = files.map do |file|
|
|
121
|
+
rel = relative_row_path(dir, file)
|
|
122
|
+
begin
|
|
123
|
+
{ path: rel, faces: audit(file, validate: validate) }
|
|
124
|
+
rescue StandardError => e
|
|
125
|
+
{ path: rel, error: { class: e.class.name, message: e.message } }
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
normalize(entries)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# The path of an audit_dir row relative to +dir+, with "/" separators. An
|
|
132
|
+
# engine tier on Windows reports "D:/a/dir\\file.ttf" (the directory as
|
|
133
|
+
# given, then a native separator), so both sides are normalised before the
|
|
134
|
+
# prefix is removed; elsewhere a backslash is an ordinary file name byte.
|
|
135
|
+
#
|
|
136
|
+
# @param dir [String]
|
|
137
|
+
# @param path [String] the row's path, below +dir+
|
|
138
|
+
# @param alt_separator [String, nil] the platform's second separator
|
|
139
|
+
# @return [String]
|
|
140
|
+
def relative_row_path(dir, path, alt_separator: File::ALT_SEPARATOR)
|
|
141
|
+
if alt_separator
|
|
142
|
+
dir = dir.tr(alt_separator, "/")
|
|
143
|
+
path = path.tr(alt_separator, "/")
|
|
144
|
+
end
|
|
145
|
+
dir = dir.delete_suffix("/") while dir.end_with?("/")
|
|
146
|
+
prefix = "#{dir}/"
|
|
147
|
+
return path unless path.start_with?(prefix)
|
|
148
|
+
|
|
149
|
+
# A glob over "dir//" yields "dir//a.ttf": drop every separator.
|
|
150
|
+
path.delete_prefix(prefix).sub(%r{\A/+}, "")
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# One row per font face for every path (see {Fontisan::Scan}).
|
|
154
|
+
#
|
|
155
|
+
# @param paths [Array<String>]
|
|
156
|
+
# @return [Array<Hash>] rows in input order; never raises for a bad font
|
|
157
|
+
def scan(paths)
|
|
158
|
+
paths = Array(paths).map { |p| path_arg(p, must_exist: false) }
|
|
159
|
+
normalize(Scan.call(paths))
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Convert a font file to another format.
|
|
163
|
+
#
|
|
164
|
+
# @param path [String]
|
|
165
|
+
# @param to [Symbol, String] target format (:ttf, :otf, :woff, :woff2,
|
|
166
|
+
# :ttc, ...); a single format
|
|
167
|
+
# @param output [String] output path
|
|
168
|
+
# @param timestamp [Integer, Time, nil] fixed head.modified for WOFF2
|
|
169
|
+
# output; ignored for other targets
|
|
170
|
+
# @return [Hash] the ConvertCommand result (success, input_path,
|
|
171
|
+
# output_path, source_format, target_format, input_size, output_size,
|
|
172
|
+
# variation_strategy | num_fonts)
|
|
173
|
+
# @raise [Fontisan::Error] "Conversion failed: ..."
|
|
174
|
+
def convert(path, output:, timestamp: nil, **options)
|
|
175
|
+
path = path_arg(path)
|
|
176
|
+
target = options.fetch(:to)
|
|
177
|
+
opts = { quiet: true, **options.except(:to) }
|
|
178
|
+
opts[:timestamp] = unix_seconds(timestamp) if stamps?(target, timestamp)
|
|
179
|
+
# The command itself, not Fontisan.convert: that one dispatches to the
|
|
180
|
+
# native tier and would loop back through the gate.
|
|
181
|
+
Commands::ConvertCommand.new(path, { to: target, output: output, **opts }).run
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Extract one member of a collection as a standalone font.
|
|
185
|
+
#
|
|
186
|
+
# @return [String] path of the extracted file (as UnpackCommand reports)
|
|
187
|
+
def extract(path, index:, output_dir:)
|
|
188
|
+
path = path_arg(path)
|
|
189
|
+
Commands::UnpackCommand.new(
|
|
190
|
+
path, output_dir: output_dir, font_index: index
|
|
191
|
+
).run[:extracted_files].first
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Static instance of a variable font.
|
|
195
|
+
#
|
|
196
|
+
# @param coords [Hash{String=>Numeric}] axis tag => value
|
|
197
|
+
# @param timestamp [Integer, Time, nil] accepted for uniformity;
|
|
198
|
+
# Instancer does not stamp, so it is ignored
|
|
199
|
+
# @return [String] binary font bytes
|
|
200
|
+
def instance(path, coords:, timestamp: nil)
|
|
201
|
+
_ = timestamp
|
|
202
|
+
font = FontLoader.load(path_arg(path), mode: :full, lazy: false)
|
|
203
|
+
values = coords.to_h { |tag, value| [tag.to_s, Float(value)] }
|
|
204
|
+
Variable::Instancer.new(font).instance(values)
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Glyph ids kept by a subset request (fontist's `glyph_ids`).
|
|
208
|
+
#
|
|
209
|
+
# @param unicodes [Array<Integer>, nil] codepoints
|
|
210
|
+
# @param text [String, nil] characters to keep
|
|
211
|
+
# @return [Array<Integer>] sorted, unique glyph ids
|
|
212
|
+
# @raise [Fontisan::Error] "None of the requested characters exist in
|
|
213
|
+
# <basename>"
|
|
214
|
+
def glyph_ids(path, unicodes: nil, text: nil)
|
|
215
|
+
path = path_arg(path)
|
|
216
|
+
font = FontLoader.load(path, mode: :full, lazy: false)
|
|
217
|
+
codepoints = Array(unicodes).map(&:to_i) + text.to_s.codepoints
|
|
218
|
+
gids_for(font, codepoints, File.basename(path))
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# Subset a font to the glyphs of the given characters.
|
|
222
|
+
#
|
|
223
|
+
# @param profile [String] subset profile (default "web")
|
|
224
|
+
# @param timestamp [Integer, Time, nil] ignored: Subset::Builder keeps
|
|
225
|
+
# the source head.modified
|
|
226
|
+
# @return [String] binary font bytes
|
|
227
|
+
def subset(path, unicodes: nil, text: nil, profile: "web", timestamp: nil)
|
|
228
|
+
_ = timestamp
|
|
229
|
+
path = path_arg(path)
|
|
230
|
+
font = FontLoader.load(path, mode: :full, lazy: false)
|
|
231
|
+
codepoints = Array(unicodes).map(&:to_i) + text.to_s.codepoints
|
|
232
|
+
gids = gids_for(font, codepoints, File.basename(path))
|
|
233
|
+
options = Subset::Options.new(profile: profile)
|
|
234
|
+
Subset::Builder.new(font, gids, options).build
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# Canonical JSON text of a read-type result: sorted keys, two-space
|
|
238
|
+
# indent, trailing newline.
|
|
239
|
+
def canonical_json(data)
|
|
240
|
+
"#{JSON.pretty_generate(normalize(data))}\n"
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# Recursively convert to JSON-ready data with sorted String keys.
|
|
244
|
+
# Floats are rejected: Fixed values must be stored as raw integers.
|
|
245
|
+
def normalize(value)
|
|
246
|
+
case value
|
|
247
|
+
when Hash
|
|
248
|
+
value.map { |k, v| [k.to_s, normalize(v)] }.sort_by(&:first).to_h
|
|
249
|
+
when Array then value.map { |v| normalize(v) }
|
|
250
|
+
when Symbol then value.to_s
|
|
251
|
+
when BinData::Base then normalize(value.snapshot)
|
|
252
|
+
when Float
|
|
253
|
+
raise ArgumentError, "Float #{value} in canonical output"
|
|
254
|
+
else value
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
private
|
|
259
|
+
|
|
260
|
+
def path_arg(path, must_exist: true)
|
|
261
|
+
unless path.is_a?(String) || path.respond_to?(:to_path)
|
|
262
|
+
raise TypeError,
|
|
263
|
+
"Reference operations take a file path, got #{path.class}"
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
path = File.path(path)
|
|
267
|
+
raise Errno::ENOENT, path if must_exist && !File.exist?(path)
|
|
268
|
+
|
|
269
|
+
path
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def face_count(path)
|
|
273
|
+
FontLoader.collection?(path) ? FontLoader.load_collection(path).num_fonts : 1
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def font_files(dir)
|
|
277
|
+
Dir.glob(File.join(dir, "**", "*"))
|
|
278
|
+
.select { |f| File.file?(f) && FONT_EXTENSIONS.include?(File.extname(f).downcase) }
|
|
279
|
+
.sort
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def stamps?(target, timestamp)
|
|
283
|
+
!timestamp.nil? && target.to_s == "woff2"
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def unix_seconds(timestamp)
|
|
287
|
+
timestamp.respond_to?(:to_time) ? timestamp.to_time.to_i : Integer(timestamp)
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def gids_for(font, codepoints, name)
|
|
291
|
+
cmap = font.table("cmap") or
|
|
292
|
+
raise Fontisan::MissingTableError,
|
|
293
|
+
"Required table 'cmap' not found in font"
|
|
294
|
+
mappings = cmap.unicode_mappings
|
|
295
|
+
gids = codepoints.uniq.filter_map { |cp| mappings[cp] }.uniq.sort
|
|
296
|
+
return gids if gids.any?
|
|
297
|
+
|
|
298
|
+
raise Fontisan::Error, "None of the requested characters exist in #{name}"
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
# ---- audit ---------------------------------------------------------
|
|
302
|
+
|
|
303
|
+
def audit_hash(report)
|
|
304
|
+
data = JSON.parse(report.to_json)
|
|
305
|
+
data.delete("generated_at")
|
|
306
|
+
data.delete("fontisan_version")
|
|
307
|
+
data["font_revision_raw"] = fixed_raw(data.delete("font_revision"))
|
|
308
|
+
data["axes"] = Array(data["axes"]).map { |axis| axis_raw(axis) }
|
|
309
|
+
mask_addresses(data)
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
# Messages of checks that crash embed `#<Class:0x000...>`; the address
|
|
313
|
+
# differs per run, so it is dropped: `#<Class>`.
|
|
314
|
+
def mask_addresses(value)
|
|
315
|
+
case value
|
|
316
|
+
when String then value.gsub(/#<([\w:]+):0x\h+>/, '#<\\1>')
|
|
317
|
+
when Array then value.map { |v| mask_addresses(v) }
|
|
318
|
+
when Hash then value.transform_values { |v| mask_addresses(v) }
|
|
319
|
+
else value
|
|
320
|
+
end
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
def axis_raw(axis)
|
|
324
|
+
%w[min_value default_value max_value].each_with_object(axis) do |key, h|
|
|
325
|
+
h["#{key}_raw"] = fixed_raw(h.delete(key))
|
|
326
|
+
end
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
def fixed_raw(value)
|
|
330
|
+
value.nil? ? nil : (value * FIXED_ONE).round
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
# ---- read ----------------------------------------------------------
|
|
334
|
+
|
|
335
|
+
def read_face(font, index, sha, format, mode, lazy)
|
|
336
|
+
{
|
|
337
|
+
index: index,
|
|
338
|
+
format: format,
|
|
339
|
+
mode: mode.to_s,
|
|
340
|
+
lazy: lazy ? true : false,
|
|
341
|
+
file_sha256: sha,
|
|
342
|
+
tables: table_digests(font),
|
|
343
|
+
names: name_records(font),
|
|
344
|
+
header_fields: header_fields(font),
|
|
345
|
+
cmap: cmap_digest(font),
|
|
346
|
+
fvar: fvar_data(font),
|
|
347
|
+
outlines: outline_summary(font),
|
|
348
|
+
hmtx_metrics: hmtx_metrics(font),
|
|
349
|
+
post: post_data(font),
|
|
350
|
+
layout: layout_data(font),
|
|
351
|
+
variation: variation_data(font),
|
|
352
|
+
}
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
# Digest of the per-glyph horizontal metrics as Tables::Hmtx#metric_for
|
|
356
|
+
# decodes them (advance and lsb of every glyph). Raw-byte digests
|
|
357
|
+
# (`tables`) cannot see a decoder bug and no convert golden reads hmtx,
|
|
358
|
+
# so this is the only golden that does. (Parsed glyf coordinates are
|
|
359
|
+
# covered by the convert goldens, which rebuild outlines in ttf -> otf.)
|
|
360
|
+
# nil when hmtx is absent or gated out by the loading mode; `{error:
|
|
361
|
+
# class}` when parsing fails.
|
|
362
|
+
def hmtx_metrics(font)
|
|
363
|
+
return nil unless available_table(font, "hmtx") && available_table(font, "hhea") && available_table(font, "maxp")
|
|
364
|
+
|
|
365
|
+
hmtx = font.table("hmtx")
|
|
366
|
+
hhea = font.table("hhea")
|
|
367
|
+
num_glyphs = font.table("maxp").num_glyphs
|
|
368
|
+
hmtx.parse_with_context(hhea.number_of_h_metrics, num_glyphs) unless hmtx.parsed?
|
|
369
|
+
packed = Array.new(num_glyphs) do |gid|
|
|
370
|
+
metric = hmtx.metric_for(gid)
|
|
371
|
+
[metric[:advance_width], metric[:lsb]].pack("Nl>")
|
|
372
|
+
end
|
|
373
|
+
{ count: num_glyphs, sha256: Digest::SHA256.hexdigest(packed.join) }
|
|
374
|
+
rescue StandardError => e
|
|
375
|
+
{ error: e.class.name }
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
# post header fields plus a count and digest of the glyph names (names
|
|
379
|
+
# joined with "\n"); a failure to read the names is recorded, not raised.
|
|
380
|
+
def post_data(font)
|
|
381
|
+
table = available_table(font, "post")
|
|
382
|
+
return nil unless table
|
|
383
|
+
|
|
384
|
+
data = POST_FIELDS.to_h { |f| [f, table.public_send(f).to_i] }
|
|
385
|
+
begin
|
|
386
|
+
names = table.glyph_names
|
|
387
|
+
data[:glyph_name_count] = names.size
|
|
388
|
+
data[:glyph_names_sha256] = Digest::SHA256.hexdigest(names.join("\n"))
|
|
389
|
+
rescue StandardError => e
|
|
390
|
+
data[:glyph_names_error] = e.class.name
|
|
391
|
+
end
|
|
392
|
+
data
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
# GSUB/GPOS script tags and, per script, feature tags, as the table
|
|
396
|
+
# classes report them (they swallow parse errors and answer []).
|
|
397
|
+
def layout_data(font)
|
|
398
|
+
%w[GSUB GPOS].to_h do |tag|
|
|
399
|
+
table = available_table(font, tag)
|
|
400
|
+
next [tag, nil] unless table
|
|
401
|
+
|
|
402
|
+
scripts = table.scripts
|
|
403
|
+
by_script = table.script_features
|
|
404
|
+
[tag, {
|
|
405
|
+
scripts: scripts,
|
|
406
|
+
features: scripts.to_h { |s| [s, by_script.fetch(s, [])] },
|
|
407
|
+
}]
|
|
408
|
+
end
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
# Parsed digests of the variation tables, from the raw table bytes
|
|
412
|
+
# (see {TableDigests}). A table that cannot be read records the error.
|
|
413
|
+
def variation_data(font)
|
|
414
|
+
VARIATION_DIGESTS.to_h do |tag, digest|
|
|
415
|
+
[tag, digest_table(font, tag, digest)]
|
|
416
|
+
end
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
def digest_table(font, tag, digest)
|
|
420
|
+
return nil unless font.has_table?(tag)
|
|
421
|
+
|
|
422
|
+
font.table(tag)
|
|
423
|
+
raw = raw_table(font, tag)
|
|
424
|
+
raw ? TableDigests.public_send(digest, raw) : nil
|
|
425
|
+
rescue ArgumentError
|
|
426
|
+
nil
|
|
427
|
+
rescue StandardError => e
|
|
428
|
+
{ error: e.class.name }
|
|
429
|
+
end
|
|
430
|
+
|
|
431
|
+
# Digest of every table the loading mode makes available. A table that
|
|
432
|
+
# the mode gates out has a nil digest; a table that fails to load or
|
|
433
|
+
# parse records the error class (Ruby fails lazily, on first access).
|
|
434
|
+
def table_digests(font)
|
|
435
|
+
font.table_names.map do |tag|
|
|
436
|
+
entry = { tag: tag, length: nil, sha256: nil, error: nil }
|
|
437
|
+
begin
|
|
438
|
+
font.table(tag)
|
|
439
|
+
raw = raw_table(font, tag)
|
|
440
|
+
if raw
|
|
441
|
+
entry[:length] = raw.bytesize
|
|
442
|
+
entry[:sha256] = Digest::SHA256.hexdigest(raw)
|
|
443
|
+
end
|
|
444
|
+
rescue ArgumentError
|
|
445
|
+
entry[:error] = nil # gated by the loading mode
|
|
446
|
+
rescue StandardError => e
|
|
447
|
+
entry[:error] = e.class.name
|
|
448
|
+
end
|
|
449
|
+
entry
|
|
450
|
+
end
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
def raw_table(font, tag)
|
|
454
|
+
if font.method(:table_data).arity.zero?
|
|
455
|
+
font.table_data[tag]
|
|
456
|
+
else
|
|
457
|
+
font.table_data(tag)
|
|
458
|
+
end
|
|
459
|
+
end
|
|
460
|
+
|
|
461
|
+
def name_records(font)
|
|
462
|
+
table = available_table(font, "name")
|
|
463
|
+
return [] unless table
|
|
464
|
+
|
|
465
|
+
table.name_records.map do |record|
|
|
466
|
+
{
|
|
467
|
+
platform_id: record.platform_id.to_i,
|
|
468
|
+
encoding_id: record.encoding_id.to_i,
|
|
469
|
+
language_id: record.language_id.to_i,
|
|
470
|
+
name_id: record.name_id.to_i,
|
|
471
|
+
string: decode_name(table, record),
|
|
472
|
+
}
|
|
473
|
+
end
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
# Same decoding rules as fontist's decode_name_record_manually.
|
|
477
|
+
def decode_name(table, record)
|
|
478
|
+
storage = table.string_storage.to_s.b
|
|
479
|
+
length = record.string_length
|
|
480
|
+
return nil if record.string_offset + length > storage.bytesize || length.zero?
|
|
481
|
+
|
|
482
|
+
data = storage.byteslice(record.string_offset, length)
|
|
483
|
+
case record.platform_id.to_i
|
|
484
|
+
when 0, 3
|
|
485
|
+
data.dup.force_encoding("UTF-16BE")
|
|
486
|
+
.encode("UTF-8", invalid: :replace, undef: :replace)
|
|
487
|
+
when 1
|
|
488
|
+
data.dup.force_encoding("ASCII-8BIT")
|
|
489
|
+
.encode("UTF-8", invalid: :replace, undef: :replace)
|
|
490
|
+
else
|
|
491
|
+
data.dup.force_encoding("UTF-8").scrub
|
|
492
|
+
end
|
|
493
|
+
end
|
|
494
|
+
|
|
495
|
+
def header_fields(font)
|
|
496
|
+
SNAPSHOT_TABLES.keys.each_with_object({}) do |tag, out|
|
|
497
|
+
table = available_table(font, tag)
|
|
498
|
+
out[tag] = table ? snapshot(table) : nil
|
|
499
|
+
end
|
|
500
|
+
end
|
|
501
|
+
|
|
502
|
+
def snapshot(table)
|
|
503
|
+
table.snapshot.transform_values { |v| snapshot_value(v) }
|
|
504
|
+
end
|
|
505
|
+
|
|
506
|
+
# Strings that are printable ASCII (tags, vendor ids) stay text; any
|
|
507
|
+
# other String is "0x" + hex.
|
|
508
|
+
def snapshot_value(value)
|
|
509
|
+
case value
|
|
510
|
+
when String
|
|
511
|
+
value.b.match?(/\A[\x20-\x7e]*\z/n) ? value : "0x#{value.b.unpack1('H*')}"
|
|
512
|
+
when Array then value.map { |v| snapshot_value(v) }
|
|
513
|
+
when Hash then value.transform_values { |v| snapshot_value(v) }
|
|
514
|
+
else value
|
|
515
|
+
end
|
|
516
|
+
end
|
|
517
|
+
|
|
518
|
+
# Count and digest of the sorted codepoints: SHA-256 over each
|
|
519
|
+
# codepoint as a big-endian uint32, ascending.
|
|
520
|
+
def cmap_digest(font)
|
|
521
|
+
table = available_table(font, "cmap")
|
|
522
|
+
return nil unless table
|
|
523
|
+
|
|
524
|
+
codepoints = (table.unicode_mappings || {}).keys.sort
|
|
525
|
+
{
|
|
526
|
+
count: codepoints.size,
|
|
527
|
+
sha256: Digest::SHA256.hexdigest(codepoints.pack("N*")),
|
|
528
|
+
subtable_formats: table.subtable_formats,
|
|
529
|
+
}
|
|
530
|
+
end
|
|
531
|
+
|
|
532
|
+
def fvar_data(font)
|
|
533
|
+
table = available_table(font, "fvar")
|
|
534
|
+
return nil unless table
|
|
535
|
+
|
|
536
|
+
{
|
|
537
|
+
axes: table.axes.map { |axis| snapshot(axis) },
|
|
538
|
+
instances: table.instances.compact.map { |inst| instance_data(inst) },
|
|
539
|
+
}
|
|
540
|
+
end
|
|
541
|
+
|
|
542
|
+
def instance_data(inst)
|
|
543
|
+
{
|
|
544
|
+
name_id: inst[:name_id],
|
|
545
|
+
flags: inst[:flags],
|
|
546
|
+
postscript_name_id: inst[:postscript_name_id],
|
|
547
|
+
coordinates_raw: Array(inst[:coordinates]).map { |c| (c * FIXED_ONE).round },
|
|
548
|
+
}
|
|
549
|
+
end
|
|
550
|
+
|
|
551
|
+
def outline_summary(font)
|
|
552
|
+
data = outline_data(font)
|
|
553
|
+
return { kind: nil, count: 0, digest: nil } if data[:kind].nil?
|
|
554
|
+
|
|
555
|
+
hashes = data[:hashes]
|
|
556
|
+
{
|
|
557
|
+
kind: data[:kind],
|
|
558
|
+
count: hashes.size,
|
|
559
|
+
digest: Digest::SHA256.hexdigest(hashes.map { |h| [h].pack("H*") }.join),
|
|
560
|
+
}
|
|
561
|
+
rescue StandardError => e
|
|
562
|
+
{ kind: nil, count: 0, digest: nil, error: e.class.name }
|
|
563
|
+
end
|
|
564
|
+
|
|
565
|
+
def outline_data(font)
|
|
566
|
+
if available_table(font, "glyf") && available_table(font, "loca")
|
|
567
|
+
{ kind: "glyf", hashes: glyf_hashes(font) }
|
|
568
|
+
elsif (cff = cff_table(font))
|
|
569
|
+
{ kind: "cff", hashes: cff_hashes(cff) }
|
|
570
|
+
elsif font.has_table?("CFF2") && (raw = raw_table(font, "CFF2"))
|
|
571
|
+
{ kind: "cff2", hashes: TableDigests.cff2_charstrings(raw).map { |c| Digest::SHA256.hexdigest(c) } }
|
|
572
|
+
else
|
|
573
|
+
{ kind: nil, hashes: [] }
|
|
574
|
+
end
|
|
575
|
+
end
|
|
576
|
+
|
|
577
|
+
def glyf_hashes(font)
|
|
578
|
+
glyf = raw_table(font, "glyf")
|
|
579
|
+
loca = font.table("loca")
|
|
580
|
+
head = font.table("head")
|
|
581
|
+
maxp = font.table("maxp")
|
|
582
|
+
loca.parse_with_context(head.index_to_loc_format, maxp.num_glyphs) unless loca.parsed?
|
|
583
|
+
Array.new(maxp.num_glyphs) do |gid|
|
|
584
|
+
bytes = glyf.byteslice(loca.offset_for(gid), loca.size_of(gid)) || ""
|
|
585
|
+
Digest::SHA256.hexdigest(bytes)
|
|
586
|
+
end
|
|
587
|
+
end
|
|
588
|
+
|
|
589
|
+
# WoffFont#table returns nil for "CFF " (measured), so parse the raw
|
|
590
|
+
# table when the font class gives no parsed one.
|
|
591
|
+
def cff_table(font)
|
|
592
|
+
parsed = available_table(font, "CFF ")
|
|
593
|
+
return parsed if parsed
|
|
594
|
+
|
|
595
|
+
raw = font.has_table?("CFF ") && raw_table(font, "CFF ")
|
|
596
|
+
raw ? Tables::Cff.read(raw) : nil
|
|
597
|
+
end
|
|
598
|
+
|
|
599
|
+
def cff_hashes(cff)
|
|
600
|
+
index = cff.charstrings_index
|
|
601
|
+
return [] unless index
|
|
602
|
+
|
|
603
|
+
Array.new(index.count) { |gid| Digest::SHA256.hexdigest(index[gid]) }
|
|
604
|
+
end
|
|
605
|
+
|
|
606
|
+
# The parsed table, or nil when the font lacks it or the loading mode
|
|
607
|
+
# gates it out.
|
|
608
|
+
def available_table(font, tag)
|
|
609
|
+
return nil unless font.has_table?(tag)
|
|
610
|
+
|
|
611
|
+
font.table(tag)
|
|
612
|
+
rescue ArgumentError
|
|
613
|
+
nil
|
|
614
|
+
end
|
|
615
|
+
end
|
|
616
|
+
end
|
|
617
|
+
end
|
|
618
|
+
|
|
619
|
+
Fontisan::Engine.install(Fontisan::Reference)
|