fontisan 0.2.1 → 0.2.3
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 +58 -392
- data/README.adoc +1509 -1430
- data/Rakefile +3 -2
- data/benchmark/variation_quick_bench.rb +4 -4
- data/docs/FONT_HINTING.adoc +562 -0
- data/docs/VARIABLE_FONT_OPERATIONS.adoc +599 -0
- data/lib/fontisan/base_collection.rb +296 -0
- data/lib/fontisan/cli.rb +10 -3
- data/lib/fontisan/collection/builder.rb +2 -1
- data/lib/fontisan/collection/offset_calculator.rb +2 -0
- data/lib/fontisan/commands/base_command.rb +5 -2
- data/lib/fontisan/commands/convert_command.rb +6 -2
- data/lib/fontisan/commands/info_command.rb +129 -5
- data/lib/fontisan/commands/instance_command.rb +8 -7
- data/lib/fontisan/commands/validate_command.rb +4 -1
- data/lib/fontisan/constants.rb +24 -24
- data/lib/fontisan/converters/format_converter.rb +8 -4
- data/lib/fontisan/converters/outline_converter.rb +21 -16
- data/lib/fontisan/converters/woff_writer.rb +8 -3
- data/lib/fontisan/font_loader.rb +120 -30
- data/lib/fontisan/font_writer.rb +2 -0
- data/lib/fontisan/formatters/text_formatter.rb +116 -19
- data/lib/fontisan/hints/hint_converter.rb +43 -47
- data/lib/fontisan/hints/hint_validator.rb +284 -0
- data/lib/fontisan/hints/postscript_hint_applier.rb +1 -3
- data/lib/fontisan/hints/postscript_hint_extractor.rb +78 -43
- data/lib/fontisan/hints/truetype_hint_extractor.rb +22 -26
- data/lib/fontisan/hints/truetype_instruction_analyzer.rb +261 -0
- data/lib/fontisan/hints/truetype_instruction_generator.rb +266 -0
- data/lib/fontisan/loading_modes.rb +4 -4
- data/lib/fontisan/models/collection_brief_info.rb +37 -0
- data/lib/fontisan/models/collection_info.rb +6 -1
- data/lib/fontisan/models/font_export.rb +2 -2
- data/lib/fontisan/models/font_info.rb +3 -30
- data/lib/fontisan/models/hint.rb +22 -23
- data/lib/fontisan/models/outline.rb +4 -1
- data/lib/fontisan/models/validation_report.rb +1 -1
- data/lib/fontisan/open_type_collection.rb +17 -220
- data/lib/fontisan/open_type_font.rb +3 -1
- data/lib/fontisan/optimizers/pattern_analyzer.rb +2 -1
- data/lib/fontisan/optimizers/subroutine_generator.rb +1 -1
- data/lib/fontisan/pipeline/output_writer.rb +8 -3
- data/lib/fontisan/pipeline/transformation_pipeline.rb +8 -3
- data/lib/fontisan/subset/table_subsetter.rb +5 -5
- data/lib/fontisan/tables/cff/charstring.rb +38 -12
- data/lib/fontisan/tables/cff/charstring_parser.rb +23 -11
- data/lib/fontisan/tables/cff/charstring_rebuilder.rb +14 -14
- data/lib/fontisan/tables/cff/dict_builder.rb +4 -1
- data/lib/fontisan/tables/cff/hint_operation_injector.rb +6 -4
- data/lib/fontisan/tables/cff/offset_recalculator.rb +1 -1
- data/lib/fontisan/tables/cff/private_dict_writer.rb +10 -4
- data/lib/fontisan/tables/cff/table_builder.rb +1 -1
- data/lib/fontisan/tables/cff2/charstring_parser.rb +14 -8
- data/lib/fontisan/tables/cff2/private_dict_blend_handler.rb +7 -6
- data/lib/fontisan/tables/cff2/region_matcher.rb +2 -2
- data/lib/fontisan/tables/cff2/table_builder.rb +26 -20
- data/lib/fontisan/tables/cff2/table_reader.rb +35 -33
- data/lib/fontisan/tables/cff2/variation_data_extractor.rb +2 -2
- data/lib/fontisan/tables/cff2.rb +1 -1
- data/lib/fontisan/tables/glyf/compound_glyph_resolver.rb +2 -1
- data/lib/fontisan/tables/glyf/curve_converter.rb +10 -4
- data/lib/fontisan/tables/glyf/glyph_builder.rb +27 -10
- data/lib/fontisan/tables/name.rb +4 -4
- data/lib/fontisan/true_type_collection.rb +29 -113
- data/lib/fontisan/true_type_font.rb +3 -1
- data/lib/fontisan/validation/checksum_validator.rb +2 -2
- data/lib/fontisan/variation/cache.rb +3 -1
- data/lib/fontisan/variation/converter.rb +2 -1
- data/lib/fontisan/variation/delta_applier.rb +2 -1
- data/lib/fontisan/variation/inspector.rb +2 -1
- data/lib/fontisan/variation/instance_generator.rb +2 -1
- data/lib/fontisan/variation/optimizer.rb +6 -3
- data/lib/fontisan/variation/subsetter.rb +32 -10
- data/lib/fontisan/variation/variation_preserver.rb +4 -1
- data/lib/fontisan/version.rb +1 -1
- data/lib/fontisan/woff2/glyf_transformer.rb +57 -30
- data/lib/fontisan/woff2_font.rb +31 -15
- data/lib/fontisan.rb +42 -2
- data/scripts/measure_optimization.rb +15 -7
- metadata +9 -2
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bindata"
|
|
4
|
+
require_relative "constants"
|
|
5
|
+
|
|
6
|
+
module Fontisan
|
|
7
|
+
# Abstract base class for font collections (TTC/OTC)
|
|
8
|
+
#
|
|
9
|
+
# This class implements the shared logic for TrueTypeCollection and OpenTypeCollection
|
|
10
|
+
# using the Template Method pattern. Subclasses must implement the abstract methods
|
|
11
|
+
# to specify their font class and collection format.
|
|
12
|
+
#
|
|
13
|
+
# The BinData structure definition is shared between both collection types since
|
|
14
|
+
# both TTC and OTC files use the same "ttcf" tag and binary format. The only
|
|
15
|
+
# differences are:
|
|
16
|
+
# 1. The type of fonts contained (TrueType vs OpenType)
|
|
17
|
+
# 2. The format string used for display ("TTC" vs "OTC")
|
|
18
|
+
#
|
|
19
|
+
# @abstract Subclass and override {font_class} and {collection_format}
|
|
20
|
+
#
|
|
21
|
+
# @example Implementing a collection subclass
|
|
22
|
+
# class TrueTypeCollection < BaseCollection
|
|
23
|
+
# def self.font_class
|
|
24
|
+
# TrueTypeFont
|
|
25
|
+
# end
|
|
26
|
+
#
|
|
27
|
+
# def self.collection_format
|
|
28
|
+
# "TTC"
|
|
29
|
+
# end
|
|
30
|
+
# end
|
|
31
|
+
class BaseCollection < BinData::Record
|
|
32
|
+
endian :big
|
|
33
|
+
|
|
34
|
+
string :tag, length: 4, assert: "ttcf"
|
|
35
|
+
uint16 :major_version
|
|
36
|
+
uint16 :minor_version
|
|
37
|
+
uint32 :num_fonts
|
|
38
|
+
array :font_offsets, type: :uint32, initial_length: :num_fonts
|
|
39
|
+
|
|
40
|
+
# Abstract method: Get the font class for this collection type
|
|
41
|
+
#
|
|
42
|
+
# Subclasses must override this to return their specific font class
|
|
43
|
+
# (TrueTypeFont or OpenTypeFont).
|
|
44
|
+
#
|
|
45
|
+
# @return [Class] The font class (TrueTypeFont or OpenTypeFont)
|
|
46
|
+
# @raise [NotImplementedError] if not overridden by subclass
|
|
47
|
+
def self.font_class
|
|
48
|
+
raise NotImplementedError,
|
|
49
|
+
"#{name} must implement self.font_class"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Abstract method: Get the collection format string
|
|
53
|
+
#
|
|
54
|
+
# Subclasses must override this to return "TTC" or "OTC".
|
|
55
|
+
#
|
|
56
|
+
# @return [String] Collection format ("TTC" or "OTC")
|
|
57
|
+
# @raise [NotImplementedError] if not overridden by subclass
|
|
58
|
+
def self.collection_format
|
|
59
|
+
raise NotImplementedError,
|
|
60
|
+
"#{name} must implement self.collection_format"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Read collection from a file
|
|
64
|
+
#
|
|
65
|
+
# @param path [String] Path to the collection file
|
|
66
|
+
# @return [BaseCollection] A new instance
|
|
67
|
+
# @raise [ArgumentError] if path is nil or empty
|
|
68
|
+
# @raise [Errno::ENOENT] if file does not exist
|
|
69
|
+
# @raise [RuntimeError] if file format is invalid
|
|
70
|
+
def self.from_file(path)
|
|
71
|
+
if path.nil? || path.to_s.empty?
|
|
72
|
+
raise ArgumentError,
|
|
73
|
+
"path cannot be nil or empty"
|
|
74
|
+
end
|
|
75
|
+
raise Errno::ENOENT, "File not found: #{path}" unless File.exist?(path)
|
|
76
|
+
|
|
77
|
+
File.open(path, "rb") { |io| read(io) }
|
|
78
|
+
rescue BinData::ValidityError => e
|
|
79
|
+
raise "Invalid #{collection_format} file: #{e.message}"
|
|
80
|
+
rescue EOFError => e
|
|
81
|
+
raise "Invalid #{collection_format} file: unexpected end of file - #{e.message}"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Extract fonts from the collection
|
|
85
|
+
#
|
|
86
|
+
# Reads each font from the collection file and returns them as font objects.
|
|
87
|
+
#
|
|
88
|
+
# @param io [IO] Open file handle to read fonts from
|
|
89
|
+
# @return [Array] Array of font objects (TrueTypeFont or OpenTypeFont)
|
|
90
|
+
def extract_fonts(io)
|
|
91
|
+
font_class = self.class.font_class
|
|
92
|
+
|
|
93
|
+
font_offsets.map do |offset|
|
|
94
|
+
font_class.from_collection(io, offset)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Get a single font from the collection
|
|
99
|
+
#
|
|
100
|
+
# @param index [Integer] Index of the font (0-based)
|
|
101
|
+
# @param io [IO] Open file handle
|
|
102
|
+
# @param mode [Symbol] Loading mode (:metadata or :full, default: :full)
|
|
103
|
+
# @return [TrueTypeFont, OpenTypeFont, nil] Font object or nil if index out of range
|
|
104
|
+
def font(index, io, mode: LoadingModes::FULL)
|
|
105
|
+
return nil if index >= num_fonts
|
|
106
|
+
|
|
107
|
+
font_class = self.class.font_class
|
|
108
|
+
font_class.from_collection(io, font_offsets[index], mode: mode)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Get font count
|
|
112
|
+
#
|
|
113
|
+
# @return [Integer] Number of fonts in collection
|
|
114
|
+
def font_count
|
|
115
|
+
num_fonts
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Validate format correctness
|
|
119
|
+
#
|
|
120
|
+
# @return [Boolean] true if the format is valid, false otherwise
|
|
121
|
+
def valid?
|
|
122
|
+
tag == Constants::TTC_TAG && num_fonts.positive? && font_offsets.length == num_fonts
|
|
123
|
+
rescue StandardError
|
|
124
|
+
false
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Get the collection version as a single integer
|
|
128
|
+
#
|
|
129
|
+
# @return [Integer] Version number (e.g., 0x00010000 for version 1.0)
|
|
130
|
+
def version
|
|
131
|
+
(major_version << 16) | minor_version
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Get the collection version as a string
|
|
135
|
+
#
|
|
136
|
+
# @return [String] Version string (e.g., "1.0")
|
|
137
|
+
def version_string
|
|
138
|
+
"#{major_version}.#{minor_version}"
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# List all fonts in the collection with basic metadata
|
|
142
|
+
#
|
|
143
|
+
# Returns a CollectionListInfo model containing summaries of all fonts.
|
|
144
|
+
# This is the API method used by the `ls` command for collections.
|
|
145
|
+
#
|
|
146
|
+
# @param io [IO] Open file handle to read fonts from
|
|
147
|
+
# @return [CollectionListInfo] List of fonts with metadata
|
|
148
|
+
#
|
|
149
|
+
# @example List fonts in collection
|
|
150
|
+
# File.open("fonts.ttc", "rb") do |io|
|
|
151
|
+
# collection = TrueTypeCollection.read(io)
|
|
152
|
+
# list = collection.list_fonts(io)
|
|
153
|
+
# list.fonts.each { |f| puts "#{f.index}: #{f.family_name}" }
|
|
154
|
+
# end
|
|
155
|
+
def list_fonts(io)
|
|
156
|
+
require_relative "models/collection_list_info"
|
|
157
|
+
require_relative "models/collection_font_summary"
|
|
158
|
+
require_relative "tables/name"
|
|
159
|
+
|
|
160
|
+
font_class = self.class.font_class
|
|
161
|
+
|
|
162
|
+
fonts = font_offsets.map.with_index do |offset, index|
|
|
163
|
+
font = font_class.from_collection(io, offset)
|
|
164
|
+
|
|
165
|
+
# Extract basic font info
|
|
166
|
+
name_table = font.table("name")
|
|
167
|
+
post_table = font.table("post")
|
|
168
|
+
|
|
169
|
+
family_name = name_table&.english_name(Tables::Name::FAMILY) || "Unknown"
|
|
170
|
+
subfamily_name = name_table&.english_name(Tables::Name::SUBFAMILY) || "Regular"
|
|
171
|
+
postscript_name = name_table&.english_name(Tables::Name::POSTSCRIPT_NAME) || "Unknown"
|
|
172
|
+
|
|
173
|
+
# Determine font format
|
|
174
|
+
sfnt = font.header.sfnt_version
|
|
175
|
+
font_format = case sfnt
|
|
176
|
+
when 0x00010000, 0x74727565 # 0x74727565 = 'true'
|
|
177
|
+
"TrueType"
|
|
178
|
+
when 0x4F54544F # 'OTTO'
|
|
179
|
+
"OpenType"
|
|
180
|
+
else
|
|
181
|
+
"Unknown"
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
num_glyphs = post_table&.glyph_names&.length || 0
|
|
185
|
+
num_tables = font.table_names.length
|
|
186
|
+
|
|
187
|
+
Models::CollectionFontSummary.new(
|
|
188
|
+
index: index,
|
|
189
|
+
family_name: family_name,
|
|
190
|
+
subfamily_name: subfamily_name,
|
|
191
|
+
postscript_name: postscript_name,
|
|
192
|
+
font_format: font_format,
|
|
193
|
+
num_glyphs: num_glyphs,
|
|
194
|
+
num_tables: num_tables,
|
|
195
|
+
)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
Models::CollectionListInfo.new(
|
|
199
|
+
collection_path: nil, # Will be set by command
|
|
200
|
+
num_fonts: num_fonts,
|
|
201
|
+
fonts: fonts,
|
|
202
|
+
)
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Get comprehensive collection metadata
|
|
206
|
+
#
|
|
207
|
+
# Returns a CollectionInfo model with header information, offsets,
|
|
208
|
+
# and table sharing statistics.
|
|
209
|
+
# This is the API method used by the `info` command for collections.
|
|
210
|
+
#
|
|
211
|
+
# @param io [IO] Open file handle to read fonts from
|
|
212
|
+
# @param path [String] Collection file path (for file size)
|
|
213
|
+
# @return [CollectionInfo] Collection metadata
|
|
214
|
+
#
|
|
215
|
+
# @example Get collection info
|
|
216
|
+
# File.open("fonts.ttc", "rb") do |io|
|
|
217
|
+
# collection = TrueTypeCollection.read(io)
|
|
218
|
+
# info = collection.collection_info(io, "fonts.ttc")
|
|
219
|
+
# puts "Version: #{info.version_string}"
|
|
220
|
+
# end
|
|
221
|
+
def collection_info(io, path)
|
|
222
|
+
require_relative "models/collection_info"
|
|
223
|
+
require_relative "models/table_sharing_info"
|
|
224
|
+
|
|
225
|
+
# Calculate table sharing statistics
|
|
226
|
+
table_sharing = calculate_table_sharing(io)
|
|
227
|
+
|
|
228
|
+
# Get file size
|
|
229
|
+
file_size = path ? File.size(path) : 0
|
|
230
|
+
|
|
231
|
+
Models::CollectionInfo.new(
|
|
232
|
+
collection_path: path,
|
|
233
|
+
collection_format: self.class.collection_format,
|
|
234
|
+
ttc_tag: tag,
|
|
235
|
+
major_version: major_version,
|
|
236
|
+
minor_version: minor_version,
|
|
237
|
+
num_fonts: num_fonts,
|
|
238
|
+
font_offsets: font_offsets.to_a,
|
|
239
|
+
file_size_bytes: file_size,
|
|
240
|
+
table_sharing: table_sharing,
|
|
241
|
+
)
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
private
|
|
245
|
+
|
|
246
|
+
# Calculate table sharing statistics
|
|
247
|
+
#
|
|
248
|
+
# Analyzes which tables are shared between fonts and calculates
|
|
249
|
+
# space savings from deduplication.
|
|
250
|
+
#
|
|
251
|
+
# @param io [IO] Open file handle
|
|
252
|
+
# @return [TableSharingInfo] Sharing statistics
|
|
253
|
+
def calculate_table_sharing(io)
|
|
254
|
+
require_relative "models/table_sharing_info"
|
|
255
|
+
|
|
256
|
+
font_class = self.class.font_class
|
|
257
|
+
|
|
258
|
+
# Extract all fonts
|
|
259
|
+
fonts = font_offsets.map do |offset|
|
|
260
|
+
font_class.from_collection(io, offset)
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# Build table hash map (checksum -> size)
|
|
264
|
+
table_map = {}
|
|
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
|
+
)
|
|
294
|
+
end
|
|
295
|
+
end
|
|
296
|
+
end
|
data/lib/fontisan/cli.rb
CHANGED
|
@@ -26,6 +26,9 @@ module Fontisan
|
|
|
26
26
|
aliases: "-q"
|
|
27
27
|
|
|
28
28
|
desc "info PATH", "Display font information"
|
|
29
|
+
option :brief, type: :boolean, default: false,
|
|
30
|
+
desc: "Brief mode - only essential info (5x faster, uses metadata loading)",
|
|
31
|
+
aliases: "-b"
|
|
29
32
|
# Extract and display comprehensive font metadata.
|
|
30
33
|
#
|
|
31
34
|
# @param path [String] Path to the font file or collection
|
|
@@ -33,7 +36,7 @@ module Fontisan
|
|
|
33
36
|
command = Commands::InfoCommand.new(path, options)
|
|
34
37
|
info = command.run
|
|
35
38
|
output_result(info) unless options[:quiet]
|
|
36
|
-
rescue Errno::ENOENT
|
|
39
|
+
rescue Errno::ENOENT
|
|
37
40
|
if options[:verbose]
|
|
38
41
|
raise
|
|
39
42
|
else
|
|
@@ -270,7 +273,10 @@ module Fontisan
|
|
|
270
273
|
|
|
271
274
|
# Merge coordinates into options
|
|
272
275
|
convert_options = options.to_h.dup
|
|
273
|
-
|
|
276
|
+
if instance_coords.any?
|
|
277
|
+
convert_options[:instance_coordinates] =
|
|
278
|
+
instance_coords
|
|
279
|
+
end
|
|
274
280
|
|
|
275
281
|
command = Commands::ConvertCommand.new(font_file, convert_options)
|
|
276
282
|
command.run
|
|
@@ -348,7 +354,8 @@ module Fontisan
|
|
|
348
354
|
option :verbose, type: :boolean, default: false,
|
|
349
355
|
desc: "Show detailed validation information"
|
|
350
356
|
def validate(font_file)
|
|
351
|
-
command = Commands::ValidateCommand.new(font_file,
|
|
357
|
+
command = Commands::ValidateCommand.new(font_file,
|
|
358
|
+
verbose: options[:verbose])
|
|
352
359
|
exit command.run
|
|
353
360
|
end
|
|
354
361
|
|
|
@@ -291,7 +291,8 @@ module Fontisan
|
|
|
291
291
|
otf_count = variable_fonts.count { |f| f.has_table?("CFF2") }
|
|
292
292
|
|
|
293
293
|
if ttf_count.positive? && otf_count.positive?
|
|
294
|
-
raise Error,
|
|
294
|
+
raise Error,
|
|
295
|
+
"Cannot mix TrueType and CFF2 variable fonts in collection"
|
|
295
296
|
end
|
|
296
297
|
end
|
|
297
298
|
|
|
@@ -165,6 +165,7 @@ module Fontisan
|
|
|
165
165
|
end
|
|
166
166
|
end
|
|
167
167
|
|
|
168
|
+
# rubocop:disable Style/CombinableLoops
|
|
168
169
|
# First, assign offsets to shared tables
|
|
169
170
|
# Shared tables are stored once and referenced by multiple fonts
|
|
170
171
|
canonical_tables.each do |canonical_id, info|
|
|
@@ -182,6 +183,7 @@ module Fontisan
|
|
|
182
183
|
@offsets[:table_offsets][canonical_id] = current_offset
|
|
183
184
|
current_offset = align_offset(current_offset + info[:size])
|
|
184
185
|
end
|
|
186
|
+
# rubocop:enable Style/CombinableLoops
|
|
185
187
|
end
|
|
186
188
|
|
|
187
189
|
# Align offset to TABLE_ALIGNMENT boundary
|
|
@@ -82,13 +82,16 @@ module Fontisan
|
|
|
82
82
|
end
|
|
83
83
|
end
|
|
84
84
|
|
|
85
|
+
# Brief mode uses metadata loading for 5x faster parsing
|
|
86
|
+
mode = @options[:brief] ? LoadingModes::METADATA : (@options[:mode] || LoadingModes::FULL)
|
|
87
|
+
|
|
85
88
|
# ConvertCommand and similar commands need all tables loaded upfront
|
|
86
89
|
# Use mode and lazy from options, or sensible defaults
|
|
87
90
|
FontLoader.load(
|
|
88
91
|
@font_path,
|
|
89
92
|
font_index: @options[:font_index] || 0,
|
|
90
|
-
mode:
|
|
91
|
-
lazy: @options.key?(:lazy) ? @options[:lazy] : false
|
|
93
|
+
mode: mode,
|
|
94
|
+
lazy: @options.key?(:lazy) ? @options[:lazy] : false,
|
|
92
95
|
)
|
|
93
96
|
rescue Errno::ENOENT
|
|
94
97
|
# Re-raise file not found as-is
|
|
@@ -89,7 +89,10 @@ module Fontisan
|
|
|
89
89
|
# Add variation options if specified
|
|
90
90
|
pipeline_options[:coordinates] = @coordinates if @coordinates
|
|
91
91
|
pipeline_options[:instance_index] = @instance_index if @instance_index
|
|
92
|
-
|
|
92
|
+
unless @preserve_variation.nil?
|
|
93
|
+
pipeline_options[:preserve_variation] =
|
|
94
|
+
@preserve_variation
|
|
95
|
+
end
|
|
93
96
|
|
|
94
97
|
# Add hint preservation option
|
|
95
98
|
pipeline_options[:preserve_hints] = @preserve_hints if @preserve_hints
|
|
@@ -155,7 +158,8 @@ module Fontisan
|
|
|
155
158
|
end
|
|
156
159
|
coords
|
|
157
160
|
rescue StandardError => e
|
|
158
|
-
raise ArgumentError,
|
|
161
|
+
raise ArgumentError,
|
|
162
|
+
"Invalid coordinates format '#{coord_string}': #{e.message}"
|
|
159
163
|
end
|
|
160
164
|
|
|
161
165
|
# Validate command options
|
|
@@ -41,24 +41,105 @@ module Fontisan
|
|
|
41
41
|
|
|
42
42
|
# Get collection information
|
|
43
43
|
#
|
|
44
|
-
# @return [Models::CollectionInfo] Collection metadata
|
|
44
|
+
# @return [Models::CollectionInfo, Models::CollectionBriefInfo] Collection metadata
|
|
45
45
|
def collection_info
|
|
46
46
|
collection = FontLoader.load_collection(@font_path)
|
|
47
47
|
|
|
48
48
|
File.open(@font_path, "rb") do |io|
|
|
49
|
-
|
|
49
|
+
if @options[:brief]
|
|
50
|
+
# Brief mode: load each font and populate brief info
|
|
51
|
+
brief_info = Models::CollectionBriefInfo.new
|
|
52
|
+
brief_info.collection_path = @font_path
|
|
53
|
+
brief_info.collection_type = collection.class.collection_format
|
|
54
|
+
brief_info.collection_version = collection.version_string
|
|
55
|
+
brief_info.num_fonts = collection.num_fonts
|
|
56
|
+
brief_info.fonts = load_collection_fonts(collection, @font_path)
|
|
57
|
+
|
|
58
|
+
brief_info
|
|
59
|
+
else
|
|
60
|
+
# Full mode: show detailed sharing statistics AND font information
|
|
61
|
+
full_info = collection.collection_info(io, @font_path)
|
|
62
|
+
|
|
63
|
+
# Add font information to full mode
|
|
64
|
+
full_info.fonts = load_collection_fonts(collection, @font_path)
|
|
65
|
+
|
|
66
|
+
full_info
|
|
67
|
+
end
|
|
50
68
|
end
|
|
51
69
|
end
|
|
52
70
|
|
|
71
|
+
# Load font information for all fonts in a collection
|
|
72
|
+
#
|
|
73
|
+
# @param collection [TrueTypeCollection, OpenTypeCollection] The collection
|
|
74
|
+
# @param collection_path [String] Path to the collection file
|
|
75
|
+
# @return [Array<Models::FontInfo>] Array of font info objects
|
|
76
|
+
def load_collection_fonts(collection, collection_path)
|
|
77
|
+
fonts = []
|
|
78
|
+
|
|
79
|
+
collection.num_fonts.times do |index|
|
|
80
|
+
# Load individual font from collection
|
|
81
|
+
font = FontLoader.load(collection_path, font_index: index, mode: LoadingModes::METADATA)
|
|
82
|
+
|
|
83
|
+
# Populate font info
|
|
84
|
+
info = Models::FontInfo.new
|
|
85
|
+
|
|
86
|
+
# Font format and variable status
|
|
87
|
+
info.font_format = case font
|
|
88
|
+
when TrueTypeFont
|
|
89
|
+
"truetype"
|
|
90
|
+
when OpenTypeFont
|
|
91
|
+
"cff"
|
|
92
|
+
else
|
|
93
|
+
"unknown"
|
|
94
|
+
end
|
|
95
|
+
info.is_variable = font.has_table?(Constants::FVAR_TAG)
|
|
96
|
+
|
|
97
|
+
# Collection offset (only populated for fonts in collections)
|
|
98
|
+
info.collection_offset = collection.font_offsets[index]
|
|
99
|
+
|
|
100
|
+
# Essential names
|
|
101
|
+
if font.has_table?(Constants::NAME_TAG)
|
|
102
|
+
name_table = font.table(Constants::NAME_TAG)
|
|
103
|
+
info.family_name = name_table.english_name(Tables::Name::FAMILY)
|
|
104
|
+
info.subfamily_name = name_table.english_name(Tables::Name::SUBFAMILY)
|
|
105
|
+
info.full_name = name_table.english_name(Tables::Name::FULL_NAME)
|
|
106
|
+
info.postscript_name = name_table.english_name(Tables::Name::POSTSCRIPT_NAME)
|
|
107
|
+
info.version = name_table.english_name(Tables::Name::VERSION)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Essential metrics
|
|
111
|
+
if font.has_table?(Constants::HEAD_TAG)
|
|
112
|
+
head = font.table(Constants::HEAD_TAG)
|
|
113
|
+
info.font_revision = head.font_revision
|
|
114
|
+
info.units_per_em = head.units_per_em
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Vendor ID
|
|
118
|
+
if font.has_table?(Constants::OS2_TAG)
|
|
119
|
+
os2_table = font.table(Constants::OS2_TAG)
|
|
120
|
+
info.vendor_id = os2_table.vendor_id
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
fonts << info
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
fonts
|
|
127
|
+
end
|
|
128
|
+
|
|
53
129
|
# Get individual font information
|
|
54
130
|
#
|
|
55
131
|
# @return [Models::FontInfo] Font metadata
|
|
56
132
|
def font_info
|
|
57
133
|
info = Models::FontInfo.new
|
|
58
134
|
populate_font_format(info)
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
135
|
+
|
|
136
|
+
# In brief mode, only populate essential fields for fast identification
|
|
137
|
+
if @options[:brief]
|
|
138
|
+
populate_brief_fields(info)
|
|
139
|
+
else
|
|
140
|
+
populate_full_fields(info)
|
|
141
|
+
end
|
|
142
|
+
|
|
62
143
|
info
|
|
63
144
|
end
|
|
64
145
|
|
|
@@ -80,6 +161,49 @@ module Fontisan
|
|
|
80
161
|
info.is_variable = font.has_table?(Constants::FVAR_TAG)
|
|
81
162
|
end
|
|
82
163
|
|
|
164
|
+
# Populate essential fields for brief mode (metadata tables only).
|
|
165
|
+
#
|
|
166
|
+
# Brief mode provides fast font identification by loading only 13 essential
|
|
167
|
+
# attributes from metadata tables (name, head, OS/2). This is 5x faster than
|
|
168
|
+
# full mode and optimized for font indexing systems.
|
|
169
|
+
#
|
|
170
|
+
# @param info [Models::FontInfo] FontInfo instance to populate
|
|
171
|
+
def populate_brief_fields(info)
|
|
172
|
+
# Essential names from name table
|
|
173
|
+
if font.has_table?(Constants::NAME_TAG)
|
|
174
|
+
name_table = font.table(Constants::NAME_TAG)
|
|
175
|
+
info.family_name = name_table.english_name(Tables::Name::FAMILY)
|
|
176
|
+
info.subfamily_name = name_table.english_name(Tables::Name::SUBFAMILY)
|
|
177
|
+
info.full_name = name_table.english_name(Tables::Name::FULL_NAME)
|
|
178
|
+
info.postscript_name = name_table.english_name(Tables::Name::POSTSCRIPT_NAME)
|
|
179
|
+
info.version = name_table.english_name(Tables::Name::VERSION)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Essential metrics from head table
|
|
183
|
+
if font.has_table?(Constants::HEAD_TAG)
|
|
184
|
+
head = font.table(Constants::HEAD_TAG)
|
|
185
|
+
info.font_revision = head.font_revision
|
|
186
|
+
info.units_per_em = head.units_per_em
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Vendor ID from OS/2 table
|
|
190
|
+
if font.has_table?(Constants::OS2_TAG)
|
|
191
|
+
os2_table = font.table(Constants::OS2_TAG)
|
|
192
|
+
info.vendor_id = os2_table.vendor_id
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Populate all fields for full mode.
|
|
197
|
+
#
|
|
198
|
+
# Full mode extracts comprehensive metadata from all available tables.
|
|
199
|
+
#
|
|
200
|
+
# @param info [Models::FontInfo] FontInfo instance to populate
|
|
201
|
+
def populate_full_fields(info)
|
|
202
|
+
populate_from_name_table(info) if font.has_table?(Constants::NAME_TAG)
|
|
203
|
+
populate_from_os2_table(info) if font.has_table?(Constants::OS2_TAG)
|
|
204
|
+
populate_from_head_table(info) if font.has_table?(Constants::HEAD_TAG)
|
|
205
|
+
end
|
|
206
|
+
|
|
83
207
|
# Populate FontInfo from the name table.
|
|
84
208
|
#
|
|
85
209
|
# @param info [Models::FontInfo] FontInfo instance to populate
|
|
@@ -67,11 +67,11 @@ module Fontisan
|
|
|
67
67
|
|
|
68
68
|
puts "Static font instance written to: #{output_path}"
|
|
69
69
|
rescue VariationError => e
|
|
70
|
-
|
|
70
|
+
warn "Variation Error: #{e.detailed_message}"
|
|
71
71
|
exit 1
|
|
72
72
|
rescue StandardError => e
|
|
73
|
-
|
|
74
|
-
|
|
73
|
+
warn "Error: #{e.message}"
|
|
74
|
+
warn e.backtrace.first(5).join("\n") if options[:verbose]
|
|
75
75
|
exit 1
|
|
76
76
|
end
|
|
77
77
|
|
|
@@ -87,9 +87,9 @@ module Fontisan
|
|
|
87
87
|
errors = validator.validate
|
|
88
88
|
|
|
89
89
|
if errors.any?
|
|
90
|
-
|
|
90
|
+
warn "Validation errors found:"
|
|
91
91
|
errors.each do |error|
|
|
92
|
-
|
|
92
|
+
warn " - #{error}"
|
|
93
93
|
end
|
|
94
94
|
exit 1
|
|
95
95
|
end
|
|
@@ -102,7 +102,7 @@ module Fontisan
|
|
|
102
102
|
# @param font [Object] Font object
|
|
103
103
|
# @param input_path [String] Input file path
|
|
104
104
|
# @param options [Hash] Command options
|
|
105
|
-
def preview_instance(
|
|
105
|
+
def preview_instance(_font, input_path, options)
|
|
106
106
|
coords = extract_coordinates(options)
|
|
107
107
|
|
|
108
108
|
if coords.empty?
|
|
@@ -117,7 +117,8 @@ module Fontisan
|
|
|
117
117
|
puts " #{axis}: #{value}"
|
|
118
118
|
end
|
|
119
119
|
puts
|
|
120
|
-
puts "Output would be written to: #{determine_output_path(input_path,
|
|
120
|
+
puts "Output would be written to: #{determine_output_path(input_path,
|
|
121
|
+
options)}"
|
|
121
122
|
puts "Output format: #{options[:to] || 'same as input'}"
|
|
122
123
|
puts
|
|
123
124
|
puts "Use without --dry-run to actually generate the instance."
|
|
@@ -84,7 +84,10 @@ quiet: false)
|
|
|
84
84
|
errors.each do |error|
|
|
85
85
|
puts " ERROR: #{error}" if @verbose && !@quiet
|
|
86
86
|
# Add to report if report supports adding errors
|
|
87
|
-
|
|
87
|
+
if report.respond_to?(:errors)
|
|
88
|
+
report.errors << { message: error,
|
|
89
|
+
category: "variable_font" }
|
|
90
|
+
end
|
|
88
91
|
end
|
|
89
92
|
elsif @verbose && !@quiet
|
|
90
93
|
puts "\n✓ Variable font structure valid"
|
data/lib/fontisan/constants.rb
CHANGED
|
@@ -117,30 +117,30 @@ module Fontisan
|
|
|
117
117
|
# These strings are frozen and reused to reduce memory allocations
|
|
118
118
|
# when parsing fonts with common subfamily names.
|
|
119
119
|
STRING_POOL = {
|
|
120
|
-
"Regular" => "Regular"
|
|
121
|
-
"Bold" => "Bold"
|
|
122
|
-
"Italic" => "Italic"
|
|
123
|
-
"Bold Italic" => "Bold Italic"
|
|
124
|
-
"BoldItalic" => "BoldItalic"
|
|
125
|
-
"Light" => "Light"
|
|
126
|
-
"Medium" => "Medium"
|
|
127
|
-
"Semibold" => "Semibold"
|
|
128
|
-
"SemiBold" => "SemiBold"
|
|
129
|
-
"Black" => "Black"
|
|
130
|
-
"Thin" => "Thin"
|
|
131
|
-
"ExtraLight" => "ExtraLight"
|
|
132
|
-
"Extra Light" => "Extra Light"
|
|
133
|
-
"ExtraBold" => "ExtraBold"
|
|
134
|
-
"Extra Bold" => "Extra Bold"
|
|
135
|
-
"Heavy" => "Heavy"
|
|
136
|
-
"Book" => "Book"
|
|
137
|
-
"Roman" => "Roman"
|
|
138
|
-
"Normal" => "Normal"
|
|
139
|
-
"Oblique" => "Oblique"
|
|
140
|
-
"Light Italic" => "Light Italic"
|
|
141
|
-
"Medium Italic" => "Medium Italic"
|
|
142
|
-
"Semibold Italic" => "Semibold Italic"
|
|
143
|
-
"Bold Oblique" => "Bold Oblique"
|
|
120
|
+
"Regular" => "Regular",
|
|
121
|
+
"Bold" => "Bold",
|
|
122
|
+
"Italic" => "Italic",
|
|
123
|
+
"Bold Italic" => "Bold Italic",
|
|
124
|
+
"BoldItalic" => "BoldItalic",
|
|
125
|
+
"Light" => "Light",
|
|
126
|
+
"Medium" => "Medium",
|
|
127
|
+
"Semibold" => "Semibold",
|
|
128
|
+
"SemiBold" => "SemiBold",
|
|
129
|
+
"Black" => "Black",
|
|
130
|
+
"Thin" => "Thin",
|
|
131
|
+
"ExtraLight" => "ExtraLight",
|
|
132
|
+
"Extra Light" => "Extra Light",
|
|
133
|
+
"ExtraBold" => "ExtraBold",
|
|
134
|
+
"Extra Bold" => "Extra Bold",
|
|
135
|
+
"Heavy" => "Heavy",
|
|
136
|
+
"Book" => "Book",
|
|
137
|
+
"Roman" => "Roman",
|
|
138
|
+
"Normal" => "Normal",
|
|
139
|
+
"Oblique" => "Oblique",
|
|
140
|
+
"Light Italic" => "Light Italic",
|
|
141
|
+
"Medium Italic" => "Medium Italic",
|
|
142
|
+
"Semibold Italic" => "Semibold Italic",
|
|
143
|
+
"Bold Oblique" => "Bold Oblique",
|
|
144
144
|
}.freeze
|
|
145
145
|
|
|
146
146
|
# Intern a string using the string pool
|