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,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ # A survey report written for screen readers as a PDF/UA-1 file:
4
+ # headings in order, a chart and a photo as figures with their `alt:` text,
5
+ # a table whose header cells a reader announces with every value, and a
6
+ # language and title of its own. `rake verify:conformance` validates it with
7
+ # veraPDF. Run it to write examples/accessible_report.pdf:
8
+ #
9
+ # ruby -Ilib examples/accessible_report.rb
10
+ # ruby -Ilib exe/stationery render examples/accessible_report.rb
11
+ require "stationery"
12
+
13
+ class ExampleAccessibleReport < Stationery::Document
14
+ PHOTO = File.expand_path("assets/hills.png", __dir__)
15
+ ACCENT = "#1D4ED8"
16
+ INK = "#111827"
17
+ MUTED = "#4B5563"
18
+ HAIRLINE = "#D1D5DB"
19
+ ZEBRA = "#F3F4F6"
20
+
21
+ page size: :a4, margin: [56, 60, 48, 60]
22
+ default_text size: 10.5, color: INK, leading: 3
23
+ # PDF/UA-1 asks for a title and a language, and turns tagging on. What it
24
+ # cannot keep (a figure without `alt:`, a heading level skipped) raises
25
+ # instead of writing a file that claims more than it is.
26
+ metadata title: "Cycling to school: survey 2026", author: "Example County Council", lang: "en",
27
+ creator: "stationery example"
28
+ conformance :pdf_ua1
29
+
30
+ footer { |page| text "Page #{page.number} of #{page.count}", size: 8, color: MUTED, align: :center }
31
+
32
+ def self.preview
33
+ new(schools: [["Northfield", 412, 38, 44], ["Riverside", 288, 51, 57], ["Hillcrest", 530, 22, 31],
34
+ ["Harbour", 196, 44, 49], ["Meadow Lane", 351, 29, 41]])
35
+ end
36
+
37
+ def initialize(schools:)
38
+ super()
39
+ @schools = schools
40
+ end
41
+
42
+ def view_template
43
+ text "Cycling to school", size: 26, weight: :bold, heading: 1
44
+ text "Results of the 2026 travel survey in five primary schools", size: 13, color: MUTED
45
+ spacer 18
46
+ summary
47
+ findings
48
+ method_section
49
+ end
50
+
51
+ private
52
+
53
+ def heading(title, level: 2)
54
+ spacer 14
55
+ text title, size: level == 2 ? 16 : 12.5, weight: :bold, color: ACCENT, heading: level, keep_with_next: 60
56
+ spacer 6
57
+ end
58
+
59
+ def summary
60
+ heading "Summary"
61
+ text "More children cycle to school than a year ago in every school surveyed. Across the five schools the " \
62
+ "share rose from 35 to 45 per cent after the new cycle paths opened in March. The largest change was " \
63
+ "at Hillcrest, where a safe crossing on the main road was the most requested improvement."
64
+ spacer 10
65
+ image PHOTO, width: 1.0, height: 120, fit: :cover, radius: 4,
66
+ alt: "Green hills under a yellow morning sky: the valley the new cycle paths run through"
67
+ end
68
+
69
+ def findings
70
+ heading "Findings"
71
+ heading "Share of pupils who cycle", level: 3
72
+ svg chart, width: 475, color: ACCENT,
73
+ alt: "Bar chart of the share of pupils who cycle, 2025 and 2026. Northfield 38 to 44 per cent, " \
74
+ "Riverside 51 to 57, Hillcrest 22 to 31, Harbour 44 to 49, Meadow Lane 29 to 41."
75
+ spacer 4
76
+ text "Light bars 2025, dark bars 2026.", size: 8.5, color: MUTED
77
+ heading "By school", level: 3
78
+ school_table
79
+ end
80
+
81
+ # A header row is tagged as TH cells with a column scope, so a screen
82
+ # reader names the column with every value it reads.
83
+ def school_table
84
+ rows = [["School", "Pupils", "Cycling 2025", "Cycling 2026", "Change"]]
85
+ @schools.each do |name, pupils, before, after|
86
+ rows << [name, pupils.to_s, "#{before}%", "#{after}%", format("%+d points", after - before)]
87
+ end
88
+ table(rows, header: true, width: :full, widths: [nil, 60, 80, 80, 70],
89
+ cell: { padding: [5, 8], borders: [:bottom], border_color: HAIRLINE, size: 9.5 }) do |t|
90
+ t.row(0).set(weight: :bold, border_color: INK)
91
+ t.columns(1..).align = :right
92
+ t.zebra(from: 1, color: ZEBRA)
93
+ end
94
+ end
95
+
96
+ def method_section
97
+ heading "Method"
98
+ text "Every pupil in years three to six answered five questions in class in the first week of May, as in " \
99
+ "2025. Answers were collected on paper and entered twice by different people. The response rate " \
100
+ "was 94 per cent; absent pupils were not followed up."
101
+ spacer 8
102
+ ul(gap: 4, marker_color: ACCENT) do
103
+ li "Cycling means cycling for at least part of the way on three days or more of the week."
104
+ li "Pupils who walk and cycle on different days were counted as cycling."
105
+ li "Figures are rounded to whole percentage points."
106
+ end
107
+ end
108
+
109
+ # The bars as SVG, 2025 light and 2026 in the accent colour (currentColor).
110
+ def chart
111
+ bars = @schools.each_with_index.map do |(name, _, before, after), index|
112
+ x = 40 + (index * 90)
113
+ <<~BAR
114
+ <rect x="#{x}" y="#{150 - (before * 2)}" width="30" height="#{before * 2}" fill="#BFDBFE"/>
115
+ <rect x="#{x + 32}" y="#{150 - (after * 2)}" width="30" height="#{after * 2}" fill="currentColor"/>
116
+ <text x="#{x + 31}" y="168" font-size="11" text-anchor="middle" fill="#4B5563">#{name}</text>
117
+ BAR
118
+ end
119
+ <<~SVG
120
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 490 175">
121
+ <line x1="30" y1="150" x2="490" y2="150" stroke="#9CA3AF"/>
122
+ #{bars.join}
123
+ </svg>
124
+ SVG
125
+ end
126
+ end
127
+
128
+ if $PROGRAM_NAME == __FILE__
129
+ report = ExampleAccessibleReport.preview
130
+ report.to_pdf(File.expand_path("accessible_report.pdf", __dir__))
131
+ warn report.warnings.map(&:message) if report.warnings.any?
132
+ puts "wrote examples/accessible_report.pdf"
133
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ # A magazine page: justified text wrapping around a floated photo, a pull
4
+ # quote floated to the right and a snapshot floated by CSS inside `html`.
5
+ # Run it to write examples/article.pdf:
6
+ #
7
+ # ruby -Ilib examples/article.rb
8
+ # ruby -Ilib exe/stationery render examples/article.rb
9
+ require "stationery"
10
+
11
+ class ExampleArticle < Stationery::Document
12
+ ASSETS = File.expand_path("assets", __dir__)
13
+ INK = "#1F2937"
14
+ MUTED = "#6B7280"
15
+ ACCENT = "#B45309"
16
+ WASH = "#FEF3C7"
17
+
18
+ PARAGRAPHS = [
19
+ "The road to the bay ends where the asphalt does. From there a track of red earth runs between dry-stone " \
20
+ "walls, past a shuttered farm and a row of prickly pears, and then the land simply stops: a shelf of pale " \
21
+ "rock, the water beyond it green over sand and blue over weed. Nobody built anything here. The nearest " \
22
+ "village is an hour away on foot, and it has never been in a hurry to come closer.",
23
+ "We walked out in the cool of the morning with bread, tomatoes and two litres of water each. The shepherd " \
24
+ "we met at the last gate counted us, counted the bottles, and let us through with a nod. By ten the sun " \
25
+ "was over the ridge and the cicadas had started; by eleven the only shade on the whole shore was the one " \
26
+ "we carried with us, a square of canvas on four driftwood poles.",
27
+ "What strikes a visitor from the north is how little the place asks of you. There is no path to follow " \
28
+ "and no view to find, because the view is everywhere. You swim, you dry, you read a page and lose your " \
29
+ "place. The afternoon wind arrives at three, as it has every day since May, and turns the bay from glass " \
30
+ "to hammered pewter in the time it takes to look up.",
31
+ "On the way back the shepherd was waiting with a bag of figs. He would not take money for them. He asked " \
32
+ "instead whether the water had been cold, and when we said no, not at all, he laughed and said that it " \
33
+ "would be by November, and that November was the month to come."
34
+ ].freeze
35
+
36
+ NOTES = <<~HTML
37
+ <h2>Getting there</h2>
38
+ <p><img src="stone.png" alt="A sandstone wall beside the track" style="float: right; width: 96pt;
39
+ margin: 0 0 6pt 12pt">Leave the car at the last farm and carry what you need: there is no water on the
40
+ shore and no shade before the evening. The track is easy underfoot, but it is an hour each way and the
41
+ walls beside it keep the wind out. Start before eight, turn back by five, and close every gate behind
42
+ you.</p>
43
+ HTML
44
+
45
+ page size: :a4, margin: [64, 72]
46
+ default_text size: 10.5, color: INK, leading: 3, hyphenate: :en
47
+ metadata title: "An hour from the road", author: "stationery example", lang: "en"
48
+ tagged
49
+
50
+ def self.preview = new
51
+
52
+ def view_template
53
+ text "TRAVEL", size: 8, weight: :bold, color: ACCENT, letter_spacing: 1.5
54
+ text "An hour from the road", size: 28, weight: :bold, heading: 1, leading: 0
55
+ text "A bay in the south of Sardinia that the asphalt never reached", size: 12, color: MUTED
56
+ spacer 8
57
+ rule height: 2, color: ACCENT, width: 48
58
+ spacer 14
59
+ story
60
+ spacer 6
61
+ html NOTES, base_path: ASSETS, styles: { h2: { color: ACCENT, scale: 1.3 } }
62
+ end
63
+
64
+ private
65
+
66
+ # The photo floats left at the top of the first paragraph; the pull quote
67
+ # floats right where it is written, between the second and the third.
68
+ def story
69
+ group(gap: 9) do
70
+ image photo("sea"), float: :left, width: 0.42, margin: { right: 14, bottom: 8 }, radius: 6,
71
+ alt: "The bay from the last gate, early in the morning"
72
+ PARAGRAPHS.first(2).each { |paragraph| text paragraph, align: :justify }
73
+ pull_quote "There is no path to follow and no view to find, because the view is everywhere."
74
+ PARAGRAPHS.drop(2).each { |paragraph| text paragraph, align: :justify }
75
+ end
76
+ end
77
+
78
+ def pull_quote(words)
79
+ box(float: :right, width: 170, margin: { left: 16, bottom: 6 }, padding: [10, 12], background: WASH,
80
+ border: { sides: [:left], width: 3, color: ACCENT }, role: :blockquote) do
81
+ text words, size: 13, style: :italic, color: ACCENT, leading: 2, hyphenate: nil
82
+ end
83
+ end
84
+
85
+ def photo(name) = File.join(ASSETS, "#{name}.png")
86
+ end
87
+
88
+ if $PROGRAM_NAME == __FILE__
89
+ article = ExampleArticle.preview
90
+ article.to_pdf(File.expand_path("article.pdf", __dir__))
91
+ warn article.warnings.map(&:message) if article.warnings.any?
92
+ puts "wrote examples/article.pdf"
93
+ end
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ # A certificate of completion on a landscape A4 page: a frame and rosettes
4
+ # drawn on a canvas under the content, centred type, and two signature fields
5
+ # for the people who sign it. Run it to write examples/certificate.pdf:
6
+ #
7
+ # ruby -Ilib examples/certificate.rb
8
+ # ruby -Ilib exe/stationery render examples/certificate.rb
9
+ require "stationery"
10
+
11
+ class ExampleCertificate < Stationery::Document
12
+ GOLD = "#B08D57"
13
+ INK = "#1F2937"
14
+ MUTED = "#6B7280"
15
+ PAPER = "#FFFBF2"
16
+ SEAL = <<~SVG
17
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
18
+ <circle cx="50" cy="50" r="48" fill="#B08D57"/>
19
+ <circle cx="50" cy="50" r="40" fill="none" stroke="#FFFBF2" stroke-width="1.5" stroke-dasharray="3 2"/>
20
+ <path d="M50 24l7.6 15.4 17 2.5-12.3 12 2.9 16.9L50 62.8l-15.2 8 2.9-16.9-12.3-12 17-2.5z" fill="#FFFBF2"/>
21
+ </svg>
22
+ SVG
23
+
24
+ page size: :a4, layout: :landscape, margin: [112, 110, 72, 110]
25
+ default_text color: INK, align: :center
26
+ metadata title: "Certificate of completion", author: "Harbour Institute of Sailing", creator: "stationery example"
27
+
28
+ # The background is painted under the content of every page: a page
29
+ # template on the background layer, with a canvas the size of the page.
30
+ page_template(layer: :background) do |page|
31
+ canvas(height: page.height, width: page.width, at: [0, 0]) { |c, _rect| frame(c, page.width, page.height) }
32
+ end
33
+
34
+ def self.preview
35
+ new(name: "Robin Example", course: "Coastal Skipper, practical course",
36
+ hours: 64, date: "12 September 2026", number: "HIS-2026-0419")
37
+ end
38
+
39
+ def initialize(name:, course:, hours:, date:, number:)
40
+ super()
41
+ @name = name
42
+ @course = course
43
+ @hours = hours
44
+ @date = date
45
+ @number = number
46
+ end
47
+
48
+ def view_template
49
+ text "HARBOUR INSTITUTE OF SAILING", size: 10, weight: :bold, color: GOLD, letter_spacing: 3
50
+ spacer 18
51
+ text "Certificate of completion", size: 34, weight: :bold
52
+ spacer 18
53
+ text "This is to certify that", size: 12, color: MUTED, style: :italic
54
+ spacer 10
55
+ text @name, size: 30, weight: :bold, color: GOLD
56
+ spacer 12
57
+ text "has completed the #{@course}, #{@hours} hours afloat and ashore,\n" \
58
+ "and has shown the seamanship the course asks for.", size: 12, leading: 4
59
+ spacer 60
60
+ signatures
61
+ end
62
+
63
+ private
64
+
65
+ # Two signature fields with the seal between them; the signers sign in
66
+ # their PDF viewer, or with `to_pdf(sign: { …, field: "director" })`.
67
+ def signatures
68
+ row(gap: 40, align: :bottom) do
69
+ column { signature_field "director", label: "Anna Example, Director of Training", height: 44 }
70
+ column(width: 70) { svg SEAL, width: 70, align: :center }
71
+ column { signature_field "examiner", label: "Jonas Sample, Examiner", height: 44 }
72
+ end
73
+ spacer 16
74
+ text "Awarded #{@date} · Certificate #{@number}", size: 8.5, color: MUTED
75
+ end
76
+
77
+ # A double frame with rosettes in the corners, in page coordinates from
78
+ # the top left.
79
+ def frame(canvas, width, height)
80
+ canvas.fill_rect(0, 0, width, height, color: PAPER)
81
+ canvas.rounded_rect(24, 24, width - 48, height - 48, radius: 4, stroke: GOLD, line_width: 2.5)
82
+ canvas.rounded_rect(34, 34, width - 68, height - 68, radius: 2, stroke: GOLD, line_width: 0.75)
83
+ [[34, 34], [width - 34, 34], [34, height - 34], [width - 34, height - 34]].each do |x, y|
84
+ rosette(canvas, x, y)
85
+ end
86
+ canvas.line((width / 2) - 60, 88, (width / 2) + 60, 88, color: GOLD, width: 0.75)
87
+ end
88
+
89
+ def rosette(canvas, x, y)
90
+ canvas.circle(x, y, 14, fill: PAPER, stroke: GOLD, line_width: 1)
91
+ 8.times do |i|
92
+ angle = i * Math::PI / 4
93
+ canvas.line(x, y, x + (12 * Math.cos(angle)), y + (12 * Math.sin(angle)), color: GOLD, width: 0.6)
94
+ end
95
+ canvas.circle(x, y, 4, fill: GOLD)
96
+ end
97
+ end
98
+
99
+ if $PROGRAM_NAME == __FILE__
100
+ certificate = ExampleCertificate.preview
101
+ certificate.to_pdf(File.expand_path("certificate.pdf", __dir__))
102
+ warn certificate.warnings.map(&:message) if certificate.warnings.any?
103
+ puts "wrote examples/certificate.pdf"
104
+ end
@@ -0,0 +1,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ # A services agreement over several pages: numbered clauses whose headings
4
+ # stay with their text, a box for both parties' initials in the footer of
5
+ # every page, and signature fields for a digital signature. Run it to write
6
+ # examples/contract.pdf:
7
+ #
8
+ # ruby -Ilib examples/contract.rb
9
+ # ruby -Ilib exe/stationery render examples/contract.rb
10
+ #
11
+ # Signing happens when the file is written, with a certificate and its key;
12
+ # `field:` names the signature field that carries the signature:
13
+ #
14
+ # ExampleContract.preview.to_pdf("contract.pdf",
15
+ # sign: { certificate: File.read("provider.crt"), key: File.read("provider.key"),
16
+ # field: "signature.provider", reason: "Agreed", location: "Gothenburg" })
17
+ require "stationery"
18
+
19
+ class ExampleContract < Stationery::Document
20
+ INK = "#111827"
21
+ MUTED = "#6B7280"
22
+ HAIRLINE = "#D1D5DB"
23
+
24
+ CLAUSES = [
25
+ ["Definitions", ["“Services” means the development, hosting and support of the Client's delivery-tracking " \
26
+ "application described in Schedule 1.",
27
+ "“Business Day” means a day other than a Saturday, a Sunday or a public holiday in Sweden.",
28
+ "Headings are for convenience only and do not affect how this Agreement is read."]],
29
+ ["Term", ["This Agreement starts on the Effective Date and runs for twenty-four months.",
30
+ "It renews for further periods of twelve months unless either party gives written notice at " \
31
+ "least ninety days before the end of the current period."]],
32
+ ["Services", ["The Provider shall perform the Services with reasonable skill and care, in line with good " \
33
+ "industry practice, and through personnel who are suitably qualified.",
34
+ "The Provider shall keep the application available 99.5 per cent of each calendar month, " \
35
+ "measured as set out in Schedule 2, excluding maintenance announced five Business Days ahead.",
36
+ "Changes to the Services are agreed in writing through the change procedure in Schedule 3."]],
37
+ ["Fees and payment", ["The Client shall pay the fees set out in Schedule 4, monthly in arrears, within " \
38
+ "thirty days of the date of each invoice.",
39
+ "Fees are exclusive of value added tax, which the Client pays at the rate in force.",
40
+ "Late payments carry interest at the reference rate of the Riksbank plus eight " \
41
+ "percentage points."]],
42
+ ["Intellectual property", ["Each party keeps the rights it owned before the Effective Date.",
43
+ "Rights in work made for the Client under this Agreement pass to the Client when " \
44
+ "it has been paid for. The Provider keeps its general tools and know-how."]],
45
+ ["Confidentiality", ["Each party shall keep the other's confidential information secret and use it only " \
46
+ "to perform this Agreement.",
47
+ "This clause survives the end of the Agreement for five years."]],
48
+ ["Data protection", ["Where the Provider processes personal data for the Client, it does so as processor " \
49
+ "under the data processing agreement in Schedule 5, which forms part of this Agreement."]],
50
+ ["Liability", ["Neither party is liable for indirect loss, loss of profit or loss of data, except in the " \
51
+ "case of gross negligence or wilful misconduct.",
52
+ "Each party's total liability in a contract year is limited to the fees paid in that year."]],
53
+ ["Termination", ["Either party may terminate this Agreement with immediate effect if the other commits a " \
54
+ "material breach and does not remedy it within thirty days of written notice.",
55
+ "On termination the Provider shall hand over the Client's data in a common machine-readable " \
56
+ "format and then delete it."]],
57
+ ["Governing law and disputes", ["This Agreement is governed by Swedish law.",
58
+ "Disputes are finally settled by arbitration under the Rules for Expedited " \
59
+ "Arbitrations of the SCC Arbitration Institute, seated in Gothenburg."]]
60
+ ].freeze
61
+
62
+ page size: :a4, margin: [56, 64, 48, 64]
63
+ default_text size: 10, color: INK, leading: 2.5
64
+ metadata title: "Services agreement", author: "Example Software AB", creator: "stationery example"
65
+
66
+ # Every page carries a box for each party's initials: a text field per
67
+ # party and page, named after the page (`initials.provider.3`) so that each
68
+ # is a field of its own. The frame is a box around the field, so it prints
69
+ # in viewers that redraw empty fields without their border.
70
+ footer(gap: 16) do |page|
71
+ row(align: :middle, gap: 12) do
72
+ column { text "Services agreement · page #{page.number} of #{page.count}", size: 8, color: MUTED }
73
+ column(width: :auto) { text "Initials", size: 8, color: MUTED }
74
+ %w[provider client].each do |party|
75
+ column(width: 44) do
76
+ box(border: { color: MUTED, width: 0.75 }, radius: 3) do
77
+ text_field "initials.#{party}.#{page.number}", height: 20, tooltip: "Initials of the #{party}",
78
+ border: nil, background: nil
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
84
+
85
+ def self.preview
86
+ new(provider: ["Example Software AB", "Org. no. 556000-0000", "Storgatan 1, 411 00 Gothenburg"],
87
+ client: ["Sample Freight Ltd", "Company no. 00000000", "1 Test Street, Testville"],
88
+ date: "1 October 2026")
89
+ end
90
+
91
+ def initialize(provider:, client:, date:)
92
+ super()
93
+ @provider = provider
94
+ @client = client
95
+ @date = date
96
+ end
97
+
98
+ def view_template
99
+ text "Services agreement", size: 22, weight: :bold
100
+ spacer 4
101
+ text "Effective Date: #{@date}", color: MUTED
102
+ spacer 16
103
+ parties
104
+ spacer 10
105
+ CLAUSES.each_with_index { |(title, paragraphs), index| clause(index + 1, title, paragraphs) }
106
+ signatures
107
+ end
108
+
109
+ private
110
+
111
+ def parties
112
+ text "This Agreement is made between:"
113
+ spacer 6
114
+ row(gap: 24) do
115
+ column { party("THE PROVIDER", @provider) }
116
+ column { party("THE CLIENT", @client) }
117
+ end
118
+ end
119
+
120
+ def party(label, lines)
121
+ text label, size: 7.5, weight: :bold, color: MUTED, letter_spacing: 0.6
122
+ text "<b>#{lines.first}</b>\n#{lines.drop(1).join("\n")}", markup: true, size: 9.5
123
+ end
124
+
125
+ # "3. Services" and its sub-clauses 3.1, 3.2, …: the heading keeps at least
126
+ # 50 points of what follows on its page, so it never ends one alone.
127
+ def clause(number, title, paragraphs)
128
+ spacer 12
129
+ text "#{number}. #{title}", size: 11.5, weight: :bold, keep_with_next: 50, bookmark: title
130
+ spacer 4
131
+ paragraphs.each_with_index do |paragraph, index|
132
+ row(gap: 8) do
133
+ column(width: 30) { text "#{number}.#{index + 1}", color: MUTED }
134
+ column { text paragraph, align: :justify, hyphenate: true }
135
+ end
136
+ spacer 4
137
+ end
138
+ end
139
+
140
+ # Kept together, so both parties sign on one page.
141
+ def signatures
142
+ spacer 24
143
+ group(keep_together: true) do
144
+ text "Signed by the parties on the dates written below their signatures.", keep_with_next: true
145
+ spacer 30
146
+ row(gap: 40) do
147
+ column { signature_field "signature.provider", label: "For #{@provider.first}", height: 48 }
148
+ column { signature_field "signature.client", label: "For #{@client.first}", height: 48 }
149
+ end
150
+ end
151
+ end
152
+ end
153
+
154
+ if $PROGRAM_NAME == __FILE__
155
+ contract = ExampleContract.preview
156
+ contract.to_pdf(File.expand_path("contract.pdf", __dir__))
157
+ warn contract.warnings.map(&:message) if contract.warnings.any?
158
+ puts "wrote examples/contract.pdf"
159
+ end
@@ -0,0 +1,161 @@
1
+ # frozen_string_literal: true
2
+
3
+ # A Factur-X (ZUGFeRD) e-invoice: the example invoice as PDF/A-3b with its
4
+ # EN 16931 invoice XML embedded, so accounting software books what people read.
5
+ # Run it to write examples/e_invoice.pdf:
6
+ #
7
+ # ruby -Ilib examples/e_invoice.rb
8
+ # ruby -Ilib exe/stationery render examples/e_invoice.rb
9
+ require "date"
10
+ require "stationery"
11
+ require_relative "invoice"
12
+
13
+ class ExampleEInvoice < ExampleInvoice
14
+ ISSUED = Date.new(2026, 9, 26)
15
+ VAT_RATE = 25
16
+ SELLER = { name: "Acme Studio AB", street: "Storgatan 1", postcode: "111 22", city: "Stockholm", country: "SE",
17
+ email: "hello@acme.test", vat_id: "SE556677889901", iban: "SE4550000000058398257466" }.freeze
18
+ BUYER = { street: "Hauptstraße 5", postcode: "10115", city: "Berlin", country: "DE", vat_id: "DE123456789" }.freeze
19
+
20
+ metadata title: "E-invoice", author: SELLER[:name], lang: "en"
21
+
22
+ # The XML comes from the document being rendered, so every invoice embeds
23
+ # its own. `factur_x` also makes the file PDF/A-3b and identifies the
24
+ # invoice in the XMP metadata.
25
+ factur_x(profile: :en16931) { cross_industry_invoice }
26
+
27
+ private
28
+
29
+ # SAMPLE CODE. Stationery embeds the invoice XML, it does not write it: in
30
+ # an application the XML comes from your invoicing code or a library made
31
+ # for it. This is the smallest Cross Industry Invoice that satisfies
32
+ # EN 16931 for the lines above: one VAT rate, no allowances or charges.
33
+ def cross_industry_invoice
34
+ <<~XML
35
+ <?xml version="1.0" encoding="UTF-8"?>
36
+ <rsm:CrossIndustryInvoice xmlns:rsm="urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100" xmlns:ram="urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:100" xmlns:udt="urn:un:unece:uncefact:data:standard:UnqualifiedDataType:100">
37
+ <rsm:ExchangedDocumentContext>
38
+ <ram:GuidelineSpecifiedDocumentContextParameter>
39
+ <ram:ID>urn:cen.eu:en16931:2017</ram:ID>
40
+ </ram:GuidelineSpecifiedDocumentContextParameter>
41
+ </rsm:ExchangedDocumentContext>
42
+ <rsm:ExchangedDocument>
43
+ <ram:ID>#{xml(@number)}</ram:ID>
44
+ <ram:TypeCode>380</ram:TypeCode>
45
+ <ram:IssueDateTime>#{xml_date(ISSUED)}</ram:IssueDateTime>
46
+ </rsm:ExchangedDocument>
47
+ <rsm:SupplyChainTradeTransaction>
48
+ #{@items.each_with_index.map { |item, index| xml_line(index + 1, *item) }.join.chomp}
49
+ <ram:ApplicableHeaderTradeAgreement>
50
+ <ram:SellerTradeParty>
51
+ <ram:Name>#{xml(SELLER[:name])}</ram:Name>
52
+ #{xml_address(SELLER)}
53
+ <ram:URIUniversalCommunication>
54
+ <ram:URIID schemeID="EM">#{xml(SELLER[:email])}</ram:URIID>
55
+ </ram:URIUniversalCommunication>
56
+ #{xml_vat_id(SELLER)}
57
+ </ram:SellerTradeParty>
58
+ <ram:BuyerTradeParty>
59
+ <ram:Name>#{xml(@customer)}</ram:Name>
60
+ #{xml_address(BUYER)}
61
+ #{xml_vat_id(BUYER)}
62
+ </ram:BuyerTradeParty>
63
+ <ram:BuyerOrderReferencedDocument>
64
+ <ram:IssuerAssignedID>PO-7781</ram:IssuerAssignedID>
65
+ </ram:BuyerOrderReferencedDocument>
66
+ </ram:ApplicableHeaderTradeAgreement>
67
+ <ram:ApplicableHeaderTradeDelivery>
68
+ <ram:ActualDeliverySupplyChainEvent>
69
+ <ram:OccurrenceDateTime>#{xml_date(ISSUED)}</ram:OccurrenceDateTime>
70
+ </ram:ActualDeliverySupplyChainEvent>
71
+ </ram:ApplicableHeaderTradeDelivery>
72
+ <ram:ApplicableHeaderTradeSettlement>
73
+ <ram:InvoiceCurrencyCode>EUR</ram:InvoiceCurrencyCode>
74
+ <ram:SpecifiedTradeSettlementPaymentMeans>
75
+ <ram:TypeCode>58</ram:TypeCode>
76
+ <ram:PayeePartyCreditorFinancialAccount>
77
+ <ram:IBANID>#{SELLER[:iban]}</ram:IBANID>
78
+ </ram:PayeePartyCreditorFinancialAccount>
79
+ </ram:SpecifiedTradeSettlementPaymentMeans>
80
+ <ram:ApplicableTradeTax>
81
+ <ram:CalculatedAmount>#{xml_amount(vat)}</ram:CalculatedAmount>
82
+ <ram:TypeCode>VAT</ram:TypeCode>
83
+ <ram:BasisAmount>#{xml_amount(subtotal)}</ram:BasisAmount>
84
+ <ram:CategoryCode>S</ram:CategoryCode>
85
+ <ram:RateApplicablePercent>#{VAT_RATE}</ram:RateApplicablePercent>
86
+ </ram:ApplicableTradeTax>
87
+ <ram:SpecifiedTradePaymentTerms>
88
+ <ram:DueDateDateTime>#{xml_date(Date.parse(@due))}</ram:DueDateDateTime>
89
+ </ram:SpecifiedTradePaymentTerms>
90
+ <ram:SpecifiedTradeSettlementHeaderMonetarySummation>
91
+ <ram:LineTotalAmount>#{xml_amount(subtotal)}</ram:LineTotalAmount>
92
+ <ram:TaxBasisTotalAmount>#{xml_amount(subtotal)}</ram:TaxBasisTotalAmount>
93
+ <ram:TaxTotalAmount currencyID="EUR">#{xml_amount(vat)}</ram:TaxTotalAmount>
94
+ <ram:GrandTotalAmount>#{xml_amount(total)}</ram:GrandTotalAmount>
95
+ <ram:DuePayableAmount>#{xml_amount(total)}</ram:DuePayableAmount>
96
+ </ram:SpecifiedTradeSettlementHeaderMonetarySummation>
97
+ </ram:ApplicableHeaderTradeSettlement>
98
+ </rsm:SupplyChainTradeTransaction>
99
+ </rsm:CrossIndustryInvoice>
100
+ XML
101
+ end
102
+
103
+ def xml_line(number, name, quantity, price)
104
+ <<~XML.gsub(/^/, " ")
105
+ <ram:IncludedSupplyChainTradeLineItem>
106
+ <ram:AssociatedDocumentLineDocument>
107
+ <ram:LineID>#{number}</ram:LineID>
108
+ </ram:AssociatedDocumentLineDocument>
109
+ <ram:SpecifiedTradeProduct>
110
+ <ram:Name>#{xml(name)}</ram:Name>
111
+ </ram:SpecifiedTradeProduct>
112
+ <ram:SpecifiedLineTradeAgreement>
113
+ <ram:NetPriceProductTradePrice>
114
+ <ram:ChargeAmount>#{xml_amount(price)}</ram:ChargeAmount>
115
+ </ram:NetPriceProductTradePrice>
116
+ </ram:SpecifiedLineTradeAgreement>
117
+ <ram:SpecifiedLineTradeDelivery>
118
+ <ram:BilledQuantity unitCode="C62">#{quantity}</ram:BilledQuantity>
119
+ </ram:SpecifiedLineTradeDelivery>
120
+ <ram:SpecifiedLineTradeSettlement>
121
+ <ram:ApplicableTradeTax>
122
+ <ram:TypeCode>VAT</ram:TypeCode>
123
+ <ram:CategoryCode>S</ram:CategoryCode>
124
+ <ram:RateApplicablePercent>#{VAT_RATE}</ram:RateApplicablePercent>
125
+ </ram:ApplicableTradeTax>
126
+ <ram:SpecifiedTradeSettlementLineMonetarySummation>
127
+ <ram:LineTotalAmount>#{xml_amount(quantity * price)}</ram:LineTotalAmount>
128
+ </ram:SpecifiedTradeSettlementLineMonetarySummation>
129
+ </ram:SpecifiedLineTradeSettlement>
130
+ </ram:IncludedSupplyChainTradeLineItem>
131
+ XML
132
+ end
133
+
134
+ def xml_address(party)
135
+ <<~XML.gsub(/^/, " ").chomp
136
+ <ram:PostalTradeAddress>
137
+ <ram:PostcodeCode>#{xml(party[:postcode])}</ram:PostcodeCode>
138
+ <ram:LineOne>#{xml(party[:street])}</ram:LineOne>
139
+ <ram:CityName>#{xml(party[:city])}</ram:CityName>
140
+ <ram:CountryID>#{party[:country]}</ram:CountryID>
141
+ </ram:PostalTradeAddress>
142
+ XML
143
+ end
144
+
145
+ def xml_vat_id(party)
146
+ <<~XML.gsub(/^/, " ").chomp
147
+ <ram:SpecifiedTaxRegistration>
148
+ <ram:ID schemeID="VA">#{party[:vat_id]}</ram:ID>
149
+ </ram:SpecifiedTaxRegistration>
150
+ XML
151
+ end
152
+
153
+ def xml_date(date) = %(<udt:DateTimeString format="102">#{date.strftime("%Y%m%d")}</udt:DateTimeString>)
154
+ def xml_amount(amount) = format("%.2f", amount)
155
+ def xml(text) = text.to_s.gsub("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;")
156
+ end
157
+
158
+ if $PROGRAM_NAME == __FILE__
159
+ ExampleEInvoice.preview.to_pdf(File.expand_path("e_invoice.pdf", __dir__))
160
+ puts "wrote examples/e_invoice.pdf"
161
+ end