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.
- checksums.yaml +4 -4
- data/.rubocop_todo.yml +221 -49
- data/README.adoc +519 -5
- data/Rakefile +20 -7
- data/lib/fontisan/cli.rb +67 -6
- 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 +88 -0
- data/lib/fontisan/commands/validate_command.rb +107 -151
- 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 +84 -13
- data/lib/fontisan/models/bitmap_glyph.rb +123 -0
- data/lib/fontisan/models/bitmap_strike.rb +94 -0
- 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/models/validation_report.rb +227 -0
- 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/pipeline/transformation_pipeline.rb +4 -8
- 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/cmap.rb +82 -2
- 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/glyf.rb +118 -0
- data/lib/fontisan/tables/head.rb +60 -0
- data/lib/fontisan/tables/hhea.rb +74 -0
- data/lib/fontisan/tables/maxp.rb +60 -0
- data/lib/fontisan/tables/name.rb +76 -0
- data/lib/fontisan/tables/os2.rb +113 -0
- data/lib/fontisan/tables/post.rb +57 -0
- data/lib/fontisan/tables/sbix.rb +379 -0
- data/lib/fontisan/tables/svg.rb +301 -0
- data/lib/fontisan/true_type_font.rb +6 -0
- data/lib/fontisan/validators/basic_validator.rb +85 -0
- data/lib/fontisan/validators/font_book_validator.rb +130 -0
- data/lib/fontisan/validators/opentype_validator.rb +112 -0
- data/lib/fontisan/validators/profile_loader.rb +139 -0
- data/lib/fontisan/validators/validator.rb +484 -0
- data/lib/fontisan/validators/web_font_validator.rb +102 -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 +90 -6
- metadata +20 -9
- data/lib/fontisan/config/validation_rules.yml +0 -149
- data/lib/fontisan/validation/checksum_validator.rb +0 -170
- data/lib/fontisan/validation/consistency_validator.rb +0 -197
- data/lib/fontisan/validation/structure_validator.rb +0 -198
- data/lib/fontisan/validation/table_validator.rb +0 -158
- data/lib/fontisan/validation/validator.rb +0 -152
- data/lib/fontisan/validation/variable_font_validator.rb +0 -218
|
@@ -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
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "stringio"
|
|
4
|
+
require_relative "../binary/base_record"
|
|
5
|
+
|
|
6
|
+
module Fontisan
|
|
7
|
+
module Tables
|
|
8
|
+
# CPAL (Color Palette) table parser
|
|
9
|
+
#
|
|
10
|
+
# The CPAL table defines color palettes used by COLR layers. Each palette
|
|
11
|
+
# contains an array of RGBA color values that can be referenced by the
|
|
12
|
+
# COLR table's palette indices.
|
|
13
|
+
#
|
|
14
|
+
# CPAL Table Structure:
|
|
15
|
+
# ```
|
|
16
|
+
# CPAL Table = Header
|
|
17
|
+
# + Palette Indices Array
|
|
18
|
+
# + Color Records Array
|
|
19
|
+
# + [Palette Types Array] (version 1)
|
|
20
|
+
# + [Palette Labels Array] (version 1)
|
|
21
|
+
# + [Palette Entry Labels Array] (version 1)
|
|
22
|
+
# ```
|
|
23
|
+
#
|
|
24
|
+
# Version 0 Header (12 bytes):
|
|
25
|
+
# - version (uint16): Table version (0 or 1)
|
|
26
|
+
# - numPaletteEntries (uint16): Number of colors per palette
|
|
27
|
+
# - numPalettes (uint16): Number of palettes
|
|
28
|
+
# - numColorRecords (uint16): Total number of color records
|
|
29
|
+
# - colorRecordsArrayOffset (uint32): Offset to color records array
|
|
30
|
+
#
|
|
31
|
+
# Version 1 adds optional metadata for palette types and labels.
|
|
32
|
+
#
|
|
33
|
+
# Color Record Structure (4 bytes, BGRA format):
|
|
34
|
+
# - blue (uint8)
|
|
35
|
+
# - green (uint8)
|
|
36
|
+
# - red (uint8)
|
|
37
|
+
# - alpha (uint8)
|
|
38
|
+
#
|
|
39
|
+
# Reference: OpenType CPAL specification
|
|
40
|
+
# https://docs.microsoft.com/en-us/typography/opentype/spec/cpal
|
|
41
|
+
#
|
|
42
|
+
# @example Reading a CPAL table
|
|
43
|
+
# data = font.table_data['CPAL']
|
|
44
|
+
# cpal = Fontisan::Tables::Cpal.read(data)
|
|
45
|
+
# palette = cpal.palette(0) # Get first palette
|
|
46
|
+
# puts palette.colors.first # => "#RRGGBBAA"
|
|
47
|
+
class Cpal < Binary::BaseRecord
|
|
48
|
+
# OpenType table tag for CPAL
|
|
49
|
+
TAG = "CPAL"
|
|
50
|
+
|
|
51
|
+
# @return [Integer] CPAL version (0 or 1)
|
|
52
|
+
attr_reader :version
|
|
53
|
+
|
|
54
|
+
# @return [Integer] Number of color entries per palette
|
|
55
|
+
attr_reader :num_palette_entries
|
|
56
|
+
|
|
57
|
+
# @return [Integer] Number of palettes in this table
|
|
58
|
+
attr_reader :num_palettes
|
|
59
|
+
|
|
60
|
+
# @return [Integer] Total number of color records
|
|
61
|
+
attr_reader :num_color_records
|
|
62
|
+
|
|
63
|
+
# @return [Integer] Offset to color records array
|
|
64
|
+
attr_reader :color_records_array_offset
|
|
65
|
+
|
|
66
|
+
# @return [String] Raw binary data for the entire CPAL table
|
|
67
|
+
attr_reader :raw_data
|
|
68
|
+
|
|
69
|
+
# @return [Array<Integer>] Palette indices (start index for each palette)
|
|
70
|
+
attr_reader :palette_indices
|
|
71
|
+
|
|
72
|
+
# @return [Array<Hash>] Parsed color records (RGBA hashes)
|
|
73
|
+
attr_reader :color_records
|
|
74
|
+
|
|
75
|
+
# Override read to parse CPAL structure
|
|
76
|
+
#
|
|
77
|
+
# @param io [IO, String] Binary data to read
|
|
78
|
+
# @return [Cpal] Parsed CPAL table
|
|
79
|
+
def self.read(io)
|
|
80
|
+
cpal = new
|
|
81
|
+
return cpal if io.nil?
|
|
82
|
+
|
|
83
|
+
data = io.is_a?(String) ? io : io.read
|
|
84
|
+
cpal.parse!(data)
|
|
85
|
+
cpal
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Parse the CPAL table structure
|
|
89
|
+
#
|
|
90
|
+
# @param data [String] Binary data for the CPAL table
|
|
91
|
+
# @raise [CorruptedTableError] If CPAL structure is invalid
|
|
92
|
+
def parse!(data)
|
|
93
|
+
@raw_data = data
|
|
94
|
+
io = StringIO.new(data)
|
|
95
|
+
|
|
96
|
+
# Parse CPAL header
|
|
97
|
+
parse_header(io)
|
|
98
|
+
validate_header!
|
|
99
|
+
|
|
100
|
+
# Parse palette indices array
|
|
101
|
+
parse_palette_indices(io)
|
|
102
|
+
|
|
103
|
+
# Parse color records
|
|
104
|
+
parse_color_records(io)
|
|
105
|
+
|
|
106
|
+
# Version 1 features (palette types, labels) not implemented yet
|
|
107
|
+
# TODO: Add version 1 features in follow-up task
|
|
108
|
+
rescue StandardError => e
|
|
109
|
+
raise CorruptedTableError, "Failed to parse CPAL table: #{e.message}"
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Get a specific palette by index
|
|
113
|
+
#
|
|
114
|
+
# Returns an array of color strings in hex format (#RRGGBBAA).
|
|
115
|
+
# Each palette contains num_palette_entries colors.
|
|
116
|
+
#
|
|
117
|
+
# @param index [Integer] Palette index (0-based)
|
|
118
|
+
# @return [Array<String>, nil] Array of hex color strings, or nil if invalid
|
|
119
|
+
def palette(index)
|
|
120
|
+
return nil if index.negative? || index >= num_palettes
|
|
121
|
+
|
|
122
|
+
# Get starting index for this palette
|
|
123
|
+
start_index = palette_indices[index]
|
|
124
|
+
|
|
125
|
+
# Extract colors for this palette
|
|
126
|
+
colors = []
|
|
127
|
+
num_palette_entries.times do |i|
|
|
128
|
+
color_record = color_records[start_index + i]
|
|
129
|
+
colors << color_to_hex(color_record) if color_record
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
colors
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Get all palettes
|
|
136
|
+
#
|
|
137
|
+
# @return [Array<Array<String>>] Array of palettes, each an array of hex colors
|
|
138
|
+
def all_palettes
|
|
139
|
+
(0...num_palettes).map { |i| palette(i) }
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Get color at specific palette and entry index
|
|
143
|
+
#
|
|
144
|
+
# @param palette_index [Integer] Palette index
|
|
145
|
+
# @param entry_index [Integer] Entry index within palette
|
|
146
|
+
# @return [String, nil] Hex color string or nil
|
|
147
|
+
def color_at(palette_index, entry_index)
|
|
148
|
+
return nil if palette_index.negative? || palette_index >= num_palettes
|
|
149
|
+
return nil if entry_index.negative? || entry_index >= num_palette_entries
|
|
150
|
+
|
|
151
|
+
start_index = palette_indices[palette_index]
|
|
152
|
+
color_record = color_records[start_index + entry_index]
|
|
153
|
+
color_record ? color_to_hex(color_record) : nil
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Validate the CPAL table structure
|
|
157
|
+
#
|
|
158
|
+
# @return [Boolean] True if valid
|
|
159
|
+
def valid?
|
|
160
|
+
return false if version.nil?
|
|
161
|
+
return false unless [0, 1].include?(version)
|
|
162
|
+
return false if num_palette_entries.nil? || num_palette_entries.negative?
|
|
163
|
+
return false if num_palettes.nil? || num_palettes.negative?
|
|
164
|
+
return false if num_color_records.nil? || num_color_records.negative?
|
|
165
|
+
return false unless palette_indices
|
|
166
|
+
return false unless color_records
|
|
167
|
+
|
|
168
|
+
true
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
private
|
|
172
|
+
|
|
173
|
+
# Parse CPAL header (12 bytes for version 0, 16 bytes for version 1)
|
|
174
|
+
#
|
|
175
|
+
# @param io [StringIO] Input stream
|
|
176
|
+
def parse_header(io)
|
|
177
|
+
@version = io.read(2).unpack1("n")
|
|
178
|
+
@num_palette_entries = io.read(2).unpack1("n")
|
|
179
|
+
@num_palettes = io.read(2).unpack1("n")
|
|
180
|
+
@num_color_records = io.read(2).unpack1("n")
|
|
181
|
+
@color_records_array_offset = io.read(4).unpack1("N")
|
|
182
|
+
|
|
183
|
+
# Version 1 has additional header fields
|
|
184
|
+
if version == 1
|
|
185
|
+
# TODO: Parse version 1 header fields
|
|
186
|
+
# - paletteTypesArrayOffset (uint32)
|
|
187
|
+
# - paletteLabelsArrayOffset (uint32)
|
|
188
|
+
# - paletteEntryLabelsArrayOffset (uint32)
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Validate header values
|
|
193
|
+
#
|
|
194
|
+
# @raise [CorruptedTableError] If validation fails
|
|
195
|
+
def validate_header!
|
|
196
|
+
unless [0, 1].include?(version)
|
|
197
|
+
raise CorruptedTableError,
|
|
198
|
+
"Unsupported CPAL version: #{version} (only versions 0 and 1 supported)"
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
if num_palette_entries.negative?
|
|
202
|
+
raise CorruptedTableError,
|
|
203
|
+
"Invalid numPaletteEntries: #{num_palette_entries}"
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
if num_palettes.negative?
|
|
207
|
+
raise CorruptedTableError,
|
|
208
|
+
"Invalid numPalettes: #{num_palettes}"
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
if num_color_records.negative?
|
|
212
|
+
raise CorruptedTableError,
|
|
213
|
+
"Invalid numColorRecords: #{num_color_records}"
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# Validate that total color records match expected count
|
|
217
|
+
expected_records = num_palettes * num_palette_entries
|
|
218
|
+
unless num_color_records >= expected_records
|
|
219
|
+
raise CorruptedTableError,
|
|
220
|
+
"Insufficient color records: expected at least #{expected_records}, " \
|
|
221
|
+
"got #{num_color_records}"
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# Parse palette indices array
|
|
226
|
+
#
|
|
227
|
+
# @param io [StringIO] Input stream
|
|
228
|
+
def parse_palette_indices(io)
|
|
229
|
+
@palette_indices = []
|
|
230
|
+
return if num_palettes.zero?
|
|
231
|
+
|
|
232
|
+
# Palette indices immediately follow header (at offset 12 for v0, 16 for v1)
|
|
233
|
+
# Each index is uint16 (2 bytes)
|
|
234
|
+
num_palettes.times do
|
|
235
|
+
index = io.read(2).unpack1("n")
|
|
236
|
+
@palette_indices << index
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# Parse color records array
|
|
241
|
+
#
|
|
242
|
+
# @param io [StringIO] Input stream
|
|
243
|
+
def parse_color_records(io)
|
|
244
|
+
@color_records = []
|
|
245
|
+
return if num_color_records.zero?
|
|
246
|
+
|
|
247
|
+
# Seek to color records array
|
|
248
|
+
io.seek(color_records_array_offset)
|
|
249
|
+
|
|
250
|
+
# Parse each color record (4 bytes, BGRA format)
|
|
251
|
+
num_color_records.times do
|
|
252
|
+
blue = io.read(1).unpack1("C")
|
|
253
|
+
green = io.read(1).unpack1("C")
|
|
254
|
+
red = io.read(1).unpack1("C")
|
|
255
|
+
alpha = io.read(1).unpack1("C")
|
|
256
|
+
|
|
257
|
+
@color_records << {
|
|
258
|
+
red: red,
|
|
259
|
+
green: green,
|
|
260
|
+
blue: blue,
|
|
261
|
+
alpha: alpha,
|
|
262
|
+
}
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# Convert color record to hex string
|
|
267
|
+
#
|
|
268
|
+
# @param color [Hash] Color hash with :red, :green, :blue, :alpha keys
|
|
269
|
+
# @return [String] Hex color string (#RRGGBBAA)
|
|
270
|
+
def color_to_hex(color)
|
|
271
|
+
format(
|
|
272
|
+
"#%<red>02X%<green>02X%<blue>02X%<alpha>02X",
|
|
273
|
+
red: color[:red],
|
|
274
|
+
green: color[:green],
|
|
275
|
+
blue: color[:blue],
|
|
276
|
+
alpha: color[:alpha],
|
|
277
|
+
)
|
|
278
|
+
end
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
end
|
|
@@ -76,7 +76,7 @@ module Fontisan
|
|
|
76
76
|
contours = outline.to_truetype_contours
|
|
77
77
|
raise ArgumentError, "no contours in outline" if contours.empty?
|
|
78
78
|
|
|
79
|
-
# Calculate bounding box from contours
|
|
79
|
+
# Calculate bounding box from contours (on-curve points only)
|
|
80
80
|
bbox = calculate_bounding_box(contours)
|
|
81
81
|
|
|
82
82
|
# Build binary data
|
|
@@ -389,6 +389,10 @@ axis)
|
|
|
389
389
|
|
|
390
390
|
contours.each do |contour|
|
|
391
391
|
contour.each do |point|
|
|
392
|
+
# Only consider on-curve points for bounding box
|
|
393
|
+
# Off-curve points are control points and may lie outside the actual outline
|
|
394
|
+
next unless point[:on_curve]
|
|
395
|
+
|
|
392
396
|
x = point[:x]
|
|
393
397
|
y = point[:y]
|
|
394
398
|
|