fontisan 0.2.11 → 0.2.13

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 +294 -52
  3. data/Gemfile +5 -0
  4. data/README.adoc +163 -2
  5. data/docs/CONVERSION_GUIDE.adoc +633 -0
  6. data/docs/TYPE1_FONTS.adoc +445 -0
  7. data/lib/fontisan/cli.rb +177 -6
  8. data/lib/fontisan/commands/convert_command.rb +32 -1
  9. data/lib/fontisan/commands/info_command.rb +83 -2
  10. data/lib/fontisan/config/conversion_matrix.yml +132 -4
  11. data/lib/fontisan/constants.rb +12 -0
  12. data/lib/fontisan/conversion_options.rb +378 -0
  13. data/lib/fontisan/converters/collection_converter.rb +45 -10
  14. data/lib/fontisan/converters/format_converter.rb +17 -5
  15. data/lib/fontisan/converters/outline_converter.rb +78 -4
  16. data/lib/fontisan/converters/type1_converter.rb +1234 -0
  17. data/lib/fontisan/font_loader.rb +46 -3
  18. data/lib/fontisan/hints/hint_converter.rb +4 -1
  19. data/lib/fontisan/type1/afm_generator.rb +436 -0
  20. data/lib/fontisan/type1/afm_parser.rb +298 -0
  21. data/lib/fontisan/type1/agl.rb +456 -0
  22. data/lib/fontisan/type1/cff_to_type1_converter.rb +302 -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 +576 -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 +369 -0
  32. data/lib/fontisan/type1/pfa_parser.rb +159 -0
  33. data/lib/fontisan/type1/pfb_generator.rb +314 -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 +342 -0
  38. data/lib/fontisan/type1/seac_expander.rb +501 -0
  39. data/lib/fontisan/type1/ttf_to_type1_converter.rb +327 -0
  40. data/lib/fontisan/type1/upm_scaler.rb +118 -0
  41. data/lib/fontisan/type1.rb +75 -0
  42. data/lib/fontisan/type1_font.rb +318 -0
  43. data/lib/fontisan/version.rb +1 -1
  44. data/lib/fontisan.rb +2 -0
  45. metadata +30 -3
  46. data/docs/DOCUMENTATION_SUMMARY.md +0 -141
@@ -0,0 +1,1234 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../conversion_options"
4
+ require_relative "../type1/charstring_converter"
5
+ require_relative "../type1/cff_to_type1_converter"
6
+ require_relative "../type1/font_dictionary"
7
+ require_relative "../type1/charstrings"
8
+ require_relative "../type1/seac_expander"
9
+ require_relative "../type1_font"
10
+ require_relative "cff_table_builder"
11
+
12
+ module Fontisan
13
+ module Converters
14
+ # Converter for Adobe Type 1 fonts to/from SFNT formats.
15
+ #
16
+ # [`Type1Converter`](lib/fontisan/converters/type1_converter.rb) handles
17
+ # bidirectional conversion between Type 1 fonts (PFB/PFA) and SFNT-based
18
+ # formats (TTF, OTF, WOFF, WOFF2).
19
+ #
20
+ # == Conversion Strategy
21
+ #
22
+ # Type 1 fonts use PostScript CharStrings that are similar to CFF CharStrings
23
+ # used in OpenType fonts. The conversion uses CharStringConverter for the
24
+ # CharString translation.
25
+ #
26
+ # * Type 1 → OTF: Convert Type 1 CharStrings to CFF format, build CFF table
27
+ # * OTF → Type 1: Convert CFF CharStrings to Type 1 format, build PFB/PFA
28
+ # * Type 1 → TTF: Type 1 → OTF → TTF (via OutlineConverter)
29
+ # * TTF → Type 1: TTF → OTF → Type 1
30
+ #
31
+ # == Conversion Options
32
+ #
33
+ # The converter accepts [`ConversionOptions`](../conversion_options) with
34
+ # opening and generating options:
35
+ #
36
+ # * Opening options: decompose_composites, generate_unicode, read_all_records
37
+ # * Generating options: decompose_on_output, hinting_mode, write_pfm, write_afm
38
+ #
39
+ # @example Convert Type 1 to OTF with options
40
+ # font = FontLoader.load("font.pfb")
41
+ # options = ConversionOptions.recommended(from: :type1, to: :otf)
42
+ # converter = Type1Converter.new
43
+ # tables = converter.convert(font, options: options)
44
+ #
45
+ # @example Convert with preset
46
+ # options = ConversionOptions.from_preset(:type1_to_modern)
47
+ # tables = converter.convert(font, options: options)
48
+ #
49
+ # @see https://www.adobe.com/devnet/font/pdfs/Type1.pdf
50
+ # @see CharStringConverter
51
+ class Type1Converter
52
+ include ConversionStrategy
53
+ include CffTableBuilder
54
+
55
+ # Initialize a new Type1Converter
56
+ #
57
+ # @param options [Hash] Converter options
58
+ # @option options [Boolean] :optimize_cff Enable CFF optimization (default: false)
59
+ # @option options [Boolean] :preserve_hints Preserve hinting (default: true)
60
+ # @option options [Symbol] :target_format Target format for conversion
61
+ def initialize(options = {})
62
+ @optimize_cff = options.fetch(:optimize_cff, false)
63
+ @preserve_hints = options.fetch(:preserve_hints, true)
64
+ @target_format = options[:target_format]
65
+ end
66
+
67
+ # Convert font to target format
68
+ #
69
+ # @param font [Type1Font, OpenTypeFont, TrueTypeFont] Source font
70
+ # @param options [Hash, ConversionOptions] Conversion options
71
+ # @option options [Symbol] :target_format Target format override
72
+ # @option options [ConversionOptions] :options ConversionOptions object
73
+ # @return [Hash<String, String>] Map of table tags to binary data
74
+ def convert(font, options = {})
75
+ # Extract ConversionOptions if provided
76
+ conv_options = extract_conversion_options(options)
77
+
78
+ target_format = options[:target_format] || conv_options&.to || @target_format ||
79
+ detect_target_format(font)
80
+ validate(font, target_format)
81
+
82
+ # Apply opening options to source font
83
+ apply_opening_options(font, conv_options) if conv_options
84
+
85
+ source_format = detect_format(font)
86
+
87
+ case [source_format, target_format]
88
+ when %i[type1 otf]
89
+ convert_type1_to_otf(font, conv_options)
90
+ when %i[otf type1]
91
+ convert_otf_to_type1(font, conv_options)
92
+ when %i[type1 ttf]
93
+ convert_type1_to_ttf(font, conv_options)
94
+ when %i[ttf type1]
95
+ convert_ttf_to_type1(font, conv_options)
96
+ else
97
+ raise Fontisan::Error,
98
+ "Unsupported conversion: #{source_format} → #{target_format}"
99
+ end
100
+ end
101
+
102
+ # Get supported conversions
103
+ #
104
+ # @return [Array<Array<Symbol>>] Supported conversion pairs
105
+ def supported_conversions
106
+ [
107
+ %i[type1 otf],
108
+ %i[otf type1],
109
+ %i[type1 ttf],
110
+ %i[ttf type1],
111
+ ]
112
+ end
113
+
114
+ # Validate font for conversion
115
+ #
116
+ # @param font [Type1Font, OpenTypeFont, TrueTypeFont] Font to validate
117
+ # @param target_format [Symbol] Target format
118
+ # @return [Boolean] True if valid
119
+ # @raise [ArgumentError] If font is invalid
120
+ # @raise [Error] If conversion is not supported
121
+ def validate(font, target_format)
122
+ raise ArgumentError, "Font cannot be nil" if font.nil?
123
+
124
+ unless font.respond_to?(:font_dictionary) || font.respond_to?(:tables)
125
+ raise ArgumentError,
126
+ "Font must be Type1Font or have :tables method"
127
+ end
128
+
129
+ source_format = detect_format(font)
130
+ unless supports?(source_format, target_format)
131
+ raise Fontisan::Error,
132
+ "Conversion #{source_format} → #{target_format} not supported"
133
+ end
134
+
135
+ true
136
+ end
137
+
138
+ private
139
+
140
+ # Extract ConversionOptions from options hash
141
+ #
142
+ # @param options [Hash, ConversionOptions] Options or hash containing :options key
143
+ # @return [ConversionOptions, nil] Extracted ConversionOptions or nil
144
+ def extract_conversion_options(options)
145
+ return options if options.is_a?(ConversionOptions)
146
+
147
+ options[:options] if options.is_a?(Hash)
148
+ end
149
+
150
+ # Apply opening options to source font
151
+ #
152
+ # @param font [Type1Font] Source font
153
+ # @param conv_options [ConversionOptions] Conversion options with opening options
154
+ def apply_opening_options(font, conv_options)
155
+ return unless font.is_a?(Type1Font)
156
+ return unless conv_options
157
+
158
+ # Generate Unicode codepoints if requested
159
+ if conv_options.opening_option?(:generate_unicode)
160
+ generate_unicode_mappings(font)
161
+ end
162
+
163
+ # Decompose seac composites if requested
164
+ if conv_options.opening_option?(:decompose_composites)
165
+ decompose_seac_glyphs(font)
166
+ end
167
+
168
+ # Read all font dictionary records if requested
169
+ if conv_options.opening_option?(:read_all_records) && font.font_dictionary.respond_to?(:reload)
170
+ # Ensure full font dictionary is loaded
171
+ font.font_dictionary.reload
172
+ end
173
+ end
174
+
175
+ # Generate Unicode codepoints from glyph names/encoding
176
+ #
177
+ # @param font [Type1Font] Source Type 1 font
178
+ def generate_unicode_mappings(_font)
179
+ # Placeholder: Generate Unicode mappings from glyph names
180
+ # A full implementation would:
181
+ # 1. Parse the Adobe Glyph List
182
+ # 2. Map glyph names to Unicode codepoints
183
+ # 3. Update the charstrings encoding
184
+ #
185
+ # For now, this is a no-op placeholder
186
+ nil
187
+ end
188
+
189
+ # Decompose seac composite glyphs to base glyphs
190
+ #
191
+ # @param font [Type1Font] Source Type 1 font
192
+ def decompose_seac_glyphs(font)
193
+ return unless font.charstrings
194
+
195
+ # Create SeacExpander to decompose composite glyphs
196
+ expander = Type1::SeacExpander.new(font.charstrings, font.private_dict)
197
+
198
+ # Get all composite glyphs
199
+ composites = expander.composite_glyphs
200
+ return if composites.empty?
201
+
202
+ # Decompose each composite glyph
203
+ composites.each do |glyph_name|
204
+ decomposed = expander.decompose(glyph_name)
205
+ next if decomposed.nil? || decomposed.empty?
206
+
207
+ # Update the CharString with decomposed version
208
+ # Access the charstrings hash directly and update
209
+ charstrings_hash = font.charstrings.charstrings
210
+ charstrings_hash[glyph_name] = decomposed
211
+
212
+ # Mark as decomposed (no longer a seac composite)
213
+ # The decomposed CharString no longer contains the seac operator
214
+ end
215
+ end
216
+
217
+ # Detect font format
218
+ #
219
+ # @param font [Type1Font, OpenTypeFont, TrueTypeFont] Font to detect
220
+ # @return [Symbol] Font format (:type1, :ttf, :otf)
221
+ def detect_format(font)
222
+ case font
223
+ when Type1Font
224
+ :type1
225
+ when TrueTypeFont
226
+ :ttf
227
+ when OpenTypeFont
228
+ :otf
229
+ else
230
+ # Try to detect from tables
231
+ if font.respond_to?(:tables)
232
+ if font.tables.key?("glyf")
233
+ :ttf
234
+ elsif font.tables.key?("CFF ") || font.tables.key?("CFF2")
235
+ :otf
236
+ else
237
+ raise Fontisan::Error, "Cannot detect font format"
238
+ end
239
+ else
240
+ raise Fontisan::Error, "Unknown font type: #{font.class}"
241
+ end
242
+ end
243
+ end
244
+
245
+ # Detect target format from font class or options
246
+ #
247
+ # @param font [Type1Font, OpenTypeFont, TrueTypeFont] Source font
248
+ # @return [Symbol] Target format
249
+ def detect_target_format(font)
250
+ case font
251
+ when Type1Font
252
+ :otf # Default: Type 1 → OTF
253
+ when TrueTypeFont
254
+ :type1 # TTF → Type 1
255
+ when OpenTypeFont
256
+ :type1 # OTF → Type 1
257
+ else
258
+ :otf
259
+ end
260
+ end
261
+
262
+ # Convert Type 1 font to OpenType/CFF
263
+ #
264
+ # @param font [Type1Font] Source Type 1 font
265
+ # @param options [Hash] Conversion options
266
+ # @return [Hash<String, String>] Target tables including CFF table
267
+ def convert_type1_to_otf(font, _options = {})
268
+ # Convert Type 1 CharStrings to CFF format
269
+ converter = Type1::CharStringConverter.new(font.charstrings)
270
+ cff_charstrings = {}
271
+
272
+ font.charstrings.each_charstring do |glyph_name, charstring|
273
+ cff_charstrings[glyph_name] = converter.convert(charstring)
274
+ end
275
+
276
+ # Build font dictionary for CFF
277
+ font_dict = build_cff_font_dict(font)
278
+
279
+ # Build private dictionary for CFF
280
+ private_dict = build_cff_private_dict(font)
281
+
282
+ # Build CFF table
283
+ # Note: This is a simplified implementation
284
+ # A full implementation would build proper CFF INDEX structures
285
+ cff_data = build_cff_table_data(font, cff_charstrings, font_dict,
286
+ private_dict)
287
+
288
+ # Build other required SFNT tables
289
+ tables = {}
290
+
291
+ # Build head table
292
+ tables["head"] = build_head_table(font)
293
+
294
+ # Build hhea table
295
+ tables["hhea"] = build_hhea_table(font)
296
+
297
+ # Build maxp table
298
+ tables["maxp"] = build_maxp_table(font)
299
+
300
+ # Build name table
301
+ tables["name"] = build_name_table(font)
302
+
303
+ # Build OS/2 table
304
+ tables["OS/2"] = build_os2_table(font)
305
+
306
+ # Build post table
307
+ tables["post"] = build_post_table(font)
308
+
309
+ # Build cmap table
310
+ tables["cmap"] = build_cmap_table(font)
311
+
312
+ # Add CFF table
313
+ tables["CFF "] = cff_data
314
+
315
+ tables
316
+ end
317
+
318
+ # Convert OpenType/CFF font to Type 1
319
+ #
320
+ # @param font [OpenTypeFont] Source OpenType font
321
+ # @param options [Hash] Conversion options
322
+ # @return [Hash<String, String>] Type 1 font data as PFB
323
+ def convert_otf_to_type1(font, _options = {})
324
+ # Extract CFF table
325
+ cff_table = font.table("CFF ")
326
+ raise Fontisan::Error, "CFF table not found" unless cff_table
327
+
328
+ # Get CharStrings INDEX from CFF
329
+ charstrings_index = cff_table.charstrings_index(0)
330
+ raise Fontisan::Error, "CharStrings INDEX not found" unless charstrings_index
331
+
332
+ # Get Private DICT for context
333
+ private_dict = cff_table.private_dict(0)
334
+
335
+ # Create CFF to Type 1 converter
336
+ converter = Type1::CffToType1Converter.new(
337
+ nominal_width: private_dict&.nominal_width || 0,
338
+ default_width: private_dict&.default_width || 0
339
+ )
340
+
341
+ # Convert each CFF CharString to Type 1 format
342
+ type1_charstrings = {}
343
+ glyph_count = charstrings_index.count
344
+
345
+ glyph_count.times do |glyph_index|
346
+ # Get raw CFF CharString data
347
+ cff_charstring = charstrings_index[glyph_index]
348
+ next unless cff_charstring
349
+
350
+ # Get glyph name
351
+ glyph_name = font.glyph_name(glyph_index) || "glyph#{glyph_index}"
352
+
353
+ # Convert CFF CharString to Type 1 format
354
+ private_dict_hash = build_private_dict_hash(private_dict)
355
+ type1_charstrings[glyph_name] = converter.convert(
356
+ cff_charstring,
357
+ private_dict: private_dict_hash
358
+ )
359
+ end
360
+
361
+ # Build Type 1 font data
362
+ build_type1_data(font, type1_charstrings, cff_table)
363
+ end
364
+
365
+ # Convert Type 1 font to TrueType (via OTF)
366
+ #
367
+ # @param font [Type1Font] Source Type 1 font
368
+ # @param options [Hash] Conversion options
369
+ # @return [Hash<String, String>] Target tables including glyf table
370
+ def convert_type1_to_ttf(font, options = {})
371
+ # First convert to OTF
372
+ otf_tables = convert_type1_to_otf(font, options)
373
+
374
+ # Then use OutlineConverter to convert OTF to TTF
375
+ # Create a temporary OTF font object
376
+ temp_otf = OpenTypeFont.new
377
+ otf_tables.each do |tag, data|
378
+ temp_otf.tables[tag] = data
379
+ end
380
+
381
+ # Use OutlineConverter for OTF → TTF
382
+ outline_converter = OutlineConverter.new(
383
+ optimize_cff: @optimize_cff,
384
+ preserve_hints: @preserve_hints,
385
+ target_format: :ttf,
386
+ )
387
+
388
+ outline_converter.convert(temp_otf, target_format: :ttf)
389
+ end
390
+
391
+ # Convert TrueType font to Type 1 (via OTF)
392
+ #
393
+ # @param font [TrueTypeFont] Source TrueType font
394
+ # @return [Hash<String, String>] Type 1 font data as PFB
395
+ def convert_ttf_to_type1(font)
396
+ # First use OutlineConverter to convert TTF to OTF
397
+ outline_converter = OutlineConverter.new(
398
+ optimize_cff: @optimize_cff,
399
+ preserve_hints: @preserve_hints,
400
+ target_format: :otf,
401
+ )
402
+
403
+ otf_tables = outline_converter.convert(font, target_format: :otf)
404
+
405
+ # Create a temporary OTF font object
406
+ temp_otf = OpenTypeFont.new
407
+ otf_tables.each do |tag, data|
408
+ temp_otf.tables[tag] = data
409
+ end
410
+
411
+ # Then convert OTF to Type 1
412
+ convert_otf_to_type1(temp_otf)
413
+ end
414
+
415
+ # Build CFF font dictionary from Type 1 font
416
+ #
417
+ # @param font [Type1Font] Source Type 1 font
418
+ # @return [Hash] CFF font dictionary data
419
+ def build_cff_font_dict(font)
420
+ {
421
+ version: font.font_dictionary.version || "001.000",
422
+ notice: font.font_dictionary.notice || "",
423
+ copyright: font.font_dictionary.copyright || "",
424
+ full_name: font.font_dictionary.full_name || font.font_name,
425
+ family_name: font.font_dictionary.family_name || font.font_name,
426
+ weight: font.font_dictionary.weight || "Medium",
427
+ font_b_box: font.font_dictionary.font_bbox || [0, 0, 0, 0],
428
+ font_matrix: font.font_dictionary.font_matrix || [0.001, 0, 0, 0.001,
429
+ 0, 0],
430
+ charset: font.charstrings.encoding.keys,
431
+ encoding: font.charstrings.encoding,
432
+ }
433
+ end
434
+
435
+ # Build CFF private dictionary from Type 1 font
436
+ #
437
+ # @param font [Type1Font] Source Type 1 font
438
+ # @return [Hash] CFF private dictionary data
439
+ def build_cff_private_dict(font)
440
+ private_dict = font.private_dict
441
+ {
442
+ blue_values: private_dict.blue_values || [],
443
+ other_blues: private_dict.other_blues || [],
444
+ family_blues: private_dict.family_blues || [],
445
+ family_other_blues: private_dict.family_other_blues || [],
446
+ blue_scale: private_dict.blue_scale || 0.039625,
447
+ blue_shift: private_dict.blue_shift || 7,
448
+ blue_fuzz: private_dict.blue_fuzz || 1,
449
+ std_hw: private_dict.std_hw || 0,
450
+ std_vw: private_dict.std_vw || 0,
451
+ stem_snap_h: private_dict.stem_snap_h || [],
452
+ stem_snap_v: private_dict.stem_snap_v || [],
453
+ force_bold: private_dict.force_bold || false,
454
+ language_group: private_dict.language_group || 0,
455
+ expansion_factor: private_dict.expansion_factor || 0.06,
456
+ initial_random_seed: private_dict.initial_random_seed || 0,
457
+ }
458
+ end
459
+
460
+ # Build CFF table data
461
+ #
462
+ # @param font [Type1Font] Source Type 1 font
463
+ # @param charstrings [Hash] CFF CharStrings (glyph_name => data)
464
+ # @param font_dict [Hash] CFF font dictionary (not used, kept for compatibility)
465
+ # @param private_dict [Hash] CFF private dictionary (not used, kept for compatibility)
466
+ # @return [String] CFF table binary data
467
+ def build_cff_table_data(font, charstrings, _font_dict, _private_dict)
468
+ # Convert charstrings hash to array (build_cff_table expects array)
469
+ charstrings_array = charstrings.values
470
+
471
+ # Build CFF table using CffTableBuilder
472
+ # We need to pass the Type1Font as-is for metadata extraction
473
+ build_cff_table(charstrings_array, [], font)
474
+ end
475
+
476
+ # Override extract_font_name to handle Type1Font
477
+ #
478
+ # @param font [Type1Font, TrueTypeFont, OpenTypeFont] Font
479
+ # @return [String] Font name
480
+ def extract_font_name(font)
481
+ if font.is_a?(Type1Font)
482
+ # Get font name from Type1Font
483
+ name = font.font_name || font.font_dictionary&.font_name
484
+ return name.dup.force_encoding("ASCII-8BIT") if name
485
+ end
486
+
487
+ # Fall back to original implementation for TrueTypeFont/OpenTypeFont
488
+ super
489
+ end
490
+
491
+ # Build Type 1 Private dictionary hash from CFF Private dict
492
+ #
493
+ # @param private_dict [Tables::Cff::PrivateDict] CFF Private dict
494
+ # @return [Hash] Private dictionary as hash for Type 1
495
+ def build_private_dict_hash(private_dict)
496
+ return {} unless private_dict
497
+
498
+ {
499
+ nominal_width: private_dict.nominal_width,
500
+ default_width: private_dict.default_width,
501
+ blue_values: private_dict.blue_values || [],
502
+ other_blues: private_dict.other_blues || [],
503
+ family_blues: private_dict.family_blues || [],
504
+ family_other_blues: private_dict.family_other_blues || [],
505
+ blue_scale: private_dict.blue_scale || 0.039625,
506
+ blue_shift: private_dict.blue_shift || 7,
507
+ blue_fuzz: private_dict.blue_fuzz || 1,
508
+ std_hw: private_dict.std_hw || 0,
509
+ std_vw: private_dict.std_vw || 0,
510
+ stem_snap_h: private_dict.stem_snap_h || [],
511
+ stem_snap_v: private_dict.stem_snap_v || [],
512
+ force_bold: private_dict.force_bold || false,
513
+ language_group: private_dict.language_group || 0,
514
+ expansion_factor: private_dict.expansion_factor || 0.06,
515
+ initial_random_seed: private_dict.initial_random_seed || 0,
516
+ }
517
+ end
518
+
519
+ # Build Type 1 font data
520
+ #
521
+ # @param font [OpenTypeFont] Source OpenType font
522
+ # @param charstrings [Hash] Type 1 CharStrings
523
+ # @param cff_table [Tables::Cff] CFF table for metadata
524
+ # @return [Hash] Type 1 font data with :pfb key
525
+ def build_type1_data(_font, _charstrings, _cff_table)
526
+ # Build PFB format
527
+ # This is a placeholder implementation
528
+ # Full implementation requires:
529
+ # 1. Build Font Dictionary
530
+ # 2. Build Private Dictionary
531
+ # 3. Build CharStrings
532
+ # 4. Encrypt with eexec
533
+ # 5. Format as PFB chunks
534
+
535
+ pfb_data = String.new(encoding: Encoding::ASCII_8BIT)
536
+
537
+ { pfb: pfb_data }
538
+ end
539
+
540
+ # Build head table from Type 1 font
541
+ #
542
+ # @param font [Type1Font] Source Type 1 font
543
+ # @return [String] head table binary data
544
+ def build_head_table(font)
545
+ data = (+"").b
546
+
547
+ # Get font metadata from Type1Font
548
+ font_bbox = font.font_dictionary&.font_bbox || [0, 0, 1000, 1000]
549
+ version_str = font.version || "001.000"
550
+
551
+ # Parse version (e.g., "001.000" => 1.0)
552
+ version_parts = version_str.split(".")
553
+ major = version_parts[0].to_i
554
+ minor = version_parts[1]&.to_i || 0
555
+ version = major + (minor / 1000.0)
556
+
557
+ # Version (Fixed 16.16) - stored as int32
558
+ integer_part = version.to_i
559
+ fractional_part = ((version - integer_part) * 65_536).to_i
560
+ version_raw = (integer_part << 16) | fractional_part
561
+ data << [version_raw].pack("N")
562
+
563
+ # Font Revision (Fixed 16.16) - default to 1.0
564
+ font_revision_raw = 0x00010000
565
+ data << [font_revision_raw].pack("N")
566
+
567
+ # Checksum Adjustment (uint32) - will be calculated later
568
+ data << [0].pack("N")
569
+
570
+ # Magic Number (uint32)
571
+ data << [0x5F0F3CF5].pack("N")
572
+
573
+ # Flags (uint16) - bit 0 indicates y direction (0 = mixed)
574
+ data << [0].pack("n")
575
+
576
+ # Units Per Em (uint16) - Type 1 standard is 1000
577
+ data << [1000].pack("n")
578
+
579
+ # Created (LONGDATETIME) - use current time
580
+ created_seconds = Time.now.to_i + 2_082_844_800
581
+ data << [created_seconds].pack("Q>")
582
+
583
+ # Modified (LONGDATETIME) - use current time
584
+ modified_seconds = Time.now.to_i + 2_082_844_800
585
+ data << [modified_seconds].pack("Q>")
586
+
587
+ # Bounding box (int16 each)
588
+ data << [font_bbox[0]].pack("s>") # x_min
589
+ data << [font_bbox[1]].pack("s>") # y_min
590
+ data << [font_bbox[2]].pack("s>") # x_max
591
+ data << [font_bbox[3]].pack("s>") # y_max
592
+
593
+ # Mac Style (uint16) - no style bits set
594
+ data << [0].pack("n")
595
+
596
+ # Lowest Rec PPEM (uint16) - readable size
597
+ data << [8].pack("n")
598
+
599
+ # Font Direction Hint (int16)
600
+ # 2 = Left to right, mixed glyphs
601
+ data << [2].pack("s>")
602
+
603
+ # Index To Loc Format (int16)
604
+ # 0 = short offsets (for CFF fonts we use this)
605
+ data << [0].pack("s>")
606
+
607
+ # Glyph Data Format (int16)
608
+ data << [0].pack("s>")
609
+
610
+ data
611
+ end
612
+
613
+ # Build hhea table from Type 1 font
614
+ #
615
+ # @param font [Type1Font] Source Type 1 font
616
+ # @return [String] hhea table binary data
617
+ def build_hhea_table(font)
618
+ data = (+"").b
619
+
620
+ # Get font metrics from Type1Font
621
+ font_bbox = font.font_dictionary&.font_bbox || [0, 0, 1000, 1000]
622
+ blue_values = font.private_dict&.blue_values || []
623
+
624
+ # Version (Fixed 16.16) - 0x00010000 (1.0)
625
+ data << [0x00010000].pack("N")
626
+
627
+ # Ascent (int16) - Distance from baseline to highest ascender
628
+ # Use BlueValues[2] or [3] if available, otherwise font_bbox[3]
629
+ if blue_values.length >= 4
630
+ ascent = blue_values[3] # Top zone top
631
+ elsif blue_values.length >= 3
632
+ ascent = blue_values[2] # Top zone bottom
633
+ else
634
+ ascent = font_bbox[3] # y_max
635
+ end
636
+ data << [ascent].pack("s>")
637
+
638
+ # Descent (int16) - Distance from baseline to lowest descender (negative)
639
+ # Use BlueValues[0] or [1] if available, otherwise font_bbox[1]
640
+ if blue_values.length >= 2
641
+ descent = blue_values[0] # Bottom zone bottom (negative)
642
+ elsif blue_values.length >= 1
643
+ descent = blue_values[0]
644
+ else
645
+ descent = font_bbox[1] # y_min (should be negative)
646
+ end
647
+ data << [descent].pack("s>")
648
+
649
+ # Line Gap (int16) - Additional space between lines
650
+ # Use typical value of 0 for Type 1 fonts
651
+ data << [0].pack("s>")
652
+
653
+ # Advance Width Max (uint16)
654
+ # Type 1 standard is typically 1000, use font_bbox width + padding
655
+ advance_max = (font_bbox[2] - font_bbox[0]) + 100
656
+ data << [advance_max].pack("n")
657
+
658
+ # Min Left Side Bearing (int16)
659
+ # Use font_bbox[0] (x_min) as reasonable default
660
+ data << [font_bbox[0]].pack("s>")
661
+
662
+ # Min Right Side Bearing (int16)
663
+ # Estimate as 0 (will be updated if actual metrics available)
664
+ data << [0].pack("s>")
665
+
666
+ # x Max Extent (int16) - Max(lsb + xMax)
667
+ # Use font_bbox[2] (x_max) as reasonable default
668
+ data << [font_bbox[2]].pack("s>")
669
+
670
+ # Caret Slope Rise (int16)
671
+ # 1 for upright fonts (not italic)
672
+ data << [1].pack("s>")
673
+
674
+ # Caret Slope Run (int16)
675
+ # 0 for upright fonts
676
+ data << [0].pack("s>")
677
+
678
+ # Caret Offset (int16)
679
+ # Set to 0 for standard fonts
680
+ data << [0].pack("s>")
681
+
682
+ # Reserved (int64) - 8 bytes of zeros
683
+ data << [0, 0].pack("Q>")
684
+
685
+ # Metric Data Format (int16)
686
+ # 0 for current format
687
+ data << [0].pack("s>")
688
+
689
+ # Number of HMetrics (uint16)
690
+ # Number of glyphs with explicit metrics (typically all glyphs)
691
+ num_glyphs = font.charstrings&.count || 1
692
+ data << [[num_glyphs, 1].max].pack("n")
693
+
694
+ data
695
+ end
696
+
697
+ # Build maxp table from Type 1 font
698
+ #
699
+ # @param font [Type1Font] Source Type 1 font
700
+ # @return [String] maxp table binary data
701
+ def build_maxp_table(font)
702
+ data = (+"").b
703
+
704
+ # Get number of glyphs from Type1Font
705
+ num_glyphs = font.charstrings&.count || 1
706
+
707
+ # Version (Fixed 16.16)
708
+ # For CFF fonts (OTF output), use version 0.5 (0x00005000)
709
+ # For TrueType fonts (TTF output), would use version 1.0 (0x00010000)
710
+ # Type 1 fonts convert to CFF-based OTF, so use version 0.5
711
+ data << [0x00005000].pack("N")
712
+
713
+ # Number of Glyphs (uint16)
714
+ # Must be >= 1 (at minimum, .notdef must be present)
715
+ data << [[num_glyphs, 1].max].pack("n")
716
+
717
+ data
718
+ end
719
+
720
+ # Build name table from Type 1 font
721
+ #
722
+ # @param font [Type1Font] Source Type 1 font
723
+ # @return [String] name table binary data
724
+ def build_name_table(font)
725
+ # Get font metadata from Type1Font
726
+ font_dict = font.font_dictionary
727
+ font_info = font_dict&.font_info
728
+
729
+ # Extract font names with fallbacks
730
+ font_name = font.font_name || font_dict&.font_name || "Unnamed"
731
+ family_name = if font_info&.respond_to?(:family_name)
732
+ font_info.family_name || font_dict&.family_name || font_name
733
+ else
734
+ font_dict&.family_name || font_name
735
+ end
736
+ full_name = if font_info&.respond_to?(:full_name)
737
+ font_info.full_name || font_dict&.full_name || family_name
738
+ else
739
+ font_dict&.full_name || family_name
740
+ end
741
+ version = if font_info&.respond_to?(:version)
742
+ font_info.version || font.version || "001.000"
743
+ else
744
+ font.version || "001.000"
745
+ end
746
+ copyright = if font_info&.respond_to?(:copyright)
747
+ font_info.copyright || font_dict&.raw_data&.dig(:copyright) || ""
748
+ else
749
+ font_dict&.raw_data&.dig(:copyright) || ""
750
+ end
751
+ postscript_name = font_name
752
+ weight = if font_info&.respond_to?(:weight)
753
+ font_info.weight
754
+ else
755
+ "Regular"
756
+ end
757
+ notice = if font_info&.respond_to?(:notice)
758
+ font_info.notice
759
+ else
760
+ ""
761
+ end
762
+
763
+ # Build name records (Windows Unicode, English US)
764
+ # Platform ID 3 (Windows), Encoding ID 1 (Unicode BMP), Language ID 0x0409 (US English)
765
+ name_records = [
766
+ # Copyright (name ID 0)
767
+ { name_id: 0, string: copyright },
768
+ # Family Name (name ID 1)
769
+ { name_id: 1, string: family_name },
770
+ # Subfamily Name (name ID 2) - derive from weight or default to Regular
771
+ { name_id: 2, string: weight || "Regular" },
772
+ # Unique ID (name ID 3) - format: version;copyright;postscript_name
773
+ { name_id: 3, string: "#{version};#{copyright};#{postscript_name}" },
774
+ # Full Name (name ID 4)
775
+ { name_id: 4, string: full_name },
776
+ # Version (name ID 5)
777
+ { name_id: 5, string: version },
778
+ # PostScript Name (name ID 6)
779
+ { name_id: 6, string: postscript_name },
780
+ # Trademark (name ID 7) - use notice if available
781
+ { name_id: 7, string: notice || "" },
782
+ ]
783
+
784
+ # Filter out empty strings and build string storage
785
+ name_records = name_records.select { |r| !r[:string].nil? && !r[:string].empty? }
786
+
787
+ # Build string storage (UTF-16BE encoded for Windows platform)
788
+ string_storage = (+"").b
789
+ name_records.each do |record|
790
+ encoded_string = record[:string].encode("UTF-16BE").force_encoding("ASCII-8BIT")
791
+ record[:encoded] = encoded_string
792
+ record[:offset] = string_storage.bytesize
793
+ string_storage << encoded_string
794
+ end
795
+
796
+ # Build name table
797
+ data = (+"").b
798
+
799
+ # Format selector (uint16) - 0 for basic
800
+ data << [0].pack("n")
801
+
802
+ # Count (uint16) - number of name records
803
+ data << [name_records.size].pack("n")
804
+
805
+ # String offset (uint16) - offset to string storage from start of table
806
+ # Header is 6 bytes, each name record is 12 bytes
807
+ string_data_offset = 6 + (name_records.size * 12)
808
+ data << [string_data_offset].pack("n")
809
+
810
+ # Write name records
811
+ platform_id = 3 # Windows
812
+ encoding_id = 1 # Unicode BMP
813
+ language_id = 0x0409 # US English
814
+
815
+ name_records.each do |record|
816
+ data << [platform_id].pack("n") # platform ID
817
+ data << [encoding_id].pack("n") # encoding ID
818
+ data << [language_id].pack("n") # language ID
819
+ data << [record[:name_id]].pack("n") # name ID
820
+ data << [record[:encoded].bytesize].pack("n") # string length
821
+ data << [record[:offset]].pack("n") # string offset
822
+ end
823
+
824
+ # Write string storage
825
+ data << string_storage
826
+
827
+ data
828
+ end
829
+
830
+ # Build OS/2 table from Type 1 font
831
+ #
832
+ # @param font [Type1Font] Source Type 1 font
833
+ # @return [String] OS/2 table binary data
834
+ def build_os2_table(font)
835
+ data = (+"").b
836
+
837
+ # Get font metadata from Type1Font
838
+ font_bbox = font.font_dictionary&.font_bbox || [0, 0, 1000, 1000]
839
+ blue_values = font.private_dict&.blue_values || []
840
+ font_info = font.font_dictionary&.font_info || {}
841
+ weight = font_info.weight || "Medium"
842
+
843
+ # Determine weight class (100-900)
844
+ # Order matters - more specific patterns must come first
845
+ weight_class = case weight.to_s.downcase
846
+ when /thin/ then 100
847
+ when /extralight/ then 200
848
+ when /light/ then 300
849
+ when /regular|normal/ then 400
850
+ when /medium/ then 400
851
+ when /semibold|semib/ then 600
852
+ when /extrabold/ then 800
853
+ when /bold/ then 700
854
+ when /black|heavy/ then 900
855
+ else 400
856
+ end
857
+
858
+ # Version (uint16) - Use version 4 for modern fonts
859
+ data << [4].pack("n")
860
+
861
+ # xAvgCharWidth (int16) - Average character width
862
+ # Use font width estimate
863
+ avg_width = ((font_bbox[2] - font_bbox[0]) * 0.5).to_i
864
+ data << [avg_width].pack("s>")
865
+
866
+ # usWeightClass (uint16)
867
+ data << [weight_class].pack("n")
868
+
869
+ # usWidthClass (uint16) - 1 = Ultra-condensed to 9 = Ultra-expanded
870
+ # Default to 5 (Medium)
871
+ data << [5].pack("n")
872
+
873
+ # fsType (uint16) - Embedding permissions
874
+ # 0 = Installable embedding, 8 = Restricted (use 0 as default)
875
+ data << [0].pack("n")
876
+
877
+ # ySubscriptXSize (int16)
878
+ data << [650].pack("s>")
879
+
880
+ # ySubscriptYSize (int16)
881
+ data << [600].pack("s>")
882
+
883
+ # ySubscriptXOffset (int16)
884
+ data << [0].pack("s>")
885
+
886
+ # ySubscriptYOffset (int16)
887
+ data << [75].pack("s>")
888
+
889
+ # ySuperscriptXSize (int16)
890
+ data << [650].pack("s>")
891
+
892
+ # ySuperscriptYSize (int16)
893
+ data << [600].pack("s>")
894
+
895
+ # ySuperscriptXOffset (int16)
896
+ data << [0].pack("s>")
897
+
898
+ # ySuperscriptYOffset (int16)
899
+ data << [350].pack("s>")
900
+
901
+ # yStrikeoutSize (int16)
902
+ data << [50].pack("s>")
903
+
904
+ # yStrikeoutPosition (int16)
905
+ data << [300].pack("s>")
906
+
907
+ # sFamilyClass (int16) - Family class and subclass
908
+ # 0 = No classification
909
+ data << [0].pack("s>")
910
+
911
+ # PANOSE (10 bytes) - Use default Latin Text family
912
+ # Family: 2 (Text and Display), Serif Style: 11 (Normal Sans)
913
+ panose = [
914
+ 2, # Family kind: Latin Text
915
+ 11, # Serif style: Normal Sans
916
+ 5, # Weight: Medium
917
+ 5, # Proportion: Modern
918
+ 2, # Contrast: Medium Low
919
+ 5, # Stroke variation: Medium
920
+ 5, # Arm style: Straight arms/serifs
921
+ 5, # Letter form: Normal
922
+ 4, # Midline: Standard
923
+ 3, # X-height: Medium
924
+ ]
925
+ data << panose.pack("C*")
926
+
927
+ # Unicode ranges (4 x uint32) - Basic Latin + Latin-1
928
+ # Bits 0-31: Basic Latin, Latin-1, Latin Extended-A/B, etc.
929
+ data << [0x00000001].pack("N") # Basic Latin (0-7F)
930
+ data << [0x00000000].pack("N")
931
+ data << [0x00000000].pack("N")
932
+ data << [0x00000000].pack("N")
933
+
934
+ # achVendID (4 bytes) - Vendor ID
935
+ data << "UKWN" # Unknown
936
+
937
+ # fsSelection (uint16) - Font selection flags
938
+ # Bit 6 (0x40) = Regular weight if 400-500
939
+ fs_selection = if weight_class >= 400 && weight_class <= 500
940
+ 0x40 # REGULAR
941
+ elsif weight_class >= 700
942
+ 0x20 # BOLD
943
+ else
944
+ 0
945
+ end
946
+ data << [fs_selection].pack("n")
947
+
948
+ # usFirstCharIndex (uint16) - First Unicode character
949
+ data << [32].pack("n") # Space
950
+
951
+ # usLastCharIndex (uint16) - Last Unicode character
952
+ data << [0xFFFD].pack("n") # Replacement character
953
+
954
+ # sTypoAscender (int16) - Use BlueValues or font bbox
955
+ if blue_values.length >= 4
956
+ typo_ascender = blue_values[3]
957
+ else
958
+ typo_ascender = font_bbox[3]
959
+ end
960
+ data << [typo_ascender].pack("s>")
961
+
962
+ # sTypoDescender (int16) - Use BlueValues or font bbox (negative)
963
+ if blue_values.length >= 2
964
+ typo_descender = blue_values[0]
965
+ else
966
+ typo_descender = font_bbox[1]
967
+ end
968
+ data << [typo_descender].pack("s>")
969
+
970
+ # sTypoLineGap (int16)
971
+ data << [0].pack("s>")
972
+
973
+ # usWinAscent (uint16)
974
+ data << [[font_bbox[3], 1000].max].pack("n")
975
+
976
+ # usWinDescent (uint16)
977
+ data << [[-font_bbox[1], 200].max].pack("n")
978
+
979
+ # ulCodePageRange1 (uint32) - Latin 1
980
+ data << [0x00000001].pack("N")
981
+
982
+ # ulCodePageRange2 (uint32)
983
+ data << [0x00000000].pack("N")
984
+
985
+ # sxHeight (int16) - x-height, approximate as 500 for 1000 UPM
986
+ data << [500].pack("s>")
987
+
988
+ # sCapHeight (int16) - Cap height, approximate as 700 for 1000 UPM
989
+ data << [700].pack("s>")
990
+
991
+ # usDefaultChar (uint16)
992
+ data << [0].pack("n")
993
+
994
+ # usBreakChar (uint16) - Space
995
+ data << [32].pack("n")
996
+
997
+ # usMaxContext (uint16)
998
+ data << [0].pack("n")
999
+
1000
+ data
1001
+ end
1002
+
1003
+ # Build post table from Type 1 font
1004
+ #
1005
+ # @param font [Type1Font] Source Type 1 font
1006
+ # @return [String] post table binary data
1007
+ def build_post_table(font)
1008
+ data = (+"").b
1009
+
1010
+ # Get font metadata from Type1Font
1011
+ font_info = font.font_dictionary&.font_info || {}
1012
+
1013
+ # Version (Fixed 16.16) - Use version 3.0 for CFF fonts (no glyph names)
1014
+ # Version 2.0 would include glyph names, but for OTF output version 3.0 is fine
1015
+ # since CFF table contains the glyph names
1016
+ data << [0x00030000].pack("N") # Version 3.0
1017
+
1018
+ # Italic Angle (Fixed 16.16)
1019
+ # Get from FontInfo if available, otherwise default to 0
1020
+ italic_angle = font_info.italic_angle || 0
1021
+ angle_raw = (italic_angle * 65_536).to_i
1022
+ data << [angle_raw].pack("N")
1023
+
1024
+ # Underline Position (int16)
1025
+ underline_position = font_info.underline_position || -100
1026
+ data << [underline_position].pack("s>")
1027
+
1028
+ # Underline Thickness (int16)
1029
+ underline_thickness = font_info.underline_thickness || 50
1030
+ data << [underline_thickness].pack("s>")
1031
+
1032
+ # Fixed Pitch (uint32) - Boolean for monospace
1033
+ is_fixed_pitch = (font_info.is_fixed_pitch || false) ? 1 : 0
1034
+ data << [is_fixed_pitch].pack("N")
1035
+
1036
+ # Min/Max Memory for Type 42 (uint32 each) - Not used for CFF, set to 0
1037
+ data << [0].pack("N") # min_mem_type42
1038
+ data << [0].pack("N") # max_mem_type42
1039
+
1040
+ # Min/Max Memory for Type 1 (uint32 each) - Not used for CFF, set to 0
1041
+ data << [0].pack("N") # min_mem_type1
1042
+ data << [0].pack("N") # max_mem_type1
1043
+
1044
+ data
1045
+ end
1046
+
1047
+ # Build cmap table from Type 1 font
1048
+ #
1049
+ # @param font [Type1Font] Source Type 1 font
1050
+ # @return [String] cmap table binary data
1051
+ def build_cmap_table(font)
1052
+ require_relative "../type1/agl"
1053
+
1054
+ data = (+"").b
1055
+
1056
+ # Get encoding from Type1Font
1057
+ encoding = font.charstrings&.encoding || {}
1058
+ glyph_names = font.charstrings&.glyph_names || encoding.keys
1059
+
1060
+ # Build Unicode mapping from glyph names using AGL
1061
+ unicode_to_glyph = {}
1062
+ glyph_index = 0
1063
+
1064
+ glyph_names.each do |glyph_name|
1065
+ # Get Unicode code point from AGL
1066
+ unicode = Type1::AGL.unicode_for_glyph_name(glyph_name)
1067
+
1068
+ # If no Unicode mapping, try to derive from encoding position
1069
+ if unicode.nil?
1070
+ # For standard encoding, try to map from position
1071
+ # This is a simplified approach - real implementation would be more robust
1072
+ unicode = glyph_index if glyph_index < 128
1073
+ end
1074
+
1075
+ # Map Unicode to glyph index
1076
+ if unicode && unicode <= 0xFFFF
1077
+ unicode_to_glyph[unicode] ||= glyph_index
1078
+ end
1079
+
1080
+ glyph_index += 1
1081
+ end
1082
+
1083
+ # Ensure at least .notdef (glyph 0) maps to something
1084
+ unicode_to_glyph[0x0000] ||= 0
1085
+
1086
+ # Build Format 4 subtable (Segment mapping to delta values)
1087
+ # This is the most common format for BMP Unicode fonts
1088
+ subtable_data = build_cmap_format_4(unicode_to_glyph)
1089
+
1090
+ # Calculate offsets
1091
+ encoding_records_offset = 4 # After version (2) + num_tables (2)
1092
+ subtable_offset = encoding_records_offset + 8 # After one encoding record (8 bytes)
1093
+
1094
+ # Build cmap table header
1095
+ # Version (uint16)
1096
+ data << [0].pack("n")
1097
+
1098
+ # Number of encoding records (uint16)
1099
+ data << [1].pack("n") # One encoding record
1100
+
1101
+ # Encoding record: Platform ID (uint16), Encoding ID (uint16), Subtable offset (uint32)
1102
+ # Platform 3 (Windows), Encoding 1 (Unicode BMP)
1103
+ data << [3].pack("n") # Platform ID: Windows
1104
+ data << [1].pack("n") # Encoding ID: Unicode BMP
1105
+ data << [subtable_offset].pack("N") # Subtable offset
1106
+
1107
+ # Append subtable data
1108
+ data << subtable_data
1109
+
1110
+ data
1111
+ end
1112
+
1113
+ # Build cmap format 4 subtable
1114
+ #
1115
+ # @param unicode_to_glyph [Hash<Integer, Integer>] Unicode to glyph index mapping
1116
+ # @return [String] Format 4 subtable binary data
1117
+ def build_cmap_format_4(unicode_to_glyph)
1118
+ data = (+"").b
1119
+
1120
+ # Get sorted Unicode values
1121
+ unicode_values = unicode_to_glyph.keys.sort
1122
+ return data if unicode_values.empty?
1123
+
1124
+ # For simplicity, create segments for continuous ranges
1125
+ # A more sophisticated implementation would optimize this
1126
+ segments = []
1127
+ current_segment = nil
1128
+
1129
+ unicode_values.each do |unicode|
1130
+ glyph_id = unicode_to_glyph[unicode]
1131
+
1132
+ if current_segment.nil?
1133
+ current_segment = {
1134
+ start: unicode,
1135
+ end: unicode,
1136
+ start_glyph: glyph_id,
1137
+ glyphs: [glyph_id],
1138
+ }
1139
+ elsif unicode == current_segment[:end] + 1 && glyph_id == current_segment[:glyphs].last + 1
1140
+ # Continue current segment (sequential)
1141
+ current_segment[:end] = unicode
1142
+ current_segment[:glyphs] << glyph_id
1143
+ else
1144
+ # Start new segment
1145
+ segments << current_segment
1146
+ current_segment = {
1147
+ start: unicode,
1148
+ end: unicode,
1149
+ start_glyph: glyph_id,
1150
+ glyphs: [glyph_id],
1151
+ }
1152
+ end
1153
+ end
1154
+
1155
+ segments << current_segment if current_segment
1156
+
1157
+ # Add end segment marker (0xFFFF)
1158
+ segments << { start: 0xFFFF, end: 0xFFFF, start_glyph: 0, glyphs: [0] }
1159
+
1160
+ # Calculate segment count and related values
1161
+ seg_count = segments.length
1162
+ seg_count_x2 = seg_count * 2
1163
+ search_range = 2 ** (Math.log2(seg_count).to_i) * 2
1164
+ entry_selector = Math.log2(search_range / 2).to_i
1165
+ range_shift = (seg_count - search_range / 2) * 2
1166
+
1167
+ # Build format 4 subtable header (14 bytes)
1168
+ data << [4].pack("n") # Format
1169
+ data << [calculate_cmap4_length(segments)].pack("n") # Length (placeholder)
1170
+ data << [0].pack("n") # Language (0 = independent)
1171
+ data << [seg_count_x2].pack("n") # segCountX2
1172
+ data << [search_range].pack("n") # searchRange
1173
+ data << [entry_selector].pack("n") # entrySelector
1174
+ data << [range_shift].pack("n") # rangeShift
1175
+
1176
+ # Build segment arrays
1177
+ end_codes = []
1178
+ start_codes = []
1179
+ id_deltas = []
1180
+ id_range_offsets = []
1181
+ glyph_id_array = []
1182
+
1183
+ segments.each do |seg|
1184
+ end_codes << seg[:end]
1185
+ start_codes << seg[:start]
1186
+
1187
+ # For sequential glyphs, use delta
1188
+ if seg[:start] == 0xFFFF
1189
+ # End segment marker
1190
+ id_deltas << 1
1191
+ id_range_offsets << 0
1192
+ elsif seg[:end] - seg[:start] == seg[:glyphs].length - 1
1193
+ # Sequential: use delta
1194
+ id_deltas << (seg[:start_glyph] - seg[:start])
1195
+ id_range_offsets << 0
1196
+ else
1197
+ # Non-sequential: use glyph ID array
1198
+ id_deltas << 0
1199
+ id_range_offsets << (glyph_id_array.length * 2 + 2)
1200
+ glyph_id_array.concat(seg[:glyphs])
1201
+ end
1202
+ end
1203
+
1204
+ # Write arrays (padded to even length)
1205
+ end_codes.each { |code| data << [code].pack("n") }
1206
+ data << [0].pack("n") # Reserved padding
1207
+ start_codes.each { |code| data << [code].pack("n") }
1208
+ id_deltas.each { |delta| data << [delta].pack("s>") } # Signed
1209
+ id_range_offsets.each { |offset| data << [offset].pack("n") }
1210
+ glyph_id_array.each { |gid| data << [gid].pack("n") }
1211
+
1212
+ # Update length in header
1213
+ length = data.bytesize
1214
+ data[2..3] = [length].pack("n")
1215
+
1216
+ data
1217
+ end
1218
+
1219
+ # Calculate length for format 4 subtable
1220
+ #
1221
+ # @param segments [Array<Hash>] Segment definitions
1222
+ # @return [Integer] Estimated length
1223
+ def calculate_cmap4_length(segments)
1224
+ # Header: 14 bytes
1225
+ # Arrays: seg_count * 2 bytes each
1226
+ # Glyph ID array: variable
1227
+ seg_count = segments.length
1228
+
1229
+ # Rough estimate (actual calculation done during construction)
1230
+ 14 + (seg_count * 8) + (seg_count * 2) + 100 # 100 for glyph ID array estimate
1231
+ end
1232
+ end
1233
+ end
1234
+ end