fontisan 0.1.0 → 0.2.0
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 +529 -65
- data/Gemfile +1 -0
- data/LICENSE +5 -1
- data/README.adoc +1301 -275
- data/Rakefile +27 -2
- 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 +309 -0
- data/lib/fontisan/collection/builder.rb +260 -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 +241 -0
- data/lib/fontisan/collection/writer.rb +306 -0
- data/lib/fontisan/commands/base_command.rb +8 -1
- data/lib/fontisan/commands/convert_command.rb +291 -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 +295 -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 +178 -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 +69 -0
- data/lib/fontisan/converters/conversion_strategy.rb +96 -0
- data/lib/fontisan/converters/format_converter.rb +259 -0
- data/lib/fontisan/converters/outline_converter.rb +936 -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 +121 -12
- data/lib/fontisan/font_writer.rb +301 -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 +177 -0
- data/lib/fontisan/hints/postscript_hint_applier.rb +185 -0
- data/lib/fontisan/hints/postscript_hint_extractor.rb +254 -0
- data/lib/fontisan/hints/truetype_hint_applier.rb +71 -0
- data/lib/fontisan/hints/truetype_hint_extractor.rb +162 -0
- data/lib/fontisan/loading_modes.rb +113 -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 +233 -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 +296 -10
- 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/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 +905 -0
- data/lib/fontisan/tables/cff/charstring_builder.rb +322 -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 +242 -0
- data/lib/fontisan/tables/cff/encoding.rb +274 -0
- data/lib/fontisan/tables/cff/header.rb +102 -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/private_dict.rb +284 -0
- data/lib/fontisan/tables/cff/top_dict.rb +236 -0
- data/lib/fontisan/tables/cff.rb +487 -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.rb +341 -0
- data/lib/fontisan/tables/cvar.rb +242 -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 +270 -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 +297 -11
- data/lib/fontisan/utilities/brotli_wrapper.rb +159 -0
- data/lib/fontisan/utilities/checksum_calculator.rb +18 -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/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 +268 -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/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/validator.rb +345 -0
- data/lib/fontisan/variation/variation_context.rb +211 -0
- data/lib/fontisan/version.rb +1 -1
- data/lib/fontisan/woff2/directory.rb +257 -0
- data/lib/fontisan/woff2/header.rb +101 -0
- data/lib/fontisan/woff2/table_transformer.rb +163 -0
- data/lib/fontisan/woff2_font.rb +712 -0
- data/lib/fontisan/woff_font.rb +483 -0
- data/lib/fontisan.rb +120 -0
- data/scripts/compare_stack_aware.rb +187 -0
- data/scripts/measure_optimization.rb +141 -0
- metadata +205 -4
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
module Fontisan
|
|
6
|
+
module Variable
|
|
7
|
+
# Calculates region scalars for variation regions
|
|
8
|
+
#
|
|
9
|
+
# Given normalized coordinates and variation regions, computes scalar values
|
|
10
|
+
# (0.0 to 1.0) that determine how much each region contributes to the final
|
|
11
|
+
# delta values. The algorithm follows the OpenType specification for region
|
|
12
|
+
# matching.
|
|
13
|
+
#
|
|
14
|
+
# A region is defined by start, peak, and end coordinates for each axis:
|
|
15
|
+
# - If coordinate is outside [start, end], scalar is 0.0
|
|
16
|
+
# - If coordinate is at peak, scalar contribution for that axis is 1.0
|
|
17
|
+
# - Otherwise, scalar is linearly interpolated
|
|
18
|
+
# - Final scalar is the product of all axis contributions
|
|
19
|
+
#
|
|
20
|
+
# @example Calculate region scalars
|
|
21
|
+
# matcher = RegionMatcher.new(variation_region_list)
|
|
22
|
+
# scalars = matcher.match({ "wght" => 0.5 })
|
|
23
|
+
# # => [0.5, 1.0, 0.0, ...]
|
|
24
|
+
class RegionMatcher
|
|
25
|
+
# @return [Hash] Configuration settings
|
|
26
|
+
attr_reader :config
|
|
27
|
+
|
|
28
|
+
# @return [Array<Array<Hash>>] Variation regions
|
|
29
|
+
attr_reader :regions
|
|
30
|
+
|
|
31
|
+
# Initialize the region matcher
|
|
32
|
+
#
|
|
33
|
+
# @param variation_region_list [VariationCommon::VariationRegionList] Region list
|
|
34
|
+
# @param axis_tags [Array<String>] Axis tags in order
|
|
35
|
+
# @param config [Hash] Optional configuration overrides
|
|
36
|
+
def initialize(variation_region_list, axis_tags, config = {})
|
|
37
|
+
@variation_region_list = variation_region_list
|
|
38
|
+
@axis_tags = axis_tags
|
|
39
|
+
@config = load_config.merge(config)
|
|
40
|
+
@regions = build_regions
|
|
41
|
+
@scalar_cache = {} if @config.dig(:region_matching, :cache_scalars)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Calculate scalars for all regions
|
|
45
|
+
#
|
|
46
|
+
# @param normalized_coords [Hash<String, Float>] Normalized coordinates
|
|
47
|
+
# @return [Array<Float>] Scalar for each region (0.0 to 1.0)
|
|
48
|
+
def match(normalized_coords)
|
|
49
|
+
# Check cache if enabled
|
|
50
|
+
if @config.dig(:region_matching, :cache_scalars)
|
|
51
|
+
cache_key = cache_key_for(normalized_coords)
|
|
52
|
+
return @scalar_cache[cache_key] if @scalar_cache.key?(cache_key)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
scalars = @regions.map do |region|
|
|
56
|
+
calculate_region_scalar(region, normalized_coords)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Cache result if enabled
|
|
60
|
+
if @config.dig(:region_matching, :cache_scalars)
|
|
61
|
+
@scalar_cache[cache_key_for(normalized_coords)] = scalars
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
scalars
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Calculate scalar for a specific region
|
|
68
|
+
#
|
|
69
|
+
# @param region_index [Integer] Region index
|
|
70
|
+
# @param normalized_coords [Hash<String, Float>] Normalized coordinates
|
|
71
|
+
# @return [Float] Region scalar (0.0 to 1.0)
|
|
72
|
+
def match_region(region_index, normalized_coords)
|
|
73
|
+
return 0.0 if region_index >= @regions.length
|
|
74
|
+
|
|
75
|
+
region = @regions[region_index]
|
|
76
|
+
calculate_region_scalar(region, normalized_coords)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Get number of regions
|
|
80
|
+
#
|
|
81
|
+
# @return [Integer] Region count
|
|
82
|
+
def region_count
|
|
83
|
+
@regions.length
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Clear scalar cache
|
|
87
|
+
def clear_cache
|
|
88
|
+
@scalar_cache&.clear
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
private
|
|
92
|
+
|
|
93
|
+
# Load configuration from YAML file
|
|
94
|
+
#
|
|
95
|
+
# @return [Hash] Configuration hash
|
|
96
|
+
def load_config
|
|
97
|
+
config_path = File.join(__dir__, "..", "config",
|
|
98
|
+
"variable_settings.yml")
|
|
99
|
+
YAML.load_file(config_path)
|
|
100
|
+
rescue StandardError
|
|
101
|
+
# Return default config
|
|
102
|
+
{
|
|
103
|
+
region_matching: {
|
|
104
|
+
algorithm: "standard",
|
|
105
|
+
multi_axis: true,
|
|
106
|
+
cache_scalars: true,
|
|
107
|
+
},
|
|
108
|
+
delta_application: {
|
|
109
|
+
min_scalar_threshold: 0.0001,
|
|
110
|
+
},
|
|
111
|
+
}
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Build region information from variation region list
|
|
115
|
+
#
|
|
116
|
+
# @return [Array<Array<Hash>>] Array of regions with axis coordinates
|
|
117
|
+
def build_regions
|
|
118
|
+
return [] unless @variation_region_list
|
|
119
|
+
|
|
120
|
+
@variation_region_list.regions.map do |region_coords|
|
|
121
|
+
# Map axis coordinates to hash
|
|
122
|
+
region_coords.each_with_index.map do |coord, axis_index|
|
|
123
|
+
{
|
|
124
|
+
axis_tag: @axis_tags[axis_index],
|
|
125
|
+
start: coord.start,
|
|
126
|
+
peak: coord.peak,
|
|
127
|
+
end: coord.end_value,
|
|
128
|
+
}
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Calculate scalar for a region
|
|
134
|
+
#
|
|
135
|
+
# @param region [Array<Hash>] Region axis coordinates
|
|
136
|
+
# @param normalized_coords [Hash<String, Float>] Normalized coordinates
|
|
137
|
+
# @return [Float] Region scalar (0.0 to 1.0)
|
|
138
|
+
def calculate_region_scalar(region, normalized_coords)
|
|
139
|
+
# Start with scalar of 1.0
|
|
140
|
+
scalar = 1.0
|
|
141
|
+
|
|
142
|
+
# Process each axis in the region
|
|
143
|
+
region.each do |axis_coord|
|
|
144
|
+
axis_tag = axis_coord[:axis_tag]
|
|
145
|
+
coord = normalized_coords[axis_tag] || normalized_coords[axis_tag.to_sym] || 0.0
|
|
146
|
+
|
|
147
|
+
# Calculate contribution for this axis
|
|
148
|
+
axis_scalar = calculate_axis_scalar(
|
|
149
|
+
coord,
|
|
150
|
+
axis_coord[:start],
|
|
151
|
+
axis_coord[:peak],
|
|
152
|
+
axis_coord[:end],
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
# Multiply into total scalar
|
|
156
|
+
scalar *= axis_scalar
|
|
157
|
+
|
|
158
|
+
# Early exit if scalar becomes 0
|
|
159
|
+
break if scalar.zero?
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Apply minimum threshold if configured
|
|
163
|
+
threshold = @config.dig(:delta_application,
|
|
164
|
+
:min_scalar_threshold) || 0.0
|
|
165
|
+
scalar < threshold ? 0.0 : scalar
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Calculate scalar contribution for a single axis
|
|
169
|
+
#
|
|
170
|
+
# @param coord [Float] Normalized coordinate
|
|
171
|
+
# @param start [Float] Region start
|
|
172
|
+
# @param peak [Float] Region peak
|
|
173
|
+
# @param end_coord [Float] Region end
|
|
174
|
+
# @return [Float] Axis scalar (0.0 to 1.0)
|
|
175
|
+
def calculate_axis_scalar(coord, start, peak, end_coord)
|
|
176
|
+
# Outside region range: no contribution
|
|
177
|
+
return 0.0 if coord < start || coord > end_coord
|
|
178
|
+
|
|
179
|
+
# At peak: full contribution
|
|
180
|
+
return 1.0 if (coord - peak).abs < Float::EPSILON
|
|
181
|
+
|
|
182
|
+
# Between start and peak
|
|
183
|
+
if coord < peak
|
|
184
|
+
range = peak - start
|
|
185
|
+
return 1.0 if range.abs < Float::EPSILON
|
|
186
|
+
|
|
187
|
+
return (coord - start) / range
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# Between peak and end
|
|
191
|
+
range = end_coord - peak
|
|
192
|
+
return 1.0 if range.abs < Float::EPSILON
|
|
193
|
+
|
|
194
|
+
(end_coord - coord) / range
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Generate cache key for coordinates
|
|
198
|
+
#
|
|
199
|
+
# @param normalized_coords [Hash] Normalized coordinates
|
|
200
|
+
# @return [String] Cache key
|
|
201
|
+
def cache_key_for(normalized_coords)
|
|
202
|
+
# Sort by axis tag for consistent keys
|
|
203
|
+
sorted_coords = normalized_coords.sort_by { |tag, _| tag.to_s }
|
|
204
|
+
sorted_coords.map { |tag, value| "#{tag}:#{value}" }.join("|")
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
end
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "table_updater"
|
|
4
|
+
require_relative "../font_writer"
|
|
5
|
+
|
|
6
|
+
module Fontisan
|
|
7
|
+
module Variable
|
|
8
|
+
# Builds static font instances from variable font data
|
|
9
|
+
#
|
|
10
|
+
# This class takes a variable font and applied variation data,
|
|
11
|
+
# then constructs a complete static font by:
|
|
12
|
+
# 1. Copying all non-variation tables unchanged
|
|
13
|
+
# 2. Removing variation-specific tables (fvar, gvar, HVAR, etc.)
|
|
14
|
+
# 3. Updating metric tables (hmtx, hhea) with varied values
|
|
15
|
+
# 4. Updating head table's modified timestamp
|
|
16
|
+
# 5. Writing the complete static font binary
|
|
17
|
+
#
|
|
18
|
+
# The result is a valid static font at the specified instance point.
|
|
19
|
+
#
|
|
20
|
+
# @example Build static font
|
|
21
|
+
# builder = StaticFontBuilder.new(font)
|
|
22
|
+
# static_binary = builder.build(varied_metrics, font_metrics)
|
|
23
|
+
class StaticFontBuilder
|
|
24
|
+
# Tables to remove from static font (variation-specific)
|
|
25
|
+
VARIATION_TABLES = %w[fvar avar gvar cvar HVAR VVAR MVAR STAT].freeze
|
|
26
|
+
|
|
27
|
+
# @return [TableUpdater] Table updater instance
|
|
28
|
+
attr_reader :table_updater
|
|
29
|
+
|
|
30
|
+
# Initialize the builder
|
|
31
|
+
#
|
|
32
|
+
# @param font [TrueTypeFont, OpenTypeFont] Variable font object
|
|
33
|
+
def initialize(font)
|
|
34
|
+
@font = font
|
|
35
|
+
@table_updater = TableUpdater.new
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Build static font from varied data
|
|
39
|
+
#
|
|
40
|
+
# @param varied_metrics [Hash<Integer, Hash>] Varied metrics by glyph ID
|
|
41
|
+
# { glyph_id => { advance_width: 500, lsb: 50 } }
|
|
42
|
+
# @param font_metrics [Hash] Varied font-level metrics
|
|
43
|
+
# { ascent: 2048, descent: -512, line_gap: 0 }
|
|
44
|
+
# @param options [Hash] Build options
|
|
45
|
+
# @option options [Boolean] :update_modified Update head modified timestamp
|
|
46
|
+
# @return [String] Complete static font binary
|
|
47
|
+
def build(varied_metrics = {}, font_metrics = {}, options = {})
|
|
48
|
+
# Collect tables for static font
|
|
49
|
+
tables = collect_tables(varied_metrics, font_metrics, options)
|
|
50
|
+
|
|
51
|
+
# Detect sfnt version
|
|
52
|
+
sfnt_version = detect_sfnt_version(tables)
|
|
53
|
+
|
|
54
|
+
# Write font using FontWriter
|
|
55
|
+
FontWriter.write_font(tables, sfnt_version: sfnt_version)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Build static font and write to file
|
|
59
|
+
#
|
|
60
|
+
# @param output_path [String] Output file path
|
|
61
|
+
# @param varied_metrics [Hash<Integer, Hash>] Varied metrics by glyph ID
|
|
62
|
+
# @param font_metrics [Hash] Varied font-level metrics
|
|
63
|
+
# @param options [Hash] Build options
|
|
64
|
+
# @return [Integer] Number of bytes written
|
|
65
|
+
def build_to_file(output_path, varied_metrics = {}, font_metrics = {},
|
|
66
|
+
options = {})
|
|
67
|
+
binary = build(varied_metrics, font_metrics, options)
|
|
68
|
+
File.binwrite(output_path, binary)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
# Collect all tables for static font
|
|
74
|
+
#
|
|
75
|
+
# @param varied_metrics [Hash] Varied glyph metrics
|
|
76
|
+
# @param font_metrics [Hash] Varied font metrics
|
|
77
|
+
# @param options [Hash] Build options
|
|
78
|
+
# @return [Hash<String, String>] Map of table tag to binary data
|
|
79
|
+
def collect_tables(varied_metrics, font_metrics, options)
|
|
80
|
+
tables = {}
|
|
81
|
+
|
|
82
|
+
# Get all table tags from font
|
|
83
|
+
table_tags = @font.respond_to?(:tables) ? @font.tables.keys : []
|
|
84
|
+
|
|
85
|
+
table_tags.each do |tag|
|
|
86
|
+
# Skip variation tables
|
|
87
|
+
next if VARIATION_TABLES.include?(tag)
|
|
88
|
+
|
|
89
|
+
# Get original table data
|
|
90
|
+
original_data = @font.table_data(tag)
|
|
91
|
+
next if original_data.nil? || original_data.empty?
|
|
92
|
+
|
|
93
|
+
# Update specific tables with varied data
|
|
94
|
+
tables[tag] = case tag
|
|
95
|
+
when "hmtx"
|
|
96
|
+
update_hmtx_table(original_data, varied_metrics)
|
|
97
|
+
when "hhea"
|
|
98
|
+
update_hhea_table(original_data, font_metrics)
|
|
99
|
+
when "OS/2"
|
|
100
|
+
update_os2_table(original_data, font_metrics)
|
|
101
|
+
when "head"
|
|
102
|
+
update_head_table(original_data, options)
|
|
103
|
+
else
|
|
104
|
+
# Copy unchanged
|
|
105
|
+
original_data
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
tables
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Update hmtx table with varied metrics
|
|
113
|
+
#
|
|
114
|
+
# @param original_data [String] Original table data
|
|
115
|
+
# @param varied_metrics [Hash] Varied glyph metrics
|
|
116
|
+
# @return [String] Updated table data
|
|
117
|
+
def update_hmtx_table(original_data, varied_metrics)
|
|
118
|
+
return original_data if varied_metrics.empty?
|
|
119
|
+
|
|
120
|
+
# Get required context from other tables
|
|
121
|
+
hhea = load_table("hhea")
|
|
122
|
+
maxp = load_table("maxp")
|
|
123
|
+
|
|
124
|
+
return original_data unless hhea && maxp
|
|
125
|
+
|
|
126
|
+
num_h_metrics = hhea.number_of_h_metrics
|
|
127
|
+
num_glyphs = maxp.num_glyphs
|
|
128
|
+
|
|
129
|
+
@table_updater.update_hmtx(
|
|
130
|
+
original_data,
|
|
131
|
+
varied_metrics,
|
|
132
|
+
num_h_metrics,
|
|
133
|
+
num_glyphs,
|
|
134
|
+
)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Update hhea table with varied metrics
|
|
138
|
+
#
|
|
139
|
+
# @param original_data [String] Original table data
|
|
140
|
+
# @param font_metrics [Hash] Varied font metrics
|
|
141
|
+
# @return [String] Updated table data
|
|
142
|
+
def update_hhea_table(original_data, font_metrics)
|
|
143
|
+
return original_data if font_metrics.empty?
|
|
144
|
+
|
|
145
|
+
# Extract hhea-specific metrics
|
|
146
|
+
hhea_metrics = {}
|
|
147
|
+
hhea_metrics[:ascent] = font_metrics["hasc"] if font_metrics["hasc"]
|
|
148
|
+
hhea_metrics[:descent] = font_metrics["hdsc"] if font_metrics["hdsc"]
|
|
149
|
+
hhea_metrics[:line_gap] = font_metrics["hlgp"] if font_metrics["hlgp"]
|
|
150
|
+
|
|
151
|
+
return original_data if hhea_metrics.empty?
|
|
152
|
+
|
|
153
|
+
@table_updater.update_hhea(original_data, hhea_metrics)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Update OS/2 table with varied metrics
|
|
157
|
+
#
|
|
158
|
+
# @param original_data [String] Original table data
|
|
159
|
+
# @param font_metrics [Hash] Varied font metrics
|
|
160
|
+
# @return [String] Updated table data
|
|
161
|
+
def update_os2_table(original_data, font_metrics)
|
|
162
|
+
return original_data if font_metrics.empty?
|
|
163
|
+
|
|
164
|
+
@table_updater.update_os2(original_data, font_metrics)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Update head table
|
|
168
|
+
#
|
|
169
|
+
# @param original_data [String] Original table data
|
|
170
|
+
# @param options [Hash] Build options
|
|
171
|
+
# @return [String] Updated table data
|
|
172
|
+
def update_head_table(original_data, options)
|
|
173
|
+
if options[:update_modified] == false
|
|
174
|
+
original_data
|
|
175
|
+
else
|
|
176
|
+
@table_updater.update_head_modified(original_data)
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Load a table from the font
|
|
181
|
+
#
|
|
182
|
+
# @param tag [String] Table tag
|
|
183
|
+
# @return [Object, nil] Parsed table or nil
|
|
184
|
+
def load_table(tag)
|
|
185
|
+
data = @font.table_data(tag)
|
|
186
|
+
return nil if data.nil? || data.empty?
|
|
187
|
+
|
|
188
|
+
table_class = case tag
|
|
189
|
+
when "hhea" then Tables::Hhea
|
|
190
|
+
when "maxp" then Tables::Maxp
|
|
191
|
+
when "head" then Tables::Head
|
|
192
|
+
else return nil
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
table_class.read(data)
|
|
196
|
+
rescue StandardError
|
|
197
|
+
nil
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Detect sfnt version from tables
|
|
201
|
+
#
|
|
202
|
+
# @param tables [Hash] Map of table tag to data
|
|
203
|
+
# @return [Integer] sfnt version
|
|
204
|
+
def detect_sfnt_version(tables)
|
|
205
|
+
if tables.key?("CFF ") || tables.key?("CFF2")
|
|
206
|
+
0x4F54544F # 'OTTO' for OpenType/CFF
|
|
207
|
+
else
|
|
208
|
+
0x00010000 # 1.0 for TrueType
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
end
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "stringio"
|
|
4
|
+
|
|
5
|
+
module Fontisan
|
|
6
|
+
module Variable
|
|
7
|
+
# Updates font tables with applied variation deltas
|
|
8
|
+
#
|
|
9
|
+
# This class is responsible for taking original table data and applying
|
|
10
|
+
# calculated deltas to create updated tables for static font instances.
|
|
11
|
+
# It handles:
|
|
12
|
+
# - Updating hmtx with varied advance widths and sidebearings
|
|
13
|
+
# - Updating hhea with varied ascent/descent/line gap
|
|
14
|
+
# - Updating OS/2 with varied metrics
|
|
15
|
+
# - Updating head table's modified timestamp
|
|
16
|
+
#
|
|
17
|
+
# Each update method takes the original table and delta values,
|
|
18
|
+
# then reconstructs the table binary with updated values.
|
|
19
|
+
#
|
|
20
|
+
# @example Update hmtx table
|
|
21
|
+
# updater = TableUpdater.new
|
|
22
|
+
# new_hmtx = updater.update_hmtx(
|
|
23
|
+
# original_hmtx_data,
|
|
24
|
+
# varied_metrics,
|
|
25
|
+
# num_h_metrics,
|
|
26
|
+
# num_glyphs
|
|
27
|
+
# )
|
|
28
|
+
class TableUpdater
|
|
29
|
+
# Update hmtx table with varied metrics
|
|
30
|
+
#
|
|
31
|
+
# @param original_data [String] Original hmtx table binary
|
|
32
|
+
# @param varied_metrics [Hash<Integer, Hash>] Varied metrics by glyph ID
|
|
33
|
+
# { glyph_id => { advance_width: 500, lsb: 50 } }
|
|
34
|
+
# @param num_h_metrics [Integer] Number of hMetrics from hhea
|
|
35
|
+
# @param num_glyphs [Integer] Total glyphs from maxp
|
|
36
|
+
# @return [String] Updated hmtx table binary
|
|
37
|
+
def update_hmtx(original_data, varied_metrics, num_h_metrics, num_glyphs)
|
|
38
|
+
io = StringIO.new(original_data)
|
|
39
|
+
io.set_encoding(Encoding::BINARY)
|
|
40
|
+
|
|
41
|
+
# Parse original hMetrics
|
|
42
|
+
h_metrics = []
|
|
43
|
+
num_h_metrics.times do
|
|
44
|
+
advance_width = io.read(2)&.unpack1("n") || 0
|
|
45
|
+
lsb = io.read(2)&.unpack1("n") || 0
|
|
46
|
+
lsb = lsb >= 0x8000 ? lsb - 0x10000 : lsb # Convert to signed
|
|
47
|
+
h_metrics << { advance_width: advance_width, lsb: lsb }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Parse additional LSBs
|
|
51
|
+
lsb_count = num_glyphs - num_h_metrics
|
|
52
|
+
left_side_bearings = []
|
|
53
|
+
lsb_count.times do
|
|
54
|
+
lsb = io.read(2)&.unpack1("n") || 0
|
|
55
|
+
lsb = lsb >= 0x8000 ? lsb - 0x10000 : lsb
|
|
56
|
+
left_side_bearings << lsb
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Apply varied metrics
|
|
60
|
+
varied_metrics.each do |glyph_id, metrics|
|
|
61
|
+
if glyph_id < num_h_metrics
|
|
62
|
+
if metrics[:advance_width]
|
|
63
|
+
h_metrics[glyph_id][:advance_width] =
|
|
64
|
+
metrics[:advance_width]
|
|
65
|
+
end
|
|
66
|
+
h_metrics[glyph_id][:lsb] = metrics[:lsb] if metrics[:lsb]
|
|
67
|
+
else
|
|
68
|
+
lsb_index = glyph_id - num_h_metrics
|
|
69
|
+
left_side_bearings[lsb_index] = metrics[:lsb] if metrics[:lsb]
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Build updated hmtx binary
|
|
74
|
+
output = String.new(encoding: Encoding::BINARY)
|
|
75
|
+
|
|
76
|
+
# Write hMetrics
|
|
77
|
+
h_metrics.each do |metric|
|
|
78
|
+
output << [metric[:advance_width]].pack("n")
|
|
79
|
+
# Convert signed LSB to unsigned for packing
|
|
80
|
+
lsb_unsigned = metric[:lsb].negative? ? metric[:lsb] + 0x10000 : metric[:lsb]
|
|
81
|
+
output << [lsb_unsigned].pack("n")
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Write additional LSBs
|
|
85
|
+
left_side_bearings.each do |lsb|
|
|
86
|
+
lsb_unsigned = lsb.negative? ? lsb + 0x10000 : lsb
|
|
87
|
+
output << [lsb_unsigned].pack("n")
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
output
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Update hhea table with varied metrics
|
|
94
|
+
#
|
|
95
|
+
# @param original_data [String] Original hhea table binary
|
|
96
|
+
# @param varied_metrics [Hash] Varied font metrics
|
|
97
|
+
# { ascent: 2048, descent: -512, line_gap: 0 }
|
|
98
|
+
# @return [String] Updated hhea table binary
|
|
99
|
+
def update_hhea(original_data, varied_metrics)
|
|
100
|
+
io = StringIO.new(original_data)
|
|
101
|
+
io.set_encoding(Encoding::BINARY)
|
|
102
|
+
|
|
103
|
+
# Read all fields
|
|
104
|
+
version = io.read(4)
|
|
105
|
+
ascent = io.read(2)&.unpack1("n") || 0
|
|
106
|
+
ascent = ascent >= 0x8000 ? ascent - 0x10000 : ascent
|
|
107
|
+
descent = io.read(2)&.unpack1("n") || 0
|
|
108
|
+
descent = descent >= 0x8000 ? descent - 0x10000 : descent
|
|
109
|
+
line_gap = io.read(2)&.unpack1("n") || 0
|
|
110
|
+
line_gap = line_gap >= 0x8000 ? line_gap - 0x10000 : line_gap
|
|
111
|
+
|
|
112
|
+
# Read remaining fields
|
|
113
|
+
rest = io.read
|
|
114
|
+
|
|
115
|
+
# Apply varied metrics
|
|
116
|
+
ascent = varied_metrics[:ascent] if varied_metrics[:ascent]
|
|
117
|
+
descent = varied_metrics[:descent] if varied_metrics[:descent]
|
|
118
|
+
line_gap = varied_metrics[:line_gap] if varied_metrics[:line_gap]
|
|
119
|
+
|
|
120
|
+
# Build updated hhea binary
|
|
121
|
+
output = String.new(encoding: Encoding::BINARY)
|
|
122
|
+
output << version
|
|
123
|
+
|
|
124
|
+
# Convert signed values to unsigned for packing
|
|
125
|
+
ascent_unsigned = ascent.negative? ? ascent + 0x10000 : ascent
|
|
126
|
+
descent_unsigned = descent.negative? ? descent + 0x10000 : descent
|
|
127
|
+
line_gap_unsigned = line_gap.negative? ? line_gap + 0x10000 : line_gap
|
|
128
|
+
|
|
129
|
+
output << [ascent_unsigned].pack("n")
|
|
130
|
+
output << [descent_unsigned].pack("n")
|
|
131
|
+
output << [line_gap_unsigned].pack("n")
|
|
132
|
+
output << rest
|
|
133
|
+
|
|
134
|
+
output
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Update OS/2 table with varied metrics
|
|
138
|
+
#
|
|
139
|
+
# @param original_data [String] Original OS/2 table binary
|
|
140
|
+
# @param varied_metrics [Hash] Varied font metrics from MVAR
|
|
141
|
+
# @return [String] Updated OS/2 table binary
|
|
142
|
+
def update_os2(original_data, varied_metrics)
|
|
143
|
+
return original_data if varied_metrics.empty?
|
|
144
|
+
|
|
145
|
+
io = StringIO.new(original_data)
|
|
146
|
+
io.set_encoding(Encoding::BINARY)
|
|
147
|
+
|
|
148
|
+
# Read version to determine table size
|
|
149
|
+
io.read(2)&.unpack1("n") || 0
|
|
150
|
+
io.rewind
|
|
151
|
+
|
|
152
|
+
# For simplicity, return original if no specific OS/2 metrics to update
|
|
153
|
+
# This would need to be expanded based on MVAR tags present
|
|
154
|
+
original_data
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Update head table's modified timestamp
|
|
158
|
+
#
|
|
159
|
+
# @param original_data [String] Original head table binary
|
|
160
|
+
# @param timestamp [Time] New modification time
|
|
161
|
+
# @return [String] Updated head table binary
|
|
162
|
+
def update_head_modified(original_data, timestamp = Time.now)
|
|
163
|
+
io = StringIO.new(original_data)
|
|
164
|
+
io.set_encoding(Encoding::BINARY)
|
|
165
|
+
|
|
166
|
+
# Read up to modified timestamp
|
|
167
|
+
header = io.read(28) # version through created timestamp
|
|
168
|
+
_old_modified = io.read(8) # Skip old modified timestamp
|
|
169
|
+
rest = io.read # Remaining data
|
|
170
|
+
|
|
171
|
+
# Convert Time to LONGDATETIME (seconds since 1904-01-01)
|
|
172
|
+
# Difference between 1904 and 1970 (Unix epoch) is 2082844800 seconds
|
|
173
|
+
longdatetime = timestamp.to_i + 2_082_844_800
|
|
174
|
+
|
|
175
|
+
# Build updated head binary
|
|
176
|
+
output = String.new(encoding: Encoding::BINARY)
|
|
177
|
+
output << header
|
|
178
|
+
output << [longdatetime].pack("q>") # 64-bit big-endian signed integer
|
|
179
|
+
output << rest
|
|
180
|
+
|
|
181
|
+
output
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Build updated table with varied values
|
|
185
|
+
#
|
|
186
|
+
# This is a generic helper for building updated table binaries
|
|
187
|
+
#
|
|
188
|
+
# @param original_data [String] Original table binary
|
|
189
|
+
# @param updates [Hash] Hash of offset => new_value pairs
|
|
190
|
+
# @return [String] Updated table binary
|
|
191
|
+
def apply_updates(original_data, updates)
|
|
192
|
+
data = original_data.dup
|
|
193
|
+
|
|
194
|
+
updates.each do |offset, value|
|
|
195
|
+
# Handle different value types
|
|
196
|
+
packed_value = case value
|
|
197
|
+
when Integer
|
|
198
|
+
if value >= -32768 && value <= 32767
|
|
199
|
+
# int16
|
|
200
|
+
unsigned = value.negative? ? value + 0x10000 : value
|
|
201
|
+
[unsigned].pack("n")
|
|
202
|
+
else
|
|
203
|
+
# int32
|
|
204
|
+
[value].pack("N")
|
|
205
|
+
end
|
|
206
|
+
when String
|
|
207
|
+
value
|
|
208
|
+
else
|
|
209
|
+
value.to_s
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
data[offset, packed_value.bytesize] = packed_value
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
data
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
end
|