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
|
@@ -103,7 +103,8 @@ module Fontisan
|
|
|
103
103
|
end
|
|
104
104
|
|
|
105
105
|
strategy = select_strategy(source_format, target_format)
|
|
106
|
-
tables = strategy.convert(font,
|
|
106
|
+
tables = strategy.convert(font,
|
|
107
|
+
options.merge(target_format: target_format))
|
|
107
108
|
|
|
108
109
|
# Preserve variation data if requested and font is variable
|
|
109
110
|
if options.fetch(:preserve_variation, true) && variable_font?(font)
|
|
@@ -204,7 +205,8 @@ module Fontisan
|
|
|
204
205
|
# @param target_format [Symbol] Target format
|
|
205
206
|
# @param options [Hash] Preservation options
|
|
206
207
|
# @return [Hash<String, String>] Tables with variation preserved
|
|
207
|
-
def preserve_variation_data(font, tables, source_format, target_format,
|
|
208
|
+
def preserve_variation_data(font, tables, source_format, target_format,
|
|
209
|
+
options)
|
|
208
210
|
# Case 1: Compatible formats (same outline format) - just copy tables
|
|
209
211
|
if compatible_variation_formats?(source_format, target_format)
|
|
210
212
|
require_relative "../variation/variation_preserver"
|
|
@@ -212,7 +214,8 @@ module Fontisan
|
|
|
212
214
|
|
|
213
215
|
# Case 2: Different outline formats - convert variation data
|
|
214
216
|
elsif convertible_variation_formats?(source_format, target_format)
|
|
215
|
-
convert_variation_data(font, tables, source_format, target_format,
|
|
217
|
+
convert_variation_data(font, tables, source_format, target_format,
|
|
218
|
+
options)
|
|
216
219
|
|
|
217
220
|
# Case 3: Unsupported conversion
|
|
218
221
|
else
|
|
@@ -268,7 +271,8 @@ module Fontisan
|
|
|
268
271
|
# @param target_format [Symbol] Target format
|
|
269
272
|
# @param options [Hash] Conversion options
|
|
270
273
|
# @return [Hash<String, String>] Tables with converted variation
|
|
271
|
-
def convert_variation_data(font, tables, source_format, target_format,
|
|
274
|
+
def convert_variation_data(font, tables, source_format, target_format,
|
|
275
|
+
_options)
|
|
272
276
|
require_relative "../variation/variation_preserver"
|
|
273
277
|
require_relative "../variation/converter"
|
|
274
278
|
|
|
@@ -131,7 +131,7 @@ module Fontisan
|
|
|
131
131
|
# @param font [TrueTypeFont] Source font
|
|
132
132
|
# @param options [Hash] Conversion options (currently unused)
|
|
133
133
|
# @return [Hash<String, String>] Target tables
|
|
134
|
-
def convert_ttf_to_otf(font,
|
|
134
|
+
def convert_ttf_to_otf(font, _options = {})
|
|
135
135
|
# Extract all glyphs from glyf table
|
|
136
136
|
outlines = extract_ttf_outlines(font)
|
|
137
137
|
|
|
@@ -184,7 +184,8 @@ module Fontisan
|
|
|
184
184
|
hints_per_glyph = @preserve_hints ? extract_cff_hints(font) : {}
|
|
185
185
|
|
|
186
186
|
# Build glyf and loca tables
|
|
187
|
-
glyf_data, loca_data, loca_format = build_glyf_loca_tables(outlines,
|
|
187
|
+
glyf_data, loca_data, loca_format = build_glyf_loca_tables(outlines,
|
|
188
|
+
hints_per_glyph)
|
|
188
189
|
|
|
189
190
|
# Copy all tables except CFF
|
|
190
191
|
tables = copy_tables(font, ["CFF ", "CFF2"])
|
|
@@ -357,7 +358,7 @@ module Fontisan
|
|
|
357
358
|
# @param outlines [Array<Outline>] Glyph outlines
|
|
358
359
|
# @param font [TrueTypeFont] Source font (for metadata)
|
|
359
360
|
# @return [String] CFF table binary data
|
|
360
|
-
def build_cff_table(outlines, font,
|
|
361
|
+
def build_cff_table(outlines, font, _hints_per_glyph)
|
|
361
362
|
# Build CharStrings INDEX from outlines
|
|
362
363
|
begin
|
|
363
364
|
charstrings = outlines.map do |outline|
|
|
@@ -478,7 +479,8 @@ module Fontisan
|
|
|
478
479
|
top_dict_data = Tables::Cff::DictBuilder.build(top_dict_hash)
|
|
479
480
|
top_dict_index_data = Tables::Cff::IndexBuilder.build([top_dict_data])
|
|
480
481
|
rescue StandardError => e
|
|
481
|
-
raise Fontisan::Error,
|
|
482
|
+
raise Fontisan::Error,
|
|
483
|
+
"Failed to calculate CFF table offsets: #{e.message}"
|
|
482
484
|
end
|
|
483
485
|
|
|
484
486
|
# Build CFF Header
|
|
@@ -512,7 +514,7 @@ module Fontisan
|
|
|
512
514
|
#
|
|
513
515
|
# @param outlines [Array<Outline>] Glyph outlines
|
|
514
516
|
# @return [Array<String, String, Integer>] [glyf_data, loca_data, loca_format]
|
|
515
|
-
def build_glyf_loca_tables(outlines,
|
|
517
|
+
def build_glyf_loca_tables(outlines, _hints_per_glyph)
|
|
516
518
|
glyf_data = "".b
|
|
517
519
|
offsets = []
|
|
518
520
|
|
|
@@ -687,7 +689,7 @@ module Fontisan
|
|
|
687
689
|
# Analyze patterns
|
|
688
690
|
analyzer = Optimizers::PatternAnalyzer.new(
|
|
689
691
|
min_length: 10,
|
|
690
|
-
stack_aware: true
|
|
692
|
+
stack_aware: true,
|
|
691
693
|
)
|
|
692
694
|
patterns = analyzer.analyze(charstrings_hash)
|
|
693
695
|
|
|
@@ -695,7 +697,8 @@ module Fontisan
|
|
|
695
697
|
return [charstrings, []] if patterns.empty?
|
|
696
698
|
|
|
697
699
|
# Optimize selection
|
|
698
|
-
optimizer = Optimizers::SubroutineOptimizer.new(patterns,
|
|
700
|
+
optimizer = Optimizers::SubroutineOptimizer.new(patterns,
|
|
701
|
+
max_subrs: 65_535)
|
|
699
702
|
selected_patterns = optimizer.optimize_selection
|
|
700
703
|
|
|
701
704
|
# Optimize ordering
|
|
@@ -705,7 +708,8 @@ module Fontisan
|
|
|
705
708
|
return [charstrings, []] if selected_patterns.empty?
|
|
706
709
|
|
|
707
710
|
# Build subroutines
|
|
708
|
-
builder = Optimizers::SubroutineBuilder.new(selected_patterns,
|
|
711
|
+
builder = Optimizers::SubroutineBuilder.new(selected_patterns,
|
|
712
|
+
type: :local)
|
|
709
713
|
local_subrs = builder.build
|
|
710
714
|
|
|
711
715
|
# Build subroutine map
|
|
@@ -718,7 +722,9 @@ module Fontisan
|
|
|
718
722
|
rewriter = Optimizers::CharstringRewriter.new(subroutine_map, builder)
|
|
719
723
|
optimized_charstrings = charstrings.map.with_index do |charstring, glyph_id|
|
|
720
724
|
# Find patterns for this glyph
|
|
721
|
-
glyph_patterns = selected_patterns.select
|
|
725
|
+
glyph_patterns = selected_patterns.select do |p|
|
|
726
|
+
p.glyphs.include?(glyph_id)
|
|
727
|
+
end
|
|
722
728
|
|
|
723
729
|
if glyph_patterns.empty?
|
|
724
730
|
charstring
|
|
@@ -743,13 +749,16 @@ module Fontisan
|
|
|
743
749
|
def generate_static_instance(font, source_format, target_format)
|
|
744
750
|
# Generate instance at specified coordinates
|
|
745
751
|
fvar = font.table("fvar")
|
|
746
|
-
|
|
752
|
+
fvar ? fvar.axes : []
|
|
747
753
|
|
|
748
|
-
generator = Variation::InstanceGenerator.new(font,
|
|
754
|
+
generator = Variation::InstanceGenerator.new(font,
|
|
755
|
+
@instance_coordinates)
|
|
749
756
|
instance_tables = generator.generate
|
|
750
757
|
|
|
751
758
|
# If target format differs from source, convert outlines
|
|
752
|
-
if source_format
|
|
759
|
+
if source_format == target_format
|
|
760
|
+
instance_tables
|
|
761
|
+
else
|
|
753
762
|
# Create temporary font with instance tables
|
|
754
763
|
temp_font = font.class.new
|
|
755
764
|
temp_font.instance_variable_set(:@table_data, instance_tables)
|
|
@@ -763,8 +772,6 @@ module Fontisan
|
|
|
763
772
|
else
|
|
764
773
|
instance_tables
|
|
765
774
|
end
|
|
766
|
-
else
|
|
767
|
-
instance_tables
|
|
768
775
|
end
|
|
769
776
|
end
|
|
770
777
|
|
|
@@ -801,8 +808,6 @@ module Fontisan
|
|
|
801
808
|
when %i[otf ttf], %i[cff2 ttf]
|
|
802
809
|
# blend → gvar
|
|
803
810
|
converter.blend_to_gvar(glyph_id)
|
|
804
|
-
else
|
|
805
|
-
nil
|
|
806
811
|
end
|
|
807
812
|
|
|
808
813
|
variation_data[glyph_id] = data if data
|
|
@@ -128,7 +128,8 @@ module Fontisan
|
|
|
128
128
|
# @raise [ArgumentError] if compression level is invalid
|
|
129
129
|
def validate_compression_level!
|
|
130
130
|
unless @compression_level.between?(0, 9)
|
|
131
|
-
raise ArgumentError,
|
|
131
|
+
raise ArgumentError,
|
|
132
|
+
"Compression level must be between 0 and 9, got #{@compression_level}"
|
|
132
133
|
end
|
|
133
134
|
end
|
|
134
135
|
|
|
@@ -235,7 +236,9 @@ module Fontisan
|
|
|
235
236
|
metadata_size = compressed_metadata ? compressed_metadata[:compressed_length] : 0
|
|
236
237
|
|
|
237
238
|
# Calculate total compressed data size
|
|
238
|
-
total_compressed_size = compressed_tables.values.sum
|
|
239
|
+
total_compressed_size = compressed_tables.values.sum do |table|
|
|
240
|
+
table[:compressed_length]
|
|
241
|
+
end
|
|
239
242
|
|
|
240
243
|
# Calculate private data offset (after table data + metadata)
|
|
241
244
|
private_offset = data_offset + total_compressed_size + metadata_size
|
|
@@ -245,7 +248,9 @@ module Fontisan
|
|
|
245
248
|
total_size = private_offset + private_size
|
|
246
249
|
|
|
247
250
|
# Calculate total SFNT size (uncompressed)
|
|
248
|
-
total_sfnt_size = compressed_tables.values.sum
|
|
251
|
+
total_sfnt_size = compressed_tables.values.sum do |table|
|
|
252
|
+
table[:original_length]
|
|
253
|
+
end +
|
|
249
254
|
header_size + table_dir_size
|
|
250
255
|
|
|
251
256
|
# Write WOFF header
|
data/lib/fontisan/font_loader.rb
CHANGED
|
@@ -46,7 +46,11 @@ module Fontisan
|
|
|
46
46
|
|
|
47
47
|
# Resolve mode and lazy parameters with environment variables
|
|
48
48
|
resolved_mode = mode || env_mode || LoadingModes::FULL
|
|
49
|
-
resolved_lazy = lazy.nil?
|
|
49
|
+
resolved_lazy = if lazy.nil?
|
|
50
|
+
env_lazy.nil? ? false : env_lazy
|
|
51
|
+
else
|
|
52
|
+
lazy
|
|
53
|
+
end
|
|
50
54
|
|
|
51
55
|
# Validate mode
|
|
52
56
|
LoadingModes.validate_mode!(resolved_mode)
|
|
@@ -57,7 +61,8 @@ module Fontisan
|
|
|
57
61
|
|
|
58
62
|
case signature
|
|
59
63
|
when Constants::TTC_TAG
|
|
60
|
-
load_from_collection(io, path, font_index, mode: resolved_mode,
|
|
64
|
+
load_from_collection(io, path, font_index, mode: resolved_mode,
|
|
65
|
+
lazy: resolved_lazy)
|
|
61
66
|
when pack_uint32(Constants::SFNT_VERSION_TRUETYPE)
|
|
62
67
|
TrueTypeFont.from_file(path, mode: resolved_mode, lazy: resolved_lazy)
|
|
63
68
|
when "OTTO"
|
|
@@ -97,6 +102,37 @@ module Fontisan
|
|
|
97
102
|
# without extracting individual fonts. Useful for inspecting collection
|
|
98
103
|
# metadata and structure.
|
|
99
104
|
#
|
|
105
|
+
# = Collection Format Understanding
|
|
106
|
+
#
|
|
107
|
+
# Both TTC (TrueType Collection) and OTC (OpenType Collection) files use
|
|
108
|
+
# the same "ttcf" signature. The distinction between TTC and OTC is NOT
|
|
109
|
+
# in the collection format itself, but in the fonts contained within:
|
|
110
|
+
#
|
|
111
|
+
# - TTC typically contains TrueType fonts (glyf outlines)
|
|
112
|
+
# - OTC typically contains OpenType fonts (CFF/CFF2 outlines)
|
|
113
|
+
# - Mixed collections are possible (both TTF and OTF in same collection)
|
|
114
|
+
#
|
|
115
|
+
# Each collection can contain multiple SFNT-format font files, with table
|
|
116
|
+
# deduplication to save space. Individual fonts within a collection are
|
|
117
|
+
# stored at different offsets within the file, each with their own table
|
|
118
|
+
# directory and data tables.
|
|
119
|
+
#
|
|
120
|
+
# = Detection Strategy
|
|
121
|
+
#
|
|
122
|
+
# This method scans ALL fonts in the collection to determine the collection
|
|
123
|
+
# type accurately:
|
|
124
|
+
#
|
|
125
|
+
# 1. Reads all font offsets from the collection header
|
|
126
|
+
# 2. Examines the sfnt_version of each font in the collection
|
|
127
|
+
# 3. Counts TrueType fonts (0x00010000 or 0x74727565 "true") vs OpenType fonts (0x4F54544F "OTTO")
|
|
128
|
+
# 4. If ANY font is OpenType (CFF), returns OpenTypeCollection
|
|
129
|
+
# 5. Only returns TrueTypeCollection if ALL fonts are TrueType
|
|
130
|
+
#
|
|
131
|
+
# This approach correctly handles:
|
|
132
|
+
# - Homogeneous collections (all TTF or all OTF)
|
|
133
|
+
# - Mixed collections (both TTF and OTF fonts) - uses OpenTypeCollection
|
|
134
|
+
# - Large collections with many fonts (like NotoSerifCJK.ttc with 35 fonts)
|
|
135
|
+
#
|
|
100
136
|
# @param path [String] Path to the collection file
|
|
101
137
|
# @return [TrueTypeCollection, OpenTypeCollection] The collection object
|
|
102
138
|
# @raise [Errno::ENOENT] if file does not exist
|
|
@@ -116,23 +152,43 @@ module Fontisan
|
|
|
116
152
|
"File is not a collection (TTC/OTC). Use FontLoader.load instead."
|
|
117
153
|
end
|
|
118
154
|
|
|
119
|
-
# Read
|
|
120
|
-
io.seek(
|
|
121
|
-
|
|
155
|
+
# Read version and num_fonts
|
|
156
|
+
io.seek(8) # Skip tag (4) + version (4)
|
|
157
|
+
num_fonts = io.read(4).unpack1("N")
|
|
158
|
+
|
|
159
|
+
# Read all font offsets
|
|
160
|
+
font_offsets = num_fonts.times.map { io.read(4).unpack1("N") }
|
|
161
|
+
|
|
162
|
+
# Scan all fonts to determine collection type (not just first)
|
|
163
|
+
truetype_count = 0
|
|
164
|
+
opentype_count = 0
|
|
165
|
+
|
|
166
|
+
font_offsets.each do |offset|
|
|
167
|
+
io.rewind
|
|
168
|
+
io.seek(offset)
|
|
169
|
+
sfnt_version = io.read(4).unpack1("N")
|
|
170
|
+
|
|
171
|
+
case sfnt_version
|
|
172
|
+
when Constants::SFNT_VERSION_TRUETYPE, 0x74727565 # 0x74727565 = 'true'
|
|
173
|
+
truetype_count += 1
|
|
174
|
+
when Constants::SFNT_VERSION_OTTO
|
|
175
|
+
opentype_count += 1
|
|
176
|
+
else
|
|
177
|
+
raise InvalidFontError,
|
|
178
|
+
"Unknown font type in collection at offset #{offset} (sfnt version: 0x#{sfnt_version.to_s(16)})"
|
|
179
|
+
end
|
|
180
|
+
end
|
|
122
181
|
|
|
123
|
-
# Peek at first font's sfnt_version
|
|
124
|
-
io.seek(first_offset)
|
|
125
|
-
sfnt_version = io.read(4).unpack1("N")
|
|
126
182
|
io.rewind
|
|
127
183
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
184
|
+
# Determine collection type based on what fonts are inside
|
|
185
|
+
# If ANY font is OpenType, use OpenTypeCollection (more general format)
|
|
186
|
+
# Only use TrueTypeCollection if ALL fonts are TrueType
|
|
187
|
+
if opentype_count > 0
|
|
132
188
|
OpenTypeCollection.from_file(path)
|
|
133
189
|
else
|
|
134
|
-
|
|
135
|
-
|
|
190
|
+
# All fonts are TrueType
|
|
191
|
+
TrueTypeCollection.from_file(path)
|
|
136
192
|
end
|
|
137
193
|
end
|
|
138
194
|
end
|
|
@@ -162,6 +218,23 @@ module Fontisan
|
|
|
162
218
|
|
|
163
219
|
# Load from a collection file (TTC or OTC)
|
|
164
220
|
#
|
|
221
|
+
# This is the internal method that handles loading individual fonts from
|
|
222
|
+
# collection files. It reads the collection header to determine the type
|
|
223
|
+
# (TTC vs OTC) and extracts the requested font.
|
|
224
|
+
#
|
|
225
|
+
# = Collection Header Structure
|
|
226
|
+
#
|
|
227
|
+
# TTC/OTC files start with:
|
|
228
|
+
# - Bytes 0-3: "ttcf" tag (4 bytes)
|
|
229
|
+
# - Bytes 4-7: version (2 bytes major + 2 bytes minor)
|
|
230
|
+
# - Bytes 8-11: num_fonts (4 bytes, big-endian uint32)
|
|
231
|
+
# - Bytes 12+: font offset array (4 bytes per font, big-endian uint32)
|
|
232
|
+
#
|
|
233
|
+
# CRITICAL: The method seeks to position 8 (after tag and version) to read
|
|
234
|
+
# num_fonts, NOT position 12 which is where the offset array starts. This
|
|
235
|
+
# was a bug that caused "Unknown font type" errors when the first offset
|
|
236
|
+
# was misread as num_fonts.
|
|
237
|
+
#
|
|
165
238
|
# @param io [IO] Open file handle
|
|
166
239
|
# @param path [String] Path to the collection file
|
|
167
240
|
# @param font_index [Integer] Index of font to extract
|
|
@@ -169,9 +242,10 @@ module Fontisan
|
|
|
169
242
|
# @param lazy [Boolean] If true, load tables on demand
|
|
170
243
|
# @return [TrueTypeFont, OpenTypeFont] The loaded font object
|
|
171
244
|
# @raise [InvalidFontError] if collection type cannot be determined
|
|
172
|
-
def self.load_from_collection(io, path, font_index,
|
|
245
|
+
def self.load_from_collection(io, path, font_index,
|
|
246
|
+
mode: LoadingModes::FULL, lazy: true)
|
|
173
247
|
# Read collection header to get font offsets
|
|
174
|
-
io.seek(
|
|
248
|
+
io.seek(8) # Skip tag (4) + version (4)
|
|
175
249
|
num_fonts = io.read(4).unpack1("N")
|
|
176
250
|
|
|
177
251
|
if font_index >= num_fonts
|
|
@@ -179,26 +253,41 @@ module Fontisan
|
|
|
179
253
|
"Font index #{font_index} out of range (collection has #{num_fonts} fonts)"
|
|
180
254
|
end
|
|
181
255
|
|
|
182
|
-
# Read
|
|
183
|
-
|
|
256
|
+
# Read all font offsets
|
|
257
|
+
font_offsets = num_fonts.times.map { io.read(4).unpack1("N") }
|
|
258
|
+
|
|
259
|
+
# Scan all fonts to determine collection type (not just first)
|
|
260
|
+
truetype_count = 0
|
|
261
|
+
opentype_count = 0
|
|
262
|
+
|
|
263
|
+
font_offsets.each do |offset|
|
|
264
|
+
io.rewind
|
|
265
|
+
io.seek(offset)
|
|
266
|
+
sfnt_version = io.read(4).unpack1("N")
|
|
267
|
+
|
|
268
|
+
case sfnt_version
|
|
269
|
+
when Constants::SFNT_VERSION_TRUETYPE, 0x74727565 # 0x74727565 = 'true'
|
|
270
|
+
truetype_count += 1
|
|
271
|
+
when Constants::SFNT_VERSION_OTTO
|
|
272
|
+
opentype_count += 1
|
|
273
|
+
else
|
|
274
|
+
raise InvalidFontError,
|
|
275
|
+
"Unknown font type in collection at offset #{offset} (sfnt version: 0x#{sfnt_version.to_s(16)})"
|
|
276
|
+
end
|
|
277
|
+
end
|
|
184
278
|
|
|
185
|
-
# Peek at first font's sfnt_version to determine TTC vs OTC
|
|
186
|
-
io.seek(first_offset)
|
|
187
|
-
sfnt_version = io.read(4).unpack1("N")
|
|
188
279
|
io.rewind
|
|
189
280
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
ttc = TrueTypeCollection.from_file(path)
|
|
194
|
-
File.open(path, "rb") { |f| ttc.font(font_index, f, mode: mode) }
|
|
195
|
-
when Constants::SFNT_VERSION_OTTO
|
|
281
|
+
# If ANY font is OpenType, use OpenTypeCollection (more general format)
|
|
282
|
+
# Only use TrueTypeCollection if ALL fonts are TrueType
|
|
283
|
+
if opentype_count > 0
|
|
196
284
|
# OpenType Collection
|
|
197
285
|
otc = OpenTypeCollection.from_file(path)
|
|
198
286
|
File.open(path, "rb") { |f| otc.font(font_index, f, mode: mode) }
|
|
199
287
|
else
|
|
200
|
-
|
|
201
|
-
|
|
288
|
+
# TrueType Collection (all fonts are TrueType)
|
|
289
|
+
ttc = TrueTypeCollection.from_file(path)
|
|
290
|
+
File.open(path, "rb") { |f| ttc.font(font_index, f, mode: mode) }
|
|
202
291
|
end
|
|
203
292
|
end
|
|
204
293
|
|
|
@@ -211,6 +300,7 @@ module Fontisan
|
|
|
211
300
|
[value].pack("N")
|
|
212
301
|
end
|
|
213
302
|
|
|
214
|
-
private_class_method :load_from_collection, :pack_uint32, :env_mode,
|
|
303
|
+
private_class_method :load_from_collection, :pack_uint32, :env_mode,
|
|
304
|
+
:env_lazy
|
|
215
305
|
end
|
|
216
306
|
end
|
data/lib/fontisan/font_writer.rb
CHANGED
|
@@ -113,6 +113,7 @@ module Fontisan
|
|
|
113
113
|
# Write offset table (sfnt header)
|
|
114
114
|
font_data << write_offset_table(table_entries.size)
|
|
115
115
|
|
|
116
|
+
# rubocop:disable Style/CombinableLoops
|
|
116
117
|
# Write table directory (ALL entries first)
|
|
117
118
|
table_entries.each do |entry|
|
|
118
119
|
font_data << write_table_entry(entry)
|
|
@@ -123,6 +124,7 @@ module Fontisan
|
|
|
123
124
|
font_data << entry[:data]
|
|
124
125
|
font_data << entry[:padding]
|
|
125
126
|
end
|
|
127
|
+
# rubocop:enable Style/CombinableLoops
|
|
126
128
|
|
|
127
129
|
# Calculate and update head table checksum adjustment
|
|
128
130
|
update_checksum_adjustment!(font_data, table_entries)
|
|
@@ -42,6 +42,8 @@ module Fontisan
|
|
|
42
42
|
format_font_summary(model)
|
|
43
43
|
when Models::CollectionInfo
|
|
44
44
|
format_collection_info(model)
|
|
45
|
+
when Models::CollectionBriefInfo
|
|
46
|
+
format_collection_brief_info(model)
|
|
45
47
|
else
|
|
46
48
|
model.to_s
|
|
47
49
|
end
|
|
@@ -312,7 +314,8 @@ module Fontisan
|
|
|
312
314
|
font_format
|
|
313
315
|
end
|
|
314
316
|
|
|
315
|
-
|
|
317
|
+
# Always show variable status explicitly
|
|
318
|
+
type += is_variable ? " (Variable)" : " (Not Variable)"
|
|
316
319
|
type
|
|
317
320
|
end
|
|
318
321
|
|
|
@@ -361,20 +364,36 @@ module Fontisan
|
|
|
361
364
|
def format_collection_info(info)
|
|
362
365
|
lines = []
|
|
363
366
|
|
|
364
|
-
# Header section
|
|
365
|
-
lines << "
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
367
|
+
# Header section with type and version (like brief mode)
|
|
368
|
+
lines << "Collection: #{info.collection_path}"
|
|
369
|
+
|
|
370
|
+
# Format collection type for display with OpenType version
|
|
371
|
+
if info.collection_format
|
|
372
|
+
collection_type_display = case info.collection_format
|
|
373
|
+
when "TTC"
|
|
374
|
+
"TrueType Collection (OpenType 1.4)"
|
|
375
|
+
when "OTC"
|
|
376
|
+
"OpenType Collection (OpenType 1.8)"
|
|
377
|
+
else
|
|
378
|
+
info.collection_format
|
|
379
|
+
end
|
|
380
|
+
lines << "Type: #{collection_type_display}"
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
lines << "Version: #{info.version_string}"
|
|
369
384
|
lines << "Size: #{format_bytes(info.file_size_bytes)}"
|
|
385
|
+
lines << "Fonts: #{info.num_fonts}"
|
|
370
386
|
lines << ""
|
|
371
387
|
|
|
372
|
-
#
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
388
|
+
# Table sharing statistics
|
|
389
|
+
if info.table_sharing
|
|
390
|
+
lines << "=== Table Sharing ==="
|
|
391
|
+
lines << "Shared tables: #{info.table_sharing.shared_tables}"
|
|
392
|
+
lines << "Unique tables: #{info.table_sharing.unique_tables}"
|
|
393
|
+
lines << "Sharing: #{format_float(info.table_sharing.sharing_percentage)}%"
|
|
394
|
+
lines << "Space saved: #{format_bytes(info.table_sharing.space_saved_bytes)}"
|
|
395
|
+
lines << ""
|
|
396
|
+
end
|
|
378
397
|
|
|
379
398
|
# Font offsets
|
|
380
399
|
lines << "=== Font Offsets ==="
|
|
@@ -384,13 +403,91 @@ module Fontisan
|
|
|
384
403
|
end
|
|
385
404
|
lines << ""
|
|
386
405
|
|
|
387
|
-
#
|
|
388
|
-
if info.
|
|
389
|
-
lines << "===
|
|
390
|
-
lines << "
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
406
|
+
# Individual font information (like brief mode)
|
|
407
|
+
if info.fonts && !info.fonts.empty?
|
|
408
|
+
lines << "=== Fonts ==="
|
|
409
|
+
lines << ""
|
|
410
|
+
|
|
411
|
+
info.fonts.each_with_index do |font_info, index|
|
|
412
|
+
# Show font index with offset
|
|
413
|
+
if font_info.collection_offset
|
|
414
|
+
lines << "Font #{index} (offset: #{font_info.collection_offset}):"
|
|
415
|
+
else
|
|
416
|
+
lines << "Font #{index}:"
|
|
417
|
+
end
|
|
418
|
+
lines << ""
|
|
419
|
+
|
|
420
|
+
# Format each font using same structure as brief mode
|
|
421
|
+
font_type_display = format_font_type_display(font_info.font_format, font_info.is_variable)
|
|
422
|
+
add_line(lines, "Font type", font_type_display)
|
|
423
|
+
add_line(lines, "Family", font_info.family_name)
|
|
424
|
+
add_line(lines, "Subfamily", font_info.subfamily_name)
|
|
425
|
+
add_line(lines, "Full name", font_info.full_name)
|
|
426
|
+
add_line(lines, "PostScript name", font_info.postscript_name)
|
|
427
|
+
add_line(lines, "Version", font_info.version)
|
|
428
|
+
add_line(lines, "Vendor ID", font_info.vendor_id)
|
|
429
|
+
add_line(lines, "Font revision", format_float(font_info.font_revision))
|
|
430
|
+
add_line(lines, "Units per em", font_info.units_per_em)
|
|
431
|
+
|
|
432
|
+
# Blank line between fonts (except after last)
|
|
433
|
+
lines << "" unless index == info.num_fonts - 1
|
|
434
|
+
end
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
lines.join("\n")
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
# Format CollectionBriefInfo as human-readable text.
|
|
441
|
+
#
|
|
442
|
+
# @param info [Models::CollectionBriefInfo] Collection brief information to format
|
|
443
|
+
# @return [String] Formatted text with collection header and each font's brief info
|
|
444
|
+
def format_collection_brief_info(info)
|
|
445
|
+
lines = []
|
|
446
|
+
|
|
447
|
+
# Collection header with type and version
|
|
448
|
+
lines << "Collection: #{info.collection_path}"
|
|
449
|
+
|
|
450
|
+
# Format collection type for display with OpenType version
|
|
451
|
+
if info.collection_type
|
|
452
|
+
collection_type_display = case info.collection_type
|
|
453
|
+
when "TTC"
|
|
454
|
+
"TrueType Collection (OpenType 1.4)"
|
|
455
|
+
when "OTC"
|
|
456
|
+
"OpenType Collection (OpenType 1.8)"
|
|
457
|
+
else
|
|
458
|
+
info.collection_type
|
|
459
|
+
end
|
|
460
|
+
lines << "Type: #{collection_type_display}"
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
lines << "Version: #{info.collection_version}" if info.collection_version
|
|
464
|
+
lines << "Fonts: #{info.num_fonts}"
|
|
465
|
+
lines << ""
|
|
466
|
+
|
|
467
|
+
# Each font's brief info
|
|
468
|
+
info.fonts.each_with_index do |font_info, index|
|
|
469
|
+
# Show font index with offset
|
|
470
|
+
if font_info.collection_offset
|
|
471
|
+
lines << "Font #{index} (offset: #{font_info.collection_offset}):"
|
|
472
|
+
else
|
|
473
|
+
lines << "Font #{index}:"
|
|
474
|
+
end
|
|
475
|
+
lines << ""
|
|
476
|
+
|
|
477
|
+
# Format each font using same structure as individual fonts
|
|
478
|
+
font_type_display = format_font_type_display(font_info.font_format, font_info.is_variable)
|
|
479
|
+
add_line(lines, "Font type", font_type_display)
|
|
480
|
+
add_line(lines, "Family", font_info.family_name)
|
|
481
|
+
add_line(lines, "Subfamily", font_info.subfamily_name)
|
|
482
|
+
add_line(lines, "Full name", font_info.full_name)
|
|
483
|
+
add_line(lines, "PostScript name", font_info.postscript_name)
|
|
484
|
+
add_line(lines, "Version", font_info.version)
|
|
485
|
+
add_line(lines, "Vendor ID", font_info.vendor_id)
|
|
486
|
+
add_line(lines, "Font revision", format_float(font_info.font_revision))
|
|
487
|
+
add_line(lines, "Units per em", font_info.units_per_em)
|
|
488
|
+
|
|
489
|
+
# Blank line between fonts (except after last)
|
|
490
|
+
lines << "" unless index == info.num_fonts - 1
|
|
394
491
|
end
|
|
395
492
|
|
|
396
493
|
lines.join("\n")
|