fontisan 0.2.7 → 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.
Files changed (83) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +103 -0
  3. data/.rubocop_todo.yml +65 -361
  4. data/CHANGELOG.md +116 -0
  5. data/Gemfile +1 -1
  6. data/README.adoc +106 -27
  7. data/Rakefile +12 -7
  8. data/benchmark/variation_quick_bench.rb +1 -1
  9. data/docs/APPLE_LEGACY_FONTS.adoc +173 -0
  10. data/docs/COLLECTION_VALIDATION.adoc +143 -0
  11. data/docs/COLOR_FONTS.adoc +127 -0
  12. data/docs/DOCUMENTATION_SUMMARY.md +141 -0
  13. data/docs/FONT_HINTING.adoc +9 -1
  14. data/docs/VALIDATION.adoc +254 -0
  15. data/docs/WOFF_WOFF2_FORMATS.adoc +94 -0
  16. data/lib/fontisan/cli.rb +45 -13
  17. data/lib/fontisan/collection/dfont_builder.rb +2 -1
  18. data/lib/fontisan/commands/convert_command.rb +2 -4
  19. data/lib/fontisan/commands/info_command.rb +3 -3
  20. data/lib/fontisan/commands/pack_command.rb +2 -1
  21. data/lib/fontisan/commands/validate_command.rb +157 -6
  22. data/lib/fontisan/converters/collection_converter.rb +22 -13
  23. data/lib/fontisan/converters/svg_generator.rb +2 -1
  24. data/lib/fontisan/converters/woff2_encoder.rb +6 -6
  25. data/lib/fontisan/converters/woff_writer.rb +3 -1
  26. data/lib/fontisan/font_loader.rb +7 -6
  27. data/lib/fontisan/formatters/text_formatter.rb +18 -14
  28. data/lib/fontisan/hints/hint_converter.rb +1 -1
  29. data/lib/fontisan/hints/hint_validator.rb +13 -10
  30. data/lib/fontisan/hints/truetype_instruction_analyzer.rb +15 -8
  31. data/lib/fontisan/hints/truetype_instruction_generator.rb +1 -1
  32. data/lib/fontisan/models/collection_validation_report.rb +104 -0
  33. data/lib/fontisan/models/font_report.rb +24 -0
  34. data/lib/fontisan/models/validation_report.rb +7 -2
  35. data/lib/fontisan/open_type_font.rb +18 -425
  36. data/lib/fontisan/optimizers/charstring_rewriter.rb +1 -1
  37. data/lib/fontisan/optimizers/subroutine_optimizer.rb +6 -2
  38. data/lib/fontisan/sfnt_font.rb +699 -0
  39. data/lib/fontisan/sfnt_table.rb +264 -0
  40. data/lib/fontisan/subset/glyph_mapping.rb +2 -0
  41. data/lib/fontisan/subset/table_subsetter.rb +2 -2
  42. data/lib/fontisan/tables/cblc.rb +8 -4
  43. data/lib/fontisan/tables/cff/index.rb +2 -0
  44. data/lib/fontisan/tables/cff.rb +6 -3
  45. data/lib/fontisan/tables/cff2/private_dict_blend_handler.rb +1 -1
  46. data/lib/fontisan/tables/cff2.rb +1 -1
  47. data/lib/fontisan/tables/cmap.rb +5 -5
  48. data/lib/fontisan/tables/cmap_table.rb +231 -0
  49. data/lib/fontisan/tables/glyf.rb +8 -10
  50. data/lib/fontisan/tables/glyf_table.rb +255 -0
  51. data/lib/fontisan/tables/head.rb +3 -3
  52. data/lib/fontisan/tables/head_table.rb +111 -0
  53. data/lib/fontisan/tables/hhea.rb +4 -4
  54. data/lib/fontisan/tables/hhea_table.rb +255 -0
  55. data/lib/fontisan/tables/hmtx_table.rb +191 -0
  56. data/lib/fontisan/tables/loca_table.rb +212 -0
  57. data/lib/fontisan/tables/maxp.rb +2 -2
  58. data/lib/fontisan/tables/maxp_table.rb +258 -0
  59. data/lib/fontisan/tables/name.rb +1 -1
  60. data/lib/fontisan/tables/name_table.rb +176 -0
  61. data/lib/fontisan/tables/os2.rb +8 -8
  62. data/lib/fontisan/tables/os2_table.rb +329 -0
  63. data/lib/fontisan/tables/post.rb +2 -2
  64. data/lib/fontisan/tables/post_table.rb +183 -0
  65. data/lib/fontisan/tables/sbix.rb +5 -4
  66. data/lib/fontisan/true_type_font.rb +12 -464
  67. data/lib/fontisan/utilities/checksum_calculator.rb +0 -44
  68. data/lib/fontisan/validation/collection_validator.rb +4 -2
  69. data/lib/fontisan/validators/basic_validator.rb +11 -21
  70. data/lib/fontisan/validators/font_book_validator.rb +29 -50
  71. data/lib/fontisan/validators/opentype_validator.rb +24 -28
  72. data/lib/fontisan/validators/validator.rb +87 -66
  73. data/lib/fontisan/validators/web_font_validator.rb +16 -21
  74. data/lib/fontisan/version.rb +1 -1
  75. data/lib/fontisan/woff2/glyf_transformer.rb +31 -8
  76. data/lib/fontisan/woff2/hmtx_transformer.rb +2 -1
  77. data/lib/fontisan/woff2/table_transformer.rb +4 -2
  78. data/lib/fontisan/woff2_font.rb +4 -2
  79. data/lib/fontisan/woff_font.rb +46 -30
  80. data/lib/fontisan.rb +2 -2
  81. data/scripts/compare_stack_aware.rb +1 -1
  82. data/scripts/measure_optimization.rb +1 -2
  83. metadata +23 -2
@@ -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
@@ -30,6 +30,8 @@ module Fontisan
30
30
  # mapping = Fontisan::Subset::GlyphMapping.new([0, 5, 10])
31
31
  # mapping.old_id(1) # => 5
32
32
  class GlyphMapping
33
+ include Enumerable
34
+
33
35
  # @return [Hash<Integer, Integer>] mapping from old GIDs to new GIDs
34
36
  attr_reader :old_to_new
35
37
 
@@ -152,7 +152,7 @@ module Fontisan
152
152
  # Build new hmtx data
153
153
  data = String.new(encoding: Encoding::BINARY)
154
154
 
155
- mapping.each do |old_id, _new_id|
155
+ mapping.old_ids.each do |old_id|
156
156
  metric = table.metric_for(old_id)
157
157
  next unless metric
158
158
 
@@ -319,7 +319,7 @@ module Fontisan
319
319
  current_offset = 0
320
320
 
321
321
  # Process glyphs in mapping order
322
- mapping.each do |old_id, _new_id|
322
+ mapping.old_ids.each do |old_id|
323
323
  @loca_offsets << current_offset
324
324
 
325
325
  # Get offset and size from original loca
@@ -86,9 +86,12 @@ module Fontisan
86
86
  size = new
87
87
 
88
88
  io = StringIO.new(data)
89
- size.instance_variable_set(:@index_subtable_array_offset, io.read(4).unpack1("N"))
90
- size.instance_variable_set(:@index_tables_size, io.read(4).unpack1("N"))
91
- size.instance_variable_set(:@number_of_index_subtables, io.read(4).unpack1("N"))
89
+ size.instance_variable_set(:@index_subtable_array_offset,
90
+ io.read(4).unpack1("N"))
91
+ size.instance_variable_set(:@index_tables_size,
92
+ io.read(4).unpack1("N"))
93
+ size.instance_variable_set(:@number_of_index_subtables,
94
+ io.read(4).unpack1("N"))
92
95
  size.instance_variable_set(:@color_ref, io.read(4).unpack1("N"))
93
96
 
94
97
  # Parse hori and vert metrics (12 bytes each)
@@ -98,7 +101,8 @@ module Fontisan
98
101
  size.instance_variable_set(:@vert, SbitLineMetrics.read(vert_data))
99
102
 
100
103
  # Parse remaining fields
101
- size.instance_variable_set(:@start_glyph_index, io.read(2).unpack1("n"))
104
+ size.instance_variable_set(:@start_glyph_index,
105
+ io.read(2).unpack1("n"))
102
106
  size.instance_variable_set(:@end_glyph_index, io.read(2).unpack1("n"))
103
107
  size.instance_variable_set(:@ppem_x, io.read(1).unpack1("C"))
104
108
  size.instance_variable_set(:@ppem_y, io.read(1).unpack1("C"))
@@ -35,6 +35,8 @@ module Fontisan
35
35
  # puts index[0] # => first item data
36
36
  # index.each { |item| puts item }
37
37
  class Index
38
+ include Enumerable
39
+
38
40
  # @return [Integer] Number of items in the INDEX
39
41
  attr_reader :count
40
42
 
@@ -299,7 +299,8 @@ module Fontisan
299
299
  io.seek(absolute_offset)
300
300
  Index.new(io, start_offset: absolute_offset)
301
301
  rescue StandardError => e
302
- raise CorruptedTableError, "Failed to parse Local Subr INDEX: #{e.message}"
302
+ raise CorruptedTableError,
303
+ "Failed to parse Local Subr INDEX: #{e.message}"
303
304
  end
304
305
 
305
306
  # Get the CharStrings INDEX for a specific font
@@ -320,7 +321,8 @@ module Fontisan
320
321
  io.seek(charstrings_offset)
321
322
  CharstringsIndex.new(io, start_offset: charstrings_offset)
322
323
  rescue StandardError => e
323
- raise CorruptedTableError, "Failed to parse CharStrings INDEX: #{e.message}"
324
+ raise CorruptedTableError,
325
+ "Failed to parse CharStrings INDEX: #{e.message}"
324
326
  end
325
327
 
326
328
  # Get a CharString for a specific glyph
@@ -355,7 +357,8 @@ module Fontisan
355
357
  local_subr_index,
356
358
  )
357
359
  rescue StandardError => e
358
- raise CorruptedTableError, "Failed to get CharString for glyph #{glyph_index}: #{e.message}"
360
+ raise CorruptedTableError,
361
+ "Failed to get CharString for glyph #{glyph_index}: #{e.message}"
359
362
  end
360
363
 
361
364
  # Get the number of glyphs in a font
@@ -51,7 +51,7 @@ module Fontisan
51
51
  # Check if this is blend data
52
52
  # Format: base1 delta1_1 ... delta1_N base2 delta2_1 ... delta2_N ...
53
53
  # The array must be divisible by (num_axes + 1)
54
- return nil unless value.size % (num_axes + 1) == 0
54
+ return nil unless (value.size % (num_axes + 1)).zero?
55
55
 
56
56
  num_values = value.size / (num_axes + 1)
57
57
  blends = []
@@ -121,7 +121,7 @@ module Fontisan
121
121
  def charstrings
122
122
  return [] unless @charstrings_index
123
123
 
124
- @charstrings_index.count.times.map do |glyph_id|
124
+ Array.new(@charstrings_index.count) do |glyph_id|
125
125
  charstring_for_glyph(glyph_id)
126
126
  end.compact
127
127
  end
@@ -278,22 +278,20 @@ module Fontisan
278
278
  end
279
279
  end
280
280
 
281
- public
282
-
283
281
  # Validation helper: Check if version is valid
284
282
  #
285
283
  # cmap version should be 0
286
284
  #
287
285
  # @return [Boolean] True if version is 0
288
286
  def valid_version?
289
- version == 0
287
+ version.zero?
290
288
  end
291
289
 
292
290
  # Validation helper: Check if at least one subtable exists
293
291
  #
294
292
  # @return [Boolean] True if num_tables > 0
295
293
  def has_subtables?
296
- num_tables && num_tables > 0
294
+ num_tables&.positive?
297
295
  end
298
296
 
299
297
  # Validation helper: Check if Unicode mapping exists
@@ -357,7 +355,9 @@ module Fontisan
357
355
  mappings = unicode_mappings
358
356
  return true if mappings.nil? || mappings.empty?
359
357
 
360
- mappings.values.all? { |glyph_id| glyph_id >= 0 && glyph_id < max_glyph_id }
358
+ mappings.values.all? do |glyph_id|
359
+ glyph_id >= 0 && glyph_id < max_glyph_id
360
+ end
361
361
  end
362
362
  end
363
363
  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
@@ -166,7 +166,7 @@ module Fontisan
166
166
  # @param head [Head] Head table for context
167
167
  # @param num_glyphs [Integer] Total number of glyphs to check
168
168
  # @return [Boolean] True if all non-special glyphs have contours
169
- def no_empty_glyphs_except_special?(loca, head, num_glyphs)
169
+ def no_empty_glyphs_except_special?(loca, _head, num_glyphs)
170
170
  # Check glyphs 1 through num_glyphs-1 (.notdef at 0 can be empty)
171
171
  (1...num_glyphs).all? do |glyph_id|
172
172
  size = loca.size_of(glyph_id)
@@ -194,7 +194,7 @@ module Fontisan
194
194
 
195
195
  (0...num_glyphs).all? do |glyph_id|
196
196
  glyph = glyph_for(glyph_id, loca, head)
197
- next true if glyph.nil? # Empty glyphs are OK
197
+ next true if glyph.nil? # Empty glyphs are OK
198
198
 
199
199
  # Check if glyph bounds are within font bounds
200
200
  glyph.x_min >= font_x_min &&
@@ -218,7 +218,7 @@ module Fontisan
218
218
  def instructions_sound?(loca, head, num_glyphs)
219
219
  (0...num_glyphs).all? do |glyph_id|
220
220
  glyph = glyph_for(glyph_id, loca, head)
221
- next true if glyph.nil? # Empty glyphs are OK
221
+ next true if glyph.nil? # Empty glyphs are OK
222
222
 
223
223
  # Simple glyphs have instructions
224
224
  if glyph.respond_to?(:instruction_length)
@@ -242,7 +242,7 @@ module Fontisan
242
242
  # @return [Boolean] True if contour count is valid
243
243
  def valid_contour_count?(glyph_id, loca, head)
244
244
  glyph = glyph_for(glyph_id, loca, head)
245
- return true if glyph.nil? # Empty glyphs are OK
245
+ return true if glyph.nil? # Empty glyphs are OK
246
246
 
247
247
  # Simple glyphs: contours should be >= 0
248
248
  # Compound glyphs: numberOfContours = -1
@@ -265,12 +265,10 @@ module Fontisan
265
265
  # @return [Boolean] True if all glyphs can be accessed
266
266
  def all_glyphs_accessible?(loca, head, num_glyphs)
267
267
  (0...num_glyphs).all? do |glyph_id|
268
- begin
269
- glyph_for(glyph_id, loca, head)
270
- true
271
- rescue Fontisan::CorruptedTableError
272
- false
273
- end
268
+ glyph_for(glyph_id, loca, head)
269
+ true
270
+ rescue Fontisan::CorruptedTableError
271
+ false
274
272
  end
275
273
  rescue StandardError
276
274
  false