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.
- checksums.yaml +4 -4
- data/.rubocop_todo.yml +529 -65
- data/Gemfile +1 -0
- data/LICENSE +5 -1
- data/README.adoc +1301 -275
- data/Rakefile +27 -2
- data/benchmark/variation_quick_bench.rb +47 -0
- data/docs/EXTRACT_TTC_MIGRATION.md +549 -0
- data/fontisan.gemspec +4 -1
- data/lib/fontisan/binary/base_record.rb +22 -1
- data/lib/fontisan/cli.rb +309 -0
- data/lib/fontisan/collection/builder.rb +260 -0
- data/lib/fontisan/collection/offset_calculator.rb +227 -0
- data/lib/fontisan/collection/table_analyzer.rb +204 -0
- data/lib/fontisan/collection/table_deduplicator.rb +241 -0
- data/lib/fontisan/collection/writer.rb +306 -0
- data/lib/fontisan/commands/base_command.rb +8 -1
- data/lib/fontisan/commands/convert_command.rb +291 -0
- data/lib/fontisan/commands/export_command.rb +161 -0
- data/lib/fontisan/commands/info_command.rb +40 -6
- data/lib/fontisan/commands/instance_command.rb +295 -0
- data/lib/fontisan/commands/ls_command.rb +113 -0
- data/lib/fontisan/commands/pack_command.rb +241 -0
- data/lib/fontisan/commands/subset_command.rb +245 -0
- data/lib/fontisan/commands/unpack_command.rb +338 -0
- data/lib/fontisan/commands/validate_command.rb +178 -0
- data/lib/fontisan/commands/variable_command.rb +30 -1
- data/lib/fontisan/config/collection_settings.yml +56 -0
- data/lib/fontisan/config/conversion_matrix.yml +212 -0
- data/lib/fontisan/config/export_settings.yml +66 -0
- data/lib/fontisan/config/subset_profiles.yml +100 -0
- data/lib/fontisan/config/svg_settings.yml +60 -0
- data/lib/fontisan/config/validation_rules.yml +149 -0
- data/lib/fontisan/config/variable_settings.yml +99 -0
- data/lib/fontisan/config/woff2_settings.yml +77 -0
- data/lib/fontisan/constants.rb +69 -0
- data/lib/fontisan/converters/conversion_strategy.rb +96 -0
- data/lib/fontisan/converters/format_converter.rb +259 -0
- data/lib/fontisan/converters/outline_converter.rb +936 -0
- data/lib/fontisan/converters/svg_generator.rb +244 -0
- data/lib/fontisan/converters/table_copier.rb +117 -0
- data/lib/fontisan/converters/woff2_encoder.rb +416 -0
- data/lib/fontisan/converters/woff_writer.rb +391 -0
- data/lib/fontisan/error.rb +203 -0
- data/lib/fontisan/export/exporter.rb +262 -0
- data/lib/fontisan/export/table_serializer.rb +255 -0
- data/lib/fontisan/export/transformers/font_to_ttx.rb +172 -0
- data/lib/fontisan/export/transformers/head_transformer.rb +96 -0
- data/lib/fontisan/export/transformers/hhea_transformer.rb +59 -0
- data/lib/fontisan/export/transformers/maxp_transformer.rb +63 -0
- data/lib/fontisan/export/transformers/name_transformer.rb +63 -0
- data/lib/fontisan/export/transformers/os2_transformer.rb +121 -0
- data/lib/fontisan/export/transformers/post_transformer.rb +51 -0
- data/lib/fontisan/export/ttx_generator.rb +527 -0
- data/lib/fontisan/export/ttx_parser.rb +300 -0
- data/lib/fontisan/font_loader.rb +121 -12
- data/lib/fontisan/font_writer.rb +301 -0
- data/lib/fontisan/formatters/text_formatter.rb +102 -0
- data/lib/fontisan/glyph_accessor.rb +503 -0
- data/lib/fontisan/hints/hint_converter.rb +177 -0
- data/lib/fontisan/hints/postscript_hint_applier.rb +185 -0
- data/lib/fontisan/hints/postscript_hint_extractor.rb +254 -0
- data/lib/fontisan/hints/truetype_hint_applier.rb +71 -0
- data/lib/fontisan/hints/truetype_hint_extractor.rb +162 -0
- data/lib/fontisan/loading_modes.rb +113 -0
- data/lib/fontisan/metrics_calculator.rb +277 -0
- data/lib/fontisan/models/collection_font_summary.rb +52 -0
- data/lib/fontisan/models/collection_info.rb +76 -0
- data/lib/fontisan/models/collection_list_info.rb +37 -0
- data/lib/fontisan/models/font_export.rb +158 -0
- data/lib/fontisan/models/font_summary.rb +48 -0
- data/lib/fontisan/models/glyph_outline.rb +343 -0
- data/lib/fontisan/models/hint.rb +233 -0
- data/lib/fontisan/models/outline.rb +664 -0
- data/lib/fontisan/models/table_sharing_info.rb +40 -0
- data/lib/fontisan/models/ttx/glyph_order.rb +31 -0
- data/lib/fontisan/models/ttx/tables/binary_table.rb +67 -0
- data/lib/fontisan/models/ttx/tables/head_table.rb +74 -0
- data/lib/fontisan/models/ttx/tables/hhea_table.rb +74 -0
- data/lib/fontisan/models/ttx/tables/maxp_table.rb +55 -0
- data/lib/fontisan/models/ttx/tables/name_table.rb +45 -0
- data/lib/fontisan/models/ttx/tables/os2_table.rb +157 -0
- data/lib/fontisan/models/ttx/tables/post_table.rb +50 -0
- data/lib/fontisan/models/ttx/ttfont.rb +49 -0
- data/lib/fontisan/models/validation_report.rb +203 -0
- data/lib/fontisan/open_type_collection.rb +156 -2
- data/lib/fontisan/open_type_font.rb +296 -10
- data/lib/fontisan/optimizers/charstring_rewriter.rb +161 -0
- data/lib/fontisan/optimizers/pattern_analyzer.rb +308 -0
- data/lib/fontisan/optimizers/stack_tracker.rb +246 -0
- data/lib/fontisan/optimizers/subroutine_builder.rb +134 -0
- data/lib/fontisan/optimizers/subroutine_generator.rb +207 -0
- data/lib/fontisan/optimizers/subroutine_optimizer.rb +107 -0
- data/lib/fontisan/outline_extractor.rb +423 -0
- data/lib/fontisan/subset/builder.rb +268 -0
- data/lib/fontisan/subset/glyph_mapping.rb +215 -0
- data/lib/fontisan/subset/options.rb +142 -0
- data/lib/fontisan/subset/profile.rb +152 -0
- data/lib/fontisan/subset/table_subsetter.rb +461 -0
- data/lib/fontisan/svg/font_face_generator.rb +278 -0
- data/lib/fontisan/svg/font_generator.rb +264 -0
- data/lib/fontisan/svg/glyph_generator.rb +168 -0
- data/lib/fontisan/svg/view_box_calculator.rb +137 -0
- data/lib/fontisan/tables/cff/cff_glyph.rb +176 -0
- data/lib/fontisan/tables/cff/charset.rb +282 -0
- data/lib/fontisan/tables/cff/charstring.rb +905 -0
- data/lib/fontisan/tables/cff/charstring_builder.rb +322 -0
- data/lib/fontisan/tables/cff/charstrings_index.rb +162 -0
- data/lib/fontisan/tables/cff/dict.rb +351 -0
- data/lib/fontisan/tables/cff/dict_builder.rb +242 -0
- data/lib/fontisan/tables/cff/encoding.rb +274 -0
- data/lib/fontisan/tables/cff/header.rb +102 -0
- data/lib/fontisan/tables/cff/index.rb +237 -0
- data/lib/fontisan/tables/cff/index_builder.rb +170 -0
- data/lib/fontisan/tables/cff/private_dict.rb +284 -0
- data/lib/fontisan/tables/cff/top_dict.rb +236 -0
- data/lib/fontisan/tables/cff.rb +487 -0
- data/lib/fontisan/tables/cff2/blend_operator.rb +240 -0
- data/lib/fontisan/tables/cff2/charstring_parser.rb +591 -0
- data/lib/fontisan/tables/cff2/operand_stack.rb +232 -0
- data/lib/fontisan/tables/cff2.rb +341 -0
- data/lib/fontisan/tables/cvar.rb +242 -0
- data/lib/fontisan/tables/fvar.rb +2 -2
- data/lib/fontisan/tables/glyf/compound_glyph.rb +483 -0
- data/lib/fontisan/tables/glyf/compound_glyph_resolver.rb +136 -0
- data/lib/fontisan/tables/glyf/curve_converter.rb +343 -0
- data/lib/fontisan/tables/glyf/glyph_builder.rb +450 -0
- data/lib/fontisan/tables/glyf/simple_glyph.rb +382 -0
- data/lib/fontisan/tables/glyf.rb +235 -0
- data/lib/fontisan/tables/gvar.rb +270 -0
- data/lib/fontisan/tables/hhea.rb +124 -0
- data/lib/fontisan/tables/hmtx.rb +287 -0
- data/lib/fontisan/tables/hvar.rb +191 -0
- data/lib/fontisan/tables/loca.rb +322 -0
- data/lib/fontisan/tables/maxp.rb +192 -0
- data/lib/fontisan/tables/mvar.rb +185 -0
- data/lib/fontisan/tables/name.rb +99 -30
- data/lib/fontisan/tables/variation_common.rb +346 -0
- data/lib/fontisan/tables/vvar.rb +234 -0
- data/lib/fontisan/true_type_collection.rb +156 -2
- data/lib/fontisan/true_type_font.rb +297 -11
- data/lib/fontisan/utilities/brotli_wrapper.rb +159 -0
- data/lib/fontisan/utilities/checksum_calculator.rb +18 -0
- data/lib/fontisan/utils/thread_pool.rb +134 -0
- data/lib/fontisan/validation/checksum_validator.rb +170 -0
- data/lib/fontisan/validation/consistency_validator.rb +197 -0
- data/lib/fontisan/validation/structure_validator.rb +198 -0
- data/lib/fontisan/validation/table_validator.rb +158 -0
- data/lib/fontisan/validation/validator.rb +152 -0
- data/lib/fontisan/variable/axis_normalizer.rb +215 -0
- data/lib/fontisan/variable/delta_applicator.rb +313 -0
- data/lib/fontisan/variable/glyph_delta_processor.rb +218 -0
- data/lib/fontisan/variable/instancer.rb +344 -0
- data/lib/fontisan/variable/metric_delta_processor.rb +282 -0
- data/lib/fontisan/variable/region_matcher.rb +208 -0
- data/lib/fontisan/variable/static_font_builder.rb +213 -0
- data/lib/fontisan/variable/table_updater.rb +219 -0
- data/lib/fontisan/variation/blend_applier.rb +199 -0
- data/lib/fontisan/variation/cache.rb +298 -0
- data/lib/fontisan/variation/cache_key_builder.rb +162 -0
- data/lib/fontisan/variation/converter.rb +268 -0
- data/lib/fontisan/variation/data_extractor.rb +86 -0
- data/lib/fontisan/variation/delta_applier.rb +266 -0
- data/lib/fontisan/variation/delta_parser.rb +228 -0
- data/lib/fontisan/variation/inspector.rb +275 -0
- data/lib/fontisan/variation/instance_generator.rb +273 -0
- data/lib/fontisan/variation/interpolator.rb +231 -0
- data/lib/fontisan/variation/metrics_adjuster.rb +318 -0
- data/lib/fontisan/variation/optimizer.rb +418 -0
- data/lib/fontisan/variation/parallel_generator.rb +150 -0
- data/lib/fontisan/variation/region_matcher.rb +221 -0
- data/lib/fontisan/variation/subsetter.rb +463 -0
- data/lib/fontisan/variation/table_accessor.rb +105 -0
- data/lib/fontisan/variation/validator.rb +345 -0
- data/lib/fontisan/variation/variation_context.rb +211 -0
- data/lib/fontisan/version.rb +1 -1
- data/lib/fontisan/woff2/directory.rb +257 -0
- data/lib/fontisan/woff2/header.rb +101 -0
- data/lib/fontisan/woff2/table_transformer.rb +163 -0
- data/lib/fontisan/woff2_font.rb +712 -0
- data/lib/fontisan/woff_font.rb +483 -0
- data/lib/fontisan.rb +120 -0
- data/scripts/compare_stack_aware.rb +187 -0
- data/scripts/measure_optimization.rb +141 -0
- 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
|