fontisan 0.2.0 → 0.2.2
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/.rubocop_todo.yml +119 -308
- data/README.adoc +1525 -1323
- data/Rakefile +45 -47
- data/benchmark/variation_quick_bench.rb +4 -4
- data/docs/FONT_HINTING.adoc +562 -0
- data/docs/VARIABLE_FONT_OPERATIONS.adoc +599 -0
- data/lib/fontisan/cli.rb +92 -34
- data/lib/fontisan/collection/builder.rb +82 -0
- data/lib/fontisan/collection/offset_calculator.rb +2 -0
- data/lib/fontisan/collection/table_deduplicator.rb +76 -0
- data/lib/fontisan/commands/base_command.rb +21 -2
- data/lib/fontisan/commands/convert_command.rb +96 -165
- data/lib/fontisan/commands/info_command.rb +111 -5
- data/lib/fontisan/commands/instance_command.rb +77 -85
- data/lib/fontisan/commands/validate_command.rb +28 -0
- data/lib/fontisan/config/validation_rules.yml +1 -1
- data/lib/fontisan/constants.rb +34 -24
- data/lib/fontisan/converters/format_converter.rb +154 -1
- data/lib/fontisan/converters/outline_converter.rb +101 -34
- data/lib/fontisan/converters/woff_writer.rb +9 -4
- data/lib/fontisan/font_loader.rb +14 -9
- data/lib/fontisan/font_writer.rb +9 -6
- data/lib/fontisan/formatters/text_formatter.rb +45 -1
- data/lib/fontisan/hints/hint_converter.rb +131 -2
- data/lib/fontisan/hints/hint_validator.rb +284 -0
- data/lib/fontisan/hints/postscript_hint_applier.rb +219 -140
- data/lib/fontisan/hints/postscript_hint_extractor.rb +151 -16
- data/lib/fontisan/hints/truetype_hint_applier.rb +90 -44
- data/lib/fontisan/hints/truetype_hint_extractor.rb +134 -11
- data/lib/fontisan/hints/truetype_instruction_analyzer.rb +261 -0
- data/lib/fontisan/hints/truetype_instruction_generator.rb +266 -0
- data/lib/fontisan/loading_modes.rb +6 -4
- data/lib/fontisan/models/collection_brief_info.rb +31 -0
- data/lib/fontisan/models/font_info.rb +3 -30
- data/lib/fontisan/models/hint.rb +183 -12
- data/lib/fontisan/models/outline.rb +4 -1
- data/lib/fontisan/open_type_font.rb +28 -10
- data/lib/fontisan/open_type_font_extensions.rb +54 -0
- data/lib/fontisan/optimizers/pattern_analyzer.rb +2 -1
- data/lib/fontisan/optimizers/subroutine_generator.rb +1 -1
- data/lib/fontisan/pipeline/format_detector.rb +249 -0
- data/lib/fontisan/pipeline/output_writer.rb +159 -0
- data/lib/fontisan/pipeline/strategies/base_strategy.rb +75 -0
- data/lib/fontisan/pipeline/strategies/instance_strategy.rb +93 -0
- data/lib/fontisan/pipeline/strategies/named_strategy.rb +118 -0
- data/lib/fontisan/pipeline/strategies/preserve_strategy.rb +56 -0
- data/lib/fontisan/pipeline/transformation_pipeline.rb +416 -0
- data/lib/fontisan/pipeline/variation_resolver.rb +165 -0
- data/lib/fontisan/subset/table_subsetter.rb +5 -5
- data/lib/fontisan/tables/cff/charstring.rb +58 -3
- data/lib/fontisan/tables/cff/charstring_builder.rb +34 -0
- data/lib/fontisan/tables/cff/charstring_parser.rb +249 -0
- data/lib/fontisan/tables/cff/charstring_rebuilder.rb +172 -0
- data/lib/fontisan/tables/cff/dict_builder.rb +19 -1
- data/lib/fontisan/tables/cff/hint_operation_injector.rb +209 -0
- data/lib/fontisan/tables/cff/offset_recalculator.rb +70 -0
- data/lib/fontisan/tables/cff/private_dict_writer.rb +131 -0
- data/lib/fontisan/tables/cff/table_builder.rb +221 -0
- data/lib/fontisan/tables/cff.rb +2 -0
- data/lib/fontisan/tables/cff2/charstring_parser.rb +14 -8
- data/lib/fontisan/tables/cff2/private_dict_blend_handler.rb +247 -0
- data/lib/fontisan/tables/cff2/region_matcher.rb +200 -0
- data/lib/fontisan/tables/cff2/table_builder.rb +580 -0
- data/lib/fontisan/tables/cff2/table_reader.rb +421 -0
- data/lib/fontisan/tables/cff2/variation_data_extractor.rb +212 -0
- data/lib/fontisan/tables/cff2.rb +10 -5
- data/lib/fontisan/tables/cvar.rb +2 -41
- data/lib/fontisan/tables/glyf/compound_glyph_resolver.rb +2 -1
- data/lib/fontisan/tables/glyf/curve_converter.rb +10 -4
- data/lib/fontisan/tables/glyf/glyph_builder.rb +27 -10
- data/lib/fontisan/tables/gvar.rb +2 -41
- data/lib/fontisan/tables/name.rb +4 -4
- data/lib/fontisan/true_type_font.rb +27 -10
- data/lib/fontisan/true_type_font_extensions.rb +54 -0
- data/lib/fontisan/utilities/checksum_calculator.rb +42 -0
- data/lib/fontisan/validation/checksum_validator.rb +2 -2
- data/lib/fontisan/validation/table_validator.rb +1 -1
- data/lib/fontisan/validation/variable_font_validator.rb +218 -0
- data/lib/fontisan/variation/cache.rb +3 -1
- data/lib/fontisan/variation/converter.rb +121 -13
- data/lib/fontisan/variation/delta_applier.rb +2 -1
- data/lib/fontisan/variation/inspector.rb +2 -1
- data/lib/fontisan/variation/instance_generator.rb +2 -1
- data/lib/fontisan/variation/instance_writer.rb +341 -0
- data/lib/fontisan/variation/optimizer.rb +6 -3
- data/lib/fontisan/variation/subsetter.rb +32 -10
- data/lib/fontisan/variation/tuple_variation_header.rb +51 -0
- data/lib/fontisan/variation/variable_svg_generator.rb +268 -0
- data/lib/fontisan/variation/variation_preserver.rb +291 -0
- data/lib/fontisan/version.rb +1 -1
- data/lib/fontisan/version.rb.orig +9 -0
- data/lib/fontisan/woff2/glyf_transformer.rb +693 -0
- data/lib/fontisan/woff2/hmtx_transformer.rb +164 -0
- data/lib/fontisan/woff2_font.rb +489 -468
- data/lib/fontisan/woff_font.rb +16 -11
- data/lib/fontisan.rb +54 -2
- data/scripts/measure_optimization.rb +15 -7
- metadata +37 -2
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "stringio"
|
|
4
|
+
require_relative "../../binary/base_record"
|
|
5
|
+
|
|
6
|
+
module Fontisan
|
|
7
|
+
module Tables
|
|
8
|
+
class Cff2
|
|
9
|
+
# Table reader for CFF2 with Variable Store support
|
|
10
|
+
#
|
|
11
|
+
# CFF2TableReader parses CFF2 tables and extracts variation data
|
|
12
|
+
# from the Variable Store, which is essential for applying hints
|
|
13
|
+
# to variable fonts with CFF2 outlines.
|
|
14
|
+
#
|
|
15
|
+
# Variable Store Structure:
|
|
16
|
+
# - RegionList: Defines variation regions (min/peak/max per axis)
|
|
17
|
+
# - ItemVariationData: Contains delta arrays per region
|
|
18
|
+
#
|
|
19
|
+
# Reference: Adobe Technical Note #5177 (CFF2)
|
|
20
|
+
# Reference: OpenType spec - Item Variation Store
|
|
21
|
+
#
|
|
22
|
+
# @example Reading CFF2 with Variable Store
|
|
23
|
+
# reader = CFF2TableReader.new(cff2_data)
|
|
24
|
+
# store = reader.read_variable_store
|
|
25
|
+
# regions = store[:regions]
|
|
26
|
+
# deltas = store[:deltas]
|
|
27
|
+
class TableReader
|
|
28
|
+
# @return [String] Binary CFF2 data
|
|
29
|
+
attr_reader :data
|
|
30
|
+
|
|
31
|
+
# @return [Hash] CFF2 header information
|
|
32
|
+
attr_reader :header
|
|
33
|
+
|
|
34
|
+
# @return [Hash] Top DICT data
|
|
35
|
+
attr_reader :top_dict
|
|
36
|
+
|
|
37
|
+
# @return [Hash, nil] Variable Store data
|
|
38
|
+
attr_reader :variable_store
|
|
39
|
+
|
|
40
|
+
# CFF2-specific operators
|
|
41
|
+
VSTORE_OPERATOR = 24
|
|
42
|
+
|
|
43
|
+
# Initialize reader with CFF2 data
|
|
44
|
+
#
|
|
45
|
+
# @param data [String] Binary CFF2 table data
|
|
46
|
+
def initialize(data)
|
|
47
|
+
@data = data
|
|
48
|
+
@io = StringIO.new(data)
|
|
49
|
+
@io.set_encoding(Encoding::BINARY)
|
|
50
|
+
@header = nil
|
|
51
|
+
@top_dict = nil
|
|
52
|
+
@variable_store = nil
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Read CFF2 header
|
|
56
|
+
#
|
|
57
|
+
# @return [Hash] Header information
|
|
58
|
+
def read_header
|
|
59
|
+
@io.rewind
|
|
60
|
+
@header = {
|
|
61
|
+
major_version: read_uint8,
|
|
62
|
+
minor_version: read_uint8,
|
|
63
|
+
header_size: read_uint8,
|
|
64
|
+
top_dict_length: read_uint16,
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
# Validate CFF2 version
|
|
68
|
+
unless @header[:major_version] == 2 && @header[:minor_version].zero?
|
|
69
|
+
raise CorruptedTableError,
|
|
70
|
+
"Invalid CFF2 version: #{@header[:major_version]}.#{@header[:minor_version]}"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
@header
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Read Top DICT
|
|
77
|
+
#
|
|
78
|
+
# @return [Hash] Top DICT operators and values
|
|
79
|
+
def read_top_dict
|
|
80
|
+
read_header unless @header
|
|
81
|
+
|
|
82
|
+
# Seek to Top DICT (after header)
|
|
83
|
+
@io.seek(@header[:header_size])
|
|
84
|
+
|
|
85
|
+
top_dict_data = @io.read(@header[:top_dict_length])
|
|
86
|
+
@top_dict = parse_dict(top_dict_data)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Read Variable Store from Top DICT
|
|
90
|
+
#
|
|
91
|
+
# The Variable Store is referenced by the vstore operator (24)
|
|
92
|
+
# in the Top DICT. It contains regions and deltas for variation.
|
|
93
|
+
#
|
|
94
|
+
# @return [Hash, nil] Variable Store data with :regions and :deltas
|
|
95
|
+
def read_variable_store
|
|
96
|
+
read_top_dict unless @top_dict
|
|
97
|
+
|
|
98
|
+
# Check if Variable Store is present (operator 24)
|
|
99
|
+
vstore_offset = @top_dict[VSTORE_OPERATOR]
|
|
100
|
+
return nil unless vstore_offset
|
|
101
|
+
|
|
102
|
+
# Seek to Variable Store
|
|
103
|
+
@io.seek(vstore_offset)
|
|
104
|
+
|
|
105
|
+
# Parse Variable Store structure
|
|
106
|
+
@variable_store = {
|
|
107
|
+
regions: read_region_list,
|
|
108
|
+
item_variation_data: read_item_variation_data,
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
@variable_store
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Read Region List from Variable Store
|
|
115
|
+
#
|
|
116
|
+
# Region List defines variation regions, where each region
|
|
117
|
+
# specifies min/peak/max values per axis.
|
|
118
|
+
#
|
|
119
|
+
# @return [Array<Hash>] Array of region definitions
|
|
120
|
+
def read_region_list
|
|
121
|
+
region_count = read_uint16
|
|
122
|
+
regions = []
|
|
123
|
+
|
|
124
|
+
region_count.times do
|
|
125
|
+
region = read_region
|
|
126
|
+
regions << region
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
regions
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Read a single region
|
|
133
|
+
#
|
|
134
|
+
# @return [Hash] Region with axis coordinates
|
|
135
|
+
def read_region
|
|
136
|
+
axis_count = read_uint16
|
|
137
|
+
axes = []
|
|
138
|
+
|
|
139
|
+
axis_count.times do
|
|
140
|
+
axes << {
|
|
141
|
+
start_coord: read_f2dot14,
|
|
142
|
+
peak_coord: read_f2dot14,
|
|
143
|
+
end_coord: read_f2dot14,
|
|
144
|
+
}
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
{ axis_count: axis_count, axes: axes }
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Read Item Variation Data
|
|
151
|
+
#
|
|
152
|
+
# Contains delta arrays per region for varying values
|
|
153
|
+
#
|
|
154
|
+
# @return [Array<Hash>] Array of item variation data
|
|
155
|
+
def read_item_variation_data
|
|
156
|
+
data_count = read_uint16
|
|
157
|
+
return [] if data_count.zero?
|
|
158
|
+
|
|
159
|
+
item_variation_data = []
|
|
160
|
+
|
|
161
|
+
data_count.times do |_idx|
|
|
162
|
+
item_data = read_single_item_variation_data
|
|
163
|
+
item_variation_data << item_data
|
|
164
|
+
rescue EOFError
|
|
165
|
+
# break
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
item_variation_data
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Read a single Item Variation Data entry
|
|
172
|
+
#
|
|
173
|
+
# @return [Hash] Item variation data with region indices and deltas
|
|
174
|
+
def read_single_item_variation_data
|
|
175
|
+
item_count = read_uint16
|
|
176
|
+
short_delta_count = read_uint16
|
|
177
|
+
region_index_count = read_uint16
|
|
178
|
+
|
|
179
|
+
# Read region indices
|
|
180
|
+
region_indices = []
|
|
181
|
+
region_index_count.times do
|
|
182
|
+
region_indices << read_uint16
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Read delta sets
|
|
186
|
+
delta_sets = []
|
|
187
|
+
item_count.times do |_item_idx|
|
|
188
|
+
deltas = []
|
|
189
|
+
|
|
190
|
+
# Short deltas (16-bit)
|
|
191
|
+
short_delta_count.times do
|
|
192
|
+
break if @io.eof?
|
|
193
|
+
|
|
194
|
+
deltas << read_int16
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Long deltas (8-bit) for remaining regions
|
|
198
|
+
(region_index_count - short_delta_count).times do
|
|
199
|
+
break if @io.eof?
|
|
200
|
+
|
|
201
|
+
deltas << read_int8
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
delta_sets << deltas
|
|
205
|
+
rescue EOFError
|
|
206
|
+
# break
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
{
|
|
210
|
+
item_count: item_count,
|
|
211
|
+
region_indices: region_indices,
|
|
212
|
+
delta_sets: delta_sets,
|
|
213
|
+
}
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# Read Private DICT with blend support
|
|
217
|
+
#
|
|
218
|
+
# Private DICT in CFF2 can contain blend operators for
|
|
219
|
+
# variable hint parameters.
|
|
220
|
+
#
|
|
221
|
+
# @param size [Integer] Private DICT size
|
|
222
|
+
# @param offset [Integer] Private DICT offset
|
|
223
|
+
# @return [Hash] Private DICT data
|
|
224
|
+
def read_private_dict(size, offset)
|
|
225
|
+
@io.seek(offset)
|
|
226
|
+
private_dict_data = @io.read(size)
|
|
227
|
+
parse_dict(private_dict_data)
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# Read CharStrings INDEX
|
|
231
|
+
#
|
|
232
|
+
# @param offset [Integer] CharStrings offset from Top DICT
|
|
233
|
+
# @return [Cff::Index] CharStrings INDEX
|
|
234
|
+
def read_charstrings(offset)
|
|
235
|
+
@io.seek(offset)
|
|
236
|
+
require_relative "../cff/index"
|
|
237
|
+
Cff::Index.new(@io, start_offset: offset)
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
private
|
|
241
|
+
|
|
242
|
+
# Read bytes safely with EOF checking
|
|
243
|
+
#
|
|
244
|
+
# @param bytes [Integer] Number of bytes to read
|
|
245
|
+
# @param description [String] Description for error messages
|
|
246
|
+
# @return [String] Binary data
|
|
247
|
+
# @raise [EOFError] If not enough bytes available
|
|
248
|
+
def read_safely(bytes, description)
|
|
249
|
+
data = @io.read(bytes)
|
|
250
|
+
if data.nil? || data.bytesize < bytes
|
|
251
|
+
raise EOFError,
|
|
252
|
+
"Unexpected EOF while reading #{description}"
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
data
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# Parse DICT structure
|
|
259
|
+
#
|
|
260
|
+
# @param data [String] DICT binary data
|
|
261
|
+
# @return [Hash] Parsed operators and values
|
|
262
|
+
def parse_dict(data)
|
|
263
|
+
dict = {}
|
|
264
|
+
io = StringIO.new(data)
|
|
265
|
+
io.set_encoding(Encoding::BINARY)
|
|
266
|
+
operands = []
|
|
267
|
+
|
|
268
|
+
until io.eof?
|
|
269
|
+
byte = io.getbyte
|
|
270
|
+
|
|
271
|
+
if operator_byte?(byte)
|
|
272
|
+
operator = read_dict_operator(io, byte)
|
|
273
|
+
dict[operator] =
|
|
274
|
+
operands.size == 1 ? operands.first : operands.dup
|
|
275
|
+
operands.clear
|
|
276
|
+
else
|
|
277
|
+
# Operand (number)
|
|
278
|
+
io.pos -= 1
|
|
279
|
+
operands << read_dict_number(io)
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
dict
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
# Check if byte is an operator
|
|
287
|
+
#
|
|
288
|
+
# CFF2 extends the operator range to include operator 24 (vstore)
|
|
289
|
+
#
|
|
290
|
+
# @param byte [Integer] Byte value
|
|
291
|
+
# @return [Boolean] True if operator
|
|
292
|
+
def operator_byte?(byte)
|
|
293
|
+
# Standard DICT operators (0-21, excluding number markers)
|
|
294
|
+
return true if byte <= 21 && ![12, 28, 29, 30, 31].include?(byte)
|
|
295
|
+
|
|
296
|
+
# CFF2-specific operators
|
|
297
|
+
return true if byte == VSTORE_OPERATOR
|
|
298
|
+
|
|
299
|
+
false
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
# Read DICT operator
|
|
303
|
+
#
|
|
304
|
+
# @param io [StringIO] Input stream
|
|
305
|
+
# @param first_byte [Integer] First operator byte
|
|
306
|
+
# @return [Integer, Array<Integer>] Operator code
|
|
307
|
+
def read_dict_operator(io, first_byte)
|
|
308
|
+
if first_byte == 12
|
|
309
|
+
# Two-byte operator
|
|
310
|
+
second_byte = io.getbyte
|
|
311
|
+
[12, second_byte]
|
|
312
|
+
else
|
|
313
|
+
first_byte
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
# Read number from DICT
|
|
318
|
+
#
|
|
319
|
+
# @param io [StringIO] Input stream
|
|
320
|
+
# @return [Integer, Float] Number value
|
|
321
|
+
def read_dict_number(io)
|
|
322
|
+
byte = io.getbyte
|
|
323
|
+
|
|
324
|
+
case byte
|
|
325
|
+
when 28
|
|
326
|
+
# 3-byte signed integer
|
|
327
|
+
b1 = io.getbyte
|
|
328
|
+
b2 = io.getbyte
|
|
329
|
+
value = (b1 << 8) | b2
|
|
330
|
+
value > 0x7FFF ? value - 0x10000 : value
|
|
331
|
+
when 29
|
|
332
|
+
# 5-byte signed integer
|
|
333
|
+
io.read(4).unpack1("l>")
|
|
334
|
+
when 30
|
|
335
|
+
# Real number
|
|
336
|
+
read_real_number(io)
|
|
337
|
+
when 32..246
|
|
338
|
+
byte - 139
|
|
339
|
+
when 247..250
|
|
340
|
+
b2 = io.getbyte
|
|
341
|
+
(byte - 247) * 256 + b2 + 108
|
|
342
|
+
when 251..254
|
|
343
|
+
b2 = io.getbyte
|
|
344
|
+
-(byte - 251) * 256 - b2 - 108
|
|
345
|
+
else
|
|
346
|
+
0
|
|
347
|
+
end
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
# Read real number from DICT
|
|
351
|
+
#
|
|
352
|
+
# @param io [StringIO] Input stream
|
|
353
|
+
# @return [Float] Real number
|
|
354
|
+
def read_real_number(io)
|
|
355
|
+
nibbles = []
|
|
356
|
+
loop do
|
|
357
|
+
byte = io.getbyte
|
|
358
|
+
nibbles << ((byte >> 4) & 0x0F)
|
|
359
|
+
nibbles << (byte & 0x0F)
|
|
360
|
+
break if (byte & 0x0F) == 0x0F
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
str = ""
|
|
364
|
+
nibbles.each do |nibble|
|
|
365
|
+
case nibble
|
|
366
|
+
when 0..9 then str << nibble.to_s
|
|
367
|
+
when 0x0A then str << "."
|
|
368
|
+
when 0x0B then str << "E"
|
|
369
|
+
when 0x0C then str << "E-"
|
|
370
|
+
when 0x0E then str << "-"
|
|
371
|
+
when 0x0F then break
|
|
372
|
+
end
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
str.to_f
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
# Read unsigned 8-bit integer
|
|
379
|
+
#
|
|
380
|
+
# @return [Integer] Value
|
|
381
|
+
def read_uint8
|
|
382
|
+
read_safely(1, "uint8").unpack1("C")
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
# Read unsigned 16-bit integer (big-endian)
|
|
386
|
+
#
|
|
387
|
+
# @return [Integer] Value
|
|
388
|
+
def read_uint16
|
|
389
|
+
read_safely(2, "uint16").unpack1("n")
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
# Read signed 16-bit integer (big-endian)
|
|
393
|
+
#
|
|
394
|
+
# @return [Integer] Value
|
|
395
|
+
def read_int16
|
|
396
|
+
read_safely(2, "int16").unpack1("s>")
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
# Read signed 8-bit integer
|
|
400
|
+
#
|
|
401
|
+
# @return [Integer] Value
|
|
402
|
+
def read_int8
|
|
403
|
+
value = read_safely(1, "int8").unpack1("C")
|
|
404
|
+
value > 0x7F ? value - 0x100 : value
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
# Read F2DOT14 format (signed 16-bit fixed-point)
|
|
408
|
+
#
|
|
409
|
+
# F2DOT14 represents a number in 2.14 format:
|
|
410
|
+
# - 2 bits for integer part
|
|
411
|
+
# - 14 bits for fractional part
|
|
412
|
+
#
|
|
413
|
+
# @return [Float] Value
|
|
414
|
+
def read_f2dot14
|
|
415
|
+
value = read_int16
|
|
416
|
+
value / 16384.0
|
|
417
|
+
end
|
|
418
|
+
end
|
|
419
|
+
end
|
|
420
|
+
end
|
|
421
|
+
end
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fontisan
|
|
4
|
+
module Tables
|
|
5
|
+
class Cff2
|
|
6
|
+
# Variation data extractor for CFF2 Variable Store
|
|
7
|
+
#
|
|
8
|
+
# Extracts regions and deltas from the Variable Store and provides
|
|
9
|
+
# utilities for working with variation data.
|
|
10
|
+
#
|
|
11
|
+
# Reference: OpenType spec - Item Variation Store
|
|
12
|
+
# Reference: Adobe Technical Note #5177 (CFF2)
|
|
13
|
+
#
|
|
14
|
+
# @example Extracting variation data
|
|
15
|
+
# extractor = VariationDataExtractor.new(variable_store)
|
|
16
|
+
# regions = extractor.regions
|
|
17
|
+
# deltas = extractor.deltas_for_item(item_index)
|
|
18
|
+
class VariationDataExtractor
|
|
19
|
+
# @return [Hash] Variable Store data
|
|
20
|
+
attr_reader :variable_store
|
|
21
|
+
|
|
22
|
+
# @return [Array<Hash>] Extracted regions
|
|
23
|
+
attr_reader :regions
|
|
24
|
+
|
|
25
|
+
# @return [Array<Hash>] Item variation data
|
|
26
|
+
attr_reader :item_variation_data
|
|
27
|
+
|
|
28
|
+
# Initialize extractor with Variable Store data
|
|
29
|
+
#
|
|
30
|
+
# @param variable_store [Hash] Variable Store from CFF2TableReader
|
|
31
|
+
def initialize(variable_store)
|
|
32
|
+
@variable_store = variable_store
|
|
33
|
+
@regions = variable_store[:regions] || []
|
|
34
|
+
@item_variation_data = variable_store[:item_variation_data] || []
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Get deltas for a specific item
|
|
38
|
+
#
|
|
39
|
+
# @param item_index [Integer] Item index
|
|
40
|
+
# @param data_index [Integer] Item variation data index (default 0)
|
|
41
|
+
# @return [Array<Integer>, nil] Deltas for the item, or nil if not found
|
|
42
|
+
def deltas_for_item(item_index, data_index: 0)
|
|
43
|
+
return nil if data_index >= @item_variation_data.size
|
|
44
|
+
|
|
45
|
+
item_data = @item_variation_data[data_index]
|
|
46
|
+
return nil if item_index >= item_data[:delta_sets].size
|
|
47
|
+
|
|
48
|
+
item_data[:delta_sets][item_index]
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Get region indices for item variation data
|
|
52
|
+
#
|
|
53
|
+
# @param data_index [Integer] Item variation data index (default 0)
|
|
54
|
+
# @return [Array<Integer>] Region indices
|
|
55
|
+
def region_indices(data_index: 0)
|
|
56
|
+
return [] if data_index >= @item_variation_data.size
|
|
57
|
+
|
|
58
|
+
@item_variation_data[data_index][:region_indices] || []
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Get number of items in item variation data
|
|
62
|
+
#
|
|
63
|
+
# @param data_index [Integer] Item variation data index (default 0)
|
|
64
|
+
# @return [Integer] Number of items
|
|
65
|
+
def item_count(data_index: 0)
|
|
66
|
+
return 0 if data_index >= @item_variation_data.size
|
|
67
|
+
|
|
68
|
+
@item_variation_data[data_index][:item_count] || 0
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Get all deltas for all items
|
|
72
|
+
#
|
|
73
|
+
# @param data_index [Integer] Item variation data index (default 0)
|
|
74
|
+
# @return [Array<Array<Integer>>] Array of delta sets
|
|
75
|
+
def all_deltas(data_index: 0)
|
|
76
|
+
return [] if data_index >= @item_variation_data.size
|
|
77
|
+
|
|
78
|
+
@item_variation_data[data_index][:delta_sets] || []
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Calculate blended value for an item at specific coordinates
|
|
82
|
+
#
|
|
83
|
+
# @param item_index [Integer] Item index
|
|
84
|
+
# @param base_value [Numeric] Base value to blend
|
|
85
|
+
# @param scalars [Array<Float>] Region scalars for each region
|
|
86
|
+
# @param data_index [Integer] Item variation data index (default 0)
|
|
87
|
+
# @return [Float] Blended value
|
|
88
|
+
def blend_value(item_index, base_value, scalars, data_index: 0)
|
|
89
|
+
deltas = deltas_for_item(item_index, data_index: data_index)
|
|
90
|
+
return base_value.to_f unless deltas
|
|
91
|
+
|
|
92
|
+
indices = region_indices(data_index: data_index)
|
|
93
|
+
|
|
94
|
+
# Apply blend: result = base + Σ(delta[i] * scalar[region_index[i]])
|
|
95
|
+
result = base_value.to_f
|
|
96
|
+
deltas.each_with_index do |delta, i|
|
|
97
|
+
region_index = indices[i]
|
|
98
|
+
next unless region_index
|
|
99
|
+
|
|
100
|
+
scalar = scalars[region_index] || 0.0
|
|
101
|
+
result += delta.to_f * scalar
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
result
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Get region by index
|
|
108
|
+
#
|
|
109
|
+
# @param region_index [Integer] Region index
|
|
110
|
+
# @return [Hash, nil] Region data or nil if not found
|
|
111
|
+
def region(region_index)
|
|
112
|
+
return nil if region_index >= @regions.size
|
|
113
|
+
|
|
114
|
+
@regions[region_index]
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Get number of regions
|
|
118
|
+
#
|
|
119
|
+
# @return [Integer] Total number of regions
|
|
120
|
+
def region_count
|
|
121
|
+
@regions.size
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Get number of axes from first region
|
|
125
|
+
#
|
|
126
|
+
# @return [Integer] Number of axes
|
|
127
|
+
def axis_count
|
|
128
|
+
return 0 if @regions.empty?
|
|
129
|
+
|
|
130
|
+
@regions.first[:axis_count] || 0
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Check if Variable Store has data
|
|
134
|
+
#
|
|
135
|
+
# @return [Boolean] True if Variable Store contains data
|
|
136
|
+
def has_data?
|
|
137
|
+
!@regions.empty? && !@item_variation_data.empty?
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Extract all region coordinates as arrays
|
|
141
|
+
#
|
|
142
|
+
# Useful for debugging and validation
|
|
143
|
+
#
|
|
144
|
+
# @return [Array<Array<Hash>>] Array of regions with axis coordinates
|
|
145
|
+
def region_coordinates
|
|
146
|
+
@regions.map do |region|
|
|
147
|
+
region[:axes].map do |axis|
|
|
148
|
+
{
|
|
149
|
+
start: axis[:start_coord],
|
|
150
|
+
peak: axis[:peak_coord],
|
|
151
|
+
end: axis[:end_coord],
|
|
152
|
+
}
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Validate Variable Store structure
|
|
158
|
+
#
|
|
159
|
+
# @return [Array<String>] Array of validation errors (empty if valid)
|
|
160
|
+
def validate
|
|
161
|
+
errors = []
|
|
162
|
+
|
|
163
|
+
# Check regions consistency
|
|
164
|
+
if @regions.any?
|
|
165
|
+
expected_axes = @regions.first[:axis_count]
|
|
166
|
+
@regions.each_with_index do |region, i|
|
|
167
|
+
unless region[:axis_count] == expected_axes
|
|
168
|
+
errors << "Region #{i} has inconsistent axis_count: " \
|
|
169
|
+
"#{region[:axis_count]} vs #{expected_axes}"
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
unless region[:axes].size == expected_axes
|
|
173
|
+
errors << "Region #{i} has #{region[:axes].size} axes, " \
|
|
174
|
+
"expected #{expected_axes}"
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Check item variation data
|
|
180
|
+
@item_variation_data.each_with_index do |item_data, i|
|
|
181
|
+
item_count = item_data[:item_count]
|
|
182
|
+
delta_sets = item_data[:delta_sets]
|
|
183
|
+
region_indices = item_data[:region_indices]
|
|
184
|
+
|
|
185
|
+
unless delta_sets.size == item_count
|
|
186
|
+
errors << "Item variation data #{i} has #{delta_sets.size} " \
|
|
187
|
+
"delta sets, expected #{item_count}"
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# Check each delta set has correct number of deltas
|
|
191
|
+
delta_sets.each_with_index do |deltas, j|
|
|
192
|
+
unless deltas.size == region_indices.size
|
|
193
|
+
errors << "Delta set #{j} in data #{i} has #{deltas.size} " \
|
|
194
|
+
"deltas, expected #{region_indices.size}"
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Check region indices are valid
|
|
199
|
+
region_indices.each_with_index do |idx, j|
|
|
200
|
+
if idx >= @regions.size
|
|
201
|
+
errors << "Region index #{idx} at position #{j} in data #{i} " \
|
|
202
|
+
"exceeds region count #{@regions.size}"
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
errors
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
end
|
data/lib/fontisan/tables/cff2.rb
CHANGED
|
@@ -22,7 +22,7 @@ module Fontisan
|
|
|
22
22
|
# num_glyphs = cff2.glyph_count
|
|
23
23
|
class Cff2 < Binary::BaseRecord
|
|
24
24
|
# CFF2 header structure
|
|
25
|
-
class
|
|
25
|
+
class Cff2Header < Binary::BaseRecord
|
|
26
26
|
uint8 :major_version
|
|
27
27
|
uint8 :minor_version
|
|
28
28
|
uint8 :header_size
|
|
@@ -53,7 +53,7 @@ module Fontisan
|
|
|
53
53
|
|
|
54
54
|
# Get the CFF2 header
|
|
55
55
|
#
|
|
56
|
-
# @return [
|
|
56
|
+
# @return [Cff2Header] Header structure
|
|
57
57
|
def header
|
|
58
58
|
parse unless @parsed
|
|
59
59
|
@header
|
|
@@ -111,7 +111,7 @@ module Fontisan
|
|
|
111
111
|
@num_axes,
|
|
112
112
|
@global_subr_index,
|
|
113
113
|
nil, # local subrs (CFF2 may not have them)
|
|
114
|
-
0
|
|
114
|
+
0, # vsindex
|
|
115
115
|
).parse
|
|
116
116
|
end
|
|
117
117
|
|
|
@@ -137,12 +137,12 @@ module Fontisan
|
|
|
137
137
|
|
|
138
138
|
# Parse CFF2 header
|
|
139
139
|
#
|
|
140
|
-
# @return [
|
|
140
|
+
# @return [Cff2Header] Parsed header
|
|
141
141
|
def parse_header
|
|
142
142
|
data = raw_data
|
|
143
143
|
return nil if data.nil? || data.bytesize < 5
|
|
144
144
|
|
|
145
|
-
|
|
145
|
+
Cff2Header.read(data.byteslice(0, 5))
|
|
146
146
|
end
|
|
147
147
|
|
|
148
148
|
# Parse Global Subr INDEX
|
|
@@ -339,3 +339,8 @@ end
|
|
|
339
339
|
require_relative "cff2/charstring_parser"
|
|
340
340
|
require_relative "cff2/blend_operator"
|
|
341
341
|
require_relative "cff2/operand_stack"
|
|
342
|
+
require_relative "cff2/table_reader"
|
|
343
|
+
require_relative "cff2/variation_data_extractor"
|
|
344
|
+
require_relative "cff2/region_matcher"
|
|
345
|
+
require_relative "cff2/private_dict_blend_handler"
|
|
346
|
+
require_relative "cff2/table_builder"
|