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,364 @@
|
|
|
1
|
+
require_relative "view/header"
|
|
2
|
+
require_relative "view/file"
|
|
3
|
+
require_relative "view/style"
|
|
4
|
+
require_relative "view/element"
|
|
5
|
+
require_relative "view/resources"
|
|
6
|
+
|
|
7
|
+
module Bandoola
|
|
8
|
+
# A Phlex-style base class for declaring PDF documents.
|
|
9
|
+
#
|
|
10
|
+
# Subclass it and describe the document in #view_template using the element
|
|
11
|
+
# methods below (#div, #text). Each call builds a node in a tree; once the
|
|
12
|
+
# tree is complete #render lays it out — resolving widths, padding, margins
|
|
13
|
+
# and reflow (see Element) — and paints it into @buffer, which Header then
|
|
14
|
+
# wraps into a finished PDF stored in @contents. #write persists it.
|
|
15
|
+
#
|
|
16
|
+
# The supporting concerns live in their own files so this one stays focused
|
|
17
|
+
# on the tags: Style (the Tailwind-ish `class` vocabulary), Element and its
|
|
18
|
+
# subclasses (layout + paint), Header (PDF structure) and File (writing).
|
|
19
|
+
#
|
|
20
|
+
# class Hello < Bandoola::View
|
|
21
|
+
# def view_template
|
|
22
|
+
# div(class: "p-4 w-1/2") do
|
|
23
|
+
# text { "Hello World!" }
|
|
24
|
+
# end
|
|
25
|
+
# end
|
|
26
|
+
# end
|
|
27
|
+
#
|
|
28
|
+
# view = Hello.new
|
|
29
|
+
# view.render
|
|
30
|
+
# view.write("hello.pdf")
|
|
31
|
+
class View
|
|
32
|
+
# A4 proportions (1 : √2) at 96 DPI rather than the PDF default of 72, so
|
|
33
|
+
# the page is larger and the fixed-point text/spacing classes (text-xs,
|
|
34
|
+
# p-4, …) read smaller against it. Override #width/#height in a subclass
|
|
35
|
+
# for a different page.
|
|
36
|
+
PAGE_WIDTH = 794
|
|
37
|
+
PAGE_HEIGHT = 1123
|
|
38
|
+
|
|
39
|
+
# Printable margin around the page (1 inch at 96 DPI), plus text metrics:
|
|
40
|
+
# the default glyph height and the vertical step between lines.
|
|
41
|
+
MARGIN = 96
|
|
42
|
+
FONT_SIZE = 24
|
|
43
|
+
LINE_HEIGHT = 28
|
|
44
|
+
|
|
45
|
+
include Header
|
|
46
|
+
include Writer
|
|
47
|
+
|
|
48
|
+
# Every subclass, in the order it was defined. The CLI uses this to find
|
|
49
|
+
# the view a loaded file declares.
|
|
50
|
+
@registry = []
|
|
51
|
+
|
|
52
|
+
class << self
|
|
53
|
+
attr_reader :registry
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def self.inherited(subclass)
|
|
57
|
+
super
|
|
58
|
+
View.registry << subclass
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
NORMAL_WEIGHT = 400
|
|
62
|
+
# Weights at or above this use a real bold face (a bold standard base font,
|
|
63
|
+
# or a registered bold file); lighter gaps are synthesised as faux bold.
|
|
64
|
+
BOLD_THRESHOLD = 700
|
|
65
|
+
|
|
66
|
+
# The three always-available families, with their regular and bold standard
|
|
67
|
+
# base fonts (the standard 14 PDF fonts ship a real bold for each).
|
|
68
|
+
BUILTIN_FONTS = {
|
|
69
|
+
"sans" => { NORMAL_WEIGHT => "Helvetica", BOLD_THRESHOLD => "Helvetica-Bold" },
|
|
70
|
+
"serif" => { NORMAL_WEIGHT => "Times-Roman", BOLD_THRESHOLD => "Times-Bold" },
|
|
71
|
+
"mono" => { NORMAL_WEIGHT => "Courier", BOLD_THRESHOLD => "Courier-Bold" }
|
|
72
|
+
}.freeze
|
|
73
|
+
|
|
74
|
+
# Register an embedded TrueType font for this view, usable via font-<name>.
|
|
75
|
+
# +source+ is either a single path (one regular face) or a Hash of weight
|
|
76
|
+
# => path for several files, since a .ttf holds only one weight:
|
|
77
|
+
#
|
|
78
|
+
# register_font :cool, "./fonts/cool-font.ttf"
|
|
79
|
+
# register_font :cool, regular: "./cool.ttf", bold: "./cool-bold.ttf"
|
|
80
|
+
def self.register_font(name, source)
|
|
81
|
+
font_faces_registry[name.to_s] = normalize_faces(source)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def self.font_faces_registry
|
|
85
|
+
@font_faces_registry ||= {}
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Whether embedded fonts are compacted (glyphs renumbered into a dense
|
|
89
|
+
# range — smaller files). On by default; opt out per view with
|
|
90
|
+
# `compact_fonts false` to trade a little size for a simpler subset.
|
|
91
|
+
# A positional boolean keeps the class-macro reading: `compact_fonts false`.
|
|
92
|
+
def self.compact_fonts(value = true) # rubocop:disable Style/OptionalBooleanParameter
|
|
93
|
+
@compact_fonts = value
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def self.compact_fonts?
|
|
97
|
+
return @compact_fonts unless @compact_fonts.nil?
|
|
98
|
+
|
|
99
|
+
superclass.respond_to?(:compact_fonts?) ? superclass.compact_fonts? : true
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# The registered weight => path faces for a family, walking up the class
|
|
103
|
+
# hierarchy so a subclass inherits its parent's registrations.
|
|
104
|
+
def self.font_faces(family)
|
|
105
|
+
klass = self
|
|
106
|
+
while klass.respond_to?(:font_faces_registry)
|
|
107
|
+
faces = klass.font_faces_registry[family]
|
|
108
|
+
return faces if faces
|
|
109
|
+
|
|
110
|
+
klass = klass.superclass
|
|
111
|
+
end
|
|
112
|
+
nil
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Normalize a register_font source to a weight(Integer) => path Hash.
|
|
116
|
+
def self.normalize_faces(source)
|
|
117
|
+
return { NORMAL_WEIGHT => source } unless source.is_a?(Hash)
|
|
118
|
+
|
|
119
|
+
source.to_h { |weight, path| [weight_value(weight), path] }
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def self.weight_value(weight)
|
|
123
|
+
return weight if weight.is_a?(Integer)
|
|
124
|
+
|
|
125
|
+
key = weight.to_s
|
|
126
|
+
return NORMAL_WEIGHT if key == "regular"
|
|
127
|
+
|
|
128
|
+
Style::Typography::WEIGHTS.fetch(key) { raise Error, "unknown font weight: #{weight}" }
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
PAGE_BREAK_EPSILON = 0.01
|
|
132
|
+
|
|
133
|
+
# The inherited font/weight and a text measurer, threaded through layout so
|
|
134
|
+
# Text can wrap. #inherit applies a div's font classes to its children.
|
|
135
|
+
Typeset = Struct.new(:font, :weight, :measure) do
|
|
136
|
+
def inherit(style)
|
|
137
|
+
Typeset.new(style.font || font, style.font_weight || weight, measure)
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
# Used when a tree is laid out without typesetting (no wrapping).
|
|
141
|
+
PLAIN_TYPESET = Typeset.new("sans", NORMAL_WEIGHT, nil)
|
|
142
|
+
|
|
143
|
+
# contents: the rendered PDF bytes. page_number/page_count are set while a
|
|
144
|
+
# page's #header and #footer run, so those methods can show "Page 2 of 5".
|
|
145
|
+
attr_reader :contents, :page_number, :page_count
|
|
146
|
+
|
|
147
|
+
# Page geometry, in PDF points. Override any of these in a subclass to
|
|
148
|
+
# change the page — e.g. `def width = 1234` for a wider page.
|
|
149
|
+
def width = PAGE_WIDTH
|
|
150
|
+
def height = PAGE_HEIGHT
|
|
151
|
+
def margin = MARGIN
|
|
152
|
+
|
|
153
|
+
# Lay out the document and serialize it. The body (#view_template) flows into
|
|
154
|
+
# the area between the optional #header and #footer bands and splits across
|
|
155
|
+
# as many pages as it needs; the header and footer repeat on every page.
|
|
156
|
+
def render
|
|
157
|
+
@resources = Resources.new(method(:resolve_font))
|
|
158
|
+
@typeset = Typeset.new("sans", NORMAL_WEIGHT, method(:measure_text))
|
|
159
|
+
|
|
160
|
+
# Header/footer heights (their content is assumed not to change height
|
|
161
|
+
# between pages); the body gets what's left.
|
|
162
|
+
@page_number = @page_count = 1
|
|
163
|
+
header_height = band_height(:header)
|
|
164
|
+
footer_height = band_height(:footer)
|
|
165
|
+
body_top = (height - margin) - header_height
|
|
166
|
+
body_height = content_height - header_height - footer_height
|
|
167
|
+
|
|
168
|
+
# Lay the body out in one tall column, then split it into pages.
|
|
169
|
+
body = build_tree { view_template }
|
|
170
|
+
body.layout(margin, body_top, content_width, body_height, @typeset)
|
|
171
|
+
pages = paginate(body.children, body_height, body_top)
|
|
172
|
+
|
|
173
|
+
@page_buffers = pages.each_with_index.map do |children, index|
|
|
174
|
+
@page_number = index + 1
|
|
175
|
+
@page_count = pages.size
|
|
176
|
+
buffer = String.new(encoding: Encoding::ASCII_8BIT)
|
|
177
|
+
paint_header(buffer)
|
|
178
|
+
paint_footer(buffer)
|
|
179
|
+
children.each { |child| child.paint(buffer, @resources) }
|
|
180
|
+
buffer
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
@contents = serialize
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Resolve a (family, weight) to a face: [cache_key, Font, native_weight].
|
|
187
|
+
# The native weight lets the caller add faux bold for any remaining gap.
|
|
188
|
+
# sans/serif/mono are standard; other families must be registered.
|
|
189
|
+
def resolve_font(family, weight)
|
|
190
|
+
if BUILTIN_FONTS.key?(family)
|
|
191
|
+
standard_face(family, weight)
|
|
192
|
+
elsif (faces = self.class.font_faces(family))
|
|
193
|
+
embedded_face(faces, weight)
|
|
194
|
+
else
|
|
195
|
+
raise Error, "unknown font: font-#{family}"
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# Subclasses describe their document here.
|
|
200
|
+
def view_template
|
|
201
|
+
raise NotImplementedError, "#{self.class} must implement #view_template"
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# Image file extension to loader class.
|
|
205
|
+
LOADERS = { ".svg" => Svg, ".jpg" => Jpeg, ".jpeg" => Jpeg }.freeze
|
|
206
|
+
|
|
207
|
+
private
|
|
208
|
+
|
|
209
|
+
def content_width = width - (2 * margin)
|
|
210
|
+
def content_height = height - (2 * margin)
|
|
211
|
+
|
|
212
|
+
# Run a tag-defining method (view_template/header/footer) into a fresh tree.
|
|
213
|
+
def build_tree
|
|
214
|
+
root = Element.new(Style.none)
|
|
215
|
+
@stack = [root]
|
|
216
|
+
yield
|
|
217
|
+
root
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# Width of +string+ at +size+ points in the resolved (family, weight) face.
|
|
221
|
+
# The text measurer threaded through layout so Text can wrap.
|
|
222
|
+
def measure_text(string, family, weight, size)
|
|
223
|
+
_key, face, = resolve_font(family, weight)
|
|
224
|
+
face.width_of(string, size)
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# The laid-out height of the header/footer band (0 when not defined).
|
|
228
|
+
def band_height(name)
|
|
229
|
+
return 0.0 unless respond_to?(name)
|
|
230
|
+
|
|
231
|
+
tree = build_tree { send(name) }
|
|
232
|
+
tree.layout(margin, height - margin, content_width, height, @typeset)
|
|
233
|
+
tree.h
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# Greedily pack top-level body elements into pages, then shift each page's
|
|
237
|
+
# elements up so the page starts at +body_top+. An element taller than a
|
|
238
|
+
# page is left to overflow (no splitting yet).
|
|
239
|
+
def paginate(children, body_height, body_top)
|
|
240
|
+
pages = pack_pages(children, body_height)
|
|
241
|
+
reposition(pages, body_top)
|
|
242
|
+
pages
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def pack_pages(children, body_height)
|
|
246
|
+
pages = [[]]
|
|
247
|
+
used = 0.0
|
|
248
|
+
children.each do |child|
|
|
249
|
+
# The space a child takes is its height plus its own top/bottom margins,
|
|
250
|
+
# the same as the flow that laid it out — otherwise margined content
|
|
251
|
+
# overflows past the page (and onto the footer).
|
|
252
|
+
outer = child.style.margin.top + child.h + child.style.margin.bottom
|
|
253
|
+
if used.positive? && used + outer > body_height + PAGE_BREAK_EPSILON
|
|
254
|
+
pages << []
|
|
255
|
+
used = 0.0
|
|
256
|
+
end
|
|
257
|
+
pages.last << child
|
|
258
|
+
used += outer
|
|
259
|
+
end
|
|
260
|
+
pages
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def reposition(pages, body_top)
|
|
264
|
+
pages.each do |group|
|
|
265
|
+
next if group.empty?
|
|
266
|
+
|
|
267
|
+
delta = body_top - group.first.top
|
|
268
|
+
group.each { |child| child.offset(delta) } unless delta.zero?
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def paint_header(buffer)
|
|
273
|
+
return unless respond_to?(:header)
|
|
274
|
+
|
|
275
|
+
tree = build_tree { header }
|
|
276
|
+
tree.layout(margin, height - margin, content_width, height, @typeset)
|
|
277
|
+
tree.paint(buffer, @resources)
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def paint_footer(buffer)
|
|
281
|
+
return unless respond_to?(:footer)
|
|
282
|
+
|
|
283
|
+
tree = build_tree { footer }
|
|
284
|
+
tree.layout(margin, height - margin, content_width, height, @typeset) # measure its height
|
|
285
|
+
tree.layout(margin, margin + tree.h, content_width, height, @typeset) # sit it above the bottom margin
|
|
286
|
+
tree.paint(buffer, @resources)
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
# Load (and cache) the image at +src+ so a logo repeated across pages embeds
|
|
290
|
+
# once and rebuilt header/footer trees reuse it.
|
|
291
|
+
def image_for(src)
|
|
292
|
+
image_cache[src] ||= begin
|
|
293
|
+
loader = LOADERS[File.extname(src).downcase]
|
|
294
|
+
raise Error, "unsupported image type: #{src}" unless loader
|
|
295
|
+
|
|
296
|
+
loader.load(src)
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
def image_cache
|
|
301
|
+
@image_cache ||= {}
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
def font_cache
|
|
305
|
+
@font_cache ||= {}
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
# A built-in family: a real bold base font at/above the bold threshold,
|
|
309
|
+
# otherwise the regular base. Its native weight is bold or normal exactly.
|
|
310
|
+
def standard_face(family, weight)
|
|
311
|
+
bold = weight >= BOLD_THRESHOLD
|
|
312
|
+
native = bold ? BOLD_THRESHOLD : NORMAL_WEIGHT
|
|
313
|
+
base = BUILTIN_FONTS.fetch(family).fetch(native)
|
|
314
|
+
[base, (font_cache[base] ||= StandardFont.new(base)), native]
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
# A registered family: the heaviest available face no heavier than the
|
|
318
|
+
# requested weight (or the lightest if all are heavier).
|
|
319
|
+
def embedded_face(faces, weight)
|
|
320
|
+
native = pick_weight(faces.keys, weight)
|
|
321
|
+
path = faces.fetch(native)
|
|
322
|
+
font = (font_cache[path] ||= EmbeddedFont.load(path, compact: self.class.compact_fonts?))
|
|
323
|
+
[path, font, native]
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
def pick_weight(available, requested)
|
|
327
|
+
available.select { |candidate| candidate <= requested }.max || available.min
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
# The div element: a rectangle that contains other elements. The block
|
|
331
|
+
# nests further elements inside it. The +class+ keyword accepts a String
|
|
332
|
+
# of space-separated tokens or an Array of tokens (see Style).
|
|
333
|
+
def div(**attributes, &)
|
|
334
|
+
append(Div.new(Style.parse(attributes[:class])), &)
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
# The text element. The block returns the string to render; embedded
|
|
338
|
+
# newlines become separate lines.
|
|
339
|
+
def text(**attributes)
|
|
340
|
+
content = block_given? ? yield.to_s : ""
|
|
341
|
+
@stack.last.children << Text.new(Style.parse(attributes[:class]), content)
|
|
342
|
+
nil
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
# The image element. +src+ is a path to an SVG or JPEG. Sizing follows the
|
|
346
|
+
# width/height classes (see Style); with none it uses the image's own size.
|
|
347
|
+
def img(src:, **attributes)
|
|
348
|
+
@stack.last.children << Img.new(Style.parse(attributes[:class]), image_for(src))
|
|
349
|
+
nil
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
# Add +element+ to the current parent, and — if a block is given — make
|
|
353
|
+
# it the parent while the block runs so nested tags land inside it.
|
|
354
|
+
def append(element, &block)
|
|
355
|
+
@stack.last.children << element
|
|
356
|
+
return element unless block
|
|
357
|
+
|
|
358
|
+
@stack.push(element)
|
|
359
|
+
yield
|
|
360
|
+
@stack.pop
|
|
361
|
+
nil
|
|
362
|
+
end
|
|
363
|
+
end
|
|
364
|
+
end
|
data/lib/bandoola.rb
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
require_relative "bandoola/version"
|
|
2
|
+
|
|
3
|
+
module Bandoola
|
|
4
|
+
class Error < StandardError; end
|
|
5
|
+
end
|
|
6
|
+
|
|
7
|
+
require_relative "bandoola/svg"
|
|
8
|
+
require_relative "bandoola/jpeg"
|
|
9
|
+
require_relative "bandoola/true_type_font"
|
|
10
|
+
require_relative "bandoola/true_type_subset"
|
|
11
|
+
require_relative "bandoola/font"
|
|
12
|
+
require_relative "bandoola/standard_metrics"
|
|
13
|
+
require_relative "bandoola/view"
|
|
14
|
+
require_relative "bandoola/cli"
|
data/sig/bandoola.rbs
ADDED
metadata
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: bandoola
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Johan Halse
|
|
8
|
+
bindir: exe
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies: []
|
|
12
|
+
description: Bandoola builds PDF documents from plain Ruby, styling elements with
|
|
13
|
+
a Tailwind-inspired class vocabulary. No runtime dependencies.
|
|
14
|
+
email:
|
|
15
|
+
- johan@hal.se
|
|
16
|
+
executables:
|
|
17
|
+
- bandoola
|
|
18
|
+
extensions: []
|
|
19
|
+
extra_rdoc_files: []
|
|
20
|
+
files:
|
|
21
|
+
- ".tool-versions"
|
|
22
|
+
- CHANGELOG.md
|
|
23
|
+
- LICENSE.txt
|
|
24
|
+
- README.md
|
|
25
|
+
- Rakefile
|
|
26
|
+
- exe/bandoola
|
|
27
|
+
- lib/bandoola.rb
|
|
28
|
+
- lib/bandoola/cli.rb
|
|
29
|
+
- lib/bandoola/font.rb
|
|
30
|
+
- lib/bandoola/jpeg.rb
|
|
31
|
+
- lib/bandoola/standard_metrics.rb
|
|
32
|
+
- lib/bandoola/svg.rb
|
|
33
|
+
- lib/bandoola/true_type_font.rb
|
|
34
|
+
- lib/bandoola/true_type_subset.rb
|
|
35
|
+
- lib/bandoola/version.rb
|
|
36
|
+
- lib/bandoola/view.rb
|
|
37
|
+
- lib/bandoola/view/element.rb
|
|
38
|
+
- lib/bandoola/view/file.rb
|
|
39
|
+
- lib/bandoola/view/header.rb
|
|
40
|
+
- lib/bandoola/view/resources.rb
|
|
41
|
+
- lib/bandoola/view/style.rb
|
|
42
|
+
- lib/bandoola/view/style/borders.rb
|
|
43
|
+
- lib/bandoola/view/style/colors.rb
|
|
44
|
+
- lib/bandoola/view/style/spacing.rb
|
|
45
|
+
- lib/bandoola/view/style/typography.rb
|
|
46
|
+
- sig/bandoola.rbs
|
|
47
|
+
homepage: https://github.com/johanhalse/bandoola
|
|
48
|
+
licenses:
|
|
49
|
+
- MIT
|
|
50
|
+
metadata:
|
|
51
|
+
allowed_push_host: https://gem.coop
|
|
52
|
+
homepage_uri: https://github.com/johanhalse/bandoola
|
|
53
|
+
source_code_uri: https://github.com/johanhalse/bandoola
|
|
54
|
+
changelog_uri: https://github.com/johanhalse/bandoola/blob/main/CHANGELOG.md
|
|
55
|
+
rubygems_mfa_required: 'true'
|
|
56
|
+
rdoc_options: []
|
|
57
|
+
require_paths:
|
|
58
|
+
- lib
|
|
59
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
60
|
+
requirements:
|
|
61
|
+
- - ">="
|
|
62
|
+
- !ruby/object:Gem::Version
|
|
63
|
+
version: 4.0.0
|
|
64
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
65
|
+
requirements:
|
|
66
|
+
- - ">="
|
|
67
|
+
- !ruby/object:Gem::Version
|
|
68
|
+
version: '0'
|
|
69
|
+
requirements: []
|
|
70
|
+
rubygems_version: 4.0.15
|
|
71
|
+
specification_version: 4
|
|
72
|
+
summary: A small, dependency-free PDF generator for Ruby with Tailwind-style classes.
|
|
73
|
+
test_files: []
|