fontisan 0.4.32 → 0.4.34

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.
@@ -235,25 +235,7 @@ module Fontisan
235
235
  Cff::Index.new(@io, start_offset: offset)
236
236
  end
237
237
 
238
- private
239
-
240
- # Read bytes safely with EOF checking
241
- #
242
- # @param bytes [Integer] Number of bytes to read
243
- # @param description [String] Description for error messages
244
- # @return [String] Binary data
245
- # @raise [EOFError] If not enough bytes available
246
- def read_safely(bytes, description)
247
- data = @io.read(bytes)
248
- if data.nil? || data.bytesize < bytes
249
- raise EOFError,
250
- "Unexpected EOF while reading #{description}"
251
- end
252
-
253
- data
254
- end
255
-
256
- # Parse DICT structure
238
+ # Parse a DICT structure (public: reused by the CFF2 subsetter).
257
239
  #
258
240
  # @param data [String] DICT binary data
259
241
  # @return [Hash] Parsed operators and values
@@ -281,20 +263,37 @@ module Fontisan
281
263
  dict
282
264
  end
283
265
 
266
+ private
267
+
268
+ # Read bytes safely with EOF checking
269
+ #
270
+ # @param bytes [Integer] Number of bytes to read
271
+ # @param description [String] Description for error messages
272
+ # @return [String] Binary data
273
+ # @raise [EOFError] If not enough bytes available
274
+ def read_safely(bytes, description)
275
+ data = @io.read(bytes)
276
+ if data.nil? || data.bytesize < bytes
277
+ raise EOFError,
278
+ "Unexpected EOF while reading #{description}"
279
+ end
280
+
281
+ data
282
+ end
283
+
284
284
  # Check if byte is an operator
285
285
  #
286
- # CFF2 extends the operator range to include operator 24 (vstore)
286
+ # CFF/CFF2 DICT operator bytes are 0-21 (with 12 as the two-byte
287
+ # escape prefix) and 24 (vstore, CFF2-specific). Number encoding
288
+ # prefixes (28, 29, 30) and ranges (32-254) are NOT operators.
287
289
  #
288
290
  # @param byte [Integer] Byte value
289
291
  # @return [Boolean] True if operator
290
292
  def operator_byte?(byte)
291
- # Standard DICT operators (0-21, excluding number markers)
292
- return true if byte <= 21 && ![12, 28, 29, 30, 31].include?(byte)
293
-
294
- # CFF2-specific operators
295
- return true if byte == VSTORE_OPERATOR
293
+ return false if (28..30).cover?(byte) # number prefixes
294
+ return false if (32..254).cover?(byte) # number ranges
296
295
 
297
- false
296
+ true # 0-27 (incl. 12 escape), 31, 255 — treat as operator
298
297
  end
299
298
 
300
299
  # Read DICT operator
@@ -26,6 +26,7 @@ module Fontisan
26
26
  autoload :CharstringParser, "fontisan/tables/cff2/charstring_parser"
27
27
  autoload :FdSelect, "fontisan/tables/cff2/fd_select"
28
28
  autoload :Header, "fontisan/tables/cff2/header"
29
+ autoload :Index, "fontisan/tables/cff2/index"
29
30
  autoload :IndexBuilder, "fontisan/tables/cff2/index_builder"
30
31
  autoload :DictEncoder, "fontisan/tables/cff2/dict_encoder"
31
32
  autoload :OperandStack, "fontisan/tables/cff2/operand_stack"
@@ -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 [Fontisan::Error] If base or accent glyphs are not found
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
- components = @charstrings.components_for(glyph_name)
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
@@ -33,42 +33,143 @@ module Fontisan
33
33
 
34
34
  # @param font [Fontisan::Ufo::Font]
35
35
  # @param glyphs [Array<Fontisan::Ufo::Glyph>] in gid order
36
+ # @param masters [Array<Hash>, nil] variation masters for variable
37
+ # CFF2. Each hash: { font:, axes: }. When present, charstrings
38
+ # emit blend operators and a VariationStore is embedded.
39
+ # @param axis_count [Integer, nil] number of variation axes
36
40
  # @param variation_store [String, nil] ItemVariationStore bytes
37
41
  # for variable CFF2 fonts. When present, embedded between the
38
42
  # GlobalSubr INDEX and CharStrings INDEX, and referenced from
39
43
  # the Top DICT via operator 24.
40
44
  # @return [String] CFF2 table bytes
41
- def self.build(_font, glyphs:, variation_store: nil)
45
+ def self.build(font, glyphs:, masters: nil, axis_count: nil,
46
+ variation_store: nil)
47
+ if masters&.any?
48
+ build_variable(font, glyphs, masters, axis_count)
49
+ else
50
+ build_static(font, glyphs, variation_store:)
51
+ end
52
+ end
53
+
54
+ # Build a static CFF2 table (no variation data).
55
+ def self.build_static(font, glyphs, variation_store:)
42
56
  charstrings = glyphs.map { |g| charstring_for(g) }
43
57
  global_subr_index = empty_global_subr_index
44
58
  font_dict = build_font_dict(private_size: 0, private_offset: 0)
45
59
  font_dict_index = Tables::Cff2::IndexBuilder.build([font_dict])
46
60
  vs_bytes = variation_store&.b
47
61
 
48
- # Fixed-point iteration: encode Top DICT, compute offsets, repeat.
49
- top_dict = encode_top_dict(
50
- charstrings_offset: 0, font_dict_index_offset: 0,
51
- variation_store_offset: vs_bytes ? 0 : nil
62
+ layout = converge_layout(
63
+ charstrings:, global_subr_index:, font_dict_index:,
64
+ variation_store: vs_bytes
52
65
  )
53
- layout = compute_layout(top_dict:, charstrings:, global_subr_index:,
54
- font_dict_index:, variation_store: vs_bytes)
66
+ assemble(layout:)
67
+ end
55
68
 
56
- 10.times do
57
- top_dict = encode_top_dict(
58
- charstrings_offset: layout[:charstrings_offset],
59
- font_dict_index_offset: layout[:font_dict_index_offset],
60
- variation_store_offset: layout[:variation_store_offset],
61
- )
62
- next_layout = compute_layout(top_dict:, charstrings:, global_subr_index:,
63
- font_dict_index:, variation_store: vs_bytes)
64
- break if same_offsets?(layout, next_layout)
69
+ # Build a variable CFF2 table with blend operators and a VStore.
70
+ #
71
+ # Each glyph gets a variable charstring with blend operators when
72
+ # master outlines are available for it. Glyphs without masters fall
73
+ # back to static charstrings. The VStore contains one region per
74
+ # master, each peaking at the master's axis location.
75
+ def self.build_variable(font, glyphs, masters, axis_count)
76
+ num_regions = masters.size
77
+ vstore = build_variation_store(masters, axis_count)
65
78
 
66
- layout = next_layout
79
+ master_glyph_sets = masters.map do |m|
80
+ { font: m[:font], by_name: m[:font].glyphs }
81
+ end
82
+
83
+ charstrings = glyphs.map.with_index do |glyph, _idx|
84
+ master_outlines = master_glyph_sets.filter_map do |mg|
85
+ mg[:by_name][glyph.name]&.to_outline
86
+ end
87
+
88
+ if master_outlines.size == num_regions && glyph.contours.any?
89
+ Tables::Cff::Cff2CharStringBuilder.build_variable(
90
+ glyph.to_outline,
91
+ master_outlines: master_outlines,
92
+ num_regions: num_regions,
93
+ width: glyph.width.to_i,
94
+ )
95
+ else
96
+ charstring_for(glyph)
97
+ end
67
98
  end
68
99
 
100
+ global_subr_index = empty_global_subr_index
101
+ font_dict = build_font_dict(private_size: 0, private_offset: 0)
102
+ font_dict_index = Tables::Cff2::IndexBuilder.build([font_dict])
103
+
104
+ layout = converge_layout(
105
+ charstrings:, global_subr_index:, font_dict_index:,
106
+ variation_store: vstore
107
+ )
69
108
  assemble(layout:)
70
109
  end
71
110
 
111
+ # Build the ItemVariationStore for CFF2: one region per master,
112
+ # each peaking at the master's axis location.
113
+ def self.build_variation_store(masters, axis_count)
114
+ ac = axis_count || masters.first&.dig(:axes)&.size || 1
115
+ regions = masters.each_with_index.map do |_master, midx|
116
+ Array.new(ac) do |aidx|
117
+ if aidx == midx
118
+ { start: -1.0, peak: 1.0, end: 1.0 }
119
+ else
120
+ { start: -1.0, peak: 0.0, end: 1.0 }
121
+ end
122
+ end
123
+ end
124
+
125
+ region_list = serialize_region_list(regions, ac)
126
+ # One ItemVariationData with all regions referenced but zero items.
127
+ # The actual deltas live in charstrings (pushed via blend operands).
128
+ var_data = serialize_empty_variation_data(regions.size)
129
+
130
+ header_size = 8
131
+ offsets_array_size = 4
132
+ region_list_offset = header_size + offsets_array_size
133
+ var_data_offset = region_list_offset + region_list.bytesize
134
+
135
+ store = +""
136
+ store << [1].pack("n")
137
+ store << [region_list_offset].pack("N")
138
+ store << [1].pack("n")
139
+ store << [var_data_offset].pack("N")
140
+ store << region_list
141
+ store << var_data
142
+ store
143
+ end
144
+
145
+ def self.serialize_region_list(regions, axis_count)
146
+ io = +""
147
+ io << [axis_count].pack("n")
148
+ io << [regions.size].pack("n")
149
+ regions.each do |region|
150
+ region.each do |coords|
151
+ io << [f2dot14(coords[:start]), f2dot14(coords[:peak]),
152
+ f2dot14(coords[:end])].pack("nnn")
153
+ end
154
+ end
155
+ io
156
+ end
157
+
158
+ # Serialize an ItemVariationData with all regions referenced but
159
+ # zero items. CFF2 charstrings push their own deltas via blend.
160
+ def self.serialize_empty_variation_data(region_count)
161
+ io = +""
162
+ io << [0].pack("n") # itemCount = 0
163
+ io << [region_count].pack("n") # shortDeltaCount
164
+ io << [region_count].pack("n") # regionIndexCount
165
+ region_count.times { |i| io << [i].pack("n") }
166
+ io
167
+ end
168
+
169
+ def self.f2dot14(value)
170
+ (value.to_f * 16384).to_i.clamp(-16384, 16384)
171
+ end
172
+
72
173
  # ---------- charstring per-glyph ----------
73
174
 
74
175
  def self.charstring_for(glyph)
@@ -148,6 +249,43 @@ module Fontisan
148
249
  }
149
250
  end
150
251
 
252
+ # Iteratively encode the Top DICT and recompute offsets until the
253
+ # layout stabilizes. After convergence, performs one final
254
+ # encode + compute pass so +layout[:top_dict]+ carries the
255
+ # converged offsets (not the stale initial placeholder).
256
+ def self.converge_layout(charstrings:, global_subr_index:,
257
+ font_dict_index:, variation_store: nil)
258
+ has_vs = !variation_store.nil?
259
+ top_dict = encode_top_dict(
260
+ charstrings_offset: 0, font_dict_index_offset: 0,
261
+ variation_store_offset: has_vs ? 0 : nil
262
+ )
263
+ layout = compute_layout(top_dict:, charstrings:, global_subr_index:,
264
+ font_dict_index:, variation_store:)
265
+
266
+ 10.times do
267
+ top_dict = encode_top_dict(
268
+ charstrings_offset: layout[:charstrings_offset],
269
+ font_dict_index_offset: layout[:font_dict_index_offset],
270
+ variation_store_offset: layout[:variation_store_offset],
271
+ )
272
+ next_layout = compute_layout(top_dict:, charstrings:, global_subr_index:,
273
+ font_dict_index:, variation_store:)
274
+ break if same_offsets?(layout, next_layout)
275
+
276
+ layout = next_layout
277
+ end
278
+
279
+ # Final encode ensures layout[:top_dict] matches converged offsets
280
+ final_td = encode_top_dict(
281
+ charstrings_offset: layout[:charstrings_offset],
282
+ font_dict_index_offset: layout[:font_dict_index_offset],
283
+ variation_store_offset: layout[:variation_store_offset],
284
+ )
285
+ compute_layout(top_dict: final_td, charstrings:, global_subr_index:,
286
+ font_dict_index:, variation_store:)
287
+ end
288
+
151
289
  def self.same_offsets?(a, b)
152
290
  a[:charstrings_offset] == b[:charstrings_offset] &&
153
291
  a[:font_dict_index_offset] == b[:font_dict_index_offset] &&
@@ -173,8 +311,12 @@ module Fontisan
173
311
 
174
312
  private_class_method :charstring_for, :empty_charstring,
175
313
  :encode_top_dict, :build_font_dict,
176
- :compute_layout, :same_offsets?,
177
- :empty_global_subr_index, :assemble
314
+ :compute_layout, :converge_layout,
315
+ :same_offsets?,
316
+ :empty_global_subr_index, :assemble,
317
+ :build_static, :build_variable,
318
+ :build_variation_store, :serialize_region_list,
319
+ :serialize_empty_variation_data, :f2dot14
178
320
  end
179
321
  end
180
322
  end
@@ -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