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
data/lib/fontisan/cli.rb
CHANGED
|
@@ -37,6 +37,27 @@ module Fontisan
|
|
|
37
37
|
handle_error(e)
|
|
38
38
|
end
|
|
39
39
|
|
|
40
|
+
desc "ls FILE", "List contents (fonts in collection or font summary)"
|
|
41
|
+
# List contents of font files with auto-detection.
|
|
42
|
+
#
|
|
43
|
+
# For collections (TTC/OTC): Lists all fonts in the collection
|
|
44
|
+
# For individual fonts (TTF/OTF): Shows quick font summary
|
|
45
|
+
#
|
|
46
|
+
# @param file [String] Path to the font or collection file
|
|
47
|
+
#
|
|
48
|
+
# @example List fonts in collection
|
|
49
|
+
# fontisan ls fonts.ttc
|
|
50
|
+
#
|
|
51
|
+
# @example Show font summary
|
|
52
|
+
# fontisan ls font.ttf
|
|
53
|
+
def ls(file)
|
|
54
|
+
command = Commands::LsCommand.new(file, options)
|
|
55
|
+
result = command.run
|
|
56
|
+
output_result(result)
|
|
57
|
+
rescue Errno::ENOENT, Error => e
|
|
58
|
+
handle_error(e)
|
|
59
|
+
end
|
|
60
|
+
|
|
40
61
|
desc "tables FONT_FILE", "List OpenType tables"
|
|
41
62
|
# List all OpenType tables in the font file.
|
|
42
63
|
#
|
|
@@ -125,6 +146,151 @@ module Fontisan
|
|
|
125
146
|
handle_error(e)
|
|
126
147
|
end
|
|
127
148
|
|
|
149
|
+
desc "subset FONT_FILE", "Subset a font to specific glyphs"
|
|
150
|
+
option :text, type: :string,
|
|
151
|
+
desc: "Text to subset (e.g., 'Hello World')",
|
|
152
|
+
aliases: "-t"
|
|
153
|
+
option :glyphs, type: :string,
|
|
154
|
+
desc: "Comma-separated glyph IDs (e.g., '0,1,65,66,67')",
|
|
155
|
+
aliases: "-g"
|
|
156
|
+
option :unicode, type: :string,
|
|
157
|
+
desc: "Comma-separated Unicode codepoints (e.g., 'U+0041,U+0042' or '0x41,0x42')",
|
|
158
|
+
aliases: "-u"
|
|
159
|
+
option :output, type: :string, required: true,
|
|
160
|
+
desc: "Output file path",
|
|
161
|
+
aliases: "-o"
|
|
162
|
+
option :profile, type: :string, default: "pdf",
|
|
163
|
+
desc: "Subsetting profile (pdf, web, minimal)",
|
|
164
|
+
aliases: "-p"
|
|
165
|
+
option :retain_gids, type: :boolean, default: false,
|
|
166
|
+
desc: "Retain original glyph IDs (leave gaps)"
|
|
167
|
+
option :drop_hints, type: :boolean, default: false,
|
|
168
|
+
desc: "Drop hinting instructions"
|
|
169
|
+
option :drop_names, type: :boolean, default: false,
|
|
170
|
+
desc: "Drop glyph names from post table"
|
|
171
|
+
option :unicode_ranges, type: :boolean, default: true,
|
|
172
|
+
desc: "Prune OS/2 Unicode ranges"
|
|
173
|
+
# Subset a font to specific glyphs.
|
|
174
|
+
#
|
|
175
|
+
# You must specify one of --text, --glyphs, or --unicode to define
|
|
176
|
+
# which glyphs to include in the subset.
|
|
177
|
+
#
|
|
178
|
+
# @param font_file [String] Path to the font file
|
|
179
|
+
def subset(font_file)
|
|
180
|
+
command = Commands::SubsetCommand.new(font_file, options)
|
|
181
|
+
result = command.run
|
|
182
|
+
|
|
183
|
+
unless options[:quiet]
|
|
184
|
+
puts "Subset font created:"
|
|
185
|
+
puts " Input: #{result[:input]}"
|
|
186
|
+
puts " Output: #{result[:output]}"
|
|
187
|
+
puts " Original glyphs: #{result[:original_glyphs]}"
|
|
188
|
+
puts " Subset glyphs: #{result[:subset_glyphs]}"
|
|
189
|
+
puts " Profile: #{result[:profile]}"
|
|
190
|
+
puts " Size: #{format_size(result[:size])}"
|
|
191
|
+
end
|
|
192
|
+
rescue Errno::ENOENT, Error => e
|
|
193
|
+
handle_error(e)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
desc "convert FONT_FILE", "Convert font to different format"
|
|
197
|
+
option :to, type: :string, required: true,
|
|
198
|
+
desc: "Target format (ttf, otf, woff2, svg)",
|
|
199
|
+
aliases: "-t"
|
|
200
|
+
option :output, type: :string, required: true,
|
|
201
|
+
desc: "Output file path",
|
|
202
|
+
aliases: "-o"
|
|
203
|
+
option :optimize, type: :boolean, default: false,
|
|
204
|
+
desc: "Optimize CFF with subroutines (TTF→OTF only)"
|
|
205
|
+
option :min_pattern_length, type: :numeric, default: 10,
|
|
206
|
+
desc: "Minimum pattern length for subroutines"
|
|
207
|
+
option :max_subroutines, type: :numeric, default: 65_535,
|
|
208
|
+
desc: "Maximum number of subroutines"
|
|
209
|
+
option :optimize_ordering, type: :boolean, default: true,
|
|
210
|
+
desc: "Optimize subroutine ordering by frequency"
|
|
211
|
+
# Convert a font to a different format.
|
|
212
|
+
#
|
|
213
|
+
# Supported conversions:
|
|
214
|
+
# - Same format (ttf→ttf, otf→otf): Copy/optimize
|
|
215
|
+
# - TTF ↔ OTF: Outline format conversion (foundation)
|
|
216
|
+
# - Future: WOFF2 compression, SVG export
|
|
217
|
+
#
|
|
218
|
+
# Subroutine Optimization (--optimize):
|
|
219
|
+
# When converting TTF→OTF, you can enable automatic CFF subroutine generation
|
|
220
|
+
# to reduce file size. This analyzes repeated byte patterns across glyphs and
|
|
221
|
+
# creates shared subroutines, typically saving 30-50% in CFF table size.
|
|
222
|
+
#
|
|
223
|
+
# @param font_file [String] Path to the font file
|
|
224
|
+
#
|
|
225
|
+
# @example Convert TTF to OTF
|
|
226
|
+
# fontisan convert font.ttf --to otf --output font.otf
|
|
227
|
+
#
|
|
228
|
+
# @example Convert with optimization
|
|
229
|
+
# fontisan convert font.ttf --to otf --output font.otf --optimize --verbose
|
|
230
|
+
#
|
|
231
|
+
# @example Convert with custom optimization parameters
|
|
232
|
+
# fontisan convert font.ttf --to otf --output font.otf --optimize \
|
|
233
|
+
# --min-pattern-length 15 --max-subroutines 10000
|
|
234
|
+
#
|
|
235
|
+
# @example Copy/optimize TTF
|
|
236
|
+
# fontisan convert font.ttf --to ttf --output optimized.ttf
|
|
237
|
+
def convert(font_file)
|
|
238
|
+
command = Commands::ConvertCommand.new(font_file, options)
|
|
239
|
+
command.run
|
|
240
|
+
rescue Errno::ENOENT, Error => e
|
|
241
|
+
handle_error(e)
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
desc "instance FONT_FILE",
|
|
245
|
+
"Generate static font instance from variable font"
|
|
246
|
+
option :output, type: :string,
|
|
247
|
+
desc: "Output file path",
|
|
248
|
+
aliases: "-o"
|
|
249
|
+
option :wght, type: :numeric,
|
|
250
|
+
desc: "Weight axis value"
|
|
251
|
+
option :wdth, type: :numeric,
|
|
252
|
+
desc: "Width axis value"
|
|
253
|
+
option :slnt, type: :numeric,
|
|
254
|
+
desc: "Slant axis value"
|
|
255
|
+
option :ital, type: :numeric,
|
|
256
|
+
desc: "Italic axis value"
|
|
257
|
+
option :opsz, type: :numeric,
|
|
258
|
+
desc: "Optical size axis value"
|
|
259
|
+
option :named_instance, type: :string,
|
|
260
|
+
desc: "Use named instance (e.g., 'Bold', 'Light')",
|
|
261
|
+
aliases: "-n"
|
|
262
|
+
option :list_instances, type: :boolean, default: false,
|
|
263
|
+
desc: "List available named instances",
|
|
264
|
+
aliases: "-l"
|
|
265
|
+
option :to, type: :string,
|
|
266
|
+
desc: "Convert to format (ttf, otf, woff, woff2, svg)",
|
|
267
|
+
aliases: "-t"
|
|
268
|
+
# Generate static font instance from variable font.
|
|
269
|
+
#
|
|
270
|
+
# You can specify axis coordinates using --wght, --wdth, etc., or use
|
|
271
|
+
# a predefined named instance with --named-instance. Use --list-instances
|
|
272
|
+
# to see available named instances.
|
|
273
|
+
#
|
|
274
|
+
# @param font_file [String] Path to the variable font file
|
|
275
|
+
#
|
|
276
|
+
# @example Generate bold instance
|
|
277
|
+
# fontisan instance variable.ttf --wght=700 --output=bold.ttf
|
|
278
|
+
#
|
|
279
|
+
# @example Use named instance
|
|
280
|
+
# fontisan instance variable.ttf --named-instance="Bold" --output=bold.ttf
|
|
281
|
+
#
|
|
282
|
+
# @example Instance with format conversion
|
|
283
|
+
# fontisan instance variable.ttf --wght=700 --to=woff2 --output=bold.woff2
|
|
284
|
+
#
|
|
285
|
+
# @example List available instances
|
|
286
|
+
# fontisan instance variable.ttf --list-instances
|
|
287
|
+
def instance(font_file)
|
|
288
|
+
command = Commands::InstanceCommand.new
|
|
289
|
+
command.execute(font_file, options)
|
|
290
|
+
rescue Errno::ENOENT, Error => e
|
|
291
|
+
handle_error(e)
|
|
292
|
+
end
|
|
293
|
+
|
|
128
294
|
desc "dump-table FONT_FILE TABLE_TAG", "Dump raw table data to stdout"
|
|
129
295
|
# Dump raw binary table data to stdout.
|
|
130
296
|
#
|
|
@@ -141,12 +307,141 @@ module Fontisan
|
|
|
141
307
|
handle_error(e)
|
|
142
308
|
end
|
|
143
309
|
|
|
310
|
+
desc "validate FONT_FILE", "Validate font file structure and checksums"
|
|
311
|
+
option :verbose, type: :boolean, default: false,
|
|
312
|
+
desc: "Show detailed validation information"
|
|
313
|
+
def validate(font_file)
|
|
314
|
+
command = Commands::ValidateCommand.new(font_file, verbose: options[:verbose])
|
|
315
|
+
exit command.run
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
desc "export FONT_FILE", "Export font to TTX/YAML/JSON format"
|
|
319
|
+
option :output, type: :string,
|
|
320
|
+
desc: "Output file path (default: stdout)",
|
|
321
|
+
aliases: "-o"
|
|
322
|
+
option :format, type: :string, default: "yaml",
|
|
323
|
+
desc: "Export format (yaml, json, ttx)",
|
|
324
|
+
aliases: "-f"
|
|
325
|
+
option :tables, type: :array,
|
|
326
|
+
desc: "Specific tables to export",
|
|
327
|
+
aliases: "-t"
|
|
328
|
+
option :binary_format, type: :string, default: "hex",
|
|
329
|
+
desc: "Binary encoding (hex, base64)",
|
|
330
|
+
aliases: "-b"
|
|
331
|
+
|
|
332
|
+
def export(font_file)
|
|
333
|
+
command = Commands::ExportCommand.new(
|
|
334
|
+
font_file,
|
|
335
|
+
output: options[:output],
|
|
336
|
+
format: options[:format].to_sym,
|
|
337
|
+
tables: options[:tables],
|
|
338
|
+
binary_format: options[:binary_format].to_sym,
|
|
339
|
+
)
|
|
340
|
+
exit command.run
|
|
341
|
+
end
|
|
342
|
+
|
|
144
343
|
desc "version", "Display version information"
|
|
145
344
|
# Display the Fontisan version.
|
|
146
345
|
def version
|
|
147
346
|
puts "Fontisan version #{Fontisan::VERSION}"
|
|
148
347
|
end
|
|
149
348
|
|
|
349
|
+
desc "unpack FONT_FILE", "Unpack fonts from TTC/OTC collection"
|
|
350
|
+
option :output_dir, type: :string, required: true,
|
|
351
|
+
desc: "Output directory for extracted fonts",
|
|
352
|
+
aliases: "-d"
|
|
353
|
+
option :font_index, type: :numeric,
|
|
354
|
+
desc: "Extract specific font by index (default: extract all)",
|
|
355
|
+
aliases: "-i"
|
|
356
|
+
option :format, type: :string,
|
|
357
|
+
desc: "Output format (ttf, otf, woff, woff2)",
|
|
358
|
+
aliases: "-f"
|
|
359
|
+
option :prefix, type: :string,
|
|
360
|
+
desc: "Filename prefix for extracted fonts",
|
|
361
|
+
aliases: "-p"
|
|
362
|
+
# Extract individual fonts from a TTC (TrueType Collection) or OTC (OpenType Collection) file.
|
|
363
|
+
#
|
|
364
|
+
# This command unpacks fonts from collection files, optionally converting them
|
|
365
|
+
# to different formats during extraction.
|
|
366
|
+
#
|
|
367
|
+
# @param font_file [String] Path to the TTC/OTC collection file
|
|
368
|
+
#
|
|
369
|
+
# @example Extract all fonts to directory
|
|
370
|
+
# fontisan unpack family.ttc --output-dir extracted/
|
|
371
|
+
#
|
|
372
|
+
# @example Extract specific font by index
|
|
373
|
+
# fontisan unpack family.ttc --output-dir extracted/ --font-index 0
|
|
374
|
+
#
|
|
375
|
+
# @example Extract with format conversion
|
|
376
|
+
# fontisan unpack family.ttc --output-dir extracted/ --format woff2
|
|
377
|
+
#
|
|
378
|
+
# @example Extract with custom prefix
|
|
379
|
+
# fontisan unpack family.ttc --output-dir extracted/ --prefix "NotoSans"
|
|
380
|
+
def unpack(font_file)
|
|
381
|
+
command = Commands::UnpackCommand.new(font_file, options)
|
|
382
|
+
result = command.run
|
|
383
|
+
|
|
384
|
+
unless options[:quiet]
|
|
385
|
+
puts "Collection unpacked successfully:"
|
|
386
|
+
puts " Input: #{result[:collection]}"
|
|
387
|
+
puts " Output directory: #{result[:output_dir]}"
|
|
388
|
+
puts " Fonts extracted: #{result[:fonts_extracted]}/#{result[:num_fonts]}"
|
|
389
|
+
result[:extracted_files].each do |file|
|
|
390
|
+
size = File.size(file)
|
|
391
|
+
puts " - #{File.basename(file)} (#{format_size(size)})"
|
|
392
|
+
end
|
|
393
|
+
end
|
|
394
|
+
rescue Errno::ENOENT, Error => e
|
|
395
|
+
handle_error(e)
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
desc "pack FONT_FILES...", "Pack multiple fonts into TTC/OTC collection"
|
|
399
|
+
option :output, type: :string, required: true,
|
|
400
|
+
desc: "Output collection file path",
|
|
401
|
+
aliases: "-o"
|
|
402
|
+
option :format, type: :string, default: "ttc",
|
|
403
|
+
desc: "Collection format (ttc, otc)",
|
|
404
|
+
aliases: "-f"
|
|
405
|
+
option :optimize, type: :boolean, default: true,
|
|
406
|
+
desc: "Enable table sharing optimization",
|
|
407
|
+
aliases: "--optimize"
|
|
408
|
+
option :analyze, type: :boolean, default: false,
|
|
409
|
+
desc: "Show analysis report before building",
|
|
410
|
+
aliases: "--analyze"
|
|
411
|
+
# Create a TTC (TrueType Collection) or OTC (OpenType Collection) from multiple font files.
|
|
412
|
+
#
|
|
413
|
+
# This command combines multiple fonts into a single collection file with
|
|
414
|
+
# shared table deduplication to save space. It supports both TTC and OTC formats.
|
|
415
|
+
#
|
|
416
|
+
# @param font_files [Array<String>] Paths to input font files (minimum 2 required)
|
|
417
|
+
#
|
|
418
|
+
# @example Pack fonts into TTC
|
|
419
|
+
# fontisan pack font1.ttf font2.ttf font3.ttf --output family.ttc
|
|
420
|
+
#
|
|
421
|
+
# @example Pack into OTC with analysis
|
|
422
|
+
# fontisan pack Regular.otf Bold.otf Italic.otf --output family.otc --analyze
|
|
423
|
+
#
|
|
424
|
+
# @example Pack without optimization
|
|
425
|
+
# fontisan pack font1.ttf font2.ttf --output collection.ttc --no-optimize
|
|
426
|
+
def pack(*font_files)
|
|
427
|
+
command = Commands::PackCommand.new(font_files, options)
|
|
428
|
+
result = command.run
|
|
429
|
+
|
|
430
|
+
unless options[:quiet]
|
|
431
|
+
puts "Collection created successfully:"
|
|
432
|
+
puts " Output: #{result[:output]}"
|
|
433
|
+
puts " Format: #{result[:format].upcase}"
|
|
434
|
+
puts " Fonts: #{result[:num_fonts]}"
|
|
435
|
+
puts " Size: #{format_size(result[:output_size])}"
|
|
436
|
+
if result[:space_savings].positive?
|
|
437
|
+
puts " Space saved: #{format_size(result[:space_savings])}"
|
|
438
|
+
puts " Sharing: #{result[:sharing_percentage]}%"
|
|
439
|
+
end
|
|
440
|
+
end
|
|
441
|
+
rescue Errno::ENOENT, Error => e
|
|
442
|
+
handle_error(e)
|
|
443
|
+
end
|
|
444
|
+
|
|
150
445
|
private
|
|
151
446
|
|
|
152
447
|
# Output the result in the requested format.
|
|
@@ -174,6 +469,20 @@ module Fontisan
|
|
|
174
469
|
formatter.format(result)
|
|
175
470
|
end
|
|
176
471
|
|
|
472
|
+
# Format file size in human-readable form
|
|
473
|
+
#
|
|
474
|
+
# @param size [Integer] Size in bytes
|
|
475
|
+
# @return [String] Formatted size
|
|
476
|
+
def format_size(size)
|
|
477
|
+
if size < 1024
|
|
478
|
+
"#{size} bytes"
|
|
479
|
+
elsif size < 1024 * 1024
|
|
480
|
+
"#{(size / 1024.0).round(2)} KB"
|
|
481
|
+
else
|
|
482
|
+
"#{(size / (1024.0 * 1024)).round(2)} MB"
|
|
483
|
+
end
|
|
484
|
+
end
|
|
485
|
+
|
|
177
486
|
# Handle errors based on verbosity settings.
|
|
178
487
|
#
|
|
179
488
|
# @param error [Error, Errno::ENOENT] The error to handle
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "table_analyzer"
|
|
4
|
+
require_relative "table_deduplicator"
|
|
5
|
+
require_relative "offset_calculator"
|
|
6
|
+
require_relative "writer"
|
|
7
|
+
require "yaml"
|
|
8
|
+
|
|
9
|
+
module Fontisan
|
|
10
|
+
module Collection
|
|
11
|
+
# CollectionBuilder orchestrates TTC/OTC creation
|
|
12
|
+
#
|
|
13
|
+
# Main responsibility: Coordinate the entire collection creation process
|
|
14
|
+
# including analysis, deduplication, offset calculation, and writing.
|
|
15
|
+
# Implements builder pattern for flexible configuration.
|
|
16
|
+
#
|
|
17
|
+
# @example Create TTC with default options
|
|
18
|
+
# builder = CollectionBuilder.new([font1, font2, font3])
|
|
19
|
+
# builder.build_to_file("family.ttc")
|
|
20
|
+
#
|
|
21
|
+
# @example Create OTC with optimization
|
|
22
|
+
# builder = CollectionBuilder.new([font1, font2, font3])
|
|
23
|
+
# builder.format = :otc
|
|
24
|
+
# builder.optimize = true
|
|
25
|
+
# result = builder.build
|
|
26
|
+
# puts "Saved #{result[:space_savings]} bytes"
|
|
27
|
+
class Builder
|
|
28
|
+
# Source fonts
|
|
29
|
+
# @return [Array<TrueTypeFont, OpenTypeFont>]
|
|
30
|
+
attr_reader :fonts
|
|
31
|
+
|
|
32
|
+
# Collection format (:ttc or :otc)
|
|
33
|
+
# @return [Symbol]
|
|
34
|
+
attr_accessor :format
|
|
35
|
+
|
|
36
|
+
# Enable table sharing optimization
|
|
37
|
+
# @return [Boolean]
|
|
38
|
+
attr_accessor :optimize
|
|
39
|
+
|
|
40
|
+
# Configuration settings
|
|
41
|
+
# @return [Hash]
|
|
42
|
+
attr_accessor :config
|
|
43
|
+
|
|
44
|
+
# Build result (populated after build)
|
|
45
|
+
# @return [Hash, nil]
|
|
46
|
+
attr_reader :result
|
|
47
|
+
|
|
48
|
+
# Initialize builder with fonts
|
|
49
|
+
#
|
|
50
|
+
# @param fonts [Array<TrueTypeFont, OpenTypeFont>] Fonts to pack
|
|
51
|
+
# @param options [Hash] Builder options
|
|
52
|
+
# @option options [Symbol] :format Format type (:ttc or :otc, default: :ttc)
|
|
53
|
+
# @option options [Boolean] :optimize Enable optimization (default: true)
|
|
54
|
+
# @option options [Hash] :config Configuration overrides
|
|
55
|
+
# @raise [ArgumentError] if fonts array is invalid
|
|
56
|
+
def initialize(fonts, options = {})
|
|
57
|
+
if fonts.nil? || fonts.empty?
|
|
58
|
+
raise ArgumentError,
|
|
59
|
+
"fonts cannot be nil or empty"
|
|
60
|
+
end
|
|
61
|
+
raise ArgumentError, "fonts must be an array" unless fonts.is_a?(Array)
|
|
62
|
+
|
|
63
|
+
unless fonts.all? do |f|
|
|
64
|
+
f.respond_to?(:table_data)
|
|
65
|
+
end
|
|
66
|
+
raise ArgumentError,
|
|
67
|
+
"all fonts must respond to table_data"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
@fonts = fonts
|
|
71
|
+
@format = options[:format] || :ttc
|
|
72
|
+
@optimize = options.fetch(:optimize, true)
|
|
73
|
+
@config = load_config.merge(options[:config] || {})
|
|
74
|
+
@result = nil
|
|
75
|
+
|
|
76
|
+
validate_format!
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Build collection and return binary
|
|
80
|
+
#
|
|
81
|
+
# Executes the complete collection creation process:
|
|
82
|
+
# 1. Analyze tables across fonts
|
|
83
|
+
# 2. Deduplicate identical tables
|
|
84
|
+
# 3. Calculate file offsets
|
|
85
|
+
# 4. Write binary structure
|
|
86
|
+
#
|
|
87
|
+
# @return [Hash] Build result with:
|
|
88
|
+
# - :binary [String] - Complete collection binary
|
|
89
|
+
# - :space_savings [Integer] - Bytes saved by sharing
|
|
90
|
+
# - :analysis [Hash] - Analysis report
|
|
91
|
+
# - :statistics [Hash] - Deduplication statistics
|
|
92
|
+
def build
|
|
93
|
+
# Step 1: Analyze tables
|
|
94
|
+
analyzer = TableAnalyzer.new(@fonts)
|
|
95
|
+
analysis_report = analyzer.analyze
|
|
96
|
+
|
|
97
|
+
# Step 2: Deduplicate tables
|
|
98
|
+
deduplicator = TableDeduplicator.new(@fonts)
|
|
99
|
+
sharing_map = deduplicator.build_sharing_map
|
|
100
|
+
statistics = deduplicator.statistics
|
|
101
|
+
|
|
102
|
+
# Step 3: Calculate offsets
|
|
103
|
+
calculator = OffsetCalculator.new(sharing_map, @fonts)
|
|
104
|
+
offsets = calculator.calculate
|
|
105
|
+
|
|
106
|
+
# Step 4: Write collection
|
|
107
|
+
writer = Writer.new(@fonts, sharing_map, offsets, format: @format)
|
|
108
|
+
binary = writer.write_collection
|
|
109
|
+
|
|
110
|
+
# Store result
|
|
111
|
+
@result = {
|
|
112
|
+
binary: binary,
|
|
113
|
+
space_savings: analysis_report[:space_savings],
|
|
114
|
+
analysis: analysis_report,
|
|
115
|
+
statistics: statistics,
|
|
116
|
+
format: @format,
|
|
117
|
+
num_fonts: @fonts.size,
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
@result
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Build collection and write to file
|
|
124
|
+
#
|
|
125
|
+
# @param path [String] Output file path
|
|
126
|
+
# @return [Hash] Build result (same as build method)
|
|
127
|
+
def build_to_file(path)
|
|
128
|
+
result = build
|
|
129
|
+
File.binwrite(path, result[:binary])
|
|
130
|
+
result[:output_path] = path
|
|
131
|
+
result[:output_size] = result[:binary].bytesize
|
|
132
|
+
result
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Get analysis report
|
|
136
|
+
#
|
|
137
|
+
# Runs analysis without building the full collection.
|
|
138
|
+
# Useful for previewing space savings before committing to build.
|
|
139
|
+
#
|
|
140
|
+
# @return [Hash] Analysis report
|
|
141
|
+
def analyze
|
|
142
|
+
analyzer = TableAnalyzer.new(@fonts)
|
|
143
|
+
analyzer.analyze
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Get potential space savings without building
|
|
147
|
+
#
|
|
148
|
+
# @return [Integer] Bytes that can be saved
|
|
149
|
+
def potential_savings
|
|
150
|
+
analyze[:space_savings]
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Validate collection can be built
|
|
154
|
+
#
|
|
155
|
+
# @return [Boolean] true if valid, raises error otherwise
|
|
156
|
+
# @raise [Error] if validation fails
|
|
157
|
+
def validate!
|
|
158
|
+
# Check minimum fonts
|
|
159
|
+
raise Error, "Collection requires at least 2 fonts" if @fonts.size < 2
|
|
160
|
+
|
|
161
|
+
# Check format compatibility
|
|
162
|
+
incompatible = check_format_compatibility
|
|
163
|
+
if incompatible.any?
|
|
164
|
+
raise Error, "Format mismatch: #{incompatible.join(', ')}"
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Check all fonts have required tables
|
|
168
|
+
@fonts.each_with_index do |font, index|
|
|
169
|
+
required_tables = %w[head hhea maxp]
|
|
170
|
+
missing = required_tables.reject { |tag| font.has_table?(tag) }
|
|
171
|
+
unless missing.empty?
|
|
172
|
+
raise Error,
|
|
173
|
+
"Font #{index} missing required tables: #{missing.join(', ')}"
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
true
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
private
|
|
181
|
+
|
|
182
|
+
# Load configuration from file
|
|
183
|
+
#
|
|
184
|
+
# @return [Hash] Configuration hash
|
|
185
|
+
def load_config
|
|
186
|
+
config_path = File.join(__dir__, "..", "config",
|
|
187
|
+
"collection_settings.yml")
|
|
188
|
+
if File.exist?(config_path)
|
|
189
|
+
YAML.load_file(config_path)
|
|
190
|
+
else
|
|
191
|
+
default_config
|
|
192
|
+
end
|
|
193
|
+
rescue StandardError => e
|
|
194
|
+
warn "Failed to load config: #{e.message}, using defaults"
|
|
195
|
+
default_config
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Default configuration
|
|
199
|
+
#
|
|
200
|
+
# @return [Hash] Default settings
|
|
201
|
+
def default_config
|
|
202
|
+
{
|
|
203
|
+
"table_sharing_strategy" => "conservative",
|
|
204
|
+
"alignment" => 4,
|
|
205
|
+
"optimize_table_order" => true,
|
|
206
|
+
"verify_checksums" => true,
|
|
207
|
+
}
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Validate format is supported
|
|
211
|
+
#
|
|
212
|
+
# @return [void]
|
|
213
|
+
# @raise [ArgumentError] if format is invalid
|
|
214
|
+
def validate_format!
|
|
215
|
+
valid_formats = %i[ttc otc]
|
|
216
|
+
return if valid_formats.include?(@format)
|
|
217
|
+
|
|
218
|
+
raise ArgumentError,
|
|
219
|
+
"Invalid format: #{@format}. Must be one of: #{valid_formats.join(', ')}"
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# Check if all fonts are compatible with selected format
|
|
223
|
+
#
|
|
224
|
+
# @return [Array<String>] Array of incompatibility messages
|
|
225
|
+
def check_format_compatibility
|
|
226
|
+
incompatible = []
|
|
227
|
+
|
|
228
|
+
if @format == :ttc
|
|
229
|
+
# TTC requires TrueType fonts (sfnt version 0x00010000 or 'true')
|
|
230
|
+
@fonts.each_with_index do |font, index|
|
|
231
|
+
sfnt = font.header.sfnt_version
|
|
232
|
+
unless [0x00010000, 0x74727565].include?(sfnt) # 0x74727565 = 'true'
|
|
233
|
+
incompatible << "Font #{index} is not TrueType (sfnt: 0x#{sfnt.to_s(16)})"
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
elsif @format == :otc
|
|
237
|
+
# OTC can contain both TrueType and OpenType/CFF fonts
|
|
238
|
+
# No strict validation needed, but warn about mixing
|
|
239
|
+
has_truetype = false
|
|
240
|
+
has_opentype = false
|
|
241
|
+
|
|
242
|
+
@fonts.each do |font|
|
|
243
|
+
sfnt = font.header.sfnt_version
|
|
244
|
+
if [0x00010000, 0x74727565].include?(sfnt)
|
|
245
|
+
has_truetype = true
|
|
246
|
+
elsif sfnt == 0x4F54544F # 'OTTO'
|
|
247
|
+
has_opentype = true
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
if has_truetype && has_opentype
|
|
252
|
+
warn "Warning: Mixing TrueType and OpenType/CFF fonts in OTC"
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
incompatible
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
end
|