fontisan 0.1.0
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 +7 -0
- data/.rspec +3 -0
- data/.rubocop.yml +13 -0
- data/.rubocop_todo.yml +217 -0
- data/Gemfile +15 -0
- data/LICENSE +24 -0
- data/README.adoc +984 -0
- data/Rakefile +95 -0
- data/exe/fontisan +7 -0
- data/fontisan.gemspec +44 -0
- data/lib/fontisan/binary/base_record.rb +57 -0
- data/lib/fontisan/binary/structures.rb +84 -0
- data/lib/fontisan/cli.rb +192 -0
- data/lib/fontisan/commands/base_command.rb +82 -0
- data/lib/fontisan/commands/dump_table_command.rb +71 -0
- data/lib/fontisan/commands/features_command.rb +94 -0
- data/lib/fontisan/commands/glyphs_command.rb +50 -0
- data/lib/fontisan/commands/info_command.rb +120 -0
- data/lib/fontisan/commands/optical_size_command.rb +41 -0
- data/lib/fontisan/commands/scripts_command.rb +59 -0
- data/lib/fontisan/commands/tables_command.rb +52 -0
- data/lib/fontisan/commands/unicode_command.rb +76 -0
- data/lib/fontisan/commands/variable_command.rb +61 -0
- data/lib/fontisan/config/features.yml +143 -0
- data/lib/fontisan/config/scripts.yml +42 -0
- data/lib/fontisan/constants.rb +78 -0
- data/lib/fontisan/error.rb +15 -0
- data/lib/fontisan/font_loader.rb +109 -0
- data/lib/fontisan/formatters/text_formatter.rb +314 -0
- data/lib/fontisan/models/all_scripts_features_info.rb +21 -0
- data/lib/fontisan/models/features_info.rb +42 -0
- data/lib/fontisan/models/font_info.rb +99 -0
- data/lib/fontisan/models/glyph_info.rb +26 -0
- data/lib/fontisan/models/optical_size_info.rb +33 -0
- data/lib/fontisan/models/scripts_info.rb +39 -0
- data/lib/fontisan/models/table_info.rb +55 -0
- data/lib/fontisan/models/unicode_mappings.rb +42 -0
- data/lib/fontisan/models/variable_font_info.rb +82 -0
- data/lib/fontisan/open_type_collection.rb +97 -0
- data/lib/fontisan/open_type_font.rb +292 -0
- data/lib/fontisan/parsers/tag.rb +77 -0
- data/lib/fontisan/tables/cmap.rb +284 -0
- data/lib/fontisan/tables/fvar.rb +157 -0
- data/lib/fontisan/tables/gpos.rb +111 -0
- data/lib/fontisan/tables/gsub.rb +111 -0
- data/lib/fontisan/tables/head.rb +114 -0
- data/lib/fontisan/tables/layout_common.rb +73 -0
- data/lib/fontisan/tables/name.rb +188 -0
- data/lib/fontisan/tables/os2.rb +175 -0
- data/lib/fontisan/tables/post.rb +148 -0
- data/lib/fontisan/true_type_collection.rb +98 -0
- data/lib/fontisan/true_type_font.rb +313 -0
- data/lib/fontisan/utilities/checksum_calculator.rb +89 -0
- data/lib/fontisan/version.rb +5 -0
- data/lib/fontisan.rb +80 -0
- metadata +150 -0
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bindata"
|
|
4
|
+
require_relative "constants"
|
|
5
|
+
require_relative "utilities/checksum_calculator"
|
|
6
|
+
|
|
7
|
+
module Fontisan
|
|
8
|
+
# OpenType Font domain object using BinData
|
|
9
|
+
#
|
|
10
|
+
# Represents a complete OpenType Font file (CFF outlines) using BinData's declarative
|
|
11
|
+
# DSL for binary structure definition. Parallel to TrueTypeFont but for CFF format.
|
|
12
|
+
#
|
|
13
|
+
# @example Reading and analyzing a font
|
|
14
|
+
# otf = OpenTypeFont.from_file("font.otf")
|
|
15
|
+
# puts otf.header.num_tables # => 12
|
|
16
|
+
# name_table = otf.table("name")
|
|
17
|
+
# puts name_table.english_name(Tables::Name::FAMILY)
|
|
18
|
+
#
|
|
19
|
+
# @example Writing a font
|
|
20
|
+
# otf.to_file("output.otf")
|
|
21
|
+
class OpenTypeFont < BinData::Record
|
|
22
|
+
endian :big
|
|
23
|
+
|
|
24
|
+
offset_table :header
|
|
25
|
+
array :tables, type: :table_directory, initial_length: lambda {
|
|
26
|
+
header.num_tables
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
# Table data is stored separately since it's at variable offsets
|
|
30
|
+
attr_accessor :table_data
|
|
31
|
+
|
|
32
|
+
# Parsed table instances cache
|
|
33
|
+
attr_accessor :parsed_tables
|
|
34
|
+
|
|
35
|
+
# Read OpenType Font from a file
|
|
36
|
+
#
|
|
37
|
+
# @param path [String] Path to the OTF file
|
|
38
|
+
# @return [OpenTypeFont] A new instance
|
|
39
|
+
# @raise [ArgumentError] if path is nil or empty
|
|
40
|
+
# @raise [Errno::ENOENT] if file does not exist
|
|
41
|
+
# @raise [RuntimeError] if file format is invalid
|
|
42
|
+
def self.from_file(path)
|
|
43
|
+
if path.nil? || path.to_s.empty?
|
|
44
|
+
raise ArgumentError,
|
|
45
|
+
"path cannot be nil or empty"
|
|
46
|
+
end
|
|
47
|
+
raise Errno::ENOENT, "File not found: #{path}" unless File.exist?(path)
|
|
48
|
+
|
|
49
|
+
File.open(path, "rb") do |io|
|
|
50
|
+
font = read(io)
|
|
51
|
+
font.initialize_storage
|
|
52
|
+
font.read_table_data(io)
|
|
53
|
+
font
|
|
54
|
+
end
|
|
55
|
+
rescue BinData::ValidityError, EOFError => e
|
|
56
|
+
raise "Invalid OTF file: #{e.message}"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Read OpenType Font from collection at specific offset
|
|
60
|
+
#
|
|
61
|
+
# @param io [IO] Open file handle
|
|
62
|
+
# @param offset [Integer] Byte offset to the font
|
|
63
|
+
# @return [OpenTypeFont] A new instance
|
|
64
|
+
def self.from_collection(io, offset)
|
|
65
|
+
io.seek(offset)
|
|
66
|
+
font = read(io)
|
|
67
|
+
font.initialize_storage
|
|
68
|
+
font.read_table_data(io)
|
|
69
|
+
font
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Initialize storage hashes
|
|
73
|
+
#
|
|
74
|
+
# @return [void]
|
|
75
|
+
def initialize_storage
|
|
76
|
+
@table_data = {}
|
|
77
|
+
@parsed_tables = {}
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Read table data for all tables
|
|
81
|
+
#
|
|
82
|
+
# @param io [IO] Open file handle
|
|
83
|
+
# @return [void]
|
|
84
|
+
def read_table_data(io)
|
|
85
|
+
@table_data = {}
|
|
86
|
+
tables.each do |entry|
|
|
87
|
+
io.seek(entry.offset)
|
|
88
|
+
# Force UTF-8 encoding on tag for hash key consistency
|
|
89
|
+
tag_key = entry.tag.dup.force_encoding("UTF-8")
|
|
90
|
+
@table_data[tag_key] = io.read(entry.table_length)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Write OpenType Font to a file
|
|
95
|
+
#
|
|
96
|
+
# Writes the complete OTF structure to disk, including proper checksum
|
|
97
|
+
# calculation and table alignment.
|
|
98
|
+
#
|
|
99
|
+
# @param path [String] Path where the OTF file will be written
|
|
100
|
+
# @return [Integer] Number of bytes written
|
|
101
|
+
# @raise [IOError] if writing fails
|
|
102
|
+
def to_file(path)
|
|
103
|
+
File.open(path, "wb") do |io|
|
|
104
|
+
# Write header and tables (directory)
|
|
105
|
+
write_structure(io)
|
|
106
|
+
|
|
107
|
+
# Write table data with updated offsets
|
|
108
|
+
write_table_data_with_offsets(io)
|
|
109
|
+
|
|
110
|
+
io.pos
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Update checksum adjustment in head table
|
|
114
|
+
update_checksum_adjustment_in_file(path) if head_table
|
|
115
|
+
|
|
116
|
+
File.size(path)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Validate format correctness
|
|
120
|
+
#
|
|
121
|
+
# @return [Boolean] true if the OTF format is valid, false otherwise
|
|
122
|
+
def valid?
|
|
123
|
+
return false unless header
|
|
124
|
+
return false unless tables.respond_to?(:length)
|
|
125
|
+
return false unless @table_data.is_a?(Hash)
|
|
126
|
+
return false if tables.length != header.num_tables
|
|
127
|
+
return false unless head_table
|
|
128
|
+
return false unless has_table?(Constants::CFF_TAG)
|
|
129
|
+
|
|
130
|
+
true
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Check if font has a specific table
|
|
134
|
+
#
|
|
135
|
+
# @param tag [String] The table tag to check for
|
|
136
|
+
# @return [Boolean] true if table exists, false otherwise
|
|
137
|
+
def has_table?(tag)
|
|
138
|
+
tables.any? { |entry| entry.tag == tag }
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Find a table entry by tag
|
|
142
|
+
#
|
|
143
|
+
# @param tag [String] The table tag to find
|
|
144
|
+
# @return [TableDirectory, nil] The table entry or nil
|
|
145
|
+
def find_table_entry(tag)
|
|
146
|
+
tables.find { |entry| entry.tag == tag }
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Get the head table entry
|
|
150
|
+
#
|
|
151
|
+
# @return [TableDirectory, nil] The head table entry or nil
|
|
152
|
+
def head_table
|
|
153
|
+
find_table_entry(Constants::HEAD_TAG)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Get list of all table tags
|
|
157
|
+
#
|
|
158
|
+
# @return [Array<String>] Array of table tag strings
|
|
159
|
+
def table_names
|
|
160
|
+
tables.map(&:tag)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Get parsed table instance
|
|
164
|
+
#
|
|
165
|
+
# This method parses the raw table data into a structured table object
|
|
166
|
+
# and caches the result for subsequent calls.
|
|
167
|
+
#
|
|
168
|
+
# @param tag [String] The table tag to retrieve
|
|
169
|
+
# @return [Tables::*, nil] Parsed table object or nil if not found
|
|
170
|
+
def table(tag)
|
|
171
|
+
@parsed_tables[tag] ||= parse_table(tag)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Get units per em from head table
|
|
175
|
+
#
|
|
176
|
+
# @return [Integer, nil] Units per em value
|
|
177
|
+
def units_per_em
|
|
178
|
+
head = table(Constants::HEAD_TAG)
|
|
179
|
+
head&.units_per_em
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
private
|
|
183
|
+
|
|
184
|
+
# Parse a table from raw data
|
|
185
|
+
#
|
|
186
|
+
# @param tag [String] The table tag to parse
|
|
187
|
+
# @return [Tables::*, nil] Parsed table object or nil
|
|
188
|
+
def parse_table(tag)
|
|
189
|
+
raw_data = @table_data[tag]
|
|
190
|
+
return nil unless raw_data
|
|
191
|
+
|
|
192
|
+
table_class = table_class_for(tag)
|
|
193
|
+
return nil unless table_class
|
|
194
|
+
|
|
195
|
+
table_class.read(raw_data)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Map table tag to parser class
|
|
199
|
+
#
|
|
200
|
+
# @param tag [String] The table tag
|
|
201
|
+
# @return [Class, nil] Table parser class or nil
|
|
202
|
+
def table_class_for(tag)
|
|
203
|
+
{
|
|
204
|
+
Constants::HEAD_TAG => Tables::Head,
|
|
205
|
+
Constants::NAME_TAG => Tables::Name,
|
|
206
|
+
Constants::OS2_TAG => Tables::Os2,
|
|
207
|
+
Constants::POST_TAG => Tables::Post,
|
|
208
|
+
Constants::CMAP_TAG => Tables::Cmap,
|
|
209
|
+
Constants::FVAR_TAG => Tables::Fvar,
|
|
210
|
+
Constants::GSUB_TAG => Tables::Gsub,
|
|
211
|
+
Constants::GPOS_TAG => Tables::Gpos,
|
|
212
|
+
}[tag]
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# Write the structure (header + table directory) to IO
|
|
216
|
+
#
|
|
217
|
+
# @param io [IO] Open file handle
|
|
218
|
+
# @return [void]
|
|
219
|
+
def write_structure(io)
|
|
220
|
+
# Write header
|
|
221
|
+
header.write(io)
|
|
222
|
+
|
|
223
|
+
# Write table directory with placeholder offsets
|
|
224
|
+
tables.each do |entry|
|
|
225
|
+
io.write(entry.tag)
|
|
226
|
+
io.write([entry.checksum].pack("N"))
|
|
227
|
+
io.write([0].pack("N")) # Placeholder offset
|
|
228
|
+
io.write([entry.table_length].pack("N"))
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# Write table data and update offsets in directory
|
|
233
|
+
#
|
|
234
|
+
# @param io [IO] Open file handle
|
|
235
|
+
# @return [void]
|
|
236
|
+
def write_table_data_with_offsets(io)
|
|
237
|
+
tables.each_with_index do |entry, index|
|
|
238
|
+
# Record current position
|
|
239
|
+
current_position = io.pos
|
|
240
|
+
|
|
241
|
+
# Write table data
|
|
242
|
+
data = @table_data[entry.tag]
|
|
243
|
+
raise IOError, "Missing table data for tag '#{entry.tag}'" if data.nil?
|
|
244
|
+
|
|
245
|
+
io.write(data)
|
|
246
|
+
|
|
247
|
+
# Add padding to align to 4-byte boundary
|
|
248
|
+
padding = (Constants::TABLE_ALIGNMENT - (io.pos % Constants::TABLE_ALIGNMENT)) % Constants::TABLE_ALIGNMENT
|
|
249
|
+
io.write("\x00" * padding) if padding.positive?
|
|
250
|
+
|
|
251
|
+
# Zero out checksumAdjustment field in head table
|
|
252
|
+
if entry.tag == Constants::HEAD_TAG
|
|
253
|
+
current_pos = io.pos
|
|
254
|
+
io.seek(current_position + 8)
|
|
255
|
+
io.write([0].pack("N"))
|
|
256
|
+
io.seek(current_pos)
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# Update offset in table directory
|
|
260
|
+
# Table directory starts at byte 12, each entry is 16 bytes
|
|
261
|
+
# Offset field is at byte 8 within each entry
|
|
262
|
+
directory_offset_position = 12 + (index * 16) + 8
|
|
263
|
+
current_pos = io.pos
|
|
264
|
+
io.seek(directory_offset_position)
|
|
265
|
+
io.write([current_position].pack("N"))
|
|
266
|
+
io.seek(current_pos)
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# Update checksumAdjustment field in head table
|
|
271
|
+
#
|
|
272
|
+
# @param path [String] Path to the OTF file
|
|
273
|
+
# @return [void]
|
|
274
|
+
def update_checksum_adjustment_in_file(path)
|
|
275
|
+
# Calculate file checksum
|
|
276
|
+
checksum = Utilities::ChecksumCalculator.calculate_file_checksum(path)
|
|
277
|
+
|
|
278
|
+
# Calculate adjustment
|
|
279
|
+
adjustment = Utilities::ChecksumCalculator.calculate_adjustment(checksum)
|
|
280
|
+
|
|
281
|
+
# Find head table position
|
|
282
|
+
head_entry = head_table
|
|
283
|
+
return unless head_entry
|
|
284
|
+
|
|
285
|
+
# Write adjustment to head table (offset 8 within head table)
|
|
286
|
+
File.open(path, "r+b") do |io|
|
|
287
|
+
io.seek(head_entry.offset + 8)
|
|
288
|
+
io.write([adjustment].pack("N"))
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fontisan
|
|
4
|
+
module Parsers
|
|
5
|
+
# Represents an OpenType tag (4-character identifier)
|
|
6
|
+
#
|
|
7
|
+
# OpenType tags are four-byte identifiers used to identify tables,
|
|
8
|
+
# scripts, languages, and features. Tags are case-sensitive and
|
|
9
|
+
# padded with spaces if shorter than 4 characters.
|
|
10
|
+
class Tag
|
|
11
|
+
attr_reader :value
|
|
12
|
+
|
|
13
|
+
# Initialize a new Tag
|
|
14
|
+
#
|
|
15
|
+
# @param value [String] Tag value (1-4 characters)
|
|
16
|
+
# @raise [Fontisan::Error] If value is not a String
|
|
17
|
+
def initialize(value)
|
|
18
|
+
@value = normalize_tag(value)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Convert tag to string
|
|
22
|
+
#
|
|
23
|
+
# @return [String] 4-character tag string
|
|
24
|
+
def to_s
|
|
25
|
+
@value
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Compare tag with another tag or string
|
|
29
|
+
#
|
|
30
|
+
# @param other [Tag, String] Object to compare with
|
|
31
|
+
# @return [Boolean] True if tags are equal
|
|
32
|
+
def ==(other)
|
|
33
|
+
case other
|
|
34
|
+
when Tag
|
|
35
|
+
@value == other.value
|
|
36
|
+
when String
|
|
37
|
+
@value == normalize_tag(other)
|
|
38
|
+
else
|
|
39
|
+
false
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
alias eql? ==
|
|
44
|
+
|
|
45
|
+
# Generate hash for use as Hash key
|
|
46
|
+
#
|
|
47
|
+
# @return [Integer] Hash value
|
|
48
|
+
def hash
|
|
49
|
+
@value.hash
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Check if tag is valid (exactly 4 characters)
|
|
53
|
+
#
|
|
54
|
+
# @return [Boolean] True if tag is valid
|
|
55
|
+
def valid?
|
|
56
|
+
@value.length == 4
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
# Normalize tag to 4 characters
|
|
62
|
+
#
|
|
63
|
+
# @param tag [String] Tag to normalize
|
|
64
|
+
# @return [String] Normalized 4-character tag
|
|
65
|
+
# @raise [Fontisan::Error] If tag is not a String
|
|
66
|
+
def normalize_tag(tag)
|
|
67
|
+
case tag
|
|
68
|
+
when String
|
|
69
|
+
tag = tag.slice(0, 4).ljust(4, " ")
|
|
70
|
+
else
|
|
71
|
+
raise Error, "Invalid tag: #{tag.inspect}"
|
|
72
|
+
end
|
|
73
|
+
tag
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../binary/base_record"
|
|
4
|
+
|
|
5
|
+
module Fontisan
|
|
6
|
+
module Tables
|
|
7
|
+
# Parser for the 'cmap' (Character to Glyph Index Mapping) table
|
|
8
|
+
#
|
|
9
|
+
# The cmap table maps character codes to glyph indices. It supports
|
|
10
|
+
# multiple encoding formats to accommodate different character sets and
|
|
11
|
+
# Unicode planes.
|
|
12
|
+
#
|
|
13
|
+
# This implementation focuses on:
|
|
14
|
+
# - Format 4: Segment mapping for BMP (Basic Multilingual Plane, U+0000-U+FFFF)
|
|
15
|
+
# - Format 12: Segmented coverage for full Unicode support
|
|
16
|
+
#
|
|
17
|
+
# Reference: OpenType specification, cmap table
|
|
18
|
+
class Cmap < Binary::BaseRecord
|
|
19
|
+
# Platform IDs
|
|
20
|
+
PLATFORM_UNICODE = 0
|
|
21
|
+
PLATFORM_MACINTOSH = 1
|
|
22
|
+
PLATFORM_MICROSOFT = 3
|
|
23
|
+
|
|
24
|
+
# Microsoft Encoding IDs
|
|
25
|
+
ENC_MS_UNICODE_BMP = 1 # Unicode BMP (UCS-2)
|
|
26
|
+
ENC_MS_UNICODE_UCS4 = 10 # Unicode full repertoire (UCS-4)
|
|
27
|
+
|
|
28
|
+
endian :big
|
|
29
|
+
|
|
30
|
+
uint16 :version
|
|
31
|
+
uint16 :num_tables
|
|
32
|
+
rest :remaining_data
|
|
33
|
+
|
|
34
|
+
# Parse encoding records and subtables
|
|
35
|
+
def unicode_mappings
|
|
36
|
+
@unicode_mappings ||= parse_mappings
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
# Parse all encoding records and extract Unicode mappings
|
|
42
|
+
def parse_mappings
|
|
43
|
+
mappings = {}
|
|
44
|
+
|
|
45
|
+
# Get the full binary data
|
|
46
|
+
data = to_binary_s
|
|
47
|
+
|
|
48
|
+
# Read encoding records
|
|
49
|
+
records = read_encoding_records(data)
|
|
50
|
+
|
|
51
|
+
# Try to find the best Unicode subtable
|
|
52
|
+
# Prefer Microsoft Unicode UCS-4 (format 12), then Unicode BMP (format 4)
|
|
53
|
+
subtable_data = find_best_unicode_subtable(records, data)
|
|
54
|
+
|
|
55
|
+
return mappings unless subtable_data
|
|
56
|
+
|
|
57
|
+
# Parse the subtable based on its format
|
|
58
|
+
format = subtable_data[0, 2].unpack1("n")
|
|
59
|
+
|
|
60
|
+
case format
|
|
61
|
+
when 4
|
|
62
|
+
parse_format_4(subtable_data, mappings)
|
|
63
|
+
when 12
|
|
64
|
+
parse_format_12(subtable_data, mappings)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
mappings
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Read encoding records from the beginning of the table
|
|
71
|
+
def read_encoding_records(data)
|
|
72
|
+
records = []
|
|
73
|
+
offset = 4 # Skip version and numTables
|
|
74
|
+
|
|
75
|
+
num_tables.times do
|
|
76
|
+
break if offset + 8 > data.length
|
|
77
|
+
|
|
78
|
+
platform_id = data[offset, 2].unpack1("n")
|
|
79
|
+
encoding_id = data[offset + 2, 2].unpack1("n")
|
|
80
|
+
subtable_offset = data[offset + 4, 4].unpack1("N")
|
|
81
|
+
|
|
82
|
+
records << {
|
|
83
|
+
platform_id: platform_id,
|
|
84
|
+
encoding_id: encoding_id,
|
|
85
|
+
offset: subtable_offset,
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
offset += 8
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
records
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Find the best Unicode subtable from encoding records
|
|
95
|
+
def find_best_unicode_subtable(records, data)
|
|
96
|
+
# Try in priority order: UCS-4, BMP, Unicode
|
|
97
|
+
find_ucs4_subtable(records, data) ||
|
|
98
|
+
find_bmp_subtable(records, data) ||
|
|
99
|
+
find_unicode_subtable(records, data)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Find Microsoft Unicode UCS-4 subtable (full Unicode)
|
|
103
|
+
def find_ucs4_subtable(records, data)
|
|
104
|
+
record = records.find do |r|
|
|
105
|
+
r[:platform_id] == PLATFORM_MICROSOFT &&
|
|
106
|
+
r[:encoding_id] == ENC_MS_UNICODE_UCS4
|
|
107
|
+
end
|
|
108
|
+
extract_subtable_data(record, data)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Find Microsoft Unicode BMP subtable
|
|
112
|
+
def find_bmp_subtable(records, data)
|
|
113
|
+
record = records.find do |r|
|
|
114
|
+
r[:platform_id] == PLATFORM_MICROSOFT &&
|
|
115
|
+
r[:encoding_id] == ENC_MS_UNICODE_BMP
|
|
116
|
+
end
|
|
117
|
+
extract_subtable_data(record, data)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Find Unicode platform subtable (any encoding)
|
|
121
|
+
def find_unicode_subtable(records, data)
|
|
122
|
+
record = records.find { |r| r[:platform_id] == PLATFORM_UNICODE }
|
|
123
|
+
extract_subtable_data(record, data)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Extract subtable data if record exists and offset is valid
|
|
127
|
+
def extract_subtable_data(record, data)
|
|
128
|
+
return nil unless record
|
|
129
|
+
return nil unless record[:offset] < data.length
|
|
130
|
+
|
|
131
|
+
data[record[:offset]..]
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Parse Format 4 subtable (segment mapping to delta values)
|
|
135
|
+
# Format 4 is the most common format for BMP Unicode fonts
|
|
136
|
+
# rubocop:disable Metrics/MethodLength
|
|
137
|
+
# rubocop:disable Metrics/CyclomaticComplexity
|
|
138
|
+
# rubocop:disable Metrics/PerceivedComplexity
|
|
139
|
+
def parse_format_4(data, mappings)
|
|
140
|
+
return if data.length < 14
|
|
141
|
+
|
|
142
|
+
# Format 4 header
|
|
143
|
+
format = data[0, 2].unpack1("n")
|
|
144
|
+
return unless format == 4
|
|
145
|
+
|
|
146
|
+
length = data[2, 2].unpack1("n")
|
|
147
|
+
return if length > data.length
|
|
148
|
+
|
|
149
|
+
seg_count_x2 = data[6, 2].unpack1("n")
|
|
150
|
+
seg_count = seg_count_x2 / 2
|
|
151
|
+
|
|
152
|
+
# Arrays start at offset 14
|
|
153
|
+
offset = 14
|
|
154
|
+
|
|
155
|
+
# Read endCode array
|
|
156
|
+
end_codes = []
|
|
157
|
+
seg_count.times do
|
|
158
|
+
break if offset + 2 > length
|
|
159
|
+
|
|
160
|
+
end_codes << data[offset, 2].unpack1("n")
|
|
161
|
+
offset += 2
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Skip reservedPad (2 bytes)
|
|
165
|
+
offset += 2
|
|
166
|
+
|
|
167
|
+
# Read startCode array
|
|
168
|
+
start_codes = []
|
|
169
|
+
seg_count.times do
|
|
170
|
+
break if offset + 2 > length
|
|
171
|
+
|
|
172
|
+
start_codes << data[offset, 2].unpack1("n")
|
|
173
|
+
offset += 2
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Read idDelta array
|
|
177
|
+
id_deltas = []
|
|
178
|
+
seg_count.times do
|
|
179
|
+
break if offset + 2 > length
|
|
180
|
+
|
|
181
|
+
id_deltas << data[offset, 2].unpack1("n")
|
|
182
|
+
offset += 2
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Read idRangeOffset array
|
|
186
|
+
id_range_offsets = []
|
|
187
|
+
id_range_offset_pos = offset
|
|
188
|
+
seg_count.times do
|
|
189
|
+
break if offset + 2 > length
|
|
190
|
+
|
|
191
|
+
id_range_offsets << data[offset, 2].unpack1("n")
|
|
192
|
+
offset += 2
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Process each segment
|
|
196
|
+
seg_count.times do |i|
|
|
197
|
+
start_code = start_codes[i]
|
|
198
|
+
end_code = end_codes[i]
|
|
199
|
+
id_delta = id_deltas[i]
|
|
200
|
+
id_range_offset = id_range_offsets[i]
|
|
201
|
+
|
|
202
|
+
# Skip the final segment (0xFFFF)
|
|
203
|
+
next if start_code == 0xFFFF
|
|
204
|
+
|
|
205
|
+
if id_range_offset.zero?
|
|
206
|
+
# Use idDelta directly
|
|
207
|
+
(start_code..end_code).each do |code|
|
|
208
|
+
glyph_index = (code + id_delta) & 0xFFFF
|
|
209
|
+
mappings[code] = glyph_index if glyph_index != 0
|
|
210
|
+
end
|
|
211
|
+
else
|
|
212
|
+
# Use glyphIdArray
|
|
213
|
+
(start_code..end_code).each do |code|
|
|
214
|
+
# Calculate position in glyphIdArray
|
|
215
|
+
array_offset = id_range_offset_pos + (i * 2) + id_range_offset
|
|
216
|
+
array_offset += (code - start_code) * 2
|
|
217
|
+
|
|
218
|
+
next if array_offset + 2 > length
|
|
219
|
+
|
|
220
|
+
glyph_index = data[array_offset, 2].unpack1("n")
|
|
221
|
+
next if glyph_index.zero?
|
|
222
|
+
|
|
223
|
+
glyph_index = (glyph_index + id_delta) & 0xFFFF
|
|
224
|
+
mappings[code] = glyph_index if glyph_index != 0
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
# rubocop:enable Metrics/MethodLength
|
|
230
|
+
# rubocop:enable Metrics/CyclomaticComplexity
|
|
231
|
+
# rubocop:enable Metrics/PerceivedComplexity
|
|
232
|
+
|
|
233
|
+
# Parse Format 12 subtable (segmented coverage)
|
|
234
|
+
# Format 12 supports full Unicode range
|
|
235
|
+
def parse_format_12(data, mappings)
|
|
236
|
+
header = parse_format_12_header(data)
|
|
237
|
+
return unless header
|
|
238
|
+
|
|
239
|
+
parse_format_12_groups(data, header[:num_groups], header[:length],
|
|
240
|
+
mappings)
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# Parse Format 12 header
|
|
244
|
+
def parse_format_12_header(data)
|
|
245
|
+
return nil if data.length < 16
|
|
246
|
+
|
|
247
|
+
format = data[0, 2].unpack1("n")
|
|
248
|
+
return nil unless format == 12
|
|
249
|
+
|
|
250
|
+
length = data[4, 4].unpack1("N")
|
|
251
|
+
return nil if length > data.length
|
|
252
|
+
|
|
253
|
+
num_groups = data[12, 4].unpack1("N")
|
|
254
|
+
|
|
255
|
+
{ length: length, num_groups: num_groups }
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# Parse Format 12 sequential map groups
|
|
259
|
+
def parse_format_12_groups(data, num_groups, length, mappings)
|
|
260
|
+
offset = 16
|
|
261
|
+
num_groups.times do
|
|
262
|
+
break if offset + 12 > length
|
|
263
|
+
|
|
264
|
+
start_char_code = data[offset, 4].unpack1("N")
|
|
265
|
+
end_char_code = data[offset + 4, 4].unpack1("N")
|
|
266
|
+
start_glyph_id = data[offset + 8, 4].unpack1("N")
|
|
267
|
+
|
|
268
|
+
map_character_range(start_char_code, end_char_code, start_glyph_id,
|
|
269
|
+
mappings)
|
|
270
|
+
|
|
271
|
+
offset += 12
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# Map a range of characters to glyphs
|
|
276
|
+
def map_character_range(start_char, end_char, start_glyph, mappings)
|
|
277
|
+
(start_char..end_char).each do |code|
|
|
278
|
+
glyph_index = start_glyph + (code - start_char)
|
|
279
|
+
mappings[code] = glyph_index if glyph_index != 0
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
end
|