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.
- checksums.yaml +4 -4
- data/.rubocop_todo.yml +214 -51
- data/README.adoc +160 -0
- data/lib/fontisan/cli.rb +177 -6
- data/lib/fontisan/commands/convert_command.rb +32 -1
- data/lib/fontisan/config/conversion_matrix.yml +132 -4
- data/lib/fontisan/constants.rb +12 -0
- data/lib/fontisan/conversion_options.rb +378 -0
- data/lib/fontisan/converters/collection_converter.rb +45 -10
- data/lib/fontisan/converters/format_converter.rb +2 -0
- data/lib/fontisan/converters/outline_converter.rb +78 -4
- data/lib/fontisan/converters/type1_converter.rb +559 -0
- data/lib/fontisan/font_loader.rb +46 -3
- data/lib/fontisan/type1/afm_generator.rb +436 -0
- data/lib/fontisan/type1/afm_parser.rb +298 -0
- data/lib/fontisan/type1/agl.rb +456 -0
- data/lib/fontisan/type1/charstring_converter.rb +240 -0
- data/lib/fontisan/type1/charstrings.rb +408 -0
- data/lib/fontisan/type1/conversion_options.rb +243 -0
- data/lib/fontisan/type1/decryptor.rb +183 -0
- data/lib/fontisan/type1/encodings.rb +697 -0
- data/lib/fontisan/type1/font_dictionary.rb +514 -0
- data/lib/fontisan/type1/generator.rb +220 -0
- data/lib/fontisan/type1/inf_generator.rb +332 -0
- data/lib/fontisan/type1/pfa_generator.rb +343 -0
- data/lib/fontisan/type1/pfa_parser.rb +158 -0
- data/lib/fontisan/type1/pfb_generator.rb +291 -0
- data/lib/fontisan/type1/pfb_parser.rb +166 -0
- data/lib/fontisan/type1/pfm_generator.rb +610 -0
- data/lib/fontisan/type1/pfm_parser.rb +433 -0
- data/lib/fontisan/type1/private_dict.rb +285 -0
- data/lib/fontisan/type1/ttf_to_type1_converter.rb +327 -0
- data/lib/fontisan/type1/upm_scaler.rb +118 -0
- data/lib/fontisan/type1.rb +73 -0
- data/lib/fontisan/type1_font.rb +331 -0
- data/lib/fontisan/version.rb +1 -1
- data/lib/fontisan.rb +2 -0
- 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
|