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
@@ -29,6 +29,28 @@ module Fontisan
29
29
  HSTEM3 = 12 << 8 | 2
30
30
  VSTEM3 = 12 << 8 | 1
31
31
 
32
+ # Extract complete hint data from OpenType/CFF font
33
+ #
34
+ # This extracts both font-level hints (CFF Private dict) and
35
+ # per-glyph hints from CharStrings.
36
+ #
37
+ # @param font [OpenTypeFont] OpenType font with CFF table
38
+ # @return [Models::HintSet] Complete hint set
39
+ def extract_from_font(font)
40
+ hint_set = Models::HintSet.new(format: "postscript")
41
+
42
+ # Extract font-level Private dict hints
43
+ hint_set.private_dict_hints = extract_private_dict_hints(font).to_json
44
+
45
+ # Extract per-glyph CharString hints
46
+ extract_charstring_hints(font, hint_set)
47
+
48
+ # Update metadata
49
+ hint_set.has_hints = !hint_set.empty?
50
+
51
+ hint_set
52
+ end
53
+
32
54
  # Extract hints from CFF CharString
33
55
  #
34
56
  # @param charstring [CharString, String] CFF CharString object or bytes
@@ -249,6 +271,84 @@ module Fontisan
249
271
  source_format: :postscript
250
272
  )
251
273
  end
274
+
275
+ # Extract Private dict hints from CFF table
276
+ #
277
+ # Private dict contains font-level hint parameters like BlueValues,
278
+ # StdHW, StdVW, etc.
279
+ #
280
+ # @param font [OpenTypeFont] OpenType font
281
+ # @return [Hash] Private dict hint parameters
282
+ def extract_private_dict_hints(font)
283
+ hints = {}
284
+
285
+ return hints unless font.has_table?("CFF ")
286
+
287
+ cff_table = font.table("CFF ")
288
+ return hints unless cff_table
289
+
290
+ # Get Private DICT for first font (index 0)
291
+ private_dict = cff_table.private_dict(0)
292
+ return hints unless private_dict
293
+
294
+ # Extract hint-related parameters from Private DICT
295
+ # These are the key hinting parameters in CFF
296
+ hints[:blue_values] = private_dict.blue_values if private_dict.respond_to?(:blue_values)
297
+ hints[:other_blues] = private_dict.other_blues if private_dict.respond_to?(:other_blues)
298
+ hints[:family_blues] = private_dict.family_blues if private_dict.respond_to?(:family_blues)
299
+ hints[:family_other_blues] = private_dict.family_other_blues if private_dict.respond_to?(:family_other_blues)
300
+ hints[:blue_scale] = private_dict.blue_scale if private_dict.respond_to?(:blue_scale)
301
+ hints[:blue_shift] = private_dict.blue_shift if private_dict.respond_to?(:blue_shift)
302
+ hints[:blue_fuzz] = private_dict.blue_fuzz if private_dict.respond_to?(:blue_fuzz)
303
+ hints[:std_hw] = private_dict.std_hw if private_dict.respond_to?(:std_hw)
304
+ hints[:std_vw] = private_dict.std_vw if private_dict.respond_to?(:std_vw)
305
+ hints[:stem_snap_h] = private_dict.stem_snap_h if private_dict.respond_to?(:stem_snap_h)
306
+ hints[:stem_snap_v] = private_dict.stem_snap_v if private_dict.respond_to?(:stem_snap_v)
307
+ hints[:force_bold] = private_dict.force_bold if private_dict.respond_to?(:force_bold)
308
+ hints[:language_group] = private_dict.language_group if private_dict.respond_to?(:language_group)
309
+
310
+ hints.compact
311
+ rescue StandardError => e
312
+ warn "Failed to extract Private dict hints: #{e.message}"
313
+ {}
314
+ end
315
+
316
+ # Extract per-glyph CharString hints from CFF table
317
+ #
318
+ # @param font [OpenTypeFont] OpenType font
319
+ # @param hint_set [Models::HintSet] Hint set to populate
320
+ # @return [void]
321
+ def extract_charstring_hints(font, hint_set)
322
+ return unless font.has_table?("CFF ")
323
+
324
+ cff_table = font.table("CFF ")
325
+ return unless cff_table
326
+
327
+ # Get CharStrings INDEX
328
+ charstrings_index = cff_table.charstrings_index(0)
329
+ return unless charstrings_index
330
+
331
+ # Iterate through all glyphs
332
+ glyph_count = cff_table.glyph_count(0)
333
+ (0...glyph_count).each do |glyph_id|
334
+ begin
335
+ # Get CharString for this glyph
336
+ charstring = cff_table.charstring_for_glyph(glyph_id, 0)
337
+ next unless charstring
338
+
339
+ # Extract hints from CharString
340
+ hints = extract(charstring)
341
+ next if hints.empty?
342
+
343
+ # Store glyph hints
344
+ hint_set.add_glyph_hints(glyph_id, hints)
345
+ rescue StandardError => e
346
+ warn "Failed to extract hints for glyph #{glyph_id}: #{e.message}"
347
+ end
348
+ end
349
+ rescue StandardError => e
350
+ warn "Failed to extract CharString hints: #{e.message}"
351
+ end
252
352
  end
253
353
  end
254
354
  end
@@ -1,70 +1,116 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "../models/hint"
4
-
5
3
  module Fontisan
6
4
  module Hints
7
- # Applies rendering hints to TrueType glyph data
5
+ # Applies rendering hints to TrueType font tables
6
+ #
7
+ # This applier writes TrueType hint data into font-level tables:
8
+ # - fpgm (Font Program) - bytecode executed once at font initialization
9
+ # - prep (Control Value Program) - bytecode for glyph preparation
10
+ # - cvt (Control Values) - array of 16-bit values for hinting metrics
8
11
  #
9
- # This applier converts universal Hint objects into TrueType bytecode
10
- # instructions and integrates them into glyph data. It ensures proper
11
- # instruction sequencing and maintains compatibility with TrueType
12
- # instruction execution model.
12
+ # The applier ensures proper table structure with correct checksums
13
+ # and does not corrupt the font if hint application fails.
13
14
  #
14
- # @example Apply hints to a glyph
15
+ # @example Apply hints from a HintSet
15
16
  # applier = TrueTypeHintApplier.new
16
- # glyph_with_hints = applier.apply(glyph, hints)
17
+ # tables = {}
18
+ # updated_tables = applier.apply(hint_set, tables)
17
19
  class TrueTypeHintApplier
18
- # Apply hints to TrueType glyph
20
+ # Apply TrueType hints to font tables
19
21
  #
20
- # @param glyph [Glyph] Target glyph
21
- # @param hints [Array<Hint>] Hints to apply
22
- # @return [Glyph] Glyph with applied hints
23
- def apply(glyph, hints)
24
- return glyph if hints.nil? || hints.empty?
25
- return glyph if glyph.nil?
22
+ # @param hint_set [HintSet] Hint data to apply
23
+ # @param tables [Hash] Font tables to update
24
+ # @return [Hash] Updated font tables
25
+ def apply(hint_set, tables)
26
+ return tables if hint_set.nil? || hint_set.empty?
27
+ return tables unless hint_set.format == "truetype"
26
28
 
27
- # Convert hints to TrueType instructions
28
- instructions = build_instructions(hints)
29
+ # Write fpgm table if present
30
+ if hint_set.font_program && !hint_set.font_program.empty?
31
+ tables["fpgm"] = build_fpgm_table(hint_set.font_program)
32
+ end
29
33
 
30
- # Apply to glyph (this is a simplified version)
31
- # In a real implementation, we would need to:
32
- # 1. Analyze existing glyph structure
33
- # 2. Insert instructions at appropriate points
34
- # 3. Update glyph instruction data
34
+ # Write prep table if present
35
+ if hint_set.control_value_program && !hint_set.control_value_program.empty?
36
+ tables["prep"] = build_prep_table(hint_set.control_value_program)
37
+ end
35
38
 
36
- # For now, we just return the glyph as-is since
37
- # this is a complex operation requiring deep integration
38
- # with the glyph structure
39
- glyph
39
+ # Write cvt table if present
40
+ if hint_set.control_values && !hint_set.control_values.empty?
41
+ tables["cvt "] = build_cvt_table(hint_set.control_values)
42
+ end
43
+
44
+ # Future enhancement: Apply per-glyph hints to glyf table
45
+ # For now, font-level tables only
46
+
47
+ tables
40
48
  end
41
49
 
42
50
  private
43
51
 
44
- # Build TrueType instruction sequence from hints
52
+ # Build fpgm (Font Program) table
45
53
  #
46
- # @param hints [Array<Hint>] Hints to convert
47
- # @return [Array<Integer>] Instruction bytes
48
- def build_instructions(hints)
49
- instructions = []
54
+ # @param program_data [String] Raw bytecode
55
+ # @return [Hash] Table structure with tag, data, and checksum
56
+ def build_fpgm_table(program_data)
57
+ {
58
+ tag: "fpgm",
59
+ data: program_data,
60
+ checksum: calculate_checksum(program_data),
61
+ }
62
+ end
50
63
 
51
- hints.each do |hint|
52
- hint_instructions = hint.to_truetype
53
- instructions.concat(hint_instructions) if hint_instructions
54
- end
64
+ # Build prep (Control Value Program) table
65
+ #
66
+ # @param program_data [String] Raw bytecode
67
+ # @return [Hash] Table structure with tag, data, and checksum
68
+ def build_prep_table(program_data)
69
+ {
70
+ tag: "prep",
71
+ data: program_data,
72
+ checksum: calculate_checksum(program_data),
73
+ }
74
+ end
75
+
76
+ # Build cvt (Control Values) table
77
+ #
78
+ # CVT values are 16-bit signed integers (FWORD) in big-endian format.
79
+ # Each value represents a design-space coordinate used for hinting.
80
+ #
81
+ # @param control_values [Array<Integer>] Array of 16-bit signed values
82
+ # @return [Hash] Table structure with tag, data, and checksum
83
+ def build_cvt_table(control_values)
84
+ # Pack as 16-bit big-endian signed integers (s> = signed big-endian)
85
+ data = control_values.pack("s>*")
55
86
 
56
- instructions
87
+ {
88
+ tag: "cvt ",
89
+ data: data,
90
+ checksum: calculate_checksum(data),
91
+ }
57
92
  end
58
93
 
59
- # Validate instruction sequence
94
+ # Calculate OpenType table checksum
95
+ #
96
+ # OpenType spec requires tables to be checksummed as 32-bit unsigned
97
+ # integers in big-endian format. The table is padded to a multiple of
98
+ # 4 bytes with zeros before checksum calculation.
60
99
  #
61
- # @param instructions [Array<Integer>] Instructions to validate
62
- # @return [Boolean] True if valid
63
- def valid_instructions?(instructions)
64
- return true if instructions.empty?
100
+ # @param data [String] Table data
101
+ # @return [Integer] 32-bit checksum
102
+ def calculate_checksum(data)
103
+ # Pad to 4-byte boundary with zeros
104
+ padding_needed = (4 - data.length % 4) % 4
105
+ padded = data + ("\x00" * padding_needed)
106
+
107
+ # Sum as 32-bit unsigned integers in big-endian
108
+ checksum = 0
109
+ (0...padded.length).step(4) do |i|
110
+ checksum = (checksum + padded[i, 4].unpack1("N")) & 0xFFFFFFFF
111
+ end
65
112
 
66
- # Basic validation - check for valid opcodes
67
- instructions.all? { |byte| byte >= 0 && byte <= 255 }
113
+ checksum
68
114
  end
69
115
  end
70
116
  end
@@ -35,6 +35,30 @@ module Fontisan
35
35
  DELTAP2 = 0x71
36
36
  DELTAP3 = 0x72
37
37
 
38
+ # Extract complete hint data from TrueType font
39
+ #
40
+ # This extracts both font-level hints (fpgm, prep, cvt tables) and
41
+ # per-glyph hints from glyph instructions.
42
+ #
43
+ # @param font [TrueTypeFont] TrueType font object
44
+ # @return [Models::HintSet] Complete hint set
45
+ def extract_from_font(font)
46
+ hint_set = Models::HintSet.new(format: "truetype")
47
+
48
+ # Extract font-level programs
49
+ hint_set.font_program = extract_font_program(font)
50
+ hint_set.control_value_program = extract_control_value_program(font)
51
+ hint_set.control_values = extract_control_values(font)
52
+
53
+ # Extract per-glyph hints
54
+ extract_glyph_hints(font, hint_set)
55
+
56
+ # Update metadata
57
+ hint_set.has_hints = !hint_set.empty?
58
+
59
+ hint_set
60
+ end
61
+
38
62
  # Extract hints from TrueType glyph
39
63
  #
40
64
  # @param glyph [Glyph] TrueType glyph with instructions
@@ -157,6 +181,109 @@ module Fontisan
157
181
  nil
158
182
  end
159
183
  end
184
+
185
+ # Extract font program (fpgm table)
186
+ #
187
+ # @param font [TrueTypeFont] TrueType font
188
+ # @return [String] Font program bytecode (binary string)
189
+ def extract_font_program(font)
190
+ return "" unless font.has_table?("fpgm")
191
+
192
+ font_program_data = font.instance_variable_get(:@table_data)["fpgm"]
193
+ return "" unless font_program_data
194
+
195
+ # Return as binary string
196
+ font_program_data.force_encoding("ASCII-8BIT")
197
+ rescue StandardError => e
198
+ warn "Failed to extract font program: #{e.message}"
199
+ ""
200
+ end
201
+
202
+ # Extract control value program (prep table)
203
+ #
204
+ # @param font [TrueTypeFont] TrueType font
205
+ # @return [String] Control value program bytecode (binary string)
206
+ def extract_control_value_program(font)
207
+ return "" unless font.has_table?("prep")
208
+
209
+ prep_data = font.instance_variable_get(:@table_data)["prep"]
210
+ return "" unless prep_data
211
+
212
+ # Return as binary string
213
+ prep_data.force_encoding("ASCII-8BIT")
214
+ rescue StandardError => e
215
+ warn "Failed to extract control value program: #{e.message}"
216
+ ""
217
+ end
218
+
219
+ # Extract control values (cvt table)
220
+ #
221
+ # @param font [TrueTypeFont] TrueType font
222
+ # @return [Array<Integer>] Control values
223
+ def extract_control_values(font)
224
+ return [] unless font.has_table?("cvt ")
225
+
226
+ cvt_data = font.instance_variable_get(:@table_data)["cvt "]
227
+ return [] unless cvt_data
228
+
229
+ # CVT table is an array of 16-bit signed integers (FWord values)
230
+ values = []
231
+ io = StringIO.new(cvt_data)
232
+ while !io.eof?
233
+ # Read 16-bit big-endian signed integer
234
+ bytes = io.read(2)
235
+ break unless bytes&.length == 2
236
+
237
+ value = bytes.unpack1("n") # Unsigned short
238
+ # Convert to signed
239
+ value = value - 65536 if value > 32767
240
+ values << value
241
+ end
242
+
243
+ values
244
+ rescue StandardError => e
245
+ warn "Failed to extract control values: #{e.message}"
246
+ []
247
+ end
248
+
249
+ # Extract per-glyph hints from glyf table
250
+ #
251
+ # @param font [TrueTypeFont] TrueType font
252
+ # @param hint_set [Models::HintSet] Hint set to populate
253
+ # @return [void]
254
+ def extract_glyph_hints(font, hint_set)
255
+ return unless font.has_table?("glyf")
256
+
257
+ glyf_table = font.table("glyf")
258
+ return unless glyf_table
259
+
260
+ # Get number of glyphs from maxp table
261
+ maxp_table = font.table("maxp")
262
+ return unless maxp_table
263
+
264
+ num_glyphs = maxp_table.num_glyphs
265
+
266
+ # Iterate through all glyphs
267
+ (0...num_glyphs).each do |glyph_id|
268
+ begin
269
+ glyph = glyf_table.glyph_for(glyph_id)
270
+ next unless glyph
271
+ next if glyph.number_of_contours <= 0 # Skip compound glyphs and empty glyphs
272
+
273
+ # Extract hints from simple glyph instructions
274
+ hints = extract(glyph)
275
+ next if hints.empty?
276
+
277
+ # Store glyph hints
278
+ hint_set.add_glyph_hints(glyph_id, hints)
279
+ rescue StandardError => e
280
+ # Skip glyphs that fail to parse
281
+ next
282
+ end
283
+ end
284
+ rescue StandardError => e
285
+ warn "Failed to extract glyph hints: #{e.message}"
286
+ end
160
287
  end
161
288
  end
162
289
  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
  #
@@ -71,8 +71,8 @@ module Fontisan
71
71
  attribute :tag, :string
72
72
  attribute :checksum, :string
73
73
  attribute :parsed, :boolean, default: -> { false }
74
- attribute :data, :string, default: -> {}
75
- attribute :fields, :string, default: -> {}
74
+ attribute :data, :string, default: -> { nil }
75
+ attribute :fields, :string, default: -> { nil }
76
76
 
77
77
  yaml do
78
78
  map "tag", to: :tag
@@ -1,7 +1,135 @@
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
+ JSON.parse(@glyph_hints)
128
+ rescue JSON::ParserError
129
+ {}
130
+ end
131
+ end
132
+
5
133
  # Universal hint representation supporting both TrueType and PostScript hints
6
134
  #
7
135
  # Hints are instructions that improve font rendering at small sizes by
@@ -60,16 +188,21 @@ module Fontisan
60
188
  case type
61
189
  when :stem
62
190
  convert_stem_to_truetype
191
+ when :stem3
192
+ convert_stem3_to_truetype
63
193
  when :flex
64
194
  convert_flex_to_truetype
65
195
  when :counter
66
196
  convert_counter_to_truetype
197
+ when :hint_replacement
198
+ convert_hintmask_to_truetype
67
199
  when :delta
68
200
  # Already in TrueType format
69
201
  data[:instructions] || []
70
202
  when :interpolate
71
203
  # IUP instruction
72
- [0x30] # IUP[y], or [0x31] for IUP[x]
204
+ axis = data[:axis] || :y
205
+ axis == :x ? [0x31] : [0x30] # IUP[x] or IUP[y]
73
206
  when :shift
74
207
  # SHP instruction
75
208
  data[:instructions] || []
@@ -80,6 +213,9 @@ module Fontisan
80
213
  # Unknown hint type - return empty
81
214
  []
82
215
  end
216
+ rescue StandardError => e
217
+ warn "Error converting hint type #{type} to TrueType: #{e.message}"
218
+ []
83
219
  end
84
220
 
85
221
  # Convert hint to PostScript hint format
@@ -106,6 +242,9 @@ module Fontisan
106
242
  # Unknown hint type
107
243
  {}
108
244
  end
245
+ rescue StandardError => e
246
+ warn "Error converting hint type #{type} to PostScript: #{e.message}"
247
+ {}
109
248
  end
110
249
 
111
250
  # Check if hint is compatible with target format
@@ -166,6 +305,39 @@ 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
+ if orientation == :vertical
318
+ # Vertical stem: use Y-axis instructions
319
+ instructions << 0x2E # MDAP[rnd] - mark reference point
320
+ instructions << 0xC0 # MDRP[min,rnd,black] - move relative point
321
+ else
322
+ # Horizontal stem: use X-axis instructions
323
+ instructions << 0x2F # MDAP[rnd]
324
+ instructions << 0xC0 # MDRP[min,rnd,black]
325
+ end
326
+ end
327
+
328
+ instructions
329
+ end
330
+
331
+ # Convert hintmask hint to TrueType instructions
332
+ def convert_hintmask_to_truetype
333
+ # Hintmask controls which hints are active at runtime
334
+ # TrueType doesn't have a direct equivalent
335
+ # We can use conditional instructions, but it's complex
336
+ # For now, return empty and let the main stems handle hinting
337
+ # TODO: Implement conditional instruction generation if needed
338
+ []
339
+ end
340
+
169
341
  # Convert stem hint to PostScript operators
170
342
  def convert_stem_to_postscript
171
343
  position = data[:position] || 0
@@ -29,7 +29,7 @@ module Fontisan
29
29
  attribute :severity, :string
30
30
  attribute :category, :string
31
31
  attribute :message, :string
32
- attribute :location, :string, default: -> {}
32
+ attribute :location, :string, default: -> { nil }
33
33
 
34
34
  yaml do
35
35
  map "severity", to: :severity