fontisan 0.1.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.rubocop_todo.yml +672 -69
- data/Gemfile +1 -0
- data/LICENSE +5 -1
- data/README.adoc +1477 -297
- data/Rakefile +63 -41
- data/benchmark/variation_quick_bench.rb +47 -0
- data/docs/EXTRACT_TTC_MIGRATION.md +549 -0
- data/fontisan.gemspec +4 -1
- data/lib/fontisan/binary/base_record.rb +22 -1
- data/lib/fontisan/cli.rb +364 -4
- data/lib/fontisan/collection/builder.rb +341 -0
- data/lib/fontisan/collection/offset_calculator.rb +227 -0
- data/lib/fontisan/collection/table_analyzer.rb +204 -0
- data/lib/fontisan/collection/table_deduplicator.rb +317 -0
- data/lib/fontisan/collection/writer.rb +306 -0
- data/lib/fontisan/commands/base_command.rb +24 -1
- data/lib/fontisan/commands/convert_command.rb +218 -0
- data/lib/fontisan/commands/export_command.rb +161 -0
- data/lib/fontisan/commands/info_command.rb +40 -6
- data/lib/fontisan/commands/instance_command.rb +286 -0
- data/lib/fontisan/commands/ls_command.rb +113 -0
- data/lib/fontisan/commands/pack_command.rb +241 -0
- data/lib/fontisan/commands/subset_command.rb +245 -0
- data/lib/fontisan/commands/unpack_command.rb +338 -0
- data/lib/fontisan/commands/validate_command.rb +203 -0
- data/lib/fontisan/commands/variable_command.rb +30 -1
- data/lib/fontisan/config/collection_settings.yml +56 -0
- data/lib/fontisan/config/conversion_matrix.yml +212 -0
- data/lib/fontisan/config/export_settings.yml +66 -0
- data/lib/fontisan/config/subset_profiles.yml +100 -0
- data/lib/fontisan/config/svg_settings.yml +60 -0
- data/lib/fontisan/config/validation_rules.yml +149 -0
- data/lib/fontisan/config/variable_settings.yml +99 -0
- data/lib/fontisan/config/woff2_settings.yml +77 -0
- data/lib/fontisan/constants.rb +79 -0
- data/lib/fontisan/converters/conversion_strategy.rb +96 -0
- data/lib/fontisan/converters/format_converter.rb +408 -0
- data/lib/fontisan/converters/outline_converter.rb +998 -0
- data/lib/fontisan/converters/svg_generator.rb +244 -0
- data/lib/fontisan/converters/table_copier.rb +117 -0
- data/lib/fontisan/converters/woff2_encoder.rb +416 -0
- data/lib/fontisan/converters/woff_writer.rb +391 -0
- data/lib/fontisan/error.rb +203 -0
- data/lib/fontisan/export/exporter.rb +262 -0
- data/lib/fontisan/export/table_serializer.rb +255 -0
- data/lib/fontisan/export/transformers/font_to_ttx.rb +172 -0
- data/lib/fontisan/export/transformers/head_transformer.rb +96 -0
- data/lib/fontisan/export/transformers/hhea_transformer.rb +59 -0
- data/lib/fontisan/export/transformers/maxp_transformer.rb +63 -0
- data/lib/fontisan/export/transformers/name_transformer.rb +63 -0
- data/lib/fontisan/export/transformers/os2_transformer.rb +121 -0
- data/lib/fontisan/export/transformers/post_transformer.rb +51 -0
- data/lib/fontisan/export/ttx_generator.rb +527 -0
- data/lib/fontisan/export/ttx_parser.rb +300 -0
- data/lib/fontisan/font_loader.rb +122 -15
- data/lib/fontisan/font_writer.rb +302 -0
- data/lib/fontisan/formatters/text_formatter.rb +102 -0
- data/lib/fontisan/glyph_accessor.rb +503 -0
- data/lib/fontisan/hints/hint_converter.rb +310 -0
- data/lib/fontisan/hints/postscript_hint_applier.rb +266 -0
- data/lib/fontisan/hints/postscript_hint_extractor.rb +354 -0
- data/lib/fontisan/hints/truetype_hint_applier.rb +117 -0
- data/lib/fontisan/hints/truetype_hint_extractor.rb +289 -0
- data/lib/fontisan/loading_modes.rb +115 -0
- data/lib/fontisan/metrics_calculator.rb +277 -0
- data/lib/fontisan/models/collection_font_summary.rb +52 -0
- data/lib/fontisan/models/collection_info.rb +76 -0
- data/lib/fontisan/models/collection_list_info.rb +37 -0
- data/lib/fontisan/models/font_export.rb +158 -0
- data/lib/fontisan/models/font_summary.rb +48 -0
- data/lib/fontisan/models/glyph_outline.rb +343 -0
- data/lib/fontisan/models/hint.rb +405 -0
- data/lib/fontisan/models/outline.rb +664 -0
- data/lib/fontisan/models/table_sharing_info.rb +40 -0
- data/lib/fontisan/models/ttx/glyph_order.rb +31 -0
- data/lib/fontisan/models/ttx/tables/binary_table.rb +67 -0
- data/lib/fontisan/models/ttx/tables/head_table.rb +74 -0
- data/lib/fontisan/models/ttx/tables/hhea_table.rb +74 -0
- data/lib/fontisan/models/ttx/tables/maxp_table.rb +55 -0
- data/lib/fontisan/models/ttx/tables/name_table.rb +45 -0
- data/lib/fontisan/models/ttx/tables/os2_table.rb +157 -0
- data/lib/fontisan/models/ttx/tables/post_table.rb +50 -0
- data/lib/fontisan/models/ttx/ttfont.rb +49 -0
- data/lib/fontisan/models/validation_report.rb +203 -0
- data/lib/fontisan/open_type_collection.rb +156 -2
- data/lib/fontisan/open_type_font.rb +321 -19
- data/lib/fontisan/open_type_font_extensions.rb +54 -0
- data/lib/fontisan/optimizers/charstring_rewriter.rb +161 -0
- data/lib/fontisan/optimizers/pattern_analyzer.rb +308 -0
- data/lib/fontisan/optimizers/stack_tracker.rb +246 -0
- data/lib/fontisan/optimizers/subroutine_builder.rb +134 -0
- data/lib/fontisan/optimizers/subroutine_generator.rb +207 -0
- data/lib/fontisan/optimizers/subroutine_optimizer.rb +107 -0
- data/lib/fontisan/outline_extractor.rb +423 -0
- data/lib/fontisan/pipeline/format_detector.rb +249 -0
- data/lib/fontisan/pipeline/output_writer.rb +154 -0
- data/lib/fontisan/pipeline/strategies/base_strategy.rb +75 -0
- data/lib/fontisan/pipeline/strategies/instance_strategy.rb +93 -0
- data/lib/fontisan/pipeline/strategies/named_strategy.rb +118 -0
- data/lib/fontisan/pipeline/strategies/preserve_strategy.rb +56 -0
- data/lib/fontisan/pipeline/transformation_pipeline.rb +411 -0
- data/lib/fontisan/pipeline/variation_resolver.rb +165 -0
- data/lib/fontisan/subset/builder.rb +268 -0
- data/lib/fontisan/subset/glyph_mapping.rb +215 -0
- data/lib/fontisan/subset/options.rb +142 -0
- data/lib/fontisan/subset/profile.rb +152 -0
- data/lib/fontisan/subset/table_subsetter.rb +461 -0
- data/lib/fontisan/svg/font_face_generator.rb +278 -0
- data/lib/fontisan/svg/font_generator.rb +264 -0
- data/lib/fontisan/svg/glyph_generator.rb +168 -0
- data/lib/fontisan/svg/view_box_calculator.rb +137 -0
- data/lib/fontisan/tables/cff/cff_glyph.rb +176 -0
- data/lib/fontisan/tables/cff/charset.rb +282 -0
- data/lib/fontisan/tables/cff/charstring.rb +934 -0
- data/lib/fontisan/tables/cff/charstring_builder.rb +356 -0
- data/lib/fontisan/tables/cff/charstring_parser.rb +237 -0
- data/lib/fontisan/tables/cff/charstring_rebuilder.rb +172 -0
- data/lib/fontisan/tables/cff/charstrings_index.rb +162 -0
- data/lib/fontisan/tables/cff/dict.rb +351 -0
- data/lib/fontisan/tables/cff/dict_builder.rb +257 -0
- data/lib/fontisan/tables/cff/encoding.rb +274 -0
- data/lib/fontisan/tables/cff/header.rb +102 -0
- data/lib/fontisan/tables/cff/hint_operation_injector.rb +207 -0
- data/lib/fontisan/tables/cff/index.rb +237 -0
- data/lib/fontisan/tables/cff/index_builder.rb +170 -0
- data/lib/fontisan/tables/cff/offset_recalculator.rb +70 -0
- data/lib/fontisan/tables/cff/private_dict.rb +284 -0
- data/lib/fontisan/tables/cff/private_dict_writer.rb +125 -0
- data/lib/fontisan/tables/cff/table_builder.rb +221 -0
- data/lib/fontisan/tables/cff/top_dict.rb +236 -0
- data/lib/fontisan/tables/cff.rb +489 -0
- data/lib/fontisan/tables/cff2/blend_operator.rb +240 -0
- data/lib/fontisan/tables/cff2/charstring_parser.rb +591 -0
- data/lib/fontisan/tables/cff2/operand_stack.rb +232 -0
- data/lib/fontisan/tables/cff2/private_dict_blend_handler.rb +246 -0
- data/lib/fontisan/tables/cff2/region_matcher.rb +200 -0
- data/lib/fontisan/tables/cff2/table_builder.rb +574 -0
- data/lib/fontisan/tables/cff2/table_reader.rb +419 -0
- data/lib/fontisan/tables/cff2/variation_data_extractor.rb +212 -0
- data/lib/fontisan/tables/cff2.rb +346 -0
- data/lib/fontisan/tables/cvar.rb +203 -0
- data/lib/fontisan/tables/fvar.rb +2 -2
- data/lib/fontisan/tables/glyf/compound_glyph.rb +483 -0
- data/lib/fontisan/tables/glyf/compound_glyph_resolver.rb +136 -0
- data/lib/fontisan/tables/glyf/curve_converter.rb +343 -0
- data/lib/fontisan/tables/glyf/glyph_builder.rb +450 -0
- data/lib/fontisan/tables/glyf/simple_glyph.rb +382 -0
- data/lib/fontisan/tables/glyf.rb +235 -0
- data/lib/fontisan/tables/gvar.rb +231 -0
- data/lib/fontisan/tables/hhea.rb +124 -0
- data/lib/fontisan/tables/hmtx.rb +287 -0
- data/lib/fontisan/tables/hvar.rb +191 -0
- data/lib/fontisan/tables/loca.rb +322 -0
- data/lib/fontisan/tables/maxp.rb +192 -0
- data/lib/fontisan/tables/mvar.rb +185 -0
- data/lib/fontisan/tables/name.rb +99 -30
- data/lib/fontisan/tables/variation_common.rb +346 -0
- data/lib/fontisan/tables/vvar.rb +234 -0
- data/lib/fontisan/true_type_collection.rb +156 -2
- data/lib/fontisan/true_type_font.rb +321 -20
- data/lib/fontisan/true_type_font_extensions.rb +54 -0
- data/lib/fontisan/utilities/brotli_wrapper.rb +159 -0
- data/lib/fontisan/utilities/checksum_calculator.rb +60 -0
- data/lib/fontisan/utils/thread_pool.rb +134 -0
- data/lib/fontisan/validation/checksum_validator.rb +170 -0
- data/lib/fontisan/validation/consistency_validator.rb +197 -0
- data/lib/fontisan/validation/structure_validator.rb +198 -0
- data/lib/fontisan/validation/table_validator.rb +158 -0
- data/lib/fontisan/validation/validator.rb +152 -0
- data/lib/fontisan/validation/variable_font_validator.rb +218 -0
- data/lib/fontisan/variable/axis_normalizer.rb +215 -0
- data/lib/fontisan/variable/delta_applicator.rb +313 -0
- data/lib/fontisan/variable/glyph_delta_processor.rb +218 -0
- data/lib/fontisan/variable/instancer.rb +344 -0
- data/lib/fontisan/variable/metric_delta_processor.rb +282 -0
- data/lib/fontisan/variable/region_matcher.rb +208 -0
- data/lib/fontisan/variable/static_font_builder.rb +213 -0
- data/lib/fontisan/variable/table_updater.rb +219 -0
- data/lib/fontisan/variation/blend_applier.rb +199 -0
- data/lib/fontisan/variation/cache.rb +298 -0
- data/lib/fontisan/variation/cache_key_builder.rb +162 -0
- data/lib/fontisan/variation/converter.rb +375 -0
- data/lib/fontisan/variation/data_extractor.rb +86 -0
- data/lib/fontisan/variation/delta_applier.rb +266 -0
- data/lib/fontisan/variation/delta_parser.rb +228 -0
- data/lib/fontisan/variation/inspector.rb +275 -0
- data/lib/fontisan/variation/instance_generator.rb +273 -0
- data/lib/fontisan/variation/instance_writer.rb +341 -0
- data/lib/fontisan/variation/interpolator.rb +231 -0
- data/lib/fontisan/variation/metrics_adjuster.rb +318 -0
- data/lib/fontisan/variation/optimizer.rb +418 -0
- data/lib/fontisan/variation/parallel_generator.rb +150 -0
- data/lib/fontisan/variation/region_matcher.rb +221 -0
- data/lib/fontisan/variation/subsetter.rb +463 -0
- data/lib/fontisan/variation/table_accessor.rb +105 -0
- data/lib/fontisan/variation/tuple_variation_header.rb +51 -0
- data/lib/fontisan/variation/validator.rb +345 -0
- data/lib/fontisan/variation/variable_svg_generator.rb +268 -0
- data/lib/fontisan/variation/variation_context.rb +211 -0
- data/lib/fontisan/variation/variation_preserver.rb +288 -0
- data/lib/fontisan/version.rb +1 -1
- data/lib/fontisan/version.rb.orig +9 -0
- data/lib/fontisan/woff2/directory.rb +257 -0
- data/lib/fontisan/woff2/glyf_transformer.rb +666 -0
- data/lib/fontisan/woff2/header.rb +101 -0
- data/lib/fontisan/woff2/hmtx_transformer.rb +164 -0
- data/lib/fontisan/woff2/table_transformer.rb +163 -0
- data/lib/fontisan/woff2_font.rb +717 -0
- data/lib/fontisan/woff_font.rb +488 -0
- data/lib/fontisan.rb +132 -0
- data/scripts/compare_stack_aware.rb +187 -0
- data/scripts/measure_optimization.rb +141 -0
- metadata +234 -4
|
@@ -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
|
|
|
@@ -151,6 +279,20 @@ module Fontisan
|
|
|
151
279
|
true
|
|
152
280
|
end
|
|
153
281
|
|
|
282
|
+
# Check if font is TrueType flavored
|
|
283
|
+
#
|
|
284
|
+
# @return [Boolean] true for TrueType fonts
|
|
285
|
+
def truetype?
|
|
286
|
+
true
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
# Check if font is CFF flavored
|
|
290
|
+
#
|
|
291
|
+
# @return [Boolean] false for TrueType fonts
|
|
292
|
+
def cff?
|
|
293
|
+
false
|
|
294
|
+
end
|
|
295
|
+
|
|
154
296
|
# Check if font has a specific table
|
|
155
297
|
#
|
|
156
298
|
# @param tag [String] The table tag to check for
|
|
@@ -159,6 +301,15 @@ module Fontisan
|
|
|
159
301
|
tables.any? { |entry| entry.tag == tag }
|
|
160
302
|
end
|
|
161
303
|
|
|
304
|
+
# Check if a table is available in the current loading mode
|
|
305
|
+
#
|
|
306
|
+
# @param tag [String] The table tag to check
|
|
307
|
+
# @return [Boolean] true if table is available in current mode
|
|
308
|
+
def table_available?(tag)
|
|
309
|
+
return false unless has_table?(tag)
|
|
310
|
+
LoadingModes.table_allowed?(@loading_mode, tag)
|
|
311
|
+
end
|
|
312
|
+
|
|
162
313
|
# Find a table entry by tag
|
|
163
314
|
#
|
|
164
315
|
# @param tag [String] The table tag to find
|
|
@@ -184,11 +335,30 @@ module Fontisan
|
|
|
184
335
|
# Get parsed table instance (Fontisan extension)
|
|
185
336
|
#
|
|
186
337
|
# This method parses the raw table data into a structured table object
|
|
187
|
-
# and caches the result for subsequent calls.
|
|
338
|
+
# and caches the result for subsequent calls. Enforces mode restrictions.
|
|
188
339
|
#
|
|
189
340
|
# @param tag [String] The table tag to retrieve
|
|
190
341
|
# @return [Tables::*, nil] Parsed table object or nil if not found
|
|
342
|
+
# @raise [ArgumentError] if table is not available in current loading mode
|
|
191
343
|
def table(tag)
|
|
344
|
+
# Check mode restrictions
|
|
345
|
+
unless table_available?(tag)
|
|
346
|
+
if has_table?(tag)
|
|
347
|
+
raise ArgumentError,
|
|
348
|
+
"Table '#{tag}' is not available in #{@loading_mode} mode. " \
|
|
349
|
+
"Available tables: #{LoadingModes.tables_for(@loading_mode).inspect}"
|
|
350
|
+
else
|
|
351
|
+
return nil
|
|
352
|
+
end
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
return @parsed_tables[tag] if @parsed_tables.key?(tag)
|
|
356
|
+
|
|
357
|
+
# Lazy load table data if enabled
|
|
358
|
+
if @lazy_load_enabled && !@table_data.key?(tag)
|
|
359
|
+
load_table_data(tag)
|
|
360
|
+
end
|
|
361
|
+
|
|
192
362
|
@parsed_tables[tag] ||= parse_table(tag)
|
|
193
363
|
end
|
|
194
364
|
|
|
@@ -200,8 +370,133 @@ module Fontisan
|
|
|
200
370
|
head&.units_per_em
|
|
201
371
|
end
|
|
202
372
|
|
|
373
|
+
# Convenience methods for accessing common name table fields
|
|
374
|
+
# These are particularly useful in minimal mode
|
|
375
|
+
|
|
376
|
+
# Get font family name
|
|
377
|
+
#
|
|
378
|
+
# @return [String, nil] Family name or nil if not found
|
|
379
|
+
def family_name
|
|
380
|
+
name_table = table(Constants::NAME_TAG)
|
|
381
|
+
name_table&.english_name(Tables::Name::FAMILY)
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
# Get font subfamily name (e.g., Regular, Bold, Italic)
|
|
385
|
+
#
|
|
386
|
+
# @return [String, nil] Subfamily name or nil if not found
|
|
387
|
+
def subfamily_name
|
|
388
|
+
name_table = table(Constants::NAME_TAG)
|
|
389
|
+
name_table&.english_name(Tables::Name::SUBFAMILY)
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
# Get full font name
|
|
393
|
+
#
|
|
394
|
+
# @return [String, nil] Full name or nil if not found
|
|
395
|
+
def full_name
|
|
396
|
+
name_table = table(Constants::NAME_TAG)
|
|
397
|
+
name_table&.english_name(Tables::Name::FULL_NAME)
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
# Get PostScript name
|
|
401
|
+
#
|
|
402
|
+
# @return [String, nil] PostScript name or nil if not found
|
|
403
|
+
def post_script_name
|
|
404
|
+
name_table = table(Constants::NAME_TAG)
|
|
405
|
+
name_table&.english_name(Tables::Name::POSTSCRIPT_NAME)
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
# Get preferred family name
|
|
409
|
+
#
|
|
410
|
+
# @return [String, nil] Preferred family name or nil if not found
|
|
411
|
+
def preferred_family_name
|
|
412
|
+
name_table = table(Constants::NAME_TAG)
|
|
413
|
+
name_table&.english_name(Tables::Name::PREFERRED_FAMILY)
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
# Get preferred subfamily name
|
|
417
|
+
#
|
|
418
|
+
# @return [String, nil] Preferred subfamily name or nil if not found
|
|
419
|
+
def preferred_subfamily_name
|
|
420
|
+
name_table = table(Constants::NAME_TAG)
|
|
421
|
+
name_table&.english_name(Tables::Name::PREFERRED_SUBFAMILY)
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
# Close the IO source (for lazy loading)
|
|
425
|
+
#
|
|
426
|
+
# @return [void]
|
|
427
|
+
def close
|
|
428
|
+
@io_source&.close
|
|
429
|
+
@io_source = nil
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
# Setup finalizer for cleanup
|
|
433
|
+
#
|
|
434
|
+
# @return [void]
|
|
435
|
+
def setup_finalizer
|
|
436
|
+
ObjectSpace.define_finalizer(self, self.class.finalize(@io_source))
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
# Finalizer proc for closing IO
|
|
440
|
+
#
|
|
441
|
+
# @param io [IO] The IO object to close
|
|
442
|
+
# @return [Proc] The finalizer proc
|
|
443
|
+
def self.finalize(io)
|
|
444
|
+
proc { io&.close }
|
|
445
|
+
end
|
|
446
|
+
|
|
203
447
|
private
|
|
204
448
|
|
|
449
|
+
# Load a single table's data on demand
|
|
450
|
+
#
|
|
451
|
+
# Uses page-aligned reads and caches pages to ensure lazy loading
|
|
452
|
+
# performance is not slower than eager loading.
|
|
453
|
+
#
|
|
454
|
+
# @param tag [String] The table tag to load
|
|
455
|
+
# @return [void]
|
|
456
|
+
def load_table_data(tag)
|
|
457
|
+
return unless @io_source
|
|
458
|
+
|
|
459
|
+
entry = find_table_entry(tag)
|
|
460
|
+
return nil unless entry
|
|
461
|
+
|
|
462
|
+
# Use page-aligned reading with caching
|
|
463
|
+
table_start = entry.offset
|
|
464
|
+
table_end = entry.offset + entry.table_length
|
|
465
|
+
|
|
466
|
+
# Calculate page boundaries
|
|
467
|
+
page_start = (table_start / PAGE_SIZE) * PAGE_SIZE
|
|
468
|
+
page_end = ((table_end + PAGE_SIZE - 1) / PAGE_SIZE) * PAGE_SIZE
|
|
469
|
+
|
|
470
|
+
# Read all required pages (or use cached pages)
|
|
471
|
+
table_data_parts = []
|
|
472
|
+
current_page = page_start
|
|
473
|
+
|
|
474
|
+
while current_page < page_end
|
|
475
|
+
page_data = @page_cache[current_page]
|
|
476
|
+
|
|
477
|
+
unless page_data
|
|
478
|
+
# Read page from disk and cache it
|
|
479
|
+
@io_source.seek(current_page)
|
|
480
|
+
page_data = @io_source.read(PAGE_SIZE) || ""
|
|
481
|
+
@page_cache[current_page] = page_data
|
|
482
|
+
end
|
|
483
|
+
|
|
484
|
+
# Calculate which part of this page we need
|
|
485
|
+
chunk_start = [table_start - current_page, 0].max
|
|
486
|
+
chunk_end = [table_end - current_page, PAGE_SIZE].min
|
|
487
|
+
|
|
488
|
+
if chunk_end > chunk_start
|
|
489
|
+
table_data_parts << page_data[chunk_start...chunk_end]
|
|
490
|
+
end
|
|
491
|
+
|
|
492
|
+
current_page += PAGE_SIZE
|
|
493
|
+
end
|
|
494
|
+
|
|
495
|
+
# Combine parts and store
|
|
496
|
+
tag_key = tag.dup.force_encoding("UTF-8")
|
|
497
|
+
@table_data[tag_key] = table_data_parts.join
|
|
498
|
+
end
|
|
499
|
+
|
|
205
500
|
# Parse a table from raw data (Fontisan extension)
|
|
206
501
|
#
|
|
207
502
|
# @param tag [String] The table tag to parse
|
|
@@ -223,6 +518,9 @@ module Fontisan
|
|
|
223
518
|
def table_class_for(tag)
|
|
224
519
|
{
|
|
225
520
|
Constants::HEAD_TAG => Tables::Head,
|
|
521
|
+
Constants::HHEA_TAG => Tables::Hhea,
|
|
522
|
+
Constants::HMTX_TAG => Tables::Hmtx,
|
|
523
|
+
Constants::MAXP_TAG => Tables::Maxp,
|
|
226
524
|
Constants::NAME_TAG => Tables::Name,
|
|
227
525
|
Constants::OS2_TAG => Tables::Os2,
|
|
228
526
|
Constants::POST_TAG => Tables::Post,
|
|
@@ -230,6 +528,8 @@ module Fontisan
|
|
|
230
528
|
Constants::FVAR_TAG => Tables::Fvar,
|
|
231
529
|
Constants::GSUB_TAG => Tables::Gsub,
|
|
232
530
|
Constants::GPOS_TAG => Tables::Gpos,
|
|
531
|
+
Constants::GLYF_TAG => Tables::Glyf,
|
|
532
|
+
Constants::LOCA_TAG => Tables::Loca,
|
|
233
533
|
}[tag]
|
|
234
534
|
end
|
|
235
535
|
|
|
@@ -283,7 +583,7 @@ module Fontisan
|
|
|
283
583
|
directory_offset_position = 12 + (index * 16) + 8
|
|
284
584
|
current_pos = io.pos
|
|
285
585
|
io.seek(directory_offset_position)
|
|
286
|
-
io.write([current_position].pack("N"))
|
|
586
|
+
io.write([current_position].pack("N")) # Offset is now known
|
|
287
587
|
io.seek(current_pos)
|
|
288
588
|
end
|
|
289
589
|
end
|
|
@@ -293,18 +593,19 @@ module Fontisan
|
|
|
293
593
|
# @param path [String] Path to the TTF file
|
|
294
594
|
# @return [void]
|
|
295
595
|
def update_checksum_adjustment_in_file(path)
|
|
296
|
-
#
|
|
297
|
-
|
|
596
|
+
# Use tempfile-based checksum calculation for Windows compatibility
|
|
597
|
+
# This keeps the tempfile alive until we're done with the checksum
|
|
598
|
+
File.open(path, "r+b") do |io|
|
|
599
|
+
checksum, _tmpfile = Utilities::ChecksumCalculator.calculate_checksum_from_io_with_tempfile(io)
|
|
298
600
|
|
|
299
|
-
|
|
300
|
-
|
|
601
|
+
# Calculate adjustment
|
|
602
|
+
adjustment = Utilities::ChecksumCalculator.calculate_adjustment(checksum)
|
|
301
603
|
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
604
|
+
# Find head table position
|
|
605
|
+
head_entry = head_table
|
|
606
|
+
return unless head_entry
|
|
305
607
|
|
|
306
|
-
|
|
307
|
-
File.open(path, "r+b") do |io|
|
|
608
|
+
# Write adjustment to head table (offset 8 within head table)
|
|
308
609
|
io.seek(head_entry.offset + 8)
|
|
309
610
|
io.write([adjustment].pack("N"))
|
|
310
611
|
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fontisan
|
|
4
|
+
# Extensions to TrueTypeFont for table-based construction
|
|
5
|
+
class TrueTypeFont
|
|
6
|
+
# Create font from hash of tables
|
|
7
|
+
#
|
|
8
|
+
# This is used during font conversion when we have tables but not a file.
|
|
9
|
+
#
|
|
10
|
+
# @param tables [Hash<String, String>] Map of table tag to binary data
|
|
11
|
+
# @return [TrueTypeFont] New font instance
|
|
12
|
+
def self.from_tables(tables)
|
|
13
|
+
# Create minimal header structure
|
|
14
|
+
font = new
|
|
15
|
+
font.initialize_storage
|
|
16
|
+
font.loading_mode = LoadingModes::FULL
|
|
17
|
+
|
|
18
|
+
# Store table data
|
|
19
|
+
font.table_data = tables
|
|
20
|
+
|
|
21
|
+
# Build header from tables
|
|
22
|
+
num_tables = tables.size
|
|
23
|
+
max_power = 0
|
|
24
|
+
n = num_tables
|
|
25
|
+
while n > 1
|
|
26
|
+
n >>= 1
|
|
27
|
+
max_power += 1
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
search_range = (1 << max_power) * 16
|
|
31
|
+
entry_selector = max_power
|
|
32
|
+
range_shift = (num_tables * 16) - search_range
|
|
33
|
+
|
|
34
|
+
font.header.sfnt_version = 0x00010000 # TrueType
|
|
35
|
+
font.header.num_tables = num_tables
|
|
36
|
+
font.header.search_range = search_range
|
|
37
|
+
font.header.entry_selector = entry_selector
|
|
38
|
+
font.header.range_shift = range_shift
|
|
39
|
+
|
|
40
|
+
# Build table directory
|
|
41
|
+
font.tables.clear
|
|
42
|
+
tables.each_key do |tag|
|
|
43
|
+
entry = TableDirectory.new
|
|
44
|
+
entry.tag = tag
|
|
45
|
+
entry.checksum = 0 # Will be calculated on write
|
|
46
|
+
entry.offset = 0 # Will be calculated on write
|
|
47
|
+
entry.table_length = tables[tag].bytesize
|
|
48
|
+
font.tables << entry
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
font
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
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
|