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,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "stringio"
4
+ require_relative "../binary/base_record"
5
+
6
+ module Fontisan
7
+ module Tables
8
+ # BinData structure for variation axis record
9
+ #
10
+ # Each axis defines a design dimension along which the font can vary,
11
+ # such as weight (wght), width (wdth), italic (ital), or slant (slnt).
12
+ class VariationAxisRecord < Binary::BaseRecord
13
+ string :axis_tag, length: 4
14
+ int32 :min_value_raw
15
+ int32 :default_value_raw
16
+ int32 :max_value_raw
17
+ uint16 :flags
18
+ uint16 :axis_name_id
19
+
20
+ # Convert minimum value from fixed-point to float
21
+ #
22
+ # @return [Float] Minimum value for this axis
23
+ def min_value
24
+ fixed_to_float(min_value_raw)
25
+ end
26
+
27
+ # Convert default value from fixed-point to float
28
+ #
29
+ # @return [Float] Default value for this axis
30
+ def default_value
31
+ fixed_to_float(default_value_raw)
32
+ end
33
+
34
+ # Convert maximum value from fixed-point to float
35
+ #
36
+ # @return [Float] Maximum value for this axis
37
+ def max_value
38
+ fixed_to_float(max_value_raw)
39
+ end
40
+ end
41
+
42
+ # BinData structure for instance record
43
+ #
44
+ # Each instance defines a predefined combination of axis values,
45
+ # representing a named style/weight/width combination.
46
+ class InstanceRecord < Binary::BaseRecord
47
+ uint16 :subfamily_name_id
48
+ uint16 :flags
49
+ # Coordinates are read based on axis_count from parent fvar table
50
+ # This needs to be handled by the parent
51
+
52
+ # Get the instance name ID
53
+ #
54
+ # @return [Integer] Name table ID for this instance
55
+ def name_id
56
+ subfamily_name_id
57
+ end
58
+ end
59
+
60
+ # Parser for the 'fvar' (Font Variations) table
61
+ #
62
+ # The fvar table contains information about variation axes and named
63
+ # instances for variable fonts. This table is present only in variable
64
+ # fonts (OpenType Font Variations).
65
+ #
66
+ # Reference: OpenType specification, fvar table
67
+ #
68
+ # @example Reading an fvar table
69
+ # data = font.table_data("fvar")
70
+ # fvar = Fontisan::Tables::Fvar.read(data)
71
+ # fvar.axes.each do |axis|
72
+ # puts "#{axis.axis_tag}: #{axis.min_value} - #{axis.max_value}"
73
+ # end
74
+ class Fvar < Binary::BaseRecord
75
+ uint16 :major_version
76
+ uint16 :minor_version
77
+ uint16 :axes_array_offset
78
+ uint16 :reserved
79
+ uint16 :axis_count
80
+ uint16 :axis_size
81
+ uint16 :instance_count
82
+ uint16 :instance_size
83
+
84
+ # Parse variation axes from the table data
85
+ #
86
+ # @return [Array<VariationAxisRecord>] Array of axis records
87
+ def axes
88
+ return @axes if @axes
89
+ return @axes = [] if axis_count.zero?
90
+
91
+ # Get the full data buffer as binary string
92
+ data = to_binary_s
93
+
94
+ @axes = Array.new(axis_count) do |i|
95
+ offset = axes_array_offset + (i * axis_size)
96
+ axis_data = data.byteslice(offset, axis_size)
97
+ VariationAxisRecord.read(axis_data)
98
+ end
99
+ end
100
+
101
+ # Parse instance records from the table data
102
+ #
103
+ # @return [Array<Hash>] Array of instance information hashes
104
+ def instances
105
+ return @instances if @instances
106
+ return @instances = [] if instance_count.zero?
107
+
108
+ # Get the full data buffer as binary string
109
+ data = to_binary_s
110
+
111
+ # Calculate instance data offset (after all axes)
112
+ instance_offset = axes_array_offset + (axis_count * axis_size)
113
+
114
+ @instances = Array.new(instance_count) do |i|
115
+ offset = instance_offset + (i * instance_size)
116
+
117
+ # Check bounds
118
+ next nil if offset + instance_size > data.bytesize
119
+
120
+ instance_data = data.byteslice(offset, instance_size)
121
+ next nil if instance_data.nil? || instance_data.empty?
122
+
123
+ # Parse instance data manually
124
+ io = StringIO.new(instance_data)
125
+ io.set_encoding(Encoding::BINARY)
126
+
127
+ # Read subfamily name ID and flags
128
+ subfamily_name_id = io.read(2).unpack1("n")
129
+ flags = io.read(2).unpack1("n")
130
+
131
+ # Read coordinates for each axis (as int32 fixed-point values)
132
+ coordinates = Array.new(axis_count) do
133
+ fixed_to_float(io.read(4).unpack1("N"))
134
+ end
135
+
136
+ # Read optional postScriptNameID if present
137
+ postscript_name_id = nil
138
+ postscript_name_id = io.read(2).unpack1("n") if instance_size >= (4 + (axis_count * 4) + 2)
139
+
140
+ {
141
+ name_id: subfamily_name_id,
142
+ flags: flags,
143
+ coordinates: coordinates,
144
+ postscript_name_id: postscript_name_id,
145
+ }
146
+ end.compact
147
+ end
148
+
149
+ # Get version as a float
150
+ #
151
+ # @return [Float] Version number (e.g., 1.0)
152
+ def version
153
+ major_version + (minor_version / 10.0)
154
+ end
155
+ end
156
+ end
157
+ end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "layout_common"
4
+
5
+ module Fontisan
6
+ module Tables
7
+ # GPOS (Glyph Positioning) table parser
8
+ # Parses OpenType GPOS table to extract scripts and features
9
+ class Gpos < Binary::BaseRecord
10
+ uint16 :major_version
11
+ uint16 :minor_version
12
+ uint16 :script_list_offset
13
+ uint16 :feature_list_offset
14
+ uint16 :lookup_list_offset
15
+ rest :table_data
16
+
17
+ # Get all script tags supported by this font
18
+ # @return [Array<String>] Array of 4-character script tags
19
+ def scripts
20
+ return [] if script_list_offset.zero?
21
+
22
+ script_list_data = table_data[(script_list_offset - 10)..]
23
+ return [] if script_list_data.nil? || script_list_data.empty?
24
+
25
+ script_list = LayoutCommon::ScriptList.read(script_list_data)
26
+ script_list.script_tags
27
+ rescue StandardError
28
+ []
29
+ end
30
+
31
+ # Get all feature tags for a given script
32
+ # @param script_tag [String] 4-character script tag (e.g., 'latn')
33
+ # @return [Array<String>] Array of 4-character feature tags
34
+ def features(script_tag: "latn")
35
+ return [] if script_list_offset.zero? || feature_list_offset.zero?
36
+
37
+ # Get feature indices from the script's LangSys
38
+ feature_indices = feature_indices_for_script(script_tag)
39
+ return [] if feature_indices.empty?
40
+
41
+ # Get feature list
42
+ feature_list_data = table_data[(feature_list_offset - 10)..]
43
+ return [] if feature_list_data.nil? || feature_list_data.empty?
44
+
45
+ feature_list = LayoutCommon::FeatureList.read(feature_list_data)
46
+
47
+ # Collect features referenced by the script
48
+ features = []
49
+ feature_indices.each do |idx|
50
+ next if idx >= feature_list.feature_count
51
+
52
+ features << feature_list.feature_records[idx].feature_tag
53
+ end
54
+
55
+ features.uniq
56
+ rescue StandardError
57
+ []
58
+ end
59
+
60
+ private
61
+
62
+ # Get feature indices for a given script
63
+ # @param script_tag [String] 4-character script tag
64
+ # @return [Array<Integer>] Array of feature indices
65
+ def feature_indices_for_script(script_tag)
66
+ return [] if script_list_offset.zero?
67
+
68
+ script_list_data = table_data[(script_list_offset - 10)..]
69
+ return [] if script_list_data.nil? || script_list_data.empty?
70
+
71
+ script_list = LayoutCommon::ScriptList.read(script_list_data)
72
+
73
+ # Find the script record
74
+ script_record = script_list.script_records.find do |rec|
75
+ rec.script_tag == script_tag
76
+ end
77
+ return [] unless script_record
78
+
79
+ # Parse the script table at the offset
80
+ script_offset = script_record.script_offset
81
+ script_data = script_list_data[script_offset..]
82
+ return [] if script_data.nil? || script_data.empty?
83
+
84
+ script = LayoutCommon::Script.read(script_data)
85
+
86
+ # Get the default LangSys if it exists
87
+ feature_indices = []
88
+ if script.default_lang_sys_offset != 0
89
+ lang_sys_data = script_data[script.default_lang_sys_offset..]
90
+ if lang_sys_data && !lang_sys_data.empty?
91
+ lang_sys = LayoutCommon::LangSys.read(lang_sys_data)
92
+ feature_indices.concat(lang_sys.feature_indices)
93
+ end
94
+ end
95
+
96
+ # Also collect from all LangSys records
97
+ script.lang_sys_records.each do |lang_sys_rec|
98
+ lang_sys_data = script_data[lang_sys_rec.lang_sys_offset..]
99
+ next if lang_sys_data.nil? || lang_sys_data.empty?
100
+
101
+ lang_sys = LayoutCommon::LangSys.read(lang_sys_data)
102
+ feature_indices.concat(lang_sys.feature_indices)
103
+ end
104
+
105
+ feature_indices.uniq
106
+ rescue StandardError
107
+ []
108
+ end
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "layout_common"
4
+
5
+ module Fontisan
6
+ module Tables
7
+ # GSUB (Glyph Substitution) table parser
8
+ # Parses OpenType GSUB table to extract scripts and features
9
+ class Gsub < Binary::BaseRecord
10
+ uint16 :major_version
11
+ uint16 :minor_version
12
+ uint16 :script_list_offset
13
+ uint16 :feature_list_offset
14
+ uint16 :lookup_list_offset
15
+ rest :table_data
16
+
17
+ # Get all script tags supported by this font
18
+ # @return [Array<String>] Array of 4-character script tags
19
+ def scripts
20
+ return [] if script_list_offset.zero?
21
+
22
+ script_list_data = table_data[(script_list_offset - 10)..]
23
+ return [] if script_list_data.nil? || script_list_data.empty?
24
+
25
+ script_list = LayoutCommon::ScriptList.read(script_list_data)
26
+ script_list.script_tags
27
+ rescue StandardError
28
+ []
29
+ end
30
+
31
+ # Get all feature tags for a given script
32
+ # @param script_tag [String] 4-character script tag (e.g., 'latn')
33
+ # @return [Array<String>] Array of 4-character feature tags
34
+ def features(script_tag: "latn")
35
+ return [] if script_list_offset.zero? || feature_list_offset.zero?
36
+
37
+ # Get feature indices from the script's LangSys
38
+ feature_indices = feature_indices_for_script(script_tag)
39
+ return [] if feature_indices.empty?
40
+
41
+ # Get feature list
42
+ feature_list_data = table_data[(feature_list_offset - 10)..]
43
+ return [] if feature_list_data.nil? || feature_list_data.empty?
44
+
45
+ feature_list = LayoutCommon::FeatureList.read(feature_list_data)
46
+
47
+ # Collect features referenced by the script
48
+ features = []
49
+ feature_indices.each do |idx|
50
+ next if idx >= feature_list.feature_count
51
+
52
+ features << feature_list.feature_records[idx].feature_tag
53
+ end
54
+
55
+ features.uniq
56
+ rescue StandardError
57
+ []
58
+ end
59
+
60
+ private
61
+
62
+ # Get feature indices for a given script
63
+ # @param script_tag [String] 4-character script tag
64
+ # @return [Array<Integer>] Array of feature indices
65
+ def feature_indices_for_script(script_tag)
66
+ return [] if script_list_offset.zero?
67
+
68
+ script_list_data = table_data[(script_list_offset - 10)..]
69
+ return [] if script_list_data.nil? || script_list_data.empty?
70
+
71
+ script_list = LayoutCommon::ScriptList.read(script_list_data)
72
+
73
+ # Find the script record
74
+ script_record = script_list.script_records.find do |rec|
75
+ rec.script_tag == script_tag
76
+ end
77
+ return [] unless script_record
78
+
79
+ # Parse the script table at the offset
80
+ script_offset = script_record.script_offset
81
+ script_data = script_list_data[script_offset..]
82
+ return [] if script_data.nil? || script_data.empty?
83
+
84
+ script = LayoutCommon::Script.read(script_data)
85
+
86
+ # Get the default LangSys if it exists
87
+ feature_indices = []
88
+ if script.default_lang_sys_offset != 0
89
+ lang_sys_data = script_data[script.default_lang_sys_offset..]
90
+ if lang_sys_data && !lang_sys_data.empty?
91
+ lang_sys = LayoutCommon::LangSys.read(lang_sys_data)
92
+ feature_indices.concat(lang_sys.feature_indices)
93
+ end
94
+ end
95
+
96
+ # Also collect from all LangSys records
97
+ script.lang_sys_records.each do |lang_sys_rec|
98
+ lang_sys_data = script_data[lang_sys_rec.lang_sys_offset..]
99
+ next if lang_sys_data.nil? || lang_sys_data.empty?
100
+
101
+ lang_sys = LayoutCommon::LangSys.read(lang_sys_data)
102
+ feature_indices.concat(lang_sys.feature_indices)
103
+ end
104
+
105
+ feature_indices.uniq
106
+ rescue StandardError
107
+ []
108
+ end
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../binary/base_record"
4
+
5
+ module Fontisan
6
+ module Tables
7
+ # BinData structure for the 'head' (Font Header) table
8
+ #
9
+ # The head table contains global information about the font, including
10
+ # metadata about the font file, bounding box, and indexing information.
11
+ #
12
+ # Reference: OpenType specification, head table
13
+ #
14
+ # @example Reading a head table
15
+ # data = File.binread("font.ttf", 54, head_offset)
16
+ # head = Fontisan::Tables::Head.read(data)
17
+ # puts head.units_per_em # => 2048
18
+ # puts head.version_number # => 1.0
19
+ class Head < Binary::BaseRecord
20
+ # Magic number that must be present in the head table
21
+ MAGIC_NUMBER = 0x5F0F3CF5
22
+
23
+ # Version as 16.16 fixed-point (stored as int32)
24
+ int32 :version_raw
25
+
26
+ # Font revision as 16.16 fixed-point (stored as int32)
27
+ int32 :font_revision_raw
28
+
29
+ uint32 :checksum_adjustment
30
+ uint32 :magic_number
31
+ uint16 :flags
32
+ uint16 :units_per_em
33
+
34
+ # Created date as 64-bit signed integer (seconds since 1904-01-01)
35
+ int64 :created_raw
36
+
37
+ # Modified date as 64-bit signed integer (seconds since 1904-01-01)
38
+ int64 :modified_raw
39
+
40
+ int16 :x_min
41
+ int16 :y_min
42
+ int16 :x_max
43
+ int16 :y_max
44
+ uint16 :mac_style
45
+ uint16 :lowest_rec_ppem
46
+ int16 :font_direction_hint
47
+ int16 :index_to_loc_format
48
+ int16 :glyph_data_format
49
+
50
+ # Convert version from fixed-point to float
51
+ #
52
+ # @return [Float] Version number (e.g., 1.0)
53
+ def version
54
+ fixed_to_float(version_raw)
55
+ end
56
+
57
+ # Convert font revision from fixed-point to float
58
+ #
59
+ # @return [Float] Font revision number
60
+ def font_revision
61
+ fixed_to_float(font_revision_raw)
62
+ end
63
+
64
+ # Convert created timestamp to Time object
65
+ #
66
+ # @return [Time] Creation time
67
+ def created
68
+ longdatetime_to_time(created_raw)
69
+ end
70
+
71
+ # Convert modified timestamp to Time object
72
+ #
73
+ # @return [Time] Modification time
74
+ def modified
75
+ longdatetime_to_time(modified_raw)
76
+ end
77
+
78
+ # Validate that the magic number is correct
79
+ #
80
+ # @return [Boolean] True if magic number is valid
81
+ def valid?
82
+ magic_number == MAGIC_NUMBER
83
+ end
84
+
85
+ # Validate magic number and raise error if invalid
86
+ #
87
+ # @raise [Fontisan::CorruptedTableError] If magic number is invalid
88
+ def validate_magic_number!
89
+ return if valid?
90
+
91
+ message = "Invalid magic number in head table: " \
92
+ "expected 0x#{MAGIC_NUMBER.to_s(16).upcase}, " \
93
+ "got 0x#{magic_number.to_s(16).upcase}"
94
+ error = Fontisan::CorruptedTableError.new(message)
95
+ error.set_backtrace(caller)
96
+ Kernel.raise(error)
97
+ end
98
+
99
+ # Alias for backward compatibility
100
+ alias validate! validate_magic_number!
101
+
102
+ private
103
+
104
+ # Convert LONGDATETIME to Ruby Time
105
+ #
106
+ # @param seconds [Integer] Seconds since 1904-01-01 00:00:00
107
+ # @return [Time] Ruby Time object
108
+ def longdatetime_to_time(seconds)
109
+ # Difference between 1904 and 1970 (Unix epoch) is 2082844800 seconds
110
+ Time.at(seconds - 2_082_844_800)
111
+ end
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Tables
5
+ # Common structures shared between GSUB and GPOS tables
6
+ module LayoutCommon
7
+ # ScriptRecord structure
8
+ class ScriptRecord < Binary::BaseRecord
9
+ string :script_tag, length: 4
10
+ uint16 :script_offset
11
+ end
12
+
13
+ # ScriptList table
14
+ class ScriptList < Binary::BaseRecord
15
+ uint16 :script_count
16
+ array :script_records, type: ScriptRecord,
17
+ initial_length: -> { script_count }
18
+
19
+ def script_tags
20
+ script_records.map(&:script_tag)
21
+ end
22
+ end
23
+
24
+ # LangSysRecord structure
25
+ class LangSysRecord < Binary::BaseRecord
26
+ string :lang_sys_tag, length: 4
27
+ uint16 :lang_sys_offset
28
+ end
29
+
30
+ # Script table
31
+ class Script < Binary::BaseRecord
32
+ uint16 :default_lang_sys_offset
33
+ uint16 :lang_sys_count
34
+ array :lang_sys_records, type: LangSysRecord,
35
+ initial_length: -> { lang_sys_count }
36
+ end
37
+
38
+ # LangSys table
39
+ class LangSys < Binary::BaseRecord
40
+ uint16 :lookup_order_offset # Reserved, set to NULL
41
+ uint16 :required_feature_index
42
+ uint16 :feature_index_count
43
+ array :feature_indices, type: :uint16,
44
+ initial_length: -> { feature_index_count }
45
+ end
46
+
47
+ # FeatureRecord structure
48
+ class FeatureRecord < Binary::BaseRecord
49
+ string :feature_tag, length: 4
50
+ uint16 :feature_offset
51
+ end
52
+
53
+ # FeatureList table
54
+ class FeatureList < Binary::BaseRecord
55
+ uint16 :feature_count
56
+ array :feature_records, type: FeatureRecord,
57
+ initial_length: -> { feature_count }
58
+
59
+ def feature_tags
60
+ feature_records.map(&:feature_tag)
61
+ end
62
+ end
63
+
64
+ # Feature table
65
+ class Feature < Binary::BaseRecord
66
+ uint16 :feature_params_offset # Reserved, set to NULL
67
+ uint16 :lookup_index_count
68
+ array :lookup_list_indices, type: :uint16,
69
+ initial_length: -> { lookup_index_count }
70
+ end
71
+ end
72
+ end
73
+ end