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
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ # A one-page business letter with a vector letterhead. Run it to write
4
+ # examples/letter.pdf:
5
+ #
6
+ # ruby -Ilib examples/letter.rb
7
+ # ruby -Ilib exe/stationery render examples/letter.rb
8
+ require "stationery"
9
+
10
+ class ExampleLetter < Stationery::Document
11
+ ACCENT = "#B45309"
12
+ INK = "#1C1917"
13
+ MUTED = "#78716C"
14
+ HAIRLINE = "#E7E5E4"
15
+ LOGO = <<~SVG
16
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40">
17
+ <rect width="40" height="40" rx="9" fill="#B45309"/>
18
+ <path d="M11 28V12l9 10 9-10v16" fill="none" stroke="#FFFFFF" stroke-width="3.2"
19
+ stroke-linecap="round" stroke-linejoin="round"/>
20
+ <circle cx="20" cy="31" r="2.2" fill="#FDE68A"/>
21
+ </svg>
22
+ SVG
23
+
24
+ page size: :a4, margin: [48, 64, 40, 64]
25
+ default_text size: 10.5, color: INK
26
+ metadata title: "Letter to Hallberg Bygg AB", author: "Mälaren Studio AB", creator: "stationery example"
27
+
28
+ footer do
29
+ rule height: 0.5, color: HAIRLINE
30
+ spacer 6
31
+ text "Mälaren Studio AB · Org. nr 559123-4567 · VAT SE559123456701 · Registered office Västerås, Sweden",
32
+ size: 7.5, color: MUTED, align: :center
33
+ end
34
+
35
+ def self.preview
36
+ new(recipient: ["Ms Anna Hallberg", "Hallberg Bygg AB", "Kungsgatan 12", "753 21 Uppsala"],
37
+ date: "27 September 2026", reference: "Proposal MS-2026-118")
38
+ end
39
+
40
+ def initialize(recipient:, date:, reference:)
41
+ super()
42
+ @recipient = recipient
43
+ @date = date
44
+ @reference = reference
45
+ end
46
+
47
+ def view_template
48
+ letterhead
49
+ spacer 56
50
+ row do
51
+ column(width: 0.6) { text @recipient.join("\n"), leading: 2 }
52
+ column(width: 0.4) { text "Västerås, #{@date}\nOur ref. #{@reference}", color: MUTED, align: :right, leading: 2 }
53
+ end
54
+ spacer 40
55
+ text "Proposal for the redesign of your customer portal", size: 12, weight: :bold
56
+ spacer 16
57
+ body
58
+ spacer 28
59
+ signature
60
+ end
61
+
62
+ private
63
+
64
+ def letterhead
65
+ row(align: :middle, gap: 12) do
66
+ column(width: 40) { svg LOGO, width: 40 }
67
+ column do
68
+ text "Mälaren Studio", size: 15, weight: :bold
69
+ text "Stora Gatan 21, 722 12 Västerås", size: 8.5, color: MUTED
70
+ end
71
+ column(width: 160) do
72
+ text_style(size: 8.5, color: MUTED, align: :right, leading: 1.5) do
73
+ text "+46 21 123 45 67"
74
+ text "hello@malaren.studio"
75
+ text "malaren.studio"
76
+ end
77
+ end
78
+ end
79
+ spacer 12
80
+ rule height: 2, color: ACCENT
81
+ end
82
+
83
+ def body
84
+ text_style(align: :justify, leading: 4) do
85
+ [
86
+ "Dear Ms Hallberg,",
87
+ "Thank you for meeting us in Uppsala last week and for walking us through the way your site managers " \
88
+ "use the customer portal today. It was clear that the portal holds the right information but makes it " \
89
+ "hard to find: most of the questions your office answers by phone are already answered somewhere in it.",
90
+ "We propose a redesign in <b>three phases over twelve weeks</b>. The first phase maps the ten most " \
91
+ "common tasks with your customers and staff; the second turns them into a clickable prototype that we " \
92
+ "test on site; the third delivers the finished design and supports your developers while they build it.",
93
+ "The fixed price for all three phases is <b>SEK 480,000</b> excluding VAT, invoiced at the end of each " \
94
+ "phase. The full proposal, with the schedule and the people involved, is available at " \
95
+ "<link href='https://malaren.studio/p/MS-2026-118'><color rgb='#{ACCENT}'><u>malaren.studio/p/MS-2026-118</u>" \
96
+ "</color></link>.",
97
+ "We would be glad to start in the week of 2 November. If you have any questions, or would like to " \
98
+ "adjust the scope, please call me directly on the number above."
99
+ ].each_with_index do |paragraph, index|
100
+ spacer 10 if index.positive?
101
+ text paragraph, markup: true
102
+ end
103
+ end
104
+ end
105
+
106
+ def signature
107
+ group(keep_together: true) do
108
+ text "Kind regards,"
109
+ spacer 30
110
+ text "Erik Sandberg", weight: :bold
111
+ text "Creative Director, Mälaren Studio AB", color: MUTED, size: 9.5
112
+ end
113
+ end
114
+ end
115
+
116
+ if $PROGRAM_NAME == __FILE__
117
+ letter = ExampleLetter.preview
118
+ letter.to_pdf(File.expand_path("letter.pdf", __dir__))
119
+ warn letter.warnings.map(&:message) if letter.warnings.any?
120
+ puts "wrote examples/letter.pdf"
121
+ end
data/examples/menu.rb ADDED
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ # A restaurant menu on one page: an introduction wrapped around a floated
4
+ # photo, the dishes poured through two balanced columns, a floated box for
5
+ # the dish of the day, and headings set in the heavier weights of Inter. Run
6
+ # it to write examples/menu.pdf:
7
+ #
8
+ # ruby -Ilib examples/menu.rb
9
+ # ruby -Ilib exe/stationery render examples/menu.rb
10
+ #
11
+ # The type is the bundled Inter. For a font pack, install it
12
+ # (`stationery fonts install noto_serif`) and name it:
13
+ # `font_family "Noto Serif"`, then `default_text font: "Noto Serif"`.
14
+ require "stationery"
15
+
16
+ class ExampleMenu < Stationery::Document
17
+ PHOTO = File.expand_path("assets/bay.png", __dir__)
18
+ INK = "#1C1917"
19
+ MUTED = "#78716C"
20
+ ACCENT = "#9A3412"
21
+ CREAM = "#FFF7ED"
22
+ LINE = "#E7E5E4"
23
+
24
+ COURSES = {
25
+ "Starters" => [
26
+ ["Smoked mackerel", "Rye crisp, pickled cucumber, horseradish cream", 145],
27
+ ["Beetroot tartare", "Goat's cheese, walnuts, dill oil", 135],
28
+ ["Fish soup", "Saffron broth, mussels, aioli, toasted sourdough", 165],
29
+ ["Chanterelles on toast", "Brown butter, thyme, aged cheese", 155]
30
+ ],
31
+ "Mains" => [
32
+ ["Pan-fried cod", "Brown butter, capers, new potatoes, peas", 295],
33
+ ["Slow-cooked lamb", "Root vegetables, rosemary jus, crispy kale", 325],
34
+ ["Barley risotto", "Mushrooms, parsley, pecorino, lemon", 245],
35
+ ["Char with sorrel sauce", "Fennel, dill potatoes, roe", 310],
36
+ ["Harbour burger", "Aged beef, cheddar, onions, fries", 225]
37
+ ],
38
+ "Desserts" => [
39
+ ["Cloudberry parfait", "Almond crumble, warm cloudberries", 125],
40
+ ["Chocolate cake", "Sea salt, crème fraîche", 115],
41
+ ["Cheese from the island", "Three cheeses, crispbread, quince", 145]
42
+ ],
43
+ "To drink" => [
44
+ ["House lemonade", "Elderflower or rhubarb", 55],
45
+ ["Local cider", "Dry, 33 cl", 85],
46
+ ["Coffee or tea", "Refills are free", 40]
47
+ ],
48
+ "Wine by the glass" => [
49
+ ["Grüner Veltliner", "Crisp and peppery, with the fish", 125],
50
+ ["Pinot noir", "Light and red-fruited, with the lamb", 135],
51
+ ["Sparkling cider", "Méthode traditionnelle, from the island", 110]
52
+ ]
53
+ }.freeze
54
+
55
+ page size: :a4, margin: [56, 56, 48, 56]
56
+ default_text size: 9.5, color: INK, leading: 2
57
+ metadata title: "Menu · The Harbour Kitchen", author: "The Harbour Kitchen", creator: "stationery example"
58
+
59
+ def self.preview = new(special: ["Grilled herring", "Mustard sauce, mashed potatoes, lingonberries", 185])
60
+
61
+ def initialize(special:)
62
+ super()
63
+ @special = special
64
+ end
65
+
66
+ def view_template
67
+ masthead
68
+ introduction
69
+ spacer 16
70
+ # One flow of courses poured through two columns: column one top to
71
+ # bottom, then column two, ending level where the dishes run out.
72
+ columns(count: 2, gap: 28, rule: { color: LINE }) do
73
+ COURSES.each { |course, dishes| course(course, dishes) }
74
+ end
75
+ spacer 14
76
+ rule color: LINE
77
+ spacer 6
78
+ text "Tell us about allergies before you order. All prices in SEK, service included.",
79
+ size: 8, color: MUTED, align: :center
80
+ end
81
+
82
+ private
83
+
84
+ def masthead
85
+ text "THE HARBOUR KITCHEN", size: 10, weight: :bold, color: ACCENT, letter_spacing: 3, align: :center
86
+ spacer 4
87
+ text "Autumn menu", size: 34, weight: :bold, align: :center
88
+ spacer 14
89
+ end
90
+
91
+ # The photo floats left and the text wraps beside it; the dish of the day
92
+ # floats right, between them, in a box of its own.
93
+ def introduction
94
+ image PHOTO, float: :left, width: 0.34, height: 104, fit: :cover, radius: 6, margin: 14
95
+ box(float: :right, width: 150, margin: { left: 14, bottom: 6 }, background: CREAM, radius: 6, padding: 10) do
96
+ text "DISH OF THE DAY", size: 7, weight: :bold, color: ACCENT, letter_spacing: 1
97
+ spacer 3
98
+ name, description, price = @special
99
+ text name, size: 11, weight: :bold
100
+ text description, size: 8.5, color: MUTED
101
+ spacer 3
102
+ text price.to_s, weight: :bold, color: ACCENT
103
+ end
104
+ text "Everything on this menu comes from within a day's drive of the harbour: the fish from the boats " \
105
+ "you can see from your table, the vegetables from two farms on the island and the cheese from the " \
106
+ "dairy up the hill. The menu changes with the catch and the season, so some dishes may run out " \
107
+ "before the evening does.", align: :justify, hyphenate: true, size: 10.5, leading: 3
108
+ end
109
+
110
+ # A course is kept together, so a column never starts in the middle of one.
111
+ def course(title, dishes)
112
+ group(keep_together: true) do
113
+ text title, size: 18, weight: :bold, color: ACCENT
114
+ spacer 8
115
+ dishes.each do |name, description, price|
116
+ row(gap: 8) do
117
+ column { text name, size: 10.5, weight: :bold }
118
+ column(width: :auto) { text price.to_s, size: 10.5, weight: :bold }
119
+ end
120
+ text description, size: 9, color: MUTED, style: :italic
121
+ spacer 10
122
+ end
123
+ spacer 14
124
+ end
125
+ end
126
+ end
127
+
128
+ if $PROGRAM_NAME == __FILE__
129
+ menu = ExampleMenu.preview
130
+ menu.to_pdf(File.expand_path("menu.pdf", __dir__))
131
+ warn menu.warnings.map(&:message) if menu.warnings.any?
132
+ puts "wrote examples/menu.pdf"
133
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ # A notice to residents in English, German and Swedish side by side, each
4
+ # column justified and hyphenated by the patterns of its own language, with
5
+ # a Japanese paragraph when a CJK font is at hand. Run it to write
6
+ # examples/multilingual_notice.pdf:
7
+ #
8
+ # ruby -Ilib examples/multilingual_notice.rb
9
+ # ruby -Ilib exe/stationery render examples/multilingual_notice.rb
10
+ #
11
+ # Inter, the font the gem bundles, has Latin, Greek and Cyrillic but no CJK
12
+ # ideographs, and no font pack has them either. Name a font that has them to
13
+ # add the Japanese paragraph; it is registered as a fallback, so it draws only
14
+ # the characters Inter lacks, and the lines break between ideographs:
15
+ #
16
+ # CJK_FONT=/path/to/NotoSansJP-Regular.ttf ruby -Ilib examples/multilingual_notice.rb
17
+ require "stationery"
18
+
19
+ class ExampleMultilingualNotice < Stationery::Document
20
+ ACCENT = "#0E7490"
21
+ INK = "#111827"
22
+ MUTED = "#6B7280"
23
+ PANEL = "#ECFEFF"
24
+ CJK_FONT = ENV.fetch("CJK_FONT", nil)
25
+
26
+ # Each language with the hyphenation patterns to break its words by:
27
+ # `true` is American English, "de" German and "sv" Swedish.
28
+ NOTICES = [
29
+ ["English", true, "Water will be turned off",
30
+ ["The water supply to every flat in the building will be turned off on Tuesday 14 October between " \
31
+ "08:00 and 16:00 while the main distribution pipes in the basement are replaced.",
32
+ "Please fill a few containers with drinking water beforehand and keep the taps closed during the " \
33
+ "work. Discoloured water may run for a short while afterwards; let the cold tap run until it is clear."]],
34
+ ["Deutsch", "de", "Das Wasser wird abgestellt",
35
+ ["Am Dienstag, dem 14. Oktober, wird die Wasserversorgung aller Wohnungen im Gebäude zwischen 08:00 " \
36
+ "und 16:00 Uhr unterbrochen, da die Hauptverteilungsleitungen im Keller erneuert werden.",
37
+ "Bitte füllen Sie vorher einige Behälter mit Trinkwasser und lassen Sie die Wasserhähne während der " \
38
+ "Arbeiten geschlossen. Danach kann das Wasser kurzzeitig verfärbt sein; lassen Sie das kalte Wasser " \
39
+ "laufen, bis es klar ist."]],
40
+ ["Svenska", "sv", "Vattnet stängs av",
41
+ ["Tisdagen den 14 oktober stängs vattnet av i alla lägenheter i huset mellan klockan 08.00 och 16.00, " \
42
+ "medan huvudledningarna i källaren byts ut.",
43
+ "Fyll gärna några kärl med dricksvatten i förväg och håll kranarna stängda under arbetet. Efteråt kan " \
44
+ "vattnet vara missfärgat en kort stund; spola kallvatten tills det är klart."]]
45
+ ].freeze
46
+
47
+ JAPANESE = "10月14日(火)8時から16時まで、地下の給水管の交換工事のため、建物内のすべての住戸で断水します。" \
48
+ "事前に飲料水をご用意いただき、工事中は蛇口を閉めておいてください。工事後、しばらく濁った水が出ることが" \
49
+ "ありますので、透明になるまで水を流してください。"
50
+
51
+ page size: :a4, margin: [52, 48, 44, 48]
52
+ default_text font: "Inter", size: 10.5, color: INK, leading: 3
53
+ metadata title: "Notice to residents", author: "Example Housing Cooperative", creator: "stationery example"
54
+
55
+ # A font for what Inter lacks: drawn only where Inter has no glyph.
56
+ if CJK_FONT
57
+ font_family "CJK", regular: CJK_FONT
58
+ font_fallbacks "CJK"
59
+ end
60
+
61
+ def self.preview = new
62
+
63
+ def view_template
64
+ text "NOTICE · HINWEIS · MEDDELANDE", size: 9, weight: :bold, color: ACCENT, letter_spacing: 2
65
+ spacer 6
66
+ text "Harbour View Housing Cooperative, 1–5 Example Street", size: 10, color: MUTED
67
+ spacer 8
68
+ rule height: 2, color: ACCENT
69
+ spacer 16
70
+ row(gap: 18) do
71
+ NOTICES.each do |language, hyphenate, title, paragraphs|
72
+ column { notice(language, hyphenate, title, paragraphs) }
73
+ end
74
+ end
75
+ spacer 18
76
+ japanese
77
+ spacer 18
78
+ contact
79
+ end
80
+
81
+ private
82
+
83
+ def notice(language, hyphenate, title, paragraphs)
84
+ text language.upcase, size: 7.5, weight: :bold, color: MUTED, letter_spacing: 1
85
+ spacer 4
86
+ text title, size: 15, weight: :bold, color: ACCENT
87
+ spacer 6
88
+ paragraphs.each do |paragraph|
89
+ text paragraph, align: :justify, hyphenate: hyphenate
90
+ spacer 6
91
+ end
92
+ end
93
+
94
+ def japanese
95
+ box(background: PANEL, radius: 6, padding: 12) do
96
+ text "JAPANESE", size: 7.5, weight: :bold, color: MUTED, letter_spacing: 1
97
+ spacer 4
98
+ if CJK_FONT
99
+ text JAPANESE, leading: 4 # Inter for the digits, the CJK font for the rest
100
+ else
101
+ text "Japanese: set CJK_FONT to a font with Japanese glyphs to include this paragraph. Inter, the " \
102
+ "font in the gem, has none; see the comment at the top of examples/multilingual_notice.rb.",
103
+ size: 8.5, color: MUTED
104
+ end
105
+ end
106
+ end
107
+
108
+ def contact
109
+ rule color: "#E5E7EB"
110
+ spacer 6
111
+ text "Questions · Fragen · Frågor: caretaker@harbour-view.example · +46 31 000 00 00",
112
+ size: 8.5, color: MUTED, align: :center
113
+ end
114
+ end
115
+
116
+ if $PROGRAM_NAME == __FILE__
117
+ notice = ExampleMultilingualNotice.preview
118
+ notice.to_pdf(File.expand_path("multilingual_notice.pdf", __dir__))
119
+ warn notice.warnings.map(&:message) if notice.warnings.any?
120
+ puts "wrote examples/multilingual_notice.pdf"
121
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ # A one-page newsletter: a masthead across the page, an article poured
4
+ # through two balanced columns with headings and a photo, and a footer note
5
+ # across the page below the columns.
6
+ # Run it to write examples/newsletter.pdf:
7
+ #
8
+ # ruby -Ilib examples/newsletter.rb
9
+ # ruby -Ilib exe/stationery render examples/newsletter.rb
10
+ require "stationery"
11
+
12
+ class ExampleNewsletter < Stationery::Document
13
+ ASSETS = File.expand_path("assets", __dir__)
14
+ INK = "#1F2937"
15
+ MUTED = "#6B7280"
16
+ ACCENT = "#0F766E"
17
+ LINE = "#D1D5DB"
18
+
19
+ ARTICLE = [
20
+ [nil, ["The harbour wall that has kept the winter swell out of the village since 1911 was reopened on " \
21
+ "Saturday, eighteen months after a storm took forty metres of it into the bay. Two hundred people " \
22
+ "walked its length behind the brass band, and the first boat through the gap was the oldest in the " \
23
+ "fleet.",
24
+ "The rebuilt section stands a metre higher than the old one. Its core is concrete, but the face is " \
25
+ "the original sandstone: divers recovered nine blocks in ten from the seabed, and the quarry above " \
26
+ "the village was opened for a month to cut the rest."]],
27
+ ["What it cost", ["The work came to 2.4 million, a third of it raised by the village itself. The harbour " \
28
+ "dues will rise by four per cent a year until 2031 to repay the loan, a rise the " \
29
+ "fishing cooperative voted for without a single vote against.",
30
+ "Nothing was spent on the lighthouse, which needs a new lantern. The council has asked " \
31
+ "for tenders and expects to decide in the spring."]],
32
+ ["What comes next", ["The inner quay is next. Its timber piles date from the fifties and the survey gives " \
33
+ "them ten years at most. Plans go on show in the old customs house in March, and " \
34
+ "the council wants comments before the summer.",
35
+ "Until then the wall is open to walkers from dawn to dusk. The gate at the landward " \
36
+ "end is locked in any wind above force seven."]]
37
+ ].freeze
38
+
39
+ page size: :a4, margin: 48
40
+ default_text size: 10, color: INK, leading: 2.5
41
+ metadata title: "The Harbour Letter", author: "stationery example", lang: "en"
42
+ tagged
43
+
44
+ def self.preview = new
45
+
46
+ def view_template
47
+ masthead
48
+ columns(count: 2, gap: 22, rule: { color: LINE }) { article }
49
+ note
50
+ end
51
+
52
+ private
53
+
54
+ def masthead
55
+ text "THE HARBOUR LETTER", size: 9, weight: :bold, color: ACCENT, letter_spacing: 1.5
56
+ spacer 6
57
+ text "The sea wall is whole again", size: 30, weight: :bold, leading: 0, heading: 1
58
+ spacer 6
59
+ text "Issue 42 · October 2026", size: 9, color: MUTED
60
+ spacer 10
61
+ rule height: 2, color: ACCENT
62
+ spacer 16
63
+ end
64
+
65
+ def article
66
+ ARTICLE.each_with_index do |(title, paragraphs), index|
67
+ section_heading(title) if title
68
+ paragraphs.each { |paragraph| body(paragraph) }
69
+ photo if index.zero?
70
+ end
71
+ end
72
+
73
+ def section_heading(title)
74
+ text title, size: 13, weight: :bold, color: ACCENT, leading: 0, heading: 2, keep_with_next: true
75
+ spacer 5
76
+ end
77
+
78
+ def body(paragraph)
79
+ text paragraph, align: :justify, hyphenate: true, orphans: 2, widows: 2
80
+ spacer 8
81
+ end
82
+
83
+ def photo
84
+ group(keep_together: true) do
85
+ image File.join(ASSETS, "bay.png"), width: 1.0, height: 120, fit: :cover, radius: 6,
86
+ alt: "The bay seen from the rebuilt wall"
87
+ spacer 4
88
+ text "The bay from the new section of the wall.", size: 8, color: MUTED, style: :italic
89
+ end
90
+ spacer 8
91
+ end
92
+
93
+ def note
94
+ spacer 16
95
+ rule color: LINE
96
+ spacer 8
97
+ text "The Harbour Letter is written by volunteers and printed by the cooperative. Letters to the " \
98
+ "editor are welcome at the customs house, where back issues are kept.",
99
+ size: 8.5, color: MUTED, align: :center
100
+ end
101
+ end
102
+
103
+ if $PROGRAM_NAME == __FILE__
104
+ newsletter = ExampleNewsletter.preview
105
+ newsletter.to_pdf(File.expand_path("newsletter.pdf", __dir__))
106
+ warn newsletter.warnings.map(&:message) if newsletter.warnings.any?
107
+ puts "wrote examples/newsletter.pdf"
108
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ # A landscape packing slip: a long table that repeats its header on every
4
+ # page and a barcode drawn on a canvas. Run it to write examples/packing_slip.pdf:
5
+ #
6
+ # ruby -Ilib examples/packing_slip.rb
7
+ # ruby -Ilib exe/stationery render examples/packing_slip.rb
8
+ require "stationery"
9
+
10
+ class ExamplePackingSlip < Stationery::Document
11
+ ACCENT = "#1D4ED8"
12
+ INK = "#111827"
13
+ MUTED = "#6B7280"
14
+ HAIRLINE = "#E5E7EB"
15
+ ZEBRA = "#F3F4F6"
16
+ PANEL = "#F9FAFB"
17
+
18
+ PRODUCTS = ["Hex bolt M8 × 40, zinc plated", "Washer M8, stainless A2", "Cable tie 200 mm, black",
19
+ "Wall plug 8 mm", "Wood screw 5 × 60, Torx", "Hinge 90 mm, brushed steel",
20
+ "Shelf bracket 200 × 250 mm, white powder coat, including screws and wall plugs for concrete",
21
+ "Drawer runner 450 mm, soft close", "Threaded rod M10 × 1000", "Nylon lock nut M10",
22
+ "Corner brace 40 mm", "Cabinet handle 128 mm, matt black aluminium, for doors up to 22 mm thick"].freeze
23
+
24
+ page size: :a4, layout: :landscape, margin: [36, 40, 32, 40]
25
+ default_text size: 9, color: INK
26
+ metadata title: "Packing slip", creator: "stationery example"
27
+
28
+ header(gap: 14) do |page|
29
+ row(align: :bottom) do
30
+ text "Packing slip · Order NW-2026-004817", size: 13, weight: :bold
31
+ text "Page #{page.number} of #{page.count}", size: 8, color: MUTED, align: :right
32
+ end
33
+ spacer 6
34
+ rule height: 1.5, color: ACCENT
35
+ end
36
+
37
+ footer { text "Check the contents against this slip within 5 days of delivery.", size: 7, color: MUTED }
38
+
39
+ def self.preview
40
+ items = Array.new(120) do |i|
41
+ sku = "NW-#{10_000 + (i * 37)}"
42
+ bin = "#{"ABCDEF"[i % 6]}-#{((i % 14) + 1).to_s.rjust(2, "0")}"
43
+ [sku, PRODUCTS[i % PRODUCTS.size], bin, ((i * 7) % 24) + 1, (((i * 13) % 40) + 5) / 10.0]
44
+ end
45
+ new(order: "NW-2026-004817", items:,
46
+ ship_to: ["Hallberg Bygg AB", "Goods reception, gate 4", "Industrigatan 8", "753 23 Uppsala", "Sweden"],
47
+ bill_to: ["Hallberg Bygg AB", "Accounts payable", "Box 1204", "751 42 Uppsala", "Sweden"])
48
+ end
49
+
50
+ def initialize(order:, items:, ship_to:, bill_to:)
51
+ super()
52
+ @order = order
53
+ @items = items
54
+ @ship_to = ship_to
55
+ @bill_to = bill_to
56
+ end
57
+
58
+ def view_template
59
+ row(gap: 16) do
60
+ column(width: 0.35) { address("SHIP TO", @ship_to) }
61
+ column(width: 0.35) { address("BILL TO", @bill_to) }
62
+ column { barcode }
63
+ end
64
+ spacer 18
65
+ items_table
66
+ end
67
+
68
+ private
69
+
70
+ def address(label, lines)
71
+ box(background: PANEL, border: { color: HAIRLINE, width: 1 }, radius: 6, padding: [10, 12]) do
72
+ text label, size: 7.5, weight: :bold, color: MUTED, letter_spacing: 0.6
73
+ spacer 4
74
+ text "<b>#{lines.first}</b>\n#{lines.drop(1).join("\n")}", markup: true, leading: 1.5
75
+ end
76
+ end
77
+
78
+ def barcode
79
+ digits = @order.delete("^0-9").chars.map(&:to_i)
80
+ canvas(height: 40) do |canvas, rect|
81
+ x = rect.x
82
+ digits.cycle(3).each_with_index do |digit, index|
83
+ width = 1 + (digit % 3)
84
+ canvas.fill_rect(x, rect.y, width, rect.height, color: INK) if index.even?
85
+ x += width + 1
86
+ end
87
+ end
88
+ spacer 4
89
+ text @order, size: 8, color: MUTED, letter_spacing: 2
90
+ spacer 8
91
+ text "Shipped 27 September 2026 · 3 parcels · Example Freight", size: 8, color: MUTED
92
+ end
93
+
94
+ def items_table
95
+ rows = [["SKU", "Description", "Bin", "Qty", "Unit (kg)", "Weight (kg)"]]
96
+ @items.each do |sku, name, bin, qty, weight|
97
+ rows << [sku, name, bin, qty.to_s, kilograms(weight), kilograms(qty * weight)]
98
+ end
99
+ rows << [{ content: "#{@items.size} lines", colspan: 3 }, @items.sum { |item| item[3] }.to_s, "",
100
+ kilograms(@items.sum { |*, qty, weight| qty * weight })]
101
+
102
+ table(rows, header: true, split_rows: true, width: :full, widths: [80, 300, nil, 60, 70, 80],
103
+ cell: { padding: [2.5, 8], borders: [], size: 8.5 }) do |t|
104
+ t.row(0).set(background: ACCENT, color: "#FFFFFF", weight: :bold)
105
+ t.columns(0).rows(1..).color = MUTED
106
+ t.columns(3..).align = :right
107
+ t.zebra(from: 1, to: @items.size, color: ZEBRA)
108
+ t.row(-1).set(weight: :bold, color: INK, borders: [:top], border_color: INK, border_width: 1)
109
+ end
110
+ end
111
+
112
+ def kilograms(value) = format("%.1f", value)
113
+ end
114
+
115
+ if $PROGRAM_NAME == __FILE__
116
+ slip = ExamplePackingSlip.preview
117
+ slip.to_pdf(File.expand_path("packing_slip.pdf", __dir__))
118
+ warn slip.warnings.map(&:message) if slip.warnings.any?
119
+ puts "wrote examples/packing_slip.pdf"
120
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ # An A5 landscape postcard: a photo collage of one large picture with two
4
+ # tilted, white-framed snapshots overlapping its corners, beside a greeting.
5
+ # Run it to write examples/postcard.pdf:
6
+ #
7
+ # ruby -Ilib examples/postcard.rb
8
+ # ruby -Ilib exe/stationery render examples/postcard.rb
9
+ require "stationery"
10
+
11
+ class ExamplePostcard < Stationery::Document
12
+ ASSETS = File.expand_path("assets", __dir__)
13
+ PAPER = "#FFFBEB"
14
+ INK = "#1F2937"
15
+ MUTED = "#6B7280"
16
+ ACCENT = "#B45309"
17
+ OVERHANG = 18
18
+
19
+ page size: :a5, layout: :landscape, margin: 36
20
+ default_text size: 10.5, color: INK
21
+ metadata title: "Greetings from Sardinia", author: "stationery example", lang: "en"
22
+ tagged
23
+
24
+ # Cream card stock: a background layer painted under every page.
25
+ page_template(layer: :background) do |page|
26
+ canvas(at: [0, 0], width: page.width, height: page.height) do |c, rect|
27
+ c.fill_rect(rect.x, rect.y, rect.width, rect.height, color: PAPER)
28
+ end
29
+ end
30
+
31
+ def self.preview = new
32
+
33
+ def view_template
34
+ spacer 30
35
+ row(gap: 20, align: :middle) do
36
+ column(width: 0.55) { collage }
37
+ column { greeting }
38
+ end
39
+ end
40
+
41
+ private
42
+
43
+ # The site-style collage: a base photo with rounded corners and a shadow, a
44
+ # 4:3 snapshot hanging off the bottom-left corner and a square one off the
45
+ # top-right, each tilted in its white frame. The stack's layers overhang by
46
+ # OVERHANG points, so the box around it keeps them on the page.
47
+ def collage
48
+ box(padding: OVERHANG) do
49
+ stack do
50
+ box(width: :auto, radius: 16, shadow: true) do
51
+ image photo("sea"), width: 228, height: 171, fit: :cover, radius: 16, alt: "The bay at dawn"
52
+ end
53
+ layer(bottom: -OVERHANG, left: -OVERHANG, width: 0.4, rotate: -3, **frame) do
54
+ image photo("hills"), width: 83, height: 62, fit: :cover, radius: 8, alt: "Hills above the village"
55
+ end
56
+ layer(top: -OVERHANG, right: -OVERHANG, width: 1/3r, rotate: 2, **frame) do
57
+ image photo("stone"), width: 68, height: 68, fit: :cover, radius: 8, alt: "A sandstone wall"
58
+ end
59
+ end
60
+ end
61
+ end
62
+
63
+ def frame = { padding: 4, background: "#FFFFFF", radius: 12, shadow: true }
64
+ def photo(name) = File.join(ASSETS, "#{name}.png")
65
+
66
+ def greeting
67
+ text "Greetings from Sardinia", size: 22, weight: :bold, color: ACCENT, heading: 1
68
+ spacer 6
69
+ text "October 2026", size: 9, color: MUTED
70
+ spacer 14
71
+ text "The sea is still warm enough to swim before breakfast, the hills smell of myrtle and the village " \
72
+ "square fills up every evening. Wish you were here.", leading: 3
73
+ spacer 14
74
+ text "With love, the whole crew", style: :italic, color: MUTED
75
+ end
76
+ end
77
+
78
+ if $PROGRAM_NAME == __FILE__
79
+ postcard = ExamplePostcard.preview
80
+ postcard.to_pdf(File.expand_path("postcard.pdf", __dir__))
81
+ warn postcard.warnings.map(&:message) if postcard.warnings.any?
82
+ puts "wrote examples/postcard.pdf"
83
+ end