fontisan 0.2.2 → 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.
- checksums.yaml +4 -4
- data/.rubocop_todo.yml +94 -48
- data/README.adoc +293 -3
- data/Rakefile +20 -7
- data/lib/fontisan/base_collection.rb +296 -0
- data/lib/fontisan/commands/base_command.rb +2 -19
- data/lib/fontisan/commands/convert_command.rb +16 -13
- data/lib/fontisan/commands/info_command.rb +156 -50
- data/lib/fontisan/config/conversion_matrix.yml +58 -20
- data/lib/fontisan/converters/outline_converter.rb +6 -3
- data/lib/fontisan/converters/svg_generator.rb +45 -0
- data/lib/fontisan/converters/woff2_encoder.rb +106 -13
- data/lib/fontisan/font_loader.rb +109 -26
- data/lib/fontisan/formatters/text_formatter.rb +72 -19
- data/lib/fontisan/models/bitmap_glyph.rb +123 -0
- data/lib/fontisan/models/bitmap_strike.rb +94 -0
- data/lib/fontisan/models/collection_brief_info.rb +6 -0
- data/lib/fontisan/models/collection_info.rb +6 -1
- data/lib/fontisan/models/color_glyph.rb +57 -0
- data/lib/fontisan/models/color_layer.rb +53 -0
- data/lib/fontisan/models/color_palette.rb +60 -0
- data/lib/fontisan/models/font_info.rb +26 -0
- data/lib/fontisan/models/svg_glyph.rb +89 -0
- data/lib/fontisan/open_type_collection.rb +17 -220
- data/lib/fontisan/open_type_font.rb +6 -0
- data/lib/fontisan/optimizers/charstring_rewriter.rb +19 -8
- data/lib/fontisan/optimizers/pattern_analyzer.rb +4 -2
- data/lib/fontisan/optimizers/subroutine_builder.rb +6 -5
- data/lib/fontisan/optimizers/subroutine_optimizer.rb +5 -2
- data/lib/fontisan/pipeline/output_writer.rb +2 -2
- data/lib/fontisan/tables/cbdt.rb +169 -0
- data/lib/fontisan/tables/cblc.rb +290 -0
- data/lib/fontisan/tables/cff.rb +6 -12
- data/lib/fontisan/tables/colr.rb +291 -0
- data/lib/fontisan/tables/cpal.rb +281 -0
- data/lib/fontisan/tables/glyf/glyph_builder.rb +5 -1
- data/lib/fontisan/tables/sbix.rb +379 -0
- data/lib/fontisan/tables/svg.rb +301 -0
- data/lib/fontisan/true_type_collection.rb +29 -113
- data/lib/fontisan/true_type_font.rb +6 -0
- data/lib/fontisan/validation/woff2_header_validator.rb +278 -0
- data/lib/fontisan/validation/woff2_table_validator.rb +270 -0
- data/lib/fontisan/validation/woff2_validator.rb +248 -0
- data/lib/fontisan/version.rb +1 -1
- data/lib/fontisan/woff2/directory.rb +40 -11
- data/lib/fontisan/woff2/table_transformer.rb +506 -73
- data/lib/fontisan/woff2_font.rb +29 -9
- data/lib/fontisan/woff_font.rb +17 -4
- data/lib/fontisan.rb +12 -0
- metadata +18 -2
|
@@ -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
|
data/lib/fontisan/tables/cff.rb
CHANGED
|
@@ -268,8 +268,7 @@ module Fontisan
|
|
|
268
268
|
|
|
269
269
|
PrivateDict.new(private_data)
|
|
270
270
|
rescue StandardError => e
|
|
271
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
471
|
-
nil
|
|
465
|
+
raise CorruptedTableError, "Failed to parse Encoding: #{e.message}"
|
|
472
466
|
end
|
|
473
467
|
end
|
|
474
468
|
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "stringio"
|
|
4
|
+
require_relative "../binary/base_record"
|
|
5
|
+
|
|
6
|
+
module Fontisan
|
|
7
|
+
module Tables
|
|
8
|
+
# COLR (Color) table parser
|
|
9
|
+
#
|
|
10
|
+
# The COLR table defines layered color glyphs where each layer references
|
|
11
|
+
# a glyph ID and a palette index from the CPAL table. This enables fonts
|
|
12
|
+
# to display multi-colored glyphs such as emoji or brand logos.
|
|
13
|
+
#
|
|
14
|
+
# COLR Table Structure:
|
|
15
|
+
# ```
|
|
16
|
+
# COLR Table = Header (14 bytes)
|
|
17
|
+
# + Base Glyph Records (6 bytes each)
|
|
18
|
+
# + Layer Records (4 bytes each)
|
|
19
|
+
# ```
|
|
20
|
+
#
|
|
21
|
+
# Version 0 Structure:
|
|
22
|
+
# - version (uint16): Table version (0)
|
|
23
|
+
# - numBaseGlyphRecords (uint16): Number of base glyphs
|
|
24
|
+
# - baseGlyphRecordsOffset (uint32): Offset to base glyph records array
|
|
25
|
+
# - layerRecordsOffset (uint32): Offset to layer records array
|
|
26
|
+
# - numLayerRecords (uint16): Number of layer records
|
|
27
|
+
#
|
|
28
|
+
# The COLR table must be used together with the CPAL (Color Palette) table
|
|
29
|
+
# which defines the actual RGB color values referenced by palette indices.
|
|
30
|
+
#
|
|
31
|
+
# Reference: OpenType COLR specification
|
|
32
|
+
# https://docs.microsoft.com/en-us/typography/opentype/spec/colr
|
|
33
|
+
#
|
|
34
|
+
# @example Reading a COLR table
|
|
35
|
+
# data = font.table_data['COLR']
|
|
36
|
+
# colr = Fontisan::Tables::Colr.read(data)
|
|
37
|
+
# layers = colr.layers_for_glyph(42)
|
|
38
|
+
# puts "Glyph 42 has #{layers.length} color layers"
|
|
39
|
+
class Colr < Binary::BaseRecord
|
|
40
|
+
# OpenType table tag for COLR
|
|
41
|
+
TAG = "COLR"
|
|
42
|
+
|
|
43
|
+
# Base Glyph Record structure for COLR table
|
|
44
|
+
#
|
|
45
|
+
# Each base glyph record associates a glyph ID with its color layers.
|
|
46
|
+
# Structure (6 bytes): glyph_id, first_layer_index, num_layers
|
|
47
|
+
class BaseGlyphRecord < Binary::BaseRecord
|
|
48
|
+
endian :big
|
|
49
|
+
uint16 :glyph_id
|
|
50
|
+
uint16 :first_layer_index
|
|
51
|
+
uint16 :num_layers
|
|
52
|
+
|
|
53
|
+
def has_layers?
|
|
54
|
+
num_layers.positive?
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Layer Record structure for COLR table
|
|
59
|
+
#
|
|
60
|
+
# Each layer record specifies a glyph and palette index.
|
|
61
|
+
# Structure (4 bytes): glyph_id, palette_index
|
|
62
|
+
class LayerRecord < Binary::BaseRecord
|
|
63
|
+
endian :big
|
|
64
|
+
FOREGROUND_COLOR = 0xFFFF
|
|
65
|
+
|
|
66
|
+
uint16 :glyph_id
|
|
67
|
+
uint16 :palette_index
|
|
68
|
+
|
|
69
|
+
def uses_foreground_color?
|
|
70
|
+
palette_index == FOREGROUND_COLOR
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def uses_palette_color?
|
|
74
|
+
!uses_foreground_color?
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# @return [Integer] COLR version (0 for version 0)
|
|
79
|
+
attr_reader :version
|
|
80
|
+
|
|
81
|
+
# @return [Integer] Number of base glyph records
|
|
82
|
+
attr_reader :num_base_glyph_records
|
|
83
|
+
|
|
84
|
+
# @return [Integer] Offset to base glyph records array
|
|
85
|
+
attr_reader :base_glyph_records_offset
|
|
86
|
+
|
|
87
|
+
# @return [Integer] Offset to layer records array
|
|
88
|
+
attr_reader :layer_records_offset
|
|
89
|
+
|
|
90
|
+
# @return [Integer] Number of layer records
|
|
91
|
+
attr_reader :num_layer_records
|
|
92
|
+
|
|
93
|
+
# @return [String] Raw binary data for the entire COLR table
|
|
94
|
+
attr_reader :raw_data
|
|
95
|
+
|
|
96
|
+
# @return [Array<BaseGlyphRecord>] Parsed base glyph records
|
|
97
|
+
attr_reader :base_glyph_records
|
|
98
|
+
|
|
99
|
+
# @return [Array<LayerRecord>] Parsed layer records
|
|
100
|
+
attr_reader :layer_records
|
|
101
|
+
|
|
102
|
+
# Override read to parse COLR structure
|
|
103
|
+
#
|
|
104
|
+
# @param io [IO, String] Binary data to read
|
|
105
|
+
# @return [Colr] Parsed COLR table
|
|
106
|
+
def self.read(io)
|
|
107
|
+
colr = new
|
|
108
|
+
return colr if io.nil?
|
|
109
|
+
|
|
110
|
+
data = io.is_a?(String) ? io : io.read
|
|
111
|
+
colr.parse!(data)
|
|
112
|
+
colr
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Parse the COLR table structure
|
|
116
|
+
#
|
|
117
|
+
# @param data [String] Binary data for the COLR table
|
|
118
|
+
# @raise [CorruptedTableError] If COLR structure is invalid
|
|
119
|
+
def parse!(data)
|
|
120
|
+
@raw_data = data
|
|
121
|
+
io = StringIO.new(data)
|
|
122
|
+
|
|
123
|
+
# Parse COLR header (14 bytes)
|
|
124
|
+
parse_header(io)
|
|
125
|
+
validate_header!
|
|
126
|
+
|
|
127
|
+
# Parse base glyph records
|
|
128
|
+
parse_base_glyph_records(io)
|
|
129
|
+
|
|
130
|
+
# Parse layer records
|
|
131
|
+
parse_layer_records(io)
|
|
132
|
+
rescue StandardError => e
|
|
133
|
+
raise CorruptedTableError, "Failed to parse COLR table: #{e.message}"
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Get color layers for a specific glyph ID
|
|
137
|
+
#
|
|
138
|
+
# Returns an array of LayerRecord objects for the specified glyph.
|
|
139
|
+
# Returns empty array if glyph has no color layers.
|
|
140
|
+
#
|
|
141
|
+
# @param glyph_id [Integer] Glyph ID to look up
|
|
142
|
+
# @return [Array<LayerRecord>] Array of layer records for this glyph
|
|
143
|
+
def layers_for_glyph(glyph_id)
|
|
144
|
+
# Find base glyph record for this glyph ID
|
|
145
|
+
base_record = find_base_glyph_record(glyph_id)
|
|
146
|
+
return [] unless base_record
|
|
147
|
+
|
|
148
|
+
# Extract layers for this glyph
|
|
149
|
+
first_index = base_record.first_layer_index
|
|
150
|
+
num_layers = base_record.num_layers
|
|
151
|
+
|
|
152
|
+
return [] if num_layers.zero?
|
|
153
|
+
|
|
154
|
+
# Return slice of layer records
|
|
155
|
+
layer_records[first_index, num_layers] || []
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Check if COLR table has color data for a specific glyph
|
|
159
|
+
#
|
|
160
|
+
# @param glyph_id [Integer] Glyph ID to check
|
|
161
|
+
# @return [Boolean] True if glyph has color layers
|
|
162
|
+
def has_color_glyph?(glyph_id)
|
|
163
|
+
!layers_for_glyph(glyph_id).empty?
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Get all glyph IDs that have color data
|
|
167
|
+
#
|
|
168
|
+
# @return [Array<Integer>] Array of glyph IDs with color layers
|
|
169
|
+
def color_glyph_ids
|
|
170
|
+
base_glyph_records.map(&:glyph_id)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Get the number of color glyphs in this table
|
|
174
|
+
#
|
|
175
|
+
# @return [Integer] Number of base glyphs
|
|
176
|
+
def num_color_glyphs
|
|
177
|
+
num_base_glyph_records
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Validate the COLR table structure
|
|
181
|
+
#
|
|
182
|
+
# @return [Boolean] True if valid
|
|
183
|
+
def valid?
|
|
184
|
+
return false if version.nil?
|
|
185
|
+
return false if version != 0 # Only version 0 supported currently
|
|
186
|
+
return false if num_base_glyph_records.nil? || num_base_glyph_records.negative?
|
|
187
|
+
return false if num_layer_records.nil? || num_layer_records.negative?
|
|
188
|
+
return false unless base_glyph_records
|
|
189
|
+
return false unless layer_records
|
|
190
|
+
|
|
191
|
+
true
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
private
|
|
195
|
+
|
|
196
|
+
# Parse COLR header (14 bytes)
|
|
197
|
+
#
|
|
198
|
+
# @param io [StringIO] Input stream
|
|
199
|
+
def parse_header(io)
|
|
200
|
+
@version = io.read(2).unpack1("n")
|
|
201
|
+
@num_base_glyph_records = io.read(2).unpack1("n")
|
|
202
|
+
@base_glyph_records_offset = io.read(4).unpack1("N")
|
|
203
|
+
@layer_records_offset = io.read(4).unpack1("N")
|
|
204
|
+
@num_layer_records = io.read(2).unpack1("n")
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Validate header values
|
|
208
|
+
#
|
|
209
|
+
# @raise [CorruptedTableError] If validation fails
|
|
210
|
+
def validate_header!
|
|
211
|
+
unless version.zero?
|
|
212
|
+
raise CorruptedTableError,
|
|
213
|
+
"Unsupported COLR version: #{version} (only version 0 supported)"
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
if num_base_glyph_records.negative?
|
|
217
|
+
raise CorruptedTableError,
|
|
218
|
+
"Invalid numBaseGlyphRecords: #{num_base_glyph_records}"
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
if num_layer_records.negative?
|
|
222
|
+
raise CorruptedTableError,
|
|
223
|
+
"Invalid numLayerRecords: #{num_layer_records}"
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# Parse base glyph records array
|
|
228
|
+
#
|
|
229
|
+
# @param io [StringIO] Input stream
|
|
230
|
+
def parse_base_glyph_records(io)
|
|
231
|
+
@base_glyph_records = []
|
|
232
|
+
return if num_base_glyph_records.zero?
|
|
233
|
+
|
|
234
|
+
# Seek to base glyph records
|
|
235
|
+
io.seek(base_glyph_records_offset)
|
|
236
|
+
|
|
237
|
+
# Parse each base glyph record (6 bytes each)
|
|
238
|
+
num_base_glyph_records.times do
|
|
239
|
+
record_data = io.read(6)
|
|
240
|
+
record = BaseGlyphRecord.read(record_data)
|
|
241
|
+
@base_glyph_records << record
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# Parse layer records array
|
|
246
|
+
#
|
|
247
|
+
# @param io [StringIO] Input stream
|
|
248
|
+
def parse_layer_records(io)
|
|
249
|
+
@layer_records = []
|
|
250
|
+
return if num_layer_records.zero?
|
|
251
|
+
|
|
252
|
+
# Seek to layer records
|
|
253
|
+
io.seek(layer_records_offset)
|
|
254
|
+
|
|
255
|
+
# Parse each layer record (4 bytes each)
|
|
256
|
+
num_layer_records.times do
|
|
257
|
+
record_data = io.read(4)
|
|
258
|
+
record = LayerRecord.read(record_data)
|
|
259
|
+
@layer_records << record
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# Find base glyph record for a specific glyph ID
|
|
264
|
+
#
|
|
265
|
+
# Uses binary search since base glyph records are sorted by glyph ID
|
|
266
|
+
#
|
|
267
|
+
# @param glyph_id [Integer] Glyph ID to find
|
|
268
|
+
# @return [BaseGlyphRecord, nil] Base glyph record or nil if not found
|
|
269
|
+
def find_base_glyph_record(glyph_id)
|
|
270
|
+
# Binary search through base glyph records
|
|
271
|
+
left = 0
|
|
272
|
+
right = base_glyph_records.length - 1
|
|
273
|
+
|
|
274
|
+
while left <= right
|
|
275
|
+
mid = (left + right) / 2
|
|
276
|
+
record = base_glyph_records[mid]
|
|
277
|
+
|
|
278
|
+
if record.glyph_id == glyph_id
|
|
279
|
+
return record
|
|
280
|
+
elsif record.glyph_id < glyph_id
|
|
281
|
+
left = mid + 1
|
|
282
|
+
else
|
|
283
|
+
right = mid - 1
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
nil
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
end
|