stationery 0.11.0 → 0.12.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 +4 -4
- data/CHANGELOG.md +54 -68
- data/README.md +775 -78
- data/examples/accessible_report.rb +133 -0
- data/examples/article.rb +93 -0
- data/examples/assets/bay.png +0 -0
- data/examples/assets/dunes.png +0 -0
- data/examples/assets/hills.png +0 -0
- data/examples/assets/logo.png +0 -0
- data/examples/assets/ridge.png +0 -0
- data/examples/assets/sea.png +0 -0
- data/examples/assets/stone.png +0 -0
- data/examples/assets/wall.png +0 -0
- data/examples/certificate.rb +104 -0
- data/examples/contract.rb +159 -0
- data/examples/e_invoice.rb +161 -0
- data/examples/flyer.rb +336 -0
- data/examples/form.rb +113 -0
- data/examples/invoice.rb +163 -0
- data/examples/letter.rb +121 -0
- data/examples/menu.rb +133 -0
- data/examples/multilingual_notice.rb +121 -0
- data/examples/newsletter.rb +108 -0
- data/examples/packing_slip.rb +120 -0
- data/examples/postcard.rb +83 -0
- data/examples/price_list.rb +107 -0
- data/examples/receipt.rb +176 -0
- data/examples/report.rb +294 -0
- data/examples/resume.rb +121 -0
- data/examples/shaping/extraction_matrix/pdfium.py +8 -0
- data/examples/shaping/extraction_matrix/pdfjs.mjs +18 -0
- data/examples/shaping/extraction_matrix/pdfkit.swift +6 -0
- data/examples/shaping/extraction_matrix.rb +208 -0
- data/examples/shaping/harfbuzz_shaper.rb +132 -0
- data/examples/shaping/rtl_letter.rb +106 -0
- data/examples/shipping_label.rb +93 -0
- data/lib/stationery/barcode/code128.rb +96 -0
- data/lib/stationery/barcode/data_matrix.rb +111 -0
- data/lib/stationery/barcode/data_matrix_placement.rb +118 -0
- data/lib/stationery/barcode/ean13.rb +66 -0
- data/lib/stationery/barcode/qr.rb +120 -0
- data/lib/stationery/barcode/qr_matrix.rb +189 -0
- data/lib/stationery/barcode/reed_solomon.rb +61 -0
- data/lib/stationery/barcode.rb +33 -0
- data/lib/stationery/builder.rb +12 -2
- data/lib/stationery/canvas/glyphs.rb +40 -0
- data/lib/stationery/canvas/interface.rb +169 -0
- data/lib/stationery/canvas/marking.rb +32 -3
- data/lib/stationery/canvas/path.rb +32 -70
- data/lib/stationery/canvas/surfacing.rb +67 -0
- data/lib/stationery/canvas/text.rb +4 -25
- data/lib/stationery/canvas.rb +24 -75
- data/lib/stationery/cli/examples.rb +66 -0
- data/lib/stationery/cli/inspect.rb +65 -0
- data/lib/stationery/cli/layout_text.rb +132 -0
- data/lib/stationery/cli/pictures.rb +50 -0
- data/lib/stationery/cli/render.rb +33 -6
- data/lib/stationery/cli/skill.rb +166 -0
- data/lib/stationery/cli.rb +5 -0
- data/lib/stationery/color.rb +27 -13
- data/lib/stationery/component.rb +1 -0
- data/lib/stationery/css/stylesheet.rb +9 -1
- data/lib/stationery/document.rb +135 -27
- data/lib/stationery/elements/forms.rb +2 -1
- data/lib/stationery/elements/lists.rb +6 -2
- data/lib/stationery/elements.rb +43 -7
- data/lib/stationery/examples.rb +22 -0
- data/lib/stationery/fonts/cff.rb +46 -0
- data/lib/stationery/fonts/charstring.rb +297 -0
- data/lib/stationery/fonts/fallback.rb +22 -1
- data/lib/stationery/fonts/font.rb +58 -3
- data/lib/stationery/fonts/font_book.rb +7 -3
- data/lib/stationery/fonts/glyf_outline.rb +230 -0
- data/lib/stationery/fonts/glyph_run.rb +37 -10
- data/lib/stationery/fonts/outline.rb +102 -0
- data/lib/stationery/fonts/shaped_run.rb +22 -4
- data/lib/stationery/fonts/true_type.rb +7 -0
- data/lib/stationery/forms/typeface.rb +2 -4
- data/lib/stationery/html/css.rb +31 -6
- data/lib/stationery/html/tree_builder/blocks.rb +7 -3
- data/lib/stationery/images/images.rb +10 -0
- data/lib/stationery/images/jpeg/bit_reader.rb +92 -0
- data/lib/stationery/images/jpeg/colors.rb +133 -0
- data/lib/stationery/images/jpeg/decoder.rb +253 -0
- data/lib/stationery/images/jpeg/huffman.rb +59 -0
- data/lib/stationery/images/jpeg/idct.rb +150 -0
- data/lib/stationery/images/jpeg/progressive.rb +128 -0
- data/lib/stationery/images/jpeg/reduced_idct.rb +106 -0
- data/lib/stationery/images/jpeg/scan.rb +116 -0
- data/lib/stationery/images/jpeg/upsampler.rb +81 -0
- data/lib/stationery/images/jpeg.rb +59 -1
- data/lib/stationery/images/png.rb +10 -7
- data/lib/stationery/images/resampled.rb +10 -2
- data/lib/stationery/images/webp.rb +6 -5
- data/lib/stationery/layout/barcode.rb +75 -0
- data/lib/stationery/layout/box/wrapping.rb +56 -0
- data/lib/stationery/layout/box.rb +41 -15
- data/lib/stationery/layout/columns/leveller.rb +65 -0
- data/lib/stationery/layout/columns/pour.rb +7 -0
- data/lib/stationery/layout/columns.rb +32 -15
- data/lib/stationery/layout/field.rb +3 -2
- data/lib/stationery/layout/floated.rb +12 -1
- data/lib/stationery/layout/flow/floating.rb +23 -5
- data/lib/stationery/layout/flow/placement.rb +16 -12
- data/lib/stationery/layout/flow/splitter.rb +56 -10
- data/lib/stationery/layout/flow.rb +1 -0
- data/lib/stationery/layout/image.rb +2 -2
- data/lib/stationery/layout/list_item.rb +41 -11
- data/lib/stationery/layout/mark.rb +2 -0
- data/lib/stationery/layout/node.rb +16 -2
- data/lib/stationery/layout/opening.rb +61 -0
- data/lib/stationery/layout/paginator.rb +24 -5
- data/lib/stationery/layout/svg.rb +1 -1
- data/lib/stationery/layout/table/cell.rb +39 -10
- data/lib/stationery/layout/table/row_splitter.rb +6 -2
- data/lib/stationery/layout/table/selection.rb +9 -3
- data/lib/stationery/layout/table/stream.rb +123 -0
- data/lib/stationery/layout/table/widths.rb +26 -0
- data/lib/stationery/layout/table.rb +151 -35
- data/lib/stationery/layout/table_of_contents/entry.rb +3 -3
- data/lib/stationery/layout/table_of_contents.rb +1 -1
- data/lib/stationery/layout/text.rb +22 -2
- data/lib/stationery/minitest.rb +11 -3
- data/lib/stationery/monochrome/bitmap.rb +78 -0
- data/lib/stationery/monochrome/canvas.rb +19 -0
- data/lib/stationery/monochrome/dither.rb +74 -0
- data/lib/stationery/monochrome/grid.rb +131 -0
- data/lib/stationery/monochrome/painting.rb +140 -0
- data/lib/stationery/monochrome/rules.rb +141 -0
- data/lib/stationery/monochrome.rb +87 -0
- data/lib/stationery/page/format.rb +84 -0
- data/lib/stationery/page.rb +11 -7
- data/lib/stationery/page_templates.rb +13 -10
- data/lib/stationery/path.rb +138 -0
- data/lib/stationery/pdf/assembler.rb +14 -3
- data/lib/stationery/pdf/canvases.rb +51 -0
- data/lib/stationery/pdf/conformance.rb +43 -13
- data/lib/stationery/pdf/print_hints.rb +137 -0
- data/lib/stationery/pdf/serializer.rb +41 -0
- data/lib/stationery/raster/adjust.rb +67 -0
- data/lib/stationery/raster/canvas.rb +108 -0
- data/lib/stationery/raster/canvases.rb +49 -0
- data/lib/stationery/raster/flattener.rb +75 -0
- data/lib/stationery/raster/glyphs.rb +81 -0
- data/lib/stationery/raster/painter.rb +184 -0
- data/lib/stationery/raster/picture.rb +69 -0
- data/lib/stationery/raster/png.rb +32 -0
- data/lib/stationery/raster/render.rb +134 -0
- data/lib/stationery/raster/scanner.rb +164 -0
- data/lib/stationery/raster/shading.rb +77 -0
- data/lib/stationery/raster/spans.rb +66 -0
- data/lib/stationery/raster/stroker.rb +208 -0
- data/lib/stationery/raster/surface.rb +70 -0
- data/lib/stationery/raster.rb +44 -0
- data/lib/stationery/rich/nodes.rb +4 -4
- data/lib/stationery/rich/renderer/boxes.rb +37 -16
- data/lib/stationery/rich/renderer.rb +3 -2
- data/lib/stationery/skill/SKILL.md.erb +244 -0
- data/lib/stationery/skill/recipes/flyer.md.erb +76 -0
- data/lib/stationery/skill/recipes/form.md.erb +78 -0
- data/lib/stationery/skill/recipes/invoice.md.erb +142 -0
- data/lib/stationery/skill/recipes/label.md.erb +116 -0
- data/lib/stationery/skill/recipes/receipt.md.erb +79 -0
- data/lib/stationery/skill/recipes/report.md.erb +113 -0
- data/lib/stationery/skill/reference/conformance.md.erb +45 -0
- data/lib/stationery/skill/reference/elements.md.erb +19 -0
- data/lib/stationery/skill/reference/fonts_and_images.md.erb +7 -0
- data/lib/stationery/skill/reference/forms.md.erb +5 -0
- data/lib/stationery/skill/reference/html_and_markdown.md.erb +5 -0
- data/lib/stationery/skill/reference/pages.md.erb +15 -0
- data/lib/stationery/skill/reference/printing.md.erb +33 -0
- data/lib/stationery/skill/reference/testing.md.erb +33 -0
- data/lib/stationery/skill.rb +101 -0
- data/lib/stationery/structure.rb +7 -6
- data/lib/stationery/svg/gradient.rb +12 -0
- data/lib/stationery/svg/painter.rb +1 -1
- data/lib/stationery/tagging/element.rb +9 -2
- data/lib/stationery/tagging/tree.rb +98 -2
- data/lib/stationery/testing/field_page.rb +89 -0
- data/lib/stationery/testing/inspector.rb +80 -2
- data/lib/stationery/testing/layout.rb +166 -0
- data/lib/stationery/testing/marked_text.rb +124 -18
- data/lib/stationery/testing/matchers.rb +63 -14
- data/lib/stationery/testing/page_reader.rb +146 -0
- data/lib/stationery/text/exclusions.rb +11 -0
- data/lib/stationery/text/paragraph.rb +13 -8
- data/lib/stationery/text/wrapper/one_word.rb +33 -0
- data/lib/stationery/text/wrapper.rb +62 -27
- data/lib/stationery/units.rb +45 -0
- data/lib/stationery/version.rb +1 -1
- data/lib/stationery/warnings.rb +67 -7
- data/lib/stationery/zpl/native.rb +51 -0
- data/lib/stationery/zpl/render.rb +56 -0
- data/lib/stationery/zpl.rb +88 -0
- data/lib/stationery.rb +48 -0
- metadata +118 -1
data/examples/flyer.rb
ADDED
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# An A4 event flyer: a cover photo bled to the page edges, a date pill, a
|
|
4
|
+
# stats band, prose from HTML, a tilted photo collage, a price table,
|
|
5
|
+
# accommodation cards, a gallery grid, testimonials and a call to action with
|
|
6
|
+
# a link. Run it to write examples/flyer.pdf:
|
|
7
|
+
#
|
|
8
|
+
# ruby -Ilib examples/flyer.rb
|
|
9
|
+
# ruby -Ilib exe/stationery render examples/flyer.rb
|
|
10
|
+
require "stationery"
|
|
11
|
+
|
|
12
|
+
class ExampleFlyer < Stationery::Document
|
|
13
|
+
ASSETS = File.expand_path("assets", __dir__)
|
|
14
|
+
PAGE = "#FAF8F2"
|
|
15
|
+
BAND = "#F1EEE7"
|
|
16
|
+
HAIRLINE = "#E3DDD3"
|
|
17
|
+
PRIMARY = "#345644"
|
|
18
|
+
PRIMARY_CONTENT = "#F3FBF6"
|
|
19
|
+
ACCENT = "#C1983A"
|
|
20
|
+
INK = "#3A2A20"
|
|
21
|
+
MUTED = "#74685F"
|
|
22
|
+
FAINT = "#9A9189"
|
|
23
|
+
TINT = "#E6E8E1"
|
|
24
|
+
MARGIN = 48
|
|
25
|
+
COVER_HEIGHT = 220
|
|
26
|
+
OVERHANG = 24
|
|
27
|
+
|
|
28
|
+
SVG_OPEN = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" ' \
|
|
29
|
+
'stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">'
|
|
30
|
+
ICONS = {
|
|
31
|
+
calendar: '<path d="M4 6h16v14H4z M4 10h16 M8 3v4 M16 3v4"/>',
|
|
32
|
+
pin: '<path d="M12 21s-6-5.5-6-11a6 6 0 0 1 12 0c0 5.5-6 11-6 11z"/><circle cx="12" cy="10" r="2.2"/>',
|
|
33
|
+
users: '<circle cx="9" cy="8" r="3.2"/>' \
|
|
34
|
+
'<path d="M3 20a6 6 0 0 1 12 0 M15.5 5.5a3 3 0 0 1 0 5.5 M17 13.5a6 6 0 0 1 4 6"/>',
|
|
35
|
+
clock: '<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3.5 2"/>',
|
|
36
|
+
check: '<path d="M5 12.5l4.5 4.5L19 7.5"/>',
|
|
37
|
+
quote: '<path d="M7 16c-1.5 0-3-1.5-3-3.5S5.5 8 8 7l1 1.5c-1.5.7-2 1.6-2 2.5 1.5 0 2.5 1 2.5 2.5S8.5 16 7 16z ' \
|
|
38
|
+
'M16 16c-1.5 0-3-1.5-3-3.5S14.5 8 17 7l1 1.5c-1.5.7-2 1.6-2 2.5 1.5 0 2.5 1 2.5 2.5S17.5 16 16 16z"/>',
|
|
39
|
+
sun: '<circle cx="12" cy="12" r="4"/><path d="M12 3v2 M12 19v2 M3 12h2 M19 12h2 M5.6 5.6l1.4 1.4 M17 17l1.4 1.4 ' \
|
|
40
|
+
'M5.6 18.4L7 17 M17 7l1.4-1.4"/>'
|
|
41
|
+
}.freeze
|
|
42
|
+
|
|
43
|
+
LIFE = <<~HTML
|
|
44
|
+
<p>We believe a good month feels natural rather than scheduled. Breakfast is long, the sea is a short walk
|
|
45
|
+
away and the evenings belong to whoever brings a guitar. Food is not included: guests shop at the Tuesday
|
|
46
|
+
market, cook for themselves or for everyone, and eat out in the harbour when the mood takes them.</p>
|
|
47
|
+
<p>Most days follow a loose rhythm:</p>
|
|
48
|
+
<ul>
|
|
49
|
+
<li>Morning movement on the terrace before it gets warm</li>
|
|
50
|
+
<li>Focused work or rest until the early afternoon</li>
|
|
51
|
+
<li>Swims, hikes and boat trips when the light softens</li>
|
|
52
|
+
<li>A shared table and a circle on two evenings a week</li>
|
|
53
|
+
</ul>
|
|
54
|
+
<p>Every session adapts to who turns up. Nothing is compulsory, and nobody keeps score.</p>
|
|
55
|
+
HTML
|
|
56
|
+
|
|
57
|
+
page size: :a4, margin: MARGIN
|
|
58
|
+
default_text size: 10, color: INK, leading: 3
|
|
59
|
+
metadata title: "Harbourside — a month on Ilha Serena", author: "stationery example", lang: "en"
|
|
60
|
+
tagged
|
|
61
|
+
|
|
62
|
+
# A cream page, painted under every page.
|
|
63
|
+
page_template(layer: :background) do |page|
|
|
64
|
+
canvas(at: [0, 0], width: page.width, height: page.height) do |c, rect|
|
|
65
|
+
c.fill_rect(rect.x, rect.y, rect.width, rect.height, color: PAGE)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
footer(gap: 12) do |page|
|
|
70
|
+
text "Harbourside · harbourside.example · Page #{page.number} of #{page.count}",
|
|
71
|
+
size: 7, color: FAINT, align: :center
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def self.preview = new
|
|
75
|
+
|
|
76
|
+
def view_template
|
|
77
|
+
hero
|
|
78
|
+
stats
|
|
79
|
+
section("Life at Harbourside", band: true)
|
|
80
|
+
html LIFE, styles: { p: { color: MUTED }, ul: { gap: 3, marker_color: ACCENT, indent: 14 } }
|
|
81
|
+
spacer 10
|
|
82
|
+
collage
|
|
83
|
+
section("What's included")
|
|
84
|
+
included
|
|
85
|
+
section("Prices", band: true)
|
|
86
|
+
prices
|
|
87
|
+
section("Where you sleep")
|
|
88
|
+
accommodation
|
|
89
|
+
section("Impressions", band: true)
|
|
90
|
+
gallery
|
|
91
|
+
section("What guests say")
|
|
92
|
+
testimonials
|
|
93
|
+
call_to_action
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
private
|
|
97
|
+
|
|
98
|
+
# ---- hero ----------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
# The cover photo covers the page's full width and bleeds off the top; the
|
|
101
|
+
# positioned box takes no flow height, so a spacer reserves the space.
|
|
102
|
+
def hero
|
|
103
|
+
box(at: [0, 0], width: page_width) do
|
|
104
|
+
image photo("sea"), width: page_width, height: COVER_HEIGHT, fit: :cover, alt: "The harbour at first light"
|
|
105
|
+
end
|
|
106
|
+
spacer COVER_HEIGHT - MARGIN + 18
|
|
107
|
+
date_pill
|
|
108
|
+
spacer 14
|
|
109
|
+
text "Harbourside", size: 30, weight: :bold, color: PRIMARY, align: :center, heading: 1
|
|
110
|
+
spacer 4
|
|
111
|
+
text "A month of community living on Ilha Serena", size: 12, color: MUTED, align: :center
|
|
112
|
+
spacer 14
|
|
113
|
+
meta_row
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def date_pill
|
|
117
|
+
group(align: :center) do
|
|
118
|
+
box(width: :auto, background: PRIMARY, radius: 10, padding: [4, 12]) do
|
|
119
|
+
row(gap: 5, align: :middle) do
|
|
120
|
+
column(width: :auto) { icon(:calendar, size: 10, color: PRIMARY_CONTENT) }
|
|
121
|
+
column(width: :auto) { text "3 – 31 May 2027", size: 9, color: PRIMARY_CONTENT }
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def meta_row
|
|
128
|
+
row do
|
|
129
|
+
[[:pin, "Porto Velho, Ilha Serena"], [:users, "12 – 20 guests"], [:clock, "7 to 28 nights"]].each do |name, label|
|
|
130
|
+
column do
|
|
131
|
+
row(gap: 5, align: :middle) do
|
|
132
|
+
column(width: 14, padding: [0, 0, 0, 4]) { icon(name, size: 10) }
|
|
133
|
+
column { text label, size: 9, color: MUTED }
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# ---- stats band ----------------------------------------------------------
|
|
141
|
+
|
|
142
|
+
def stats
|
|
143
|
+
spacer 22
|
|
144
|
+
box(background: BAND, radius: 12, padding: [14, 10]) do
|
|
145
|
+
row do
|
|
146
|
+
[[:clock, "Duration", "7 – 28 nights"], [:sun, "Investment", "from € 690"],
|
|
147
|
+
[:users, "Group size", "12 – 20 guests"]].each do |name, label, value|
|
|
148
|
+
column(align: :center) { stat(name, label, value) }
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def stat(name, label, value)
|
|
155
|
+
icon_disc(name)
|
|
156
|
+
spacer 6
|
|
157
|
+
text label, size: 8, color: FAINT, align: :center
|
|
158
|
+
spacer 2
|
|
159
|
+
text value, size: 11, weight: :bold, color: PRIMARY, align: :center
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def icon_disc(name, diameter: 26)
|
|
163
|
+
group(align: :center) do
|
|
164
|
+
box(width: diameter, height: diameter, radius: diameter / 2.0, background: TINT, valign: :middle) do
|
|
165
|
+
icon(name, size: 13, align: :center)
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# ---- sections ------------------------------------------------------------
|
|
171
|
+
|
|
172
|
+
# A section heading, on a full-bleed tinted strip for every other section,
|
|
173
|
+
# bookmarked in the outline and kept with the start of its section.
|
|
174
|
+
def section(title, band: false)
|
|
175
|
+
spacer 18
|
|
176
|
+
group(keep_with_next: 120, bookmark: title) do
|
|
177
|
+
if band
|
|
178
|
+
box(background: BAND, outset: [0, MARGIN, 0, MARGIN], padding: [12, 0]) { heading(title) }
|
|
179
|
+
else
|
|
180
|
+
heading(title)
|
|
181
|
+
end
|
|
182
|
+
spacer 10
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def heading(title) = text(title, size: 19, weight: :bold, color: PRIMARY, align: :center, heading: 2)
|
|
187
|
+
|
|
188
|
+
def collage
|
|
189
|
+
group(align: :center) do
|
|
190
|
+
box(padding: OVERHANG, width: :auto) do
|
|
191
|
+
stack do
|
|
192
|
+
box(width: :auto, radius: 16, shadow: true) do
|
|
193
|
+
image photo("hills"), width: 300, height: 225, fit: :cover, radius: 16, alt: "Hills above the harbour"
|
|
194
|
+
end
|
|
195
|
+
layer(bottom: -OVERHANG, left: -OVERHANG, width: 0.4, rotate: -3, **frame) do
|
|
196
|
+
image photo("stone"), width: 112, height: 84, fit: :cover, radius: 8, alt: "The old stone quay"
|
|
197
|
+
end
|
|
198
|
+
layer(top: -OVERHANG, right: -OVERHANG, width: 1/3r, rotate: 2, **frame) do
|
|
199
|
+
image photo("bay"), width: 92, height: 92, fit: :cover, radius: 8, alt: "The bay from the terrace"
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def frame = { padding: 4, background: "#FFFFFF", radius: 12, shadow: true }
|
|
207
|
+
|
|
208
|
+
def included
|
|
209
|
+
items = ["Your room for the whole stay", "Morning movement, six days a week",
|
|
210
|
+
"Two evening circles every week", "A boat day and two guided hikes",
|
|
211
|
+
"A shared kitchen and the Tuesday market run", "Fast Wi-Fi and a quiet room to work in"]
|
|
212
|
+
items.each_slice(2) do |pair|
|
|
213
|
+
row(gap: 14) do
|
|
214
|
+
pair.each do |item|
|
|
215
|
+
column(width: 0.5) do
|
|
216
|
+
row(gap: 6) do
|
|
217
|
+
column(width: 10, padding: [3, 0, 0, 0]) { icon(:check, size: 9, color: ACCENT) }
|
|
218
|
+
column { text item, size: 10, leading: 2 }
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
spacer 7
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def prices
|
|
228
|
+
text "Prices are per person and include everything listed above. Children under twelve stay free in " \
|
|
229
|
+
"their parents' room.", color: MUTED, align: :center
|
|
230
|
+
spacer 10
|
|
231
|
+
rows = [["Stay", "Regular", "Early bird\n<font size='7'>until 31 January</font>", "Returning guests"],
|
|
232
|
+
["7 nights", "€ 690", "€ 620", "€ 590"], ["14 nights", "€ 1,290", "€ 1,160", "€ 1,090"],
|
|
233
|
+
["21 nights", "€ 1,790", "€ 1,610", "€ 1,520"], ["28 nights", "€ 2,190", "€ 1,970", "€ 1,860"]]
|
|
234
|
+
table(rows, width: :full, header: true,
|
|
235
|
+
cell: { markup: true, size: 9.5, borders: [:bottom], border_color: HAIRLINE, padding: [6, 8] }) do |t|
|
|
236
|
+
t.row(0).set(background: PRIMARY, color: PRIMARY_CONTENT, weight: :bold)
|
|
237
|
+
t.columns(1..).align = :right
|
|
238
|
+
t.zebra(from: 2, color: BAND)
|
|
239
|
+
end
|
|
240
|
+
spacer 8
|
|
241
|
+
text "Returning guests are anyone who has stayed with us before, in any house.", size: 8.5, color: FAINT,
|
|
242
|
+
align: :center
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def accommodation
|
|
246
|
+
[["The Harbour House", "6 rooms · sleeps 10", "ridge",
|
|
247
|
+
"The old harbourmaster's house: a big kitchen, a walled garden and rooms that look over the boats."],
|
|
248
|
+
["The Terrace Apartments", "2 apartments · sleeps 6", "wall",
|
|
249
|
+
"Two quiet apartments above the square, each with its own kitchen and a terrace that catches the " \
|
|
250
|
+
"afternoon sun."]].each do |name, capacity, picture, blurb|
|
|
251
|
+
group(keep_together: true) do
|
|
252
|
+
row(gap: 14, align: :middle) do
|
|
253
|
+
column(width: 150) { image photo(picture), width: 150, height: 96, fit: :cover, radius: 10, alt: name }
|
|
254
|
+
column do
|
|
255
|
+
text name, size: 13, weight: :bold, color: PRIMARY, heading: 3
|
|
256
|
+
text capacity, size: 9, color: ACCENT
|
|
257
|
+
spacer 4
|
|
258
|
+
text blurb, color: MUTED
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
spacer 14
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def gallery
|
|
267
|
+
%w[sea hills stone bay ridge dunes].each_slice(3) do |names|
|
|
268
|
+
row(gap: 8) do
|
|
269
|
+
names.each do |name|
|
|
270
|
+
column(width: 1 / 3.0) do
|
|
271
|
+
image photo(name), width: 161, height: 96, fit: :cover, radius: 8, alt: "Impression"
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
spacer 8
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
def testimonials
|
|
280
|
+
[["Three weeks in, I stopped checking the time. That has never happened to me on a holiday.", "Mara, Vienna"],
|
|
281
|
+
["Came for the sea, stayed for the Tuesday market and the people around the table.", "Jonas, Malmö"]]
|
|
282
|
+
.each do |quote, author|
|
|
283
|
+
group(keep_together: true) do
|
|
284
|
+
box(role: :blockquote, background: BAND, radius: 12, padding: [14, 18]) do
|
|
285
|
+
icon(:quote, size: 14, color: ACCENT, align: :center)
|
|
286
|
+
spacer 6
|
|
287
|
+
text %("#{quote}"), style: :italic, color: MUTED, align: :center
|
|
288
|
+
spacer 4
|
|
289
|
+
text author, size: 9, color: PRIMARY, align: :center
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
spacer 10
|
|
293
|
+
end
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
# A pill the whole shape of which is clickable, and the address spelled out
|
|
297
|
+
# for the printed copy.
|
|
298
|
+
def call_to_action
|
|
299
|
+
url = "https://harbourside.example/apply"
|
|
300
|
+
spacer 22
|
|
301
|
+
group(keep_together: true) do
|
|
302
|
+
box(background: BAND, outset: [0, MARGIN, 0, MARGIN], padding: [16, 0]) do
|
|
303
|
+
text "Ready to come?", size: 19, weight: :bold, color: PRIMARY, align: :center, heading: 2
|
|
304
|
+
spacer 6
|
|
305
|
+
text "Applications open until the house is full. We read every one and reply within a week.",
|
|
306
|
+
color: MUTED, align: :center
|
|
307
|
+
spacer 12
|
|
308
|
+
group(align: :center) do
|
|
309
|
+
box(width: :auto, height: 26, background: PRIMARY, radius: 13, padding: [0, 28], valign: :middle,
|
|
310
|
+
link: url) do
|
|
311
|
+
text "Apply for a call", size: 11, color: PRIMARY_CONTENT, align: :center
|
|
312
|
+
end
|
|
313
|
+
end
|
|
314
|
+
spacer 8
|
|
315
|
+
text(size: 9, align: :center) { |t| t.link(url) { t.color(MUTED, "or write to hello@harbourside.example") } }
|
|
316
|
+
end
|
|
317
|
+
end
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
# ---- helpers -------------------------------------------------------------
|
|
321
|
+
|
|
322
|
+
def page_width = Stationery::Page.new(size: :a4).width
|
|
323
|
+
def photo(name) = File.join(ASSETS, "#{name}.png")
|
|
324
|
+
|
|
325
|
+
# Decorative line icons; the label beside each one carries the meaning.
|
|
326
|
+
def icon(name, size: 12, color: PRIMARY, align: nil)
|
|
327
|
+
svg "#{SVG_OPEN}#{ICONS.fetch(name)}</svg>", width: size, height: size, color:, align:, alt: false
|
|
328
|
+
end
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
if $PROGRAM_NAME == __FILE__
|
|
332
|
+
flyer = ExampleFlyer.preview
|
|
333
|
+
flyer.to_pdf(File.expand_path("flyer.pdf", __dir__))
|
|
334
|
+
warn flyer.warnings.map(&:message) if flyer.warnings.any?
|
|
335
|
+
puts "wrote examples/flyer.pdf"
|
|
336
|
+
end
|
data/examples/form.rb
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# A one-page membership application with every kind of form field: text
|
|
4
|
+
# inputs, a multiline note, a comb field, a select box, a radio group, check
|
|
5
|
+
# boxes and a signature field. Run it to write examples/form.pdf:
|
|
6
|
+
#
|
|
7
|
+
# ruby -Ilib examples/form.rb
|
|
8
|
+
# ruby -Ilib exe/stationery render examples/form.rb
|
|
9
|
+
require "stationery"
|
|
10
|
+
|
|
11
|
+
class ExampleForm < Stationery::Document
|
|
12
|
+
ACCENT = "#0F766E"
|
|
13
|
+
INK = "#111827"
|
|
14
|
+
MUTED = "#6B7280"
|
|
15
|
+
HAIRLINE = "#E5E7EB"
|
|
16
|
+
PLANS = { "basic" => "Basic · SEK 290 / year", "pro" => "Pro · SEK 590 / year",
|
|
17
|
+
"team" => "Team · SEK 1,490 / year" }.freeze
|
|
18
|
+
|
|
19
|
+
page size: :a4, margin: [48, 56, 40, 56]
|
|
20
|
+
default_text size: 9.5, color: INK
|
|
21
|
+
metadata title: "Membership application", author: "Nordic Makers Guild", creator: "stationery example",
|
|
22
|
+
lang: "en"
|
|
23
|
+
|
|
24
|
+
footer do
|
|
25
|
+
rule height: 0.5, color: HAIRLINE
|
|
26
|
+
spacer 6
|
|
27
|
+
text "Nordic Makers Guild · Box 118, 221 00 Lund · members@makers.example", size: 7.5, color: MUTED,
|
|
28
|
+
align: :center
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.preview = new(name: "Astrid Lindqvist", email: "astrid@example.se", plan: "pro")
|
|
32
|
+
|
|
33
|
+
def initialize(name: "", email: "", plan: nil)
|
|
34
|
+
super()
|
|
35
|
+
@name = name
|
|
36
|
+
@email = email
|
|
37
|
+
@plan = plan
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def view_template
|
|
41
|
+
text "Membership application", size: 20, weight: :bold, color: ACCENT
|
|
42
|
+
text "Fill in the form on screen, sign it and send it back to us.", color: MUTED
|
|
43
|
+
spacer 20
|
|
44
|
+
applicant
|
|
45
|
+
spacer 18
|
|
46
|
+
membership
|
|
47
|
+
spacer 18
|
|
48
|
+
consent
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def heading(title)
|
|
54
|
+
text title, size: 11, weight: :bold, keep_with_next: true
|
|
55
|
+
rule height: 0.5, color: HAIRLINE
|
|
56
|
+
spacer 8
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def labelled(label)
|
|
60
|
+
column do
|
|
61
|
+
text label, size: 8, color: MUTED
|
|
62
|
+
spacer 3
|
|
63
|
+
yield
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def applicant
|
|
68
|
+
heading "Applicant"
|
|
69
|
+
row(gap: 12) do
|
|
70
|
+
labelled("Full name") { text_field "applicant.name", value: @name, required: true }
|
|
71
|
+
labelled("Email") { text_field "applicant.email", value: @email, required: true }
|
|
72
|
+
end
|
|
73
|
+
spacer 10
|
|
74
|
+
row(gap: 12) do
|
|
75
|
+
labelled("Street address") { text_field "applicant.address.street" }
|
|
76
|
+
column(width: 90) { labelled("Postcode") { text_field "applicant.address.postcode", comb: 5 } }
|
|
77
|
+
labelled("Country") do
|
|
78
|
+
select "applicant.address.country", options: %w[Sweden Norway Denmark Finland Iceland], value: "Sweden"
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def membership
|
|
84
|
+
heading "Membership"
|
|
85
|
+
PLANS.each do |value, label|
|
|
86
|
+
radio "plan", value, checked: value == @plan, label: label
|
|
87
|
+
spacer 4
|
|
88
|
+
end
|
|
89
|
+
spacer 8
|
|
90
|
+
text "Anything we should know? (workshops you are interested in, accessibility needs)", size: 8, color: MUTED
|
|
91
|
+
spacer 3
|
|
92
|
+
text_field "notes", multiline: true, height: 70
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def consent
|
|
96
|
+
heading "Consent"
|
|
97
|
+
checkbox "terms", label: "I accept the terms of membership and the workshop safety rules."
|
|
98
|
+
spacer 4
|
|
99
|
+
checkbox "newsletter", checked: true, label: "Send me the monthly newsletter."
|
|
100
|
+
spacer 24
|
|
101
|
+
row(gap: 24, align: :bottom) do
|
|
102
|
+
column { signature_field "signature", label: "Signature of the applicant" }
|
|
103
|
+
column(width: 150) { labelled("Date") { text_field "signed_on" } }
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
if $PROGRAM_NAME == __FILE__
|
|
109
|
+
form = ExampleForm.preview
|
|
110
|
+
form.to_pdf(File.expand_path("form.pdf", __dir__))
|
|
111
|
+
warn form.warnings.map(&:message) if form.warnings.any?
|
|
112
|
+
puts "wrote examples/form.pdf"
|
|
113
|
+
end
|
data/examples/invoice.rb
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# An invoice: a logo beside an amount-due callout, a summary table without
|
|
4
|
+
# borders, a line-item table with a coloured header, zebra rows and a totals
|
|
5
|
+
# row, in a font family of its own. Run it to write examples/invoice.pdf:
|
|
6
|
+
#
|
|
7
|
+
# ruby -Ilib examples/invoice.rb
|
|
8
|
+
# ruby -Ilib exe/stationery render examples/invoice.rb
|
|
9
|
+
require "stationery"
|
|
10
|
+
|
|
11
|
+
class ExampleInvoice < Stationery::Document
|
|
12
|
+
FONTS = File.expand_path("../spec/fixtures/fonts", __dir__)
|
|
13
|
+
LOGO = File.expand_path("assets/logo.png", __dir__)
|
|
14
|
+
|
|
15
|
+
ACCENT = "#4F46E5"
|
|
16
|
+
INK = "#1F2937"
|
|
17
|
+
MUTED = "#6B7280"
|
|
18
|
+
HAIRLINE = "#E5E7EB"
|
|
19
|
+
ZEBRA = "#F9FAFB"
|
|
20
|
+
CALLOUT = "#F3F4F6"
|
|
21
|
+
|
|
22
|
+
# Open Sans lives with the test suite, so an installed gem sets the invoice
|
|
23
|
+
# in the Inter it bundles.
|
|
24
|
+
FAMILY = File.exist?(File.join(FONTS, "OpenSans-Regular.ttf")) ? "Open Sans" : "Inter"
|
|
25
|
+
|
|
26
|
+
page size: :a4, margin: [40, 44, 56, 44]
|
|
27
|
+
if FAMILY == "Open Sans"
|
|
28
|
+
font_family "Open Sans",
|
|
29
|
+
regular: File.join(FONTS, "OpenSans-Regular.ttf"), bold: File.join(FONTS, "OpenSans-Bold.ttf"),
|
|
30
|
+
italic: File.join(FONTS, "OpenSans-Italic.ttf"),
|
|
31
|
+
bold_italic: File.join(FONTS, "OpenSans-BoldItalic.ttf")
|
|
32
|
+
end
|
|
33
|
+
default_text font: FAMILY, size: 9, color: INK
|
|
34
|
+
metadata title: "Invoice", creator: "stationery example"
|
|
35
|
+
|
|
36
|
+
page_template do |page|
|
|
37
|
+
box(at: [page.margin[3], page.height - 34], width: page.content_box.width) do
|
|
38
|
+
text "Page #{page.number} of #{page.count}", size: 7, color: MUTED, align: :right
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def self.preview
|
|
43
|
+
items = [["Brand workshop", 1, 2400.0], ["Logo design, three concepts", 1, 3200.0],
|
|
44
|
+
["Illustration set (12)", 12, 180.0], ["Print-ready files", 1, 450.0], ["Rush delivery", 1, 300.0]]
|
|
45
|
+
new(number: "INV-2026-042", items:, customer: "Müller & Söhne GmbH", due: "26 October 2026")
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def initialize(number:, items:, customer:, due:)
|
|
49
|
+
super()
|
|
50
|
+
@number = number
|
|
51
|
+
@items = items
|
|
52
|
+
@customer = customer
|
|
53
|
+
@due = due
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def view_template
|
|
57
|
+
header
|
|
58
|
+
spacer 18
|
|
59
|
+
summary
|
|
60
|
+
spacer 22
|
|
61
|
+
parties
|
|
62
|
+
spacer 26
|
|
63
|
+
line_items
|
|
64
|
+
spacer 28
|
|
65
|
+
footer
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
def header
|
|
71
|
+
row(align: :middle) do
|
|
72
|
+
column(width: 0.5) { text "Invoice #{@number}", size: 22, weight: :bold }
|
|
73
|
+
column(width: 0.5, align: :right) { image LOGO, height: 34, align: :right }
|
|
74
|
+
end
|
|
75
|
+
spacer 10
|
|
76
|
+
rule height: 3, color: ACCENT
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def summary
|
|
80
|
+
row(gap: 24) do
|
|
81
|
+
column do
|
|
82
|
+
table([["Invoice date", "26 September 2026"], ["Due date", @due], ["Reference", "PO-7781"]],
|
|
83
|
+
widths: [90, nil], cell: { borders: [], padding: [1, 12, 2, 0], color: MUTED }) do |t|
|
|
84
|
+
t.column(0).weight = :bold
|
|
85
|
+
t.column(0).color = INK
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
column(width: 190) { amount_due }
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def amount_due
|
|
93
|
+
box(background: CALLOUT, radius: 6, padding: 12) do
|
|
94
|
+
text "AMOUNT DUE", size: 8, weight: :bold, color: MUTED, letter_spacing: 0.5
|
|
95
|
+
text money(total), size: 19, weight: :bold, color: ACCENT
|
|
96
|
+
spacer 6
|
|
97
|
+
row { column(width: :auto) { status_pill("OPEN", "#3B82F6") } }
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def status_pill(label, color)
|
|
102
|
+
box(background: color, radius: 7, padding: [2, 9]) { text label, size: 7, weight: :bold, color: "#FFFFFF" }
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def parties
|
|
106
|
+
row(gap: 24) do
|
|
107
|
+
column(width: 0.5) do
|
|
108
|
+
party("FROM", ["<b>Acme Studio AB</b>", "Storgatan 1", "111 22 Stockholm", "hello@acme.test"])
|
|
109
|
+
end
|
|
110
|
+
column(width: 0.5) { party("BILL TO", ["<b>#{@customer}</b>", "Hauptstraße 5", "10115 Berlin"]) }
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def party(label, lines)
|
|
115
|
+
text label, size: 8, weight: :bold, color: MUTED, letter_spacing: 0.5
|
|
116
|
+
spacer 3
|
|
117
|
+
text lines.join("\n"), markup: true, leading: 1
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def line_items
|
|
121
|
+
rows = [%w[Description Qty Price VAT Amount]]
|
|
122
|
+
@items.each { |name, qty, price| rows << [name, qty.to_s, money(price), "25%", money(qty * price)] }
|
|
123
|
+
rows << ["", "", "", "Subtotal", money(subtotal)]
|
|
124
|
+
rows << ["", "", "", "VAT 25%", money(vat)]
|
|
125
|
+
rows << ["", "", "", "Total", money(total)]
|
|
126
|
+
count = @items.size
|
|
127
|
+
|
|
128
|
+
table(rows, width: :full, widths: [nil, 45, 80, 60, 85], header: true,
|
|
129
|
+
cell: { padding: [7, 8], borders: [] }) do |t|
|
|
130
|
+
t.row(0).set(background: ACCENT, color: "#FFFFFF", weight: :bold)
|
|
131
|
+
t.columns(1..).align = :right
|
|
132
|
+
t.zebra(from: 2, to: count, color: ZEBRA)
|
|
133
|
+
t.row(count + 1).columns(3..).set(borders: [:top], border_color: HAIRLINE, border_width: 1)
|
|
134
|
+
t.row(-1).set(weight: :bold, size: 11)
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def footer
|
|
139
|
+
group(keep_together: true) do
|
|
140
|
+
rule height: 1, color: HAIRLINE
|
|
141
|
+
spacer 14
|
|
142
|
+
text "Thank you for your business", size: 9, weight: :bold
|
|
143
|
+
spacer 3
|
|
144
|
+
text "Pay to IBAN SE45 5000 0000 0583 9825 7466 within 30 days. Questions? " \
|
|
145
|
+
"<link href='mailto:hello@acme.test'><color rgb='#{ACCENT}'>hello@acme.test</color></link>",
|
|
146
|
+
markup: true, size: 8.5, color: MUTED, leading: 2
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def subtotal = @items.sum { |_, qty, price| qty * price }
|
|
151
|
+
def vat = (subtotal * 0.25).round(2)
|
|
152
|
+
def total = subtotal + vat
|
|
153
|
+
|
|
154
|
+
def money(amount)
|
|
155
|
+
whole, cents = format("%.2f", amount).split(".")
|
|
156
|
+
"€#{whole.reverse.scan(/\d{1,3}/).join(" ").reverse},#{cents}"
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
if $PROGRAM_NAME == __FILE__
|
|
161
|
+
ExampleInvoice.preview.to_pdf(File.expand_path("invoice.pdf", __dir__))
|
|
162
|
+
puts "wrote examples/invoice.pdf"
|
|
163
|
+
end
|