fontisan 0.4.42 → 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 (130) hide show
  1. checksums.yaml +4 -4
  2. data/TODO.bug-fixes/09-type1-cff-seac-expansion.md +22 -0
  3. data/TODO.bug-fixes/10-ttf-type1-composite-glyphs.md +23 -0
  4. data/TODO.bug-fixes/11-remaining-respond-to.md +25 -0
  5. data/TODO.bug-fixes/12-type1-generators-duck-typing.md +80 -0
  6. data/TODO.bug-fixes/13-hint-extractor-duck-typing.md +30 -0
  7. data/TODO.bug-fixes/14-font-interface-type-checks.md +82 -0
  8. data/TODO.bug-fixes/15-glyph-type-checks-and-simple-glyph-points-bug.md +49 -0
  9. data/TODO.bug-fixes/16-variation-table-checks.md +31 -0
  10. data/TODO.bug-fixes/17-ufo-and-stitcher-cleanup.md +32 -0
  11. data/TODO.bug-fixes/18-generic-value-and-io-checks.md +56 -0
  12. data/TODO.bug-fixes/19-serialization-migration.md +52 -0
  13. data/TODO.bug-fixes/20-variation-validator-type-checks.md +29 -0
  14. data/TODO.bug-fixes/21-xml-builder-send.md +24 -0
  15. data/TODO.bug-fixes/22-type1-converter-font-info.md +25 -0
  16. data/TODO.bug-fixes/23-boundary-type-checks.md +31 -0
  17. data/TODO.bug-fixes/24-validator-dynamic-field-key.md +25 -0
  18. data/TODO.bug-fixes/25-serialization-migration.md +49 -0
  19. data/TODO.bug-fixes/26-variation-preserver-spec-migration.md +25 -0
  20. data/TODO.bug-fixes/27-table-class-registry.md +50 -0
  21. data/TODO.bug-fixes/28-spec-doubles-cleanup.md +40 -0
  22. data/TODO.bug-fixes/29-strategy-registries.md +30 -0
  23. data/TODO.bug-fixes/README.md +29 -2
  24. data/lib/fontisan/binary/base_record.rb +13 -12
  25. data/lib/fontisan/collection/builder.rb +3 -5
  26. data/lib/fontisan/collection/dfont_builder.rb +4 -11
  27. data/lib/fontisan/collection/writer.rb +1 -1
  28. data/lib/fontisan/commands/pack_command.rb +4 -4
  29. data/lib/fontisan/commands/unpack_command.rb +1 -1
  30. data/lib/fontisan/commands/validate_command.rb +1 -1
  31. data/lib/fontisan/converters/format_converter.rb +2 -2
  32. data/lib/fontisan/converters/glyf_table_builder.rb +1 -1
  33. data/lib/fontisan/converters/outline_converter.rb +2 -6
  34. data/lib/fontisan/converters/table_copier.rb +4 -12
  35. data/lib/fontisan/converters/type1_converter.rb +12 -37
  36. data/lib/fontisan/converters/woff2_encoder.rb +8 -10
  37. data/lib/fontisan/converters/woff_writer.rb +3 -3
  38. data/lib/fontisan/export/exporter.rb +8 -9
  39. data/lib/fontisan/export/table_serializer.rb +71 -186
  40. data/lib/fontisan/export/transformers/font_to_ttx.rb +18 -18
  41. data/lib/fontisan/export/transformers/head_transformer.rb +1 -1
  42. data/lib/fontisan/export/transformers/hhea_transformer.rb +1 -1
  43. data/lib/fontisan/export/transformers/maxp_transformer.rb +1 -1
  44. data/lib/fontisan/export/transformers/name_transformer.rb +1 -1
  45. data/lib/fontisan/export/transformers/os2_transformer.rb +1 -1
  46. data/lib/fontisan/export/transformers/post_transformer.rb +1 -1
  47. data/lib/fontisan/export/ttx_generator.rb +36 -37
  48. data/lib/fontisan/font_writer.rb +1 -1
  49. data/lib/fontisan/glyph_accessor.rb +9 -15
  50. data/lib/fontisan/hints/postscript_hint_extractor.rb +24 -64
  51. data/lib/fontisan/hints/truetype_hint_extractor.rb +1 -1
  52. data/lib/fontisan/models/ttx/tables/binary_table.rb +1 -1
  53. data/lib/fontisan/optimizers/charstring_rewriter.rb +1 -1
  54. data/lib/fontisan/outline_extractor.rb +1 -5
  55. data/lib/fontisan/sfnt_font.rb +3 -3
  56. data/lib/fontisan/sfnt_source.rb +28 -0
  57. data/lib/fontisan/stitcher/source.rb +8 -10
  58. data/lib/fontisan/subset/builder.rb +2 -2
  59. data/lib/fontisan/subset/table_strategy/cff2.rb +2 -3
  60. data/lib/fontisan/svg/font_face_generator.rb +0 -4
  61. data/lib/fontisan/svg/font_generator.rb +0 -4
  62. data/lib/fontisan/svg_to_glyf/assembler.rb +59 -20
  63. data/lib/fontisan/svg_to_glyf/document.rb +28 -9
  64. data/lib/fontisan/svg_to_glyf/geometry/normalizer.rb +34 -17
  65. data/lib/fontisan/tables/cff/charstring.rb +3 -0
  66. data/lib/fontisan/tables/cff/dict.rb +6 -3
  67. data/lib/fontisan/tables/cff/index.rb +1 -1
  68. data/lib/fontisan/tables/cff/private_dict_writer.rb +2 -2
  69. data/lib/fontisan/tables/cff/table_builder.rb +1 -1
  70. data/lib/fontisan/tables/cff.rb +3 -0
  71. data/lib/fontisan/tables/cff2/table_builder.rb +2 -4
  72. data/lib/fontisan/tables/cff2.rb +3 -0
  73. data/lib/fontisan/tables/cmap.rb +3 -0
  74. data/lib/fontisan/tables/cvar.rb +3 -0
  75. data/lib/fontisan/tables/fvar.rb +3 -0
  76. data/lib/fontisan/tables/glyf/simple_glyph.rb +9 -0
  77. data/lib/fontisan/tables/glyf.rb +12 -8
  78. data/lib/fontisan/tables/glyf_table.rb +4 -4
  79. data/lib/fontisan/tables/gvar.rb +3 -0
  80. data/lib/fontisan/tables/head.rb +3 -0
  81. data/lib/fontisan/tables/hhea.rb +3 -0
  82. data/lib/fontisan/tables/hmtx.rb +3 -7
  83. data/lib/fontisan/tables/hvar.rb +4 -0
  84. data/lib/fontisan/tables/loca.rb +3 -0
  85. data/lib/fontisan/tables/maxp.rb +3 -0
  86. data/lib/fontisan/tables/mvar.rb +3 -0
  87. data/lib/fontisan/tables/name.rb +3 -0
  88. data/lib/fontisan/tables/os2.rb +3 -0
  89. data/lib/fontisan/tables/post.rb +3 -0
  90. data/lib/fontisan/tables/registry.rb +71 -0
  91. data/lib/fontisan/tables/vvar.rb +3 -0
  92. data/lib/fontisan/tables.rb +2 -0
  93. data/lib/fontisan/type1/afm_generator.rb +12 -65
  94. data/lib/fontisan/type1/charstring_converter.rb +21 -23
  95. data/lib/fontisan/type1/conversion_options.rb +3 -2
  96. data/lib/fontisan/type1/decryptor.rb +1 -1
  97. data/lib/fontisan/type1/generator.rb +14 -9
  98. data/lib/fontisan/type1/inf_generator.rb +10 -35
  99. data/lib/fontisan/type1/pfa_generator.rb +36 -26
  100. data/lib/fontisan/type1/pfb_generator.rb +24 -12
  101. data/lib/fontisan/type1/pfm_generator.rb +14 -31
  102. data/lib/fontisan/type1/ttf_to_type1_converter.rb +88 -26
  103. data/lib/fontisan/ufo/bounds.rb +91 -0
  104. data/lib/fontisan/ufo/compile/fvar.rb +2 -2
  105. data/lib/fontisan/ufo/contour.rb +5 -0
  106. data/lib/fontisan/ufo/convert/from_bin_data.rb +15 -29
  107. data/lib/fontisan/ufo/info.rb +4 -0
  108. data/lib/fontisan/ufo/point.rb +0 -7
  109. data/lib/fontisan/ufo.rb +1 -0
  110. data/lib/fontisan/utilities/brotli_wrapper.rb +3 -5
  111. data/lib/fontisan/utilities/padding.rb +9 -8
  112. data/lib/fontisan/validation/collection_validator.rb +2 -2
  113. data/lib/fontisan/validators/validator.rb +4 -4
  114. data/lib/fontisan/variable/delta_applicator.rb +4 -11
  115. data/lib/fontisan/variable/metric_delta_processor.rb +3 -3
  116. data/lib/fontisan/variable/static_font_builder.rb +19 -23
  117. data/lib/fontisan/variation/instance_font_wrapper.rb +41 -0
  118. data/lib/fontisan/variation/instance_writer.rb +19 -17
  119. data/lib/fontisan/variation/metrics_adjuster.rb +17 -20
  120. data/lib/fontisan/variation/validator.rb +26 -38
  121. data/lib/fontisan/variation/variable_svg_generator.rb +0 -48
  122. data/lib/fontisan/variation/variation_preserver.rb +2 -3
  123. data/lib/fontisan/variation.rb +1 -0
  124. data/lib/fontisan/version.rb +1 -1
  125. data/lib/fontisan/woff2/sfnt_checksum.rb +1 -1
  126. data/lib/fontisan/woff2/table_transformer.rb +22 -29
  127. data/lib/fontisan/woff2_font.rb +11 -10
  128. data/lib/fontisan/woff_font.rb +2 -1
  129. data/lib/fontisan.rb +1 -0
  130. metadata +27 -2
@@ -139,32 +139,37 @@ module Fontisan
139
139
  def generate
140
140
  result = {}
141
141
 
142
+ opts = options_hash
143
+
142
144
  # Always generate AFM
143
- result[:afm] = AFMGenerator.generate(@font, to_hash)
145
+ result[:afm] = AFMGenerator.generate(@font, opts)
144
146
 
145
147
  # Always generate PFM (for Windows compatibility)
146
- result[:pfm] = PFMGenerator.generate(@font, to_hash)
148
+ result[:pfm] = PFMGenerator.generate(@font, opts)
147
149
 
148
150
  # Generate PFB or PFA based on format option
149
151
  format = @options.format || :pfb
150
152
  if format == :pfa
151
- result[:pfa] = PFAGenerator.generate(@font, to_hash)
153
+ result[:pfa] = PFAGenerator.generate(@font, opts)
152
154
  else
153
- result[:pfb] = PFBGenerator.generate(@font, to_hash)
155
+ result[:pfb] = PFBGenerator.generate(@font, opts)
154
156
  end
155
157
 
156
158
  # Generate INF for Windows installation
157
- result[:inf] = INFGenerator.generate(@font, to_hash)
159
+ result[:inf] = INFGenerator.generate(@font, opts)
158
160
 
159
161
  result
160
162
  end
161
163
 
162
164
  private
163
165
 
164
- # Convert options to hash
166
+ # Build the options hash forwarded to the per-format generators.
167
+ # This is an internal adapter — ConversionOptions exposes typed
168
+ # accessors; the per-format generators accept a Hash for historical
169
+ # reasons.
165
170
  #
166
171
  # @return [Hash] Options as hash
167
- def to_hash
172
+ def options_hash
168
173
  {
169
174
  upm_scale: @options.upm_scale || 1000,
170
175
  encoding: @options.encoding || Encodings::AdobeStandard,
@@ -200,8 +205,8 @@ module Fontisan
200
205
  # @return [String] Base filename
201
206
  def self.extract_base_name(font)
202
207
  name_table = font.table(Constants::NAME_TAG)
203
- if name_table.respond_to?(:postscript_name)
204
- name = name_table.postscript_name(1) || name_table.postscript_name(3)
208
+ if name_table
209
+ name = name_table.english_name(Tables::Name::POSTSCRIPT_NAME)
205
210
  return name if name
206
211
  end
207
212
 
@@ -181,15 +181,10 @@ module Fontisan
181
181
  # @return [String] Font name
182
182
  def extract_font_name
183
183
  name_table = @font.table(Constants::NAME_TAG)
184
- return "" unless name_table
184
+ return extract_postscript_name unless name_table
185
185
 
186
- # Try full font name first, then postscript name
187
- if name_table.respond_to?(:full_font_name)
188
- name_table.full_font_name(1) || name_table.full_font_name(3) ||
189
- extract_postscript_name
190
- else
186
+ name_table.english_name(Tables::Name::FULL_NAME) ||
191
187
  extract_postscript_name
192
- end
193
188
  end
194
189
 
195
190
  # Extract PostScript name
@@ -199,12 +194,8 @@ module Fontisan
199
194
  name_table = @font.table(Constants::NAME_TAG)
200
195
  return @font.post_script_name || "Unknown" unless name_table
201
196
 
202
- if name_table.respond_to?(:postscript_name)
203
- name_table.postscript_name(1) || name_table.postscript_name(3) ||
204
- @font.post_script_name || "Unknown"
205
- else
197
+ name_table.english_name(Tables::Name::POSTSCRIPT_NAME) ||
206
198
  @font.post_script_name || "Unknown"
207
- end
208
199
  end
209
200
 
210
201
  # Extract family name
@@ -214,9 +205,7 @@ module Fontisan
214
205
  name_table = @font.table(Constants::NAME_TAG)
215
206
  return nil unless name_table
216
207
 
217
- if name_table.respond_to?(:font_family)
218
- name_table.font_family(1) || name_table.font_family(3)
219
- end
208
+ name_table.english_name(Tables::Name::FAMILY)
220
209
  end
221
210
 
222
211
  # Extract weight
@@ -226,11 +215,7 @@ module Fontisan
226
215
  os2 = @font.table(Constants::OS2_TAG)
227
216
  return nil unless os2
228
217
 
229
- weight_class = if os2.respond_to?(:us_weight_class)
230
- os2.us_weight_class
231
- elsif os2.respond_to?(:weight_class)
232
- os2.weight_class
233
- end
218
+ weight_class = os2.us_weight_class
234
219
  return nil unless weight_class
235
220
 
236
221
  case weight_class
@@ -254,9 +239,7 @@ module Fontisan
254
239
  post = @font.table(Constants::POST_TAG)
255
240
  return nil unless post
256
241
 
257
- if post.respond_to?(:italic_angle)
258
- post.italic_angle
259
- end
242
+ post.italic_angle
260
243
  end
261
244
 
262
245
  # Extract version
@@ -266,9 +249,7 @@ module Fontisan
266
249
  name_table = @font.table(Constants::NAME_TAG)
267
250
  return nil unless name_table
268
251
 
269
- if name_table.respond_to?(:version_string)
270
- name_table.version_string(1) || name_table.version_string(3)
271
- end
252
+ name_table.english_name(Tables::Name::VERSION)
272
253
  end
273
254
 
274
255
  # Extract copyright
@@ -278,9 +259,7 @@ module Fontisan
278
259
  name_table = @font.table(Constants::NAME_TAG)
279
260
  return nil unless name_table
280
261
 
281
- if name_table.respond_to?(:copyright)
282
- name_table.copyright(1) || name_table.copyright(3)
283
- end
262
+ name_table.english_name(Tables::Name::COPYRIGHT)
284
263
  end
285
264
 
286
265
  # Extract vendor/manufacturer
@@ -290,9 +269,7 @@ module Fontisan
290
269
  name_table = @font.table(Constants::NAME_TAG)
291
270
  return nil unless name_table
292
271
 
293
- if name_table.respond_to?(:manufacturer)
294
- name_table.manufacturer(1) || name_table.manufacturer(3)
295
- end
272
+ name_table.english_name(Tables::Name::MANUFACTURER)
296
273
  end
297
274
 
298
275
  # Extract license information
@@ -302,9 +279,7 @@ module Fontisan
302
279
  name_table = @font.table(Constants::NAME_TAG)
303
280
  return nil unless name_table
304
281
 
305
- if name_table.respond_to?(:license)
306
- name_table.license(1) || name_table.license(3)
307
- end
282
+ name_table.english_name(Tables::Name::LICENSE_DESCRIPTION)
308
283
  end
309
284
 
310
285
  # Get default PFB filename
@@ -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
@@ -251,19 +262,70 @@ module Fontisan
251
262
  # @param glyph [Object] TTF composite glyph
252
263
  # @param glyf_table [Object] TTF glyf table
253
264
  # @return [String] Type 1 CharString data
254
- def convert_composite_glyph(_glyph, _glyf_table)
255
- # For composite glyphs, we need to decompose or use seac
256
- # TODO: Implement proper composite handling with seac or decomposition
265
+ def convert_composite_glyph(glyph, glyf_table)
266
+ commands = []
267
+
268
+ glyph.components.each do |component|
269
+ next unless component.args_are_xy?
270
+
271
+ component_glyph = glyf_table.glyph_for(
272
+ component.glyph_index,
273
+ @loca_table,
274
+ @head_table,
275
+ )
276
+ next unless component_glyph&.simple?
257
277
 
258
- # For now, return a simple placeholder
259
- # In a full implementation, we would:
260
- # 1. Extract component glyphs
261
- # 2. Transform and merge their outlines
262
- # 3. Generate combined CharString
278
+ matrix = component.transformation_matrix
279
+ commands.concat(transform_simple_glyph_commands(component_glyph, matrix))
280
+ end
281
+
282
+ return empty_charstring if commands.empty?
263
283
 
284
+ encode_charstring(commands)
285
+ rescue StandardError
264
286
  empty_charstring
265
287
  end
266
288
 
289
+ # Generate Type 1 charstring commands from a simple glyph's
290
+ # contours, applying a 2x3 affine transformation matrix.
291
+ def transform_simple_glyph_commands(simple, matrix)
292
+ commands = []
293
+ num_contours = simple.end_pts_of_contours&.size || 0
294
+
295
+ num_contours.times do |ci|
296
+ points = simple.points_for_contour(ci)
297
+ next unless points && !points.empty?
298
+
299
+ first = points.first
300
+ fx = transform_x(first[:x], first[:y], matrix)
301
+ fy = transform_y(first[:x], first[:y], matrix)
302
+ commands << [RMOVETO, fx, fy]
303
+
304
+ prev_x = first[:x].to_f
305
+ prev_y = first[:y].to_f
306
+ points[1..].each do |pt|
307
+ tx = transform_x(pt[:x], pt[:y], matrix)
308
+ ty = transform_y(pt[:x], pt[:y], matrix)
309
+ dx = tx - transform_x(prev_x, prev_y, matrix)
310
+ dy = ty - transform_y(prev_x, prev_y, matrix)
311
+
312
+ commands << [RLINETO, dx.to_i, dy.to_i]
313
+ prev_x = pt[:x].to_f
314
+ prev_y = pt[:y].to_f
315
+ end
316
+ end
317
+
318
+ commands
319
+ end
320
+
321
+ def transform_x(x, y, m)
322
+ (m[0] * x + m[2] * y + m[4]).to_i
323
+ end
324
+
325
+ def transform_y(x, y, m)
326
+ (m[1] * x + m[3] * y + m[5]).to_i
327
+ end
328
+
267
329
  # Encode commands to Type 1 CharString binary format
268
330
  #
269
331
  # @param commands [Array<Array<Integer>>] Array of command arrays