stationery 0.12.1 → 0.12.2
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 +3 -0
- data/README.md +104 -16
- data/examples/price_card.rb +85 -0
- data/lib/stationery/cli/layout_text.rb +14 -2
- data/lib/stationery/cli/verify.rb +98 -0
- data/lib/stationery/cli.rb +1 -0
- data/lib/stationery/elements/forms.rb +4 -2
- data/lib/stationery/forms/appearance.rb +26 -21
- data/lib/stationery/forms/auto_size.rb +94 -0
- data/lib/stationery/forms/field.rb +45 -3
- data/lib/stationery/monochrome/painting.rb +10 -0
- data/lib/stationery/pdf/conformance.rb +14 -1
- data/lib/stationery/skill/recipes/form.md.erb +9 -6
- data/lib/stationery/skill/recipes/invoice.md.erb +13 -9
- data/lib/stationery/skill/recipes/label.md.erb +4 -2
- data/lib/stationery/skill/recipes/report.md.erb +9 -4
- data/lib/stationery/testing/inspector.rb +8 -5
- data/lib/stationery/testing/layout.rb +18 -1
- data/lib/stationery/verify/allowlist.rb +29 -0
- data/lib/stationery/verify/command.rb +80 -0
- data/lib/stationery/verify/comparison.rb +191 -0
- data/lib/stationery/verify/engine.rb +63 -0
- data/lib/stationery/verify/engines/mupdf.rb +44 -0
- data/lib/stationery/verify/engines/pdfium.rb +35 -0
- data/lib/stationery/verify/engines/pdfjs.rb +41 -0
- data/lib/stationery/verify/engines/pdfkit.rb +20 -0
- data/lib/stationery/verify/engines/poppler.rb +83 -0
- data/lib/stationery/verify/engines/qpdf.rb +46 -0
- data/lib/stationery/verify/expectation.rb +44 -0
- data/lib/stationery/verify/report.rb +49 -0
- data/lib/stationery/verify/scripts/pdfium.py +99 -0
- data/lib/stationery/verify/scripts/pdfjs.mjs +108 -0
- data/lib/stationery/verify/scripts/pdfkit.swift +80 -0
- data/lib/stationery/verify.rb +89 -0
- data/lib/stationery/version.rb +1 -1
- data/lib/stationery.rb +1 -0
- metadata +20 -1
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stationery
|
|
4
|
+
module Forms
|
|
5
|
+
# The size `font_size: :auto` draws a field's value at: the largest that
|
|
6
|
+
# fits the field's box less its padding, no larger than `max_font_size:`
|
|
7
|
+
# and no smaller than `min_font_size:` (4 points by default, the smallest
|
|
8
|
+
# Chrome's PDFium auto-sizes to). Sizes are whole tenths of a point,
|
|
9
|
+
# rounded down.
|
|
10
|
+
#
|
|
11
|
+
# A typeface's widths and heights are linear in the size, so one line and
|
|
12
|
+
# a comb field's cells are arithmetic; wrapped lines are not, and their
|
|
13
|
+
# size is found by bisection. Only the typeface's measures are asked:
|
|
14
|
+
# nothing is drawn or named here.
|
|
15
|
+
class AutoSize
|
|
16
|
+
MIN = 4
|
|
17
|
+
# Measures are taken at this size and scaled: less rounding than at 1.
|
|
18
|
+
UNIT = 100.0
|
|
19
|
+
|
|
20
|
+
# The value broken into lines no wider than `width` at `size`, keeping
|
|
21
|
+
# its own line breaks; a word wider than a line stands alone.
|
|
22
|
+
def self.wrap(type, text, width, size)
|
|
23
|
+
text.split("\n", -1).flat_map do |paragraph|
|
|
24
|
+
paragraph.split.each_with_object([+""]) do |word, lines|
|
|
25
|
+
candidate = lines.last.empty? ? word : "#{lines.last} #{word}"
|
|
26
|
+
if lines.last.empty? || type.width(candidate, size) <= width
|
|
27
|
+
lines[-1] = candidate
|
|
28
|
+
else
|
|
29
|
+
lines << word
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# `type` is the typeface the appearance draws with; `width` and
|
|
36
|
+
# `height` the widget's.
|
|
37
|
+
def initialize(field, type, width, height)
|
|
38
|
+
@field = field
|
|
39
|
+
@type = type
|
|
40
|
+
@inner_width = width - (2 * Appearance::PADDING)
|
|
41
|
+
@inner_height = height - (2 * Appearance::PADDING)
|
|
42
|
+
@cell = field.options[:comb] && width.fdiv(field.max_length)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def size
|
|
46
|
+
min = @field.min_font_size || [MIN, @field.max_font_size].compact.min
|
|
47
|
+
max = [tenth([@field.max_font_size, height_fit].compact.min), min].max
|
|
48
|
+
fit = @field.options[:multiline] && !@cell ? lines_fit(min, max) : [max, width_fit].compact.min
|
|
49
|
+
[tenth(fit), min].max
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def value = @field.value.to_s
|
|
55
|
+
def tenth(size) = (size * 10).floor / 10.0
|
|
56
|
+
def per_point(measure) = measure / UNIT
|
|
57
|
+
def glyph_box = per_point(@type.ascent(UNIT) + @type.descent(UNIT))
|
|
58
|
+
def height_fit = @inner_height / glyph_box
|
|
59
|
+
|
|
60
|
+
# What the width allows one line, or the widest character a cell; nil
|
|
61
|
+
# when the value has no width.
|
|
62
|
+
def width_fit
|
|
63
|
+
return cell_fit if @cell
|
|
64
|
+
|
|
65
|
+
width = per_point(@type.width(value, UNIT))
|
|
66
|
+
@inner_width / width if width.positive?
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def cell_fit
|
|
70
|
+
widest = value[0, @field.max_length].chars.map { |char| per_point(@type.width(char, UNIT)) }.max
|
|
71
|
+
@cell / widest if widest&.positive?
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# The largest tenth in min..max at which the wrapped value fits.
|
|
75
|
+
def lines_fit(min, max)
|
|
76
|
+
low = (min * 10).ceil
|
|
77
|
+
high = (max * 10).floor
|
|
78
|
+
return min unless high >= low && fits?(low / 10.0)
|
|
79
|
+
|
|
80
|
+
while low < high
|
|
81
|
+
middle = (low + high + 1) / 2
|
|
82
|
+
fits?(middle / 10.0) ? low = middle : high = middle - 1
|
|
83
|
+
end
|
|
84
|
+
low / 10.0
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def fits?(size)
|
|
88
|
+
lines = AutoSize.wrap(@type, value, @inner_width, size)
|
|
89
|
+
height = @type.ascent(size) + ((lines.size - 1) * size * Appearance::LEADING) + @type.descent(size)
|
|
90
|
+
height <= @inner_height && lines.all? { |line| @type.width(line, size) <= @inner_width }
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -16,13 +16,20 @@ module Stationery
|
|
|
16
16
|
comb: 25 }.freeze
|
|
17
17
|
DEFAULTS = { font_size: 10, read_only: false, required: false, border: "#9CA3AF", background: "#FFFFFF",
|
|
18
18
|
radius: 2, tooltip: nil }.freeze
|
|
19
|
+
# What a text field and a select take beyond the defaults: where the
|
|
20
|
+
# value sits, its colour, and the bounds of `font_size: :auto`.
|
|
21
|
+
BOUNDS = %i[min_font_size max_font_size].freeze
|
|
22
|
+
STYLE = [:align, :color, *BOUNDS].freeze
|
|
19
23
|
OPTIONS = {
|
|
20
|
-
text:
|
|
21
|
-
signature: %i[label]
|
|
24
|
+
text: [:multiline, :max_length, :comb, *STYLE], checkbox: [], radio: %i[checked],
|
|
25
|
+
select: [:options, :editable, *STYLE], signature: %i[label]
|
|
22
26
|
}.freeze
|
|
23
27
|
VARIABLE_TEXT = %i[text select].freeze
|
|
28
|
+
# The /Q (quadding) of each `align:` (PDF 32000-1, 12.7.3.3).
|
|
29
|
+
ALIGNMENTS = { left: 0, center: 1, right: 2 }.freeze
|
|
30
|
+
BLACK = Color.parse("#000000")
|
|
24
31
|
|
|
25
|
-
attr_reader :kind, :name, :value, :options, :typeface
|
|
32
|
+
attr_reader :kind, :name, :value, :options, :typeface, :color
|
|
26
33
|
|
|
27
34
|
def initialize(kind, name, value: nil, typeface: Standard.new, **options)
|
|
28
35
|
@kind = kind
|
|
@@ -33,12 +40,24 @@ module Stationery
|
|
|
33
40
|
raise ArgumentError, "unknown #{kind} field option: #{unknown.join(", ")}" if unknown.any?
|
|
34
41
|
|
|
35
42
|
@options = DEFAULTS.merge(options)
|
|
43
|
+
@color = Color.parse(@options.fetch(:color, BLACK))
|
|
36
44
|
validate_comb
|
|
45
|
+
validate_style
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# A copy with `changes` to its options.
|
|
49
|
+
def with(**changes)
|
|
50
|
+
self.class.new(@kind, @name, value: @value, typeface: @typeface, **@options, **changes)
|
|
37
51
|
end
|
|
38
52
|
|
|
39
53
|
def segments = @name.split(".")
|
|
40
54
|
def type = TYPES.fetch(@kind)
|
|
41
55
|
def font_size = @options[:font_size]
|
|
56
|
+
# Whether the value is drawn at the largest size that fits (`font_size: :auto`).
|
|
57
|
+
def auto_size? = font_size == :auto
|
|
58
|
+
def min_font_size = @options[:min_font_size]
|
|
59
|
+
def max_font_size = @options[:max_font_size]
|
|
60
|
+
def align = @options.fetch(:align, :left)
|
|
42
61
|
# Whether a viewer redraws its text when the value changes.
|
|
43
62
|
def variable_text? = VARIABLE_TEXT.include?(@kind)
|
|
44
63
|
# The accessible name: `tooltip:`, a signature's label, else the name.
|
|
@@ -62,6 +81,7 @@ module Stationery
|
|
|
62
81
|
def field_entries(value = field_value, default_appearance: nil)
|
|
63
82
|
entries = { FT: type, TU: PDF::TextString.new(tooltip) }
|
|
64
83
|
entries[:DA] = default_appearance if default_appearance
|
|
84
|
+
entries[:Q] = ALIGNMENTS.fetch(align) unless align == :left
|
|
65
85
|
entries[:Ff] = flags if flags.positive?
|
|
66
86
|
entries[:V] = value unless value.nil?
|
|
67
87
|
entries[:MaxLen] = max_length if max_length
|
|
@@ -126,6 +146,28 @@ module Stationery
|
|
|
126
146
|
|
|
127
147
|
raise ArgumentError, "a comb field needs max_length: (or comb: <cells>)"
|
|
128
148
|
end
|
|
149
|
+
|
|
150
|
+
def validate_style
|
|
151
|
+
raise ArgumentError, "align: is :left, :center or :right, not #{align.inspect}" unless ALIGNMENTS.key?(align)
|
|
152
|
+
|
|
153
|
+
points(:font_size, " or :auto") unless auto_size?
|
|
154
|
+
BOUNDS.each do |key|
|
|
155
|
+
next unless @options.key?(key)
|
|
156
|
+
raise ArgumentError, "#{key}: needs font_size: :auto" unless auto_size?
|
|
157
|
+
|
|
158
|
+
points(key)
|
|
159
|
+
end
|
|
160
|
+
return unless min_font_size && max_font_size && min_font_size > max_font_size
|
|
161
|
+
|
|
162
|
+
raise ArgumentError, "min_font_size: #{min_font_size} is above max_font_size: #{max_font_size}"
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def points(key, alternative = "")
|
|
166
|
+
value = @options[key]
|
|
167
|
+
return if value.is_a?(Numeric) && value.positive?
|
|
168
|
+
|
|
169
|
+
raise ArgumentError, "#{key}: is a number of points#{alternative}, not #{value.inspect}"
|
|
170
|
+
end
|
|
129
171
|
end
|
|
130
172
|
end
|
|
131
173
|
end
|
|
@@ -47,6 +47,16 @@ module Stationery
|
|
|
47
47
|
super
|
|
48
48
|
end
|
|
49
49
|
|
|
50
|
+
# A field's value is text: its colour is reported, or snapped in /DA
|
|
51
|
+
# and the appearance alike. Its frame is drawn as it is.
|
|
52
|
+
def widget(field, x, y, w, h, tag: nil)
|
|
53
|
+
if field.variable_text?
|
|
54
|
+
color, = @monochrome.paint(field.color, :text, @page)
|
|
55
|
+
field = field.with(color:) unless color.equal?(field.color)
|
|
56
|
+
end
|
|
57
|
+
super
|
|
58
|
+
end
|
|
59
|
+
|
|
50
60
|
# A gradient is reported by its stops; snapped, it is a black fill or nothing.
|
|
51
61
|
def shade_path(path, shading, matrix:, even_odd: false, opacity: nil)
|
|
52
62
|
return super unless shading.respond_to?(:stops)
|
|
@@ -27,6 +27,8 @@ module Stationery
|
|
|
27
27
|
OUTPUT_CONDITION = "sRGB IEC61966-2.1"
|
|
28
28
|
PRINT = 4
|
|
29
29
|
CMYK_OPERATOR = /^(?:-?[\d.]+ ){4}[kK]$/
|
|
30
|
+
# The colour a field's /DA sets, after its font and size.
|
|
31
|
+
CMYK_DEFAULT_APPEARANCE = /Tf (?:-?[\d.]+ ){4}k\z/
|
|
30
32
|
# What a character no font has does: :raise, or :replace to draw the
|
|
31
33
|
# font's stand-in for it (see Fonts::Font#stand_in).
|
|
32
34
|
MISSING_GLYPHS = %i[raise replace].freeze
|
|
@@ -174,7 +176,18 @@ module Stationery
|
|
|
174
176
|
pages.each_with_index do |page, index|
|
|
175
177
|
subjects << "CMYK colour on page #{index + 1}" if page.content.match?(CMYK_OPERATOR)
|
|
176
178
|
end
|
|
177
|
-
subjects
|
|
179
|
+
subjects + cmyk_fields(pages)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# A field's appearance and /DA are not page content: each is read on its own.
|
|
183
|
+
def cmyk_fields(pages)
|
|
184
|
+
widgets = pages.flat_map(&:annotations).select { |annotation| annotation[:widget] && annotation[:appearance] }
|
|
185
|
+
widgets.select { |annotation| cmyk_paint?(annotation[:appearance].paints) }
|
|
186
|
+
.map { |annotation| %(CMYK colour in form field "#{annotation[:widget].name}") }.uniq
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def cmyk_paint?(paints)
|
|
190
|
+
paints.any? { |paint| paint.match?(CMYK_OPERATOR) || paint.match?(CMYK_DEFAULT_APPEARANCE) }
|
|
178
191
|
end
|
|
179
192
|
end
|
|
180
193
|
end
|
|
@@ -23,17 +23,17 @@ class ApplicationFormPdf < Stationery::Document
|
|
|
23
23
|
text "Membership application", size: 20, weight: :bold, heading: 1
|
|
24
24
|
spacer 16
|
|
25
25
|
row(gap: 12) do
|
|
26
|
-
labelled("Full name") { text_field "applicant.name", value: @name, required: true }
|
|
27
|
-
labelled("Email") { text_field "applicant.email" }
|
|
26
|
+
labelled("Full name") { text_field "applicant.name", value: @name, required: true, tooltip: "Full name" }
|
|
27
|
+
labelled("Email") { text_field "applicant.email", tooltip: "Email" }
|
|
28
28
|
end
|
|
29
29
|
spacer 10
|
|
30
30
|
row(gap: 12) do
|
|
31
|
-
column(width: 90) { labelled_field("Postcode") { text_field "applicant.postcode", comb: 5 } }
|
|
32
|
-
labelled("Country") { select "applicant.country", options: %w[Sweden Norway Denmark], value: "Sweden" }
|
|
31
|
+
column(width: 90) { labelled_field("Postcode") { text_field "applicant.postcode", comb: 5, tooltip: "Postcode" } }
|
|
32
|
+
labelled("Country") { select "applicant.country", options: %w[Sweden Norway Denmark], value: "Sweden", tooltip: "Country" }
|
|
33
33
|
end
|
|
34
34
|
spacer 10
|
|
35
35
|
labelled_field("Plan") do
|
|
36
|
-
radio "plan", "basic", label: "Basic"
|
|
36
|
+
radio "plan", "basic", label: "Basic", tooltip: "Plan" # a radio group is named by its first choice
|
|
37
37
|
radio "plan", "pro", label: "Pro", checked: true
|
|
38
38
|
end
|
|
39
39
|
spacer 10
|
|
@@ -72,7 +72,10 @@ end
|
|
|
72
72
|
which paints the appearances the gem writes: `mutool draw -r 96 -o form-%d.png form.pdf`
|
|
73
73
|
(`pdftoppm` draws the values but not the borders). `stationery inspect form.pdf` lists each
|
|
74
74
|
field with its rectangle and value.
|
|
75
|
-
- Fields keep PDF/A and PDF/UA
|
|
75
|
+
- Fields keep PDF/A and PDF/UA. Every field has an accessible name: its `tooltip:`, else a check
|
|
76
|
+
box's or signature field's `label:`, else its field name (`applicant.name`), which is what a
|
|
77
|
+
screen reader then says. So a text field, a select and a radio group get a `tooltip:`, as above:
|
|
78
|
+
the text printed beside a field is not its name.
|
|
76
79
|
- Signing the file: `sign certificate:, key:, field: "signature"` fills the signature field with
|
|
77
80
|
a digital signature (see [../reference/conformance.md](../reference/conformance.md)).
|
|
78
81
|
- Every option of every field: [../reference/forms.md](../reference/forms.md) and <%= docs("forms") %>.
|
|
@@ -93,9 +93,9 @@ class InvoicePdf < Stationery::Document
|
|
|
93
93
|
|
|
94
94
|
def subtotal = @lines.sum { |line| line.quantity * line.unit_cents }
|
|
95
95
|
|
|
96
|
-
def vat_by_rate
|
|
97
|
-
@lines.group_by(&:vat_rate).
|
|
98
|
-
(lines.sum { |line| line.quantity * line.unit_cents } *
|
|
96
|
+
def vat_by_rate # exact: Rational, rounded once per rate
|
|
97
|
+
@lines.group_by(&:vat_rate).to_h do |rate, lines|
|
|
98
|
+
[rate, (lines.sum { |line| line.quantity * line.unit_cents } * Rational(rate, 100)).round]
|
|
99
99
|
end
|
|
100
100
|
end
|
|
101
101
|
|
|
@@ -108,9 +108,12 @@ end
|
|
|
108
108
|
|
|
109
109
|
## What matters here
|
|
110
110
|
|
|
111
|
-
- **Money is
|
|
112
|
-
|
|
113
|
-
|
|
111
|
+
- **Money is integer cents** (or `BigDecimal`), never a Float. This document derives the line
|
|
112
|
+
amounts and VAT from its lines, exactly (`Rational(rate, 100)`, rounded once per rate); an
|
|
113
|
+
application whose invoice already holds its totals, as its books have them, passes those in
|
|
114
|
+
and the document only formats them, so the PDF cannot disagree with the ledger by a cent.
|
|
115
|
+
Totals are ordinary table rows: a cell `{ content:, colspan: }` spans columns, `t.rows(-3..)`
|
|
116
|
+
selects the last three rows.
|
|
114
117
|
- **A table has black 0.5 pt borders on every cell by default.** `cell: { borders: [] }` takes
|
|
115
118
|
them away; `borders: [:top]` on a selection draws one rule. `header: true` repeats the first row
|
|
116
119
|
on every page the table continues onto; `t.zebra(from:, to:, color:)` stripes rows.
|
|
@@ -118,9 +121,10 @@ end
|
|
|
118
121
|
as wide as the page.
|
|
119
122
|
- **PDF/A-3b** (`conformance :pdf_a3b`) embeds the sRGB output intent and XMP identification, and
|
|
120
123
|
raises `Stationery::ConformanceError` instead of writing a file that breaks the claim: a CMYK
|
|
121
|
-
colour,
|
|
122
|
-
|
|
123
|
-
|
|
124
|
+
colour, or a character no font has (unless `missing_glyphs: :replace`, as above). `encrypt` with
|
|
125
|
+
a conformance level raises `ArgumentError` before anything renders: the two contradict each
|
|
126
|
+
other. Set `metadata title:`. Transparency (a PNG logo with alpha) is allowed. Validate the
|
|
127
|
+
file with veraPDF: `verapdf --format text -v --flavour 3b invoice_pdf.pdf` prints `PASS` (`brew install
|
|
124
128
|
verapdf`; with Docker, see [../reference/conformance.md](../reference/conformance.md)).
|
|
125
129
|
- An application that would rather send a plain PDF than none rescues the claim:
|
|
126
130
|
|
|
@@ -73,7 +73,8 @@ every change:
|
|
|
73
73
|
`CODE-128:SX0042771903` and `QR-Code:https://…`. A code it cannot read will not scan either.
|
|
74
74
|
4. **Check the native commands** are in the ZPL: `grep -o '\^B[CQEX][^^]*' label.zpl` shows `^BC`
|
|
75
75
|
(Code 128), `^BQ` (QR), `^BE` (EAN-13), `^BX` (Data Matrix). A barcode that is missing there
|
|
76
|
-
stayed in the picture (see below)
|
|
76
|
+
stayed in the picture (see below): it prints, on the dot grid as part of the `^GFA` graphic,
|
|
77
|
+
but drawn by stationery, not by the printer's own barcode encoder.
|
|
77
78
|
5. **Warnings**: `label.warnings` after `to_zpl` (or `to_png`) lists what a one-bit printer
|
|
78
79
|
cannot print as it is.
|
|
79
80
|
|
|
@@ -100,7 +101,8 @@ possibly another QR mask. (Online, labelary.com renders a `.zpl` file.)
|
|
|
100
101
|
dot grid. A barcode the printer could not draw as it is stays in the picture without a word: one
|
|
101
102
|
rotated or clipped, paler than half grey, a module wider than 10 dots (a QR code sized with a
|
|
102
103
|
large `width:` at 300 dpi), or a QR code of text outside ASCII. Size a native QR code by
|
|
103
|
-
`module_size
|
|
104
|
+
`module_size:`, at most 720 / dpi points so a module is 10 dots or fewer (3.5 pt at 203 dpi,
|
|
105
|
+
2.4 pt at 300, 1.2 pt at 600), and check the ZPL has its `^BQ` line.
|
|
104
106
|
Send it to port 9100: `TCPSocket.open(host, 9100) { |s| label.to_zpl(s) }`.
|
|
105
107
|
From the shell: `stationery render label_pdf.rb --zpl --dpi 300`.
|
|
106
108
|
- **Black and white only.** A thermal printer prints black or nothing. `monochrome dpi: 203` reports
|
|
@@ -6,6 +6,8 @@ the footer, a table that continues across pages and a chart. The examples in the
|
|
|
6
6
|
`stationery examples accessible_report --source` (PDF/UA-1, a chart as a figure).
|
|
7
7
|
|
|
8
8
|
```ruby
|
|
9
|
+
require "erb" # ERB::Util.html_escape, for data written into SVG
|
|
10
|
+
|
|
9
11
|
class ReportPdf < Stationery::Document
|
|
10
12
|
ACCENT = "#0F766E"
|
|
11
13
|
MUTED = "#6B7280"
|
|
@@ -63,14 +65,15 @@ class ReportPdf < Stationery::Document
|
|
|
63
65
|
spacer 8
|
|
64
66
|
end
|
|
65
67
|
|
|
66
|
-
# A bar chart as SVG, drawn from the data: there is no chart element.
|
|
68
|
+
# A bar chart as SVG, drawn from the data: there is no chart element. Text
|
|
69
|
+
# from the data is escaped, as "&" or "<" in it would break the XML.
|
|
67
70
|
def chart
|
|
68
71
|
top = @quarters.map(&:last).max
|
|
69
72
|
bars = @quarters.each_with_index.map do |(label, value), i|
|
|
70
73
|
height = (value / top * 150).round(1)
|
|
71
74
|
x = 30 + (i * 110)
|
|
72
75
|
%(<rect x="#{x}" y="#{170 - height}" width="70" height="#{height}" fill="currentColor"/>) +
|
|
73
|
-
%(<text x="#{x + 35}" y="190" font-size="12" text-anchor="middle">#{label}</text>) +
|
|
76
|
+
%(<text x="#{x + 35}" y="190" font-size="12" text-anchor="middle">#{ERB::Util.html_escape(label)}</text>) +
|
|
74
77
|
%(<text x="#{x + 35}" y="#{164 - height}" font-size="11" text-anchor="middle">#{value}</text>)
|
|
75
78
|
end
|
|
76
79
|
%(<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 460 200">#{bars.join}</svg>)
|
|
@@ -91,13 +94,15 @@ end
|
|
|
91
94
|
same page. `heading: 1..6` makes a heading in a tagged PDF; do not skip a level (1, then 3).
|
|
92
95
|
- **Long tables** continue on the next page with `header: true` rows repeated; a row never splits
|
|
93
96
|
unless it is taller than a page (or `split_rows: true`). Rows given as an Enumerator
|
|
94
|
-
(`Order.find_each.lazy.map { |o| [o.number, o.total] }
|
|
97
|
+
(`Order.preload(:customer).find_each.lazy.map { |o| [o.number, o.customer.name, o.total] }`,
|
|
98
|
+
preloading what each row reads) to a table with a width for every
|
|
95
99
|
column (`widths: [80, 0.5, 90]`) are read as pages reach them, so the rows of about a page are
|
|
96
100
|
held at a time, however many there are; such a table selects rows from its start only (`t.row(0)`, `t.rows(1..)`,
|
|
97
101
|
`zebra`), and none of its cells may span rows. With a column without a width the rows are read
|
|
98
102
|
when the table is built.
|
|
99
103
|
- **Charts are SVG or `canvas`.** Build the SVG from the data as above (`currentColor` takes
|
|
100
|
-
`color:`, `<text>` is set in the document's fonts)
|
|
104
|
+
`color:`, `<text>` is set in the document's fonts), and escape every string from the data with
|
|
105
|
+
`ERB::Util.html_escape`: an "&" or "<" in a label breaks the SVG or adds elements to it. An `svg` is drawn at its viewBox size in
|
|
101
106
|
points, shrunk to the width there is; `width:` is points (`width: 1.0` is one point, not the
|
|
102
107
|
whole width). Or draw the shapes on a canvas (its `text` takes a font object, so label a canvas
|
|
103
108
|
chart with SVG or with `text` elements around it):
|
|
@@ -10,8 +10,9 @@ require_relative "layout"
|
|
|
10
10
|
module Stationery
|
|
11
11
|
module Testing
|
|
12
12
|
# Reads a rendered PDF back for assertions. The subject is a
|
|
13
|
-
# Stationery::Document, PDF bytes, a path (String or Pathname) or an IO
|
|
14
|
-
# Needs the pdf-reader gem, loaded on
|
|
13
|
+
# Stationery::Document, PDF bytes, a path (String or Pathname) or an IO;
|
|
14
|
+
# `password:` opens an encrypted one. Needs the pdf-reader gem, loaded on
|
|
15
|
+
# first use.
|
|
15
16
|
class Inspector
|
|
16
17
|
UTF16_BOM = "\xFE\xFF".b
|
|
17
18
|
# id-aa-signatureTimeStampToken: a signature's RFC 3161 timestamp.
|
|
@@ -23,9 +24,11 @@ module Stationery
|
|
|
23
24
|
COLOR_OPERATORS = { "g" => 1, "G" => 1, "rg" => 3, "RG" => 3, "k" => 4, "K" => 4 }.freeze
|
|
24
25
|
NUMBER = /\A[-+]?(?:\d+\.?\d*|\.\d+)\z/
|
|
25
26
|
LITERAL_STRING = /\((?:\\.|[^\\()])*\)/m
|
|
27
|
+
NEEDS_READER = 'Stationery::Testing needs pdf-reader: add gem "pdf-reader" to your test group'
|
|
26
28
|
|
|
27
|
-
def initialize(subject)
|
|
29
|
+
def initialize(subject, password: nil)
|
|
28
30
|
@subject = subject
|
|
31
|
+
@password = password
|
|
29
32
|
end
|
|
30
33
|
|
|
31
34
|
def pdf
|
|
@@ -40,9 +43,9 @@ module Stationery
|
|
|
40
43
|
def reader
|
|
41
44
|
@reader ||= begin
|
|
42
45
|
require "pdf/reader"
|
|
43
|
-
::PDF::Reader.new(StringIO.new(pdf))
|
|
46
|
+
::PDF::Reader.new(StringIO.new(pdf), **{ password: @password }.compact)
|
|
44
47
|
rescue LoadError
|
|
45
|
-
raise Stationery::Error,
|
|
48
|
+
raise Stationery::Error, NEEDS_READER
|
|
46
49
|
end
|
|
47
50
|
end
|
|
48
51
|
|
|
@@ -18,6 +18,9 @@ module Stationery
|
|
|
18
18
|
# The Radio and Pushbutton flags of a button field's /Ff.
|
|
19
19
|
RADIO = 1 << 15
|
|
20
20
|
PUSHBUTTON = 1 << 16
|
|
21
|
+
ALIGNMENTS = %i[left center right].freeze
|
|
22
|
+
# The font size and colour operator of a /DA: "/F1 10 Tf 0.5 g".
|
|
23
|
+
DEFAULT_APPEARANCE = /([\d.]+) Tf\s+((?:-?[\d.]+\s+)+)(g|rg|k)\s*\z/
|
|
21
24
|
|
|
22
25
|
def initialize(inspector)
|
|
23
26
|
@inspector = inspector
|
|
@@ -98,10 +101,24 @@ module Stationery
|
|
|
98
101
|
name = chain.filter_map { |field| field[:T] && decode(field[:T]) }.reverse.join(".")
|
|
99
102
|
type = inherit(chain, :FT)
|
|
100
103
|
{ name:, type: field_type(type, inherit(chain, :Ff).to_i), value: value(inherit(chain, :V)),
|
|
101
|
-
**rect(widget, left, top), state: state(widget) }.compact
|
|
104
|
+
**rect(widget, left, top), state: state(widget), **style(chain) }.compact
|
|
102
105
|
end
|
|
103
106
|
end
|
|
104
107
|
|
|
108
|
+
# The alignment, size (:auto for 0) and colour a field's value is
|
|
109
|
+
# drawn in; nothing for a field without variable text.
|
|
110
|
+
def style(chain)
|
|
111
|
+
match = DEFAULT_APPEARANCE.match(inherit(chain, :DA).to_s) or return {}
|
|
112
|
+
|
|
113
|
+
size, values, operator = match.captures
|
|
114
|
+
values = values.split.map(&:to_f)
|
|
115
|
+
values *= 3 if operator == "g"
|
|
116
|
+
{ align: ALIGNMENTS.fetch(inherit(chain, :Q).to_i, :left), font_size: size.to_f.zero? ? :auto : number(size),
|
|
117
|
+
color: Monochrome.hex(Color.new(operator == "k" ? :cmyk : :rgb, values)) }
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def number(text) = text.include?(".") ? text.to_f : text.to_i
|
|
121
|
+
|
|
105
122
|
def parents(widget)
|
|
106
123
|
chain = [widget]
|
|
107
124
|
chain << @objects.deref_hash(chain.last[:Parent]) while chain.last[:Parent] && chain.size < 32
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stationery
|
|
4
|
+
module Verify
|
|
5
|
+
# The engine messages that are the check's environment and not the PDF:
|
|
6
|
+
# each names the engine, a pattern and why. Everything else an engine
|
|
7
|
+
# says fails the check. An entry is added only once the cause is found
|
|
8
|
+
# in the engine's source and the file; a file that lacks what a viewer
|
|
9
|
+
# needs is a bug in the gem, not an entry here.
|
|
10
|
+
module Allowlist
|
|
11
|
+
ENTRIES = [
|
|
12
|
+
{ engine: "pdfjs", pattern: /\AWarning: _getAppearance: OffscreenCanvas is not supported/,
|
|
13
|
+
why: "Node has no OffscreenCanvas. pdf.js builds a text field's appearance again when the form sets " \
|
|
14
|
+
"/NeedAppearances, which Forms::AcroForm does for a form that is neither signed nor PDF/A or " \
|
|
15
|
+
"PDF/UA, and warns whenever it builds one without OffscreenCanvas. Every widget carries its /AP." },
|
|
16
|
+
{ engine: "poppler", pattern: /\ANSS_Init failed: security library: bad database\.\z/,
|
|
17
|
+
why: "pdfsig opens an NSS certificate database to judge trust, and says so when the machine has none " \
|
|
18
|
+
"(~/.pki/nssdb). Whether the signature verifies does not need it." },
|
|
19
|
+
{ engine: "mupdf", pattern: /\Awarning: ICC support is not available\z/,
|
|
20
|
+
why: "Debian's mutool (mupdf-tools, in the verify image) is built without colour management and says so " \
|
|
21
|
+
"before it opens any file, even one that is not a PDF." }
|
|
22
|
+
].freeze
|
|
23
|
+
|
|
24
|
+
def self.allows?(entries, engine, message)
|
|
25
|
+
entries.any? { |entry| entry[:engine] == engine && entry[:pattern].match?(message) }
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "tempfile"
|
|
4
|
+
require "tmpdir"
|
|
5
|
+
|
|
6
|
+
module Stationery
|
|
7
|
+
module Verify
|
|
8
|
+
# Runs an engine's tool: an argument array, never through a shell, its
|
|
9
|
+
# output written to files as it runs (so a large one cannot block it),
|
|
10
|
+
# in `chdir` (the temporary directory unless asked: a tool that loads
|
|
11
|
+
# modules from where it runs must not load a checked file's neighbours),
|
|
12
|
+
# and its process group killed when it takes longer than `timeout`
|
|
13
|
+
# seconds (swift runs the compiled script as a child of its own).
|
|
14
|
+
module Command
|
|
15
|
+
POLL = 0.01 # seconds between looks at a running child
|
|
16
|
+
|
|
17
|
+
# `timeout` is the seconds the tool was given.
|
|
18
|
+
Result = Data.define(:stdout, :stderr, :status, :timed_out, :timeout) do
|
|
19
|
+
def success? = !timed_out && status&.success? == true
|
|
20
|
+
def exitstatus = status&.exitstatus
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.run(*argv, env: {}, timeout: 60, chdir: Dir.tmpdir)
|
|
24
|
+
Tempfile.create("stationery-verify-out") do |out|
|
|
25
|
+
Tempfile.create("stationery-verify-err") do |err|
|
|
26
|
+
status, timed_out = spawn(argv, env:, timeout:, chdir:, out:, err:)
|
|
27
|
+
Result.new(stdout: read(out), stderr: read(err), status:, timed_out:, timeout:)
|
|
28
|
+
rescue SystemCallError => e # the program could not start: that tool's failure, not the run's
|
|
29
|
+
Result.new(stdout: "", stderr: "#{e.message}\n", status: nil, timed_out: false, timeout:)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def self.spawn(argv, env:, timeout:, chdir:, out:, err:)
|
|
35
|
+
# [program, argv0] never goes through a shell, whatever argv holds.
|
|
36
|
+
pid = Process.spawn(env, [argv.first, argv.first], *argv.drop(1), in: File::NULL, out:, err:, chdir:,
|
|
37
|
+
pgroup: true)
|
|
38
|
+
waited = wait(pid, timeout)
|
|
39
|
+
ensure
|
|
40
|
+
kill(pid) if pid && !waited
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# What a tool wrote, as UTF-8 with anything that is not replaced.
|
|
44
|
+
def self.read(file) = File.read(file.path, encoding: Encoding::UTF_8).scrub
|
|
45
|
+
|
|
46
|
+
# The executable `name` on the PATH (or at `name`, a path from the
|
|
47
|
+
# working directory, answered absolute), or nil.
|
|
48
|
+
def self.which(name)
|
|
49
|
+
if name.include?(File::SEPARATOR)
|
|
50
|
+
path = File.expand_path(name)
|
|
51
|
+
return (path if File.file?(path) && File.executable?(path))
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).map { |dir| File.join(dir, name) }
|
|
55
|
+
.find { |path| File.file?(path) && File.executable?(path) }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def self.wait(pid, timeout)
|
|
59
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
|
|
60
|
+
loop do
|
|
61
|
+
_, status = Process.waitpid2(pid, Process::WNOHANG)
|
|
62
|
+
return [status, false] if status
|
|
63
|
+
|
|
64
|
+
break if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
|
|
65
|
+
|
|
66
|
+
sleep POLL
|
|
67
|
+
end
|
|
68
|
+
[kill(pid), true]
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Kills the child's process group and answers the child's status.
|
|
72
|
+
def self.kill(pid)
|
|
73
|
+
Process.kill(:KILL, -pid)
|
|
74
|
+
Process.waitpid2(pid).last
|
|
75
|
+
rescue Errno::ESRCH, Errno::ECHILD
|
|
76
|
+
nil # it was gone already
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|