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
|
@@ -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}"
|
|
@@ -44,7 +44,7 @@ module Fontisan
|
|
|
44
44
|
glyf = font.table("glyf")
|
|
45
45
|
next true unless glyf # Skip if CFF font
|
|
46
46
|
|
|
47
|
-
loca = font
|
|
47
|
+
loca = parsed_loca(font)
|
|
48
48
|
head = font.table("head")
|
|
49
49
|
maxp = font.table("maxp")
|
|
50
50
|
glyf.all_glyphs_accessible?(loca, head, maxp.num_glyphs)
|
|
@@ -55,7 +55,7 @@ module Fontisan
|
|
|
55
55
|
glyf = font.table("glyf")
|
|
56
56
|
next true unless glyf
|
|
57
57
|
|
|
58
|
-
loca = font
|
|
58
|
+
loca = parsed_loca(font)
|
|
59
59
|
head = font.table("head")
|
|
60
60
|
maxp = font.table("maxp")
|
|
61
61
|
glyf.no_clipped_glyphs?(loca, head, maxp.num_glyphs)
|
|
@@ -66,7 +66,7 @@ module Fontisan
|
|
|
66
66
|
glyf = font.table("glyf")
|
|
67
67
|
next true unless glyf
|
|
68
68
|
|
|
69
|
-
loca = font
|
|
69
|
+
loca = parsed_loca(font)
|
|
70
70
|
head = font.table("head")
|
|
71
71
|
maxp = font.table("maxp")
|
|
72
72
|
|
|
@@ -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
|
|
@@ -101,6 +103,16 @@ module Fontisan
|
|
|
101
103
|
true # Placeholder - actual checksum validation if desired
|
|
102
104
|
end
|
|
103
105
|
end
|
|
106
|
+
|
|
107
|
+
# loca must be parsed with head/maxp context before glyf can read it.
|
|
108
|
+
def parsed_loca(font)
|
|
109
|
+
loca = font.table("loca")
|
|
110
|
+
return loca if loca.parsed?
|
|
111
|
+
|
|
112
|
+
loca.parse_with_context(font.table("head").index_to_loc_format,
|
|
113
|
+
font.table("maxp").num_glyphs)
|
|
114
|
+
loca
|
|
115
|
+
end
|
|
104
116
|
end
|
|
105
117
|
end
|
|
106
118
|
end
|
|
@@ -25,12 +25,6 @@ module Fontisan
|
|
|
25
25
|
# @return [Object] The variable font object
|
|
26
26
|
attr_reader :font
|
|
27
27
|
|
|
28
|
-
# @return [DeltaApplicator] Delta applicator
|
|
29
|
-
attr_reader :delta_applicator
|
|
30
|
-
|
|
31
|
-
# @return [StaticFontBuilder] Static font builder
|
|
32
|
-
attr_reader :static_font_builder
|
|
33
|
-
|
|
34
28
|
# Initialize the instancer
|
|
35
29
|
#
|
|
36
30
|
# @param font [TrueTypeFont, OpenTypeFont] Variable font object
|
|
@@ -38,9 +32,6 @@ module Fontisan
|
|
|
38
32
|
def initialize(font)
|
|
39
33
|
@font = font
|
|
40
34
|
validate_variable_font!
|
|
41
|
-
|
|
42
|
-
@delta_applicator = DeltaApplicator.new(font)
|
|
43
|
-
@static_font_builder = StaticFontBuilder.new(font)
|
|
44
35
|
end
|
|
45
36
|
|
|
46
37
|
# Generate static font instance at specified coordinates
|
|
@@ -53,21 +44,11 @@ module Fontisan
|
|
|
53
44
|
#
|
|
54
45
|
# @example
|
|
55
46
|
# binary = instancer.instance({ "wght" => 700, "wdth" => 100 })
|
|
56
|
-
def instance(user_coords,
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
varied_metrics = collect_varied_glyph_metrics(
|
|
62
|
-
delta_result[:normalized_coords],
|
|
63
|
-
delta_result[:region_scalars],
|
|
64
|
-
)
|
|
65
|
-
|
|
66
|
-
# Extract font-level metrics
|
|
67
|
-
font_metrics = delta_result[:font_metrics]
|
|
68
|
-
|
|
69
|
-
# Build static font
|
|
70
|
-
@static_font_builder.build(varied_metrics, font_metrics, options)
|
|
47
|
+
def instance(user_coords, _options = {})
|
|
48
|
+
Fontisan::Engine.record_ruby(:instance)
|
|
49
|
+
tables = Variation::Instancing::TrueTypeInstancer
|
|
50
|
+
.new(@font.table_data, user_coords).call
|
|
51
|
+
FontWriter.write_font(tables)
|
|
71
52
|
end
|
|
72
53
|
|
|
73
54
|
# Generate static font instance and write to file
|
|
@@ -117,23 +98,16 @@ module Fontisan
|
|
|
117
98
|
# @return [Array<Hash>] Array of named instance information
|
|
118
99
|
# [{ name: "Bold", coords: { "wght" => 700 } }, ...]
|
|
119
100
|
def named_instances
|
|
120
|
-
fvar =
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
fvar.instances.map do |instance|
|
|
127
|
-
coords = extract_instance_coords(instance, fvar)
|
|
128
|
-
name = if name_table
|
|
129
|
-
get_instance_name(instance[:name_id],
|
|
130
|
-
name_table)
|
|
131
|
-
end
|
|
132
|
-
|
|
101
|
+
fvar = @font.table_data["fvar"]
|
|
102
|
+
names = if @font.table_data["name"]
|
|
103
|
+
Variation::Instancing::NameRewriter.new(@font.table_data["name"])
|
|
104
|
+
end
|
|
105
|
+
tags = axis_tags
|
|
106
|
+
Variation::Instancing::AxisSpace.named_instances(fvar).map do |inst|
|
|
133
107
|
{
|
|
134
|
-
name_id:
|
|
135
|
-
name:
|
|
136
|
-
coordinates:
|
|
108
|
+
name_id: inst[:name_id],
|
|
109
|
+
name: names&.lookup(inst[:name_id]) || "Instance #{inst[:name_id]}",
|
|
110
|
+
coordinates: tags.zip(inst[:values]).to_h,
|
|
137
111
|
}
|
|
138
112
|
end
|
|
139
113
|
end
|
|
@@ -142,21 +116,21 @@ module Fontisan
|
|
|
142
116
|
#
|
|
143
117
|
# @return [Boolean] True if variable font
|
|
144
118
|
def variable_font?
|
|
145
|
-
|
|
119
|
+
true
|
|
146
120
|
end
|
|
147
121
|
|
|
148
122
|
# Get available axis information
|
|
149
123
|
#
|
|
150
|
-
# @return [
|
|
124
|
+
# @return [Array<Variation::Instancing::AxisSpace::Axis>]
|
|
151
125
|
def axes
|
|
152
|
-
|
|
126
|
+
axis_space.axes
|
|
153
127
|
end
|
|
154
128
|
|
|
155
129
|
# Get available axis tags
|
|
156
130
|
#
|
|
157
131
|
# @return [Array<String>] Array of axis tags
|
|
158
132
|
def axis_tags
|
|
159
|
-
|
|
133
|
+
axis_space.tags
|
|
160
134
|
end
|
|
161
135
|
|
|
162
136
|
private
|
|
@@ -165,60 +139,16 @@ module Fontisan
|
|
|
165
139
|
#
|
|
166
140
|
# @raise [ArgumentError] If not a variable font
|
|
167
141
|
def validate_variable_font!
|
|
168
|
-
fvar_data = @font.table_data
|
|
142
|
+
fvar_data = @font.table_data["fvar"]
|
|
169
143
|
return unless fvar_data.nil? || fvar_data.empty?
|
|
170
144
|
|
|
171
145
|
raise ArgumentError, "Font is not a variable font (missing fvar table)"
|
|
172
146
|
end
|
|
173
147
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
# @return [Hash<Integer, Hash>] Varied metrics by glyph ID
|
|
179
|
-
def collect_varied_glyph_metrics(normalized_coords, _region_scalars)
|
|
180
|
-
varied_metrics = {}
|
|
181
|
-
|
|
182
|
-
# Get number of glyphs
|
|
183
|
-
maxp = load_maxp_table
|
|
184
|
-
return varied_metrics unless maxp
|
|
185
|
-
|
|
186
|
-
num_glyphs = maxp.num_glyphs
|
|
187
|
-
|
|
188
|
-
# Get original hmtx metrics
|
|
189
|
-
hmtx = load_hmtx_table
|
|
190
|
-
return varied_metrics unless hmtx
|
|
191
|
-
|
|
192
|
-
# For each glyph, calculate varied metrics
|
|
193
|
-
num_glyphs.times do |glyph_id|
|
|
194
|
-
original_metric = hmtx.metric_for(glyph_id)
|
|
195
|
-
next unless original_metric
|
|
196
|
-
|
|
197
|
-
# Get deltas from delta applicator
|
|
198
|
-
deltas = @delta_applicator.apply_glyph(glyph_id, normalized_coords)
|
|
199
|
-
metric_deltas = deltas[:metric_deltas]
|
|
200
|
-
|
|
201
|
-
# Calculate varied values
|
|
202
|
-
varied_metric = {
|
|
203
|
-
advance_width: original_metric[:advance_width],
|
|
204
|
-
lsb: original_metric[:lsb],
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
# Apply horizontal deltas if present
|
|
208
|
-
if metric_deltas[:horizontal]
|
|
209
|
-
if metric_deltas[:horizontal][:advance_width]
|
|
210
|
-
varied_metric[:advance_width] += metric_deltas[:horizontal][:advance_width]
|
|
211
|
-
end
|
|
212
|
-
|
|
213
|
-
if metric_deltas[:horizontal][:lsb]
|
|
214
|
-
varied_metric[:lsb] += metric_deltas[:horizontal][:lsb]
|
|
215
|
-
end
|
|
216
|
-
end
|
|
217
|
-
|
|
218
|
-
varied_metrics[glyph_id] = varied_metric
|
|
219
|
-
end
|
|
220
|
-
|
|
221
|
-
varied_metrics
|
|
148
|
+
def axis_space
|
|
149
|
+
@axis_space ||= Variation::Instancing::AxisSpace.new(
|
|
150
|
+
@font.table_data["fvar"], @font.table_data["avar"]
|
|
151
|
+
)
|
|
222
152
|
end
|
|
223
153
|
|
|
224
154
|
# Find coordinates for a named instance
|
|
@@ -237,105 +167,6 @@ module Fontisan
|
|
|
237
167
|
|
|
238
168
|
instance[:coordinates]
|
|
239
169
|
end
|
|
240
|
-
|
|
241
|
-
# Extract coordinates from instance record
|
|
242
|
-
#
|
|
243
|
-
# @param instance [Hash] Instance record
|
|
244
|
-
# @param fvar [Fvar] fvar table
|
|
245
|
-
# @return [Hash<String, Float>] Coordinates by axis tag
|
|
246
|
-
def extract_instance_coords(instance, fvar)
|
|
247
|
-
coords = {}
|
|
248
|
-
instance[:coordinates].each_with_index do |value, index|
|
|
249
|
-
axis = fvar.axes[index]
|
|
250
|
-
coords[axis.axis_tag.to_s] = value if axis
|
|
251
|
-
end
|
|
252
|
-
coords
|
|
253
|
-
end
|
|
254
|
-
|
|
255
|
-
# Get instance name from name table
|
|
256
|
-
#
|
|
257
|
-
# @param name_id [Integer] Name table ID
|
|
258
|
-
# @param name_table [Name] name table
|
|
259
|
-
# @return [String, nil] Instance name
|
|
260
|
-
def get_instance_name(name_id, name_table)
|
|
261
|
-
# Try to get English name
|
|
262
|
-
record = name_table.records.find do |r|
|
|
263
|
-
r.name_id == name_id && r.language_id == 0x0409 # English (US)
|
|
264
|
-
end
|
|
265
|
-
|
|
266
|
-
record ||= name_table.records.find { |r| r.name_id == name_id }
|
|
267
|
-
record&.value
|
|
268
|
-
rescue StandardError
|
|
269
|
-
nil
|
|
270
|
-
end
|
|
271
|
-
|
|
272
|
-
# Load fvar table
|
|
273
|
-
#
|
|
274
|
-
# @return [Fvar, nil] fvar table or nil
|
|
275
|
-
def load_fvar_table
|
|
276
|
-
data = @font.table_data("fvar")
|
|
277
|
-
return nil if data.nil? || data.empty?
|
|
278
|
-
|
|
279
|
-
Tables::Fvar.read(data)
|
|
280
|
-
rescue StandardError
|
|
281
|
-
nil
|
|
282
|
-
end
|
|
283
|
-
|
|
284
|
-
# Load name table
|
|
285
|
-
#
|
|
286
|
-
# @return [Name, nil] name table or nil
|
|
287
|
-
def load_name_table
|
|
288
|
-
data = @font.table_data("name")
|
|
289
|
-
return nil if data.nil? || data.empty?
|
|
290
|
-
|
|
291
|
-
Tables::Name.read(data)
|
|
292
|
-
rescue StandardError
|
|
293
|
-
nil
|
|
294
|
-
end
|
|
295
|
-
|
|
296
|
-
# Load maxp table
|
|
297
|
-
#
|
|
298
|
-
# @return [Maxp, nil] maxp table or nil
|
|
299
|
-
def load_maxp_table
|
|
300
|
-
data = @font.table_data("maxp")
|
|
301
|
-
return nil if data.nil? || data.empty?
|
|
302
|
-
|
|
303
|
-
Tables::Maxp.read(data)
|
|
304
|
-
rescue StandardError
|
|
305
|
-
nil
|
|
306
|
-
end
|
|
307
|
-
|
|
308
|
-
# Load hmtx table
|
|
309
|
-
#
|
|
310
|
-
# @return [Hmtx, nil] hmtx table or nil
|
|
311
|
-
def load_hmtx_table
|
|
312
|
-
data = @font.table_data("hmtx")
|
|
313
|
-
return nil if data.nil? || data.empty?
|
|
314
|
-
|
|
315
|
-
hmtx = Tables::Hmtx.read(data)
|
|
316
|
-
|
|
317
|
-
# Parse with context
|
|
318
|
-
hhea = load_hhea_table
|
|
319
|
-
maxp = load_maxp_table
|
|
320
|
-
return nil unless hhea && maxp
|
|
321
|
-
|
|
322
|
-
hmtx.parse_with_context(hhea.number_of_h_metrics, maxp.num_glyphs)
|
|
323
|
-
hmtx
|
|
324
|
-
rescue StandardError
|
|
325
|
-
nil
|
|
326
|
-
end
|
|
327
|
-
|
|
328
|
-
# Load hhea table
|
|
329
|
-
#
|
|
330
|
-
# @return [Hhea, nil] hhea table or nil
|
|
331
|
-
def load_hhea_table
|
|
332
|
-
data = @font.table_data("hhea")
|
|
333
|
-
return nil if data.nil? || data.empty?
|
|
334
|
-
|
|
335
|
-
Tables::Hhea.read(data)
|
|
336
|
-
rescue StandardError
|
|
337
|
-
nil
|
|
338
|
-
end
|
|
339
170
|
end
|
|
340
171
|
end
|
|
341
172
|
end
|
|
@@ -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
|
|