fontisan 0.4.46 → 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/pipeline/transformation_pipeline.rb +10 -8
- data/lib/fontisan/pipeline.rb +1 -1
- 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/glyf.rb +4 -6
- 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 +15 -3
- data/lib/fontisan/variable/instancer.rb +23 -192
- data/lib/fontisan/variable/static_font_builder.rb +4 -1
- data/lib/fontisan/variable/table_updater.rb +4 -2
- data/lib/fontisan/variation/instance_generator.rb +3 -149
- data/lib/fontisan/variation/instancing/axis_space.rb +136 -0
- data/lib/fontisan/variation/instancing/glyph_outline.rb +204 -0
- data/lib/fontisan/variation/instancing/gvar_reader.rb +146 -0
- data/lib/fontisan/variation/instancing/item_variation_store.rb +92 -0
- data/lib/fontisan/variation/instancing/name_rewriter.rb +90 -0
- data/lib/fontisan/variation/instancing/true_type_instancer.rb +326 -0
- data/lib/fontisan/variation/instancing.rb +20 -0
- data/lib/fontisan/variation/variable_svg_generator.rb +19 -1
- data/lib/fontisan/variation.rb +1 -0
- 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 +75 -3
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
module Fontisan
|
|
6
|
+
# Builds the scan rows served by {Fontisan::Reference.scan}: one row per font
|
|
7
|
+
# face, carrying everything fontist's `FontFile`, `CollectionFile` and
|
|
8
|
+
# indexability check read from a font, so the same rows can be produced by
|
|
9
|
+
# any engine tier.
|
|
10
|
+
#
|
|
11
|
+
# The field set and the name-decoding rules are taken from fontist at
|
|
12
|
+
# 85e904c96fff60f60781ae0eb11a8c06e0c73816 (`lib/fontist/font_file.rb`,
|
|
13
|
+
# `collection_file.rb`). This class never raises for a bad font: the failure
|
|
14
|
+
# that fontist would raise or show is recorded in the row's `errors`.
|
|
15
|
+
#
|
|
16
|
+
# Rows are plain Hashes with Symbol keys (see docs/reference-facade.md for
|
|
17
|
+
# the schema). Every call builds its own state, so concurrent calls on
|
|
18
|
+
# different paths are safe.
|
|
19
|
+
class Scan
|
|
20
|
+
NAME_FIELDS = {
|
|
21
|
+
full_name: Tables::Name::FULL_NAME,
|
|
22
|
+
family_name: Tables::Name::FAMILY,
|
|
23
|
+
subfamily_name: Tables::Name::SUBFAMILY,
|
|
24
|
+
preferred_family: Tables::Name::PREFERRED_FAMILY,
|
|
25
|
+
preferred_subfamily: Tables::Name::PREFERRED_SUBFAMILY,
|
|
26
|
+
postscript_name: Tables::Name::POSTSCRIPT_NAME,
|
|
27
|
+
}.freeze
|
|
28
|
+
|
|
29
|
+
RIBBI = ["regular", "bold", "italic", "bold italic"].freeze
|
|
30
|
+
|
|
31
|
+
FONT_FILE_ERROR = "Fontist::Errors::FontFileError"
|
|
32
|
+
INDEXABILITY_ERROR = "Fontist::Errors::FontIndexabilityValidationError"
|
|
33
|
+
# CollectionFile raises this inside the method whose blanket rescue wraps
|
|
34
|
+
# every non-indexability failure, so it surfaces as "could not be loaded"
|
|
35
|
+
# with the original FontFileError as its cause.
|
|
36
|
+
NOT_A_COLLECTION = {
|
|
37
|
+
class: FONT_FILE_ERROR,
|
|
38
|
+
message: "Font collection could not be loaded: " \
|
|
39
|
+
"File is not a recognized font collection (TTC/OTC/dfont)",
|
|
40
|
+
cause: { class: FONT_FILE_ERROR,
|
|
41
|
+
message: "File is not a recognized font collection (TTC/OTC/dfont)" },
|
|
42
|
+
}.freeze
|
|
43
|
+
|
|
44
|
+
# @param paths [Array<String>] font file paths
|
|
45
|
+
# @return [Array<Hash>] rows in input order, faces in index order
|
|
46
|
+
def self.call(paths)
|
|
47
|
+
Array(paths).flat_map { |path| new(path).rows }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# @param path [String]
|
|
51
|
+
def initialize(path)
|
|
52
|
+
@path = path.to_s
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# @return [Array<Hash>] one row per face; one row with
|
|
56
|
+
# `collection_index: nil` when no face could be enumerated
|
|
57
|
+
def rows
|
|
58
|
+
facts = file_facts
|
|
59
|
+
return [failed_file_row(facts)] if facts[:fatal]
|
|
60
|
+
|
|
61
|
+
if facts[:is_collection]
|
|
62
|
+
collection_rows(facts)
|
|
63
|
+
else
|
|
64
|
+
[face_row(facts, nil, 1)]
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
# ---- file level -----------------------------------------------------
|
|
71
|
+
|
|
72
|
+
# What FontFile reads first: collection?, detect_format, sha256.
|
|
73
|
+
def file_facts
|
|
74
|
+
facts = { sha256: nil, format: nil, is_collection: false, fatal: nil }
|
|
75
|
+
facts[:sha256] = Digest::SHA256.file(@path).hexdigest
|
|
76
|
+
facts[:is_collection] = FontLoader.collection?(@path)
|
|
77
|
+
facts[:format] = detect_format
|
|
78
|
+
facts
|
|
79
|
+
rescue StandardError => e
|
|
80
|
+
facts[:fatal] = e
|
|
81
|
+
facts
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def detect_format
|
|
85
|
+
format = FontLoader.detect_format(@path)
|
|
86
|
+
format&.to_s
|
|
87
|
+
rescue StandardError
|
|
88
|
+
nil
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def failed_file_row(facts)
|
|
92
|
+
error = facts[:fatal]
|
|
93
|
+
row = base_row(facts, nil, 0)
|
|
94
|
+
row[:errors][:font_file] = font_file_error(error)
|
|
95
|
+
row[:errors][:collection_file] = load_error(error)
|
|
96
|
+
row
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# ---- collections ----------------------------------------------------
|
|
100
|
+
|
|
101
|
+
def collection_rows(facts)
|
|
102
|
+
collection = begin
|
|
103
|
+
FontLoader.load_collection(@path)
|
|
104
|
+
rescue StandardError => e
|
|
105
|
+
e
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
if collection.is_a?(StandardError)
|
|
109
|
+
row = base_row(facts, nil, 0)
|
|
110
|
+
row[:errors][:font_file] = font_file_error(collection)
|
|
111
|
+
row[:errors][:collection_file] = load_error(collection)
|
|
112
|
+
return [row]
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
count = collection.num_fonts
|
|
116
|
+
return [empty_collection_row(facts)] if count.zero?
|
|
117
|
+
|
|
118
|
+
Array.new(count) { |index| face_row(facts, index, count) }
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# A collection without faces has no row to hold what fontist raises for it
|
|
122
|
+
# (the first face cannot be loaded), so it gets one of its own.
|
|
123
|
+
def empty_collection_row(facts)
|
|
124
|
+
row = base_row(facts, nil, 0)
|
|
125
|
+
first = load_face(0, lazy: true)
|
|
126
|
+
row[:errors][:font_file] = font_file_error(first) if first.is_a?(StandardError)
|
|
127
|
+
row[:errors][:collection_file] = first_face_error
|
|
128
|
+
row
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# ---- one face -------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
def face_row(facts, index, num_faces)
|
|
134
|
+
row = base_row(facts, index, num_faces)
|
|
135
|
+
fontist_face = index.nil? || index.zero?
|
|
136
|
+
|
|
137
|
+
meta = load_face(index, lazy: true)
|
|
138
|
+
if meta.is_a?(StandardError)
|
|
139
|
+
row[:errors][:font_file] = font_file_error(meta) if fontist_face
|
|
140
|
+
else
|
|
141
|
+
begin
|
|
142
|
+
fill_from_face(row, meta, index)
|
|
143
|
+
rescue StandardError => e
|
|
144
|
+
row[:errors][:font_file] = font_file_error(e) if fontist_face
|
|
145
|
+
ensure
|
|
146
|
+
meta.close if meta.respond_to?(:close)
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
if index.nil?
|
|
151
|
+
row[:errors][:collection_file] = NOT_A_COLLECTION
|
|
152
|
+
else
|
|
153
|
+
fill_collection_errors(row, index)
|
|
154
|
+
end
|
|
155
|
+
row
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# FontFile flow: validate -> names (with fallback) -> fvar -> instances.
|
|
159
|
+
def fill_from_face(row, font, index)
|
|
160
|
+
row[:validation] = validate(font)
|
|
161
|
+
row[:names] = names_with_fallback(font)
|
|
162
|
+
row[:has_fvar] = font.has_table?("fvar")
|
|
163
|
+
row[:variable_instances] = variable_instances(index) if row[:has_fvar] && row[:names][:family_name]
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# CollectionFile flow: metadata, lazy: false, english names only; and the
|
|
167
|
+
# indexability check of the first face, which gates build_collection for
|
|
168
|
+
# the whole file (so every row of the file carries the same value).
|
|
169
|
+
def fill_collection_errors(row, index)
|
|
170
|
+
member = load_face(index, lazy: false)
|
|
171
|
+
if member.is_a?(StandardError)
|
|
172
|
+
row[:errors][:collection_member] = error_info(member)
|
|
173
|
+
else
|
|
174
|
+
begin
|
|
175
|
+
row[:english_names] = english_names(member)
|
|
176
|
+
rescue StandardError => e
|
|
177
|
+
row[:errors][:collection_member] = error_info(e)
|
|
178
|
+
ensure
|
|
179
|
+
member.close if member.respond_to?(:close)
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
row[:errors][:collection_file] = first_face_error
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def first_face_error
|
|
186
|
+
return @first_face_error if defined?(@first_face_error)
|
|
187
|
+
|
|
188
|
+
@first_face_error = compute_first_face_error
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def compute_first_face_error
|
|
192
|
+
first = load_face(0, lazy: true)
|
|
193
|
+
return load_error(first) if first.is_a?(StandardError)
|
|
194
|
+
|
|
195
|
+
begin
|
|
196
|
+
report = validate(first)
|
|
197
|
+
report[:valid] ? nil : indexability_error(report)
|
|
198
|
+
rescue StandardError => e
|
|
199
|
+
load_error(e)
|
|
200
|
+
ensure
|
|
201
|
+
first.close if first.respond_to?(:close)
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def indexability_error(report)
|
|
206
|
+
{ class: INDEXABILITY_ERROR,
|
|
207
|
+
message: "Font collection failed indexability validation " \
|
|
208
|
+
"(first font): #{report[:error_summary]}",
|
|
209
|
+
cause: nil }
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# CollectionFile wraps every non-indexability failure this way.
|
|
213
|
+
def load_error(error)
|
|
214
|
+
wrapped("Font collection could not be loaded: #{error.message}",
|
|
215
|
+
FONT_FILE_ERROR, error)
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def load_face(index, lazy:)
|
|
219
|
+
args = { mode: :metadata, lazy: lazy }
|
|
220
|
+
args[:font_index] = index unless index.nil?
|
|
221
|
+
FontLoader.load(@path, **args)
|
|
222
|
+
rescue StandardError => e
|
|
223
|
+
e
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# ---- names ----------------------------------------------------------
|
|
227
|
+
|
|
228
|
+
def names_with_fallback(font)
|
|
229
|
+
name_table = font.table(Constants::NAME_TAG)
|
|
230
|
+
return {} unless name_table
|
|
231
|
+
|
|
232
|
+
NAME_FIELDS.transform_values do |id|
|
|
233
|
+
name_table.english_name(id) || any_name(name_table, id)
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def english_names(font)
|
|
238
|
+
name_table = font.table(Constants::NAME_TAG)
|
|
239
|
+
return {} unless name_table
|
|
240
|
+
|
|
241
|
+
NAME_FIELDS.transform_values { |id| name_table.english_name(id) }
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# fontist's extract_any_name / decode_name_record_manually.
|
|
245
|
+
def any_name(name_table, name_id)
|
|
246
|
+
records = name_table.name_records.select { |r| r.name_id == name_id }
|
|
247
|
+
records.each do |record|
|
|
248
|
+
decoded = decode_record(name_table, record)
|
|
249
|
+
return decoded unless decoded.nil? || decoded.empty?
|
|
250
|
+
end
|
|
251
|
+
nil
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def decode_record(name_table, record)
|
|
255
|
+
storage = name_table.string_storage.to_s.b
|
|
256
|
+
offset = record.string_offset
|
|
257
|
+
length = record.string_length
|
|
258
|
+
return nil if offset + length > storage.bytesize || length.zero?
|
|
259
|
+
|
|
260
|
+
data = storage.byteslice(offset, length)
|
|
261
|
+
case record.platform_id
|
|
262
|
+
when 0, 3
|
|
263
|
+
data.dup.force_encoding("UTF-16BE")
|
|
264
|
+
.encode("UTF-8", invalid: :replace, undef: :replace)
|
|
265
|
+
when 1
|
|
266
|
+
data.dup.force_encoding("ASCII-8BIT")
|
|
267
|
+
.encode("UTF-8", invalid: :replace, undef: :replace)
|
|
268
|
+
else
|
|
269
|
+
data.dup.force_encoding("UTF-8")
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
# ---- variable instances (fontist FontFile.extract_variable_instances) -
|
|
274
|
+
|
|
275
|
+
# fvar data is gated out of metadata mode, so this is the second load.
|
|
276
|
+
def variable_instances(index)
|
|
277
|
+
font = if index.nil?
|
|
278
|
+
FontLoader.load(@path, mode: :full, lazy: true)
|
|
279
|
+
else
|
|
280
|
+
FontLoader.load(@path, font_index: index, mode: :full, lazy: true)
|
|
281
|
+
end
|
|
282
|
+
instances_of(font)
|
|
283
|
+
rescue StandardError
|
|
284
|
+
[]
|
|
285
|
+
ensure
|
|
286
|
+
font.close if font.respond_to?(:close)
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def instances_of(font)
|
|
290
|
+
return [] unless font.has_table?("fvar")
|
|
291
|
+
|
|
292
|
+
fvar = font.table("fvar")
|
|
293
|
+
name_table = font.table(Constants::NAME_TAG)
|
|
294
|
+
return [] unless name_table
|
|
295
|
+
|
|
296
|
+
base = name_table.english_name(Tables::Name::PREFERRED_FAMILY) ||
|
|
297
|
+
any_name(name_table, Tables::Name::PREFERRED_FAMILY) ||
|
|
298
|
+
name_table.english_name(Tables::Name::FAMILY)
|
|
299
|
+
return [] unless base
|
|
300
|
+
|
|
301
|
+
fvar.instances.filter_map do |instance|
|
|
302
|
+
style = name_table.english_name(instance[:name_id]) ||
|
|
303
|
+
any_name(name_table, instance[:name_id])
|
|
304
|
+
next unless style
|
|
305
|
+
|
|
306
|
+
instance_entry(base, style)
|
|
307
|
+
end
|
|
308
|
+
rescue StandardError
|
|
309
|
+
[]
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
def instance_entry(base, style)
|
|
313
|
+
if RIBBI.include?(style.downcase)
|
|
314
|
+
{ family: base, style: style, full_name: "#{base} #{style}" }
|
|
315
|
+
else
|
|
316
|
+
family = "#{base} #{style}"
|
|
317
|
+
{ family: family, style: "Regular", full_name: family }
|
|
318
|
+
end
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
# ---- validation -----------------------------------------------------
|
|
322
|
+
|
|
323
|
+
# The raw indexability result, with fontist's joined message string.
|
|
324
|
+
def validate(font)
|
|
325
|
+
report = Validators::ProfileLoader.load(:indexability).validate(font)
|
|
326
|
+
errors = report.errors
|
|
327
|
+
{
|
|
328
|
+
profile: "indexability",
|
|
329
|
+
valid: report.valid? || false,
|
|
330
|
+
status: report.status,
|
|
331
|
+
issues: report.issues.map do |i|
|
|
332
|
+
{ severity: i.severity, category: i.category, message: i.message,
|
|
333
|
+
location: i.location }
|
|
334
|
+
end,
|
|
335
|
+
error_summary: errors.map { |e| "#{e.category}: #{e.message}" }.join("; "),
|
|
336
|
+
}
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
# ---- rows and errors ------------------------------------------------
|
|
340
|
+
|
|
341
|
+
def base_row(facts, index, num_faces)
|
|
342
|
+
{
|
|
343
|
+
path: @path,
|
|
344
|
+
collection_index: index,
|
|
345
|
+
sha256: facts[:sha256],
|
|
346
|
+
format: facts[:format],
|
|
347
|
+
is_collection: facts[:is_collection],
|
|
348
|
+
num_faces: num_faces,
|
|
349
|
+
has_fvar: false,
|
|
350
|
+
names: nil,
|
|
351
|
+
english_names: nil,
|
|
352
|
+
variable_instances: [],
|
|
353
|
+
validation: nil,
|
|
354
|
+
errors: { font_file: nil, collection_file: nil, collection_member: nil },
|
|
355
|
+
}
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
# `FontFile.raise_font_file_error`: "Font file could not be parsed: <inspect>."
|
|
359
|
+
def font_file_error(error)
|
|
360
|
+
wrapped("Font file could not be parsed: #{error.inspect}.",
|
|
361
|
+
FONT_FILE_ERROR, error)
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
def wrapped(message, klass, cause)
|
|
365
|
+
{ class: klass, message: message, cause: error_info(cause) }
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
def error_info(error)
|
|
369
|
+
{ class: error.class.name, message: error.message }
|
|
370
|
+
end
|
|
371
|
+
end
|
|
372
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fontisan
|
|
4
|
+
# Bounds check of a plain sfnt (TrueType or OpenType) table directory, run
|
|
5
|
+
# before a conversion reads it.
|
|
6
|
+
#
|
|
7
|
+
# fontisan-rs rejects a directory that runs past the end of the file, or a
|
|
8
|
+
# table that does, with "invalid sfnt table directory". Without this check
|
|
9
|
+
# the Ruby tier fails the same files with whatever BinData or the converters
|
|
10
|
+
# raise: "End of file reached" for a truncated directory, and an internal
|
|
11
|
+
# NoMethodError text for a bad table offset. This gives every tier one error
|
|
12
|
+
# for the same input (fontist/fontisan#159).
|
|
13
|
+
module SfntDirectory
|
|
14
|
+
# Raised for a malformed directory; the converter adds its own prefix.
|
|
15
|
+
class Malformed < Error; end
|
|
16
|
+
|
|
17
|
+
MESSAGE = "Transformation failed: invalid sfnt table directory"
|
|
18
|
+
HEADER_SIZE = 12
|
|
19
|
+
ENTRY_SIZE = 16
|
|
20
|
+
|
|
21
|
+
module_function
|
|
22
|
+
|
|
23
|
+
# @param data [String] the whole file, binary
|
|
24
|
+
# @raise [Malformed] when the directory or a table is out of range
|
|
25
|
+
# @return [void]
|
|
26
|
+
def check!(data)
|
|
27
|
+
size = data.bytesize
|
|
28
|
+
malformed! if size < HEADER_SIZE
|
|
29
|
+
num_tables = data.byteslice(4, 2).unpack1("n")
|
|
30
|
+
malformed! if HEADER_SIZE + (num_tables * ENTRY_SIZE) > size
|
|
31
|
+
num_tables.times do |index|
|
|
32
|
+
offset, length = data.byteslice(HEADER_SIZE + (index * ENTRY_SIZE) + 8, 8).unpack("NN")
|
|
33
|
+
malformed! if offset + length > size
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def malformed!
|
|
38
|
+
raise Malformed, MESSAGE
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
data/lib/fontisan/sfnt_font.rb
CHANGED
|
@@ -66,7 +66,15 @@ module Fontisan
|
|
|
66
66
|
# silently miss due to encoding mismatch — BinData-derived tag
|
|
67
67
|
# strings are ASCII-8BIT, literals callers pass are usually UTF-8,
|
|
68
68
|
# and String#eql? is encoding-sensitive.
|
|
69
|
-
|
|
69
|
+
# Raw table bytes: the Hash of all tables, or one table's bytes when a
|
|
70
|
+
# tag is given (the WOFF classes already accept a tag; StaticFontBuilder
|
|
71
|
+
# calls it that way).
|
|
72
|
+
#
|
|
73
|
+
# @param tag [String, nil]
|
|
74
|
+
# @return [Hash{String=>String}, String, nil]
|
|
75
|
+
def table_data(tag = nil)
|
|
76
|
+
tag.nil? ? @table_data : @table_data&.[](tag)
|
|
77
|
+
end
|
|
70
78
|
|
|
71
79
|
def table_data=(hash)
|
|
72
80
|
@table_data = hash.transform_keys { |tag| normalize_tag(tag) }
|
|
@@ -271,7 +279,9 @@ module Fontisan
|
|
|
271
279
|
|
|
272
280
|
# Get metadata tables sorted by offset for sequential access
|
|
273
281
|
metadata_entries = tables.select { |entry| LoadingModes::METADATA_TABLES_SET.include?(entry.tag) }
|
|
274
|
-
|
|
282
|
+
# Tables may share an offset (a zero-length table sits where the next one
|
|
283
|
+
# starts) and sort_by is not stable, so tie-break on the directory order.
|
|
284
|
+
metadata_entries = metadata_entries.each_with_index.sort_by { |entry, idx| [entry.offset, idx] }.map(&:first)
|
|
275
285
|
|
|
276
286
|
return if metadata_entries.empty?
|
|
277
287
|
|
|
@@ -279,18 +289,18 @@ module Fontisan
|
|
|
279
289
|
i = 0
|
|
280
290
|
while i < metadata_entries.size
|
|
281
291
|
batch_start = metadata_entries[i]
|
|
282
|
-
|
|
292
|
+
batch_end_pos = batch_start.offset + batch_start.table_length
|
|
283
293
|
batch_entries = [batch_start]
|
|
284
294
|
|
|
285
295
|
# Extend batch while next table is within page threshold
|
|
286
296
|
j = i + 1
|
|
287
297
|
while j < metadata_entries.size
|
|
288
298
|
next_entry = metadata_entries[j]
|
|
289
|
-
gap = next_entry.offset -
|
|
299
|
+
gap = next_entry.offset - batch_end_pos
|
|
290
300
|
|
|
291
301
|
# If gap is small (within page threshold), include in batch
|
|
292
302
|
if gap <= page_threshold
|
|
293
|
-
|
|
303
|
+
batch_end_pos = [batch_end_pos, next_entry.offset + next_entry.table_length].max
|
|
294
304
|
batch_entries << next_entry
|
|
295
305
|
j += 1
|
|
296
306
|
else
|
|
@@ -307,7 +317,7 @@ module Fontisan
|
|
|
307
317
|
else
|
|
308
318
|
# Multiple tables, read contiguous segment
|
|
309
319
|
batch_offset = batch_start.offset
|
|
310
|
-
batch_length =
|
|
320
|
+
batch_length = batch_end_pos - batch_start.offset
|
|
311
321
|
|
|
312
322
|
io.seek(batch_offset)
|
|
313
323
|
batch_data = io.read(batch_length)
|
|
@@ -91,6 +91,7 @@ module Fontisan
|
|
|
91
91
|
# subset_binary = builder.build
|
|
92
92
|
# File.binwrite('subset.ttf', subset_binary)
|
|
93
93
|
def build
|
|
94
|
+
Fontisan::Engine.record_ruby(:subset)
|
|
94
95
|
validate_input!
|
|
95
96
|
calculate_closure
|
|
96
97
|
build_mapping
|
|
@@ -133,7 +134,7 @@ module Fontisan
|
|
|
133
134
|
#
|
|
134
135
|
# @raise [Fontisan::MissingTableError] If required tables are missing
|
|
135
136
|
def validate_required_tables!
|
|
136
|
-
required = %w[head maxp]
|
|
137
|
+
required = %w[head hhea maxp]
|
|
137
138
|
required.each do |tag|
|
|
138
139
|
table = font.table(tag)
|
|
139
140
|
next if table
|
|
@@ -107,6 +107,11 @@ module Fontisan
|
|
|
107
107
|
[12, 37] => :flex1,
|
|
108
108
|
}.freeze
|
|
109
109
|
|
|
110
|
+
# Total operators and operands one CharString (subroutines included)
|
|
111
|
+
# may execute. Real glyphs need hundreds; this only stops exponential
|
|
112
|
+
# subroutine fan-out. Matches the native tiers' `MAX_OPS` (1 << 20).
|
|
113
|
+
MAX_OPS = 1 << 20
|
|
114
|
+
|
|
110
115
|
# Initialize and interpret a CharString
|
|
111
116
|
#
|
|
112
117
|
# @param data [String] Binary CharString data
|
|
@@ -128,6 +133,7 @@ module Fontisan
|
|
|
128
133
|
@transient_array = []
|
|
129
134
|
@subroutine_bias = calculate_bias(local_subrs)
|
|
130
135
|
@global_subroutine_bias = calculate_bias(global_subrs)
|
|
136
|
+
@ops = 0
|
|
131
137
|
|
|
132
138
|
parse!
|
|
133
139
|
end
|
|
@@ -184,6 +190,7 @@ module Fontisan
|
|
|
184
190
|
width_parsed = false
|
|
185
191
|
|
|
186
192
|
until io.eof?
|
|
193
|
+
count_op
|
|
187
194
|
byte = io.getbyte
|
|
188
195
|
|
|
189
196
|
if byte <= 31 && byte != 28
|
|
@@ -207,6 +214,14 @@ module Fontisan
|
|
|
207
214
|
"Failed to parse CharString: #{e.message}"
|
|
208
215
|
end
|
|
209
216
|
|
|
217
|
+
# Count one operator or operand against the budget.
|
|
218
|
+
def count_op
|
|
219
|
+
@ops += 1
|
|
220
|
+
return if @ops <= MAX_OPS
|
|
221
|
+
|
|
222
|
+
raise CorruptedTableError, "charstring operation budget exceeded"
|
|
223
|
+
end
|
|
224
|
+
|
|
210
225
|
# Read an operator from the CharString
|
|
211
226
|
#
|
|
212
227
|
# @param io [StringIO] Input stream
|
|
@@ -836,6 +851,7 @@ module Fontisan
|
|
|
836
851
|
|
|
837
852
|
# Process subroutine until return or end
|
|
838
853
|
until @io.eof?
|
|
854
|
+
count_op
|
|
839
855
|
byte = @io.getbyte
|
|
840
856
|
|
|
841
857
|
if byte <= 31 && byte != 28
|
data/lib/fontisan/tables/cff.rb
CHANGED
|
@@ -370,14 +370,10 @@ module Fontisan
|
|
|
370
370
|
# puts charstring.bounding_box
|
|
371
371
|
# charstring.to_commands.each { |cmd| puts cmd.inspect }
|
|
372
372
|
def charstring_for_glyph(glyph_index, font_index = 0)
|
|
373
|
-
charstrings =
|
|
373
|
+
charstrings, priv_dict, local_subr_index = glyph_sources(font_index)
|
|
374
374
|
return nil unless charstrings
|
|
375
|
-
|
|
376
|
-
priv_dict = private_dict(font_index)
|
|
377
375
|
return nil unless priv_dict
|
|
378
376
|
|
|
379
|
-
local_subr_index = local_subrs(font_index)
|
|
380
|
-
|
|
381
377
|
charstrings.charstring_at(
|
|
382
378
|
glyph_index,
|
|
383
379
|
priv_dict,
|
|
@@ -482,6 +478,20 @@ module Fontisan
|
|
|
482
478
|
rescue StandardError => e
|
|
483
479
|
raise CorruptedTableError, "Failed to parse Encoding: #{e.message}"
|
|
484
480
|
end
|
|
481
|
+
|
|
482
|
+
private
|
|
483
|
+
|
|
484
|
+
# CharStrings INDEX, Private DICT and Local Subr INDEX of one font,
|
|
485
|
+
# parsed once. charstring_for_glyph is called per glyph, and re-parsing
|
|
486
|
+
# the whole INDEX each time made it quadratic in the glyph count.
|
|
487
|
+
def glyph_sources(font_index)
|
|
488
|
+
@glyph_sources ||= {}
|
|
489
|
+
@glyph_sources[font_index] ||= begin
|
|
490
|
+
charstrings = charstrings_index(font_index)
|
|
491
|
+
priv_dict = charstrings && private_dict(font_index)
|
|
492
|
+
[charstrings, priv_dict, priv_dict && local_subrs(font_index)]
|
|
493
|
+
end
|
|
494
|
+
end
|
|
485
495
|
end
|
|
486
496
|
end
|
|
487
497
|
end
|
data/lib/fontisan/tables/cmap.rb
CHANGED
|
@@ -22,6 +22,9 @@ module Fontisan
|
|
|
22
22
|
PLATFORM_MACINTOSH = 1
|
|
23
23
|
PLATFORM_MICROSOFT = 3
|
|
24
24
|
|
|
25
|
+
# Last Unicode scalar; bounds format 12 group spans.
|
|
26
|
+
MAX_CODEPOINT = 0x10FFFF
|
|
27
|
+
|
|
25
28
|
# Microsoft Encoding IDs
|
|
26
29
|
ENC_MS_UNICODE_BMP = 1 # Unicode BMP (UCS-2)
|
|
27
30
|
ENC_MS_UNICODE_UCS4 = 10 # Unicode full repertoire (UCS-4)
|
|
@@ -198,6 +201,10 @@ module Fontisan
|
|
|
198
201
|
id_delta = id_deltas[i]
|
|
199
202
|
id_range_offset = id_range_offsets[i]
|
|
200
203
|
|
|
204
|
+
# A segmentCount past the arrays inside the declared length leaves
|
|
205
|
+
# the later arrays short: skip the cut segment (was NoMethodError).
|
|
206
|
+
next if [start_code, end_code, id_delta, id_range_offset].any?(&:nil?)
|
|
207
|
+
|
|
201
208
|
# Skip the final segment (0xFFFF)
|
|
202
209
|
next if start_code == 0xFFFF
|
|
203
210
|
|
|
@@ -272,8 +279,11 @@ module Fontisan
|
|
|
272
279
|
end
|
|
273
280
|
|
|
274
281
|
# Map a range of characters to glyphs
|
|
282
|
+
# The end is clamped to the last Unicode scalar: a fuzzed group spanning
|
|
283
|
+
# the u32 range would otherwise loop for minutes (same bound as the Rust
|
|
284
|
+
# engine's cmap parser).
|
|
275
285
|
def map_character_range(start_char, end_char, start_glyph, mappings)
|
|
276
|
-
(start_char..end_char).each do |code|
|
|
286
|
+
(start_char..[end_char, MAX_CODEPOINT].min).each do |code|
|
|
277
287
|
glyph_index = start_glyph + (code - start_char)
|
|
278
288
|
mappings[code] = glyph_index if glyph_index != 0
|
|
279
289
|
end
|
data/lib/fontisan/tables/glyf.rb
CHANGED
|
@@ -241,13 +241,11 @@ module Fontisan
|
|
|
241
241
|
glyph = glyph_for(glyph_id, loca, head)
|
|
242
242
|
return true if glyph.nil? # Empty glyphs are OK
|
|
243
243
|
|
|
244
|
-
# Simple glyphs: contours
|
|
245
|
-
#
|
|
244
|
+
# Simple glyphs: contours must be >= 0. Compound glyphs always have
|
|
245
|
+
# numberOfContours = -1; CompoundGlyph.parse rejects anything else.
|
|
246
246
|
case glyph
|
|
247
|
-
when SimpleGlyph
|
|
248
|
-
|
|
249
|
-
else
|
|
250
|
-
true
|
|
247
|
+
when SimpleGlyph then glyph.num_contours >= 0
|
|
248
|
+
else true
|
|
251
249
|
end
|
|
252
250
|
rescue StandardError
|
|
253
251
|
false
|
data/lib/fontisan/tables/gpos.rb
CHANGED
|
@@ -28,6 +28,13 @@ module Fontisan
|
|
|
28
28
|
[]
|
|
29
29
|
end
|
|
30
30
|
|
|
31
|
+
# Every script with its feature tags, parsed once and work-budgeted
|
|
32
|
+
# (see {LayoutCommon::ScriptFeatures}).
|
|
33
|
+
# @return [Hash{String=>Array<String>}] script tag => feature tags
|
|
34
|
+
def script_features
|
|
35
|
+
LayoutCommon::ScriptFeatures.new(self).call
|
|
36
|
+
end
|
|
37
|
+
|
|
31
38
|
# Get all feature tags for a given script
|
|
32
39
|
# @param script_tag [String] 4-character script tag (e.g., 'latn')
|
|
33
40
|
# @return [Array<String>] Array of 4-character feature tags
|
data/lib/fontisan/tables/gsub.rb
CHANGED
|
@@ -26,6 +26,13 @@ module Fontisan
|
|
|
26
26
|
[]
|
|
27
27
|
end
|
|
28
28
|
|
|
29
|
+
# Every script with its feature tags, parsed once and work-budgeted
|
|
30
|
+
# (see {LayoutCommon::ScriptFeatures}).
|
|
31
|
+
# @return [Hash{String=>Array<String>}] script tag => feature tags
|
|
32
|
+
def script_features
|
|
33
|
+
LayoutCommon::ScriptFeatures.new(self).call
|
|
34
|
+
end
|
|
35
|
+
|
|
29
36
|
# Get all feature tags for a given script
|
|
30
37
|
# @param script_tag [String] 4-character script tag (e.g., 'latn')
|
|
31
38
|
# @return [Array<String>] Array of 4-character feature tags
|