fontisan 0.2.12 → 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.
@@ -60,7 +60,7 @@ module Fontisan
60
60
 
61
61
  # Check for Type 1 format first (PFB/PFA have different signatures)
62
62
  if type1_font?(path)
63
- return Type1Font.from_file(path)
63
+ return Type1Font.from_file(path, mode: resolved_mode)
64
64
  end
65
65
 
66
66
  File.open(path, "rb") do |io|
@@ -274,7 +274,10 @@ module Fontisan
274
274
  end
275
275
 
276
276
  # Merge all extracted hints (prep_hints and fpgm_hints override stem widths if present)
277
- hints.merge!(prep_hints).merge!(fpgm_hints).merge!(blue_zones)
277
+ # Note: fpgm_hints contains metadata (fpgm_size, has_functions, complexity)
278
+ # which we must filter out before merging into PostScript dict hints
279
+ fpgm_dict_hints = fpgm_hints.reject { |k, _| %i[fpgm_size has_functions complexity].include?(k) }
280
+ hints.merge!(prep_hints).merge!(fpgm_dict_hints).merge!(blue_zones)
278
281
 
279
282
  # Provide default blue_values if none were detected
280
283
  # These are standard values that work for most Latin fonts
@@ -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
@@ -137,6 +137,34 @@ module Fontisan
137
137
  @raw_data[key]
138
138
  end
139
139
 
140
+ # Convert FontDictionary to Type 1 text format
141
+ #
142
+ # Generates the PostScript code for the Font Dictionary section
143
+ # of a Type 1 font.
144
+ #
145
+ # @return [String] Type 1 Font Dictionary text
146
+ #
147
+ # @example Generate Type 1 format
148
+ # dict = FontDictionary.new
149
+ # puts dict.to_type1_format
150
+ def to_type1_format
151
+ result = []
152
+ result << "% Type 1 Font Dictionary"
153
+ result << "12 dict begin"
154
+ result << ""
155
+ result << "/FontType 1 def"
156
+ result << "/PaintType #{@paint_type} def"
157
+ result << font_matrix_to_type1
158
+ result << font_bbox_to_type1
159
+ result << ""
160
+ result << font_info_to_type1
161
+ result << ""
162
+ result << "currentdict end"
163
+ result << "/FontName #{@font_name} def"
164
+
165
+ result.join("\n")
166
+ end
167
+
140
168
  private
141
169
 
142
170
  # Extract font dictionary from data
@@ -270,6 +298,40 @@ module Fontisan
270
298
  @font_type = @raw_data[:font_type] || 1
271
299
  end
272
300
 
301
+ # Format FontMatrix for Type 1 output
302
+ #
303
+ # @return [String] Formatted FontMatrix definition
304
+ def font_matrix_to_type1
305
+ "/FontMatrix [#{@font_matrix.join(' ')}] def"
306
+ end
307
+
308
+ # Format FontBBox for Type 1 output
309
+ #
310
+ # @return [String] Formatted FontBBox definition
311
+ def font_bbox_to_type1
312
+ "/FontBBox {#{@font_b_box.join(' ')}} def"
313
+ end
314
+
315
+ # Format FontInfo for Type 1 output
316
+ #
317
+ # @return [String] Formatted FontInfo definitions
318
+ def font_info_to_type1
319
+ info = @font_info
320
+ result = []
321
+ result << "/FullName (#{info.full_name || 'Untitled'}) readonly def" if info.full_name
322
+ result << "/FamilyName (#{info.family_name || 'Untitled'}) readonly def" if info.family_name
323
+ result << "/version (#{info.version || '001.000'}) readonly def" if info.version
324
+ result << "/Copyright (#{info.copyright || ''}) readonly def" if info.copyright
325
+ result << "/Notice (#{info.notice || ''}) readonly def" if info.notice
326
+ result << "/Weight (#{info.weight || 'Medium'}) readonly def" if info.weight
327
+ result << "/isFixedPitch #{info.is_fixed_pitch || false} def" if info.is_fixed_pitch
328
+ result << "/UnderlinePosition #{info.underline_position || -100} def" if info.underline_position
329
+ result << "/UnderlineThickness #{info.underline_thickness || 50} def" if info.underline_thickness
330
+ result << "/ItalicAngle #{info.italic_angle || 0} def" if info.italic_angle
331
+
332
+ result.join("\n")
333
+ end
334
+
273
335
  # FontInfo sub-dictionary
274
336
  #
275
337
  # Contains font metadata such as FullName, FamilyName, version, etc.
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "pfb_generator"
4
+ require_relative "decryptor"
4
5
 
5
6
  module Fontisan
6
7
  module Type1
@@ -220,17 +221,20 @@ module Fontisan
220
221
  generate_simple_charstrings
221
222
  end
222
223
 
223
- # Combine all charstrings
224
- binary_data = charstrings.values.join
224
+ # Build CharStrings dictionary text
225
+ charstrings_dict = build_charstrings_dict_text(charstrings)
225
226
 
226
- # Convert to hex representation
227
+ # Encrypt with eexec
228
+ encrypted_data = Decryptor.eexec_encrypt(charstrings_dict)
229
+
230
+ # Convert encrypted data to hex representation
227
231
  hex_lines = []
228
232
 
229
233
  # Start hex section marker
230
234
  hex_lines << "00" # Start binary data marker
231
235
 
232
- # Encode binary data as hex with line breaks
233
- hex_string = binary_data.bytes.map { |b| format("%02x", b) }.join
236
+ # Encode encrypted data as hex with line breaks
237
+ hex_string = encrypted_data.bytes.map { |b| format("%02x", b) }.join
234
238
 
235
239
  # Split into lines of HEX_LINE_LENGTH characters
236
240
  hex_string.scan(/.{#{HEX_LINE_LENGTH}}/o) do |line|
@@ -243,6 +247,28 @@ module Fontisan
243
247
  hex_lines
244
248
  end
245
249
 
250
+ # Build CharStrings dictionary text for eexec encryption
251
+ #
252
+ # @param charstrings [Hash] Glyph ID to CharString mapping
253
+ # @return [String] CharStrings dictionary text
254
+ def build_charstrings_dict_text(charstrings)
255
+ lines = []
256
+ lines << "dup /FontName get exch definefont pop"
257
+ lines << "begin"
258
+ lines << "/CharStrings #{charstrings.size} dict dup begin"
259
+
260
+ charstrings.each do |gid, charstring|
261
+ # Convert charstring bytes to hex representation for PostScript
262
+ hex_data = charstring.unpack1("H*")
263
+ lines << "#{gid} #{charstring.bytesize} #{hex_data} RD"
264
+ end
265
+
266
+ lines << "end"
267
+ lines << "end"
268
+ lines << "put"
269
+ lines.join("\n")
270
+ end
271
+
246
272
  # Generate simple CharStrings (without curve conversion)
247
273
  #
248
274
  # @return [Hash<Integer, String>] Glyph ID to CharString mapping
@@ -36,12 +36,18 @@ module Fontisan
36
36
  # @return [String] Encrypted portion as hex string
37
37
  attr_reader :encrypted_hex
38
38
 
39
+ # @return [String] Encrypted portion as binary data
40
+ attr_reader :encrypted_binary
41
+
39
42
  # @return [String] Trailing text after zeros (if any)
40
43
  attr_reader :trailing_text
41
44
 
42
45
  # Parse PFA format data
43
46
  #
44
- # @param data [String] ASCII PFA data
47
+ # Handles both standard PFA (hex-encoded encrypted data with zero marker)
48
+ # and .t1 format (binary encrypted data without zero marker).
49
+ #
50
+ # @param data [String] ASCII PFA data or .t1 format data
45
51
  # @return [PFAParser] Self for method chaining
46
52
  # @raise [ArgumentError] If data is nil or empty
47
53
  # @raise [Fontisan::Error] If PFA format is invalid
@@ -58,6 +64,7 @@ module Fontisan
58
64
  # No eexec marker - entire file is clear text
59
65
  @clear_text = data
60
66
  @encrypted_hex = ""
67
+ @encrypted_binary = ""
61
68
  @trailing_text = ""
62
69
  return self
63
70
  end
@@ -72,25 +79,31 @@ module Fontisan
72
79
  encrypted_start = skip_whitespace(after_eexec, 0)
73
80
  encrypted_data = after_eexec[encrypted_start..]
74
81
 
75
- # Find zero marker
82
+ # Find zero marker (optional for .t1 format)
76
83
  zero_index = encrypted_data.index(ZERO_MARKER)
77
- if zero_index.nil?
78
- raise Fontisan::Error,
79
- "Invalid PFA: cannot find zero marker after eexec"
80
- end
81
84
 
82
- # Extract encrypted hex data (before zeros)
83
- @encrypted_hex = encrypted_data[0...zero_index].strip
84
-
85
- # Extract trailing text (after zeros)
86
- trailing_start = zero_index + ZERO_MARKER.length
87
- trailing_start = skip_whitespace(encrypted_data, trailing_start)
88
-
89
- @trailing_text = if trailing_start < encrypted_data.length
90
- encrypted_data[trailing_start..]
91
- else
92
- ""
93
- end
85
+ if zero_index
86
+ # Standard PFA format with zero marker
87
+ # Extract encrypted hex data (before zeros)
88
+ @encrypted_hex = encrypted_data[0...zero_index].strip
89
+ @encrypted_binary = [@encrypted_hex.gsub(/\s/, "")].pack("H*")
90
+
91
+ # Extract trailing text (after zeros)
92
+ trailing_start = zero_index + ZERO_MARKER.length
93
+ trailing_start = skip_whitespace(encrypted_data, trailing_start)
94
+
95
+ @trailing_text = if trailing_start < encrypted_data.length
96
+ encrypted_data[trailing_start..]
97
+ else
98
+ ""
99
+ end
100
+ else
101
+ # .t1 format - binary encrypted data without zero marker
102
+ # Treat everything after eexec as binary encrypted data
103
+ @encrypted_binary = encrypted_data.lstrip
104
+ @encrypted_hex = @encrypted_binary.unpack1("H*")
105
+ @trailing_text = ""
106
+ end
94
107
 
95
108
  self
96
109
  end
@@ -120,18 +133,6 @@ module Fontisan
120
133
  data.include?("%!PS-Adobe-3.0 Resource-Font")
121
134
  end
122
135
 
123
- # Get encrypted hex as binary data
124
- #
125
- # Decodes the hexadecimal encrypted portion to binary.
126
- #
127
- # @return [String] Binary encrypted data
128
- def encrypted_binary
129
- return "" if @encrypted_hex.nil? || @encrypted_hex.empty?
130
-
131
- # Convert hex string to binary
132
- [@encrypted_hex.gsub(/\s/, "")].pack("H*")
133
- end
134
-
135
136
  private
136
137
 
137
138
  # Normalize line endings to LF
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "upm_scaler"
4
4
  require_relative "ttf_to_type1_converter"
5
+ require_relative "decryptor"
5
6
  require_relative "../tables/name"
6
7
 
7
8
  module Fontisan
@@ -197,9 +198,31 @@ module Fontisan
197
198
  "/CharStrings #{@charstrings&.size || 0} dict dup begin\nend"
198
199
  end
199
200
 
201
+ # Build CharStrings dictionary text for eexec encryption
202
+ #
203
+ # @param charstrings [Hash] Glyph ID to CharString mapping
204
+ # @return [String] CharStrings dictionary text
205
+ def build_charstrings_dict_text(charstrings)
206
+ lines = []
207
+ lines << "dup /FontName get exch definefont pop"
208
+ lines << "begin"
209
+ lines << "/CharStrings #{charstrings.size} dict dup begin"
210
+
211
+ charstrings.each do |gid, charstring|
212
+ # Convert charstring bytes to hex representation for PostScript
213
+ hex_data = charstring.unpack1("H*")
214
+ lines << "#{gid} #{charstring.bytesize} #{hex_data} RD"
215
+ end
216
+
217
+ lines << "end"
218
+ lines << "end"
219
+ lines << "put"
220
+ lines.join("\n")
221
+ end
222
+
200
223
  # Build binary segment (CharStrings)
201
224
  #
202
- # @return [String] Binary CharString data
225
+ # @return [String] Binary CharString data (encrypted with eexec)
203
226
  def build_binary_segment
204
227
  # Convert glyphs to Type 1 CharStrings
205
228
  charstrings = if @convert_curves
@@ -209,11 +232,11 @@ module Fontisan
209
232
  generate_simple_charstrings
210
233
  end
211
234
 
212
- # Encode charstrings to eexec format (encrypted)
213
- # For now, we'll use plain format (not encrypted)
214
- # TODO: Implement eexec encryption
235
+ # Build CharStrings dictionary text
236
+ charstrings_dict = build_charstrings_dict_text(charstrings)
215
237
 
216
- charstrings.values.join
238
+ # Encrypt with eexec
239
+ Decryptor.eexec_encrypt(charstrings_dict)
217
240
  end
218
241
 
219
242
  # Generate simple CharStrings (without curve conversion)
@@ -163,6 +163,63 @@ module Fontisan
163
163
  !@stem_snap_h.empty? || !@stem_snap_v.empty?
164
164
  end
165
165
 
166
+ # Convert PrivateDict to Type 1 text format
167
+ #
168
+ # Generates the PostScript code for the Private dictionary section
169
+ # of a Type 1 font.
170
+ #
171
+ # @return [String] Type 1 Private dictionary text
172
+ #
173
+ # @example Generate Type 1 format
174
+ # priv = PrivateDict.new
175
+ # priv.blue_values = [-10, 0, 470, 480]
176
+ # puts priv.to_type1_format
177
+ def to_type1_format
178
+ result = []
179
+ result << array_to_type1(:BlueValues, @blue_values) unless @blue_values.empty?
180
+ result << array_to_type1(:OtherBlues, @other_blues) unless @other_blues.empty?
181
+ result << array_to_type1(:FamilyBlues, @family_blues) unless @family_blues.empty?
182
+ result << array_to_type1(:FamilyOtherBlues, @family_other_blues) unless @family_other_blues.empty?
183
+ result << scalar_to_type1(:BlueScale, @blue_scale)
184
+ result << scalar_to_type1(:BlueShift, @blue_shift)
185
+ result << scalar_to_type1(:BlueFuzz, @blue_fuzz)
186
+ result << array_to_type1(:StdHW, @std_hw) unless @std_hw.empty?
187
+ result << array_to_type1(:StdVW, @std_vw) unless @std_vw.empty?
188
+ result << array_to_type1(:StemSnapH, @stem_snap_h) unless @stem_snap_h.empty?
189
+ result << array_to_type1(:StemSnapV, @stem_snap_v) unless @stem_snap_v.empty?
190
+ result << boolean_to_type1(:ForceBold, @force_bold) unless @force_bold == false
191
+ result << scalar_to_type1(:lenIV, @len_iv)
192
+
193
+ result.join("\n")
194
+ end
195
+
196
+ # Format an array value for Type 1 output
197
+ #
198
+ # @param name [Symbol] Array name
199
+ # @param value [Array] Array value
200
+ # @return [String] Formatted Type 1 array definition
201
+ def array_to_type1(name, value)
202
+ "/#{name} [#{value.join(' ')}] def"
203
+ end
204
+
205
+ # Format a scalar value for Type 1 output
206
+ #
207
+ # @param name [Symbol] Value name
208
+ # @param value [Numeric] Numeric value
209
+ # @return [String] Formatted Type 1 scalar definition
210
+ def scalar_to_type1(name, value)
211
+ "/#{name} #{value} def"
212
+ end
213
+
214
+ # Format a boolean value for Type 1 output
215
+ #
216
+ # @param name [Symbol] Value name
217
+ # @param value [Boolean] Boolean value
218
+ # @return [String] Formatted Type 1 boolean definition
219
+ def boolean_to_type1(name, value)
220
+ "/#{name} #{value} def"
221
+ end
222
+
166
223
  private
167
224
 
168
225
  # Extract private dictionary from data