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,204 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest/sha2"
4
+
5
+ module Fontisan
6
+ module Collection
7
+ # TableAnalyzer analyzes tables across multiple fonts to identify sharing opportunities
8
+ #
9
+ # Single responsibility: Analyze tables across fonts to identify identical tables
10
+ # that can be shared in a font collection. Uses SHA256 checksums for reliable
11
+ # content comparison.
12
+ #
13
+ # @example Analyze tables across fonts
14
+ # analyzer = TableAnalyzer.new([font1, font2, font3])
15
+ # report = analyzer.analyze
16
+ # puts "Potential savings: #{report[:space_savings]} bytes"
17
+ # puts "Shared tables: #{report[:shared_tables].keys.join(', ')}"
18
+ class TableAnalyzer
19
+ # Analysis report structure
20
+ # @return [Hash] Analysis results
21
+ attr_reader :report
22
+
23
+ # Initialize analyzer with fonts
24
+ #
25
+ # @param fonts [Array<TrueTypeFont, OpenTypeFont>] Fonts to analyze
26
+ # @raise [ArgumentError] if fonts array is empty or contains invalid fonts
27
+ def initialize(fonts)
28
+ if fonts.nil? || fonts.empty?
29
+ raise ArgumentError,
30
+ "fonts cannot be nil or empty"
31
+ end
32
+ raise ArgumentError, "fonts must be an array" unless fonts.is_a?(Array)
33
+
34
+ @fonts = fonts
35
+ @report = nil
36
+ end
37
+
38
+ # Analyze tables across all fonts
39
+ #
40
+ # Identifies tables that are identical across fonts based on content checksum.
41
+ # Returns a comprehensive analysis report with sharing opportunities and
42
+ # potential space savings.
43
+ #
44
+ # @return [Hash] Analysis report with:
45
+ # - :total_fonts [Integer] Number of fonts analyzed
46
+ # - :table_checksums [Hash<String, Hash>] Map of tag to checksum to font indices
47
+ # - :shared_tables [Hash<String, Array>] Map of tag to array of font indices sharing that table
48
+ # - :unique_tables [Hash<String, Array>] Map of tag to array of font indices with unique versions
49
+ # - :space_savings [Integer] Potential bytes saved by sharing
50
+ # - :sharing_percentage [Float] Percentage of tables that can be shared
51
+ def analyze
52
+ @report = {
53
+ total_fonts: @fonts.size,
54
+ table_checksums: {},
55
+ shared_tables: {},
56
+ unique_tables: {},
57
+ space_savings: 0,
58
+ sharing_percentage: 0.0,
59
+ }
60
+
61
+ # Collect checksums for all tables across all fonts
62
+ collect_table_checksums
63
+
64
+ # Identify which tables are shared
65
+ identify_shared_tables
66
+
67
+ # Calculate space savings
68
+ calculate_space_savings
69
+
70
+ @report
71
+ end
72
+
73
+ # Get tables that can be shared
74
+ #
75
+ # @return [Hash<String, Array<Integer>>] Map of table tag to font indices
76
+ def shared_tables
77
+ analyze unless @report
78
+ @report[:shared_tables]
79
+ end
80
+
81
+ # Get potential space savings in bytes
82
+ #
83
+ # @return [Integer] Bytes that can be saved by sharing
84
+ def space_savings
85
+ analyze unless @report
86
+ @report[:space_savings]
87
+ end
88
+
89
+ # Get sharing percentage
90
+ #
91
+ # @return [Float] Percentage of tables that can be shared (0.0-100.0)
92
+ def sharing_percentage
93
+ analyze unless @report
94
+ @report[:sharing_percentage]
95
+ end
96
+
97
+ private
98
+
99
+ # Collect checksums for all tables in all fonts
100
+ #
101
+ # Builds a map of: tag -> checksum -> array of font indices
102
+ # This allows quick identification of which fonts share identical tables.
103
+ #
104
+ # @return [void]
105
+ def collect_table_checksums
106
+ @fonts.each_with_index do |font, font_index|
107
+ font.table_names.each do |tag|
108
+ # Get raw table data
109
+ table_data = font.table_data[tag]
110
+ next unless table_data
111
+
112
+ # Calculate checksum
113
+ checksum = calculate_checksum(table_data)
114
+
115
+ # Store in report
116
+ @report[:table_checksums][tag] ||= {}
117
+ @report[:table_checksums][tag][checksum] ||= []
118
+ @report[:table_checksums][tag][checksum] << font_index
119
+ end
120
+ end
121
+ end
122
+
123
+ # Identify which tables are shared across fonts
124
+ #
125
+ # A table is considered shared if 2 or more fonts have identical content
126
+ # (same checksum) for that table.
127
+ #
128
+ # @return [void]
129
+ def identify_shared_tables
130
+ @report[:table_checksums].each do |tag, checksums|
131
+ checksums.each do |checksum, font_indices|
132
+ if font_indices.size > 1
133
+ # This table is shared across multiple fonts
134
+ @report[:shared_tables][tag] ||= []
135
+ @report[:shared_tables][tag] << {
136
+ checksum: checksum,
137
+ font_indices: font_indices,
138
+ count: font_indices.size,
139
+ }
140
+ else
141
+ # This table is unique to one font
142
+ @report[:unique_tables][tag] ||= []
143
+ @report[:unique_tables][tag] << {
144
+ checksum: checksum,
145
+ font_index: font_indices.first,
146
+ }
147
+ end
148
+ end
149
+ end
150
+ end
151
+
152
+ # Calculate potential space savings from table sharing
153
+ #
154
+ # Space is saved when N fonts share a table - we only need to store it once
155
+ # instead of N times. Savings = (N-1) * table_size
156
+ #
157
+ # @return [void]
158
+ def calculate_space_savings
159
+ total_savings = 0
160
+ total_table_instances = 0
161
+ shared_table_instances = 0
162
+
163
+ @report[:shared_tables].each do |tag, sharing_groups|
164
+ sharing_groups.each do |group|
165
+ font_indices = group[:font_indices]
166
+ count = font_indices.size
167
+
168
+ # Get table size from first font in group
169
+ table_data = @fonts[font_indices.first].table_data[tag]
170
+ table_size = table_data.bytesize
171
+
172
+ # Savings = (count - 1) * table_size
173
+ # We only need to store the table once instead of count times
174
+ savings = (count - 1) * table_size
175
+ total_savings += savings
176
+
177
+ shared_table_instances += count
178
+ end
179
+ end
180
+
181
+ # Count total table instances
182
+ @fonts.each do |font|
183
+ total_table_instances += font.table_names.size
184
+ end
185
+
186
+ @report[:space_savings] = total_savings
187
+
188
+ # Calculate sharing percentage
189
+ if total_table_instances.positive?
190
+ @report[:sharing_percentage] =
191
+ (shared_table_instances.to_f / total_table_instances * 100).round(2)
192
+ end
193
+ end
194
+
195
+ # Calculate SHA256 checksum for table data
196
+ #
197
+ # @param data [String] Binary table data
198
+ # @return [String] Hexadecimal checksum
199
+ def calculate_checksum(data)
200
+ Digest::SHA256.hexdigest(data)
201
+ end
202
+ end
203
+ end
204
+ end
@@ -0,0 +1,317 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest/sha2"
4
+
5
+ module Fontisan
6
+ module Collection
7
+ # TableDeduplicator deduplicates identical tables across fonts
8
+ #
9
+ # Single responsibility: Group identical tables and create a canonical mapping
10
+ # for shared table references. Ensures that each unique table content is stored
11
+ # only once in the collection.
12
+ #
13
+ # @example Deduplicate tables
14
+ # deduplicator = TableDeduplicator.new([font1, font2, font3])
15
+ # sharing_map = deduplicator.build_sharing_map
16
+ # canonical_tables = deduplicator.canonical_tables
17
+ class TableDeduplicator
18
+ # Tables that can be shared in variable font collections if identical
19
+ VARIATION_SHAREABLE_TABLES = %w[fvar avar STAT HVAR VVAR MVAR].freeze
20
+
21
+ # Tables that must remain font-specific in variable fonts
22
+ VARIATION_FONT_SPECIFIC_TABLES = %w[gvar CFF2].freeze
23
+
24
+ # Canonical tables (unique table data)
25
+ # @return [Hash<String, Hash>] Map of table tag to canonical versions
26
+ attr_reader :canonical_tables
27
+
28
+ # Sharing map (font -> table -> canonical reference)
29
+ # @return [Hash<Integer, Hash<String, Hash>>] Sharing map
30
+ attr_reader :sharing_map
31
+
32
+ # Initialize deduplicator with fonts
33
+ #
34
+ # @param fonts [Array<TrueTypeFont, OpenTypeFont>] Fonts to process
35
+ # @raise [ArgumentError] if fonts array is empty or invalid
36
+ def initialize(fonts)
37
+ if fonts.nil? || fonts.empty?
38
+ raise ArgumentError,
39
+ "fonts cannot be nil or empty"
40
+ end
41
+ raise ArgumentError, "fonts must be an array" unless fonts.is_a?(Array)
42
+
43
+ @fonts = fonts
44
+ @canonical_tables = {}
45
+ @sharing_map = {}
46
+ @checksum_to_canonical = {}
47
+ end
48
+
49
+ # Build sharing map for all fonts
50
+ #
51
+ # Creates a map structure that indicates which canonical table each font
52
+ # should reference for each table tag. This enables efficient table sharing
53
+ # in the final collection.
54
+ #
55
+ # @return [Hash<Integer, Hash<String, Hash>>] Sharing map with structure:
56
+ # {
57
+ # font_index => {
58
+ # table_tag => {
59
+ # canonical_id: unique_id,
60
+ # checksum: sha256_checksum,
61
+ # data: table_data,
62
+ # shared: true/false,
63
+ # shared_with: [font_indices]
64
+ # }
65
+ # }
66
+ # }
67
+ def build_sharing_map
68
+ # First pass: collect all unique tables
69
+ collect_canonical_tables
70
+
71
+ # Handle variable font table deduplication
72
+ deduplicate_variation_tables if has_variable_fonts?
73
+
74
+ # Second pass: build sharing map for each font
75
+ build_font_sharing_references
76
+
77
+ @sharing_map
78
+ end
79
+
80
+ # Get canonical table data for a specific table
81
+ #
82
+ # @param tag [String] Table tag
83
+ # @param canonical_id [String] Canonical table identifier
84
+ # @return [String, nil] Binary table data
85
+ def canonical_table_data(tag, canonical_id)
86
+ @canonical_tables.dig(tag, canonical_id, :data)
87
+ end
88
+
89
+ # Get all canonical tables for a specific tag
90
+ #
91
+ # @param tag [String] Table tag
92
+ # @return [Hash<String, Hash>, nil] Map of canonical_id to table info
93
+ def canonical_tables_for_tag(tag)
94
+ @canonical_tables[tag]
95
+ end
96
+
97
+ # Get sharing statistics
98
+ #
99
+ # @return [Hash] Statistics about table sharing
100
+ def statistics
101
+ total_tables = 0
102
+ shared_tables = 0
103
+ unique_tables = 0
104
+
105
+ @sharing_map.each_value do |tables|
106
+ tables.each_value do |info|
107
+ total_tables += 1
108
+ if info[:shared]
109
+ shared_tables += 1
110
+ else
111
+ unique_tables += 1
112
+ end
113
+ end
114
+ end
115
+
116
+ {
117
+ total_tables: total_tables,
118
+ shared_tables: shared_tables,
119
+ unique_tables: unique_tables,
120
+ sharing_percentage: total_tables.positive? ? (shared_tables.to_f / total_tables * 100).round(2) : 0.0,
121
+ canonical_count: @canonical_tables.values.sum(&:size),
122
+ }
123
+ end
124
+
125
+ private
126
+
127
+ # Check if any font is a variable font
128
+ #
129
+ # @return [Boolean] true if any font has fvar table
130
+ def has_variable_fonts?
131
+ @fonts.any? { |font| font.has_table?("fvar") }
132
+ end
133
+
134
+ # Deduplicate variable font tables
135
+ #
136
+ # Handles special logic for variable font tables:
137
+ # - Share tables that are identical across fonts (fvar, avar, etc.)
138
+ # - Keep font-specific tables separate (gvar, CFF2)
139
+ #
140
+ # @return [void]
141
+ def deduplicate_variation_tables
142
+ # Share tables that are identical across fonts
143
+ VARIATION_SHAREABLE_TABLES.each do |tag|
144
+ share_if_identical(tag)
145
+ end
146
+
147
+ # Never share font-specific variation tables
148
+ VARIATION_FONT_SPECIFIC_TABLES.each do |tag|
149
+ keep_separate(tag)
150
+ end
151
+ end
152
+
153
+ # Share table if identical across all fonts that have it
154
+ #
155
+ # @param tag [String] Table tag
156
+ # @return [void]
157
+ def share_if_identical(tag)
158
+ # Get all instances of this table
159
+ tables = @fonts.map { |f| f.table_data[tag] }.compact
160
+ return if tables.empty?
161
+
162
+ # Check if all instances are identical
163
+ nil if tables.uniq.length > 1
164
+
165
+ # All instances are identical, mark as shareable
166
+ # The normal deduplication logic will handle this
167
+ end
168
+
169
+ # Ensure table stays separate for each font
170
+ #
171
+ # @param tag [String] Table tag
172
+ # @return [void]
173
+ def keep_separate(tag)
174
+ # Mark each font's instance as non-shareable
175
+ @fonts.each_with_index do |font, _font_index|
176
+ next unless font.has_table?(tag)
177
+
178
+ # Find this font's canonical table for this tag
179
+ table_data = font.table_data[tag]
180
+ checksum = calculate_checksum(table_data)
181
+
182
+ # Ensure canonical table exists
183
+ @canonical_tables[tag] ||= {}
184
+ canonical_id = @checksum_to_canonical.dig(tag, checksum)
185
+
186
+ next unless canonical_id && @canonical_tables[tag][canonical_id]
187
+
188
+ # Mark as non-shareable
189
+ @canonical_tables[tag][canonical_id][:shared] = false
190
+ @canonical_tables[tag][canonical_id][:font_specific] = true
191
+ end
192
+ end
193
+
194
+ # Collect all unique (canonical) tables across all fonts
195
+ #
196
+ # Identifies unique table content based on checksum and stores one
197
+ # canonical version of each unique table.
198
+ #
199
+ # @return [void]
200
+ def collect_canonical_tables
201
+ @fonts.each_with_index do |font, font_index|
202
+ font.table_names.each do |tag|
203
+ table_data = font.table_data[tag]
204
+ next unless table_data
205
+
206
+ # Calculate checksum
207
+ checksum = calculate_checksum(table_data)
208
+
209
+ # Check if we've seen this exact table content before
210
+ canonical_id = find_or_create_canonical(tag, checksum, table_data,
211
+ font_index)
212
+
213
+ # Track which fonts use this canonical table
214
+ @canonical_tables[tag][canonical_id][:font_indices] << font_index
215
+ end
216
+ end
217
+
218
+ # Mark shared tables
219
+ mark_shared_tables
220
+ end
221
+
222
+ # Find existing canonical table or create new one
223
+ #
224
+ # @param tag [String] Table tag
225
+ # @param checksum [String] Table checksum
226
+ # @param data [String] Table data
227
+ # @param font_index [Integer] Font index
228
+ # @return [String] Canonical table ID
229
+ def find_or_create_canonical(tag, checksum, data, _font_index)
230
+ # Initialize tag entry if needed
231
+ @canonical_tables[tag] ||= {}
232
+ @checksum_to_canonical[tag] ||= {}
233
+
234
+ # Check if we already have this exact table content
235
+ if @checksum_to_canonical[tag][checksum]
236
+ # Reuse existing canonical table
237
+ @checksum_to_canonical[tag][checksum]
238
+ else
239
+ # Create new canonical table
240
+ canonical_id = generate_canonical_id(tag, checksum)
241
+ @checksum_to_canonical[tag][checksum] = canonical_id
242
+
243
+ @canonical_tables[tag][canonical_id] = {
244
+ checksum: checksum,
245
+ data: data,
246
+ size: data.bytesize,
247
+ font_indices: [],
248
+ shared: false,
249
+ }
250
+
251
+ canonical_id
252
+ end
253
+ end
254
+
255
+ # Generate unique canonical ID for a table
256
+ #
257
+ # @param tag [String] Table tag
258
+ # @param checksum [String] Table checksum
259
+ # @return [String] Canonical ID
260
+ def generate_canonical_id(tag, checksum)
261
+ # Use first 12 characters of checksum for brevity
262
+ "#{tag}_#{checksum[0...12]}"
263
+ end
264
+
265
+ # Mark tables that are shared across multiple fonts
266
+ #
267
+ # @return [void]
268
+ def mark_shared_tables
269
+ @canonical_tables.each_value do |canonical_versions|
270
+ canonical_versions.each_value do |info|
271
+ info[:shared] = info[:font_indices].size > 1
272
+ info[:shared_with] = info[:font_indices].dup if info[:shared]
273
+ end
274
+ end
275
+ end
276
+
277
+ # Build sharing references for each font
278
+ #
279
+ # Creates a map for each font indicating which canonical table it should
280
+ # reference for each tag.
281
+ #
282
+ # @return [void]
283
+ def build_font_sharing_references
284
+ @fonts.each_with_index do |font, font_index|
285
+ @sharing_map[font_index] = {}
286
+
287
+ font.table_names.each do |tag|
288
+ table_data = font.table_data[tag]
289
+ next unless table_data
290
+
291
+ checksum = calculate_checksum(table_data)
292
+ canonical_id = @checksum_to_canonical[tag][checksum]
293
+
294
+ # Reference canonical table
295
+ canonical_info = @canonical_tables[tag][canonical_id]
296
+ @sharing_map[font_index][tag] = {
297
+ canonical_id: canonical_id,
298
+ checksum: checksum,
299
+ data: canonical_info[:data],
300
+ size: canonical_info[:size],
301
+ shared: canonical_info[:shared],
302
+ shared_with: canonical_info[:shared_with] || [],
303
+ }
304
+ end
305
+ end
306
+ end
307
+
308
+ # Calculate SHA256 checksum for table data
309
+ #
310
+ # @param data [String] Binary table data
311
+ # @return [String] Hexadecimal checksum
312
+ def calculate_checksum(data)
313
+ Digest::SHA256.hexdigest(data)
314
+ end
315
+ end
316
+ end
317
+ end