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,241 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base_command"
|
|
4
|
+
require_relative "../collection/builder"
|
|
5
|
+
require_relative "../font_loader"
|
|
6
|
+
|
|
7
|
+
module Fontisan
|
|
8
|
+
module Commands
|
|
9
|
+
# Command for packing multiple fonts into a TTC/OTC collection
|
|
10
|
+
#
|
|
11
|
+
# This command provides CLI access to font collection creation functionality.
|
|
12
|
+
# It loads multiple font files and combines them into a single TTC (TrueType Collection)
|
|
13
|
+
# or OTC (OpenType Collection) file with shared table deduplication to save space.
|
|
14
|
+
#
|
|
15
|
+
# @example Pack fonts into TTC
|
|
16
|
+
# command = PackCommand.new(
|
|
17
|
+
# ['font1.ttf', 'font2.ttf', 'font3.ttf'],
|
|
18
|
+
# output: 'family.ttc',
|
|
19
|
+
# format: :ttc,
|
|
20
|
+
# optimize: true
|
|
21
|
+
# )
|
|
22
|
+
# result = command.run
|
|
23
|
+
# puts "Saved #{result[:space_savings]} bytes through table sharing"
|
|
24
|
+
#
|
|
25
|
+
# @example Pack with analysis
|
|
26
|
+
# command = PackCommand.new(
|
|
27
|
+
# ['Regular.otf', 'Bold.otf', 'Italic.otf'],
|
|
28
|
+
# output: 'family.otc',
|
|
29
|
+
# format: :otc,
|
|
30
|
+
# analyze: true
|
|
31
|
+
# )
|
|
32
|
+
# result = command.run
|
|
33
|
+
class PackCommand
|
|
34
|
+
# Initialize pack command
|
|
35
|
+
#
|
|
36
|
+
# @param font_paths [Array<String>] Paths to input font files
|
|
37
|
+
# @param options [Hash] Command options
|
|
38
|
+
# @option options [String] :output Output file path (required)
|
|
39
|
+
# @option options [Symbol, String] :format Format type (:ttc or :otc, default: :ttc)
|
|
40
|
+
# @option options [Boolean] :optimize Enable table sharing optimization (default: true)
|
|
41
|
+
# @option options [Boolean] :analyze Show analysis report before building (default: false)
|
|
42
|
+
# @option options [Boolean] :verbose Enable verbose output (default: false)
|
|
43
|
+
# @raise [ArgumentError] if font_paths or output is invalid
|
|
44
|
+
def initialize(font_paths, options = {})
|
|
45
|
+
@font_paths = font_paths
|
|
46
|
+
@options = options
|
|
47
|
+
@output_path = options[:output]
|
|
48
|
+
@format = parse_format(options[:format] || :ttc)
|
|
49
|
+
@optimize = options.fetch(:optimize, true)
|
|
50
|
+
@analyze = options.fetch(:analyze, false)
|
|
51
|
+
@verbose = options.fetch(:verbose, false)
|
|
52
|
+
|
|
53
|
+
validate_options!
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Execute the pack command
|
|
57
|
+
#
|
|
58
|
+
# Loads all fonts, analyzes tables, and creates a TTC/OTC collection.
|
|
59
|
+
# Optionally displays analysis before building.
|
|
60
|
+
#
|
|
61
|
+
# @return [Hash] Result information with:
|
|
62
|
+
# - :output [String] - Output file path
|
|
63
|
+
# - :output_size [Integer] - Output file size in bytes
|
|
64
|
+
# - :num_fonts [Integer] - Number of fonts packed
|
|
65
|
+
# - :format [Symbol] - Collection format (:ttc or :otc)
|
|
66
|
+
# - :space_savings [Integer] - Bytes saved through sharing
|
|
67
|
+
# - :sharing_percentage [Float] - Percentage of tables shared
|
|
68
|
+
# - :analysis [Hash] - Analysis report (if analyze option enabled)
|
|
69
|
+
# @raise [ArgumentError] if options are invalid
|
|
70
|
+
# @raise [Fontisan::Error] if packing fails
|
|
71
|
+
def run
|
|
72
|
+
puts "Loading #{@font_paths.size} fonts..." if @verbose
|
|
73
|
+
|
|
74
|
+
# Load all fonts
|
|
75
|
+
fonts = load_fonts
|
|
76
|
+
|
|
77
|
+
# Create builder
|
|
78
|
+
builder = Collection::Builder.new(fonts, {
|
|
79
|
+
format: @format,
|
|
80
|
+
optimize: @optimize,
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
# Validate before building
|
|
84
|
+
builder.validate!
|
|
85
|
+
|
|
86
|
+
# Show analysis if requested
|
|
87
|
+
if @analyze || @verbose
|
|
88
|
+
show_analysis(builder)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Build collection
|
|
92
|
+
puts "Building #{@format.upcase} collection..." if @verbose
|
|
93
|
+
result = builder.build_to_file(@output_path)
|
|
94
|
+
|
|
95
|
+
# Display results
|
|
96
|
+
if @verbose
|
|
97
|
+
display_results(result)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
result
|
|
101
|
+
rescue Fontisan::Error => e
|
|
102
|
+
raise Fontisan::Error, "Collection packing failed: #{e.message}"
|
|
103
|
+
rescue StandardError => e
|
|
104
|
+
raise Fontisan::Error, "Unexpected error during packing: #{e.message}"
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
private
|
|
108
|
+
|
|
109
|
+
# Validate command options
|
|
110
|
+
#
|
|
111
|
+
# @raise [ArgumentError] if options are invalid
|
|
112
|
+
def validate_options!
|
|
113
|
+
# Must have output path
|
|
114
|
+
unless @output_path
|
|
115
|
+
raise ArgumentError, "Output path is required (--output)"
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Must have at least 2 fonts for collection
|
|
119
|
+
if @font_paths.nil? || @font_paths.empty?
|
|
120
|
+
raise ArgumentError, "Must specify at least 2 font files"
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
if @font_paths.size < 2
|
|
124
|
+
raise ArgumentError,
|
|
125
|
+
"Collection requires at least 2 fonts, got #{@font_paths.size}"
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Validate format
|
|
129
|
+
unless %i[ttc otc].include?(@format)
|
|
130
|
+
raise ArgumentError,
|
|
131
|
+
"Invalid format: #{@format}. Must be :ttc or :otc"
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Check output extension matches format
|
|
135
|
+
ext = File.extname(@output_path).downcase
|
|
136
|
+
expected_ext = @format == :ttc ? ".ttc" : ".otc"
|
|
137
|
+
if ext != expected_ext
|
|
138
|
+
warn "Warning: Output extension '#{ext}' doesn't match format '#{@format}' (expected '#{expected_ext}')"
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Load all fonts
|
|
143
|
+
#
|
|
144
|
+
# @return [Array<TrueTypeFont, OpenTypeFont>] Loaded fonts
|
|
145
|
+
# @raise [Fontisan::Error] if any font fails to load
|
|
146
|
+
def load_fonts
|
|
147
|
+
fonts = []
|
|
148
|
+
|
|
149
|
+
@font_paths.each_with_index do |path, index|
|
|
150
|
+
puts " [#{index + 1}/#{@font_paths.size}] Loading #{File.basename(path)}..." if @verbose
|
|
151
|
+
|
|
152
|
+
begin
|
|
153
|
+
font = FontLoader.load(path)
|
|
154
|
+
fonts << font
|
|
155
|
+
rescue Errno::ENOENT
|
|
156
|
+
raise Fontisan::Error, "Font file not found: #{path}"
|
|
157
|
+
rescue StandardError => e
|
|
158
|
+
raise Fontisan::Error, "Failed to load font '#{path}': #{e.message}"
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
fonts
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Parse format option
|
|
166
|
+
#
|
|
167
|
+
# @param format [Symbol, String] Format option
|
|
168
|
+
# @return [Symbol] Parsed format (:ttc or :otc)
|
|
169
|
+
# @raise [ArgumentError] if format is invalid
|
|
170
|
+
def parse_format(format)
|
|
171
|
+
return format if format.is_a?(Symbol) && %i[ttc otc].include?(format)
|
|
172
|
+
|
|
173
|
+
case format.to_s.downcase
|
|
174
|
+
when "ttc"
|
|
175
|
+
:ttc
|
|
176
|
+
when "otc"
|
|
177
|
+
:otc
|
|
178
|
+
else
|
|
179
|
+
raise ArgumentError,
|
|
180
|
+
"Invalid format: #{format}. Must be 'ttc' or 'otc'"
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Show analysis report
|
|
185
|
+
#
|
|
186
|
+
# @param builder [Collection::Builder] Collection builder
|
|
187
|
+
# @return [void]
|
|
188
|
+
def show_analysis(builder)
|
|
189
|
+
puts "\n=== Collection Analysis ==="
|
|
190
|
+
|
|
191
|
+
analysis = builder.analyze
|
|
192
|
+
|
|
193
|
+
puts "Total fonts: #{analysis[:total_fonts]}"
|
|
194
|
+
puts "Shared tables: #{analysis[:shared_tables].size}"
|
|
195
|
+
puts "Potential space savings: #{format_bytes(analysis[:space_savings])}"
|
|
196
|
+
puts "Table sharing: #{analysis[:sharing_percentage]}%"
|
|
197
|
+
|
|
198
|
+
if @verbose && analysis[:shared_tables].any?
|
|
199
|
+
puts "\nShared table details:"
|
|
200
|
+
analysis[:shared_tables].each do |tag, groups|
|
|
201
|
+
groups.each do |group|
|
|
202
|
+
font_indices = group[:font_indices]
|
|
203
|
+
puts " #{tag}: shared by fonts #{font_indices.join(', ')}"
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
puts ""
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# Display build results
|
|
212
|
+
#
|
|
213
|
+
# @param result [Hash] Build result
|
|
214
|
+
# @return [void]
|
|
215
|
+
def display_results(result)
|
|
216
|
+
puts "\n=== Collection Created ==="
|
|
217
|
+
puts "Output: #{result[:output_path]}"
|
|
218
|
+
puts "Format: #{result[:format].upcase}"
|
|
219
|
+
puts "Fonts: #{result[:num_fonts]}"
|
|
220
|
+
puts "Size: #{format_bytes(result[:output_size])}"
|
|
221
|
+
puts "Space saved: #{format_bytes(result[:space_savings])}"
|
|
222
|
+
puts "Sharing: #{result[:statistics][:sharing_percentage]}%"
|
|
223
|
+
puts ""
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# Format bytes for display
|
|
227
|
+
#
|
|
228
|
+
# @param bytes [Integer] Byte count
|
|
229
|
+
# @return [String] Formatted string
|
|
230
|
+
def format_bytes(bytes)
|
|
231
|
+
if bytes < 1024
|
|
232
|
+
"#{bytes} B"
|
|
233
|
+
elsif bytes < 1024 * 1024
|
|
234
|
+
"#{(bytes / 1024.0).round(2)} KB"
|
|
235
|
+
else
|
|
236
|
+
"#{(bytes / (1024.0 * 1024)).round(2)} MB"
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
end
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base_command"
|
|
4
|
+
require_relative "../subset/builder"
|
|
5
|
+
require_relative "../subset/options"
|
|
6
|
+
require "fileutils"
|
|
7
|
+
|
|
8
|
+
module Fontisan
|
|
9
|
+
module Commands
|
|
10
|
+
# Command for subsetting fonts
|
|
11
|
+
#
|
|
12
|
+
# This command provides CLI access to font subsetting functionality.
|
|
13
|
+
# It supports multiple input methods for specifying glyphs:
|
|
14
|
+
# - Text input: Subset to characters in a text string
|
|
15
|
+
# - Glyph IDs: Subset to specific glyph IDs
|
|
16
|
+
# - Unicode codepoints: Subset to specific Unicode values
|
|
17
|
+
#
|
|
18
|
+
# The command also supports various subsetting options:
|
|
19
|
+
# - Profile selection (pdf, web, minimal)
|
|
20
|
+
# - Glyph ID retention
|
|
21
|
+
# - Hint dropping
|
|
22
|
+
# - Name dropping
|
|
23
|
+
#
|
|
24
|
+
# @example Subset to text characters
|
|
25
|
+
# command = SubsetCommand.new('font.ttf',
|
|
26
|
+
# text: 'Hello World',
|
|
27
|
+
# output: 'subset.ttf',
|
|
28
|
+
# profile: 'pdf'
|
|
29
|
+
# )
|
|
30
|
+
# command.run
|
|
31
|
+
#
|
|
32
|
+
# @example Subset to specific glyphs
|
|
33
|
+
# command = SubsetCommand.new('font.ttf',
|
|
34
|
+
# glyphs: [0, 1, 65, 66, 67],
|
|
35
|
+
# output: 'subset.ttf'
|
|
36
|
+
# )
|
|
37
|
+
# command.run
|
|
38
|
+
class SubsetCommand < BaseCommand
|
|
39
|
+
# Initialize subset command
|
|
40
|
+
#
|
|
41
|
+
# @param font_path [String] Path to input font file
|
|
42
|
+
# @param options [Hash] Command options
|
|
43
|
+
# @option options [String] :text Text to subset
|
|
44
|
+
# @option options [Array<Integer>] :glyphs Glyph IDs to subset
|
|
45
|
+
# @option options [Array<Integer>] :unicode Unicode codepoints to subset
|
|
46
|
+
# @option options [String] :output Output file path (required)
|
|
47
|
+
# @option options [String] :profile Subsetting profile (pdf, web, minimal)
|
|
48
|
+
# @option options [Boolean] :retain_gids Retain original glyph IDs
|
|
49
|
+
# @option options [Boolean] :drop_hints Drop hinting instructions
|
|
50
|
+
# @option options [Boolean] :drop_names Drop glyph names
|
|
51
|
+
# @option options [Boolean] :unicode_ranges Prune OS/2 Unicode ranges
|
|
52
|
+
def initialize(font_path, options = {})
|
|
53
|
+
super(font_path, options)
|
|
54
|
+
@output_path = options[:output]
|
|
55
|
+
validate_options!
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Execute the subset command
|
|
59
|
+
#
|
|
60
|
+
# @return [Hash] Result information with output path and glyph count
|
|
61
|
+
# @raise [ArgumentError] If options are invalid
|
|
62
|
+
# @raise [Fontisan::SubsettingError] If subsetting fails
|
|
63
|
+
def run
|
|
64
|
+
# Determine glyph IDs to subset
|
|
65
|
+
glyph_ids = determine_glyph_ids
|
|
66
|
+
|
|
67
|
+
# Build subsetting options
|
|
68
|
+
subset_options = build_subset_options
|
|
69
|
+
|
|
70
|
+
# Create builder and perform subsetting
|
|
71
|
+
builder = Subset::Builder.new(font, glyph_ids, subset_options)
|
|
72
|
+
subset_binary = builder.build
|
|
73
|
+
|
|
74
|
+
# Write output file (create parent directories if needed)
|
|
75
|
+
FileUtils.mkdir_p(File.dirname(@output_path))
|
|
76
|
+
File.binwrite(@output_path, subset_binary)
|
|
77
|
+
|
|
78
|
+
# Return result
|
|
79
|
+
{
|
|
80
|
+
input: font_path,
|
|
81
|
+
output: @output_path,
|
|
82
|
+
original_glyphs: font.table("maxp").num_glyphs,
|
|
83
|
+
subset_glyphs: builder.mapping.size,
|
|
84
|
+
profile: subset_options.profile,
|
|
85
|
+
size: subset_binary.bytesize,
|
|
86
|
+
}
|
|
87
|
+
rescue Fontisan::SubsettingError => e
|
|
88
|
+
raise Fontisan::SubsettingError,
|
|
89
|
+
"Subsetting failed: #{e.message}"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
private
|
|
93
|
+
|
|
94
|
+
# Validate command options
|
|
95
|
+
#
|
|
96
|
+
# @raise [ArgumentError] If options are invalid
|
|
97
|
+
def validate_options!
|
|
98
|
+
# Must have output path
|
|
99
|
+
unless @output_path
|
|
100
|
+
raise ArgumentError, "Output path is required (--output)"
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Must have at least one input method
|
|
104
|
+
unless options[:text] || options[:glyphs] || options[:unicode]
|
|
105
|
+
raise ArgumentError,
|
|
106
|
+
"Must specify --text, --glyphs, or --unicode"
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Can only use one input method
|
|
110
|
+
input_methods = [
|
|
111
|
+
options[:text],
|
|
112
|
+
options[:glyphs],
|
|
113
|
+
options[:unicode],
|
|
114
|
+
].compact.size
|
|
115
|
+
|
|
116
|
+
if input_methods > 1
|
|
117
|
+
raise ArgumentError,
|
|
118
|
+
"Can only specify one of --text, --glyphs, or --unicode"
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Determine glyph IDs to subset based on input options
|
|
123
|
+
#
|
|
124
|
+
# @return [Array<Integer>] Array of glyph IDs
|
|
125
|
+
# @raise [ArgumentError] If input is invalid
|
|
126
|
+
def determine_glyph_ids
|
|
127
|
+
if options[:text]
|
|
128
|
+
glyph_ids_from_text(options[:text])
|
|
129
|
+
elsif options[:glyphs]
|
|
130
|
+
parse_glyph_ids(options[:glyphs])
|
|
131
|
+
elsif options[:unicode]
|
|
132
|
+
glyph_ids_from_unicode(options[:unicode])
|
|
133
|
+
else
|
|
134
|
+
raise ArgumentError, "No input specified"
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Convert text to glyph IDs
|
|
139
|
+
#
|
|
140
|
+
# @param text [String] Input text
|
|
141
|
+
# @return [Array<Integer>] Array of glyph IDs
|
|
142
|
+
def glyph_ids_from_text(text)
|
|
143
|
+
cmap = font.table("cmap")
|
|
144
|
+
raise Fontisan::MissingTableError, "Font has no cmap table" unless cmap
|
|
145
|
+
|
|
146
|
+
mappings = cmap.unicode_mappings
|
|
147
|
+
glyph_ids = Set.new
|
|
148
|
+
|
|
149
|
+
text.each_char do |char|
|
|
150
|
+
codepoint = char.ord
|
|
151
|
+
glyph_id = mappings[codepoint]
|
|
152
|
+
|
|
153
|
+
if glyph_id
|
|
154
|
+
glyph_ids.add(glyph_id)
|
|
155
|
+
elsif options[:verbose]
|
|
156
|
+
warn "Warning: Character '#{char}' (U+#{codepoint.to_s(16).upcase}) not found in font"
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
if glyph_ids.empty?
|
|
161
|
+
raise ArgumentError, "No characters from text found in font"
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
glyph_ids.to_a.sort
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Convert Unicode codepoints to glyph IDs
|
|
168
|
+
#
|
|
169
|
+
# @param unicode_input [String, Array<Integer>] Unicode codepoints
|
|
170
|
+
# @return [Array<Integer>] Array of glyph IDs
|
|
171
|
+
def glyph_ids_from_unicode(unicode_input)
|
|
172
|
+
cmap = font.table("cmap")
|
|
173
|
+
raise Fontisan::MissingTableError, "Font has no cmap table" unless cmap
|
|
174
|
+
|
|
175
|
+
mappings = cmap.unicode_mappings
|
|
176
|
+
codepoints = parse_unicode(unicode_input)
|
|
177
|
+
glyph_ids = Set.new
|
|
178
|
+
|
|
179
|
+
codepoints.each do |codepoint|
|
|
180
|
+
glyph_id = mappings[codepoint]
|
|
181
|
+
|
|
182
|
+
if glyph_id
|
|
183
|
+
glyph_ids.add(glyph_id)
|
|
184
|
+
elsif options[:verbose]
|
|
185
|
+
warn "Warning: U+#{codepoint.to_s(16).upcase} not found in font"
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
if glyph_ids.empty?
|
|
190
|
+
raise ArgumentError, "No Unicode codepoints found in font"
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
glyph_ids.to_a.sort
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Parse glyph IDs from input
|
|
197
|
+
#
|
|
198
|
+
# @param glyph_input [String, Array<Integer>] Glyph IDs
|
|
199
|
+
# @return [Array<Integer>] Array of glyph IDs
|
|
200
|
+
def parse_glyph_ids(glyph_input)
|
|
201
|
+
if glyph_input.is_a?(Array)
|
|
202
|
+
glyph_input.map(&:to_i)
|
|
203
|
+
elsif glyph_input.is_a?(String)
|
|
204
|
+
# Parse comma-separated or space-separated list
|
|
205
|
+
glyph_input.split(/[,\s]+/).map(&:to_i)
|
|
206
|
+
else
|
|
207
|
+
raise ArgumentError, "Invalid glyph input: #{glyph_input.inspect}"
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# Parse Unicode codepoints from input
|
|
212
|
+
#
|
|
213
|
+
# @param unicode_input [String, Array<Integer>] Unicode codepoints
|
|
214
|
+
# @return [Array<Integer>] Array of codepoints
|
|
215
|
+
def parse_unicode(unicode_input)
|
|
216
|
+
if unicode_input.is_a?(Array)
|
|
217
|
+
unicode_input.map(&:to_i)
|
|
218
|
+
elsif unicode_input.is_a?(String)
|
|
219
|
+
# Parse comma-separated list with optional U+ prefix
|
|
220
|
+
unicode_input.split(/[,\s]+/).map do |s|
|
|
221
|
+
s = s.sub(/^U\+/i, "")
|
|
222
|
+
s.to_i(16)
|
|
223
|
+
end
|
|
224
|
+
else
|
|
225
|
+
raise ArgumentError, "Invalid Unicode input: #{unicode_input.inspect}"
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# Build subsetting options from command options
|
|
230
|
+
#
|
|
231
|
+
# @return [Subset::Options] Subsetting options
|
|
232
|
+
def build_subset_options
|
|
233
|
+
Subset::Options.new(
|
|
234
|
+
profile: options[:profile] || "pdf",
|
|
235
|
+
retain_gids: options[:retain_gids] || false,
|
|
236
|
+
drop_hints: options[:drop_hints] || false,
|
|
237
|
+
drop_names: options[:drop_names] || false,
|
|
238
|
+
unicode_ranges: options[:unicode_ranges].nil? || options[:unicode_ranges],
|
|
239
|
+
include_notdef: true,
|
|
240
|
+
include_null: false,
|
|
241
|
+
)
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
end
|