bandoola 0.1.0 → 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 399aa72c7e648f1d22ac8e4c8a3dabd5bec6cb1cbdab69f198c07c678ead2e7e
4
- data.tar.gz: c2911c8a52cc62d55df0e5ab93c41ac45bcb13647b35ea805640454b37b029d5
3
+ metadata.gz: ca546c7de64a2889f63c5d6263add435f60097dabd9b5a7f7019dcd411993eb6
4
+ data.tar.gz: aac0858803151d55f36cd237278ca8e54bf82b7a098f760749ff0b5fc4b19ff0
5
5
  SHA512:
6
- metadata.gz: 9a3601d9583eb626212aea6b50a929c810cf38a40d47072e02b323455227bb0e4ba83dab3d1fcf49252d663563d2990527fce2b8d8a443e9203d4588deadda16
7
- data.tar.gz: 3da607cdeef5663505cc48f02b1849750da611369c495b5601f3d87d40b3ef460207367b7427cad9c14b6365b903215b43dcaf06966f6452494faa2ffdac2faa
6
+ metadata.gz: 8985871cdc3266c7a2ed27a00488e1faab5757c79e558725acad21f15705af21f2de4b208d85cf7c4b1783082961d614241bff473d8605a3e609f8d262e70b01
7
+ data.tar.gz: 97b668f1390c7b01cef9575c93c9d865d40bc20b3ce76d8472bccc5a5d8bc879ef3b61ebb1ab7d2a25518102443b8584a2fc442a7900a529ed53f231c4d9e9f6
data/CHANGELOG.md CHANGED
@@ -1,5 +1,38 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.2.0] - 2026-09-30
4
+
5
+ ### Added
6
+
7
+ - `span` element: inline text. Consecutive spans flow together as one wrapped
8
+ paragraph, each with its own font, weight, size and color.
9
+ - `a(href:)` element: an inline, clickable link (a PDF `/Link` annotation with a
10
+ URI action). Wrapped links are clickable on every line. Unstyled by default.
11
+ - `underline` / `no-underline` classes for `text`, `span` and `a`.
12
+ - Line height: `leading-none`, `leading-tight`, `leading-snug`,
13
+ `leading-normal`, `leading-relaxed`, `leading-loose` (relative to the text
14
+ size) and `leading-3` … `leading-10` (fixed). Inherited from a `div`, like CSS.
15
+ - Border variants: bare `border`, and per-edge `border-t/-r/-b/-l/-x/-y`,
16
+ optionally with a width (`border-b-2`). Partial borders are straight edges.
17
+ - SVG masks: `<mask>` definitions (their `<path>`/`<rect>` children) clip the
18
+ paths or groups that reference them via `mask="url(#id)"`.
19
+ - Tests and samples covering every font weight from `font-thin` to `font-black`.
20
+
21
+ ### Changed
22
+
23
+ - The extra space a line height adds is now split evenly above and below the
24
+ text, as in CSS, instead of all going below. Text at the default size sits 2pt
25
+ lower in its box than in 0.1.0; box sizes are unchanged.
26
+
27
+ ### Fixed
28
+
29
+ - Standard-font widths for WinAnsi's 0x80–0x9F characters (— – “ ” ‘ ’ € … •
30
+ ™ Š Ž Œ and the rest). They used to be measured at the width of "?", so
31
+ wrapping and alignment were off and text after one of them could overlap the
32
+ next span.
33
+ - Standard-font widths of the straight apostrophe `'` and the backtick, which
34
+ used Adobe StandardEncoding's curly-quote widths instead of WinAnsi's glyphs.
35
+
3
36
  ## [0.1.0] - 2026-06-16
4
37
 
5
38
  - Initial release
data/README.md CHANGED
@@ -32,7 +32,14 @@ space-separated string or an array of tokens) and, where it makes sense, a block
32
32
  - `div(class:) { … }` — a block-level box that lays out the elements inside it.
33
33
  - `text(class:) { "…" }` — a run of text; the block returns the string. It wraps
34
34
  to its box width, and embedded newlines start new lines.
35
- - `img(src:, class:)` — an image; `src` is a path to an SVG or JPEG.
35
+ - `span(class:) { "…" }` — inline text. Consecutive spans flow together as one
36
+ wrapped paragraph, each with its own weight, size and color:
37
+ `div { span { "This is " }; span(class: "font-bold") { "cool" } }`.
38
+ - `a(href:, class:) { "…" }` — a clickable link to `href`. It flows inline like
39
+ `span` and is unstyled by default, so add e.g. `class: "underline text-blue-800"`.
40
+ - `img(src:, class:)` — an image; `src` is a path to an SVG or JPEG. SVG support
41
+ covers solid-filled `<path>`s, `<g>` groups and `<mask>` clipping — enough for
42
+ icons and logos, but no gradients, strokes or text.
36
43
 
37
44
  Elements flow like HTML blocks: each fills its container's width and stacks below
38
45
  the previous one, unless a width class makes it share a row (and reflow).
@@ -46,22 +53,52 @@ for the full scales. What Bandoola understands today:
46
53
  | --- | --- |
47
54
  | Padding / margin | `p-4`, `px-2`, `pt-1`, `m-4`, `mx-2`, … |
48
55
  | Width / height | `w-32`, `w-1/2`, `w-full`, `h-16`, `h-1/2`, `h-full` |
49
- | Border width | `border-1` … `border-16` |
56
+ | Border width | `border`, `border-1` … `border-16`; per edge `border-t`, `-r`, `-b`, `-l`, `-x`, `-y` (e.g. `border-b-2`) |
50
57
  | Border radius | `rounded`, `rounded-sm` … `rounded-3xl`, `rounded-full` |
51
58
  | Background | `bg-cyan-100`, `bg-slate-800`, `bg-black`, `bg-white` |
52
59
  | Border color | `border-blue-600` (needs a border width to show) |
53
60
  | Text color | `text-rose-500`, `text-white` |
54
61
  | Font family | `font-sans`, `font-serif`, `font-mono`, `font-<registered>` |
55
- | Font weight | `font-light` … `font-black` |
62
+ | Font weight | `font-thin`, `font-extralight`, `font-light`, `font-normal`, `font-medium`, `font-semibold`, `font-bold`, `font-extrabold`, `font-black` |
56
63
  | Text size | `text-xs` … `text-9xl` |
64
+ | Text decoration | `underline`, `no-underline` |
65
+ | Line height | `leading-none`, `-tight`, `-snug`, `-normal`, `-relaxed`, `-loose`; `leading-3` … `leading-10` |
57
66
 
58
67
  Sizes and spacing are points (1px ≈ 1pt). Notes:
59
68
 
60
69
  - `div` has no fill or border by default — add `bg-*`, `border-*`, or `rounded-*`.
61
70
  - Colors work on the full Tailwind palette plus `black`/`white`; a color with
62
71
  no shade (`bg-cyan`) uses 500.
63
- - A `div`'s `font-*` family and weight are inherited by the text inside it; text
64
- size is set per `text` element.
72
+ - A `div`'s `font-*` family and weight and its `leading-*` are inherited by the
73
+ text inside it; text size is set per `text` element.
74
+ - `leading-tight` etc. are multiples of each element's own text size, while
75
+ `leading-6` is a fixed height (24pt). As in CSS, the extra space is split evenly
76
+ above and below the text.
77
+ - The standard fonts only have regular and bold faces: weights below normal
78
+ render as normal, and in-between or heavier weights use the nearest face plus a
79
+ synthetic (faux-bold) stroke. Register a file per weight (`thin:`, `black:`, …)
80
+ to get real faces.
81
+ - Spans ignore padding, margin and alignment classes.
82
+
83
+ ## Inline text & links
84
+
85
+ `span` and `a` flow inline: consecutive ones form a single paragraph that wraps
86
+ across them, so each piece can have its own styling. A `text`, `div` or `img`
87
+ between them starts a new paragraph.
88
+
89
+ ```ruby
90
+ div(class: "leading-relaxed") do
91
+ span { "Bandoola is " }
92
+ span(class: "font-bold") { "cool " }
93
+ span { "and you can read " }
94
+ a(class: "underline text-blue-800", href: "https://www.example.com") { "the docs" }
95
+ span { "." }
96
+ end
97
+ ```
98
+
99
+ Links become clickable areas in the PDF that open their `href`. Like in
100
+ Tailwind, they have no styling by default, so give them a color and `underline`.
101
+ Line height (`leading-*`) set on the `div` applies to every span in it.
65
102
 
66
103
  ## Fonts
67
104
 
@@ -1,11 +1,15 @@
1
1
  module Bandoola
2
2
  class StandardFont
3
- # Adobe Core font advance widths (1000-unit em) by WinAnsi/Latin-1 code
4
- # point, for the standard PDF faces families map to. Used to measure and
5
- # wrap text. Courier is monospace, handled in code.
3
+ # Adobe Core font advance widths (1000-unit em) by Unicode code point, for
4
+ # the standard PDF faces families map to. Used to measure and wrap text.
5
+ # They cover every WinAnsi character: ASCII, Latin-1 (160-255) and the
6
+ # 0x80-0x9F extras (curly quotes, dashes, €, …), which sit at their Unicode
7
+ # code points (U+2014 for the em dash). Codes 39 and 96 are WinAnsi's
8
+ # quotesingle and grave, not Adobe StandardEncoding's curly quotes.
9
+ # Courier is monospace, handled in code.
6
10
  METRICS = {
7
11
  "Helvetica" => {
8
- 32 => 278, 33 => 278, 34 => 355, 35 => 556, 36 => 556, 37 => 889, 38 => 667, 39 => 222,
12
+ 32 => 278, 33 => 278, 34 => 355, 35 => 556, 36 => 556, 37 => 889, 38 => 667, 39 => 191,
9
13
  40 => 333, 41 => 333, 42 => 389, 43 => 584, 44 => 278, 45 => 333, 46 => 278, 47 => 278,
10
14
  48 => 556, 49 => 556, 50 => 556, 51 => 556, 52 => 556, 53 => 556, 54 => 556, 55 => 556,
11
15
  56 => 556, 57 => 556, 58 => 278, 59 => 278, 60 => 584, 61 => 584, 62 => 584, 63 => 556,
@@ -13,7 +17,7 @@ module Bandoola
13
17
  72 => 722, 73 => 278, 74 => 500, 75 => 667, 76 => 556, 77 => 833, 78 => 722, 79 => 778,
14
18
  80 => 667, 81 => 778, 82 => 722, 83 => 667, 84 => 611, 85 => 722, 86 => 667, 87 => 944,
15
19
  88 => 667, 89 => 667, 90 => 611, 91 => 278, 92 => 278, 93 => 278, 94 => 469, 95 => 556,
16
- 96 => 222, 97 => 556, 98 => 556, 99 => 500, 100 => 556, 101 => 556, 102 => 278, 103 => 556,
20
+ 96 => 333, 97 => 556, 98 => 556, 99 => 500, 100 => 556, 101 => 556, 102 => 278, 103 => 556,
17
21
  104 => 556, 105 => 222, 106 => 222, 107 => 500, 108 => 222, 109 => 833, 110 => 556, 111 => 556,
18
22
  112 => 556, 113 => 556, 114 => 333, 115 => 500, 116 => 278, 117 => 556, 118 => 500, 119 => 722,
19
23
  120 => 500, 121 => 500, 122 => 500, 123 => 334, 124 => 260, 125 => 334, 126 => 584, 160 => 278,
@@ -28,10 +32,14 @@ module Bandoola
28
32
  225 => 556, 226 => 556, 227 => 556, 228 => 556, 229 => 556, 230 => 889, 231 => 500, 232 => 556,
29
33
  233 => 556, 234 => 556, 235 => 556, 236 => 278, 237 => 278, 238 => 278, 239 => 278, 240 => 556,
30
34
  241 => 556, 242 => 556, 243 => 556, 244 => 556, 245 => 556, 246 => 556, 247 => 584, 248 => 611,
31
- 249 => 556, 250 => 556, 251 => 556, 252 => 556, 253 => 500, 254 => 556, 255 => 500
35
+ 249 => 556, 250 => 556, 251 => 556, 252 => 556, 253 => 500, 254 => 556, 255 => 500, 338 => 1000,
36
+ 339 => 944, 352 => 667, 353 => 500, 376 => 667, 381 => 611, 382 => 500, 402 => 556, 710 => 333,
37
+ 732 => 333, 8211 => 556, 8212 => 1000, 8216 => 222, 8217 => 222, 8218 => 222, 8220 => 333, 8221 => 333,
38
+ 8222 => 333, 8224 => 556, 8225 => 556, 8226 => 350, 8230 => 1000, 8240 => 1000, 8249 => 333, 8250 => 333,
39
+ 8364 => 556, 8482 => 1000
32
40
  },
33
41
  "Helvetica-Bold" => {
34
- 32 => 278, 33 => 333, 34 => 474, 35 => 556, 36 => 556, 37 => 889, 38 => 722, 39 => 278,
42
+ 32 => 278, 33 => 333, 34 => 474, 35 => 556, 36 => 556, 37 => 889, 38 => 722, 39 => 238,
35
43
  40 => 333, 41 => 333, 42 => 389, 43 => 584, 44 => 278, 45 => 333, 46 => 278, 47 => 278,
36
44
  48 => 556, 49 => 556, 50 => 556, 51 => 556, 52 => 556, 53 => 556, 54 => 556, 55 => 556,
37
45
  56 => 556, 57 => 556, 58 => 333, 59 => 333, 60 => 584, 61 => 584, 62 => 584, 63 => 611,
@@ -39,7 +47,7 @@ module Bandoola
39
47
  72 => 722, 73 => 278, 74 => 556, 75 => 722, 76 => 611, 77 => 833, 78 => 722, 79 => 778,
40
48
  80 => 667, 81 => 778, 82 => 722, 83 => 667, 84 => 611, 85 => 722, 86 => 667, 87 => 944,
41
49
  88 => 667, 89 => 667, 90 => 611, 91 => 333, 92 => 278, 93 => 333, 94 => 584, 95 => 556,
42
- 96 => 278, 97 => 556, 98 => 611, 99 => 556, 100 => 611, 101 => 556, 102 => 333, 103 => 611,
50
+ 96 => 333, 97 => 556, 98 => 611, 99 => 556, 100 => 611, 101 => 556, 102 => 333, 103 => 611,
43
51
  104 => 611, 105 => 278, 106 => 278, 107 => 556, 108 => 278, 109 => 889, 110 => 611, 111 => 611,
44
52
  112 => 611, 113 => 611, 114 => 389, 115 => 556, 116 => 333, 117 => 611, 118 => 556, 119 => 778,
45
53
  120 => 556, 121 => 556, 122 => 500, 123 => 389, 124 => 280, 125 => 389, 126 => 584, 160 => 278,
@@ -54,10 +62,14 @@ module Bandoola
54
62
  225 => 556, 226 => 556, 227 => 556, 228 => 556, 229 => 556, 230 => 889, 231 => 556, 232 => 556,
55
63
  233 => 556, 234 => 556, 235 => 556, 236 => 278, 237 => 278, 238 => 278, 239 => 278, 240 => 611,
56
64
  241 => 611, 242 => 611, 243 => 611, 244 => 611, 245 => 611, 246 => 611, 247 => 584, 248 => 611,
57
- 249 => 611, 250 => 611, 251 => 611, 252 => 611, 253 => 556, 254 => 611, 255 => 556
65
+ 249 => 611, 250 => 611, 251 => 611, 252 => 611, 253 => 556, 254 => 611, 255 => 556, 338 => 1000,
66
+ 339 => 944, 352 => 667, 353 => 556, 376 => 667, 381 => 611, 382 => 500, 402 => 556, 710 => 333,
67
+ 732 => 333, 8211 => 556, 8212 => 1000, 8216 => 278, 8217 => 278, 8218 => 278, 8220 => 500, 8221 => 500,
68
+ 8222 => 500, 8224 => 556, 8225 => 556, 8226 => 350, 8230 => 1000, 8240 => 1000, 8249 => 333, 8250 => 333,
69
+ 8364 => 556, 8482 => 1000
58
70
  },
59
71
  "Times-Roman" => {
60
- 32 => 250, 33 => 333, 34 => 408, 35 => 500, 36 => 500, 37 => 833, 38 => 778, 39 => 333,
72
+ 32 => 250, 33 => 333, 34 => 408, 35 => 500, 36 => 500, 37 => 833, 38 => 778, 39 => 180,
61
73
  40 => 333, 41 => 333, 42 => 500, 43 => 564, 44 => 250, 45 => 333, 46 => 250, 47 => 278,
62
74
  48 => 500, 49 => 500, 50 => 500, 51 => 500, 52 => 500, 53 => 500, 54 => 500, 55 => 500,
63
75
  56 => 500, 57 => 500, 58 => 278, 59 => 278, 60 => 564, 61 => 564, 62 => 564, 63 => 444,
@@ -80,10 +92,14 @@ module Bandoola
80
92
  225 => 444, 226 => 444, 227 => 444, 228 => 444, 229 => 444, 230 => 667, 231 => 444, 232 => 444,
81
93
  233 => 444, 234 => 444, 235 => 444, 236 => 278, 237 => 278, 238 => 278, 239 => 278, 240 => 500,
82
94
  241 => 500, 242 => 500, 243 => 500, 244 => 500, 245 => 500, 246 => 500, 247 => 564, 248 => 500,
83
- 249 => 500, 250 => 500, 251 => 500, 252 => 500, 253 => 500, 254 => 500, 255 => 500
95
+ 249 => 500, 250 => 500, 251 => 500, 252 => 500, 253 => 500, 254 => 500, 255 => 500, 338 => 889,
96
+ 339 => 722, 352 => 556, 353 => 389, 376 => 722, 381 => 611, 382 => 444, 402 => 500, 710 => 333,
97
+ 732 => 333, 8211 => 500, 8212 => 1000, 8216 => 333, 8217 => 333, 8218 => 333, 8220 => 444, 8221 => 444,
98
+ 8222 => 444, 8224 => 500, 8225 => 500, 8226 => 350, 8230 => 1000, 8240 => 1000, 8249 => 333, 8250 => 333,
99
+ 8364 => 500, 8482 => 980
84
100
  },
85
101
  "Times-Bold" => {
86
- 32 => 250, 33 => 333, 34 => 555, 35 => 500, 36 => 500, 37 => 1000, 38 => 833, 39 => 333,
102
+ 32 => 250, 33 => 333, 34 => 555, 35 => 500, 36 => 500, 37 => 1000, 38 => 833, 39 => 278,
87
103
  40 => 333, 41 => 333, 42 => 500, 43 => 570, 44 => 250, 45 => 333, 46 => 250, 47 => 278,
88
104
  48 => 500, 49 => 500, 50 => 500, 51 => 500, 52 => 500, 53 => 500, 54 => 500, 55 => 500,
89
105
  56 => 500, 57 => 500, 58 => 333, 59 => 333, 60 => 570, 61 => 570, 62 => 570, 63 => 500,
@@ -106,7 +122,11 @@ module Bandoola
106
122
  225 => 500, 226 => 500, 227 => 500, 228 => 500, 229 => 500, 230 => 722, 231 => 444, 232 => 444,
107
123
  233 => 444, 234 => 444, 235 => 444, 236 => 278, 237 => 278, 238 => 278, 239 => 278, 240 => 500,
108
124
  241 => 556, 242 => 500, 243 => 500, 244 => 500, 245 => 500, 246 => 500, 247 => 570, 248 => 500,
109
- 249 => 556, 250 => 556, 251 => 556, 252 => 556, 253 => 500, 254 => 556, 255 => 500
125
+ 249 => 556, 250 => 556, 251 => 556, 252 => 556, 253 => 500, 254 => 556, 255 => 500, 338 => 1000,
126
+ 339 => 722, 352 => 556, 353 => 389, 376 => 722, 381 => 667, 382 => 444, 402 => 500, 710 => 333,
127
+ 732 => 333, 8211 => 500, 8212 => 1000, 8216 => 333, 8217 => 333, 8218 => 333, 8220 => 500, 8221 => 500,
128
+ 8222 => 500, 8224 => 500, 8225 => 500, 8226 => 350, 8230 => 1000, 8240 => 1000, 8249 => 333, 8250 => 333,
129
+ 8364 => 500, 8482 => 1000
110
130
  }
111
131
  }.freeze
112
132
  end
@@ -1,3 +1,3 @@
1
1
  module Bandoola
2
- VERSION = "0.1.0".freeze
2
+ VERSION = "0.2.0".freeze
3
3
  end
@@ -270,6 +270,11 @@ module Bandoola
270
270
  # the font size. Tuned so font-bold (700) reads clearly bold.
271
271
  BOLD_STEP = 0.008
272
272
 
273
+ # The underline's gap below the baseline and its thickness, as fractions
274
+ # of the font size.
275
+ UNDERLINE_OFFSET = 0.1
276
+ UNDERLINE_THICKNESS = 0.06
277
+
273
278
  attr_reader :content
274
279
 
275
280
  def initialize(style, content)
@@ -280,6 +285,7 @@ module Bandoola
280
285
  # Wrap the text to the available width (using +typeset+'s measurer and the
281
286
  # effective font), storing the lines for paint, and return their height.
282
287
  def content_height(_left, _top, avail, _avail_height, typeset)
288
+ @line_height = line_height_for(typeset.leading)
283
289
  @lines = wrap(avail, typeset)
284
290
  @lines.length * line_height
285
291
  end
@@ -296,17 +302,33 @@ module Bandoola
296
302
 
297
303
  buffer << "q\n#{setup}" if setup
298
304
  wrapped.each_with_index do |line, index|
299
- baseline = content_top - font_size - (index * line_height)
305
+ baseline = content_top - half_leading - font_size - (index * line_height)
300
306
  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"
307
+ buffer << show(name, face, line, left + indent, baseline)
308
+ buffer << underline(left + indent, baseline, face.width_of(line, font_size))
304
309
  end
305
310
  buffer << "Q\n" if setup
306
311
  end
307
312
 
308
313
  private
309
314
 
315
+ # A text object drawing +line+ at (left, baseline). The font encodes the
316
+ # text (WinAnsi literal or Identity-H glyph ids).
317
+ def show(name, face, line, left, baseline)
318
+ "BT\n/#{name} #{font_size} Tf\n#{coord(left)} #{coord(baseline)} Td\n#{face.encode(line)} Tj\nET\n"
319
+ end
320
+
321
+ # With the underline class, a thin filled rule under a run +width+ wide,
322
+ # in the text color; otherwise nothing.
323
+ def underline(left, baseline, width)
324
+ return "" unless style.underline
325
+
326
+ thickness = font_size * UNDERLINE_THICKNESS
327
+ bottom = baseline - (font_size * UNDERLINE_OFFSET) - thickness
328
+ "q\n#{color_op(style.text_color || [0.0, 0.0, 0.0])}" \
329
+ "#{coord(left)} #{coord(bottom)} #{coord(width)} #{coord(thickness)} re\nf\nQ\n"
330
+ end
331
+
310
332
  # Horizontal offset for a line under the text-align class: 0 for the
311
333
  # default left, the leftover width for right, half of it for center.
312
334
  def align_indent(line, content_width, face)
@@ -361,10 +383,29 @@ module Bandoola
361
383
  style.text_size || FONT_SIZE
362
384
  end
363
385
 
364
- # Line height scaled from the default proportion (LINE_HEIGHT for the
365
- # default FONT_SIZE).
386
+ # The line height decided during layout (from a leading-* class, this
387
+ # element's or inherited), else the default.
366
388
  def line_height
367
- (font_size * LINE_HEIGHT / FONT_SIZE.to_f).round
389
+ @line_height || line_height_for(nil)
390
+ end
391
+
392
+ # The line height under this element's leading, or +inherited+ when it
393
+ # has none. A ratio applies to this element's own font size (like a
394
+ # unitless CSS line-height); with no leading at all it's the default
395
+ # proportion (LINE_HEIGHT for the default FONT_SIZE).
396
+ def line_height_for(inherited)
397
+ kind, value = style.leading || inherited
398
+ case kind
399
+ when :ratio then font_size * value
400
+ when :fixed then value
401
+ else (font_size * LINE_HEIGHT / FONT_SIZE.to_f).round
402
+ end
403
+ end
404
+
405
+ # As in CSS, the space a line height adds beyond the font size is split
406
+ # evenly above and below the glyphs; this is the share above.
407
+ def half_leading
408
+ (line_height - font_size) / 2.0
368
409
  end
369
410
 
370
411
  # The graphics-state setup for a text run: a fill color (text-<color>)
@@ -38,8 +38,8 @@ module Bandoola
38
38
  end
39
39
 
40
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.
41
+ # objects, the N content streams, then a block per used font, one
42
+ # XObject per used image and one annotation per link. Object numbers are 1-based array positions.
43
43
  def build_objects
44
44
  pages = @page_buffers.size
45
45
  first_page = 3
@@ -60,7 +60,14 @@ module Bandoola
60
60
  number += 1
61
61
  end
62
62
 
63
- page_objects = Array.new(pages) { |i| page_object(first_content + i) }
63
+ annot_refs = @page_links.map do |links|
64
+ links.map do |rect, uri|
65
+ extras << link_annotation(rect, uri)
66
+ (number += 1) - 1
67
+ end
68
+ end
69
+
70
+ page_objects = Array.new(pages) { |i| page_object(first_content + i, annot_refs[i]) }
64
71
  content_objects = @page_buffers.map { |buffer| content_object(buffer) }
65
72
 
66
73
  [catalog, pages_object(first_page, pages, font_refs, image_refs), *page_objects, *content_objects, *extras]
@@ -79,8 +86,18 @@ module Bandoola
79
86
  "/MediaBox [0 0 #{width} #{height}] /Resources << #{resources} >> >>"
80
87
  end
81
88
 
82
- def page_object(content_number)
83
- "<< /Type /Page /Parent 2 0 R /Contents #{content_number} 0 R >>"
89
+ # A page lists its link annotations (by object number) in /Annots.
90
+ def page_object(content_number, annot_numbers)
91
+ annots = annot_numbers.empty? ? "" : " /Annots [#{annot_numbers.map { |n| "#{n} 0 R" }.join(" ")}]"
92
+ "<< /Type /Page /Parent 2 0 R /Contents #{content_number} 0 R#{annots} >>"
93
+ end
94
+
95
+ # A clickable area ([left, bottom, right, top]) that opens +uri+. No
96
+ # border, so it's invisible; the text's own styling marks it as a link.
97
+ def link_annotation(rect, uri)
98
+ corners = rect.map { |value| format("%.2f", value).sub(/\.?0+\z/, "") }.join(" ")
99
+ escaped = uri.gsub(/[\\()]/) { |char| "\\#{char}" }
100
+ "<< /Type /Annot /Subtype /Link /Rect [#{corners}] /Border [0 0 0] /A << /S /URI /URI (#{escaped}) >> >>"
84
101
  end
85
102
 
86
103
  def content_object(buffer)
@@ -0,0 +1,188 @@
1
+ module Bandoola
2
+ class View
3
+ # A piece of inline text. Spans never lay themselves out; the InlineRun
4
+ # holding them breaks their words into lines and asks each span to measure
5
+ # and draw its share. A span's own font-*/weight classes win over the
6
+ # inherited ones, like Text; text-<size> and text-<color> apply per span.
7
+ # Padding, margin and alignment classes are ignored on spans.
8
+ class Span < Text
9
+ def family_for(font) = style.font || font
10
+ def weight_for(weight) = style.font_weight || weight
11
+ def size = font_size
12
+ # This span's line height under the inherited +typeset+ leading.
13
+ def leading(typeset) = line_height_for(typeset.leading)
14
+
15
+ # Width of +string+ in this span's face; 0 without a measurer.
16
+ def width_of(string, typeset)
17
+ return 0.0 unless typeset.measure
18
+
19
+ typeset.measure.call(string, family_for(typeset.font), weight_for(typeset.weight), size)
20
+ end
21
+
22
+ # Draw +string+ with its baseline starting at +origin+ ([x, y]), in the
23
+ # face resolved from the inherited [font, weight].
24
+ def paint_piece(buffer, resources, string, origin, inherited)
25
+ font, weight = inherited
26
+ name, native, face = resources.font(family_for(font), weight_for(weight))
27
+ setup = text_state(weight_for(weight) - native)
28
+
29
+ width = face.width_of(string, size)
30
+
31
+ buffer << "q\n#{setup}" if setup
32
+ buffer << show(name, face, string, *origin)
33
+ buffer << "Q\n" if setup
34
+ buffer << underline(*origin, width)
35
+ annotate(resources, origin, width)
36
+ end
37
+
38
+ private
39
+
40
+ # A hook for spans that attach something to the page where they're drawn.
41
+ def annotate(_resources, _origin, _width); end
42
+ end
43
+
44
+ # The a element: a span that is also a link. Each piece it draws gets a
45
+ # clickable area on its page pointing at +href+ (a URI).
46
+ class A < Span
47
+ # How far the clickable area reaches below the baseline and above it, as
48
+ # fractions of the font size — roughly the glyphs' descent and ascent.
49
+ DESCENT = 0.25
50
+ ASCENT = 0.9
51
+
52
+ attr_reader :href
53
+
54
+ def initialize(style, content, href)
55
+ super(style, content)
56
+ @href = href
57
+ end
58
+
59
+ private
60
+
61
+ def annotate(resources, origin, width)
62
+ left, baseline = origin
63
+ rect = [left, baseline - (size * DESCENT), left + width, baseline + (size * ASCENT)]
64
+ resources.add_link(rect, href)
65
+ end
66
+ end
67
+
68
+ # An anonymous block that holds consecutive spans and lays them out as one
69
+ # paragraph: a greedy word wrap across span boundaries, where each line is
70
+ # as tall as its tallest span and every span on it shares a baseline. Words
71
+ # break only at whitespace, so "cool" + "er" in two spans stays together.
72
+ class InlineRun < Element
73
+ def initialize
74
+ super(Style.none)
75
+ end
76
+
77
+ def content_height(_left, _top, avail, _avail_height, typeset)
78
+ @lines = LineBreaker.new(children, avail, typeset).lines
79
+ @lines.sum(&:height)
80
+ end
81
+
82
+ def paint(buffer, resources, font = "sans", weight = NORMAL_WEIGHT)
83
+ line_top = top
84
+ (@lines || []).each do |line|
85
+ baseline = line_top - line.ascent
86
+ line.pieces.each do |span, text, offset|
87
+ span.paint_piece(buffer, resources, text, [x + offset, baseline], [font, weight])
88
+ end
89
+ line_top -= line.height
90
+ end
91
+ end
92
+
93
+ # The spans have no geometry of their own; only the run moves.
94
+ def offset(delta)
95
+ @top += delta
96
+ end
97
+ end
98
+
99
+ # Breaks a run of spans into lines no wider than +avail+.
100
+ class LineBreaker
101
+ # +pieces+ are [span, text, x offset]; +ascent+ is the distance from the
102
+ # line's top to its shared baseline.
103
+ Line = Struct.new(:pieces, :width, :height, :ascent)
104
+
105
+ TOKEN = /\n|[^\S\n]+|\S+/
106
+
107
+ attr_reader :lines
108
+
109
+ def initialize(spans, avail, typeset)
110
+ @spans = spans
111
+ @avail = avail
112
+ @typeset = typeset
113
+ @lines = []
114
+ @line = new_line
115
+ @space = nil # [span, width] of the gap before the next word
116
+ chunks.each { |parts| consume(parts) }
117
+ finish(@spans.last) if @line.pieces.any? || @lines.empty?
118
+ end
119
+
120
+ private
121
+
122
+ # The spans' content as chunks: a hard break, a space, or a word — the
123
+ # [span, text] parts of adjacent non-space tokens, joined across spans.
124
+ def chunks
125
+ tokens = @spans.flat_map { |span| span.content.scan(TOKEN).map { |text| [span, text] } }
126
+ tokens.chunk_while { |(_, left), (_, right)| word?(left) && word?(right) }
127
+ end
128
+
129
+ def word?(text) = !text.match?(/\s/)
130
+
131
+ def consume(parts)
132
+ span, text = parts.first
133
+ if text == "\n"
134
+ finish(span)
135
+ elsif !word?(text)
136
+ @space = [span, span.width_of(" ", @typeset)] if @line.pieces.any?
137
+ else
138
+ place(parts)
139
+ end
140
+ end
141
+
142
+ # Add a word after the pending space, or start a new line when it would
143
+ # overflow this one.
144
+ def place(parts)
145
+ widths = parts.map { |span, text| span.width_of(text, @typeset) }
146
+ finish(parts.first[0]) if @line.pieces.any? && @line.width + gap + widths.sum > @avail + Element::EPSILON
147
+
148
+ cursor = @line.width + gap
149
+ parts.each_with_index do |(span, text), index|
150
+ if index.zero? && continues?(span)
151
+ @line.pieces.last[1] << " " << text # one run of the same span: draw it in a single Tj
152
+ else
153
+ @line.pieces << [span, +text, cursor]
154
+ end
155
+ cursor += widths[index]
156
+ end
157
+ @line.width = cursor
158
+ @space = nil
159
+ end
160
+
161
+ def gap = @space ? @space[1] : 0.0
162
+
163
+ # Whether +span+'s word joins the previous piece across a space in the same span.
164
+ def continues?(span)
165
+ last = @line.pieces.last
166
+ @space && last && last[0] == span && @space[0] == span
167
+ end
168
+
169
+ def new_line = Line.new([], 0.0, 0.0, 0.0)
170
+
171
+ # Close the current line and start a fresh one. Each span (or +fallback+
172
+ # when the line is empty) needs its font size plus half its extra leading
173
+ # above the baseline and the other half below; the line is tall enough
174
+ # for the most demanding span on each side, as in CSS.
175
+ def finish(fallback)
176
+ spans = @line.pieces.map(&:first)
177
+ spans = [fallback] if spans.empty?
178
+ halves = spans.map { |span| (span.leading(@typeset) - span.size) / 2.0 }
179
+ above = spans.zip(halves).map { |span, half| span.size + half }.max
180
+ @line.ascent = above
181
+ @line.height = above + halves.max
182
+ @lines << @line
183
+ @line = new_line
184
+ @space = nil
185
+ end
186
+ end
187
+ end
188
+ end
@@ -13,6 +13,7 @@ module Bandoola
13
13
  @font_resolver = font_resolver
14
14
  @fonts = {} # face key => [resource_name, font]
15
15
  @images = {} # image => [resource_name, image]
16
+ @links = [] # [rect, uri] on the page being painted
16
17
  end
17
18
 
18
19
  # The used fonts/images as [resource_name, …] pairs, in first-use order.
@@ -33,6 +34,19 @@ module Bandoola
33
34
  [entry[0], native, font]
34
35
  end
35
36
 
37
+ # Record a link area ([left, bottom, right, top]) on the current page.
38
+ def add_link(rect, uri)
39
+ @links << [rect, uri]
40
+ end
41
+
42
+ # The links recorded since the last call, i.e. those of the page just
43
+ # painted, leaving the list empty for the next page.
44
+ def take_links
45
+ links = @links
46
+ @links = []
47
+ links
48
+ end
49
+
36
50
  # Resource name for an image, deduped so the same image painted more than
37
51
  # once (e.g. a logo in a header repeated on every page) embeds once.
38
52
  def add_image(image)
@@ -5,6 +5,8 @@ module Bandoola
5
5
  # font-sans / font-serif / font-mono / font-<name> -> font family
6
6
  # font-light/normal/medium/semibold/bold/... -> font weight
7
7
  # text-xs … text-9xl -> font size (pt)
8
+ # underline / no-underline -> text decoration
9
+ # leading-none/tight/…/loose, leading-3 … leading-10 -> line height
8
10
  #
9
11
  # The family is recorded by name (the View resolves it to an actual font
10
12
  # at render time). The weight is a number; since we don't carry separate
@@ -35,13 +37,41 @@ module Bandoola
35
37
 
36
38
  ALIGNMENTS = %w[left center right].freeze
37
39
 
38
- attr_reader :font, :font_weight, :text_size, :text_align
40
+ # Tailwind's relative line heights, as multiples of the font size.
41
+ # leading-<n> is instead a fixed height on the spacing scale.
42
+ LEADINGS = {
43
+ "none" => 1.0, "tight" => 1.25, "snug" => 1.375, "normal" => 1.5,
44
+ "relaxed" => 1.625, "loose" => 2.0
45
+ }.freeze
46
+
47
+ # +leading+ is [:ratio, multiple] or [:fixed, points], or nil.
48
+ attr_reader :font, :font_weight, :text_size, :text_align, :underline, :leading
39
49
 
40
50
  def init_typography
41
51
  @font = nil
42
52
  @font_weight = nil
43
53
  @text_size = nil
44
54
  @text_align = nil
55
+ @underline = false
56
+ @leading = nil
57
+ end
58
+
59
+ def apply_leading_token(token)
60
+ return unless token.start_with?("leading-")
61
+
62
+ key = token.delete_prefix("leading-")
63
+ if (ratio = LEADINGS[key])
64
+ @leading = [:ratio, ratio]
65
+ elsif key.match?(/\A\d+\z/) && (points = SCALE[key.to_i])
66
+ @leading = [:fixed, points]
67
+ end
68
+ end
69
+
70
+ def apply_decoration_token(token)
71
+ case token
72
+ when "underline" then @underline = true
73
+ when "no-underline" then @underline = false
74
+ end
45
75
  end
46
76
 
47
77
  def apply_font_token(token)
@@ -89,6 +89,8 @@ module Bandoola
89
89
  apply_color_token(token)
90
90
  apply_font_token(token)
91
91
  apply_text_token(token)
92
+ apply_decoration_token(token)
93
+ apply_leading_token(token)
92
94
  end
93
95
 
94
96
  def init_sizing
data/lib/bandoola/view.rb CHANGED
@@ -2,6 +2,7 @@ require_relative "view/header"
2
2
  require_relative "view/file"
3
3
  require_relative "view/style"
4
4
  require_relative "view/element"
5
+ require_relative "view/inline"
5
6
  require_relative "view/resources"
6
7
 
7
8
  module Bandoola
@@ -130,11 +131,11 @@ module Bandoola
130
131
 
131
132
  PAGE_BREAK_EPSILON = 0.01
132
133
 
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
134
+ # The inherited font/weight/leading and a text measurer, threaded through
135
+ # layout so Text can wrap. #inherit applies a div's classes to its children.
136
+ Typeset = Struct.new(:font, :weight, :measure, :leading) do
136
137
  def inherit(style)
137
- Typeset.new(style.font || font, style.font_weight || weight, measure)
138
+ Typeset.new(style.font || font, style.font_weight || weight, measure, style.leading || leading)
138
139
  end
139
140
  end
140
141
  # Used when a tree is laid out without typesetting (no wrapping).
@@ -170,14 +171,18 @@ module Bandoola
170
171
  body.layout(margin, body_top, content_width, body_height, @typeset)
171
172
  pages = paginate(body.children, body_height, body_top)
172
173
 
173
- @page_buffers = pages.each_with_index.map do |children, index|
174
+ # Each page's buffer, plus the link areas painted onto it.
175
+ @page_buffers = []
176
+ @page_links = []
177
+ pages.each_with_index do |children, index|
174
178
  @page_number = index + 1
175
179
  @page_count = pages.size
176
180
  buffer = String.new(encoding: Encoding::ASCII_8BIT)
177
181
  paint_header(buffer)
178
182
  paint_footer(buffer)
179
183
  children.each { |child| child.paint(buffer, @resources) }
180
- buffer
184
+ @page_buffers << buffer
185
+ @page_links << @resources.take_links
181
186
  end
182
187
 
183
188
  @contents = serialize
@@ -342,6 +347,23 @@ module Bandoola
342
347
  nil
343
348
  end
344
349
 
350
+ # The span element: an inline run of text. Consecutive spans flow together
351
+ # as one paragraph, wrapping across span boundaries, so each can carry its
352
+ # own font, weight, size and color:
353
+ #
354
+ # div { span { "This is " } span(class: "font-bold") { "cool" } }
355
+ def span(**attributes)
356
+ content = block_given? ? yield.to_s : ""
357
+ inline(Span.new(Style.parse(attributes[:class]), content))
358
+ end
359
+
360
+ # The a element: inline text like span that links to +href+ (a URI) when
361
+ # clicked. It isn't styled by default; add e.g. "underline text-blue-800".
362
+ def a(href:, **attributes)
363
+ content = block_given? ? yield.to_s : ""
364
+ inline(A.new(Style.parse(attributes[:class]), content, href.to_s))
365
+ end
366
+
345
367
  # The image element. +src+ is a path to an SVG or JPEG. Sizing follows the
346
368
  # width/height classes (see Style); with none it uses the image's own size.
347
369
  def img(src:, **attributes)
@@ -349,6 +371,15 @@ module Bandoola
349
371
  nil
350
372
  end
351
373
 
374
+ # Add an inline element to the current parent's trailing run of inline
375
+ # text, starting a new run if the previous sibling isn't one.
376
+ def inline(element)
377
+ siblings = @stack.last.children
378
+ siblings << InlineRun.new unless siblings.last.is_a?(InlineRun)
379
+ siblings.last.children << element
380
+ nil
381
+ end
382
+
352
383
  # Add +element+ to the current parent, and — if a block is given — make
353
384
  # it the parent while the block runs so nested tags land inside it.
354
385
  def append(element, &block)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: bandoola
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Johan Halse
@@ -37,6 +37,7 @@ files:
37
37
  - lib/bandoola/view/element.rb
38
38
  - lib/bandoola/view/file.rb
39
39
  - lib/bandoola/view/header.rb
40
+ - lib/bandoola/view/inline.rb
40
41
  - lib/bandoola/view/resources.rb
41
42
  - lib/bandoola/view/style.rb
42
43
  - lib/bandoola/view/style/borders.rb
@@ -67,7 +68,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
67
68
  - !ruby/object:Gem::Version
68
69
  version: '0'
69
70
  requirements: []
70
- rubygems_version: 4.0.15
71
+ rubygems_version: 4.0.21
71
72
  specification_version: 4
72
73
  summary: A small, dependency-free PDF generator for Ruby with Tailwind-style classes.
73
74
  test_files: []