fontisan 0.2.5 → 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.
@@ -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,185 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "parsers/dfont_parser"
4
+ require_relative "error"
5
+
6
+ module Fontisan
7
+ # DfontCollection represents an Apple dfont suitcase containing multiple fonts
8
+ #
9
+ # dfont (Data Fork Font) is an Apple-specific format that stores Mac font
10
+ # suitcase resources in the data fork. It can contain multiple SFNT fonts
11
+ # (TrueType or OpenType).
12
+ #
13
+ # This class provides a collection interface similar to TrueTypeCollection
14
+ # and OpenTypeCollection for consistency.
15
+ #
16
+ # @example Load dfont collection
17
+ # collection = DfontCollection.from_file("family.dfont")
18
+ # puts "Collection has #{collection.num_fonts} fonts"
19
+ #
20
+ # @example Extract fonts from dfont
21
+ # File.open("family.dfont", "rb") do |io|
22
+ # fonts = collection.extract_fonts(io)
23
+ # fonts.each { |font| puts font.class.name }
24
+ # end
25
+ class DfontCollection
26
+ # Path to dfont file
27
+ # @return [String]
28
+ attr_reader :path
29
+
30
+ # Number of fonts in collection
31
+ # @return [Integer]
32
+ attr_reader :num_fonts
33
+ alias font_count num_fonts
34
+
35
+ # Load dfont collection from file
36
+ #
37
+ # @param path [String] Path to dfont file
38
+ # @return [DfontCollection] Collection object
39
+ # @raise [InvalidFontError] if not valid dfont
40
+ def self.from_file(path)
41
+ File.open(path, "rb") do |io|
42
+ unless Parsers::DfontParser.dfont?(io)
43
+ raise InvalidFontError, "Not a valid dfont file: #{path}"
44
+ end
45
+
46
+ num_fonts = Parsers::DfontParser.sfnt_count(io)
47
+ new(path, num_fonts)
48
+ end
49
+ end
50
+
51
+ # Initialize collection
52
+ #
53
+ # @param path [String] Path to dfont file
54
+ # @param num_fonts [Integer] Number of fonts
55
+ # @api private
56
+ def initialize(path, num_fonts)
57
+ @path = path
58
+ @num_fonts = num_fonts
59
+ end
60
+
61
+ # Check if collection is valid
62
+ #
63
+ # @return [Boolean] true if valid
64
+ def valid?
65
+ File.exist?(@path) && @num_fonts.positive?
66
+ end
67
+
68
+ # Extract all fonts from dfont
69
+ #
70
+ # @param io [IO] Open file handle
71
+ # @return [Array<TrueTypeFont, OpenTypeFont>] Array of fonts
72
+ def extract_fonts(io)
73
+ require "stringio"
74
+
75
+ fonts = []
76
+
77
+ @num_fonts.times do |index|
78
+ io.rewind
79
+ sfnt_data = Parsers::DfontParser.extract_sfnt(io, index: index)
80
+
81
+ # Load font from SFNT binary
82
+ sfnt_io = StringIO.new(sfnt_data)
83
+ signature = sfnt_io.read(4)
84
+ sfnt_io.rewind
85
+
86
+ # Create font based on signature
87
+ font = case signature
88
+ when [Constants::SFNT_VERSION_TRUETYPE].pack("N"), "true"
89
+ TrueTypeFont.read(sfnt_io)
90
+ when "OTTO"
91
+ OpenTypeFont.read(sfnt_io)
92
+ else
93
+ raise InvalidFontError,
94
+ "Invalid SFNT signature in dfont at index #{index}: #{signature.inspect}"
95
+ end
96
+
97
+ font.initialize_storage
98
+ font.loading_mode = LoadingModes::FULL
99
+ font.lazy_load_enabled = false
100
+ font.read_table_data(sfnt_io)
101
+
102
+ fonts << font
103
+ end
104
+
105
+ fonts
106
+ end
107
+
108
+ # List fonts in collection (brief info)
109
+ #
110
+ # @param io [IO] Open file handle
111
+ # @return [Models::CollectionListInfo] Collection list info
112
+ def list_fonts(io)
113
+ require_relative "models/collection_list_info"
114
+ require_relative "models/collection_font_summary"
115
+
116
+ fonts = extract_fonts(io)
117
+
118
+ summaries = fonts.map.with_index do |font, index|
119
+ name_table = font.table("name")
120
+ family = name_table.english_name(Models::Tables::Name::FAMILY) || "Unknown"
121
+ subfamily = name_table.english_name(Models::Tables::Name::SUBFAMILY) || "Regular"
122
+
123
+ # Detect font format
124
+ format = if font.has_table?("CFF ") || font.has_table?("CFF2")
125
+ "OpenType"
126
+ else
127
+ "TrueType"
128
+ end
129
+
130
+ Models::CollectionFontSummary.new(
131
+ index: index,
132
+ family_name: family,
133
+ subfamily_name: subfamily,
134
+ font_format: format,
135
+ )
136
+ end
137
+
138
+ Models::CollectionListInfo.new(
139
+ num_fonts: @num_fonts,
140
+ fonts: summaries,
141
+ )
142
+ end
143
+
144
+ # Get specific font from collection
145
+ #
146
+ # @param index [Integer] Font index
147
+ # @param io [IO] Open file handle
148
+ # @param mode [Symbol] Loading mode
149
+ # @return [TrueTypeFont, OpenTypeFont] Font object
150
+ # @raise [InvalidFontError] if index out of range
151
+ def font(index, io, mode: LoadingModes::FULL)
152
+ if index >= @num_fonts
153
+ raise InvalidFontError,
154
+ "Font index #{index} out of range (collection has #{@num_fonts} fonts)"
155
+ end
156
+
157
+ io.rewind
158
+ sfnt_data = Parsers::DfontParser.extract_sfnt(io, index: index)
159
+
160
+ # Load font from SFNT binary
161
+ require "stringio"
162
+ sfnt_io = StringIO.new(sfnt_data)
163
+ signature = sfnt_io.read(4)
164
+ sfnt_io.rewind
165
+
166
+ # Create font based on signature
167
+ font = case signature
168
+ when [Constants::SFNT_VERSION_TRUETYPE].pack("N"), "true"
169
+ TrueTypeFont.read(sfnt_io)
170
+ when "OTTO"
171
+ OpenTypeFont.read(sfnt_io)
172
+ else
173
+ raise InvalidFontError,
174
+ "Invalid SFNT signature: #{signature.inspect}"
175
+ end
176
+
177
+ font.initialize_storage
178
+ font.loading_mode = mode
179
+ font.lazy_load_enabled = false
180
+ font.read_table_data(sfnt_io)
181
+
182
+ font
183
+ end
184
+ end
185
+ end