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.
- checksums.yaml +4 -4
- data/.rubocop_todo.yml +294 -52
- data/Gemfile +5 -0
- data/README.adoc +163 -2
- data/docs/CONVERSION_GUIDE.adoc +633 -0
- data/docs/TYPE1_FONTS.adoc +445 -0
- data/lib/fontisan/cli.rb +177 -6
- data/lib/fontisan/commands/convert_command.rb +32 -1
- data/lib/fontisan/commands/info_command.rb +83 -2
- 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 +17 -5
- data/lib/fontisan/converters/outline_converter.rb +78 -4
- data/lib/fontisan/converters/type1_converter.rb +1234 -0
- data/lib/fontisan/font_loader.rb +46 -3
- data/lib/fontisan/hints/hint_converter.rb +4 -1
- 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/cff_to_type1_converter.rb +302 -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 +576 -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 +369 -0
- data/lib/fontisan/type1/pfa_parser.rb +159 -0
- data/lib/fontisan/type1/pfb_generator.rb +314 -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 +342 -0
- data/lib/fontisan/type1/seac_expander.rb +501 -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 +75 -0
- data/lib/fontisan/type1_font.rb +318 -0
- data/lib/fontisan/version.rb +1 -1
- data/lib/fontisan.rb +2 -0
- metadata +30 -3
- data/docs/DOCUMENTATION_SUMMARY.md +0 -141
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "pfb_generator"
|
|
4
|
+
require_relative "decryptor"
|
|
5
|
+
|
|
6
|
+
module Fontisan
|
|
7
|
+
module Type1
|
|
8
|
+
# PFA (Printer Font ASCII) Generator
|
|
9
|
+
#
|
|
10
|
+
# [`PFAGenerator`](lib/fontisan/type1/pfa_generator.rb) generates Type 1 PFA files
|
|
11
|
+
# from TrueType fonts.
|
|
12
|
+
#
|
|
13
|
+
# PFA files are ASCII-encoded Type 1 fonts used by Unix systems.
|
|
14
|
+
# They are the same as PFB files but with binary data hex-encoded.
|
|
15
|
+
#
|
|
16
|
+
# @example Generate PFA from TTF
|
|
17
|
+
# font = Fontisan::FontLoader.load("font.ttf")
|
|
18
|
+
# pfa_data = Fontisan::Type1::PFAGenerator.generate(font)
|
|
19
|
+
# File.write("font.pfa", pfa_data)
|
|
20
|
+
#
|
|
21
|
+
# @example Generate PFA with custom options
|
|
22
|
+
# options = { upm_scale: 1000, format: :pfa }
|
|
23
|
+
# pfa_data = Fontisan::Type1::PFAGenerator.generate(font, options)
|
|
24
|
+
#
|
|
25
|
+
# @see https://www.adobe.com/devnet/font/pdfs/5178.Type1.pdf
|
|
26
|
+
class PFAGenerator
|
|
27
|
+
# Hex line length for ASCII encoding
|
|
28
|
+
HEX_LINE_LENGTH = 64
|
|
29
|
+
|
|
30
|
+
# Generate PFA from TTF font
|
|
31
|
+
#
|
|
32
|
+
# @param font [Fontisan::Font] Source TTF font
|
|
33
|
+
# @param options [Hash] Generation options
|
|
34
|
+
# @option options [Integer, :native] :upm_scale Target UPM (default: 1000)
|
|
35
|
+
# @option options [Class] :encoding Encoding class (default: Encodings::AdobeStandard)
|
|
36
|
+
# @option options [Boolean] :convert_curves Convert quadratic to cubic (default: true)
|
|
37
|
+
# @return [String] PFA file content (ASCII text)
|
|
38
|
+
def self.generate(font, options = {})
|
|
39
|
+
new(font, options).generate
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def initialize(font, options = {})
|
|
43
|
+
@font = font
|
|
44
|
+
@options = options
|
|
45
|
+
@metrics = MetricsCalculator.new(font)
|
|
46
|
+
|
|
47
|
+
# Set up scaler
|
|
48
|
+
upm_scale = options[:upm_scale] || 1000
|
|
49
|
+
@scaler = if upm_scale == :native
|
|
50
|
+
UPMScaler.native(font)
|
|
51
|
+
else
|
|
52
|
+
UPMScaler.new(font, target_upm: upm_scale)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Set up encoding
|
|
56
|
+
@encoding = options[:encoding] || Encodings::AdobeStandard
|
|
57
|
+
|
|
58
|
+
# Set up converter options
|
|
59
|
+
@convert_curves = options.fetch(:convert_curves, true)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Generate PFA file content
|
|
63
|
+
#
|
|
64
|
+
# @return [String] PFA ASCII content
|
|
65
|
+
def generate
|
|
66
|
+
lines = []
|
|
67
|
+
|
|
68
|
+
# Header (ASCII section 1)
|
|
69
|
+
lines << build_pfa_header
|
|
70
|
+
lines << build_font_dict
|
|
71
|
+
lines << build_private_dict
|
|
72
|
+
lines << ""
|
|
73
|
+
lines << "currentdict end"
|
|
74
|
+
lines << "dup /FontName get exch definefont pop"
|
|
75
|
+
lines << ""
|
|
76
|
+
|
|
77
|
+
# Binary section (hex-encoded)
|
|
78
|
+
lines << "%--Data to be hex-encoded:"
|
|
79
|
+
hex_data = build_hex_encoded_charstrings
|
|
80
|
+
lines.concat(hex_data)
|
|
81
|
+
lines << ""
|
|
82
|
+
|
|
83
|
+
# Trailer
|
|
84
|
+
lines << build_pfa_trailer
|
|
85
|
+
|
|
86
|
+
lines.join("\n")
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
private
|
|
90
|
+
|
|
91
|
+
# Build PFA header
|
|
92
|
+
#
|
|
93
|
+
# @return [String] PFA header comment
|
|
94
|
+
def build_pfa_header
|
|
95
|
+
format("%%!PS-AdobeFont-1.0: %s 1.0\n", @font.post_script_name)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Build font dictionary
|
|
99
|
+
#
|
|
100
|
+
# @return [String] Font dictionary in PostScript
|
|
101
|
+
def build_font_dict
|
|
102
|
+
dict = []
|
|
103
|
+
dict << "25 dict begin"
|
|
104
|
+
|
|
105
|
+
# Font type
|
|
106
|
+
dict << "/FontType 1 def"
|
|
107
|
+
dict << "/FontMatrix [0.001 0 0 0.001 0 0] def"
|
|
108
|
+
|
|
109
|
+
# Font info
|
|
110
|
+
name_table = @font.table(Constants::NAME_TAG)
|
|
111
|
+
if name_table
|
|
112
|
+
font_name = name_table.english_name(Tables::Name::POSTSCRIPT_NAME) || @font.post_script_name
|
|
113
|
+
dict << "/FontName /#{font_name} def"
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Bounding box
|
|
117
|
+
head = @font.table(Constants::HEAD_TAG)
|
|
118
|
+
if head
|
|
119
|
+
bbox = [
|
|
120
|
+
@scaler.scale(head.x_min || 0),
|
|
121
|
+
@scaler.scale(head.y_min || 0),
|
|
122
|
+
@scaler.scale(head.x_max || 1000),
|
|
123
|
+
@scaler.scale(head.y_max || 1000),
|
|
124
|
+
]
|
|
125
|
+
dict << "/FontBBox {#{bbox.join(' ')}} def"
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Paint type
|
|
129
|
+
dict << "/PaintType 0 def"
|
|
130
|
+
|
|
131
|
+
# Encoding
|
|
132
|
+
if @encoding == Encodings::AdobeStandard
|
|
133
|
+
dict << "/Encoding StandardEncoding def"
|
|
134
|
+
elsif @encoding == Encodings::ISOLatin1
|
|
135
|
+
dict << "/Encoding ISOLatin1Encoding def"
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Font info
|
|
139
|
+
if name_table
|
|
140
|
+
if name_table.respond_to?(:version_string)
|
|
141
|
+
version = name_table.version_string(1) || name_table.version_string(3)
|
|
142
|
+
dict << "/Version (#{version}) def" if version
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
if name_table.respond_to?(:copyright)
|
|
146
|
+
copyright = name_table.copyright(1) || name_table.copyright(3)
|
|
147
|
+
dict << "/Notice (#{copyright}) def" if copyright
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
dict << "currentdict end"
|
|
152
|
+
dict << "begin"
|
|
153
|
+
|
|
154
|
+
dict.join("\n")
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Build Private dictionary
|
|
158
|
+
#
|
|
159
|
+
# @return [String] Private dictionary in PostScript
|
|
160
|
+
def build_private_dict
|
|
161
|
+
dict = []
|
|
162
|
+
dict << "/Private 15 dict begin"
|
|
163
|
+
|
|
164
|
+
# Blue values (for hinting)
|
|
165
|
+
# These are typically derived from the font's alignment zones
|
|
166
|
+
os2 = @font.table(Constants::OS2_TAG)
|
|
167
|
+
if os2.respond_to?(:typo_ascender) && os2.typo_ascender
|
|
168
|
+
blue_values = [
|
|
169
|
+
@scaler.scale(os2.typo_descender || -200),
|
|
170
|
+
@scaler.scale(os2.typo_descender || -200) + 20,
|
|
171
|
+
@scaler.scale(os2.typo_ascender),
|
|
172
|
+
@scaler.scale(os2.typo_ascender) + 10,
|
|
173
|
+
]
|
|
174
|
+
dict << "/BlueValues {#{blue_values.join(' ')}} def"
|
|
175
|
+
else
|
|
176
|
+
dict << "/BlueValues [-20 0 500 510] def"
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
dict << "/BlueScale 0.039625 def"
|
|
180
|
+
dict << "/BlueShift 7 def"
|
|
181
|
+
dict << "/BlueFuzz 1 def"
|
|
182
|
+
|
|
183
|
+
# Stem snap hints
|
|
184
|
+
if os2.respond_to?(:weight_class) && os2.weight_class
|
|
185
|
+
stem_width = @scaler.scale([100, 80,
|
|
186
|
+
90][os2.weight_class / 100] || 80)
|
|
187
|
+
dict << "/StemSnapH [#{stem_width}] def"
|
|
188
|
+
dict << "/StemSnapV [#{stem_width}] def"
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# Force bold flag
|
|
192
|
+
dict << if os2.respond_to?(:weight_class) && os2.weight_class && os2.weight_class >= 700
|
|
193
|
+
"/ForceBold true def"
|
|
194
|
+
else
|
|
195
|
+
"/ForceBold false def"
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Language group
|
|
199
|
+
dict << "/LanguageGroup 0 def"
|
|
200
|
+
|
|
201
|
+
# Unique ID (random)
|
|
202
|
+
dict << "/UniqueID #{rand(1000000..9999999)} def"
|
|
203
|
+
|
|
204
|
+
# Subrs (empty for now)
|
|
205
|
+
dict << "/Subrs 0 array def"
|
|
206
|
+
|
|
207
|
+
dict << "private dict begin"
|
|
208
|
+
dict << "end"
|
|
209
|
+
|
|
210
|
+
dict.join("\n")
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# Build hex-encoded CharStrings section
|
|
214
|
+
#
|
|
215
|
+
# @return [Array<String>] Array of hex-encoded lines
|
|
216
|
+
def build_hex_encoded_charstrings
|
|
217
|
+
# Generate CharStrings
|
|
218
|
+
charstrings = if @convert_curves
|
|
219
|
+
TTFToType1Converter.convert(@font, @scaler, @encoding)
|
|
220
|
+
else
|
|
221
|
+
generate_simple_charstrings
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# Build CharStrings dictionary text
|
|
225
|
+
charstrings_dict = build_charstrings_dict_text(charstrings)
|
|
226
|
+
|
|
227
|
+
# Encrypt with eexec
|
|
228
|
+
encrypted_data = Decryptor.eexec_encrypt(charstrings_dict)
|
|
229
|
+
|
|
230
|
+
# Convert encrypted data to hex representation
|
|
231
|
+
hex_lines = []
|
|
232
|
+
|
|
233
|
+
# Start hex section marker
|
|
234
|
+
hex_lines << "00" # Start binary data marker
|
|
235
|
+
|
|
236
|
+
# Encode encrypted data as hex with line breaks
|
|
237
|
+
hex_string = encrypted_data.bytes.map { |b| format("%02x", b) }.join
|
|
238
|
+
|
|
239
|
+
# Split into lines of HEX_LINE_LENGTH characters
|
|
240
|
+
hex_string.scan(/.{#{HEX_LINE_LENGTH}}/o) do |line|
|
|
241
|
+
hex_lines << line
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# End hex section marker
|
|
245
|
+
hex_lines << "00" # End binary data marker
|
|
246
|
+
|
|
247
|
+
hex_lines
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# Build CharStrings dictionary text for eexec encryption
|
|
251
|
+
#
|
|
252
|
+
# @param charstrings [Hash] Glyph ID to CharString mapping
|
|
253
|
+
# @return [String] CharStrings dictionary text
|
|
254
|
+
def build_charstrings_dict_text(charstrings)
|
|
255
|
+
lines = []
|
|
256
|
+
lines << "dup /FontName get exch definefont pop"
|
|
257
|
+
lines << "begin"
|
|
258
|
+
lines << "/CharStrings #{charstrings.size} dict dup begin"
|
|
259
|
+
|
|
260
|
+
charstrings.each do |gid, charstring|
|
|
261
|
+
# Convert charstring bytes to hex representation for PostScript
|
|
262
|
+
hex_data = charstring.unpack1("H*")
|
|
263
|
+
lines << "#{gid} #{charstring.bytesize} #{hex_data} RD"
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
lines << "end"
|
|
267
|
+
lines << "end"
|
|
268
|
+
lines << "put"
|
|
269
|
+
lines.join("\n")
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# Generate simple CharStrings (without curve conversion)
|
|
273
|
+
#
|
|
274
|
+
# @return [Hash<Integer, String>] Glyph ID to CharString mapping
|
|
275
|
+
def generate_simple_charstrings
|
|
276
|
+
glyf_table = @font.table(Constants::GLYF_TAG)
|
|
277
|
+
return {} unless glyf_table
|
|
278
|
+
|
|
279
|
+
maxp = @font.table(Constants::MAXP_TAG)
|
|
280
|
+
num_glyphs = maxp&.num_glyphs || 0
|
|
281
|
+
|
|
282
|
+
charstrings = {}
|
|
283
|
+
num_glyphs.times do |gid|
|
|
284
|
+
charstrings[gid] = simple_charstring(glyf_table, gid)
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
charstrings
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
# Generate a simple CharString for a glyph
|
|
291
|
+
#
|
|
292
|
+
# @param glyf_table [Object] TTF glyf table
|
|
293
|
+
# @param gid [Integer] Glyph ID
|
|
294
|
+
# @return [String] Type 1 CharString data
|
|
295
|
+
def simple_charstring(glyf_table, gid)
|
|
296
|
+
glyph = glyf_table.glyph(gid)
|
|
297
|
+
|
|
298
|
+
# Empty or compound glyph
|
|
299
|
+
if glyph.nil? || glyph.contour_count.zero? || glyph.compound?
|
|
300
|
+
# Return empty charstring (hsbw + endchar)
|
|
301
|
+
return [0, 500, 14].pack("C*")
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
# For simple glyphs without curve conversion, generate minimal charstring
|
|
305
|
+
lsb = @scaler.scale(glyph.left_side_bearing || 0)
|
|
306
|
+
width = @scaler.scale(glyph.advance_width || 500)
|
|
307
|
+
bytes = [13, lsb, width] # hsbw command (13)
|
|
308
|
+
|
|
309
|
+
# Add simple line commands (very basic)
|
|
310
|
+
if glyph.respond_to?(:points) && glyph.points && !glyph.points.empty?
|
|
311
|
+
# Just draw lines between consecutive on-curve points
|
|
312
|
+
prev_point = nil
|
|
313
|
+
glyph.points.each do |point|
|
|
314
|
+
next unless point.on_curve?
|
|
315
|
+
|
|
316
|
+
if prev_point
|
|
317
|
+
dx = @scaler.scale(point.x) - @scaler.scale(prev_point.x)
|
|
318
|
+
dy = @scaler.scale(point.y) - @scaler.scale(prev_point.y)
|
|
319
|
+
bytes << 5 # rlineto
|
|
320
|
+
bytes.concat(encode_number(dx))
|
|
321
|
+
bytes.concat(encode_number(dy))
|
|
322
|
+
end
|
|
323
|
+
prev_point = point
|
|
324
|
+
end
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
bytes << 14 # endchar
|
|
328
|
+
bytes.pack("C*")
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
# Encode a number for Type 1 CharString
|
|
332
|
+
#
|
|
333
|
+
# @param value [Integer] Number to encode
|
|
334
|
+
# @return [Array<Integer>] Array of bytes
|
|
335
|
+
def encode_number(value)
|
|
336
|
+
if value >= -107 && value <= 107
|
|
337
|
+
[value + 139]
|
|
338
|
+
elsif value >= 108 && value <= 1131
|
|
339
|
+
byte1 = ((value - 108) >> 8) + 247
|
|
340
|
+
byte2 = (value - 108) & 0xFF
|
|
341
|
+
[byte1, byte2]
|
|
342
|
+
elsif value >= -1131 && value <= -108
|
|
343
|
+
byte1 = ((-value - 108) >> 8) + 251
|
|
344
|
+
byte2 = (-value - 108) & 0xFF
|
|
345
|
+
[byte1, byte2]
|
|
346
|
+
elsif value >= -32768 && value <= 32767
|
|
347
|
+
[255, value & 0xFF, (value >> 8) & 0xFF]
|
|
348
|
+
else
|
|
349
|
+
bytes = []
|
|
350
|
+
4.times do |i|
|
|
351
|
+
bytes << ((value >> (8 * i)) & 0xFF)
|
|
352
|
+
end
|
|
353
|
+
[255] + bytes
|
|
354
|
+
end
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
# Build PFA trailer
|
|
358
|
+
#
|
|
359
|
+
# @return [String] PFA trailer
|
|
360
|
+
def build_pfa_trailer
|
|
361
|
+
lines = []
|
|
362
|
+
lines << "currentdict end"
|
|
363
|
+
lines << "dup /FontName get exch definefont pop"
|
|
364
|
+
lines << "% cleartomark"
|
|
365
|
+
lines.join("\n")
|
|
366
|
+
end
|
|
367
|
+
end
|
|
368
|
+
end
|
|
369
|
+
end
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fontisan
|
|
4
|
+
module Type1
|
|
5
|
+
# Parser for PFA (Printer Font ASCII) format
|
|
6
|
+
#
|
|
7
|
+
# [`PFAparser`](lib/fontisan/type1/pfa_parser.rb) parses the ASCII PFA format
|
|
8
|
+
# used for storing Adobe Type 1 fonts, primarily on Unix/Linux systems.
|
|
9
|
+
#
|
|
10
|
+
# The PFA format is pure ASCII text with encrypted portions marked by
|
|
11
|
+
# `currentfile eexec` and terminated by 512 ASCII zeros.
|
|
12
|
+
#
|
|
13
|
+
# Format structure:
|
|
14
|
+
# - Clear text: Font dictionary and initial data
|
|
15
|
+
# - Encrypted portion: Starts with `currentfile eexec`
|
|
16
|
+
# - Encrypted data: Binary data encoded as hexadecimal
|
|
17
|
+
# - End marker: 512 ASCII zeros ('0')
|
|
18
|
+
# - Cleartext again: Font dictionary closing
|
|
19
|
+
#
|
|
20
|
+
# @example Parse a PFA file
|
|
21
|
+
# parser = Fontisan::Type1::PFAparser.new
|
|
22
|
+
# result = parser.parse(File.read('font.pfa'))
|
|
23
|
+
# puts result.clear_text # => "!PS-AdobeFont-1.0..."
|
|
24
|
+
# puts result.encrypted_hex # => Encrypted hex string
|
|
25
|
+
#
|
|
26
|
+
# @see https://www.adobe.com/devnet/font/pdfs/Type1.pdf
|
|
27
|
+
class PFAParser
|
|
28
|
+
# Markers in PFA format
|
|
29
|
+
EEXEC_MARKER = "currentfile eexec"
|
|
30
|
+
# 512 ASCII zeros mark the end of encrypted portion
|
|
31
|
+
ZERO_MARKER = "0" * 512
|
|
32
|
+
|
|
33
|
+
# @return [String] Clear text portion (before eexec)
|
|
34
|
+
attr_reader :clear_text
|
|
35
|
+
|
|
36
|
+
# @return [String] Encrypted portion as hex string
|
|
37
|
+
attr_reader :encrypted_hex
|
|
38
|
+
|
|
39
|
+
# @return [String] Encrypted portion as binary data
|
|
40
|
+
attr_reader :encrypted_binary
|
|
41
|
+
|
|
42
|
+
# @return [String] Trailing text after zeros (if any)
|
|
43
|
+
attr_reader :trailing_text
|
|
44
|
+
|
|
45
|
+
# Parse PFA format data
|
|
46
|
+
#
|
|
47
|
+
# Handles both standard PFA (hex-encoded encrypted data with zero marker)
|
|
48
|
+
# and .t1 format (binary encrypted data without zero marker).
|
|
49
|
+
#
|
|
50
|
+
# @param data [String] ASCII PFA data or .t1 format data
|
|
51
|
+
# @return [PFAParser] Self for method chaining
|
|
52
|
+
# @raise [ArgumentError] If data is nil or empty
|
|
53
|
+
# @raise [Fontisan::Error] If PFA format is invalid
|
|
54
|
+
def parse(data)
|
|
55
|
+
raise ArgumentError, "Data cannot be nil" if data.nil?
|
|
56
|
+
raise ArgumentError, "Data cannot be empty" if data.empty?
|
|
57
|
+
|
|
58
|
+
# Normalize line endings
|
|
59
|
+
data = normalize_line_endings(data)
|
|
60
|
+
|
|
61
|
+
# Find eexec marker
|
|
62
|
+
eexec_index = data.index(EEXEC_MARKER)
|
|
63
|
+
if eexec_index.nil?
|
|
64
|
+
# No eexec marker - entire file is clear text
|
|
65
|
+
@clear_text = data
|
|
66
|
+
@encrypted_hex = ""
|
|
67
|
+
@encrypted_binary = ""
|
|
68
|
+
@trailing_text = ""
|
|
69
|
+
return self
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Clear text is everything before and including eexec marker
|
|
73
|
+
@clear_text = data[0...eexec_index + EEXEC_MARKER.length]
|
|
74
|
+
|
|
75
|
+
# Look for zeros after eexec marker
|
|
76
|
+
after_eexec = data[eexec_index + EEXEC_MARKER.length..]
|
|
77
|
+
|
|
78
|
+
# Skip whitespace after eexec marker
|
|
79
|
+
encrypted_start = skip_whitespace(after_eexec, 0)
|
|
80
|
+
encrypted_data = after_eexec[encrypted_start..]
|
|
81
|
+
|
|
82
|
+
# Find zero marker (optional for .t1 format)
|
|
83
|
+
zero_index = encrypted_data.index(ZERO_MARKER)
|
|
84
|
+
|
|
85
|
+
if zero_index
|
|
86
|
+
# Standard PFA format with zero marker
|
|
87
|
+
# Extract encrypted hex data (before zeros)
|
|
88
|
+
@encrypted_hex = encrypted_data[0...zero_index].strip
|
|
89
|
+
@encrypted_binary = [@encrypted_hex.gsub(/\s/, "")].pack("H*")
|
|
90
|
+
|
|
91
|
+
# Extract trailing text (after zeros)
|
|
92
|
+
trailing_start = zero_index + ZERO_MARKER.length
|
|
93
|
+
trailing_start = skip_whitespace(encrypted_data, trailing_start)
|
|
94
|
+
|
|
95
|
+
@trailing_text = if trailing_start < encrypted_data.length
|
|
96
|
+
encrypted_data[trailing_start..]
|
|
97
|
+
else
|
|
98
|
+
""
|
|
99
|
+
end
|
|
100
|
+
else
|
|
101
|
+
# .t1 format - binary encrypted data without zero marker
|
|
102
|
+
# Treat everything after eexec as binary encrypted data
|
|
103
|
+
@encrypted_binary = encrypted_data.lstrip
|
|
104
|
+
@encrypted_hex = @encrypted_binary.unpack1("H*")
|
|
105
|
+
@trailing_text = ""
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
self
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Check if parser has parsed data
|
|
112
|
+
#
|
|
113
|
+
# @return [Boolean] True if data has been parsed
|
|
114
|
+
def parsed?
|
|
115
|
+
!@clear_text.nil?
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Check if this appears to be a PFA file
|
|
119
|
+
#
|
|
120
|
+
# @param data [String] Text data to check
|
|
121
|
+
# @return [Boolean] True if data appears to be PFA format
|
|
122
|
+
#
|
|
123
|
+
# @example Check if file is PFA format
|
|
124
|
+
# if Fontisan::Type1::PFAParser.pfa_file?(data)
|
|
125
|
+
# # Handle PFA format
|
|
126
|
+
# end
|
|
127
|
+
def self.pfa_file?(data)
|
|
128
|
+
return false if data.nil?
|
|
129
|
+
return false if data.length < 15
|
|
130
|
+
|
|
131
|
+
# Check for Adobe Type 1 font header
|
|
132
|
+
data.include?("%!PS-AdobeFont-1.0") ||
|
|
133
|
+
data.include?("%!PS-Adobe-3.0 Resource-Font")
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
private
|
|
137
|
+
|
|
138
|
+
# Normalize line endings to LF
|
|
139
|
+
#
|
|
140
|
+
# @param data [String] Input data
|
|
141
|
+
# @return [String] Data with normalized line endings
|
|
142
|
+
def normalize_line_endings(data)
|
|
143
|
+
data.gsub("\r\n", "\n").gsub("\r", "\n")
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Skip whitespace in string
|
|
147
|
+
#
|
|
148
|
+
# @param str [String] Input string
|
|
149
|
+
# @param offset [Integer] Starting offset
|
|
150
|
+
# @return [Integer] Offset after skipping whitespace
|
|
151
|
+
def skip_whitespace(str, offset)
|
|
152
|
+
while offset < str.length && str[offset].match?(/\s/)
|
|
153
|
+
offset += 1
|
|
154
|
+
end
|
|
155
|
+
offset
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|