fontisan 0.2.6 → 0.2.8
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.yml +103 -0
- data/.rubocop_todo.yml +107 -318
- data/Gemfile +1 -1
- data/README.adoc +127 -17
- data/Rakefile +12 -7
- data/benchmark/variation_quick_bench.rb +1 -1
- data/lib/fontisan/base_collection.rb +5 -33
- data/lib/fontisan/cli.rb +45 -13
- data/lib/fontisan/collection/dfont_builder.rb +2 -1
- data/lib/fontisan/collection/shared_logic.rb +54 -0
- data/lib/fontisan/commands/convert_command.rb +2 -4
- data/lib/fontisan/commands/info_command.rb +3 -3
- data/lib/fontisan/commands/pack_command.rb +2 -1
- data/lib/fontisan/commands/validate_command.rb +157 -6
- data/lib/fontisan/converters/collection_converter.rb +22 -13
- data/lib/fontisan/converters/svg_generator.rb +2 -1
- data/lib/fontisan/converters/woff2_encoder.rb +6 -6
- data/lib/fontisan/converters/woff_writer.rb +3 -1
- data/lib/fontisan/dfont_collection.rb +84 -0
- data/lib/fontisan/font_loader.rb +9 -9
- data/lib/fontisan/formatters/text_formatter.rb +18 -14
- data/lib/fontisan/hints/hint_converter.rb +1 -1
- data/lib/fontisan/hints/hint_validator.rb +13 -10
- data/lib/fontisan/hints/truetype_instruction_analyzer.rb +15 -8
- data/lib/fontisan/hints/truetype_instruction_generator.rb +1 -1
- data/lib/fontisan/models/collection_validation_report.rb +104 -0
- data/lib/fontisan/models/font_report.rb +24 -0
- data/lib/fontisan/models/validation_report.rb +7 -2
- data/lib/fontisan/open_type_font.rb +2 -3
- data/lib/fontisan/optimizers/charstring_rewriter.rb +1 -1
- data/lib/fontisan/optimizers/subroutine_optimizer.rb +6 -2
- data/lib/fontisan/subset/glyph_mapping.rb +2 -0
- data/lib/fontisan/subset/table_subsetter.rb +2 -2
- data/lib/fontisan/tables/cblc.rb +8 -4
- data/lib/fontisan/tables/cff/index.rb +2 -0
- data/lib/fontisan/tables/cff.rb +6 -3
- data/lib/fontisan/tables/cff2/private_dict_blend_handler.rb +1 -1
- data/lib/fontisan/tables/cff2.rb +1 -1
- data/lib/fontisan/tables/cmap.rb +5 -5
- data/lib/fontisan/tables/glyf.rb +8 -10
- data/lib/fontisan/tables/head.rb +3 -3
- data/lib/fontisan/tables/hhea.rb +4 -4
- data/lib/fontisan/tables/maxp.rb +2 -2
- data/lib/fontisan/tables/name.rb +1 -1
- data/lib/fontisan/tables/os2.rb +8 -8
- data/lib/fontisan/tables/post.rb +2 -2
- data/lib/fontisan/tables/sbix.rb +5 -4
- data/lib/fontisan/true_type_font.rb +2 -3
- data/lib/fontisan/utilities/checksum_calculator.rb +0 -44
- data/lib/fontisan/validation/collection_validator.rb +4 -2
- data/lib/fontisan/validators/basic_validator.rb +11 -21
- data/lib/fontisan/validators/font_book_validator.rb +29 -50
- data/lib/fontisan/validators/opentype_validator.rb +24 -28
- data/lib/fontisan/validators/validator.rb +87 -66
- data/lib/fontisan/validators/web_font_validator.rb +16 -21
- data/lib/fontisan/version.rb +1 -1
- data/lib/fontisan/woff2/glyf_transformer.rb +31 -8
- data/lib/fontisan/woff2/hmtx_transformer.rb +2 -1
- data/lib/fontisan/woff2/table_transformer.rb +4 -2
- data/lib/fontisan/woff2_font.rb +4 -2
- data/lib/fontisan/woff_font.rb +2 -2
- data/lib/fontisan.rb +2 -2
- data/scripts/compare_stack_aware.rb +1 -1
- data/scripts/measure_optimization.rb +1 -2
- metadata +5 -2
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require_relative "base_command"
|
|
4
4
|
require_relative "../validators/profile_loader"
|
|
5
5
|
require_relative "../font_loader"
|
|
6
|
+
require_relative "../tables/name"
|
|
6
7
|
|
|
7
8
|
module Fontisan
|
|
8
9
|
module Commands
|
|
@@ -70,12 +71,40 @@ module Fontisan
|
|
|
70
71
|
# Load font with appropriate mode
|
|
71
72
|
profile_config = Validators::ProfileLoader.profile_info(@profile)
|
|
72
73
|
unless profile_config
|
|
73
|
-
|
|
74
|
+
unless @suppress_warnings
|
|
75
|
+
puts "Error: Unknown profile '#{@profile}'"
|
|
76
|
+
puts ""
|
|
77
|
+
puts "Available profiles:"
|
|
78
|
+
Validators::ProfileLoader.all_profiles.each do |name, config|
|
|
79
|
+
puts " #{name.to_s.ljust(20)} - #{config[:description]}"
|
|
80
|
+
end
|
|
81
|
+
puts ""
|
|
82
|
+
puts "Use --list to see all available profiles"
|
|
83
|
+
end
|
|
74
84
|
return 1
|
|
75
85
|
end
|
|
76
86
|
|
|
77
87
|
mode = profile_config[:loading_mode].to_sym
|
|
78
88
|
|
|
89
|
+
# Check if input is a collection
|
|
90
|
+
if FontLoader.collection?(@input)
|
|
91
|
+
validate_collection(mode)
|
|
92
|
+
else
|
|
93
|
+
validate_single_font(mode)
|
|
94
|
+
end
|
|
95
|
+
rescue StandardError => e
|
|
96
|
+
puts "Error: #{e.message}" unless @suppress_warnings
|
|
97
|
+
puts e.backtrace.join("\n") if @verbose && !@suppress_warnings
|
|
98
|
+
1
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
private
|
|
102
|
+
|
|
103
|
+
# Validate a single font file
|
|
104
|
+
#
|
|
105
|
+
# @param mode [Symbol] Loading mode
|
|
106
|
+
# @return [Integer] Exit code
|
|
107
|
+
def validate_single_font(mode)
|
|
79
108
|
font = FontLoader.load(@input, mode: mode)
|
|
80
109
|
|
|
81
110
|
# Select validator
|
|
@@ -102,13 +131,134 @@ module Fontisan
|
|
|
102
131
|
|
|
103
132
|
# Return exit code
|
|
104
133
|
exit_code(report)
|
|
105
|
-
rescue => e
|
|
106
|
-
puts "Error: #{e.message}" unless @suppress_warnings
|
|
107
|
-
puts e.backtrace.join("\n") if @verbose && !@suppress_warnings
|
|
108
|
-
1
|
|
109
134
|
end
|
|
110
135
|
|
|
111
|
-
|
|
136
|
+
# Validate all fonts in a collection
|
|
137
|
+
#
|
|
138
|
+
# @param mode [Symbol] Loading mode
|
|
139
|
+
# @return [Integer] Exit code
|
|
140
|
+
def validate_collection(mode)
|
|
141
|
+
require_relative "../models/collection_validation_report"
|
|
142
|
+
require_relative "../models/font_report"
|
|
143
|
+
|
|
144
|
+
# Load collection metadata
|
|
145
|
+
collection = FontLoader.load_collection(@input)
|
|
146
|
+
|
|
147
|
+
# Create collection report
|
|
148
|
+
collection_report = Models::CollectionValidationReport.new(
|
|
149
|
+
collection_path: @input,
|
|
150
|
+
collection_type: collection.class.collection_format,
|
|
151
|
+
num_fonts: collection.num_fonts,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
# Get validator
|
|
155
|
+
validator = Validators::ProfileLoader.load(@profile)
|
|
156
|
+
|
|
157
|
+
# Validate each font
|
|
158
|
+
collection.num_fonts.times do |index|
|
|
159
|
+
font = FontLoader.load(@input, font_index: index, mode: mode)
|
|
160
|
+
font_report = validator.validate(font)
|
|
161
|
+
|
|
162
|
+
# Set font_path to indicate collection file with index
|
|
163
|
+
# Fonts in collections don't have individual file paths
|
|
164
|
+
font_report.font_path = "#{@input}:#{index}"
|
|
165
|
+
|
|
166
|
+
# Extract font name
|
|
167
|
+
font_name = extract_font_name(font, index)
|
|
168
|
+
|
|
169
|
+
# Create and add font report
|
|
170
|
+
collection_report.add_font_report(
|
|
171
|
+
Models::FontReport.new(
|
|
172
|
+
font_index: index,
|
|
173
|
+
font_name: font_name,
|
|
174
|
+
report: font_report,
|
|
175
|
+
),
|
|
176
|
+
)
|
|
177
|
+
rescue StandardError => e
|
|
178
|
+
# Create error report for failed font loading
|
|
179
|
+
# Use collection file path with index for fonts in collections
|
|
180
|
+
error_report = Models::ValidationReport.new(
|
|
181
|
+
font_path: "#{@input}:#{index}",
|
|
182
|
+
valid: false,
|
|
183
|
+
)
|
|
184
|
+
error_report.add_error("font_loading",
|
|
185
|
+
"Failed to load font #{index}: #{e.message}", nil)
|
|
186
|
+
|
|
187
|
+
collection_report.add_font_report(
|
|
188
|
+
Models::FontReport.new(
|
|
189
|
+
font_index: index,
|
|
190
|
+
font_name: "Font #{index}",
|
|
191
|
+
report: error_report,
|
|
192
|
+
),
|
|
193
|
+
)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Generate output based on format
|
|
197
|
+
output = case @format
|
|
198
|
+
when :yaml
|
|
199
|
+
collection_report.to_yaml
|
|
200
|
+
when :json
|
|
201
|
+
collection_report.to_json
|
|
202
|
+
else
|
|
203
|
+
collection_report.text_summary
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# Write to file or stdout
|
|
207
|
+
if @output
|
|
208
|
+
File.write(@output, output)
|
|
209
|
+
puts "Validation report written to #{@output}" if @verbose && !@suppress_warnings
|
|
210
|
+
else
|
|
211
|
+
puts output unless @suppress_warnings
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# Return exit code based on worst status
|
|
215
|
+
collection_exit_code(collection_report)
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# Extract font name from font object
|
|
219
|
+
#
|
|
220
|
+
# @param font [TrueTypeFont, OpenTypeFont] Font object
|
|
221
|
+
# @param index [Integer] Font index (fallback)
|
|
222
|
+
# @return [String] Font name
|
|
223
|
+
def extract_font_name(font, index)
|
|
224
|
+
return "Font #{index}" unless font.respond_to?(:table)
|
|
225
|
+
|
|
226
|
+
name_table = font.table("name")
|
|
227
|
+
return "Font #{index}" unless name_table
|
|
228
|
+
|
|
229
|
+
full_name = name_table.english_name(Tables::Name::FULL_NAME)
|
|
230
|
+
return full_name if full_name && !full_name.empty?
|
|
231
|
+
|
|
232
|
+
postscript_name = name_table.english_name(Tables::Name::POSTSCRIPT_NAME)
|
|
233
|
+
return postscript_name if postscript_name && !postscript_name.empty?
|
|
234
|
+
|
|
235
|
+
"Font #{index}"
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# Calculate exit code for collection validation
|
|
239
|
+
#
|
|
240
|
+
# Uses worst status across all fonts
|
|
241
|
+
#
|
|
242
|
+
# @param report [CollectionValidationReport] Collection report
|
|
243
|
+
# @return [Integer] Exit code
|
|
244
|
+
def collection_exit_code(report)
|
|
245
|
+
return 0 unless @return_value_results
|
|
246
|
+
|
|
247
|
+
# Check for fatal errors first
|
|
248
|
+
return 2 if report.font_reports.any? do |fr|
|
|
249
|
+
fr.report.fatal_errors.any?
|
|
250
|
+
end
|
|
251
|
+
# Then check for errors
|
|
252
|
+
return 3 if report.font_reports.any? { |fr| fr.report.errors_only.any? }
|
|
253
|
+
# Then check for warnings
|
|
254
|
+
return 4 if report.font_reports.any? do |fr|
|
|
255
|
+
fr.report.warnings_only.any?
|
|
256
|
+
end
|
|
257
|
+
# Then check for info
|
|
258
|
+
return 5 if report.font_reports.any? { |fr| fr.report.info_only.any? }
|
|
259
|
+
|
|
260
|
+
0
|
|
261
|
+
end
|
|
112
262
|
|
|
113
263
|
# Generate output based on requested format
|
|
114
264
|
#
|
|
@@ -155,6 +305,7 @@ module Fontisan
|
|
|
155
305
|
return 3 if report.errors_only.any?
|
|
156
306
|
return 4 if report.warnings_only.any?
|
|
157
307
|
return 5 if report.info_only.any?
|
|
308
|
+
|
|
158
309
|
0
|
|
159
310
|
end
|
|
160
311
|
end
|
|
@@ -59,11 +59,12 @@ module Fontisan
|
|
|
59
59
|
|
|
60
60
|
verbose = options.fetch(:verbose, false)
|
|
61
61
|
output_path = options[:output]
|
|
62
|
-
target_format = options.fetch(:target_format,
|
|
62
|
+
target_format = options.fetch(:target_format, "preserve").to_s
|
|
63
63
|
|
|
64
64
|
# Validate target_format
|
|
65
65
|
unless %w[preserve ttf otf].include?(target_format)
|
|
66
|
-
raise ArgumentError,
|
|
66
|
+
raise ArgumentError,
|
|
67
|
+
"Invalid target_format: #{target_format}. Must be 'preserve', 'ttf', or 'otf'"
|
|
67
68
|
end
|
|
68
69
|
|
|
69
70
|
puts "Converting collection to #{target_type.to_s.upcase}..." if verbose
|
|
@@ -76,19 +77,22 @@ module Fontisan
|
|
|
76
77
|
if source_type == target_type
|
|
77
78
|
puts " Source and target formats are the same, copying collection..." if verbose
|
|
78
79
|
FileUtils.cp(collection_path, output_path)
|
|
79
|
-
return build_result(collection_path, output_path, source_type,
|
|
80
|
+
return build_result(collection_path, output_path, source_type,
|
|
81
|
+
target_type, fonts.size, [])
|
|
80
82
|
end
|
|
81
83
|
|
|
82
84
|
# Step 2: Convert - transform fonts if requested
|
|
83
85
|
puts " Converting #{fonts.size} font(s)..." if verbose
|
|
84
|
-
converted_fonts, conversions = convert_fonts(fonts, source_type,
|
|
86
|
+
converted_fonts, conversions = convert_fonts(fonts, source_type,
|
|
87
|
+
target_type, options.merge(target_format: target_format))
|
|
85
88
|
|
|
86
89
|
# Step 3: Repack - build target collection
|
|
87
90
|
puts " Repacking into #{target_type.to_s.upcase} format..." if verbose
|
|
88
91
|
repack_fonts(converted_fonts, target_type, output_path, options)
|
|
89
92
|
|
|
90
93
|
# Build result
|
|
91
|
-
result = build_result(collection_path, output_path, source_type,
|
|
94
|
+
result = build_result(collection_path, output_path, source_type,
|
|
95
|
+
target_type, fonts.size, conversions)
|
|
92
96
|
|
|
93
97
|
if verbose
|
|
94
98
|
display_result(result)
|
|
@@ -111,7 +115,8 @@ module Fontisan
|
|
|
111
115
|
end
|
|
112
116
|
|
|
113
117
|
unless %i[ttc otc dfont].include?(target_type)
|
|
114
|
-
raise ArgumentError,
|
|
118
|
+
raise ArgumentError,
|
|
119
|
+
"Invalid target type: #{target_type}. Must be :ttc, :otc, or :dfont"
|
|
115
120
|
end
|
|
116
121
|
|
|
117
122
|
unless options[:output]
|
|
@@ -202,20 +207,21 @@ module Fontisan
|
|
|
202
207
|
# @param target_type [Symbol] Target collection type
|
|
203
208
|
# @param options [Hash] Conversion options
|
|
204
209
|
# @return [Array<(Array<Font>, Array<Hash>)>] [converted_fonts, conversions]
|
|
205
|
-
def convert_fonts(fonts,
|
|
210
|
+
def convert_fonts(fonts, _source_type, target_type, options)
|
|
206
211
|
converted_fonts = []
|
|
207
212
|
conversions = []
|
|
208
213
|
|
|
209
214
|
# Determine if outline conversion is needed
|
|
210
|
-
target_format = options.fetch(:target_format,
|
|
215
|
+
target_format = options.fetch(:target_format, "preserve").to_s
|
|
211
216
|
|
|
212
217
|
fonts.each_with_index do |font, index|
|
|
213
218
|
source_format = detect_font_format(font)
|
|
214
|
-
needs_conversion = outline_conversion_needed?(source_format,
|
|
219
|
+
needs_conversion = outline_conversion_needed?(source_format,
|
|
220
|
+
target_format)
|
|
215
221
|
|
|
216
222
|
if needs_conversion
|
|
217
223
|
# Convert outline format
|
|
218
|
-
desired_format = target_format ==
|
|
224
|
+
desired_format = target_format == "preserve" ? source_format : target_format.to_sym
|
|
219
225
|
converter = FormatConverter.new
|
|
220
226
|
|
|
221
227
|
begin
|
|
@@ -266,7 +272,7 @@ module Fontisan
|
|
|
266
272
|
# @return [Boolean] true if conversion needed
|
|
267
273
|
def outline_conversion_needed?(source_format, target_format)
|
|
268
274
|
# 'preserve' means keep original format
|
|
269
|
-
return false if target_format ==
|
|
275
|
+
return false if target_format == "preserve"
|
|
270
276
|
|
|
271
277
|
# Convert if target format differs from source
|
|
272
278
|
target_format.to_sym != source_format
|
|
@@ -401,7 +407,8 @@ module Fontisan
|
|
|
401
407
|
# @param num_fonts [Integer] Number of fonts
|
|
402
408
|
# @param conversions [Array<Hash>] Conversion details
|
|
403
409
|
# @return [Hash] Result
|
|
404
|
-
def build_result(input, output, source_type, target_type, num_fonts,
|
|
410
|
+
def build_result(input, output, source_type, target_type, num_fonts,
|
|
411
|
+
conversions)
|
|
405
412
|
{
|
|
406
413
|
input: input,
|
|
407
414
|
output: output,
|
|
@@ -425,7 +432,9 @@ module Fontisan
|
|
|
425
432
|
puts "Fonts: #{result[:num_fonts]}"
|
|
426
433
|
|
|
427
434
|
if result[:conversions].any?
|
|
428
|
-
converted_count = result[:conversions].count
|
|
435
|
+
converted_count = result[:conversions].count do |c|
|
|
436
|
+
c[:status] == :converted
|
|
437
|
+
end
|
|
429
438
|
if converted_count.positive?
|
|
430
439
|
puts "Outline conversions: #{converted_count}"
|
|
431
440
|
end
|
|
@@ -107,7 +107,8 @@ module Fontisan
|
|
|
107
107
|
# @param extractor [OutlineExtractor] Outline extractor for layer glyphs
|
|
108
108
|
# @param palette_index [Integer] Palette index to use (default: 0)
|
|
109
109
|
# @return [String, nil] SVG path data with color layers, or nil if not color glyph
|
|
110
|
-
def generate_color_glyph(glyph_id, colr_table, cpal_table, extractor,
|
|
110
|
+
def generate_color_glyph(glyph_id, colr_table, cpal_table, extractor,
|
|
111
|
+
palette_index: 0)
|
|
111
112
|
# Get layers for this glyph
|
|
112
113
|
layers = colr_table.layers_for_glyph(glyph_id)
|
|
113
114
|
return nil if layers.empty?
|
|
@@ -109,7 +109,7 @@ module Fontisan
|
|
|
109
109
|
woff2_binary = assemble_woff2(header, entries, compressed_data)
|
|
110
110
|
|
|
111
111
|
# Prepare result
|
|
112
|
-
|
|
112
|
+
{ woff2_binary: woff2_binary }
|
|
113
113
|
|
|
114
114
|
# Optional validation
|
|
115
115
|
# Temporarily disabled - will be reimplemented with new DSL framework
|
|
@@ -117,8 +117,6 @@ module Fontisan
|
|
|
117
117
|
# validation_report = validate_encoding(woff2_binary, options)
|
|
118
118
|
# result[:validation_report] = validation_report
|
|
119
119
|
# end
|
|
120
|
-
|
|
121
|
-
result
|
|
122
120
|
end
|
|
123
121
|
|
|
124
122
|
# Get list of supported conversions
|
|
@@ -185,14 +183,16 @@ module Fontisan
|
|
|
185
183
|
end
|
|
186
184
|
|
|
187
185
|
# Read table directory
|
|
188
|
-
woff2.table_entries = Woff2Font.read_table_directory_from_io(io,
|
|
186
|
+
woff2.table_entries = Woff2Font.read_table_directory_from_io(io,
|
|
187
|
+
woff2.header)
|
|
189
188
|
|
|
190
189
|
# Decompress tables
|
|
191
190
|
woff2.decompressed_tables = Woff2Font.decompress_tables(io, woff2.header,
|
|
192
|
-
|
|
191
|
+
woff2.table_entries)
|
|
193
192
|
|
|
194
193
|
# Apply transformations
|
|
195
|
-
Woff2Font.apply_transformations!(woff2.table_entries,
|
|
194
|
+
Woff2Font.apply_transformations!(woff2.table_entries,
|
|
195
|
+
woff2.decompressed_tables)
|
|
196
196
|
|
|
197
197
|
woff2
|
|
198
198
|
end
|
|
@@ -369,9 +369,11 @@ module Fontisan
|
|
|
369
369
|
# Sort tables by tag for consistent output (same order as directory)
|
|
370
370
|
sorted_tables = compressed_tables.sort_by { |tag, _| tag }
|
|
371
371
|
|
|
372
|
-
sorted_tables
|
|
372
|
+
# rubocop:disable Style/HashEachMethods - sorted_tables is an Array, not a Hash
|
|
373
|
+
sorted_tables.each do |_, table_info|
|
|
373
374
|
io.write(table_info[:compressed_data])
|
|
374
375
|
end
|
|
376
|
+
# rubocop:enable Style/HashEachMethods
|
|
375
377
|
end
|
|
376
378
|
|
|
377
379
|
# Write metadata to output
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "parsers/dfont_parser"
|
|
4
4
|
require_relative "error"
|
|
5
|
+
require_relative "collection/shared_logic"
|
|
5
6
|
|
|
6
7
|
module Fontisan
|
|
7
8
|
# DfontCollection represents an Apple dfont suitcase containing multiple fonts
|
|
@@ -23,6 +24,8 @@ module Fontisan
|
|
|
23
24
|
# fonts.each { |font| puts font.class.name }
|
|
24
25
|
# end
|
|
25
26
|
class DfontCollection
|
|
27
|
+
include Collection::SharedLogic
|
|
28
|
+
|
|
26
29
|
# Path to dfont file
|
|
27
30
|
# @return [String]
|
|
28
31
|
attr_reader :path
|
|
@@ -32,6 +35,22 @@ module Fontisan
|
|
|
32
35
|
attr_reader :num_fonts
|
|
33
36
|
alias font_count num_fonts
|
|
34
37
|
|
|
38
|
+
# Get font offsets (indices for dfont)
|
|
39
|
+
#
|
|
40
|
+
# dfont doesn't use byte offsets like TTC/OTC, so we return indices
|
|
41
|
+
#
|
|
42
|
+
# @return [Array<Integer>] Array of font indices
|
|
43
|
+
def font_offsets
|
|
44
|
+
(0...@num_fonts).to_a
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Get the collection format identifier
|
|
48
|
+
#
|
|
49
|
+
# @return [String] "dfont" for dfont collection
|
|
50
|
+
def self.collection_format
|
|
51
|
+
"dfont"
|
|
52
|
+
end
|
|
53
|
+
|
|
35
54
|
# Load dfont collection from file
|
|
36
55
|
#
|
|
37
56
|
# @param path [String] Path to dfont file
|
|
@@ -65,6 +84,15 @@ module Fontisan
|
|
|
65
84
|
File.exist?(@path) && @num_fonts.positive?
|
|
66
85
|
end
|
|
67
86
|
|
|
87
|
+
# Get the collection version as a string
|
|
88
|
+
#
|
|
89
|
+
# dfont files don't have version numbers like TTC/OTC
|
|
90
|
+
#
|
|
91
|
+
# @return [String] Version string (always "N/A" for dfont)
|
|
92
|
+
def version_string
|
|
93
|
+
"N/A"
|
|
94
|
+
end
|
|
95
|
+
|
|
68
96
|
# Extract all fonts from dfont
|
|
69
97
|
#
|
|
70
98
|
# @param io [IO] Open file handle
|
|
@@ -181,5 +209,61 @@ module Fontisan
|
|
|
181
209
|
|
|
182
210
|
font
|
|
183
211
|
end
|
|
212
|
+
|
|
213
|
+
# Get comprehensive collection metadata
|
|
214
|
+
#
|
|
215
|
+
# Returns a CollectionInfo model with header information and
|
|
216
|
+
# table sharing statistics for the dfont collection.
|
|
217
|
+
# This is the API method used by the `info` command for collections.
|
|
218
|
+
#
|
|
219
|
+
# @param io [IO] Open file handle to read fonts from
|
|
220
|
+
# @param path [String] Collection file path (for file size)
|
|
221
|
+
# @return [Models::CollectionInfo] Collection metadata
|
|
222
|
+
#
|
|
223
|
+
# @example Get collection info
|
|
224
|
+
# File.open("family.dfont", "rb") do |io|
|
|
225
|
+
# collection = DfontCollection.from_file("family.dfont")
|
|
226
|
+
# info = collection.collection_info(io, "family.dfont")
|
|
227
|
+
# puts "Format: #{info.collection_format}"
|
|
228
|
+
# end
|
|
229
|
+
def collection_info(io, path)
|
|
230
|
+
require_relative "models/collection_info"
|
|
231
|
+
require_relative "models/table_sharing_info"
|
|
232
|
+
|
|
233
|
+
# Calculate table sharing statistics
|
|
234
|
+
table_sharing = calculate_table_sharing(io)
|
|
235
|
+
|
|
236
|
+
# Get file size
|
|
237
|
+
file_size = path ? File.size(path) : 0
|
|
238
|
+
|
|
239
|
+
Models::CollectionInfo.new(
|
|
240
|
+
collection_path: path,
|
|
241
|
+
collection_format: self.class.collection_format,
|
|
242
|
+
ttc_tag: "dfnt", # dfont doesn't use ttcf tag
|
|
243
|
+
major_version: 0, # dfont doesn't have version
|
|
244
|
+
minor_version: 0,
|
|
245
|
+
num_fonts: @num_fonts,
|
|
246
|
+
font_offsets: font_offsets,
|
|
247
|
+
file_size_bytes: file_size,
|
|
248
|
+
table_sharing: table_sharing,
|
|
249
|
+
)
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
private
|
|
253
|
+
|
|
254
|
+
# Calculate table sharing statistics
|
|
255
|
+
#
|
|
256
|
+
# Analyzes which tables are shared between fonts and calculates
|
|
257
|
+
# space savings from deduplication.
|
|
258
|
+
#
|
|
259
|
+
# @param io [IO] Open file handle
|
|
260
|
+
# @return [Models::TableSharingInfo] Sharing statistics
|
|
261
|
+
def calculate_table_sharing(io)
|
|
262
|
+
# Extract all fonts
|
|
263
|
+
fonts = extract_fonts(io)
|
|
264
|
+
|
|
265
|
+
# Use shared logic for calculation
|
|
266
|
+
calculate_table_sharing_for_fonts(fonts)
|
|
267
|
+
end
|
|
184
268
|
end
|
|
185
269
|
end
|
data/lib/fontisan/font_loader.rb
CHANGED
|
@@ -72,7 +72,8 @@ module Fontisan
|
|
|
72
72
|
when "wOF2"
|
|
73
73
|
Woff2Font.from_file(path, mode: resolved_mode, lazy: resolved_lazy)
|
|
74
74
|
when Constants::DFONT_RESOURCE_HEADER
|
|
75
|
-
extract_and_load_dfont(io, path, font_index, resolved_mode,
|
|
75
|
+
extract_and_load_dfont(io, path, font_index, resolved_mode,
|
|
76
|
+
resolved_lazy)
|
|
76
77
|
else
|
|
77
78
|
raise InvalidFontError,
|
|
78
79
|
"Unknown font format. Expected TTF, OTF, TTC, OTC, WOFF, or WOFF2 file."
|
|
@@ -99,11 +100,10 @@ module Fontisan
|
|
|
99
100
|
# Check for TTC/OTC signature
|
|
100
101
|
return true if signature == Constants::TTC_TAG
|
|
101
102
|
|
|
102
|
-
# Check for
|
|
103
|
+
# Check for dfont - dfont is a collection format even if it contains only one font
|
|
103
104
|
if signature == Constants::DFONT_RESOURCE_HEADER
|
|
104
105
|
require_relative "parsers/dfont_parser"
|
|
105
|
-
|
|
106
|
-
return Parsers::DfontParser.dfont?(io) && Parsers::DfontParser.sfnt_count(io) > 1
|
|
106
|
+
return Parsers::DfontParser.dfont?(io)
|
|
107
107
|
end
|
|
108
108
|
|
|
109
109
|
false
|
|
@@ -186,7 +186,7 @@ module Fontisan
|
|
|
186
186
|
num_fonts = io.read(4).unpack1("N")
|
|
187
187
|
|
|
188
188
|
# Read all font offsets
|
|
189
|
-
font_offsets = num_fonts
|
|
189
|
+
font_offsets = Array.new(num_fonts) { io.read(4).unpack1("N") }
|
|
190
190
|
|
|
191
191
|
# Scan all fonts to determine collection type (not just first)
|
|
192
192
|
truetype_count = 0
|
|
@@ -213,7 +213,7 @@ module Fontisan
|
|
|
213
213
|
# Determine collection type based on what fonts are inside
|
|
214
214
|
# If ANY font is OpenType, use OpenTypeCollection (more general format)
|
|
215
215
|
# Only use TrueTypeCollection if ALL fonts are TrueType
|
|
216
|
-
if opentype_count
|
|
216
|
+
if opentype_count.positive?
|
|
217
217
|
OpenTypeCollection.from_file(path)
|
|
218
218
|
else
|
|
219
219
|
# All fonts are TrueType
|
|
@@ -283,7 +283,7 @@ mode: LoadingModes::FULL, lazy: true)
|
|
|
283
283
|
end
|
|
284
284
|
|
|
285
285
|
# Read all font offsets
|
|
286
|
-
font_offsets = num_fonts
|
|
286
|
+
font_offsets = Array.new(num_fonts) { io.read(4).unpack1("N") }
|
|
287
287
|
|
|
288
288
|
# Scan all fonts to determine collection type (not just first)
|
|
289
289
|
truetype_count = 0
|
|
@@ -309,7 +309,7 @@ mode: LoadingModes::FULL, lazy: true)
|
|
|
309
309
|
|
|
310
310
|
# If ANY font is OpenType, use OpenTypeCollection (more general format)
|
|
311
311
|
# Only use TrueTypeCollection if ALL fonts are TrueType
|
|
312
|
-
if opentype_count
|
|
312
|
+
if opentype_count.positive?
|
|
313
313
|
# OpenType Collection
|
|
314
314
|
otc = OpenTypeCollection.from_file(path)
|
|
315
315
|
File.open(path, "rb") { |f| otc.font(font_index, f, mode: mode) }
|
|
@@ -329,7 +329,7 @@ mode: LoadingModes::FULL, lazy: true)
|
|
|
329
329
|
# @param lazy [Boolean] Lazy loading flag
|
|
330
330
|
# @return [TrueTypeFont, OpenTypeFont] Loaded font
|
|
331
331
|
# @api private
|
|
332
|
-
def self.extract_and_load_dfont(io,
|
|
332
|
+
def self.extract_and_load_dfont(io, _path, font_index, mode, lazy)
|
|
333
333
|
require_relative "parsers/dfont_parser"
|
|
334
334
|
|
|
335
335
|
# Extract SFNT data from resource fork
|
|
@@ -410,15 +410,16 @@ module Fontisan
|
|
|
410
410
|
|
|
411
411
|
info.fonts.each_with_index do |font_info, index|
|
|
412
412
|
# Show font index with offset
|
|
413
|
-
if font_info.collection_offset
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
413
|
+
lines << if font_info.collection_offset
|
|
414
|
+
"Font #{index} (offset: #{font_info.collection_offset}):"
|
|
415
|
+
else
|
|
416
|
+
"Font #{index}:"
|
|
417
|
+
end
|
|
418
418
|
lines << ""
|
|
419
419
|
|
|
420
420
|
# Format each font using same structure as brief mode
|
|
421
|
-
font_type_display = format_font_type_display(font_info.font_format,
|
|
421
|
+
font_type_display = format_font_type_display(font_info.font_format,
|
|
422
|
+
font_info.is_variable)
|
|
422
423
|
add_line(lines, "Font type", font_type_display)
|
|
423
424
|
add_line(lines, "Family", font_info.family_name)
|
|
424
425
|
add_line(lines, "Subfamily", font_info.subfamily_name)
|
|
@@ -426,7 +427,8 @@ module Fontisan
|
|
|
426
427
|
add_line(lines, "PostScript name", font_info.postscript_name)
|
|
427
428
|
add_line(lines, "Version", font_info.version)
|
|
428
429
|
add_line(lines, "Vendor ID", font_info.vendor_id)
|
|
429
|
-
add_line(lines, "Font revision",
|
|
430
|
+
add_line(lines, "Font revision",
|
|
431
|
+
format_float(font_info.font_revision))
|
|
430
432
|
add_line(lines, "Units per em", font_info.units_per_em)
|
|
431
433
|
|
|
432
434
|
# Blank line between fonts (except after last)
|
|
@@ -467,15 +469,16 @@ module Fontisan
|
|
|
467
469
|
# Each font's brief info
|
|
468
470
|
info.fonts.each_with_index do |font_info, index|
|
|
469
471
|
# Show font index with offset
|
|
470
|
-
if font_info.collection_offset
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
472
|
+
lines << if font_info.collection_offset
|
|
473
|
+
"Font #{index} (offset: #{font_info.collection_offset}):"
|
|
474
|
+
else
|
|
475
|
+
"Font #{index}:"
|
|
476
|
+
end
|
|
475
477
|
lines << ""
|
|
476
478
|
|
|
477
479
|
# Format each font using same structure as individual fonts
|
|
478
|
-
font_type_display = format_font_type_display(font_info.font_format,
|
|
480
|
+
font_type_display = format_font_type_display(font_info.font_format,
|
|
481
|
+
font_info.is_variable)
|
|
479
482
|
add_line(lines, "Font type", font_type_display)
|
|
480
483
|
add_line(lines, "Family", font_info.family_name)
|
|
481
484
|
add_line(lines, "Subfamily", font_info.subfamily_name)
|
|
@@ -483,7 +486,8 @@ module Fontisan
|
|
|
483
486
|
add_line(lines, "PostScript name", font_info.postscript_name)
|
|
484
487
|
add_line(lines, "Version", font_info.version)
|
|
485
488
|
add_line(lines, "Vendor ID", font_info.vendor_id)
|
|
486
|
-
add_line(lines, "Font revision",
|
|
489
|
+
add_line(lines, "Font revision",
|
|
490
|
+
format_float(font_info.font_revision))
|
|
487
491
|
add_line(lines, "Units per em", font_info.units_per_em)
|
|
488
492
|
|
|
489
493
|
# Blank line between fonts (except after last)
|
|
@@ -244,7 +244,7 @@ module Fontisan
|
|
|
244
244
|
# CVT values typically contain standard widths at the beginning
|
|
245
245
|
if cvt && !cvt.empty?
|
|
246
246
|
# First CVT value often represents standard horizontal stem
|
|
247
|
-
hints[:std_hw] = cvt[0].abs if cvt.length
|
|
247
|
+
hints[:std_hw] = cvt[0].abs if cvt.length.positive?
|
|
248
248
|
# Second CVT value often represents standard vertical stem
|
|
249
249
|
hints[:std_vw] = cvt[1].abs if cvt.length > 1
|
|
250
250
|
end
|