stationery 0.11.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +54 -68
- data/README.md +775 -78
- data/examples/accessible_report.rb +133 -0
- data/examples/article.rb +93 -0
- data/examples/assets/bay.png +0 -0
- data/examples/assets/dunes.png +0 -0
- data/examples/assets/hills.png +0 -0
- data/examples/assets/logo.png +0 -0
- data/examples/assets/ridge.png +0 -0
- data/examples/assets/sea.png +0 -0
- data/examples/assets/stone.png +0 -0
- data/examples/assets/wall.png +0 -0
- data/examples/certificate.rb +104 -0
- data/examples/contract.rb +159 -0
- data/examples/e_invoice.rb +161 -0
- data/examples/flyer.rb +336 -0
- data/examples/form.rb +113 -0
- data/examples/invoice.rb +163 -0
- data/examples/letter.rb +121 -0
- data/examples/menu.rb +133 -0
- data/examples/multilingual_notice.rb +121 -0
- data/examples/newsletter.rb +108 -0
- data/examples/packing_slip.rb +120 -0
- data/examples/postcard.rb +83 -0
- data/examples/price_list.rb +107 -0
- data/examples/receipt.rb +176 -0
- data/examples/report.rb +294 -0
- data/examples/resume.rb +121 -0
- data/examples/shaping/extraction_matrix/pdfium.py +8 -0
- data/examples/shaping/extraction_matrix/pdfjs.mjs +18 -0
- data/examples/shaping/extraction_matrix/pdfkit.swift +6 -0
- data/examples/shaping/extraction_matrix.rb +208 -0
- data/examples/shaping/harfbuzz_shaper.rb +132 -0
- data/examples/shaping/rtl_letter.rb +106 -0
- data/examples/shipping_label.rb +93 -0
- data/lib/stationery/barcode/code128.rb +96 -0
- data/lib/stationery/barcode/data_matrix.rb +111 -0
- data/lib/stationery/barcode/data_matrix_placement.rb +118 -0
- data/lib/stationery/barcode/ean13.rb +66 -0
- data/lib/stationery/barcode/qr.rb +120 -0
- data/lib/stationery/barcode/qr_matrix.rb +189 -0
- data/lib/stationery/barcode/reed_solomon.rb +61 -0
- data/lib/stationery/barcode.rb +33 -0
- data/lib/stationery/builder.rb +12 -2
- data/lib/stationery/canvas/glyphs.rb +40 -0
- data/lib/stationery/canvas/interface.rb +169 -0
- data/lib/stationery/canvas/marking.rb +32 -3
- data/lib/stationery/canvas/path.rb +32 -70
- data/lib/stationery/canvas/surfacing.rb +67 -0
- data/lib/stationery/canvas/text.rb +4 -25
- data/lib/stationery/canvas.rb +24 -75
- data/lib/stationery/cli/examples.rb +66 -0
- data/lib/stationery/cli/inspect.rb +65 -0
- data/lib/stationery/cli/layout_text.rb +132 -0
- data/lib/stationery/cli/pictures.rb +50 -0
- data/lib/stationery/cli/render.rb +33 -6
- data/lib/stationery/cli/skill.rb +166 -0
- data/lib/stationery/cli.rb +5 -0
- data/lib/stationery/color.rb +27 -13
- data/lib/stationery/component.rb +1 -0
- data/lib/stationery/css/stylesheet.rb +9 -1
- data/lib/stationery/document.rb +135 -27
- data/lib/stationery/elements/forms.rb +2 -1
- data/lib/stationery/elements/lists.rb +6 -2
- data/lib/stationery/elements.rb +43 -7
- data/lib/stationery/examples.rb +22 -0
- data/lib/stationery/fonts/cff.rb +46 -0
- data/lib/stationery/fonts/charstring.rb +297 -0
- data/lib/stationery/fonts/fallback.rb +22 -1
- data/lib/stationery/fonts/font.rb +58 -3
- data/lib/stationery/fonts/font_book.rb +7 -3
- data/lib/stationery/fonts/glyf_outline.rb +230 -0
- data/lib/stationery/fonts/glyph_run.rb +37 -10
- data/lib/stationery/fonts/outline.rb +102 -0
- data/lib/stationery/fonts/shaped_run.rb +22 -4
- data/lib/stationery/fonts/true_type.rb +7 -0
- data/lib/stationery/forms/typeface.rb +2 -4
- data/lib/stationery/html/css.rb +31 -6
- data/lib/stationery/html/tree_builder/blocks.rb +7 -3
- data/lib/stationery/images/images.rb +10 -0
- data/lib/stationery/images/jpeg/bit_reader.rb +92 -0
- data/lib/stationery/images/jpeg/colors.rb +133 -0
- data/lib/stationery/images/jpeg/decoder.rb +253 -0
- data/lib/stationery/images/jpeg/huffman.rb +59 -0
- data/lib/stationery/images/jpeg/idct.rb +150 -0
- data/lib/stationery/images/jpeg/progressive.rb +128 -0
- data/lib/stationery/images/jpeg/reduced_idct.rb +106 -0
- data/lib/stationery/images/jpeg/scan.rb +116 -0
- data/lib/stationery/images/jpeg/upsampler.rb +81 -0
- data/lib/stationery/images/jpeg.rb +59 -1
- data/lib/stationery/images/png.rb +10 -7
- data/lib/stationery/images/resampled.rb +10 -2
- data/lib/stationery/images/webp.rb +6 -5
- data/lib/stationery/layout/barcode.rb +75 -0
- data/lib/stationery/layout/box/wrapping.rb +56 -0
- data/lib/stationery/layout/box.rb +41 -15
- data/lib/stationery/layout/columns/leveller.rb +65 -0
- data/lib/stationery/layout/columns/pour.rb +7 -0
- data/lib/stationery/layout/columns.rb +32 -15
- data/lib/stationery/layout/field.rb +3 -2
- data/lib/stationery/layout/floated.rb +12 -1
- data/lib/stationery/layout/flow/floating.rb +23 -5
- data/lib/stationery/layout/flow/placement.rb +16 -12
- data/lib/stationery/layout/flow/splitter.rb +56 -10
- data/lib/stationery/layout/flow.rb +1 -0
- data/lib/stationery/layout/image.rb +2 -2
- data/lib/stationery/layout/list_item.rb +41 -11
- data/lib/stationery/layout/mark.rb +2 -0
- data/lib/stationery/layout/node.rb +16 -2
- data/lib/stationery/layout/opening.rb +61 -0
- data/lib/stationery/layout/paginator.rb +24 -5
- data/lib/stationery/layout/svg.rb +1 -1
- data/lib/stationery/layout/table/cell.rb +39 -10
- data/lib/stationery/layout/table/row_splitter.rb +6 -2
- data/lib/stationery/layout/table/selection.rb +9 -3
- data/lib/stationery/layout/table/stream.rb +123 -0
- data/lib/stationery/layout/table/widths.rb +26 -0
- data/lib/stationery/layout/table.rb +151 -35
- data/lib/stationery/layout/table_of_contents/entry.rb +3 -3
- data/lib/stationery/layout/table_of_contents.rb +1 -1
- data/lib/stationery/layout/text.rb +22 -2
- data/lib/stationery/minitest.rb +11 -3
- data/lib/stationery/monochrome/bitmap.rb +78 -0
- data/lib/stationery/monochrome/canvas.rb +19 -0
- data/lib/stationery/monochrome/dither.rb +74 -0
- data/lib/stationery/monochrome/grid.rb +131 -0
- data/lib/stationery/monochrome/painting.rb +140 -0
- data/lib/stationery/monochrome/rules.rb +141 -0
- data/lib/stationery/monochrome.rb +87 -0
- data/lib/stationery/page/format.rb +84 -0
- data/lib/stationery/page.rb +11 -7
- data/lib/stationery/page_templates.rb +13 -10
- data/lib/stationery/path.rb +138 -0
- data/lib/stationery/pdf/assembler.rb +14 -3
- data/lib/stationery/pdf/canvases.rb +51 -0
- data/lib/stationery/pdf/conformance.rb +43 -13
- data/lib/stationery/pdf/print_hints.rb +137 -0
- data/lib/stationery/pdf/serializer.rb +41 -0
- data/lib/stationery/raster/adjust.rb +67 -0
- data/lib/stationery/raster/canvas.rb +108 -0
- data/lib/stationery/raster/canvases.rb +49 -0
- data/lib/stationery/raster/flattener.rb +75 -0
- data/lib/stationery/raster/glyphs.rb +81 -0
- data/lib/stationery/raster/painter.rb +184 -0
- data/lib/stationery/raster/picture.rb +69 -0
- data/lib/stationery/raster/png.rb +32 -0
- data/lib/stationery/raster/render.rb +134 -0
- data/lib/stationery/raster/scanner.rb +164 -0
- data/lib/stationery/raster/shading.rb +77 -0
- data/lib/stationery/raster/spans.rb +66 -0
- data/lib/stationery/raster/stroker.rb +208 -0
- data/lib/stationery/raster/surface.rb +70 -0
- data/lib/stationery/raster.rb +44 -0
- data/lib/stationery/rich/nodes.rb +4 -4
- data/lib/stationery/rich/renderer/boxes.rb +37 -16
- data/lib/stationery/rich/renderer.rb +3 -2
- data/lib/stationery/skill/SKILL.md.erb +244 -0
- data/lib/stationery/skill/recipes/flyer.md.erb +76 -0
- data/lib/stationery/skill/recipes/form.md.erb +78 -0
- data/lib/stationery/skill/recipes/invoice.md.erb +142 -0
- data/lib/stationery/skill/recipes/label.md.erb +116 -0
- data/lib/stationery/skill/recipes/receipt.md.erb +79 -0
- data/lib/stationery/skill/recipes/report.md.erb +113 -0
- data/lib/stationery/skill/reference/conformance.md.erb +45 -0
- data/lib/stationery/skill/reference/elements.md.erb +19 -0
- data/lib/stationery/skill/reference/fonts_and_images.md.erb +7 -0
- data/lib/stationery/skill/reference/forms.md.erb +5 -0
- data/lib/stationery/skill/reference/html_and_markdown.md.erb +5 -0
- data/lib/stationery/skill/reference/pages.md.erb +15 -0
- data/lib/stationery/skill/reference/printing.md.erb +33 -0
- data/lib/stationery/skill/reference/testing.md.erb +33 -0
- data/lib/stationery/skill.rb +101 -0
- data/lib/stationery/structure.rb +7 -6
- data/lib/stationery/svg/gradient.rb +12 -0
- data/lib/stationery/svg/painter.rb +1 -1
- data/lib/stationery/tagging/element.rb +9 -2
- data/lib/stationery/tagging/tree.rb +98 -2
- data/lib/stationery/testing/field_page.rb +89 -0
- data/lib/stationery/testing/inspector.rb +80 -2
- data/lib/stationery/testing/layout.rb +166 -0
- data/lib/stationery/testing/marked_text.rb +124 -18
- data/lib/stationery/testing/matchers.rb +63 -14
- data/lib/stationery/testing/page_reader.rb +146 -0
- data/lib/stationery/text/exclusions.rb +11 -0
- data/lib/stationery/text/paragraph.rb +13 -8
- data/lib/stationery/text/wrapper/one_word.rb +33 -0
- data/lib/stationery/text/wrapper.rb +62 -27
- data/lib/stationery/units.rb +45 -0
- data/lib/stationery/version.rb +1 -1
- data/lib/stationery/warnings.rb +67 -7
- data/lib/stationery/zpl/native.rb +51 -0
- data/lib/stationery/zpl/render.rb +56 -0
- data/lib/stationery/zpl.rb +88 -0
- data/lib/stationery.rb +48 -0
- metadata +118 -1
|
@@ -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
|
data/examples/article.rb
ADDED
|
@@ -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("&", "&").gsub("<", "<").gsub(">", ">")
|
|
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
|