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,408 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Type1
5
+ # Type 1 CharStrings parser
6
+ #
7
+ # [`CharStrings`](lib/fontisan/type1/charstrings.rb) parses and stores
8
+ # the CharStrings dictionary from a Type 1 font, which contains
9
+ # glyph outline descriptions.
10
+ #
11
+ # CharStrings in Type 1 fonts use a stack-based language with commands
12
+ # for drawing curves, lines, and composite glyphs (via the seac operator).
13
+ #
14
+ # @example Parse CharStrings from decrypted font data
15
+ # charstrings = Fontisan::Type1::CharStrings.parse(decrypted_data, private_dict)
16
+ # outline = charstrings.outline_for("A")
17
+ #
18
+ # @see https://www.adobe.com/devnet/font/pdfs/Type1.pdf
19
+ class CharStrings
20
+ # @return [Hash] Glyph name to CharString data mapping
21
+ attr_reader :charstrings
22
+
23
+ # @return [PrivateDict] Private dictionary for decryption
24
+ attr_reader :private_dict
25
+
26
+ # Parse CharStrings dictionary from decrypted Type 1 font data
27
+ #
28
+ # @param data [String] Decrypted Type 1 font data
29
+ # @param private_dict [PrivateDict] Private dictionary for lenIV
30
+ # @return [CharStrings] Parsed CharStrings dictionary
31
+ # @raise [Fontisan::Error] If CharStrings cannot be parsed
32
+ #
33
+ # @example Parse from decrypted font data
34
+ # charstrings = Fontisan::Type1::CharStrings.parse(decrypted_data, private_dict)
35
+ def self.parse(data, private_dict = nil)
36
+ new(private_dict).parse(data)
37
+ end
38
+
39
+ # Initialize a new CharStrings parser
40
+ #
41
+ # @param private_dict [PrivateDict, nil] Private dictionary for lenIV
42
+ def initialize(private_dict = nil)
43
+ @private_dict = private_dict || PrivateDict.new
44
+ @charstrings = {}
45
+ end
46
+
47
+ # Parse CharStrings dictionary from decrypted Type 1 font data
48
+ #
49
+ # @param data [String] Decrypted Type 1 font data
50
+ # @return [CharStrings] Self for method chaining
51
+ def parse(data)
52
+ extract_charstrings(data)
53
+ decrypt_charstrings
54
+ self
55
+ end
56
+
57
+ # Get list of glyph names
58
+ #
59
+ # @return [Array<String>] Glyph names
60
+ def glyph_names
61
+ @charstrings.keys
62
+ end
63
+
64
+ # Get the number of charstrings
65
+ #
66
+ # @return [Integer] Number of charstrings
67
+ def count
68
+ @charstrings.size
69
+ end
70
+
71
+ # Get encoding map
72
+ #
73
+ # @return [Hash] Character code to glyph name mapping
74
+ def encoding
75
+ @encoding ||= build_standard_encoding
76
+ end
77
+
78
+ # Iterate over all charstrings
79
+ #
80
+ # @yield [glyph_name, charstring_data] Each glyph name and its charstring data
81
+ # @return [Enumerator] If no block given
82
+ def each_charstring(&)
83
+ return enum_for(:each_charstring) unless block_given?
84
+
85
+ @charstrings.each(&)
86
+ end
87
+
88
+ # Check if glyph exists
89
+ #
90
+ # @param glyph_name [String] Glyph name
91
+ # @return [Boolean] True if glyph exists
92
+ def has_glyph?(glyph_name)
93
+ @charstrings.key?(glyph_name)
94
+ end
95
+
96
+ # Get CharString data for glyph
97
+ #
98
+ # @param glyph_name [String] Glyph name
99
+ # @return [String, nil] CharString data or nil if not found
100
+ def [](glyph_name)
101
+ @charstrings[glyph_name]
102
+ end
103
+
104
+ # Alias for #[]
105
+ #
106
+ # @param glyph_name [String] Glyph name
107
+ # @return [String, nil] CharString data or nil if not found
108
+ def charstring(glyph_name)
109
+ @charstrings[glyph_name]
110
+ end
111
+
112
+ # Get outline for glyph by name
113
+ #
114
+ # Parses the CharString and returns outline commands.
115
+ #
116
+ # @param glyph_name [String] Glyph name
117
+ # @return [Array] Outline commands
118
+ def outline_for(glyph_name)
119
+ charstring = @charstrings[glyph_name]
120
+ return nil if charstring.nil?
121
+
122
+ parser = CharStringParser.new(@private_dict)
123
+ parser.parse(charstring)
124
+ end
125
+
126
+ # Check if glyph is composite (uses seac)
127
+ #
128
+ # @param glyph_name [String] Glyph name
129
+ # @return [Boolean] True if glyph uses seac operator
130
+ def composite?(glyph_name)
131
+ charstring = @charstrings[glyph_name]
132
+ return false if charstring.nil?
133
+
134
+ charstring.include?(SEAC_OPCODE)
135
+ end
136
+
137
+ # Get components for composite glyph
138
+ #
139
+ # @param glyph_name [String] Glyph name
140
+ # @return [Hash, nil] Component info {:base, :accent} or nil
141
+ def components_for(glyph_name)
142
+ return nil unless composite?(glyph_name)
143
+
144
+ charstring = @charstrings[glyph_name]
145
+ parser = CharStringParser.new(@private_dict)
146
+ parser.parse(charstring)
147
+
148
+ parser.seac_components
149
+ end
150
+
151
+ private
152
+
153
+ # Extract CharStrings dictionary from font data
154
+ #
155
+ # @param data [String] Decrypted Type 1 font data
156
+ def extract_charstrings(data)
157
+ # Find CharStrings dictionary
158
+ # Format: /CharStrings <dict_size> dict def begin ... end
159
+ #
160
+ # The CharStrings dict contains entries like:
161
+ # /.notdef <index> CharString_data
162
+ # /A <index> CharString_data
163
+ # etc.
164
+
165
+ # Look for /CharStrings dict def begin ... end pattern
166
+ # Use bounded patterns to prevent ReDoS - the dict size is a number
167
+ # Limit capture to 100KB which is sufficient for CharStrings
168
+ # Use [\s\S] to match any character including newlines
169
+ charstrings_match = data.match(%r{/CharStrings\s+\d+(?:\s+dup)?\s+dict\s+(?:dup\s+)?begin([\s\S]{0,100000}?)end}m)
170
+ return if charstrings_match.nil?
171
+
172
+ charstrings_text = charstrings_match[1]
173
+ parse_charstrings_dict(charstrings_text)
174
+ end
175
+
176
+ # Parse CharStrings dictionary text
177
+ #
178
+ # @param text [String] CharStrings dictionary text
179
+ def parse_charstrings_dict(text)
180
+ # Type 1 CharStrings format:
181
+ # /glyphname <index> RD <binary_data>
182
+ # /glyphname <index> -| <binary_data> |-
183
+ # where RD and -| mark binary data
184
+
185
+ # Use a non-greedy match to capture data between the marker and end marker
186
+ # For -| ... |- format:
187
+ text.scan(/\/([^\s]+)\s+(\d+)\s+-\|(.*?)\|-/m) do |match|
188
+ glyph_name = match[0]
189
+ _index = match[1].to_i
190
+ encrypted_data = match[2]
191
+
192
+ @charstrings[glyph_name] = encrypted_data
193
+ end
194
+
195
+ # For RD format (no end marker, data ends at next glyph or end):
196
+ # This is harder to parse, so we'll skip for now and focus on -| format
197
+ # which is what PFB uses
198
+ end
199
+
200
+ # Decrypt all CharStrings
201
+ def decrypt_charstrings
202
+ len_iv = @private_dict.len_iv || 4
203
+
204
+ @charstrings.transform_values! do |encrypted|
205
+ # Check if data is binary (from PFB) or hex-encoded (from PFA)
206
+ binary_data = if encrypted.ascii_only?
207
+ # Hex-encoded string (PFA format)
208
+ [encrypted.gsub(/\s/, "")].pack("H*")
209
+ else
210
+ # Already binary data (PFB format)
211
+ encrypted
212
+ end
213
+
214
+ # Decrypt CharString
215
+ Decryptor.charstring_decrypt(binary_data, len_iv: len_iv)
216
+ end
217
+ end
218
+
219
+ # seac opcode
220
+ SEAC_OPCODE = "\x0C\x06".b
221
+
222
+ # Build standard encoding map
223
+ #
224
+ # @return [Hash] Standard encoding map (character code to glyph name)
225
+ def build_standard_encoding
226
+ # A subset of Adobe StandardEncoding
227
+ {
228
+ 32 => "space", 33 => "exclam", 34 => "quotedbl", 35 => "numbersign",
229
+ 36 => "dollar", 37 => "percent", 38 => "ampersand", 39 => "quoteright",
230
+ 40 => "parenleft", 41 => "parenright", 42 => "asterisk", 43 => "plus",
231
+ 44 => "comma", 45 => "hyphen", 46 => "period", 47 => "slash",
232
+ 48 => "zero", 49 => "one", 50 => "two", 51 => "three",
233
+ 52 => "four", 53 => "five", 54 => "six", 55 => "seven",
234
+ 56 => "eight", 57 => "nine", 58 => "colon", 59 => "semicolon",
235
+ 60 => "less", 61 => "equal", 62 => "greater", 63 => "question",
236
+ 64 => "at",
237
+ 65 => "A", 66 => "B", 67 => "C", 68 => "D", 69 => "E",
238
+ 70 => "F", 71 => "G", 72 => "H", 73 => "I", 74 => "J",
239
+ 75 => "K", 76 => "L", 77 => "M", 78 => "N", 79 => "O",
240
+ 80 => "P", 81 => "Q", 82 => "R", 83 => "S", 84 => "T",
241
+ 85 => "U", 86 => "V", 87 => "W", 88 => "X", 89 => "Y",
242
+ 90 => "Z",
243
+ 91 => "bracketleft", 92 => "backslash", 93 => "bracketright",
244
+ 94 => "asciicircum", 95 => "underscore", 96 => "quoteleft",
245
+ 97 => "a", 98 => "b", 99 => "c", 100 => "d", 101 => "e",
246
+ 102 => "f", 103 => "g", 104 => "h", 105 => "i", 106 => "j",
247
+ 107 => "k", 108 => "l", 109 => "m", 110 => "n", 111 => "o",
248
+ 112 => "p", 113 => "q", 114 => "r", 115 => "s", 116 => "t",
249
+ 117 => "u", 118 => "v", 119 => "w", 120 => "x", 121 => "y",
250
+ 122 => "z",
251
+ 123 => "braceleft", 124 => "bar", 125 => "braceright",
252
+ 126 => "asciitilde"
253
+ }
254
+ end
255
+
256
+ # CharString parser
257
+ #
258
+ # Parses Type 1 CharString bytecode into commands.
259
+ class CharStringParser
260
+ # @return [Array] Parsed commands
261
+ attr_reader :commands
262
+
263
+ # @return [Hash, nil] seac components if seac found
264
+ attr_reader :seac_components
265
+
266
+ # @return [PrivateDict] Private dictionary
267
+ attr_reader :private_dict
268
+
269
+ # Initialize parser
270
+ #
271
+ # @param private_dict [PrivateDict] Private dictionary
272
+ def initialize(private_dict = nil)
273
+ @private_dict = private_dict || PrivateDict.new
274
+ @commands = []
275
+ @seac_components = nil
276
+ end
277
+
278
+ # Parse CharString bytecode
279
+ #
280
+ # @param charstring [String] Binary CharString data
281
+ # @return [Array] Parsed commands
282
+ def parse(charstring)
283
+ return [] if charstring.nil? || charstring.empty?
284
+
285
+ @commands = []
286
+ @seac_components = nil
287
+
288
+ i = 0
289
+ while i < charstring.length
290
+ byte = charstring.getbyte(i)
291
+
292
+ if byte <= 31
293
+ # Operator
294
+ parse_operator(charstring, byte, i)
295
+ break if @seac_components # Stop at seac for now
296
+
297
+ i += 1
298
+ elsif byte == 255
299
+ # Escaped number (2 bytes follow)
300
+ num = charstring.getbyte(i + 1) |
301
+ (charstring.getbyte(i + 2) << 8)
302
+ num = num - 32768 if num >= 32768
303
+ @commands << [:number, num]
304
+ i += 3
305
+ elsif byte >= 32 && byte <= 246
306
+ # Small number (-107 to 107)
307
+ num = byte - 139
308
+ @commands << [:number, num]
309
+ i += 1
310
+ else
311
+ # Unknown
312
+ i += 1
313
+ end
314
+ end
315
+
316
+ @commands
317
+ end
318
+
319
+ private
320
+
321
+ # Parse operator
322
+ #
323
+ # @param charstring [String] Full CharString data
324
+ # @param byte [Integer] Operator byte
325
+ # @param offset [Integer] Current offset
326
+ def parse_operator(charstring, byte, offset)
327
+ case byte
328
+ when 12
329
+ # Two-byte operator
330
+ next_byte = charstring.getbyte(offset + 1)
331
+ parse_two_byte_operator(next_byte)
332
+ when 1
333
+ @commands << [:hstem]
334
+ when 3
335
+ @commands << [:vstem]
336
+ when 4
337
+ @commands << [:vmoveto]
338
+ when 5
339
+ @commands << [:rlineto]
340
+ when 6
341
+ @commands << [:hlineto]
342
+ when 7
343
+ @commands << [:vlineto]
344
+ when 8
345
+ @commands << [:rrcurveto]
346
+ when 10
347
+ @commands << [:callsubr]
348
+ when 11
349
+ @commands << [:return]
350
+ when 14
351
+ @commands << [:endchar]
352
+ when 21
353
+ @commands << [:rmoveto]
354
+ when 22
355
+ @commands << [:hmoveto]
356
+ when 30
357
+ @commands << [:vhcurveto]
358
+ when 31
359
+ @commands << [:hvcurveto]
360
+ end
361
+ end
362
+
363
+ # Parse two-byte operator
364
+ #
365
+ # @param byte [Integer] Second operator byte
366
+ def parse_two_byte_operator(byte)
367
+ case byte
368
+ when 6
369
+ @commands << [:seac]
370
+ parse_seac
371
+ when 7
372
+ @commands << [:sbw]
373
+ when 34
374
+ @commands << [:hsbw]
375
+ when 36
376
+ @commands << [:div]
377
+ when 5
378
+ @commands << [:callgsubr]
379
+ end
380
+ end
381
+
382
+ # Parse seac composite glyph
383
+ #
384
+ # seac format: asb adx ady bchar achar seac
385
+ def parse_seac
386
+ # seac takes 5 arguments: asb, adx, ady, bchar, achar
387
+ # We need to extract the last 5 numbers from the command stack
388
+ nums = @commands.select { |c| c.first == :number }.map(&:last)
389
+
390
+ return if nums.length < 5
391
+
392
+ # Last two are bchar and achar (character codes)
393
+ bchar = nums[-2]
394
+ achar = nums[-1]
395
+ adx = nums[-3]
396
+ ady = nums[-4]
397
+
398
+ @seac_components = {
399
+ base: bchar,
400
+ accent: achar,
401
+ adx: adx,
402
+ ady: ady,
403
+ }
404
+ end
405
+ end
406
+ end
407
+ end
408
+ end
@@ -0,0 +1,243 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "encodings"
4
+
5
+ module Fontisan
6
+ module Type1
7
+ # Conversion options for Type 1 font generation
8
+ #
9
+ # [`ConversionOptions`](lib/fontisan/type1/conversion_options.rb) provides a unified
10
+ # configuration interface for converting outline fonts (TTF, OTF, TTC, etc.) to
11
+ # Type 1 formats (PFA, PFB, AFM, PFM, INF).
12
+ #
13
+ # @example Create Windows Type 1 options
14
+ # options = Fontisan::Type1::ConversionOptions.windows_type1
15
+ # options.upm_scale # => 1000
16
+ # options.encoding # => Fontisan::Type1::Encodings::AdobeStandard
17
+ # options.format # => :pfb
18
+ #
19
+ # @example Create custom options
20
+ # options = Fontisan::Type1::ConversionOptions.new(
21
+ # upm_scale: 1000,
22
+ # encoding: Fontisan::Type1::Encodings::Unicode,
23
+ # format: :pfa
24
+ # )
25
+ #
26
+ # @see http://www.adobe.com/devnet/font/pdfs/5178.Type1.pdf
27
+ class ConversionOptions
28
+ # @return [Integer, :native] Target UPM (1000 for Type 1, :native to keep source UPM)
29
+ attr_accessor :upm_scale
30
+
31
+ # @return [Class] Encoding class (AdobeStandard, ISOLatin1, Unicode)
32
+ attr_accessor :encoding
33
+
34
+ # @return [Boolean] Decompose composite glyphs into base glyphs
35
+ attr_accessor :decompose_composites
36
+
37
+ # @return [Boolean] Convert quadratic curves to cubic
38
+ attr_accessor :convert_curves
39
+
40
+ # @return [Boolean] Apply autohinting to generated Type 1
41
+ attr_accessor :autohint
42
+
43
+ # @return [Boolean] Preserve native hinting from source font
44
+ attr_accessor :preserve_hinting
45
+
46
+ # @return [Symbol] Output format (:pfb or :pfa)
47
+ attr_accessor :format
48
+
49
+ # Default values based on TypeTool 3 and Adobe Type 1 specifications
50
+ DEFAULTS = {
51
+ upm_scale: 1000,
52
+ encoding: Encodings::AdobeStandard,
53
+ decompose_composites: false,
54
+ convert_curves: true,
55
+ autohint: false,
56
+ preserve_hinting: false,
57
+ format: :pfb,
58
+ }.freeze
59
+
60
+ # Initialize conversion options
61
+ #
62
+ # @param options [Hash] Option values
63
+ # @option options [Integer, :native] :upm_scale Target UPM (default: 1000)
64
+ # @option options [Class] :encoding Encoding class (default: AdobeStandard)
65
+ # @option options [Boolean] :decompose_composites Decompose composites (default: false)
66
+ # @option options [Boolean] :convert_curves Convert curves (default: true)
67
+ # @option options [Boolean] :autohint Apply autohinting (default: false)
68
+ # @option options [Boolean] :preserve_hinting Preserve hinting (default: false)
69
+ # @option options [Symbol] :format Output format :pfb or :pfa (default: :pfb)
70
+ def initialize(options = {})
71
+ @upm_scale = options[:upm_scale] || DEFAULTS[:upm_scale]
72
+ @encoding = options[:encoding] || DEFAULTS[:encoding]
73
+ @decompose_composites = options[:decompose_composites] || DEFAULTS[:decompose_composites]
74
+ @convert_curves = options.fetch(:convert_curves,
75
+ DEFAULTS[:convert_curves])
76
+ @autohint = options[:autohint] || DEFAULTS[:autohint]
77
+ @preserve_hinting = options[:preserve_hinting] || DEFAULTS[:preserve_hinting]
78
+ @format = options[:format] || DEFAULTS[:format]
79
+
80
+ validate!
81
+ end
82
+
83
+ # Check if UPM scaling is needed
84
+ #
85
+ # @return [Boolean] True if upm_scale is not :native
86
+ def needs_scaling?
87
+ @upm_scale != :native
88
+ end
89
+
90
+ # Check if curve conversion is needed
91
+ #
92
+ # @return [Boolean] True if convert_curves is true
93
+ def needs_curve_conversion?
94
+ @convert_curves
95
+ end
96
+
97
+ # Check if autohinting is requested
98
+ #
99
+ # @return [Boolean] True if autohint is true
100
+ def needs_autohinting?
101
+ @autohint
102
+ end
103
+
104
+ # Convert to hash
105
+ #
106
+ # @return [Hash] Options as hash
107
+ def to_hash
108
+ {
109
+ upm_scale: @upm_scale,
110
+ encoding: @encoding,
111
+ decompose_composites: @decompose_composites,
112
+ convert_curves: @convert_curves,
113
+ autohint: @autohint,
114
+ preserve_hinting: @preserve_hinting,
115
+ format: @format,
116
+ }
117
+ end
118
+
119
+ # Create options for Windows Type 1 output
120
+ #
121
+ # @return [ConversionOptions] Options configured for Windows Type 1
122
+ def self.windows_type1
123
+ new(
124
+ upm_scale: 1000,
125
+ encoding: Encodings::AdobeStandard,
126
+ format: :pfb,
127
+ )
128
+ end
129
+
130
+ # Create options for Unix Type 1 output
131
+ #
132
+ # @return [ConversionOptions] Options configured for Unix Type 1
133
+ def self.unix_type1
134
+ new(
135
+ upm_scale: 1000,
136
+ encoding: Encodings::AdobeStandard,
137
+ format: :pfa,
138
+ )
139
+ end
140
+
141
+ # Create options with native UPM (no scaling)
142
+ #
143
+ # @return [ConversionOptions] Options with native UPM
144
+ def self.native_upm
145
+ new(
146
+ upm_scale: :native,
147
+ encoding: Encodings::Unicode,
148
+ format: :pfb,
149
+ )
150
+ end
151
+
152
+ # Create options for ISO-8859-1 encoding
153
+ #
154
+ # @return [ConversionOptions] Options with ISO Latin-1 encoding
155
+ def self.iso_latin1
156
+ new(
157
+ upm_scale: 1000,
158
+ encoding: Encodings::ISOLatin1,
159
+ format: :pfb,
160
+ )
161
+ end
162
+
163
+ # Create options with Unicode encoding
164
+ #
165
+ # @return [ConversionOptions] Options with Unicode encoding
166
+ def self.unicode_encoding
167
+ new(
168
+ upm_scale: 1000,
169
+ encoding: Encodings::Unicode,
170
+ format: :pfb,
171
+ )
172
+ end
173
+
174
+ # Create options for high-quality output (with curve conversion)
175
+ #
176
+ # @return [ConversionOptions] Options optimized for quality
177
+ def self.high_quality
178
+ new(
179
+ upm_scale: 1000,
180
+ encoding: Encodings::AdobeStandard,
181
+ convert_curves: true,
182
+ decompose_composites: true,
183
+ format: :pfb,
184
+ )
185
+ end
186
+
187
+ # Create options for minimal file size
188
+ #
189
+ # @return [ConversionOptions] Options optimized for size
190
+ def self.minimal_size
191
+ new(
192
+ upm_scale: 1000,
193
+ encoding: Encodings::AdobeStandard,
194
+ convert_curves: false,
195
+ decompose_composites: false,
196
+ format: :pfa,
197
+ )
198
+ end
199
+
200
+ private
201
+
202
+ # Validate options
203
+ #
204
+ # @raise [ArgumentError] If options are invalid
205
+ def validate!
206
+ validate_upm_scale!
207
+ validate_encoding!
208
+ validate_format!
209
+ end
210
+
211
+ # Validate UPM scale value
212
+ #
213
+ # @raise [ArgumentError] If upm_scale is invalid
214
+ def validate_upm_scale!
215
+ return if @upm_scale == :native
216
+ return if @upm_scale.is_a?(Integer) && @upm_scale.positive?
217
+
218
+ raise ArgumentError,
219
+ "upm_scale must be a positive integer or :native, got: #{@upm_scale.inspect}"
220
+ end
221
+
222
+ # Validate encoding class
223
+ #
224
+ # @raise [ArgumentError] If encoding is not a valid encoding class
225
+ def validate_encoding!
226
+ return if @encoding.is_a?(Class) && @encoding < Encodings::Encoding
227
+
228
+ raise ArgumentError,
229
+ "encoding must be an Encoding class (AdobeStandard, ISOLatin1, Unicode), got: #{@encoding.inspect}"
230
+ end
231
+
232
+ # Validate format value
233
+ #
234
+ # @raise [ArgumentError] If format is not :pfb or :pfa
235
+ def validate_format!
236
+ return if %i[pfb pfa].include?(@format)
237
+
238
+ raise ArgumentError,
239
+ "format must be :pfb or :pfa, got: #{@format.inspect}"
240
+ end
241
+ end
242
+ end
243
+ end