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
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fontisan
|
|
4
|
+
module Variation
|
|
5
|
+
module Instancing
|
|
6
|
+
# Evaluates HVAR advance-width deltas (ItemVariationStore plus the
|
|
7
|
+
# optional DeltaSetIndexMap) at a normalized location.
|
|
8
|
+
class ItemVariationStore
|
|
9
|
+
# @param hvar [String] raw HVAR table
|
|
10
|
+
def initialize(hvar)
|
|
11
|
+
@hvar = hvar
|
|
12
|
+
store_offset, @advance_map_offset = hvar[4, 8].unpack("NN")
|
|
13
|
+
@store = hvar.byteslice(store_offset..)
|
|
14
|
+
region_list_offset, data_count = @store[2, 6].unpack("Nn")
|
|
15
|
+
@regions = parse_regions(region_list_offset)
|
|
16
|
+
@data_offsets = @store[8, data_count * 4].unpack("N*")
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# @return [Float] advance-width delta for +gid+
|
|
20
|
+
def advance_delta(gid, coords)
|
|
21
|
+
outer, inner = delta_index(gid)
|
|
22
|
+
delta(outer, inner, coords)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def parse_regions(offset)
|
|
28
|
+
axis_count, region_count = @store[offset, 4].unpack("nn")
|
|
29
|
+
Array.new(region_count) do |r|
|
|
30
|
+
pos = offset + 4 + (r * axis_count * 6)
|
|
31
|
+
@store[pos, axis_count * 6].unpack("s>*").map { |v| v / 16384.0 }
|
|
32
|
+
.each_slice(3).to_a
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def delta_index(gid)
|
|
37
|
+
return [0, gid] if @advance_map_offset.zero?
|
|
38
|
+
|
|
39
|
+
format, entry_format = @hvar[@advance_map_offset, 2].unpack("CC")
|
|
40
|
+
if format.zero?
|
|
41
|
+
count = @hvar[@advance_map_offset + 2, 2].unpack1("n")
|
|
42
|
+
pos = @advance_map_offset + 4
|
|
43
|
+
else
|
|
44
|
+
count = @hvar[@advance_map_offset + 2, 4].unpack1("N")
|
|
45
|
+
pos = @advance_map_offset + 6
|
|
46
|
+
end
|
|
47
|
+
inner_bits = (entry_format & 0x0F) + 1
|
|
48
|
+
size = ((entry_format & 0x30) >> 4) + 1
|
|
49
|
+
entry_gid = [gid, count - 1].min
|
|
50
|
+
entry = @hvar[pos + (entry_gid * size), size].bytes.inject(0) { |a, b| (a << 8) | b }
|
|
51
|
+
[entry >> inner_bits, entry & ((1 << inner_bits) - 1)]
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def delta(outer, inner, coords)
|
|
55
|
+
offset = @data_offsets[outer]
|
|
56
|
+
return 0.0 unless offset
|
|
57
|
+
|
|
58
|
+
item_count, word_field, region_count = @store[offset, 6].unpack("nnn")
|
|
59
|
+
return 0.0 if inner >= item_count
|
|
60
|
+
|
|
61
|
+
long = word_field.anybits?(0x8000)
|
|
62
|
+
word_count = word_field & 0x7FFF
|
|
63
|
+
indexes = @store[offset + 6, region_count * 2].unpack("n*")
|
|
64
|
+
big = long ? 4 : 2
|
|
65
|
+
small = long ? 2 : 1
|
|
66
|
+
row_size = (word_count * big) + ((region_count - word_count) * small)
|
|
67
|
+
pos = offset + 6 + (region_count * 2) + (inner * row_size)
|
|
68
|
+
words = @store[pos, word_count * big].unpack(long ? "l>*" : "s>*")
|
|
69
|
+
rest = @store[pos + (word_count * big), (region_count - word_count) * small]
|
|
70
|
+
.unpack(long ? "s>*" : "c*")
|
|
71
|
+
(words + rest).each_with_index.sum do |d, i|
|
|
72
|
+
d * region_scalar(@regions[indexes[i]], coords)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def region_scalar(region, coords)
|
|
77
|
+
scalar = 1.0
|
|
78
|
+
region.each_with_index do |(lower, peak, upper), i|
|
|
79
|
+
v = coords[i]
|
|
80
|
+
next if peak.zero? || lower > peak || peak > upper
|
|
81
|
+
next if lower.negative? && upper.positive?
|
|
82
|
+
next if v == peak
|
|
83
|
+
return 0.0 if v <= lower || upper <= v
|
|
84
|
+
|
|
85
|
+
scalar *= v < peak ? (v - lower) / (peak - lower) : (upper - v) / (upper - peak)
|
|
86
|
+
end
|
|
87
|
+
scalar
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fontisan
|
|
4
|
+
module Variation
|
|
5
|
+
module Instancing
|
|
6
|
+
# Rewrites a name table so it describes one instance of a family.
|
|
7
|
+
class NameRewriter
|
|
8
|
+
Record = Struct.new(:platform, :encoding, :language, :name_id, :text)
|
|
9
|
+
|
|
10
|
+
# @param name_data [String] raw name table
|
|
11
|
+
def initialize(name_data)
|
|
12
|
+
@records = parse(name_data)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def lookup(name_id)
|
|
16
|
+
same = @records.select { |r| r.name_id == name_id }
|
|
17
|
+
rec = same.find { |r| r.platform == 3 && r.language == 0x409 } ||
|
|
18
|
+
same.find { |r| r.platform == 3 } || same.first
|
|
19
|
+
rec&.text
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Names the instance +label+ (e.g. "Light") of the family.
|
|
23
|
+
def rename(label)
|
|
24
|
+
family = lookup(16) || lookup(1) || "Font"
|
|
25
|
+
legacy = ["Regular", "Bold", "Italic", "Bold Italic"].include?(label)
|
|
26
|
+
full = "#{family} #{label}".strip
|
|
27
|
+
ps = "#{family}-#{label}".delete(" ")
|
|
28
|
+
set(1, legacy ? family : full)
|
|
29
|
+
set(2, legacy ? label : "Regular")
|
|
30
|
+
set(3, "#{lookup(5) || ''};#{ps}".delete_prefix(";"))
|
|
31
|
+
set(4, full)
|
|
32
|
+
set(6, ps)
|
|
33
|
+
set(16, family)
|
|
34
|
+
set(17, label)
|
|
35
|
+
@records.reject! { |r| r.name_id == 25 }
|
|
36
|
+
self
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def to_binary
|
|
40
|
+
recs = @records.sort_by { |r| [r.platform, r.encoding, r.language, r.name_id] }
|
|
41
|
+
strings = +"".b
|
|
42
|
+
entries = recs.map do |r|
|
|
43
|
+
bin = encode(r)
|
|
44
|
+
off = strings.bytesize
|
|
45
|
+
strings << bin
|
|
46
|
+
[r.platform, r.encoding, r.language, r.name_id, bin.bytesize, off]
|
|
47
|
+
end
|
|
48
|
+
header = [0, recs.size, 6 + (recs.size * 12)].pack("nnn")
|
|
49
|
+
header + entries.map { |e| e.pack("n6") }.join + strings
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def set(name_id, text)
|
|
55
|
+
same = @records.select { |r| r.name_id == name_id }
|
|
56
|
+
same.each { |r| r.text = text }
|
|
57
|
+
return if same.any? { |r| r.platform == 3 }
|
|
58
|
+
|
|
59
|
+
@records << Record.new(3, 1, 0x409, name_id, text)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def parse(data)
|
|
63
|
+
count, str_off = data[2, 4].unpack("nn")
|
|
64
|
+
recs = Array.new(count) do |i|
|
|
65
|
+
plat, enc, lang, nid, len, off = data[6 + (i * 12), 12].unpack("n6")
|
|
66
|
+
raw = data[str_off + off, len]
|
|
67
|
+
Record.new(plat, enc, lang, nid, decode(plat, raw))
|
|
68
|
+
end
|
|
69
|
+
recs.reject { |r| r.language >= 0x8000 }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def decode(platform, raw)
|
|
73
|
+
if [0, 3].include?(platform)
|
|
74
|
+
raw.dup.force_encoding("UTF-16BE").encode("UTF-8", invalid: :replace)
|
|
75
|
+
else
|
|
76
|
+
raw.dup.force_encoding("ISO-8859-1").encode("UTF-8")
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def encode(rec)
|
|
81
|
+
if [0, 3].include?(rec.platform)
|
|
82
|
+
rec.text.encode("UTF-16BE").b
|
|
83
|
+
else
|
|
84
|
+
rec.text.encode("ISO-8859-1", invalid: :replace, undef: :replace).b
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fontisan
|
|
4
|
+
module Variation
|
|
5
|
+
module Instancing
|
|
6
|
+
# Produces a static TrueType font from a gvar-based variable font by
|
|
7
|
+
# applying gvar deltas (with IUP for untouched points) to glyf, HVAR or
|
|
8
|
+
# phantom-point deltas to hmtx, and updating OS/2 and name for the
|
|
9
|
+
# chosen location. cvar, GPOS/GDEF variations and MVAR are not applied.
|
|
10
|
+
class TrueTypeInstancer
|
|
11
|
+
DROPPED = %w[fvar gvar avar HVAR VVAR MVAR STAT cvar DSIG].freeze
|
|
12
|
+
WIDTH_CLASSES = [50, 62.5, 75, 87.5, 100, 112.5, 125, 150, 200].freeze
|
|
13
|
+
|
|
14
|
+
# @param tables [Hash{String=>String}] raw tables of the variable font
|
|
15
|
+
# @param coordinates [Hash{String=>Numeric}] axis tag => user value
|
|
16
|
+
def initialize(tables, coordinates)
|
|
17
|
+
@tables = tables
|
|
18
|
+
@coordinates = coordinates
|
|
19
|
+
check_font!
|
|
20
|
+
@space = AxisSpace.new(tables["fvar"], tables["avar"])
|
|
21
|
+
@location = @space.validate(coordinates)
|
|
22
|
+
@norm = @space.normalize(@location)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# @return [Hash{String=>String}] raw tables of the static instance
|
|
26
|
+
def call
|
|
27
|
+
num_glyphs = @tables["maxp"][4, 2].unpack1("n")
|
|
28
|
+
read_metrics(num_glyphs)
|
|
29
|
+
glyphs = Array.new(num_glyphs) { |gid| instance_glyph(gid) }
|
|
30
|
+
resolve_bounds(glyphs)
|
|
31
|
+
out = @tables.except(*DROPPED)
|
|
32
|
+
write_glyf_loca(out, glyphs)
|
|
33
|
+
write_hmtx(out, glyphs)
|
|
34
|
+
update_head_hhea(out, glyphs)
|
|
35
|
+
update_os2(out)
|
|
36
|
+
update_name(out)
|
|
37
|
+
out
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
Glyph = Struct.new(:outline, :points, :advance, :bbox, :data)
|
|
43
|
+
|
|
44
|
+
def check_font!
|
|
45
|
+
unless @tables["fvar"]
|
|
46
|
+
raise MissingVariationTableError.new(table: "fvar")
|
|
47
|
+
end
|
|
48
|
+
return if @tables["gvar"] && @tables["glyf"]
|
|
49
|
+
|
|
50
|
+
raise MissingVariationTableError.new(
|
|
51
|
+
table: "gvar",
|
|
52
|
+
message: "Static instancing needs TrueType outlines with gvar " \
|
|
53
|
+
"(CFF2 variable fonts are not supported yet)",
|
|
54
|
+
)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def read_metrics(num_glyphs)
|
|
58
|
+
nhm = @tables["hhea"][34, 2].unpack1("n")
|
|
59
|
+
hmtx = @tables["hmtx"]
|
|
60
|
+
@advances = []
|
|
61
|
+
@lsbs = []
|
|
62
|
+
num_glyphs.times do |gid|
|
|
63
|
+
if gid < nhm
|
|
64
|
+
@advances << hmtx[gid * 4, 2].unpack1("n")
|
|
65
|
+
@lsbs << hmtx[(gid * 4) + 2, 2].unpack1("s>")
|
|
66
|
+
else
|
|
67
|
+
@advances << @advances.last
|
|
68
|
+
@lsbs << hmtx[(nhm * 4) + ((gid - nhm) * 2), 2].unpack1("s>")
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
long = @tables["head"][50, 2].unpack1("s>") == 1
|
|
72
|
+
@loca = long ? @tables["loca"].unpack("N*") : @tables["loca"].unpack("n*").map { |v| v * 2 }
|
|
73
|
+
@gvar = GvarReader.new(@tables["gvar"])
|
|
74
|
+
@hvar = @tables["HVAR"] ? ItemVariationStore.new(@tables["HVAR"]) : nil
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def raw_glyph(gid)
|
|
78
|
+
@tables["glyf"].byteslice(@loca[gid]...@loca[gid + 1]) || "".b
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def instance_glyph(gid)
|
|
82
|
+
outline = GlyphOutline.parse(raw_glyph(gid))
|
|
83
|
+
orig = outline.points
|
|
84
|
+
n = orig.size
|
|
85
|
+
xmin = outline.empty? ? 0 : raw_glyph(gid)[2, 2].unpack1("s>")
|
|
86
|
+
pp1x = xmin - @lsbs[gid]
|
|
87
|
+
total = Array.new(n + 4) { [0.0, 0.0] }
|
|
88
|
+
@gvar.tuples_for(gid, n + 4).each do |t|
|
|
89
|
+
s = tuple_scalar(t)
|
|
90
|
+
next if s.zero?
|
|
91
|
+
|
|
92
|
+
dx, dy = expand_deltas(t, outline, orig)
|
|
93
|
+
(n + 4).times do |i|
|
|
94
|
+
total[i][0] += s * dx[i]
|
|
95
|
+
total[i][1] += s * dy[i]
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
pts = orig.each_with_index.map do |(x, y), i|
|
|
99
|
+
[round(x + total[i][0]), round(y + total[i][1])]
|
|
100
|
+
end
|
|
101
|
+
advance = if @hvar
|
|
102
|
+
@advances[gid] + round(@hvar.advance_delta(gid, @norm))
|
|
103
|
+
else
|
|
104
|
+
round(pp1x + @advances[gid] + total[n + 1][0]) -
|
|
105
|
+
round(pp1x + total[n][0])
|
|
106
|
+
end
|
|
107
|
+
Glyph.new(outline, pts, [advance, 0].max, nil, nil)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def round(value)
|
|
111
|
+
(value + 0.5).floor
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def tuple_scalar(tuple)
|
|
115
|
+
scalar = 1.0
|
|
116
|
+
tuple.peak.each_with_index do |peak, i|
|
|
117
|
+
next if peak.zero?
|
|
118
|
+
|
|
119
|
+
v = @norm[i]
|
|
120
|
+
next if v == peak
|
|
121
|
+
|
|
122
|
+
lo, hi = if tuple.intermediate?
|
|
123
|
+
[tuple.start[i], tuple.end[i]]
|
|
124
|
+
else
|
|
125
|
+
[0.0, peak].minmax
|
|
126
|
+
end
|
|
127
|
+
next if lo > peak || peak > hi || (lo.negative? && hi.positive?)
|
|
128
|
+
return 0.0 if v <= lo || hi <= v
|
|
129
|
+
|
|
130
|
+
scalar *= v < peak ? (v - lo) / (peak - lo) : (hi - v) / (hi - peak)
|
|
131
|
+
end
|
|
132
|
+
scalar
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Full per-point delta arrays (length n + 4), IUP applied when only
|
|
136
|
+
# some points were given.
|
|
137
|
+
def expand_deltas(tuple, outline, orig)
|
|
138
|
+
total = orig.size + 4
|
|
139
|
+
return [tuple.dx, tuple.dy] if tuple.points.nil?
|
|
140
|
+
|
|
141
|
+
dx = Array.new(total)
|
|
142
|
+
dy = Array.new(total)
|
|
143
|
+
tuple.points.each_with_index do |p, i|
|
|
144
|
+
next if p >= total
|
|
145
|
+
|
|
146
|
+
dx[p] = tuple.dx[i]
|
|
147
|
+
dy[p] = tuple.dy[i]
|
|
148
|
+
end
|
|
149
|
+
unless outline.composite?
|
|
150
|
+
iup(orig.map(&:first), dx, outline.end_pts)
|
|
151
|
+
iup(orig.map(&:last), dy, outline.end_pts)
|
|
152
|
+
end
|
|
153
|
+
[dx.map { |v| v || 0 }, dy.map { |v| v || 0 }]
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def iup(coords, deltas, ends)
|
|
157
|
+
start = 0
|
|
158
|
+
ends.each do |last|
|
|
159
|
+
touched = (start..last).select { |i| deltas[i] }
|
|
160
|
+
if touched.empty?
|
|
161
|
+
(start..last).each { |i| deltas[i] = 0 }
|
|
162
|
+
elsif touched.size == 1
|
|
163
|
+
(start..last).each { |i| deltas[i] = deltas[touched.first] }
|
|
164
|
+
else
|
|
165
|
+
touched.each_with_index do |t, k|
|
|
166
|
+
nxt = touched[(k + 1) % touched.size]
|
|
167
|
+
i = t
|
|
168
|
+
loop do
|
|
169
|
+
i = i == last ? start : i + 1
|
|
170
|
+
break if i == nxt
|
|
171
|
+
|
|
172
|
+
deltas[i] = iup_value(coords[i], coords[t], deltas[t],
|
|
173
|
+
coords[nxt], deltas[nxt])
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
start = last + 1
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def iup_value(coord, ref1, delta1, ref2, delta2)
|
|
182
|
+
return (delta1 == delta2 ? delta1 : 0) if ref1 == ref2
|
|
183
|
+
|
|
184
|
+
ref1, delta1, ref2, delta2 = ref2, delta2, ref1, delta1 if ref1 > ref2
|
|
185
|
+
return delta1 if coord <= ref1
|
|
186
|
+
return delta2 if coord >= ref2
|
|
187
|
+
|
|
188
|
+
delta1 + ((coord - ref1) * (delta2 - delta1) / (ref2 - ref1).to_f)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# Bounding boxes; composites derive theirs from their components.
|
|
192
|
+
def resolve_bounds(glyphs)
|
|
193
|
+
glyphs.each_index { |gid| bbox_for(glyphs, gid, []) }
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def bbox_for(glyphs, gid, stack)
|
|
197
|
+
g = glyphs[gid]
|
|
198
|
+
return g.bbox if g.bbox
|
|
199
|
+
return g.bbox = [0, 0, 0, 0] if g.outline.empty? || stack.include?(gid)
|
|
200
|
+
|
|
201
|
+
if g.outline.composite?
|
|
202
|
+
corners = []
|
|
203
|
+
g.outline.components.each_with_index do |c, i|
|
|
204
|
+
sub = bbox_for(glyphs, c.gid, stack + [gid])
|
|
205
|
+
next if glyphs[c.gid].outline.empty?
|
|
206
|
+
|
|
207
|
+
corners.concat(transform_corners(sub, c, g.points[i]))
|
|
208
|
+
end
|
|
209
|
+
g.bbox = corners.empty? ? [0, 0, 0, 0] : g.outline.bounds_of(corners).map(&:round)
|
|
210
|
+
else
|
|
211
|
+
g.bbox = g.outline.bounds_of(g.points)
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def transform_corners(bbox, comp, offset)
|
|
216
|
+
xmin, ymin, xmax, ymax = bbox
|
|
217
|
+
t = comp.transform
|
|
218
|
+
a, b, c, d = case t.bytesize
|
|
219
|
+
when 2 then s = t.unpack1("s>") / 16384.0
|
|
220
|
+
[s, 0, 0, s]
|
|
221
|
+
when 4 then sx, sy = t.unpack("s>s>").map { |v| v / 16384.0 }
|
|
222
|
+
[sx, 0, 0, sy]
|
|
223
|
+
when 8 then xs, s01, s10, ys = t.unpack("s>4").map { |v| v / 16384.0 }
|
|
224
|
+
[xs, s01, s10, ys]
|
|
225
|
+
else [1, 0, 0, 1]
|
|
226
|
+
end
|
|
227
|
+
[[xmin, ymin], [xmin, ymax], [xmax, ymin], [xmax, ymax]].map do |x, y|
|
|
228
|
+
[(a * x) + (c * y) + offset[0], (b * x) + (d * y) + offset[1]]
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def write_glyf_loca(out, glyphs)
|
|
233
|
+
glyf = +"".b
|
|
234
|
+
loca = [0]
|
|
235
|
+
glyphs.each do |g|
|
|
236
|
+
data = g.outline.serialize(g.points, g.bbox)
|
|
237
|
+
data << "\0".b until (data.bytesize % 4).zero?
|
|
238
|
+
glyf << data
|
|
239
|
+
loca << glyf.bytesize
|
|
240
|
+
end
|
|
241
|
+
out["glyf"] = glyf
|
|
242
|
+
out["loca"] = loca.pack("N*")
|
|
243
|
+
head = out["head"].dup
|
|
244
|
+
head[50, 2] = [1].pack("s>")
|
|
245
|
+
out["head"] = head
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def write_hmtx(out, glyphs)
|
|
249
|
+
hmtx = +"".b
|
|
250
|
+
glyphs.each_with_index do |g, gid|
|
|
251
|
+
lsb = g.outline.empty? ? @lsbs[gid] : g.bbox[0]
|
|
252
|
+
g.data = [g.advance, lsb]
|
|
253
|
+
hmtx << [g.advance, lsb].pack("ns>")
|
|
254
|
+
end
|
|
255
|
+
out["hmtx"] = hmtx
|
|
256
|
+
hhea = out["hhea"].dup
|
|
257
|
+
hhea[34, 2] = [glyphs.size].pack("n")
|
|
258
|
+
out["hhea"] = hhea
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def update_head_hhea(out, glyphs)
|
|
262
|
+
drawn = glyphs.reject { |g| g.outline.empty? }
|
|
263
|
+
return if drawn.empty?
|
|
264
|
+
|
|
265
|
+
head = out["head"].dup
|
|
266
|
+
head[36, 8] = [drawn.map { |g| g.bbox[0] }.min, drawn.map { |g| g.bbox[1] }.min,
|
|
267
|
+
drawn.map { |g| g.bbox[2] }.max, drawn.map { |g| g.bbox[3] }.max].pack("s>4")
|
|
268
|
+
out["head"] = head
|
|
269
|
+
hhea = out["hhea"].dup
|
|
270
|
+
min_lsb = drawn.map { |g| g.data[1] }.min
|
|
271
|
+
min_rsb = drawn.map { |g| g.data[0] - g.data[1] - (g.bbox[2] - g.bbox[0]) }.min
|
|
272
|
+
max_ext = drawn.map { |g| g.data[1] + (g.bbox[2] - g.bbox[0]) }.max
|
|
273
|
+
hhea[10, 8] = [glyphs.map(&:advance).max, min_lsb, min_rsb, max_ext].pack("ns>s>s>")
|
|
274
|
+
out["hhea"] = hhea
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def update_os2(out)
|
|
278
|
+
return unless out["OS/2"]
|
|
279
|
+
|
|
280
|
+
os2 = out["OS/2"].dup
|
|
281
|
+
if @location.key?("wght")
|
|
282
|
+
os2[4, 2] = [@location["wght"].round.clamp(1, 1000)].pack("n")
|
|
283
|
+
end
|
|
284
|
+
if @location.key?("wdth")
|
|
285
|
+
nearest = WIDTH_CLASSES.each_index.min_by { |i| (WIDTH_CLASSES[i] - @location["wdth"]).abs }
|
|
286
|
+
os2[6, 2] = [nearest + 1].pack("n")
|
|
287
|
+
end
|
|
288
|
+
out["OS/2"] = os2
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def update_name(out)
|
|
292
|
+
return unless out["name"]
|
|
293
|
+
|
|
294
|
+
rewriter = NameRewriter.new(out["name"])
|
|
295
|
+
out["name"] = rewriter.rename(instance_label(rewriter)).to_binary
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def instance_label(rewriter)
|
|
299
|
+
match = AxisSpace.named_instances(@tables["fvar"]).find do |inst|
|
|
300
|
+
@space.tags.each_with_index.all? do |tag, i|
|
|
301
|
+
(inst[:values][i] - @location[tag]).abs < 1e-6
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
named = match && rewriter.lookup(match[:name_id])
|
|
305
|
+
return named if named
|
|
306
|
+
|
|
307
|
+
parts = @space.axes.reject { |a| @location[a.tag] == a.default }
|
|
308
|
+
.map { |a| "#{a.tag.capitalize}#{format_value(@location[a.tag])}" }
|
|
309
|
+
parts.empty? ? "Regular" : parts.join(" ")
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
def format_value(value)
|
|
313
|
+
value == value.to_i ? value.to_i.to_s : value.to_s
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
end
|
|
317
|
+
end
|
|
318
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Autoload hub for the Fontisan::Variation::Instancing namespace.
|
|
4
|
+
|
|
5
|
+
module Fontisan
|
|
6
|
+
module Variation
|
|
7
|
+
# Static instancing of TrueType variable fonts: gvar/HVAR delta
|
|
8
|
+
# application, glyf/hmtx rebuilding and OS/2 + name updates.
|
|
9
|
+
module Instancing
|
|
10
|
+
autoload :AxisSpace, "fontisan/variation/instancing/axis_space"
|
|
11
|
+
autoload :GlyphOutline, "fontisan/variation/instancing/glyph_outline"
|
|
12
|
+
autoload :GvarReader, "fontisan/variation/instancing/gvar_reader"
|
|
13
|
+
autoload :ItemVariationStore,
|
|
14
|
+
"fontisan/variation/instancing/item_variation_store"
|
|
15
|
+
autoload :NameRewriter, "fontisan/variation/instancing/name_rewriter"
|
|
16
|
+
autoload :TrueTypeInstancer,
|
|
17
|
+
"fontisan/variation/instancing/true_type_instancer"
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -107,6 +107,20 @@ module Fontisan
|
|
|
107
107
|
# Returns all axes at their default values.
|
|
108
108
|
#
|
|
109
109
|
# @return [Hash<String, Float>] Default coordinates
|
|
110
|
+
# Out-of-range requests are pulled to the nearest axis limit and
|
|
111
|
+
# unknown axes dropped, so SVG preview stays tolerant.
|
|
112
|
+
def clamp_to_axes(coords)
|
|
113
|
+
return coords unless @font.has_table?("fvar")
|
|
114
|
+
|
|
115
|
+
by_tag = @font.table("fvar").axes.to_h { |a| [a.axis_tag.to_s, a] }
|
|
116
|
+
coords.each_with_object({}) do |(tag, value), clamped|
|
|
117
|
+
axis = by_tag[tag.to_s]
|
|
118
|
+
next unless axis
|
|
119
|
+
|
|
120
|
+
clamped[tag.to_s] = Float(value).clamp(axis.min_value, axis.max_value)
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
110
124
|
def default_coordinates
|
|
111
125
|
return {} unless @font.has_table?("fvar")
|
|
112
126
|
|
|
@@ -164,7 +178,11 @@ module Fontisan
|
|
|
164
178
|
# @return [Hash<String, String>] Instance tables
|
|
165
179
|
def generate_static_instance
|
|
166
180
|
# Use coordinates or defaults if none specified
|
|
167
|
-
coords = @coordinates.empty?
|
|
181
|
+
coords = if @coordinates.empty?
|
|
182
|
+
default_coordinates
|
|
183
|
+
else
|
|
184
|
+
clamp_to_axes(@coordinates)
|
|
185
|
+
end
|
|
168
186
|
|
|
169
187
|
instance_generator = InstanceGenerator.new(@font, coords)
|
|
170
188
|
instance_generator.generate
|
data/lib/fontisan/variation.rb
CHANGED
|
@@ -15,6 +15,7 @@ module Fontisan
|
|
|
15
15
|
autoload :InstanceGenerator, "fontisan/variation/instance_generator"
|
|
16
16
|
autoload :InstanceFontWrapper, "fontisan/variation/instance_font_wrapper"
|
|
17
17
|
autoload :InstanceWriter, "fontisan/variation/instance_writer"
|
|
18
|
+
autoload :Instancing, "fontisan/variation/instancing"
|
|
18
19
|
autoload :Interpolator, "fontisan/variation/interpolator"
|
|
19
20
|
autoload :MetricsAdjuster, "fontisan/variation/metrics_adjuster"
|
|
20
21
|
autoload :Optimizer, "fontisan/variation/optimizer"
|
data/lib/fontisan/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: fontisan
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.4.
|
|
4
|
+
version: 0.4.47
|
|
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-11-03 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: base64
|
|
@@ -322,7 +322,6 @@ files:
|
|
|
322
322
|
- docs/public/web-app-manifest-512x512.png
|
|
323
323
|
- docs/scripts/post-build.mjs
|
|
324
324
|
- exe/fontisan
|
|
325
|
-
- fontisan.gemspec
|
|
326
325
|
- lib/fontisan.rb
|
|
327
326
|
- lib/fontisan/audit.rb
|
|
328
327
|
- lib/fontisan/audit/check.rb
|
|
@@ -644,6 +643,9 @@ files:
|
|
|
644
643
|
- lib/fontisan/tables/glyf/simple_glyph.rb
|
|
645
644
|
- lib/fontisan/tables/glyf_table.rb
|
|
646
645
|
- lib/fontisan/tables/gpos.rb
|
|
646
|
+
- lib/fontisan/tables/gpos/glyph_filter.rb
|
|
647
|
+
- lib/fontisan/tables/gpos/kerning_pairs.rb
|
|
648
|
+
- lib/fontisan/tables/gpos/pair_position.rb
|
|
647
649
|
- lib/fontisan/tables/gsub.rb
|
|
648
650
|
- lib/fontisan/tables/gvar.rb
|
|
649
651
|
- lib/fontisan/tables/head.rb
|
|
@@ -825,6 +827,13 @@ files:
|
|
|
825
827
|
- lib/fontisan/variation/instance_font_wrapper.rb
|
|
826
828
|
- lib/fontisan/variation/instance_generator.rb
|
|
827
829
|
- lib/fontisan/variation/instance_writer.rb
|
|
830
|
+
- lib/fontisan/variation/instancing.rb
|
|
831
|
+
- lib/fontisan/variation/instancing/axis_space.rb
|
|
832
|
+
- lib/fontisan/variation/instancing/glyph_outline.rb
|
|
833
|
+
- lib/fontisan/variation/instancing/gvar_reader.rb
|
|
834
|
+
- lib/fontisan/variation/instancing/item_variation_store.rb
|
|
835
|
+
- lib/fontisan/variation/instancing/name_rewriter.rb
|
|
836
|
+
- lib/fontisan/variation/instancing/true_type_instancer.rb
|
|
828
837
|
- lib/fontisan/variation/interpolator.rb
|
|
829
838
|
- lib/fontisan/variation/metrics_adjuster.rb
|
|
830
839
|
- lib/fontisan/variation/optimizer.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
|