Skip to content
This repository was archived by the owner on Dec 19, 2023. It is now read-only.

Commit 6fe89cb

Browse files
committedDec 26, 2008
Cleanup, first pass.
This cleanup is a significant refactoring of the original ttfunk codebase. It removes the dependence on method_missing, and adds explicit attribute lists. It also avoids dynamic require statements, preferring to explicitly list the files to load. This cleanup also adds support for the 'loca', 'post', 'OS/2', and 'glyf' TrueType tables.
1 parent 26cedc8 commit 6fe89cb

29 files changed

+1120
-318
lines changed
 

‎.gitignore

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
*.swp

‎example.rb

+34-1
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,37 @@
22
require "ttfunk"
33

44
file = TTFunk::File.new("data/fonts/DejaVuSans.ttf")
5-
p file.directory.tables#kern#.sub_tables[0].keys.sort
5+
6+
puts "-- FONT ------------------------------------"
7+
8+
puts "revision : %08x" % file.header.font_revision
9+
puts "name : #{file.name.font_name.join(', ')}"
10+
puts "family : #{file.name.font_family.join(', ')}"
11+
puts "subfamily : #{file.name.font_subfamily.join(', ')}"
12+
puts "postscript: #{file.name.postscript_name}"
13+
14+
puts "-- FONT METRICS ----------------------------"
15+
16+
puts "units/em : #{file.header.units_per_em}"
17+
puts "ascent : #{file.ascent}"
18+
puts "descent : #{file.descent}"
19+
puts "line gap : #{file.line_gap}"
20+
puts "bbox : (%d,%d)-(%d,%d)" % file.bbox
21+
22+
puts "-- CHARACTER -> GLYPH LOOKUP ---------------"
23+
24+
character = "\xE2\x98\x9C"
25+
puts "character : #{character}"
26+
27+
character_code = character.unpack("U*").first
28+
puts "character code: #{character_code}"
29+
30+
glyph_id = file.cmap.unicode.first[character_code]
31+
puts "glyph id : #{glyph_id}"
32+
33+
glyph_index = file.glyph_locations.index_of(glyph_id)
34+
glyph_size = file.glyph_locations.size_of(glyph_id)
35+
puts "glyph index : %d (%db)" % [glyph_index, glyph_size]
36+
37+
glyph = file.glyph_outlines.at(glyph_index)
38+
puts "glyph : (%d,%d)-(%d,%d) (%s)" % [glyph.x_min, glyph.y_min, glyph.x_max, glyph.y_max, glyph.class.name.split(/::/).last.downcase]

‎lib/ttfunk.rb

+87-42
Original file line numberDiff line numberDiff line change
@@ -1,48 +1,93 @@
1+
require 'stringio'
2+
require 'ttfunk/directory'
3+
14
module TTFunk
25
class File
3-
def initialize(file)
4-
@file = file
5-
open_file { |fh| @directory = Table::Directory.new(fh) }
6-
end
7-
8-
def open_file
9-
::File.open(@file,"rb") do |fh|
10-
yield(fh)
11-
end
12-
end
13-
14-
def self.has_tables(*tables)
15-
tables.each { |t| has_table(t) }
16-
end
17-
18-
def self.has_table(t)
19-
t = t.to_s
20-
21-
define_method t do
22-
var = "@#{t}"
23-
if ivar = instance_variable_get(var)
24-
return ivar
25-
else
26-
klass = Table.const_get(t.capitalize)
27-
open_file do |fh|
28-
instance_variable_set(var,
29-
klass.new(fh, self, directory_info(t)))
30-
end
31-
end
32-
end
33-
end
34-
35-
def directory_info(table)
36-
directory.tables[table.to_s]
37-
end
38-
39-
def method_missing(id,*a,&b)
40-
super unless id.to_s["?"]
41-
!!directory_info(id.to_s.chop)
42-
end
43-
6+
attr_reader :contents
447
attr_reader :directory
8+
9+
def initialize(file)
10+
@contents = StringIO.new(IO.read(file))
11+
@directory = Directory.new(@contents)
12+
end
13+
14+
15+
def ascent
16+
@ascent ||= os2.exists? && os2.ascent || horizontal_header.ascent
17+
end
18+
19+
def descent
20+
@descent ||= os2.exists? && os2.descent || horizontal_header.descent
21+
end
22+
23+
def line_gap
24+
@line_gap ||= os2.exists? && os2.line_gap || horizontal_header.line_gap
25+
end
26+
27+
def bbox
28+
[header.x_min, header.y_min, header.x_max, header.y_max]
29+
end
30+
31+
32+
def directory_info(tag)
33+
directory.tables[tag.to_s]
34+
end
35+
36+
def header
37+
@header ||= TTFunk::Table::Head.new(self)
38+
end
39+
40+
def cmap
41+
@cmap ||= TTFunk::Table::Cmap.new(self)
42+
end
43+
44+
def horizontal_header
45+
@horizontal_header ||= TTFunk::Table::Hhea.new(self)
46+
end
47+
48+
def horizontal_metrics
49+
@horizontal_metrics ||= TTFunk::Table::Hmtx.new(self)
50+
end
51+
52+
def maximum_profile
53+
@maximum_profile ||= TTFunk::Table::Maxp.new(self)
54+
end
55+
56+
def kerning
57+
@kerning ||= TTFunk::Table::Kern.new(self)
58+
end
59+
60+
def name
61+
@name ||= TTFunk::Table::Name.new(self)
62+
end
63+
64+
def os2
65+
@os2 ||= TTFunk::Table::OS2.new(self)
66+
end
67+
68+
def postscript
69+
@postscript ||= TTFunk::Table::Post.new(self)
70+
end
71+
72+
def glyph_locations
73+
@glyph_locations ||= TTFunk::Table::Loca.new(self)
74+
end
75+
76+
def glyph_outlines
77+
@glyph_outlines ||= TTFunk::Table::Glyf.new(self)
78+
end
4579
end
4680
end
4781

48-
require "ttfunk/table"
82+
require "ttfunk/table/cmap"
83+
require "ttfunk/table/glyf"
84+
require "ttfunk/table/head"
85+
require "ttfunk/table/hhea"
86+
require "ttfunk/table/hmtx"
87+
require "ttfunk/table/kern"
88+
require "ttfunk/table/loca"
89+
require "ttfunk/table/maxp"
90+
require "ttfunk/table/name"
91+
require "ttfunk/table/os2"
92+
require "ttfunk/table/post"
93+

‎lib/ttfunk/directory.rb

+16
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
module TTFunk
2+
class Directory
3+
attr_reader :tables
4+
5+
def initialize(io)
6+
scaler_type, table_count, search_range,
7+
entry_selector, range_shift = io.read(12).unpack("Nn*")
8+
9+
@tables = {}
10+
table_count.times do
11+
tag, checksum, offset, length = io.read(16).unpack("a4N*")
12+
@tables[tag] = { :tag => tag, :checksum => checksum, :offset => offset, :length => length }
13+
end
14+
end
15+
end
16+
end

‎lib/ttfunk/reader.rb

+28
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
module TTFunk
2+
module Reader
3+
private
4+
5+
def io
6+
@file.contents
7+
end
8+
9+
def read(bytes, format)
10+
io.read(bytes).unpack(format)
11+
end
12+
13+
def read_signed(count)
14+
read(count*2, "n*").map { |i| to_signed(i) }
15+
end
16+
17+
def to_signed(n)
18+
(n>=0x8000) ? -((n ^ 0xFFFF) + 1) : n
19+
end
20+
21+
def parse_from(position)
22+
saved, io.pos = io.pos, position
23+
result = yield
24+
io.pos = saved
25+
return result
26+
end
27+
end
28+
end

‎lib/ttfunk/table.rb

+24-21
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,32 @@
1-
require "ttfunk/table/directory"
2-
3-
%w[cmap head hhea hmtx name kern maxp].each do |lib|
4-
require "ttfunk/table/" + lib
5-
TTFunk::File.has_table lib
6-
end
1+
require 'ttfunk/reader'
72

83
module TTFunk
94
class Table
10-
def method_missing(*args, &block)
11-
var = "@#{args.first}"
12-
13-
# RUBY 1.9 compatability
14-
if instance_variables.map { |e| e.to_s }.include?(var)
15-
instance_variable_get(var)
16-
else
17-
super
5+
include Reader
6+
7+
attr_reader :file
8+
attr_reader :offset
9+
attr_reader :length
10+
11+
def initialize(file)
12+
@file = file
13+
14+
info = file.directory_info(tag)
15+
16+
if info
17+
@offset = info[:offset]
18+
@length = info[:length]
19+
20+
parse_from(@offset) { parse! }
1821
end
1922
end
20-
21-
private
22-
23-
def to_signed(n, length=16)
24-
max = 2**length-1
25-
mid = 2**(length-1)
26-
(n>=mid) ? -((n ^ max) + 1) : n
23+
24+
def exists?
25+
!@offset.nil?
26+
end
27+
28+
def tag
29+
self.class.name.split(/::/).last.downcase
2730
end
2831
end
2932
end

‎lib/ttfunk/table/cmap.rb

+16-84
Original file line numberDiff line numberDiff line change
@@ -1,94 +1,26 @@
11
module TTFunk
22
class Table
33
class Cmap < Table
4-
def initialize(fh, font, info)
5-
@file = fh
6-
@file.pos = info[:offset]
7-
8-
@version, @table_count = @file.read(4).unpack("n2")
9-
10-
process_subtables(info[:offset])
4+
attr_reader :version
5+
attr_reader :tables
6+
7+
def unicode
8+
@unicode ||= @tables.select { |table| table.unicode? }
119
end
12-
10+
1311
private
14-
15-
def process_subtables(table_start)
16-
@sub_tables = {}
17-
@formats = {}
18-
@table_count.times do
19-
platform_id, encoding_id, offset = @file.read(8).unpack("n2N")
20-
@sub_tables[[platform_id, encoding_id]] = offset
21-
end
22-
23-
@sub_tables.each do |ident, offset|
24-
@file.pos = table_start + offset
25-
format = @file.read(2).unpack("n").first
26-
case format
27-
when 0
28-
read_format0
29-
when 4
30-
read_format4(table_start)
31-
else
32-
if $DEBUG
33-
warn "TTFunk: Format #{format} not implemented, skipping"
34-
end
35-
end
36-
end
37-
end
38-
39-
def read_segment
40-
@file.read(@segcount_x2).unpack("n#{@segcount_x2 / 2}")
41-
end
42-
43-
def read_format0
44-
@file.read(4) # skip length, language for now
45-
glyph_ids = @file.read(256).unpack("C256")
46-
@formats[0] = glyph_ids
47-
end
48-
49-
def read_format4(table_start)
50-
@formats[4] = {}
51-
52-
length, language = @file.read(4).unpack("n2")
53-
@segcount_x2, search_range, entry_selector, range_shift =
54-
@file.read(8).unpack("n4")
55-
56-
extract_format4_glyph_ids(table_start)
57-
end
58-
59-
def extract_format4_glyph_ids(table_start)
60-
end_count = read_segment
61-
62-
@file.read(2) # skip reserved value
63-
64-
start_count = read_segment
65-
id_delta = read_segment.map { |e| to_signed(e) }
66-
id_range_offset = read_segment
67-
68-
remaining_shorts = (@file.pos - table_start) / 2
69-
glyph_ids = @file.read(remaining_shorts*2).unpack("n#{remaining_shorts}")
70-
71-
start_count.each_with_index do |start, i|
72-
end_i = end_count[i]
73-
delta = id_delta[i]
74-
range = id_range_offset[i]
75-
76-
start.upto(end_i) do |char|
77-
if range.zero?
78-
gid = char + delta
79-
else
80-
gindex = range / 2 + (char - start_count[i]) -
81-
(segcount_x2 / 2 - i)
82-
gid = glyph_ids[gindex] || 0
83-
gid += id_delta[i] if gid != 0
84-
end
85-
gid %= 65536
86-
87-
@formats[4][char] = gid
12+
13+
def parse!
14+
@version, table_count = read(4, "nn")
15+
@tables = []
16+
17+
table_count.times do
18+
@tables << Cmap::Subtable.new(file, offset)
8819
end
8920
end
90-
end
9121
end
9222

9323
end
94-
end
24+
end
25+
26+
require 'ttfunk/table/cmap/subtable'

‎lib/ttfunk/table/cmap/format00.rb

+27
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
module TTFunk
2+
class Table
3+
class Cmap
4+
5+
module Format00
6+
attr_reader :language
7+
attr_reader :code_map
8+
9+
def [](code)
10+
@code_map[code] || 0
11+
end
12+
13+
def supported?
14+
true
15+
end
16+
17+
private
18+
19+
def parse_cmap!
20+
length, @language = read(6, "nn")
21+
@code_map = read(256, "C*")
22+
end
23+
end
24+
25+
end
26+
end
27+
end

‎lib/ttfunk/table/cmap/format04.rb

+53
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
module TTFunk
2+
class Table
3+
class Cmap
4+
5+
module Format04
6+
attr_reader :language
7+
attr_reader :code_map
8+
9+
def [](code)
10+
@code_map[code] || 0
11+
end
12+
13+
def supported?
14+
true
15+
end
16+
17+
private
18+
19+
def parse_cmap!
20+
length, @language, segcount_x2 = read(6, "nnn")
21+
segcount = segcount_x2 / 2
22+
23+
io.read(6) # skip searching hints
24+
25+
end_code = read(segcount_x2, "n*")
26+
io.read(2) # skip reserved value
27+
start_code = read(segcount_x2, "n*")
28+
id_delta = read_signed(segcount)
29+
id_range_offset = read(segcount_x2, "n*")
30+
31+
glyph_ids = read(length - io.pos + @offset, "n*")
32+
33+
@code_map = {}
34+
35+
end_code.each_with_index do |tail, i|
36+
start_code[i].upto(tail) do |code|
37+
if id_range_offset[i].zero?
38+
glyph_id = code + id_delta[i]
39+
else
40+
index = id_range_offset[i] / 2 + (code - start_code[i]) - (segcount - i)
41+
glyph_id = glyph_ids[index] || 0 # because some TTF fonts are broken
42+
glyph_id += id_delta[i] if glyph_id != 0
43+
end
44+
45+
@code_map[code] = glyph_id & 0xFFFF
46+
end
47+
end
48+
end
49+
end
50+
51+
end
52+
end
53+
end

‎lib/ttfunk/table/cmap/subtable.rb

+54
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
require 'ttfunk/reader'
2+
3+
module TTFunk
4+
class Table
5+
class Cmap
6+
class Subtable
7+
include Reader
8+
9+
attr_reader :platform_id
10+
attr_reader :encoding_id
11+
attr_reader :format
12+
13+
def initialize(file, table_start)
14+
@file = file
15+
@platform_id, @encoding_id, @offset = read(8, "nnN")
16+
@offset += table_start
17+
18+
saved, io.pos = io.pos, @offset
19+
@format = read(2, "n").first
20+
21+
case @format
22+
when 0 then extend(TTFunk::Table::Cmap::Format00)
23+
when 4 then extend(TTFunk::Table::Cmap::Format04)
24+
end
25+
26+
parse_cmap!
27+
io.pos = saved
28+
end
29+
30+
def unicode?
31+
platform_id == 3 && encoding_id == 1 && format == 4 ||
32+
platform_id == 0 && format == 4
33+
end
34+
35+
def supported?
36+
false
37+
end
38+
39+
def [](code)
40+
raise NotImplementedError, "cmap format #{@format} is not supported"
41+
end
42+
43+
private
44+
45+
def parse_cmap!
46+
# do nothing...
47+
end
48+
end
49+
end
50+
end
51+
end
52+
53+
require 'ttfunk/table/cmap/format00'
54+
require 'ttfunk/table/cmap/format04'

‎lib/ttfunk/table/directory.rb

-19
This file was deleted.

‎lib/ttfunk/table/glyf.rb

+32
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
require 'ttfunk/table'
2+
3+
module TTFunk
4+
class Table
5+
class Glyf < Table
6+
def at(glyph_offset)
7+
return @cache[glyph_offset] if @cache.key?(glyph_offset)
8+
9+
parse_from(offset + glyph_offset) do
10+
number_of_contours, x_min, y_min, x_max, y_max = read_signed(5)
11+
12+
@cache[glyph_offset] = if number_of_contours == -1
13+
Compound.new(io, x_min, y_min, x_max, y_max)
14+
else
15+
Simple.new(io, number_of_contours, x_min, y_min, x_max, y_max)
16+
end
17+
end
18+
end
19+
20+
private
21+
22+
def parse!
23+
# because the glyf table is rather complex to parse, we defer
24+
# the parse until we need a specific glyf, and then cache it.
25+
@cache = {}
26+
end
27+
end
28+
end
29+
end
30+
31+
require 'ttfunk/table/glyf/compound'
32+
require 'ttfunk/table/glyf/simple'

‎lib/ttfunk/table/glyf/compound.rb

+72
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
require 'ttfunk/reader'
2+
3+
module TTFunk
4+
class Table
5+
class Glyf
6+
class Compound
7+
include Reader
8+
9+
ARG_1_AND_2_ARE_WORDS = 0x0001
10+
WE_HAVE_A_SCALE = 0x0008
11+
MORE_COMPONENTS = 0x0020
12+
WE_HAVE_AN_X_AND_Y_SCALE = 0x0040
13+
WE_HAVE_A_TWO_BY_TWO = 0x0080
14+
WE_HAVE_INSTRUCTIONS = 0x0100
15+
16+
attr_reader :x_min, :y_min, :x_max, :y_max
17+
attr_reader :components
18+
attr_reader :instructions
19+
20+
Component = Struct.new(:flags, :glyph_index, :arg1, :arg2, :transform)
21+
22+
def initialize(io, x_min, y_min, x_max, y_max)
23+
@io = io
24+
@x_min, @y_min, @x_max, @y_max = x_min, y_min, x_max, y_max
25+
26+
@components = []
27+
instr_requests = 0
28+
29+
loop do
30+
flags, glyph_index = read(4, "n*")
31+
if flags & ARG_1_AND_2_ARE_WORDS != 0
32+
arg1, arg2 = read(4, "n*")
33+
else
34+
arg1, arg2 = read(2, "C*")
35+
end
36+
37+
if flags & WE_HAVE_A_TWO_BY_TWO != 0
38+
transform = read(8, "n*")
39+
elsif flags & WE_HAVE_AN_X_AND_Y_SCALE != 0
40+
transform = read(4, "n*")
41+
elsif flags & WE_HAVE_A_SCALE != 0
42+
transform = read(2, "n")
43+
else
44+
transform = []
45+
end
46+
47+
instr_requests += 1 if flags & WE_HAVE_INSTRUCTIONS != 0
48+
49+
@components << Component.new(flags, glyph_index, arg1, arg2, transform)
50+
break unless flags & MORE_COMPONENTS != 0
51+
end
52+
53+
# The docs are a bit vague on how instructions are to be parsed from
54+
# a compound glyph. This seems to work for the glyphs I've tried, but...
55+
@instructions = ""
56+
while instr_requests > 0
57+
length = read(2, "n").first
58+
@instructions << io.read(length)
59+
instr_requests -= 1
60+
end
61+
end
62+
63+
private
64+
65+
def io
66+
@io
67+
end
68+
end
69+
end
70+
end
71+
end
72+

‎lib/ttfunk/table/glyf/simple.rb

+79
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
require 'ttfunk/reader'
2+
3+
module TTFunk
4+
class Table
5+
class Glyf
6+
class Simple
7+
include Reader
8+
9+
ON_CURVE = 0x01
10+
X_SHORT = 0x02
11+
Y_SHORT = 0x04
12+
REPEAT = 0x08
13+
X_SAME = 0x10
14+
X_POSITIVE = 0x10
15+
Y_SAME = 0x20
16+
Y_POSITIVE = 0x20
17+
18+
attr_reader :number_of_contours
19+
attr_reader :x_min, :y_min, :x_max, :y_max
20+
attr_reader :end_points, :instructions, :flags
21+
attr_reader :xs, :ys
22+
23+
def initialize(io, number_of_contours, x_min, y_min, x_max, y_max)
24+
@io = io
25+
@number_of_contours = number_of_contours
26+
@x_min, @y_min = x_min, y_min
27+
@x_max, @y_max = x_max, y_max
28+
29+
@end_points = read(number_of_contours * 2, "n*")
30+
point_count = @end_points.last || 0
31+
32+
instr_len = read(2, "n").first
33+
@instructions = io.read(instr_len)
34+
35+
@flags = []
36+
while @flags.length < point_count
37+
flag = read(1, "C").first
38+
@flags << flag
39+
40+
if flag & REPEAT != 0
41+
count = read(1, "C").first
42+
@flags.concat([flag] * count)
43+
end
44+
end
45+
46+
@xs = []
47+
read_coords(@xs, point_count, X_SHORT, X_POSITIVE, X_SAME)
48+
49+
@ys = []
50+
read_coords(@ys, point_count, Y_SHORT, Y_POSITIVE, Y_SAME)
51+
end
52+
53+
private
54+
55+
def read_coords(array, count, short_flag, positive_flag, same_flag)
56+
while array.length < count
57+
flag = @flags[array.length]
58+
59+
if flag & short_flag != 0
60+
coord = read(1, "C").first
61+
coord = -coord if flag & positive_flag == 0
62+
elsif flag & same_flag != 0
63+
coord = 0
64+
else
65+
coord = read_signed(1).first
66+
end
67+
68+
array << coord
69+
end
70+
end
71+
72+
def io
73+
@io
74+
end
75+
end
76+
end
77+
end
78+
end
79+

‎lib/ttfunk/table/head.rb

+32-20
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,37 @@
1+
require 'ttfunk/table'
2+
13
module TTFunk
24
class Table
35
class Head < TTFunk::Table
4-
def initialize(fh, font, info)
5-
fh.pos = info[:offset]
6-
data = fh.read(20)
7-
@version, @font_revision, @check_sum_adjustment, @magic_number,
8-
@flags, @units_per_em = data.unpack("N4n2")
9-
10-
# skip dates
11-
fh.read(16)
12-
13-
data = fh.read(8)
14-
@x_min, @y_min, @x_max, @y_max = data.unpack("n4").map { |e| to_signed(e) }
15-
16-
data = fh.read(4)
17-
@mac_style, @lowest_rec_ppem = data.unpack("n2")
18-
19-
data = fh.read(6)
20-
@font_direction_hint, @index_to_loc_format, @glyph_data_format =
21-
data.unpack("n3")
22-
end
6+
attr_reader :version
7+
attr_reader :font_revision
8+
attr_reader :checksum_adjustment
9+
attr_reader :magic_number
10+
attr_reader :flags
11+
attr_reader :units_per_em
12+
attr_reader :created
13+
attr_reader :modified
14+
attr_reader :x_min
15+
attr_reader :y_min
16+
attr_reader :x_max
17+
attr_reader :y_max
18+
attr_reader :mac_style
19+
attr_reader :lowest_rec_ppem
20+
attr_reader :font_direction_hint
21+
attr_reader :index_to_loc_format
22+
attr_reader :glyph_data_format
23+
24+
private
25+
26+
def parse!
27+
@version, @font_revision, @check_sum_adjustment, @magic_number,
28+
@flags, @units_per_em, @created, @modified = read(36, "N4n2q2")
29+
30+
@x_min, @y_min, @x_max, @y_max = read_signed(4)
31+
32+
@mac_style, @lowest_rec_ppem, @font_direction_hint,
33+
@index_to_loc_format, @glyph_data_format = read(10, "n*")
34+
end
2335
end
2436
end
25-
end
37+
end

‎lib/ttfunk/table/hhea.rb

+30-22
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,35 @@
1+
require 'ttfunk/table'
2+
13
module TTFunk
24
class Table
35
class Hhea < Table
4-
def initialize(fh, font, info)
5-
fh.pos = info[:offset]
6-
@length = info[:length]
7-
data = fh.read(4)
8-
@version = data.unpack("N")
9-
10-
data = fh.read(6)
11-
@ascent, @descent, @line_gap= data.unpack("n3").map {|e| to_signed(e) }
12-
13-
data = fh.read(2)
14-
@advance_width_max = data.unpack("n")
15-
16-
data = fh.read(22)
17-
@min_left_side_bearing, @min_right_side_bearing, @x_max_extent,
18-
@caret_slope_rise, @caret_slope_run,
19-
@caret_offset, _, _, _, _, @metric_data_format =
20-
data.unpack("n11").map {|e| to_signed(e) }
21-
22-
data = fh.read(2)
23-
@number_of_hmetrics = data.unpack("n").first
24-
end
6+
attr_reader :version
7+
attr_reader :ascent
8+
attr_reader :descent
9+
attr_reader :line_gap
10+
attr_reader :advance_width_max
11+
attr_reader :min_left_side_bearing
12+
attr_reader :min_right_side_bearing
13+
attr_reader :x_max_extent
14+
attr_reader :carot_slope_rise
15+
attr_reader :carot_slope_run
16+
attr_reader :metric_data_format
17+
attr_reader :number_of_metrics
18+
19+
private
20+
21+
def parse!
22+
@version = read(4, "N").first
23+
@ascent, @descent, @line_gap = read_signed(3)
24+
@advance_width_max = read(2, "n").first
25+
26+
@min_left_side_bearing, @min_right_side_bearing, @x_max_extent,
27+
@carot_slope_rise, @carot_slope_run, @caret_offset,
28+
reserved, reserved, reserved, reserved,
29+
@metric_data_format = read_signed(11)
30+
31+
@number_of_metrics = read(2, "n").first
32+
end
2533
end
2634
end
27-
end
35+
end

‎lib/ttfunk/table/hmtx.rb

+31-14
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,37 @@
1+
require 'ttfunk/table'
2+
13
module TTFunk
24
class Table
35
class Hmtx < Table
4-
def initialize(fh, font, info)
5-
fh.pos = info[:offset]
6-
@values = []
7-
8-
font.hhea.number_of_hmetrics.times do
9-
advance = fh.read(2).unpack("n").first
10-
lsb = to_signed(fh.read(2).unpack("n").first)
11-
@values << [advance,lsb]
12-
end
13-
14-
lsb_count = font.hhea.number_of_hmetrics - font.maxp.num_glyphs
15-
pattern = "n#{lsb_count}"
16-
@lsb = fh.read(2*lsb_count).unpack(pattern).map { |e| to_signed(e) }
6+
attr_reader :metrics
7+
attr_reader :left_side_bearings
8+
attr_reader :widths
9+
10+
HorizontalMetric = Struct.new(:advance_width, :left_side_bearing)
11+
12+
def for(glyph_id)
13+
@metrics[glyph_id] ||
14+
HorizontalMetric.new(@metrics.last.advance_width,
15+
@left_side_bearings[glyph_id - @metrics.length])
1716
end
17+
18+
private
19+
20+
def parse!
21+
@metrics = []
22+
23+
file.horizontal_header.number_of_metrics.times do
24+
advance = read(2, "n").first
25+
lsb = read_sshort(1).first
26+
@metrics.push HorizontalMetric.new(advance, lsb)
27+
end
28+
29+
lsb_count = file.maximum_profile.num_glyphs - file.horizontal_header.number_of_metrics
30+
@left_side_bearings = read_sshort(lsb_count)
31+
32+
@widths = @metrics.map { |metric| metric.advance_width }
33+
@widths += @left_side_bearings.length * @widths.last
34+
end
1835
end
1936
end
20-
end
37+
end

‎lib/ttfunk/table/kern.rb

+53-39
Original file line numberDiff line numberDiff line change
@@ -1,48 +1,62 @@
1+
require 'ttfunk/table'
2+
13
module TTFunk
24
class Table
35
class Kern < Table
4-
def initialize(fh, font, info)
5-
fh.pos = info[:offset]
6-
data = fh.read(4)
7-
@fh = fh
8-
@version, @table_count = data.unpack("n2")
9-
10-
@table_headers = {}
11-
12-
@table_count.times do
13-
version, length, coverage = fh.read(6).unpack("n3")
14-
@table_headers[version] = { :length => length,
15-
:coverage => coverage,
16-
:format => coverage >> 8 }
17-
end
18-
19-
generate_subtables
20-
end
21-
22-
def generate_subtables
23-
@sub_tables = {}
24-
@table_headers.each do |version, data|
25-
if data[:format].zero?
26-
@sub_tables[0] = parse_subtable_format0
6+
attr_reader :version
7+
attr_reader :tables
8+
9+
private
10+
11+
def parse!
12+
@version, num_tables = read(4, "n*")
13+
@tables = []
14+
15+
if @version == 1 # Mac OS X fonts
16+
@version = (@version << 16) + num_tables
17+
num_tables = read(4, "N").first
18+
parse_version_1_tables(num_tables)
2719
else
28-
warn "TTFunk does not support kerning tables of format #{data[:format]}"
20+
parse_version_0_tables(num_tables)
2921
end
3022
end
31-
end
32-
33-
def parse_subtable_format0
34-
sub_table = {}
35-
pair_count, search_range, entry_selector, range_shift = @fh.read(8).unpack("n4")
36-
37-
pair_count.times do
38-
left, right = @fh.read(4).unpack("n2")
39-
fword = to_signed(@fh.read(2).unpack("n").first)
40-
sub_table[[left,right]] = fword
23+
24+
def parse_version_0_tables(num_tables)
25+
num_tables.times do # MS fonts
26+
version, length, coverage = read(6, "n*")
27+
format = coverage >> 8
28+
29+
add_table format, :version => version, :length => length,
30+
:coverage => coverage, :data => handle.read(length-6),
31+
:vertical => (coverage & 0x1 == 0),
32+
:minimum => (coverage & 0x2 != 0),
33+
:cross => (coverage & 0x4 != 0),
34+
:override => (coverage & 0x8 != 0)
35+
end
36+
end
37+
38+
def parse_version_1_tables(num_tables)
39+
num_tables.times do
40+
length, coverage, tuple_index = read(8, "Nnn")
41+
format = coverage & 0x0FF
42+
43+
add_table format, :length => length, :coverage => coverage,
44+
:tuple_index => tuple_index, :data => handle.read(length-8),
45+
:vertical => (coverage & 0x8000 != 0),
46+
:cross => (coverage & 0x4000 != 0),
47+
:variation => (coverage & 0x2000 != 0)
48+
end
49+
end
50+
51+
def add_table(format, attributes={})
52+
if format == 0
53+
@tables << Kern::Format0.new(attributes)
54+
else
55+
# silently ignore unsupported kerning tables
56+
end
4157
end
42-
43-
return sub_table
44-
end
45-
4658
end
4759
end
48-
end
60+
end
61+
62+
require 'ttfunk/table/kern/format0'

‎lib/ttfunk/table/kern/format0.rb

+42
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
require 'ttfunk/reader'
2+
3+
module TTFunk
4+
class Table
5+
class Kern
6+
class Format0
7+
include Reader
8+
9+
attr_reader :attributes
10+
attr_reader :pairs
11+
12+
def initialize(attributes={})
13+
@file = file
14+
@attributes = attributes
15+
16+
num_pairs, search_range, entry_selector, range_shift, *pairs =
17+
attributes.delete(:data).unpack("n*")
18+
19+
@pairs = {}
20+
num_pairs.times do |i|
21+
left = pairs[i*3]
22+
right = pairs[i*3+1]
23+
value = to_signed(pairs[i*3+2])
24+
@pairs[[left, right]] = value
25+
end
26+
end
27+
28+
def vertical?
29+
@attributes[:vertical]
30+
end
31+
32+
def horizontal?
33+
!vertical?
34+
end
35+
36+
def cross_stream?
37+
@attributes[:cross]
38+
end
39+
end
40+
end
41+
end
42+
end

‎lib/ttfunk/table/loca.rb

+28
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
require 'ttfunk/table'
2+
3+
module TTFunk
4+
class Table
5+
class Loca < Table
6+
attr_reader :offsets
7+
8+
def index_of(glyph_id)
9+
@offsets[glyph_id]
10+
end
11+
12+
def size_of(glyph_id)
13+
@offsets[glyph_id+1] - @offsets[glyph_id]
14+
end
15+
16+
private
17+
18+
def parse!
19+
type = file.header.index_to_loc_format == 0 ? "n" : "N"
20+
@offsets = read(length, "#{type}*")
21+
22+
if file.header.index_to_loc_format == 0
23+
@offsets.map! { |v| v * 2 }
24+
end
25+
end
26+
end
27+
end
28+
end

‎lib/ttfunk/table/maxp.rb

+28-12
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,33 @@
1+
require 'ttfunk/table'
2+
13
module TTFunk
24
class Table
35
class Maxp < Table
4-
def initialize(fh, font, info)
5-
fh.pos = info[:offset]
6-
@length = info[:length]
7-
data = fh.read(@length)
8-
@version, @num_glyphs, @max_points, @max_contours,
9-
@max_component_points,@max_component_contours, @max_zones,
10-
@max_twilight_points, @max_storage, @max_function_defs,
11-
@max_instruction_defs,@max_stack_elements,
12-
@max_size_of_instructions, @max_component_elements,
13-
@max_component_depth = data.unpack("Nn14")
14-
end
6+
attr_reader :version
7+
attr_reader :num_glyphs
8+
attr_reader :max_points
9+
attr_reader :max_contours
10+
attr_reader :max_component_points
11+
attr_reader :max_component_contours
12+
attr_reader :max_zones
13+
attr_reader :max_twilight_points
14+
attr_reader :max_storage
15+
attr_reader :max_function_defs
16+
attr_reader :max_instruction_defs
17+
attr_reader :max_stack_elements
18+
attr_reader :max_size_of_instructions
19+
attr_reader :max_component_elements
20+
attr_reader :max_component_depth
21+
22+
private
23+
24+
def parse!
25+
@version, @num_glyphs, @max_points, @max_contours, @max_component_points,
26+
@max_component_contours, @max_zones, @max_twilight_points, @max_storage,
27+
@max_function_defs, @max_instruction_defs, @max_stack_elements,
28+
@max_size_of_instructions, @max_component_elements, @max_component_depth =
29+
read(length, "Nn*")
30+
end
1531
end
1632
end
17-
end
33+
end

‎lib/ttfunk/table/name.rb

+82-44
Original file line numberDiff line numberDiff line change
@@ -1,52 +1,90 @@
1+
require 'ttfunk/table'
2+
13
module TTFunk
24
class Table
35
class Name < Table
4-
def initialize(fh, font, info)
5-
fh.pos = info[:offset]
6-
data = fh.read(6)
7-
@table_start = info[:offset]
8-
@format, @record_count, @string_offset = data.unpack("nnn")
9-
parse_name_records(fh)
10-
parse_strings(fh)
11-
end
12-
13-
def parse_name_records(fh)
14-
@records = {}
15-
@record_count.times { @records.update(parse_name_record(fh)) }
16-
end
17-
18-
def parse_name_record(fh)
19-
data = fh.read(12).unpack("n6")
20-
platform, encoding, language, id, length, offset = data
21-
{ id => {
22-
:platform => platform, :encoding => encoding,
23-
:language => language, :length => length,
24-
:offset => offset } }
25-
end
26-
27-
def parse_strings(fh)
28-
@strings = @records.inject({}) do |s,v|
29-
id, options = v
30-
31-
fh.pos = @table_start + @string_offset + options[:offset]
32-
s.merge(id => fh.read(options[:length]).delete("\000"))
6+
class String < ::String
7+
attr_reader :platform_id
8+
attr_reader :encoding_id
9+
attr_reader :language_id
10+
11+
def initialize(text, platform_id, encoding_id, language_id)
12+
super(text)
13+
@platform_id = platform_id
14+
@encoding_id = encoding_id
15+
@language_id = language_id
3316
end
3417
end
35-
36-
def name_data
37-
[:copyright, :font_family, :font_subfamily, :unique_subfamily_id,
38-
:full_name, :name_table_version, :postscript_name, :trademark_notice,
39-
:manufacturer_name, :designer, :description, :vendor_url,
40-
:designer_url, :license_description, :license_info_url ]
41-
end
42-
43-
def method_missing(*args,&block)
44-
if name_data.include?(args.first)
45-
@strings[name_data.index(args.first)]
46-
else
47-
super
18+
19+
attr_reader :strings
20+
21+
attr_reader :copyright
22+
attr_reader :font_family
23+
attr_reader :font_subfamily
24+
attr_reader :unique_subfamily
25+
attr_reader :font_name
26+
attr_reader :version
27+
attr_reader :postscript_name
28+
attr_reader :trademark
29+
attr_reader :manufacturer
30+
attr_reader :designer
31+
attr_reader :description
32+
attr_reader :vendor_url
33+
attr_reader :designer_url
34+
attr_reader :license
35+
attr_reader :license_url
36+
attr_reader :preferred_family
37+
attr_reader :preferred_subfamily
38+
attr_reader :compatible_full
39+
attr_reader :sample_text
40+
41+
private
42+
43+
def parse!
44+
format, count, string_offset = read(6, "n*")
45+
46+
entries = []
47+
count.times do
48+
platform, encoding, language, id, length, start_offset = read(12, "n*")
49+
entries << {
50+
:platform_id => platform,
51+
:encoding_id => encoding,
52+
:language_id => language,
53+
:name_id => id,
54+
:length => length,
55+
:offset => offset + string_offset + start_offset
56+
}
57+
end
58+
59+
@strings = Hash.new { |h,k| h[k] = [] }
60+
61+
count.times do |i|
62+
io.pos = entries[i][:offset]
63+
text = io.read(entries[i][:length])
64+
@strings[entries[i][:name_id]] << Name::String.new(text,
65+
entries[i][:platform_id], entries[i][:encoding_id], entries[i][:language_id])
66+
end
67+
68+
@copyright = @strings[0]
69+
@font_family = @strings[1]
70+
@font_subfamily = @strings[2]
71+
@unique_subfamily = @strings[3]
72+
@font_name = @strings[4]
73+
@version = @strings[5]
74+
@postscript_name = @strings[6].first # should only be ONE postscript name
75+
@trademark = @strings[7]
76+
@manufacturer = @strings[8]
77+
@designer = @strings[9]
78+
@description = @strings[10]
79+
@vendor_url = @strings[11]
80+
@designer_url = @strings[12]
81+
@license = @strings[13]
82+
@license_url = @strings[14]
83+
@preferred_family = @strings[15]
84+
@preferred_subfamily = @strings[17]
85+
@compatible_full = @strings[18]
86+
@sample_text = @strings[19]
4887
end
49-
end
5088
end
5189
end
52-
end
90+
end

‎lib/ttfunk/table/os2.rb

+78
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
require 'ttfunk/table'
2+
3+
module TTFunk
4+
class Table
5+
class OS2 < Table
6+
attr_reader :version
7+
8+
attr_reader :ave_char_width
9+
attr_reader :weight_class
10+
attr_reader :width_class
11+
attr_reader :type
12+
attr_reader :y_subscript_x_size
13+
attr_reader :y_subscript_y_size
14+
attr_reader :y_subscript_x_offset
15+
attr_reader :y_subscript_y_offset
16+
attr_reader :y_superscript_x_size
17+
attr_reader :y_superscript_y_size
18+
attr_reader :y_superscript_x_offset
19+
attr_reader :y_superscript_y_offset
20+
attr_reader :y_strikeout_size
21+
attr_reader :y_strikeout_position
22+
attr_reader :family_class
23+
attr_reader :panose
24+
attr_reader :char_range
25+
attr_reader :vendor_id
26+
attr_reader :selection
27+
attr_reader :first_char_index
28+
attr_reader :last_char_index
29+
30+
attr_reader :ascent
31+
attr_reader :descent
32+
attr_reader :line_gap
33+
attr_reader :win_ascent
34+
attr_reader :win_descent
35+
attr_reader :code_page_range
36+
37+
attr_reader :x_height
38+
attr_reader :cap_height
39+
attr_reader :default_char
40+
attr_reader :break_char
41+
attr_reader :max_context
42+
43+
def tag
44+
"OS/2"
45+
end
46+
47+
private
48+
49+
def parse!
50+
@version = read(2, "n").first
51+
52+
@ave_char_width = read_signed(1)
53+
@weight_class, @width_class = read(4, "nn")
54+
@type, @y_subscript_x_size, @y_subscript_y_size, @y_subscript_x_offset,
55+
@y_subscript_y_offset, @y_superscript_x_size, @y_superscript_y_size,
56+
@y_superscript_x_offset, @y_superscript_y_offset, @y_strikeout_size,
57+
@y_strikeout_position, @family_class = read_signed(12)
58+
@panose = io.read(10)
59+
60+
@char_range = io.read(16)
61+
@vendor_id = io.read(4)
62+
63+
@selection, @first_char_index, @last_char_index = read(6, "n*")
64+
65+
if @version > 0
66+
@ascent, @descent, @line_gap = read_signed(3)
67+
@win_ascent, @win_descent = read(4, "nn")
68+
@code_page_range = io.read(8)
69+
70+
if @version > 1
71+
@x_height, @cap_height = read_signed(2)
72+
@default_char, @break_char, @max_context = read(6, "nnn")
73+
end
74+
end
75+
end
76+
end
77+
end
78+
end

‎lib/ttfunk/table/post.rb

+58
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
require 'ttfunk/table'
2+
3+
module TTFunk
4+
class Table
5+
class Post < Table
6+
attr_reader :format
7+
attr_reader :italic_angle
8+
attr_reader :underline_position
9+
attr_reader :underline_thickness
10+
attr_reader :fixed_pitch
11+
attr_reader :min_mem_type42
12+
attr_reader :max_mem_type42
13+
attr_reader :min_mem_type1
14+
attr_reader :max_mem_type1
15+
16+
attr_reader :subtable
17+
18+
def fixed_pitch?
19+
@fixed_pitch != 0
20+
end
21+
22+
def glyph_for(code)
23+
".notdef"
24+
end
25+
26+
private
27+
28+
def parse!
29+
@format, @italic_angle, @underline_position, @underline_thickness,
30+
@fixed_pitch, @min_mem_type42, @max_mem_type42,
31+
@min_mem_type1, @max_mem_type1 = read(32, "N2n2N*")
32+
33+
end_of_table = offset + length
34+
35+
@subtable = case @format
36+
when 0x00010000 then extend(Post::Format10)
37+
when 0x00020000 then extend(Post::Format20)
38+
when 0x00025000 then extend(Post::Format25)
39+
when 0x00030000 then extend(Post::Format30)
40+
when 0x00040000 then extend(Post::Format40)
41+
end
42+
43+
parse_table!
44+
end
45+
46+
def parse_table!
47+
warn "postscript table format 0x%08X is not supported" % @format
48+
end
49+
end
50+
51+
end
52+
end
53+
54+
require 'ttfunk/table/post/format10'
55+
require 'ttfunk/table/post/format20'
56+
require 'ttfunk/table/post/format25'
57+
require 'ttfunk/table/post/format30'
58+
require 'ttfunk/table/post/format40'

‎lib/ttfunk/table/post/format10.rb

+43
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
module TTFunk
2+
class Table
3+
class Post
4+
module Format10
5+
POSTSCRIPT_GLYPHS = %w(
6+
.notdef .null nonmarkingreturn space exclam quotedbl numbersign dollar percent
7+
ampersand quotesingle parenleft parenright asterisk plus comma hyphen period slash
8+
zero one two three four five six seven eight nine colon semicolon less equal greater
9+
question at A B C D E F G H I J K L M N O P Q R S T U V W X Y Z
10+
bracketleft backslash bracketright asciicircum underscore grave
11+
a b c d e f g h i j k l m n o p q r s t u v w x y z
12+
braceleft bar braceright asciitilde Adieresis Aring Ccedilla Eacute Ntilde Odieresis
13+
Udieresis aacute agrave acircumflex adieresis atilde aring ccedilla eacute egrave
14+
ecircumflex edieresis iacute igrave icircumflex idieresis ntilde oacute ograve
15+
ocircumflex odieresis otilde uacute ugrave ucircumflex udieresis dagger degree cent
16+
sterling section bullet paragraph germandbls registered copyright trademark acute
17+
dieresis notequal AE Oslash infinity plusminus lessequal greaterequal yen mu
18+
partialdiff summation product pi integral ordfeminine ordmasculine Omega ae oslash
19+
questiondown exclamdown logicalnot radical florin approxequal Delta guillemotleft
20+
guillemotright ellipsis nonbreakingspace Agrave Atilde Otilde OE oe endash emdash
21+
quotedblleft quotedblright quoteleft quoteright divide lozenge ydieresis Ydieresis
22+
fraction currency guilsinglleft guilsinglright fi fl daggerdbl periodcentered
23+
quotesinglbase quotedblbase perthousand Acircumflex Ecircumflex Aacute Edieresis
24+
Egrave Iacute Icircumflex Idieresis Igrave Oacute Ocircumflex apple Ograve Uacute
25+
Ucircumflex Ugrave dotlessi circumflex tilde macron breve dotaccent ring cedilla
26+
hungarumlaut ogonek caron Lslash lslash Scaron scaron Zcaron zcaron brokenbar Eth
27+
eth Yacute yacute Thorn thorn minus multiply onesuperior twosuperior threesuperior
28+
onehalf onequarter threequarters franc Gbreve gbreve Idotaccent Scedilla scedilla
29+
Cacute cacute Ccaron ccaron dcroat)
30+
31+
def glyph_for(code)
32+
POSTSCRIPT_GLYPHS[code] || ".notdef"
33+
end
34+
35+
private
36+
37+
def parse_format!
38+
# do nothing. Format 1 is easy-sauce.
39+
end
40+
end
41+
end
42+
end
43+
end

‎lib/ttfunk/table/post/format20.rb

+35
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
require 'ttfunk/table/post/format10'
2+
require 'stringio'
3+
4+
module TTFunk
5+
class Table
6+
class Post
7+
module Format20
8+
include Format10
9+
10+
def glyph_for(code)
11+
index = @glyph_name_index[code]
12+
if index <= 257
13+
POSTSCRIPT_GLYPHS[index]
14+
else
15+
@names[index - 257] || ".notdef"
16+
end
17+
end
18+
19+
private
20+
21+
def parse_format!
22+
number_of_glyphs = read(2, 'n').first
23+
@glyph_name_index = read(number_of_glyphs*2, 'n*')
24+
@names = []
25+
26+
strings = StringIO.new(io.read(offset + length - io.pos))
27+
while !strings.eof?
28+
length = strings.read(1).unpack("C").first
29+
@names << strings.read(length)
30+
end
31+
end
32+
end
33+
end
34+
end
35+
end

‎lib/ttfunk/table/post/format25.rb

+23
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
require 'ttfunk/table/post/format10'
2+
require 'stringio'
3+
4+
module TTFunk
5+
class Table
6+
class Post
7+
module Format25
8+
include Format10
9+
10+
def glyph_for(code)
11+
POSTSCRIPT_GLYPHS[code + @offsets[code]] || ".notdef"
12+
end
13+
14+
private
15+
16+
def parse_format!
17+
number_of_glyphs = read(2, 'n').first
18+
@offsets = read(@number_of_glyphs, "c*")
19+
end
20+
end
21+
end
22+
end
23+
end

‎lib/ttfunk/table/post/format30.rb

+17
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
module TTFunk
2+
class Table
3+
class Post
4+
module Format30
5+
def glyph_for(code)
6+
".notdef"
7+
end
8+
9+
private
10+
11+
def parse_format!
12+
# do nothing. Format 3 is easy-sauce.
13+
end
14+
end
15+
end
16+
end
17+
end

‎lib/ttfunk/table/post/format40.rb

+17
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
module TTFunk
2+
class Table
3+
class Post
4+
module Format40
5+
def glyph_for(code)
6+
@map[code] || 0xFFFF
7+
end
8+
9+
private
10+
11+
def parse_format!
12+
@map = read(file.maximum_profile.num_glyphs * 2, "N*")
13+
end
14+
end
15+
end
16+
end
17+
end

0 commit comments

Comments
 (0)
This repository has been archived.