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,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
@@ -0,0 +1,4 @@
1
+ module Bandoola
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
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: []