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.
- 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/pipeline/transformation_pipeline.rb +10 -8
- data/lib/fontisan/pipeline.rb +1 -1
- 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/glyf.rb +4 -6
- 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/validators/opentype_validator.rb +13 -3
- data/lib/fontisan/variable/instancer.rb +22 -192
- data/lib/fontisan/variation/instance_generator.rb +3 -149
- data/lib/fontisan/variation/instancing/axis_space.rb +136 -0
- data/lib/fontisan/variation/instancing/glyph_outline.rb +204 -0
- data/lib/fontisan/variation/instancing/gvar_reader.rb +146 -0
- data/lib/fontisan/variation/instancing/item_variation_store.rb +92 -0
- data/lib/fontisan/variation/instancing/name_rewriter.rb +90 -0
- data/lib/fontisan/variation/instancing/true_type_instancer.rb +318 -0
- data/lib/fontisan/variation/instancing.rb +20 -0
- data/lib/fontisan/variation/variable_svg_generator.rb +19 -1
- data/lib/fontisan/variation.rb +1 -0
- data/lib/fontisan/version.rb +1 -1
- metadata +12 -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: 999790a5a5e57d822deee3a86f540b3dac9b8bef06c234c883cfaacb87e9f0b1
|
|
4
|
+
data.tar.gz: c376a3129b02bec87f5754656a66bef27695e0f445ef5740a60dc8a60013b71e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
@@ -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
|
|
@@ -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?(
|
|
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
|
-
#
|
|
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?(
|
|
236
|
-
|
|
237
|
-
|
|
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
|
-
|
|
241
|
-
|
|
242
|
+
source_outline = tables.key?("glyf") ? :glyf : :cff
|
|
243
|
+
source_outline != target_outline
|
|
242
244
|
end
|
|
243
245
|
|
|
244
246
|
# Write output font file
|
data/lib/fontisan/pipeline.rb
CHANGED
|
@@ -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
|
|
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
|
data/lib/fontisan/tables/glyf.rb
CHANGED
|
@@ -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
|
|
245
|
-
#
|
|
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
|
|
248
|
-
|
|
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
|