fontisan 0.2.3 → 0.2.5

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 (65) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +221 -49
  3. data/README.adoc +519 -5
  4. data/Rakefile +20 -7
  5. data/lib/fontisan/cli.rb +67 -6
  6. data/lib/fontisan/commands/base_command.rb +2 -19
  7. data/lib/fontisan/commands/convert_command.rb +16 -13
  8. data/lib/fontisan/commands/info_command.rb +88 -0
  9. data/lib/fontisan/commands/validate_command.rb +107 -151
  10. data/lib/fontisan/config/conversion_matrix.yml +58 -20
  11. data/lib/fontisan/converters/outline_converter.rb +6 -3
  12. data/lib/fontisan/converters/svg_generator.rb +45 -0
  13. data/lib/fontisan/converters/woff2_encoder.rb +84 -13
  14. data/lib/fontisan/models/bitmap_glyph.rb +123 -0
  15. data/lib/fontisan/models/bitmap_strike.rb +94 -0
  16. data/lib/fontisan/models/color_glyph.rb +57 -0
  17. data/lib/fontisan/models/color_layer.rb +53 -0
  18. data/lib/fontisan/models/color_palette.rb +60 -0
  19. data/lib/fontisan/models/font_info.rb +26 -0
  20. data/lib/fontisan/models/svg_glyph.rb +89 -0
  21. data/lib/fontisan/models/validation_report.rb +227 -0
  22. data/lib/fontisan/open_type_font.rb +6 -0
  23. data/lib/fontisan/optimizers/charstring_rewriter.rb +19 -8
  24. data/lib/fontisan/optimizers/pattern_analyzer.rb +4 -2
  25. data/lib/fontisan/optimizers/subroutine_builder.rb +6 -5
  26. data/lib/fontisan/optimizers/subroutine_optimizer.rb +5 -2
  27. data/lib/fontisan/pipeline/output_writer.rb +2 -2
  28. data/lib/fontisan/pipeline/transformation_pipeline.rb +4 -8
  29. data/lib/fontisan/tables/cbdt.rb +169 -0
  30. data/lib/fontisan/tables/cblc.rb +290 -0
  31. data/lib/fontisan/tables/cff.rb +6 -12
  32. data/lib/fontisan/tables/cmap.rb +82 -2
  33. data/lib/fontisan/tables/colr.rb +291 -0
  34. data/lib/fontisan/tables/cpal.rb +281 -0
  35. data/lib/fontisan/tables/glyf/glyph_builder.rb +5 -1
  36. data/lib/fontisan/tables/glyf.rb +118 -0
  37. data/lib/fontisan/tables/head.rb +60 -0
  38. data/lib/fontisan/tables/hhea.rb +74 -0
  39. data/lib/fontisan/tables/maxp.rb +60 -0
  40. data/lib/fontisan/tables/name.rb +76 -0
  41. data/lib/fontisan/tables/os2.rb +113 -0
  42. data/lib/fontisan/tables/post.rb +57 -0
  43. data/lib/fontisan/tables/sbix.rb +379 -0
  44. data/lib/fontisan/tables/svg.rb +301 -0
  45. data/lib/fontisan/true_type_font.rb +6 -0
  46. data/lib/fontisan/validators/basic_validator.rb +85 -0
  47. data/lib/fontisan/validators/font_book_validator.rb +130 -0
  48. data/lib/fontisan/validators/opentype_validator.rb +112 -0
  49. data/lib/fontisan/validators/profile_loader.rb +139 -0
  50. data/lib/fontisan/validators/validator.rb +484 -0
  51. data/lib/fontisan/validators/web_font_validator.rb +102 -0
  52. data/lib/fontisan/version.rb +1 -1
  53. data/lib/fontisan/woff2/directory.rb +40 -11
  54. data/lib/fontisan/woff2/table_transformer.rb +506 -73
  55. data/lib/fontisan/woff2_font.rb +29 -9
  56. data/lib/fontisan/woff_font.rb +17 -4
  57. data/lib/fontisan.rb +90 -6
  58. metadata +20 -9
  59. data/lib/fontisan/config/validation_rules.yml +0 -149
  60. data/lib/fontisan/validation/checksum_validator.rb +0 -170
  61. data/lib/fontisan/validation/consistency_validator.rb +0 -197
  62. data/lib/fontisan/validation/structure_validator.rb +0 -198
  63. data/lib/fontisan/validation/table_validator.rb +0 -158
  64. data/lib/fontisan/validation/validator.rb +0 -152
  65. data/lib/fontisan/validation/variable_font_validator.rb +0 -218
@@ -0,0 +1,379 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "stringio"
4
+ require_relative "../binary/base_record"
5
+
6
+ module Fontisan
7
+ module Tables
8
+ # sbix (Standard Bitmap Graphics) table parser
9
+ #
10
+ # The sbix table contains embedded bitmap graphics (PNG, JPEG, TIFF)
11
+ # organized by strike sizes. This is Apple's format for color emoji.
12
+ #
13
+ # sbix Table Structure:
14
+ # ```
15
+ # sbix Table = Header (8 bytes)
16
+ # + Strike Offsets Array (4 bytes × numStrikes)
17
+ # + Strike Data (variable)
18
+ # ```
19
+ #
20
+ # Header (8 bytes):
21
+ # - version (uint16): Table version (1)
22
+ # - flags (uint16): Flags (0)
23
+ # - numStrikes (uint32): Number of bitmap strikes
24
+ #
25
+ # Each Strike contains:
26
+ # - ppem (uint16): Pixels per em
27
+ # - ppi (uint16): Pixels per inch (usually 72)
28
+ # - glyphDataOffsets (uint32 × numGlyphs+1): Array of glyph data offsets
29
+ # - glyph data records (variable)
30
+ #
31
+ # Glyph Data Record:
32
+ # - originOffsetX (int16): X offset
33
+ # - originOffsetY (int16): Y offset
34
+ # - graphicType (uint32): 'png ', 'jpg ', 'tiff', 'dupe', 'mask'
35
+ # - data (variable): Image data
36
+ #
37
+ # Reference: https://docs.microsoft.com/en-us/typography/opentype/spec/sbix
38
+ #
39
+ # @example Reading an sbix table
40
+ # data = font.table_data['sbix']
41
+ # sbix = Fontisan::Tables::Sbix.read(data)
42
+ # strikes = sbix.strikes
43
+ # png_data = sbix.glyph_data(42, 64) # Get glyph 42 at 64 ppem
44
+ class Sbix < Binary::BaseRecord
45
+ # OpenType table tag for sbix
46
+ TAG = "sbix"
47
+
48
+ # Supported sbix version
49
+ VERSION_1 = 1
50
+
51
+ # Graphic type constants (4-byte ASCII codes)
52
+ GRAPHIC_TYPE_PNG = 0x706E6720 # 'png '
53
+ GRAPHIC_TYPE_JPG = 0x6A706720 # 'jpg '
54
+ GRAPHIC_TYPE_TIFF = 0x74696666 # 'tiff'
55
+ GRAPHIC_TYPE_DUPE = 0x64757065 # 'dupe'
56
+ GRAPHIC_TYPE_MASK = 0x6D61736B # 'mask'
57
+
58
+ # Graphic type names
59
+ GRAPHIC_TYPE_NAMES = {
60
+ GRAPHIC_TYPE_PNG => "PNG",
61
+ GRAPHIC_TYPE_JPG => "JPEG",
62
+ GRAPHIC_TYPE_TIFF => "TIFF",
63
+ GRAPHIC_TYPE_DUPE => "dupe",
64
+ GRAPHIC_TYPE_MASK => "mask",
65
+ }.freeze
66
+
67
+ # @return [Integer] sbix version (should be 1)
68
+ attr_reader :version
69
+
70
+ # @return [Integer] Flags (reserved, should be 0)
71
+ attr_reader :flags
72
+
73
+ # @return [Integer] Number of bitmap strikes
74
+ attr_reader :num_strikes
75
+
76
+ # @return [Array<Integer>] Offsets to strike data from start of table
77
+ attr_reader :strike_offsets
78
+
79
+ # @return [Array<Hash>] Parsed strike records
80
+ attr_reader :strikes
81
+
82
+ # @return [String] Raw binary data for the entire sbix table
83
+ attr_reader :raw_data
84
+
85
+ # Override read to parse sbix structure
86
+ #
87
+ # @param io [IO, String] Binary data to read
88
+ # @return [Sbix] Parsed sbix table
89
+ def self.read(io)
90
+ sbix = new
91
+ return sbix if io.nil?
92
+
93
+ data = io.is_a?(String) ? io : io.read
94
+ sbix.parse!(data)
95
+ sbix
96
+ end
97
+
98
+ # Parse the sbix table structure
99
+ #
100
+ # @param data [String] Binary data for the sbix table
101
+ # @raise [CorruptedTableError] If sbix structure is invalid
102
+ def parse!(data)
103
+ @raw_data = data
104
+ io = StringIO.new(data)
105
+
106
+ # Parse sbix header (8 bytes)
107
+ parse_header(io)
108
+ validate_header!
109
+
110
+ # Parse strike offsets
111
+ parse_strike_offsets(io)
112
+
113
+ # Parse strike records
114
+ parse_strikes
115
+ rescue StandardError => e
116
+ raise CorruptedTableError, "Failed to parse sbix table: #{e.message}"
117
+ end
118
+
119
+ # Get glyph data at specific ppem
120
+ #
121
+ # @param glyph_id [Integer] Glyph ID
122
+ # @param ppem [Integer] Pixels per em
123
+ # @return [Hash, nil] Glyph data hash with keys: :origin_x, :origin_y, :graphic_type, :data
124
+ def glyph_data(glyph_id, ppem)
125
+ strike = strike_for_ppem(ppem)
126
+ return nil unless strike
127
+
128
+ extract_glyph_data(strike, glyph_id)
129
+ end
130
+
131
+ # Get strike for specific ppem
132
+ #
133
+ # @param ppem [Integer] Pixels per em
134
+ # @return [Hash, nil] Strike record or nil
135
+ def strike_for_ppem(ppem)
136
+ strikes&.find { |s| s[:ppem] == ppem }
137
+ end
138
+
139
+ # Get all ppem sizes
140
+ #
141
+ # @return [Array<Integer>] Sorted array of ppem sizes
142
+ def ppem_sizes
143
+ return [] unless strikes
144
+
145
+ strikes.map { |s| s[:ppem] }.uniq.sort
146
+ end
147
+
148
+ # Check if glyph has bitmap at ppem
149
+ #
150
+ # @param glyph_id [Integer] Glyph ID
151
+ # @param ppem [Integer] Pixels per em
152
+ # @return [Boolean] True if glyph has bitmap
153
+ def has_glyph_at_ppem?(glyph_id, ppem)
154
+ data = glyph_data(glyph_id, ppem)
155
+ !data.nil? && data[:data] && !data[:data].empty?
156
+ end
157
+
158
+ # Get supported graphic formats across all strikes
159
+ #
160
+ # @return [Array<String>] Array of format names (e.g., ["PNG", "JPEG"])
161
+ def supported_formats
162
+ return [] unless strikes
163
+
164
+ formats = []
165
+ strikes.each do |strike|
166
+ # Sample first few glyphs to detect formats
167
+ strike[:graphic_types]&.each do |type|
168
+ format_name = GRAPHIC_TYPE_NAMES[type]
169
+ formats << format_name if format_name && !["dupe", "mask"].include?(format_name)
170
+ end
171
+ end
172
+ formats.uniq.compact
173
+ end
174
+
175
+ # Validate the sbix table structure
176
+ #
177
+ # @return [Boolean] True if valid
178
+ def valid?
179
+ return false if version.nil?
180
+ return false if version != VERSION_1
181
+ return false if num_strikes.nil? || num_strikes.negative?
182
+ return false unless strikes
183
+
184
+ true
185
+ end
186
+
187
+ private
188
+
189
+ # Parse sbix header (8 bytes)
190
+ #
191
+ # @param io [StringIO] Input stream
192
+ def parse_header(io)
193
+ @version = io.read(2).unpack1("n")
194
+ @flags = io.read(2).unpack1("n")
195
+ @num_strikes = io.read(4).unpack1("N")
196
+ end
197
+
198
+ # Validate header values
199
+ #
200
+ # @raise [CorruptedTableError] If validation fails
201
+ def validate_header!
202
+ unless version == VERSION_1
203
+ raise CorruptedTableError,
204
+ "Unsupported sbix version: #{version} (only version 1 supported)"
205
+ end
206
+
207
+ if num_strikes.negative?
208
+ raise CorruptedTableError,
209
+ "Invalid numStrikes: #{num_strikes}"
210
+ end
211
+ end
212
+
213
+ # Parse strike offsets array
214
+ #
215
+ # @param io [StringIO] Input stream
216
+ def parse_strike_offsets(io)
217
+ @strike_offsets = []
218
+ return if num_strikes.zero?
219
+
220
+ num_strikes.times do
221
+ @strike_offsets << io.read(4).unpack1("N")
222
+ end
223
+ end
224
+
225
+ # Parse all strike records
226
+ #
227
+ # The number of glyphs is calculated from offset differences
228
+ def parse_strikes
229
+ @strikes = []
230
+ return if num_strikes.zero?
231
+
232
+ strike_offsets.each_with_index do |offset, index|
233
+ # Calculate strike size from offset difference
234
+ next_offset = if index < num_strikes - 1
235
+ strike_offsets[index + 1]
236
+ else
237
+ raw_data.length
238
+ end
239
+
240
+ strike = parse_strike(offset, next_offset - offset)
241
+ @strikes << strike
242
+ end
243
+ end
244
+
245
+ # Parse a single strike record
246
+ #
247
+ # @param offset [Integer] Offset from start of table
248
+ # @param size [Integer] Size of strike data
249
+ # @return [Hash] Strike record
250
+ def parse_strike(offset, size)
251
+ io = StringIO.new(raw_data)
252
+ io.seek(offset)
253
+
254
+ ppem = io.read(2).unpack1("n")
255
+ ppi = io.read(2).unpack1("n")
256
+
257
+ # Read glyph data offsets - they're relative to the start of the strike
258
+ # The array is numGlyphs+1 long, with the last offset marking the end
259
+ glyph_offsets = []
260
+
261
+ # Keep reading offsets until we find the pattern
262
+ # Offsets are relative to strike start, so they should be monotonically increasing
263
+ loop do
264
+ current_pos = io.pos
265
+ break if current_pos >= offset + size
266
+
267
+ offset_value = io.read(4)&.unpack1("N")
268
+ break unless offset_value
269
+
270
+ # If offset is beyond the strike size or smaller than previous, we've hit glyph data
271
+ if glyph_offsets.any? && offset_value < glyph_offsets.last
272
+ # Rewind - we read part of glyph data
273
+ io.seek(current_pos)
274
+ break
275
+ end
276
+
277
+ glyph_offsets << offset_value
278
+ end
279
+
280
+ num_glyphs = [glyph_offsets.length - 1, 0].max
281
+
282
+ # Sample graphic types from first few glyphs
283
+ graphic_types = sample_graphic_types(offset, glyph_offsets, size)
284
+
285
+ {
286
+ ppem: ppem,
287
+ ppi: ppi,
288
+ num_glyphs: num_glyphs,
289
+ base_offset: offset,
290
+ glyph_offsets: glyph_offsets,
291
+ graphic_types: graphic_types,
292
+ }
293
+ end
294
+
295
+ # Sample graphic types from first few glyphs
296
+ #
297
+ # @param strike_offset [Integer] Strike offset from table start
298
+ # @param glyph_offsets [Array<Integer>] Glyph data offsets (relative to strike start)
299
+ # @param strike_size [Integer] Total strike size
300
+ # @return [Array<Integer>] Unique graphic type codes found
301
+ def sample_graphic_types(strike_offset, glyph_offsets, strike_size)
302
+ types = []
303
+ return types if glyph_offsets.length < 2
304
+
305
+ # Sample first 5 glyphs or all glyphs if fewer
306
+ sample_count = [5, glyph_offsets.length - 1].min
307
+
308
+ sample_count.times do |i|
309
+ # Offsets are relative to strike start
310
+ glyph_offset = glyph_offsets[i]
311
+ next_glyph_offset = glyph_offsets[i + 1]
312
+
313
+ # Check if offsets are valid
314
+ next if glyph_offset >= strike_size || next_glyph_offset > strike_size
315
+ next if next_glyph_offset <= glyph_offset # Empty glyph
316
+
317
+ # Calculate absolute offset in table
318
+ # glyph_offset is relative to strike start, so add strike_offset
319
+ absolute_offset = strike_offset + glyph_offset
320
+ next if absolute_offset + 8 > raw_data.length # Need at least header
321
+
322
+ # Read graphic type (skip originOffsetX and originOffsetY = 4 bytes)
323
+ io = StringIO.new(raw_data)
324
+ io.seek(absolute_offset + 4)
325
+ graphic_type = io.read(4)&.unpack1("N")
326
+ types << graphic_type if graphic_type
327
+ end
328
+
329
+ types.compact.uniq
330
+ end
331
+
332
+ # Extract glyph data from strike
333
+ #
334
+ # @param strike [Hash] Strike record
335
+ # @param glyph_id [Integer] Glyph ID
336
+ # @return [Hash, nil] Glyph data or nil
337
+ def extract_glyph_data(strike, glyph_id)
338
+ return nil unless strike
339
+ return nil if glyph_id >= strike[:num_glyphs]
340
+ return nil unless strike[:glyph_offsets]
341
+ return nil if glyph_id >= strike[:glyph_offsets].length - 1
342
+
343
+ # Offsets are relative to strike start
344
+ offset = strike[:glyph_offsets][glyph_id]
345
+ next_offset = strike[:glyph_offsets][glyph_id + 1]
346
+
347
+ return nil unless offset && next_offset
348
+ return nil if next_offset <= offset # Empty glyph
349
+
350
+ # Calculate absolute position in table
351
+ absolute_offset = strike[:base_offset] + offset
352
+ data_length = next_offset - offset
353
+
354
+ # Need at least 8 bytes for glyph record header
355
+ return nil if data_length < 8
356
+ return nil if absolute_offset + data_length > raw_data.length
357
+
358
+ # Parse glyph data record
359
+ io = StringIO.new(raw_data)
360
+ io.seek(absolute_offset)
361
+
362
+ origin_x = io.read(2).unpack1("s>") # int16 big-endian
363
+ origin_y = io.read(2).unpack1("s>") # int16 big-endian
364
+ graphic_type = io.read(4).unpack1("N")
365
+
366
+ # Remaining bytes are the actual image data
367
+ image_data = io.read(data_length - 8)
368
+
369
+ {
370
+ origin_x: origin_x,
371
+ origin_y: origin_y,
372
+ graphic_type: graphic_type,
373
+ graphic_type_name: GRAPHIC_TYPE_NAMES[graphic_type] || "unknown",
374
+ data: image_data,
375
+ }
376
+ end
377
+ end
378
+ end
379
+ end
@@ -0,0 +1,301 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "stringio"
4
+ require "zlib"
5
+ require_relative "../binary/base_record"
6
+
7
+ module Fontisan
8
+ module Tables
9
+ # SVG (Scalable Vector Graphics) table parser
10
+ #
11
+ # The SVG table contains embedded SVG documents for glyphs, typically used
12
+ # for color emoji or graphic elements. Each document can cover a range of
13
+ # glyph IDs and may be compressed with gzip.
14
+ #
15
+ # SVG Table Structure:
16
+ # ```
17
+ # SVG Table = Header (10 bytes)
18
+ # + Document Index
19
+ # + SVG Documents
20
+ # ```
21
+ #
22
+ # Header (10 bytes):
23
+ # - version (uint16): Table version (0)
24
+ # - svgDocumentListOffset (uint32): Offset to SVG Document Index
25
+ # - reserved (uint32): Reserved, set to 0
26
+ #
27
+ # Document Index:
28
+ # - numEntries (uint16): Number of SVG Document Index Entries
29
+ # - entries[numEntries]: Array of SVG Document Index Entries
30
+ #
31
+ # SVG Document Index Entry (12 bytes):
32
+ # - startGlyphID (uint16): First glyph ID
33
+ # - endGlyphID (uint16): Last glyph ID (inclusive)
34
+ # - svgDocOffset (uint32): Offset to SVG document
35
+ # - svgDocLength (uint32): Length of SVG document
36
+ #
37
+ # SVG documents may be compressed with gzip (identified by magic bytes 0x1f 0x8b).
38
+ #
39
+ # Reference: OpenType SVG specification
40
+ # https://docs.microsoft.com/en-us/typography/opentype/spec/svg
41
+ #
42
+ # @example Reading an SVG table
43
+ # data = font.table_data['SVG ']
44
+ # svg = Fontisan::Tables::Svg.read(data)
45
+ # svg_content = svg.svg_for_glyph(42)
46
+ # puts "Glyph 42 SVG: #{svg_content}"
47
+ class Svg < Binary::BaseRecord
48
+ # OpenType table tag for SVG (note: includes trailing space)
49
+ TAG = "SVG "
50
+
51
+ # SVG Document Index Entry structure
52
+ #
53
+ # Each entry associates a glyph range with an SVG document.
54
+ # Structure (12 bytes): start_glyph_id, end_glyph_id, svg_doc_offset, svg_doc_length
55
+ class SvgDocumentRecord < Binary::BaseRecord
56
+ endian :big
57
+ uint16 :start_glyph_id
58
+ uint16 :end_glyph_id
59
+ uint32 :svg_doc_offset
60
+ uint32 :svg_doc_length
61
+
62
+ # Check if this record includes a specific glyph ID
63
+ #
64
+ # @param glyph_id [Integer] Glyph ID to check
65
+ # @return [Boolean] True if glyph is in range
66
+ def includes_glyph?(glyph_id)
67
+ glyph_id >= start_glyph_id && glyph_id <= end_glyph_id
68
+ end
69
+
70
+ # Get the glyph range for this record
71
+ #
72
+ # @return [Range] Range of glyph IDs
73
+ def glyph_range
74
+ start_glyph_id..end_glyph_id
75
+ end
76
+ end
77
+
78
+ # @return [Integer] SVG table version (0)
79
+ attr_reader :version
80
+
81
+ # @return [Integer] Offset to SVG Document Index
82
+ attr_reader :svg_document_list_offset
83
+
84
+ # @return [Integer] Number of SVG document entries
85
+ attr_reader :num_entries
86
+
87
+ # @return [Array<SvgDocumentRecord>] Parsed document records
88
+ attr_reader :document_records
89
+
90
+ # @return [String] Raw binary data for the entire SVG table
91
+ attr_reader :raw_data
92
+
93
+ # Override read to parse SVG structure
94
+ #
95
+ # @param io [IO, String] Binary data to read
96
+ # @return [Svg] Parsed SVG table
97
+ def self.read(io)
98
+ svg = new
99
+ return svg if io.nil?
100
+
101
+ data = io.is_a?(String) ? io : io.read
102
+ svg.parse!(data)
103
+ svg
104
+ end
105
+
106
+ # Parse the SVG table structure
107
+ #
108
+ # @param data [String] Binary data for the SVG table
109
+ # @raise [CorruptedTableError] If SVG structure is invalid
110
+ def parse!(data)
111
+ @raw_data = data
112
+ io = StringIO.new(data)
113
+
114
+ # Parse SVG header (10 bytes)
115
+ parse_header(io)
116
+ validate_header!
117
+
118
+ # Parse document index
119
+ parse_document_index(io)
120
+ rescue StandardError => e
121
+ raise CorruptedTableError, "Failed to parse SVG table: #{e.message}"
122
+ end
123
+
124
+ # Get SVG document for a specific glyph ID
125
+ #
126
+ # Returns the SVG XML content for the specified glyph.
127
+ # Automatically decompresses gzipped content.
128
+ # Returns nil if glyph has no SVG data.
129
+ #
130
+ # @param glyph_id [Integer] Glyph ID to look up
131
+ # @return [String, nil] SVG XML content or nil
132
+ def svg_for_glyph(glyph_id)
133
+ record = find_document_record(glyph_id)
134
+ return nil unless record
135
+
136
+ extract_svg_document(record)
137
+ end
138
+
139
+ # Check if glyph has SVG data
140
+ #
141
+ # @param glyph_id [Integer] Glyph ID to check
142
+ # @return [Boolean] True if glyph has SVG
143
+ def has_svg_for_glyph?(glyph_id)
144
+ !find_document_record(glyph_id).nil?
145
+ end
146
+
147
+ # Get all glyph IDs that have SVG data
148
+ #
149
+ # @return [Array<Integer>] Array of glyph IDs with SVG
150
+ def glyph_ids_with_svg
151
+ document_records.flat_map do |record|
152
+ record.glyph_range.to_a
153
+ end
154
+ end
155
+
156
+ # Get the number of SVG documents in this table
157
+ #
158
+ # @return [Integer] Number of SVG documents
159
+ def num_svg_documents
160
+ num_entries
161
+ end
162
+
163
+ # Validate the SVG table structure
164
+ #
165
+ # @return [Boolean] True if valid
166
+ def valid?
167
+ return false if version.nil?
168
+ return false if version != 0 # Only version 0 supported
169
+ return false if num_entries.nil? || num_entries.negative?
170
+ return false unless document_records
171
+
172
+ true
173
+ end
174
+
175
+ private
176
+
177
+ # Parse SVG header (10 bytes)
178
+ #
179
+ # @param io [StringIO] Input stream
180
+ def parse_header(io)
181
+ @version = io.read(2).unpack1("n")
182
+ @svg_document_list_offset = io.read(4).unpack1("N")
183
+ @reserved = io.read(4).unpack1("N")
184
+ end
185
+
186
+ # Validate header values
187
+ #
188
+ # @raise [CorruptedTableError] If validation fails
189
+ def validate_header!
190
+ unless version.zero?
191
+ raise CorruptedTableError,
192
+ "Unsupported SVG version: #{version} (only version 0 supported)"
193
+ end
194
+
195
+ if svg_document_list_offset > raw_data.length
196
+ raise CorruptedTableError,
197
+ "Invalid svgDocumentListOffset: #{svg_document_list_offset}"
198
+ end
199
+ end
200
+
201
+ # Parse document index
202
+ #
203
+ # @param io [StringIO] Input stream
204
+ def parse_document_index(io)
205
+ # Seek to document index
206
+ io.seek(svg_document_list_offset)
207
+
208
+ # Check if there's enough data to read num_entries
209
+ return if io.eof?
210
+
211
+ # Parse number of entries
212
+ num_entries_data = io.read(2)
213
+ return if num_entries_data.nil? || num_entries_data.length < 2
214
+
215
+ @num_entries = num_entries_data.unpack1("n")
216
+ @document_records = []
217
+
218
+ return if num_entries.zero?
219
+
220
+ # Parse each document record (12 bytes each)
221
+ num_entries.times do
222
+ record_data = io.read(12)
223
+ record = SvgDocumentRecord.read(record_data)
224
+ @document_records << record
225
+ end
226
+ end
227
+
228
+ # Find document record for a specific glyph ID
229
+ #
230
+ # Uses binary search since document records should be sorted by glyph ID.
231
+ #
232
+ # @param glyph_id [Integer] Glyph ID to find
233
+ # @return [SvgDocumentRecord, nil] Document record or nil if not found
234
+ def find_document_record(glyph_id)
235
+ # Binary search through document records
236
+ left = 0
237
+ right = document_records.length - 1
238
+
239
+ while left <= right
240
+ mid = (left + right) / 2
241
+ record = document_records[mid]
242
+
243
+ if record.includes_glyph?(glyph_id)
244
+ return record
245
+ elsif glyph_id < record.start_glyph_id
246
+ right = mid - 1
247
+ else
248
+ left = mid + 1
249
+ end
250
+ end
251
+
252
+ nil
253
+ end
254
+
255
+ # Extract SVG document from record
256
+ #
257
+ # Calculates absolute offset and extracts SVG data.
258
+ # Automatically decompresses gzipped content.
259
+ #
260
+ # @param record [SvgDocumentRecord] Document record
261
+ # @return [String] SVG XML content
262
+ def extract_svg_document(record)
263
+ # Calculate absolute offset
264
+ # Offset is relative to start of SVG Document List
265
+ # Document List = numEntries (2 bytes) + entries array + documents
266
+ documents_offset = svg_document_list_offset + 2 + (num_entries * 12)
267
+ absolute_offset = documents_offset + record.svg_doc_offset
268
+
269
+ # Extract SVG data
270
+ svg_data = raw_data[absolute_offset, record.svg_doc_length]
271
+
272
+ # Check if compressed (gzip magic bytes: 0x1f 0x8b)
273
+ if gzipped?(svg_data)
274
+ decompress_gzip(svg_data)
275
+ else
276
+ svg_data
277
+ end
278
+ end
279
+
280
+ # Check if data is gzipped
281
+ #
282
+ # @param data [String] Binary data
283
+ # @return [Boolean] True if gzipped
284
+ def gzipped?(data)
285
+ return false if data.nil? || data.length < 2
286
+
287
+ data[0..1].unpack("C*") == [0x1f, 0x8b]
288
+ end
289
+
290
+ # Decompress gzipped data
291
+ #
292
+ # @param data [String] Gzipped binary data
293
+ # @return [String] Decompressed data
294
+ def decompress_gzip(data)
295
+ Zlib::GzipReader.new(StringIO.new(data)).read
296
+ rescue Zlib::Error => e
297
+ raise CorruptedTableError, "Failed to decompress SVG data: #{e.message}"
298
+ end
299
+ end
300
+ end
301
+ end
@@ -532,6 +532,12 @@ module Fontisan
532
532
  Constants::GPOS_TAG => Tables::Gpos,
533
533
  Constants::GLYF_TAG => Tables::Glyf,
534
534
  Constants::LOCA_TAG => Tables::Loca,
535
+ "SVG " => Tables::Svg,
536
+ "COLR" => Tables::Colr,
537
+ "CPAL" => Tables::Cpal,
538
+ "CBDT" => Tables::Cbdt,
539
+ "CBLC" => Tables::Cblc,
540
+ "sbix" => Tables::Sbix,
535
541
  }[tag]
536
542
  end
537
543