stationery 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.
Files changed (59) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +12 -0
  3. data/LICENSE.txt +28 -0
  4. data/README.md +145 -0
  5. data/lib/stationery/builder.rb +61 -0
  6. data/lib/stationery/canvas/path.rb +82 -0
  7. data/lib/stationery/canvas/text.rb +55 -0
  8. data/lib/stationery/canvas.rb +121 -0
  9. data/lib/stationery/color.rb +62 -0
  10. data/lib/stationery/component.rb +54 -0
  11. data/lib/stationery/document.rb +95 -0
  12. data/lib/stationery/elements.rb +90 -0
  13. data/lib/stationery/errors.rb +11 -0
  14. data/lib/stationery/fonts/cmap.rb +75 -0
  15. data/lib/stationery/fonts/family.rb +51 -0
  16. data/lib/stationery/fonts/font.rb +149 -0
  17. data/lib/stationery/fonts/font_book.rb +36 -0
  18. data/lib/stationery/fonts/name_table.rb +39 -0
  19. data/lib/stationery/fonts/registry.rb +38 -0
  20. data/lib/stationery/fonts/subset.rb +139 -0
  21. data/lib/stationery/fonts/true_type.rb +174 -0
  22. data/lib/stationery/geometry.rb +56 -0
  23. data/lib/stationery/images/cache.rb +32 -0
  24. data/lib/stationery/images/images.rb +45 -0
  25. data/lib/stationery/images/jpeg.rb +53 -0
  26. data/lib/stationery/images/png.rb +119 -0
  27. data/lib/stationery/images/scanlines.rb +56 -0
  28. data/lib/stationery/layout/box.rb +134 -0
  29. data/lib/stationery/layout/flow.rb +121 -0
  30. data/lib/stationery/layout/image.rb +53 -0
  31. data/lib/stationery/layout/leaves.rb +58 -0
  32. data/lib/stationery/layout/node.rb +46 -0
  33. data/lib/stationery/layout/paginator.rb +41 -0
  34. data/lib/stationery/layout/positioned.rb +24 -0
  35. data/lib/stationery/layout/row.rb +71 -0
  36. data/lib/stationery/layout/table/cell.rb +93 -0
  37. data/lib/stationery/layout/table/selection.rb +52 -0
  38. data/lib/stationery/layout/table/widths.rb +53 -0
  39. data/lib/stationery/layout/table.rb +118 -0
  40. data/lib/stationery/layout/text.rb +62 -0
  41. data/lib/stationery/page.rb +47 -0
  42. data/lib/stationery/page_templates.rb +36 -0
  43. data/lib/stationery/pdf/assembler.rb +53 -0
  44. data/lib/stationery/pdf/serializer.rb +56 -0
  45. data/lib/stationery/pdf/stream.rb +27 -0
  46. data/lib/stationery/pdf/types.rb +15 -0
  47. data/lib/stationery/pdf/writer.rb +59 -0
  48. data/lib/stationery/rails.rb +20 -0
  49. data/lib/stationery/resources.rb +34 -0
  50. data/lib/stationery/text/entities.rb +23 -0
  51. data/lib/stationery/text/line.rb +26 -0
  52. data/lib/stationery/text/markup.rb +103 -0
  53. data/lib/stationery/text/paragraph.rb +98 -0
  54. data/lib/stationery/text/runs_builder.rb +52 -0
  55. data/lib/stationery/text/style.rb +46 -0
  56. data/lib/stationery/text/wrapper.rb +131 -0
  57. data/lib/stationery/version.rb +5 -0
  58. data/lib/stationery.rb +58 -0
  59. metadata +108 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 7df6bdee0612f0952d1fb587f64720fb78d976e77b7327292dd261b5afbe14d7
4
+ data.tar.gz: 9c9c800f4c4a5aea772df63676eeb5936a3282828eec401494256d1691cae30b
5
+ SHA512:
6
+ metadata.gz: ea8fa33394b8f68a0316d0bba056e97d48bee53db8ffd4b8addb9aea023b0d492f42c34884408d0771c136993bcc1249eb2064bfbe0f11cecb00aa52bc1561e0
7
+ data.tar.gz: dc14973b6ca838dd0be5651dc0cb51b404e8c0e3fe5958ad1edcac3dad9026ad6a66a13c21d15bb01483412a651a9aafad655c047d9c73868405d123608d1daa
data/CHANGELOG.md ADDED
@@ -0,0 +1,12 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 (unreleased)
4
+
5
+ - PDF writer with Flate streams, per-page resources and link annotations.
6
+ - TrueType fonts: cmap 4/12, glyph subsetting, Type0/CIDFontType2 embedding with ToUnicode, synthetic bold and oblique.
7
+ - JPEG and PNG images, including alpha soft masks and palette transparency.
8
+ - Canvas: rectangles, rounded rectangles, circles, lines, Bézier paths, clipping, opacity, text runs, images, links.
9
+ - Text: inline markup, Ruby run builder, wrapping, alignment, leading, truncate and shrink-to-fit.
10
+ - Layout: flow, box, row/column, table, image, rule, spacer, page break, group, canvas escape hatch; pagination with header rows, keep-together and keep-with-next.
11
+ - Components with Phlex's lifecycle; documents with page settings, font families, default text, metadata and page templates.
12
+ - `Stationery::Rails#send_pdf`.
data/LICENSE.txt ADDED
@@ -0,0 +1,28 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Mikael Henriksson
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
22
+
23
+ ---
24
+
25
+ Portions of lib/stationery/pdf, lib/stationery/fonts and lib/stationery/images
26
+ are derived from the PDF engine of the receipts gem
27
+ (https://github.com/excid3/receipts), MIT License,
28
+ Copyright (c) 2020 Chris Oliver.
data/README.md ADDED
@@ -0,0 +1,145 @@
1
+ # Stationery
2
+
3
+ Pure-Ruby PDF documents built from Phlex-style components. Describe the page
4
+ with rows, columns, boxes, tables, text and images; a box-layout engine
5
+ measures, places and paginates them; a small PDF writer embeds subsetted
6
+ TrueType fonts and JPEG/PNG images.
7
+
8
+ - **No runtime dependencies.** Standard library only.
9
+ - **No native extensions and no other processes.** No Prawn, no headless
10
+ Chrome, nothing to leak or kill.
11
+ - **Declarative layout.** No cursor arithmetic: padding, backgrounds, borders,
12
+ radii, column widths and page breaks are the engine's job.
13
+
14
+ ```ruby
15
+ gem "stationery"
16
+ ```
17
+
18
+ Ruby 3.4 or newer.
19
+
20
+ ## A document
21
+
22
+ ```ruby
23
+ class InvoicePdf < Stationery::Document
24
+ page size: :a4, margin: [40, 44, 56, 44]
25
+ font_family "Inter", regular: "fonts/Inter-Regular.ttf", bold: "fonts/Inter-Bold.ttf"
26
+ default_text font: "Inter", size: 9, color: "#1F2937"
27
+ metadata title: "Invoice"
28
+
29
+ page_template do |page|
30
+ box(at: [44, page.height - 34], width: page.content_box.width) do
31
+ text "Page #{page.number} of #{page.count}", size: 7, align: :right
32
+ end
33
+ end
34
+
35
+ def initialize(invoice)
36
+ super()
37
+ @invoice = invoice
38
+ end
39
+
40
+ def view_template
41
+ row(align: :middle) do
42
+ column(width: 0.5) { text "Invoice #{@invoice.number}", size: 22, weight: :bold }
43
+ column(width: 0.5, align: :right) { image "logo.png", height: 34, align: :right }
44
+ end
45
+ rule height: 3, color: "#4F46E5"
46
+ spacer 18
47
+ box(background: "#F3F4F6", radius: 6, padding: 12, width: 190) do
48
+ text "AMOUNT DUE", size: 8, weight: :bold, letter_spacing: 0.5
49
+ text @invoice.total, size: 19, weight: :bold, color: "#4F46E5"
50
+ end
51
+ table(@invoice.rows, width: :full, widths: [nil, 45, 80, 85], header: true, cell: { borders: [] }) do |t|
52
+ t.row(0).set(background: "#4F46E5", color: "#FFFFFF", weight: :bold)
53
+ t.columns(1..).align = :right
54
+ t.zebra(from: 2, color: "#F9FAFB")
55
+ end
56
+ end
57
+ end
58
+
59
+ InvoicePdf.new(invoice).to_pdf # => "%PDF-1.7…" (binary String)
60
+ InvoicePdf.new(invoice).to_pdf("a.pdf") # also writes a path or an IO
61
+ ```
62
+
63
+ `examples/invoice.rb` is a complete, runnable invoice: `ruby -Ilib examples/invoice.rb`.
64
+
65
+ ## Elements
66
+
67
+ | Element | What it does |
68
+ |---|---|
69
+ | `text(string, **style)` | A paragraph. Plain strings are literal. |
70
+ | `text(string, markup: true)` | Reads `<b> <i> <u> <strikethrough> <sub> <sup> <br> <color rgb=""> <font size="" name=""> <link href="">`. |
71
+ | `text { b "Total"; plain " due" }` | Styled runs in Ruby. Take a block argument (`{ \|t\| t.b @x }`) to keep your own `self`. |
72
+ | `box(padding:, background:, border:, radius:, width:, height:, overflow:, at:) { }` | A container. Moves to the next page whole. `overflow: :truncate` or `:shrink_to_fit` for fixed heights. `at: [x, y]` pins it to a page position. |
73
+ | `row(gap:, align:) { column(width:) { } }` | Columns side by side. `width:` is points, a fraction (`0.5`), `:auto` or `nil` (equal share). |
74
+ | `table(rows, widths:, width:, header:, cell:) { \|t\| }` | Tables. Style with `t.row(0)`, `t.rows(-1)`, `t.column(1)`, `t.columns(1..)`, chained, plus `t.zebra`. Header rows repeat after a page break. |
75
+ | `image(path_or_io, width:, height:, fit:, align:)` | JPEG or PNG, aspect preserved. |
76
+ | `rule(height:, color:)`, `spacer(height)`, `page_break` | Dividers and spacing. |
77
+ | `group(keep_together: true) { }` | Keep a block on one page. |
78
+ | `text_style(**style) { }` | Default text style for a block. |
79
+ | `canvas(height:) { \|canvas, rect\| }` | Draw directly: rectangles, rounded rectangles, circles, lines, Bézier paths, clipping, images, links. |
80
+
81
+ Text style options: `font`, `size`, `weight` (`:regular`, `:bold`), `style` (`:italic`), `color`,
82
+ `letter_spacing`, `underline`, `strikethrough`, `link`, `opacity`, `align`, `leading`.
83
+ Colours are `"#RRGGBB"`, `"RRGGBB"`, `"#RGB"`, `[r, g, b]` (0-255) or `[c, m, y, k]` (0-100).
84
+
85
+ ## Components
86
+
87
+ Components have Phlex's lifecycle (`around_template`, `before_template`,
88
+ `view_template`, `after_template`) and `render`:
89
+
90
+ ```ruby
91
+ class Callout < Stationery::Component
92
+ def initialize(color:) = (super(); @color = color)
93
+ def view_template(&) = box(background: @color, padding: 12, radius: 6, &)
94
+ end
95
+
96
+ render Callout.new(color: "#F3F4F6") { text "Amount due" }
97
+ ```
98
+
99
+ `render` takes a component, a component class, a string, a proc or a list of those.
100
+
101
+ ## Pages
102
+
103
+ - `page size: :a4 | :a3 | :a5 | :letter | :legal | :tabloid | [w, h], margin:, layout: :landscape`
104
+ - `page_template { |page| … }` runs on every page after pagination with `page.number`, `page.count`,
105
+ `page.width`, `page.height`, `page.margin` and `page.content_box`. `page_template(layer: :background)`
106
+ paints under the content (full-bleed backgrounds).
107
+ - Content taller than a page is placed anyway; `document.warnings` lists every overflow.
108
+
109
+ ## Rails
110
+
111
+ ```ruby
112
+ require "stationery/rails" # adds send_pdf to controllers
113
+
114
+ def show = send_pdf(InvoicePdf.new(@invoice), filename: "invoice.pdf")
115
+ ```
116
+
117
+ ## Fonts and images
118
+
119
+ Fonts are TrueType (`.ttf`) files. Only the glyphs a document uses are
120
+ embedded, with a ToUnicode map so text copies and searches correctly. A style
121
+ without its own file (bold, italic) is synthesised. There is no built-in
122
+ font: declare at least one `font_family`.
123
+
124
+ Images are JPEG (grey, RGB, CMYK) and PNG (every colour type, alpha as a soft
125
+ mask). Parsed fonts and images are cached per process.
126
+
127
+ ## Why not Prawn, Chrome or Typst?
128
+
129
+ - **Prawn** is an imperative cursor API: every document does its own layout
130
+ arithmetic. Stationery is a layout engine with a component DSL.
131
+ - **Headless Chrome** (Grover, ferrum_pdf) renders HTML beautifully but runs
132
+ a browser per worker; stray processes and memory are the price.
133
+ - **Typst** is excellent but a native extension and a second template language.
134
+
135
+ ## Limitations
136
+
137
+ No kerning or ligatures, no OpenType/CFF, TrueType collections, variable
138
+ fonts or WOFF; no full justification; no SVG (paths are available on the
139
+ canvas); no encryption, outlines, forms or tagged PDF; boxes and rows do not
140
+ split across pages.
141
+
142
+ ## License
143
+
144
+ MIT. The PDF writer, TrueType subsetter and image decoders are derived from
145
+ the [receipts](https://github.com/excid3/receipts) gem (MIT).
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stationery
4
+ # Collects the nodes a component tree describes. Holds the container stack
5
+ # (where the next node goes) and the text defaults in effect.
6
+ class Builder
7
+ STYLE_KEYS = %i[size color weight style letter_spacing underline strikethrough link opacity].freeze
8
+
9
+ # Collects a row's columns; anything that is not already a column box is
10
+ # wrapped in one.
11
+ class Columns
12
+ attr_reader :nodes
13
+
14
+ def initialize = @nodes = []
15
+
16
+ def <<(node)
17
+ @nodes << (node.is_a?(Layout::Box) ? node : Layout::Box.new(Layout::Flow.new([node])))
18
+ self
19
+ end
20
+ end
21
+
22
+ attr_reader :root, :book
23
+
24
+ def initialize(book:, text: {})
25
+ @book = book
26
+ @root = Layout::Flow.new
27
+ @containers = [@root]
28
+ @text = [text]
29
+ end
30
+
31
+ def add(node)
32
+ @containers.last << node
33
+ node
34
+ end
35
+
36
+ def within(container)
37
+ @containers << container
38
+ yield
39
+ container
40
+ ensure
41
+ @containers.pop
42
+ end
43
+
44
+ def with_text(**overrides)
45
+ @text << text_defaults.merge(overrides.compact)
46
+ yield
47
+ ensure
48
+ @text.pop
49
+ end
50
+
51
+ def text_defaults = @text.last
52
+
53
+ def style(options = {})
54
+ options = text_defaults.merge(options)
55
+ family = options[:font] || @book.families.keys.first || "default"
56
+ Text::Style.new(family: family.to_s, **options.slice(*STYLE_KEYS))
57
+ end
58
+
59
+ def context(style = self.style) = Layout::Context.new(book: @book, style:)
60
+ end
61
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stationery
4
+ class Canvas
5
+ # Collects path segments in top-left coordinates and writes them in PDF
6
+ # space. An optional affine transform (a, b, c, d, e, f), also in top-left
7
+ # space, is applied to every point — how scaled icons are drawn.
8
+ class Path
9
+ KAPPA = 0.5522847498
10
+
11
+ def initialize(canvas, transform: nil)
12
+ @canvas = canvas
13
+ @transform = transform
14
+ @ops = []
15
+ end
16
+
17
+ def move_to(x, y) = add("#{point(x, y)} m")
18
+ def line_to(x, y) = add("#{point(x, y)} l")
19
+ def curve_to(x1, y1, x2, y2, x, y) = add("#{point(x1, y1)} #{point(x2, y2)} #{point(x, y)} c")
20
+ def close = add("h")
21
+
22
+ def rect(x, y, w, h)
23
+ unless @transform
24
+ return add([x, @canvas.page.height - y - h, w, h].map { |v| @canvas.num(v) }.push("re").join(" "))
25
+ end
26
+
27
+ move_to(x, y)
28
+ line_to(x + w, y)
29
+ line_to(x + w, y + h)
30
+ line_to(x, y + h)
31
+ close
32
+ end
33
+
34
+ def rounded_rect(x, y, w, h, r)
35
+ r = [r, w / 2.0, h / 2.0].min
36
+ return rect(x, y, w, h) if r <= 0
37
+
38
+ k = r * KAPPA
39
+ move_to(x + r, y)
40
+ line_to(x + w - r, y)
41
+ curve_to(x + w - r + k, y, x + w, y + r - k, x + w, y + r)
42
+ line_to(x + w, y + h - r)
43
+ curve_to(x + w, y + h - r + k, x + w - r + k, y + h, x + w - r, y + h)
44
+ line_to(x + r, y + h)
45
+ curve_to(x + r - k, y + h, x, y + h - r + k, x, y + h - r)
46
+ line_to(x, y + r)
47
+ curve_to(x, y + r - k, x + r - k, y, x + r, y)
48
+ close
49
+ end
50
+
51
+ def ellipse(cx, cy, rx, ry)
52
+ kx = rx * KAPPA
53
+ ky = ry * KAPPA
54
+ move_to(cx + rx, cy)
55
+ curve_to(cx + rx, cy + ky, cx + kx, cy + ry, cx, cy + ry)
56
+ curve_to(cx - kx, cy + ry, cx - rx, cy + ky, cx - rx, cy)
57
+ curve_to(cx - rx, cy - ky, cx - kx, cy - ry, cx, cy - ry)
58
+ curve_to(cx + kx, cy - ry, cx + rx, cy - ky, cx + rx, cy)
59
+ close
60
+ end
61
+
62
+ def to_s = @ops.join("\n")
63
+
64
+ private
65
+
66
+ def add(op)
67
+ @ops << op
68
+ self
69
+ end
70
+
71
+ def point(x, y)
72
+ x, y = apply(x, y) if @transform
73
+ "#{@canvas.num(x)} #{@canvas.num(@canvas.page.height - y)}"
74
+ end
75
+
76
+ def apply(x, y)
77
+ a, b, c, d, e, f = @transform
78
+ [(a * x) + (c * y) + e, (b * x) + (d * y) + f]
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stationery
4
+ class Canvas
5
+ # Single-run text drawing: one font, size and colour at a baseline.
6
+ module Text
7
+ BOLD_STROKE = 0.03
8
+
9
+ # Draws `string` with its baseline at (x, y) in top-left coordinates and
10
+ # returns its advance width.
11
+ def text(string, x:, y:, font:, size:, color: "#000000", letter_spacing: 0, rise: 0, opacity: nil,
12
+ synthetic_bold: false, synthetic_oblique: false, underline: false, strikethrough: false)
13
+ return 0 if string.empty?
14
+
15
+ color = Color.parse(color)
16
+ width = font.width_of(string, size, letter_spacing:)
17
+ graphics(opacity:) do |ops|
18
+ ops << color.fill
19
+ ops.push(color.stroke, "#{num(size * BOLD_STROKE)} w") if synthetic_bold
20
+ ops << text_object(string, x, y, font, size,
21
+ letter_spacing:, rise:, bold: synthetic_bold, oblique: synthetic_oblique)
22
+ end
23
+ decorate(x, y, width, font, size, color:, underline:, strikethrough:)
24
+ width
25
+ end
26
+
27
+ private
28
+
29
+ def text_object(string, x, y, font, size, letter_spacing:, rise:, bold:, oblique:)
30
+ skew = oblique ? Fonts::Font::OBLIQUE_SKEW : 0
31
+ ops = ["BT", "/#{@page.use(:Font, @resources.font(font))} #{num(size)} Tf"]
32
+ ops << "#{num(letter_spacing)} Tc" unless letter_spacing.zero?
33
+ ops << "#{num(rise)} Ts" unless rise.zero?
34
+ ops << "2 Tr" if bold
35
+ # Td from the identity text matrix is an absolute position (and what
36
+ # text-extraction tools read positions from); Tm only when shearing.
37
+ ops << if skew.zero?
38
+ "#{num(x)} #{num(@page.height - y)} Td"
39
+ else
40
+ "1 0 #{num(skew)} 1 #{num(x)} #{num(@page.height - y)} Tm"
41
+ end
42
+ ops << "<#{font.encode(string).unpack1("H*").upcase}> Tj"
43
+ ops << "ET"
44
+ ops.join("\n")
45
+ end
46
+
47
+ def decorate(x, y, width, font, size, color:, underline:, strikethrough:)
48
+ fill_rect(x, y - font.underline_position(size), width, font.underline_thickness(size), color:) if underline
49
+ return unless strikethrough
50
+
51
+ fill_rect(x, y - font.strikeout_position(size), width, font.strikeout_size(size), color:)
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stationery
4
+ # Draws on one page in top-left coordinates (points, y grows downwards).
5
+ # Every operation is wrapped in its own graphics state so colours and line
6
+ # settings never leak into the next one.
7
+ class Canvas
8
+ include Text
9
+
10
+ CAPS = { butt: 0, round: 1, square: 2 }.freeze
11
+ JOINS = { miter: 0, round: 1, bevel: 2 }.freeze
12
+
13
+ attr_reader :page
14
+
15
+ def initialize(page, resources)
16
+ @page = page
17
+ @resources = resources
18
+ end
19
+
20
+ def save
21
+ emit("q")
22
+ yield self
23
+ emit("Q")
24
+ end
25
+
26
+ def clip(x, y, w, h, radius: 0)
27
+ save do
28
+ emit(Path.new(self).rounded_rect(x, y, w, h, radius).to_s, "W n")
29
+ yield self
30
+ end
31
+ end
32
+
33
+ def fill_rect(x, y, w, h, color:, opacity: nil)
34
+ shape(fill: color, opacity:) { |p| p.rect(x, y, w, h) }
35
+ end
36
+
37
+ def rounded_rect(x, y, w, h, radius:, fill: nil, stroke: nil, line_width: 1, opacity: nil)
38
+ shape(fill:, stroke:, line_width:, opacity:) { |p| p.rounded_rect(x, y, w, h, radius) }
39
+ end
40
+
41
+ def circle(cx, cy, r, fill: nil, stroke: nil, line_width: 1, opacity: nil)
42
+ shape(fill:, stroke:, line_width:, opacity:) { |p| p.ellipse(cx, cy, r, r) }
43
+ end
44
+
45
+ def line(x1, y1, x2, y2, color:, width: 1, dash: nil, cap: :butt, opacity: nil)
46
+ shape(stroke: color, line_width: width, dash:, cap:, opacity:) do |p|
47
+ p.move_to(x1, y1)
48
+ p.line_to(x2, y2)
49
+ end
50
+ end
51
+
52
+ def path(fill: nil, stroke: nil, line_width: 1, cap: :butt, join: :miter, dash: nil, even_odd: false,
53
+ opacity: nil, transform: nil, &)
54
+ shape(fill:, stroke:, line_width:, cap:, join:, dash:, even_odd:, opacity:, transform:, &)
55
+ end
56
+
57
+ def image(image, x:, y:, width:, height:, opacity: nil)
58
+ name = @page.use(:XObject, @resources.image(image))
59
+ graphics(opacity:) do |ops|
60
+ ops << "#{num(width)} 0 0 #{num(height)} #{num(x)} #{num(@page.height - y - height)} cm"
61
+ ops << "/#{name} Do"
62
+ end
63
+ end
64
+
65
+ # A clickable area opening `url`. Annotation rectangles live in absolute,
66
+ # untransformed page space, so they ignore clips and path transforms.
67
+ def link(x, y, w, h, url)
68
+ @page.annotations << { rect: [x, @page.height - y - h, x + w, @page.height - y].map { |v| num_value(v) },
69
+ url: url.to_s }
70
+ end
71
+
72
+ def num(value) = PDF::Serializer.number(num_value(value))
73
+
74
+ private
75
+
76
+ def num_value(value)
77
+ value.is_a?(Float) && value == value.round ? value.round : value
78
+ end
79
+
80
+ def shape(fill: nil, stroke: nil, line_width: 1, cap: nil, join: nil, dash: nil, even_odd: false,
81
+ opacity: nil, transform: nil)
82
+ path = Path.new(self, transform:)
83
+ yield path
84
+ graphics(opacity:) do |ops|
85
+ ops << Color.parse(fill).fill if fill
86
+ ops << Color.parse(stroke).stroke if stroke
87
+ ops.concat(line_style(stroke, line_width, cap, join, dash))
88
+ ops << path.to_s << paint_operator(fill, stroke, even_odd)
89
+ end
90
+ end
91
+
92
+ def line_style(stroke, width, cap, join, dash)
93
+ return [] unless stroke
94
+
95
+ ops = ["#{num(width)} w"]
96
+ ops << "[#{dash.map { |v| num(v) }.join(" ")}] 0 d" if dash
97
+ ops << "#{CAPS.fetch(cap)} J" if cap && cap != :butt
98
+ ops << "#{JOINS.fetch(join)} j" if join && join != :miter
99
+ ops
100
+ end
101
+
102
+ def paint_operator(fill, stroke, even_odd)
103
+ if fill && stroke then even_odd ? "B*" : "B"
104
+ elsif fill then even_odd ? "f*" : "f"
105
+ elsif stroke then "S"
106
+ else "n"
107
+ end
108
+ end
109
+
110
+ def graphics(opacity: nil)
111
+ ops = []
112
+ ops << "/#{@page.use(:ExtGState, @resources.opacity(opacity))} gs" if opacity && opacity < 1
113
+ yield ops
114
+ emit("q", *ops, "Q")
115
+ end
116
+
117
+ def emit(*ops)
118
+ @page.content << ops.join("\n") << "\n"
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stationery
4
+ # A device colour. Accepts "#RRGGBB", "RRGGBB", "#RGB", [r, g, b] in 0-255 or
5
+ # [c, m, y, k] in 0-100. Input values are never mutated.
6
+ class Color
7
+ HEX = /\A#?(\h{3}|\h{6})\z/
8
+
9
+ attr_reader :space, :components
10
+
11
+ def self.parse(value)
12
+ case value
13
+ when Color then value
14
+ when String then from_hex(value)
15
+ when Array then from_array(value)
16
+ else invalid(value)
17
+ end
18
+ end
19
+
20
+ def self.from_hex(value)
21
+ hex = value[HEX, 1] || invalid(value)
22
+ hex = hex.chars.map { |c| c * 2 }.join if hex.size == 3
23
+ new(:rgb, hex.scan(/../).map { |pair| pair.to_i(16) / 255.0 })
24
+ end
25
+
26
+ def self.from_array(value)
27
+ case value.size
28
+ when 3 then new(:rgb, value.map { |v| v.to_f / 255 })
29
+ when 4 then new(:cmyk, value.map { |v| v.to_f / 100 })
30
+ else invalid(value)
31
+ end
32
+ end
33
+
34
+ def self.invalid(value)
35
+ raise ArgumentError, "not a colour: #{value.inspect} (use \"#RRGGBB\", [r, g, b] or [c, m, y, k])"
36
+ end
37
+
38
+ def initialize(space, components)
39
+ @space = space
40
+ @components = components.freeze
41
+ freeze
42
+ end
43
+
44
+ def fill = operator(false)
45
+ def stroke = operator(true)
46
+
47
+ def ==(other)
48
+ other.is_a?(Color) && other.space == space && other.components == components
49
+ end
50
+ alias eql? ==
51
+
52
+ def hash = [space, components].hash
53
+
54
+ private
55
+
56
+ def operator(stroke)
57
+ numbers = components.map { |v| PDF::Serializer.number(v.round(4)) }.join(" ")
58
+ op = space == :rgb ? "rg" : "k"
59
+ "#{numbers} #{stroke ? op.upcase : op}"
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stationery
4
+ # A reusable piece of a document, with Phlex's lifecycle:
5
+ #
6
+ # class Callout < Stationery::Component
7
+ # def initialize(color:) = @color = color
8
+ # def view_template(&) = box(background: @color, padding: 12, radius: 6, &)
9
+ # end
10
+ #
11
+ # render Callout.new(color: "#F3F4F6") { text "Amount due" }
12
+ class Component
13
+ include Elements
14
+
15
+ def call(builder, &)
16
+ @_builder = builder
17
+ around_template do
18
+ before_template
19
+ view_template(&)
20
+ after_template
21
+ end
22
+ nil
23
+ end
24
+
25
+ def around_template = yield
26
+ def before_template = nil
27
+ def after_template = nil
28
+ def view_template(&) = yield_content(&)
29
+
30
+ def render(renderable, &)
31
+ case renderable
32
+ when Component then renderable.call(@_builder, &)
33
+ when Class then renderable < Component ? render(renderable.new, &) : cannot_render(renderable)
34
+ when String then text(renderable)
35
+ when Proc, Method then yield_content(&renderable)
36
+ when Enumerable then renderable.each { |item| render(item, &) }
37
+ else cannot_render(renderable)
38
+ end
39
+ nil
40
+ end
41
+
42
+ def yield_content(&block)
43
+ return unless block
44
+
45
+ block.arity.zero? ? yield : yield(self)
46
+ end
47
+
48
+ private
49
+
50
+ def cannot_render(renderable)
51
+ raise ArgumentError, "You can't render #{renderable.inspect}; render a component, class, string, proc or list."
52
+ end
53
+ end
54
+ end