fontisan 0.2.5 → 0.2.7

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.
@@ -0,0 +1,438 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "format_converter"
4
+ require_relative "../collection/builder"
5
+ require_relative "../collection/dfont_builder"
6
+ require_relative "../parsers/dfont_parser"
7
+ require_relative "../font_loader"
8
+
9
+ module Fontisan
10
+ module Converters
11
+ # CollectionConverter handles conversion between collection formats
12
+ #
13
+ # Main responsibility: Convert between TTC, OTC, and dfont collection
14
+ # formats using a three-step strategy:
15
+ # 1. Unpack: Extract individual fonts from source collection
16
+ # 2. Convert: Transform each font's outline format if requested
17
+ # 3. Repack: Rebuild collection in target format
18
+ #
19
+ # Supported conversions:
20
+ # - TTC ↔ OTC (preserves mixed TTF+OTF by default)
21
+ # - TTC → dfont (repackage)
22
+ # - OTC → dfont (repackage)
23
+ # - dfont → TTC (preserves mixed formats)
24
+ # - dfont → OTC (preserves mixed formats)
25
+ #
26
+ # @example Convert TTC to OTC (preserve formats)
27
+ # converter = CollectionConverter.new
28
+ # result = converter.convert(ttc_path, target_type: :otc, output: 'family.otc')
29
+ #
30
+ # @example Convert TTC to OTC with outline conversion
31
+ # converter = CollectionConverter.new
32
+ # result = converter.convert(ttc_path, target_type: :otc,
33
+ # options: { output: 'family.otc', convert_outlines: true })
34
+ #
35
+ # @example Convert dfont to TTC
36
+ # converter = CollectionConverter.new
37
+ # result = converter.convert(dfont_path, target_type: :ttc, output: 'family.ttc')
38
+ class CollectionConverter
39
+ # Convert collection to target format
40
+ #
41
+ # @param collection_path [String] Path to source collection
42
+ # @param target_type [Symbol] Target collection type (:ttc, :otc, :dfont)
43
+ # @param options [Hash] Conversion options
44
+ # @option options [String] :output Output file path (required)
45
+ # @option options [String] :target_format Target outline format: 'preserve' (default), 'ttf', or 'otf'
46
+ # @option options [Boolean] :optimize Enable table sharing (default: true, TTC/OTC only)
47
+ # @option options [Boolean] :verbose Enable verbose output (default: false)
48
+ # @return [Hash] Conversion result with:
49
+ # - :input [String] - Input collection path
50
+ # - :output [String] - Output collection path
51
+ # - :source_type [Symbol] - Source collection type
52
+ # - :target_type [Symbol] - Target collection type
53
+ # - :num_fonts [Integer] - Number of fonts converted
54
+ # - :conversions [Array<Hash>] - Per-font conversion details
55
+ # @raise [ArgumentError] if parameters invalid
56
+ # @raise [Error] if conversion fails
57
+ def convert(collection_path, target_type:, options: {})
58
+ validate_parameters!(collection_path, target_type, options)
59
+
60
+ verbose = options.fetch(:verbose, false)
61
+ output_path = options[:output]
62
+ target_format = options.fetch(:target_format, 'preserve').to_s
63
+
64
+ # Validate target_format
65
+ unless %w[preserve ttf otf].include?(target_format)
66
+ raise ArgumentError, "Invalid target_format: #{target_format}. Must be 'preserve', 'ttf', or 'otf'"
67
+ end
68
+
69
+ puts "Converting collection to #{target_type.to_s.upcase}..." if verbose
70
+
71
+ # Step 1: Unpack - extract fonts from source collection
72
+ puts " Unpacking fonts from source collection..." if verbose
73
+ fonts, source_type = unpack_fonts(collection_path)
74
+
75
+ # Check if conversion is needed
76
+ if source_type == target_type
77
+ puts " Source and target formats are the same, copying collection..." if verbose
78
+ FileUtils.cp(collection_path, output_path)
79
+ return build_result(collection_path, output_path, source_type, target_type, fonts.size, [])
80
+ end
81
+
82
+ # Step 2: Convert - transform fonts if requested
83
+ puts " Converting #{fonts.size} font(s)..." if verbose
84
+ converted_fonts, conversions = convert_fonts(fonts, source_type, target_type, options.merge(target_format: target_format))
85
+
86
+ # Step 3: Repack - build target collection
87
+ puts " Repacking into #{target_type.to_s.upcase} format..." if verbose
88
+ repack_fonts(converted_fonts, target_type, output_path, options)
89
+
90
+ # Build result
91
+ result = build_result(collection_path, output_path, source_type, target_type, fonts.size, conversions)
92
+
93
+ if verbose
94
+ display_result(result)
95
+ end
96
+
97
+ result
98
+ end
99
+
100
+ private
101
+
102
+ # Validate conversion parameters
103
+ #
104
+ # @param collection_path [String] Collection path
105
+ # @param target_type [Symbol] Target type
106
+ # @param options [Hash] Options
107
+ # @raise [ArgumentError] if invalid
108
+ def validate_parameters!(collection_path, target_type, options)
109
+ unless File.exist?(collection_path)
110
+ raise ArgumentError, "Collection file not found: #{collection_path}"
111
+ end
112
+
113
+ unless %i[ttc otc dfont].include?(target_type)
114
+ raise ArgumentError, "Invalid target type: #{target_type}. Must be :ttc, :otc, or :dfont"
115
+ end
116
+
117
+ unless options[:output]
118
+ raise ArgumentError, "Output path is required (:output option)"
119
+ end
120
+ end
121
+
122
+ # Unpack fonts from source collection
123
+ #
124
+ # @param collection_path [String] Collection path
125
+ # @return [Array<(Array<Font>, Symbol)>] Array of [fonts, source_type]
126
+ # @raise [Error] if unpacking fails
127
+ def unpack_fonts(collection_path)
128
+ # Detect collection type
129
+ source_type = detect_collection_type(collection_path)
130
+
131
+ fonts = case source_type
132
+ when :ttc, :otc
133
+ unpack_ttc_otc(collection_path)
134
+ when :dfont
135
+ unpack_dfont(collection_path)
136
+ else
137
+ raise Error, "Unknown collection type: #{source_type}"
138
+ end
139
+
140
+ [fonts, source_type]
141
+ end
142
+
143
+ # Detect collection type from file
144
+ #
145
+ # @param path [String] Collection path
146
+ # @return [Symbol] Collection type (:ttc, :otc, or :dfont)
147
+ def detect_collection_type(path)
148
+ File.open(path, "rb") do |io|
149
+ signature = io.read(4)
150
+ io.rewind
151
+
152
+ if signature == "ttcf"
153
+ # TTC or OTC - check extension
154
+ ext = File.extname(path).downcase
155
+ ext == ".otc" ? :otc : :ttc
156
+ elsif Parsers::DfontParser.dfont?(io)
157
+ :dfont
158
+ else
159
+ raise Error, "Not a valid collection file: #{path}"
160
+ end
161
+ end
162
+ end
163
+
164
+ # Unpack fonts from TTC/OTC
165
+ #
166
+ # @param path [String] TTC/OTC path
167
+ # @return [Array<Font>] Unpacked fonts
168
+ def unpack_ttc_otc(path)
169
+ collection = FontLoader.load_collection(path)
170
+
171
+ File.open(path, "rb") do |io|
172
+ collection.extract_fonts(io)
173
+ end
174
+ end
175
+
176
+ # Unpack fonts from dfont
177
+ #
178
+ # @param path [String] dfont path
179
+ # @return [Array<Font>] Unpacked fonts
180
+ def unpack_dfont(path)
181
+ fonts = []
182
+
183
+ File.open(path, "rb") do |io|
184
+ count = Parsers::DfontParser.sfnt_count(io)
185
+
186
+ count.times do |index|
187
+ sfnt_data = Parsers::DfontParser.extract_sfnt(io, index: index)
188
+
189
+ # Load font from SFNT binary
190
+ font = FontLoader.load_from_binary(sfnt_data)
191
+ fonts << font
192
+ end
193
+ end
194
+
195
+ fonts
196
+ end
197
+
198
+ # Convert fonts if outline format change needed
199
+ #
200
+ # @param fonts [Array<Font>] Source fonts
201
+ # @param source_type [Symbol] Source collection type
202
+ # @param target_type [Symbol] Target collection type
203
+ # @param options [Hash] Conversion options
204
+ # @return [Array<(Array<Font>, Array<Hash>)>] [converted_fonts, conversions]
205
+ def convert_fonts(fonts, source_type, target_type, options)
206
+ converted_fonts = []
207
+ conversions = []
208
+
209
+ # Determine if outline conversion is needed
210
+ target_format = options.fetch(:target_format, 'preserve').to_s
211
+
212
+ fonts.each_with_index do |font, index|
213
+ source_format = detect_font_format(font)
214
+ needs_conversion = outline_conversion_needed?(source_format, target_format)
215
+
216
+ if needs_conversion
217
+ # Convert outline format
218
+ desired_format = target_format == 'preserve' ? source_format : target_format.to_sym
219
+ converter = FormatConverter.new
220
+
221
+ begin
222
+ tables = converter.convert(font, desired_format, options)
223
+ converted_font = build_font_from_tables(tables, desired_format)
224
+ converted_fonts << converted_font
225
+
226
+ conversions << {
227
+ index: index,
228
+ source_format: source_format,
229
+ target_format: desired_format,
230
+ status: :converted,
231
+ }
232
+ rescue Error => e
233
+ # If conversion fails, keep original for dfont (supports mixed)
234
+ if target_type == :dfont
235
+ converted_fonts << font
236
+ conversions << {
237
+ index: index,
238
+ source_format: source_format,
239
+ target_format: source_format,
240
+ status: :preserved,
241
+ note: "Conversion failed, kept original: #{e.message}",
242
+ }
243
+ else
244
+ raise Error, "Font #{index} conversion failed: #{e.message}"
245
+ end
246
+ end
247
+ else
248
+ # No conversion needed, use original
249
+ converted_fonts << font
250
+ conversions << {
251
+ index: index,
252
+ source_format: source_format,
253
+ target_format: source_format,
254
+ status: :preserved,
255
+ }
256
+ end
257
+ end
258
+
259
+ [converted_fonts, conversions]
260
+ end
261
+
262
+ # Check if outline conversion is needed
263
+ #
264
+ # @param source_format [Symbol] Source font format (:ttf or :otf)
265
+ # @param target_format [String] Target format ('preserve', 'ttf', or 'otf')
266
+ # @return [Boolean] true if conversion needed
267
+ def outline_conversion_needed?(source_format, target_format)
268
+ # 'preserve' means keep original format
269
+ return false if target_format == 'preserve'
270
+
271
+ # Convert if target format differs from source
272
+ target_format.to_sym != source_format
273
+ end
274
+
275
+ # Determine target outline format for a font
276
+ #
277
+ # @param target_type [Symbol] Target collection type
278
+ # @param font [Font] Font object
279
+ # @return [Symbol] Target outline format (:ttf or :otf)
280
+ def target_outline_format(target_type, font)
281
+ case target_type
282
+ when :ttc
283
+ :ttf # TTC requires TrueType
284
+ when :otc
285
+ :otf # OTC requires OpenType/CFF
286
+ when :dfont
287
+ # dfont preserves original format
288
+ detect_font_format(font)
289
+ else
290
+ detect_font_format(font)
291
+ end
292
+ end
293
+
294
+ # Detect font outline format
295
+ #
296
+ # @param font [Font] Font object
297
+ # @return [Symbol] Format (:ttf or :otf)
298
+ def detect_font_format(font)
299
+ if font.has_table?("CFF ") || font.has_table?("CFF2")
300
+ :otf
301
+ elsif font.has_table?("glyf")
302
+ :ttf
303
+ else
304
+ raise Error, "Cannot detect font format"
305
+ end
306
+ end
307
+
308
+ # Build font object from tables
309
+ #
310
+ # @param tables [Hash] Table data
311
+ # @param format [Symbol] Font format
312
+ # @return [Font] Font object
313
+ def build_font_from_tables(tables, format)
314
+ # Create temporary font from tables
315
+ require_relative "../font_writer"
316
+ require "stringio"
317
+
318
+ sfnt_version = format == :otf ? 0x4F54544F : 0x00010000
319
+ binary = FontWriter.write_font(tables, sfnt_version: sfnt_version)
320
+
321
+ # Load font from binary using StringIO
322
+ sfnt_io = StringIO.new(binary)
323
+ signature = sfnt_io.read(4)
324
+ sfnt_io.rewind
325
+
326
+ # Create font based on signature
327
+ case signature
328
+ when [Constants::SFNT_VERSION_TRUETYPE].pack("N"), "true"
329
+ font = TrueTypeFont.read(sfnt_io)
330
+ font.initialize_storage
331
+ font.loading_mode = LoadingModes::FULL
332
+ font.lazy_load_enabled = false
333
+ font.read_table_data(sfnt_io)
334
+ font
335
+ when "OTTO"
336
+ font = OpenTypeFont.read(sfnt_io)
337
+ font.initialize_storage
338
+ font.loading_mode = LoadingModes::FULL
339
+ font.lazy_load_enabled = false
340
+ font.read_table_data(sfnt_io)
341
+ font
342
+ else
343
+ raise Error, "Invalid SFNT signature: #{signature.inspect}"
344
+ end
345
+ end
346
+
347
+ # Repack fonts into target collection
348
+ #
349
+ # @param fonts [Array<Font>] Fonts to pack
350
+ # @param target_type [Symbol] Target type
351
+ # @param output_path [String] Output path
352
+ # @param options [Hash] Packing options
353
+ # @return [void]
354
+ def repack_fonts(fonts, target_type, output_path, options)
355
+ case target_type
356
+ when :ttc, :otc
357
+ repack_ttc_otc(fonts, target_type, output_path, options)
358
+ when :dfont
359
+ repack_dfont(fonts, output_path, options)
360
+ else
361
+ raise Error, "Unknown target type: #{target_type}"
362
+ end
363
+ end
364
+
365
+ # Repack fonts into TTC/OTC
366
+ #
367
+ # @param fonts [Array<Font>] Fonts
368
+ # @param target_type [Symbol] :ttc or :otc
369
+ # @param output_path [String] Output path
370
+ # @param options [Hash] Options
371
+ # @return [void]
372
+ def repack_ttc_otc(fonts, target_type, output_path, options)
373
+ optimize = options.fetch(:optimize, true)
374
+
375
+ builder = Collection::Builder.new(
376
+ fonts,
377
+ format: target_type,
378
+ optimize: optimize,
379
+ )
380
+
381
+ builder.build_to_file(output_path)
382
+ end
383
+
384
+ # Repack fonts into dfont
385
+ #
386
+ # @param fonts [Array<Font>] Fonts
387
+ # @param output_path [String] Output path
388
+ # @param options [Hash] Options
389
+ # @return [void]
390
+ def repack_dfont(fonts, output_path, _options)
391
+ builder = Collection::DfontBuilder.new(fonts)
392
+ builder.build_to_file(output_path)
393
+ end
394
+
395
+ # Build conversion result
396
+ #
397
+ # @param input [String] Input path
398
+ # @param output [String] Output path
399
+ # @param source_type [Symbol] Source type
400
+ # @param target_type [Symbol] Target type
401
+ # @param num_fonts [Integer] Number of fonts
402
+ # @param conversions [Array<Hash>] Conversion details
403
+ # @return [Hash] Result
404
+ def build_result(input, output, source_type, target_type, num_fonts, conversions)
405
+ {
406
+ input: input,
407
+ output: output,
408
+ source_type: source_type,
409
+ target_type: target_type,
410
+ num_fonts: num_fonts,
411
+ conversions: conversions,
412
+ }
413
+ end
414
+
415
+ # Display conversion result
416
+ #
417
+ # @param result [Hash] Result
418
+ # @return [void]
419
+ def display_result(result)
420
+ puts "\n=== Collection Conversion Complete ==="
421
+ puts "Input: #{result[:input]}"
422
+ puts "Output: #{result[:output]}"
423
+ puts "Source format: #{result[:source_type].to_s.upcase}"
424
+ puts "Target format: #{result[:target_type].to_s.upcase}"
425
+ puts "Fonts: #{result[:num_fonts]}"
426
+
427
+ if result[:conversions].any?
428
+ converted_count = result[:conversions].count { |c| c[:status] == :converted }
429
+ if converted_count.positive?
430
+ puts "Outline conversions: #{converted_count}"
431
+ end
432
+ end
433
+
434
+ puts ""
435
+ end
436
+ end
437
+ end
438
+ end
@@ -0,0 +1,269 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "parsers/dfont_parser"
4
+ require_relative "error"
5
+ require_relative "collection/shared_logic"
6
+
7
+ module Fontisan
8
+ # DfontCollection represents an Apple dfont suitcase containing multiple fonts
9
+ #
10
+ # dfont (Data Fork Font) is an Apple-specific format that stores Mac font
11
+ # suitcase resources in the data fork. It can contain multiple SFNT fonts
12
+ # (TrueType or OpenType).
13
+ #
14
+ # This class provides a collection interface similar to TrueTypeCollection
15
+ # and OpenTypeCollection for consistency.
16
+ #
17
+ # @example Load dfont collection
18
+ # collection = DfontCollection.from_file("family.dfont")
19
+ # puts "Collection has #{collection.num_fonts} fonts"
20
+ #
21
+ # @example Extract fonts from dfont
22
+ # File.open("family.dfont", "rb") do |io|
23
+ # fonts = collection.extract_fonts(io)
24
+ # fonts.each { |font| puts font.class.name }
25
+ # end
26
+ class DfontCollection
27
+ include Collection::SharedLogic
28
+
29
+ # Path to dfont file
30
+ # @return [String]
31
+ attr_reader :path
32
+
33
+ # Number of fonts in collection
34
+ # @return [Integer]
35
+ attr_reader :num_fonts
36
+ alias font_count num_fonts
37
+
38
+ # Get font offsets (indices for dfont)
39
+ #
40
+ # dfont doesn't use byte offsets like TTC/OTC, so we return indices
41
+ #
42
+ # @return [Array<Integer>] Array of font indices
43
+ def font_offsets
44
+ (0...@num_fonts).to_a
45
+ end
46
+
47
+ # Get the collection format identifier
48
+ #
49
+ # @return [String] "dfont" for dfont collection
50
+ def self.collection_format
51
+ "dfont"
52
+ end
53
+
54
+ # Load dfont collection from file
55
+ #
56
+ # @param path [String] Path to dfont file
57
+ # @return [DfontCollection] Collection object
58
+ # @raise [InvalidFontError] if not valid dfont
59
+ def self.from_file(path)
60
+ File.open(path, "rb") do |io|
61
+ unless Parsers::DfontParser.dfont?(io)
62
+ raise InvalidFontError, "Not a valid dfont file: #{path}"
63
+ end
64
+
65
+ num_fonts = Parsers::DfontParser.sfnt_count(io)
66
+ new(path, num_fonts)
67
+ end
68
+ end
69
+
70
+ # Initialize collection
71
+ #
72
+ # @param path [String] Path to dfont file
73
+ # @param num_fonts [Integer] Number of fonts
74
+ # @api private
75
+ def initialize(path, num_fonts)
76
+ @path = path
77
+ @num_fonts = num_fonts
78
+ end
79
+
80
+ # Check if collection is valid
81
+ #
82
+ # @return [Boolean] true if valid
83
+ def valid?
84
+ File.exist?(@path) && @num_fonts.positive?
85
+ end
86
+
87
+ # Get the collection version as a string
88
+ #
89
+ # dfont files don't have version numbers like TTC/OTC
90
+ #
91
+ # @return [String] Version string (always "N/A" for dfont)
92
+ def version_string
93
+ "N/A"
94
+ end
95
+
96
+ # Extract all fonts from dfont
97
+ #
98
+ # @param io [IO] Open file handle
99
+ # @return [Array<TrueTypeFont, OpenTypeFont>] Array of fonts
100
+ def extract_fonts(io)
101
+ require "stringio"
102
+
103
+ fonts = []
104
+
105
+ @num_fonts.times do |index|
106
+ io.rewind
107
+ sfnt_data = Parsers::DfontParser.extract_sfnt(io, index: index)
108
+
109
+ # Load font from SFNT binary
110
+ sfnt_io = StringIO.new(sfnt_data)
111
+ signature = sfnt_io.read(4)
112
+ sfnt_io.rewind
113
+
114
+ # Create font based on signature
115
+ font = case signature
116
+ when [Constants::SFNT_VERSION_TRUETYPE].pack("N"), "true"
117
+ TrueTypeFont.read(sfnt_io)
118
+ when "OTTO"
119
+ OpenTypeFont.read(sfnt_io)
120
+ else
121
+ raise InvalidFontError,
122
+ "Invalid SFNT signature in dfont at index #{index}: #{signature.inspect}"
123
+ end
124
+
125
+ font.initialize_storage
126
+ font.loading_mode = LoadingModes::FULL
127
+ font.lazy_load_enabled = false
128
+ font.read_table_data(sfnt_io)
129
+
130
+ fonts << font
131
+ end
132
+
133
+ fonts
134
+ end
135
+
136
+ # List fonts in collection (brief info)
137
+ #
138
+ # @param io [IO] Open file handle
139
+ # @return [Models::CollectionListInfo] Collection list info
140
+ def list_fonts(io)
141
+ require_relative "models/collection_list_info"
142
+ require_relative "models/collection_font_summary"
143
+
144
+ fonts = extract_fonts(io)
145
+
146
+ summaries = fonts.map.with_index do |font, index|
147
+ name_table = font.table("name")
148
+ family = name_table.english_name(Models::Tables::Name::FAMILY) || "Unknown"
149
+ subfamily = name_table.english_name(Models::Tables::Name::SUBFAMILY) || "Regular"
150
+
151
+ # Detect font format
152
+ format = if font.has_table?("CFF ") || font.has_table?("CFF2")
153
+ "OpenType"
154
+ else
155
+ "TrueType"
156
+ end
157
+
158
+ Models::CollectionFontSummary.new(
159
+ index: index,
160
+ family_name: family,
161
+ subfamily_name: subfamily,
162
+ font_format: format,
163
+ )
164
+ end
165
+
166
+ Models::CollectionListInfo.new(
167
+ num_fonts: @num_fonts,
168
+ fonts: summaries,
169
+ )
170
+ end
171
+
172
+ # Get specific font from collection
173
+ #
174
+ # @param index [Integer] Font index
175
+ # @param io [IO] Open file handle
176
+ # @param mode [Symbol] Loading mode
177
+ # @return [TrueTypeFont, OpenTypeFont] Font object
178
+ # @raise [InvalidFontError] if index out of range
179
+ def font(index, io, mode: LoadingModes::FULL)
180
+ if index >= @num_fonts
181
+ raise InvalidFontError,
182
+ "Font index #{index} out of range (collection has #{@num_fonts} fonts)"
183
+ end
184
+
185
+ io.rewind
186
+ sfnt_data = Parsers::DfontParser.extract_sfnt(io, index: index)
187
+
188
+ # Load font from SFNT binary
189
+ require "stringio"
190
+ sfnt_io = StringIO.new(sfnt_data)
191
+ signature = sfnt_io.read(4)
192
+ sfnt_io.rewind
193
+
194
+ # Create font based on signature
195
+ font = case signature
196
+ when [Constants::SFNT_VERSION_TRUETYPE].pack("N"), "true"
197
+ TrueTypeFont.read(sfnt_io)
198
+ when "OTTO"
199
+ OpenTypeFont.read(sfnt_io)
200
+ else
201
+ raise InvalidFontError,
202
+ "Invalid SFNT signature: #{signature.inspect}"
203
+ end
204
+
205
+ font.initialize_storage
206
+ font.loading_mode = mode
207
+ font.lazy_load_enabled = false
208
+ font.read_table_data(sfnt_io)
209
+
210
+ font
211
+ end
212
+
213
+ # Get comprehensive collection metadata
214
+ #
215
+ # Returns a CollectionInfo model with header information and
216
+ # table sharing statistics for the dfont collection.
217
+ # This is the API method used by the `info` command for collections.
218
+ #
219
+ # @param io [IO] Open file handle to read fonts from
220
+ # @param path [String] Collection file path (for file size)
221
+ # @return [Models::CollectionInfo] Collection metadata
222
+ #
223
+ # @example Get collection info
224
+ # File.open("family.dfont", "rb") do |io|
225
+ # collection = DfontCollection.from_file("family.dfont")
226
+ # info = collection.collection_info(io, "family.dfont")
227
+ # puts "Format: #{info.collection_format}"
228
+ # end
229
+ def collection_info(io, path)
230
+ require_relative "models/collection_info"
231
+ require_relative "models/table_sharing_info"
232
+
233
+ # Calculate table sharing statistics
234
+ table_sharing = calculate_table_sharing(io)
235
+
236
+ # Get file size
237
+ file_size = path ? File.size(path) : 0
238
+
239
+ Models::CollectionInfo.new(
240
+ collection_path: path,
241
+ collection_format: self.class.collection_format,
242
+ ttc_tag: "dfnt", # dfont doesn't use ttcf tag
243
+ major_version: 0, # dfont doesn't have version
244
+ minor_version: 0,
245
+ num_fonts: @num_fonts,
246
+ font_offsets: font_offsets,
247
+ file_size_bytes: file_size,
248
+ table_sharing: table_sharing,
249
+ )
250
+ end
251
+
252
+ private
253
+
254
+ # Calculate table sharing statistics
255
+ #
256
+ # Analyzes which tables are shared between fonts and calculates
257
+ # space savings from deduplication.
258
+ #
259
+ # @param io [IO] Open file handle
260
+ # @return [Models::TableSharingInfo] Sharing statistics
261
+ def calculate_table_sharing(io)
262
+ # Extract all fonts
263
+ fonts = extract_fonts(io)
264
+
265
+ # Use shared logic for calculation
266
+ calculate_table_sharing_for_fonts(fonts)
267
+ end
268
+ end
269
+ end