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.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +216 -42
  3. data/README.adoc +160 -0
  4. data/lib/fontisan/cli.rb +177 -6
  5. data/lib/fontisan/collection/table_analyzer.rb +88 -3
  6. data/lib/fontisan/commands/convert_command.rb +32 -1
  7. data/lib/fontisan/config/conversion_matrix.yml +132 -4
  8. data/lib/fontisan/constants.rb +12 -0
  9. data/lib/fontisan/conversion_options.rb +378 -0
  10. data/lib/fontisan/converters/cff_table_builder.rb +198 -0
  11. data/lib/fontisan/converters/collection_converter.rb +45 -10
  12. data/lib/fontisan/converters/format_converter.rb +2 -0
  13. data/lib/fontisan/converters/glyf_table_builder.rb +63 -0
  14. data/lib/fontisan/converters/outline_converter.rb +111 -374
  15. data/lib/fontisan/converters/outline_extraction.rb +93 -0
  16. data/lib/fontisan/converters/outline_optimizer.rb +89 -0
  17. data/lib/fontisan/converters/type1_converter.rb +559 -0
  18. data/lib/fontisan/font_loader.rb +46 -3
  19. data/lib/fontisan/glyph_accessor.rb +29 -1
  20. data/lib/fontisan/type1/afm_generator.rb +436 -0
  21. data/lib/fontisan/type1/afm_parser.rb +298 -0
  22. data/lib/fontisan/type1/agl.rb +456 -0
  23. data/lib/fontisan/type1/charstring_converter.rb +240 -0
  24. data/lib/fontisan/type1/charstrings.rb +408 -0
  25. data/lib/fontisan/type1/conversion_options.rb +243 -0
  26. data/lib/fontisan/type1/decryptor.rb +183 -0
  27. data/lib/fontisan/type1/encodings.rb +697 -0
  28. data/lib/fontisan/type1/font_dictionary.rb +514 -0
  29. data/lib/fontisan/type1/generator.rb +220 -0
  30. data/lib/fontisan/type1/inf_generator.rb +332 -0
  31. data/lib/fontisan/type1/pfa_generator.rb +343 -0
  32. data/lib/fontisan/type1/pfa_parser.rb +158 -0
  33. data/lib/fontisan/type1/pfb_generator.rb +291 -0
  34. data/lib/fontisan/type1/pfb_parser.rb +166 -0
  35. data/lib/fontisan/type1/pfm_generator.rb +610 -0
  36. data/lib/fontisan/type1/pfm_parser.rb +433 -0
  37. data/lib/fontisan/type1/private_dict.rb +285 -0
  38. data/lib/fontisan/type1/ttf_to_type1_converter.rb +327 -0
  39. data/lib/fontisan/type1/upm_scaler.rb +118 -0
  40. data/lib/fontisan/type1.rb +73 -0
  41. data/lib/fontisan/type1_font.rb +331 -0
  42. data/lib/fontisan/variation/cache.rb +1 -0
  43. data/lib/fontisan/version.rb +1 -1
  44. data/lib/fontisan/woff2_font.rb +3 -3
  45. data/lib/fontisan.rb +2 -0
  46. 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 TTC to OTC with outline conversion
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 dfont to TTC
36
+ # @example Convert TTC to OTC with outline conversion
36
37
  # converter = CollectionConverter.new
37
- # result = converter.convert(dfont_path, target_type: :ttc, output: 'family.ttc')
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
- target_format = options.fetch(:target_format, "preserve").to_s
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 = options.fetch(:target_format, "preserve").to_s
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, options)
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