fontisan 0.2.3 → 0.2.4

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 (43) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +92 -40
  3. data/README.adoc +262 -3
  4. data/Rakefile +20 -7
  5. data/lib/fontisan/commands/base_command.rb +2 -19
  6. data/lib/fontisan/commands/convert_command.rb +16 -13
  7. data/lib/fontisan/commands/info_command.rb +88 -0
  8. data/lib/fontisan/config/conversion_matrix.yml +58 -20
  9. data/lib/fontisan/converters/outline_converter.rb +6 -3
  10. data/lib/fontisan/converters/svg_generator.rb +45 -0
  11. data/lib/fontisan/converters/woff2_encoder.rb +106 -13
  12. data/lib/fontisan/models/bitmap_glyph.rb +123 -0
  13. data/lib/fontisan/models/bitmap_strike.rb +94 -0
  14. data/lib/fontisan/models/color_glyph.rb +57 -0
  15. data/lib/fontisan/models/color_layer.rb +53 -0
  16. data/lib/fontisan/models/color_palette.rb +60 -0
  17. data/lib/fontisan/models/font_info.rb +26 -0
  18. data/lib/fontisan/models/svg_glyph.rb +89 -0
  19. data/lib/fontisan/open_type_font.rb +6 -0
  20. data/lib/fontisan/optimizers/charstring_rewriter.rb +19 -8
  21. data/lib/fontisan/optimizers/pattern_analyzer.rb +4 -2
  22. data/lib/fontisan/optimizers/subroutine_builder.rb +6 -5
  23. data/lib/fontisan/optimizers/subroutine_optimizer.rb +5 -2
  24. data/lib/fontisan/pipeline/output_writer.rb +2 -2
  25. data/lib/fontisan/tables/cbdt.rb +169 -0
  26. data/lib/fontisan/tables/cblc.rb +290 -0
  27. data/lib/fontisan/tables/cff.rb +6 -12
  28. data/lib/fontisan/tables/colr.rb +291 -0
  29. data/lib/fontisan/tables/cpal.rb +281 -0
  30. data/lib/fontisan/tables/glyf/glyph_builder.rb +5 -1
  31. data/lib/fontisan/tables/sbix.rb +379 -0
  32. data/lib/fontisan/tables/svg.rb +301 -0
  33. data/lib/fontisan/true_type_font.rb +6 -0
  34. data/lib/fontisan/validation/woff2_header_validator.rb +278 -0
  35. data/lib/fontisan/validation/woff2_table_validator.rb +270 -0
  36. data/lib/fontisan/validation/woff2_validator.rb +248 -0
  37. data/lib/fontisan/version.rb +1 -1
  38. data/lib/fontisan/woff2/directory.rb +40 -11
  39. data/lib/fontisan/woff2/table_transformer.rb +506 -73
  40. data/lib/fontisan/woff2_font.rb +29 -9
  41. data/lib/fontisan/woff_font.rb +17 -4
  42. data/lib/fontisan.rb +12 -0
  43. metadata +17 -2
@@ -0,0 +1,169 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "stringio"
4
+ require_relative "../binary/base_record"
5
+
6
+ module Fontisan
7
+ module Tables
8
+ # CBDT (Color Bitmap Data) table parser
9
+ #
10
+ # The CBDT table contains the actual bitmap data for color glyphs. It works
11
+ # together with the CBLC table which provides the location information for
12
+ # finding bitmaps in this table.
13
+ #
14
+ # CBDT Table Structure:
15
+ # ```
16
+ # CBDT Table = Header (8 bytes)
17
+ # + Bitmap Data (variable length)
18
+ # ```
19
+ #
20
+ # Header (8 bytes):
21
+ # - majorVersion (uint16): Major version (2 or 3)
22
+ # - minorVersion (uint16): Minor version (0)
23
+ # - reserved (uint32): Reserved, set to 0
24
+ #
25
+ # The bitmap data format depends on the index subtable format in CBLC.
26
+ # Common formats include:
27
+ # - Format 17: Small metrics, PNG data
28
+ # - Format 18: Big metrics, PNG data
29
+ # - Format 19: Metrics in CBLC, PNG data
30
+ #
31
+ # This parser provides low-level access to bitmap data. For proper bitmap
32
+ # extraction, use together with CBLC table which contains the index.
33
+ #
34
+ # Reference: OpenType CBDT specification
35
+ # https://docs.microsoft.com/en-us/typography/opentype/spec/cbdt
36
+ #
37
+ # @example Reading a CBDT table
38
+ # data = font.table_data['CBDT']
39
+ # cbdt = Fontisan::Tables::Cbdt.read(data)
40
+ # bitmap_data = cbdt.bitmap_data_at(offset, length)
41
+ class Cbdt < Binary::BaseRecord
42
+ # OpenType table tag for CBDT
43
+ TAG = "CBDT"
44
+
45
+ # Supported CBDT versions
46
+ VERSION_2_0 = 0x0002_0000
47
+ VERSION_3_0 = 0x0003_0000
48
+
49
+ # @return [Integer] Major version (2 or 3)
50
+ attr_reader :major_version
51
+
52
+ # @return [Integer] Minor version (0)
53
+ attr_reader :minor_version
54
+
55
+ # @return [String] Raw binary data for the entire CBDT table
56
+ attr_reader :raw_data
57
+
58
+ # Override read to parse CBDT structure
59
+ #
60
+ # @param io [IO, String] Binary data to read
61
+ # @return [Cbdt] Parsed CBDT table
62
+ def self.read(io)
63
+ cbdt = new
64
+ return cbdt if io.nil?
65
+
66
+ data = io.is_a?(String) ? io : io.read
67
+ cbdt.parse!(data)
68
+ cbdt
69
+ end
70
+
71
+ # Parse the CBDT table structure
72
+ #
73
+ # @param data [String] Binary data for the CBDT table
74
+ # @raise [CorruptedTableError] If CBDT structure is invalid
75
+ def parse!(data)
76
+ @raw_data = data
77
+ io = StringIO.new(data)
78
+
79
+ # Parse CBDT header (8 bytes)
80
+ parse_header(io)
81
+ validate_header!
82
+ rescue StandardError => e
83
+ raise CorruptedTableError, "Failed to parse CBDT table: #{e.message}"
84
+ end
85
+
86
+ # Get bitmap data at specific offset and length
87
+ #
88
+ # Used together with CBLC index to extract bitmap data.
89
+ #
90
+ # @param offset [Integer] Offset from start of table
91
+ # @param length [Integer] Length of bitmap data
92
+ # @return [String, nil] Binary bitmap data or nil
93
+ def bitmap_data_at(offset, length)
94
+ return nil if offset.nil? || length.nil?
95
+ return nil if offset.negative? || length.negative?
96
+ return nil if offset + length > raw_data.length
97
+
98
+ raw_data[offset, length]
99
+ end
100
+
101
+ # Get combined version number
102
+ #
103
+ # @return [Integer] Combined version (e.g., 0x00020000 for v2.0)
104
+ def version
105
+ return nil if major_version.nil? || minor_version.nil?
106
+
107
+ (major_version << 16) | minor_version
108
+ end
109
+
110
+ # Get table data size
111
+ #
112
+ # @return [Integer] Size of CBDT table in bytes
113
+ def data_size
114
+ raw_data&.length || 0
115
+ end
116
+
117
+ # Check if offset is valid for this table
118
+ #
119
+ # @param offset [Integer] Offset to check
120
+ # @return [Boolean] True if offset is within table bounds
121
+ def valid_offset?(offset)
122
+ return false if offset.nil? || offset.negative?
123
+ return false if raw_data.nil?
124
+
125
+ offset < raw_data.length
126
+ end
127
+
128
+ # Validate the CBDT table structure
129
+ #
130
+ # @return [Boolean] True if valid
131
+ def valid?
132
+ return false if major_version.nil? || minor_version.nil?
133
+ return false unless [2, 3].include?(major_version)
134
+ return false unless minor_version.zero?
135
+ return false unless raw_data
136
+
137
+ true
138
+ end
139
+
140
+ private
141
+
142
+ # Parse CBDT header (8 bytes)
143
+ #
144
+ # @param io [StringIO] Input stream
145
+ def parse_header(io)
146
+ @major_version = io.read(2).unpack1("n")
147
+ @minor_version = io.read(2).unpack1("n")
148
+ @reserved = io.read(4).unpack1("N")
149
+ end
150
+
151
+ # Validate header values
152
+ #
153
+ # @raise [CorruptedTableError] If validation fails
154
+ def validate_header!
155
+ unless [2, 3].include?(major_version)
156
+ raise CorruptedTableError,
157
+ "Unsupported CBDT major version: #{major_version} " \
158
+ "(only versions 2 and 3 supported)"
159
+ end
160
+
161
+ unless minor_version.zero?
162
+ raise CorruptedTableError,
163
+ "Unsupported CBDT minor version: #{minor_version} " \
164
+ "(only version 0 supported)"
165
+ end
166
+ end
167
+ end
168
+ end
169
+ end
@@ -0,0 +1,290 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "stringio"
4
+ require_relative "../binary/base_record"
5
+
6
+ module Fontisan
7
+ module Tables
8
+ # CBLC (Color Bitmap Location) table parser
9
+ #
10
+ # The CBLC table contains location information for bitmap glyphs at various
11
+ # sizes (strikes). It works together with the CBDT table which contains the
12
+ # actual bitmap data.
13
+ #
14
+ # CBLC Table Structure:
15
+ # ```
16
+ # CBLC Table = Header (8 bytes)
17
+ # + BitmapSize Records (48 bytes each)
18
+ # ```
19
+ #
20
+ # Header (8 bytes):
21
+ # - version (uint32): Table version (0x00020000 or 0x00030000)
22
+ # - numSizes (uint32): Number of BitmapSize records
23
+ #
24
+ # Each BitmapSize record (48 bytes) contains:
25
+ # - indexSubTableArrayOffset (uint32): Offset to index subtable array
26
+ # - indexTablesSize (uint32): Size of index subtables
27
+ # - numberOfIndexSubTables (uint32): Number of index subtables
28
+ # - colorRef (uint32): Not used, set to 0
29
+ # - hori (SbitLineMetrics, 12 bytes): Horizontal line metrics
30
+ # - vert (SbitLineMetrics, 12 bytes): Vertical line metrics
31
+ # - startGlyphIndex (uint16): First glyph ID in strike
32
+ # - endGlyphIndex (uint16): Last glyph ID in strike
33
+ # - ppemX (uint8): Horizontal pixels per em
34
+ # - ppemY (uint8): Vertical pixels per em
35
+ # - bitDepth (uint8): Bit depth (1, 2, 4, 8, 32)
36
+ # - flags (int8): Flags
37
+ #
38
+ # Reference: OpenType CBLC specification
39
+ # https://docs.microsoft.com/en-us/typography/opentype/spec/cblc
40
+ #
41
+ # @example Reading a CBLC table
42
+ # data = font.table_data['CBLC']
43
+ # cblc = Fontisan::Tables::Cblc.read(data)
44
+ # strikes = cblc.strikes
45
+ # puts "Font has #{strikes.length} bitmap strikes"
46
+ class Cblc < Binary::BaseRecord
47
+ # OpenType table tag for CBLC
48
+ TAG = "CBLC"
49
+
50
+ # Supported CBLC versions
51
+ VERSION_2_0 = 0x00020000
52
+ VERSION_3_0 = 0x00030000
53
+
54
+ # SbitLineMetrics structure (12 bytes)
55
+ #
56
+ # Contains metrics for horizontal or vertical layout
57
+ class SbitLineMetrics < Binary::BaseRecord
58
+ endian :big
59
+ int8 :ascender
60
+ int8 :descender
61
+ uint8 :width_max
62
+ int8 :caret_slope_numerator
63
+ int8 :caret_slope_denominator
64
+ int8 :caret_offset
65
+ int8 :min_origin_sb
66
+ int8 :min_advance_sb
67
+ int8 :max_before_bl
68
+ int8 :min_after_bl
69
+ int8 :pad1
70
+ int8 :pad2
71
+ end
72
+
73
+ # BitmapSize record structure (48 bytes)
74
+ #
75
+ # Describes a bitmap strike at a specific ppem size
76
+ class BitmapSize < Binary::BaseRecord
77
+ endian :big
78
+ uint32 :index_subtable_array_offset
79
+ uint32 :index_tables_size
80
+ uint32 :number_of_index_subtables
81
+ uint32 :color_ref
82
+
83
+ # Read the SbitLineMetrics structures manually
84
+ def self.read(io)
85
+ data = io.is_a?(String) ? io : io.read
86
+ size = new
87
+
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"))
92
+ size.instance_variable_set(:@color_ref, io.read(4).unpack1("N"))
93
+
94
+ # Parse hori and vert metrics (12 bytes each)
95
+ hori_data = io.read(12)
96
+ vert_data = io.read(12)
97
+ size.instance_variable_set(:@hori, SbitLineMetrics.read(hori_data))
98
+ size.instance_variable_set(:@vert, SbitLineMetrics.read(vert_data))
99
+
100
+ # Parse remaining fields
101
+ size.instance_variable_set(:@start_glyph_index, io.read(2).unpack1("n"))
102
+ size.instance_variable_set(:@end_glyph_index, io.read(2).unpack1("n"))
103
+ size.instance_variable_set(:@ppem_x, io.read(1).unpack1("C"))
104
+ size.instance_variable_set(:@ppem_y, io.read(1).unpack1("C"))
105
+ size.instance_variable_set(:@bit_depth, io.read(1).unpack1("C"))
106
+ size.instance_variable_set(:@flags, io.read(1).unpack1("c"))
107
+
108
+ size
109
+ end
110
+
111
+ attr_reader :index_subtable_array_offset, :index_tables_size,
112
+ :number_of_index_subtables, :color_ref, :hori, :vert,
113
+ :start_glyph_index, :end_glyph_index, :ppem_x, :ppem_y,
114
+ :bit_depth, :flags
115
+
116
+ # Get ppem size (assumes square pixels)
117
+ #
118
+ # @return [Integer] Pixels per em
119
+ def ppem
120
+ ppem_x
121
+ end
122
+
123
+ # Get glyph range for this strike
124
+ #
125
+ # @return [Range] Range of glyph IDs
126
+ def glyph_range
127
+ start_glyph_index..end_glyph_index
128
+ end
129
+
130
+ # Check if this strike includes a specific glyph ID
131
+ #
132
+ # @param glyph_id [Integer] Glyph ID to check
133
+ # @return [Boolean] True if glyph is in range
134
+ def includes_glyph?(glyph_id)
135
+ glyph_range.include?(glyph_id)
136
+ end
137
+ end
138
+
139
+ # @return [Integer] CBLC version
140
+ attr_reader :version
141
+
142
+ # @return [Integer] Number of bitmap size records
143
+ attr_reader :num_sizes
144
+
145
+ # @return [Array<BitmapSize>] Parsed bitmap size records
146
+ attr_reader :bitmap_sizes
147
+
148
+ # @return [String] Raw binary data for the entire CBLC table
149
+ attr_reader :raw_data
150
+
151
+ # Override read to parse CBLC structure
152
+ #
153
+ # @param io [IO, String] Binary data to read
154
+ # @return [Cblc] Parsed CBLC table
155
+ def self.read(io)
156
+ cblc = new
157
+ return cblc if io.nil?
158
+
159
+ data = io.is_a?(String) ? io : io.read
160
+ cblc.parse!(data)
161
+ cblc
162
+ end
163
+
164
+ # Parse the CBLC table structure
165
+ #
166
+ # @param data [String] Binary data for the CBLC table
167
+ # @raise [CorruptedTableError] If CBLC structure is invalid
168
+ def parse!(data)
169
+ @raw_data = data
170
+ io = StringIO.new(data)
171
+
172
+ # Parse CBLC header (8 bytes)
173
+ parse_header(io)
174
+ validate_header!
175
+
176
+ # Parse bitmap size records
177
+ parse_bitmap_sizes(io)
178
+ rescue StandardError => e
179
+ raise CorruptedTableError, "Failed to parse CBLC table: #{e.message}"
180
+ end
181
+
182
+ # Get bitmap strikes (sizes)
183
+ #
184
+ # @return [Array<BitmapSize>] Array of bitmap strikes
185
+ def strikes
186
+ bitmap_sizes || []
187
+ end
188
+
189
+ # Get strikes for specific ppem size
190
+ #
191
+ # @param ppem [Integer] Pixels per em
192
+ # @return [Array<BitmapSize>] Strikes matching ppem
193
+ def strikes_for_ppem(ppem)
194
+ strikes.select { |size| size.ppem == ppem }
195
+ end
196
+
197
+ # Check if glyph has bitmap at ppem size
198
+ #
199
+ # @param glyph_id [Integer] Glyph ID
200
+ # @param ppem [Integer] Pixels per em
201
+ # @return [Boolean] True if glyph has bitmap
202
+ def has_bitmap_for_glyph?(glyph_id, ppem)
203
+ strikes_for_ppem(ppem).any? do |strike|
204
+ strike.includes_glyph?(glyph_id)
205
+ end
206
+ end
207
+
208
+ # Get all available ppem sizes
209
+ #
210
+ # @return [Array<Integer>] Sorted array of ppem sizes
211
+ def ppem_sizes
212
+ strikes.map(&:ppem).uniq.sort
213
+ end
214
+
215
+ # Get all glyph IDs that have bitmaps across all strikes
216
+ #
217
+ # @return [Array<Integer>] Array of glyph IDs
218
+ def glyph_ids_with_bitmaps
219
+ strikes.flat_map { |strike| strike.glyph_range.to_a }.uniq.sort
220
+ end
221
+
222
+ # Get strikes that include a specific glyph ID
223
+ #
224
+ # @param glyph_id [Integer] Glyph ID
225
+ # @return [Array<BitmapSize>] Strikes containing glyph
226
+ def strikes_for_glyph(glyph_id)
227
+ strikes.select { |strike| strike.includes_glyph?(glyph_id) }
228
+ end
229
+
230
+ # Get the number of bitmap strikes
231
+ #
232
+ # @return [Integer] Number of strikes
233
+ def num_strikes
234
+ num_sizes || 0
235
+ end
236
+
237
+ # Validate the CBLC table structure
238
+ #
239
+ # @return [Boolean] True if valid
240
+ def valid?
241
+ return false if version.nil?
242
+ return false unless [VERSION_2_0, VERSION_3_0].include?(version)
243
+ return false if num_sizes.nil? || num_sizes.negative?
244
+ return false unless bitmap_sizes
245
+
246
+ true
247
+ end
248
+
249
+ private
250
+
251
+ # Parse CBLC header (8 bytes)
252
+ #
253
+ # @param io [StringIO] Input stream
254
+ def parse_header(io)
255
+ @version = io.read(4).unpack1("N")
256
+ @num_sizes = io.read(4).unpack1("N")
257
+ end
258
+
259
+ # Validate header values
260
+ #
261
+ # @raise [CorruptedTableError] If validation fails
262
+ def validate_header!
263
+ unless [VERSION_2_0, VERSION_3_0].include?(version)
264
+ raise CorruptedTableError,
265
+ "Unsupported CBLC version: 0x#{version.to_s(16).upcase} " \
266
+ "(only versions 2.0 and 3.0 supported)"
267
+ end
268
+
269
+ if num_sizes.negative?
270
+ raise CorruptedTableError,
271
+ "Invalid numSizes: #{num_sizes}"
272
+ end
273
+ end
274
+
275
+ # Parse bitmap size records
276
+ #
277
+ # @param io [StringIO] Input stream
278
+ def parse_bitmap_sizes(io)
279
+ @bitmap_sizes = []
280
+ return if num_sizes.zero?
281
+
282
+ # Each BitmapSize record is 48 bytes
283
+ num_sizes.times do
284
+ size_data = io.read(48)
285
+ @bitmap_sizes << BitmapSize.read(size_data)
286
+ end
287
+ end
288
+ end
289
+ end
290
+ end
@@ -268,8 +268,7 @@ module Fontisan
268
268
 
269
269
  PrivateDict.new(private_data)
270
270
  rescue StandardError => e
271
- warn "Failed to parse Private DICT: #{e.message}"
272
- nil
271
+ raise CorruptedTableError, "Failed to parse Private DICT: #{e.message}"
273
272
  end
274
273
 
275
274
  # Get the Local Subr INDEX for a specific font
@@ -300,8 +299,7 @@ module Fontisan
300
299
  io.seek(absolute_offset)
301
300
  Index.new(io, start_offset: absolute_offset)
302
301
  rescue StandardError => e
303
- warn "Failed to parse Local Subr INDEX: #{e.message}"
304
- nil
302
+ raise CorruptedTableError, "Failed to parse Local Subr INDEX: #{e.message}"
305
303
  end
306
304
 
307
305
  # Get the CharStrings INDEX for a specific font
@@ -322,8 +320,7 @@ module Fontisan
322
320
  io.seek(charstrings_offset)
323
321
  CharstringsIndex.new(io, start_offset: charstrings_offset)
324
322
  rescue StandardError => e
325
- warn "Failed to parse CharStrings INDEX: #{e.message}"
326
- nil
323
+ raise CorruptedTableError, "Failed to parse CharStrings INDEX: #{e.message}"
327
324
  end
328
325
 
329
326
  # Get a CharString for a specific glyph
@@ -358,8 +355,7 @@ module Fontisan
358
355
  local_subr_index,
359
356
  )
360
357
  rescue StandardError => e
361
- warn "Failed to get CharString for glyph #{glyph_index}: #{e.message}"
362
- nil
358
+ raise CorruptedTableError, "Failed to get CharString for glyph #{glyph_index}: #{e.message}"
363
359
  end
364
360
 
365
361
  # Get the number of glyphs in a font
@@ -437,8 +433,7 @@ module Fontisan
437
433
  num_glyphs = glyph_count(index)
438
434
  Charset.new(charset_data, num_glyphs, self)
439
435
  rescue StandardError => e
440
- warn "Failed to parse Charset: #{e.message}"
441
- nil
436
+ raise CorruptedTableError, "Failed to parse Charset: #{e.message}"
442
437
  end
443
438
 
444
439
  # Get the Encoding for a specific font
@@ -467,8 +462,7 @@ module Fontisan
467
462
  num_glyphs = glyph_count(index)
468
463
  Encoding.new(encoding_data, num_glyphs)
469
464
  rescue StandardError => e
470
- warn "Failed to parse Encoding: #{e.message}"
471
- nil
465
+ raise CorruptedTableError, "Failed to parse Encoding: #{e.message}"
472
466
  end
473
467
  end
474
468