fontisan 0.1.0 → 0.2.1

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 (214) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +672 -69
  3. data/Gemfile +1 -0
  4. data/LICENSE +5 -1
  5. data/README.adoc +1477 -297
  6. data/Rakefile +63 -41
  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 +364 -4
  12. data/lib/fontisan/collection/builder.rb +341 -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 +317 -0
  16. data/lib/fontisan/collection/writer.rb +306 -0
  17. data/lib/fontisan/commands/base_command.rb +24 -1
  18. data/lib/fontisan/commands/convert_command.rb +218 -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 +286 -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 +203 -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 +79 -0
  37. data/lib/fontisan/converters/conversion_strategy.rb +96 -0
  38. data/lib/fontisan/converters/format_converter.rb +408 -0
  39. data/lib/fontisan/converters/outline_converter.rb +998 -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 +122 -15
  57. data/lib/fontisan/font_writer.rb +302 -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 +310 -0
  61. data/lib/fontisan/hints/postscript_hint_applier.rb +266 -0
  62. data/lib/fontisan/hints/postscript_hint_extractor.rb +354 -0
  63. data/lib/fontisan/hints/truetype_hint_applier.rb +117 -0
  64. data/lib/fontisan/hints/truetype_hint_extractor.rb +289 -0
  65. data/lib/fontisan/loading_modes.rb +115 -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 +405 -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 +321 -19
  88. data/lib/fontisan/open_type_font_extensions.rb +54 -0
  89. data/lib/fontisan/optimizers/charstring_rewriter.rb +161 -0
  90. data/lib/fontisan/optimizers/pattern_analyzer.rb +308 -0
  91. data/lib/fontisan/optimizers/stack_tracker.rb +246 -0
  92. data/lib/fontisan/optimizers/subroutine_builder.rb +134 -0
  93. data/lib/fontisan/optimizers/subroutine_generator.rb +207 -0
  94. data/lib/fontisan/optimizers/subroutine_optimizer.rb +107 -0
  95. data/lib/fontisan/outline_extractor.rb +423 -0
  96. data/lib/fontisan/pipeline/format_detector.rb +249 -0
  97. data/lib/fontisan/pipeline/output_writer.rb +154 -0
  98. data/lib/fontisan/pipeline/strategies/base_strategy.rb +75 -0
  99. data/lib/fontisan/pipeline/strategies/instance_strategy.rb +93 -0
  100. data/lib/fontisan/pipeline/strategies/named_strategy.rb +118 -0
  101. data/lib/fontisan/pipeline/strategies/preserve_strategy.rb +56 -0
  102. data/lib/fontisan/pipeline/transformation_pipeline.rb +411 -0
  103. data/lib/fontisan/pipeline/variation_resolver.rb +165 -0
  104. data/lib/fontisan/subset/builder.rb +268 -0
  105. data/lib/fontisan/subset/glyph_mapping.rb +215 -0
  106. data/lib/fontisan/subset/options.rb +142 -0
  107. data/lib/fontisan/subset/profile.rb +152 -0
  108. data/lib/fontisan/subset/table_subsetter.rb +461 -0
  109. data/lib/fontisan/svg/font_face_generator.rb +278 -0
  110. data/lib/fontisan/svg/font_generator.rb +264 -0
  111. data/lib/fontisan/svg/glyph_generator.rb +168 -0
  112. data/lib/fontisan/svg/view_box_calculator.rb +137 -0
  113. data/lib/fontisan/tables/cff/cff_glyph.rb +176 -0
  114. data/lib/fontisan/tables/cff/charset.rb +282 -0
  115. data/lib/fontisan/tables/cff/charstring.rb +934 -0
  116. data/lib/fontisan/tables/cff/charstring_builder.rb +356 -0
  117. data/lib/fontisan/tables/cff/charstring_parser.rb +237 -0
  118. data/lib/fontisan/tables/cff/charstring_rebuilder.rb +172 -0
  119. data/lib/fontisan/tables/cff/charstrings_index.rb +162 -0
  120. data/lib/fontisan/tables/cff/dict.rb +351 -0
  121. data/lib/fontisan/tables/cff/dict_builder.rb +257 -0
  122. data/lib/fontisan/tables/cff/encoding.rb +274 -0
  123. data/lib/fontisan/tables/cff/header.rb +102 -0
  124. data/lib/fontisan/tables/cff/hint_operation_injector.rb +207 -0
  125. data/lib/fontisan/tables/cff/index.rb +237 -0
  126. data/lib/fontisan/tables/cff/index_builder.rb +170 -0
  127. data/lib/fontisan/tables/cff/offset_recalculator.rb +70 -0
  128. data/lib/fontisan/tables/cff/private_dict.rb +284 -0
  129. data/lib/fontisan/tables/cff/private_dict_writer.rb +125 -0
  130. data/lib/fontisan/tables/cff/table_builder.rb +221 -0
  131. data/lib/fontisan/tables/cff/top_dict.rb +236 -0
  132. data/lib/fontisan/tables/cff.rb +489 -0
  133. data/lib/fontisan/tables/cff2/blend_operator.rb +240 -0
  134. data/lib/fontisan/tables/cff2/charstring_parser.rb +591 -0
  135. data/lib/fontisan/tables/cff2/operand_stack.rb +232 -0
  136. data/lib/fontisan/tables/cff2/private_dict_blend_handler.rb +246 -0
  137. data/lib/fontisan/tables/cff2/region_matcher.rb +200 -0
  138. data/lib/fontisan/tables/cff2/table_builder.rb +574 -0
  139. data/lib/fontisan/tables/cff2/table_reader.rb +419 -0
  140. data/lib/fontisan/tables/cff2/variation_data_extractor.rb +212 -0
  141. data/lib/fontisan/tables/cff2.rb +346 -0
  142. data/lib/fontisan/tables/cvar.rb +203 -0
  143. data/lib/fontisan/tables/fvar.rb +2 -2
  144. data/lib/fontisan/tables/glyf/compound_glyph.rb +483 -0
  145. data/lib/fontisan/tables/glyf/compound_glyph_resolver.rb +136 -0
  146. data/lib/fontisan/tables/glyf/curve_converter.rb +343 -0
  147. data/lib/fontisan/tables/glyf/glyph_builder.rb +450 -0
  148. data/lib/fontisan/tables/glyf/simple_glyph.rb +382 -0
  149. data/lib/fontisan/tables/glyf.rb +235 -0
  150. data/lib/fontisan/tables/gvar.rb +231 -0
  151. data/lib/fontisan/tables/hhea.rb +124 -0
  152. data/lib/fontisan/tables/hmtx.rb +287 -0
  153. data/lib/fontisan/tables/hvar.rb +191 -0
  154. data/lib/fontisan/tables/loca.rb +322 -0
  155. data/lib/fontisan/tables/maxp.rb +192 -0
  156. data/lib/fontisan/tables/mvar.rb +185 -0
  157. data/lib/fontisan/tables/name.rb +99 -30
  158. data/lib/fontisan/tables/variation_common.rb +346 -0
  159. data/lib/fontisan/tables/vvar.rb +234 -0
  160. data/lib/fontisan/true_type_collection.rb +156 -2
  161. data/lib/fontisan/true_type_font.rb +321 -20
  162. data/lib/fontisan/true_type_font_extensions.rb +54 -0
  163. data/lib/fontisan/utilities/brotli_wrapper.rb +159 -0
  164. data/lib/fontisan/utilities/checksum_calculator.rb +60 -0
  165. data/lib/fontisan/utils/thread_pool.rb +134 -0
  166. data/lib/fontisan/validation/checksum_validator.rb +170 -0
  167. data/lib/fontisan/validation/consistency_validator.rb +197 -0
  168. data/lib/fontisan/validation/structure_validator.rb +198 -0
  169. data/lib/fontisan/validation/table_validator.rb +158 -0
  170. data/lib/fontisan/validation/validator.rb +152 -0
  171. data/lib/fontisan/validation/variable_font_validator.rb +218 -0
  172. data/lib/fontisan/variable/axis_normalizer.rb +215 -0
  173. data/lib/fontisan/variable/delta_applicator.rb +313 -0
  174. data/lib/fontisan/variable/glyph_delta_processor.rb +218 -0
  175. data/lib/fontisan/variable/instancer.rb +344 -0
  176. data/lib/fontisan/variable/metric_delta_processor.rb +282 -0
  177. data/lib/fontisan/variable/region_matcher.rb +208 -0
  178. data/lib/fontisan/variable/static_font_builder.rb +213 -0
  179. data/lib/fontisan/variable/table_updater.rb +219 -0
  180. data/lib/fontisan/variation/blend_applier.rb +199 -0
  181. data/lib/fontisan/variation/cache.rb +298 -0
  182. data/lib/fontisan/variation/cache_key_builder.rb +162 -0
  183. data/lib/fontisan/variation/converter.rb +375 -0
  184. data/lib/fontisan/variation/data_extractor.rb +86 -0
  185. data/lib/fontisan/variation/delta_applier.rb +266 -0
  186. data/lib/fontisan/variation/delta_parser.rb +228 -0
  187. data/lib/fontisan/variation/inspector.rb +275 -0
  188. data/lib/fontisan/variation/instance_generator.rb +273 -0
  189. data/lib/fontisan/variation/instance_writer.rb +341 -0
  190. data/lib/fontisan/variation/interpolator.rb +231 -0
  191. data/lib/fontisan/variation/metrics_adjuster.rb +318 -0
  192. data/lib/fontisan/variation/optimizer.rb +418 -0
  193. data/lib/fontisan/variation/parallel_generator.rb +150 -0
  194. data/lib/fontisan/variation/region_matcher.rb +221 -0
  195. data/lib/fontisan/variation/subsetter.rb +463 -0
  196. data/lib/fontisan/variation/table_accessor.rb +105 -0
  197. data/lib/fontisan/variation/tuple_variation_header.rb +51 -0
  198. data/lib/fontisan/variation/validator.rb +345 -0
  199. data/lib/fontisan/variation/variable_svg_generator.rb +268 -0
  200. data/lib/fontisan/variation/variation_context.rb +211 -0
  201. data/lib/fontisan/variation/variation_preserver.rb +288 -0
  202. data/lib/fontisan/version.rb +1 -1
  203. data/lib/fontisan/version.rb.orig +9 -0
  204. data/lib/fontisan/woff2/directory.rb +257 -0
  205. data/lib/fontisan/woff2/glyf_transformer.rb +666 -0
  206. data/lib/fontisan/woff2/header.rb +101 -0
  207. data/lib/fontisan/woff2/hmtx_transformer.rb +164 -0
  208. data/lib/fontisan/woff2/table_transformer.rb +163 -0
  209. data/lib/fontisan/woff2_font.rb +717 -0
  210. data/lib/fontisan/woff_font.rb +488 -0
  211. data/lib/fontisan.rb +132 -0
  212. data/scripts/compare_stack_aware.rb +187 -0
  213. data/scripts/measure_optimization.rb +141 -0
  214. metadata +234 -4
@@ -0,0 +1,341 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../font_writer"
4
+ require_relative "../converters/outline_converter"
5
+ require_relative "../converters/woff_writer"
6
+ require_relative "../error"
7
+
8
+ module Fontisan
9
+ module Variation
10
+ # Writes generated static font instances to files in various formats
11
+ #
12
+ # [`InstanceWriter`](lib/fontisan/variation/instance_writer.rb) takes
13
+ # instance tables generated by
14
+ # [`InstanceGenerator`](lib/fontisan/variation/instance_generator.rb) and
15
+ # writes them to files in the desired output format. It handles:
16
+ # - Format detection from file extension
17
+ # - Format conversion when needed (e.g., glyf → CFF for OTF)
18
+ # - SFNT version selection based on output format
19
+ # - Integration with FontWriter for binary output
20
+ # - Integration with OutlineConverter for format conversion
21
+ # - Integration with WoffWriter for WOFF packaging
22
+ #
23
+ # **Supported Output Formats:**
24
+ # - TTF (TrueType with glyf outlines)
25
+ # - OTF (OpenType with CFF outlines)
26
+ # - WOFF (Web Open Font Format)
27
+ # - WOFF2 (Web Open Font Format 2.0, future)
28
+ #
29
+ # @example Write instance to TTF
30
+ # tables = generator.generate
31
+ # InstanceWriter.write(tables, 'bold.ttf')
32
+ #
33
+ # @example Write instance to OTF with format conversion
34
+ # tables = generator.generate # from variable TTF
35
+ # InstanceWriter.write(tables, 'bold.otf', source_format: :ttf)
36
+ #
37
+ # @example Write instance to WOFF
38
+ # tables = generator.generate
39
+ # InstanceWriter.write(tables, 'bold.woff')
40
+ class InstanceWriter
41
+ # Supported output formats
42
+ SUPPORTED_FORMATS = %i[ttf otf woff woff2].freeze
43
+
44
+ # SFNT version constants
45
+ SFNT_VERSION_TRUETYPE = 0x00010000 # TrueType with glyf
46
+ SFNT_VERSION_CFF = 0x4F54544F # 'OTTO' for CFF
47
+
48
+ # Write instance tables to file
49
+ #
50
+ # @param tables [Hash<String, String>] Instance tables from
51
+ # InstanceGenerator
52
+ # @param output_path [String] Output file path
53
+ # @param options [Hash] Options
54
+ # @option options [Symbol] :format Output format (:ttf, :otf, :woff,
55
+ # :woff2)
56
+ # @option options [Symbol] :source_format Source format before instancing
57
+ # (:ttf or :otf)
58
+ # @option options [Boolean] :optimize Enable CFF optimization for OTF
59
+ # (default: false)
60
+ # @option options [Integer] :sfnt_version Override SFNT version
61
+ # @return [Integer] Number of bytes written
62
+ # @raise [ArgumentError] If parameters are invalid
63
+ # @raise [Error] If format conversion fails
64
+ def self.write(tables, output_path, options = {})
65
+ new(tables, options).write(output_path)
66
+ end
67
+
68
+ # @return [Hash<String, String>] Instance tables
69
+ attr_reader :tables
70
+
71
+ # @return [Hash] Writer options
72
+ attr_reader :options
73
+
74
+ # Initialize writer with instance tables
75
+ #
76
+ # @param tables [Hash<String, String>] Instance tables from
77
+ # InstanceGenerator
78
+ # @param options [Hash] Writer options
79
+ # @option options [Symbol] :source_format Source format before instancing
80
+ # @option options [Boolean] :optimize Enable CFF optimization
81
+ def initialize(tables, options = {})
82
+ @tables = tables
83
+ @options = options
84
+ validate_tables!
85
+ end
86
+
87
+ # Write instance to file
88
+ #
89
+ # @param output_path [String] Output file path
90
+ # @return [Integer] Number of bytes written
91
+ def write(output_path)
92
+ # Detect output format
93
+ format = detect_output_format(output_path)
94
+ validate_format!(format)
95
+
96
+ # Detect source format from tables
97
+ source_format = detect_source_format(@tables)
98
+
99
+ # Convert format if needed
100
+ output_tables = if format_conversion_needed?(source_format, format)
101
+ convert_format(source_format, format)
102
+ else
103
+ @tables
104
+ end
105
+
106
+ # Write to file based on format
107
+ case format
108
+ when :ttf, :otf
109
+ write_sfnt(output_tables, output_path, format)
110
+ when :woff
111
+ write_woff(output_tables, output_path, source_format)
112
+ when :woff2
113
+ raise Fontisan::Error,
114
+ "WOFF2 output not yet implemented (planned for Phase 6)"
115
+ end
116
+ end
117
+
118
+ private
119
+
120
+ # Validate instance tables
121
+ #
122
+ # @raise [ArgumentError] If tables are invalid
123
+ def validate_tables!
124
+ raise ArgumentError, "Tables cannot be nil" if @tables.nil?
125
+
126
+ unless @tables.is_a?(Hash)
127
+ raise ArgumentError,
128
+ "Tables must be a Hash, got: #{@tables.class}"
129
+ end
130
+
131
+ if @tables.empty?
132
+ raise ArgumentError, "Tables cannot be empty"
133
+ end
134
+
135
+ # Check for required tables
136
+ required_tables = %w[head hhea maxp]
137
+ required_tables.each do |tag|
138
+ unless @tables.key?(tag)
139
+ raise ArgumentError, "Missing required table: #{tag}"
140
+ end
141
+ end
142
+ end
143
+
144
+ # Detect output format from file path
145
+ #
146
+ # @param path [String] Output file path
147
+ # @return [Symbol] Format (:ttf, :otf, :woff, :woff2)
148
+ def detect_output_format(path)
149
+ return @options[:format] if @options[:format]
150
+
151
+ ext = File.extname(path).downcase
152
+ case ext
153
+ when ".ttf" then :ttf
154
+ when ".otf" then :otf
155
+ when ".woff" then :woff
156
+ when ".woff2" then :woff2
157
+ else
158
+ raise ArgumentError,
159
+ "Cannot determine format from extension: #{ext}. " \
160
+ "Supported: .ttf, .otf, .woff, .woff2"
161
+ end
162
+ end
163
+
164
+ # Validate output format
165
+ #
166
+ # @param format [Symbol] Format to validate
167
+ # @raise [ArgumentError] If format is not supported
168
+ def validate_format!(format)
169
+ unless SUPPORTED_FORMATS.include?(format)
170
+ raise ArgumentError,
171
+ "Unsupported format: #{format}. " \
172
+ "Supported: #{SUPPORTED_FORMATS.join(', ')}"
173
+ end
174
+ end
175
+
176
+ # Detect source format from instance tables
177
+ #
178
+ # @param tables [Hash<String, String>] Instance tables
179
+ # @return [Symbol] Source format (:ttf or :otf)
180
+ def detect_source_format(tables)
181
+ # Check for outline tables
182
+ if tables.key?("CFF ") || tables.key?("CFF2")
183
+ :otf
184
+ elsif tables.key?("glyf")
185
+ :ttf
186
+ else
187
+ # If no outline tables, use option or default to TTF
188
+ @options[:source_format] || :ttf
189
+ end
190
+ end
191
+
192
+ # Check if format conversion is needed
193
+ #
194
+ # @param source_format [Symbol] Source format
195
+ # @param target_format [Symbol] Target format
196
+ # @return [Boolean] True if conversion needed
197
+ def format_conversion_needed?(source_format, target_format)
198
+ # WOFF doesn't need outline conversion
199
+ return false if %i[woff woff2].include?(target_format)
200
+
201
+ # Check if outline formats differ
202
+ source_format != target_format
203
+ end
204
+
205
+ # Convert instance tables from source format to target format
206
+ #
207
+ # @param source_format [Symbol] Source format
208
+ # @param target_format [Symbol] Target format
209
+ # @return [Hash<String, String>] Converted tables
210
+ # @raise [Error] If conversion fails
211
+ def convert_format(source_format, target_format)
212
+ # Create temporary font object for conversion
213
+ temp_font = create_temp_font(@tables, source_format)
214
+
215
+ # Use OutlineConverter for format conversion
216
+ converter = Converters::OutlineConverter.new
217
+ converter.convert(
218
+ temp_font,
219
+ target_format: target_format,
220
+ optimize_cff: @options[:optimize] || false,
221
+ )
222
+ rescue StandardError => e
223
+ raise Fontisan::Error,
224
+ "Failed to convert instance from #{source_format} to " \
225
+ "#{target_format}: #{e.message}"
226
+ end
227
+
228
+ # Create temporary font object from tables
229
+ #
230
+ # @param tables [Hash<String, String>] Font tables
231
+ # @param format [Symbol] Font format
232
+ # @return [Object] Font object
233
+ def create_temp_font(tables, format)
234
+ # Create minimal font object that responds to required methods
235
+ font_class = format == :otf ? OpenTypeFont : TrueTypeFont
236
+ font = font_class.new
237
+
238
+ # Set table data
239
+ font.instance_variable_set(:@table_data, tables)
240
+
241
+ # Define required methods
242
+ font.define_singleton_method(:table_data) { tables }
243
+ font.define_singleton_method(:table_names) { tables.keys }
244
+ font.define_singleton_method(:has_table?) { |tag| tables.key?(tag) }
245
+ font.define_singleton_method(:table) do |tag|
246
+ # Return nil if table doesn't exist
247
+ return nil unless tables.key?(tag)
248
+
249
+ # Parse and return table object
250
+ # For conversion, we need to lazy-load tables
251
+ parse_table(tag, tables[tag])
252
+ end
253
+
254
+ font
255
+ end
256
+
257
+ # Parse table data into table object
258
+ #
259
+ # @param tag [String] Table tag
260
+ # @param data [String] Table binary data
261
+ # @return [Object] Parsed table object
262
+ def parse_table(tag, data)
263
+ # For OutlineConverter, we need head, maxp, loca, glyf for TTF
264
+ # and CFF for OTF
265
+ case tag
266
+ when "head"
267
+ Tables::Head.new.tap { |t| t.parse(data) }
268
+ when "maxp"
269
+ Tables::Maxp.new.tap { |t| t.parse(data) }
270
+ when "loca"
271
+ Tables::Loca.new.tap { |t| t.data = data }
272
+ when "glyf"
273
+ Tables::Glyf.new.tap { |t| t.data = data }
274
+ when "CFF "
275
+ Tables::Cff.new.tap { |t| t.parse(data) }
276
+ when "CFF2"
277
+ Tables::Cff2.new.tap { |t| t.parse(data) }
278
+ else
279
+ # For other tables, return a simple object that just holds data
280
+ Object.new.tap do |obj|
281
+ obj.define_singleton_method(:data) { data }
282
+ end
283
+ end
284
+ rescue StandardError => e
285
+ warn "Warning: Failed to parse #{tag} table: #{e.message}"
286
+ nil
287
+ end
288
+
289
+ # Write SFNT format (TTF or OTF)
290
+ #
291
+ # @param tables [Hash<String, String>] Output tables
292
+ # @param output_path [String] Output file path
293
+ # @param format [Symbol] Output format
294
+ # @return [Integer] Number of bytes written
295
+ def write_sfnt(tables, output_path, format)
296
+ # Determine SFNT version
297
+ sfnt_version = @options[:sfnt_version] || sfnt_version_for_format(
298
+ format,
299
+ )
300
+
301
+ # Write using FontWriter
302
+ FontWriter.write_to_file(tables, output_path,
303
+ sfnt_version: sfnt_version)
304
+ end
305
+
306
+ # Write WOFF format
307
+ #
308
+ # @param tables [Hash<String, String>] Output tables
309
+ # @param output_path [String] Output file path
310
+ # @param source_format [Symbol] Source format (for flavor detection)
311
+ # @return [Integer] Number of bytes written
312
+ def write_woff(tables, output_path, source_format)
313
+ # Create temporary font for WOFF writer
314
+ temp_font = create_temp_font(tables, source_format)
315
+
316
+ # Add cff? method for WoffWriter flavor detection
317
+ temp_font.define_singleton_method(:cff?) do
318
+ tables.key?("CFF ") || tables.key?("CFF2")
319
+ end
320
+
321
+ # Use WoffWriter to create WOFF
322
+ writer = Converters::WoffWriter.new
323
+ woff_data = writer.convert(temp_font)
324
+
325
+ # Write to file
326
+ File.binwrite(output_path, woff_data)
327
+ rescue StandardError => e
328
+ raise Fontisan::Error,
329
+ "Failed to write WOFF output: #{e.message}"
330
+ end
331
+
332
+ # Get SFNT version for output format
333
+ #
334
+ # @param format [Symbol] Output format
335
+ # @return [Integer] SFNT version constant
336
+ def sfnt_version_for_format(format)
337
+ format == :otf ? SFNT_VERSION_CFF : SFNT_VERSION_TRUETYPE
338
+ end
339
+ end
340
+ end
341
+ end
@@ -0,0 +1,231 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Variation
5
+ # Coordinate interpolator for variable fonts
6
+ #
7
+ # This class interpolates values in the variation design space by
8
+ # calculating scalars based on the current coordinates and variation
9
+ # regions/tuples.
10
+ #
11
+ # Interpolation Process:
12
+ # 1. Normalize user coordinates to [-1, 1] range based on axis min/default/max
13
+ # 2. For each variation region, calculate a scalar that represents how much
14
+ # that region contributes at the current coordinates
15
+ # 3. Apply the scalars to deltas to get the final interpolated value
16
+ #
17
+ # Region Scalar Calculation:
18
+ # For each axis, given a region [start, peak, end] and coordinate c:
19
+ # - If c < start or c > end: scalar = 0 (outside region)
20
+ # - If c in [start, peak]: scalar = (c - start) / (peak - start)
21
+ # - If c in [peak, end]: scalar = (end - c) / (end - peak)
22
+ # - If c == peak: scalar = 1 (at peak)
23
+ #
24
+ # For multi-axis regions, multiply the per-axis scalars together.
25
+ #
26
+ # Reference: OpenType Font Variations specification
27
+ #
28
+ # @example Interpolating a coordinate
29
+ # interpolator = Interpolator.new(axes)
30
+ # scalar = interpolator.calculate_scalar(
31
+ # coordinates: { "wght" => 600.0 },
32
+ # region: { "wght" => { start: 400, peak: 700, end: 900 } }
33
+ # )
34
+ # # => 0.666... (normalized position between 400 and 700)
35
+ class Interpolator
36
+ # @return [Array<VariationAxisRecord>] Variation axes
37
+ attr_reader :axes
38
+
39
+ # Initialize interpolator
40
+ #
41
+ # @param axes [Array<VariationAxisRecord>] Variation axes from fvar table
42
+ def initialize(axes)
43
+ @axes = axes || []
44
+ end
45
+
46
+ # Normalize a coordinate value to [-1, 1] range
47
+ #
48
+ # @param value [Float] User-space coordinate value
49
+ # @param axis_tag [String] Axis tag (e.g., "wght", "wdth")
50
+ # @return [Float] Normalized coordinate in [-1, 1]
51
+ def normalize_coordinate(value, axis_tag)
52
+ axis = find_axis(axis_tag)
53
+ return 0.0 unless axis
54
+
55
+ # Clamp to axis range
56
+ value = [[value, axis.min_value].max, axis.max_value].min
57
+
58
+ # Normalize to [-1, 1]
59
+ if value < axis.default_value
60
+ # Normalize between min and default (maps to -1..0)
61
+ range = axis.default_value - axis.min_value
62
+ return -1.0 if range.zero?
63
+
64
+ (value - axis.default_value) / range
65
+ elsif value > axis.default_value
66
+ # Normalize between default and max (maps to 0..1)
67
+ range = axis.max_value - axis.default_value
68
+ return 1.0 if range.zero?
69
+
70
+ (value - axis.default_value) / range
71
+ else
72
+ # At default value
73
+ 0.0
74
+ end
75
+ end
76
+
77
+ # Normalize all coordinates
78
+ #
79
+ # @param coordinates [Hash<String, Float>] User-space coordinates
80
+ # @return [Hash<String, Float>] Normalized coordinates
81
+ def normalize_coordinates(coordinates)
82
+ result = {}
83
+ @axes.each do |axis|
84
+ tag = axis.axis_tag
85
+ value = coordinates[tag] || axis.default_value
86
+ result[tag] = normalize_coordinate(value, tag)
87
+ end
88
+ result
89
+ end
90
+
91
+ # Calculate scalar for a single axis region
92
+ #
93
+ # @param coord [Float] Normalized coordinate value [-1, 1]
94
+ # @param region [Hash] Region definition with :start, :peak, :end
95
+ # @return [Float] Scalar value [0, 1]
96
+ def calculate_axis_scalar(coord, region)
97
+ start_val = region[:start] || -1.0
98
+ peak = region[:peak] || 0.0
99
+ end_val = region[:end] || 1.0
100
+
101
+ # Outside region
102
+ return 0.0 if coord < start_val || coord > end_val
103
+
104
+ # At or beyond peak
105
+ return 1.0 if coord == peak
106
+
107
+ # Between start and peak
108
+ if coord < peak
109
+ range = peak - start_val
110
+ return 1.0 if range.zero?
111
+
112
+ (coord - start_val) / range
113
+ else
114
+ # Between peak and end
115
+ range = end_val - peak
116
+ return 1.0 if range.zero?
117
+
118
+ (end_val - coord) / range
119
+ end
120
+ end
121
+
122
+ # Calculate scalar for a multi-axis region
123
+ #
124
+ # For multi-axis regions, the final scalar is the product of per-axis scalars.
125
+ #
126
+ # @param coordinates [Hash<String, Float>] Normalized coordinates
127
+ # @param region [Hash<String, Hash>] Region definition per axis
128
+ # @return [Float] Combined scalar [0, 1]
129
+ def calculate_region_scalar(coordinates, region)
130
+ scalar = 1.0
131
+
132
+ region.each do |axis_tag, axis_region|
133
+ coord = coordinates[axis_tag] || 0.0
134
+ axis_scalar = calculate_axis_scalar(coord, axis_region)
135
+
136
+ # If any axis has zero scalar, entire region has zero contribution
137
+ return 0.0 if axis_scalar.zero?
138
+
139
+ scalar *= axis_scalar
140
+ end
141
+
142
+ scalar
143
+ end
144
+
145
+ # Calculate scalars for all regions
146
+ #
147
+ # @param coordinates [Hash<String, Float>] User-space coordinates
148
+ # @param regions [Array<Hash>] Array of region definitions
149
+ # @return [Array<Float>] Scalars for each region
150
+ def calculate_scalars(coordinates, regions)
151
+ # Normalize coordinates first
152
+ normalized = normalize_coordinates(coordinates)
153
+
154
+ # Calculate scalar for each region
155
+ regions.map do |region|
156
+ calculate_region_scalar(normalized, region)
157
+ end
158
+ end
159
+
160
+ # Interpolate a value using deltas
161
+ #
162
+ # @param base_value [Numeric] Base value
163
+ # @param deltas [Array<Numeric>] Delta values (one per region)
164
+ # @param scalars [Array<Float>] Region scalars (one per region)
165
+ # @return [Float] Interpolated value
166
+ def interpolate_value(base_value, deltas, scalars)
167
+ result = base_value.to_f
168
+
169
+ deltas.each_with_index do |delta, index|
170
+ scalar = scalars[index] || 0.0
171
+ result += delta.to_f * scalar
172
+ end
173
+
174
+ result
175
+ end
176
+
177
+ # Interpolate a point (x, y coordinates)
178
+ #
179
+ # @param base_point [Hash] Base point with :x and :y
180
+ # @param delta_points [Array<Hash>] Delta points (one per region)
181
+ # @param scalars [Array<Float>] Region scalars
182
+ # @return [Hash] Interpolated point with :x and :y
183
+ def interpolate_point(base_point, delta_points, scalars)
184
+ x = base_point[:x].to_f
185
+ y = base_point[:y].to_f
186
+
187
+ delta_points.each_with_index do |delta_point, index|
188
+ scalar = scalars[index] || 0.0
189
+ x += delta_point[:x].to_f * scalar
190
+ y += delta_point[:y].to_f * scalar
191
+ end
192
+
193
+ { x: x, y: y }
194
+ end
195
+
196
+ # Build region from tuple variation data
197
+ #
198
+ # Converts gvar tuple data to the region format used by interpolator
199
+ #
200
+ # @param tuple [Hash] Tuple variation data with :peak, :start, :end
201
+ # @return [Hash<String, Hash>] Region definition per axis
202
+ def build_region_from_tuple(tuple)
203
+ region = {}
204
+
205
+ @axes.each_with_index do |axis, axis_index|
206
+ peak = tuple[:peak] ? tuple[:peak][axis_index] : 0.0
207
+ start_val = tuple[:start] ? tuple[:start][axis_index] : -1.0
208
+ end_val = tuple[:end] ? tuple[:end][axis_index] : 1.0
209
+
210
+ region[axis.axis_tag] = {
211
+ start: start_val,
212
+ peak: peak,
213
+ end: end_val,
214
+ }
215
+ end
216
+
217
+ region
218
+ end
219
+
220
+ private
221
+
222
+ # Find axis by tag
223
+ #
224
+ # @param axis_tag [String] Axis tag
225
+ # @return [VariationAxisRecord, nil] Axis or nil
226
+ def find_axis(axis_tag)
227
+ @axes.find { |axis| axis.axis_tag == axis_tag }
228
+ end
229
+ end
230
+ end
231
+ end