fontisan 0.1.0

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.
Files changed (56) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/.rubocop.yml +13 -0
  4. data/.rubocop_todo.yml +217 -0
  5. data/Gemfile +15 -0
  6. data/LICENSE +24 -0
  7. data/README.adoc +984 -0
  8. data/Rakefile +95 -0
  9. data/exe/fontisan +7 -0
  10. data/fontisan.gemspec +44 -0
  11. data/lib/fontisan/binary/base_record.rb +57 -0
  12. data/lib/fontisan/binary/structures.rb +84 -0
  13. data/lib/fontisan/cli.rb +192 -0
  14. data/lib/fontisan/commands/base_command.rb +82 -0
  15. data/lib/fontisan/commands/dump_table_command.rb +71 -0
  16. data/lib/fontisan/commands/features_command.rb +94 -0
  17. data/lib/fontisan/commands/glyphs_command.rb +50 -0
  18. data/lib/fontisan/commands/info_command.rb +120 -0
  19. data/lib/fontisan/commands/optical_size_command.rb +41 -0
  20. data/lib/fontisan/commands/scripts_command.rb +59 -0
  21. data/lib/fontisan/commands/tables_command.rb +52 -0
  22. data/lib/fontisan/commands/unicode_command.rb +76 -0
  23. data/lib/fontisan/commands/variable_command.rb +61 -0
  24. data/lib/fontisan/config/features.yml +143 -0
  25. data/lib/fontisan/config/scripts.yml +42 -0
  26. data/lib/fontisan/constants.rb +78 -0
  27. data/lib/fontisan/error.rb +15 -0
  28. data/lib/fontisan/font_loader.rb +109 -0
  29. data/lib/fontisan/formatters/text_formatter.rb +314 -0
  30. data/lib/fontisan/models/all_scripts_features_info.rb +21 -0
  31. data/lib/fontisan/models/features_info.rb +42 -0
  32. data/lib/fontisan/models/font_info.rb +99 -0
  33. data/lib/fontisan/models/glyph_info.rb +26 -0
  34. data/lib/fontisan/models/optical_size_info.rb +33 -0
  35. data/lib/fontisan/models/scripts_info.rb +39 -0
  36. data/lib/fontisan/models/table_info.rb +55 -0
  37. data/lib/fontisan/models/unicode_mappings.rb +42 -0
  38. data/lib/fontisan/models/variable_font_info.rb +82 -0
  39. data/lib/fontisan/open_type_collection.rb +97 -0
  40. data/lib/fontisan/open_type_font.rb +292 -0
  41. data/lib/fontisan/parsers/tag.rb +77 -0
  42. data/lib/fontisan/tables/cmap.rb +284 -0
  43. data/lib/fontisan/tables/fvar.rb +157 -0
  44. data/lib/fontisan/tables/gpos.rb +111 -0
  45. data/lib/fontisan/tables/gsub.rb +111 -0
  46. data/lib/fontisan/tables/head.rb +114 -0
  47. data/lib/fontisan/tables/layout_common.rb +73 -0
  48. data/lib/fontisan/tables/name.rb +188 -0
  49. data/lib/fontisan/tables/os2.rb +175 -0
  50. data/lib/fontisan/tables/post.rb +148 -0
  51. data/lib/fontisan/true_type_collection.rb +98 -0
  52. data/lib/fontisan/true_type_font.rb +313 -0
  53. data/lib/fontisan/utilities/checksum_calculator.rb +89 -0
  54. data/lib/fontisan/version.rb +5 -0
  55. data/lib/fontisan.rb +80 -0
  56. metadata +150 -0
@@ -0,0 +1,188 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../binary/base_record"
4
+
5
+ module Fontisan
6
+ module Tables
7
+ # BinData structure for a single name record
8
+ #
9
+ # Represents metadata about a string in the name table,
10
+ # including platform, encoding, language, and offset information.
11
+ class NameRecord < Binary::BaseRecord
12
+ uint16 :platform_id
13
+ uint16 :encoding_id
14
+ uint16 :language_id
15
+ uint16 :name_id
16
+ uint16 :string_length
17
+ uint16 :string_offset
18
+
19
+ # The decoded string value (set after reading from string storage)
20
+ attr_accessor :string
21
+
22
+ # Get the length of the string (for backward compatibility)
23
+ #
24
+ # @return [Integer] String length in bytes
25
+ def length
26
+ string_length
27
+ end
28
+
29
+ # Decode the string data based on platform and encoding
30
+ #
31
+ # @param data [String] Raw binary string data
32
+ def decode_string(data)
33
+ @string = case platform_id
34
+ when Name::PLATFORM_MACINTOSH
35
+ # Platform 1 (Mac): ASCII/MacRoman
36
+ data.dup.force_encoding("ASCII-8BIT").encode("UTF-8",
37
+ invalid: :replace,
38
+ undef: :replace)
39
+ when Name::PLATFORM_WINDOWS
40
+ # Platform 3 (Windows): UTF-16BE
41
+ data.dup.force_encoding("UTF-16BE").encode("UTF-8",
42
+ invalid: :replace,
43
+ undef: :replace)
44
+ when Name::PLATFORM_UNICODE
45
+ # Platform 0 (Unicode): UTF-16BE
46
+ data.dup.force_encoding("UTF-16BE").encode("UTF-8",
47
+ invalid: :replace,
48
+ undef: :replace)
49
+ else
50
+ # Unknown platform: try UTF-8
51
+ data.dup.force_encoding("UTF-8")
52
+ end
53
+ end
54
+ end
55
+
56
+ # BinData structure for the 'name' (Naming Table) table
57
+ #
58
+ # The name table allows multilingual strings to be associated with the font.
59
+ # These strings can represent copyright notices, font names, family names,
60
+ # style names, and other information.
61
+ #
62
+ # Reference: OpenType specification, name table
63
+ #
64
+ # @example Reading a name table
65
+ # data = File.binread("font.ttf", length, name_offset)
66
+ # name = Fontisan::Tables::Name.read(data)
67
+ # puts name.english_name(Fontisan::Tables::Name::FAMILY)
68
+ class Name < Binary::BaseRecord
69
+ # Name ID constants for common name records
70
+ COPYRIGHT = 0
71
+ FAMILY = 1
72
+ SUBFAMILY = 2
73
+ UNIQUE_ID = 3
74
+ FULL_NAME = 4
75
+ VERSION = 5
76
+ POSTSCRIPT_NAME = 6
77
+ TRADEMARK = 7
78
+ MANUFACTURER = 8
79
+ DESIGNER = 9
80
+ DESCRIPTION = 10
81
+ VENDOR_URL = 11
82
+ DESIGNER_URL = 12
83
+ LICENSE_DESCRIPTION = 13
84
+ LICENSE_URL = 14
85
+ PREFERRED_FAMILY = 16
86
+ PREFERRED_SUBFAMILY = 17
87
+ COMPATIBLE_FULL = 18
88
+ SAMPLE_TEXT = 19
89
+ POSTSCRIPT_CID = 20
90
+ WWS_FAMILY = 21
91
+ WWS_SUBFAMILY = 22
92
+
93
+ # Platform IDs
94
+ PLATFORM_UNICODE = 0
95
+ PLATFORM_MACINTOSH = 1
96
+ PLATFORM_WINDOWS = 3
97
+
98
+ # Windows language ID for US English
99
+ WINDOWS_LANGUAGE_EN_US = 0x0409
100
+
101
+ # Mac language ID for English
102
+ MAC_LANGUAGE_ENGLISH = 0
103
+
104
+ uint16 :format
105
+ uint16 :record_count
106
+ uint16 :string_offset
107
+ array :name_records, type: :name_record, initial_length: :record_count
108
+ rest :string_storage
109
+
110
+ # Hook that gets called after all fields are read
111
+ def after_read_hook
112
+ decode_all_strings
113
+ end
114
+
115
+ # Make sure we call our hook after BinData finishes reading
116
+ def do_read(io)
117
+ super
118
+ after_read_hook
119
+ end
120
+
121
+ # Get the count of name records (for backward compatibility)
122
+ #
123
+ # @return [Integer] Number of name records
124
+ def count
125
+ record_count
126
+ end
127
+
128
+ # Find an English name for the given name ID
129
+ #
130
+ # Priority: Platform 3 (Windows) with language 0x0409 (US English)
131
+ # Fallback: Platform 1 (Mac) with language 0
132
+ #
133
+ # @param name_id [Integer] The name ID to search for
134
+ # @return [String, nil] The decoded string or nil if not found
135
+ def english_name(name_id)
136
+ # First try Windows English
137
+ record = name_records.find do |rec|
138
+ rec.name_id == name_id &&
139
+ rec.platform_id == PLATFORM_WINDOWS &&
140
+ rec.language_id == WINDOWS_LANGUAGE_EN_US
141
+ end
142
+
143
+ # Fallback to Mac English
144
+ record ||= name_records.find do |rec|
145
+ rec.name_id == name_id &&
146
+ rec.platform_id == PLATFORM_MACINTOSH &&
147
+ rec.language_id == MAC_LANGUAGE_ENGLISH
148
+ end
149
+
150
+ record&.string
151
+ end
152
+
153
+ # Validate the table
154
+ #
155
+ # @return [Boolean] True if the table is valid
156
+ def valid?
157
+ !format.nil?
158
+ rescue StandardError
159
+ false
160
+ end
161
+
162
+ private
163
+
164
+ # Decode all strings from the string storage area
165
+ def decode_all_strings
166
+ # Get the raw string storage as a plain Ruby binary string
167
+ storage_bytes = string_storage.to_s.b
168
+
169
+ return if storage_bytes.empty?
170
+
171
+ name_records.each do |record|
172
+ # Extract string data from storage using offset and length
173
+ offset = record.string_offset
174
+ length = record.string_length
175
+
176
+ # Validate bounds
177
+ next if offset.nil? || length.nil?
178
+ next if offset + length > storage_bytes.bytesize
179
+ next if length.zero?
180
+
181
+ # Slice the bytes from storage
182
+ string_data = storage_bytes.byteslice(offset, length)
183
+ record.decode_string(string_data) if string_data && !string_data.empty?
184
+ end
185
+ end
186
+ end
187
+ end
188
+ end
@@ -0,0 +1,175 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Tables
5
+ # Parser for the 'OS/2' (OS/2 and Windows Metrics) table
6
+ #
7
+ # The OS/2 table contains OS/2 and Windows-specific metrics that are
8
+ # required by Windows and OS/2. This includes font metrics, character
9
+ # ranges, vendor information, and embedding permissions.
10
+ #
11
+ # The table has evolved through multiple versions (0-5), with newer
12
+ # versions adding additional fields while maintaining backward
13
+ # compatibility.
14
+ #
15
+ # Reference: OpenType specification, OS/2 table
16
+ class Os2 < Binary::BaseRecord
17
+ endian :big
18
+
19
+ # Version 0 fields (all versions have these)
20
+ uint16 :version
21
+ int16 :x_avg_char_width
22
+ uint16 :us_weight_class
23
+ uint16 :us_width_class
24
+ uint16 :fs_type
25
+ int16 :y_subscript_x_size
26
+ int16 :y_subscript_y_size
27
+ int16 :y_subscript_x_offset
28
+ int16 :y_subscript_y_offset
29
+ int16 :y_superscript_x_size
30
+ int16 :y_superscript_y_size
31
+ int16 :y_superscript_x_offset
32
+ int16 :y_superscript_y_offset
33
+ int16 :y_strikeout_size
34
+ int16 :y_strikeout_position
35
+ int16 :s_family_class
36
+
37
+ # PANOSE - 10 bytes
38
+ array :panose, type: :uint8, initial_length: 10
39
+
40
+ # Unicode ranges
41
+ uint32 :ul_unicode_range1
42
+ uint32 :ul_unicode_range2
43
+ uint32 :ul_unicode_range3
44
+ uint32 :ul_unicode_range4
45
+
46
+ # Vendor ID - 4 bytes
47
+ string :ach_vend_id, length: 4
48
+
49
+ # Selection flags and character indices
50
+ uint16 :fs_selection
51
+ uint16 :us_first_char_index
52
+ uint16 :us_last_char_index
53
+ int16 :s_typo_ascender
54
+ int16 :s_typo_descender
55
+ int16 :s_typo_line_gap
56
+ uint16 :us_win_ascent
57
+ uint16 :us_win_descent
58
+
59
+ # Version 1+ fields
60
+ uint32 :ul_code_page_range1, onlyif: -> { version >= 1 }
61
+ uint32 :ul_code_page_range2, onlyif: -> { version >= 1 }
62
+
63
+ # Version 2+ fields
64
+ int16 :sx_height, onlyif: -> { version >= 2 }
65
+ int16 :s_cap_height, onlyif: -> { version >= 2 }
66
+ uint16 :us_default_char, onlyif: -> { version >= 2 }
67
+ uint16 :us_break_char, onlyif: -> { version >= 2 }
68
+ uint16 :us_max_context, onlyif: -> { version >= 2 }
69
+
70
+ # Version 5+ fields
71
+ uint16 :us_lower_optical_point_size, onlyif: -> { version >= 5 }
72
+ uint16 :us_upper_optical_point_size, onlyif: -> { version >= 5 }
73
+
74
+ # Override conditional field accessors to return nil when not present
75
+ # BinData's onlyif fields return default values even when not read,
76
+ # so we need to check the version before accessing them
77
+ def ul_code_page_range1
78
+ return nil unless version >= 1
79
+
80
+ super
81
+ end
82
+
83
+ def ul_code_page_range2
84
+ return nil unless version >= 1
85
+
86
+ super
87
+ end
88
+
89
+ def sx_height
90
+ return nil unless version >= 2
91
+
92
+ super
93
+ end
94
+
95
+ def s_cap_height
96
+ return nil unless version >= 2
97
+
98
+ super
99
+ end
100
+
101
+ def us_default_char
102
+ return nil unless version >= 2
103
+
104
+ super
105
+ end
106
+
107
+ def us_break_char
108
+ return nil unless version >= 2
109
+
110
+ super
111
+ end
112
+
113
+ def us_max_context
114
+ return nil unless version >= 2
115
+
116
+ super
117
+ end
118
+
119
+ def us_lower_optical_point_size
120
+ return nil unless version >= 5
121
+
122
+ super
123
+ end
124
+
125
+ def us_upper_optical_point_size
126
+ return nil unless version >= 5
127
+
128
+ super
129
+ end
130
+
131
+ # Get the vendor ID as a trimmed string
132
+ #
133
+ # @return [String] The vendor ID with trailing spaces and nulls removed
134
+ def vendor_id
135
+ return "" unless ach_vend_id
136
+
137
+ ach_vend_id.gsub(/[\x00\s]+$/, "")
138
+ end
139
+
140
+ # Get the embedding type flags
141
+ #
142
+ # @return [Integer] The fs_type value (embedding permissions)
143
+ def type_flags
144
+ fs_type
145
+ end
146
+
147
+ # Check if optical point size information is available
148
+ #
149
+ # @return [Boolean] True if version >= 5
150
+ def has_optical_point_size?
151
+ version >= 5
152
+ end
153
+
154
+ # Get the lower optical point size
155
+ #
156
+ # @return [Float, nil] The lower optical point size in points, or nil
157
+ # if not available
158
+ def lower_optical_point_size
159
+ return nil unless has_optical_point_size?
160
+
161
+ us_lower_optical_point_size / 20.0
162
+ end
163
+
164
+ # Get the upper optical point size
165
+ #
166
+ # @return [Float, nil] The upper optical point size in points, or nil
167
+ # if not available
168
+ def upper_optical_point_size
169
+ return nil unless has_optical_point_size?
170
+
171
+ us_upper_optical_point_size / 20.0
172
+ end
173
+ end
174
+ end
175
+ end
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Tables
5
+ # Parser for the 'post' (PostScript) table
6
+ #
7
+ # The post table contains PostScript information, primarily glyph names.
8
+ # Different versions exist (1.0, 2.0, 2.5, 3.0, 4.0) with varying
9
+ # glyph name storage strategies.
10
+ #
11
+ # Reference: OpenType specification, post table
12
+ class Post < Binary::BaseRecord
13
+ # Standard Mac glyph names for version 1.0 (258 glyphs)
14
+ # rubocop:disable Metrics/CollectionLiteralLength
15
+ STANDARD_NAMES = %w[
16
+ .notdef .null nonmarkingreturn space exclam quotedbl numbersign
17
+ dollar percent ampersand quotesingle parenleft parenright asterisk
18
+ plus comma hyphen period slash zero one two three four five six
19
+ seven eight nine colon semicolon less equal greater question at
20
+ A B C D E F G H I J K L M N O P Q R S T U V W X Y Z
21
+ bracketleft backslash bracketright asciicircum underscore grave
22
+ a b c d e f g h i j k l m n o p q r s t u v w x y z
23
+ braceleft bar braceright asciitilde Adieresis Aring Ccedilla
24
+ Eacute Ntilde Odieresis Udieresis aacute agrave acircumflex
25
+ adieresis atilde aring ccedilla eacute egrave ecircumflex
26
+ edieresis iacute igrave icircumflex idieresis ntilde oacute
27
+ ograve ocircumflex odieresis otilde uacute ugrave ucircumflex
28
+ udieresis dagger degree cent sterling section bullet paragraph
29
+ germandbls registered copyright trademark acute dieresis notequal
30
+ AE Oslash infinity plusminus lessequal greaterequal yen mu
31
+ partialdiff summation product pi integral ordfeminine ordmasculine
32
+ Omega ae oslash questiondown exclamdown logicalnot radical florin
33
+ approxequal Delta guillemotleft guillemotright ellipsis
34
+ nonbreakingspace Agrave Atilde Otilde OE oe endash emdash
35
+ quotedblleft quotedblright quoteleft quoteright divide lozenge
36
+ ydieresis Ydieresis fraction currency guilsinglleft guilsinglright
37
+ fi fl daggerdbl periodcentered quotesinglbase quotedblbase
38
+ perthousand Acircumflex Ecircumflex Aacute Edieresis Egrave
39
+ Iacute Icircumflex Idieresis Igrave Oacute Ocircumflex apple
40
+ Ograve Uacute Ucircumflex Ugrave dotlessi circumflex tilde
41
+ macron breve dotaccent ring cedilla hungarumlaut ogonek caron
42
+ Lslash lslash Scaron scaron Zcaron zcaron brokenbar Eth
43
+ eth Yacute yacute Thorn thorn minus multiply onesuperior
44
+ twosuperior threesuperior onehalf onequarter threequarters franc
45
+ Gbreve gbreve Idotaccent Scedilla scedilla Cacute cacute Ccaron
46
+ ccaron dcroat
47
+ ].freeze
48
+ # rubocop:enable Metrics/CollectionLiteralLength
49
+
50
+ # Version 2.0 as Fixed 16.16 constant
51
+ VERSION_2_0_RAW = 131_072 # 2.0 * 65536
52
+
53
+ endian :big
54
+
55
+ int32 :version_raw
56
+ int32 :italic_angle_raw
57
+ int16 :underline_position
58
+ int16 :underline_thickness
59
+ uint32 :is_fixed_pitch
60
+ uint32 :min_mem_type42
61
+ uint32 :max_mem_type42
62
+ uint32 :min_mem_type1
63
+ uint32 :max_mem_type1
64
+
65
+ # Version 2.0 specific fields
66
+ uint16 :num_glyphs_v2, onlyif: -> { version_raw == VERSION_2_0_RAW }
67
+ rest :remaining_data
68
+
69
+ # Get version as float (Fixed 16.16 format)
70
+ def version
71
+ fixed_to_float(version_raw)
72
+ end
73
+
74
+ # Get italic angle as float (Fixed 16.16 format)
75
+ def italic_angle
76
+ fixed_to_float(italic_angle_raw)
77
+ end
78
+
79
+ # Get glyph names based on version
80
+ #
81
+ # @return [Array<String>] array of glyph names
82
+ def glyph_names
83
+ @glyph_names ||= case version
84
+ when 1.0
85
+ STANDARD_NAMES.dup
86
+ when 2.0
87
+ parse_version_2_names
88
+ else
89
+ []
90
+ end
91
+ end
92
+
93
+ private
94
+
95
+ # Parse version 2.0 glyph names
96
+ #
97
+ # Version 2.0 uses a combination of standard Mac names (indices 0-257)
98
+ # and custom names (indices >= 258) stored as Pascal strings.
99
+ # rubocop:disable Metrics/PerceivedComplexity
100
+ def parse_version_2_names
101
+ return [] unless version_raw == VERSION_2_0_RAW
102
+ return [] if remaining_data.empty?
103
+
104
+ data = remaining_data
105
+ offset = 0
106
+
107
+ # Read glyph name indices (uint16 array)
108
+ indices = []
109
+ num_glyphs_v2.times do
110
+ break if offset + 2 > data.length
111
+
112
+ index = data[offset, 2].unpack1("n")
113
+ indices << index
114
+ offset += 2
115
+ end
116
+
117
+ # Read Pascal strings for custom names (index >= 258)
118
+ custom_names = []
119
+ while offset < data.length
120
+ length = data[offset].ord
121
+ offset += 1
122
+ break if length.zero? || offset + length > data.length
123
+
124
+ name = data[offset, length]
125
+ offset += length
126
+ custom_names << name
127
+ end
128
+
129
+ # Map indices to names
130
+ indices.map do |index|
131
+ if index < 258
132
+ # Standard Mac name
133
+ STANDARD_NAMES[index]
134
+ else
135
+ # Custom name
136
+ custom_index = index - 258
137
+ if custom_index < custom_names.length
138
+ custom_names[custom_index]
139
+ else
140
+ ".notdef"
141
+ end
142
+ end
143
+ end
144
+ end
145
+ # rubocop:enable Metrics/PerceivedComplexity
146
+ end
147
+ end
148
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bindata"
4
+ require_relative "constants"
5
+
6
+ module Fontisan
7
+ # TrueType Collection domain object using BinData
8
+ #
9
+ # Represents a complete TrueType Collection file using BinData's declarative
10
+ # DSL for binary structure definition. The structure definition IS the
11
+ # documentation, and BinData handles all low-level reading/writing.
12
+ #
13
+ # @example Reading and extracting fonts
14
+ # File.open("Helvetica.ttc", "rb") do |io|
15
+ # ttc = TrueTypeCollection.read(io)
16
+ # puts ttc.num_fonts # => 6
17
+ # fonts = ttc.extract_fonts(io) # => [TrueTypeFont, TrueTypeFont, ...]
18
+ # end
19
+ class TrueTypeCollection < BinData::Record
20
+ endian :big
21
+
22
+ string :tag, length: 4, assert: "ttcf"
23
+ uint16 :major_version
24
+ uint16 :minor_version
25
+ uint32 :num_fonts
26
+ array :font_offsets, type: :uint32, initial_length: :num_fonts
27
+
28
+ # Read TrueType Collection from a file
29
+ #
30
+ # @param path [String] Path to the TTC file
31
+ # @return [TrueTypeCollection] A new instance
32
+ # @raise [ArgumentError] if path is nil or empty
33
+ # @raise [Errno::ENOENT] if file does not exist
34
+ # @raise [RuntimeError] if file format is invalid
35
+ def self.from_file(path)
36
+ if path.nil? || path.to_s.empty?
37
+ raise ArgumentError,
38
+ "path cannot be nil or empty"
39
+ end
40
+ raise Errno::ENOENT, "File not found: #{path}" unless File.exist?(path)
41
+
42
+ File.open(path, "rb") { |io| read(io) }
43
+ rescue BinData::ValidityError => e
44
+ raise "Invalid TTC file: #{e.message}"
45
+ rescue EOFError => e
46
+ raise "Invalid TTC file: unexpected end of file - #{e.message}"
47
+ end
48
+
49
+ # Extract fonts as TrueTypeFont objects
50
+ #
51
+ # Reads each font from the TTC file and returns them as TrueTypeFont objects.
52
+ #
53
+ # @param io [IO] Open file handle to read fonts from
54
+ # @return [Array<TrueTypeFont>] Array of font objects
55
+ def extract_fonts(io)
56
+ require_relative "true_type_font"
57
+
58
+ font_offsets.map do |offset|
59
+ TrueTypeFont.from_ttc(io, offset)
60
+ end
61
+ end
62
+
63
+ # Get a single font from the collection (Fontisan extension)
64
+ #
65
+ # @param index [Integer] Index of the font (0-based)
66
+ # @param io [IO] Open file handle
67
+ # @return [TrueTypeFont, nil] Font object or nil if index out of range
68
+ def font(index, io)
69
+ return nil if index >= num_fonts
70
+
71
+ require_relative "true_type_font"
72
+ TrueTypeFont.from_ttc(io, font_offsets[index])
73
+ end
74
+
75
+ # Get font count (Fontisan extension)
76
+ #
77
+ # @return [Integer] Number of fonts in collection
78
+ def font_count
79
+ num_fonts
80
+ end
81
+
82
+ # Validate format correctness
83
+ #
84
+ # @return [Boolean] true if the format is valid, false otherwise
85
+ def valid?
86
+ tag == Constants::TTC_TAG && num_fonts.positive? && font_offsets.length == num_fonts
87
+ rescue StandardError
88
+ false
89
+ end
90
+
91
+ # Get the TTC version as a single integer
92
+ #
93
+ # @return [Integer] Version number (e.g., 0x00010000 for version 1.0)
94
+ def version
95
+ (major_version << 16) | minor_version
96
+ end
97
+ end
98
+ end