fontisan 0.2.0 → 0.2.1

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 (74) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +270 -131
  3. data/README.adoc +158 -4
  4. data/Rakefile +44 -47
  5. data/lib/fontisan/cli.rb +84 -33
  6. data/lib/fontisan/collection/builder.rb +81 -0
  7. data/lib/fontisan/collection/table_deduplicator.rb +76 -0
  8. data/lib/fontisan/commands/base_command.rb +16 -0
  9. data/lib/fontisan/commands/convert_command.rb +97 -170
  10. data/lib/fontisan/commands/instance_command.rb +71 -80
  11. data/lib/fontisan/commands/validate_command.rb +25 -0
  12. data/lib/fontisan/config/validation_rules.yml +1 -1
  13. data/lib/fontisan/constants.rb +10 -0
  14. data/lib/fontisan/converters/format_converter.rb +150 -1
  15. data/lib/fontisan/converters/outline_converter.rb +80 -18
  16. data/lib/fontisan/converters/woff_writer.rb +1 -1
  17. data/lib/fontisan/font_loader.rb +3 -5
  18. data/lib/fontisan/font_writer.rb +7 -6
  19. data/lib/fontisan/hints/hint_converter.rb +133 -0
  20. data/lib/fontisan/hints/postscript_hint_applier.rb +221 -140
  21. data/lib/fontisan/hints/postscript_hint_extractor.rb +100 -0
  22. data/lib/fontisan/hints/truetype_hint_applier.rb +90 -44
  23. data/lib/fontisan/hints/truetype_hint_extractor.rb +127 -0
  24. data/lib/fontisan/loading_modes.rb +2 -0
  25. data/lib/fontisan/models/font_export.rb +2 -2
  26. data/lib/fontisan/models/hint.rb +173 -1
  27. data/lib/fontisan/models/validation_report.rb +1 -1
  28. data/lib/fontisan/open_type_font.rb +25 -9
  29. data/lib/fontisan/open_type_font_extensions.rb +54 -0
  30. data/lib/fontisan/pipeline/format_detector.rb +249 -0
  31. data/lib/fontisan/pipeline/output_writer.rb +154 -0
  32. data/lib/fontisan/pipeline/strategies/base_strategy.rb +75 -0
  33. data/lib/fontisan/pipeline/strategies/instance_strategy.rb +93 -0
  34. data/lib/fontisan/pipeline/strategies/named_strategy.rb +118 -0
  35. data/lib/fontisan/pipeline/strategies/preserve_strategy.rb +56 -0
  36. data/lib/fontisan/pipeline/transformation_pipeline.rb +411 -0
  37. data/lib/fontisan/pipeline/variation_resolver.rb +165 -0
  38. data/lib/fontisan/tables/cff/charstring.rb +33 -4
  39. data/lib/fontisan/tables/cff/charstring_builder.rb +34 -0
  40. data/lib/fontisan/tables/cff/charstring_parser.rb +237 -0
  41. data/lib/fontisan/tables/cff/charstring_rebuilder.rb +172 -0
  42. data/lib/fontisan/tables/cff/dict_builder.rb +15 -0
  43. data/lib/fontisan/tables/cff/hint_operation_injector.rb +207 -0
  44. data/lib/fontisan/tables/cff/offset_recalculator.rb +70 -0
  45. data/lib/fontisan/tables/cff/private_dict_writer.rb +125 -0
  46. data/lib/fontisan/tables/cff/table_builder.rb +221 -0
  47. data/lib/fontisan/tables/cff.rb +2 -0
  48. data/lib/fontisan/tables/cff2/private_dict_blend_handler.rb +246 -0
  49. data/lib/fontisan/tables/cff2/region_matcher.rb +200 -0
  50. data/lib/fontisan/tables/cff2/table_builder.rb +574 -0
  51. data/lib/fontisan/tables/cff2/table_reader.rb +419 -0
  52. data/lib/fontisan/tables/cff2/variation_data_extractor.rb +212 -0
  53. data/lib/fontisan/tables/cff2.rb +9 -4
  54. data/lib/fontisan/tables/cvar.rb +2 -41
  55. data/lib/fontisan/tables/gvar.rb +2 -41
  56. data/lib/fontisan/true_type_font.rb +24 -9
  57. data/lib/fontisan/true_type_font_extensions.rb +54 -0
  58. data/lib/fontisan/utilities/checksum_calculator.rb +42 -0
  59. data/lib/fontisan/validation/checksum_validator.rb +2 -2
  60. data/lib/fontisan/validation/table_validator.rb +1 -1
  61. data/lib/fontisan/validation/variable_font_validator.rb +218 -0
  62. data/lib/fontisan/variation/converter.rb +120 -13
  63. data/lib/fontisan/variation/instance_writer.rb +341 -0
  64. data/lib/fontisan/variation/tuple_variation_header.rb +51 -0
  65. data/lib/fontisan/variation/variable_svg_generator.rb +268 -0
  66. data/lib/fontisan/variation/variation_preserver.rb +288 -0
  67. data/lib/fontisan/version.rb +1 -1
  68. data/lib/fontisan/version.rb.orig +9 -0
  69. data/lib/fontisan/woff2/glyf_transformer.rb +666 -0
  70. data/lib/fontisan/woff2/hmtx_transformer.rb +164 -0
  71. data/lib/fontisan/woff2_font.rb +475 -470
  72. data/lib/fontisan/woff_font.rb +16 -11
  73. data/lib/fontisan.rb +12 -0
  74. metadata +31 -2
@@ -263,17 +263,18 @@ module Fontisan
263
263
  head_entry = table_entries.find { |e| e[:tag] == "head" }
264
264
  return unless head_entry
265
265
 
266
- # Calculate font checksum (with head checksumAdjustment set to 0)
267
- # The head table at offset 8 should already be 0 from original table
266
+ # Zero out checksumAdjustment field (offset 8 in head table) before calculating
267
+ # This ensures we calculate the correct checksum regardless of source font's value
268
+ head_offset = head_entry[:offset]
269
+ checksum_offset = head_offset + 8
270
+ font_data[checksum_offset, 4] = "\x00\x00\x00\x00"
271
+
272
+ # Calculate font checksum (with head checksumAdjustment zeroed)
268
273
  font_checksum = calculate_font_checksum(font_data)
269
274
 
270
275
  # Calculate adjustment
271
276
  adjustment = (Constants::CHECKSUM_ADJUSTMENT_MAGIC - font_checksum) & 0xFFFFFFFF
272
277
 
273
- # Update head table checksumAdjustment field (offset 8 in head table)
274
- head_offset = head_entry[:offset]
275
- checksum_offset = head_offset + 8
276
-
277
278
  # Write adjustment as uint32 big-endian
278
279
  font_data[checksum_offset, 4] = [adjustment].pack("N")
279
280
  end
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "json"
3
4
  require_relative "../models/hint"
4
5
 
5
6
  module Fontisan
@@ -67,6 +68,59 @@ module Fontisan
67
68
  remove_conflicts(unique_hints)
68
69
  end
69
70
 
71
+ # Convert entire HintSet between formats
72
+ #
73
+ # @param hint_set [Models::HintSet] Source hint set
74
+ # @param target_format [Symbol] Target format (:truetype or :postscript)
75
+ # @return [Models::HintSet] Converted hint set
76
+ def convert_hint_set(hint_set, target_format)
77
+ return hint_set if hint_set.format == target_format.to_s
78
+
79
+ result = Models::HintSet.new(format: target_format.to_s)
80
+
81
+ case target_format
82
+ when :postscript
83
+ # Convert font-level TT → PS
84
+ if hint_set.font_program || hint_set.control_value_program ||
85
+ hint_set.control_values&.any?
86
+ ps_dict = convert_tt_programs_to_ps_dict(
87
+ hint_set.font_program,
88
+ hint_set.control_value_program,
89
+ hint_set.control_values
90
+ )
91
+ result.private_dict_hints = ps_dict.to_json
92
+ end
93
+
94
+ # Convert per-glyph hints
95
+ hint_set.hinted_glyph_ids.each do |glyph_id|
96
+ glyph_hints = hint_set.get_glyph_hints(glyph_id)
97
+ ps_hints = to_postscript(glyph_hints)
98
+ result.add_glyph_hints(glyph_id, ps_hints) unless ps_hints.empty?
99
+ end
100
+
101
+ when :truetype
102
+ # Convert font-level PS → TT
103
+ if hint_set.private_dict_hints && hint_set.private_dict_hints != "{}"
104
+ tt_programs = convert_ps_dict_to_tt_programs(
105
+ JSON.parse(hint_set.private_dict_hints)
106
+ )
107
+ result.font_program = tt_programs[:fpgm]
108
+ result.control_value_program = tt_programs[:prep]
109
+ result.control_values = tt_programs[:cvt]
110
+ end
111
+
112
+ # Convert per-glyph hints
113
+ hint_set.hinted_glyph_ids.each do |glyph_id|
114
+ glyph_hints = hint_set.get_glyph_hints(glyph_id)
115
+ tt_hints = to_truetype(glyph_hints)
116
+ result.add_glyph_hints(glyph_id, tt_hints) unless tt_hints.empty?
117
+ end
118
+ end
119
+
120
+ result.has_hints = !result.empty?
121
+ result
122
+ end
123
+
70
124
  private
71
125
 
72
126
  # Convert a single hint to PostScript format
@@ -172,6 +226,85 @@ module Fontisan
172
226
 
173
227
  pos1 < end2 && pos2 < end1
174
228
  end
229
+
230
+ # Convert TrueType font programs to PostScript Private dict
231
+ #
232
+ # Analyzes TrueType fpgm, prep, and cvt to extract semantic intent
233
+ # and generate corresponding PostScript hint parameters.
234
+ #
235
+ # @param fpgm [String] Font program bytecode
236
+ # @param prep [String] Control value program bytecode
237
+ # @param cvt [Array<Integer>] Control values
238
+ # @return [Hash] PostScript Private dict hint parameters
239
+ def convert_tt_programs_to_ps_dict(fpgm, prep, cvt)
240
+ hints = {}
241
+
242
+ # Extract stem widths from cvt if present
243
+ # CVT values typically contain standard widths
244
+ if cvt && !cvt.empty?
245
+ # First CVT value often represents standard horizontal stem
246
+ hints[:std_hw] = cvt[0].abs if cvt.length > 0
247
+ # Second CVT value often represents standard vertical stem
248
+ hints[:std_vw] = cvt[1].abs if cvt.length > 1
249
+ end
250
+
251
+ # Analyze control value program for alignment zones
252
+ # TrueType doesn't have exact Blue zones, so we use defaults
253
+ # These are standard values that work for most Latin fonts
254
+ hints[:blue_values] = [-20, 0, 706, 726]
255
+
256
+ # Optional: Add other_blues for descenders if we detect them
257
+ # This would require analyzing prep program, which is complex
258
+ # For now, use conservative defaults
259
+
260
+ hints
261
+ rescue StandardError => e
262
+ warn "Error converting TT programs to PS dict: #{e.message}"
263
+ {}
264
+ end
265
+
266
+ # Convert PostScript Private dict to TrueType font programs
267
+ #
268
+ # Generates TrueType control values and programs from PostScript
269
+ # hint parameters.
270
+ #
271
+ # @param ps_dict [Hash] PostScript Private dict parameters
272
+ # @return [Hash] TrueType programs ({ fpgm:, prep:, cvt: })
273
+ def convert_ps_dict_to_tt_programs(ps_dict)
274
+ # Handle both string and symbol keys from JSON
275
+ ps_dict = ps_dict.transform_keys(&:to_sym) if ps_dict.keys.first.is_a?(String)
276
+
277
+ # Generate control values from PS parameters
278
+ cvt = []
279
+
280
+ # Add standard stem widths as CVT values
281
+ cvt << ps_dict[:std_hw] if ps_dict[:std_hw]
282
+ cvt << ps_dict[:std_vw] if ps_dict[:std_vw]
283
+
284
+ # Add stem snap values if present
285
+ if ps_dict[:stem_snap_h]&.is_a?(Array)
286
+ cvt.concat(ps_dict[:stem_snap_h])
287
+ end
288
+ if ps_dict[:stem_snap_v]&.is_a?(Array)
289
+ cvt.concat(ps_dict[:stem_snap_v])
290
+ end
291
+
292
+ # Remove duplicates and sort
293
+ cvt = cvt.uniq.sort
294
+
295
+ # Generate basic prep program (empty for converted fonts)
296
+ # A real implementation would generate instructions to set up CVT
297
+ prep = ""
298
+
299
+ # fpgm typically empty for converted fonts
300
+ # Functions would need to be synthesized from scratch
301
+ fpgm = ""
302
+
303
+ { fpgm: fpgm, prep: prep, cvt: cvt }
304
+ rescue StandardError => e
305
+ warn "Error converting PS dict to TT programs: #{e.message}"
306
+ { fpgm: "", prep: "", cvt: [] }
307
+ end
175
308
  end
176
309
  end
177
310
  end
@@ -1,184 +1,265 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../models/hint"
4
+ require "json"
4
5
 
5
6
  module Fontisan
6
7
  module Hints
7
- # Applies rendering hints to PostScript/CFF CharString data
8
+ # Applies rendering hints to PostScript/CFF font tables
8
9
  #
9
- # This applier converts universal Hint objects into PostScript hint
10
- # operators and integrates them into CharString data. It ensures proper
11
- # operator placement and maintains CharString validity.
10
+ # This applier validates and applies PostScript hint data to CFF fonts by
11
+ # rebuilding the entire CFF table structure with updated Private DICT parameters.
12
12
  #
13
- # **PostScript Hint Placement:**
13
+ # **Status**: Fully Operational (Phase 10A Complete)
14
14
  #
15
- # - Stem hints (hstem/vstem) must appear at the beginning
16
- # - Hintmask operators can appear throughout the CharString
17
- # - Hints affect all subsequent path operations
15
+ # **PostScript Hint Parameters (Private DICT)**:
18
16
  #
19
- # @example Apply hints to a CharString
17
+ # - blue_values: Alignment zones for overshoot suppression
18
+ # - other_blues: Additional alignment zones
19
+ # - std_hw: Standard horizontal stem width
20
+ # - std_vw: Standard vertical stem width
21
+ # - stem_snap_h: Horizontal stem snap widths
22
+ # - stem_snap_v: Vertical stem snap widths
23
+ # - blue_scale, blue_shift, blue_fuzz: Overshoot parameters
24
+ # - force_bold: Force bold flag
25
+ # - language_group: Language group (0=Latin, 1=CJK)
26
+ #
27
+ # @example Apply PostScript hints
20
28
  # applier = PostScriptHintApplier.new
21
- # charstring_with_hints = applier.apply(charstring, hints)
29
+ # tables = { "CFF " => cff_table }
30
+ # hint_set = HintSet.new(format: "postscript", private_dict_hints: hints_json)
31
+ # result = applier.apply(hint_set, tables)
22
32
  class PostScriptHintApplier
23
- # CFF CharString operators
24
- HSTEM = 1
25
- VSTEM = 3
26
- HINTMASK = 19
27
- CNTRMASK = 20
28
- HSTEM3 = [12, 2]
29
- VSTEM3 = [12, 1]
30
-
31
- # Apply hints to CharString
33
+ # Apply PostScript hints to font tables
34
+ #
35
+ # Validates hint data and rebuilds CFF table with updated Private DICT.
36
+ # Supports arbitrary Private DICT size changes through full table reconstruction.
37
+ # Also supports per-glyph hints injected directly into CharStrings.
32
38
  #
33
- # @param charstring [String] Original CharString bytes
34
- # @param hints [Array<Hint>] Hints to apply
35
- # @return [String] CharString with applied hints
36
- def apply(charstring, hints)
37
- return charstring if hints.nil? || hints.empty?
38
- return charstring if charstring.nil? || charstring.empty?
39
-
40
- # Build hint operators
41
- hint_ops = build_hint_operators(hints)
42
-
43
- # Insert hints at the beginning of CharString
44
- # (simplified - real implementation would analyze existing structure)
45
- hint_ops + charstring
39
+ # @param hint_set [HintSet] Hint data to apply
40
+ # @param tables [Hash] Font tables (must include "CFF " or "CFF2 ")
41
+ # @return [Hash] Updated font tables with hints applied
42
+ def apply(hint_set, tables)
43
+ return tables if hint_set.nil? || hint_set.empty?
44
+ return tables unless hint_set.format == "postscript"
45
+
46
+ if cff2_table?(tables)
47
+ apply_cff2_hints(hint_set, tables)
48
+ elsif cff_table?(tables)
49
+ apply_cff_hints(hint_set, tables)
50
+ else
51
+ tables
52
+ end
46
53
  end
47
54
 
48
55
  private
49
56
 
50
- # Build hint operators from hints
57
+ # Check if tables contain CFF2 table
51
58
  #
52
- # @param hints [Array<Hint>] Hints to convert
53
- # @return [String] Hint operator bytes
54
- def build_hint_operators(hints)
55
- operators = "".b
56
-
57
- # Group hints by type for proper ordering
58
- stem_hints = hints.select { |h| h.type == :stem }
59
- stem3_hints = hints.select { |h| h.type == :stem3 }
60
- mask_hints = hints.select { |h| %i[hint_replacement counter].include?(h.type) }
61
-
62
- # Add stem hints first
63
- stem_hints.each do |hint|
64
- operators << encode_stem_hint(hint)
65
- end
59
+ # @param tables [Hash] Font tables
60
+ # @return [Boolean] True if CFF2 table present
61
+ def cff2_table?(tables)
62
+ tables.key?("CFF2") || tables.key?("CFF2 ")
63
+ end
66
64
 
67
- # Add stem3 hints
68
- stem3_hints.each do |hint|
69
- operators << encode_stem3_hint(hint)
70
- end
65
+ # Check if tables contain CFF table
66
+ #
67
+ # @param tables [Hash] Font tables
68
+ # @return [Boolean] True if CFF table present
69
+ def cff_table?(tables)
70
+ tables.key?("CFF ")
71
+ end
71
72
 
72
- # Add mask hints
73
- mask_hints.each do |hint|
74
- operators << encode_mask_hint(hint)
75
- end
73
+ # Apply hints to CFF2 variable font
74
+ #
75
+ # @param hint_set [HintSet] Hint set with font-level and per-glyph hints
76
+ # @param tables [Hash] Font tables
77
+ # @return [Hash] Updated tables
78
+ def apply_cff2_hints(hint_set, tables)
79
+ # Load CFF2 table
80
+ cff2_data = tables["CFF2"] || tables["CFF2 "]
76
81
 
77
- operators
82
+ begin
83
+ require_relative "../tables/cff2/table_reader"
84
+ require_relative "../tables/cff2/table_builder"
85
+
86
+ reader = Tables::Cff2::TableReader.new(cff2_data)
87
+
88
+ # Validate CFF2 version
89
+ reader.read_header
90
+ unless reader.header[:major_version] == 2
91
+ warn "Invalid CFF2 table version: #{reader.header[:major_version]}"
92
+ return tables
93
+ end
94
+
95
+ # Build with hints
96
+ builder = Tables::Cff2::TableBuilder.new(reader, hint_set)
97
+ modified_table = builder.build
98
+
99
+ # Update tables
100
+ table_key = tables.key?("CFF2") ? "CFF2" : "CFF2 "
101
+ tables[table_key] = modified_table
102
+
103
+ tables
104
+ rescue StandardError => e
105
+ warn "Error applying CFF2 hints: #{e.message}"
106
+ tables
107
+ end
78
108
  end
79
109
 
80
- # Encode stem hint as CharString bytes
110
+ # Apply hints to CFF font
81
111
  #
82
- # @param hint [Hint] Stem hint
83
- # @return [String] Encoded bytes
84
- def encode_stem_hint(hint)
85
- data = hint.to_postscript
86
- return "".b if data.empty?
87
-
88
- args = data[:args] || []
89
- operator = data[:operator]
90
-
91
- # Encode arguments as CFF integers
92
- bytes = args.map { |arg| encode_cff_integer(arg) }.join
93
-
94
- # Add operator
95
- bytes << if operator == :vstem
96
- [VSTEM].pack("C")
97
- else
98
- [HSTEM].pack("C")
99
- end
100
-
101
- bytes
112
+ # @param hint_set [HintSet] Hint set with font-level and per-glyph hints
113
+ # @param tables [Hash] Font tables
114
+ # @return [Hash] Updated tables
115
+ def apply_cff_hints(hint_set, tables)
116
+ return tables unless tables["CFF "]
117
+
118
+ # Validate hint parameters (Private DICT)
119
+ hint_params = parse_hint_parameters(hint_set)
120
+
121
+ # Check if we have per-glyph hints
122
+ has_per_glyph_hints = hint_set.hinted_glyph_count.positive?
123
+
124
+ # If neither font-level nor per-glyph hints, return unchanged
125
+ return tables unless hint_params || has_per_glyph_hints
126
+
127
+ # Validate font-level parameters if present
128
+ if hint_params && !valid_hint_parameters?(hint_params)
129
+ return tables
130
+ end
131
+
132
+ # Apply hints (both font-level and per-glyph)
133
+ begin
134
+ require_relative "../tables/cff/table_builder"
135
+ require_relative "../tables/cff/charstring_rebuilder"
136
+ require_relative "../tables/cff/hint_operation_injector"
137
+ require_relative "../tables/cff"
138
+
139
+ # Parse CFF binary data into Cff object if needed
140
+ cff_data = tables["CFF "]
141
+ cff_table = if cff_data.is_a?(Tables::Cff)
142
+ cff_data
143
+ else
144
+ Tables::Cff.read(cff_data)
145
+ end
146
+
147
+ # Prepare per-glyph hint data if present
148
+ per_glyph_hints = if has_per_glyph_hints
149
+ extract_per_glyph_hints(hint_set)
150
+ else
151
+ nil
152
+ end
153
+
154
+ new_cff_data = Tables::Cff::TableBuilder.rebuild(
155
+ cff_table,
156
+ private_dict_hints: hint_params,
157
+ per_glyph_hints: per_glyph_hints
158
+ )
159
+
160
+ tables["CFF "] = new_cff_data
161
+ tables
162
+ rescue StandardError => e
163
+ warn "Failed to apply PostScript hints: #{e.message}"
164
+ tables
165
+ end
102
166
  end
103
167
 
104
- # Encode stem3 hint as CharString bytes
168
+ # Parse hint parameters from HintSet
105
169
  #
106
- # @param hint [Hint] Stem3 hint
107
- # @return [String] Encoded bytes
108
- def encode_stem3_hint(hint)
109
- data = hint.to_postscript
110
- return "".b if data.empty?
111
-
112
- args = data[:args] || []
113
- operator = data[:operator]
114
-
115
- # Encode arguments
116
- bytes = args.map { |arg| encode_cff_integer(arg) }.join
117
-
118
- # Add two-byte operator (12 followed by subop)
119
- bytes << if operator == :vstem3
120
- VSTEM3.pack("C*")
121
- else
122
- HSTEM3.pack("C*")
123
- end
124
-
125
- bytes
170
+ # @param hint_set [HintSet] Hint set with Private dict hints
171
+ # @return [Hash, nil] Parsed hint parameters, or nil if invalid
172
+ def parse_hint_parameters(hint_set)
173
+ return nil unless hint_set.private_dict_hints
174
+ return nil if hint_set.private_dict_hints == "{}"
175
+
176
+ JSON.parse(hint_set.private_dict_hints)
177
+ rescue JSON::ParserError => e
178
+ warn "Failed to parse Private dict hints: #{e.message}"
179
+ nil
126
180
  end
127
181
 
128
- # Encode mask hint as CharString bytes
182
+ # Validate hint parameters against CFF specification limits
129
183
  #
130
- # @param hint [Hint] Mask hint
131
- # @return [String] Encoded bytes
132
- def encode_mask_hint(hint)
133
- operator = hint.type == :hint_replacement ? HINTMASK : CNTRMASK
134
- mask = hint.data[:mask] || []
184
+ # @param params [Hash] Hint parameters
185
+ # @return [Boolean] True if all parameters are valid
186
+ def valid_hint_parameters?(params)
187
+ # Validate blue values (must be pairs, max 7 pairs = 14 values)
188
+ if params["blue_values"] || params[:blue_values]
189
+ values = params["blue_values"] || params[:blue_values]
190
+ return false unless values.is_a?(Array)
191
+ return false if values.length > 14 # Max 7 pairs
192
+ return false if values.length.odd? # Must be pairs
193
+ end
135
194
 
136
- # Encode mask bytes
137
- bytes = mask.map { |b| [b].pack("C") }.join
195
+ # Validate other_blues (max 5 pairs = 10 values)
196
+ if params["other_blues"] || params[:other_blues]
197
+ values = params["other_blues"] || params[:other_blues]
198
+ return false unless values.is_a?(Array)
199
+ return false if values.length > 10
200
+ return false if values.length.odd?
201
+ end
138
202
 
139
- # Add operator
140
- bytes + [operator].pack("C")
141
- end
203
+ # Validate stem widths (single values)
204
+ if params["std_hw"] || params[:std_hw]
205
+ value = params["std_hw"] || params[:std_hw]
206
+ return false unless value.is_a?(Numeric)
207
+ return false if value.negative?
208
+ end
142
209
 
143
- # Encode integer as CFF CharString number
144
- #
145
- # @param num [Integer] Number to encode
146
- # @return [String] Encoded bytes
147
- def encode_cff_integer(num)
148
- # Range 1: -107 to 107 (single byte)
149
- if num >= -107 && num <= 107
150
- return [32 + num].pack("c")
210
+ if params["std_vw"] || params[:std_vw]
211
+ value = params["std_vw"] || params[:std_vw]
212
+ return false unless value.is_a?(Numeric)
213
+ return false if value.negative?
214
+ end
215
+
216
+ # Validate stem snaps (arrays, max 12 values each)
217
+ %w[stem_snap_h stem_snap_v].each do |key|
218
+ next unless params[key] || params[key.to_sym]
219
+
220
+ values = params[key] || params[key.to_sym]
221
+ return false unless values.is_a?(Array)
222
+ return false if values.length > 12
151
223
  end
152
224
 
153
- # Range 2: 108 to 1131 (two bytes)
154
- if num >= 108 && num <= 1131
155
- b0 = 247 + ((num - 108) >> 8)
156
- b1 = (num - 108) & 0xff
157
- return [b0, b1].pack("C*")
225
+ # Validate blue_scale (should be positive)
226
+ if params["blue_scale"] || params[:blue_scale]
227
+ value = params["blue_scale"] || params[:blue_scale]
228
+ return false unless value.is_a?(Numeric)
229
+ return false if value <= 0
158
230
  end
159
231
 
160
- # Range 3: -1131 to -108 (two bytes)
161
- if num >= -1131 && num <= -108
162
- b0 = 251 - ((num + 108) >> 8)
163
- b1 = -(num + 108) & 0xff
164
- return [b0, b1].pack("C*")
232
+ # Validate language_group (0 or 1 only)
233
+ if params["language_group"] || params[:language_group]
234
+ value = params["language_group"] || params[:language_group]
235
+ return false unless [0, 1].include?(value)
165
236
  end
166
237
 
167
- # Range 4: -32768 to 32767 (three bytes)
168
- if num >= -32_768 && num <= 32_767
169
- bytes = [28, (num >> 8) & 0xff, num & 0xff]
170
- return bytes.pack("C*")
238
+ true
239
+ end
240
+
241
+ # Extract specific hint parameter with symbol/string key support
242
+ #
243
+ # @param params [Hash] Hint parameters
244
+ # @param key [String] Parameter name
245
+ # @return [Object, nil] Parameter value
246
+ def extract_param(params, key)
247
+ params[key] || params[key.to_sym]
248
+ end
249
+
250
+ # Extract per-glyph hint data from HintSet
251
+ #
252
+ # @param hint_set [HintSet] Hint set with per-glyph hints
253
+ # @return [Hash] Hash mapping glyph_id => Array<Hint>
254
+ def extract_per_glyph_hints(hint_set)
255
+ per_glyph = {}
256
+
257
+ hint_set.hinted_glyph_ids.each do |glyph_id|
258
+ hints = hint_set.get_glyph_hints(glyph_id)
259
+ per_glyph[glyph_id.to_i] = hints unless hints.empty?
171
260
  end
172
261
 
173
- # Range 5: Larger numbers (five bytes)
174
- bytes = [
175
- 255,
176
- (num >> 24) & 0xff,
177
- (num >> 16) & 0xff,
178
- (num >> 8) & 0xff,
179
- num & 0xff
180
- ]
181
- bytes.pack("C*")
262
+ per_glyph
182
263
  end
183
264
  end
184
265
  end