fontisan 0.2.8 → 0.2.9

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.
@@ -0,0 +1,176 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../sfnt_table"
4
+ require_relative "name"
5
+
6
+ module Fontisan
7
+ module Tables
8
+ # OOP representation of the 'name' (Naming) table
9
+ #
10
+ # The name table contains all naming strings for the font, including
11
+ # font family name, style name, designer, license, etc.
12
+ #
13
+ # This class extends SfntTable to provide name-specific convenience
14
+ # methods for accessing common name records.
15
+ #
16
+ # @example Accessing name table data
17
+ # name = font.table("name") # Returns SfntTable instance
18
+ # name.family_name # => "Noto Sans"
19
+ # name.subfamily_name # => "Regular"
20
+ # name.full_name # => "Noto Sans Regular"
21
+ # name.postscript_name # => "NotoSans-Regular"
22
+ class NameTable < SfntTable
23
+ # Name record identifiers
24
+ #
25
+ # These are the name IDs defined in the OpenType spec
26
+ FAMILY = 1
27
+ SUBFAMILY = 2
28
+ FULL_NAME = 4
29
+ POSTSCRIPT_NAME = 6
30
+ PREFERRED_FAMILY = 16
31
+ PREFERRED_SUBFAMILY = 17
32
+ WWS_FAMILY = 21
33
+ WWS_SUBFAMILY = 22
34
+
35
+ # Platform IDs
36
+ PLATFORM_UNICODE = 0
37
+ PLATFORM_MACINTOSH = 1
38
+ PLATFORM_WINDOWS = 3
39
+
40
+ # Get font family name
41
+ #
42
+ # Attempts to get the preferred family name, falling back to the
43
+ # standard family name if preferred is not available.
44
+ #
45
+ # @return [String, nil] Family name or nil if not found
46
+ def family_name
47
+ english_name(PREFERRED_FAMILY) || english_name(FAMILY)
48
+ end
49
+
50
+ # Get font subfamily name
51
+ #
52
+ # Attempts to get the preferred subfamily name, falling back to the
53
+ # standard subfamily name if preferred is not available.
54
+ #
55
+ # @return [String, nil] Subfamily name or nil if not found
56
+ def subfamily_name
57
+ english_name(PREFERRED_SUBFAMILY) || english_name(SUBFAMILY)
58
+ end
59
+
60
+ # Get full font name
61
+ #
62
+ # @return [String, nil] Full name or nil if not found
63
+ def full_name
64
+ english_name(FULL_NAME)
65
+ end
66
+
67
+ # Get PostScript name
68
+ #
69
+ # @return [String, nil] PostScript name or nil if not found
70
+ def postscript_name
71
+ english_name(POSTSCRIPT_NAME)
72
+ end
73
+
74
+ # Get preferred family name
75
+ #
76
+ # @return [String, nil] Preferred family name or nil if not found
77
+ def preferred_family_name
78
+ english_name(PREFERRED_FAMILY)
79
+ end
80
+
81
+ # Get preferred subfamily name
82
+ #
83
+ # @return [String, nil] Preferred subfamily name or nil if not found
84
+ def preferred_subfamily_name
85
+ english_name(PREFERRED_SUBFAMILY)
86
+ end
87
+
88
+ # Get English name for a specific name ID
89
+ #
90
+ # Searches for an English name record with the given name ID.
91
+ # Prefers Windows (platform 3) over Mac (platform 1) over Unicode (platform 0).
92
+ #
93
+ # @param name_id [Integer] The name record ID
94
+ # @return [String, nil] The name string, or nil if not found
95
+ def english_name(name_id)
96
+ return nil unless parsed
97
+
98
+ # Find all name records with this name_id
99
+ records = parsed.name_records.select { |nr| nr.name_id == name_id }
100
+ return nil if records.empty?
101
+
102
+ # Try to find English Windows name first (platform 3, language 0x409)
103
+ windows = records.find do |nr|
104
+ nr.platform_id == PLATFORM_WINDOWS && nr.language_id == 0x409
105
+ end
106
+ return windows.string if windows&.string
107
+
108
+ # Try Mac English (platform 1, language 0)
109
+ mac = records.find do |nr|
110
+ nr.platform_id == PLATFORM_MACINTOSH && nr.language_id.zero?
111
+ end
112
+ return mac.string if mac&.string
113
+
114
+ # Try any English Unicode name (platform 0, language 0)
115
+ unicode = records.find do |nr|
116
+ nr.platform_id == PLATFORM_UNICODE && nr.language_id.zero?
117
+ end
118
+ return unicode.string if unicode&.string
119
+
120
+ # Fallback to first record with this name_id
121
+ first = records.first
122
+ first&.string
123
+ end
124
+
125
+ # Get all name records
126
+ #
127
+ # @return [Array<NameRecord>, nil] Array of name records, or nil if not parsed
128
+ def name_records
129
+ parsed&.name_records
130
+ end
131
+
132
+ # Get all names for a specific name ID
133
+ #
134
+ # @param name_id [Integer] The name record ID
135
+ # @return [Array<Hash>] Array of hashes with platform, encoding, language, and string
136
+ def all_names_for(name_id)
137
+ return [] unless parsed
138
+
139
+ parsed.name_records
140
+ .select { |nr| nr.name_id == name_id }
141
+ .map do |nr|
142
+ {
143
+ platform_id: nr.platform_id,
144
+ encoding_id: nr.encoding_id,
145
+ language_id: nr.language_id,
146
+ string: nr.string,
147
+ }
148
+ end
149
+ end
150
+
151
+ protected
152
+
153
+ # Validate the parsed name table
154
+ #
155
+ # @return [Boolean] true if valid
156
+ # @raise [InvalidFontError] if format identifier is invalid
157
+ def validate_parsed_table?
158
+ return true unless parsed
159
+
160
+ # Validate format selector
161
+ unless [0, 1].include?(parsed.format)
162
+ raise InvalidFontError,
163
+ "Invalid name table format: #{parsed.format} (must be 0 or 1)"
164
+ end
165
+
166
+ # Validate that we have at least some name records
167
+ if parsed.name_records.empty?
168
+ raise InvalidFontError,
169
+ "Name table has no name records"
170
+ end
171
+
172
+ true
173
+ end
174
+ end
175
+ end
176
+ end
@@ -0,0 +1,329 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../sfnt_table"
4
+ require_relative "os2"
5
+
6
+ module Fontisan
7
+ module Tables
8
+ # OOP representation of the 'OS/2' (OS/2 and Windows Metrics) table
9
+ #
10
+ # The OS/2 table contains OS/2 and Windows-specific metrics required by
11
+ # Windows and OS/2, including font metrics, character ranges, vendor
12
+ # information, and embedding permissions.
13
+ #
14
+ # This class extends SfntTable to provide OS/2-specific validation and
15
+ # convenience methods for accessing common OS/2 table fields.
16
+ #
17
+ # @example Accessing OS/2 table data
18
+ # os2 = font.sfnt_table("OS/2")
19
+ # os2.weight_class # => 400 (Normal)
20
+ # os2.width_class # => 5 (Medium)
21
+ # os2.vendor_id # => "APPL"
22
+ # os2.embedding_allowed? # => true
23
+ class Os2Table < SfntTable
24
+ # Weight class names (from OpenType spec)
25
+ WEIGHT_NAMES = {
26
+ 100 => "Thin",
27
+ 200 => "Extra-light (Ultra-light)",
28
+ 300 => "Light",
29
+ 400 => "Normal (Regular)",
30
+ 500 => "Medium",
31
+ 600 => "Semi-bold (Demi-bold)",
32
+ 700 => "Bold",
33
+ 800 => "Extra-bold (Ultra-bold)",
34
+ 900 => "Black (Heavy)",
35
+ }.freeze
36
+
37
+ # Width class names (from OpenType spec)
38
+ WIDTH_NAMES = {
39
+ 1 => "Ultra-condensed",
40
+ 2 => "Extra-condensed",
41
+ 3 => "Condensed",
42
+ 4 => "Semi-condensed",
43
+ 5 => "Medium (Normal)",
44
+ 6 => "Semi-expanded",
45
+ 7 => "Expanded",
46
+ 8 => "Extra-expanded",
47
+ 9 => "Ultra-expanded",
48
+ }.freeze
49
+
50
+ # Selection flags (bit field)
51
+ FS_ITALIC = 1 << 0
52
+ FS_UNDERSCORE = 1 << 1
53
+ FS_NEGATIVE = 1 << 2
54
+ FS_OUTLINED = 1 << 3
55
+ FS_STRIKEOUT = 1 << 4
56
+ FS_BOLD = 1 << 5
57
+ FS_REGULAR = 1 << 6
58
+ FS_USE_TYPO_METRICS = 1 << 7
59
+ FS_WWS = 1 << 8
60
+ FS_OBLIQUE = 1 << 9
61
+
62
+ # Get OS/2 table version
63
+ #
64
+ # @return [Integer, nil] Version number (0-5), or nil if not parsed
65
+ def version
66
+ parsed&.version
67
+ end
68
+
69
+ # Get weight class
70
+ #
71
+ # @return [Integer, nil] Weight class (100-900), or nil if not parsed
72
+ def weight_class
73
+ parsed&.us_weight_class
74
+ end
75
+
76
+ # Get weight class name
77
+ #
78
+ # @return [String, nil] Human-readable weight name, or nil if not parsed
79
+ def weight_class_name
80
+ return nil unless parsed
81
+
82
+ WEIGHT_NAMES[parsed.us_weight_class] || "Unknown"
83
+ end
84
+
85
+ # Get width class
86
+ #
87
+ # @return [Integer, nil] Width class (1-9), or nil if not parsed
88
+ def width_class
89
+ parsed&.us_width_class
90
+ end
91
+
92
+ # Get width class name
93
+ #
94
+ # @return [String, nil] Human-readable width name, or nil if not parsed
95
+ def width_class_name
96
+ return nil unless parsed
97
+
98
+ WIDTH_NAMES[parsed.us_width_class] || "Unknown"
99
+ end
100
+
101
+ # Get vendor ID
102
+ #
103
+ # @return [String, nil] 4-character vendor identifier, or nil if not parsed
104
+ def vendor_id
105
+ parsed&.vendor_id
106
+ end
107
+
108
+ # Check if font is italic
109
+ #
110
+ # @return [Boolean] true if italic flag is set
111
+ def italic?
112
+ parsed && (parsed.fs_selection & FS_ITALIC) != 0
113
+ end
114
+
115
+ # Check if font is bold
116
+ #
117
+ # @return [Boolean] true if bold flag is set
118
+ def bold?
119
+ parsed && (parsed.fs_selection & FS_BOLD) != 0
120
+ end
121
+
122
+ # Check if font uses regular style
123
+ #
124
+ # @return [Boolean] true if regular flag is set
125
+ def regular?
126
+ parsed && (parsed.fs_selection & FS_REGULAR) != 0
127
+ end
128
+
129
+ # Check if font uses typographic metrics
130
+ #
131
+ # @return [Boolean] true if use typo metrics flag is set
132
+ def use_typo_metrics?
133
+ parsed && (parsed.fs_selection & FS_USE_TYPO_METRICS) != 0
134
+ end
135
+
136
+ # Check if font is oblique
137
+ #
138
+ # @return [Boolean] true if oblique flag is set
139
+ def oblique?
140
+ parsed && (parsed.fs_selection & FS_OBLIQUE) != 0
141
+ end
142
+
143
+ # Get typographic ascent
144
+ #
145
+ # @return [Integer, nil] Typographic ascender, or nil if not parsed
146
+ def typo_ascender
147
+ parsed&.s_typo_ascender
148
+ end
149
+
150
+ # Get typographic descent
151
+ #
152
+ # @return [Integer, nil] Typographic descender (negative value), or nil if not parsed
153
+ def typo_descender
154
+ parsed&.s_typo_descender
155
+ end
156
+
157
+ # Get typographic line gap
158
+ #
159
+ # @return [Integer, nil] Line gap, or nil if not parsed
160
+ def typo_line_gap
161
+ parsed&.s_typo_line_gap
162
+ end
163
+
164
+ # Get Windows ascent
165
+ #
166
+ # @return [Integer, nil] Windows ascender, or nil if not parsed
167
+ def win_ascent
168
+ parsed&.us_win_ascent
169
+ end
170
+
171
+ # Get Windows descent
172
+ #
173
+ # @return [Integer, nil] Windows descender, or nil if not parsed
174
+ def win_descent
175
+ parsed&.us_win_descent
176
+ end
177
+
178
+ # Get x-height (version 2+)
179
+ #
180
+ # @return [Integer, nil] x-height value, or nil if not available
181
+ def x_height
182
+ parsed&.sx_height
183
+ end
184
+
185
+ # Get cap height (version 2+)
186
+ #
187
+ # @return [Integer, nil] Cap height value, or nil if not available
188
+ def cap_height
189
+ parsed&.s_cap_height
190
+ end
191
+
192
+ # Check if embedding is allowed
193
+ #
194
+ # @return [Boolean] true if embedding is permitted (fs_type & 0x8 == 0)
195
+ def embedding_allowed?
196
+ return false unless parsed
197
+
198
+ # fs_type bit 3 (0x8) = Embedding must not be allowed
199
+ # If bit 3 is NOT set, embedding is allowed
200
+ (parsed.fs_type & 0x8).zero?
201
+ end
202
+
203
+ # Check if embedding is restricted
204
+ #
205
+ # @return [Boolean] true if embedding is restricted
206
+ def embedding_restricted?
207
+ !embedding_allowed?
208
+ end
209
+
210
+ # Check if preview/print embedding is allowed
211
+ #
212
+ # @return [Boolean] true if preview and print embedding is permitted
213
+ def preview_print_allowed?
214
+ return false unless parsed
215
+
216
+ # fs_type bit 1 (0x2) = Preview & Print embedding allowed
217
+ (parsed.fs_type & 0x2) != 0
218
+ end
219
+
220
+ # Check if editable embedding is allowed
221
+ #
222
+ # @return [Boolean] true if editable embedding is permitted
223
+ def editable_allowed?
224
+ return false unless parsed
225
+
226
+ # fs_type bit 2 (0x4) = Editable embedding allowed
227
+ (parsed.fs_type & 0x4) != 0
228
+ end
229
+
230
+ # Check if subsetting is allowed
231
+ #
232
+ # @return [Boolean] true if subsetting is permitted (fs_type bit 8 is NOT set)
233
+ def subsetting_allowed?
234
+ return false unless parsed
235
+
236
+ # fs_type bit 8 (0x100) = No subsetting
237
+ (parsed.fs_type & 0x100).zero?
238
+ end
239
+
240
+ # Check if bitmap embedding only is allowed
241
+ #
242
+ # @return [Boolean] true if only bitmaps can be embedded
243
+ def bitmap_embedding_only?
244
+ return false unless parsed
245
+
246
+ # fs_type bit 9 (0x200) = Bitmap embedding only
247
+ (parsed.fs_type & 0x200) != 0
248
+ end
249
+
250
+ # Get PANOSE classification
251
+ #
252
+ # @return [Array<Integer>, nil] Array of 10 PANOSE bytes, or nil if not parsed
253
+ def panose
254
+ parsed&.panose&.to_a
255
+ end
256
+
257
+ # Get first character index
258
+ #
259
+ # @return [Integer, nil] First character Unicode value, or nil if not parsed
260
+ def first_char_index
261
+ parsed&.us_first_char_index
262
+ end
263
+
264
+ # Get last character index
265
+ #
266
+ # @return [Integer, nil] Last character Unicode value, or nil if not parsed
267
+ def last_char_index
268
+ parsed&.us_last_char_index
269
+ end
270
+
271
+ protected
272
+
273
+ # Validate the parsed OS/2 table
274
+ #
275
+ # @return [Boolean] true if valid
276
+ # @raise [InvalidFontError] if OS/2 table is invalid
277
+ def validate_parsed_table?
278
+ return true unless parsed
279
+
280
+ # Validate version
281
+ unless parsed.valid_version?
282
+ raise InvalidFontError,
283
+ "Invalid OS/2 table version: #{parsed.version} (must be 0-5)"
284
+ end
285
+
286
+ # Validate weight class
287
+ unless parsed.valid_weight_class?
288
+ raise InvalidFontError,
289
+ "Invalid OS/2 weight class: #{parsed.us_weight_class} (must be 1-1000)"
290
+ end
291
+
292
+ # Validate width class
293
+ unless parsed.valid_width_class?
294
+ raise InvalidFontError,
295
+ "Invalid OS/2 width class: #{parsed.us_width_class} (must be 1-9)"
296
+ end
297
+
298
+ # Validate vendor ID
299
+ unless parsed.has_vendor_id?
300
+ raise InvalidFontError,
301
+ "Invalid OS/2 vendor ID: empty or missing"
302
+ end
303
+
304
+ # Validate typo metrics
305
+ unless parsed.valid_typo_metrics?
306
+ raise InvalidFontError,
307
+ "Invalid OS/2 typo metrics: ascent=#{parsed.s_typo_ascender}, " \
308
+ "descent=#{parsed.s_typo_descender}, line_gap=#{parsed.s_typo_line_gap}"
309
+ end
310
+
311
+ # Validate Win metrics
312
+ unless parsed.valid_win_metrics?
313
+ raise InvalidFontError,
314
+ "Invalid OS/2 Win metrics: win_ascent=#{parsed.us_win_ascent}, " \
315
+ "win_descent=#{parsed.us_win_descent} (both must be positive)"
316
+ end
317
+
318
+ # Validate character range
319
+ unless parsed.valid_char_range?
320
+ raise InvalidFontError,
321
+ "Invalid OS/2 character range: first=#{parsed.us_first_char_index}, " \
322
+ "last=#{parsed.us_last_char_index} (first must be <= last)"
323
+ end
324
+
325
+ true
326
+ end
327
+ end
328
+ end
329
+ end
@@ -0,0 +1,183 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../sfnt_table"
4
+ require_relative "post"
5
+
6
+ module Fontisan
7
+ module Tables
8
+ # OOP representation of the 'post' (PostScript) table
9
+ #
10
+ # The post table contains PostScript information, primarily glyph names.
11
+ # Different versions exist (1.0, 2.0, 2.5, 3.0, 4.0) with varying
12
+ # glyph name storage strategies.
13
+ #
14
+ # This class extends SfntTable to provide post-specific validation and
15
+ # convenience methods for accessing PostScript metrics and glyph names.
16
+ #
17
+ # @example Accessing post table data
18
+ # post = font.sfnt_table("post")
19
+ # post.italic_angle # => 0.0
20
+ # post.underline_position # => -100
21
+ # post.underline_thickness # => 50
22
+ # post.glyph_name_for(42) # => "A"
23
+ class PostTable < SfntTable
24
+ # Get post table version
25
+ #
26
+ # @return [Float, nil] Version number (1.0, 2.0, 2.5, 3.0, or 4.0)
27
+ def version
28
+ return nil unless parsed
29
+
30
+ parsed.version
31
+ end
32
+
33
+ # Get italic angle in degrees
34
+ #
35
+ # Positive value means counter-clockwise tilt
36
+ #
37
+ # @return [Float, nil] Italic angle in degrees, or nil if not parsed
38
+ def italic_angle
39
+ return nil unless parsed
40
+
41
+ parsed.italic_angle
42
+ end
43
+
44
+ # Check if font is italic
45
+ #
46
+ # @return [Boolean] true if italic_angle != 0
47
+ def italic?
48
+ angle = italic_angle
49
+ !angle.nil? && angle != 0
50
+ end
51
+
52
+ # Get underline position
53
+ #
54
+ # Distance from baseline to top of underline (negative for under baseline)
55
+ #
56
+ # @return [Integer, nil] Underline position in FUnits, or nil if not parsed
57
+ def underline_position
58
+ parsed&.underline_position
59
+ end
60
+
61
+ # Get underline thickness
62
+ #
63
+ # @return [Integer, nil] Underline thickness in FUnits, or nil if not parsed
64
+ def underline_thickness
65
+ parsed&.underline_thickness
66
+ end
67
+
68
+ # Check if font is fixed pitch (monospaced)
69
+ #
70
+ # @return [Boolean] true if font is monospaced
71
+ def fixed_pitch?
72
+ return false unless parsed
73
+
74
+ parsed.is_fixed_pitch == 1
75
+ end
76
+
77
+ # Get minimum memory for Type 42 fonts
78
+ #
79
+ # @return [Integer, nil] Minimum memory in bytes, or nil if not parsed
80
+ def min_mem_type42
81
+ parsed&.min_mem_type42
82
+ end
83
+
84
+ # Get maximum memory for Type 42 fonts
85
+ #
86
+ # @return [Integer, nil] Maximum memory in bytes, or nil if not parsed
87
+ def max_mem_type42
88
+ parsed&.max_mem_type42
89
+ end
90
+
91
+ # Get minimum memory for Type 1 fonts
92
+ #
93
+ # @return [Integer, nil] Minimum memory in bytes, or nil if not parsed
94
+ def min_mem_type1
95
+ parsed&.min_mem_type1
96
+ end
97
+
98
+ # Get maximum memory for Type 1 fonts
99
+ #
100
+ # @return [Integer, nil] Maximum memory in bytes, or nil if not parsed
101
+ def max_mem_type1
102
+ parsed&.max_mem_type1
103
+ end
104
+
105
+ # Get all glyph names
106
+ #
107
+ # Only available for version 1.0 and 2.0
108
+ #
109
+ # @return [Array<String>] Array of glyph names
110
+ def glyph_names
111
+ return [] unless parsed
112
+
113
+ parsed.glyph_names || []
114
+ end
115
+
116
+ # Get glyph name by ID
117
+ #
118
+ # @param glyph_id [Integer] Glyph ID
119
+ # @return [String, nil] Glyph name, or nil if not found
120
+ def glyph_name_for(glyph_id)
121
+ names = glyph_names
122
+ return nil if glyph_id.negative? || glyph_id >= names.length
123
+
124
+ names[glyph_id]
125
+ end
126
+
127
+ # Check if glyph names are available
128
+ #
129
+ # @return [Boolean] true if glyph names can be retrieved
130
+ def has_glyph_names?
131
+ return false unless parsed
132
+
133
+ parsed.has_glyph_names?
134
+ end
135
+
136
+ # Get the number of glyphs with names
137
+ #
138
+ # @return [Integer] Number of named glyphs
139
+ def named_glyph_count
140
+ glyph_names.length
141
+ end
142
+
143
+ protected
144
+
145
+ # Validate the parsed post table
146
+ #
147
+ # @return [Boolean] true if valid
148
+ # @raise [InvalidFontError] if post table is invalid
149
+ def validate_parsed_table?
150
+ return true unless parsed
151
+
152
+ # Validate version
153
+ unless parsed.valid_version?
154
+ raise InvalidFontError,
155
+ "Invalid post table version: #{parsed.version} " \
156
+ "(must be 1.0, 2.0, 2.5, 3.0, or 4.0)"
157
+ end
158
+
159
+ # Validate italic angle
160
+ unless parsed.valid_italic_angle?
161
+ raise InvalidFontError,
162
+ "Invalid post italic angle: #{parsed.italic_angle} " \
163
+ "(must be between -60 and 60 degrees)"
164
+ end
165
+
166
+ # Validate fixed pitch flag
167
+ unless parsed.valid_fixed_pitch_flag?
168
+ raise InvalidFontError,
169
+ "Invalid post is_fixed_pitch: #{parsed.is_fixed_pitch} " \
170
+ "(must be 0 or 1)"
171
+ end
172
+
173
+ # Validate version 2.0 data completeness
174
+ unless parsed.complete_version_2_data?
175
+ raise InvalidFontError,
176
+ "Invalid post version 2.0 table: incomplete data"
177
+ end
178
+
179
+ true
180
+ end
181
+ end
182
+ end
183
+ end