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.
- checksums.yaml +4 -4
- data/.rubocop_todo.yml +672 -69
- data/Gemfile +1 -0
- data/LICENSE +5 -1
- data/README.adoc +1477 -297
- data/Rakefile +63 -41
- data/benchmark/variation_quick_bench.rb +47 -0
- data/docs/EXTRACT_TTC_MIGRATION.md +549 -0
- data/fontisan.gemspec +4 -1
- data/lib/fontisan/binary/base_record.rb +22 -1
- data/lib/fontisan/cli.rb +364 -4
- data/lib/fontisan/collection/builder.rb +341 -0
- data/lib/fontisan/collection/offset_calculator.rb +227 -0
- data/lib/fontisan/collection/table_analyzer.rb +204 -0
- data/lib/fontisan/collection/table_deduplicator.rb +317 -0
- data/lib/fontisan/collection/writer.rb +306 -0
- data/lib/fontisan/commands/base_command.rb +24 -1
- data/lib/fontisan/commands/convert_command.rb +218 -0
- data/lib/fontisan/commands/export_command.rb +161 -0
- data/lib/fontisan/commands/info_command.rb +40 -6
- data/lib/fontisan/commands/instance_command.rb +286 -0
- data/lib/fontisan/commands/ls_command.rb +113 -0
- data/lib/fontisan/commands/pack_command.rb +241 -0
- data/lib/fontisan/commands/subset_command.rb +245 -0
- data/lib/fontisan/commands/unpack_command.rb +338 -0
- data/lib/fontisan/commands/validate_command.rb +203 -0
- data/lib/fontisan/commands/variable_command.rb +30 -1
- data/lib/fontisan/config/collection_settings.yml +56 -0
- data/lib/fontisan/config/conversion_matrix.yml +212 -0
- data/lib/fontisan/config/export_settings.yml +66 -0
- data/lib/fontisan/config/subset_profiles.yml +100 -0
- data/lib/fontisan/config/svg_settings.yml +60 -0
- data/lib/fontisan/config/validation_rules.yml +149 -0
- data/lib/fontisan/config/variable_settings.yml +99 -0
- data/lib/fontisan/config/woff2_settings.yml +77 -0
- data/lib/fontisan/constants.rb +79 -0
- data/lib/fontisan/converters/conversion_strategy.rb +96 -0
- data/lib/fontisan/converters/format_converter.rb +408 -0
- data/lib/fontisan/converters/outline_converter.rb +998 -0
- data/lib/fontisan/converters/svg_generator.rb +244 -0
- data/lib/fontisan/converters/table_copier.rb +117 -0
- data/lib/fontisan/converters/woff2_encoder.rb +416 -0
- data/lib/fontisan/converters/woff_writer.rb +391 -0
- data/lib/fontisan/error.rb +203 -0
- data/lib/fontisan/export/exporter.rb +262 -0
- data/lib/fontisan/export/table_serializer.rb +255 -0
- data/lib/fontisan/export/transformers/font_to_ttx.rb +172 -0
- data/lib/fontisan/export/transformers/head_transformer.rb +96 -0
- data/lib/fontisan/export/transformers/hhea_transformer.rb +59 -0
- data/lib/fontisan/export/transformers/maxp_transformer.rb +63 -0
- data/lib/fontisan/export/transformers/name_transformer.rb +63 -0
- data/lib/fontisan/export/transformers/os2_transformer.rb +121 -0
- data/lib/fontisan/export/transformers/post_transformer.rb +51 -0
- data/lib/fontisan/export/ttx_generator.rb +527 -0
- data/lib/fontisan/export/ttx_parser.rb +300 -0
- data/lib/fontisan/font_loader.rb +122 -15
- data/lib/fontisan/font_writer.rb +302 -0
- data/lib/fontisan/formatters/text_formatter.rb +102 -0
- data/lib/fontisan/glyph_accessor.rb +503 -0
- data/lib/fontisan/hints/hint_converter.rb +310 -0
- data/lib/fontisan/hints/postscript_hint_applier.rb +266 -0
- data/lib/fontisan/hints/postscript_hint_extractor.rb +354 -0
- data/lib/fontisan/hints/truetype_hint_applier.rb +117 -0
- data/lib/fontisan/hints/truetype_hint_extractor.rb +289 -0
- data/lib/fontisan/loading_modes.rb +115 -0
- data/lib/fontisan/metrics_calculator.rb +277 -0
- data/lib/fontisan/models/collection_font_summary.rb +52 -0
- data/lib/fontisan/models/collection_info.rb +76 -0
- data/lib/fontisan/models/collection_list_info.rb +37 -0
- data/lib/fontisan/models/font_export.rb +158 -0
- data/lib/fontisan/models/font_summary.rb +48 -0
- data/lib/fontisan/models/glyph_outline.rb +343 -0
- data/lib/fontisan/models/hint.rb +405 -0
- data/lib/fontisan/models/outline.rb +664 -0
- data/lib/fontisan/models/table_sharing_info.rb +40 -0
- data/lib/fontisan/models/ttx/glyph_order.rb +31 -0
- data/lib/fontisan/models/ttx/tables/binary_table.rb +67 -0
- data/lib/fontisan/models/ttx/tables/head_table.rb +74 -0
- data/lib/fontisan/models/ttx/tables/hhea_table.rb +74 -0
- data/lib/fontisan/models/ttx/tables/maxp_table.rb +55 -0
- data/lib/fontisan/models/ttx/tables/name_table.rb +45 -0
- data/lib/fontisan/models/ttx/tables/os2_table.rb +157 -0
- data/lib/fontisan/models/ttx/tables/post_table.rb +50 -0
- data/lib/fontisan/models/ttx/ttfont.rb +49 -0
- data/lib/fontisan/models/validation_report.rb +203 -0
- data/lib/fontisan/open_type_collection.rb +156 -2
- data/lib/fontisan/open_type_font.rb +321 -19
- data/lib/fontisan/open_type_font_extensions.rb +54 -0
- data/lib/fontisan/optimizers/charstring_rewriter.rb +161 -0
- data/lib/fontisan/optimizers/pattern_analyzer.rb +308 -0
- data/lib/fontisan/optimizers/stack_tracker.rb +246 -0
- data/lib/fontisan/optimizers/subroutine_builder.rb +134 -0
- data/lib/fontisan/optimizers/subroutine_generator.rb +207 -0
- data/lib/fontisan/optimizers/subroutine_optimizer.rb +107 -0
- data/lib/fontisan/outline_extractor.rb +423 -0
- data/lib/fontisan/pipeline/format_detector.rb +249 -0
- data/lib/fontisan/pipeline/output_writer.rb +154 -0
- data/lib/fontisan/pipeline/strategies/base_strategy.rb +75 -0
- data/lib/fontisan/pipeline/strategies/instance_strategy.rb +93 -0
- data/lib/fontisan/pipeline/strategies/named_strategy.rb +118 -0
- data/lib/fontisan/pipeline/strategies/preserve_strategy.rb +56 -0
- data/lib/fontisan/pipeline/transformation_pipeline.rb +411 -0
- data/lib/fontisan/pipeline/variation_resolver.rb +165 -0
- data/lib/fontisan/subset/builder.rb +268 -0
- data/lib/fontisan/subset/glyph_mapping.rb +215 -0
- data/lib/fontisan/subset/options.rb +142 -0
- data/lib/fontisan/subset/profile.rb +152 -0
- data/lib/fontisan/subset/table_subsetter.rb +461 -0
- data/lib/fontisan/svg/font_face_generator.rb +278 -0
- data/lib/fontisan/svg/font_generator.rb +264 -0
- data/lib/fontisan/svg/glyph_generator.rb +168 -0
- data/lib/fontisan/svg/view_box_calculator.rb +137 -0
- data/lib/fontisan/tables/cff/cff_glyph.rb +176 -0
- data/lib/fontisan/tables/cff/charset.rb +282 -0
- data/lib/fontisan/tables/cff/charstring.rb +934 -0
- data/lib/fontisan/tables/cff/charstring_builder.rb +356 -0
- data/lib/fontisan/tables/cff/charstring_parser.rb +237 -0
- data/lib/fontisan/tables/cff/charstring_rebuilder.rb +172 -0
- data/lib/fontisan/tables/cff/charstrings_index.rb +162 -0
- data/lib/fontisan/tables/cff/dict.rb +351 -0
- data/lib/fontisan/tables/cff/dict_builder.rb +257 -0
- data/lib/fontisan/tables/cff/encoding.rb +274 -0
- data/lib/fontisan/tables/cff/header.rb +102 -0
- data/lib/fontisan/tables/cff/hint_operation_injector.rb +207 -0
- data/lib/fontisan/tables/cff/index.rb +237 -0
- data/lib/fontisan/tables/cff/index_builder.rb +170 -0
- data/lib/fontisan/tables/cff/offset_recalculator.rb +70 -0
- data/lib/fontisan/tables/cff/private_dict.rb +284 -0
- data/lib/fontisan/tables/cff/private_dict_writer.rb +125 -0
- data/lib/fontisan/tables/cff/table_builder.rb +221 -0
- data/lib/fontisan/tables/cff/top_dict.rb +236 -0
- data/lib/fontisan/tables/cff.rb +489 -0
- data/lib/fontisan/tables/cff2/blend_operator.rb +240 -0
- data/lib/fontisan/tables/cff2/charstring_parser.rb +591 -0
- data/lib/fontisan/tables/cff2/operand_stack.rb +232 -0
- data/lib/fontisan/tables/cff2/private_dict_blend_handler.rb +246 -0
- data/lib/fontisan/tables/cff2/region_matcher.rb +200 -0
- data/lib/fontisan/tables/cff2/table_builder.rb +574 -0
- data/lib/fontisan/tables/cff2/table_reader.rb +419 -0
- data/lib/fontisan/tables/cff2/variation_data_extractor.rb +212 -0
- data/lib/fontisan/tables/cff2.rb +346 -0
- data/lib/fontisan/tables/cvar.rb +203 -0
- data/lib/fontisan/tables/fvar.rb +2 -2
- data/lib/fontisan/tables/glyf/compound_glyph.rb +483 -0
- data/lib/fontisan/tables/glyf/compound_glyph_resolver.rb +136 -0
- data/lib/fontisan/tables/glyf/curve_converter.rb +343 -0
- data/lib/fontisan/tables/glyf/glyph_builder.rb +450 -0
- data/lib/fontisan/tables/glyf/simple_glyph.rb +382 -0
- data/lib/fontisan/tables/glyf.rb +235 -0
- data/lib/fontisan/tables/gvar.rb +231 -0
- data/lib/fontisan/tables/hhea.rb +124 -0
- data/lib/fontisan/tables/hmtx.rb +287 -0
- data/lib/fontisan/tables/hvar.rb +191 -0
- data/lib/fontisan/tables/loca.rb +322 -0
- data/lib/fontisan/tables/maxp.rb +192 -0
- data/lib/fontisan/tables/mvar.rb +185 -0
- data/lib/fontisan/tables/name.rb +99 -30
- data/lib/fontisan/tables/variation_common.rb +346 -0
- data/lib/fontisan/tables/vvar.rb +234 -0
- data/lib/fontisan/true_type_collection.rb +156 -2
- data/lib/fontisan/true_type_font.rb +321 -20
- data/lib/fontisan/true_type_font_extensions.rb +54 -0
- data/lib/fontisan/utilities/brotli_wrapper.rb +159 -0
- data/lib/fontisan/utilities/checksum_calculator.rb +60 -0
- data/lib/fontisan/utils/thread_pool.rb +134 -0
- data/lib/fontisan/validation/checksum_validator.rb +170 -0
- data/lib/fontisan/validation/consistency_validator.rb +197 -0
- data/lib/fontisan/validation/structure_validator.rb +198 -0
- data/lib/fontisan/validation/table_validator.rb +158 -0
- data/lib/fontisan/validation/validator.rb +152 -0
- data/lib/fontisan/validation/variable_font_validator.rb +218 -0
- data/lib/fontisan/variable/axis_normalizer.rb +215 -0
- data/lib/fontisan/variable/delta_applicator.rb +313 -0
- data/lib/fontisan/variable/glyph_delta_processor.rb +218 -0
- data/lib/fontisan/variable/instancer.rb +344 -0
- data/lib/fontisan/variable/metric_delta_processor.rb +282 -0
- data/lib/fontisan/variable/region_matcher.rb +208 -0
- data/lib/fontisan/variable/static_font_builder.rb +213 -0
- data/lib/fontisan/variable/table_updater.rb +219 -0
- data/lib/fontisan/variation/blend_applier.rb +199 -0
- data/lib/fontisan/variation/cache.rb +298 -0
- data/lib/fontisan/variation/cache_key_builder.rb +162 -0
- data/lib/fontisan/variation/converter.rb +375 -0
- data/lib/fontisan/variation/data_extractor.rb +86 -0
- data/lib/fontisan/variation/delta_applier.rb +266 -0
- data/lib/fontisan/variation/delta_parser.rb +228 -0
- data/lib/fontisan/variation/inspector.rb +275 -0
- data/lib/fontisan/variation/instance_generator.rb +273 -0
- data/lib/fontisan/variation/instance_writer.rb +341 -0
- data/lib/fontisan/variation/interpolator.rb +231 -0
- data/lib/fontisan/variation/metrics_adjuster.rb +318 -0
- data/lib/fontisan/variation/optimizer.rb +418 -0
- data/lib/fontisan/variation/parallel_generator.rb +150 -0
- data/lib/fontisan/variation/region_matcher.rb +221 -0
- data/lib/fontisan/variation/subsetter.rb +463 -0
- data/lib/fontisan/variation/table_accessor.rb +105 -0
- data/lib/fontisan/variation/tuple_variation_header.rb +51 -0
- data/lib/fontisan/variation/validator.rb +345 -0
- data/lib/fontisan/variation/variable_svg_generator.rb +268 -0
- data/lib/fontisan/variation/variation_context.rb +211 -0
- data/lib/fontisan/variation/variation_preserver.rb +288 -0
- data/lib/fontisan/version.rb +1 -1
- data/lib/fontisan/version.rb.orig +9 -0
- data/lib/fontisan/woff2/directory.rb +257 -0
- data/lib/fontisan/woff2/glyf_transformer.rb +666 -0
- data/lib/fontisan/woff2/header.rb +101 -0
- data/lib/fontisan/woff2/hmtx_transformer.rb +164 -0
- data/lib/fontisan/woff2/table_transformer.rb +163 -0
- data/lib/fontisan/woff2_font.rb +717 -0
- data/lib/fontisan/woff_font.rb +488 -0
- data/lib/fontisan.rb +132 -0
- data/scripts/compare_stack_aware.rb +187 -0
- data/scripts/measure_optimization.rb +141 -0
- 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
|