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,191 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stationery
4
+ module Verify
5
+ # Holds one engine's facts about a file (see Verify) against the
6
+ # Expectation and answers the problems, as lines a person reads. A fact
7
+ # the engine does not report (nil) is not held to anything; one it does
8
+ # report is held to the expectation.
9
+ #
10
+ # Text is "found", not "equal": both sides are normalized the same way
11
+ # (NFKC, no whitespace, no soft hyphens), then each of the expectation's
12
+ # lines for a page must appear in the engine's text for that page. Every
13
+ # error and warning is a problem unless the allowlist names it.
14
+ class Comparison
15
+ IGNORED = /[[:space:]\u00AD]/
16
+ # A list's marker: 1. 10) a. iv) or a bullet.
17
+ MARKER = /\A(?:\d{1,3}|[a-z]|[ivxlcdm]{1,6})[.)]\z|\A[\u2022\u25E6\u25AA\u2023\u2013\u2014*-]\z/i
18
+ MARKER_PREFIX = /\A(?:(?:\d{1,3}|[a-z]|[ivxlcdm]{1,6})[.)]|[\u2022\u25E6\u25AA\u2023\u2013\u2014*-])/i
19
+ SHOWN = 5 # text lines not found named on a page; the rest are counted
20
+ BARE_ORIGIN = %r{\A(https?://[^/?#]+)\z}i
21
+
22
+ def initialize(expectation, facts, engine:, allowlist: Allowlist::ENTRIES)
23
+ @expectation = expectation
24
+ @facts = facts
25
+ @engine = engine.to_s
26
+ @allowlist = allowlist
27
+ end
28
+
29
+ def problems
30
+ [*messages("errors", "error"), *messages("warnings", "warning"), *page_count, *pages, *fonts, *fields,
31
+ *listed("outline", @expectation.outline, ordered: true),
32
+ *listed("attachments", @expectation.attachments, ordered: false),
33
+ *signatures, *structure]
34
+ end
35
+
36
+ private
37
+
38
+ def messages(key, label)
39
+ Array(@facts[key]).uniq.reject { |message| Allowlist.allows?(@allowlist, @engine, message) }
40
+ .map { |message| "#{label}: #{message}" }
41
+ end
42
+
43
+ def read_pages = Array(@facts["pages"])
44
+
45
+ def page_count
46
+ count = read_pages.size
47
+ expected = @expectation.pages.size
48
+ count == expected ? [] : ["#{count} pages, expected #{expected}"]
49
+ end
50
+
51
+ def pages
52
+ @expectation.pages.zip(read_pages).flat_map do |expected, read|
53
+ next [] unless read
54
+
55
+ [*text(expected, read["text"]), *painted(expected, read["painted"]), *links(expected, read["links"])]
56
+ .map { |problem| "page #{expected.number}: #{problem}" }
57
+ end
58
+ end
59
+
60
+ def text(expected, text)
61
+ return [] if text.nil?
62
+
63
+ found = normalize(text)
64
+ items = expected.lines.filter_map { |line| split_marker(line)[1] }
65
+ missing = expected.lines.reject { |line| found?(found, line, items) }
66
+ more = missing.size > SHOWN ? ["text not found: #{missing.size - SHOWN} more lines"] : []
67
+ missing.first(SHOWN).map { |line| "text not found: #{line.inspect}" } + more
68
+ end
69
+
70
+ # A line is found whole, or, when it starts with a list's marker, as
71
+ # the rest found whole with the marker right after it, or before it
72
+ # with nothing between them but other markers and other items of the
73
+ # page. The page draws a marker apart from its item (its own Lbl, its
74
+ # own colour) and an engine may read the markers as a column of their
75
+ # own (PDFKit on macOS 26: "1. 2. 3. First… Second… Third…").
76
+ def found?(found, line, items = [])
77
+ return true if whole?(found, normalize(line))
78
+
79
+ marker, rest = split_marker(line)
80
+ rest ? marked?(found, marker, rest, items) : false
81
+ end
82
+
83
+ # [marker, rest], normalized, for a line that starts with a marker.
84
+ def split_marker(line)
85
+ marker, rest = line.strip.split(/[[:space:]]+/, 2)
86
+ rest && marker.match?(MARKER) ? [normalize(marker), normalize(rest)] : []
87
+ end
88
+
89
+ def marked?(found, marker, rest, items)
90
+ offsets(found, rest).any? do |start|
91
+ found[start + rest.size, marker.size] == marker ||
92
+ offsets(found[0...start], marker).any? { |at| list?(found[(at + marker.size)...start], items) }
93
+ end
94
+ end
95
+
96
+ # Whether `gap` is made of markers and the items of the page's lists,
97
+ # every way of reading it tried (an item may start with something
98
+ # shaped like a marker: "3.5 mm screws"), each offset once.
99
+ def list?(gap, items, from = 0, seen = {})
100
+ return true if from == gap.size
101
+ return false if seen[from]
102
+
103
+ seen[from] = true
104
+ rest = gap[from..]
105
+ steps = [rest[MARKER_PREFIX], *items.select { |item| !item.empty? && rest.start_with?(item) }].compact
106
+ steps.any? { |step| list?(gap, items, from + step.size, seen) }
107
+ end
108
+
109
+ def offsets(text, part) = text.enum_for(:scan, part).map { Regexp.last_match.begin(0) }
110
+
111
+ # A line that ends in a hyphen is found without it too: an engine may
112
+ # join a word broken across lines (pdftotext does).
113
+ def whole?(found, line) = found.include?(line) || (line.end_with?("-") && found.include?(line.chomp("-")))
114
+
115
+ def normalize(text) = text.unicode_normalize(:nfkc).gsub(IGNORED, "")
116
+
117
+ def painted(expected, painted)
118
+ painted == false && expected.content ? ["painted blank, but it has content"] : []
119
+ end
120
+
121
+ def links(expected, links)
122
+ return [] if links.nil?
123
+
124
+ read = links.map { |link| target(link.transform_keys(&:to_sym)) }
125
+ count = read.size == expected.links.size ? [] : ["#{read.size} links, expected #{expected.links.size}"]
126
+ missing = expected.links.reject(&:empty?).reject do |link|
127
+ index = read.index(target(link))
128
+ read.delete_at(index) if index
129
+ end
130
+ count + missing.map { |link| "link not found: #{link.inspect}" }
131
+ end
132
+
133
+ # A web address with no path is the same with the "/" pdf.js adds.
134
+ def target(link) = link[:uri] ? { uri: link[:uri].sub(BARE_ORIGIN, '\\1/') } : link
135
+
136
+ def fonts
137
+ Array(@facts["fonts"]).flat_map do |font|
138
+ [("font #{font["name"]} is not embedded" if font["embedded"] == false),
139
+ ("font #{font["name"]} has no ToUnicode" if font["unicode"] == false)].compact
140
+ end
141
+ end
142
+
143
+ def fields
144
+ read = @facts["fields"]
145
+ return [] if read.nil?
146
+
147
+ @expectation.fields.flat_map do |field|
148
+ widgets = read.select { |widget| widget["name"] == field[:name] }
149
+ next ["field #{field[:name]} not found"] if widgets.empty?
150
+
151
+ [*value(field, widgets), *appearance(field, widgets)]
152
+ end
153
+ end
154
+
155
+ def appearance(field, widgets)
156
+ return [] unless field[:visible] && widgets.any? { |widget| widget["appearance"] == false }
157
+
158
+ ["field #{field[:name]} has no appearance"]
159
+ end
160
+
161
+ def value(field, widgets)
162
+ return [] unless field[:type] == :text && field[:value].is_a?(String)
163
+
164
+ values = widgets.filter_map { |widget| widget["value"] }
165
+ values.reject { it == field[:value] }.first(1).map do |value|
166
+ "field #{field[:name]} holds #{value.inspect}, expected #{field[:value].inspect}"
167
+ end
168
+ end
169
+
170
+ def listed(key, expected, ordered:)
171
+ read = @facts[key]
172
+ return [] if read.nil? || (ordered ? read == expected : read.sort == expected.sort)
173
+
174
+ ["#{key} #{read.inspect}, expected #{expected.inspect}"]
175
+ end
176
+
177
+ def signatures
178
+ read = @facts["signatures"]
179
+ return [] if read.nil?
180
+
181
+ expected = @expectation.signatures
182
+ count = read["count"] == expected ? [] : ["#{read["count"]} signatures, expected #{expected}"]
183
+ count + (read["valid"] == false ? ["a signature does not verify"] : [])
184
+ end
185
+
186
+ def structure
187
+ @facts["structure"] == false && @expectation.tagged ? ["no structure tree, but the file is tagged"] : []
188
+ end
189
+ end
190
+ end
191
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "tempfile"
5
+ require "tmpdir"
6
+
7
+ module Stationery
8
+ module Verify
9
+ # What every engine adapter answers: its `name`, the line that
10
+ # installs it, whether it is `available?`, its `version`, and
11
+ # `facts(paths, password:)`, one Hash of facts per path (see Verify).
12
+ class Engine
13
+ TIMEOUT = 60 # seconds for each file
14
+
15
+ def name = self.class::NAME
16
+ def install = self.class::INSTALL
17
+
18
+ private
19
+
20
+ def run(*argv, env: {}, timeout: TIMEOUT, **) = Command.run(*argv, env:, timeout:, **)
21
+
22
+ def failed(path, message) = { "file" => path, "pages" => [], "errors" => [message], "warnings" => [] }
23
+
24
+ # Why a tool failed, from its result: timed out, or its exit status
25
+ # and the last lines it wrote to stderr.
26
+ def failure(tool, result)
27
+ return "#{tool} timed out after #{result.timeout} s" if result.timed_out
28
+
29
+ ended = result.exitstatus ? "exited #{result.exitstatus}" : "did not finish"
30
+ ["#{tool} #{ended}", *result.stderr.lines.last(3).map(&:strip)].join(": ")
31
+ end
32
+
33
+ def lines(text) = text.to_s.lines.map(&:strip).reject(&:empty?)
34
+
35
+ # Whether a binary PGM (P5, 8-bit) has a pixel that is not white.
36
+ def painted?(pgm)
37
+ header = pgm.match(/\AP5\s+(\d+)\s+(\d+)\s+(\d+)\s/n) or return false
38
+ width = header[1].to_i
39
+ height = header[2].to_i
40
+ pixels = pgm.byteslice(header[0].bytesize, width * height).to_s
41
+ pixels.count("\xFF".b) != pixels.bytesize
42
+ end
43
+
44
+ # Runs a script that prints one JSON line of facts per path; a path
45
+ # the script did not answer for fails with why the script stopped.
46
+ def scripted(argv, paths, env:)
47
+ result = run(*argv, *paths, env:, timeout: (TIMEOUT * paths.size) + TIMEOUT)
48
+ answers = lines(result.stdout).select { |line| line.start_with?("{") }.filter_map { |line| parse(line) }
49
+ read = answers.to_h { |facts| [facts["file"], facts] }
50
+ paths.map { |path| read.fetch(path) { failed(path, failure(argv.first, result)) } }
51
+ end
52
+
53
+ # A line that is not JSON answers for no file, which then fails.
54
+ def parse(line)
55
+ JSON.parse(line)
56
+ rescue JSON::ParserError
57
+ nil
58
+ end
59
+
60
+ def password_env(password) = password ? { "STATIONERY_VERIFY_PASSWORD" => password } : {}
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stationery
4
+ module Verify
5
+ module Engines
6
+ # MuPDF (`mutool draw`): each page drawn in grey and as text. What it
7
+ # writes on stderr fails the check. mutool takes a password on its
8
+ # command line only, where other local processes can read it.
9
+ class Mupdf < Engine
10
+ NAME = "mupdf"
11
+ INSTALL = "brew install mupdf, or apt-get install mupdf-tools"
12
+
13
+ def available? = !Command.which("mutool").nil?
14
+ def version = "mutool #{run("mutool", "-v").stderr[/\d+(\.\d+)+/]}"
15
+
16
+ def facts(paths, password: nil)
17
+ paths.map { |path| Dir.mktmpdir("stationery-verify") { |dir| read(path, password, dir) } }
18
+ end
19
+
20
+ private
21
+
22
+ def read(path, password, dir)
23
+ options = password ? ["-p", password] : []
24
+ draws = %w[pgm txt].map do |format|
25
+ run("mutool", "draw", "-q", *options, "-r", "36", "-F", format, "-o", File.join(dir, "p%d.#{format}"), path)
26
+ end
27
+ errors = draws.reject(&:success?).map { |draw| failure("mutool draw", draw) }
28
+ { "file" => path, "pages" => pages(dir), "errors" => errors,
29
+ "warnings" => draws.flat_map { |draw| lines(draw.stderr) }.uniq }
30
+ end
31
+
32
+ # The pages MuPDF drew, in order, each with its picture and its text.
33
+ def pages(dir)
34
+ pictures = Dir[File.join(dir, "p*.pgm")].sort_by { |file| file[/(\d+)\.pgm\z/, 1].to_i }
35
+ pictures.map.with_index(1) do |picture, number|
36
+ text = File.join(dir, "p#{number}.txt")
37
+ { "number" => number, "painted" => painted?(File.binread(picture)),
38
+ "text" => File.exist?(text) ? File.read(text, encoding: Encoding::UTF_8) : nil }
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stationery
4
+ module Verify
5
+ module Engines
6
+ # PDFium, the engine of Chrome and Edge, through pypdfium2 and
7
+ # scripts/pdfium.py. STATIONERY_PYTHON names the Python that has it.
8
+ class Pdfium < Engine
9
+ NAME = "pdfium"
10
+ INSTALL = "pip install pypdfium2 (STATIONERY_PYTHON names the Python that has it)"
11
+ SCRIPT = File.expand_path("../scripts/pdfium.py", __dir__)
12
+ VERSION = "import pypdfium2.version as v; print(v.PYPDFIUM_INFO, v.PDFIUM_INFO)"
13
+
14
+ # -P everywhere: Python loads no module from beside the script or from
15
+ # the working directory, and still finds a pip install --user.
16
+ def available? = !Command.which(python).nil? && run(python, "-P", "-c", "import pypdfium2").success?
17
+
18
+ def version
19
+ versions = run(python, "-P", "-c", VERSION).stdout.split
20
+ "pypdfium2 #{versions[0]}, PDFium #{versions[1]}"
21
+ end
22
+
23
+ def facts(paths, password: nil) = scripted([python, "-P", SCRIPT], paths, env: password_env(password))
24
+
25
+ private
26
+
27
+ # Absolute: the tools run in the temporary directory.
28
+ def python
29
+ name = ENV.fetch("STATIONERY_PYTHON", "python3")
30
+ name.include?(File::SEPARATOR) ? File.expand_path(name) : name
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stationery
4
+ module Verify
5
+ module Engines
6
+ # pdf.js, the engine of Firefox, through Node, pdfjs-dist and
7
+ # scripts/pdfjs.mjs. STATIONERY_PDFJS names pdfjs-dist's directory;
8
+ # without it, the one Node resolves from the working directory, whose
9
+ # code then runs: set STATIONERY_PDFJS where that directory is not yours.
10
+ class Pdfjs < Engine
11
+ NAME = "pdfjs"
12
+ INSTALL = "npm i pdfjs-dist (STATIONERY_PDFJS names its directory)"
13
+ SCRIPT = File.expand_path("../scripts/pdfjs.mjs", __dir__)
14
+ RESOLVE = "console.log(require('path').dirname(require.resolve('pdfjs-dist/package.json')))"
15
+
16
+ def available? = !directory.nil?
17
+ def version = "pdfjs-dist #{JSON.parse(File.read(File.join(directory, "package.json")))["version"]}"
18
+
19
+ def facts(paths, password: nil)
20
+ scripted(["node", SCRIPT], paths, env: { "STATIONERY_PDFJS" => directory, **password_env(password) })
21
+ end
22
+
23
+ private
24
+
25
+ def directory
26
+ return @directory if defined?(@directory)
27
+
28
+ found = ENV["STATIONERY_PDFJS"] ? File.expand_path(ENV["STATIONERY_PDFJS"]) : resolved
29
+ @directory = found if found && File.file?(File.join(found, "legacy/build/pdf.mjs"))
30
+ end
31
+
32
+ def resolved
33
+ return unless Command.which("node")
34
+
35
+ result = run("node", "-e", RESOLVE, chdir: Dir.pwd)
36
+ result.stdout.strip if result.success?
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stationery
4
+ module Verify
5
+ module Engines
6
+ # PDFKit, the engine of Preview and Safari, through scripts/pdfkit.swift.
7
+ # macOS only; Swift comes with Xcode or its command line tools.
8
+ class Pdfkit < Engine
9
+ NAME = "pdfkit"
10
+ INSTALL = "macOS only: xcode-select --install"
11
+ SCRIPT = File.expand_path("../scripts/pdfkit.swift", __dir__)
12
+
13
+ def available? = RUBY_PLATFORM.include?("darwin") && !Command.which("swift").nil?
14
+ def version = "macOS #{run("sw_vers", "-productVersion").stdout.strip}"
15
+
16
+ def facts(paths, password: nil) = scripted(["swift", SCRIPT], paths, env: password_env(password))
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stationery
4
+ module Verify
5
+ module Engines
6
+ # Poppler, the engine of the Linux desktop viewers (Evince, Okular):
7
+ # pdfinfo for the pages and the structure tree, pdftotext, pdftoppm
8
+ # in grey, pdffonts, pdfdetach and pdfsig. What any of them writes on
9
+ # stderr fails the check. The tools take a password on their command
10
+ # line only, where other local processes can read it; pdfsig takes
11
+ # none, so a file opened with one reports no signatures.
12
+ class Poppler < Engine
13
+ NAME = "poppler"
14
+ INSTALL = "brew install poppler, or apt-get install poppler-utils"
15
+ TOOLS = %w[pdfinfo pdftotext pdftoppm pdffonts pdfdetach pdfsig].freeze
16
+ UNSIGNED = "does not contain any signatures"
17
+ NOT_SIGNED = "The signature form field is not signed."
18
+
19
+ def available? = TOOLS.all? { |tool| Command.which(tool) }
20
+ def version = "poppler #{run("pdfinfo", "-v").stderr[/\d+(\.\d+)+/]}"
21
+
22
+ def facts(paths, password: nil)
23
+ paths.map { |path| Dir.mktmpdir("stationery-verify") { |dir| read(path, password, dir) } }
24
+ end
25
+
26
+ private
27
+
28
+ def read(path, password, dir)
29
+ @results = []
30
+ options = password ? ["-upw", password] : []
31
+ count = tool("pdfinfo", *options, path).stdout[/^Pages:\s+(\d+)/, 1].to_i
32
+ { "file" => path, "pages" => pages(path, options, dir, count), "fonts" => fonts(path, options),
33
+ "attachments" => attachments(path, options), "signatures" => (signatures(path) unless password),
34
+ "structure" => !tool("pdfinfo", "-struct", *options, path).stdout.strip.empty?,
35
+ "errors" => @results.reject { |(_, result)| result.success? || unsigned?(result) }
36
+ .map { |(name, result)| failure(name, result) },
37
+ "warnings" => @results.flat_map { |(_, result)| lines(result.stderr) }.uniq }
38
+ end
39
+
40
+ def tool(name, *)
41
+ run(name, *).tap { |result| @results << [name, result] }
42
+ end
43
+
44
+ def pages(path, options, dir, count)
45
+ texts = tool("pdftotext", "-enc", "UTF-8", *options, path, "-").stdout.split("\f")
46
+ tool("pdftoppm", "-gray", "-r", "36", *options, path, File.join(dir, "p"))
47
+ pictures = Dir[File.join(dir, "p-*.pgm")].sort_by { |file| file[/-(\d+)\.pgm\z/, 1].to_i }
48
+ (1..count).map do |number|
49
+ picture = pictures[number - 1]
50
+ { "number" => number, "text" => texts[number - 1].to_s,
51
+ "painted" => picture && painted?(File.binread(picture)) }
52
+ end
53
+ end
54
+
55
+ def fonts(path, options)
56
+ tool("pdffonts", *options, path).stdout.lines.drop(2).map do |line|
57
+ columns = line.split
58
+ { "name" => columns.first, "embedded" => columns[-5] == "yes", "unicode" => columns[-3] == "yes" }
59
+ end
60
+ end
61
+
62
+ def attachments(path, options)
63
+ tool("pdfdetach", "-list", *options, path).stdout.lines.filter_map { |line| line[/\A\d+: (.*)$/, 1] }
64
+ end
65
+
66
+ # pdfsig answers "valid" for a signature over the whole file whose
67
+ # CMS verifies; -nocert leaves the certificate's trust out of it.
68
+ def signatures(path)
69
+ result = tool("pdfsig", "-nocert", path)
70
+ return { "count" => 0, "valid" => nil } if unsigned?(result)
71
+
72
+ blocks = result.stdout.split(/^Signature #\d+:$/).drop(1).reject { |block| block.include?(NOT_SIGNED) }
73
+ { "count" => blocks.size,
74
+ "valid" => blocks.all? do |block|
75
+ block.include?("Signature is Valid") && block.include?("Total document signed")
76
+ end }
77
+ end
78
+
79
+ def unsigned?(result) = result.exitstatus == 2 && result.stdout.include?(UNSIGNED)
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stationery
4
+ module Verify
5
+ module Engines
6
+ # qpdf: the file's structure (`--check`, whose warnings fail it too)
7
+ # and its page count. The password goes through a file.
8
+ class Qpdf < Engine
9
+ NAME = "qpdf"
10
+ INSTALL = "brew install qpdf, or apt-get install qpdf"
11
+
12
+ def available? = !Command.which("qpdf").nil?
13
+ def version = run("qpdf", "--version").stdout.lines.first.to_s.strip
14
+
15
+ def facts(paths, password: nil)
16
+ with_password_file(password) do |options|
17
+ paths.map { |path| read(path, options) }
18
+ end
19
+ end
20
+
21
+ private
22
+
23
+ def read(path, options)
24
+ check = run("qpdf", *options, "--check", path)
25
+ count = run("qpdf", *options, "--show-npages", path)
26
+ errors = check.success? || check.exitstatus == 3 ? [] : [failure("qpdf --check", check)]
27
+ errors << failure("qpdf --show-npages", count) unless count.success?
28
+ warnings = check.exitstatus == 3 ? lines(check.stderr) + lines(check.stdout).grep(/WARNING/) : []
29
+ { "file" => path, "pages" => (1..count.stdout.to_i).map { |number| { "number" => number } },
30
+ "errors" => errors, "warnings" => warnings.uniq }
31
+ end
32
+
33
+ def with_password_file(password)
34
+ return yield([]) unless password
35
+
36
+ Tempfile.create("stationery-verify") do |file|
37
+ file.chmod(0o600)
38
+ file.write(password)
39
+ file.flush
40
+ yield(["--password-file=#{file.path}"])
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stationery
4
+ module Verify
5
+ # What a file holds, read with pdf-reader (Testing::Inspector), for the
6
+ # engines' facts to be held against: each page's text lines, links and
7
+ # whether it has content; the outline's titles in order; the fields,
8
+ # once by name, and whether one of its widgets has an area; the
9
+ # attachments' names; how many signatures the file carries and whether
10
+ # they verify; whether it is tagged.
11
+ Expectation = Data.define(:pages, :outline, :fields, :attachments, :signatures, :valid, :tagged) do
12
+ # A page's `lines` are its runs of text (Testing::PageReader); it has
13
+ # `content` when it shows text or an image. Paths are not read, so a
14
+ # page that draws only paths is held to nothing painted.
15
+ self::Page = Data.define(:number, :lines, :links, :content)
16
+
17
+ def self.read(inspector)
18
+ layout = inspector.layout
19
+ signatures = inspector.signatures
20
+ new(pages: layout[:pages].map { |page| page(page) }, outline: titles(layout[:outline]),
21
+ fields: fields(layout[:pages]), attachments: layout[:attachments].map { |file| file[:name] },
22
+ signatures: signatures.size, valid: signatures.all? { |signature| signature[:valid] },
23
+ tagged: layout[:tagged])
24
+ end
25
+
26
+ def self.page(page)
27
+ self::Page.new(number: page[:number], lines: page[:text].map { |run| run[:text] },
28
+ links: page[:links].map { |link| link.slice(:uri, :page) },
29
+ content: page[:text].any? || page[:images].any?)
30
+ end
31
+
32
+ def self.titles(entries) = entries.flat_map { |entry| [entry[:title], *titles(entry[:children])] }
33
+
34
+ # A field is `visible` when one of its widgets has an area: a widget
35
+ # of no size (an invisible signature's) needs no appearance.
36
+ def self.fields(pages)
37
+ pages.flat_map { |page| page[:fields] }.group_by { |field| field[:name] }.map do |_, widgets|
38
+ widgets.first.slice(:name, :type, :value)
39
+ .merge(visible: widgets.any? { |widget| widget[:width].positive? && widget[:height].positive? })
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stationery
4
+ module Verify
5
+ # What Verify.run found: a Result for each file and engine that ran, and
6
+ # the engines that are not installed. It passes when every engine that
7
+ # ran finds nothing wrong with any file, and at least one ran.
8
+ Report = Data.define(:results, :missing) do
9
+ self::Result = Data.define(:file, :engine, :version, :problems) do
10
+ def passed? = problems.empty?
11
+ end
12
+
13
+ def passed? = results.any? && results.all?(&:passed?)
14
+
15
+ # One line per file and engine, its problems under it, then the
16
+ # engines not run with the line that installs each.
17
+ def lines
18
+ width = results.map { |result| result.engine.size }.max.to_i
19
+ found = results.group_by(&:file).flat_map do |file, ran|
20
+ [file, *ran.flat_map { |result| result_lines(result, width) }]
21
+ end
22
+ found + (if missing.empty?
23
+ []
24
+ else
25
+ ["not run: #{missing.map do |engine|
26
+ "#{engine.name} (#{engine.install})"
27
+ end.join(", ")}"]
28
+ end)
29
+ end
30
+
31
+ def to_h
32
+ { passed: passed?, results: results.map(&:to_h),
33
+ missing: missing.map { |engine| { engine: engine.name, install: engine.install } } }
34
+ end
35
+
36
+ private
37
+
38
+ def result_lines(result, width)
39
+ verdict = result.passed? ? "ok" : "FAILED"
40
+ [" #{result.engine.ljust(width)} #{verdict} (#{result.version})",
41
+ *result.problems.map { |problem| " #{printable(problem)}" }]
42
+ end
43
+
44
+ # A problem quotes what engines read from the file, which may hold
45
+ # control characters: they print escaped, not to the terminal.
46
+ def printable(text) = text.scrub.gsub(/[[:cntrl:]]/) { |char| char.dump[1..-2] }
47
+ end
48
+ end
49
+ end