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
|
|
@@ -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
|
|
|
@@ -130,6 +258,20 @@ module Fontisan
|
|
|
130
258
|
true
|
|
131
259
|
end
|
|
132
260
|
|
|
261
|
+
# Check if font is TrueType flavored
|
|
262
|
+
#
|
|
263
|
+
# @return [Boolean] false for OpenType fonts
|
|
264
|
+
def truetype?
|
|
265
|
+
false
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# Check if font is CFF flavored
|
|
269
|
+
#
|
|
270
|
+
# @return [Boolean] true for OpenType fonts
|
|
271
|
+
def cff?
|
|
272
|
+
true
|
|
273
|
+
end
|
|
274
|
+
|
|
133
275
|
# Check if font has a specific table
|
|
134
276
|
#
|
|
135
277
|
# @param tag [String] The table tag to check for
|
|
@@ -138,6 +280,15 @@ module Fontisan
|
|
|
138
280
|
tables.any? { |entry| entry.tag == tag }
|
|
139
281
|
end
|
|
140
282
|
|
|
283
|
+
# Check if a table is available in the current loading mode
|
|
284
|
+
#
|
|
285
|
+
# @param tag [String] The table tag to check
|
|
286
|
+
# @return [Boolean] true if table is available in current mode
|
|
287
|
+
def table_available?(tag)
|
|
288
|
+
return false unless has_table?(tag)
|
|
289
|
+
LoadingModes.table_allowed?(@loading_mode, tag)
|
|
290
|
+
end
|
|
291
|
+
|
|
141
292
|
# Find a table entry by tag
|
|
142
293
|
#
|
|
143
294
|
# @param tag [String] The table tag to find
|
|
@@ -163,11 +314,30 @@ module Fontisan
|
|
|
163
314
|
# Get parsed table instance
|
|
164
315
|
#
|
|
165
316
|
# This method parses the raw table data into a structured table object
|
|
166
|
-
# and caches the result for subsequent calls.
|
|
317
|
+
# and caches the result for subsequent calls. Enforces mode restrictions.
|
|
167
318
|
#
|
|
168
319
|
# @param tag [String] The table tag to retrieve
|
|
169
320
|
# @return [Tables::*, nil] Parsed table object or nil if not found
|
|
321
|
+
# @raise [ArgumentError] if table is not available in current loading mode
|
|
170
322
|
def table(tag)
|
|
323
|
+
# Check mode restrictions
|
|
324
|
+
unless table_available?(tag)
|
|
325
|
+
if has_table?(tag)
|
|
326
|
+
raise ArgumentError,
|
|
327
|
+
"Table '#{tag}' is not available in #{@loading_mode} mode. " \
|
|
328
|
+
"Available tables: #{LoadingModes.tables_for(@loading_mode).inspect}"
|
|
329
|
+
else
|
|
330
|
+
return nil
|
|
331
|
+
end
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
return @parsed_tables[tag] if @parsed_tables.key?(tag)
|
|
335
|
+
|
|
336
|
+
# Lazy load table data if enabled
|
|
337
|
+
if @lazy_load_enabled && !@table_data.key?(tag)
|
|
338
|
+
load_table_data(tag)
|
|
339
|
+
end
|
|
340
|
+
|
|
171
341
|
@parsed_tables[tag] ||= parse_table(tag)
|
|
172
342
|
end
|
|
173
343
|
|
|
@@ -179,8 +349,133 @@ module Fontisan
|
|
|
179
349
|
head&.units_per_em
|
|
180
350
|
end
|
|
181
351
|
|
|
352
|
+
# Convenience methods for accessing common name table fields
|
|
353
|
+
# These are particularly useful in minimal mode
|
|
354
|
+
|
|
355
|
+
# Get font family name
|
|
356
|
+
#
|
|
357
|
+
# @return [String, nil] Family name or nil if not found
|
|
358
|
+
def family_name
|
|
359
|
+
name_table = table(Constants::NAME_TAG)
|
|
360
|
+
name_table&.english_name(Tables::Name::FAMILY)
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
# Get font subfamily name (e.g., Regular, Bold, Italic)
|
|
364
|
+
#
|
|
365
|
+
# @return [String, nil] Subfamily name or nil if not found
|
|
366
|
+
def subfamily_name
|
|
367
|
+
name_table = table(Constants::NAME_TAG)
|
|
368
|
+
name_table&.english_name(Tables::Name::SUBFAMILY)
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
# Get full font name
|
|
372
|
+
#
|
|
373
|
+
# @return [String, nil] Full name or nil if not found
|
|
374
|
+
def full_name
|
|
375
|
+
name_table = table(Constants::NAME_TAG)
|
|
376
|
+
name_table&.english_name(Tables::Name::FULL_NAME)
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
# Get PostScript name
|
|
380
|
+
#
|
|
381
|
+
# @return [String, nil] PostScript name or nil if not found
|
|
382
|
+
def post_script_name
|
|
383
|
+
name_table = table(Constants::NAME_TAG)
|
|
384
|
+
name_table&.english_name(Tables::Name::POSTSCRIPT_NAME)
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
# Get preferred family name
|
|
388
|
+
#
|
|
389
|
+
# @return [String, nil] Preferred family name or nil if not found
|
|
390
|
+
def preferred_family_name
|
|
391
|
+
name_table = table(Constants::NAME_TAG)
|
|
392
|
+
name_table&.english_name(Tables::Name::PREFERRED_FAMILY)
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
# Get preferred subfamily name
|
|
396
|
+
#
|
|
397
|
+
# @return [String, nil] Preferred subfamily name or nil if not found
|
|
398
|
+
def preferred_subfamily_name
|
|
399
|
+
name_table = table(Constants::NAME_TAG)
|
|
400
|
+
name_table&.english_name(Tables::Name::PREFERRED_SUBFAMILY)
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
# Close the IO source (for lazy loading)
|
|
404
|
+
#
|
|
405
|
+
# @return [void]
|
|
406
|
+
def close
|
|
407
|
+
@io_source&.close
|
|
408
|
+
@io_source = nil
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
# Setup finalizer for cleanup
|
|
412
|
+
#
|
|
413
|
+
# @return [void]
|
|
414
|
+
def setup_finalizer
|
|
415
|
+
ObjectSpace.define_finalizer(self, self.class.finalize(@io_source))
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
# Finalizer proc for closing IO
|
|
419
|
+
#
|
|
420
|
+
# @param io [IO] The IO object to close
|
|
421
|
+
# @return [Proc] The finalizer proc
|
|
422
|
+
def self.finalize(io)
|
|
423
|
+
proc { io&.close }
|
|
424
|
+
end
|
|
425
|
+
|
|
182
426
|
private
|
|
183
427
|
|
|
428
|
+
# Load a single table's data on demand
|
|
429
|
+
#
|
|
430
|
+
# Uses page-aligned reads and caches pages to ensure lazy loading
|
|
431
|
+
# performance is not slower than eager loading.
|
|
432
|
+
#
|
|
433
|
+
# @param tag [String] The table tag to load
|
|
434
|
+
# @return [void]
|
|
435
|
+
def load_table_data(tag)
|
|
436
|
+
return unless @io_source
|
|
437
|
+
|
|
438
|
+
entry = find_table_entry(tag)
|
|
439
|
+
return nil unless entry
|
|
440
|
+
|
|
441
|
+
# Use page-aligned reading with caching
|
|
442
|
+
table_start = entry.offset
|
|
443
|
+
table_end = entry.offset + entry.table_length
|
|
444
|
+
|
|
445
|
+
# Calculate page boundaries
|
|
446
|
+
page_start = (table_start / PAGE_SIZE) * PAGE_SIZE
|
|
447
|
+
page_end = ((table_end + PAGE_SIZE - 1) / PAGE_SIZE) * PAGE_SIZE
|
|
448
|
+
|
|
449
|
+
# Read all required pages (or use cached pages)
|
|
450
|
+
table_data_parts = []
|
|
451
|
+
current_page = page_start
|
|
452
|
+
|
|
453
|
+
while current_page < page_end
|
|
454
|
+
page_data = @page_cache[current_page]
|
|
455
|
+
|
|
456
|
+
unless page_data
|
|
457
|
+
# Read page from disk and cache it
|
|
458
|
+
@io_source.seek(current_page)
|
|
459
|
+
page_data = @io_source.read(PAGE_SIZE) || ""
|
|
460
|
+
@page_cache[current_page] = page_data
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
# Calculate which part of this page we need
|
|
464
|
+
chunk_start = [table_start - current_page, 0].max
|
|
465
|
+
chunk_end = [table_end - current_page, PAGE_SIZE].min
|
|
466
|
+
|
|
467
|
+
if chunk_end > chunk_start
|
|
468
|
+
table_data_parts << page_data[chunk_start...chunk_end]
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
current_page += PAGE_SIZE
|
|
472
|
+
end
|
|
473
|
+
|
|
474
|
+
# Combine parts and store
|
|
475
|
+
tag_key = tag.dup.force_encoding("UTF-8")
|
|
476
|
+
@table_data[tag_key] = table_data_parts.join
|
|
477
|
+
end
|
|
478
|
+
|
|
184
479
|
# Parse a table from raw data
|
|
185
480
|
#
|
|
186
481
|
# @param tag [String] The table tag to parse
|
|
@@ -202,13 +497,19 @@ module Fontisan
|
|
|
202
497
|
def table_class_for(tag)
|
|
203
498
|
{
|
|
204
499
|
Constants::HEAD_TAG => Tables::Head,
|
|
500
|
+
Constants::HHEA_TAG => Tables::Hhea,
|
|
501
|
+
Constants::HMTX_TAG => Tables::Hmtx,
|
|
502
|
+
Constants::MAXP_TAG => Tables::Maxp,
|
|
205
503
|
Constants::NAME_TAG => Tables::Name,
|
|
206
504
|
Constants::OS2_TAG => Tables::Os2,
|
|
207
505
|
Constants::POST_TAG => Tables::Post,
|
|
208
506
|
Constants::CMAP_TAG => Tables::Cmap,
|
|
507
|
+
Constants::CFF_TAG => Tables::Cff,
|
|
209
508
|
Constants::FVAR_TAG => Tables::Fvar,
|
|
210
509
|
Constants::GSUB_TAG => Tables::Gsub,
|
|
211
510
|
Constants::GPOS_TAG => Tables::Gpos,
|
|
511
|
+
Constants::GLYF_TAG => Tables::Glyf,
|
|
512
|
+
Constants::LOCA_TAG => Tables::Loca,
|
|
212
513
|
}[tag]
|
|
213
514
|
end
|
|
214
515
|
|
|
@@ -272,18 +573,19 @@ module Fontisan
|
|
|
272
573
|
# @param path [String] Path to the OTF file
|
|
273
574
|
# @return [void]
|
|
274
575
|
def update_checksum_adjustment_in_file(path)
|
|
275
|
-
#
|
|
276
|
-
|
|
576
|
+
# Use tempfile-based checksum calculation for Windows compatibility
|
|
577
|
+
# This keeps the tempfile alive until we're done with the checksum
|
|
578
|
+
File.open(path, "r+b") do |io|
|
|
579
|
+
checksum, _tmpfile = Utilities::ChecksumCalculator.calculate_checksum_from_io_with_tempfile(io)
|
|
277
580
|
|
|
278
|
-
|
|
279
|
-
|
|
581
|
+
# Calculate adjustment
|
|
582
|
+
adjustment = Utilities::ChecksumCalculator.calculate_adjustment(checksum)
|
|
280
583
|
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
584
|
+
# Find head table position
|
|
585
|
+
head_entry = head_table
|
|
586
|
+
return unless head_entry
|
|
284
587
|
|
|
285
|
-
|
|
286
|
-
File.open(path, "r+b") do |io|
|
|
588
|
+
# Write adjustment to head table (offset 8 within head table)
|
|
287
589
|
io.seek(head_entry.offset + 8)
|
|
288
590
|
io.write([adjustment].pack("N"))
|
|
289
591
|
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fontisan
|
|
4
|
+
# Extensions to OpenTypeFont for table-based construction
|
|
5
|
+
class OpenTypeFont
|
|
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 [OpenTypeFont] 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 = 0x4F54544F # 'OTTO' for OpenType/CFF
|
|
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,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
|