fontisan 0.4.44 → 0.4.46

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7d195924806ae6e5a9ca3f0796493ec611303a30f39aeed2293829a6d3317a06
4
- data.tar.gz: 96183528b1693ff11796622f2df05541a371606cfaddd8bc8ae5fccacf42b488
3
+ metadata.gz: 1866983941321f8342976513ef0fb988454641cd88e677c9f8ea5c5811fe4368
4
+ data.tar.gz: 959ca2b7cec09514b960b1f9c649ca9b6f3d9716dfd87c31dfa8155ae54d6ac2
5
5
  SHA512:
6
- metadata.gz: 0c991caf6890e43dd5d95c47fe187421f536b4dc14c70f1a2c035ab254212356a4f85886e118832e3586721726fc69411b71739654fcf017ecaa9d09e5a2aa7a
7
- data.tar.gz: dd7c245e25cc82d4024a49404938c624462a0b8d1593764c5c2c5bdb0555da052ce7bb436079d6959eaf17359f601034bf0c4c46b064b8be6cb419abc941829a
6
+ metadata.gz: 8515b8d576d88acd19737ccfb14fc5aea468c7ac94b1242beb09757a6a45c11f9846caf8e64df1558ccd472f31a74ceee3fae415471861c8fe0b0520802d86fd
7
+ data.tar.gz: b025f9cb74a3a4059367d33453113838fbde355ff434f51879a97d19a134e5753d640b6b2460741d3999a69b79ce16e5bc78db3e5e6e929e85974a77e5cebca2
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")
@@ -0,0 +1,36 @@
1
+ # 30 — Eliminate .send(:private_method) calls in specs
2
+
3
+ ## Priority
4
+ P1
5
+
6
+ ## Problem
7
+ 288 `.send(:method_name, ...)` calls in spec files bypass
8
+ encapsulation to test private methods directly. This violates the
9
+ "never use private send methods" rule.
10
+
11
+ ## Approach
12
+ For each method tested via `.send()`:
13
+ 1. Make it public (move above `private` keyword)
14
+ 2. Replace `.send(:method, args)` with `.method(args)` in specs
15
+
16
+ Rationale: if a method is important enough to test directly,
17
+ it should be part of the public API.
18
+
19
+ ## Top files by send count
20
+ - type1_converter_spec.rb (66)
21
+ - cff2/table_builder_spec.rb (34)
22
+ - format_converter_spec.rb (17)
23
+ - type1_property_spec.rb (13)
24
+ - outline_converter_spec.rb (11)
25
+ - conversion_options_spec.rb (10)
26
+ - info_command_spec.rb (10)
27
+ - base_command_spec.rb (10)
28
+ - pattern_analyzer_spec.rb (10)
29
+ - variation/converter_spec.rb (10)
30
+ - woff2_font_spec.rb (10)
31
+ - Plus ~15 more files
32
+
33
+ ## Acceptance criteria
34
+ - 0 `.send(:` calls in spec/
35
+ - All tested methods are public
36
+ - All specs pass
@@ -45,5 +45,8 @@ Each file is `NN-short-name.md` where `NN` is the priority order.
45
45
 
46
46
  ### P3 — Architecture polish (post-#137 audit)
47
47
  - [x] ~~[27 — Table class registry (OCP: eliminate case/when tag dispatch)](27-table-class-registry.md)~~ ✓ Done (#138)
48
- - [ ] [28 — Spec doubles cleanup](28-spec-doubles-cleanup.md)
49
- - [x] ~~[29 — Strategy registries for per-tag dispatch](29-strategy-registries.md)~~ ✓ Done
48
+ - [x] ~~[28 — Spec doubles cleanup](28-spec-doubles-cleanup.md)~~ ✓ Done (#140-#145)
49
+ - [x] ~~[29 — Strategy registries for per-tag dispatch](29-strategy-registries.md)~~ ✓ Done (#139)
50
+
51
+ ### P3 — Encapsulation completion (post-#146 audit)
52
+ - [ ] [30 — Eliminate .send(:private_method) calls in specs](30-eliminate-send-in-specs.md)
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],
@@ -50,7 +50,7 @@ module Fontisan
50
50
  # @return [Hash] Command options
51
51
  attr_reader :font_path, :font, :options
52
52
 
53
- private
53
+ # Methods below were private; made public for testability (TODO 30)
54
54
 
55
55
  # Load the font using FontLoader.
56
56
  #
@@ -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
@@ -37,7 +37,7 @@ module Fontisan
37
37
  end
38
38
  end
39
39
 
40
- private
40
+ # Methods below were private; made public for testability (TODO 30)
41
41
 
42
42
  # Get collection information
43
43
  #
@@ -150,7 +150,7 @@ module Fontisan
150
150
  PRESETS.keys
151
151
  end
152
152
 
153
- private
153
+ # Methods below were private; made public for testability (TODO 30)
154
154
 
155
155
  # Normalize format symbol
156
156
  #
@@ -236,7 +236,7 @@ module Fontisan
236
236
  end
237
237
  end
238
238
 
239
- private
239
+ # Methods below were private; made public for testability (TODO 30)
240
240
 
241
241
  # Convert variable font to SVG at specific coordinates
242
242
  #
@@ -126,7 +126,7 @@ module Fontisan
126
126
  true
127
127
  end
128
128
 
129
- private
129
+ # Methods below were private; made public for testability (TODO 30)
130
130
 
131
131
  # Extract ConversionOptions from options hash
132
132
  #
@@ -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
@@ -120,7 +120,7 @@ module Fontisan
120
120
  result
121
121
  end
122
122
 
123
- private
123
+ # Methods below were private; made public for testability (TODO 30)
124
124
 
125
125
  # Convert a single hint to PostScript format
126
126
  #
@@ -83,7 +83,7 @@ module Fontisan
83
83
  @patterns.values.sort_by { |p| -p.savings }
84
84
  end
85
85
 
86
- private
86
+ # Methods below were private; made public for testability (TODO 30)
87
87
 
88
88
  # Find operator boundaries in CharString
89
89
  # Returns positions where operators end, which are valid pattern boundaries
@@ -184,8 +184,8 @@ module Fontisan
184
184
  io.read(data_length)
185
185
  end
186
186
 
187
- private_class_method :parse_header, :find_sfnt_resources,
188
- :extract_resource_data
187
+ # parse_header, find_sfnt_resources, extract_resource_data
188
+ # were private_class_method; made public for testability (TODO 30)
189
189
  end
190
190
  end
191
191
  end
@@ -105,7 +105,7 @@ module Fontisan
105
105
  handle_error(e)
106
106
  end
107
107
 
108
- private
108
+ # Methods below were private; made public for testability (TODO 30)
109
109
 
110
110
  # Detect input format and capabilities
111
111
  #
@@ -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
@@ -86,7 +86,7 @@ module Fontisan
86
86
  !@variable_store.nil?
87
87
  end
88
88
 
89
- private
89
+ # Methods below were private; made public for testability (TODO 30)
90
90
 
91
91
  # Extract number of variation axes from Variable Store
92
92
  #
@@ -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
@@ -0,0 +1,274 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fontisan"
4
+
5
+ module Fontisan
6
+ module Tables
7
+ class Gpos
8
+ # Checked views share physical identity without copying table suffixes.
9
+ class BinaryView
10
+ def initialize(data, offset: 0, cache: {})
11
+ @data = data
12
+ @offset = offset
13
+ @cache = cache
14
+ end
15
+
16
+ def bytes(offset, length)
17
+ if offset.negative? || length.negative? || @offset + offset + length > @data.bytesize
18
+ raise ParseError, "Truncated GPOS data at byte #{offset}"
19
+ end
20
+
21
+ @data.byteslice(@offset + offset, length)
22
+ end
23
+
24
+ def uint16(offset)
25
+ bytes(offset, 2).unpack1("n")
26
+ end
27
+
28
+ def uint32(offset)
29
+ bytes(offset, 4).unpack1("N")
30
+ end
31
+
32
+ def array(offset, count)
33
+ bytes(offset, count * 2).unpack("n*")
34
+ end
35
+
36
+ def child(offset, minimum:)
37
+ raise ParseError, "Invalid GPOS offset #{offset}" if offset < minimum
38
+
39
+ bytes(offset, 1)
40
+ self.class.new(@data, offset: @offset + offset, cache: @cache)
41
+ end
42
+
43
+ def record(type, header:, count_offset:, item_size:)
44
+ bytes(0, header + uint16(count_offset) * item_size)
45
+ type.read(@data.byteslice(@offset..))
46
+ end
47
+
48
+ def cached(context)
49
+ key = [@offset, context]
50
+ @cache.fetch(key) { @cache[key] = yield }
51
+ end
52
+
53
+ def freeze
54
+ @cache.freeze
55
+ super
56
+ end
57
+ end
58
+
59
+ # Coverage ranges remain compact, including the full glyph-ID domain.
60
+ class Coverage
61
+ include Enumerable
62
+
63
+ attr_reader :size
64
+
65
+ def initialize(view)
66
+ format = view.uint16(0)
67
+ count = view.uint16(2)
68
+ case format
69
+ when 1
70
+ @glyphs = view.array(4, count).freeze
71
+ unless @glyphs.each_cons(2).all? { |first, second| first < second }
72
+ raise ParseError, "Unsorted or duplicate GPOS glyph IDs"
73
+ end
74
+
75
+ @size = count
76
+ when 2
77
+ @ranges = view.array(4, count * 3).each_slice(3).map(&:freeze).freeze
78
+ @size = 0
79
+ previous = -1
80
+ @ranges.each do |first, last, index|
81
+ if first > last || index != @size || first <= previous
82
+ raise ParseError, "Invalid GPOS coverage range"
83
+ end
84
+
85
+ @size += last - first + 1
86
+ previous = last
87
+ end
88
+ else
89
+ raise ParseError, "Unsupported Coverage format #{format}"
90
+ end
91
+ freeze
92
+ end
93
+
94
+ def include?(glyph)
95
+ return @glyphs.bsearch { |candidate| candidate >= glyph } == glyph if @glyphs
96
+
97
+ record = @ranges.bsearch { |_first, last, _index| last >= glyph }
98
+ record && record[0] <= glyph
99
+ end
100
+
101
+ def each(&block)
102
+ return enum_for(:each) unless block
103
+
104
+ if @glyphs
105
+ @glyphs.each(&block)
106
+ else
107
+ @ranges.each do |first, last, _index|
108
+ (first..last).each(&block)
109
+ end
110
+ end
111
+ self
112
+ end
113
+ end
114
+
115
+ # Class arrays support direct glyph indexing without singleton ranges.
116
+ class ClassDefinition
117
+ attr_reader :maximum
118
+
119
+ def initialize(view)
120
+ @view = view
121
+ case view.uint16(0)
122
+ when 1
123
+ @first = view.uint16(2)
124
+ @count = view.uint16(4)
125
+ raise ParseError, "Invalid GPOS class definition" if @first + @count > 65536
126
+
127
+ @maximum = view.array(6, @count).max || 0
128
+ when 2
129
+ @ranges = view.array(4, view.uint16(2) * 3).each_slice(3).map(&:freeze).freeze
130
+ previous = -1
131
+ @maximum = 0
132
+ @ranges.each do |first, last, value|
133
+ if first <= previous || first > last
134
+ raise ParseError, "Invalid GPOS class definition"
135
+ end
136
+
137
+ @maximum = [@maximum, value].max
138
+ previous = last
139
+ end
140
+ else
141
+ raise ParseError, "Unsupported ClassDef format #{view.uint16(0)}"
142
+ end
143
+ freeze
144
+ end
145
+
146
+ def [](glyph)
147
+ if @ranges
148
+ record = @ranges.bsearch { |_first, last, _value| last >= glyph }
149
+ record && record[0] <= glyph ? record[2] : 0
150
+ else
151
+ index = glyph - @first
152
+ index.between?(0, @count - 1) ? @view.uint16(6 + index * 2) : 0
153
+ end
154
+ end
155
+ end
156
+
157
+ # Compact PairPos data; class matrices stay in their binary representation.
158
+ class PairPosition
159
+ attr_reader :left_glyphs
160
+
161
+ def initialize(view)
162
+ @view = view
163
+ @format = view.uint16(0)
164
+ raise ParseError, "Unsupported PairPos format #{@format}" unless [1, 2].include?(@format)
165
+
166
+ @first_format = view.uint16(4)
167
+ @second_format = view.uint16(6)
168
+ @first_size = value_size(@first_format)
169
+ @record_size = @first_size + value_size(@second_format)
170
+ @advance_offset = value_size(@first_format & 3)
171
+ @coverage = coverage
172
+ @left_glyphs = @coverage
173
+ @format == 1 ? parse_pairs : parse_classes
174
+ freeze
175
+ end
176
+
177
+ def class_based?
178
+ @format == 2
179
+ end
180
+
181
+ def covers?(glyph)
182
+ @coverage.include?(glyph)
183
+ end
184
+
185
+ def adjustment(left, right)
186
+ return unless covers?(left)
187
+ return @pairs.fetch(left)[right] unless class_based?
188
+
189
+ row = @first_classes[left]
190
+ column = @second_classes[right]
191
+ advance(@matrix, (row * @second_count + column) * @record_size)
192
+ end
193
+
194
+ def right_glyphs(left)
195
+ @pairs.fetch(left).each_key
196
+ end
197
+
198
+ private
199
+
200
+ def value_size(format)
201
+ raise ParseError, "Reserved GPOS ValueFormat bits" unless (format & 0xff00).zero?
202
+
203
+ format.digits(2).sum * 2
204
+ end
205
+
206
+ def advance(view, offset)
207
+ return 0 if (@first_format & 4).zero?
208
+
209
+ value = view.uint16(offset + @advance_offset)
210
+ value >= 0x8000 ? value - 0x10000 : value
211
+ end
212
+
213
+ def coverage
214
+ minimum = @format == 1 ? 10 + @view.uint16(8) * 2 : 16 + matrix_size
215
+ view = @view.child(@view.uint16(2), minimum: minimum)
216
+ view.cached(:coverage) { Coverage.new(view) }
217
+ end
218
+
219
+ def parse_pairs
220
+ count = @view.uint16(8)
221
+ raise ParseError, "GPOS PairSet count differs from coverage" unless count == left_glyphs.size
222
+
223
+ offsets = @view.array(10, count)
224
+ @pairs = {}
225
+ left_glyphs.each_with_index do |left, index|
226
+ view = @view.child(offsets[index], minimum: 10 + count * 2)
227
+ @pairs[left] = view.cached([:pair_set, @first_format, @second_format]) { pair_set(view) }
228
+ end
229
+ @pairs.freeze
230
+ end
231
+
232
+ def pair_set(view)
233
+ count = view.uint16(0)
234
+ view.bytes(2, count * (2 + @record_size))
235
+ previous = -1
236
+ pairs = {}
237
+ count.times do |index|
238
+ start = 2 + index * (2 + @record_size)
239
+ glyph = view.uint16(start)
240
+ raise ParseError, "Unsorted or duplicate GPOS glyph IDs" if glyph <= previous
241
+
242
+ pairs[glyph] = advance(view, start + 2)
243
+ previous = glyph
244
+ end
245
+ pairs.freeze
246
+ end
247
+
248
+ def matrix_size
249
+ @view.uint16(12) * @view.uint16(14) * @record_size
250
+ end
251
+
252
+ def parse_classes
253
+ first_count = @view.uint16(12)
254
+ @second_count = @view.uint16(14)
255
+ raise ParseError, "Empty GPOS class matrix" if first_count.zero? || @second_count.zero?
256
+
257
+ @matrix = BinaryView.new(@view.bytes(16, matrix_size).freeze).freeze
258
+ @first_classes = class_definition(@view.uint16(8), first_count)
259
+ @second_classes = class_definition(@view.uint16(10), @second_count)
260
+ end
261
+
262
+ def class_definition(offset, class_count)
263
+ view = @view.child(offset, minimum: 16 + matrix_size)
264
+ definition = view.cached(:class_definition) { ClassDefinition.new(view) }
265
+ raise ParseError, "Invalid GPOS class definition" if definition.maximum >= class_count
266
+
267
+ definition
268
+ end
269
+ end
270
+
271
+ private_constant :BinaryView, :Coverage, :ClassDefinition, :PairPosition
272
+ end
273
+ end
274
+ end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "fontisan"
4
+
3
5
  module Fontisan
4
6
  module Tables
5
7
  # GPOS (Glyph Positioning) table parser
@@ -55,6 +57,25 @@ module Fontisan
55
57
  []
56
58
  end
57
59
 
60
+ # Read PairPos value1.xAdvance in font units, directly or via extensions.
61
+ # The returned Enumerable supports pairs[[left_glyph_id, right_glyph_id]];
62
+ # unmatched pairs return zero. Use glyph_count: font.table("maxp").num_glyphs.to_i
63
+ # to enumerate class-based pairs, including implicit class zero, with to_h.
64
+ # Selects the script's default language system, with no script fallback.
65
+ # First matching subtables take precedence; distinct lookups are summed.
66
+ # Supports PairPos formats 1 and 2 in lookup types 2 and 9. Pass raw GDEF
67
+ # bytes with gdef: font.table_data["GDEF"] for glyph-filtering lookups.
68
+ # Lazy SFNT fonts can use font.sfnt_table("GDEF").load_data!.data instead.
69
+ # Filters apply to supplied pair endpoints; this does not shape a stream.
70
+ # Other lookup types, device corrections and variations are not applied.
71
+ # @return [Enumerable] nonzero glyph-ID pairs and signed advances
72
+ # @raise [ParseError] malformed selected positioning data
73
+ # @raise [UnsupportedFormatError] selected filtering requires missing GDEF data
74
+ def kern_pairs(script_tag: "latn", feature_tag: "kern", glyph_count: nil, gdef: nil)
75
+ KerningPairs.new(self, script_tag: script_tag, feature_tag: feature_tag,
76
+ glyph_count: glyph_count, gdef: gdef)
77
+ end
78
+
58
79
  private
59
80
 
60
81
  # Get feature indices for a given script
@@ -107,3 +128,5 @@ module Fontisan
107
128
  end
108
129
  end
109
130
  end
131
+
132
+ require "fontisan/tables/gpos/kerning_pairs"
@@ -14,7 +14,7 @@ module Fontisan
14
14
 
15
15
  register_tag "post"
16
16
  # Standard Mac glyph names for version 1.0 (258 glyphs)
17
- # rubocop:disable Metrics/CollectionLiteralLength
17
+ # rubocop:disable-next Metrics/CollectionLiteralLength
18
18
  STANDARD_NAMES = %w[
19
19
  .notdef .null nonmarkingreturn space exclam quotedbl numbersign
20
20
  dollar percent ampersand quotesingle parenleft parenright asterisk
@@ -48,7 +48,6 @@ module Fontisan
48
48
  Gbreve gbreve Idotaccent Scedilla scedilla Cacute cacute Ccaron
49
49
  ccaron dcroat
50
50
  ].freeze
51
- # rubocop:enable Metrics/CollectionLiteralLength
52
51
 
53
52
  # Version 2.0 as Fixed 16.16 constant
54
53
  VERSION_2_0_RAW = 131_072 # 2.0 * 65536
@@ -99,7 +98,7 @@ module Fontisan
99
98
  #
100
99
  # Version 2.0 uses a combination of standard Mac names (indices 0-257)
101
100
  # and custom names (indices >= 258) stored as Pascal strings.
102
- # rubocop:disable Metrics/PerceivedComplexity
101
+ # rubocop:disable-next Metrics/PerceivedComplexity
103
102
  def parse_version_2_names
104
103
  return [] unless version_raw == VERSION_2_0_RAW
105
104
  return [] if remaining_data.empty?
@@ -145,7 +144,6 @@ module Fontisan
145
144
  end
146
145
  end
147
146
  end
148
- # rubocop:enable Metrics/PerceivedComplexity
149
147
 
150
148
  public
151
149
 
@@ -170,7 +170,7 @@ module Fontisan
170
170
  !parser.seac_components.nil?
171
171
  end
172
172
 
173
- private
173
+ # Methods below were private; made public for testability (TODO 30)
174
174
 
175
175
  # Encode number in CFF format
176
176
  #
@@ -124,7 +124,7 @@ module Fontisan
124
124
  )
125
125
  end
126
126
 
127
- private
127
+ # Methods below were private; made public for testability (TODO 30)
128
128
 
129
129
  # Convert blend data from a glyph to tuple format
130
130
  #
@@ -118,7 +118,7 @@ module Fontisan
118
118
  true
119
119
  end
120
120
 
121
- private
121
+ # Methods below were private; made public for testability (TODO 30)
122
122
 
123
123
  # Extract regions from ItemVariationStore
124
124
  #
@@ -213,8 +213,6 @@ module Fontisan
213
213
  end
214
214
  end
215
215
 
216
- public
217
-
218
216
  # Maps MVAR metric tags to [table_tag, method] for base value lookup.
219
217
  METRIC_SOURCES = {
220
218
  "hasc" => ["hhea", :ascender],
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Fontisan
4
- VERSION = "0.4.44"
4
+ VERSION = "0.4.46"
5
5
  end
@@ -55,7 +55,7 @@ module Fontisan
55
55
  TRANSFORM_VERSIONS.fetch(tag, TRANSFORM_NONE)
56
56
  end
57
57
 
58
- private
58
+ # Methods below were private; made public for testability (TODO 30)
59
59
 
60
60
  def tag_index
61
61
  @flags & 0x3F
@@ -726,7 +726,7 @@ module Fontisan
726
726
  sfnt_data[head_offset + 8, 4] = [adjustment].pack("N")
727
727
  end
728
728
 
729
- private
729
+ # Methods below were private; made public for testability (TODO 30)
730
730
 
731
731
  # Read variable-length UIntBase128 integer from IO
732
732
  def read_uint_base128(io)
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: fontisan
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.44
4
+ version: 0.4.46
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-07-18 00:00:00.000000000 Z
11
+ date: 2026-10-15 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: base64
@@ -162,6 +162,7 @@ files:
162
162
  - TODO.bug-fixes/27-table-class-registry.md
163
163
  - TODO.bug-fixes/28-spec-doubles-cleanup.md
164
164
  - TODO.bug-fixes/29-strategy-registries.md
165
+ - TODO.bug-fixes/30-eliminate-send-in-specs.md
165
166
  - TODO.bug-fixes/README.md
166
167
  - TODO.improvements/01-cbdt-cblc-gid-stable-propagation.md
167
168
  - TODO.improvements/02-collection-outline-priority.md
@@ -321,7 +322,6 @@ files:
321
322
  - docs/public/web-app-manifest-512x512.png
322
323
  - docs/scripts/post-build.mjs
323
324
  - exe/fontisan
324
- - fontisan.gemspec
325
325
  - lib/fontisan.rb
326
326
  - lib/fontisan/audit.rb
327
327
  - lib/fontisan/audit/check.rb
@@ -643,6 +643,9 @@ files:
643
643
  - lib/fontisan/tables/glyf/simple_glyph.rb
644
644
  - lib/fontisan/tables/glyf_table.rb
645
645
  - lib/fontisan/tables/gpos.rb
646
+ - lib/fontisan/tables/gpos/glyph_filter.rb
647
+ - lib/fontisan/tables/gpos/kerning_pairs.rb
648
+ - lib/fontisan/tables/gpos/pair_position.rb
646
649
  - lib/fontisan/tables/gsub.rb
647
650
  - lib/fontisan/tables/gvar.rb
648
651
  - lib/fontisan/tables/head.rb
data/fontisan.gemspec DELETED
@@ -1,48 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "lib/fontisan/version"
4
-
5
- Gem::Specification.new do |spec|
6
- spec.name = "fontisan"
7
- spec.version = Fontisan::VERSION
8
- spec.authors = ["Ribose Inc."]
9
- spec.email = ["open.source@ribose.com"]
10
-
11
- spec.summary = "Font analysis tools and utilities for OpenType fonts"
12
- spec.description = <<~HEREDOC
13
- Fontisan provides font analysis tools and utilities. It is
14
- designed as a pure Ruby implementation with full object-oriented architecture,
15
- supporting extraction of information from OpenType and TrueType fonts (OTF, TTF, OTC, TTC).
16
-
17
- The gem provides both a Ruby library API and a command-line interface,
18
- with structured output formats (YAML, JSON, text).
19
- HEREDOC
20
-
21
- spec.homepage = "https://github.com/fontist/fontisan"
22
- spec.license = "BSD-2-Clause"
23
- spec.required_ruby_version = ">= 3.0.0"
24
-
25
- spec.metadata["homepage_uri"] = spec.homepage
26
- spec.metadata["source_code_uri"] = "https://github.com/fontist/fontisan"
27
- spec.metadata["changelog_uri"] = "https://github.com/fontist/fontisan/blob/main/CHANGELOG.md"
28
- spec.metadata["rubygems_mfa_required"] = "true"
29
-
30
- # Specify which files should be added to the gem when it is released.
31
- # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
32
- spec.files = Dir.chdir(__dir__) do
33
- `git ls-files -z`.split("\x0").reject do |f|
34
- (f == __FILE__) || f.match(%r{\A(?:(?:bin|test|spec|features)/|\.(?:git|travis|circleci)|appveyor)})
35
- end
36
- end
37
- spec.bindir = "exe"
38
- spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
39
- spec.require_paths = ["lib"]
40
-
41
- spec.add_dependency "base64"
42
- spec.add_dependency "bindata", "~> 2.5"
43
- spec.add_dependency "brotli", "~> 0.5"
44
- spec.add_dependency "logger"
45
- spec.add_dependency "lutaml-model", "~> 0.8"
46
- spec.add_dependency "nokogiri", "~> 1.16"
47
- spec.add_dependency "thor", "~> 1.3"
48
- end