fontisan 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.rubocop_todo.yml +529 -65
- data/Gemfile +1 -0
- data/LICENSE +5 -1
- data/README.adoc +1301 -275
- data/Rakefile +27 -2
- data/benchmark/variation_quick_bench.rb +47 -0
- data/docs/EXTRACT_TTC_MIGRATION.md +549 -0
- data/fontisan.gemspec +4 -1
- data/lib/fontisan/binary/base_record.rb +22 -1
- data/lib/fontisan/cli.rb +309 -0
- data/lib/fontisan/collection/builder.rb +260 -0
- data/lib/fontisan/collection/offset_calculator.rb +227 -0
- data/lib/fontisan/collection/table_analyzer.rb +204 -0
- data/lib/fontisan/collection/table_deduplicator.rb +241 -0
- data/lib/fontisan/collection/writer.rb +306 -0
- data/lib/fontisan/commands/base_command.rb +8 -1
- data/lib/fontisan/commands/convert_command.rb +291 -0
- data/lib/fontisan/commands/export_command.rb +161 -0
- data/lib/fontisan/commands/info_command.rb +40 -6
- data/lib/fontisan/commands/instance_command.rb +295 -0
- data/lib/fontisan/commands/ls_command.rb +113 -0
- data/lib/fontisan/commands/pack_command.rb +241 -0
- data/lib/fontisan/commands/subset_command.rb +245 -0
- data/lib/fontisan/commands/unpack_command.rb +338 -0
- data/lib/fontisan/commands/validate_command.rb +178 -0
- data/lib/fontisan/commands/variable_command.rb +30 -1
- data/lib/fontisan/config/collection_settings.yml +56 -0
- data/lib/fontisan/config/conversion_matrix.yml +212 -0
- data/lib/fontisan/config/export_settings.yml +66 -0
- data/lib/fontisan/config/subset_profiles.yml +100 -0
- data/lib/fontisan/config/svg_settings.yml +60 -0
- data/lib/fontisan/config/validation_rules.yml +149 -0
- data/lib/fontisan/config/variable_settings.yml +99 -0
- data/lib/fontisan/config/woff2_settings.yml +77 -0
- data/lib/fontisan/constants.rb +69 -0
- data/lib/fontisan/converters/conversion_strategy.rb +96 -0
- data/lib/fontisan/converters/format_converter.rb +259 -0
- data/lib/fontisan/converters/outline_converter.rb +936 -0
- data/lib/fontisan/converters/svg_generator.rb +244 -0
- data/lib/fontisan/converters/table_copier.rb +117 -0
- data/lib/fontisan/converters/woff2_encoder.rb +416 -0
- data/lib/fontisan/converters/woff_writer.rb +391 -0
- data/lib/fontisan/error.rb +203 -0
- data/lib/fontisan/export/exporter.rb +262 -0
- data/lib/fontisan/export/table_serializer.rb +255 -0
- data/lib/fontisan/export/transformers/font_to_ttx.rb +172 -0
- data/lib/fontisan/export/transformers/head_transformer.rb +96 -0
- data/lib/fontisan/export/transformers/hhea_transformer.rb +59 -0
- data/lib/fontisan/export/transformers/maxp_transformer.rb +63 -0
- data/lib/fontisan/export/transformers/name_transformer.rb +63 -0
- data/lib/fontisan/export/transformers/os2_transformer.rb +121 -0
- data/lib/fontisan/export/transformers/post_transformer.rb +51 -0
- data/lib/fontisan/export/ttx_generator.rb +527 -0
- data/lib/fontisan/export/ttx_parser.rb +300 -0
- data/lib/fontisan/font_loader.rb +121 -12
- data/lib/fontisan/font_writer.rb +301 -0
- data/lib/fontisan/formatters/text_formatter.rb +102 -0
- data/lib/fontisan/glyph_accessor.rb +503 -0
- data/lib/fontisan/hints/hint_converter.rb +177 -0
- data/lib/fontisan/hints/postscript_hint_applier.rb +185 -0
- data/lib/fontisan/hints/postscript_hint_extractor.rb +254 -0
- data/lib/fontisan/hints/truetype_hint_applier.rb +71 -0
- data/lib/fontisan/hints/truetype_hint_extractor.rb +162 -0
- data/lib/fontisan/loading_modes.rb +113 -0
- data/lib/fontisan/metrics_calculator.rb +277 -0
- data/lib/fontisan/models/collection_font_summary.rb +52 -0
- data/lib/fontisan/models/collection_info.rb +76 -0
- data/lib/fontisan/models/collection_list_info.rb +37 -0
- data/lib/fontisan/models/font_export.rb +158 -0
- data/lib/fontisan/models/font_summary.rb +48 -0
- data/lib/fontisan/models/glyph_outline.rb +343 -0
- data/lib/fontisan/models/hint.rb +233 -0
- data/lib/fontisan/models/outline.rb +664 -0
- data/lib/fontisan/models/table_sharing_info.rb +40 -0
- data/lib/fontisan/models/ttx/glyph_order.rb +31 -0
- data/lib/fontisan/models/ttx/tables/binary_table.rb +67 -0
- data/lib/fontisan/models/ttx/tables/head_table.rb +74 -0
- data/lib/fontisan/models/ttx/tables/hhea_table.rb +74 -0
- data/lib/fontisan/models/ttx/tables/maxp_table.rb +55 -0
- data/lib/fontisan/models/ttx/tables/name_table.rb +45 -0
- data/lib/fontisan/models/ttx/tables/os2_table.rb +157 -0
- data/lib/fontisan/models/ttx/tables/post_table.rb +50 -0
- data/lib/fontisan/models/ttx/ttfont.rb +49 -0
- data/lib/fontisan/models/validation_report.rb +203 -0
- data/lib/fontisan/open_type_collection.rb +156 -2
- data/lib/fontisan/open_type_font.rb +296 -10
- data/lib/fontisan/optimizers/charstring_rewriter.rb +161 -0
- data/lib/fontisan/optimizers/pattern_analyzer.rb +308 -0
- data/lib/fontisan/optimizers/stack_tracker.rb +246 -0
- data/lib/fontisan/optimizers/subroutine_builder.rb +134 -0
- data/lib/fontisan/optimizers/subroutine_generator.rb +207 -0
- data/lib/fontisan/optimizers/subroutine_optimizer.rb +107 -0
- data/lib/fontisan/outline_extractor.rb +423 -0
- data/lib/fontisan/subset/builder.rb +268 -0
- data/lib/fontisan/subset/glyph_mapping.rb +215 -0
- data/lib/fontisan/subset/options.rb +142 -0
- data/lib/fontisan/subset/profile.rb +152 -0
- data/lib/fontisan/subset/table_subsetter.rb +461 -0
- data/lib/fontisan/svg/font_face_generator.rb +278 -0
- data/lib/fontisan/svg/font_generator.rb +264 -0
- data/lib/fontisan/svg/glyph_generator.rb +168 -0
- data/lib/fontisan/svg/view_box_calculator.rb +137 -0
- data/lib/fontisan/tables/cff/cff_glyph.rb +176 -0
- data/lib/fontisan/tables/cff/charset.rb +282 -0
- data/lib/fontisan/tables/cff/charstring.rb +905 -0
- data/lib/fontisan/tables/cff/charstring_builder.rb +322 -0
- data/lib/fontisan/tables/cff/charstrings_index.rb +162 -0
- data/lib/fontisan/tables/cff/dict.rb +351 -0
- data/lib/fontisan/tables/cff/dict_builder.rb +242 -0
- data/lib/fontisan/tables/cff/encoding.rb +274 -0
- data/lib/fontisan/tables/cff/header.rb +102 -0
- data/lib/fontisan/tables/cff/index.rb +237 -0
- data/lib/fontisan/tables/cff/index_builder.rb +170 -0
- data/lib/fontisan/tables/cff/private_dict.rb +284 -0
- data/lib/fontisan/tables/cff/top_dict.rb +236 -0
- data/lib/fontisan/tables/cff.rb +487 -0
- data/lib/fontisan/tables/cff2/blend_operator.rb +240 -0
- data/lib/fontisan/tables/cff2/charstring_parser.rb +591 -0
- data/lib/fontisan/tables/cff2/operand_stack.rb +232 -0
- data/lib/fontisan/tables/cff2.rb +341 -0
- data/lib/fontisan/tables/cvar.rb +242 -0
- data/lib/fontisan/tables/fvar.rb +2 -2
- data/lib/fontisan/tables/glyf/compound_glyph.rb +483 -0
- data/lib/fontisan/tables/glyf/compound_glyph_resolver.rb +136 -0
- data/lib/fontisan/tables/glyf/curve_converter.rb +343 -0
- data/lib/fontisan/tables/glyf/glyph_builder.rb +450 -0
- data/lib/fontisan/tables/glyf/simple_glyph.rb +382 -0
- data/lib/fontisan/tables/glyf.rb +235 -0
- data/lib/fontisan/tables/gvar.rb +270 -0
- data/lib/fontisan/tables/hhea.rb +124 -0
- data/lib/fontisan/tables/hmtx.rb +287 -0
- data/lib/fontisan/tables/hvar.rb +191 -0
- data/lib/fontisan/tables/loca.rb +322 -0
- data/lib/fontisan/tables/maxp.rb +192 -0
- data/lib/fontisan/tables/mvar.rb +185 -0
- data/lib/fontisan/tables/name.rb +99 -30
- data/lib/fontisan/tables/variation_common.rb +346 -0
- data/lib/fontisan/tables/vvar.rb +234 -0
- data/lib/fontisan/true_type_collection.rb +156 -2
- data/lib/fontisan/true_type_font.rb +297 -11
- data/lib/fontisan/utilities/brotli_wrapper.rb +159 -0
- data/lib/fontisan/utilities/checksum_calculator.rb +18 -0
- data/lib/fontisan/utils/thread_pool.rb +134 -0
- data/lib/fontisan/validation/checksum_validator.rb +170 -0
- data/lib/fontisan/validation/consistency_validator.rb +197 -0
- data/lib/fontisan/validation/structure_validator.rb +198 -0
- data/lib/fontisan/validation/table_validator.rb +158 -0
- data/lib/fontisan/validation/validator.rb +152 -0
- data/lib/fontisan/variable/axis_normalizer.rb +215 -0
- data/lib/fontisan/variable/delta_applicator.rb +313 -0
- data/lib/fontisan/variable/glyph_delta_processor.rb +218 -0
- data/lib/fontisan/variable/instancer.rb +344 -0
- data/lib/fontisan/variable/metric_delta_processor.rb +282 -0
- data/lib/fontisan/variable/region_matcher.rb +208 -0
- data/lib/fontisan/variable/static_font_builder.rb +213 -0
- data/lib/fontisan/variable/table_updater.rb +219 -0
- data/lib/fontisan/variation/blend_applier.rb +199 -0
- data/lib/fontisan/variation/cache.rb +298 -0
- data/lib/fontisan/variation/cache_key_builder.rb +162 -0
- data/lib/fontisan/variation/converter.rb +268 -0
- data/lib/fontisan/variation/data_extractor.rb +86 -0
- data/lib/fontisan/variation/delta_applier.rb +266 -0
- data/lib/fontisan/variation/delta_parser.rb +228 -0
- data/lib/fontisan/variation/inspector.rb +275 -0
- data/lib/fontisan/variation/instance_generator.rb +273 -0
- data/lib/fontisan/variation/interpolator.rb +231 -0
- data/lib/fontisan/variation/metrics_adjuster.rb +318 -0
- data/lib/fontisan/variation/optimizer.rb +418 -0
- data/lib/fontisan/variation/parallel_generator.rb +150 -0
- data/lib/fontisan/variation/region_matcher.rb +221 -0
- data/lib/fontisan/variation/subsetter.rb +463 -0
- data/lib/fontisan/variation/table_accessor.rb +105 -0
- data/lib/fontisan/variation/validator.rb +345 -0
- data/lib/fontisan/variation/variation_context.rb +211 -0
- data/lib/fontisan/version.rb +1 -1
- data/lib/fontisan/woff2/directory.rb +257 -0
- data/lib/fontisan/woff2/header.rb +101 -0
- data/lib/fontisan/woff2/table_transformer.rb +163 -0
- data/lib/fontisan/woff2_font.rb +712 -0
- data/lib/fontisan/woff_font.rb +483 -0
- data/lib/fontisan.rb +120 -0
- data/scripts/compare_stack_aware.rb +187 -0
- data/scripts/measure_optimization.rb +141 -0
- metadata +205 -4
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../constants"
|
|
4
|
+
require_relative "../utilities/checksum_calculator"
|
|
5
|
+
|
|
6
|
+
module Fontisan
|
|
7
|
+
module Collection
|
|
8
|
+
# CollectionWriter writes binary TTC/OTC files
|
|
9
|
+
#
|
|
10
|
+
# Single responsibility: Write complete TTC/OTC binary structure to disk
|
|
11
|
+
# including header, offset table, font directories, and table data.
|
|
12
|
+
# Handles checksums and proper binary formatting.
|
|
13
|
+
#
|
|
14
|
+
# @example Write collection
|
|
15
|
+
# writer = CollectionWriter.new(fonts, sharing_map, offsets)
|
|
16
|
+
# writer.write_to_file("output.ttc")
|
|
17
|
+
class Writer
|
|
18
|
+
# TTC signature
|
|
19
|
+
TTC_TAG = "ttcf"
|
|
20
|
+
|
|
21
|
+
# TTC version 1.0 (major=1, minor=0)
|
|
22
|
+
VERSION_1_0_MAJOR = 1
|
|
23
|
+
VERSION_1_0_MINOR = 0
|
|
24
|
+
|
|
25
|
+
# Initialize writer
|
|
26
|
+
#
|
|
27
|
+
# @param fonts [Array<TrueTypeFont, OpenTypeFont>] Source fonts
|
|
28
|
+
# @param sharing_map [Hash] Sharing map from TableDeduplicator
|
|
29
|
+
# @param offsets [Hash] Offset map from OffsetCalculator
|
|
30
|
+
# @param format [Symbol] Format type (:ttc or :otc)
|
|
31
|
+
# @raise [ArgumentError] if parameters are invalid
|
|
32
|
+
def initialize(fonts, sharing_map, offsets, format: :ttc)
|
|
33
|
+
if fonts.nil? || fonts.empty?
|
|
34
|
+
raise ArgumentError,
|
|
35
|
+
"fonts cannot be nil or empty"
|
|
36
|
+
end
|
|
37
|
+
raise ArgumentError, "sharing_map cannot be nil" if sharing_map.nil?
|
|
38
|
+
raise ArgumentError, "offsets cannot be nil" if offsets.nil?
|
|
39
|
+
raise ArgumentError, "format must be :ttc or :otc" unless %i[ttc
|
|
40
|
+
otc].include?(format)
|
|
41
|
+
|
|
42
|
+
@fonts = fonts
|
|
43
|
+
@sharing_map = sharing_map
|
|
44
|
+
@offsets = offsets
|
|
45
|
+
@format = format
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Write collection to file
|
|
49
|
+
#
|
|
50
|
+
# @param path [String] Output file path
|
|
51
|
+
# @return [Integer] Number of bytes written
|
|
52
|
+
def write_to_file(path)
|
|
53
|
+
binary = write_collection
|
|
54
|
+
File.binwrite(path, binary)
|
|
55
|
+
binary.bytesize
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Write collection to binary string
|
|
59
|
+
#
|
|
60
|
+
# @return [String] Complete collection binary
|
|
61
|
+
def write_collection
|
|
62
|
+
binary = String.new(encoding: Encoding::BINARY)
|
|
63
|
+
|
|
64
|
+
# Write TTC header
|
|
65
|
+
binary << write_ttc_header
|
|
66
|
+
|
|
67
|
+
# Write offset table (offsets to each font's directory)
|
|
68
|
+
binary << write_offset_table
|
|
69
|
+
|
|
70
|
+
# Write each font's table directory
|
|
71
|
+
@fonts.each_with_index do |font, font_index|
|
|
72
|
+
# Pad to expected offset
|
|
73
|
+
pad_to_offset(binary, @offsets[:font_directory_offsets][font_index])
|
|
74
|
+
|
|
75
|
+
# Write font directory
|
|
76
|
+
binary << write_font_directory(font, font_index)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Write table data (shared tables first, then unique tables)
|
|
80
|
+
write_table_data(binary)
|
|
81
|
+
|
|
82
|
+
binary
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
# Write TTC header (12 bytes)
|
|
88
|
+
#
|
|
89
|
+
# Structure:
|
|
90
|
+
# - TAG: 'ttcf' (4 bytes)
|
|
91
|
+
# - Major version: 1 (2 bytes)
|
|
92
|
+
# - Minor version: 0 (2 bytes)
|
|
93
|
+
# - Number of fonts (4 bytes)
|
|
94
|
+
#
|
|
95
|
+
# @return [String] TTC header binary
|
|
96
|
+
def write_ttc_header
|
|
97
|
+
[
|
|
98
|
+
TTC_TAG, # char[4] - tag
|
|
99
|
+
VERSION_1_0_MAJOR, # uint16 - major version
|
|
100
|
+
VERSION_1_0_MINOR, # uint16 - minor version
|
|
101
|
+
@fonts.size, # uint32 - number of fonts
|
|
102
|
+
].pack("a4 n n N")
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Write offset table
|
|
106
|
+
#
|
|
107
|
+
# Contains N uint32 values, one for each font, indicating the byte offset
|
|
108
|
+
# from the beginning of the file to that font's table directory.
|
|
109
|
+
#
|
|
110
|
+
# @return [String] Offset table binary
|
|
111
|
+
def write_offset_table
|
|
112
|
+
@offsets[:font_directory_offsets].pack("N*")
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Write font directory for a specific font
|
|
116
|
+
#
|
|
117
|
+
# Structure:
|
|
118
|
+
# - Font directory header (12 bytes: sfnt_version, num_tables, searchRange, entrySelector, rangeShift)
|
|
119
|
+
# - Table directory entries (16 bytes each: tag, checksum, offset, length)
|
|
120
|
+
#
|
|
121
|
+
# @param font [TrueTypeFont, OpenTypeFont] Font object
|
|
122
|
+
# @param font_index [Integer] Font index
|
|
123
|
+
# @return [String] Font directory binary
|
|
124
|
+
def write_font_directory(font, font_index)
|
|
125
|
+
binary = String.new(encoding: Encoding::BINARY)
|
|
126
|
+
|
|
127
|
+
# Get font's table tags
|
|
128
|
+
table_tags = font.table_names.sort
|
|
129
|
+
|
|
130
|
+
# Write directory header
|
|
131
|
+
binary << write_directory_header(font, table_tags.size)
|
|
132
|
+
|
|
133
|
+
# Write table directory entries
|
|
134
|
+
table_tags.each do |tag|
|
|
135
|
+
binary << write_table_directory_entry(font_index, tag)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
binary
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Write font directory header
|
|
142
|
+
#
|
|
143
|
+
# @param font [TrueTypeFont, OpenTypeFont] Font object
|
|
144
|
+
# @param num_tables [Integer] Number of tables
|
|
145
|
+
# @return [String] Directory header binary
|
|
146
|
+
def write_directory_header(font, num_tables)
|
|
147
|
+
# Get sfnt version from font
|
|
148
|
+
sfnt_version = font.header.sfnt_version
|
|
149
|
+
|
|
150
|
+
# Calculate search parameters
|
|
151
|
+
search_params = calculate_search_params(num_tables)
|
|
152
|
+
|
|
153
|
+
[
|
|
154
|
+
sfnt_version, # uint32 - sfnt version
|
|
155
|
+
num_tables, # uint16 - number of tables
|
|
156
|
+
search_params[:search_range], # uint16 - search range
|
|
157
|
+
search_params[:entry_selector], # uint16 - entry selector
|
|
158
|
+
search_params[:range_shift], # uint16 - range shift
|
|
159
|
+
].pack("N n n n n")
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Write table directory entry
|
|
163
|
+
#
|
|
164
|
+
# @param font_index [Integer] Font index
|
|
165
|
+
# @param tag [String] Table tag
|
|
166
|
+
# @return [String] Table directory entry binary
|
|
167
|
+
def write_table_directory_entry(font_index, tag)
|
|
168
|
+
# Get canonical table info from sharing map
|
|
169
|
+
table_info = @sharing_map[font_index][tag]
|
|
170
|
+
canonical_id = table_info[:canonical_id]
|
|
171
|
+
|
|
172
|
+
# Get table offset from offset map
|
|
173
|
+
table_offset = @offsets[:table_offsets][canonical_id]
|
|
174
|
+
|
|
175
|
+
# Calculate checksum
|
|
176
|
+
checksum = calculate_table_checksum(table_info[:data])
|
|
177
|
+
|
|
178
|
+
[
|
|
179
|
+
tag, # char[4] - table tag
|
|
180
|
+
checksum, # uint32 - checksum
|
|
181
|
+
table_offset, # uint32 - offset
|
|
182
|
+
table_info[:size], # uint32 - length
|
|
183
|
+
].pack("a4 N N N")
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Write all table data
|
|
187
|
+
#
|
|
188
|
+
# Writes shared tables first (once each), then unique tables
|
|
189
|
+
# (once per font). Tables are written at their calculated offsets
|
|
190
|
+
# with proper alignment.
|
|
191
|
+
#
|
|
192
|
+
# @param binary [String] Binary string to append to
|
|
193
|
+
# @return [void]
|
|
194
|
+
def write_table_data(binary)
|
|
195
|
+
# Collect all canonical tables with their offsets
|
|
196
|
+
tables_by_offset = {}
|
|
197
|
+
|
|
198
|
+
@offsets[:table_offsets].each do |canonical_id, offset|
|
|
199
|
+
# Find the table data from sharing map
|
|
200
|
+
table_data = find_canonical_table_data(canonical_id)
|
|
201
|
+
|
|
202
|
+
tables_by_offset[offset] = {
|
|
203
|
+
canonical_id: canonical_id,
|
|
204
|
+
data: table_data,
|
|
205
|
+
}
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# Write tables in order of their offsets
|
|
209
|
+
tables_by_offset.keys.sort.each do |offset|
|
|
210
|
+
table_info = tables_by_offset[offset]
|
|
211
|
+
|
|
212
|
+
# Pad to expected offset
|
|
213
|
+
pad_to_offset(binary, offset)
|
|
214
|
+
|
|
215
|
+
# Write table data
|
|
216
|
+
binary << table_info[:data]
|
|
217
|
+
|
|
218
|
+
# Pad to 4-byte boundary
|
|
219
|
+
padding = calculate_padding(table_info[:data].bytesize)
|
|
220
|
+
binary << ("\x00" * padding) if padding.positive?
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# Find canonical table data by ID
|
|
225
|
+
#
|
|
226
|
+
# @param canonical_id [String] Canonical table ID
|
|
227
|
+
# @return [String] Table data
|
|
228
|
+
def find_canonical_table_data(canonical_id)
|
|
229
|
+
@sharing_map.each_value do |tables|
|
|
230
|
+
tables.each_value do |info|
|
|
231
|
+
return info[:data] if info[:canonical_id] == canonical_id
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
raise "Canonical table not found: #{canonical_id}"
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# Pad binary to specific offset
|
|
239
|
+
#
|
|
240
|
+
# @param binary [String] Binary string to pad
|
|
241
|
+
# @param target_offset [Integer] Target offset
|
|
242
|
+
# @return [void]
|
|
243
|
+
def pad_to_offset(binary, target_offset)
|
|
244
|
+
current_size = binary.bytesize
|
|
245
|
+
return if current_size >= target_offset
|
|
246
|
+
|
|
247
|
+
padding_needed = target_offset - current_size
|
|
248
|
+
binary << ("\x00" * padding_needed)
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# Calculate padding needed for 4-byte alignment
|
|
252
|
+
#
|
|
253
|
+
# @param size [Integer] Current size
|
|
254
|
+
# @return [Integer] Padding bytes needed
|
|
255
|
+
def calculate_padding(size)
|
|
256
|
+
remainder = size % 4
|
|
257
|
+
return 0 if remainder.zero?
|
|
258
|
+
|
|
259
|
+
4 - remainder
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
# Calculate table checksum
|
|
263
|
+
#
|
|
264
|
+
# @param data [String] Table data
|
|
265
|
+
# @return [Integer] Checksum
|
|
266
|
+
def calculate_table_checksum(data)
|
|
267
|
+
# Pad to 4-byte boundary
|
|
268
|
+
padded_data = data.dup
|
|
269
|
+
padding_length = calculate_padding(data.bytesize)
|
|
270
|
+
padded_data << ("\x00" * padding_length) if padding_length.positive?
|
|
271
|
+
|
|
272
|
+
# Sum all uint32 values
|
|
273
|
+
sum = 0
|
|
274
|
+
(0...padded_data.bytesize).step(4) do |i|
|
|
275
|
+
value = padded_data[i, 4].unpack1("N")
|
|
276
|
+
sum = (sum + value) & 0xFFFFFFFF
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
sum
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
# Calculate search parameters for directory header
|
|
283
|
+
#
|
|
284
|
+
# @param num_tables [Integer] Number of tables
|
|
285
|
+
# @return [Hash] Search parameters
|
|
286
|
+
def calculate_search_params(num_tables)
|
|
287
|
+
max_power = 0
|
|
288
|
+
n = num_tables
|
|
289
|
+
while n > 1
|
|
290
|
+
n >>= 1
|
|
291
|
+
max_power += 1
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
search_range = (1 << max_power) * 16
|
|
295
|
+
entry_selector = max_power
|
|
296
|
+
range_shift = (num_tables * 16) - search_range
|
|
297
|
+
|
|
298
|
+
{
|
|
299
|
+
search_range: search_range,
|
|
300
|
+
entry_selector: entry_selector,
|
|
301
|
+
range_shift: range_shift,
|
|
302
|
+
}
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
end
|
|
@@ -66,7 +66,14 @@ module Fontisan
|
|
|
66
66
|
# @raise [InvalidFontError] for corrupted or unknown formats
|
|
67
67
|
# @raise [Error] for other loading failures
|
|
68
68
|
def load_font
|
|
69
|
-
|
|
69
|
+
# ConvertCommand and similar commands need all tables loaded upfront
|
|
70
|
+
# Use mode and lazy from options, or sensible defaults
|
|
71
|
+
FontLoader.load(
|
|
72
|
+
@font_path,
|
|
73
|
+
font_index: @options[:font_index] || 0,
|
|
74
|
+
mode: @options[:mode] || LoadingModes::FULL,
|
|
75
|
+
lazy: @options.key?(:lazy) ? @options[:lazy] : false
|
|
76
|
+
)
|
|
70
77
|
rescue Errno::ENOENT
|
|
71
78
|
# Re-raise file not found as-is
|
|
72
79
|
raise
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base_command"
|
|
4
|
+
require_relative "../converters/format_converter"
|
|
5
|
+
require_relative "../font_writer"
|
|
6
|
+
|
|
7
|
+
module Fontisan
|
|
8
|
+
module Commands
|
|
9
|
+
# Command for converting fonts between formats
|
|
10
|
+
#
|
|
11
|
+
# [`ConvertCommand`](lib/fontisan/commands/convert_command.rb) provides
|
|
12
|
+
# CLI interface for font format conversion operations. It supports:
|
|
13
|
+
# - Same-format operations (copy/optimize)
|
|
14
|
+
# - TTF ↔ OTF outline format conversion (foundation)
|
|
15
|
+
# - Future: WOFF/WOFF2 compression, SVG export
|
|
16
|
+
#
|
|
17
|
+
# The command uses [`FormatConverter`](lib/fontisan/converters/format_converter.rb)
|
|
18
|
+
# to orchestrate conversions with appropriate strategies.
|
|
19
|
+
#
|
|
20
|
+
# @example Convert TTF to OTF
|
|
21
|
+
# command = ConvertCommand.new(
|
|
22
|
+
# 'input.ttf',
|
|
23
|
+
# to: 'otf',
|
|
24
|
+
# output: 'output.otf'
|
|
25
|
+
# )
|
|
26
|
+
# command.run
|
|
27
|
+
#
|
|
28
|
+
# @example Copy/optimize same format
|
|
29
|
+
# command = ConvertCommand.new(
|
|
30
|
+
# 'input.ttf',
|
|
31
|
+
# to: 'ttf',
|
|
32
|
+
# output: 'optimized.ttf'
|
|
33
|
+
# )
|
|
34
|
+
# command.run
|
|
35
|
+
class ConvertCommand < BaseCommand
|
|
36
|
+
# Initialize convert command
|
|
37
|
+
#
|
|
38
|
+
# @param font_path [String] Path to input font file
|
|
39
|
+
# @param options [Hash] Conversion options
|
|
40
|
+
# @option options [String] :to Target format (ttf, otf, woff2, svg)
|
|
41
|
+
# @option options [String] :output Output file path (required)
|
|
42
|
+
# @option options [Integer] :font_index Index for TTC/OTC (default: 0)
|
|
43
|
+
# @option options [Boolean] :optimize Enable subroutine optimization (TTF→OTF only)
|
|
44
|
+
# @option options [Integer] :min_pattern_length Minimum pattern length for subroutines
|
|
45
|
+
# @option options [Integer] :max_subroutines Maximum number of subroutines
|
|
46
|
+
# @option options [Boolean] :optimize_ordering Optimize subroutine ordering
|
|
47
|
+
def initialize(font_path, options = {})
|
|
48
|
+
super(font_path, options)
|
|
49
|
+
@target_format = parse_target_format(options[:to])
|
|
50
|
+
@output_path = options[:output]
|
|
51
|
+
@converter = Converters::FormatConverter.new
|
|
52
|
+
|
|
53
|
+
# Optimization options
|
|
54
|
+
@optimize = options[:optimize] || false
|
|
55
|
+
@min_pattern_length = options[:min_pattern_length] || 10
|
|
56
|
+
@max_subroutines = options[:max_subroutines] || 65_535
|
|
57
|
+
@optimize_ordering = options[:optimize_ordering] != false
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Execute the conversion
|
|
61
|
+
#
|
|
62
|
+
# @return [Hash] Result information
|
|
63
|
+
# @raise [ArgumentError] If output path is not specified
|
|
64
|
+
# @raise [Error] If conversion fails
|
|
65
|
+
def run
|
|
66
|
+
validate_options!
|
|
67
|
+
|
|
68
|
+
puts "Converting #{File.basename(font_path)} to #{@target_format}..."
|
|
69
|
+
|
|
70
|
+
# Build converter options
|
|
71
|
+
converter_options = {
|
|
72
|
+
target_format: @target_format,
|
|
73
|
+
optimize_subroutines: @optimize,
|
|
74
|
+
min_pattern_length: @min_pattern_length,
|
|
75
|
+
max_subroutines: @max_subroutines,
|
|
76
|
+
optimize_ordering: @optimize_ordering,
|
|
77
|
+
verbose: options[:verbose],
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
# Perform conversion with options
|
|
81
|
+
result = @converter.convert(font, @target_format, converter_options)
|
|
82
|
+
|
|
83
|
+
# Handle special formats that return complete binary/text
|
|
84
|
+
if @target_format == :woff && result.is_a?(String)
|
|
85
|
+
# WOFF returns complete binary
|
|
86
|
+
File.binwrite(@output_path, result)
|
|
87
|
+
elsif @target_format == :woff2 && result.is_a?(Hash) && result[:woff2_binary]
|
|
88
|
+
File.binwrite(@output_path, result[:woff2_binary])
|
|
89
|
+
elsif @target_format == :svg && result.is_a?(Hash) && result[:svg_xml]
|
|
90
|
+
File.write(@output_path, result[:svg_xml])
|
|
91
|
+
else
|
|
92
|
+
# Standard table-based conversion
|
|
93
|
+
tables = result
|
|
94
|
+
|
|
95
|
+
# Determine sfnt version for output
|
|
96
|
+
sfnt_version = determine_sfnt_version(@target_format)
|
|
97
|
+
|
|
98
|
+
# Write output font
|
|
99
|
+
FontWriter.write_to_file(tables, @output_path,
|
|
100
|
+
sfnt_version: sfnt_version)
|
|
101
|
+
|
|
102
|
+
# Display optimization results if available
|
|
103
|
+
display_optimization_results(tables) if @optimize && options[:verbose]
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
output_size = File.size(@output_path)
|
|
107
|
+
input_size = File.size(font_path)
|
|
108
|
+
|
|
109
|
+
puts "Conversion complete!"
|
|
110
|
+
puts " Input: #{font_path} (#{format_size(input_size)})"
|
|
111
|
+
puts " Output: #{@output_path} (#{format_size(output_size)})"
|
|
112
|
+
|
|
113
|
+
{
|
|
114
|
+
success: true,
|
|
115
|
+
input_path: font_path,
|
|
116
|
+
output_path: @output_path,
|
|
117
|
+
source_format: detect_source_format,
|
|
118
|
+
target_format: @target_format,
|
|
119
|
+
input_size: input_size,
|
|
120
|
+
output_size: output_size,
|
|
121
|
+
}
|
|
122
|
+
rescue NotImplementedError
|
|
123
|
+
# Let NotImplementedError propagate for tests that expect it
|
|
124
|
+
raise
|
|
125
|
+
rescue Converters::ConversionStrategy => e
|
|
126
|
+
handle_conversion_error(e)
|
|
127
|
+
rescue ArgumentError
|
|
128
|
+
# Let ArgumentError propagate for validation errors
|
|
129
|
+
raise
|
|
130
|
+
rescue StandardError => e
|
|
131
|
+
raise Error, "Conversion failed: #{e.message}"
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Get list of supported conversions
|
|
135
|
+
#
|
|
136
|
+
# @return [Array<Hash>] List of supported conversions
|
|
137
|
+
def self.supported_conversions
|
|
138
|
+
converter = Converters::FormatConverter.new
|
|
139
|
+
converter.all_conversions
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Check if a conversion is supported
|
|
143
|
+
#
|
|
144
|
+
# @param source [Symbol] Source format
|
|
145
|
+
# @param target [Symbol] Target format
|
|
146
|
+
# @return [Boolean] True if supported
|
|
147
|
+
def self.supported?(source, target)
|
|
148
|
+
converter = Converters::FormatConverter.new
|
|
149
|
+
converter.supported?(source, target)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
private
|
|
153
|
+
|
|
154
|
+
# Validate command options
|
|
155
|
+
#
|
|
156
|
+
# @raise [ArgumentError] If required options are missing
|
|
157
|
+
def validate_options!
|
|
158
|
+
unless @output_path
|
|
159
|
+
raise ArgumentError,
|
|
160
|
+
"Output path is required. Use --output option."
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
unless @target_format
|
|
164
|
+
raise ArgumentError,
|
|
165
|
+
"Target format is required. Use --to option."
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Check if conversion is supported
|
|
169
|
+
source_format = detect_source_format
|
|
170
|
+
unless @converter.supported?(source_format, @target_format)
|
|
171
|
+
available = @converter.supported_targets(source_format)
|
|
172
|
+
message = "Conversion from #{source_format} to #{@target_format} " \
|
|
173
|
+
"is not supported."
|
|
174
|
+
if available.any?
|
|
175
|
+
message += " Available targets: #{available.join(', ')}"
|
|
176
|
+
end
|
|
177
|
+
raise ArgumentError, message
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Parse target format from string/symbol
|
|
182
|
+
#
|
|
183
|
+
# @param format [String, Symbol, nil] Target format
|
|
184
|
+
# @return [Symbol, nil] Parsed format symbol
|
|
185
|
+
def parse_target_format(format)
|
|
186
|
+
return nil if format.nil?
|
|
187
|
+
|
|
188
|
+
format_str = format.to_s.downcase
|
|
189
|
+
case format_str
|
|
190
|
+
when "ttf", "truetype"
|
|
191
|
+
:ttf
|
|
192
|
+
when "otf", "opentype", "cff"
|
|
193
|
+
:otf
|
|
194
|
+
when "woff"
|
|
195
|
+
:woff
|
|
196
|
+
when "woff2"
|
|
197
|
+
:woff2
|
|
198
|
+
when "svg"
|
|
199
|
+
:svg
|
|
200
|
+
else
|
|
201
|
+
raise ArgumentError,
|
|
202
|
+
"Unknown target format: #{format}. " \
|
|
203
|
+
"Supported: ttf, otf, woff2, svg"
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Detect source font format
|
|
208
|
+
#
|
|
209
|
+
# @return [Symbol] Source format
|
|
210
|
+
def detect_source_format
|
|
211
|
+
# Check for CFF/CFF2 tables (OpenType/CFF)
|
|
212
|
+
if font.has_table?("CFF ") || font.has_table?("CFF2")
|
|
213
|
+
:otf
|
|
214
|
+
# Check for glyf table (TrueType)
|
|
215
|
+
elsif font.has_table?("glyf")
|
|
216
|
+
:ttf
|
|
217
|
+
else
|
|
218
|
+
:unknown
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# Determine sfnt version for target format
|
|
223
|
+
#
|
|
224
|
+
# @param format [Symbol] Target format
|
|
225
|
+
# @return [Integer] sfnt version
|
|
226
|
+
def determine_sfnt_version(format)
|
|
227
|
+
case format
|
|
228
|
+
when :otf
|
|
229
|
+
0x4F54544F # 'OTTO' for OpenType/CFF
|
|
230
|
+
when :ttf
|
|
231
|
+
0x00010000 # 1.0 for TrueType
|
|
232
|
+
else
|
|
233
|
+
0x00010000 # Default to TrueType
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# Format file size for display
|
|
238
|
+
#
|
|
239
|
+
# @param bytes [Integer] Size in bytes
|
|
240
|
+
# @return [String] Formatted size
|
|
241
|
+
def format_size(bytes)
|
|
242
|
+
if bytes < 1024
|
|
243
|
+
"#{bytes} bytes"
|
|
244
|
+
elsif bytes < 1024 * 1024
|
|
245
|
+
"#{(bytes / 1024.0).round(1)} KB"
|
|
246
|
+
else
|
|
247
|
+
"#{(bytes / (1024.0 * 1024)).round(1)} MB"
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# Handle conversion errors with helpful messages
|
|
252
|
+
#
|
|
253
|
+
# @param error [StandardError] The error that occurred
|
|
254
|
+
# @raise [Error] Wrapped error with helpful message
|
|
255
|
+
def handle_conversion_error(error)
|
|
256
|
+
message = "Conversion failed: #{error.message}"
|
|
257
|
+
|
|
258
|
+
# Add helpful hints based on error type
|
|
259
|
+
if error.is_a?(NotImplementedError)
|
|
260
|
+
message += "\n\nNote: Some conversions are not yet fully " \
|
|
261
|
+
"implemented. Check the conversion matrix configuration " \
|
|
262
|
+
"for implementation status."
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
raise Error, message
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# Display optimization results from subroutine generation
|
|
269
|
+
#
|
|
270
|
+
# @param tables [Hash] Table data with optimization metadata
|
|
271
|
+
def display_optimization_results(tables)
|
|
272
|
+
optimization = tables.instance_variable_get(:@subroutine_optimization)
|
|
273
|
+
return unless optimization
|
|
274
|
+
|
|
275
|
+
puts "\n=== Subroutine Optimization Results ==="
|
|
276
|
+
puts " Patterns found: #{optimization[:pattern_count]}"
|
|
277
|
+
puts " Patterns selected: #{optimization[:selected_count]}"
|
|
278
|
+
puts " Subroutines generated: #{optimization[:local_subrs].length}"
|
|
279
|
+
puts " Estimated bytes saved: #{optimization[:savings]}"
|
|
280
|
+
puts " CFF bias: #{optimization[:bias]}"
|
|
281
|
+
|
|
282
|
+
if optimization[:selected_count].zero?
|
|
283
|
+
puts " Note: No beneficial patterns found for optimization"
|
|
284
|
+
elsif optimization[:savings].positive?
|
|
285
|
+
savings_kb = (optimization[:savings] / 1024.0).round(1)
|
|
286
|
+
puts " Estimated space savings: #{savings_kb} KB"
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
end
|