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,431 @@
|
|
|
1
|
+
module Bandoola
|
|
2
|
+
class View
|
|
3
|
+
# The base node in the document tree. Subclasses describe a kind of
|
|
4
|
+
# content (Div, Text, Img); the layout and paint machinery here is shared
|
|
5
|
+
# so a new element only has to say how tall it is and how to draw itself.
|
|
6
|
+
#
|
|
7
|
+
# Geometry is in PDF user space (origin bottom-left, y increasing up).
|
|
8
|
+
# +x+/+top+ are the top-left corner of the element's border box and
|
|
9
|
+
# +w+/+h+ its size; they're filled in during #layout.
|
|
10
|
+
#
|
|
11
|
+
# Elements flow like HTML blocks: by default each fills its container's
|
|
12
|
+
# content width and stacks below the previous one with no gap. A width
|
|
13
|
+
# (w-1/2, w-32, …) shrinks an element so siblings can share a row; when
|
|
14
|
+
# the next element no longer fits, the flow reflows onto a new row. Height
|
|
15
|
+
# is the content's natural height unless a class fixes it (h-16, h-1/2).
|
|
16
|
+
# Padding insets an element's children; margin spaces it from neighbours.
|
|
17
|
+
class Element
|
|
18
|
+
EPSILON = 0.01
|
|
19
|
+
|
|
20
|
+
# The cubic-Bézier control-point ratio that approximates a quarter
|
|
21
|
+
# circle (4/3 * (sqrt(2) - 1)), used to round box corners.
|
|
22
|
+
KAPPA = 0.5522847498
|
|
23
|
+
|
|
24
|
+
attr_reader :style, :children
|
|
25
|
+
attr_accessor :x, :top, :w, :h
|
|
26
|
+
|
|
27
|
+
def initialize(style)
|
|
28
|
+
@style = style
|
|
29
|
+
@children = []
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Position this element's border box and lay out its contents, setting
|
|
33
|
+
# +h+. A width is handed down by the parent's flow; the height is either
|
|
34
|
+
# fixed by a class or grows to fit the content. +avail_height+ is the
|
|
35
|
+
# content-height budget the parent offers, used to resolve h-fractions.
|
|
36
|
+
def layout(origin_x, top, width, avail_height, typeset = PLAIN_TYPESET)
|
|
37
|
+
@x = origin_x
|
|
38
|
+
@top = top
|
|
39
|
+
@w = width
|
|
40
|
+
|
|
41
|
+
pad = style.padding
|
|
42
|
+
fixed = resolved_height(avail_height)
|
|
43
|
+
budget = (fixed || avail_height) - pad.top - pad.bottom
|
|
44
|
+
inner = content_height(origin_x + pad.left, top - pad.top, width - pad.left - pad.right, budget, typeset)
|
|
45
|
+
@h = fixed || (pad.top + inner + pad.bottom)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Lay out any children and return the height the content occupies. +typeset+
|
|
49
|
+
# carries the inherited font and a text measurer for wrapping. The default
|
|
50
|
+
# is container behavior (flow children); leaf elements such as Text and
|
|
51
|
+
# Img override this with their own intrinsic height.
|
|
52
|
+
def content_height(left, top, avail, avail_height, typeset)
|
|
53
|
+
flow(left, top, avail, avail_height, typeset)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Append this element's drawing instructions to +buffer+. +resources+
|
|
57
|
+
# collects fonts and image XObjects for the serializer; +font+ and
|
|
58
|
+
# +weight+ are inherited from ancestors. The default is a container that
|
|
59
|
+
# paints its children, passing the inherited typography down.
|
|
60
|
+
def paint(buffer, resources, font = "sans", weight = NORMAL_WEIGHT)
|
|
61
|
+
children.each { |child| child.paint(buffer, resources, font, weight) }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Shift this element and its descendants vertically by +delta+ points. Used
|
|
65
|
+
# to move a page's worth of body content up to the top of its page.
|
|
66
|
+
def offset(delta)
|
|
67
|
+
@top += delta
|
|
68
|
+
children.each { |child| child.offset(delta) }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# The element's outer (margin-box) width within +avail+: its fixed width
|
|
72
|
+
# plus margins, a fraction of +avail+, or the whole of +avail+. Public
|
|
73
|
+
# because a parent's #flow asks each child for it.
|
|
74
|
+
def outer_width(avail)
|
|
75
|
+
margin = style.margin
|
|
76
|
+
return style.width + margin.left + margin.right if style.width
|
|
77
|
+
|
|
78
|
+
if style.width_fraction
|
|
79
|
+
numerator, denominator = style.width_fraction
|
|
80
|
+
return avail * numerator / denominator.to_f
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
avail
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
protected
|
|
87
|
+
|
|
88
|
+
def coord(value)
|
|
89
|
+
format("%.2f", value).sub(/\.?0+\z/, "")
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Format a color channel (0..1) for the rg/RG operators.
|
|
93
|
+
def channel(value)
|
|
94
|
+
format("%.3f", value).sub(/\.?0+\z/, "")
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# A color operator from an [r, g, b] triple. +operator+ is rg for the
|
|
98
|
+
# nonstroking (fill) color or RG for the stroking color.
|
|
99
|
+
def color_op(rgb, operator = "rg")
|
|
100
|
+
red, green, blue = rgb
|
|
101
|
+
"#{channel(red)} #{channel(green)} #{channel(blue)} #{operator}\n"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
private
|
|
105
|
+
|
|
106
|
+
# An explicit border-box height from a class (fixed or a fraction of the
|
|
107
|
+
# available height), or nil to grow with the content.
|
|
108
|
+
def resolved_height(avail_height)
|
|
109
|
+
return style.height if style.height
|
|
110
|
+
|
|
111
|
+
if style.height_fraction
|
|
112
|
+
numerator, denominator = style.height_fraction
|
|
113
|
+
return avail_height * numerator / denominator.to_f
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
nil
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Flow children left-to-right within the content box, wrapping to a new
|
|
120
|
+
# row when the next child would overflow. Returns the total height.
|
|
121
|
+
def flow(left, top, avail, avail_height, typeset)
|
|
122
|
+
cursor_x = left
|
|
123
|
+
row_top = top
|
|
124
|
+
row_height = 0
|
|
125
|
+
stacked = 0
|
|
126
|
+
|
|
127
|
+
children.each do |child|
|
|
128
|
+
margin = child.style.margin
|
|
129
|
+
outer = child.outer_width(avail)
|
|
130
|
+
|
|
131
|
+
if cursor_x > left && (cursor_x - left) + outer > avail + EPSILON
|
|
132
|
+
stacked += row_height
|
|
133
|
+
row_top -= row_height
|
|
134
|
+
cursor_x = left
|
|
135
|
+
row_height = 0
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
child.layout(cursor_x + margin.left, row_top - margin.top,
|
|
139
|
+
outer - margin.left - margin.right, avail_height, typeset)
|
|
140
|
+
outer_height = margin.top + child.h + margin.bottom
|
|
141
|
+
row_height = outer_height if outer_height > row_height
|
|
142
|
+
cursor_x += outer
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
stacked + row_height
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Clamp a requested radius so it never exceeds half the box (which is
|
|
149
|
+
# what turns rounded-full into a pill/circle).
|
|
150
|
+
def clamp_radius(radius, width, height)
|
|
151
|
+
[radius.to_f, width / 2.0, height / 2.0].min
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# A rounded rectangle as PDF path operators: straight edges joined by
|
|
155
|
+
# quarter-circle corners, each a cubic Bézier whose control points sit
|
|
156
|
+
# KAPPA of the radius from the corner. (left, bottom) is the lower-left.
|
|
157
|
+
def rounded_path(left, bottom, width, height, radius)
|
|
158
|
+
right = left + width
|
|
159
|
+
top_edge = bottom + height
|
|
160
|
+
inset = radius - (radius * KAPPA)
|
|
161
|
+
[
|
|
162
|
+
"#{point([left + radius, bottom])} m",
|
|
163
|
+
"#{point([right - radius, bottom])} l",
|
|
164
|
+
corner([right - inset, bottom], [right, bottom + inset], [right, bottom + radius]),
|
|
165
|
+
"#{point([right, top_edge - radius])} l",
|
|
166
|
+
corner([right, top_edge - inset], [right - inset, top_edge], [right - radius, top_edge]),
|
|
167
|
+
"#{point([left + radius, top_edge])} l",
|
|
168
|
+
corner([left + inset, top_edge], [left, top_edge - inset], [left, top_edge - radius]),
|
|
169
|
+
"#{point([left, bottom + radius])} l",
|
|
170
|
+
corner([left, bottom + inset], [left + inset, bottom], [left + radius, bottom]),
|
|
171
|
+
"h"
|
|
172
|
+
].join("\n")
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def corner(control1, control2, endpoint)
|
|
176
|
+
"#{point(control1)} #{point(control2)} #{point(endpoint)} c"
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def point(coords)
|
|
180
|
+
"#{coord(coords[0])} #{coord(coords[1])}"
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# A box that wraps whatever its block draws. It is invisible by default; a
|
|
185
|
+
# bg-<color>-<shade> class fills it and a border-N class strokes it (in
|
|
186
|
+
# border-<color>-<shade> if given, otherwise black).
|
|
187
|
+
class Div < Element
|
|
188
|
+
def paint(buffer, resources, font = "sans", weight = NORMAL_WEIGHT)
|
|
189
|
+
paint_background(buffer) if style.background
|
|
190
|
+
paint_border(buffer) if style.border
|
|
191
|
+
# Children inherit this div's font/weight classes if it set them.
|
|
192
|
+
super(buffer, resources, style.font || font, style.font_weight || weight)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Children also inherit the div's font/weight when wrapping, so they
|
|
196
|
+
# measure against the right metrics.
|
|
197
|
+
def content_height(left, top, avail, avail_height, typeset)
|
|
198
|
+
flow(left, top, avail, avail_height, typeset.inherit(style))
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
private
|
|
202
|
+
|
|
203
|
+
def paint_background(buffer)
|
|
204
|
+
fill(buffer, style.background, "rg", "f")
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# A uniform border (every side the same width — the bare `border` and
|
|
208
|
+
# border-N classes) strokes the whole box outline, so it still follows a
|
|
209
|
+
# rounded-* radius. A mixed/partial border (border-b, border-x-2, …)
|
|
210
|
+
# strokes only the requested edges as straight line segments; those
|
|
211
|
+
# ignore any corner radius.
|
|
212
|
+
def paint_border(buffer)
|
|
213
|
+
sides = style.border
|
|
214
|
+
if uniform_border?(sides)
|
|
215
|
+
line_width = "#{sides[:top]} w\n"
|
|
216
|
+
fill(buffer, border_color, "RG", "S", line_width)
|
|
217
|
+
else
|
|
218
|
+
paint_border_sides(buffer, sides)
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def uniform_border?(sides)
|
|
223
|
+
widths = sides.values
|
|
224
|
+
widths.all?(&:positive?) && widths.uniq.size == 1
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def paint_border_sides(buffer, sides)
|
|
228
|
+
bottom = top - h
|
|
229
|
+
right = x + w
|
|
230
|
+
stroke_edge(buffer, sides[:top], [x, top], [right, top]) if sides[:top].positive?
|
|
231
|
+
stroke_edge(buffer, sides[:bottom], [x, bottom], [right, bottom]) if sides[:bottom].positive?
|
|
232
|
+
stroke_edge(buffer, sides[:left], [x, top], [x, bottom]) if sides[:left].positive?
|
|
233
|
+
stroke_edge(buffer, sides[:right], [right, top], [right, bottom]) if sides[:right].positive?
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def stroke_edge(buffer, width, from, to)
|
|
237
|
+
red, green, blue = border_color
|
|
238
|
+
buffer << "q\n#{width} w\n#{channel(red)} #{channel(green)} #{channel(blue)} RG\n" \
|
|
239
|
+
"#{coord(from[0])} #{coord(from[1])} m\n#{coord(to[0])} #{coord(to[1])} l\nS\nQ\n"
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def border_color
|
|
243
|
+
style.border_color || [0.0, 0.0, 0.0]
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# Draw this box's shape with the given color. +set+ is the color
|
|
247
|
+
# operator (rg fill / RG stroke), +paint_op+ is f or S, and +prefix+ is
|
|
248
|
+
# any extra setup (e.g. line width). q/Q isolates the graphics state.
|
|
249
|
+
def fill(buffer, color, set, paint_op, prefix = "")
|
|
250
|
+
red, green, blue = color
|
|
251
|
+
buffer << "q\n#{prefix}#{channel(red)} #{channel(green)} #{channel(blue)} #{set}\n" \
|
|
252
|
+
"#{box_path}\n#{paint_op}\nQ\n"
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# The box outline as PDF path operators: a plain rectangle, or — when a
|
|
256
|
+
# rounded-* class set a radius — a rectangle with Bézier corners.
|
|
257
|
+
def box_path
|
|
258
|
+
bottom = top - h
|
|
259
|
+
radius = style.radius ? clamp_radius(style.radius, w, h) : 0.0
|
|
260
|
+
return "#{coord(x)} #{coord(bottom)} #{coord(w)} #{coord(h)} re" unless radius.positive?
|
|
261
|
+
|
|
262
|
+
rounded_path(x, bottom, w, h, radius)
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# A run of text. Embedded newlines become separate lines; there is no word
|
|
267
|
+
# wrapping yet, so a long line can extend past its box.
|
|
268
|
+
class Text < Element
|
|
269
|
+
# Faux-bold stroke per 100 weight steps above normal, as a fraction of
|
|
270
|
+
# the font size. Tuned so font-bold (700) reads clearly bold.
|
|
271
|
+
BOLD_STEP = 0.008
|
|
272
|
+
|
|
273
|
+
attr_reader :content
|
|
274
|
+
|
|
275
|
+
def initialize(style, content)
|
|
276
|
+
super(style)
|
|
277
|
+
@content = content
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
# Wrap the text to the available width (using +typeset+'s measurer and the
|
|
281
|
+
# effective font), storing the lines for paint, and return their height.
|
|
282
|
+
def content_height(_left, _top, avail, _avail_height, typeset)
|
|
283
|
+
@lines = wrap(avail, typeset)
|
|
284
|
+
@lines.length * line_height
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def paint(buffer, resources, font = "sans", weight = NORMAL_WEIGHT)
|
|
288
|
+
pad = style.padding
|
|
289
|
+
left = x + pad.left
|
|
290
|
+
content_top = top - pad.top
|
|
291
|
+
content_width = w - pad.left - pad.right
|
|
292
|
+
# The text's own font-*/weight classes win over the inherited ones.
|
|
293
|
+
name, native, face = resources.font(style.font || font, style.font_weight || weight)
|
|
294
|
+
# Synthesise only the weight the chosen face doesn't already provide.
|
|
295
|
+
setup = text_state((style.font_weight || weight) - native)
|
|
296
|
+
|
|
297
|
+
buffer << "q\n#{setup}" if setup
|
|
298
|
+
wrapped.each_with_index do |line, index|
|
|
299
|
+
baseline = content_top - font_size - (index * line_height)
|
|
300
|
+
indent = align_indent(line, content_width, face)
|
|
301
|
+
# The font encodes the text (WinAnsi literal or Identity-H glyph ids).
|
|
302
|
+
buffer << "BT\n/#{name} #{font_size} Tf\n" \
|
|
303
|
+
"#{coord(left + indent)} #{coord(baseline)} Td\n#{face.encode(line)} Tj\nET\n"
|
|
304
|
+
end
|
|
305
|
+
buffer << "Q\n" if setup
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
private
|
|
309
|
+
|
|
310
|
+
# Horizontal offset for a line under the text-align class: 0 for the
|
|
311
|
+
# default left, the leftover width for right, half of it for center.
|
|
312
|
+
def align_indent(line, content_width, face)
|
|
313
|
+
return 0.0 if style.text_align.nil? || style.text_align == :left
|
|
314
|
+
|
|
315
|
+
slack = content_width - face.width_of(line, font_size)
|
|
316
|
+
style.text_align == :center ? slack / 2.0 : slack
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
# The lines decided during layout; falls back to hard line breaks only if
|
|
320
|
+
# painted without a prior layout.
|
|
321
|
+
def wrapped
|
|
322
|
+
@lines || content.split("\n", -1)
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
# Break the content into lines that fit +avail+ points wide. With no
|
|
326
|
+
# measurer (e.g. a layout without typesetting) only hard newlines break.
|
|
327
|
+
def wrap(avail, typeset)
|
|
328
|
+
return content.split("\n", -1) unless typeset.measure
|
|
329
|
+
|
|
330
|
+
family = style.font || typeset.font
|
|
331
|
+
weight = style.font_weight || typeset.weight
|
|
332
|
+
fits = ->(text) { typeset.measure.call(text, family, weight, font_size) <= avail }
|
|
333
|
+
content.split("\n", -1).flat_map { |paragraph| wrap_paragraph(paragraph, fits) }
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
# Greedy word wrap: keep adding words while they fit; a word too wide to
|
|
337
|
+
# fit alone gets its own (overflowing) line.
|
|
338
|
+
def wrap_paragraph(paragraph, fits)
|
|
339
|
+
words = paragraph.split
|
|
340
|
+
return [paragraph] if words.empty?
|
|
341
|
+
|
|
342
|
+
lines = []
|
|
343
|
+
current = nil
|
|
344
|
+
words.each do |word|
|
|
345
|
+
candidate = current ? "#{current} #{word}" : word
|
|
346
|
+
if current.nil? || fits.call(candidate)
|
|
347
|
+
current = candidate
|
|
348
|
+
else
|
|
349
|
+
lines << current
|
|
350
|
+
current = word
|
|
351
|
+
end
|
|
352
|
+
end
|
|
353
|
+
lines << current
|
|
354
|
+
lines
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
# Font size from a text-<size> class, else the page default. Size affects
|
|
358
|
+
# layout (it sets the line height), so it is per-text rather than
|
|
359
|
+
# inherited like font/weight.
|
|
360
|
+
def font_size
|
|
361
|
+
style.text_size || FONT_SIZE
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
# Line height scaled from the default proportion (LINE_HEIGHT for the
|
|
365
|
+
# default FONT_SIZE).
|
|
366
|
+
def line_height
|
|
367
|
+
(font_size * LINE_HEIGHT / FONT_SIZE.to_f).round
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
# The graphics-state setup for a text run: a fill color (text-<color>)
|
|
371
|
+
# and, when +extra_weight+ remains beyond the chosen face, a matching
|
|
372
|
+
# stroke that thickens the glyphs (render mode 2 = fill + stroke).
|
|
373
|
+
# Returns nil when neither applies, so plain black text needs no wrapper.
|
|
374
|
+
def text_state(extra_weight)
|
|
375
|
+
stroke = faux_bold_width(extra_weight)
|
|
376
|
+
color = style.text_color
|
|
377
|
+
return nil unless color || stroke.positive?
|
|
378
|
+
|
|
379
|
+
color ||= [0.0, 0.0, 0.0]
|
|
380
|
+
setup = color_op(color)
|
|
381
|
+
setup += "#{color_op(color, "RG")}#{coord(stroke)} w\n2 Tr\n" if stroke.positive?
|
|
382
|
+
setup
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
def faux_bold_width(extra_weight)
|
|
386
|
+
return 0.0 if extra_weight <= 0
|
|
387
|
+
|
|
388
|
+
extra_weight / 100.0 * font_size * BOLD_STEP
|
|
389
|
+
end
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
# An image element. +image+ is an already-loaded Svg (drawn inline) or Jpeg
|
|
393
|
+
# (embedded as an XObject); View loads and caches it. With no width class it
|
|
394
|
+
# takes the image's intrinsic width; height defaults to the aspect-preserving
|
|
395
|
+
# value unless a height class overrides it.
|
|
396
|
+
class Img < Element
|
|
397
|
+
def initialize(style, image)
|
|
398
|
+
super(style)
|
|
399
|
+
@image = image
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
def content_height(_left, _top, avail, _avail_height, _typeset)
|
|
403
|
+
avail / @image.aspect
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
def paint(buffer, resources, _font = "sans", _weight = NORMAL_WEIGHT)
|
|
407
|
+
pad = style.padding
|
|
408
|
+
left = x + pad.left
|
|
409
|
+
box_top = top - pad.top
|
|
410
|
+
width = w - pad.left - pad.right
|
|
411
|
+
height = h - pad.top - pad.bottom
|
|
412
|
+
box = [left, box_top, width, height]
|
|
413
|
+
radius = style.radius ? clamp_radius(style.radius, width, height) : 0.0
|
|
414
|
+
|
|
415
|
+
return @image.paint_into(buffer, resources, box) unless radius.positive?
|
|
416
|
+
|
|
417
|
+
# Clip the image to a rounded rectangle: build the path, set it as the
|
|
418
|
+
# clip region with `W n`, draw the image inside, then restore.
|
|
419
|
+
buffer << "q\n#{rounded_path(left, box_top - height, width, height, radius)}\nW n\n"
|
|
420
|
+
@image.paint_into(buffer, resources, box)
|
|
421
|
+
buffer << "Q\n"
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
def outer_width(avail)
|
|
425
|
+
return super if style.width || style.width_fraction
|
|
426
|
+
|
|
427
|
+
@image.intrinsic_width + style.margin.left + style.margin.right
|
|
428
|
+
end
|
|
429
|
+
end
|
|
430
|
+
end
|
|
431
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
module Bandoola
|
|
2
|
+
class View
|
|
3
|
+
# Persisting a rendered document to disk.
|
|
4
|
+
#
|
|
5
|
+
# Mixed into View; #write reads the View instance's #contents. Named
|
|
6
|
+
# Writer rather than File so it never shadows Ruby's ::File for code in a
|
|
7
|
+
# View subclass body.
|
|
8
|
+
module Writer
|
|
9
|
+
# Write the most recently rendered contents to +path+. Returns self.
|
|
10
|
+
def write(path)
|
|
11
|
+
File.binwrite(path, contents)
|
|
12
|
+
self
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
module Bandoola
|
|
2
|
+
class View
|
|
3
|
+
# The PDF document structure that wraps the rendered page buffers: the file
|
|
4
|
+
# header, the numbered objects (catalog, pages, one Page and content stream
|
|
5
|
+
# per page, then the fonts and image XObjects used), the cross-reference
|
|
6
|
+
# table of their byte offsets, and the trailer pointing at the root.
|
|
7
|
+
#
|
|
8
|
+
# The shared MediaBox and /Resources sit on the Pages node and are inherited
|
|
9
|
+
# by every page, so they're declared once however many pages there are.
|
|
10
|
+
#
|
|
11
|
+
# Mixed into View; its methods run against the View instance, so they can
|
|
12
|
+
# read @page_buffers, @resources and the geometry methods on the class.
|
|
13
|
+
module Header
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
# Wrap @page_buffers in PDF structure and return the file as bytes. Built
|
|
17
|
+
# in binary, since embedded fonts and images carry raw bytes.
|
|
18
|
+
def serialize
|
|
19
|
+
objects = build_objects
|
|
20
|
+
|
|
21
|
+
out = String.new("%PDF-1.4\n", encoding: Encoding::ASCII_8BIT)
|
|
22
|
+
# A binary comment marks the file as containing 8-bit data, which
|
|
23
|
+
# keeps naive tools from mangling it as plain text.
|
|
24
|
+
out << "%\xE2\xE3\xCF\xD3\n".b
|
|
25
|
+
|
|
26
|
+
offsets = []
|
|
27
|
+
objects.each_with_index do |body, index|
|
|
28
|
+
offsets << out.bytesize
|
|
29
|
+
# body.b keeps text bytes (which may be non-ASCII) and binary font/
|
|
30
|
+
# image bytes alike from clashing with the buffer's encoding.
|
|
31
|
+
out << "#{index + 1} 0 obj\n" << body.b << "\nendobj\n"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
xref_offset = out.bytesize
|
|
35
|
+
out << build_xref(offsets)
|
|
36
|
+
out << build_trailer(objects.size, xref_offset)
|
|
37
|
+
out
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# The document's objects, in order: catalog (1), pages (2), the N Page
|
|
41
|
+
# objects, the N content streams, then a block per used font and one
|
|
42
|
+
# XObject per used image. Object numbers are 1-based array positions.
|
|
43
|
+
def build_objects
|
|
44
|
+
pages = @page_buffers.size
|
|
45
|
+
first_page = 3
|
|
46
|
+
first_content = first_page + pages
|
|
47
|
+
number = first_content + pages
|
|
48
|
+
|
|
49
|
+
extras = []
|
|
50
|
+
font_refs = {}
|
|
51
|
+
@resources.fonts.each do |name, font|
|
|
52
|
+
font_refs[name] = number
|
|
53
|
+
extras.concat(font.pdf_objects(number))
|
|
54
|
+
number += font.object_count
|
|
55
|
+
end
|
|
56
|
+
image_refs = {}
|
|
57
|
+
@resources.images.each do |name, image|
|
|
58
|
+
image_refs[name] = number
|
|
59
|
+
extras << image.xobject
|
|
60
|
+
number += 1
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
page_objects = Array.new(pages) { |i| page_object(first_content + i) }
|
|
64
|
+
content_objects = @page_buffers.map { |buffer| content_object(buffer) }
|
|
65
|
+
|
|
66
|
+
[catalog, pages_object(first_page, pages, font_refs, image_refs), *page_objects, *content_objects, *extras]
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def catalog
|
|
70
|
+
"<< /Type /Catalog /Pages 2 0 R >>"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# The Pages node holds the kid list and the inherited MediaBox/Resources.
|
|
74
|
+
def pages_object(first_page, count, font_refs, image_refs)
|
|
75
|
+
kids = (first_page...(first_page + count)).map { |n| "#{n} 0 R" }.join(" ")
|
|
76
|
+
resources = "/Font << #{refs(font_refs)} >>"
|
|
77
|
+
resources += " /XObject << #{refs(image_refs)} >>" unless image_refs.empty?
|
|
78
|
+
"<< /Type /Pages /Kids [#{kids}] /Count #{count} " \
|
|
79
|
+
"/MediaBox [0 0 #{width} #{height}] /Resources << #{resources} >> >>"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def page_object(content_number)
|
|
83
|
+
"<< /Type /Page /Parent 2 0 R /Contents #{content_number} 0 R >>"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def content_object(buffer)
|
|
87
|
+
"<< /Length #{buffer.bytesize} >>\nstream\n#{buffer}\nendstream"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def refs(name_to_number)
|
|
91
|
+
name_to_number.map { |name, number| "/#{name} #{number} 0 R" }.join(" ")
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def build_xref(offsets)
|
|
95
|
+
# The table opens with a free entry for object 0, then one
|
|
96
|
+
# fixed-width line per object giving its byte offset.
|
|
97
|
+
rows = +"0000000000 65535 f \n"
|
|
98
|
+
offsets.each do |offset|
|
|
99
|
+
rows << format("%010d 00000 n \n", offset)
|
|
100
|
+
end
|
|
101
|
+
"xref\n0 #{offsets.size + 1}\n#{rows}"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def build_trailer(object_count, xref_offset)
|
|
105
|
+
"trailer\n<< /Size #{object_count + 1} /Root 1 0 R >>\n" \
|
|
106
|
+
"startxref\n#{xref_offset}\n%%EOF\n"
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
module Bandoola
|
|
2
|
+
class View
|
|
3
|
+
# Collects the fonts and image XObjects referenced by the content stream
|
|
4
|
+
# during the paint pass, so the serializer can emit them as objects and
|
|
5
|
+
# list them in the page's /Resources. Each gets a name used by the content
|
|
6
|
+
# stream: F1, F2, … for fonts (the Tf operator) and Im1, Im2, … for images
|
|
7
|
+
# (the Do operator).
|
|
8
|
+
#
|
|
9
|
+
# Fonts are resolved lazily through +font_resolver+ (a callable name ->
|
|
10
|
+
# Font), so a font registered but never used costs nothing.
|
|
11
|
+
class Resources
|
|
12
|
+
def initialize(font_resolver)
|
|
13
|
+
@font_resolver = font_resolver
|
|
14
|
+
@fonts = {} # face key => [resource_name, font]
|
|
15
|
+
@images = {} # image => [resource_name, image]
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# The used fonts/images as [resource_name, …] pairs, in first-use order.
|
|
19
|
+
def fonts
|
|
20
|
+
@fonts.values
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def images
|
|
24
|
+
@images.values
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Resolve a (family, weight) to a face and return [resource_name,
|
|
28
|
+
# native_weight, font]. Faces are deduped by the resolver's cache key, so
|
|
29
|
+
# two weights of one family share a resource only if they map to one file.
|
|
30
|
+
def font(family, weight)
|
|
31
|
+
key, font, native = @font_resolver.call(family, weight)
|
|
32
|
+
entry = (@fonts[key] ||= ["F#{@fonts.size + 1}", font])
|
|
33
|
+
[entry[0], native, font]
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Resource name for an image, deduped so the same image painted more than
|
|
37
|
+
# once (e.g. a logo in a header repeated on every page) embeds once.
|
|
38
|
+
def add_image(image)
|
|
39
|
+
entry = (@images[image] ||= ["Im#{@images.size + 1}", image])
|
|
40
|
+
entry[0]
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
module Bandoola
|
|
2
|
+
class View
|
|
3
|
+
class Style
|
|
4
|
+
# Border widths and corner radius. `border` and `border-N` (border-1 …
|
|
5
|
+
# border-16) set a stroke width in points on every side; the directional
|
|
6
|
+
# classes set a subset — -t/-r/-b/-l one edge, -x left+right, -y
|
|
7
|
+
# top+bottom — each optionally with a width (`border-b-2`) and defaulting
|
|
8
|
+
# to 1pt. The rounded-* classes set a corner radius, also in points
|
|
9
|
+
# (Tailwind's scale, 1px ≈ 1pt). Border color lives in Colors;
|
|
10
|
+
# border-<color>-<shade> has a word between the dashes, so it never
|
|
11
|
+
# collides with these. Mixed into Style.
|
|
12
|
+
module Borders
|
|
13
|
+
# border / border-2 / border-b / border-t-4 / border-x-2.
|
|
14
|
+
BORDER = /\Aborder(?:-([xytrbl]))?(?:-(\d+))?\z/
|
|
15
|
+
WIDTHS = (1..16)
|
|
16
|
+
|
|
17
|
+
# Which edges each directional suffix paints (nil = bare/numeric form).
|
|
18
|
+
SIDES = {
|
|
19
|
+
nil => %i[top right bottom left],
|
|
20
|
+
"t" => %i[top], "r" => %i[right], "b" => %i[bottom], "l" => %i[left],
|
|
21
|
+
"x" => %i[left right], "y" => %i[top bottom]
|
|
22
|
+
}.freeze
|
|
23
|
+
|
|
24
|
+
# Tailwind's border-radius scale, in points. rounded-full uses a large
|
|
25
|
+
# value the renderer clamps to half the box (a pill / circle).
|
|
26
|
+
RADII = {
|
|
27
|
+
"rounded-none" => 0,
|
|
28
|
+
"rounded-sm" => 2,
|
|
29
|
+
"rounded" => 4,
|
|
30
|
+
"rounded-md" => 6,
|
|
31
|
+
"rounded-lg" => 8,
|
|
32
|
+
"rounded-xl" => 12,
|
|
33
|
+
"rounded-2xl" => 16,
|
|
34
|
+
"rounded-3xl" => 24,
|
|
35
|
+
"rounded-full" => 9999
|
|
36
|
+
}.freeze
|
|
37
|
+
|
|
38
|
+
attr_reader :border, :radius
|
|
39
|
+
|
|
40
|
+
def init_borders
|
|
41
|
+
@border = nil # {top:, right:, bottom:, left:} widths in points, or nil
|
|
42
|
+
@radius = nil # corner radius in points, or nil for square corners
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def apply_border_token(token)
|
|
46
|
+
if (match = token.match(BORDER))
|
|
47
|
+
apply_border_width(match[1], match[2])
|
|
48
|
+
elsif RADII.key?(token)
|
|
49
|
+
@radius = RADII[token]
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Set +width+ (defaulting to 1pt) on the edges named by +side+, lazily
|
|
54
|
+
# allocating the per-side hash so a div with no border classes keeps a
|
|
55
|
+
# nil border.
|
|
56
|
+
def apply_border_width(side, width)
|
|
57
|
+
width = width ? width.to_i : 1
|
|
58
|
+
return unless WIDTHS.include?(width)
|
|
59
|
+
|
|
60
|
+
@border ||= { top: 0, right: 0, bottom: 0, left: 0 }
|
|
61
|
+
SIDES.fetch(side).each { |edge| @border[edge] = width }
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|