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.
- checksums.yaml +7 -0
- data/.rspec +3 -0
- data/.rubocop.yml +13 -0
- data/.rubocop_todo.yml +217 -0
- data/Gemfile +15 -0
- data/LICENSE +24 -0
- data/README.adoc +984 -0
- data/Rakefile +95 -0
- data/exe/fontisan +7 -0
- data/fontisan.gemspec +44 -0
- data/lib/fontisan/binary/base_record.rb +57 -0
- data/lib/fontisan/binary/structures.rb +84 -0
- data/lib/fontisan/cli.rb +192 -0
- data/lib/fontisan/commands/base_command.rb +82 -0
- data/lib/fontisan/commands/dump_table_command.rb +71 -0
- data/lib/fontisan/commands/features_command.rb +94 -0
- data/lib/fontisan/commands/glyphs_command.rb +50 -0
- data/lib/fontisan/commands/info_command.rb +120 -0
- data/lib/fontisan/commands/optical_size_command.rb +41 -0
- data/lib/fontisan/commands/scripts_command.rb +59 -0
- data/lib/fontisan/commands/tables_command.rb +52 -0
- data/lib/fontisan/commands/unicode_command.rb +76 -0
- data/lib/fontisan/commands/variable_command.rb +61 -0
- data/lib/fontisan/config/features.yml +143 -0
- data/lib/fontisan/config/scripts.yml +42 -0
- data/lib/fontisan/constants.rb +78 -0
- data/lib/fontisan/error.rb +15 -0
- data/lib/fontisan/font_loader.rb +109 -0
- data/lib/fontisan/formatters/text_formatter.rb +314 -0
- data/lib/fontisan/models/all_scripts_features_info.rb +21 -0
- data/lib/fontisan/models/features_info.rb +42 -0
- data/lib/fontisan/models/font_info.rb +99 -0
- data/lib/fontisan/models/glyph_info.rb +26 -0
- data/lib/fontisan/models/optical_size_info.rb +33 -0
- data/lib/fontisan/models/scripts_info.rb +39 -0
- data/lib/fontisan/models/table_info.rb +55 -0
- data/lib/fontisan/models/unicode_mappings.rb +42 -0
- data/lib/fontisan/models/variable_font_info.rb +82 -0
- data/lib/fontisan/open_type_collection.rb +97 -0
- data/lib/fontisan/open_type_font.rb +292 -0
- data/lib/fontisan/parsers/tag.rb +77 -0
- data/lib/fontisan/tables/cmap.rb +284 -0
- data/lib/fontisan/tables/fvar.rb +157 -0
- data/lib/fontisan/tables/gpos.rb +111 -0
- data/lib/fontisan/tables/gsub.rb +111 -0
- data/lib/fontisan/tables/head.rb +114 -0
- data/lib/fontisan/tables/layout_common.rb +73 -0
- data/lib/fontisan/tables/name.rb +188 -0
- data/lib/fontisan/tables/os2.rb +175 -0
- data/lib/fontisan/tables/post.rb +148 -0
- data/lib/fontisan/true_type_collection.rb +98 -0
- data/lib/fontisan/true_type_font.rb +313 -0
- data/lib/fontisan/utilities/checksum_calculator.rb +89 -0
- data/lib/fontisan/version.rb +5 -0
- data/lib/fontisan.rb +80 -0
- 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
|