fontisan 0.2.0 → 0.2.1
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 +270 -131
- data/README.adoc +158 -4
- data/Rakefile +44 -47
- data/lib/fontisan/cli.rb +84 -33
- data/lib/fontisan/collection/builder.rb +81 -0
- data/lib/fontisan/collection/table_deduplicator.rb +76 -0
- data/lib/fontisan/commands/base_command.rb +16 -0
- data/lib/fontisan/commands/convert_command.rb +97 -170
- data/lib/fontisan/commands/instance_command.rb +71 -80
- data/lib/fontisan/commands/validate_command.rb +25 -0
- data/lib/fontisan/config/validation_rules.yml +1 -1
- data/lib/fontisan/constants.rb +10 -0
- data/lib/fontisan/converters/format_converter.rb +150 -1
- data/lib/fontisan/converters/outline_converter.rb +80 -18
- data/lib/fontisan/converters/woff_writer.rb +1 -1
- data/lib/fontisan/font_loader.rb +3 -5
- data/lib/fontisan/font_writer.rb +7 -6
- data/lib/fontisan/hints/hint_converter.rb +133 -0
- data/lib/fontisan/hints/postscript_hint_applier.rb +221 -140
- data/lib/fontisan/hints/postscript_hint_extractor.rb +100 -0
- data/lib/fontisan/hints/truetype_hint_applier.rb +90 -44
- data/lib/fontisan/hints/truetype_hint_extractor.rb +127 -0
- data/lib/fontisan/loading_modes.rb +2 -0
- data/lib/fontisan/models/font_export.rb +2 -2
- data/lib/fontisan/models/hint.rb +173 -1
- data/lib/fontisan/models/validation_report.rb +1 -1
- data/lib/fontisan/open_type_font.rb +25 -9
- data/lib/fontisan/open_type_font_extensions.rb +54 -0
- data/lib/fontisan/pipeline/format_detector.rb +249 -0
- data/lib/fontisan/pipeline/output_writer.rb +154 -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 +411 -0
- data/lib/fontisan/pipeline/variation_resolver.rb +165 -0
- data/lib/fontisan/tables/cff/charstring.rb +33 -4
- data/lib/fontisan/tables/cff/charstring_builder.rb +34 -0
- data/lib/fontisan/tables/cff/charstring_parser.rb +237 -0
- data/lib/fontisan/tables/cff/charstring_rebuilder.rb +172 -0
- data/lib/fontisan/tables/cff/dict_builder.rb +15 -0
- data/lib/fontisan/tables/cff/hint_operation_injector.rb +207 -0
- data/lib/fontisan/tables/cff/offset_recalculator.rb +70 -0
- data/lib/fontisan/tables/cff/private_dict_writer.rb +125 -0
- data/lib/fontisan/tables/cff/table_builder.rb +221 -0
- data/lib/fontisan/tables/cff.rb +2 -0
- data/lib/fontisan/tables/cff2/private_dict_blend_handler.rb +246 -0
- data/lib/fontisan/tables/cff2/region_matcher.rb +200 -0
- data/lib/fontisan/tables/cff2/table_builder.rb +574 -0
- data/lib/fontisan/tables/cff2/table_reader.rb +419 -0
- data/lib/fontisan/tables/cff2/variation_data_extractor.rb +212 -0
- data/lib/fontisan/tables/cff2.rb +9 -4
- data/lib/fontisan/tables/cvar.rb +2 -41
- data/lib/fontisan/tables/gvar.rb +2 -41
- data/lib/fontisan/true_type_font.rb +24 -9
- 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/converter.rb +120 -13
- data/lib/fontisan/variation/instance_writer.rb +341 -0
- 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 +288 -0
- data/lib/fontisan/version.rb +1 -1
- data/lib/fontisan/version.rb.orig +9 -0
- data/lib/fontisan/woff2/glyf_transformer.rb +666 -0
- data/lib/fontisan/woff2/hmtx_transformer.rb +164 -0
- data/lib/fontisan/woff2_font.rb +475 -470
- data/lib/fontisan/woff_font.rb +16 -11
- data/lib/fontisan.rb +12 -0
- metadata +31 -2
data/lib/fontisan/woff2_font.rb
CHANGED
|
@@ -2,32 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
require "bindata"
|
|
4
4
|
require "brotli"
|
|
5
|
+
require "stringio"
|
|
5
6
|
require_relative "constants"
|
|
7
|
+
require_relative "loading_modes"
|
|
6
8
|
require_relative "utilities/checksum_calculator"
|
|
9
|
+
require_relative "woff2/header"
|
|
10
|
+
require_relative "woff2/glyf_transformer"
|
|
11
|
+
require_relative "woff2/hmtx_transformer"
|
|
12
|
+
require_relative "true_type_font"
|
|
13
|
+
require_relative "open_type_font"
|
|
14
|
+
require_relative "error"
|
|
7
15
|
|
|
8
16
|
module Fontisan
|
|
9
|
-
# WOFF2 Header structure
|
|
10
|
-
#
|
|
11
|
-
# WOFF2 header is more compact than WOFF, using variable-length integers
|
|
12
|
-
# for some fields and omitting redundant information.
|
|
13
|
-
class Woff2Header < BinData::Record
|
|
14
|
-
endian :big
|
|
15
|
-
uint32 :signature # 0x774F4632 'wOF2'
|
|
16
|
-
uint32 :flavor # sfnt version (0x00010000 for TTF, 'OTTO' for CFF)
|
|
17
|
-
uint32 :woff2_length # Total size of WOFF2 file
|
|
18
|
-
uint16 :num_tables # Number of entries in directory
|
|
19
|
-
uint16 :reserved # Reserved, must be zero
|
|
20
|
-
uint32 :total_sfnt_size # Total size needed for uncompressed font
|
|
21
|
-
uint32 :total_compressed_size # Total size of compressed data block
|
|
22
|
-
uint16 :major_version # Major version of WOFF file
|
|
23
|
-
uint16 :minor_version # Minor version of WOFF file
|
|
24
|
-
uint32 :meta_offset # Offset to metadata block
|
|
25
|
-
uint32 :meta_length # Length of compressed metadata block
|
|
26
|
-
uint32 :meta_orig_length # Length of uncompressed metadata block
|
|
27
|
-
uint32 :priv_offset # Offset to private data block
|
|
28
|
-
uint32 :priv_length # Length of private data block
|
|
29
|
-
end
|
|
30
|
-
|
|
31
17
|
# WOFF2 Table Directory Entry structure
|
|
32
18
|
#
|
|
33
19
|
# WOFF2 table directory entries are more complex than WOFF,
|
|
@@ -56,7 +42,8 @@ module Fontisan
|
|
|
56
42
|
|
|
57
43
|
def initialize
|
|
58
44
|
@flags = 0
|
|
59
|
-
|
|
45
|
+
# Don't initialize transform_version - leave it nil
|
|
46
|
+
# It will be set during parsing if table is transformed
|
|
60
47
|
end
|
|
61
48
|
|
|
62
49
|
# Check if table is transformed
|
|
@@ -85,59 +72,21 @@ module Fontisan
|
|
|
85
72
|
end
|
|
86
73
|
end
|
|
87
74
|
|
|
88
|
-
# Web Open Font Format 2.0 (WOFF2) font
|
|
89
|
-
#
|
|
90
|
-
# Represents a WOFF2 font file that uses Brotli compression and table
|
|
91
|
-
# transformations. WOFF2 is significantly more complex than WOFF.
|
|
75
|
+
# Web Open Font Format 2.0 (WOFF2) font loader
|
|
92
76
|
#
|
|
93
|
-
#
|
|
94
|
-
#
|
|
95
|
-
# - All compressed data in a single Brotli stream
|
|
96
|
-
# - Variable-length integer encoding (UIntBase128, 255UInt16)
|
|
97
|
-
# - More efficient compression than WOFF
|
|
77
|
+
# This class manages WOFF2 font files and provides access to
|
|
78
|
+
# decompressed tables and transformed data.
|
|
98
79
|
#
|
|
99
80
|
# @example Reading a WOFF2 font
|
|
100
|
-
#
|
|
101
|
-
# puts
|
|
102
|
-
#
|
|
103
|
-
# puts name_table.english_name(Tables::Name::FAMILY)
|
|
104
|
-
#
|
|
105
|
-
# @example Converting to TTF/OTF
|
|
106
|
-
# woff2 = Woff2Font.from_file("font.woff2")
|
|
107
|
-
# woff2.to_ttf("output.ttf") # if TrueType flavored
|
|
108
|
-
# woff2.to_otf("output.otf") # if CFF flavored
|
|
81
|
+
# font = Woff2Font.from_file("font.woff2")
|
|
82
|
+
# puts font.header.flavor
|
|
83
|
+
# puts font.table_names
|
|
109
84
|
class Woff2Font
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
# WOFF2 signature constant
|
|
114
|
-
WOFF2_SIGNATURE = 0x774F4632 # 'wOF2'
|
|
85
|
+
# Simple struct for storing file path
|
|
86
|
+
IOSource = Struct.new(:path)
|
|
115
87
|
|
|
116
|
-
|
|
117
|
-
#
|
|
118
|
-
# @param path [String] Path to the WOFF2 file
|
|
119
|
-
# @return [Woff2Font] A new instance
|
|
120
|
-
# @raise [ArgumentError] if path is nil or empty
|
|
121
|
-
# @raise [Errno::ENOENT] if file does not exist
|
|
122
|
-
# @raise [InvalidFontError] if file format is invalid
|
|
123
|
-
def self.from_file(path)
|
|
124
|
-
if path.nil? || path.to_s.empty?
|
|
125
|
-
raise ArgumentError, "path cannot be nil or empty"
|
|
126
|
-
end
|
|
127
|
-
raise Errno::ENOENT, "File not found: #{path}" unless File.exist?(path)
|
|
128
|
-
|
|
129
|
-
File.open(path, "rb") do |io|
|
|
130
|
-
font = new
|
|
131
|
-
font.read_from_io(io)
|
|
132
|
-
font.validate_signature!
|
|
133
|
-
font.initialize_storage
|
|
134
|
-
font.decompress_and_parse_tables(io)
|
|
135
|
-
font.io_source = io
|
|
136
|
-
font
|
|
137
|
-
end
|
|
138
|
-
rescue BinData::ValidityError, EOFError => e
|
|
139
|
-
raise InvalidFontError, "Invalid WOFF2 file: #{e.message}"
|
|
140
|
-
end
|
|
88
|
+
attr_accessor :header, :table_entries, :decompressed_tables, :parsed_tables, :io_source
|
|
89
|
+
attr_accessor :underlying_font # Allow both reading and setting for table delegation
|
|
141
90
|
|
|
142
91
|
def initialize
|
|
143
92
|
@header = nil
|
|
@@ -145,253 +94,297 @@ module Fontisan
|
|
|
145
94
|
@decompressed_tables = {}
|
|
146
95
|
@parsed_tables = {}
|
|
147
96
|
@io_source = nil
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
# Read header and table directory from IO
|
|
151
|
-
#
|
|
152
|
-
# @param io [IO] Open file handle
|
|
153
|
-
# @return [void]
|
|
154
|
-
def read_from_io(io)
|
|
155
|
-
@header = Woff2Header.read(io)
|
|
156
|
-
read_table_directory(io)
|
|
97
|
+
@underlying_font = nil # Store the actual TrueTypeFont/OpenTypeFont
|
|
157
98
|
end
|
|
158
99
|
|
|
159
100
|
# Initialize storage hashes
|
|
160
|
-
#
|
|
161
|
-
# @return [void]
|
|
162
101
|
def initialize_storage
|
|
163
102
|
@decompressed_tables ||= {}
|
|
164
103
|
@initialize_storage ||= {}
|
|
165
104
|
end
|
|
166
105
|
|
|
167
|
-
#
|
|
168
|
-
#
|
|
169
|
-
# @raise [InvalidFontError] if signature is invalid
|
|
170
|
-
# @return [void]
|
|
171
|
-
def validate_signature!
|
|
172
|
-
signature_value = header.signature.to_i
|
|
173
|
-
unless signature_value == WOFF2_SIGNATURE
|
|
174
|
-
Kernel.raise(::Fontisan::InvalidFontError,
|
|
175
|
-
"Invalid WOFF2 signature: expected 0x#{WOFF2_SIGNATURE.to_s(16)}, " \
|
|
176
|
-
"got 0x#{signature_value.to_s(16)}")
|
|
177
|
-
end
|
|
178
|
-
end
|
|
179
|
-
|
|
180
|
-
# Check if font is TrueType flavored
|
|
181
|
-
#
|
|
182
|
-
# @return [Boolean] true if TrueType, false if CFF
|
|
106
|
+
# Check if font has TrueType flavor
|
|
183
107
|
def truetype?
|
|
184
|
-
|
|
108
|
+
return false unless @header
|
|
109
|
+
|
|
110
|
+
[Constants::SFNT_VERSION_TRUETYPE, 0x00010000].include?(@header.flavor)
|
|
185
111
|
end
|
|
186
112
|
|
|
187
|
-
# Check if font
|
|
188
|
-
#
|
|
189
|
-
# @return [Boolean] true if CFF, false if TrueType
|
|
113
|
+
# Check if font has CFF flavor
|
|
190
114
|
def cff?
|
|
191
|
-
|
|
115
|
+
return false unless @header
|
|
116
|
+
|
|
117
|
+
[Constants::SFNT_VERSION_OTTO, 0x4F54544F].include?(@header.flavor)
|
|
192
118
|
end
|
|
193
119
|
|
|
194
|
-
#
|
|
195
|
-
#
|
|
196
|
-
# Provides unified interface compatible with WoffFont
|
|
120
|
+
# Check if font is a variable font
|
|
197
121
|
#
|
|
198
|
-
# @
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
@decompressed_tables[tag]
|
|
122
|
+
# @return [Boolean] true if font has fvar table (variable font)
|
|
123
|
+
def variable_font?
|
|
124
|
+
has_table?("fvar")
|
|
202
125
|
end
|
|
203
126
|
|
|
204
|
-
#
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
table_entries.any? { |entry| entry.tag == tag }
|
|
127
|
+
# Validate WOFF2 signature
|
|
128
|
+
def validate_signature!
|
|
129
|
+
unless @header && @header.signature == Woff2::Woff2Header::SIGNATURE
|
|
130
|
+
raise InvalidFontError, "Invalid WOFF2 signature"
|
|
131
|
+
end
|
|
210
132
|
end
|
|
211
133
|
|
|
212
|
-
#
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
134
|
+
# Check if font is valid
|
|
135
|
+
def valid?
|
|
136
|
+
return false unless @header
|
|
137
|
+
return false unless @header.signature == Woff2::Woff2Header::SIGNATURE
|
|
138
|
+
return false unless @header.num_tables == @table_entries.length
|
|
139
|
+
return false unless has_table?("head")
|
|
140
|
+
|
|
141
|
+
true
|
|
218
142
|
end
|
|
219
143
|
|
|
220
|
-
#
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
def table_names
|
|
224
|
-
table_entries.map(&:tag)
|
|
144
|
+
# Check if table exists
|
|
145
|
+
def has_table?(tag)
|
|
146
|
+
@table_entries.any? { |entry| entry.tag == tag }
|
|
225
147
|
end
|
|
226
148
|
|
|
227
|
-
#
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
# structured table object and caches the result for subsequent calls.
|
|
231
|
-
#
|
|
232
|
-
# @param tag [String] The table tag to retrieve
|
|
233
|
-
# @return [Tables::*, nil] Parsed table object or nil if not found
|
|
234
|
-
def table(tag)
|
|
235
|
-
@parsed_tables[tag] ||= parse_table(tag)
|
|
149
|
+
# Find table entry by tag
|
|
150
|
+
def find_table_entry(tag)
|
|
151
|
+
@table_entries.find { |entry| entry.tag == tag }
|
|
236
152
|
end
|
|
237
153
|
|
|
238
|
-
# Get
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
def units_per_em
|
|
242
|
-
head = table(Constants::HEAD_TAG)
|
|
243
|
-
head&.units_per_em
|
|
154
|
+
# Get list of table tags
|
|
155
|
+
def table_names
|
|
156
|
+
@table_entries.map(&:tag)
|
|
244
157
|
end
|
|
245
158
|
|
|
246
|
-
# Get
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
159
|
+
# Get decompressed table data
|
|
160
|
+
def table_data(tag)
|
|
161
|
+
# First try underlying font's table data if available
|
|
162
|
+
if @underlying_font && @underlying_font.respond_to?(:table_data)
|
|
163
|
+
underlying_data = @underlying_font.table_data[tag]
|
|
164
|
+
return underlying_data if underlying_data
|
|
165
|
+
end
|
|
252
166
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
@metadata = Brotli.inflate(compressed_meta)
|
|
167
|
+
# Fallback to decompressed_tables
|
|
168
|
+
@decompressed_tables[tag]
|
|
169
|
+
end
|
|
257
170
|
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
end
|
|
171
|
+
# Get parsed table object
|
|
172
|
+
def table(tag)
|
|
173
|
+
# Delegate to underlying font if available
|
|
174
|
+
return @underlying_font.table(tag) if @underlying_font
|
|
263
175
|
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
@
|
|
176
|
+
# Fallback to parsed_tables hash
|
|
177
|
+
# Normalize tag to UTF-8 string for hash lookup
|
|
178
|
+
# Use dup to create mutable copy since force_encoding modifies in place
|
|
179
|
+
tag_key = tag.to_s.dup.force_encoding("UTF-8")
|
|
180
|
+
@parsed_tables[tag_key]
|
|
269
181
|
end
|
|
270
182
|
|
|
271
|
-
# Convert
|
|
272
|
-
#
|
|
273
|
-
# Decompresses and reconstructs tables, then builds a standard TTF file
|
|
274
|
-
#
|
|
275
|
-
# @param output_path [String] Path where TTF file will be written
|
|
276
|
-
# @return [Integer] Number of bytes written
|
|
277
|
-
# @raise [InvalidFontError] if font is not TrueType flavored
|
|
183
|
+
# Convert to TTF
|
|
278
184
|
def to_ttf(output_path)
|
|
279
185
|
unless truetype?
|
|
280
|
-
raise InvalidFontError,
|
|
281
|
-
"Cannot convert to TTF: font is CFF flavored (use to_otf)"
|
|
186
|
+
raise InvalidFontError, "Cannot convert to TTF: font is not TrueType flavored"
|
|
282
187
|
end
|
|
283
188
|
|
|
284
|
-
|
|
189
|
+
# Build SFNT and create TrueTypeFont
|
|
190
|
+
sfnt_data = self.class.build_sfnt_in_memory(@header, @table_entries, @decompressed_tables)
|
|
191
|
+
sfnt_io = StringIO.new(sfnt_data)
|
|
192
|
+
|
|
193
|
+
# Create actual TrueTypeFont and save for table delegation
|
|
194
|
+
@underlying_font = TrueTypeFont.read(sfnt_io)
|
|
195
|
+
@underlying_font.initialize_storage
|
|
196
|
+
@underlying_font.read_table_data(sfnt_io)
|
|
197
|
+
|
|
198
|
+
FontWriter.write_to_file(@underlying_font.tables, output_path)
|
|
285
199
|
end
|
|
286
200
|
|
|
287
|
-
# Convert
|
|
288
|
-
#
|
|
289
|
-
# Decompresses and reconstructs tables, then builds a standard OTF file
|
|
290
|
-
#
|
|
291
|
-
# @param output_path [String] Path where OTF file will be written
|
|
292
|
-
# @return [Integer] Number of bytes written
|
|
293
|
-
# @raise [InvalidFontError] if font is not CFF flavored
|
|
201
|
+
# Convert to OTF
|
|
294
202
|
def to_otf(output_path)
|
|
295
203
|
unless cff?
|
|
296
|
-
raise InvalidFontError,
|
|
297
|
-
"Cannot convert to OTF: font is TrueType flavored (use to_ttf)"
|
|
204
|
+
raise InvalidFontError, "Cannot convert to OTF: font is not CFF flavored"
|
|
298
205
|
end
|
|
299
206
|
|
|
300
|
-
|
|
207
|
+
# Build SFNT and create OpenTypeFont
|
|
208
|
+
sfnt_data = self.class.build_sfnt_in_memory(@header, @table_entries, @decompressed_tables)
|
|
209
|
+
sfnt_io = StringIO.new(sfnt_data)
|
|
210
|
+
|
|
211
|
+
# Create actual OpenTypeFont and save for table delegation
|
|
212
|
+
@underlying_font = OpenTypeFont.read(sfnt_io)
|
|
213
|
+
@underlying_font.initialize_storage
|
|
214
|
+
@underlying_font.read_table_data(sfnt_io)
|
|
215
|
+
|
|
216
|
+
FontWriter.write_to_file(@underlying_font.tables, output_path)
|
|
301
217
|
end
|
|
302
218
|
|
|
303
|
-
#
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
219
|
+
# Get metadata (if present)
|
|
220
|
+
def metadata
|
|
221
|
+
return nil unless @header&.meta_length&.positive?
|
|
222
|
+
return nil unless @io_source
|
|
223
|
+
|
|
224
|
+
begin
|
|
225
|
+
File.open(@io_source.path, "rb") do |io|
|
|
226
|
+
io.seek(@header.meta_offset)
|
|
227
|
+
compressed_meta = io.read(@header.meta_length)
|
|
228
|
+
Brotli.inflate(compressed_meta)
|
|
229
|
+
end
|
|
230
|
+
rescue StandardError => e
|
|
231
|
+
warn "Failed to decompress metadata: #{e.message}"
|
|
232
|
+
nil
|
|
233
|
+
end
|
|
234
|
+
end
|
|
312
235
|
|
|
313
|
-
|
|
236
|
+
# Convenience methods for accessing common name table fields
|
|
237
|
+
|
|
238
|
+
# Get font family name
|
|
239
|
+
def family_name
|
|
240
|
+
name_table = table("name")
|
|
241
|
+
name_table&.english_name(Tables::Name::FAMILY)
|
|
314
242
|
end
|
|
315
243
|
|
|
316
|
-
|
|
244
|
+
# Get font subfamily name
|
|
245
|
+
def subfamily_name
|
|
246
|
+
name_table = table("name")
|
|
247
|
+
name_table&.english_name(Tables::Name::SUBFAMILY)
|
|
248
|
+
end
|
|
317
249
|
|
|
318
|
-
#
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
# - Maximum 5 bytes for a 32-bit value
|
|
324
|
-
#
|
|
325
|
-
# @param io [IO] Open file handle
|
|
326
|
-
# @return [Integer] The decoded integer value
|
|
327
|
-
def read_uint_base128(io)
|
|
328
|
-
result = 0
|
|
329
|
-
5.times do
|
|
330
|
-
byte = io.read(1).unpack1("C")
|
|
331
|
-
return nil unless byte
|
|
250
|
+
# Get full font name
|
|
251
|
+
def full_name
|
|
252
|
+
name_table = table("name")
|
|
253
|
+
name_table&.english_name(Tables::Name::FULL_NAME)
|
|
254
|
+
end
|
|
332
255
|
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
end
|
|
339
|
-
end
|
|
256
|
+
# Get PostScript name
|
|
257
|
+
def post_script_name
|
|
258
|
+
name_table = table("name")
|
|
259
|
+
name_table&.english_name(Tables::Name::POSTSCRIPT_NAME)
|
|
260
|
+
end
|
|
340
261
|
|
|
341
|
-
|
|
342
|
-
|
|
262
|
+
# Get preferred family name
|
|
263
|
+
def preferred_family_name
|
|
264
|
+
name_table = table("name")
|
|
265
|
+
name_table&.english_name(Tables::Name::PREFERRED_FAMILY)
|
|
343
266
|
end
|
|
344
267
|
|
|
345
|
-
#
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
#
|
|
268
|
+
# Get preferred subfamily name
|
|
269
|
+
def preferred_subfamily_name
|
|
270
|
+
name_table = table("name")
|
|
271
|
+
name_table&.english_name(Tables::Name::PREFERRED_SUBFAMILY)
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
# Get units per em
|
|
275
|
+
def units_per_em
|
|
276
|
+
head = table("head")
|
|
277
|
+
head&.units_per_em
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
# Read WOFF2 font from a file and return Woff2Font instance
|
|
352
281
|
#
|
|
353
|
-
# @param
|
|
354
|
-
# @
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
282
|
+
# @param path [String] Path to the WOFF2 file
|
|
283
|
+
# @param mode [Symbol] Loading mode (:metadata or :full)
|
|
284
|
+
# @param lazy [Boolean] If true, load tables on demand
|
|
285
|
+
# @return [Woff2Font] The WOFF2 font object
|
|
286
|
+
# @raise [ArgumentError] if path is nil or empty
|
|
287
|
+
# @raise [Errno::ENOENT] if file does not exist
|
|
288
|
+
# @raise [InvalidFontError] if file format is invalid
|
|
289
|
+
def self.from_file(path, mode: LoadingModes::FULL, lazy: false)
|
|
290
|
+
if path.nil? || path.to_s.empty?
|
|
291
|
+
raise ArgumentError, "path cannot be nil or empty"
|
|
292
|
+
end
|
|
293
|
+
raise Errno::ENOENT, "File not found: #{path}" unless File.exist?(path)
|
|
358
294
|
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
295
|
+
woff2 = new
|
|
296
|
+
woff2.io_source = IOSource.new(path)
|
|
297
|
+
|
|
298
|
+
File.open(path, "rb") do |io|
|
|
299
|
+
# Read header to determine font flavor
|
|
300
|
+
woff2.header = Woff2::Woff2Header.read(io)
|
|
301
|
+
|
|
302
|
+
# Validate signature
|
|
303
|
+
unless woff2.header.signature == Woff2::Woff2Header::SIGNATURE
|
|
304
|
+
raise InvalidFontError,
|
|
305
|
+
"Invalid WOFF2 signature: expected 0x#{Woff2::Woff2Header::SIGNATURE.to_s(16)}, " \
|
|
306
|
+
"got 0x#{woff2.header.signature.to_i.to_s(16)}"
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
# Read table directory
|
|
310
|
+
woff2.table_entries = read_table_directory_from_io(io, woff2.header)
|
|
311
|
+
|
|
312
|
+
# Decompress table data
|
|
313
|
+
woff2.decompressed_tables = decompress_tables(io, woff2.header, woff2.table_entries)
|
|
314
|
+
|
|
315
|
+
# Apply table transformations if present
|
|
316
|
+
apply_transformations!(woff2.table_entries, woff2.decompressed_tables)
|
|
317
|
+
|
|
318
|
+
# Build SFNT structure in memory
|
|
319
|
+
sfnt_data = build_sfnt_in_memory(woff2.header, woff2.table_entries, woff2.decompressed_tables)
|
|
320
|
+
|
|
321
|
+
# Create StringIO for reading
|
|
322
|
+
sfnt_io = StringIO.new(sfnt_data)
|
|
323
|
+
sfnt_io.rewind
|
|
324
|
+
|
|
325
|
+
# Parse tables based on font type
|
|
326
|
+
if woff2.truetype?
|
|
327
|
+
font = TrueTypeFont.read(sfnt_io)
|
|
328
|
+
font.initialize_storage
|
|
329
|
+
font.loading_mode = mode
|
|
330
|
+
font.lazy_load_enabled = lazy
|
|
331
|
+
|
|
332
|
+
# Create fresh StringIO for table data reading
|
|
333
|
+
table_io = StringIO.new(sfnt_data)
|
|
334
|
+
font.read_table_data(table_io)
|
|
335
|
+
|
|
336
|
+
# Store underlying font for table access delegation
|
|
337
|
+
woff2.underlying_font = font
|
|
338
|
+
woff2.parsed_tables = font.parsed_tables
|
|
339
|
+
elsif woff2.cff?
|
|
340
|
+
font = OpenTypeFont.read(sfnt_io)
|
|
341
|
+
font.initialize_storage
|
|
342
|
+
font.loading_mode = mode
|
|
343
|
+
font.lazy_load_enabled = lazy
|
|
344
|
+
|
|
345
|
+
# Create fresh StringIO for table data reading
|
|
346
|
+
table_io = StringIO.new(sfnt_data)
|
|
347
|
+
font.read_table_data(table_io)
|
|
348
|
+
|
|
349
|
+
# Store underlying font for table access delegation
|
|
350
|
+
woff2.underlying_font = font
|
|
351
|
+
woff2.parsed_tables = font.parsed_tables
|
|
352
|
+
else
|
|
353
|
+
raise InvalidFontError,
|
|
354
|
+
"Unknown WOFF2 flavor: 0x#{woff2.header.flavor.to_s(16)}"
|
|
355
|
+
end
|
|
370
356
|
end
|
|
357
|
+
|
|
358
|
+
woff2
|
|
359
|
+
rescue BinData::ValidityError, EOFError => e
|
|
360
|
+
raise InvalidFontError, "Invalid WOFF2 file: #{e.message}"
|
|
371
361
|
end
|
|
372
362
|
|
|
373
|
-
# Read
|
|
374
|
-
#
|
|
375
|
-
# The table directory in WOFF2 is more compact than WOFF,
|
|
376
|
-
# using variable-length integers and known table indices.
|
|
363
|
+
# Read table directory from IO
|
|
377
364
|
#
|
|
378
365
|
# @param io [IO] Open file handle
|
|
379
|
-
# @
|
|
380
|
-
|
|
381
|
-
|
|
366
|
+
# @param header [Woff2::Woff2Header] WOFF2 header
|
|
367
|
+
# @return [Array<Woff2TableDirectoryEntry>] Table entries
|
|
368
|
+
def self.read_table_directory_from_io(io, header)
|
|
369
|
+
table_entries = []
|
|
382
370
|
|
|
383
371
|
header.num_tables.times do
|
|
384
372
|
entry = Woff2TableDirectoryEntry.new
|
|
385
373
|
|
|
386
|
-
# Read flags byte
|
|
387
|
-
|
|
374
|
+
# Read flags byte with nil check
|
|
375
|
+
flags_data = io.read(1)
|
|
376
|
+
raise EOFError, "Unexpected EOF while reading table directory flags" if flags_data.nil?
|
|
377
|
+
|
|
378
|
+
flags = flags_data.unpack1("C")
|
|
388
379
|
entry.flags = flags
|
|
389
380
|
|
|
390
381
|
# Determine tag
|
|
391
382
|
tag_index = flags & 0x3F
|
|
392
383
|
if tag_index == 0x3F
|
|
393
384
|
# Custom tag (4 bytes)
|
|
394
|
-
|
|
385
|
+
tag_data = io.read(4)
|
|
386
|
+
raise EOFError, "Unexpected EOF while reading custom tag" if tag_data.nil? || tag_data.bytesize < 4
|
|
387
|
+
entry.tag = tag_data.force_encoding("UTF-8")
|
|
395
388
|
else
|
|
396
389
|
# Known tag from table
|
|
397
390
|
entry.tag = Woff2TableDirectoryEntry::KNOWN_TAGS[tag_index]
|
|
@@ -401,64 +394,153 @@ module Fontisan
|
|
|
401
394
|
end
|
|
402
395
|
|
|
403
396
|
# Read orig_length (UIntBase128)
|
|
404
|
-
entry.orig_length =
|
|
397
|
+
entry.orig_length = read_uint_base128_from_io(io)
|
|
405
398
|
|
|
406
|
-
#
|
|
399
|
+
# Determine if transformLength should be read
|
|
400
|
+
# According to WOFF2 spec section 4.2:
|
|
401
|
+
# - glyf/loca with version 0: TRANSFORMED (transformLength present)
|
|
402
|
+
# - hmtx with non-zero version: TRANSFORMED (transformLength present)
|
|
403
|
+
# - all other tables: transformation version is 0 (no transformLength)
|
|
407
404
|
transform_version = (flags >> 6) & 0x03
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
405
|
+
has_transform_length = if ["glyf", "loca"].include?(entry.tag) && transform_version.zero?
|
|
406
|
+
true
|
|
407
|
+
elsif entry.tag == "hmtx" && transform_version != 0
|
|
408
|
+
true
|
|
409
|
+
else
|
|
410
|
+
false
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
if has_transform_length
|
|
414
|
+
entry.transform_length = read_uint_base128_from_io(io)
|
|
411
415
|
entry.transform_version = transform_version
|
|
412
416
|
end
|
|
413
417
|
|
|
414
|
-
|
|
418
|
+
table_entries << entry
|
|
415
419
|
end
|
|
420
|
+
|
|
421
|
+
table_entries
|
|
416
422
|
end
|
|
417
423
|
|
|
418
|
-
#
|
|
419
|
-
#
|
|
420
|
-
# WOFF2 stores all table data in a single Brotli-compressed block.
|
|
421
|
-
# After decompression, we need to:
|
|
422
|
-
# 1. Split into individual tables
|
|
423
|
-
# 2. Reconstruct transformed tables (glyf, loca, hmtx)
|
|
424
|
+
# Read variable-length UIntBase128 integer from IO
|
|
424
425
|
#
|
|
425
426
|
# @param io [IO] Open file handle
|
|
426
|
-
# @return [
|
|
427
|
-
def
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
427
|
+
# @return [Integer] The decoded integer value
|
|
428
|
+
def self.read_uint_base128_from_io(io)
|
|
429
|
+
result = 0
|
|
430
|
+
5.times do
|
|
431
|
+
byte_data = io.read(1)
|
|
432
|
+
raise EOFError, "Unexpected EOF while reading UIntBase128" if byte_data.nil?
|
|
433
|
+
|
|
434
|
+
byte = byte_data.unpack1("C")
|
|
435
|
+
|
|
436
|
+
# Continue if high bit is set
|
|
437
|
+
if (byte & 0x80).zero?
|
|
438
|
+
return (result << 7) | byte
|
|
439
|
+
else
|
|
440
|
+
result = (result << 7) | (byte & 0x7F)
|
|
441
|
+
end
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
# If we're here, the encoding is invalid
|
|
445
|
+
raise InvalidFontError, "Invalid UIntBase128 encoding"
|
|
446
|
+
end
|
|
432
447
|
|
|
433
|
-
|
|
448
|
+
# Decompress tables from WOFF2 compressed data block
|
|
449
|
+
#
|
|
450
|
+
# @param io [IO] Open file handle
|
|
451
|
+
# @param header [Woff2::Woff2Header] WOFF2 header
|
|
452
|
+
# @param table_entries [Array<Woff2TableDirectoryEntry>] Table entries
|
|
453
|
+
# @return [Hash<String, String>] Map of tag to decompressed data
|
|
454
|
+
def self.decompress_tables(io, header, table_entries)
|
|
455
|
+
# IO stream is already positioned at compressed data after reading table directory
|
|
456
|
+
# No need to seek - just read from current position
|
|
434
457
|
compressed_data = io.read(header.total_compressed_size)
|
|
435
458
|
|
|
436
459
|
# Decompress entire data block with Brotli
|
|
437
460
|
decompressed_data = Brotli.inflate(compressed_data)
|
|
438
461
|
|
|
439
462
|
# Split decompressed data into individual tables
|
|
463
|
+
decompressed_tables = {}
|
|
440
464
|
offset = 0
|
|
465
|
+
|
|
441
466
|
table_entries.each do |entry|
|
|
442
467
|
table_size = entry.transform_length || entry.orig_length
|
|
443
|
-
|
|
444
468
|
table_data = decompressed_data[offset, table_size]
|
|
445
469
|
offset += table_size
|
|
446
470
|
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
471
|
+
decompressed_tables[entry.tag] = table_data
|
|
472
|
+
end
|
|
473
|
+
|
|
474
|
+
decompressed_tables
|
|
475
|
+
end
|
|
476
|
+
|
|
477
|
+
# Apply table transformations for glyf/loca/hmtx tables
|
|
478
|
+
#
|
|
479
|
+
# @param table_entries [Array<Woff2TableDirectoryEntry>] Table entries
|
|
480
|
+
# @param decompressed_tables [Hash<String, String>] Decompressed tables
|
|
481
|
+
# @return [void] Modifies decompressed_tables in place
|
|
482
|
+
def self.apply_transformations!(table_entries, decompressed_tables)
|
|
483
|
+
# Find entries that need transformation
|
|
484
|
+
glyf_entry = table_entries.find { |e| e.tag == "glyf" }
|
|
485
|
+
hmtx_entry = table_entries.find { |e| e.tag == "hmtx" }
|
|
486
|
+
|
|
487
|
+
# Get required metadata for transformations
|
|
488
|
+
maxp_data = decompressed_tables["maxp"]
|
|
489
|
+
hhea_data = decompressed_tables["hhea"]
|
|
490
|
+
|
|
491
|
+
return unless maxp_data && hhea_data
|
|
492
|
+
|
|
493
|
+
# Parse num_glyphs from maxp table
|
|
494
|
+
# maxp format: version(4) + numGlyphs(2) + ...
|
|
495
|
+
num_glyphs = maxp_data[4, 2].unpack1("n")
|
|
496
|
+
|
|
497
|
+
# Parse numberOfHMetrics from hhea table
|
|
498
|
+
# hhea format: ... + numberOfHMetrics(2) at offset 34
|
|
499
|
+
number_of_h_metrics = hhea_data[34, 2].unpack1("n")
|
|
500
|
+
|
|
501
|
+
# Check if this is a variable font by checking for fvar table
|
|
502
|
+
variable_font = table_entries.any? { |e| e.tag == "fvar" }
|
|
503
|
+
|
|
504
|
+
# Transform glyf/loca if needed
|
|
505
|
+
# transform_length is only set when table is actually transformed
|
|
506
|
+
# Check that transform_length exists and is greater than 0
|
|
507
|
+
if glyf_entry&.instance_variable_defined?(:@transform_length) &&
|
|
508
|
+
glyf_entry.transform_length&.positive?
|
|
509
|
+
transformed_glyf = decompressed_tables["glyf"]
|
|
510
|
+
|
|
511
|
+
if transformed_glyf
|
|
512
|
+
result = Woff2::GlyfTransformer.reconstruct(
|
|
513
|
+
transformed_glyf,
|
|
514
|
+
num_glyphs,
|
|
515
|
+
variable_font: variable_font
|
|
516
|
+
)
|
|
517
|
+
decompressed_tables["glyf"] = result[:glyf]
|
|
518
|
+
decompressed_tables["loca"] = result[:loca]
|
|
450
519
|
end
|
|
520
|
+
end
|
|
451
521
|
|
|
452
|
-
|
|
522
|
+
# Transform hmtx if needed
|
|
523
|
+
# transform_length is only set when table is actually transformed
|
|
524
|
+
# Check that transform_length exists and is greater than 0
|
|
525
|
+
if hmtx_entry&.instance_variable_defined?(:@transform_length) &&
|
|
526
|
+
hmtx_entry.transform_length&.positive?
|
|
527
|
+
transformed_hmtx = decompressed_tables["hmtx"]
|
|
528
|
+
|
|
529
|
+
if transformed_hmtx
|
|
530
|
+
decompressed_tables["hmtx"] = Woff2::HmtxTransformer.reconstruct(
|
|
531
|
+
transformed_hmtx,
|
|
532
|
+
num_glyphs,
|
|
533
|
+
number_of_h_metrics,
|
|
534
|
+
)
|
|
535
|
+
end
|
|
453
536
|
end
|
|
454
537
|
end
|
|
455
538
|
|
|
456
539
|
# Calculate size of table directory
|
|
457
540
|
#
|
|
458
|
-
#
|
|
459
|
-
#
|
|
541
|
+
# @param table_entries [Array<Woff2TableDirectoryEntry>] Table entries
|
|
460
542
|
# @return [Integer] Size in bytes
|
|
461
|
-
def calculate_table_directory_size
|
|
543
|
+
def self.calculate_table_directory_size(table_entries)
|
|
462
544
|
size = 0
|
|
463
545
|
table_entries.each do |entry|
|
|
464
546
|
size += 1 # flags byte
|
|
@@ -471,7 +553,7 @@ module Fontisan
|
|
|
471
553
|
size += uint_base128_size(entry.orig_length)
|
|
472
554
|
|
|
473
555
|
# transform_length if present
|
|
474
|
-
if entry.transform_version && entry.transform_version
|
|
556
|
+
if entry.transform_version && !entry.transform_version.nil?
|
|
475
557
|
size += uint_base128_size(entry.transform_length)
|
|
476
558
|
end
|
|
477
559
|
end
|
|
@@ -482,7 +564,7 @@ module Fontisan
|
|
|
482
564
|
#
|
|
483
565
|
# @param value [Integer] The value to encode
|
|
484
566
|
# @return [Integer] Estimated size in bytes
|
|
485
|
-
def uint_base128_size(value)
|
|
567
|
+
def self.uint_base128_size(value)
|
|
486
568
|
return 1 if value < 128
|
|
487
569
|
|
|
488
570
|
bytes = 0
|
|
@@ -491,222 +573,145 @@ module Fontisan
|
|
|
491
573
|
bytes += 1
|
|
492
574
|
v >>= 7
|
|
493
575
|
end
|
|
494
|
-
[
|
|
576
|
+
[
|
|
577
|
+
bytes,
|
|
578
|
+
5,
|
|
579
|
+
].min # Max 5 bytes
|
|
495
580
|
end
|
|
496
581
|
|
|
497
|
-
#
|
|
498
|
-
#
|
|
499
|
-
# WOFF2 can transform certain tables for better compression:
|
|
500
|
-
# - glyf/loca: Complex transformation with multiple streams
|
|
501
|
-
# - hmtx: Can omit redundant data
|
|
582
|
+
# Build SFNT binary structure in memory
|
|
502
583
|
#
|
|
503
|
-
# @param
|
|
504
|
-
# @param
|
|
505
|
-
# @
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
584
|
+
# @param header [Woff2::Woff2Header] WOFF2 header
|
|
585
|
+
# @param table_entries [Array<Woff2TableDirectoryEntry>] Table entries
|
|
586
|
+
# @param decompressed_tables [Hash<String, String>] Decompressed table data
|
|
587
|
+
# @return [String] Complete SFNT binary data
|
|
588
|
+
def self.build_sfnt_in_memory(header, table_entries, decompressed_tables)
|
|
589
|
+
sfnt_data = +""
|
|
590
|
+
|
|
591
|
+
# Calculate offset table fields
|
|
592
|
+
num_tables = table_entries.length
|
|
593
|
+
entry_selector = (Math.log(num_tables) / Math.log(2)).floor
|
|
594
|
+
search_range = (2**entry_selector) * 16
|
|
595
|
+
range_shift = num_tables * 16 - search_range
|
|
596
|
+
|
|
597
|
+
# Write offset table
|
|
598
|
+
sfnt_data << [header.flavor].pack("N")
|
|
599
|
+
sfnt_data << [num_tables].pack("n")
|
|
600
|
+
sfnt_data << [search_range].pack("n")
|
|
601
|
+
sfnt_data << [entry_selector].pack("n")
|
|
602
|
+
sfnt_data << [range_shift].pack("n")
|
|
603
|
+
|
|
604
|
+
# Calculate table offsets
|
|
605
|
+
offset = 12 + (num_tables * 16) # Header + directory
|
|
606
|
+
table_records = []
|
|
607
|
+
|
|
608
|
+
table_entries.each do |entry|
|
|
609
|
+
tag = entry.tag
|
|
610
|
+
data = decompressed_tables[tag]
|
|
611
|
+
next unless data
|
|
612
|
+
|
|
613
|
+
length = data.bytesize
|
|
614
|
+
|
|
615
|
+
# Calculate checksum
|
|
616
|
+
checksum = Utilities::ChecksumCalculator.calculate_table_checksum(data)
|
|
617
|
+
|
|
618
|
+
table_records << {
|
|
619
|
+
tag: tag,
|
|
620
|
+
checksum: checksum,
|
|
621
|
+
offset: offset,
|
|
622
|
+
length: length,
|
|
623
|
+
data: data,
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
# Update offset for next table (with padding)
|
|
627
|
+
offset += length
|
|
628
|
+
padding = (Constants::TABLE_ALIGNMENT - (length % Constants::TABLE_ALIGNMENT)) %
|
|
629
|
+
Constants::TABLE_ALIGNMENT
|
|
630
|
+
offset += padding
|
|
515
631
|
end
|
|
516
|
-
end
|
|
517
632
|
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
# @return [String] Reconstructed glyf or loca table data
|
|
526
|
-
def reconstruct_glyf_loca(_entry, _data)
|
|
527
|
-
# TODO: Implement full glyf/loca reconstruction
|
|
528
|
-
# This is extremely complex and requires:
|
|
529
|
-
# 1. Parse glyph streams (nContour, nPoints, flags, coords, etc.)
|
|
530
|
-
# 2. Reconstruct standard glyf format
|
|
531
|
-
# 3. Build loca table with proper offsets
|
|
532
|
-
#
|
|
533
|
-
# For now, return empty data to prevent crashes
|
|
534
|
-
# This will need proper implementation for production use
|
|
535
|
-
warn "WOFF2 transformed glyf/loca reconstruction not yet implemented"
|
|
536
|
-
""
|
|
537
|
-
end
|
|
538
|
-
|
|
539
|
-
# Reconstruct hmtx table from WOFF2 transformed format
|
|
540
|
-
#
|
|
541
|
-
# WOFF2 can store hmtx in a more compact format by:
|
|
542
|
-
# - Omitting redundant advance widths
|
|
543
|
-
# - Using flags to indicate presence of LSB array
|
|
544
|
-
#
|
|
545
|
-
# @param entry [Woff2TableDirectoryEntry] Table entry
|
|
546
|
-
# @param data [String] Transformed data
|
|
547
|
-
# @return [String] Reconstructed hmtx table data
|
|
548
|
-
def reconstruct_hmtx(_entry, data)
|
|
549
|
-
# TODO: Implement hmtx reconstruction
|
|
550
|
-
# This requires:
|
|
551
|
-
# 1. Parse flags
|
|
552
|
-
# 2. Reconstruct advance width array
|
|
553
|
-
# 3. Reconstruct LSB array (if present) or derive from glyf
|
|
554
|
-
#
|
|
555
|
-
# For now, return as-is
|
|
556
|
-
warn "WOFF2 transformed hmtx reconstruction not yet implemented"
|
|
557
|
-
data
|
|
558
|
-
end
|
|
559
|
-
|
|
560
|
-
# Parse a table from decompressed data
|
|
561
|
-
#
|
|
562
|
-
# @param tag [String] The table tag to parse
|
|
563
|
-
# @return [Tables::*, nil] Parsed table object or nil
|
|
564
|
-
def parse_table(tag)
|
|
565
|
-
raw_data = table_data(tag)
|
|
566
|
-
return nil unless raw_data
|
|
633
|
+
# Write table directory
|
|
634
|
+
table_records.each do |record|
|
|
635
|
+
sfnt_data << record[:tag].ljust(4, "\x00")
|
|
636
|
+
sfnt_data << [record[:checksum]].pack("N")
|
|
637
|
+
sfnt_data << [record[:offset]].pack("N")
|
|
638
|
+
sfnt_data << [record[:length]].pack("N")
|
|
639
|
+
end
|
|
567
640
|
|
|
568
|
-
|
|
569
|
-
|
|
641
|
+
# Write table data with padding
|
|
642
|
+
table_records.each do |record|
|
|
643
|
+
sfnt_data << record[:data]
|
|
644
|
+
|
|
645
|
+
# Add padding
|
|
646
|
+
padding = (Constants::TABLE_ALIGNMENT - (record[:length] % Constants::TABLE_ALIGNMENT)) %
|
|
647
|
+
Constants::TABLE_ALIGNMENT
|
|
648
|
+
sfnt_data << ("\x00" * padding) if padding.positive?
|
|
649
|
+
end
|
|
570
650
|
|
|
571
|
-
|
|
651
|
+
# Update checksumAdjustment in head table
|
|
652
|
+
update_checksum_in_memory(sfnt_data, table_records)
|
|
653
|
+
|
|
654
|
+
sfnt_data
|
|
572
655
|
end
|
|
573
656
|
|
|
574
|
-
#
|
|
575
|
-
#
|
|
576
|
-
# @param tag [String] The table tag
|
|
577
|
-
# @return [Class, nil] Table parser class or nil
|
|
578
|
-
def table_class_for(tag)
|
|
579
|
-
{
|
|
580
|
-
Constants::HEAD_TAG => Tables::Head,
|
|
581
|
-
Constants::HHEA_TAG => Tables::Hhea,
|
|
582
|
-
Constants::HMTX_TAG => Tables::Hmtx,
|
|
583
|
-
Constants::MAXP_TAG => Tables::Maxp,
|
|
584
|
-
Constants::NAME_TAG => Tables::Name,
|
|
585
|
-
Constants::OS2_TAG => Tables::Os2,
|
|
586
|
-
Constants::POST_TAG => Tables::Post,
|
|
587
|
-
Constants::CMAP_TAG => Tables::Cmap,
|
|
588
|
-
Constants::FVAR_TAG => Tables::Fvar,
|
|
589
|
-
Constants::GSUB_TAG => Tables::Gsub,
|
|
590
|
-
Constants::GPOS_TAG => Tables::Gpos,
|
|
591
|
-
}[tag]
|
|
592
|
-
end
|
|
593
|
-
|
|
594
|
-
# Build an SFNT font file (TTF or OTF) from decompressed WOFF2 data
|
|
657
|
+
# Update checksumAdjustment field in head table in memory
|
|
595
658
|
#
|
|
596
|
-
# @param
|
|
597
|
-
# @param
|
|
598
|
-
# @return [
|
|
599
|
-
def
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
search_range, entry_selector, range_shift = calculate_offset_table_fields(num_tables)
|
|
604
|
-
|
|
605
|
-
# Write offset table
|
|
606
|
-
io.write([sfnt_version].pack("N"))
|
|
607
|
-
io.write([num_tables].pack("n"))
|
|
608
|
-
io.write([search_range].pack("n"))
|
|
609
|
-
io.write([entry_selector].pack("n"))
|
|
610
|
-
io.write([range_shift].pack("n"))
|
|
611
|
-
|
|
612
|
-
# Calculate table offsets
|
|
613
|
-
offset = 12 + (num_tables * 16) # Header + directory
|
|
614
|
-
table_records = []
|
|
615
|
-
|
|
616
|
-
table_entries.each do |entry|
|
|
617
|
-
tag = entry.tag
|
|
618
|
-
data = @decompressed_tables[tag]
|
|
619
|
-
next unless data
|
|
620
|
-
|
|
621
|
-
length = data.bytesize
|
|
622
|
-
|
|
623
|
-
# Calculate checksum
|
|
624
|
-
checksum = Utilities::ChecksumCalculator.calculate_table_checksum(data)
|
|
625
|
-
|
|
626
|
-
table_records << {
|
|
627
|
-
tag: tag,
|
|
628
|
-
checksum: checksum,
|
|
629
|
-
offset: offset,
|
|
630
|
-
length: length,
|
|
631
|
-
data: data,
|
|
632
|
-
}
|
|
633
|
-
|
|
634
|
-
# Update offset for next table (with padding)
|
|
635
|
-
offset += length
|
|
636
|
-
padding = (Constants::TABLE_ALIGNMENT - (length % Constants::TABLE_ALIGNMENT)) %
|
|
637
|
-
Constants::TABLE_ALIGNMENT
|
|
638
|
-
offset += padding
|
|
639
|
-
end
|
|
659
|
+
# @param sfnt_data [String] The SFNT binary data
|
|
660
|
+
# @param table_records [Array<Hash>] Table records with offsets
|
|
661
|
+
# @return [void]
|
|
662
|
+
def self.update_checksum_in_memory(sfnt_data, table_records)
|
|
663
|
+
# Find head table record
|
|
664
|
+
head_record = table_records.find { |r| r[:tag] == Constants::HEAD_TAG }
|
|
665
|
+
return unless head_record
|
|
640
666
|
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
io.write([record[:checksum]].pack("N"))
|
|
645
|
-
io.write([record[:offset]].pack("N"))
|
|
646
|
-
io.write([record[:length]].pack("N"))
|
|
667
|
+
# Zero out checksumAdjustment field first
|
|
668
|
+
head_offset = head_record[:offset]
|
|
669
|
+
sfnt_data[head_offset + 8, 4] = "\x00\x00\x00\x00"
|
|
647
670
|
|
|
648
|
-
|
|
649
|
-
|
|
671
|
+
# Calculate file checksum
|
|
672
|
+
checksum = 0
|
|
673
|
+
sfnt_data.bytes.each_slice(4) do |bytes|
|
|
674
|
+
word = bytes.pack("C*").ljust(4, "\x00").unpack1("N")
|
|
675
|
+
checksum = (checksum + word) & 0xFFFFFFFF
|
|
676
|
+
end
|
|
650
677
|
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
Constants::TABLE_ALIGNMENT
|
|
654
|
-
io.write("\x00" * padding) if padding.positive?
|
|
655
|
-
end
|
|
678
|
+
# Calculate adjustment
|
|
679
|
+
adjustment = (0xB1B0AFBA - checksum) & 0xFFFFFFFF
|
|
656
680
|
|
|
657
|
-
|
|
658
|
-
|
|
681
|
+
# Write adjustment to head table
|
|
682
|
+
sfnt_data[head_offset + 8, 4] = [adjustment].pack("N")
|
|
683
|
+
end
|
|
684
|
+
|
|
685
|
+
private
|
|
659
686
|
|
|
660
|
-
|
|
661
|
-
|
|
687
|
+
# Read variable-length UIntBase128 integer from IO
|
|
688
|
+
def read_uint_base128(io)
|
|
689
|
+
self.class.read_uint_base128_from_io(io)
|
|
690
|
+
end
|
|
662
691
|
|
|
663
|
-
|
|
692
|
+
# Read 255UInt16 variable-length integer
|
|
693
|
+
def read_255_uint16(io)
|
|
694
|
+
code = io.read(1).unpack1("C")
|
|
695
|
+
|
|
696
|
+
case code
|
|
697
|
+
when 0..252
|
|
698
|
+
code
|
|
699
|
+
when 253
|
|
700
|
+
253 + io.read(1).unpack1("C")
|
|
701
|
+
when 254
|
|
702
|
+
io.read(2).unpack1("n")
|
|
703
|
+
when 255
|
|
704
|
+
io.read(2).unpack1("n") + 506
|
|
705
|
+
end
|
|
664
706
|
end
|
|
665
707
|
|
|
666
708
|
# Calculate offset table fields
|
|
667
|
-
#
|
|
668
|
-
# @param num_tables [Integer] Number of tables
|
|
669
|
-
# @return [Array<Integer>] [searchRange, entrySelector, rangeShift]
|
|
670
709
|
def calculate_offset_table_fields(num_tables)
|
|
671
710
|
entry_selector = (Math.log(num_tables) / Math.log(2)).floor
|
|
672
711
|
search_range = (2**entry_selector) * 16
|
|
673
712
|
range_shift = num_tables * 16 - search_range
|
|
674
|
-
[search_range, entry_selector, range_shift]
|
|
675
|
-
end
|
|
676
|
-
|
|
677
|
-
# Update checksumAdjustment field in head table
|
|
678
|
-
#
|
|
679
|
-
# @param path [String] Path to the font file
|
|
680
|
-
# @return [void]
|
|
681
|
-
def update_checksum_adjustment_in_file(path)
|
|
682
|
-
# Calculate file checksum
|
|
683
|
-
checksum = Utilities::ChecksumCalculator.calculate_file_checksum(path)
|
|
684
713
|
|
|
685
|
-
|
|
686
|
-
adjustment = Utilities::ChecksumCalculator.calculate_adjustment(checksum)
|
|
687
|
-
|
|
688
|
-
# Find head table position in output file
|
|
689
|
-
File.open(path, "rb") do |io|
|
|
690
|
-
io.seek(4) # Skip sfnt_version
|
|
691
|
-
num_tables = io.read(2).unpack1("n")
|
|
692
|
-
io.seek(12) # Start of table directory
|
|
693
|
-
|
|
694
|
-
num_tables.times do
|
|
695
|
-
tag = io.read(4)
|
|
696
|
-
io.read(4) # checksum
|
|
697
|
-
offset = io.read(4).unpack1("N")
|
|
698
|
-
io.read(4) # length
|
|
699
|
-
|
|
700
|
-
if tag == Constants::HEAD_TAG
|
|
701
|
-
# Write adjustment to head table (offset 8 within head table)
|
|
702
|
-
File.open(path, "r+b") do |write_io|
|
|
703
|
-
write_io.seek(offset + 8)
|
|
704
|
-
write_io.write([adjustment].pack("N"))
|
|
705
|
-
end
|
|
706
|
-
break
|
|
707
|
-
end
|
|
708
|
-
end
|
|
709
|
-
end
|
|
714
|
+
[search_range, entry_selector, range_shift]
|
|
710
715
|
end
|
|
711
716
|
end
|
|
712
717
|
end
|