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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +4 -0
- data/README.md +108 -16
- data/examples/price_card.rb +87 -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 +27 -21
- data/lib/stationery/forms/auto_size.rb +94 -0
- data/lib/stationery/forms/field.rb +49 -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,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
|