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,266 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "delta_parser"
|
|
4
|
+
require_relative "interpolator"
|
|
5
|
+
require_relative "region_matcher"
|
|
6
|
+
require_relative "table_accessor"
|
|
7
|
+
|
|
8
|
+
module Fontisan
|
|
9
|
+
module Variation
|
|
10
|
+
# Applies variation deltas to glyph outlines
|
|
11
|
+
#
|
|
12
|
+
# This class handles the complete delta application process for TrueType
|
|
13
|
+
# variable fonts using gvar table data:
|
|
14
|
+
# 1. Parse base glyph outline points
|
|
15
|
+
# 2. Match active tuple variations to coordinates
|
|
16
|
+
# 3. Parse and decompress deltas
|
|
17
|
+
# 4. Apply deltas: new_point = base + Σ(delta × scalar)
|
|
18
|
+
# 5. Expand IUP (Inferred Untouched Points)
|
|
19
|
+
#
|
|
20
|
+
# Reference: OpenType specification, gvar table
|
|
21
|
+
#
|
|
22
|
+
# @example Applying deltas to a glyph
|
|
23
|
+
# applier = Fontisan::Variation::DeltaApplier.new(font, interpolator, region_matcher)
|
|
24
|
+
# adjusted_points = applier.apply_deltas(glyph_id, coordinates)
|
|
25
|
+
class DeltaApplier
|
|
26
|
+
include TableAccessor
|
|
27
|
+
|
|
28
|
+
# @return [Font] Font object
|
|
29
|
+
attr_reader :font
|
|
30
|
+
|
|
31
|
+
# @return [Interpolator] Coordinate interpolator
|
|
32
|
+
attr_reader :interpolator
|
|
33
|
+
|
|
34
|
+
# @return [RegionMatcher] Region matcher
|
|
35
|
+
attr_reader :region_matcher
|
|
36
|
+
|
|
37
|
+
# @return [DeltaParser] Delta parser
|
|
38
|
+
attr_reader :delta_parser
|
|
39
|
+
|
|
40
|
+
# Initialize delta applier
|
|
41
|
+
#
|
|
42
|
+
# @param font [TrueTypeFont, OpenTypeFont] Font with gvar table
|
|
43
|
+
# @param interpolator [Interpolator] Coordinate interpolator
|
|
44
|
+
# @param region_matcher [RegionMatcher] Region matcher
|
|
45
|
+
def initialize(font, interpolator, region_matcher)
|
|
46
|
+
@font = font
|
|
47
|
+
@interpolator = interpolator
|
|
48
|
+
@region_matcher = region_matcher
|
|
49
|
+
@delta_parser = DeltaParser.new
|
|
50
|
+
@variation_tables = {}
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Apply deltas to a glyph at given coordinates
|
|
54
|
+
#
|
|
55
|
+
# @param glyph_id [Integer] Glyph ID
|
|
56
|
+
# @param coordinates [Hash<String, Float>] Design space coordinates
|
|
57
|
+
# @return [Array<Hash>, nil] Adjusted points or nil if not applicable
|
|
58
|
+
def apply_deltas(glyph_id, coordinates)
|
|
59
|
+
gvar = variation_table("gvar")
|
|
60
|
+
glyf = variation_table("glyf")
|
|
61
|
+
return nil unless gvar && glyf
|
|
62
|
+
|
|
63
|
+
# Get base glyph outline points
|
|
64
|
+
base_points = extract_glyph_points(glyph_id, glyf)
|
|
65
|
+
return nil if base_points.nil? || base_points.empty?
|
|
66
|
+
|
|
67
|
+
# Get tuple variations for this glyph
|
|
68
|
+
tuple_data = gvar.glyph_tuple_variations(glyph_id)
|
|
69
|
+
return base_points if tuple_data.nil? || tuple_data[:tuples].empty?
|
|
70
|
+
|
|
71
|
+
# Match active tuples to coordinates
|
|
72
|
+
matches = @region_matcher.match_tuples(
|
|
73
|
+
coordinates: coordinates,
|
|
74
|
+
tuples: tuple_data[:tuples],
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
return base_points if matches.empty?
|
|
78
|
+
|
|
79
|
+
# Apply each active tuple's deltas
|
|
80
|
+
adjusted_points = base_points.dup
|
|
81
|
+
matches.each do |match|
|
|
82
|
+
apply_tuple_deltas(adjusted_points, match, tuple_data, base_points.length)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
adjusted_points
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Extract outline points from glyph
|
|
89
|
+
#
|
|
90
|
+
# @param glyph_id [Integer] Glyph ID
|
|
91
|
+
# @param glyf [Glyf] Glyf table
|
|
92
|
+
# @return [Array<Hash>, nil] Array of points with :x, :y, :on_curve
|
|
93
|
+
def extract_glyph_points(glyph_id, glyf)
|
|
94
|
+
# This is a simplified version - full implementation would parse
|
|
95
|
+
# complete glyf table data including composite glyphs
|
|
96
|
+
glyph_data = glyf.glyph_data(glyph_id)
|
|
97
|
+
return nil if glyph_data.nil?
|
|
98
|
+
|
|
99
|
+
# Parse glyph outline (simplified)
|
|
100
|
+
# Real implementation would fully parse SimpleGlyph or CompositeGlyph
|
|
101
|
+
[]
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
private
|
|
105
|
+
|
|
106
|
+
# Apply a single tuple's deltas to points
|
|
107
|
+
#
|
|
108
|
+
# @param points [Array<Hash>] Points to adjust (modified in place)
|
|
109
|
+
# @param match [Hash] Matched tuple with :tuple and :scalar
|
|
110
|
+
# @param tuple_data [Hash] Complete tuple data from gvar
|
|
111
|
+
# @param point_count [Integer] Number of points
|
|
112
|
+
def apply_tuple_deltas(points, match, tuple_data, point_count)
|
|
113
|
+
tuple = match[:tuple]
|
|
114
|
+
scalar = match[:scalar]
|
|
115
|
+
|
|
116
|
+
return if scalar.zero?
|
|
117
|
+
|
|
118
|
+
# Parse deltas for this tuple
|
|
119
|
+
# Note: In real implementation, we'd need to extract the actual
|
|
120
|
+
# delta data from the gvar table at the correct offset
|
|
121
|
+
deltas = parse_tuple_deltas(tuple, point_count, tuple_data)
|
|
122
|
+
return if deltas.nil?
|
|
123
|
+
|
|
124
|
+
# Check if points need IUP expansion
|
|
125
|
+
if tuple[:private_points]
|
|
126
|
+
# Expand IUP for untouched points
|
|
127
|
+
deltas = expand_iup(deltas, point_count)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Apply deltas with scalar
|
|
131
|
+
points.each_with_index do |point, i|
|
|
132
|
+
next if i >= deltas.length
|
|
133
|
+
|
|
134
|
+
delta = deltas[i]
|
|
135
|
+
point[:x] += delta[:x] * scalar
|
|
136
|
+
point[:y] += delta[:y] * scalar
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Parse deltas for a tuple variation
|
|
141
|
+
#
|
|
142
|
+
# @param tuple [Hash] Tuple variation info
|
|
143
|
+
# @param point_count [Integer] Number of points
|
|
144
|
+
# @param tuple_data [Hash] Complete tuple data
|
|
145
|
+
# @return [Array<Hash>, nil] Array of point deltas
|
|
146
|
+
def parse_tuple_deltas(tuple, point_count, tuple_data)
|
|
147
|
+
# In real implementation, this would:
|
|
148
|
+
# 1. Calculate offset to delta data
|
|
149
|
+
# 2. Extract raw delta bytes
|
|
150
|
+
# 3. Call delta_parser.parse with appropriate flags
|
|
151
|
+
|
|
152
|
+
# Placeholder - full implementation needs access to raw delta data
|
|
153
|
+
@delta_parser.parse(
|
|
154
|
+
"",
|
|
155
|
+
point_count,
|
|
156
|
+
private_points: tuple[:private_points],
|
|
157
|
+
shared_points: tuple_data[:has_shared_points] ? [] : nil,
|
|
158
|
+
)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Expand IUP (Inferred Untouched Points)
|
|
162
|
+
#
|
|
163
|
+
# Points without explicit deltas have their deltas inferred through
|
|
164
|
+
# linear interpolation between surrounding touched points.
|
|
165
|
+
#
|
|
166
|
+
# @param deltas [Array<Hash>] Delta array (sparse)
|
|
167
|
+
# @param point_count [Integer] Total number of points
|
|
168
|
+
# @return [Array<Hash>] Expanded delta array
|
|
169
|
+
def expand_iup(deltas, point_count)
|
|
170
|
+
return deltas if deltas.length == point_count
|
|
171
|
+
|
|
172
|
+
expanded = Array.new(point_count) { { x: 0, y: 0 } }
|
|
173
|
+
|
|
174
|
+
# Copy explicit deltas
|
|
175
|
+
deltas.each_with_index do |delta, i|
|
|
176
|
+
next if i >= point_count
|
|
177
|
+
|
|
178
|
+
expanded[i] = delta if delta[:x] != 0 || delta[:y] != 0
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Find touched points
|
|
182
|
+
touched = []
|
|
183
|
+
deltas.each_with_index do |delta, i|
|
|
184
|
+
touched << i if delta[:x] != 0 || delta[:y] != 0
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
return expanded if touched.empty?
|
|
188
|
+
|
|
189
|
+
# Infer untouched points
|
|
190
|
+
point_count.times do |i|
|
|
191
|
+
next if touched.include?(i)
|
|
192
|
+
|
|
193
|
+
# Find previous and next touched points
|
|
194
|
+
prev_idx = find_previous_touched(touched, i)
|
|
195
|
+
next_idx = find_next_touched(touched, i, point_count)
|
|
196
|
+
|
|
197
|
+
# Interpolate delta
|
|
198
|
+
if prev_idx && next_idx
|
|
199
|
+
expanded[i] = interpolate_delta(
|
|
200
|
+
deltas[prev_idx],
|
|
201
|
+
deltas[next_idx],
|
|
202
|
+
i, prev_idx, next_idx
|
|
203
|
+
)
|
|
204
|
+
elsif prev_idx
|
|
205
|
+
# Use previous delta
|
|
206
|
+
expanded[i] = deltas[prev_idx].dup
|
|
207
|
+
elsif next_idx
|
|
208
|
+
# Use next delta
|
|
209
|
+
expanded[i] = deltas[next_idx].dup
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
expanded
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# Find previous touched point
|
|
217
|
+
#
|
|
218
|
+
# @param touched [Array<Integer>] Touched point indices
|
|
219
|
+
# @param index [Integer] Current point index
|
|
220
|
+
# @return [Integer, nil] Previous touched index or nil
|
|
221
|
+
def find_previous_touched(touched, index)
|
|
222
|
+
touched.reverse_each do |t|
|
|
223
|
+
return t if t < index
|
|
224
|
+
end
|
|
225
|
+
nil
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# Find next touched point
|
|
229
|
+
#
|
|
230
|
+
# @param touched [Array<Integer>] Touched point indices
|
|
231
|
+
# @param index [Integer] Current point index
|
|
232
|
+
# @param point_count [Integer] Total points (for wrapping)
|
|
233
|
+
# @return [Integer, nil] Next touched index or nil
|
|
234
|
+
def find_next_touched(touched, index, _point_count)
|
|
235
|
+
# Check forward
|
|
236
|
+
touched.each do |t|
|
|
237
|
+
return t if t > index
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# Wrap around (contour is closed)
|
|
241
|
+
touched.first
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# Interpolate delta between two touched points
|
|
245
|
+
#
|
|
246
|
+
# @param delta1 [Hash] First delta
|
|
247
|
+
# @param delta2 [Hash] Second delta
|
|
248
|
+
# @param current [Integer] Current point index
|
|
249
|
+
# @param idx1 [Integer] First point index
|
|
250
|
+
# @param idx2 [Integer] Second point index
|
|
251
|
+
# @return [Hash] Interpolated delta
|
|
252
|
+
def interpolate_delta(delta1, delta2, current, idx1, idx2)
|
|
253
|
+
# Linear interpolation
|
|
254
|
+
range = idx2 - idx1
|
|
255
|
+
return delta1.dup if range.zero?
|
|
256
|
+
|
|
257
|
+
ratio = (current - idx1).to_f / range
|
|
258
|
+
|
|
259
|
+
{
|
|
260
|
+
x: delta1[:x] + (delta2[:x] - delta1[:x]) * ratio,
|
|
261
|
+
y: delta1[:y] + (delta2[:y] - delta1[:y]) * ratio,
|
|
262
|
+
}
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
end
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "stringio"
|
|
4
|
+
|
|
5
|
+
module Fontisan
|
|
6
|
+
module Variation
|
|
7
|
+
# Parses variation deltas from gvar tuple data
|
|
8
|
+
#
|
|
9
|
+
# The gvar table stores deltas in various compression formats to minimize
|
|
10
|
+
# file size. This parser handles all delta formats and decompresses them
|
|
11
|
+
# into usable point delta arrays.
|
|
12
|
+
#
|
|
13
|
+
# Delta Formats:
|
|
14
|
+
# - DELTAS_ARE_ZERO: All deltas are zero (no data stored)
|
|
15
|
+
# - DELTAS_ARE_WORDS: Deltas stored as signed 16-bit words
|
|
16
|
+
# - DELTAS_ARE_BYTES: Deltas stored as signed 8-bit bytes
|
|
17
|
+
# - Point number runs: Compressed sequences of affected points
|
|
18
|
+
#
|
|
19
|
+
# Reference: OpenType specification, gvar table delta encoding
|
|
20
|
+
#
|
|
21
|
+
# @example Parsing delta data
|
|
22
|
+
# parser = Fontisan::Variation::DeltaParser.new
|
|
23
|
+
# deltas = parser.parse(tuple_data, point_count)
|
|
24
|
+
# # Returns: [{ x: 10, y: 5 }, { x: -3, y: 2 }, ...]
|
|
25
|
+
class DeltaParser
|
|
26
|
+
# Delta format flags (from tuple variation header flags)
|
|
27
|
+
DELTAS_ARE_ZERO = 0x80
|
|
28
|
+
DELTAS_ARE_WORDS = 0x40
|
|
29
|
+
|
|
30
|
+
# Point number flags
|
|
31
|
+
POINTS_ARE_WORDS = 0x80
|
|
32
|
+
POINT_RUN_COUNT_MASK = 0x7F
|
|
33
|
+
|
|
34
|
+
# Parse delta data from tuple variation
|
|
35
|
+
#
|
|
36
|
+
# @param data [String] Binary delta data
|
|
37
|
+
# @param point_count [Integer] Total number of points in glyph
|
|
38
|
+
# @param private_points [Boolean] Whether tuple has private point numbers
|
|
39
|
+
# @param shared_points [Array<Integer>, nil] Shared point numbers if applicable
|
|
40
|
+
# @return [Array<Hash>] Array of point deltas { x:, y: }
|
|
41
|
+
# @raise [VariationDataCorruptedError] If delta data is corrupted or cannot be parsed
|
|
42
|
+
def parse(data, point_count, private_points: false, shared_points: nil)
|
|
43
|
+
return zero_deltas(point_count) if data.nil? || data.empty?
|
|
44
|
+
|
|
45
|
+
io = StringIO.new(data)
|
|
46
|
+
io.set_encoding(Encoding::BINARY)
|
|
47
|
+
|
|
48
|
+
# Parse point numbers if present
|
|
49
|
+
points = if private_points
|
|
50
|
+
parse_point_numbers(io)
|
|
51
|
+
elsif shared_points
|
|
52
|
+
shared_points
|
|
53
|
+
else
|
|
54
|
+
# All points affected
|
|
55
|
+
(0...point_count).to_a
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Determine delta format from first byte (if present)
|
|
59
|
+
format_byte = io.getbyte
|
|
60
|
+
return zero_deltas(point_count) if format_byte.nil?
|
|
61
|
+
|
|
62
|
+
io.pos -= 1 # Put byte back
|
|
63
|
+
|
|
64
|
+
# Parse X deltas
|
|
65
|
+
x_deltas = parse_delta_array(io, points.length)
|
|
66
|
+
|
|
67
|
+
# Parse Y deltas
|
|
68
|
+
y_deltas = parse_delta_array(io, points.length)
|
|
69
|
+
|
|
70
|
+
# Build full delta array (zero for untouched points)
|
|
71
|
+
build_full_deltas(points, x_deltas, y_deltas, point_count)
|
|
72
|
+
rescue StandardError => e
|
|
73
|
+
raise VariationDataCorruptedError.new(
|
|
74
|
+
message: "Failed to parse delta data: #{e.message}",
|
|
75
|
+
details: {
|
|
76
|
+
point_count: point_count,
|
|
77
|
+
private_points: private_points,
|
|
78
|
+
error_class: e.class.name,
|
|
79
|
+
},
|
|
80
|
+
)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Parse delta data with explicit format flag
|
|
84
|
+
#
|
|
85
|
+
# @param data [String] Binary delta data
|
|
86
|
+
# @param point_count [Integer] Total number of points
|
|
87
|
+
# @param flags [Integer] Tuple variation flags
|
|
88
|
+
# @return [Array<Hash>] Array of point deltas
|
|
89
|
+
def parse_with_flags(data, point_count, flags)
|
|
90
|
+
if (flags & DELTAS_ARE_ZERO).zero?
|
|
91
|
+
parse(data, point_count)
|
|
92
|
+
else
|
|
93
|
+
zero_deltas(point_count)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
private
|
|
98
|
+
|
|
99
|
+
# Parse point numbers from packed format
|
|
100
|
+
#
|
|
101
|
+
# Point numbers indicate which points have deltas. Uses run-length
|
|
102
|
+
# encoding to compress sequences of point numbers.
|
|
103
|
+
#
|
|
104
|
+
# @param io [StringIO] Input stream
|
|
105
|
+
# @return [Array<Integer>] Array of point numbers
|
|
106
|
+
def parse_point_numbers(io)
|
|
107
|
+
points = []
|
|
108
|
+
first_byte = io.getbyte
|
|
109
|
+
return points if first_byte.nil?
|
|
110
|
+
|
|
111
|
+
# First byte indicates total number of point numbers
|
|
112
|
+
total_points = first_byte
|
|
113
|
+
|
|
114
|
+
# Parse all point number runs
|
|
115
|
+
point_index = 0
|
|
116
|
+
remaining = total_points
|
|
117
|
+
|
|
118
|
+
while remaining.positive?
|
|
119
|
+
control = io.getbyte
|
|
120
|
+
return points if control.nil?
|
|
121
|
+
|
|
122
|
+
# Number of points in this run
|
|
123
|
+
run_count = (control & POINT_RUN_COUNT_MASK) + 1
|
|
124
|
+
|
|
125
|
+
# Limit run_count to remaining points
|
|
126
|
+
run_count = [run_count, remaining].min
|
|
127
|
+
|
|
128
|
+
if (control & POINTS_ARE_WORDS).zero?
|
|
129
|
+
# Points stored as 8-bit bytes (deltas from previous)
|
|
130
|
+
run_count.times do
|
|
131
|
+
byte = io.getbyte
|
|
132
|
+
return points if byte.nil?
|
|
133
|
+
|
|
134
|
+
point_index += byte
|
|
135
|
+
points << point_index
|
|
136
|
+
remaining -= 1
|
|
137
|
+
end
|
|
138
|
+
else
|
|
139
|
+
# Points stored as 16-bit words
|
|
140
|
+
run_count.times do
|
|
141
|
+
bytes = io.read(2)
|
|
142
|
+
return points if bytes.nil? || bytes.bytesize < 2
|
|
143
|
+
|
|
144
|
+
point = bytes.unpack1("n")
|
|
145
|
+
points << point
|
|
146
|
+
point_index = point
|
|
147
|
+
remaining -= 1
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
points
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Parse an array of delta values
|
|
156
|
+
#
|
|
157
|
+
# Deltas can be stored as bytes or words depending on value range.
|
|
158
|
+
# The format is determined by inspecting the first byte.
|
|
159
|
+
#
|
|
160
|
+
# @param io [StringIO] Input stream
|
|
161
|
+
# @param count [Integer] Number of deltas to parse
|
|
162
|
+
# @return [Array<Integer>] Array of delta values
|
|
163
|
+
def parse_delta_array(io, count)
|
|
164
|
+
return [] if count.zero?
|
|
165
|
+
|
|
166
|
+
deltas = []
|
|
167
|
+
|
|
168
|
+
# Read control byte to determine format
|
|
169
|
+
control = io.getbyte
|
|
170
|
+
return deltas if control.nil?
|
|
171
|
+
|
|
172
|
+
if (control & DELTAS_ARE_WORDS).zero?
|
|
173
|
+
# Deltas stored as 8-bit signed bytes
|
|
174
|
+
count.times do
|
|
175
|
+
byte = io.getbyte
|
|
176
|
+
return deltas if byte.nil?
|
|
177
|
+
|
|
178
|
+
signed = byte > 0x7F ? byte - 0x100 : byte
|
|
179
|
+
deltas << signed
|
|
180
|
+
end
|
|
181
|
+
else
|
|
182
|
+
# Deltas stored as 16-bit signed words
|
|
183
|
+
count.times do
|
|
184
|
+
bytes = io.read(2)
|
|
185
|
+
return deltas if bytes.nil? || bytes.bytesize < 2
|
|
186
|
+
|
|
187
|
+
value = bytes.unpack1("n")
|
|
188
|
+
signed = value > 0x7FFF ? value - 0x10000 : value
|
|
189
|
+
deltas << signed
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
deltas
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Build full delta array including untouched points
|
|
197
|
+
#
|
|
198
|
+
# @param points [Array<Integer>] Point numbers with deltas
|
|
199
|
+
# @param x_deltas [Array<Integer>] X deltas
|
|
200
|
+
# @param y_deltas [Array<Integer>] Y deltas
|
|
201
|
+
# @param point_count [Integer] Total points in glyph
|
|
202
|
+
# @return [Array<Hash>] Full delta array
|
|
203
|
+
def build_full_deltas(points, x_deltas, y_deltas, point_count)
|
|
204
|
+
full_deltas = Array.new(point_count) { { x: 0, y: 0 } }
|
|
205
|
+
|
|
206
|
+
points.each_with_index do |point_num, i|
|
|
207
|
+
next if point_num >= point_count
|
|
208
|
+
next if i >= x_deltas.length || i >= y_deltas.length
|
|
209
|
+
|
|
210
|
+
full_deltas[point_num] = {
|
|
211
|
+
x: x_deltas[i],
|
|
212
|
+
y: y_deltas[i],
|
|
213
|
+
}
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
full_deltas
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# Create array of zero deltas
|
|
220
|
+
#
|
|
221
|
+
# @param count [Integer] Number of deltas
|
|
222
|
+
# @return [Array<Hash>] Array of zero deltas
|
|
223
|
+
def zero_deltas(count)
|
|
224
|
+
Array.new(count) { { x: 0, y: 0 } }
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
end
|