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,220 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require_relative "upm_scaler"
5
+ require_relative "encodings"
6
+ require_relative "conversion_options"
7
+ require_relative "afm_generator"
8
+ require_relative "pfm_generator"
9
+ require_relative "pfa_generator"
10
+ require_relative "pfb_generator"
11
+ require_relative "inf_generator"
12
+
13
+ module Fontisan
14
+ module Type1
15
+ # Unified Type 1 font generator
16
+ #
17
+ # [`Generator`](lib/fontisan/type1/generator.rb) provides a unified interface
18
+ # for generating all Type 1 font formats from TrueType/OpenType fonts.
19
+ #
20
+ # This generator creates:
21
+ # - AFM (Adobe Font Metrics) - Text-based font metrics
22
+ # - PFM (Printer Font Metrics) - Windows font metrics
23
+ # - PFA (Printer Font ASCII) - Unix Type 1 font (ASCII-hex encoded)
24
+ # - PFB (Printer Font Binary) - Windows Type 1 font (binary)
25
+ # - INF (Font Information) - Windows installation metadata
26
+ #
27
+ # @example Generate all Type 1 formats with default options (1000 UPM)
28
+ # font = Fontisan::FontLoader.load("font.ttf")
29
+ # result = Fontisan::Type1::Generator.generate(font)
30
+ # result[:afm] # => AFM file content
31
+ # result[:pfm] # => PFM file content
32
+ # result[:pfb] # => PFB file content
33
+ # result[:inf] # => INF file content
34
+ #
35
+ # @example Generate Unix Type 1 (PFA) with custom options
36
+ # result = Fontisan::Type1::Generator.generate(font,
37
+ # format: :pfa,
38
+ # upm_scale: 1000,
39
+ # encoding: Fontisan::Type1::Encodings::AdobeStandard
40
+ # )
41
+ #
42
+ # @example Generate with ConversionOptions preset
43
+ # options = Fontisan::Type1::ConversionOptions.windows_type1
44
+ # result = Fontisan::Type1::Generator.generate(font, options)
45
+ #
46
+ # @see https://www.adobe.com/devnet/font/pdfs/5178.Type1.pdf
47
+ class Generator
48
+ # Default generation options
49
+ DEFAULT_OPTIONS = {
50
+ upm_scale: 1000,
51
+ encoding: Encodings::AdobeStandard,
52
+ decompose_composites: false,
53
+ convert_curves: true,
54
+ autohint: false,
55
+ preserve_hinting: false,
56
+ format: :pfb,
57
+ }.freeze
58
+
59
+ # Generate all Type 1 formats from a font
60
+ #
61
+ # @param font [Fontisan::TrueTypeFont, Fontisan::OpenTypeFont] Source font
62
+ # @param options [ConversionOptions, Hash] Generation options
63
+ # @return [Hash] Generated file contents
64
+ def self.generate(font, options = {})
65
+ options = normalize_options(options)
66
+ new(font, options).generate
67
+ end
68
+
69
+ # Generate Type 1 files and write to disk
70
+ #
71
+ # @param font [Fontisan::TrueTypeFont, Fontisan::OpenTypeFont] Source font
72
+ # @param output_dir [String] Directory to write files
73
+ # @param options [ConversionOptions, Hash] Generation options
74
+ # @return [Array<String>] Paths to generated files
75
+ def self.generate_to_files(font, output_dir, options = {})
76
+ options = normalize_options(options)
77
+ result = generate(font, options)
78
+
79
+ # Ensure output directory exists
80
+ FileUtils.mkdir_p(output_dir)
81
+
82
+ # Get base filename from font
83
+ base_name = extract_base_name(font)
84
+
85
+ # Write files
86
+ written_files = []
87
+
88
+ # Write AFM
89
+ if result[:afm]
90
+ afm_path = File.join(output_dir, "#{base_name}.afm")
91
+ File.write(afm_path, result[:afm], encoding: "ISO-8859-1")
92
+ written_files << afm_path
93
+ end
94
+
95
+ # Write PFM
96
+ if result[:pfm]
97
+ pfm_path = File.join(output_dir, "#{base_name}.pfm")
98
+ File.binwrite(pfm_path, result[:pfm])
99
+ written_files << pfm_path
100
+ end
101
+
102
+ # Write PFB or PFA
103
+ if result[:pfb]
104
+ pfb_path = File.join(output_dir, "#{base_name}.pfb")
105
+ File.binwrite(pfb_path, result[:pfb])
106
+ written_files << pfb_path
107
+ elsif result[:pfa]
108
+ pfa_path = File.join(output_dir, "#{base_name}.pfa")
109
+ File.write(pfa_path, result[:pfa])
110
+ written_files << pfa_path
111
+ end
112
+
113
+ # Write INF
114
+ if result[:inf]
115
+ inf_path = File.join(output_dir, "#{base_name}.inf")
116
+ File.write(inf_path, result[:inf], encoding: "ISO-8859-1")
117
+ written_files << inf_path
118
+ end
119
+
120
+ written_files
121
+ end
122
+
123
+ # Initialize a new Generator
124
+ #
125
+ # @param font [Fontisan::TrueTypeFont, Fontisan::OpenTypeFont] Source font
126
+ # @param options [ConversionOptions, Hash] Generation options
127
+ def initialize(font, options = {})
128
+ @font = font
129
+ @options = normalize_options_value(options)
130
+ @metrics = MetricsCalculator.new(font)
131
+
132
+ # Set up scaler
133
+ upm_scale = @options.upm_scale || 1000
134
+ @scaler = if upm_scale == :native
135
+ UPMScaler.native(font)
136
+ else
137
+ UPMScaler.new(font, target_upm: upm_scale)
138
+ end
139
+
140
+ # Set up encoding
141
+ @encoding = @options.encoding || Encodings::AdobeStandard
142
+ end
143
+
144
+ # Generate all Type 1 formats
145
+ #
146
+ # @return [Hash] Generated file contents
147
+ def generate
148
+ result = {}
149
+
150
+ # Always generate AFM
151
+ result[:afm] = AFMGenerator.generate(@font, to_hash)
152
+
153
+ # Always generate PFM (for Windows compatibility)
154
+ result[:pfm] = PFMGenerator.generate(@font, to_hash)
155
+
156
+ # Generate PFB or PFA based on format option
157
+ format = @options.format || :pfb
158
+ if format == :pfa
159
+ result[:pfa] = PFAGenerator.generate(@font, to_hash)
160
+ else
161
+ result[:pfb] = PFBGenerator.generate(@font, to_hash)
162
+ end
163
+
164
+ # Generate INF for Windows installation
165
+ result[:inf] = INFGenerator.generate(@font, to_hash)
166
+
167
+ result
168
+ end
169
+
170
+ private
171
+
172
+ # Convert options to hash
173
+ #
174
+ # @return [Hash] Options as hash
175
+ def to_hash
176
+ {
177
+ upm_scale: @options.upm_scale || 1000,
178
+ encoding: @options.encoding || Encodings::AdobeStandard,
179
+ decompose_composites: @options.decompose_composites || false,
180
+ convert_curves: @options.convert_curves || true,
181
+ autohint: @options.autohint || false,
182
+ preserve_hinting: @options.preserve_hinting || false,
183
+ format: @options.format || :pfb,
184
+ }
185
+ end
186
+
187
+ # Normalize options to ConversionOptions
188
+ #
189
+ # @param options [ConversionOptions, Hash] Options to normalize
190
+ # @return [ConversionOptions] Normalized options
191
+ def self.normalize_options(options)
192
+ return options if options.is_a?(ConversionOptions)
193
+
194
+ ConversionOptions.new(options)
195
+ end
196
+
197
+ # Instance method version of normalize_options
198
+ #
199
+ # @param options [ConversionOptions, Hash] Options to normalize
200
+ # @return [ConversionOptions] Normalized options
201
+ def normalize_options_value(options)
202
+ self.class.normalize_options(options)
203
+ end
204
+
205
+ # Extract base filename from font
206
+ #
207
+ # @param font [Fontisan::TrueTypeFont, Fontisan::OpenTypeFont] Source font
208
+ # @return [String] Base filename
209
+ def self.extract_base_name(font)
210
+ name_table = font.table(Constants::NAME_TAG)
211
+ if name_table.respond_to?(:postscript_name)
212
+ name = name_table.postscript_name(1) || name_table.postscript_name(3)
213
+ return name if name
214
+ end
215
+
216
+ font.post_script_name || "font"
217
+ end
218
+ end
219
+ end
220
+ end
@@ -0,0 +1,332 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Type1
5
+ # INF (Font Information) Generator
6
+ #
7
+ # [`INFGenerator`](lib/fontisan/type1/inf_generator.rb) generates INF files
8
+ # for Windows Type 1 font installation.
9
+ #
10
+ # INF files contain metadata for installing Type 1 fonts on Windows systems.
11
+ # They reference the PFB, PFM, and AFM files that make up a Windows Type 1 font.
12
+ #
13
+ # @example Generate INF from TTF
14
+ # font = Fontisan::FontLoader.load("font.ttf")
15
+ # inf_data = Fontisan::Type1::INFGenerator.generate(font)
16
+ # File.write("font.inf", inf_data)
17
+ #
18
+ # @example Generate INF with custom file names
19
+ # inf_data = Fontisan::Type1::INFGenerator.generate(font,
20
+ # pfb_file: "myfont.pfb",
21
+ # afm_file: "myfont.afm",
22
+ # pfm_file: "myfont.pfm"
23
+ # )
24
+ #
25
+ # @see https://www.adobe.com/devnet/font/pdfs/5005.PFM_Spec.pdf
26
+ class INFGenerator
27
+ # Generate INF file content from a font
28
+ #
29
+ # @param font [Fontisan::TrueTypeFont, Fontisan::OpenTypeFont] The font to generate INF from
30
+ # @param options [Hash] Generation options
31
+ # @option options [String] :pfb_file PFB filename (default: based on font name)
32
+ # @option options [String] :afm_file AFM filename (default: based on font name)
33
+ # @option options [String] :pfm_file PFM filename (default: based on font name)
34
+ # @option options [String] :inf_file INF filename (default: based on font name)
35
+ # @option options [String] :otf_file OTF filename (for OpenType fonts)
36
+ # @return [String] INF file content
37
+ def self.generate(font, options = {})
38
+ new(font, options).generate
39
+ end
40
+
41
+ # Generate INF file from a font and write to file
42
+ #
43
+ # @param font [Fontisan::TrueTypeFont, Fontisan::OpenTypeFont] The font to generate INF from
44
+ # @param path [String] Path to write INF file
45
+ # @param options [Hash] Generation options
46
+ # @return [void]
47
+ def self.generate_to_file(font, path, options = {})
48
+ inf_content = generate(font, options)
49
+ File.write(path, inf_content, encoding: "ISO-8859-1")
50
+ end
51
+
52
+ # Initialize a new INFGenerator
53
+ #
54
+ # @param font [Fontisan::TrueTypeFont, Fontisan::OpenTypeFont] The font to generate INF from
55
+ # @param options [Hash] Generation options
56
+ def initialize(font, options = {})
57
+ @font = font
58
+ @options = options
59
+ @metrics = MetricsCalculator.new(font)
60
+ end
61
+
62
+ # Generate INF file content
63
+ #
64
+ # @return [String] INF file content
65
+ def generate
66
+ lines = []
67
+
68
+ # Font description section
69
+ lines << "[Font Description]"
70
+ lines << build_font_description
71
+ lines << ""
72
+
73
+ # Files section
74
+ lines << "[Files]"
75
+ lines << build_file_list
76
+ lines << ""
77
+
78
+ # Other section
79
+ lines << "[Other]"
80
+ lines << build_other_section
81
+
82
+ lines.join("\n")
83
+ end
84
+
85
+ private
86
+
87
+ # Build font description section
88
+ #
89
+ # @return [String] Font description lines
90
+ def build_font_description
91
+ lines = []
92
+
93
+ # Font name (required)
94
+ font_name = extract_font_name
95
+ lines << "FontName=#{font_name}"
96
+
97
+ # Font files
98
+ pfb_file = @options[:pfb_file] || default_pfb_file
99
+ lines << "FontFile=#{pfb_file}"
100
+
101
+ afm_file = @options[:afm_file] || default_afm_file
102
+ lines << "MetricsFile=#{afm_file}"
103
+
104
+ pfm_file = @options[:pfm_file] || default_pfm_file
105
+ lines << "WinMetricsFile=#{pfm_file}"
106
+
107
+ # Font family
108
+ family_name = extract_family_name
109
+ lines << "FamilyName=#{family_name}" if family_name
110
+
111
+ # Font weight
112
+ weight = extract_weight
113
+ lines << "Weight=#{weight}" if weight
114
+
115
+ # Italic angle
116
+ italic_angle = extract_italic_angle
117
+ lines << "ItalicAngle=#{italic_angle}" if italic_angle && italic_angle != 0
118
+
119
+ # Version
120
+ version = extract_version
121
+ lines << "Version=#{version}" if version
122
+
123
+ # Copyright
124
+ copyright = extract_copyright
125
+ lines << "Copyright=#{copyright}" if copyright
126
+
127
+ # Font type
128
+ lines << "FontType=Type 1"
129
+
130
+ lines.join("\n")
131
+ end
132
+
133
+ # Build file list section
134
+ #
135
+ # @return [String] File list lines
136
+ def build_file_list
137
+ lines = []
138
+
139
+ # PFB file (required)
140
+ pfb_file = @options[:pfb_file] || default_pfb_file
141
+ lines << "#{pfb_file}=PFB"
142
+
143
+ # AFM file (required)
144
+ afm_file = @options[:afm_file] || default_afm_file
145
+ lines << "#{afm_file}=AFM"
146
+
147
+ # PFM file (required for Windows)
148
+ pfm_file = @options[:pfm_file] || default_pfm_file
149
+ lines << "#{pfm_file}=PFM"
150
+
151
+ # OTF file (if converting from OTF)
152
+ if @options[:otf_file]
153
+ lines << "#{@options[:otf_file]}=OTF"
154
+ end
155
+
156
+ lines.join("\n")
157
+ end
158
+
159
+ # Build other section
160
+ #
161
+ # @return [String] Other section lines
162
+ def build_other_section
163
+ lines = []
164
+
165
+ # Installation notes
166
+ lines << "Notes=This font is generated from #{@font.post_script_name} by Fontisan"
167
+
168
+ # Vendor
169
+ vendor = extract_vendor
170
+ lines << "Vendor=#{vendor}" if vendor
171
+
172
+ # License
173
+ license = extract_license
174
+ lines << "License=#{license}" if license
175
+
176
+ lines.join("\n")
177
+ end
178
+
179
+ # Extract font name
180
+ #
181
+ # @return [String] Font name
182
+ def extract_font_name
183
+ name_table = @font.table(Constants::NAME_TAG)
184
+ return "" unless name_table
185
+
186
+ # Try full font name first, then postscript name
187
+ if name_table.respond_to?(:full_font_name)
188
+ name_table.full_font_name(1) || name_table.full_font_name(3) ||
189
+ extract_postscript_name
190
+ else
191
+ extract_postscript_name
192
+ end
193
+ end
194
+
195
+ # Extract PostScript name
196
+ #
197
+ # @return [String] PostScript name
198
+ def extract_postscript_name
199
+ name_table = @font.table(Constants::NAME_TAG)
200
+ return @font.post_script_name || "Unknown" unless name_table
201
+
202
+ if name_table.respond_to?(:postscript_name)
203
+ name_table.postscript_name(1) || name_table.postscript_name(3) ||
204
+ @font.post_script_name || "Unknown"
205
+ else
206
+ @font.post_script_name || "Unknown"
207
+ end
208
+ end
209
+
210
+ # Extract family name
211
+ #
212
+ # @return [String, nil] Family name
213
+ def extract_family_name
214
+ name_table = @font.table(Constants::NAME_TAG)
215
+ return nil unless name_table
216
+
217
+ if name_table.respond_to?(:font_family)
218
+ name_table.font_family(1) || name_table.font_family(3)
219
+ end
220
+ end
221
+
222
+ # Extract weight
223
+ #
224
+ # @return [String, nil] Weight
225
+ def extract_weight
226
+ os2 = @font.table(Constants::OS2_TAG)
227
+ return nil unless os2
228
+
229
+ weight_class = if os2.respond_to?(:us_weight_class)
230
+ os2.us_weight_class
231
+ elsif os2.respond_to?(:weight_class)
232
+ os2.weight_class
233
+ end
234
+ return nil unless weight_class
235
+
236
+ case weight_class
237
+ when 100..200 then "Thin"
238
+ when 200..300 then "ExtraLight"
239
+ when 300..400 then "Light"
240
+ when 400..500 then "Regular"
241
+ when 500..600 then "Medium"
242
+ when 600..700 then "SemiBold"
243
+ when 700..800 then "Bold"
244
+ when 800..900 then "ExtraBold"
245
+ when 900..1000 then "Black"
246
+ else "Regular"
247
+ end
248
+ end
249
+
250
+ # Extract italic angle
251
+ #
252
+ # @return [Float, nil] Italic angle
253
+ def extract_italic_angle
254
+ post = @font.table(Constants::POST_TAG)
255
+ return nil unless post
256
+
257
+ if post.respond_to?(:italic_angle)
258
+ post.italic_angle
259
+ end
260
+ end
261
+
262
+ # Extract version
263
+ #
264
+ # @return [String, nil] Version string
265
+ def extract_version
266
+ name_table = @font.table(Constants::NAME_TAG)
267
+ return nil unless name_table
268
+
269
+ if name_table.respond_to?(:version_string)
270
+ name_table.version_string(1) || name_table.version_string(3)
271
+ end
272
+ end
273
+
274
+ # Extract copyright
275
+ #
276
+ # @return [String, nil] Copyright notice
277
+ def extract_copyright
278
+ name_table = @font.table(Constants::NAME_TAG)
279
+ return nil unless name_table
280
+
281
+ if name_table.respond_to?(:copyright)
282
+ name_table.copyright(1) || name_table.copyright(3)
283
+ end
284
+ end
285
+
286
+ # Extract vendor/manufacturer
287
+ #
288
+ # @return [String, nil] Vendor name
289
+ def extract_vendor
290
+ name_table = @font.table(Constants::NAME_TAG)
291
+ return nil unless name_table
292
+
293
+ if name_table.respond_to?(:manufacturer)
294
+ name_table.manufacturer(1) || name_table.manufacturer(3)
295
+ end
296
+ end
297
+
298
+ # Extract license information
299
+ #
300
+ # @return [String, nil] License information
301
+ def extract_license
302
+ name_table = @font.table(Constants::NAME_TAG)
303
+ return nil unless name_table
304
+
305
+ if name_table.respond_to?(:license)
306
+ name_table.license(1) || name_table.license(3)
307
+ end
308
+ end
309
+
310
+ # Get default PFB filename
311
+ #
312
+ # @return [String] PFB filename
313
+ def default_pfb_file
314
+ "#{extract_postscript_name}.pfb"
315
+ end
316
+
317
+ # Get default AFM filename
318
+ #
319
+ # @return [String] AFM filename
320
+ def default_afm_file
321
+ "#{extract_postscript_name}.afm"
322
+ end
323
+
324
+ # Get default PFM filename
325
+ #
326
+ # @return [String] PFM filename
327
+ def default_pfm_file
328
+ "#{extract_postscript_name}.pfm"
329
+ end
330
+ end
331
+ end
332
+ end