fontisan 0.2.8 → 0.2.10
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 +17 -101
- data/CHANGELOG.md +116 -0
- data/README.adoc +25 -13
- data/docs/APPLE_LEGACY_FONTS.adoc +173 -0
- data/docs/COLLECTION_VALIDATION.adoc +143 -0
- data/docs/COLOR_FONTS.adoc +127 -0
- data/docs/DOCUMENTATION_SUMMARY.md +141 -0
- data/docs/FONT_HINTING.adoc +9 -1
- data/docs/VALIDATION.adoc +254 -0
- data/docs/WOFF_WOFF2_FORMATS.adoc +94 -0
- data/lib/fontisan/open_type_font.rb +18 -424
- data/lib/fontisan/sfnt_font.rb +690 -0
- data/lib/fontisan/sfnt_table.rb +264 -0
- data/lib/fontisan/tables/cmap_table.rb +231 -0
- data/lib/fontisan/tables/glyf_table.rb +255 -0
- data/lib/fontisan/tables/head_table.rb +111 -0
- data/lib/fontisan/tables/hhea_table.rb +255 -0
- data/lib/fontisan/tables/hmtx_table.rb +191 -0
- data/lib/fontisan/tables/loca_table.rb +212 -0
- data/lib/fontisan/tables/maxp_table.rb +258 -0
- data/lib/fontisan/tables/name_table.rb +176 -0
- data/lib/fontisan/tables/os2_table.rb +329 -0
- data/lib/fontisan/tables/post_table.rb +183 -0
- data/lib/fontisan/true_type_font.rb +12 -463
- data/lib/fontisan/version.rb +1 -1
- data/lib/fontisan/woff_font.rb +45 -29
- metadata +21 -2
|
@@ -0,0 +1,690 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bindata"
|
|
4
|
+
require_relative "constants"
|
|
5
|
+
require_relative "loading_modes"
|
|
6
|
+
require_relative "utilities/checksum_calculator"
|
|
7
|
+
require_relative "sfnt_table"
|
|
8
|
+
require_relative "tables/head_table"
|
|
9
|
+
require_relative "tables/name_table"
|
|
10
|
+
require_relative "tables/os2_table"
|
|
11
|
+
require_relative "tables/cmap_table"
|
|
12
|
+
require_relative "tables/glyf_table"
|
|
13
|
+
require_relative "tables/hhea_table"
|
|
14
|
+
require_relative "tables/maxp_table"
|
|
15
|
+
require_relative "tables/post_table"
|
|
16
|
+
require_relative "tables/hmtx_table"
|
|
17
|
+
require_relative "tables/loca_table"
|
|
18
|
+
|
|
19
|
+
module Fontisan
|
|
20
|
+
# SFNT Offset Table structure
|
|
21
|
+
#
|
|
22
|
+
# Common structure for both TrueType and OpenType fonts.
|
|
23
|
+
class OffsetTable < BinData::Record
|
|
24
|
+
endian :big
|
|
25
|
+
uint32 :sfnt_version
|
|
26
|
+
uint16 :num_tables
|
|
27
|
+
uint16 :search_range
|
|
28
|
+
uint16 :entry_selector
|
|
29
|
+
uint16 :range_shift
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# SFNT Table Directory Entry structure
|
|
33
|
+
#
|
|
34
|
+
# Common structure for both TrueType and OpenType fonts.
|
|
35
|
+
class TableDirectory < BinData::Record
|
|
36
|
+
endian :big
|
|
37
|
+
string :tag, length: 4
|
|
38
|
+
uint32 :checksum
|
|
39
|
+
uint32 :offset
|
|
40
|
+
uint32 :table_length
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Base class for SFNT font formats (TrueType and OpenType)
|
|
44
|
+
#
|
|
45
|
+
# This class contains all shared SFNT structure and behavior.
|
|
46
|
+
# TrueType and OpenType fonts inherit from this class and add
|
|
47
|
+
# format-specific functionality.
|
|
48
|
+
#
|
|
49
|
+
# @abstract Subclasses must implement format-specific validation
|
|
50
|
+
#
|
|
51
|
+
# @example Reading a font (format detected automatically)
|
|
52
|
+
# font = Fontisan::FontLoader.load("font.ttf") # Returns TrueTypeFont
|
|
53
|
+
# font = Fontisan::FontLoader.load("font.otf") # Returns OpenTypeFont
|
|
54
|
+
#
|
|
55
|
+
# @example Reading and analyzing a font
|
|
56
|
+
# ttf = Fontisan::TrueTypeFont.from_file("font.ttf")
|
|
57
|
+
# puts ttf.header.num_tables # => 14
|
|
58
|
+
# name_table = ttf.table("name")
|
|
59
|
+
# puts name_table.english_name(Tables::Name::FAMILY)
|
|
60
|
+
#
|
|
61
|
+
# @example Loading with metadata mode
|
|
62
|
+
# ttf = Fontisan::TrueTypeFont.from_file("font.ttf", mode: :metadata)
|
|
63
|
+
# puts ttf.loading_mode # => :metadata
|
|
64
|
+
# ttf.table_available?("GSUB") # => false
|
|
65
|
+
#
|
|
66
|
+
# @example Writing a font
|
|
67
|
+
# ttf.to_file("output.ttf")
|
|
68
|
+
class SfntFont < BinData::Record
|
|
69
|
+
endian :big
|
|
70
|
+
|
|
71
|
+
offset_table :header
|
|
72
|
+
array :tables, type: :table_directory, initial_length: lambda {
|
|
73
|
+
header.num_tables
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
# Table data is stored separately since it's at variable offsets
|
|
77
|
+
attr_accessor :table_data
|
|
78
|
+
|
|
79
|
+
# Parsed table instances cache
|
|
80
|
+
attr_accessor :parsed_tables
|
|
81
|
+
|
|
82
|
+
# OOP SfntTable instances (tag => SfntTable)
|
|
83
|
+
attr_accessor :sfnt_tables
|
|
84
|
+
|
|
85
|
+
# Table entry lookup cache (tag => TableDirectory)
|
|
86
|
+
attr_accessor :table_entry_cache
|
|
87
|
+
|
|
88
|
+
# Loading mode for this font (:metadata or :full)
|
|
89
|
+
attr_accessor :loading_mode
|
|
90
|
+
|
|
91
|
+
# IO source for lazy loading
|
|
92
|
+
attr_accessor :io_source
|
|
93
|
+
|
|
94
|
+
# Whether lazy loading is enabled
|
|
95
|
+
attr_accessor :lazy_load_enabled
|
|
96
|
+
|
|
97
|
+
# Map table tag to parser class (cached as constant for performance)
|
|
98
|
+
#
|
|
99
|
+
# @return [Hash<String, Class>] Mapping of table tags to parser classes
|
|
100
|
+
TABLE_CLASS_MAP = {
|
|
101
|
+
Constants::HEAD_TAG => Tables::Head,
|
|
102
|
+
Constants::HHEA_TAG => Tables::Hhea,
|
|
103
|
+
Constants::HMTX_TAG => Tables::Hmtx,
|
|
104
|
+
Constants::MAXP_TAG => Tables::Maxp,
|
|
105
|
+
Constants::NAME_TAG => Tables::Name,
|
|
106
|
+
Constants::OS2_TAG => Tables::Os2,
|
|
107
|
+
Constants::POST_TAG => Tables::Post,
|
|
108
|
+
Constants::CMAP_TAG => Tables::Cmap,
|
|
109
|
+
Constants::FVAR_TAG => Tables::Fvar,
|
|
110
|
+
Constants::GSUB_TAG => Tables::Gsub,
|
|
111
|
+
Constants::GPOS_TAG => Tables::Gpos,
|
|
112
|
+
Constants::GLYF_TAG => Tables::Glyf,
|
|
113
|
+
Constants::LOCA_TAG => Tables::Loca,
|
|
114
|
+
"SVG " => Tables::Svg,
|
|
115
|
+
"COLR" => Tables::Colr,
|
|
116
|
+
"CPAL" => Tables::Cpal,
|
|
117
|
+
"CBDT" => Tables::Cbdt,
|
|
118
|
+
"CBLC" => Tables::Cblc,
|
|
119
|
+
"sbix" => Tables::Sbix,
|
|
120
|
+
}.freeze
|
|
121
|
+
|
|
122
|
+
# Map table tag to SfntTable wrapper class (cached as constant for performance)
|
|
123
|
+
#
|
|
124
|
+
# @return [Hash<String, Class>] Mapping of table tags to SfntTable wrapper classes
|
|
125
|
+
SFNT_TABLE_CLASS_MAP = {
|
|
126
|
+
Constants::HEAD_TAG => Tables::HeadTable,
|
|
127
|
+
Constants::NAME_TAG => Tables::NameTable,
|
|
128
|
+
Constants::OS2_TAG => Tables::Os2Table,
|
|
129
|
+
Constants::CMAP_TAG => Tables::CmapTable,
|
|
130
|
+
Constants::GLYF_TAG => Tables::GlyfTable,
|
|
131
|
+
Constants::HHEA_TAG => Tables::HheaTable,
|
|
132
|
+
Constants::MAXP_TAG => Tables::MaxpTable,
|
|
133
|
+
Constants::POST_TAG => Tables::PostTable,
|
|
134
|
+
Constants::HMTX_TAG => Tables::HmtxTable,
|
|
135
|
+
Constants::LOCA_TAG => Tables::LocaTable,
|
|
136
|
+
}.freeze
|
|
137
|
+
|
|
138
|
+
# Padding bytes for table alignment (frozen to avoid reallocation)
|
|
139
|
+
PADDING_BYTES = ("\x00" * 4).freeze
|
|
140
|
+
|
|
141
|
+
# Read SFNT Font from a file
|
|
142
|
+
#
|
|
143
|
+
# @param path [String] Path to the font file
|
|
144
|
+
# @param mode [Symbol] Loading mode (:metadata or :full, default: :full)
|
|
145
|
+
# @param lazy [Boolean] If true, load tables on demand (default: false)
|
|
146
|
+
# @return [SfntFont] A new instance
|
|
147
|
+
# @raise [ArgumentError] if path is nil or empty, or if mode is invalid
|
|
148
|
+
# @raise [Errno::ENOENT] if file does not exist
|
|
149
|
+
# @raise [RuntimeError] if file format is invalid
|
|
150
|
+
def self.from_file(path, mode: LoadingModes::FULL, lazy: false)
|
|
151
|
+
if path.nil? || path.to_s.empty?
|
|
152
|
+
raise ArgumentError,
|
|
153
|
+
"path cannot be nil or empty"
|
|
154
|
+
end
|
|
155
|
+
raise Errno::ENOENT, "File not found: #{path}" unless File.exist?(path)
|
|
156
|
+
|
|
157
|
+
# Validate mode
|
|
158
|
+
LoadingModes.validate_mode!(mode)
|
|
159
|
+
|
|
160
|
+
File.open(path, "rb") do |io|
|
|
161
|
+
font = read(io)
|
|
162
|
+
font.initialize_storage
|
|
163
|
+
font.loading_mode = mode
|
|
164
|
+
font.lazy_load_enabled = lazy
|
|
165
|
+
|
|
166
|
+
if lazy
|
|
167
|
+
# Keep file handle open for lazy loading
|
|
168
|
+
font.io_source = File.open(path, "rb")
|
|
169
|
+
font.setup_finalizer
|
|
170
|
+
else
|
|
171
|
+
# Read tables upfront
|
|
172
|
+
font.read_table_data(io)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
font
|
|
176
|
+
end
|
|
177
|
+
rescue BinData::ValidityError, EOFError => e
|
|
178
|
+
raise "Invalid font file: #{e.message}"
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Read SFNT Font from collection at specific offset
|
|
182
|
+
#
|
|
183
|
+
# @param io [IO] Open file handle
|
|
184
|
+
# @param offset [Integer] Byte offset to the font
|
|
185
|
+
# @param mode [Symbol] Loading mode (:metadata or :full, default: :full)
|
|
186
|
+
# @return [SfntFont] A new instance
|
|
187
|
+
def self.from_collection(io, offset, mode: LoadingModes::FULL)
|
|
188
|
+
LoadingModes.validate_mode!(mode)
|
|
189
|
+
|
|
190
|
+
io.seek(offset)
|
|
191
|
+
font = read(io)
|
|
192
|
+
font.initialize_storage
|
|
193
|
+
font.loading_mode = mode
|
|
194
|
+
font.read_table_data(io)
|
|
195
|
+
font
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Initialize storage hashes
|
|
199
|
+
#
|
|
200
|
+
# @return [void]
|
|
201
|
+
def initialize_storage
|
|
202
|
+
@table_data = {}
|
|
203
|
+
@parsed_tables = {}
|
|
204
|
+
@sfnt_tables = {}
|
|
205
|
+
@table_entry_cache = {}
|
|
206
|
+
@tag_encoding_cache = {} # Cache for normalized tag encodings
|
|
207
|
+
@table_names = nil # Cache for table names array
|
|
208
|
+
@loading_mode = LoadingModes::FULL
|
|
209
|
+
@lazy_load_enabled = false
|
|
210
|
+
@io_source = nil
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# Read table data for all tables in the font
|
|
214
|
+
#
|
|
215
|
+
# In metadata mode, only reads metadata tables. In full mode, reads all tables.
|
|
216
|
+
# In lazy load mode, doesn't read data upfront.
|
|
217
|
+
#
|
|
218
|
+
# @param io [IO] IO object to read from
|
|
219
|
+
# @return [void]
|
|
220
|
+
def read_table_data(io)
|
|
221
|
+
@table_data = {}
|
|
222
|
+
|
|
223
|
+
if @lazy_load_enabled
|
|
224
|
+
# Don't read data, just keep IO reference
|
|
225
|
+
@io_source = io
|
|
226
|
+
return
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
if @loading_mode == LoadingModes::METADATA
|
|
230
|
+
# Only read metadata tables for performance
|
|
231
|
+
# Use page-aware batched reading to maximize filesystem prefetching
|
|
232
|
+
read_metadata_tables_batched(io)
|
|
233
|
+
else
|
|
234
|
+
# Read all tables
|
|
235
|
+
tables.each do |entry|
|
|
236
|
+
io.seek(entry.offset)
|
|
237
|
+
# Normalize tag encoding for hash key consistency
|
|
238
|
+
tag_key = normalize_tag(entry.tag)
|
|
239
|
+
@table_data[tag_key] = io.read(entry.table_length)
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# Read metadata tables using page-aware batching
|
|
245
|
+
#
|
|
246
|
+
# Groups adjacent tables within page boundaries and reads them together
|
|
247
|
+
# to maximize filesystem prefetching and minimize random seeks.
|
|
248
|
+
#
|
|
249
|
+
# @param io [IO] Open file handle
|
|
250
|
+
# @return [void]
|
|
251
|
+
def read_metadata_tables_batched(io)
|
|
252
|
+
# Typical filesystem page size (4KB is common, but 8KB gives better prefetch window)
|
|
253
|
+
page_threshold = 8192
|
|
254
|
+
|
|
255
|
+
# Get metadata tables sorted by offset for sequential access
|
|
256
|
+
metadata_entries = tables.select { |entry| LoadingModes::METADATA_TABLES_SET.include?(entry.tag) }
|
|
257
|
+
metadata_entries.sort_by!(&:offset)
|
|
258
|
+
|
|
259
|
+
return if metadata_entries.empty?
|
|
260
|
+
|
|
261
|
+
# Group adjacent tables within page threshold for batched reading
|
|
262
|
+
i = 0
|
|
263
|
+
while i < metadata_entries.size
|
|
264
|
+
batch_start = metadata_entries[i]
|
|
265
|
+
batch_end = batch_start
|
|
266
|
+
batch_entries = [batch_start]
|
|
267
|
+
|
|
268
|
+
# Extend batch while next table is within page threshold
|
|
269
|
+
j = i + 1
|
|
270
|
+
while j < metadata_entries.size
|
|
271
|
+
next_entry = metadata_entries[j]
|
|
272
|
+
gap = next_entry.offset - (batch_end.offset + batch_end.table_length)
|
|
273
|
+
|
|
274
|
+
# If gap is small (within page threshold), include in batch
|
|
275
|
+
if gap <= page_threshold
|
|
276
|
+
batch_end = next_entry
|
|
277
|
+
batch_entries << next_entry
|
|
278
|
+
j += 1
|
|
279
|
+
else
|
|
280
|
+
break
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
# Read batch
|
|
285
|
+
if batch_entries.size == 1
|
|
286
|
+
# Single table, read normally
|
|
287
|
+
io.seek(batch_start.offset)
|
|
288
|
+
tag_key = normalize_tag(batch_start.tag)
|
|
289
|
+
@table_data[tag_key] = io.read(batch_start.table_length)
|
|
290
|
+
else
|
|
291
|
+
# Multiple tables, read contiguous segment
|
|
292
|
+
batch_offset = batch_start.offset
|
|
293
|
+
batch_length = (batch_end.offset + batch_end.table_length) - batch_start.offset
|
|
294
|
+
|
|
295
|
+
io.seek(batch_offset)
|
|
296
|
+
batch_data = io.read(batch_length)
|
|
297
|
+
|
|
298
|
+
# Extract individual tables from batch
|
|
299
|
+
batch_entries.each do |entry|
|
|
300
|
+
relative_offset = entry.offset - batch_offset
|
|
301
|
+
tag_key = normalize_tag(entry.tag)
|
|
302
|
+
@table_data[tag_key] =
|
|
303
|
+
batch_data[relative_offset, entry.table_length]
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
i = j
|
|
308
|
+
end
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
# Write SFNT Font to a file
|
|
312
|
+
#
|
|
313
|
+
# Writes the complete font structure to disk, including proper checksum
|
|
314
|
+
# calculation and table alignment.
|
|
315
|
+
#
|
|
316
|
+
# @param path [String] Path where the font file will be written
|
|
317
|
+
# @return [Integer] Number of bytes written
|
|
318
|
+
# @raise [IOError] if writing fails
|
|
319
|
+
def to_file(path)
|
|
320
|
+
File.open(path, "w+b") do |io|
|
|
321
|
+
# Write header and tables (directory)
|
|
322
|
+
write_structure(io)
|
|
323
|
+
|
|
324
|
+
# Write table data with updated offsets
|
|
325
|
+
write_table_data_with_offsets(io)
|
|
326
|
+
|
|
327
|
+
# Update checksum adjustment in head table BEFORE closing file
|
|
328
|
+
# This avoids Windows file locking issues when Tempfiles are used
|
|
329
|
+
head = head_table
|
|
330
|
+
update_checksum_adjustment_in_io(io, head.offset) if head
|
|
331
|
+
|
|
332
|
+
io.pos
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
File.size(path)
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
# Validate format correctness
|
|
339
|
+
#
|
|
340
|
+
# @return [Boolean] true if the font format is valid, false otherwise
|
|
341
|
+
def valid?
|
|
342
|
+
return false unless header
|
|
343
|
+
return false unless tables.respond_to?(:length)
|
|
344
|
+
return false unless @table_data.is_a?(Hash)
|
|
345
|
+
return false if tables.length != header.num_tables
|
|
346
|
+
return false unless head_table
|
|
347
|
+
|
|
348
|
+
true
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
# Check if font has a specific table (optimized with cache)
|
|
352
|
+
#
|
|
353
|
+
# @param tag [String] The table tag to check for
|
|
354
|
+
# @return [Boolean] true if table exists, false otherwise
|
|
355
|
+
def has_table?(tag)
|
|
356
|
+
!find_table_entry(tag).nil?
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
# Check if a table is available in the current loading mode
|
|
360
|
+
#
|
|
361
|
+
# @param tag [String] The table tag to check
|
|
362
|
+
# @return [Boolean] true if table is available in current mode
|
|
363
|
+
def table_available?(tag)
|
|
364
|
+
return false unless has_table?(tag)
|
|
365
|
+
|
|
366
|
+
LoadingModes.table_allowed?(@loading_mode, tag)
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
# Find a table entry by tag (cached for performance)
|
|
370
|
+
#
|
|
371
|
+
# @param tag [String] The table tag to find
|
|
372
|
+
# @return [TableDirectory, nil] The table entry or nil
|
|
373
|
+
def find_table_entry(tag)
|
|
374
|
+
return @table_entry_cache[tag] if @table_entry_cache.key?(tag)
|
|
375
|
+
|
|
376
|
+
entry = tables.find { |entry| entry.tag == tag }
|
|
377
|
+
@table_entry_cache[tag] = entry
|
|
378
|
+
entry
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
# Get the head table entry
|
|
382
|
+
#
|
|
383
|
+
# @return [TableDirectory, nil] The head table entry or nil
|
|
384
|
+
def head_table
|
|
385
|
+
find_table_entry(Constants::HEAD_TAG)
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
# Get list of all table tags (cached for performance)
|
|
389
|
+
#
|
|
390
|
+
# @return [Array<String>] Array of table tag strings
|
|
391
|
+
def table_names
|
|
392
|
+
@table_names ||= tables.map(&:tag)
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
# Get OOP SfntTable instance for a table
|
|
396
|
+
#
|
|
397
|
+
# Returns a SfntTable (or subclass) instance that encapsulates the table's
|
|
398
|
+
# metadata, lazy loading, parsing, and validation. This provides a more
|
|
399
|
+
# object-oriented interface than the separate TableDirectory/@table_data/@parsed_tables
|
|
400
|
+
# approach.
|
|
401
|
+
#
|
|
402
|
+
# @param tag [String] The table tag to retrieve
|
|
403
|
+
# @return [SfntTable, nil] SfntTable instance (or subclass like HeadTable), or nil if not found
|
|
404
|
+
#
|
|
405
|
+
# @example Using SfntTable for validation
|
|
406
|
+
# head = font.sfnt_table("head")
|
|
407
|
+
# head.validate! # Performs head-specific validation
|
|
408
|
+
# head.units_per_em # => 2048 (convenience method)
|
|
409
|
+
def sfnt_table(tag)
|
|
410
|
+
# Return cached instance if available (fast path)
|
|
411
|
+
cached = @sfnt_tables[tag]
|
|
412
|
+
return cached if cached
|
|
413
|
+
|
|
414
|
+
# Only check has_table? if not cached (avoids redundant lookup)
|
|
415
|
+
return nil unless has_table?(tag)
|
|
416
|
+
|
|
417
|
+
# Create and cache (find_table_entry is already cached internally)
|
|
418
|
+
@sfnt_tables[tag] = create_sfnt_table(tag)
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
# Get all SfntTable instances
|
|
422
|
+
#
|
|
423
|
+
# @return [Hash<String, SfntTable>] Hash mapping tag => SfntTable instance
|
|
424
|
+
def all_sfnt_tables
|
|
425
|
+
table_names.each_with_object({}) do |tag, hash|
|
|
426
|
+
hash[tag] = sfnt_table(tag)
|
|
427
|
+
end
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
# Get parsed table instance
|
|
431
|
+
#
|
|
432
|
+
# This method parses the raw table data into a structured table object
|
|
433
|
+
# and caches the result for subsequent calls. Enforces mode restrictions.
|
|
434
|
+
#
|
|
435
|
+
# @param tag [String] The table tag to retrieve
|
|
436
|
+
# @return [Tables::*, nil] Parsed table object or nil if not found
|
|
437
|
+
# @raise [ArgumentError] if table is not available in current loading mode
|
|
438
|
+
def table(tag)
|
|
439
|
+
# Check mode restrictions
|
|
440
|
+
unless table_available?(tag)
|
|
441
|
+
if has_table?(tag)
|
|
442
|
+
raise ArgumentError,
|
|
443
|
+
"Table '#{tag}' is not available in #{@loading_mode} mode. " \
|
|
444
|
+
"Available tables: #{LoadingModes.tables_for(@loading_mode).inspect}"
|
|
445
|
+
else
|
|
446
|
+
return nil
|
|
447
|
+
end
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
# Return cached if available (fast path)
|
|
451
|
+
return @parsed_tables[tag] if @parsed_tables.key?(tag)
|
|
452
|
+
|
|
453
|
+
# Lazy load table data if enabled
|
|
454
|
+
load_table_data(tag) if @lazy_load_enabled && !@table_data.key?(tag)
|
|
455
|
+
|
|
456
|
+
# Parse and cache
|
|
457
|
+
@parsed_tables[tag] ||= parse_table(tag)
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
# Get units per em from head table
|
|
461
|
+
#
|
|
462
|
+
# @return [Integer, nil] Units per em value
|
|
463
|
+
def units_per_em
|
|
464
|
+
head = table(Constants::HEAD_TAG)
|
|
465
|
+
head&.units_per_em
|
|
466
|
+
end
|
|
467
|
+
|
|
468
|
+
# Convenience methods for accessing common name table fields
|
|
469
|
+
# These are particularly useful in minimal mode
|
|
470
|
+
|
|
471
|
+
# Get font family name
|
|
472
|
+
#
|
|
473
|
+
# @return [String, nil] Family name or nil if not found
|
|
474
|
+
def family_name
|
|
475
|
+
name_table = table(Constants::NAME_TAG)
|
|
476
|
+
name_table&.english_name(Tables::Name::FAMILY)
|
|
477
|
+
end
|
|
478
|
+
|
|
479
|
+
# Get font subfamily name (e.g., Regular, Bold, Italic)
|
|
480
|
+
#
|
|
481
|
+
# @return [String, nil] Subfamily name or nil if not found
|
|
482
|
+
def subfamily_name
|
|
483
|
+
name_table = table(Constants::NAME_TAG)
|
|
484
|
+
name_table&.english_name(Tables::Name::SUBFAMILY)
|
|
485
|
+
end
|
|
486
|
+
|
|
487
|
+
# Get full font name
|
|
488
|
+
#
|
|
489
|
+
# @return [String, nil] Full name or nil if not found
|
|
490
|
+
def full_name
|
|
491
|
+
name_table = table(Constants::NAME_TAG)
|
|
492
|
+
name_table&.english_name(Tables::Name::FULL_NAME)
|
|
493
|
+
end
|
|
494
|
+
|
|
495
|
+
# Get PostScript name
|
|
496
|
+
#
|
|
497
|
+
# @return [String, nil] PostScript name or nil if not found
|
|
498
|
+
def post_script_name
|
|
499
|
+
name_table = table(Constants::NAME_TAG)
|
|
500
|
+
name_table&.english_name(Tables::Name::POSTSCRIPT_NAME)
|
|
501
|
+
end
|
|
502
|
+
|
|
503
|
+
# Get preferred family name
|
|
504
|
+
#
|
|
505
|
+
# @return [String, nil] Preferred family name or nil if not found
|
|
506
|
+
def preferred_family_name
|
|
507
|
+
name_table = table(Constants::NAME_TAG)
|
|
508
|
+
name_table&.english_name(Tables::Name::PREFERRED_FAMILY)
|
|
509
|
+
end
|
|
510
|
+
|
|
511
|
+
# Get preferred subfamily name
|
|
512
|
+
#
|
|
513
|
+
# @return [String, nil] Preferred subfamily name or nil if not found
|
|
514
|
+
def preferred_subfamily_name
|
|
515
|
+
name_table = table(Constants::NAME_TAG)
|
|
516
|
+
name_table&.english_name(Tables::Name::PREFERRED_SUBFAMILY)
|
|
517
|
+
end
|
|
518
|
+
|
|
519
|
+
# Close the IO source (for lazy loading)
|
|
520
|
+
#
|
|
521
|
+
# @return [void]
|
|
522
|
+
def close
|
|
523
|
+
@io_source&.close
|
|
524
|
+
@io_source = nil
|
|
525
|
+
end
|
|
526
|
+
|
|
527
|
+
# Setup finalizer for cleanup
|
|
528
|
+
#
|
|
529
|
+
# @return [void]
|
|
530
|
+
def setup_finalizer
|
|
531
|
+
ObjectSpace.define_finalizer(self, self.class.finalize(@io_source))
|
|
532
|
+
end
|
|
533
|
+
|
|
534
|
+
# Finalizer proc for closing IO
|
|
535
|
+
#
|
|
536
|
+
# @param io [IO] The IO object to close
|
|
537
|
+
# @return [Proc] The finalizer proc
|
|
538
|
+
def self.finalize(io)
|
|
539
|
+
proc { io&.close }
|
|
540
|
+
end
|
|
541
|
+
|
|
542
|
+
# Update checksum adjustment in head table using IO
|
|
543
|
+
#
|
|
544
|
+
# Calculates the checksum of the entire file and writes the
|
|
545
|
+
# adjustment value to the head table's checksumAdjustment field.
|
|
546
|
+
#
|
|
547
|
+
# @param io [IO] IO object to read from and write to
|
|
548
|
+
# @param head_offset [Integer] Offset to the head table
|
|
549
|
+
# @return [void]
|
|
550
|
+
def update_checksum_adjustment_in_io(io, head_offset)
|
|
551
|
+
io.rewind
|
|
552
|
+
checksum = Utilities::ChecksumCalculator.calculate_checksum_from_io(io)
|
|
553
|
+
adjustment = Utilities::ChecksumCalculator.calculate_adjustment(checksum)
|
|
554
|
+
io.seek(head_offset + 8)
|
|
555
|
+
io.write([adjustment].pack("N"))
|
|
556
|
+
end
|
|
557
|
+
|
|
558
|
+
# Update checksum adjustment in head table by file path
|
|
559
|
+
#
|
|
560
|
+
# Opens the file, calculates the checksum, and updates the adjustment.
|
|
561
|
+
#
|
|
562
|
+
# @param path [String] Path to the font file
|
|
563
|
+
# @param head_offset [Integer] Offset to the head table
|
|
564
|
+
# @return [void]
|
|
565
|
+
def update_checksum_adjustment_in_file(path, head_offset)
|
|
566
|
+
File.open(path, "r+b") do |io|
|
|
567
|
+
update_checksum_adjustment_in_io(io, head_offset)
|
|
568
|
+
end
|
|
569
|
+
end
|
|
570
|
+
|
|
571
|
+
private
|
|
572
|
+
|
|
573
|
+
# Normalize tag encoding to UTF-8 (cached for performance)
|
|
574
|
+
#
|
|
575
|
+
# @param tag [String] The tag to normalize
|
|
576
|
+
# @return [String] UTF-8 encoded tag
|
|
577
|
+
def normalize_tag(tag)
|
|
578
|
+
@tag_encoding_cache[tag] ||= tag.dup.force_encoding("UTF-8")
|
|
579
|
+
end
|
|
580
|
+
|
|
581
|
+
# Load a single table's data on demand
|
|
582
|
+
#
|
|
583
|
+
# Uses direct seek-and-read for minimal overhead. This ensures lazy loading
|
|
584
|
+
# performance is comparable to eager loading when accessing all tables.
|
|
585
|
+
#
|
|
586
|
+
# @param tag [String] The table tag to load
|
|
587
|
+
# @return [void]
|
|
588
|
+
def load_table_data(tag)
|
|
589
|
+
return unless @io_source
|
|
590
|
+
|
|
591
|
+
entry = find_table_entry(tag)
|
|
592
|
+
return nil unless entry
|
|
593
|
+
|
|
594
|
+
# Direct seek and read - same as eager loading but on-demand
|
|
595
|
+
@io_source.seek(entry.offset)
|
|
596
|
+
tag_key = normalize_tag(tag)
|
|
597
|
+
@table_data[tag_key] = @io_source.read(entry.table_length)
|
|
598
|
+
end
|
|
599
|
+
|
|
600
|
+
# Parse a table from raw data
|
|
601
|
+
#
|
|
602
|
+
# @param tag [String] The table tag to parse
|
|
603
|
+
# @return [Tables::*, nil] Parsed table object or nil
|
|
604
|
+
def parse_table(tag)
|
|
605
|
+
raw_data = @table_data[tag]
|
|
606
|
+
return nil unless raw_data
|
|
607
|
+
|
|
608
|
+
table_class = table_class_for(tag)
|
|
609
|
+
return nil unless table_class
|
|
610
|
+
|
|
611
|
+
table_class.read(raw_data)
|
|
612
|
+
end
|
|
613
|
+
|
|
614
|
+
# Map table tag to SfntTable wrapper class
|
|
615
|
+
#
|
|
616
|
+
# @param tag [String] The table tag
|
|
617
|
+
# @return [SfntTable, nil] SfntTable instance or nil
|
|
618
|
+
def create_sfnt_table(tag)
|
|
619
|
+
entry = find_table_entry(tag)
|
|
620
|
+
return nil unless entry
|
|
621
|
+
|
|
622
|
+
# Use hash lookup for O(1) dispatch instead of case statement
|
|
623
|
+
table_class = SFNT_TABLE_CLASS_MAP[tag] || SfntTable
|
|
624
|
+
table_class.new(self, entry)
|
|
625
|
+
end
|
|
626
|
+
|
|
627
|
+
# Map table tag to parser class
|
|
628
|
+
#
|
|
629
|
+
# @param tag [String] The table tag
|
|
630
|
+
# @return [Class, nil] Table parser class or nil
|
|
631
|
+
def table_class_for(tag)
|
|
632
|
+
TABLE_CLASS_MAP[tag]
|
|
633
|
+
end
|
|
634
|
+
|
|
635
|
+
# Write the structure (header + table directory) to IO
|
|
636
|
+
#
|
|
637
|
+
# @param io [IO] Open file handle
|
|
638
|
+
# @return [void]
|
|
639
|
+
def write_structure(io)
|
|
640
|
+
# Write header
|
|
641
|
+
header.write(io)
|
|
642
|
+
|
|
643
|
+
# Write table directory with placeholder offsets
|
|
644
|
+
tables.each do |entry|
|
|
645
|
+
io.write(entry.tag)
|
|
646
|
+
io.write([entry.checksum].pack("N"))
|
|
647
|
+
io.write([0].pack("N")) # Placeholder offset
|
|
648
|
+
io.write([entry.table_length].pack("N"))
|
|
649
|
+
end
|
|
650
|
+
end
|
|
651
|
+
|
|
652
|
+
# Write table data and update offsets in directory
|
|
653
|
+
#
|
|
654
|
+
# @param io [IO] Open file handle
|
|
655
|
+
# @return [void]
|
|
656
|
+
def write_table_data_with_offsets(io)
|
|
657
|
+
tables.each_with_index do |entry, index|
|
|
658
|
+
# Record current position
|
|
659
|
+
current_position = io.pos
|
|
660
|
+
|
|
661
|
+
# Write table data
|
|
662
|
+
data = @table_data[entry.tag]
|
|
663
|
+
raise IOError, "Missing table data for tag '#{entry.tag}'" if data.nil?
|
|
664
|
+
|
|
665
|
+
io.write(data)
|
|
666
|
+
|
|
667
|
+
# Add padding to align to 4-byte boundary
|
|
668
|
+
padding = (Constants::TABLE_ALIGNMENT - (io.pos % Constants::TABLE_ALIGNMENT)) % Constants::TABLE_ALIGNMENT
|
|
669
|
+
io.write(PADDING_BYTES[0, padding]) if padding.positive?
|
|
670
|
+
|
|
671
|
+
# Zero out checksumAdjustment field in head table
|
|
672
|
+
if entry.tag == Constants::HEAD_TAG
|
|
673
|
+
current_pos = io.pos
|
|
674
|
+
io.seek(current_position + 8)
|
|
675
|
+
io.write([0].pack("N"))
|
|
676
|
+
io.seek(current_pos)
|
|
677
|
+
end
|
|
678
|
+
|
|
679
|
+
# Update offset in table directory
|
|
680
|
+
# Table directory starts at byte 12, each entry is 16 bytes
|
|
681
|
+
# Offset field is at byte 8 within each entry
|
|
682
|
+
directory_offset_position = 12 + (index * 16) + 8
|
|
683
|
+
current_pos = io.pos
|
|
684
|
+
io.seek(directory_offset_position)
|
|
685
|
+
io.write([current_position].pack("N")) # Offset is now known
|
|
686
|
+
io.seek(current_pos)
|
|
687
|
+
end
|
|
688
|
+
end
|
|
689
|
+
end
|
|
690
|
+
end
|