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