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
|
@@ -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,30 @@ 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
|
+
# BaseCommand is for inspection - reject compressed formats first
|
|
70
|
+
# Check file signature before attempting to load
|
|
71
|
+
File.open(@font_path, "rb") do |io|
|
|
72
|
+
signature = io.read(4)
|
|
73
|
+
|
|
74
|
+
if signature == "wOFF"
|
|
75
|
+
raise UnsupportedFormatError,
|
|
76
|
+
"Unsupported font format: WOFF files must be decompressed first. " \
|
|
77
|
+
"Use ConvertCommand to convert WOFF to TTF/OTF."
|
|
78
|
+
elsif signature == "wOF2"
|
|
79
|
+
raise UnsupportedFormatError,
|
|
80
|
+
"Unsupported font format: WOFF2 files must be decompressed first. " \
|
|
81
|
+
"Use ConvertCommand to convert WOFF2 to TTF/OTF."
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# ConvertCommand and similar commands need all tables loaded upfront
|
|
86
|
+
# Use mode and lazy from options, or sensible defaults
|
|
87
|
+
FontLoader.load(
|
|
88
|
+
@font_path,
|
|
89
|
+
font_index: @options[:font_index] || 0,
|
|
90
|
+
mode: @options[:mode] || LoadingModes::FULL,
|
|
91
|
+
lazy: @options.key?(:lazy) ? @options[:lazy] : false
|
|
92
|
+
)
|
|
70
93
|
rescue Errno::ENOENT
|
|
71
94
|
# Re-raise file not found as-is
|
|
72
95
|
raise
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base_command"
|
|
4
|
+
require_relative "../pipeline/transformation_pipeline"
|
|
5
|
+
|
|
6
|
+
module Fontisan
|
|
7
|
+
module Commands
|
|
8
|
+
# Command for converting fonts between formats
|
|
9
|
+
#
|
|
10
|
+
# [`ConvertCommand`](lib/fontisan/commands/convert_command.rb) provides
|
|
11
|
+
# CLI interface for font format conversion operations using the universal
|
|
12
|
+
# transformation pipeline. It supports:
|
|
13
|
+
# - Same-format operations (copy/optimize)
|
|
14
|
+
# - TTF ↔ OTF outline format conversion
|
|
15
|
+
# - Variable font operations (preserve/instance generation)
|
|
16
|
+
# - WOFF/WOFF2 compression
|
|
17
|
+
#
|
|
18
|
+
# The command uses [`TransformationPipeline`](lib/fontisan/pipeline/transformation_pipeline.rb)
|
|
19
|
+
# to orchestrate conversions with appropriate strategies.
|
|
20
|
+
#
|
|
21
|
+
# @example Convert TTF to OTF
|
|
22
|
+
# command = ConvertCommand.new(
|
|
23
|
+
# 'input.ttf',
|
|
24
|
+
# to: 'otf',
|
|
25
|
+
# output: 'output.otf'
|
|
26
|
+
# )
|
|
27
|
+
# command.run
|
|
28
|
+
#
|
|
29
|
+
# @example Generate instance at coordinates
|
|
30
|
+
# command = ConvertCommand.new(
|
|
31
|
+
# 'variable.ttf',
|
|
32
|
+
# to: 'ttf',
|
|
33
|
+
# output: 'bold.ttf',
|
|
34
|
+
# coordinates: 'wght=700,wdth=100'
|
|
35
|
+
# )
|
|
36
|
+
# command.run
|
|
37
|
+
class ConvertCommand < BaseCommand
|
|
38
|
+
# Initialize convert command
|
|
39
|
+
#
|
|
40
|
+
# @param font_path [String] Path to input font file
|
|
41
|
+
# @param options [Hash] Conversion options
|
|
42
|
+
# @option options [String] :to Target format (ttf, otf, woff, woff2)
|
|
43
|
+
# @option options [String] :output Output file path (required)
|
|
44
|
+
# @option options [Integer] :font_index Index for TTC/OTC (default: 0)
|
|
45
|
+
# @option options [String] :coordinates Coordinate string (e.g., "wght=700,wdth=100")
|
|
46
|
+
# @option options [Hash] :instance_coordinates Axis coordinates hash (e.g., {"wght" => 700.0})
|
|
47
|
+
# @option options [Integer] :instance_index Named instance index
|
|
48
|
+
# @option options [Boolean] :preserve_variation Preserve variation data (default: auto)
|
|
49
|
+
# @option options [Boolean] :preserve_hints Preserve rendering hints (default: false)
|
|
50
|
+
# @option options [Boolean] :no_validate Skip output validation
|
|
51
|
+
# @option options [Boolean] :verbose Verbose output
|
|
52
|
+
def initialize(font_path, options = {})
|
|
53
|
+
super(font_path, options)
|
|
54
|
+
@output_path = options[:output]
|
|
55
|
+
|
|
56
|
+
# Parse target format
|
|
57
|
+
@target_format = parse_target_format(options[:to])
|
|
58
|
+
|
|
59
|
+
# Parse coordinates if string provided
|
|
60
|
+
@coordinates = if options[:coordinates]
|
|
61
|
+
parse_coordinates(options[:coordinates])
|
|
62
|
+
elsif options[:instance_coordinates]
|
|
63
|
+
options[:instance_coordinates]
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
@instance_index = options[:instance_index]
|
|
67
|
+
@preserve_variation = options[:preserve_variation]
|
|
68
|
+
@preserve_hints = options.fetch(:preserve_hints, false)
|
|
69
|
+
@validate = !options[:no_validate]
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Execute the conversion
|
|
73
|
+
#
|
|
74
|
+
# @return [Hash] Result information
|
|
75
|
+
# @raise [ArgumentError] If output path is not specified
|
|
76
|
+
# @raise [Error] If conversion fails
|
|
77
|
+
def run
|
|
78
|
+
validate_options!
|
|
79
|
+
|
|
80
|
+
puts "Converting #{File.basename(font_path)} to #{@target_format}..." unless @options[:quiet]
|
|
81
|
+
|
|
82
|
+
# Build pipeline options
|
|
83
|
+
pipeline_options = {
|
|
84
|
+
target_format: @target_format,
|
|
85
|
+
validate: @validate,
|
|
86
|
+
verbose: @options[:verbose],
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
# Add variation options if specified
|
|
90
|
+
pipeline_options[:coordinates] = @coordinates if @coordinates
|
|
91
|
+
pipeline_options[:instance_index] = @instance_index if @instance_index
|
|
92
|
+
pipeline_options[:preserve_variation] = @preserve_variation unless @preserve_variation.nil?
|
|
93
|
+
|
|
94
|
+
# Add hint preservation option
|
|
95
|
+
pipeline_options[:preserve_hints] = @preserve_hints if @preserve_hints
|
|
96
|
+
|
|
97
|
+
# Use TransformationPipeline for universal conversion
|
|
98
|
+
pipeline = Pipeline::TransformationPipeline.new(
|
|
99
|
+
font_path,
|
|
100
|
+
@output_path,
|
|
101
|
+
pipeline_options,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
result = pipeline.transform
|
|
105
|
+
|
|
106
|
+
# Display results
|
|
107
|
+
unless @options[:quiet]
|
|
108
|
+
output_size = File.size(@output_path)
|
|
109
|
+
input_size = File.size(font_path)
|
|
110
|
+
|
|
111
|
+
puts "Conversion complete!"
|
|
112
|
+
puts " Input: #{font_path} (#{format_size(input_size)})"
|
|
113
|
+
puts " Output: #{@output_path} (#{format_size(output_size)})"
|
|
114
|
+
puts " Format: #{result[:details][:source_format]} → #{result[:details][:target_format]}"
|
|
115
|
+
|
|
116
|
+
if result[:details][:variation_preserved]
|
|
117
|
+
puts " Variation: Preserved (#{result[:details][:variation_strategy]})"
|
|
118
|
+
elsif result[:details][:variation_strategy] != :preserve
|
|
119
|
+
puts " Variation: Instance generated (#{result[:details][:variation_strategy]})"
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
{
|
|
124
|
+
success: true,
|
|
125
|
+
input_path: font_path,
|
|
126
|
+
output_path: @output_path,
|
|
127
|
+
source_format: result[:details][:source_format],
|
|
128
|
+
target_format: result[:details][:target_format],
|
|
129
|
+
input_size: File.size(font_path),
|
|
130
|
+
output_size: File.size(@output_path),
|
|
131
|
+
variation_strategy: result[:details][:variation_strategy],
|
|
132
|
+
}
|
|
133
|
+
rescue ArgumentError
|
|
134
|
+
# Let ArgumentError propagate for validation errors
|
|
135
|
+
raise
|
|
136
|
+
rescue StandardError => e
|
|
137
|
+
raise Error, "Conversion failed: #{e.message}"
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
private
|
|
141
|
+
|
|
142
|
+
# Parse coordinates string to hash
|
|
143
|
+
#
|
|
144
|
+
# Parses strings like "wght=700,wdth=100" into {"wght" => 700.0, "wdth" => 100.0}
|
|
145
|
+
#
|
|
146
|
+
# @param coord_string [String] Coordinate string
|
|
147
|
+
# @return [Hash] Parsed coordinates
|
|
148
|
+
def parse_coordinates(coord_string)
|
|
149
|
+
coords = {}
|
|
150
|
+
coord_string.split(",").each do |pair|
|
|
151
|
+
key, value = pair.split("=")
|
|
152
|
+
next unless key && value
|
|
153
|
+
|
|
154
|
+
coords[key.strip] = value.to_f
|
|
155
|
+
end
|
|
156
|
+
coords
|
|
157
|
+
rescue StandardError => e
|
|
158
|
+
raise ArgumentError, "Invalid coordinates format '#{coord_string}': #{e.message}"
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Validate command options
|
|
162
|
+
#
|
|
163
|
+
# @raise [ArgumentError] If required options are missing
|
|
164
|
+
def validate_options!
|
|
165
|
+
unless @output_path
|
|
166
|
+
raise ArgumentError,
|
|
167
|
+
"Output path is required. Use --output option."
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
unless @target_format
|
|
171
|
+
raise ArgumentError,
|
|
172
|
+
"Target format is required. Use --to option."
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Parse target format from string/symbol
|
|
177
|
+
#
|
|
178
|
+
# @param format [String, Symbol, nil] Target format
|
|
179
|
+
# @return [Symbol, nil] Parsed format symbol
|
|
180
|
+
def parse_target_format(format)
|
|
181
|
+
return nil if format.nil?
|
|
182
|
+
|
|
183
|
+
format_str = format.to_s.downcase
|
|
184
|
+
case format_str
|
|
185
|
+
when "ttf", "truetype"
|
|
186
|
+
:ttf
|
|
187
|
+
when "otf", "opentype", "cff"
|
|
188
|
+
:otf
|
|
189
|
+
when "svg"
|
|
190
|
+
:svg
|
|
191
|
+
when "woff"
|
|
192
|
+
raise ArgumentError,
|
|
193
|
+
"WOFF format conversion is not supported yet. Use woff2 instead."
|
|
194
|
+
when "woff2"
|
|
195
|
+
:woff2
|
|
196
|
+
else
|
|
197
|
+
raise ArgumentError,
|
|
198
|
+
"Unknown target format: #{format}. " \
|
|
199
|
+
"Supported: ttf, otf, svg, woff2"
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Format file size for display
|
|
204
|
+
#
|
|
205
|
+
# @param bytes [Integer] Size in bytes
|
|
206
|
+
# @return [String] Formatted size
|
|
207
|
+
def format_size(bytes)
|
|
208
|
+
if bytes < 1024
|
|
209
|
+
"#{bytes} bytes"
|
|
210
|
+
elsif bytes < 1024 * 1024
|
|
211
|
+
"#{(bytes / 1024.0).round(1)} KB"
|
|
212
|
+
else
|
|
213
|
+
"#{(bytes / (1024.0 * 1024)).round(1)} MB"
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
end
|