stationery 0.12.1 → 0.12.3

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 (38) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +4 -0
  3. data/README.md +108 -16
  4. data/examples/price_card.rb +87 -0
  5. data/lib/stationery/cli/layout_text.rb +14 -2
  6. data/lib/stationery/cli/verify.rb +98 -0
  7. data/lib/stationery/cli.rb +1 -0
  8. data/lib/stationery/elements/forms.rb +4 -2
  9. data/lib/stationery/forms/appearance.rb +27 -21
  10. data/lib/stationery/forms/auto_size.rb +94 -0
  11. data/lib/stationery/forms/field.rb +49 -3
  12. data/lib/stationery/monochrome/painting.rb +10 -0
  13. data/lib/stationery/pdf/conformance.rb +14 -1
  14. data/lib/stationery/skill/recipes/form.md.erb +9 -6
  15. data/lib/stationery/skill/recipes/invoice.md.erb +13 -9
  16. data/lib/stationery/skill/recipes/label.md.erb +4 -2
  17. data/lib/stationery/skill/recipes/report.md.erb +9 -4
  18. data/lib/stationery/testing/inspector.rb +8 -5
  19. data/lib/stationery/testing/layout.rb +18 -1
  20. data/lib/stationery/verify/allowlist.rb +29 -0
  21. data/lib/stationery/verify/command.rb +80 -0
  22. data/lib/stationery/verify/comparison.rb +191 -0
  23. data/lib/stationery/verify/engine.rb +63 -0
  24. data/lib/stationery/verify/engines/mupdf.rb +44 -0
  25. data/lib/stationery/verify/engines/pdfium.rb +35 -0
  26. data/lib/stationery/verify/engines/pdfjs.rb +41 -0
  27. data/lib/stationery/verify/engines/pdfkit.rb +20 -0
  28. data/lib/stationery/verify/engines/poppler.rb +83 -0
  29. data/lib/stationery/verify/engines/qpdf.rb +46 -0
  30. data/lib/stationery/verify/expectation.rb +44 -0
  31. data/lib/stationery/verify/report.rb +49 -0
  32. data/lib/stationery/verify/scripts/pdfium.py +99 -0
  33. data/lib/stationery/verify/scripts/pdfjs.mjs +108 -0
  34. data/lib/stationery/verify/scripts/pdfkit.swift +80 -0
  35. data/lib/stationery/verify.rb +89 -0
  36. data/lib/stationery/version.rb +1 -1
  37. data/lib/stationery.rb +1 -0
  38. 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` or `:fit`.
21
+ BOUNDS = %i[min_font_size max_font_size].freeze
22
+ STYLE = [:align, :color, *BOUNDS].freeze
19
23
  OPTIONS = {
20
- text: %i[multiline max_length comb], checkbox: [], radio: %i[checked], select: %i[options editable],
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,28 @@ 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
+ # Whether it is the size `:auto` would draw, written as a number (`font_size: :fit`).
59
+ def fit? = font_size == :fit
60
+ # Whether the gem chooses the size the value is drawn at (`:auto` or `:fit`).
61
+ def sized? = auto_size? || fit?
62
+ def min_font_size = @options[:min_font_size]
63
+ def max_font_size = @options[:max_font_size]
64
+ def align = @options.fetch(:align, :left)
42
65
  # Whether a viewer redraws its text when the value changes.
43
66
  def variable_text? = VARIABLE_TEXT.include?(@kind)
44
67
  # The accessible name: `tooltip:`, a signature's label, else the name.
@@ -62,6 +85,7 @@ module Stationery
62
85
  def field_entries(value = field_value, default_appearance: nil)
63
86
  entries = { FT: type, TU: PDF::TextString.new(tooltip) }
64
87
  entries[:DA] = default_appearance if default_appearance
88
+ entries[:Q] = ALIGNMENTS.fetch(align) unless align == :left
65
89
  entries[:Ff] = flags if flags.positive?
66
90
  entries[:V] = value unless value.nil?
67
91
  entries[:MaxLen] = max_length if max_length
@@ -126,6 +150,28 @@ module Stationery
126
150
 
127
151
  raise ArgumentError, "a comb field needs max_length: (or comb: <cells>)"
128
152
  end
153
+
154
+ def validate_style
155
+ raise ArgumentError, "align: is :left, :center or :right, not #{align.inspect}" unless ALIGNMENTS.key?(align)
156
+
157
+ points(:font_size, ", :auto or :fit") unless sized?
158
+ BOUNDS.each do |key|
159
+ next unless @options.key?(key)
160
+ raise ArgumentError, "#{key}: needs font_size: :auto or :fit" unless sized?
161
+
162
+ points(key)
163
+ end
164
+ return unless min_font_size && max_font_size && min_font_size > max_font_size
165
+
166
+ raise ArgumentError, "min_font_size: #{min_font_size} is above max_font_size: #{max_font_size}"
167
+ end
168
+
169
+ def points(key, alternative = "")
170
+ value = @options[key]
171
+ return if value.is_a?(Numeric) && value.positive?
172
+
173
+ raise ArgumentError, "#{key}: is a number of points#{alternative}, not #{value.inspect}"
174
+ end
129
175
  end
130
176
  end
131
177
  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 (every field has an accessible name from `tooltip:` or its label).
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).transform_values do |lines|
98
- (lines.sum { |line| line.quantity * line.unit_cents } * lines.first.vat_rate / 100.0).round
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 computed by the application**, in integer cents or `BigDecimal`, never in Floats;
112
- the document only formats it. Totals are ordinary table rows: a cell `{ content:, colspan: }`
113
- spans columns, `t.rows(-3..)` selects the last three rows.
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, `encrypt`, or a character no font has (unless `missing_glyphs: :replace`, as above). Set
122
- `metadata title:`. Transparency (a PNG logo with alpha) is allowed. Validate the file with
123
- veraPDF: `verapdf --format text -v --flavour 3b invoice_pdf.pdf` prints `PASS` (`brew install
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), which prints too, but not on the printer's own dot grid.
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:` (1 to 2 pt) and check the ZPL has its `^BQ` line.
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] }`) to a table with a width for every
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). An `svg` is drawn at its viewBox size in
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 first use.
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, 'Stationery::Testing needs pdf-reader: add gem "pdf-reader" to your test group'
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