fontisan 0.2.0 → 0.2.1
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 +270 -131
- data/README.adoc +158 -4
- data/Rakefile +44 -47
- data/lib/fontisan/cli.rb +84 -33
- data/lib/fontisan/collection/builder.rb +81 -0
- data/lib/fontisan/collection/table_deduplicator.rb +76 -0
- data/lib/fontisan/commands/base_command.rb +16 -0
- data/lib/fontisan/commands/convert_command.rb +97 -170
- data/lib/fontisan/commands/instance_command.rb +71 -80
- data/lib/fontisan/commands/validate_command.rb +25 -0
- data/lib/fontisan/config/validation_rules.yml +1 -1
- data/lib/fontisan/constants.rb +10 -0
- data/lib/fontisan/converters/format_converter.rb +150 -1
- data/lib/fontisan/converters/outline_converter.rb +80 -18
- data/lib/fontisan/converters/woff_writer.rb +1 -1
- data/lib/fontisan/font_loader.rb +3 -5
- data/lib/fontisan/font_writer.rb +7 -6
- data/lib/fontisan/hints/hint_converter.rb +133 -0
- data/lib/fontisan/hints/postscript_hint_applier.rb +221 -140
- data/lib/fontisan/hints/postscript_hint_extractor.rb +100 -0
- data/lib/fontisan/hints/truetype_hint_applier.rb +90 -44
- data/lib/fontisan/hints/truetype_hint_extractor.rb +127 -0
- data/lib/fontisan/loading_modes.rb +2 -0
- data/lib/fontisan/models/font_export.rb +2 -2
- data/lib/fontisan/models/hint.rb +173 -1
- data/lib/fontisan/models/validation_report.rb +1 -1
- data/lib/fontisan/open_type_font.rb +25 -9
- data/lib/fontisan/open_type_font_extensions.rb +54 -0
- data/lib/fontisan/pipeline/format_detector.rb +249 -0
- data/lib/fontisan/pipeline/output_writer.rb +154 -0
- data/lib/fontisan/pipeline/strategies/base_strategy.rb +75 -0
- data/lib/fontisan/pipeline/strategies/instance_strategy.rb +93 -0
- data/lib/fontisan/pipeline/strategies/named_strategy.rb +118 -0
- data/lib/fontisan/pipeline/strategies/preserve_strategy.rb +56 -0
- data/lib/fontisan/pipeline/transformation_pipeline.rb +411 -0
- data/lib/fontisan/pipeline/variation_resolver.rb +165 -0
- data/lib/fontisan/tables/cff/charstring.rb +33 -4
- data/lib/fontisan/tables/cff/charstring_builder.rb +34 -0
- data/lib/fontisan/tables/cff/charstring_parser.rb +237 -0
- data/lib/fontisan/tables/cff/charstring_rebuilder.rb +172 -0
- data/lib/fontisan/tables/cff/dict_builder.rb +15 -0
- data/lib/fontisan/tables/cff/hint_operation_injector.rb +207 -0
- data/lib/fontisan/tables/cff/offset_recalculator.rb +70 -0
- data/lib/fontisan/tables/cff/private_dict_writer.rb +125 -0
- data/lib/fontisan/tables/cff/table_builder.rb +221 -0
- data/lib/fontisan/tables/cff.rb +2 -0
- data/lib/fontisan/tables/cff2/private_dict_blend_handler.rb +246 -0
- data/lib/fontisan/tables/cff2/region_matcher.rb +200 -0
- data/lib/fontisan/tables/cff2/table_builder.rb +574 -0
- data/lib/fontisan/tables/cff2/table_reader.rb +419 -0
- data/lib/fontisan/tables/cff2/variation_data_extractor.rb +212 -0
- data/lib/fontisan/tables/cff2.rb +9 -4
- data/lib/fontisan/tables/cvar.rb +2 -41
- data/lib/fontisan/tables/gvar.rb +2 -41
- data/lib/fontisan/true_type_font.rb +24 -9
- data/lib/fontisan/true_type_font_extensions.rb +54 -0
- data/lib/fontisan/utilities/checksum_calculator.rb +42 -0
- data/lib/fontisan/validation/checksum_validator.rb +2 -2
- data/lib/fontisan/validation/table_validator.rb +1 -1
- data/lib/fontisan/validation/variable_font_validator.rb +218 -0
- data/lib/fontisan/variation/converter.rb +120 -13
- data/lib/fontisan/variation/instance_writer.rb +341 -0
- data/lib/fontisan/variation/tuple_variation_header.rb +51 -0
- data/lib/fontisan/variation/variable_svg_generator.rb +268 -0
- data/lib/fontisan/variation/variation_preserver.rb +288 -0
- data/lib/fontisan/version.rb +1 -1
- data/lib/fontisan/version.rb.orig +9 -0
- data/lib/fontisan/woff2/glyf_transformer.rb +666 -0
- data/lib/fontisan/woff2/hmtx_transformer.rb +164 -0
- data/lib/fontisan/woff2_font.rb +475 -470
- data/lib/fontisan/woff_font.rb +16 -11
- data/lib/fontisan.rb +12 -0
- metadata +31 -2
data/lib/fontisan/tables/cvar.rb
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "stringio"
|
|
4
4
|
require_relative "../binary/base_record"
|
|
5
|
+
require_relative "../variation/tuple_variation_header"
|
|
5
6
|
|
|
6
7
|
module Fontisan
|
|
7
8
|
module Tables
|
|
@@ -26,46 +27,6 @@ module Fontisan
|
|
|
26
27
|
uint16 :tuple_variation_count
|
|
27
28
|
uint16 :data_offset
|
|
28
29
|
|
|
29
|
-
# Tuple variation header
|
|
30
|
-
class TupleVariationHeader < Binary::BaseRecord
|
|
31
|
-
uint16 :variation_data_size
|
|
32
|
-
uint16 :tuple_index
|
|
33
|
-
|
|
34
|
-
# Tuple index flags
|
|
35
|
-
EMBEDDED_PEAK_TUPLE = 0x8000
|
|
36
|
-
INTERMEDIATE_REGION = 0x4000
|
|
37
|
-
PRIVATE_POINT_NUMBERS = 0x2000
|
|
38
|
-
TUPLE_INDEX_MASK = 0x0FFF
|
|
39
|
-
|
|
40
|
-
# Check if tuple has embedded peak coordinates
|
|
41
|
-
#
|
|
42
|
-
# @return [Boolean] True if embedded
|
|
43
|
-
def embedded_peak_tuple?
|
|
44
|
-
(tuple_index & EMBEDDED_PEAK_TUPLE) != 0
|
|
45
|
-
end
|
|
46
|
-
|
|
47
|
-
# Check if tuple has intermediate region
|
|
48
|
-
#
|
|
49
|
-
# @return [Boolean] True if intermediate region
|
|
50
|
-
def intermediate_region?
|
|
51
|
-
(tuple_index & INTERMEDIATE_REGION) != 0
|
|
52
|
-
end
|
|
53
|
-
|
|
54
|
-
# Check if tuple has private point numbers
|
|
55
|
-
#
|
|
56
|
-
# @return [Boolean] True if private points
|
|
57
|
-
def private_point_numbers?
|
|
58
|
-
(tuple_index & PRIVATE_POINT_NUMBERS) != 0
|
|
59
|
-
end
|
|
60
|
-
|
|
61
|
-
# Get shared tuple index
|
|
62
|
-
#
|
|
63
|
-
# @return [Integer] Tuple index
|
|
64
|
-
def shared_tuple_index
|
|
65
|
-
tuple_index & TUPLE_INDEX_MASK
|
|
66
|
-
end
|
|
67
|
-
end
|
|
68
|
-
|
|
69
30
|
# Get version as a float
|
|
70
31
|
#
|
|
71
32
|
# @return [Float] Version number (e.g., 1.0)
|
|
@@ -109,7 +70,7 @@ module Fontisan
|
|
|
109
70
|
break if offset + 4 > data.bytesize
|
|
110
71
|
|
|
111
72
|
header_data = data.byteslice(offset, 4)
|
|
112
|
-
header = TupleVariationHeader.read(header_data)
|
|
73
|
+
header = Variation::TupleVariationHeader.read(header_data)
|
|
113
74
|
offset += 4
|
|
114
75
|
|
|
115
76
|
tuple_info = {
|
data/lib/fontisan/tables/gvar.rb
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "stringio"
|
|
4
4
|
require_relative "../binary/base_record"
|
|
5
|
+
require_relative "../variation/tuple_variation_header"
|
|
5
6
|
|
|
6
7
|
module Fontisan
|
|
7
8
|
module Tables
|
|
@@ -34,46 +35,6 @@ module Fontisan
|
|
|
34
35
|
SHARED_POINT_NUMBERS = 0x8000
|
|
35
36
|
LONG_OFFSETS = 0x0001
|
|
36
37
|
|
|
37
|
-
# Tuple variation header
|
|
38
|
-
class TupleVariationHeader < Binary::BaseRecord
|
|
39
|
-
uint16 :variation_data_size
|
|
40
|
-
uint16 :tuple_index
|
|
41
|
-
|
|
42
|
-
# Tuple index flags
|
|
43
|
-
EMBEDDED_PEAK_TUPLE = 0x8000
|
|
44
|
-
INTERMEDIATE_REGION = 0x4000
|
|
45
|
-
PRIVATE_POINT_NUMBERS = 0x2000
|
|
46
|
-
TUPLE_INDEX_MASK = 0x0FFF
|
|
47
|
-
|
|
48
|
-
# Check if tuple has embedded peak coordinates
|
|
49
|
-
#
|
|
50
|
-
# @return [Boolean] True if embedded
|
|
51
|
-
def embedded_peak_tuple?
|
|
52
|
-
(tuple_index & EMBEDDED_PEAK_TUPLE) != 0
|
|
53
|
-
end
|
|
54
|
-
|
|
55
|
-
# Check if tuple has intermediate region
|
|
56
|
-
#
|
|
57
|
-
# @return [Boolean] True if intermediate region
|
|
58
|
-
def intermediate_region?
|
|
59
|
-
(tuple_index & INTERMEDIATE_REGION) != 0
|
|
60
|
-
end
|
|
61
|
-
|
|
62
|
-
# Check if tuple has private point numbers
|
|
63
|
-
#
|
|
64
|
-
# @return [Boolean] True if private points
|
|
65
|
-
def private_point_numbers?
|
|
66
|
-
(tuple_index & PRIVATE_POINT_NUMBERS) != 0
|
|
67
|
-
end
|
|
68
|
-
|
|
69
|
-
# Get shared tuple index
|
|
70
|
-
#
|
|
71
|
-
# @return [Integer] Tuple index
|
|
72
|
-
def shared_tuple_index
|
|
73
|
-
tuple_index & TUPLE_INDEX_MASK
|
|
74
|
-
end
|
|
75
|
-
end
|
|
76
|
-
|
|
77
38
|
# Get version as a float
|
|
78
39
|
#
|
|
79
40
|
# @return [Float] Version number (e.g., 1.0)
|
|
@@ -198,7 +159,7 @@ module Fontisan
|
|
|
198
159
|
header_data = io.read(4)
|
|
199
160
|
break if header_data.nil? || header_data.bytesize < 4
|
|
200
161
|
|
|
201
|
-
header = TupleVariationHeader.read(header_data)
|
|
162
|
+
header = Variation::TupleVariationHeader.read(header_data)
|
|
202
163
|
|
|
203
164
|
tuple_info = {
|
|
204
165
|
data_size: header.variation_data_size,
|
|
@@ -279,6 +279,20 @@ module Fontisan
|
|
|
279
279
|
true
|
|
280
280
|
end
|
|
281
281
|
|
|
282
|
+
# Check if font is TrueType flavored
|
|
283
|
+
#
|
|
284
|
+
# @return [Boolean] true for TrueType fonts
|
|
285
|
+
def truetype?
|
|
286
|
+
true
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
# Check if font is CFF flavored
|
|
290
|
+
#
|
|
291
|
+
# @return [Boolean] false for TrueType fonts
|
|
292
|
+
def cff?
|
|
293
|
+
false
|
|
294
|
+
end
|
|
295
|
+
|
|
282
296
|
# Check if font has a specific table
|
|
283
297
|
#
|
|
284
298
|
# @param tag [String] The table tag to check for
|
|
@@ -579,18 +593,19 @@ module Fontisan
|
|
|
579
593
|
# @param path [String] Path to the TTF file
|
|
580
594
|
# @return [void]
|
|
581
595
|
def update_checksum_adjustment_in_file(path)
|
|
582
|
-
#
|
|
583
|
-
|
|
596
|
+
# Use tempfile-based checksum calculation for Windows compatibility
|
|
597
|
+
# This keeps the tempfile alive until we're done with the checksum
|
|
598
|
+
File.open(path, "r+b") do |io|
|
|
599
|
+
checksum, _tmpfile = Utilities::ChecksumCalculator.calculate_checksum_from_io_with_tempfile(io)
|
|
584
600
|
|
|
585
|
-
|
|
586
|
-
|
|
601
|
+
# Calculate adjustment
|
|
602
|
+
adjustment = Utilities::ChecksumCalculator.calculate_adjustment(checksum)
|
|
587
603
|
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
604
|
+
# Find head table position
|
|
605
|
+
head_entry = head_table
|
|
606
|
+
return unless head_entry
|
|
591
607
|
|
|
592
|
-
|
|
593
|
-
File.open(path, "r+b") do |io|
|
|
608
|
+
# Write adjustment to head table (offset 8 within head table)
|
|
594
609
|
io.seek(head_entry.offset + 8)
|
|
595
610
|
io.write([adjustment].pack("N"))
|
|
596
611
|
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fontisan
|
|
4
|
+
# Extensions to TrueTypeFont for table-based construction
|
|
5
|
+
class TrueTypeFont
|
|
6
|
+
# Create font from hash of tables
|
|
7
|
+
#
|
|
8
|
+
# This is used during font conversion when we have tables but not a file.
|
|
9
|
+
#
|
|
10
|
+
# @param tables [Hash<String, String>] Map of table tag to binary data
|
|
11
|
+
# @return [TrueTypeFont] New font instance
|
|
12
|
+
def self.from_tables(tables)
|
|
13
|
+
# Create minimal header structure
|
|
14
|
+
font = new
|
|
15
|
+
font.initialize_storage
|
|
16
|
+
font.loading_mode = LoadingModes::FULL
|
|
17
|
+
|
|
18
|
+
# Store table data
|
|
19
|
+
font.table_data = tables
|
|
20
|
+
|
|
21
|
+
# Build header from tables
|
|
22
|
+
num_tables = tables.size
|
|
23
|
+
max_power = 0
|
|
24
|
+
n = num_tables
|
|
25
|
+
while n > 1
|
|
26
|
+
n >>= 1
|
|
27
|
+
max_power += 1
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
search_range = (1 << max_power) * 16
|
|
31
|
+
entry_selector = max_power
|
|
32
|
+
range_shift = (num_tables * 16) - search_range
|
|
33
|
+
|
|
34
|
+
font.header.sfnt_version = 0x00010000 # TrueType
|
|
35
|
+
font.header.num_tables = num_tables
|
|
36
|
+
font.header.search_range = search_range
|
|
37
|
+
font.header.entry_selector = entry_selector
|
|
38
|
+
font.header.range_shift = range_shift
|
|
39
|
+
|
|
40
|
+
# Build table directory
|
|
41
|
+
font.tables.clear
|
|
42
|
+
tables.each_key do |tag|
|
|
43
|
+
entry = TableDirectory.new
|
|
44
|
+
entry.tag = tag
|
|
45
|
+
entry.checksum = 0 # Will be calculated on write
|
|
46
|
+
entry.offset = 0 # Will be calculated on write
|
|
47
|
+
entry.table_length = tables[tag].bytesize
|
|
48
|
+
font.tables << entry
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
font
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "stringio"
|
|
4
|
+
require "tempfile"
|
|
4
5
|
require_relative "../constants"
|
|
5
6
|
|
|
6
7
|
module Fontisan
|
|
@@ -101,6 +102,47 @@ module Fontisan
|
|
|
101
102
|
sum
|
|
102
103
|
end
|
|
103
104
|
|
|
105
|
+
# Calculate checksum from an IO object using a tempfile for Windows compatibility.
|
|
106
|
+
#
|
|
107
|
+
# This method creates a temporary file from the IO content to ensure proper
|
|
108
|
+
# file handle semantics on Windows, where file handles must remain open
|
|
109
|
+
# for checksum calculation. The tempfile reference is returned alongside
|
|
110
|
+
# the checksum to prevent premature garbage collection on Windows.
|
|
111
|
+
#
|
|
112
|
+
# @param io [IO] the IO object to read from (must be rewindable)
|
|
113
|
+
# @return [Array<Integer, Tempfile>] array containing [checksum, tempfile]
|
|
114
|
+
# The checksum value and the tempfile that must be kept alive until
|
|
115
|
+
# the caller is done with the checksum.
|
|
116
|
+
#
|
|
117
|
+
# @example
|
|
118
|
+
# checksum, tmpfile = ChecksumCalculator.calculate_checksum_from_io_with_tempfile(io)
|
|
119
|
+
# # Use checksum...
|
|
120
|
+
# # tmpfile will be GC'd when it goes out of scope, which is safe
|
|
121
|
+
#
|
|
122
|
+
# @note On Windows, Ruby's Tempfile automatically deletes the temp file when
|
|
123
|
+
# the Tempfile object is garbage collected. In multi-threaded environments,
|
|
124
|
+
# this can cause PermissionDenied errors if the file is deleted while
|
|
125
|
+
# another thread is still using it. By returning the tempfile reference,
|
|
126
|
+
# the caller can ensure it remains alive until all operations complete.
|
|
127
|
+
def self.calculate_checksum_from_io_with_tempfile(io)
|
|
128
|
+
io.rewind
|
|
129
|
+
|
|
130
|
+
# Create a tempfile to handle Windows file locking issues
|
|
131
|
+
tmpfile = Tempfile.new(["font", ".ttf"])
|
|
132
|
+
tmpfile.binmode
|
|
133
|
+
|
|
134
|
+
# Copy IO content to tempfile
|
|
135
|
+
IO.copy_stream(io, tmpfile)
|
|
136
|
+
tmpfile.close
|
|
137
|
+
|
|
138
|
+
# Calculate checksum from the tempfile
|
|
139
|
+
checksum = calculate_file_checksum(tmpfile.path)
|
|
140
|
+
|
|
141
|
+
# Return both checksum and tempfile to keep it alive
|
|
142
|
+
# The caller must keep the tempfile reference until done with checksum
|
|
143
|
+
[checksum, tmpfile]
|
|
144
|
+
end
|
|
145
|
+
|
|
104
146
|
private_class_method :calculate_checksum_from_io
|
|
105
147
|
end
|
|
106
148
|
end
|
|
@@ -114,7 +114,7 @@ module Fontisan
|
|
|
114
114
|
skip_tables = @checksum_config["skip_tables"] || []
|
|
115
115
|
|
|
116
116
|
font.tables.each do |table_entry|
|
|
117
|
-
tag = table_entry.tag
|
|
117
|
+
tag = table_entry.tag.to_s # Convert BinData field to string
|
|
118
118
|
|
|
119
119
|
# Skip tables that are exempt from checksum validation
|
|
120
120
|
next if skip_tables.include?(tag)
|
|
@@ -125,7 +125,7 @@ module Fontisan
|
|
|
125
125
|
|
|
126
126
|
# Calculate checksum for the table
|
|
127
127
|
calculated_checksum = calculate_table_checksum(table_data)
|
|
128
|
-
declared_checksum = table_entry.checksum
|
|
128
|
+
declared_checksum = table_entry.checksum.to_i # Convert BinData field to integer
|
|
129
129
|
|
|
130
130
|
# Special handling for head table (checksum adjustment field should be 0)
|
|
131
131
|
if tag == Constants::HEAD_TAG
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fontisan
|
|
4
|
+
module Validation
|
|
5
|
+
# VariableFontValidator validates variable font structure
|
|
6
|
+
#
|
|
7
|
+
# Validates:
|
|
8
|
+
# - fvar table structure
|
|
9
|
+
# - Axis definitions and ranges
|
|
10
|
+
# - Instance definitions
|
|
11
|
+
# - Variation table consistency
|
|
12
|
+
# - Metrics variation tables
|
|
13
|
+
#
|
|
14
|
+
# @example Validate a variable font
|
|
15
|
+
# validator = VariableFontValidator.new(font)
|
|
16
|
+
# errors = validator.validate
|
|
17
|
+
# puts "Found #{errors.length} errors" if errors.any?
|
|
18
|
+
class VariableFontValidator
|
|
19
|
+
# Initialize validator with font
|
|
20
|
+
#
|
|
21
|
+
# @param font [TrueTypeFont, OpenTypeFont] Font to validate
|
|
22
|
+
def initialize(font)
|
|
23
|
+
@font = font
|
|
24
|
+
@errors = []
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Validate variable font
|
|
28
|
+
#
|
|
29
|
+
# @return [Array<String>] Array of error messages
|
|
30
|
+
def validate
|
|
31
|
+
return [] unless @font.has_table?("fvar")
|
|
32
|
+
|
|
33
|
+
validate_fvar_structure
|
|
34
|
+
validate_axes
|
|
35
|
+
validate_instances
|
|
36
|
+
validate_variation_tables
|
|
37
|
+
validate_metrics_variation
|
|
38
|
+
|
|
39
|
+
@errors
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
# Validate fvar table structure
|
|
45
|
+
#
|
|
46
|
+
# @return [void]
|
|
47
|
+
def validate_fvar_structure
|
|
48
|
+
fvar = @font.table("fvar")
|
|
49
|
+
return unless fvar
|
|
50
|
+
|
|
51
|
+
if !fvar.respond_to?(:axes) || fvar.axes.nil? || fvar.axes.empty?
|
|
52
|
+
@errors << "fvar: No axes defined"
|
|
53
|
+
return
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
if fvar.respond_to?(:axis_count) && fvar.axis_count != fvar.axes.length
|
|
57
|
+
@errors << "fvar: Axis count mismatch (expected #{fvar.axis_count}, got #{fvar.axes.length})"
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Validate all axes
|
|
62
|
+
#
|
|
63
|
+
# @return [void]
|
|
64
|
+
def validate_axes
|
|
65
|
+
fvar = @font.table("fvar")
|
|
66
|
+
return unless fvar.respond_to?(:axes)
|
|
67
|
+
|
|
68
|
+
fvar.axes.each_with_index do |axis, index|
|
|
69
|
+
validate_axis_range(axis, index)
|
|
70
|
+
validate_axis_tag(axis, index)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Validate axis range values
|
|
75
|
+
#
|
|
76
|
+
# @param axis [Object] Axis object
|
|
77
|
+
# @param index [Integer] Axis index
|
|
78
|
+
# @return [void]
|
|
79
|
+
def validate_axis_range(axis, index)
|
|
80
|
+
return unless axis.respond_to?(:min_value) && axis.respond_to?(:max_value)
|
|
81
|
+
|
|
82
|
+
if axis.min_value > axis.max_value
|
|
83
|
+
tag = axis.respond_to?(:axis_tag) ? axis.axis_tag : "axis #{index}"
|
|
84
|
+
@errors << "Axis #{tag}: min_value (#{axis.min_value}) > max_value (#{axis.max_value})"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
if axis.respond_to?(:default_value) && (axis.default_value < axis.min_value || axis.default_value > axis.max_value)
|
|
88
|
+
tag = axis.respond_to?(:axis_tag) ? axis.axis_tag : "axis #{index}"
|
|
89
|
+
@errors << "Axis #{tag}: default_value (#{axis.default_value}) out of range [#{axis.min_value}, #{axis.max_value}]"
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Validate axis tag format
|
|
94
|
+
#
|
|
95
|
+
# @param axis [Object] Axis object
|
|
96
|
+
# @param index [Integer] Axis index
|
|
97
|
+
# @return [void]
|
|
98
|
+
def validate_axis_tag(axis, index)
|
|
99
|
+
return unless axis.respond_to?(:axis_tag)
|
|
100
|
+
|
|
101
|
+
tag = axis.axis_tag
|
|
102
|
+
unless tag.is_a?(String) && tag.length == 4 && tag =~ /^[a-zA-Z]{4}$/
|
|
103
|
+
@errors << "Axis #{index}: invalid tag '#{tag}' (must be 4 ASCII letters)"
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Validate named instances
|
|
108
|
+
#
|
|
109
|
+
# @return [void]
|
|
110
|
+
def validate_instances
|
|
111
|
+
fvar = @font.table("fvar")
|
|
112
|
+
return unless fvar.respond_to?(:instances)
|
|
113
|
+
return unless fvar.instances
|
|
114
|
+
|
|
115
|
+
fvar.instances.each_with_index do |instance, idx|
|
|
116
|
+
validate_instance_coordinates(instance, idx, fvar)
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Validate instance coordinates
|
|
121
|
+
#
|
|
122
|
+
# @param instance [Object] Instance object
|
|
123
|
+
# @param idx [Integer] Instance index
|
|
124
|
+
# @param fvar [Object] fvar table
|
|
125
|
+
# @return [void]
|
|
126
|
+
def validate_instance_coordinates(instance, idx, fvar)
|
|
127
|
+
return unless instance.is_a?(Hash) && instance[:coordinates]
|
|
128
|
+
|
|
129
|
+
coords = instance[:coordinates]
|
|
130
|
+
axis_count = fvar.respond_to?(:axis_count) ? fvar.axis_count : fvar.axes.length
|
|
131
|
+
|
|
132
|
+
if coords.length != axis_count
|
|
133
|
+
@errors << "Instance #{idx}: coordinate count mismatch (expected #{axis_count}, got #{coords.length})"
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
coords.each_with_index do |value, axis_idx|
|
|
137
|
+
next if axis_idx >= fvar.axes.length
|
|
138
|
+
|
|
139
|
+
axis = fvar.axes[axis_idx]
|
|
140
|
+
next unless axis.respond_to?(:min_value) && axis.respond_to?(:max_value)
|
|
141
|
+
|
|
142
|
+
if value < axis.min_value || value > axis.max_value
|
|
143
|
+
tag = axis.respond_to?(:axis_tag) ? axis.axis_tag : "axis #{axis_idx}"
|
|
144
|
+
@errors << "Instance #{idx}: coordinate for #{tag} (#{value}) out of range [#{axis.min_value}, #{axis.max_value}]"
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Validate variation tables
|
|
150
|
+
#
|
|
151
|
+
# @return [void]
|
|
152
|
+
def validate_variation_tables
|
|
153
|
+
has_gvar = @font.has_table?("gvar")
|
|
154
|
+
has_cff2 = @font.has_table?("CFF2")
|
|
155
|
+
has_glyf = @font.has_table?("glyf")
|
|
156
|
+
has_cff = @font.has_table?("CFF ")
|
|
157
|
+
|
|
158
|
+
# TrueType variable fonts should have gvar
|
|
159
|
+
if has_glyf && !has_gvar
|
|
160
|
+
@errors << "TrueType variable font missing gvar table"
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# CFF variable fonts should have CFF2
|
|
164
|
+
if has_cff && !has_cff2
|
|
165
|
+
@errors << "CFF variable font missing CFF2 table"
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Can't have both gvar and CFF2
|
|
169
|
+
if has_gvar && has_cff2
|
|
170
|
+
@errors << "Font has both gvar and CFF2 tables (incompatible)"
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Validate metrics variation tables
|
|
175
|
+
#
|
|
176
|
+
# @return [void]
|
|
177
|
+
def validate_metrics_variation
|
|
178
|
+
validate_hvar if @font.has_table?("HVAR")
|
|
179
|
+
validate_vvar if @font.has_table?("VVAR")
|
|
180
|
+
validate_mvar if @font.has_table?("MVAR")
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Validate HVAR table
|
|
184
|
+
#
|
|
185
|
+
# @return [void]
|
|
186
|
+
def validate_hvar
|
|
187
|
+
# HVAR validation would go here
|
|
188
|
+
# For now, just check it exists
|
|
189
|
+
hvar = @font.table_data["HVAR"]
|
|
190
|
+
if hvar.nil? || hvar.empty?
|
|
191
|
+
@errors << "HVAR table is empty"
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Validate VVAR table
|
|
196
|
+
#
|
|
197
|
+
# @return [void]
|
|
198
|
+
def validate_vvar
|
|
199
|
+
# VVAR validation would go here
|
|
200
|
+
vvar = @font.table_data["VVAR"]
|
|
201
|
+
if vvar.nil? || vvar.empty?
|
|
202
|
+
@errors << "VVAR table is empty"
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# Validate MVAR table
|
|
207
|
+
#
|
|
208
|
+
# @return [void]
|
|
209
|
+
def validate_mvar
|
|
210
|
+
# MVAR validation would go here
|
|
211
|
+
mvar = @font.table_data["MVAR"]
|
|
212
|
+
if mvar.nil? || mvar.empty?
|
|
213
|
+
@errors << "MVAR table is empty"
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
end
|