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
|
|
@@ -16,6 +17,11 @@ module Fontisan
|
|
|
16
17
|
# name_table = otf.table("name")
|
|
17
18
|
# puts name_table.english_name(Tables::Name::FAMILY)
|
|
18
19
|
#
|
|
20
|
+
# @example Loading with metadata mode
|
|
21
|
+
# otf = OpenTypeFont.from_file("font.otf", mode: :metadata)
|
|
22
|
+
# puts otf.loading_mode # => :metadata
|
|
23
|
+
# otf.table_available?("GSUB") # => false
|
|
24
|
+
#
|
|
19
25
|
# @example Writing a font
|
|
20
26
|
# otf.to_file("output.otf")
|
|
21
27
|
class OpenTypeFont < BinData::Record
|
|
@@ -32,24 +38,55 @@ module Fontisan
|
|
|
32
38
|
# Parsed table instances cache
|
|
33
39
|
attr_accessor :parsed_tables
|
|
34
40
|
|
|
41
|
+
# Loading mode for this font (:metadata or :full)
|
|
42
|
+
attr_accessor :loading_mode
|
|
43
|
+
|
|
44
|
+
# IO source for lazy loading
|
|
45
|
+
attr_accessor :io_source
|
|
46
|
+
|
|
47
|
+
# Whether lazy loading is enabled
|
|
48
|
+
attr_accessor :lazy_load_enabled
|
|
49
|
+
|
|
50
|
+
# Page cache for lazy loading (maps page_start_offset => page_data)
|
|
51
|
+
attr_accessor :page_cache
|
|
52
|
+
|
|
53
|
+
# Page size for lazy loading alignment (typical filesystem page size)
|
|
54
|
+
PAGE_SIZE = 4096
|
|
55
|
+
|
|
35
56
|
# Read OpenType Font from a file
|
|
36
57
|
#
|
|
37
58
|
# @param path [String] Path to the OTF file
|
|
59
|
+
# @param mode [Symbol] Loading mode (:metadata or :full, default: :full)
|
|
60
|
+
# @param lazy [Boolean] If true, load tables on demand (default: false for eager loading)
|
|
38
61
|
# @return [OpenTypeFont] A new instance
|
|
39
|
-
# @raise [ArgumentError] if path is nil or empty
|
|
62
|
+
# @raise [ArgumentError] if path is nil or empty, or if mode is invalid
|
|
40
63
|
# @raise [Errno::ENOENT] if file does not exist
|
|
41
64
|
# @raise [RuntimeError] if file format is invalid
|
|
42
|
-
def self.from_file(path)
|
|
65
|
+
def self.from_file(path, mode: LoadingModes::FULL, lazy: false)
|
|
43
66
|
if path.nil? || path.to_s.empty?
|
|
44
67
|
raise ArgumentError,
|
|
45
68
|
"path cannot be nil or empty"
|
|
46
69
|
end
|
|
47
70
|
raise Errno::ENOENT, "File not found: #{path}" unless File.exist?(path)
|
|
48
71
|
|
|
72
|
+
# Validate mode
|
|
73
|
+
LoadingModes.validate_mode!(mode)
|
|
74
|
+
|
|
49
75
|
File.open(path, "rb") do |io|
|
|
50
76
|
font = read(io)
|
|
51
77
|
font.initialize_storage
|
|
52
|
-
font.
|
|
78
|
+
font.loading_mode = mode
|
|
79
|
+
font.lazy_load_enabled = lazy
|
|
80
|
+
|
|
81
|
+
if lazy
|
|
82
|
+
# Keep file handle open for lazy loading
|
|
83
|
+
font.io_source = File.open(path, "rb")
|
|
84
|
+
font.setup_finalizer
|
|
85
|
+
else
|
|
86
|
+
# Read tables upfront
|
|
87
|
+
font.read_table_data(io)
|
|
88
|
+
end
|
|
89
|
+
|
|
53
90
|
font
|
|
54
91
|
end
|
|
55
92
|
rescue BinData::ValidityError, EOFError => e
|
|
@@ -60,11 +97,15 @@ module Fontisan
|
|
|
60
97
|
#
|
|
61
98
|
# @param io [IO] Open file handle
|
|
62
99
|
# @param offset [Integer] Byte offset to the font
|
|
100
|
+
# @param mode [Symbol] Loading mode (:metadata or :full, default: :full)
|
|
63
101
|
# @return [OpenTypeFont] A new instance
|
|
64
|
-
def self.from_collection(io, offset)
|
|
102
|
+
def self.from_collection(io, offset, mode: LoadingModes::FULL)
|
|
103
|
+
LoadingModes.validate_mode!(mode)
|
|
104
|
+
|
|
65
105
|
io.seek(offset)
|
|
66
106
|
font = read(io)
|
|
67
107
|
font.initialize_storage
|
|
108
|
+
font.loading_mode = mode
|
|
68
109
|
font.read_table_data(io)
|
|
69
110
|
font
|
|
70
111
|
end
|
|
@@ -75,19 +116,106 @@ module Fontisan
|
|
|
75
116
|
def initialize_storage
|
|
76
117
|
@table_data = {}
|
|
77
118
|
@parsed_tables = {}
|
|
119
|
+
@loading_mode = LoadingModes::FULL
|
|
120
|
+
@lazy_load_enabled = false
|
|
121
|
+
@io_source = nil
|
|
122
|
+
@page_cache = {}
|
|
78
123
|
end
|
|
79
124
|
|
|
80
125
|
# Read table data for all tables
|
|
81
126
|
#
|
|
127
|
+
# In metadata mode, only reads metadata tables. In full mode, reads all tables.
|
|
128
|
+
# In lazy load mode, doesn't read data upfront.
|
|
129
|
+
#
|
|
82
130
|
# @param io [IO] Open file handle
|
|
83
131
|
# @return [void]
|
|
84
132
|
def read_table_data(io)
|
|
85
133
|
@table_data = {}
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
#
|
|
89
|
-
|
|
90
|
-
|
|
134
|
+
|
|
135
|
+
if @lazy_load_enabled
|
|
136
|
+
# Don't read data, just keep IO reference
|
|
137
|
+
@io_source = io
|
|
138
|
+
return
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
if @loading_mode == LoadingModes::METADATA
|
|
142
|
+
# Only read metadata tables for performance
|
|
143
|
+
# Use page-aware batched reading to maximize filesystem prefetching
|
|
144
|
+
read_metadata_tables_batched(io)
|
|
145
|
+
else
|
|
146
|
+
# Read all tables
|
|
147
|
+
tables.each do |entry|
|
|
148
|
+
io.seek(entry.offset)
|
|
149
|
+
# Force UTF-8 encoding on tag for hash key consistency
|
|
150
|
+
tag_key = entry.tag.dup.force_encoding("UTF-8")
|
|
151
|
+
@table_data[tag_key] = io.read(entry.table_length)
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Read metadata tables using page-aware batching
|
|
157
|
+
#
|
|
158
|
+
# Groups adjacent tables within page boundaries and reads them together
|
|
159
|
+
# to maximize filesystem prefetching and minimize random seeks.
|
|
160
|
+
#
|
|
161
|
+
# @param io [IO] Open file handle
|
|
162
|
+
# @return [void]
|
|
163
|
+
def read_metadata_tables_batched(io)
|
|
164
|
+
# Typical filesystem page size (4KB is common, but 8KB gives better prefetch window)
|
|
165
|
+
page_threshold = 8192
|
|
166
|
+
|
|
167
|
+
# Get metadata tables sorted by offset for sequential access
|
|
168
|
+
metadata_entries = tables.select { |entry| LoadingModes::METADATA_TABLES_SET.include?(entry.tag) }
|
|
169
|
+
metadata_entries.sort_by!(&:offset)
|
|
170
|
+
|
|
171
|
+
return if metadata_entries.empty?
|
|
172
|
+
|
|
173
|
+
# Group adjacent tables within page threshold for batched reading
|
|
174
|
+
i = 0
|
|
175
|
+
while i < metadata_entries.size
|
|
176
|
+
batch_start = metadata_entries[i]
|
|
177
|
+
batch_end = batch_start
|
|
178
|
+
batch_entries = [batch_start]
|
|
179
|
+
|
|
180
|
+
# Extend batch while next table is within page threshold
|
|
181
|
+
j = i + 1
|
|
182
|
+
while j < metadata_entries.size
|
|
183
|
+
next_entry = metadata_entries[j]
|
|
184
|
+
gap = next_entry.offset - (batch_end.offset + batch_end.table_length)
|
|
185
|
+
|
|
186
|
+
# If gap is small (within page threshold), include in batch
|
|
187
|
+
if gap <= page_threshold
|
|
188
|
+
batch_end = next_entry
|
|
189
|
+
batch_entries << next_entry
|
|
190
|
+
j += 1
|
|
191
|
+
else
|
|
192
|
+
break
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Read batch
|
|
197
|
+
if batch_entries.size == 1
|
|
198
|
+
# Single table, read normally
|
|
199
|
+
io.seek(batch_start.offset)
|
|
200
|
+
tag_key = batch_start.tag.dup.force_encoding("UTF-8")
|
|
201
|
+
@table_data[tag_key] = io.read(batch_start.table_length)
|
|
202
|
+
else
|
|
203
|
+
# Multiple tables, read contiguous segment
|
|
204
|
+
batch_offset = batch_start.offset
|
|
205
|
+
batch_length = (batch_end.offset + batch_end.table_length) - batch_start.offset
|
|
206
|
+
|
|
207
|
+
io.seek(batch_offset)
|
|
208
|
+
batch_data = io.read(batch_length)
|
|
209
|
+
|
|
210
|
+
# Extract individual tables from batch
|
|
211
|
+
batch_entries.each do |entry|
|
|
212
|
+
relative_offset = entry.offset - batch_offset
|
|
213
|
+
tag_key = entry.tag.dup.force_encoding("UTF-8")
|
|
214
|
+
@table_data[tag_key] = batch_data[relative_offset, entry.table_length]
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
i = j
|
|
91
219
|
end
|
|
92
220
|
end
|
|
93
221
|
|
|
@@ -138,6 +266,15 @@ module Fontisan
|
|
|
138
266
|
tables.any? { |entry| entry.tag == tag }
|
|
139
267
|
end
|
|
140
268
|
|
|
269
|
+
# Check if a table is available in the current loading mode
|
|
270
|
+
#
|
|
271
|
+
# @param tag [String] The table tag to check
|
|
272
|
+
# @return [Boolean] true if table is available in current mode
|
|
273
|
+
def table_available?(tag)
|
|
274
|
+
return false unless has_table?(tag)
|
|
275
|
+
LoadingModes.table_allowed?(@loading_mode, tag)
|
|
276
|
+
end
|
|
277
|
+
|
|
141
278
|
# Find a table entry by tag
|
|
142
279
|
#
|
|
143
280
|
# @param tag [String] The table tag to find
|
|
@@ -163,11 +300,30 @@ module Fontisan
|
|
|
163
300
|
# Get parsed table instance
|
|
164
301
|
#
|
|
165
302
|
# This method parses the raw table data into a structured table object
|
|
166
|
-
# and caches the result for subsequent calls.
|
|
303
|
+
# and caches the result for subsequent calls. Enforces mode restrictions.
|
|
167
304
|
#
|
|
168
305
|
# @param tag [String] The table tag to retrieve
|
|
169
306
|
# @return [Tables::*, nil] Parsed table object or nil if not found
|
|
307
|
+
# @raise [ArgumentError] if table is not available in current loading mode
|
|
170
308
|
def table(tag)
|
|
309
|
+
# Check mode restrictions
|
|
310
|
+
unless table_available?(tag)
|
|
311
|
+
if has_table?(tag)
|
|
312
|
+
raise ArgumentError,
|
|
313
|
+
"Table '#{tag}' is not available in #{@loading_mode} mode. " \
|
|
314
|
+
"Available tables: #{LoadingModes.tables_for(@loading_mode).inspect}"
|
|
315
|
+
else
|
|
316
|
+
return nil
|
|
317
|
+
end
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
return @parsed_tables[tag] if @parsed_tables.key?(tag)
|
|
321
|
+
|
|
322
|
+
# Lazy load table data if enabled
|
|
323
|
+
if @lazy_load_enabled && !@table_data.key?(tag)
|
|
324
|
+
load_table_data(tag)
|
|
325
|
+
end
|
|
326
|
+
|
|
171
327
|
@parsed_tables[tag] ||= parse_table(tag)
|
|
172
328
|
end
|
|
173
329
|
|
|
@@ -179,8 +335,133 @@ module Fontisan
|
|
|
179
335
|
head&.units_per_em
|
|
180
336
|
end
|
|
181
337
|
|
|
338
|
+
# Convenience methods for accessing common name table fields
|
|
339
|
+
# These are particularly useful in minimal mode
|
|
340
|
+
|
|
341
|
+
# Get font family name
|
|
342
|
+
#
|
|
343
|
+
# @return [String, nil] Family name or nil if not found
|
|
344
|
+
def family_name
|
|
345
|
+
name_table = table(Constants::NAME_TAG)
|
|
346
|
+
name_table&.english_name(Tables::Name::FAMILY)
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
# Get font subfamily name (e.g., Regular, Bold, Italic)
|
|
350
|
+
#
|
|
351
|
+
# @return [String, nil] Subfamily name or nil if not found
|
|
352
|
+
def subfamily_name
|
|
353
|
+
name_table = table(Constants::NAME_TAG)
|
|
354
|
+
name_table&.english_name(Tables::Name::SUBFAMILY)
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
# Get full font name
|
|
358
|
+
#
|
|
359
|
+
# @return [String, nil] Full name or nil if not found
|
|
360
|
+
def full_name
|
|
361
|
+
name_table = table(Constants::NAME_TAG)
|
|
362
|
+
name_table&.english_name(Tables::Name::FULL_NAME)
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
# Get PostScript name
|
|
366
|
+
#
|
|
367
|
+
# @return [String, nil] PostScript name or nil if not found
|
|
368
|
+
def post_script_name
|
|
369
|
+
name_table = table(Constants::NAME_TAG)
|
|
370
|
+
name_table&.english_name(Tables::Name::POSTSCRIPT_NAME)
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
# Get preferred family name
|
|
374
|
+
#
|
|
375
|
+
# @return [String, nil] Preferred family name or nil if not found
|
|
376
|
+
def preferred_family_name
|
|
377
|
+
name_table = table(Constants::NAME_TAG)
|
|
378
|
+
name_table&.english_name(Tables::Name::PREFERRED_FAMILY)
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
# Get preferred subfamily name
|
|
382
|
+
#
|
|
383
|
+
# @return [String, nil] Preferred subfamily name or nil if not found
|
|
384
|
+
def preferred_subfamily_name
|
|
385
|
+
name_table = table(Constants::NAME_TAG)
|
|
386
|
+
name_table&.english_name(Tables::Name::PREFERRED_SUBFAMILY)
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
# Close the IO source (for lazy loading)
|
|
390
|
+
#
|
|
391
|
+
# @return [void]
|
|
392
|
+
def close
|
|
393
|
+
@io_source&.close
|
|
394
|
+
@io_source = nil
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
# Setup finalizer for cleanup
|
|
398
|
+
#
|
|
399
|
+
# @return [void]
|
|
400
|
+
def setup_finalizer
|
|
401
|
+
ObjectSpace.define_finalizer(self, self.class.finalize(@io_source))
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
# Finalizer proc for closing IO
|
|
405
|
+
#
|
|
406
|
+
# @param io [IO] The IO object to close
|
|
407
|
+
# @return [Proc] The finalizer proc
|
|
408
|
+
def self.finalize(io)
|
|
409
|
+
proc { io&.close }
|
|
410
|
+
end
|
|
411
|
+
|
|
182
412
|
private
|
|
183
413
|
|
|
414
|
+
# Load a single table's data on demand
|
|
415
|
+
#
|
|
416
|
+
# Uses page-aligned reads and caches pages to ensure lazy loading
|
|
417
|
+
# performance is not slower than eager loading.
|
|
418
|
+
#
|
|
419
|
+
# @param tag [String] The table tag to load
|
|
420
|
+
# @return [void]
|
|
421
|
+
def load_table_data(tag)
|
|
422
|
+
return unless @io_source
|
|
423
|
+
|
|
424
|
+
entry = find_table_entry(tag)
|
|
425
|
+
return nil unless entry
|
|
426
|
+
|
|
427
|
+
# Use page-aligned reading with caching
|
|
428
|
+
table_start = entry.offset
|
|
429
|
+
table_end = entry.offset + entry.table_length
|
|
430
|
+
|
|
431
|
+
# Calculate page boundaries
|
|
432
|
+
page_start = (table_start / PAGE_SIZE) * PAGE_SIZE
|
|
433
|
+
page_end = ((table_end + PAGE_SIZE - 1) / PAGE_SIZE) * PAGE_SIZE
|
|
434
|
+
|
|
435
|
+
# Read all required pages (or use cached pages)
|
|
436
|
+
table_data_parts = []
|
|
437
|
+
current_page = page_start
|
|
438
|
+
|
|
439
|
+
while current_page < page_end
|
|
440
|
+
page_data = @page_cache[current_page]
|
|
441
|
+
|
|
442
|
+
unless page_data
|
|
443
|
+
# Read page from disk and cache it
|
|
444
|
+
@io_source.seek(current_page)
|
|
445
|
+
page_data = @io_source.read(PAGE_SIZE) || ""
|
|
446
|
+
@page_cache[current_page] = page_data
|
|
447
|
+
end
|
|
448
|
+
|
|
449
|
+
# Calculate which part of this page we need
|
|
450
|
+
chunk_start = [table_start - current_page, 0].max
|
|
451
|
+
chunk_end = [table_end - current_page, PAGE_SIZE].min
|
|
452
|
+
|
|
453
|
+
if chunk_end > chunk_start
|
|
454
|
+
table_data_parts << page_data[chunk_start...chunk_end]
|
|
455
|
+
end
|
|
456
|
+
|
|
457
|
+
current_page += PAGE_SIZE
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
# Combine parts and store
|
|
461
|
+
tag_key = tag.dup.force_encoding("UTF-8")
|
|
462
|
+
@table_data[tag_key] = table_data_parts.join
|
|
463
|
+
end
|
|
464
|
+
|
|
184
465
|
# Parse a table from raw data
|
|
185
466
|
#
|
|
186
467
|
# @param tag [String] The table tag to parse
|
|
@@ -202,6 +483,9 @@ module Fontisan
|
|
|
202
483
|
def table_class_for(tag)
|
|
203
484
|
{
|
|
204
485
|
Constants::HEAD_TAG => Tables::Head,
|
|
486
|
+
Constants::HHEA_TAG => Tables::Hhea,
|
|
487
|
+
Constants::HMTX_TAG => Tables::Hmtx,
|
|
488
|
+
Constants::MAXP_TAG => Tables::Maxp,
|
|
205
489
|
Constants::NAME_TAG => Tables::Name,
|
|
206
490
|
Constants::OS2_TAG => Tables::Os2,
|
|
207
491
|
Constants::POST_TAG => Tables::Post,
|
|
@@ -209,6 +493,8 @@ module Fontisan
|
|
|
209
493
|
Constants::FVAR_TAG => Tables::Fvar,
|
|
210
494
|
Constants::GSUB_TAG => Tables::Gsub,
|
|
211
495
|
Constants::GPOS_TAG => Tables::Gpos,
|
|
496
|
+
Constants::GLYF_TAG => Tables::Glyf,
|
|
497
|
+
Constants::LOCA_TAG => Tables::Loca,
|
|
212
498
|
}[tag]
|
|
213
499
|
end
|
|
214
500
|
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fontisan
|
|
4
|
+
module Optimizers
|
|
5
|
+
# Rewrites CharStrings by replacing repeated patterns with subroutine calls.
|
|
6
|
+
# Uses position-aware replacement to handle multiple patterns per glyph
|
|
7
|
+
# without offset corruption.
|
|
8
|
+
#
|
|
9
|
+
# @example Basic usage
|
|
10
|
+
# builder = SubroutineBuilder.new(patterns, type: :local)
|
|
11
|
+
# builder.build
|
|
12
|
+
# subroutine_map = patterns.each_with_index.to_h { |p, i| [p.bytes, i] }
|
|
13
|
+
# rewriter = CharstringRewriter.new(subroutine_map, builder)
|
|
14
|
+
# rewritten = rewriter.rewrite(charstring, patterns_for_glyph)
|
|
15
|
+
# valid = rewriter.validate(rewritten)
|
|
16
|
+
#
|
|
17
|
+
# @see docs/SUBROUTINE_ARCHITECTURE.md
|
|
18
|
+
class CharstringRewriter
|
|
19
|
+
# Initialize rewriter with subroutine map and builder
|
|
20
|
+
# @param subroutine_map [Hash<String, Integer>] pattern bytes => subroutine_id
|
|
21
|
+
# @param builder [SubroutineBuilder] builder for creating calls
|
|
22
|
+
def initialize(subroutine_map, builder)
|
|
23
|
+
@subroutine_map = subroutine_map
|
|
24
|
+
@builder = builder
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Rewrite a CharString by replacing patterns with subroutine calls
|
|
28
|
+
# Sorts patterns by position (descending) to avoid offset issues when
|
|
29
|
+
# replacing multiple patterns in the same CharString.
|
|
30
|
+
#
|
|
31
|
+
# @param charstring [String] original CharString bytes
|
|
32
|
+
# @param patterns [Array<Pattern>] patterns to replace in this CharString
|
|
33
|
+
# @return [String] rewritten CharString with subroutine calls
|
|
34
|
+
def rewrite(charstring, patterns)
|
|
35
|
+
return charstring if patterns.empty?
|
|
36
|
+
|
|
37
|
+
# Build list of all replacements: [position, pattern]
|
|
38
|
+
replacements = build_replacement_list(charstring, patterns)
|
|
39
|
+
|
|
40
|
+
# Remove overlapping replacements
|
|
41
|
+
replacements = remove_overlaps(replacements)
|
|
42
|
+
|
|
43
|
+
# Sort by position (descending) to avoid offset corruption
|
|
44
|
+
replacements.sort_by! { |pos, _pattern| -pos }
|
|
45
|
+
|
|
46
|
+
# Apply each replacement
|
|
47
|
+
rewritten = charstring.dup
|
|
48
|
+
replacements.each do |position, pattern|
|
|
49
|
+
subroutine_id = @subroutine_map[pattern.bytes]
|
|
50
|
+
next if subroutine_id.nil?
|
|
51
|
+
|
|
52
|
+
call = @builder.create_call(subroutine_id)
|
|
53
|
+
|
|
54
|
+
# Replace pattern with call at position
|
|
55
|
+
rewritten[position, pattern.length] = call
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
rewritten
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Validate rewritten CharString for structural correctness
|
|
62
|
+
# For now, performs basic validation. Future: full CFF parsing.
|
|
63
|
+
#
|
|
64
|
+
# @param charstring [String] CharString to validate
|
|
65
|
+
# @return [Boolean] true if valid, false otherwise
|
|
66
|
+
def validate(charstring)
|
|
67
|
+
return false if charstring.nil? || charstring.empty?
|
|
68
|
+
|
|
69
|
+
# Basic validation: check for return operator at end
|
|
70
|
+
# and reasonable length
|
|
71
|
+
return false if charstring.empty?
|
|
72
|
+
|
|
73
|
+
# More comprehensive validation can be added later
|
|
74
|
+
true
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
# Remove overlapping replacements, keeping higher-value patterns
|
|
80
|
+
# When two patterns occupy overlapping byte positions, we keep the one
|
|
81
|
+
# with higher savings to maximize total optimization benefit.
|
|
82
|
+
#
|
|
83
|
+
# @param replacements [Array<Array>] array of [position, pattern] pairs
|
|
84
|
+
# @return [Array<Array>] non-overlapping replacements
|
|
85
|
+
def remove_overlaps(replacements)
|
|
86
|
+
return replacements if replacements.empty?
|
|
87
|
+
|
|
88
|
+
# Sort by position (ascending) then by savings (descending)
|
|
89
|
+
sorted = replacements.sort_by { |pos, pattern| [pos, -pattern.savings] }
|
|
90
|
+
|
|
91
|
+
non_overlapping = []
|
|
92
|
+
last_end = 0
|
|
93
|
+
|
|
94
|
+
sorted.each do |position, pattern|
|
|
95
|
+
pattern_end = position + pattern.length
|
|
96
|
+
|
|
97
|
+
# Check if this replacement starts after the last one ended
|
|
98
|
+
if position >= last_end
|
|
99
|
+
# No overlap - add this replacement
|
|
100
|
+
non_overlapping << [position, pattern]
|
|
101
|
+
last_end = pattern_end
|
|
102
|
+
elsif non_overlapping.any?
|
|
103
|
+
# Overlap detected - compare with previous
|
|
104
|
+
prev_position, prev_pattern = non_overlapping.last
|
|
105
|
+
prev_position + prev_pattern.length
|
|
106
|
+
|
|
107
|
+
# If current pattern has higher savings, replace the previous one
|
|
108
|
+
if pattern.savings > prev_pattern.savings
|
|
109
|
+
# Current pattern is more valuable - replace previous
|
|
110
|
+
non_overlapping[-1] = [position, pattern]
|
|
111
|
+
last_end = pattern_end
|
|
112
|
+
end
|
|
113
|
+
# else: keep previous, skip current
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
non_overlapping
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Build list of all pattern replacements with their positions
|
|
121
|
+
# @param charstring [String] CharString being rewritten
|
|
122
|
+
# @param patterns [Array<Pattern>] patterns to find
|
|
123
|
+
# @return [Array<Array>] array of [position, pattern] pairs
|
|
124
|
+
def build_replacement_list(charstring, patterns)
|
|
125
|
+
replacements = []
|
|
126
|
+
|
|
127
|
+
patterns.each do |pattern|
|
|
128
|
+
# Find all positions where this pattern occurs
|
|
129
|
+
positions = find_pattern_positions(charstring, pattern)
|
|
130
|
+
|
|
131
|
+
positions.each do |position|
|
|
132
|
+
replacements << [position, pattern]
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
replacements
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Find all positions where a pattern occurs in the CharString
|
|
140
|
+
# @param charstring [String] CharString to search
|
|
141
|
+
# @param pattern [Pattern] pattern to find
|
|
142
|
+
# @return [Array<Integer>] array of start positions
|
|
143
|
+
def find_pattern_positions(charstring, pattern)
|
|
144
|
+
positions = []
|
|
145
|
+
offset = 0
|
|
146
|
+
|
|
147
|
+
while offset <= charstring.length - pattern.length
|
|
148
|
+
if charstring[offset, pattern.length] == pattern.bytes
|
|
149
|
+
positions << offset
|
|
150
|
+
# Move past this occurrence
|
|
151
|
+
offset += pattern.length
|
|
152
|
+
else
|
|
153
|
+
offset += 1
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
positions
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|