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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +12 -0
- data/LICENSE.txt +28 -0
- data/README.md +145 -0
- data/lib/stationery/builder.rb +61 -0
- data/lib/stationery/canvas/path.rb +82 -0
- data/lib/stationery/canvas/text.rb +55 -0
- data/lib/stationery/canvas.rb +121 -0
- data/lib/stationery/color.rb +62 -0
- data/lib/stationery/component.rb +54 -0
- data/lib/stationery/document.rb +95 -0
- data/lib/stationery/elements.rb +90 -0
- data/lib/stationery/errors.rb +11 -0
- data/lib/stationery/fonts/cmap.rb +75 -0
- data/lib/stationery/fonts/family.rb +51 -0
- data/lib/stationery/fonts/font.rb +149 -0
- data/lib/stationery/fonts/font_book.rb +36 -0
- data/lib/stationery/fonts/name_table.rb +39 -0
- data/lib/stationery/fonts/registry.rb +38 -0
- data/lib/stationery/fonts/subset.rb +139 -0
- data/lib/stationery/fonts/true_type.rb +174 -0
- data/lib/stationery/geometry.rb +56 -0
- data/lib/stationery/images/cache.rb +32 -0
- data/lib/stationery/images/images.rb +45 -0
- data/lib/stationery/images/jpeg.rb +53 -0
- data/lib/stationery/images/png.rb +119 -0
- data/lib/stationery/images/scanlines.rb +56 -0
- data/lib/stationery/layout/box.rb +134 -0
- data/lib/stationery/layout/flow.rb +121 -0
- data/lib/stationery/layout/image.rb +53 -0
- data/lib/stationery/layout/leaves.rb +58 -0
- data/lib/stationery/layout/node.rb +46 -0
- data/lib/stationery/layout/paginator.rb +41 -0
- data/lib/stationery/layout/positioned.rb +24 -0
- data/lib/stationery/layout/row.rb +71 -0
- data/lib/stationery/layout/table/cell.rb +93 -0
- data/lib/stationery/layout/table/selection.rb +52 -0
- data/lib/stationery/layout/table/widths.rb +53 -0
- data/lib/stationery/layout/table.rb +118 -0
- data/lib/stationery/layout/text.rb +62 -0
- data/lib/stationery/page.rb +47 -0
- data/lib/stationery/page_templates.rb +36 -0
- data/lib/stationery/pdf/assembler.rb +53 -0
- data/lib/stationery/pdf/serializer.rb +56 -0
- data/lib/stationery/pdf/stream.rb +27 -0
- data/lib/stationery/pdf/types.rb +15 -0
- data/lib/stationery/pdf/writer.rb +59 -0
- data/lib/stationery/rails.rb +20 -0
- data/lib/stationery/resources.rb +34 -0
- data/lib/stationery/text/entities.rb +23 -0
- data/lib/stationery/text/line.rb +26 -0
- data/lib/stationery/text/markup.rb +103 -0
- data/lib/stationery/text/paragraph.rb +98 -0
- data/lib/stationery/text/runs_builder.rb +52 -0
- data/lib/stationery/text/style.rb +46 -0
- data/lib/stationery/text/wrapper.rb +131 -0
- data/lib/stationery/version.rb +5 -0
- data/lib/stationery.rb +58 -0
- 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
|