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