fontisan 0.2.11 → 0.2.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +294 -52
  3. data/Gemfile +5 -0
  4. data/README.adoc +163 -2
  5. data/docs/CONVERSION_GUIDE.adoc +633 -0
  6. data/docs/TYPE1_FONTS.adoc +445 -0
  7. data/lib/fontisan/cli.rb +177 -6
  8. data/lib/fontisan/commands/convert_command.rb +32 -1
  9. data/lib/fontisan/commands/info_command.rb +83 -2
  10. data/lib/fontisan/config/conversion_matrix.yml +132 -4
  11. data/lib/fontisan/constants.rb +12 -0
  12. data/lib/fontisan/conversion_options.rb +378 -0
  13. data/lib/fontisan/converters/collection_converter.rb +45 -10
  14. data/lib/fontisan/converters/format_converter.rb +17 -5
  15. data/lib/fontisan/converters/outline_converter.rb +78 -4
  16. data/lib/fontisan/converters/type1_converter.rb +1234 -0
  17. data/lib/fontisan/font_loader.rb +46 -3
  18. data/lib/fontisan/hints/hint_converter.rb +4 -1
  19. data/lib/fontisan/type1/afm_generator.rb +436 -0
  20. data/lib/fontisan/type1/afm_parser.rb +298 -0
  21. data/lib/fontisan/type1/agl.rb +456 -0
  22. data/lib/fontisan/type1/cff_to_type1_converter.rb +302 -0
  23. data/lib/fontisan/type1/charstring_converter.rb +240 -0
  24. data/lib/fontisan/type1/charstrings.rb +408 -0
  25. data/lib/fontisan/type1/conversion_options.rb +243 -0
  26. data/lib/fontisan/type1/decryptor.rb +183 -0
  27. data/lib/fontisan/type1/encodings.rb +697 -0
  28. data/lib/fontisan/type1/font_dictionary.rb +576 -0
  29. data/lib/fontisan/type1/generator.rb +220 -0
  30. data/lib/fontisan/type1/inf_generator.rb +332 -0
  31. data/lib/fontisan/type1/pfa_generator.rb +369 -0
  32. data/lib/fontisan/type1/pfa_parser.rb +159 -0
  33. data/lib/fontisan/type1/pfb_generator.rb +314 -0
  34. data/lib/fontisan/type1/pfb_parser.rb +166 -0
  35. data/lib/fontisan/type1/pfm_generator.rb +610 -0
  36. data/lib/fontisan/type1/pfm_parser.rb +433 -0
  37. data/lib/fontisan/type1/private_dict.rb +342 -0
  38. data/lib/fontisan/type1/seac_expander.rb +501 -0
  39. data/lib/fontisan/type1/ttf_to_type1_converter.rb +327 -0
  40. data/lib/fontisan/type1/upm_scaler.rb +118 -0
  41. data/lib/fontisan/type1.rb +75 -0
  42. data/lib/fontisan/type1_font.rb +318 -0
  43. data/lib/fontisan/version.rb +1 -1
  44. data/lib/fontisan.rb +2 -0
  45. metadata +30 -3
  46. data/docs/DOCUMENTATION_SUMMARY.md +0 -141
@@ -0,0 +1,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