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