fontisan 0.2.2 → 0.2.3
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 +13 -19
- data/README.adoc +31 -0
- data/lib/fontisan/base_collection.rb +296 -0
- data/lib/fontisan/commands/info_command.rb +68 -50
- data/lib/fontisan/font_loader.rb +109 -26
- data/lib/fontisan/formatters/text_formatter.rb +72 -19
- data/lib/fontisan/models/collection_brief_info.rb +6 -0
- data/lib/fontisan/models/collection_info.rb +6 -1
- data/lib/fontisan/open_type_collection.rb +17 -220
- data/lib/fontisan/true_type_collection.rb +29 -113
- data/lib/fontisan/version.rb +1 -1
- metadata +2 -1
data/lib/fontisan/font_loader.rb
CHANGED
|
@@ -102,6 +102,37 @@ module Fontisan
|
|
|
102
102
|
# without extracting individual fonts. Useful for inspecting collection
|
|
103
103
|
# metadata and structure.
|
|
104
104
|
#
|
|
105
|
+
# = Collection Format Understanding
|
|
106
|
+
#
|
|
107
|
+
# Both TTC (TrueType Collection) and OTC (OpenType Collection) files use
|
|
108
|
+
# the same "ttcf" signature. The distinction between TTC and OTC is NOT
|
|
109
|
+
# in the collection format itself, but in the fonts contained within:
|
|
110
|
+
#
|
|
111
|
+
# - TTC typically contains TrueType fonts (glyf outlines)
|
|
112
|
+
# - OTC typically contains OpenType fonts (CFF/CFF2 outlines)
|
|
113
|
+
# - Mixed collections are possible (both TTF and OTF in same collection)
|
|
114
|
+
#
|
|
115
|
+
# Each collection can contain multiple SFNT-format font files, with table
|
|
116
|
+
# deduplication to save space. Individual fonts within a collection are
|
|
117
|
+
# stored at different offsets within the file, each with their own table
|
|
118
|
+
# directory and data tables.
|
|
119
|
+
#
|
|
120
|
+
# = Detection Strategy
|
|
121
|
+
#
|
|
122
|
+
# This method scans ALL fonts in the collection to determine the collection
|
|
123
|
+
# type accurately:
|
|
124
|
+
#
|
|
125
|
+
# 1. Reads all font offsets from the collection header
|
|
126
|
+
# 2. Examines the sfnt_version of each font in the collection
|
|
127
|
+
# 3. Counts TrueType fonts (0x00010000 or 0x74727565 "true") vs OpenType fonts (0x4F54544F "OTTO")
|
|
128
|
+
# 4. If ANY font is OpenType (CFF), returns OpenTypeCollection
|
|
129
|
+
# 5. Only returns TrueTypeCollection if ALL fonts are TrueType
|
|
130
|
+
#
|
|
131
|
+
# This approach correctly handles:
|
|
132
|
+
# - Homogeneous collections (all TTF or all OTF)
|
|
133
|
+
# - Mixed collections (both TTF and OTF fonts) - uses OpenTypeCollection
|
|
134
|
+
# - Large collections with many fonts (like NotoSerifCJK.ttc with 35 fonts)
|
|
135
|
+
#
|
|
105
136
|
# @param path [String] Path to the collection file
|
|
106
137
|
# @return [TrueTypeCollection, OpenTypeCollection] The collection object
|
|
107
138
|
# @raise [Errno::ENOENT] if file does not exist
|
|
@@ -121,23 +152,43 @@ module Fontisan
|
|
|
121
152
|
"File is not a collection (TTC/OTC). Use FontLoader.load instead."
|
|
122
153
|
end
|
|
123
154
|
|
|
124
|
-
# Read
|
|
125
|
-
io.seek(
|
|
126
|
-
|
|
155
|
+
# Read version and num_fonts
|
|
156
|
+
io.seek(8) # Skip tag (4) + version (4)
|
|
157
|
+
num_fonts = io.read(4).unpack1("N")
|
|
158
|
+
|
|
159
|
+
# Read all font offsets
|
|
160
|
+
font_offsets = num_fonts.times.map { io.read(4).unpack1("N") }
|
|
161
|
+
|
|
162
|
+
# Scan all fonts to determine collection type (not just first)
|
|
163
|
+
truetype_count = 0
|
|
164
|
+
opentype_count = 0
|
|
165
|
+
|
|
166
|
+
font_offsets.each do |offset|
|
|
167
|
+
io.rewind
|
|
168
|
+
io.seek(offset)
|
|
169
|
+
sfnt_version = io.read(4).unpack1("N")
|
|
170
|
+
|
|
171
|
+
case sfnt_version
|
|
172
|
+
when Constants::SFNT_VERSION_TRUETYPE, 0x74727565 # 0x74727565 = 'true'
|
|
173
|
+
truetype_count += 1
|
|
174
|
+
when Constants::SFNT_VERSION_OTTO
|
|
175
|
+
opentype_count += 1
|
|
176
|
+
else
|
|
177
|
+
raise InvalidFontError,
|
|
178
|
+
"Unknown font type in collection at offset #{offset} (sfnt version: 0x#{sfnt_version.to_s(16)})"
|
|
179
|
+
end
|
|
180
|
+
end
|
|
127
181
|
|
|
128
|
-
# Peek at first font's sfnt_version
|
|
129
|
-
io.seek(first_offset)
|
|
130
|
-
sfnt_version = io.read(4).unpack1("N")
|
|
131
182
|
io.rewind
|
|
132
183
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
184
|
+
# Determine collection type based on what fonts are inside
|
|
185
|
+
# If ANY font is OpenType, use OpenTypeCollection (more general format)
|
|
186
|
+
# Only use TrueTypeCollection if ALL fonts are TrueType
|
|
187
|
+
if opentype_count > 0
|
|
137
188
|
OpenTypeCollection.from_file(path)
|
|
138
189
|
else
|
|
139
|
-
|
|
140
|
-
|
|
190
|
+
# All fonts are TrueType
|
|
191
|
+
TrueTypeCollection.from_file(path)
|
|
141
192
|
end
|
|
142
193
|
end
|
|
143
194
|
end
|
|
@@ -167,6 +218,23 @@ module Fontisan
|
|
|
167
218
|
|
|
168
219
|
# Load from a collection file (TTC or OTC)
|
|
169
220
|
#
|
|
221
|
+
# This is the internal method that handles loading individual fonts from
|
|
222
|
+
# collection files. It reads the collection header to determine the type
|
|
223
|
+
# (TTC vs OTC) and extracts the requested font.
|
|
224
|
+
#
|
|
225
|
+
# = Collection Header Structure
|
|
226
|
+
#
|
|
227
|
+
# TTC/OTC files start with:
|
|
228
|
+
# - Bytes 0-3: "ttcf" tag (4 bytes)
|
|
229
|
+
# - Bytes 4-7: version (2 bytes major + 2 bytes minor)
|
|
230
|
+
# - Bytes 8-11: num_fonts (4 bytes, big-endian uint32)
|
|
231
|
+
# - Bytes 12+: font offset array (4 bytes per font, big-endian uint32)
|
|
232
|
+
#
|
|
233
|
+
# CRITICAL: The method seeks to position 8 (after tag and version) to read
|
|
234
|
+
# num_fonts, NOT position 12 which is where the offset array starts. This
|
|
235
|
+
# was a bug that caused "Unknown font type" errors when the first offset
|
|
236
|
+
# was misread as num_fonts.
|
|
237
|
+
#
|
|
170
238
|
# @param io [IO] Open file handle
|
|
171
239
|
# @param path [String] Path to the collection file
|
|
172
240
|
# @param font_index [Integer] Index of font to extract
|
|
@@ -177,7 +245,7 @@ module Fontisan
|
|
|
177
245
|
def self.load_from_collection(io, path, font_index,
|
|
178
246
|
mode: LoadingModes::FULL, lazy: true)
|
|
179
247
|
# Read collection header to get font offsets
|
|
180
|
-
io.seek(
|
|
248
|
+
io.seek(8) # Skip tag (4) + version (4)
|
|
181
249
|
num_fonts = io.read(4).unpack1("N")
|
|
182
250
|
|
|
183
251
|
if font_index >= num_fonts
|
|
@@ -185,26 +253,41 @@ mode: LoadingModes::FULL, lazy: true)
|
|
|
185
253
|
"Font index #{font_index} out of range (collection has #{num_fonts} fonts)"
|
|
186
254
|
end
|
|
187
255
|
|
|
188
|
-
# Read
|
|
189
|
-
|
|
256
|
+
# Read all font offsets
|
|
257
|
+
font_offsets = num_fonts.times.map { io.read(4).unpack1("N") }
|
|
258
|
+
|
|
259
|
+
# Scan all fonts to determine collection type (not just first)
|
|
260
|
+
truetype_count = 0
|
|
261
|
+
opentype_count = 0
|
|
262
|
+
|
|
263
|
+
font_offsets.each do |offset|
|
|
264
|
+
io.rewind
|
|
265
|
+
io.seek(offset)
|
|
266
|
+
sfnt_version = io.read(4).unpack1("N")
|
|
267
|
+
|
|
268
|
+
case sfnt_version
|
|
269
|
+
when Constants::SFNT_VERSION_TRUETYPE, 0x74727565 # 0x74727565 = 'true'
|
|
270
|
+
truetype_count += 1
|
|
271
|
+
when Constants::SFNT_VERSION_OTTO
|
|
272
|
+
opentype_count += 1
|
|
273
|
+
else
|
|
274
|
+
raise InvalidFontError,
|
|
275
|
+
"Unknown font type in collection at offset #{offset} (sfnt version: 0x#{sfnt_version.to_s(16)})"
|
|
276
|
+
end
|
|
277
|
+
end
|
|
190
278
|
|
|
191
|
-
# Peek at first font's sfnt_version to determine TTC vs OTC
|
|
192
|
-
io.seek(first_offset)
|
|
193
|
-
sfnt_version = io.read(4).unpack1("N")
|
|
194
279
|
io.rewind
|
|
195
280
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
ttc = TrueTypeCollection.from_file(path)
|
|
200
|
-
File.open(path, "rb") { |f| ttc.font(font_index, f, mode: mode) }
|
|
201
|
-
when Constants::SFNT_VERSION_OTTO
|
|
281
|
+
# If ANY font is OpenType, use OpenTypeCollection (more general format)
|
|
282
|
+
# Only use TrueTypeCollection if ALL fonts are TrueType
|
|
283
|
+
if opentype_count > 0
|
|
202
284
|
# OpenType Collection
|
|
203
285
|
otc = OpenTypeCollection.from_file(path)
|
|
204
286
|
File.open(path, "rb") { |f| otc.font(font_index, f, mode: mode) }
|
|
205
287
|
else
|
|
206
|
-
|
|
207
|
-
|
|
288
|
+
# TrueType Collection (all fonts are TrueType)
|
|
289
|
+
ttc = TrueTypeCollection.from_file(path)
|
|
290
|
+
File.open(path, "rb") { |f| ttc.font(font_index, f, mode: mode) }
|
|
208
291
|
end
|
|
209
292
|
end
|
|
210
293
|
|
|
@@ -364,20 +364,36 @@ module Fontisan
|
|
|
364
364
|
def format_collection_info(info)
|
|
365
365
|
lines = []
|
|
366
366
|
|
|
367
|
-
# Header section
|
|
368
|
-
lines << "
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
367
|
+
# Header section with type and version (like brief mode)
|
|
368
|
+
lines << "Collection: #{info.collection_path}"
|
|
369
|
+
|
|
370
|
+
# Format collection type for display with OpenType version
|
|
371
|
+
if info.collection_format
|
|
372
|
+
collection_type_display = case info.collection_format
|
|
373
|
+
when "TTC"
|
|
374
|
+
"TrueType Collection (OpenType 1.4)"
|
|
375
|
+
when "OTC"
|
|
376
|
+
"OpenType Collection (OpenType 1.8)"
|
|
377
|
+
else
|
|
378
|
+
info.collection_format
|
|
379
|
+
end
|
|
380
|
+
lines << "Type: #{collection_type_display}"
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
lines << "Version: #{info.version_string}"
|
|
372
384
|
lines << "Size: #{format_bytes(info.file_size_bytes)}"
|
|
385
|
+
lines << "Fonts: #{info.num_fonts}"
|
|
373
386
|
lines << ""
|
|
374
387
|
|
|
375
|
-
#
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
388
|
+
# Table sharing statistics
|
|
389
|
+
if info.table_sharing
|
|
390
|
+
lines << "=== Table Sharing ==="
|
|
391
|
+
lines << "Shared tables: #{info.table_sharing.shared_tables}"
|
|
392
|
+
lines << "Unique tables: #{info.table_sharing.unique_tables}"
|
|
393
|
+
lines << "Sharing: #{format_float(info.table_sharing.sharing_percentage)}%"
|
|
394
|
+
lines << "Space saved: #{format_bytes(info.table_sharing.space_saved_bytes)}"
|
|
395
|
+
lines << ""
|
|
396
|
+
end
|
|
381
397
|
|
|
382
398
|
# Font offsets
|
|
383
399
|
lines << "=== Font Offsets ==="
|
|
@@ -387,13 +403,35 @@ module Fontisan
|
|
|
387
403
|
end
|
|
388
404
|
lines << ""
|
|
389
405
|
|
|
390
|
-
#
|
|
391
|
-
if info.
|
|
392
|
-
lines << "===
|
|
393
|
-
lines << "
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
406
|
+
# Individual font information (like brief mode)
|
|
407
|
+
if info.fonts && !info.fonts.empty?
|
|
408
|
+
lines << "=== Fonts ==="
|
|
409
|
+
lines << ""
|
|
410
|
+
|
|
411
|
+
info.fonts.each_with_index do |font_info, index|
|
|
412
|
+
# Show font index with offset
|
|
413
|
+
if font_info.collection_offset
|
|
414
|
+
lines << "Font #{index} (offset: #{font_info.collection_offset}):"
|
|
415
|
+
else
|
|
416
|
+
lines << "Font #{index}:"
|
|
417
|
+
end
|
|
418
|
+
lines << ""
|
|
419
|
+
|
|
420
|
+
# Format each font using same structure as brief mode
|
|
421
|
+
font_type_display = format_font_type_display(font_info.font_format, font_info.is_variable)
|
|
422
|
+
add_line(lines, "Font type", font_type_display)
|
|
423
|
+
add_line(lines, "Family", font_info.family_name)
|
|
424
|
+
add_line(lines, "Subfamily", font_info.subfamily_name)
|
|
425
|
+
add_line(lines, "Full name", font_info.full_name)
|
|
426
|
+
add_line(lines, "PostScript name", font_info.postscript_name)
|
|
427
|
+
add_line(lines, "Version", font_info.version)
|
|
428
|
+
add_line(lines, "Vendor ID", font_info.vendor_id)
|
|
429
|
+
add_line(lines, "Font revision", format_float(font_info.font_revision))
|
|
430
|
+
add_line(lines, "Units per em", font_info.units_per_em)
|
|
431
|
+
|
|
432
|
+
# Blank line between fonts (except after last)
|
|
433
|
+
lines << "" unless index == info.num_fonts - 1
|
|
434
|
+
end
|
|
397
435
|
end
|
|
398
436
|
|
|
399
437
|
lines.join("\n")
|
|
@@ -406,8 +444,23 @@ module Fontisan
|
|
|
406
444
|
def format_collection_brief_info(info)
|
|
407
445
|
lines = []
|
|
408
446
|
|
|
409
|
-
# Collection header
|
|
447
|
+
# Collection header with type and version
|
|
410
448
|
lines << "Collection: #{info.collection_path}"
|
|
449
|
+
|
|
450
|
+
# Format collection type for display with OpenType version
|
|
451
|
+
if info.collection_type
|
|
452
|
+
collection_type_display = case info.collection_type
|
|
453
|
+
when "TTC"
|
|
454
|
+
"TrueType Collection (OpenType 1.4)"
|
|
455
|
+
when "OTC"
|
|
456
|
+
"OpenType Collection (OpenType 1.8)"
|
|
457
|
+
else
|
|
458
|
+
info.collection_type
|
|
459
|
+
end
|
|
460
|
+
lines << "Type: #{collection_type_display}"
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
lines << "Version: #{info.collection_version}" if info.collection_version
|
|
411
464
|
lines << "Fonts: #{info.num_fonts}"
|
|
412
465
|
lines << ""
|
|
413
466
|
|
|
@@ -13,16 +13,22 @@ module Fontisan
|
|
|
13
13
|
# @example Creating collection brief info
|
|
14
14
|
# info = CollectionBriefInfo.new(
|
|
15
15
|
# collection_path: "fonts.ttc",
|
|
16
|
+
# collection_type: "TTC",
|
|
17
|
+
# collection_version: "1.0",
|
|
16
18
|
# num_fonts: 3,
|
|
17
19
|
# fonts: [font_info1, font_info2, font_info3]
|
|
18
20
|
# )
|
|
19
21
|
class CollectionBriefInfo < Lutaml::Model::Serializable
|
|
20
22
|
attribute :collection_path, :string
|
|
23
|
+
attribute :collection_type, :string
|
|
24
|
+
attribute :collection_version, :string
|
|
21
25
|
attribute :num_fonts, :integer
|
|
22
26
|
attribute :fonts, FontInfo, collection: true
|
|
23
27
|
|
|
24
28
|
key_value do
|
|
25
29
|
map "collection_path", to: :collection_path
|
|
30
|
+
map "collection_type", to: :collection_type
|
|
31
|
+
map "collection_version", to: :collection_version
|
|
26
32
|
map "num_fonts", to: :num_fonts
|
|
27
33
|
map "fonts", to: :fonts
|
|
28
34
|
end
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "lutaml/model"
|
|
4
4
|
require_relative "table_sharing_info"
|
|
5
|
+
require_relative "font_info"
|
|
5
6
|
|
|
6
7
|
module Fontisan
|
|
7
8
|
module Models
|
|
@@ -20,7 +21,8 @@ module Fontisan
|
|
|
20
21
|
# num_fonts: 6,
|
|
21
22
|
# font_offsets: [48, 380, 712, 1044, 1376, 1676],
|
|
22
23
|
# file_size_bytes: 2240000,
|
|
23
|
-
# table_sharing: table_sharing_obj
|
|
24
|
+
# table_sharing: table_sharing_obj,
|
|
25
|
+
# fonts: [font_info1, font_info2, ...]
|
|
24
26
|
# )
|
|
25
27
|
class CollectionInfo < Lutaml::Model::Serializable
|
|
26
28
|
attribute :collection_path, :string
|
|
@@ -32,6 +34,7 @@ module Fontisan
|
|
|
32
34
|
attribute :font_offsets, :integer, collection: true
|
|
33
35
|
attribute :file_size_bytes, :integer
|
|
34
36
|
attribute :table_sharing, TableSharingInfo
|
|
37
|
+
attribute :fonts, FontInfo, collection: true
|
|
35
38
|
|
|
36
39
|
yaml do
|
|
37
40
|
map "collection_path", to: :collection_path
|
|
@@ -43,6 +46,7 @@ module Fontisan
|
|
|
43
46
|
map "font_offsets", to: :font_offsets
|
|
44
47
|
map "file_size_bytes", to: :file_size_bytes
|
|
45
48
|
map "table_sharing", to: :table_sharing
|
|
49
|
+
map "fonts", to: :fonts
|
|
46
50
|
end
|
|
47
51
|
|
|
48
52
|
json do
|
|
@@ -55,6 +59,7 @@ module Fontisan
|
|
|
55
59
|
map "font_offsets", to: :font_offsets
|
|
56
60
|
map "file_size_bytes", to: :file_size_bytes
|
|
57
61
|
map "table_sharing", to: :table_sharing
|
|
62
|
+
map "fonts", to: :fonts
|
|
58
63
|
end
|
|
59
64
|
|
|
60
65
|
# Get version as a formatted string
|
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
require_relative "constants"
|
|
3
|
+
require_relative "base_collection"
|
|
5
4
|
|
|
6
5
|
module Fontisan
|
|
7
|
-
# OpenType Collection domain object
|
|
6
|
+
# OpenType Collection domain object
|
|
8
7
|
#
|
|
9
|
-
# Represents a complete OpenType Collection file (OTC)
|
|
10
|
-
#
|
|
8
|
+
# Represents a complete OpenType Collection file (OTC). Inherits all shared
|
|
9
|
+
# functionality from BaseCollection and implements OTC-specific behavior.
|
|
11
10
|
#
|
|
12
11
|
# @example Reading and extracting fonts
|
|
13
12
|
# File.open("fonts.otc", "rb") do |io|
|
|
@@ -15,39 +14,26 @@ module Fontisan
|
|
|
15
14
|
# puts otc.num_fonts # => 4
|
|
16
15
|
# fonts = otc.extract_fonts(io) # => [OpenTypeFont, OpenTypeFont, ...]
|
|
17
16
|
# end
|
|
18
|
-
class OpenTypeCollection <
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
string :tag, length: 4, assert: "ttcf"
|
|
22
|
-
uint16 :major_version
|
|
23
|
-
uint16 :minor_version
|
|
24
|
-
uint32 :num_fonts
|
|
25
|
-
array :font_offsets, type: :uint32, initial_length: :num_fonts
|
|
26
|
-
|
|
27
|
-
# Read OpenType Collection from a file
|
|
17
|
+
class OpenTypeCollection < BaseCollection
|
|
18
|
+
# Get the font class for OpenType collections
|
|
28
19
|
#
|
|
29
|
-
# @
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
def self.from_file(path)
|
|
35
|
-
if path.nil? || path.to_s.empty?
|
|
36
|
-
raise ArgumentError,
|
|
37
|
-
"path cannot be nil or empty"
|
|
38
|
-
end
|
|
39
|
-
raise Errno::ENOENT, "File not found: #{path}" unless File.exist?(path)
|
|
20
|
+
# @return [Class] OpenTypeFont class
|
|
21
|
+
def self.font_class
|
|
22
|
+
require_relative "open_type_font"
|
|
23
|
+
OpenTypeFont
|
|
24
|
+
end
|
|
40
25
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
26
|
+
# Get the collection format identifier
|
|
27
|
+
#
|
|
28
|
+
# @return [String] "OTC" for OpenType Collection
|
|
29
|
+
def self.collection_format
|
|
30
|
+
"OTC"
|
|
46
31
|
end
|
|
47
32
|
|
|
48
33
|
# Extract fonts as OpenTypeFont objects
|
|
49
34
|
#
|
|
50
35
|
# Reads each font from the OTC file and returns them as OpenTypeFont objects.
|
|
36
|
+
# This method uses the from_collection method.
|
|
51
37
|
#
|
|
52
38
|
# @param io [IO] Open file handle to read fonts from
|
|
53
39
|
# @return [Array<OpenTypeFont>] Array of font objects
|
|
@@ -58,194 +44,5 @@ module Fontisan
|
|
|
58
44
|
OpenTypeFont.from_collection(io, offset)
|
|
59
45
|
end
|
|
60
46
|
end
|
|
61
|
-
|
|
62
|
-
# Get a single font from the collection
|
|
63
|
-
#
|
|
64
|
-
# @param index [Integer] Index of the font (0-based)
|
|
65
|
-
# @param io [IO] Open file handle
|
|
66
|
-
# @param mode [Symbol] Loading mode (:metadata or :full, default: :full)
|
|
67
|
-
# @return [OpenTypeFont, nil] Font object or nil if index out of range
|
|
68
|
-
def font(index, io, mode: LoadingModes::FULL)
|
|
69
|
-
return nil if index >= num_fonts
|
|
70
|
-
|
|
71
|
-
require_relative "open_type_font"
|
|
72
|
-
OpenTypeFont.from_collection(io, font_offsets[index], mode: mode)
|
|
73
|
-
end
|
|
74
|
-
|
|
75
|
-
# Get font count
|
|
76
|
-
#
|
|
77
|
-
# @return [Integer] Number of fonts in collection
|
|
78
|
-
def font_count
|
|
79
|
-
num_fonts
|
|
80
|
-
end
|
|
81
|
-
|
|
82
|
-
# Validate format correctness
|
|
83
|
-
#
|
|
84
|
-
# @return [Boolean] true if the format is valid, false otherwise
|
|
85
|
-
def valid?
|
|
86
|
-
tag == Constants::TTC_TAG && num_fonts.positive? && font_offsets.length == num_fonts
|
|
87
|
-
rescue StandardError
|
|
88
|
-
false
|
|
89
|
-
end
|
|
90
|
-
|
|
91
|
-
# Get the OTC version as a single integer
|
|
92
|
-
#
|
|
93
|
-
# @return [Integer] Version number (e.g., 0x00010000 for version 1.0)
|
|
94
|
-
def version
|
|
95
|
-
(major_version << 16) | minor_version
|
|
96
|
-
end
|
|
97
|
-
|
|
98
|
-
# List all fonts in the collection with basic metadata
|
|
99
|
-
#
|
|
100
|
-
# Returns a CollectionListInfo model containing summaries of all fonts.
|
|
101
|
-
# This is the API method used by the `ls` command for collections.
|
|
102
|
-
#
|
|
103
|
-
# @param io [IO] Open file handle to read fonts from
|
|
104
|
-
# @return [CollectionListInfo] List of fonts with metadata
|
|
105
|
-
#
|
|
106
|
-
# @example List fonts in collection
|
|
107
|
-
# File.open("fonts.otc", "rb") do |io|
|
|
108
|
-
# otc = OpenTypeCollection.read(io)
|
|
109
|
-
# list = otc.list_fonts(io)
|
|
110
|
-
# list.fonts.each { |f| puts "#{f.index}: #{f.family_name}" }
|
|
111
|
-
# end
|
|
112
|
-
def list_fonts(io)
|
|
113
|
-
require_relative "models/collection_list_info"
|
|
114
|
-
require_relative "models/collection_font_summary"
|
|
115
|
-
require_relative "open_type_font"
|
|
116
|
-
require_relative "tables/name"
|
|
117
|
-
|
|
118
|
-
fonts = font_offsets.map.with_index do |offset, index|
|
|
119
|
-
font = OpenTypeFont.from_collection(io, offset)
|
|
120
|
-
|
|
121
|
-
# Extract basic font info
|
|
122
|
-
name_table = font.table("name")
|
|
123
|
-
post_table = font.table("post")
|
|
124
|
-
|
|
125
|
-
family_name = name_table&.english_name(Tables::Name::FAMILY) || "Unknown"
|
|
126
|
-
subfamily_name = name_table&.english_name(Tables::Name::SUBFAMILY) || "Regular"
|
|
127
|
-
postscript_name = name_table&.english_name(Tables::Name::POSTSCRIPT_NAME) || "Unknown"
|
|
128
|
-
|
|
129
|
-
# Determine font format
|
|
130
|
-
sfnt = font.header.sfnt_version
|
|
131
|
-
font_format = case sfnt
|
|
132
|
-
when 0x00010000, 0x74727565 # 0x74727565 = 'true'
|
|
133
|
-
"TrueType"
|
|
134
|
-
when 0x4F54544F # 'OTTO'
|
|
135
|
-
"OpenType"
|
|
136
|
-
else
|
|
137
|
-
"Unknown"
|
|
138
|
-
end
|
|
139
|
-
|
|
140
|
-
num_glyphs = post_table&.glyph_names&.length || 0
|
|
141
|
-
num_tables = font.table_names.length
|
|
142
|
-
|
|
143
|
-
Models::CollectionFontSummary.new(
|
|
144
|
-
index: index,
|
|
145
|
-
family_name: family_name,
|
|
146
|
-
subfamily_name: subfamily_name,
|
|
147
|
-
postscript_name: postscript_name,
|
|
148
|
-
font_format: font_format,
|
|
149
|
-
num_glyphs: num_glyphs,
|
|
150
|
-
num_tables: num_tables,
|
|
151
|
-
)
|
|
152
|
-
end
|
|
153
|
-
|
|
154
|
-
Models::CollectionListInfo.new(
|
|
155
|
-
collection_path: nil, # Will be set by command
|
|
156
|
-
num_fonts: num_fonts,
|
|
157
|
-
fonts: fonts,
|
|
158
|
-
)
|
|
159
|
-
end
|
|
160
|
-
|
|
161
|
-
# Get comprehensive collection metadata
|
|
162
|
-
#
|
|
163
|
-
# Returns a CollectionInfo model with header information, offsets,
|
|
164
|
-
# and table sharing statistics.
|
|
165
|
-
# This is the API method used by the `info` command for collections.
|
|
166
|
-
#
|
|
167
|
-
# @param io [IO] Open file handle to read fonts from
|
|
168
|
-
# @param path [String] Collection file path (for file size)
|
|
169
|
-
# @return [CollectionInfo] Collection metadata
|
|
170
|
-
#
|
|
171
|
-
# @example Get collection info
|
|
172
|
-
# File.open("fonts.otc", "rb") do |io|
|
|
173
|
-
# otc = OpenTypeCollection.read(io)
|
|
174
|
-
# info = otc.collection_info(io, "fonts.otc")
|
|
175
|
-
# puts "Version: #{info.version_string}"
|
|
176
|
-
# end
|
|
177
|
-
def collection_info(io, path)
|
|
178
|
-
require_relative "models/collection_info"
|
|
179
|
-
require_relative "models/table_sharing_info"
|
|
180
|
-
|
|
181
|
-
# Calculate table sharing statistics
|
|
182
|
-
table_sharing = calculate_table_sharing(io)
|
|
183
|
-
|
|
184
|
-
# Get file size
|
|
185
|
-
file_size = path ? File.size(path) : 0
|
|
186
|
-
|
|
187
|
-
Models::CollectionInfo.new(
|
|
188
|
-
collection_path: path,
|
|
189
|
-
collection_format: "OTC",
|
|
190
|
-
ttc_tag: tag,
|
|
191
|
-
major_version: major_version,
|
|
192
|
-
minor_version: minor_version,
|
|
193
|
-
num_fonts: num_fonts,
|
|
194
|
-
font_offsets: font_offsets.to_a,
|
|
195
|
-
file_size_bytes: file_size,
|
|
196
|
-
table_sharing: table_sharing,
|
|
197
|
-
)
|
|
198
|
-
end
|
|
199
|
-
|
|
200
|
-
private
|
|
201
|
-
|
|
202
|
-
# Calculate table sharing statistics
|
|
203
|
-
#
|
|
204
|
-
# Analyzes which tables are shared between fonts and calculates
|
|
205
|
-
# space savings from deduplication.
|
|
206
|
-
#
|
|
207
|
-
# @param io [IO] Open file handle
|
|
208
|
-
# @return [TableSharingInfo] Sharing statistics
|
|
209
|
-
def calculate_table_sharing(io)
|
|
210
|
-
require_relative "models/table_sharing_info"
|
|
211
|
-
require_relative "open_type_font"
|
|
212
|
-
|
|
213
|
-
# Extract all fonts
|
|
214
|
-
fonts = font_offsets.map do |offset|
|
|
215
|
-
OpenTypeFont.from_collection(io, offset)
|
|
216
|
-
end
|
|
217
|
-
|
|
218
|
-
# Build table hash map (checksum -> size)
|
|
219
|
-
table_map = {}
|
|
220
|
-
total_table_size = 0
|
|
221
|
-
|
|
222
|
-
fonts.each do |font|
|
|
223
|
-
font.tables.each do |entry|
|
|
224
|
-
key = entry.checksum
|
|
225
|
-
size = entry.table_length
|
|
226
|
-
table_map[key] ||= size
|
|
227
|
-
total_table_size += size
|
|
228
|
-
end
|
|
229
|
-
end
|
|
230
|
-
|
|
231
|
-
# Count unique vs shared
|
|
232
|
-
unique_tables = table_map.size
|
|
233
|
-
total_tables = fonts.sum { |f| f.tables.length }
|
|
234
|
-
shared_tables = total_tables - unique_tables
|
|
235
|
-
|
|
236
|
-
# Calculate space saved
|
|
237
|
-
unique_size = table_map.values.sum
|
|
238
|
-
space_saved = total_table_size - unique_size
|
|
239
|
-
|
|
240
|
-
# Calculate sharing percentage
|
|
241
|
-
sharing_pct = total_tables.positive? ? (shared_tables.to_f / total_tables * 100).round(2) : 0.0
|
|
242
|
-
|
|
243
|
-
Models::TableSharingInfo.new(
|
|
244
|
-
shared_tables: shared_tables,
|
|
245
|
-
unique_tables: unique_tables,
|
|
246
|
-
sharing_percentage: sharing_pct,
|
|
247
|
-
space_saved_bytes: space_saved,
|
|
248
|
-
)
|
|
249
|
-
end
|
|
250
47
|
end
|
|
251
48
|
end
|