fontisan 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.rubocop_todo.yml +529 -65
- data/Gemfile +1 -0
- data/LICENSE +5 -1
- data/README.adoc +1301 -275
- data/Rakefile +27 -2
- data/benchmark/variation_quick_bench.rb +47 -0
- data/docs/EXTRACT_TTC_MIGRATION.md +549 -0
- data/fontisan.gemspec +4 -1
- data/lib/fontisan/binary/base_record.rb +22 -1
- data/lib/fontisan/cli.rb +309 -0
- data/lib/fontisan/collection/builder.rb +260 -0
- data/lib/fontisan/collection/offset_calculator.rb +227 -0
- data/lib/fontisan/collection/table_analyzer.rb +204 -0
- data/lib/fontisan/collection/table_deduplicator.rb +241 -0
- data/lib/fontisan/collection/writer.rb +306 -0
- data/lib/fontisan/commands/base_command.rb +8 -1
- data/lib/fontisan/commands/convert_command.rb +291 -0
- data/lib/fontisan/commands/export_command.rb +161 -0
- data/lib/fontisan/commands/info_command.rb +40 -6
- data/lib/fontisan/commands/instance_command.rb +295 -0
- data/lib/fontisan/commands/ls_command.rb +113 -0
- data/lib/fontisan/commands/pack_command.rb +241 -0
- data/lib/fontisan/commands/subset_command.rb +245 -0
- data/lib/fontisan/commands/unpack_command.rb +338 -0
- data/lib/fontisan/commands/validate_command.rb +178 -0
- data/lib/fontisan/commands/variable_command.rb +30 -1
- data/lib/fontisan/config/collection_settings.yml +56 -0
- data/lib/fontisan/config/conversion_matrix.yml +212 -0
- data/lib/fontisan/config/export_settings.yml +66 -0
- data/lib/fontisan/config/subset_profiles.yml +100 -0
- data/lib/fontisan/config/svg_settings.yml +60 -0
- data/lib/fontisan/config/validation_rules.yml +149 -0
- data/lib/fontisan/config/variable_settings.yml +99 -0
- data/lib/fontisan/config/woff2_settings.yml +77 -0
- data/lib/fontisan/constants.rb +69 -0
- data/lib/fontisan/converters/conversion_strategy.rb +96 -0
- data/lib/fontisan/converters/format_converter.rb +259 -0
- data/lib/fontisan/converters/outline_converter.rb +936 -0
- data/lib/fontisan/converters/svg_generator.rb +244 -0
- data/lib/fontisan/converters/table_copier.rb +117 -0
- data/lib/fontisan/converters/woff2_encoder.rb +416 -0
- data/lib/fontisan/converters/woff_writer.rb +391 -0
- data/lib/fontisan/error.rb +203 -0
- data/lib/fontisan/export/exporter.rb +262 -0
- data/lib/fontisan/export/table_serializer.rb +255 -0
- data/lib/fontisan/export/transformers/font_to_ttx.rb +172 -0
- data/lib/fontisan/export/transformers/head_transformer.rb +96 -0
- data/lib/fontisan/export/transformers/hhea_transformer.rb +59 -0
- data/lib/fontisan/export/transformers/maxp_transformer.rb +63 -0
- data/lib/fontisan/export/transformers/name_transformer.rb +63 -0
- data/lib/fontisan/export/transformers/os2_transformer.rb +121 -0
- data/lib/fontisan/export/transformers/post_transformer.rb +51 -0
- data/lib/fontisan/export/ttx_generator.rb +527 -0
- data/lib/fontisan/export/ttx_parser.rb +300 -0
- data/lib/fontisan/font_loader.rb +121 -12
- data/lib/fontisan/font_writer.rb +301 -0
- data/lib/fontisan/formatters/text_formatter.rb +102 -0
- data/lib/fontisan/glyph_accessor.rb +503 -0
- data/lib/fontisan/hints/hint_converter.rb +177 -0
- data/lib/fontisan/hints/postscript_hint_applier.rb +185 -0
- data/lib/fontisan/hints/postscript_hint_extractor.rb +254 -0
- data/lib/fontisan/hints/truetype_hint_applier.rb +71 -0
- data/lib/fontisan/hints/truetype_hint_extractor.rb +162 -0
- data/lib/fontisan/loading_modes.rb +113 -0
- data/lib/fontisan/metrics_calculator.rb +277 -0
- data/lib/fontisan/models/collection_font_summary.rb +52 -0
- data/lib/fontisan/models/collection_info.rb +76 -0
- data/lib/fontisan/models/collection_list_info.rb +37 -0
- data/lib/fontisan/models/font_export.rb +158 -0
- data/lib/fontisan/models/font_summary.rb +48 -0
- data/lib/fontisan/models/glyph_outline.rb +343 -0
- data/lib/fontisan/models/hint.rb +233 -0
- data/lib/fontisan/models/outline.rb +664 -0
- data/lib/fontisan/models/table_sharing_info.rb +40 -0
- data/lib/fontisan/models/ttx/glyph_order.rb +31 -0
- data/lib/fontisan/models/ttx/tables/binary_table.rb +67 -0
- data/lib/fontisan/models/ttx/tables/head_table.rb +74 -0
- data/lib/fontisan/models/ttx/tables/hhea_table.rb +74 -0
- data/lib/fontisan/models/ttx/tables/maxp_table.rb +55 -0
- data/lib/fontisan/models/ttx/tables/name_table.rb +45 -0
- data/lib/fontisan/models/ttx/tables/os2_table.rb +157 -0
- data/lib/fontisan/models/ttx/tables/post_table.rb +50 -0
- data/lib/fontisan/models/ttx/ttfont.rb +49 -0
- data/lib/fontisan/models/validation_report.rb +203 -0
- data/lib/fontisan/open_type_collection.rb +156 -2
- data/lib/fontisan/open_type_font.rb +296 -10
- data/lib/fontisan/optimizers/charstring_rewriter.rb +161 -0
- data/lib/fontisan/optimizers/pattern_analyzer.rb +308 -0
- data/lib/fontisan/optimizers/stack_tracker.rb +246 -0
- data/lib/fontisan/optimizers/subroutine_builder.rb +134 -0
- data/lib/fontisan/optimizers/subroutine_generator.rb +207 -0
- data/lib/fontisan/optimizers/subroutine_optimizer.rb +107 -0
- data/lib/fontisan/outline_extractor.rb +423 -0
- data/lib/fontisan/subset/builder.rb +268 -0
- data/lib/fontisan/subset/glyph_mapping.rb +215 -0
- data/lib/fontisan/subset/options.rb +142 -0
- data/lib/fontisan/subset/profile.rb +152 -0
- data/lib/fontisan/subset/table_subsetter.rb +461 -0
- data/lib/fontisan/svg/font_face_generator.rb +278 -0
- data/lib/fontisan/svg/font_generator.rb +264 -0
- data/lib/fontisan/svg/glyph_generator.rb +168 -0
- data/lib/fontisan/svg/view_box_calculator.rb +137 -0
- data/lib/fontisan/tables/cff/cff_glyph.rb +176 -0
- data/lib/fontisan/tables/cff/charset.rb +282 -0
- data/lib/fontisan/tables/cff/charstring.rb +905 -0
- data/lib/fontisan/tables/cff/charstring_builder.rb +322 -0
- data/lib/fontisan/tables/cff/charstrings_index.rb +162 -0
- data/lib/fontisan/tables/cff/dict.rb +351 -0
- data/lib/fontisan/tables/cff/dict_builder.rb +242 -0
- data/lib/fontisan/tables/cff/encoding.rb +274 -0
- data/lib/fontisan/tables/cff/header.rb +102 -0
- data/lib/fontisan/tables/cff/index.rb +237 -0
- data/lib/fontisan/tables/cff/index_builder.rb +170 -0
- data/lib/fontisan/tables/cff/private_dict.rb +284 -0
- data/lib/fontisan/tables/cff/top_dict.rb +236 -0
- data/lib/fontisan/tables/cff.rb +487 -0
- data/lib/fontisan/tables/cff2/blend_operator.rb +240 -0
- data/lib/fontisan/tables/cff2/charstring_parser.rb +591 -0
- data/lib/fontisan/tables/cff2/operand_stack.rb +232 -0
- data/lib/fontisan/tables/cff2.rb +341 -0
- data/lib/fontisan/tables/cvar.rb +242 -0
- data/lib/fontisan/tables/fvar.rb +2 -2
- data/lib/fontisan/tables/glyf/compound_glyph.rb +483 -0
- data/lib/fontisan/tables/glyf/compound_glyph_resolver.rb +136 -0
- data/lib/fontisan/tables/glyf/curve_converter.rb +343 -0
- data/lib/fontisan/tables/glyf/glyph_builder.rb +450 -0
- data/lib/fontisan/tables/glyf/simple_glyph.rb +382 -0
- data/lib/fontisan/tables/glyf.rb +235 -0
- data/lib/fontisan/tables/gvar.rb +270 -0
- data/lib/fontisan/tables/hhea.rb +124 -0
- data/lib/fontisan/tables/hmtx.rb +287 -0
- data/lib/fontisan/tables/hvar.rb +191 -0
- data/lib/fontisan/tables/loca.rb +322 -0
- data/lib/fontisan/tables/maxp.rb +192 -0
- data/lib/fontisan/tables/mvar.rb +185 -0
- data/lib/fontisan/tables/name.rb +99 -30
- data/lib/fontisan/tables/variation_common.rb +346 -0
- data/lib/fontisan/tables/vvar.rb +234 -0
- data/lib/fontisan/true_type_collection.rb +156 -2
- data/lib/fontisan/true_type_font.rb +297 -11
- data/lib/fontisan/utilities/brotli_wrapper.rb +159 -0
- data/lib/fontisan/utilities/checksum_calculator.rb +18 -0
- data/lib/fontisan/utils/thread_pool.rb +134 -0
- data/lib/fontisan/validation/checksum_validator.rb +170 -0
- data/lib/fontisan/validation/consistency_validator.rb +197 -0
- data/lib/fontisan/validation/structure_validator.rb +198 -0
- data/lib/fontisan/validation/table_validator.rb +158 -0
- data/lib/fontisan/validation/validator.rb +152 -0
- data/lib/fontisan/variable/axis_normalizer.rb +215 -0
- data/lib/fontisan/variable/delta_applicator.rb +313 -0
- data/lib/fontisan/variable/glyph_delta_processor.rb +218 -0
- data/lib/fontisan/variable/instancer.rb +344 -0
- data/lib/fontisan/variable/metric_delta_processor.rb +282 -0
- data/lib/fontisan/variable/region_matcher.rb +208 -0
- data/lib/fontisan/variable/static_font_builder.rb +213 -0
- data/lib/fontisan/variable/table_updater.rb +219 -0
- data/lib/fontisan/variation/blend_applier.rb +199 -0
- data/lib/fontisan/variation/cache.rb +298 -0
- data/lib/fontisan/variation/cache_key_builder.rb +162 -0
- data/lib/fontisan/variation/converter.rb +268 -0
- data/lib/fontisan/variation/data_extractor.rb +86 -0
- data/lib/fontisan/variation/delta_applier.rb +266 -0
- data/lib/fontisan/variation/delta_parser.rb +228 -0
- data/lib/fontisan/variation/inspector.rb +275 -0
- data/lib/fontisan/variation/instance_generator.rb +273 -0
- data/lib/fontisan/variation/interpolator.rb +231 -0
- data/lib/fontisan/variation/metrics_adjuster.rb +318 -0
- data/lib/fontisan/variation/optimizer.rb +418 -0
- data/lib/fontisan/variation/parallel_generator.rb +150 -0
- data/lib/fontisan/variation/region_matcher.rb +221 -0
- data/lib/fontisan/variation/subsetter.rb +463 -0
- data/lib/fontisan/variation/table_accessor.rb +105 -0
- data/lib/fontisan/variation/validator.rb +345 -0
- data/lib/fontisan/variation/variation_context.rb +211 -0
- data/lib/fontisan/version.rb +1 -1
- data/lib/fontisan/woff2/directory.rb +257 -0
- data/lib/fontisan/woff2/header.rb +101 -0
- data/lib/fontisan/woff2/table_transformer.rb +163 -0
- data/lib/fontisan/woff2_font.rb +712 -0
- data/lib/fontisan/woff_font.rb +483 -0
- data/lib/fontisan.rb +120 -0
- data/scripts/compare_stack_aware.rb +187 -0
- data/scripts/measure_optimization.rb +141 -0
- metadata +205 -4
|
@@ -0,0 +1,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
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "yaml"
|
|
5
|
+
require_relative "variation_context"
|
|
6
|
+
|
|
7
|
+
module Fontisan
|
|
8
|
+
module Variation
|
|
9
|
+
# Inspects and analyzes variable font structure
|
|
10
|
+
#
|
|
11
|
+
# This class provides comprehensive analysis of variable font structure,
|
|
12
|
+
# including axes, instances, regions, and variation statistics. Results
|
|
13
|
+
# can be exported to JSON or YAML formats.
|
|
14
|
+
#
|
|
15
|
+
# @example Inspecting a variable font
|
|
16
|
+
# inspector = Fontisan::Variation::Inspector.new(font)
|
|
17
|
+
# info = inspector.inspect_variation
|
|
18
|
+
# # => { axes: [...], instances: [...], regions: {...}, statistics: {...} }
|
|
19
|
+
#
|
|
20
|
+
# @example Exporting to JSON
|
|
21
|
+
# inspector.export_json
|
|
22
|
+
# # => "{ \"axes\": [...], ... }"
|
|
23
|
+
#
|
|
24
|
+
# @example Exporting to YAML
|
|
25
|
+
# inspector.export_yaml
|
|
26
|
+
# # => "---\naxes:\n - ..."
|
|
27
|
+
class Inspector
|
|
28
|
+
# @return [TrueTypeFont, OpenTypeFont] Font to inspect
|
|
29
|
+
attr_reader :font
|
|
30
|
+
|
|
31
|
+
# @return [VariationContext] Variation context
|
|
32
|
+
attr_reader :context
|
|
33
|
+
|
|
34
|
+
# Initialize inspector
|
|
35
|
+
#
|
|
36
|
+
# @param font [TrueTypeFont, OpenTypeFont] Variable font
|
|
37
|
+
def initialize(font)
|
|
38
|
+
@font = font
|
|
39
|
+
@context = VariationContext.new(font)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Inspect complete variation structure
|
|
43
|
+
#
|
|
44
|
+
# Returns comprehensive information about font variation capabilities.
|
|
45
|
+
#
|
|
46
|
+
# @return [Hash] Complete variation information
|
|
47
|
+
def inspect_variation
|
|
48
|
+
{
|
|
49
|
+
axes: inspect_axes,
|
|
50
|
+
instances: inspect_instances,
|
|
51
|
+
regions: inspect_regions,
|
|
52
|
+
statistics: calculate_statistics,
|
|
53
|
+
}
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Export inspection results as JSON
|
|
57
|
+
#
|
|
58
|
+
# @return [String] JSON formatted output
|
|
59
|
+
def export_json
|
|
60
|
+
JSON.pretty_generate(inspect_variation)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Export inspection results as YAML
|
|
64
|
+
#
|
|
65
|
+
# @return [String] YAML formatted output
|
|
66
|
+
def export_yaml
|
|
67
|
+
YAML.dump(inspect_variation)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Check if font is a variable font
|
|
71
|
+
#
|
|
72
|
+
# @return [Boolean] True if font has variation tables
|
|
73
|
+
def variable_font?
|
|
74
|
+
@context.variable_font?
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
# Inspect variation axes
|
|
80
|
+
#
|
|
81
|
+
# @return [Array<Hash>] Array of axis information
|
|
82
|
+
def inspect_axes
|
|
83
|
+
return [] unless variable_font?
|
|
84
|
+
return [] unless @context.fvar
|
|
85
|
+
|
|
86
|
+
@context.axes.map do |axis|
|
|
87
|
+
{
|
|
88
|
+
tag: axis.axis_tag,
|
|
89
|
+
name: axis_name(axis.axis_name_id),
|
|
90
|
+
min: axis.min_value,
|
|
91
|
+
default: axis.default_value,
|
|
92
|
+
max: axis.max_value,
|
|
93
|
+
hidden: axis.flags & 0x0001 != 0,
|
|
94
|
+
}
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Inspect named instances
|
|
99
|
+
#
|
|
100
|
+
# @return [Array<Hash>] Array of instance information
|
|
101
|
+
def inspect_instances
|
|
102
|
+
return [] unless variable_font?
|
|
103
|
+
return [] unless @context.fvar
|
|
104
|
+
|
|
105
|
+
@context.fvar.instances.map.with_index do |instance, index|
|
|
106
|
+
{
|
|
107
|
+
index: index,
|
|
108
|
+
name: instance_name(instance[:subfamily_name_id]),
|
|
109
|
+
postscript_name: instance_name(instance[:postscript_name_id]),
|
|
110
|
+
coordinates: instance_coordinates(instance[:coordinates], @context.axes),
|
|
111
|
+
}
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Inspect variation regions
|
|
116
|
+
#
|
|
117
|
+
# @return [Hash] Region statistics and information
|
|
118
|
+
def inspect_regions
|
|
119
|
+
regions = {
|
|
120
|
+
gvar: nil,
|
|
121
|
+
hvar: nil,
|
|
122
|
+
vvar: nil,
|
|
123
|
+
mvar: nil,
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if @font.has_table?("gvar")
|
|
127
|
+
regions[:gvar] = inspect_gvar_regions
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
if @font.has_table?("HVAR")
|
|
131
|
+
regions[:hvar] = inspect_hvar_regions
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
if @font.has_table?("VVAR")
|
|
135
|
+
regions[:vvar] = inspect_vvar_regions
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
if @font.has_table?("MVAR")
|
|
139
|
+
regions[:mvar] = inspect_mvar_regions
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
regions.compact
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Inspect gvar table regions
|
|
146
|
+
#
|
|
147
|
+
# @return [Hash] Gvar region information
|
|
148
|
+
def inspect_gvar_regions
|
|
149
|
+
gvar = @font.table("gvar")
|
|
150
|
+
return nil unless gvar
|
|
151
|
+
|
|
152
|
+
{
|
|
153
|
+
glyph_count: gvar.glyph_count,
|
|
154
|
+
axis_count: gvar.axis_count,
|
|
155
|
+
shared_tuples: gvar.shared_tuple_count || 0,
|
|
156
|
+
glyph_variation_data_present: gvar.glyph_count.positive?,
|
|
157
|
+
}
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Inspect HVAR table regions
|
|
161
|
+
#
|
|
162
|
+
# @return [Hash] HVAR region information
|
|
163
|
+
def inspect_hvar_regions
|
|
164
|
+
hvar = @font.table("HVAR")
|
|
165
|
+
return nil unless hvar
|
|
166
|
+
|
|
167
|
+
{
|
|
168
|
+
advance_width_mapping: hvar.advance_width_mapping ? true : false,
|
|
169
|
+
lsb_mapping: hvar.lsb_mapping ? true : false,
|
|
170
|
+
rsb_mapping: hvar.rsb_mapping ? true : false,
|
|
171
|
+
}
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Inspect VVAR table regions
|
|
175
|
+
#
|
|
176
|
+
# @return [Hash] VVAR region information
|
|
177
|
+
def inspect_vvar_regions
|
|
178
|
+
vvar = @font.table("VVAR")
|
|
179
|
+
return nil unless vvar
|
|
180
|
+
|
|
181
|
+
{
|
|
182
|
+
advance_height_mapping: vvar.advance_height_mapping ? true : false,
|
|
183
|
+
tsb_mapping: vvar.tsb_mapping ? true : false,
|
|
184
|
+
bsb_mapping: vvar.bsb_mapping ? true : false,
|
|
185
|
+
}
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Inspect MVAR table regions
|
|
189
|
+
#
|
|
190
|
+
# @return [Hash] MVAR region information
|
|
191
|
+
def inspect_mvar_regions
|
|
192
|
+
mvar = @font.table("MVAR")
|
|
193
|
+
return nil unless mvar
|
|
194
|
+
|
|
195
|
+
{
|
|
196
|
+
value_record_count: mvar.value_record_count || 0,
|
|
197
|
+
metrics_varied: mvar.value_records&.map { |r| r[:value_tag] } || [],
|
|
198
|
+
}
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Calculate variation statistics
|
|
202
|
+
#
|
|
203
|
+
# @return [Hash] Statistical information
|
|
204
|
+
def calculate_statistics
|
|
205
|
+
stats = {
|
|
206
|
+
is_variable: variable_font?,
|
|
207
|
+
axis_count: 0,
|
|
208
|
+
instance_count: 0,
|
|
209
|
+
has_glyph_variations: @context.has_glyph_variations?,
|
|
210
|
+
has_metrics_variations: @context.has_metrics_variations?,
|
|
211
|
+
variation_tables: [],
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if variable_font?
|
|
215
|
+
stats[:axis_count] = @context.axis_count
|
|
216
|
+
stats[:instance_count] = @context.fvar.instance_count if @context.fvar
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# List variation tables present
|
|
220
|
+
variation_table_tags = %w[fvar gvar cvar HVAR VVAR MVAR avar STAT]
|
|
221
|
+
stats[:variation_tables] = variation_table_tags.select do |tag|
|
|
222
|
+
@font.has_table?(tag)
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# Calculate design space size
|
|
226
|
+
if stats[:axis_count].positive?
|
|
227
|
+
stats[:design_space_dimensions] = stats[:axis_count]
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
stats
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# Get axis name from name table
|
|
234
|
+
#
|
|
235
|
+
# @param name_id [Integer] Name ID
|
|
236
|
+
# @return [String] Axis name
|
|
237
|
+
def axis_name(name_id)
|
|
238
|
+
return "Unknown" unless @font.has_table?("name")
|
|
239
|
+
|
|
240
|
+
name_table = @font.table("name")
|
|
241
|
+
record = name_table.names.find { |n| n[:name_id] == name_id }
|
|
242
|
+
record ? record[:string] : "Axis #{name_id}"
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# Get instance name from name table
|
|
246
|
+
#
|
|
247
|
+
# @param name_id [Integer] Name ID
|
|
248
|
+
# @return [String, nil] Instance name
|
|
249
|
+
def instance_name(name_id)
|
|
250
|
+
return nil unless name_id
|
|
251
|
+
return nil unless @font.has_table?("name")
|
|
252
|
+
|
|
253
|
+
name_table = @font.table("name")
|
|
254
|
+
record = name_table.names.find { |n| n[:name_id] == name_id }
|
|
255
|
+
record ? record[:string] : "Instance #{name_id}"
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# Build coordinates hash from instance
|
|
259
|
+
#
|
|
260
|
+
# @param coordinates [Array<Float>] Coordinate values
|
|
261
|
+
# @param axes [Array] Variation axes
|
|
262
|
+
# @return [Hash<String, Float>] Coordinates by axis tag
|
|
263
|
+
def instance_coordinates(coordinates, axes)
|
|
264
|
+
coords = {}
|
|
265
|
+
coordinates.each_with_index do |value, index|
|
|
266
|
+
break if index >= axes.length
|
|
267
|
+
|
|
268
|
+
axis = axes[index]
|
|
269
|
+
coords[axis.axis_tag] = value
|
|
270
|
+
end
|
|
271
|
+
coords
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
end
|