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
@@ -56,24 +56,23 @@ module Fontisan
56
56
  def extract(charstring)
57
57
  return [] if charstring.nil?
58
58
 
59
- # Get CharString bytes
60
- bytes = if charstring.respond_to?(:data)
61
- charstring.data
62
- elsif charstring.respond_to?(:bytes)
63
- charstring.bytes
64
- elsif charstring.is_a?(String)
65
- charstring.bytes
66
- else
67
- return []
68
- end
69
-
70
- return [] if bytes.empty?
59
+ bytes = charstring_to_bytes(charstring)
60
+ return [] if bytes.nil? || bytes.empty?
71
61
 
72
62
  parse_charstring(bytes)
73
63
  end
74
64
 
75
65
  private
76
66
 
67
+ # Coerce a CharString input to an Array<Integer> of bytes.
68
+ # Accepts a String (raw bytes) or a CFF::CharString.
69
+ def charstring_to_bytes(charstring)
70
+ return charstring.bytes if charstring.is_a?(String)
71
+ return charstring.data if charstring.is_a?(Tables::Cff::CharString)
72
+
73
+ raise TypeError, "Unsupported charstring type: #{charstring.class}"
74
+ end
75
+
77
76
  # Parse CharString bytes to extract hints
78
77
  #
79
78
  # @param bytes [Array<Integer>] CharString bytes
@@ -289,58 +288,19 @@ module Fontisan
289
288
 
290
289
  # Extract hint-related parameters from Private DICT
291
290
  # These are the key hinting parameters in CFF
292
- if private_dict.respond_to?(:blue_values)
293
- hints[:blue_values] =
294
- private_dict.blue_values
295
- end
296
- if private_dict.respond_to?(:other_blues)
297
- hints[:other_blues] =
298
- private_dict.other_blues
299
- end
300
- if private_dict.respond_to?(:family_blues)
301
- hints[:family_blues] =
302
- private_dict.family_blues
303
- end
304
- if private_dict.respond_to?(:family_other_blues)
305
- hints[:family_other_blues] =
306
- private_dict.family_other_blues
307
- end
308
- if private_dict.respond_to?(:blue_scale)
309
- hints[:blue_scale] =
310
- private_dict.blue_scale
311
- end
312
- if private_dict.respond_to?(:blue_shift)
313
- hints[:blue_shift] =
314
- private_dict.blue_shift
315
- end
316
- if private_dict.respond_to?(:blue_fuzz)
317
- hints[:blue_fuzz] =
318
- private_dict.blue_fuzz
319
- end
320
- if private_dict.respond_to?(:std_hw)
321
- hints[:std_hw] =
322
- private_dict.std_hw
323
- end
324
- if private_dict.respond_to?(:std_vw)
325
- hints[:std_vw] =
326
- private_dict.std_vw
327
- end
328
- if private_dict.respond_to?(:stem_snap_h)
329
- hints[:stem_snap_h] =
330
- private_dict.stem_snap_h
331
- end
332
- if private_dict.respond_to?(:stem_snap_v)
333
- hints[:stem_snap_v] =
334
- private_dict.stem_snap_v
335
- end
336
- if private_dict.respond_to?(:force_bold)
337
- hints[:force_bold] =
338
- private_dict.force_bold
339
- end
340
- if private_dict.respond_to?(:language_group)
341
- hints[:language_group] =
342
- private_dict.language_group
343
- end
291
+ hints[:blue_values] = private_dict.blue_values
292
+ hints[:other_blues] = private_dict.other_blues
293
+ hints[:family_blues] = private_dict.family_blues
294
+ hints[:family_other_blues] = private_dict.family_other_blues
295
+ hints[:blue_scale] = private_dict.blue_scale
296
+ hints[:blue_shift] = private_dict.blue_shift
297
+ hints[:blue_fuzz] = private_dict.blue_fuzz
298
+ hints[:std_hw] = private_dict.std_hw
299
+ hints[:std_vw] = private_dict.std_vw
300
+ hints[:stem_snap_h] = private_dict.stem_snap_h
301
+ hints[:stem_snap_v] = private_dict.stem_snap_v
302
+ hints[:force_bold] = private_dict.force_bold?
303
+ hints[:language_group] = private_dict.language_group
344
304
 
345
305
  hints.compact
346
306
  rescue StandardError => e
@@ -63,7 +63,7 @@ module Fontisan
63
63
  # @return [Array<Hint>] Extracted hints
64
64
  def extract(glyph)
65
65
  return [] if glyph.nil? || glyph.empty?
66
- return [] unless glyph.respond_to?(:instructions)
66
+ return [] unless glyph.is_a?(Fontisan::Tables::SimpleGlyph)
67
67
 
68
68
  instructions = glyph.instructions || []
69
69
  return [] if instructions.empty?
@@ -18,7 +18,7 @@ module Fontisan
18
18
  # based on table tag
19
19
  def to_xml(options = {})
20
20
  builder = Nokogiri::XML::Builder.new(encoding: "UTF-8") do |xml|
21
- xml.send(tag.to_sym) do
21
+ xml.public_send(tag.to_sym) do
22
22
  xml.hexdata do
23
23
  xml.text("\n #{format_hexdata(hexdata)}\n ")
24
24
  end
@@ -127,7 +127,7 @@ module Fontisan
127
127
  replacements = []
128
128
 
129
129
  patterns.each do |pattern|
130
- if glyph_id && pattern.respond_to?(:positions) && pattern.positions.is_a?(Hash)
130
+ if glyph_id && pattern.is_a?(PatternAnalyzer::Pattern) && pattern.positions.is_a?(Hash)
131
131
  # Use exact positions from pattern analysis for this glyph
132
132
  glyph_positions = pattern.positions[glyph_id] || []
133
133
 
@@ -52,6 +52,8 @@ module Fontisan
52
52
  # @example Writing a font
53
53
  # ttf.to_file("output.ttf")
54
54
  class SfntFont < BinData::Record
55
+ include SfntSource
56
+
55
57
  endian :big
56
58
 
57
59
  offset_table :header
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ # Marks a class as providing the SFNT font source interface.
5
+ #
6
+ # SfntFont, WoffFont, and Woff2Font all expose the same table-access
7
+ # surface (#table, #tables, #table_data, #table_names, #has_table?)
8
+ # but don't share a common base class (WOFF/WOFF2 wrap an SfntFont
9
+ # but inherit from BinData::Record directly).
10
+ #
11
+ # Including this module lets callers type-check with `is_a?(SfntSource)`
12
+ # instead of `respond_to?(:table)` duck-typing.
13
+ module SfntSource
14
+ # @param tag [String] table tag
15
+ # @return [Object, nil] parsed table or nil
16
+ def table(tag); end
17
+
18
+ # @return [Hash<String, String>] raw table bytes by tag
19
+ def table_data(tag = nil); end
20
+
21
+ # @return [Array<String>] table tags present in this font
22
+ def table_names; end
23
+
24
+ # @param tag [String] table tag
25
+ # @return [Boolean]
26
+ def has_table?(tag); end
27
+ end
28
+ end
@@ -80,7 +80,7 @@ module Fontisan
80
80
  # @return [Symbol]
81
81
  def bitmap_mode
82
82
  return :none if @font.is_a?(Fontisan::Ufo::Font)
83
- return :none unless @font.respond_to?(:has_table?)
83
+ return :none unless @font.is_a?(SfntSource)
84
84
 
85
85
  has_cbdt = @font.has_table?("CBDT") && @font.has_table?("CBLC")
86
86
  has_glyf = @font.has_table?("glyf") || @font.has_table?("CFF ")
@@ -190,7 +190,7 @@ module Fontisan
190
190
  cache[:loca] = @font.table("loca")
191
191
  cache[:glyf] = @font.table("glyf")
192
192
  # loca needs head's index_to_loc_format to size its offsets
193
- if cache[:loca].respond_to?(:parse_with_context) && cache[:head]
193
+ if cache[:head]
194
194
  cache[:loca].parse_with_context(
195
195
  cache[:head].index_to_loc_format,
196
196
  @font.table("maxp")&.num_glyphs || 0,
@@ -217,15 +217,13 @@ module Fontisan
217
217
  num_h_metrics = hhea&.number_of_h_metrics || 1
218
218
  num_glyphs = maxp&.num_glyphs || 0
219
219
 
220
- if hmtx.respond_to?(:parse_with_context)
221
- hmtx.parse_with_context(num_h_metrics, num_glyphs)
222
- end
220
+ hmtx.parse_with_context(num_h_metrics, num_glyphs)
223
221
 
224
222
  # Fallback advance width when hmtx lookup fails for a GID.
225
223
  # Per the OpenType spec, glyphs at GID >= numberOfHMetrics
226
224
  # inherit the last LongHorMetric's advanceWidth. If the table
227
225
  # is empty or corrupt, fall back to the font's unitsPerEm.
228
- fallback_width = if hmtx.respond_to?(:h_metrics) && hmtx.h_metrics&.any?
226
+ fallback_width = if hmtx.h_metrics&.any?
229
227
  hmtx.h_metrics.last[:advance_width]
230
228
  else
231
229
  head&.units_per_em || 1000
@@ -284,9 +282,9 @@ module Fontisan
284
282
  glyph = Fontisan::Ufo::Glyph.new(name: name)
285
283
  glyph.width = glyph_width(gid)
286
284
 
287
- if raw.respond_to?(:simple?) && raw.simple?
285
+ if raw.is_a?(Fontisan::Tables::SimpleGlyph) && raw.simple?
288
286
  copy_simple_contours(raw, glyph)
289
- elsif raw.respond_to?(:compound?) && raw.compound?
287
+ elsif raw.is_a?(Fontisan::Tables::CompoundGlyph) && raw.compound?
290
288
  flatten_compound_into(raw, glyph, cache, Set.new)
291
289
  end
292
290
 
@@ -372,9 +370,9 @@ module Fontisan
372
370
 
373
371
  matrix = component.transformation_matrix
374
372
 
375
- if raw.respond_to?(:simple?) && raw.simple?
373
+ if raw.is_a?(Fontisan::Tables::SimpleGlyph) && raw.simple?
376
374
  flatten_simple_component(raw, ufo_glyph, matrix)
377
- elsif raw.respond_to?(:compound?) && raw.compound?
375
+ elsif raw.is_a?(Fontisan::Tables::CompoundGlyph) && raw.compound?
378
376
  flatten_compound_into(raw, ufo_glyph, cache, visited, depth + 1)
379
377
  end
380
378
  end
@@ -110,8 +110,8 @@ module Fontisan
110
110
  def validate_input!
111
111
  raise ArgumentError, "Font cannot be nil" if font.nil?
112
112
 
113
- unless font.respond_to?(:table)
114
- raise ArgumentError, "Font must respond to :table method"
113
+ unless font.is_a?(SfntSource)
114
+ raise ArgumentError, "Font must be an SfntSource instance"
115
115
  end
116
116
 
117
117
  # Validate options
@@ -55,13 +55,12 @@ module Fontisan
55
55
  end
56
56
 
57
57
  def self.raw_bytes_from_font(font)
58
- t = font.table("CFF2") if font.respond_to?(:table)
59
- t&.raw_data
58
+ font.table("CFF2")&.raw_data
60
59
  end
61
60
  private_class_method :raw_bytes_from_font
62
61
 
63
62
  def self.raw_bytes_from_table(table)
64
- table&.raw_data if table.respond_to?(:raw_data)
63
+ table&.raw_data
65
64
  end
66
65
  private_class_method :raw_bytes_from_table
67
66
 
@@ -7,7 +7,10 @@ module Fontisan
7
7
  # path data + transforms
8
8
  # → Path::Parser.parse → [Command]
9
9
  # → Path::ContourBuilder.build → [Ufo::Contour]
10
- # → apply final transform (normalization · group transform)
10
+ # → measure content bounds (Ufo::Bounds)
11
+ # → union content bounds with the SVG viewBox bounds
12
+ # → Normalizer maps the unioned space into the em-square
13
+ # → compose with the accumulated group transform
11
14
  # → round to Integer
12
15
  # → Ufo::Glyph
13
16
  #
@@ -16,6 +19,8 @@ module Fontisan
16
19
  # composes the normalizer with the group transform and runs the
17
20
  # pipeline once per path.
18
21
  class Assembler
22
+ DEFAULT_VIEWBOX = Ufo::Bounds.new(min_x: 0, min_y: 0, max_x: DEFAULT_UPM, max_y: DEFAULT_UPM)
23
+
19
24
  attr_reader :upm
20
25
 
21
26
  # @param upm [Integer] font units-per-em
@@ -28,15 +33,16 @@ module Fontisan
28
33
  # @param path_data [String] SVG path d= attribute
29
34
  # @param codepoint [Integer, nil] Unicode codepoint
30
35
  # @param name [String, nil] glyph name
31
- # @param viewbox [Hash{Symbol=>Float}, nil] :width, :height
36
+ # @param viewbox [Ufo::Bounds, Hash{Symbol=>Float}, nil] SVG viewbox
32
37
  # @param transform [Geometry::AffineTransform, nil] group transform
33
38
  # @return [Fontisan::Ufo::Glyph]
34
39
  def build_from_path_data(path_data, codepoint: nil, name: nil,
35
40
  viewbox: nil, transform: nil)
36
- viewbox ||= { width: @upm, height: @upm }
37
- final = normalizer_for(**viewbox).final_transform(transform || Geometry::AffineTransform.identity)
38
- contours = build_contours(path_data, final)
39
- assemble_glyph(name || glyph_name_for(codepoint), contours, codepoint)
41
+ assemble(path_data: path_data,
42
+ viewbox: resolve_viewbox(viewbox),
43
+ group_transform: transform || Geometry::AffineTransform.identity,
44
+ codepoint: codepoint,
45
+ name: name)
40
46
  end
41
47
 
42
48
  # Build a glyph from an SVG file.
@@ -49,7 +55,11 @@ module Fontisan
49
55
  codepoint ||= codepoint_from_filename(File.basename(file_path))
50
56
 
51
57
  doc.each_path.with_object(nil) do |(data, transform), _|
52
- return build_from_doc_path(data, transform, doc, codepoint)
58
+ return assemble(path_data: data,
59
+ viewbox: doc.viewbox || default_viewbox,
60
+ group_transform: transform,
61
+ codepoint: codepoint,
62
+ name: nil)
53
63
  end
54
64
 
55
65
  empty_glyph(codepoint)
@@ -73,18 +83,33 @@ module Fontisan
73
83
 
74
84
  private
75
85
 
76
- def build_from_doc_path(data, group_transform, doc, codepoint)
77
- final = normalizer_for(width: doc.viewbox_width,
78
- height: doc.viewbox_height)
79
- .final_transform(group_transform)
80
- contours = build_contours(data, final)
81
- assemble_glyph(glyph_name_for(codepoint), contours, codepoint)
82
- end
83
-
84
- def build_contours(path_data, final_transform)
86
+ # Shared pipeline used by every entry point. Parses the d-string,
87
+ # builds contours, computes the normalization bounds as the union
88
+ # of the actual content bounds and the declared viewBox bounds,
89
+ # applies the normalizer composed with the group transform, and
90
+ # assembles the result into a Ufo::Glyph.
91
+ def assemble(path_data:, viewbox:, group_transform:, codepoint:, name:)
85
92
  commands = Path::Parser.parse(path_data)
86
93
  contours = Path::ContourBuilder.new.build(commands)
87
- contours.map { |c| transform_contour(c, final_transform) }
94
+ norm_bounds = normalization_bounds(contours, viewbox)
95
+ final = normalizer_for(norm_bounds).final_transform(group_transform)
96
+ transformed = contours.map { |c| transform_contour(c, final) }
97
+ assemble_glyph(name || glyph_name_for(codepoint), transformed, codepoint)
98
+ end
99
+
100
+ # Union the actual content bounds with the SVG viewBox bounds.
101
+ # Content outside the viewBox is preserved (not clipped) by
102
+ # expanding the normalization space to contain it; content inside
103
+ # a larger viewBox keeps its relative scale.
104
+ #
105
+ # @param contours [Array<Ufo::Contour>]
106
+ # @param viewbox [Ufo::Bounds]
107
+ # @return [Ufo::Bounds]
108
+ def normalization_bounds(contours, viewbox)
109
+ content = Ufo::Bounds.measure(contours)
110
+ return viewbox if content.empty?
111
+
112
+ content.union(viewbox)
88
113
  end
89
114
 
90
115
  def transform_contour(contour, transform)
@@ -96,9 +121,23 @@ module Fontisan
96
121
  Fontisan::Ufo::Contour.new(points)
97
122
  end
98
123
 
99
- def normalizer_for(width:, height:)
100
- Geometry::Normalizer.new(viewbox_width: width, viewbox_height: height,
101
- upm: @upm)
124
+ def normalizer_for(bounds)
125
+ Geometry::Normalizer.new(bounds: bounds, upm: @upm)
126
+ end
127
+
128
+ # Accept either a Ufo::Bounds (preferred) or a legacy hash with
129
+ # :width, :height (treated as origin 0,0). Returns a Bounds.
130
+ def resolve_viewbox(viewbox)
131
+ return default_viewbox unless viewbox
132
+ return viewbox if viewbox.is_a?(Ufo::Bounds)
133
+
134
+ Ufo::Bounds.new(min_x: 0, min_y: 0,
135
+ max_x: viewbox[:width].to_f,
136
+ max_y: viewbox[:height].to_f)
137
+ end
138
+
139
+ def default_viewbox
140
+ self.class.const_get(:DEFAULT_VIEWBOX)
102
141
  end
103
142
 
104
143
  def assemble_glyph(name, contours, codepoint)
@@ -9,7 +9,7 @@ module Fontisan
9
9
  # transforms apply to which paths and what coordinate space the
10
10
  # SVG defines (via viewBox).
11
11
  class Document
12
- attr_reader :viewbox_width, :viewbox_height, :source
12
+ attr_reader :viewbox, :source
13
13
 
14
14
  # @param xml [String] raw SVG XML
15
15
  def self.from_xml(xml)
@@ -25,7 +25,7 @@ module Fontisan
25
25
  def initialize(doc)
26
26
  @doc = doc
27
27
  @source = doc
28
- extract_viewbox
28
+ @viewbox = extract_viewbox
29
29
  end
30
30
 
31
31
  # Yield each <path> element's d= string along with the accumulated
@@ -41,17 +41,36 @@ module Fontisan
41
41
 
42
42
  private
43
43
 
44
+ # Parse the SVG viewBox attribute. The SVG spec allows either
45
+ # whitespace- or comma-separated values: "0 0 1000 1000" or
46
+ # "0,0,1000,1000". The four components are min_x, min_y, width,
47
+ # height — all are significant for normalization.
48
+ #
49
+ # Falls back to the root element's width/height attributes (origin
50
+ # assumed to be 0,0). Returns nil if no geometry can be derived.
51
+ #
52
+ # @return [Ufo::Bounds, nil]
44
53
  def extract_viewbox
45
54
  root = @doc.root
46
- vb = root&.attribute("viewBox")&.value
55
+ return unless root
56
+
57
+ vb = root.attribute("viewBox")&.value
47
58
  if vb
48
- _, _, w, h = vb.split(/\s+/).map(&:to_f)
49
- @viewbox_width = w
50
- @viewbox_height = h
51
- else
52
- @viewbox_width = (root&.attribute("width")&.value || DEFAULT_UPM).to_f
53
- @viewbox_height = (root&.attribute("height")&.value || DEFAULT_UPM).to_f
59
+ parts = vb.split(/[\s,]+/).map(&:to_f)
60
+ return if parts.length != 4
61
+
62
+ min_x, min_y, w, h = parts
63
+ return if w.zero? || h.zero?
64
+
65
+ return Ufo::Bounds.new(min_x: min_x, min_y: min_y,
66
+ max_x: min_x + w, max_y: min_y + h)
54
67
  end
68
+
69
+ w = root.attribute("width")&.value&.to_f
70
+ h = root.attribute("height")&.value&.to_f
71
+ return if w.nil? || h.nil? || w.zero? || h.zero?
72
+
73
+ Ufo::Bounds.new(min_x: 0, min_y: 0, max_x: w, max_y: h)
55
74
  end
56
75
 
57
76
  # Recursively walk the XML tree. When a <g> has a transform=,
@@ -3,32 +3,49 @@
3
3
  module Fontisan
4
4
  module SvgToGlyf
5
5
  module Geometry
6
- # Computes the affine transform that maps SVG viewBox coordinates
7
- # (Y-down, origin at top-left) into font coordinates (Y-up, origin
8
- # at bottom-left, scaled to UPM).
6
+ # Computes the affine transform that maps SVG coordinate space
7
+ # (Y-down, arbitrary origin) into font coordinate space (Y-up,
8
+ # origin at the bottom-left of the em-square, scaled to UPM).
9
9
  #
10
- # The normalization combines a Y-flip across the viewBox midline
11
- # with a uniform scale from viewBox units to font units. The
12
- # resulting matrix is then composed with the SVG document's
10
+ # The normalization accepts a Ufo::Bounds describing the SVG
11
+ # coordinate extent to map into the em-square. That bounds may
12
+ # be the SVG viewBox, the actual content extents, or the union
13
+ # of both — Assembler decides which.
14
+ #
15
+ # The transform is:
16
+ #
17
+ # 1. Translate the source origin (min_x, min_y) to (0, 0).
18
+ # 2. Scale uniformly to font units: (upm/w, upm/h).
19
+ # 3. Flip Y across the half-height of the scaled space.
20
+ # 4. Translate up by upm so the top of the source maps to the
21
+ # top of the em-square.
22
+ #
23
+ # The resulting matrix is composed with the SVG document's
13
24
  # accumulated group transform to produce the final per-point
14
25
  # transform.
15
26
  class Normalizer
16
- attr_reader :viewbox_width, :viewbox_height, :upm
27
+ attr_reader :bounds, :upm
17
28
 
18
- # @param viewbox_width [Float] SVG viewBox width
19
- # @param viewbox_height [Float] SVG viewBox height
20
- # @param upm [Integer] font units-per-em
21
- def initialize(viewbox_width:, viewbox_height:, upm:)
22
- @viewbox_width = viewbox_width.to_f
23
- @viewbox_height = viewbox_height.to_f
29
+ # @param bounds [Ufo::Bounds] source coordinate extents
30
+ # @param upm [Integer, Float] font units-per-em
31
+ def initialize(bounds:, upm:)
32
+ @bounds = bounds
24
33
  @upm = upm.to_f
25
34
  end
26
35
 
27
- # @return [AffineTransform] the viewBox→font normalization
36
+ # @return [AffineTransform] the source→font normalization
28
37
  def matrix
29
- sx = @upm / @viewbox_width
30
- sy = @upm / @viewbox_height
31
- AffineTransform.new(sx, 0, 0, -sy, 0, @upm)
38
+ w = @bounds.width
39
+ h = @bounds.height
40
+ return AffineTransform.identity if w.zero? || h.zero?
41
+
42
+ sx = @upm / w
43
+ sy = @upm / h
44
+ # x' = sx * (x - min_x)
45
+ # y' = -sy * (y - min_y) + upm
46
+ AffineTransform.new(sx, 0, 0, -sy,
47
+ -sx * @bounds.min_x,
48
+ sy * @bounds.min_y + @upm)
32
49
  end
33
50
 
34
51
  # Compose the normalization with an SVG group transform,
@@ -36,6 +36,9 @@ module Fontisan
36
36
  # puts charstring.path # => array of path commands
37
37
  # bbox = charstring.bounding_box # => [xMin, yMin, xMax, yMax]
38
38
  class CharString
39
+ # @return [String, Array<Integer>] Raw charstring data/bytes
40
+ attr_reader :data
41
+
39
42
  # @return [Integer, nil] Glyph width (nil if using default width)
40
43
  attr_reader :width
41
44
 
@@ -106,10 +106,13 @@ module Fontisan
106
106
  @dict.values
107
107
  end
108
108
 
109
- # Convert DICT to Hash
109
+ # Expose the raw key/value pairs as a Hash. This is an accessor
110
+ # for the DICT's internal hash, NOT wire-format serialization —
111
+ # CFF DICT wire format is the binary operand/operator stream
112
+ # produced by to_binary_s.
110
113
  #
111
- # @return [Hash] Dictionary as hash
112
- def to_h
114
+ # @return [Hash] Dictionary as hash (dup of internal storage)
115
+ def raw_hash
113
116
  @dict.dup
114
117
  end
115
118
 
@@ -58,7 +58,7 @@ module Fontisan
58
58
  def initialize(io, start_offset: 0)
59
59
  @io = io.is_a?(String) ? StringIO.new(io) : io
60
60
  @start_offset = start_offset
61
- @io.seek(start_offset) if @io.respond_to?(:seek)
61
+ @io.seek(start_offset)
62
62
 
63
63
  parse!
64
64
  end
@@ -69,10 +69,10 @@ module Fontisan
69
69
  #
70
70
  # @param source_dict [PrivateDict] Source dictionary
71
71
  def parse_source(source_dict)
72
- return unless source_dict.respond_to?(:to_h)
72
+ return unless source_dict.is_a?(Tables::Cff::Dict)
73
73
 
74
74
  # Extract only non-hint params (subrs, widths)
75
- @params = source_dict.to_h.select do |k, _|
75
+ @params = source_dict.raw_hash.select do |k, _|
76
76
  %i[subrs default_width_x nominal_width_x].include?(k)
77
77
  end
78
78
  end
@@ -168,7 +168,7 @@ module Fontisan
168
168
  #
169
169
  # @return [Hash] Top DICT parameters
170
170
  def extract_top_dict_data
171
- @source.top_dict(0).to_h
171
+ @source.top_dict(0).raw_hash
172
172
  end
173
173
 
174
174
  # Rebuild Top DICT INDEX with updated data
@@ -41,6 +41,9 @@ module Fontisan
41
41
  # puts cff.font_count # => 1
42
42
  # puts cff.header.version # => "1.0"
43
43
  class Cff < Binary::BaseRecord
44
+ extend Registered
45
+
46
+ register_tag "CFF "
44
47
  # Inner namespace autoloads — declared here so Cff::* constants
45
48
  # resolve on first reference without require_relative.
46
49
  autoload :CFFGlyph, "fontisan/tables/cff/cff_glyph"
@@ -161,9 +161,7 @@ module Fontisan
161
161
  # @return [Integer] Total stem count (hstem + vstem)
162
162
  def calculate_stem_count
163
163
  return 0 unless @hint_set
164
-
165
- # Get font-level hints (from private_dict_hints JSON)
166
- return 0 unless @hint_set.respond_to?(:private_dict_hints)
164
+ return 0 unless @hint_set.is_a?(Models::HintSet)
167
165
 
168
166
  begin
169
167
  font_hints = JSON.parse(@hint_set.private_dict_hints || "{}")
@@ -194,7 +192,7 @@ module Fontisan
194
192
  #
195
193
  # @return [Boolean] True if private_dict_hints are present
196
194
  def has_font_level_hints?
197
- return false unless @hint_set.respond_to?(:private_dict_hints)
195
+ return false unless @hint_set.is_a?(Models::HintSet)
198
196
 
199
197
  hints = JSON.parse(@hint_set.private_dict_hints || "{}")
200
198
  !hints.empty?
@@ -20,6 +20,9 @@ module Fontisan
20
20
  # cff2 = Fontisan::Tables::Cff2.read(data)
21
21
  # num_glyphs = cff2.glyph_count
22
22
  class Cff2 < Binary::BaseRecord
23
+ extend Registered
24
+
25
+ register_tag "CFF2"
23
26
  # Inner namespace autoloads — declared here so Cff2::* constants
24
27
  # resolve on first reference without require_relative.
25
28
  autoload :BlendOperator, "fontisan/tables/cff2/blend_operator"
@@ -14,6 +14,9 @@ module Fontisan
14
14
  #
15
15
  # Reference: OpenType specification, cmap table
16
16
  class Cmap < Binary::BaseRecord
17
+ extend Registered
18
+
19
+ register_tag "cmap"
17
20
  # Platform IDs
18
21
  PLATFORM_UNICODE = 0
19
22
  PLATFORM_MACINTOSH = 1
@@ -20,6 +20,9 @@ module Fontisan
20
20
  # cvar = Fontisan::Tables::Cvar.read(data)
21
21
  # cvt_deltas = cvar.cvt_variations
22
22
  class Cvar < Binary::BaseRecord
23
+ extend Registered
24
+
25
+ register_tag "cvar"
23
26
  uint16 :major_version
24
27
  uint16 :minor_version
25
28
  uint16 :tuple_variation_count