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,433 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Type1
5
+ # PFM (Printer Font Metrics) file parser
6
+ #
7
+ # [`PFMParser`](lib/fontisan/type1/pfm_parser.rb) parses Printer Font Metrics
8
+ # files which contain font metric information for Type 1 fonts on Windows.
9
+ #
10
+ # PFM files are binary files that include:
11
+ # - Character widths
12
+ # - Kerning pairs
13
+ # - Font metadata (name, version, copyright, etc.)
14
+ # - Extended text metrics
15
+ #
16
+ # @example Parse a PFM file
17
+ # pfm = Fontisan::Type1::PFMParser.parse_file("font.pfm")
18
+ # puts pfm.font_name
19
+ # puts pfm.character_widths['A']
20
+ # puts pfm.kerning_pairs[['A', 'V']]
21
+ #
22
+ # @see https://www.adobe.com/devnet/font/pdfs/5005.PFM_Spec.pdf
23
+ class PFMParser
24
+ # PFM Header structure
25
+ PFM_HEADER_SIZE = 256
26
+ PFM_VERSION = 0x0100
27
+
28
+ # @return [String] Font name
29
+ attr_reader :font_name
30
+
31
+ # @return [String] Full name
32
+ attr_reader :full_name
33
+
34
+ # @return [String] Family name
35
+ attr_reader :family_name
36
+
37
+ # @return [String] Copyright notice
38
+ attr_reader :copyright
39
+
40
+ # @return [Hash<String, Integer>] Character widths (glyph index => width)
41
+ attr_reader :character_widths
42
+
43
+ # @return [Hash<Array(Integer), Integer>] Kerning pairs ([left_idx, right_idx] => adjustment)
44
+ attr_reader :kerning_pairs
45
+
46
+ # @return [Hash] Extended text metrics
47
+ attr_reader :extended_metrics
48
+
49
+ # @return [Integer] Font bounding box [llx, lly, urx, ury]
50
+ attr_reader :font_bbox
51
+
52
+ # @return [String] Raw data
53
+ attr_reader :raw_data
54
+
55
+ # Parse PFM file
56
+ #
57
+ # @param path [String] Path to PFM file
58
+ # @return [PFMParser] Parsed PFM data
59
+ # @raise [ArgumentError] If path is nil
60
+ # @raise [Fontisan::Error] If file cannot be read or parsed
61
+ def self.parse_file(path)
62
+ raise ArgumentError, "Path cannot be nil" if path.nil?
63
+
64
+ unless File.exist?(path)
65
+ raise Fontisan::Error, "PFM file not found: #{path}"
66
+ end
67
+
68
+ content = File.binread(path)
69
+ parse(content)
70
+ end
71
+
72
+ # Parse PFM content
73
+ #
74
+ # @param content [String] PFM file content (binary)
75
+ # @return [PFMParser] Parsed PFM data
76
+ def self.parse(content)
77
+ new.parse(content)
78
+ end
79
+
80
+ # Alias for parse method
81
+ def self.parse_string(content)
82
+ parse(content)
83
+ end
84
+
85
+ # Initialize a new PFMParser
86
+ def initialize
87
+ @character_widths = {}
88
+ @kerning_pairs = {}
89
+ @extended_metrics = {}
90
+ @font_bbox = nil
91
+ end
92
+
93
+ # Parse PFM content
94
+ #
95
+ # @param content [String] PFM file content (binary)
96
+ # @return [PFMParser] Self for method chaining
97
+ def parse(content)
98
+ @raw_data = content
99
+ parse_header(content)
100
+ parse_driver_info(content)
101
+ parse_extended_metrics(content)
102
+ parse_character_widths(content)
103
+ parse_kerning_pairs(content)
104
+ self
105
+ end
106
+
107
+ # Get character width for character index
108
+ #
109
+ # @param char_index [Integer] Character index
110
+ # @return [Integer, nil] Character width or nil if not found
111
+ def width(char_index)
112
+ @character_widths[char_index]
113
+ end
114
+
115
+ # Get kerning adjustment for character pair
116
+ #
117
+ # @param left_idx [Integer] Left character index
118
+ # @param right_idx [Integer] Right character index
119
+ # @return [Integer, nil] Kerning adjustment or nil if not found
120
+ def kerning(left_idx, right_idx)
121
+ @kerning_pairs[[left_idx, right_idx]]
122
+ end
123
+
124
+ # Check if character exists
125
+ #
126
+ # @param char_index [Integer] Character index
127
+ # @return [Boolean] True if character exists
128
+ def has_character?(char_index)
129
+ @character_widths.key?(char_index)
130
+ end
131
+
132
+ private
133
+
134
+ # Parse PFM header
135
+ #
136
+ # @param content [String] PFM content
137
+ def parse_header(content)
138
+ # Read first 256 bytes as header
139
+ return if content.length < PFM_HEADER_SIZE
140
+
141
+ # Version (2 bytes at offset 0)
142
+ read_uint16(content, 0)
143
+ # dfVersion = version
144
+
145
+ # Size info (4 bytes at offset 2)
146
+ # dfSize = read_uint32(content, 2)
147
+
148
+ # Copyright (60 bytes at offset 6)
149
+ @copyright = read_pascal_string(content[6, 60])
150
+
151
+ # Font type (2 bytes at offset 66)
152
+ # dfType = read_uint16(content, 66)
153
+
154
+ # Points (2 bytes at offset 68)
155
+ # dfPoints = read_uint16(content, 68)
156
+
157
+ # VertRes (2 bytes at offset 70)
158
+ # dfVertRes = read_uint16(content, 70)
159
+
160
+ # HorizRes (2 bytes at offset 72)
161
+ # dfHorizRes = read_uint16(content, 72)
162
+
163
+ # Ascent (2 bytes at offset 74)
164
+ # dfAscent = read_uint16(content, 74)
165
+
166
+ # InternalLeading (2 bytes at offset 76)
167
+ # dfInternalLeading = read_uint16(content, 76)
168
+
169
+ # ExternalLeading (2 bytes at offset 78)
170
+ # dfExternalLeading = read_uint16(content, 78)
171
+
172
+ # Italic (1 byte at offset 80)
173
+ # dfItalic = content.getbyte(80)
174
+
175
+ # Underline (1 byte at offset 81)
176
+ # dfUnderline = content.getbyte(81)
177
+
178
+ # StrikeOut (1 byte at offset 82)
179
+ # dfStrikeOut = content.getbyte(82)
180
+
181
+ # Weight (2 bytes at offset 83)
182
+ # dfWeight = read_uint16(content, 83)
183
+
184
+ # CharSet (1 byte at offset 85)
185
+ # dfCharSet = content.getbyte(85)
186
+
187
+ # PixWidth (2 bytes at offset 86)
188
+ # dfPixWidth = read_uint16(content, 86)
189
+
190
+ # PixHeight (2 bytes at offset 88)
191
+ # dfPixHeight = read_uint16(content, 88)
192
+
193
+ # PitchAndFamily (1 byte at offset 90)
194
+ # dfPitchAndFamily = content.getbyte(90)
195
+
196
+ # AverageWidth (2 bytes at offset 91)
197
+ # dfAverageWidth = read_uint16(content, 91)
198
+
199
+ # MaxWidth (2 bytes at offset 93)
200
+ # dfMaxWidth = read_uint16(content, 93)
201
+
202
+ # FirstChar (1 byte at offset 95)
203
+ # dfFirstChar = content.getbyte(95)
204
+
205
+ # LastChar (1 byte at offset 96)
206
+ # dfLastChar = content.getbyte(96)
207
+
208
+ # DefaultChar (1 byte at offset 97)
209
+ # dfDefaultChar = content.getbyte(97)
210
+
211
+ # BreakChar (1 byte at offset 98)
212
+ # dfBreakChar = content.getbyte(98)
213
+
214
+ # WidthBytes (2 bytes at offset 99)
215
+ # dfWidthBytes = read_uint16(content, 99)
216
+
217
+ # Device (4 bytes at offset 101)
218
+ # dfDevice = read_uint32(content, 101)
219
+
220
+ # Face (4 bytes at offset 105)
221
+ # dfFace = read_uint32(content, 105)
222
+
223
+ # Device name (usually empty in PFM)
224
+ # BitsPointer (4 bytes at offset 109)
225
+ # dfBitsPointer = read_uint32(content, 109)
226
+
227
+ # BitsOffset (4 bytes at offset 113)
228
+ # dfBitsOffset = read_uint32(content, 113)
229
+
230
+ # Font name offset (4 bytes at offset 117)
231
+ @dfFace_offset = read_uint32(content, 105)
232
+
233
+ # Ext metrics offset (4 bytes at offset 117)
234
+ @dfExtMetrics_offset = read_uint32(content, 117)
235
+
236
+ # Ext table offset (4 bytes at offset 121)
237
+ @dfExtentTable_offset = read_uint32(content, 121)
238
+
239
+ # Origin table offset (4 bytes at offset 125)
240
+ # dfOriginTable = read_uint32(content, 125)
241
+
242
+ # PairKernTable offset (4 bytes at offset 129)
243
+ @dfPairKernTable_offset = read_uint32(content, 129)
244
+
245
+ # TrackKernTable offset (4 bytes at offset 133)
246
+ # dfTrackKernTable = read_uint32(content, 133)
247
+
248
+ # DriverInfo offset (4 bytes at offset 137)
249
+ @dfDriverInfo_offset = read_uint32(content, 137)
250
+
251
+ # Reserved (4 bytes at offset 141)
252
+ # dfReserved = read_uint32(content, 141)
253
+
254
+ # Signature (4 bytes at offset 145)
255
+ # dfSignature = read_uint32(content, 145)
256
+ end
257
+
258
+ # Parse driver info to get font name
259
+ #
260
+ # @param content [String] PFM content
261
+ def parse_driver_info(content)
262
+ return unless @dfFace_offset&.positive?
263
+
264
+ # Read font name at dfFace offset (byte 105 in header)
265
+ # Font name is a Pascal-style string
266
+ offset = @dfFace_offset
267
+ return if offset >= content.length
268
+
269
+ @font_name = read_pascal_string(content[offset..])
270
+ end
271
+
272
+ # Parse extended text metrics
273
+ #
274
+ # @param content [String] PFM content
275
+ def parse_extended_metrics(content)
276
+ return unless @dfExtMetrics_offset&.positive?
277
+
278
+ offset = @dfExtMetrics_offset
279
+ return if offset + 48 > content.length
280
+
281
+ # Extended text metrics are 48 bytes
282
+ # etmSize (4 bytes)
283
+ # etmPointSize (4 bytes)
284
+ # etmOrientation (4 bytes)
285
+ # etmMasterHeight (4 bytes)
286
+ # etmMinScale (4 bytes)
287
+ # etmMaxScale (4 bytes)
288
+ # etmMasterUnits (4 bytes)
289
+ # etmCapHeight (4 bytes)
290
+ # etmXHeight (4 bytes)
291
+ # etmLowerCaseAscent (4 bytes)
292
+ # etmLowerCaseDescent (4 bytes)
293
+ # etmSlant (4 bytes)
294
+ # etmSuperScript (4 bytes)
295
+ # etmSubScript (4 bytes)
296
+ # etmSuperScriptSize (4 bytes)
297
+ # etmSubScriptSize (4 bytes)
298
+ # etmUnderlineOffset (4 bytes)
299
+ # etmUnderlineWidth (4 bytes)
300
+ # etmDoubleUpperUnderlineOffset (4 bytes)
301
+ # etmDoubleLowerUnderlineOffset (4 bytes)
302
+ # etmDoubleUpperUnderlineWidth (4 bytes)
303
+ # etmDoubleLowerUnderlineWidth (4 bytes)
304
+ # etmStrikeOutOffset (4 bytes)
305
+ # etmStrikeOutWidth (4 bytes)
306
+ # etmKernPairs (4 bytes)
307
+ # etmKernTracks (4 bytes)
308
+
309
+ # Just read some key metrics
310
+ @extended_metrics[:cap_height] = read_int32(content, offset + 28)
311
+ @extended_metrics[:x_height] = read_int32(content, offset + 32)
312
+ end
313
+
314
+ # Parse character width table
315
+ #
316
+ # @param content [String] PFM content
317
+ def parse_character_widths(content)
318
+ return unless @dfExtentTable_offset&.positive?
319
+
320
+ offset = @dfExtentTable_offset
321
+ return if offset >= content.length
322
+
323
+ # Read extent table
324
+ # The extent table is an array of 2-byte values
325
+ # First value is the number of extents
326
+ num_extents = read_uint16(content, offset)
327
+ offset += 2
328
+
329
+ num_extents.times do |i|
330
+ break if offset + 2 > content.length
331
+
332
+ width = read_uint16(content, offset)
333
+ @character_widths[i] = width
334
+ offset += 2
335
+ end
336
+ end
337
+
338
+ # Parse kerning pairs
339
+ #
340
+ # @param content [String] PFM content
341
+ def parse_kerning_pairs(content)
342
+ return unless @dfPairKernTable_offset&.positive?
343
+
344
+ offset = @dfPairKernTable_offset
345
+ return if offset >= content.length
346
+
347
+ # Read number of kern pairs (2 bytes)
348
+ num_pairs = read_uint16(content, offset)
349
+ return if num_pairs.zero?
350
+
351
+ offset += 2
352
+ # Skip size info (2 bytes)
353
+ offset += 2
354
+
355
+ # Each kern pair is 6 bytes:
356
+ # - First character index (2 bytes)
357
+ # - Second character index (2 bytes)
358
+ # - Kerning amount (2 bytes)
359
+ num_pairs.times do
360
+ break if offset + 6 > content.length
361
+
362
+ first = read_uint16(content, offset)
363
+ second = read_uint16(content, offset + 2)
364
+ amount = read_int16(content, offset + 4)
365
+
366
+ @kerning_pairs[[first, second]] = amount
367
+ offset += 6
368
+ end
369
+ end
370
+
371
+ # Read 16-bit unsigned integer (little-endian)
372
+ #
373
+ # @param data [String] Binary data
374
+ # @param offset [Integer] Offset to read from
375
+ # @return [Integer] 16-bit unsigned integer
376
+ def read_uint16(data, offset)
377
+ return 0 if offset + 2 > data.length
378
+
379
+ data.getbyte(offset) | (data.getbyte(offset + 1) << 8)
380
+ end
381
+
382
+ # Read 32-bit unsigned integer (little-endian)
383
+ #
384
+ # @param data [String] Binary data
385
+ # @param offset [Integer] Offset to read from
386
+ # @return [Integer] 32-bit unsigned integer
387
+ def read_uint32(data, offset)
388
+ return 0 if offset + 4 > data.length
389
+
390
+ data.getbyte(offset) |
391
+ (data.getbyte(offset + 1) << 8) |
392
+ (data.getbyte(offset + 2) << 16) |
393
+ (data.getbyte(offset + 3) << 24)
394
+ end
395
+
396
+ # Read 32-bit signed integer (little-endian)
397
+ #
398
+ # @param data [String] Binary data
399
+ # @param offset [Integer] Offset to read from
400
+ # @return [Integer] 32-bit signed integer
401
+ def read_int32(data, offset)
402
+ value = read_uint32(data, offset)
403
+ # Convert to signed
404
+ value >= 0x80000000 ? value - 0x100000000 : value
405
+ end
406
+
407
+ # Read 16-bit signed integer (little-endian)
408
+ #
409
+ # @param data [String] Binary data
410
+ # @param offset [Integer] Offset to read from
411
+ # @return [Integer] 16-bit signed integer
412
+ def read_int16(data, offset)
413
+ value = read_uint16(data, offset)
414
+ # Convert to signed
415
+ value >= 0x8000 ? value - 0x10000 : value
416
+ end
417
+
418
+ # Read Pascal-style string
419
+ #
420
+ # @param data [String] Binary data starting with length byte
421
+ # @return [String] String value
422
+ def read_pascal_string(data)
423
+ return "" if data.nil? || data.empty?
424
+
425
+ length = data.getbyte(0)
426
+ return "" if length.nil? || length.zero? || length > data.length - 1
427
+
428
+ data[1, length].to_s.force_encoding("ASCII-8BIT").encode("UTF-8",
429
+ invalid: :replace, undef: :replace)
430
+ end
431
+ end
432
+ end
433
+ end
@@ -0,0 +1,285 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Type1
5
+ # Type 1 Private Dictionary model
6
+ #
7
+ # [`PrivateDict`](lib/fontisan/type1/private_dict.rb) parses and stores
8
+ # the private dictionary from a Type 1 font, which contains hinting
9
+ # and spacing information used by the CharString interpreter.
10
+ #
11
+ # The private dictionary includes:
12
+ # - BlueValues and OtherBlues (alignment zones for optical consistency)
13
+ # - StdHW and StdVW (standard stem widths)
14
+ # - StemSnapH and StemSnapV (stem snap arrays)
15
+ # - Subrs (local subroutines)
16
+ # - lenIV (CharString encryption IV length)
17
+ #
18
+ # @example Parse private dictionary from decrypted font data
19
+ # priv = Fontisan::Type1::PrivateDict.parse(decrypted_data)
20
+ # puts priv.blue_values
21
+ # puts priv.std_hw
22
+ #
23
+ # @see https://www.adobe.com/devnet/font/pdfs/Type1.pdf
24
+ class PrivateDict
25
+ # @return [Array<Integer>] BlueValues alignment zones
26
+ attr_accessor :blue_values
27
+
28
+ # @return [Array<Integer>] OtherBlues alignment zones
29
+ attr_accessor :other_blues
30
+
31
+ # @return [Array<Integer>] FamilyBlues alignment zones
32
+ attr_accessor :family_blues
33
+
34
+ # @return [Array<Integer>] FamilyOtherBlues alignment zones
35
+ attr_accessor :family_other_blues
36
+
37
+ # @return [Float] BlueScale
38
+ attr_accessor :blue_scale
39
+
40
+ # @return [Integer] BlueShift
41
+ attr_accessor :blue_shift
42
+
43
+ # @return [Integer] BlueFuzz
44
+ attr_accessor :blue_fuzz
45
+
46
+ # @return [Array<Float>] StdHW (standard horizontal width)
47
+ attr_accessor :std_hw
48
+
49
+ # @return [Array<Float>] StdVW (standard vertical width)
50
+ attr_accessor :std_vw
51
+
52
+ # @return [Array<Float>] StemSnapH (horizontal stem snap array)
53
+ attr_accessor :stem_snap_h
54
+
55
+ # @return [Array<Float>] StemSnapV (vertical stem snap array)
56
+ attr_accessor :stem_snap_v
57
+
58
+ # @return [Boolean] ForceBold flag
59
+ attr_accessor :force_bold
60
+
61
+ # @return [Integer] lenIV (CharString encryption IV length)
62
+ attr_accessor :len_iv
63
+
64
+ # @return [Array<String>] Subrs (local subroutines)
65
+ attr_accessor :subrs
66
+
67
+ # @return [Integer] LanguageGroup (0 for Latin, 1 for Japanese, etc.)
68
+ attr_accessor :language_group
69
+
70
+ # @return [Float] ExpansionFactor for counter widening
71
+ attr_accessor :expansion_factor
72
+
73
+ # @return [Integer] InitialRandomSeed for randomization
74
+ attr_accessor :initial_random_seed
75
+
76
+ # @return [Hash] Raw dictionary data
77
+ attr_reader :raw_data
78
+
79
+ # Parse private dictionary from decrypted Type 1 font data
80
+ #
81
+ # @param data [String] Decrypted Type 1 font data
82
+ # @return [PrivateDict] Parsed private dictionary
83
+ # @raise [Fontisan::Error] If dictionary cannot be parsed
84
+ #
85
+ # @example Parse from decrypted font data
86
+ # priv = Fontisan::Type1::PrivateDict.parse(decrypted_data)
87
+ def self.parse(data)
88
+ new.parse(data)
89
+ end
90
+
91
+ # Initialize a new PrivateDict
92
+ def initialize
93
+ @blue_values = []
94
+ @other_blues = []
95
+ @family_blues = []
96
+ @family_other_blues = []
97
+ @blue_scale = 0.039625
98
+ @blue_shift = 7
99
+ @blue_fuzz = 1
100
+ @std_hw = []
101
+ @std_vw = []
102
+ @stem_snap_h = []
103
+ @stem_snap_v = []
104
+ @force_bold = false
105
+ @len_iv = 4
106
+ @subrs = []
107
+ @language_group = 0
108
+ @expansion_factor = 0.06
109
+ @initial_random_seed = 0
110
+ @raw_data = {}
111
+ @parsed = false
112
+ end
113
+
114
+ # Parse private dictionary from decrypted Type 1 font data
115
+ #
116
+ # @param data [String] Decrypted Type 1 font data
117
+ # @return [PrivateDict] Self for method chaining
118
+ def parse(data)
119
+ extract_private_dict(data)
120
+ extract_properties
121
+ @parsed = true
122
+ self
123
+ end
124
+
125
+ # Check if dictionary was successfully parsed
126
+ #
127
+ # @return [Boolean] True if dictionary has been parsed
128
+ def parsed?
129
+ @parsed
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
+ # Get effective BlueValues for hinting
141
+ #
142
+ # Returns BlueValues adjusted by BlueScale.
143
+ #
144
+ # @return [Array<Float>] Scaled blue values
145
+ def effective_blue_values
146
+ return [] if @blue_values.empty?
147
+
148
+ @blue_values.map { |v| v * @blue_scale }
149
+ end
150
+
151
+ # Check if font has blues
152
+ #
153
+ # @return [Boolean] True if BlueValues or OtherBlues are defined
154
+ def has_blues?
155
+ !@blue_values.empty? || !@other_blues.empty?
156
+ end
157
+
158
+ # Check if font has stem hints
159
+ #
160
+ # @return [Boolean] True if StdHW, StdVW, or StemSnap arrays are defined
161
+ def has_stem_hints?
162
+ !@std_hw.empty? || !@std_vw.empty? ||
163
+ !@stem_snap_h.empty? || !@stem_snap_v.empty?
164
+ end
165
+
166
+ private
167
+
168
+ # Extract private dictionary from data
169
+ #
170
+ # @param data [String] Decrypted Type 1 font data
171
+ def extract_private_dict(data)
172
+ # Find the Private dictionary definition
173
+ # Type 1 fonts have: /Private <dict_size> dict def ... end
174
+
175
+ # Look for /Private dict def pattern - use safer pattern
176
+ # Match until we find the matching 'end' keyword
177
+ private_match = data.match(%r{/Private\s+\d+\s+dict\s+def\b(.*)end}m)
178
+ return if private_match.nil?
179
+
180
+ private_text = private_match[1]
181
+ @raw_data = parse_private_dict_text(private_text)
182
+ end
183
+
184
+ # Parse private dictionary text
185
+ #
186
+ # @param text [String] Private dictionary text
187
+ # @return [Hash] Parsed key-value pairs
188
+ def parse_private_dict_text(text)
189
+ result = {}
190
+
191
+ # Parse BlueValues array
192
+ if (match = text.match(/\/BlueValues\s*\[([^\]]+)\]\s+def/m))
193
+ result[:blue_values] = parse_array(match[1])
194
+ end
195
+
196
+ # Parse OtherBlues array
197
+ if (match = text.match(/\/OtherBlues\s*\[([^\]]+)\]\s+def/m))
198
+ result[:other_blues] = parse_array(match[1])
199
+ end
200
+
201
+ # Parse FamilyBlues array
202
+ if (match = text.match(/\/FamilyBlues\s*\[([^\]]+)\]\s+def/m))
203
+ result[:family_blues] = parse_array(match[1])
204
+ end
205
+
206
+ # Parse FamilyOtherBlues array
207
+ if (match = text.match(/\/FamilyOtherBlues\s*\[([^\]]+)\]\s+def/m))
208
+ result[:family_other_blues] = parse_array(match[1])
209
+ end
210
+
211
+ # Parse BlueScale
212
+ if (match = text.match(/\/BlueScale\s+([0-9.-]+)\s+def/m))
213
+ result[:blue_scale] = match[1].to_f
214
+ end
215
+
216
+ # Parse BlueShift
217
+ if (match = text.match(/\/BlueShift\s+(\d+)\s+def/m))
218
+ result[:blue_shift] = match[1].to_i
219
+ end
220
+
221
+ # Parse BlueFuzz
222
+ if (match = text.match(/\/BlueFuzz\s+(\d+)\s+def/m))
223
+ result[:blue_fuzz] = match[1].to_i
224
+ end
225
+
226
+ # Parse StdHW array
227
+ if (match = text.match(/\/StdHW\s*\[([^\]]+)\]\s+def/m))
228
+ result[:std_hw] = parse_array(match[1]).map(&:to_f)
229
+ end
230
+
231
+ # Parse StdVW array
232
+ if (match = text.match(/\/StdVW\s*\[([^\]]+)\]\s+def/m))
233
+ result[:std_vw] = parse_array(match[1]).map(&:to_f)
234
+ end
235
+
236
+ # Parse StemSnapH array
237
+ if (match = text.match(/\/StemSnapH\s*\[([^\]]+)\]\s+def/m))
238
+ result[:stem_snap_h] = parse_array(match[1]).map(&:to_f)
239
+ end
240
+
241
+ # Parse StemSnapV array
242
+ if (match = text.match(/\/StemSnapV\s*\[([^\]]+)\]\s+def/m))
243
+ result[:stem_snap_v] = parse_array(match[1]).map(&:to_f)
244
+ end
245
+
246
+ # Parse ForceBold
247
+ if (match = text.match(/\/ForceBold\s+(true|false)\s+def/m))
248
+ result[:force_bold] = match[1] == "true"
249
+ end
250
+
251
+ # Parse lenIV
252
+ if (match = text.match(/\/lenIV\s+(\d+)\s+def/m))
253
+ result[:len_iv] = match[1].to_i
254
+ end
255
+
256
+ result
257
+ end
258
+
259
+ # Parse array from string
260
+ #
261
+ # @param str [String] Array string (e.g., "1 2 3 4")
262
+ # @return [Array<Integer>] Parsed integers
263
+ def parse_array(str)
264
+ str.strip.split.map(&:strip).reject(&:empty?).map(&:to_i)
265
+ end
266
+
267
+ # Extract properties from raw data
268
+ def extract_properties
269
+ @blue_values = @raw_data[:blue_values] || []
270
+ @other_blues = @raw_data[:other_blues] || []
271
+ @family_blues = @raw_data[:family_blues] || []
272
+ @family_other_blues = @raw_data[:family_other_blues] || []
273
+ @blue_scale = @raw_data[:blue_scale] || 0.039625
274
+ @blue_shift = @raw_data[:blue_shift] || 7
275
+ @blue_fuzz = @raw_data[:blue_fuzz] || 1
276
+ @std_hw = @raw_data[:std_hw] || []
277
+ @std_vw = @raw_data[:std_vw] || []
278
+ @stem_snap_h = @raw_data[:stem_snap_h] || []
279
+ @stem_snap_v = @raw_data[:stem_snap_v] || []
280
+ @force_bold = @raw_data[:force_bold] || false
281
+ @len_iv = @raw_data[:len_iv] || 4
282
+ end
283
+ end
284
+ end
285
+ end