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,42 @@
1
+ # OpenType script tags and descriptions
2
+ # Reference: OpenType specification
3
+ arab: Arabic
4
+ armn: Armenian
5
+ beng: Bengali
6
+ bopo: Bopomofo
7
+ brai: Braille
8
+ byzm: Byzantine Musical Symbols
9
+ cans: Canadian Aboriginal Syllabics
10
+ cher: Cherokee
11
+ cyrl: Cyrillic
12
+ DFLT: Default
13
+ deva: Devanagari
14
+ ethi: Ethiopic
15
+ geor: Georgian
16
+ goth: Gothic
17
+ grek: Greek
18
+ gujr: Gujarati
19
+ guru: Gurmukhi
20
+ hang: Hangul
21
+ hani: Han Ideographs
22
+ hebr: Hebrew
23
+ hira: Hiragana
24
+ kana: Katakana
25
+ khmr: Khmer
26
+ knda: Kannada
27
+ lao: Lao
28
+ latn: Latin
29
+ math: Mathematical Alphanumeric Symbols
30
+ mlym: Malayalam
31
+ mong: Mongolian
32
+ mymr: Myanmar
33
+ ogam: Ogham
34
+ orya: Oriya
35
+ runr: Runic
36
+ sinh: Sinhala
37
+ syrc: Syriac
38
+ taml: Tamil
39
+ telu: Telugu
40
+ thaa: Thaana
41
+ thai: Thai
42
+ tibt: Tibetan
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ # Constants module containing immutable constant definitions for font file operations.
5
+ #
6
+ # This module defines all magic numbers, version identifiers, and file format constants
7
+ # used throughout the fontisan gem. These values are based on the TrueType Collection,
8
+ # TrueType Font, and OpenType Font specifications.
9
+ module Constants
10
+ # TrueType Collection file signature tag.
11
+ # All valid TTC files must begin with this 4-byte tag.
12
+ TTC_TAG = "ttcf"
13
+
14
+ # TrueType Collection Version 1.0 identifier.
15
+ # Represents the original TTC format version.
16
+ TTC_VERSION_1 = 0x00010000
17
+
18
+ # TrueType Collection Version 2.0 identifier.
19
+ # Represents the extended TTC format with digital signature support.
20
+ TTC_VERSION_2 = 0x00020000
21
+
22
+ # SFNT version for TrueType fonts
23
+ SFNT_VERSION_TRUETYPE = 0x00010000
24
+
25
+ # SFNT version for OpenType fonts with CFF outlines ('OTTO')
26
+ SFNT_VERSION_OTTO = 0x4F54544F
27
+
28
+ # Head table tag identifier.
29
+ # The 'head' table contains global font header information including
30
+ # the checksum adjustment field.
31
+ HEAD_TAG = "head"
32
+
33
+ # Name table tag identifier
34
+ NAME_TAG = "name"
35
+
36
+ # OS/2 table tag identifier
37
+ OS2_TAG = "OS/2"
38
+
39
+ # Post table tag identifier
40
+ POST_TAG = "post"
41
+
42
+ # Cmap table tag identifier
43
+ CMAP_TAG = "cmap"
44
+
45
+ # Glyf table tag identifier (TrueType glyph data)
46
+ GLYF_TAG = "glyf"
47
+
48
+ # Loca table tag identifier (TrueType glyph index to location)
49
+ LOCA_TAG = "loca"
50
+
51
+ # CFF table tag identifier (OpenType CFF glyph data)
52
+ CFF_TAG = "CFF "
53
+
54
+ # GSUB table tag identifier (Glyph Substitution)
55
+ GSUB_TAG = "GSUB"
56
+
57
+ # GPOS table tag identifier (Glyph Positioning)
58
+ GPOS_TAG = "GPOS"
59
+
60
+ # Fvar table tag identifier (Font Variations)
61
+ FVAR_TAG = "fvar"
62
+
63
+ # Magic number used for font file checksum adjustment calculation.
64
+ # This constant is used in conjunction with the file checksum to compute
65
+ # the checksumAdjustment value stored in the 'head' table.
66
+ # Formula: checksumAdjustment = CHECKSUM_ADJUSTMENT_MAGIC - file_checksum
67
+ CHECKSUM_ADJUSTMENT_MAGIC = 0xB1B0AFBA
68
+
69
+ # Supported TTC version numbers.
70
+ # An array of valid version identifiers for TrueType Collection files.
71
+ SUPPORTED_VERSIONS = [TTC_VERSION_1, TTC_VERSION_2].freeze
72
+
73
+ # Table data alignment boundary in bytes.
74
+ # All table data in TTF files must be aligned to 4-byte boundaries,
75
+ # with padding added as necessary.
76
+ TABLE_ALIGNMENT = 4
77
+ end
78
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ class Error < StandardError; end
5
+
6
+ class InvalidFontError < Error; end
7
+
8
+ class UnsupportedFormatError < Error; end
9
+
10
+ class CorruptedTableError < Error; end
11
+
12
+ class MissingTableError < Error; end
13
+
14
+ class ParseError < Error; end
15
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "constants"
4
+ require_relative "true_type_font"
5
+ require_relative "open_type_font"
6
+ require_relative "true_type_collection"
7
+ require_relative "open_type_collection"
8
+ require_relative "error"
9
+
10
+ module Fontisan
11
+ # FontLoader provides unified font loading with automatic format detection.
12
+ #
13
+ # This class is the primary entry point for loading fonts in Fontisan.
14
+ # It automatically detects the font format and returns the appropriate
15
+ # domain object (TrueTypeFont, OpenTypeFont, TrueTypeCollection, or OpenTypeCollection).
16
+ #
17
+ # @example Load any font type
18
+ # font = FontLoader.load("font.ttf") # => TrueTypeFont
19
+ # font = FontLoader.load("font.otf") # => OpenTypeFont
20
+ # font = FontLoader.load("fonts.ttc") # => TrueTypeFont (first in collection)
21
+ # font = FontLoader.load("fonts.ttc", font_index: 2) # => TrueTypeFont (third in collection)
22
+ class FontLoader
23
+ # Load a font from file with automatic format detection
24
+ #
25
+ # @param path [String] Path to the font file
26
+ # @param font_index [Integer] Index of font in collection (0-based, default: 0)
27
+ # @return [TrueTypeFont, OpenTypeFont] The loaded font object
28
+ # @raise [Errno::ENOENT] if file does not exist
29
+ # @raise [UnsupportedFormatError] for WOFF/WOFF2 or other unsupported formats
30
+ # @raise [InvalidFontError] for corrupted or unknown formats
31
+ def self.load(path, font_index: 0)
32
+ raise Errno::ENOENT, "File not found: #{path}" unless File.exist?(path)
33
+
34
+ File.open(path, "rb") do |io|
35
+ signature = io.read(4)
36
+ io.rewind
37
+
38
+ case signature
39
+ when Constants::TTC_TAG
40
+ load_from_collection(io, path, font_index)
41
+ when pack_uint32(Constants::SFNT_VERSION_TRUETYPE)
42
+ TrueTypeFont.from_file(path)
43
+ when "OTTO"
44
+ OpenTypeFont.from_file(path)
45
+ when "wOFF"
46
+ raise UnsupportedFormatError,
47
+ "Unsupported font format: WOFF. Fontisan currently supports TTF, OTF, TTC, and OTC files."
48
+ when "wOF2"
49
+ raise UnsupportedFormatError,
50
+ "Unsupported font format: WOFF2. Fontisan currently supports TTF, OTF, TTC, and OTC files."
51
+ else
52
+ raise InvalidFontError,
53
+ "Unknown font format. Expected TTF, OTF, TTC, or OTC file."
54
+ end
55
+ end
56
+ end
57
+
58
+ # Load from a collection file (TTC or OTC)
59
+ #
60
+ # @param io [IO] Open file handle
61
+ # @param path [String] Path to the collection file
62
+ # @param font_index [Integer] Index of font to extract
63
+ # @return [TrueTypeFont, OpenTypeFont] The loaded font object
64
+ # @raise [InvalidFontError] if collection type cannot be determined
65
+ def self.load_from_collection(io, path, font_index)
66
+ # Read collection header to get font offsets
67
+ io.seek(12) # Skip tag (4) + major_version (2) + minor_version (2) + num_fonts marker (4)
68
+ num_fonts = io.read(4).unpack1("N")
69
+
70
+ if font_index >= num_fonts
71
+ raise InvalidFontError,
72
+ "Font index #{font_index} out of range (collection has #{num_fonts} fonts)"
73
+ end
74
+
75
+ # Read first offset to detect collection type
76
+ first_offset = io.read(4).unpack1("N")
77
+
78
+ # Peek at first font's sfnt_version to determine TTC vs OTC
79
+ io.seek(first_offset)
80
+ sfnt_version = io.read(4).unpack1("N")
81
+ io.rewind
82
+
83
+ case sfnt_version
84
+ when Constants::SFNT_VERSION_TRUETYPE
85
+ # TrueType Collection
86
+ ttc = TrueTypeCollection.from_file(path)
87
+ File.open(path, "rb") { |f| ttc.font(font_index, f) }
88
+ when Constants::SFNT_VERSION_OTTO
89
+ # OpenType Collection
90
+ otc = OpenTypeCollection.from_file(path)
91
+ File.open(path, "rb") { |f| otc.font(font_index, f) }
92
+ else
93
+ raise InvalidFontError,
94
+ "Unknown font type in collection (sfnt version: 0x#{sfnt_version.to_s(16)})"
95
+ end
96
+ end
97
+
98
+ # Pack uint32 value to big-endian bytes
99
+ #
100
+ # @param value [Integer] The uint32 value
101
+ # @return [String] 4-byte binary string
102
+ # @api private
103
+ def self.pack_uint32(value)
104
+ [value].pack("N")
105
+ end
106
+
107
+ private_class_method :load_from_collection, :pack_uint32
108
+ end
109
+ end
@@ -0,0 +1,314 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Formatters
5
+ # TextFormatter formats model objects into human-readable text output.
6
+ #
7
+ # This formatter handles Models::FontInfo and Models::TableInfo objects,
8
+ # presenting them with proper alignment and spacing for terminal display.
9
+ #
10
+ # @example Format font information
11
+ # formatter = TextFormatter.new
12
+ # text = formatter.format(font_info)
13
+ # puts text
14
+ class TextFormatter
15
+ # Format a model object into human-readable text.
16
+ #
17
+ # @param model [Object] The model to format (FontInfo, TableInfo, etc.)
18
+ # @return [String] Formatted text representation
19
+ def format(model)
20
+ case model
21
+ when Models::FontInfo
22
+ format_font_info(model)
23
+ when Models::TableInfo
24
+ format_table_info(model)
25
+ when Models::GlyphInfo
26
+ format_glyph_info(model)
27
+ when Models::UnicodeMappings
28
+ format_unicode_mappings(model)
29
+ when Models::VariableFontInfo
30
+ format_variable_font_info(model)
31
+ when Models::OpticalSizeInfo
32
+ format_optical_size_info(model)
33
+ when Models::ScriptsInfo
34
+ format_scripts_info(model)
35
+ when Models::AllScriptsFeaturesInfo
36
+ format_all_scripts_features_info(model)
37
+ when Models::FeaturesInfo
38
+ format_features_info(model)
39
+ else
40
+ model.to_s
41
+ end
42
+ end
43
+
44
+ private
45
+
46
+ # Format FontInfo as human-readable text.
47
+ #
48
+ # @param info [Models::FontInfo] Font information to format
49
+ # @return [String] Formatted text with aligned labels and values
50
+ def format_font_info(info)
51
+ lines = []
52
+
53
+ # Font type should be first (formatted for display)
54
+ font_type_display = format_font_type_display(info.font_format,
55
+ info.is_variable)
56
+ add_line(lines, "Font type", font_type_display)
57
+
58
+ add_line(lines, "Family", info.family_name)
59
+ add_line(lines, "Subfamily", info.subfamily_name)
60
+ add_line(lines, "Full name", info.full_name)
61
+ add_line(lines, "PostScript name", info.postscript_name)
62
+ add_line(lines, "PostScript CID name", info.postscript_cid_name)
63
+ add_line(lines, "Preferred family", info.preferred_family)
64
+ add_line(lines, "Preferred subfamily", info.preferred_subfamily)
65
+ add_line(lines, "Mac font menu name", info.mac_font_menu_name)
66
+ add_line(lines, "Version", info.version)
67
+ add_line(lines, "Unique ID", info.unique_id)
68
+ add_line(lines, "Description", info.description)
69
+ add_line(lines, "Designer", info.designer)
70
+ add_line(lines, "Designer URL", info.designer_url)
71
+ add_line(lines, "Manufacturer", info.manufacturer)
72
+ add_line(lines, "Vendor URL", info.vendor_url)
73
+ add_line(lines, "Vendor ID", info.vendor_id)
74
+ add_line(lines, "Trademark", info.trademark)
75
+ add_line(lines, "Copyright", info.copyright)
76
+ add_line(lines, "License Description", info.license_description)
77
+ add_line(lines, "License URL", info.license_url)
78
+ add_line(lines, "Sample text", info.sample_text)
79
+ add_line(lines, "Font revision", format_float(info.font_revision))
80
+ add_line(lines, "Permissions", info.permissions)
81
+ add_line(lines, "Units per em", info.units_per_em)
82
+
83
+ lines.join("\n")
84
+ end
85
+
86
+ # Format TableInfo as human-readable text.
87
+ #
88
+ # @param info [Models::TableInfo] Table information to format
89
+ # @return [String] Formatted text with table directory listing
90
+ def format_table_info(info)
91
+ lines = []
92
+ lines << "SFNT Version: #{info.sfnt_version}"
93
+ lines << "Number of tables: #{info.num_tables}"
94
+ lines << ""
95
+ lines << "Tables:"
96
+
97
+ # Find max tag length for alignment
98
+ max_tag_len = info.tables.map { |t| t.tag.length }.max || 4
99
+
100
+ info.tables.each do |table|
101
+ tag = table.tag.ljust(max_tag_len)
102
+ lines << Kernel.format(" %<tag>s %<length>10d bytes (offset: %<offset>d, checksum: 0x%<checksum>08X)",
103
+ tag: tag, length: table.length, offset: table.offset, checksum: table.checksum)
104
+ end
105
+
106
+ lines.join("\n")
107
+ end
108
+
109
+ # Format GlyphInfo as human-readable text.
110
+ #
111
+ # @param info [Models::GlyphInfo] Glyph information to format
112
+ # @return [String] Formatted text with glyph names
113
+ def format_glyph_info(info)
114
+ lines = []
115
+
116
+ if info.glyph_names.empty?
117
+ lines << "No glyph name information available"
118
+ lines << "Source: #{info.source}"
119
+ else
120
+ lines << "Glyph count: #{info.glyph_count}"
121
+ lines << "Source: #{info.source}"
122
+ lines << ""
123
+ lines << "Glyph names:"
124
+
125
+ info.glyph_names.each_with_index do |name, index|
126
+ lines << Kernel.format(" %5d %s", index, name)
127
+ end
128
+ end
129
+
130
+ lines.join("\n")
131
+ end
132
+
133
+ # Format UnicodeMappings as human-readable text.
134
+ #
135
+ # @param mappings [Models::UnicodeMappings] Unicode mappings to format
136
+ # @return [String] Formatted text with Unicode to glyph mappings
137
+ def format_unicode_mappings(mappings)
138
+ lines = []
139
+
140
+ if mappings.mappings.empty?
141
+ lines << "No Unicode mappings available"
142
+ else
143
+ lines << "Unicode mappings: #{mappings.count}"
144
+ lines << ""
145
+
146
+ mappings.mappings.each do |mapping|
147
+ lines << if mapping.glyph_name
148
+ "#{mapping.codepoint} glyph #{mapping.glyph_index} #{mapping.glyph_name}"
149
+ else
150
+ "#{mapping.codepoint} glyph #{mapping.glyph_index}"
151
+ end
152
+ end
153
+ end
154
+
155
+ lines.join("\n")
156
+ end
157
+
158
+ # Format VariableFontInfo as human-readable text.
159
+ #
160
+ # @param info [Models::VariableFontInfo] Variable font information to format
161
+ # @return [String] Formatted text with axes and instances
162
+ def format_variable_font_info(info)
163
+ lines = []
164
+
165
+ unless info.is_variable
166
+ lines << "Not a variable font"
167
+ return lines.join("\n")
168
+ end
169
+
170
+ info.axes.each_with_index do |axis, i|
171
+ lines << "Axis #{i}: #{axis.tag}"
172
+ lines << "Axis #{i} name: #{axis.name}" if axis.name
173
+ lines << "Axis #{i} range: #{format_float(axis.min_value)} #{format_float(axis.max_value)}"
174
+ lines << "Axis #{i} default: #{format_float(axis.default_value)}"
175
+ end
176
+
177
+ info.instances.each_with_index do |instance, i|
178
+ lines << "Instance #{i} name: #{instance.name}"
179
+ coordinates = instance.coordinates.map do |c|
180
+ format_float(c)
181
+ end.join(" ")
182
+ lines << "Instance #{i} position: #{coordinates}"
183
+ end
184
+
185
+ lines.join("\n")
186
+ end
187
+
188
+ # Format OpticalSizeInfo as human-readable text.
189
+ #
190
+ # @param info [Models::OpticalSizeInfo] Optical size information to format
191
+ # @return [String] Formatted text with optical size range
192
+ def format_optical_size_info(info)
193
+ return "No optical size information" unless info.has_optical_size
194
+
195
+ "Size range: [#{format_float(info.lower_point_size)}, #{format_float(info.upper_point_size)}) pt (source: #{info.source})"
196
+ end
197
+
198
+ # Format ScriptsInfo as human-readable text.
199
+ #
200
+ # @param info [Models::ScriptsInfo] Scripts information to format
201
+ # @return [String] Formatted text with script tags and descriptions
202
+ def format_scripts_info(info)
203
+ lines = []
204
+
205
+ if info.scripts.empty?
206
+ lines << "No scripts found"
207
+ else
208
+ lines << "Script count: #{info.script_count}"
209
+ lines << ""
210
+
211
+ info.scripts.each do |script|
212
+ lines << "#{script.tag} #{script.description}"
213
+ end
214
+ end
215
+
216
+ lines.join("\n")
217
+ end
218
+
219
+ # Format AllScriptsFeaturesInfo as human-readable text.
220
+ #
221
+ # @param info [Models::AllScriptsFeaturesInfo] All scripts features information to format
222
+ # @return [String] Formatted text with features for all scripts
223
+ def format_all_scripts_features_info(info)
224
+ lines = []
225
+
226
+ info.scripts_features.each_with_index do |script_features, index|
227
+ lines << "" if index.positive? # Add blank line between scripts
228
+ lines << "Script: #{script_features.script}"
229
+ lines << "Feature count: #{script_features.feature_count}"
230
+ lines << ""
231
+
232
+ if script_features.features.empty?
233
+ lines << " No features found"
234
+ else
235
+ script_features.features.each do |feature|
236
+ lines << " #{feature.tag} #{feature.description}"
237
+ end
238
+ end
239
+ end
240
+
241
+ lines.join("\n")
242
+ end
243
+
244
+ # Format FeaturesInfo as human-readable text.
245
+ #
246
+ # @param info [Models::FeaturesInfo] Features information to format
247
+ # @return [String] Formatted text with feature tags and descriptions
248
+ def format_features_info(info)
249
+ lines = []
250
+
251
+ if info.features.empty?
252
+ lines << "No features found for script '#{info.script}'"
253
+ else
254
+ lines << "Script: #{info.script}"
255
+ lines << "Feature count: #{info.feature_count}"
256
+ lines << ""
257
+
258
+ info.features.each do |feature|
259
+ lines << "#{feature.tag} #{feature.description}"
260
+ end
261
+ end
262
+
263
+ lines.join("\n")
264
+ end
265
+
266
+ # Add a formatted line to the output if the value is present.
267
+ #
268
+ # @param lines [Array<String>] Output lines array
269
+ # @param label [String] Field label
270
+ # @param value [Object] Field value (skipped if nil or empty string)
271
+ def add_line(lines, label, value)
272
+ return if value.nil? || (value.is_a?(String) && value.empty?)
273
+
274
+ formatted_label = "#{label}:".ljust(25)
275
+ lines << "#{formatted_label} #{value}"
276
+ end
277
+
278
+ # Format a float value for display.
279
+ #
280
+ # @param value [Float, nil] Float value to format
281
+ # @return [String, nil] Formatted float or nil if input is nil
282
+ def format_float(value)
283
+ return nil if value.nil?
284
+
285
+ # Format to 5 decimal places, remove trailing zeros
286
+ formatted = Kernel.format("%<value>.5f", value: value)
287
+ formatted.sub(/\.?0+$/, "")
288
+ end
289
+
290
+ # Format font type for human-readable display.
291
+ #
292
+ # @param font_format [String] Enumerated font format code
293
+ # @param is_variable [Boolean] Whether font is variable
294
+ # @return [String, nil] Formatted font type or nil if font_format is nil
295
+ def format_font_type_display(font_format, is_variable)
296
+ return nil if font_format.nil?
297
+
298
+ type = case font_format
299
+ when "truetype"
300
+ "TrueType"
301
+ when "cff"
302
+ "OpenType (CFF)"
303
+ when "unknown"
304
+ "Unknown"
305
+ else
306
+ font_format
307
+ end
308
+
309
+ type += " (Variable)" if is_variable
310
+ type
311
+ end
312
+ end
313
+ end
314
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "lutaml/model"
4
+ require_relative "features_info"
5
+
6
+ module Fontisan
7
+ module Models
8
+ # Represents features information for all scripts from GSUB/GPOS tables
9
+ class AllScriptsFeaturesInfo < Lutaml::Model::Serializable
10
+ attribute :scripts_features, FeaturesInfo, collection: true
11
+
12
+ json do
13
+ map "scripts_features", to: :scripts_features
14
+ end
15
+
16
+ yaml do
17
+ map "scripts_features", to: :scripts_features
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "lutaml/model"
4
+
5
+ module Fontisan
6
+ module Models
7
+ # Represents a single feature record
8
+ class FeatureRecord < Lutaml::Model::Serializable
9
+ attribute :tag, :string
10
+ attribute :description, :string
11
+
12
+ json do
13
+ map "tag", to: :tag
14
+ map "description", to: :description
15
+ end
16
+
17
+ yaml do
18
+ map "tag", to: :tag
19
+ map "description", to: :description
20
+ end
21
+ end
22
+
23
+ # Represents features information from GSUB/GPOS tables
24
+ class FeaturesInfo < Lutaml::Model::Serializable
25
+ attribute :script, :string
26
+ attribute :feature_count, :integer
27
+ attribute :features, FeatureRecord, collection: true
28
+
29
+ json do
30
+ map "script", to: :script
31
+ map "feature_count", to: :feature_count
32
+ map "features", to: :features
33
+ end
34
+
35
+ yaml do
36
+ map "script", to: :script
37
+ map "feature_count", to: :feature_count
38
+ map "features", to: :features
39
+ end
40
+ end
41
+ end
42
+ end