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,185 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../binary/base_record"
|
|
4
|
+
require_relative "variation_common"
|
|
5
|
+
|
|
6
|
+
module Fontisan
|
|
7
|
+
module Tables
|
|
8
|
+
# Parser for the 'MVAR' (Metrics Variations) table
|
|
9
|
+
#
|
|
10
|
+
# The MVAR table provides variation data for global font metrics such as:
|
|
11
|
+
# - Ascender and descender
|
|
12
|
+
# - Line gap
|
|
13
|
+
# - Caret offsets
|
|
14
|
+
# - Strikeout and underline positions/sizes
|
|
15
|
+
# - Subscript and superscript sizes
|
|
16
|
+
#
|
|
17
|
+
# Each metric is identified by a tag and references delta sets in the
|
|
18
|
+
# ItemVariationStore.
|
|
19
|
+
#
|
|
20
|
+
# Reference: OpenType specification, MVAR table
|
|
21
|
+
#
|
|
22
|
+
# @example Reading an MVAR table
|
|
23
|
+
# data = font.table_data("MVAR")
|
|
24
|
+
# mvar = Fontisan::Tables::Mvar.read(data)
|
|
25
|
+
# hasc_deltas = mvar.metric_deltas("hasc")
|
|
26
|
+
class Mvar < Binary::BaseRecord
|
|
27
|
+
uint16 :major_version
|
|
28
|
+
uint16 :minor_version
|
|
29
|
+
uint16 :reserved
|
|
30
|
+
uint16 :value_record_size
|
|
31
|
+
uint16 :value_record_count
|
|
32
|
+
uint32 :item_variation_store_offset
|
|
33
|
+
|
|
34
|
+
# Value tags for standard metrics
|
|
35
|
+
METRIC_TAGS = {
|
|
36
|
+
"hasc" => :horizontal_ascender,
|
|
37
|
+
"hdsc" => :horizontal_descender,
|
|
38
|
+
"hlgp" => :horizontal_line_gap,
|
|
39
|
+
"hcla" => :horizontal_caret_ascender,
|
|
40
|
+
"hcld" => :horizontal_caret_descender,
|
|
41
|
+
"hcof" => :horizontal_caret_offset,
|
|
42
|
+
"vasc" => :vertical_ascender,
|
|
43
|
+
"vdsc" => :vertical_descender,
|
|
44
|
+
"vlgp" => :vertical_line_gap,
|
|
45
|
+
"vcof" => :vertical_caret_offset,
|
|
46
|
+
"xhgt" => :x_height,
|
|
47
|
+
"cpht" => :cap_height,
|
|
48
|
+
"sbxs" => :subscript_em_x_size,
|
|
49
|
+
"sbys" => :subscript_em_y_size,
|
|
50
|
+
"sbxo" => :subscript_em_x_offset,
|
|
51
|
+
"sbyo" => :subscript_em_y_offset,
|
|
52
|
+
"spxs" => :superscript_em_x_size,
|
|
53
|
+
"spys" => :superscript_em_y_size,
|
|
54
|
+
"spxo" => :superscript_em_x_offset,
|
|
55
|
+
"spyo" => :superscript_em_y_offset,
|
|
56
|
+
"strs" => :strikeout_size,
|
|
57
|
+
"stro" => :strikeout_offset,
|
|
58
|
+
"unds" => :underline_size,
|
|
59
|
+
"undo" => :underline_offset,
|
|
60
|
+
}.freeze
|
|
61
|
+
|
|
62
|
+
# Value record structure
|
|
63
|
+
class ValueRecord < Binary::BaseRecord
|
|
64
|
+
string :value_tag, length: 4
|
|
65
|
+
uint32 :delta_set_outer_index
|
|
66
|
+
uint32 :delta_set_inner_index
|
|
67
|
+
|
|
68
|
+
# Get the metric name for this value tag
|
|
69
|
+
#
|
|
70
|
+
# @return [Symbol, nil] Metric name or nil
|
|
71
|
+
def metric_name
|
|
72
|
+
METRIC_TAGS[value_tag]
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Get version as a float
|
|
77
|
+
#
|
|
78
|
+
# @return [Float] Version number (e.g., 1.0)
|
|
79
|
+
def version
|
|
80
|
+
major_version + (minor_version / 10.0)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Parse the item variation store
|
|
84
|
+
#
|
|
85
|
+
# @return [VariationCommon::ItemVariationStore, nil] Variation store
|
|
86
|
+
def item_variation_store
|
|
87
|
+
return @item_variation_store if defined?(@item_variation_store)
|
|
88
|
+
return @item_variation_store = nil if item_variation_store_offset.zero?
|
|
89
|
+
|
|
90
|
+
data = raw_data
|
|
91
|
+
offset = item_variation_store_offset
|
|
92
|
+
|
|
93
|
+
return @item_variation_store = nil if offset >= data.bytesize
|
|
94
|
+
|
|
95
|
+
store_data = data.byteslice(offset..-1)
|
|
96
|
+
@item_variation_store = VariationCommon::ItemVariationStore.read(store_data)
|
|
97
|
+
rescue StandardError => e
|
|
98
|
+
warn "Failed to parse MVAR item variation store: #{e.message}"
|
|
99
|
+
@item_variation_store = nil
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Parse value records
|
|
103
|
+
#
|
|
104
|
+
# @return [Array<ValueRecord>] Value records
|
|
105
|
+
def value_records
|
|
106
|
+
return @value_records if @value_records
|
|
107
|
+
return @value_records = [] if value_record_count.zero?
|
|
108
|
+
|
|
109
|
+
data = raw_data
|
|
110
|
+
# Value records start after the header (14 bytes: 2+2+2+2+2+4)
|
|
111
|
+
offset = 14
|
|
112
|
+
|
|
113
|
+
@value_records = Array.new(value_record_count) do |i|
|
|
114
|
+
record_offset = offset + (i * value_record_size)
|
|
115
|
+
|
|
116
|
+
next nil if record_offset + value_record_size > data.bytesize
|
|
117
|
+
|
|
118
|
+
record_data = data.byteslice(record_offset, value_record_size)
|
|
119
|
+
ValueRecord.read(record_data)
|
|
120
|
+
end.compact
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Get value record by tag
|
|
124
|
+
#
|
|
125
|
+
# @param tag [String] Value tag (e.g., "hasc", "hdsc")
|
|
126
|
+
# @return [ValueRecord, nil] Value record or nil
|
|
127
|
+
def value_record(tag)
|
|
128
|
+
value_records.find { |record| record.value_tag.to_s == tag }
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Get delta set for a specific metric tag
|
|
132
|
+
#
|
|
133
|
+
# @param tag [String] Value tag (e.g., "hasc", "hdsc")
|
|
134
|
+
# @return [Array<Integer>, nil] Delta values or nil
|
|
135
|
+
def metric_delta_set(tag)
|
|
136
|
+
return nil unless item_variation_store
|
|
137
|
+
|
|
138
|
+
record = value_record(tag)
|
|
139
|
+
return nil if record.nil?
|
|
140
|
+
|
|
141
|
+
item_variation_store.delta_set(
|
|
142
|
+
record.delta_set_outer_index,
|
|
143
|
+
record.delta_set_inner_index,
|
|
144
|
+
)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Get all metric tags present in this table
|
|
148
|
+
#
|
|
149
|
+
# @return [Array<String>] Array of metric tags
|
|
150
|
+
def metric_tags
|
|
151
|
+
value_records.map { |record| record.value_tag.to_s }
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Get all metrics as a hash
|
|
155
|
+
#
|
|
156
|
+
# @return [Hash<String, Hash>] Hash of metric tag to record info
|
|
157
|
+
def metrics
|
|
158
|
+
value_records.each_with_object({}) do |record, hash|
|
|
159
|
+
# Strip trailing nulls from value_tag
|
|
160
|
+
tag = record.value_tag.delete("\x00")
|
|
161
|
+
hash[tag] = {
|
|
162
|
+
name: record.metric_name,
|
|
163
|
+
outer_index: record.delta_set_outer_index,
|
|
164
|
+
inner_index: record.delta_set_inner_index,
|
|
165
|
+
}
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Check if a specific metric is present
|
|
170
|
+
#
|
|
171
|
+
# @param tag [String] Value tag
|
|
172
|
+
# @return [Boolean] True if metric is present
|
|
173
|
+
def has_metric?(tag)
|
|
174
|
+
!value_record(tag).nil?
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Check if table is valid
|
|
178
|
+
#
|
|
179
|
+
# @return [Boolean] True if valid
|
|
180
|
+
def valid?
|
|
181
|
+
major_version == 1 && minor_version.zero?
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
data/lib/fontisan/tables/name.rb
CHANGED
|
@@ -107,9 +107,13 @@ module Fontisan
|
|
|
107
107
|
array :name_records, type: :name_record, initial_length: :record_count
|
|
108
108
|
rest :string_storage
|
|
109
109
|
|
|
110
|
+
# Cache for decoded names
|
|
111
|
+
attr_accessor :decoded_names_cache
|
|
112
|
+
|
|
110
113
|
# Hook that gets called after all fields are read
|
|
111
114
|
def after_read_hook
|
|
112
|
-
|
|
115
|
+
# Don't decode anything yet - wait for request
|
|
116
|
+
@decoded_names_cache = {}
|
|
113
117
|
end
|
|
114
118
|
|
|
115
119
|
# Make sure we call our hook after BinData finishes reading
|
|
@@ -125,6 +129,35 @@ module Fontisan
|
|
|
125
129
|
record_count
|
|
126
130
|
end
|
|
127
131
|
|
|
132
|
+
# Decode all strings from the string storage area
|
|
133
|
+
#
|
|
134
|
+
# This method can be called explicitly to decode all name records upfront.
|
|
135
|
+
# Useful for testing or when you know you'll need all strings.
|
|
136
|
+
# By default, strings are decoded lazily on demand.
|
|
137
|
+
#
|
|
138
|
+
# @return [void]
|
|
139
|
+
def decode_all_strings
|
|
140
|
+
# Get the raw string storage as a plain Ruby binary string
|
|
141
|
+
storage_bytes = string_storage.to_s.b
|
|
142
|
+
|
|
143
|
+
return if storage_bytes.empty?
|
|
144
|
+
|
|
145
|
+
name_records.each do |record|
|
|
146
|
+
# Extract string data from storage using offset and length
|
|
147
|
+
offset = record.string_offset
|
|
148
|
+
length = record.string_length
|
|
149
|
+
|
|
150
|
+
# Validate bounds
|
|
151
|
+
next if offset.nil? || length.nil?
|
|
152
|
+
next if offset + length > storage_bytes.bytesize
|
|
153
|
+
next if length.zero?
|
|
154
|
+
|
|
155
|
+
# Slice the bytes from storage
|
|
156
|
+
string_data = storage_bytes.byteslice(offset, length)
|
|
157
|
+
record.decode_string(string_data) if string_data && !string_data.empty?
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
128
161
|
# Find an English name for the given name ID
|
|
129
162
|
#
|
|
130
163
|
# Priority: Platform 3 (Windows) with language 0x0409 (US English)
|
|
@@ -133,21 +166,28 @@ module Fontisan
|
|
|
133
166
|
# @param name_id [Integer] The name ID to search for
|
|
134
167
|
# @return [String, nil] The decoded string or nil if not found
|
|
135
168
|
def english_name(name_id)
|
|
136
|
-
#
|
|
137
|
-
|
|
138
|
-
rec.name_id == name_id &&
|
|
139
|
-
rec.platform_id == PLATFORM_WINDOWS &&
|
|
140
|
-
rec.language_id == WINDOWS_LANGUAGE_EN_US
|
|
141
|
-
end
|
|
169
|
+
# Check cache first
|
|
170
|
+
return @decoded_names_cache[name_id] if @decoded_names_cache.key?(name_id)
|
|
142
171
|
|
|
143
|
-
#
|
|
144
|
-
record
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
172
|
+
# Find record (don't decode yet)
|
|
173
|
+
record = find_name_record(
|
|
174
|
+
name_id,
|
|
175
|
+
platform: PLATFORM_WINDOWS,
|
|
176
|
+
language: WINDOWS_LANGUAGE_EN_US
|
|
177
|
+
)
|
|
149
178
|
|
|
150
|
-
record
|
|
179
|
+
record ||= find_name_record(
|
|
180
|
+
name_id,
|
|
181
|
+
platform: PLATFORM_MACINTOSH,
|
|
182
|
+
language: MAC_LANGUAGE_ENGLISH
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
return nil unless record
|
|
186
|
+
|
|
187
|
+
# Decode only this one record
|
|
188
|
+
decoded = decode_name_record(record)
|
|
189
|
+
@decoded_names_cache[name_id] = decoded
|
|
190
|
+
decoded
|
|
151
191
|
end
|
|
152
192
|
|
|
153
193
|
# Validate the table
|
|
@@ -161,27 +201,56 @@ module Fontisan
|
|
|
161
201
|
|
|
162
202
|
private
|
|
163
203
|
|
|
164
|
-
#
|
|
165
|
-
|
|
166
|
-
|
|
204
|
+
# Find a name record matching the criteria
|
|
205
|
+
#
|
|
206
|
+
# @param name_id [Integer] The name ID to search for
|
|
207
|
+
# @param platform [Integer] The platform ID
|
|
208
|
+
# @param language [Integer] The language ID
|
|
209
|
+
# @return [NameRecord, nil] The matching record or nil
|
|
210
|
+
def find_name_record(name_id, platform:, language:)
|
|
211
|
+
name_records.find do |rec|
|
|
212
|
+
rec.name_id == name_id &&
|
|
213
|
+
rec.platform_id == platform &&
|
|
214
|
+
rec.language_id == language
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# Decode a single name record on demand
|
|
219
|
+
#
|
|
220
|
+
# @param record [NameRecord] The record to decode
|
|
221
|
+
# @return [String] The decoded string
|
|
222
|
+
def decode_name_record(record)
|
|
223
|
+
# Get raw string storage
|
|
167
224
|
storage_bytes = string_storage.to_s.b
|
|
168
225
|
|
|
169
|
-
|
|
226
|
+
# Extract this record's string
|
|
227
|
+
offset = record.string_offset
|
|
228
|
+
length = record.string_length
|
|
170
229
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
offset = record.string_offset
|
|
174
|
-
length = record.string_length
|
|
230
|
+
return nil if offset + length > storage_bytes.bytesize
|
|
231
|
+
return nil if length.zero?
|
|
175
232
|
|
|
176
|
-
|
|
177
|
-
next if offset.nil? || length.nil?
|
|
178
|
-
next if offset + length > storage_bytes.bytesize
|
|
179
|
-
next if length.zero?
|
|
233
|
+
string_data = storage_bytes.byteslice(offset, length)
|
|
180
234
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
235
|
+
# Decode based on platform
|
|
236
|
+
decoded = case record.platform_id
|
|
237
|
+
when PLATFORM_WINDOWS, PLATFORM_UNICODE
|
|
238
|
+
string_data.dup.force_encoding("UTF-16BE")
|
|
239
|
+
.encode("UTF-8", invalid: :replace, undef: :replace)
|
|
240
|
+
when PLATFORM_MACINTOSH
|
|
241
|
+
string_data.dup.force_encoding("ASCII-8BIT")
|
|
242
|
+
.encode("UTF-8", invalid: :replace, undef: :replace)
|
|
243
|
+
else
|
|
244
|
+
string_data.dup.force_encoding("UTF-8")
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# Intern common strings to reduce memory usage
|
|
248
|
+
interned = Fontisan::Constants.intern_string(decoded)
|
|
249
|
+
|
|
250
|
+
# Also populate the record's string attribute for backward compatibility
|
|
251
|
+
record.string = interned
|
|
252
|
+
|
|
253
|
+
interned
|
|
185
254
|
end
|
|
186
255
|
end
|
|
187
256
|
end
|
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "stringio"
|
|
4
|
+
require_relative "../binary/base_record"
|
|
5
|
+
|
|
6
|
+
module Fontisan
|
|
7
|
+
module Tables
|
|
8
|
+
# Shared structures for OpenType variation tables (HVAR, VVAR, MVAR, etc.)
|
|
9
|
+
#
|
|
10
|
+
# These structures are used across multiple variation tables to define
|
|
11
|
+
# variation regions in design space and organize delta values that are
|
|
12
|
+
# applied based on axis coordinates.
|
|
13
|
+
#
|
|
14
|
+
# Reference: OpenType specification, Variation Common Table Formats
|
|
15
|
+
module VariationCommon
|
|
16
|
+
# Variation region in design space
|
|
17
|
+
#
|
|
18
|
+
# A region is defined by ranges on one or more axes. Each region has a
|
|
19
|
+
# scalar value (0.0 to 1.0) that determines how much its deltas contribute
|
|
20
|
+
# based on the current design coordinates.
|
|
21
|
+
class RegionAxisCoordinates < Binary::BaseRecord
|
|
22
|
+
int16 :start_coord # Start of region range (F2DOT14)
|
|
23
|
+
int16 :peak_coord # Peak value in range (F2DOT14)
|
|
24
|
+
int16 :end_coord # End of region range (F2DOT14)
|
|
25
|
+
|
|
26
|
+
# Convert start coordinate from F2DOT14 to float
|
|
27
|
+
#
|
|
28
|
+
# @return [Float] Start coordinate value
|
|
29
|
+
def start
|
|
30
|
+
f2dot14_to_float(start_coord)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Convert peak coordinate from F2DOT14 to float
|
|
34
|
+
#
|
|
35
|
+
# @return [Float] Peak coordinate value
|
|
36
|
+
def peak
|
|
37
|
+
f2dot14_to_float(peak_coord)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Convert end coordinate from F2DOT14 to float
|
|
41
|
+
#
|
|
42
|
+
# @return [Float] End coordinate value
|
|
43
|
+
def end_value
|
|
44
|
+
f2dot14_to_float(end_coord)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
# Convert F2DOT14 fixed-point (2.14) to float
|
|
50
|
+
#
|
|
51
|
+
# @param value [Integer] F2DOT14 value
|
|
52
|
+
# @return [Float] Floating-point value
|
|
53
|
+
def f2dot14_to_float(value)
|
|
54
|
+
# Handle signed 16-bit value
|
|
55
|
+
signed = value > 0x7FFF ? value - 0x10000 : value
|
|
56
|
+
signed / 16384.0
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Variation region definition
|
|
61
|
+
#
|
|
62
|
+
# Defines a region in design space with coordinate ranges for each axis.
|
|
63
|
+
class VariationRegion < Binary::BaseRecord
|
|
64
|
+
# Region axis coordinates array - length determined by axis count
|
|
65
|
+
# This is manually parsed by the parent VariationRegionList
|
|
66
|
+
|
|
67
|
+
# Parse region axis coordinates
|
|
68
|
+
#
|
|
69
|
+
# @param data [String] Binary data
|
|
70
|
+
# @param axis_count [Integer] Number of axes
|
|
71
|
+
# @return [Array<RegionAxisCoordinates>] Axis coordinates
|
|
72
|
+
def self.parse_coordinates(data, axis_count)
|
|
73
|
+
io = StringIO.new(data)
|
|
74
|
+
io.set_encoding(Encoding::BINARY)
|
|
75
|
+
|
|
76
|
+
Array.new(axis_count) do
|
|
77
|
+
coord_data = io.read(6) # 3 * int16
|
|
78
|
+
next nil if coord_data.nil? || coord_data.bytesize < 6
|
|
79
|
+
|
|
80
|
+
RegionAxisCoordinates.read(coord_data)
|
|
81
|
+
end.compact
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Variation region list
|
|
86
|
+
#
|
|
87
|
+
# Contains the regions used by variation data. Multiple variation tables
|
|
88
|
+
# can reference the same region list.
|
|
89
|
+
class VariationRegionList < Binary::BaseRecord
|
|
90
|
+
uint16 :axis_count
|
|
91
|
+
uint16 :region_count
|
|
92
|
+
|
|
93
|
+
# Parse all variation regions
|
|
94
|
+
#
|
|
95
|
+
# @return [Array<Array<RegionAxisCoordinates>>] Array of regions
|
|
96
|
+
def regions
|
|
97
|
+
return @regions if @regions
|
|
98
|
+
return @regions = [] if region_count.zero?
|
|
99
|
+
|
|
100
|
+
data = raw_data
|
|
101
|
+
offset = 4 # After axis_count and region_count
|
|
102
|
+
|
|
103
|
+
@regions = Array.new(region_count) do |i|
|
|
104
|
+
region_offset = offset + (i * axis_count * 6)
|
|
105
|
+
region_size = axis_count * 6
|
|
106
|
+
|
|
107
|
+
next nil if region_offset + region_size > data.bytesize
|
|
108
|
+
|
|
109
|
+
region_data = data.byteslice(region_offset, region_size)
|
|
110
|
+
VariationRegion.parse_coordinates(region_data, axis_count)
|
|
111
|
+
end.compact
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Item variation data
|
|
116
|
+
#
|
|
117
|
+
# Contains delta values for a set of items. Each item can have deltas
|
|
118
|
+
# for multiple regions.
|
|
119
|
+
class ItemVariationData < Binary::BaseRecord
|
|
120
|
+
uint16 :item_count
|
|
121
|
+
uint16 :short_delta_count
|
|
122
|
+
uint16 :region_index_count
|
|
123
|
+
|
|
124
|
+
# Parse region indices
|
|
125
|
+
#
|
|
126
|
+
# @return [Array<Integer>] Region indices
|
|
127
|
+
def region_indices
|
|
128
|
+
return @region_indices if @region_indices
|
|
129
|
+
return @region_indices = [] if region_index_count.zero?
|
|
130
|
+
|
|
131
|
+
data = raw_data
|
|
132
|
+
offset = 6 # After header fields
|
|
133
|
+
|
|
134
|
+
@region_indices = Array.new(region_index_count) do |i|
|
|
135
|
+
idx_offset = offset + (i * 2)
|
|
136
|
+
next nil if idx_offset + 2 > data.bytesize
|
|
137
|
+
|
|
138
|
+
data.byteslice(idx_offset, 2).unpack1("n")
|
|
139
|
+
end.compact
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Parse delta sets for all items
|
|
143
|
+
#
|
|
144
|
+
# @return [Array<Array<Integer>>] Delta sets for each item
|
|
145
|
+
def delta_sets
|
|
146
|
+
return @delta_sets if @delta_sets
|
|
147
|
+
return @delta_sets = [] if item_count.zero?
|
|
148
|
+
|
|
149
|
+
data = raw_data
|
|
150
|
+
# Delta data starts after header and region indices
|
|
151
|
+
offset = 6 + (region_index_count * 2)
|
|
152
|
+
|
|
153
|
+
# Each item has region_index_count deltas
|
|
154
|
+
# short_delta_count are int16, rest are int8
|
|
155
|
+
long_count = region_index_count - short_delta_count
|
|
156
|
+
|
|
157
|
+
# Safety check: long_count should not be negative
|
|
158
|
+
if long_count.negative?
|
|
159
|
+
warn "ItemVariationData parsing error: short_delta_count (#{short_delta_count}) > region_index_count (#{region_index_count})"
|
|
160
|
+
return @delta_sets = []
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
@delta_sets = Array.new(item_count) do |i|
|
|
164
|
+
item_offset = offset + (i * (short_delta_count * 2 + long_count))
|
|
165
|
+
|
|
166
|
+
# Read short deltas (int16)
|
|
167
|
+
shorts = Array.new(short_delta_count) do |j|
|
|
168
|
+
delta_offset = item_offset + (j * 2)
|
|
169
|
+
next nil if delta_offset + 2 > data.bytesize
|
|
170
|
+
|
|
171
|
+
# Signed 16-bit
|
|
172
|
+
value = data.byteslice(delta_offset, 2).unpack1("n")
|
|
173
|
+
value > 0x7FFF ? value - 0x10000 : value
|
|
174
|
+
end.compact
|
|
175
|
+
|
|
176
|
+
# Read long deltas (int8)
|
|
177
|
+
longs = Array.new(long_count) do |j|
|
|
178
|
+
delta_offset = item_offset + (short_delta_count * 2) + j
|
|
179
|
+
next nil if delta_offset + 1 > data.bytesize
|
|
180
|
+
|
|
181
|
+
# Signed 8-bit
|
|
182
|
+
value = data.byteslice(delta_offset, 1).unpack1("C")
|
|
183
|
+
value > 0x7F ? value - 0x100 : value
|
|
184
|
+
end.compact
|
|
185
|
+
|
|
186
|
+
shorts + longs
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# Item variation store
|
|
192
|
+
#
|
|
193
|
+
# Hierarchical storage for delta values. Contains variation data entries
|
|
194
|
+
# and a region list that defines variation regions in design space.
|
|
195
|
+
#
|
|
196
|
+
# Used by: HVAR, VVAR, MVAR tables
|
|
197
|
+
class ItemVariationStore < Binary::BaseRecord
|
|
198
|
+
uint16 :format
|
|
199
|
+
uint32 :variation_region_list_offset
|
|
200
|
+
uint16 :item_variation_data_count
|
|
201
|
+
|
|
202
|
+
# Parse variation region list
|
|
203
|
+
#
|
|
204
|
+
# @return [VariationRegionList, nil] Region list or nil
|
|
205
|
+
def variation_region_list
|
|
206
|
+
return @variation_region_list if defined?(@variation_region_list)
|
|
207
|
+
return @variation_region_list = nil if variation_region_list_offset.zero?
|
|
208
|
+
|
|
209
|
+
data = raw_data
|
|
210
|
+
offset = variation_region_list_offset
|
|
211
|
+
|
|
212
|
+
return @variation_region_list = nil if offset >= data.bytesize
|
|
213
|
+
|
|
214
|
+
region_data = data.byteslice(offset..-1)
|
|
215
|
+
@variation_region_list = VariationRegionList.read(region_data)
|
|
216
|
+
rescue StandardError
|
|
217
|
+
@variation_region_list = nil
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# Parse item variation data offsets
|
|
221
|
+
#
|
|
222
|
+
# @return [Array<Integer>] Offsets to ItemVariationData
|
|
223
|
+
def item_variation_data_offsets
|
|
224
|
+
return @data_offsets if @data_offsets
|
|
225
|
+
return @data_offsets = [] if item_variation_data_count.zero?
|
|
226
|
+
|
|
227
|
+
data = raw_data
|
|
228
|
+
offset = 8 # After header fields
|
|
229
|
+
|
|
230
|
+
@data_offsets = Array.new(item_variation_data_count) do |i|
|
|
231
|
+
offset_pos = offset + (i * 4)
|
|
232
|
+
next nil if offset_pos + 4 > data.bytesize
|
|
233
|
+
|
|
234
|
+
data.byteslice(offset_pos, 4).unpack1("N")
|
|
235
|
+
end.compact
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# Parse all item variation data entries
|
|
239
|
+
#
|
|
240
|
+
# @return [Array<ItemVariationData>] Variation data entries
|
|
241
|
+
def item_variation_data_entries
|
|
242
|
+
return @data_entries if @data_entries
|
|
243
|
+
return @data_entries = [] if item_variation_data_count.zero?
|
|
244
|
+
|
|
245
|
+
data = raw_data
|
|
246
|
+
offsets = item_variation_data_offsets
|
|
247
|
+
|
|
248
|
+
@data_entries = offsets.map do |data_offset|
|
|
249
|
+
next nil if data_offset >= data.bytesize
|
|
250
|
+
|
|
251
|
+
entry_data = data.byteslice(data_offset..-1)
|
|
252
|
+
ItemVariationData.read(entry_data)
|
|
253
|
+
end.compact
|
|
254
|
+
rescue StandardError
|
|
255
|
+
@data_entries = []
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# Get delta set for specific item
|
|
259
|
+
#
|
|
260
|
+
# @param outer_index [Integer] Outer index (data entry)
|
|
261
|
+
# @param inner_index [Integer] Inner index (item within entry)
|
|
262
|
+
# @return [Array<Integer>, nil] Delta values or nil
|
|
263
|
+
def delta_set(outer_index, inner_index)
|
|
264
|
+
return nil if outer_index >= item_variation_data_count
|
|
265
|
+
|
|
266
|
+
entry = item_variation_data_entries[outer_index]
|
|
267
|
+
return nil if entry.nil? || inner_index >= entry.item_count
|
|
268
|
+
|
|
269
|
+
entry.delta_sets[inner_index]
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
# Delta set index mapping
|
|
274
|
+
#
|
|
275
|
+
# Maps glyph IDs to delta set indices in an ItemVariationStore.
|
|
276
|
+
# Used for efficient lookup of variation data.
|
|
277
|
+
class DeltaSetIndexMap < Binary::BaseRecord
|
|
278
|
+
uint8 :format
|
|
279
|
+
uint8 :entry_format
|
|
280
|
+
|
|
281
|
+
# Get map data based on format
|
|
282
|
+
#
|
|
283
|
+
# @return [Array<Integer>] Map data
|
|
284
|
+
def map_data
|
|
285
|
+
return @map_data if @map_data
|
|
286
|
+
|
|
287
|
+
data = raw_data
|
|
288
|
+
|
|
289
|
+
case format
|
|
290
|
+
when 0
|
|
291
|
+
parse_format0(data)
|
|
292
|
+
when 1
|
|
293
|
+
parse_format1(data)
|
|
294
|
+
else
|
|
295
|
+
@map_data = []
|
|
296
|
+
end
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
private
|
|
300
|
+
|
|
301
|
+
# Parse format 0 map data
|
|
302
|
+
def parse_format0(data)
|
|
303
|
+
# Format 0: mapCount + mapData array
|
|
304
|
+
return [] if data.bytesize < 4
|
|
305
|
+
|
|
306
|
+
map_count = data.byteslice(2, 2).unpack1("n")
|
|
307
|
+
|
|
308
|
+
# entry_format bits 4-5: outer size - 1, bits 0-3: inner size - 1
|
|
309
|
+
outer_size = ((entry_format >> 4) & 0x3) + 1
|
|
310
|
+
inner_size = (entry_format & 0xF) + 1
|
|
311
|
+
entry_size = outer_size + inner_size
|
|
312
|
+
|
|
313
|
+
@map_data = Array.new(map_count) do |i|
|
|
314
|
+
offset = 4 + (i * entry_size)
|
|
315
|
+
next nil if offset + entry_size > data.bytesize
|
|
316
|
+
|
|
317
|
+
# Read entry and combine outer and inner indices
|
|
318
|
+
# For simplicity, treat as combined integer
|
|
319
|
+
case entry_size
|
|
320
|
+
when 1
|
|
321
|
+
data.byteslice(offset, 1).unpack1("C")
|
|
322
|
+
when 2
|
|
323
|
+
data.byteslice(offset, 2).unpack1("n")
|
|
324
|
+
when 3
|
|
325
|
+
bytes = data.byteslice(offset, 3).unpack("C3")
|
|
326
|
+
(bytes[0] << 16) | (bytes[1] << 8) | bytes[2]
|
|
327
|
+
when 4
|
|
328
|
+
data.byteslice(offset, 4).unpack1("N")
|
|
329
|
+
else
|
|
330
|
+
# For larger sizes, read as big-endian integer
|
|
331
|
+
bytes = data.byteslice(offset, entry_size).unpack("C*")
|
|
332
|
+
bytes.reduce(0) { |acc, b| (acc << 8) | b }
|
|
333
|
+
end
|
|
334
|
+
end.compact
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
# Parse format 1 map data
|
|
338
|
+
def parse_format1(_data)
|
|
339
|
+
# Format 1: More complex with map count and data
|
|
340
|
+
# Simplified implementation
|
|
341
|
+
@map_data = []
|
|
342
|
+
end
|
|
343
|
+
end
|
|
344
|
+
end
|
|
345
|
+
end
|
|
346
|
+
end
|