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,436 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "upm_scaler"
4
+ require_relative "encodings"
5
+ require_relative "agl"
6
+
7
+ module Fontisan
8
+ module Type1
9
+ # AFM (Adobe Font Metrics) file generator
10
+ #
11
+ # [`AFMGenerator`](lib/fontisan/type1/afm_generator.rb) generates Adobe Font Metrics
12
+ # files from TTF/OTF fonts.
13
+ #
14
+ # AFM files include:
15
+ # - Character widths
16
+ # - Kerning pairs
17
+ # - Character bounding boxes
18
+ # - Font metadata (name, version, copyright, etc.)
19
+ #
20
+ # @example Generate AFM from TTF
21
+ # font = Fontisan::FontLoader.load("font.ttf")
22
+ # afm = Fontisan::Type1::AFMGenerator.generate(font)
23
+ # File.write("font.afm", afm)
24
+ #
25
+ # @example Generate AFM with 1000 UPM scaling
26
+ # afm = Fontisan::Type1::AFMGenerator.generate(font, upm_scale: 1000)
27
+ #
28
+ # @example Generate AFM with Unicode encoding
29
+ # afm = Fontisan::Type1::AFMGenerator.generate(font, encoding: Fontisan::Type1::Encodings::Unicode)
30
+ #
31
+ # @see https://www.adobe.com/devnet/font/pdfs/5004.AFM_Spec.pdf
32
+ class AFMGenerator
33
+ class << self
34
+ # Generate AFM content from a font
35
+ #
36
+ # @param font [Fontisan::TrueTypeFont, Fontisan::OpenTypeFont] The font to generate AFM from
37
+ # @param options [Hash] Generation options
38
+ # @option options [Integer, :native] :upm_scale Target UPM (1000 for Type 1, :native for no scaling)
39
+ # @option options [Class] :encoding Encoding class (default: AdobeStandard)
40
+ # @return [String] AFM file content
41
+ def generate(font, options = {})
42
+ new(font, options).generate_afm
43
+ end
44
+
45
+ # Generate AFM file from a font and write to file
46
+ #
47
+ # @param font [Fontisan::TrueTypeFont, Fontisan::OpenTypeFont] The font to generate AFM from
48
+ # @param path [String] Path to write AFM file
49
+ # @param options [Hash] Generation options
50
+ # @return [void
51
+ def generate_to_file(font, path, options = {})
52
+ afm_content = generate(font, options)
53
+ File.write(path, afm_content, encoding: "ISO-8859-1")
54
+ end
55
+
56
+ # Get Adobe glyph name from Unicode codepoint
57
+ #
58
+ # @param codepoint [Integer] Unicode codepoint
59
+ # @param encoding [Class] Encoding class to use (default: nil for direct AGL lookup)
60
+ # @return [String] Adobe glyph name
61
+ def adobe_glyph_name(codepoint, encoding: nil)
62
+ if encoding
63
+ encoding.glyph_name_for_code(codepoint)
64
+ else
65
+ AGL.glyph_name_for_unicode(codepoint)
66
+ end
67
+ end
68
+ end
69
+
70
+ # Initialize a new AFMGenerator
71
+ #
72
+ # @param font [Fontisan::TrueTypeFont, Fontisan::OpenTypeFont] The font to generate AFM from
73
+ # @param options [Hash] Generation options
74
+ def initialize(font, options = {})
75
+ @font = font
76
+ @metrics = MetricsCalculator.new(font)
77
+
78
+ # Set up scaler
79
+ upm_scale = options[:upm_scale] || 1000
80
+ @scaler = if upm_scale == :native
81
+ UPMScaler.native(font)
82
+ else
83
+ UPMScaler.new(font, target_upm: upm_scale)
84
+ end
85
+
86
+ # Set up encoding
87
+ @encoding = options[:encoding] || Encodings::AdobeStandard
88
+ end
89
+
90
+ # Generate AFM content
91
+ #
92
+ # @return [String] AFM file content
93
+ def generate_afm
94
+ afm_lines = []
95
+
96
+ # Header
97
+ afm_lines << "StartFontMetrics 4.1"
98
+
99
+ # Font metadata
100
+ add_font_metadata(afm_lines)
101
+
102
+ # Font bounding box
103
+ add_font_bounding_box(afm_lines)
104
+
105
+ # Character metrics
106
+ add_character_metrics(afm_lines)
107
+
108
+ # Kerning data
109
+ add_kerning_data(afm_lines)
110
+
111
+ # Footer
112
+ afm_lines << "EndFontMetrics"
113
+
114
+ afm_lines.join("\n")
115
+ end
116
+
117
+ private
118
+
119
+ # Add font metadata to AFM
120
+ #
121
+ # @param afm_lines [Array<String>] AFM lines array
122
+ def add_font_metadata(afm_lines)
123
+ # Font name
124
+ font_name = @font.post_script_name
125
+ afm_lines << "FontName #{font_name}" if font_name
126
+
127
+ # Full name
128
+ full_name = @font.full_name
129
+ afm_lines << "FullName #{full_name}" if full_name
130
+
131
+ # Family name
132
+ family_name = @font.family_name
133
+ afm_lines << "FamilyName #{family_name}" if family_name
134
+
135
+ # Weight
136
+ weight = extract_weight
137
+ afm_lines << "Weight #{weight}" if weight
138
+
139
+ # Italic angle
140
+ italic_angle = extract_italic_angle
141
+ afm_lines << "ItalicAngle #{italic_angle}" if italic_angle
142
+
143
+ # IsFixedPitch
144
+ is_fixed_pitch = is_monospace? ? "true" : "false"
145
+ afm_lines << "IsFixedPitch #{is_fixed_pitch}"
146
+
147
+ # Character direction
148
+ afm_lines << "CharacterDirection 0"
149
+
150
+ # Version
151
+ version = extract_version
152
+ afm_lines << "Version #{version}" if version
153
+
154
+ # Notice (copyright)
155
+ notice = extract_copyright
156
+ afm_lines << "Notice #{notice}" if notice
157
+
158
+ # Encoding scheme
159
+ afm_lines << "EncodingScheme AdobeStandardEncoding"
160
+
161
+ # Mapping scheme
162
+ afm_lines << "MappingScheme 0"
163
+
164
+ # Ascender
165
+ ascender = @metrics.ascent
166
+ afm_lines << "Ascender #{ascender}" if ascender
167
+
168
+ # Descender
169
+ descender = @metrics.descent
170
+ afm_lines << "Descender #{descender}" if descender
171
+
172
+ # Underline properties
173
+ post = @font.table(Constants::POST_TAG)
174
+ underline_position = post&.underline_position if post.respond_to?(:underline_position)
175
+ afm_lines << "UnderlinePosition #{underline_position}" if underline_position
176
+
177
+ underline_thickness = post&.underline_thickness if post.respond_to?(:underline_thickness)
178
+ afm_lines << "UnderlineThickness #{underline_thickness}" if underline_thickness
179
+ end
180
+
181
+ # Add font bounding box to AFM
182
+ #
183
+ # @param afm_lines [Array<String>] AFM lines array
184
+ def add_font_bounding_box(afm_lines)
185
+ bbox = extract_font_bounding_box
186
+ return unless bbox && bbox.length == 4
187
+
188
+ # Scale bounding box
189
+ scaled_bbox = @scaler.scale_bbox(bbox)
190
+ afm_lines << "FontBBox #{scaled_bbox[0]} #{scaled_bbox[1]} #{scaled_bbox[2]} #{scaled_bbox[3]}"
191
+ end
192
+
193
+ # Add character metrics to AFM
194
+ #
195
+ # @param afm_lines [Array<String>] AFM lines array
196
+ def add_character_metrics(afm_lines)
197
+ # Get character mappings from cmap
198
+ char_mappings = extract_character_mappings
199
+ return if char_mappings.empty?
200
+
201
+ afm_lines << "StartCharMetrics #{char_mappings.length}"
202
+
203
+ char_mappings.each do |unicode, glyph_id|
204
+ next unless unicode && unicode >= 32 && unicode <= 255
205
+
206
+ # Get glyph name from encoding
207
+ glyph_name = @encoding.glyph_name_for_code(unicode)
208
+ glyph_name ||= AGL.glyph_name_for_unicode(unicode)
209
+ next unless glyph_name
210
+
211
+ # Get and scale width
212
+ width = @metrics.glyph_width(glyph_id)
213
+ next unless width
214
+
215
+ scaled_width = @scaler.scale_width(width)
216
+
217
+ # Get and scale bounding box if available
218
+ bbox = extract_glyph_bounding_box(glyph_id)
219
+ scaled_bbox = bbox ? @scaler.scale_bbox(bbox) : nil
220
+
221
+ # Format: C code ; WX width ; N name ; B llx lly urx ury ;
222
+ metric_line = "C #{unicode} ; WX #{scaled_width} ; N #{glyph_name}"
223
+ if scaled_bbox && scaled_bbox.length == 4
224
+ metric_line += " ; B #{scaled_bbox[0]} #{scaled_bbox[1]} #{scaled_bbox[2]} #{scaled_bbox[3]}"
225
+ end
226
+ afm_lines << metric_line
227
+ end
228
+
229
+ afm_lines << "EndCharMetrics"
230
+ end
231
+
232
+ # Add kerning data to AFM
233
+ #
234
+ # @param afm_lines [Array<String>] AFM lines array
235
+ def add_kerning_data(afm_lines)
236
+ kerning_pairs = extract_kerning_pairs
237
+ return if kerning_pairs.empty?
238
+
239
+ afm_lines << "StartKernData"
240
+ afm_lines << "StartKernPairs #{kerning_pairs.length}"
241
+
242
+ kerning_pairs.each do |left, right, adjustment|
243
+ left_name = self.class.adobe_glyph_name(left)
244
+ right_name = self.class.adobe_glyph_name(right)
245
+ afm_lines << "KPX #{left_name} #{right_name} #{adjustment}"
246
+ end
247
+
248
+ afm_lines << "EndKernPairs"
249
+ afm_lines << "EndKernData"
250
+ end
251
+
252
+ # Extract weight from OS/2 table
253
+ #
254
+ # @return [String] Weight
255
+ def extract_weight
256
+ os2 = @font.table(Constants::OS2_TAG)
257
+ return "Regular" unless os2
258
+
259
+ weight_class = if os2.respond_to?(:us_weight_class)
260
+ os2.us_weight_class
261
+ elsif os2.respond_to?(:weight_class)
262
+ os2.weight_class
263
+ end
264
+ return "Regular" unless weight_class
265
+
266
+ case weight_class
267
+ when 100..200 then "Thin"
268
+ when 200..300 then "ExtraLight"
269
+ when 300..400 then "Light"
270
+ when 400..500 then "Regular"
271
+ when 500..600 then "Medium"
272
+ when 600..700 then "SemiBold"
273
+ when 700..800 then "Bold"
274
+ when 800..900 then "ExtraBold"
275
+ when 900..1000 then "Black"
276
+ else "Regular"
277
+ end
278
+ end
279
+
280
+ # Extract italic angle from post table
281
+ #
282
+ # @return [Float] Italic angle
283
+ def extract_italic_angle
284
+ post = @font.table(Constants::POST_TAG)
285
+ return 0.0 unless post
286
+
287
+ if post.respond_to?(:italic_angle)
288
+ post.italic_angle
289
+ else
290
+ 0.0
291
+ end
292
+ end
293
+
294
+ # Check if font is monospace
295
+ #
296
+ # @return [Boolean] True if monospace
297
+ def is_monospace?
298
+ post = @font.table(Constants::POST_TAG)
299
+ return false unless post
300
+
301
+ if post.respond_to?(:is_fixed_pitch)
302
+ post.is_fixed_pitch
303
+ else
304
+ false
305
+ end
306
+ end
307
+
308
+ # Extract version from name table
309
+ #
310
+ # @return [String, nil] Version string
311
+ def extract_version
312
+ name_table = @font.table(Constants::NAME_TAG)
313
+ return nil unless name_table
314
+
315
+ if name_table.respond_to?(:version_string)
316
+ name_table.version_string(1) || name_table.version_string(3)
317
+ end
318
+ end
319
+
320
+ # Extract copyright from name table
321
+ #
322
+ # @return [String, nil] Copyright notice
323
+ def extract_copyright
324
+ name_table = @font.table(Constants::NAME_TAG)
325
+ return nil unless name_table
326
+
327
+ if name_table.respond_to?(:copyright)
328
+ name_table.copyright(1) || name_table.copyright(3)
329
+ end
330
+ end
331
+
332
+ # Extract character mappings from cmap table
333
+ #
334
+ # @return [Hash<Integer, Integer>] Unicode to glyph ID mappings
335
+ def extract_character_mappings
336
+ cmap = @font.table(Constants::CMAP_TAG)
337
+ return {} unless cmap
338
+
339
+ @extract_character_mappings ||= begin
340
+ mappings = {}
341
+
342
+ # Try to get Unicode mappings (most reliable method)
343
+ if cmap.respond_to?(:unicode_mappings)
344
+ mappings = cmap.unicode_mappings || {}
345
+ elsif cmap.respond_to?(:unicode_bmp_mapping)
346
+ mappings = cmap.unicode_bmp_mapping || {}
347
+ elsif cmap.respond_to?(:subtables)
348
+ # Look for Unicode BMP subtable
349
+ unicode_subtable = cmap.subtables.find do |subtable|
350
+ subtable.respond_to?(:platform_id) &&
351
+ subtable.platform_id == 3 &&
352
+ subtable.respond_to?(:encoding_id) &&
353
+ subtable.encoding_id == 1
354
+ end
355
+
356
+ if unicode_subtable.respond_to?(:glyph_index_map)
357
+ mappings = unicode_subtable.glyph_index_map
358
+ end
359
+ end
360
+
361
+ mappings
362
+ end
363
+ end
364
+
365
+ # Extract font bounding box
366
+ #
367
+ # @return [Array<Integer>, nil] Bounding box [llx, lly, urx, ury]
368
+ def extract_font_bounding_box
369
+ head = @font.table(Constants::HEAD_TAG)
370
+ return nil unless head
371
+
372
+ if head.respond_to?(:font_bounding_box)
373
+ head.font_bounding_box
374
+ elsif head.respond_to?(:x_min) && head.respond_to?(:y_min) &&
375
+ head.respond_to?(:x_max) && head.respond_to?(:y_max)
376
+ [head.x_min, head.y_min, head.x_max, head.y_max]
377
+ end
378
+ end
379
+
380
+ # Extract glyph bounding box
381
+ #
382
+ # @param glyph_id [Integer] Glyph ID
383
+ # @return [Array<Integer>, nil] Bounding box [llx, lly, urx, ury]
384
+ def extract_glyph_bounding_box(glyph_id)
385
+ return nil unless @font.truetype?
386
+
387
+ glyf_table = @font.table(Constants::GLYF_TAG)
388
+ return nil unless glyf_table
389
+
390
+ loca_table = @font.table(Constants::LOCA_TAG)
391
+ return nil unless loca_table
392
+
393
+ head_table = @font.table(Constants::HEAD_TAG)
394
+ return nil unless head_table
395
+
396
+ # Ensure loca is parsed with context
397
+ if loca_table.respond_to?(:parse_with_context) && !loca_table.parsed?
398
+ maxp = @font.table(Constants::MAXP_TAG)
399
+ if maxp
400
+ loca_table.parse_with_context(head_table.index_to_loc_format,
401
+ maxp.num_glyphs)
402
+ end
403
+ end
404
+
405
+ if glyf_table.respond_to?(:glyph_for)
406
+ glyph = glyf_table.glyph_for(glyph_id, loca_table, head_table)
407
+ return nil unless glyph
408
+
409
+ if glyph.respond_to?(:bounding_box)
410
+ glyph.bounding_box
411
+ elsif glyph.respond_to?(:x_min) && glyph.respond_to?(:y_min) &&
412
+ glyph.respond_to?(:x_max) && glyph.respond_to?(:y_max)
413
+ [glyph.x_min, glyph.y_min, glyph.x_max, glyph.y_max]
414
+ end
415
+ end
416
+ end
417
+
418
+ # Extract kerning pairs from GPOS table
419
+ #
420
+ # @return [Array<Array>] Array of [left_unicode, right_unicode, adjustment]
421
+ def extract_kerning_pairs
422
+ gpos = @font.table(Constants::GPOS_TAG)
423
+ return [] unless gpos
424
+
425
+ @extract_kerning_pairs ||= begin
426
+ pairs = []
427
+
428
+ # This is a simplified implementation
429
+ # Full implementation would parse GPOS lookup type 2 (Pair positioning)
430
+ # For now, return empty array
431
+ pairs
432
+ end
433
+ end
434
+ end
435
+ end
436
+ end