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,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
|
|
|
@@ -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
|