fontisan 0.2.4 → 0.2.6
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 +168 -32
- data/README.adoc +673 -1091
- data/lib/fontisan/cli.rb +94 -13
- data/lib/fontisan/collection/dfont_builder.rb +315 -0
- data/lib/fontisan/commands/convert_command.rb +118 -7
- data/lib/fontisan/commands/pack_command.rb +129 -22
- data/lib/fontisan/commands/validate_command.rb +107 -151
- data/lib/fontisan/config/conversion_matrix.yml +175 -1
- data/lib/fontisan/constants.rb +8 -0
- data/lib/fontisan/converters/collection_converter.rb +438 -0
- data/lib/fontisan/converters/woff2_encoder.rb +7 -29
- data/lib/fontisan/dfont_collection.rb +185 -0
- data/lib/fontisan/font_loader.rb +91 -6
- data/lib/fontisan/models/validation_report.rb +227 -0
- data/lib/fontisan/parsers/dfont_parser.rb +192 -0
- data/lib/fontisan/pipeline/transformation_pipeline.rb +4 -8
- data/lib/fontisan/tables/cmap.rb +82 -2
- data/lib/fontisan/tables/glyf.rb +118 -0
- data/lib/fontisan/tables/head.rb +60 -0
- data/lib/fontisan/tables/hhea.rb +74 -0
- data/lib/fontisan/tables/maxp.rb +60 -0
- data/lib/fontisan/tables/name.rb +76 -0
- data/lib/fontisan/tables/os2.rb +113 -0
- data/lib/fontisan/tables/post.rb +57 -0
- data/lib/fontisan/true_type_font.rb +8 -46
- data/lib/fontisan/validation/collection_validator.rb +265 -0
- data/lib/fontisan/validators/basic_validator.rb +85 -0
- data/lib/fontisan/validators/font_book_validator.rb +130 -0
- data/lib/fontisan/validators/opentype_validator.rb +112 -0
- data/lib/fontisan/validators/profile_loader.rb +139 -0
- data/lib/fontisan/validators/validator.rb +484 -0
- data/lib/fontisan/validators/web_font_validator.rb +102 -0
- data/lib/fontisan/version.rb +1 -1
- data/lib/fontisan.rb +78 -6
- metadata +13 -12
- data/lib/fontisan/config/validation_rules.yml +0 -149
- data/lib/fontisan/validation/checksum_validator.rb +0 -170
- data/lib/fontisan/validation/consistency_validator.rb +0 -197
- data/lib/fontisan/validation/structure_validator.rb +0 -198
- data/lib/fontisan/validation/table_validator.rb +0 -158
- data/lib/fontisan/validation/validator.rb +0 -152
- data/lib/fontisan/validation/variable_font_validator.rb +0 -218
- data/lib/fontisan/validation/woff2_header_validator.rb +0 -278
- data/lib/fontisan/validation/woff2_table_validator.rb +0 -270
- data/lib/fontisan/validation/woff2_validator.rb +0 -248
data/lib/fontisan/cli.rb
CHANGED
|
@@ -220,6 +220,8 @@ module Fontisan
|
|
|
220
220
|
desc: "Skip output validation"
|
|
221
221
|
option :preserve_hints, type: :boolean, default: false,
|
|
222
222
|
desc: "Preserve rendering hints during conversion (TTF→OTF preservations may be limited)"
|
|
223
|
+
option :target_format, type: :string, default: "preserve",
|
|
224
|
+
desc: "Target outline format for collections: preserve (default), ttf, or otf"
|
|
223
225
|
option :wght, type: :numeric,
|
|
224
226
|
desc: "Weight axis value (alternative to --coordinates)"
|
|
225
227
|
option :wdth, type: :numeric,
|
|
@@ -236,6 +238,12 @@ module Fontisan
|
|
|
236
238
|
# - TTF ↔ OTF: Outline format conversion
|
|
237
239
|
# - WOFF/WOFF2: Web font packaging
|
|
238
240
|
# - Variable fonts: Automatic variation preservation or instance generation
|
|
241
|
+
# - Collections (TTC/OTC/dfont): Preserve mixed TTF+OTF by default, or standardize with --target-format
|
|
242
|
+
#
|
|
243
|
+
# Collection Format Support:
|
|
244
|
+
# TTC, OTC, and dfont all support mixed TrueType and OpenType fonts. By default, original font formats
|
|
245
|
+
# are preserved during collection conversion (--target-format preserve). Use --target-format ttf to
|
|
246
|
+
# convert all fonts to TrueType, or --target-format otf to convert all to OpenType/CFF.
|
|
239
247
|
#
|
|
240
248
|
# Variable Font Operations:
|
|
241
249
|
# The pipeline automatically detects whether variation data can be preserved based on
|
|
@@ -253,6 +261,15 @@ module Fontisan
|
|
|
253
261
|
# @example Convert TTF to OTF
|
|
254
262
|
# fontisan convert font.ttf --to otf --output font.otf
|
|
255
263
|
#
|
|
264
|
+
# @example Convert TTC to OTC (preserves mixed formats by default)
|
|
265
|
+
# fontisan convert family.ttc --to otc --output family.otc
|
|
266
|
+
#
|
|
267
|
+
# @example Convert TTC to OTC with all fonts as TrueType
|
|
268
|
+
# fontisan convert family.ttc --to otc --output family.otc --target-format ttf
|
|
269
|
+
#
|
|
270
|
+
# @example Convert TTC to OTC with all fonts as OpenType/CFF
|
|
271
|
+
# fontisan convert family.ttc --to otc --output family.otc --target-format otf
|
|
272
|
+
#
|
|
256
273
|
# @example Generate bold instance at specific coordinates
|
|
257
274
|
# fontisan convert variable.ttf --to ttf --output bold.ttf --coordinates "wght=700,wdth=100"
|
|
258
275
|
#
|
|
@@ -350,13 +367,62 @@ module Fontisan
|
|
|
350
367
|
handle_error(e)
|
|
351
368
|
end
|
|
352
369
|
|
|
353
|
-
desc "validate FONT_FILE", "Validate font file
|
|
354
|
-
|
|
355
|
-
|
|
370
|
+
desc "validate FONT_FILE", "Validate font file"
|
|
371
|
+
long_desc <<-DESC
|
|
372
|
+
Validate font file against quality checks and standards.
|
|
373
|
+
|
|
374
|
+
Test lists (-t/--test-list):
|
|
375
|
+
indexability - Fast indexing validation
|
|
376
|
+
usability - Installation compatibility
|
|
377
|
+
production - Comprehensive quality (default)
|
|
378
|
+
web - Web font readiness
|
|
379
|
+
spec_compliance - OpenType spec compliance
|
|
380
|
+
default - Production profile (alias)
|
|
381
|
+
|
|
382
|
+
Return values (with -R/--return-value-results):
|
|
383
|
+
0 No results
|
|
384
|
+
1 Execution errors
|
|
385
|
+
2 Fatal errors found
|
|
386
|
+
3 Major errors found
|
|
387
|
+
4 Minor errors found
|
|
388
|
+
5 Spec violations found
|
|
389
|
+
DESC
|
|
390
|
+
|
|
391
|
+
option :exclude, aliases: "-e", type: :array, desc: "Tests to exclude"
|
|
392
|
+
option :list, aliases: "-l", type: :boolean, desc: "List available tests"
|
|
393
|
+
option :output, aliases: "-o", type: :string, desc: "Output file"
|
|
394
|
+
option :full_report, aliases: "-r", type: :boolean, desc: "Full report"
|
|
395
|
+
option :return_value_results, aliases: "-R", type: :boolean, desc: "Use return value for results"
|
|
396
|
+
option :summary_report, aliases: "-S", type: :boolean, desc: "Summary report"
|
|
397
|
+
option :test_list, aliases: "-t", type: :string, default: "default", desc: "Tests to execute"
|
|
398
|
+
option :table_report, aliases: "-T", type: :boolean, desc: "Tabular report"
|
|
399
|
+
option :verbose, aliases: "-v", type: :boolean, desc: "Verbose output"
|
|
400
|
+
option :suppress_warnings, aliases: "-W", type: :boolean, desc: "Suppress warnings"
|
|
401
|
+
|
|
356
402
|
def validate(font_file)
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
403
|
+
if options[:list]
|
|
404
|
+
list_available_tests
|
|
405
|
+
return
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
cmd = Commands::ValidateCommand.new(
|
|
409
|
+
input: font_file,
|
|
410
|
+
profile: options[:test_list],
|
|
411
|
+
exclude: options[:exclude] || [],
|
|
412
|
+
output: options[:output],
|
|
413
|
+
format: options[:format].to_sym,
|
|
414
|
+
full_report: options[:full_report],
|
|
415
|
+
summary_report: options[:summary_report],
|
|
416
|
+
table_report: options[:table_report],
|
|
417
|
+
verbose: options[:verbose],
|
|
418
|
+
suppress_warnings: options[:suppress_warnings],
|
|
419
|
+
return_value_results: options[:return_value_results]
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
exit cmd.run
|
|
423
|
+
rescue => e
|
|
424
|
+
error "Validation failed: #{e.message}"
|
|
425
|
+
exit 1
|
|
360
426
|
end
|
|
361
427
|
|
|
362
428
|
desc "export FONT_FILE", "Export font to TTX/YAML/JSON format"
|
|
@@ -444,7 +510,7 @@ module Fontisan
|
|
|
444
510
|
desc: "Output collection file path",
|
|
445
511
|
aliases: "-o"
|
|
446
512
|
option :format, type: :string, default: "ttc",
|
|
447
|
-
desc: "Collection format (ttc, otc)",
|
|
513
|
+
desc: "Collection format (ttc, otc, dfont)",
|
|
448
514
|
aliases: "-f"
|
|
449
515
|
option :optimize, type: :boolean, default: true,
|
|
450
516
|
desc: "Enable table sharing optimization",
|
|
@@ -452,10 +518,10 @@ module Fontisan
|
|
|
452
518
|
option :analyze, type: :boolean, default: false,
|
|
453
519
|
desc: "Show analysis report before building",
|
|
454
520
|
aliases: "--analyze"
|
|
455
|
-
# Create a TTC (TrueType Collection)
|
|
521
|
+
# Create a TTC (TrueType Collection), OTC (OpenType Collection), or dfont (Apple Data Fork Font) from multiple font files.
|
|
456
522
|
#
|
|
457
523
|
# This command combines multiple fonts into a single collection file with
|
|
458
|
-
# shared table deduplication to save space. It supports
|
|
524
|
+
# shared table deduplication to save space. It supports TTC, OTC, and dfont formats.
|
|
459
525
|
#
|
|
460
526
|
# @param font_files [Array<String>] Paths to input font files (minimum 2 required)
|
|
461
527
|
#
|
|
@@ -465,6 +531,9 @@ module Fontisan
|
|
|
465
531
|
# @example Pack into OTC with analysis
|
|
466
532
|
# fontisan pack Regular.otf Bold.otf Italic.otf --output family.otc --analyze
|
|
467
533
|
#
|
|
534
|
+
# @example Pack into dfont (Apple suitcase)
|
|
535
|
+
# fontisan pack font1.ttf font2.otf --output family.dfont --format dfont
|
|
536
|
+
#
|
|
468
537
|
# @example Pack without optimization
|
|
469
538
|
# fontisan pack font1.ttf font2.ttf --output collection.ttc --no-optimize
|
|
470
539
|
def pack(*font_files)
|
|
@@ -473,13 +542,13 @@ module Fontisan
|
|
|
473
542
|
|
|
474
543
|
unless options[:quiet]
|
|
475
544
|
puts "Collection created successfully:"
|
|
476
|
-
puts " Output: #{result[:output]}"
|
|
545
|
+
puts " Output: #{result[:output_path] || result[:output]}"
|
|
477
546
|
puts " Format: #{result[:format].upcase}"
|
|
478
547
|
puts " Fonts: #{result[:num_fonts]}"
|
|
479
|
-
puts " Size: #{format_size(result[:output_size])}"
|
|
480
|
-
if result[:space_savings].positive?
|
|
548
|
+
puts " Size: #{format_size(result[:output_size] || result[:total_size])}"
|
|
549
|
+
if result[:space_savings] && result[:space_savings].positive?
|
|
481
550
|
puts " Space saved: #{format_size(result[:space_savings])}"
|
|
482
|
-
puts " Sharing: #{result[:sharing_percentage]}%"
|
|
551
|
+
puts " Sharing: #{result[:statistics][:sharing_percentage]}%"
|
|
483
552
|
end
|
|
484
553
|
end
|
|
485
554
|
rescue Errno::ENOENT, Error => e
|
|
@@ -555,5 +624,17 @@ module Fontisan
|
|
|
555
624
|
warn message unless options[:quiet]
|
|
556
625
|
exit 1
|
|
557
626
|
end
|
|
627
|
+
|
|
628
|
+
# List available validation tests/profiles
|
|
629
|
+
#
|
|
630
|
+
# @return [void]
|
|
631
|
+
def list_available_tests
|
|
632
|
+
require_relative "validators/profile_loader"
|
|
633
|
+
profiles = Validators::ProfileLoader.all_profiles
|
|
634
|
+
puts "Available validation profiles:"
|
|
635
|
+
profiles.each do |profile_name, config|
|
|
636
|
+
puts " #{profile_name.to_s.ljust(20)} - #{config[:description]}"
|
|
637
|
+
end
|
|
638
|
+
end
|
|
558
639
|
end
|
|
559
640
|
end
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../font_writer"
|
|
4
|
+
require_relative "../error"
|
|
5
|
+
require_relative "../validation/collection_validator"
|
|
6
|
+
|
|
7
|
+
module Fontisan
|
|
8
|
+
module Collection
|
|
9
|
+
# DfontBuilder creates Apple dfont (Data Fork Font) resource fork structures
|
|
10
|
+
#
|
|
11
|
+
# Main responsibility: Build complete dfont binary from multiple fonts
|
|
12
|
+
# by creating a resource fork structure containing 'sfnt' resources.
|
|
13
|
+
#
|
|
14
|
+
# dfont is an Apple-specific format that stores Mac font suitcase resources
|
|
15
|
+
# in the data fork instead of the resource fork. It can contain multiple fonts.
|
|
16
|
+
#
|
|
17
|
+
# @example Build dfont from multiple fonts
|
|
18
|
+
# builder = DfontBuilder.new([font1, font2, font3])
|
|
19
|
+
# binary = builder.build
|
|
20
|
+
#
|
|
21
|
+
# @example Write directly to file
|
|
22
|
+
# builder = DfontBuilder.new([font1, font2])
|
|
23
|
+
# builder.build_to_file("family.dfont")
|
|
24
|
+
class DfontBuilder
|
|
25
|
+
# Source fonts
|
|
26
|
+
# @return [Array<TrueTypeFont, OpenTypeFont>]
|
|
27
|
+
attr_reader :fonts
|
|
28
|
+
|
|
29
|
+
# Build result (populated after build)
|
|
30
|
+
# @return [Hash, nil]
|
|
31
|
+
attr_reader :result
|
|
32
|
+
|
|
33
|
+
# Constants for resource reference packing
|
|
34
|
+
NO_NAME_OFFSET = [-1].freeze
|
|
35
|
+
ZERO_ATTRIBUTES = [0].freeze
|
|
36
|
+
ZERO_RESERVED = [0].freeze
|
|
37
|
+
|
|
38
|
+
# Initialize builder with fonts
|
|
39
|
+
#
|
|
40
|
+
# @param fonts [Array<TrueTypeFont, OpenTypeFont>] Fonts to pack into dfont
|
|
41
|
+
# @param options [Hash] Builder options
|
|
42
|
+
# @raise [ArgumentError] if fonts array is invalid
|
|
43
|
+
def initialize(fonts, options = {})
|
|
44
|
+
if fonts.nil? || fonts.empty?
|
|
45
|
+
raise ArgumentError, "fonts cannot be nil or empty"
|
|
46
|
+
end
|
|
47
|
+
raise ArgumentError, "fonts must be an array" unless fonts.is_a?(Array)
|
|
48
|
+
|
|
49
|
+
unless fonts.all? { |f| f.respond_to?(:table_data) }
|
|
50
|
+
raise ArgumentError, "all fonts must respond to table_data"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
@fonts = fonts
|
|
54
|
+
@options = options
|
|
55
|
+
@result = nil
|
|
56
|
+
|
|
57
|
+
validate_fonts!
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Build dfont and return binary
|
|
61
|
+
#
|
|
62
|
+
# Executes the complete dfont creation process:
|
|
63
|
+
# 1. Serialize each font to SFNT binary
|
|
64
|
+
# 2. Build resource data section
|
|
65
|
+
# 3. Calculate resource map size
|
|
66
|
+
# 4. Build resource map
|
|
67
|
+
# 5. Build resource fork header
|
|
68
|
+
# 6. Assemble complete dfont binary
|
|
69
|
+
#
|
|
70
|
+
# @return [Hash] Build result with:
|
|
71
|
+
# - :binary [String] - Complete dfont binary
|
|
72
|
+
# - :num_fonts [Integer] - Number of fonts packed
|
|
73
|
+
# - :total_size [Integer] - Total size in bytes
|
|
74
|
+
def build
|
|
75
|
+
# Step 1: Serialize all fonts to SFNT binaries
|
|
76
|
+
sfnt_binaries = serialize_fonts
|
|
77
|
+
|
|
78
|
+
# Step 2: Build resource data section
|
|
79
|
+
resource_data = build_resource_data(sfnt_binaries)
|
|
80
|
+
|
|
81
|
+
# Step 3: Calculate expected resource map size
|
|
82
|
+
# Map header: 28 bytes
|
|
83
|
+
# Type list header: 2 bytes
|
|
84
|
+
# Type entry: 8 bytes
|
|
85
|
+
# References: 12 bytes each
|
|
86
|
+
map_size = 28 + 2 + 8 + (sfnt_binaries.size * 12)
|
|
87
|
+
|
|
88
|
+
# Step 4: Build resource map
|
|
89
|
+
resource_map = build_resource_map(sfnt_binaries, resource_data.bytesize, map_size)
|
|
90
|
+
|
|
91
|
+
# Step 5: Build header
|
|
92
|
+
header = build_header(resource_data.bytesize, resource_map.bytesize)
|
|
93
|
+
|
|
94
|
+
# Step 6: Assemble complete dfont binary
|
|
95
|
+
binary = header + resource_data + resource_map
|
|
96
|
+
|
|
97
|
+
# Store result
|
|
98
|
+
@result = {
|
|
99
|
+
binary: binary,
|
|
100
|
+
num_fonts: @fonts.size,
|
|
101
|
+
total_size: binary.bytesize,
|
|
102
|
+
format: :dfont,
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
@result
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Build dfont and write to file
|
|
109
|
+
#
|
|
110
|
+
# @param path [String] Output file path
|
|
111
|
+
# @return [Hash] Build result (same as build method)
|
|
112
|
+
def build_to_file(path)
|
|
113
|
+
result = build
|
|
114
|
+
File.binwrite(path, result[:binary])
|
|
115
|
+
result[:output_path] = path
|
|
116
|
+
result
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
private
|
|
120
|
+
|
|
121
|
+
# Validate fonts can be packed into dfont
|
|
122
|
+
#
|
|
123
|
+
# @return [void]
|
|
124
|
+
# @raise [Error] if validation fails
|
|
125
|
+
def validate_fonts!
|
|
126
|
+
validator = Validation::CollectionValidator.new
|
|
127
|
+
validator.validate!(@fonts, :dfont)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Check if font is a web font
|
|
131
|
+
#
|
|
132
|
+
# @param font [Object] Font object
|
|
133
|
+
# @return [Boolean] true if WOFF or WOFF2
|
|
134
|
+
def web_font?(font)
|
|
135
|
+
font.class.name.include?("Woff")
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Serialize all fonts to SFNT binaries
|
|
139
|
+
#
|
|
140
|
+
# @return [Array<String>] Array of SFNT binaries
|
|
141
|
+
def serialize_fonts
|
|
142
|
+
@fonts.map do |font|
|
|
143
|
+
# Get all table data from font
|
|
144
|
+
tables_hash = font.table_data
|
|
145
|
+
|
|
146
|
+
# Determine sfnt version from font
|
|
147
|
+
sfnt_version = detect_sfnt_version(font)
|
|
148
|
+
|
|
149
|
+
# Write font to binary using FontWriter
|
|
150
|
+
FontWriter.write_font(tables_hash, sfnt_version: sfnt_version)
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Detect sfnt version from font
|
|
155
|
+
#
|
|
156
|
+
# @param font [TrueTypeFont, OpenTypeFont] Font object
|
|
157
|
+
# @return [Integer] sfnt version
|
|
158
|
+
def detect_sfnt_version(font)
|
|
159
|
+
if font.respond_to?(:header) && font.header.respond_to?(:sfnt_version)
|
|
160
|
+
font.header.sfnt_version
|
|
161
|
+
elsif font.respond_to?(:table_data)
|
|
162
|
+
# Auto-detect from tables
|
|
163
|
+
FontWriter.detect_sfnt_version(font.table_data)
|
|
164
|
+
else
|
|
165
|
+
0x00010000 # Default to TrueType
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Build resource fork header (16 bytes)
|
|
170
|
+
#
|
|
171
|
+
# @param data_length [Integer] Length of resource data section
|
|
172
|
+
# @param map_length [Integer] Length of resource map section
|
|
173
|
+
# @return [String] Binary header
|
|
174
|
+
def build_header(data_length, map_length)
|
|
175
|
+
# Resource fork header structure:
|
|
176
|
+
# - resource_data_offset (4 bytes): always 256 (0x100) for dfont
|
|
177
|
+
# - resource_map_offset (4 bytes): header_size + data_length
|
|
178
|
+
# - resource_data_length (4 bytes)
|
|
179
|
+
# - resource_map_length (4 bytes)
|
|
180
|
+
|
|
181
|
+
data_offset = 256 # Standard dfont offset (conceptual, points to useful data)
|
|
182
|
+
header_size = 16 # Actual header size in file
|
|
183
|
+
map_offset = header_size + data_length # Map comes after header + data
|
|
184
|
+
|
|
185
|
+
[
|
|
186
|
+
data_offset,
|
|
187
|
+
map_offset,
|
|
188
|
+
data_length,
|
|
189
|
+
map_length,
|
|
190
|
+
].pack("N4")
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Build resource data section
|
|
194
|
+
#
|
|
195
|
+
# Contains all SFNT resources with 4-byte length prefix for each.
|
|
196
|
+
#
|
|
197
|
+
# @param sfnt_binaries [Array<String>] SFNT binaries
|
|
198
|
+
# @return [String] Resource data section binary
|
|
199
|
+
def build_resource_data(sfnt_binaries)
|
|
200
|
+
data = String.new(encoding: Encoding::BINARY)
|
|
201
|
+
|
|
202
|
+
# Add padding to reach offset 256 (header is 16 bytes, pad 240 bytes)
|
|
203
|
+
data << ("\0" * 240)
|
|
204
|
+
|
|
205
|
+
# Write each SFNT resource with length prefix
|
|
206
|
+
sfnt_binaries.each do |sfnt|
|
|
207
|
+
# 4-byte length prefix (big-endian)
|
|
208
|
+
data << [sfnt.bytesize].pack("N")
|
|
209
|
+
# SFNT data
|
|
210
|
+
data << sfnt
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
data
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# Build resource map section
|
|
217
|
+
#
|
|
218
|
+
# Contains type list and reference list for all 'sfnt' resources.
|
|
219
|
+
#
|
|
220
|
+
# @param sfnt_binaries [Array<String>] SFNT binaries for offset calculation
|
|
221
|
+
# @param data_length [Integer] Actual resource data section length
|
|
222
|
+
# @param map_length [Integer] Actual resource map section length
|
|
223
|
+
# @return [String] Resource map section binary
|
|
224
|
+
def build_resource_map(sfnt_binaries, data_length, map_length)
|
|
225
|
+
map = String.new(encoding: Encoding::BINARY)
|
|
226
|
+
|
|
227
|
+
# Calculate offsets for header copy (must match main header)
|
|
228
|
+
data_offset = 256 # Standard dfont offset (conceptual)
|
|
229
|
+
header_size = 16 # Actual header size in file
|
|
230
|
+
map_offset = header_size + data_length # Map comes after header + data
|
|
231
|
+
|
|
232
|
+
# Resource map header (28 bytes):
|
|
233
|
+
# - Copy of resource header (16 bytes)
|
|
234
|
+
# - Handle to next resource map (4 bytes) - set to 0
|
|
235
|
+
# - File reference number (2 bytes) - set to 0
|
|
236
|
+
# - Resource file attributes (2 bytes) - set to 0
|
|
237
|
+
# - Offset to type list (2 bytes) - set to 28
|
|
238
|
+
# - Offset to name list (2 bytes) - calculated later
|
|
239
|
+
|
|
240
|
+
# Copy of resource header (must match main header exactly)
|
|
241
|
+
map << [data_offset, map_offset, data_length, map_length].pack("N4")
|
|
242
|
+
|
|
243
|
+
# Reserved fields
|
|
244
|
+
map << [0, 0, 0].pack("N n n")
|
|
245
|
+
|
|
246
|
+
# Offset to type list (from start of map)
|
|
247
|
+
map << [28].pack("n")
|
|
248
|
+
|
|
249
|
+
# Offset to name list (we don't use names, so point past all data)
|
|
250
|
+
type_list_size = 2 + 8 + (sfnt_binaries.size * 12) # type count + type entry + references
|
|
251
|
+
name_list_offset = 28 + type_list_size
|
|
252
|
+
map << [name_list_offset].pack("n")
|
|
253
|
+
|
|
254
|
+
# Type list:
|
|
255
|
+
# - Number of types - 1 (2 bytes) - we have 1 type ('sfnt')
|
|
256
|
+
# - Type entries (8 bytes each)
|
|
257
|
+
map << [0].pack("n") # 1 type - 1 = 0
|
|
258
|
+
|
|
259
|
+
# Type entry for 'sfnt':
|
|
260
|
+
# - Resource type (4 bytes): 'sfnt'
|
|
261
|
+
# - Number of resources - 1 (2 bytes)
|
|
262
|
+
# - Offset to reference list (2 bytes): from start of type list
|
|
263
|
+
map << "sfnt"
|
|
264
|
+
map << [sfnt_binaries.size - 1].pack("n")
|
|
265
|
+
reference_list_offset = 2 + 8 # After type count and type entry
|
|
266
|
+
map << [reference_list_offset].pack("n")
|
|
267
|
+
|
|
268
|
+
# Build reference list
|
|
269
|
+
map << build_reference_list(sfnt_binaries)
|
|
270
|
+
|
|
271
|
+
map
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
# Build reference list for 'sfnt' resources
|
|
275
|
+
#
|
|
276
|
+
# @param sfnt_binaries [Array<String>] SFNT binaries
|
|
277
|
+
# @return [String] Reference list binary
|
|
278
|
+
def build_reference_list(sfnt_binaries)
|
|
279
|
+
refs = String.new(encoding: Encoding::BINARY)
|
|
280
|
+
|
|
281
|
+
# Calculate offset for each resource in data section
|
|
282
|
+
# Offsets are relative to the start of the resource data section (not including padding)
|
|
283
|
+
# The resource_data_offset in header points to where resources actually start
|
|
284
|
+
current_offset = 0
|
|
285
|
+
|
|
286
|
+
sfnt_binaries.each_with_index do |sfnt, i|
|
|
287
|
+
# Resource reference (12 bytes):
|
|
288
|
+
# - Resource ID (2 bytes): start from 128
|
|
289
|
+
# - Name offset (2 bytes): -1 (no name)
|
|
290
|
+
# - Attributes (1 byte): 0
|
|
291
|
+
# - Data offset (3 bytes): offset in data section (24-bit big-endian)
|
|
292
|
+
# - Reserved (4 bytes): 0
|
|
293
|
+
|
|
294
|
+
resource_id = 128 + i
|
|
295
|
+
refs << [resource_id].pack("n")
|
|
296
|
+
refs << NO_NAME_OFFSET.pack("n") # No name
|
|
297
|
+
refs << ZERO_ATTRIBUTES.pack("C") # Attributes
|
|
298
|
+
|
|
299
|
+
# Pack 24-bit offset (3 bytes big-endian)
|
|
300
|
+
# Offset is from start of resource data section
|
|
301
|
+
offset_bytes = [current_offset].pack("N")[1..3]
|
|
302
|
+
refs << offset_bytes
|
|
303
|
+
|
|
304
|
+
refs << ZERO_RESERVED.pack("N") # Reserved
|
|
305
|
+
|
|
306
|
+
# Update offset for next resource
|
|
307
|
+
# Each resource has: 4-byte length + SFNT data
|
|
308
|
+
current_offset += 4 + sfnt.bytesize
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
refs
|
|
312
|
+
end
|
|
313
|
+
end
|
|
314
|
+
end
|
|
315
|
+
end
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "base_command"
|
|
4
4
|
require_relative "../pipeline/transformation_pipeline"
|
|
5
|
+
require_relative "../converters/collection_converter"
|
|
6
|
+
require_relative "../font_loader"
|
|
5
7
|
|
|
6
8
|
module Fontisan
|
|
7
9
|
module Commands
|
|
@@ -47,6 +49,7 @@ module Fontisan
|
|
|
47
49
|
# @option options [Integer] :instance_index Named instance index
|
|
48
50
|
# @option options [Boolean] :preserve_variation Preserve variation data (default: auto)
|
|
49
51
|
# @option options [Boolean] :preserve_hints Preserve rendering hints (default: false)
|
|
52
|
+
# @option options [String] :target_format Target outline format for collections: 'preserve' (default), 'ttf', or 'otf'
|
|
50
53
|
# @option options [Boolean] :no_validate Skip output validation
|
|
51
54
|
# @option options [Boolean] :verbose Verbose output
|
|
52
55
|
def initialize(font_path, options = {})
|
|
@@ -70,6 +73,7 @@ module Fontisan
|
|
|
70
73
|
@instance_index = opts[:instance_index]
|
|
71
74
|
@preserve_variation = opts[:preserve_variation]
|
|
72
75
|
@preserve_hints = opts.fetch(:preserve_hints, false)
|
|
76
|
+
@collection_target_format = opts.fetch(:target_format, 'preserve').to_s
|
|
73
77
|
@validate = !opts[:no_validate]
|
|
74
78
|
end
|
|
75
79
|
|
|
@@ -81,6 +85,35 @@ module Fontisan
|
|
|
81
85
|
def run
|
|
82
86
|
validate_options!
|
|
83
87
|
|
|
88
|
+
# Check if input is a collection
|
|
89
|
+
if collection_file?
|
|
90
|
+
convert_collection
|
|
91
|
+
else
|
|
92
|
+
convert_single_font
|
|
93
|
+
end
|
|
94
|
+
rescue ArgumentError
|
|
95
|
+
# Let ArgumentError propagate for validation errors
|
|
96
|
+
raise
|
|
97
|
+
rescue StandardError => e
|
|
98
|
+
raise Error, "Conversion failed: #{e.message}"
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
private
|
|
102
|
+
|
|
103
|
+
# Check if input file is a collection
|
|
104
|
+
#
|
|
105
|
+
# @return [Boolean] true if collection
|
|
106
|
+
def collection_file?
|
|
107
|
+
FontLoader.collection?(font_path)
|
|
108
|
+
rescue StandardError
|
|
109
|
+
# If detection fails, assume single font
|
|
110
|
+
false
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Convert a single font (original implementation)
|
|
114
|
+
#
|
|
115
|
+
# @return [Hash] Result information
|
|
116
|
+
def convert_single_font
|
|
84
117
|
puts "Converting #{File.basename(font_path)} to #{@target_format}..." unless @options[:quiet]
|
|
85
118
|
|
|
86
119
|
# Build pipeline options
|
|
@@ -137,14 +170,86 @@ module Fontisan
|
|
|
137
170
|
output_size: File.size(@output_path),
|
|
138
171
|
variation_strategy: result[:details][:variation_strategy],
|
|
139
172
|
}
|
|
140
|
-
rescue ArgumentError
|
|
141
|
-
# Let ArgumentError propagate for validation errors
|
|
142
|
-
raise
|
|
143
|
-
rescue StandardError => e
|
|
144
|
-
raise Error, "Conversion failed: #{e.message}"
|
|
145
173
|
end
|
|
146
174
|
|
|
147
|
-
|
|
175
|
+
# Convert a collection
|
|
176
|
+
#
|
|
177
|
+
# @return [Hash] Result information
|
|
178
|
+
def convert_collection
|
|
179
|
+
# Determine target collection type from target format
|
|
180
|
+
target_type = collection_type_from_format(@target_format)
|
|
181
|
+
|
|
182
|
+
unless target_type
|
|
183
|
+
raise ArgumentError,
|
|
184
|
+
"Target format #{@target_format} is not a collection format. " \
|
|
185
|
+
"Use ttc, otc, or dfont for collection conversion."
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
puts "Converting collection to #{target_type.to_s.upcase}..." unless @options[:quiet]
|
|
189
|
+
|
|
190
|
+
# Use CollectionConverter
|
|
191
|
+
converter = Converters::CollectionConverter.new
|
|
192
|
+
result = converter.convert(
|
|
193
|
+
font_path,
|
|
194
|
+
target_type: target_type,
|
|
195
|
+
options: {
|
|
196
|
+
output: @output_path,
|
|
197
|
+
target_format: @collection_target_format,
|
|
198
|
+
verbose: @options[:verbose],
|
|
199
|
+
}
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
# Display results
|
|
203
|
+
unless @options[:quiet]
|
|
204
|
+
output_size = File.size(@output_path)
|
|
205
|
+
input_size = File.size(font_path)
|
|
206
|
+
|
|
207
|
+
puts "Conversion complete!"
|
|
208
|
+
puts " Input: #{font_path} (#{format_size(input_size)})"
|
|
209
|
+
puts " Output: #{@output_path} (#{format_size(output_size)})"
|
|
210
|
+
puts " Format: #{result[:source_type].to_s.upcase} → #{result[:target_type].to_s.upcase}"
|
|
211
|
+
puts " Fonts: #{result[:num_fonts]}"
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
{
|
|
215
|
+
success: true,
|
|
216
|
+
input_path: font_path,
|
|
217
|
+
output_path: @output_path,
|
|
218
|
+
source_format: result[:source_type],
|
|
219
|
+
target_format: result[:target_type],
|
|
220
|
+
input_size: File.size(font_path),
|
|
221
|
+
output_size: File.size(@output_path),
|
|
222
|
+
num_fonts: result[:num_fonts],
|
|
223
|
+
}
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# Determine collection type from format
|
|
227
|
+
#
|
|
228
|
+
# @param format [Symbol] Target format
|
|
229
|
+
# @return [Symbol, nil] Collection type (:ttc, :otc, :dfont) or nil
|
|
230
|
+
def collection_type_from_format(format)
|
|
231
|
+
case format
|
|
232
|
+
when :ttc
|
|
233
|
+
:ttc
|
|
234
|
+
when :otc
|
|
235
|
+
:otc
|
|
236
|
+
when :dfont
|
|
237
|
+
:dfont
|
|
238
|
+
else
|
|
239
|
+
# Check output extension
|
|
240
|
+
ext = File.extname(@output_path).downcase
|
|
241
|
+
case ext
|
|
242
|
+
when ".ttc"
|
|
243
|
+
:ttc
|
|
244
|
+
when ".otc"
|
|
245
|
+
:otc
|
|
246
|
+
when ".dfont"
|
|
247
|
+
:dfont
|
|
248
|
+
else
|
|
249
|
+
nil
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
end
|
|
148
253
|
|
|
149
254
|
# Parse coordinates string to hash
|
|
150
255
|
#
|
|
@@ -194,6 +299,12 @@ module Fontisan
|
|
|
194
299
|
:ttf
|
|
195
300
|
when "otf", "opentype", "cff"
|
|
196
301
|
:otf
|
|
302
|
+
when "ttc"
|
|
303
|
+
:ttc
|
|
304
|
+
when "otc"
|
|
305
|
+
:otc
|
|
306
|
+
when "dfont"
|
|
307
|
+
:dfont
|
|
197
308
|
when "svg"
|
|
198
309
|
:svg
|
|
199
310
|
when "woff"
|
|
@@ -203,7 +314,7 @@ module Fontisan
|
|
|
203
314
|
else
|
|
204
315
|
raise ArgumentError,
|
|
205
316
|
"Unknown target format: #{format}. " \
|
|
206
|
-
"Supported: ttf, otf, svg, woff, woff2"
|
|
317
|
+
"Supported: ttf, otf, ttc, otc, dfont, svg, woff, woff2"
|
|
207
318
|
end
|
|
208
319
|
end
|
|
209
320
|
|