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,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
 
@@ -36,8 +36,6 @@ module Fontisan
36
36
  @unicode_mappings ||= parse_mappings
37
37
  end
38
38
 
39
- private
40
-
41
39
  # Parse all encoding records and extract Unicode mappings
42
40
  def parse_mappings
43
41
  mappings = {}
@@ -279,6 +277,88 @@ module Fontisan
279
277
  mappings[code] = glyph_index if glyph_index != 0
280
278
  end
281
279
  end
280
+
281
+ public
282
+
283
+ # Validation helper: Check if version is valid
284
+ #
285
+ # cmap version should be 0
286
+ #
287
+ # @return [Boolean] True if version is 0
288
+ def valid_version?
289
+ version == 0
290
+ end
291
+
292
+ # Validation helper: Check if at least one subtable exists
293
+ #
294
+ # @return [Boolean] True if num_tables > 0
295
+ def has_subtables?
296
+ num_tables && num_tables > 0
297
+ end
298
+
299
+ # Validation helper: Check if Unicode mapping exists
300
+ #
301
+ # @return [Boolean] True if Unicode mappings are present
302
+ def has_unicode_mapping?
303
+ !unicode_mappings.nil? && !unicode_mappings.empty?
304
+ end
305
+
306
+ # Validation helper: Check if BMP coverage exists
307
+ #
308
+ # Checks if the Basic Multilingual Plane (U+0000-U+FFFF) has mappings
309
+ #
310
+ # @return [Boolean] True if BMP characters are mapped
311
+ def has_bmp_coverage?
312
+ mappings = unicode_mappings
313
+ return false if mappings.nil? || mappings.empty?
314
+
315
+ # Check if any BMP characters (0x0000-0xFFFF) are mapped
316
+ mappings.keys.any? { |code| code.between?(0x0000, 0xFFFF) }
317
+ end
318
+
319
+ # Validation helper: Check if required characters are mapped
320
+ #
321
+ # Checks for essential characters like space (U+0020)
322
+ #
323
+ # @param required_chars [Array<Integer>] Character codes that must be present
324
+ # @return [Boolean] True if all required characters are mapped
325
+ def has_required_characters?(*required_chars)
326
+ mappings = unicode_mappings
327
+ return false if mappings.nil?
328
+
329
+ required_chars.all? { |code| mappings.key?(code) }
330
+ end
331
+
332
+ # Validation helper: Check if format 4 subtable exists
333
+ #
334
+ # Format 4 is the minimum requirement for Unicode BMP support
335
+ #
336
+ # @return [Boolean] True if format 4 subtable is found
337
+ def has_format_4_subtable?
338
+ data = to_binary_s
339
+ records = read_encoding_records(data)
340
+
341
+ records.any? do |record|
342
+ subtable_data = extract_subtable_data(record, data)
343
+ next false unless subtable_data && subtable_data.length >= 2
344
+
345
+ format = subtable_data[0, 2].unpack1("n")
346
+ format == 4
347
+ end
348
+ rescue StandardError
349
+ false
350
+ end
351
+
352
+ # Validation helper: Check if glyph indices are within bounds
353
+ #
354
+ # @param max_glyph_id [Integer] Maximum valid glyph ID from maxp table
355
+ # @return [Boolean] True if all mapped glyph IDs are valid
356
+ def valid_glyph_indices?(max_glyph_id)
357
+ mappings = unicode_mappings
358
+ return true if mappings.nil? || mappings.empty?
359
+
360
+ mappings.values.all? { |glyph_id| glyph_id >= 0 && glyph_id < max_glyph_id }
361
+ end
282
362
  end
283
363
  end
284
364
  end