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