fontisan 0.4.45 → 0.4.47

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.
Files changed (31) hide show
  1. checksums.yaml +4 -4
  2. data/Rakefile +1 -2
  3. data/lib/fontisan/cli.rb +1 -1
  4. data/lib/fontisan/commands/export_command.rb +3 -3
  5. data/lib/fontisan/export/exporter.rb +1 -1
  6. data/lib/fontisan/export/table_serializer.rb +36 -38
  7. data/lib/fontisan/pipeline/transformation_pipeline.rb +10 -8
  8. data/lib/fontisan/pipeline.rb +1 -1
  9. data/lib/fontisan/stitcher/partition_strategy/by_block.rb +1 -2
  10. data/lib/fontisan/tables/cff/standard_strings_data.rb +1 -2
  11. data/lib/fontisan/tables/glyf.rb +4 -6
  12. data/lib/fontisan/tables/gpos/glyph_filter.rb +87 -0
  13. data/lib/fontisan/tables/gpos/kerning_pairs.rb +215 -0
  14. data/lib/fontisan/tables/gpos/pair_position.rb +274 -0
  15. data/lib/fontisan/tables/gpos.rb +23 -0
  16. data/lib/fontisan/tables/post.rb +2 -4
  17. data/lib/fontisan/validators/opentype_validator.rb +13 -3
  18. data/lib/fontisan/variable/instancer.rb +22 -192
  19. data/lib/fontisan/variation/instance_generator.rb +3 -149
  20. data/lib/fontisan/variation/instancing/axis_space.rb +136 -0
  21. data/lib/fontisan/variation/instancing/glyph_outline.rb +204 -0
  22. data/lib/fontisan/variation/instancing/gvar_reader.rb +146 -0
  23. data/lib/fontisan/variation/instancing/item_variation_store.rb +92 -0
  24. data/lib/fontisan/variation/instancing/name_rewriter.rb +90 -0
  25. data/lib/fontisan/variation/instancing/true_type_instancer.rb +318 -0
  26. data/lib/fontisan/variation/instancing.rb +20 -0
  27. data/lib/fontisan/variation/variable_svg_generator.rb +19 -1
  28. data/lib/fontisan/variation.rb +1 -0
  29. data/lib/fontisan/version.rb +1 -1
  30. metadata +12 -3
  31. data/fontisan.gemspec +0 -48
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5809fbd27b8c1e2d11d559e90e45201748c35aee50106d6eda8e34f363c4ead7
4
- data.tar.gz: 897ed09e0131fc562d21b5796d1c4e263967277bbbbc038b1eac3ea4f10ffc9e
3
+ metadata.gz: 999790a5a5e57d822deee3a86f540b3dac9b8bef06c234c883cfaacb87e9f0b1
4
+ data.tar.gz: c376a3129b02bec87f5754656a66bef27695e0f445ef5740a60dc8a60013b71e
5
5
  SHA512:
6
- metadata.gz: 31e50b7dc96edb8ad675f5ad4843264708890a40b77586496a75ca61335dc46a5ab1e96b587d66e03512b5cca8b38a7d6d78804ea3d3520797b5b9cd740571c4
7
- data.tar.gz: 19d29274a8fd77b7fe5dc39dec2f2435f38ecfc16df1a00c560051afd5b42f14add68cf92f0873b3cb59c1d35647904917a99826ca5b10c1c4710871cacdae7f
6
+ metadata.gz: 481c4a053c4aece81d321e5bc6f76abb7633f6f2d987c901bcba4587dbed9a40462dd192a9f9cd71166114b0a5c0fc6bbc628e7cb71b1c043423cf321bca19ec
7
+ data.tar.gz: b367817e918c9ec6de0e346180c9e45f2a2afa73d4c223997357e7b2d38ed445ce04aff98b05da36e1d297d63a71c29728c0e8e9807a79c2567a2c47b9544645
data/Rakefile CHANGED
@@ -8,7 +8,7 @@ require "rubocop/rake_task"
8
8
 
9
9
  RuboCop::RakeTask.new
10
10
 
11
- # rubocop:disable Metrics/BlockLength
11
+ # rubocop:disable-next Metrics/BlockLength
12
12
  namespace :fixtures do
13
13
  # Load centralized fixture configuration and the test-only downloader.
14
14
  # The downloader lives under spec/support (not lib/) because it is
@@ -144,7 +144,6 @@ namespace :fixtures do
144
144
  end
145
145
  end
146
146
  end
147
- # rubocop:enable Metrics/BlockLength
148
147
 
149
148
  # RSpec task depends on fixtures
150
149
  RSpec::Core::RakeTask.new(spec: "fixtures:download")
data/lib/fontisan/cli.rb CHANGED
@@ -599,7 +599,7 @@ module Fontisan
599
599
 
600
600
  def export(font_file)
601
601
  command = Commands::ExportCommand.new(
602
- font_file,
602
+ input: font_file,
603
603
  output: options[:output],
604
604
  format: options[:format].to_sym,
605
605
  tables: options[:tables],
@@ -32,8 +32,8 @@ module Fontisan
32
32
  # @param pretty [Boolean] Pretty-print output
33
33
  def initialize(input:, output: nil, format: :yaml, tables: nil,
34
34
  binary_format: :hex, pretty: true)
35
- super()
36
35
  @input = input
36
+ super(input)
37
37
  @output = output
38
38
  @format = format.to_sym
39
39
  @tables = tables
@@ -47,8 +47,8 @@ module Fontisan
47
47
  def run
48
48
  validate_params!
49
49
 
50
- # Load font
51
- font = load_font
50
+ # BaseCommand loads the font during initialization
51
+ font = @font
52
52
  return 1 unless font
53
53
 
54
54
  # Create exporter
@@ -188,7 +188,7 @@ module Fontisan
188
188
  tag: tag,
189
189
  checksum: format_checksum(checksum),
190
190
  parsed: false,
191
- data: @serializer.encode_binary(binary_data),
191
+ data: @serializer.serialize_binary(binary_data, tag).fetch(:data),
192
192
  fields: { error: error.message }.to_json,
193
193
  )
194
194
  end
@@ -28,14 +28,6 @@ module Fontisan
28
28
  cvt fpgm prep gasp DSIG GDEF GPOS GSUB
29
29
  ].freeze
30
30
 
31
- # Tables that ship a summary alongside the raw binary.
32
- MIXED_SUMMARIES = {
33
- "glyf" => GlyphSummary,
34
- "loca" => LocaSummary,
35
- "cmap" => CmapSummary,
36
- "CFF" => CffSummary,
37
- }.freeze
38
-
39
31
  # @param binary_format [Symbol] :hex or :base64
40
32
  def initialize(binary_format: :hex)
41
33
  @binary_format = binary_format
@@ -62,6 +54,42 @@ module Fontisan
62
54
  { tag: tag, parsed: false, data: encode_binary(data), fields: nil }
63
55
  end
64
56
 
57
+ # Summary builders for mixed-content tables.
58
+ module GlyphSummary
59
+ def self.call(table)
60
+ { type: "glyf", num_glyphs: table.glyphs.length,
61
+ note: "Outline data stored as binary" }
62
+ end
63
+ end
64
+
65
+ module LocaSummary
66
+ def self.call(table)
67
+ { type: "loca", num_offsets: table.offsets.length,
68
+ format: table.format }
69
+ end
70
+ end
71
+
72
+ module CmapSummary
73
+ def self.call(table)
74
+ { type: "cmap", version: table.version,
75
+ note: "Character mappings stored as binary" }
76
+ end
77
+ end
78
+
79
+ module CffSummary
80
+ def self.call(_table)
81
+ { type: "CFF", note: "CharString data stored as binary" }
82
+ end
83
+ end
84
+
85
+ # Tables that ship a summary alongside the raw binary.
86
+ MIXED_SUMMARIES = {
87
+ "glyf" => GlyphSummary,
88
+ "loca" => LocaSummary,
89
+ "cmap" => CmapSummary,
90
+ "CFF" => CffSummary,
91
+ }.freeze
92
+
65
93
  private
66
94
 
67
95
  def fully_parsed?(tag)
@@ -107,36 +135,6 @@ module Fontisan
107
135
  "Invalid binary format: #{@binary_format}. " \
108
136
  "Must be one of: #{valid.join(', ')}"
109
137
  end
110
-
111
- # Summary builders for mixed-content tables. Each is a callable
112
- # that receives the typed table and returns a JSON-able Hash.
113
-
114
- module GlyphSummary
115
- def self.call(table)
116
- { type: "glyf", num_glyphs: table.glyphs.length,
117
- note: "Outline data stored as binary" }
118
- end
119
- end
120
-
121
- module LocaSummary
122
- def self.call(table)
123
- { type: "loca", num_offsets: table.offsets.length,
124
- format: table.format }
125
- end
126
- end
127
-
128
- module CmapSummary
129
- def self.call(table)
130
- { type: "cmap", version: table.version,
131
- note: "Character mappings stored as binary" }
132
- end
133
- end
134
-
135
- module CffSummary
136
- def self.call(_table)
137
- { type: "CFF", note: "CharString data stored as binary" }
138
- end
139
- end
140
138
  end
141
139
  end
142
140
  end
@@ -216,7 +216,7 @@ module Fontisan
216
216
  return tables if source_format == target
217
217
 
218
218
  # Use FormatConverter for outline conversion
219
- if needs_outline_conversion?(source_format, target) || target == :svg
219
+ if needs_outline_conversion?(tables, target) || target == :svg
220
220
  converter = Converters::FormatConverter.new
221
221
  # Create temporary font object from tables
222
222
  font = build_font_from_tables(tables, source_format)
@@ -229,16 +229,18 @@ module Fontisan
229
229
 
230
230
  # Check if outline conversion is needed
231
231
  #
232
- # @param source [Symbol] Source format
232
+ # TTF <-> OTF changes the outline format. WOFF/WOFF2 targets only
233
+ # repackage whichever outlines the font already has.
234
+ #
235
+ # @param tables [Hash] Font tables (their outlines decide the source)
233
236
  # @param target [Symbol] Target format
234
237
  # @return [Boolean] True if outline conversion needed
235
- def needs_outline_conversion?(source, target)
236
- # TTF ↔ OTF requires outline conversion
237
- ttf_formats = %i[ttf ttc woff woff2]
238
- otf_formats = %i[otf otc]
238
+ def needs_outline_conversion?(tables, target)
239
+ target_outline = { ttf: :glyf, ttc: :glyf, otf: :cff, otc: :cff }[target]
240
+ return false unless target_outline
239
241
 
240
- (ttf_formats.include?(source) && otf_formats.include?(target)) ||
241
- (otf_formats.include?(source) && ttf_formats.include?(target))
242
+ source_outline = tables.key?("glyf") ? :glyf : :cff
243
+ source_outline != target_outline
242
244
  end
243
245
 
244
246
  # Write output font file
@@ -6,7 +6,7 @@ module Fontisan
6
6
  module Pipeline
7
7
  autoload :FormatDetector, "fontisan/pipeline/format_detector"
8
8
  autoload :OutputWriter, "fontisan/pipeline/output_writer"
9
- autoload :Strategies, "fontisan/pipeline/strategies/base_strategy"
9
+ autoload :Strategies, "fontisan/pipeline/strategies"
10
10
  autoload :TransformationPipeline,
11
11
  "fontisan/pipeline/transformation_pipeline"
12
12
  autoload :VariationResolver, "fontisan/pipeline/variation_resolver"
@@ -33,7 +33,7 @@ module Fontisan
33
33
  # be data-driven, swap BLOCKS for a CSV/YAML loader behind
34
34
  # the same constant — callers don't care about the source
35
35
  # (OCP).
36
- # rubocop:disable Metrics/CollectionLiteralLength
36
+ # rubocop:disable-next Metrics/CollectionLiteralLength
37
37
  BLOCKS = {
38
38
  # BMP (Plane 0)
39
39
  "Basic_Latin" => 0x0000..0x007F,
@@ -375,7 +375,6 @@ module Fontisan
375
375
  "Tags" => 0xE0000..0xE007F,
376
376
  "Variation_Selectors_Supplement" => 0xE0100..0xE01EF,
377
377
  }.freeze
378
- # rubocop:enable Metrics/CollectionLiteralLength
379
378
 
380
379
  # @param cp_map [Hash{Integer=>Object}] codepoint → donor label
381
380
  # @param cap [Integer] max codepoints per partition
@@ -13,7 +13,7 @@ module Fontisan
13
13
  module StandardStrings
14
14
  # The 391 CFF standard strings are a fixed spec-defined data
15
15
  # table (Adobe TN 5176 Appendix A).
16
- # rubocop:disable Metrics/CollectionLiteralLength
16
+ # rubocop:disable-next Metrics/CollectionLiteralLength
17
17
  LIST = %w[
18
18
  .notdef space exclam quotedbl numbersign
19
19
  dollar percent ampersand quoteright
@@ -75,7 +75,6 @@ module Fontisan
75
75
  afii57518 afii57519 afii57520 afii57521 afii64237 afii64238
76
76
  exclamdouble uni204A uni2080 uni2081 uni2082 uni2083 uni2084
77
77
  ].freeze
78
- # rubocop:enable Metrics/CollectionLiteralLength
79
78
 
80
79
  COUNT = 391
81
80
  end
@@ -241,13 +241,11 @@ module Fontisan
241
241
  glyph = glyph_for(glyph_id, loca, head)
242
242
  return true if glyph.nil? # Empty glyphs are OK
243
243
 
244
- # Simple glyphs: contours should be >= 0
245
- # Compound glyphs: numberOfContours = -1
244
+ # Simple glyphs: contours must be >= 0. Compound glyphs always have
245
+ # numberOfContours = -1; CompoundGlyph.parse rejects anything else.
246
246
  case glyph
247
- when SimpleGlyph, CompoundGlyph
248
- glyph.num_contours >= -1
249
- else
250
- true
247
+ when SimpleGlyph then glyph.num_contours >= 0
248
+ else true
251
249
  end
252
250
  rescue StandardError
253
251
  false
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fontisan/tables/gpos/pair_position"
4
+
5
+ module Fontisan
6
+ module Tables
7
+ class Gpos
8
+ # Evaluates lookup flags against explicit GDEF classes for pair endpoints.
9
+ class GlyphFilter
10
+ def initialize(flags, gdef, mark_set:)
11
+ raise ParseError, "Reserved GPOS lookup flags" unless (flags & 0x00e0).zero?
12
+
13
+ @flags = flags
14
+ if (flags & 0xff1e).positive?
15
+ unless gdef
16
+ raise UnsupportedFormatError, "GPOS lookup filtering requires gdef: font.table_data['GDEF']"
17
+ end
18
+
19
+ @gdef = gdef
20
+ @header = header
21
+ @classes = definition(@header[1], "glyph classes")
22
+ raise ParseError, "Invalid GDEF glyph class" if @classes.maximum > 4
23
+
24
+ if (flags & 8).zero?
25
+ if (flags & 16).positive?
26
+ @mark_set = mark_set(mark_set)
27
+ elsif (flags >> 8).positive?
28
+ @mark_classes = definition(@header[2], "mark attachment classes")
29
+ end
30
+ end
31
+ end
32
+ freeze
33
+ end
34
+
35
+ def allows?(glyph)
36
+ return true unless @classes
37
+
38
+ glyph_class = @classes[glyph]
39
+ return false if glyph_class.between?(1, 3) && (@flags & (1 << glyph_class)).positive?
40
+ return true unless glyph_class == 3
41
+ return @mark_set.include?(glyph) if @mark_set
42
+ return @mark_classes[glyph] == (@flags >> 8) if @mark_classes
43
+
44
+ true
45
+ end
46
+
47
+ private
48
+
49
+ def header
50
+ @gdef.cached(:gdef_header) do
51
+ minor = @gdef.uint16(2)
52
+ unless @gdef.uint16(0) == 1 && [0, 2, 3].include?(minor)
53
+ raise ParseError, "Unsupported GDEF version"
54
+ end
55
+
56
+ length = { 0 => 12, 2 => 14, 3 => 18 }.fetch(minor)
57
+ @gdef.bytes(0, length)
58
+ [length, @gdef.uint16(4), @gdef.uint16(10), minor.zero? ? 0 : @gdef.uint16(12)].freeze
59
+ end
60
+ end
61
+
62
+ def definition(offset, name)
63
+ raise UnsupportedFormatError, "GPOS filtering requires GDEF #{name}" if offset.zero?
64
+
65
+ view = @gdef.child(offset, minimum: @header[0])
66
+ view.cached(:class_definition) { ClassDefinition.new(view) }
67
+ end
68
+
69
+ def mark_set(index)
70
+ raise UnsupportedFormatError, "GPOS filtering requires GDEF mark glyph sets" if @header[3].zero?
71
+
72
+ sets = @gdef.child(@header[3], minimum: @header[0])
73
+ raise ParseError, "Unsupported GDEF mark glyph sets format" unless sets.uint16(0) == 1
74
+
75
+ count = sets.uint16(2)
76
+ raise ParseError, "Invalid GDEF mark filtering set #{index}" if index >= count
77
+
78
+ sets.uint32(4 + (count - 1) * 4)
79
+ view = sets.child(sets.uint32(4 + index * 4), minimum: 4 + count * 4)
80
+ view.cached(:coverage) { Coverage.new(view) }
81
+ end
82
+ end
83
+
84
+ private_constant :GlyphFilter
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,215 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fontisan/tables/gpos/glyph_filter"
4
+
5
+ module Fontisan
6
+ module Tables
7
+ class Gpos
8
+ # Glyph-ID pair queries with explicit bounds for class-based enumeration.
9
+ class KerningPairs
10
+ include Enumerable
11
+
12
+ def initialize(table, script_tag:, feature_tag:, glyph_count:, gdef:)
13
+ if !glyph_count.nil? && (!glyph_count.is_a?(Integer) || !glyph_count.between?(0, 65536))
14
+ raise ArgumentError, "glyph_count must be an integer from 0 to 65536"
15
+ end
16
+
17
+ raise ArgumentError, "gdef must contain raw GDEF table bytes" unless gdef.nil? || gdef.is_a?(String)
18
+
19
+ @glyph_count = glyph_count
20
+ @view = BinaryView.new(table.raw_data.dup.freeze)
21
+ @gdef = BinaryView.new(gdef.dup.freeze) if gdef
22
+ @lookups = selected_lookups(table, script_tag, feature_tag).tally.freeze
23
+ @subtables = @lookups.keys.flat_map(&:subtables).uniq.freeze
24
+ @view.freeze
25
+ @gdef&.freeze
26
+ end
27
+
28
+ def [](pair)
29
+ unless pair.is_a?(Array) && pair.size == 2 && pair.all? { |gid| gid.is_a?(Integer) && gid.between?(0, 65535) }
30
+ raise ArgumentError, "Expected two glyph IDs from 0 to 65535"
31
+ end
32
+
33
+ @lookups.sum { |lookup, count| lookup.adjustment(*pair) * count }
34
+ end
35
+
36
+ def each
37
+ return enum_for(:each) unless block_given?
38
+
39
+ if @glyph_count.nil? && @subtables.any?(&:class_based?)
40
+ raise ArgumentError, "glyph_count is required to enumerate class-based kerning"
41
+ end
42
+
43
+ left_glyphs(@subtables).each do |left|
44
+ candidates(@subtables, left).each do |right|
45
+ pair = [left, right]
46
+ value = self[pair]
47
+ yield pair, value unless value.zero?
48
+ end
49
+ end
50
+ self
51
+ end
52
+
53
+ private
54
+
55
+ def candidates(subtables, left)
56
+ matching = subtables.select { |subtable| subtable.covers?(left) }
57
+ return (0...@glyph_count) if matching.any?(&:class_based?)
58
+
59
+ union(matching.map { |subtable| subtable.right_glyphs(left) })
60
+ end
61
+
62
+ def left_glyphs(subtables)
63
+ union(subtables.map(&:left_glyphs))
64
+ end
65
+
66
+ def union(sequences)
67
+ glyphs = {}
68
+ sequences.each do |sequence|
69
+ sequence.each do |glyph|
70
+ break if @glyph_count && glyph >= @glyph_count
71
+
72
+ glyphs[glyph] = true
73
+ end
74
+ end
75
+ glyphs.keys.sort
76
+ end
77
+
78
+ def selected_lookups(table, script_tag, feature_tag)
79
+ return [] if table.script_list_offset.zero? || table.feature_list_offset.zero?
80
+
81
+ indices = feature_indices(table.script_list_offset.to_i, script_tag)
82
+ return [] if indices.empty?
83
+
84
+ features = @view.child(table.feature_list_offset.to_i, minimum: 10)
85
+ list = features.record(LayoutCommon::FeatureList, header: 2, count_offset: 0, item_size: 6)
86
+ feature_offsets = indices.filter_map do |index|
87
+ raise ParseError, "Invalid GPOS feature index #{index}" if index >= list.feature_count
88
+
89
+ record = list.feature_records[index]
90
+ record.feature_offset.to_i if record.feature_tag == feature_tag
91
+ end
92
+ lookup_indices = feature_lookup_indices(features, feature_offsets.uniq,
93
+ 2 + list.feature_count.to_i * 6,
94
+ table.lookup_list_offset.to_i)
95
+ return [] if lookup_indices.empty?
96
+
97
+ lookups(table.lookup_list_offset.to_i, lookup_indices)
98
+ end
99
+
100
+ def feature_lookup_indices(features, offsets, minimum, lookup_list_offset)
101
+ ranges = feature_ranges(features, offsets, minimum)
102
+ return [] if ranges.empty?
103
+
104
+ lookup_count = @view.child(lookup_list_offset, minimum: 10).uint16(0)
105
+ indices = {}
106
+ ranges.group_by { |first, _last| first % 2 }.each_value do |spans|
107
+ previous = -1
108
+ spans.sort.each do |first, last|
109
+ [first, previous + 2].max.step(last, 2) do |position|
110
+ lookup_index = features.uint16(position)
111
+ raise ParseError, "Invalid GPOS lookup index #{lookup_index}" if lookup_index >= lookup_count
112
+
113
+ indices[lookup_index] = true
114
+ end
115
+ previous = [previous, last].max
116
+ end
117
+ end
118
+ indices.keys.sort
119
+ end
120
+
121
+ def feature_ranges(features, offsets, minimum)
122
+ offsets.filter_map do |offset|
123
+ feature = features.child(offset, minimum: minimum)
124
+ count = feature.uint16(2)
125
+ feature.bytes(2 + count * 2, 2)
126
+ [offset + 4, offset + 2 + count * 2] unless count.zero?
127
+ end
128
+ end
129
+
130
+ def feature_indices(offset, script_tag)
131
+ scripts = @view.child(offset, minimum: 10)
132
+ list = scripts.record(LayoutCommon::ScriptList, header: 2, count_offset: 0, item_size: 6)
133
+ record = list.script_records.find { |item| item.script_tag == script_tag }
134
+ return [] unless record
135
+
136
+ script = scripts.child(record.script_offset.to_i, minimum: 2 + list.script_count.to_i * 6)
137
+ header = script.record(LayoutCommon::Script, header: 4, count_offset: 2, item_size: 6)
138
+ return [] if header.default_lang_sys_offset.zero?
139
+
140
+ lang = script.child(header.default_lang_sys_offset.to_i, minimum: 4 + header.lang_sys_count.to_i * 6)
141
+ record = lang.record(LayoutCommon::LangSys, header: 6, count_offset: 4, item_size: 2)
142
+ indices = record.feature_indices.map(&:to_i)
143
+ indices << record.required_feature_index.to_i unless record.required_feature_index == 0xffff
144
+ indices.uniq
145
+ end
146
+
147
+ def extension_subtables(views)
148
+ extensions = views.map do |view|
149
+ view.cached(:extension_position) do
150
+ raise ParseError, "Unsupported GPOS extension format" unless view.uint16(0) == 1
151
+
152
+ type = view.uint16(2)
153
+ raise ParseError, "Invalid GPOS extension lookup type #{type}" unless type.between?(1, 8)
154
+
155
+ [type, view.child(view.uint32(4), minimum: 8)].freeze
156
+ end
157
+ end
158
+ if extensions.map(&:first).uniq.size > 1
159
+ raise ParseError, "Inconsistent GPOS extension lookup types"
160
+ end
161
+
162
+ extensions.filter_map { |type, view| view if type == 2 }
163
+ end
164
+
165
+ def lookups(offset, indices)
166
+ list = @view.child(offset, minimum: 10)
167
+ count = list.uint16(0)
168
+ offsets = list.array(2, count)
169
+ indices.filter_map do |index|
170
+ lookup = list.child(offsets[index], minimum: 2 + count * 2)
171
+ type = lookup.uint16(0)
172
+ next unless [2, 9].include?(type)
173
+
174
+ lookup.cached(:pair_lookup) do
175
+ subtable_count = lookup.uint16(4)
176
+ subtable_offsets = lookup.array(6, subtable_count)
177
+ header = 6 + subtable_count * 2
178
+ header += 2 unless (lookup.uint16(2) & 16).zero?
179
+ lookup.bytes(0, header)
180
+ views = subtable_offsets.uniq.map { |subtable_offset| lookup.child(subtable_offset, minimum: header) }
181
+ views = extension_subtables(views) if type == 9
182
+ subtables = views.map { |view| view.cached(:pair_position) { PairPosition.new(view) } }.freeze
183
+ flags = subtables.empty? ? 0 : lookup.uint16(2)
184
+ mark_set = (flags & 16).zero? ? 0 : lookup.uint16(6 + subtable_count * 2)
185
+ PairLookup.new(subtables, GlyphFilter.new(flags, @gdef, mark_set: mark_set))
186
+ end
187
+ end
188
+ end
189
+ end
190
+
191
+ # One physical lookup can be referenced by multiple logical lookup indices.
192
+ class PairLookup
193
+ attr_reader :subtables
194
+
195
+ def initialize(subtables, filter)
196
+ @subtables = subtables
197
+ @filter = filter
198
+ freeze
199
+ end
200
+
201
+ def adjustment(left, right)
202
+ return 0 unless @filter.allows?(left) && @filter.allows?(right)
203
+
204
+ subtables.each do |subtable|
205
+ value = subtable.adjustment(left, right)
206
+ return value unless value.nil?
207
+ end
208
+ 0
209
+ end
210
+ end
211
+
212
+ private_constant :KerningPairs, :PairLookup
213
+ end
214
+ end
215
+ end