fontisan 0.1.0 → 0.2.0

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 (185) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +529 -65
  3. data/Gemfile +1 -0
  4. data/LICENSE +5 -1
  5. data/README.adoc +1301 -275
  6. data/Rakefile +27 -2
  7. data/benchmark/variation_quick_bench.rb +47 -0
  8. data/docs/EXTRACT_TTC_MIGRATION.md +549 -0
  9. data/fontisan.gemspec +4 -1
  10. data/lib/fontisan/binary/base_record.rb +22 -1
  11. data/lib/fontisan/cli.rb +309 -0
  12. data/lib/fontisan/collection/builder.rb +260 -0
  13. data/lib/fontisan/collection/offset_calculator.rb +227 -0
  14. data/lib/fontisan/collection/table_analyzer.rb +204 -0
  15. data/lib/fontisan/collection/table_deduplicator.rb +241 -0
  16. data/lib/fontisan/collection/writer.rb +306 -0
  17. data/lib/fontisan/commands/base_command.rb +8 -1
  18. data/lib/fontisan/commands/convert_command.rb +291 -0
  19. data/lib/fontisan/commands/export_command.rb +161 -0
  20. data/lib/fontisan/commands/info_command.rb +40 -6
  21. data/lib/fontisan/commands/instance_command.rb +295 -0
  22. data/lib/fontisan/commands/ls_command.rb +113 -0
  23. data/lib/fontisan/commands/pack_command.rb +241 -0
  24. data/lib/fontisan/commands/subset_command.rb +245 -0
  25. data/lib/fontisan/commands/unpack_command.rb +338 -0
  26. data/lib/fontisan/commands/validate_command.rb +178 -0
  27. data/lib/fontisan/commands/variable_command.rb +30 -1
  28. data/lib/fontisan/config/collection_settings.yml +56 -0
  29. data/lib/fontisan/config/conversion_matrix.yml +212 -0
  30. data/lib/fontisan/config/export_settings.yml +66 -0
  31. data/lib/fontisan/config/subset_profiles.yml +100 -0
  32. data/lib/fontisan/config/svg_settings.yml +60 -0
  33. data/lib/fontisan/config/validation_rules.yml +149 -0
  34. data/lib/fontisan/config/variable_settings.yml +99 -0
  35. data/lib/fontisan/config/woff2_settings.yml +77 -0
  36. data/lib/fontisan/constants.rb +69 -0
  37. data/lib/fontisan/converters/conversion_strategy.rb +96 -0
  38. data/lib/fontisan/converters/format_converter.rb +259 -0
  39. data/lib/fontisan/converters/outline_converter.rb +936 -0
  40. data/lib/fontisan/converters/svg_generator.rb +244 -0
  41. data/lib/fontisan/converters/table_copier.rb +117 -0
  42. data/lib/fontisan/converters/woff2_encoder.rb +416 -0
  43. data/lib/fontisan/converters/woff_writer.rb +391 -0
  44. data/lib/fontisan/error.rb +203 -0
  45. data/lib/fontisan/export/exporter.rb +262 -0
  46. data/lib/fontisan/export/table_serializer.rb +255 -0
  47. data/lib/fontisan/export/transformers/font_to_ttx.rb +172 -0
  48. data/lib/fontisan/export/transformers/head_transformer.rb +96 -0
  49. data/lib/fontisan/export/transformers/hhea_transformer.rb +59 -0
  50. data/lib/fontisan/export/transformers/maxp_transformer.rb +63 -0
  51. data/lib/fontisan/export/transformers/name_transformer.rb +63 -0
  52. data/lib/fontisan/export/transformers/os2_transformer.rb +121 -0
  53. data/lib/fontisan/export/transformers/post_transformer.rb +51 -0
  54. data/lib/fontisan/export/ttx_generator.rb +527 -0
  55. data/lib/fontisan/export/ttx_parser.rb +300 -0
  56. data/lib/fontisan/font_loader.rb +121 -12
  57. data/lib/fontisan/font_writer.rb +301 -0
  58. data/lib/fontisan/formatters/text_formatter.rb +102 -0
  59. data/lib/fontisan/glyph_accessor.rb +503 -0
  60. data/lib/fontisan/hints/hint_converter.rb +177 -0
  61. data/lib/fontisan/hints/postscript_hint_applier.rb +185 -0
  62. data/lib/fontisan/hints/postscript_hint_extractor.rb +254 -0
  63. data/lib/fontisan/hints/truetype_hint_applier.rb +71 -0
  64. data/lib/fontisan/hints/truetype_hint_extractor.rb +162 -0
  65. data/lib/fontisan/loading_modes.rb +113 -0
  66. data/lib/fontisan/metrics_calculator.rb +277 -0
  67. data/lib/fontisan/models/collection_font_summary.rb +52 -0
  68. data/lib/fontisan/models/collection_info.rb +76 -0
  69. data/lib/fontisan/models/collection_list_info.rb +37 -0
  70. data/lib/fontisan/models/font_export.rb +158 -0
  71. data/lib/fontisan/models/font_summary.rb +48 -0
  72. data/lib/fontisan/models/glyph_outline.rb +343 -0
  73. data/lib/fontisan/models/hint.rb +233 -0
  74. data/lib/fontisan/models/outline.rb +664 -0
  75. data/lib/fontisan/models/table_sharing_info.rb +40 -0
  76. data/lib/fontisan/models/ttx/glyph_order.rb +31 -0
  77. data/lib/fontisan/models/ttx/tables/binary_table.rb +67 -0
  78. data/lib/fontisan/models/ttx/tables/head_table.rb +74 -0
  79. data/lib/fontisan/models/ttx/tables/hhea_table.rb +74 -0
  80. data/lib/fontisan/models/ttx/tables/maxp_table.rb +55 -0
  81. data/lib/fontisan/models/ttx/tables/name_table.rb +45 -0
  82. data/lib/fontisan/models/ttx/tables/os2_table.rb +157 -0
  83. data/lib/fontisan/models/ttx/tables/post_table.rb +50 -0
  84. data/lib/fontisan/models/ttx/ttfont.rb +49 -0
  85. data/lib/fontisan/models/validation_report.rb +203 -0
  86. data/lib/fontisan/open_type_collection.rb +156 -2
  87. data/lib/fontisan/open_type_font.rb +296 -10
  88. data/lib/fontisan/optimizers/charstring_rewriter.rb +161 -0
  89. data/lib/fontisan/optimizers/pattern_analyzer.rb +308 -0
  90. data/lib/fontisan/optimizers/stack_tracker.rb +246 -0
  91. data/lib/fontisan/optimizers/subroutine_builder.rb +134 -0
  92. data/lib/fontisan/optimizers/subroutine_generator.rb +207 -0
  93. data/lib/fontisan/optimizers/subroutine_optimizer.rb +107 -0
  94. data/lib/fontisan/outline_extractor.rb +423 -0
  95. data/lib/fontisan/subset/builder.rb +268 -0
  96. data/lib/fontisan/subset/glyph_mapping.rb +215 -0
  97. data/lib/fontisan/subset/options.rb +142 -0
  98. data/lib/fontisan/subset/profile.rb +152 -0
  99. data/lib/fontisan/subset/table_subsetter.rb +461 -0
  100. data/lib/fontisan/svg/font_face_generator.rb +278 -0
  101. data/lib/fontisan/svg/font_generator.rb +264 -0
  102. data/lib/fontisan/svg/glyph_generator.rb +168 -0
  103. data/lib/fontisan/svg/view_box_calculator.rb +137 -0
  104. data/lib/fontisan/tables/cff/cff_glyph.rb +176 -0
  105. data/lib/fontisan/tables/cff/charset.rb +282 -0
  106. data/lib/fontisan/tables/cff/charstring.rb +905 -0
  107. data/lib/fontisan/tables/cff/charstring_builder.rb +322 -0
  108. data/lib/fontisan/tables/cff/charstrings_index.rb +162 -0
  109. data/lib/fontisan/tables/cff/dict.rb +351 -0
  110. data/lib/fontisan/tables/cff/dict_builder.rb +242 -0
  111. data/lib/fontisan/tables/cff/encoding.rb +274 -0
  112. data/lib/fontisan/tables/cff/header.rb +102 -0
  113. data/lib/fontisan/tables/cff/index.rb +237 -0
  114. data/lib/fontisan/tables/cff/index_builder.rb +170 -0
  115. data/lib/fontisan/tables/cff/private_dict.rb +284 -0
  116. data/lib/fontisan/tables/cff/top_dict.rb +236 -0
  117. data/lib/fontisan/tables/cff.rb +487 -0
  118. data/lib/fontisan/tables/cff2/blend_operator.rb +240 -0
  119. data/lib/fontisan/tables/cff2/charstring_parser.rb +591 -0
  120. data/lib/fontisan/tables/cff2/operand_stack.rb +232 -0
  121. data/lib/fontisan/tables/cff2.rb +341 -0
  122. data/lib/fontisan/tables/cvar.rb +242 -0
  123. data/lib/fontisan/tables/fvar.rb +2 -2
  124. data/lib/fontisan/tables/glyf/compound_glyph.rb +483 -0
  125. data/lib/fontisan/tables/glyf/compound_glyph_resolver.rb +136 -0
  126. data/lib/fontisan/tables/glyf/curve_converter.rb +343 -0
  127. data/lib/fontisan/tables/glyf/glyph_builder.rb +450 -0
  128. data/lib/fontisan/tables/glyf/simple_glyph.rb +382 -0
  129. data/lib/fontisan/tables/glyf.rb +235 -0
  130. data/lib/fontisan/tables/gvar.rb +270 -0
  131. data/lib/fontisan/tables/hhea.rb +124 -0
  132. data/lib/fontisan/tables/hmtx.rb +287 -0
  133. data/lib/fontisan/tables/hvar.rb +191 -0
  134. data/lib/fontisan/tables/loca.rb +322 -0
  135. data/lib/fontisan/tables/maxp.rb +192 -0
  136. data/lib/fontisan/tables/mvar.rb +185 -0
  137. data/lib/fontisan/tables/name.rb +99 -30
  138. data/lib/fontisan/tables/variation_common.rb +346 -0
  139. data/lib/fontisan/tables/vvar.rb +234 -0
  140. data/lib/fontisan/true_type_collection.rb +156 -2
  141. data/lib/fontisan/true_type_font.rb +297 -11
  142. data/lib/fontisan/utilities/brotli_wrapper.rb +159 -0
  143. data/lib/fontisan/utilities/checksum_calculator.rb +18 -0
  144. data/lib/fontisan/utils/thread_pool.rb +134 -0
  145. data/lib/fontisan/validation/checksum_validator.rb +170 -0
  146. data/lib/fontisan/validation/consistency_validator.rb +197 -0
  147. data/lib/fontisan/validation/structure_validator.rb +198 -0
  148. data/lib/fontisan/validation/table_validator.rb +158 -0
  149. data/lib/fontisan/validation/validator.rb +152 -0
  150. data/lib/fontisan/variable/axis_normalizer.rb +215 -0
  151. data/lib/fontisan/variable/delta_applicator.rb +313 -0
  152. data/lib/fontisan/variable/glyph_delta_processor.rb +218 -0
  153. data/lib/fontisan/variable/instancer.rb +344 -0
  154. data/lib/fontisan/variable/metric_delta_processor.rb +282 -0
  155. data/lib/fontisan/variable/region_matcher.rb +208 -0
  156. data/lib/fontisan/variable/static_font_builder.rb +213 -0
  157. data/lib/fontisan/variable/table_updater.rb +219 -0
  158. data/lib/fontisan/variation/blend_applier.rb +199 -0
  159. data/lib/fontisan/variation/cache.rb +298 -0
  160. data/lib/fontisan/variation/cache_key_builder.rb +162 -0
  161. data/lib/fontisan/variation/converter.rb +268 -0
  162. data/lib/fontisan/variation/data_extractor.rb +86 -0
  163. data/lib/fontisan/variation/delta_applier.rb +266 -0
  164. data/lib/fontisan/variation/delta_parser.rb +228 -0
  165. data/lib/fontisan/variation/inspector.rb +275 -0
  166. data/lib/fontisan/variation/instance_generator.rb +273 -0
  167. data/lib/fontisan/variation/interpolator.rb +231 -0
  168. data/lib/fontisan/variation/metrics_adjuster.rb +318 -0
  169. data/lib/fontisan/variation/optimizer.rb +418 -0
  170. data/lib/fontisan/variation/parallel_generator.rb +150 -0
  171. data/lib/fontisan/variation/region_matcher.rb +221 -0
  172. data/lib/fontisan/variation/subsetter.rb +463 -0
  173. data/lib/fontisan/variation/table_accessor.rb +105 -0
  174. data/lib/fontisan/variation/validator.rb +345 -0
  175. data/lib/fontisan/variation/variation_context.rb +211 -0
  176. data/lib/fontisan/version.rb +1 -1
  177. data/lib/fontisan/woff2/directory.rb +257 -0
  178. data/lib/fontisan/woff2/header.rb +101 -0
  179. data/lib/fontisan/woff2/table_transformer.rb +163 -0
  180. data/lib/fontisan/woff2_font.rb +712 -0
  181. data/lib/fontisan/woff_font.rb +483 -0
  182. data/lib/fontisan.rb +120 -0
  183. data/scripts/compare_stack_aware.rb +187 -0
  184. data/scripts/measure_optimization.rb +141 -0
  185. metadata +205 -4
@@ -0,0 +1,228 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "stringio"
4
+
5
+ module Fontisan
6
+ module Variation
7
+ # Parses variation deltas from gvar tuple data
8
+ #
9
+ # The gvar table stores deltas in various compression formats to minimize
10
+ # file size. This parser handles all delta formats and decompresses them
11
+ # into usable point delta arrays.
12
+ #
13
+ # Delta Formats:
14
+ # - DELTAS_ARE_ZERO: All deltas are zero (no data stored)
15
+ # - DELTAS_ARE_WORDS: Deltas stored as signed 16-bit words
16
+ # - DELTAS_ARE_BYTES: Deltas stored as signed 8-bit bytes
17
+ # - Point number runs: Compressed sequences of affected points
18
+ #
19
+ # Reference: OpenType specification, gvar table delta encoding
20
+ #
21
+ # @example Parsing delta data
22
+ # parser = Fontisan::Variation::DeltaParser.new
23
+ # deltas = parser.parse(tuple_data, point_count)
24
+ # # Returns: [{ x: 10, y: 5 }, { x: -3, y: 2 }, ...]
25
+ class DeltaParser
26
+ # Delta format flags (from tuple variation header flags)
27
+ DELTAS_ARE_ZERO = 0x80
28
+ DELTAS_ARE_WORDS = 0x40
29
+
30
+ # Point number flags
31
+ POINTS_ARE_WORDS = 0x80
32
+ POINT_RUN_COUNT_MASK = 0x7F
33
+
34
+ # Parse delta data from tuple variation
35
+ #
36
+ # @param data [String] Binary delta data
37
+ # @param point_count [Integer] Total number of points in glyph
38
+ # @param private_points [Boolean] Whether tuple has private point numbers
39
+ # @param shared_points [Array<Integer>, nil] Shared point numbers if applicable
40
+ # @return [Array<Hash>] Array of point deltas { x:, y: }
41
+ # @raise [VariationDataCorruptedError] If delta data is corrupted or cannot be parsed
42
+ def parse(data, point_count, private_points: false, shared_points: nil)
43
+ return zero_deltas(point_count) if data.nil? || data.empty?
44
+
45
+ io = StringIO.new(data)
46
+ io.set_encoding(Encoding::BINARY)
47
+
48
+ # Parse point numbers if present
49
+ points = if private_points
50
+ parse_point_numbers(io)
51
+ elsif shared_points
52
+ shared_points
53
+ else
54
+ # All points affected
55
+ (0...point_count).to_a
56
+ end
57
+
58
+ # Determine delta format from first byte (if present)
59
+ format_byte = io.getbyte
60
+ return zero_deltas(point_count) if format_byte.nil?
61
+
62
+ io.pos -= 1 # Put byte back
63
+
64
+ # Parse X deltas
65
+ x_deltas = parse_delta_array(io, points.length)
66
+
67
+ # Parse Y deltas
68
+ y_deltas = parse_delta_array(io, points.length)
69
+
70
+ # Build full delta array (zero for untouched points)
71
+ build_full_deltas(points, x_deltas, y_deltas, point_count)
72
+ rescue StandardError => e
73
+ raise VariationDataCorruptedError.new(
74
+ message: "Failed to parse delta data: #{e.message}",
75
+ details: {
76
+ point_count: point_count,
77
+ private_points: private_points,
78
+ error_class: e.class.name,
79
+ },
80
+ )
81
+ end
82
+
83
+ # Parse delta data with explicit format flag
84
+ #
85
+ # @param data [String] Binary delta data
86
+ # @param point_count [Integer] Total number of points
87
+ # @param flags [Integer] Tuple variation flags
88
+ # @return [Array<Hash>] Array of point deltas
89
+ def parse_with_flags(data, point_count, flags)
90
+ if (flags & DELTAS_ARE_ZERO).zero?
91
+ parse(data, point_count)
92
+ else
93
+ zero_deltas(point_count)
94
+ end
95
+ end
96
+
97
+ private
98
+
99
+ # Parse point numbers from packed format
100
+ #
101
+ # Point numbers indicate which points have deltas. Uses run-length
102
+ # encoding to compress sequences of point numbers.
103
+ #
104
+ # @param io [StringIO] Input stream
105
+ # @return [Array<Integer>] Array of point numbers
106
+ def parse_point_numbers(io)
107
+ points = []
108
+ first_byte = io.getbyte
109
+ return points if first_byte.nil?
110
+
111
+ # First byte indicates total number of point numbers
112
+ total_points = first_byte
113
+
114
+ # Parse all point number runs
115
+ point_index = 0
116
+ remaining = total_points
117
+
118
+ while remaining.positive?
119
+ control = io.getbyte
120
+ return points if control.nil?
121
+
122
+ # Number of points in this run
123
+ run_count = (control & POINT_RUN_COUNT_MASK) + 1
124
+
125
+ # Limit run_count to remaining points
126
+ run_count = [run_count, remaining].min
127
+
128
+ if (control & POINTS_ARE_WORDS).zero?
129
+ # Points stored as 8-bit bytes (deltas from previous)
130
+ run_count.times do
131
+ byte = io.getbyte
132
+ return points if byte.nil?
133
+
134
+ point_index += byte
135
+ points << point_index
136
+ remaining -= 1
137
+ end
138
+ else
139
+ # Points stored as 16-bit words
140
+ run_count.times do
141
+ bytes = io.read(2)
142
+ return points if bytes.nil? || bytes.bytesize < 2
143
+
144
+ point = bytes.unpack1("n")
145
+ points << point
146
+ point_index = point
147
+ remaining -= 1
148
+ end
149
+ end
150
+ end
151
+
152
+ points
153
+ end
154
+
155
+ # Parse an array of delta values
156
+ #
157
+ # Deltas can be stored as bytes or words depending on value range.
158
+ # The format is determined by inspecting the first byte.
159
+ #
160
+ # @param io [StringIO] Input stream
161
+ # @param count [Integer] Number of deltas to parse
162
+ # @return [Array<Integer>] Array of delta values
163
+ def parse_delta_array(io, count)
164
+ return [] if count.zero?
165
+
166
+ deltas = []
167
+
168
+ # Read control byte to determine format
169
+ control = io.getbyte
170
+ return deltas if control.nil?
171
+
172
+ if (control & DELTAS_ARE_WORDS).zero?
173
+ # Deltas stored as 8-bit signed bytes
174
+ count.times do
175
+ byte = io.getbyte
176
+ return deltas if byte.nil?
177
+
178
+ signed = byte > 0x7F ? byte - 0x100 : byte
179
+ deltas << signed
180
+ end
181
+ else
182
+ # Deltas stored as 16-bit signed words
183
+ count.times do
184
+ bytes = io.read(2)
185
+ return deltas if bytes.nil? || bytes.bytesize < 2
186
+
187
+ value = bytes.unpack1("n")
188
+ signed = value > 0x7FFF ? value - 0x10000 : value
189
+ deltas << signed
190
+ end
191
+ end
192
+
193
+ deltas
194
+ end
195
+
196
+ # Build full delta array including untouched points
197
+ #
198
+ # @param points [Array<Integer>] Point numbers with deltas
199
+ # @param x_deltas [Array<Integer>] X deltas
200
+ # @param y_deltas [Array<Integer>] Y deltas
201
+ # @param point_count [Integer] Total points in glyph
202
+ # @return [Array<Hash>] Full delta array
203
+ def build_full_deltas(points, x_deltas, y_deltas, point_count)
204
+ full_deltas = Array.new(point_count) { { x: 0, y: 0 } }
205
+
206
+ points.each_with_index do |point_num, i|
207
+ next if point_num >= point_count
208
+ next if i >= x_deltas.length || i >= y_deltas.length
209
+
210
+ full_deltas[point_num] = {
211
+ x: x_deltas[i],
212
+ y: y_deltas[i],
213
+ }
214
+ end
215
+
216
+ full_deltas
217
+ end
218
+
219
+ # Create array of zero deltas
220
+ #
221
+ # @param count [Integer] Number of deltas
222
+ # @return [Array<Hash>] Array of zero deltas
223
+ def zero_deltas(count)
224
+ Array.new(count) { { x: 0, y: 0 } }
225
+ end
226
+ end
227
+ end
228
+ end
@@ -0,0 +1,275 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "yaml"
5
+ require_relative "variation_context"
6
+
7
+ module Fontisan
8
+ module Variation
9
+ # Inspects and analyzes variable font structure
10
+ #
11
+ # This class provides comprehensive analysis of variable font structure,
12
+ # including axes, instances, regions, and variation statistics. Results
13
+ # can be exported to JSON or YAML formats.
14
+ #
15
+ # @example Inspecting a variable font
16
+ # inspector = Fontisan::Variation::Inspector.new(font)
17
+ # info = inspector.inspect_variation
18
+ # # => { axes: [...], instances: [...], regions: {...}, statistics: {...} }
19
+ #
20
+ # @example Exporting to JSON
21
+ # inspector.export_json
22
+ # # => "{ \"axes\": [...], ... }"
23
+ #
24
+ # @example Exporting to YAML
25
+ # inspector.export_yaml
26
+ # # => "---\naxes:\n - ..."
27
+ class Inspector
28
+ # @return [TrueTypeFont, OpenTypeFont] Font to inspect
29
+ attr_reader :font
30
+
31
+ # @return [VariationContext] Variation context
32
+ attr_reader :context
33
+
34
+ # Initialize inspector
35
+ #
36
+ # @param font [TrueTypeFont, OpenTypeFont] Variable font
37
+ def initialize(font)
38
+ @font = font
39
+ @context = VariationContext.new(font)
40
+ end
41
+
42
+ # Inspect complete variation structure
43
+ #
44
+ # Returns comprehensive information about font variation capabilities.
45
+ #
46
+ # @return [Hash] Complete variation information
47
+ def inspect_variation
48
+ {
49
+ axes: inspect_axes,
50
+ instances: inspect_instances,
51
+ regions: inspect_regions,
52
+ statistics: calculate_statistics,
53
+ }
54
+ end
55
+
56
+ # Export inspection results as JSON
57
+ #
58
+ # @return [String] JSON formatted output
59
+ def export_json
60
+ JSON.pretty_generate(inspect_variation)
61
+ end
62
+
63
+ # Export inspection results as YAML
64
+ #
65
+ # @return [String] YAML formatted output
66
+ def export_yaml
67
+ YAML.dump(inspect_variation)
68
+ end
69
+
70
+ # Check if font is a variable font
71
+ #
72
+ # @return [Boolean] True if font has variation tables
73
+ def variable_font?
74
+ @context.variable_font?
75
+ end
76
+
77
+ private
78
+
79
+ # Inspect variation axes
80
+ #
81
+ # @return [Array<Hash>] Array of axis information
82
+ def inspect_axes
83
+ return [] unless variable_font?
84
+ return [] unless @context.fvar
85
+
86
+ @context.axes.map do |axis|
87
+ {
88
+ tag: axis.axis_tag,
89
+ name: axis_name(axis.axis_name_id),
90
+ min: axis.min_value,
91
+ default: axis.default_value,
92
+ max: axis.max_value,
93
+ hidden: axis.flags & 0x0001 != 0,
94
+ }
95
+ end
96
+ end
97
+
98
+ # Inspect named instances
99
+ #
100
+ # @return [Array<Hash>] Array of instance information
101
+ def inspect_instances
102
+ return [] unless variable_font?
103
+ return [] unless @context.fvar
104
+
105
+ @context.fvar.instances.map.with_index do |instance, index|
106
+ {
107
+ index: index,
108
+ name: instance_name(instance[:subfamily_name_id]),
109
+ postscript_name: instance_name(instance[:postscript_name_id]),
110
+ coordinates: instance_coordinates(instance[:coordinates], @context.axes),
111
+ }
112
+ end
113
+ end
114
+
115
+ # Inspect variation regions
116
+ #
117
+ # @return [Hash] Region statistics and information
118
+ def inspect_regions
119
+ regions = {
120
+ gvar: nil,
121
+ hvar: nil,
122
+ vvar: nil,
123
+ mvar: nil,
124
+ }
125
+
126
+ if @font.has_table?("gvar")
127
+ regions[:gvar] = inspect_gvar_regions
128
+ end
129
+
130
+ if @font.has_table?("HVAR")
131
+ regions[:hvar] = inspect_hvar_regions
132
+ end
133
+
134
+ if @font.has_table?("VVAR")
135
+ regions[:vvar] = inspect_vvar_regions
136
+ end
137
+
138
+ if @font.has_table?("MVAR")
139
+ regions[:mvar] = inspect_mvar_regions
140
+ end
141
+
142
+ regions.compact
143
+ end
144
+
145
+ # Inspect gvar table regions
146
+ #
147
+ # @return [Hash] Gvar region information
148
+ def inspect_gvar_regions
149
+ gvar = @font.table("gvar")
150
+ return nil unless gvar
151
+
152
+ {
153
+ glyph_count: gvar.glyph_count,
154
+ axis_count: gvar.axis_count,
155
+ shared_tuples: gvar.shared_tuple_count || 0,
156
+ glyph_variation_data_present: gvar.glyph_count.positive?,
157
+ }
158
+ end
159
+
160
+ # Inspect HVAR table regions
161
+ #
162
+ # @return [Hash] HVAR region information
163
+ def inspect_hvar_regions
164
+ hvar = @font.table("HVAR")
165
+ return nil unless hvar
166
+
167
+ {
168
+ advance_width_mapping: hvar.advance_width_mapping ? true : false,
169
+ lsb_mapping: hvar.lsb_mapping ? true : false,
170
+ rsb_mapping: hvar.rsb_mapping ? true : false,
171
+ }
172
+ end
173
+
174
+ # Inspect VVAR table regions
175
+ #
176
+ # @return [Hash] VVAR region information
177
+ def inspect_vvar_regions
178
+ vvar = @font.table("VVAR")
179
+ return nil unless vvar
180
+
181
+ {
182
+ advance_height_mapping: vvar.advance_height_mapping ? true : false,
183
+ tsb_mapping: vvar.tsb_mapping ? true : false,
184
+ bsb_mapping: vvar.bsb_mapping ? true : false,
185
+ }
186
+ end
187
+
188
+ # Inspect MVAR table regions
189
+ #
190
+ # @return [Hash] MVAR region information
191
+ def inspect_mvar_regions
192
+ mvar = @font.table("MVAR")
193
+ return nil unless mvar
194
+
195
+ {
196
+ value_record_count: mvar.value_record_count || 0,
197
+ metrics_varied: mvar.value_records&.map { |r| r[:value_tag] } || [],
198
+ }
199
+ end
200
+
201
+ # Calculate variation statistics
202
+ #
203
+ # @return [Hash] Statistical information
204
+ def calculate_statistics
205
+ stats = {
206
+ is_variable: variable_font?,
207
+ axis_count: 0,
208
+ instance_count: 0,
209
+ has_glyph_variations: @context.has_glyph_variations?,
210
+ has_metrics_variations: @context.has_metrics_variations?,
211
+ variation_tables: [],
212
+ }
213
+
214
+ if variable_font?
215
+ stats[:axis_count] = @context.axis_count
216
+ stats[:instance_count] = @context.fvar.instance_count if @context.fvar
217
+ end
218
+
219
+ # List variation tables present
220
+ variation_table_tags = %w[fvar gvar cvar HVAR VVAR MVAR avar STAT]
221
+ stats[:variation_tables] = variation_table_tags.select do |tag|
222
+ @font.has_table?(tag)
223
+ end
224
+
225
+ # Calculate design space size
226
+ if stats[:axis_count].positive?
227
+ stats[:design_space_dimensions] = stats[:axis_count]
228
+ end
229
+
230
+ stats
231
+ end
232
+
233
+ # Get axis name from name table
234
+ #
235
+ # @param name_id [Integer] Name ID
236
+ # @return [String] Axis name
237
+ def axis_name(name_id)
238
+ return "Unknown" unless @font.has_table?("name")
239
+
240
+ name_table = @font.table("name")
241
+ record = name_table.names.find { |n| n[:name_id] == name_id }
242
+ record ? record[:string] : "Axis #{name_id}"
243
+ end
244
+
245
+ # Get instance name from name table
246
+ #
247
+ # @param name_id [Integer] Name ID
248
+ # @return [String, nil] Instance name
249
+ def instance_name(name_id)
250
+ return nil unless name_id
251
+ return nil unless @font.has_table?("name")
252
+
253
+ name_table = @font.table("name")
254
+ record = name_table.names.find { |n| n[:name_id] == name_id }
255
+ record ? record[:string] : "Instance #{name_id}"
256
+ end
257
+
258
+ # Build coordinates hash from instance
259
+ #
260
+ # @param coordinates [Array<Float>] Coordinate values
261
+ # @param axes [Array] Variation axes
262
+ # @return [Hash<String, Float>] Coordinates by axis tag
263
+ def instance_coordinates(coordinates, axes)
264
+ coords = {}
265
+ coordinates.each_with_index do |value, index|
266
+ break if index >= axes.length
267
+
268
+ axis = axes[index]
269
+ coords[axis.axis_tag] = value
270
+ end
271
+ coords
272
+ end
273
+ end
274
+ end
275
+ end