stationery 0.12.1 → 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 +3 -0
- data/README.md +104 -16
- data/examples/price_card.rb +85 -0
- data/lib/stationery/cli/layout_text.rb +14 -2
- data/lib/stationery/cli/verify.rb +98 -0
- data/lib/stationery/cli.rb +1 -0
- data/lib/stationery/elements/forms.rb +4 -2
- data/lib/stationery/forms/appearance.rb +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/conformance.rb +14 -1
- data/lib/stationery/skill/recipes/form.md.erb +9 -6
- data/lib/stationery/skill/recipes/invoice.md.erb +13 -9
- data/lib/stationery/skill/recipes/label.md.erb +4 -2
- data/lib/stationery/skill/recipes/report.md.erb +9 -4
- data/lib/stationery/testing/inspector.rb +8 -5
- data/lib/stationery/testing/layout.rb +18 -1
- data/lib/stationery/verify/allowlist.rb +29 -0
- data/lib/stationery/verify/command.rb +80 -0
- data/lib/stationery/verify/comparison.rb +191 -0
- data/lib/stationery/verify/engine.rb +63 -0
- data/lib/stationery/verify/engines/mupdf.rb +44 -0
- data/lib/stationery/verify/engines/pdfium.rb +35 -0
- data/lib/stationery/verify/engines/pdfjs.rb +41 -0
- data/lib/stationery/verify/engines/pdfkit.rb +20 -0
- data/lib/stationery/verify/engines/poppler.rb +83 -0
- data/lib/stationery/verify/engines/qpdf.rb +46 -0
- data/lib/stationery/verify/expectation.rb +44 -0
- data/lib/stationery/verify/report.rb +49 -0
- data/lib/stationery/verify/scripts/pdfium.py +99 -0
- data/lib/stationery/verify/scripts/pdfjs.mjs +108 -0
- data/lib/stationery/verify/scripts/pdfkit.swift +80 -0
- data/lib/stationery/verify.rb +89 -0
- data/lib/stationery/version.rb +1 -1
- data/lib/stationery.rb +1 -0
- metadata +20 -1
|
@@ -0,0 +1,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
|
@@ -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.
|
|
4
|
+
version: 0.12.2
|
|
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
|