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
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "bindata"
|
|
4
4
|
require_relative "constants"
|
|
5
|
+
require_relative "loading_modes"
|
|
5
6
|
require_relative "utilities/checksum_calculator"
|
|
6
7
|
|
|
7
8
|
module Fontisan
|
|
@@ -38,6 +39,11 @@ module Fontisan
|
|
|
38
39
|
# name_table = ttf.table("name") # Fontisan extension
|
|
39
40
|
# puts name_table.english_name(Tables::Name::FAMILY)
|
|
40
41
|
#
|
|
42
|
+
# @example Loading with metadata mode
|
|
43
|
+
# ttf = TrueTypeFont.from_file("font.ttf", mode: :metadata)
|
|
44
|
+
# puts ttf.loading_mode # => :metadata
|
|
45
|
+
# ttf.table_available?("GSUB") # => false
|
|
46
|
+
#
|
|
41
47
|
# @example Writing a font
|
|
42
48
|
# ttf.to_file("output.ttf")
|
|
43
49
|
class TrueTypeFont < BinData::Record
|
|
@@ -54,24 +60,55 @@ module Fontisan
|
|
|
54
60
|
# Parsed table instances cache (Fontisan extension)
|
|
55
61
|
attr_accessor :parsed_tables
|
|
56
62
|
|
|
63
|
+
# Loading mode for this font (:metadata or :full)
|
|
64
|
+
attr_accessor :loading_mode
|
|
65
|
+
|
|
66
|
+
# IO source for lazy loading
|
|
67
|
+
attr_accessor :io_source
|
|
68
|
+
|
|
69
|
+
# Whether lazy loading is enabled
|
|
70
|
+
attr_accessor :lazy_load_enabled
|
|
71
|
+
|
|
72
|
+
# Page cache for lazy loading (maps page_start_offset => page_data)
|
|
73
|
+
attr_accessor :page_cache
|
|
74
|
+
|
|
75
|
+
# Page size for lazy loading alignment (typical filesystem page size)
|
|
76
|
+
PAGE_SIZE = 4096
|
|
77
|
+
|
|
57
78
|
# Read TrueType Font from a file
|
|
58
79
|
#
|
|
59
80
|
# @param path [String] Path to the TTF file
|
|
81
|
+
# @param mode [Symbol] Loading mode (:metadata or :full, default: :full)
|
|
82
|
+
# @param lazy [Boolean] If true, load tables on demand (default: false for eager loading)
|
|
60
83
|
# @return [TrueTypeFont] A new instance
|
|
61
|
-
# @raise [ArgumentError] if path is nil or empty
|
|
84
|
+
# @raise [ArgumentError] if path is nil or empty, or if mode is invalid
|
|
62
85
|
# @raise [Errno::ENOENT] if file does not exist
|
|
63
86
|
# @raise [RuntimeError] if file format is invalid
|
|
64
|
-
def self.from_file(path)
|
|
87
|
+
def self.from_file(path, mode: LoadingModes::FULL, lazy: false)
|
|
65
88
|
if path.nil? || path.to_s.empty?
|
|
66
89
|
raise ArgumentError,
|
|
67
90
|
"path cannot be nil or empty"
|
|
68
91
|
end
|
|
69
92
|
raise Errno::ENOENT, "File not found: #{path}" unless File.exist?(path)
|
|
70
93
|
|
|
94
|
+
# Validate mode
|
|
95
|
+
LoadingModes.validate_mode!(mode)
|
|
96
|
+
|
|
71
97
|
File.open(path, "rb") do |io|
|
|
72
98
|
font = read(io)
|
|
73
99
|
font.initialize_storage
|
|
74
|
-
font.
|
|
100
|
+
font.loading_mode = mode
|
|
101
|
+
font.lazy_load_enabled = lazy
|
|
102
|
+
|
|
103
|
+
if lazy
|
|
104
|
+
# Keep file handle open for lazy loading
|
|
105
|
+
font.io_source = File.open(path, "rb")
|
|
106
|
+
font.setup_finalizer
|
|
107
|
+
else
|
|
108
|
+
# Read tables upfront
|
|
109
|
+
font.read_table_data(io)
|
|
110
|
+
end
|
|
111
|
+
|
|
75
112
|
font
|
|
76
113
|
end
|
|
77
114
|
rescue BinData::ValidityError, EOFError => e
|
|
@@ -82,11 +119,15 @@ module Fontisan
|
|
|
82
119
|
#
|
|
83
120
|
# @param io [IO] Open file handle
|
|
84
121
|
# @param offset [Integer] Byte offset to the font
|
|
122
|
+
# @param mode [Symbol] Loading mode (:metadata or :full, default: :full)
|
|
85
123
|
# @return [TrueTypeFont] A new instance
|
|
86
|
-
def self.from_ttc(io, offset)
|
|
124
|
+
def self.from_ttc(io, offset, mode: LoadingModes::FULL)
|
|
125
|
+
LoadingModes.validate_mode!(mode)
|
|
126
|
+
|
|
87
127
|
io.seek(offset)
|
|
88
128
|
font = read(io)
|
|
89
129
|
font.initialize_storage
|
|
130
|
+
font.loading_mode = mode
|
|
90
131
|
font.read_table_data(io)
|
|
91
132
|
font
|
|
92
133
|
end
|
|
@@ -97,19 +138,106 @@ module Fontisan
|
|
|
97
138
|
def initialize_storage
|
|
98
139
|
@table_data = {}
|
|
99
140
|
@parsed_tables = {}
|
|
141
|
+
@loading_mode = LoadingModes::FULL
|
|
142
|
+
@lazy_load_enabled = false
|
|
143
|
+
@io_source = nil
|
|
144
|
+
@page_cache = {}
|
|
100
145
|
end
|
|
101
146
|
|
|
102
147
|
# Read table data for all tables
|
|
103
148
|
#
|
|
149
|
+
# In metadata mode, only reads metadata tables. In full mode, reads all tables.
|
|
150
|
+
# In lazy load mode, doesn't read data upfront.
|
|
151
|
+
#
|
|
104
152
|
# @param io [IO] Open file handle
|
|
105
153
|
# @return [void]
|
|
106
154
|
def read_table_data(io)
|
|
107
155
|
@table_data = {}
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
#
|
|
111
|
-
|
|
112
|
-
|
|
156
|
+
|
|
157
|
+
if @lazy_load_enabled
|
|
158
|
+
# Don't read data, just keep IO reference
|
|
159
|
+
@io_source = io
|
|
160
|
+
return
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
if @loading_mode == LoadingModes::METADATA
|
|
164
|
+
# Only read metadata tables for performance
|
|
165
|
+
# Use page-aware batched reading to maximize filesystem prefetching
|
|
166
|
+
read_metadata_tables_batched(io)
|
|
167
|
+
else
|
|
168
|
+
# Read all tables
|
|
169
|
+
tables.each do |entry|
|
|
170
|
+
io.seek(entry.offset)
|
|
171
|
+
# Force UTF-8 encoding on tag for hash key consistency
|
|
172
|
+
tag_key = entry.tag.dup.force_encoding("UTF-8")
|
|
173
|
+
@table_data[tag_key] = io.read(entry.table_length)
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Read metadata tables using page-aware batching
|
|
179
|
+
#
|
|
180
|
+
# Groups adjacent tables within page boundaries and reads them together
|
|
181
|
+
# to maximize filesystem prefetching and minimize random seeks.
|
|
182
|
+
#
|
|
183
|
+
# @param io [IO] Open file handle
|
|
184
|
+
# @return [void]
|
|
185
|
+
def read_metadata_tables_batched(io)
|
|
186
|
+
# Typical filesystem page size (4KB is common, but 8KB gives better prefetch window)
|
|
187
|
+
page_threshold = 8192
|
|
188
|
+
|
|
189
|
+
# Get metadata tables sorted by offset for sequential access
|
|
190
|
+
metadata_entries = tables.select { |entry| LoadingModes::METADATA_TABLES_SET.include?(entry.tag) }
|
|
191
|
+
metadata_entries.sort_by!(&:offset)
|
|
192
|
+
|
|
193
|
+
return if metadata_entries.empty?
|
|
194
|
+
|
|
195
|
+
# Group adjacent tables within page threshold for batched reading
|
|
196
|
+
i = 0
|
|
197
|
+
while i < metadata_entries.size
|
|
198
|
+
batch_start = metadata_entries[i]
|
|
199
|
+
batch_end = batch_start
|
|
200
|
+
batch_entries = [batch_start]
|
|
201
|
+
|
|
202
|
+
# Extend batch while next table is within page threshold
|
|
203
|
+
j = i + 1
|
|
204
|
+
while j < metadata_entries.size
|
|
205
|
+
next_entry = metadata_entries[j]
|
|
206
|
+
gap = next_entry.offset - (batch_end.offset + batch_end.table_length)
|
|
207
|
+
|
|
208
|
+
# If gap is small (within page threshold), include in batch
|
|
209
|
+
if gap <= page_threshold
|
|
210
|
+
batch_end = next_entry
|
|
211
|
+
batch_entries << next_entry
|
|
212
|
+
j += 1
|
|
213
|
+
else
|
|
214
|
+
break
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# Read batch
|
|
219
|
+
if batch_entries.size == 1
|
|
220
|
+
# Single table, read normally
|
|
221
|
+
io.seek(batch_start.offset)
|
|
222
|
+
tag_key = batch_start.tag.dup.force_encoding("UTF-8")
|
|
223
|
+
@table_data[tag_key] = io.read(batch_start.table_length)
|
|
224
|
+
else
|
|
225
|
+
# Multiple tables, read contiguous segment
|
|
226
|
+
batch_offset = batch_start.offset
|
|
227
|
+
batch_length = (batch_end.offset + batch_end.table_length) - batch_start.offset
|
|
228
|
+
|
|
229
|
+
io.seek(batch_offset)
|
|
230
|
+
batch_data = io.read(batch_length)
|
|
231
|
+
|
|
232
|
+
# Extract individual tables from batch
|
|
233
|
+
batch_entries.each do |entry|
|
|
234
|
+
relative_offset = entry.offset - batch_offset
|
|
235
|
+
tag_key = entry.tag.dup.force_encoding("UTF-8")
|
|
236
|
+
@table_data[tag_key] = batch_data[relative_offset, entry.table_length]
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
i = j
|
|
113
241
|
end
|
|
114
242
|
end
|
|
115
243
|
|
|
@@ -159,6 +287,15 @@ module Fontisan
|
|
|
159
287
|
tables.any? { |entry| entry.tag == tag }
|
|
160
288
|
end
|
|
161
289
|
|
|
290
|
+
# Check if a table is available in the current loading mode
|
|
291
|
+
#
|
|
292
|
+
# @param tag [String] The table tag to check
|
|
293
|
+
# @return [Boolean] true if table is available in current mode
|
|
294
|
+
def table_available?(tag)
|
|
295
|
+
return false unless has_table?(tag)
|
|
296
|
+
LoadingModes.table_allowed?(@loading_mode, tag)
|
|
297
|
+
end
|
|
298
|
+
|
|
162
299
|
# Find a table entry by tag
|
|
163
300
|
#
|
|
164
301
|
# @param tag [String] The table tag to find
|
|
@@ -184,11 +321,30 @@ module Fontisan
|
|
|
184
321
|
# Get parsed table instance (Fontisan extension)
|
|
185
322
|
#
|
|
186
323
|
# This method parses the raw table data into a structured table object
|
|
187
|
-
# and caches the result for subsequent calls.
|
|
324
|
+
# and caches the result for subsequent calls. Enforces mode restrictions.
|
|
188
325
|
#
|
|
189
326
|
# @param tag [String] The table tag to retrieve
|
|
190
327
|
# @return [Tables::*, nil] Parsed table object or nil if not found
|
|
328
|
+
# @raise [ArgumentError] if table is not available in current loading mode
|
|
191
329
|
def table(tag)
|
|
330
|
+
# Check mode restrictions
|
|
331
|
+
unless table_available?(tag)
|
|
332
|
+
if has_table?(tag)
|
|
333
|
+
raise ArgumentError,
|
|
334
|
+
"Table '#{tag}' is not available in #{@loading_mode} mode. " \
|
|
335
|
+
"Available tables: #{LoadingModes.tables_for(@loading_mode).inspect}"
|
|
336
|
+
else
|
|
337
|
+
return nil
|
|
338
|
+
end
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
return @parsed_tables[tag] if @parsed_tables.key?(tag)
|
|
342
|
+
|
|
343
|
+
# Lazy load table data if enabled
|
|
344
|
+
if @lazy_load_enabled && !@table_data.key?(tag)
|
|
345
|
+
load_table_data(tag)
|
|
346
|
+
end
|
|
347
|
+
|
|
192
348
|
@parsed_tables[tag] ||= parse_table(tag)
|
|
193
349
|
end
|
|
194
350
|
|
|
@@ -200,8 +356,133 @@ module Fontisan
|
|
|
200
356
|
head&.units_per_em
|
|
201
357
|
end
|
|
202
358
|
|
|
359
|
+
# Convenience methods for accessing common name table fields
|
|
360
|
+
# These are particularly useful in minimal mode
|
|
361
|
+
|
|
362
|
+
# Get font family name
|
|
363
|
+
#
|
|
364
|
+
# @return [String, nil] Family name or nil if not found
|
|
365
|
+
def family_name
|
|
366
|
+
name_table = table(Constants::NAME_TAG)
|
|
367
|
+
name_table&.english_name(Tables::Name::FAMILY)
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
# Get font subfamily name (e.g., Regular, Bold, Italic)
|
|
371
|
+
#
|
|
372
|
+
# @return [String, nil] Subfamily name or nil if not found
|
|
373
|
+
def subfamily_name
|
|
374
|
+
name_table = table(Constants::NAME_TAG)
|
|
375
|
+
name_table&.english_name(Tables::Name::SUBFAMILY)
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
# Get full font name
|
|
379
|
+
#
|
|
380
|
+
# @return [String, nil] Full name or nil if not found
|
|
381
|
+
def full_name
|
|
382
|
+
name_table = table(Constants::NAME_TAG)
|
|
383
|
+
name_table&.english_name(Tables::Name::FULL_NAME)
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
# Get PostScript name
|
|
387
|
+
#
|
|
388
|
+
# @return [String, nil] PostScript name or nil if not found
|
|
389
|
+
def post_script_name
|
|
390
|
+
name_table = table(Constants::NAME_TAG)
|
|
391
|
+
name_table&.english_name(Tables::Name::POSTSCRIPT_NAME)
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
# Get preferred family name
|
|
395
|
+
#
|
|
396
|
+
# @return [String, nil] Preferred family name or nil if not found
|
|
397
|
+
def preferred_family_name
|
|
398
|
+
name_table = table(Constants::NAME_TAG)
|
|
399
|
+
name_table&.english_name(Tables::Name::PREFERRED_FAMILY)
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
# Get preferred subfamily name
|
|
403
|
+
#
|
|
404
|
+
# @return [String, nil] Preferred subfamily name or nil if not found
|
|
405
|
+
def preferred_subfamily_name
|
|
406
|
+
name_table = table(Constants::NAME_TAG)
|
|
407
|
+
name_table&.english_name(Tables::Name::PREFERRED_SUBFAMILY)
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
# Close the IO source (for lazy loading)
|
|
411
|
+
#
|
|
412
|
+
# @return [void]
|
|
413
|
+
def close
|
|
414
|
+
@io_source&.close
|
|
415
|
+
@io_source = nil
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
# Setup finalizer for cleanup
|
|
419
|
+
#
|
|
420
|
+
# @return [void]
|
|
421
|
+
def setup_finalizer
|
|
422
|
+
ObjectSpace.define_finalizer(self, self.class.finalize(@io_source))
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
# Finalizer proc for closing IO
|
|
426
|
+
#
|
|
427
|
+
# @param io [IO] The IO object to close
|
|
428
|
+
# @return [Proc] The finalizer proc
|
|
429
|
+
def self.finalize(io)
|
|
430
|
+
proc { io&.close }
|
|
431
|
+
end
|
|
432
|
+
|
|
203
433
|
private
|
|
204
434
|
|
|
435
|
+
# Load a single table's data on demand
|
|
436
|
+
#
|
|
437
|
+
# Uses page-aligned reads and caches pages to ensure lazy loading
|
|
438
|
+
# performance is not slower than eager loading.
|
|
439
|
+
#
|
|
440
|
+
# @param tag [String] The table tag to load
|
|
441
|
+
# @return [void]
|
|
442
|
+
def load_table_data(tag)
|
|
443
|
+
return unless @io_source
|
|
444
|
+
|
|
445
|
+
entry = find_table_entry(tag)
|
|
446
|
+
return nil unless entry
|
|
447
|
+
|
|
448
|
+
# Use page-aligned reading with caching
|
|
449
|
+
table_start = entry.offset
|
|
450
|
+
table_end = entry.offset + entry.table_length
|
|
451
|
+
|
|
452
|
+
# Calculate page boundaries
|
|
453
|
+
page_start = (table_start / PAGE_SIZE) * PAGE_SIZE
|
|
454
|
+
page_end = ((table_end + PAGE_SIZE - 1) / PAGE_SIZE) * PAGE_SIZE
|
|
455
|
+
|
|
456
|
+
# Read all required pages (or use cached pages)
|
|
457
|
+
table_data_parts = []
|
|
458
|
+
current_page = page_start
|
|
459
|
+
|
|
460
|
+
while current_page < page_end
|
|
461
|
+
page_data = @page_cache[current_page]
|
|
462
|
+
|
|
463
|
+
unless page_data
|
|
464
|
+
# Read page from disk and cache it
|
|
465
|
+
@io_source.seek(current_page)
|
|
466
|
+
page_data = @io_source.read(PAGE_SIZE) || ""
|
|
467
|
+
@page_cache[current_page] = page_data
|
|
468
|
+
end
|
|
469
|
+
|
|
470
|
+
# Calculate which part of this page we need
|
|
471
|
+
chunk_start = [table_start - current_page, 0].max
|
|
472
|
+
chunk_end = [table_end - current_page, PAGE_SIZE].min
|
|
473
|
+
|
|
474
|
+
if chunk_end > chunk_start
|
|
475
|
+
table_data_parts << page_data[chunk_start...chunk_end]
|
|
476
|
+
end
|
|
477
|
+
|
|
478
|
+
current_page += PAGE_SIZE
|
|
479
|
+
end
|
|
480
|
+
|
|
481
|
+
# Combine parts and store
|
|
482
|
+
tag_key = tag.dup.force_encoding("UTF-8")
|
|
483
|
+
@table_data[tag_key] = table_data_parts.join
|
|
484
|
+
end
|
|
485
|
+
|
|
205
486
|
# Parse a table from raw data (Fontisan extension)
|
|
206
487
|
#
|
|
207
488
|
# @param tag [String] The table tag to parse
|
|
@@ -223,6 +504,9 @@ module Fontisan
|
|
|
223
504
|
def table_class_for(tag)
|
|
224
505
|
{
|
|
225
506
|
Constants::HEAD_TAG => Tables::Head,
|
|
507
|
+
Constants::HHEA_TAG => Tables::Hhea,
|
|
508
|
+
Constants::HMTX_TAG => Tables::Hmtx,
|
|
509
|
+
Constants::MAXP_TAG => Tables::Maxp,
|
|
226
510
|
Constants::NAME_TAG => Tables::Name,
|
|
227
511
|
Constants::OS2_TAG => Tables::Os2,
|
|
228
512
|
Constants::POST_TAG => Tables::Post,
|
|
@@ -230,6 +514,8 @@ module Fontisan
|
|
|
230
514
|
Constants::FVAR_TAG => Tables::Fvar,
|
|
231
515
|
Constants::GSUB_TAG => Tables::Gsub,
|
|
232
516
|
Constants::GPOS_TAG => Tables::Gpos,
|
|
517
|
+
Constants::GLYF_TAG => Tables::Glyf,
|
|
518
|
+
Constants::LOCA_TAG => Tables::Loca,
|
|
233
519
|
}[tag]
|
|
234
520
|
end
|
|
235
521
|
|
|
@@ -283,7 +569,7 @@ module Fontisan
|
|
|
283
569
|
directory_offset_position = 12 + (index * 16) + 8
|
|
284
570
|
current_pos = io.pos
|
|
285
571
|
io.seek(directory_offset_position)
|
|
286
|
-
io.write([current_position].pack("N"))
|
|
572
|
+
io.write([current_position].pack("N")) # Offset is now known
|
|
287
573
|
io.seek(current_pos)
|
|
288
574
|
end
|
|
289
575
|
end
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "brotli"
|
|
4
|
+
|
|
5
|
+
module Fontisan
|
|
6
|
+
module Utilities
|
|
7
|
+
# Wrapper for Brotli compression with consistent settings
|
|
8
|
+
#
|
|
9
|
+
# [`BrotliWrapper`](lib/fontisan/utilities/brotli_wrapper.rb) provides
|
|
10
|
+
# a consistent interface for Brotli compression with configurable quality
|
|
11
|
+
# and error handling. Used primarily for WOFF2 encoding.
|
|
12
|
+
#
|
|
13
|
+
# Brotli compression is significantly more effective than zlib (used in WOFF),
|
|
14
|
+
# typically achieving 20-30% better compression ratios on font data.
|
|
15
|
+
#
|
|
16
|
+
# @example Compress table data
|
|
17
|
+
# compressed = BrotliWrapper.compress(table_data, quality: 11)
|
|
18
|
+
#
|
|
19
|
+
# @example Decompress data
|
|
20
|
+
# decompressed = BrotliWrapper.decompress(compressed_data)
|
|
21
|
+
class BrotliWrapper
|
|
22
|
+
# Default compression quality (0-11, higher = better but slower)
|
|
23
|
+
# Quality 11 gives best compression for WOFF2
|
|
24
|
+
DEFAULT_QUALITY = 11
|
|
25
|
+
|
|
26
|
+
# Minimum quality level
|
|
27
|
+
MIN_QUALITY = 0
|
|
28
|
+
|
|
29
|
+
# Maximum quality level
|
|
30
|
+
MAX_QUALITY = 11
|
|
31
|
+
|
|
32
|
+
# Compress data using Brotli
|
|
33
|
+
#
|
|
34
|
+
# @param data [String] Data to compress
|
|
35
|
+
# @param quality [Integer] Compression quality (0-11)
|
|
36
|
+
# @param mode [Symbol] Compression mode (:generic, :text, :font)
|
|
37
|
+
# @return [String] Compressed data
|
|
38
|
+
# @raise [ArgumentError] If quality is out of range
|
|
39
|
+
# @raise [Error] If compression fails
|
|
40
|
+
#
|
|
41
|
+
# @example Compress with default quality
|
|
42
|
+
# compressed = BrotliWrapper.compress(data)
|
|
43
|
+
#
|
|
44
|
+
# @example Compress with specific quality
|
|
45
|
+
# compressed = BrotliWrapper.compress(data, quality: 9)
|
|
46
|
+
def self.compress(data, quality: DEFAULT_QUALITY, mode: :font)
|
|
47
|
+
validate_quality!(quality)
|
|
48
|
+
validate_data!(data)
|
|
49
|
+
|
|
50
|
+
begin
|
|
51
|
+
# Use Brotli gem with specified quality
|
|
52
|
+
# The brotli gem doesn't expose mode constants, only quality
|
|
53
|
+
Brotli.deflate(data, quality: quality)
|
|
54
|
+
rescue StandardError => e
|
|
55
|
+
raise Fontisan::Error,
|
|
56
|
+
"Brotli compression failed: #{e.message}"
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Decompress Brotli-compressed data
|
|
61
|
+
#
|
|
62
|
+
# @param data [String] Compressed data
|
|
63
|
+
# @return [String] Decompressed data
|
|
64
|
+
# @raise [Error] If decompression fails
|
|
65
|
+
#
|
|
66
|
+
# @example
|
|
67
|
+
# decompressed = BrotliWrapper.decompress(compressed_data)
|
|
68
|
+
def self.decompress(data)
|
|
69
|
+
validate_data!(data)
|
|
70
|
+
|
|
71
|
+
begin
|
|
72
|
+
Brotli.inflate(data)
|
|
73
|
+
rescue StandardError => e
|
|
74
|
+
raise Fontisan::Error,
|
|
75
|
+
"Brotli decompression failed: #{e.message}"
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Calculate compression ratio
|
|
80
|
+
#
|
|
81
|
+
# @param original_size [Integer] Original data size
|
|
82
|
+
# @param compressed_size [Integer] Compressed data size
|
|
83
|
+
# @return [Float] Compression ratio (0.0-1.0)
|
|
84
|
+
#
|
|
85
|
+
# @example
|
|
86
|
+
# ratio = BrotliWrapper.compression_ratio(1000, 300)
|
|
87
|
+
# # => 0.3 (30% of original size)
|
|
88
|
+
def self.compression_ratio(original_size, compressed_size)
|
|
89
|
+
return 0.0 if original_size.zero?
|
|
90
|
+
|
|
91
|
+
compressed_size.to_f / original_size
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Calculate compression percentage
|
|
95
|
+
#
|
|
96
|
+
# @param original_size [Integer] Original data size
|
|
97
|
+
# @param compressed_size [Integer] Compressed data size
|
|
98
|
+
# @return [Float] Compression percentage reduction
|
|
99
|
+
#
|
|
100
|
+
# @example
|
|
101
|
+
# pct = BrotliWrapper.compression_percentage(1000, 300)
|
|
102
|
+
# # => 70.0 (70% reduction)
|
|
103
|
+
def self.compression_percentage(original_size, compressed_size)
|
|
104
|
+
return 0.0 if original_size.zero?
|
|
105
|
+
|
|
106
|
+
((original_size - compressed_size).to_f / original_size * 100).round(1)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
class << self
|
|
110
|
+
private
|
|
111
|
+
|
|
112
|
+
# Validate compression quality parameter
|
|
113
|
+
#
|
|
114
|
+
# @param quality [Integer] Quality level
|
|
115
|
+
# @raise [ArgumentError] If quality is invalid
|
|
116
|
+
def validate_quality!(quality)
|
|
117
|
+
unless quality.is_a?(Integer)
|
|
118
|
+
raise ArgumentError,
|
|
119
|
+
"Quality must be an Integer, got #{quality.class}"
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
unless (MIN_QUALITY..MAX_QUALITY).cover?(quality)
|
|
123
|
+
raise ArgumentError,
|
|
124
|
+
"Quality must be between #{MIN_QUALITY} and #{MAX_QUALITY}, " \
|
|
125
|
+
"got #{quality}"
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Validate data parameter
|
|
130
|
+
#
|
|
131
|
+
# @param data [String] Data to validate
|
|
132
|
+
# @raise [ArgumentError] If data is invalid
|
|
133
|
+
def validate_data!(data)
|
|
134
|
+
if data.nil?
|
|
135
|
+
raise ArgumentError, "Data cannot be nil"
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
unless data.respond_to?(:bytesize)
|
|
139
|
+
raise ArgumentError,
|
|
140
|
+
"Data must be a String-like object, got #{data.class}"
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Convert mode symbol to Brotli constant
|
|
145
|
+
#
|
|
146
|
+
# NOTE: The brotli gem doesn't expose mode constants
|
|
147
|
+
# This method is kept for API compatibility but unused
|
|
148
|
+
#
|
|
149
|
+
# @param mode [Symbol] Mode symbol
|
|
150
|
+
# @return [Integer] Mode value (unused)
|
|
151
|
+
def brotli_mode(_mode)
|
|
152
|
+
# The brotli gem only accepts quality parameter
|
|
153
|
+
# Mode is not configurable in current version
|
|
154
|
+
0
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "stringio"
|
|
3
4
|
require_relative "../constants"
|
|
4
5
|
|
|
5
6
|
module Fontisan
|
|
@@ -55,6 +56,23 @@ module Fontisan
|
|
|
55
56
|
(Constants::CHECKSUM_ADJUSTMENT_MAGIC - file_checksum) & 0xFFFFFFFF
|
|
56
57
|
end
|
|
57
58
|
|
|
59
|
+
# Calculate checksum for raw table data.
|
|
60
|
+
#
|
|
61
|
+
# This method calculates the checksum for a binary string of table data.
|
|
62
|
+
# Used when creating WOFF files or validating table integrity.
|
|
63
|
+
#
|
|
64
|
+
# @param data [String] binary table data
|
|
65
|
+
# @return [Integer] the calculated uint32 checksum
|
|
66
|
+
#
|
|
67
|
+
# @example
|
|
68
|
+
# checksum = ChecksumCalculator.calculate_table_checksum(table_data)
|
|
69
|
+
# # => 1234567890
|
|
70
|
+
def self.calculate_table_checksum(data)
|
|
71
|
+
io = StringIO.new(data)
|
|
72
|
+
io.set_encoding(Encoding::BINARY)
|
|
73
|
+
calculate_checksum_from_io(io)
|
|
74
|
+
end
|
|
75
|
+
|
|
58
76
|
# Calculate checksum from an IO object.
|
|
59
77
|
#
|
|
60
78
|
# Reads the IO stream in 4-byte chunks and calculates the uint32 checksum.
|