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
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "stringio"
4
4
  require_relative "../binary/base_record"
5
+ require_relative "../variation/tuple_variation_header"
5
6
 
6
7
  module Fontisan
7
8
  module Tables
@@ -26,46 +27,6 @@ module Fontisan
26
27
  uint16 :tuple_variation_count
27
28
  uint16 :data_offset
28
29
 
29
- # Tuple variation header
30
- class TupleVariationHeader < Binary::BaseRecord
31
- uint16 :variation_data_size
32
- uint16 :tuple_index
33
-
34
- # Tuple index flags
35
- EMBEDDED_PEAK_TUPLE = 0x8000
36
- INTERMEDIATE_REGION = 0x4000
37
- PRIVATE_POINT_NUMBERS = 0x2000
38
- TUPLE_INDEX_MASK = 0x0FFF
39
-
40
- # Check if tuple has embedded peak coordinates
41
- #
42
- # @return [Boolean] True if embedded
43
- def embedded_peak_tuple?
44
- (tuple_index & EMBEDDED_PEAK_TUPLE) != 0
45
- end
46
-
47
- # Check if tuple has intermediate region
48
- #
49
- # @return [Boolean] True if intermediate region
50
- def intermediate_region?
51
- (tuple_index & INTERMEDIATE_REGION) != 0
52
- end
53
-
54
- # Check if tuple has private point numbers
55
- #
56
- # @return [Boolean] True if private points
57
- def private_point_numbers?
58
- (tuple_index & PRIVATE_POINT_NUMBERS) != 0
59
- end
60
-
61
- # Get shared tuple index
62
- #
63
- # @return [Integer] Tuple index
64
- def shared_tuple_index
65
- tuple_index & TUPLE_INDEX_MASK
66
- end
67
- end
68
-
69
30
  # Get version as a float
70
31
  #
71
32
  # @return [Float] Version number (e.g., 1.0)
@@ -109,7 +70,7 @@ module Fontisan
109
70
  break if offset + 4 > data.bytesize
110
71
 
111
72
  header_data = data.byteslice(offset, 4)
112
- header = TupleVariationHeader.read(header_data)
73
+ header = Variation::TupleVariationHeader.read(header_data)
113
74
  offset += 4
114
75
 
115
76
  tuple_info = {
@@ -118,7 +118,8 @@ module Fontisan
118
118
  resolve(component_glyph, visited, depth + 1)
119
119
  else
120
120
  # Convert simple glyph to outline
121
- Models::Outline.from_truetype(component_glyph, component.glyph_index)
121
+ Models::Outline.from_truetype(component_glyph,
122
+ component.glyph_index)
122
123
  end
123
124
 
124
125
  # Apply transformation matrix
@@ -119,7 +119,10 @@ module Fontisan
119
119
  # @raise [ArgumentError] If parameters are invalid
120
120
  def self.calculate_error(cubic, quadratics)
121
121
  validate_cubic_curve!(cubic)
122
- raise ArgumentError, "quadratics must be Array" unless quadratics.is_a?(Array)
122
+ unless quadratics.is_a?(Array)
123
+ raise ArgumentError,
124
+ "quadratics must be Array"
125
+ end
123
126
  raise ArgumentError, "quadratics cannot be empty" if quadratics.empty?
124
127
 
125
128
  max_error = 0.0
@@ -305,7 +308,8 @@ module Fontisan
305
308
  required.each do |key|
306
309
  value = quad[key]
307
310
  unless value.is_a?(Numeric)
308
- raise ArgumentError, "quad[:#{key}] must be Numeric, got: #{value.class}"
311
+ raise ArgumentError,
312
+ "quad[:#{key}] must be Numeric, got: #{value.class}"
309
313
  end
310
314
  end
311
315
  end
@@ -324,14 +328,16 @@ module Fontisan
324
328
  required.each do |key|
325
329
  value = cubic[key]
326
330
  unless value.is_a?(Numeric)
327
- raise ArgumentError, "cubic[:#{key}] must be Numeric, got: #{value.class}"
331
+ raise ArgumentError,
332
+ "cubic[:#{key}] must be Numeric, got: #{value.class}"
328
333
  end
329
334
  end
330
335
  end
331
336
 
332
337
  private_class_method def self.validate_max_error!(max_error)
333
338
  unless max_error.is_a?(Numeric)
334
- raise ArgumentError, "max_error must be Numeric, got: #{max_error.class}"
339
+ raise ArgumentError,
340
+ "max_error must be Numeric, got: #{max_error.class}"
335
341
  end
336
342
 
337
343
  if max_error <= 0
@@ -106,7 +106,11 @@ module Fontisan
106
106
  # @raise [ArgumentError] If parameters are invalid
107
107
  def self.build_compound_glyph(components, bbox, instructions: "".b)
108
108
  raise ArgumentError, "components cannot be nil" if components.nil?
109
- raise ArgumentError, "components must be Array" unless components.is_a?(Array)
109
+
110
+ unless components.is_a?(Array)
111
+ raise ArgumentError,
112
+ "components must be Array"
113
+ end
110
114
  raise ArgumentError, "components cannot be empty" if components.empty?
111
115
 
112
116
  validate_bbox!(bbox)
@@ -114,7 +118,8 @@ module Fontisan
114
118
  build_compound_glyph_data(components, bbox, instructions)
115
119
  end
116
120
 
117
- private_class_method def self.build_simple_glyph_data(contours, bbox, instructions)
121
+ private_class_method def self.build_simple_glyph_data(contours, bbox,
122
+ instructions)
118
123
  num_contours = contours.length
119
124
 
120
125
  # Build endPtsOfContours array
@@ -136,7 +141,8 @@ module Fontisan
136
141
 
137
142
  # Header (10 bytes)
138
143
  data << [num_contours].pack("n") # numberOfContours
139
- data << [bbox[:x_min], bbox[:y_min], bbox[:x_max], bbox[:y_max]].pack("n4")
144
+ data << [bbox[:x_min], bbox[:y_min], bbox[:x_max],
145
+ bbox[:y_max]].pack("n4")
140
146
 
141
147
  # endPtsOfContours
142
148
  data << end_pts_of_contours.pack("n*")
@@ -155,18 +161,21 @@ module Fontisan
155
161
  data
156
162
  end
157
163
 
158
- private_class_method def self.build_compound_glyph_data(components, bbox, instructions)
164
+ private_class_method def self.build_compound_glyph_data(components, bbox,
165
+ instructions)
159
166
  data = (+"").force_encoding(Encoding::BINARY)
160
167
 
161
168
  # Header (10 bytes) - numberOfContours = -1 for compound
162
169
  data << [-1].pack("n") # Use signed pack, will convert to 0xFFFF
163
- data << [bbox[:x_min], bbox[:y_min], bbox[:x_max], bbox[:y_max]].pack("n4")
170
+ data << [bbox[:x_min], bbox[:y_min], bbox[:x_max],
171
+ bbox[:y_max]].pack("n4")
164
172
 
165
173
  # Encode components
166
174
  has_instructions = instructions.bytesize.positive?
167
175
  components.each_with_index do |component, index|
168
176
  is_last = (index == components.length - 1)
169
- component_data = encode_component(component, is_last, has_instructions)
177
+ component_data = encode_component(component, is_last,
178
+ has_instructions)
170
179
  data << component_data
171
180
  end
172
181
 
@@ -179,7 +188,8 @@ module Fontisan
179
188
  data
180
189
  end
181
190
 
182
- private_class_method def self.encode_component(component, is_last, has_instructions)
191
+ private_class_method def self.encode_component(component, is_last,
192
+ has_instructions)
183
193
  validate_component!(component)
184
194
 
185
195
  glyph_index = component[:glyph_index]
@@ -344,7 +354,8 @@ module Fontisan
344
354
  data
345
355
  end
346
356
 
347
- private_class_method def self.encode_coordinate_values(flags, deltas, axis)
357
+ private_class_method def self.encode_coordinate_values(flags, deltas,
358
+ axis)
348
359
  data = (+"").force_encoding(Encoding::BINARY)
349
360
  short_flag = axis == :x ? X_SHORT_VECTOR : Y_SHORT_VECTOR
350
361
  same_flag = axis == :x ? X_IS_SAME_OR_POSITIVE_X_SHORT : Y_IS_SAME_OR_POSITIVE_Y_SHORT
@@ -400,7 +411,10 @@ module Fontisan
400
411
  # Convert float to F2DOT14 fixed-point format
401
412
  # F2DOT14: 2 bits integer, 14 bits fractional
402
413
  # Range: -2.0 to ~1.99993896484375
403
- raise ArgumentError, "value out of F2DOT14 range" if value < -2.0 || value > 2.0
414
+ if value < -2.0 || value > 2.0
415
+ raise ArgumentError,
416
+ "value out of F2DOT14 range"
417
+ end
404
418
 
405
419
  fixed = (value * 16_384.0).round
406
420
  # Convert to unsigned 16-bit
@@ -434,7 +448,10 @@ module Fontisan
434
448
  end
435
449
 
436
450
  private_class_method def self.validate_component!(component)
437
- raise ArgumentError, "component must be Hash" unless component.is_a?(Hash)
451
+ unless component.is_a?(Hash)
452
+ raise ArgumentError,
453
+ "component must be Hash"
454
+ end
438
455
  unless component[:glyph_index]
439
456
  raise ArgumentError, "component must have :glyph_index"
440
457
  end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "stringio"
4
4
  require_relative "../binary/base_record"
5
+ require_relative "../variation/tuple_variation_header"
5
6
 
6
7
  module Fontisan
7
8
  module Tables
@@ -34,46 +35,6 @@ module Fontisan
34
35
  SHARED_POINT_NUMBERS = 0x8000
35
36
  LONG_OFFSETS = 0x0001
36
37
 
37
- # Tuple variation header
38
- class TupleVariationHeader < Binary::BaseRecord
39
- uint16 :variation_data_size
40
- uint16 :tuple_index
41
-
42
- # Tuple index flags
43
- EMBEDDED_PEAK_TUPLE = 0x8000
44
- INTERMEDIATE_REGION = 0x4000
45
- PRIVATE_POINT_NUMBERS = 0x2000
46
- TUPLE_INDEX_MASK = 0x0FFF
47
-
48
- # Check if tuple has embedded peak coordinates
49
- #
50
- # @return [Boolean] True if embedded
51
- def embedded_peak_tuple?
52
- (tuple_index & EMBEDDED_PEAK_TUPLE) != 0
53
- end
54
-
55
- # Check if tuple has intermediate region
56
- #
57
- # @return [Boolean] True if intermediate region
58
- def intermediate_region?
59
- (tuple_index & INTERMEDIATE_REGION) != 0
60
- end
61
-
62
- # Check if tuple has private point numbers
63
- #
64
- # @return [Boolean] True if private points
65
- def private_point_numbers?
66
- (tuple_index & PRIVATE_POINT_NUMBERS) != 0
67
- end
68
-
69
- # Get shared tuple index
70
- #
71
- # @return [Integer] Tuple index
72
- def shared_tuple_index
73
- tuple_index & TUPLE_INDEX_MASK
74
- end
75
- end
76
-
77
38
  # Get version as a float
78
39
  #
79
40
  # @return [Float] Version number (e.g., 1.0)
@@ -198,7 +159,7 @@ module Fontisan
198
159
  header_data = io.read(4)
199
160
  break if header_data.nil? || header_data.bytesize < 4
200
161
 
201
- header = TupleVariationHeader.read(header_data)
162
+ header = Variation::TupleVariationHeader.read(header_data)
202
163
 
203
164
  tuple_info = {
204
165
  data_size: header.variation_data_size,
@@ -173,13 +173,13 @@ module Fontisan
173
173
  record = find_name_record(
174
174
  name_id,
175
175
  platform: PLATFORM_WINDOWS,
176
- language: WINDOWS_LANGUAGE_EN_US
176
+ language: WINDOWS_LANGUAGE_EN_US,
177
177
  )
178
178
 
179
179
  record ||= find_name_record(
180
180
  name_id,
181
181
  platform: PLATFORM_MACINTOSH,
182
- language: MAC_LANGUAGE_ENGLISH
182
+ language: MAC_LANGUAGE_ENGLISH,
183
183
  )
184
184
 
185
185
  return nil unless record
@@ -236,10 +236,10 @@ module Fontisan
236
236
  decoded = case record.platform_id
237
237
  when PLATFORM_WINDOWS, PLATFORM_UNICODE
238
238
  string_data.dup.force_encoding("UTF-16BE")
239
- .encode("UTF-8", invalid: :replace, undef: :replace)
239
+ .encode("UTF-8", invalid: :replace, undef: :replace)
240
240
  when PLATFORM_MACINTOSH
241
241
  string_data.dup.force_encoding("ASCII-8BIT")
242
- .encode("UTF-8", invalid: :replace, undef: :replace)
242
+ .encode("UTF-8", invalid: :replace, undef: :replace)
243
243
  else
244
244
  string_data.dup.force_encoding("UTF-8")
245
245
  end
@@ -233,7 +233,8 @@ module Fontisan
233
233
  batch_entries.each do |entry|
234
234
  relative_offset = entry.offset - batch_offset
235
235
  tag_key = entry.tag.dup.force_encoding("UTF-8")
236
- @table_data[tag_key] = batch_data[relative_offset, entry.table_length]
236
+ @table_data[tag_key] =
237
+ batch_data[relative_offset, entry.table_length]
237
238
  end
238
239
  end
239
240
 
@@ -279,6 +280,20 @@ module Fontisan
279
280
  true
280
281
  end
281
282
 
283
+ # Check if font is TrueType flavored
284
+ #
285
+ # @return [Boolean] true for TrueType fonts
286
+ def truetype?
287
+ true
288
+ end
289
+
290
+ # Check if font is CFF flavored
291
+ #
292
+ # @return [Boolean] false for TrueType fonts
293
+ def cff?
294
+ false
295
+ end
296
+
282
297
  # Check if font has a specific table
283
298
  #
284
299
  # @param tag [String] The table tag to check for
@@ -293,6 +308,7 @@ module Fontisan
293
308
  # @return [Boolean] true if table is available in current mode
294
309
  def table_available?(tag)
295
310
  return false unless has_table?(tag)
311
+
296
312
  LoadingModes.table_allowed?(@loading_mode, tag)
297
313
  end
298
314
 
@@ -579,18 +595,19 @@ module Fontisan
579
595
  # @param path [String] Path to the TTF file
580
596
  # @return [void]
581
597
  def update_checksum_adjustment_in_file(path)
582
- # Calculate file checksum
583
- checksum = Utilities::ChecksumCalculator.calculate_file_checksum(path)
598
+ # Use tempfile-based checksum calculation for Windows compatibility
599
+ # This keeps the tempfile alive until we're done with the checksum
600
+ File.open(path, "r+b") do |io|
601
+ checksum, _tmpfile = Utilities::ChecksumCalculator.calculate_checksum_from_io_with_tempfile(io)
584
602
 
585
- # Calculate adjustment
586
- adjustment = Utilities::ChecksumCalculator.calculate_adjustment(checksum)
603
+ # Calculate adjustment
604
+ adjustment = Utilities::ChecksumCalculator.calculate_adjustment(checksum)
587
605
 
588
- # Find head table position
589
- head_entry = head_table
590
- return unless head_entry
606
+ # Find head table position
607
+ head_entry = head_table
608
+ return unless head_entry
591
609
 
592
- # Write adjustment to head table (offset 8 within head table)
593
- File.open(path, "r+b") do |io|
610
+ # Write adjustment to head table (offset 8 within head table)
594
611
  io.seek(head_entry.offset + 8)
595
612
  io.write([adjustment].pack("N"))
596
613
  end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ # Extensions to TrueTypeFont for table-based construction
5
+ class TrueTypeFont
6
+ # Create font from hash of tables
7
+ #
8
+ # This is used during font conversion when we have tables but not a file.
9
+ #
10
+ # @param tables [Hash<String, String>] Map of table tag to binary data
11
+ # @return [TrueTypeFont] New font instance
12
+ def self.from_tables(tables)
13
+ # Create minimal header structure
14
+ font = new
15
+ font.initialize_storage
16
+ font.loading_mode = LoadingModes::FULL
17
+
18
+ # Store table data
19
+ font.table_data = tables
20
+
21
+ # Build header from tables
22
+ num_tables = tables.size
23
+ max_power = 0
24
+ n = num_tables
25
+ while n > 1
26
+ n >>= 1
27
+ max_power += 1
28
+ end
29
+
30
+ search_range = (1 << max_power) * 16
31
+ entry_selector = max_power
32
+ range_shift = (num_tables * 16) - search_range
33
+
34
+ font.header.sfnt_version = 0x00010000 # TrueType
35
+ font.header.num_tables = num_tables
36
+ font.header.search_range = search_range
37
+ font.header.entry_selector = entry_selector
38
+ font.header.range_shift = range_shift
39
+
40
+ # Build table directory
41
+ font.tables.clear
42
+ tables.each_key do |tag|
43
+ entry = TableDirectory.new
44
+ entry.tag = tag
45
+ entry.checksum = 0 # Will be calculated on write
46
+ entry.offset = 0 # Will be calculated on write
47
+ entry.table_length = tables[tag].bytesize
48
+ font.tables << entry
49
+ end
50
+
51
+ font
52
+ end
53
+ end
54
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "stringio"
4
+ require "tempfile"
4
5
  require_relative "../constants"
5
6
 
6
7
  module Fontisan
@@ -101,6 +102,47 @@ module Fontisan
101
102
  sum
102
103
  end
103
104
 
105
+ # Calculate checksum from an IO object using a tempfile for Windows compatibility.
106
+ #
107
+ # This method creates a temporary file from the IO content to ensure proper
108
+ # file handle semantics on Windows, where file handles must remain open
109
+ # for checksum calculation. The tempfile reference is returned alongside
110
+ # the checksum to prevent premature garbage collection on Windows.
111
+ #
112
+ # @param io [IO] the IO object to read from (must be rewindable)
113
+ # @return [Array<Integer, Tempfile>] array containing [checksum, tempfile]
114
+ # The checksum value and the tempfile that must be kept alive until
115
+ # the caller is done with the checksum.
116
+ #
117
+ # @example
118
+ # checksum, tmpfile = ChecksumCalculator.calculate_checksum_from_io_with_tempfile(io)
119
+ # # Use checksum...
120
+ # # tmpfile will be GC'd when it goes out of scope, which is safe
121
+ #
122
+ # @note On Windows, Ruby's Tempfile automatically deletes the temp file when
123
+ # the Tempfile object is garbage collected. In multi-threaded environments,
124
+ # this can cause PermissionDenied errors if the file is deleted while
125
+ # another thread is still using it. By returning the tempfile reference,
126
+ # the caller can ensure it remains alive until all operations complete.
127
+ def self.calculate_checksum_from_io_with_tempfile(io)
128
+ io.rewind
129
+
130
+ # Create a tempfile to handle Windows file locking issues
131
+ tmpfile = Tempfile.new(["font", ".ttf"])
132
+ tmpfile.binmode
133
+
134
+ # Copy IO content to tempfile
135
+ IO.copy_stream(io, tmpfile)
136
+ tmpfile.close
137
+
138
+ # Calculate checksum from the tempfile
139
+ checksum = calculate_file_checksum(tmpfile.path)
140
+
141
+ # Return both checksum and tempfile to keep it alive
142
+ # The caller must keep the tempfile reference until done with checksum
143
+ [checksum, tmpfile]
144
+ end
145
+
104
146
  private_class_method :calculate_checksum_from_io
105
147
  end
106
148
  end
@@ -114,7 +114,7 @@ module Fontisan
114
114
  skip_tables = @checksum_config["skip_tables"] || []
115
115
 
116
116
  font.tables.each do |table_entry|
117
- tag = table_entry.tag
117
+ tag = table_entry.tag.to_s # Convert BinData field to string
118
118
 
119
119
  # Skip tables that are exempt from checksum validation
120
120
  next if skip_tables.include?(tag)
@@ -125,7 +125,7 @@ module Fontisan
125
125
 
126
126
  # Calculate checksum for the table
127
127
  calculated_checksum = calculate_table_checksum(table_data)
128
- declared_checksum = table_entry.checksum
128
+ declared_checksum = table_entry.checksum.to_i # Convert BinData field to integer
129
129
 
130
130
  # Special handling for head table (checksum adjustment field should be 0)
131
131
  if tag == Constants::HEAD_TAG
@@ -84,7 +84,7 @@ module Fontisan
84
84
  next if font.has_table?(table_tag)
85
85
 
86
86
  # Special case: CFF or CFF2 are alternatives
87
- if (table_tag == "CFF") && font.has_table?("CFF2")
87
+ if (table_tag == Constants::CFF_TAG) && font.has_table?("CFF2")
88
88
  next
89
89
  end
90
90