fontisan 0.4.43 → 0.4.44

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 (124) hide show
  1. checksums.yaml +4 -4
  2. data/TODO.bug-fixes/12-type1-generators-duck-typing.md +80 -0
  3. data/TODO.bug-fixes/13-hint-extractor-duck-typing.md +30 -0
  4. data/TODO.bug-fixes/14-font-interface-type-checks.md +82 -0
  5. data/TODO.bug-fixes/15-glyph-type-checks-and-simple-glyph-points-bug.md +49 -0
  6. data/TODO.bug-fixes/16-variation-table-checks.md +31 -0
  7. data/TODO.bug-fixes/17-ufo-and-stitcher-cleanup.md +32 -0
  8. data/TODO.bug-fixes/18-generic-value-and-io-checks.md +56 -0
  9. data/TODO.bug-fixes/19-serialization-migration.md +52 -0
  10. data/TODO.bug-fixes/20-variation-validator-type-checks.md +29 -0
  11. data/TODO.bug-fixes/21-xml-builder-send.md +24 -0
  12. data/TODO.bug-fixes/22-type1-converter-font-info.md +25 -0
  13. data/TODO.bug-fixes/23-boundary-type-checks.md +31 -0
  14. data/TODO.bug-fixes/24-validator-dynamic-field-key.md +25 -0
  15. data/TODO.bug-fixes/25-serialization-migration.md +49 -0
  16. data/TODO.bug-fixes/26-variation-preserver-spec-migration.md +25 -0
  17. data/TODO.bug-fixes/27-table-class-registry.md +50 -0
  18. data/TODO.bug-fixes/28-spec-doubles-cleanup.md +40 -0
  19. data/TODO.bug-fixes/29-strategy-registries.md +30 -0
  20. data/TODO.bug-fixes/README.md +24 -0
  21. data/lib/fontisan/binary/base_record.rb +13 -12
  22. data/lib/fontisan/collection/builder.rb +3 -5
  23. data/lib/fontisan/collection/dfont_builder.rb +4 -11
  24. data/lib/fontisan/collection/writer.rb +1 -1
  25. data/lib/fontisan/commands/pack_command.rb +4 -4
  26. data/lib/fontisan/commands/unpack_command.rb +1 -1
  27. data/lib/fontisan/commands/validate_command.rb +1 -1
  28. data/lib/fontisan/converters/format_converter.rb +2 -2
  29. data/lib/fontisan/converters/glyf_table_builder.rb +1 -1
  30. data/lib/fontisan/converters/outline_converter.rb +2 -6
  31. data/lib/fontisan/converters/table_copier.rb +4 -12
  32. data/lib/fontisan/converters/type1_converter.rb +12 -37
  33. data/lib/fontisan/converters/woff2_encoder.rb +8 -10
  34. data/lib/fontisan/converters/woff_writer.rb +3 -3
  35. data/lib/fontisan/export/exporter.rb +8 -9
  36. data/lib/fontisan/export/table_serializer.rb +71 -186
  37. data/lib/fontisan/export/transformers/font_to_ttx.rb +18 -18
  38. data/lib/fontisan/export/transformers/head_transformer.rb +1 -1
  39. data/lib/fontisan/export/transformers/hhea_transformer.rb +1 -1
  40. data/lib/fontisan/export/transformers/maxp_transformer.rb +1 -1
  41. data/lib/fontisan/export/transformers/name_transformer.rb +1 -1
  42. data/lib/fontisan/export/transformers/os2_transformer.rb +1 -1
  43. data/lib/fontisan/export/transformers/post_transformer.rb +1 -1
  44. data/lib/fontisan/export/ttx_generator.rb +36 -37
  45. data/lib/fontisan/font_writer.rb +1 -1
  46. data/lib/fontisan/glyph_accessor.rb +7 -9
  47. data/lib/fontisan/hints/postscript_hint_extractor.rb +24 -64
  48. data/lib/fontisan/hints/truetype_hint_extractor.rb +1 -1
  49. data/lib/fontisan/models/ttx/tables/binary_table.rb +1 -1
  50. data/lib/fontisan/optimizers/charstring_rewriter.rb +1 -1
  51. data/lib/fontisan/sfnt_font.rb +2 -0
  52. data/lib/fontisan/sfnt_source.rb +28 -0
  53. data/lib/fontisan/stitcher/source.rb +8 -10
  54. data/lib/fontisan/subset/builder.rb +2 -2
  55. data/lib/fontisan/subset/table_strategy/cff2.rb +2 -3
  56. data/lib/fontisan/svg_to_glyf/assembler.rb +59 -20
  57. data/lib/fontisan/svg_to_glyf/document.rb +28 -9
  58. data/lib/fontisan/svg_to_glyf/geometry/normalizer.rb +34 -17
  59. data/lib/fontisan/tables/cff/charstring.rb +3 -0
  60. data/lib/fontisan/tables/cff/dict.rb +6 -3
  61. data/lib/fontisan/tables/cff/index.rb +1 -1
  62. data/lib/fontisan/tables/cff/private_dict_writer.rb +2 -2
  63. data/lib/fontisan/tables/cff/table_builder.rb +1 -1
  64. data/lib/fontisan/tables/cff.rb +3 -0
  65. data/lib/fontisan/tables/cff2/table_builder.rb +2 -4
  66. data/lib/fontisan/tables/cff2.rb +3 -0
  67. data/lib/fontisan/tables/cmap.rb +3 -0
  68. data/lib/fontisan/tables/cvar.rb +3 -0
  69. data/lib/fontisan/tables/fvar.rb +3 -0
  70. data/lib/fontisan/tables/glyf/simple_glyph.rb +9 -0
  71. data/lib/fontisan/tables/glyf.rb +12 -8
  72. data/lib/fontisan/tables/glyf_table.rb +1 -1
  73. data/lib/fontisan/tables/gvar.rb +3 -0
  74. data/lib/fontisan/tables/head.rb +3 -0
  75. data/lib/fontisan/tables/hhea.rb +3 -0
  76. data/lib/fontisan/tables/hmtx.rb +3 -7
  77. data/lib/fontisan/tables/hvar.rb +4 -0
  78. data/lib/fontisan/tables/loca.rb +3 -0
  79. data/lib/fontisan/tables/maxp.rb +3 -0
  80. data/lib/fontisan/tables/mvar.rb +3 -0
  81. data/lib/fontisan/tables/name.rb +3 -0
  82. data/lib/fontisan/tables/os2.rb +3 -0
  83. data/lib/fontisan/tables/post.rb +3 -0
  84. data/lib/fontisan/tables/registry.rb +71 -0
  85. data/lib/fontisan/tables/vvar.rb +3 -0
  86. data/lib/fontisan/tables.rb +2 -0
  87. data/lib/fontisan/type1/afm_generator.rb +12 -65
  88. data/lib/fontisan/type1/charstring_converter.rb +2 -2
  89. data/lib/fontisan/type1/conversion_options.rb +3 -2
  90. data/lib/fontisan/type1/decryptor.rb +1 -1
  91. data/lib/fontisan/type1/generator.rb +14 -9
  92. data/lib/fontisan/type1/inf_generator.rb +10 -35
  93. data/lib/fontisan/type1/pfa_generator.rb +36 -26
  94. data/lib/fontisan/type1/pfb_generator.rb +24 -12
  95. data/lib/fontisan/type1/pfm_generator.rb +14 -31
  96. data/lib/fontisan/type1/ttf_to_type1_converter.rb +30 -24
  97. data/lib/fontisan/ufo/bounds.rb +91 -0
  98. data/lib/fontisan/ufo/compile/fvar.rb +2 -2
  99. data/lib/fontisan/ufo/contour.rb +5 -0
  100. data/lib/fontisan/ufo/convert/from_bin_data.rb +15 -29
  101. data/lib/fontisan/ufo/info.rb +4 -0
  102. data/lib/fontisan/ufo/point.rb +0 -7
  103. data/lib/fontisan/ufo.rb +1 -0
  104. data/lib/fontisan/utilities/brotli_wrapper.rb +3 -5
  105. data/lib/fontisan/utilities/padding.rb +9 -8
  106. data/lib/fontisan/validation/collection_validator.rb +2 -2
  107. data/lib/fontisan/validators/validator.rb +4 -4
  108. data/lib/fontisan/variable/delta_applicator.rb +4 -11
  109. data/lib/fontisan/variable/metric_delta_processor.rb +3 -3
  110. data/lib/fontisan/variable/static_font_builder.rb +19 -23
  111. data/lib/fontisan/variation/instance_font_wrapper.rb +41 -0
  112. data/lib/fontisan/variation/instance_writer.rb +19 -17
  113. data/lib/fontisan/variation/metrics_adjuster.rb +17 -20
  114. data/lib/fontisan/variation/validator.rb +26 -38
  115. data/lib/fontisan/variation/variable_svg_generator.rb +0 -48
  116. data/lib/fontisan/variation/variation_preserver.rb +2 -3
  117. data/lib/fontisan/variation.rb +1 -0
  118. data/lib/fontisan/version.rb +1 -1
  119. data/lib/fontisan/woff2/sfnt_checksum.rb +1 -1
  120. data/lib/fontisan/woff2/table_transformer.rb +22 -29
  121. data/lib/fontisan/woff2_font.rb +9 -8
  122. data/lib/fontisan/woff_font.rb +2 -0
  123. data/lib/fontisan.rb +1 -0
  124. metadata +24 -2
@@ -134,15 +134,11 @@ module Fontisan
134
134
 
135
135
  # Font info
136
136
  if name_table
137
- if name_table.respond_to?(:version_string)
138
- version = name_table.version_string(1) || name_table.version_string(3)
139
- dict << "/Version (#{version}) def" if version
140
- end
137
+ version = name_table.english_name(Tables::Name::VERSION)
138
+ dict << "/Version (#{version}) def" if version
141
139
 
142
- if name_table.respond_to?(:copyright)
143
- copyright = name_table.copyright(1) || name_table.copyright(3)
144
- dict << "/Notice (#{copyright}) def" if copyright
145
- end
140
+ copyright = name_table.english_name(Tables::Name::COPYRIGHT)
141
+ dict << "/Notice (#{copyright}) def" if copyright
146
142
  end
147
143
 
148
144
  dict << "currentdict end"
@@ -161,12 +157,12 @@ module Fontisan
161
157
  # Blue values (for hinting)
162
158
  # These are typically derived from the font's alignment zones
163
159
  os2 = @font.table(Constants::OS2_TAG)
164
- if os2.respond_to?(:typo_ascender) && os2.typo_ascender
160
+ if os2&.s_typo_ascender
165
161
  blue_values = [
166
- @scaler.scale(os2.typo_descender || -200),
167
- @scaler.scale(os2.typo_descender || -200) + 20,
168
- @scaler.scale(os2.typo_ascender),
169
- @scaler.scale(os2.typo_ascender) + 10,
162
+ @scaler.scale(os2.s_typo_descender || -200),
163
+ @scaler.scale(os2.s_typo_descender || -200) + 20,
164
+ @scaler.scale(os2.s_typo_ascender),
165
+ @scaler.scale(os2.s_typo_ascender) + 10,
170
166
  ]
171
167
  dict << "/BlueValues {#{blue_values.join(' ')}} def"
172
168
  else
@@ -178,15 +174,15 @@ module Fontisan
178
174
  dict << "/BlueFuzz 1 def"
179
175
 
180
176
  # Stem snap hints
181
- if os2.respond_to?(:weight_class) && os2.weight_class
177
+ if os2&.us_weight_class
182
178
  stem_width = @scaler.scale([100, 80,
183
- 90][os2.weight_class / 100] || 80)
179
+ 90][os2.us_weight_class / 100] || 80)
184
180
  dict << "/StemSnapH [#{stem_width}] def"
185
181
  dict << "/StemSnapV [#{stem_width}] def"
186
182
  end
187
183
 
188
184
  # Force bold flag
189
- dict << if os2.respond_to?(:weight_class) && os2.weight_class && os2.weight_class >= 700
185
+ dict << if os2&.us_weight_class && os2.us_weight_class >= 700
190
186
  "/ForceBold true def"
191
187
  else
192
188
  "/ForceBold false def"
@@ -286,33 +282,33 @@ module Fontisan
286
282
 
287
283
  # Generate a simple CharString for a glyph
288
284
  #
289
- # @param glyf_table [Object] TTF glyf table
285
+ # @param glyf_table [Tables::Glyf] TTF glyf table
290
286
  # @param gid [Integer] Glyph ID
291
287
  # @return [String] Type 1 CharString data
292
288
  def simple_charstring(glyf_table, gid)
293
289
  glyph = glyf_table.glyph(gid)
294
290
 
295
291
  # Empty or compound glyph
296
- if glyph.nil? || glyph.contour_count.zero? || glyph.compound?
292
+ if glyph.nil? || glyph.num_contours.zero? || glyph.compound?
297
293
  # Return empty charstring (hsbw + endchar)
298
294
  return [0, 500, 14].pack("C*")
299
295
  end
300
296
 
301
297
  # For simple glyphs without curve conversion, generate minimal charstring
302
- lsb = @scaler.scale(glyph.left_side_bearing || 0)
303
- width = @scaler.scale(glyph.advance_width || 500)
304
- bytes = [13, lsb, width] # hsbw command (13)
298
+ lsb, width = hmtx_metrics_for(gid)
299
+ bytes = [13, @scaler.scale(lsb), @scaler.scale(width)] # hsbw command (13)
305
300
 
306
301
  # Add simple line commands (very basic)
307
- if glyph.respond_to?(:points) && glyph.points && !glyph.points.empty?
302
+ points = glyph.points
303
+ unless points.empty?
308
304
  # Just draw lines between consecutive on-curve points
309
305
  prev_point = nil
310
- glyph.points.each do |point|
311
- next unless point.on_curve?
306
+ points.each do |point|
307
+ next unless point[:on_curve]
312
308
 
313
309
  if prev_point
314
- dx = @scaler.scale(point.x) - @scaler.scale(prev_point.x)
315
- dy = @scaler.scale(point.y) - @scaler.scale(prev_point.y)
310
+ dx = @scaler.scale(point[:x]) - @scaler.scale(prev_point[:x])
311
+ dy = @scaler.scale(point[:y]) - @scaler.scale(prev_point[:y])
316
312
  bytes << 5 # rlineto
317
313
  bytes.concat(encode_number(dx))
318
314
  bytes.concat(encode_number(dy))
@@ -325,6 +321,20 @@ module Fontisan
325
321
  bytes.pack("C*")
326
322
  end
327
323
 
324
+ # Look up LSB and advance width from hmtx for a glyph.
325
+ #
326
+ # @param gid [Integer] Glyph ID
327
+ # @return [Array(Integer, Integer)] [lsb, advance_width]
328
+ def hmtx_metrics_for(gid)
329
+ hmtx = @font.table(Constants::HMTX_TAG)
330
+ return [0, 500] unless hmtx
331
+
332
+ metric = hmtx.metric_for(gid)
333
+ return [0, 500] unless metric
334
+
335
+ [metric[:lsb] || 0, metric[:advance_width] || 500]
336
+ end
337
+
328
338
  # Encode a number for Type 1 CharString
329
339
  #
330
340
  # @param value [Integer] Number to encode
@@ -159,15 +159,15 @@ module Fontisan
159
159
 
160
160
  # Stem snap hints
161
161
  os2 = @font.table(Constants::OS2_TAG)
162
- if os2.respond_to?(:weight_class)
162
+ if os2&.us_weight_class
163
163
  stem_width = @scaler.scale([100, 80,
164
- 90][os2.weight_class / 100] || 80)
164
+ 90][os2.us_weight_class / 100] || 80)
165
165
  dict << "/StemSnapH [#{stem_width}] def"
166
166
  dict << "/StemSnapV [#{stem_width}] def"
167
167
  end
168
168
 
169
169
  # Force bold flag
170
- dict << if os2.respond_to?(:weight_class) && os2.weight_class && os2.weight_class >= 700
170
+ dict << if os2&.us_weight_class && os2.us_weight_class >= 700
171
171
  "/ForceBold true def"
172
172
  else
173
173
  "/ForceBold false def"
@@ -261,30 +261,42 @@ module Fontisan
261
261
  glyph = glyf_table.glyph(gid)
262
262
 
263
263
  # Empty or compound glyph
264
- if glyph.nil? || glyph.contour_count.zero? || glyph.compound?
264
+ if glyph.nil? || glyph.num_contours.zero? || glyph.compound?
265
265
  # Return empty charstring (hsbw + endchar)
266
266
  return [0, 500, 14].pack("C*")
267
267
  end
268
268
 
269
269
  # For simple glyphs without curve conversion, generate line-based charstring
270
270
  # This is a simplified implementation
271
- lsb = @scaler.scale(glyph.left_side_bearing || 0)
272
- width = @scaler.scale(glyph.advance_width || 500)
273
- bytes = [0, lsb, width] # hsbw
271
+ lsb, width = hmtx_metrics_for(gid)
272
+ bytes = [0, @scaler.scale(lsb), @scaler.scale(width)] # hsbw
274
273
 
275
274
  # Add lines between points (simplified)
276
- if glyph.respond_to?(:points) && glyph.points && !glyph.points.empty?
277
- glyph.points.each do |point|
278
- next unless point.on_curve?
275
+ points = glyph.points
276
+ points.each do |point|
277
+ next unless point[:on_curve]
279
278
 
280
- # This is very simplified - proper implementation would handle curves
281
- end
279
+ # This is very simplified - proper implementation would handle curves
282
280
  end
283
281
 
284
282
  bytes << 14 # endchar
285
283
  bytes.pack("C*")
286
284
  end
287
285
 
286
+ # Look up LSB and advance width from hmtx for a glyph.
287
+ #
288
+ # @param gid [Integer] Glyph ID
289
+ # @return [Array(Integer, Integer)] [lsb, advance_width]
290
+ def hmtx_metrics_for(gid)
291
+ hmtx = @font.table(Constants::HMTX_TAG)
292
+ return [0, 500] unless hmtx
293
+
294
+ metric = hmtx.metric_for(gid)
295
+ return [0, 500] unless metric
296
+
297
+ [metric[:lsb] || 0, metric[:advance_width] || 500]
298
+ end
299
+
288
300
  # Build second ASCII segment (trailer)
289
301
  #
290
302
  # @return [String] ASCII trailer
@@ -145,7 +145,7 @@ module Fontisan
145
145
  return widths unless cmap
146
146
 
147
147
  # Get Unicode mappings
148
- mappings = if cmap.respond_to?(:unicode_mappings)
148
+ mappings = if cmap
149
149
  cmap.unicode_mappings || {}
150
150
  else
151
151
  {}
@@ -369,18 +369,14 @@ module Fontisan
369
369
  metrics << [0].pack("V")
370
370
 
371
371
  # etmCapHeight (4 bytes)
372
- cap_height = if os2.respond_to?(:cap_height) && os2.cap_height
373
- os2.cap_height
374
- elsif os2.respond_to?(:s_typo_ascender) && os2.s_typo_ascender
375
- os2.s_typo_ascender
376
- else
377
- @metrics.ascent || 1000
378
- end
372
+ cap_height = os2.s_cap_height ||
373
+ os2.s_typo_ascender ||
374
+ @metrics.ascent || 1000
379
375
  metrics << [@scaler.scale(cap_height)].pack("V")
380
376
 
381
377
  # etmXHeight (4 bytes)
382
- x_height = if os2.respond_to?(:x_height) && os2.x_height&.positive?
383
- os2.x_height
378
+ x_height = if os2.sx_height&.positive?
379
+ os2.sx_height
384
380
  else
385
381
  # Fallback: use roughly half the ascent for x-height
386
382
  (@metrics.ascent / 2) || 500
@@ -503,11 +499,7 @@ module Fontisan
503
499
  name_table = @font.table(Constants::NAME_TAG)
504
500
  return "" unless name_table
505
501
 
506
- if name_table.respond_to?(:copyright)
507
- name_table.copyright(1) || name_table.copyright(3) || ""
508
- else
509
- ""
510
- end
502
+ name_table.english_name(Tables::Name::COPYRIGHT) || ""
511
503
  end
512
504
 
513
505
  # Extract face name from font
@@ -515,18 +507,13 @@ module Fontisan
515
507
  # @return [String] Face name
516
508
  def extract_face_name
517
509
  name_table = @font.table(Constants::NAME_TAG)
518
- return "" unless name_table
510
+ return @font.post_script_name.to_s unless name_table
519
511
 
520
512
  # Try full font name first, then font family, then postscript name
521
- face_name = if name_table.respond_to?(:full_font_name)
522
- name_table.full_font_name(1) || name_table.full_font_name(3) || ""
523
- elsif name_table.respond_to?(:font_family)
524
- name_table.font_family(1) || name_table.font_family(3) || ""
525
- elsif name_table.respond_to?(:postscript_name)
526
- name_table.postscript_name(1) || name_table.postscript_name(3) || ""
527
- else
528
- @font.post_script_name || ""
529
- end
513
+ face_name = name_table.english_name(Tables::Name::FULL_NAME) ||
514
+ name_table.english_name(Tables::Name::FAMILY) ||
515
+ name_table.english_name(Tables::Name::POSTSCRIPT_NAME) ||
516
+ @font.post_script_name
530
517
 
531
518
  face_name.to_s
532
519
  end
@@ -538,11 +525,7 @@ module Fontisan
538
525
  os2 = @font.table(Constants::OS2_TAG)
539
526
  return 400 unless os2
540
527
 
541
- weight_class = if os2.respond_to?(:us_weight_class)
542
- os2.us_weight_class
543
- elsif os2.respond_to?(:weight_class)
544
- os2.weight_class
545
- end
528
+ weight_class = os2.us_weight_class
546
529
  return 400 unless weight_class
547
530
 
548
531
  # Map OS/2 weight class to PFM weight
@@ -564,7 +547,7 @@ module Fontisan
564
547
  # @return [Integer] Pitch and family byte
565
548
  def pitch_and_family_value
566
549
  post = @font.table(Constants::POST_TAG)
567
- is_fixed = post.respond_to?(:is_fixed_pitch) ? post.is_fixed_pitch : false
550
+ is_fixed = post&.is_fixed_pitch || false
568
551
 
569
552
  pitch = is_fixed ? FIXED_PITCH : VARIABLE_PITCH
570
553
 
@@ -97,23 +97,23 @@ module Fontisan
97
97
  end
98
98
 
99
99
  # Convert simple glyph
100
- convert_simple_glyph(glyph)
100
+ convert_simple_glyph(glyph, gid)
101
101
  end
102
102
 
103
103
  # Convert a simple glyph to CharString
104
104
  #
105
- # @param glyph [Object] TTF simple glyph
105
+ # @param glyph [Tables::SimpleGlyph] TTF simple glyph
106
+ # @param gid [Integer] Glyph ID (for hmtx lookup)
106
107
  # @return [String] Type 1 CharString data
107
- def convert_simple_glyph(glyph)
108
+ def convert_simple_glyph(glyph, gid)
108
109
  commands = []
109
110
  points = extract_points(glyph)
110
111
 
111
112
  return empty_charstring if points.empty?
112
113
 
113
114
  # Start with hsbw (horizontal side bearing and width)
114
- lsb = @scaler.scale(glyph.left_side_bearing || 0)
115
- width = @scaler.scale(glyph.advance_width || 500)
116
- commands << [HSBW, lsb, width]
115
+ lsb, width = hmtx_metrics_for(gid)
116
+ commands << [HSBW, @scaler.scale(lsb), @scaler.scale(width)]
117
117
 
118
118
  # Convert contours to Type 1 commands
119
119
  contour_commands = convert_contours(points)
@@ -126,23 +126,34 @@ module Fontisan
126
126
  encode_charstring(commands)
127
127
  end
128
128
 
129
- # Extract points from a simple glyph
129
+ # Extract points from a simple glyph, scaled to target UPM.
130
130
  #
131
- # @param glyph [Object] TTF simple glyph
132
- # @return [Array<Hash>] Array of points with on_curve flag
131
+ # @param glyph [Tables::SimpleGlyph] TTF simple glyph
132
+ # @return [Array<Hash>] Array of {x:, y:, on_curve:} hashes
133
133
  def extract_points(glyph)
134
- return [] unless glyph.respond_to?(:points)
135
-
136
- points = []
137
- glyph.points.each do |point|
138
- points << {
139
- x: @scaler.scale(point.x),
140
- y: @scaler.scale(point.y),
141
- on_curve: point.on_curve?,
134
+ return [] unless glyph.is_a?(Tables::SimpleGlyph)
135
+
136
+ glyph.points.map do |point|
137
+ {
138
+ x: @scaler.scale(point[:x]),
139
+ y: @scaler.scale(point[:y]),
140
+ on_curve: point[:on_curve],
142
141
  }
143
142
  end
143
+ end
144
+
145
+ # Look up LSB and advance width from hmtx for a glyph.
146
+ #
147
+ # @param gid [Integer] Glyph ID
148
+ # @return [Array(Integer, Integer)] [lsb, advance_width]
149
+ def hmtx_metrics_for(gid)
150
+ hmtx = @font.table(Constants::HMTX_TAG)
151
+ return [0, 500] unless hmtx
152
+
153
+ metric = hmtx.metric_for(gid)
154
+ return [0, 500] unless metric
144
155
 
145
- points
156
+ [metric[:lsb] || 0, metric[:advance_width] || 500]
146
157
  end
147
158
 
148
159
  # Convert contours to Type 1 commands
@@ -298,12 +309,7 @@ module Fontisan
298
309
  dx = tx - transform_x(prev_x, prev_y, matrix)
299
310
  dy = ty - transform_y(prev_x, prev_y, matrix)
300
311
 
301
- on_curve = pt[:on_curve].nil? || pt[:on_curve]
302
- if on_curve
303
- commands << [RLINETO, dx.to_i, dy.to_i]
304
- else
305
- commands << [RLINETO, dx.to_i, dy.to_i]
306
- end
312
+ commands << [RLINETO, dx.to_i, dy.to_i]
307
313
  prev_x = pt[:x].to_f
308
314
  prev_y = pt[:y].to_f
309
315
  end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Ufo
5
+ # Immutable axis-aligned bounding box over outline points.
6
+ #
7
+ # Bounds is a value object: equality and hash are based on the four
8
+ # coordinates, not identity. Bounds composes via #union so callers
9
+ # can fold bounds across many contours or many glyphs without
10
+ # mutating either side.
11
+ class Bounds
12
+ include Comparable
13
+
14
+ attr_reader :min_x, :min_y, :max_x, :max_y
15
+
16
+ def initialize(min_x:, min_y:, max_x:, max_y:)
17
+ @min_x = min_x.to_f
18
+ @min_y = min_y.to_f
19
+ @max_x = max_x.to_f
20
+ @max_y = max_y.to_f
21
+ end
22
+
23
+ # @param contours [Array<Contour>, Enumerable<Contour>]
24
+ # @return [Bounds]
25
+ def self.measure(contours)
26
+ min_x = min_y = +Float::INFINITY
27
+ max_x = max_y = -Float::INFINITY
28
+ saw_point = false
29
+
30
+ contours.each do |contour|
31
+ contour.points.each do |pt|
32
+ saw_point = true
33
+ min_x = pt.x if pt.x < min_x
34
+ min_y = pt.y if pt.y < min_y
35
+ max_x = pt.x if pt.x > max_x
36
+ max_y = pt.y if pt.y > max_y
37
+ end
38
+ end
39
+
40
+ return empty unless saw_point
41
+
42
+ new(min_x: min_x, min_y: min_y, max_x: max_x, max_y: max_y)
43
+ end
44
+
45
+ # @return [Bounds] zero-extent bounds anchored at the origin
46
+ def self.empty
47
+ new(min_x: 0, min_y: 0, max_x: 0, max_y: 0)
48
+ end
49
+
50
+ def width
51
+ max_x - min_x
52
+ end
53
+
54
+ def height
55
+ max_y - min_y
56
+ end
57
+
58
+ # A bounds is empty when it has zero extent in both axes. Note that
59
+ # a contour containing a single point yields zero-extent bounds but
60
+ # is not "empty" in the geometric sense — callers that need to
61
+ # distinguish should test the contour directly.
62
+ def empty?
63
+ width.zero? && height.zero?
64
+ end
65
+
66
+ # Smallest bounds containing both self and other.
67
+ #
68
+ # @param other [Bounds]
69
+ # @return [Bounds]
70
+ def union(other)
71
+ self.class.new(
72
+ min_x: [@min_x, other.min_x].min,
73
+ min_y: [@min_y, other.min_y].min,
74
+ max_x: [@max_x, other.max_x].max,
75
+ max_y: [@max_y, other.max_y].max,
76
+ )
77
+ end
78
+
79
+ def <=>(other)
80
+ return nil unless other.is_a?(Bounds)
81
+
82
+ [@min_x, @min_y, @max_x, @max_y] <=>
83
+ [other.min_x, other.min_y, other.max_x, other.max_y]
84
+ end
85
+
86
+ def hash
87
+ [@min_x, @min_y, @max_x, @max_y].hash
88
+ end
89
+ end
90
+ end
91
+ end
@@ -20,8 +20,8 @@ module Fontisan
20
20
  # @param instances [Array<Hash>] named instance definitions
21
21
  # @return [String, nil] fvar table bytes, or nil if no axes
22
22
  def self.build(font, axes: nil, instances: nil)
23
- axes ||= font.info.respond_to?(:axes) ? font.info.axes : nil
24
- instances ||= font.info.respond_to?(:named_instances) ? font.info.named_instances : nil
23
+ axes ||= font.info.axes
24
+ instances ||= font.info.named_instances
25
25
 
26
26
  axes ||= []
27
27
  return nil if axes.empty?
@@ -24,6 +24,11 @@ module Fontisan
24
24
  def point_count
25
25
  @points.size
26
26
  end
27
+
28
+ # @return [Bounds] extents of this contour's points
29
+ def bounds
30
+ Bounds.measure([self])
31
+ end
27
32
  end
28
33
  end
29
34
  end
@@ -61,8 +61,7 @@ module Fontisan
61
61
  name_table = font.table("name")
62
62
  return unless name_table
63
63
 
64
- records = name_table.respond_to?(:name_records) ? name_table.name_records : []
65
- records.each do |record|
64
+ name_table.name_records.each do |record|
66
65
  next unless record.platform_id == 3 && record.encoding_id == 1 # Windows Unicode BMP
67
66
 
68
67
  value = decode_name_value(record, name_table)
@@ -79,9 +78,7 @@ module Fontisan
79
78
  end
80
79
 
81
80
  def self.decode_name_value(record, name_table)
82
- raw = if name_table.respond_to?(:string_for_record)
83
- name_table.string_for_record(record)
84
- end
81
+ raw = name_table.string_for_record(record)
85
82
 
86
83
  if raw && raw.encoding == Encoding::UTF_16BE
87
84
  raw.encode("UTF-8")
@@ -100,12 +97,7 @@ module Fontisan
100
97
  post = font.table("post")
101
98
  return unless post
102
99
 
103
- if post.respond_to?(:italic_angle)
104
- info.italic_angle = post.italic_angle
105
- elsif post.respond_to?(:italic_angle_raw)
106
- raw = post.italic_angle_raw
107
- info.italic_angle = raw.to_i / 65536.0
108
- end
100
+ info.italic_angle = post.italic_angle
109
101
  rescue NoMethodError
110
102
  # post table may not have italic_angle
111
103
  end
@@ -128,7 +120,7 @@ module Fontisan
128
120
  cmap_table = font.table("cmap")
129
121
  return {} unless cmap_table
130
122
 
131
- mappings = cmap_table.respond_to?(:unicode_mappings) ? cmap_table.unicode_mappings : {}
123
+ mappings = cmap_table.unicode_mappings || {}
132
124
  # Invert: gid → [codepoints]
133
125
  inverted = Hash.new { |h, k| h[k] = [] }
134
126
  mappings.each { |cp, gid| inverted[gid] << cp }
@@ -146,13 +138,11 @@ module Fontisan
146
138
  num_glyphs = maxp&.num_glyphs || 0
147
139
 
148
140
  # Hmtx requires context-aware parsing before metric_for works.
149
- if hmtx.respond_to?(:parse_with_context)
150
- hmtx.parse_with_context(num_h_metrics, num_glyphs)
151
- end
141
+ hmtx.parse_with_context(num_h_metrics, num_glyphs)
152
142
 
153
143
  widths = {}
154
144
  num_glyphs.times do |gid|
155
- metric = hmtx.respond_to?(:metric_for) ? hmtx.metric_for(gid) : nil
145
+ metric = hmtx.metric_for(gid)
156
146
  widths[gid] = metric ? metric[:advance_width] : 0
157
147
  end
158
148
  widths
@@ -169,10 +159,8 @@ module Fontisan
169
159
  return unless glyf && loca && head
170
160
 
171
161
  # Tables need context-aware initialization before per-glyph access.
172
- if loca.respond_to?(:parse_with_context)
173
- loca.parse_with_context(head.index_to_loc_format,
174
- num_glyphs)
175
- end
162
+ loca.parse_with_context(head.index_to_loc_format,
163
+ num_glyphs)
176
164
 
177
165
  num_glyphs.times do |gid|
178
166
  glyph_name = glyph_name_for(font, gid) || "glyph#{gid}"
@@ -189,7 +177,7 @@ module Fontisan
189
177
 
190
178
  if simple.is_a?(Fontisan::Tables::SimpleGlyph)
191
179
  extract_simple_contours(simple, ufo_glyph)
192
- elsif simple.respond_to?(:compound?) && simple.compound?
180
+ elsif simple.is_a?(Fontisan::Tables::CompoundGlyph) && simple.compound?
193
181
  extract_compound_contours(simple, ufo_glyph, glyf, loca, head)
194
182
  end
195
183
 
@@ -309,7 +297,7 @@ module Fontisan
309
297
  visited = visited.dup.add(compound.glyph_id)
310
298
 
311
299
  compound.components.each do |component|
312
- next unless component.respond_to?(:args_are_xy?) ? component.args_are_xy? : true
300
+ next unless component.args_are_xy?
313
301
 
314
302
  raw = begin
315
303
  glyf.glyph_for(component.glyph_index, loca, head)
@@ -320,9 +308,9 @@ module Fontisan
320
308
 
321
309
  matrix = component.transformation_matrix
322
310
 
323
- if raw.respond_to?(:simple?) && raw.simple?
311
+ if raw.is_a?(Fontisan::Tables::SimpleGlyph) && raw.simple?
324
312
  flatten_simple_component(raw, ufo_glyph, matrix)
325
- elsif raw.respond_to?(:compound?) && raw.compound?
313
+ elsif raw.is_a?(Fontisan::Tables::CompoundGlyph) && raw.compound?
326
314
  flatten_compound(raw, ufo_glyph, glyf, loca, head, visited, depth + 1)
327
315
  end
328
316
  end
@@ -358,12 +346,10 @@ module Fontisan
358
346
  post = font.table("post")
359
347
  return nil unless post
360
348
 
361
- if post.respond_to?(:glyph_name)
362
- name = post.glyph_name(gid)
363
- return name unless name.nil? || name.empty?
364
- end
349
+ name = post.glyph_name(gid)
350
+ return nil if name.nil? || name.empty?
365
351
 
366
- nil
352
+ name
367
353
  rescue NoMethodError
368
354
  nil
369
355
  end
@@ -26,6 +26,10 @@ module Fontisan
26
26
 
27
27
  attr_accessor(*STANDARD_FIELDS)
28
28
 
29
+ # Variation font metadata. Populated by UFO 3 extensions or by
30
+ # explicit compiler configuration; nil when not set.
31
+ attr_accessor :axes, :named_instances
32
+
29
33
  # Catch-all for non-standard (vendor-specific) fields.
30
34
  attr_accessor :extras
31
35
 
@@ -27,13 +27,6 @@ module Fontisan
27
27
  def off_curve?
28
28
  @type == "offcurve" || @type == "qcurve"
29
29
  end
30
-
31
- # @return [Hash] suitable for `to_glif` output
32
- def to_h
33
- h = { x: @x, y: @y, type: @type }
34
- h[:smooth] = true if @smooth
35
- h
36
- end
37
30
  end
38
31
  end
39
32
  end
data/lib/fontisan/ufo.rb CHANGED
@@ -24,6 +24,7 @@ module Fontisan
24
24
  autoload :LayerSet, "fontisan/ufo/layer_set"
25
25
  autoload :Glyph, "fontisan/ufo/glyph"
26
26
  autoload :Contour, "fontisan/ufo/contour"
27
+ autoload :Bounds, "fontisan/ufo/bounds"
27
28
  autoload :Point, "fontisan/ufo/point"
28
29
  autoload :Component, "fontisan/ufo/component"
29
30
  autoload :Anchor, "fontisan/ufo/anchor"
@@ -129,13 +129,11 @@ module Fontisan
129
129
  # @param data [String] Data to validate
130
130
  # @raise [ArgumentError] If data is invalid
131
131
  def validate_data!(data)
132
- if data.nil?
133
- raise ArgumentError, "Data cannot be nil"
134
- end
132
+ raise ArgumentError, "Data cannot be nil" if data.nil?
135
133
 
136
- unless data.respond_to?(:bytesize)
134
+ unless data.is_a?(String)
137
135
  raise ArgumentError,
138
- "Data must be a String-like object, got #{data.class}"
136
+ "Data must be a String, got #{data.class}"
139
137
  end
140
138
  end
141
139
  end