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