stationery 0.12.1 → 0.12.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +4 -0
  3. data/README.md +108 -16
  4. data/examples/price_card.rb +87 -0
  5. data/lib/stationery/cli/layout_text.rb +14 -2
  6. data/lib/stationery/cli/verify.rb +98 -0
  7. data/lib/stationery/cli.rb +1 -0
  8. data/lib/stationery/elements/forms.rb +4 -2
  9. data/lib/stationery/forms/appearance.rb +27 -21
  10. data/lib/stationery/forms/auto_size.rb +94 -0
  11. data/lib/stationery/forms/field.rb +49 -3
  12. data/lib/stationery/monochrome/painting.rb +10 -0
  13. data/lib/stationery/pdf/conformance.rb +14 -1
  14. data/lib/stationery/skill/recipes/form.md.erb +9 -6
  15. data/lib/stationery/skill/recipes/invoice.md.erb +13 -9
  16. data/lib/stationery/skill/recipes/label.md.erb +4 -2
  17. data/lib/stationery/skill/recipes/report.md.erb +9 -4
  18. data/lib/stationery/testing/inspector.rb +8 -5
  19. data/lib/stationery/testing/layout.rb +18 -1
  20. data/lib/stationery/verify/allowlist.rb +29 -0
  21. data/lib/stationery/verify/command.rb +80 -0
  22. data/lib/stationery/verify/comparison.rb +191 -0
  23. data/lib/stationery/verify/engine.rb +63 -0
  24. data/lib/stationery/verify/engines/mupdf.rb +44 -0
  25. data/lib/stationery/verify/engines/pdfium.rb +35 -0
  26. data/lib/stationery/verify/engines/pdfjs.rb +41 -0
  27. data/lib/stationery/verify/engines/pdfkit.rb +20 -0
  28. data/lib/stationery/verify/engines/poppler.rb +83 -0
  29. data/lib/stationery/verify/engines/qpdf.rb +46 -0
  30. data/lib/stationery/verify/expectation.rb +44 -0
  31. data/lib/stationery/verify/report.rb +49 -0
  32. data/lib/stationery/verify/scripts/pdfium.py +99 -0
  33. data/lib/stationery/verify/scripts/pdfjs.mjs +108 -0
  34. data/lib/stationery/verify/scripts/pdfkit.swift +80 -0
  35. data/lib/stationery/verify.rb +89 -0
  36. data/lib/stationery/version.rb +1 -1
  37. data/lib/stationery.rb +1 -0
  38. metadata +20 -1
@@ -0,0 +1,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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Stationery
4
- VERSION = "0.12.1"
4
+ VERSION = "0.12.3"
5
5
  end
data/lib/stationery.rb CHANGED
@@ -121,6 +121,7 @@ require_relative "stationery/pdf/signature/timestamp"
121
121
  require_relative "stationery/forms/metrics"
122
122
  require_relative "stationery/forms/typeface"
123
123
  require_relative "stationery/forms/field"
124
+ require_relative "stationery/forms/auto_size"
124
125
  require_relative "stationery/forms/appearance"
125
126
  require_relative "stationery/forms/acro_form"
126
127
  require_relative "stationery/pdf/page_sealer"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: stationery
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.12.1
4
+ version: 0.12.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mikael Henriksson
@@ -48,6 +48,7 @@ files:
48
48
  - examples/newsletter.rb
49
49
  - examples/packing_slip.rb
50
50
  - examples/postcard.rb
51
+ - examples/price_card.rb
51
52
  - examples/price_list.rb
52
53
  - examples/receipt.rb
53
54
  - examples/report.rb
@@ -87,6 +88,7 @@ files:
87
88
  - lib/stationery/cli/pictures.rb
88
89
  - lib/stationery/cli/render.rb
89
90
  - lib/stationery/cli/skill.rb
91
+ - lib/stationery/cli/verify.rb
90
92
  - lib/stationery/color.rb
91
93
  - lib/stationery/component.rb
92
94
  - lib/stationery/css.rb
@@ -144,6 +146,7 @@ files:
144
146
  - lib/stationery/fonts/woff.rb
145
147
  - lib/stationery/forms/acro_form.rb
146
148
  - lib/stationery/forms/appearance.rb
149
+ - lib/stationery/forms/auto_size.rb
147
150
  - lib/stationery/forms/field.rb
148
151
  - lib/stationery/forms/metrics.rb
149
152
  - lib/stationery/forms/typeface.rb
@@ -354,6 +357,22 @@ files:
354
357
  - lib/stationery/text/wrapper/one_word.rb
355
358
  - lib/stationery/text/wrapper/remainder.rb
356
359
  - lib/stationery/units.rb
360
+ - lib/stationery/verify.rb
361
+ - lib/stationery/verify/allowlist.rb
362
+ - lib/stationery/verify/command.rb
363
+ - lib/stationery/verify/comparison.rb
364
+ - lib/stationery/verify/engine.rb
365
+ - lib/stationery/verify/engines/mupdf.rb
366
+ - lib/stationery/verify/engines/pdfium.rb
367
+ - lib/stationery/verify/engines/pdfjs.rb
368
+ - lib/stationery/verify/engines/pdfkit.rb
369
+ - lib/stationery/verify/engines/poppler.rb
370
+ - lib/stationery/verify/engines/qpdf.rb
371
+ - lib/stationery/verify/expectation.rb
372
+ - lib/stationery/verify/report.rb
373
+ - lib/stationery/verify/scripts/pdfium.py
374
+ - lib/stationery/verify/scripts/pdfjs.mjs
375
+ - lib/stationery/verify/scripts/pdfkit.swift
357
376
  - lib/stationery/version.rb
358
377
  - lib/stationery/warnings.rb
359
378
  - lib/stationery/zpl.rb