bandoola 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/.tool-versions +2 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +146 -0
- data/Rakefile +10 -0
- data/exe/bandoola +5 -0
- data/lib/bandoola/cli.rb +44 -0
- data/lib/bandoola/font.rb +204 -0
- data/lib/bandoola/jpeg.rb +104 -0
- data/lib/bandoola/standard_metrics.rb +113 -0
- data/lib/bandoola/svg.rb +349 -0
- data/lib/bandoola/true_type_font.rb +206 -0
- data/lib/bandoola/true_type_subset.rb +302 -0
- data/lib/bandoola/version.rb +3 -0
- data/lib/bandoola/view/element.rb +431 -0
- data/lib/bandoola/view/file.rb +16 -0
- data/lib/bandoola/view/header.rb +110 -0
- data/lib/bandoola/view/resources.rb +44 -0
- data/lib/bandoola/view/style/borders.rb +66 -0
- data/lib/bandoola/view/style/colors.rb +167 -0
- data/lib/bandoola/view/style/spacing.rb +42 -0
- data/lib/bandoola/view/style/typography.rb +72 -0
- data/lib/bandoola/view/style.rb +139 -0
- data/lib/bandoola/view.rb +364 -0
- data/lib/bandoola.rb +14 -0
- data/sig/bandoola.rbs +4 -0
- metadata +73 -0
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
module Bandoola
|
|
2
|
+
# Tree-shakes a TrueType font down to a set of glyphs. Given the glyph ids a
|
|
3
|
+
# document actually used (collected by EmbeddedFont), it rebuilds the font
|
|
4
|
+
# with only those glyphs — the glyf table is almost the whole file (>95% for
|
|
5
|
+
# a CJK font) — and drops the tables a CIDFontType2/Identity-H PDF font never
|
|
6
|
+
# reads (cmap, post, OS/2, GPOS, …).
|
|
7
|
+
#
|
|
8
|
+
# Two modes:
|
|
9
|
+
#
|
|
10
|
+
# * .sparse keeps the original glyph numbering, emptying the unused glyphs.
|
|
11
|
+
# loca/hmtx stay full size (one entry per original glyph), but it needs no
|
|
12
|
+
# remapping — the PDF's /W, content stream and ToUnicode are untouched.
|
|
13
|
+
#
|
|
14
|
+
# * .compact renumbers the kept glyphs (and their composite components) into
|
|
15
|
+
# a dense range, so loca, hmtx and maxp shrink to the glyphs actually used.
|
|
16
|
+
# This is the smaller, default mode; it requires the content stream to use
|
|
17
|
+
# the new ids (EmbeddedFont assigns them) and composite component indices
|
|
18
|
+
# to be rewritten.
|
|
19
|
+
class TrueTypeSubset
|
|
20
|
+
# Copied verbatim when present (the rest is rebuilt or dropped).
|
|
21
|
+
HINTING = ["cvt ", "fpgm", "prep", "gasp"].freeze
|
|
22
|
+
|
|
23
|
+
# Composite-glyph flags (a glyph can be built from other glyphs).
|
|
24
|
+
ARGS_ARE_WORDS = 0x0001
|
|
25
|
+
HAVE_SCALE = 0x0008
|
|
26
|
+
MORE_COMPONENTS = 0x0020
|
|
27
|
+
HAVE_XY_SCALE = 0x0040
|
|
28
|
+
HAVE_2X2 = 0x0080
|
|
29
|
+
|
|
30
|
+
def self.sparse(data, glyphs)
|
|
31
|
+
new(data).sparse(glyphs)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def self.compact(data, order)
|
|
35
|
+
new(data).compact(order)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def initialize(data)
|
|
39
|
+
@data = data
|
|
40
|
+
@directory = read_directory
|
|
41
|
+
raise Error, "not a glyf-based TrueType font" unless @directory.key?("glyf")
|
|
42
|
+
|
|
43
|
+
@num_glyphs = u16(table_start("maxp") + 4)
|
|
44
|
+
@long_loca = u16(table_start("head") + 50) == 1
|
|
45
|
+
@num_hmetrics = u16(table_start("hhea") + 34)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Phase 1: keep glyph ids, empty the unused ones.
|
|
49
|
+
def sparse(glyphs)
|
|
50
|
+
kept = closure(glyphs)
|
|
51
|
+
glyf = String.new(encoding: Encoding::ASCII_8BIT)
|
|
52
|
+
offsets = [0]
|
|
53
|
+
(0...@num_glyphs).each do |glyph|
|
|
54
|
+
glyf << aligned(glyph_data(glyph)) if kept[glyph]
|
|
55
|
+
offsets << glyf.bytesize
|
|
56
|
+
end
|
|
57
|
+
tables = base_tables(glyf, offsets.pack("N*"))
|
|
58
|
+
["hhea", "maxp", "hmtx", *HINTING].each { |tag| tables[tag] = table_bytes(tag) if @directory.key?(tag) }
|
|
59
|
+
assemble(tables)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Phase 2: renumber the kept glyphs into a dense range. +order+ maps new
|
|
63
|
+
# glyph id (array index) to original glyph id; it begins with the ids the
|
|
64
|
+
# content stream already uses and is extended here with composite parts.
|
|
65
|
+
def compact(order)
|
|
66
|
+
list, index = extend_with_components(order)
|
|
67
|
+
glyf, loca = build_compact_glyf(list, index)
|
|
68
|
+
tables = base_tables(glyf, loca)
|
|
69
|
+
tables["hhea"] = resized("hhea", 34, list.size)
|
|
70
|
+
tables["maxp"] = resized("maxp", 4, list.size)
|
|
71
|
+
tables["hmtx"] = compact_hmtx(list)
|
|
72
|
+
HINTING.each { |tag| tables[tag] = table_bytes(tag) if @directory.key?(tag) }
|
|
73
|
+
assemble(tables)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
def base_tables(glyf, loca)
|
|
79
|
+
{ "glyf" => glyf, "loca" => loca, "head" => patched_head }
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def read_directory
|
|
83
|
+
directory = {}
|
|
84
|
+
u16(4).times do |i|
|
|
85
|
+
entry = 12 + (i * 16)
|
|
86
|
+
directory[@data[entry, 4]] = [u32(entry + 8), u32(entry + 12)]
|
|
87
|
+
end
|
|
88
|
+
directory
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def table_start(tag)
|
|
92
|
+
@directory.fetch(tag)[0]
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def table_bytes(tag)
|
|
96
|
+
start, length = @directory.fetch(tag)
|
|
97
|
+
@data[start, length].b
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# --- glyph closure / components ------------------------------------------
|
|
101
|
+
|
|
102
|
+
# Grow the requested glyph set to include glyph 0 (.notdef) and, for any
|
|
103
|
+
# composite glyph, the component glyphs it is assembled from.
|
|
104
|
+
def closure(glyphs)
|
|
105
|
+
kept = {}
|
|
106
|
+
pending = glyphs.to_a + [0]
|
|
107
|
+
until pending.empty?
|
|
108
|
+
glyph = pending.pop
|
|
109
|
+
next if glyph >= @num_glyphs || kept[glyph]
|
|
110
|
+
|
|
111
|
+
kept[glyph] = true
|
|
112
|
+
pending.concat(components(glyph))
|
|
113
|
+
end
|
|
114
|
+
kept
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Append composite components to the new-id ordering, so the final list
|
|
118
|
+
# covers every glyph the subset needs. Returns [list, original => new_id].
|
|
119
|
+
def extend_with_components(order)
|
|
120
|
+
list = order.dup
|
|
121
|
+
index = {}
|
|
122
|
+
list.each_with_index { |glyph, new_id| index[glyph] = new_id }
|
|
123
|
+
cursor = 0
|
|
124
|
+
while cursor < list.size
|
|
125
|
+
components(list[cursor]).each do |component|
|
|
126
|
+
next if index.key?(component)
|
|
127
|
+
|
|
128
|
+
index[component] = list.size
|
|
129
|
+
list << component
|
|
130
|
+
end
|
|
131
|
+
cursor += 1
|
|
132
|
+
end
|
|
133
|
+
[list, index]
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def components(glyph)
|
|
137
|
+
return [] unless composite?(glyph)
|
|
138
|
+
|
|
139
|
+
read_components((table_start("glyf") + glyph_location(glyph)) + 10) # skip header + bbox
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def composite?(glyph)
|
|
143
|
+
start = glyph_location(glyph)
|
|
144
|
+
return false if glyph_location(glyph + 1) <= start
|
|
145
|
+
|
|
146
|
+
i16(table_start("glyf") + start).negative? # numberOfContours < 0
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def read_components(position)
|
|
150
|
+
found = []
|
|
151
|
+
loop do
|
|
152
|
+
flags = u16(position)
|
|
153
|
+
found << u16(position + 2)
|
|
154
|
+
position += 4 + (flags.nobits?(ARGS_ARE_WORDS) ? 2 : 4) + transform_size(flags)
|
|
155
|
+
break if flags.nobits?(MORE_COMPONENTS)
|
|
156
|
+
end
|
|
157
|
+
found
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def transform_size(flags)
|
|
161
|
+
return 2 unless flags.nobits?(HAVE_SCALE)
|
|
162
|
+
return 4 unless flags.nobits?(HAVE_XY_SCALE)
|
|
163
|
+
return 8 unless flags.nobits?(HAVE_2X2)
|
|
164
|
+
|
|
165
|
+
0
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# --- glyf / loca rebuilds ------------------------------------------------
|
|
169
|
+
|
|
170
|
+
def build_compact_glyf(list, index)
|
|
171
|
+
glyf = String.new(encoding: Encoding::ASCII_8BIT)
|
|
172
|
+
offsets = [0]
|
|
173
|
+
list.each do |original|
|
|
174
|
+
data = glyph_data(original)
|
|
175
|
+
data = remap_components(data, index) if composite?(original)
|
|
176
|
+
glyf << aligned(data)
|
|
177
|
+
offsets << glyf.bytesize
|
|
178
|
+
end
|
|
179
|
+
[glyf, offsets.pack("N*")]
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Rewrite each component's glyph id from the original to the new numbering.
|
|
183
|
+
def remap_components(data, index)
|
|
184
|
+
bytes = data.dup
|
|
185
|
+
position = 10
|
|
186
|
+
loop do
|
|
187
|
+
flags = (bytes.getbyte(position) << 8) | bytes.getbyte(position + 1)
|
|
188
|
+
original = (bytes.getbyte(position + 2) << 8) | bytes.getbyte(position + 3)
|
|
189
|
+
bytes[position + 2, 2] = [index.fetch(original)].pack("n")
|
|
190
|
+
position += 4 + (flags.nobits?(ARGS_ARE_WORDS) ? 2 : 4) + transform_size(flags)
|
|
191
|
+
break if flags.nobits?(MORE_COMPONENTS)
|
|
192
|
+
end
|
|
193
|
+
bytes
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def glyph_data(glyph)
|
|
197
|
+
start = glyph_location(glyph)
|
|
198
|
+
stop = glyph_location(glyph + 1)
|
|
199
|
+
stop > start ? @data[table_start("glyf") + start, stop - start].b : "".b
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def glyph_location(index)
|
|
203
|
+
if @long_loca
|
|
204
|
+
u32(table_start("loca") + (index * 4))
|
|
205
|
+
else
|
|
206
|
+
u16(table_start("loca") + (index * 2)) * 2
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# --- metrics / head ------------------------------------------------------
|
|
211
|
+
|
|
212
|
+
def compact_hmtx(list)
|
|
213
|
+
hmtx = String.new(encoding: Encoding::ASCII_8BIT)
|
|
214
|
+
list.each { |glyph| hmtx << [advance(glyph), left_bearing(glyph) & 0xFFFF].pack("nn") }
|
|
215
|
+
hmtx
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def advance(glyph)
|
|
219
|
+
index = glyph < @num_hmetrics ? glyph : @num_hmetrics - 1
|
|
220
|
+
u16(table_start("hmtx") + (index * 4))
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def left_bearing(glyph)
|
|
224
|
+
if glyph < @num_hmetrics
|
|
225
|
+
i16(table_start("hmtx") + (glyph * 4) + 2)
|
|
226
|
+
else
|
|
227
|
+
i16(table_start("hmtx") + (@num_hmetrics * 4) + ((glyph - @num_hmetrics) * 2))
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# A copy of +tag+ with its glyph-count field (at +offset+) set to +count+.
|
|
232
|
+
def resized(tag, offset, count)
|
|
233
|
+
bytes = table_bytes(tag)
|
|
234
|
+
bytes[offset, 2] = [count].pack("n")
|
|
235
|
+
bytes
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# head, with checkSumAdjustment zeroed (set after assembly) and loca format
|
|
239
|
+
# switched to long, matching the rebuilt loca.
|
|
240
|
+
def patched_head
|
|
241
|
+
head = table_bytes("head")
|
|
242
|
+
head[8, 4] = "\x00\x00\x00\x00".b
|
|
243
|
+
head[50, 2] = "\x00\x01".b
|
|
244
|
+
head
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# --- sfnt assembly -------------------------------------------------------
|
|
248
|
+
|
|
249
|
+
def assemble(tables)
|
|
250
|
+
ordered = tables.sort.to_h
|
|
251
|
+
directory = String.new(encoding: Encoding::ASCII_8BIT)
|
|
252
|
+
blob = String.new(encoding: Encoding::ASCII_8BIT)
|
|
253
|
+
position = 12 + (ordered.size * 16)
|
|
254
|
+
head_position = nil
|
|
255
|
+
|
|
256
|
+
ordered.each do |tag, body|
|
|
257
|
+
head_position = position if tag == "head"
|
|
258
|
+
directory << tag.b << [checksum(body), position, body.bytesize].pack("NNN")
|
|
259
|
+
chunk = aligned(body)
|
|
260
|
+
blob << chunk
|
|
261
|
+
position += chunk.bytesize
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
font = +@data[0, 4].b << sfnt_header(ordered.size) << directory << blob
|
|
265
|
+
adjust_head_checksum(font, head_position)
|
|
266
|
+
font
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def sfnt_header(count)
|
|
270
|
+
exponent = 0
|
|
271
|
+
exponent += 1 while (1 << (exponent + 1)) <= count
|
|
272
|
+
max_power = (1 << exponent) * 16
|
|
273
|
+
[count, max_power, exponent, (count * 16) - max_power].pack("nnnn")
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def adjust_head_checksum(font, head_position)
|
|
277
|
+
adjustment = (0xB1B0_AFBA - checksum(font)) & 0xFFFF_FFFF
|
|
278
|
+
font[head_position + 8, 4] = [adjustment].pack("N")
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def aligned(bytes)
|
|
282
|
+
bytes.b + ("\x00".b * ((-bytes.bytesize) % 4))
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def checksum(bytes)
|
|
286
|
+
aligned(bytes).unpack("N*").sum & 0xFFFF_FFFF
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def u16(offset)
|
|
290
|
+
(@data.getbyte(offset) << 8) | @data.getbyte(offset + 1)
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def i16(offset)
|
|
294
|
+
value = u16(offset)
|
|
295
|
+
value >= 0x8000 ? value - 0x10000 : value
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def u32(offset)
|
|
299
|
+
(u16(offset) << 16) | u16(offset + 2)
|
|
300
|
+
end
|
|
301
|
+
end
|
|
302
|
+
end
|