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,94 @@
|
|
|
1
|
+
= WOFF and WOFF2 Font Format Support
|
|
2
|
+
|
|
3
|
+
== General
|
|
4
|
+
|
|
5
|
+
Fontisan provides comprehensive support for Web Open Font Format (WOFF) and WOFF2,
|
|
6
|
+
including reading, writing, and conversion between these and other font formats.
|
|
7
|
+
|
|
8
|
+
WOFF is a wrapper around SFNT fonts (TTF/OTF) using zlib compression, while WOFF2
|
|
9
|
+
uses Brotli compression for better compression ratios and table transformations.
|
|
10
|
+
|
|
11
|
+
=== Display WOFF/WOFF2 font information
|
|
12
|
+
|
|
13
|
+
Show metadata specific to WOFF/WOFF2 fonts including version, compression details,
|
|
14
|
+
and original font flavor.
|
|
15
|
+
|
|
16
|
+
[source,shell]
|
|
17
|
+
----
|
|
18
|
+
$ fontisan info FONT.woff [--format FORMAT]
|
|
19
|
+
----
|
|
20
|
+
|
|
21
|
+
Where,
|
|
22
|
+
|
|
23
|
+
`FONT.woff`:: Path to the WOFF or WOFF2 file
|
|
24
|
+
`FORMAT`:: Output format: `text` (default), `json`, or `yaml`
|
|
25
|
+
|
|
26
|
+
.Major version from WOFF/WOFF2 header
|
|
27
|
+
[example]
|
|
28
|
+
====
|
|
29
|
+
[source,shell]
|
|
30
|
+
----
|
|
31
|
+
$ fontisan info /path/to/your/font.woff
|
|
32
|
+
|
|
33
|
+
Font type: WOFF (TrueType)
|
|
34
|
+
Family: Example Font
|
|
35
|
+
WOFF Major Version: 1
|
|
36
|
+
WOFF Minor Version: 0
|
|
37
|
+
WOFF Metadata: 0 entries
|
|
38
|
+
WOFF Private Data: 0 bytes
|
|
39
|
+
Original Font Flavor: TrueType
|
|
40
|
+
----
|
|
41
|
+
====
|
|
42
|
+
|
|
43
|
+
=== WOFF2 validation
|
|
44
|
+
|
|
45
|
+
Fontisan validates WOFF2 fonts according to the specification, checking:
|
|
46
|
+
|
|
47
|
+
* Required table presence and order
|
|
48
|
+
* Table transformation flags (glyf, loca transformations)
|
|
49
|
+
* Checksum validation for transformed tables
|
|
50
|
+
* Compression format validation
|
|
51
|
+
|
|
52
|
+
.Validation example
|
|
53
|
+
[example]
|
|
54
|
+
====
|
|
55
|
+
[source,shell]
|
|
56
|
+
----
|
|
57
|
+
$ fontisan validate font.woff2 -t web
|
|
58
|
+
|
|
59
|
+
CHECK_ID | STATUS | SEVERITY
|
|
60
|
+
--------------------------------------------------------------
|
|
61
|
+
woff2_required_tables | PASS | error
|
|
62
|
+
woff2_table_transformations | PASS | error
|
|
63
|
+
woff2_checksum_validation | PASS | error
|
|
64
|
+
----
|
|
65
|
+
====
|
|
66
|
+
|
|
67
|
+
=== Convert fonts to WOFF/WOFF2
|
|
68
|
+
|
|
69
|
+
Convert TTF/OTF fonts to web-optimized formats.
|
|
70
|
+
|
|
71
|
+
[source,shell]
|
|
72
|
+
----
|
|
73
|
+
$ fontisan convert INPUT.ttf --to woff --output output.woff
|
|
74
|
+
$ fontisan convert INPUT.ttf --to woff2 --output output.woff2
|
|
75
|
+
----
|
|
76
|
+
|
|
77
|
+
.Conversion with optimization
|
|
78
|
+
[example]
|
|
79
|
+
====
|
|
80
|
+
[source,shell]
|
|
81
|
+
----
|
|
82
|
+
$ fontisan convert font.ttf --to woff2 --output font.woff2
|
|
83
|
+
|
|
84
|
+
Converting font.ttf to WOFF2...
|
|
85
|
+
Input: 245.8 KB (TTF)
|
|
86
|
+
Output: 89.2 KB (WOFF2)
|
|
87
|
+
Ratio: 63.7% reduction
|
|
88
|
+
|
|
89
|
+
Compression details:
|
|
90
|
+
Tables: 12
|
|
91
|
+
Brotli compression: Applied
|
|
92
|
+
Transformed tables: glyf, loca
|
|
93
|
+
----
|
|
94
|
+
====
|
|
@@ -1,15 +1,13 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
require_relative "constants"
|
|
5
|
-
require_relative "loading_modes"
|
|
6
|
-
require_relative "utilities/checksum_calculator"
|
|
3
|
+
require_relative "sfnt_font"
|
|
7
4
|
|
|
8
5
|
module Fontisan
|
|
9
|
-
# OpenType Font domain object
|
|
6
|
+
# OpenType Font domain object
|
|
10
7
|
#
|
|
11
|
-
# Represents
|
|
12
|
-
#
|
|
8
|
+
# Represents an OpenType Font file (CFF outlines). Inherits all shared
|
|
9
|
+
# SFNT functionality from SfntFont and adds OpenType-specific behavior
|
|
10
|
+
# including page-aligned lazy loading for optimal performance.
|
|
13
11
|
#
|
|
14
12
|
# @example Reading and analyzing a font
|
|
15
13
|
# otf = OpenTypeFont.from_file("font.otf")
|
|
@@ -24,29 +22,10 @@ module Fontisan
|
|
|
24
22
|
#
|
|
25
23
|
# @example Writing a font
|
|
26
24
|
# otf.to_file("output.otf")
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
array :tables, type: :table_directory, initial_length: lambda {
|
|
32
|
-
header.num_tables
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
# Table data is stored separately since it's at variable offsets
|
|
36
|
-
attr_accessor :table_data
|
|
37
|
-
|
|
38
|
-
# Parsed table instances cache
|
|
39
|
-
attr_accessor :parsed_tables
|
|
40
|
-
|
|
41
|
-
# Loading mode for this font (:metadata or :full)
|
|
42
|
-
attr_accessor :loading_mode
|
|
43
|
-
|
|
44
|
-
# IO source for lazy loading
|
|
45
|
-
attr_accessor :io_source
|
|
46
|
-
|
|
47
|
-
# Whether lazy loading is enabled
|
|
48
|
-
attr_accessor :lazy_load_enabled
|
|
49
|
-
|
|
25
|
+
#
|
|
26
|
+
# @example Reading from TTC collection
|
|
27
|
+
# otf = OpenTypeFont.from_collection(io, offset)
|
|
28
|
+
class OpenTypeFont < SfntFont
|
|
50
29
|
# Page cache for lazy loading (maps page_start_offset => page_data)
|
|
51
30
|
attr_accessor :page_cache
|
|
52
31
|
|
|
@@ -57,7 +36,7 @@ module Fontisan
|
|
|
57
36
|
#
|
|
58
37
|
# @param path [String] Path to the OTF file
|
|
59
38
|
# @param mode [Symbol] Loading mode (:metadata or :full, default: :full)
|
|
60
|
-
# @param lazy [Boolean] If true, load tables on demand (default: false
|
|
39
|
+
# @param lazy [Boolean] If true, load tables on demand (default: false)
|
|
61
40
|
# @return [OpenTypeFont] A new instance
|
|
62
41
|
# @raise [ArgumentError] if path is nil or empty, or if mode is invalid
|
|
63
42
|
# @raise [Errno::ENOENT] if file does not exist
|
|
@@ -93,167 +72,23 @@ module Fontisan
|
|
|
93
72
|
raise "Invalid OTF file: #{e.message}"
|
|
94
73
|
end
|
|
95
74
|
|
|
96
|
-
# Read OpenType Font from collection at specific offset
|
|
97
|
-
#
|
|
98
|
-
# @param io [IO] Open file handle
|
|
99
|
-
# @param offset [Integer] Byte offset to the font
|
|
100
|
-
# @param mode [Symbol] Loading mode (:metadata or :full, default: :full)
|
|
101
|
-
# @return [OpenTypeFont] A new instance
|
|
102
|
-
def self.from_collection(io, offset, mode: LoadingModes::FULL)
|
|
103
|
-
LoadingModes.validate_mode!(mode)
|
|
104
|
-
|
|
105
|
-
io.seek(offset)
|
|
106
|
-
font = read(io)
|
|
107
|
-
font.initialize_storage
|
|
108
|
-
font.loading_mode = mode
|
|
109
|
-
font.read_table_data(io)
|
|
110
|
-
font
|
|
111
|
-
end
|
|
112
|
-
|
|
113
75
|
# Initialize storage hashes
|
|
114
76
|
#
|
|
77
|
+
# Extends base class to add page_cache for lazy loading.
|
|
78
|
+
#
|
|
115
79
|
# @return [void]
|
|
116
80
|
def initialize_storage
|
|
117
|
-
|
|
118
|
-
@parsed_tables = {}
|
|
119
|
-
@loading_mode = LoadingModes::FULL
|
|
120
|
-
@lazy_load_enabled = false
|
|
121
|
-
@io_source = nil
|
|
81
|
+
super
|
|
122
82
|
@page_cache = {}
|
|
123
83
|
end
|
|
124
84
|
|
|
125
|
-
# Read table data for all tables
|
|
126
|
-
#
|
|
127
|
-
# In metadata mode, only reads metadata tables. In full mode, reads all tables.
|
|
128
|
-
# In lazy load mode, doesn't read data upfront.
|
|
129
|
-
#
|
|
130
|
-
# @param io [IO] Open file handle
|
|
131
|
-
# @return [void]
|
|
132
|
-
def read_table_data(io)
|
|
133
|
-
@table_data = {}
|
|
134
|
-
|
|
135
|
-
if @lazy_load_enabled
|
|
136
|
-
# Don't read data, just keep IO reference
|
|
137
|
-
@io_source = io
|
|
138
|
-
return
|
|
139
|
-
end
|
|
140
|
-
|
|
141
|
-
if @loading_mode == LoadingModes::METADATA
|
|
142
|
-
# Only read metadata tables for performance
|
|
143
|
-
# Use page-aware batched reading to maximize filesystem prefetching
|
|
144
|
-
read_metadata_tables_batched(io)
|
|
145
|
-
else
|
|
146
|
-
# Read all tables
|
|
147
|
-
tables.each do |entry|
|
|
148
|
-
io.seek(entry.offset)
|
|
149
|
-
# Force UTF-8 encoding on tag for hash key consistency
|
|
150
|
-
tag_key = entry.tag.dup.force_encoding("UTF-8")
|
|
151
|
-
@table_data[tag_key] = io.read(entry.table_length)
|
|
152
|
-
end
|
|
153
|
-
end
|
|
154
|
-
end
|
|
155
|
-
|
|
156
|
-
# Read metadata tables using page-aware batching
|
|
157
|
-
#
|
|
158
|
-
# Groups adjacent tables within page boundaries and reads them together
|
|
159
|
-
# to maximize filesystem prefetching and minimize random seeks.
|
|
160
|
-
#
|
|
161
|
-
# @param io [IO] Open file handle
|
|
162
|
-
# @return [void]
|
|
163
|
-
def read_metadata_tables_batched(io)
|
|
164
|
-
# Typical filesystem page size (4KB is common, but 8KB gives better prefetch window)
|
|
165
|
-
page_threshold = 8192
|
|
166
|
-
|
|
167
|
-
# Get metadata tables sorted by offset for sequential access
|
|
168
|
-
metadata_entries = tables.select { |entry| LoadingModes::METADATA_TABLES_SET.include?(entry.tag) }
|
|
169
|
-
metadata_entries.sort_by!(&:offset)
|
|
170
|
-
|
|
171
|
-
return if metadata_entries.empty?
|
|
172
|
-
|
|
173
|
-
# Group adjacent tables within page threshold for batched reading
|
|
174
|
-
i = 0
|
|
175
|
-
while i < metadata_entries.size
|
|
176
|
-
batch_start = metadata_entries[i]
|
|
177
|
-
batch_end = batch_start
|
|
178
|
-
batch_entries = [batch_start]
|
|
179
|
-
|
|
180
|
-
# Extend batch while next table is within page threshold
|
|
181
|
-
j = i + 1
|
|
182
|
-
while j < metadata_entries.size
|
|
183
|
-
next_entry = metadata_entries[j]
|
|
184
|
-
gap = next_entry.offset - (batch_end.offset + batch_end.table_length)
|
|
185
|
-
|
|
186
|
-
# If gap is small (within page threshold), include in batch
|
|
187
|
-
if gap <= page_threshold
|
|
188
|
-
batch_end = next_entry
|
|
189
|
-
batch_entries << next_entry
|
|
190
|
-
j += 1
|
|
191
|
-
else
|
|
192
|
-
break
|
|
193
|
-
end
|
|
194
|
-
end
|
|
195
|
-
|
|
196
|
-
# Read batch
|
|
197
|
-
if batch_entries.size == 1
|
|
198
|
-
# Single table, read normally
|
|
199
|
-
io.seek(batch_start.offset)
|
|
200
|
-
tag_key = batch_start.tag.dup.force_encoding("UTF-8")
|
|
201
|
-
@table_data[tag_key] = io.read(batch_start.table_length)
|
|
202
|
-
else
|
|
203
|
-
# Multiple tables, read contiguous segment
|
|
204
|
-
batch_offset = batch_start.offset
|
|
205
|
-
batch_length = (batch_end.offset + batch_end.table_length) - batch_start.offset
|
|
206
|
-
|
|
207
|
-
io.seek(batch_offset)
|
|
208
|
-
batch_data = io.read(batch_length)
|
|
209
|
-
|
|
210
|
-
# Extract individual tables from batch
|
|
211
|
-
batch_entries.each do |entry|
|
|
212
|
-
relative_offset = entry.offset - batch_offset
|
|
213
|
-
tag_key = entry.tag.dup.force_encoding("UTF-8")
|
|
214
|
-
@table_data[tag_key] =
|
|
215
|
-
batch_data[relative_offset, entry.table_length]
|
|
216
|
-
end
|
|
217
|
-
end
|
|
218
|
-
|
|
219
|
-
i = j
|
|
220
|
-
end
|
|
221
|
-
end
|
|
222
|
-
|
|
223
|
-
# Write OpenType Font to a file
|
|
224
|
-
#
|
|
225
|
-
# Writes the complete OTF structure to disk, including proper checksum
|
|
226
|
-
# calculation and table alignment.
|
|
227
|
-
#
|
|
228
|
-
# @param path [String] Path where the OTF file will be written
|
|
229
|
-
# @return [Integer] Number of bytes written
|
|
230
|
-
# @raise [IOError] if writing fails
|
|
231
|
-
def to_file(path)
|
|
232
|
-
File.open(path, "wb") do |io|
|
|
233
|
-
# Write header and tables (directory)
|
|
234
|
-
write_structure(io)
|
|
235
|
-
|
|
236
|
-
# Write table data with updated offsets
|
|
237
|
-
write_table_data_with_offsets(io)
|
|
238
|
-
|
|
239
|
-
io.pos
|
|
240
|
-
end
|
|
241
|
-
|
|
242
|
-
# Update checksum adjustment in head table
|
|
243
|
-
update_checksum_adjustment_in_file(path) if head_table
|
|
244
|
-
|
|
245
|
-
File.size(path)
|
|
246
|
-
end
|
|
247
|
-
|
|
248
85
|
# Validate format correctness
|
|
249
86
|
#
|
|
87
|
+
# Extends base class to check for CFF table (OpenType-specific).
|
|
88
|
+
#
|
|
250
89
|
# @return [Boolean] true if the OTF format is valid, false otherwise
|
|
251
90
|
def valid?
|
|
252
|
-
return false unless
|
|
253
|
-
return false unless tables.respond_to?(:length)
|
|
254
|
-
return false unless @table_data.is_a?(Hash)
|
|
255
|
-
return false if tables.length != header.num_tables
|
|
256
|
-
return false unless head_table
|
|
91
|
+
return false unless super
|
|
257
92
|
return false unless has_table?(Constants::CFF_TAG)
|
|
258
93
|
|
|
259
94
|
true
|
|
@@ -273,158 +108,6 @@ module Fontisan
|
|
|
273
108
|
true
|
|
274
109
|
end
|
|
275
110
|
|
|
276
|
-
# Check if font has a specific table
|
|
277
|
-
#
|
|
278
|
-
# @param tag [String] The table tag to check for
|
|
279
|
-
# @return [Boolean] true if table exists, false otherwise
|
|
280
|
-
def has_table?(tag)
|
|
281
|
-
tables.any? { |entry| entry.tag == tag }
|
|
282
|
-
end
|
|
283
|
-
|
|
284
|
-
# Check if a table is available in the current loading mode
|
|
285
|
-
#
|
|
286
|
-
# @param tag [String] The table tag to check
|
|
287
|
-
# @return [Boolean] true if table is available in current mode
|
|
288
|
-
def table_available?(tag)
|
|
289
|
-
return false unless has_table?(tag)
|
|
290
|
-
|
|
291
|
-
LoadingModes.table_allowed?(@loading_mode, tag)
|
|
292
|
-
end
|
|
293
|
-
|
|
294
|
-
# Find a table entry by tag
|
|
295
|
-
#
|
|
296
|
-
# @param tag [String] The table tag to find
|
|
297
|
-
# @return [TableDirectory, nil] The table entry or nil
|
|
298
|
-
def find_table_entry(tag)
|
|
299
|
-
tables.find { |entry| entry.tag == tag }
|
|
300
|
-
end
|
|
301
|
-
|
|
302
|
-
# Get the head table entry
|
|
303
|
-
#
|
|
304
|
-
# @return [TableDirectory, nil] The head table entry or nil
|
|
305
|
-
def head_table
|
|
306
|
-
find_table_entry(Constants::HEAD_TAG)
|
|
307
|
-
end
|
|
308
|
-
|
|
309
|
-
# Get list of all table tags
|
|
310
|
-
#
|
|
311
|
-
# @return [Array<String>] Array of table tag strings
|
|
312
|
-
def table_names
|
|
313
|
-
tables.map(&:tag)
|
|
314
|
-
end
|
|
315
|
-
|
|
316
|
-
# Get parsed table instance
|
|
317
|
-
#
|
|
318
|
-
# This method parses the raw table data into a structured table object
|
|
319
|
-
# and caches the result for subsequent calls. Enforces mode restrictions.
|
|
320
|
-
#
|
|
321
|
-
# @param tag [String] The table tag to retrieve
|
|
322
|
-
# @return [Tables::*, nil] Parsed table object or nil if not found
|
|
323
|
-
# @raise [ArgumentError] if table is not available in current loading mode
|
|
324
|
-
def table(tag)
|
|
325
|
-
# Check mode restrictions
|
|
326
|
-
unless table_available?(tag)
|
|
327
|
-
if has_table?(tag)
|
|
328
|
-
raise ArgumentError,
|
|
329
|
-
"Table '#{tag}' is not available in #{@loading_mode} mode. " \
|
|
330
|
-
"Available tables: #{LoadingModes.tables_for(@loading_mode).inspect}"
|
|
331
|
-
else
|
|
332
|
-
return nil
|
|
333
|
-
end
|
|
334
|
-
end
|
|
335
|
-
|
|
336
|
-
return @parsed_tables[tag] if @parsed_tables.key?(tag)
|
|
337
|
-
|
|
338
|
-
# Lazy load table data if enabled
|
|
339
|
-
if @lazy_load_enabled && !@table_data.key?(tag)
|
|
340
|
-
load_table_data(tag)
|
|
341
|
-
end
|
|
342
|
-
|
|
343
|
-
@parsed_tables[tag] ||= parse_table(tag)
|
|
344
|
-
end
|
|
345
|
-
|
|
346
|
-
# Get units per em from head table
|
|
347
|
-
#
|
|
348
|
-
# @return [Integer, nil] Units per em value
|
|
349
|
-
def units_per_em
|
|
350
|
-
head = table(Constants::HEAD_TAG)
|
|
351
|
-
head&.units_per_em
|
|
352
|
-
end
|
|
353
|
-
|
|
354
|
-
# Convenience methods for accessing common name table fields
|
|
355
|
-
# These are particularly useful in minimal mode
|
|
356
|
-
|
|
357
|
-
# Get font family name
|
|
358
|
-
#
|
|
359
|
-
# @return [String, nil] Family name or nil if not found
|
|
360
|
-
def family_name
|
|
361
|
-
name_table = table(Constants::NAME_TAG)
|
|
362
|
-
name_table&.english_name(Tables::Name::FAMILY)
|
|
363
|
-
end
|
|
364
|
-
|
|
365
|
-
# Get font subfamily name (e.g., Regular, Bold, Italic)
|
|
366
|
-
#
|
|
367
|
-
# @return [String, nil] Subfamily name or nil if not found
|
|
368
|
-
def subfamily_name
|
|
369
|
-
name_table = table(Constants::NAME_TAG)
|
|
370
|
-
name_table&.english_name(Tables::Name::SUBFAMILY)
|
|
371
|
-
end
|
|
372
|
-
|
|
373
|
-
# Get full font name
|
|
374
|
-
#
|
|
375
|
-
# @return [String, nil] Full name or nil if not found
|
|
376
|
-
def full_name
|
|
377
|
-
name_table = table(Constants::NAME_TAG)
|
|
378
|
-
name_table&.english_name(Tables::Name::FULL_NAME)
|
|
379
|
-
end
|
|
380
|
-
|
|
381
|
-
# Get PostScript name
|
|
382
|
-
#
|
|
383
|
-
# @return [String, nil] PostScript name or nil if not found
|
|
384
|
-
def post_script_name
|
|
385
|
-
name_table = table(Constants::NAME_TAG)
|
|
386
|
-
name_table&.english_name(Tables::Name::POSTSCRIPT_NAME)
|
|
387
|
-
end
|
|
388
|
-
|
|
389
|
-
# Get preferred family name
|
|
390
|
-
#
|
|
391
|
-
# @return [String, nil] Preferred family name or nil if not found
|
|
392
|
-
def preferred_family_name
|
|
393
|
-
name_table = table(Constants::NAME_TAG)
|
|
394
|
-
name_table&.english_name(Tables::Name::PREFERRED_FAMILY)
|
|
395
|
-
end
|
|
396
|
-
|
|
397
|
-
# Get preferred subfamily name
|
|
398
|
-
#
|
|
399
|
-
# @return [String, nil] Preferred subfamily name or nil if not found
|
|
400
|
-
def preferred_subfamily_name
|
|
401
|
-
name_table = table(Constants::NAME_TAG)
|
|
402
|
-
name_table&.english_name(Tables::Name::PREFERRED_SUBFAMILY)
|
|
403
|
-
end
|
|
404
|
-
|
|
405
|
-
# Close the IO source (for lazy loading)
|
|
406
|
-
#
|
|
407
|
-
# @return [void]
|
|
408
|
-
def close
|
|
409
|
-
@io_source&.close
|
|
410
|
-
@io_source = nil
|
|
411
|
-
end
|
|
412
|
-
|
|
413
|
-
# Setup finalizer for cleanup
|
|
414
|
-
#
|
|
415
|
-
# @return [void]
|
|
416
|
-
def setup_finalizer
|
|
417
|
-
ObjectSpace.define_finalizer(self, self.class.finalize(@io_source))
|
|
418
|
-
end
|
|
419
|
-
|
|
420
|
-
# Finalizer proc for closing IO
|
|
421
|
-
#
|
|
422
|
-
# @param io [IO] The IO object to close
|
|
423
|
-
# @return [Proc] The finalizer proc
|
|
424
|
-
def self.finalize(io)
|
|
425
|
-
proc { io&.close }
|
|
426
|
-
end
|
|
427
|
-
|
|
428
111
|
private
|
|
429
112
|
|
|
430
113
|
# Load a single table's data on demand
|
|
@@ -478,22 +161,10 @@ module Fontisan
|
|
|
478
161
|
@table_data[tag_key] = table_data_parts.join
|
|
479
162
|
end
|
|
480
163
|
|
|
481
|
-
# Parse a table from raw data
|
|
482
|
-
#
|
|
483
|
-
# @param tag [String] The table tag to parse
|
|
484
|
-
# @return [Tables::*, nil] Parsed table object or nil
|
|
485
|
-
def parse_table(tag)
|
|
486
|
-
raw_data = @table_data[tag]
|
|
487
|
-
return nil unless raw_data
|
|
488
|
-
|
|
489
|
-
table_class = table_class_for(tag)
|
|
490
|
-
return nil unless table_class
|
|
491
|
-
|
|
492
|
-
table_class.read(raw_data)
|
|
493
|
-
end
|
|
494
|
-
|
|
495
164
|
# Map table tag to parser class
|
|
496
165
|
#
|
|
166
|
+
# OpenType-specific mapping includes CFF table.
|
|
167
|
+
#
|
|
497
168
|
# @param tag [String] The table tag
|
|
498
169
|
# @return [Class, nil] Table parser class or nil
|
|
499
170
|
def table_class_for(tag)
|
|
@@ -520,82 +191,5 @@ module Fontisan
|
|
|
520
191
|
"sbix" => Tables::Sbix,
|
|
521
192
|
}[tag]
|
|
522
193
|
end
|
|
523
|
-
|
|
524
|
-
# Write the structure (header + table directory) to IO
|
|
525
|
-
#
|
|
526
|
-
# @param io [IO] Open file handle
|
|
527
|
-
# @return [void]
|
|
528
|
-
def write_structure(io)
|
|
529
|
-
# Write header
|
|
530
|
-
header.write(io)
|
|
531
|
-
|
|
532
|
-
# Write table directory with placeholder offsets
|
|
533
|
-
tables.each do |entry|
|
|
534
|
-
io.write(entry.tag)
|
|
535
|
-
io.write([entry.checksum].pack("N"))
|
|
536
|
-
io.write([0].pack("N")) # Placeholder offset
|
|
537
|
-
io.write([entry.table_length].pack("N"))
|
|
538
|
-
end
|
|
539
|
-
end
|
|
540
|
-
|
|
541
|
-
# Write table data and update offsets in directory
|
|
542
|
-
#
|
|
543
|
-
# @param io [IO] Open file handle
|
|
544
|
-
# @return [void]
|
|
545
|
-
def write_table_data_with_offsets(io)
|
|
546
|
-
tables.each_with_index do |entry, index|
|
|
547
|
-
# Record current position
|
|
548
|
-
current_position = io.pos
|
|
549
|
-
|
|
550
|
-
# Write table data
|
|
551
|
-
data = @table_data[entry.tag]
|
|
552
|
-
raise IOError, "Missing table data for tag '#{entry.tag}'" if data.nil?
|
|
553
|
-
|
|
554
|
-
io.write(data)
|
|
555
|
-
|
|
556
|
-
# Add padding to align to 4-byte boundary
|
|
557
|
-
padding = (Constants::TABLE_ALIGNMENT - (io.pos % Constants::TABLE_ALIGNMENT)) % Constants::TABLE_ALIGNMENT
|
|
558
|
-
io.write("\x00" * padding) if padding.positive?
|
|
559
|
-
|
|
560
|
-
# Zero out checksumAdjustment field in head table
|
|
561
|
-
if entry.tag == Constants::HEAD_TAG
|
|
562
|
-
current_pos = io.pos
|
|
563
|
-
io.seek(current_position + 8)
|
|
564
|
-
io.write([0].pack("N"))
|
|
565
|
-
io.seek(current_pos)
|
|
566
|
-
end
|
|
567
|
-
|
|
568
|
-
# Update offset in table directory
|
|
569
|
-
# Table directory starts at byte 12, each entry is 16 bytes
|
|
570
|
-
# Offset field is at byte 8 within each entry
|
|
571
|
-
directory_offset_position = 12 + (index * 16) + 8
|
|
572
|
-
current_pos = io.pos
|
|
573
|
-
io.seek(directory_offset_position)
|
|
574
|
-
io.write([current_position].pack("N"))
|
|
575
|
-
io.seek(current_pos)
|
|
576
|
-
end
|
|
577
|
-
end
|
|
578
|
-
|
|
579
|
-
# Update checksumAdjustment field in head table
|
|
580
|
-
#
|
|
581
|
-
# @param path [String] Path to the OTF file
|
|
582
|
-
# @return [void]
|
|
583
|
-
def update_checksum_adjustment_in_file(path)
|
|
584
|
-
File.open(path, "r+b") do |io|
|
|
585
|
-
# Calculate checksum directly from IO to avoid Windows Tempfile issues
|
|
586
|
-
checksum = Utilities::ChecksumCalculator.calculate_checksum_from_io(io)
|
|
587
|
-
|
|
588
|
-
# Calculate adjustment
|
|
589
|
-
adjustment = Utilities::ChecksumCalculator.calculate_adjustment(checksum)
|
|
590
|
-
|
|
591
|
-
# Find head table position
|
|
592
|
-
head_entry = head_table
|
|
593
|
-
return unless head_entry
|
|
594
|
-
|
|
595
|
-
# Write adjustment to head table (offset 8 within head table)
|
|
596
|
-
io.seek(head_entry.offset + 8)
|
|
597
|
-
io.write([adjustment].pack("N"))
|
|
598
|
-
end
|
|
599
|
-
end
|
|
600
194
|
end
|
|
601
195
|
end
|