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,302 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Type1
5
+ # Converter for CFF CharStrings to Type 1 CharStrings
6
+ #
7
+ # [`CffToType1Converter`](lib/fontisan/type1/cff_to_type1_converter.rb) converts
8
+ # CFF (Compact Font Format) Type 2 CharStrings to Type 1 CharStrings.
9
+ #
10
+ # CFF and Type 1 use similar stack-based languages but have different operator
11
+ # codes and some structural differences:
12
+ # - Operator codes differ between formats
13
+ # - Type 1 uses hsbw/sbw for width selection; CFF uses initial operand
14
+ # - Hint operators need to be preserved with code translation
15
+ #
16
+ # @example Convert a CFF CharString to Type 1
17
+ # converter = Fontisan::Type1::CffToType1Converter.new
18
+ # type1_charstring = converter.convert(cff_charstring)
19
+ #
20
+ # @see https://www.adobe.com/devnet/font/pdfs/Type1.pdf
21
+ # @see https://www.microsoft.com/typography/otspec/cff.htm
22
+ class CffToType1Converter
23
+ # CFF to Type 1 operator mapping
24
+ #
25
+ # Maps CFF operator codes to Type 1 operator codes.
26
+ # Most operators are the same, but some differ.
27
+ CFF_TO_TYPE1 = {
28
+ # Path construction operators
29
+ hmoveto: 22, # CFF: 22, Type 1: 22
30
+ vmoveto: 4, # CFF: 4, Type 1: 4
31
+ rlineto: 5, # CFF: 5, Type 1: 5
32
+ hlineto: 6, # CFF: 6, Type 1: 6
33
+ vlineto: 7, # CFF: 7, Type 1: 7
34
+ rrcurveto: 8, # CFF: 8, Type 1: 8
35
+ hhcurveto: 27, # CFF: 27, Type 1: 27
36
+ hvcurveto: 31, # CFF: 31, Type 1: 31
37
+ vhcurveto: 30, # CFF: 30, Type 1: 30
38
+ rcurveline: 24, # CFF: 24, Type 1: 24
39
+ rlinecurve: 25, # CFF: 25, Type 1: 25
40
+
41
+ # Hint operators
42
+ hstem: 1, # CFF: 1, Type 1: 1
43
+ vstem: 3, # CFF: 3, Type 1: 3
44
+ hstemhm: 18, # CFF: 18, Type 1: 18
45
+ vstemhm: 23, # CFF: 23, Type 1: 23
46
+
47
+ # Hint substitution (preserve for compatibility)
48
+ hintmask: 19, # CFF: 19, Type 1: Not supported (skip)
49
+ cntrmask: 20, # CFF: 20, Type 1: Not supported (skip)
50
+
51
+ # End char
52
+ endchar: 14, # CFF: 14, Type 1: 14
53
+
54
+ # Miscellaneous
55
+ callsubr: 10, # CFF: 10, Type 1: 10
56
+ return: 11, # CFF: 11, Type 1: 11
57
+ rmoveto: 21, # CFF: 21, Type 1: 21
58
+ }.freeze
59
+
60
+ # Escape code for two-byte operators
61
+ ESCAPE_BYTE = 12
62
+
63
+ # Initialize a new CffToType1Converter
64
+ #
65
+ # @param nominal_width [Integer] Nominal width from CFF Private dict (default: 0)
66
+ # @param default_width [Integer] Default width from CFF Private dict (default: 0)
67
+ def initialize(nominal_width: 0, default_width: 0)
68
+ @nominal_width = nominal_width
69
+ @default_width = default_width
70
+ end
71
+
72
+ # Convert CFF CharString to Type 1 CharString
73
+ #
74
+ # Takes binary CFF CharString data and converts it to Type 1 format.
75
+ #
76
+ # @param cff_charstring [String] CFF CharString bytecode
77
+ # @param private_dict [Hash] CFF Private dict for context (optional)
78
+ # @return [String] Type 1 CharString bytecode
79
+ #
80
+ # @example Convert a CharString
81
+ # converter = Fontisan::Type1::CffToType1Converter.new
82
+ # type1_bytes = converter.convert(cff_bytes)
83
+ def convert(cff_charstring, private_dict: {})
84
+ # Parse CFF CharString into operations
85
+ parser = Tables::Cff::CharStringParser.new(cff_charstring,
86
+ stem_count: private_dict[:stem_count]&.to_i || 0)
87
+ operations = parser.parse
88
+
89
+ # Extract width from operations (CFF spec: odd stack before first move = width)
90
+ width = extract_width(operations)
91
+
92
+ # Convert operations to Type 1 format
93
+ convert_operations(operations, width)
94
+ end
95
+
96
+ # Extract width from CFF operations
97
+ #
98
+ # In CFF, if there's an odd number of arguments before the first move
99
+ # operator (rmoveto, hmoveto, vmoveto, rcurveline, rrcurveline, vvcurveto,
100
+ # hhcurveto), the first argument is the width.
101
+ #
102
+ # @param operations [Array<Hash>] Parsed CFF operations
103
+ # @return [Integer, nil] Width value or nil if using default
104
+ def extract_width(operations)
105
+ return @default_width if operations.empty?
106
+
107
+ # Find first move operator
108
+ first_move_idx = operations.index do |op|
109
+ %i[rmoveto hmoveto vmoveto rcurveline rrcurveline vvcurveto hhcurveto].include?(op[:name])
110
+ end
111
+
112
+ return @default_width unless first_move_idx
113
+
114
+ # Count operands before first move
115
+ operand_count = operations[0...first_move_idx].sum(0) do |op|
116
+ op[:operands]&.length || 0
117
+ end
118
+
119
+ # If odd, first operand of first move is width
120
+ if operand_count.odd?
121
+ first_move = operations[first_move_idx]
122
+ if first_move[:operands] && !first_move[:operands].empty?
123
+ return first_move[:operands].first
124
+ end
125
+ end
126
+
127
+ @default_width
128
+ end
129
+
130
+ # Convert parsed CFF operations to Type 1 CharString
131
+ #
132
+ # @param operations [Array<Hash>] Parsed CFF operations
133
+ # @param width [Integer, nil] Glyph width from CFF CharString
134
+ # @return [String] Type 1 CharString bytecode
135
+ def convert_operations(operations, width = nil)
136
+ result = String.new(encoding: Encoding::ASCII_8BIT)
137
+
138
+ # Determine width: use provided width or default/nominal
139
+ glyph_width = width || @default_width
140
+
141
+ # Add hsbw (horizontal sidebearing and width) at start
142
+ # This is the standard width operator for horizontal fonts
143
+ result << encode_number(0) # left sidebearing (usually 0 for CFF)
144
+ result << encode_number(glyph_width)
145
+ result << ESCAPE_BYTE
146
+ result << 34 # hsbw operator (two-byte: 12 34)
147
+
148
+ x = 0
149
+ y = 0
150
+ first_move = true
151
+ skip_first_operand = false
152
+
153
+ # Check if width was extracted (odd stack before first move)
154
+ if width && operations.any?
155
+ # Count operands before first move to determine if width was in stack
156
+ first_move_idx = operations.index do |op|
157
+ %i[rmoveto hmoveto vmoveto rcurveline rrcurveline vvcurveto hhcurveto].include?(op[:name])
158
+ end
159
+
160
+ if first_move_idx
161
+ operand_count = operations[0...first_move_idx].sum(0) do |op|
162
+ op[:operands]&.length || 0
163
+ end
164
+
165
+ skip_first_operand = operand_count.odd?
166
+ end
167
+ end
168
+
169
+ operations.each do |op|
170
+ case op[:name]
171
+ when :hstem, :vstem, :hstemhm, :vstemhm
172
+ # Hint operators - preserve
173
+ op[:operands].each { |val| result << encode_number(val) }
174
+ result << CFF_TO_TYPE1[op[:name]]
175
+ when :rmoveto
176
+ # rmoveto dx dy (or width dx dy if first move with odd stack)
177
+ operands = op[:operands]
178
+ if first_move && skip_first_operand && !operands.empty?
179
+ # Skip first operand (it was the width)
180
+ operands = operands[1..]
181
+ skip_first_operand = false
182
+ end
183
+
184
+ if operands.length >= 2
185
+ dx, dy = operands[0], operands[1]
186
+ x += dx
187
+ y += dy
188
+ result << encode_number(dx)
189
+ result << encode_number(dy)
190
+ result << 21 # rmoveto
191
+ elsif operands.length == 1
192
+ # Only dy (hmoveto/vmoveto style)
193
+ result << encode_number(operands.first)
194
+ result << 4 # vmoveto (closest approximation)
195
+ end
196
+ first_move = false
197
+ when :hmoveto
198
+ # hmoveto dx (or width dx if first move)
199
+ operands = op[:operands]
200
+ if first_move && skip_first_operand && !operands.empty?
201
+ operands = [operands[0]] if operands.length > 1
202
+ skip_first_operand = false
203
+ end
204
+
205
+ dx = operands.first
206
+ x += dx if dx
207
+ result << encode_number(dx)
208
+ result << 22 # hmoveto
209
+ first_move = false
210
+ when :vmoveto
211
+ # vmoveto dy
212
+ dy = op[:operands].first
213
+ y += dy if dy
214
+ result << encode_number(dy)
215
+ result << 4 # vmoveto
216
+ first_move = false
217
+ when :rlineto
218
+ # rlineto dx dy
219
+ dx, dy = op[:operands]
220
+ x += dx
221
+ y += dy
222
+ result << encode_number(dx)
223
+ result << encode_number(dy)
224
+ result << 5 # rlineto
225
+ first_move = false
226
+ when :hlineto
227
+ # hlineto dx
228
+ dx = op[:operands].first
229
+ x += dx
230
+ result << encode_number(dx)
231
+ result << 6 # hlineto
232
+ first_move = false
233
+ when :vlineto
234
+ # vlineto dy
235
+ dy = op[:operands].first
236
+ y += dy
237
+ result << encode_number(dy)
238
+ result << 7 # vlineto
239
+ first_move = false
240
+ when :rrcurveto
241
+ # rrcurveto dx1 dy1 dx2 dy2 dx3 dy3
242
+ dx1, dy1, dx2, dy2, dx3, dy3 = op[:operands]
243
+ x += dx1 + dx2 + dx3
244
+ y += dy1 + dy2 + dy3
245
+ [dx1, dy1, dx2, dy2, dx3, dy3].each { |val| result << encode_number(val) }
246
+ result << 8 # rrcurveto
247
+ first_move = false
248
+ when :hhcurveto, :hvcurveto, :vhcurveto
249
+ # Flexible curve operators
250
+ op[:operands].each { |val| result << encode_number(val) }
251
+ result << CFF_TO_TYPE1[op[:name]]
252
+ first_move = false
253
+ when :rcurveline, :rlinecurve
254
+ # Flexible curve operators
255
+ op[:operands].each { |val| result << encode_number(val) }
256
+ result << CFF_TO_TYPE1[op[:name]]
257
+ first_move = false
258
+ when :callsubr, :return, :endchar
259
+ # Control operators
260
+ result << CFF_TO_TYPE1[op[:name]]
261
+ first_move = false
262
+ when :hintmask, :cntrmask
263
+ # Hint mask operators - Type 1 doesn't support these
264
+ # Skip them
265
+ first_move = false
266
+ when :shortint
267
+ # Short integer push - handled by operand encoding
268
+ first_move = false
269
+ else
270
+ # Unknown operator - skip
271
+ first_move = false
272
+ end
273
+ end
274
+
275
+ # Add endchar
276
+ result << 14
277
+
278
+ result
279
+ end
280
+
281
+ private
282
+
283
+ # Encode integer for Type 1 CharString
284
+ #
285
+ # Type 1 CharStrings use a variable-length integer encoding:
286
+ # - Numbers from -107 to 107: single byte (byte + 139)
287
+ # - Larger numbers: escaped with 255, then 2-byte value
288
+ #
289
+ # @param num [Integer] Number to encode
290
+ # @return [String] Encoded bytes
291
+ def encode_number(num)
292
+ if num >= -107 && num <= 107
293
+ [num + 139].pack("C")
294
+ else
295
+ # Use escape sequence (255) followed by 2-byte signed integer
296
+ num += 32768 if num < 0
297
+ [255, num % 256, num >> 8].pack("C*")
298
+ end
299
+ end
300
+ end
301
+ end
302
+ end
@@ -0,0 +1,240 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Type1
5
+ # Converter for Type 1 CharStrings to CFF CharStrings
6
+ #
7
+ # [`CharStringConverter`](lib/fontisan/type1/charstring_converter.rb) converts
8
+ # Type 1 CharString bytecode to CFF (Compact Font Format) CharString bytecode.
9
+ #
10
+ # Type 1 and CFF use similar stack-based languages but have different operator
11
+ # codes and some structural differences:
12
+ # - Operator codes differ between formats
13
+ # - Type 1 has seac operator for composites; CFF doesn't support it
14
+ # - Hint operators need to be preserved with code translation
15
+ #
16
+ # @example Convert a Type 1 CharString to CFF
17
+ # converter = Fontisan::Type1::CharStringConverter.new
18
+ # cff_charstring = converter.convert(type1_charstring)
19
+ #
20
+ # @see https://www.adobe.com/devnet/font/pdfs/Type1.pdf
21
+ # @see https://www.microsoft.com/typography/otspec/cff.htm
22
+ class CharStringConverter
23
+ # Type 1 to CFF operator mapping
24
+ #
25
+ # Maps Type 1 operator codes to CFF operator codes.
26
+ # Some operators have the same code, others differ.
27
+ TYPE1_TO_CFF = {
28
+ # Path construction operators
29
+ hmoveto: 22, # Type 1: 22, CFF: 22
30
+ vmoveto: 4, # Type 1: 4, CFF: 4
31
+ rlineto: 5, # Type 1: 5, CFF: 5
32
+ hlineto: 6, # Type 1: 6, CFF: 6
33
+ vlineto: 7, # Type 1: 7, CFF: 7
34
+ rrcurveto: 8, # Type 1: 8, CFF: 8
35
+ hhcurveto: 27, # Type 1: 27, CFF: 27
36
+ hvcurveto: 31, # Type 1: 31, CFF: 31
37
+ vhcurveto: 30, # Type 1: 30, CFF: 30
38
+ rcurveline: 24, # Type 1: 24, CFF: 24
39
+ rlinecurve: 25, # Type 1: 25, CFF: 25
40
+
41
+ # Hint operators
42
+ hstem: 1, # Type 1: 1, CFF: 1
43
+ vstem: 3, # Type 1: 3, CFF: 3
44
+ hstemhm: 18, # Type 1: 18, CFF: 18
45
+ vstemhm: 23, # Type 1: 23, CFF: 23
46
+
47
+ # Hint substitution (not in Type 1, but we preserve for compatibility)
48
+ hintmask: 19, # Type 1: N/A, CFF: 19
49
+ cntrmask: 20, # Type 1: N/A, CFF: 20
50
+
51
+ # End char
52
+ endchar: 14, # Type 1: 14 (or 11 in some specs), CFF: 14
53
+
54
+ # Miscellaneous
55
+ callsubr: 10, # Type 1: 10, CFF: 10
56
+ return: 11, # Type 1: 11, CFF: 11
57
+
58
+ # Deprecated operators (preserve for compatibility)
59
+ hstem3: 12, # Type 1: 12 (escape 0), CFF: 12 (escape 0)
60
+ vstem3: 13, # Type 1: 13 (escape 1), CFF: 13 (escape 1)
61
+ seac: 12, # Type 1: 12 (escape 6), CFF: Not supported
62
+ }.freeze
63
+
64
+ # Escape code for two-byte operators
65
+ ESCAPE_BYTE = 12
66
+
67
+ # seac operator escape code (second byte)
68
+ SEAC_ESCAPE_CODE = 6
69
+
70
+ # Initialize a new CharStringConverter
71
+ #
72
+ # @param charstrings [CharStrings, nil] CharStrings dictionary for seac expansion
73
+ def initialize(charstrings = nil)
74
+ @charstrings = charstrings
75
+ end
76
+
77
+ # Convert Type 1 CharString to CFF CharString
78
+ #
79
+ # @param type1_charstring [String] Type 1 CharString bytecode
80
+ # @return [String] CFF CharString bytecode
81
+ #
82
+ # @example Convert a CharString
83
+ # converter = Fontisan::Type1::CharStringConverter.new
84
+ # cff_bytes = converter.convert(type1_bytes)
85
+ def convert(type1_charstring)
86
+ # Parse Type 1 CharString into commands
87
+ parser = Type1::CharStrings::CharStringParser.new
88
+ commands = parser.parse(type1_charstring)
89
+
90
+ # Check for seac operator and expand if needed
91
+ if parser.seac_components
92
+ return expand_seac(parser.seac_components)
93
+ end
94
+
95
+ # Convert commands to CFF format
96
+ convert_commands(commands)
97
+ end
98
+
99
+ # Convert parsed commands to CFF CharString
100
+ #
101
+ # @param commands [Array<Array>] Parsed Type 1 commands
102
+ # @return [String] CFF CharString bytecode
103
+ def convert_commands(commands)
104
+ result = String.new(encoding: Encoding::ASCII_8BIT)
105
+
106
+ commands.each do |command|
107
+ case command[0]
108
+ when :number
109
+ # Encode number in CFF format
110
+ result << encode_cff_number(command[1])
111
+ when :seac
112
+ # seac should be expanded before this point
113
+ raise Fontisan::Error,
114
+ "seac operator not supported in CFF, must be expanded first"
115
+ else
116
+ # Convert operator
117
+ op_code = TYPE1_TO_CFF[command[0]]
118
+ if op_code.nil?
119
+ # Unknown operator, skip or raise error
120
+ next
121
+ end
122
+
123
+ result << encode_cff_operator(op_code)
124
+ end
125
+ end
126
+
127
+ result
128
+ end
129
+
130
+ # Expand seac composite glyph
131
+ #
132
+ # The seac operator in Type 1 creates composite glyphs (like À = A + `).
133
+ # CFF doesn't support seac, so we need to expand it into the base glyphs
134
+ # with appropriate positioning.
135
+ #
136
+ # @param seac_data [Hash] seac component data
137
+ # @return [String] CFF CharString bytecode with expanded seac
138
+ def expand_seac(seac_data)
139
+ # seac format: adx ady bchar achar seac
140
+ # adx, ady: accent offset
141
+ # bchar: base character code
142
+ # achar: accent character code
143
+ # The accent is positioned at (adx, ady) relative to the base
144
+
145
+ seac_data[:base]
146
+ seac_data[:accent]
147
+ seac_data[:adx]
148
+ seac_data[:ady]
149
+
150
+ # For now, we'll create a simple placeholder that indicates seac expansion
151
+ # In a full implementation, we would:
152
+ # 1. Parse the base glyph's CharString
153
+ # 2. Parse the accent glyph's CharString
154
+ # 3. Merge them with the appropriate offset
155
+ # 4. Convert to CFF format
156
+
157
+ # This is a simplified implementation that creates a composite reference
158
+ # CFF doesn't have native seac, so we need to actually merge the outlines
159
+
160
+ # For now, return endchar as placeholder
161
+ # TODO: Implement full seac expansion by merging glyph outlines
162
+ encode_cff_operator(TYPE1_TO_CFF[:endchar])
163
+ end
164
+
165
+ # Check if CharString contains seac operator
166
+ #
167
+ # @param type1_charstring [String] Type 1 CharString bytecode
168
+ # @return [Boolean] True if CharString contains seac
169
+ def seac?(type1_charstring)
170
+ parser = Type1::CharStrings::CharStringParser.new
171
+ parser.parse(type1_charstring)
172
+ !parser.seac_components.nil?
173
+ end
174
+
175
+ private
176
+
177
+ # Encode number in CFF format
178
+ #
179
+ # CFF uses a variable-length encoding for numbers:
180
+ # - 32-246: 1 byte (value - 139)
181
+ # - 247-250: 2 bytes (first byte indicates format)
182
+ # - 251-254: 3 bytes (first byte indicates format)
183
+ # - 255: 5 bytes (signed 16-bit integer)
184
+ # - 28: 2 bytes (signed 16.16 fixed point, not used for CharStrings)
185
+ #
186
+ # @param value [Integer] Number to encode
187
+ # @return [String] Encoded number bytes
188
+ def encode_cff_number(value)
189
+ result = String.new(encoding: Encoding::ASCII_8BIT)
190
+
191
+ if value >= -107 && value <= 107
192
+ # 1-byte number: value + 139
193
+ result << (value + 139).chr
194
+ elsif value >= 108 && value <= 1131
195
+ # 2-byte positive number
196
+ value -= 108
197
+ result << ((value >> 8) + 247).chr
198
+ result << (value & 0xFF).chr
199
+ elsif value >= -1131 && value <= -108
200
+ # 2-byte negative number
201
+ value = -value - 108
202
+ result << ((value >> 8) + 251).chr
203
+ result << (value & 0xFF).chr
204
+ elsif value >= -32768 && value <= 32767
205
+ # 5-byte number (16-bit integer)
206
+ result << 255.chr
207
+ result << [(value >> 8) & 0xFF, value & 0xFF].pack("CC")
208
+ result << [0, 0].pack("CC") # Pad to 5 bytes
209
+ else
210
+ raise Fontisan::Error,
211
+ "Number out of range for CFF encoding: #{value}"
212
+ end
213
+
214
+ result
215
+ end
216
+
217
+ # Encode operator in CFF format
218
+ #
219
+ # Most operators are single-byte. Some use escape byte (12) followed
220
+ # by a second byte.
221
+ #
222
+ # @param op_code [Integer] Operator code
223
+ # @return [String] Encoded operator bytes
224
+ def encode_cff_operator(op_code)
225
+ result = String.new(encoding: Encoding::ASCII_8BIT)
226
+
227
+ if op_code > 31 && op_code != ESCAPE_BYTE
228
+ # Two-byte operator (escape + code)
229
+ result << ESCAPE_BYTE.chr
230
+ result << (op_code - ESCAPE_BYTE).chr
231
+ else
232
+ # Single-byte operator
233
+ result << op_code.chr
234
+ end
235
+
236
+ result
237
+ end
238
+ end
239
+ end
240
+ end