fontisan 0.4.33 → 0.4.35
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 +19 -217
- data/TODO.improvements/README.md +4 -4
- data/lib/fontisan/audit/check.rb +47 -0
- data/lib/fontisan/audit/check_registry.rb +47 -0
- data/lib/fontisan/audit/checks/cmap_check.rb +249 -0
- data/lib/fontisan/audit/checks/collection_integrity_check.rb +102 -0
- data/lib/fontisan/audit/checks/glyph_name_check.rb +107 -0
- data/lib/fontisan/audit/checks/ots_compatibility_check.rb +191 -0
- data/lib/fontisan/audit/checks/table_directory_check.rb +157 -0
- data/lib/fontisan/audit/checks.rb +22 -0
- data/lib/fontisan/audit.rb +20 -0
- data/lib/fontisan/cli.rb +78 -0
- data/lib/fontisan/commands/audit_command.rb +291 -0
- data/lib/fontisan/commands.rb +1 -0
- data/lib/fontisan/models/audit_report.rb +148 -0
- data/lib/fontisan/models.rb +2 -0
- data/lib/fontisan/type1/charstrings.rb +34 -1
- data/lib/fontisan/type1/seac_expander.rb +68 -57
- data/lib/fontisan/ufo/compile/feature_compiler.rb +161 -0
- data/lib/fontisan/ufo/compile/gsub.rb +267 -0
- data/lib/fontisan/ufo/compile.rb +3 -1
- data/lib/fontisan/ufo/font.rb +3 -2
- data/lib/fontisan/ufo/image.rb +15 -2
- data/lib/fontisan/ufo/image_set.rb +82 -2
- data/lib/fontisan/ufo/reader.rb +8 -0
- data/lib/fontisan/version.rb +1 -1
- data/lib/fontisan.rb +1 -0
- metadata +14 -1
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "lutaml/model"
|
|
4
|
+
|
|
5
|
+
module Fontisan
|
|
6
|
+
module Models
|
|
7
|
+
# Variable-font axis descriptor for the audit report.
|
|
8
|
+
class AuditAxis < Lutaml::Model::Serializable
|
|
9
|
+
attribute :tag, :string
|
|
10
|
+
attribute :min_value, :float
|
|
11
|
+
attribute :default_value, :float
|
|
12
|
+
attribute :max_value, :float
|
|
13
|
+
attribute :name_id, :integer
|
|
14
|
+
|
|
15
|
+
json do
|
|
16
|
+
map "tag", to: :tag
|
|
17
|
+
map "min_value", to: :min_value
|
|
18
|
+
map "default_value", to: :default_value
|
|
19
|
+
map "max_value", to: :max_value
|
|
20
|
+
map "name_id", to: :name_id
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
yaml do
|
|
24
|
+
map "tag", to: :tag
|
|
25
|
+
map "min_value", to: :min_value
|
|
26
|
+
map "default_value", to: :default_value
|
|
27
|
+
map "max_value", to: :max_value
|
|
28
|
+
map "name_id", to: :name_id
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Structured font audit report covering identity, style, coverage,
|
|
33
|
+
# and OpenType layout facts. Designed for archival use — each report
|
|
34
|
+
# is self-describing with provenance (generated_at, fontisan_version,
|
|
35
|
+
# source_sha256).
|
|
36
|
+
#
|
|
37
|
+
# The report is a value object: it captures what the font FILE
|
|
38
|
+
# declares, not what any external index says about it.
|
|
39
|
+
class AuditReport < Lutaml::Model::Serializable
|
|
40
|
+
# Provenance
|
|
41
|
+
attribute :generated_at, :string
|
|
42
|
+
attribute :fontisan_version, :string
|
|
43
|
+
attribute :source_file, :string
|
|
44
|
+
attribute :source_sha256, :string
|
|
45
|
+
attribute :source_format, :string
|
|
46
|
+
attribute :font_index, :integer
|
|
47
|
+
attribute :num_fonts_in_source, :integer
|
|
48
|
+
|
|
49
|
+
# Identity
|
|
50
|
+
attribute :family_name, :string
|
|
51
|
+
attribute :subfamily_name, :string
|
|
52
|
+
attribute :full_name, :string
|
|
53
|
+
attribute :postscript_name, :string
|
|
54
|
+
attribute :version, :string
|
|
55
|
+
attribute :font_revision, :float
|
|
56
|
+
|
|
57
|
+
# Style
|
|
58
|
+
attribute :weight_class, :integer
|
|
59
|
+
attribute :width_class, :integer
|
|
60
|
+
attribute :italic, Lutaml::Model::Type::Boolean
|
|
61
|
+
attribute :bold, Lutaml::Model::Type::Boolean
|
|
62
|
+
attribute :panose, :string
|
|
63
|
+
attribute :is_variable, Lutaml::Model::Type::Boolean
|
|
64
|
+
attribute :axes, AuditAxis, collection: true
|
|
65
|
+
|
|
66
|
+
# Coverage
|
|
67
|
+
attribute :total_codepoints, :integer
|
|
68
|
+
attribute :total_glyphs, :integer
|
|
69
|
+
attribute :cmap_subtables, :integer, collection: true
|
|
70
|
+
attribute :codepoints, :string, collection: true
|
|
71
|
+
|
|
72
|
+
# OpenType layout
|
|
73
|
+
attribute :opentype_scripts, :string, collection: true
|
|
74
|
+
attribute :features, :string, collection: true
|
|
75
|
+
|
|
76
|
+
# Validation (populated only with --validate)
|
|
77
|
+
attribute :validation_issues, Models::ValidationReport::Issue, collection: true
|
|
78
|
+
|
|
79
|
+
json do
|
|
80
|
+
map "generated_at", to: :generated_at
|
|
81
|
+
map "fontisan_version", to: :fontisan_version
|
|
82
|
+
map "source_file", to: :source_file
|
|
83
|
+
map "source_sha256", to: :source_sha256
|
|
84
|
+
map "source_format", to: :source_format
|
|
85
|
+
map "font_index", to: :font_index
|
|
86
|
+
map "num_fonts_in_source", to: :num_fonts_in_source
|
|
87
|
+
|
|
88
|
+
map "family_name", to: :family_name
|
|
89
|
+
map "subfamily_name", to: :subfamily_name
|
|
90
|
+
map "full_name", to: :full_name
|
|
91
|
+
map "postscript_name", to: :postscript_name
|
|
92
|
+
map "version", to: :version
|
|
93
|
+
map "font_revision", to: :font_revision
|
|
94
|
+
|
|
95
|
+
map "weight_class", to: :weight_class
|
|
96
|
+
map "width_class", to: :width_class
|
|
97
|
+
map "italic", to: :italic
|
|
98
|
+
map "bold", to: :bold
|
|
99
|
+
map "panose", to: :panose
|
|
100
|
+
map "is_variable", to: :is_variable
|
|
101
|
+
map "axes", to: :axes
|
|
102
|
+
|
|
103
|
+
map "total_codepoints", to: :total_codepoints
|
|
104
|
+
map "total_glyphs", to: :total_glyphs
|
|
105
|
+
map "cmap_subtables", to: :cmap_subtables
|
|
106
|
+
map "codepoints", to: :codepoints
|
|
107
|
+
|
|
108
|
+
map "opentype_scripts", to: :opentype_scripts
|
|
109
|
+
map "features", to: :features
|
|
110
|
+
map "validation_issues", to: :validation_issues
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
yaml do
|
|
114
|
+
map "generated_at", to: :generated_at
|
|
115
|
+
map "fontisan_version", to: :fontisan_version
|
|
116
|
+
map "source_file", to: :source_file
|
|
117
|
+
map "source_sha256", to: :source_sha256
|
|
118
|
+
map "source_format", to: :source_format
|
|
119
|
+
map "font_index", to: :font_index
|
|
120
|
+
map "num_fonts_in_source", to: :num_fonts_in_source
|
|
121
|
+
|
|
122
|
+
map "family_name", to: :family_name
|
|
123
|
+
map "subfamily_name", to: :subfamily_name
|
|
124
|
+
map "full_name", to: :full_name
|
|
125
|
+
map "postscript_name", to: :postscript_name
|
|
126
|
+
map "version", to: :version
|
|
127
|
+
map "font_revision", to: :font_revision
|
|
128
|
+
|
|
129
|
+
map "weight_class", to: :weight_class
|
|
130
|
+
map "width_class", to: :width_class
|
|
131
|
+
map "italic", to: :italic
|
|
132
|
+
map "bold", to: :bold
|
|
133
|
+
map "panose", to: :panose
|
|
134
|
+
map "is_variable", to: :is_variable
|
|
135
|
+
map "axes", to: :axes
|
|
136
|
+
|
|
137
|
+
map "total_codepoints", to: :total_codepoints
|
|
138
|
+
map "total_glyphs", to: :total_glyphs
|
|
139
|
+
map "cmap_subtables", to: :cmap_subtables
|
|
140
|
+
map "codepoints", to: :codepoints
|
|
141
|
+
|
|
142
|
+
map "opentype_scripts", to: :opentype_scripts
|
|
143
|
+
map "features", to: :features
|
|
144
|
+
map "validation_issues", to: :validation_issues
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
data/lib/fontisan/models.rb
CHANGED
|
@@ -6,6 +6,8 @@ module Fontisan
|
|
|
6
6
|
module Models
|
|
7
7
|
autoload :AllScriptsFeaturesInfo,
|
|
8
8
|
"fontisan/models/all_scripts_features_info"
|
|
9
|
+
autoload :AuditAxis, "fontisan/models/audit_report"
|
|
10
|
+
autoload :AuditReport, "fontisan/models/audit_report"
|
|
9
11
|
autoload :AxisInfo, "fontisan/models/variable_font_info"
|
|
10
12
|
autoload :BitmapGlyph, "fontisan/models/bitmap_glyph"
|
|
11
13
|
autoload :BitmapStrike, "fontisan/models/bitmap_strike"
|
|
@@ -42,6 +42,39 @@ module Fontisan
|
|
|
42
42
|
def initialize(private_dict = nil)
|
|
43
43
|
@private_dict = private_dict || PrivateDict.new
|
|
44
44
|
@charstrings = {}
|
|
45
|
+
@encoding_override = nil
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Build a CharStrings from a pre-built glyph-name → charstring-bytecode
|
|
49
|
+
# hash. Useful for constructing test fixtures without parsing a full
|
|
50
|
+
# Type 1 font. Optionally accepts +encoding:+ to override the default
|
|
51
|
+
# Adobe Standard Encoding (char code → glyph name).
|
|
52
|
+
#
|
|
53
|
+
# @param charstrings_hash [Hash{String=>String}]
|
|
54
|
+
# @param encoding [Hash{Integer=>String}, nil]
|
|
55
|
+
# @param private_dict [PrivateDict, nil]
|
|
56
|
+
# @return [CharStrings]
|
|
57
|
+
def self.from_hash(charstrings_hash, encoding: nil, private_dict: nil)
|
|
58
|
+
cs = new(private_dict)
|
|
59
|
+
charstrings_hash.each { |name, data| cs.register(name, data) }
|
|
60
|
+
cs.set_encoding(encoding) if encoding
|
|
61
|
+
cs
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Register (or replace) a glyph's charstring data.
|
|
65
|
+
#
|
|
66
|
+
# @param glyph_name [String]
|
|
67
|
+
# @param charstring_data [String] binary charstring bytecode
|
|
68
|
+
def register(glyph_name, charstring_data)
|
|
69
|
+
@charstrings[glyph_name] = charstring_data
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Override the default Adobe Standard Encoding with a custom
|
|
73
|
+
# +char_code → glyph_name+ map.
|
|
74
|
+
#
|
|
75
|
+
# @param encoding [Hash{Integer=>String}]
|
|
76
|
+
def set_encoding(encoding)
|
|
77
|
+
@encoding_override = encoding
|
|
45
78
|
end
|
|
46
79
|
|
|
47
80
|
# Parse CharStrings dictionary from decrypted Type 1 font data
|
|
@@ -72,7 +105,7 @@ module Fontisan
|
|
|
72
105
|
#
|
|
73
106
|
# @return [Hash] Character code to glyph name mapping
|
|
74
107
|
def encoding
|
|
75
|
-
@encoding ||= build_standard_encoding
|
|
108
|
+
@encoding_override || (@encoding ||= build_standard_encoding)
|
|
76
109
|
end
|
|
77
110
|
|
|
78
111
|
# Iterate over all charstrings
|
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
module Fontisan
|
|
4
4
|
module Type1
|
|
5
|
+
# Error raised when a seac reference cannot be resolved (the referenced
|
|
6
|
+
# glyph is missing, or a cycle is detected in nested seac).
|
|
7
|
+
class SeacReferenceError < Fontisan::Error; end
|
|
8
|
+
|
|
5
9
|
# Expands Type 1 seac composite glyphs into base + accent outlines
|
|
6
10
|
#
|
|
7
11
|
# [`SeacExpander`](lib/fontisan/type1/seac_expander.rb) handles the
|
|
@@ -9,6 +13,10 @@ module Fontisan
|
|
|
9
13
|
# The seac operator combines two glyphs (a base character and an accent)
|
|
10
14
|
# with a positioning offset, which must be decomposed for CFF conversion.
|
|
11
15
|
#
|
|
16
|
+
# Supports nested seac: if a base or accent glyph is itself a seac
|
|
17
|
+
# composite, it is recursively expanded. Cycles are detected and raise
|
|
18
|
+
# {SeacReferenceError}.
|
|
19
|
+
#
|
|
12
20
|
# The seac operator format is:
|
|
13
21
|
# ```
|
|
14
22
|
# seac asb adx ady bchar achar
|
|
@@ -28,6 +36,8 @@ module Fontisan
|
|
|
28
36
|
#
|
|
29
37
|
# @see https://www.adobe.com/devnet/font/pdfs/Type1.pdf
|
|
30
38
|
class SeacExpander
|
|
39
|
+
MAX_DEPTH = 16
|
|
40
|
+
|
|
31
41
|
# @return [CharStrings] Type 1 CharStrings dictionary
|
|
32
42
|
attr_reader :charstrings
|
|
33
43
|
|
|
@@ -43,67 +53,15 @@ module Fontisan
|
|
|
43
53
|
@private_dict = private_dict
|
|
44
54
|
end
|
|
45
55
|
|
|
46
|
-
# Decompose a seac composite glyph into base + accent outlines
|
|
47
|
-
#
|
|
48
|
-
# This method:
|
|
49
|
-
# 1. Parses the seac operator to extract components
|
|
50
|
-
# 2. Gets CharStrings for base and accent glyphs
|
|
51
|
-
# 3. Parses both CharStrings into outline commands
|
|
52
|
-
# 4. Transforms the accent by (adx, ady) offset
|
|
53
|
-
# 5. Merges base and accent outlines into a single path
|
|
54
|
-
# 6. Returns the decomposed CharString data
|
|
56
|
+
# Decompose a seac composite glyph into base + accent outlines.
|
|
57
|
+
# Handles nested seac recursively.
|
|
55
58
|
#
|
|
56
59
|
# @param glyph_name [String] Name of the composite glyph to decompose
|
|
57
60
|
# @return [String, nil] Decomposed CharString bytecode, or nil if not a seac composite
|
|
58
|
-
# @raise [
|
|
59
|
-
#
|
|
60
|
-
# @example Decompose "Agrave" glyph
|
|
61
|
-
# expander.decompose("Agrave")
|
|
61
|
+
# @raise [SeacReferenceError] If base or accent glyphs are not found,
|
|
62
|
+
# or if a cycle is detected
|
|
62
63
|
def decompose(glyph_name)
|
|
63
|
-
|
|
64
|
-
return nil unless components
|
|
65
|
-
|
|
66
|
-
# Use the encoding map to lookup glyph names from character codes
|
|
67
|
-
base_glyph_name = @charstrings.encoding[components[:base]]
|
|
68
|
-
accent_glyph_name = @charstrings.encoding[components[:accent]]
|
|
69
|
-
|
|
70
|
-
if base_glyph_name.nil?
|
|
71
|
-
raise Fontisan::Error,
|
|
72
|
-
"Base glyph for char code #{components[:base]} not found"
|
|
73
|
-
end
|
|
74
|
-
|
|
75
|
-
if accent_glyph_name.nil?
|
|
76
|
-
raise Fontisan::Error,
|
|
77
|
-
"Accent glyph for char code #{components[:accent]} not found"
|
|
78
|
-
end
|
|
79
|
-
|
|
80
|
-
# Get CharStrings for base and accent
|
|
81
|
-
base_charstring = @charstrings[base_glyph_name]
|
|
82
|
-
accent_charstring = @charstrings[accent_glyph_name]
|
|
83
|
-
|
|
84
|
-
if base_charstring.nil?
|
|
85
|
-
raise Fontisan::Error,
|
|
86
|
-
"CharString not found for base glyph #{base_glyph_name}"
|
|
87
|
-
end
|
|
88
|
-
|
|
89
|
-
if accent_charstring.nil?
|
|
90
|
-
raise Fontisan::Error,
|
|
91
|
-
"CharString not found for accent glyph #{accent_glyph_name}"
|
|
92
|
-
end
|
|
93
|
-
|
|
94
|
-
# Parse both CharStrings into command sequences
|
|
95
|
-
base_commands = parse_charstring_to_commands(base_charstring)
|
|
96
|
-
accent_commands = parse_charstring_to_commands(accent_charstring)
|
|
97
|
-
|
|
98
|
-
# Transform accent by (adx, ady) offset
|
|
99
|
-
accent_commands = transform_commands(accent_commands, components[:adx],
|
|
100
|
-
components[:ady])
|
|
101
|
-
|
|
102
|
-
# Merge base and accent commands
|
|
103
|
-
merged_commands = merge_outline_commands(base_commands, accent_commands)
|
|
104
|
-
|
|
105
|
-
# Convert merged commands back to CharString bytecode
|
|
106
|
-
generate_charstring(merged_commands)
|
|
64
|
+
decompose_with_visited(glyph_name, Set.new)
|
|
107
65
|
end
|
|
108
66
|
|
|
109
67
|
# Check if a glyph is a seac composite
|
|
@@ -123,6 +81,59 @@ module Fontisan
|
|
|
123
81
|
|
|
124
82
|
private
|
|
125
83
|
|
|
84
|
+
# Recursive decomposition with cycle detection via +visited+ set.
|
|
85
|
+
def decompose_with_visited(glyph_name, visited)
|
|
86
|
+
return nil unless composite?(glyph_name)
|
|
87
|
+
|
|
88
|
+
if visited.include?(glyph_name)
|
|
89
|
+
raise SeacReferenceError,
|
|
90
|
+
"Cycle detected in seac references: #{visited.to_a.join(' → ')} → #{glyph_name}"
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
if visited.size >= MAX_DEPTH
|
|
94
|
+
raise SeacReferenceError,
|
|
95
|
+
"seac nesting depth exceeds #{MAX_DEPTH} at #{glyph_name}"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
visited_next = visited.dup.add(glyph_name)
|
|
99
|
+
components = @charstrings.components_for(glyph_name)
|
|
100
|
+
|
|
101
|
+
base_name = resolve_char_code(components[:base], "base", glyph_name)
|
|
102
|
+
accent_name = resolve_char_code(components[:accent], "accent", glyph_name)
|
|
103
|
+
|
|
104
|
+
base_commands = resolve_glyph_commands(base_name, visited_next)
|
|
105
|
+
accent_commands = resolve_glyph_commands(accent_name, visited_next)
|
|
106
|
+
accent_commands = transform_commands(accent_commands, components[:adx],
|
|
107
|
+
components[:ady])
|
|
108
|
+
merged = merge_outline_commands(base_commands, accent_commands)
|
|
109
|
+
generate_charstring(merged)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Resolve a char code to a glyph name via the font's encoding.
|
|
113
|
+
def resolve_char_code(char_code, role, parent)
|
|
114
|
+
name = @charstrings.encoding[char_code]
|
|
115
|
+
return name if name
|
|
116
|
+
|
|
117
|
+
raise SeacReferenceError,
|
|
118
|
+
"#{role} glyph for char code #{char_code} (in #{parent}) not found in encoding"
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Get outline commands for a glyph, recursively expanding nested seac.
|
|
122
|
+
def resolve_glyph_commands(glyph_name, visited)
|
|
123
|
+
charstring = @charstrings[glyph_name]
|
|
124
|
+
if charstring.nil?
|
|
125
|
+
raise SeacReferenceError,
|
|
126
|
+
"CharString not found for glyph #{glyph_name}"
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
if composite?(glyph_name)
|
|
130
|
+
nested = decompose_with_visited(glyph_name, visited)
|
|
131
|
+
parse_charstring_to_commands(nested)
|
|
132
|
+
else
|
|
133
|
+
parse_charstring_to_commands(charstring)
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
126
137
|
# Parse Type 1 CharString into drawing commands
|
|
127
138
|
#
|
|
128
139
|
# Converts Type 1 CharString bytecode into a list of drawing commands
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fontisan
|
|
4
|
+
module Ufo
|
|
5
|
+
module Compile
|
|
6
|
+
# Minimal Adobe FEA (features.fea) parser. Extracts the two most
|
|
7
|
+
# common feature constructs from UFO sources:
|
|
8
|
+
#
|
|
9
|
+
# feature liga { sub f i by fi; } liga; → LigatureSubst
|
|
10
|
+
# feature kern { pos A V -50; } kern; → PairPos
|
|
11
|
+
#
|
|
12
|
+
# Unsupported constructs are collected and surfaced via
|
|
13
|
+
# {ParsedFeatures#unsupported} so the caller can decide whether
|
|
14
|
+
# to warn or raise. This is the "limited fea subset" approach
|
|
15
|
+
# from TODO #10b — expand as real-world fonts demand.
|
|
16
|
+
#
|
|
17
|
+
# The parser is deliberately simple: it tokenizes on whitespace
|
|
18
|
+
# and semicolons, recognizes `feature <tag> { ... } <tag>;` blocks,
|
|
19
|
+
# and dispatches per-statement to registered rule handlers (OCP —
|
|
20
|
+
# new rule kinds are one handler addition).
|
|
21
|
+
#
|
|
22
|
+
# @example
|
|
23
|
+
# parsed = FeatureCompiler.parse(fea_text)
|
|
24
|
+
# parsed.ligatures_for("liga") # => [{sequence: ["f", "i"], result: "fi"}]
|
|
25
|
+
class FeatureCompiler
|
|
26
|
+
# @param text [String] features.fea content
|
|
27
|
+
# @return [ParsedFeatures]
|
|
28
|
+
def self.parse(text)
|
|
29
|
+
new.parse(text)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# @param text [String]
|
|
33
|
+
# @return [ParsedFeatures]
|
|
34
|
+
def parse(text)
|
|
35
|
+
@parsed = ParsedFeatures.new
|
|
36
|
+
tokenize_blocks(text || "")
|
|
37
|
+
@parsed
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
# Split text into `feature <tag> { body } <tag>;` blocks and
|
|
43
|
+
# pass each block body to the statement dispatcher.
|
|
44
|
+
def tokenize_blocks(text)
|
|
45
|
+
# Strip comments (# to end of line)
|
|
46
|
+
cleaned = text.gsub(%r{#[^\n]*}, "")
|
|
47
|
+
# Match feature blocks: feature TAG { ... } TAG;
|
|
48
|
+
cleaned.scan(/feature\s+([A-Za-z0-9]{4})\s*\{([^}]*)\}\s*\1\s*;/m) do |tag, body|
|
|
49
|
+
parse_body(tag, body)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Parse statements within a feature block.
|
|
54
|
+
def parse_body(tag, body)
|
|
55
|
+
statements = body.split(";").map(&:strip).reject(&:empty?)
|
|
56
|
+
statements.each do |stmt|
|
|
57
|
+
tokens = stmt.split(/\s+/)
|
|
58
|
+
dispatch(tag, tokens, stmt)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Dispatch a statement to the appropriate handler based on the
|
|
63
|
+
# leading keyword. Unknown statements are collected as unsupported.
|
|
64
|
+
def dispatch(tag, tokens, raw)
|
|
65
|
+
case tokens.first
|
|
66
|
+
when "sub" then handle_sub(tag, tokens)
|
|
67
|
+
when "pos" then handle_pos(tag, tokens)
|
|
68
|
+
else
|
|
69
|
+
@parsed.add_unsupported(tag, raw)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# `sub A B C by ABC;` → ligature substitution
|
|
74
|
+
def handle_sub(tag, tokens)
|
|
75
|
+
# tokens: ["sub", "A", "B", ..., "by", "ABC"]
|
|
76
|
+
by_idx = tokens.index("by")
|
|
77
|
+
unless by_idx && by_idx > 1 && tokens.size > by_idx + 1
|
|
78
|
+
@parsed.add_unsupported(tag, tokens.join(" "))
|
|
79
|
+
return
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
sequence = tokens[1, by_idx - 1]
|
|
83
|
+
result = tokens[by_idx + 1]
|
|
84
|
+
return if sequence.nil? || sequence.empty? || result.nil?
|
|
85
|
+
|
|
86
|
+
if sequence.size == 1
|
|
87
|
+
# Single substitution: `sub A by A.alt;` — unsupported for now
|
|
88
|
+
@parsed.add_unsupported(tag, tokens.join(" "))
|
|
89
|
+
return
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
@parsed.add_ligature(tag, sequence: sequence, result: result)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# `pos A B -50;` → pair positioning
|
|
96
|
+
def handle_pos(tag, tokens)
|
|
97
|
+
# tokens: ["pos", "A", "B", "-50"] or ["pos", "A", "B", "<", "0", "-50", "0", ">"]
|
|
98
|
+
return unless tokens.size >= 4
|
|
99
|
+
|
|
100
|
+
left = tokens[1]
|
|
101
|
+
right = tokens[2]
|
|
102
|
+
value = parse_pos_value(tokens[3])
|
|
103
|
+
return unless value
|
|
104
|
+
|
|
105
|
+
@parsed.add_pair(tag, left:, right:, value:)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Parse a positioning value: either a bare integer or the start
|
|
109
|
+
# of a ValueRecord (we only handle the bare integer case for now).
|
|
110
|
+
def parse_pos_value(token)
|
|
111
|
+
Integer(token)
|
|
112
|
+
rescue ArgumentError
|
|
113
|
+
nil
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Result of parsing features.fea. Aggregates ligature and pair
|
|
118
|
+
# rules by feature tag, plus any unsupported statements.
|
|
119
|
+
class ParsedFeatures
|
|
120
|
+
attr_reader :ligatures, :pairs, :unsupported
|
|
121
|
+
|
|
122
|
+
def initialize
|
|
123
|
+
@ligatures = Hash.new { |h, k| h[k] = [] }
|
|
124
|
+
@pairs = Hash.new { |h, k| h[k] = [] }
|
|
125
|
+
@unsupported = Hash.new { |h, k| h[k] = [] }
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# @param tag [String] feature tag
|
|
129
|
+
# @return [Array<Hash>] ligature rules: {sequence: [names], result: name}
|
|
130
|
+
def ligatures_for(tag)
|
|
131
|
+
@ligatures[tag] || []
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# @param tag [String] feature tag
|
|
135
|
+
# @return [Array<Hash>] pair rules: {left:, right:, value:}
|
|
136
|
+
def pairs_for(tag)
|
|
137
|
+
@pairs[tag] || []
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# @return [Boolean] true if no features were parsed
|
|
141
|
+
def empty?
|
|
142
|
+
@ligatures.empty? && @pairs.empty?
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# ---- mutation (used by FeatureCompiler) ----
|
|
146
|
+
|
|
147
|
+
def add_ligature(tag, sequence:, result:)
|
|
148
|
+
@ligatures[tag] << { sequence: sequence, result: result }
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def add_pair(tag, left:, right:, value:)
|
|
152
|
+
@pairs[tag] << { left: left, right: right, value: value }
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def add_unsupported(tag, statement)
|
|
156
|
+
@unsupported[tag] << statement
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|