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.
@@ -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 first font offset to detect collection type
125
- io.seek(12) # Skip tag (4) + versions (4) + num_fonts (4)
126
- first_offset = io.read(4).unpack1("N")
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
- case sfnt_version
134
- when Constants::SFNT_VERSION_TRUETYPE
135
- TrueTypeCollection.from_file(path)
136
- when Constants::SFNT_VERSION_OTTO
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
- raise InvalidFontError,
140
- "Unknown font type in collection (sfnt version: 0x#{sfnt_version.to_s(16)})"
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(12) # Skip tag (4) + major_version (2) + minor_version (2) + num_fonts marker (4)
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 first offset to detect collection type
189
- first_offset = io.read(4).unpack1("N")
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
- case sfnt_version
197
- when Constants::SFNT_VERSION_TRUETYPE
198
- # TrueType Collection
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
- raise InvalidFontError,
207
- "Unknown font type in collection (sfnt version: 0x#{sfnt_version.to_s(16)})"
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 << "=== Collection Information ==="
369
- lines << ""
370
- lines << "File: #{info.collection_path}"
371
- lines << "Format: #{info.collection_format}"
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
- # Header details
376
- lines << "=== Header ==="
377
- lines << "Tag: #{info.ttc_tag}"
378
- lines << "Version: #{info.version_string} (#{info.version_hex})"
379
- lines << "Number of fonts: #{info.num_fonts}"
380
- lines << ""
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
- # Table sharing statistics
391
- if info.table_sharing
392
- lines << "=== Table Sharing ==="
393
- lines << "Shared tables: #{info.table_sharing.shared_tables}"
394
- lines << "Unique tables: #{info.table_sharing.unique_tables}"
395
- lines << "Sharing: #{format_float(info.table_sharing.sharing_percentage)}%"
396
- lines << "Space saved: #{format_bytes(info.table_sharing.space_saved_bytes)}"
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
- require "bindata"
4
- require_relative "constants"
3
+ require_relative "base_collection"
5
4
 
6
5
  module Fontisan
7
- # OpenType Collection domain object using BinData
6
+ # OpenType Collection domain object
8
7
  #
9
- # Represents a complete OpenType Collection file (OTC) using BinData's declarative
10
- # DSL for binary structure definition. Parallel to TrueTypeCollection but for OpenType fonts.
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 < BinData::Record
19
- endian :big
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
- # @param path [String] Path to the OTC file
30
- # @return [OpenTypeCollection] A new instance
31
- # @raise [ArgumentError] if path is nil or empty
32
- # @raise [Errno::ENOENT] if file does not exist
33
- # @raise [RuntimeError] if file format is invalid
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
- File.open(path, "rb") { |io| read(io) }
42
- rescue BinData::ValidityError => e
43
- raise "Invalid OTC file: #{e.message}"
44
- rescue EOFError => e
45
- raise "Invalid OTC file: unexpected end of file - #{e.message}"
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