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.
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "bindata"
4
4
  require_relative "constants"
5
+ require_relative "collection/shared_logic"
5
6
 
6
7
  module Fontisan
7
8
  # Abstract base class for font collections (TTC/OTC)
@@ -29,6 +30,8 @@ module Fontisan
29
30
  # end
30
31
  # end
31
32
  class BaseCollection < BinData::Record
33
+ include Collection::SharedLogic
34
+
32
35
  endian :big
33
36
 
34
37
  string :tag, length: 4, assert: "ttcf"
@@ -251,8 +254,6 @@ module Fontisan
251
254
  # @param io [IO] Open file handle
252
255
  # @return [TableSharingInfo] Sharing statistics
253
256
  def calculate_table_sharing(io)
254
- require_relative "models/table_sharing_info"
255
-
256
257
  font_class = self.class.font_class
257
258
 
258
259
  # Extract all fonts
@@ -260,37 +261,8 @@ module Fontisan
260
261
  font_class.from_collection(io, offset)
261
262
  end
262
263
 
263
- # Build table hash map (checksum -> size)
264
- table_map = {}
265
- total_table_size = 0
266
-
267
- fonts.each do |font|
268
- font.tables.each do |entry|
269
- key = entry.checksum
270
- size = entry.table_length
271
- table_map[key] ||= size
272
- total_table_size += size
273
- end
274
- end
275
-
276
- # Count unique vs shared
277
- unique_tables = table_map.size
278
- total_tables = fonts.sum { |f| f.tables.length }
279
- shared_tables = total_tables - unique_tables
280
-
281
- # Calculate space saved
282
- unique_size = table_map.values.sum
283
- space_saved = total_table_size - unique_size
284
-
285
- # Calculate sharing percentage
286
- sharing_pct = total_tables.positive? ? (shared_tables.to_f / total_tables * 100).round(2) : 0.0
287
-
288
- Models::TableSharingInfo.new(
289
- shared_tables: shared_tables,
290
- unique_tables: unique_tables,
291
- sharing_percentage: sharing_pct,
292
- space_saved_bytes: space_saved,
293
- )
264
+ # Use shared logic for calculation
265
+ calculate_table_sharing_for_fonts(fonts)
294
266
  end
295
267
  end
296
268
  end
data/lib/fontisan/cli.rb CHANGED
@@ -220,6 +220,8 @@ module Fontisan
220
220
  desc: "Skip output validation"
221
221
  option :preserve_hints, type: :boolean, default: false,
222
222
  desc: "Preserve rendering hints during conversion (TTF→OTF preservations may be limited)"
223
+ option :target_format, type: :string, default: "preserve",
224
+ desc: "Target outline format for collections: preserve (default), ttf, or otf"
223
225
  option :wght, type: :numeric,
224
226
  desc: "Weight axis value (alternative to --coordinates)"
225
227
  option :wdth, type: :numeric,
@@ -236,6 +238,12 @@ module Fontisan
236
238
  # - TTF ↔ OTF: Outline format conversion
237
239
  # - WOFF/WOFF2: Web font packaging
238
240
  # - Variable fonts: Automatic variation preservation or instance generation
241
+ # - Collections (TTC/OTC/dfont): Preserve mixed TTF+OTF by default, or standardize with --target-format
242
+ #
243
+ # Collection Format Support:
244
+ # TTC, OTC, and dfont all support mixed TrueType and OpenType fonts. By default, original font formats
245
+ # are preserved during collection conversion (--target-format preserve). Use --target-format ttf to
246
+ # convert all fonts to TrueType, or --target-format otf to convert all to OpenType/CFF.
239
247
  #
240
248
  # Variable Font Operations:
241
249
  # The pipeline automatically detects whether variation data can be preserved based on
@@ -253,6 +261,15 @@ module Fontisan
253
261
  # @example Convert TTF to OTF
254
262
  # fontisan convert font.ttf --to otf --output font.otf
255
263
  #
264
+ # @example Convert TTC to OTC (preserves mixed formats by default)
265
+ # fontisan convert family.ttc --to otc --output family.otc
266
+ #
267
+ # @example Convert TTC to OTC with all fonts as TrueType
268
+ # fontisan convert family.ttc --to otc --output family.otc --target-format ttf
269
+ #
270
+ # @example Convert TTC to OTC with all fonts as OpenType/CFF
271
+ # fontisan convert family.ttc --to otc --output family.otc --target-format otf
272
+ #
256
273
  # @example Generate bold instance at specific coordinates
257
274
  # fontisan convert variable.ttf --to ttf --output bold.ttf --coordinates "wght=700,wdth=100"
258
275
  #
@@ -493,7 +510,7 @@ module Fontisan
493
510
  desc: "Output collection file path",
494
511
  aliases: "-o"
495
512
  option :format, type: :string, default: "ttc",
496
- desc: "Collection format (ttc, otc)",
513
+ desc: "Collection format (ttc, otc, dfont)",
497
514
  aliases: "-f"
498
515
  option :optimize, type: :boolean, default: true,
499
516
  desc: "Enable table sharing optimization",
@@ -501,10 +518,10 @@ module Fontisan
501
518
  option :analyze, type: :boolean, default: false,
502
519
  desc: "Show analysis report before building",
503
520
  aliases: "--analyze"
504
- # Create a TTC (TrueType Collection) or OTC (OpenType Collection) from multiple font files.
521
+ # Create a TTC (TrueType Collection), OTC (OpenType Collection), or dfont (Apple Data Fork Font) from multiple font files.
505
522
  #
506
523
  # This command combines multiple fonts into a single collection file with
507
- # shared table deduplication to save space. It supports both TTC and OTC formats.
524
+ # shared table deduplication to save space. It supports TTC, OTC, and dfont formats.
508
525
  #
509
526
  # @param font_files [Array<String>] Paths to input font files (minimum 2 required)
510
527
  #
@@ -514,6 +531,9 @@ module Fontisan
514
531
  # @example Pack into OTC with analysis
515
532
  # fontisan pack Regular.otf Bold.otf Italic.otf --output family.otc --analyze
516
533
  #
534
+ # @example Pack into dfont (Apple suitcase)
535
+ # fontisan pack font1.ttf font2.otf --output family.dfont --format dfont
536
+ #
517
537
  # @example Pack without optimization
518
538
  # fontisan pack font1.ttf font2.ttf --output collection.ttc --no-optimize
519
539
  def pack(*font_files)
@@ -522,13 +542,13 @@ module Fontisan
522
542
 
523
543
  unless options[:quiet]
524
544
  puts "Collection created successfully:"
525
- puts " Output: #{result[:output]}"
545
+ puts " Output: #{result[:output_path] || result[:output]}"
526
546
  puts " Format: #{result[:format].upcase}"
527
547
  puts " Fonts: #{result[:num_fonts]}"
528
- puts " Size: #{format_size(result[:output_size])}"
529
- if result[:space_savings].positive?
548
+ puts " Size: #{format_size(result[:output_size] || result[:total_size])}"
549
+ if result[:space_savings] && result[:space_savings].positive?
530
550
  puts " Space saved: #{format_size(result[:space_savings])}"
531
- puts " Sharing: #{result[:sharing_percentage]}%"
551
+ puts " Sharing: #{result[:statistics][:sharing_percentage]}%"
532
552
  end
533
553
  end
534
554
  rescue Errno::ENOENT, Error => e
@@ -0,0 +1,315 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../font_writer"
4
+ require_relative "../error"
5
+ require_relative "../validation/collection_validator"
6
+
7
+ module Fontisan
8
+ module Collection
9
+ # DfontBuilder creates Apple dfont (Data Fork Font) resource fork structures
10
+ #
11
+ # Main responsibility: Build complete dfont binary from multiple fonts
12
+ # by creating a resource fork structure containing 'sfnt' resources.
13
+ #
14
+ # dfont is an Apple-specific format that stores Mac font suitcase resources
15
+ # in the data fork instead of the resource fork. It can contain multiple fonts.
16
+ #
17
+ # @example Build dfont from multiple fonts
18
+ # builder = DfontBuilder.new([font1, font2, font3])
19
+ # binary = builder.build
20
+ #
21
+ # @example Write directly to file
22
+ # builder = DfontBuilder.new([font1, font2])
23
+ # builder.build_to_file("family.dfont")
24
+ class DfontBuilder
25
+ # Source fonts
26
+ # @return [Array<TrueTypeFont, OpenTypeFont>]
27
+ attr_reader :fonts
28
+
29
+ # Build result (populated after build)
30
+ # @return [Hash, nil]
31
+ attr_reader :result
32
+
33
+ # Constants for resource reference packing
34
+ NO_NAME_OFFSET = [-1].freeze
35
+ ZERO_ATTRIBUTES = [0].freeze
36
+ ZERO_RESERVED = [0].freeze
37
+
38
+ # Initialize builder with fonts
39
+ #
40
+ # @param fonts [Array<TrueTypeFont, OpenTypeFont>] Fonts to pack into dfont
41
+ # @param options [Hash] Builder options
42
+ # @raise [ArgumentError] if fonts array is invalid
43
+ def initialize(fonts, options = {})
44
+ if fonts.nil? || fonts.empty?
45
+ raise ArgumentError, "fonts cannot be nil or empty"
46
+ end
47
+ raise ArgumentError, "fonts must be an array" unless fonts.is_a?(Array)
48
+
49
+ unless fonts.all? { |f| f.respond_to?(:table_data) }
50
+ raise ArgumentError, "all fonts must respond to table_data"
51
+ end
52
+
53
+ @fonts = fonts
54
+ @options = options
55
+ @result = nil
56
+
57
+ validate_fonts!
58
+ end
59
+
60
+ # Build dfont and return binary
61
+ #
62
+ # Executes the complete dfont creation process:
63
+ # 1. Serialize each font to SFNT binary
64
+ # 2. Build resource data section
65
+ # 3. Calculate resource map size
66
+ # 4. Build resource map
67
+ # 5. Build resource fork header
68
+ # 6. Assemble complete dfont binary
69
+ #
70
+ # @return [Hash] Build result with:
71
+ # - :binary [String] - Complete dfont binary
72
+ # - :num_fonts [Integer] - Number of fonts packed
73
+ # - :total_size [Integer] - Total size in bytes
74
+ def build
75
+ # Step 1: Serialize all fonts to SFNT binaries
76
+ sfnt_binaries = serialize_fonts
77
+
78
+ # Step 2: Build resource data section
79
+ resource_data = build_resource_data(sfnt_binaries)
80
+
81
+ # Step 3: Calculate expected resource map size
82
+ # Map header: 28 bytes
83
+ # Type list header: 2 bytes
84
+ # Type entry: 8 bytes
85
+ # References: 12 bytes each
86
+ map_size = 28 + 2 + 8 + (sfnt_binaries.size * 12)
87
+
88
+ # Step 4: Build resource map
89
+ resource_map = build_resource_map(sfnt_binaries, resource_data.bytesize, map_size)
90
+
91
+ # Step 5: Build header
92
+ header = build_header(resource_data.bytesize, resource_map.bytesize)
93
+
94
+ # Step 6: Assemble complete dfont binary
95
+ binary = header + resource_data + resource_map
96
+
97
+ # Store result
98
+ @result = {
99
+ binary: binary,
100
+ num_fonts: @fonts.size,
101
+ total_size: binary.bytesize,
102
+ format: :dfont,
103
+ }
104
+
105
+ @result
106
+ end
107
+
108
+ # Build dfont and write to file
109
+ #
110
+ # @param path [String] Output file path
111
+ # @return [Hash] Build result (same as build method)
112
+ def build_to_file(path)
113
+ result = build
114
+ File.binwrite(path, result[:binary])
115
+ result[:output_path] = path
116
+ result
117
+ end
118
+
119
+ private
120
+
121
+ # Validate fonts can be packed into dfont
122
+ #
123
+ # @return [void]
124
+ # @raise [Error] if validation fails
125
+ def validate_fonts!
126
+ validator = Validation::CollectionValidator.new
127
+ validator.validate!(@fonts, :dfont)
128
+ end
129
+
130
+ # Check if font is a web font
131
+ #
132
+ # @param font [Object] Font object
133
+ # @return [Boolean] true if WOFF or WOFF2
134
+ def web_font?(font)
135
+ font.class.name.include?("Woff")
136
+ end
137
+
138
+ # Serialize all fonts to SFNT binaries
139
+ #
140
+ # @return [Array<String>] Array of SFNT binaries
141
+ def serialize_fonts
142
+ @fonts.map do |font|
143
+ # Get all table data from font
144
+ tables_hash = font.table_data
145
+
146
+ # Determine sfnt version from font
147
+ sfnt_version = detect_sfnt_version(font)
148
+
149
+ # Write font to binary using FontWriter
150
+ FontWriter.write_font(tables_hash, sfnt_version: sfnt_version)
151
+ end
152
+ end
153
+
154
+ # Detect sfnt version from font
155
+ #
156
+ # @param font [TrueTypeFont, OpenTypeFont] Font object
157
+ # @return [Integer] sfnt version
158
+ def detect_sfnt_version(font)
159
+ if font.respond_to?(:header) && font.header.respond_to?(:sfnt_version)
160
+ font.header.sfnt_version
161
+ elsif font.respond_to?(:table_data)
162
+ # Auto-detect from tables
163
+ FontWriter.detect_sfnt_version(font.table_data)
164
+ else
165
+ 0x00010000 # Default to TrueType
166
+ end
167
+ end
168
+
169
+ # Build resource fork header (16 bytes)
170
+ #
171
+ # @param data_length [Integer] Length of resource data section
172
+ # @param map_length [Integer] Length of resource map section
173
+ # @return [String] Binary header
174
+ def build_header(data_length, map_length)
175
+ # Resource fork header structure:
176
+ # - resource_data_offset (4 bytes): always 256 (0x100) for dfont
177
+ # - resource_map_offset (4 bytes): header_size + data_length
178
+ # - resource_data_length (4 bytes)
179
+ # - resource_map_length (4 bytes)
180
+
181
+ data_offset = 256 # Standard dfont offset (conceptual, points to useful data)
182
+ header_size = 16 # Actual header size in file
183
+ map_offset = header_size + data_length # Map comes after header + data
184
+
185
+ [
186
+ data_offset,
187
+ map_offset,
188
+ data_length,
189
+ map_length,
190
+ ].pack("N4")
191
+ end
192
+
193
+ # Build resource data section
194
+ #
195
+ # Contains all SFNT resources with 4-byte length prefix for each.
196
+ #
197
+ # @param sfnt_binaries [Array<String>] SFNT binaries
198
+ # @return [String] Resource data section binary
199
+ def build_resource_data(sfnt_binaries)
200
+ data = String.new(encoding: Encoding::BINARY)
201
+
202
+ # Add padding to reach offset 256 (header is 16 bytes, pad 240 bytes)
203
+ data << ("\0" * 240)
204
+
205
+ # Write each SFNT resource with length prefix
206
+ sfnt_binaries.each do |sfnt|
207
+ # 4-byte length prefix (big-endian)
208
+ data << [sfnt.bytesize].pack("N")
209
+ # SFNT data
210
+ data << sfnt
211
+ end
212
+
213
+ data
214
+ end
215
+
216
+ # Build resource map section
217
+ #
218
+ # Contains type list and reference list for all 'sfnt' resources.
219
+ #
220
+ # @param sfnt_binaries [Array<String>] SFNT binaries for offset calculation
221
+ # @param data_length [Integer] Actual resource data section length
222
+ # @param map_length [Integer] Actual resource map section length
223
+ # @return [String] Resource map section binary
224
+ def build_resource_map(sfnt_binaries, data_length, map_length)
225
+ map = String.new(encoding: Encoding::BINARY)
226
+
227
+ # Calculate offsets for header copy (must match main header)
228
+ data_offset = 256 # Standard dfont offset (conceptual)
229
+ header_size = 16 # Actual header size in file
230
+ map_offset = header_size + data_length # Map comes after header + data
231
+
232
+ # Resource map header (28 bytes):
233
+ # - Copy of resource header (16 bytes)
234
+ # - Handle to next resource map (4 bytes) - set to 0
235
+ # - File reference number (2 bytes) - set to 0
236
+ # - Resource file attributes (2 bytes) - set to 0
237
+ # - Offset to type list (2 bytes) - set to 28
238
+ # - Offset to name list (2 bytes) - calculated later
239
+
240
+ # Copy of resource header (must match main header exactly)
241
+ map << [data_offset, map_offset, data_length, map_length].pack("N4")
242
+
243
+ # Reserved fields
244
+ map << [0, 0, 0].pack("N n n")
245
+
246
+ # Offset to type list (from start of map)
247
+ map << [28].pack("n")
248
+
249
+ # Offset to name list (we don't use names, so point past all data)
250
+ type_list_size = 2 + 8 + (sfnt_binaries.size * 12) # type count + type entry + references
251
+ name_list_offset = 28 + type_list_size
252
+ map << [name_list_offset].pack("n")
253
+
254
+ # Type list:
255
+ # - Number of types - 1 (2 bytes) - we have 1 type ('sfnt')
256
+ # - Type entries (8 bytes each)
257
+ map << [0].pack("n") # 1 type - 1 = 0
258
+
259
+ # Type entry for 'sfnt':
260
+ # - Resource type (4 bytes): 'sfnt'
261
+ # - Number of resources - 1 (2 bytes)
262
+ # - Offset to reference list (2 bytes): from start of type list
263
+ map << "sfnt"
264
+ map << [sfnt_binaries.size - 1].pack("n")
265
+ reference_list_offset = 2 + 8 # After type count and type entry
266
+ map << [reference_list_offset].pack("n")
267
+
268
+ # Build reference list
269
+ map << build_reference_list(sfnt_binaries)
270
+
271
+ map
272
+ end
273
+
274
+ # Build reference list for 'sfnt' resources
275
+ #
276
+ # @param sfnt_binaries [Array<String>] SFNT binaries
277
+ # @return [String] Reference list binary
278
+ def build_reference_list(sfnt_binaries)
279
+ refs = String.new(encoding: Encoding::BINARY)
280
+
281
+ # Calculate offset for each resource in data section
282
+ # Offsets are relative to the start of the resource data section (not including padding)
283
+ # The resource_data_offset in header points to where resources actually start
284
+ current_offset = 0
285
+
286
+ sfnt_binaries.each_with_index do |sfnt, i|
287
+ # Resource reference (12 bytes):
288
+ # - Resource ID (2 bytes): start from 128
289
+ # - Name offset (2 bytes): -1 (no name)
290
+ # - Attributes (1 byte): 0
291
+ # - Data offset (3 bytes): offset in data section (24-bit big-endian)
292
+ # - Reserved (4 bytes): 0
293
+
294
+ resource_id = 128 + i
295
+ refs << [resource_id].pack("n")
296
+ refs << NO_NAME_OFFSET.pack("n") # No name
297
+ refs << ZERO_ATTRIBUTES.pack("C") # Attributes
298
+
299
+ # Pack 24-bit offset (3 bytes big-endian)
300
+ # Offset is from start of resource data section
301
+ offset_bytes = [current_offset].pack("N")[1..3]
302
+ refs << offset_bytes
303
+
304
+ refs << ZERO_RESERVED.pack("N") # Reserved
305
+
306
+ # Update offset for next resource
307
+ # Each resource has: 4-byte length + SFNT data
308
+ current_offset += 4 + sfnt.bytesize
309
+ end
310
+
311
+ refs
312
+ end
313
+ end
314
+ end
315
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Collection
5
+ # Shared logic for font collection classes
6
+ #
7
+ # This module provides common functionality for all collection types
8
+ # (TTC, OTC, dfont) to maintain DRY principles.
9
+ module SharedLogic
10
+ # Calculate table sharing statistics
11
+ #
12
+ # Analyzes which tables are shared between fonts and calculates
13
+ # space savings from deduplication.
14
+ #
15
+ # @param fonts [Array<TrueTypeFont, OpenTypeFont>] Array of fonts
16
+ # @return [Models::TableSharingInfo] Sharing statistics
17
+ def calculate_table_sharing_for_fonts(fonts)
18
+ require_relative "../models/table_sharing_info"
19
+
20
+ # Build table hash map (checksum -> size)
21
+ table_map = {}
22
+ total_table_size = 0
23
+
24
+ fonts.each do |font|
25
+ font.tables.each do |entry|
26
+ key = entry.checksum
27
+ size = entry.table_length
28
+ table_map[key] ||= size
29
+ total_table_size += size
30
+ end
31
+ end
32
+
33
+ # Count unique vs shared
34
+ unique_tables = table_map.size
35
+ total_tables = fonts.sum { |f| f.tables.length }
36
+ shared_tables = total_tables - unique_tables
37
+
38
+ # Calculate space saved
39
+ unique_size = table_map.values.sum
40
+ space_saved = total_table_size - unique_size
41
+
42
+ # Calculate sharing percentage
43
+ sharing_pct = total_tables.positive? ? (shared_tables.to_f / total_tables * 100).round(2) : 0.0
44
+
45
+ Models::TableSharingInfo.new(
46
+ shared_tables: shared_tables,
47
+ unique_tables: unique_tables,
48
+ sharing_percentage: sharing_pct,
49
+ space_saved_bytes: space_saved,
50
+ )
51
+ end
52
+ end
53
+ end
54
+ end
@@ -2,6 +2,8 @@
2
2
 
3
3
  require_relative "base_command"
4
4
  require_relative "../pipeline/transformation_pipeline"
5
+ require_relative "../converters/collection_converter"
6
+ require_relative "../font_loader"
5
7
 
6
8
  module Fontisan
7
9
  module Commands
@@ -47,6 +49,7 @@ module Fontisan
47
49
  # @option options [Integer] :instance_index Named instance index
48
50
  # @option options [Boolean] :preserve_variation Preserve variation data (default: auto)
49
51
  # @option options [Boolean] :preserve_hints Preserve rendering hints (default: false)
52
+ # @option options [String] :target_format Target outline format for collections: 'preserve' (default), 'ttf', or 'otf'
50
53
  # @option options [Boolean] :no_validate Skip output validation
51
54
  # @option options [Boolean] :verbose Verbose output
52
55
  def initialize(font_path, options = {})
@@ -70,6 +73,7 @@ module Fontisan
70
73
  @instance_index = opts[:instance_index]
71
74
  @preserve_variation = opts[:preserve_variation]
72
75
  @preserve_hints = opts.fetch(:preserve_hints, false)
76
+ @collection_target_format = opts.fetch(:target_format, 'preserve').to_s
73
77
  @validate = !opts[:no_validate]
74
78
  end
75
79
 
@@ -81,6 +85,35 @@ module Fontisan
81
85
  def run
82
86
  validate_options!
83
87
 
88
+ # Check if input is a collection
89
+ if collection_file?
90
+ convert_collection
91
+ else
92
+ convert_single_font
93
+ end
94
+ rescue ArgumentError
95
+ # Let ArgumentError propagate for validation errors
96
+ raise
97
+ rescue StandardError => e
98
+ raise Error, "Conversion failed: #{e.message}"
99
+ end
100
+
101
+ private
102
+
103
+ # Check if input file is a collection
104
+ #
105
+ # @return [Boolean] true if collection
106
+ def collection_file?
107
+ FontLoader.collection?(font_path)
108
+ rescue StandardError
109
+ # If detection fails, assume single font
110
+ false
111
+ end
112
+
113
+ # Convert a single font (original implementation)
114
+ #
115
+ # @return [Hash] Result information
116
+ def convert_single_font
84
117
  puts "Converting #{File.basename(font_path)} to #{@target_format}..." unless @options[:quiet]
85
118
 
86
119
  # Build pipeline options
@@ -137,14 +170,86 @@ module Fontisan
137
170
  output_size: File.size(@output_path),
138
171
  variation_strategy: result[:details][:variation_strategy],
139
172
  }
140
- rescue ArgumentError
141
- # Let ArgumentError propagate for validation errors
142
- raise
143
- rescue StandardError => e
144
- raise Error, "Conversion failed: #{e.message}"
145
173
  end
146
174
 
147
- private
175
+ # Convert a collection
176
+ #
177
+ # @return [Hash] Result information
178
+ def convert_collection
179
+ # Determine target collection type from target format
180
+ target_type = collection_type_from_format(@target_format)
181
+
182
+ unless target_type
183
+ raise ArgumentError,
184
+ "Target format #{@target_format} is not a collection format. " \
185
+ "Use ttc, otc, or dfont for collection conversion."
186
+ end
187
+
188
+ puts "Converting collection to #{target_type.to_s.upcase}..." unless @options[:quiet]
189
+
190
+ # Use CollectionConverter
191
+ converter = Converters::CollectionConverter.new
192
+ result = converter.convert(
193
+ font_path,
194
+ target_type: target_type,
195
+ options: {
196
+ output: @output_path,
197
+ target_format: @collection_target_format,
198
+ verbose: @options[:verbose],
199
+ }
200
+ )
201
+
202
+ # Display results
203
+ unless @options[:quiet]
204
+ output_size = File.size(@output_path)
205
+ input_size = File.size(font_path)
206
+
207
+ puts "Conversion complete!"
208
+ puts " Input: #{font_path} (#{format_size(input_size)})"
209
+ puts " Output: #{@output_path} (#{format_size(output_size)})"
210
+ puts " Format: #{result[:source_type].to_s.upcase} → #{result[:target_type].to_s.upcase}"
211
+ puts " Fonts: #{result[:num_fonts]}"
212
+ end
213
+
214
+ {
215
+ success: true,
216
+ input_path: font_path,
217
+ output_path: @output_path,
218
+ source_format: result[:source_type],
219
+ target_format: result[:target_type],
220
+ input_size: File.size(font_path),
221
+ output_size: File.size(@output_path),
222
+ num_fonts: result[:num_fonts],
223
+ }
224
+ end
225
+
226
+ # Determine collection type from format
227
+ #
228
+ # @param format [Symbol] Target format
229
+ # @return [Symbol, nil] Collection type (:ttc, :otc, :dfont) or nil
230
+ def collection_type_from_format(format)
231
+ case format
232
+ when :ttc
233
+ :ttc
234
+ when :otc
235
+ :otc
236
+ when :dfont
237
+ :dfont
238
+ else
239
+ # Check output extension
240
+ ext = File.extname(@output_path).downcase
241
+ case ext
242
+ when ".ttc"
243
+ :ttc
244
+ when ".otc"
245
+ :otc
246
+ when ".dfont"
247
+ :dfont
248
+ else
249
+ nil
250
+ end
251
+ end
252
+ end
148
253
 
149
254
  # Parse coordinates string to hash
150
255
  #
@@ -194,6 +299,12 @@ module Fontisan
194
299
  :ttf
195
300
  when "otf", "opentype", "cff"
196
301
  :otf
302
+ when "ttc"
303
+ :ttc
304
+ when "otc"
305
+ :otc
306
+ when "dfont"
307
+ :dfont
197
308
  when "svg"
198
309
  :svg
199
310
  when "woff"
@@ -203,7 +314,7 @@ module Fontisan
203
314
  else
204
315
  raise ArgumentError,
205
316
  "Unknown target format: #{format}. " \
206
- "Supported: ttf, otf, svg, woff, woff2"
317
+ "Supported: ttf, otf, ttc, otc, dfont, svg, woff, woff2"
207
318
  end
208
319
  end
209
320