fontisan 0.2.0 → 0.2.2
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 +119 -308
- data/README.adoc +1525 -1323
- data/Rakefile +45 -47
- data/benchmark/variation_quick_bench.rb +4 -4
- data/docs/FONT_HINTING.adoc +562 -0
- data/docs/VARIABLE_FONT_OPERATIONS.adoc +599 -0
- data/lib/fontisan/cli.rb +92 -34
- data/lib/fontisan/collection/builder.rb +82 -0
- data/lib/fontisan/collection/offset_calculator.rb +2 -0
- data/lib/fontisan/collection/table_deduplicator.rb +76 -0
- data/lib/fontisan/commands/base_command.rb +21 -2
- data/lib/fontisan/commands/convert_command.rb +96 -165
- data/lib/fontisan/commands/info_command.rb +111 -5
- data/lib/fontisan/commands/instance_command.rb +77 -85
- data/lib/fontisan/commands/validate_command.rb +28 -0
- data/lib/fontisan/config/validation_rules.yml +1 -1
- data/lib/fontisan/constants.rb +34 -24
- data/lib/fontisan/converters/format_converter.rb +154 -1
- data/lib/fontisan/converters/outline_converter.rb +101 -34
- data/lib/fontisan/converters/woff_writer.rb +9 -4
- data/lib/fontisan/font_loader.rb +14 -9
- data/lib/fontisan/font_writer.rb +9 -6
- data/lib/fontisan/formatters/text_formatter.rb +45 -1
- data/lib/fontisan/hints/hint_converter.rb +131 -2
- data/lib/fontisan/hints/hint_validator.rb +284 -0
- data/lib/fontisan/hints/postscript_hint_applier.rb +219 -140
- data/lib/fontisan/hints/postscript_hint_extractor.rb +151 -16
- data/lib/fontisan/hints/truetype_hint_applier.rb +90 -44
- data/lib/fontisan/hints/truetype_hint_extractor.rb +134 -11
- data/lib/fontisan/hints/truetype_instruction_analyzer.rb +261 -0
- data/lib/fontisan/hints/truetype_instruction_generator.rb +266 -0
- data/lib/fontisan/loading_modes.rb +6 -4
- data/lib/fontisan/models/collection_brief_info.rb +31 -0
- data/lib/fontisan/models/font_info.rb +3 -30
- data/lib/fontisan/models/hint.rb +183 -12
- data/lib/fontisan/models/outline.rb +4 -1
- data/lib/fontisan/open_type_font.rb +28 -10
- data/lib/fontisan/open_type_font_extensions.rb +54 -0
- data/lib/fontisan/optimizers/pattern_analyzer.rb +2 -1
- data/lib/fontisan/optimizers/subroutine_generator.rb +1 -1
- data/lib/fontisan/pipeline/format_detector.rb +249 -0
- data/lib/fontisan/pipeline/output_writer.rb +159 -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 +416 -0
- data/lib/fontisan/pipeline/variation_resolver.rb +165 -0
- data/lib/fontisan/subset/table_subsetter.rb +5 -5
- data/lib/fontisan/tables/cff/charstring.rb +58 -3
- data/lib/fontisan/tables/cff/charstring_builder.rb +34 -0
- data/lib/fontisan/tables/cff/charstring_parser.rb +249 -0
- data/lib/fontisan/tables/cff/charstring_rebuilder.rb +172 -0
- data/lib/fontisan/tables/cff/dict_builder.rb +19 -1
- data/lib/fontisan/tables/cff/hint_operation_injector.rb +209 -0
- data/lib/fontisan/tables/cff/offset_recalculator.rb +70 -0
- data/lib/fontisan/tables/cff/private_dict_writer.rb +131 -0
- data/lib/fontisan/tables/cff/table_builder.rb +221 -0
- data/lib/fontisan/tables/cff.rb +2 -0
- data/lib/fontisan/tables/cff2/charstring_parser.rb +14 -8
- data/lib/fontisan/tables/cff2/private_dict_blend_handler.rb +247 -0
- data/lib/fontisan/tables/cff2/region_matcher.rb +200 -0
- data/lib/fontisan/tables/cff2/table_builder.rb +580 -0
- data/lib/fontisan/tables/cff2/table_reader.rb +421 -0
- data/lib/fontisan/tables/cff2/variation_data_extractor.rb +212 -0
- data/lib/fontisan/tables/cff2.rb +10 -5
- data/lib/fontisan/tables/cvar.rb +2 -41
- data/lib/fontisan/tables/glyf/compound_glyph_resolver.rb +2 -1
- data/lib/fontisan/tables/glyf/curve_converter.rb +10 -4
- data/lib/fontisan/tables/glyf/glyph_builder.rb +27 -10
- data/lib/fontisan/tables/gvar.rb +2 -41
- data/lib/fontisan/tables/name.rb +4 -4
- data/lib/fontisan/true_type_font.rb +27 -10
- data/lib/fontisan/true_type_font_extensions.rb +54 -0
- data/lib/fontisan/utilities/checksum_calculator.rb +42 -0
- data/lib/fontisan/validation/checksum_validator.rb +2 -2
- data/lib/fontisan/validation/table_validator.rb +1 -1
- data/lib/fontisan/validation/variable_font_validator.rb +218 -0
- data/lib/fontisan/variation/cache.rb +3 -1
- data/lib/fontisan/variation/converter.rb +121 -13
- data/lib/fontisan/variation/delta_applier.rb +2 -1
- data/lib/fontisan/variation/inspector.rb +2 -1
- data/lib/fontisan/variation/instance_generator.rb +2 -1
- data/lib/fontisan/variation/instance_writer.rb +341 -0
- data/lib/fontisan/variation/optimizer.rb +6 -3
- data/lib/fontisan/variation/subsetter.rb +32 -10
- data/lib/fontisan/variation/tuple_variation_header.rb +51 -0
- data/lib/fontisan/variation/variable_svg_generator.rb +268 -0
- data/lib/fontisan/variation/variation_preserver.rb +291 -0
- data/lib/fontisan/version.rb +1 -1
- data/lib/fontisan/version.rb.orig +9 -0
- data/lib/fontisan/woff2/glyf_transformer.rb +693 -0
- data/lib/fontisan/woff2/hmtx_transformer.rb +164 -0
- data/lib/fontisan/woff2_font.rb +489 -468
- data/lib/fontisan/woff_font.rb +16 -11
- data/lib/fontisan.rb +54 -2
- data/scripts/measure_optimization.rb +15 -7
- metadata +37 -2
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "private_dict_writer"
|
|
4
|
+
require_relative "offset_recalculator"
|
|
5
|
+
require_relative "index_builder"
|
|
6
|
+
require_relative "dict_builder"
|
|
7
|
+
require_relative "charstring_rebuilder"
|
|
8
|
+
require_relative "hint_operation_injector"
|
|
9
|
+
require "stringio"
|
|
10
|
+
|
|
11
|
+
module Fontisan
|
|
12
|
+
module Tables
|
|
13
|
+
class Cff
|
|
14
|
+
# Rebuilds CFF table with modifications
|
|
15
|
+
#
|
|
16
|
+
# This builder extracts sections from a source CFF table, applies
|
|
17
|
+
# modifications (e.g., hint parameters to Private DICT), recalculates
|
|
18
|
+
# offsets, and assembles a new CFF table.
|
|
19
|
+
#
|
|
20
|
+
# Process:
|
|
21
|
+
# 1. Extract all CFF sections (header, indexes, dicts)
|
|
22
|
+
# 2. Apply modifications to Private DICT
|
|
23
|
+
# 3. Recalculate offsets (charstrings, private)
|
|
24
|
+
# 4. Rebuild Top DICT INDEX with new offsets
|
|
25
|
+
# 5. Reassemble all sections into new CFF table
|
|
26
|
+
#
|
|
27
|
+
# @example Rebuild with hints
|
|
28
|
+
# new_cff = TableBuilder.rebuild(source_cff, {
|
|
29
|
+
# private_dict_hints: { blue_values: [-15, 0], std_hw: 70 }
|
|
30
|
+
# })
|
|
31
|
+
class TableBuilder
|
|
32
|
+
# Rebuild CFF table with modifications
|
|
33
|
+
#
|
|
34
|
+
# @param source_cff [Cff] Source CFF table
|
|
35
|
+
# @param modifications [Hash] Modifications to apply
|
|
36
|
+
# @return [String] Binary CFF table data
|
|
37
|
+
def self.rebuild(source_cff, modifications = {})
|
|
38
|
+
new(source_cff).tap do |builder|
|
|
39
|
+
builder.apply_modifications(modifications)
|
|
40
|
+
end.serialize
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Initialize with source CFF
|
|
44
|
+
#
|
|
45
|
+
# @param source_cff [Cff] Source CFF table
|
|
46
|
+
def initialize(source_cff)
|
|
47
|
+
@source = source_cff
|
|
48
|
+
@sections = extract_sections
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Apply modifications to CFF structure
|
|
52
|
+
#
|
|
53
|
+
# @param mods [Hash] Modifications hash
|
|
54
|
+
def apply_modifications(mods)
|
|
55
|
+
update_private_dict(mods[:private_dict_hints]) if mods[:private_dict_hints]
|
|
56
|
+
update_charstrings(mods[:per_glyph_hints]) if mods[:per_glyph_hints]
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Serialize to binary CFF table
|
|
60
|
+
#
|
|
61
|
+
# @return [String] Binary CFF data
|
|
62
|
+
def serialize
|
|
63
|
+
# Calculate initial offsets
|
|
64
|
+
offsets = OffsetRecalculator.calculate_offsets(@sections)
|
|
65
|
+
top_dict = extract_top_dict_data
|
|
66
|
+
updated = OffsetRecalculator.update_top_dict(top_dict, offsets)
|
|
67
|
+
rebuild_top_dict_index(updated)
|
|
68
|
+
|
|
69
|
+
# Recalculate after Top DICT rebuild (size may change)
|
|
70
|
+
offsets = OffsetRecalculator.calculate_offsets(@sections)
|
|
71
|
+
updated = OffsetRecalculator.update_top_dict(top_dict, offsets)
|
|
72
|
+
rebuild_top_dict_index(updated)
|
|
73
|
+
|
|
74
|
+
assemble
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
# Extract all CFF sections from source
|
|
80
|
+
#
|
|
81
|
+
# @return [Hash] Hash of section_name => binary_data
|
|
82
|
+
def extract_sections
|
|
83
|
+
{
|
|
84
|
+
header: extract_header,
|
|
85
|
+
name_index: extract_index(@source.name_index),
|
|
86
|
+
top_dict_index: extract_index(@source.top_dict_index),
|
|
87
|
+
string_index: extract_index(@source.string_index),
|
|
88
|
+
global_subr_index: extract_index(@source.global_subr_index),
|
|
89
|
+
charstrings_index: extract_index(@source.charstrings_index(0)),
|
|
90
|
+
private_dict: extract_private_dict,
|
|
91
|
+
}
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Extract header bytes
|
|
95
|
+
#
|
|
96
|
+
# @return [String] Binary header data
|
|
97
|
+
def extract_header
|
|
98
|
+
@source.raw_data[0, @source.header.hdr_size]
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Extract INDEX as binary data
|
|
102
|
+
#
|
|
103
|
+
# @param index [Index] INDEX object
|
|
104
|
+
# @return [String] Binary INDEX data
|
|
105
|
+
def extract_index(index)
|
|
106
|
+
return [0].pack("n") if index.nil? || index.count.zero?
|
|
107
|
+
|
|
108
|
+
start = index.instance_variable_get(:@start_offset)
|
|
109
|
+
io = StringIO.new(@source.raw_data)
|
|
110
|
+
io.seek(start)
|
|
111
|
+
|
|
112
|
+
count = io.read(2).unpack1("n")
|
|
113
|
+
return [0].pack("n") if count.zero?
|
|
114
|
+
|
|
115
|
+
off_size = io.read(1).unpack1("C")
|
|
116
|
+
offset_array_size = (count + 1) * off_size
|
|
117
|
+
|
|
118
|
+
# Read last offset to determine data size
|
|
119
|
+
io.seek(start + 3 + count * off_size)
|
|
120
|
+
last_offset = read_offset(io, off_size)
|
|
121
|
+
data_size = last_offset - 1
|
|
122
|
+
|
|
123
|
+
# Read entire INDEX
|
|
124
|
+
io.seek(start)
|
|
125
|
+
io.read(3 + offset_array_size + data_size)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Extract Private DICT bytes
|
|
129
|
+
#
|
|
130
|
+
# @return [String] Binary Private DICT data
|
|
131
|
+
def extract_private_dict
|
|
132
|
+
priv_info = @source.top_dict(0).private
|
|
133
|
+
return "".b unless priv_info
|
|
134
|
+
|
|
135
|
+
size, offset = priv_info
|
|
136
|
+
@source.raw_data[offset, size]
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Update Private DICT with hints
|
|
140
|
+
#
|
|
141
|
+
# @param hints [Hash] Hint parameters
|
|
142
|
+
def update_private_dict(hints)
|
|
143
|
+
source_priv = @source.private_dict(0)
|
|
144
|
+
writer = PrivateDictWriter.new(source_priv)
|
|
145
|
+
writer.update_hints(hints)
|
|
146
|
+
@sections[:private_dict] = writer.serialize
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Update CharStrings with per-glyph hints
|
|
150
|
+
#
|
|
151
|
+
# @param per_glyph_hints [Hash] Hash of glyph_id => Array<Hint>
|
|
152
|
+
def update_charstrings(per_glyph_hints)
|
|
153
|
+
return if per_glyph_hints.nil? || per_glyph_hints.empty?
|
|
154
|
+
|
|
155
|
+
# Create CharStringRebuilder
|
|
156
|
+
charstrings_index = @source.charstrings_index(0)
|
|
157
|
+
rebuilder = CharStringRebuilder.new(charstrings_index)
|
|
158
|
+
|
|
159
|
+
# Inject hints for each glyph
|
|
160
|
+
per_glyph_hints.each do |glyph_id, hints|
|
|
161
|
+
injector = HintOperationInjector.new
|
|
162
|
+
|
|
163
|
+
rebuilder.modify_charstring(glyph_id) do |operations|
|
|
164
|
+
# Inject hint operations
|
|
165
|
+
injector.inject(hints, operations)
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Rebuild CharStrings INDEX
|
|
170
|
+
@sections[:charstrings_index] = rebuilder.rebuild
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Extract Top DICT data as hash
|
|
174
|
+
#
|
|
175
|
+
# @return [Hash] Top DICT parameters
|
|
176
|
+
def extract_top_dict_data
|
|
177
|
+
@source.top_dict(0).to_h
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Rebuild Top DICT INDEX with updated data
|
|
181
|
+
#
|
|
182
|
+
# @param data [Hash] Top DICT parameters
|
|
183
|
+
def rebuild_top_dict_index(data)
|
|
184
|
+
dict_bytes = DictBuilder.build(data)
|
|
185
|
+
@sections[:top_dict_index] = IndexBuilder.build([dict_bytes])
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Assemble all sections into CFF table
|
|
189
|
+
#
|
|
190
|
+
# @return [String] Binary CFF table
|
|
191
|
+
def assemble
|
|
192
|
+
output = StringIO.new("".b)
|
|
193
|
+
output.write(@sections[:header])
|
|
194
|
+
output.write(@sections[:name_index])
|
|
195
|
+
output.write(@sections[:top_dict_index])
|
|
196
|
+
output.write(@sections[:string_index])
|
|
197
|
+
output.write(@sections[:global_subr_index])
|
|
198
|
+
output.write(@sections[:charstrings_index])
|
|
199
|
+
output.write(@sections[:private_dict])
|
|
200
|
+
output.string
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Read offset of specified size
|
|
204
|
+
#
|
|
205
|
+
# @param io [IO] IO object
|
|
206
|
+
# @param size [Integer] Offset size (1-4 bytes)
|
|
207
|
+
# @return [Integer] Offset value
|
|
208
|
+
def read_offset(io, size)
|
|
209
|
+
case size
|
|
210
|
+
when 1 then io.read(1).unpack1("C")
|
|
211
|
+
when 2 then io.read(2).unpack1("n")
|
|
212
|
+
when 3
|
|
213
|
+
bytes = io.read(3).unpack("C*")
|
|
214
|
+
(bytes[0] << 16) | (bytes[1] << 8) | bytes[2]
|
|
215
|
+
when 4 then io.read(4).unpack1("N")
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
end
|
data/lib/fontisan/tables/cff.rb
CHANGED
|
@@ -479,6 +479,8 @@ module Fontisan
|
|
|
479
479
|
require_relative "cff/top_dict"
|
|
480
480
|
require_relative "cff/private_dict"
|
|
481
481
|
require_relative "cff/charstring"
|
|
482
|
+
require_relative "cff/charstring_parser"
|
|
483
|
+
require_relative "cff/charstring_rebuilder"
|
|
482
484
|
require_relative "cff/charstrings_index"
|
|
483
485
|
require_relative "cff/charset"
|
|
484
486
|
require_relative "cff/encoding"
|
|
@@ -68,7 +68,8 @@ module Fontisan
|
|
|
68
68
|
# @param global_subrs [Cff::Index, nil] Global subroutines INDEX
|
|
69
69
|
# @param local_subrs [Cff::Index, nil] Local subroutines INDEX
|
|
70
70
|
# @param vsindex [Integer] Variation store index (default 0)
|
|
71
|
-
def initialize(data, num_axes = 0, global_subrs = nil,
|
|
71
|
+
def initialize(data, num_axes = 0, global_subrs = nil,
|
|
72
|
+
local_subrs = nil, vsindex = 0)
|
|
72
73
|
@data = data
|
|
73
74
|
@num_axes = num_axes
|
|
74
75
|
@global_subrs = global_subrs
|
|
@@ -123,7 +124,8 @@ module Fontisan
|
|
|
123
124
|
when :line_to
|
|
124
125
|
[:line_to, cmd[:x], cmd[:y]]
|
|
125
126
|
when :curve_to
|
|
126
|
-
[:curve_to, cmd[:x1], cmd[:y1], cmd[:x2], cmd[:y2], cmd[:x],
|
|
127
|
+
[:curve_to, cmd[:x1], cmd[:y1], cmd[:x2], cmd[:y2], cmd[:x],
|
|
128
|
+
cmd[:y]]
|
|
127
129
|
end
|
|
128
130
|
end
|
|
129
131
|
end
|
|
@@ -146,7 +148,8 @@ module Fontisan
|
|
|
146
148
|
end
|
|
147
149
|
end
|
|
148
150
|
rescue StandardError => e
|
|
149
|
-
raise CorruptedTableError,
|
|
151
|
+
raise CorruptedTableError,
|
|
152
|
+
"Failed to parse CFF2 CharString: #{e.message}"
|
|
150
153
|
end
|
|
151
154
|
|
|
152
155
|
# Check if byte is an operator
|
|
@@ -165,7 +168,10 @@ module Fontisan
|
|
|
165
168
|
if first_byte == 12
|
|
166
169
|
# Two-byte operator
|
|
167
170
|
second_byte = @io.getbyte
|
|
168
|
-
|
|
171
|
+
if second_byte.nil?
|
|
172
|
+
raise CorruptedTableError,
|
|
173
|
+
"Unexpected end of CharString"
|
|
174
|
+
end
|
|
169
175
|
|
|
170
176
|
[12, second_byte]
|
|
171
177
|
else
|
|
@@ -305,7 +311,7 @@ module Fontisan
|
|
|
305
311
|
# @param blend_op [Hash] Blend operation data
|
|
306
312
|
# @param coordinates [Hash<String, Float>] Axis coordinates
|
|
307
313
|
# @return [Array<Float>] Blended values
|
|
308
|
-
def apply_blend(blend_op,
|
|
314
|
+
def apply_blend(blend_op, _coordinates)
|
|
309
315
|
blend_op[:blends].map do |blend|
|
|
310
316
|
base = blend[:base]
|
|
311
317
|
deltas = blend[:deltas]
|
|
@@ -313,7 +319,7 @@ module Fontisan
|
|
|
313
319
|
# Apply deltas based on coordinates
|
|
314
320
|
# This will be enhanced when we have proper coordinate interpolation
|
|
315
321
|
blended_value = base
|
|
316
|
-
deltas.each_with_index do |delta,
|
|
322
|
+
deltas.each_with_index do |delta, _axis_index|
|
|
317
323
|
# Placeholder: use normalized coordinate (will be replaced with proper interpolation)
|
|
318
324
|
scalar = 0.0 # Will be calculated by interpolator
|
|
319
325
|
blended_value += delta * scalar
|
|
@@ -573,7 +579,7 @@ module Fontisan
|
|
|
573
579
|
def callsubr
|
|
574
580
|
return if @local_subrs.nil? || @stack.empty?
|
|
575
581
|
|
|
576
|
-
|
|
582
|
+
@stack.pop
|
|
577
583
|
# Implement subroutine call (placeholder)
|
|
578
584
|
@stack.clear
|
|
579
585
|
end
|
|
@@ -581,7 +587,7 @@ module Fontisan
|
|
|
581
587
|
def callgsubr
|
|
582
588
|
return if @global_subrs.nil? || @stack.empty?
|
|
583
589
|
|
|
584
|
-
|
|
590
|
+
@stack.pop
|
|
585
591
|
# Implement global subroutine call (placeholder)
|
|
586
592
|
@stack.clear
|
|
587
593
|
end
|
|
@@ -0,0 +1,247 @@
|
|
|
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] =
|
|
153
|
+
blend_data ? apply_blend(blend_data, scalars) : value
|
|
154
|
+
end
|
|
155
|
+
else
|
|
156
|
+
# Non-blend value, copy as-is
|
|
157
|
+
result[key] = value
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
result
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Check if value looks like blend data
|
|
165
|
+
#
|
|
166
|
+
# @param value [Object] Value to check
|
|
167
|
+
# @return [Boolean] True if value could be blend data
|
|
168
|
+
def blend_value?(value)
|
|
169
|
+
# Blend values are arrays with multiple elements
|
|
170
|
+
value.is_a?(Array) && value.size > 1
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Rebuild Private DICT with hints injected
|
|
174
|
+
#
|
|
175
|
+
# This method prepares Private DICT for rebuilding, preserving
|
|
176
|
+
# blend operators while incorporating new hint values.
|
|
177
|
+
#
|
|
178
|
+
# @param hints [Hash] Hint values to inject
|
|
179
|
+
# @param num_axes [Integer] Number of variation axes
|
|
180
|
+
# @return [Hash] Modified Private DICT
|
|
181
|
+
def rebuild_with_hints(hints, num_axes:)
|
|
182
|
+
result = @private_dict.dup
|
|
183
|
+
|
|
184
|
+
# Inject hint values
|
|
185
|
+
hints.each do |key, value|
|
|
186
|
+
if value.is_a?(Hash) && (value.key?(:base) || value.key?("base")) && (value.key?(:deltas) || value.key?("deltas"))
|
|
187
|
+
# Hint with blend data - normalize and flatten for DICT storage
|
|
188
|
+
normalized_value = {
|
|
189
|
+
base: value[:base] || value["base"],
|
|
190
|
+
deltas: value[:deltas] || value["deltas"],
|
|
191
|
+
}
|
|
192
|
+
result[key] = flatten_blend(normalized_value, num_axes: num_axes)
|
|
193
|
+
else
|
|
194
|
+
# Simple hint value
|
|
195
|
+
result[key] = value
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
result
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# Flatten blend data to array format
|
|
203
|
+
#
|
|
204
|
+
# @param blend_data [Hash] Blend data with :base and :deltas
|
|
205
|
+
# @param num_axes [Integer] Number of variation axes
|
|
206
|
+
# @return [Array] Flattened array
|
|
207
|
+
def flatten_blend(blend_data, num_axes:)
|
|
208
|
+
if blend_data.key?(:blends)
|
|
209
|
+
# Array of blends
|
|
210
|
+
blend_data[:blends].flat_map do |blend|
|
|
211
|
+
[blend[:base]] + blend[:deltas]
|
|
212
|
+
end
|
|
213
|
+
else
|
|
214
|
+
# Single blend
|
|
215
|
+
[blend_data[:base]] + blend_data[:deltas]
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# Validate blend data structure
|
|
220
|
+
#
|
|
221
|
+
# @param num_axes [Integer] Expected number of axes
|
|
222
|
+
# @return [Array<String>] Validation errors (empty if valid)
|
|
223
|
+
def validate(num_axes:)
|
|
224
|
+
errors = []
|
|
225
|
+
|
|
226
|
+
@private_dict.each do |key, value|
|
|
227
|
+
next unless value.is_a?(Array)
|
|
228
|
+
next unless blend_value?(value)
|
|
229
|
+
|
|
230
|
+
# Try parsing as blend array
|
|
231
|
+
blend_data = parse_blend_array(key, num_axes: num_axes)
|
|
232
|
+
unless blend_data
|
|
233
|
+
# Try as single blend value
|
|
234
|
+
blend_data = parse_blend_value(key, num_axes: num_axes)
|
|
235
|
+
unless blend_data
|
|
236
|
+
errors << "Key #{key} has array value that doesn't match " \
|
|
237
|
+
"blend format for #{num_axes} axes"
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
errors
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
end
|