fontisan 0.2.4 → 0.2.6

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.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +168 -32
  3. data/README.adoc +673 -1091
  4. data/lib/fontisan/cli.rb +94 -13
  5. data/lib/fontisan/collection/dfont_builder.rb +315 -0
  6. data/lib/fontisan/commands/convert_command.rb +118 -7
  7. data/lib/fontisan/commands/pack_command.rb +129 -22
  8. data/lib/fontisan/commands/validate_command.rb +107 -151
  9. data/lib/fontisan/config/conversion_matrix.yml +175 -1
  10. data/lib/fontisan/constants.rb +8 -0
  11. data/lib/fontisan/converters/collection_converter.rb +438 -0
  12. data/lib/fontisan/converters/woff2_encoder.rb +7 -29
  13. data/lib/fontisan/dfont_collection.rb +185 -0
  14. data/lib/fontisan/font_loader.rb +91 -6
  15. data/lib/fontisan/models/validation_report.rb +227 -0
  16. data/lib/fontisan/parsers/dfont_parser.rb +192 -0
  17. data/lib/fontisan/pipeline/transformation_pipeline.rb +4 -8
  18. data/lib/fontisan/tables/cmap.rb +82 -2
  19. data/lib/fontisan/tables/glyf.rb +118 -0
  20. data/lib/fontisan/tables/head.rb +60 -0
  21. data/lib/fontisan/tables/hhea.rb +74 -0
  22. data/lib/fontisan/tables/maxp.rb +60 -0
  23. data/lib/fontisan/tables/name.rb +76 -0
  24. data/lib/fontisan/tables/os2.rb +113 -0
  25. data/lib/fontisan/tables/post.rb +57 -0
  26. data/lib/fontisan/true_type_font.rb +8 -46
  27. data/lib/fontisan/validation/collection_validator.rb +265 -0
  28. data/lib/fontisan/validators/basic_validator.rb +85 -0
  29. data/lib/fontisan/validators/font_book_validator.rb +130 -0
  30. data/lib/fontisan/validators/opentype_validator.rb +112 -0
  31. data/lib/fontisan/validators/profile_loader.rb +139 -0
  32. data/lib/fontisan/validators/validator.rb +484 -0
  33. data/lib/fontisan/validators/web_font_validator.rb +102 -0
  34. data/lib/fontisan/version.rb +1 -1
  35. data/lib/fontisan.rb +78 -6
  36. metadata +13 -12
  37. data/lib/fontisan/config/validation_rules.yml +0 -149
  38. data/lib/fontisan/validation/checksum_validator.rb +0 -170
  39. data/lib/fontisan/validation/consistency_validator.rb +0 -197
  40. data/lib/fontisan/validation/structure_validator.rb +0 -198
  41. data/lib/fontisan/validation/table_validator.rb +0 -158
  42. data/lib/fontisan/validation/validator.rb +0 -152
  43. data/lib/fontisan/validation/variable_font_validator.rb +0 -218
  44. data/lib/fontisan/validation/woff2_header_validator.rb +0 -278
  45. data/lib/fontisan/validation/woff2_table_validator.rb +0 -270
  46. data/lib/fontisan/validation/woff2_validator.rb +0 -248
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
  #
@@ -350,13 +367,62 @@ module Fontisan
350
367
  handle_error(e)
351
368
  end
352
369
 
353
- desc "validate FONT_FILE", "Validate font file structure and checksums"
354
- option :verbose, type: :boolean, default: false,
355
- desc: "Show detailed validation information"
370
+ desc "validate FONT_FILE", "Validate font file"
371
+ long_desc <<-DESC
372
+ Validate font file against quality checks and standards.
373
+
374
+ Test lists (-t/--test-list):
375
+ indexability - Fast indexing validation
376
+ usability - Installation compatibility
377
+ production - Comprehensive quality (default)
378
+ web - Web font readiness
379
+ spec_compliance - OpenType spec compliance
380
+ default - Production profile (alias)
381
+
382
+ Return values (with -R/--return-value-results):
383
+ 0 No results
384
+ 1 Execution errors
385
+ 2 Fatal errors found
386
+ 3 Major errors found
387
+ 4 Minor errors found
388
+ 5 Spec violations found
389
+ DESC
390
+
391
+ option :exclude, aliases: "-e", type: :array, desc: "Tests to exclude"
392
+ option :list, aliases: "-l", type: :boolean, desc: "List available tests"
393
+ option :output, aliases: "-o", type: :string, desc: "Output file"
394
+ option :full_report, aliases: "-r", type: :boolean, desc: "Full report"
395
+ option :return_value_results, aliases: "-R", type: :boolean, desc: "Use return value for results"
396
+ option :summary_report, aliases: "-S", type: :boolean, desc: "Summary report"
397
+ option :test_list, aliases: "-t", type: :string, default: "default", desc: "Tests to execute"
398
+ option :table_report, aliases: "-T", type: :boolean, desc: "Tabular report"
399
+ option :verbose, aliases: "-v", type: :boolean, desc: "Verbose output"
400
+ option :suppress_warnings, aliases: "-W", type: :boolean, desc: "Suppress warnings"
401
+
356
402
  def validate(font_file)
357
- command = Commands::ValidateCommand.new(font_file,
358
- verbose: options[:verbose])
359
- exit command.run
403
+ if options[:list]
404
+ list_available_tests
405
+ return
406
+ end
407
+
408
+ cmd = Commands::ValidateCommand.new(
409
+ input: font_file,
410
+ profile: options[:test_list],
411
+ exclude: options[:exclude] || [],
412
+ output: options[:output],
413
+ format: options[:format].to_sym,
414
+ full_report: options[:full_report],
415
+ summary_report: options[:summary_report],
416
+ table_report: options[:table_report],
417
+ verbose: options[:verbose],
418
+ suppress_warnings: options[:suppress_warnings],
419
+ return_value_results: options[:return_value_results]
420
+ )
421
+
422
+ exit cmd.run
423
+ rescue => e
424
+ error "Validation failed: #{e.message}"
425
+ exit 1
360
426
  end
361
427
 
362
428
  desc "export FONT_FILE", "Export font to TTX/YAML/JSON format"
@@ -444,7 +510,7 @@ module Fontisan
444
510
  desc: "Output collection file path",
445
511
  aliases: "-o"
446
512
  option :format, type: :string, default: "ttc",
447
- desc: "Collection format (ttc, otc)",
513
+ desc: "Collection format (ttc, otc, dfont)",
448
514
  aliases: "-f"
449
515
  option :optimize, type: :boolean, default: true,
450
516
  desc: "Enable table sharing optimization",
@@ -452,10 +518,10 @@ module Fontisan
452
518
  option :analyze, type: :boolean, default: false,
453
519
  desc: "Show analysis report before building",
454
520
  aliases: "--analyze"
455
- # 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.
456
522
  #
457
523
  # This command combines multiple fonts into a single collection file with
458
- # 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.
459
525
  #
460
526
  # @param font_files [Array<String>] Paths to input font files (minimum 2 required)
461
527
  #
@@ -465,6 +531,9 @@ module Fontisan
465
531
  # @example Pack into OTC with analysis
466
532
  # fontisan pack Regular.otf Bold.otf Italic.otf --output family.otc --analyze
467
533
  #
534
+ # @example Pack into dfont (Apple suitcase)
535
+ # fontisan pack font1.ttf font2.otf --output family.dfont --format dfont
536
+ #
468
537
  # @example Pack without optimization
469
538
  # fontisan pack font1.ttf font2.ttf --output collection.ttc --no-optimize
470
539
  def pack(*font_files)
@@ -473,13 +542,13 @@ module Fontisan
473
542
 
474
543
  unless options[:quiet]
475
544
  puts "Collection created successfully:"
476
- puts " Output: #{result[:output]}"
545
+ puts " Output: #{result[:output_path] || result[:output]}"
477
546
  puts " Format: #{result[:format].upcase}"
478
547
  puts " Fonts: #{result[:num_fonts]}"
479
- puts " Size: #{format_size(result[:output_size])}"
480
- 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?
481
550
  puts " Space saved: #{format_size(result[:space_savings])}"
482
- puts " Sharing: #{result[:sharing_percentage]}%"
551
+ puts " Sharing: #{result[:statistics][:sharing_percentage]}%"
483
552
  end
484
553
  end
485
554
  rescue Errno::ENOENT, Error => e
@@ -555,5 +624,17 @@ module Fontisan
555
624
  warn message unless options[:quiet]
556
625
  exit 1
557
626
  end
627
+
628
+ # List available validation tests/profiles
629
+ #
630
+ # @return [void]
631
+ def list_available_tests
632
+ require_relative "validators/profile_loader"
633
+ profiles = Validators::ProfileLoader.all_profiles
634
+ puts "Available validation profiles:"
635
+ profiles.each do |profile_name, config|
636
+ puts " #{profile_name.to_s.ljust(20)} - #{config[:description]}"
637
+ end
638
+ end
558
639
  end
559
640
  end
@@ -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
@@ -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