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,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