fontisan 0.4.45 → 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 +4 -4
- data/Rakefile +1 -2
- data/lib/fontisan/cli.rb +1 -1
- data/lib/fontisan/commands/export_command.rb +3 -3
- data/lib/fontisan/export/exporter.rb +1 -1
- data/lib/fontisan/export/table_serializer.rb +36 -38
- data/lib/fontisan/stitcher/partition_strategy/by_block.rb +1 -2
- data/lib/fontisan/tables/cff/standard_strings_data.rb +1 -2
- data/lib/fontisan/tables/gpos/glyph_filter.rb +87 -0
- data/lib/fontisan/tables/gpos/kerning_pairs.rb +215 -0
- data/lib/fontisan/tables/gpos/pair_position.rb +274 -0
- data/lib/fontisan/tables/gpos.rb +23 -0
- data/lib/fontisan/tables/post.rb +2 -4
- data/lib/fontisan/version.rb +1 -1
- metadata +5 -3
- data/fontisan.gemspec +0 -48
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1866983941321f8342976513ef0fb988454641cd88e677c9f8ea5c5811fe4368
|
|
4
|
+
data.tar.gz: 959ca2b7cec09514b960b1f9c649ca9b6f3d9716dfd87c31dfa8155ae54d6ac2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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")
|
data/lib/fontisan/cli.rb
CHANGED
|
@@ -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
|
-
#
|
|
51
|
-
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.
|
|
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
|
|
@@ -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
|
|
@@ -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
|
data/lib/fontisan/tables/gpos.rb
CHANGED
|
@@ -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"
|
data/lib/fontisan/tables/post.rb
CHANGED
|
@@ -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
|
|
data/lib/fontisan/version.rb
CHANGED
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.
|
|
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-
|
|
11
|
+
date: 2026-10-15 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: base64
|
|
@@ -322,7 +322,6 @@ files:
|
|
|
322
322
|
- docs/public/web-app-manifest-512x512.png
|
|
323
323
|
- docs/scripts/post-build.mjs
|
|
324
324
|
- exe/fontisan
|
|
325
|
-
- fontisan.gemspec
|
|
326
325
|
- lib/fontisan.rb
|
|
327
326
|
- lib/fontisan/audit.rb
|
|
328
327
|
- lib/fontisan/audit/check.rb
|
|
@@ -644,6 +643,9 @@ files:
|
|
|
644
643
|
- lib/fontisan/tables/glyf/simple_glyph.rb
|
|
645
644
|
- lib/fontisan/tables/glyf_table.rb
|
|
646
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
|
|
647
649
|
- lib/fontisan/tables/gsub.rb
|
|
648
650
|
- lib/fontisan/tables/gvar.rb
|
|
649
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
|