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,501 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Type1
5
+ # Expands Type 1 seac composite glyphs into base + accent outlines
6
+ #
7
+ # [`SeacExpander`](lib/fontisan/type1/seac_expander.rb) handles the
8
+ # decomposition of Type 1 composite glyphs that use the `seac` operator.
9
+ # The seac operator combines two glyphs (a base character and an accent)
10
+ # with a positioning offset, which must be decomposed for CFF conversion.
11
+ #
12
+ # The seac operator format is:
13
+ # ```
14
+ # seac asb adx ady bchar achar
15
+ # ```
16
+ #
17
+ # Where:
18
+ # - `asb`: Accent side bearing (not used in decomposition)
19
+ # - `adx`: X offset for accent placement
20
+ # - `ady`: Y offset for accent placement
21
+ # - `bchar`: Character code of base glyph
22
+ # - `achar`: Character code of accent glyph
23
+ #
24
+ # @example Decompose a seac composite
25
+ # expander = Fontisan::Type1::SeacExpander.new(charstrings, private_dict)
26
+ # decomposed = expander.decompose("Agrave")
27
+ # # => Returns merged outline of base 'A' + accent 'grave'
28
+ #
29
+ # @see https://www.adobe.com/devnet/font/pdfs/Type1.pdf
30
+ class SeacExpander
31
+ # @return [CharStrings] Type 1 CharStrings dictionary
32
+ attr_reader :charstrings
33
+
34
+ # @return [PrivateDict] Private dictionary for hinting
35
+ attr_reader :private_dict
36
+
37
+ # Initialize a new SeacExpander
38
+ #
39
+ # @param charstrings [CharStrings] Type 1 CharStrings dictionary
40
+ # @param private_dict [PrivateDict] Private dictionary for context
41
+ def initialize(charstrings, private_dict)
42
+ @charstrings = charstrings
43
+ @private_dict = private_dict
44
+ end
45
+
46
+ # Decompose a seac composite glyph into base + accent outlines
47
+ #
48
+ # This method:
49
+ # 1. Parses the seac operator to extract components
50
+ # 2. Gets CharStrings for base and accent glyphs
51
+ # 3. Parses both CharStrings into outline commands
52
+ # 4. Transforms the accent by (adx, ady) offset
53
+ # 5. Merges base and accent outlines into a single path
54
+ # 6. Returns the decomposed CharString data
55
+ #
56
+ # @param glyph_name [String] Name of the composite glyph to decompose
57
+ # @return [String, nil] Decomposed CharString bytecode, or nil if not a seac composite
58
+ # @raise [Fontisan::Error] If base or accent glyphs are not found
59
+ #
60
+ # @example Decompose "Agrave" glyph
61
+ # expander.decompose("Agrave")
62
+ def decompose(glyph_name)
63
+ components = @charstrings.components_for(glyph_name)
64
+ return nil unless components
65
+
66
+ # Use the encoding map to lookup glyph names from character codes
67
+ base_glyph_name = @charstrings.encoding[components[:base]]
68
+ accent_glyph_name = @charstrings.encoding[components[:accent]]
69
+
70
+ if base_glyph_name.nil?
71
+ raise Fontisan::Error, "Base glyph for char code #{components[:base]} not found"
72
+ end
73
+
74
+ if accent_glyph_name.nil?
75
+ raise Fontisan::Error, "Accent glyph for char code #{components[:accent]} not found"
76
+ end
77
+
78
+ # Get CharStrings for base and accent
79
+ base_charstring = @charstrings[base_glyph_name]
80
+ accent_charstring = @charstrings[accent_glyph_name]
81
+
82
+ if base_charstring.nil?
83
+ raise Fontisan::Error, "CharString not found for base glyph #{base_glyph_name}"
84
+ end
85
+
86
+ if accent_charstring.nil?
87
+ raise Fontisan::Error, "CharString not found for accent glyph #{accent_glyph_name}"
88
+ end
89
+
90
+ # Parse both CharStrings into command sequences
91
+ base_commands = parse_charstring_to_commands(base_charstring)
92
+ accent_commands = parse_charstring_to_commands(accent_charstring)
93
+
94
+ # Transform accent by (adx, ady) offset
95
+ accent_commands = transform_commands(accent_commands, components[:adx], components[:ady])
96
+
97
+ # Merge base and accent commands
98
+ merged_commands = merge_outline_commands(base_commands, accent_commands)
99
+
100
+ # Convert merged commands back to CharString bytecode
101
+ generate_charstring(merged_commands)
102
+ end
103
+
104
+ # Check if a glyph is a seac composite
105
+ #
106
+ # @param glyph_name [String] Glyph name to check
107
+ # @return [Boolean] True if the glyph uses seac operator
108
+ def composite?(glyph_name)
109
+ @charstrings.composite?(glyph_name)
110
+ end
111
+
112
+ # Get all seac composite glyphs in the font
113
+ #
114
+ # @return [Array<String>] List of composite glyph names
115
+ def composite_glyphs
116
+ @charstrings.glyph_names.select { |name| composite?(name) }
117
+ end
118
+
119
+ private
120
+
121
+ # Parse Type 1 CharString into drawing commands
122
+ #
123
+ # Converts Type 1 CharString bytecode into a list of drawing commands
124
+ # that can be manipulated and transformed.
125
+ #
126
+ # @param charstring [String] Binary CharString data
127
+ # @return [Array<Hash>] Array of command hashes
128
+ def parse_charstring_to_commands(charstring)
129
+ parser = CharStrings::CharStringParser.new(@private_dict)
130
+ parser.parse(charstring)
131
+ # Preprocess to combine numbers with operators
132
+ combined_commands = combine_numbers_with_operators(parser.commands)
133
+ commands_to_outline(combined_commands)
134
+ end
135
+
136
+ # Combine separate number commands with their operators
137
+ #
138
+ # The CharStringParser produces commands like [[:number, 100], [:number, 0], [:hsbw]]
139
+ # This method combines them into [[:hsbw, 100, 0]]
140
+ #
141
+ # @param commands [Array] Parser commands
142
+ # @return [Array] Combined commands
143
+ def combine_numbers_with_operators(commands)
144
+ result = []
145
+ number_stack = []
146
+
147
+ commands.each do |cmd|
148
+ if cmd[0] == :number
149
+ number_stack << cmd[1]
150
+ elsif cmd[0] == :seac
151
+ # seac is special - it uses the number stack for components
152
+ result << cmd
153
+ parse_seac_from_stack(result, number_stack)
154
+ else
155
+ # Operator - pop required numbers from stack
156
+ operator = cmd[0]
157
+ args = get_args_for_operator(operator, number_stack)
158
+ result << [operator, *args] if args
159
+ end
160
+ end
161
+
162
+ result
163
+ end
164
+
165
+ # Get arguments for an operator from the number stack
166
+ #
167
+ # @param operator [Symbol] Operator symbol
168
+ # @param stack [Array] Number stack
169
+ # @return [Array, nil] Arguments or nil if operator unknown
170
+ def get_args_for_operator(operator, stack)
171
+ case operator
172
+ when :hsbw
173
+ # hsbw takes 2 args: sbw, width
174
+ stack.pop(2).reverse
175
+ when :sbw
176
+ # sbw takes 3 args: sbw, wy, wx
177
+ stack.pop(3).reverse
178
+ when :rmoveto
179
+ # rmoveto takes 2 args: dy, dx
180
+ stack.pop(2).reverse
181
+ when :hmoveto
182
+ # hmoveto takes 1 arg: dx
183
+ stack.pop(1)
184
+ when :vmoveto
185
+ # vmoveto takes 1 arg: dy
186
+ stack.pop(1)
187
+ when :rlineto
188
+ # rlineto takes 2 args: dy, dx
189
+ stack.pop(2).reverse
190
+ when :hlineto
191
+ # hlineto takes 1 arg: dx
192
+ stack.pop(1)
193
+ when :vlineto
194
+ # vlineto takes 1 arg: dy
195
+ stack.pop(1)
196
+ when :rrcurveto
197
+ # rrcurveto takes 6 args: dy3, dx3, dy2, dx2, dy1, dx1
198
+ stack.pop(6).reverse
199
+ when :vhcurveto, :hvcurveto
200
+ # vhcurveto/hvcurveto take 4 args
201
+ stack.pop(4).reverse
202
+ when :hstem, :vstem, :callsubr, :return, :endchar
203
+ # These operators don't need args for outline conversion
204
+ []
205
+ else
206
+ # Unknown operator - return nil
207
+ nil
208
+ end
209
+ end
210
+
211
+ # Parse seac components from number stack
212
+ #
213
+ # @param result [Array] Result array to append to
214
+ # @param stack [Array] Number stack
215
+ def parse_seac_from_stack(result, stack)
216
+ return if stack.length < 5
217
+
218
+ # Last 5 numbers are: asb, adx, ady, bchar, achar
219
+ args = stack.pop(5).reverse
220
+ result.last[1] = args[1] # adx
221
+ result.last[2] = args[2] # ady
222
+ result.last[3] = args[3] # bchar
223
+ result.last[4] = args[4] # achar
224
+ end
225
+
226
+ # Convert parser commands to outline format
227
+ #
228
+ # @param commands [Array] Parser command format [type, *args]
229
+ # @return [Array<Hash>] Outline command format
230
+ def commands_to_outline(commands)
231
+ outline_commands = []
232
+ x = 0
233
+ y = 0
234
+
235
+ commands.each do |cmd|
236
+ case cmd[0]
237
+ when :hsbw
238
+ # hsbw x0 sbw: Set horizontal width and left sidebearing
239
+ # This is usually the first command in a CharString
240
+ x = cmd[1]
241
+ y = 0
242
+ outline_commands << { type: :move_to, x: x, y: y }
243
+ when :sbw
244
+ # sbw sbw x0 sbw: Set width and side bearings (vertical)
245
+ y = cmd[1]
246
+ x = cmd[2]
247
+ outline_commands << { type: :move_to, x: x, y: y }
248
+ when :rmoveto
249
+ # rmoveto dx dy: Relative move to
250
+ x += cmd[1]
251
+ y += cmd[2]
252
+ outline_commands << { type: :line_to, x: x, y: y }
253
+ when :hmoveto
254
+ # hmoveto dx: Horizontal move to
255
+ x += cmd[1]
256
+ outline_commands << { type: :line_to, x: x, y: y }
257
+ when :vmoveto
258
+ # vmoveto dy: Vertical move to
259
+ y += cmd[1]
260
+ outline_commands << { type: :line_to, x: x, y: y }
261
+ when :rlineto
262
+ # rlineto dx dy: Relative line to
263
+ x += cmd[1]
264
+ y += cmd[2]
265
+ outline_commands << { type: :line_to, x: x, y: y }
266
+ when :hlineto
267
+ # hlineto dx: Horizontal line to
268
+ x += cmd[1]
269
+ outline_commands << { type: :line_to, x: x, y: y }
270
+ when :vlineto
271
+ # vlineto dy: Vertical line to
272
+ y += cmd[1]
273
+ outline_commands << { type: :line_to, x: x, y: y }
274
+ when :rrcurveto
275
+ # rrcurveto dx1 dy1 dx2 dy2 dx3 dy3: Relative curved line to
276
+ # This is a quadratic curve in Type 1
277
+ # Convert to our outline format (quadratic)
278
+ dx1, dy1, dx2, dy2, dx3, dy3 = cmd[1..6]
279
+ control_x = x + dx1
280
+ control_y = y + dy1
281
+ anchor_x = x + dx1 + dx2
282
+ anchor_y = y + dy1 + dy2
283
+ end_x = x + dx1 + dx2 + dx3
284
+ end_y = y + dy1 + dy2 + dy3
285
+
286
+ outline_commands << {
287
+ type: :quad_to,
288
+ cx: control_x,
289
+ cy: control_y,
290
+ x: end_x,
291
+ y: end_y
292
+ }
293
+ x = end_x
294
+ y = end_y
295
+ when :vhcurveto, :hvcurveto
296
+ # vhcurveto: Vertical-to-horizontal curve
297
+ # hvcurveto: Horizontal-to-vertical curve
298
+ # These are also quadratic curves
299
+ # For now, treat as simple curves
300
+ handle_curve_command(outline_commands, cmd, x, y)
301
+ x = outline_commands.last[:x]
302
+ y = outline_commands.last[:y]
303
+ when :endchar
304
+ # End of CharString
305
+ break
306
+ when :number
307
+ # Number - skip, consumed by operators
308
+ nil
309
+ else
310
+ # Unknown command - skip
311
+ nil
312
+ end
313
+ end
314
+
315
+ outline_commands
316
+ end
317
+
318
+ # Handle curve commands (vhcurveto, hvcurveto)
319
+ #
320
+ # @param commands [Array] Command list to append to
321
+ # @param cmd [Array] The curve command
322
+ # @param x [Integer] Current X position
323
+ # @param y [Integer] Current Y position
324
+ def handle_curve_command(commands, cmd, x, y)
325
+ # Simplified handling - treat as quadratic curve
326
+ # vhcurveto: dy1 dx2 dy2 dx3
327
+ # hvcurveto: dx1 dy2 dx3 dy3
328
+ if cmd[0] == :vhcurveto
329
+ dy1, dx2, dy2, dx3 = cmd[1..4]
330
+ control_x = x
331
+ control_y = y + dy1
332
+ end_x = x + dx2
333
+ end_y = y + dy1 + dy2
334
+ else
335
+ dx1, dy2, dx3, dy3 = cmd[1..4]
336
+ control_x = x + dx1
337
+ control_y = y
338
+ end_x = x + dx1 + dx2
339
+ end_y = y + dy2 + dy3
340
+ end
341
+
342
+ commands << {
343
+ type: :quad_to,
344
+ cx: control_x,
345
+ cy: control_y,
346
+ x: end_x,
347
+ y: end_y
348
+ }
349
+ end
350
+
351
+ # Transform outline commands by translation
352
+ #
353
+ # @param commands [Array<Hash>] Outline commands
354
+ # @param dx [Integer] X offset
355
+ # @param dy [Integer] Y offset
356
+ # @return [Array<Hash>] Transformed commands
357
+ def transform_commands(commands, dx, dy)
358
+ return commands if dx == 0 && dy == 0
359
+
360
+ commands.map do |cmd|
361
+ case cmd[:type]
362
+ when :move_to, :line_to
363
+ { type: cmd[:type], x: cmd[:x] + dx, y: cmd[:y] + dy }
364
+ when :quad_to
365
+ {
366
+ type: :quad_to,
367
+ cx: cmd[:cx] + dx,
368
+ cy: cmd[:cy] + dy,
369
+ x: cmd[:x] + dx,
370
+ y: cmd[:y] + dy
371
+ }
372
+ when :curve_to
373
+ {
374
+ type: :curve_to,
375
+ cx1: cmd[:cx1] + dx,
376
+ cy1: cmd[:cy1] + dy,
377
+ cx2: cmd[:cx2] + dx,
378
+ cy2: cmd[:cy2] + dy,
379
+ x: cmd[:x] + dx,
380
+ y: cmd[:y] + dy
381
+ }
382
+ else
383
+ cmd # close_path, etc. pass through
384
+ end
385
+ end
386
+ end
387
+
388
+ # Merge base and accent outline commands
389
+ #
390
+ # Combines two outline sequences into one. The accent outline is
391
+ # appended to the base outline with proper contour separation.
392
+ #
393
+ # @param base_commands [Array<Hash>] Base glyph commands
394
+ # @param accent_commands [Array<Hash>] Accent glyph commands
395
+ # @return [Array<Hash>] Merged commands
396
+ def merge_outline_commands(base_commands, accent_commands)
397
+ # Remove the final close_path from base (if any)
398
+ # Then append accent commands
399
+ merged = base_commands.dup
400
+
401
+ # If base ends with close_path, remove it (accent will have its own)
402
+ merged.pop if merged.last && merged.last[:type] == :close_path
403
+
404
+ # Add accent commands
405
+ merged.concat(accent_commands)
406
+
407
+ merged
408
+ end
409
+
410
+ # Generate CharString bytecode from outline commands
411
+ #
412
+ # Converts outline commands back to Type 1 CharString format.
413
+ # This is a simplified implementation that handles common cases.
414
+ #
415
+ # @param commands [Array<Hash>] Outline commands
416
+ # @return [String] Binary CharString data
417
+ def generate_charstring(commands)
418
+ return "" if commands.empty?
419
+
420
+ charstring = String.new(encoding: Encoding::ASCII_8BIT)
421
+
422
+ x = 0
423
+ y = 0
424
+
425
+ commands.each do |cmd|
426
+ case cmd[:type]
427
+ when :move_to
428
+ # Use hsbw to set initial position
429
+ dx = cmd[:x] - x
430
+ # hsbw is a two-byte operator: 12 34
431
+ charstring << encode_number(dx) # sbw value
432
+ charstring << encode_number(0) # width (always 0 for decomposed glyphs)
433
+ charstring << 12 # First byte of two-byte operator
434
+ charstring << 34 # Second byte - hsbw
435
+ x = cmd[:x]
436
+ y = cmd[:y]
437
+ when :line_to
438
+ dx = cmd[:x] - x
439
+ dy = cmd[:y] - y
440
+ if dx != 0 && dy != 0
441
+ charstring << encode_number(dx)
442
+ charstring << encode_number(dy)
443
+ charstring << 5 # rlineto
444
+ elsif dx != 0
445
+ charstring << encode_number(dx)
446
+ charstring << 6 # hlineto
447
+ elsif dy != 0
448
+ charstring << encode_number(dy)
449
+ charstring << 7 # vlineto
450
+ end
451
+ x = cmd[:x]
452
+ y = cmd[:y]
453
+ when :quad_to
454
+ # rrcurveto: dx1 dy1 dx2 dy2 dx3 dy3
455
+ dx1 = cmd[:cx] - x
456
+ dy1 = cmd[:cy] - y
457
+ # For quadratic curves, we need to convert to cubic (Type 1 rrcurveto)
458
+ # This is a simplified conversion
459
+ anchor_dx = (cmd[:x] - x) / 2 - dx1 / 2
460
+ anchor_dy = (cmd[:y] - y) / 2 - dy1 / 2
461
+ end_dx = (cmd[:x] - x) - dx1 - anchor_dx
462
+ end_dy = (cmd[:y] - y) - dy1 - anchor_dy
463
+
464
+ charstring << encode_number(dx1)
465
+ charstring << encode_number(dy1)
466
+ charstring << encode_number(anchor_dx)
467
+ charstring << encode_number(anchor_dy)
468
+ charstring << encode_number(end_dx)
469
+ charstring << encode_number(end_dy)
470
+ charstring << 8 # rrcurveto
471
+ x = cmd[:x]
472
+ y = cmd[:y]
473
+ end
474
+ end
475
+
476
+ # Add endchar
477
+ charstring << 14
478
+
479
+ charstring
480
+ end
481
+
482
+ # Encode integer for Type 1 CharString
483
+ #
484
+ # Type 1 CharStrings use a variable-length integer encoding:
485
+ # - Numbers from -107 to 107: single byte (byte + 139)
486
+ # - Larger numbers: escaped with 255, then 2-byte value
487
+ #
488
+ # @param num [Integer] Number to encode
489
+ # @return [String] Encoded bytes
490
+ def encode_number(num)
491
+ if num >= -107 && num <= 107
492
+ [num + 139].pack("C*")
493
+ else
494
+ # Use escape sequence (255) followed by 2-byte signed integer
495
+ num += 32768 if num < 0
496
+ [255, num % 256, num >> 8].pack("C*")
497
+ end
498
+ end
499
+ end
500
+ end
501
+ end