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.
Files changed (196) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +54 -68
  3. data/README.md +775 -78
  4. data/examples/accessible_report.rb +133 -0
  5. data/examples/article.rb +93 -0
  6. data/examples/assets/bay.png +0 -0
  7. data/examples/assets/dunes.png +0 -0
  8. data/examples/assets/hills.png +0 -0
  9. data/examples/assets/logo.png +0 -0
  10. data/examples/assets/ridge.png +0 -0
  11. data/examples/assets/sea.png +0 -0
  12. data/examples/assets/stone.png +0 -0
  13. data/examples/assets/wall.png +0 -0
  14. data/examples/certificate.rb +104 -0
  15. data/examples/contract.rb +159 -0
  16. data/examples/e_invoice.rb +161 -0
  17. data/examples/flyer.rb +336 -0
  18. data/examples/form.rb +113 -0
  19. data/examples/invoice.rb +163 -0
  20. data/examples/letter.rb +121 -0
  21. data/examples/menu.rb +133 -0
  22. data/examples/multilingual_notice.rb +121 -0
  23. data/examples/newsletter.rb +108 -0
  24. data/examples/packing_slip.rb +120 -0
  25. data/examples/postcard.rb +83 -0
  26. data/examples/price_list.rb +107 -0
  27. data/examples/receipt.rb +176 -0
  28. data/examples/report.rb +294 -0
  29. data/examples/resume.rb +121 -0
  30. data/examples/shaping/extraction_matrix/pdfium.py +8 -0
  31. data/examples/shaping/extraction_matrix/pdfjs.mjs +18 -0
  32. data/examples/shaping/extraction_matrix/pdfkit.swift +6 -0
  33. data/examples/shaping/extraction_matrix.rb +208 -0
  34. data/examples/shaping/harfbuzz_shaper.rb +132 -0
  35. data/examples/shaping/rtl_letter.rb +106 -0
  36. data/examples/shipping_label.rb +93 -0
  37. data/lib/stationery/barcode/code128.rb +96 -0
  38. data/lib/stationery/barcode/data_matrix.rb +111 -0
  39. data/lib/stationery/barcode/data_matrix_placement.rb +118 -0
  40. data/lib/stationery/barcode/ean13.rb +66 -0
  41. data/lib/stationery/barcode/qr.rb +120 -0
  42. data/lib/stationery/barcode/qr_matrix.rb +189 -0
  43. data/lib/stationery/barcode/reed_solomon.rb +61 -0
  44. data/lib/stationery/barcode.rb +33 -0
  45. data/lib/stationery/builder.rb +12 -2
  46. data/lib/stationery/canvas/glyphs.rb +40 -0
  47. data/lib/stationery/canvas/interface.rb +169 -0
  48. data/lib/stationery/canvas/marking.rb +32 -3
  49. data/lib/stationery/canvas/path.rb +32 -70
  50. data/lib/stationery/canvas/surfacing.rb +67 -0
  51. data/lib/stationery/canvas/text.rb +4 -25
  52. data/lib/stationery/canvas.rb +24 -75
  53. data/lib/stationery/cli/examples.rb +66 -0
  54. data/lib/stationery/cli/inspect.rb +65 -0
  55. data/lib/stationery/cli/layout_text.rb +132 -0
  56. data/lib/stationery/cli/pictures.rb +50 -0
  57. data/lib/stationery/cli/render.rb +33 -6
  58. data/lib/stationery/cli/skill.rb +166 -0
  59. data/lib/stationery/cli.rb +5 -0
  60. data/lib/stationery/color.rb +27 -13
  61. data/lib/stationery/component.rb +1 -0
  62. data/lib/stationery/css/stylesheet.rb +9 -1
  63. data/lib/stationery/document.rb +135 -27
  64. data/lib/stationery/elements/forms.rb +2 -1
  65. data/lib/stationery/elements/lists.rb +6 -2
  66. data/lib/stationery/elements.rb +43 -7
  67. data/lib/stationery/examples.rb +22 -0
  68. data/lib/stationery/fonts/cff.rb +46 -0
  69. data/lib/stationery/fonts/charstring.rb +297 -0
  70. data/lib/stationery/fonts/fallback.rb +22 -1
  71. data/lib/stationery/fonts/font.rb +58 -3
  72. data/lib/stationery/fonts/font_book.rb +7 -3
  73. data/lib/stationery/fonts/glyf_outline.rb +230 -0
  74. data/lib/stationery/fonts/glyph_run.rb +37 -10
  75. data/lib/stationery/fonts/outline.rb +102 -0
  76. data/lib/stationery/fonts/shaped_run.rb +22 -4
  77. data/lib/stationery/fonts/true_type.rb +7 -0
  78. data/lib/stationery/forms/typeface.rb +2 -4
  79. data/lib/stationery/html/css.rb +31 -6
  80. data/lib/stationery/html/tree_builder/blocks.rb +7 -3
  81. data/lib/stationery/images/images.rb +10 -0
  82. data/lib/stationery/images/jpeg/bit_reader.rb +92 -0
  83. data/lib/stationery/images/jpeg/colors.rb +133 -0
  84. data/lib/stationery/images/jpeg/decoder.rb +253 -0
  85. data/lib/stationery/images/jpeg/huffman.rb +59 -0
  86. data/lib/stationery/images/jpeg/idct.rb +150 -0
  87. data/lib/stationery/images/jpeg/progressive.rb +128 -0
  88. data/lib/stationery/images/jpeg/reduced_idct.rb +106 -0
  89. data/lib/stationery/images/jpeg/scan.rb +116 -0
  90. data/lib/stationery/images/jpeg/upsampler.rb +81 -0
  91. data/lib/stationery/images/jpeg.rb +59 -1
  92. data/lib/stationery/images/png.rb +10 -7
  93. data/lib/stationery/images/resampled.rb +10 -2
  94. data/lib/stationery/images/webp.rb +6 -5
  95. data/lib/stationery/layout/barcode.rb +75 -0
  96. data/lib/stationery/layout/box/wrapping.rb +56 -0
  97. data/lib/stationery/layout/box.rb +41 -15
  98. data/lib/stationery/layout/columns/leveller.rb +65 -0
  99. data/lib/stationery/layout/columns/pour.rb +7 -0
  100. data/lib/stationery/layout/columns.rb +32 -15
  101. data/lib/stationery/layout/field.rb +3 -2
  102. data/lib/stationery/layout/floated.rb +12 -1
  103. data/lib/stationery/layout/flow/floating.rb +23 -5
  104. data/lib/stationery/layout/flow/placement.rb +16 -12
  105. data/lib/stationery/layout/flow/splitter.rb +56 -10
  106. data/lib/stationery/layout/flow.rb +1 -0
  107. data/lib/stationery/layout/image.rb +2 -2
  108. data/lib/stationery/layout/list_item.rb +41 -11
  109. data/lib/stationery/layout/mark.rb +2 -0
  110. data/lib/stationery/layout/node.rb +16 -2
  111. data/lib/stationery/layout/opening.rb +61 -0
  112. data/lib/stationery/layout/paginator.rb +24 -5
  113. data/lib/stationery/layout/svg.rb +1 -1
  114. data/lib/stationery/layout/table/cell.rb +39 -10
  115. data/lib/stationery/layout/table/row_splitter.rb +6 -2
  116. data/lib/stationery/layout/table/selection.rb +9 -3
  117. data/lib/stationery/layout/table/stream.rb +123 -0
  118. data/lib/stationery/layout/table/widths.rb +26 -0
  119. data/lib/stationery/layout/table.rb +151 -35
  120. data/lib/stationery/layout/table_of_contents/entry.rb +3 -3
  121. data/lib/stationery/layout/table_of_contents.rb +1 -1
  122. data/lib/stationery/layout/text.rb +22 -2
  123. data/lib/stationery/minitest.rb +11 -3
  124. data/lib/stationery/monochrome/bitmap.rb +78 -0
  125. data/lib/stationery/monochrome/canvas.rb +19 -0
  126. data/lib/stationery/monochrome/dither.rb +74 -0
  127. data/lib/stationery/monochrome/grid.rb +131 -0
  128. data/lib/stationery/monochrome/painting.rb +140 -0
  129. data/lib/stationery/monochrome/rules.rb +141 -0
  130. data/lib/stationery/monochrome.rb +87 -0
  131. data/lib/stationery/page/format.rb +84 -0
  132. data/lib/stationery/page.rb +11 -7
  133. data/lib/stationery/page_templates.rb +13 -10
  134. data/lib/stationery/path.rb +138 -0
  135. data/lib/stationery/pdf/assembler.rb +14 -3
  136. data/lib/stationery/pdf/canvases.rb +51 -0
  137. data/lib/stationery/pdf/conformance.rb +43 -13
  138. data/lib/stationery/pdf/print_hints.rb +137 -0
  139. data/lib/stationery/pdf/serializer.rb +41 -0
  140. data/lib/stationery/raster/adjust.rb +67 -0
  141. data/lib/stationery/raster/canvas.rb +108 -0
  142. data/lib/stationery/raster/canvases.rb +49 -0
  143. data/lib/stationery/raster/flattener.rb +75 -0
  144. data/lib/stationery/raster/glyphs.rb +81 -0
  145. data/lib/stationery/raster/painter.rb +184 -0
  146. data/lib/stationery/raster/picture.rb +69 -0
  147. data/lib/stationery/raster/png.rb +32 -0
  148. data/lib/stationery/raster/render.rb +134 -0
  149. data/lib/stationery/raster/scanner.rb +164 -0
  150. data/lib/stationery/raster/shading.rb +77 -0
  151. data/lib/stationery/raster/spans.rb +66 -0
  152. data/lib/stationery/raster/stroker.rb +208 -0
  153. data/lib/stationery/raster/surface.rb +70 -0
  154. data/lib/stationery/raster.rb +44 -0
  155. data/lib/stationery/rich/nodes.rb +4 -4
  156. data/lib/stationery/rich/renderer/boxes.rb +37 -16
  157. data/lib/stationery/rich/renderer.rb +3 -2
  158. data/lib/stationery/skill/SKILL.md.erb +244 -0
  159. data/lib/stationery/skill/recipes/flyer.md.erb +76 -0
  160. data/lib/stationery/skill/recipes/form.md.erb +78 -0
  161. data/lib/stationery/skill/recipes/invoice.md.erb +142 -0
  162. data/lib/stationery/skill/recipes/label.md.erb +116 -0
  163. data/lib/stationery/skill/recipes/receipt.md.erb +79 -0
  164. data/lib/stationery/skill/recipes/report.md.erb +113 -0
  165. data/lib/stationery/skill/reference/conformance.md.erb +45 -0
  166. data/lib/stationery/skill/reference/elements.md.erb +19 -0
  167. data/lib/stationery/skill/reference/fonts_and_images.md.erb +7 -0
  168. data/lib/stationery/skill/reference/forms.md.erb +5 -0
  169. data/lib/stationery/skill/reference/html_and_markdown.md.erb +5 -0
  170. data/lib/stationery/skill/reference/pages.md.erb +15 -0
  171. data/lib/stationery/skill/reference/printing.md.erb +33 -0
  172. data/lib/stationery/skill/reference/testing.md.erb +33 -0
  173. data/lib/stationery/skill.rb +101 -0
  174. data/lib/stationery/structure.rb +7 -6
  175. data/lib/stationery/svg/gradient.rb +12 -0
  176. data/lib/stationery/svg/painter.rb +1 -1
  177. data/lib/stationery/tagging/element.rb +9 -2
  178. data/lib/stationery/tagging/tree.rb +98 -2
  179. data/lib/stationery/testing/field_page.rb +89 -0
  180. data/lib/stationery/testing/inspector.rb +80 -2
  181. data/lib/stationery/testing/layout.rb +166 -0
  182. data/lib/stationery/testing/marked_text.rb +124 -18
  183. data/lib/stationery/testing/matchers.rb +63 -14
  184. data/lib/stationery/testing/page_reader.rb +146 -0
  185. data/lib/stationery/text/exclusions.rb +11 -0
  186. data/lib/stationery/text/paragraph.rb +13 -8
  187. data/lib/stationery/text/wrapper/one_word.rb +33 -0
  188. data/lib/stationery/text/wrapper.rb +62 -27
  189. data/lib/stationery/units.rb +45 -0
  190. data/lib/stationery/version.rb +1 -1
  191. data/lib/stationery/warnings.rb +67 -7
  192. data/lib/stationery/zpl/native.rb +51 -0
  193. data/lib/stationery/zpl/render.rb +56 -0
  194. data/lib/stationery/zpl.rb +88 -0
  195. data/lib/stationery.rb +48 -0
  196. 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
@@ -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