fontisan 0.2.0 → 0.2.2

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 (99) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +119 -308
  3. data/README.adoc +1525 -1323
  4. data/Rakefile +45 -47
  5. data/benchmark/variation_quick_bench.rb +4 -4
  6. data/docs/FONT_HINTING.adoc +562 -0
  7. data/docs/VARIABLE_FONT_OPERATIONS.adoc +599 -0
  8. data/lib/fontisan/cli.rb +92 -34
  9. data/lib/fontisan/collection/builder.rb +82 -0
  10. data/lib/fontisan/collection/offset_calculator.rb +2 -0
  11. data/lib/fontisan/collection/table_deduplicator.rb +76 -0
  12. data/lib/fontisan/commands/base_command.rb +21 -2
  13. data/lib/fontisan/commands/convert_command.rb +96 -165
  14. data/lib/fontisan/commands/info_command.rb +111 -5
  15. data/lib/fontisan/commands/instance_command.rb +77 -85
  16. data/lib/fontisan/commands/validate_command.rb +28 -0
  17. data/lib/fontisan/config/validation_rules.yml +1 -1
  18. data/lib/fontisan/constants.rb +34 -24
  19. data/lib/fontisan/converters/format_converter.rb +154 -1
  20. data/lib/fontisan/converters/outline_converter.rb +101 -34
  21. data/lib/fontisan/converters/woff_writer.rb +9 -4
  22. data/lib/fontisan/font_loader.rb +14 -9
  23. data/lib/fontisan/font_writer.rb +9 -6
  24. data/lib/fontisan/formatters/text_formatter.rb +45 -1
  25. data/lib/fontisan/hints/hint_converter.rb +131 -2
  26. data/lib/fontisan/hints/hint_validator.rb +284 -0
  27. data/lib/fontisan/hints/postscript_hint_applier.rb +219 -140
  28. data/lib/fontisan/hints/postscript_hint_extractor.rb +151 -16
  29. data/lib/fontisan/hints/truetype_hint_applier.rb +90 -44
  30. data/lib/fontisan/hints/truetype_hint_extractor.rb +134 -11
  31. data/lib/fontisan/hints/truetype_instruction_analyzer.rb +261 -0
  32. data/lib/fontisan/hints/truetype_instruction_generator.rb +266 -0
  33. data/lib/fontisan/loading_modes.rb +6 -4
  34. data/lib/fontisan/models/collection_brief_info.rb +31 -0
  35. data/lib/fontisan/models/font_info.rb +3 -30
  36. data/lib/fontisan/models/hint.rb +183 -12
  37. data/lib/fontisan/models/outline.rb +4 -1
  38. data/lib/fontisan/open_type_font.rb +28 -10
  39. data/lib/fontisan/open_type_font_extensions.rb +54 -0
  40. data/lib/fontisan/optimizers/pattern_analyzer.rb +2 -1
  41. data/lib/fontisan/optimizers/subroutine_generator.rb +1 -1
  42. data/lib/fontisan/pipeline/format_detector.rb +249 -0
  43. data/lib/fontisan/pipeline/output_writer.rb +159 -0
  44. data/lib/fontisan/pipeline/strategies/base_strategy.rb +75 -0
  45. data/lib/fontisan/pipeline/strategies/instance_strategy.rb +93 -0
  46. data/lib/fontisan/pipeline/strategies/named_strategy.rb +118 -0
  47. data/lib/fontisan/pipeline/strategies/preserve_strategy.rb +56 -0
  48. data/lib/fontisan/pipeline/transformation_pipeline.rb +416 -0
  49. data/lib/fontisan/pipeline/variation_resolver.rb +165 -0
  50. data/lib/fontisan/subset/table_subsetter.rb +5 -5
  51. data/lib/fontisan/tables/cff/charstring.rb +58 -3
  52. data/lib/fontisan/tables/cff/charstring_builder.rb +34 -0
  53. data/lib/fontisan/tables/cff/charstring_parser.rb +249 -0
  54. data/lib/fontisan/tables/cff/charstring_rebuilder.rb +172 -0
  55. data/lib/fontisan/tables/cff/dict_builder.rb +19 -1
  56. data/lib/fontisan/tables/cff/hint_operation_injector.rb +209 -0
  57. data/lib/fontisan/tables/cff/offset_recalculator.rb +70 -0
  58. data/lib/fontisan/tables/cff/private_dict_writer.rb +131 -0
  59. data/lib/fontisan/tables/cff/table_builder.rb +221 -0
  60. data/lib/fontisan/tables/cff.rb +2 -0
  61. data/lib/fontisan/tables/cff2/charstring_parser.rb +14 -8
  62. data/lib/fontisan/tables/cff2/private_dict_blend_handler.rb +247 -0
  63. data/lib/fontisan/tables/cff2/region_matcher.rb +200 -0
  64. data/lib/fontisan/tables/cff2/table_builder.rb +580 -0
  65. data/lib/fontisan/tables/cff2/table_reader.rb +421 -0
  66. data/lib/fontisan/tables/cff2/variation_data_extractor.rb +212 -0
  67. data/lib/fontisan/tables/cff2.rb +10 -5
  68. data/lib/fontisan/tables/cvar.rb +2 -41
  69. data/lib/fontisan/tables/glyf/compound_glyph_resolver.rb +2 -1
  70. data/lib/fontisan/tables/glyf/curve_converter.rb +10 -4
  71. data/lib/fontisan/tables/glyf/glyph_builder.rb +27 -10
  72. data/lib/fontisan/tables/gvar.rb +2 -41
  73. data/lib/fontisan/tables/name.rb +4 -4
  74. data/lib/fontisan/true_type_font.rb +27 -10
  75. data/lib/fontisan/true_type_font_extensions.rb +54 -0
  76. data/lib/fontisan/utilities/checksum_calculator.rb +42 -0
  77. data/lib/fontisan/validation/checksum_validator.rb +2 -2
  78. data/lib/fontisan/validation/table_validator.rb +1 -1
  79. data/lib/fontisan/validation/variable_font_validator.rb +218 -0
  80. data/lib/fontisan/variation/cache.rb +3 -1
  81. data/lib/fontisan/variation/converter.rb +121 -13
  82. data/lib/fontisan/variation/delta_applier.rb +2 -1
  83. data/lib/fontisan/variation/inspector.rb +2 -1
  84. data/lib/fontisan/variation/instance_generator.rb +2 -1
  85. data/lib/fontisan/variation/instance_writer.rb +341 -0
  86. data/lib/fontisan/variation/optimizer.rb +6 -3
  87. data/lib/fontisan/variation/subsetter.rb +32 -10
  88. data/lib/fontisan/variation/tuple_variation_header.rb +51 -0
  89. data/lib/fontisan/variation/variable_svg_generator.rb +268 -0
  90. data/lib/fontisan/variation/variation_preserver.rb +291 -0
  91. data/lib/fontisan/version.rb +1 -1
  92. data/lib/fontisan/version.rb.orig +9 -0
  93. data/lib/fontisan/woff2/glyf_transformer.rb +693 -0
  94. data/lib/fontisan/woff2/hmtx_transformer.rb +164 -0
  95. data/lib/fontisan/woff2_font.rb +489 -468
  96. data/lib/fontisan/woff_font.rb +16 -11
  97. data/lib/fontisan.rb +54 -2
  98. data/scripts/measure_optimization.rb +15 -7
  99. metadata +37 -2
@@ -0,0 +1,266 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Hints
5
+ # Generates TrueType instruction bytecode from PostScript hint parameters
6
+ #
7
+ # This class is the inverse of TrueTypeInstructionAnalyzer - it takes
8
+ # PostScript hint parameters and generates equivalent TrueType prep/fpgm
9
+ # programs and CVT values.
10
+ #
11
+ # TrueType Instruction Opcodes:
12
+ # - NPUSHB (0x40): Push n bytes
13
+ # - NPUSHW (0x41): Push n words (16-bit)
14
+ # - PUSHB[n] (0xB0-0xB7): Push 1-8 bytes
15
+ # - PUSHW[n] (0xB8-0xBF): Push 1-8 words
16
+ # - SSW (0x1F): Set Single Width
17
+ # - SSWCI (0x1E): Set Single Width Cut-In
18
+ # - SCVTCI (0x1D): Set CVT Cut-In
19
+ # - WCVTP (0x44): Write CVT in Pixels
20
+ # - WCVTF (0x70): Write CVT in FUnits
21
+ #
22
+ # @example Generate TrueType programs
23
+ # generator = TrueTypeInstructionGenerator.new
24
+ # programs = generator.generate({
25
+ # blue_scale: 0.039625,
26
+ # std_hw: 80,
27
+ # std_vw: 90
28
+ # })
29
+ # programs[:prep] # => Binary prep program
30
+ # programs[:fpgm] # => Binary fpgm program (usually empty)
31
+ # programs[:cvt] # => Array of CVT values
32
+ class TrueTypeInstructionGenerator
33
+ # TrueType instruction opcodes
34
+ NPUSHB = 0x40 # Push n bytes
35
+ NPUSHW = 0x41 # Push n words (16-bit)
36
+ PUSHB_BASE = 0xB0 # PUSHB[0] through PUSHB[7]
37
+ PUSHW_BASE = 0xB8 # PUSHW[0] through PUSHW[7]
38
+ SSW = 0x1F # Set Single Width
39
+ SSWCI = 0x1E # Set Single Width Cut-In
40
+ SCVTCI = 0x1D # Set CVT Cut-In
41
+ WCVTP = 0x44 # Write CVT in Pixels
42
+ WCVTF = 0x70 # Write CVT in FUnits
43
+
44
+ # Size thresholds for instruction selection
45
+ MAX_PUSHB_INLINE = 8 # Maximum bytes for PUSHB[n]
46
+ MAX_PUSHW_INLINE = 8 # Maximum words for PUSHW[n]
47
+ BYTE_MAX = 255 # Maximum value for byte
48
+ WORD_MAX = 65535 # Maximum value for word
49
+
50
+ # Generate TrueType programs and CVT from PostScript parameters
51
+ #
52
+ # @param ps_params [Hash] PostScript hint parameters
53
+ # @option ps_params [Float] :blue_scale Blue scale value (0.0-1.0)
54
+ # @option ps_params [Integer] :std_hw Standard horizontal width
55
+ # @option ps_params [Integer] :std_vw Standard vertical width
56
+ # @option ps_params [Array<Integer>] :stem_snap_h Horizontal stem snap values
57
+ # @option ps_params [Array<Integer>] :stem_snap_v Vertical stem snap values
58
+ # @option ps_params [Array<Integer>] :blue_values Blue zone values
59
+ # @option ps_params [Array<Integer>] :other_blues Other blue zone values
60
+ # @return [Hash] Hash with :prep, :fpgm, and :cvt keys
61
+ def generate(ps_params)
62
+ # Normalize keys to symbols
63
+ ps_params = normalize_keys(ps_params)
64
+
65
+ {
66
+ fpgm: generate_fpgm(ps_params),
67
+ prep: generate_prep(ps_params),
68
+ cvt: generate_cvt(ps_params)
69
+ }
70
+ end
71
+
72
+ # Generate prep (Control Value Program) from PostScript parameters
73
+ #
74
+ # The prep program sets up global hint parameters:
75
+ # - CVT Cut-In (from blue_scale)
76
+ # - Single Width Cut-In (from std_hw/std_vw)
77
+ # - Single Width (from std_hw or std_vw)
78
+ #
79
+ # @param ps_params [Hash] PostScript parameters
80
+ # @return [String] Binary instruction bytes
81
+ def generate_prep(ps_params)
82
+ instructions = []
83
+
84
+ # Set CVT Cut-In from blue_scale if present
85
+ if ps_params[:blue_scale]
86
+ cvt_cut_in = calculate_cvt_cut_in(ps_params[:blue_scale])
87
+ instructions.concat(push_value(cvt_cut_in))
88
+ instructions << SCVTCI
89
+ end
90
+
91
+ # Set Single Width Cut-In if we have stem widths
92
+ if ps_params[:std_hw] || ps_params[:std_vw]
93
+ sw_cut_in = calculate_sw_cut_in(ps_params)
94
+ instructions.concat(push_value(sw_cut_in))
95
+ instructions << SSWCI
96
+ end
97
+
98
+ # Set Single Width (prefer horizontal, fall back to vertical)
99
+ single_width = ps_params[:std_hw] || ps_params[:std_vw]
100
+ if single_width
101
+ instructions.concat(push_value(single_width))
102
+ instructions << SSW
103
+ end
104
+
105
+ instructions.pack("C*")
106
+ end
107
+
108
+ # Generate fpgm (Font Program) from PostScript parameters
109
+ #
110
+ # For converted fonts, fpgm is typically empty as font-level
111
+ # functions are not needed for basic hint conversion.
112
+ #
113
+ # @param _ps_params [Hash] PostScript parameters (unused)
114
+ # @return [String] Binary instruction bytes (empty for converted fonts)
115
+ def generate_fpgm(_ps_params)
116
+ # For converted fonts, fpgm is typically empty
117
+ # Advanced implementations might generate function definitions here
118
+ "".b
119
+ end
120
+
121
+ # Generate CVT (Control Value Table) from PostScript parameters
122
+ #
123
+ # CVT entries are derived from:
124
+ # - stem_snap_h/stem_snap_v: Stem widths
125
+ # - blue_values/other_blues: Alignment zones
126
+ # - std_hw/std_vw: Standard widths
127
+ #
128
+ # Duplicates are removed and values sorted for optimal CVT organization.
129
+ #
130
+ # @param ps_params [Hash] PostScript parameters
131
+ # @return [Array<Integer>] Array of 16-bit signed integers
132
+ def generate_cvt(ps_params)
133
+ cvt = []
134
+
135
+ # Add standard widths to CVT
136
+ cvt << ps_params[:std_hw] if ps_params[:std_hw]
137
+ cvt << ps_params[:std_vw] if ps_params[:std_vw]
138
+
139
+ # Add stem snap values
140
+ if ps_params[:stem_snap_h]
141
+ cvt.concat(ps_params[:stem_snap_h])
142
+ end
143
+
144
+ if ps_params[:stem_snap_v]
145
+ cvt.concat(ps_params[:stem_snap_v])
146
+ end
147
+
148
+ # Add blue zone values (as pairs: bottom, top)
149
+ if ps_params[:blue_values]
150
+ cvt.concat(ps_params[:blue_values])
151
+ end
152
+
153
+ if ps_params[:other_blues]
154
+ cvt.concat(ps_params[:other_blues])
155
+ end
156
+
157
+ # Remove duplicates and sort for optimal CVT organization
158
+ cvt.uniq.sort
159
+ end
160
+
161
+ private
162
+
163
+ # Normalize hash keys to symbols
164
+ #
165
+ # @param hash [Hash] Input hash with string or symbol keys
166
+ # @return [Hash] Hash with symbol keys
167
+ def normalize_keys(hash)
168
+ return hash unless hash.is_a?(Hash)
169
+ return hash if hash.empty? || hash.keys.first.is_a?(Symbol)
170
+
171
+ hash.transform_keys(&:to_sym)
172
+ end
173
+
174
+ # Calculate CVT Cut-In from PostScript blue_scale
175
+ #
176
+ # Blue scale controls the threshold at which alignment zones apply.
177
+ # We convert this to TrueType's CVT Cut-In value.
178
+ #
179
+ # @param blue_scale [Float] PostScript blue scale (0.0-1.0)
180
+ # @return [Integer] CVT Cut-In value in pixels
181
+ def calculate_cvt_cut_in(blue_scale)
182
+ # blue_scale of 0.039625 (common default) maps to ~17px cut-in
183
+ # Linear scaling: 0.039625 -> 17, 0.0 -> 0, 1.0 -> 428
184
+ (blue_scale * 428).round.clamp(0, 255)
185
+ end
186
+
187
+ # Calculate Single Width Cut-In from stem widths
188
+ #
189
+ # The cut-in determines when to apply single-width rounding.
190
+ # We use 9 pixels as a sensible default.
191
+ #
192
+ # @param _ps_params [Hash] PostScript parameters (for future use)
193
+ # @return [Integer] Single Width Cut-In in pixels
194
+ def calculate_sw_cut_in(_ps_params)
195
+ 9 # Standard value: 9 pixels
196
+ end
197
+
198
+ # Push a single value onto the TrueType stack
199
+ #
200
+ # Selects the most efficient instruction based on value size.
201
+ #
202
+ # @param value [Integer] Value to push
203
+ # @return [Array<Integer>] Instruction bytes
204
+ def push_value(value)
205
+ if value <= BYTE_MAX
206
+ push_bytes([value])
207
+ else
208
+ push_words([value])
209
+ end
210
+ end
211
+
212
+ # Push byte values using most efficient instruction
213
+ #
214
+ # Uses PUSHB[n] for 1-8 values, NPUSHB for more.
215
+ #
216
+ # @param values [Array<Integer>] Byte values (0-255)
217
+ # @return [Array<Integer>] Instruction bytes
218
+ def push_bytes(values)
219
+ return [] if values.empty?
220
+
221
+ # Validate all values fit in bytes
222
+ unless values.all? { |v| v >= 0 && v <= BYTE_MAX }
223
+ raise ArgumentError, "Values must be in range 0-255 for PUSHB"
224
+ end
225
+
226
+ count = values.size
227
+
228
+ if count <= MAX_PUSHB_INLINE
229
+ # Use PUSHB[n-1] for 1-8 values
230
+ [PUSHB_BASE + count - 1] + values
231
+ else
232
+ # Use NPUSHB for more than 8 values
233
+ [NPUSHB, count] + values
234
+ end
235
+ end
236
+
237
+ # Push word values using most efficient instruction
238
+ #
239
+ # Uses PUSHW[n] for 1-8 values, NPUSHW for more.
240
+ # Words are encoded big-endian (high byte first).
241
+ #
242
+ # @param values [Array<Integer>] Word values (0-65535)
243
+ # @return [Array<Integer>] Instruction bytes
244
+ def push_words(values)
245
+ return [] if values.empty?
246
+
247
+ # Validate all values fit in words
248
+ unless values.all? { |v| v >= 0 && v <= WORD_MAX }
249
+ raise ArgumentError, "Values must be in range 0-65535 for PUSHW"
250
+ end
251
+
252
+ count = values.size
253
+ # Convert words to big-endian byte pairs
254
+ word_bytes = values.flat_map { |v| [(v >> 8) & 0xFF, v & 0xFF] }
255
+
256
+ if count <= MAX_PUSHW_INLINE
257
+ # Use PUSHW[n-1] for 1-8 values
258
+ [PUSHW_BASE + count - 1] + word_bytes
259
+ else
260
+ # Use NPUSHW for more than 8 values
261
+ [NPUSHW, count] + word_bytes
262
+ end
263
+ end
264
+ end
265
+ end
266
+ end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "set"
4
+
3
5
  module Fontisan
4
6
  # Loading modes module that defines which tables are loaded in each mode.
5
7
  #
@@ -26,12 +28,12 @@ module Fontisan
26
28
  MODES = {
27
29
  METADATA => {
28
30
  tables: %w[name head hhea maxp OS/2 post].freeze,
29
- description: "Metadata mode - loads only identification and metrics tables (otfinfo-equivalent)"
31
+ description: "Metadata mode - loads only identification and metrics tables (otfinfo-equivalent)",
30
32
  }.freeze,
31
33
  FULL => {
32
34
  tables: :all,
33
- description: "Full mode - loads all tables in the font"
34
- }.freeze
35
+ description: "Full mode - loads all tables in the font",
36
+ }.freeze,
35
37
  }.freeze
36
38
 
37
39
  # Pre-computed Set for O(1) lookup of metadata tables
@@ -78,7 +80,7 @@ module Fontisan
78
80
  # @raise [ArgumentError] if mode is invalid
79
81
  def self.default_lazy?(mode)
80
82
  validate_mode!(mode)
81
- true # Lazy loading is recommended for all modes
83
+ true # Lazy loading is recommended for all modes
82
84
  end
83
85
 
84
86
  # Get mode description
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "lutaml/model"
4
+ require_relative "font_info"
5
+
6
+ module Fontisan
7
+ module Models
8
+ # Model for collection brief information
9
+ #
10
+ # Represents collection metadata plus brief info for each font.
11
+ # Used by InfoCommand in brief mode for collections.
12
+ #
13
+ # @example Creating collection brief info
14
+ # info = CollectionBriefInfo.new(
15
+ # collection_path: "fonts.ttc",
16
+ # num_fonts: 3,
17
+ # fonts: [font_info1, font_info2, font_info3]
18
+ # )
19
+ class CollectionBriefInfo < Lutaml::Model::Serializable
20
+ attribute :collection_path, :string
21
+ attribute :num_fonts, :integer
22
+ attribute :fonts, FontInfo, collection: true
23
+
24
+ key_value do
25
+ map "collection_path", to: :collection_path
26
+ map "num_fonts", to: :num_fonts
27
+ map "fonts", to: :fonts
28
+ end
29
+ end
30
+ end
31
+ end
@@ -36,37 +36,9 @@ module Fontisan
36
36
  attribute :font_revision, :float
37
37
  attribute :permissions, :string
38
38
  attribute :units_per_em, :integer
39
+ attribute :collection_offset, :integer
39
40
 
40
- json do
41
- map "font_format", to: :font_format
42
- map "is_variable", to: :is_variable
43
- map "family_name", to: :family_name
44
- map "subfamily_name", to: :subfamily_name
45
- map "full_name", to: :full_name
46
- map "postscript_name", to: :postscript_name
47
- map "postscript_cid_name", to: :postscript_cid_name
48
- map "preferred_family", to: :preferred_family
49
- map "preferred_subfamily", to: :preferred_subfamily
50
- map "mac_font_menu_name", to: :mac_font_menu_name
51
- map "version", to: :version
52
- map "unique_id", to: :unique_id
53
- map "description", to: :description
54
- map "designer", to: :designer
55
- map "designer_url", to: :designer_url
56
- map "manufacturer", to: :manufacturer
57
- map "vendor_url", to: :vendor_url
58
- map "vendor_id", to: :vendor_id
59
- map "trademark", to: :trademark
60
- map "copyright", to: :copyright
61
- map "license_description", to: :license_description
62
- map "license_url", to: :license_url
63
- map "sample_text", to: :sample_text
64
- map "font_revision", to: :font_revision
65
- map "permissions", to: :permissions
66
- map "units_per_em", to: :units_per_em
67
- end
68
-
69
- yaml do
41
+ key_value do
70
42
  map "font_format", to: :font_format
71
43
  map "is_variable", to: :is_variable
72
44
  map "family_name", to: :family_name
@@ -93,6 +65,7 @@ module Fontisan
93
65
  map "font_revision", to: :font_revision
94
66
  map "permissions", to: :permissions
95
67
  map "units_per_em", to: :units_per_em
68
+ map "collection_offset", to: :collection_offset
96
69
  end
97
70
  end
98
71
  end
@@ -1,7 +1,136 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "json"
4
+
3
5
  module Fontisan
4
6
  module Models
7
+ # Container for all font hint data
8
+ #
9
+ # This model holds complete hint information from a font including
10
+ # font-level programs, control values, and per-glyph hints. It provides
11
+ # a format-agnostic representation that can be converted between
12
+ # TrueType and PostScript formats.
13
+ #
14
+ # @example Creating a HintSet
15
+ # hint_set = HintSet.new(
16
+ # format: :truetype,
17
+ # font_program: fpgm_data,
18
+ # control_value_program: prep_data
19
+ # )
20
+ class HintSet
21
+ # @return [String] Hint format (:truetype or :postscript)
22
+ attr_accessor :format
23
+
24
+ # TrueType font-level hint data
25
+ # @return [String] Font program (fpgm table) - bytecode executed once
26
+ attr_accessor :font_program
27
+
28
+ # @return [String] Control value program (prep table) - initialization code
29
+ attr_accessor :control_value_program
30
+
31
+ # @return [Array<Integer>] Control values (cvt table) - metrics for hinting
32
+ attr_accessor :control_values
33
+
34
+ # PostScript font-level hint data
35
+ # @return [String] CFF Private dict hint data (BlueValues, StdHW, etc.) as JSON
36
+ attr_accessor :private_dict_hints
37
+
38
+ # @return [Integer] Number of glyphs with hints
39
+ attr_accessor :hinted_glyph_count
40
+
41
+ # @return [Boolean] Whether hints are present
42
+ attr_accessor :has_hints
43
+
44
+ # Initialize a new HintSet
45
+ #
46
+ # @param format [String, Symbol] Hint format (:truetype or :postscript)
47
+ # @param font_program [String] Font program bytecode
48
+ # @param control_value_program [String] Control value program bytecode
49
+ # @param control_values [Array<Integer>] Control values
50
+ # @param private_dict_hints [String] Private dict hints as JSON
51
+ # @param hinted_glyph_count [Integer] Number of hinted glyphs
52
+ # @param has_hints [Boolean] Whether hints are present
53
+ def initialize(format: nil, font_program: "", control_value_program: "",
54
+ control_values: [], private_dict_hints: "{}",
55
+ hinted_glyph_count: 0, has_hints: false)
56
+ @format = format.to_s if format
57
+ @font_program = font_program || ""
58
+ @control_value_program = control_value_program || ""
59
+ @control_values = control_values || []
60
+ @private_dict_hints = private_dict_hints || "{}"
61
+ @glyph_hints = "{}"
62
+ @hinted_glyph_count = hinted_glyph_count
63
+ @has_hints = has_hints
64
+ end
65
+
66
+ # Add hints for a specific glyph
67
+ #
68
+ # @param glyph_id [Integer, String] Glyph identifier
69
+ # @param hints [Array<Hint>] Hints for the glyph
70
+ def add_glyph_hints(glyph_id, hints)
71
+ return if hints.nil? || hints.empty?
72
+
73
+ glyph_hints_hash = parse_glyph_hints
74
+ # Convert Hint objects to hashes for storage
75
+ hints_data = hints.map do |h|
76
+ {
77
+ type: h.type,
78
+ data: h.data,
79
+ source_format: h.source_format,
80
+ }
81
+ end
82
+ glyph_hints_hash[glyph_id.to_s] = hints_data
83
+ @glyph_hints = glyph_hints_hash.to_json
84
+ @hinted_glyph_count = glyph_hints_hash.keys.length
85
+ @has_hints = true
86
+ end
87
+
88
+ # Get hints for a specific glyph
89
+ #
90
+ # @param glyph_id [Integer, String] Glyph identifier
91
+ # @return [Array<Hint>] Hints for the glyph
92
+ def get_glyph_hints(glyph_id)
93
+ glyph_hints_hash = parse_glyph_hints
94
+ hints_data = glyph_hints_hash[glyph_id.to_s]
95
+ return [] unless hints_data
96
+
97
+ # Reconstruct Hint objects from serialized data
98
+ hints_data.map { |h| Hint.new(**h.transform_keys(&:to_sym)) }
99
+ end
100
+
101
+ # Get all glyph IDs with hints
102
+ #
103
+ # @return [Array<String>] Glyph identifiers
104
+ def hinted_glyph_ids
105
+ parse_glyph_hints.keys
106
+ end
107
+
108
+ # Check if empty (no hints)
109
+ #
110
+ # @return [Boolean] True if no hints present
111
+ def empty?
112
+ !has_hints &&
113
+ (font_program.nil? || font_program.empty?) &&
114
+ (control_value_program.nil? || control_value_program.empty?) &&
115
+ (control_values.nil? || control_values.empty?) &&
116
+ (private_dict_hints.nil? || private_dict_hints == "{}")
117
+ end
118
+
119
+ private
120
+
121
+ # @return [String] Glyph hints as JSON
122
+ attr_accessor :glyph_hints
123
+
124
+ # Parse glyph hints JSON
125
+ def parse_glyph_hints
126
+ return {} if @glyph_hints.nil? || @glyph_hints.empty? || @glyph_hints == "{}"
127
+
128
+ JSON.parse(@glyph_hints)
129
+ rescue JSON::ParserError
130
+ {}
131
+ end
132
+ end
133
+
5
134
  # Universal hint representation supporting both TrueType and PostScript hints
6
135
  #
7
136
  # Hints are instructions that improve font rendering at small sizes by
@@ -60,16 +189,21 @@ module Fontisan
60
189
  case type
61
190
  when :stem
62
191
  convert_stem_to_truetype
192
+ when :stem3
193
+ convert_stem3_to_truetype
63
194
  when :flex
64
195
  convert_flex_to_truetype
65
196
  when :counter
66
197
  convert_counter_to_truetype
198
+ when :hint_replacement
199
+ convert_hintmask_to_truetype
67
200
  when :delta
68
201
  # Already in TrueType format
69
202
  data[:instructions] || []
70
203
  when :interpolate
71
204
  # IUP instruction
72
- [0x30] # IUP[y], or [0x31] for IUP[x]
205
+ axis = data[:axis] || :y
206
+ axis == :x ? [0x31] : [0x30] # IUP[x] or IUP[y]
73
207
  when :shift
74
208
  # SHP instruction
75
209
  data[:instructions] || []
@@ -80,6 +214,9 @@ module Fontisan
80
214
  # Unknown hint type - return empty
81
215
  []
82
216
  end
217
+ rescue StandardError => e
218
+ warn "Error converting hint type #{type} to TrueType: #{e.message}"
219
+ []
83
220
  end
84
221
 
85
222
  # Convert hint to PostScript hint format
@@ -106,6 +243,9 @@ module Fontisan
106
243
  # Unknown hint type
107
244
  {}
108
245
  end
246
+ rescue StandardError => e
247
+ warn "Error converting hint type #{type} to PostScript: #{e.message}"
248
+ {}
109
249
  end
110
250
 
111
251
  # Check if hint is compatible with target format
@@ -129,23 +269,22 @@ module Fontisan
129
269
 
130
270
  # Convert stem hint to TrueType instructions
131
271
  def convert_stem_to_truetype
132
- position = data[:position] || 0
133
- width = data[:width] || 0
272
+ data[:position] || 0
273
+ data[:width] || 0
134
274
  orientation = data[:orientation] || :vertical
135
275
 
136
276
  # TrueType uses MDAP (Move Direct Absolute Point) and MDRP (Move Direct Relative Point)
137
277
  # to control stem positioning
138
278
  instructions = []
139
279
 
140
- if orientation == :vertical
141
- # Vertical stem: use Y-axis instructions
142
- instructions << 0x2E # MDAP[rnd] - mark reference point
143
- instructions << 0xC0 # MDRP[min,rnd,black] - move relative point
144
- else
145
- # Horizontal stem: use X-axis instructions
146
- instructions << 0x2F # MDAP[rnd]
147
- instructions << 0xC0 # MDRP[min,rnd,black]
148
- end
280
+ instructions << if orientation == :vertical
281
+ # Vertical stem: use Y-axis instructions
282
+ 0x2E # MDAP[rnd] - mark reference point
283
+ else
284
+ # Horizontal stem: use X-axis instructions
285
+ 0x2F # MDAP[rnd]
286
+ end
287
+ instructions << 0xC0 # MDRP[min,rnd,black] - move relative point
149
288
 
150
289
  instructions
151
290
  end
@@ -166,6 +305,38 @@ module Fontisan
166
305
  []
167
306
  end
168
307
 
308
+ # Convert stem3 hint to TrueType instructions
309
+ def convert_stem3_to_truetype
310
+ stems = data[:stems] || []
311
+ orientation = data[:orientation] || :vertical
312
+
313
+ # Generate MDAP/MDRP pairs for each stem
314
+ instructions = []
315
+
316
+ stems.each do |_stem|
317
+ instructions << if orientation == :vertical
318
+ # Vertical stem: use Y-axis instructions
319
+ 0x2E # MDAP[rnd] - mark reference point
320
+ else
321
+ # Horizontal stem: use X-axis instructions
322
+ 0x2F # MDAP[rnd]
323
+ end
324
+ instructions << 0xC0 # MDRP[min,rnd,black] - move relative point
325
+ end
326
+
327
+ instructions
328
+ end
329
+
330
+ # Convert hintmask hint to TrueType instructions
331
+ def convert_hintmask_to_truetype
332
+ # Hintmask controls which hints are active at runtime
333
+ # TrueType doesn't have a direct equivalent
334
+ # We can use conditional instructions, but it's complex
335
+ # For now, return empty and let the main stems handle hinting
336
+ # TODO: Implement conditional instruction generation if needed
337
+ []
338
+ end
339
+
169
340
  # Convert stem hint to PostScript operators
170
341
  def convert_stem_to_postscript
171
342
  position = data[:position] || 0
@@ -121,7 +121,10 @@ module Fontisan
121
121
 
122
122
  # Get path from CharString
123
123
  path = charstring.path
124
- raise ArgumentError, "CharString has no path data" if path.nil? || path.empty?
124
+ if path.nil? || path.empty?
125
+ raise ArgumentError,
126
+ "CharString has no path data"
127
+ end
125
128
 
126
129
  commands = convert_cff_path_to_commands(path)
127
130