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,298 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Type1
5
+ # AFM (Adobe Font Metrics) file parser
6
+ #
7
+ # [`AFMParser`](lib/fontisan/type1/afm_parser.rb) parses Adobe Font Metrics
8
+ # files which contain font metric information for Type 1 fonts.
9
+ #
10
+ # AFM files include:
11
+ # - Character widths
12
+ # - Kerning pairs
13
+ # - Character bounding boxes
14
+ # - Font metadata (name, version, copyright, etc.)
15
+ #
16
+ # @example Parse an AFM file
17
+ # afm = Fontisan::Type1::AFMParser.parse_file("font.afm")
18
+ # puts afm.font_name
19
+ # puts afm.character_widths['A']
20
+ # puts afm.kerning_pairs[['A', 'V']]
21
+ #
22
+ # @see https://www.adobe.com/devnet/font/pdfs/5004.AFM_Spec.pdf
23
+ class AFMParser
24
+ # @return [String] Font name
25
+ attr_reader :font_name
26
+
27
+ # @return [String] Full name
28
+ attr_reader :full_name
29
+
30
+ # @return [String] Family name
31
+ attr_reader :family_name
32
+
33
+ # @return [String] Weight
34
+ attr_reader :weight
35
+
36
+ # @return [String] Version
37
+ attr_reader :version
38
+
39
+ # @return [String] Copyright notice
40
+ attr_reader :copyright
41
+
42
+ # @return [Hash<String, Integer>] Character widths (glyph name => width)
43
+ attr_reader :character_widths
44
+
45
+ # @return [Hash<Array(String>, Integer>] Kerning pairs ([left, right] => adjustment)
46
+ attr_reader :kerning_pairs
47
+
48
+ # @return [Hash] Character bounding boxes (glyph name => {llx, lly, urx, ury})
49
+ attr_reader :character_bboxes
50
+
51
+ # @return [Integer] Font bounding box [llx, lly, urx, ury]
52
+ attr_reader :font_bbox
53
+
54
+ # @return [Hash] Raw AFM data
55
+ attr_reader :raw_data
56
+
57
+ # Parse AFM file
58
+ #
59
+ # @param path [String] Path to AFM file
60
+ # @return [AFMParser] Parsed AFM data
61
+ # @raise [ArgumentError] If path is nil
62
+ # @raise [Fontisan::Error] If file cannot be read or parsed
63
+ def self.parse_file(path)
64
+ raise ArgumentError, "Path cannot be nil" if path.nil?
65
+
66
+ unless File.exist?(path)
67
+ raise Fontisan::Error, "AFM file not found: #{path}"
68
+ end
69
+
70
+ content = File.read(path, encoding: "ISO-8859-1")
71
+ parse(content)
72
+ end
73
+
74
+ # Parse AFM content
75
+ #
76
+ # @param content [String] AFM file content
77
+ # @return [AFMParser] Parsed AFM data
78
+ def self.parse(content)
79
+ new.parse(content)
80
+ end
81
+
82
+ # Alias for parse method
83
+ def self.parse_string(content)
84
+ parse(content)
85
+ end
86
+
87
+ # Initialize a new AFMParser
88
+ def initialize
89
+ @character_widths = {}
90
+ @kerning_pairs = {}
91
+ @character_bboxes = {}
92
+ @raw_data = {}
93
+ @font_bbox = nil
94
+ end
95
+
96
+ # Parse AFM content
97
+ #
98
+ # @param content [String] AFM file content
99
+ # @return [AFMParser] Self for method chaining
100
+ def parse(content)
101
+ parse_global_metrics(content)
102
+ parse_character_metrics(content)
103
+ parse_kerning_data(content)
104
+ self
105
+ end
106
+
107
+ # Get character width for glyph
108
+ #
109
+ # @param glyph_name [String] Glyph name
110
+ # @return [Integer, nil] Character width or nil if not found
111
+ def width(glyph_name)
112
+ @character_widths[glyph_name]
113
+ end
114
+
115
+ # Get kerning adjustment for character pair
116
+ #
117
+ # @param left [String] Left glyph name
118
+ # @param right [String] Right glyph name
119
+ # @return [Integer, nil] Kerning adjustment or nil if not found
120
+ def kerning(left, right)
121
+ @kerning_pairs[[left, right]]
122
+ end
123
+
124
+ # Check if character exists
125
+ #
126
+ # @param glyph_name [String] Glyph name
127
+ # @return [Boolean] True if character exists
128
+ def has_character?(glyph_name)
129
+ @character_widths.key?(glyph_name)
130
+ end
131
+
132
+ private
133
+
134
+ # Parse global font metrics
135
+ #
136
+ # @param content [String] AFM content
137
+ def parse_global_metrics(content)
138
+ # Split into lines for safer processing (limits input size for regex)
139
+ lines = content.lines
140
+
141
+ # Parse all metrics in a single loop to satisfy RuboCop
142
+ lines.each do |line|
143
+ if @font_name.nil? && (match = line.match(/^FontName\s+(\S+)/i))
144
+ @font_name = match[1]
145
+ elsif @full_name.nil? && (match = line.match(/^FullName\s+(\S.*)/i))
146
+ @full_name = match[1].strip
147
+ elsif @family_name.nil? && (match = line.match(/^FamilyName\s+(\S.*)/i))
148
+ @family_name = match[1].strip
149
+ elsif @weight.nil? && (match = line.match(/^Weight\s+(\S.*)/i))
150
+ @weight = match[1].strip
151
+ elsif @version.nil? && (match = line.match(/^Version\s+(\S.*)/i))
152
+ @version = match[1].strip
153
+ elsif @copyright.nil? && (match = line.match(/^Notice\s+(\S.*)/i))
154
+ @copyright = match[1].strip
155
+ elsif @font_bbox.nil? && (match = line.match(/^FontBBox\s+(-?\d+)\s+(-?\d+)\s+(-?\d+)\s+(-?\d+)$/i))
156
+ @font_bbox = [match[1].to_i, match[2].to_i, match[3].to_i, match[4].to_i]
157
+ end
158
+
159
+ # Break early if all metrics found
160
+ break if @font_name && @full_name && @family_name &&
161
+ @weight && @version && @copyright && @font_bbox
162
+ end
163
+ end
164
+
165
+ # Parse character metrics
166
+ #
167
+ # @param content [String] AFM content
168
+ def parse_character_metrics(content)
169
+ # Find StartCharMetrics section - use safer pattern
170
+ char_metrics_section = content.match(/^StartCharMetrics\s+(\d+)/i)
171
+ return unless char_metrics_section
172
+
173
+ # Parse character metrics until EndCharMetrics
174
+ in_char_metrics = false
175
+ content.each_line do |line|
176
+ if /^StartCharMetrics/i.match?(line)
177
+ in_char_metrics = true
178
+ next
179
+ end
180
+
181
+ break if /^EndCharMetrics/i.match?(line)
182
+ next unless in_char_metrics
183
+
184
+ # Parse character metric line
185
+ # Format: C name ; WX width ; B llx lly urx ury ...
186
+ parse_char_metric_line(line)
187
+ end
188
+ end
189
+
190
+ # Parse a single character metric line
191
+ #
192
+ # @param line [String] Character metric line
193
+ def parse_char_metric_line(line)
194
+ return if line.strip.empty?
195
+
196
+ glyph_name = nil
197
+ char_width = nil
198
+ char_bbox = nil
199
+
200
+ # Parse character metric line
201
+ # Format: C code ; WX width ; N name ; B llx lly urx ury ...
202
+ # Or: C code ; WX width ; B llx lly urx ury ... (no N field)
203
+
204
+ # Extract glyph name from N field if present
205
+ # Format: N name ; or N;name;
206
+ if (name_match = line.match(/;\s+N\s+([^\s;]+)/))
207
+ glyph_name = name_match[1]
208
+ # Remove quotes if present
209
+ glyph_name = glyph_name.gsub(/['"]/, "")
210
+ end
211
+
212
+ # Extract width (WX)
213
+ if (width_match = line.match(/WX\s+(\d+)/))
214
+ char_width = width_match[1].to_i
215
+ end
216
+
217
+ # Extract bounding box (B)
218
+ if (bbox_match = line.match(/B\s+(-?\d+)\s+(-?\d+)\s+(-?\d+)\s+(-?\d+)/))
219
+ char_bbox = {
220
+ llx: bbox_match[1].to_i,
221
+ lly: bbox_match[2].to_i,
222
+ urx: bbox_match[3].to_i,
223
+ ury: bbox_match[4].to_i,
224
+ }
225
+ end
226
+
227
+ # Store if we have a name and width
228
+ if glyph_name && char_width
229
+ @character_widths[glyph_name] = char_width
230
+ end
231
+
232
+ # Store bounding box if we have a name and bbox
233
+ if glyph_name && char_bbox
234
+ @character_bboxes[glyph_name] = char_bbox
235
+ end
236
+ end
237
+
238
+ # Parse kerning data
239
+ #
240
+ # @param content [String] AFM content
241
+ def parse_kerning_data(content)
242
+ # Find StartKernData section
243
+ in_kern_data = false
244
+ in_kern_pairs = false
245
+
246
+ content.each_line do |line|
247
+ if /^StartKernData/i.match?(line)
248
+ in_kern_data = true
249
+ next
250
+ end
251
+
252
+ if /^EndKernData/i.match?(line)
253
+ in_kern_data = false
254
+ next
255
+ end
256
+
257
+ if /^StartKernPairs\s+/i.match?(line)
258
+ in_kern_pairs = true
259
+ next
260
+ end
261
+
262
+ if /^EndKernPairs/i.match?(line)
263
+ in_kern_pairs = false
264
+ next
265
+ end
266
+
267
+ next unless in_kern_data && in_kern_pairs
268
+
269
+ # Parse kerning pair line
270
+ # Format: KPX left right adjustment
271
+ parse_kern_pair_line(line)
272
+ end
273
+ end
274
+
275
+ # Parse a kerning pair line
276
+ #
277
+ # @param line [String] Kerning pair line
278
+ def parse_kern_pair_line(line)
279
+ return if line.strip.empty?
280
+
281
+ # KPX format: KPX left right adjustment
282
+ # Use specific character class for glyph names to prevent ReDoS
283
+ # AFM glyph names contain letters, digits, period, underscore
284
+ if (match = line.match(/^KPX\s+([A-Za-z0-9._]{1,64})\s+([A-Za-z0-9._]{1,64})\s+(-?\d+)/i))
285
+ left = match[1]
286
+ right = match[2]
287
+ adjustment = match[3].to_i
288
+
289
+ # Remove quotes if present (some AFM files quote glyph names)
290
+ left = left.gsub(/['"]/, "")
291
+ right = right.gsub(/['"]/, "")
292
+
293
+ @kerning_pairs[[left, right]] = adjustment
294
+ end
295
+ end
296
+ end
297
+ end
298
+ end