| 1 | """The Emergency Kit oracle: read the PDF back the way a person would. |
| 2 | |
| 3 | Two questions, and the second is the one that matters: |
| 4 | |
| 5 | 1. Does the text extract? A document whose code cannot be selected or |
| 6 | searched is one somebody has to retype by hand from a photograph. |
| 7 | 2. **Does the QR inside the rendered page actually scan?** Not the matrix |
| 8 | the encoder produced — the page as a reader draws it. Between those two |
| 9 | sit the coordinate flip, the module size, the quiet zone and whether the |
| 10 | squares land on the same grid. Every one of those can be wrong in a |
| 11 | document whose matrix is perfect. |
| 12 | |
| 13 | PyMuPDF renders, ZXing decodes, pypdf extracts. None of them is ours. |
| 14 | |
| 15 | Reads {pdf_base64, expect_code, expect_text[]} on stdin, writes a verdict. |
| 16 | |
| 17 | Needs: pip install --user pymupdf zxing-cpp pypdf pillow numpy |
| 18 | """ |
| 19 | |
| 20 | import base64 |
| 21 | import io |
| 22 | import json |
| 23 | import sys |
| 24 | |
| 25 | import numpy as np |
| 26 | import pymupdf |
| 27 | import zxingcpp |
| 28 | from PIL import Image |
| 29 | from pypdf import PdfReader |
| 30 | |
| 31 | |
| 32 | def check(case): |
| 33 | raw = base64.b64decode(case["pdf_base64"]) |
| 34 | out = {"name": case.get("name", "")} |
| 35 | |
| 36 | # 1. Structure and text, through a second reader. |
| 37 | reader = PdfReader(io.BytesIO(raw)) |
| 38 | out["pages"] = len(reader.pages) |
| 39 | text = "\n".join(page.extract_text() or "" for page in reader.pages) |
| 40 | out["missing_text"] = [s for s in case.get("expect_text", []) if s not in text] |
| 41 | out["code_in_text"] = case["expect_code"] in text.replace("\n", "") |
| 42 | |
| 43 | # 2. Render at print resolution and scan it, exactly as a phone would. |
| 44 | doc = pymupdf.open(stream=raw, filetype="pdf") |
| 45 | found = [] |
| 46 | for page in doc: |
| 47 | pix = page.get_pixmap(dpi=case.get("dpi", 200)) |
| 48 | img = Image.frombytes("RGB", (pix.width, pix.height), pix.samples).convert("L") |
| 49 | for r in zxingcpp.read_barcodes(np.array(img)): |
| 50 | found.append(r.text) |
| 51 | out["scanned"] = found |
| 52 | out["qr_scans"] = case["expect_code"] in found |
| 53 | |
| 54 | # Exactly one QR per document: a second one would mean the two secrets had |
| 55 | # ended up on one sheet, which is the whole thing these documents are two |
| 56 | # of in order to prevent. |
| 57 | out["one_qr"] = len(found) == 1 |
| 58 | |
| 59 | out["ok"] = bool( |
| 60 | out["qr_scans"] and out["one_qr"] and out["code_in_text"] and not out["missing_text"] |
| 61 | ) |
| 62 | return out |
| 63 | |
| 64 | |
| 65 | def main(): |
| 66 | cases = json.load(sys.stdin) |
| 67 | print(json.dumps([check(c) for c in cases])) |
| 68 | |
| 69 | |
| 70 | if __name__ == "__main__": |
| 71 | main() |
| 72 | |