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,314 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "upm_scaler"
4
+ require_relative "ttf_to_type1_converter"
5
+ require_relative "decryptor"
6
+ require_relative "../tables/name"
7
+
8
+ module Fontisan
9
+ module Type1
10
+ # PFB (Printer Font Binary) Generator
11
+ #
12
+ # [`PFBGenerator`](lib/fontisan/type1/pfb_generator.rb) generates Type 1 PFB files
13
+ # from TrueType fonts.
14
+ #
15
+ # PFB files are segmented binary files used by Windows for Type 1 fonts.
16
+ # They contain:
17
+ # - ASCII segment: Font dictionary
18
+ # - Binary segment: CharString data
19
+ # - ASCII segment: Trailer
20
+ #
21
+ # @example Generate PFB from TTF
22
+ # font = Fontisan::FontLoader.load("font.ttf")
23
+ # pfb_data = Fontisan::Type1::PFBGenerator.generate(font)
24
+ # File.binwrite("font.pfb", pfb_data)
25
+ #
26
+ # @example Generate PFB with custom options
27
+ # options = { upm_scale: 1000, format: :pfb }
28
+ # pfb_data = Fontisan::Type1::PFBGenerator.generate(font, options)
29
+ #
30
+ # @see https://www.adobe.com/devnet/font/pdfs/5178.Type1.pdf
31
+ class PFBGenerator
32
+ # PFB segment markers
33
+ ASCII_SEGMENT = 0x01
34
+ BINARY_SEGMENT = 0x02
35
+ END_SEGMENT = 0x03
36
+
37
+ # Header format string
38
+ PFB_HEADER = "%%!PS-AdobeFont-1.0: %s 1.0\n"
39
+
40
+ # Generate PFB from TTF font
41
+ #
42
+ # @param font [Fontisan::Font] Source TTF font
43
+ # @param options [Hash] Generation options
44
+ # @option options [Integer, :native] :upm_scale Target UPM (default: 1000)
45
+ # @option options [Class] :encoding Encoding class (default: Encodings::AdobeStandard)
46
+ # @option options [Boolean] :convert_curves Convert quadratic to cubic (default: true)
47
+ # @return [String] PFB file content (binary)
48
+ def self.generate(font, options = {})
49
+ new(font, options).generate
50
+ end
51
+
52
+ def initialize(font, options = {})
53
+ @font = font
54
+ @options = options
55
+ @metrics = MetricsCalculator.new(font)
56
+
57
+ # Set up scaler
58
+ upm_scale = options[:upm_scale] || 1000
59
+ @scaler = if upm_scale == :native
60
+ UPMScaler.native(font)
61
+ else
62
+ UPMScaler.new(font, target_upm: upm_scale)
63
+ end
64
+
65
+ # Set up encoding
66
+ @encoding = options[:encoding] || Encodings::AdobeStandard
67
+
68
+ # Set up converter options
69
+ @convert_curves = options.fetch(:convert_curves, true)
70
+ end
71
+
72
+ # Generate PFB file content
73
+ #
74
+ # @return [String] PFB binary content
75
+ def generate
76
+ # Build PFB segments
77
+ ascii_segment1 = build_ascii_segment_1
78
+ binary_segment = build_binary_segment
79
+ ascii_segment2 = build_ascii_segment_2
80
+
81
+ # Combine with segment headers
82
+ [
83
+ segment_header(ASCII_SEGMENT, ascii_segment1.bytesize),
84
+ ascii_segment1,
85
+ segment_header(BINARY_SEGMENT, binary_segment.bytesize),
86
+ binary_segment,
87
+ segment_header(ASCII_SEGMENT, ascii_segment2.bytesize),
88
+ ascii_segment2,
89
+ [END_SEGMENT, 0, 0, 0, 0, 0].pack("CV"),
90
+ ].join
91
+ end
92
+
93
+ private
94
+
95
+ # Build first ASCII segment (font dictionary)
96
+ #
97
+ # @return [String] ASCII font dictionary
98
+ def build_ascii_segment_1
99
+ lines = []
100
+ lines << format(PFB_HEADER, @font.post_script_name)
101
+ lines << build_font_dict
102
+ lines << build_private_dict
103
+ lines << build_charstrings_dict
104
+ lines.join("\n")
105
+ end
106
+
107
+ # Build font dictionary
108
+ #
109
+ # @return [String] Font dictionary in PostScript
110
+ def build_font_dict
111
+ dict = []
112
+ dict << "10 dict begin"
113
+ dict << "/FontType 1 def"
114
+ dict << "/FontMatrix [0.001 0 0 0.001 0 0] def"
115
+
116
+ # Font info
117
+ name_table = @font.table(Constants::NAME_TAG)
118
+ if name_table
119
+ font_name = name_table.english_name(Tables::Name::POSTSCRIPT_NAME) || @font.post_script_name
120
+ dict << "/FontName /#{font_name} def"
121
+ end
122
+
123
+ # Bounding box
124
+ head = @font.table(Constants::HEAD_TAG)
125
+ if head
126
+ bbox = [
127
+ @scaler.scale(head.x_min || 0),
128
+ @scaler.scale(head.y_min || 0),
129
+ @scaler.scale(head.x_max || 1000),
130
+ @scaler.scale(head.y_max || 1000),
131
+ ]
132
+ dict << "/FontBBox {#{bbox.join(' ')}} def"
133
+ end
134
+
135
+ # Paint type
136
+ dict << "/PaintType 0 def"
137
+
138
+ # Encoding
139
+ if @encoding == Encodings::AdobeStandard
140
+ dict << "/Encoding StandardEncoding def"
141
+ elsif @encoding == Encodings::ISOLatin1
142
+ dict << "/Encoding ISOLatin1Encoding def"
143
+ end
144
+
145
+ dict << "currentdict end"
146
+ dict << "dup /FontName get exch definefont pop"
147
+
148
+ dict.join("\n")
149
+ end
150
+
151
+ # Build Private dictionary
152
+ #
153
+ # @return [String] Private dictionary in PostScript
154
+ def build_private_dict
155
+ dict = []
156
+ dict << "/Private 15 dict begin"
157
+
158
+ # Blue values (for hinting)
159
+ # These are typically derived from the font's alignment zones
160
+ dict << "/BlueValues [-20 0 500 510] def"
161
+ dict << "/BlueScale 0.039625 def"
162
+ dict << "/BlueShift 7 def"
163
+ dict << "/BlueFuzz 1 def"
164
+
165
+ # Stem snap hints
166
+ os2 = @font.table(Constants::OS2_TAG)
167
+ if os2.respond_to?(:weight_class)
168
+ stem_width = @scaler.scale([100, 80,
169
+ 90][os2.weight_class / 100] || 80)
170
+ dict << "/StemSnapH [#{stem_width}] def"
171
+ dict << "/StemSnapV [#{stem_width}] def"
172
+ end
173
+
174
+ # Force bold flag
175
+ dict << if os2.respond_to?(:weight_class) && os2.weight_class && os2.weight_class >= 700
176
+ "/ForceBold true def"
177
+ else
178
+ "/ForceBold false def"
179
+ end
180
+
181
+ # Language group
182
+ dict << "/LanguageGroup 0 def"
183
+
184
+ # Unique ID (random)
185
+ dict << "/UniqueID #{rand(1000000..9999999)} def"
186
+
187
+ dict << "currentdict end"
188
+ dict << "dup /Private get"
189
+
190
+ dict.join("\n")
191
+ end
192
+
193
+ # Build CharStrings dictionary
194
+ #
195
+ # @return [String] CharStrings dictionary reference
196
+ def build_charstrings_dict
197
+ # This is a placeholder - actual CharStrings are in the binary segment
198
+ "/CharStrings #{@charstrings&.size || 0} dict dup begin\nend"
199
+ end
200
+
201
+ # Build CharStrings dictionary text for eexec encryption
202
+ #
203
+ # @param charstrings [Hash] Glyph ID to CharString mapping
204
+ # @return [String] CharStrings dictionary text
205
+ def build_charstrings_dict_text(charstrings)
206
+ lines = []
207
+ lines << "dup /FontName get exch definefont pop"
208
+ lines << "begin"
209
+ lines << "/CharStrings #{charstrings.size} dict dup begin"
210
+
211
+ charstrings.each do |gid, charstring|
212
+ # Convert charstring bytes to hex representation for PostScript
213
+ hex_data = charstring.unpack1("H*")
214
+ lines << "#{gid} #{charstring.bytesize} #{hex_data} RD"
215
+ end
216
+
217
+ lines << "end"
218
+ lines << "end"
219
+ lines << "put"
220
+ lines.join("\n")
221
+ end
222
+
223
+ # Build binary segment (CharStrings)
224
+ #
225
+ # @return [String] Binary CharString data (encrypted with eexec)
226
+ def build_binary_segment
227
+ # Convert glyphs to Type 1 CharStrings
228
+ charstrings = if @convert_curves
229
+ TTFToType1Converter.convert(@font, @scaler, @encoding)
230
+ else
231
+ # For simple curve conversion skip, generate minimal charstrings
232
+ generate_simple_charstrings
233
+ end
234
+
235
+ # Build CharStrings dictionary text
236
+ charstrings_dict = build_charstrings_dict_text(charstrings)
237
+
238
+ # Encrypt with eexec
239
+ Decryptor.eexec_encrypt(charstrings_dict)
240
+ end
241
+
242
+ # Generate simple CharStrings (without curve conversion)
243
+ #
244
+ # @return [Hash<Integer, String>] Glyph ID to CharString mapping
245
+ def generate_simple_charstrings
246
+ glyf_table = @font.table(Constants::GLYF_TAG)
247
+ return {} unless glyf_table
248
+
249
+ maxp = @font.table(Constants::MAXP_TAG)
250
+ num_glyphs = maxp&.num_glyphs || 0
251
+
252
+ charstrings = {}
253
+ num_glyphs.times do |gid|
254
+ charstrings[gid] = simple_charstring(glyf_table, gid)
255
+ end
256
+
257
+ charstrings
258
+ end
259
+
260
+ # Generate a simple CharString for a glyph
261
+ #
262
+ # @param glyf_table [Object] TTF glyf table
263
+ # @param gid [Integer] Glyph ID
264
+ # @return [String] Type 1 CharString data
265
+ def simple_charstring(glyf_table, gid)
266
+ glyph = glyf_table.glyph(gid)
267
+
268
+ # Empty or compound glyph
269
+ if glyph.nil? || glyph.contour_count.zero? || glyph.compound?
270
+ # Return empty charstring (hsbw + endchar)
271
+ return [0, 500, 14].pack("C*")
272
+ end
273
+
274
+ # For simple glyphs without curve conversion, generate line-based charstring
275
+ # This is a simplified implementation
276
+ lsb = @scaler.scale(glyph.left_side_bearing || 0)
277
+ width = @scaler.scale(glyph.advance_width || 500)
278
+ bytes = [0, lsb, width] # hsbw
279
+
280
+ # Add lines between points (simplified)
281
+ if glyph.respond_to?(:points) && glyph.points && !glyph.points.empty?
282
+ glyph.points.each do |point|
283
+ next unless point.on_curve?
284
+
285
+ # This is very simplified - proper implementation would handle curves
286
+ end
287
+ end
288
+
289
+ bytes << 14 # endchar
290
+ bytes.pack("C*")
291
+ end
292
+
293
+ # Build second ASCII segment (trailer)
294
+ #
295
+ # @return [String] ASCII trailer
296
+ def build_ascii_segment_2
297
+ lines = []
298
+ lines << "put" # Put the Private dictionary
299
+ lines << "dup /FontName get exch definefont pop"
300
+ lines << "% cleartomark"
301
+ lines.join("\n")
302
+ end
303
+
304
+ # Create PFB segment header
305
+ #
306
+ # @param marker [Integer] Segment type marker
307
+ # @param size [Integer] Segment data size
308
+ # @return [String] 6-byte segment header
309
+ def segment_header(marker, size)
310
+ [marker, size].pack("CV")
311
+ end
312
+ end
313
+ end
314
+ end
@@ -0,0 +1,166 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Type1
5
+ # Parser for PFB (Printer Font Binary) format
6
+ #
7
+ # [`PFBParser`](lib/fontisan/type1/pfb_parser.rb) parses the binary PFB format
8
+ # used for storing Adobe Type 1 fonts, primarily on Windows systems.
9
+ #
10
+ # The PFB format consists of binary chunks marked with special codes:
11
+ # - 0x8001: ASCII text chunk
12
+ # - 0x8002: Binary data chunk (usually encrypted)
13
+ # - 0x8003: End of file marker
14
+ #
15
+ # Each chunk (except EOF) has a 4-byte little-endian length prefix.
16
+ #
17
+ # @example Parse a PFB file
18
+ # parser = Fontisan::Type1::PFBParser.new
19
+ # result = parser.parse(File.binread('font.pfb'))
20
+ # puts result.ascii_parts # => ["%!PS-AdobeFont-1.0...", ...]
21
+ # puts result.binary_parts # => [encrypted_binary_data, ...]
22
+ #
23
+ # @see https://www.adobe.com/devnet/font/pdfs/Type1.pdf
24
+ class PFBParser
25
+ # PFB chunk markers
26
+ ASCII_CHUNK = 0x8001
27
+ BINARY_CHUNK = 0x8002
28
+ EOF_CHUNK = 0x8003
29
+
30
+ # @return [Array<String>] ASCII text parts
31
+ attr_reader :ascii_parts
32
+
33
+ # @return [Array<String>] Binary data parts
34
+ attr_reader :binary_parts
35
+
36
+ # Parse PFB format data
37
+ #
38
+ # @param data [String] Binary PFB data
39
+ # @return [PFBParser] Self for method chaining
40
+ # @raise [ArgumentError] If data is nil or empty
41
+ # @raise [Fontisan::Error] If PFB format is invalid
42
+ def parse(data)
43
+ raise ArgumentError, "Data cannot be nil" if data.nil?
44
+ raise ArgumentError, "Data cannot be empty" if data.empty?
45
+
46
+ @ascii_parts = []
47
+ @binary_parts = []
48
+
49
+ offset = 0
50
+ chunk_index = 0
51
+
52
+ while offset < data.length
53
+ # Check for chunk marker (must have at least 2 bytes)
54
+ if offset + 2 > data.length
55
+ raise Fontisan::Error,
56
+ "Invalid PFB: incomplete chunk header at offset #{offset}"
57
+ end
58
+
59
+ # Read chunk marker (big-endian)
60
+ marker = (data.getbyte(offset) << 8) |
61
+ data.getbyte(offset + 1)
62
+ offset += 2
63
+
64
+ case marker
65
+ when ASCII_CHUNK
66
+ chunk = read_chunk(data, offset, chunk_index, "ASCII")
67
+ @ascii_parts << chunk[:data]
68
+ offset = chunk[:next_offset]
69
+ chunk_index += 1
70
+
71
+ when BINARY_CHUNK
72
+ chunk = read_chunk(data, offset, chunk_index, "binary")
73
+ @binary_parts << chunk[:data]
74
+ offset = chunk[:next_offset]
75
+ chunk_index += 1
76
+
77
+ when EOF_CHUNK
78
+ # End of file - no more chunks
79
+ break
80
+
81
+ else
82
+ raise Fontisan::Error,
83
+ "Invalid PFB: unknown chunk marker 0x#{marker.to_s(16).upcase} at offset #{offset - 2}"
84
+ end
85
+ end
86
+
87
+ self
88
+ end
89
+
90
+ # Get all ASCII parts concatenated
91
+ #
92
+ # @return [String] All ASCII parts joined together
93
+ def ascii_text
94
+ @ascii_parts.join
95
+ end
96
+
97
+ # Get all binary parts concatenated
98
+ #
99
+ # @return [String] All binary parts joined together
100
+ def binary_data
101
+ @binary_parts.join
102
+ end
103
+
104
+ # Check if parser has parsed data
105
+ #
106
+ # @return [Boolean] True if data has been parsed
107
+ def parsed?
108
+ !@ascii_parts.nil? && !@binary_parts.nil?
109
+ end
110
+
111
+ # Check if this appears to be a PFB file
112
+ #
113
+ # @param data [String] Binary data to check
114
+ # @return [Boolean] True if data starts with PFB marker
115
+ #
116
+ # @example Check if file is PFB format
117
+ # if Fontisan::Type1::PFBParser.pfb_file?(data)
118
+ # # Handle PFB format
119
+ # end
120
+ def self.pfb_file?(data)
121
+ return false if data.nil? || data.length < 2
122
+
123
+ # PFB marker is big-endian (first byte is high byte)
124
+ marker = (data.getbyte(0) << 8) | data.getbyte(1)
125
+ [ASCII_CHUNK, BINARY_CHUNK].include?(marker)
126
+ end
127
+
128
+ private
129
+
130
+ # Read a chunk from PFB data
131
+ #
132
+ # @param data [String] PFB binary data
133
+ # @param offset [Integer] Current offset in data
134
+ # @param chunk_index [Integer] Index of current chunk (for error messages)
135
+ # @param type [String] Type of chunk ("ASCII" or "binary")
136
+ # @return [Hash] Chunk data with :data and :next_offset
137
+ def read_chunk(data, offset, chunk_index, type)
138
+ # Read 4-byte length (little-endian)
139
+ if offset + 4 > data.length
140
+ raise Fontisan::Error,
141
+ "Invalid PFB: incomplete length for #{type} chunk #{chunk_index}"
142
+ end
143
+
144
+ length = data.getbyte(offset) |
145
+ (data.getbyte(offset + 1) << 8) |
146
+ (data.getbyte(offset + 2) << 16) |
147
+ (data.getbyte(offset + 3) << 24)
148
+ offset += 4
149
+
150
+ # Read chunk data
151
+ if offset + length > data.length
152
+ raise Fontisan::Error,
153
+ "Invalid PFB: #{type} chunk #{chunk_index} length #{length} exceeds remaining data"
154
+ end
155
+
156
+ chunk_data = data.byteslice(offset, length)
157
+ next_offset = offset + length
158
+
159
+ {
160
+ data: chunk_data,
161
+ next_offset: next_offset,
162
+ }
163
+ end
164
+ end
165
+ end
166
+ end