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 "table_analyzer"
4
+ require_relative "table_deduplicator"
5
+ require_relative "offset_calculator"
6
+ require_relative "writer"
7
+ require "yaml"
8
+
9
+ module Fontisan
10
+ module Collection
11
+ # CollectionBuilder orchestrates TTC/OTC creation
12
+ #
13
+ # Main responsibility: Coordinate the entire collection creation process
14
+ # including analysis, deduplication, offset calculation, and writing.
15
+ # Implements builder pattern for flexible configuration.
16
+ #
17
+ # @example Create TTC with default options
18
+ # builder = CollectionBuilder.new([font1, font2, font3])
19
+ # builder.build_to_file("family.ttc")
20
+ #
21
+ # @example Create OTC with optimization
22
+ # builder = CollectionBuilder.new([font1, font2, font3])
23
+ # builder.format = :otc
24
+ # builder.optimize = true
25
+ # result = builder.build
26
+ # puts "Saved #{result[:space_savings]} bytes"
27
+ class Builder
28
+ # Source fonts
29
+ # @return [Array<TrueTypeFont, OpenTypeFont>]
30
+ attr_reader :fonts
31
+
32
+ # Collection format (:ttc or :otc)
33
+ # @return [Symbol]
34
+ attr_accessor :format
35
+
36
+ # Enable table sharing optimization
37
+ # @return [Boolean]
38
+ attr_accessor :optimize
39
+
40
+ # Configuration settings
41
+ # @return [Hash]
42
+ attr_accessor :config
43
+
44
+ # Build result (populated after build)
45
+ # @return [Hash, nil]
46
+ attr_reader :result
47
+
48
+ # Initialize builder with fonts
49
+ #
50
+ # @param fonts [Array<TrueTypeFont, OpenTypeFont>] Fonts to pack
51
+ # @param options [Hash] Builder options
52
+ # @option options [Symbol] :format Format type (:ttc or :otc, default: :ttc)
53
+ # @option options [Boolean] :optimize Enable optimization (default: true)
54
+ # @option options [Hash] :config Configuration overrides
55
+ # @raise [ArgumentError] if fonts array is invalid
56
+ def initialize(fonts, options = {})
57
+ if fonts.nil? || fonts.empty?
58
+ raise ArgumentError,
59
+ "fonts cannot be nil or empty"
60
+ end
61
+ raise ArgumentError, "fonts must be an array" unless fonts.is_a?(Array)
62
+
63
+ unless fonts.all? do |f|
64
+ f.respond_to?(:table_data)
65
+ end
66
+ raise ArgumentError,
67
+ "all fonts must respond to table_data"
68
+ end
69
+
70
+ @fonts = fonts
71
+ @format = options[:format] || :ttc
72
+ @optimize = options.fetch(:optimize, true)
73
+ @config = load_config.merge(options[:config] || {})
74
+ @result = nil
75
+
76
+ validate_format!
77
+ end
78
+
79
+ # Build collection and return binary
80
+ #
81
+ # Executes the complete collection creation process:
82
+ # 1. Analyze tables across fonts
83
+ # 2. Deduplicate identical tables
84
+ # 3. Calculate file offsets
85
+ # 4. Write binary structure
86
+ #
87
+ # @return [Hash] Build result with:
88
+ # - :binary [String] - Complete collection binary
89
+ # - :space_savings [Integer] - Bytes saved by sharing
90
+ # - :analysis [Hash] - Analysis report
91
+ # - :statistics [Hash] - Deduplication statistics
92
+ def build
93
+ # Step 1: Analyze tables
94
+ analyzer = TableAnalyzer.new(@fonts)
95
+ analysis_report = analyzer.analyze
96
+
97
+ # Step 2: Deduplicate tables
98
+ deduplicator = TableDeduplicator.new(@fonts)
99
+ sharing_map = deduplicator.build_sharing_map
100
+ statistics = deduplicator.statistics
101
+
102
+ # Step 3: Calculate offsets
103
+ calculator = OffsetCalculator.new(sharing_map, @fonts)
104
+ offsets = calculator.calculate
105
+
106
+ # Step 4: Write collection
107
+ writer = Writer.new(@fonts, sharing_map, offsets, format: @format)
108
+ binary = writer.write_collection
109
+
110
+ # Store result
111
+ @result = {
112
+ binary: binary,
113
+ space_savings: analysis_report[:space_savings],
114
+ analysis: analysis_report,
115
+ statistics: statistics,
116
+ format: @format,
117
+ num_fonts: @fonts.size,
118
+ }
119
+
120
+ @result
121
+ end
122
+
123
+ # Build collection and write to file
124
+ #
125
+ # @param path [String] Output file path
126
+ # @return [Hash] Build result (same as build method)
127
+ def build_to_file(path)
128
+ result = build
129
+ File.binwrite(path, result[:binary])
130
+ result[:output_path] = path
131
+ result[:output_size] = result[:binary].bytesize
132
+ result
133
+ end
134
+
135
+ # Get analysis report
136
+ #
137
+ # Runs analysis without building the full collection.
138
+ # Useful for previewing space savings before committing to build.
139
+ #
140
+ # @return [Hash] Analysis report
141
+ def analyze
142
+ analyzer = TableAnalyzer.new(@fonts)
143
+ analyzer.analyze
144
+ end
145
+
146
+ # Get potential space savings without building
147
+ #
148
+ # @return [Integer] Bytes that can be saved
149
+ def potential_savings
150
+ analyze[:space_savings]
151
+ end
152
+
153
+ # Validate collection can be built
154
+ #
155
+ # @return [Boolean] true if valid, raises error otherwise
156
+ # @raise [Error] if validation fails
157
+ def validate!
158
+ # Check minimum fonts
159
+ raise Error, "Collection requires at least 2 fonts" if @fonts.size < 2
160
+
161
+ # Check format compatibility
162
+ incompatible = check_format_compatibility
163
+ if incompatible.any?
164
+ raise Error, "Format mismatch: #{incompatible.join(', ')}"
165
+ end
166
+
167
+ # Check variable font compatibility
168
+ validate_variation_compatibility! if variable_fonts_in_collection?
169
+
170
+ # Check all fonts have required tables
171
+ @fonts.each_with_index do |font, index|
172
+ required_tables = %w[head hhea maxp]
173
+ missing = required_tables.reject { |tag| font.has_table?(tag) }
174
+ unless missing.empty?
175
+ raise Error,
176
+ "Font #{index} missing required tables: #{missing.join(', ')}"
177
+ end
178
+ end
179
+
180
+ true
181
+ end
182
+
183
+ # Check if collection contains variable fonts
184
+ #
185
+ # @return [Boolean] true if any font has fvar table
186
+ def variable_fonts_in_collection?
187
+ @fonts.any? { |font| font.has_table?("fvar") }
188
+ end
189
+
190
+ # Validate variable font compatibility
191
+ #
192
+ # Ensures all variable fonts in the collection are compatible:
193
+ # - All must be same variation type (TrueType or CFF2)
194
+ # - All must have the same axes
195
+ #
196
+ # @return [void]
197
+ # @raise [Error] if variable fonts are incompatible
198
+ def validate_variation_compatibility!
199
+ validate_all_same_variation_type!
200
+ validate_same_axes!
201
+ end
202
+
203
+ private
204
+
205
+ # Load configuration from file
206
+ #
207
+ # @return [Hash] Configuration hash
208
+ def load_config
209
+ config_path = File.join(__dir__, "..", "config",
210
+ "collection_settings.yml")
211
+ if File.exist?(config_path)
212
+ YAML.load_file(config_path)
213
+ else
214
+ default_config
215
+ end
216
+ rescue StandardError => e
217
+ warn "Failed to load config: #{e.message}, using defaults"
218
+ default_config
219
+ end
220
+
221
+ # Default configuration
222
+ #
223
+ # @return [Hash] Default settings
224
+ def default_config
225
+ {
226
+ "table_sharing_strategy" => "conservative",
227
+ "alignment" => 4,
228
+ "optimize_table_order" => true,
229
+ "verify_checksums" => true,
230
+ }
231
+ end
232
+
233
+ # Validate format is supported
234
+ #
235
+ # @return [void]
236
+ # @raise [ArgumentError] if format is invalid
237
+ def validate_format!
238
+ valid_formats = %i[ttc otc]
239
+ return if valid_formats.include?(@format)
240
+
241
+ raise ArgumentError,
242
+ "Invalid format: #{@format}. Must be one of: #{valid_formats.join(', ')}"
243
+ end
244
+
245
+ # Check if all fonts are compatible with selected format
246
+ #
247
+ # @return [Array<String>] Array of incompatibility messages
248
+ def check_format_compatibility
249
+ incompatible = []
250
+
251
+ if @format == :ttc
252
+ # TTC requires TrueType fonts (sfnt version 0x00010000 or 'true')
253
+ @fonts.each_with_index do |font, index|
254
+ sfnt = font.header.sfnt_version
255
+ unless [0x00010000, 0x74727565].include?(sfnt) # 0x74727565 = 'true'
256
+ incompatible << "Font #{index} is not TrueType (sfnt: 0x#{sfnt.to_s(16)})"
257
+ end
258
+ end
259
+ elsif @format == :otc
260
+ # OTC can contain both TrueType and OpenType/CFF fonts
261
+ # No strict validation needed, but warn about mixing
262
+ has_truetype = false
263
+ has_opentype = false
264
+
265
+ @fonts.each do |font|
266
+ sfnt = font.header.sfnt_version
267
+ if [0x00010000, 0x74727565].include?(sfnt)
268
+ has_truetype = true
269
+ elsif sfnt == 0x4F54544F # 'OTTO'
270
+ has_opentype = true
271
+ end
272
+ end
273
+
274
+ if has_truetype && has_opentype
275
+ warn "Warning: Mixing TrueType and OpenType/CFF fonts in OTC"
276
+ end
277
+ end
278
+
279
+ incompatible
280
+ end
281
+
282
+ # Validate all variable fonts use same variation type
283
+ #
284
+ # @return [void]
285
+ # @raise [Error] if mixing TrueType and CFF2 variable fonts
286
+ def validate_all_same_variation_type!
287
+ variable_fonts = @fonts.select { |f| f.has_table?("fvar") }
288
+ return if variable_fonts.empty?
289
+
290
+ ttf_count = variable_fonts.count { |f| f.has_table?("glyf") }
291
+ otf_count = variable_fonts.count { |f| f.has_table?("CFF2") }
292
+
293
+ if ttf_count.positive? && otf_count.positive?
294
+ raise Error, "Cannot mix TrueType and CFF2 variable fonts in collection"
295
+ end
296
+ end
297
+
298
+ # Validate all variable fonts have same axes
299
+ #
300
+ # @return [void]
301
+ # @raise [Error] if variable fonts have different axes
302
+ def validate_same_axes!
303
+ variable_fonts = @fonts.select { |f| f.has_table?("fvar") }
304
+ return if variable_fonts.size < 2
305
+
306
+ first_axes = extract_axes(variable_fonts.first)
307
+ variable_fonts.each_with_index do |font, index|
308
+ font_axes = extract_axes(font)
309
+ unless axes_match?(font_axes, first_axes)
310
+ raise Error,
311
+ "Variable font #{index} has different axes. " \
312
+ "Expected: #{first_axes.join(', ')}, " \
313
+ "Got: #{font_axes.join(', ')}"
314
+ end
315
+ end
316
+ end
317
+
318
+ # Extract axis tags from a font's fvar table
319
+ #
320
+ # @param font [TrueTypeFont, OpenTypeFont] Font to extract axes from
321
+ # @return [Array<String>] Sorted array of axis tags
322
+ def extract_axes(font)
323
+ return [] unless font.has_table?("fvar")
324
+
325
+ fvar_table = font.table("fvar")
326
+ return [] unless fvar_table.respond_to?(:axes)
327
+
328
+ fvar_table.axes.map(&:axis_tag).sort
329
+ end
330
+
331
+ # Check if two axis arrays match
332
+ #
333
+ # @param axes1 [Array<String>] First axis array
334
+ # @param axes2 [Array<String>] Second axis array
335
+ # @return [Boolean] true if axes match
336
+ def axes_match?(axes1, axes2)
337
+ axes1 == axes2
338
+ end
339
+ end
340
+ end
341
+ end
@@ -0,0 +1,227 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Collection
5
+ # OffsetCalculator calculates file offsets for TTC/OTC structure
6
+ #
7
+ # Single responsibility: Calculate all file offsets for the collection structure
8
+ # including TTC header, offset table, font directories, and table data.
9
+ # Handles 4-byte alignment requirements.
10
+ #
11
+ # TTC/OTC Structure:
12
+ # - TTC Header (12 bytes)
13
+ # - Offset Table (4 bytes per font)
14
+ # - Font 0 Table Directory
15
+ # - Font 1 Table Directory
16
+ # - ...
17
+ # - Shared Tables
18
+ # - Unique Tables
19
+ #
20
+ # @example Calculate offsets
21
+ # calculator = OffsetCalculator.new(sharing_map, fonts)
22
+ # offsets = calculator.calculate
23
+ # header_offset = offsets[:header_offset]
24
+ # font_directory_offsets = offsets[:font_directory_offsets]
25
+ class OffsetCalculator
26
+ # Alignment requirement for tables (4 bytes)
27
+ TABLE_ALIGNMENT = 4
28
+
29
+ # TTC header size (12 bytes)
30
+ TTC_HEADER_SIZE = 12
31
+
32
+ # Size of each font offset entry (4 bytes)
33
+ FONT_OFFSET_SIZE = 4
34
+
35
+ # Size of font directory header (12 bytes: sfnt_version, num_tables, searchRange, entrySelector, rangeShift)
36
+ FONT_DIRECTORY_HEADER_SIZE = 12
37
+
38
+ # Size of each table directory entry (16 bytes: tag, checksum, offset, length)
39
+ TABLE_DIRECTORY_ENTRY_SIZE = 16
40
+
41
+ # Initialize calculator
42
+ #
43
+ # @param sharing_map [Hash] Sharing map from TableDeduplicator
44
+ # @param fonts [Array<TrueTypeFont, OpenTypeFont>] Source fonts
45
+ # @raise [ArgumentError] if parameters are invalid
46
+ def initialize(sharing_map, fonts)
47
+ raise ArgumentError, "sharing_map cannot be nil" if sharing_map.nil?
48
+
49
+ if fonts.nil? || fonts.empty?
50
+ raise ArgumentError,
51
+ "fonts cannot be nil or empty"
52
+ end
53
+
54
+ @sharing_map = sharing_map
55
+ @fonts = fonts
56
+ @offsets = {}
57
+ end
58
+
59
+ # Calculate all offsets for the collection
60
+ #
61
+ # @return [Hash] Complete offset map with:
62
+ # - :header_offset [Integer] - TTC header offset (always 0)
63
+ # - :offset_table_offset [Integer] - Offset table offset (always 12)
64
+ # - :font_directory_offsets [Array<Integer>] - Offset to each font's directory
65
+ # - :table_offsets [Hash] - Map of canonical_id to file offset
66
+ # - :font_table_directories [Hash] - Per-font table directory info
67
+ def calculate
68
+ @offsets = {
69
+ header_offset: 0,
70
+ offset_table_offset: TTC_HEADER_SIZE,
71
+ font_directory_offsets: [],
72
+ table_offsets: {},
73
+ font_table_directories: {},
74
+ }
75
+
76
+ # Calculate offset after TTC header and offset table
77
+ current_offset = TTC_HEADER_SIZE + (@fonts.size * FONT_OFFSET_SIZE)
78
+
79
+ # Calculate offsets for each font's table directory
80
+ calculate_font_directory_offsets(current_offset)
81
+
82
+ # Calculate offsets for table data
83
+ calculate_table_data_offsets
84
+
85
+ @offsets
86
+ end
87
+
88
+ # Get offset for specific font's directory
89
+ #
90
+ # @param font_index [Integer] Font index
91
+ # @return [Integer, nil] Offset or nil if not calculated
92
+ def font_directory_offset(font_index)
93
+ calculate unless @offsets.key?(:font_directory_offsets) && @offsets[:font_directory_offsets].any?
94
+ @offsets[:font_directory_offsets][font_index]
95
+ end
96
+
97
+ # Get offset for specific table
98
+ #
99
+ # @param canonical_id [String] Canonical table ID
100
+ # @return [Integer, nil] Offset or nil if not found
101
+ def table_offset(canonical_id)
102
+ calculate unless @offsets.key?(:table_offsets) && @offsets[:table_offsets].any?
103
+ @offsets[:table_offsets][canonical_id]
104
+ end
105
+
106
+ private
107
+
108
+ # Calculate offsets for each font's table directory
109
+ #
110
+ # Each font directory contains:
111
+ # - Font directory header (12 bytes)
112
+ # - Table directory entries (16 bytes each)
113
+ #
114
+ # @param start_offset [Integer] Starting offset
115
+ # @return [void]
116
+ def calculate_font_directory_offsets(start_offset)
117
+ current_offset = start_offset
118
+
119
+ @fonts.each_with_index do |font, font_index|
120
+ # Store this font's directory offset
121
+ @offsets[:font_directory_offsets] << current_offset
122
+
123
+ # Calculate size of this font's directory
124
+ num_tables = font.table_names.size
125
+ directory_size = FONT_DIRECTORY_HEADER_SIZE + (num_tables * TABLE_DIRECTORY_ENTRY_SIZE)
126
+
127
+ # Store directory info
128
+ @offsets[:font_table_directories][font_index] = {
129
+ offset: current_offset,
130
+ size: directory_size,
131
+ num_tables: num_tables,
132
+ table_tags: font.table_names,
133
+ }
134
+
135
+ # Move to next font's directory (with alignment)
136
+ current_offset = align_offset(current_offset + directory_size)
137
+ end
138
+
139
+ # Store offset where table data begins
140
+ @table_data_start_offset = current_offset
141
+ end
142
+
143
+ # Calculate offsets for all table data
144
+ #
145
+ # Processes tables in two groups:
146
+ # 1. Shared tables (stored once)
147
+ # 2. Unique tables (stored per font)
148
+ #
149
+ # @return [void]
150
+ def calculate_table_data_offsets
151
+ current_offset = @table_data_start_offset
152
+
153
+ # Collect all unique canonical tables
154
+ canonical_tables = {}
155
+ @sharing_map.each_value do |tables|
156
+ tables.each do |tag, info|
157
+ canonical_id = info[:canonical_id]
158
+ next if canonical_tables[canonical_id] # Already processed
159
+
160
+ canonical_tables[canonical_id] = {
161
+ tag: tag,
162
+ size: info[:size],
163
+ shared: info[:shared],
164
+ }
165
+ end
166
+ end
167
+
168
+ # First, assign offsets to shared tables
169
+ # Shared tables are stored once and referenced by multiple fonts
170
+ canonical_tables.each do |canonical_id, info|
171
+ next unless info[:shared]
172
+
173
+ @offsets[:table_offsets][canonical_id] = current_offset
174
+ current_offset = align_offset(current_offset + info[:size])
175
+ end
176
+
177
+ # Then, assign offsets to unique tables
178
+ # Each font gets its own copy of unique tables
179
+ canonical_tables.each do |canonical_id, info|
180
+ next if info[:shared]
181
+
182
+ @offsets[:table_offsets][canonical_id] = current_offset
183
+ current_offset = align_offset(current_offset + info[:size])
184
+ end
185
+ end
186
+
187
+ # Align offset to TABLE_ALIGNMENT boundary
188
+ #
189
+ # @param offset [Integer] Unaligned offset
190
+ # @return [Integer] Aligned offset
191
+ def align_offset(offset)
192
+ remainder = offset % TABLE_ALIGNMENT
193
+ return offset if remainder.zero?
194
+
195
+ offset + (TABLE_ALIGNMENT - remainder)
196
+ end
197
+
198
+ # Calculate search range parameters for font directory header
199
+ #
200
+ # These values are used in the font directory header for binary search:
201
+ # - searchRange: (max power of 2 <= numTables) * 16
202
+ # - entrySelector: log2(max power of 2 <= numTables)
203
+ # - rangeShift: numTables * 16 - searchRange
204
+ #
205
+ # @param num_tables [Integer] Number of tables
206
+ # @return [Hash] Search parameters
207
+ def calculate_search_params(num_tables)
208
+ max_power = 0
209
+ n = num_tables
210
+ while n > 1
211
+ n >>= 1
212
+ max_power += 1
213
+ end
214
+
215
+ search_range = (1 << max_power) * 16
216
+ entry_selector = max_power
217
+ range_shift = (num_tables * 16) - search_range
218
+
219
+ {
220
+ search_range: search_range,
221
+ entry_selector: entry_selector,
222
+ range_shift: range_shift,
223
+ }
224
+ end
225
+ end
226
+ end
227
+ end