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,270 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "stringio"
|
|
4
|
+
require_relative "../binary/base_record"
|
|
5
|
+
|
|
6
|
+
module Fontisan
|
|
7
|
+
module Tables
|
|
8
|
+
# Parser for the 'gvar' (Glyph Variations) table
|
|
9
|
+
#
|
|
10
|
+
# The gvar table provides variation data for glyph outlines in TrueType
|
|
11
|
+
# variable fonts. It contains delta values for each glyph's control points
|
|
12
|
+
# that are applied based on the current design space coordinates.
|
|
13
|
+
#
|
|
14
|
+
# Unlike HVAR/VVAR/MVAR which use ItemVariationStore, gvar uses a
|
|
15
|
+
# TupleVariationStore structure with packed delta values.
|
|
16
|
+
#
|
|
17
|
+
# Reference: OpenType specification, gvar table
|
|
18
|
+
#
|
|
19
|
+
# @example Reading a gvar table
|
|
20
|
+
# data = font.table_data("gvar")
|
|
21
|
+
# gvar = Fontisan::Tables::Gvar.read(data)
|
|
22
|
+
# deltas = gvar.glyph_variations(glyph_id)
|
|
23
|
+
class Gvar < Binary::BaseRecord
|
|
24
|
+
uint16 :major_version
|
|
25
|
+
uint16 :minor_version
|
|
26
|
+
uint16 :axis_count
|
|
27
|
+
uint16 :shared_tuple_count
|
|
28
|
+
uint32 :shared_tuples_offset
|
|
29
|
+
uint16 :glyph_count
|
|
30
|
+
uint16 :flags
|
|
31
|
+
uint32 :glyph_variation_data_array_offset
|
|
32
|
+
|
|
33
|
+
# Flags
|
|
34
|
+
SHARED_POINT_NUMBERS = 0x8000
|
|
35
|
+
LONG_OFFSETS = 0x0001
|
|
36
|
+
|
|
37
|
+
# Tuple variation header
|
|
38
|
+
class TupleVariationHeader < Binary::BaseRecord
|
|
39
|
+
uint16 :variation_data_size
|
|
40
|
+
uint16 :tuple_index
|
|
41
|
+
|
|
42
|
+
# Tuple index flags
|
|
43
|
+
EMBEDDED_PEAK_TUPLE = 0x8000
|
|
44
|
+
INTERMEDIATE_REGION = 0x4000
|
|
45
|
+
PRIVATE_POINT_NUMBERS = 0x2000
|
|
46
|
+
TUPLE_INDEX_MASK = 0x0FFF
|
|
47
|
+
|
|
48
|
+
# Check if tuple has embedded peak coordinates
|
|
49
|
+
#
|
|
50
|
+
# @return [Boolean] True if embedded
|
|
51
|
+
def embedded_peak_tuple?
|
|
52
|
+
(tuple_index & EMBEDDED_PEAK_TUPLE) != 0
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Check if tuple has intermediate region
|
|
56
|
+
#
|
|
57
|
+
# @return [Boolean] True if intermediate region
|
|
58
|
+
def intermediate_region?
|
|
59
|
+
(tuple_index & INTERMEDIATE_REGION) != 0
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Check if tuple has private point numbers
|
|
63
|
+
#
|
|
64
|
+
# @return [Boolean] True if private points
|
|
65
|
+
def private_point_numbers?
|
|
66
|
+
(tuple_index & PRIVATE_POINT_NUMBERS) != 0
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Get shared tuple index
|
|
70
|
+
#
|
|
71
|
+
# @return [Integer] Tuple index
|
|
72
|
+
def shared_tuple_index
|
|
73
|
+
tuple_index & TUPLE_INDEX_MASK
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Get version as a float
|
|
78
|
+
#
|
|
79
|
+
# @return [Float] Version number (e.g., 1.0)
|
|
80
|
+
def version
|
|
81
|
+
major_version + (minor_version / 10.0)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Check if using long offsets
|
|
85
|
+
#
|
|
86
|
+
# @return [Boolean] True if long offsets
|
|
87
|
+
def long_offsets?
|
|
88
|
+
(flags & LONG_OFFSETS) != 0
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Check if using shared point numbers
|
|
92
|
+
#
|
|
93
|
+
# @return [Boolean] True if shared points
|
|
94
|
+
def shared_point_numbers?
|
|
95
|
+
(flags & SHARED_POINT_NUMBERS) != 0
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Parse shared tuples
|
|
99
|
+
#
|
|
100
|
+
# @return [Array<Array<Integer>>] Shared peak tuples
|
|
101
|
+
def shared_tuples
|
|
102
|
+
return @shared_tuples if @shared_tuples
|
|
103
|
+
return @shared_tuples = [] if shared_tuple_count.zero?
|
|
104
|
+
|
|
105
|
+
data = raw_data
|
|
106
|
+
offset = shared_tuples_offset
|
|
107
|
+
|
|
108
|
+
return @shared_tuples = [] if offset >= data.bytesize
|
|
109
|
+
|
|
110
|
+
@shared_tuples = Array.new(shared_tuple_count) do |i|
|
|
111
|
+
tuple_offset = offset + (i * axis_count * 2)
|
|
112
|
+
|
|
113
|
+
Array.new(axis_count) do |j|
|
|
114
|
+
coord_offset = tuple_offset + (j * 2)
|
|
115
|
+
next nil if coord_offset + 2 > data.bytesize
|
|
116
|
+
|
|
117
|
+
# F2DOT14 format
|
|
118
|
+
value = data.byteslice(coord_offset, 2).unpack1("n")
|
|
119
|
+
signed = value > 0x7FFF ? value - 0x10000 : value
|
|
120
|
+
signed / 16384.0
|
|
121
|
+
end.compact
|
|
122
|
+
end.compact
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Parse glyph variation data offsets
|
|
126
|
+
#
|
|
127
|
+
# @return [Array<Integer>] Array of offsets
|
|
128
|
+
def glyph_variation_data_offsets
|
|
129
|
+
return @glyph_offsets if @glyph_offsets
|
|
130
|
+
|
|
131
|
+
data = raw_data
|
|
132
|
+
# Offsets start after the header (20 bytes)
|
|
133
|
+
offset = 20
|
|
134
|
+
|
|
135
|
+
offset_size = long_offsets? ? 4 : 2
|
|
136
|
+
offset_count = glyph_count + 1 # One extra for the end
|
|
137
|
+
|
|
138
|
+
@glyph_offsets = Array.new(offset_count) do |i|
|
|
139
|
+
offset_pos = offset + (i * offset_size)
|
|
140
|
+
next nil if offset_pos + offset_size > data.bytesize
|
|
141
|
+
|
|
142
|
+
raw_offset = if long_offsets?
|
|
143
|
+
data.byteslice(offset_pos, 4).unpack1("N")
|
|
144
|
+
else
|
|
145
|
+
data.byteslice(offset_pos, 2).unpack1("n") * 2
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
glyph_variation_data_array_offset + raw_offset
|
|
149
|
+
end.compact
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Get variation data for a specific glyph
|
|
153
|
+
#
|
|
154
|
+
# @param glyph_id [Integer] Glyph ID
|
|
155
|
+
# @return [String, nil] Raw variation data or nil
|
|
156
|
+
def glyph_variation_data(glyph_id)
|
|
157
|
+
return nil if glyph_id >= glyph_count
|
|
158
|
+
|
|
159
|
+
offsets = glyph_variation_data_offsets
|
|
160
|
+
return nil if glyph_id >= offsets.length - 1
|
|
161
|
+
|
|
162
|
+
start_offset = offsets[glyph_id]
|
|
163
|
+
end_offset = offsets[glyph_id + 1]
|
|
164
|
+
|
|
165
|
+
return nil if start_offset == end_offset # No data
|
|
166
|
+
|
|
167
|
+
data = raw_data
|
|
168
|
+
return nil if end_offset > data.bytesize
|
|
169
|
+
|
|
170
|
+
data.byteslice(start_offset, end_offset - start_offset)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Parse tuple variation headers for a glyph
|
|
174
|
+
#
|
|
175
|
+
# @param glyph_id [Integer] Glyph ID
|
|
176
|
+
# @return [Array<Hash>, nil] Array of tuple info or nil
|
|
177
|
+
def glyph_tuple_variations(glyph_id)
|
|
178
|
+
var_data = glyph_variation_data(glyph_id)
|
|
179
|
+
return nil if var_data.nil? || var_data.empty?
|
|
180
|
+
|
|
181
|
+
io = StringIO.new(var_data)
|
|
182
|
+
io.set_encoding(Encoding::BINARY)
|
|
183
|
+
|
|
184
|
+
# Read header
|
|
185
|
+
tuple_count_and_offset = io.read(4).unpack1("N")
|
|
186
|
+
tuple_count = tuple_count_and_offset >> 16
|
|
187
|
+
data_offset = tuple_count_and_offset & 0xFFFF
|
|
188
|
+
|
|
189
|
+
# Check for shared point numbers
|
|
190
|
+
has_shared_points = (tuple_count & 0x8000) != 0
|
|
191
|
+
tuple_count &= 0x0FFF
|
|
192
|
+
|
|
193
|
+
return [] if tuple_count.zero?
|
|
194
|
+
|
|
195
|
+
# Parse each tuple
|
|
196
|
+
tuples = []
|
|
197
|
+
tuple_count.times do
|
|
198
|
+
header_data = io.read(4)
|
|
199
|
+
break if header_data.nil? || header_data.bytesize < 4
|
|
200
|
+
|
|
201
|
+
header = TupleVariationHeader.read(header_data)
|
|
202
|
+
|
|
203
|
+
tuple_info = {
|
|
204
|
+
data_size: header.variation_data_size,
|
|
205
|
+
embedded_peak: header.embedded_peak_tuple?,
|
|
206
|
+
intermediate: header.intermediate_region?,
|
|
207
|
+
private_points: header.private_point_numbers?,
|
|
208
|
+
shared_index: header.shared_tuple_index,
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
# Read peak tuple if embedded
|
|
212
|
+
if header.embedded_peak_tuple?
|
|
213
|
+
peak = Array.new(axis_count) do
|
|
214
|
+
coord_data = io.read(2)
|
|
215
|
+
break nil if coord_data.nil?
|
|
216
|
+
|
|
217
|
+
value = coord_data.unpack1("n")
|
|
218
|
+
signed = value > 0x7FFF ? value - 0x10000 : value
|
|
219
|
+
signed / 16384.0
|
|
220
|
+
end
|
|
221
|
+
tuple_info[:peak] = peak.compact
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# Read intermediate region if present
|
|
225
|
+
if header.intermediate_region?
|
|
226
|
+
start_tuple = Array.new(axis_count) do
|
|
227
|
+
coord_data = io.read(2)
|
|
228
|
+
break nil if coord_data.nil?
|
|
229
|
+
|
|
230
|
+
value = coord_data.unpack1("n")
|
|
231
|
+
signed = value > 0x7FFF ? value - 0x10000 : value
|
|
232
|
+
signed / 16384.0
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
end_tuple = Array.new(axis_count) do
|
|
236
|
+
coord_data = io.read(2)
|
|
237
|
+
break nil if coord_data.nil?
|
|
238
|
+
|
|
239
|
+
value = coord_data.unpack1("n")
|
|
240
|
+
signed = value > 0x7FFF ? value - 0x10000 : value
|
|
241
|
+
signed / 16384.0
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
tuple_info[:start] = start_tuple.compact
|
|
245
|
+
tuple_info[:end] = end_tuple.compact
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
tuples << tuple_info
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
{
|
|
252
|
+
tuple_count: tuple_count,
|
|
253
|
+
has_shared_points: has_shared_points,
|
|
254
|
+
data_offset: data_offset,
|
|
255
|
+
tuples: tuples,
|
|
256
|
+
}
|
|
257
|
+
rescue StandardError => e
|
|
258
|
+
warn "Failed to parse glyph tuple variations: #{e.message}"
|
|
259
|
+
nil
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
# Check if table is valid
|
|
263
|
+
#
|
|
264
|
+
# @return [Boolean] True if valid
|
|
265
|
+
def valid?
|
|
266
|
+
major_version == 1 && minor_version.zero?
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
end
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../binary/base_record"
|
|
4
|
+
|
|
5
|
+
module Fontisan
|
|
6
|
+
module Tables
|
|
7
|
+
# BinData structure for the 'hhea' (Horizontal Header) table
|
|
8
|
+
#
|
|
9
|
+
# The hhea table contains horizontal layout metrics for the entire font.
|
|
10
|
+
# It defines font-wide horizontal metrics such as ascent, descent, line
|
|
11
|
+
# gap, and the number of horizontal metrics in the hmtx table.
|
|
12
|
+
#
|
|
13
|
+
# Reference: OpenType specification, hhea table
|
|
14
|
+
# https://docs.microsoft.com/en-us/typography/opentype/spec/hhea
|
|
15
|
+
#
|
|
16
|
+
# @example Reading an hhea table
|
|
17
|
+
# data = File.binread("font.ttf", 36, hhea_offset)
|
|
18
|
+
# hhea = Fontisan::Tables::Hhea.read(data)
|
|
19
|
+
# puts hhea.ascent # => 2048
|
|
20
|
+
# puts hhea.descent # => -512
|
|
21
|
+
# puts hhea.version_number # => 1.0
|
|
22
|
+
class Hhea < Binary::BaseRecord
|
|
23
|
+
# Table size in bytes (fixed size)
|
|
24
|
+
TABLE_SIZE = 36
|
|
25
|
+
|
|
26
|
+
# Version as 16.16 fixed-point (stored as int32)
|
|
27
|
+
# Typically 0x00010000 (1.0)
|
|
28
|
+
int32 :version_raw
|
|
29
|
+
|
|
30
|
+
# Typographic ascent (distance from baseline to highest ascender)
|
|
31
|
+
# Positive value in FUnits
|
|
32
|
+
int16 :ascent
|
|
33
|
+
|
|
34
|
+
# Typographic descent (distance from baseline to lowest descender)
|
|
35
|
+
# Negative value in FUnits
|
|
36
|
+
int16 :descent
|
|
37
|
+
|
|
38
|
+
# Typographic line gap (additional space between lines)
|
|
39
|
+
# Non-negative value in FUnits
|
|
40
|
+
int16 :line_gap
|
|
41
|
+
|
|
42
|
+
# Maximum advance width value in hmtx table
|
|
43
|
+
uint16 :advance_width_max
|
|
44
|
+
|
|
45
|
+
# Minimum left sidebearing value in hmtx table
|
|
46
|
+
int16 :min_left_side_bearing
|
|
47
|
+
|
|
48
|
+
# Minimum right sidebearing value in hmtx table
|
|
49
|
+
int16 :min_right_side_bearing
|
|
50
|
+
|
|
51
|
+
# Maximum extent: max(lsb + (xMax - xMin))
|
|
52
|
+
int16 :x_max_extent
|
|
53
|
+
|
|
54
|
+
# Used to calculate slope of the cursor (rise/run)
|
|
55
|
+
# For vertical text: rise = 1, run = 0
|
|
56
|
+
# For italic text: rise != run
|
|
57
|
+
int16 :caret_slope_rise
|
|
58
|
+
|
|
59
|
+
# Used to calculate slope of the cursor (rise/run)
|
|
60
|
+
# For vertical text: run = 0
|
|
61
|
+
int16 :caret_slope_run
|
|
62
|
+
|
|
63
|
+
# Amount by which slanted highlight should be shifted
|
|
64
|
+
int16 :caret_offset
|
|
65
|
+
|
|
66
|
+
# Reserved fields (must be zero)
|
|
67
|
+
# 4 x int16 = 8 bytes
|
|
68
|
+
skip length: 8
|
|
69
|
+
|
|
70
|
+
# Format of metric data (0 for current format)
|
|
71
|
+
int16 :metric_data_format
|
|
72
|
+
|
|
73
|
+
# Number of hMetric entries in hmtx table
|
|
74
|
+
# Must be >= 1
|
|
75
|
+
uint16 :number_of_h_metrics
|
|
76
|
+
|
|
77
|
+
# Convert version from fixed-point to float
|
|
78
|
+
#
|
|
79
|
+
# @return [Float] Version number (typically 1.0)
|
|
80
|
+
def version
|
|
81
|
+
fixed_to_float(version_raw)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Check if the table is valid
|
|
85
|
+
#
|
|
86
|
+
# @return [Boolean] True if valid, false otherwise
|
|
87
|
+
def valid?
|
|
88
|
+
# Version should be 1.0 (0x00010000)
|
|
89
|
+
return false unless version_raw == 0x00010000
|
|
90
|
+
|
|
91
|
+
# Metric data format must be 0
|
|
92
|
+
return false unless metric_data_format.zero?
|
|
93
|
+
|
|
94
|
+
# Number of metrics must be at least 1
|
|
95
|
+
return false unless number_of_h_metrics >= 1
|
|
96
|
+
|
|
97
|
+
true
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Validate the table and raise error if invalid
|
|
101
|
+
#
|
|
102
|
+
# @raise [Fontisan::CorruptedTableError] If table is invalid
|
|
103
|
+
def validate!
|
|
104
|
+
unless version_raw == 0x00010000
|
|
105
|
+
message = "Invalid hhea version: expected 0x00010000 (1.0), " \
|
|
106
|
+
"got 0x#{version_raw.to_i.to_s(16).upcase}"
|
|
107
|
+
raise Fontisan::CorruptedTableError, message
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
unless metric_data_format.zero?
|
|
111
|
+
message = "Invalid metric data format: expected 0, " \
|
|
112
|
+
"got #{metric_data_format.to_i}"
|
|
113
|
+
raise Fontisan::CorruptedTableError, message
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
unless number_of_h_metrics >= 1
|
|
117
|
+
message = "Invalid number of h metrics: must be >= 1, " \
|
|
118
|
+
"got #{number_of_h_metrics.to_i}"
|
|
119
|
+
raise Fontisan::CorruptedTableError, message
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../binary/base_record"
|
|
4
|
+
|
|
5
|
+
module Fontisan
|
|
6
|
+
module Tables
|
|
7
|
+
# Parser for the 'hmtx' (Horizontal Metrics) table
|
|
8
|
+
#
|
|
9
|
+
# The hmtx table contains horizontal metrics for each glyph in the font.
|
|
10
|
+
# It provides advance width and left sidebearing values needed for proper
|
|
11
|
+
# glyph positioning and text layout.
|
|
12
|
+
#
|
|
13
|
+
# Structure:
|
|
14
|
+
# - hMetrics[numberOfHMetrics]: Array of LongHorMetric records
|
|
15
|
+
# Each record contains:
|
|
16
|
+
# - advanceWidth (uint16): Advance width in FUnits
|
|
17
|
+
# - lsb (int16): Left side bearing in FUnits
|
|
18
|
+
# - leftSideBearings[numGlyphs - numberOfHMetrics]: Array of int16 values
|
|
19
|
+
# Additional LSB values for glyphs beyond numberOfHMetrics
|
|
20
|
+
#
|
|
21
|
+
# The table is context-dependent and requires:
|
|
22
|
+
# - numberOfHMetrics from hhea table
|
|
23
|
+
# - numGlyphs from maxp table
|
|
24
|
+
#
|
|
25
|
+
# Reference: OpenType specification, hmtx table
|
|
26
|
+
# https://docs.microsoft.com/en-us/typography/opentype/spec/hmtx
|
|
27
|
+
#
|
|
28
|
+
# @example Parsing hmtx with context
|
|
29
|
+
# # Get required tables first
|
|
30
|
+
# hhea = font.table('hhea')
|
|
31
|
+
# maxp = font.table('maxp')
|
|
32
|
+
#
|
|
33
|
+
# # Parse hmtx with context
|
|
34
|
+
# data = font.read_table_data('hmtx')
|
|
35
|
+
# hmtx = Fontisan::Tables::Hmtx.read(data)
|
|
36
|
+
# hmtx.parse_with_context(hhea.number_of_h_metrics, maxp.num_glyphs)
|
|
37
|
+
#
|
|
38
|
+
# # Get metrics for a glyph
|
|
39
|
+
# metric = hmtx.metric_for(42)
|
|
40
|
+
# puts "Advance width: #{metric[:advance_width]}"
|
|
41
|
+
# puts "LSB: #{metric[:lsb]}"
|
|
42
|
+
class Hmtx < Binary::BaseRecord
|
|
43
|
+
# LongHorMetric record structure
|
|
44
|
+
#
|
|
45
|
+
# @!attribute advance_width
|
|
46
|
+
# @return [Integer] Advance width in FUnits
|
|
47
|
+
# @!attribute lsb
|
|
48
|
+
# @return [Integer] Left side bearing in FUnits
|
|
49
|
+
class LongHorMetric < Binary::BaseRecord
|
|
50
|
+
uint16 :advance_width
|
|
51
|
+
int16 :lsb
|
|
52
|
+
|
|
53
|
+
# Convert to hash for convenience
|
|
54
|
+
#
|
|
55
|
+
# @return [Hash] Hash with :advance_width and :lsb keys
|
|
56
|
+
def to_h
|
|
57
|
+
{ advance_width: advance_width, lsb: lsb }
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Store the raw data for deferred parsing
|
|
62
|
+
attr_accessor :raw_data
|
|
63
|
+
|
|
64
|
+
# Parsed horizontal metrics array
|
|
65
|
+
# @return [Array<Hash>] Array of metrics hashes
|
|
66
|
+
attr_reader :h_metrics
|
|
67
|
+
|
|
68
|
+
# Parsed left side bearings array
|
|
69
|
+
# @return [Array<Integer>] Array of LSB values
|
|
70
|
+
attr_reader :left_side_bearings
|
|
71
|
+
|
|
72
|
+
# Number of horizontal metrics from hhea table
|
|
73
|
+
# @return [Integer] Number of LongHorMetric records
|
|
74
|
+
attr_reader :number_of_h_metrics
|
|
75
|
+
|
|
76
|
+
# Total number of glyphs from maxp table
|
|
77
|
+
# @return [Integer] Total glyph count
|
|
78
|
+
attr_reader :num_glyphs
|
|
79
|
+
|
|
80
|
+
# Override read to capture raw data
|
|
81
|
+
#
|
|
82
|
+
# @param io [IO, String] Input data
|
|
83
|
+
# @return [Hmtx] Parsed table instance
|
|
84
|
+
def self.read(io)
|
|
85
|
+
instance = new
|
|
86
|
+
|
|
87
|
+
# Handle nil or empty data gracefully
|
|
88
|
+
instance.raw_data = if io.nil?
|
|
89
|
+
"".b
|
|
90
|
+
elsif io.is_a?(String)
|
|
91
|
+
io
|
|
92
|
+
else
|
|
93
|
+
io.read || "".b
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
instance
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Parse the table with font context
|
|
100
|
+
#
|
|
101
|
+
# This method must be called after reading the table data, providing
|
|
102
|
+
# the numberOfHMetrics from hhea and numGlyphs from maxp.
|
|
103
|
+
#
|
|
104
|
+
# @param number_of_h_metrics [Integer] Number of LongHorMetric records (from hhea)
|
|
105
|
+
# @param num_glyphs [Integer] Total number of glyphs (from maxp)
|
|
106
|
+
# @raise [ArgumentError] If context parameters are invalid
|
|
107
|
+
# @raise [Fontisan::CorruptedTableError] If table data is insufficient
|
|
108
|
+
def parse_with_context(number_of_h_metrics, num_glyphs)
|
|
109
|
+
validate_context_params(number_of_h_metrics, num_glyphs)
|
|
110
|
+
|
|
111
|
+
@number_of_h_metrics = number_of_h_metrics
|
|
112
|
+
@num_glyphs = num_glyphs
|
|
113
|
+
|
|
114
|
+
io = StringIO.new(raw_data)
|
|
115
|
+
io.set_encoding(Encoding::BINARY)
|
|
116
|
+
|
|
117
|
+
# Parse hMetrics array
|
|
118
|
+
@h_metrics = parse_h_metrics(io, number_of_h_metrics)
|
|
119
|
+
|
|
120
|
+
# Parse additional left side bearings
|
|
121
|
+
lsb_count = num_glyphs - number_of_h_metrics
|
|
122
|
+
@left_side_bearings = parse_left_side_bearings(io, lsb_count)
|
|
123
|
+
|
|
124
|
+
validate_parsed_data!(io)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Get horizontal metrics for a specific glyph ID
|
|
128
|
+
#
|
|
129
|
+
# For glyph IDs less than numberOfHMetrics, returns the corresponding
|
|
130
|
+
# hMetrics entry. For glyph IDs >= numberOfHMetrics, uses the last
|
|
131
|
+
# advance width from hMetrics with the indexed left side bearing.
|
|
132
|
+
#
|
|
133
|
+
# @param glyph_id [Integer] Glyph ID (0-based)
|
|
134
|
+
# @return [Hash, nil] Hash with :advance_width and :lsb keys, or nil if invalid
|
|
135
|
+
# @raise [RuntimeError] If table has not been parsed with context
|
|
136
|
+
#
|
|
137
|
+
# @example Getting metrics
|
|
138
|
+
# metric = hmtx.metric_for(0) # .notdef glyph
|
|
139
|
+
# metric = hmtx.metric_for(65) # 'A' glyph (if mapped to 65)
|
|
140
|
+
def metric_for(glyph_id)
|
|
141
|
+
raise "Table not parsed. Call parse_with_context first." unless @h_metrics
|
|
142
|
+
|
|
143
|
+
return nil if glyph_id >= num_glyphs || glyph_id.negative?
|
|
144
|
+
|
|
145
|
+
if glyph_id < h_metrics.length
|
|
146
|
+
# Direct lookup in hMetrics array
|
|
147
|
+
h_metrics[glyph_id]
|
|
148
|
+
else
|
|
149
|
+
# Use last advance width with indexed LSB
|
|
150
|
+
lsb_index = glyph_id - h_metrics.length
|
|
151
|
+
{
|
|
152
|
+
advance_width: h_metrics.last[:advance_width],
|
|
153
|
+
lsb: left_side_bearings[lsb_index],
|
|
154
|
+
}
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Check if the table has been parsed with context
|
|
159
|
+
#
|
|
160
|
+
# @return [Boolean] True if parsed, false otherwise
|
|
161
|
+
def parsed?
|
|
162
|
+
!@h_metrics.nil?
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Get the expected minimum size for this table
|
|
166
|
+
#
|
|
167
|
+
# @return [Integer] Minimum size in bytes, or nil if not parsed
|
|
168
|
+
def expected_min_size
|
|
169
|
+
return nil unless parsed?
|
|
170
|
+
|
|
171
|
+
# numberOfHMetrics × 4 bytes (uint16 + int16)
|
|
172
|
+
# + (numGlyphs - numberOfHMetrics) × 2 bytes (int16)
|
|
173
|
+
(number_of_h_metrics * 4) + ((num_glyphs - number_of_h_metrics) * 2)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
private
|
|
177
|
+
|
|
178
|
+
# Validate context parameters
|
|
179
|
+
#
|
|
180
|
+
# @param number_of_h_metrics [Integer] Number of hMetrics
|
|
181
|
+
# @param num_glyphs [Integer] Total glyphs
|
|
182
|
+
# @raise [ArgumentError] If parameters are invalid
|
|
183
|
+
def validate_context_params(number_of_h_metrics, num_glyphs)
|
|
184
|
+
if number_of_h_metrics.nil? || number_of_h_metrics < 1
|
|
185
|
+
raise ArgumentError,
|
|
186
|
+
"numberOfHMetrics must be >= 1, got: #{number_of_h_metrics.inspect}"
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
if num_glyphs.nil? || num_glyphs < 1
|
|
190
|
+
raise ArgumentError,
|
|
191
|
+
"numGlyphs must be >= 1, got: #{num_glyphs.inspect}"
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
if number_of_h_metrics > num_glyphs
|
|
195
|
+
raise ArgumentError,
|
|
196
|
+
"numberOfHMetrics (#{number_of_h_metrics}) cannot exceed " \
|
|
197
|
+
"numGlyphs (#{num_glyphs})"
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Parse horizontal metrics array
|
|
202
|
+
#
|
|
203
|
+
# @param io [StringIO] Input stream
|
|
204
|
+
# @param count [Integer] Number of metrics to parse
|
|
205
|
+
# @return [Array<Hash>] Array of metric hashes
|
|
206
|
+
# @raise [Fontisan::CorruptedTableError] If insufficient data
|
|
207
|
+
def parse_h_metrics(io, count)
|
|
208
|
+
metrics = []
|
|
209
|
+
count.times do |i|
|
|
210
|
+
advance_width = read_uint16(io)
|
|
211
|
+
lsb = read_int16(io)
|
|
212
|
+
|
|
213
|
+
if advance_width.nil? || lsb.nil?
|
|
214
|
+
raise Fontisan::CorruptedTableError,
|
|
215
|
+
"Insufficient data for hMetric at index #{i}"
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
metrics << { advance_width: advance_width, lsb: lsb }
|
|
219
|
+
end
|
|
220
|
+
metrics
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# Parse left side bearings array
|
|
224
|
+
#
|
|
225
|
+
# @param io [StringIO] Input stream
|
|
226
|
+
# @param count [Integer] Number of LSBs to parse
|
|
227
|
+
# @return [Array<Integer>] Array of LSB values
|
|
228
|
+
# @raise [Fontisan::CorruptedTableError] If insufficient data
|
|
229
|
+
def parse_left_side_bearings(io, count)
|
|
230
|
+
return [] if count.zero?
|
|
231
|
+
|
|
232
|
+
lsbs = []
|
|
233
|
+
count.times do |i|
|
|
234
|
+
lsb = read_int16(io)
|
|
235
|
+
|
|
236
|
+
if lsb.nil?
|
|
237
|
+
raise Fontisan::CorruptedTableError,
|
|
238
|
+
"Insufficient data for LSB at index #{i}"
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
lsbs << lsb
|
|
242
|
+
end
|
|
243
|
+
lsbs
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# Validate that all expected data was parsed
|
|
247
|
+
#
|
|
248
|
+
# @param io [StringIO] Input stream
|
|
249
|
+
# @raise [Fontisan::CorruptedTableError] If unexpected data remains
|
|
250
|
+
def validate_parsed_data!(io)
|
|
251
|
+
remaining = io.read
|
|
252
|
+
return if remaining.nil? || remaining.empty?
|
|
253
|
+
|
|
254
|
+
# Some fonts may have padding, which is acceptable
|
|
255
|
+
# Only warn if there's more than 3 bytes of extra data
|
|
256
|
+
if remaining.length > 3
|
|
257
|
+
warn "Warning: hmtx table has #{remaining.length} unexpected " \
|
|
258
|
+
"bytes after parsing"
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
# Read unsigned 16-bit integer
|
|
263
|
+
#
|
|
264
|
+
# @param io [StringIO] Input stream
|
|
265
|
+
# @return [Integer, nil] Value or nil if insufficient data
|
|
266
|
+
def read_uint16(io)
|
|
267
|
+
data = io.read(2)
|
|
268
|
+
return nil if data.nil? || data.length < 2
|
|
269
|
+
|
|
270
|
+
data.unpack1("n") # Big-endian unsigned 16-bit
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
# Read signed 16-bit integer
|
|
274
|
+
#
|
|
275
|
+
# @param io [StringIO] Input stream
|
|
276
|
+
# @return [Integer, nil] Value or nil if insufficient data
|
|
277
|
+
def read_int16(io)
|
|
278
|
+
data = io.read(2)
|
|
279
|
+
return nil if data.nil? || data.length < 2
|
|
280
|
+
|
|
281
|
+
# Unpack as unsigned, then convert to signed
|
|
282
|
+
value = data.unpack1("n")
|
|
283
|
+
value >= 0x8000 ? value - 0x10000 : value
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
end
|