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
data/lib/bandoola/svg.rb
ADDED
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
module Bandoola
|
|
2
|
+
# A tiny SVG reader that turns a path-based SVG into PDF path-painting
|
|
3
|
+
# operators. It is deliberately small: it understands <path> elements with
|
|
4
|
+
# solid fills — which covers icon/logo SVGs like a BankID mark — and not
|
|
5
|
+
# gradients, strokes, text, or raster <image> data.
|
|
6
|
+
#
|
|
7
|
+
# It is a standalone value object that knows nothing about View or layout:
|
|
8
|
+
# give it SVG source and a target box, get back PDF operators.
|
|
9
|
+
#
|
|
10
|
+
# The SVG coordinate system has its origin at the top-left with y pointing
|
|
11
|
+
# down; PDF's points up from the bottom-left. #to_pdf emits a `cm` transform
|
|
12
|
+
# that maps the viewBox into a target box and flips y, so the path data can
|
|
13
|
+
# be written out essentially verbatim.
|
|
14
|
+
class Svg
|
|
15
|
+
VIEWBOX = /viewBox="\s*([-\d.]+)[\s,]+([-\d.]+)[\s,]+([-\d.]+)[\s,]+([-\d.]+)"/
|
|
16
|
+
DIMENSION = /\A(\d*\.?\d+)/
|
|
17
|
+
PATH_TAG = /<path\b([^>]*)>/m
|
|
18
|
+
ATTRIBUTE = ->(name) { /#{name}="([^"]*)"/ }
|
|
19
|
+
|
|
20
|
+
# <mask> (and <clipPath>) define stencils, not visible artwork; the paths
|
|
21
|
+
# inside them must not be painted, so we strip these blocks before scanning.
|
|
22
|
+
STENCIL = %r{<(mask|clipPath)\b[^>]*>.*?</\1>}m
|
|
23
|
+
|
|
24
|
+
# A <mask id="..."> definition and its body, captured to build a clip.
|
|
25
|
+
MASK = %r{<mask\b[^>]*\bid="([^"]*)"[^>]*>(.*?)</mask>}m
|
|
26
|
+
# A <rect> inside a mask, turned into a rectangular clip subpath.
|
|
27
|
+
RECT_TAG = %r{<rect\b([^>]*?)/?>}m
|
|
28
|
+
# Open <g>, close </g>, or <path> — walked in document order so a path
|
|
29
|
+
# inherits the mask of any enclosing group.
|
|
30
|
+
ELEMENT = %r{<g\b([^>]*)>|</g>|<path\b([^>]*)>}m
|
|
31
|
+
# The id inside a mask="url(#id)" reference.
|
|
32
|
+
MASK_REF = /#([\w-]+)/
|
|
33
|
+
|
|
34
|
+
# A path command letter or a number (incl. decimals and exponents).
|
|
35
|
+
TOKEN = /[MmLlHhVvCcSsZz]|-?\d*\.?\d+(?:[eE][-+]?\d+)?/
|
|
36
|
+
|
|
37
|
+
def self.load(src)
|
|
38
|
+
new(File.read(src))
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def initialize(source)
|
|
42
|
+
@source = source
|
|
43
|
+
@view_width, @view_height = parse_viewbox
|
|
44
|
+
@paths = parse_paths
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
attr_reader :view_width, :view_height
|
|
48
|
+
|
|
49
|
+
# Intrinsic display size: the SVG's width/height attributes if present,
|
|
50
|
+
# otherwise the viewBox dimensions.
|
|
51
|
+
def intrinsic_width
|
|
52
|
+
dimension("width") || view_width
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def intrinsic_height
|
|
56
|
+
dimension("height") || view_height
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Content aspect ratio (width / height) of the viewBox.
|
|
60
|
+
def aspect
|
|
61
|
+
view_width / view_height
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Paint the SVG into +box+ ([left, top, width, height]). SVG paths draw
|
|
65
|
+
# inline, so +resources+ is unused (only raster images register XObjects);
|
|
66
|
+
# the signature matches Jpeg so Img can treat both the same.
|
|
67
|
+
def paint_into(buffer, _resources, box)
|
|
68
|
+
buffer << to_pdf(*box)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# PDF operators that paint the SVG into the box whose top-left corner is
|
|
72
|
+
# (left, top) and whose size is width x height, in PDF points.
|
|
73
|
+
def to_pdf(left, top, width, height)
|
|
74
|
+
scale_x = width / view_width
|
|
75
|
+
scale_y = height / view_height
|
|
76
|
+
|
|
77
|
+
out = +"q\n"
|
|
78
|
+
out << "#{fmt(scale_x)} 0 0 #{fmt(-scale_y)} #{fmt(left)} #{fmt(top)} cm\n"
|
|
79
|
+
@paths.each { |path| paint_path(out, path) }
|
|
80
|
+
out << "Q\n"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
# Paint one parsed path. If it carries mask geometry, the fill is wrapped
|
|
86
|
+
# in its own save-state and clipped to that geometry first. Masks use the
|
|
87
|
+
# even-odd rule (W*) so the white-rect-with-knockouts idiom resolves to the
|
|
88
|
+
# expected holes.
|
|
89
|
+
def paint_path(out, path)
|
|
90
|
+
clip = path[:clip]
|
|
91
|
+
clipped = clip && !clip.empty?
|
|
92
|
+
out << "q\n" << clip << "W* n\n" if clipped
|
|
93
|
+
red, green, blue = path[:color]
|
|
94
|
+
out << "#{fmt(red)} #{fmt(green)} #{fmt(blue)} rg\n"
|
|
95
|
+
out << path[:ops]
|
|
96
|
+
out << (path[:even_odd] ? "f*\n" : "f\n")
|
|
97
|
+
out << "Q\n" if clipped
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def parse_viewbox
|
|
101
|
+
if (match = @source.match(VIEWBOX))
|
|
102
|
+
[match[3].to_f, match[4].to_f]
|
|
103
|
+
else
|
|
104
|
+
[dimension("width") || 1.0, dimension("height") || 1.0]
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def dimension(name)
|
|
109
|
+
match = @source.match(ATTRIBUTE.call(name))
|
|
110
|
+
return nil unless match
|
|
111
|
+
|
|
112
|
+
number = match[1].match(DIMENSION)
|
|
113
|
+
number && number[1].to_f
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def parse_paths
|
|
117
|
+
masks = parse_masks
|
|
118
|
+
groups = [] # stack of enclosing-group mask ids (nil when a group is unmasked)
|
|
119
|
+
|
|
120
|
+
@source.gsub(STENCIL, "").scan(ELEMENT).filter_map do |group_attrs, path_attrs|
|
|
121
|
+
if group_attrs
|
|
122
|
+
groups.push(mask_ref(group_attrs))
|
|
123
|
+
next
|
|
124
|
+
elsif path_attrs.nil? # a </g>
|
|
125
|
+
groups.pop
|
|
126
|
+
next
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
build_path(path_attrs, masks, groups.compact.last)
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def build_path(attributes, masks, group_mask)
|
|
134
|
+
fill = attribute(attributes, "fill")
|
|
135
|
+
return if fill.nil? || fill == "none"
|
|
136
|
+
|
|
137
|
+
data = attribute(attributes, "d")
|
|
138
|
+
return if data.nil?
|
|
139
|
+
|
|
140
|
+
mask = mask_ref(attributes) || group_mask
|
|
141
|
+
{
|
|
142
|
+
ops: Pen.new(data.scan(TOKEN)).draw,
|
|
143
|
+
color: rgb(fill),
|
|
144
|
+
even_odd: attribute(attributes, "fill-rule") == "evenodd",
|
|
145
|
+
clip: mask && masks[mask]
|
|
146
|
+
}
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# id => clip operators, for every <mask> in the document. A mask's <path>
|
|
150
|
+
# and <rect> children become the clip geometry; other shapes are ignored.
|
|
151
|
+
def parse_masks
|
|
152
|
+
@source.scan(MASK).to_h do |id, body|
|
|
153
|
+
[id, mask_clip(body)]
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def mask_clip(body)
|
|
158
|
+
ops = +""
|
|
159
|
+
body.scan(PATH_TAG).each do |(attributes)|
|
|
160
|
+
data = attribute(attributes, "d")
|
|
161
|
+
ops << Pen.new(data.scan(TOKEN)).draw if data
|
|
162
|
+
end
|
|
163
|
+
body.scan(RECT_TAG).each { |(attributes)| ops << rect_path(attributes) }
|
|
164
|
+
ops
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# A <rect> as an explicit closed subpath (rx/ry rounding is ignored).
|
|
168
|
+
def rect_path(attributes)
|
|
169
|
+
x = number_attribute(attributes, "x")
|
|
170
|
+
y = number_attribute(attributes, "y")
|
|
171
|
+
width = number_attribute(attributes, "width")
|
|
172
|
+
height = number_attribute(attributes, "height")
|
|
173
|
+
|
|
174
|
+
"#{fmt(x)} #{fmt(y)} m\n" \
|
|
175
|
+
"#{fmt(x + width)} #{fmt(y)} l\n" \
|
|
176
|
+
"#{fmt(x + width)} #{fmt(y + height)} l\n" \
|
|
177
|
+
"#{fmt(x)} #{fmt(y + height)} l\n" \
|
|
178
|
+
"h\n"
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def number_attribute(attributes, name)
|
|
182
|
+
value = attribute(attributes, name)
|
|
183
|
+
value ? value.to_f : 0.0
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# The id referenced by a mask="url(#id)" attribute, or nil if absent.
|
|
187
|
+
def mask_ref(attributes)
|
|
188
|
+
ref = attribute(attributes, "mask")
|
|
189
|
+
ref && ref[MASK_REF, 1]
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def attribute(attributes, name)
|
|
193
|
+
match = attributes.match(ATTRIBUTE.call(name))
|
|
194
|
+
match && match[1]
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# "#193E4F" => [0.098, 0.243, 0.310], each channel scaled to 0..1.
|
|
198
|
+
# Shorthand 3-digit hex ("#fff") expands to its 6-digit form first.
|
|
199
|
+
def rgb(hex)
|
|
200
|
+
digits = hex.delete("#")
|
|
201
|
+
digits = digits.chars.map { |digit| digit * 2 }.join if digits.length == 3
|
|
202
|
+
[0, 2, 4].map { |offset| digits[offset, 2].to_i(16) / 255.0 }
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def fmt(value)
|
|
206
|
+
format("%.3f", value).sub(/\.?0+\z/, "")
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# Walks the token stream of one path's `d` attribute, tracking the current
|
|
210
|
+
# point so relative commands and implicit repeats resolve, and emits PDF
|
|
211
|
+
# path operators (m/l/c/h) in the SVG's own coordinate space.
|
|
212
|
+
class Pen
|
|
213
|
+
# SVG path command letter (upper = absolute, lower = relative) to the
|
|
214
|
+
# method that emits it.
|
|
215
|
+
COMMANDS = {
|
|
216
|
+
"M" => :move, "m" => :move,
|
|
217
|
+
"L" => :line, "l" => :line,
|
|
218
|
+
"H" => :horizontal, "h" => :horizontal,
|
|
219
|
+
"V" => :vertical, "v" => :vertical,
|
|
220
|
+
"C" => :cubic, "c" => :cubic,
|
|
221
|
+
"S" => :smooth_cubic, "s" => :smooth_cubic,
|
|
222
|
+
"Z" => :close, "z" => :close
|
|
223
|
+
}.freeze
|
|
224
|
+
|
|
225
|
+
def initialize(tokens)
|
|
226
|
+
@tokens = tokens
|
|
227
|
+
@index = 0
|
|
228
|
+
@x = 0.0
|
|
229
|
+
@y = 0.0
|
|
230
|
+
@start_x = 0.0
|
|
231
|
+
@start_y = 0.0
|
|
232
|
+
@command = nil
|
|
233
|
+
@control_x = nil
|
|
234
|
+
@control_y = nil
|
|
235
|
+
@ops = +""
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def draw
|
|
239
|
+
while @index < @tokens.length
|
|
240
|
+
token = @tokens[@index]
|
|
241
|
+
if token.match?(/[A-Za-z]/)
|
|
242
|
+
@command = token
|
|
243
|
+
@index += 1
|
|
244
|
+
end
|
|
245
|
+
step(@command)
|
|
246
|
+
end
|
|
247
|
+
@ops
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
private
|
|
251
|
+
|
|
252
|
+
def step(command)
|
|
253
|
+
action = COMMANDS[command]
|
|
254
|
+
return @index += 1 unless action # skip unsupported/leading token, ensuring progress
|
|
255
|
+
|
|
256
|
+
send(action)
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def number
|
|
260
|
+
value = @tokens[@index].to_f
|
|
261
|
+
@index += 1
|
|
262
|
+
value
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def relative?
|
|
266
|
+
@command == @command.downcase
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def move
|
|
270
|
+
implicit = relative? ? "l" : "L"
|
|
271
|
+
target = point
|
|
272
|
+
@x = @start_x = target[0]
|
|
273
|
+
@y = @start_y = target[1]
|
|
274
|
+
@ops << "#{fmt(target[0])} #{fmt(target[1])} m\n"
|
|
275
|
+
# Subsequent coordinate pairs after a moveto are implicit linetos.
|
|
276
|
+
@command = implicit
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
def line
|
|
280
|
+
line_to(point)
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def horizontal
|
|
284
|
+
along = number
|
|
285
|
+
along += @x if relative?
|
|
286
|
+
line_to([along, @y])
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def vertical
|
|
290
|
+
along = number
|
|
291
|
+
along += @y if relative?
|
|
292
|
+
line_to([@x, along])
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def cubic
|
|
296
|
+
control1 = point
|
|
297
|
+
control2 = point
|
|
298
|
+
endpoint = point
|
|
299
|
+
curve_to(control1, control2, endpoint)
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def smooth_cubic
|
|
303
|
+
control2 = point
|
|
304
|
+
endpoint = point
|
|
305
|
+
curve_to(reflected_control, control2, endpoint)
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
# Read the next coordinate pair, resolving relative commands against the
|
|
309
|
+
# current point.
|
|
310
|
+
def point
|
|
311
|
+
coords = [number, number]
|
|
312
|
+
return coords unless relative?
|
|
313
|
+
|
|
314
|
+
[coords[0] + @x, coords[1] + @y]
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
def reflected_control
|
|
318
|
+
return [@x, @y] unless @control_x
|
|
319
|
+
|
|
320
|
+
[(2 * @x) - @control_x, (2 * @y) - @control_y]
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
def close
|
|
324
|
+
@ops << "h\n"
|
|
325
|
+
@x = @start_x
|
|
326
|
+
@y = @start_y
|
|
327
|
+
@command = nil # a fresh command (or EOF) must follow a closepath
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
def line_to(target)
|
|
331
|
+
@x, @y = target
|
|
332
|
+
@control_x = @control_y = nil
|
|
333
|
+
@ops << "#{fmt(@x)} #{fmt(@y)} l\n"
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
def curve_to(control1, control2, target)
|
|
337
|
+
@ops << "#{fmt(control1[0])} #{fmt(control1[1])} " \
|
|
338
|
+
"#{fmt(control2[0])} #{fmt(control2[1])} " \
|
|
339
|
+
"#{fmt(target[0])} #{fmt(target[1])} c\n"
|
|
340
|
+
@control_x, @control_y = control2
|
|
341
|
+
@x, @y = target
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
def fmt(value)
|
|
345
|
+
format("%.3f", value).sub(/\.?0+\z/, "")
|
|
346
|
+
end
|
|
347
|
+
end
|
|
348
|
+
end
|
|
349
|
+
end
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
module Bandoola
|
|
2
|
+
# A minimal TrueType font reader: enough to embed the font in a PDF and look
|
|
3
|
+
# up the metrics PDF needs. It parses the table directory and the head/hhea/
|
|
4
|
+
# maxp/hmtx/cmap/post/OS2/name tables — not the glyph outlines, which travel
|
|
5
|
+
# to the viewer untouched inside the embedded font file.
|
|
6
|
+
#
|
|
7
|
+
# Metrics are scaled to PDF glyph space (1000 units per em). Character codes
|
|
8
|
+
# are treated as Unicode (Latin-1 for 0..255), resolved to glyphs through a
|
|
9
|
+
# format-4 Unicode cmap subtable.
|
|
10
|
+
class TrueTypeFont
|
|
11
|
+
# A right-leaning slant for fonts flagged italic whose post table doesn't
|
|
12
|
+
# report an angle (common in variable fonts, where the slant is in the
|
|
13
|
+
# variation data). PDF measures the angle counter-clockwise from vertical.
|
|
14
|
+
DEFAULT_ITALIC_ANGLE = -12
|
|
15
|
+
|
|
16
|
+
def self.load(path)
|
|
17
|
+
new(File.binread(path))
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def initialize(data)
|
|
21
|
+
@data = data
|
|
22
|
+
@tables = read_directory
|
|
23
|
+
read_head
|
|
24
|
+
read_hhea
|
|
25
|
+
@num_glyphs = u16(table_offset("maxp") + 4)
|
|
26
|
+
read_cmap
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
attr_reader :data, :ascent, :descent, :cap_height, :font_bbox, :italic_angle
|
|
30
|
+
|
|
31
|
+
# The glyph index for a Unicode code point (0 = .notdef / missing).
|
|
32
|
+
def glyph(codepoint)
|
|
33
|
+
glyph_for(codepoint)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# The advance width (in 1000-unit em) of a glyph index.
|
|
37
|
+
def glyph_width(glyph)
|
|
38
|
+
index = glyph < @num_hmetrics ? glyph : @num_hmetrics - 1
|
|
39
|
+
scale(u16(table_offset("hmtx") + (index * 4)))
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# The advance width (in 1000-unit em) of a character code.
|
|
43
|
+
def width(codepoint)
|
|
44
|
+
glyph_width(glyph(codepoint))
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def widths(first, last)
|
|
48
|
+
(first..last).map { |code| width(code) }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# A PDF/PostScript-safe BaseFont name, from the font's name table.
|
|
52
|
+
def postscript_name
|
|
53
|
+
@postscript_name ||= read_postscript_name
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# FontDescriptor flags: 32 = Nonsymbolic (uses a standard Latin set), plus
|
|
57
|
+
# 64 if the font is italic.
|
|
58
|
+
def flags
|
|
59
|
+
@italic ? 96 : 32
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def read_directory
|
|
65
|
+
tables = {}
|
|
66
|
+
u16(4).times do |i|
|
|
67
|
+
entry = 12 + (i * 16)
|
|
68
|
+
tables[@data[entry, 4]] = u32(entry + 8)
|
|
69
|
+
end
|
|
70
|
+
tables
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def table_offset(tag)
|
|
74
|
+
@tables.fetch(tag) { raise Error, "TrueType font missing #{tag} table" }
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def read_head
|
|
78
|
+
offset = table_offset("head")
|
|
79
|
+
@units_per_em = u16(offset + 18)
|
|
80
|
+
@font_bbox = [offset + 36, offset + 38, offset + 40, offset + 42].map { |pos| scale(i16(pos)) }
|
|
81
|
+
@italic = u16(offset + 44).anybits?(0x0002) || os2_italic? # head.macStyle bit 1
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def os2_italic?
|
|
85
|
+
return false unless @tables["OS/2"]
|
|
86
|
+
|
|
87
|
+
u16(table_offset("OS/2") + 62).anybits?(0x0001) # fsSelection bit 0
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def read_hhea
|
|
91
|
+
offset = table_offset("hhea")
|
|
92
|
+
@ascent = scale(i16(offset + 4))
|
|
93
|
+
@descent = scale(i16(offset + 6))
|
|
94
|
+
@num_hmetrics = u16(offset + 34)
|
|
95
|
+
@cap_height = cap_height_from_os2 || @ascent
|
|
96
|
+
@italic_angle = italic_angle_value
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def cap_height_from_os2
|
|
100
|
+
return nil unless @tables["OS/2"]
|
|
101
|
+
|
|
102
|
+
offset = table_offset("OS/2")
|
|
103
|
+
return nil if u16(offset) < 2 # sCapHeight only exists from version 2
|
|
104
|
+
|
|
105
|
+
scale(i16(offset + 88))
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def italic_angle_value
|
|
109
|
+
from_post = @tables["post"] ? (i32(table_offset("post") + 4) / 65_536.0).round : 0
|
|
110
|
+
return from_post unless from_post.zero?
|
|
111
|
+
|
|
112
|
+
@italic ? DEFAULT_ITALIC_ANGLE : 0
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Locate a format-4 Unicode cmap subtable (preferring Windows 3,1) and
|
|
116
|
+
# cache the offsets of its parallel segment arrays.
|
|
117
|
+
def read_cmap
|
|
118
|
+
base = table_offset("cmap")
|
|
119
|
+
subtable = find_format4_subtable(base)
|
|
120
|
+
raise Error, "TrueType font has no usable Unicode cmap" unless subtable
|
|
121
|
+
|
|
122
|
+
@seg_count = u16(subtable + 6) / 2
|
|
123
|
+
@cmap_end = subtable + 14
|
|
124
|
+
@cmap_start = @cmap_end + (@seg_count * 2) + 2
|
|
125
|
+
@cmap_delta = @cmap_start + (@seg_count * 2)
|
|
126
|
+
@cmap_range = @cmap_delta + (@seg_count * 2)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def find_format4_subtable(base)
|
|
130
|
+
fallback = nil
|
|
131
|
+
u16(base + 2).times do |i|
|
|
132
|
+
record = base + 4 + (i * 8)
|
|
133
|
+
platform = u16(record)
|
|
134
|
+
encoding = u16(record + 2)
|
|
135
|
+
subtable = base + u32(record + 4)
|
|
136
|
+
next unless u16(subtable) == 4
|
|
137
|
+
|
|
138
|
+
return subtable if platform == 3 && encoding == 1
|
|
139
|
+
|
|
140
|
+
fallback ||= subtable if platform.zero?
|
|
141
|
+
end
|
|
142
|
+
fallback
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# cmap format 4 lookup: find the segment covering +code+ and map it to a
|
|
146
|
+
# glyph index via idRangeOffset/idDelta.
|
|
147
|
+
def glyph_for(code)
|
|
148
|
+
segment = 0
|
|
149
|
+
segment += 1 while segment < @seg_count && u16(@cmap_end + (2 * segment)) < code
|
|
150
|
+
return 0 if segment >= @seg_count
|
|
151
|
+
return 0 if u16(@cmap_start + (2 * segment)) > code
|
|
152
|
+
|
|
153
|
+
range = u16(@cmap_range + (2 * segment))
|
|
154
|
+
delta = i16(@cmap_delta + (2 * segment))
|
|
155
|
+
return (code + delta) % 65_536 if range.zero?
|
|
156
|
+
|
|
157
|
+
start = u16(@cmap_start + (2 * segment))
|
|
158
|
+
glyph = u16(@cmap_range + (2 * segment) + range + (2 * (code - start)))
|
|
159
|
+
glyph.zero? ? 0 : (glyph + delta) % 65_536
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def read_postscript_name
|
|
163
|
+
return "Embedded" unless @tables["name"]
|
|
164
|
+
|
|
165
|
+
offset = table_offset("name")
|
|
166
|
+
strings = offset + u16(offset + 4)
|
|
167
|
+
u16(offset + 2).times do |i|
|
|
168
|
+
record = offset + 6 + (i * 12)
|
|
169
|
+
next unless u16(record + 6) == 6 # nameID 6 = PostScript name
|
|
170
|
+
|
|
171
|
+
raw = @data[strings + u16(record + 10), u16(record + 8)] || ""
|
|
172
|
+
# Keep printable ASCII, which also drops the 0x00s of UTF-16BE names.
|
|
173
|
+
ascii = raw.bytes.grep(0x21..0x7E).pack("C*")
|
|
174
|
+
return sanitize(ascii) unless ascii.empty?
|
|
175
|
+
end
|
|
176
|
+
"Embedded"
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def sanitize(name)
|
|
180
|
+
cleaned = name.gsub(/[^A-Za-z0-9-]/, "")
|
|
181
|
+
cleaned.empty? ? "Embedded" : cleaned
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def scale(value)
|
|
185
|
+
(value * 1000.0 / @units_per_em).round
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def u16(offset)
|
|
189
|
+
(@data.getbyte(offset) << 8) | @data.getbyte(offset + 1)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def i16(offset)
|
|
193
|
+
value = u16(offset)
|
|
194
|
+
value >= 0x8000 ? value - 0x10000 : value
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def u32(offset)
|
|
198
|
+
(u16(offset) << 16) | u16(offset + 2)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def i32(offset)
|
|
202
|
+
value = u32(offset)
|
|
203
|
+
value >= 0x8000_0000 ? value - 0x1_0000_0000 : value
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
end
|