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,221 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Variation
5
+ # Region matcher for variable fonts
6
+ #
7
+ # This class matches design space coordinates to variation regions/tuples,
8
+ # determining which regions contribute to the final interpolated value
9
+ # and calculating their contribution scalars.
10
+ #
11
+ # A variation region defines a sub-space within the design space where
12
+ # a particular set of deltas applies. Regions are defined by start, peak,
13
+ # and end coordinates on each axis.
14
+ #
15
+ # Matching Process:
16
+ # 1. For each region, check if current coordinates fall within the region
17
+ # 2. Calculate the scalar (contribution factor) for each matching region
18
+ # 3. Return only non-zero contributions
19
+ #
20
+ # Reference: OpenType Font Variations specification, gvar table
21
+ #
22
+ # @example Matching coordinates to regions
23
+ # matcher = RegionMatcher.new(axes)
24
+ # matches = matcher.match_regions(
25
+ # coordinates: { "wght" => 600.0 },
26
+ # regions: [region1, region2, region3]
27
+ # )
28
+ # # => [{ region_index: 0, scalar: 0.5 }, { region_index: 1, scalar: 0.8 }]
29
+ class RegionMatcher
30
+ # @return [Array<VariationAxisRecord>] Variation axes
31
+ attr_reader :axes
32
+
33
+ # @return [Interpolator] Coordinate interpolator
34
+ attr_reader :interpolator
35
+
36
+ # Initialize region matcher
37
+ #
38
+ # @param axes [Array<VariationAxisRecord>] Variation axes from fvar table
39
+ def initialize(axes)
40
+ @axes = axes || []
41
+ @interpolator = Interpolator.new(@axes)
42
+ end
43
+
44
+ # Match coordinates to variation regions
45
+ #
46
+ # Returns all regions that contribute (have non-zero scalar) at the
47
+ # given coordinates, along with their contribution scalars.
48
+ #
49
+ # @param coordinates [Hash<String, Float>] User-space coordinates
50
+ # @param regions [Array<Hash>] Array of region definitions
51
+ # @return [Array<Hash>] Matches with :region_index and :scalar
52
+ def match_regions(coordinates:, regions:)
53
+ # Normalize coordinates
54
+ normalized = @interpolator.normalize_coordinates(coordinates)
55
+
56
+ # Find matching regions
57
+ matches = []
58
+ regions.each_with_index do |region, index|
59
+ scalar = @interpolator.calculate_region_scalar(normalized, region)
60
+
61
+ # Only include non-zero contributions
62
+ matches << { region_index: index, scalar: scalar } if scalar > 0.0
63
+ end
64
+
65
+ matches
66
+ end
67
+
68
+ # Match coordinates to gvar tuple variations
69
+ #
70
+ # Converts gvar tuple data to regions and matches them.
71
+ #
72
+ # @param coordinates [Hash<String, Float>] User-space coordinates
73
+ # @param tuples [Array<Hash>] Tuple variation data from gvar
74
+ # @return [Array<Hash>] Matches with :tuple_index and :scalar
75
+ def match_tuples(coordinates:, tuples:)
76
+ # Convert tuples to regions
77
+ regions = tuples.map do |tuple|
78
+ @interpolator.build_region_from_tuple(tuple)
79
+ end
80
+
81
+ # Match regions
82
+ match_regions(coordinates: coordinates, regions: regions)
83
+ end
84
+
85
+ # Check if coordinates are within a region
86
+ #
87
+ # @param coordinates [Hash<String, Float>] Normalized coordinates
88
+ # @param region [Hash<String, Hash>] Region definition per axis
89
+ # @return [Boolean] True if within region
90
+ def within_region?(coordinates, region)
91
+ region.all? do |axis_tag, axis_region|
92
+ coord = coordinates[axis_tag] || 0.0
93
+ start_val = axis_region[:start] || -1.0
94
+ end_val = axis_region[:end] || 1.0
95
+
96
+ coord >= start_val && coord <= end_val
97
+ end
98
+ end
99
+
100
+ # Get active regions at coordinates
101
+ #
102
+ # Returns the subset of regions that are active (non-zero contribution)
103
+ # at the given coordinates.
104
+ #
105
+ # @param coordinates [Hash<String, Float>] User-space coordinates
106
+ # @param regions [Array<Hash>] All regions
107
+ # @return [Array<Integer>] Indices of active regions
108
+ def active_region_indices(coordinates, regions)
109
+ matches = match_regions(coordinates: coordinates, regions: regions)
110
+ matches.map { |m| m[:region_index] }
111
+ end
112
+
113
+ # Calculate contribution percentages for all regions
114
+ #
115
+ # Returns the percentage contribution of each region at the given
116
+ # coordinates. All percentages sum to 100% (or less if some regions
117
+ # are inactive).
118
+ #
119
+ # @param coordinates [Hash<String, Float>] User-space coordinates
120
+ # @param regions [Array<Hash>] All regions
121
+ # @return [Array<Float>] Contribution percentages (0.0 to 1.0)
122
+ def contribution_percentages(coordinates, regions)
123
+ matches = match_regions(coordinates: coordinates, regions: regions)
124
+
125
+ # Calculate total scalar
126
+ total_scalar = matches.sum { |m| m[:scalar] }
127
+ return Array.new(regions.size, 0.0) if total_scalar.zero?
128
+
129
+ # Build percentage array
130
+ percentages = Array.new(regions.size, 0.0)
131
+ matches.each do |match|
132
+ percentages[match[:region_index]] = match[:scalar] / total_scalar
133
+ end
134
+
135
+ percentages
136
+ end
137
+
138
+ # Find the dominant region at coordinates
139
+ #
140
+ # Returns the region with the highest contribution scalar.
141
+ #
142
+ # @param coordinates [Hash<String, Float>] User-space coordinates
143
+ # @param regions [Array<Hash>] All regions
144
+ # @return [Hash, nil] Match with highest scalar or nil
145
+ def dominant_region(coordinates, regions)
146
+ matches = match_regions(coordinates: coordinates, regions: regions)
147
+ return nil if matches.empty?
148
+
149
+ matches.max_by { |m| m[:scalar] }
150
+ end
151
+
152
+ # Build region from peak coordinates
153
+ #
154
+ # Creates a simple region definition from peak coordinates only,
155
+ # using ±1.0 for start/end on each axis.
156
+ #
157
+ # @param peaks [Hash<String, Float>] Peak coordinates per axis
158
+ # @return [Hash<String, Hash>] Region definition
159
+ def build_region_from_peaks(peaks)
160
+ region = {}
161
+
162
+ @axes.each do |axis|
163
+ tag = axis.axis_tag
164
+ peak = peaks[tag] || 0.0
165
+
166
+ region[tag] = {
167
+ start: peak.negative? ? -1.0 : 0.0,
168
+ peak: peak,
169
+ end: peak.positive? ? 1.0 : 0.0,
170
+ }
171
+ end
172
+
173
+ region
174
+ end
175
+
176
+ # Build region from start, peak, end arrays
177
+ #
178
+ # Converts array-based region data (as in gvar) to hash-based format.
179
+ #
180
+ # @param start_arr [Array<Float>] Start coordinates (one per axis)
181
+ # @param peak_arr [Array<Float>] Peak coordinates (one per axis)
182
+ # @param end_arr [Array<Float>] End coordinates (one per axis)
183
+ # @return [Hash<String, Hash>] Region definition
184
+ def build_region_from_arrays(start_arr, peak_arr, end_arr)
185
+ region = {}
186
+
187
+ @axes.each_with_index do |axis, index|
188
+ region[axis.axis_tag] = {
189
+ start: start_arr[index] || -1.0,
190
+ peak: peak_arr[index] || 0.0,
191
+ end: end_arr[index] || 1.0,
192
+ }
193
+ end
194
+
195
+ region
196
+ end
197
+
198
+ # Validate region definition
199
+ #
200
+ # Checks if a region is well-formed.
201
+ #
202
+ # @param region [Hash<String, Hash>] Region definition
203
+ # @return [Boolean] True if valid
204
+ def valid_region?(region)
205
+ return false unless region.is_a?(Hash)
206
+
207
+ region.all? do |_axis_tag, axis_region|
208
+ next false unless axis_region.is_a?(Hash)
209
+ next false unless axis_region.key?(:peak)
210
+
211
+ start_val = axis_region[:start] || -1.0
212
+ peak = axis_region[:peak]
213
+ end_val = axis_region[:end] || 1.0
214
+
215
+ # Validate ordering: start <= peak <= end
216
+ start_val <= peak && peak <= end_val
217
+ end
218
+ end
219
+ end
220
+ end
221
+ end
@@ -0,0 +1,463 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "validator"
4
+ require_relative "optimizer"
5
+ require_relative "table_accessor"
6
+
7
+ module Fontisan
8
+ module Variation
9
+ # Subset variable fonts while preserving variation
10
+ #
11
+ # This class enables subsetting operations on variable fonts while
12
+ # maintaining variation capabilities. It can subset by glyphs, axes,
13
+ # or both, and includes validation to ensure the resulting subset
14
+ # remains a valid variable font.
15
+ #
16
+ # Subsetting operations:
17
+ # 1. Glyph subsetting - Keep only specified glyphs with their variations
18
+ # 2. Axis subsetting - Keep only specified axes
19
+ # 3. Region simplification - Deduplicate and merge similar regions
20
+ # 4. Validation - Ensure subset integrity
21
+ #
22
+ # @example Subset to specific glyphs
23
+ # subsetter = Fontisan::Variation::Subsetter.new(font)
24
+ # result = subsetter.subset_glyphs([0, 1, 2, 3])
25
+ #
26
+ # @example Subset to specific axes
27
+ # subsetter = Fontisan::Variation::Subsetter.new(font)
28
+ # result = subsetter.subset_axes(["wght", "wdth"])
29
+ class Subsetter
30
+ include TableAccessor
31
+
32
+ # @return [TrueTypeFont, OpenTypeFont] Font being subset
33
+ attr_reader :font
34
+
35
+ # @return [Validator] Validation utility
36
+ attr_reader :validator
37
+
38
+ # @return [Hash] Subsetter options
39
+ attr_reader :options
40
+
41
+ # @return [Hash] Last operation report
42
+ attr_reader :report
43
+
44
+ # Initialize subsetter
45
+ #
46
+ # @param font [TrueTypeFont, OpenTypeFont] Variable font to subset
47
+ # @param options [Hash] Subsetter options
48
+ # @option options [Boolean] :validate Validate before/after subsetting (default: true)
49
+ # @option options [Boolean] :optimize Optimize after subsetting (default: true)
50
+ # @option options [Float] :region_threshold Region similarity threshold (default: 0.01)
51
+ def initialize(font, options = {})
52
+ @font = font
53
+ @validator = Validator.new(font)
54
+ @options = {
55
+ validate: true,
56
+ optimize: true,
57
+ region_threshold: 0.01,
58
+ }.merge(options)
59
+ @report = {}
60
+ @variation_tables = {}
61
+ end
62
+
63
+ # Subset glyphs while preserving variation
64
+ #
65
+ # Filters variation data to keep only specified glyphs.
66
+ #
67
+ # @param glyph_ids [Array<Integer>] Glyph IDs to keep
68
+ # @return [Hash] Subset result with :tables and :report
69
+ def subset_glyphs(glyph_ids)
70
+ validate_input if @options[:validate]
71
+
72
+ @report = {
73
+ operation: :subset_glyphs,
74
+ original_glyph_count: get_glyph_count,
75
+ subset_glyph_count: glyph_ids.length,
76
+ glyphs_removed: get_glyph_count - glyph_ids.length,
77
+ }
78
+
79
+ # Start with all tables
80
+ tables = @font.table_data.dup
81
+
82
+ # Subset gvar if present
83
+ if has_variation_table?("gvar")
84
+ subset_gvar_table(tables, glyph_ids)
85
+ @report[:gvar_updated] = true
86
+ end
87
+
88
+ # Subset CFF2 if present
89
+ if has_variation_table?("CFF2")
90
+ subset_cff2_table(tables, glyph_ids)
91
+ @report[:cff2_updated] = true
92
+ end
93
+
94
+ # Subset metrics variations
95
+ subset_metrics_variations(tables, glyph_ids)
96
+
97
+ # Update non-variation tables
98
+ update_glyph_tables(tables, glyph_ids)
99
+
100
+ validate_output(tables) if @options[:validate]
101
+
102
+ { tables: tables, report: @report }
103
+ end
104
+
105
+ # Filter to specific axes
106
+ #
107
+ # Removes unused axes and updates all variation tables.
108
+ #
109
+ # @param axis_tags [Array<String>] Axis tags to keep (e.g., ["wght", "wdth"])
110
+ # @return [Hash] Subset result with :tables and :report
111
+ def subset_axes(axis_tags)
112
+ validate_input if @options[:validate]
113
+
114
+ fvar = variation_table("fvar")
115
+ return { tables: @font.table_data.dup, report: { error: "No fvar table" } } unless fvar
116
+
117
+ # Find axes to keep
118
+ all_axes = fvar.axes
119
+ keep_axes = all_axes.select { |axis| axis_tags.include?(axis.axis_tag) }
120
+ keep_indices = keep_axes.map { |axis| all_axes.index(axis) }
121
+
122
+ @report = {
123
+ operation: :subset_axes,
124
+ original_axis_count: all_axes.length,
125
+ subset_axis_count: keep_axes.length,
126
+ axes_removed: all_axes.length - keep_axes.length,
127
+ removed_axes: (all_axes.map(&:axis_tag) - axis_tags),
128
+ }
129
+
130
+ # Start with all tables
131
+ tables = @font.table_data.dup
132
+
133
+ # Update fvar table
134
+ subset_fvar_table(tables, keep_axes, keep_indices)
135
+
136
+ # Update gvar if present
137
+ if has_variation_table?("gvar")
138
+ subset_gvar_axes(tables, keep_indices)
139
+ @report[:gvar_updated] = true
140
+ end
141
+
142
+ # Update CFF2 if present
143
+ if has_variation_table?("CFF2")
144
+ subset_cff2_axes(tables, keep_indices)
145
+ @report[:cff2_updated] = true
146
+ end
147
+
148
+ # Update metrics variation tables
149
+ subset_metrics_axes(tables, keep_indices)
150
+
151
+ validate_output(tables) if @options[:validate]
152
+
153
+ { tables: tables, report: @report }
154
+ end
155
+
156
+ # Simplify regions within threshold
157
+ #
158
+ # Uses VariationOptimizer to deduplicate regions.
159
+ #
160
+ # @param threshold [Float] Similarity threshold (default: from options)
161
+ # @return [Hash] Simplification result with :tables and :report
162
+ def simplify_regions(threshold: nil)
163
+ threshold ||= @options[:region_threshold]
164
+
165
+ @report = {
166
+ operation: :simplify_regions,
167
+ threshold: threshold,
168
+ }
169
+
170
+ tables = @font.table_data.dup
171
+
172
+ # Optimize CFF2 if present
173
+ if has_variation_table?("CFF2")
174
+ cff2 = variation_table("CFF2")
175
+ optimizer = Optimizer.new(cff2, region_threshold: threshold)
176
+ optimizer.optimize
177
+
178
+ @report[:regions_deduplicated] = optimizer.stats[:regions_deduplicated]
179
+ @report[:cff2_optimized] = true
180
+ end
181
+
182
+ # Simplify metrics table regions
183
+ simplify_metrics_regions(tables, threshold)
184
+
185
+ validate_output(tables) if @options[:validate]
186
+
187
+ { tables: tables, report: @report }
188
+ end
189
+
190
+ # Combined subset operation
191
+ #
192
+ # Performs multiple subsetting operations in sequence.
193
+ #
194
+ # @param glyphs [Array<Integer>, nil] Glyph IDs to keep (nil = all)
195
+ # @param axes [Array<String>, nil] Axis tags to keep (nil = all)
196
+ # @param simplify [Boolean] Simplify regions after subsetting
197
+ # @return [Hash] Combined result with :tables and :report
198
+ def subset(glyphs: nil, axes: nil, simplify: true)
199
+ # Don't validate input here - let sub-methods handle it
200
+ # to avoid multiple validations
201
+
202
+ steps = []
203
+ tables = @font.table_data.dup
204
+
205
+ # Step 1: Subset glyphs if specified
206
+ if glyphs
207
+ subsetter = Subsetter.new(@font, @options)
208
+ glyph_result = subsetter.subset_glyphs(glyphs)
209
+ tables = glyph_result[:tables]
210
+ steps << { step: :subset_glyphs, report: glyph_result[:report] }
211
+ end
212
+
213
+ # Step 2: Subset axes if specified
214
+ if axes
215
+ # Create temporary font wrapper with subset tables
216
+ temp_font = create_temp_font(tables)
217
+ axis_subsetter = Subsetter.new(temp_font, @options)
218
+ axis_result = axis_subsetter.subset_axes(axes)
219
+ tables = axis_result[:tables]
220
+ steps << { step: :subset_axes, report: axis_result[:report] }
221
+ end
222
+
223
+ # Step 3: Simplify regions if requested
224
+ if simplify && @options[:optimize]
225
+ temp_font = create_temp_font(tables)
226
+ region_subsetter = Subsetter.new(temp_font, @options)
227
+ simplify_result = region_subsetter.simplify_regions
228
+ tables = simplify_result[:tables]
229
+ steps << { step: :simplify_regions, report: simplify_result[:report] }
230
+ end
231
+
232
+ # Create combined report
233
+ @report = {
234
+ operation: :combined_subset,
235
+ steps: steps,
236
+ }
237
+
238
+ # Validate final output if requested
239
+ if @options[:validate]
240
+ validate_output(tables)
241
+ end
242
+
243
+ { tables: tables, report: @report }
244
+ end
245
+
246
+ private
247
+
248
+ # Validate input font
249
+ # @raise [InvalidVariationDataError] If font is invalid
250
+ def validate_input
251
+ result = @validator.validate
252
+ return if result[:valid]
253
+
254
+ errors = result[:errors].join(", ")
255
+ raise InvalidVariationDataError.new(
256
+ message: "Invalid input font: #{errors}",
257
+ details: { validation_errors: result[:errors] },
258
+ )
259
+ end
260
+
261
+ # Validate output tables
262
+ #
263
+ # @param tables [Hash] Output tables
264
+ def validate_output(tables)
265
+ temp_font = create_temp_font(tables)
266
+ validator = Validator.new(temp_font)
267
+ result = validator.validate
268
+
269
+ @report[:validation] = result
270
+
271
+ unless result[:valid]
272
+ @report[:validation_errors] = result[:errors]
273
+ end
274
+ end
275
+
276
+ # Get glyph count from maxp table
277
+ #
278
+ # @return [Integer] Glyph count
279
+ def get_glyph_count
280
+ maxp = variation_table("maxp")
281
+ maxp ? maxp.num_glyphs : 0
282
+ end
283
+
284
+ # Subset gvar table to specific glyphs
285
+ #
286
+ # @param tables [Hash] Font tables
287
+ # @param glyph_ids [Array<Integer>] Glyph IDs to keep
288
+ def subset_gvar_table(_tables, _glyph_ids)
289
+ # This is a placeholder - full implementation would:
290
+ # 1. Read gvar table
291
+ # 2. Extract variation data for keep glyphs
292
+ # 3. Rebuild glyph variation data array with new offsets
293
+ # 4. Update glyph_count
294
+ # 5. Serialize back to binary
295
+
296
+ @report[:gvar_note] = "gvar subsetting not yet implemented"
297
+ end
298
+
299
+ # Subset CFF2 table to specific glyphs
300
+ #
301
+ # @param tables [Hash] Font tables
302
+ # @param glyph_ids [Array<Integer>] Glyph IDs to keep
303
+ def subset_cff2_table(_tables, _glyph_ids)
304
+ # This is a placeholder - full implementation would:
305
+ # 1. Read CFF2 table
306
+ # 2. Extract CharStrings for keep glyphs
307
+ # 3. Rebuild CharString INDEX
308
+ # 4. Update FDSelect if present
309
+ # 5. Serialize back to binary
310
+
311
+ @report[:cff2_note] = "CFF2 subsetting not yet implemented"
312
+ end
313
+
314
+ # Subset metrics variation tables
315
+ #
316
+ # @param tables [Hash] Font tables
317
+ # @param glyph_ids [Array<Integer>] Glyph IDs to keep
318
+ def subset_metrics_variations(tables, glyph_ids)
319
+ subset_metrics_table(tables, "HVAR", glyph_ids) if has_variation_table?("HVAR")
320
+ subset_metrics_table(tables, "VVAR", glyph_ids) if has_variation_table?("VVAR")
321
+ # MVAR is font-wide, no glyph subsetting needed
322
+ end
323
+
324
+ # Subset a single metrics table
325
+ #
326
+ # @param tables [Hash] Font tables
327
+ # @param table_tag [String] Table tag
328
+ # @param glyph_ids [Array<Integer>] Glyph IDs to keep
329
+ def subset_metrics_table(_tables, table_tag, _glyph_ids)
330
+ # This is a placeholder - full implementation would:
331
+ # 1. Read metrics table
332
+ # 2. Filter DeltaSetIndexMap to keep glyphs
333
+ # 3. Remove unused ItemVariationData
334
+ # 4. Rebuild and serialize
335
+
336
+ @report[:"#{table_tag.downcase}_note"] = "#{table_tag} subsetting not yet implemented"
337
+ end
338
+
339
+ # Update non-variation glyph tables
340
+ #
341
+ # @param tables [Hash] Font tables
342
+ # @param glyph_ids [Array<Integer>] Glyph IDs to keep
343
+ def update_glyph_tables(_tables, _glyph_ids)
344
+ # Update maxp
345
+ # Update glyf/loca or CFF
346
+ # Update cmap
347
+ # etc.
348
+
349
+ @report[:glyph_tables_note] = "Glyph table updates not yet implemented"
350
+ end
351
+
352
+ # Subset fvar table
353
+ #
354
+ # @param tables [Hash] Font tables
355
+ # @param keep_axes [Array] Axes to keep
356
+ # @param keep_indices [Array<Integer>] Axis indices to keep
357
+ def subset_fvar_table(_tables, _keep_axes, _keep_indices)
358
+ # This is a placeholder - full implementation would:
359
+ # 1. Rebuild fvar with subset axes
360
+ # 2. Update instances to remove coordinates for removed axes
361
+ # 3. Serialize back to binary
362
+
363
+ @report[:fvar_note] = "fvar subsetting not yet implemented"
364
+ end
365
+
366
+ # Subset gvar axes
367
+ #
368
+ # @param tables [Hash] Font tables
369
+ # @param keep_indices [Array<Integer>] Axis indices to keep
370
+ def subset_gvar_axes(_tables, _keep_indices)
371
+ # This is a placeholder - full implementation would:
372
+ # 1. Update axis_count
373
+ # 2. Filter shared tuples to keep indices
374
+ # 3. Filter tuple variations to keep indices
375
+ # 4. Serialize back to binary
376
+
377
+ @report[:gvar_axes_note] = "gvar axis subsetting not yet implemented"
378
+ end
379
+
380
+ # Subset CFF2 axes
381
+ #
382
+ # @param tables [Hash] Font tables
383
+ # @param keep_indices [Array<Integer>] Axis indices to keep
384
+ def subset_cff2_axes(_tables, _keep_indices)
385
+ # This is a placeholder - full implementation would:
386
+ # 1. Update num_axes in CFF2
387
+ # 2. Filter blend operands to keep indices
388
+ # 3. Update ItemVariationStore regions
389
+ # 4. Serialize back to binary
390
+
391
+ @report[:cff2_axes_note] = "CFF2 axis subsetting not yet implemented"
392
+ end
393
+
394
+ # Subset metrics table axes
395
+ #
396
+ # @param tables [Hash] Font tables
397
+ # @param keep_indices [Array<Integer>] Axis indices to keep
398
+ def subset_metrics_axes(tables, keep_indices)
399
+ subset_metrics_table_axes(tables, "HVAR", keep_indices) if has_variation_table?("HVAR")
400
+ subset_metrics_table_axes(tables, "VVAR", keep_indices) if has_variation_table?("VVAR")
401
+ subset_metrics_table_axes(tables, "MVAR", keep_indices) if has_variation_table?("MVAR")
402
+ end
403
+
404
+ # Subset a single metrics table's axes
405
+ #
406
+ # @param tables [Hash] Font tables
407
+ # @param table_tag [String] Table tag
408
+ # @param keep_indices [Array<Integer>] Axis indices to keep
409
+ def subset_metrics_table_axes(_tables, table_tag, _keep_indices)
410
+ # This is a placeholder - full implementation would:
411
+ # 1. Read metrics table
412
+ # 2. Filter ItemVariationStore regions to keep axis indices
413
+ # 3. Rebuild and serialize
414
+
415
+ @report[:"#{table_tag.downcase}_axes_note"] = "#{table_tag} axis subsetting not yet implemented"
416
+ end
417
+
418
+ # Simplify metrics table regions
419
+ #
420
+ # @param tables [Hash] Font tables
421
+ # @param threshold [Float] Similarity threshold
422
+ def simplify_metrics_regions(_tables, _threshold)
423
+ # This is a placeholder - full implementation would:
424
+ # 1. Load each metrics table
425
+ # 2. Deduplicate regions in ItemVariationStore
426
+ # 3. Update delta set indices
427
+ # 4. Serialize back to binary
428
+
429
+ @report[:metrics_simplify_note] = "Metrics region simplification not yet implemented"
430
+ end
431
+
432
+ # Create temporary font wrapper for validation
433
+ #
434
+ # @param tables [Hash] Table data
435
+ # @return [Object] Temporary font wrapper
436
+ def create_temp_font(tables)
437
+ # This is a simplified wrapper for validation
438
+ # In production, would create proper font object
439
+ Class.new do
440
+ attr_reader :table_data
441
+
442
+ def initialize(tables)
443
+ @table_data = tables
444
+ @parsed_tables = {}
445
+ end
446
+
447
+ def has_table?(tag)
448
+ @table_data.key?(tag)
449
+ end
450
+
451
+ def table(tag)
452
+ return @parsed_tables[tag] if @parsed_tables.key?(tag)
453
+ return nil unless has_table?(tag)
454
+
455
+ # Parse table on demand
456
+ # This is simplified - real implementation would use proper parsers
457
+ @parsed_tables[tag] = nil
458
+ end
459
+ end.new(tables)
460
+ end
461
+ end
462
+ end
463
+ end