fontisan 0.2.10 → 0.2.12
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 +216 -42
- data/README.adoc +160 -0
- data/lib/fontisan/cli.rb +177 -6
- data/lib/fontisan/collection/table_analyzer.rb +88 -3
- data/lib/fontisan/commands/convert_command.rb +32 -1
- data/lib/fontisan/config/conversion_matrix.yml +132 -4
- data/lib/fontisan/constants.rb +12 -0
- data/lib/fontisan/conversion_options.rb +378 -0
- data/lib/fontisan/converters/cff_table_builder.rb +198 -0
- data/lib/fontisan/converters/collection_converter.rb +45 -10
- data/lib/fontisan/converters/format_converter.rb +2 -0
- data/lib/fontisan/converters/glyf_table_builder.rb +63 -0
- data/lib/fontisan/converters/outline_converter.rb +111 -374
- data/lib/fontisan/converters/outline_extraction.rb +93 -0
- data/lib/fontisan/converters/outline_optimizer.rb +89 -0
- data/lib/fontisan/converters/type1_converter.rb +559 -0
- data/lib/fontisan/font_loader.rb +46 -3
- data/lib/fontisan/glyph_accessor.rb +29 -1
- data/lib/fontisan/type1/afm_generator.rb +436 -0
- data/lib/fontisan/type1/afm_parser.rb +298 -0
- data/lib/fontisan/type1/agl.rb +456 -0
- data/lib/fontisan/type1/charstring_converter.rb +240 -0
- data/lib/fontisan/type1/charstrings.rb +408 -0
- data/lib/fontisan/type1/conversion_options.rb +243 -0
- data/lib/fontisan/type1/decryptor.rb +183 -0
- data/lib/fontisan/type1/encodings.rb +697 -0
- data/lib/fontisan/type1/font_dictionary.rb +514 -0
- data/lib/fontisan/type1/generator.rb +220 -0
- data/lib/fontisan/type1/inf_generator.rb +332 -0
- data/lib/fontisan/type1/pfa_generator.rb +343 -0
- data/lib/fontisan/type1/pfa_parser.rb +158 -0
- data/lib/fontisan/type1/pfb_generator.rb +291 -0
- data/lib/fontisan/type1/pfb_parser.rb +166 -0
- data/lib/fontisan/type1/pfm_generator.rb +610 -0
- data/lib/fontisan/type1/pfm_parser.rb +433 -0
- data/lib/fontisan/type1/private_dict.rb +285 -0
- data/lib/fontisan/type1/ttf_to_type1_converter.rb +327 -0
- data/lib/fontisan/type1/upm_scaler.rb +118 -0
- data/lib/fontisan/type1.rb +73 -0
- data/lib/fontisan/type1_font.rb +331 -0
- data/lib/fontisan/variation/cache.rb +1 -0
- data/lib/fontisan/version.rb +1 -1
- data/lib/fontisan/woff2_font.rb +3 -3
- data/lib/fontisan.rb +2 -0
- metadata +30 -2
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fontisan
|
|
4
|
+
# Conversion options for font format transformations
|
|
5
|
+
#
|
|
6
|
+
# Defines all options for opening (reading) and generating (writing) fonts
|
|
7
|
+
# during conversion operations. Includes validation, defaults, and presets.
|
|
8
|
+
#
|
|
9
|
+
# @example Using options directly
|
|
10
|
+
# options = ConversionOptions.new(
|
|
11
|
+
# from: :ttf,
|
|
12
|
+
# to: :otf,
|
|
13
|
+
# opening: { convert_curves: true, scale_to_1000: true },
|
|
14
|
+
# generating: { hinting_mode: "auto" }
|
|
15
|
+
# )
|
|
16
|
+
#
|
|
17
|
+
# @example Using recommended options
|
|
18
|
+
# options = ConversionOptions.recommended(from: :ttf, to: :otf)
|
|
19
|
+
#
|
|
20
|
+
# @example Using a preset
|
|
21
|
+
# options = ConversionOptions.from_preset(:type1_to_modern)
|
|
22
|
+
class ConversionOptions
|
|
23
|
+
# Opening options (input processing)
|
|
24
|
+
OPENING_OPTIONS = %i[
|
|
25
|
+
decompose_composites
|
|
26
|
+
convert_curves
|
|
27
|
+
scale_to_1000
|
|
28
|
+
scale_from_1000
|
|
29
|
+
autohint
|
|
30
|
+
generate_unicode
|
|
31
|
+
store_custom_tables
|
|
32
|
+
store_native_hinting
|
|
33
|
+
interpret_ot
|
|
34
|
+
read_all_records
|
|
35
|
+
preserve_encoding
|
|
36
|
+
].freeze
|
|
37
|
+
|
|
38
|
+
# Generating options (output processing)
|
|
39
|
+
GENERATING_OPTIONS = %i[
|
|
40
|
+
write_pfm
|
|
41
|
+
write_afm
|
|
42
|
+
write_inf
|
|
43
|
+
select_encoding_automatically
|
|
44
|
+
hinting_mode
|
|
45
|
+
decompose_on_output
|
|
46
|
+
write_custom_tables
|
|
47
|
+
optimize_tables
|
|
48
|
+
reencode_first_256
|
|
49
|
+
encoding_vector
|
|
50
|
+
compression
|
|
51
|
+
transform_tables
|
|
52
|
+
preserve_metadata
|
|
53
|
+
strip_metadata
|
|
54
|
+
target_format
|
|
55
|
+
].freeze
|
|
56
|
+
|
|
57
|
+
# Valid hinting modes
|
|
58
|
+
HINTING_MODES = %w[preserve auto none full].freeze
|
|
59
|
+
|
|
60
|
+
# Valid compression modes
|
|
61
|
+
COMPRESSION_MODES = %w[zlib brotli none].freeze
|
|
62
|
+
|
|
63
|
+
attr_reader :from, :to, :opening, :generating
|
|
64
|
+
|
|
65
|
+
# Initialize conversion options
|
|
66
|
+
#
|
|
67
|
+
# @param from [String, Symbol] Source format
|
|
68
|
+
# @param to [String, Symbol] Target format
|
|
69
|
+
# @param opening [Hash] Opening options
|
|
70
|
+
# @param generating [Hash] Generating options
|
|
71
|
+
def initialize(from:, to:, opening: {}, generating: {})
|
|
72
|
+
@from = self.class.normalize_format(from)
|
|
73
|
+
@to = self.class.normalize_format(to)
|
|
74
|
+
@opening = apply_opening_defaults(opening)
|
|
75
|
+
@generating = apply_generating_defaults(generating)
|
|
76
|
+
validate!
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Get recommended options for a conversion pair
|
|
80
|
+
#
|
|
81
|
+
# @param from [String, Symbol] Source format
|
|
82
|
+
# @param to [String, Symbol] Target format
|
|
83
|
+
# @return [ConversionOptions] Pre-configured options
|
|
84
|
+
def self.recommended(from:, to:)
|
|
85
|
+
from_sym = normalize_format(from)
|
|
86
|
+
to_sym = normalize_format(to)
|
|
87
|
+
|
|
88
|
+
options = RECOMMENDED_OPTIONS.dig(from_sym, to_sym) || {}
|
|
89
|
+
new(
|
|
90
|
+
from: from_sym,
|
|
91
|
+
to: to_sym,
|
|
92
|
+
opening: options[:opening] || {},
|
|
93
|
+
generating: options[:generating] || {},
|
|
94
|
+
)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Load options from a named preset
|
|
98
|
+
#
|
|
99
|
+
# @param preset_name [Symbol, String] Preset name
|
|
100
|
+
# @return [ConversionOptions] Pre-configured options
|
|
101
|
+
def self.from_preset(preset_name)
|
|
102
|
+
preset_key = preset_name.to_sym
|
|
103
|
+
preset = PRESETS.fetch(preset_key) do
|
|
104
|
+
raise ArgumentError, "Unknown preset: #{preset_name}. " \
|
|
105
|
+
"Available: #{PRESETS.keys.join(', ')}"
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
new(
|
|
109
|
+
from: preset[:from],
|
|
110
|
+
to: preset[:to],
|
|
111
|
+
opening: preset[:opening] || {},
|
|
112
|
+
generating: preset[:generating] || {},
|
|
113
|
+
)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Check if an opening option is set
|
|
117
|
+
#
|
|
118
|
+
# @param key [Symbol] Option key
|
|
119
|
+
# @return [Boolean] true if option is truthy
|
|
120
|
+
def opening_option?(key)
|
|
121
|
+
!!@opening[key]
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Check if a generating option has a specific value
|
|
125
|
+
#
|
|
126
|
+
# @param key [Symbol] Option key
|
|
127
|
+
# @param value [Object] Value to check
|
|
128
|
+
# @return [Boolean] true if option equals value
|
|
129
|
+
def generating_option?(key, value = true)
|
|
130
|
+
@generating[key] == value
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Get list of available presets
|
|
134
|
+
#
|
|
135
|
+
# @return [Array<Symbol>] Available preset names
|
|
136
|
+
def self.available_presets
|
|
137
|
+
PRESETS.keys
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
private
|
|
141
|
+
|
|
142
|
+
# Normalize format symbol
|
|
143
|
+
#
|
|
144
|
+
# @param format [String, Symbol] Format identifier
|
|
145
|
+
# @return [Symbol] Normalized format symbol
|
|
146
|
+
def self.normalize_format(format)
|
|
147
|
+
case format.to_s.downcase
|
|
148
|
+
when "ttf", "truetype", "truetype-tt", "ot-tt" then :ttf
|
|
149
|
+
when "otf", "cff", "opentype", "ot-ps", "opentype-ps" then :otf
|
|
150
|
+
when "type1", "type-1", "t1", "pfb", "pfa" then :type1
|
|
151
|
+
when "ttc" then :ttc
|
|
152
|
+
when "otc" then :otc
|
|
153
|
+
when "dfont", "suitcase" then :dfont
|
|
154
|
+
when "woff" then :woff
|
|
155
|
+
when "woff2" then :woff2
|
|
156
|
+
when "svg" then :svg
|
|
157
|
+
else
|
|
158
|
+
raise ArgumentError, "Unknown format: #{format}. " \
|
|
159
|
+
"Supported: ttf, otf, type1, ttc, otc, dfont, woff, woff2, svg"
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Apply default values for opening options
|
|
164
|
+
#
|
|
165
|
+
# @param options [Hash] User-provided options
|
|
166
|
+
# @return [Hash] Options with defaults applied
|
|
167
|
+
def apply_opening_defaults(options)
|
|
168
|
+
defaults = OPENING_DEFAULTS.dig(@from, @to) || {}
|
|
169
|
+
defaults.merge(options)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Apply default values for generating options
|
|
173
|
+
#
|
|
174
|
+
# @param options [Hash] User-provided options
|
|
175
|
+
# @return [Hash] Options with defaults applied
|
|
176
|
+
def apply_generating_defaults(options)
|
|
177
|
+
defaults = GENERATING_DEFAULTS.dig(@from, @to) || {}
|
|
178
|
+
defaults.merge(options)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Validate options
|
|
182
|
+
#
|
|
183
|
+
# @raise [ArgumentError] If options are invalid
|
|
184
|
+
def validate!
|
|
185
|
+
validate_opening_options!
|
|
186
|
+
validate_generating_options!
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Validate opening options
|
|
190
|
+
def validate_opening_options!
|
|
191
|
+
@opening.each_key do |key|
|
|
192
|
+
unless OPENING_OPTIONS.include?(key)
|
|
193
|
+
raise ArgumentError, "Unknown opening option: #{key}. " \
|
|
194
|
+
"Available: #{OPENING_OPTIONS.join(', ')}"
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# Validate generating options
|
|
200
|
+
def validate_generating_options!
|
|
201
|
+
@generating.each_key do |key|
|
|
202
|
+
unless GENERATING_OPTIONS.include?(key)
|
|
203
|
+
raise ArgumentError, "Unknown generating option: #{key}. " \
|
|
204
|
+
"Available: #{GENERATING_OPTIONS.join(', ')}"
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# Validate hinting_mode
|
|
209
|
+
if @generating[:hinting_mode]
|
|
210
|
+
mode = @generating[:hinting_mode].to_s
|
|
211
|
+
unless HINTING_MODES.include?(mode)
|
|
212
|
+
raise ArgumentError, "Invalid hinting_mode: #{mode}. " \
|
|
213
|
+
"Available: #{HINTING_MODES.join(', ')}"
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# Validate compression mode
|
|
218
|
+
if @generating[:compression]
|
|
219
|
+
comp = @generating[:compression].to_s
|
|
220
|
+
unless COMPRESSION_MODES.include?(comp)
|
|
221
|
+
raise ArgumentError, "Invalid compression: #{comp}. " \
|
|
222
|
+
"Available: #{COMPRESSION_MODES.join(', ')}"
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# Validate target_format for collection conversions
|
|
227
|
+
if @generating[:target_format]
|
|
228
|
+
target = @generating[:target_format].to_s
|
|
229
|
+
unless ["ttf", "otf", "preserve"].include?(target)
|
|
230
|
+
raise ArgumentError, "Invalid target_format: #{target}"
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# Default opening options per conversion pair
|
|
236
|
+
OPENING_DEFAULTS = {
|
|
237
|
+
ttf: {
|
|
238
|
+
ttf: { decompose_composites: false, convert_curves: false,
|
|
239
|
+
store_custom_tables: true },
|
|
240
|
+
otf: { convert_curves: true, scale_to_1000: true,
|
|
241
|
+
autohint: true, decompose_composites: false },
|
|
242
|
+
type1: { convert_curves: true, scale_to_1000: true,
|
|
243
|
+
autohint: true, decompose_composites: false },
|
|
244
|
+
},
|
|
245
|
+
otf: {
|
|
246
|
+
ttf: { convert_curves: true, decompose_composites: false,
|
|
247
|
+
interpret_ot: true },
|
|
248
|
+
otf: { decompose_composites: false, store_custom_tables: true },
|
|
249
|
+
type1: { decompose_composites: false },
|
|
250
|
+
},
|
|
251
|
+
type1: {
|
|
252
|
+
ttf: { decompose_composites: false, generate_unicode: true,
|
|
253
|
+
read_all_records: true },
|
|
254
|
+
otf: { decompose_composites: false, generate_unicode: true,
|
|
255
|
+
read_all_records: true },
|
|
256
|
+
},
|
|
257
|
+
}.freeze
|
|
258
|
+
|
|
259
|
+
# Default generating options per conversion pair
|
|
260
|
+
GENERATING_DEFAULTS = {
|
|
261
|
+
ttf: {
|
|
262
|
+
ttf: { hinting_mode: "preserve", write_custom_tables: true,
|
|
263
|
+
optimize_tables: true },
|
|
264
|
+
otf: { hinting_mode: "auto", decompose_on_output: false,
|
|
265
|
+
write_custom_tables: true },
|
|
266
|
+
type1: { write_pfm: true, write_afm: true, write_inf: true,
|
|
267
|
+
select_encoding_automatically: true, hinting_mode: "auto" },
|
|
268
|
+
},
|
|
269
|
+
otf: {
|
|
270
|
+
ttf: { hinting_mode: "auto", decompose_on_output: false,
|
|
271
|
+
write_custom_tables: true },
|
|
272
|
+
otf: { hinting_mode: "preserve", decompose_on_output: false,
|
|
273
|
+
write_custom_tables: true, optimize_tables: true },
|
|
274
|
+
type1: { write_pfm: true, write_afm: true, write_inf: true,
|
|
275
|
+
select_encoding_automatically: true, hinting_mode: "preserve",
|
|
276
|
+
decompose_on_output: false },
|
|
277
|
+
},
|
|
278
|
+
type1: {
|
|
279
|
+
ttf: { hinting_mode: "auto", decompose_on_output: true },
|
|
280
|
+
otf: { hinting_mode: "preserve", decompose_on_output: true },
|
|
281
|
+
type1: { write_pfm: true, write_afm: true, write_inf: true,
|
|
282
|
+
select_encoding_automatically: true },
|
|
283
|
+
},
|
|
284
|
+
}.freeze
|
|
285
|
+
|
|
286
|
+
# Recommended options from TypeTool 3 manual
|
|
287
|
+
# Based on "Options for Converting Fonts" table (lines 6735-6803)
|
|
288
|
+
RECOMMENDED_OPTIONS = {
|
|
289
|
+
ttf: {
|
|
290
|
+
ttf: {
|
|
291
|
+
opening: { convert_curves: false, scale_to_1000: false,
|
|
292
|
+
decompose_composites: false, autohint: false,
|
|
293
|
+
store_custom_tables: true, store_native_hinting: true },
|
|
294
|
+
generating: { hinting_mode: "full", write_custom_tables: true },
|
|
295
|
+
},
|
|
296
|
+
otf: {
|
|
297
|
+
opening: { convert_curves: true, scale_to_1000: true,
|
|
298
|
+
autohint: true, decompose_composites: false,
|
|
299
|
+
store_custom_tables: true },
|
|
300
|
+
generating: { hinting_mode: "auto", decompose_on_output: true },
|
|
301
|
+
},
|
|
302
|
+
type1: {
|
|
303
|
+
opening: { convert_curves: true, scale_to_1000: true,
|
|
304
|
+
autohint: true, decompose_composites: false,
|
|
305
|
+
store_custom_tables: false },
|
|
306
|
+
generating: { write_pfm: true, write_afm: true, write_inf: true,
|
|
307
|
+
select_encoding_automatically: true },
|
|
308
|
+
},
|
|
309
|
+
},
|
|
310
|
+
otf: {
|
|
311
|
+
ttf: {
|
|
312
|
+
opening: { decompose_composites: false, read_all_records: true,
|
|
313
|
+
interpret_ot: true, store_custom_tables: true,
|
|
314
|
+
store_native_hinting: false },
|
|
315
|
+
generating: { hinting_mode: "full", reencode_first_256: false },
|
|
316
|
+
},
|
|
317
|
+
otf: {
|
|
318
|
+
opening: { decompose_composites: false, store_custom_tables: true },
|
|
319
|
+
generating: { hinting_mode: "none", decompose_on_output: false,
|
|
320
|
+
write_custom_tables: true },
|
|
321
|
+
},
|
|
322
|
+
type1: {
|
|
323
|
+
opening: { decompose_composites: false },
|
|
324
|
+
generating: { write_pfm: true, write_afm: true, write_inf: true,
|
|
325
|
+
select_encoding_automatically: true,
|
|
326
|
+
hinting_mode: "none" },
|
|
327
|
+
},
|
|
328
|
+
},
|
|
329
|
+
type1: {
|
|
330
|
+
ttf: {
|
|
331
|
+
opening: { decompose_composites: false, generate_unicode: true },
|
|
332
|
+
generating: { hinting_mode: "full" },
|
|
333
|
+
},
|
|
334
|
+
otf: {
|
|
335
|
+
opening: { decompose_composites: false, generate_unicode: true },
|
|
336
|
+
generating: { hinting_mode: "none", decompose_on_output: true },
|
|
337
|
+
},
|
|
338
|
+
type1: {
|
|
339
|
+
opening: { decompose_composites: false, generate_unicode: true },
|
|
340
|
+
generating: { write_pfm: true, write_afm: true, write_inf: true,
|
|
341
|
+
select_encoding_automatically: true },
|
|
342
|
+
},
|
|
343
|
+
},
|
|
344
|
+
}.freeze
|
|
345
|
+
|
|
346
|
+
# Named presets for common conversion scenarios
|
|
347
|
+
PRESETS = {
|
|
348
|
+
type1_to_modern: {
|
|
349
|
+
from: :type1,
|
|
350
|
+
to: :otf,
|
|
351
|
+
opening: { generate_unicode: true, decompose_composites: false },
|
|
352
|
+
generating: { hinting_mode: "preserve", decompose_on_output: true },
|
|
353
|
+
},
|
|
354
|
+
modern_to_type1: {
|
|
355
|
+
from: :otf,
|
|
356
|
+
to: :type1,
|
|
357
|
+
opening: { convert_curves: true, scale_to_1000: true,
|
|
358
|
+
autohint: true, decompose_composites: false,
|
|
359
|
+
store_custom_tables: false },
|
|
360
|
+
generating: { write_pfm: true, write_afm: true, write_inf: true,
|
|
361
|
+
select_encoding_automatically: true, hinting_mode: "preserve" },
|
|
362
|
+
},
|
|
363
|
+
web_optimized: {
|
|
364
|
+
from: :otf,
|
|
365
|
+
to: :woff2,
|
|
366
|
+
opening: {},
|
|
367
|
+
generating: { compression: "brotli", transform_tables: true,
|
|
368
|
+
optimize_tables: true, preserve_metadata: true },
|
|
369
|
+
},
|
|
370
|
+
archive_to_modern: {
|
|
371
|
+
from: :ttc,
|
|
372
|
+
to: :otf,
|
|
373
|
+
opening: { convert_curves: true, decompose_composites: false },
|
|
374
|
+
generating: { target_format: "otf", hinting_mode: "preserve" },
|
|
375
|
+
},
|
|
376
|
+
}.freeze
|
|
377
|
+
end
|
|
378
|
+
end
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../tables/cff/index_builder"
|
|
4
|
+
require_relative "../tables/cff/dict_builder"
|
|
5
|
+
|
|
6
|
+
module Fontisan
|
|
7
|
+
module Converters
|
|
8
|
+
# Builds CFF table data from glyph outlines
|
|
9
|
+
#
|
|
10
|
+
# This module handles the construction of complete CFF tables including
|
|
11
|
+
# all INDEX structures (name, Top DICT, String, GlobalSubr, CharStrings, LocalSubr)
|
|
12
|
+
# and the Private DICT.
|
|
13
|
+
#
|
|
14
|
+
# The CFF table structure is:
|
|
15
|
+
# - Header (4 bytes)
|
|
16
|
+
# - Name INDEX
|
|
17
|
+
# - Top DICT INDEX
|
|
18
|
+
# - String INDEX
|
|
19
|
+
# - Global Subr INDEX
|
|
20
|
+
# - CharStrings INDEX
|
|
21
|
+
# - Private DICT (with offset in Top DICT)
|
|
22
|
+
# - Local Subr INDEX (with offset in Private DICT)
|
|
23
|
+
module CffTableBuilder
|
|
24
|
+
# Build complete CFF table from pre-built charstrings
|
|
25
|
+
#
|
|
26
|
+
# @param charstrings [Array<String>] Pre-built CharString data (already optimized if needed)
|
|
27
|
+
# @param local_subrs [Array<String>] Local subroutines from optimization
|
|
28
|
+
# @param font [TrueTypeFont] Source font (for metadata)
|
|
29
|
+
# @return [String] Complete CFF table binary data
|
|
30
|
+
def build_cff_table(charstrings, local_subrs, font)
|
|
31
|
+
# If we have local subrs from optimization, use them
|
|
32
|
+
local_subrs = [] unless local_subrs.is_a?(Array)
|
|
33
|
+
|
|
34
|
+
# Build font metadata
|
|
35
|
+
font_name = extract_font_name(font)
|
|
36
|
+
|
|
37
|
+
# Build all INDEXes
|
|
38
|
+
header_size = 4
|
|
39
|
+
name_index_data = Tables::Cff::IndexBuilder.build([font_name])
|
|
40
|
+
string_index_data = Tables::Cff::IndexBuilder.build([]) # Empty strings
|
|
41
|
+
global_subr_index_data = Tables::Cff::IndexBuilder.build([]) # Empty global subrs
|
|
42
|
+
charstrings_index_data = Tables::Cff::IndexBuilder.build(charstrings)
|
|
43
|
+
local_subrs_index_data = Tables::Cff::IndexBuilder.build(local_subrs)
|
|
44
|
+
|
|
45
|
+
# Build Private DICT with Subrs offset if we have local subroutines
|
|
46
|
+
private_dict_data, private_dict_size = build_private_dict(local_subrs)
|
|
47
|
+
|
|
48
|
+
# Calculate offsets with iterative refinement
|
|
49
|
+
top_dict_index_data, =
|
|
50
|
+
calculate_cff_offsets(
|
|
51
|
+
header_size,
|
|
52
|
+
name_index_data,
|
|
53
|
+
string_index_data,
|
|
54
|
+
global_subr_index_data,
|
|
55
|
+
charstrings_index_data,
|
|
56
|
+
private_dict_size,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
# Build CFF Header
|
|
60
|
+
header = build_cff_header
|
|
61
|
+
|
|
62
|
+
# Assemble complete CFF table
|
|
63
|
+
header +
|
|
64
|
+
name_index_data +
|
|
65
|
+
top_dict_index_data +
|
|
66
|
+
string_index_data +
|
|
67
|
+
global_subr_index_data +
|
|
68
|
+
charstrings_index_data +
|
|
69
|
+
private_dict_data +
|
|
70
|
+
local_subrs_index_data
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
# Build Private DICT with optional Subrs offset
|
|
76
|
+
#
|
|
77
|
+
# @param local_subrs [Array<String>] Local subroutines
|
|
78
|
+
# @return [Array<String, Integer>] [Private DICT data, size]
|
|
79
|
+
def build_private_dict(local_subrs)
|
|
80
|
+
private_dict_hash = {
|
|
81
|
+
default_width_x: 1000,
|
|
82
|
+
nominal_width_x: 0,
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
# If we have local subroutines, add Subrs offset
|
|
86
|
+
# Subrs offset is relative to Private DICT start
|
|
87
|
+
if local_subrs.any?
|
|
88
|
+
# Add a placeholder Subrs offset first to get accurate size
|
|
89
|
+
private_dict_hash[:subrs] = 0
|
|
90
|
+
|
|
91
|
+
# Calculate size of Private DICT with Subrs entry
|
|
92
|
+
temp_private_dict_data = Tables::Cff::DictBuilder.build(private_dict_hash)
|
|
93
|
+
subrs_offset = temp_private_dict_data.bytesize
|
|
94
|
+
|
|
95
|
+
# Update with actual Subrs offset
|
|
96
|
+
private_dict_hash[:subrs] = subrs_offset
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Build final Private DICT
|
|
100
|
+
private_dict_data = Tables::Cff::DictBuilder.build(private_dict_hash)
|
|
101
|
+
[private_dict_data, private_dict_data.bytesize]
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Calculate CFF table offsets with iterative refinement
|
|
105
|
+
#
|
|
106
|
+
# @param header_size [Integer] CFF header size
|
|
107
|
+
# @param name_index_data [String] Name INDEX data
|
|
108
|
+
# @param string_index_data [String] String INDEX data
|
|
109
|
+
# @param global_subr_index_data [String] Global Subr INDEX data
|
|
110
|
+
# @param charstrings_index_data [String] CharStrings INDEX data
|
|
111
|
+
# @param private_dict_size [Integer] Private DICT size
|
|
112
|
+
# @return [Array<String, Integer, Integer>] [Top DICT INDEX, CharStrings offset, Private DICT offset]
|
|
113
|
+
def calculate_cff_offsets(
|
|
114
|
+
header_size,
|
|
115
|
+
name_index_data,
|
|
116
|
+
string_index_data,
|
|
117
|
+
global_subr_index_data,
|
|
118
|
+
charstrings_index_data,
|
|
119
|
+
private_dict_size
|
|
120
|
+
)
|
|
121
|
+
# Initial pass
|
|
122
|
+
top_dict_index_start = header_size + name_index_data.bytesize
|
|
123
|
+
string_index_start = top_dict_index_start + 100 # Approximate
|
|
124
|
+
global_subr_index_start = string_index_start + string_index_data.bytesize
|
|
125
|
+
charstrings_offset = global_subr_index_start + global_subr_index_data.bytesize
|
|
126
|
+
|
|
127
|
+
# Build Top DICT
|
|
128
|
+
top_dict_hash = {
|
|
129
|
+
charset: 0,
|
|
130
|
+
encoding: 0,
|
|
131
|
+
charstrings: charstrings_offset,
|
|
132
|
+
}
|
|
133
|
+
top_dict_data = Tables::Cff::DictBuilder.build(top_dict_hash)
|
|
134
|
+
top_dict_index_data = Tables::Cff::IndexBuilder.build([top_dict_data])
|
|
135
|
+
|
|
136
|
+
# Recalculate with actual Top DICT size
|
|
137
|
+
string_index_start = top_dict_index_start + top_dict_index_data.bytesize
|
|
138
|
+
global_subr_index_start = string_index_start + string_index_data.bytesize
|
|
139
|
+
charstrings_offset = global_subr_index_start + global_subr_index_data.bytesize
|
|
140
|
+
private_dict_offset = charstrings_offset + charstrings_index_data.bytesize
|
|
141
|
+
|
|
142
|
+
# Update Top DICT with Private DICT info
|
|
143
|
+
top_dict_hash = {
|
|
144
|
+
charset: 0,
|
|
145
|
+
encoding: 0,
|
|
146
|
+
charstrings: charstrings_offset,
|
|
147
|
+
private: [private_dict_size, private_dict_offset],
|
|
148
|
+
}
|
|
149
|
+
top_dict_data = Tables::Cff::DictBuilder.build(top_dict_hash)
|
|
150
|
+
top_dict_index_data = Tables::Cff::IndexBuilder.build([top_dict_data])
|
|
151
|
+
|
|
152
|
+
# Final recalculation
|
|
153
|
+
string_index_start = top_dict_index_start + top_dict_index_data.bytesize
|
|
154
|
+
global_subr_index_start = string_index_start + string_index_data.bytesize
|
|
155
|
+
charstrings_offset = global_subr_index_start + global_subr_index_data.bytesize
|
|
156
|
+
private_dict_offset = charstrings_offset + charstrings_index_data.bytesize
|
|
157
|
+
|
|
158
|
+
# Final Top DICT
|
|
159
|
+
top_dict_hash = {
|
|
160
|
+
charset: 0,
|
|
161
|
+
encoding: 0,
|
|
162
|
+
charstrings: charstrings_offset,
|
|
163
|
+
private: [private_dict_size, private_dict_offset],
|
|
164
|
+
}
|
|
165
|
+
top_dict_data = Tables::Cff::DictBuilder.build(top_dict_hash)
|
|
166
|
+
top_dict_index_data = Tables::Cff::IndexBuilder.build([top_dict_data])
|
|
167
|
+
|
|
168
|
+
[top_dict_index_data, charstrings_offset, private_dict_offset]
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Build CFF Header
|
|
172
|
+
#
|
|
173
|
+
# @return [String] 4-byte CFF header
|
|
174
|
+
def build_cff_header
|
|
175
|
+
[
|
|
176
|
+
1, # major version
|
|
177
|
+
0, # minor version
|
|
178
|
+
4, # header size
|
|
179
|
+
4, # offSize (will be in INDEX)
|
|
180
|
+
].pack("C4")
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Extract font name from name table
|
|
184
|
+
#
|
|
185
|
+
# @param font [TrueTypeFont, OpenTypeFont] Font
|
|
186
|
+
# @return [String] Font name
|
|
187
|
+
def extract_font_name(font)
|
|
188
|
+
name_table = font.table("name")
|
|
189
|
+
if name_table
|
|
190
|
+
font_name = name_table.english_name(Tables::Name::FAMILY)
|
|
191
|
+
return font_name.dup.force_encoding("ASCII-8BIT") if font_name
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
"UnnamedFont"
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "../conversion_options"
|
|
3
4
|
require_relative "format_converter"
|
|
4
5
|
require_relative "../collection/builder"
|
|
5
6
|
require_relative "../collection/dfont_builder"
|
|
@@ -27,14 +28,15 @@ module Fontisan
|
|
|
27
28
|
# converter = CollectionConverter.new
|
|
28
29
|
# result = converter.convert(ttc_path, target_type: :otc, output: 'family.otc')
|
|
29
30
|
#
|
|
30
|
-
# @example Convert
|
|
31
|
+
# @example Convert with ConversionOptions
|
|
32
|
+
# options = ConversionOptions.recommended(from: :ttc, to: :otc)
|
|
31
33
|
# converter = CollectionConverter.new
|
|
32
|
-
# result = converter.convert(ttc_path, target_type: :otc,
|
|
33
|
-
# options: { output: 'family.otc', convert_outlines: true })
|
|
34
|
+
# result = converter.convert(ttc_path, target_type: :otc, options: { output: 'family.otc', options: options })
|
|
34
35
|
#
|
|
35
|
-
# @example Convert
|
|
36
|
+
# @example Convert TTC to OTC with outline conversion
|
|
36
37
|
# converter = CollectionConverter.new
|
|
37
|
-
# result = converter.convert(
|
|
38
|
+
# result = converter.convert(ttc_path, target_type: :otc,
|
|
39
|
+
# options: { output: 'family.otc', target_format: 'otf' })
|
|
38
40
|
class CollectionConverter
|
|
39
41
|
# Convert collection to target format
|
|
40
42
|
#
|
|
@@ -45,6 +47,7 @@ module Fontisan
|
|
|
45
47
|
# @option options [String] :target_format Target outline format: 'preserve' (default), 'ttf', or 'otf'
|
|
46
48
|
# @option options [Boolean] :optimize Enable table sharing (default: true, TTC/OTC only)
|
|
47
49
|
# @option options [Boolean] :verbose Enable verbose output (default: false)
|
|
50
|
+
# @option options [ConversionOptions] :options ConversionOptions object
|
|
48
51
|
# @return [Hash] Conversion result with:
|
|
49
52
|
# - :input [String] - Input collection path
|
|
50
53
|
# - :output [String] - Output collection path
|
|
@@ -57,9 +60,19 @@ module Fontisan
|
|
|
57
60
|
def convert(collection_path, target_type:, options: {})
|
|
58
61
|
validate_parameters!(collection_path, target_type, options)
|
|
59
62
|
|
|
63
|
+
# Extract ConversionOptions if provided
|
|
64
|
+
conv_options = extract_conversion_options(options)
|
|
65
|
+
|
|
60
66
|
verbose = options.fetch(:verbose, false)
|
|
61
67
|
output_path = options[:output]
|
|
62
|
-
|
|
68
|
+
|
|
69
|
+
# Determine target format from ConversionOptions or options hash
|
|
70
|
+
target_format = if conv_options&.generating_option?(:target_format,
|
|
71
|
+
"otf")
|
|
72
|
+
conv_options.generating[:target_format]
|
|
73
|
+
else
|
|
74
|
+
options.fetch(:target_format, "preserve").to_s
|
|
75
|
+
end
|
|
63
76
|
|
|
64
77
|
# Validate target_format
|
|
65
78
|
unless %w[preserve ttf otf].include?(target_format)
|
|
@@ -84,7 +97,8 @@ module Fontisan
|
|
|
84
97
|
# Step 2: Convert - transform fonts if requested
|
|
85
98
|
puts " Converting #{fonts.size} font(s)..." if verbose
|
|
86
99
|
converted_fonts, conversions = convert_fonts(fonts, source_type,
|
|
87
|
-
target_type, options.merge(target_format: target_format)
|
|
100
|
+
target_type, options.merge(target_format: target_format),
|
|
101
|
+
conv_options)
|
|
88
102
|
|
|
89
103
|
# Step 3: Repack - build target collection
|
|
90
104
|
puts " Repacking into #{target_type.to_s.upcase} format..." if verbose
|
|
@@ -206,13 +220,20 @@ module Fontisan
|
|
|
206
220
|
# @param source_type [Symbol] Source collection type
|
|
207
221
|
# @param target_type [Symbol] Target collection type
|
|
208
222
|
# @param options [Hash] Conversion options
|
|
223
|
+
# @param conv_options [ConversionOptions, nil] Conversion options object
|
|
209
224
|
# @return [Array<(Array<Font>, Array<Hash>)>] [converted_fonts, conversions]
|
|
210
|
-
def convert_fonts(fonts, _source_type, target_type, options
|
|
225
|
+
def convert_fonts(fonts, _source_type, target_type, options,
|
|
226
|
+
conv_options = nil)
|
|
211
227
|
converted_fonts = []
|
|
212
228
|
conversions = []
|
|
213
229
|
|
|
214
230
|
# Determine if outline conversion is needed
|
|
215
|
-
target_format =
|
|
231
|
+
target_format = if conv_options&.generating_option?(:target_format,
|
|
232
|
+
"otf")
|
|
233
|
+
conv_options.generating[:target_format]
|
|
234
|
+
else
|
|
235
|
+
options.fetch(:target_format, "preserve").to_s
|
|
236
|
+
end
|
|
216
237
|
|
|
217
238
|
fonts.each_with_index do |font, index|
|
|
218
239
|
source_format = detect_font_format(font)
|
|
@@ -224,8 +245,12 @@ module Fontisan
|
|
|
224
245
|
desired_format = target_format == "preserve" ? source_format : target_format.to_sym
|
|
225
246
|
converter = FormatConverter.new
|
|
226
247
|
|
|
248
|
+
# Build converter options, including ConversionOptions if provided
|
|
249
|
+
converter_opts = options.dup
|
|
250
|
+
converter_opts[:options] = conv_options if conv_options
|
|
251
|
+
|
|
227
252
|
begin
|
|
228
|
-
tables = converter.convert(font, desired_format,
|
|
253
|
+
tables = converter.convert(font, desired_format, converter_opts)
|
|
229
254
|
converted_font = build_font_from_tables(tables, desired_format)
|
|
230
255
|
converted_fonts << converted_font
|
|
231
256
|
|
|
@@ -442,6 +467,16 @@ conversions)
|
|
|
442
467
|
|
|
443
468
|
puts ""
|
|
444
469
|
end
|
|
470
|
+
|
|
471
|
+
# Extract ConversionOptions from options hash
|
|
472
|
+
#
|
|
473
|
+
# @param options [Hash, ConversionOptions] Options or hash containing :options key
|
|
474
|
+
# @return [ConversionOptions, nil] Extracted ConversionOptions or nil
|
|
475
|
+
def extract_conversion_options(options)
|
|
476
|
+
return options if options.is_a?(ConversionOptions)
|
|
477
|
+
|
|
478
|
+
options[:options] if options.is_a?(Hash)
|
|
479
|
+
end
|
|
445
480
|
end
|
|
446
481
|
end
|
|
447
482
|
end
|