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 +4 -4
- data/Rakefile +1 -2
- data/TODO.bug-fixes/30-eliminate-send-in-specs.md +36 -0
- data/TODO.bug-fixes/README.md +5 -2
- data/lib/fontisan/cli.rb +1 -1
- data/lib/fontisan/commands/base_command.rb +1 -1
- data/lib/fontisan/commands/export_command.rb +3 -3
- data/lib/fontisan/commands/info_command.rb +1 -1
- data/lib/fontisan/conversion_options.rb +1 -1
- data/lib/fontisan/converters/format_converter.rb +1 -1
- data/lib/fontisan/converters/type1_converter.rb +1 -1
- data/lib/fontisan/export/exporter.rb +1 -1
- data/lib/fontisan/export/table_serializer.rb +36 -38
- data/lib/fontisan/hints/hint_converter.rb +1 -1
- data/lib/fontisan/optimizers/pattern_analyzer.rb +1 -1
- data/lib/fontisan/parsers/dfont_parser.rb +2 -2
- data/lib/fontisan/pipeline/transformation_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/cff2/table_builder.rb +1 -1
- 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/type1/charstring_converter.rb +1 -1
- data/lib/fontisan/variation/converter.rb +1 -1
- data/lib/fontisan/variation/metrics_adjuster.rb +1 -3
- data/lib/fontisan/version.rb +1 -1
- data/lib/fontisan/woff2_font.rb +2 -2
- metadata +6 -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")
|
|
@@ -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
|
data/TODO.bug-fixes/README.md
CHANGED
|
@@ -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
|
-
- [
|
|
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
|
@@ -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
|
|
@@ -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
|
-
|
|
188
|
-
|
|
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
|
|
@@ -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
|
|
|
@@ -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],
|
data/lib/fontisan/version.rb
CHANGED
data/lib/fontisan/woff2_font.rb
CHANGED
|
@@ -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.
|
|
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
|
|
@@ -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
|