fontisan 0.2.8 → 0.2.10

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,264 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "constants"
4
+ require_relative "loading_modes"
5
+
6
+ module Fontisan
7
+ # Base class for SFNT font tables
8
+ #
9
+ # Represents a single table in an SFNT font file, encapsulating:
10
+ # - Table metadata (tag, checksum, offset, length)
11
+ # - Lazy loading of table data
12
+ # - Parsing of table data into structured objects
13
+ # - Table-specific validation
14
+ #
15
+ # This class provides an OOP representation of font tables, replacing
16
+ # the previous separation of TableDirectory (metadata), @table_data (raw bytes),
17
+ # and @parsed_tables (parsed objects) with a single cohesive domain object.
18
+ #
19
+ # @abstract Subclasses should override `parser_class` and `validate_parsed_table?`
20
+ #
21
+ # @example Accessing table metadata
22
+ # table = SfntTable.new(font, entry)
23
+ # puts table.tag # => "head"
24
+ # puts table.checksum # => 0x12345678
25
+ # puts table.offset # => 0x0000012C
26
+ # puts table.length # => 54
27
+ #
28
+ # @example Lazy loading table data
29
+ # table.load_data! # Loads raw bytes from IO
30
+ # puts table.data.bytesize
31
+ #
32
+ # @example Parsing table data
33
+ # head_table = table.parse
34
+ # puts head_table.units_per_em
35
+ #
36
+ # @example Validating table
37
+ # table.validate! # Raises InvalidFontError if invalid
38
+ class SfntTable
39
+ # Table metadata entry (from TableDirectory)
40
+ #
41
+ # @return [TableDirectory] The table directory entry
42
+ attr_reader :entry
43
+
44
+ # Parent font containing this table
45
+ #
46
+ # @return [SfntFont] The font that contains this table
47
+ attr_reader :font
48
+
49
+ # Raw table data (loaded lazily)
50
+ #
51
+ # @return [String, nil] Raw binary table data, or nil if not loaded
52
+ attr_reader :data
53
+
54
+ # Parsed table object (cached)
55
+ #
56
+ # @return [Object, nil] Parsed table object, or nil if not parsed
57
+ attr_reader :parsed
58
+
59
+ # Table tag (4-character string)
60
+ #
61
+ # @return [String] The table tag (e.g., "head", "name", "cmap")
62
+ def tag
63
+ @entry.tag
64
+ end
65
+
66
+ # Table checksum
67
+ #
68
+ # @return [Integer] The table checksum
69
+ def checksum
70
+ @entry.checksum
71
+ end
72
+
73
+ # Table offset in font file
74
+ #
75
+ # @return [Integer] Byte offset of table data
76
+ def offset
77
+ @entry.offset
78
+ end
79
+
80
+ # Table length in bytes
81
+ #
82
+ # @return [Integer] Table data length in bytes
83
+ def length
84
+ @entry.table_length
85
+ end
86
+
87
+ # Initialize a new SfntTable
88
+ #
89
+ # @param font [SfntFont] The font containing this table
90
+ # @param entry [TableDirectory] The table directory entry
91
+ def initialize(font, entry)
92
+ @font = font
93
+ @entry = entry
94
+ @data = nil
95
+ @parsed = nil
96
+ end
97
+
98
+ # Load raw table data from font file
99
+ #
100
+ # Reads the table data from the font's IO source or from cached
101
+ # table data. This method supports lazy loading.
102
+ #
103
+ # @return [self] Returns self for chaining
104
+ # @raise [RuntimeError] if table data cannot be loaded
105
+ def load_data!
106
+ # Check if already loaded
107
+ return self if @data
108
+
109
+ # Try to get from font's table_data cache
110
+ if @font.table_data && @font.table_data[tag]
111
+ @data = @font.table_data[tag]
112
+ return self
113
+ end
114
+
115
+ # Load from IO source if available
116
+ if @font.io_source
117
+ @font.io_source.seek(offset)
118
+ @data = @font.io_source.read(length)
119
+ return self
120
+ end
121
+
122
+ raise "Cannot load table '#{tag}': no IO source or cached data"
123
+ end
124
+
125
+ # Check if table data is loaded
126
+ #
127
+ # @return [Boolean] true if table data has been loaded
128
+ def data_loaded?
129
+ !@data.nil?
130
+ end
131
+
132
+ # Check if table has been parsed
133
+ #
134
+ # @return [Boolean] true if table has been parsed
135
+ def parsed?
136
+ !@parsed.nil?
137
+ end
138
+
139
+ # Parse table data into structured object
140
+ #
141
+ # Loads data if needed, then parses using the table-specific parser class.
142
+ # Results are cached for subsequent calls.
143
+ #
144
+ # @return [Object, nil] Parsed table object, or nil if no parser available
145
+ # @raise [RuntimeError] if table data cannot be loaded for parsing
146
+ def parse
147
+ return @parsed if parsed?
148
+
149
+ # Load data if not already loaded
150
+ load_data! unless data_loaded?
151
+
152
+ # Get parser class for this table type
153
+ parser = parser_class
154
+ return nil unless parser
155
+
156
+ # Parse and cache
157
+ @parsed = parser.read(@data)
158
+ @parsed
159
+ end
160
+
161
+ # Validate the table
162
+ #
163
+ # Performs table-specific validation. Subclasses should override
164
+ # `validate_parsed_table?` to provide custom validation logic.
165
+ #
166
+ # @return [Boolean] true if table is valid
167
+ # @raise [Fontisan::InvalidFontError] if table is invalid
168
+ def validate!
169
+ # Ensure data is loaded
170
+ load_data! unless data_loaded?
171
+
172
+ # Basic validation: data size matches expected size
173
+ if @data.bytesize != length
174
+ raise InvalidFontError,
175
+ "Table '#{tag}' data size mismatch: expected #{length} bytes, got #{@data.bytesize}"
176
+ end
177
+
178
+ # Validate checksum if not head table (head table checksum is special)
179
+ if tag != Constants::HEAD_TAG
180
+ expected_checksum = calculate_checksum
181
+ if checksum != expected_checksum
182
+ # Checksum mismatch might be OK for some tables, log a warning
183
+ # But don't fail validation for it
184
+ end
185
+ end
186
+
187
+ # Table-specific validation (if parsed)
188
+ if parsed?
189
+ validate_parsed_table?
190
+ end
191
+
192
+ true
193
+ end
194
+
195
+ # Calculate table checksum
196
+ #
197
+ # @return [Integer] The checksum of the table data
198
+ def calculate_checksum
199
+ load_data! unless data_loaded?
200
+
201
+ require_relative "utilities/checksum_calculator"
202
+ Utilities::ChecksumCalculator.calculate_table_checksum(@data)
203
+ end
204
+
205
+ # Check if table is available in current loading mode
206
+ #
207
+ # @return [Boolean] true if table is available
208
+ def available?
209
+ @font.table_available?(tag)
210
+ end
211
+
212
+ # Check if table is required for the font
213
+ #
214
+ # @return [Boolean] true if table is required
215
+ def required?
216
+ Constants::REQUIRED_TABLES.include?(tag)
217
+ end
218
+
219
+ # Get human-readable table name
220
+ #
221
+ # @return [String] Human-readable name
222
+ def human_name
223
+ Constants::TABLE_NAMES[tag] || tag
224
+ end
225
+
226
+ # String representation
227
+ #
228
+ # @return [String] Human-readable representation
229
+ def inspect
230
+ "#<#{self.class.name} tag=#{tag.inspect} offset=0x#{offset.to_s(16).upcase} length=#{length}>"
231
+ end
232
+
233
+ # String representation for display
234
+ #
235
+ # @return [String] Human-readable representation
236
+ def to_s
237
+ "#{tag}: #{human_name} (#{length} bytes @ 0x#{offset.to_s(16).upcase})"
238
+ end
239
+
240
+ protected
241
+
242
+ # Get the parser class for this table type
243
+ #
244
+ # Subclasses should override this method to return the appropriate
245
+ # Tables::* class (e.g., Tables::Head, Tables::Name).
246
+ #
247
+ # @return [Class, nil] The parser class, or nil if no parser available
248
+ def parser_class
249
+ # Direct access to TABLE_CLASS_MAP for better performance
250
+ @font.class::TABLE_CLASS_MAP[tag]
251
+ end
252
+
253
+ # Validate the parsed table object
254
+ #
255
+ # Subclasses should override this method to provide table-specific
256
+ # validation logic. The default implementation does nothing.
257
+ #
258
+ # @return [Boolean] true if valid
259
+ # @raise [Fontisan::InvalidFontError] if table is invalid
260
+ def validate_parsed_table?
261
+ true
262
+ end
263
+ end
264
+ end
@@ -0,0 +1,231 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../sfnt_table"
4
+ require_relative "cmap"
5
+
6
+ module Fontisan
7
+ module Tables
8
+ # OOP representation of the 'cmap' (Character to Glyph Index Mapping) table
9
+ #
10
+ # The cmap table maps character codes to glyph indices, supporting multiple
11
+ # encoding formats for different character sets and Unicode planes.
12
+ #
13
+ # This class extends SfntTable to provide cmap-specific validation and
14
+ # convenience methods for character-to-glyph mapping.
15
+ #
16
+ # @example Mapping characters to glyphs
17
+ # cmap = font.sfnt_table("cmap")
18
+ # cmap.glyph_for('A') # => 36
19
+ # cmap.glyph_for(0x0041) # => 36 (same as 'A')
20
+ # cmap.has_glyph?('€') # => true
21
+ # cmap.character_count # => 1234
22
+ class CmapTable < SfntTable
23
+ # Get Unicode character to glyph index mappings
24
+ #
25
+ # @return [Hash<Integer, Integer>] Mapping from Unicode codepoints to glyph IDs
26
+ def unicode_mappings
27
+ return {} unless parsed
28
+
29
+ parsed.unicode_mappings || {}
30
+ end
31
+
32
+ # Get glyph ID for a character
33
+ #
34
+ # @param char [String, Integer] Character (string or Unicode codepoint)
35
+ # @return [Integer, nil] Glyph ID, or nil if character not mapped
36
+ def glyph_for(char)
37
+ codepoint = char.is_a?(String) ? char.ord : char
38
+ unicode_mappings[codepoint]
39
+ end
40
+
41
+ # Check if a character has a glyph mapping
42
+ #
43
+ # @param char [String, Integer] Character (string or Unicode codepoint)
44
+ # @return [Boolean] true if character is mapped to a glyph
45
+ def has_glyph?(char)
46
+ !glyph_for(char).nil?
47
+ end
48
+
49
+ # Check if multiple characters have glyph mappings
50
+ #
51
+ # @param chars [Array<String, Integer>] Characters to check
52
+ # @return [Boolean] true if all characters are mapped
53
+ def has_glyphs?(*chars)
54
+ chars.all? { |char| has_glyph?(char) }
55
+ end
56
+
57
+ # Get the number of mapped characters
58
+ #
59
+ # @return [Integer] Number of unique character mappings
60
+ def character_count
61
+ unicode_mappings.size
62
+ end
63
+
64
+ # Get all mapped character codes
65
+ #
66
+ # @return [Array<Integer>] Array of Unicode codepoints
67
+ def character_codes
68
+ unicode_mappings.keys.sort
69
+ end
70
+
71
+ # Get all mapped glyphs
72
+ #
73
+ # @return [Array<Integer>] Array of glyph IDs
74
+ def glyph_ids
75
+ unicode_mappings.values.uniq.sort
76
+ end
77
+
78
+ # Check if BMP (Basic Multilingual Plane) coverage exists
79
+ #
80
+ # @return [Boolean] true if BMP characters (U+0000-U+FFFF) are mapped
81
+ def has_bmp_coverage?
82
+ return false unless parsed
83
+
84
+ parsed.has_bmp_coverage?
85
+ end
86
+
87
+ # Check if specific required characters are mapped
88
+ #
89
+ # @param chars [Array<Integer>] Unicode codepoints that must be present
90
+ # @return [Boolean] true if all required characters are mapped
91
+ def has_required_characters?(*chars)
92
+ return false unless parsed
93
+
94
+ parsed.has_required_characters?(*chars)
95
+ end
96
+
97
+ # Check if space character is mapped
98
+ #
99
+ # @return [Boolean] true if U+0020 (space) is mapped
100
+ def has_space?
101
+ has_glyph?(0x0020)
102
+ end
103
+
104
+ # Check if common Latin characters are mapped
105
+ #
106
+ # @return [Boolean] true if A-Z, a-z are mapped
107
+ def has_basic_latin?
108
+ # Check uppercase A-Z
109
+ return false unless has_glyphs?(*(0x0041..0x005A).to_a)
110
+
111
+ # Check lowercase a-z
112
+ has_glyphs?(*(0x0061..0x007A).to_a)
113
+ end
114
+
115
+ # Check if digits are mapped
116
+ #
117
+ # @return [Boolean] true if 0-9 are mapped
118
+ def has_digits?
119
+ has_glyphs?(*(0x0030..0x0039).to_a)
120
+ end
121
+
122
+ # Check if common punctuation is mapped
123
+ #
124
+ # @return [Boolean] true if common punctuation marks are mapped
125
+ def has_basic_punctuation?
126
+ required = [0x0020, 0x0021, 0x0022, 0x0027, 0x0028, 0x0029, 0x002C, 0x002E,
127
+ 0x003A, 0x003B, 0x003F, 0x005F] # space !"()',.:;?_
128
+ has_required_characters?(*required)
129
+ end
130
+
131
+ # Get glyph IDs for a string of characters
132
+ #
133
+ # @param text [String] Text string
134
+ # @return [Array<Integer>] Array of glyph IDs
135
+ def glyphs_for_text(text)
136
+ text.chars.map { |char| glyph_for(char) || 0 }
137
+ end
138
+
139
+ # Create a simple text rendering glyph sequence
140
+ #
141
+ # @param text [String] Text string
142
+ # @return [Array<Integer>] Array of glyph IDs for rendering
143
+ def glyph_sequence_for(text)
144
+ glyphs_for_text(text)
145
+ end
146
+
147
+ # Get the highest Unicode codepoint mapped
148
+ #
149
+ # @return [Integer, nil] Maximum codepoint, or nil if no mappings
150
+ def max_codepoint
151
+ codes = character_codes
152
+ codes.last unless codes.empty?
153
+ end
154
+
155
+ # Get the lowest Unicode codepoint mapped
156
+ #
157
+ # @return [Integer, nil] Minimum codepoint, or nil if no mappings
158
+ def min_codepoint
159
+ codes = character_codes
160
+ codes.first unless codes.empty?
161
+ end
162
+
163
+ # Check if font has full Unicode coverage
164
+ #
165
+ # @return [Boolean] true if characters beyond BMP are mapped
166
+ def has_full_unicode?
167
+ max_cp = max_codepoint
168
+ !max_cp.nil? && max_cp > 0xFFFF
169
+ end
170
+
171
+ # Get mapping statistics
172
+ #
173
+ # @return [Hash] Statistics about the character mapping
174
+ def statistics
175
+ {
176
+ character_count: character_count,
177
+ glyph_count: glyph_ids.size,
178
+ min_codepoint: min_codepoint,
179
+ max_codepoint: max_codepoint,
180
+ has_bmp: has_bmp_coverage?,
181
+ has_full_unicode: has_full_unicode?,
182
+ has_space: has_space?,
183
+ has_basic_latin: has_basic_latin?,
184
+ has_digits: has_digits?,
185
+ }
186
+ end
187
+
188
+ protected
189
+
190
+ # Validate the parsed cmap table
191
+ #
192
+ # @return [Boolean] true if valid
193
+ # @raise [InvalidFontError] if cmap table is invalid
194
+ def validate_parsed_table?
195
+ return true unless parsed
196
+
197
+ # Validate version
198
+ unless parsed.valid_version?
199
+ raise InvalidFontError,
200
+ "Invalid cmap table version: #{parsed.version} (must be 0)"
201
+ end
202
+
203
+ # Validate subtables exist
204
+ unless parsed.has_subtables?
205
+ raise InvalidFontError,
206
+ "Invalid cmap table: no subtables found (num_tables=#{parsed.num_tables})"
207
+ end
208
+
209
+ # Validate Unicode mapping exists
210
+ unless parsed.has_unicode_mapping?
211
+ raise InvalidFontError,
212
+ "Invalid cmap table: no Unicode mappings found"
213
+ end
214
+
215
+ # Validate BMP coverage (required for fonts)
216
+ unless parsed.has_bmp_coverage?
217
+ raise InvalidFontError,
218
+ "Invalid cmap table: no BMP character coverage found"
219
+ end
220
+
221
+ # Validate required characters (space at minimum)
222
+ unless parsed.has_required_characters?(0x0020)
223
+ raise InvalidFontError,
224
+ "Invalid cmap table: missing required character U+0020 (space)"
225
+ end
226
+
227
+ true
228
+ end
229
+ end
230
+ end
231
+ end