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
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,20 @@ 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'
|
|
115
|
-
|
|
116
|
-
# Read WOFF2 font from a file
|
|
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)
|
|
85
|
+
# Simple struct for storing file path
|
|
86
|
+
IOSource = Struct.new(:path)
|
|
128
87
|
|
|
129
|
-
|
|
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, :underlying_font # Allow both reading and setting for table delegation
|
|
141
89
|
|
|
142
90
|
def initialize
|
|
143
91
|
@header = nil
|
|
@@ -145,253 +93,310 @@ module Fontisan
|
|
|
145
93
|
@decompressed_tables = {}
|
|
146
94
|
@parsed_tables = {}
|
|
147
95
|
@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)
|
|
96
|
+
@underlying_font = nil # Store the actual TrueTypeFont/OpenTypeFont
|
|
157
97
|
end
|
|
158
98
|
|
|
159
99
|
# Initialize storage hashes
|
|
160
|
-
#
|
|
161
|
-
# @return [void]
|
|
162
100
|
def initialize_storage
|
|
163
101
|
@decompressed_tables ||= {}
|
|
164
102
|
@initialize_storage ||= {}
|
|
165
103
|
end
|
|
166
104
|
|
|
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
|
|
105
|
+
# Check if font has TrueType flavor
|
|
183
106
|
def truetype?
|
|
184
|
-
|
|
107
|
+
return false unless @header
|
|
108
|
+
|
|
109
|
+
[Constants::SFNT_VERSION_TRUETYPE, 0x00010000].include?(@header.flavor)
|
|
185
110
|
end
|
|
186
111
|
|
|
187
|
-
# Check if font
|
|
188
|
-
#
|
|
189
|
-
# @return [Boolean] true if CFF, false if TrueType
|
|
112
|
+
# Check if font has CFF flavor
|
|
190
113
|
def cff?
|
|
191
|
-
|
|
114
|
+
return false unless @header
|
|
115
|
+
|
|
116
|
+
[Constants::SFNT_VERSION_OTTO, 0x4F54544F].include?(@header.flavor)
|
|
192
117
|
end
|
|
193
118
|
|
|
194
|
-
#
|
|
119
|
+
# Check if font is a variable font
|
|
195
120
|
#
|
|
196
|
-
#
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
# @return [String, nil] Decompressed table data or nil if not found
|
|
200
|
-
def table_data(tag)
|
|
201
|
-
@decompressed_tables[tag]
|
|
121
|
+
# @return [Boolean] true if font has fvar table (variable font)
|
|
122
|
+
def variable_font?
|
|
123
|
+
has_table?("fvar")
|
|
202
124
|
end
|
|
203
125
|
|
|
204
|
-
#
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
table_entries.any? { |entry| entry.tag == tag }
|
|
126
|
+
# Validate WOFF2 signature
|
|
127
|
+
def validate_signature!
|
|
128
|
+
unless @header && @header.signature == Woff2::Woff2Header::SIGNATURE
|
|
129
|
+
raise InvalidFontError, "Invalid WOFF2 signature"
|
|
130
|
+
end
|
|
210
131
|
end
|
|
211
132
|
|
|
212
|
-
#
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
133
|
+
# Check if font is valid
|
|
134
|
+
def valid?
|
|
135
|
+
return false unless @header
|
|
136
|
+
return false unless @header.signature == Woff2::Woff2Header::SIGNATURE
|
|
137
|
+
return false unless @header.num_tables == @table_entries.length
|
|
138
|
+
return false unless has_table?("head")
|
|
139
|
+
|
|
140
|
+
true
|
|
218
141
|
end
|
|
219
142
|
|
|
220
|
-
#
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
def table_names
|
|
224
|
-
table_entries.map(&:tag)
|
|
143
|
+
# Check if table exists
|
|
144
|
+
def has_table?(tag)
|
|
145
|
+
@table_entries.any? { |entry| entry.tag == tag }
|
|
225
146
|
end
|
|
226
147
|
|
|
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)
|
|
148
|
+
# Find table entry by tag
|
|
149
|
+
def find_table_entry(tag)
|
|
150
|
+
@table_entries.find { |entry| entry.tag == tag }
|
|
236
151
|
end
|
|
237
152
|
|
|
238
|
-
# Get
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
def units_per_em
|
|
242
|
-
head = table(Constants::HEAD_TAG)
|
|
243
|
-
head&.units_per_em
|
|
153
|
+
# Get list of table tags
|
|
154
|
+
def table_names
|
|
155
|
+
@table_entries.map(&:tag)
|
|
244
156
|
end
|
|
245
157
|
|
|
246
|
-
# Get
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
158
|
+
# Get decompressed table data
|
|
159
|
+
def table_data(tag)
|
|
160
|
+
# First try underlying font's table data if available
|
|
161
|
+
if @underlying_font.respond_to?(:table_data)
|
|
162
|
+
underlying_data = @underlying_font.table_data[tag]
|
|
163
|
+
return underlying_data if underlying_data
|
|
164
|
+
end
|
|
252
165
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
@metadata = Brotli.inflate(compressed_meta)
|
|
166
|
+
# Fallback to decompressed_tables
|
|
167
|
+
@decompressed_tables[tag]
|
|
168
|
+
end
|
|
257
169
|
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
end
|
|
170
|
+
# Get parsed table object
|
|
171
|
+
def table(tag)
|
|
172
|
+
# Delegate to underlying font if available
|
|
173
|
+
return @underlying_font.table(tag) if @underlying_font
|
|
263
174
|
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
@
|
|
175
|
+
# Fallback to parsed_tables hash
|
|
176
|
+
# Normalize tag to UTF-8 string for hash lookup
|
|
177
|
+
# Use dup to create mutable copy since force_encoding modifies in place
|
|
178
|
+
tag_key = tag.to_s.dup.force_encoding("UTF-8")
|
|
179
|
+
@parsed_tables[tag_key]
|
|
269
180
|
end
|
|
270
181
|
|
|
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
|
|
182
|
+
# Convert to TTF
|
|
278
183
|
def to_ttf(output_path)
|
|
279
184
|
unless truetype?
|
|
280
185
|
raise InvalidFontError,
|
|
281
|
-
"Cannot convert to TTF: font is
|
|
186
|
+
"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,
|
|
191
|
+
@decompressed_tables)
|
|
192
|
+
sfnt_io = StringIO.new(sfnt_data)
|
|
193
|
+
|
|
194
|
+
# Create actual TrueTypeFont and save for table delegation
|
|
195
|
+
@underlying_font = TrueTypeFont.read(sfnt_io)
|
|
196
|
+
@underlying_font.initialize_storage
|
|
197
|
+
@underlying_font.read_table_data(sfnt_io)
|
|
198
|
+
|
|
199
|
+
FontWriter.write_to_file(@underlying_font.tables, output_path)
|
|
285
200
|
end
|
|
286
201
|
|
|
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
|
|
202
|
+
# Convert to OTF
|
|
294
203
|
def to_otf(output_path)
|
|
295
204
|
unless cff?
|
|
296
205
|
raise InvalidFontError,
|
|
297
|
-
"Cannot convert to OTF: font is
|
|
206
|
+
"Cannot convert to OTF: font is not CFF flavored"
|
|
298
207
|
end
|
|
299
208
|
|
|
300
|
-
|
|
209
|
+
# Build SFNT and create OpenTypeFont
|
|
210
|
+
sfnt_data = self.class.build_sfnt_in_memory(@header, @table_entries,
|
|
211
|
+
@decompressed_tables)
|
|
212
|
+
sfnt_io = StringIO.new(sfnt_data)
|
|
213
|
+
|
|
214
|
+
# Create actual OpenTypeFont and save for table delegation
|
|
215
|
+
@underlying_font = OpenTypeFont.read(sfnt_io)
|
|
216
|
+
@underlying_font.initialize_storage
|
|
217
|
+
@underlying_font.read_table_data(sfnt_io)
|
|
218
|
+
|
|
219
|
+
FontWriter.write_to_file(@underlying_font.tables, output_path)
|
|
301
220
|
end
|
|
302
221
|
|
|
303
|
-
#
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
222
|
+
# Get metadata (if present)
|
|
223
|
+
def metadata
|
|
224
|
+
return nil unless @header&.meta_length&.positive?
|
|
225
|
+
return nil unless @io_source
|
|
226
|
+
|
|
227
|
+
begin
|
|
228
|
+
File.open(@io_source.path, "rb") do |io|
|
|
229
|
+
io.seek(@header.meta_offset)
|
|
230
|
+
compressed_meta = io.read(@header.meta_length)
|
|
231
|
+
Brotli.inflate(compressed_meta)
|
|
232
|
+
end
|
|
233
|
+
rescue StandardError => e
|
|
234
|
+
warn "Failed to decompress metadata: #{e.message}"
|
|
235
|
+
nil
|
|
236
|
+
end
|
|
237
|
+
end
|
|
312
238
|
|
|
313
|
-
|
|
239
|
+
# Convenience methods for accessing common name table fields
|
|
240
|
+
|
|
241
|
+
# Get font family name
|
|
242
|
+
def family_name
|
|
243
|
+
name_table = table("name")
|
|
244
|
+
name_table&.english_name(Tables::Name::FAMILY)
|
|
314
245
|
end
|
|
315
246
|
|
|
316
|
-
|
|
247
|
+
# Get font subfamily name
|
|
248
|
+
def subfamily_name
|
|
249
|
+
name_table = table("name")
|
|
250
|
+
name_table&.english_name(Tables::Name::SUBFAMILY)
|
|
251
|
+
end
|
|
317
252
|
|
|
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
|
|
253
|
+
# Get full font name
|
|
254
|
+
def full_name
|
|
255
|
+
name_table = table("name")
|
|
256
|
+
name_table&.english_name(Tables::Name::FULL_NAME)
|
|
257
|
+
end
|
|
332
258
|
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
end
|
|
339
|
-
end
|
|
259
|
+
# Get PostScript name
|
|
260
|
+
def post_script_name
|
|
261
|
+
name_table = table("name")
|
|
262
|
+
name_table&.english_name(Tables::Name::POSTSCRIPT_NAME)
|
|
263
|
+
end
|
|
340
264
|
|
|
341
|
-
|
|
342
|
-
|
|
265
|
+
# Get preferred family name
|
|
266
|
+
def preferred_family_name
|
|
267
|
+
name_table = table("name")
|
|
268
|
+
name_table&.english_name(Tables::Name::PREFERRED_FAMILY)
|
|
343
269
|
end
|
|
344
270
|
|
|
345
|
-
#
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
#
|
|
271
|
+
# Get preferred subfamily name
|
|
272
|
+
def preferred_subfamily_name
|
|
273
|
+
name_table = table("name")
|
|
274
|
+
name_table&.english_name(Tables::Name::PREFERRED_SUBFAMILY)
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
# Get units per em
|
|
278
|
+
def units_per_em
|
|
279
|
+
head = table("head")
|
|
280
|
+
head&.units_per_em
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# Read WOFF2 font from a file and return Woff2Font instance
|
|
352
284
|
#
|
|
353
|
-
# @param
|
|
354
|
-
# @
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
285
|
+
# @param path [String] Path to the WOFF2 file
|
|
286
|
+
# @param mode [Symbol] Loading mode (:metadata or :full)
|
|
287
|
+
# @param lazy [Boolean] If true, load tables on demand
|
|
288
|
+
# @return [Woff2Font] The WOFF2 font object
|
|
289
|
+
# @raise [ArgumentError] if path is nil or empty
|
|
290
|
+
# @raise [Errno::ENOENT] if file does not exist
|
|
291
|
+
# @raise [InvalidFontError] if file format is invalid
|
|
292
|
+
def self.from_file(path, mode: LoadingModes::FULL, lazy: false)
|
|
293
|
+
if path.nil? || path.to_s.empty?
|
|
294
|
+
raise ArgumentError, "path cannot be nil or empty"
|
|
295
|
+
end
|
|
296
|
+
raise Errno::ENOENT, "File not found: #{path}" unless File.exist?(path)
|
|
358
297
|
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
298
|
+
woff2 = new
|
|
299
|
+
woff2.io_source = IOSource.new(path)
|
|
300
|
+
|
|
301
|
+
File.open(path, "rb") do |io|
|
|
302
|
+
# Read header to determine font flavor
|
|
303
|
+
woff2.header = Woff2::Woff2Header.read(io)
|
|
304
|
+
|
|
305
|
+
# Validate signature
|
|
306
|
+
unless woff2.header.signature == Woff2::Woff2Header::SIGNATURE
|
|
307
|
+
raise InvalidFontError,
|
|
308
|
+
"Invalid WOFF2 signature: expected 0x#{Woff2::Woff2Header::SIGNATURE.to_s(16)}, " \
|
|
309
|
+
"got 0x#{woff2.header.signature.to_i.to_s(16)}"
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
# Read table directory
|
|
313
|
+
woff2.table_entries = read_table_directory_from_io(io, woff2.header)
|
|
314
|
+
|
|
315
|
+
# Decompress table data
|
|
316
|
+
woff2.decompressed_tables = decompress_tables(io, woff2.header,
|
|
317
|
+
woff2.table_entries)
|
|
318
|
+
|
|
319
|
+
# Apply table transformations if present
|
|
320
|
+
apply_transformations!(woff2.table_entries, woff2.decompressed_tables)
|
|
321
|
+
|
|
322
|
+
# Build SFNT structure in memory
|
|
323
|
+
sfnt_data = build_sfnt_in_memory(woff2.header, woff2.table_entries,
|
|
324
|
+
woff2.decompressed_tables)
|
|
325
|
+
|
|
326
|
+
# Create StringIO for reading
|
|
327
|
+
sfnt_io = StringIO.new(sfnt_data)
|
|
328
|
+
sfnt_io.rewind
|
|
329
|
+
|
|
330
|
+
# Parse tables based on font type
|
|
331
|
+
if woff2.truetype?
|
|
332
|
+
font = TrueTypeFont.read(sfnt_io)
|
|
333
|
+
font.initialize_storage
|
|
334
|
+
font.loading_mode = mode
|
|
335
|
+
font.lazy_load_enabled = lazy
|
|
336
|
+
|
|
337
|
+
# Create fresh StringIO for table data reading
|
|
338
|
+
table_io = StringIO.new(sfnt_data)
|
|
339
|
+
font.read_table_data(table_io)
|
|
340
|
+
|
|
341
|
+
# Store underlying font for table access delegation
|
|
342
|
+
woff2.underlying_font = font
|
|
343
|
+
woff2.parsed_tables = font.parsed_tables
|
|
344
|
+
elsif woff2.cff?
|
|
345
|
+
font = OpenTypeFont.read(sfnt_io)
|
|
346
|
+
font.initialize_storage
|
|
347
|
+
font.loading_mode = mode
|
|
348
|
+
font.lazy_load_enabled = lazy
|
|
349
|
+
|
|
350
|
+
# Create fresh StringIO for table data reading
|
|
351
|
+
table_io = StringIO.new(sfnt_data)
|
|
352
|
+
font.read_table_data(table_io)
|
|
353
|
+
|
|
354
|
+
# Store underlying font for table access delegation
|
|
355
|
+
woff2.underlying_font = font
|
|
356
|
+
woff2.parsed_tables = font.parsed_tables
|
|
357
|
+
else
|
|
358
|
+
raise InvalidFontError,
|
|
359
|
+
"Unknown WOFF2 flavor: 0x#{woff2.header.flavor.to_s(16)}"
|
|
360
|
+
end
|
|
370
361
|
end
|
|
362
|
+
|
|
363
|
+
woff2
|
|
364
|
+
rescue BinData::ValidityError, EOFError => e
|
|
365
|
+
raise InvalidFontError, "Invalid WOFF2 file: #{e.message}"
|
|
371
366
|
end
|
|
372
367
|
|
|
373
|
-
# Read
|
|
374
|
-
#
|
|
375
|
-
# The table directory in WOFF2 is more compact than WOFF,
|
|
376
|
-
# using variable-length integers and known table indices.
|
|
368
|
+
# Read table directory from IO
|
|
377
369
|
#
|
|
378
370
|
# @param io [IO] Open file handle
|
|
379
|
-
# @
|
|
380
|
-
|
|
381
|
-
|
|
371
|
+
# @param header [Woff2::Woff2Header] WOFF2 header
|
|
372
|
+
# @return [Array<Woff2TableDirectoryEntry>] Table entries
|
|
373
|
+
def self.read_table_directory_from_io(io, header)
|
|
374
|
+
table_entries = []
|
|
382
375
|
|
|
383
376
|
header.num_tables.times do
|
|
384
377
|
entry = Woff2TableDirectoryEntry.new
|
|
385
378
|
|
|
386
|
-
# Read flags byte
|
|
387
|
-
|
|
379
|
+
# Read flags byte with nil check
|
|
380
|
+
flags_data = io.read(1)
|
|
381
|
+
if flags_data.nil?
|
|
382
|
+
raise EOFError,
|
|
383
|
+
"Unexpected EOF while reading table directory flags"
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
flags = flags_data.unpack1("C")
|
|
388
387
|
entry.flags = flags
|
|
389
388
|
|
|
390
389
|
# Determine tag
|
|
391
390
|
tag_index = flags & 0x3F
|
|
392
391
|
if tag_index == 0x3F
|
|
393
392
|
# Custom tag (4 bytes)
|
|
394
|
-
|
|
393
|
+
tag_data = io.read(4)
|
|
394
|
+
if tag_data.nil? || tag_data.bytesize < 4
|
|
395
|
+
raise EOFError,
|
|
396
|
+
"Unexpected EOF while reading custom tag"
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
entry.tag = tag_data.force_encoding("UTF-8")
|
|
395
400
|
else
|
|
396
401
|
# Known tag from table
|
|
397
402
|
entry.tag = Woff2TableDirectoryEntry::KNOWN_TAGS[tag_index]
|
|
@@ -401,64 +406,157 @@ module Fontisan
|
|
|
401
406
|
end
|
|
402
407
|
|
|
403
408
|
# Read orig_length (UIntBase128)
|
|
404
|
-
entry.orig_length =
|
|
409
|
+
entry.orig_length = read_uint_base128_from_io(io)
|
|
405
410
|
|
|
406
|
-
#
|
|
411
|
+
# Determine if transformLength should be read
|
|
412
|
+
# According to WOFF2 spec section 4.2:
|
|
413
|
+
# - glyf/loca with version 0: TRANSFORMED (transformLength present)
|
|
414
|
+
# - hmtx with non-zero version: TRANSFORMED (transformLength present)
|
|
415
|
+
# - all other tables: transformation version is 0 (no transformLength)
|
|
407
416
|
transform_version = (flags >> 6) & 0x03
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
417
|
+
has_transform_length = if ["glyf",
|
|
418
|
+
"loca"].include?(entry.tag) && transform_version.zero?
|
|
419
|
+
true
|
|
420
|
+
elsif entry.tag == "hmtx" && transform_version != 0
|
|
421
|
+
true
|
|
422
|
+
else
|
|
423
|
+
false
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
if has_transform_length
|
|
427
|
+
entry.transform_length = read_uint_base128_from_io(io)
|
|
411
428
|
entry.transform_version = transform_version
|
|
412
429
|
end
|
|
413
430
|
|
|
414
|
-
|
|
431
|
+
table_entries << entry
|
|
415
432
|
end
|
|
433
|
+
|
|
434
|
+
table_entries
|
|
416
435
|
end
|
|
417
436
|
|
|
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)
|
|
437
|
+
# Read variable-length UIntBase128 integer from IO
|
|
424
438
|
#
|
|
425
439
|
# @param io [IO] Open file handle
|
|
426
|
-
# @return [
|
|
427
|
-
def
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
440
|
+
# @return [Integer] The decoded integer value
|
|
441
|
+
def self.read_uint_base128_from_io(io)
|
|
442
|
+
result = 0
|
|
443
|
+
5.times do
|
|
444
|
+
byte_data = io.read(1)
|
|
445
|
+
if byte_data.nil?
|
|
446
|
+
raise EOFError,
|
|
447
|
+
"Unexpected EOF while reading UIntBase128"
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
byte = byte_data.unpack1("C")
|
|
451
|
+
|
|
452
|
+
# Continue if high bit is set
|
|
453
|
+
if (byte & 0x80).zero?
|
|
454
|
+
return (result << 7) | byte
|
|
455
|
+
else
|
|
456
|
+
result = (result << 7) | (byte & 0x7F)
|
|
457
|
+
end
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
# If we're here, the encoding is invalid
|
|
461
|
+
raise InvalidFontError, "Invalid UIntBase128 encoding"
|
|
462
|
+
end
|
|
432
463
|
|
|
433
|
-
|
|
464
|
+
# Decompress tables from WOFF2 compressed data block
|
|
465
|
+
#
|
|
466
|
+
# @param io [IO] Open file handle
|
|
467
|
+
# @param header [Woff2::Woff2Header] WOFF2 header
|
|
468
|
+
# @param table_entries [Array<Woff2TableDirectoryEntry>] Table entries
|
|
469
|
+
# @return [Hash<String, String>] Map of tag to decompressed data
|
|
470
|
+
def self.decompress_tables(io, header, table_entries)
|
|
471
|
+
# IO stream is already positioned at compressed data after reading table directory
|
|
472
|
+
# No need to seek - just read from current position
|
|
434
473
|
compressed_data = io.read(header.total_compressed_size)
|
|
435
474
|
|
|
436
475
|
# Decompress entire data block with Brotli
|
|
437
476
|
decompressed_data = Brotli.inflate(compressed_data)
|
|
438
477
|
|
|
439
478
|
# Split decompressed data into individual tables
|
|
479
|
+
decompressed_tables = {}
|
|
440
480
|
offset = 0
|
|
481
|
+
|
|
441
482
|
table_entries.each do |entry|
|
|
442
483
|
table_size = entry.transform_length || entry.orig_length
|
|
443
|
-
|
|
444
484
|
table_data = decompressed_data[offset, table_size]
|
|
445
485
|
offset += table_size
|
|
446
486
|
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
487
|
+
decompressed_tables[entry.tag] = table_data
|
|
488
|
+
end
|
|
489
|
+
|
|
490
|
+
decompressed_tables
|
|
491
|
+
end
|
|
492
|
+
|
|
493
|
+
# Apply table transformations for glyf/loca/hmtx tables
|
|
494
|
+
#
|
|
495
|
+
# @param table_entries [Array<Woff2TableDirectoryEntry>] Table entries
|
|
496
|
+
# @param decompressed_tables [Hash<String, String>] Decompressed tables
|
|
497
|
+
# @return [void] Modifies decompressed_tables in place
|
|
498
|
+
def self.apply_transformations!(table_entries, decompressed_tables)
|
|
499
|
+
# Find entries that need transformation
|
|
500
|
+
glyf_entry = table_entries.find { |e| e.tag == "glyf" }
|
|
501
|
+
hmtx_entry = table_entries.find { |e| e.tag == "hmtx" }
|
|
502
|
+
|
|
503
|
+
# Get required metadata for transformations
|
|
504
|
+
maxp_data = decompressed_tables["maxp"]
|
|
505
|
+
hhea_data = decompressed_tables["hhea"]
|
|
506
|
+
|
|
507
|
+
return unless maxp_data && hhea_data
|
|
508
|
+
|
|
509
|
+
# Parse num_glyphs from maxp table
|
|
510
|
+
# maxp format: version(4) + numGlyphs(2) + ...
|
|
511
|
+
num_glyphs = maxp_data[4, 2].unpack1("n")
|
|
512
|
+
|
|
513
|
+
# Parse numberOfHMetrics from hhea table
|
|
514
|
+
# hhea format: ... + numberOfHMetrics(2) at offset 34
|
|
515
|
+
number_of_h_metrics = hhea_data[34, 2].unpack1("n")
|
|
516
|
+
|
|
517
|
+
# Check if this is a variable font by checking for fvar table
|
|
518
|
+
variable_font = table_entries.any? { |e| e.tag == "fvar" }
|
|
519
|
+
|
|
520
|
+
# Transform glyf/loca if needed
|
|
521
|
+
# transform_length is only set when table is actually transformed
|
|
522
|
+
# Check that transform_length exists and is greater than 0
|
|
523
|
+
if glyf_entry&.instance_variable_defined?(:@transform_length) &&
|
|
524
|
+
glyf_entry.transform_length&.positive?
|
|
525
|
+
transformed_glyf = decompressed_tables["glyf"]
|
|
526
|
+
|
|
527
|
+
if transformed_glyf
|
|
528
|
+
result = Woff2::GlyfTransformer.reconstruct(
|
|
529
|
+
transformed_glyf,
|
|
530
|
+
num_glyphs,
|
|
531
|
+
variable_font: variable_font,
|
|
532
|
+
)
|
|
533
|
+
decompressed_tables["glyf"] = result[:glyf]
|
|
534
|
+
decompressed_tables["loca"] = result[:loca]
|
|
450
535
|
end
|
|
536
|
+
end
|
|
451
537
|
|
|
452
|
-
|
|
538
|
+
# Transform hmtx if needed
|
|
539
|
+
# transform_length is only set when table is actually transformed
|
|
540
|
+
# Check that transform_length exists and is greater than 0
|
|
541
|
+
if hmtx_entry&.instance_variable_defined?(:@transform_length) &&
|
|
542
|
+
hmtx_entry.transform_length&.positive?
|
|
543
|
+
transformed_hmtx = decompressed_tables["hmtx"]
|
|
544
|
+
|
|
545
|
+
if transformed_hmtx
|
|
546
|
+
decompressed_tables["hmtx"] = Woff2::HmtxTransformer.reconstruct(
|
|
547
|
+
transformed_hmtx,
|
|
548
|
+
num_glyphs,
|
|
549
|
+
number_of_h_metrics,
|
|
550
|
+
)
|
|
551
|
+
end
|
|
453
552
|
end
|
|
454
553
|
end
|
|
455
554
|
|
|
456
555
|
# Calculate size of table directory
|
|
457
556
|
#
|
|
458
|
-
#
|
|
459
|
-
#
|
|
557
|
+
# @param table_entries [Array<Woff2TableDirectoryEntry>] Table entries
|
|
460
558
|
# @return [Integer] Size in bytes
|
|
461
|
-
def calculate_table_directory_size
|
|
559
|
+
def self.calculate_table_directory_size(table_entries)
|
|
462
560
|
size = 0
|
|
463
561
|
table_entries.each do |entry|
|
|
464
562
|
size += 1 # flags byte
|
|
@@ -471,7 +569,7 @@ module Fontisan
|
|
|
471
569
|
size += uint_base128_size(entry.orig_length)
|
|
472
570
|
|
|
473
571
|
# transform_length if present
|
|
474
|
-
if entry.transform_version && entry.transform_version
|
|
572
|
+
if entry.transform_version && !entry.transform_version.nil?
|
|
475
573
|
size += uint_base128_size(entry.transform_length)
|
|
476
574
|
end
|
|
477
575
|
end
|
|
@@ -482,7 +580,7 @@ module Fontisan
|
|
|
482
580
|
#
|
|
483
581
|
# @param value [Integer] The value to encode
|
|
484
582
|
# @return [Integer] Estimated size in bytes
|
|
485
|
-
def uint_base128_size(value)
|
|
583
|
+
def self.uint_base128_size(value)
|
|
486
584
|
return 1 if value < 128
|
|
487
585
|
|
|
488
586
|
bytes = 0
|
|
@@ -491,222 +589,145 @@ module Fontisan
|
|
|
491
589
|
bytes += 1
|
|
492
590
|
v >>= 7
|
|
493
591
|
end
|
|
494
|
-
[
|
|
592
|
+
[
|
|
593
|
+
bytes,
|
|
594
|
+
5,
|
|
595
|
+
].min # Max 5 bytes
|
|
495
596
|
end
|
|
496
597
|
|
|
497
|
-
#
|
|
598
|
+
# Build SFNT binary structure in memory
|
|
498
599
|
#
|
|
499
|
-
#
|
|
500
|
-
#
|
|
501
|
-
#
|
|
502
|
-
#
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
600
|
+
# @param header [Woff2::Woff2Header] WOFF2 header
|
|
601
|
+
# @param table_entries [Array<Woff2TableDirectoryEntry>] Table entries
|
|
602
|
+
# @param decompressed_tables [Hash<String, String>] Decompressed table data
|
|
603
|
+
# @return [String] Complete SFNT binary data
|
|
604
|
+
def self.build_sfnt_in_memory(header, table_entries, decompressed_tables)
|
|
605
|
+
sfnt_data = +""
|
|
606
|
+
|
|
607
|
+
# Calculate offset table fields
|
|
608
|
+
num_tables = table_entries.length
|
|
609
|
+
entry_selector = (Math.log(num_tables) / Math.log(2)).floor
|
|
610
|
+
search_range = (2**entry_selector) * 16
|
|
611
|
+
range_shift = num_tables * 16 - search_range
|
|
612
|
+
|
|
613
|
+
# Write offset table
|
|
614
|
+
sfnt_data << [header.flavor].pack("N")
|
|
615
|
+
sfnt_data << [num_tables].pack("n")
|
|
616
|
+
sfnt_data << [search_range].pack("n")
|
|
617
|
+
sfnt_data << [entry_selector].pack("n")
|
|
618
|
+
sfnt_data << [range_shift].pack("n")
|
|
619
|
+
|
|
620
|
+
# Calculate table offsets
|
|
621
|
+
offset = 12 + (num_tables * 16) # Header + directory
|
|
622
|
+
table_records = []
|
|
623
|
+
|
|
624
|
+
table_entries.each do |entry|
|
|
625
|
+
tag = entry.tag
|
|
626
|
+
data = decompressed_tables[tag]
|
|
627
|
+
next unless data
|
|
628
|
+
|
|
629
|
+
length = data.bytesize
|
|
630
|
+
|
|
631
|
+
# Calculate checksum
|
|
632
|
+
checksum = Utilities::ChecksumCalculator.calculate_table_checksum(data)
|
|
633
|
+
|
|
634
|
+
table_records << {
|
|
635
|
+
tag: tag,
|
|
636
|
+
checksum: checksum,
|
|
637
|
+
offset: offset,
|
|
638
|
+
length: length,
|
|
639
|
+
data: data,
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
# Update offset for next table (with padding)
|
|
643
|
+
offset += length
|
|
644
|
+
padding = (Constants::TABLE_ALIGNMENT - (length % Constants::TABLE_ALIGNMENT)) %
|
|
645
|
+
Constants::TABLE_ALIGNMENT
|
|
646
|
+
offset += padding
|
|
515
647
|
end
|
|
516
|
-
end
|
|
517
648
|
|
|
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
|
|
649
|
+
# Write table directory
|
|
650
|
+
table_records.each do |record|
|
|
651
|
+
sfnt_data << record[:tag].ljust(4, "\x00")
|
|
652
|
+
sfnt_data << [record[:checksum]].pack("N")
|
|
653
|
+
sfnt_data << [record[:offset]].pack("N")
|
|
654
|
+
sfnt_data << [record[:length]].pack("N")
|
|
655
|
+
end
|
|
567
656
|
|
|
568
|
-
|
|
569
|
-
|
|
657
|
+
# Write table data with padding
|
|
658
|
+
table_records.each do |record|
|
|
659
|
+
sfnt_data << record[:data]
|
|
570
660
|
|
|
571
|
-
|
|
661
|
+
# Add padding
|
|
662
|
+
padding = (Constants::TABLE_ALIGNMENT - (record[:length] % Constants::TABLE_ALIGNMENT)) %
|
|
663
|
+
Constants::TABLE_ALIGNMENT
|
|
664
|
+
sfnt_data << ("\x00" * padding) if padding.positive?
|
|
665
|
+
end
|
|
666
|
+
|
|
667
|
+
# Update checksumAdjustment in head table
|
|
668
|
+
update_checksum_in_memory(sfnt_data, table_records)
|
|
669
|
+
|
|
670
|
+
sfnt_data
|
|
572
671
|
end
|
|
573
672
|
|
|
574
|
-
#
|
|
673
|
+
# Update checksumAdjustment field in head table in memory
|
|
575
674
|
#
|
|
576
|
-
# @param
|
|
577
|
-
# @
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
# Build an SFNT font file (TTF or OTF) from decompressed WOFF2 data
|
|
595
|
-
#
|
|
596
|
-
# @param output_path [String] Path where font will be written
|
|
597
|
-
# @param sfnt_version [Integer] SFNT version
|
|
598
|
-
# @return [Integer] Number of bytes written
|
|
599
|
-
def build_sfnt_font(output_path, sfnt_version)
|
|
600
|
-
File.open(output_path, "wb") do |io|
|
|
601
|
-
# Calculate offset table fields
|
|
602
|
-
num_tables = table_entries.length
|
|
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
|
|
675
|
+
# @param sfnt_data [String] The SFNT binary data
|
|
676
|
+
# @param table_records [Array<Hash>] Table records with offsets
|
|
677
|
+
# @return [void]
|
|
678
|
+
def self.update_checksum_in_memory(sfnt_data, table_records)
|
|
679
|
+
# Find head table record
|
|
680
|
+
head_record = table_records.find { |r| r[:tag] == Constants::HEAD_TAG }
|
|
681
|
+
return unless head_record
|
|
682
|
+
|
|
683
|
+
# Zero out checksumAdjustment field first
|
|
684
|
+
head_offset = head_record[:offset]
|
|
685
|
+
sfnt_data[head_offset + 8, 4] = "\x00\x00\x00\x00"
|
|
686
|
+
|
|
687
|
+
# Calculate file checksum
|
|
688
|
+
checksum = 0
|
|
689
|
+
sfnt_data.bytes.each_slice(4) do |bytes|
|
|
690
|
+
word = bytes.pack("C*").ljust(4, "\x00").unpack1("N")
|
|
691
|
+
checksum = (checksum + word) & 0xFFFFFFFF
|
|
692
|
+
end
|
|
640
693
|
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
io.write(record[:tag].ljust(4, "\x00"))
|
|
644
|
-
io.write([record[:checksum]].pack("N"))
|
|
645
|
-
io.write([record[:offset]].pack("N"))
|
|
646
|
-
io.write([record[:length]].pack("N"))
|
|
694
|
+
# Calculate adjustment
|
|
695
|
+
adjustment = (0xB1B0AFBA - checksum) & 0xFFFFFFFF
|
|
647
696
|
|
|
648
|
-
|
|
649
|
-
|
|
697
|
+
# Write adjustment to head table
|
|
698
|
+
sfnt_data[head_offset + 8, 4] = [adjustment].pack("N")
|
|
699
|
+
end
|
|
650
700
|
|
|
651
|
-
|
|
652
|
-
padding = (Constants::TABLE_ALIGNMENT - (record[:length] % Constants::TABLE_ALIGNMENT)) %
|
|
653
|
-
Constants::TABLE_ALIGNMENT
|
|
654
|
-
io.write("\x00" * padding) if padding.positive?
|
|
655
|
-
end
|
|
701
|
+
private
|
|
656
702
|
|
|
657
|
-
|
|
658
|
-
|
|
703
|
+
# Read variable-length UIntBase128 integer from IO
|
|
704
|
+
def read_uint_base128(io)
|
|
705
|
+
self.class.read_uint_base128_from_io(io)
|
|
706
|
+
end
|
|
659
707
|
|
|
660
|
-
|
|
661
|
-
|
|
708
|
+
# Read 255UInt16 variable-length integer
|
|
709
|
+
def read_255_uint16(io)
|
|
710
|
+
code = io.read(1).unpack1("C")
|
|
662
711
|
|
|
663
|
-
|
|
712
|
+
case code
|
|
713
|
+
when 0..252
|
|
714
|
+
code
|
|
715
|
+
when 253
|
|
716
|
+
253 + io.read(1).unpack1("C")
|
|
717
|
+
when 254
|
|
718
|
+
io.read(2).unpack1("n")
|
|
719
|
+
when 255
|
|
720
|
+
io.read(2).unpack1("n") + 506
|
|
721
|
+
end
|
|
664
722
|
end
|
|
665
723
|
|
|
666
724
|
# Calculate offset table fields
|
|
667
|
-
#
|
|
668
|
-
# @param num_tables [Integer] Number of tables
|
|
669
|
-
# @return [Array<Integer>] [searchRange, entrySelector, rangeShift]
|
|
670
725
|
def calculate_offset_table_fields(num_tables)
|
|
671
726
|
entry_selector = (Math.log(num_tables) / Math.log(2)).floor
|
|
672
727
|
search_range = (2**entry_selector) * 16
|
|
673
728
|
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
729
|
|
|
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
|
|
730
|
+
[search_range, entry_selector, range_shift]
|
|
710
731
|
end
|
|
711
732
|
end
|
|
712
733
|
end
|