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.
- checksums.yaml +4 -4
- data/.rubocop_todo.yml +41 -23
- data/README.adoc +230 -860
- data/lib/fontisan/base_collection.rb +5 -33
- data/lib/fontisan/cli.rb +27 -7
- data/lib/fontisan/collection/dfont_builder.rb +315 -0
- data/lib/fontisan/collection/shared_logic.rb +54 -0
- data/lib/fontisan/commands/convert_command.rb +118 -7
- data/lib/fontisan/commands/pack_command.rb +129 -22
- data/lib/fontisan/config/conversion_matrix.yml +175 -1
- data/lib/fontisan/constants.rb +8 -0
- data/lib/fontisan/converters/collection_converter.rb +438 -0
- data/lib/fontisan/dfont_collection.rb +269 -0
- data/lib/fontisan/font_loader.rb +90 -6
- data/lib/fontisan/parsers/dfont_parser.rb +192 -0
- data/lib/fontisan/true_type_font.rb +8 -46
- data/lib/fontisan/validation/collection_validator.rb +265 -0
- data/lib/fontisan/version.rb +1 -1
- metadata +8 -2
|
@@ -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
|