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,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
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "stringio"
|
|
4
|
+
require "zlib"
|
|
5
|
+
require_relative "../binary/base_record"
|
|
6
|
+
|
|
7
|
+
module Fontisan
|
|
8
|
+
module Tables
|
|
9
|
+
# SVG (Scalable Vector Graphics) table parser
|
|
10
|
+
#
|
|
11
|
+
# The SVG table contains embedded SVG documents for glyphs, typically used
|
|
12
|
+
# for color emoji or graphic elements. Each document can cover a range of
|
|
13
|
+
# glyph IDs and may be compressed with gzip.
|
|
14
|
+
#
|
|
15
|
+
# SVG Table Structure:
|
|
16
|
+
# ```
|
|
17
|
+
# SVG Table = Header (10 bytes)
|
|
18
|
+
# + Document Index
|
|
19
|
+
# + SVG Documents
|
|
20
|
+
# ```
|
|
21
|
+
#
|
|
22
|
+
# Header (10 bytes):
|
|
23
|
+
# - version (uint16): Table version (0)
|
|
24
|
+
# - svgDocumentListOffset (uint32): Offset to SVG Document Index
|
|
25
|
+
# - reserved (uint32): Reserved, set to 0
|
|
26
|
+
#
|
|
27
|
+
# Document Index:
|
|
28
|
+
# - numEntries (uint16): Number of SVG Document Index Entries
|
|
29
|
+
# - entries[numEntries]: Array of SVG Document Index Entries
|
|
30
|
+
#
|
|
31
|
+
# SVG Document Index Entry (12 bytes):
|
|
32
|
+
# - startGlyphID (uint16): First glyph ID
|
|
33
|
+
# - endGlyphID (uint16): Last glyph ID (inclusive)
|
|
34
|
+
# - svgDocOffset (uint32): Offset to SVG document
|
|
35
|
+
# - svgDocLength (uint32): Length of SVG document
|
|
36
|
+
#
|
|
37
|
+
# SVG documents may be compressed with gzip (identified by magic bytes 0x1f 0x8b).
|
|
38
|
+
#
|
|
39
|
+
# Reference: OpenType SVG specification
|
|
40
|
+
# https://docs.microsoft.com/en-us/typography/opentype/spec/svg
|
|
41
|
+
#
|
|
42
|
+
# @example Reading an SVG table
|
|
43
|
+
# data = font.table_data['SVG ']
|
|
44
|
+
# svg = Fontisan::Tables::Svg.read(data)
|
|
45
|
+
# svg_content = svg.svg_for_glyph(42)
|
|
46
|
+
# puts "Glyph 42 SVG: #{svg_content}"
|
|
47
|
+
class Svg < Binary::BaseRecord
|
|
48
|
+
# OpenType table tag for SVG (note: includes trailing space)
|
|
49
|
+
TAG = "SVG "
|
|
50
|
+
|
|
51
|
+
# SVG Document Index Entry structure
|
|
52
|
+
#
|
|
53
|
+
# Each entry associates a glyph range with an SVG document.
|
|
54
|
+
# Structure (12 bytes): start_glyph_id, end_glyph_id, svg_doc_offset, svg_doc_length
|
|
55
|
+
class SvgDocumentRecord < Binary::BaseRecord
|
|
56
|
+
endian :big
|
|
57
|
+
uint16 :start_glyph_id
|
|
58
|
+
uint16 :end_glyph_id
|
|
59
|
+
uint32 :svg_doc_offset
|
|
60
|
+
uint32 :svg_doc_length
|
|
61
|
+
|
|
62
|
+
# Check if this record includes a specific glyph ID
|
|
63
|
+
#
|
|
64
|
+
# @param glyph_id [Integer] Glyph ID to check
|
|
65
|
+
# @return [Boolean] True if glyph is in range
|
|
66
|
+
def includes_glyph?(glyph_id)
|
|
67
|
+
glyph_id >= start_glyph_id && glyph_id <= end_glyph_id
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Get the glyph range for this record
|
|
71
|
+
#
|
|
72
|
+
# @return [Range] Range of glyph IDs
|
|
73
|
+
def glyph_range
|
|
74
|
+
start_glyph_id..end_glyph_id
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# @return [Integer] SVG table version (0)
|
|
79
|
+
attr_reader :version
|
|
80
|
+
|
|
81
|
+
# @return [Integer] Offset to SVG Document Index
|
|
82
|
+
attr_reader :svg_document_list_offset
|
|
83
|
+
|
|
84
|
+
# @return [Integer] Number of SVG document entries
|
|
85
|
+
attr_reader :num_entries
|
|
86
|
+
|
|
87
|
+
# @return [Array<SvgDocumentRecord>] Parsed document records
|
|
88
|
+
attr_reader :document_records
|
|
89
|
+
|
|
90
|
+
# @return [String] Raw binary data for the entire SVG table
|
|
91
|
+
attr_reader :raw_data
|
|
92
|
+
|
|
93
|
+
# Override read to parse SVG structure
|
|
94
|
+
#
|
|
95
|
+
# @param io [IO, String] Binary data to read
|
|
96
|
+
# @return [Svg] Parsed SVG table
|
|
97
|
+
def self.read(io)
|
|
98
|
+
svg = new
|
|
99
|
+
return svg if io.nil?
|
|
100
|
+
|
|
101
|
+
data = io.is_a?(String) ? io : io.read
|
|
102
|
+
svg.parse!(data)
|
|
103
|
+
svg
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Parse the SVG table structure
|
|
107
|
+
#
|
|
108
|
+
# @param data [String] Binary data for the SVG table
|
|
109
|
+
# @raise [CorruptedTableError] If SVG structure is invalid
|
|
110
|
+
def parse!(data)
|
|
111
|
+
@raw_data = data
|
|
112
|
+
io = StringIO.new(data)
|
|
113
|
+
|
|
114
|
+
# Parse SVG header (10 bytes)
|
|
115
|
+
parse_header(io)
|
|
116
|
+
validate_header!
|
|
117
|
+
|
|
118
|
+
# Parse document index
|
|
119
|
+
parse_document_index(io)
|
|
120
|
+
rescue StandardError => e
|
|
121
|
+
raise CorruptedTableError, "Failed to parse SVG table: #{e.message}"
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Get SVG document for a specific glyph ID
|
|
125
|
+
#
|
|
126
|
+
# Returns the SVG XML content for the specified glyph.
|
|
127
|
+
# Automatically decompresses gzipped content.
|
|
128
|
+
# Returns nil if glyph has no SVG data.
|
|
129
|
+
#
|
|
130
|
+
# @param glyph_id [Integer] Glyph ID to look up
|
|
131
|
+
# @return [String, nil] SVG XML content or nil
|
|
132
|
+
def svg_for_glyph(glyph_id)
|
|
133
|
+
record = find_document_record(glyph_id)
|
|
134
|
+
return nil unless record
|
|
135
|
+
|
|
136
|
+
extract_svg_document(record)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Check if glyph has SVG data
|
|
140
|
+
#
|
|
141
|
+
# @param glyph_id [Integer] Glyph ID to check
|
|
142
|
+
# @return [Boolean] True if glyph has SVG
|
|
143
|
+
def has_svg_for_glyph?(glyph_id)
|
|
144
|
+
!find_document_record(glyph_id).nil?
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Get all glyph IDs that have SVG data
|
|
148
|
+
#
|
|
149
|
+
# @return [Array<Integer>] Array of glyph IDs with SVG
|
|
150
|
+
def glyph_ids_with_svg
|
|
151
|
+
document_records.flat_map do |record|
|
|
152
|
+
record.glyph_range.to_a
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Get the number of SVG documents in this table
|
|
157
|
+
#
|
|
158
|
+
# @return [Integer] Number of SVG documents
|
|
159
|
+
def num_svg_documents
|
|
160
|
+
num_entries
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Validate the SVG table structure
|
|
164
|
+
#
|
|
165
|
+
# @return [Boolean] True if valid
|
|
166
|
+
def valid?
|
|
167
|
+
return false if version.nil?
|
|
168
|
+
return false if version != 0 # Only version 0 supported
|
|
169
|
+
return false if num_entries.nil? || num_entries.negative?
|
|
170
|
+
return false unless document_records
|
|
171
|
+
|
|
172
|
+
true
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
private
|
|
176
|
+
|
|
177
|
+
# Parse SVG header (10 bytes)
|
|
178
|
+
#
|
|
179
|
+
# @param io [StringIO] Input stream
|
|
180
|
+
def parse_header(io)
|
|
181
|
+
@version = io.read(2).unpack1("n")
|
|
182
|
+
@svg_document_list_offset = io.read(4).unpack1("N")
|
|
183
|
+
@reserved = io.read(4).unpack1("N")
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Validate header values
|
|
187
|
+
#
|
|
188
|
+
# @raise [CorruptedTableError] If validation fails
|
|
189
|
+
def validate_header!
|
|
190
|
+
unless version.zero?
|
|
191
|
+
raise CorruptedTableError,
|
|
192
|
+
"Unsupported SVG version: #{version} (only version 0 supported)"
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
if svg_document_list_offset > raw_data.length
|
|
196
|
+
raise CorruptedTableError,
|
|
197
|
+
"Invalid svgDocumentListOffset: #{svg_document_list_offset}"
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Parse document index
|
|
202
|
+
#
|
|
203
|
+
# @param io [StringIO] Input stream
|
|
204
|
+
def parse_document_index(io)
|
|
205
|
+
# Seek to document index
|
|
206
|
+
io.seek(svg_document_list_offset)
|
|
207
|
+
|
|
208
|
+
# Check if there's enough data to read num_entries
|
|
209
|
+
return if io.eof?
|
|
210
|
+
|
|
211
|
+
# Parse number of entries
|
|
212
|
+
num_entries_data = io.read(2)
|
|
213
|
+
return if num_entries_data.nil? || num_entries_data.length < 2
|
|
214
|
+
|
|
215
|
+
@num_entries = num_entries_data.unpack1("n")
|
|
216
|
+
@document_records = []
|
|
217
|
+
|
|
218
|
+
return if num_entries.zero?
|
|
219
|
+
|
|
220
|
+
# Parse each document record (12 bytes each)
|
|
221
|
+
num_entries.times do
|
|
222
|
+
record_data = io.read(12)
|
|
223
|
+
record = SvgDocumentRecord.read(record_data)
|
|
224
|
+
@document_records << record
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# Find document record for a specific glyph ID
|
|
229
|
+
#
|
|
230
|
+
# Uses binary search since document records should be sorted by glyph ID.
|
|
231
|
+
#
|
|
232
|
+
# @param glyph_id [Integer] Glyph ID to find
|
|
233
|
+
# @return [SvgDocumentRecord, nil] Document record or nil if not found
|
|
234
|
+
def find_document_record(glyph_id)
|
|
235
|
+
# Binary search through document records
|
|
236
|
+
left = 0
|
|
237
|
+
right = document_records.length - 1
|
|
238
|
+
|
|
239
|
+
while left <= right
|
|
240
|
+
mid = (left + right) / 2
|
|
241
|
+
record = document_records[mid]
|
|
242
|
+
|
|
243
|
+
if record.includes_glyph?(glyph_id)
|
|
244
|
+
return record
|
|
245
|
+
elsif glyph_id < record.start_glyph_id
|
|
246
|
+
right = mid - 1
|
|
247
|
+
else
|
|
248
|
+
left = mid + 1
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
nil
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# Extract SVG document from record
|
|
256
|
+
#
|
|
257
|
+
# Calculates absolute offset and extracts SVG data.
|
|
258
|
+
# Automatically decompresses gzipped content.
|
|
259
|
+
#
|
|
260
|
+
# @param record [SvgDocumentRecord] Document record
|
|
261
|
+
# @return [String] SVG XML content
|
|
262
|
+
def extract_svg_document(record)
|
|
263
|
+
# Calculate absolute offset
|
|
264
|
+
# Offset is relative to start of SVG Document List
|
|
265
|
+
# Document List = numEntries (2 bytes) + entries array + documents
|
|
266
|
+
documents_offset = svg_document_list_offset + 2 + (num_entries * 12)
|
|
267
|
+
absolute_offset = documents_offset + record.svg_doc_offset
|
|
268
|
+
|
|
269
|
+
# Extract SVG data
|
|
270
|
+
svg_data = raw_data[absolute_offset, record.svg_doc_length]
|
|
271
|
+
|
|
272
|
+
# Check if compressed (gzip magic bytes: 0x1f 0x8b)
|
|
273
|
+
if gzipped?(svg_data)
|
|
274
|
+
decompress_gzip(svg_data)
|
|
275
|
+
else
|
|
276
|
+
svg_data
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
# Check if data is gzipped
|
|
281
|
+
#
|
|
282
|
+
# @param data [String] Binary data
|
|
283
|
+
# @return [Boolean] True if gzipped
|
|
284
|
+
def gzipped?(data)
|
|
285
|
+
return false if data.nil? || data.length < 2
|
|
286
|
+
|
|
287
|
+
data[0..1].unpack("C*") == [0x1f, 0x8b]
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
# Decompress gzipped data
|
|
291
|
+
#
|
|
292
|
+
# @param data [String] Gzipped binary data
|
|
293
|
+
# @return [String] Decompressed data
|
|
294
|
+
def decompress_gzip(data)
|
|
295
|
+
Zlib::GzipReader.new(StringIO.new(data)).read
|
|
296
|
+
rescue Zlib::Error => e
|
|
297
|
+
raise CorruptedTableError, "Failed to decompress SVG data: #{e.message}"
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
end
|
|
@@ -532,6 +532,12 @@ module Fontisan
|
|
|
532
532
|
Constants::GPOS_TAG => Tables::Gpos,
|
|
533
533
|
Constants::GLYF_TAG => Tables::Glyf,
|
|
534
534
|
Constants::LOCA_TAG => Tables::Loca,
|
|
535
|
+
"SVG " => Tables::Svg,
|
|
536
|
+
"COLR" => Tables::Colr,
|
|
537
|
+
"CPAL" => Tables::Cpal,
|
|
538
|
+
"CBDT" => Tables::Cbdt,
|
|
539
|
+
"CBLC" => Tables::Cblc,
|
|
540
|
+
"sbix" => Tables::Sbix,
|
|
535
541
|
}[tag]
|
|
536
542
|
end
|
|
537
543
|
|