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
|
@@ -6,6 +6,8 @@ the footer, a table that continues across pages and a chart. The examples in the
|
|
|
6
6
|
`stationery examples accessible_report --source` (PDF/UA-1, a chart as a figure).
|
|
7
7
|
|
|
8
8
|
```ruby
|
|
9
|
+
require "erb" # ERB::Util.html_escape, for data written into SVG
|
|
10
|
+
|
|
9
11
|
class ReportPdf < Stationery::Document
|
|
10
12
|
ACCENT = "#0F766E"
|
|
11
13
|
MUTED = "#6B7280"
|
|
@@ -63,14 +65,15 @@ class ReportPdf < Stationery::Document
|
|
|
63
65
|
spacer 8
|
|
64
66
|
end
|
|
65
67
|
|
|
66
|
-
# A bar chart as SVG, drawn from the data: there is no chart element.
|
|
68
|
+
# A bar chart as SVG, drawn from the data: there is no chart element. Text
|
|
69
|
+
# from the data is escaped, as "&" or "<" in it would break the XML.
|
|
67
70
|
def chart
|
|
68
71
|
top = @quarters.map(&:last).max
|
|
69
72
|
bars = @quarters.each_with_index.map do |(label, value), i|
|
|
70
73
|
height = (value / top * 150).round(1)
|
|
71
74
|
x = 30 + (i * 110)
|
|
72
75
|
%(<rect x="#{x}" y="#{170 - height}" width="70" height="#{height}" fill="currentColor"/>) +
|
|
73
|
-
%(<text x="#{x + 35}" y="190" font-size="12" text-anchor="middle">#{label}</text>) +
|
|
76
|
+
%(<text x="#{x + 35}" y="190" font-size="12" text-anchor="middle">#{ERB::Util.html_escape(label)}</text>) +
|
|
74
77
|
%(<text x="#{x + 35}" y="#{164 - height}" font-size="11" text-anchor="middle">#{value}</text>)
|
|
75
78
|
end
|
|
76
79
|
%(<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 460 200">#{bars.join}</svg>)
|
|
@@ -91,13 +94,15 @@ end
|
|
|
91
94
|
same page. `heading: 1..6` makes a heading in a tagged PDF; do not skip a level (1, then 3).
|
|
92
95
|
- **Long tables** continue on the next page with `header: true` rows repeated; a row never splits
|
|
93
96
|
unless it is taller than a page (or `split_rows: true`). Rows given as an Enumerator
|
|
94
|
-
(`Order.find_each.lazy.map { |o| [o.number, o.total] }
|
|
97
|
+
(`Order.preload(:customer).find_each.lazy.map { |o| [o.number, o.customer.name, o.total] }`,
|
|
98
|
+
preloading what each row reads) to a table with a width for every
|
|
95
99
|
column (`widths: [80, 0.5, 90]`) are read as pages reach them, so the rows of about a page are
|
|
96
100
|
held at a time, however many there are; such a table selects rows from its start only (`t.row(0)`, `t.rows(1..)`,
|
|
97
101
|
`zebra`), and none of its cells may span rows. With a column without a width the rows are read
|
|
98
102
|
when the table is built.
|
|
99
103
|
- **Charts are SVG or `canvas`.** Build the SVG from the data as above (`currentColor` takes
|
|
100
|
-
`color:`, `<text>` is set in the document's fonts)
|
|
104
|
+
`color:`, `<text>` is set in the document's fonts), and escape every string from the data with
|
|
105
|
+
`ERB::Util.html_escape`: an "&" or "<" in a label breaks the SVG or adds elements to it. An `svg` is drawn at its viewBox size in
|
|
101
106
|
points, shrunk to the width there is; `width:` is points (`width: 1.0` is one point, not the
|
|
102
107
|
whole width). Or draw the shapes on a canvas (its `text` takes a font object, so label a canvas
|
|
103
108
|
chart with SVG or with `text` elements around it):
|
|
@@ -10,8 +10,9 @@ require_relative "layout"
|
|
|
10
10
|
module Stationery
|
|
11
11
|
module Testing
|
|
12
12
|
# Reads a rendered PDF back for assertions. The subject is a
|
|
13
|
-
# Stationery::Document, PDF bytes, a path (String or Pathname) or an IO
|
|
14
|
-
# Needs the pdf-reader gem, loaded on
|
|
13
|
+
# Stationery::Document, PDF bytes, a path (String or Pathname) or an IO;
|
|
14
|
+
# `password:` opens an encrypted one. Needs the pdf-reader gem, loaded on
|
|
15
|
+
# first use.
|
|
15
16
|
class Inspector
|
|
16
17
|
UTF16_BOM = "\xFE\xFF".b
|
|
17
18
|
# id-aa-signatureTimeStampToken: a signature's RFC 3161 timestamp.
|
|
@@ -23,9 +24,11 @@ module Stationery
|
|
|
23
24
|
COLOR_OPERATORS = { "g" => 1, "G" => 1, "rg" => 3, "RG" => 3, "k" => 4, "K" => 4 }.freeze
|
|
24
25
|
NUMBER = /\A[-+]?(?:\d+\.?\d*|\.\d+)\z/
|
|
25
26
|
LITERAL_STRING = /\((?:\\.|[^\\()])*\)/m
|
|
27
|
+
NEEDS_READER = 'Stationery::Testing needs pdf-reader: add gem "pdf-reader" to your test group'
|
|
26
28
|
|
|
27
|
-
def initialize(subject)
|
|
29
|
+
def initialize(subject, password: nil)
|
|
28
30
|
@subject = subject
|
|
31
|
+
@password = password
|
|
29
32
|
end
|
|
30
33
|
|
|
31
34
|
def pdf
|
|
@@ -40,9 +43,9 @@ module Stationery
|
|
|
40
43
|
def reader
|
|
41
44
|
@reader ||= begin
|
|
42
45
|
require "pdf/reader"
|
|
43
|
-
::PDF::Reader.new(StringIO.new(pdf))
|
|
46
|
+
::PDF::Reader.new(StringIO.new(pdf), **{ password: @password }.compact)
|
|
44
47
|
rescue LoadError
|
|
45
|
-
raise Stationery::Error,
|
|
48
|
+
raise Stationery::Error, NEEDS_READER
|
|
46
49
|
end
|
|
47
50
|
end
|
|
48
51
|
|
|
@@ -18,6 +18,9 @@ module Stationery
|
|
|
18
18
|
# The Radio and Pushbutton flags of a button field's /Ff.
|
|
19
19
|
RADIO = 1 << 15
|
|
20
20
|
PUSHBUTTON = 1 << 16
|
|
21
|
+
ALIGNMENTS = %i[left center right].freeze
|
|
22
|
+
# The font size and colour operator of a /DA: "/F1 10 Tf 0.5 g".
|
|
23
|
+
DEFAULT_APPEARANCE = /([\d.]+) Tf\s+((?:-?[\d.]+\s+)+)(g|rg|k)\s*\z/
|
|
21
24
|
|
|
22
25
|
def initialize(inspector)
|
|
23
26
|
@inspector = inspector
|
|
@@ -98,10 +101,24 @@ module Stationery
|
|
|
98
101
|
name = chain.filter_map { |field| field[:T] && decode(field[:T]) }.reverse.join(".")
|
|
99
102
|
type = inherit(chain, :FT)
|
|
100
103
|
{ name:, type: field_type(type, inherit(chain, :Ff).to_i), value: value(inherit(chain, :V)),
|
|
101
|
-
**rect(widget, left, top), state: state(widget) }.compact
|
|
104
|
+
**rect(widget, left, top), state: state(widget), **style(chain) }.compact
|
|
102
105
|
end
|
|
103
106
|
end
|
|
104
107
|
|
|
108
|
+
# The alignment, size (:auto for 0) and colour a field's value is
|
|
109
|
+
# drawn in; nothing for a field without variable text.
|
|
110
|
+
def style(chain)
|
|
111
|
+
match = DEFAULT_APPEARANCE.match(inherit(chain, :DA).to_s) or return {}
|
|
112
|
+
|
|
113
|
+
size, values, operator = match.captures
|
|
114
|
+
values = values.split.map(&:to_f)
|
|
115
|
+
values *= 3 if operator == "g"
|
|
116
|
+
{ align: ALIGNMENTS.fetch(inherit(chain, :Q).to_i, :left), font_size: size.to_f.zero? ? :auto : number(size),
|
|
117
|
+
color: Monochrome.hex(Color.new(operator == "k" ? :cmyk : :rgb, values)) }
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def number(text) = text.include?(".") ? text.to_f : text.to_i
|
|
121
|
+
|
|
105
122
|
def parents(widget)
|
|
106
123
|
chain = [widget]
|
|
107
124
|
chain << @objects.deref_hash(chain.last[:Parent]) while chain.last[:Parent] && chain.size < 32
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stationery
|
|
4
|
+
module Verify
|
|
5
|
+
# The engine messages that are the check's environment and not the PDF:
|
|
6
|
+
# each names the engine, a pattern and why. Everything else an engine
|
|
7
|
+
# says fails the check. An entry is added only once the cause is found
|
|
8
|
+
# in the engine's source and the file; a file that lacks what a viewer
|
|
9
|
+
# needs is a bug in the gem, not an entry here.
|
|
10
|
+
module Allowlist
|
|
11
|
+
ENTRIES = [
|
|
12
|
+
{ engine: "pdfjs", pattern: /\AWarning: _getAppearance: OffscreenCanvas is not supported/,
|
|
13
|
+
why: "Node has no OffscreenCanvas. pdf.js builds a text field's appearance again when the form sets " \
|
|
14
|
+
"/NeedAppearances, which Forms::AcroForm does for a form that is neither signed nor PDF/A or " \
|
|
15
|
+
"PDF/UA, and warns whenever it builds one without OffscreenCanvas. Every widget carries its /AP." },
|
|
16
|
+
{ engine: "poppler", pattern: /\ANSS_Init failed: security library: bad database\.\z/,
|
|
17
|
+
why: "pdfsig opens an NSS certificate database to judge trust, and says so when the machine has none " \
|
|
18
|
+
"(~/.pki/nssdb). Whether the signature verifies does not need it." },
|
|
19
|
+
{ engine: "mupdf", pattern: /\Awarning: ICC support is not available\z/,
|
|
20
|
+
why: "Debian's mutool (mupdf-tools, in the verify image) is built without colour management and says so " \
|
|
21
|
+
"before it opens any file, even one that is not a PDF." }
|
|
22
|
+
].freeze
|
|
23
|
+
|
|
24
|
+
def self.allows?(entries, engine, message)
|
|
25
|
+
entries.any? { |entry| entry[:engine] == engine && entry[:pattern].match?(message) }
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "tempfile"
|
|
4
|
+
require "tmpdir"
|
|
5
|
+
|
|
6
|
+
module Stationery
|
|
7
|
+
module Verify
|
|
8
|
+
# Runs an engine's tool: an argument array, never through a shell, its
|
|
9
|
+
# output written to files as it runs (so a large one cannot block it),
|
|
10
|
+
# in `chdir` (the temporary directory unless asked: a tool that loads
|
|
11
|
+
# modules from where it runs must not load a checked file's neighbours),
|
|
12
|
+
# and its process group killed when it takes longer than `timeout`
|
|
13
|
+
# seconds (swift runs the compiled script as a child of its own).
|
|
14
|
+
module Command
|
|
15
|
+
POLL = 0.01 # seconds between looks at a running child
|
|
16
|
+
|
|
17
|
+
# `timeout` is the seconds the tool was given.
|
|
18
|
+
Result = Data.define(:stdout, :stderr, :status, :timed_out, :timeout) do
|
|
19
|
+
def success? = !timed_out && status&.success? == true
|
|
20
|
+
def exitstatus = status&.exitstatus
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.run(*argv, env: {}, timeout: 60, chdir: Dir.tmpdir)
|
|
24
|
+
Tempfile.create("stationery-verify-out") do |out|
|
|
25
|
+
Tempfile.create("stationery-verify-err") do |err|
|
|
26
|
+
status, timed_out = spawn(argv, env:, timeout:, chdir:, out:, err:)
|
|
27
|
+
Result.new(stdout: read(out), stderr: read(err), status:, timed_out:, timeout:)
|
|
28
|
+
rescue SystemCallError => e # the program could not start: that tool's failure, not the run's
|
|
29
|
+
Result.new(stdout: "", stderr: "#{e.message}\n", status: nil, timed_out: false, timeout:)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def self.spawn(argv, env:, timeout:, chdir:, out:, err:)
|
|
35
|
+
# [program, argv0] never goes through a shell, whatever argv holds.
|
|
36
|
+
pid = Process.spawn(env, [argv.first, argv.first], *argv.drop(1), in: File::NULL, out:, err:, chdir:,
|
|
37
|
+
pgroup: true)
|
|
38
|
+
waited = wait(pid, timeout)
|
|
39
|
+
ensure
|
|
40
|
+
kill(pid) if pid && !waited
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# What a tool wrote, as UTF-8 with anything that is not replaced.
|
|
44
|
+
def self.read(file) = File.read(file.path, encoding: Encoding::UTF_8).scrub
|
|
45
|
+
|
|
46
|
+
# The executable `name` on the PATH (or at `name`, a path from the
|
|
47
|
+
# working directory, answered absolute), or nil.
|
|
48
|
+
def self.which(name)
|
|
49
|
+
if name.include?(File::SEPARATOR)
|
|
50
|
+
path = File.expand_path(name)
|
|
51
|
+
return (path if File.file?(path) && File.executable?(path))
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).map { |dir| File.join(dir, name) }
|
|
55
|
+
.find { |path| File.file?(path) && File.executable?(path) }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def self.wait(pid, timeout)
|
|
59
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
|
|
60
|
+
loop do
|
|
61
|
+
_, status = Process.waitpid2(pid, Process::WNOHANG)
|
|
62
|
+
return [status, false] if status
|
|
63
|
+
|
|
64
|
+
break if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
|
|
65
|
+
|
|
66
|
+
sleep POLL
|
|
67
|
+
end
|
|
68
|
+
[kill(pid), true]
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Kills the child's process group and answers the child's status.
|
|
72
|
+
def self.kill(pid)
|
|
73
|
+
Process.kill(:KILL, -pid)
|
|
74
|
+
Process.waitpid2(pid).last
|
|
75
|
+
rescue Errno::ESRCH, Errno::ECHILD
|
|
76
|
+
nil # it was gone already
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -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
|