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
|
@@ -68,6 +68,178 @@ module Fontisan
|
|
|
68
68
|
array :lookup_list_indices, type: :uint16,
|
|
69
69
|
initial_length: -> { lookup_index_count }
|
|
70
70
|
end
|
|
71
|
+
|
|
72
|
+
# Scripts paired with their feature tags, from one parse of the
|
|
73
|
+
# ScriptList and FeatureList instead of re-reading both per script.
|
|
74
|
+
#
|
|
75
|
+
# Gives exactly the answers of calling `scripts` and then
|
|
76
|
+
# `features(script_tag:)` for each (the first record of a tag decides;
|
|
77
|
+
# any short read yields []), but its total LangSys work is capped at
|
|
78
|
+
# {WORK_PER_BYTE} x the table length, so a table whose scripts each
|
|
79
|
+
# re-walk thousands of overlapping LangSys arrays cannot take minutes.
|
|
80
|
+
# Scripts reached after the budget is gone read as failed ([]), the
|
|
81
|
+
# same outcome as any other bad read. Mirrors the native tiers'
|
|
82
|
+
# `LayoutTable::script_features`.
|
|
83
|
+
class ScriptFeatures
|
|
84
|
+
# Work units per byte of table; a well-formed font spends a small
|
|
85
|
+
# fraction of one.
|
|
86
|
+
WORK_PER_BYTE = 2
|
|
87
|
+
|
|
88
|
+
# A short read or an exhausted budget: the script reads as [].
|
|
89
|
+
class Failed < StandardError; end
|
|
90
|
+
|
|
91
|
+
# @param table [Gsub, Gpos]
|
|
92
|
+
def initialize(table)
|
|
93
|
+
@table = table
|
|
94
|
+
@data = table.table_data
|
|
95
|
+
@budget = @data.bytesize * WORK_PER_BYTE
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# @return [Hash{String=>Array<String>}] script tag => feature tags,
|
|
99
|
+
# in record order
|
|
100
|
+
def call
|
|
101
|
+
return {} if @table.script_list_offset.zero?
|
|
102
|
+
|
|
103
|
+
list_data = slice_from(@data, @table.script_list_offset - 10)
|
|
104
|
+
return {} if list_data.nil? || list_data.empty?
|
|
105
|
+
|
|
106
|
+
records = ScriptList.read(list_data).script_records
|
|
107
|
+
features = feature_tags
|
|
108
|
+
records.each_with_object({}) do |record, result|
|
|
109
|
+
tag = record.script_tag
|
|
110
|
+
next if result.key?(tag)
|
|
111
|
+
|
|
112
|
+
result[tag] = script_feature_tags(list_data, record.script_offset,
|
|
113
|
+
features)
|
|
114
|
+
end
|
|
115
|
+
rescue StandardError
|
|
116
|
+
{}
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
private
|
|
120
|
+
|
|
121
|
+
# Ruby `String#[start..]`: negative starts count from the end.
|
|
122
|
+
def slice_from(string, start)
|
|
123
|
+
string[start..]
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# FeatureList tags, or nil when every script must read as [].
|
|
127
|
+
def feature_tags
|
|
128
|
+
return nil if @table.script_list_offset.zero? ||
|
|
129
|
+
@table.feature_list_offset.zero?
|
|
130
|
+
|
|
131
|
+
data = slice_from(@data, @table.feature_list_offset - 10)
|
|
132
|
+
return nil if data.nil? || data.empty?
|
|
133
|
+
|
|
134
|
+
FeatureList.read(data).feature_tags
|
|
135
|
+
rescue StandardError
|
|
136
|
+
nil
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def script_feature_tags(list_data, offset, features)
|
|
140
|
+
return [] unless features
|
|
141
|
+
|
|
142
|
+
indices = feature_indices(list_data, offset)
|
|
143
|
+
indices.filter_map { |index| features[index] }.uniq
|
|
144
|
+
rescue StandardError
|
|
145
|
+
[]
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Feature indices of one Script table: its default LangSys and every
|
|
149
|
+
# LangSys record, deduplicated in first-seen order.
|
|
150
|
+
def feature_indices(list_data, offset)
|
|
151
|
+
data = slice_from(list_data, offset)
|
|
152
|
+
return [] if data.nil? || data.empty?
|
|
153
|
+
|
|
154
|
+
default_offset, count = read_header(data)
|
|
155
|
+
spend(count)
|
|
156
|
+
offsets = lang_sys_offsets(data, count)
|
|
157
|
+
collector = IndexCollector.new(data)
|
|
158
|
+
([default_offset] + offsets).each do |lang_offset|
|
|
159
|
+
next if lang_offset.zero? || lang_offset >= data.bytesize
|
|
160
|
+
|
|
161
|
+
collector.add(lang_offset) { |units| spend(units) }
|
|
162
|
+
end
|
|
163
|
+
collector.indices
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def read_header(data)
|
|
167
|
+
raise Failed if data.bytesize < 4
|
|
168
|
+
|
|
169
|
+
data.unpack("nn")
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def lang_sys_offsets(data, count)
|
|
173
|
+
raise Failed if data.bytesize < 4 + (6 * count)
|
|
174
|
+
|
|
175
|
+
Array.new(count) { |i| data.byteslice(8 + (6 * i), 2).unpack1("n") }
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def spend(units)
|
|
179
|
+
raise Failed if units > @budget
|
|
180
|
+
|
|
181
|
+
@budget -= units
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Distinct feature indices of many LangSys tables in first-seen order,
|
|
186
|
+
# reading every u16 word once. A LangSys index array is a run of words at
|
|
187
|
+
# a fixed byte parity inside the Script table, so hostile overlapping
|
|
188
|
+
# arrays would otherwise re-read the same words again and again; a word
|
|
189
|
+
# already collected is skipped (a "next unread slot" union-find per
|
|
190
|
+
# parity), which leaves the result and its order unchanged.
|
|
191
|
+
class IndexCollector
|
|
192
|
+
attr_reader :indices
|
|
193
|
+
|
|
194
|
+
def initialize(data)
|
|
195
|
+
@data = data
|
|
196
|
+
@skip = [{}, {}]
|
|
197
|
+
@seen = {}
|
|
198
|
+
@indices = []
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# LangSys at +offset+: reserved, required index, count, count x u16.
|
|
202
|
+
# Yields the work units it costs (1 per table, 1 per word read).
|
|
203
|
+
def add(offset)
|
|
204
|
+
raise ScriptFeatures::Failed if @data.bytesize < offset + 6
|
|
205
|
+
|
|
206
|
+
count = @data.byteslice(offset + 4, 2).unpack1("n")
|
|
207
|
+
raise ScriptFeatures::Failed if @data.bytesize < offset + 6 + (2 * count)
|
|
208
|
+
|
|
209
|
+
yield(1)
|
|
210
|
+
first = offset + 6
|
|
211
|
+
parity = first & 1
|
|
212
|
+
slot = find(parity, first >> 1)
|
|
213
|
+
limit = (first + (2 * count)) >> 1
|
|
214
|
+
while slot < limit
|
|
215
|
+
yield(1)
|
|
216
|
+
collect(@data.byteslice((slot * 2) + parity, 2).unpack1("n"))
|
|
217
|
+
@skip[parity][slot] = slot + 1
|
|
218
|
+
slot = find(parity, slot + 1)
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
private
|
|
223
|
+
|
|
224
|
+
def collect(index)
|
|
225
|
+
return if @seen.key?(index)
|
|
226
|
+
|
|
227
|
+
@seen[index] = true
|
|
228
|
+
@indices << index
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def find(parity, slot)
|
|
232
|
+
skip = @skip[parity]
|
|
233
|
+
root = slot
|
|
234
|
+
root = skip[root] while skip.key?(root)
|
|
235
|
+
while slot != root
|
|
236
|
+
following = skip[slot]
|
|
237
|
+
skip[slot] = root
|
|
238
|
+
slot = following
|
|
239
|
+
end
|
|
240
|
+
root
|
|
241
|
+
end
|
|
242
|
+
end
|
|
71
243
|
end
|
|
72
244
|
end
|
|
73
245
|
end
|
data/lib/fontisan/tables/name.rb
CHANGED
|
@@ -108,8 +108,14 @@ module Fontisan
|
|
|
108
108
|
array :name_records, type: :name_record, initial_length: :record_count
|
|
109
109
|
rest :string_storage
|
|
110
110
|
|
|
111
|
-
# Cache for decoded names
|
|
112
|
-
|
|
111
|
+
# Cache for decoded names. Created on first use: do_read does not set
|
|
112
|
+
# it up when the table is empty, and english_name then raised
|
|
113
|
+
# NoMethodError on nil.
|
|
114
|
+
attr_writer :decoded_names_cache
|
|
115
|
+
|
|
116
|
+
def decoded_names_cache
|
|
117
|
+
@decoded_names_cache ||= {}
|
|
118
|
+
end
|
|
113
119
|
|
|
114
120
|
# Hook that gets called after all fields are read
|
|
115
121
|
def after_read_hook
|
|
@@ -168,7 +174,7 @@ module Fontisan
|
|
|
168
174
|
# @return [String, nil] The decoded string or nil if not found
|
|
169
175
|
def english_name(name_id)
|
|
170
176
|
# Check cache first
|
|
171
|
-
return
|
|
177
|
+
return decoded_names_cache[name_id] if decoded_names_cache.key?(name_id)
|
|
172
178
|
|
|
173
179
|
# Find record (don't decode yet)
|
|
174
180
|
record = find_name_record(
|
|
@@ -187,7 +193,7 @@ module Fontisan
|
|
|
187
193
|
|
|
188
194
|
# Decode only this one record
|
|
189
195
|
decoded = decode_name_record(record)
|
|
190
|
-
|
|
196
|
+
decoded_names_cache[name_id] = decoded
|
|
191
197
|
decoded
|
|
192
198
|
end
|
|
193
199
|
|
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "brotli"
|
|
4
|
-
|
|
5
3
|
module Fontisan
|
|
6
4
|
module Utilities
|
|
7
5
|
# Wrapper for Brotli compression with consistent settings
|
|
@@ -19,6 +17,15 @@ module Fontisan
|
|
|
19
17
|
# @example Decompress data
|
|
20
18
|
# decompressed = BrotliWrapper.decompress(compressed_data)
|
|
21
19
|
class BrotliWrapper
|
|
20
|
+
# Raised when the optional `brotli` C extension cannot be loaded (the
|
|
21
|
+
# java-platform gem omits it; WOFF2 is then served by the :ffi tier).
|
|
22
|
+
class Unavailable < Fontisan::Error; end
|
|
23
|
+
|
|
24
|
+
MISSING_MESSAGE = "WOFF2 needs the `brotli` gem, which is not available " \
|
|
25
|
+
"(java-platform fontisan omits it). Use " \
|
|
26
|
+
"FONTISAN_ENGINE=ffi or auto with the bundled cdylib, " \
|
|
27
|
+
"or install the brotli gem."
|
|
28
|
+
|
|
22
29
|
# Default compression quality (0-11, higher = better but slower)
|
|
23
30
|
# Quality 11 gives best compression for WOFF2
|
|
24
31
|
DEFAULT_QUALITY = 11
|
|
@@ -29,6 +36,30 @@ module Fontisan
|
|
|
29
36
|
# Maximum quality level
|
|
30
37
|
MAX_QUALITY = 11
|
|
31
38
|
|
|
39
|
+
# Whether the `brotli` gem loads here. Memoised; never raises.
|
|
40
|
+
def self.available?
|
|
41
|
+
return @available if defined?(@available)
|
|
42
|
+
|
|
43
|
+
@available = begin
|
|
44
|
+
require "brotli"
|
|
45
|
+
true
|
|
46
|
+
rescue LoadError
|
|
47
|
+
false
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Forget the memoised probe (specs).
|
|
52
|
+
def self.reset!
|
|
53
|
+
remove_instance_variable(:@available) if defined?(@available)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# The ::Brotli module, or {Unavailable} with a clear message.
|
|
57
|
+
def self.library
|
|
58
|
+
raise Unavailable, MISSING_MESSAGE unless available?
|
|
59
|
+
|
|
60
|
+
::Brotli
|
|
61
|
+
end
|
|
62
|
+
|
|
32
63
|
# Compress data using Brotli
|
|
33
64
|
#
|
|
34
65
|
# @param data [String] Data to compress
|
|
@@ -48,7 +79,9 @@ module Fontisan
|
|
|
48
79
|
validate_data!(data)
|
|
49
80
|
|
|
50
81
|
begin
|
|
51
|
-
|
|
82
|
+
library.deflate(data, quality: quality, mode: mode)
|
|
83
|
+
rescue Unavailable
|
|
84
|
+
raise
|
|
52
85
|
rescue StandardError => e
|
|
53
86
|
raise Fontisan::Error,
|
|
54
87
|
"Brotli compression failed: #{e.message}"
|
|
@@ -67,7 +100,9 @@ module Fontisan
|
|
|
67
100
|
validate_data!(data)
|
|
68
101
|
|
|
69
102
|
begin
|
|
70
|
-
|
|
103
|
+
library.inflate(data)
|
|
104
|
+
rescue Unavailable
|
|
105
|
+
raise
|
|
71
106
|
rescue StandardError => e
|
|
72
107
|
raise Fontisan::Error,
|
|
73
108
|
"Brotli decompression failed: #{e.message}"
|
|
@@ -90,6 +90,8 @@ module Fontisan
|
|
|
90
90
|
# Check 35: Cmap glyph indices must be valid
|
|
91
91
|
check_structure :cmap_glyph_indices, severity: :error do |font|
|
|
92
92
|
cmap = font.table("cmap")
|
|
93
|
+
next true unless cmap
|
|
94
|
+
|
|
93
95
|
maxp = font.table("maxp")
|
|
94
96
|
cmap.valid_glyph_indices?(maxp.num_glyphs)
|
|
95
97
|
end
|
|
@@ -45,6 +45,7 @@ module Fontisan
|
|
|
45
45
|
# @example
|
|
46
46
|
# binary = instancer.instance({ "wght" => 700, "wdth" => 100 })
|
|
47
47
|
def instance(user_coords, _options = {})
|
|
48
|
+
Fontisan::Engine.record_ruby(:instance)
|
|
48
49
|
tables = Variation::Instancing::TrueTypeInstancer
|
|
49
50
|
.new(@font.table_data, user_coords).call
|
|
50
51
|
FontWriter.write_font(tables)
|
|
@@ -40,6 +40,8 @@ module Fontisan
|
|
|
40
40
|
# { ascent: 2048, descent: -512, line_gap: 0 }
|
|
41
41
|
# @param options [Hash] Build options
|
|
42
42
|
# @option options [Boolean] :update_modified Update head modified timestamp
|
|
43
|
+
# @option options [Integer, Time] :timestamp Fixed value to stamp when
|
|
44
|
+
# updating head.modified (nil = current time)
|
|
43
45
|
# @return [String] Complete static font binary
|
|
44
46
|
def build(varied_metrics = {}, font_metrics = {}, options = {})
|
|
45
47
|
# Collect tables for static font
|
|
@@ -170,7 +172,8 @@ options = {})
|
|
|
170
172
|
if options[:update_modified] == false
|
|
171
173
|
original_data
|
|
172
174
|
else
|
|
173
|
-
@table_updater.update_head_modified(original_data
|
|
175
|
+
@table_updater.update_head_modified(original_data,
|
|
176
|
+
options[:timestamp])
|
|
174
177
|
end
|
|
175
178
|
end
|
|
176
179
|
|
|
@@ -157,9 +157,11 @@ module Fontisan
|
|
|
157
157
|
# Update head table's modified timestamp
|
|
158
158
|
#
|
|
159
159
|
# @param original_data [String] Original head table binary
|
|
160
|
-
# @param timestamp [Time] New modification time
|
|
160
|
+
# @param timestamp [Time, Integer, nil] New modification time (Time or
|
|
161
|
+
# Unix seconds); nil uses the current time
|
|
161
162
|
# @return [String] Updated head table binary
|
|
162
|
-
def update_head_modified(original_data, timestamp =
|
|
163
|
+
def update_head_modified(original_data, timestamp = nil)
|
|
164
|
+
timestamp ||= Time.now
|
|
163
165
|
io = StringIO.new(original_data)
|
|
164
166
|
io.set_encoding(Encoding::BINARY)
|
|
165
167
|
|
|
@@ -9,6 +9,8 @@ module Fontisan
|
|
|
9
9
|
# chosen location. cvar, GPOS/GDEF variations and MVAR are not applied.
|
|
10
10
|
class TrueTypeInstancer
|
|
11
11
|
DROPPED = %w[fvar gvar avar HVAR VVAR MVAR STAT cvar DSIG].freeze
|
|
12
|
+
# Tables read by #call besides the variation tables checked below.
|
|
13
|
+
REQUIRED = %w[head hhea hmtx maxp loca].freeze
|
|
12
14
|
WIDTH_CLASSES = [50, 62.5, 75, 87.5, 100, 112.5, 125, 150, 200].freeze
|
|
13
15
|
|
|
14
16
|
# @param tables [Hash{String=>String}] raw tables of the variable font
|
|
@@ -45,13 +47,19 @@ module Fontisan
|
|
|
45
47
|
unless @tables["fvar"]
|
|
46
48
|
raise MissingVariationTableError.new(table: "fvar")
|
|
47
49
|
end
|
|
48
|
-
|
|
50
|
+
unless @tables["gvar"] && @tables["glyf"]
|
|
51
|
+
raise MissingVariationTableError.new(
|
|
52
|
+
table: "gvar",
|
|
53
|
+
message: "Static instancing needs TrueType outlines with gvar " \
|
|
54
|
+
"(CFF2 variable fonts are not supported yet)",
|
|
55
|
+
)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
REQUIRED.each do |tag|
|
|
59
|
+
next if @tables[tag]
|
|
49
60
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
message: "Static instancing needs TrueType outlines with gvar " \
|
|
53
|
-
"(CFF2 variable fonts are not supported yet)",
|
|
54
|
-
)
|
|
61
|
+
raise MissingTableError, "Font missing required #{tag} table"
|
|
62
|
+
end
|
|
55
63
|
end
|
|
56
64
|
|
|
57
65
|
def read_metrics(num_glyphs)
|
data/lib/fontisan/version.rb
CHANGED
|
@@ -59,15 +59,29 @@ module Fontisan
|
|
|
59
59
|
# the glyf was transformed.
|
|
60
60
|
font_entries.map do |fe|
|
|
61
61
|
font_tables = {}
|
|
62
|
+
glyf_transformed = false
|
|
62
63
|
fe.table_indices.each do |idx|
|
|
63
64
|
entry = table_entries.fetch(idx)
|
|
64
65
|
font_tables[entry.tag] = entry.raw_bytes
|
|
66
|
+
glyf_transformed = !entry.transform_length.nil? if entry.tag == "glyf"
|
|
65
67
|
end
|
|
66
|
-
reconstruct_glyf_loca!(font_tables) if
|
|
68
|
+
reconstruct_glyf_loca!(font_tables) if glyf_transformed
|
|
67
69
|
{ flavor: fe.flavor, tables: font_tables }
|
|
68
70
|
end
|
|
69
71
|
end
|
|
70
72
|
|
|
73
|
+
# Number of fonts, read from the CollectionDirectory alone: no Brotli
|
|
74
|
+
# inflate, so it works where the `brotli` gem is absent (JRuby).
|
|
75
|
+
#
|
|
76
|
+
# @return [Integer]
|
|
77
|
+
def font_count
|
|
78
|
+
validate_signature!
|
|
79
|
+
validate_collection_flavor!
|
|
80
|
+
|
|
81
|
+
_entries, post_dir_pos = read_table_directory
|
|
82
|
+
read_collection_directory(post_dir_pos).first.length
|
|
83
|
+
end
|
|
84
|
+
|
|
71
85
|
private
|
|
72
86
|
|
|
73
87
|
def validate_signature!
|
|
@@ -21,10 +21,15 @@ module Fontisan
|
|
|
21
21
|
# adjacent (loca immediately follows glyf) in the directory.
|
|
22
22
|
class CollectionEncoder
|
|
23
23
|
TTC_FLAVOR = 0x74746366 # 'ttcf'
|
|
24
|
+
# Largest padded glyf a short (uint16, offset / 2) loca can index.
|
|
25
|
+
SHORT_LOCA_GLYF_MAX = 0x1FFFE
|
|
24
26
|
|
|
25
27
|
# @param brotli_quality [Integer] 0-11
|
|
26
|
-
|
|
28
|
+
# @param timestamp [Integer, nil] Unix seconds stamped into every
|
|
29
|
+
# head.modified; nil uses the clock
|
|
30
|
+
def initialize(brotli_quality: 11, timestamp: nil)
|
|
27
31
|
@brotli_quality = brotli_quality
|
|
32
|
+
@timestamp = timestamp
|
|
28
33
|
end
|
|
29
34
|
|
|
30
35
|
# Encode an array of fonts as a WOFF2 collection.
|
|
@@ -58,7 +63,7 @@ module Fontisan
|
|
|
58
63
|
tables[tag] = data
|
|
59
64
|
end
|
|
60
65
|
|
|
61
|
-
EncoderRules.apply!(tables)
|
|
66
|
+
EncoderRules.apply!(tables, timestamp: @timestamp)
|
|
62
67
|
loca_orig_length = apply_glyf_loca_transform!(tables, font)
|
|
63
68
|
|
|
64
69
|
{ index:, tables:, loca_orig_length: }
|
|
@@ -73,12 +78,30 @@ module Fontisan
|
|
|
73
78
|
head = font.table("head")
|
|
74
79
|
return nil unless maxp && head
|
|
75
80
|
|
|
76
|
-
|
|
81
|
+
glyf = tables["glyf"]
|
|
82
|
+
loca = tables["loca"]
|
|
83
|
+
index_format = head.index_to_loc_format
|
|
84
|
+
# The WOFF2 decoder pads every glyph to 4 bytes; a short loca
|
|
85
|
+
# (offset / 2 as uint16) wraps once that padded glyf passes
|
|
86
|
+
# SHORT_LOCA_GLYF_MAX, so such a member is re-emitted with a long loca.
|
|
87
|
+
if index_format.zero? &&
|
|
88
|
+
glyf.bytesize + 3 * maxp.num_glyphs > SHORT_LOCA_GLYF_MAX
|
|
89
|
+
canonical = GlyfCanonicalizer.new(
|
|
90
|
+
glyf_data: glyf, loca_data: loca, num_glyphs: maxp.num_glyphs,
|
|
91
|
+
source_format: 0, target_format: 1
|
|
92
|
+
).canonical
|
|
93
|
+
glyf = canonical[:glyf]
|
|
94
|
+
loca = canonical[:loca]
|
|
95
|
+
index_format = 1
|
|
96
|
+
EncoderRules.set_head_index_to_loc_format!(tables, 1)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
loca_orig_length = loca.bytesize
|
|
77
100
|
tables["glyf"] = GlyfLocaTransform.new(
|
|
78
|
-
glyf_data:
|
|
79
|
-
loca_data:
|
|
101
|
+
glyf_data: glyf,
|
|
102
|
+
loca_data: loca,
|
|
80
103
|
num_glyphs: maxp.num_glyphs,
|
|
81
|
-
index_format
|
|
104
|
+
index_format:,
|
|
82
105
|
).transform
|
|
83
106
|
tables.delete("loca")
|
|
84
107
|
loca_orig_length
|
|
@@ -29,11 +29,13 @@ module Fontisan
|
|
|
29
29
|
#
|
|
30
30
|
# @param table_data [Hash{String => String}] Map of tag → binary
|
|
31
31
|
# table data. Mutated.
|
|
32
|
+
# @param timestamp [Integer, Time, nil] Fixed value for head.modified
|
|
33
|
+
# (Unix seconds); nil stamps the current wall-clock time.
|
|
32
34
|
# @return [Hash{String => String}] The same hash, for chaining.
|
|
33
|
-
def self.apply!(table_data)
|
|
35
|
+
def self.apply!(table_data, timestamp: nil)
|
|
34
36
|
exclude_tables!(table_data)
|
|
35
37
|
mark_lossless_modifying!(table_data)
|
|
36
|
-
touch_head_modified!(table_data)
|
|
38
|
+
touch_head_modified!(table_data, timestamp: timestamp)
|
|
37
39
|
table_data
|
|
38
40
|
end
|
|
39
41
|
|
|
@@ -94,11 +96,17 @@ module Fontisan
|
|
|
94
96
|
# are included in the checksum.
|
|
95
97
|
#
|
|
96
98
|
# @param table_data [Hash{String => String}]
|
|
97
|
-
|
|
99
|
+
# @param timestamp [Integer, Time, nil] Unix seconds to stamp instead of
|
|
100
|
+
# the current time (reproducible output); nil keeps the clock.
|
|
101
|
+
def self.touch_head_modified!(table_data, timestamp: nil)
|
|
98
102
|
return unless table_data.key?("head")
|
|
99
103
|
|
|
100
104
|
head = Tables::Head.read(table_data["head"])
|
|
101
|
-
head.modified_raw =
|
|
105
|
+
head.modified_raw = if timestamp.nil?
|
|
106
|
+
Tables::Head.now_longdatetime
|
|
107
|
+
else
|
|
108
|
+
Tables::Head.to_longdatetime(timestamp)
|
|
109
|
+
end
|
|
102
110
|
table_data["head"] = head.to_binary_s
|
|
103
111
|
end
|
|
104
112
|
end
|
|
@@ -62,13 +62,14 @@ module Fontisan
|
|
|
62
62
|
#
|
|
63
63
|
# @param index [Integer] 0-based font index
|
|
64
64
|
# @param _io [IO, nil] Ignored.
|
|
65
|
-
# @param
|
|
66
|
-
#
|
|
65
|
+
# @param mode [Symbol] Loading mode, as for the TTC/OTC collections
|
|
66
|
+
# (default: full). Decoding is eager, so mode only limits which tables
|
|
67
|
+
# are kept on the returned font.
|
|
67
68
|
# @return [TrueTypeFont, OpenTypeFont, nil] nil if index out of range
|
|
68
|
-
def font(index, _io = nil,
|
|
69
|
+
def font(index, _io = nil, mode: LoadingModes::FULL)
|
|
69
70
|
return nil if index >= num_fonts
|
|
70
71
|
|
|
71
|
-
build_font(decoded[index])
|
|
72
|
+
build_font(decoded[index], mode)
|
|
72
73
|
end
|
|
73
74
|
|
|
74
75
|
# Whether this object represents a font collection. WOFF2 collections
|
|
@@ -108,12 +109,13 @@ module Fontisan
|
|
|
108
109
|
# Build a TrueTypeFont or OpenTypeFont from a decoded font entry.
|
|
109
110
|
# Constructs an in-memory SFNT binary from the per-table data and
|
|
110
111
|
# loads it through the normal font reader path.
|
|
111
|
-
def build_font(entry)
|
|
112
|
+
def build_font(entry, mode = LoadingModes::FULL)
|
|
112
113
|
sfnt = SfntBuilder.build(flavor: entry[:flavor], tables: entry[:tables])
|
|
113
114
|
sfnt_io = StringIO.new(sfnt)
|
|
114
115
|
font_class = truetype_flavor?(entry[:flavor]) ? TrueTypeFont : OpenTypeFont
|
|
115
116
|
font = font_class.read(sfnt_io)
|
|
116
117
|
font.initialize_storage
|
|
118
|
+
font.loading_mode = mode
|
|
117
119
|
font.read_table_data(StringIO.new(sfnt))
|
|
118
120
|
font
|
|
119
121
|
end
|
data/lib/fontisan/woff2_font.rb
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "bindata"
|
|
4
|
-
require "brotli"
|
|
5
4
|
require "stringio"
|
|
6
5
|
|
|
7
6
|
module Fontisan
|
|
@@ -266,7 +265,7 @@ module Fontisan
|
|
|
266
265
|
File.open(@io_source.path, "rb") do |io|
|
|
267
266
|
io.seek(@header.meta_offset)
|
|
268
267
|
compressed_meta = io.read(@header.meta_length)
|
|
269
|
-
|
|
268
|
+
Utilities::BrotliWrapper.library.inflate(compressed_meta)
|
|
270
269
|
end
|
|
271
270
|
rescue StandardError => e
|
|
272
271
|
warn "Failed to decompress metadata: #{e.message}"
|
|
@@ -516,7 +515,7 @@ module Fontisan
|
|
|
516
515
|
compressed_data = io.read(header.total_compressed_size)
|
|
517
516
|
|
|
518
517
|
# Decompress entire data block with Brotli
|
|
519
|
-
decompressed_data =
|
|
518
|
+
decompressed_data = inflate_brotli(compressed_data)
|
|
520
519
|
|
|
521
520
|
# Split decompressed data into individual tables
|
|
522
521
|
decompressed_tables = {}
|
|
@@ -533,6 +532,16 @@ module Fontisan
|
|
|
533
532
|
decompressed_tables
|
|
534
533
|
end
|
|
535
534
|
|
|
535
|
+
# Brotli-inflate the table block. A bad stream is the same InvalidFontError
|
|
536
|
+
# on every tier (the native tiers say "brotli stream is corrupt"); the
|
|
537
|
+
# binding's own message and class are not a contract.
|
|
538
|
+
def self.inflate_brotli(data)
|
|
539
|
+
Utilities::BrotliWrapper.library.inflate(data)
|
|
540
|
+
rescue StandardError
|
|
541
|
+
raise InvalidFontError.new("Invalid WOFF2 file: woff: brotli stream is corrupt"), cause: nil
|
|
542
|
+
end
|
|
543
|
+
private_class_method :inflate_brotli
|
|
544
|
+
|
|
536
545
|
# Apply table transformations for glyf/loca/hmtx tables
|
|
537
546
|
#
|
|
538
547
|
# @param table_entries [Array<Woff2TableDirectoryEntry>] Table entries
|