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,576 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Type1
5
+ # Type 1 Font Dictionary model
6
+ #
7
+ # [`FontDictionary`](lib/fontisan/type1/font_dictionary.rb) parses and stores
8
+ # the font dictionary from a Type 1 font, which contains metadata about
9
+ # the font including FontInfo, FontName, Encoding, and other properties.
10
+ #
11
+ # The font dictionary is the top-level PostScript dictionary that defines
12
+ # the font's properties and contains references to the Private dictionary
13
+ # and CharStrings.
14
+ #
15
+ # @example Parse font dictionary from decrypted font data
16
+ # dict = Fontisan::Type1::FontDictionary.parse(decrypted_data)
17
+ # puts dict.font_name
18
+ # puts dict.font_info.full_name
19
+ # puts dict.font_b_box
20
+ #
21
+ # @see https://www.adobe.com/devnet/font/pdfs/Type1.pdf
22
+ class FontDictionary
23
+ # @return [FontInfo] Font information
24
+ attr_reader :font_info
25
+
26
+ # @return [String] Font name
27
+ attr_reader :font_name
28
+
29
+ # @return [Encoding] Font encoding
30
+ attr_reader :encoding
31
+
32
+ # @return [Hash] Font bounding box [x_min, y_min, x_max, y_max]
33
+ attr_reader :font_b_box
34
+
35
+ # Alias for font_b_box (camelCase compatibility)
36
+ alias font_bbox font_b_box
37
+
38
+ # @return [Array<Float>] Font matrix [xx, xy, yx, yy, tx, ty]
39
+ attr_reader :font_matrix
40
+
41
+ # @return [Integer] Paint type (0=symbol, 1=character)
42
+ attr_reader :paint_type
43
+
44
+ # @return [Integer] Font type (always 1 for Type 1)
45
+ attr_reader :font_type
46
+
47
+ # @return [Hash] Raw dictionary data
48
+ attr_reader :raw_data
49
+
50
+ # Parse font dictionary from decrypted Type 1 font data
51
+ #
52
+ # @param data [String] Decrypted Type 1 font data
53
+ # @return [FontDictionary] Parsed font dictionary
54
+ # @raise [Fontisan::Error] If dictionary cannot be parsed
55
+ #
56
+ # @example Parse from decrypted font data
57
+ # dict = Fontisan::Type1::FontDictionary.parse(decrypted_data)
58
+ def self.parse(data)
59
+ new.parse(data)
60
+ end
61
+
62
+ # Initialize a new FontDictionary
63
+ def initialize
64
+ @font_info = FontInfo.new
65
+ @encoding = Encoding.new
66
+ @raw_data = {}
67
+ @parsed = false
68
+ end
69
+
70
+ # Parse font dictionary from decrypted Type 1 font data
71
+ #
72
+ # @param data [String] Decrypted Type 1 font data
73
+ # @return [FontDictionary] Self for method chaining
74
+ def parse(data)
75
+ extract_font_dictionary(data)
76
+ extract_font_info
77
+ extract_encoding
78
+ extract_properties
79
+ @parsed = true
80
+ self
81
+ end
82
+
83
+ # Check if dictionary was successfully parsed
84
+ #
85
+ # @return [Boolean] True if dictionary has been parsed
86
+ def parsed?
87
+ @parsed
88
+ end
89
+
90
+ # Get full name from FontInfo
91
+ #
92
+ # @return [String, nil] Full font name
93
+ def full_name
94
+ @font_info&.full_name
95
+ end
96
+
97
+ # Get family name from FontInfo
98
+ #
99
+ # @return [String, nil] Family name
100
+ def family_name
101
+ @font_info&.family_name
102
+ end
103
+
104
+ # Get version from FontInfo
105
+ #
106
+ # @return [String, nil] Font version
107
+ def version
108
+ @font_info&.version
109
+ end
110
+
111
+ # Get copyright from FontInfo
112
+ #
113
+ # @return [String, nil] Copyright notice
114
+ def copyright
115
+ @font_info&.copyright
116
+ end
117
+
118
+ # Get notice from FontInfo
119
+ #
120
+ # @return [String, nil] Notice string
121
+ def notice
122
+ @font_info&.notice
123
+ end
124
+
125
+ # Get weight from FontInfo
126
+ #
127
+ # @return [String, nil] Font weight (Thin, Light, Regular, Bold, etc.)
128
+ def weight
129
+ @font_info&.weight
130
+ end
131
+
132
+ # Get raw value from dictionary
133
+ #
134
+ # @param key [String] Dictionary key
135
+ # @return [Object, nil] Value or nil if not found
136
+ def [](key)
137
+ @raw_data[key]
138
+ end
139
+
140
+ # Convert FontDictionary to Type 1 text format
141
+ #
142
+ # Generates the PostScript code for the Font Dictionary section
143
+ # of a Type 1 font.
144
+ #
145
+ # @return [String] Type 1 Font Dictionary text
146
+ #
147
+ # @example Generate Type 1 format
148
+ # dict = FontDictionary.new
149
+ # puts dict.to_type1_format
150
+ def to_type1_format
151
+ result = []
152
+ result << "% Type 1 Font Dictionary"
153
+ result << "12 dict begin"
154
+ result << ""
155
+ result << "/FontType 1 def"
156
+ result << "/PaintType #{@paint_type} def"
157
+ result << font_matrix_to_type1
158
+ result << font_bbox_to_type1
159
+ result << ""
160
+ result << font_info_to_type1
161
+ result << ""
162
+ result << "currentdict end"
163
+ result << "/FontName #{@font_name} def"
164
+
165
+ result.join("\n")
166
+ end
167
+
168
+ private
169
+
170
+ # Extract font dictionary from data
171
+ #
172
+ # @param data [String] Decrypted Type 1 font data
173
+ def extract_font_dictionary(data)
174
+ # Find the font dictionary definition
175
+ # Type 1 fonts use PostScript dictionary syntax
176
+ # Format: /FontName dict def ... end
177
+ #
178
+ # We need to extract the dictionary between "dict def" and "end"
179
+
180
+ # Look for dictionary pattern
181
+ # The font dict typically starts after the version comment
182
+ # and contains key-value pairs
183
+
184
+ @raw_data = parse_dictionary(data)
185
+ end
186
+
187
+ # Parse PostScript dictionary from text
188
+ #
189
+ # @param text [String] PostScript text
190
+ # @return [Hash] Parsed key-value pairs
191
+ def parse_dictionary(text)
192
+ result = {}
193
+
194
+ # Find dict def ... end blocks
195
+ # This is a simplified parser for Type 1 font dictionaries
196
+
197
+ # Extract key-value pairs using regex
198
+ # Patterns:
199
+ # /key value def
200
+ # /key (string) def
201
+ # /key [array] def
202
+ # /key number def
203
+
204
+ # Parse FontName - use bounded pattern to prevent ReDoS
205
+ # Font names are typically 1-64 characters of alphanumeric, dash, underscore
206
+ if (match = text.match(%r{/FontName\s+/([A-Za-z0-9_-]{1,64})\s+def}m))
207
+ result[:font_name] = match[1]
208
+ end
209
+
210
+ # Parse FontInfo entries
211
+ # These are typically at the top level or in a FontInfo sub-dictionary
212
+ # Format: /FullName (value) readonly def
213
+ # Use safer patterns with bounded content and optional readonly keyword
214
+ if (match = text.match(%r{/FullName\s+\(([^)]{1,128}?)\)\s+(?:readonly\s+)?def}m))
215
+ result[:full_name] = match[1]
216
+ end
217
+
218
+ if (match = text.match(%r{/FamilyName\s+\(([^)]{1,128}?)\)\s+(?:readonly\s+)?def}m))
219
+ result[:family_name] = match[1]
220
+ end
221
+
222
+ if (match = text.match(%r{/version\s+\(([^)]{1,128}?)\)\s+(?:readonly\s+)?def}m))
223
+ result[:version] = match[1]
224
+ end
225
+
226
+ if (match = text.match(%r{/Copyright\s+\(([^)]{1,128}?)\)\s+(?:readonly\s+)?def}m))
227
+ result[:copyright] = match[1]
228
+ end
229
+
230
+ if (match = text.match(%r{/Notice\s+\(([^)]{1,128}?)\)\s+(?:readonly\s+)?def}m))
231
+ result[:notice] = match[1]
232
+ end
233
+
234
+ if (match = text.match(%r{/Weight\s+\(([^)]{1,128}?)\)\s+(?:readonly\s+)?def}m))
235
+ result[:weight] = match[1]
236
+ end
237
+
238
+ if (match = text.match(%r{/isFixedPitch\s+(true|false)\s+def}m))
239
+ result[:is_fixed_pitch] = match[1] == "true"
240
+ end
241
+
242
+ if (match = text.match(%r{/UnderlinePosition\s+(-?\d+)\s+def}m))
243
+ result[:underline_position] = match[1].to_i
244
+ end
245
+
246
+ if (match = text.match(%r{/UnderlineThickness\s+(-?\d+)\s+def}m))
247
+ result[:underline_thickness] = match[1].to_i
248
+ end
249
+
250
+ if (match = text.match(%r{/ItalicAngle\s+(-?\d+)\s+def}m))
251
+ result[:italic_angle] = match[1].to_i
252
+ end
253
+
254
+ # Parse FontBBox - use safer patterns
255
+ if (match = text.match(%r{/FontBBox\s*\{([^\}]{1,100}?)\}\s+(?:readonly\s+)?def}m))
256
+ bbox_str = match[1].gsub(/[{}]/, "").strip.split
257
+ result[:font_b_box] = bbox_str.map(&:to_i) if bbox_str.length >= 4
258
+ elsif (match = text.match(%r{/FontBBox\s*\[([^\]]{1,100}?)\]\s+(?:readonly\s+)?def}m))
259
+ bbox_str = match[1].strip.split
260
+ result[:font_b_box] = bbox_str.map(&:to_i) if bbox_str.length >= 4
261
+ end
262
+
263
+ # Parse FontMatrix
264
+ if (match = text.match(%r{/FontMatrix\s*\[([^\]]{1,100}?)\]\s+(?:readonly\s+)?def}m))
265
+ matrix_str = match[1].strip.split
266
+ result[:font_matrix] = matrix_str.map(&:to_f)
267
+ end
268
+
269
+ # Parse PaintType
270
+ if (match = text.match(%r{/PaintType\s+(\d+)\s+def}m))
271
+ result[:paint_type] = match[1].to_i
272
+ end
273
+
274
+ # Parse FontType
275
+ if (match = text.match(%r{/FontType\s+(\d+)\s+def}m))
276
+ result[:font_type] = match[1].to_i
277
+ end
278
+
279
+ result
280
+ end
281
+
282
+ # Extract FontInfo sub-dictionary
283
+ def extract_font_info
284
+ @font_info.parse(@raw_data)
285
+ end
286
+
287
+ # Extract encoding
288
+ def extract_encoding
289
+ @encoding.parse(@raw_data)
290
+ end
291
+
292
+ # Extract standard properties
293
+ def extract_properties
294
+ @font_name = @raw_data[:font_name]
295
+ @font_b_box = @raw_data[:font_b_box] || [0, 0, 0, 0]
296
+ @font_matrix = @raw_data[:font_matrix] || [0.001, 0, 0, 0.001, 0, 0]
297
+ @paint_type = @raw_data[:paint_type] || 0
298
+ @font_type = @raw_data[:font_type] || 1
299
+ end
300
+
301
+ # Format FontMatrix for Type 1 output
302
+ #
303
+ # @return [String] Formatted FontMatrix definition
304
+ def font_matrix_to_type1
305
+ "/FontMatrix [#{@font_matrix.join(' ')}] def"
306
+ end
307
+
308
+ # Format FontBBox for Type 1 output
309
+ #
310
+ # @return [String] Formatted FontBBox definition
311
+ def font_bbox_to_type1
312
+ "/FontBBox {#{@font_b_box.join(' ')}} def"
313
+ end
314
+
315
+ # Format FontInfo for Type 1 output
316
+ #
317
+ # @return [String] Formatted FontInfo definitions
318
+ def font_info_to_type1
319
+ info = @font_info
320
+ result = []
321
+ result << "/FullName (#{info.full_name || 'Untitled'}) readonly def" if info.full_name
322
+ result << "/FamilyName (#{info.family_name || 'Untitled'}) readonly def" if info.family_name
323
+ result << "/version (#{info.version || '001.000'}) readonly def" if info.version
324
+ result << "/Copyright (#{info.copyright || ''}) readonly def" if info.copyright
325
+ result << "/Notice (#{info.notice || ''}) readonly def" if info.notice
326
+ result << "/Weight (#{info.weight || 'Medium'}) readonly def" if info.weight
327
+ result << "/isFixedPitch #{info.is_fixed_pitch || false} def" if info.is_fixed_pitch
328
+ result << "/UnderlinePosition #{info.underline_position || -100} def" if info.underline_position
329
+ result << "/UnderlineThickness #{info.underline_thickness || 50} def" if info.underline_thickness
330
+ result << "/ItalicAngle #{info.italic_angle || 0} def" if info.italic_angle
331
+
332
+ result.join("\n")
333
+ end
334
+
335
+ # FontInfo sub-dictionary
336
+ #
337
+ # Contains font metadata such as FullName, FamilyName, version, etc.
338
+ class FontInfo
339
+ # @return [String, nil] Full font name
340
+ attr_accessor :full_name
341
+
342
+ # @return [String, nil] Family name
343
+ attr_accessor :family_name
344
+
345
+ # @return [String, nil] Font version
346
+ attr_accessor :version
347
+
348
+ # @return [String, nil] Copyright notice
349
+ attr_accessor :copyright
350
+
351
+ # @return [String, nil] Notice string
352
+ attr_accessor :notice
353
+
354
+ # @return [String, nil] Font weight (Thin, Light, Regular, Bold, etc.)
355
+ attr_accessor :weight
356
+
357
+ # @return [String, nil] Fixed pitch (monospace) indicator
358
+ attr_accessor :is_fixed_pitch
359
+
360
+ # @return [String, nil] Underline position
361
+ attr_accessor :underline_position
362
+
363
+ # @return [String, nil] Underline thickness
364
+ attr_accessor :underline_thickness
365
+
366
+ # @return [String, nil] Italic angle
367
+ attr_accessor :italic_angle
368
+
369
+ # Parse FontInfo from dictionary data
370
+ #
371
+ # @param dict_data [Hash] Raw dictionary data
372
+ def parse(dict_data)
373
+ # FontInfo can be embedded in the main dict or as a sub-dict
374
+ # Try to extract from various patterns
375
+ @full_name = extract_string_value(dict_data, "FullName")
376
+ @family_name = extract_string_value(dict_data, "FamilyName")
377
+ @version = extract_string_value(dict_data, "version")
378
+ @copyright = extract_string_value(dict_data, "Copyright")
379
+ @notice = extract_string_value(dict_data, "Notice")
380
+ @weight = extract_string_value(dict_data, "Weight")
381
+ @is_fixed_pitch = extract_value(dict_data, "isFixedPitch")
382
+ @underline_position = extract_value(dict_data, "UnderlinePosition")
383
+ @underline_thickness = extract_value(dict_data, "UnderlineThickness")
384
+ @italic_angle = extract_value(dict_data, "ItalicAngle")
385
+ end
386
+
387
+ private
388
+
389
+ # Extract string value from dictionary
390
+ #
391
+ # @param dict_data [Hash] Dictionary data
392
+ # @param key [String] Key to extract
393
+ # @return [String, nil] String value or nil
394
+ def extract_string_value(dict_data, key)
395
+ val = extract_value(dict_data, key)
396
+ return nil if val.nil?
397
+
398
+ # Remove parentheses if present
399
+ val = val.to_s
400
+ val = val[1..-2] if val.start_with?("(") && val.end_with?(")")
401
+ val
402
+ end
403
+
404
+ # Extract value from dictionary
405
+ #
406
+ # @param dict_data [Hash] Dictionary data
407
+ # @param key [String] Key to extract
408
+ # @return [Object, nil] Value or nil
409
+ def extract_value(dict_data, key)
410
+ sym_key = key.to_sym
411
+ return dict_data[sym_key] if dict_data.key?(sym_key)
412
+
413
+ # Try underscore version (e.g., FullName => full_name)
414
+ underscore_key = key.gsub(/([A-Z])/, '_\1').downcase.sub(/^_/,
415
+ "").to_sym
416
+ dict_data[underscore_key]
417
+ end
418
+ end
419
+
420
+ # Encoding vector
421
+ #
422
+ # Maps character codes to glyph names in the font.
423
+ class Encoding
424
+ # @return [Hash] Character code to glyph name mapping
425
+ attr_reader :encoding_map
426
+
427
+ # @return [Symbol] Encoding type (:standard, :custom, :identity)
428
+ attr_reader :encoding_type
429
+
430
+ def initialize
431
+ @encoding_map = {}
432
+ @encoding_type = :standard
433
+ end
434
+
435
+ # Parse encoding from dictionary data
436
+ #
437
+ # @param dict_data [Hash] Dictionary data
438
+ def parse(dict_data)
439
+ # Type 1 fonts typically use StandardEncoding by default
440
+ # Custom encodings are specified as an array
441
+
442
+ @encoding_type = if dict_data[:encoding]
443
+ :custom
444
+ else
445
+ :standard
446
+ end
447
+
448
+ # Populate with StandardEncoding if standard
449
+ populate_standard_encoding if @encoding_type == :standard
450
+ end
451
+
452
+ # Get glyph name for character code
453
+ #
454
+ # @param char_code [Integer] Character code
455
+ # @return [String, nil] Glyph name or nil
456
+ def [](char_code)
457
+ @encoding_map[char_code]
458
+ end
459
+
460
+ # Check if encoding is standard
461
+ #
462
+ # @return [Boolean] True if using StandardEncoding
463
+ def standard?
464
+ @encoding_type == :standard
465
+ end
466
+
467
+ private
468
+
469
+ # Populate with Adobe StandardEncoding
470
+ def populate_standard_encoding
471
+ # A subset of Adobe StandardEncoding
472
+ # This is a simplified version for common characters
473
+ standard_mapping = {
474
+ 32 => "space",
475
+ 33 => "exclam",
476
+ 34 => "quotedbl",
477
+ 35 => "numbersign",
478
+ 36 => "dollar",
479
+ 37 => "percent",
480
+ 38 => "ampersand",
481
+ 39 => "quoteright",
482
+ 40 => "parenleft",
483
+ 41 => "parenright",
484
+ 42 => "asterisk",
485
+ 43 => "plus",
486
+ 44 => "comma",
487
+ 45 => "hyphen",
488
+ 46 => "period",
489
+ 47 => "slash",
490
+ 48 => "zero",
491
+ 49 => "one",
492
+ 50 => "two",
493
+ 51 => "three",
494
+ 52 => "four",
495
+ 53 => "five",
496
+ 54 => "six",
497
+ 55 => "seven",
498
+ 56 => "eight",
499
+ 57 => "nine",
500
+ 58 => "colon",
501
+ 59 => "semicolon",
502
+ 60 => "less",
503
+ 61 => "equal",
504
+ 62 => "greater",
505
+ 63 => "question",
506
+ 64 => "at",
507
+ 65 => "A",
508
+ 66 => "B",
509
+ 67 => "C",
510
+ 68 => "D",
511
+ 69 => "E",
512
+ 70 => "F",
513
+ 71 => "G",
514
+ 72 => "H",
515
+ 73 => "I",
516
+ 74 => "J",
517
+ 75 => "K",
518
+ 76 => "L",
519
+ 77 => "M",
520
+ 78 => "N",
521
+ 79 => "O",
522
+ 80 => "P",
523
+ 81 => "Q",
524
+ 82 => "R",
525
+ 83 => "S",
526
+ 84 => "T",
527
+ 85 => "U",
528
+ 86 => "V",
529
+ 87 => "W",
530
+ 88 => "X",
531
+ 89 => "Y",
532
+ 90 => "Z",
533
+ 91 => "bracketleft",
534
+ 92 => "backslash",
535
+ 93 => "bracketright",
536
+ 94 => "asciicircum",
537
+ 95 => "underscore",
538
+ 96 => "quoteleft",
539
+ 97 => "a",
540
+ 98 => "b",
541
+ 99 => "c",
542
+ 100 => "d",
543
+ 101 => "e",
544
+ 102 => "f",
545
+ 103 => "g",
546
+ 104 => "h",
547
+ 105 => "i",
548
+ 106 => "j",
549
+ 107 => "k",
550
+ 108 => "l",
551
+ 109 => "m",
552
+ 110 => "n",
553
+ 111 => "o",
554
+ 112 => "p",
555
+ 113 => "q",
556
+ 114 => "r",
557
+ 115 => "s",
558
+ 116 => "t",
559
+ 117 => "u",
560
+ 118 => "v",
561
+ 119 => "w",
562
+ 120 => "x",
563
+ 121 => "y",
564
+ 122 => "z",
565
+ 123 => "braceleft",
566
+ 124 => "bar",
567
+ 125 => "braceright",
568
+ 126 => "asciitilde",
569
+ }
570
+
571
+ @encoding_map = standard_mapping
572
+ end
573
+ end
574
+ end
575
+ end
576
+ end