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,199 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "interpolator"
|
|
4
|
+
|
|
5
|
+
module Fontisan
|
|
6
|
+
module Variation
|
|
7
|
+
# Applies CFF2 blend operators during CharString execution
|
|
8
|
+
#
|
|
9
|
+
# The blend operator in CFF2 CharStrings provides variation support by
|
|
10
|
+
# blending base values with deltas based on design space coordinates.
|
|
11
|
+
#
|
|
12
|
+
# Blend Format:
|
|
13
|
+
# v1 Δv1_axis1 Δv1_axis2 ... v2 Δv2_axis1 ... K N blend
|
|
14
|
+
#
|
|
15
|
+
# Where:
|
|
16
|
+
# - K = number of values to blend
|
|
17
|
+
# - N = number of axes
|
|
18
|
+
# - Each value has N deltas (one per axis)
|
|
19
|
+
#
|
|
20
|
+
# The applier calculates blended values:
|
|
21
|
+
# result = base + Σ(delta_i × scalar_i)
|
|
22
|
+
#
|
|
23
|
+
# Reference: Adobe Technical Note #5177 (CFF2 specification)
|
|
24
|
+
#
|
|
25
|
+
# @example Applying blend operators
|
|
26
|
+
# applier = Fontisan::Variation::BlendApplier.new(interpolator)
|
|
27
|
+
# blended = applier.apply_blend(base: 100, deltas: [10, 5], scalars: [0.8, 0.5])
|
|
28
|
+
# # => 110.5 (100 + 10*0.8 + 5*0.5)
|
|
29
|
+
class BlendApplier
|
|
30
|
+
# @return [Interpolator] Coordinate interpolator
|
|
31
|
+
attr_reader :interpolator
|
|
32
|
+
|
|
33
|
+
# @return [Array<Float>] Current variation scalars
|
|
34
|
+
attr_reader :scalars
|
|
35
|
+
|
|
36
|
+
# Initialize blend applier
|
|
37
|
+
#
|
|
38
|
+
# @param interpolator [Interpolator] Coordinate interpolator
|
|
39
|
+
# @param coordinates [Hash<String, Float>] Design space coordinates
|
|
40
|
+
def initialize(interpolator, coordinates = {})
|
|
41
|
+
@interpolator = interpolator
|
|
42
|
+
@coordinates = coordinates
|
|
43
|
+
@scalars = []
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Set design space coordinates
|
|
47
|
+
#
|
|
48
|
+
# Updates the variation scalars based on new coordinates.
|
|
49
|
+
#
|
|
50
|
+
# @param coordinates [Hash<String, Float>] Axis tag => value
|
|
51
|
+
# @param axes [Array] Variation axes from fvar
|
|
52
|
+
def set_coordinates(coordinates, axes)
|
|
53
|
+
@coordinates = coordinates
|
|
54
|
+
@scalars = calculate_scalars(axes)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Apply blend operation
|
|
58
|
+
#
|
|
59
|
+
# Blends base value with deltas using variation scalars.
|
|
60
|
+
#
|
|
61
|
+
# @param base [Numeric] Base value
|
|
62
|
+
# @param deltas [Array<Numeric>] Delta values (one per axis)
|
|
63
|
+
# @param num_axes [Integer] Number of axes (for validation)
|
|
64
|
+
# @return [Float] Blended value
|
|
65
|
+
# @raise [InvalidVariationDataError] If delta count doesn't match axis count
|
|
66
|
+
def apply_blend(base:, deltas:, num_axes: nil)
|
|
67
|
+
# Validate delta count matches axes
|
|
68
|
+
if num_axes && deltas.length != num_axes
|
|
69
|
+
raise InvalidVariationDataError.new(
|
|
70
|
+
message: "Blend delta count (#{deltas.length}) doesn't match axes (#{num_axes})",
|
|
71
|
+
details: {
|
|
72
|
+
delta_count: deltas.length,
|
|
73
|
+
expected_axes: num_axes,
|
|
74
|
+
base_value: base,
|
|
75
|
+
},
|
|
76
|
+
)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Start with base value
|
|
80
|
+
result = base.to_f
|
|
81
|
+
|
|
82
|
+
# Apply each delta with its scalar
|
|
83
|
+
deltas.each_with_index do |delta, index|
|
|
84
|
+
scalar = @scalars[index] || 0.0
|
|
85
|
+
result += delta.to_f * scalar
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
result
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Apply multiple blend operations
|
|
92
|
+
#
|
|
93
|
+
# Processes multiple values with their deltas.
|
|
94
|
+
#
|
|
95
|
+
# @param blends [Array<Hash>] Array of { base:, deltas: } hashes
|
|
96
|
+
# @param num_axes [Integer] Number of axes
|
|
97
|
+
# @return [Array<Float>] Blended values
|
|
98
|
+
def apply_blends(blends, num_axes)
|
|
99
|
+
blends.map do |blend|
|
|
100
|
+
apply_blend(
|
|
101
|
+
base: blend[:base],
|
|
102
|
+
deltas: blend[:deltas],
|
|
103
|
+
num_axes: num_axes,
|
|
104
|
+
)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Apply blend operator from CharString stack
|
|
109
|
+
#
|
|
110
|
+
# Processes blend operator arguments from CharString execution.
|
|
111
|
+
#
|
|
112
|
+
# @param operands [Array<Numeric>] Blend operands from stack
|
|
113
|
+
# @param num_values [Integer] K (number of values to blend)
|
|
114
|
+
# @param num_axes [Integer] N (number of axes)
|
|
115
|
+
# @return [Array<Float>] Blended values
|
|
116
|
+
# @raise [InvalidVariationDataError] If operand count doesn't match expected format
|
|
117
|
+
def apply_blend_operands(operands, num_values, num_axes)
|
|
118
|
+
# Expected operands: K * (N + 1)
|
|
119
|
+
expected_count = num_values * (num_axes + 1)
|
|
120
|
+
|
|
121
|
+
if operands.length != expected_count
|
|
122
|
+
raise InvalidVariationDataError.new(
|
|
123
|
+
message: "Blend operand count mismatch: expected #{expected_count}, got #{operands.length}",
|
|
124
|
+
details: {
|
|
125
|
+
operand_count: operands.length,
|
|
126
|
+
expected_count: expected_count,
|
|
127
|
+
num_values: num_values,
|
|
128
|
+
num_axes: num_axes,
|
|
129
|
+
},
|
|
130
|
+
)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
blended_values = []
|
|
134
|
+
|
|
135
|
+
num_values.times do |i|
|
|
136
|
+
offset = i * (num_axes + 1)
|
|
137
|
+
base = operands[offset]
|
|
138
|
+
deltas = operands[offset + 1, num_axes] || []
|
|
139
|
+
|
|
140
|
+
blended_values << apply_blend(
|
|
141
|
+
base: base,
|
|
142
|
+
deltas: deltas,
|
|
143
|
+
num_axes: num_axes,
|
|
144
|
+
)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
blended_values
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Calculate scalars for current coordinates
|
|
151
|
+
#
|
|
152
|
+
# Converts design space coordinates to normalized scalars [-1, 1].
|
|
153
|
+
#
|
|
154
|
+
# @param axes [Array] Variation axes
|
|
155
|
+
# @return [Array<Float>] Scalar for each axis
|
|
156
|
+
def calculate_scalars(axes)
|
|
157
|
+
axes.map do |axis|
|
|
158
|
+
coord = @coordinates[axis.axis_tag] || axis.default_value
|
|
159
|
+
@interpolator.normalize_coordinate(coord, axis.axis_tag)
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Check if coordinates are at default
|
|
164
|
+
#
|
|
165
|
+
# @return [Boolean] True if all scalars are zero
|
|
166
|
+
def at_default?
|
|
167
|
+
@scalars.all?(&:zero?)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Get blended point coordinates
|
|
171
|
+
#
|
|
172
|
+
# Applies blend to X and Y coordinates simultaneously.
|
|
173
|
+
#
|
|
174
|
+
# @param base_x [Numeric] Base X coordinate
|
|
175
|
+
# @param base_y [Numeric] Base Y coordinate
|
|
176
|
+
# @param deltas_x [Array<Numeric>] X deltas
|
|
177
|
+
# @param deltas_y [Array<Numeric>] Y deltas
|
|
178
|
+
# @return [Array<Float>] [blended_x, blended_y]
|
|
179
|
+
def blend_point(base_x, base_y, deltas_x, deltas_y)
|
|
180
|
+
[
|
|
181
|
+
apply_blend(base: base_x, deltas: deltas_x),
|
|
182
|
+
apply_blend(base: base_y, deltas: deltas_y),
|
|
183
|
+
]
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Convert blend data to static values
|
|
187
|
+
#
|
|
188
|
+
# For instance generation, replaces blend operators with static values.
|
|
189
|
+
#
|
|
190
|
+
# @param blend_data [Array<Hash>] Blend operations data
|
|
191
|
+
# @return [Array<Float>] Static blended values
|
|
192
|
+
def blend_to_static(blend_data)
|
|
193
|
+
blend_data.flat_map do |blend_op|
|
|
194
|
+
apply_blends(blend_op[:blends], blend_op[:num_axes])
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
end
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "cache_key_builder"
|
|
4
|
+
|
|
5
|
+
module Fontisan
|
|
6
|
+
module Variation
|
|
7
|
+
# Caches variation calculations for performance
|
|
8
|
+
#
|
|
9
|
+
# This class implements a caching layer for expensive variation calculations
|
|
10
|
+
# to significantly improve instance generation performance. It caches:
|
|
11
|
+
# - Normalized scalars per coordinate set
|
|
12
|
+
# - Interpolated values
|
|
13
|
+
# - Instance generation results
|
|
14
|
+
# - Region matches
|
|
15
|
+
#
|
|
16
|
+
# Cache strategies:
|
|
17
|
+
# 1. LRU (Least Recently Used) for memory management
|
|
18
|
+
# 2. Coordinate-based keys for scalar caching
|
|
19
|
+
# 3. Invalidation on font modification
|
|
20
|
+
# 4. Optional persistent caching across sessions
|
|
21
|
+
#
|
|
22
|
+
# @example Using the cache with instance generation
|
|
23
|
+
# cache = Fontisan::Variation::Cache.new(max_size: 100)
|
|
24
|
+
# scalars = cache.fetch_scalars(coordinates, axes) do
|
|
25
|
+
# calculate_scalars(coordinates, axes)
|
|
26
|
+
# end
|
|
27
|
+
#
|
|
28
|
+
# @example Cache statistics
|
|
29
|
+
# cache.statistics
|
|
30
|
+
# # => { hits: 150, misses: 50, hit_rate: 0.75 }
|
|
31
|
+
class Cache
|
|
32
|
+
# @return [Integer] Maximum cache size
|
|
33
|
+
attr_reader :max_size
|
|
34
|
+
|
|
35
|
+
# @return [Hash] Cache statistics
|
|
36
|
+
attr_reader :stats
|
|
37
|
+
|
|
38
|
+
# Initialize cache
|
|
39
|
+
#
|
|
40
|
+
# @param max_size [Integer] Maximum number of entries (default: 1000)
|
|
41
|
+
# @param ttl [Integer, nil] Time-to-live in seconds (nil for no expiration)
|
|
42
|
+
def initialize(max_size: 1000, ttl: nil)
|
|
43
|
+
@max_size = max_size
|
|
44
|
+
@ttl = ttl
|
|
45
|
+
@cache = {}
|
|
46
|
+
@access_times = {}
|
|
47
|
+
@stats = {
|
|
48
|
+
hits: 0,
|
|
49
|
+
misses: 0,
|
|
50
|
+
evictions: 0,
|
|
51
|
+
invalidations: 0,
|
|
52
|
+
}
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Fetch or compute scalars for coordinates
|
|
56
|
+
#
|
|
57
|
+
# @param coordinates [Hash<String, Float>] Design space coordinates
|
|
58
|
+
# @param axes [Array] Variation axes
|
|
59
|
+
# @yield Block to calculate scalars if not cached
|
|
60
|
+
# @return [Array<Float>] Cached or computed scalars
|
|
61
|
+
def fetch_scalars(coordinates, axes, &)
|
|
62
|
+
key = CacheKeyBuilder.scalars_key(coordinates, axes)
|
|
63
|
+
fetch(key, &)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Fetch or compute interpolated value
|
|
67
|
+
#
|
|
68
|
+
# @param base_value [Numeric] Base value
|
|
69
|
+
# @param deltas [Array<Numeric>] Delta values
|
|
70
|
+
# @param scalars [Array<Float>] Region scalars
|
|
71
|
+
# @yield Block to calculate value if not cached
|
|
72
|
+
# @return [Float] Cached or computed value
|
|
73
|
+
def fetch_interpolated(base_value, deltas, scalars, &)
|
|
74
|
+
key = CacheKeyBuilder.interpolation_key(base_value, deltas, scalars)
|
|
75
|
+
fetch(key, &)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Fetch or compute instance generation result
|
|
79
|
+
#
|
|
80
|
+
# @param font_checksum [String] Font identifier
|
|
81
|
+
# @param coordinates [Hash<String, Float>] Instance coordinates
|
|
82
|
+
# @yield Block to generate instance if not cached
|
|
83
|
+
# @return [Hash] Cached or generated instance tables
|
|
84
|
+
def fetch_instance(font_checksum, coordinates, &)
|
|
85
|
+
key = CacheKeyBuilder.instance_key(font_checksum, coordinates)
|
|
86
|
+
fetch(key, &)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Fetch or compute region matches
|
|
90
|
+
#
|
|
91
|
+
# @param coordinates [Hash<String, Float>] Design space coordinates
|
|
92
|
+
# @param regions [Array] Variation regions
|
|
93
|
+
# @yield Block to calculate matches if not cached
|
|
94
|
+
# @return [Array] Cached or computed region matches
|
|
95
|
+
def fetch_region_matches(coordinates, regions, &)
|
|
96
|
+
key = CacheKeyBuilder.region_matches_key(coordinates, regions)
|
|
97
|
+
fetch(key, &)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Generic fetch with caching
|
|
101
|
+
#
|
|
102
|
+
# @param key [String] Cache key
|
|
103
|
+
# @yield Block to compute value if not cached
|
|
104
|
+
# @return [Object] Cached or computed value
|
|
105
|
+
def fetch(key)
|
|
106
|
+
if cached?(key)
|
|
107
|
+
@stats[:hits] += 1
|
|
108
|
+
touch(key)
|
|
109
|
+
return @cache[key][:value]
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
@stats[:misses] += 1
|
|
113
|
+
value = yield
|
|
114
|
+
store(key, value)
|
|
115
|
+
value
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Check if key is cached and valid
|
|
119
|
+
#
|
|
120
|
+
# @param key [String] Cache key
|
|
121
|
+
# @return [Boolean] True if cached and valid
|
|
122
|
+
def cached?(key)
|
|
123
|
+
return false unless @cache.key?(key)
|
|
124
|
+
return false if expired?(key)
|
|
125
|
+
|
|
126
|
+
true
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Store value in cache
|
|
130
|
+
#
|
|
131
|
+
# @param key [String] Cache key
|
|
132
|
+
# @param value [Object] Value to store
|
|
133
|
+
def store(key, value)
|
|
134
|
+
evict_if_needed
|
|
135
|
+
|
|
136
|
+
@cache[key] = {
|
|
137
|
+
value: value,
|
|
138
|
+
created_at: Time.now,
|
|
139
|
+
}
|
|
140
|
+
touch(key)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Clear entire cache
|
|
144
|
+
def clear
|
|
145
|
+
@cache.clear
|
|
146
|
+
@access_times.clear
|
|
147
|
+
@stats[:invalidations] += 1
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Invalidate specific key
|
|
151
|
+
#
|
|
152
|
+
# @param key [String] Cache key to invalidate
|
|
153
|
+
def invalidate(key)
|
|
154
|
+
@cache.delete(key)
|
|
155
|
+
@access_times.delete(key)
|
|
156
|
+
@stats[:invalidations] += 1
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Invalidate keys matching pattern
|
|
160
|
+
#
|
|
161
|
+
# @param pattern [Regexp] Pattern to match keys
|
|
162
|
+
def invalidate_matching(pattern)
|
|
163
|
+
keys = @cache.keys.select { |k| k.match?(pattern) }
|
|
164
|
+
keys.each { |k| invalidate(k) }
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Get cache statistics
|
|
168
|
+
#
|
|
169
|
+
# @return [Hash] Statistics including hit rate
|
|
170
|
+
def statistics
|
|
171
|
+
total = @stats[:hits] + @stats[:misses]
|
|
172
|
+
hit_rate = total.zero? ? 0.0 : @stats[:hits].to_f / total
|
|
173
|
+
|
|
174
|
+
@stats.merge(
|
|
175
|
+
total_requests: total,
|
|
176
|
+
hit_rate: hit_rate,
|
|
177
|
+
size: @cache.size,
|
|
178
|
+
max_size: @max_size,
|
|
179
|
+
)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Get cache size
|
|
183
|
+
#
|
|
184
|
+
# @return [Integer] Number of cached entries
|
|
185
|
+
def size
|
|
186
|
+
@cache.size
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Check if cache is empty
|
|
190
|
+
#
|
|
191
|
+
# @return [Boolean] True if empty
|
|
192
|
+
def empty?
|
|
193
|
+
@cache.empty?
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Check if cache is full
|
|
197
|
+
#
|
|
198
|
+
# @return [Boolean] True if at capacity
|
|
199
|
+
def full?
|
|
200
|
+
@cache.size >= @max_size
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
private
|
|
204
|
+
|
|
205
|
+
# Check if entry has expired
|
|
206
|
+
#
|
|
207
|
+
# @param key [String] Cache key
|
|
208
|
+
# @return [Boolean] True if expired
|
|
209
|
+
def expired?(key)
|
|
210
|
+
return false unless @ttl
|
|
211
|
+
|
|
212
|
+
entry = @cache[key]
|
|
213
|
+
return true unless entry
|
|
214
|
+
|
|
215
|
+
Time.now - entry[:created_at] > @ttl
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# Update access time for LRU
|
|
219
|
+
#
|
|
220
|
+
# @param key [String] Cache key
|
|
221
|
+
def touch(key)
|
|
222
|
+
@access_times[key] = Time.now
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# Evict entries if cache is full
|
|
226
|
+
def evict_if_needed
|
|
227
|
+
return unless full?
|
|
228
|
+
|
|
229
|
+
# Remove least recently used entry
|
|
230
|
+
lru_key = @access_times.min_by { |_k, v| v }&.first
|
|
231
|
+
return unless lru_key
|
|
232
|
+
|
|
233
|
+
@cache.delete(lru_key)
|
|
234
|
+
@access_times.delete(lru_key)
|
|
235
|
+
@stats[:evictions] += 1
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# Thread-safe cache wrapper
|
|
240
|
+
#
|
|
241
|
+
# Wraps Cache with Mutex for thread-safe operations.
|
|
242
|
+
class ThreadSafeCache < Cache
|
|
243
|
+
def initialize(max_size: 1000, ttl: nil)
|
|
244
|
+
super
|
|
245
|
+
@mutex = Mutex.new
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def fetch(key)
|
|
249
|
+
# Check cache without entering critical section for computation
|
|
250
|
+
@mutex.synchronize do
|
|
251
|
+
if cached?(key)
|
|
252
|
+
@stats[:hits] += 1
|
|
253
|
+
touch(key)
|
|
254
|
+
return @cache[key][:value]
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# Compute value outside of mutex
|
|
259
|
+
value = yield
|
|
260
|
+
|
|
261
|
+
# Store result
|
|
262
|
+
@mutex.synchronize do
|
|
263
|
+
evict_if_needed
|
|
264
|
+
@cache[key] = {
|
|
265
|
+
value: value,
|
|
266
|
+
created_at: Time.now,
|
|
267
|
+
}
|
|
268
|
+
touch(key)
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
value
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def store(key, value)
|
|
275
|
+
@mutex.synchronize do
|
|
276
|
+
evict_if_needed
|
|
277
|
+
@cache[key] = {
|
|
278
|
+
value: value,
|
|
279
|
+
created_at: Time.now,
|
|
280
|
+
}
|
|
281
|
+
touch(key)
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def clear
|
|
286
|
+
@mutex.synchronize { super }
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def invalidate(key)
|
|
290
|
+
@mutex.synchronize { super }
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def statistics
|
|
294
|
+
@mutex.synchronize { super }
|
|
295
|
+
end
|
|
296
|
+
end
|
|
297
|
+
end
|
|
298
|
+
end
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fontisan
|
|
4
|
+
module Variation
|
|
5
|
+
# Builds cache keys for variation calculations
|
|
6
|
+
#
|
|
7
|
+
# This class centralizes cache key generation with consistent formatting
|
|
8
|
+
# and efficient string construction. All variation caches should use
|
|
9
|
+
# this builder to ensure key compatibility.
|
|
10
|
+
#
|
|
11
|
+
# @example Building cache keys
|
|
12
|
+
# builder = CacheKeyBuilder
|
|
13
|
+
#
|
|
14
|
+
# # Scalars key
|
|
15
|
+
# key = builder.scalars_key(coordinates, axes)
|
|
16
|
+
#
|
|
17
|
+
# # Instance key
|
|
18
|
+
# key = builder.instance_key(font_checksum, coordinates)
|
|
19
|
+
class CacheKeyBuilder
|
|
20
|
+
class << self
|
|
21
|
+
# Build cache key for scalars
|
|
22
|
+
#
|
|
23
|
+
# Generates a deterministic key based on axis tags and coordinate values.
|
|
24
|
+
# Axes are sorted to ensure consistent keys regardless of hash order.
|
|
25
|
+
#
|
|
26
|
+
# @param coordinates [Hash<String, Float>] Design space coordinates
|
|
27
|
+
# @param axes [Array<VariationAxisRecord>] Variation axes
|
|
28
|
+
# @return [String] Cache key
|
|
29
|
+
#
|
|
30
|
+
# @example
|
|
31
|
+
# key = CacheKeyBuilder.scalars_key(
|
|
32
|
+
# { "wght" => 700, "wdth" => 100 },
|
|
33
|
+
# axes
|
|
34
|
+
# )
|
|
35
|
+
# # => "scalars:wdth,wght:100.0,700.0"
|
|
36
|
+
def scalars_key(coordinates, axes)
|
|
37
|
+
axis_tags = axes.map(&:axis_tag).sort
|
|
38
|
+
coord_values = axis_tags.map { |tag| coordinates[tag] || 0.0 }
|
|
39
|
+
"scalars:#{axis_tags.join(',')}:#{coord_values.join(',')}"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Build cache key for interpolated value
|
|
43
|
+
#
|
|
44
|
+
# Generates key based on base value, deltas, and scalars.
|
|
45
|
+
# Useful for caching individual interpolation results.
|
|
46
|
+
#
|
|
47
|
+
# @param base_value [Numeric] Base value
|
|
48
|
+
# @param deltas [Array<Numeric>] Delta values
|
|
49
|
+
# @param scalars [Array<Float>] Region scalars
|
|
50
|
+
# @return [String] Cache key
|
|
51
|
+
#
|
|
52
|
+
# @example
|
|
53
|
+
# key = CacheKeyBuilder.interpolation_key(100, [10, 5], [0.8, 0.5])
|
|
54
|
+
# # => "interp:100:10,5:0.8,0.5"
|
|
55
|
+
def interpolation_key(base_value, deltas, scalars)
|
|
56
|
+
"interp:#{base_value}:#{deltas.join(',')}:#{scalars.join(',')}"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Build cache key for font instance
|
|
60
|
+
#
|
|
61
|
+
# Generates key for entire instance generation result.
|
|
62
|
+
# Coordinates are sorted to ensure consistency.
|
|
63
|
+
#
|
|
64
|
+
# @param font_checksum [String] Font identifier
|
|
65
|
+
# @param coordinates [Hash<String, Float>] Instance coordinates
|
|
66
|
+
# @return [String] Cache key
|
|
67
|
+
#
|
|
68
|
+
# @example
|
|
69
|
+
# key = CacheKeyBuilder.instance_key("font_123", { "wght" => 700 })
|
|
70
|
+
# # => "instance:font_123:{\"wght\"=>700.0}"
|
|
71
|
+
def instance_key(font_checksum, coordinates)
|
|
72
|
+
sorted_coords = coordinates.sort.to_h
|
|
73
|
+
"instance:#{font_checksum}:#{sorted_coords}"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Build cache key for region matches
|
|
77
|
+
#
|
|
78
|
+
# Generates key based on coordinates and region hash.
|
|
79
|
+
# Region hash is used to quickly identify region set without
|
|
80
|
+
# serializing entire region data.
|
|
81
|
+
#
|
|
82
|
+
# @param coordinates [Hash<String, Float>] Design space coordinates
|
|
83
|
+
# @param regions [Array<Hash>] Variation regions
|
|
84
|
+
# @return [String] Cache key
|
|
85
|
+
#
|
|
86
|
+
# @example
|
|
87
|
+
# key = CacheKeyBuilder.region_matches_key(coords, regions)
|
|
88
|
+
# # => "regions:{\"wght\"=>700.0}:12345678"
|
|
89
|
+
def region_matches_key(coordinates, regions)
|
|
90
|
+
sorted_coords = coordinates.sort.to_h
|
|
91
|
+
region_hash = regions.hash
|
|
92
|
+
"regions:#{sorted_coords}:#{region_hash}"
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Build cache key for glyph deltas
|
|
96
|
+
#
|
|
97
|
+
# Generates key for cached glyph delta application results.
|
|
98
|
+
#
|
|
99
|
+
# @param glyph_id [Integer] Glyph ID
|
|
100
|
+
# @param coordinates [Hash<String, Float>] Design space coordinates
|
|
101
|
+
# @return [String] Cache key
|
|
102
|
+
#
|
|
103
|
+
# @example
|
|
104
|
+
# key = CacheKeyBuilder.glyph_deltas_key(42, { "wght" => 700 })
|
|
105
|
+
# # => "glyph:42:{\"wght\"=>700.0}"
|
|
106
|
+
def glyph_deltas_key(glyph_id, coordinates)
|
|
107
|
+
sorted_coords = coordinates.sort.to_h
|
|
108
|
+
"glyph:#{glyph_id}:#{sorted_coords}"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Build cache key for metrics deltas
|
|
112
|
+
#
|
|
113
|
+
# Generates key for cached metrics variation results.
|
|
114
|
+
#
|
|
115
|
+
# @param metrics_type [String] Metrics table tag (HVAR, VVAR, MVAR)
|
|
116
|
+
# @param glyph_id [Integer, nil] Glyph ID (nil for font-wide metrics)
|
|
117
|
+
# @param coordinates [Hash<String, Float>] Design space coordinates
|
|
118
|
+
# @return [String] Cache key
|
|
119
|
+
#
|
|
120
|
+
# @example
|
|
121
|
+
# key = CacheKeyBuilder.metrics_deltas_key("HVAR", 42, coords)
|
|
122
|
+
# # => "metrics:HVAR:42:{\"wght\"=>700.0}"
|
|
123
|
+
def metrics_deltas_key(metrics_type, glyph_id, coordinates)
|
|
124
|
+
sorted_coords = coordinates.sort.to_h
|
|
125
|
+
glyph_part = glyph_id ? ":#{glyph_id}" : ""
|
|
126
|
+
"metrics:#{metrics_type}#{glyph_part}:#{sorted_coords}"
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Build cache key for blend operations
|
|
130
|
+
#
|
|
131
|
+
# Generates key for CFF2 blend operator results.
|
|
132
|
+
#
|
|
133
|
+
# @param blend_index [Integer] Blend operation index
|
|
134
|
+
# @param scalars [Array<Float>] Variation scalars
|
|
135
|
+
# @return [String] Cache key
|
|
136
|
+
#
|
|
137
|
+
# @example
|
|
138
|
+
# key = CacheKeyBuilder.blend_key(0, [0.8, 0.5])
|
|
139
|
+
# # => "blend:0:0.8,0.5"
|
|
140
|
+
def blend_key(blend_index, scalars)
|
|
141
|
+
"blend:#{blend_index}:#{scalars.join(',')}"
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Build custom cache key
|
|
145
|
+
#
|
|
146
|
+
# Generates key with custom prefix and components.
|
|
147
|
+
# Use for specialized caching needs.
|
|
148
|
+
#
|
|
149
|
+
# @param prefix [String] Key prefix
|
|
150
|
+
# @param components [Array] Key components (will be joined with :)
|
|
151
|
+
# @return [String] Cache key
|
|
152
|
+
#
|
|
153
|
+
# @example
|
|
154
|
+
# key = CacheKeyBuilder.custom_key("mydata", [font_id, value1, value2])
|
|
155
|
+
# # => "mydata:font_123:100:200"
|
|
156
|
+
def custom_key(prefix, *components)
|
|
157
|
+
"#{prefix}:#{components.join(':')}"
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|