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,232 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fontisan
|
|
4
|
+
module Tables
|
|
5
|
+
class Cff2
|
|
6
|
+
# Operand stack manager for CFF2 CharStrings
|
|
7
|
+
#
|
|
8
|
+
# This class manages the operand stack for CFF2 CharStrings, with special
|
|
9
|
+
# handling for blend operations that mix base values and deltas.
|
|
10
|
+
#
|
|
11
|
+
# In CFF2, the blend operator takes operands in the format:
|
|
12
|
+
# [base1, delta1_axis1, delta1_axis2, ..., base2, delta2_axis1, ..., K, N]
|
|
13
|
+
#
|
|
14
|
+
# Where:
|
|
15
|
+
# - K = number of values to blend
|
|
16
|
+
# - N = number of variation axes
|
|
17
|
+
#
|
|
18
|
+
# The stack manager separates base values from deltas and applies blend
|
|
19
|
+
# operations to produce final values based on variation coordinates.
|
|
20
|
+
#
|
|
21
|
+
# @example Managing a blend operation
|
|
22
|
+
# stack = OperandStack.new(num_axes: 2)
|
|
23
|
+
# stack.push(100, 10, 5) # base=100, deltas=[10, 5]
|
|
24
|
+
# stack.push(200, 20, 10) # base=200, deltas=[20, 10]
|
|
25
|
+
# blended = stack.apply_blend(k: 2, coordinates: { "wght" => 0.5, "wdth" => 0.3 })
|
|
26
|
+
# # => [105.0, 206.0] # base + (delta * scalar)
|
|
27
|
+
class OperandStack
|
|
28
|
+
# @return [Array<Numeric>] The operand stack
|
|
29
|
+
attr_reader :stack
|
|
30
|
+
|
|
31
|
+
# @return [Integer] Number of variation axes
|
|
32
|
+
attr_reader :num_axes
|
|
33
|
+
|
|
34
|
+
# @return [Array<Hash>] Blend values (base + deltas)
|
|
35
|
+
attr_reader :blend_values
|
|
36
|
+
|
|
37
|
+
# Initialize operand stack
|
|
38
|
+
#
|
|
39
|
+
# @param num_axes [Integer] Number of variation axes (default 0)
|
|
40
|
+
def initialize(num_axes: 0)
|
|
41
|
+
@stack = []
|
|
42
|
+
@num_axes = num_axes
|
|
43
|
+
@blend_values = []
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Push a value onto the stack
|
|
47
|
+
#
|
|
48
|
+
# @param values [Numeric] Values to push
|
|
49
|
+
def push(*values)
|
|
50
|
+
@stack.concat(values)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Pop a value from the stack
|
|
54
|
+
#
|
|
55
|
+
# @return [Numeric, nil] Popped value or nil if empty
|
|
56
|
+
def pop
|
|
57
|
+
@stack.pop
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Pop multiple values from the stack
|
|
61
|
+
#
|
|
62
|
+
# @param count [Integer] Number of values to pop
|
|
63
|
+
# @return [Array<Numeric>] Popped values
|
|
64
|
+
def pop_many(count)
|
|
65
|
+
return [] if count <= 0 || @stack.empty?
|
|
66
|
+
|
|
67
|
+
@stack.pop(count)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Shift a value from the front of the stack
|
|
71
|
+
#
|
|
72
|
+
# @return [Numeric, nil] Shifted value or nil if empty
|
|
73
|
+
def shift
|
|
74
|
+
@stack.shift
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Get the top value without popping
|
|
78
|
+
#
|
|
79
|
+
# @return [Numeric, nil] Top value or nil if empty
|
|
80
|
+
def peek
|
|
81
|
+
@stack.last
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Get stack size
|
|
85
|
+
#
|
|
86
|
+
# @return [Integer] Number of values on stack
|
|
87
|
+
def size
|
|
88
|
+
@stack.size
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Check if stack is empty
|
|
92
|
+
#
|
|
93
|
+
# @return [Boolean] True if empty
|
|
94
|
+
def empty?
|
|
95
|
+
@stack.empty?
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Clear the stack
|
|
99
|
+
def clear
|
|
100
|
+
@stack.clear
|
|
101
|
+
@blend_values.clear
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Apply blend operation
|
|
105
|
+
#
|
|
106
|
+
# This pops K * (N + 1) + 2 operands from the stack, where:
|
|
107
|
+
# - K = number of values to blend
|
|
108
|
+
# - N = number of axes
|
|
109
|
+
# - Last 2 values are K and N themselves
|
|
110
|
+
#
|
|
111
|
+
# @param scalars [Array<Float>] Variation scalars for each axis
|
|
112
|
+
# @return [Array<Float>] Blended values
|
|
113
|
+
def apply_blend(scalars = [])
|
|
114
|
+
# Pop N and K
|
|
115
|
+
n = pop.to_i
|
|
116
|
+
k = pop.to_i
|
|
117
|
+
|
|
118
|
+
# Validate
|
|
119
|
+
required_operands = k * (n + 1)
|
|
120
|
+
if size < required_operands
|
|
121
|
+
warn "Blend requires #{required_operands} operands, got #{size}"
|
|
122
|
+
clear
|
|
123
|
+
return []
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Extract operands (base + deltas for each value)
|
|
127
|
+
blend_operands = pop_many(required_operands).reverse
|
|
128
|
+
|
|
129
|
+
# Process each value to blend
|
|
130
|
+
blended_values = []
|
|
131
|
+
k.times do |i|
|
|
132
|
+
offset = i * (n + 1)
|
|
133
|
+
base = blend_operands[offset]
|
|
134
|
+
deltas = blend_operands[offset + 1, n] || []
|
|
135
|
+
|
|
136
|
+
# Apply blend: result = base + sum(delta[i] * scalar[i])
|
|
137
|
+
blended = base.to_f
|
|
138
|
+
deltas.each_with_index do |delta, axis_index|
|
|
139
|
+
scalar = scalars[axis_index] || 0.0
|
|
140
|
+
blended += delta.to_f * scalar
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Store blend info for debugging/inspection
|
|
144
|
+
@blend_values << {
|
|
145
|
+
base: base,
|
|
146
|
+
deltas: deltas,
|
|
147
|
+
blended: blended,
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
blended_values << blended
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Push blended values back onto stack
|
|
154
|
+
push(*blended_values)
|
|
155
|
+
|
|
156
|
+
blended_values
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Extract blend data without applying
|
|
160
|
+
#
|
|
161
|
+
# This is used when we need to store blend operations for later
|
|
162
|
+
# application with specific coordinates.
|
|
163
|
+
#
|
|
164
|
+
# @return [Hash] Blend operation data
|
|
165
|
+
def extract_blend_data
|
|
166
|
+
# Pop N and K
|
|
167
|
+
n = pop.to_i
|
|
168
|
+
k = pop.to_i
|
|
169
|
+
|
|
170
|
+
# Validate
|
|
171
|
+
required_operands = k * (n + 1)
|
|
172
|
+
if size < required_operands
|
|
173
|
+
warn "Blend requires #{required_operands} operands, got #{size}"
|
|
174
|
+
clear
|
|
175
|
+
return nil
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Extract operands
|
|
179
|
+
blend_operands = pop_many(required_operands).reverse
|
|
180
|
+
|
|
181
|
+
# Parse into base + deltas structure
|
|
182
|
+
blends = []
|
|
183
|
+
k.times do |i|
|
|
184
|
+
offset = i * (n + 1)
|
|
185
|
+
base = blend_operands[offset]
|
|
186
|
+
deltas = blend_operands[offset + 1, n] || []
|
|
187
|
+
|
|
188
|
+
blends << {
|
|
189
|
+
base: base,
|
|
190
|
+
deltas: deltas,
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
# Push base value back (will be blended later)
|
|
194
|
+
push(base)
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
{
|
|
198
|
+
num_values: k,
|
|
199
|
+
num_axes: n,
|
|
200
|
+
blends: blends,
|
|
201
|
+
}
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# Get all values on the stack
|
|
205
|
+
#
|
|
206
|
+
# @return [Array<Numeric>] Stack contents
|
|
207
|
+
def to_a
|
|
208
|
+
@stack.dup
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# Get string representation for debugging
|
|
212
|
+
#
|
|
213
|
+
# @return [String] Stack contents as string
|
|
214
|
+
def inspect
|
|
215
|
+
"#<OperandStack size=#{size} values=#{@stack.inspect}>"
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# Get blend value history
|
|
219
|
+
#
|
|
220
|
+
# @return [Array<Hash>] Blend values that have been calculated
|
|
221
|
+
def blend_history
|
|
222
|
+
@blend_values.dup
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# Reset blend history
|
|
226
|
+
def reset_blend_history
|
|
227
|
+
@blend_values.clear
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
end
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fontisan
|
|
4
|
+
module Tables
|
|
5
|
+
class Cff2
|
|
6
|
+
# Private DICT blend handler for CFF2
|
|
7
|
+
#
|
|
8
|
+
# Handles blend operators in Private DICT which allow hint parameters
|
|
9
|
+
# to vary across the design space in variable fonts.
|
|
10
|
+
#
|
|
11
|
+
# Blend in Private DICT format:
|
|
12
|
+
# base_value delta1 delta2 ... deltaN num_axes blend
|
|
13
|
+
#
|
|
14
|
+
# Example for BlueValues with 2 axes:
|
|
15
|
+
# -10 2 1 0 1 0 500 10 5 510 12 6 2 blend
|
|
16
|
+
# This creates BlueValues that vary across the design space.
|
|
17
|
+
#
|
|
18
|
+
# Reference: Adobe Technical Note #5177 (CFF2)
|
|
19
|
+
#
|
|
20
|
+
# @example Parsing blend in Private DICT
|
|
21
|
+
# handler = PrivateDictBlendHandler.new(private_dict)
|
|
22
|
+
# blue_values = handler.parse_blend_array(:blue_values, num_axes: 2)
|
|
23
|
+
class PrivateDictBlendHandler
|
|
24
|
+
# @return [Hash] Private DICT data
|
|
25
|
+
attr_reader :private_dict
|
|
26
|
+
|
|
27
|
+
# Initialize handler with Private DICT data
|
|
28
|
+
#
|
|
29
|
+
# @param private_dict [Hash] Parsed Private DICT
|
|
30
|
+
def initialize(private_dict)
|
|
31
|
+
@private_dict = private_dict
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Check if Private DICT contains blend data
|
|
35
|
+
#
|
|
36
|
+
# @return [Boolean] True if blend operators are present
|
|
37
|
+
def has_blend?
|
|
38
|
+
# In a DICT with blend, values are arrays with blend data
|
|
39
|
+
@private_dict.values.any? { |v| blend_value?(v) }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Parse blended array (like BlueValues)
|
|
43
|
+
#
|
|
44
|
+
# @param key [Symbol, Integer] DICT operator key
|
|
45
|
+
# @param num_axes [Integer] Number of variation axes
|
|
46
|
+
# @return [Hash, nil] Parsed blend data or nil if not present
|
|
47
|
+
def parse_blend_array(key, num_axes:)
|
|
48
|
+
value = @private_dict[key]
|
|
49
|
+
return nil unless value.is_a?(Array)
|
|
50
|
+
|
|
51
|
+
# Check if this is blend data
|
|
52
|
+
# Format: base1 delta1_1 ... delta1_N base2 delta2_1 ... delta2_N ...
|
|
53
|
+
# The array must be divisible by (num_axes + 1)
|
|
54
|
+
return nil unless value.size % (num_axes + 1) == 0
|
|
55
|
+
|
|
56
|
+
num_values = value.size / (num_axes + 1)
|
|
57
|
+
blends = []
|
|
58
|
+
|
|
59
|
+
num_values.times do |i|
|
|
60
|
+
offset = i * (num_axes + 1)
|
|
61
|
+
base = value[offset]
|
|
62
|
+
deltas = value[offset + 1, num_axes] || []
|
|
63
|
+
|
|
64
|
+
blends << {
|
|
65
|
+
base: base,
|
|
66
|
+
deltas: deltas
|
|
67
|
+
}
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
{
|
|
71
|
+
num_values: num_values,
|
|
72
|
+
num_axes: num_axes,
|
|
73
|
+
blends: blends
|
|
74
|
+
}
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Parse single blended value
|
|
78
|
+
#
|
|
79
|
+
# @param key [Symbol, Integer] DICT operator key
|
|
80
|
+
# @param num_axes [Integer] Number of variation axes
|
|
81
|
+
# @return [Hash, nil] Parsed blend data or nil if not present
|
|
82
|
+
def parse_blend_value(key, num_axes:)
|
|
83
|
+
value = @private_dict[key]
|
|
84
|
+
return nil unless value.is_a?(Array)
|
|
85
|
+
|
|
86
|
+
# Single value format: base delta1 delta2 ... deltaN
|
|
87
|
+
expected_size = num_axes + 1
|
|
88
|
+
return nil unless value.size == expected_size
|
|
89
|
+
|
|
90
|
+
{
|
|
91
|
+
base: value[0],
|
|
92
|
+
deltas: value[1..num_axes],
|
|
93
|
+
num_axes: num_axes
|
|
94
|
+
}
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Apply blend at specific coordinates
|
|
98
|
+
#
|
|
99
|
+
# @param blend_data [Hash] Parsed blend data
|
|
100
|
+
# @param scalars [Array<Float>] Region scalars for each axis
|
|
101
|
+
# @return [Array<Float>, Float] Blended values
|
|
102
|
+
def apply_blend(blend_data, scalars)
|
|
103
|
+
return nil unless blend_data
|
|
104
|
+
|
|
105
|
+
if blend_data.key?(:blends)
|
|
106
|
+
# Array of blended values
|
|
107
|
+
blend_data[:blends].map do |blend|
|
|
108
|
+
apply_single_blend(blend, scalars)
|
|
109
|
+
end
|
|
110
|
+
else
|
|
111
|
+
# Single blended value
|
|
112
|
+
apply_single_blend(blend_data, scalars)
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Apply blend to a single value
|
|
117
|
+
#
|
|
118
|
+
# @param blend [Hash] Single blend with :base and :deltas
|
|
119
|
+
# @param scalars [Array<Float>] Region scalars
|
|
120
|
+
# @return [Float] Blended value
|
|
121
|
+
def apply_single_blend(blend, scalars)
|
|
122
|
+
base = blend[:base].to_f
|
|
123
|
+
deltas = blend[:deltas]
|
|
124
|
+
|
|
125
|
+
# Apply formula: result = base + Σ(delta[i] * scalar[i])
|
|
126
|
+
result = base
|
|
127
|
+
deltas.each_with_index do |delta, i|
|
|
128
|
+
scalar = scalars[i] || 0.0
|
|
129
|
+
result += delta.to_f * scalar
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
result
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Get blended Private DICT values at coordinates
|
|
136
|
+
#
|
|
137
|
+
# @param num_axes [Integer] Number of variation axes
|
|
138
|
+
# @param scalars [Array<Float>] Region scalars
|
|
139
|
+
# @return [Hash] Private DICT with blended values
|
|
140
|
+
def blended_dict(num_axes:, scalars:)
|
|
141
|
+
result = {}
|
|
142
|
+
|
|
143
|
+
@private_dict.each do |key, value|
|
|
144
|
+
if value.is_a?(Array) && blend_value?(value)
|
|
145
|
+
# Try parsing as blend array
|
|
146
|
+
blend_data = parse_blend_array(key, num_axes: num_axes)
|
|
147
|
+
if blend_data
|
|
148
|
+
result[key] = apply_blend(blend_data, scalars)
|
|
149
|
+
else
|
|
150
|
+
# Try as single blend value
|
|
151
|
+
blend_data = parse_blend_value(key, num_axes: num_axes)
|
|
152
|
+
result[key] = blend_data ? apply_blend(blend_data, scalars) : value
|
|
153
|
+
end
|
|
154
|
+
else
|
|
155
|
+
# Non-blend value, copy as-is
|
|
156
|
+
result[key] = value
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
result
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Check if value looks like blend data
|
|
164
|
+
#
|
|
165
|
+
# @param value [Object] Value to check
|
|
166
|
+
# @return [Boolean] True if value could be blend data
|
|
167
|
+
def blend_value?(value)
|
|
168
|
+
# Blend values are arrays with multiple elements
|
|
169
|
+
value.is_a?(Array) && value.size > 1
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Rebuild Private DICT with hints injected
|
|
173
|
+
#
|
|
174
|
+
# This method prepares Private DICT for rebuilding, preserving
|
|
175
|
+
# blend operators while incorporating new hint values.
|
|
176
|
+
#
|
|
177
|
+
# @param hints [Hash] Hint values to inject
|
|
178
|
+
# @param num_axes [Integer] Number of variation axes
|
|
179
|
+
# @return [Hash] Modified Private DICT
|
|
180
|
+
def rebuild_with_hints(hints, num_axes:)
|
|
181
|
+
result = @private_dict.dup
|
|
182
|
+
|
|
183
|
+
# Inject hint values
|
|
184
|
+
hints.each do |key, value|
|
|
185
|
+
if value.is_a?(Hash) && (value.key?(:base) || value.key?("base")) && (value.key?(:deltas) || value.key?("deltas"))
|
|
186
|
+
# Hint with blend data - normalize and flatten for DICT storage
|
|
187
|
+
normalized_value = {
|
|
188
|
+
base: value[:base] || value["base"],
|
|
189
|
+
deltas: value[:deltas] || value["deltas"]
|
|
190
|
+
}
|
|
191
|
+
result[key] = flatten_blend(normalized_value, num_axes: num_axes)
|
|
192
|
+
else
|
|
193
|
+
# Simple hint value
|
|
194
|
+
result[key] = value
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
result
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Flatten blend data to array format
|
|
202
|
+
#
|
|
203
|
+
# @param blend_data [Hash] Blend data with :base and :deltas
|
|
204
|
+
# @param num_axes [Integer] Number of variation axes
|
|
205
|
+
# @return [Array] Flattened array
|
|
206
|
+
def flatten_blend(blend_data, num_axes:)
|
|
207
|
+
if blend_data.key?(:blends)
|
|
208
|
+
# Array of blends
|
|
209
|
+
blend_data[:blends].flat_map do |blend|
|
|
210
|
+
[blend[:base]] + blend[:deltas]
|
|
211
|
+
end
|
|
212
|
+
else
|
|
213
|
+
# Single blend
|
|
214
|
+
[blend_data[:base]] + blend_data[:deltas]
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# Validate blend data structure
|
|
219
|
+
#
|
|
220
|
+
# @param num_axes [Integer] Expected number of axes
|
|
221
|
+
# @return [Array<String>] Validation errors (empty if valid)
|
|
222
|
+
def validate(num_axes:)
|
|
223
|
+
errors = []
|
|
224
|
+
|
|
225
|
+
@private_dict.each do |key, value|
|
|
226
|
+
next unless value.is_a?(Array)
|
|
227
|
+
next unless blend_value?(value)
|
|
228
|
+
|
|
229
|
+
# Try parsing as blend array
|
|
230
|
+
blend_data = parse_blend_array(key, num_axes: num_axes)
|
|
231
|
+
unless blend_data
|
|
232
|
+
# Try as single blend value
|
|
233
|
+
blend_data = parse_blend_value(key, num_axes: num_axes)
|
|
234
|
+
unless blend_data
|
|
235
|
+
errors << "Key #{key} has array value that doesn't match " \
|
|
236
|
+
"blend format for #{num_axes} axes"
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
errors
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
end
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fontisan
|
|
4
|
+
module Tables
|
|
5
|
+
class Cff2
|
|
6
|
+
# Region matcher for calculating variation scalars
|
|
7
|
+
#
|
|
8
|
+
# Maps design space coordinates to region scalars based on
|
|
9
|
+
# the Variable Store region definitions. Each region defines
|
|
10
|
+
# a range (start, peak, end) for each variation axis.
|
|
11
|
+
#
|
|
12
|
+
# Scalar Calculation:
|
|
13
|
+
# - If coordinate is at peak: scalar = 1.0
|
|
14
|
+
# - If coordinate is between start and peak: linear interpolation
|
|
15
|
+
# - If coordinate is between peak and end: linear interpolation
|
|
16
|
+
# - If coordinate is outside [start, end]: scalar = 0.0
|
|
17
|
+
#
|
|
18
|
+
# Reference: OpenType Font Variations Overview
|
|
19
|
+
# Reference: Adobe Technical Note #5177 (CFF2)
|
|
20
|
+
#
|
|
21
|
+
# @example Calculating scalars
|
|
22
|
+
# matcher = RegionMatcher.new(regions)
|
|
23
|
+
# scalars = matcher.calculate_scalars({ "wght" => 0.5, "wdth" => 0.3 })
|
|
24
|
+
class RegionMatcher
|
|
25
|
+
# @return [Array<Hash>] Regions from Variable Store
|
|
26
|
+
attr_reader :regions
|
|
27
|
+
|
|
28
|
+
# Initialize matcher with regions
|
|
29
|
+
#
|
|
30
|
+
# @param regions [Array<Hash>] Region definitions from Variable Store
|
|
31
|
+
def initialize(regions)
|
|
32
|
+
@regions = regions
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Calculate scalars for all regions at given coordinates
|
|
36
|
+
#
|
|
37
|
+
# Coordinates are normalized values in the range [-1.0, 1.0]
|
|
38
|
+
# where 0.0 represents the default/regular style.
|
|
39
|
+
#
|
|
40
|
+
# @param coordinates [Array<Float>] Normalized coordinates per axis
|
|
41
|
+
# @return [Array<Float>] Scalars for each region
|
|
42
|
+
def calculate_scalars(coordinates)
|
|
43
|
+
@regions.map do |region|
|
|
44
|
+
calculate_region_scalar(region, coordinates)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Calculate scalar for a single region
|
|
49
|
+
#
|
|
50
|
+
# The scalar is the product of scalars for all axes in the region.
|
|
51
|
+
# If any axis has scalar 0.0, the entire region scalar is 0.0.
|
|
52
|
+
#
|
|
53
|
+
# @param region [Hash] Region definition
|
|
54
|
+
# @param coordinates [Array<Float>] Normalized coordinates per axis
|
|
55
|
+
# @return [Float] Scalar for the region (0.0 to 1.0)
|
|
56
|
+
def calculate_region_scalar(region, coordinates)
|
|
57
|
+
axes = region[:axes]
|
|
58
|
+
|
|
59
|
+
# Multiply scalars for all axes
|
|
60
|
+
scalar = 1.0
|
|
61
|
+
axes.each_with_index do |axis, i|
|
|
62
|
+
coord = coordinates[i] || 0.0
|
|
63
|
+
axis_scalar = calculate_axis_scalar(axis, coord)
|
|
64
|
+
scalar *= axis_scalar
|
|
65
|
+
|
|
66
|
+
# Early exit if any axis is out of range
|
|
67
|
+
return 0.0 if axis_scalar.zero?
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
scalar
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Calculate scalar for a single axis
|
|
74
|
+
#
|
|
75
|
+
# @param axis [Hash] Axis definition with :start_coord, :peak_coord, :end_coord
|
|
76
|
+
# @param coordinate [Float] Normalized coordinate for this axis
|
|
77
|
+
# @return [Float] Scalar for this axis (0.0 to 1.0)
|
|
78
|
+
def calculate_axis_scalar(axis, coordinate)
|
|
79
|
+
start_coord = axis[:start_coord]
|
|
80
|
+
peak_coord = axis[:peak_coord]
|
|
81
|
+
end_coord = axis[:end_coord]
|
|
82
|
+
|
|
83
|
+
# Outside the region
|
|
84
|
+
return 0.0 if coordinate < start_coord || coordinate > end_coord
|
|
85
|
+
|
|
86
|
+
# At or beyond peak
|
|
87
|
+
return 1.0 if coordinate == peak_coord
|
|
88
|
+
|
|
89
|
+
# Between start and peak
|
|
90
|
+
if coordinate < peak_coord
|
|
91
|
+
# Linear interpolation: (coord - start) / (peak - start)
|
|
92
|
+
range = peak_coord - start_coord
|
|
93
|
+
return 1.0 if range.zero? # Avoid division by zero
|
|
94
|
+
|
|
95
|
+
(coordinate - start_coord) / range
|
|
96
|
+
else
|
|
97
|
+
# Between peak and end
|
|
98
|
+
# Linear interpolation: (end - coord) / (end - peak)
|
|
99
|
+
range = end_coord - peak_coord
|
|
100
|
+
return 1.0 if range.zero? # Avoid division by zero
|
|
101
|
+
|
|
102
|
+
(end_coord - coordinate) / range
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Check if coordinates are within any region
|
|
107
|
+
#
|
|
108
|
+
# @param coordinates [Array<Float>] Normalized coordinates
|
|
109
|
+
# @return [Boolean] True if coordinates activate any region
|
|
110
|
+
def coordinates_active?(coordinates)
|
|
111
|
+
scalars = calculate_scalars(coordinates)
|
|
112
|
+
scalars.any?(&:positive?)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Get active regions for coordinates
|
|
116
|
+
#
|
|
117
|
+
# Returns indices of regions that have non-zero scalars
|
|
118
|
+
#
|
|
119
|
+
# @param coordinates [Array<Float>] Normalized coordinates
|
|
120
|
+
# @return [Array<Integer>] Indices of active regions
|
|
121
|
+
def active_regions(coordinates)
|
|
122
|
+
scalars = calculate_scalars(coordinates)
|
|
123
|
+
scalars.each_with_index.select { |scalar, _| scalar.positive? }
|
|
124
|
+
.map(&:last)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Get scalar for specific region index
|
|
128
|
+
#
|
|
129
|
+
# @param region_index [Integer] Region index
|
|
130
|
+
# @param coordinates [Array<Float>] Normalized coordinates
|
|
131
|
+
# @return [Float, nil] Scalar for the region, or nil if index invalid
|
|
132
|
+
def scalar_for_region(region_index, coordinates)
|
|
133
|
+
return nil if region_index >= @regions.size
|
|
134
|
+
|
|
135
|
+
region = @regions[region_index]
|
|
136
|
+
calculate_region_scalar(region, coordinates)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Validate region structure
|
|
140
|
+
#
|
|
141
|
+
# @return [Array<String>] Array of validation errors (empty if valid)
|
|
142
|
+
def validate
|
|
143
|
+
errors = []
|
|
144
|
+
|
|
145
|
+
@regions.each_with_index do |region, i|
|
|
146
|
+
axes = region[:axes]
|
|
147
|
+
unless axes.is_a?(Array)
|
|
148
|
+
errors << "Region #{i} has invalid axes (not an array)"
|
|
149
|
+
next
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
axes.each_with_index do |axis, j|
|
|
153
|
+
unless axis.is_a?(Hash)
|
|
154
|
+
errors << "Region #{i}, axis #{j} is not a hash"
|
|
155
|
+
next
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Check required keys
|
|
159
|
+
%i[start_coord peak_coord end_coord].each do |key|
|
|
160
|
+
unless axis.key?(key)
|
|
161
|
+
errors << "Region #{i}, axis #{j} missing #{key}"
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Validate coordinate ordering
|
|
166
|
+
if axis[:start_coord] && axis[:peak_coord] && axis[:end_coord]
|
|
167
|
+
start = axis[:start_coord]
|
|
168
|
+
peak = axis[:peak_coord]
|
|
169
|
+
ending = axis[:end_coord]
|
|
170
|
+
|
|
171
|
+
unless start <= peak && peak <= ending
|
|
172
|
+
errors << "Region #{i}, axis #{j} has invalid ordering: " \
|
|
173
|
+
"#{start} > #{peak} > #{ending}"
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
errors
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Get number of axes from first region
|
|
183
|
+
#
|
|
184
|
+
# @return [Integer] Number of axes
|
|
185
|
+
def axis_count
|
|
186
|
+
return 0 if @regions.empty?
|
|
187
|
+
|
|
188
|
+
@regions.first[:axis_count] || @regions.first[:axes]&.size || 0
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# Check if matcher has regions
|
|
192
|
+
#
|
|
193
|
+
# @return [Boolean] True if regions are present
|
|
194
|
+
def has_regions?
|
|
195
|
+
!@regions.empty?
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
end
|