fontisan 0.2.5 → 0.2.7
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 +41 -23
- data/README.adoc +230 -860
- data/lib/fontisan/base_collection.rb +5 -33
- data/lib/fontisan/cli.rb +27 -7
- data/lib/fontisan/collection/dfont_builder.rb +315 -0
- data/lib/fontisan/collection/shared_logic.rb +54 -0
- data/lib/fontisan/commands/convert_command.rb +118 -7
- data/lib/fontisan/commands/pack_command.rb +129 -22
- 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/dfont_collection.rb +269 -0
- data/lib/fontisan/font_loader.rb +90 -6
- data/lib/fontisan/parsers/dfont_parser.rb +192 -0
- data/lib/fontisan/true_type_font.rb +8 -46
- data/lib/fontisan/validation/collection_validator.rb +265 -0
- data/lib/fontisan/version.rb +1 -1
- metadata +8 -2
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "bindata"
|
|
4
4
|
require_relative "constants"
|
|
5
|
+
require_relative "collection/shared_logic"
|
|
5
6
|
|
|
6
7
|
module Fontisan
|
|
7
8
|
# Abstract base class for font collections (TTC/OTC)
|
|
@@ -29,6 +30,8 @@ module Fontisan
|
|
|
29
30
|
# end
|
|
30
31
|
# end
|
|
31
32
|
class BaseCollection < BinData::Record
|
|
33
|
+
include Collection::SharedLogic
|
|
34
|
+
|
|
32
35
|
endian :big
|
|
33
36
|
|
|
34
37
|
string :tag, length: 4, assert: "ttcf"
|
|
@@ -251,8 +254,6 @@ module Fontisan
|
|
|
251
254
|
# @param io [IO] Open file handle
|
|
252
255
|
# @return [TableSharingInfo] Sharing statistics
|
|
253
256
|
def calculate_table_sharing(io)
|
|
254
|
-
require_relative "models/table_sharing_info"
|
|
255
|
-
|
|
256
257
|
font_class = self.class.font_class
|
|
257
258
|
|
|
258
259
|
# Extract all fonts
|
|
@@ -260,37 +261,8 @@ module Fontisan
|
|
|
260
261
|
font_class.from_collection(io, offset)
|
|
261
262
|
end
|
|
262
263
|
|
|
263
|
-
#
|
|
264
|
-
|
|
265
|
-
total_table_size = 0
|
|
266
|
-
|
|
267
|
-
fonts.each do |font|
|
|
268
|
-
font.tables.each do |entry|
|
|
269
|
-
key = entry.checksum
|
|
270
|
-
size = entry.table_length
|
|
271
|
-
table_map[key] ||= size
|
|
272
|
-
total_table_size += size
|
|
273
|
-
end
|
|
274
|
-
end
|
|
275
|
-
|
|
276
|
-
# Count unique vs shared
|
|
277
|
-
unique_tables = table_map.size
|
|
278
|
-
total_tables = fonts.sum { |f| f.tables.length }
|
|
279
|
-
shared_tables = total_tables - unique_tables
|
|
280
|
-
|
|
281
|
-
# Calculate space saved
|
|
282
|
-
unique_size = table_map.values.sum
|
|
283
|
-
space_saved = total_table_size - unique_size
|
|
284
|
-
|
|
285
|
-
# Calculate sharing percentage
|
|
286
|
-
sharing_pct = total_tables.positive? ? (shared_tables.to_f / total_tables * 100).round(2) : 0.0
|
|
287
|
-
|
|
288
|
-
Models::TableSharingInfo.new(
|
|
289
|
-
shared_tables: shared_tables,
|
|
290
|
-
unique_tables: unique_tables,
|
|
291
|
-
sharing_percentage: sharing_pct,
|
|
292
|
-
space_saved_bytes: space_saved,
|
|
293
|
-
)
|
|
264
|
+
# Use shared logic for calculation
|
|
265
|
+
calculate_table_sharing_for_fonts(fonts)
|
|
294
266
|
end
|
|
295
267
|
end
|
|
296
268
|
end
|
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
|
#
|
|
@@ -493,7 +510,7 @@ module Fontisan
|
|
|
493
510
|
desc: "Output collection file path",
|
|
494
511
|
aliases: "-o"
|
|
495
512
|
option :format, type: :string, default: "ttc",
|
|
496
|
-
desc: "Collection format (ttc, otc)",
|
|
513
|
+
desc: "Collection format (ttc, otc, dfont)",
|
|
497
514
|
aliases: "-f"
|
|
498
515
|
option :optimize, type: :boolean, default: true,
|
|
499
516
|
desc: "Enable table sharing optimization",
|
|
@@ -501,10 +518,10 @@ module Fontisan
|
|
|
501
518
|
option :analyze, type: :boolean, default: false,
|
|
502
519
|
desc: "Show analysis report before building",
|
|
503
520
|
aliases: "--analyze"
|
|
504
|
-
# Create a TTC (TrueType Collection)
|
|
521
|
+
# Create a TTC (TrueType Collection), OTC (OpenType Collection), or dfont (Apple Data Fork Font) from multiple font files.
|
|
505
522
|
#
|
|
506
523
|
# This command combines multiple fonts into a single collection file with
|
|
507
|
-
# shared table deduplication to save space. It supports
|
|
524
|
+
# shared table deduplication to save space. It supports TTC, OTC, and dfont formats.
|
|
508
525
|
#
|
|
509
526
|
# @param font_files [Array<String>] Paths to input font files (minimum 2 required)
|
|
510
527
|
#
|
|
@@ -514,6 +531,9 @@ module Fontisan
|
|
|
514
531
|
# @example Pack into OTC with analysis
|
|
515
532
|
# fontisan pack Regular.otf Bold.otf Italic.otf --output family.otc --analyze
|
|
516
533
|
#
|
|
534
|
+
# @example Pack into dfont (Apple suitcase)
|
|
535
|
+
# fontisan pack font1.ttf font2.otf --output family.dfont --format dfont
|
|
536
|
+
#
|
|
517
537
|
# @example Pack without optimization
|
|
518
538
|
# fontisan pack font1.ttf font2.ttf --output collection.ttc --no-optimize
|
|
519
539
|
def pack(*font_files)
|
|
@@ -522,13 +542,13 @@ module Fontisan
|
|
|
522
542
|
|
|
523
543
|
unless options[:quiet]
|
|
524
544
|
puts "Collection created successfully:"
|
|
525
|
-
puts " Output: #{result[:output]}"
|
|
545
|
+
puts " Output: #{result[:output_path] || result[:output]}"
|
|
526
546
|
puts " Format: #{result[:format].upcase}"
|
|
527
547
|
puts " Fonts: #{result[:num_fonts]}"
|
|
528
|
-
puts " Size: #{format_size(result[:output_size])}"
|
|
529
|
-
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?
|
|
530
550
|
puts " Space saved: #{format_size(result[:space_savings])}"
|
|
531
|
-
puts " Sharing: #{result[:sharing_percentage]}%"
|
|
551
|
+
puts " Sharing: #{result[:statistics][:sharing_percentage]}%"
|
|
532
552
|
end
|
|
533
553
|
end
|
|
534
554
|
rescue Errno::ENOENT, Error => e
|
|
@@ -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
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fontisan
|
|
4
|
+
module Collection
|
|
5
|
+
# Shared logic for font collection classes
|
|
6
|
+
#
|
|
7
|
+
# This module provides common functionality for all collection types
|
|
8
|
+
# (TTC, OTC, dfont) to maintain DRY principles.
|
|
9
|
+
module SharedLogic
|
|
10
|
+
# Calculate table sharing statistics
|
|
11
|
+
#
|
|
12
|
+
# Analyzes which tables are shared between fonts and calculates
|
|
13
|
+
# space savings from deduplication.
|
|
14
|
+
#
|
|
15
|
+
# @param fonts [Array<TrueTypeFont, OpenTypeFont>] Array of fonts
|
|
16
|
+
# @return [Models::TableSharingInfo] Sharing statistics
|
|
17
|
+
def calculate_table_sharing_for_fonts(fonts)
|
|
18
|
+
require_relative "../models/table_sharing_info"
|
|
19
|
+
|
|
20
|
+
# Build table hash map (checksum -> size)
|
|
21
|
+
table_map = {}
|
|
22
|
+
total_table_size = 0
|
|
23
|
+
|
|
24
|
+
fonts.each do |font|
|
|
25
|
+
font.tables.each do |entry|
|
|
26
|
+
key = entry.checksum
|
|
27
|
+
size = entry.table_length
|
|
28
|
+
table_map[key] ||= size
|
|
29
|
+
total_table_size += size
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Count unique vs shared
|
|
34
|
+
unique_tables = table_map.size
|
|
35
|
+
total_tables = fonts.sum { |f| f.tables.length }
|
|
36
|
+
shared_tables = total_tables - unique_tables
|
|
37
|
+
|
|
38
|
+
# Calculate space saved
|
|
39
|
+
unique_size = table_map.values.sum
|
|
40
|
+
space_saved = total_table_size - unique_size
|
|
41
|
+
|
|
42
|
+
# Calculate sharing percentage
|
|
43
|
+
sharing_pct = total_tables.positive? ? (shared_tables.to_f / total_tables * 100).round(2) : 0.0
|
|
44
|
+
|
|
45
|
+
Models::TableSharingInfo.new(
|
|
46
|
+
shared_tables: shared_tables,
|
|
47
|
+
unique_tables: unique_tables,
|
|
48
|
+
sharing_percentage: sharing_pct,
|
|
49
|
+
space_saved_bytes: space_saved,
|
|
50
|
+
)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
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
|
|