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,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