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,338 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base_command"
|
|
4
|
+
require_relative "../font_loader"
|
|
5
|
+
require_relative "../font_writer"
|
|
6
|
+
require "fileutils"
|
|
7
|
+
|
|
8
|
+
module Fontisan
|
|
9
|
+
module Commands
|
|
10
|
+
# Command for unpacking fonts from TTC/OTC collections
|
|
11
|
+
#
|
|
12
|
+
# This command extracts individual font files from a TTC (TrueType Collection)
|
|
13
|
+
# or OTC (OpenType Collection) file. It can extract all fonts or a specific
|
|
14
|
+
# font by index, optionally converting to different formats during extraction.
|
|
15
|
+
#
|
|
16
|
+
# @example Extract all fonts
|
|
17
|
+
# command = UnpackCommand.new(
|
|
18
|
+
# 'family.ttc',
|
|
19
|
+
# output_dir: 'fonts/',
|
|
20
|
+
# format: :ttf
|
|
21
|
+
# )
|
|
22
|
+
# result = command.run
|
|
23
|
+
# puts "Extracted #{result[:fonts_extracted]} fonts"
|
|
24
|
+
#
|
|
25
|
+
# @example Extract specific font
|
|
26
|
+
# command = UnpackCommand.new(
|
|
27
|
+
# 'family.ttc',
|
|
28
|
+
# output_dir: 'fonts/',
|
|
29
|
+
# font_index: 2
|
|
30
|
+
# )
|
|
31
|
+
# result = command.run
|
|
32
|
+
class UnpackCommand
|
|
33
|
+
# Initialize unpack command
|
|
34
|
+
#
|
|
35
|
+
# @param collection_path [String] Path to TTC/OTC file
|
|
36
|
+
# @param options [Hash] Command options
|
|
37
|
+
# @option options [String] :output_dir Output directory (required)
|
|
38
|
+
# @option options [Integer] :font_index Extract specific font index (optional)
|
|
39
|
+
# @option options [Symbol, String] :format Output format (ttf, otf, woff, woff2)
|
|
40
|
+
# @option options [String] :prefix Filename prefix for extracted fonts
|
|
41
|
+
# @option options [Boolean] :verbose Enable verbose output (default: false)
|
|
42
|
+
# @raise [ArgumentError] if collection_path or output_dir is invalid
|
|
43
|
+
def initialize(collection_path, options = {})
|
|
44
|
+
@collection_path = collection_path
|
|
45
|
+
@options = options
|
|
46
|
+
@output_dir = options[:output_dir]
|
|
47
|
+
@font_index = options[:font_index]
|
|
48
|
+
@format = parse_format(options[:format])
|
|
49
|
+
@prefix = options[:prefix]
|
|
50
|
+
@verbose = options.fetch(:verbose, false)
|
|
51
|
+
|
|
52
|
+
validate_options!
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Execute the unpack command
|
|
56
|
+
#
|
|
57
|
+
# Extracts fonts from the collection and writes them as individual files.
|
|
58
|
+
#
|
|
59
|
+
# @return [Hash] Result information with:
|
|
60
|
+
# - :collection [String] - Input collection path
|
|
61
|
+
# - :output_dir [String] - Output directory
|
|
62
|
+
# - :num_fonts [Integer] - Total fonts in collection
|
|
63
|
+
# - :fonts_extracted [Integer] - Number of fonts extracted
|
|
64
|
+
# - :extracted_files [Array<String>] - Paths to extracted files
|
|
65
|
+
# @raise [Fontisan::Error] if unpacking fails
|
|
66
|
+
def run
|
|
67
|
+
puts "Loading collection from #{File.basename(@collection_path)}..." if @verbose
|
|
68
|
+
|
|
69
|
+
# Load collection
|
|
70
|
+
collection = load_collection
|
|
71
|
+
|
|
72
|
+
# Create output directory
|
|
73
|
+
FileUtils.mkdir_p(@output_dir) unless Dir.exist?(@output_dir)
|
|
74
|
+
|
|
75
|
+
# Determine which fonts to extract
|
|
76
|
+
indices_to_extract = determine_indices(collection)
|
|
77
|
+
|
|
78
|
+
puts "Extracting #{indices_to_extract.size} font(s)..." if @verbose
|
|
79
|
+
|
|
80
|
+
# Extract fonts
|
|
81
|
+
extracted_files = extract_fonts(collection, indices_to_extract)
|
|
82
|
+
|
|
83
|
+
# Display results
|
|
84
|
+
if @verbose
|
|
85
|
+
display_results(collection, extracted_files)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
{
|
|
89
|
+
collection: @collection_path,
|
|
90
|
+
output_dir: @output_dir,
|
|
91
|
+
num_fonts: collection.font_count,
|
|
92
|
+
fonts_extracted: extracted_files.size,
|
|
93
|
+
extracted_files: extracted_files,
|
|
94
|
+
}
|
|
95
|
+
rescue Fontisan::Error => e
|
|
96
|
+
raise Fontisan::Error, "Collection unpacking failed: #{e.message}"
|
|
97
|
+
rescue ArgumentError
|
|
98
|
+
# Let ArgumentError propagate for validation errors
|
|
99
|
+
raise
|
|
100
|
+
rescue StandardError => e
|
|
101
|
+
raise Fontisan::Error, "Unexpected error during unpacking: #{e.message}"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
private
|
|
105
|
+
|
|
106
|
+
# Validate command options
|
|
107
|
+
#
|
|
108
|
+
# @raise [ArgumentError] if options are invalid
|
|
109
|
+
def validate_options!
|
|
110
|
+
# Must have output directory
|
|
111
|
+
unless @output_dir
|
|
112
|
+
raise ArgumentError, "Output directory is required (--output-dir)"
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Check collection file exists
|
|
116
|
+
unless File.exist?(@collection_path)
|
|
117
|
+
raise ArgumentError, "Collection file not found: #{@collection_path}"
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Validate font index if provided
|
|
121
|
+
if @font_index&.negative?
|
|
122
|
+
raise ArgumentError, "Font index must be >= 0, got #{@font_index}"
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Load collection file
|
|
127
|
+
#
|
|
128
|
+
# @return [TrueTypeCollection, OpenTypeCollection] Loaded collection
|
|
129
|
+
# @raise [Fontisan::Error] if loading fails
|
|
130
|
+
def load_collection
|
|
131
|
+
# Try to detect format from extension
|
|
132
|
+
ext = File.extname(@collection_path).downcase
|
|
133
|
+
|
|
134
|
+
File.open(@collection_path, "rb") do |io|
|
|
135
|
+
# Read tag to determine type
|
|
136
|
+
tag = io.read(4)
|
|
137
|
+
io.rewind
|
|
138
|
+
|
|
139
|
+
unless tag == "ttcf"
|
|
140
|
+
raise Fontisan::Error,
|
|
141
|
+
"Not a valid TTC/OTC file (invalid signature)"
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Load as TTC or OTC based on extension hint
|
|
145
|
+
# Both use same structure, main difference is expected font types
|
|
146
|
+
if ext == ".otc"
|
|
147
|
+
require_relative "../open_type_collection"
|
|
148
|
+
OpenTypeCollection.read(io)
|
|
149
|
+
else
|
|
150
|
+
require_relative "../true_type_collection"
|
|
151
|
+
TrueTypeCollection.read(io)
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
rescue Errno::ENOENT
|
|
155
|
+
raise Fontisan::Error, "Collection file not found: #{@collection_path}"
|
|
156
|
+
rescue BinData::ValidityError => e
|
|
157
|
+
raise Fontisan::Error, "Invalid collection file: #{e.message}"
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Determine which font indices to extract
|
|
161
|
+
#
|
|
162
|
+
# @param collection [TrueTypeCollection, OpenTypeCollection] Collection
|
|
163
|
+
# @return [Array<Integer>] Array of font indices
|
|
164
|
+
# @raise [ArgumentError] if font_index is out of range
|
|
165
|
+
def determine_indices(collection)
|
|
166
|
+
if @font_index
|
|
167
|
+
# Extract specific font
|
|
168
|
+
if @font_index >= collection.font_count
|
|
169
|
+
raise ArgumentError,
|
|
170
|
+
"Font index #{@font_index} out of range (collection has #{collection.font_count} fonts)"
|
|
171
|
+
end
|
|
172
|
+
[@font_index]
|
|
173
|
+
else
|
|
174
|
+
# Extract all fonts
|
|
175
|
+
(0...collection.font_count).to_a
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Extract fonts from collection
|
|
180
|
+
#
|
|
181
|
+
# @param collection [TrueTypeCollection, OpenTypeCollection] Collection
|
|
182
|
+
# @param indices [Array<Integer>] Indices to extract
|
|
183
|
+
# @return [Array<String>] Paths to extracted files
|
|
184
|
+
def extract_fonts(collection, indices)
|
|
185
|
+
extracted_files = []
|
|
186
|
+
|
|
187
|
+
File.open(@collection_path, "rb") do |io|
|
|
188
|
+
fonts = collection.extract_fonts(io)
|
|
189
|
+
|
|
190
|
+
indices.each do |index|
|
|
191
|
+
font = fonts[index]
|
|
192
|
+
filename = generate_filename(font, index)
|
|
193
|
+
output_path = File.join(@output_dir, filename)
|
|
194
|
+
|
|
195
|
+
puts " [#{index + 1}/#{indices.size}] Extracting to #{filename}..." if @verbose
|
|
196
|
+
|
|
197
|
+
# Write font
|
|
198
|
+
write_font(font, output_path)
|
|
199
|
+
|
|
200
|
+
extracted_files << output_path
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
extracted_files
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Generate output filename for extracted font
|
|
208
|
+
#
|
|
209
|
+
# @param font [TrueTypeFont, OpenTypeFont] Font object
|
|
210
|
+
# @param index [Integer] Font index
|
|
211
|
+
# @return [String] Filename
|
|
212
|
+
def generate_filename(font, index)
|
|
213
|
+
# Try to get font name from name table
|
|
214
|
+
base_name = nil
|
|
215
|
+
if font.respond_to?(:table) && font.table("name")
|
|
216
|
+
name_table = font.table("name")
|
|
217
|
+
# Try to get PostScript name, then family name
|
|
218
|
+
base_name = name_table.english_name(Tables::Name::POSTSCRIPT_NAME) ||
|
|
219
|
+
name_table.english_name(Tables::Name::FAMILY)
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# Fallback to prefix or generic name
|
|
223
|
+
base_name ||= @prefix || "font"
|
|
224
|
+
base_name = "#{base_name}_#{index}" unless @font_index
|
|
225
|
+
|
|
226
|
+
# Clean filename
|
|
227
|
+
base_name = base_name.gsub(/[^a-zA-Z0-9_-]/, "_")
|
|
228
|
+
|
|
229
|
+
# Add extension based on format
|
|
230
|
+
ext = format_extension
|
|
231
|
+
"#{base_name}#{ext}"
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# Write font to file
|
|
235
|
+
#
|
|
236
|
+
# @param font [TrueTypeFont, OpenTypeFont] Font object
|
|
237
|
+
# @param output_path [String] Output file path
|
|
238
|
+
# @return [void]
|
|
239
|
+
def write_font(font, output_path)
|
|
240
|
+
if @format
|
|
241
|
+
# Convert to specified format
|
|
242
|
+
convert_and_write(font, output_path)
|
|
243
|
+
else
|
|
244
|
+
# Write in native format
|
|
245
|
+
font.to_file(output_path)
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# Convert font and write to file
|
|
250
|
+
#
|
|
251
|
+
# @param font [TrueTypeFont, OpenTypeFont] Font object
|
|
252
|
+
# @param output_path [String] Output file path
|
|
253
|
+
# @return [void]
|
|
254
|
+
def convert_and_write(font, output_path)
|
|
255
|
+
require_relative "../converters/format_converter"
|
|
256
|
+
|
|
257
|
+
converter = Converters::FormatConverter.new
|
|
258
|
+
converter.convert(font, @format, output_path: output_path)
|
|
259
|
+
rescue StandardError => e
|
|
260
|
+
raise Fontisan::Error, "Format conversion failed: #{e.message}"
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# Parse format option
|
|
264
|
+
#
|
|
265
|
+
# @param format [Symbol, String, nil] Format option
|
|
266
|
+
# @return [Symbol, nil] Parsed format
|
|
267
|
+
def parse_format(format)
|
|
268
|
+
return nil unless format
|
|
269
|
+
|
|
270
|
+
return format if format.is_a?(Symbol)
|
|
271
|
+
|
|
272
|
+
case format.to_s.downcase
|
|
273
|
+
when "ttf"
|
|
274
|
+
:ttf
|
|
275
|
+
when "otf"
|
|
276
|
+
:otf
|
|
277
|
+
when "woff"
|
|
278
|
+
:woff
|
|
279
|
+
when "woff2"
|
|
280
|
+
:woff2
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
# Get file extension for format
|
|
285
|
+
#
|
|
286
|
+
# @return [String] File extension with dot
|
|
287
|
+
def format_extension
|
|
288
|
+
case @format
|
|
289
|
+
when :ttf
|
|
290
|
+
".ttf"
|
|
291
|
+
when :otf
|
|
292
|
+
".otf"
|
|
293
|
+
when :woff
|
|
294
|
+
".woff"
|
|
295
|
+
when :woff2
|
|
296
|
+
".woff2"
|
|
297
|
+
else
|
|
298
|
+
# Detect from collection type
|
|
299
|
+
ext = File.extname(@collection_path).downcase
|
|
300
|
+
ext == ".otc" ? ".otf" : ".ttf"
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
# Display extraction results
|
|
305
|
+
#
|
|
306
|
+
# @param collection [TrueTypeCollection, OpenTypeCollection] Collection
|
|
307
|
+
# @param extracted_files [Array<String>] Extracted file paths
|
|
308
|
+
# @return [void]
|
|
309
|
+
def display_results(collection, extracted_files)
|
|
310
|
+
puts "\n=== Extraction Complete ==="
|
|
311
|
+
puts "Collection: #{File.basename(@collection_path)}"
|
|
312
|
+
puts "Total fonts: #{collection.font_count}"
|
|
313
|
+
puts "Extracted: #{extracted_files.size}"
|
|
314
|
+
puts "Output directory: #{@output_dir}"
|
|
315
|
+
puts "\nExtracted files:"
|
|
316
|
+
extracted_files.each do |path|
|
|
317
|
+
size = File.size(path)
|
|
318
|
+
puts " - #{File.basename(path)} (#{format_bytes(size)})"
|
|
319
|
+
end
|
|
320
|
+
puts ""
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
# Format bytes for display
|
|
324
|
+
#
|
|
325
|
+
# @param bytes [Integer] Byte count
|
|
326
|
+
# @return [String] Formatted string
|
|
327
|
+
def format_bytes(bytes)
|
|
328
|
+
if bytes < 1024
|
|
329
|
+
"#{bytes} B"
|
|
330
|
+
elsif bytes < 1024 * 1024
|
|
331
|
+
"#{(bytes / 1024.0).round(2)} KB"
|
|
332
|
+
else
|
|
333
|
+
"#{(bytes / (1024.0 * 1024)).round(2)} MB"
|
|
334
|
+
end
|
|
335
|
+
end
|
|
336
|
+
end
|
|
337
|
+
end
|
|
338
|
+
end
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base_command"
|
|
4
|
+
require_relative "../validation/validator"
|
|
5
|
+
require_relative "../font_loader"
|
|
6
|
+
|
|
7
|
+
module Fontisan
|
|
8
|
+
module Commands
|
|
9
|
+
# ValidateCommand provides CLI interface for font validation
|
|
10
|
+
#
|
|
11
|
+
# This command validates fonts against quality checks, structural integrity,
|
|
12
|
+
# and OpenType specification compliance. It supports different validation
|
|
13
|
+
# levels and output formats.
|
|
14
|
+
#
|
|
15
|
+
# @example Validating a font
|
|
16
|
+
# command = ValidateCommand.new(
|
|
17
|
+
# input: "font.ttf",
|
|
18
|
+
# level: :standard,
|
|
19
|
+
# format: :text
|
|
20
|
+
# )
|
|
21
|
+
# exit_code = command.run
|
|
22
|
+
class ValidateCommand < BaseCommand
|
|
23
|
+
# Initialize validate command
|
|
24
|
+
#
|
|
25
|
+
# @param input [String] Path to font file
|
|
26
|
+
# @param level [Symbol] Validation level (:strict, :standard, :lenient)
|
|
27
|
+
# @param format [Symbol] Output format (:text, :yaml, :json)
|
|
28
|
+
# @param verbose [Boolean] Show all issues (default: true)
|
|
29
|
+
# @param quiet [Boolean] Only return exit code, no output (default: false)
|
|
30
|
+
def initialize(input:, level: :standard, format: :text, verbose: true,
|
|
31
|
+
quiet: false)
|
|
32
|
+
super()
|
|
33
|
+
@input = input
|
|
34
|
+
@level = level.to_sym
|
|
35
|
+
@format = format.to_sym
|
|
36
|
+
@verbose = verbose
|
|
37
|
+
@quiet = quiet
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Run the validation command
|
|
41
|
+
#
|
|
42
|
+
# @return [Integer] Exit code (0 = valid, 1 = errors, 2 = warnings only)
|
|
43
|
+
def run
|
|
44
|
+
validate_params!
|
|
45
|
+
|
|
46
|
+
# Load font
|
|
47
|
+
font = load_font
|
|
48
|
+
return 1 unless font
|
|
49
|
+
|
|
50
|
+
# Create validator
|
|
51
|
+
validator = Validation::Validator.new(level: @level)
|
|
52
|
+
|
|
53
|
+
# Run validation
|
|
54
|
+
report = validator.validate(font, @input)
|
|
55
|
+
|
|
56
|
+
# Output results unless quiet mode
|
|
57
|
+
output_report(report) unless @quiet
|
|
58
|
+
|
|
59
|
+
# Return appropriate exit code
|
|
60
|
+
determine_exit_code(report)
|
|
61
|
+
rescue StandardError => e
|
|
62
|
+
puts "Error: #{e.message}" unless @quiet
|
|
63
|
+
puts e.backtrace.join("\n") if @verbose && !@quiet
|
|
64
|
+
1
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
# Validate command parameters
|
|
70
|
+
#
|
|
71
|
+
# @raise [ArgumentError] if parameters are invalid
|
|
72
|
+
# @return [void]
|
|
73
|
+
def validate_params!
|
|
74
|
+
if @input.nil? || @input.empty?
|
|
75
|
+
raise ArgumentError,
|
|
76
|
+
"Input file is required"
|
|
77
|
+
end
|
|
78
|
+
unless File.exist?(@input)
|
|
79
|
+
raise ArgumentError,
|
|
80
|
+
"Input file does not exist: #{@input}"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
valid_levels = %i[strict standard lenient]
|
|
84
|
+
unless valid_levels.include?(@level)
|
|
85
|
+
raise ArgumentError,
|
|
86
|
+
"Invalid level: #{@level}. Must be one of: #{valid_levels.join(', ')}"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
valid_formats = %i[text yaml json]
|
|
90
|
+
unless valid_formats.include?(@format)
|
|
91
|
+
raise ArgumentError,
|
|
92
|
+
"Invalid format: #{@format}. Must be one of: #{valid_formats.join(', ')}"
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Load the font file
|
|
97
|
+
#
|
|
98
|
+
# @return [TrueTypeFont, OpenTypeFont, nil] The loaded font or nil on error
|
|
99
|
+
def load_font
|
|
100
|
+
FontLoader.load(@input)
|
|
101
|
+
rescue StandardError => e
|
|
102
|
+
puts "Error loading font: #{e.message}" unless @quiet
|
|
103
|
+
nil
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Output validation report in requested format
|
|
107
|
+
#
|
|
108
|
+
# @param report [Models::ValidationReport] The validation report
|
|
109
|
+
# @return [void]
|
|
110
|
+
def output_report(report)
|
|
111
|
+
case @format
|
|
112
|
+
when :text
|
|
113
|
+
output_text(report)
|
|
114
|
+
when :yaml
|
|
115
|
+
output_yaml(report)
|
|
116
|
+
when :json
|
|
117
|
+
output_json(report)
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Output report in text format
|
|
122
|
+
#
|
|
123
|
+
# @param report [Models::ValidationReport] The validation report
|
|
124
|
+
# @return [void]
|
|
125
|
+
def output_text(report)
|
|
126
|
+
if @verbose
|
|
127
|
+
puts report.text_summary
|
|
128
|
+
else
|
|
129
|
+
# Compact output: just status and error/warning counts
|
|
130
|
+
status = report.valid ? "VALID" : "INVALID"
|
|
131
|
+
puts "#{status}: #{report.summary.errors} errors, #{report.summary.warnings} warnings"
|
|
132
|
+
|
|
133
|
+
# Show errors only in non-verbose mode
|
|
134
|
+
report.errors.each do |error|
|
|
135
|
+
puts " [ERROR] #{error.message}"
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Output report in YAML format
|
|
141
|
+
#
|
|
142
|
+
# @param report [Models::ValidationReport] The validation report
|
|
143
|
+
# @return [void]
|
|
144
|
+
def output_yaml(report)
|
|
145
|
+
require "yaml"
|
|
146
|
+
puts report.to_yaml
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Output report in JSON format
|
|
150
|
+
#
|
|
151
|
+
# @param report [Models::ValidationReport] The validation report
|
|
152
|
+
# @return [void]
|
|
153
|
+
def output_json(report)
|
|
154
|
+
require "json"
|
|
155
|
+
puts report.to_json
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Determine exit code based on validation results
|
|
159
|
+
#
|
|
160
|
+
# Exit codes:
|
|
161
|
+
# - 0: Valid (no errors, or only warnings in lenient mode)
|
|
162
|
+
# - 1: Has errors
|
|
163
|
+
# - 2: Has warnings only (no errors)
|
|
164
|
+
#
|
|
165
|
+
# @param report [Models::ValidationReport] The validation report
|
|
166
|
+
# @return [Integer] Exit code
|
|
167
|
+
def determine_exit_code(report)
|
|
168
|
+
if report.has_errors?
|
|
169
|
+
1
|
|
170
|
+
elsif report.has_warnings?
|
|
171
|
+
2
|
|
172
|
+
else
|
|
173
|
+
0
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
@@ -1,16 +1,24 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "base_command"
|
|
4
|
+
require_relative "../variation/inspector"
|
|
5
|
+
|
|
3
6
|
module Fontisan
|
|
4
7
|
module Commands
|
|
5
8
|
# Command to extract variable font information.
|
|
6
9
|
#
|
|
7
10
|
# This command extracts variation axes and named instances from variable
|
|
8
|
-
# fonts using the fvar (Font Variations) table.
|
|
11
|
+
# fonts using the fvar (Font Variations) table. It also provides
|
|
12
|
+
# detailed inspection capabilities through the Inspector class.
|
|
9
13
|
#
|
|
10
14
|
# @example Extract variable font information
|
|
11
15
|
# command = VariableCommand.new("path/to/variable-font.ttf")
|
|
12
16
|
# info = command.run
|
|
13
17
|
# puts info.axes.first.tag
|
|
18
|
+
#
|
|
19
|
+
# @example Inspect variable font structure
|
|
20
|
+
# command = VariableCommand.new("path/to/variable-font.ttf")
|
|
21
|
+
# command.inspect(format: "json")
|
|
14
22
|
class VariableCommand < BaseCommand
|
|
15
23
|
# Extract variable font information from the fvar table.
|
|
16
24
|
#
|
|
@@ -56,6 +64,27 @@ module Fontisan
|
|
|
56
64
|
|
|
57
65
|
result
|
|
58
66
|
end
|
|
67
|
+
|
|
68
|
+
# Inspect variable font structure
|
|
69
|
+
#
|
|
70
|
+
# Provides detailed analysis of variable font structure including
|
|
71
|
+
# axes, instances, regions, and statistics.
|
|
72
|
+
#
|
|
73
|
+
# @param options [Hash] Inspection options
|
|
74
|
+
# @option options [String] :format Output format ("json" or "yaml")
|
|
75
|
+
# @return [String] Formatted inspection output
|
|
76
|
+
def inspect(options = {})
|
|
77
|
+
inspector = Variation::Inspector.new(font)
|
|
78
|
+
|
|
79
|
+
format = options[:format] || "json"
|
|
80
|
+
|
|
81
|
+
case format.downcase
|
|
82
|
+
when "yaml"
|
|
83
|
+
inspector.export_yaml
|
|
84
|
+
else
|
|
85
|
+
inspector.export_json
|
|
86
|
+
end
|
|
87
|
+
end
|
|
59
88
|
end
|
|
60
89
|
end
|
|
61
90
|
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# Collection Creation Settings
|
|
2
|
+
# Configuration for TTC/OTC (TrueType/OpenType Collection) creation
|
|
3
|
+
|
|
4
|
+
# Table sharing strategy determines how aggressive the deduplication is
|
|
5
|
+
# Options:
|
|
6
|
+
# - conservative: Only share tables with exact checksum match (safest)
|
|
7
|
+
# - aggressive: Consider sharing even with minor differences (not implemented yet)
|
|
8
|
+
table_sharing_strategy: conservative
|
|
9
|
+
|
|
10
|
+
# Alignment requirement for tables in bytes
|
|
11
|
+
# Must be 4 for TrueType/OpenType compliance
|
|
12
|
+
alignment: 4
|
|
13
|
+
|
|
14
|
+
# Optimize table order for better performance
|
|
15
|
+
# When true, tables are ordered according to recommended order
|
|
16
|
+
optimize_table_order: true
|
|
17
|
+
|
|
18
|
+
# Verify checksums after writing
|
|
19
|
+
# When true, verifies all checksums match expected values
|
|
20
|
+
verify_checksums: true
|
|
21
|
+
|
|
22
|
+
# Priority tables for sharing
|
|
23
|
+
# These tables are commonly identical across font families
|
|
24
|
+
# and should be prioritized for sharing to maximize space savings
|
|
25
|
+
priority_tables:
|
|
26
|
+
- head # Font header
|
|
27
|
+
- hhea # Horizontal header
|
|
28
|
+
- maxp # Maximum profile
|
|
29
|
+
- OS/2 # OS/2 and Windows metrics
|
|
30
|
+
- name # Naming table
|
|
31
|
+
- post # PostScript information
|
|
32
|
+
- cvt # Control value table
|
|
33
|
+
- fpgm # Font program
|
|
34
|
+
- prep # Control value program
|
|
35
|
+
|
|
36
|
+
# Tables that should typically not be shared
|
|
37
|
+
# These tables often differ between fonts in a family
|
|
38
|
+
unique_tables:
|
|
39
|
+
- cmap # Character to glyph mapping (can vary)
|
|
40
|
+
- glyf # Glyph data (usually unique per font)
|
|
41
|
+
- loca # Index to location (tied to glyf)
|
|
42
|
+
- hmtx # Horizontal metrics (varies by font)
|
|
43
|
+
- GSUB # Glyph substitution (may vary)
|
|
44
|
+
- GPOS # Glyph positioning (may vary)
|
|
45
|
+
|
|
46
|
+
# Minimum space savings threshold (in bytes)
|
|
47
|
+
# If potential savings are below this, a warning is issued
|
|
48
|
+
min_savings_threshold: 1024
|
|
49
|
+
|
|
50
|
+
# Maximum collection size (in bytes)
|
|
51
|
+
# Collections larger than this will trigger a warning
|
|
52
|
+
# Set to 0 to disable
|
|
53
|
+
max_collection_size: 104857600 # 100 MB
|
|
54
|
+
|
|
55
|
+
# Enable verbose logging during collection creation
|
|
56
|
+
verbose: false
|