stationery 0.12.0 → 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 +8 -0
- data/README.md +114 -17
- data/examples/price_card.rb +85 -0
- data/lib/stationery/cli/layout_text.rb +14 -2
- data/lib/stationery/cli/render.rb +7 -6
- data/lib/stationery/cli/verify.rb +98 -0
- data/lib/stationery/cli.rb +1 -0
- data/lib/stationery/document.rb +32 -7
- data/lib/stationery/elements/forms.rb +4 -2
- data/lib/stationery/forms/acro_form.rb +1 -1
- 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/assembler.rb +5 -3
- data/lib/stationery/pdf/conformance.rb +14 -1
- data/lib/stationery/pdf/object_streams.rb +41 -0
- data/lib/stationery/pdf/writer.rb +59 -5
- data/lib/stationery/raster/render.rb +1 -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 +2 -0
- metadata +21 -1
|
@@ -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
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Reads PDFs with PDFium (pypdfium2) and prints one JSON object of facts per
|
|
2
|
+
file, one per line, for Stationery::Verify.
|
|
3
|
+
|
|
4
|
+
python3 pdfium.py FILE...
|
|
5
|
+
|
|
6
|
+
STATIONERY_VERIFY_PASSWORD opens an encrypted file.
|
|
7
|
+
"""
|
|
8
|
+
import ctypes
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import sys
|
|
12
|
+
|
|
13
|
+
import pypdfium2 as pdfium
|
|
14
|
+
import pypdfium2.raw as raw
|
|
15
|
+
|
|
16
|
+
PASSWORD = os.environ.get("STATIONERY_VERIFY_PASSWORD") or None
|
|
17
|
+
SCALE = 0.5 # 36 dpi: enough for a page's text to darken a pixel
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def utf16(function, *args):
|
|
21
|
+
size = function(*args, None, 0)
|
|
22
|
+
if size <= 2:
|
|
23
|
+
return ""
|
|
24
|
+
buffer = ctypes.create_string_buffer(size)
|
|
25
|
+
function(*args, ctypes.cast(buffer, ctypes.POINTER(ctypes.c_ushort)), size)
|
|
26
|
+
return buffer.raw[: size - 2].decode("utf-16-le")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def painted(page):
|
|
30
|
+
bitmap = page.render(scale=SCALE, grayscale=True, may_draw_forms=True)
|
|
31
|
+
data = bytes(bitmap.buffer)
|
|
32
|
+
width, stride = bitmap.width * bitmap.n_channels, bitmap.stride
|
|
33
|
+
return any(data[row * stride : row * stride + width].count(255) != width for row in range(bitmap.height))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def target(document, link):
|
|
37
|
+
action = raw.FPDFLink_GetAction(link)
|
|
38
|
+
if action and raw.FPDFAction_GetType(action) == raw.PDFACTION_URI:
|
|
39
|
+
size = raw.FPDFAction_GetURIPath(document.raw, action, None, 0)
|
|
40
|
+
buffer = ctypes.create_string_buffer(size)
|
|
41
|
+
raw.FPDFAction_GetURIPath(document.raw, action, buffer, size)
|
|
42
|
+
return {"uri": buffer.raw[: size - 1].decode("utf-8", "replace")}
|
|
43
|
+
dest = raw.FPDFLink_GetDest(document.raw, link)
|
|
44
|
+
if not dest and action:
|
|
45
|
+
dest = raw.FPDFAction_GetDest(document.raw, action)
|
|
46
|
+
return {"page": raw.FPDFDest_GetDestPageIndex(document.raw, dest) + 1} if dest else {}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def annotations(document, page):
|
|
50
|
+
links, fields = [], []
|
|
51
|
+
form = document.formenv.raw if document.formenv else None
|
|
52
|
+
for index in range(raw.FPDFPage_GetAnnotCount(page.raw)):
|
|
53
|
+
annot = raw.FPDFPage_GetAnnot(page.raw, index)
|
|
54
|
+
subtype = raw.FPDFAnnot_GetSubtype(annot)
|
|
55
|
+
if subtype == raw.FPDF_ANNOT_LINK:
|
|
56
|
+
links.append(target(document, raw.FPDFAnnot_GetLink(annot)))
|
|
57
|
+
elif subtype == raw.FPDF_ANNOT_WIDGET and form:
|
|
58
|
+
value = utf16(raw.FPDFAnnot_GetFormFieldValue, form, annot)
|
|
59
|
+
fields.append({"name": utf16(raw.FPDFAnnot_GetFormFieldName, form, annot),
|
|
60
|
+
"value": value if raw.FPDFAnnot_GetFormFieldType(form, annot) == raw.FPDF_FORMFIELD_TEXTFIELD else None,
|
|
61
|
+
"appearance": bool(raw.FPDFAnnot_HasKey(annot, b"AP"))})
|
|
62
|
+
raw.FPDFPage_CloseAnnot(annot)
|
|
63
|
+
return links, fields
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def signed(document):
|
|
67
|
+
"""The signature fields that hold a signature: PDFium counts empty ones too."""
|
|
68
|
+
count = raw.FPDF_GetSignatureCount(document.raw)
|
|
69
|
+
objects = (raw.FPDF_GetSignatureObject(document.raw, index) for index in range(count))
|
|
70
|
+
return sum(1 for signature in objects if raw.FPDFSignatureObj_GetContents(signature, None, 0) > 0)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def facts(path):
|
|
74
|
+
result = {"file": path, "pages": [], "errors": [], "warnings": []}
|
|
75
|
+
try:
|
|
76
|
+
read(path, result)
|
|
77
|
+
except Exception as error: # a file PDFium cannot read is that file's error, not the run's
|
|
78
|
+
result["errors"].append(f"{type(error).__name__}: {error}")
|
|
79
|
+
return result
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def read(path, result):
|
|
83
|
+
document = pdfium.PdfDocument(path, password=PASSWORD)
|
|
84
|
+
document.init_forms()
|
|
85
|
+
fields = []
|
|
86
|
+
for index in range(len(document)):
|
|
87
|
+
page = document[index]
|
|
88
|
+
links, widgets = annotations(document, page)
|
|
89
|
+
fields.extend(widgets)
|
|
90
|
+
text = page.get_textpage().get_text_range()
|
|
91
|
+
result["pages"].append({"number": index + 1, "text": text, "painted": painted(page), "links": links})
|
|
92
|
+
result["outline"] = [item.get_title() for item in document.get_toc()]
|
|
93
|
+
result["fields"] = fields
|
|
94
|
+
result["signatures"] = {"count": signed(document), "valid": None}
|
|
95
|
+
document.close()
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
for path in sys.argv[1:]:
|
|
99
|
+
print(json.dumps(facts(path)), flush=True)
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// Reads PDFs with pdf.js (pdfjs-dist's legacy build under Node) and prints
|
|
2
|
+
// one JSON object of facts per file, one per line, for Stationery::Verify.
|
|
3
|
+
//
|
|
4
|
+
// STATIONERY_PDFJS=/path/to/node_modules/pdfjs-dist node pdfjs.mjs FILE…
|
|
5
|
+
//
|
|
6
|
+
// STATIONERY_VERIFY_PASSWORD opens an encrypted file. Node has no canvas, so
|
|
7
|
+
// a page "paints" when its operator list holds a painting operator.
|
|
8
|
+
import { readFile } from "node:fs/promises";
|
|
9
|
+
import { pathToFileURL } from "node:url";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
|
|
12
|
+
const root = process.env.STATIONERY_PDFJS;
|
|
13
|
+
const pdfjs = await import(pathToFileURL(path.join(root, "legacy/build/pdf.mjs")).href);
|
|
14
|
+
const { getDocument, VerbosityLevel, OPS, AnnotationType } = pdfjs;
|
|
15
|
+
const password = process.env.STATIONERY_VERIFY_PASSWORD || undefined;
|
|
16
|
+
const write = process.stdout.write.bind(process.stdout);
|
|
17
|
+
const PAINTS = new Set(
|
|
18
|
+
["fill", "eoFill", "stroke", "fillStroke", "eoFillStroke", "closeStroke", "closeFillStroke", "closeEOFillStroke",
|
|
19
|
+
"showText", "showSpacedText", "nextLineShowText", "nextLineSetSpacingShowText", "shadingFill",
|
|
20
|
+
"paintImageXObject", "paintInlineImageXObject", "paintImageMaskXObject", "paintSolidColorImageMask",
|
|
21
|
+
"paintImageXObjectRepeat", "paintImageMaskXObjectRepeat", "paintImageMaskXObjectGroup", "constructPath"]
|
|
22
|
+
.filter((name) => name in OPS).map((name) => OPS[name])
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
let messages = [];
|
|
26
|
+
for (const level of ["log", "info", "warn", "error"]) {
|
|
27
|
+
console[level] = (...args) => messages.push(args.map(String).join(" "));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// pdf.js 6 answers Maps where earlier versions answered plain objects.
|
|
31
|
+
const entries = (found) => (found instanceof Map ? [...found] : Object.entries(found || {}));
|
|
32
|
+
|
|
33
|
+
async function titles(items) {
|
|
34
|
+
const found = [];
|
|
35
|
+
for (const item of items || []) found.push(item.title, ...(await titles(item.items)));
|
|
36
|
+
return found;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function target(document, annotation) {
|
|
40
|
+
if (annotation.url) return { uri: annotation.url };
|
|
41
|
+
let dest = annotation.dest;
|
|
42
|
+
if (typeof dest === "string") dest = await document.getDestination(dest);
|
|
43
|
+
if (!Array.isArray(dest)) return {};
|
|
44
|
+
const ref = dest[0];
|
|
45
|
+
const index = typeof ref === "number" ? ref : await document.getPageIndex(ref);
|
|
46
|
+
return { page: index + 1 };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function page(document, number) {
|
|
50
|
+
const page = await document.getPage(number);
|
|
51
|
+
const text = (await page.getTextContent()).items.map((item) => item.str ?? "").join(" ");
|
|
52
|
+
const operators = await page.getOperatorList();
|
|
53
|
+
const painted = operators.fnArray.some((fn) => PAINTS.has(fn));
|
|
54
|
+
const annotations = await page.getAnnotations();
|
|
55
|
+
const links = [];
|
|
56
|
+
for (const annotation of annotations) {
|
|
57
|
+
if (annotation.annotationType === AnnotationType.LINK) links.push(await target(document, annotation));
|
|
58
|
+
}
|
|
59
|
+
const widgets = annotations.filter((annotation) => annotation.annotationType === AnnotationType.WIDGET);
|
|
60
|
+
const structure = await page.getStructTree();
|
|
61
|
+
return { page: { number, text, painted, links }, widgets, structure: structure !== null };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function fields(objects, widgets) {
|
|
65
|
+
return entries(objects).map(([name, kids]) => {
|
|
66
|
+
const values = kids.map((kid) => kid.value).filter((value) => value !== undefined && value !== null);
|
|
67
|
+
const own = widgets.filter((widget) => widget.fieldName === name);
|
|
68
|
+
return {
|
|
69
|
+
name,
|
|
70
|
+
value: typeof values[0] === "string" ? values[0] : null,
|
|
71
|
+
appearance: own.length === 0 ? null : own.every((widget) => widget.hasAppearance),
|
|
72
|
+
};
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function facts(file) {
|
|
77
|
+
messages = [];
|
|
78
|
+
const result = { file, pages: [], errors: [] };
|
|
79
|
+
try {
|
|
80
|
+
const data = new Uint8Array(await readFile(file));
|
|
81
|
+
const task = getDocument({
|
|
82
|
+
data, password, verbosity: VerbosityLevel.WARNINGS, isEvalSupported: false,
|
|
83
|
+
standardFontDataUrl: path.join(root, "standard_fonts") + path.sep,
|
|
84
|
+
cMapUrl: path.join(root, "cmaps") + path.sep, cMapPacked: true,
|
|
85
|
+
wasmUrl: path.join(root, "wasm") + path.sep,
|
|
86
|
+
});
|
|
87
|
+
const document = await task.promise;
|
|
88
|
+
const widgets = [];
|
|
89
|
+
let structure = false;
|
|
90
|
+
for (let number = 1; number <= document.numPages; number++) {
|
|
91
|
+
const read = await page(document, number);
|
|
92
|
+
result.pages.push(read.page);
|
|
93
|
+
widgets.push(...read.widgets);
|
|
94
|
+
structure ||= read.structure;
|
|
95
|
+
}
|
|
96
|
+
result.outline = await titles(await document.getOutline());
|
|
97
|
+
result.fields = fields(await document.getFieldObjects(), widgets);
|
|
98
|
+
result.attachments = entries(await document.getAttachments()).map(([, file]) => file.filename);
|
|
99
|
+
result.structure = structure;
|
|
100
|
+
await task.destroy();
|
|
101
|
+
} catch (error) {
|
|
102
|
+
result.errors.push(String(error?.message || error));
|
|
103
|
+
}
|
|
104
|
+
result.warnings = messages;
|
|
105
|
+
return result;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
for (const file of process.argv.slice(2)) write(JSON.stringify(await facts(file)) + "\n");
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// Reads PDFs with Apple's PDFKit (what Preview and Safari show) and prints
|
|
2
|
+
// one JSON object of facts per file, one per line, for Stationery::Verify.
|
|
3
|
+
//
|
|
4
|
+
// swift pdfkit.swift FILE…
|
|
5
|
+
//
|
|
6
|
+
// STATIONERY_VERIFY_PASSWORD opens an encrypted file.
|
|
7
|
+
import CoreGraphics
|
|
8
|
+
import Foundation
|
|
9
|
+
import PDFKit
|
|
10
|
+
|
|
11
|
+
let password = ProcessInfo.processInfo.environment["STATIONERY_VERIFY_PASSWORD"] ?? ""
|
|
12
|
+
let scale: CGFloat = 0.5 // 36 dpi: enough for a page's text to darken a pixel
|
|
13
|
+
|
|
14
|
+
func painted(_ page: PDFPage) -> Bool {
|
|
15
|
+
let box = page.bounds(for: .mediaBox)
|
|
16
|
+
let width = max(Int(box.width * scale), 1), height = max(Int(box.height * scale), 1)
|
|
17
|
+
guard let context = CGContext(data: nil, width: width, height: height, bitsPerComponent: 8, bytesPerRow: width,
|
|
18
|
+
space: CGColorSpaceCreateDeviceGray(),
|
|
19
|
+
bitmapInfo: CGImageAlphaInfo.none.rawValue) else { return false }
|
|
20
|
+
context.setFillColor(gray: 1, alpha: 1)
|
|
21
|
+
context.fill(CGRect(x: 0, y: 0, width: width, height: height))
|
|
22
|
+
context.scaleBy(x: scale, y: scale)
|
|
23
|
+
page.draw(with: .mediaBox, to: context)
|
|
24
|
+
guard let data = context.data else { return false }
|
|
25
|
+
let pixels = data.bindMemory(to: UInt8.self, capacity: width * height)
|
|
26
|
+
return (0 ..< width * height).contains { pixels[$0] != 255 }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
func target(_ annotation: PDFAnnotation, in document: PDFDocument) -> [String: Any] {
|
|
30
|
+
if let url = annotation.url ?? (annotation.action as? PDFActionURL)?.url { return ["uri": url.absoluteString] }
|
|
31
|
+
let destination = annotation.destination ?? (annotation.action as? PDFActionGoTo)?.destination
|
|
32
|
+
if let page = destination?.page { return ["page": document.index(for: page) + 1] }
|
|
33
|
+
return [:]
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
func titles(_ item: PDFOutline?) -> [String] {
|
|
37
|
+
guard let item else { return [] }
|
|
38
|
+
return (0 ..< item.numberOfChildren).flatMap { index -> [String] in
|
|
39
|
+
let child = item.child(at: index)!
|
|
40
|
+
return [child.label ?? ""] + titles(child)
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
func facts(_ path: String) -> [String: Any] {
|
|
45
|
+
var result: [String: Any] = ["file": path, "pages": [], "errors": [], "warnings": []]
|
|
46
|
+
guard let document = PDFDocument(url: URL(fileURLWithPath: path)) else {
|
|
47
|
+
result["errors"] = ["PDFKit cannot open the file"]
|
|
48
|
+
return result
|
|
49
|
+
}
|
|
50
|
+
if document.isLocked && !document.unlock(withPassword: password) {
|
|
51
|
+
result["errors"] = ["PDFKit cannot unlock the file"]
|
|
52
|
+
return result
|
|
53
|
+
}
|
|
54
|
+
var pages: [[String: Any]] = [], fields: [[String: Any]] = []
|
|
55
|
+
for index in 0 ..< document.pageCount {
|
|
56
|
+
guard let page = document.page(at: index) else {
|
|
57
|
+
result["errors"] = ["PDFKit cannot read page \(index + 1)"]
|
|
58
|
+
return result
|
|
59
|
+
}
|
|
60
|
+
var links: [[String: Any]] = []
|
|
61
|
+
for annotation in page.annotations {
|
|
62
|
+
if annotation.type == "Link" { links.append(target(annotation, in: document)) }
|
|
63
|
+
if annotation.type == "Widget", let name = annotation.fieldName {
|
|
64
|
+
let text = annotation.widgetFieldType == .text ? annotation.widgetStringValue : nil
|
|
65
|
+
// PDFKit keeps a widget's /AP to itself: the appearance is not reported.
|
|
66
|
+
fields.append(["name": name, "value": text as Any, "appearance": NSNull()])
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
pages.append(["number": index + 1, "text": page.string ?? "", "painted": painted(page), "links": links])
|
|
70
|
+
}
|
|
71
|
+
result["pages"] = pages
|
|
72
|
+
result["outline"] = titles(document.outlineRoot)
|
|
73
|
+
result["fields"] = fields
|
|
74
|
+
return result
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
for path in CommandLine.arguments.dropFirst() {
|
|
78
|
+
let data = try! JSONSerialization.data(withJSONObject: facts(path))
|
|
79
|
+
FileHandle.standardOutput.write(data + Data("\n".utf8))
|
|
80
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "stationery"
|
|
4
|
+
require "stationery/testing/inspector"
|
|
5
|
+
require_relative "verify/command"
|
|
6
|
+
require_relative "verify/engine"
|
|
7
|
+
require_relative "verify/engines/qpdf"
|
|
8
|
+
require_relative "verify/engines/poppler"
|
|
9
|
+
require_relative "verify/engines/mupdf"
|
|
10
|
+
require_relative "verify/engines/pdfium"
|
|
11
|
+
require_relative "verify/engines/pdfjs"
|
|
12
|
+
require_relative "verify/engines/pdfkit"
|
|
13
|
+
require_relative "verify/allowlist"
|
|
14
|
+
require_relative "verify/expectation"
|
|
15
|
+
require_relative "verify/comparison"
|
|
16
|
+
require_relative "verify/report"
|
|
17
|
+
|
|
18
|
+
module Stationery
|
|
19
|
+
# Checks PDFs in the engines viewers are built on: qpdf, Poppler (the
|
|
20
|
+
# Linux desktop viewers), MuPDF, PDFium (Chrome, Edge), pdf.js (Firefox)
|
|
21
|
+
# and PDFKit (Preview, Safari). Each engine is an external tool the check
|
|
22
|
+
# finds when it is installed; the gem depends on none of them. What the
|
|
23
|
+
# engines read is held against what pdf-reader reads (Expectation), so
|
|
24
|
+
# `stationery verify` needs pdf-reader, as `inspect` does.
|
|
25
|
+
#
|
|
26
|
+
# `require "stationery"` does not load this: `stationery verify` and
|
|
27
|
+
# `rake verify:readers` do.
|
|
28
|
+
#
|
|
29
|
+
# An engine's facts about a file are a Hash, as its script prints them in
|
|
30
|
+
# JSON: `"file"`, `"errors"`, `"warnings"`, `"pages"` (each `"number"`,
|
|
31
|
+
# `"text"`, `"painted"`, `"links"`, a link `{"uri"}` or `{"page"}`), and
|
|
32
|
+
# `"outline"` (titles in order), `"fields"` (`"name"`, `"value"`,
|
|
33
|
+
# `"appearance"`), `"attachments"` (names), `"signatures"` (`"count"`,
|
|
34
|
+
# `"valid"`), `"structure"` and `"fonts"` (`"name"`, `"embedded"`,
|
|
35
|
+
# `"unicode"`). A fact an engine does not report is nil or left out, and
|
|
36
|
+
# is not checked.
|
|
37
|
+
module Verify
|
|
38
|
+
ENGINES = { "qpdf" => Engines::Qpdf, "poppler" => Engines::Poppler, "mupdf" => Engines::Mupdf,
|
|
39
|
+
"pdfium" => Engines::Pdfium, "pdfjs" => Engines::Pdfjs, "pdfkit" => Engines::Pdfkit }.freeze
|
|
40
|
+
|
|
41
|
+
# One adapter for each engine, or for each of `names`; raises
|
|
42
|
+
# ArgumentError for a name that is not an engine.
|
|
43
|
+
def self.adapters(names = ENGINES.keys)
|
|
44
|
+
names.map do |name|
|
|
45
|
+
ENGINES.fetch(name.to_s) { raise ArgumentError, "no engine #{name} (engines: #{ENGINES.keys.join(", ")})" }.new
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Reads each of `paths` with each of `engines` that is available and
|
|
50
|
+
# holds what it read against the file: a Report. `password` opens
|
|
51
|
+
# encrypted files.
|
|
52
|
+
def self.run(paths, engines: adapters, password: nil)
|
|
53
|
+
require_reader
|
|
54
|
+
available, missing = engines.partition(&:available?)
|
|
55
|
+
expectations = paths.to_h { |path| [path, expectation(path, password)] }
|
|
56
|
+
# Absolute, so no path reaches a tool as an option ("-o…").
|
|
57
|
+
absolute = paths.map { |path| File.expand_path(path) }
|
|
58
|
+
results = available.flat_map do |engine|
|
|
59
|
+
version = engine.version
|
|
60
|
+
engine.facts(absolute, password:).zip(paths).map do |facts, path|
|
|
61
|
+
Report::Result.new(file: path, engine: engine.name, version:,
|
|
62
|
+
problems: problems(expectations[path], facts, engine))
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
Report.new(results: results.sort_by.with_index { |result, index| [paths.index(result.file), index] }, missing:)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def self.require_reader
|
|
69
|
+
require "pdf/reader"
|
|
70
|
+
rescue LoadError
|
|
71
|
+
raise Stationery::Error, Testing::Inspector::NEEDS_READER
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def self.expectation(path, password)
|
|
75
|
+
Expectation.read(Testing::Inspector.new(Pathname(path), password:))
|
|
76
|
+
rescue StandardError => e
|
|
77
|
+
e # pdf-reader cannot read it: each engine is then held to opening it with no error, and the file fails
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def self.problems(expectation, facts, engine)
|
|
81
|
+
return Comparison.new(expectation, facts, engine: engine.name).problems if expectation.is_a?(Expectation)
|
|
82
|
+
|
|
83
|
+
["pdf-reader cannot read the file (#{expectation.class.name.split("::").last}: #{expectation.message}), " \
|
|
84
|
+
"so there is nothing to hold the engine to",
|
|
85
|
+
*Comparison.new(Expectation.new(pages: [], outline: [], fields: [], attachments: [], signatures: 0, valid: true,
|
|
86
|
+
tagged: false), facts.slice("errors", "warnings"), engine: engine.name).problems]
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
data/lib/stationery/version.rb
CHANGED
data/lib/stationery.rb
CHANGED
|
@@ -11,6 +11,7 @@ require_relative "stationery/pdf/encryption/aes"
|
|
|
11
11
|
require_relative "stationery/pdf/encryption/revision4"
|
|
12
12
|
require_relative "stationery/pdf/encryption/revision6"
|
|
13
13
|
require_relative "stationery/pdf/encryption/standard_security"
|
|
14
|
+
require_relative "stationery/pdf/object_streams"
|
|
14
15
|
require_relative "stationery/pdf/writer"
|
|
15
16
|
require_relative "stationery/fonts/sfnt_metrics"
|
|
16
17
|
require_relative "stationery/fonts/woff"
|
|
@@ -120,6 +121,7 @@ require_relative "stationery/pdf/signature/timestamp"
|
|
|
120
121
|
require_relative "stationery/forms/metrics"
|
|
121
122
|
require_relative "stationery/forms/typeface"
|
|
122
123
|
require_relative "stationery/forms/field"
|
|
124
|
+
require_relative "stationery/forms/auto_size"
|
|
123
125
|
require_relative "stationery/forms/appearance"
|
|
124
126
|
require_relative "stationery/forms/acro_form"
|
|
125
127
|
require_relative "stationery/pdf/page_sealer"
|