fontisan 0.2.7 → 0.2.9
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.yml +103 -0
- data/.rubocop_todo.yml +65 -361
- data/CHANGELOG.md +116 -0
- data/Gemfile +1 -1
- data/README.adoc +106 -27
- data/Rakefile +12 -7
- data/benchmark/variation_quick_bench.rb +1 -1
- data/docs/APPLE_LEGACY_FONTS.adoc +173 -0
- data/docs/COLLECTION_VALIDATION.adoc +143 -0
- data/docs/COLOR_FONTS.adoc +127 -0
- data/docs/DOCUMENTATION_SUMMARY.md +141 -0
- data/docs/FONT_HINTING.adoc +9 -1
- data/docs/VALIDATION.adoc +254 -0
- data/docs/WOFF_WOFF2_FORMATS.adoc +94 -0
- data/lib/fontisan/cli.rb +45 -13
- data/lib/fontisan/collection/dfont_builder.rb +2 -1
- data/lib/fontisan/commands/convert_command.rb +2 -4
- data/lib/fontisan/commands/info_command.rb +3 -3
- data/lib/fontisan/commands/pack_command.rb +2 -1
- data/lib/fontisan/commands/validate_command.rb +157 -6
- data/lib/fontisan/converters/collection_converter.rb +22 -13
- data/lib/fontisan/converters/svg_generator.rb +2 -1
- data/lib/fontisan/converters/woff2_encoder.rb +6 -6
- data/lib/fontisan/converters/woff_writer.rb +3 -1
- data/lib/fontisan/font_loader.rb +7 -6
- data/lib/fontisan/formatters/text_formatter.rb +18 -14
- data/lib/fontisan/hints/hint_converter.rb +1 -1
- data/lib/fontisan/hints/hint_validator.rb +13 -10
- data/lib/fontisan/hints/truetype_instruction_analyzer.rb +15 -8
- data/lib/fontisan/hints/truetype_instruction_generator.rb +1 -1
- data/lib/fontisan/models/collection_validation_report.rb +104 -0
- data/lib/fontisan/models/font_report.rb +24 -0
- data/lib/fontisan/models/validation_report.rb +7 -2
- data/lib/fontisan/open_type_font.rb +18 -425
- data/lib/fontisan/optimizers/charstring_rewriter.rb +1 -1
- data/lib/fontisan/optimizers/subroutine_optimizer.rb +6 -2
- data/lib/fontisan/sfnt_font.rb +699 -0
- data/lib/fontisan/sfnt_table.rb +264 -0
- data/lib/fontisan/subset/glyph_mapping.rb +2 -0
- data/lib/fontisan/subset/table_subsetter.rb +2 -2
- data/lib/fontisan/tables/cblc.rb +8 -4
- data/lib/fontisan/tables/cff/index.rb +2 -0
- data/lib/fontisan/tables/cff.rb +6 -3
- data/lib/fontisan/tables/cff2/private_dict_blend_handler.rb +1 -1
- data/lib/fontisan/tables/cff2.rb +1 -1
- data/lib/fontisan/tables/cmap.rb +5 -5
- data/lib/fontisan/tables/cmap_table.rb +231 -0
- data/lib/fontisan/tables/glyf.rb +8 -10
- data/lib/fontisan/tables/glyf_table.rb +255 -0
- data/lib/fontisan/tables/head.rb +3 -3
- data/lib/fontisan/tables/head_table.rb +111 -0
- data/lib/fontisan/tables/hhea.rb +4 -4
- data/lib/fontisan/tables/hhea_table.rb +255 -0
- data/lib/fontisan/tables/hmtx_table.rb +191 -0
- data/lib/fontisan/tables/loca_table.rb +212 -0
- data/lib/fontisan/tables/maxp.rb +2 -2
- data/lib/fontisan/tables/maxp_table.rb +258 -0
- data/lib/fontisan/tables/name.rb +1 -1
- data/lib/fontisan/tables/name_table.rb +176 -0
- data/lib/fontisan/tables/os2.rb +8 -8
- data/lib/fontisan/tables/os2_table.rb +329 -0
- data/lib/fontisan/tables/post.rb +2 -2
- data/lib/fontisan/tables/post_table.rb +183 -0
- data/lib/fontisan/tables/sbix.rb +5 -4
- data/lib/fontisan/true_type_font.rb +12 -464
- data/lib/fontisan/utilities/checksum_calculator.rb +0 -44
- data/lib/fontisan/validation/collection_validator.rb +4 -2
- data/lib/fontisan/validators/basic_validator.rb +11 -21
- data/lib/fontisan/validators/font_book_validator.rb +29 -50
- data/lib/fontisan/validators/opentype_validator.rb +24 -28
- data/lib/fontisan/validators/validator.rb +87 -66
- data/lib/fontisan/validators/web_font_validator.rb +16 -21
- data/lib/fontisan/version.rb +1 -1
- data/lib/fontisan/woff2/glyf_transformer.rb +31 -8
- data/lib/fontisan/woff2/hmtx_transformer.rb +2 -1
- data/lib/fontisan/woff2/table_transformer.rb +4 -2
- data/lib/fontisan/woff2_font.rb +4 -2
- data/lib/fontisan/woff_font.rb +46 -30
- data/lib/fontisan.rb +2 -2
- data/scripts/compare_stack_aware.rb +1 -1
- data/scripts/measure_optimization.rb +1 -2
- metadata +23 -2
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "constants"
|
|
4
|
+
require_relative "loading_modes"
|
|
5
|
+
|
|
6
|
+
module Fontisan
|
|
7
|
+
# Base class for SFNT font tables
|
|
8
|
+
#
|
|
9
|
+
# Represents a single table in an SFNT font file, encapsulating:
|
|
10
|
+
# - Table metadata (tag, checksum, offset, length)
|
|
11
|
+
# - Lazy loading of table data
|
|
12
|
+
# - Parsing of table data into structured objects
|
|
13
|
+
# - Table-specific validation
|
|
14
|
+
#
|
|
15
|
+
# This class provides an OOP representation of font tables, replacing
|
|
16
|
+
# the previous separation of TableDirectory (metadata), @table_data (raw bytes),
|
|
17
|
+
# and @parsed_tables (parsed objects) with a single cohesive domain object.
|
|
18
|
+
#
|
|
19
|
+
# @abstract Subclasses should override `parser_class` and `validate_parsed_table?`
|
|
20
|
+
#
|
|
21
|
+
# @example Accessing table metadata
|
|
22
|
+
# table = SfntTable.new(font, entry)
|
|
23
|
+
# puts table.tag # => "head"
|
|
24
|
+
# puts table.checksum # => 0x12345678
|
|
25
|
+
# puts table.offset # => 0x0000012C
|
|
26
|
+
# puts table.length # => 54
|
|
27
|
+
#
|
|
28
|
+
# @example Lazy loading table data
|
|
29
|
+
# table.load_data! # Loads raw bytes from IO
|
|
30
|
+
# puts table.data.bytesize
|
|
31
|
+
#
|
|
32
|
+
# @example Parsing table data
|
|
33
|
+
# head_table = table.parse
|
|
34
|
+
# puts head_table.units_per_em
|
|
35
|
+
#
|
|
36
|
+
# @example Validating table
|
|
37
|
+
# table.validate! # Raises InvalidFontError if invalid
|
|
38
|
+
class SfntTable
|
|
39
|
+
# Table metadata entry (from TableDirectory)
|
|
40
|
+
#
|
|
41
|
+
# @return [TableDirectory] The table directory entry
|
|
42
|
+
attr_reader :entry
|
|
43
|
+
|
|
44
|
+
# Parent font containing this table
|
|
45
|
+
#
|
|
46
|
+
# @return [SfntFont] The font that contains this table
|
|
47
|
+
attr_reader :font
|
|
48
|
+
|
|
49
|
+
# Raw table data (loaded lazily)
|
|
50
|
+
#
|
|
51
|
+
# @return [String, nil] Raw binary table data, or nil if not loaded
|
|
52
|
+
attr_reader :data
|
|
53
|
+
|
|
54
|
+
# Parsed table object (cached)
|
|
55
|
+
#
|
|
56
|
+
# @return [Object, nil] Parsed table object, or nil if not parsed
|
|
57
|
+
attr_reader :parsed
|
|
58
|
+
|
|
59
|
+
# Table tag (4-character string)
|
|
60
|
+
#
|
|
61
|
+
# @return [String] The table tag (e.g., "head", "name", "cmap")
|
|
62
|
+
def tag
|
|
63
|
+
@entry.tag
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Table checksum
|
|
67
|
+
#
|
|
68
|
+
# @return [Integer] The table checksum
|
|
69
|
+
def checksum
|
|
70
|
+
@entry.checksum
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Table offset in font file
|
|
74
|
+
#
|
|
75
|
+
# @return [Integer] Byte offset of table data
|
|
76
|
+
def offset
|
|
77
|
+
@entry.offset
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Table length in bytes
|
|
81
|
+
#
|
|
82
|
+
# @return [Integer] Table data length in bytes
|
|
83
|
+
def length
|
|
84
|
+
@entry.table_length
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Initialize a new SfntTable
|
|
88
|
+
#
|
|
89
|
+
# @param font [SfntFont] The font containing this table
|
|
90
|
+
# @param entry [TableDirectory] The table directory entry
|
|
91
|
+
def initialize(font, entry)
|
|
92
|
+
@font = font
|
|
93
|
+
@entry = entry
|
|
94
|
+
@data = nil
|
|
95
|
+
@parsed = nil
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Load raw table data from font file
|
|
99
|
+
#
|
|
100
|
+
# Reads the table data from the font's IO source or from cached
|
|
101
|
+
# table data. This method supports lazy loading.
|
|
102
|
+
#
|
|
103
|
+
# @return [self] Returns self for chaining
|
|
104
|
+
# @raise [RuntimeError] if table data cannot be loaded
|
|
105
|
+
def load_data!
|
|
106
|
+
# Check if already loaded
|
|
107
|
+
return self if @data
|
|
108
|
+
|
|
109
|
+
# Try to get from font's table_data cache
|
|
110
|
+
if @font.table_data && @font.table_data[tag]
|
|
111
|
+
@data = @font.table_data[tag]
|
|
112
|
+
return self
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Load from IO source if available
|
|
116
|
+
if @font.io_source
|
|
117
|
+
@font.io_source.seek(offset)
|
|
118
|
+
@data = @font.io_source.read(length)
|
|
119
|
+
return self
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
raise "Cannot load table '#{tag}': no IO source or cached data"
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Check if table data is loaded
|
|
126
|
+
#
|
|
127
|
+
# @return [Boolean] true if table data has been loaded
|
|
128
|
+
def data_loaded?
|
|
129
|
+
!@data.nil?
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Check if table has been parsed
|
|
133
|
+
#
|
|
134
|
+
# @return [Boolean] true if table has been parsed
|
|
135
|
+
def parsed?
|
|
136
|
+
!@parsed.nil?
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Parse table data into structured object
|
|
140
|
+
#
|
|
141
|
+
# Loads data if needed, then parses using the table-specific parser class.
|
|
142
|
+
# Results are cached for subsequent calls.
|
|
143
|
+
#
|
|
144
|
+
# @return [Object, nil] Parsed table object, or nil if no parser available
|
|
145
|
+
# @raise [RuntimeError] if table data cannot be loaded for parsing
|
|
146
|
+
def parse
|
|
147
|
+
return @parsed if parsed?
|
|
148
|
+
|
|
149
|
+
# Load data if not already loaded
|
|
150
|
+
load_data! unless data_loaded?
|
|
151
|
+
|
|
152
|
+
# Get parser class for this table type
|
|
153
|
+
parser = parser_class
|
|
154
|
+
return nil unless parser
|
|
155
|
+
|
|
156
|
+
# Parse and cache
|
|
157
|
+
@parsed = parser.read(@data)
|
|
158
|
+
@parsed
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Validate the table
|
|
162
|
+
#
|
|
163
|
+
# Performs table-specific validation. Subclasses should override
|
|
164
|
+
# `validate_parsed_table?` to provide custom validation logic.
|
|
165
|
+
#
|
|
166
|
+
# @return [Boolean] true if table is valid
|
|
167
|
+
# @raise [Fontisan::InvalidFontError] if table is invalid
|
|
168
|
+
def validate!
|
|
169
|
+
# Ensure data is loaded
|
|
170
|
+
load_data! unless data_loaded?
|
|
171
|
+
|
|
172
|
+
# Basic validation: data size matches expected size
|
|
173
|
+
if @data.bytesize != length
|
|
174
|
+
raise InvalidFontError,
|
|
175
|
+
"Table '#{tag}' data size mismatch: expected #{length} bytes, got #{@data.bytesize}"
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Validate checksum if not head table (head table checksum is special)
|
|
179
|
+
if tag != Constants::HEAD_TAG
|
|
180
|
+
expected_checksum = calculate_checksum
|
|
181
|
+
if checksum != expected_checksum
|
|
182
|
+
# Checksum mismatch might be OK for some tables, log a warning
|
|
183
|
+
# But don't fail validation for it
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Table-specific validation (if parsed)
|
|
188
|
+
if parsed?
|
|
189
|
+
validate_parsed_table?
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
true
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Calculate table checksum
|
|
196
|
+
#
|
|
197
|
+
# @return [Integer] The checksum of the table data
|
|
198
|
+
def calculate_checksum
|
|
199
|
+
load_data! unless data_loaded?
|
|
200
|
+
|
|
201
|
+
require_relative "utilities/checksum_calculator"
|
|
202
|
+
Utilities::ChecksumCalculator.calculate_table_checksum(@data)
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Check if table is available in current loading mode
|
|
206
|
+
#
|
|
207
|
+
# @return [Boolean] true if table is available
|
|
208
|
+
def available?
|
|
209
|
+
@font.table_available?(tag)
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# Check if table is required for the font
|
|
213
|
+
#
|
|
214
|
+
# @return [Boolean] true if table is required
|
|
215
|
+
def required?
|
|
216
|
+
Constants::REQUIRED_TABLES.include?(tag)
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# Get human-readable table name
|
|
220
|
+
#
|
|
221
|
+
# @return [String] Human-readable name
|
|
222
|
+
def human_name
|
|
223
|
+
Constants::TABLE_NAMES[tag] || tag
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# String representation
|
|
227
|
+
#
|
|
228
|
+
# @return [String] Human-readable representation
|
|
229
|
+
def inspect
|
|
230
|
+
"#<#{self.class.name} tag=#{tag.inspect} offset=0x#{offset.to_s(16).upcase} length=#{length}>"
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# String representation for display
|
|
234
|
+
#
|
|
235
|
+
# @return [String] Human-readable representation
|
|
236
|
+
def to_s
|
|
237
|
+
"#{tag}: #{human_name} (#{length} bytes @ 0x#{offset.to_s(16).upcase})"
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
protected
|
|
241
|
+
|
|
242
|
+
# Get the parser class for this table type
|
|
243
|
+
#
|
|
244
|
+
# Subclasses should override this method to return the appropriate
|
|
245
|
+
# Tables::* class (e.g., Tables::Head, Tables::Name).
|
|
246
|
+
#
|
|
247
|
+
# @return [Class, nil] The parser class, or nil if no parser available
|
|
248
|
+
def parser_class
|
|
249
|
+
# Direct access to TABLE_CLASS_MAP for better performance
|
|
250
|
+
@font.class::TABLE_CLASS_MAP[tag]
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# Validate the parsed table object
|
|
254
|
+
#
|
|
255
|
+
# Subclasses should override this method to provide table-specific
|
|
256
|
+
# validation logic. The default implementation does nothing.
|
|
257
|
+
#
|
|
258
|
+
# @return [Boolean] true if valid
|
|
259
|
+
# @raise [Fontisan::InvalidFontError] if table is invalid
|
|
260
|
+
def validate_parsed_table?
|
|
261
|
+
true
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
end
|
|
@@ -152,7 +152,7 @@ module Fontisan
|
|
|
152
152
|
# Build new hmtx data
|
|
153
153
|
data = String.new(encoding: Encoding::BINARY)
|
|
154
154
|
|
|
155
|
-
mapping.each do |old_id
|
|
155
|
+
mapping.old_ids.each do |old_id|
|
|
156
156
|
metric = table.metric_for(old_id)
|
|
157
157
|
next unless metric
|
|
158
158
|
|
|
@@ -319,7 +319,7 @@ module Fontisan
|
|
|
319
319
|
current_offset = 0
|
|
320
320
|
|
|
321
321
|
# Process glyphs in mapping order
|
|
322
|
-
mapping.each do |old_id
|
|
322
|
+
mapping.old_ids.each do |old_id|
|
|
323
323
|
@loca_offsets << current_offset
|
|
324
324
|
|
|
325
325
|
# Get offset and size from original loca
|
data/lib/fontisan/tables/cblc.rb
CHANGED
|
@@ -86,9 +86,12 @@ module Fontisan
|
|
|
86
86
|
size = new
|
|
87
87
|
|
|
88
88
|
io = StringIO.new(data)
|
|
89
|
-
size.instance_variable_set(:@index_subtable_array_offset,
|
|
90
|
-
|
|
91
|
-
size.instance_variable_set(:@
|
|
89
|
+
size.instance_variable_set(:@index_subtable_array_offset,
|
|
90
|
+
io.read(4).unpack1("N"))
|
|
91
|
+
size.instance_variable_set(:@index_tables_size,
|
|
92
|
+
io.read(4).unpack1("N"))
|
|
93
|
+
size.instance_variable_set(:@number_of_index_subtables,
|
|
94
|
+
io.read(4).unpack1("N"))
|
|
92
95
|
size.instance_variable_set(:@color_ref, io.read(4).unpack1("N"))
|
|
93
96
|
|
|
94
97
|
# Parse hori and vert metrics (12 bytes each)
|
|
@@ -98,7 +101,8 @@ module Fontisan
|
|
|
98
101
|
size.instance_variable_set(:@vert, SbitLineMetrics.read(vert_data))
|
|
99
102
|
|
|
100
103
|
# Parse remaining fields
|
|
101
|
-
size.instance_variable_set(:@start_glyph_index,
|
|
104
|
+
size.instance_variable_set(:@start_glyph_index,
|
|
105
|
+
io.read(2).unpack1("n"))
|
|
102
106
|
size.instance_variable_set(:@end_glyph_index, io.read(2).unpack1("n"))
|
|
103
107
|
size.instance_variable_set(:@ppem_x, io.read(1).unpack1("C"))
|
|
104
108
|
size.instance_variable_set(:@ppem_y, io.read(1).unpack1("C"))
|
data/lib/fontisan/tables/cff.rb
CHANGED
|
@@ -299,7 +299,8 @@ module Fontisan
|
|
|
299
299
|
io.seek(absolute_offset)
|
|
300
300
|
Index.new(io, start_offset: absolute_offset)
|
|
301
301
|
rescue StandardError => e
|
|
302
|
-
raise CorruptedTableError,
|
|
302
|
+
raise CorruptedTableError,
|
|
303
|
+
"Failed to parse Local Subr INDEX: #{e.message}"
|
|
303
304
|
end
|
|
304
305
|
|
|
305
306
|
# Get the CharStrings INDEX for a specific font
|
|
@@ -320,7 +321,8 @@ module Fontisan
|
|
|
320
321
|
io.seek(charstrings_offset)
|
|
321
322
|
CharstringsIndex.new(io, start_offset: charstrings_offset)
|
|
322
323
|
rescue StandardError => e
|
|
323
|
-
raise CorruptedTableError,
|
|
324
|
+
raise CorruptedTableError,
|
|
325
|
+
"Failed to parse CharStrings INDEX: #{e.message}"
|
|
324
326
|
end
|
|
325
327
|
|
|
326
328
|
# Get a CharString for a specific glyph
|
|
@@ -355,7 +357,8 @@ module Fontisan
|
|
|
355
357
|
local_subr_index,
|
|
356
358
|
)
|
|
357
359
|
rescue StandardError => e
|
|
358
|
-
raise CorruptedTableError,
|
|
360
|
+
raise CorruptedTableError,
|
|
361
|
+
"Failed to get CharString for glyph #{glyph_index}: #{e.message}"
|
|
359
362
|
end
|
|
360
363
|
|
|
361
364
|
# Get the number of glyphs in a font
|
|
@@ -51,7 +51,7 @@ module Fontisan
|
|
|
51
51
|
# Check if this is blend data
|
|
52
52
|
# Format: base1 delta1_1 ... delta1_N base2 delta2_1 ... delta2_N ...
|
|
53
53
|
# The array must be divisible by (num_axes + 1)
|
|
54
|
-
return nil unless value.size % (num_axes + 1)
|
|
54
|
+
return nil unless (value.size % (num_axes + 1)).zero?
|
|
55
55
|
|
|
56
56
|
num_values = value.size / (num_axes + 1)
|
|
57
57
|
blends = []
|
data/lib/fontisan/tables/cff2.rb
CHANGED
data/lib/fontisan/tables/cmap.rb
CHANGED
|
@@ -278,22 +278,20 @@ module Fontisan
|
|
|
278
278
|
end
|
|
279
279
|
end
|
|
280
280
|
|
|
281
|
-
public
|
|
282
|
-
|
|
283
281
|
# Validation helper: Check if version is valid
|
|
284
282
|
#
|
|
285
283
|
# cmap version should be 0
|
|
286
284
|
#
|
|
287
285
|
# @return [Boolean] True if version is 0
|
|
288
286
|
def valid_version?
|
|
289
|
-
version
|
|
287
|
+
version.zero?
|
|
290
288
|
end
|
|
291
289
|
|
|
292
290
|
# Validation helper: Check if at least one subtable exists
|
|
293
291
|
#
|
|
294
292
|
# @return [Boolean] True if num_tables > 0
|
|
295
293
|
def has_subtables?
|
|
296
|
-
num_tables
|
|
294
|
+
num_tables&.positive?
|
|
297
295
|
end
|
|
298
296
|
|
|
299
297
|
# Validation helper: Check if Unicode mapping exists
|
|
@@ -357,7 +355,9 @@ module Fontisan
|
|
|
357
355
|
mappings = unicode_mappings
|
|
358
356
|
return true if mappings.nil? || mappings.empty?
|
|
359
357
|
|
|
360
|
-
mappings.values.all?
|
|
358
|
+
mappings.values.all? do |glyph_id|
|
|
359
|
+
glyph_id >= 0 && glyph_id < max_glyph_id
|
|
360
|
+
end
|
|
361
361
|
end
|
|
362
362
|
end
|
|
363
363
|
end
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../sfnt_table"
|
|
4
|
+
require_relative "cmap"
|
|
5
|
+
|
|
6
|
+
module Fontisan
|
|
7
|
+
module Tables
|
|
8
|
+
# OOP representation of the 'cmap' (Character to Glyph Index Mapping) table
|
|
9
|
+
#
|
|
10
|
+
# The cmap table maps character codes to glyph indices, supporting multiple
|
|
11
|
+
# encoding formats for different character sets and Unicode planes.
|
|
12
|
+
#
|
|
13
|
+
# This class extends SfntTable to provide cmap-specific validation and
|
|
14
|
+
# convenience methods for character-to-glyph mapping.
|
|
15
|
+
#
|
|
16
|
+
# @example Mapping characters to glyphs
|
|
17
|
+
# cmap = font.sfnt_table("cmap")
|
|
18
|
+
# cmap.glyph_for('A') # => 36
|
|
19
|
+
# cmap.glyph_for(0x0041) # => 36 (same as 'A')
|
|
20
|
+
# cmap.has_glyph?('€') # => true
|
|
21
|
+
# cmap.character_count # => 1234
|
|
22
|
+
class CmapTable < SfntTable
|
|
23
|
+
# Get Unicode character to glyph index mappings
|
|
24
|
+
#
|
|
25
|
+
# @return [Hash<Integer, Integer>] Mapping from Unicode codepoints to glyph IDs
|
|
26
|
+
def unicode_mappings
|
|
27
|
+
return {} unless parsed
|
|
28
|
+
|
|
29
|
+
parsed.unicode_mappings || {}
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Get glyph ID for a character
|
|
33
|
+
#
|
|
34
|
+
# @param char [String, Integer] Character (string or Unicode codepoint)
|
|
35
|
+
# @return [Integer, nil] Glyph ID, or nil if character not mapped
|
|
36
|
+
def glyph_for(char)
|
|
37
|
+
codepoint = char.is_a?(String) ? char.ord : char
|
|
38
|
+
unicode_mappings[codepoint]
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Check if a character has a glyph mapping
|
|
42
|
+
#
|
|
43
|
+
# @param char [String, Integer] Character (string or Unicode codepoint)
|
|
44
|
+
# @return [Boolean] true if character is mapped to a glyph
|
|
45
|
+
def has_glyph?(char)
|
|
46
|
+
!glyph_for(char).nil?
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Check if multiple characters have glyph mappings
|
|
50
|
+
#
|
|
51
|
+
# @param chars [Array<String, Integer>] Characters to check
|
|
52
|
+
# @return [Boolean] true if all characters are mapped
|
|
53
|
+
def has_glyphs?(*chars)
|
|
54
|
+
chars.all? { |char| has_glyph?(char) }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Get the number of mapped characters
|
|
58
|
+
#
|
|
59
|
+
# @return [Integer] Number of unique character mappings
|
|
60
|
+
def character_count
|
|
61
|
+
unicode_mappings.size
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Get all mapped character codes
|
|
65
|
+
#
|
|
66
|
+
# @return [Array<Integer>] Array of Unicode codepoints
|
|
67
|
+
def character_codes
|
|
68
|
+
unicode_mappings.keys.sort
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Get all mapped glyphs
|
|
72
|
+
#
|
|
73
|
+
# @return [Array<Integer>] Array of glyph IDs
|
|
74
|
+
def glyph_ids
|
|
75
|
+
unicode_mappings.values.uniq.sort
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Check if BMP (Basic Multilingual Plane) coverage exists
|
|
79
|
+
#
|
|
80
|
+
# @return [Boolean] true if BMP characters (U+0000-U+FFFF) are mapped
|
|
81
|
+
def has_bmp_coverage?
|
|
82
|
+
return false unless parsed
|
|
83
|
+
|
|
84
|
+
parsed.has_bmp_coverage?
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Check if specific required characters are mapped
|
|
88
|
+
#
|
|
89
|
+
# @param chars [Array<Integer>] Unicode codepoints that must be present
|
|
90
|
+
# @return [Boolean] true if all required characters are mapped
|
|
91
|
+
def has_required_characters?(*chars)
|
|
92
|
+
return false unless parsed
|
|
93
|
+
|
|
94
|
+
parsed.has_required_characters?(*chars)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Check if space character is mapped
|
|
98
|
+
#
|
|
99
|
+
# @return [Boolean] true if U+0020 (space) is mapped
|
|
100
|
+
def has_space?
|
|
101
|
+
has_glyph?(0x0020)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Check if common Latin characters are mapped
|
|
105
|
+
#
|
|
106
|
+
# @return [Boolean] true if A-Z, a-z are mapped
|
|
107
|
+
def has_basic_latin?
|
|
108
|
+
# Check uppercase A-Z
|
|
109
|
+
return false unless has_glyphs?(*(0x0041..0x005A).to_a)
|
|
110
|
+
|
|
111
|
+
# Check lowercase a-z
|
|
112
|
+
has_glyphs?(*(0x0061..0x007A).to_a)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Check if digits are mapped
|
|
116
|
+
#
|
|
117
|
+
# @return [Boolean] true if 0-9 are mapped
|
|
118
|
+
def has_digits?
|
|
119
|
+
has_glyphs?(*(0x0030..0x0039).to_a)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Check if common punctuation is mapped
|
|
123
|
+
#
|
|
124
|
+
# @return [Boolean] true if common punctuation marks are mapped
|
|
125
|
+
def has_basic_punctuation?
|
|
126
|
+
required = [0x0020, 0x0021, 0x0022, 0x0027, 0x0028, 0x0029, 0x002C, 0x002E,
|
|
127
|
+
0x003A, 0x003B, 0x003F, 0x005F] # space !"()',.:;?_
|
|
128
|
+
has_required_characters?(*required)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Get glyph IDs for a string of characters
|
|
132
|
+
#
|
|
133
|
+
# @param text [String] Text string
|
|
134
|
+
# @return [Array<Integer>] Array of glyph IDs
|
|
135
|
+
def glyphs_for_text(text)
|
|
136
|
+
text.chars.map { |char| glyph_for(char) || 0 }
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Create a simple text rendering glyph sequence
|
|
140
|
+
#
|
|
141
|
+
# @param text [String] Text string
|
|
142
|
+
# @return [Array<Integer>] Array of glyph IDs for rendering
|
|
143
|
+
def glyph_sequence_for(text)
|
|
144
|
+
glyphs_for_text(text)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Get the highest Unicode codepoint mapped
|
|
148
|
+
#
|
|
149
|
+
# @return [Integer, nil] Maximum codepoint, or nil if no mappings
|
|
150
|
+
def max_codepoint
|
|
151
|
+
codes = character_codes
|
|
152
|
+
codes.last unless codes.empty?
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Get the lowest Unicode codepoint mapped
|
|
156
|
+
#
|
|
157
|
+
# @return [Integer, nil] Minimum codepoint, or nil if no mappings
|
|
158
|
+
def min_codepoint
|
|
159
|
+
codes = character_codes
|
|
160
|
+
codes.first unless codes.empty?
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Check if font has full Unicode coverage
|
|
164
|
+
#
|
|
165
|
+
# @return [Boolean] true if characters beyond BMP are mapped
|
|
166
|
+
def has_full_unicode?
|
|
167
|
+
max_cp = max_codepoint
|
|
168
|
+
!max_cp.nil? && max_cp > 0xFFFF
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Get mapping statistics
|
|
172
|
+
#
|
|
173
|
+
# @return [Hash] Statistics about the character mapping
|
|
174
|
+
def statistics
|
|
175
|
+
{
|
|
176
|
+
character_count: character_count,
|
|
177
|
+
glyph_count: glyph_ids.size,
|
|
178
|
+
min_codepoint: min_codepoint,
|
|
179
|
+
max_codepoint: max_codepoint,
|
|
180
|
+
has_bmp: has_bmp_coverage?,
|
|
181
|
+
has_full_unicode: has_full_unicode?,
|
|
182
|
+
has_space: has_space?,
|
|
183
|
+
has_basic_latin: has_basic_latin?,
|
|
184
|
+
has_digits: has_digits?,
|
|
185
|
+
}
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
protected
|
|
189
|
+
|
|
190
|
+
# Validate the parsed cmap table
|
|
191
|
+
#
|
|
192
|
+
# @return [Boolean] true if valid
|
|
193
|
+
# @raise [InvalidFontError] if cmap table is invalid
|
|
194
|
+
def validate_parsed_table?
|
|
195
|
+
return true unless parsed
|
|
196
|
+
|
|
197
|
+
# Validate version
|
|
198
|
+
unless parsed.valid_version?
|
|
199
|
+
raise InvalidFontError,
|
|
200
|
+
"Invalid cmap table version: #{parsed.version} (must be 0)"
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Validate subtables exist
|
|
204
|
+
unless parsed.has_subtables?
|
|
205
|
+
raise InvalidFontError,
|
|
206
|
+
"Invalid cmap table: no subtables found (num_tables=#{parsed.num_tables})"
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# Validate Unicode mapping exists
|
|
210
|
+
unless parsed.has_unicode_mapping?
|
|
211
|
+
raise InvalidFontError,
|
|
212
|
+
"Invalid cmap table: no Unicode mappings found"
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# Validate BMP coverage (required for fonts)
|
|
216
|
+
unless parsed.has_bmp_coverage?
|
|
217
|
+
raise InvalidFontError,
|
|
218
|
+
"Invalid cmap table: no BMP character coverage found"
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# Validate required characters (space at minimum)
|
|
222
|
+
unless parsed.has_required_characters?(0x0020)
|
|
223
|
+
raise InvalidFontError,
|
|
224
|
+
"Invalid cmap table: missing required character U+0020 (space)"
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
true
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
end
|
data/lib/fontisan/tables/glyf.rb
CHANGED
|
@@ -166,7 +166,7 @@ module Fontisan
|
|
|
166
166
|
# @param head [Head] Head table for context
|
|
167
167
|
# @param num_glyphs [Integer] Total number of glyphs to check
|
|
168
168
|
# @return [Boolean] True if all non-special glyphs have contours
|
|
169
|
-
def no_empty_glyphs_except_special?(loca,
|
|
169
|
+
def no_empty_glyphs_except_special?(loca, _head, num_glyphs)
|
|
170
170
|
# Check glyphs 1 through num_glyphs-1 (.notdef at 0 can be empty)
|
|
171
171
|
(1...num_glyphs).all? do |glyph_id|
|
|
172
172
|
size = loca.size_of(glyph_id)
|
|
@@ -194,7 +194,7 @@ module Fontisan
|
|
|
194
194
|
|
|
195
195
|
(0...num_glyphs).all? do |glyph_id|
|
|
196
196
|
glyph = glyph_for(glyph_id, loca, head)
|
|
197
|
-
next true if glyph.nil?
|
|
197
|
+
next true if glyph.nil? # Empty glyphs are OK
|
|
198
198
|
|
|
199
199
|
# Check if glyph bounds are within font bounds
|
|
200
200
|
glyph.x_min >= font_x_min &&
|
|
@@ -218,7 +218,7 @@ module Fontisan
|
|
|
218
218
|
def instructions_sound?(loca, head, num_glyphs)
|
|
219
219
|
(0...num_glyphs).all? do |glyph_id|
|
|
220
220
|
glyph = glyph_for(glyph_id, loca, head)
|
|
221
|
-
next true if glyph.nil?
|
|
221
|
+
next true if glyph.nil? # Empty glyphs are OK
|
|
222
222
|
|
|
223
223
|
# Simple glyphs have instructions
|
|
224
224
|
if glyph.respond_to?(:instruction_length)
|
|
@@ -242,7 +242,7 @@ module Fontisan
|
|
|
242
242
|
# @return [Boolean] True if contour count is valid
|
|
243
243
|
def valid_contour_count?(glyph_id, loca, head)
|
|
244
244
|
glyph = glyph_for(glyph_id, loca, head)
|
|
245
|
-
return true if glyph.nil?
|
|
245
|
+
return true if glyph.nil? # Empty glyphs are OK
|
|
246
246
|
|
|
247
247
|
# Simple glyphs: contours should be >= 0
|
|
248
248
|
# Compound glyphs: numberOfContours = -1
|
|
@@ -265,12 +265,10 @@ module Fontisan
|
|
|
265
265
|
# @return [Boolean] True if all glyphs can be accessed
|
|
266
266
|
def all_glyphs_accessible?(loca, head, num_glyphs)
|
|
267
267
|
(0...num_glyphs).all? do |glyph_id|
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
false
|
|
273
|
-
end
|
|
268
|
+
glyph_for(glyph_id, loca, head)
|
|
269
|
+
true
|
|
270
|
+
rescue Fontisan::CorruptedTableError
|
|
271
|
+
false
|
|
274
272
|
end
|
|
275
273
|
rescue StandardError
|
|
276
274
|
false
|