alhena 0.1.0
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 +7 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +178 -0
- data/examples/color.rb +11 -0
- data/examples/render.rb +21 -0
- data/lib/alhena/binary.rb +49 -0
- data/lib/alhena/bitmap.rb +27 -0
- data/lib/alhena/cache.rb +46 -0
- data/lib/alhena/cff.rb +423 -0
- data/lib/alhena/color.rb +342 -0
- data/lib/alhena/data_compat.rb +25 -0
- data/lib/alhena/font.rb +427 -0
- data/lib/alhena/outline.rb +100 -0
- data/lib/alhena/png.rb +171 -0
- data/lib/alhena/rasterizer.rb +172 -0
- data/lib/alhena/variation.rb +314 -0
- data/lib/alhena/version.rb +5 -0
- data/lib/alhena.rb +21 -0
- data/sig/alhena.rbs +117 -0
- metadata +64 -0
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Alhena
|
|
4
|
+
# Analytic signed-area scan conversion; no supersampling for grayscale.
|
|
5
|
+
class Rasterizer
|
|
6
|
+
MAX_PIXELS = 16_777_216
|
|
7
|
+
|
|
8
|
+
def initialize(width:, height:, tolerance: 0.25)
|
|
9
|
+
unless [width, height].all? { |n| n.is_a?(Integer) && n >= 0 && n <= MAX_PIXELS } && width * height <= MAX_PIXELS
|
|
10
|
+
raise ArgumentError, "invalid or excessively large raster dimensions"
|
|
11
|
+
end
|
|
12
|
+
raise ArgumentError, "tolerance must be positive" unless tolerance.is_a?(Numeric) && tolerance.finite? && tolerance > 0
|
|
13
|
+
@width, @height, @tolerance = width, height, tolerance.to_f
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Coordinates increase right and down. Open subpaths are implicitly closed.
|
|
17
|
+
# darkening is a coverage-space gain (0..1); gamma is a positive exponent.
|
|
18
|
+
def fill(outline, transform: nil, left: 0, top: 0, gamma: 1.0, darkening: 0.0, lcd: nil)
|
|
19
|
+
raise ArgumentError, "gamma must be positive" unless gamma.is_a?(Numeric) && gamma.finite? && gamma > 0
|
|
20
|
+
raise ArgumentError, "darkening must be between 0 and 1" unless darkening.is_a?(Numeric) && darkening.finite? && (0..1).cover?(darkening)
|
|
21
|
+
raise ArgumentError, "LCD order must be :rgb or :bgr" unless [nil, :rgb, :bgr].include?(lcd)
|
|
22
|
+
if @width.zero? || @height.zero?
|
|
23
|
+
return Bitmap.new(width: @width, height: @height, left: left, top: top, coverage: "", channels: lcd ? 3 : 1)
|
|
24
|
+
end
|
|
25
|
+
outline = outline.transform(transform) if transform
|
|
26
|
+
return lcd_bitmap(outline, left: left, top: top, gamma: gamma, darkening: darkening, order: lcd) if lcd
|
|
27
|
+
@area = Array.new((@width + 1) * @height, 0.0)
|
|
28
|
+
draw_outline(outline)
|
|
29
|
+
Bitmap.new(width: @width, height: @height, left: left, top: top, coverage: grayscale_coverage(gamma, darkening))
|
|
30
|
+
ensure
|
|
31
|
+
@area = nil
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def lcd_bitmap(outline, left:, top:, gamma:, darkening:, order:)
|
|
37
|
+
high = self.class.new(width: @width * 3 + 4, height: @height, tolerance: @tolerance)
|
|
38
|
+
coverage = high.fill(outline, transform: [3, 0, 0, 1, 2, 0]).coverage
|
|
39
|
+
output = String.new(capacity: @width * @height * 3, encoding: Encoding::BINARY)
|
|
40
|
+
@height.times do |y|
|
|
41
|
+
@width.times do |x|
|
|
42
|
+
values = 3.times.map do |channel|
|
|
43
|
+
at = y * (@width * 3 + 4) + x * 3 + channel
|
|
44
|
+
value = (coverage.getbyte(at) + 2 * coverage.getbyte(at + 1) + 3 * coverage.getbyte(at + 2) +
|
|
45
|
+
2 * coverage.getbyte(at + 3) + coverage.getbyte(at + 4)) / (9.0 * 255)
|
|
46
|
+
encode_coverage(value, gamma, darkening)
|
|
47
|
+
end
|
|
48
|
+
values.reverse! if order == :bgr
|
|
49
|
+
values.each { |value| output << value }
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
Bitmap.new(width: @width, height: @height, left: left, top: top, coverage: output, channels: 3)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def draw_outline(outline)
|
|
56
|
+
x = y = sx = sy = 0.0
|
|
57
|
+
opened = false
|
|
58
|
+
outline.each do |command, *args|
|
|
59
|
+
case command
|
|
60
|
+
when :move_to
|
|
61
|
+
draw_line(x, y, sx, sy) if opened
|
|
62
|
+
x, y = args
|
|
63
|
+
sx, sy = x, y
|
|
64
|
+
opened = true
|
|
65
|
+
when :line_to
|
|
66
|
+
draw_line(x, y, *args)
|
|
67
|
+
x, y = args
|
|
68
|
+
when :quad_to
|
|
69
|
+
flatten_quad(x, y, *args)
|
|
70
|
+
x, y = args[-2, 2]
|
|
71
|
+
when :cubic_to
|
|
72
|
+
flatten_cubic(x, y, *args)
|
|
73
|
+
x, y = args[-2, 2]
|
|
74
|
+
when :close
|
|
75
|
+
draw_line(x, y, sx, sy) if opened
|
|
76
|
+
x, y, opened = sx, sy, false
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
draw_line(x, y, sx, sy) if opened
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def grayscale_coverage(gamma, darkening)
|
|
83
|
+
output = String.new(capacity: @width * @height, encoding: Encoding::BINARY)
|
|
84
|
+
@height.times do |row|
|
|
85
|
+
acc = 0.0
|
|
86
|
+
@width.times do |col|
|
|
87
|
+
acc += @area[row * (@width + 1) + col]
|
|
88
|
+
output << encode_coverage([acc.abs, 1.0].min, gamma, darkening)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
output
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def encode_coverage(value, gamma, darkening)
|
|
95
|
+
value = [value * (1.0 + darkening), 1.0].min
|
|
96
|
+
((gamma == 1.0 ? value : value**(1.0 / gamma)) * 255).round
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def flatten_quad(x0, y0, cx, cy, x1, y1)
|
|
100
|
+
deviation = [(x0 - 2 * cx + x1).abs, (y0 - 2 * cy + y1).abs].max
|
|
101
|
+
needed = [Math.sqrt(deviation / @tolerance).ceil, 1].max
|
|
102
|
+
count = [1 << (needed - 1).bit_length, 4096].min
|
|
103
|
+
px, py = x0, y0
|
|
104
|
+
1.upto(count) do |i|
|
|
105
|
+
t = i.to_f / count
|
|
106
|
+
s = 1.0 - t
|
|
107
|
+
x = s * s * x0 + 2 * s * t * cx + t * t * x1
|
|
108
|
+
y = s * s * y0 + 2 * s * t * cy + t * t * y1
|
|
109
|
+
draw_line(px, py, x, y)
|
|
110
|
+
px, py = x, y
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def flatten_cubic(x0, y0, c1x, c1y, c2x, c2y, x1, y1, depth = 0)
|
|
115
|
+
deviation = [(2 * x1 - 3 * c2x + x0).abs, (2 * y1 - 3 * c2y + y0).abs,
|
|
116
|
+
(x1 - 3 * c1x + 2 * x0).abs, (y1 - 3 * c1y + 2 * y0).abs].max
|
|
117
|
+
if deviation <= @tolerance * 2 || depth >= 16
|
|
118
|
+
draw_line(x0, y0, x1, y1)
|
|
119
|
+
else
|
|
120
|
+
ax, ay = (x0 + c1x) / 2.0, (y0 + c1y) / 2.0
|
|
121
|
+
bx, by = (c1x + c2x) / 2.0, (c1y + c2y) / 2.0
|
|
122
|
+
cx, cy = (c2x + x1) / 2.0, (c2y + y1) / 2.0
|
|
123
|
+
dx, dy = (ax + bx) / 2.0, (ay + by) / 2.0
|
|
124
|
+
ex, ey = (bx + cx) / 2.0, (by + cy) / 2.0
|
|
125
|
+
mx, my = (dx + ex) / 2.0, (dy + ey) / 2.0
|
|
126
|
+
flatten_cubic(x0, y0, ax, ay, dx, dy, mx, my, depth + 1)
|
|
127
|
+
flatten_cubic(mx, my, ex, ey, cx, cy, x1, y1, depth + 1)
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Integral of clamp(x, 0, 1), used to integrate an edge's cell coverage.
|
|
132
|
+
def coverage_integral(x)
|
|
133
|
+
x <= 0 ? 0.0 : x >= 1 ? x - 0.5 : x * x * 0.5
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def draw_line(x0, y0, x1, y1)
|
|
137
|
+
return if y0 == y1 || @width.zero? || @height.zero?
|
|
138
|
+
sign = 1.0
|
|
139
|
+
if y0 > y1
|
|
140
|
+
x0, y0, x1, y1 = x1, y1, x0, y0
|
|
141
|
+
sign = -1.0
|
|
142
|
+
end
|
|
143
|
+
first, last = [y0.floor, 0].max, [y1.ceil, @height].min
|
|
144
|
+
slope = (x1 - x0) / (y1 - y0)
|
|
145
|
+
first.upto(last - 1) do |row|
|
|
146
|
+
ya, yb = [y0, row].max, [y1, row + 1].min
|
|
147
|
+
xa, xb = x0 + (ya - y0) * slope, x0 + (yb - y0) * slope
|
|
148
|
+
xa, xb = xb, xa if xa > xb
|
|
149
|
+
height = (yb - ya) * sign
|
|
150
|
+
at = row * (@width + 1)
|
|
151
|
+
if xb <= 0
|
|
152
|
+
@area[at] += height
|
|
153
|
+
next
|
|
154
|
+
end
|
|
155
|
+
next if xa >= @width
|
|
156
|
+
start, finish = [xa.floor, 0].max, [xb.floor, @width - 1].min
|
|
157
|
+
previous = 0.0
|
|
158
|
+
start.upto(finish) do |col|
|
|
159
|
+
fraction = if xb - xa < 1e-12
|
|
160
|
+
[[col + 1 - xa, 0].max, 1].min
|
|
161
|
+
else
|
|
162
|
+
(coverage_integral(col + 1 - xa) - coverage_integral(col + 1 - xb)) / (xb - xa)
|
|
163
|
+
end
|
|
164
|
+
value = height * fraction
|
|
165
|
+
@area[at + col] += value - previous
|
|
166
|
+
previous = value
|
|
167
|
+
end
|
|
168
|
+
@area[at + finish + 1] += height - previous
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Alhena
|
|
4
|
+
# Shared ItemVariationStore interpolation used by CFF2, HVAR and VVAR.
|
|
5
|
+
class VariationStore
|
|
6
|
+
def initialize(data, offset, coordinates)
|
|
7
|
+
@data, @offset, @coordinates = data, offset, coordinates
|
|
8
|
+
raise InvalidFont, "invalid item variation store" unless data.u16(offset) == 1
|
|
9
|
+
region_offset = offset + data.u32(offset + 2)
|
|
10
|
+
count = data.u16(offset + 6)
|
|
11
|
+
data.validate_bounds(offset + 8, count * 4)
|
|
12
|
+
@items = count.times.map { |i| offset + data.u32(offset + 8 + i * 4) }
|
|
13
|
+
axes, regions = data.u16(region_offset), data.u16(region_offset + 2)
|
|
14
|
+
raise InvalidFont, "variation axis count mismatch" unless axes == coordinates.length
|
|
15
|
+
data.validate_bounds(region_offset + 4, axes * regions * 6)
|
|
16
|
+
@scalars = regions.times.map do |region|
|
|
17
|
+
scalar = 1.0
|
|
18
|
+
axes.times do |axis|
|
|
19
|
+
at = region_offset + 4 + (region * axes + axis) * 6
|
|
20
|
+
start, peak, finish = 3.times.map { |i| data.i16(at + i * 2) / 16_384.0 }
|
|
21
|
+
next if peak.zero? || start > peak || peak > finish || (start < 0 && finish > 0)
|
|
22
|
+
coordinate = coordinates[axis]
|
|
23
|
+
scalar *= if coordinate == peak
|
|
24
|
+
1.0
|
|
25
|
+
elsif coordinate <= start || coordinate >= finish
|
|
26
|
+
0.0
|
|
27
|
+
elsif coordinate < peak
|
|
28
|
+
(coordinate - start) / (peak - start)
|
|
29
|
+
else
|
|
30
|
+
(finish - coordinate) / (finish - peak)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
scalar
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def region_scalars(index)
|
|
38
|
+
at = @items.fetch(index) { raise InvalidFont, "variation outer index out of range" }
|
|
39
|
+
count = @data.u16(at + 4)
|
|
40
|
+
@data.validate_bounds(at + 6, count * 2)
|
|
41
|
+
count.times.map { |i| @scalars.fetch(@data.u16(at + 6 + i * 2)) { raise InvalidFont, "variation region out of range" } }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def delta(outer, inner)
|
|
45
|
+
return 0.0 if outer == 0xffff && inner == 0xffff
|
|
46
|
+
at = @items.fetch(outer) { raise InvalidFont, "variation outer index out of range" }
|
|
47
|
+
items, words, count = @data.u16(at), @data.u16(at + 2), @data.u16(at + 4)
|
|
48
|
+
raise InvalidFont, "variation inner index out of range" unless inner >= 0 && inner < items
|
|
49
|
+
long = words & 0x8000 != 0
|
|
50
|
+
words &= 0x7fff
|
|
51
|
+
raise InvalidFont, "invalid variation word count" if words > count
|
|
52
|
+
stride = long ? words * 4 + (count - words) * 2 : words * 2 + count - words
|
|
53
|
+
position = at + 6 + count * 2 + inner * stride
|
|
54
|
+
scalars = region_scalars(outer)
|
|
55
|
+
@data.validate_bounds(position, stride)
|
|
56
|
+
@data.position = position
|
|
57
|
+
scalars.each_with_index.sum do |scalar, i|
|
|
58
|
+
value = long ? (i < words ? @data.i32 : @data.i16) : (i < words ? @data.i16 : @data.i8)
|
|
59
|
+
value * scalar
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def self.index(data, at, glyph)
|
|
64
|
+
return [0, glyph] if at.zero?
|
|
65
|
+
format, entry_format = data.u8(at), data.u8(at + 1)
|
|
66
|
+
raise InvalidFont, "invalid variation index map" unless [0, 1].include?(format)
|
|
67
|
+
count = format.zero? ? data.u16(at + 2) : data.u32(at + 2)
|
|
68
|
+
return [0xffff, 0xffff] if count.zero?
|
|
69
|
+
width = ((entry_format >> 4) & 3) + 1
|
|
70
|
+
bits = (entry_format & 15) + 1
|
|
71
|
+
start = at + (format.zero? ? 4 : 6) + [glyph, count - 1].min * width
|
|
72
|
+
value = 0
|
|
73
|
+
width.times { |i| value = (value << 8) | data.u8(start + i) }
|
|
74
|
+
[value >> bits, value & ((1 << bits) - 1)]
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
class Font
|
|
79
|
+
# Axis descriptors are in fvar order; coordinates use user-space values.
|
|
80
|
+
def axes
|
|
81
|
+
@axes ||= begin
|
|
82
|
+
if @tables.key?("fvar")
|
|
83
|
+
data = table("fvar")
|
|
84
|
+
offset, count, stride = data.u16(4), data.u16(8), data.u16(10)
|
|
85
|
+
raise InvalidFont, "invalid fvar axis record size" if stride < 20
|
|
86
|
+
data.validate_bounds(offset, count * stride)
|
|
87
|
+
count.times.to_h do |i|
|
|
88
|
+
at = offset + i * stride
|
|
89
|
+
tag = data.bytes(at, 4)
|
|
90
|
+
minimum, default, maximum = data.fixed(at + 4), data.fixed(at + 8), data.fixed(at + 12)
|
|
91
|
+
raise InvalidFont, "invalid fvar axis range" unless minimum <= default && default <= maximum
|
|
92
|
+
[tag, {min: minimum, default: default, max: maximum, hidden: data.u16(at + 16) & 1 != 0, name: names[data.u16(at + 18)]}.freeze]
|
|
93
|
+
end.freeze
|
|
94
|
+
else
|
|
95
|
+
{}.freeze
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def variation(values)
|
|
101
|
+
self.class.new(@binary.data, index: @index, axes: @axis_values.merge(values.transform_keys(&:to_s)))
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def normalized_coordinates
|
|
105
|
+
@normalized_coordinates ||= begin
|
|
106
|
+
unknown = @axis_values.keys - axes.keys
|
|
107
|
+
raise ArgumentError, "unknown variation axes: #{unknown.join(', ')}" unless unknown.empty?
|
|
108
|
+
values = axes.map do |tag, axis|
|
|
109
|
+
value = @axis_values.fetch(tag, axis[:default])
|
|
110
|
+
raise ArgumentError, "axis values must be finite" unless value.is_a?(Numeric) && value.finite?
|
|
111
|
+
value = [[value, axis[:min]].max, axis[:max]].min
|
|
112
|
+
value == axis[:default] ? 0.0 : (value - axis[:default]) / (value < axis[:default] ? axis[:default] - axis[:min] : axis[:max] - axis[:default])
|
|
113
|
+
end
|
|
114
|
+
if @tables.key?("avar")
|
|
115
|
+
data = table("avar")
|
|
116
|
+
raise UnsupportedFont, "avar version 2 is unsupported" unless data.u16(0) == 1
|
|
117
|
+
raise InvalidFont, "avar axis count mismatch" unless data.u16(6) == values.length
|
|
118
|
+
data.position = 8
|
|
119
|
+
values.map! do |value|
|
|
120
|
+
count = data.u16
|
|
121
|
+
maps = count.times.map { [data.i16 / 16_384.0, data.i16 / 16_384.0] }
|
|
122
|
+
raise InvalidFont, "invalid avar segment map" unless maps.length >= 3 && maps.each_cons(2).all? { |a, b| a[0] < b[0] }
|
|
123
|
+
match = maps.find { |from, _| from == value }
|
|
124
|
+
if match
|
|
125
|
+
match[1]
|
|
126
|
+
else
|
|
127
|
+
pair = maps.each_cons(2).find { |a, b| value > a[0] && value < b[0] }
|
|
128
|
+
raise InvalidFont, "avar map does not cover coordinate" unless pair
|
|
129
|
+
a, b = pair
|
|
130
|
+
a[1] + (b[1] - a[1]) * (value - a[0]) / (b[0] - a[0])
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
values.freeze
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
private
|
|
139
|
+
|
|
140
|
+
def variable? = @tables.key?("fvar") && normalized_coordinates.any? { |n| n != 0 }
|
|
141
|
+
|
|
142
|
+
def metric(glyph, vertical: false)
|
|
143
|
+
result = raw_metric(glyph, vertical: vertical)
|
|
144
|
+
return result unless variable?
|
|
145
|
+
tag = vertical ? "VVAR" : "HVAR"
|
|
146
|
+
if @tables.key?(tag)
|
|
147
|
+
data = table(tag)
|
|
148
|
+
@metric_stores ||= {}
|
|
149
|
+
store = @metric_stores[tag] ||= VariationStore.new(data, data.u32(4), normalized_coordinates)
|
|
150
|
+
result[0] += store.delta(*VariationStore.index(data, data.u32(8), glyph))
|
|
151
|
+
mapping = data.u32(12)
|
|
152
|
+
result[1] += store.delta(*VariationStore.index(data, mapping, glyph)) unless mapping.zero?
|
|
153
|
+
elsif @tables.key?("gvar")
|
|
154
|
+
@metric_deltas ||= {}
|
|
155
|
+
truetype_glyph(glyph, []) unless @metric_deltas.key?(glyph)
|
|
156
|
+
deltas = @metric_deltas.fetch(glyph, [0, 0])
|
|
157
|
+
result[0] += deltas[vertical ? 1 : 0]
|
|
158
|
+
end
|
|
159
|
+
result
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def vary_points(glyph, points, ends, glyph_data)
|
|
163
|
+
return points unless variable? && @tables.key?("gvar")
|
|
164
|
+
# Phantom points let gvar supply advances when HVAR/VVAR are absent.
|
|
165
|
+
advance, bearing = raw_metric(glyph)
|
|
166
|
+
xmin = glyph_data.size >= 10 ? glyph_data.i16(2) : 0
|
|
167
|
+
ymax = glyph_data.size >= 10 ? glyph_data.i16(8) : 0
|
|
168
|
+
left = xmin - bearing
|
|
169
|
+
vadvance, vbearing = @tables.key?("vmtx") ? raw_metric(glyph, vertical: true) : [ascent - descent, ascent - ymax]
|
|
170
|
+
top = ymax + vbearing
|
|
171
|
+
all = points + [[left, 0], [left + advance, 0], [0, top], [0, top - vadvance]]
|
|
172
|
+
deltas = glyph_deltas(glyph, all, ends)
|
|
173
|
+
@metric_deltas ||= {}
|
|
174
|
+
@metric_deltas[glyph] = [deltas[-3][0] - deltas[-4][0], deltas[-2][1] - deltas[-1][1]]
|
|
175
|
+
points.each_with_index.map { |(x, y), i| [x + deltas[i][0], y + deltas[i][1]] }
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def glyph_deltas(glyph, points, ends)
|
|
179
|
+
data = table("gvar")
|
|
180
|
+
axis_count = data.u16(4)
|
|
181
|
+
raise InvalidFont, "gvar axis count mismatch" unless axis_count == normalized_coordinates.length
|
|
182
|
+
raise InvalidFont, "gvar glyph count mismatch" unless data.u16(12) == glyph_count
|
|
183
|
+
long = data.u16(14) & 1 != 0
|
|
184
|
+
base = data.u32(16)
|
|
185
|
+
first = long ? data.u32(20 + glyph * 4) : data.u16(20 + glyph * 2) * 2
|
|
186
|
+
last = long ? data.u32(24 + glyph * 4) : data.u16(22 + glyph * 2) * 2
|
|
187
|
+
result = Array.new(points.length) { [0.0, 0.0] }
|
|
188
|
+
return result if first == last
|
|
189
|
+
record = Binary.new(data.bytes(base + first, last - first))
|
|
190
|
+
flags, serialized = record.u16, record.u16
|
|
191
|
+
headers = (flags & 0xfff).times.map do
|
|
192
|
+
size, index = record.u16, record.u16
|
|
193
|
+
peak = if index & 0x8000 != 0
|
|
194
|
+
axis_count.times.map { record.i16 / 16_384.0 }
|
|
195
|
+
else
|
|
196
|
+
shared = index & 0xfff
|
|
197
|
+
raise InvalidFont, "gvar shared tuple out of range" if shared >= data.u16(6)
|
|
198
|
+
location = data.u32(8) + shared * axis_count * 2
|
|
199
|
+
axis_count.times.map { |i| data.i16(location + i * 2) / 16_384.0 }
|
|
200
|
+
end
|
|
201
|
+
start, finish = if index & 0x4000 != 0
|
|
202
|
+
[axis_count.times.map { record.i16 / 16_384.0 }, axis_count.times.map { record.i16 / 16_384.0 }]
|
|
203
|
+
end
|
|
204
|
+
[size, index, peak, start, finish]
|
|
205
|
+
end
|
|
206
|
+
raise InvalidFont, "gvar expansion exceeds work limit" if points.length * headers.length > 4_000_000
|
|
207
|
+
raise InvalidFont, "overlapping gvar header and data" if record.position > serialized
|
|
208
|
+
record.position = serialized
|
|
209
|
+
shared_points = flags & 0x8000 != 0 ? packed_points(record, points.length) : nil
|
|
210
|
+
headers.each do |size, index, peak, start, finish|
|
|
211
|
+
tuple = Binary.new(record.bytes(record.position, size))
|
|
212
|
+
record.position += size
|
|
213
|
+
scalar = tuple_scalar(peak, start, finish)
|
|
214
|
+
next if scalar.zero?
|
|
215
|
+
selected = index & 0x2000 != 0 ? packed_points(tuple, points.length) : shared_points
|
|
216
|
+
selected ||= (0...points.length).to_a
|
|
217
|
+
dx, dy = packed_deltas(tuple, selected.length), packed_deltas(tuple, selected.length)
|
|
218
|
+
offsets = Array.new(points.length)
|
|
219
|
+
selected.each_with_index do |point, i|
|
|
220
|
+
offsets[point] ||= [0.0, 0.0]
|
|
221
|
+
offsets[point][0] += dx[i]
|
|
222
|
+
offsets[point][1] += dy[i]
|
|
223
|
+
end
|
|
224
|
+
interpolate_untouched(points, offsets, ends)
|
|
225
|
+
offsets.each_with_index do |delta, i|
|
|
226
|
+
next unless delta
|
|
227
|
+
result[i][0] += delta[0] * scalar
|
|
228
|
+
result[i][1] += delta[1] * scalar
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
result
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def tuple_scalar(peak, start, finish)
|
|
235
|
+
scalar = 1.0
|
|
236
|
+
peak.each_with_index do |p, i|
|
|
237
|
+
next if p.zero?
|
|
238
|
+
value = normalized_coordinates[i]
|
|
239
|
+
if start
|
|
240
|
+
return 0.0 if value < start[i] || value > finish[i]
|
|
241
|
+
next if value == p
|
|
242
|
+
scalar *= value < p ? (value - start[i]) / (p - start[i]) : (finish[i] - value) / (finish[i] - p)
|
|
243
|
+
else
|
|
244
|
+
return 0.0 if value.zero? || value * p < 0
|
|
245
|
+
scalar *= value / p if value.abs < p.abs
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
scalar
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def packed_points(data, maximum)
|
|
252
|
+
count = data.u8
|
|
253
|
+
return nil if count.zero?
|
|
254
|
+
count = ((count & 0x7f) << 8) | data.u8 if count & 0x80 != 0
|
|
255
|
+
points, previous = [], 0
|
|
256
|
+
while points.length < count
|
|
257
|
+
control = data.u8
|
|
258
|
+
run = (control & 0x7f) + 1
|
|
259
|
+
raise InvalidFont, "gvar point run overflow" if points.length + run > count
|
|
260
|
+
run.times do
|
|
261
|
+
previous += control & 0x80 != 0 ? data.u16 : data.u8
|
|
262
|
+
raise InvalidFont, "gvar point out of bounds" if previous >= maximum
|
|
263
|
+
points << previous
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
points
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def packed_deltas(data, count)
|
|
270
|
+
result = []
|
|
271
|
+
while result.length < count
|
|
272
|
+
control = data.u8
|
|
273
|
+
length = (control & 0x3f) + 1
|
|
274
|
+
raise InvalidFont, "gvar delta run overflow" if result.length + length > count
|
|
275
|
+
length.times { result << (control & 0x80 != 0 ? 0 : control & 0x40 != 0 ? data.i16 : data.i8) }
|
|
276
|
+
end
|
|
277
|
+
result
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def interpolate_untouched(points, deltas, ends)
|
|
281
|
+
first = 0
|
|
282
|
+
ends.each do |last|
|
|
283
|
+
touched = (first..last).select { |i| deltas[i] }
|
|
284
|
+
if touched.length == 1
|
|
285
|
+
(first..last).each { |i| deltas[i] ||= deltas[touched[0]].dup }
|
|
286
|
+
elsif touched.length > 1
|
|
287
|
+
touched.each_with_index do |a, index|
|
|
288
|
+
b = touched[(index + 1) % touched.length]
|
|
289
|
+
cursor = a == last ? first : a + 1
|
|
290
|
+
while cursor != b
|
|
291
|
+
deltas[cursor] = 2.times.map do |axis|
|
|
292
|
+
c1, c2 = points[a][axis], points[b][axis]
|
|
293
|
+
d1, d2 = deltas[a][axis], deltas[b][axis]
|
|
294
|
+
c1, c2, d1, d2 = c2, c1, d2, d1 if c1 > c2
|
|
295
|
+
value = points[cursor][axis]
|
|
296
|
+
if c1 == c2
|
|
297
|
+
d1 == d2 ? d1 : 0.0
|
|
298
|
+
elsif value <= c1
|
|
299
|
+
d1
|
|
300
|
+
elsif value >= c2
|
|
301
|
+
d2
|
|
302
|
+
else
|
|
303
|
+
d1 + (d2 - d1) * (value - c1).to_f / (c2 - c1)
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
cursor = cursor == last ? first : cursor + 1
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
end
|
|
310
|
+
first = last + 1
|
|
311
|
+
end
|
|
312
|
+
end
|
|
313
|
+
end
|
|
314
|
+
end
|
data/lib/alhena.rb
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "alhena/version"
|
|
4
|
+
require_relative "alhena/data_compat"
|
|
5
|
+
|
|
6
|
+
module Alhena
|
|
7
|
+
class Error < StandardError; end
|
|
8
|
+
class InvalidFont < Error; end
|
|
9
|
+
class UnsupportedFont < Error; end
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
require_relative "alhena/bitmap"
|
|
13
|
+
require_relative "alhena/outline"
|
|
14
|
+
require_relative "alhena/binary"
|
|
15
|
+
require_relative "alhena/rasterizer"
|
|
16
|
+
require_relative "alhena/cff"
|
|
17
|
+
require_relative "alhena/font"
|
|
18
|
+
require_relative "alhena/variation"
|
|
19
|
+
require_relative "alhena/png"
|
|
20
|
+
require_relative "alhena/color"
|
|
21
|
+
require_relative "alhena/cache"
|
data/sig/alhena.rbs
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
module Alhena
|
|
2
|
+
VERSION: String
|
|
3
|
+
type matrix = Array[Numeric]
|
|
4
|
+
class Error < StandardError
|
|
5
|
+
end
|
|
6
|
+
class InvalidFont < Error
|
|
7
|
+
end
|
|
8
|
+
class UnsupportedFont < Error
|
|
9
|
+
end
|
|
10
|
+
class Binary
|
|
11
|
+
attr_reader data: String
|
|
12
|
+
attr_accessor position: Integer
|
|
13
|
+
def initialize: (String data) -> void
|
|
14
|
+
def size: () -> Integer
|
|
15
|
+
def bytes: (Integer offset, Integer length) -> String
|
|
16
|
+
def validate_bounds: (Integer offset, Integer length) -> void
|
|
17
|
+
def check: (Integer offset, Integer length) -> void
|
|
18
|
+
def u8: (?Integer? offset) -> Integer
|
|
19
|
+
def i8: (?Integer? offset) -> Integer
|
|
20
|
+
def u16: (?Integer? offset) -> Integer
|
|
21
|
+
def i16: (?Integer? offset) -> Integer
|
|
22
|
+
def u24: (?Integer? offset) -> Integer
|
|
23
|
+
def u32: (?Integer? offset) -> Integer
|
|
24
|
+
def i32: (?Integer? offset) -> Integer
|
|
25
|
+
def fixed: (?Integer? offset) -> Float
|
|
26
|
+
end
|
|
27
|
+
class Outline
|
|
28
|
+
attr_reader commands: Array[Symbol]
|
|
29
|
+
attr_reader coordinates: Array[Float]
|
|
30
|
+
def initialize: () -> void
|
|
31
|
+
def move_to: (Numeric x, Numeric y) -> self
|
|
32
|
+
def line_to: (Numeric x, Numeric y) -> self
|
|
33
|
+
def quad_to: (Numeric cx, Numeric cy, Numeric x, Numeric y) -> self
|
|
34
|
+
def cubic_to: (Numeric c1x, Numeric c1y, Numeric c2x, Numeric c2y, Numeric x, Numeric y) -> self
|
|
35
|
+
def close: () -> self
|
|
36
|
+
def empty?: () -> bool
|
|
37
|
+
def each: () { (Symbol, *Float) -> void } -> self
|
|
38
|
+
| () -> Enumerator[untyped, self]
|
|
39
|
+
def transform: (matrix) -> Outline
|
|
40
|
+
def append: (Outline) -> self
|
|
41
|
+
def bounds: () -> [Numeric, Numeric, Numeric, Numeric]
|
|
42
|
+
def to_quadratic: (?tolerance: Numeric) -> Outline
|
|
43
|
+
end
|
|
44
|
+
class Bitmap
|
|
45
|
+
attr_reader width: Integer
|
|
46
|
+
attr_reader height: Integer
|
|
47
|
+
attr_reader left: Integer
|
|
48
|
+
attr_reader top: Integer
|
|
49
|
+
attr_reader coverage: String
|
|
50
|
+
attr_reader channels: Integer
|
|
51
|
+
def initialize: (width: Integer, height: Integer, coverage: String, ?left: Integer, ?top: Integer, ?channels: Integer) -> void
|
|
52
|
+
def to_ascii: (?ramp: String) -> String
|
|
53
|
+
end
|
|
54
|
+
class Font
|
|
55
|
+
attr_reader index: Integer
|
|
56
|
+
attr_reader tables: Hash[String, [Integer, Integer]]
|
|
57
|
+
attr_reader axis_values: Hash[String, Numeric]
|
|
58
|
+
def data: () -> String
|
|
59
|
+
def self.open: (String path, ?index: Integer, ?axes: Hash[String | Symbol, Numeric]) -> Font
|
|
60
|
+
def initialize: (String data, ?index: Integer, ?axes: Hash[String | Symbol, Numeric]) -> void
|
|
61
|
+
def table: (String tag) -> Binary
|
|
62
|
+
def units_per_em: () -> Integer
|
|
63
|
+
def glyph_count: () -> Integer
|
|
64
|
+
def ascent: () -> Integer
|
|
65
|
+
def descent: () -> Integer
|
|
66
|
+
def line_gap: () -> Integer
|
|
67
|
+
def names: () -> Hash[Integer, String]
|
|
68
|
+
def family: () -> String?
|
|
69
|
+
def os2: () -> Hash[Symbol, Integer]
|
|
70
|
+
def post: () -> Hash[Symbol, Numeric | bool]
|
|
71
|
+
def glyph_id: (String | Integer character, ?variation_selector: String | Integer | nil) -> Integer
|
|
72
|
+
def advance: (Integer glyph, ?size: Numeric, ?vertical: bool) -> Float
|
|
73
|
+
def bearing: (Integer glyph, ?size: Numeric, ?vertical: bool) -> Float
|
|
74
|
+
def outline: (Integer glyph) -> Outline
|
|
75
|
+
def axes: () -> Hash[String, Hash[Symbol, Numeric | bool | String | nil]]
|
|
76
|
+
def variation: (Hash[String | Symbol, Numeric]) -> Font
|
|
77
|
+
def normalized_coordinates: () -> Array[Float]
|
|
78
|
+
def palettes: () -> Array[Array[Array[Integer]]]
|
|
79
|
+
def color_layers: (Integer glyph) -> Array[[Integer, Integer]]?
|
|
80
|
+
def color_bitmap: (Integer glyph, size: Numeric, ?palette: Integer, ?foreground: Array[Integer], ?subpixel_x: Numeric) -> ColorBitmap?
|
|
81
|
+
def embedded_bitmap: (Integer glyph, size: Numeric) -> EmbeddedBitmap?
|
|
82
|
+
def rasterize: (Integer glyph, size: Numeric, ?subpixel_x: Numeric, ?tolerance: Numeric, ?gamma: Numeric, ?darkening: Numeric, ?lcd: :rgb | :bgr | nil) -> Bitmap
|
|
83
|
+
end
|
|
84
|
+
class ColorBitmap
|
|
85
|
+
attr_reader width: Integer
|
|
86
|
+
attr_reader height: Integer
|
|
87
|
+
attr_reader left: Integer
|
|
88
|
+
attr_reader top: Integer
|
|
89
|
+
attr_reader rgba: String
|
|
90
|
+
def initialize: (width: Integer, height: Integer, rgba: String, ?left: Integer, ?top: Integer) -> void
|
|
91
|
+
def to_bitmap: () -> Bitmap
|
|
92
|
+
def resize: (Numeric factor) -> ColorBitmap
|
|
93
|
+
end
|
|
94
|
+
class EmbeddedBitmap < Data
|
|
95
|
+
attr_reader format: Symbol
|
|
96
|
+
attr_reader data: String
|
|
97
|
+
attr_reader ppem: Integer
|
|
98
|
+
attr_reader left: Integer
|
|
99
|
+
attr_reader top: Integer?
|
|
100
|
+
attr_reader width: Integer?
|
|
101
|
+
attr_reader height: Integer?
|
|
102
|
+
end
|
|
103
|
+
class Rasterizer
|
|
104
|
+
MAX_PIXELS: Integer
|
|
105
|
+
def initialize: (width: Integer, height: Integer, ?tolerance: Numeric) -> void
|
|
106
|
+
def fill: (Outline, ?transform: matrix?, ?left: Integer, ?top: Integer, ?gamma: Numeric, ?darkening: Numeric, ?lcd: :rgb | :bgr | nil) -> Bitmap
|
|
107
|
+
end
|
|
108
|
+
class Cache
|
|
109
|
+
attr_reader capacity: Integer
|
|
110
|
+
attr_reader bytesize: Integer
|
|
111
|
+
def initialize: (?capacity: Integer, ?max_bytes: Integer?) -> void
|
|
112
|
+
def size: () -> Integer
|
|
113
|
+
def rasterize: (Font font, Integer glyph, size: Numeric, ?subpixel_x: Numeric, **untyped options) -> Bitmap
|
|
114
|
+
def prewarm: (Font font, String text, ?Numeric? size, **untyped options) -> self
|
|
115
|
+
def clear: () -> self
|
|
116
|
+
end
|
|
117
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: alhena
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Yudai Takada
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies: []
|
|
12
|
+
description: Read sfnt and TTC fonts, extract outlines, and produce antialiased grayscale
|
|
13
|
+
or LCD glyph bitmaps without native dependencies.
|
|
14
|
+
email:
|
|
15
|
+
- t.yudai92@gmail.com
|
|
16
|
+
executables: []
|
|
17
|
+
extensions: []
|
|
18
|
+
extra_rdoc_files: []
|
|
19
|
+
files:
|
|
20
|
+
- CHANGELOG.md
|
|
21
|
+
- LICENSE.txt
|
|
22
|
+
- README.md
|
|
23
|
+
- examples/color.rb
|
|
24
|
+
- examples/render.rb
|
|
25
|
+
- lib/alhena.rb
|
|
26
|
+
- lib/alhena/binary.rb
|
|
27
|
+
- lib/alhena/bitmap.rb
|
|
28
|
+
- lib/alhena/cache.rb
|
|
29
|
+
- lib/alhena/cff.rb
|
|
30
|
+
- lib/alhena/color.rb
|
|
31
|
+
- lib/alhena/data_compat.rb
|
|
32
|
+
- lib/alhena/font.rb
|
|
33
|
+
- lib/alhena/outline.rb
|
|
34
|
+
- lib/alhena/png.rb
|
|
35
|
+
- lib/alhena/rasterizer.rb
|
|
36
|
+
- lib/alhena/variation.rb
|
|
37
|
+
- lib/alhena/version.rb
|
|
38
|
+
- sig/alhena.rbs
|
|
39
|
+
homepage: https://github.com/noxdea/alhena
|
|
40
|
+
licenses:
|
|
41
|
+
- MIT
|
|
42
|
+
metadata:
|
|
43
|
+
allowed_push_host: https://rubygems.org
|
|
44
|
+
source_code_uri: https://github.com/noxdea/alhena
|
|
45
|
+
changelog_uri: https://github.com/noxdea/alhena/blob/main/CHANGELOG.md
|
|
46
|
+
rubygems_mfa_required: 'true'
|
|
47
|
+
rdoc_options: []
|
|
48
|
+
require_paths:
|
|
49
|
+
- lib
|
|
50
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
51
|
+
requirements:
|
|
52
|
+
- - ">="
|
|
53
|
+
- !ruby/object:Gem::Version
|
|
54
|
+
version: '3.1'
|
|
55
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
56
|
+
requirements:
|
|
57
|
+
- - ">="
|
|
58
|
+
- !ruby/object:Gem::Version
|
|
59
|
+
version: '0'
|
|
60
|
+
requirements: []
|
|
61
|
+
rubygems_version: 4.0.19
|
|
62
|
+
specification_version: 4
|
|
63
|
+
summary: Pure Ruby TrueType and CFF font rasterization
|
|
64
|
+
test_files: []
|