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