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.
@@ -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
@@ -0,0 +1,3 @@
1
+ module Bandoola
2
+ VERSION = "0.1.0".freeze
3
+ end